You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Postgres缓解更新锁冲突时如何保持Nonce的顺序性?

高并发下账户余额更新与连续nonce的解决方案

问题背景

原有业务逻辑中,客户端每次请求会触发如下事务:更新client_balances表对应账户的余额,并插入一条带连续递增nonce的transaction_records交易记录:

BEGIN;

UPDATE client_balances SET nonce = nonce + 1, balance_amount = balance_amount - 100 WHERE client_id = 100 RETURNING *;

INSERT INTO transaction_records (date, client_id, nonce, balance_changed, balance_amount) VALUES (TIMESTAMPTZ '2023-01-01', 100, <nonce>, -100, <balance_amount>) RETURNING *;

COMMIT;

当单客户端并发请求达到200次/秒时,UPDATE语句因行锁冲突导致性能骤降,平均耗时约400ms。为缓解锁冲突,将每个客户端的余额记录拆分为32个bin,随机选择bin进行更新,调整后的事务逻辑如下:

BEGIN;

UPDATE client_balances SET nonce = nonce + 1, balance_amount = balance_amount - 100 WHERE client_id = 100 AND bin = <rand_int_from (0,31)> RETURNING *;

SELECT SUM(nonce) AS nonce_aggr, SUM(balance_amount) AS balance_amount FROM client_balances WHERE client_id = 100 RETURNING *;

INSERT INTO transaction_records (date, client_id, nonce, balance_changed, balance_amount) VALUES (TIMESTAMPTZ '2023-01-01', 100, <nonce_aggr>, -100, <balance_amount_aggr>) RETURNING *;

COMMIT;

该方案解决了锁冲突问题,余额计算正确,但transaction_records中的nonce不再连续(例如出现1,2,2,2,5,6...的情况),仅总数统计正确。现在需要实现每次余额变更对应唯一连续nonce,同时保留拆分bin带来的并发性能优势,因此需要评估是否锁定全部32个bin,或寻找更优方案。

不建议锁定全部32个bin

如果锁定全部32个bin,相当于每个事务都要获取客户端所有余额记录的行锁,这会回到原有单记录锁冲突的问题,并发性能再次骤降,完全失去拆分bin的意义,因此该方案不可取。

更优解决方案

方案1:单独维护客户端全局nonce序列表

创建一张独立的client_nonce_sequence表,每个客户端对应一条记录,存储当前的最大连续nonce:

CREATE TABLE client_nonce_sequence (
    client_id INT PRIMARY KEY,
    current_nonce BIGINT NOT NULL DEFAULT 0
);

调整后的事务逻辑为:

BEGIN;

-- 第一步:获取全局唯一连续nonce(仅更新轻量的序列记录,锁冲突远低于余额表)
UPDATE client_nonce_sequence SET current_nonce = current_nonce + 1 WHERE client_id = 100 RETURNING current_nonce;

-- 第二步:随机更新一个bin的余额,保留并发优势
UPDATE client_balances SET nonce = nonce + 1, balance_amount = balance_amount - 100 WHERE client_id = 100 AND bin = <rand_int_from (0,31)> RETURNING *;

-- 第三步:计算总余额(若交易记录需要存储总余额)
SELECT SUM(balance_amount) AS balance_amount_aggr FROM client_balances WHERE client_id = 100;

-- 第四步:插入交易记录,使用刚获取的连续nonce
INSERT INTO transaction_records (date, client_id, nonce, balance_changed, balance_amount) VALUES (TIMESTAMPTZ '2023-01-01', 100, <current_nonce>, -100, <balance_amount_aggr>) RETURNING *;

COMMIT;

该方案的核心优势是:将连续nonce的生成与余额更新解耦,client_nonce_sequence的更新逻辑仅为整数自增,锁冲突的影响远低于原有余额表;同时保留了拆分bin带来的并发性能提升,完美满足“每次余额变更对应唯一连续nonce”的需求。

方案2:使用数据库专属序列(适合客户端数量较少的场景)

为每个客户端创建一个专属的数据库序列,例如seq_client_100,通过nextval()获取连续nonce:

-- 为客户端100创建序列
CREATE SEQUENCE seq_client_100 START WITH 1 INCREMENT BY 1;

事务逻辑调整为:

BEGIN;

-- 获取连续nonce
SELECT nextval('seq_client_100') AS current_nonce;

-- 随机更新bin余额
UPDATE client_balances SET nonce = nonce + 1, balance_amount = balance_amount - 100 WHERE client_id = 100 AND bin = <rand_int_from (0,31)> RETURNING *;

-- 计算总余额
SELECT SUM(balance_amount) AS balance_amount_aggr FROM client_balances WHERE client_id = 100;

-- 插入交易记录
INSERT INTO transaction_records (date, client_id, nonce, balance_changed, balance_amount) VALUES (TIMESTAMPTZ '2023-01-01', 100, <current_nonce>, -100, <balance_amount_aggr>) RETURNING *;

COMMIT;

注意:数据库序列的生成是独立于事务的,若事务回滚,已获取的序列值不会回滚,会导致nonce出现间隙。如果业务严格要求无间隙连续nonce,该方案不适用;若可接受偶尔间隙,且客户端数量较少(避免大量序列占用资源),则可以选用。

方案3:应用层结合分布式锁生成nonce(分布式系统场景)

在分布式架构下,可使用Redis的INCR命令为每个客户端生成连续nonce:

  1. 应用层先调用INCR client:100:nonce获取唯一连续的nonce值;
  2. 执行数据库事务,更新随机bin的余额并插入交易记录;
  3. 若数据库事务失败,需调用DECR client:100:nonce回滚nonce值(需处理幂等性问题)。

该方案适合跨服务的分布式场景,但引入了Redis依赖,需要额外维护分布式事务的一致性。

总结

最优方案为方案1,既保留了拆分bin的并发性能优势,又保证了nonce的连续唯一性,且无需引入额外组件,维护成本低。锁定全部32个bin的方案会完全抵消拆分bin的收益,不建议使用。

内容的提问来源于stack exchange,提问作者1qnew

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.30 05:37:54