
我是石页兄,朋友不因远而疏,高山不隔友谊情
欢迎关注微信公众号「架构染色」交流和学
关于分布式锁的话题,不知不觉已经整理了这么多篇了:
分布式系统有一个特点,就是无论你学习积累多少知识点,只要在分布式的战线中,总能遇到各种超出主观意识的神奇问题。比如前文使用Jedis来实现分布式锁的技术知识点储备,本以为很稳不会再遇到什么问题,但实际情况却是啪啪打脸。
为了照顾一些同学不喜欢看连载,这里就必须把上下文再粘贴过来,否则内容不连贯,看起来不流畅。
如果已经看过《分布式锁中-基于 Redis 的实现需避坑 - Jedis 篇》和《分布式锁中-基于 Redis 的实现如何防重入》的同学,可以跳过本小节【技术背景同步】,直接进入第三小节【诊断过程】。
我们使用的是 SET 指令来实现加锁的逻辑,指令形式如下:
SET键值[NX | XX] [GET] [EX 秒 | PX 毫秒 |
EXAT unix 时间秒 | PXAT unix 时间毫秒 | 保持]
1)加锁成功的逻辑是这样:
2)加锁不成功的逻辑是这样:
SetParams params = SetParams.setParams().nx().ex(lockState.getLeaseTTL());
String result = client.set(lockState.getLockKey(), lockState.getLockValue(), params);
上边代码是之前《分布式锁中-基于 Redis 的实现需避坑 - Jedis 篇》中写的加锁逻辑,其中只根据正常加锁的返回值来判断是否加锁成功,即 result 是不是 "OK",但 key 已存在导致加锁不成功的返回值到底是什么,应该如何判断呢?
在官网中,查看 SET 返回值的描述,为方便大家,这里直接贴出结果,应该很多同学都没看过这段描述吧。
简单字符串回复:
OK如果SET正确执行。空回复:
(nil)如果SET由于用户指定了NX或XX选项但不满足条件而未执行操作。如果命令与
GET选项一起发出,则上述内容不适用。它会改为如下回复,无论是否SET实际执行:批量字符串回复:存储在键中的旧字符串值。
空回复:
(nil)如果密钥不存在。
通过官网给出的描述可以得知,当前 SET 指令的使用方式,只要返回的不是“OK",就是锁已存在了,所以放重入功能是将《分布式锁中-基于 Redis 的实现需避坑 - Jedis 篇》示例中tryLock的逻辑中,加入一个判断锁类型的逻辑即可,即如果锁 key 已存在,并且锁是”一次性“锁,则不循环等待而是立即返回。
使用 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 指定的 valueSET 的耗时也不算太长,是 208ms4) 难道 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;
}
构造函数里,能看到 几个关键参数的信息:
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的问题
模拟一个场景方便理解:

SET 的指令SET 指令,存储后给客户端响应说第一个SET 成功,但响应返回的有点慢SET 的指令SET的响应到了,但客户端不关心了SET 指令,判断发现 key 已存在,给客户端响应说第二个 SET 失败SET 失败的响应。SET 使用者来说,效果是SET 失败了,但 key 设置成功了。既然是重试+超时时间引发的,那么可以从此特性出发,将其配置的值进行调整,比如:
soTimeout设置得足够大Jedis内部重试但这两个参数既然能暴露给我们使用,那么他们必然有其很重要的价值,而采用这两种方法都只是尝试去避免问题,从根治的视角看也都不合适:
超时如果设置的太长
如果不使用重试
所以这种方式应该算只是逃避问题而未能从根本上解决问题。
山不向我走来,我便向山走去
多年前看电视剧《少帅》,张作霖用这句话引导张学良,笔者也深受启发,并作为公众号【架构染色】的座右铭。
既然这些问题逃避也无用,那就想办法适应它,适应这个现象的关键是什么?
适应这个现象的关键就变成了只要这把锁是我加的就行,如何确认这个锁是我自己加的?
在《分布式锁上-初探》有介绍过分布式锁对称性(也又叫可重入性)的特点: 对同一个锁,加锁和解锁必须是同一个线程,即不能把其他线程持有的锁给释放了。
这个原则,在《分布式锁中-基于 Redis 的实现需避坑 - Jedis 篇》介绍使用 Jedis 加锁的时候,刚好有一个特别的处理技巧,
引入 lockValue 的随机值校验,避免误释放其它客户端的锁,场景如下:
解决办法是:加锁时指定的 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
《分布式锁中-基于 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 客户端较高版本才支持。
我是石页兄,如果这篇文章对您有帮助,或者有所启发的话,欢迎关注笔者的微信公众号【 架构染色 】进行交流和学习,您的支持是我坚持写作最大的动力。
另外技术群中也有许多热心的伙伴儿时常互助、交流;通过公众号主页【交流探讨】即可获得笔者微信二维码,加好友时备注“加群”即可。
原文作者:架构染色;如有侵权,请联系管理员删除。