当前位置: 首页 > news >正文

线上问题复盘:分布式锁 + @Transactional 组合失效?

摘要:本文深入剖析了 Spring Boot 项目中分布式锁与 @Transactional 事务组合使用时,因锁提前释放而导致的并发数据不一致问题。通过对比问题版本与修复版本的时序差异,揭示了 Spring AOP 执行顺序的陷阱,并提供了两种有效的解决方案:服务层拆分与编程式事务,帮助开发者避免类似的高并发坑。

一:问题场景

用户并发操作某个资源(比如领取限量优惠券,或者高频修改某个配置)。

这种防并发超卖的场景,老 Java 开发闭着眼睛都能想到一套组合拳:Spring Boot + @Transactional + Redisson 分布式锁。

当时同事咔咔一顿敲,代码写得极其丝滑,本地单线程测了没毛病,经过 CodeReview 后直接提测上线。结果上线第一天,运营跑过来说:“后台数据不对啊,怎么同一个资源被重复扣了两次?”

同事当时赶紧去看日志。不看不知道,一看麻了:虽然加了分布式锁,但在极高并发下,锁竟然“失效”了!

今天给大家复盘一下这个差点让同事背绩效 C 的坑,也提醒大家 CodeReview 时多关注这类时序问题。

二:代码复现

为了直观,我把当时的业务代码简化一下。大体逻辑是这样的:查询数据库中的剩余量 -> 判断是否足够 -> 扣减 -> 保存。

Java 代码示例(问题版本):

@Service public class ResourceServiceImpl implements ResourceService { @Autowired private RedissonClient redissonClient; @Autowired private ResourceMapper resourceMapper; @Override @Transactional(rollbackFor = Exception.class) public void consumeResource(String resourceId) { String lockKey = "lock:resource:" + resourceId; RLock lock = redissonClient.getLock(lockKey); try { // 尝试获取锁,最多等3秒 if (lock.tryLock(3, TimeUnit.SECONDS)) { // 1. 查数据库当前库存 Resource res = resourceMapper.selectById(resourceId); if (res.getStock() > 0) { // 2. 扣减 res.setStock(res.getStock() - 1); resourceMapper.updateById(res); } else { throw new RuntimeException("库存不足"); } } else { throw new RuntimeException("系统繁忙,请重试"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { // 3. 释放锁 if (lock.isLocked() && lock.isHeldByCurrentThread()) { lock.unlock(); } } } }

可以直接看下这代码是不是很眼熟?@Transactional保证事务,try...finally保证锁必定释放。这代码经过 CodeReview 时,逻辑上看起来都没毛病啊!

但是,并发一上来,数据库的库存还是出现了负数。

三:问题分析-锁释放与事务提交的时差

为了更直观地理解这个问题,下面通过时序图对比问题版本和修复版本的关键执行顺序:

从上图可以清晰地看到:

  1. 问题版本:线程A在第5步就释放了锁,但事务提交在第9步。线程B在第6-8步拿到锁并操作数据库时,看到的是未提交的老数据,导致脏写。
  2. 修复版本:线程A在第5步先提交事务,然后在第6步释放锁。线程B必须等待锁释放后才能操作,看到的是已提交的新数据。

排查了大半天,抓了 MySQL 的 binlog 和 Redis 的执行日志对比,终于发现了问题所在:Spring AOP 的执行顺序问题。

大家回忆一下@Transactional的底层原理。Spring 是通过 AOP 动态代理来实现事务的,相当于在你的业务方法外层包了一层:

Spring 代理类的伪代码:

// Spring 代理类的伪代码 public void proxyConsumeResource(String resourceId) { // 1. 开启数据库事务 connection.setAutoCommit(false); try { // 2. 执行你的真实业务逻辑(包含加锁、改数据、释放锁) target.consumeResource(resourceId); // 3. 提交事务 connection.commit(); } catch (Exception e) { connection.rollback(); } }

看出致命问题了吗?!

在我的业务代码里,finally块执行了lock.unlock(),此时分布式锁已经被释放了。但是!此时target.consumeResource()方法才刚刚执行完,Spring 代理类的connection.commit()还没执行!

也就是说,锁已经没了,但数据还没落盘。

这时候,如果有另一个线程(线程B)并发打进来:

  1. 线程B看到 Redis 里没有锁,顺利拿到锁。
  2. 线程B去查数据库。因为线程A的事务还没 commit,线程B查到的还是老数据
  3. 线程B拿着老数据做扣减。
  4. 线程A提交事务,线程B紧接着也提交事务。

完美,脏写产生了,数据被彻底打穿。

四:解决方案

找到原因后,解决起来就非常简单了。核心思想只有一个:必须保证事务提交之后,再释放锁。

方案一:粗暴拆分

直接把加锁的逻辑往上提,放到 controller 层,或者再包一层 service。确保锁的范围大于事务的范围。

Java 代码示例(修复版本):

@Service public class ResourceLockService { @Autowired private RedissonClient redissonClient; @Autowired private ResourceServiceImpl resourceService; // 注入原来的事务Service public void safeConsume(String resourceId) { String lockKey = "lock:resource:" + resourceId; RLock lock = redissonClient.getLock(lockKey); try { if (lock.tryLock(3, TimeUnit.SECONDS)) { // 调用事务方法,由于事务方法是一个独立的 proxy, // 执行完毕返回到这里时,事务已经 commit 啦! resourceService.consumeResource(resourceId); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isLocked() && lock.isHeldByCurrentThread()) { lock.unlock(); } } } }

注:注意不要在同一个类里写这两个方法直接 this 调用,会导致 AOP 失效,老生常谈了。

方案二:编程式事务

如果你不想多写一层类,可以使用TransactionTemplate手动控制事务的边界。

Java 代码示例:

// 在获取锁之后执行: transactionTemplate.execute(status -> { // 查库、扣减、更新 return null; }); // 事务提交完毕,再进入 finally 释放锁

其它问题:Redisson 看门狗失效问题

借着这个机会,再分享一个很多人用 Redisson 容易踩的坑。有些喜欢直接使用vibe coding会在加锁的时候传 leaseTime(锁过期时间):

错误示例:

// 试图加锁,等待3秒,锁定10秒后自动释放 lock.tryLock(3, 10, TimeUnit.SECONDS);

一旦你显式传入了leaseTimeRedisson 的 WatchDog(看门狗)机制就会失效!如果你的业务逻辑执行时间超过了 10 秒(比如发生了 Full GC 或者调了很慢的第三方接口),锁会自动释放,其他线程就会趁虚而入。

正确做法是,如果不知道业务具体执行多久,千万别传leaseTime

正确示例:

// 只传等待时间,不传 leaseTime,看门狗机制生效,会自动帮你续期 lock.tryLock(3, TimeUnit.SECONDS);

总结与建议

平时的业务开发中,@Transactional和各种锁(包括本地锁synchronized和分布式锁)一起用的时候,一定要多留个心眼,画一画它们的作用域边界。

核心要点回顾:

  • 事务与锁的顺序:确保锁的生命周期完全覆盖事务的生命周期,即先加锁,后开启事务;先提交事务,后释放锁
  • 方案选择:优先推荐方案一(粗暴拆分),逻辑清晰,职责分离。方案二(编程式事务)适合简单场景。
  • Redisson 使用:避免随意指定leaseTime,充分利用看门狗自动续期机制。
  • 测试验证:高并发场景务必进行压测,并结合日志和监控观察锁与事务的时序。

有时候真不是底层组件不行,纯粹是我们没把 Spring AOP 的执行顺序盘明白。希望这次的血泪教训,能帮大家少掉几根头发。大家在线上还踩过什么离谱的并发问题?欢迎评论区交流

http://www.jsqmd.com/news/1309206/

相关文章:

  • Windows 11应用美化终极指南:Mica For Everyone完全配置教程
  • Open WebUI完全离线AI平台部署终极指南:告别云端依赖的智能对话解决方案
  • 3分钟搞定Windows远程桌面多用户连接:RDPWrap配置文件全解析
  • 5分钟让你的桌面活起来:BongoCat跨平台互动桌宠完全指南
  • C:if...else 语句
  • 告别手速焦虑:3步搭建Python大麦网自动抢票脚本完整指南
  • 椰林海鲜码头性价比高吗? - 云溪自乐
  • 上海女性刑事律师事务所推荐:女性嫌疑人权益保障与辩护要点 - 品牌深度评测
  • 西安德州酒吧小程序开发实战:从零搭建完整流程指南
  • AAA诚信经营示范单位怎么办理?手机在线申请指南 - 跑政通
  • 基于FT2232HL的双路隔离RS485转换器设计与实现
  • rdclientax.dll问题
  • 3分钟快速上手:Balena Etcher - 最安全的SD卡和USB镜像烧录工具终极指南
  • GetQzonehistory完整指南:3步免费备份你的QQ空间所有历史记录
  • 实盘资格复查日到了:账户、软件与策略条件重新确认
  • 2026漳州别墅开荒保洁多少钱?碧湖片区三套别墅费用施工对比 - 品牌观察室
  • 如何快速部署smokeping_prober?3分钟上手Prometheus网络性能监控
  • 上海未成年人犯罪律师事务所:少年司法保护与辩护策略解析 - 品牌深度评测
  • 重合同守信用认证如何办理?在线申办方法 - 跑政通
  • 椰林海鲜码头食材新鲜吗? - 晴光转树
  • 别把整个项目都塞给 Codex:开发任务真正需要的是这份上下文清单
  • 双通道CAN转以太网网关实战:STM32F407+LwIP协议栈实现工业数据上云
  • 通义千问表格识别精度突破98.6%:基于127万张真实票据训练的专用微调模型,内部测试版限时开放申请
  • 2026 年 7 月新发布:沧县知名的dn200钢拍门制造厂家竞争格局,这种不起眼的铸铁钢构件,为何能成为泵站防洪排水的关键硬装备? - 企业推荐官【认证官方】
  • 椰林海鲜码头食材新鲜吗? - 秋山寄远
  • Token是什么?大模型为什么按Token计算费用
  • 计算机单片机毕设实战-基于 STM32 编码器电机 PID 调速小车控制系统 基于单片机线性插值算法的红外循迹小车设计(017401)
  • TTL转RS485隔离模块设计:从原理到工业应用实战
  • 终极大数据机器学习框架mlf:一站式解决高效模型训练与部署难题
  • TTL转RS485模块选型与工业级电路设计全解析