“ bug 是程序员的天敌,同时也是程序员进步的阶梯”
前情提要
—
前段时间,同事反馈又一批业务数据入库非常慢,而且有些数据和合作方数据对不上,偶尔会有出入。于是,作为对疑难问题感兴趣的我就开始协助排查。
事故现场
—
经过登录日志平台排查日志发现:
com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException:
Deadlock found when trying to get lock
那么这就是入库慢和数据和客户返回部分数据对不上的问题的根源了,死锁。而且出现的概率非常高。
开始怀疑是不是业务库的事物隔离级别设置的问题,经过和基建部门相关同事确认,数据库隔离级别是RC。
开始代码走查
发现入库的时候,因为记录中除了唯一索引字段外,其他字段可能会存在更新, 所以用的是replace into ,且是多线程插入的。那么问题很可能就出现在这里。
replace into先是执行insert,发生duplicate key之后再去更新原有记录。这意味着从引擎的角度看操作并不是原子性的,有可能会有多个行锁。
为了简化业务逻辑,说明问题,简化版表的DDL:
其中,字段b是唯一索引键。
CREATE TABLE `c` (
`a` int(11) NOT NULL AUTO_INCREMENT,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`a`),
UNIQUE KEY `idx_uk_b` (`b`)
) ENGINE=InnoDB
我们用程序模拟一下 案发现场,三个方法对三条数据进行更新:
"replace into c values(NULL,3)"}) ({
void replaceInsertTest1();
"replace into c values(NULL,1)"}) ({
void replaceInsertTest2();
"replace into c values(NULL,2)"}) ({
void replaceInsertTest3();
模拟多线程写入
new Thread() {
@Override
public void run() {
while (flag) {
try {
mapper.replaceInsertTest1();
} catch (Exception e) {
log.error(e.getMessage(), e);
flag = false;
}
}
}
}.start();
new Thread() {
@Override
public void run() {
while (flag) {
try {
mapper.replaceInsertTest2();
} catch (Exception e) {
log.error(e.getMessage(), e);
flag = false;
}
}
}
}.start();
new Thread() {
@Override
public void run() {
while (flag) {
try {
mapper.replaceInsertTest3();
} catch (Exception e) {
log.error(e.getMessage(), e);
flag = false;
}
}
}
}.start();
死锁出现了。
原因分析
—
发生duplicate key冲突的索引是idx_uk_b。这种情况下replace into可以分为以下几步:
插入聚集索引主键,这步一定成功。
插入二级索引,检查二级唯一索引idx_uk_b上是否有冲突。若是,则undo步骤1插入的聚集索引记录,转到步骤3;若否,转到步骤4。
处理冲突。通过idx_uk索引定位冲突行并加锁,insert新记录成功后delete冲突行。
直接insert记录。
死锁就发生在步骤3的delete + insert中。
我们知道MySQL在RR隔离级别下引入间隙锁来解决数据记录的幻读问题,在RC隔离级别下,通常间隙锁会消失,降级为记录锁,所以在RC隔离级别下能够提高并发写入的性能。
但是在某些特殊场景下,RC隔离级别也会包含间隙锁。要搞明白这个问题
首先需要知道下面2个知识点:
-
MySQL在唯一索引上加锁的原则:
唯一索引上的范围查询会访问到不满足条件的第一个值为止
这个加锁原则看似不太合理,像一个bug,因为唯一索引意味着所有的记录不能重复,理论上只需要添加记录的行锁就可以,但是实际中确实是需要访问到当前记录的下一条记录进行加锁。
-
插入意向锁
插入意向锁之间是不冲突的,插入意向锁也是一种间隙锁,他的存在是为了提高插入的并发度。在申请插入意向锁的时候,需要判断当前插入记录位置的下一条记录上是否持有锁,如果有,则需要判断是否与插入意向锁冲突。如果没有,则不需要判断,直接加上插入意向锁(Next-Key Lock)。
而且根据MySQL 5.7 Reference,在delete一行时Next-Key Lock会将该行在索引上的前一个区间锁住以防止幻读。
That is, a next-key lock is an index-record lock plus a gap lock on the gap preceding the index record. If one session has a shared or exclusive lock on record R in an index, another session cannot insert a new index record in the gap immediately before R in the index order.
如果两个session同时需要delete同一个主键的记录【要知道,唯一索引也是一颗B+树,这个B+树的主键就是字段b】,并insert一条B+树索引排序在删除数据的前面的记录(例如:insert一条主键更小的记录),死锁就有可能发生。
结论
—
现在我们知道,问题关键点在于replace是以delete + insert的方式去更新记录,改变了聚集索引上的值,更新后自增主键id 是不一样的。解决这个问题的方案是,更新的时候避免重新分配新的记录,具体可以使用insert ... on duplicate key update ...
。该SQL遇到唯一索引冲突时,总是使用update旧记录的方式来更新。
insert into c values(NULL,4) on duplicate key update b=4
这样问题就解决了,当然,也给下次海量数据分页留下了伏笔。
我是小汪哥,希望能收到你的键盘传递的电流!
本文使用 文章同步助手 同步