来源:https://biiy.cn/000PZC
-
前言
-
步入正题
-
加锁过程分析
-
释放锁过程分析
-
正视自己的缺点
-
总结
前言
提到数据一致性、操作原子性,诸如此类的一些与并发有关的词汇时不知道你第一时间会联想到什么呢?我相信大多数人可能会想到 “锁”,为什么是锁呢,这个我不多说,大家心里应该都明白。在单体应用时代,我们使用 jvm 提供的锁就可以很好的工作,但是到了分布式应用时代,jvm 提供的锁就行不通了,那么势必要借助一些跨 jvm 的临界资源来支持锁的相关语义,比如 redis,zookeeper 等。
步入正题
我今天就来分享下我司基于 redis 来实现的分布式锁,2013 年投入使用,也算是久经沙场。但是也存在一些设计上的缺陷,这个我后面也会提到,希望大家秉着互相学习的态度文明交流,别一上来就说这不行那不行,还是那句话 “适合自己的才是最好的”。
加锁过程分析
我第一次读代码的时候,有这么几个疑惑:
Q1:为什么不使用 SET key value [expiration EX seconds|PX milliseconds] [NX|XX] 这个指令来实现 key 的自动过期呢,反而放到应用代码判断 key 是否过期?
A1:我们的分布式锁开发的时候 SET 命令还不支持 NX、PX,所以才想出这种办法来实现 key 过期,NX、PX 在 2.6.12 以后开始支持;
Q2:已经判断了当前 key 对应的时间戳已经过期了,为什么还要使用 getset 再获取一次呢,直接使用 set 指令覆盖不可以吗?
A2:这里其实牵扯到并发的一些事情,如果直接使用 set,那有可能多个客户端会同时获取到锁,如果使用 getset 然后判断旧值是否过期就不会有这个问题,设想一下如下场景:
1.C1 加锁成功,不巧的是,这时 C1 意外的奔溃了,自然就不会释放锁;
2.C2,C3 尝试加锁,这时 key 已存在,所以 C2,C3 去判断 key 是否已过期,这里假设 key 已经过期了,所以 C2,C3 使用 set 指令去设置值,那两个都会加锁成功,这就闯大祸了;如果使用 getset 指令,然后判断下返回值是否过期就可以避免这种问题,假如 C2 跑的快,那 C3 判断返回的时间戳已经过期,自然就加锁失败;
释放锁过程分析
Q1:为什么释放锁时还需要判断 key 是否过期呢,直接 del 不是性能更高吗?
A1:考虑这样一种场景:
1. C1 获取锁成功,开始执行自己的操作,不幸的是 C1 这时被阻塞了;
2. C2 这时来获取锁,由于 C1 被阻塞了很长时间,所以 key 对应的 value 已经过期了,这时 C2 通过 getset 加锁成功;
3. C1 尘封了太久终于被再次唤醒,对于释放锁这件事它可是认真的,伴随着一波 del 操作,悲剧即将发生;
4. C3 来获取锁,好家伙,居然一下就成功了,接着就是一波操作猛如虎,接着就是一堆的客诉过来了;
为什么会这样呢?回想 C1 被唤醒以后的事情,居然敢直接 del,C2 活都没干完呢,锁就被 C1 给释放了,这时 C3 来直接就加锁成功,所以为了安全起见 C3 释放锁时得分成两步:1. 判断 value 是否已经过期 2. 如果已过期直接忽略,如果没过期就执行 del。这样就真的安全了吗?安全了吗?安全了吗?假如第一步和第二步之间相隔了很久是不是也会出现锁被其他人释放的问题呢?是吧?是的!有没有别的解决办法呢?听说借助 lua 就可以解决这个问题了。
正视自己的缺点
Q1:Redis 锁的过期时间小于业务的执行时间该如何续期?
A1:这个暂时没有实现,据说有一个叫 Redisson 的家伙解决了这个问题,我们也有部分业务在使用,未来有可能会切换到 Redisson。
Q2:怎么实现的高可用?
A2:我们采用 Failover 机制,初始化 redis 锁的时候会维护一个 redis 连接池,加锁或者释放锁的时候采用多写的方式来保障一致性,如果某个节点不可用的时候会自动切换到其他节点,但是这种机制可能会导致多个客户端同时获取到锁的情况,考虑这种情况:
1. C1 去 redis1 加锁,加锁成功后会写到 redis2,redis3;
2. C2 也去 redis1 加锁,但是此时 C2 到 redis1 的网络出现问题,这时 C2 切换到 redis2 去加锁,由于第一步中的 redis 多写并不是原子的,所有就有可能导致 C2 也获取锁成功;
针对这种情况,目前有些业务方是通过数据库唯一索引的方式来规避的,未来会修复这个 bug,具体方案目前还没有。
总结
希望对有些同学能起到帮助,不喜勿喷。