@Transactional注解事务与锁引发的时序问题

@Transactional注解事务与锁引发的时序问题

spring事务与锁引发的时序问题 双重保险下暗藏问题 我们在使用spring事务时,都接触过spring的注解事务@Transactional。有些场景下,我们在使用事务的同时,为了保证原子性操作会引进应用层锁,如:Synchroized、CeenCrantLock等。 @Service publ

spring事务与锁引发的时序问题

双重保险下暗藏问题

我们在使用spring事务时,都接触过spring的注解事务@Transactional。有些场景下,我们在使用事务的同时,为了保证原子性操作会引进应用层锁,如:Synchroized、CeenCrantLock等。

@Service
public class AddressService {

    @Autowired
    private AddressRepository repository;

    @Transactional(rollbackFor = BusinessException.class)
    public String insertAddress(AddressDTO dto, String userId) {
        // 获取用户级别的锁 此处getLock方法具体实现省略
        Object lock = getLock(userId);

        synchronized(lock) {
        // 检查地址总数量是否大于限定值 (逻辑在方法内部)
        repository.checkAddressSum(userId);

        // 如果插入地址设置为默认地址,则需要取消其他默认地址
        if (dto.isDefault()) {
            Long defaultId = repository.findDefaultAddressId(userId);
            if (defaultId != null) {
                repository.cancelDefaultAddress(defaultId);
            }
        }

        Address address = new Address(dto, userId);
        repository.save(address);
        return address.getId();
        }
    }
}

此段代码看起来没有什么问题,确保了检查、取消和插入的原子性执行。大家也一定是考量了,怕会有其他线程在此线程插入之前,也执行了检查后,错误地认为当前没有默认地址,而最终导致库中出现了两个默认地址。

但是实际上,上述的写法实际上并没有保证原子性,甚至可以说毫无用处。

原因分析

我们把上述代码转换为流程图:

graph LR A[线程1]-->B[开启事务]-->N[获取锁]-->C[检查] C[检查]-->|true|D[取消默认地址]-->E[插入] C[检查]-->|false|E[插入] E[插入]-->O[释放锁]-->F[提交事务] G[线程2]-->H[开启事务]-->Q[获取锁]-->I[检查]-->|true|J[取消默认地址]-->K[插入] I[检查]-->|false|K[插入]-->R[释放锁]-->M[提交事务]

相信大家已经能看出来问题所在了。

我们忘记考虑的事务的状态了。我们都知道事务没提交之前,数据库中的数据实际上是没有发送改变的。

在上述代码中,释放锁在事务提交之前。这就导致了实际上在极端条件下在线程1未提交事务时,线程2获取到锁并执行了检查,错误地认为没有默认地址存在,也进行了插入地址,进而导致了数据库中有了两条默认地址。

如果上述流程图,关于事务提交时机,你有所疑问。这说明你对于Spring的AOP机制模糊、不清楚。可以去先了解AOP机制再来理解问题。

解决方法

扩大锁范围,使锁范围大于事务范围

同样是因为AOP机制原因,同类下调用其他带事务注解的方法时,事务不会生效。所以这里需要再创建一个新类,来封装事务操作。

@Service
public class AddressService {

    @Autowired
    private AddressRepository repository;

    public String insertAddress(AddressDTO dto, String userId) {
        // 获取用户级别的锁 getLock()方法省略
        Object lock = getLock(userId);
        synchronized(lock) {
            return addressTransactionService.insertAddressTransactional(dto, userId);
        }
    }
}

@Service
public class AddressTransactionService {

    @Transactional(rollbackFor = BusinessException.class)
    public String insertAddressTransactional(AddressDTO dto, String userId) {
        // 检查地址总数量是否大于限定值 (逻辑在方法内部)
        repository.checkAddressSum(userId);

        // 如果设置为默认地址,需要取消其他默认地址
        if (dto.isDefault()) {
            Long defaultId = repository.findDefaultAddressId(userId);
            if (defaultId != null) {
                repository.cancelDefaultAddress(defaultId);
            }
        }

        Address address = new Address(dto, userId);
        repository.save(address);
        return address.getId();
    }
}

不使用注解式事务,使用编程式事务

@Service
@Slf4j(topic = "AddressService")
public class AddressService {

    @Autowired
    private AddressRepository repository;
    @Autowired
    private TransactionTemplate transactionTemplate;

    public String insertAddress(AddressDTO dto, String userId) {
        // 获取用户级别的锁 此处getLock方法具体实现省略
        Object lock = getLock(userId);

        synchronized(lock) {
          return transactionTemplate.execute(status->{
             // 检查地址总数量是否大于限定值 (逻辑在方法内部)
             try{
                repository.checkAddressSum(userId);

                // 如果插入地址设置为默认地址,则需要取消其他默认地址
                if (dto.isDefault()) {
                      Long defaultId = repository.findDefaultAddressId(userId);
                      if (defaultId != null) {
                      repository.cancelDefaultAddress(defaultId);
                      }
                }

                Address address = new Address(dto, userId);
                repository.save(address);
                return address.getId();
                }catch(Exception e){
                    //记录日志
                    log.error("xxx");
                    // 回滚
                    status.setRollback();
                    throw e;
                }
            });
       }
   }

使用悲观锁

因为加锁性能损失较大。且当前例子中,需要锁定所有行、锁定默认地址行等。

损失较大,容易死锁。一般不会使用,故不做介绍。

RBAC权限模型笔记 2026-03-27
Notion-笔记、文档、项目管理协同一体化平台 2026-03-27

评论区