分布式锁与 Spring 事物管理顺序问题
这个是网上的网友提问,我给他推理并解决了他的问题。故此记录一下 。

问题背景
"加了分布式锁,数据还是不对"是很典型的一类并发问题。锁本身没有 bug,事务也确实回滚提交都正常,但两者组合在一起时,释放锁和提交事务的先后顺序稍有差错,串行化的保护就形同虚设。这类问题在压测之前很难暴露,排查时也容易把注意力放在锁的实现上,反而忽略了事务边界。这位网友遇到的正是这种情况。
他在使用zookeeper的临时顺序节点分布式锁来修改mysql中的数据值,此功能类似于售票机制,高并发下,要求售票数量保持一致。不可以数量多或少。
使用zookeeper的临时顺序节点分布式锁的时候,他锁住service层时,执行完整个service方法时,锁释放,但此时方式上标注的spring 管理的声明式事物却还未提交。这样的场景在并发情况下会导致数据问题。
理所当然他的数据库中数据会出现异常情况。100个线程同时执行1次一次拿1张,100张票应该为剩余0,而他的结果却并不是0,没有达到预期值。
原因分析
原理是:第一个线程执行update方法结束之后,但由于事物还未提交完成。分布式锁却已经先一步释放,下一个线程执行此方法时 select 获取的还是update之前的脏读数据。导致数据出现问题。因为spring声明式事物@Transactional(rollbackFor = Exception.class) 是基于aop,方法执行完成之后的再执行的环绕通知,但环绕通知提交事务时,zookeeper的锁却已经释放。这个先后顺序差异导致下一个线程读到脏数据,数据处理产生错误。
展开说一下这个执行顺序。声明式事务的本质是 Spring 为目标类生成了一个代理对象,事务的开启和提交都发生在代理层,包裹在业务方法的外面。而他的加锁、解锁代码写在业务方法内部。于是一次调用的实际时序是这样的:
// 代理对象的调用时序(示意)
// 1. 事务拦截器开启事务
// 2. 进入业务方法,获取 zookeeper 锁
// 3. select 查余票,update 扣减
// 4. 业务方法内释放锁 <-- 锁在这里就没了
// 5. 业务方法返回,事务拦截器提交事务 <-- 提交却在这里
第 4 步和第 5 步之间存在一个窗口:锁已经释放,事务还没提交。下一个等锁的线程在这个窗口内拿到锁并执行 select,由于上一个事务未提交,在默认的读已提交或可重复读隔离级别下,它读到的是扣减之前的旧值,相当于两个线程基于同一份余票各扣了一次。锁的排他性没有被破坏,被破坏的是"锁内的读一定能看到上一个持锁者的写"这个隐含假设。
解决办法
所以要修改 spring事物和分布式锁释放的先后顺序。先提交事务之后,再释放掉分布式锁或直接锁controller层就可以避免此问题。
两种做法本质相同,都是让锁的持有范围完全覆盖事务的生命周期:
1)在 service 外层(比如 controller 或一个不带事务的外层方法)加锁、解锁,事务方法作为被锁包住的内层调用,方法返回即事务已提交,之后才释放锁。
2)不用声明式事务,改用编程式事务(TransactionTemplate 或手动 commit),在代码里显式地先提交、再解锁,把顺序掌握在自己手里。
需要留意的是,如果把加锁逻辑挪到同一个类的另一个方法里,通过 this 自调用进入事务方法,事务注解不会生效——自调用不经过代理对象。要么拆到两个类,要么从容器里拿到自身的代理再调用。
分布式锁不等于数据完整性
这里再多说一句,分布式锁不能保证数据完整性,只能保证当前业务层service方法的唯一调用。
如果其他service方法中有mapper也在处理这一条数据,那么数据将还是会出现问题。
所以理应给表中添加乐观锁字段来保证数据的完整性。乐观锁可以很好的保持数据的完整性,并且并发性能高于他使用的service加锁。
乐观锁的做法是在表里加一个 version 字段,更新时带上读到的版本号做条件:
-- 读的时候把 version 一起查出来
-- 更新时以旧版本号为条件,同时把版本号加一
update ticket
set stock = stock - 1, version = version + 1
where id = #{id} and version = #{version};
-- 受影响行数为 0 说明数据已被别人改过,重试或报错
这条防线加在数据本身上,不管有多少个入口在改这行数据,最终一致性都由数据库保证。分布式锁解决的是"同一时刻只有一个人在干活",乐观锁解决的是"就算多个人干了活,数据也不会错",两者针对的层面不同,并不互相替代。
踩坑与注意
1)加分布式锁时,先想清楚锁的边界和事务的边界谁包住谁。锁必须完全覆盖事务,"方法内加锁 + 方法上 @Transactional"是最容易写出来、也最容易出错的组合。
2)这类问题单元测试和低并发场景基本测不出来,只有并发压到锁释放与事务提交之间的时间窗口才会复现。上线前对扣减类接口做并发一致性验证是必要的。
3)排查数据不一致时,不要只盯着锁的实现是否正确,把事务的开启、提交时机也画进时序里,往往问题就在两者的交界处。
小结
这个案例里锁和事务各自都工作正常,出问题的是两者的先后顺序:锁在事务提交前释放,给了下一个线程读脏数据的窗口。修复思路是让锁完全包住事务,或者干脆用编程式事务显式控制提交时机。更进一步,扣减类的数据正确性不应该只依赖入口处的互斥,给表加乐观锁字段,把最后一道防线放在数据库层面,才是更稳妥的做法。
评论 / COMMENTS