明明加了分布式锁,高并发下数据还是对不上?聊聊这个被 90% 开发者忽视的坑

1,372 阅读5分钟

在处理资金扣减、库存冲抵等核心业务时,我们最常用的杀手锏就是“分布式锁 + 数据库事务”。逻辑看起来天衣无缝:先上锁保证单线程执行,再开事务保证 ACID。

但现实往往很骨感。最近在一次高并发压测中,我负责的系统出现了诡异的“数据覆盖”现象:账户余额 100,线程 A 扣 10,线程 B 扣 20,最后结果竟然是 80(A 的结果被覆盖了)。

翻遍了代码,Redis 锁确实加了,事务也没失效。直到我深入扒开 Spring AOP 代理机制与锁的释放时机,才发现了那个隐蔽的“时延黑洞”。


一、 现场还原:那段“教科书式”的错误代码

很多资深开发可能都写过类似这样的逻辑。大家可以先一眼扫过去,看看能不能发现猫腻:

 // ❌ 存在隐患的代码逻辑
 @Transactional(rollbackFor = Exception.class)
 public void decreaseAsset(Long id, Long delta) {
     String lockKey = "LOCK:ASSET:" + id;
     
     // 1. 获取分布式锁
     redisLock.lock(lockKey); 
     try {
         // 2. 查询当前余额
         Account account = accountDao.selectById(id);
         
         // 3. 内存计算并更新(Read-Modify-Write)
         long newBalance = account.getBalance() - delta;
         if (newBalance < 0) throw new BusinessException("余额不足");
         
         account.setBalance(newBalance);
         accountDao.updateById(account); // 执行 SET balance = #{newBalance}
         
     } finally {
         // 4. 释放锁
         redisLock.unlock(lockKey);
     }
 }
 // 5. 🚩 这里的坑:事务其实是在方法结束后才提交的

为什么会出问题?

表面上看,锁在 finally 里释放了,很安全。但别忘了,@Transactional 是通过 Spring AOP 增强实现的。

真实的执行序列是这样的:

  1. Proxy: 开启数据库事务。
  2. Target Method: 获取 Redis 锁。
  3. Target Method: 执行业务更新。
  4. Target Method: 释放 Redis 锁
  5. Proxy: 提交数据库事务(COMMIT)

致命的问题就在“第 4 步”和“第 5 步”之间。 锁已经释放了,但事务还没提交!此时如果另一个线程 B 冲进来拿到了锁,它去数据库查到的依然是线程 A 提交前的旧数据(基于数据库隔离级别 RC 或 RR)。


二、 漏洞推演:毫秒级的“灰色地带”

为了看得更清楚,我们把时间线拉开:

时间点线程 A (执行中)线程 B (新请求)数据库状态
T1开启事务,获得锁,读取余额=100等待锁100
T2计算 100-10=90,执行 Update等待锁100 (Buffer Pool 已改但未提交)
T3释放 Redis 锁-100
T4(网络略微抖动,事务尚未 Commit)成功获得锁,查询余额100 (读到旧值!)
T5提交事务计算 100-20=80,执行 Update90 (A 提交的结果)
T6-提交事务80 (A 的修改被彻底覆盖)

这就是典型的更新丢失(Lost Update) 。在分布式环境下,这 10 毫秒的空档足以让上千个请求穿透你的防御。


三、 生产级的防御范式:三道防线

要彻底解决这个问题,不能只靠分布式锁,我们需要构建从应用层到数据库层的“纵深防御”。

防线一:把锁顶到事务外面(解决时序问题)

既然事务提交晚于锁释放,那我们就手动调整顺序。这里不是说声明式事务 @Transactional 一定不能用,而是要确保锁的释放发生在事务提交之后。为了把时序讲清楚,下面先用编程式事务 TransactionTemplate 举例。

核心原则:确保“事务提交”动作在“锁释放”之前完成。

 public void decreaseSafe(Long id, Long delta) {
     String lockKey = "LOCK:ASSET:" + id;
     redisLock.lock(lockKey);
     try {
         // 通过 TransactionTemplate 显式控制事务范围
         transactionTemplate.execute(status -> {
             doBusiness(id, delta); 
             return null;
         }); 
         // 🚩 事务在这里已经 Commit 了,然后再走下面的 finally 释放锁
     } finally {
         redisLock.unlock(lockKey);
     }
 }

如果你仍然想保留 @Transactional,也可以拆成“外层加锁方法 + 内层事务方法”:外层方法只负责加锁和释放锁,内层方法再通过 Spring 代理触发事务。关键不是工具选型,而是锁范围要覆盖事务提交全过程

防线二:将计算下沉至数据库(解决覆盖问题)

永远不要在 Java 代码里计算完余额再 SET balance = #{newValue}。

利用 SQL 的原子性,将“计算最终值”改为“计算变化量”。

 -- 即使两个线程同时进来,数据库行锁也会让它们串行执行,且基于最新值扣减
 UPDATE t_account 
 SET balance = balance - #{delta} 
 WHERE id = #{id}

防线三:CAS 兜底约束(防止资产透支)

分布式锁可能会因为 TTL 过期、Full GC 导致失效。我们需要最后一道防线:把业务约束写进 SQL 的 WHERE 条件里。

 UPDATE t_account 
 SET balance = balance - #{delta}
 WHERE id = #{id}
   AND balance >= #{delta} -- 🔥 核心防线:即便锁全崩了,数据库也能保证不扣出负数

实际落地时,还要根据 affected rows 判断是否扣减成功:如果影响行数为 0,说明余额不足、账户不存在,或者并发条件下扣减失败,业务层必须返回明确失败,而不是默认执行成功。


四、 总结:零信任架构思维

在处理金融级高并发业务时,我总结了一套“零信任”的代码准则:

  1. 别太迷信分布式锁:把它当成一种“流量削峰”和“减轻 DB 压力”的辅助手段,而不是唯一的安全屏障。
  2. 锁范围要覆盖事务范围:如果用 AOP 事务,就想办法让锁的获取和释放包裹在调用链的最外层,确保事务提交完成后再释放锁。
  3. 拥抱原子 SQL:能用一条 SQL 解决的逻辑,绝对不要搬到 Java 内存里来做。

那行 AND balance >= #{delta},往往比你写几百行加锁代码都靠谱。