基于本文回答

播面 播面

文图音视,全方位拆解八股文
0
评论

高并发场景下,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)。这会导致:
    1. CPU飙升:应用服务器不断循环重试。
    2. 数据库连接耗尽:大量重试请求压垮数据库,导致系统雪崩。
    3. 响应时间极慢:部分请求可能重试几十次才成功或最终超时。

二、 最优 SQL 层解决方案:基于悲观锁思想的“原子更新”

针对这种高并发转账,不需要用最传统的先 SELECT ... FOR UPDATEUPDATE 的重度悲观锁,而是应该使用 数据库自身的原子更新操作(利用 InnoDB 的行级写锁)

核心实现:单条 SQL CAS + 余额校验

把计算逻辑下放到数据库,直接在 UPDATE 语句中进行增减,并利用 WHERE 条件防止透支。

A用户扣款(带透支校验):

sql
UPDATE account 
SET balance = balance - 100, update_time = NOW() 
WHERE id = 'A' AND balance >= 100;

如果影响行数 (affected_rows) 为 0,说明余额不足,直接返回失败。

B用户加款:

sql
UPDATE account 
SET balance = balance + 100, update_time = NOW() 
WHERE id = 'B';

为什么这种方式最好?

  1. 绝对防止丢失更新UPDATE 语句在 InnoDB 引擎下执行时,会自动给该行数据加上排他锁(X锁),其他并发的 UPDATE 必须排队等待,保证了操作的串行化。
  2. 性能最高:避免了 SELECT FOR UPDATE 带来的网络交互往返时间(RTT),锁持有的时间最短。
  3. 无重试风暴:不需要像乐观锁那样在应用层自旋重试。

三、 传统悲观锁(SELECT FOR UPDATE)什么时候用?

如果你的转账逻辑非常复杂,不仅要扣余额,还要根据用户的等级、信用分等多个表的联合状态来决定是否能转账,必须在内存中进行复杂计算,那么只能使用标准悲观锁。

sql
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 较大的账户

java
// 伪代码:统一加锁顺序
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_0balance_9)。

  • 给 B 转账时,随机挑选 0-9 中的一条记录进行 UPDATE balance = balance + X
  • 查询 B 的总余额时,执行 SUM(balance)
  • 这样将单行的锁竞争分散到了 10 行上,理论上 TPS 可以提升近 10 倍。

总结

对于“高并发且频繁转账”导致的丢失更新问题:

  1. 不要用乐观锁(冲突高,重试多,拖垮系统)。
  2. 首选直接在 SQL 中进行增减的“原子更新”(利用DB原生行锁,性能好,无死锁,防透支)。
  3. 如果有复杂逻辑必须查出来算,使用 SELECT FOR UPDATE(悲观锁),但务必排序后加锁以防死锁。
  4. 如果并发达到数千 TPS 以上,抛弃纯 DB 方案,引入 MQ 异步化Redis 合并请求
00:00
00:00