在分析案例之前,我们先了解一下MySQL INNODB。在MySQL INNODB引擎中主键是采用聚簇索引的形式,即在B树的叶子节点中既存储了索引值也存储了数据记录,即数据记录和主键索引是存在一起的。而普通索引的叶子节点存储的只是主键索引的值,一次查询找到普通索引的叶子节点后,还要根据叶子节点中的主键索引去找到聚簇索引叶子节点并拿到其中的具体数据记录,这个过程也叫“回表”。
故障发生的场景是关于我们商城的订单系统。有一个定时任务,每一小时跑一次,每次把所有一小时前未支付订单取消掉。而客服后台也可以批量取消订单。订单表 t_order结构大至如下:
id | 订单id,主键 |
status | 订单状态 |
created_time | 订单创建时间 |
id是表的主键,created_time字段上是普通索引。
聚簇索引(主键id)
id(索引) | status | created_time |
1 | UNPAID | 2020-01-01 07:30:00 |
2 | UNPAID | 2020-01-01 08:33:00 |
3 | UNPAID | 2020-01-01 09:30:00 |
4 | UNPAID | 2020-01-01 09:39:00 |
5 | UNPAID | 2020-01-01 09:50:00 |
普通索引(created_time字段)
created_time(索引) | id(主键) |
2020-01-01 09:50:00 | 5 |
2020-01-01 09:39:00 | 4 |
2020-01-01 09:30:00 | 3 |
2020-01-01 08:33:00 | 2 |
2020-01-01 07:30:00 | 1 |
定时任务每一小时跑一次,每次把所有一小时前两小时内的未支付订单取消掉,比如上午11点会取消8点到10点的未支付订单。SQL语句如下:
update t_order set status = 'CANCELLED' where created_time > '2020-01-01 08:00:00' and created_time < '2020-01-01 10:00:00' and status = 'UNPAID'
客服批量取消订单SQL如下:
update t_order set status = 'CANCELLED' where id in (2, 3, 5) and status = 'UNPAID'
上面的两条语句同时执行就可能发生死锁。我们来分析一下原因。第一条定时任务的SQL,会先找到 created_time普通索引并加锁,然后再在找到主键索引并加锁。
第一步,created_time普通索引加锁
第二步,主键索引加锁
第二条客服批量取消订单SQL,直接走主键索引,直接在主键索引上加锁。
我们可以看到,定时任务 SQL对主键加锁顺序是5,4,3,2。客服批量取消订单 SQL对主键加锁顺序是2,3,5。当第一个 SQL对3加锁后,正准备对2加锁时,发现2已经被第二个SQL加锁了,所以第一个SQL要等待2的锁释放。而此时第二个SQL准备对3加锁,却发现3已经被第一个SQL加锁了,就要等待3的锁释放。两个SQL互相等待对方的锁,也就发生了“死锁”。
解决办法就是从SQL语句上保证加锁顺序一致。或者把客服批量取消订单 SQL改成每次 SQL操作只能取消一个订单,然后在程序里多次循环执行SQL,如果批量操作的订单数量不多,这种笨办法也是可行的。