当前位置:首页 > 问答 > 正文

用Redis搞个阻塞分布式锁,感觉比想象中复杂还好玩儿

(引用来源:知乎问题“用Redis搞个阻塞分布式锁,感觉比想象中复杂还好玩儿”下的高赞回答,作者“小灰”)

用Redis搞个阻塞分布式锁,感觉比想象中复杂还好玩儿,一开始你以为不就是个锁嘛,SET一个key,完了DEL掉,多简单,但真动手做,才发现坑是一个接一个,每个坑都挺有意思,像在解谜。

第一个大坑:原子性

最天真的想法是,客户端A执行SET lock_key client_id,如果返回OK,说明抢到锁了,用完后再DEL lock_key释放锁,听着没问题对吧?但万一客户端A在SET之后、DEL之前,程序崩溃了怎么办?这个锁就永远没人能释放了,成了“死锁”,其他客户端全都得干等着。

得给锁加个“过期时间”,想法升级为:SET lock_key client_id EX 10,10秒后自动过期,这样即使客户端挂了,锁也能自己解开,但这里又有个小坑:SET和设置过期时间必须是原子操作,你不能先SET,再EXPIRE,因为万一SET成功,但还没执行EXPIRE客户端就崩了,又死锁了,幸好Redis提供了把SET和过期时间写在一起的命令,一步到位,解决了这个问题。

第二个大坑:释放锁的权限

锁加了过期时间,安心了不少,现在客户端A抢到锁,执行操作,然后准备DEL key释放锁,但你想过没有,万一客户端A因为某些原因(比如垃圾回收)卡住了,超过了10秒,锁自动释放了,此时客户端B成功抢到锁,然后客户端A缓过劲儿来了,它还以为锁是自己的,顺手就是一个DEL……完蛋,客户端B的锁被A给删了!客户端C一看锁没了,也冲进来,系统就乱套了。

锁不能乱删,你得确保“谁加的锁,谁才能删”,解决办法就是在SET的时候,value不要用简单的1或true,而是用一个唯一标识,比如UUID或者客户端ID,释放锁的时候,先GET lock_key看看value是不是自己当初设置的那个,如果是,才执行DEL

但这里又又又有个坑!GETDEL是两个操作,不是原子的,你GET发现value是自己的,正准备DEL,这时候锁过期了,而且被客户端B抢去了(value已经是B的了),你紧接着的DEL操作又会把B的锁误删掉。

用Redis搞个阻塞分布式锁,感觉比想象中复杂还好玩儿

判断value和删除这个动作,也必须是原子的,这时候就需要用到Lua脚本了,Redis支持用Lua脚本一次性执行多条命令,保证原子性,脚本大概长这样:if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end,这样才安全地实现了“只能删自己的锁”。

第三个大坑,也是最玩儿的地方:阻塞

上面说的,其实只是个“非阻塞锁”,如果锁被占用,其他客户端只能失败返回,或者不停地重试(这叫“忙等待”),很浪费资源,我们想要的是“阻塞锁”:拿不到锁就在那里等着,一旦锁被释放,能立刻被唤醒去抢。

怎么实现“等”和“唤醒”呢?最简单的想法是让客户端隔一秒检查一次(轮询),但这样有延迟,也不够优雅。

更“Redis”的做法是利用其“发布订阅”功能,思路是这样的:

用Redis搞个阻塞分布式锁,感觉比想象中复杂还好玩儿

  1. 所有没抢到锁的客户端,都SUBSCRIBE(订阅)一个特定的频道,比如lock_channel
  2. 当某个客户端释放锁的时候,它在删除锁之后,再向lock_channel``PUBLISH(发布)一条消息,锁释放啦!”
  3. 所有订阅了这个频道的客户端都会收到消息,它们一收到消息,就立刻跳起来再去尝试抢锁。

但这里有个非常精妙的竞争条件,特别好玩:客户端A释放锁,DEL锁和PUBLISH消息这两个动作,即便用Lua脚本保证原子性,也没法完美解决,想象一下这个顺序:

  1. 客户端A执行删除锁的Lua脚本,锁被释放。
  2. 一直在轮询的客户端B瞬间检测到锁没了,成功SET,抢到了锁。
  3. 紧接着,客户端A的PUBLISH消息才发出去。
  4. 客户端C收到消息,也去抢锁,但此时锁已经被B持有了,C只能继续等待。

你看,B通过轮询“偷跑”成功了,而C虽然收到了通知,却还是没抢到,这其实没问题,因为我们的目标是保证同一时间只有一个客户端能拿到锁,B和C谁抢到都行,但这样会导致C白白激动一次,更高效的做法是,收到通知的客户端在抢锁前,再检查一下锁是否确实已经被释放(也就是还是用轮询的思想,但只在收到通知后才轮询一次),避免无效的争抢。

感觉比想象中复杂还好玩儿

你看,就这么一个锁,从最简单的SET/DEL,到加过期时间防止死锁,到用唯一value防止误删,再到用Lua脚本保证原子操作,最后用发布订阅实现阻塞通知,每一步都是为了解决一个实际场景中必然会出现的坑。

这个过程就像搭积木,或者解决一个逻辑谜题,你以为解决了,马上就有新的边界情况冒出来挑战你,当你用Lua脚本、发布订阅这些基础积木,最终搭出一个健壮的分布式锁时,那种成就感是非常足的,它让你深刻地理解了分布式系统里“时间”和“顺序”的微妙性,也感受到了Redis这种简单工具所能构建出的强大能力。

所以有人说,你能亲手实现一个正确的Redis分布式锁,就算是对分布式并发问题入门了,这话一点也不假,它看似简单,却内涵乾坤,确实又复杂又好玩儿。