Redis分布式锁实战解析,如何解决高并发下的数据一致性与死锁问题
在高并发场景下,要安全地使用Redis分布式锁,核心是用SET命令的NX和PX选项来确保锁的原子性获取和超时释放,并通过唯一标识符来保证只有锁的持有者才能删除锁,从而避免数据不一致和死锁。例如使用命令 SET lock_key unique_value NX PX 30000。
为什么需要分布式锁
当多个服务或线程同时操作同一份数据时,比如减库存,如果不加控制,可能会发生超卖。分布式锁就像一把只能一个人拿到的钥匙,拿到锁才能操作数据,操作完再放回去。
一个简单的锁实现与它的坑
很多人一开始用SETNX命令加锁,再用DEL命令解锁。这听起来简单,但藏着大问题。假设服务A拿到锁后,执行时间过长,超过了锁的过期时间,锁自动释放了。这时服务B拿到了锁。服务A执行完,用DEL删锁,结果把服务B刚拿到的锁给删了!数据就乱套了。另外,SETNX和EXPIRE两个命令不是原子的,如果中间服务挂了,锁就永远不释放,形成死锁。
正确的加锁方法
解决上面问题的方法是,使用一个命令完成设置值和过期时间。Redis的SET命令支持NX和PX参数,可以原子地完成“如果不存在则设置并给定超时时间”。命令是:SET lock_key random_value NX PX 30000。这里的random_value必须是一个全局唯一的字符串,比如UUID。PX 30000表示锁30秒后自动过期,防止死锁。
安全的解锁操作
解锁不是简单删除key。为了防止删掉别人的锁,删除前要先判断这个锁是不是自己设置的。这需要用到Lua脚本,因为判断和删除需要原子执行。脚本大致逻辑是:如果redis.call('get', KEYS[1]) == ARGV[1],那么返回redis.call('del', KEYS[1]),否则返回0。这样,只有锁的值和自己当初设置的值一致时,才会删除。
锁的续期问题
如果业务执行时间可能超过锁的过期时间怎么办?这就需要“看门狗”机制。在拿到锁后,可以启动一个后台线程,定期(比如过期时间的1/3)去检查锁是否还在,如果还在就延长过期时间。一些现成的客户端库,比如Redisson,已经实现了这个功能。
集群环境下的挑战
如果Redis用的是主从集群,主节点写入锁数据后,如果还没同步到从节点就宕机了,从节点升级为主,这时新的客户端可能又会拿到一把锁,导致锁失效。对于这种极端情况,Redis官方提出了RedLock算法,它要求客户端向多个独立的Redis实例依次申请锁,只有当大多数实例都成功时才算加锁成功。但这个算法比较复杂,争议也多,一般场景下,用单Redis节点的主从+上述正确的加解锁方法,已经能覆盖绝大部分需求。
实践中的小建议
第一,锁的粒度要细,尽量锁具体的资源ID,而不是整个业务。第二,锁的超时时间要设置得比业务平均执行时间稍长,并尽量让业务代码执行时间可控。第三,加锁和解锁的代码一定要放在try-finally块中,确保锁最终会被释放。第四,考虑使用成熟的客户端库,比如Java的Redisson,它封装好了各种细节。
FAQ
问:SETNX和SET命令加锁有什么区别?
答:主要区别是原子性。SETNX(SET if Not eXists)只能设置值,需要再单独用EXPIRE命令设置过期时间,这两个步骤不是原子的,如果中间Redis服务重启或客户端崩溃,会导致锁没有过期时间而永远不释放(死锁)。而SET命令配合NX和PX参数,可以在一个原子操作中完成“不存在则设置”和“设置过期时间”,安全得多,是现在推荐的做法。
问:分布式锁一定要用Lua脚本解锁吗?
答:是的,强烈建议用Lua脚本。因为解锁需要两步:1. 获取锁当前的值;2. 判断是否与自己的标识符匹配,匹配则删除。如果不是原子操作,在第一步和第二步之间,锁可能刚好过期并被其他客户端获取,这时第二步删除操作就会误删别人的锁。Lua脚本能保证这两步在Redis服务器端原子性执行。
问:业务代码执行时间超过了锁的超时时间怎么办?
答:这是一个常见风险。解决方案主要有两种:一是合理评估并设置足够长的锁超时时间,但这不精确。二是实现锁的自动续期机制,即“看门狗”。在持有锁期间,由一个后台线程定期检查并刷新锁的过期时间。许多分布式锁客户端(如Redisson)都内置了这个功能,直接使用会更方便可靠。
引用来源:本文经验主要基于Redis官方文档关于分布式锁的说明,以及社区广泛认可的实践模式,如Redisson客户端库的实现原理。