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 1EXPIRE 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:锁提前被释放——释放了别人的锁
- 把设置的
value
从1
之类的魔法值,更改为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]thenreturn redis.call("DEL",KEYS[1])elsereturn 0end
使用 SETNX 设置分布式锁的步骤总结
- 通过
SET [KEY] [UUID] EX [TimeValue] NX
设置分布式锁- 操作共享资源
- 使用
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 个前提:
- 只用的到主库,用不上从库和哨兵实例
- 主库要部署多个,官方推荐至少 5 个实例
也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个互不相关的实例。
注意:不是部署 Redis Cluster,就是部署 5 个简单的 Redis 实例。
在看具体如何实现 Redlock, 整个流程如下
- 客户端先获取「当前时间戳 T1」
- 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),并设置超时时间(毫秒级),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
- 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳 T2」,如果锁的租期 > T2 - T1 ,此时,认为客户端加锁成功,否则认为加锁失败
- 加锁成功,去操作共享资源
- 加锁失败或操作结束,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)
整个逻辑说白了就是大家都去抢注锁,谁抢注的多谁就拥有锁
这里面有几个重点
- 客户端必须在多个 Redis 实例上申请加锁
- 必须保证大多数节点加锁成功
- 大多数节点加锁的总耗时,要小于锁设置的过期时间(租期不要设置的过短,防止锁还没抢到手,就失效了)
- 释放锁,要向全部节点发起释放锁请求,防止锁残留(锁残留指定是,实际加锁成功但返回结果时由于网络等原因客户端以为加锁失败。)
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 trueelsereturn falsedef unlock:exec sql: delete from locl_table lockRecord where order_id = 'order_id'