一、前言
最近在项目中需要设计一个关于MySQL的selectOrInsert操作场景。于是为了提高操作性能,我便采用了DCL(Double Check Lock)的思想。系统上线运行了很长一段时间都没有问题,但偶然间却发现其抛出了死锁异常。接下来,我就和大家一起来分析下该场景,以及为什么会出现死锁。
二、场景说明
在实际项目中有这么一个场景,我们需要将第三方提供的某个字符串类型的ID,转换成一个全局唯一的数字类型的ID。于是当时设计了一张表t_id_mapper。其包含两个字段:自增类型的ID(数字类型),以及字符串类型的O_ID(Other ID)字段,该o_id字段为唯一索引。业务场景大致如下:
- 获取到三方字符串类型ID:strId
- 在数据库t_id_mapper中判断该strId是否存在
- 如果存在,则直接返回对应的数字类型id
- 如果不存在,则将strId插入到表t_id_mapper,并返回其对应的自增字段id
于是我写了类似如下的操作:
- 首先执行select * from t_id_mapper where o_id = :strId
- 如果存在则直接返回对应的ID字段。
- 如果不存在则执行select * from t_id_mapper where o_id = :strId for update(利用for update加锁, 此时不提交事务)
- 然后再次判断记录是否存在,如果存在则返回对应ID字段。
- 如果不存在,则执行insert into 语句向t_id_mapper中插入strId内容,然后提交事务。并返回本次自增序列ID,该ID即为strId对应的数字类型ID。
上述操作的伪代码如下:
public int selectOrInsert(String strId) {
id = doSelect(strId);//无锁查询
if (null == id) {
transaction {//开启事务
id = doSelectForUpdate(strId); //加锁查询
if (id == null) {
id = doInsert(strId); //执行插入
}
}//提交事务
}
return id;
}
整个MySQL版本的DCL流程如下:
整个流程看看起来并没有任何问题,且也完全符合DCL的思想,第一次无锁判断。第二次加锁判断,然后再持有锁期间(事务内)完成insert操作,防止了并发写入。
但是运行一段时间后,程序却在图中insert的位置抛出了死锁异常。
由此可见,这个DCL模式在MySQL里面应该有什么不合适的地方。下面让我们来进一步分析具体原因。
三、过程分析
1、死锁日志分析
遇到死锁异常,我们首先应该想到的就是去查看死锁日志。于是,我们首先从DBA处拿到了如下死锁日志。
------------------------
LATEST DETECTED DEADLOCK
------------------------
200526 17:49:17
*** (1) TRANSACTION:
TRANSACTION 7892ECEC4, ACTIVE 50 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1248, 2 row lock(s), undo log entries 1
MySQL thread id 41920347, OS thread handle 0x7f8fe5598700, query id 26349859046 10.174.41.70 panda_rw update
insert into
tm_***_center
(
id, template_id, template_type, modify_time, create_time
)
values(null, '***', 2, now(), now())
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1326 page no 4 n bits 256 index `UNIQ_IDX_TEMPLATE` of table `panda`.`tm_***_center` trx id 7892ECEC4 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 168 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 30; hex 393064383166343162336330343833343930353432383962313863663932; asc 90d81f41b3c048349054289b18cf92; (total 32 bytes);
1: len 4; hex 000000f5; asc ;;
*** (2) TRANSACTION:
TRANSACTION 7892F00C6, ACTIVE 35 sec inserting, thread declared inside InnoDB 500
mysql tables in use 1, locked 1
4 lock struct(s), heap size 1248, 2 row lock(s), undo log entries 1
MySQL thread id 41920345, OS thread handle 0x7f8ff7041700, query id 26349859047 10.174.41.70 panda_rw update
insert into
tm_***_center
(
id, template_id, template_type, modify_time, create_time
)
values(null, '***', 2, now(), now())
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 1326 page no 4 n bits 256 index `UNIQ_IDX_TEMPLATE` of table `panda`.`tm_***_center` trx id 7892F00C6 lock_mode X locks gap before rec
Record lock, heap no 168 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 30; hex 393064383166343162336330343833343930353432383962313863663932; asc 90d81f41b3c048349054289b18cf92; (total 32 bytes);
1: len 4; hex 000000f5; asc ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1326 page no 4 n bits 256 index `UNIQ_IDX_TEMPLATE` of table `panda`.`tm_***_center` trx id 7892F00C6 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 168 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 30; hex 393064383166343162336330343833343930353432383962313863663932; asc 90d81f41b3c048349054289b18cf92; (total 32 bytes);
1: len 4; hex 000000f5; asc ;;
*** WE ROLL BACK TRANSACTION (2)
通过死锁日志我们可以看到,事务1在等待主键上的插入意向锁。
事务2则持有一个间隙锁:
同时session2需要等待获取插入意向锁:
在分析上面场景出现的原因之前我们首先要明确下面几个知识点:
2、MySQL锁相关只是点
a、MySQL中各种锁之间的兼容关系
通过下表我们可以知道,当一个位置被加了间隙锁之后,该区域还可以继续加间隙锁和next-key锁,但是不能加插入意向锁。
即间隙锁和间隙锁与next-key锁之间兼容,但是间隙锁和插入意向锁之间互斥。
b、for update的基本加锁机制
假设id是唯一主键,则select * from update where id = 3,如果id=3这条记录存在,则这条语句只会锁住这条记录,即加记录锁。
如果id=3这条记录不存在,则会在加一个间隙锁。比如数据库只存在id=1和id=6的记录,则此时会见间隙锁(1,6)。
PS:这里我们只是列举了部分for update的加锁场景,for update语句还有很多其他的加锁场景。
3、死锁重现
接下来,我们继续分析上述问题。在知道上诉两个知识点之后,那么如果我们在查询一个全新的strId的时候(即表中不存在),此时就和上述我们讨论的for update查询记录不存在的场景一样,即其会加间隙锁。因此我们可以将上述问题整理成如下执行流程。
通过上述表格,我们就可以清晰的看出为什么我们这种DCL场景会出现死锁。下面我们详细解说每个时间点的执行操作。
- 首先t1和t2时间点都为无锁查询(即快照读),所以肯定是可以执行的。
- t3的时候,session1加锁查询记录,由于记录不存在,此时会加间隙锁。
- t4的时候,session2加锁查询,也会由于记录不存在而加间隙锁。t3的时候已经加了间隙锁了,此时为何能加锁成功呢?因为根据上面的知识点我们知道,间隙锁和间隙之间是互相兼容的。
- t5的时候,session1执行插入操作,需要加插入意向锁。由于插入意向锁和已经存在的间隙锁互斥,所以此时不能加锁成功,session1必须等待。
- t6的时候,session2执行插入操作,也需要加插入意向锁,同样由于该锁和已存在的间隙锁互斥,所以此时必须等待。此时变出现了互相等待(循环依赖)的场景,即session1持有间隙锁,等到插入意向锁,session2也持有间隙锁,等到插入意向锁。因此出现了session1和session2互相等待的场景,即死锁。在这种场景下,MySQL会抛出死锁异常,并选择一个事务进行回滚。
到此,我们就了解了为什么MySQL中DCL的模式会导致死锁了。
四、解决方法
其实通过上述的分析我们可以看出,在这种场景下,我们无法使用DCL模式。我们可以不使用加锁的方式执行这个selectOrInsert场景。即首先执行无锁的select查询,如果发现无记录,则执行执行insert操作。由于o_id字段是唯一索引,所以如果出现冲突,后执行insert操作的线程则会抛出DuplicateKey异常。当我们得到DuplicateKey异常之后,我们再重新执行无锁查询即可查询到已经存在的o_id对应的数字类型的ID(由另外一个线程插入的)了。对应的伪代码如下:
public int selectOrInsert(String strId) {
id = doSelect(strId); //无锁查询
if (id == null) {
try {
id = doInsert(strId); //直接插入
}
catch (DuplicateKey e) {
id = doSelect(strId); //重复key异常后,再次无锁查询
}
}
return id;
}
五、惯例
如果你对本文有任何疑问或者高见,欢迎添加公众号共同交流探讨(添加公众号可以获得”Java高级架构“上10G的视频和图文资料哦)。