事务加分布式锁也挡不住重复提交?注意事务边界控制
在一次对接流程引擎的项目中,我们线上遇到了一个非常“诡异”的问题:
用户点击“审批通过”后,后台逻辑居然被重复提交多次且都成功,最终导致业务表写入了两条重复数据。
明明代码里早就加了 分布式锁和事务控制,怎么还会出问题?这次线上事故,揭示了一个微服务开发中常被忽略的陷阱:锁与事务边界不一致,导致幂等失效。
背景场景复盘
接口逻辑如下:
- 用户点击“审批通过”;
- 后端执行
submitTask()方法,处理任务状态变更 + 业务数据落库; submitTask()被@Lock4j注解加锁,内部也加了@Transactional(Propagation.REQUIRED);- 一切看似天衣无缝,按理说不能重复提交。
但由于审批动作存在一定的耗时操作(如外部接口调用、复杂业务逻辑),用户在等待响应过程中不断点击“通过”按钮,后续请求虽然被加锁排队,但却都返回成功,数据库里也确实出现了重复写入。
🔍 问题根因:锁释放早于事务提交
我们的问题出在一个非常关键的时机差异上:
分布式锁是在
submitTask()方法级别生效,而事务却是外层整个接口包裹的。
也就是说:
- 第一次请求进入
submitTask(),成功处理并释放了锁; - 但由于外层事务未提交,数据库中的“任务记录”仍可见;
- 后续请求在锁释放后进入,依旧能查到“未处理任务”,进而重复执行;
- 更致命的是,由于代码未判断影响行数,第二次执行虽然数据库操作无效,仍被当作成功处理返回。
复现时序图:锁释放早于事务提交(导致并发穿透)
sequenceDiagram
participant 用户
participant 用户
participant 应用服务
participant 数据库
用户->>应用服务: 点击“审批通过”
activate 应用服务
应用服务->>应用服务: 获取分布式锁(成功)
应用服务->>数据库: 查询任务
应用服务->>数据库: 修改任务状态
应用服务-->>应用服务: submitTask 执行结束,释放锁
note right of 应用服务: 外层事务仍未提交
用户->>应用服务: 再次点击“审批通过”
activate 应用服务
应用服务->>应用服务: 获取分布式锁(成功)
应用服务->>数据库: 查询任务(仍查得到)
应用服务->>数据库: update 无效(影响行数为0)
应用服务-->>用户: 返回“成功”
应用服务->>数据库: 提交事务1
应用服务->>数据库: 提交事务2
deactivate 应用服务
解决方案:数据库兜底是核心保障
问题本质在于锁和事务边界不一致,关键业务操作缺乏数据库层的幂等控制。以下是几种解决方式的对比与建议:
方案一:数据库兜底防重(推荐)
核心做法:
- 使用
SELECT ... FOR UPDATE加悲观锁,防止并发读取未提交数据; UPDATE/DELETE时加原始状态条件,并判断影响行数,如影响行数为 0,说明已处理,直接返回业务提示。
优势: 逻辑清晰、性能可控,适配大部分业务场景。
方案二:放大锁范围(不推荐)
将分布式锁加到外层接口,确保整个事务期间不被并发进入。
问题: 锁持续时间变长,长耗时操作易拖慢系统,影响并发性能。
方案三:优化事务粒度(治标不治本)
将耗时逻辑移出事务,缩短事务执行时间,减少并发窗口。
问题: 只能降低问题概率,不能彻底解决,仍需数据库层兜底配合。
简单对比
| 方案 | 能防重? | 对性能影响 | 推荐场景 |
|---|---|---|---|
| 数据库幂等控制 | ✅ 强 | 小 | 通用、首选 |
| 分布式锁扩大范围 | ✅ | 大 | 非高并发场景 |
| 缩事务范围 | ❌ | 小 | 降低概率,需配合防重 |
总结
这个案例暴露了一个常见误区:
事务+锁并不是万能组合。 如果两者边界不一致,就有可能在锁释放后,数据未提交前被其他线程读到,造成重复提交。
建议原则:
| 建议 | 说明 |
|---|---|
| 幂等靠数据库保障 | 判断状态 + 判断影响行数,落地最稳妥 |
| 不依赖锁控制幂等 | 锁主要用于控制并发,不等于幂等控制 |
| 适当划分事务范围 | 耗时逻辑应移出事务,防止锁释放过早 |
| 避免大事务嵌套使用锁 | 容易导致性能瓶颈及逻辑边界混乱 |
这次线上故障是一个典型的“伪幂等”案例,看似使用了分布式锁和事务控制,实则没有真正保障数据幂等执行。希望这次复盘能对你在微服务架构中处理事务、并发和幂等控制时有所启发。
你也遇到过类似问题吗?欢迎留言交流。