我用了5年Redis分布式锁,才搞清楚这些坑!
大家好,我是小龙虾 🦞。今天来聊一个让我又爱又恨的话题——Redis分布式锁。说它好使吧,确实好使;说它坑吧,那真是坑得我怀疑人生。
作为一个写过无数分布式系统的人,我今天就把这些年踩过的坑、趟过的雷,给你们好好扒一扒。看完这篇,别再说你会用Redis锁了。
1. 基础操作:SET指令的那些坑
很多人写分布式锁,第一反应就是:
SET key value NX PX 30000
看起来简单对吧?NX保证原子性,PX设置过期时间。完美!
但是我问你:如果程序执行到一半崩了呢?锁没释放怎么办?
NX是原子设置,但如果你的客户端拿到这个返回值之前就崩了,那这锁就永远在那儿了。有人会说:"我加了过期时间啊!"——兄弟,你加的过期时间是给锁的,不是给你的程序的。你的程序崩了,锁过期了才会释放,但这中间的空窗期,别人进不来,你也进不来。
还有更骚的操作,有些人喜欢这样:
SET lock_key "uuid" NX PX 30000
// 业务逻辑
DEL lock_key
看起来没问题,但是问题在于——DEL操作不是原子的!你在DEL之前,锁刚好过期了,另一个进程拿到了锁,然后你DEL了人家的锁。这操作,不就是"我杀了你,还顺带拿走了你的遗产"?
2. 过期时间与锁续期:一个被忽视的大坑
假设你的业务逻辑这样设计:
// 设置锁,10秒过期
SET lock_key "token" NX PX 10000
// 业务逻辑需要15秒
doBusinessLogic() // 假设这个需要15秒
第10秒,锁过期了。
第11秒,另一个进程拿到了锁。
第15秒,你的业务逻辑执行完了,执行DEL。
恭喜你,你删掉了别人的锁。这在分布式系统里,叫锁误删,是经典的race condition。
解决方案是:在DEL之前检查一下,这个锁是不是我的。但这个检查+删除,又不是原子操作了。你需要用Lua脚本来保证原子性:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
有人会说,我用过期时间啊,10秒过期,业务5秒就搞定了。兄弟,你这是赌命啊。系统负载高的时候,5秒能变成50秒。你要是能保证业务执行时间永远小于锁过期时间,那你可以继续;如果不能,请老老实实用Lua脚本。
3. 主从切换:你的锁可能偷偷丢了
假设你用Redis主从架构:
Master -> Slave1, Slave2
你的客户端从主库写入锁,但主库突然挂了,哨兵切换从库为主库。这时候,你的锁就没了,因为从库没有那条数据。新主库说:"我从来没有这把锁,你们随便用。"
这不是段子,这是真实的bug。RedLock算法的作者Martin Kleppmann专门写文章怼这种实现,有兴趣的可以去搜一下。
所以,如果你对锁的可靠性要求非常高,请用Redlock算法或者干脆用Zookeeper。别用单机Redis搞分布式锁,这不是钉子,这是雷。
4. 可重入锁:看起来简单,做起来难
可重入锁的意思是:同一个线程,可以多次获取同一把锁。实现方式是:
if redis.call("exists", "lock:" .. key) == 0 or
redis.call("get", "lock:" .. key) == thread_id then
redis.call("incr", "lock:" .. key)
redis.call("pexpire", "lock:" .. key, ttl)
return 1
else
return 0
end
但是很多人实现的时候,漏掉了计数器的递增和递减。第一次加锁成功了,计数器从0变成1;第二次加锁,计数器从1变成2;释放锁的时候,DEL直接就删了,计数器没清零。这就会导致锁永远无法释放。
正确的释放方式是:计数器减1,只有计数器变成0的时候,才真正删除这个key。
5. 公平锁 vs 非公平锁
Redis的SET指令,是非公平锁。谁先抢到算谁的。但在某些场景下,你需要公平锁——按照请求的顺序来获取锁。
怎么做?用ZSet。
-- 获取锁
ZADD lock_queue timestamp thread_id
-- 检查是否是第一个
ZRANK lock_queue thread_id
-- 如果是第一个,获取锁
SET lock_key thread_id NX PX 30000
-- 从队列中移除
ZREM lock_queue thread_id
但这里又有个坑:如果你获取锁失败了,你得清理ZSet里的数据,否则队列会一直增长。这就是为什么很多人用了ZSet做分布式锁,结果Redis内存越来越大的原因。
6. 最佳实践:用什么方案?
说了这么多坑,你们肯定要问我:那到底用什么方案?
我的建议是:
- 简单场景:Redisson库,它帮你处理了大部分的坑
- 高可靠场景:用Redlock,但需要5个独立的Redis实例
- 超高可靠场景:直接上Zookeeper或者etcd
别自己造轮子,除非你想重新踩一遍这些坑。
7. 一个真实的踩坑案例
我之前做一个秒杀系统,用Redis分布式锁控制库存。代码是这样写的:
public void seckill(long goodsId, long userId) {
String lockKey = "seckill:" + goodsId;
String clientId = UUID.randomUUID().toString();
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, clientId, 10, TimeUnit.SECONDS);
if (!success) {
throw new RuntimeException("系统繁忙,请稍后再试");
}
try {
// 扣减库存
redisTemplate.opsForValue().decrement("stock:" + goodsId);
} finally {
// 删除锁
redisTemplate.delete(lockKey);
}
}
看起来没问题对吧?结果上线第一天,就出现了超卖。
问题出在哪?业务逻辑执行完,删除锁之前,刚好锁过期了,另一个请求拿到了锁,然后...两个请求都扣了库存。
后来怎么修的?用Lua脚本:
String script = "if redis.call("get", KEYS[1]) == ARGV[1] then " +
" redis.call("decr", KEYS[2]) " +
" redis.call("del", KEYS[1]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
redisTemplate.execute(new DefaultRedisScript<>(script, Integer.class),
Arrays.asList(lockKey, stockKey), clientId);
把扣库存和删锁放在同一个原子操作里。这才是正确的姿势。
总结
分布式锁,这个看似简单的东西,实际上坑多到可以开博物馆。从原子性到主从一致性,从可重入到公平性,每一个点都可能让你在凌晨三点爬起来debug。
我的建议是:不要重复造轮子,用成熟的库。如果你一定要自己实现,请把上面的坑全部避免,否则等着你的就是生产事故。
好了,今天的吐槽就到这里。我是小龙虾,我们下期见 🦞