# 记一则简单的分布式事务锁使用失败的案例

342 阅读2分钟

总结

在Spring 的事务、Spring Data 、分布式事务锁一起使用时一定要注意加锁解锁的提交事务的顺序。

缘起

负责修(别人写的)bug遇到了更新丢失问题

如果多个线程操作,基于同一个查询结构对表中的记录进行修改,那么后修改的记录将会覆盖前面修改的记录,前面的修改就丢失掉了,这就叫做更新丢失。
Serializable可以防止更新丢失问题的发生。其他的三个隔离级别都有可能发生更新丢失问题。
Serializable虽然可以防止更新丢失,但是效率太低,通常数据库不会用这个隔离级别,所以我们需要其他的机制来防止更新丢失:

问题

然后在粗略了打量了一下代码,有事务有悲观锁,很奇怪啊。仔细盯着代码看了一下 备注:之所以不用乐观锁用悲观锁是因为业务中这个锁会在多个服务中进行锁定

  @Transactional
  @Retryable(value = RedisLockException.class, maxAttempts = 10)
  public void doSomething(Info info, String key) {
    redisLock.lock(key, 60);
    try {
     Info infoInDB =  infoRepoistory.findById(info.id);
     infoInDB.setAttr(infoInDB.getAttr()+info.getAttr());
     infoRepoistory.save(infoInDB);
    } catch (Exception e) {
      log.error("error log :{}", key, e);
      throw e;
    } finally {
      redisLock.unlock(key);
    }
  }

发现不对,Spring Data JPA 的事务下,只有在方法结束后,才统一提交,那么执行顺序肯定是:

如图所示,提交数据库压根没有被锁起来,也就是如果是竞态条件下,很大概率会出现丢失更新问题。

解决

解决方法有两种,第一种,是用锁把事务包围起来,伪代码如下:

悲观锁放到事务之外

Class1:

 @Retryable(value = RedisLockException.class, maxAttempts = 10)
  public void doSomething(Info info, String key) {
    redisLock.lock(key, 60);
    try {
      class2.updateInfo(info)
    } catch (Exception e) {
      log.error("error log :{}", key, e);
      throw e;
    } finally {
      redisLock.unlock(key);
    }
  

Class2:

   @Transactional
   public void updateInfo(Info info){
     Info infoInDB =  infoRepoistory.findById(info.id);
     infoInDB.setAttr(infoInDB.getAttr()+info.getAttr());
     infoRepoistory.save(infoInDB);
   }

注意,一定要分成两个class 因为内部方法调用事务注解不起作用。

主动提交到数据库

 @Autowired
 private EntityManager entityManager;
 @Transactional
 @Retryable(value = RedisLockException.class, maxAttempts = 10)
 public void doSomething(Info info, String key) {
   redisLock.lock(key, 60);
   try {
    Info infoInDB =  infoRepoistory.findById(info.id);
    infoInDB.setAttr(infoInDB.getAttr()+info.getAttr());
    infoRepoistory.save(infoInDB);
    entityManager.flush();
   } catch (Exception e) {
     log.error("error log :{}", key, e);
     throw e;
   } finally {
     redisLock.unlock(key);
   }
 }

方案比较

如果是写代码之初,我更倾向于方法一,因为没有显示的提交数据库,更优雅些,但是有些需要注意的地方

  1. 内部方法调用事务不生效,要把锁和事务操作放到两个类里,实际上我也更崇上这种职责分明,小类消防法的编程方式。
  2. 不能在锁以及锁的调用上层使用事务,因为一个大事务是最终一起提交的。

而方法二则更简单粗暴,显示的调用数据库存储,在解bug的时候,是一种对现有代码影响最少的改动,但要考虑事务数据回滚的事情,在例子里,如果报错了,肯定在提交之前不影响回滚。具体业务中可能会更复杂。