• 分布式.分布式锁


    缘起

    有一个公共厕所只有一个坑位,为了控制正在使用的人不被使用,旁边设置了个门房,并给厕所上了密码锁,只有拿到单次使用密码的人才可以使用。

    1. 只有一个坑位,所以同一时刻只能有一个人使用
    2. 使用前需要跟门房要一个临时单次密码
    3. 使用完后需要给门房说一声才可以生成新密码
    4. 但有些人用完就走了,所以门房就规定5min使用过期时间,如果超过时间就可以生成新密码,其他人也就可使用了
    5. 这就可能有问题,如果一个人确实使用时间超过5min,那么新进去的人会干扰他的使用。但如果5min内,一个人出去了又拉肚子需要重复使用,那么这是否算第二次使用
    6. 如果是这个人没有带卫生纸,是否按照二次使用获取新密码呢?
    7. 如果厕所正在使用,后面来的人是等待还是直接找其他厕所呢需要根据实际情况设定
    8. 如果等待,是否需要设置一个最长等待时间呢
    9. 如果等待,等待的人是否需要排队,下一个的时候是抢占呢,还是按照来的迟早获取
    10. 获取新密码的时间要尽可能短,尽可能减少非使用厕所的损耗
    11. 尽可能一直有人值守门房,否则拿不到密码也没法使用厕所

    如果多人(多台机器,多个线程等)操作一个单元,那么结果就可能不是谁都愿意看到的。这是同步问题,放在分布式中也一样,解决方案就是:有一个尽可能小的单线程中间变量作为是否可以该变量的标记。

    谁拿到这把钥匙,谁就可以开启箱子。

    分布式锁要求

    1. 互斥:进程外同一刻时间,只能有一个客户端访问 (必选)
    2. 死锁:访问可能中断,锁需要有一定过期时间(必选)
    3. 在过期时间和使用时间做好平衡,时间太长*并发=常数
    4. 锁尽可能高可用,高性能
    5. 阻塞和非阻塞特性:对后续来的客户是直接打发回去呢,还是等待一定时间,还没有获取到就打发回去
    6. 等待的客户是否需要排队,还是看运气抢占
    7. 如果同一个多次要密码,是否给生成,还是等待(可重入问题)
    8. 解铃还须系铃人:防止别人等不住说我用不了,自己去使用;当然门房设置的超时机制除外

    实现方案1:数据库

    原理或步骤:

    1. 开启事务
    2. 使用数据库select lock from table1 where id = 0 for update; 给改行添加排它锁,事务没有提交之前,其他事务不能更新
    3. 判断lock==0,0没有锁,1已锁
    4. 更新数据lock=1
    5. 提交事务
    1. 业务操作
    1. 开启事务
    2. 使用数据库select lock from table1 where id = 0 for update; 给改行添加排它锁,事务没有提交之前,其他事务不能更新
    3. 判断lock==1,0没有锁,1已锁
    4. 更新数据lock=0
    5. 提交事务

    问题:

    1. 锁的使用者没法得到
    2. 没有超时清理机制,可能导致死锁

    延迟消息+添加使用者字段:

    1. 更新lock=0的时候,先添加延迟清理消息,并添加使用者
    2. 延迟消息,如果user没有匹配,就丢弃
    3. 如果业务操作超时,延迟消息执行解锁,新客户锁后,超时的业务操作客户解锁时需要判断user:update table1 set lock=0 where id = 0 and user = 当前线程ID;

    特点:

    仅限于有事务数据库引擎,如果Mysql的Innodb。

    方案2:缓存Memcached

    利用 Memcached 的 add 命令。此命令是原子性操作,只有在 key 不存在的情况下,才能 add成功,也就意味着线程得到了锁。

    类似Mysql,超时机制需要自己设置

    方案3:Redis

    setnx命令:如果有key有数据,就不设置,返回0;没有数据就赋值,返回1。单线程

    超时机制:expire命令,设置超时命令

    问题:

    1. 没法先设置超时expire,然后在setnx。(先发送延迟消息,然后更新数据库)
    2. 先设置setnx,如果没有设置expire就会死锁

    解决:

    合并命令 jedis版本:2.9.0

    jedis.set(lockKey, value(requestId), “NX”, “PX”, expireTime)

    value:设置当前请求的requestID,用作解锁的依据

    expireTime 过期时间

    缺点:可重入 实现困难

    1. set并设置时间返回0
    2. 过期,清理
    3. get并判断value是否为当前requestID
    4. 获取到数据为空,返回false
    5. 获取到数据为其他客户端加锁,返回false
    6. 如果是返回true,已获取锁;否则返回false
    7. 场景:可重复场景类似两层AOP都加锁,根据此次线程IP所谓requestId判断,第二次获取锁就可以直接拿到,否则获取同一个锁可能死锁。

    解锁:也需要合并命令

        // 判断加锁与解锁是不是同一个客户端

    if (requestId.equals(jedis.get(lockKey))) {

            // 若在此时,过期自动删除,新客户端已获取锁,则会误解锁

            jedis.del(lockKey);

        }

    解决:使用lua脚本,合并命令

    完整代码&逻辑:

    1. 逻辑:
    2. 下面我们假设锁的key为“ lock ”,hashKey是当前线程的id:“ threadId ”,锁自动释放时间假设为20
    3. 获取锁的步骤:
    4. 1、判断lock是否存在 EXISTS lock
    5. 2、不存在,则自己获取锁,记录重入层数为1.
    6. 2、存在,说明有人获取锁了,下面判断是不是自己的锁,即判断当前线程id作为hashKey是否存在:HEXISTS lock threadId
    7. 3、不存在,说明锁已经有了,且不是自己获取的,锁获取失败.
    8. 3、存在,说明是自己获取的锁,重入次数+1: HINCRBY lock threadId 1 ,最后更新锁自动释放时间, EXPIRE lock 20
    9. 释放锁的步骤:
    10. 1、判断当前线程id作为hashKey是否存在: HEXISTS lock threadId
    11. 2、不存在,说明锁已经失效,不用管了
    12. 2、存在,说明锁还在,重入次数减1: HINCRBY lock threadId -1
    13.   3、获取新的重入次数,判断重入次数是否为0,为0说明锁全部释放,删除key: DEL lock
    14. 获取锁的脚本(注释删掉,不然运行报错)
    15. local key = KEYS[1]; -- 第1个参数,锁的key
    16. local threadId = ARGV[1]; -- 第2个参数,线程唯一标识
    17. local releaseTime = ARGV[2]; -- 第3个参数,锁的自动释放时间
    18. if(redis.call('exists', key) == 0) then -- 判断锁是否已存在
    19. redis.call('hset', key, threadId, '1'); -- 不存在, 则获取锁
    20. redis.call('expire', key, releaseTime); -- 设置有效期
    21. return 1; -- 返回结果
    22. end;
    23. if(redis.call('hexists', key, threadId) == 1) then -- 锁已经存在,判断threadId是否是自己
    24. redis.call('hincrby', key, threadId, '1'); -- 如果是自己,则重入次数+1
    25. redis.call('expire', key, releaseTime); -- 设置有效期
    26. return 1; -- 返回结果
    27. end;
    28. return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败
    29. 释放锁的脚本(注释删掉,不然运行报错)
    30. local key = KEYS[1]; -- 第1个参数,锁的key
    31. local threadId = ARGV[1]; -- 第2个参数,线程唯一标识
    32. if (redis.call('HEXISTS', key, threadId) == 0) then -- 判断当前锁是否还是被自己持有
    33. return nil; -- 如果已经不是自己,则直接返回
    34. end;
    35. local count = redis.call('HINCRBY', key, threadId, -1); -- 是自己的锁,则重入次数-1
    36. if (count == 0) then -- 判断是否重入次数是否已经为0
    37. redis.call('DEL', key); -- 等于0说明可以释放锁,直接删除
    38. return nil;
    39. end;
    40. 完整代码
    41. import java.util.Collections;
    42. import java.util.UUID;
    43. import org.springframework.core.io.ClassPathResource;
    44. import org.springframework.data.redis.core.StringRedisTemplate;
    45. import org.springframework.data.redis.core.script.DefaultRedisScript;
    46. import org.springframework.scripting.support.ResourceScriptSource;
    47. /**
    48. * Redis可重入锁
    49. */
    50. public class RedisLock {
    51. private static final StringRedisTemplate redisTemplate = SpringUtil.getBean(StringRedisTemplate.class);
    52. private static final DefaultRedisScript LOCK_SCRIPT;
    53. private static final DefaultRedisScript UNLOCK_SCRIPT;
    54. static {
    55. // 加载释放锁的脚本
    56. LOCK_SCRIPT = new DefaultRedisScript<>();
    57. LOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("lock.lua")));
    58. LOCK_SCRIPT.setResultType(Long.class);
    59. // 加载释放锁的脚本
    60. UNLOCK_SCRIPT = new DefaultRedisScript<>();
    61. UNLOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("unlock.lua")));
    62. }
    63. /**
    64. * 获取锁
    65. * @param lockName 锁名称
    66. * @param releaseTime 超时时间(单位:秒)
    67. * @return key 解锁标识
    68. */
    69. public static String tryLock(String lockName,String releaseTime) {
    70. // 存入的线程信息的前缀,防止与其它JVM中线程信息冲突
    71. String key = UUID.randomUUID().toString();
    72. // 执行脚本
    73. Long result = redisTemplate.execute(
    74. LOCK_SCRIPT,
    75. Collections.singletonList(lockName),
    76. key + Thread.currentThread().getId(), releaseTime);
    77. // 判断结果
    78. if(result != null && result.intValue() == 1) {
    79. return key;
    80. }else {
    81. return null;
    82. }
    83. }
    84. /**
    85. * 释放锁
    86. * @param lockName 锁名称
    87. * @param key 解锁标识
    88. */
    89. public static void unlock(String lockName,String key) {
    90. // 执行脚本
    91. redisTemplate.execute(
    92. UNLOCK_SCRIPT,
    93. Collections.singletonList(lockName),
    94. key + Thread.currentThread().getId(), null);
    95. }
    96. }
    97. 方案4:Zookeeper

      基础概念:

      1. Zookeeper类似一颗树结构
      2. 节点类型:持久节点,持久顺序节点,临时节点,临时顺序节点
      3. 顺序:按照add的顺序给node添加序号;临时:客户端断了就删除了
      4. 分布式锁就使用临时顺序节点

      大致思想即为:

      每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。

      重点问题属性:

      锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。

      非阻塞锁?使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。

      不可重入?使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。

      单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。

      Curator提供的InterProcessMutex是分布式锁的实现。acquire方法用户获取锁,release方法用于释放锁。

      使用ZK实现的分布式锁好像完全符合了本文开头我们对一个分布式锁的所有期望。但是,其实并不是,Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。

      其实,使用Zookeeper也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。(所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。)

      Chubby

      [ˈtʃʌbi] adj.胖乎乎的;圆胖的;丰满的

      参考:Google Chubby(中文版)_左罗CTO的技术博客_51CTO博客

      方案选型

      上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。

      从理解的难易程度角度(从低到高)

      数据库 > 缓存 > Zookeeper

      从实现的复杂性角度(从低到高)

      Zookeeper >= 缓存 > 数据库

      从性能角度(从高到低)

      缓存 > Zookeeper >= 数据库

      从可靠性角度(从高到低)

      Zookeeper > 缓存 > 数据库

      难点

      1. 可重入:业务上同一个客户端可以多次获取锁,并成功返回
      2. 超时和业务处理时间的平衡
      3. 客户端连接检查,解决网络抖动问题
      4. 加锁和解锁操作原子性

      Mysql数据库锁

      共享锁(S锁):

      允许多个事务对于同一数据可以共享一把锁,都能访问到数据,

      阻止其它事务对于同一数据获取排它锁。

      排它锁(X锁)

      允许事务删除或者更新一行数据,

      阻止其它事务对于同一数据获取其它锁,包括共享锁和排它锁。

      select 语句默认不获取任何锁,所以是可以读被其它事务持有排它锁的数据的!

      行级锁?表级锁?

      select * from table_name where ... for update;

      for update 仅适用于InnoDB,并且必须开启事务,在begin与commit之间才生效。

      InnoDB 默认是行级锁,当有明确指定的主键/索引时候,是行级锁,否则是表级锁。

      假设表 user,存在有id跟name字段,id是主键,有5条数据。

      SELECT * FROM user WHERE id = 1 FOR UPDATE;   (行锁)

      SELECT * FROM user WHERE name = 'segon' FOR UPDATE; (表锁)

      SELECT * FROM user WHERE id = -1 FOR UPDATE; (没有数据,无锁)


      文档参考:

      这才是真正的分布式锁_w3cschool

      分布式锁的几种解决方案 - xuwc - 博客园

      END

    98. 相关阅读:
      JAVA之单元测试:Junit框架
      OpenVPN服务器搭建与OpenVPN客户端访问
      第二十章 多线程
      2023美团暑期实习自驾仿真算法一面面经
      《向量数据库指南》——AI原生Milvus Cloud 中NATS 配置项详解
      【FATE联邦学习】FATE框架的大坑,使用6个月有感
      创新洞见|2023年B2B业务为何必须采用PLG增长策略
      【深入理解C++】移动构造函数和移动赋值运算符
      pod详解
      【Python+Appium】开展自动化测试(5)appium元素定位常用方法
    99. 原文地址:https://blog.csdn.net/weixin_42754896/article/details/126010133