分布式系统有一个特点,就是无论你学习积累多少知识点,只要在分布式的战线中,总能遇到各种超出主观意识的神奇问题。比如前文使用Jedis来实现分布式锁的技术知识点储备,本以为很稳不会再遇到什么问题,但实际情况却是啪啪打脸。
为了照顾一些同学不喜欢看连载,这里就必须把上下文再粘贴过来,否则内容不连贯,看起来不流畅。
我们使用的是 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 指定的 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的问题
模拟一个场景方便理解:
- 0ms 客户端发出第一个 SET 的指令
- 30ms 服务端收到第一个 SET 指令,存储后给客户端响应说第一个SET 成功,但响应返回的有点慢
- 200ms 客户端仍未收到 服务端的响应,出现了超时异常,捕获后,发起重试
- 201ms 客户端开始重试,发出第二个SET 的指令
- 202ms 服务端给第一个SET的响应到了,但客户端不关心了
- 204ms 服务端收到第二个 SET 指令,判断发现 key 已存在,给客户端响应说第二个 SET 失败
- 208ms 客户端收到 服务端第二个 SET 失败的响应。
- 而对于Client端最上层的 SET 使用者来说,效果是SET 失败了,但key 设置成功了。
四、如何避免
既然是重试+超时时间引发的,那么可以从此特性出发,将其配置的值进行调整,比如:
- 把soTimeout设置的足够大
- 取消掉Jedis内部重试
但这两个参数既然能暴露给我们使用,那么他们必然有其很重要的价值,这两种方法都只是尝试去避免问题,但并不能根治。
我们既需要这些核心能力,又要避免遇到这类破坏原子性语义的问题。读者朋友,您有没有什么好的办法来解决呢?