分布式锁 + 事务防重失效 ------ 警惕事务粒度
最近接入了一个二方流程引擎,在生产环境中发现了审批任务重复提交成功 的问题,导致业务数据被重复写入。这个问题不仅会造成数据混乱,严重时甚至可能引发重复账务处理,后果不容忽视。
背景场景
用户在前端点击"审批通过"后,后台会提交任务并写入业务表。由于提交流程中存在耗时操作 ,用户在等待过程中多次点击按钮,结果多次提交均成功返回,后台也落了多条数据。
初步以为是并发未加锁。但经排查,系统使用了 Lock4j
(基于 Redisson
)的分布式锁机制。任务提交方法上的处理包括:
- 使用
@Lock4j
注解; - 使用
@Transactional(Propagation.REQUIRED)
注解; - 锁获取超时 10 秒,锁持有时间 60 秒;
- 正常情况下,第一次提交后任务会被删除,后续提交应提示"任务不存在"。
看似逻辑完备,但仍发生了重复提交。进一步分析发现了核心原因。
问题根源:锁与事务范围不一致
submitTask
方法同时使用了分布式锁和 REQUIRED
事务注解,意味着它与外部接口共享一个大事务。
问题在于:锁在 submitTask
方法执行结束后即释放,而此时外部事务仍未提交。
因此,形成如下并发场景:
- 第一个线程获取锁,执行
submitTask
,开始事务。 - 方法执行结束后释放锁,但事务未提交;
- 第二个线程获取锁后进入
submitTask
; - 由于前一个事务未提交,任务数据仍可查询;
- 后续的
update
/delete
操作实际未生效(影响行数为 0),但以成功退出; - 最终造成数据重复。
图示:锁释放早于事务提交
sequenceDiagram
participant 用户
participant 用户
participant 应用服务
participant 数据库
用户->>应用服务: 点击"审批通过"
activate 应用服务
应用服务->>应用服务: 获取分布式锁
应用服务->>数据库: 查询任务
应用服务->>数据库: 修改状态 / 删除
应用服务-->>应用服务: 释放锁(事务未提交)
用户->>应用服务: 重复点击
activate 应用服务
应用服务->>应用服务: 获取锁
应用服务->>数据库: 查询任务 (数据未提交仍存在)
应用服务->>数据库: 操作无效 (行数 = 0)
应用服务-->>用户2: 返回成功
应用服务->>数据库: 提交事务
解决方案:确保范围一致
方案一:扩大锁范围(不推荐)
将分布式锁拉到外部大事务范围,确保整个流程执行期间关键操作不被打断。
- 优点:逻辑简单
- 缺点:耗时操作会使锁持久不释,性能降低
方案二:简化事务,缩短耗时(治标不治本)
- 将耗时操作移出事务范围,或改为异步
- 有助于缩短锁+事务的同时经历时间
方案三:基于数据库的应急处理(建议)
通过数据库原子操作确保任务不可重复处理,是最稳定方案。
1. SELECT ... FOR UPDATE
查询任务时使用悲观锁,确保同一时间只有一个事务操作该行
sql
SELECT * FROM task_table WHERE task_id = ? FOR UPDATE;
2. 判断状态 + 影响行数
sql
UPDATE task_table SET status = 'approved' WHERE task_id = ? AND status = 'pending';
如果 affectedRows != 1
,则提示任务已处理,阻止重复操作。
总结
锁控并发,事务保数据一致,两者范围不一致时最易出问题。
建议:
- 不依赖分布式锁本身保障应急性
- 任何不可重复操作,必须在数据库层有备案
- 状态判断 + 影响行数检查是成本最低且最稳定的方案
- 有条件时考虑用唯一索引或幂等记录表进一步防重