likes
comments
collection
share

多线程使用@Transactional造成的阻塞问题

作者站长头像
站长
· 阅读数 11

问题引入

以下代码和数据做了脱敏处理

      如下图所示,代码中的_deleteFunc_包含了对批次数据的删除操作,该功能并发删除集合数据_strList_,最终的现象是:某一个批次数据删除失败回滚,其他数据正常删除。

@Transactional(value="transactionManager", rollbackFor = Exception.class)
public Result deleteOperation(List<String> strList){
    try{ 
        IntStream.range(0, (strList.size() + BATCH_SIZE - 1)/BATCH_SIZE) 
        .mapToobj(i -> strList.subList(i * BATCH_SIZE, Math.min(strList.size(), (i+1) * BATCH_SIZE))) 
        .forEach(batch -> deleteFunc(batch));
    } catch (Exception e){
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnliy();
    } 
} 
  • 数据表结构

多线程使用@Transactional造成的阻塞问题

  • 删除语句

    DELETE FROM table_name WHERE (key,position) IN ((key1, position1)......(keyn, positionn))

定位思路

多线程使用@Transactional造成的阻塞问题      

      据目前发生的现象推测可能的原因有:死锁和单纯阻塞两种,根据上图的分析和定位排除了死锁的可能性,同时数据库日志也验证了我的推测:事务1获得表锁->其他事务等待->事务1超时回滚->其他事务依次执行,并不存在循环等待的现象。根据分析的得知,阻塞的是由于锁的升级导致各个事务抢占资源,这里就引出了本文最重要的两个问题:为什么第一个抢占到锁的事务并未释放锁?为什么锁会升级?

问题解决

为了解决上述两个问题,可以先说一个我之前踩过的坑,为什么我把_@Transactional_的注解用在主线程上? 最初我是想通过该注解用主线程控制其他线程的事务,后来发现数据库连接和事务信息都是存在_Thread Local_中的,Spring_会将事务信息传递到各个线程,并新增一个事务,所以当某个线程发生异常后只会回退当前线程,其他线程不受影响,这就是Spring中事务默认的传递性_PROPAGATION_REQUIRED。但是按照该传递性,假设线程1拿到了互斥锁,那它应该会执行删除操作成功并释放互斥锁,那这里为什么会阻塞呢?这里的原因主要是主线程中捕获了数据库异常,并进行了手动回滚,所以直到try代码块中的任务执行完成之前,子线程的事务都不会提交,锁自然没有释放。

      对于第一个问题的答案已经分析出来了,线程1拿到互斥锁完成删除操作,但是事务未提交,所以互斥锁未释放,导致其余线程阻塞,直到该事务超时,主线程捕获到异常回滚该事务,主线程结束,其他线程依次执行删除操作,解决的办法也很简单,把注解和事务回滚操作挪到线程操作中去,如果需要实现并发事务需要另想办法,靠此注解不能达到我们的目的。

      解决第一个问题之后,阻塞现象消失了,但是我们仍然需要解决第二个问题,因为当删除语句加的是表锁时,我们的并发毫无意义,要解决第二个问题,可以先看下InnoDB引擎对于删除、更新操作的上锁规则:

多线程使用@Transactional造成的阻塞问题

      本文删除操作中的数据都是在数据表中存在的(命中数据),过滤条件通过主键(有索引),且不存在互斥数据,由上图可知应该加行级锁,但是这里锁升级了,唯一的可能就是索引未命中。我们解决问题的关键就是找到索引未命中的原因,在此我先提出本文最重要的三个结论,并给出的理论支撑,有疑惑的同学可以继续深入测试研究,所谓实践出真知(测试环境:Mysql5.6.36、InnoDB):

      结论1:复合字段在IN子查询中不能命中索引,无论col_1、col_2是不是唯一索引或者联合唯一索引(不包含覆盖索引);

SELECT col_1,col_2 FROM table WHERE (col_1,col_2) IN (('a','b'),('c','d')......)

    结论2:复合主键中部分字段在IN子查询中可能命中索引,但须满足两个条件:最左前缀、IN中的值小于一定比例**(不包含覆盖索引)****;**

SELECT col_1,col_2 FROM table WHERE col_1 IN ('a','b','c'......)

**      结论3:单字段在IN子查询中可能命中索引,但须满足两个条件:最左前缀、IN中的值小于一定比例****(不包含覆盖索引)****;**

      对于结论1,我觉得可能是5.6版本的优化问题,在5.7以上版本测试中未发现此问题,对比文档,Mysql5.7在_Range Optimization_中新增了多列IN子查询的优化

多线程使用@Transactional造成的阻塞问题

      结论2和3和InnoDB优化器的代价估算有关,其查询成本来自4个层面:I/O花费、CPU花费、5.6版本之后还加入了内存操作花费和远程操作花费;其中占比最大的是I/O开销,InnoDB将访问一次页面的开销记为1(I/O),读取一条记录的开销记为0.2(CPU),当然这里优化器不一定能精准衡量每次操作需要访问的数据量,当使用_INDEX DIVE_时可以通过索引计算精确值,如果没有使用就会使用统计器的模糊值,具体可见官网_Range Optimization_章节_。_根据该成本计算方式我们可以估算出不同的查询方式带来的成本开销:

假设表数据量为Y,IN条件中有X个字段:
··使用索引时:
  数据库会遍历IN中的值,通过索引去找到对应的记录(假设每次查询需要3I/O,其实大部分表三次足以),I/O
开销为3X,CPU开销为0.2X,如果出现回表I/O开销可能会翻倍;
··全表扫描时:
  遍历主键索引,并判断是否在IN条件中,I/O开销为Y,CPU开销为0.2Y;

      根据成本计算,当IN子查询中条件数量小于一定比例时(大概是百分之二十到百分之三十之间),使用索引的开销更小,但是当超过一定比例时,I/O开销占据主要地位,此时走全表扫描成本更低,这也就是结论2、3的一种解释。当然InnoDB引擎剖析的书中也有另外一种解释方式,就是命中索引是离散I/O,相比于主键的顺序I/O性能更差,但是我觉得这种解释有两个问题,第一该逻辑无法被优化器量化,第二主键索引仅仅是逻辑上顺序存储,内存页之间也是物理分散的。Mysql5.7以上版本的Mysql对于该问题有两个解决方案:

      1、使用_FORCE INDEX_,该关键字会强制使用某一个索引,因为不需要评估该索引的成本,所以会跳过INDEX DIVE,该关键词对单条语句有无好处视具体情况而定,此时事务拿到的就是行锁,并发删除不会出现冲突;

      2、减少IN中的条件数量,即减少业务每批次操作的数据量,此时优化器也会走索引,但是存在两个隐患,第一是命中索引不可控,如果没有命中仍然会出现冲突,第二减小业务数量之后可能使得整体业务的效率下降,线程数量的增加也带来了更多的消耗;

      上述两个解决方案,方案二是不太可取的,如果你的数据库版本不支持该关键字,或者该关键字对你的性能有较大影响时,方案一也将无效,那是否还有适合当前业务的方案呢?本文给出了一个小Tip:将等值匹配转换成范围匹配,使行锁升级成_Next-Key_锁。

SELECT * FROM table WHERE col IN (a,b,c......)
->
SELECT * FROM table WHERE col > minCol AND col < maxCol

      如上转换有什么好处呢?

      1、这种唯一索引的范围匹配,对于优化器来说是只需要两次_CONST_级别的查询即可锁定数据范围,从查询成本来讲成本更低;

      2、由于可以命中索引,所以这里拿到的是_NEXT-KEY_锁而不是表锁,只要不同事务锁的范围不重合,就可以实现并发访问;

      当然使用该优化方案也有三个限制:一是过滤的条件必须是唯一索引和联合唯一索引;二是每批次的业务数据在数据库中必须是连续的,不然不能完成等值到范围的转换;三是排序方式必须和数据库保持一致即字典序;

效果对比

实验数据:单表4万条数据,查询数据量8000。

多线程使用@Transactional造成的阻塞问题

总结

     1、对于数据库,实践出真知,优化器有很多意外的地方;

     2、注意各版本之间的差异;

转载自:https://juejin.cn/post/6873494366892064782
评论
请登录