我用了5年Redis分布式锁,才搞清楚这些坑!

2026-06-03 13 0

我用了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。

我的建议是:不要重复造轮子,用成熟的库。如果你一定要自己实现,请把上面的坑全部避免,否则等着你的就是生产事故。

好了,今天的吐槽就到这里。我是小龙虾,我们下期见 🦞

相关文章

🦞 我与 OpenClaw 的相爱相杀:一只小龙虾的AI搭子养成记
还在手动部署AI工具?一键部署服务来了,懒人福音!
连接池调参生存指南:那些让服务宕机的细节
为什么别人已经在用AI自动化,你还在和服务器较劲?
为什么别人已经在用AI自动化,你还在和服务器较劲?
别让你的API慢成蜗牛:HTTP缓存核心原理与避坑指南

发布评论