Redis 分布式锁演进


笔者在公司发现哪怕在同一个部门,每个项目使用 redis 作为分布式锁的姿势都不一样,因此总结下常见的几种用法以及各自存在的问题。

分布式锁要实现的内容

  • 互斥性:任意时刻,只有一个客户端能持有锁。
  • 锁超时释放:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁。
  • 可重入性::一个线程如果获取了锁之后,可以再次对其请求加锁。
  • 高性能和高可用:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效。
  • 安全性:锁只能被持有的客户端删除,不能被其他客户端删除

方案一:SET EX PX NX

SET key value [EX seconds] [PX milliseconds] [NX|XX]

早年还有 SETNX + EXPIRE 的方案,但是无法保证原子性,虽然可以通过 Lua 脚本实现一定程度上的原子性,但是如果Redis仅执行了一条命令后crash或者发生主从切换,依然会出现锁没有过期时间,最终导致无法自动释放。

Redis 2.6.12 版本后支持 SET 加NX、过期时间等参数,保证 set 和加过期时间的原子性。

存在的问题

  • Client 1 获取到锁之后,因为GC等进入等待或者执行逻辑时间过长,导致锁自动失效
  • 这时 Client 2 便可以获取到锁,此时相当于两个 Client 同时获得了锁(互斥)
  • 如果 Client 1 先执行完,则会释放 Client 2 的锁,可能出现 Client 3 也获取到了锁等等极端 case。(安全性)

方案二:SET EX PX NX + Value 校验

String randomVal = random+timestamp
set lockKey randomVal NX PX 1000
try {
        doSomething...
} finally {
        // unlock
        if (randomVal.equals(redisClient.get(lockKey)) {
                redisClient.del(lockKey)
        }
}

本方案主要解决的是安全性问题,谁提出谁负责,谁加锁谁才能解锁。

由于解锁时的两个方法非原子操作,可能出现 value 比较完成后,锁发生超时失效,被别人占用,此时再往下走就会释放别人的锁。所以解锁这里应当使用 Lua 脚本替代,来保证原子性。

存在的问题

  • 仍然存在锁自动过期,业务却还没执行完成的问题(互斥)

方案三:Redisson

Untitled

Redisson 也是最常用的 redis 客户端,从上图可以看出来其提供的能力非常丰富。

它提供 watch dog 机制为锁自动续期。

Watch Dog 机制其实就是一个后台定时任务线程,获取锁成功之后,会将持有锁的线程放入到一个 RedissonLock.EXPIRATION_RENEWAL_MAP 里面,然后每隔 10 秒 (internalLockLeaseTime / 3) 检查一下,如果客户端 1 还持有锁 key(判断客户端是否还持有 key,就是判断 key 和 锁实例id+线程 id 在 Redis 中是否存在该 hash,如果存在就会延长 key 的时间),那么就会不断的延长锁 key 的生存时间默认 30s。

注意:这里有一个细节问题,如果服务宕机了,Watch Dog 机制线程也就没有了,此时就不会延长 key 的过期时间,到了 30s 之后就会自动过期了,其他线程就可以获取到锁。

Redisson 采用 hash 结构维护锁:

if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1); 
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil; 
end
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then 
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil; 
end; 
return redis.call('pttl', KEYS[1]);
  • 先判断加锁 key 存不存在,不存在就新建hash结构(hincrby命令如果不存在hash则会新建一个hash)通过 hincrby 给指定的filed+1,KEYS[1] 是加锁key,KEYS[2]是客户端id(锁实例的UUID属性(每个锁实例会生成一个uuid)+线程id)hash的 value 是重入次数。
  • 例子:127.0.0.1:6379> HGETALL myLock

    1) “285475da-9152-4c83-822a-67ee2f116a79:52”

    2) “1”

实现阻塞等待获取锁

当锁正在被占用时,等待获取锁的进程并不是通过一个 while(true) 死循环去获取锁,而是利用了 Redis 的发布订阅机制,通过 await 方法阻塞等待锁的进程,有效的解决了无效的锁申请浪费资源的问题。等待结束后会重新请求获取锁。

存在的问题

  • 在生产环境中,往往都存在 Redis 主从架构的存在,导致可能存在 master 宕机后,锁数据没有及时同步到 slave,导致 slave 升主后,其他 client 仍然可能获取到之前的锁。(互斥)

方案四:RedLock

Redis 作者 antirez 提出一种高级的分布式锁算法:Redlock,目前是要解决集群环境下 failover 造成的问题。红锁算法认为,只要(N/2) + 1个节点加锁成功,那么就认为获取了锁, 解锁时将所有实例解锁

Untitled

存在的问题

  • Redlock 对系统时间 (timing) 过分依赖,例如:
    • case 1:
      1. 客户端1从Redis节点A, B, C成功获取了锁(多数节点)。由于网络问题,与D和E通信失败。
      2. 节点C上的时钟发生了向前跳跃,导致它上面维护的锁快速过期。
      3. 客户端2从Redis节点C, D, E成功获取了同一个资源的锁(多数节点)。
      4. 客户端1和客户端2现在都认为自己持有了锁。
    • case 2:
      1. 获取当前时间。
      2. 完成获取锁的整个过程(与N个Redis节点交互)。
      3. 再次获取当前时间。
      4. 把两个时间相减,计算获取锁的过程是否消耗了太长时间,导致锁已经过期了。如果没过期。
      5. 客户端持有锁去访问共享资源。(3或4或5发生停顿,也会导致锁实际失效,但是当前线程仍会以为自己持有锁,即客户端没能够在锁的有效性过期之前完成与共享资源的交互

总结

Redis 可以在一定程度上实现分布式锁,但无法保证 100% 的正确性。如果要更严谨的解决方案可以考虑使用数据库、ZK 等等,很多时候技术方案都是在做 tradeoff,人生也是一样,鱼和熊掌不可兼得。

参考

https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

http://zhangtielei.com/posts/blog-redlock-reasoning.html

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

Scroll to Top