在日常业务开发时,经常会遇到查询表记录状态,然后做相应的新增插入。而当遇到并发请求的时候,就存在多个事务查到同样的数据库状态,于是同时执行新增,导致业务逻辑异常。
例如:
- 系统中的系统初始化模块
系统的初始化在用户访问系统的相关菜单页面时,便会触发对当前组织架构是否进行过初始化的判断,并对各个功能模块表进行数据初始化。在多用户同时进入系统触发初始化或者前端重复发起初始化请求的场景下,会存在重复初始化数据的问题。
- 版本记录表模块。
版本记录表在用户操作相关模块时,需要判断模块版本状态,进而选择是否新增版本记录。也会存在并发情况下,重复新增版本记录的问题。
既然是对数据操作的同步问题,那不可避免的就需要加锁来解决。那有两种加锁思路:
- 在应用程序层面加锁
- 在数据库层面加锁
在应用程序层面加锁
这个思路无非是想让新增表记录的事务操作保持串行,让业务不会存在同时对表做出新增相同数据的操作。
实际操作就是对相关SQL逻辑代码块加【分布式锁】。但是这时候需要注意的一个问题是,我们的业务代码往往伴随着Spring事务的包裹,此时的加锁范围必须保证在事务开启之前,并且囊括整个事务的生命周期。
否则就会出现一下以下几种情况:
- 在隔离级别为RR的情况下,Spring先对并发业务线程开启了事务,此时再对SQL代码进行加锁也无济于事,Mysql再RR级别下默认使用快照读,无法感知其他事务对数据进行的新增操作。
- 处于SpringAOP层面的事务提交代码还未执行,事务还未提交,锁就已经释放了,此时另外一个业务线程便可以提前获取到锁,执行相同的业务逻辑。
通过查阅TransactionInterceptor 事务拦截器代码可以看到,如果只对Service层的代码进行加锁,锁的范围是小于事务的覆盖范围的,无法对事务进行有效控制。
public abstract class TransactionAspectSupport implements BeanFactoryAware, InitializingBean {
protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,final InvocationCallback invocation) throws Throwable {
// If the transaction attribute is null, the method is non-transactional.
TransactionAttributeSource tas = getTransactionAttributeSource();
final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
final PlatformTransactionManager tm = determineTransactionManager(txAttr);
final String joinpointIdentification = methodIdentification(method, targetClass, txAttr);
if (txAttr == null || !(tm instanceof CallbackPreferringPlatformTransactionManager)) {
// Standard transaction demarcation with getTransaction and commit/rollback calls.
// Spring执行事务创建
TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);
Object retVal = null;
try {
// This is an around advice: Invoke the next interceptor in the chain.
// This will normally result in a target object being invoked.
// 正常加锁的范围,处于Service层的代码
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
// target invocation exception
//Spring执行事务异常回滚
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
//Spring执行事务提交
commitTransactionAfterReturning(txInfo);
return retVal;
}
//...
}
}
复制代码
解决问题的思路有两个:
- 让锁的覆盖范围涵盖事务的生命周期,即将加锁操作提前,比如在代码从controller层进入service层之前的中间层,做加同步锁的逻辑。
- 第二个思路就是在数据库层面加锁
在数据库层面加锁
数据库层面加锁有三种思路:
- 事务隔离级别提高到Serializable串行,但是此方案对数据库性能影响较大,除非是非常低频并且事务较小的情况下不建议使用。
- 加唯一索引,唯一索引是相对简单高效的一种处理方式,在表数据支持添加唯一索引的情况下建议使用。
- 使用数据库的排他锁,在Mysql 的实际使用就是利用【for update】关键字。
Mysql在执行有for update修饰的select语句是,会新增一个IX意向排他锁,此锁为表级锁。IX意向排他锁与其他意向锁兼容,但是与行锁(S读锁,X写锁)互斥。同时还会对索引条件下的所有记录,加next-key锁,即锁住记录的同时锁住记录之间的范围区间。
这样当事务1使用for update对指定索引下的记录加排他锁之后,事务2使用for update进行查询时会被事务1阻塞,直到事务1提交锁释放。
但是理想很丰富,现实很骨感,Mysql 在RR隔离级别,指定索引条件无数据的情况下,使用for update会对一个叫【supremum pseudo-record】的虚拟记录(代表正无穷)加next-key锁,以达到锁定[0-∞]零到正无穷的区间,但是此时next-key锁互斥失效,导致多个事务同时持有对【supremum pseudo-record】记录的next-key锁,进而在执行之后的insert插入操作时,由于在持有next-key锁的情况下,插入意向锁阻塞,从而事务双方均等待对方释放next-key锁,造成死锁的局面。
对于此Mysql的bug,在另外一篇文章中有详细描述 【Mysql关于无数据情况下的互斥锁失效的问题研究】
所以在使用数据库加锁的方法时,要额外注意避开此bug。