在分布式系统中,分布式锁已经的使用越发常见,我们系统由于较为老旧,使用数据库实现分布式锁,方案为:使用lock_key, lock_biz组成唯一索引,利用数据库对一条记录insert和delete操作的事务性来实现不可重入的db分布式锁。
前一段时间,发现系统分布式锁出现死锁告警,在处理完问题后,重新去看现场日志,发现数据库出现了死锁,在阿里云性能诊断系统中的锁分析里找出死锁日志,但奇怪的是不同的唯一索引值出现了死锁,如下图所示。抱着不懂就研究的心态,重新去研究了mysql Inndb的锁机制(ps:公司mysql 默认是RC隔离级别)。
关于锁的基础知识
本文下方所有实例均依旧该表进行,建表语句如下:
CREATE TABLE `test_lock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `gmt_create` datetime NOT NULL COMMENT '创建时间', `gmt_modified` datetime NOT NULL COMMENT '修改时间', `lock_key` varchar(128) NOT NULL COMMENT '锁名称', `lock_context` varchar(512) DEFAULT NULL COMMENT '锁上下文', `lock_biz` varchar(64) NOT NULL COMMENT '锁类型', PRIMARY KEY (`id`), UNIQUE KEY `idx_uk_lock_name` (`lock_key`,`lock_biz`) ) ENGINE=InnoDB AUTO_INCREMENT=26229 DEFAULT CHARSET=utf8 COMMENT='分布式锁表' ;
锁的作用范围分类
全局锁
在DB级别对整个数据库实例加锁
加锁表现:
- 数据库处于只读状态
- 阻塞对数据的增删改以及DDL
加锁方式:lock Flush tables with read lock 释放锁:unlock tables(发生异常时会自动释放)
作用场景:全局锁主要用于做全库的逻辑备份,和设置数据库只读(set global readonly=true)相比,全局锁在发生异常时会自动释放
表锁
表级别对操作的整张表加锁, 锁定颗粒度大,资源消耗少,不会出现死锁,但并发度低
分为表共享锁和表排他锁,注意:意向锁为表锁,但是由存储引擎自己维护,无需用户手工命令干预。
显示加锁方式:lock tables {tb_name} read/write 释放锁:unlock table {tb_name} (连接中断也会自动释放)
行锁
InnoDB支持行级别锁, 锁粒度小并发度高,但是加锁开销大也很可能会出现死锁innodb行锁住的是索引项,注意当回表时,主键的聚簇索引也会加上锁。
举个栗子:
INSERT INTO `test_lock` (`id`,`gmt_create`,`gmt_modified`,`lock_key`,`lock_context`,`lock_biz`) VALUES (1,now(),now(),'123456',null,'AccountUser');
当执行下面语句,因为查询字段多余组合索引覆盖字段,会出现回表操作补齐其他字段,此时唯一索引 lock_key=123423-lock_biz=AccountUsery以及主键索引 id=1,均被锁住。
select * from test_lock where lock_key='123456' and lock_biz='AccountUser' for update;
加锁方式:
- 普通 select… 查询 (不加锁)
- 普通 insert、update、delete… (隐式加写锁)
- select…lock in share mode (加读锁)
- select…for update (加写锁)
解锁:
提交/回滚事物(commit/rollback) kill 阻塞进程
由于行锁用的最多且更容易出现死锁问题,下面会详细讲述行锁。
锁的模式分类
我们常规理解的锁分为2大类:读锁(也叫共享锁,S)和写锁(也叫排他锁,X)。
这两把锁之间的兼容性说明如下表
横轴表示已持有的锁,纵轴表示尝试获取的锁。1表示成功(即兼容,表现为正常进行下一步操作),0表示失败(即冲突,表现为阻塞住当前操作)
兼容性 | X | S |
X | 0 | 0 |
S | 0 | 1 |
总结一句话就是:排他锁和任何锁均不兼容。
如果仅有读写锁,会存在一个性能问题,思考下面这个场景,其中T代表事务,T1代表事务1,以此类推。
T1: 锁住表中的一行,只能读不能写(行级读锁)。
T2:申请整个表的写锁(表级写锁)。
如果T2申请成功,则能任意修改表中的一行,但这与T1持有的行锁是冲突的。故数据库应识别这种冲突,让T2的申请锁被阻塞,直到T1释放行锁。
若自己实现,最容易想到的识别方案就是遍历:
step1:判断表是否已被其他事物用表锁锁住。
step2: 判断表中的每一行是否已被行锁锁住。
其中step2需要遍历整个表,效率在数据库是没法接收的。因此innodb使用意向锁来解决这个问题
Innodb实现方案:
T1需要先申请表的意向共享锁IS(注意意向共享锁为表级锁,且是由存储引擎自己维护,无需用户命手工命令干预),成功后再申请一行的行锁S。
在意向锁存在的情况下,上面的判断可以改为:step1: 不变
step2:发现表上有意向共享锁,说明表中行被共享行锁锁住了,因此,事务B申请表的写锁被阻塞。
此时就引入的意向锁,加入意向锁后,锁的兼容性分析如下表:
横轴表示已持有的锁,纵轴表示尝试获取的锁。1表示成功(即兼容,表现为正常进行下一步操作),0表示失败(即冲突,表现为阻塞住当前操作)
兼容性 | IX | IS | X | S |
IX | 1 | 1 | 0 | 0 |
IS | 1 | 1 | 0 | 1 |
X | 0 | 0 | 0 | 0 |
S | 0 | 1 | 0 | 1 |
锁的类型分类
key代表二级索引,pk代表主键,截图二级索引一段数据,说明锁的分类。
记录锁(Record Locks)
- 记录锁是最简单的行锁,仅仅锁住一行。如:
SELECT id FROM t WHERE id=1 FOR UPDATE
- 记录锁永远都是加在索引上的,即使一个表没有索引,InnoDB也会隐式的创建一个索引,并使用这个索引实施记录锁。
- 会阻塞其他事务对其插入、更新、删除
间隙锁(Gap Locks)
- 间隙锁是一种加在两个索引之间的锁(众所周知索引是有序的),或者加在第一个索引之前,或最后一个索引之后的间隙。
- 使用间隙锁锁住的是一个区间,而不仅仅是这个区间中的每一条数据。
- 间隙锁只阻止其他事务插入到间隙中,他们不阻止其他事务在同一个间隙上获得间隙锁,所以 gap x lock 和 gap s lock 有相同的作用。
Next-Key Locks
- Next-key锁是记录锁和间隙锁的组合,它指的是加在某条记录以及这条记录前面间隙上的锁。
插入意向锁(Insert Intention)
顾明思义,该锁只会出现在insert操作执行前(并不是所有insert操作都会出现),目的是为了提高并发插入能力,注意虽有意向二字,但插入意向锁是行锁。
- 插入意向锁是在插入一行记录操作之前设置的一种特殊的间隙锁,这个锁释放了一种插入方式的信号,亦即多个事务在相同的索引间隙插入时如果不是插入间隙中相同的位置就不需要互相等待。普通的Gap Lock 不容许 在 (上一条记录,本记录) 范围内插入数据插入意向锁Gap Lock 容许 在 (上一条记录,本记录) 范围内插入数据
- 假设有索引值4、7,几个不同的事务准备插入5、6,每个锁都在获得插入行的独占锁之前用插入意向锁各自锁住了4、7之间的间隙,但是不阻塞对方不冲突的插入行。
锁类型兼容矩阵,横轴表示已持有的锁,纵轴表示尝试获取的锁。1表示成功(即兼容,表现为正常进行下一步操作),0表示失败(即冲突,表现为阻塞住当前操作):
兼容性 | Gap | Insert Intention | Record | Next-Key |
Gap | 1 | 1 | 1 | 1 |
Insert Intention | 0 | 1 | 1 | 0 |
Record | 1 | 1 | 0 | 0 |
Next-Key | 1 | 1 | 0 | 0 |
锁组合
虽然我们了解了上述知识,但是看死锁日志时偶尔也会一脸懵,是因为实际运行他们是组合起来共同完成的锁机制。
锁的模式:
lock_s(读锁,共享锁)
lock_x(写锁,排它锁)
锁的类型:
Record_Lock (锁记录)
Gap_Lock (锁记录前的GAP)
Next-Key Lock (同时锁记录+记录前的GAP)
insert_Intention_Lock (插入意向锁,其实是特殊的GAP锁)
锁模型可以和锁类型任意组合,如:
locks gap before rec,表示为gap锁:lock->type_mode & LOCK_GAP
locks rec but not gap,表示为记录锁,非gap锁:lock->type_mode & LOCK_REC_NOT_GAP
insert intention,表示为插入意向锁:lock->type_mode & LOCK_INSERT_INTENTION
waiting,表示锁等待:lock->type_mode & LOCK_WAIT
在mysql源码中使用了uint32类型来表示锁, 最低的 4 个 bit 表示 lock_mode, 5-8 bit 表示 lock_type(目前只用了 5 和 6 位,大小为 16 和 32 ,表示 LOCK_TABLE 和 LOCK_REC), 剩下的高位 bit 表示行锁的类型record_lock_type
insert语句加锁详解
唯一性索引插入
非唯一性索引插入
问题分析
复现
表中已有数据:
----+---------------------+---------------------+----------+-------------+-------------+ | id | gmt_create | gmt_modified | lock_key | lock_biz | lock_context| +----+---------------------+---------------------+----------+-------------+-------------+ | 12 | 2022-02-15 19:54:42 | 2022-02-15 19:54:42 | 123 | accountUser | 0 | | 50 | 2022-02-15 19:55:05 | 2022-02-15 19:55:05 | 150 | accountUser | 0 | | 75 | 2022-02-15 19:55:19 | 2022-02-15 19:55:19 | 200 | accountUser | 0 |
从死锁日志可以看出,是由于insert操作引发死锁,故重点研究与讲解。
步骤 | T1 | T2 | T3 | T4 |
1 | begin; | begin; | ||
2 | insert into test_lock( `gmt_create` ,`gmt_modified` ,`lock_key` , `lock_biz` ) VALUE (now(),now(), '140', 'AccountUser'); | insert into test_lock( `gmt_create` ,`gmt_modified` ,`lock_key` , `lock_biz` ) VALUE (now(),now(), '144', 'AccountUser'); | ||
3 | begin; | begin; | ||
4 | insert into test_lock`( `gmt_create` ,`gmt_modified` ,`lock_key` , `lock_biz` ) VALUE (now(),now(), '140', 'AccountUser'); | insert into `test_lock`( `gmt_create` ,`gmt_modified` ,`lock_key` , `lock_biz` ) VALUE (now(),now(), '144', 'AccountUser'); | ||
5 | 存在lock_key=140, lock_biz='AccoutUser'的X record行锁 | 因为出现唯一性冲突,故加S Next-key Lock,锁住(123-140],(140,150]之间的空间, | 存在lock_key=144, lock_biz='AccoutUser'的X record行锁 | 因为出现唯一性冲突,故加S Next-key Lock,锁住(123-144],(144,150]之间的空间 |
6 | rollback; | 申请插入意向锁,阻塞 | ||
7 | rollback; | 申请插入意向锁,阻塞 | ||
8 | 成功获取锁,并插入 | deadLock |
T1: insetrt后,存在lock_key=140, lock_biz='AccoutUser'的X记录锁
T2: 与T1发生唯一键冲突,故加上S Next-key Lock(也就是lock mode S waiting),锁住(123-140],(140,150]之间的空间。
T3: insert后,存在lock_key=144, lock_biz='AccoutUser'的X记录锁
T4: 与T3发生唯一键冲突,故加上S Next-key Lock(也就是lock mode S waiting),锁住(123-144],(144,150]之间的空间。
T2:T1 回滚后,T2与T4锁冲突,等待T4 S-Next-key Lock锁释放,然后申请意向锁,在日志中显示lock_mode X locks gap before rec insert intention waiting.
T4:T3回滚后,T2和T4同时申请意向锁,死锁出现。
通过show engine innodb status;命令查看死锁日志,可以看到与线上表现一致。
------------------------ LATEST DETECTED DEADLOCK ------------------------ 2022-02-15 20:34:19 0x70000ec62000 *** (1) TRANSACTION: TRANSACTION 8501, ACTIVE 10 sec inserting mysql tables in use 1, locked 1 LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1 MySQL thread id 5, OS thread handle 123145550733312, query id 93 localhost root update insert into `test_lock`( `gmt_create` ,`gmt_modified` ,`lock_key` , `lock_biz` ) VALUE (now(),now(), '144', 'AccountUser') *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 23 page no 4 n bits 80 index idx_uk_lock_name of table `dianjing_test`.`test_lock` trx id 8501 lock_mode X locks gap before rec insert intention waiting Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0 0: len 3; hex 313530; asc 150;; 1: len 11; hex 6163636f756e7455736572; asc accountUser;; 2: len 8; hex 8000000000000032; asc 2;; *** (2) TRANSACTION: TRANSACTION 8495, ACTIVE 31 sec inserting mysql tables in use 1, locked 1 5 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1 MySQL thread id 3, OS thread handle 123145550176256, query id 89 localhost root update insert into `test_lock`( `gmt_create` ,`gmt_modified` ,`lock_key` , `lock_biz` ) VALUE (now(),now(), '140', 'AccountUser') *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 23 page no 4 n bits 80 index idx_uk_lock_name of table `dianjing_test`.`test_lock` trx id 8495 lock mode S locks gap before rec Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0 0: len 3; hex 313530; asc 150;; 1: len 11; hex 6163636f756e7455736572; asc accountUser;; 2: len 8; hex 8000000000000032; asc 2;; *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 23 page no 4 n bits 80 index idx_uk_lock_name of table `dianjing_test`.`test_lock` trx id 8495 lock_mode X locks gap before rec insert intention waiting Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0 0: len 3; hex 313530; asc 150;; 1: len 11; hex 6163636f756e7455736572; asc accountUser;; 2: len 8; hex 8000000000000032; asc 2;;
小注解:mysql的锁,放在系统库information_schema的INNODB_LOCKS和INNODB_LOCK_WAITS两个表中,可直接select查看。
业务场景分析
复现出问题,在结合业务流程去排查和分析,发现问题出自结算域的多个定时任务中,这些定时任务的使用分布式定时框架,执行模式为网格计算,结算的每条记录都有主键id, 活动id和商家id以及用于计算结算金额的投放数据uv和pv等,原定时任务的处理逻辑是by最细粒度的活动数据主键id步进分发到各个机器上,每个机器通过id查找计算应结算金额,然后开启事务-》加锁-》执行扣款操作(涉及解冻等复杂逻辑,会进行多表操作)-》释放锁-》提交事务。
注:网格计算本质上是通过并发提来提升任务的处理速度,分为主任务和子任务,主任务负责按照自定义规则分发子任务到各个机器上,子任务互不干涉同步运行。
解决方案
通过业务场景梳理,很容易就发现2个设计问题-并发和大事务。
并发
因配置不当,人为造成高并发场景。针对并发我们进行了两种方式的改造:
- 针对只有少量数据的结算任务,改成单机运行,实例并发数设置为1。
- 针对大数据量的结算任务,主任务的分发逻辑从基于id步进分发,改为基于sellerId纬度分发,原因是结算是基于商家纬度进行的,分布式锁的纬度也是sellerId。
大事务
大事务导致独占区变大(加解锁的逻辑也归属与独占区了),增加了冲突概率,相当于变相提升了并发度。
针对大事务问题,因为本质上锁的方案和业务执行逻辑完全无关,这里将技术问题和业务逻辑进行了耦合,故按照解耦的思路将加锁和解锁操作开启独立子事务,减少冲突的概率。
参考文献
- MySQL锁介绍与加锁分析
- insert 语句加锁机制(https://cloud.tencent.com/developer/article/1181532?from=14588)
- 如何阅读死锁日志(https://cloud.tencent.com/developer/article/1181190)
- MySQL死锁案例分析(https://cloud.tencent.com/developer/article/1892524?from=article.detail.1181187)
- MySQL · 引擎分析 · InnoDB行锁分析(http://mysql.taobao.org/monthly/2018/05/04/)
- MySQL锁系列(七)之 锁算法详解(http://keithlan.github.io/2017/06/21/innodb_locks_algorithms/)
- mysql 查看谁在持有锁(http://www.javashuo.com/article/p-wixmuvea-co.html)