Version: Next

Redis 分布式锁

参考博客与拓展读物


Redis 分布式锁实现

Redis 分布式锁,实际上就是 设置一个 String 记录加锁解锁 过程就是对这个记录的操作过程;获取锁释放锁 类似于 CAS 操作,不过是通过 LUA 脚本实现的

通过 SETNX 命令设置 Redis 分布式锁(简陋)

加锁

SETNX KEY Value

  • SETNX 表示 Set If Not Exist 不存在才创建
  • 创建成功返回 1,失败返回 0
  • 例如 SETNX Lock 1
    • 返回 1 表示加锁成功
    • 返回 0 表示加锁失败

释放

  • DEL Lock

死锁的出现与处理

上述加锁方式,如果在加锁之后,程序挂了,那么解锁代码就无法执行到,从而出现死锁

处理方式——加过期时间

  • 通过 EXPIRE Key TimeValue 实现
SETNX lock 1
EXPIRE lock 10

问题 1:上述过程不是原子操作

  • 可能出现执行了 SETNX 然后挂了没执行 EXPIRE 的情况,导致设置过期时间失败

问题 2:锁提前释放——释放了其他客户端的锁

  • 由于种种原因(网络、卡顿),程序正常执行的时间超过了锁过期时间,导致程序没运行完锁就被放了
  • 接着,另一个客户端获取了锁
  • 最后,起初的程序执行完了自己的代码,最后执行释放锁的代码,此时持有锁的已经是另一个客户端程序了,于是出现了 释放了别人的锁

问题处理

问题 1:原子操作

  • Redis 2.6.12 后提供了扩展语法
SET key value EX expireTimeValue NX
  • 例如想要实现 SETNX 且过期时间为 10 秒 可以使用
    • SET lock 1 EX 10 NX

问题 2:锁提前被释放——释放了别人的锁

  • 把设置的 value1 之类的魔法值,更改为 UUID 或者其他具有唯一性的东西
  • 在释放锁前,判断锁上的 UUID (通过 GET 获取)与当前程序生成的 UUID 是否一致
    • 一致:可以释放
    • 不一致:这是别人的锁我不能释放
  • 提前释放问题看后面的 Redisson

问题 2 的解决带来问题 3 :获取 GET 操作与 DEL 释放操作不是原子操作

  • Redis 没有提供原子性的扩展语法

问题 3 的解决——使用 LUA 脚本

  • 通过 eval() 方法执行 LUA 脚本,Redis 保证了将一段 LUA 脚本看做一个整体,因此 LUA 脚本具有原子性
// 判断锁是自己的,才释放
if redis.call("GET",KEYS[1]) == ARGV[1]
then
return redis.call("DEL",KEYS[1])
else
return 0
end

使用 SETNX 设置分布式锁的步骤总结

  1. 通过 SET [KEY] [UUID] EX [TimeValue] NX 设置分布式锁
  2. 操作共享资源
  3. 使用 LUA 脚本保证原子性, 判断 锁的值,是自己的锁进行 释放
注意

上述过程没有实现 可重入锁


Redisson 实现分布式锁

Redisson 实现可重入分布式锁

  • 实际使用就直接用 Redission 的 API 创建锁就行了,和正常使用锁没啥区别
  • 底层:Redisson 实现可重入锁其实和普通的可重入锁思想基本一致
    • 判断当前尝试获取锁的线程是不是当前持有锁的线程
      • 是:用一个值记录 重入次数 然后进行 +1 操作
        • Redissson 底层使用 Redis 的 Hash 数据结构实现上述操作
        • 使用 Hash 的命令进行加 1
        • 为了保证比较线程、计算重入次数的过程的原子性,依然使用了 LUA
      • 否:通过 LUA 执行 hset 指令,把当前持有锁的过期时间设置起来,等待过期时间到达后,再次尝试获取锁
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit,
long threadId, RedisStrictCommand<T> command) {
//过期时间
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
//如果锁不存在,则通过hset设置它的值,并设置过期时间
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
//如果锁已存在,并且锁的是当前线程,则通过hincrby给数值递增1
"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; " +
//如果锁已存在,但并非本线程,则返回过期时间ttl
"return redis.call('pttl', KEYS[1]);",
Collections.<Object>singletonList(getName()),
internalLockLeaseTime, getLockName(threadId));
}

Redisson 处理锁提前被释放问题

  • Redisson 内部提供了 Watch Dog 看门狗功能
  • 一个守护进程,周期性检查,如果过期时间已经到了,程序还没有提出释放锁,那么就为它进行 续期
  • 看门狗自己有个超时时间 lockWatchDogTimeout,以防止某服务不是较为正常的执行时间过长,而是已经宕机了挂了从而产生无限续期,发生死锁

主从集群+哨兵模式下的分布式锁

问题出现场景 1

  • 在主节点上加了个锁,锁还没被释放,主节点挂了
  • 从节点顶上升级为主节点,但 分布式锁 丢了

问题出现场景 2

  • 非集群场景下也可能发生
  • 程序执行过程中发生了 GC,GC 运行到了 Stop-The-World时间过久,出发了分布式锁超时过期,此时另一个程序持有了锁,原程序恢复正常运行(Watch Dog 续期超过看门狗超时时间)
  • 此时出现 两个程序持有锁

Redlock(红锁)

Redis 作者提出的 Redlock(红锁) 的解决方案基于 2 个前提:

  1. 只用的到主库,用不上从库和哨兵实例
  2. 主库要部署多个,官方推荐至少 5 个实例

也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个互不相关的实例。

注意:不是部署 Redis Cluster,就是部署 5 个简单的 Redis 实例。

在看具体如何实现 Redlock, 整个流程如下

  1. 客户端先获取「当前时间戳 T1」
  2. 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),并设置超时时间(毫秒级),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
  3. 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳 T2」,如果锁的租期 > T2 - T1 ,此时,认为客户端加锁成功,否则认为加锁失败
  4. 加锁成功,去操作共享资源
  5. 加锁失败或操作结束,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)

整个逻辑说白了就是大家都去抢注锁,谁抢注的多谁就拥有锁

这里面有几个重点

  1. 客户端必须在多个 Redis 实例上申请加锁
  2. 必须保证大多数节点加锁成功
  3. 大多数节点加锁的总耗时,要小于锁设置的过期时间(租期不要设置的过短,防止锁还没抢到手,就失效了)
  4. 释放锁,要向全部节点发起释放锁请求,防止锁残留(锁残留指定是,实际加锁成功但返回结果时由于网络等原因客户端以为加锁失败。)

Redlock(红锁) 通过向当前存活的多个主库抢注锁,实现了即使部分节点不可用,也不会影响到分布式锁系统,解决主从切换时,锁丢失的问题

Redlock(红锁) 和 主从集群 + 哨兵的模式 并不冲突,Redlock(红锁) 只要求有5个主库,至于这5个主库背后有没有从库和哨兵,它并不关心

注意 Redlock(红锁) 依然需要搭配 Redisson 来解决锁提前过期的问题

那么 Redlock(红锁) 真的安全吗?有人并不这么认为

红锁争论


MySQL 如何实现分布式锁

在 MySQL 中创建一张表,设置一个 主键UNIQUE KEY,这就是要 key

  • 这样一来,同一个 Key 在表中只能插入一次
  • 这样,锁竞争就交给了数据库,处理同一个 Key 数据库保证了只有一个节点能插入成功,其他节点全部失败

数据库分布式锁的实现

  • 加锁形式是向数据库中插入一条数据
  • 利用主键索引 | 唯一索引,将具体一条数据的对应字段作为分布式锁
  • 其他尝试操作的节点必须先删除对应的数据库记录才能操作
伪代码
def locl:
exec sql: insert into lock_table (xxx) values (xxx)
if result == true:
return true
else
return false
def unlock:
exec sql: delete from locl_table lockRecord where order_id = 'order_id'