高并发场景下,A用户频繁向B用户转账,如何避免更新余额时出现“丢失更新”问题?(乐观锁与悲观锁的选择)
在高并发场景下,A用户频繁向B用户转账(属于高冲突/热点数据场景),为了避免“丢失更新”(Lost Update)问题,强烈建议使用悲观锁的变种(原子更新)或结合队列异步处理,而不建议使用纯粹的乐观锁。
下面为您深度解析两者的选择逻辑、具体实现方案以及针对该场景的终极架构优化。
一、 为什么在这种场景下“乐观锁”不合适?
乐观锁(Optimistic Lock)通常通过 version 字段或 CAS(Compare And Swap)机制实现。
- SQL示例:sql
-- 先查出版本号和余额 SELECT balance, version FROM account WHERE id = 'A'; -- 内存中计算新余额,并在更新时带上版本号 UPDATE account SET balance = 80, version = version + 1 WHERE id = 'A' AND version = 1; - 致命缺点(针对当前场景):
乐观锁的核心假设是“冲突很少发生”。但在“A频繁向B转账”的高并发场景下,A和B的账户记录是超级热点数据。
如果使用乐观锁,大量的并发请求在执行UPDATE时会因为version已经被其他事务修改而失败。应用层必须进行重试(Retry)。这会导致:- CPU飙升:应用服务器不断循环重试。
- 数据库连接耗尽:大量重试请求压垮数据库,导致系统雪崩。
- 响应时间极慢:部分请求可能重试几十次才成功或最终超时。
二、 最优 SQL 层解决方案:基于悲观锁思想的“原子更新”
针对这种高并发转账,不需要用最传统的先 SELECT ... FOR UPDATE 再 UPDATE 的重度悲观锁,而是应该使用 数据库自身的原子更新操作(利用 InnoDB 的行级写锁)。
核心实现:单条 SQL CAS + 余额校验
把计算逻辑下放到数据库,直接在 UPDATE 语句中进行增减,并利用 WHERE 条件防止透支。
A用户扣款(带透支校验):
UPDATE account
SET balance = balance - 100, update_time = NOW()
WHERE id = 'A' AND balance >= 100;
如果影响行数 (affected_rows) 为 0,说明余额不足,直接返回失败。
B用户加款:
UPDATE account
SET balance = balance + 100, update_time = NOW()
WHERE id = 'B';
为什么这种方式最好?
- 绝对防止丢失更新:
UPDATE语句在 InnoDB 引擎下执行时,会自动给该行数据加上排他锁(X锁),其他并发的UPDATE必须排队等待,保证了操作的串行化。 - 性能最高:避免了
SELECT FOR UPDATE带来的网络交互往返时间(RTT),锁持有的时间最短。 - 无重试风暴:不需要像乐观锁那样在应用层自旋重试。
三、 传统悲观锁(SELECT FOR UPDATE)什么时候用?
如果你的转账逻辑非常复杂,不仅要扣余额,还要根据用户的等级、信用分等多个表的联合状态来决定是否能转账,必须在内存中进行复杂计算,那么只能使用标准悲观锁。
BEGIN;
-- 1. 锁住A和B的账户
SELECT balance FROM account WHERE id = 'A' FOR UPDATE;
SELECT balance FROM account WHERE id = 'B' FOR UPDATE;
-- 2. 应用层进行复杂业务校验和计算...
-- 3. 执行更新
UPDATE account SET balance = xxx WHERE id = 'A';
UPDATE account SET balance = yyy WHERE id = 'B';
COMMIT;
⚠️ 死锁警告(非常重要):
如果有其他并发业务是“B向A转账”,一个事务先锁A再锁B,另一个先锁B再锁A,必然发生死锁。
解决办法: 必须全局统一加锁顺序。例如,始终先锁 ID 较小的账户,再锁 ID 较大的账户。
// 伪代码:统一加锁顺序
if (A.id < B.id) {
lock(A); lock(B);
} else {
lock(B); lock(A);
}
四、 突破单行 TPS 瓶颈的架构级方案
即使使用了原子更新(悲观锁),MySQL 针对单行记录的 UPDATE TPS 上限通常在 1000 - 3000 左右(受限于磁盘IO和行锁竞争)。如果 A 频繁给 B 转账的并发量达到万级/秒(例如直播打赏头部主播),数据库行锁依然会被打爆。
此时必须引入架构层面的优化:
方案1:消息队列(MQ)削峰填谷 + 异步串行化(推荐)
对于频繁向B转账,A的扣款可以实时同步进行(因为A自己发起的,频率受限),但给B加钱的操作可以放入 Kafka/RocketMQ 中。
- 把并发的写请求,通过 MQ 变成单线程串行的处理。
- B 用户的余额更新虽然有延迟(可能延迟几百毫秒),但彻底消除了数据库行锁竞争。
方案2:内存合并(Redis 缓存扣减 + 批量刷盘)
- 在 Redis 中使用 Lua 脚本进行原子扣减/增加操作(Redis 内存操作单线程,QPS极高且无并发问题)。
- 应用层起一个定时任务,每隔 1 秒或 5 秒,把这期间 A 给 B 转账的总增量,通过一条 SQL 批量更新到 MySQL 中。
- 风险:如果 Redis 宕机可能存在数据一致性问题,需要结合 MySQL 事务日志进行严格对账。
方案3:账户分段(热点账户拆分)
专门针对收款方 B(例如大主播)。把 B 的账户余额拆分成 10 条记录(如 balance_0 到 balance_9)。
- 给 B 转账时,随机挑选
0-9中的一条记录进行UPDATE balance = balance + X。 - 查询 B 的总余额时,执行
SUM(balance)。 - 这样将单行的锁竞争分散到了 10 行上,理论上 TPS 可以提升近 10 倍。
总结
对于“高并发且频繁转账”导致的丢失更新问题:
- 不要用乐观锁(冲突高,重试多,拖垮系统)。
- 首选直接在 SQL 中进行增减的“原子更新”(利用DB原生行锁,性能好,无死锁,防透支)。
- 如果有复杂逻辑必须查出来算,使用
SELECT FOR UPDATE(悲观锁),但务必排序后加锁以防死锁。 - 如果并发达到数千 TPS 以上,抛弃纯 DB 方案,引入 MQ 异步化或 Redis 合并请求。