你不知道的Redis SET NX 指令不保障原子性的应对之法

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
简介: 你不知道的Redis SET NX 指令不保障原子性的应对之法

一、前情概要

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

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

二、技术问题同步

《别再随意说 Redis 的 SET 保障原子性,在客户端不一定》中介绍过runWithRetries具有重试能力,因为其重试 + soTimeout的机制设计,导致重试逻辑内部把通信异常吞掉了,并重新发出执行指令的请求。就会导致用户层看到 SET 返回的是空,但key 实际已存在,通过下图示例描述:

2Zmh5D.gif

  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 调整思维模型

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

电视剧《少帅》中,张作霖用这句话引导张学良,笔者也深受启发,并作为公众号的座右铭

2Zmh5D.gif

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

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 如下:

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 时间毫秒 | 保持]
复制代码

你想出另一种写法了没?

六、最后说一句

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


相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore     ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库 ECS 实例和一台目标数据库 RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
2月前
|
存储 NoSQL 关系型数据库
Redis 集合(Set)
10月更文挑战第17天
38 5
|
22天前
|
存储 NoSQL PHP
如何用Redis高效实现点赞功能?用Set?还是Bitmap?
在众多软件应用中,点赞功能几乎成为标配。本文从实际需求出发,探讨如何利用 Redis 的 `Set` 和 `Bitmap` 数据结构设计高效点赞系统,分析其优缺点,并提供 PHP 实现示例。通过对比两种方案,帮助开发者选择最适合的存储方式。
28 3
|
2月前
|
存储 NoSQL 关系型数据库
Redis 有序集合(sorted set)
10月更文挑战第17天
58 4
|
2月前
|
存储 分布式计算 NoSQL
大数据-40 Redis 类型集合 string list set sorted hash 指令列表 执行结果 附截图
大数据-40 Redis 类型集合 string list set sorted hash 指令列表 执行结果 附截图
27 3
|
2月前
|
NoSQL Java 关系型数据库
阿里 P7二面:Redis 执行 Lua,到底能不能保证原子性?
Redis 和 Lua,两个看似风流马不相及的技术点,为何能产生“爱”的火花,成为工作开发中的黄金搭档?技术面试中更是高频出现,Redis 执行 Lua 到底能不能保证原子性?今天就来聊一聊。 
89 1
|
2月前
|
存储 JavaScript 前端开发
Set、Map、WeakSet 和 WeakMap 的区别
在 JavaScript 中,Set 和 Map 用于存储唯一值和键值对,支持多种操作方法,如添加、删除和检查元素。WeakSet 和 WeakMap 则存储弱引用的对象,有助于防止内存泄漏,适合特定场景使用。
|
3月前
|
存储 Java API
【数据结构】map&set详解
本文详细介绍了Java集合框架中的Set系列和Map系列集合。Set系列包括HashSet(哈希表实现,无序且元素唯一)、LinkedHashSet(保持插入顺序的HashSet)、TreeSet(红黑树实现,自动排序)。Map系列为双列集合,键值一一对应,键不可重复,值可重复。文章还介绍了HashMap、LinkedHashMap、TreeMap的具体实现与应用场景,并提供了面试题示例,如随机链表复制、宝石与石头、前K个高频单词等问题的解决方案。
41 6
【数据结构】map&set详解
|
2月前
|
存储 缓存 Java
【用Java学习数据结构系列】HashMap与TreeMap的区别,以及Map与Set的关系
【用Java学习数据结构系列】HashMap与TreeMap的区别,以及Map与Set的关系
39 1
|
3月前
|
算法
你对Collection中Set、List、Map理解?
你对Collection中Set、List、Map理解?
38 5
|
3月前
|
存储 JavaScript 前端开发
js的map和set |21
js的map和set |21