欢迎来到窥视未来的博客

https://github.com/lwx57280 https://gitee.com/li_VillageHead

把公司的Redis分布式锁搞砸之后,我翻遍了源码,这是我们的终极补救方案

上周四晚上10点,我正躺在床上刷手机,突然运维群里炸了:“订单服务全部超时,库存扣减重复了!”我爬起来一看,监控显示同一笔订单被两个不同的Pod同时扣了库存——我们引以为傲的Redis分布式锁,居然失效了。

坦白说,这不是Redis的锅,是我们的锁方案有问题。当天晚上,我把从SET NX到Redisson再到Redlock的演进路线重新梳理了一遍,才发现之前踩的坑有多深。今天就把这次翻车的教训和完整补救方案复盘出来。文章会从头讲清楚:分布式锁到底是什么、解决什么问题、有哪些实现方案,以及Redis Cluster主节点挂了之后锁到底还能不能用

 

一、事故现场:锁是怎么失效的?

我们的业务是秒杀场景,同一个商品不能超卖。一开始用的是最基础的方案:

SET lock:product:123 uuid NX EX 30

看起来没问题,对吧?但问题出在锁过期时间上。秒杀逻辑需要调用外部风控接口,偶尔会超过30秒。锁自动释放后,另一个线程拿到锁,两个线程同时扣库存——超卖了

更坑的是,主从架构下还有另一个隐患:

Redis-lock

 

Redis主从复制是异步的,锁数据还没来得及同步到Slave,Master就挂了。Slave晋升后,锁根本不存在

这就是我们翻车的根本原因。下面从头梳理正确的方案。

二、为什么需要分布式锁?

在回答“怎么实现”之前,先搞清楚“为什么需要”。

2.1 从单机到分布式,锁的演进

在单体应用时代,解决并发问题很简单——用synchronized或者ReentrantLock就行了。JVM会保证同一时刻只有一个线程能进入临界区。

但到了分布式系统,问题就变了:

image

 

多个服务实例同时访问同一个共享资源(数据库、库存、订单等),每个实例的本地锁只能管住自己,跨实例的并发冲突完全无法解决

分布式锁本质上是分布式系统中的“交通规则”——当多个服务实例同时争抢同一个资源时,需要通过分布式锁来保证操作的原子性和一致性

2.2 分布式锁解决什么场景问题?

场景问题分布式锁的作用
库存扣减(超卖) 多个线程同时扣减同一商品库存 保证同一时刻只有一个线程能扣库存
幂等性保障 网络重试导致同一请求被执行多次 同一请求ID只能被处理一次
定时任务防重复执行 多实例部署下同一任务被多次触发 同一时间只有一个实例执行任务
分布式事务协调 多服务间状态不一致 协调分布式操作顺序
限流/计数器 高并发下计数不准 保证计数操作的原子性

以电商秒杀为例:100件库存,10000个人抢。没有分布式锁,10000个请求同时打到数据库,超卖是必然的。有了分布式锁,同一时刻只有一个请求能扣库存,保证了数据一致性。

2.3 分布式锁的三大核心要求

一个合格的分布式锁必须满足:

  1. 互斥性:同一时刻只有一个客户端能持有锁

  2. 安全性锁必须能自动释放(防死锁),且只能被持有者释放

  3. 容错性即使部分节点故障,锁服务仍可用

三、分布式锁有哪些实现方案?

3.1 方案对比总览

 
方案实现方式优点缺点适用场景
SET NX + Lua Redis单节点 简单、性能最高 主从切换丢锁、无自动续期 低并发、可容忍锁丢失
Redisson + 看门狗 Redis单节点+自动续期 自动续期、可重入、开箱即用 主从切换仍可能丢锁(概率低) 90%业务场景
Redlock 多Redis节点多数派 抵抗节点故障、更安全 性能差、时钟依赖、官方不推荐 金融级极端可靠性
ZooKeeper 临时顺序节点 强一致性、无脑裂 性能低于Redis、运维复杂 对一致性要求极高的场景

3.2 方案一:SET NX + EX —— 最基础的实现

Redis官方推荐的最基础实现

SET lock:resource <unique_value> NX EX 30
  • NX键不存在时才设置,保证互斥

  • EX 3030秒后自动过期,防止死锁

  • unique_value客户端唯一标识(如UUID+线程ID),用于安全释放锁

解锁必须用Lua脚本保证原子性

-- 只有值匹配时才删除,防止误删别人的锁
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

为什么要用Lua?因为“判断值是否匹配”和“删除锁”是两个操作,分开执行会有竞态条件。Redis的Lua脚本是原子执行的

这个方案的致命缺陷:

 
问题说明
锁过期时间固定 业务执行时间不确定,设短了锁提前释放,设长了故障时锁迟迟不释放
主从切换丢锁 异步复制导致锁在故障转移时丢失
不可重入 同一线程无法多次获取同一把锁

3.3 方案二:Redisson —— 带看门狗的工业级方案

Redisson是Redis官方推荐的Java客户端,它把分布式锁做成了“工业级”产品。源码基于3.12.2版本。

加锁的核心流程:

RLock lock = redisson.getLock("order_lock");
lock.lock();  // 默认30秒,带看门狗自动续期

Redisson的加锁核心是Lua脚本:

-- 检查锁是否存在
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hset', KEYS[1], ARGV[2], 1);  -- 创建锁,计数器=1
    redis.call('pexpire', KEYS[1], ARGV[1]);  -- 设置过期时间
    return nil;
end;
-- 检查是否是当前线程持有的锁(可重入)
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);  -- 计数器+1
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
-- 锁被其他线程持有,返回剩余过期时间
return redis.call('pttl', KEYS[1]);

锁的数据结构是Hash,field是线程ID,value是重入次数。同一线程多次加锁,计数器递增;解锁时递减到0才真正释放。

 

看门狗(Watchdog)—— 自动续期的秘密:

这是我们翻车后最需要的东西。看门狗的核心逻辑:

 

private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
    if (leaseTime != -1) {
        // 用户指定了过期时间,不启动看门狗
        return tryLockInnerAsync(leaseTime, unit, threadId);
    } else {
        // 使用默认30秒,并启动看门狗
        ttl = tryLockInnerAsync(lockWatchdogTimeout, TimeUnit.MILLISECONDS, threadId);
        scheduleExpirationRenewal(threadId);  // 启动看门狗
        return ttl;
    }
}

看门狗是一个后台定时任务

 

image

 

续期间隔是锁过期时间的1/3。默认锁30秒,每10秒续期一次,只要业务还在运行,锁就不会过期
-- 续期Lua脚本
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('pexpire', KEYS[1], ARGV[1]);  -- 重新设置30秒
    return 1;
end;
return 0;

但有一个坑如果调用lock(10, TimeUnit.SECONDS)指定了过期时间,看门狗不会启动。业务必须在这个时间内完成,否则锁自动释放。

解锁流程:

解锁同样用Lua脚本保证原子性:

-- 检查锁是否存在
if (redis.call('exists', KEYS[1]) == 0) then
    return 1;  -- 锁已不存在
end;
-- 检查是否是当前线程的锁
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
    return nil;  -- 不是自己的锁,解锁失败
end;
-- 重入计数器减1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
    redis.call('pexpire', KEYS[1], ARGV[2]);  -- 还有重入,重置过期时间
    return 0;
else
    redis.call('del', KEYS[1]);  -- 完全释放
    return 1;
end;

3.4 方案三:Redlock —— 多节点多数派方案

Redlock是Redis作者Antirez提出的分布式锁算法,用于解决主从切换丢锁的问题

核心思想:

不依赖单一Redis节点,而是向N个独立节点(N为奇数,通常5个)请求锁

Redlock-node

 

成功条件:

  1. N/2+1(多数)节点上获取锁成功

  2. 获取锁的总耗时小于锁的过期时间

Redisson中的实现

RLock lock1 = redisson1.getLock("order_lock");
RLock lock2 = redisson2.getLock("order_lock");
RLock lock3 = redisson3.getLock("order_lock");
RLock redLock = new RedissonRedLock(lock1, lock2, lock3);
redLock.lock();

Redlock的争议与现状:

Redlock不是银蛋。Martin Kleppmann(《Designing Data-Intensive Applications》作者)曾发文指出Redlock的根本问题

问题说明
时钟漂移 依赖系统时间判断锁过期,时钟不同步会导致锁提前失效
GC停顿 GC时客户端“暂停”,锁可能在此期间过期,其他客户端拿到锁
性能开销 需要多次网络往返,性能远低于单实例
主流框架停止支持 由于官方不再推荐,主流框架也相应停止支持

更关键的是,Redisson官方已不推荐使用RedLock。普通加锁配合看门狗机制,已经能满足绝大多数场景

四、Redis主节点挂了,分布式锁还能不能用?

这是这篇文章最硬核的部分,也是面试和线上问题的高频考点。

4.1 问题还原:主从切换时锁丢了

在Redis主从架构中,分布式锁失效的核心风险源于主从复制的异步特性。

Redis-mastar

 

根本原因:Redis主从复制是异步的。客户端A在Master上加锁成功,但锁数据还没来得及同步到Slave。此时Master宕机,Slave被提升为新的Master。新的Master上没有锁数据,客户端B去加锁,成功!

于是,两个客户端同时持有同一把锁——互斥性被彻底打破。

4.2 Redis Cluster模式下呢?

Redis Cluster比主从复制更复杂一些。Cluster模式下,数据被分片到16384个哈希槽,每个槽由一个主节点负责,每个主节点可以有多个从节点。

在Redis Cluster中,分布式锁面临同样的风险

  1. 锁数据存储在单个主节点上某个key的锁只会落在该key所在槽的主节点上

  2. 主从复制仍然是异步的锁数据写入主节点后,异步复制到从节点

  3. 主节点故障时触发故障转移从节点被提升为新主节点

  4. 如果锁数据未同步新主节点没有锁数据,其他客户端可以成功加锁

image

 

 

结论在Redis Cluster下,分布式锁仍然存在主从切换丢锁的风险。Cluster的故障转移机制并不能解决异步复制带来的锁丢失问题。

4.3 如何保证在主节点挂了之后还能获取锁?

方案一:Redlock算法

Redlock通过向多个独立的Redis节点(不是Cluster的多个副本,而是完全独立的节点)请求锁,用多数派机制来抵抗单点故障。即使某个节点挂了,只要多数节点(N/2+1)还活着,锁服务就可用

但这套方案有争议——前面已经分析过了。

方案二:使用ZooKeeper或etcd

ZooKeeper使用Zab协议实现强一致性,写入数据需要多数节点确认后才返回成功。主节点挂了之后,数据已经在大多数节点上持久化,新主节点不会丢失锁数据。

方案三:接受风险 + 业务兜底

坦白说,大部分公司的Redis Cluster+Redisson方案,并没有用Redlock。而是:

  1. 接受极小概率的锁丢失(概率极低,约万分之几)

  2. 在业务层做兜底:比如扣库存时用数据库乐观锁(版本号)做二次校验

  3. 监控锁持有时间和异常情况

我的建议:

 
场景推荐方案
普通业务(99%场景) Redisson + 看门狗 + 业务兜底
金融级强一致性 ZooKeeper分布式锁
极端可靠性要求 Redlock(但需理解其局限性)

4.4 生产环境配置建议

如果使用Redis Cluster + Redisson,注意以下几点:

# Redisson配置
cluster:
  nodes:
    - redis://127.0.0.1:7000
    - redis://127.0.0.1:7001
    - redis://127.0.0.1:7002
  # 故障转移时的重试策略
  retryAttempts: 3
  retryInterval: 1500

# 锁配置
lockWatchdogTimeout: 30000  # 看门狗超时时间

关键点:

  1. 不要显式指定leaseTime让看门狗自动续期

  2. 设置合理的重试策略:故障转移期间自动重试

  3. 业务层做兜底数据库乐观锁、幂等性设计

五、三种方案怎么选?

 
方案适用场景优点缺点
SET NX + Lua 简单业务,能容忍锁过期风险 实现简单,性能最高 无自动续期,主从切换可能丢锁
Redisson + 看门狗 绝大多数业务(90%场景) 自动续期、可重入、开箱即用 主从切换仍可能丢锁(概率极低)
Redlock 极端可靠性要求(金融级) 多数派机制,抵抗节点故障 性能差、时钟依赖、官方不推荐
ZooKeeper 强一致性要求 写入强一致,无丢锁风险 性能低于Redis,运维复杂

我们最终的补救方案:

  1. 用Redisson替换手写SET NX方案,看门狗自动续期解决锁过期问题

  2. 锁粒度从“订单全局锁”细化到“订单ID锁”lock:order:{orderId},并发能力大幅提升

  3. 配合Lua脚本扣库存,在Redis层面做原子操作,即使锁有极小概率失效,也不会超卖

  4. 监控锁持有时间,超过阈值报警

六、三个核心教训

  1. 别自己手写分布式锁,Redisson已经把坑都填好了

  2. 指定过期时间会禁用看门狗——别画蛇添足

  3. 锁粒度越细,并发能力越强

兄弟们,你们在生产环境中遇到过分布式锁的坑吗?主从切换丢锁、锁过期导致业务中断、还是Redlock的时钟漂移?评论区聊聊,我帮你们分析分析。

posted on 2026-06-21 17:21  k8s-Mango  阅读(6)  评论(0)    收藏  举报

导航