Postgres缓解更新锁冲突时如何保持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:
- 应用层先调用
INCR client:100:nonce获取唯一连续的nonce值; - 执行数据库事务,更新随机bin的余额并插入交易记录;
- 若数据库事务失败,需调用
DECR client:100:nonce回滚nonce值(需处理幂等性问题)。
该方案适合跨服务的分布式场景,但引入了Redis依赖,需要额外维护分布式事务的一致性。
总结
最优方案为方案1,既保留了拆分bin的并发性能优势,又保证了nonce的连续唯一性,且无需引入额外组件,维护成本低。锁定全部32个bin的方案会完全抵消拆分bin的收益,不建议使用。
内容的提问来源于stack exchange,提问作者1qnew

