一、背景
在预发环境中,由消息驱动最终触发执行事务来写库存,但是导致MySQL发生死锁,写库存失败。
com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException: rpc error: code = Aborted desc = Deadlock found when trying to get lock; try restarting transaction (errno 1213) (sqlstate 40001) (CallerID: ): Sql: "/* uag::omni_stock_rw;xx.xx.xx.xx:xxxxx;xx.xx.xx.xx:xxxxx;xx.xx.xx.xx:xxxxx;enable */ insert into stock_info(tenant_id, sku_id, store_id, available_num, actual_good_num, order_num, created, modified, SAVE_VERSION, stock_id) values (:vtg1, :vtg2, :_store_id0, :vtg4, :vtg5, :vtg6, now(), now(), :vtg7, :__seq0) /* vtgate:: keyspace_id:e267ed155be60efe */", BindVars: {__seq0: "type:INT64 value:"29332459" "_store_id0: "type:INT64 value:"50650235" "vtg1: "type:INT64 value:"71" "vtg2: "type:INT64 value:"113817631" "vtg3: "type:INT64 value:"50650235" "vtg4: "type:FLOAT64 value:"1000.000" "vtg5: "type:FLOAT64 value:"1000.000" "vtg6: "type:INT64 value:"0" "vtg7: "type:INT64 value:"20937611645" "}
初步排查,在同一时刻有两条请求进行写库存的操作。
时间前后相差1s,但最终执行结果是,这两个事务相互死锁,均失败。
事务定义非常简单,伪代码描述如下:
start transaction
// 1、查询数据
data = select for update(tenantId, storeId, skuId);
if (data == null) {
// 插入数据
insert(tenantId, storeId, skuId);
} else {
// 更新数据
update(tenantId, storeId, skuId);
}
end transaction
该数据库表的索引结构如下:
索引类型 | 索引组成列 |
---|---|
PRIMARY KEY | (stock_id ) |
UNIQUE KEY | (sku_id ,store_id ) |
所使用的数据库引擎为Innodb,隔离级别为RR[Repeatable Read]可重复读。
二、分析思路
首先了解下Innodb引擎中有关于锁的内容
2.1 Innodb中的锁
2.1.1 行级锁
在Innodb引擎中,行级锁的实现方式有以下三种:
名称 | 描述 |
---|---|
Record Lock | 锁定单行记录,在隔离级别RC和RR下均支持。 |
Gap Lock | 间隙锁,锁定索引记录间隙(不包含查询的记录),锁定区间为左开右开,仅在RR隔离级别下支持。 |
Next-Key Lock | 临键锁,锁定查询记录所在行,同时锁定前面的区间,故区间为左开右闭,仅在RR隔离级别下支持。 |
同时,在Innodb中实现了标准的行锁,按照锁定类型又可分为两类:
名称 | 符号 | 描述 |
---|---|---|
共享锁 | S | 允许事务读一行数据,阻止其他事务获得相同的数据集的排他锁。 |
排他锁 | X | 允许事务删除或更新一行数据,阻止其他事务获得相同数据集的共享锁和排他锁。 |
简言之,当某个事物获取了共享锁后,其他事物只能获取共享锁,若想获取排他锁,必须要等待共享锁释放;若某个事物获取了排他锁,则其余事物无论获取共享锁还是排他锁,都需要等待排他锁释放。如下表所示:
将获取的锁(下)已获取的锁(右) | 共享锁S | 排他锁X |
---|---|---|
共享锁S | 兼容 | 不兼容 |
排他锁X | 不兼容 | 不兼容 |
2.1.2 RR隔离级别下加锁示例
假如现在有这样一张表user,下面将针对不同的查询请求逐一分析加锁情况。user表定义如下:
CREATE TABLE `user` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` bigint(20) DEFAULT NULL COMMENT '用户id',
`mobile_num` bigint(20) NOT NULL COMMENT '手机号',
PRIMARY KEY (`id`),
UNIQUE KEY `IDX_USER_ID` (`user_id`),
KEY `IDX_MOBILE_NUM` (`mobile_num`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户信息表'
其中主键id与user_id为唯一索引,user_name为普通索引。
假设该表中现有数据如下所示:
id | user_id | mobile_num |
---|---|---|
1 | 1 | 3 |
5 | 5 | 6 |
8 | 8 | 7 |
9 | 9 | 9 |
下面将使用select ... for update 语句进行查询,分别针对唯一索引、普通索引来进行举例。
1、唯一索引等值查询
select * from user
where id = 5 for update
select * from user
where user_id = 5 for update
在这两条SQL中,Innodb执行查询过程时,会如何加锁呢?
我们都知道Innodb默认的索引数据结构为B+树,B+树的叶子结点包含指向下一个叶子结点的指针。在查询过程中,会按照B+树的搜索方式来进行查找,其底层原理类似二分查找。故在加锁过程中会按照以下两条原则进行加锁:
1.只会对满足查询目标附近的区间加锁,并不是对搜索路径中的所有区间都加锁。本例中对搜索id=5或者user_id=5时,最终可以定位到满足该搜索条件的区域(1,5]。
2.加锁时,会以Next key Lock为加锁单位。那按照1满足的区域进行加Next key Lock锁(左开右闭),同时因为id=5或者user_id=5存在,所以该Next key Lock会退化为Record Lock,故只对id=5或user_id=5这个索引行加锁。
如果查询的id不存在,例如:
select * from user
where id = 6 for update
按照上面两条原则,首先按照满足查询目标条件附近区域加锁,所以最终会找到的区间为(5,8]。因为id=6这条记录并不存在,所以Next key Lock(5, 8]最终会退化为Gap Lock,即对索引(5,8)加间隙锁。
2、唯一索引范围查询
select * from user
where id >= 4 and id = 6 and mobile_num