先把结论抛出来: Redis 无法正确实现分布式锁! 即使是 Redis 单节点也不行! Redis 的所谓分布式锁无法用在对锁要求严格的场景下, 比如: 同一个时间点只能有一个客户端获取锁.
首先来看下单节点下一般 Redis 分布式锁的实现, 其实就是个 set:
加锁:
- /**
- * 尝试获取分布式锁
- * @param jedis Redis 客户端
- * @param lockKey 锁
- * @param requestId 请求标识
- * @param expireTime 超期时间
- * @return 是否获取成功
- */
- public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
- String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
- if (LOCK_SUCCESS.equals(result)) {
- return true;
- }
- return false;
- }
可以看到, 加锁其实就一行代码: jedis.set(String key, String value, String nxxx, String expx, int time), 这个 set() 方法一共有五个参数:
(1) 第一个为 key, 我们使用 key 来当锁, 因为 key 是唯一的.
(2) 第二个为 value, 我们传的是 requestId, 很多童鞋可能不明白, 有 key 作为锁不就够了吗, 为什么还要用到 value? 原因就是我们在上面讲到可靠性时, 分布式锁要满足第四个条件解铃还须系铃人, 通过给 value 赋值为 requestId, 我们就知道这把锁是哪个请求加的了, 在解锁的时候就可以有依据. requestId 可以使用 UUID.randomUUID().toString() 方法生成.
(3) 第三个为 nxxx, 这个参数我们填的是 NX, 意思是 SET IF NOT EXIST, 即当 key 不存在时, 我们进行 set 操作; 若 key 已经存在, 则不做任何操作;
(4) 第四个为 expx, 这个参数我们传的是 PX, 意思是我们要给这个 key 加一个过期的设置, 具体时间由第五个参数决定.
(5) 第五个为 time, 与第四个参数相呼应, 代表 key 的过期时间
解锁:
- /**
- * 释放分布式锁
- * @param jedis Redis 客户端
- * @param lockKey 锁
- * @param requestId 请求标识
- * @return 是否释放成功
- */
- public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
- String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
- Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
- if (RELEASE_SUCCESS.equals(result)) {
- return true;
- }
- return false;
- }
解锁也很简单, 只需要两行代码就搞定了! 第一行代码, 我们写了一个简单的 Lua 脚本代码, 第二行代码, 我们将 Lua 代码传到 jedis.eval() 方法里, 并使参数 KEYS[1] 赋值为 lockKey,ARGV[1] 赋值为 requestId.eval() 方法是将 Lua 代码交给 Redis 服务端执行, 首先获取锁对应的 value 值, 检查是否与 requestId 相等, 如果相等则删除锁 (解锁). 使用 Lua 语言主要是确保上述操作是原子性的.
看上去似乎是完美无瑕的一种分布式锁的实现方式, 我们重新看下加锁的代码:
- public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
- String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
- if (LOCK_SUCCESS.equals(result)) {
- return true;
- }
- return false;
- }
场景 1:
线程 1 在执行 set 的时候, Redis 服务端已经执行成功, 但是因为网络原因, 响应还没有返回给客户端, 过了 expireTime 时间以后, 响应终于回来了, 对于线程 1 来说, 它是拿到了分布式锁的, 但是注意, 此时的锁已经是失效的了! 如果此时又来个线程 2 申请加锁, 显然也能获取锁, 因为线程 1 的锁已经失效了, 此时就会有 2 个线程同时获取锁!
场景 2:
线程 1 执行完 set 以后, Redis 服务端执行成功, 在执行 if 的时候, jvm 发生了 FullGC, 应用暂停, 超过了 expireTime 以后, GC 完成, 程序继续执行, 此时线程 1 仍然认为自己是持有锁的, 实际上锁已经过期了! 如果此时线程 2 又来申请加锁, 成功, 此时线程 2 也获得了锁, 因此也会出现 2 个线程同时执行被锁保护的代码的情况!
综上, 可以看出来, 就算是在单节点情况下, Redis 也是无法实现严格意义上的分布式锁的!
如果想要实现严格意义上的分布式锁呢? 最常用的就是 zookeeper 了. 我们来看下 zookeeper 为啥可以实现分布式锁.
zookeeper 实现分布式锁的步骤:
假设锁空间的根节点为 / lock:
(1) 客户端连接 zookeeper, 并在 / lock 下创建临时的且有序的子节点, 第一个客户端对应的子节点为 / lock/lock-0000000000, 第二个为 / lock/lock-0000000001, 以此类推.
(2) 客户端获取 / lock 下的子节点列表, 判断自己创建的子节点是否为当前子节点列表中序号最小的子节点, 如果是则认为获得锁, 否则监听 / lock 的子节点变更消息, 获得子节点变更通知后重复此步骤直至获得锁;
(3) 执行业务代码;
(4) 完成业务流程后, 删除对应的子节点释放锁.
上面的步骤可以看出来, zookeeper 跟 Redis 不一样, 它是完全不依赖客户端的状态的, 因此 zookeeper 才可以严格实现分布式锁!
Redis 的分布式锁是不是就一无是处了呢? 当然不是! 在一些要求不是那么严格的场景下还是可以使用的, 比如: 凌晨 1 点执行定时任务出报表, 哪怕是执行 2 次也没什么问题.
参考文献:
- https://www.cnblogs.com/linjiqin/p/8003838.html
- http://zhangtielei.com/posts/blog-redlock-reasoning.html
- https://blog.csdn.net/qiangcuo6087/article/details/79067136
来源: http://www.bubuko.com/infodetail-3339606.html