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;
}
});
}
}
使用悲观锁
因为加锁性能损失较大。且当前例子中,需要锁定所有行、锁定默认地址行等。
损失较大,容易死锁。一般不会使用,故不做介绍。