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

Redis 分布式锁那些花样你真都用过吗,聊聊不同实现和坑

网络技术博客与社区讨论综合)

Redis分布式锁,说白了就是在分布式系统里,大家抢着用同一个资源的时候,用来保证同一时间只有一个人能用的一个家伙事儿,听起来挺简单的,不就是用一个Redis的key来占坑嘛,但这里面的花样和坑可多了去了,今天就来聊聊几种常见的实现和它们各自的问题。

最古老也是最简单的办法,就是用SETNX命令,这个命令的意思是,如果这个key不存在,我就设置它(相当于拿到锁);如果已经存在了,我就不设置(拿锁失败),以前很多人这么写:SETNX lock_key 1,然后业务操作完了,再用DEL lock_key把锁删掉,但这有个天坑:如果拿到锁的那个服务节点突然挂掉了,它还没来得及删锁,那这个锁就永远留在Redis里了,其他所有人都别想再拿到锁,资源就被死锁了,为了解决这个“锁永久死锁”的问题,大家想了个招儿:给锁加个过期时间,一开始有人用SETNX拿到锁后,紧跟着用EXPIRE命令给锁设置一个几秒钟的过期时间,但这又引出新问题了,这两个命令不是原子的呀!万一SETNX成功了,正要执行EXPIRE的时候,服务崩了,过期时间没设上,锁又成永久的了。

(来源:Redis官方文档及早期开发者实践)

Redis 2.6.12版本之后,救星来了:一个命令同时完成SETNX和EXPIRE的功能,写法是:SET lock_key random_value NX PX 30000,这个命令是原子性的,NX表示只有key不存在时才设置,PX 30000表示过期时间是30秒,这样,就算持有锁的节点挂了,最多等30秒锁自动释放,不会永久死锁,看起来完美了是吧?但坑又来了:释放锁的时候不能简单用DEL了,想象一下,节点A拿到了锁,设定的过期时间是30秒,但可能因为某些原因(比如垃圾回收导致程序卡顿),它实际执行业务逻辑花了35秒,这时候,锁在30秒的时候已经自动过期释放了,节点B趁机拿到了锁,然后节点A卡顿结束,还傻乎乎地执行了DEL命令,结果把节点B刚创建的锁给删了!这就乱套了,释放锁的时候,需要判断一下这个锁是不是自己当初设置的,这就是为什么上面命令里value要用一个随机字符串(random_value),在删除锁之前,要先GET一下lock_key的值,看看是不是自己设置的那个random_value,如果是,才能删;如果不是,就不能删,但问题又双叒叕来了:GET和DEL是两个命令,不是原子的!可能在GET之后,判断确实是自己的值,但在执行DEL之前,锁过期了,而且又被另一个节点C设置了新值,这时你再DEL,删掉的又是节点C的锁了。

(来源:Redis作者Antirez的博客《How to do distributed locking》)

为了解决这个删除原子性的问题,就得用Lua脚本了,因为Lua脚本在Redis里是原子执行的,释放锁的脚本大概长这样:如果Redis里存的value等于我传过来的value,我就删除这个key;否则,返回0表示删除失败,这样就能确保判断和删除是一气呵成的,不会被打断,这套“SET NX PX + 校验随机值Lua脚本删除”的组合拳,是很长一段时间内大家公认的相对安全的Redis分布式锁方案,有时候也被叫做“单节点Redis分布式锁”的经典实现。

(来源:广泛流传于各大技术社区如Stack Overflow、GitHub讨论区)

但即使这样,还是没到终点,如果你的Redis是单节点的,它自己挂掉了,整个锁服务就不可用了,为了保证高可用,大家会用Redis集群,比如Redis Sentinel(哨兵)模式或者Cluster模式,但这又引入了新的魔鬼,比如在主从切换的情况下,可能会出问题:客户端在旧的Master节点上成功设置了锁,但这个锁还没来得及同步到新的Slave节点上,旧的Master就宕机了,哨兵机制选举出了一个Slave成为新的Master,但这个新的Master上没有刚才那个锁!这时,另一个客户端来加锁,在新的Master上又能成功设置锁了,结果就是,两个客户端同时认为自己持有了锁,数据就可能出错,这个问题是分布式系统里著名的脑裂问题导致的,Redis作者Antirez自己也承认,在这种异步复制的模型下,他提出的Redlock算法试图解决这个问题,但Redlock本身也引发了巨大的争议。

(来源:Martin Kleppmann的博客《How to do distributed locking》与Antirez的辩论)

Redlock算法的大致思路是,不再依赖单个Redis实例,而是同时向多个(比如5个)独立的Redis主节点发起加锁请求(还是用SET NX PX命令),只有当超过半数的节点(比如3个)都加锁成功,并且总耗时小于锁的过期时间,才算真正拿到锁,释放锁时也要向所有节点发起释放操作,这个算法听起来很严谨,但遭到了另一位大神Martin Kleppmann的强烈质疑,Martin认为,Redlock严重依赖系统时钟的可靠性,如果某个节点的时钟突然发生跳跃,会导致锁的过期时间计算不准,可能提前释放,还是会导致两个客户端同时持有锁,他建议,对于需要强一致性的场景,不如用ZooKeeper这类共识算法实现的锁更稳妥,Antirez也进行了反驳,这场辩论没有绝对的对错,但它深刻地揭示了分布式锁的复杂性:你需要在安全性、活性、性能之间做出权衡,没有一劳永逸的银弹。

(来源:Martin Kleppmann与Antirez的公开技术辩论) Redis分布式锁那些花样你真都用过吗?”,从最简单的SETNX/DEL,到SET NX PX + 随机值,再到引入Lua脚本保证原子删除,最后到应对集群环境的Redlock算法及其争议,每一步演进都是为了填上前一步的坑,但每一步也可能带来新的复杂性和潜在风险,在实际项目中,选择哪种实现,很大程度上取决于你的业务场景对一致性的要求到底有多高,以及你愿意为这种一致性付出多少性能代价和运维复杂性,很多时候,如果业务能接受极低概率的锁失效,那么简单的单Redis实例加过期时间和Lua脚本删除的方案,可能反而是最实用、最经济的选择。

Redis 分布式锁那些花样你真都用过吗,聊聊不同实现和坑