事务加分布式锁也挡不住重复提交?注意事务边界控制

154 阅读4分钟

事务加分布式锁也挡不住重复提交?注意事务边界控制

在一次对接流程引擎的项目中,我们线上遇到了一个非常“诡异”的问题:

用户点击“审批通过”后,后台逻辑居然被重复提交多次且都成功,最终导致业务表写入了两条重复数据

明明代码里早就加了 分布式锁和事务控制,怎么还会出问题?这次线上事故,揭示了一个微服务开发中常被忽略的陷阱:锁与事务边界不一致,导致幂等失效

背景场景复盘

接口逻辑如下:

  • 用户点击“审批通过”;
  • 后端执行 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,说明已处理,直接返回业务提示。

优势: 逻辑清晰、性能可控,适配大部分业务场景。

方案二:放大锁范围(不推荐)

将分布式锁加到外层接口,确保整个事务期间不被并发进入。

问题: 锁持续时间变长,长耗时操作易拖慢系统,影响并发性能。

方案三:优化事务粒度(治标不治本)

将耗时逻辑移出事务,缩短事务执行时间,减少并发窗口。

问题: 只能降低问题概率,不能彻底解决,仍需数据库层兜底配合。

简单对比

方案能防重?对性能影响推荐场景
数据库幂等控制✅ 强通用、首选
分布式锁扩大范围非高并发场景
缩事务范围降低概率,需配合防重

总结

这个案例暴露了一个常见误区:

事务+锁并不是万能组合。 如果两者边界不一致,就有可能在锁释放后,数据未提交前被其他线程读到,造成重复提交。

建议原则:

建议说明
幂等靠数据库保障判断状态 + 判断影响行数,落地最稳妥
不依赖锁控制幂等锁主要用于控制并发,不等于幂等控制
适当划分事务范围耗时逻辑应移出事务,防止锁释放过早
避免大事务嵌套使用锁容易导致性能瓶颈及逻辑边界混乱

这次线上故障是一个典型的“伪幂等”案例,看似使用了分布式锁和事务控制,实则没有真正保障数据幂等执行。希望这次复盘能对你在微服务架构中处理事务、并发和幂等控制时有所启发。

你也遇到过类似问题吗?欢迎留言交流。