线上问题复盘:分布式锁 + @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 时逻辑上看起来都没毛病啊但是并发一上来数据库的库存还是出现了负数。三问题分析-锁释放与事务提交的时差为了更直观地理解这个问题下面通过时序图对比问题版本和修复版本的关键执行顺序从上图可以清晰地看到问题版本线程A在第5步就释放了锁但事务提交在第9步。线程B在第6-8步拿到锁并操作数据库时看到的是未提交的老数据导致脏写。修复版本线程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并发打进来线程B看到 Redis 里没有锁顺利拿到锁。线程B去查数据库。因为线程A的事务还没 commit线程B查到的还是老数据线程B拿着老数据做扣减。线程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 的执行顺序盘明白。希望这次的血泪教训能帮大家少掉几根头发。大家在线上还踩过什么离谱的并发问题欢迎评论区交流

相关新闻

LaserGRBL:当激光雕刻遇到开源智慧,你的创意如何被完美实现?

LaserGRBL:当激光雕刻遇到开源智慧,你的创意如何被完美实现?

LaserGRBL:当激光雕刻遇到开源智慧,你的创意如何被完美实现? 【免费下载链接】LaserGRBL Laser optimized GUI for GRBL 项目地址: https://gitcode.com/gh_mirrors/la/LaserGRBL 你是否曾经遇到过这样的困扰?手里有一台激…

2026/10/14 16:48:35 阅读更多 →
基于 OpenLayers 实现态势地图手动标绘航线、作战区域及点位悬浮弹窗

基于 OpenLayers 实现态势地图手动标绘航线、作战区域及点位悬浮弹窗

需求:基于 OpenLayers 技术框架实现地图交互效果,支持操作人员在地图上手动绘制飞机运动轨迹、舰船航行轨迹以及作战区域范围;当鼠标光标移动悬浮至飞机、舰船点位之上时,页面弹出悬浮信息面板,展示当前对应装备的详细…

2026/10/15 13:01:51 阅读更多 →
C++进阶(11):C++ IO 流

C++进阶(11):C++ IO 流

💬 :如果你在阅读过程中有任何疑问或想要进一步探讨的内容,欢迎在评论区畅所欲言!我们一起学习、共同成长~!👍 :如果你觉得这篇文章还不错,不妨顺手点个赞、加入收藏,并分…

2026/10/14 1:29:01 阅读更多 →

最新新闻

Qt跨数据库获取SQL表字段元数据的工程实践

Qt跨数据库获取SQL表字段元数据的工程实践

1. 项目概述:为什么“获取SQL数据表所有字段”是Qt开发中绕不开的硬需求在用Qt做数据库应用开发时,我几乎每天都会遇到这个场景:界面刚打开,需要动态加载某张表的全部字段名来构建查询条件面板;或者用户点开一个数据编…

2026/10/15 13:01:26 阅读更多 →
页面没有报截断,短屏上确认按钮却消失了:HarmonyOS 7 怎么验关键任务始终可达

页面没有报截断,短屏上确认按钮却消失了:HarmonyOS 7 怎么验关键任务始终可达

页面没有报截断,短屏上确认按钮却消失了:HarmonyOS 7 怎么验关键任务始终可达 DevEco Studio 26.0 场景化体检把“截断”纳入多设备检测,官方多设备指南也明确提醒短屏高度受限时要保证关键区可见并支持滚动。但最难发现的不是文字少一行&am…

2026/10/15 13:01:26 阅读更多 →
AI 卖课博主年入 120 万?程序员用 Cursor 算完这笔账,我沉默了

AI 卖课博主年入 120 万?程序员用 Cursor 算完这笔账,我沉默了

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 13:01:26 阅读更多 →
RetroArch BIOS 资源包:一键补齐多平台模拟器 BIOS 文件

RetroArch BIOS 资源包:一键补齐多平台模拟器 BIOS 文件

简介:这份资源面向复古游戏模拟器玩家与 Retropie、Recalbox、Lakka、Retrobat、EmuVR 等前端使用者,集中提供 libretro 体系所需的系统固件与 BIOS 文件,解决模拟器因缺少底层固件而无法启动或运行异常的问题。压缩包共 262 个文件&#xff…

2026/10/15 13:01:26 阅读更多 →
深入理解context-mode:给AI划边界,让编程更稳定、更省token

深入理解context-mode:给AI划边界,让编程更稳定、更省token

其实最开始我对context-mode这词挺不以为然,觉得不就是“多给AI塞点资料”吗?直到在项目里被坑了好几次,才意识到这观念错得离谱。有一阵子我习惯把项目里相关的文件全部丢给AI代码助手,结果它给的修改建议经常自相矛盾&#xff0…

2026/10/15 13:01:26 阅读更多 →
人脸特征提取三路线:HoG、Dlib与CNN原理及选型指南

人脸特征提取三路线:HoG、Dlib与CNN原理及选型指南

人脸识别这几年从实验室走向了门禁、考勤、相册聚类、甚至手机解锁,几乎成了计算机视觉里最"接地气"的方向之一。但真到动手做项目的时候,很多人会卡在同一个地方:特征到底怎么提?我见过不少朋友一上来就堆深度学习模型…

2026/10/15 13:00:25 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 9:49:14 阅读更多 →