ITPub博客

首页 > 数据库 > 数据库开发技术 > Jedis 实现分布式锁其 SET NX 不保障原子性的应对之法

Jedis 实现分布式锁其 SET NX 不保障原子性的应对之法

原创 数据库开发技术 作者:架构染色 时间:2023-01-25 12:59:01 0 删除 编辑

我是石页兄,朋友不因远而疏,高山不隔友谊情

欢迎关注微信公众号「架构染色」交流和学

一、前情概要

关于分布式锁的话题,不知不觉已经整理了这么多篇了:

分布式系统有一个特点,就是无论你学习积累多少知识点,只要在分布式的战线中,总能遇到各种超出主观意识的神奇问题。比如前文使用Jedis来实现分布式锁的技术知识点储备,本以为很稳不会再遇到什么问题,但实际情况却是啪啪打脸。

二、技术背景同步

为了照顾一些同学不喜欢看连载,这里就必须把上下文再粘贴过来,否则内容不连贯,看起来不流畅。

如果已经看过《分布式锁中-基于 Redis 的实现需避坑 - Jedis 篇》和《分布式锁中-基于 Redis 的实现如何防重入》的同学,可以跳过本小节【技术背景同步】,直接进入第三小节【诊断过程】。

2.1 如何使用 SET 指令来加锁

我们使用的是 SET 指令来实现加锁的逻辑,指令形式如下:

SET键值[NX | XX] [GET] [EX 秒 | PX 毫秒 |
  EXAT unix 时间秒 | PXAT unix 时间毫秒 | 保持]

1)加锁成功的逻辑是这样:

  1. 判断 key 是否存在
  2. 若 key 不存在,就设置 key
  3. 给 key 指定过期时间

2)加锁不成功的逻辑是这样:

  1. 判断 key 是否存在
  2. 若 key 已存在,则返回
SetParams params = SetParams.setParams().nx().ex(lockState.getLeaseTTL());
String result = client.set(lockState.getLockKey(), lockState.getLockValue(), params);

上边代码是之前《分布式锁中-基于 Redis 的实现需避坑 - Jedis 篇》中写的加锁逻辑,其中只根据正常加锁的返回值来判断是否加锁成功,即 result 是不是 "OK",但 key 已存在导致加锁不成功的返回值到底是什么,应该如何判断呢?

2.2 SET 的返回值都有什么

在官网中,查看 SET 返回值的描述,为方便大家,这里直接贴出结果,应该很多同学都没看过这段描述吧。

简单字符串回复:OK如果SET正确执行。

空回复:(nil)如果SET由于用户指定了NX或XX选项但不满足条件而未执行操作。

如果命令与GET选项一起发出,则上述内容不适用。它会改为如下回复,无论是否SET实际执行:

批量字符串回复:存储在键中的旧字符串值。

空回复:(nil)如果密钥不存在。

2.3 SET 指令加锁的结论

通过官网给出的描述可以得知,当前 SET 指令的使用方式,只要返回的不是“OK",就是锁已存在了,所以放重入功能是将《分布式锁中-基于 Redis 的实现需避坑 - Jedis 篇》示例中tryLock的逻辑中,加入一个判断锁类型的逻辑即可,即如果锁 key 已存在,并且锁是”一次性“锁,则不循环等待而是立即返回。

2.4 无情的现实

使用 Jedis 客户端来实现分布式锁功能的时候,我们发现并确认了,从客户端用户的视角来看 SET NX 指令的原子性语义并不一定能得到保障。

三、诊断过程

1) 用户测试反馈,偶发一次防重入锁的加锁失败了

从日志的结果看,与这个 key 相关的加锁日志中,只有SET返回空,即 key 已存在的信息。

是不是有其他的程序也可以加锁,比如人工在 Redis 里设置了 key 或 还有其他的实例也在运行?

经确认,没有人工设置 key 的现象,整个程序在测试环境中只有 1 个实例,没有其他实例

2)没有足够的可观测信息,的确是看不出来哪里有问题

用 SkyWalking 中 @Trace的方法 通过 Trace 以及 Tag 来记录几个怀疑点:1. 从用户请求进入到结束,加锁 SET 指令执行了几次 2. SET 不成功的时候,返回的结果到底是 OK 还是 空 3. 如果 SET 返回的是空, 通过 GET 查询一下,记录其 value,可以判断跟加锁时的 value 是否一致

3)用户测试反馈,又出现了

我:通过 TraceId 信息查看 Trace,越不相信什么越呈现什么:

  • 只有一次有效的SET指令
  • SET 返回的是空
  • GET 返回有结果,并且 value 是 SET 指定的 value
  • SET 的耗时也不算太长,是 208ms

4) 难道 SET 指令 并非官网所讲的效果,有什么坑?

通过直观的 Trace 信息,不再怀疑上层加锁逻辑和应用程序的逻辑,而把 Jedis 客户端和定位成最大怀疑对象,但一次现象还是缺少一些研判的依据,再复现一下找一找规律,甚至也怀疑 Reids 服务端

5) 规律出现了,耗时偏长

问题再次出现,通过 Trace信息来对比出问题的 SET 与 无问题的 SET 表现出了哪些差异,很快一个显著的特征被找了出来,出问题的 SET 指令的执行耗时 都在 200ms 以上,而没问题的 SET 的耗时 都在 20ms 以下。

6)200ms 是什么?

通过排查发现,Jedis客户端几个超时时间设置的是 200ms ,莫非是哪个环节的超时导致了问题?

7)调试源码

从下边的调用堆栈,你是不是也发现一个单词挺让人生疑?没错runWithRetries,它会重试。

execute:112, JedisCluster$2 (redis.clients.jedis)
execute:109, JedisCluster$2 (redis.clients.jedis)
runWithRetries:120, JedisClusterCommand (redis.clients.jedis)//》这里
run:31, JedisClusterCommand (redis.clients.jedis)
set:109, JedisCluster (redis.clients.jedis)

8)再看一看那几个超时时间都是什么意思

public BinaryJedisCluster(Set<HostAndPort> jedisClusterNode, int connectionTimeout, int soTimeout, int maxAttempts, String password, GenericObjectPoolConfig poolConfig) {
  this.connectionHandler = new JedisSlotBasedConnectionHandler(jedisClusterNode, poolConfig,
          connectionTimeout, soTimeout, password);
  this.maxAttempts = maxAttempts;
}

构造函数里,能看到 几个关键参数的信息:

  • connectionTimeout = 200
  • soTimeout = 200
  • maxAttempts = 3

9)分析 connectionTimeout

这是建连的耗时,推理一下,如果 200ms 都没连接上,那么 200ms 后会有第二次连接,连接成功后,再发指令。

这种情况下应该发一次指令就够了。

10)分析 soTimeout

soTimeout 指定给了 socket。

public void connect() {
  if (!isConnected()) {
    try {
      socket = new Socket();
      ...
      socket.connect(new InetSocketAddress(host, port), connectionTimeout);
      socket.setSoTimeout(soTimeout);//在这里

看权威解释:

Enable/disable SO_TIMEOUT with the specified timeout, in milliseconds. With this option set to a non-zero timeout, a read() call on the InputStream associated with this Socket will block for only this amount of time. If the timeout expires, a java.net.SocketTimeoutException is raised, though the Socket is still valid. The option must be enabled prior to entering the blocking operation to have effect. The timeout must be > 0. A timeout of zero is interpreted as an infinite timeout.

结合 JDK 注释解释一下本次遇到的情况:

通过socket.setSoTimeout(int timeout)方法设置,socket 关联的InputStream的read()方法会阻塞,直到超过设置的soTimeout,就会抛出SocketTimeoutException。当不设置这个参数时,默认值为无穷大,即InputStream的read()方法会一直阻塞下去,除非连接断开。

但重试逻辑内部把异常吞掉了,并重新发出执行指令的请求。

11)所以是重试 + soTimeout的问题

模拟一个场景方便理解:

  1. 0ms 客户端发出第一个 SET 的指令
  2. 30ms 服务端收到第一个 SET 指令,存储后给客户端响应说第一个SET 成功,但响应返回的有点慢
  3. 200ms 客户端仍未收到 服务端的响应,出现了超时异常,捕获后,发起重试
  4. 201ms 客户端开始重试,发出第二个SET 的指令
  5. 202ms 服务端给第一个SET的响应到了,但客户端不关心了
  6. 204ms 服务端收到第二个 SET 指令,判断发现 key 已存在,给客户端响应说第二个 SET 失败
  7. 208ms 客户端收到 服务端第二个 SET 失败的响应。
  8. 而对于 Client 端最上层的 SET 使用者来说,效果是SET 失败了,但 key 设置成功了。

四、如何避免

既然是重试+超时时间引发的,那么可以从此特性出发,将其配置的值进行调整,比如:

  1. 把soTimeout设置得足够大
  2. 取消掉Jedis内部重试

但这两个参数既然能暴露给我们使用,那么他们必然有其很重要的价值,而采用这两种方法都只是尝试去避免问题,从根治的视角看也都不合适:

  • 超时如果设置的太长

    • 那带来结果是当某个节点通信异常时,redis 调用耗时很长,而拿到的结果还是错误;遇到这种情况真实的诉求是快速把错误报出好让客户端快速感知以应对。
  • 如果不使用重试

    • 重试本就是应对分布式系统中节点异常的常规做法,Jedis 组件内的重试机制本是完善且设计优质的,若弃之不用,由外层再来做一层重试逻辑,未必做的健壮

所以这种方式应该算只是逃避问题而未能从根本上解决问题。

4.1 调整思路

山不向我走来,我便向山走去

多年前看电视剧《少帅》,张作霖用这句话引导张学良,笔者也深受启发,并作为公众号【架构染色】的座右铭。

既然这些问题逃避也无用,那就想办法适应它,适应这个现象的关键是什么?

4.2 关键在于这把锁是谁加

适应这个现象的关键就变成了只要这把锁是我加的就行,如何确认这个锁是我自己加的?

在《分布式锁上-初探》有介绍过分布式锁对称性(也又叫可重入性)的特点: 对同一个锁,加锁和解锁必须是同一个线程,即不能把其他线程持有的锁给释放了。

这个原则,在《分布式锁中-基于 Redis 的实现需避坑 - Jedis 篇》介绍使用 Jedis 加锁的时候,刚好有一个特别的处理技巧,

引入 lockValue 的随机值校验,避免误释放其它客户端的锁,场景如下:

  • client1 加锁成功,key 10s 后过期,完成逻辑后,删除  key  之前,因 GC 导致持锁超过 10s,Redis 自动删除了 key,之后其他客户端可以抢锁
  • 假如是 client2 接下来成功抢锁,开始处理持锁后的逻辑。而此时 client1 GC 结束了会继续执行删除  key  的操作,但此时释放的其实是 client2 的  key

解决办法是:加锁时指定的 lockValue 为随机值,每次加锁时的值都是唯一的,释放锁时若 lockValue 与加锁时的值一致才可释放,否则什么都不做,逻辑如下:

if( jedis.set(key, randomLockValue, "NX", "EX", 100) == “OK" ){ //加锁
   try {
       do something  //业务处理
   }catch(){
 }
 finally {
      //判断是不是当前线程加的锁,是才释放
      //但判断和释放锁两个操作不是原子性的
      if (randomLockValue.equals(jedis.get(key))) {
         jedis.del(key); //释放锁
      }
   }
}

因为每次加锁时的值都是唯一的,所以在失败的时候,再读一下看看是不是自己那个唯一的 lockValue

4.3 判断是不是自己加的锁

《分布式锁中-基于 Redis 的实现需避坑 - Jedis 篇》的实例代码中,关于判断是否加锁成功的代码稍作调整,这行代码中也有一个高性能或操作的小技巧,尽量少的去调用 get (因为 get 操作会触发耗时I/O逻辑), 如下:

if (RESULT_OK.equals(result) || ((!RESULT_OK.equals(result))
    && (client.get(lockState.getLockKey()).equals(lockState.lockValue))))

tryLock 的全貌

public boolean tryLock(long waitTime, TimeUnit waitUnit) throws DtLockException {
    long totalMillisSeconds = waitUnit.toMillis(waitTime);
    long start = System.currentTimeMillis();
    //重试,直到成功或超过指定时间
    while (true) {
        // 抢锁
        try {
            SetParams params = SetParams.setParams().nx().ex(lockState.getLeaseTTL());
            String result = client.set(lockState.getLockKey(), lockState.getLockValue(), params);
            //返回 OK 或者 未返回ok,但 value 是自己
            if (RESULT_OK.equals(result) || ((!RESULT_OK.equals(result))
                    && (client.get(lockState.getLockKey()).equals(lockState.lockValue)))) {
                manualKeepAlive();
                log.info("[jedis-lock] lock success 线程:{} 加锁成功,key:{} , value:{}", Thread.currentThread().getName(), lockState.getLockKey(), lockState.getLockValue());
                lockState.setLockSuccess(true);
                return true;
            } else {
                if (System.currentTimeMillis() - start >= totalMillisSeconds) {
                    return false;
                }
                Thread.sleep(sleepMillisecond);
            }
        } catch (Exception e) {
            Throwable cause = e.getCause();
            if (cause instanceof SocketTimeoutException) {//忽略网络抖动等异常
            }
            log.error("[jedis-lock] lock failed:" + e);
            throw new DtLockException("[jedis-lock] lock failed:" + e.getMessage(), e);
        }
    }
}

五、总结

本篇讨论了,因 Jedis 中重试 + soTimeout的机制设计,导致重试逻辑内部把通信异常吞掉了,并重新发出执行指令的请求。就会导致用户层看到 SET 返回的是空,但 key 实际已存在*。我们找到应对的办法,只要判断出是自己加的锁;就不再担心异常情况。

当然,示例只是一种写法把问题描述清楚,而且效率并不高,有兴趣的读者朋友可再多了解一下 SET 用法,寻找另一种写法,SET 指令形式如下:

SET键值[NX | XX] [GET] [EX 秒 | PX 毫秒 |
  EXAT unix 时间秒 | PXAT unix 时间毫秒 | 保持]

留意看SET指令的语法,其中也可以附带 GET 的指令来获取结果,这才是应用于生产中的高效写法,但这个语法要 Jedis 客户端较高版本才支持。

六、最后说一句

我是石页兄,如果这篇文章对您有帮助,或者有所启发的话,欢迎关注笔者的微信公众号【 架构染色 】进行交流和学习,您的支持是我坚持写作最大的动力。

另外技术群中也有许多热心的伙伴儿时常互助、交流;通过公众号主页【交流探讨】即可获得笔者微信二维码,加好友时备注“加群”即可。

原文作者:架构染色;如有侵权,请联系管理员删除。

请登录后发表评论 登录
全部评论

注册时间:2022-08-23

  • 博文量
    49
  • 访问量
    23706