一、前情概要
关于分布式锁的话题,不知不觉已经整理了这么多篇了:
- 《分布式锁上-初探》
- 《分布式锁中-基于Zookeeper的实现是怎样》
- 《分布式锁中-基于 Redis 的实现需避坑 - Jedis 篇》
- 《分布式锁中-基于 Redis 的实现如何防重入》
- 《分布式锁实战-偶遇 etcd 后就想抛弃 Redis ?》
- 《分布式锁主动续期的入门级实现-自省 | 简约而不简单》
分布式系统有一个特点,就是无论你学习积累多少知识点,只要在分布式的战线中,总能遇到各种超出主观意识的神奇问题。比如前文使用Jedis
来实现分布式锁的技术知识点储备,本以为很稳不会再遇到什么问题,但实际情况却是啪啪打脸。
二、技术背景同步
为了照顾一些同学不喜欢看连载,这里就必须把上下文再粘贴过来,否则内容不连贯,看起来不流畅。
如果已经看过《分布式锁中-基于 Redis 的实现如何防重入》和 《分布式锁实战-偶遇 etcd 后就想抛弃 Redis ?》的同学,可以跳过本小节【技术背景同步】,直接进入第三小节【诊断过程】。
2.1 如何使用 SET 指令来加锁
我们使用的是 SET
指令来实现加锁的逻辑,指令形式如下:
SET键值[NX | XX] [GET] [EX 秒 | PX 毫秒 | EXAT unix 时间秒 | PXAT unix 时间毫秒 | 保持] 复制代码
1)加锁成功的逻辑是这样:
- 判断 key 是否存在
- 若 key 不存在,就设置 key
- 给 key 指定过期时间
2)加锁不成功的逻辑是这样:
- 判断 key 是否存在
- 若 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
指令的原子性语义并不一定能得到保障。
三、诊断过程
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
指定的 valueSET
的耗时也不算太长,是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
的问题
模拟一个场景方便理解:
- 0ms 客户端发出第一个
SET
的指令 - 30ms 服务端收到第一个
SET
指令,存储后给客户端响应说第一个SET
成功,但响应返回的有点慢 - 200ms 客户端仍未收到 服务端的响应,出现了超时异常,捕获后,发起重试
- 201ms 客户端开始重试,发出第二个
SET
的指令 - 202ms 服务端给第一个
SET
的响应到了,但客户端不关心了 - 204ms 服务端收到第二个
SET
指令,判断发现 key 已存在,给客户端响应说第二个SET
失败 - 208ms 客户端收到 服务端第二个
SET
失败的响应。 - 而对于Client端最上层的
SET
使用者来说,效果是SET
失败了,但key 设置成功了。
四、如何避免
既然是重试+超时时间引发的,那么可以从此特性出发,将其配置的值进行调整,比如:
- 把
soTimeout
设置的足够大 - 取消掉
Jedis
内部重试
但这两个参数既然能暴露给我们使用,那么他们必然有其很重要的价值,这两种方法都只是尝试去避免问题,但并不能根治。
我们既需要这些核心能力,又要避免遇到这类破坏原子性语义的问题。读者朋友,您有没有什么好的办法来解决呢?
五、最后说一句
我是石页兄,如果这篇文章对您有帮助,或者有所启发的话,欢迎关注笔者的微信公众号【 架构染色 】进行交流和学习。您的支持是我坚持写作最大的动力。