分布式锁阐述

在搞明白分布式锁之前,先明白其由来。

谈到分布式锁自然也就能联想到分布式应用。

  1. 在将应用拆分为分布式应用之前,是单机系统,在单机系统中的并发场景为单进程多线程模式

    • 采用加锁或者非阻塞同步或者无锁同步可以简单的实现同步操作
  2. 将应用拆分为分布式应用之后,并发场景变成了多进程+多线程的模式

    业界常用的解决方案通常是借助于一个第三方组件并==利用它自身的排他性来达到多进程的互斥==。如:

    • 基于DB的唯一索引

    • 基于ZK的临时节点

    • 基于Redis的NX EX参数

      • EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value
      • PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value
      • NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value
      • XX :只在键已经存在时,才对键进行设置操作。

一个良好的分布式锁,要解决如下的几个核心的问题:

  1. 可用问题: 无论何时都要保证锁服务的可用性(这是系统正常执行锁操作的基础)。
  2. 死锁问题: 客户端一定可以获取锁,即使其他客户端获取锁之后,在释放锁之前宕机了。
  3. 脑裂问题: 集群同步时产生的数据不一致或者故障转移时,导致新的进程有可能拿到锁,但之前的进程以为自己还有锁,那么就出现两个进程拿到了同一个锁的问题。
  4. 可重入: 一个节点获取了锁之后,还可以再次获取整个锁资源。

MySQL实现分布式锁

基于MySQL的方案,一般分为3类:基于表记录乐观锁悲观锁

基于表记录

最直观的形式就是创建一张表,然后在表里面执行操作,获取锁时在记录中新增一条记录,释放锁的时候再把该记录删除即可。

DDL:

CREATE TABLE `database_lock` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `resource` int NOT NULL COMMENT '锁定的资源',
    `description` varchar(1024) NOT NULL DEFAULT "" COMMENT '描述',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uiq_idx_resource` (`resource`) 
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';

因为添加了唯一索引,所以能够保证排他性,同一时间只有一个客户端获取到共享资源(报错:ERROR 1062 (23000): Duplicate entry ‘1’ for key ‘uiq_idx_resource’)。

获取锁:

INSERT INTO database_lock(resource, description) VALUES (1, 'lock');

释放锁:

DELETE FROM database_lock WHERE resource = 1;

注意事项:

  • 没有时效时间,如果客户端获取锁之后,宕机之后,或者其他释放锁操作失败,就会导致其他客户端无法获取锁
    • 可以做一个定时任务去定时清理。
  • 这种锁的可靠性依赖于数据库。建议设置备库,避免单点,进一步提高可靠性
  • 这种锁是非阻塞的,因为插入数据失败之后会直接报错,想要获得锁就需要再次操作。如果需要阻塞式的,可以弄个for循环、while循环之类的,直至INSERT成功再返回
  • 这种锁也是非可重入的,因为同一个线程在没有释放锁之前无法再次获得锁,因为数据库中已经存在同一份记录了。想要实现可重入锁,可以在数据库中添加一些字段,比如获得锁的主机信息、线程信息等,那么在再次获得锁的时候可以先查询数据,如果当前的主机信息和线程信息等能被查到的话,可以直接把锁分配给它。

基于乐观锁

DDL:

CREATE TABLE `optimistic_lock` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `resource` int NOT NULL COMMENT '锁定的资源',
    `version` int NOT NULL COMMENT '版本信息',
    `created_at` datetime COMMENT '创建时间',
    `updated_at` datetime COMMENT '更新时间',
    `deleted_at` datetime COMMENT '删除时间', 
    PRIMARY KEY (`id`),
    UNIQUE KEY `uiq_idx_resource` (`resource`) 
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';

在使用乐观锁之前要确保表中有相应的数据,比如:

INSERT INTO optimistic_lock(resource, version, created_at, updated_at) VALUES(20, 1, CURTIME(), CURTIME());

在单线程的情况中,流程如下:

  1. 获取资源: select resource from optimistic_lock where id = 1;
  2. 执行业务逻辑
  3. 更新资源:update optimistic_lock set resource = resource - 1 where id = 1

不过在多线程的情况下,可能会出现超减的问题。所以我们可以通过一个version或者时间戳来进行控制,类似CAS

  1. 获取资源:SELECT resource, version FROM optimistic_lock WHERE id = 1
  2. 执行业务逻辑
  3. 更新资源:UPDATE optimistic_lock SET resource = resource -1, version = version + 1 WHERE id = 1 AND version = oldVersion

虽然本身并没有利用到数据库自身的锁机制,不影响请求性能,但是,并发量大的时候,会导致大量的请求失败。同时因为都是作用在同一条记录上,在一些特殊场景,如大促、秒杀等活动开展的时候,大量的请求同时请求同一条记录的行锁,会对数据库产生很大的写压力。所以综合数据库乐观锁的优缺点,乐观锁比较适合并发量不高,并且写操作不频繁的场景

基于悲观锁

除了可以通过增删操作数据库表中的记录以外,我们还可以借助数据库中自带的锁来实现分布式锁。在查询语句后面增加FOR UPDATE,数据库会在查询过程中给数据库表增加悲观锁,也称排他锁。当某条记录被加上悲观锁之后,其它线程也就无法再改行上增加悲观锁。

步骤如下:

  1. 获取资源 SELECT * FROM database_lock WHERE id = 1 FOR UPDATE
  2. 执行业务逻辑
  3. 释放锁 COMMIT

注意事项:

避免锁升级到表锁。所以需要明确能够走到索引。

在悲观锁中,每一次行数据的访问都是独占的,只有当正在访问该行数据的请求事务提交以后,其他请求才能依次访问该数据,否则将阻塞等待锁的获取。悲观锁可以严格保证数据访问的安全。但是缺点也明显,即每次请求都会额外产生加锁的开销且未获取到锁的请求将会阻塞等待锁的获取,在高并发环境下,容易造成大量请求阻塞,影响系统可用性。另外,悲观锁使用不当还可能产生死锁的情况。

Redis实现分布式锁

单机Redis实现分布式锁

获取锁:

SET resource_name my_random_value NX PX 30000

上面的命令如果执行成功,则客户端成功获取到了锁,接下来就可以访问共享资源了;而如果上面的命令执行失败,则说明获取锁失败。

SET命令阐述:

  • resource_name 可以视为一个共享资源
  • my_random_value 通过客户端自己生成一个随机符号,作为唯一标志符,避免错误解锁问题
  • NX 若当前key已经存在,则不执行任何操作,若当前key不存在,执行命令,也就是set操作。这保证了只有第一个请求的客户端才能获得锁,而其它客户端在锁被释放之前都无法获得锁(很好的排他性)。
  • PX 3000 表示这个锁有一个30秒的自动过期时间。当然,这里30秒只是一个例子,客户端可以选择合适的过期时间。

释放锁:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

LUA命令阐述:

  • 前面的my_random_value作为ARGV[1]传入,然后把resource_name作为KEY[1]传进来

单机Redis实例实现分布式锁的一些核心点:

  • 锁必须要设置一个过期时间(锁的有效时间),避免客户端获取锁之后崩溃或者出现网络分割等问题导致的其无法和Redis实例继续通信,最后导致其他客户端都无法获取锁

  • 获取锁操作要保证原子性

    • 上述获取锁的语义分为两步骤: set 、设置过期时间

      SETNX resource_name my_random_value
      EXPIRE resource_name 30
      

      但分为两个命令的话,无法保证原子性操作(set之后,客户端宕机了,则会一直持有锁),上述的获取锁操作就是一个命令,也就具有原子性了。

  • my_random_value是必须的

    • 通过my_random_value作为每个客户端的唯一标识符,可以有效的解决错误解锁的问题
      • 客户端A获取锁,然后执行操作
      • 客户端A在某个操作上阻塞了很久,锁的有效时间超过了,自动释放锁
      • 客户端B成功获取锁,执行自己的业务
      • 客户端A从阻塞中恢复,然后释放了客户端B的锁
  • 释放锁要保证原子性

    • 对于释放锁来说,共有三个步骤:查询判断删除,和获取锁同理,也要保证原子性。为了实现原子性操作,可以通过LUA脚本来做

      if redis.call("get", KEY[1]) == ARGV[1] then
            return redis.call("del", KEY[1])
      else 
            return 0;
      end
      
      • 如果无法保证原子性的话,会出现如下的情况
        1. 客户端A成功获取到了锁
        2. 客户端A对共享资源进行操作
        3. 客户端A执行结束,开始释放锁,先执行GET操作获取锁,然后进行比较客户端自身的随机值比较,符合预期
        4. 客户端A因为某些原因,发生了阻塞
        5. 超过了锁的使用时间,客户端B在锁被超时释放后,成功获取锁
        6. 客户端A从阻塞中恢复过来,执行最后一步,也就是DEL操作,结果错误释放了客户端B获取的锁。

分布式锁RedLock

以上问题,在实现分布式锁时,稍加注意就能够很好的的解决,但是有一种情况很难解决,其是由failover问题导致的,具体如下:

failover引起的问题

  1. 客户端1从Master中获取锁
  2. Master宕机了,并且Master的数据还同步到Slave上
  3. Slave升级为Master
  4. 客户端2从新的Master中获取到了对应同一个资源的锁

这样就导致了客户端A和客户端B同时获取了同一个资源的锁,这样就违背了分布式锁的语义了,锁的安全性被打破了。针对这个问题,antirez设计了Redlock算法。

获取锁步骤:

  1. 获取系统当前的时间(毫秒级别)
  2. 按照顺序依次向N个Redis实例执行获取锁操作
    • 整体流程和基于单个Redis实例获取锁相同,包含有random_value和过期时间(PX 3000 也称为有效时间)
    • 同时为了保证某个Redis实例宕机时,RedLock能够正常工作,对于每次获取锁操作,还要有一个超时时间,并且要远小于整个锁的有效时间。客户端在当前Redis实例获取锁失败(可能是宕机、可能时网络超时)的话,需要立即尝试在后续的Redis的实例上获取锁。
    • 计算客户端获取锁的过程中,共消耗了多少时间。计算方式为:访问N个Redis实例之后的时间 - 第一步开始时的时间。同时保证 成功获取了 $N/2 + 1$个Redis实例上获取到了锁。那么认为获取锁成功, 否则视为失败。
    • 获取锁成功,对应的锁的有效时间要重新计算。它等于最开始设置的有效时间减去获取锁消耗的时间。
    • 获取锁失败,要求客户端要对所有的Redis实例进行释放锁操作(Lua脚本保证原子性)。

释放锁的步骤

客户端要对所有的Redis实例进行释放锁操作(Lua脚本保证原子性),不管这些节点当时在获取锁的时候成功与否。

ZooKeeper实现分布式锁

  1. 客户端创建一个znode节点,比如/lock,那么就意味着客户端获取锁成功了。其他客户端获取锁失败(znode已经存在)
  2. 持有锁的客户端业务逻辑执行完成之后,会删除znode,这样其他的客户端就接下来就能获取锁了
  3. znode应该被创建成ephemeral(临时的)的。这是znode的一个特性,它保证如果创建znode的那个客户端崩溃了,那么相应的znode会被自动删除。这保证了锁一定会被释放。

那么问题来了,ZooKeeper是如何检测到客户端是否崩溃呢?实际上每个客户端和ZooKeeper的某个服务器都维护一个 Session,这个Session通过心跳检测来维持,如果ZooKeeper长时间获取不到客户端的心跳(Session的过期时间),那么就认为Session过期了,同时会该Session对应的ephemeralznode都会自动删除。

设想如下的执行序列:

  1. 客户端1创建了znode节点/lock,获得了锁。
  2. 客户端1进入了长时间的GC pause。
  3. 客户端1连接到ZooKeeper的Session过期了。znode节点/lock被自动删除。
  4. 客户端2创建了znode节点/lock,从而获得了锁。
  5. 客户端1从GC pause中恢复过来,它仍然认为自己持有锁。

最后,客户端1和客户端2都认为自己持有了锁,冲突了。这与Martin在文章中描述的由于GC pause导致的分布式锁失效的情况类似。

看起来,用ZooKeeper实现的分布式锁也不一定就是安全的,该有的问题它还是有。但是,ZooKeeper作为一个专门为分布式应用提供方案的框架,它提供了一些非常好的特性,是Redis之类的方案所没有的。像前面提到的ephemeral类型的znode自动删除的功能就是一个例子。

还有一个很有用的特性是ZooKeeperwatch机制。这个机制可以这样来使用,比如当客户端试图创建/lock的时候,发现它已经存在了,这时候创建失败,但客户端不一定就此对外宣告获取锁失败。客户端可以进入一种等待状态,等待当/lock节点被删除的时候,ZooKeeper通过watch机制通知它,这样它就可以继续完成创建操作(获取锁)。这可以让分布式锁在客户端用起来就像一个本地的锁一样:加锁失败就阻塞住,直到获取到锁为止。这样的特性Redlock就无法实现。

基于ZooKeeper的锁和基于Redis的锁相比在实现特性上有两个不同:

  • 在正常情况下,客户端可以持有锁任意长的时间,这可以确保它做完所有需要的资源访问操作之后再释放锁。这避免了基于Redis的锁对于有效时间(lock validity time)到底设置多长的两难问题。实际上,基于ZooKeeper的锁是依靠Session(心跳)来维持锁的持有状态的,而Redis不支持Sesion。
  • 基于ZooKeeper的锁支持在获取锁失败之后等待锁重新释放的事件。这让客户端对锁的使用更加灵活。

总结

摘自张铁蕾的博客

按照锁的两种用途,如果仅是为了效率(efficiency),那么你可以自己选择你喜欢的一种分布式锁的实现。当然,你需要清楚地知道它在安全性上有哪些不足,以及它会带来什么后果。而如果你是为了正确性(correctness),那么请慎之又慎。我们在分布式锁的正确性上走得最远的地方,要数对于ZooKeeper分布式锁、单调递增的epoch number以及对分布式资源进行标记的分析了。请仔细审查相关的论证。

References

  1. 基于数据库实现的分布式锁
  2. 基于Redis的分布式锁到底安全吗
  3. 分布式系统中,如何回答锁的实现原理