前言
谈一下自己实现Redis分布式锁的过程以及相应的思考
要考虑什么
技术服务于业务,业务上需要什么技术就要提供什么
锁应该满足以下条件:
- 同一个锁在同一时间只能被一个线程拥有
- 线程不能释放不属于自己的锁
- 拥有锁的线程可以重复获取所拥有的锁
- 锁有时间限制
实现
先考虑1,2,3条,这三条无非是关于锁的归属问题,我们只需要将对应 key
的 value
设置
为线程 id
表明归属,加解锁前先判断即可
get test-lock # 锁不存在
set test-lock thread-1 # thread-1 是锁的拥有者
但是稍微考虑可以发现以下问题
# Thread-1 & Thread-2
get test-lock # 同时发现锁不存在
set test-lock thread-1 # Thread-1设置锁
set test-lock thread-2 # Thread-2设置锁
也就是两个线程同时发现锁不存在并且都尝试获取锁的时候,其中一个线程获取的锁会被另一个线程覆盖,违反第1条,所以 判断锁是否存在及后续获取锁应该是原子操作
然后考虑第4条,也是比较简单,获取锁后设置过期时间即可
set test-lock thread-1
expire test-lock 100 # 100s后过期
这里也可以发现问题,如果线程获取锁之后设置过期时间之前挂了,那么锁将一直存在导致所有需要锁的线程饥饿
所以获取锁和设置过期时间也应该是原子操作
Redis的原子操作
redis其实已经替我们考虑到了原子问题,从文档摘取和需求相关的指令
SETNX
如果值不存在就设值,否则什么也不做,解决1,2,3条中出现的问题
SETEX
设置值的同时设置过期时间,解决第4条出现的问题
以上两个指令好像解决了问题 ,但是为了能使锁正常工作,我们的需求会升级成:
- 如果锁不存在那么获取锁并设置过期时间(第1,4条)
- 如果锁存在那么判断当前线程是否为拥有者,如果是则刷新过期时间(第3条)
- 如果锁存在那么判断当前线程是否为拥有者,如果是则删除锁(第2条)
查看Redis的指令集并没有发现相关指令,怎么办?
Lua
Lua是一门脚本语言,先看下Redis文档中对 Lua
的相关应用
可以发现Redis的 EVAL
指令接收 Lua
脚本作为参数并将整个脚本当成原子操作
所以升级后的需求我们使用Lua
脚本就可以实现了,给出示例代码:
public Boolean lock(String key, String holder, Long expireTime) {
String lockScript = "if redis.call('set', KEYS[1], KEYS[2], 'ex', KEYS[3], 'nx') then " +
"return 1 " +
"elseif redis.call('get', KEYS[1]) == KEYS[2] then " +
"return redis.call('expire', KEYS[1], KEYS[3]) " +
"else " +
"return 0 " +
"end";
return connection.sync().eval(lockScript, ScriptOutputType.BOOLEAN, key, holder, String.valueOf(expireTime));
}
public Boolean unlock(String key, String holder) {
String unlockScript = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
return connection.sync().eval(unlockScript, ScriptOutputType.BOOLEAN, key, holder);
}
题外
业界其实有挺多成熟的分布式锁方案,即使不直接拿来用也有很大的参考意义。例如参考Redisson时就发现对于锁的重入方面,锁有计数器,进入一次计数器加一,释放一次计数器减一,在计数器为0时则删除锁。这样的设计虽然在我的业务场景上用不上,但以后有更多业务场景时就可能要借鉴它。
然后关于Redis的客户端估计大多数人都是用的 Jedis
,毕竟它是如此成熟好用。其实这里我想推荐新秀Lettuce,理由只有一条 ——Spring Boot已经将默认的Redis实现由 Jedis
更改为 Lettuce
最后实现分布式锁时我们不仅要考虑加解锁逻辑的正确性,还要考虑Redis本身的可用性,所以 哨兵模式
了解一下