众所周知,redis 分布式锁使用 SET
指令可以实现,但是仅仅使用该命令就行了吗?是否还需要考虑 CAP
理论。
要是有上面说的那么简单就好喽,我们平时在开发中用到的分布式锁方案可能比较简单,这个取决于业务的复杂程度以及并发量。
下面我们来说说在高并发场景中,该如何正确使用分布式锁。
在正式讲解分布式锁之前,先来看下将要围绕展开来讲的几个问题:
什么场景使用分布式锁?
在同一时刻,只能有一个线程去读写一个【共享资源】,也就是高并发的场景下,通常为了保证数据的正确,需要控制同一时刻只允许一个线程访问。
此时就需要使用分布式锁了。
简而言之,分布式锁就是用来控制同一时刻,只有一个线程可以访问被保护的资源。
分布式锁的特性
-
无死锁:都有机会获得锁,即使获取锁的客户端挂掉;
-
互斥:在任何的某一时刻,只有一个线程可以持有锁;
可以使用 SETNX key value
命令实现互斥的特性。
解释下:如果 key
不存在,则设置 value
给这个key
,否则啥都不做。
该命令的返回值有如下两种情况:
- 1:表示设置成功;
- 0:表示 key 没有设置成功。
举例如下:
成功的情况:
> SETNX lock:101 1 (integer) 1 # 获取 101 锁 成功
失败的情况:
> SETNX lock:101 2 (integer) 0 # 后面申请锁 获取失败
可见,成功的就可以开始使用「共享资源」了。
使用结束后,要及时释放锁,给后面申请获得资源的机会。
那么该如何释放锁呢?
释放锁比较简单,使用 DEL
命令删除这个 key
就可以了。如下:
> DEL lock:101 (integer) 1
分布式锁简单使用方案如下:
这看起来不是挺简单的吗,能有什么问题?往下听我分析。
首先该方案存在一个锁无法被释放的问题,场景如下:
- 服务重启了,导致无法正常释放锁;
- 业务逻辑异常,无法执行
DEL
指令。
可见,这个锁就会一直被占用,导致其它客户端也拿不到这个锁了。
key 设置过期时间
优化一点:可以考虑在获取锁成功的时候给 key 设置一个「过期时间」
设置举例如下:
> SETNX lock:101 1 // 获取锁 (integer) 1
> EXPIRE lock:101 60 // 60s 过期删除 (integer) 1
可见,60 秒后后该锁就好释放掉,其他客户就可以申请使用了。
说实话,这种写法太 low 了,仍然存在问题。
由上面举例可知:加锁和设置过期时间是两个操作命令,并不是原子操作。
试想一下,可能存在这么个情况:
比如执行第一条命了成功,第二条命令还没来得及执行就出现了异常,导致设置「过期时间」失败,这样锁也是无法释放。
不要慌,Redis 2.6.X 之后,官方对
SET
命令的参数进行了扩展,即 key 不存在的时候设置 value,同时设置过期时间,这样就可以解决以上说到的问题了,即满足了原子性。
SET keyName value NX PX 30000
- NX:表示当
keyName
不存在的时候才SET
成功,从而可以成功获得锁; - PX 30000:表示获得的锁有设置 30 秒的过期时间。
这样一看,似乎没啥毛病。不,仔细一看,写的还是不够严谨。想下,有没可能释放的不是自己加的锁。
思考中……
释放的锁不是自己的
说下什么场景下,释放的锁不是自己的:
- A 获取锁成功并设置过期时间 30 秒;
- A 由于不知什么原因执行比较慢(有可能是网络延迟问题、此时正在 GC 等),30 秒过后还没执行完,但是锁已经过期释放了;
- B 此时过来申请加锁成功;
- 接着 A 开始执行
DEL
释放锁指令,这个时候就会把 B 的锁释放了。
所以,有个关键点需要注意的是:只能释放自己申请的锁。
总之,解铃还须系铃人
解决释放不是自己的锁问题:
可以在加锁的时候设置一个「唯一标识」作为
value
值。在释放锁的时候,判断获取的 value 值与锁的 value 值是否相等,相等则删除,否则不能释放锁。
伪代码如下:
// 判断 value 与 锁的唯一标识
if (redis.get("lock:101").equals(value)){
redis.del("lock:101"); // 相等就删除
}
复制代码
同样也是需要考虑原子性问题,因为这也这是
GET + DEL
指令组合而成的。
此时,我们可以考虑通过 Lua
脚本来实现,这样判断和删除的过程就是原子操作了。
// 获取锁的 value 值与 ARGV[1] 比较,匹配成功则执行 del
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
复制代码
使用上面的脚本,为每个锁分配一个随机字符串“签名”,只有当删除锁的客户端的“签名”与锁的 value 匹配的时候,才会去删除它。
遇到问题不要慌,先从官方文档入手:redis.io/topics/dist…
到目前为止,以上修改后(优化后)的方案算相比较完善的了,业界大部分使用的也都是该方案。
锁的过期时间设置多少才合适呢?
当然这个锁的过期时间不能瞎写,通常是根据多次测试后的结果来做选择,比如压测多轮之后,平均执行时间在 300 ms。
那么我们的锁过期时间就应该放大到平均执行时间的 3~4 倍。
有些小伙伴可能会问:为啥是放大 3~4 倍呢 ?
这叫凡事留一手:考虑到锁的操作逻辑中如果有网络 IO 操作等,线上的网络不会总是稳定的,此时需要留点时间来缓冲。
我们可以让获得锁的线程开启一个守护线程,用来给快要过期的锁「续航」。
加锁的时候设置一个过期时间,同时客户端开启一个「守护线程」,定时去检测这个锁的失效时间。
如果快要过期,但是业务逻辑还没执行完成,自动对这个锁进行续期,重新设置过期时间。
道理大家都懂,可是这样的代码该怎么写呢。
可以先谷歌一下,相信谷歌大哥会告诉你有这么一个库把这些工作都封装好了,你只管用就是了,它叫 Redisson。
在使用分布式锁的时候,其实就是采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。
记过多次优化,方案也算比较「完美」了,抽象出对应的模型如下。
- 首先通过
SET keyName randomValue NX PX expireTime
,同时启动守护线程为快要过期但还没执行完的客户端的锁续命; - 然后客户端执行业务逻辑,操作共享资源;
- 最后通过
Lua
脚本来释放锁,通常先 get 锁,判断锁是不是自己加的,然后再执行DEL 操作
。
这个方案可以说很 OK 了,能想到这些的优化点已经击败一大批程序猿了。
对于追求极致的程序员来说,你们可能会考虑到:
- 在代码具体什么位置加、解锁?
- 怎么考虑实现可重入锁?
- 要不要考虑主从架构带来的问题?
- ......
这里就不展开讨论了。有兴趣的可以在评论区一起讨论交流哈。
总结
- 到这里,分布式锁涉及的问题,以及该如何正确使用的方案都讲完了,相信你看到这里,多少会有点收获。其实,不管用什么做分布式锁都会存在的问题,重要的是自己思考的过程。
- 此时,建议你合眼睛,自己在脑子里回顾一下,分布式锁的每一步都在做什么,为什么要这么做,解决什么问题。
- 对于系统的设计,出发点都不一样,设计出来的方案也不尽相同,没有最好的框架,也没有最好的方案,只有最合适的。