Redis 分布式锁这个话题,网上文章确实写烂了。但大部分要么停留在 SETNX 那一套,要么直接甩一个 Redisson 让你用,中间那层"为什么"讲不清楚。更关键的是,很少有人告诉你:分布式锁本身就有它解决不了的问题。
我尽量把这事说透。
从最原始的实现说起
早期大家用 SETNX + EXPIRE 两条命令:
SETNX lock_key 1
EXPIRE lock_key 30
问题很明显:这俩命令不是原子的。客户端执行完 SETNX 拿到锁,还没来得及设过期时间就挂了,这把锁就永远释放不了。
Redis 2.6.12 之后,SET 命令支持了 NX 和 PX 参数,一条命令搞定:
SET lock_key unique_value NX PX 30000
NX 是"不存在才设置",PX 是过期时间(毫秒)。原子性问题解决了。
但还有个坑:unique_value 必须是客户端唯一的,比如 UUID。
为什么?假设 A 拿到锁,过期时间 30 秒,但业务逻辑跑了 35 秒。第 30 秒锁自动过期,B 拿到了锁。第 35 秒 A 执行完了,去删锁------把 B 的锁给删了。后面 C 又能拿到锁,和 B 同时操作,出事了。
所以释放锁不能直接 DEL,得先验证是不是自己的锁:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
这段 Lua 脚本为什么能保证原子性?因为 Redis 是单线程执行命令的,一段 Lua 脚本在执行过程中不会被其他命令打断。这是 Redis 做分布式锁的底层基础。
到这一步,单节点 Redis 的分布式锁"基本"可用了。我说"基本",是因为还有几个深坑。
第一个深坑:锁过期了业务没跑完
这是生产中最常见的问题。
锁过期时间设短了,业务没执行完锁就没了,别人进来了;设长了,一旦客户端挂掉,其他人要干等。
解决方案是看门狗机制:拿到锁之后,起一个后台线程定期给锁续期。只要业务还在跑,锁就不会过期;业务结束了,主动释放锁,看门狗停掉。
Redisson 就是这么干的,默认锁 30 秒,每 10 秒续一次。
但这里有个坑很多人不知道:看门狗续期依赖网络连接。如果你的客户端和 Redis 之间网络断了,续期请求发不出去,锁照样会过期。而你的业务代码可能还在本地跑着,完全不知道锁已经没了。
我见过一个案例:服务 A 拿到锁开始处理订单,中间网络抖动了 15 秒,Redisson 续期失败,锁过期。服务 B 拿到锁也开始处理同一笔订单。网络恢复后,两边都在写数据库,订单状态乱了。
这不是 Redisson 的 bug,是分布式锁的本质局限。
第二个深坑:主从切换丢锁
用 Redis Sentinel 做高可用,主节点挂了从节点顶上,听起来很美好。
但 Redis 主从复制是异步的。
场景:客户端在主节点上拿到锁,主节点还没来得及把这个写操作同步到从节点,就挂了。从节点升为主节点,这时候锁的信息根本不存在。另一个客户端来请求,又拿到了"同一把锁"。
两个客户端都认为自己持有锁,同时操作共享资源,出事。
这个问题在 Redis Cluster 里同样存在,因为 Cluster 内部也是异步复制。
有多大概率?概率很小,但不是零。你的系统跑得越久、并发越高,迟早会遇到。
RedLock:看起来很美的方案
Redis 作者 Antirez 提出了 RedLock 算法试图解决上面的问题:部署 N 个完全独立 的 Redis 实例(不是集群,是独立的),获取锁时依次向每个实例申请,只有在大多数(N/2+1)实例上都获取成功,才算真正拿到锁。
思路是:就算一两个实例挂了或者数据丢了,只要大多数还在,锁的状态就是可靠的。
但分布式系统专家 Martin Kleppmann 写了篇文章直接开怼,标题就叫"How to do distributed locking"。核心论点不是说 RedLock 实现有 bug,而是说这个思路从根上就有问题。
他举了个例子:
- 客户端 A 获取锁成功
- 客户端 A 发生长时间 GC 暂停(或者网络延迟)
- 锁过期了,客户端 B 获取锁成功
- 客户端 A 从 GC 中恢复,它不知道锁已经过期,继续操作共享资源
- 客户端 A 和 B 同时在操作,出事
注意,这个问题不是 RedLock 特有的,所有基于过期时间的分布式锁都有这个问题。RedLock 没解决它,只是让"锁丢失"的概率变低了一点。
真正的安全:Fencing Token
Martin Kleppmann 提出的解决方案叫 fencing token。
思路是这样的:每次获取锁的时候,锁服务返回一个单调递增的 token(比如 1, 2, 3...)。客户端拿着这个 token 去操作共享资源,资源端会记录见过的最大 token,如果收到的 token 比记录的小,直接拒绝。
回到刚才的例子:
- 客户端 A 获取锁,拿到 token=33
- 客户端 A 发生 GC 暂停
- 锁过期,客户端 B 获取锁,拿到 token=34
- 客户端 B 带着 token=34 写入存储,存储记录 max_token=34
- 客户端 A 恢复,带着 token=33 去写入,存储发现 33 < 34,拒绝
这才是真正安全的方案。但问题是:Redis 本身不支持 fencing token,你的存储层也得配合改造。
ZooKeeper 天然支持这个------它的 zxid 就是单调递增的,每次创建临时节点都能拿到。这也是为什么在强一致性要求的场景下,ZooKeeper 比 Redis 更适合做分布式锁。
那 Redis 分布式锁还能用吗?
能用,但你得清楚它的边界。
Redis 分布式锁本质上是一种尽力而为的协调机制,它能防止大部分并发冲突,但不能保证 100% 互斥。在以下场景它工作得很好:
- 防止重复执行(幂等性兜底)
- 控制并发数量(限流)
- 避免缓存击穿(热点数据重建)
- 分布式任务调度(允许偶尔重复执行)
这些场景的共同特点是:就算锁偶尔失效,业务也能兜住。
但如果你的场景是:
- 扣库存、扣余额(超卖就是资损)
- 金融交易(重复扣款就是事故)
单靠 Redis 分布式锁是不够的,你需要:
- 数据库层面的乐观锁/悲观锁
- 唯一约束防重
- 业务层幂等设计
- 或者换 ZooKeeper/etcd
实际踩过的坑
坑一:Redisson 连接池耗尽导致续期失败
高并发场景下,Redisson 连接池打满了,看门狗的续期请求拿不到连接,锁悄悄过期。业务代码完全没感知,继续往下跑。后来我们加了监控:如果续期失败,主动抛异常中断业务。
坑二:锁的粒度太粗
一开始图省事,整个订单处理流程用一把锁。结果锁持有时间太长,其他请求全在排队,接口超时。后来拆成多把细粒度的锁:校验库存一把、扣减库存一把、创建订单一把。并发能力直接上来了。
坑三:忘记处理获取锁失败的情况
代码里写了 lock.tryLock(),但没处理返回 false 的情况,直接往下跑了。等于锁白加。这种低级错误 code review 没发现,上线后出了并发问题才查到。
选型建议
单 Redis + Lua 脚本:简单场景够用,但要清楚它的局限性。
Redisson:Java 项目首选,功能全,但要理解它的机制,别当黑盒用。
RedLock:不推荐。复杂度高,但并没有真正解决分布式锁的本质问题。
ZooKeeper/etcd:强一致性场景,愿意用性能换可靠性的时候用。
数据库行锁 :别忘了这个选项,简单场景下 SELECT ... FOR UPDATE 也能用,而且天然支持事务。
最后说一句:分布式锁用多了就是性能瓶颈。能用队列串行化的别用锁,能用 CAS 乐观控制的别用锁,能把热点 key 打散的别让大家抢同一把锁。
锁是用来兜底的,不是用来当主力的。