基于DB的分布式锁

317 阅读7分钟

本文的讨论基于MySQL InnoDB引擎。

互斥锁

锁的基本特性是互斥性,可以利用数据库的锁来实现这种互斥性。

  • 创建数据库表
CREATE TABLE biz_lock
(
    id           bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
    biz_id       varchar(64)         NOT NULL COMMENT '业务单号',
    biz_type     varchar(64)         NOT NULL COMMENT '业务类型',
    gmt_create   timestamp DEFAULT current_timestamp COMMENT '创建时间',
    gmt_modified timestamp DEFAULT current_timestamp ON UPDATE current_timestamp COMMENT '修改时间',
    PRIMARY KEY (id),
    UNIQUE KEY uk_biz_id_type (biz_id, biz_type)
) CHARSET = utf8mb4 COMMENT = '简单业务锁'
  • biz_type+biz_id唯一确定一把锁。

基于唯一索引

  • 加锁
insert into biz_lock(biz_id, biz_type, locked_time) value ('1','ORDER','2021-07-26 12:00:00');

插入时,会获取唯一索引的记录锁。

如果此时有其它线程试图加锁,因为唯一索引约束会报错[23000][1062] Duplicate entry '1-ORDER' for key 'biz_lock.uk_biz_id_type',加锁失败。

  • 解锁
delete from biz_lock where biz_type = 'ORDER' and biz_id = '1';

锁记录已经从数据库表中移除,此时其它线程请求加锁,将会有一个线程成功。

select...for update

基于一条已存在的记录获取记录锁。

  • 加锁
begin ;
select * from biz_lock where biz_type = 'ORDER' and biz_id = '1' for update nowait ;

开启事务,使用select...for update获取记录锁,设置nowait获取锁失败时立即返回,不会阻塞。如果返回预期的行记录,则加锁成功。

此时,如果有另一个线程也在事务中执行该操作,则select语句返回错误信息[HY000][3572] Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set.,加锁失败。

  • 解锁
commit ;
rollback ;

事务提交/回滚后会自动释放事务持有的全部锁。

缺陷

  1. select...for update必须在事务中使用,否则语句执行成功的同时,获取到的记录锁也会释放,达不到加锁的目的。
  2. 由于第一点限制,锁的范围要尽可能小,且不应执行耗时的操作(例如RPC)。
  3. 需要定时清理无用的锁记录。

综上所述,基于唯一索引的实现方式更为简单、灵活,适用范围更广。下文的讨论默认都是基于唯一索引的DB锁实现。

可重入

可重入的含义是:如果某个线程已经持有了一把锁,那么它可以再次获得这把锁且不会出现死锁。

实现可重入的基本思路:

  • 加锁

    1. 首次加锁成功,记录锁的持有者线程为当前线程,重入次数=1;
    2. 如果锁已被某个线程持有,判断持有者是否为当前线程。如果是,则重入次数加1,加锁成功;否则,加锁失败;
  • 解锁

    1. 判断锁的持有者是否为当前线程,如果不是,解锁失败;
    2. 否则,锁的可重入次数减1。如果剩余可重入次数大于0,则直接返回;否则,删除锁记录。

基于DB行记录

增加表字段

  • owner:锁持有者
  • reentrant:重入次数

修改后建表语句如下:

CREATE TABLE biz_lock
(
    id           bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
    biz_id       varchar(64)         NOT NULL COMMENT '业务单号',
    biz_type     varchar(64)         NOT NULL COMMENT '业务类型',
    owner        varchar(64)         not null comment '持有者线程',
    reentrant    int                 not null default 1 comment '重入次数',
    gmt_create   timestamp                    DEFAULT current_timestamp COMMENT '创建时间',
    gmt_modified timestamp                    DEFAULT current_timestamp ON UPDATE current_timestamp COMMENT '修改时间',
    PRIMARY KEY (id),
    UNIQUE KEY uk_biz_id_type (biz_id, biz_type)
) CHARSET = utf8mb4 COMMENT = '简单业务锁'
  • 加锁
public boolean lock(String bizType, String bizId) {
    // 线程ID + JVM实例在分布式环境下唯一定位一个线程
    String owner = Thread.currentThread().getId() + "@" + ManagementFactory.getRuntimeMXBean().getName();
    try {
        // 插入锁记录, 设置持有者为当前线程, 重入次数=默认值1
        jdbcTemplate.update("insert into biz_lock (biz_type, biz_id, owner) values (?, ?, ?)", bizType, bizId, owner);
        return true;
    } catch (DuplicateKeyException e) {
        // 锁记录已存在, 如果持有者是当前线程, 则重入次数加1
        int i = jdbcTemplate.update("update biz_lock set reentrant = reentrant + 1 where biz_type = ? and biz_id = ? and owner = ?", bizType, bizId, owner);
        // 更新失败代表持有者不是当前线程, 加锁失败
        return i == 1;
    }
}
  • 解锁
public void unlock(String bizType, String bizId) {
    // 线程ID + JVM 实例在分布式环境下唯一定位一个线程
    String owner = Thread.currentThread().getId() + "@" + ManagementFactory.getRuntimeMXBean().getName();
    // 如果持有者是当前线程, 可重入次数减1
    int i = jdbcTemplate.update("update biz_lock set reentrant = reentrant - 1 where biz_type = ? and biz_id = ? and owner = ?", bizType, bizId, owner);
    // 更新失败代表当前线程未持有锁
    if (i == 0) {
        throw new IllegalMonitorStateException();
    }
    // 更新成功检查可重入次数
    Integer reentrant = jdbcTemplate.queryForObject("select reentrant from biz_lock where  biz_type = ? and biz_id = ? and owner = ?", new Object[]{bizType, bizId, owner}, Integer.class);
    // 可重入次数等于0, 删除锁记录
    if (reentrant == 0) {
        i = jdbcTemplate.update("delete from biz_lock where  biz_type = ? and biz_id = ? and owner = ?", bizType, bizId, owner);
        Assert.isTrue(i == 1);
    }
    // 以上的查询、判断、更新操作是安全的, 因为锁被当前线程持有, 其它线程无法更新锁记录
}

基于原生的JVM锁

可重入的关键是要能感知到持有锁的线程是否是当前线程。而JVM锁java.util.concurrent.locks.ReentrantLock 已经实现了该功能,因此只需要在基于唯一索引的DB锁实现上以装饰者模式增加可重入的职责即可。

image.png

死锁风险

唯一索引死锁

试想以下场景:

线程1:插入记录成功,事务提交; 线程2:插入失败,拿到唯一索引的独占锁; 线程3:插入失败,拿到唯一索引的共享锁; 线程2:请求更新,等待唯一索引的独占锁; 线程3:请求更新,等待唯一索引的独占锁;

线程2独占锁(持有) ← 线程3共享锁(持有)← 线程2独占锁(等待)← 线程3独占锁(等待)。

此时线程2持有独占锁,等待线程3释放共享锁;线程3持有共享锁,等待线程2释放独占锁。死锁形成。

因此,最好不要在同一个事务中对唯一索引执行插入和更新操作,否则并发场景下很容易形成死锁。

锁未释放引发死锁

如果获得DB锁的线程对应的服务器宕机了会发生什么?——这个锁将永远无法释放,其它服务器对该锁的请求也将失败。为了应对这种情况,需要给锁加上超时时间。

数据库表新增字段timeout

CREATE TABLE biz_lock
(
    id           bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
    biz_id       varchar(64)         NOT NULL COMMENT '业务单号',
    biz_type     varchar(64)         NOT NULL COMMENT '业务类型',
    timeout      timestamp           not null comment '超时时间',
    gmt_create   timestamp DEFAULT current_timestamp COMMENT '创建时间',
    gmt_modified timestamp DEFAULT current_timestamp ON UPDATE current_timestamp COMMENT '修改时间',
    PRIMARY KEY (id),
    UNIQUE KEY uk_biz_id_type (biz_id, biz_type)
) CHARSET = utf8mb4 COMMENT = '简单业务锁'

加锁时,如果唯一索引冲突,继续检查锁记录:如果当前时间 > 锁的超时时间(已过期),则更新锁的超时时间为本次请求的超时时间,更新成功则加锁成功(更新时使用乐观锁保证并发更新安全)。

锁超时时间又会引入新的问题:如果业务逻辑因为GC或者网络延时等原因没有在预期的时间内执行完成,此时另一个线程判断锁已过期,成功获得了锁。那么锁的互斥性就被打破了,并发安全问题将会再次出现。

最佳实践

在事务中使用基于DB的锁

加锁的时机和位置是自由的,如果在事务中使用基于唯一索引的DB锁会发生什么呢?

假设线程1在事务中插入了锁记录,且无唯一索引冲突,那么该事务持有唯一索引的互斥记录锁; 此时线程2在事务中插入锁记录时,将会因获取唯一索引的记录锁而阻塞,直到线程1的事务结束释放记录锁。

大部分情况下,我们希望事务中不要发生阻塞,如果获取锁失败,直接抛出异常,事务回滚。

在Spring管理的事务中,可以将DB锁涉及的操作放在一个传播机制为PROPAGATION_REQUIRES_NEW的事务中:

<aop:aspectj-autoproxy/>
<aop:config>
    <aop:advisor advice-ref="bizLockAdvice"
                 pointcut="execution(* com.alipay.pcloan.common.lock.repository.BizLockDao.*(..))"/>
</aop:config>
<tx:advice id="bizLockAdvice">
    <tx:attributes>
        <tx:method name="insert*" propagation="REQUIRES_NEW"/>
        <tx:method name="delete*" propagation="REQUIRES_NEW"/>
        <tx:method name="update*" propagation="REQUIRES_NEW"/>
    </tx:attributes>
</tx:advice>

锁涉及的DB操作在一个独立的物理事务中,可以无视外层事务直接提交或回滚;外层事务也不会受锁操作事务状态的影响。

总结

特性描述
排他性支持
可重入支持
死锁可避免
阻塞和非阻塞非阻塞;不支持阻塞
公平锁和非公平锁非公平锁;不支持公平锁
高可用、高性能高可用依赖DB的高可用主备;性能较基于缓存的分布式锁低