Contents

Distributed Lock With MySQL

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

只有一条数据时

idstatusname
1SUCCESSname
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,所以也保证了唯一性。