当前位置: 首页 > 后端技术 > Java

不要光说Redis的SET保证原子性,在客户端不一定是

时间:2023-04-01 13:56:27 Java

一个超越主观意识的神奇问题。比如我在上一篇文章中使用Jedis实现了分布式锁的技术知识储备。本以为会很稳定,不会遇到什么问题,结果实际情况被打脸了。2.技术背景的同步为了照顾一些不喜欢看连续剧的同学,这里一定要贴上下文,不然内容会不连贯,看起来不流畅。如果你看过《分布式锁中-基于 Redis 的实现如何防重入》和《分布式锁实战-偶遇 etcd 后就想抛弃 Redis ?》,可以跳过本节【技术背景同步】,直接进入第三节【诊断流程】。2.1如何使用SET指令加锁我们使用SET指令来实现加锁逻辑,指令形式如下:SETkeyvalue[NX|XX][GET][EX秒|PX毫秒|EXATunix时间秒|PXATunix时间(毫秒)|hold]1)加锁成功逻辑如下:判断key是否存在;如果key不存在,则设置key指定key的过期时间如果存在,则返回SetParamsparams=SetParams.setParams().nx().ex(lockState.getLeaseTTL());Stringresult=client.set(lockState.getLockKey(),lockState.getLockValue(),params);上面的代码是之前《分布式锁中-基于 Redis 的实现需避坑 - Jedis 篇》写的加锁逻辑,其中只用正常加锁的返回值来判断加锁是否成功,即结果是否为“OK”,但是key已经存在并且锁的返回值不成功。什么,我们应该怎么判断?2.2SET的返回值有哪些?官网上查看SET返回值的说明。为了您的方便,结果直接张贴在这里。很多同学应该没有看过这个描述。简单的字符串回复:OK如果SET正确执行。空回复:(nil)如果SET没有执行操作,因为用户指定了NX或XX选项但条件不满足。如果使用GET选项发出命令,则以上内容不适用。它会改为如下回复,而不管SET是否实际执行:批量字符串回复:密钥中存储的旧字符串值。空回复:(nil)如果密钥不存在。2.3SET命令加锁的结论从官网给出的描述可以知道,当前使用SET命令,只要返回不是“OK”,就说明锁已经存在,所以在《分布式锁中-基于 Redis 的实现需避坑 - Jedis 篇》例子中tryLock的逻辑,只是增加了一个判断锁类型的逻辑,即如果锁key已经存在,并且锁是“一次性”锁,则立即返回,无需等待环形。2.4残酷的现实在使用Jedis客户端实现分布式锁功能时,我们发现并确认,从客户端用户的角度来看,SET指令的原子语义可能不一定得到保证。3、诊断过程1)根据用户反馈,偶尔会出现反重入锁加锁失败的情况。从日志的结果来看,在这个key相关的加锁日志中,只有SET返回空,即key已经存在的信息。是否有其他程序也可以加锁,比如在Redis中手动设置key或者是否有其他实例在运行?已经确认没有手动设置key。整个程序在测试环境中只有一个实例,没有其他实例。并Tag记录几个疑惑:从用户请求到结束,多次执行lockSET命令,SET不成功时,返回结果是OK还是空。如果SET返回空,通过GET查询,记录它的值,可以判断是否和加锁时的值一致3)用户反馈,我又出现了:通过TraceId信息查看Trace,越多你不信,你越展示:只有一个有效的SET命令,SET返回一个空的GET结果,返回值就是SET指定的值。SET的耗时不算太长,是208ms4)SET命令不是官网说的效果吗,有什么坑?通过直观的Trace信息,我们不再怀疑上层的加锁逻辑和应用程序的逻辑,而是将Jedis客户端定位为最可疑的对象,但仍然缺乏对一个现象的研判依据,然后重现,找到规律。我什至怀疑是Reidsserver5)模式出现了,耗时问题又出现了。通过Trace信息,我们对比了有问题的SET和没有问题的SET。他们表现出什么不同,很快就发现了一个显着的特征,有问题的SET命令执行时间超过200ms,正常的SET命令执行时间不到20ms。6)什么是200ms?通过排查,发现Jedis客户端的几个超时设置为200ms。会不会是哪个链接超时导致的问题?7)Debugsourcecode从下面的调用栈中,你是不是也发现了一个可疑的词?没错runWithRetries,它会重试。execute:112,JedisCluster$2(redis.clients.jedis)execute:109,JedisCluster$2(redis.clients.jedis)runWithRetries:120,JedisClusterCommand(redis.clients.jedis)//”这里运行:31,JedisClusterCommand(redis.clients.jedis)set:109,JedisCluster(redis.clients.jedis)8)让我们看看超时是什么意思publicBinaryJedisCluster(SetjedisClusterNode,intconnectionTimeout,intsoTimeout,intmaxAttempts,Stringpassword,GenericObjectPoolConfigpoolConfig){this.connectionHandler=newJedisSlotBasedConnectionHandler(jedisClusterNode,poolConfig,connectionTimeout,soTimeout,password);this.maxAttempts=maxAttempts;}在构造函数中可以看到几个关键参数的信息:connectionTimeout=200soTimeout=200maxAttempt=39)connectionTimeout分析这个是建立连接的耗时,推论,如果200ms内没有连接,那么200ms后会有第二次连接,连接成功后,再次发送命令。在这种情况下,一条指令就足够了。10)分析soTimeoutsoTimeout被赋值给socket。publicvoidconnect(){if(!isConnected()){try{socket=newSocket();...socket.connect(newInetSocketAddress(host,port),connectionTimeout);socket.setSoTimeout(soTimeout);//权威解释看这里:Enable/disableSO_TIMEOUTwiththespecifiedtimeout,inmilliseconds.将此选项设置为非零超时后,与此Socket关联的InputStream上的read()调用将仅阻塞这段时间。如果超时到期,将引发java.net.SocketTimeoutException,但Socket仍然有效。该选项必须在进入阻塞操作之前启用才能生效。超时必须>0。超时为零被解释为无限超时。结合JDK注释说明这次遇到的情况:通过socket.setSoTimeout(inttimeout)方法设置,与socket关联的InputStream的read()方法会阻塞,直到超过设置的soTimeout,抛出SocketTimeoutException抛出。不设置该参数时,默认值为无穷大,即InputStream的read()方法会一直阻塞,除非连接断开。但是重试逻辑在内部吞下了异常并重新发出执行指令的请求。11)所以就是retry+soTimeout的问题模拟一个场景方便理解:0ms,client发送第一个SET命令30ms,server收到第一个SET命令,存储并响应client说第一个SET成功,但是响应有点慢,要返回200ms。客户端没有收到服务器的响应,超时异常。捕获后,将启动重试201毫秒。客户端开始重试,202ms发出第二个SET命令。服务器发送第一个SET响应到达,但客户端不关心它。204ms服务器收到第二次SET指令,判断key已经存在,响应客户端说第二次SET失败。208ms客户端从服务器收到第二次SET失败。回复。对于client端的顶层SET用户,效果是SET失败,但是key设置成功。4、如何避免?既然是retry+timeout导致的,那么可以从这个特性入手,调整它的配置值,比如:设置soTimeout足够大,可以取消Jedis内部重试,但是既然可以暴露这两个参数给我们使用,那么它们必须具有非常重要的价值。这两种方法都只是想避免问题,却无法根治。我们需要这些核心功能,同时避免破坏原子性语义的问题。读者们,你们有什么好的解决办法吗?