跳至主要內容

程序员小富大约 8 分钟

大家好,我是小富~

《十万个why》系列持续更新中

不知道大家有没有做过支付系统,接触过热点账户问题。热点账户就是那些并发特别高的账户,比如双十一的天猫官方营销账户、微信红包的资金池账户、或者美团外卖某个头部商家的结算账户。这种账户每秒可能有几千甚至上万笔交易同时打进来。

业内处理热点账户的做法:入账加钱的时候可以先 INSERT 一条流水,后面异步汇总;扣款减钱的时候,必须同步锁行扣减,不能异步。

正常的记账逻辑

一般的账户系统,不管是加钱还是扣钱,SQL 都长这样:

-- 扣款 100 元
UPDATE account 
SET balance = balance - 10000
WHERE account_id = 'ACC_001' AND balance >= 10000;

-- 入账 100 元
UPDATE account 
SET balance = balance + 10000
WHERE account_id = 'ACC_001';

这两条 SQL 都是对 account 表的同一行做 UPDATE,在 MySQL里 UPDATE 会加排他锁(X Lock),同一时刻只有一个事务能操作这一行,其他事务全部排队等锁。

并发低的时候没问题,但如果是热点账户,每秒几千个事务同时要更新这一行余额,所有请求串行排队等锁,TPS 可能就只剩几百。要命的是锁等待一旦超时,大量事务回滚,数据库连接池被打满,整个系统可能直接雪崩。

这就是热点账户问题的本质:高并发下,同一行数据的排他锁竞争。

入账为什么可以 INSERT?

支付宝在处理双十一的热点商户入账时,用的就是汇总记账这类方案的思路。简单说就是不直接 UPDATE 余额,而是先 INSERT 一条入账流水,后面再异步把流水汇总到余额上。

-- 不再直接 UPDATE 余额,而是插入一条入账流水
INSERT INTO account_journal (account_id, amount, direction, create_time)
VALUES ('ACC_001', 10000, 'CREDIT', NOW());

INSERT 操作和 UPDATE 有一个关键区别,INSERT 插入的是一条全新的行,不会跟其他事务竞争同一行已有的锁。

多个事务同时 INSERT,各插各的,互不干扰,这是 INSERT 能扛住高并发的根本原因,它压根不需要跟别人抢资源。

值得注意的是另一个问题:如果这张流水表用的是自增主键做聚簇索引,超高吞吐下所有 INSERT 都在往同一个最后页里写,会出现页面级别的 latch 争抢,也叫热点页问题,这个和行锁不是一回事,但同样会限制极限吞吐,规模足够大的话需要考虑用更离散的主键,比如带哈希前缀的ID 来打散写入。

然后后台起一个定时任务,比如每秒或者每分钟跑一次,把这段时间内的流水汇总一下:

-- 用 batch_id 圈定这一批要处理的流水,避免和其他并发任务/重试产生重复统计或漏统计
UPDATE account_journal 
SET batch_id = 'BATCH_20260101_001'
WHERE account_id = 'ACC_001' AND direction = 'CREDIT' AND status = 'PENDING'
LIMIT 1000;

SELECT SUM(amount) AS total_credit
FROM account_journal
WHERE account_id = 'ACC_001' AND batch_id = 'BATCH_20260101_001';

-- 一次性更新到余额
UPDATE account 
SET balance = balance + 15800
WHERE account_id = 'ACC_001';

-- 标记这一批流水已处理
UPDATE account_journal 
SET status = 'DONE'
WHERE account_id = 'ACC_001' AND batch_id = 'BATCH_20260101_001';

直接 SELECT SUM 再 UPDATE 状态这种写法,中间有个时间窗口,如果汇总任务本身出现并发(重复调度、失败重试等),会导致同一批流水被重复汇总,或者新流入的流水被漏掉。所以实际生产里一定要先用类似 batch_id 这样的标记把这一批流水圈住再统计,或者用 SELECT ... FOR UPDATE 锁住这批行,并且保证汇总任务本身是单一消费者在跑,不能允许多实例并发处理同一账户的流水。

假设 1 秒内有 3000 笔入账请求,原来要对 account 表同一行做 3000 次 UPDATE,现在变成了 3000 次 INSERT(高并发无锁竞争)+ 1 次 UPDATE(汇总写入)。锁冲突从 3000 次降到 1 次,性能差了几个数量级。

这么做有一个代价,账户余额不是实时的。 在汇总任务跑完之前,查 account 表的 balance 字段看到的余额是滞后的。不过对于入账场景,这通常可以接受——钱到了就行,晚几秒显示到账不影响业务。

扣款为什么不能这么玩?

假设我们也用同样的方案处理扣款:先 INSERT 一条扣款流水,后面异步汇总扣减。

想象一下这个场景:账户余额 1000 元

T1: 用户 A 发起扣款 600 元 → INSERT 扣款流水 600 T2: 用户 B 发起扣款 500 元 → INSERT 扣款流水 500 T3: 用户 C 发起扣款 400 元 → INSERT 扣款流水 400

汇总时: 600 + 500 + 400 = 1500 元,但余额只有 1000 元

三笔扣款都成功了,但汇总的时候发现余额根本不够。这时候你怎么办?600 和 500 都已经告诉用户扣款成功了,现在要退回一笔?给谁退?退多少?

这就是扣款不能异步的根本原因:扣款有一个硬约束,余额不能为负。

必须在扣款的那一瞬间,原子性地完成查余额 → 判断够不够 → 扣减这三步。一旦拆开,中间就会被并发请求插进来,导致超扣。

用一个时序图看得更清楚:

而同步扣减的时序:

同步扣减虽然慢,要排队等锁,但不会超扣,那条 WHERE balance >= amount 就是最后的安全网。

入账和扣款的本质区别

说到底加法没有下限约束,减法有。

入账是往上加,余额从 1000 变成 2000,从 2000 变成 3000,不管怎么加都是合法的。所以你可以先把流水记下来,回头再算总数,反正不会出错。

扣款是往下减,余额从 1000 变成 400,从 400 变成 0,到 0 就不能再减了。每一笔扣减都需要知道当前的准确余额,否则可能减穿。

异步汇总意味着扣款的那个瞬间不知道真实余额是多少,这在金融场景下是绝对不允许的。

那热点账户扣款怎么优化?

既然扣款不能用 INSERT 异步化,那并发高了怎么办?

方案一:子账户拆分

把一个热点账户拆成 N 个子账户,比如拆成 10 个。扣款请求进来的时候,用哈希算法把请求分散到不同的子账户上:

// 将扣款请求路由到子账户
int subAccountIndex = Math.abs(userId.hashCode()) % 10;
String subAccountId = "ACC_001_SUB_" + subAccountIndex;

// 扣减子账户余额
int rows = jdbcTemplate.update(
    "UPDATE account_sub SET balance = balance - ? WHERE sub_account_id = ? AND balance >= ?",
    amount, subAccountId, amount
);

原来 3000 个事务抢一把锁,现在分散到 10 个子账户上,每把锁只有 300 个事务在抢,锁冲突降了一个量级。蚂蚁金服在双十一就是用类似这样的思路处理热点商户的。

但这个方案有几个坑:一是某个子账户余额不够,不代表总余额不够,可能子账户 3 余额为 0,但子账户 7 还有一大笔钱,这就需要额外做一个资金归集逻辑,定期把各子账户的余额重新分配均衡。

另一个如果业务上需要实时展示账户总余额,这时候得对多个子账户做 SUM,本身也存在一个跨行读取的一致性窗口问题,不是拆完就没问题了。

方案二:Redis + Lua 预扣减

把账户余额加载到 Redis 里,用 Lua 脚本做原子性的余额校验和扣减:

-- Redis Lua 脚本:原子扣减
local balance = tonumber(redis.call('GET', KEYS[1]))
local amount = tonumber(ARGV[1])

if balance >= amount then
    redis.call('DECRBY', KEYS[1], amount)
    return 1 -- 扣减成功
else
    return 0 -- 余额不足
end

Redis 单线程执行 Lua 脚本,天然保证原子性,不存在锁竞争问题,扣减操作的吞吐能力比走数据库行锁高出一到两个数量级(具体数值受网络、实例规格影响,不用死记某个绝对QPS)。

扣减成功后,再通过消息队列异步把扣款记录落到数据库里持久化,这个方案在红包、积分、秒杀场景用得很多。

但代价也很明显,Redis 和数据库之间的一致性要靠对账来兜底。每天凌晨跑一次对账任务,拿 Redis 余额和数据库余额比对,有差异就报警人工介入。

方案三:OceanBase 提前解行锁 ELR

蚂蚁金服用的 OceanBase 数据库有一个优化叫 ELR,事务提交流程里,不用等到日志在多数派副本间同步确认完成才释放行锁,而是在满足特定条件后提前释放,让下一个事务提前开始执行,缩短锁的持有时间,从而提升单行更新的并发能力。

但这个优化不是没有代价,用之前要清楚两个前提,一是目前 ELR 只支持单机事务,分布式事务用不了;二是提前释放锁的事务如果最终发生回滚,所有在这期间读到过它的后续事务都要跟着一起回滚(级联回滚),所以这不是一个无成本的开关,是拿回滚概率低换并发能力高的权衡。

说在最后

热点账户入账能 INSERT 异步汇总、扣款必须同步扣减,因为入账没有约束条件,扣款有余额下限约束。

金融系统涉及到钱,凡是涉及够不够判断的操作,必须同步完成。能先记后算的操作,才有异步优化的空间。

上次编辑于: