一、数据库实现分布式锁

1.1、基于唯一索引

1.1.1、实现逻辑

  • 获取锁时在数据库中insert一条数据,包括id、方法名(唯一索引)、线程名(用于重入)、重入计数
  • 获取锁如果成功则返回true
  • 获取锁的动作放在while循环中,周期性尝试获取锁直到结束或者可以定义方法来限定时间内获取锁
  • 释放锁的时候,delete对应的数据

1.1.2、问题

  • 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
  • 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
  • 非阻塞的?搞一个while循环,直到insert成功再返回成功。

1.2、基于悲观锁 for update

1.2.1、实现逻辑

  • 关闭自动提交

    1. set autocommit=0;
  • 添加排他锁

    1. select * from methodLock where method_name = xxx for update;
  • 解锁,提交事务

    1. // 提交事务,解锁
    2. commit;

    1.2.2、问题

  • 行级锁都是基于索引的,如果一条SQL语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住

  • 排他锁会占用连接,产生连接爆满的问题

    1.3、基于乐观锁

    乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。

  • 在表中添加版本号字段 version

  • 在更新是更新版本号
    1. update t_goods
    2. set status=2,version=version+1
    3. where id=#{id} and version=#{version};

1.4、注意

  • 使用mysql分布式锁,必须保证多个服务节点使用的是同一个mysql库。

二、Redis 实现分布式锁

2.1、使用 set 命令

2.1.1、加锁设置

  1. SET resource_name my_random_value PX 30000 NX
  2. EX seconds -- 设置过期时间
  3. PX milliseconds -- 设置过期时间,毫秒
  4. EXAT timestamp-seconds -- Set the specified Unix time at which the key will expire, in seconds.
  5. PXAT timestamp-milliseconds -- Set the specified Unix time at which the key will expire, in milliseconds.
  6. NX -- key 不存在时,设置
  7. XX -- key 存在时,设置
  8. KEEPTTL -- Retain the time to live associated with the key.
  9. GET -- 返回旧的值,设置新的值替换

2.1.2、解锁设置

  1. del

2.1.3、原子性问题

  • 使用 lua 脚本保证原子性操作

2.2、使用 redisson 实现

2.2.1、redisson 加锁过程

  1. # 判断key 是否存在
  2. if (redis.call('exists', KEYS[1]) == 0) then
  3. # 不存在,设置 hash key 值为 1
  4. redis.call('hincrby', KEYS[1], ARGV[2], 1);
  5. # 设置过期时间
  6. redis.call('pexpire', KEYS[1], ARGV[1]);
  7. return nil;
  8. end;
  9. if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
  10. # 存在,自增1
  11. redis.call('hincrby', KEYS[1], ARGV[2], 1);
  12. # 设置过期时间
  13. redis.call('pexpire', KEYS[1], ARGV[1]);
  14. return nil;
  15. end;
  16. return redis.call('pttl', KEYS[1]);

2.2.2、redisson 解锁过程

  1. if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
  2. # key 不存在,返回空
  3. return nil;
  4. end;
  5. # 减一
  6. local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
  7. if (counter > 0) then
  8. # 更新过期时间
  9. redis.call('pexpire', KEYS[1], ARGV[2]);
  10. return 0;
  11. else
  12. # counter <= 0 ,释放锁
  13. redis.call('del', KEYS[1]);
  14. # 推送key 过期事件
  15. redis.call('publish', KEYS[2], ARGV[1]);
  16. return 1;
  17. end;
  18. return nil;

2.2.3、自动续期问题

  • 实现逻辑

客户端A加锁的锁key默认生存时间只有30秒,如果超过了30秒,客户端A还想一直持有这把锁,怎么办?其实只要客户端A一旦加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果客户端A还持有锁key,那么就会不断的延长锁key的生存时间。

  • 底层任务存储数据结构
    • netty 实现的时间轮

2.3 、了解 Redlock 锁?

这个场景是假设有一个 Redis cluster,有 5 个 Redis master 实例。然后执行如下步骤获取一把锁:

  1. 获取当前时间戳,单位是毫秒;
  2. 跟上面类似,轮流尝试在每个 master 节点上创建锁,超时时间较短,一般就几十毫秒(客户端为了获取锁而使用的超时时间比自动释放锁的总时间要小。例如,如果自动释放时间是 10 秒,那么超时时间可能在 5~50 毫秒范围内);
  3. 尝试在大多数节点上建立一个锁,比如 5 个节点就要求是 3 个节点 n / 2 + 1 ;
  4. 客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了;
  5. 要是锁建立失败了,那么就依次之前建立过的锁删除;
  6. 只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁

分布式解决方案之分布式锁 - 图1

2.4、Redis 相关问题

  • 非原子性操作
    • 使用 Lua 脚本保证
  • 释放了别人的锁
    • 针对线程或服务添加唯一标识
  • 获取锁时间大于事务等待时间
    • 采用手动提交事务,回滚事务
  • 锁超时问题
    • 使用看门狗机制(自动续期)
  • redis主从复制的坑(暂时无法解决,尽量保证机器稳定)
    • A 客户端获取到了锁
    • 在主从复制过程中,redis master宕机了,此时主备切换,slave变为了redis master
    • B客户端从新的redis master中获取到了锁
    • 此时就会导致同一时间内多个客户端对一个分布式锁完成了加锁,导致各种脏数据的产生。

三、Zookeeper 实现分布式锁

3.1、Zookeeper 中的相关概念

3.1.1、底层数据结构

  • 分层命名空间

image.png

3.1.2、节点类型

  • 永久节点:不会因为会话结束或者超时而消失;
  • 临时节点:如果会话结束或者超时就会消失;不允许有子节点
  • 顺序节点:会在节点名的后面加一个数字后缀,并且是有序的,例如生成的有序节点为 /lock/node-0000000000,它的下一个有序节点则为 /lock/node-0000000001,从小到大排序。
  • 容器节点( 3.6.0添加):
    • 容器 znode 是特殊用途的 znode,可用于leader, lock等
    • 当容器znode 的最后一个字节被删除时,它将成为将来某个时候被服务器删除的候选节点。
  • TTL 节点(3.6.0):
    • 可以选择为 znode 设置一个 TTL(以毫秒为单位)。 如果 znode 在 TTL 内没有被修改并且没有子节点,它将成为将来某个时候被服务器删除的候选节点。
    • TTL 节点必须通过系统属性启用,因为默认情况下它们是禁用的

3.1.3、节点监听机制

每个线程抢占锁之前,先抢号创建自己的ZNode。同样,释放锁的时候,就需要删除抢号的Znode。抢号成功后,如果不是排号最小的节点,就处于等待通知的状态。等谁的通知呢?不需要其他人,只需要等前一个Znode 的通知就可以了。当前一个Znode 删除的时候,就是轮到了自己占有锁的时候。第一个通知第二个、第二个通知第三个,击鼓传花似的依次向后。

3.2、实现锁的方式

3.2.1、临时顺序节点实现公平锁

  • 创建临时顺序节点
  • 获得所有的子节点,并排序
  • 判断当前节点是否是最小的,如果是最小的节点,则表明此这个client可以获取锁
  • 序号不为最小则监听(watch)前一个顺序号
  • 当前一个顺序号被删除的时候表明锁被释放了,则会通知下一个客户端

    3.2.2、同名临时节点实现非公平锁

  • 某个节点尝试创建临时 znode

  • 创建成功了就获取了这个锁
  • 这个时候别的客户端来创建锁会失败,只能注册个监听器监听这个锁
  • 释放锁就是删除这个 znode

    3.2.3、相关开源项目

  • apache.curator

3.3、Zookeeper 实现分布锁相关问题

3.3.1、多客户获取锁问题

由于 zk 依靠 session 定期的心跳来维持客户端,如果客户端进入长时间的 GC,可能会导致 zk 认为客户端宕机而释放锁,让其他的客户端获取锁,但是客户端在 GC 恢复后,会认为自己还持有锁,从而可能出现多个客户端同时获取到锁的情形。

可以通过 JVM 调优,尽量避免长时间 GC 的情况发生

四、Redis 和 Zookeeper 作为分布式锁选型

  • ZK 保证一致性 CP
  • Redis Cluster 保证可用性 AP
  • redis 分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。
  • zk 分布式锁,获取不到锁,注册个监听器即可,不需要不断主动尝试获取锁,性能开销较小。

参考