background
背景:两台服务企图通过MySQL select for update来作为分布式锁,保证Schedule只有一个执行。
问题:select for update有时候锁不住,即两台服务同时执行了Schedule任务。
下面通过实验方式去复现它。
首先直觉,for update可以重入应该是 查询了不存在的行,进而获取到的是gap lock,而gap lock之间是不互斥的。所以,就有了上面的现象。
表如下:
1
2
3
4
5
6
7
| CREATE TABLE `test` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`status` varchar(255) DEFAULT NULL,
`name` varchar(255) DEFAULT '',
PRIMARY KEY (`id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
status 可能为:PENDING、SUCCESS、FAILED
只有一条数据时
1
2
3
4
5
6
7
| session1:
select * from test where status = 'PENDING' or status = 'FAILED' for update; // no block
session2:
select * from test where status = 'PENDING' or status = 'FAILED' for update; // block
|
这里session2会block住,这里很奇怪,一条不存在的记录,应该是加的gap lock,为什么会block住呢。
这时候,就要从select for update的语义出发,它本质也是查询,也会走优化器,走不走索引和数据有很大关系。
数据少时,优化器觉得直接去扫cluster index树 会比 读secondary index再回表效率高。
数据多时,优化器会去secondary index再回表。
下面在看下现象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| mysql> select * from information_schema.INNODB_LOCKS \G
*************************** 1. row ***************************
lock_id: 2995:33:3:2
lock_trx_id: 2995
lock_mode: X
lock_type: RECORD
lock_table: `go_dev`.`test`
lock_index: PRIMARY
lock_space: 33
lock_page: 3
lock_rec: 2
lock_data: 1
*************************** 2. row ***************************
lock_id: 2994:33:3:2
lock_trx_id: 2994
lock_mode: X
lock_type: RECORD
lock_table: `go_dev`.`test`
lock_index: PRIMARY
lock_space: 33
lock_page: 3
lock_rec: 2
lock_data: 1
2 rows in set, 1 warning (0.00 sec)
|
我们在explain下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| mysql> explain select * from test where status = 'PENDING' or status = 'FAILED' for update \G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: test
partitions: NULL
type: ALL
possible_keys: idx_status
key: NULL
key_len: NULL
ref: NULL
rows: 2
filtered: 100.00
Extra: Using where
1 row in set, 1 warning (0.00 sec)
|
看到这个查询仍然是ALL,优化器采用的是全表扫描,进而for update锁住的是所有行。现在有1条SUCCESS的数据,这条数据就会锁住。如果有2个SUCCESS的数据,这2条也都会锁住,因为explain它们走的是全表扫描。
多条数据,让优化器走索引
先插入50条SUCCESS的数据,我们在explain下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| mysql> explain select * from test where status = 'PENDING' or status = 'FAILED' for update \G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: test
partitions: NULL
type: range
possible_keys: idx_status
key: idx_status
key_len: 1023
ref: NULL
rows: 2
filtered: 100.00
Extra: Using index condition
1 row in set, 1 warning (0.00 sec)
|
看到数据多了,优化器就会选择走secondary index再回表查询。这时候用到了索引,那for update会怎么样呢。
1
2
3
4
5
6
7
| session1:
select * from test where status = 'PENDING' or status = 'FAILED' for update; // no block
session2:
select * from test where status = 'PENDING' or status = 'FAILED' for update; // no block
|
走了索引后,由于数据都是SUCCESS的,所以又是for update一条不存在的记录,进而是gap lock,它本身之间是可重入的。
这种情况,就会导致锁失效,在并发锁资源的时候不可用。
数据中有了PENDING或FAILED的数据
继续在上面50条SUCCESS基础上,加上一个51-PENDING 和 52-FAILED的数据。
1
2
3
4
5
6
7
| session1:
select * from test where status = 'PENDING' or status = 'FAILED' for update; // no block
session2:
select * from test where status = 'PENDING' or status = 'FAILED' for update; // block
|
看下此时的lock,锁住了id 52, status FAILED的数据。注意MySQL动态加锁的观点,现在看innodb_locks只有52-FAILED的数据,其实51-PENDING的数据也会锁住。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| mysql> select * from information_schema.INNODB_LOCKS \G
*************************** 1. row ***************************
lock_id: 3012:33:4:54
lock_trx_id: 3012
lock_mode: X
lock_type: RECORD
lock_table: `go_dev`.`test`
lock_index: idx_status
lock_space: 33
lock_page: 4
lock_rec: 54
lock_data: 'FAILED', 52
*************************** 2. row ***************************
lock_id: 3011:33:4:54
lock_trx_id: 3011
lock_mode: X
lock_type: RECORD
lock_table: `go_dev`.`test`
lock_index: idx_status
lock_space: 33
lock_page: 4
lock_rec: 54
lock_data: 'FAILED', 52
2 rows in set, 1 warning (0.00 sec)
|
总结 与 shedlock
OK,上面的三种情况基本分析了原因。当然select for update仍然是可以锁记录的,只是在这个记录不存在的时候会有些问题,做不了distributed-lock。所以,以后要想分布式锁资源,需要仔细考虑:
- 数据少时,即使要查的行不存在,select for update也会block,因为此时没走索引。
- 数据多时,要查的行不存在,select for update不会block,因为此时走了索引,进而是gap lock,不相互block。
那我们在infrastructure只允许有MySQL的情况下,仍然需要distributed-lock如何做呢。可以使用shedlock
通过源码StorageBasedLockProvider与JdbcTemplateStorageAccessor理解,它通过update where来锁记录(当然第一次是insert与update互斥来锁记录)。
shedlock优势
ShedLock的优势在于:在第一次数据不存在的时候,通过insert与update互斥来锁记录。而且它引入了lock_until的字段。
如下SQL:
1
2
3
4
5
| // session1
insert into `shedlock` (`name`, `lock_until`, `locked_at`, `locked_by`) values ('test', '2019-12-17 14:14:13', '2019-12-17 13:14:13', 'user');
// session2
update shedlock set lock_until = "2019-12-17 14:14:13", locked_at = '2019-12-17 13:14:13' where name = 'test' and lock_until <= '2019-12-17 9:14:13';
|
注意update操作 where name = 'test' and lock_until <= NowTime
也就是说,insert成功后会留一个lock_until字段,含义是session1我想要持有这个锁直到某个时间,而session2在session1 commit之后,获得到锁,此时它的SQL的where lock_until=now,也就是说session2.lock_until < session1.lock_until。
所以,session2 update条数=0,也就是没GET到锁。
注意:JdbcTemplateStorageAccessor的TransactionTemplate的spring事务级别是PROPAGATION_REQUIRES_NEW,也就是说,我们去锁记录这个操作是单独的Transaction,不和业务事务共用一个Transaction。也就是说,session1获取到锁后,在执行到业务流程时候,session2的update语句已经不再block了,但是它update where条件筛选出来的是0条,所以也是获取锁失败,进而继续retry。
lockAtMostUntil 与 lockAtLeastUntil
shedlock两个比较难理解的参数:lockAtMostUntil 、lockAtLeastUntil。
lockAtMostUntil用来设置要持有锁直到多久,它的含义,提供了自动release锁的机制。即在这个时间之后,其他update可以获取到锁。也可以理解它的应用就是一个心跳机制,用来表示我master还活着,参考。我们上下文中,不希望自动释放机制,因为有可能session1 task在until_time里还没有执行完,这时候session2在超过until_time的时候就获取到了锁,执行task。此时session1也在执行task,就不满足同一时刻只有一个task在运行。所以,要想实现同一时刻只有一个task在运行,就必须要让session1一直持有锁,知道任务执行完,否则不释放。所以,可以将lockAtMostUntil设置很大(也不要supernum,避免意外情况失效不了),在任务执行完时,主动unlock锁,设置until_time为当前时刻。
lockAtLeastUntil的作用用于unlock操作,在unlock时候,如果你还想保持一段时间,让锁自动释放,就可以设置lockAtLeastUntil。代码里会去判断:return lockAtLeastUntil.isAfter(now) ? lockAtLeastUntil : now;
所以,到这里对distributed-lock又有一层思考。提供自动release机制,来避免一直占用不释放带来的deadlock。
这个SQL巧妙的设计,实现了类似乐观锁的感觉,update不到数据,表示已被别人修改,重试就好了。也类似到etcd set key ttl with PrevValue check用来做master保证心跳。
thoughts
而现实生活中,有了distributed-lock还不够,它只能保证同一时刻只有一个task在运行,而无法保证一天内task只能被run一次。所以,保证一天内task只能被run一次看起来是个业务需求,它不应该和distributed-lock放到一起,进而推导出 需要一个业务的表去标识 task每天被run的情况,操作这张表的前提是GET到distributed-lock,所以也保证了唯一性。