分布式锁与 Redisson 深度:续期、红锁与无锁化

概述

前文《缓存设计与 Spring Cache 深度整合》拆解了 Redis 在缓存场景下的设计模式与 Spring 整合。但在高并发系统中,缓存解决的是"读"的性能问题,而"写"的并发控制------比如秒杀库存扣减、分布式任务调度------需要分布式锁 来保证数据一致性。Redis 从基础的 SETNX 演进到 Redisson 的工业级分布式锁框架,再到 RedLock 的学术争议,形成了一套完整的分布式协调方案。本文将从基础实现到 Redisson 源码,从红锁原理到 fencing token 替代方案,系统拆解 Redis 分布式锁的内核。

摘要 :分布式锁是分布式系统中最基础的协调原语,也是最容易出错的技术之一。一个看似简单的锁,需要同时满足互斥性防死锁可重入锁续期 四项要求。Redisson 通过 Hash 可重入计数Watchdog 自动续期 ,将 Redis 的 SET NX PX 包装成了 JUC Lock 接口的完整实现。而 RedLock 尝试通过多实例独立锁解决单点故障,却因 GC 停顿等现实问题陷入学术争议。本文将从一行 SET key value NX PX 30000 开始,逐层深入 Redisson 的 Watchdog 机制和红锁的正确性边界,并给出无锁化的替代方案。

核心要点

  • 分布式锁基础实现SET NX PX + Lua 脚本安全解锁。
  • Redisson RLock:可重入(Hash + 计数)、Watchdog 自动续期、Pub/Sub 通知等待线程。
  • RedLock 红锁 :多实例顺序加锁、过半成功判定、RedissonRedLock 实现。
  • 红锁争议:Martin Kleppmann 的 GC 停顿分析、fencing token 缺失问题。
  • 替代方案:etcd/Consul 强一致性锁 + fencing token、ZooKeeper 临时顺序节点锁。
  • 无锁化方案INCR/DECR 原子操作、Lua 脚本、RPOPLPUSH 安全队列。

文章组织架构图

flowchart TB subgraph s1 ["1. 分布式锁的核心要求与基础实现"] direction LR A1["四项核心要求"] --> A2["SET NX PX 原子加锁"] A2 --> A3["Lua 脚本安全解锁"] end subgraph s2 ["2. Redisson RLock 的可重入与 Watchdog 机制"] direction LR B1["RLock.lock 调用链"] --> B2["Hash 可重入计数"] B2 --> B3["Watchdog 自动续期"] B3 --> B4["解锁与 Pub/Sub 通知"] end subgraph s3 ["3. RedLock 红锁原理与 RedissonRedLock 实现"] direction LR C1["多实例顺序加锁"] --> C2["过半成功判定"] C2 --> C3["RedissonRedLock 源码"] end subgraph s4 ["4. 红锁争议:Martin Kleppmann 的分析"] direction LR D1["GC 停顿竞态"] --> D2["fencing token 缺失"] end subgraph s5 ["5. 替代方案:etcd/Consul + fencing token"] direction LR E1["etcd Raft + Lease"] --> E2["fencing token 机制"] E2 --> E3["ZK 临时顺序节点对比"] end subgraph s6 ["6. 无锁化方案:原子操作与 Lua 脚本"] direction LR F1["INCR/DECR"] --> F2["Lua 复合原子操作"] F2 --> F3["RPOPLPUSH 安全队列"] end subgraph s7 ["7. 面试高频专题"] G1["≥12 题含系统设计"] end s1 --> s2 --> s3 --> s4 --> s5 --> s6 --> s7 classDef part1 fill:#f1f5f9,stroke:#334155,stroke-width:2px,color:#0f172a classDef part2 fill:#f8fafc,stroke:#475569,stroke-width:2px,color:#1e293b classDef part3 fill:#e2e8f0,stroke:#64748b,stroke-width:2px,color:#1e293b classDef part4 fill:#f1f5f9,stroke:#334155,stroke-width:2px,color:#0f172a class s1,s2 part1 class s3,s4 part2 class s5,s6 part3 class s7 part4

总览说明:全文 7 个模块从分布式锁的基础实现出发,逐步深入 Redisson 的工业级设计、红锁的原理与争议、替代方案和无锁化实践,最后以面试题收尾。

逐模块说明:模块 1 建立分布式锁的基础认知;模块 2-3 是全文核心,深入 Redisson 的源码级实现与红锁协议;模块 4 引入学术争议体现严谨性;模块 5-6 给出正确的选型与替代方案;模块 7 面试巩固。

关键结论Redis 分布式锁从 SETNX 到 Redisson 的 Watchdog,再到 RedLock 的争议,反映了分布式系统在正确性与性能之间的永恒权衡。理解 Redisson 的可重入机制、Watchdog 续期逻辑和红锁的 fencing token 缺失问题,是生产环境中正确使用分布式锁的前提。


1. 分布式锁的核心要求与基础实现

1.1 四项核心要求

一个合格的分布式锁必须同时满足以下四个条件:

  1. 互斥性 (Mutual Exclusion):任意时刻,只能有一个客户端持有锁。这是锁存在的最基本意义。
  2. 防死锁 (Deadlock Prevention) :即使持有锁的客户端崩溃、网络分区或宕机,锁必须能被自动释放,使其他客户端能够获取锁。通常通过给锁加一个 租约/过期时间 实现。
  3. 可重入 (Reentrancy) :同一个客户端/线程在持有锁的情况下,可以再次成功获取同一把锁,而不会自己阻塞自己。这要求锁的持有者能被标识,并且锁内部维护一个 重入计数器
  4. 锁续期 (Lease Extension/Lock Renewal) :如果客户端持有锁的操作执行时间可能长于锁的初始过期时间,需要一种机制能在后台自动延长锁的生命周期 ,防止操作未完成锁就提前释放。这通常被称为 Watchdog 机制。

注意 :基础的单点 Redis 锁(SET NX PX)仅能满足前两点。要实现完整的工业级分布式锁,必须解决可重入和锁续期问题,这正是 Redisson 的核心贡献。

1.2 基础实现:SET NX PX 原子加锁

在 Redis 2.6.12 之前,通常使用 SETNXEXPIRE 两个命令组合实现锁。但这存在严重的原子性问题:如果客户端执行 SETNX 成功后在执行 EXPIRE 之前崩溃,将导致死锁 。Redis 2.6.12 版本提供了扩展的 SET 命令,可将加锁与设置过期时间合为一个原子操作:

bash 复制代码
SET key value NX PX 30000
  • key:锁的标识,通常为资源 ID 或业务标识。
  • value :客户端唯一标识,建议使用 UUID + 当前线程 ID(如 550e8400-e29b-41d4-a716-446655440000:thread-1)。用于安全解锁时校验锁的持有者。
  • NX:仅当 Key 不存在时才设置,保证互斥性。
  • PX 30000:设置 30000 毫秒的过期时间,防止死锁。

Java (Lettuce) 加锁示例

java 复制代码
import io.lettuce.core.api.sync.RedisCommands;

public class RedisLock {
    // 客户端唯一标识,格式:UUID:线程ID
    private static final String LOCK_VALUE = UUID.randomUUID() + ":" + Thread.currentThread().getId();

    /**
     * 尝试加锁
     * @param commands Redis 同步命令接口
     * @param lockKey 锁的 Key
     * @param expireTimeMs 过期时间(毫秒)
     * @return 是否成功加锁
     */
    public boolean tryLock(RedisCommands<String, String> commands, String lockKey, long expireTimeMs) {
        // SET key value NX PX expireTimeMs
        String result = commands.set(lockKey, LOCK_VALUE, 
                io.lettuce.core.SetArgs.Builder.nx().px(expireTimeMs));
        return "OK".equals(result);
    }
}

设计意图 :使用 UUID:ThreadId 作为 value 确保了锁持有者的全局唯一性。即便不同 JVM 的线程 ID 可能相同,前缀 UUID 也保证了不会误删对方的锁。

1.3 Lua 脚本安全解锁

解锁时不能简单地使用 DEL key,否则会误删其他客户端的锁(例如客户端 A 操作超时,锁自动释放后被客户端 B 获取,此时 A 尝试解锁就会删除 B 的锁)。因此,解锁必须原子性 地校验 value 是否为自己设置的,校验通过方可删除。这正是 Lua 脚本大显身手之处------Redis 单线程执行 Lua 脚本保证了 get-and-compare-and-del 的原子性(详见系列第 2 篇《线程模型与事件循环》)。

安全解锁 Lua 脚本 (unlock.lua):

lua 复制代码
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

Java 调用 Lua 解锁

java 复制代码
public boolean unlock(RedisCommands<String, String> commands, String lockKey) {
    // 加载 Lua 脚本
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                    "return redis.call('del', KEYS[1]) " +
                    "else return 0 end";
    // 执行 eval,KEYS: [lockKey], ARGV: [LOCK_VALUE]
    Long result = commands.eval(script, io.lettuce.core.ScriptOutputType.INTEGER, 
                                new String[]{lockKey}, LOCK_VALUE);
    return result == 1L;
}

生产要点 :如果业务执行时间可能超过锁过期时间,上述基础锁存在隐患------锁提前释放,A 正在写数据时 B 又获得了锁。因此必须引入 锁续期(Watchdog) 或使用足够长的过期时间。但过长的过期时间又会加剧死锁风险。这便是 Redisson 需要解决的核心矛盾。

1.4 加锁与解锁序列图

以下序列图描述了一个典型的基于 SET NX PX + Lua 解锁的交互流程:

sequenceDiagram participant CA as 客户端A participant Redis as Redis Server participant CB as 客户端B Note over CA,CB: 初始状态:lock:order 不存在 CA->>Redis: SET lock:order NX PX 30000 (value=A) Redis-->>CA: OK (加锁成功) Note right of CA: A 执行业务逻辑 CB->>Redis: SET lock:order NX PX 30000 (value=B) Redis-->>CB: nil (加锁失败,Key 已存在) Note right of CB: B 可选择等待或直接返回 CA->>Redis: EVAL(unlock.lua, KEYS=[lock:order], ARGV=[A]) Note over Redis: 校验 value 为 A,执行 DEL Redis-->>CA: 1 (解锁成功) CB->>Redis: SET lock:order NX PX 30000 (value=B) Redis-->>CB: OK (加锁成功)

四层说明

  • 参与角色 :客户端 A 与 B 竞争同一把锁 lock:order,Redis Server 作为协调者。
  • 时序流程 :A 通过 SET NX PX 原子性占锁;B 尝试失败;A 完成业务后通过 Lua 原子解锁;B 方才成功获取锁。
  • 关键决策点:Lua 脚本中 value 比较是防止误删的关键。如果 A 的锁因超时被自动删除,其 value 将不匹配,解锁操作返回 0,避免误删 B 的锁。
  • 生产影响:此流程保证了互斥性与基本防死锁。但未解决可重入和锁续期。一旦 A 业务执行时间超过 30s,B 将提前获取锁,破坏互斥性。

2. Redisson RLock 的可重入与 Watchdog 机制

Redisson 是一个基于 Redis 的 Java 驻内存数据网格和分布式锁框架,它完全实现了 JUC 的 Lock 接口,并赋予了分布式协调的能力。其核心锁实现 RedissonLock 通过一系列精巧的 Lua 脚本,弥补了基础锁的所有不足。

2.1 RLock.lock() 调用链总览

在 Redisson 中,RLock 最核心的方法是 lock()。其内部流程大致如下:

  1. lock() 方法调用 tryAcquire(-1, null, threadId),即尝试获取锁,无限等待。
  2. tryAcquire 调用 tryAcquireAsync(waitTime, leaseTime, unit, threadId),返回 RFuture<Boolean>
  3. 异步方法 tryAcquireAsync 中,若 leaseTime != -1(即用户指定了锁租约时间),则不启动 Watchdog;否则,在加锁成功后,会调用 scheduleExpirationRenewal(threadId) 启动看门狗。
  4. 实际执行加锁的是 tryLockInnerAsync() 方法,它向 Redis 发送一段精心设计的 Lua 脚本
sequenceDiagram participant Client as 客户端线程 participant RLock as RedissonLock participant Redis as Redis participant Watchdog as Watchdog Timer Client->>RLock: lock() RLock->>RLock: tryAcquire(-1, null, threadId) Note over RLock: leaseTime = -1,将自动续期 RLock->>Redis: EVAL(加锁Lua脚本) Redis-->>RLock: nil (首次加锁成功) RLock->>Watchdog: 启动 scheduleExpirationRenewal Watchdog-->>Watchdog: 每 10s 执行续期 Lua 脚本 RLock-->>Client: 获取锁成功 Client->>RLock: unlock() RLock->>Watchdog: 取消续期任务 RLock->>Redis: EVAL(解锁Lua脚本) Redis-->>RLock: 1 (解锁成功,并 Pub/Sub 通知)

2.2 Hash 可重入计数:加锁 Lua 脚本深度解析

Redisson 锁并非使用简单的 String,而是使用 Redis Hash 数据结构。Hash 的 key 是锁的名称,例如 lock:order;Hash 内部 field 是客户端标识符 UUID:ThreadId;其对应的 value 是重入次数

加锁 Lua 脚本 (核心逻辑)

lua 复制代码
-- KEYS[1]: 锁的Key (如 lock:order)
-- ARGV[1]: 锁的过期时间 (默认 30000ms)
-- ARGV[2]: 客户端标识 (UUID:ThreadId)

-- 若锁不存在 (exists == 0)
if (redis.call('exists', KEYS[1]) == 0) then
    -- 设置 Hash field 为 1,并设置过期时间
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
-- 若锁存在,且是当前线程持有 (hexists == 1)
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    -- 重入计数 +1,并重置过期时间
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
-- 锁被他人持有,返回锁的剩余有效时间 (TTL)
return redis.call('pttl', KEYS[1]);

设计解读

  • 互斥性 :通过 exists 判断锁是否空闲;hexists 判断是否自己持有。别人持有时返回 TTL,客户端据此进入自旋等待。
  • 可重入性hincrby 使同一客户端可多次获取锁,field 对应的 value 递增。这正是 Hash 结构相比 String 的优势。
  • 防死锁 :每次加锁(包括重入)都 pexpire 重置过期时间,避免因客户端崩溃导致 Hash 永存。

为什么是 Hash 而不是 String?

String 只能存储一个值,若想同时表示"持有者"和"重入次数",需要复杂的编码(如 JSON),既增加解析开销又无法保证原子更新。Hash 天然支持字段更新,且 hincrby 是原子操作,完美契合重入计数需求。

2.3 Watchdog(看门狗)自动续期机制

如果用户在 lock() 时未指定 leaseTime(或使用 lock(-1, null)),Redisson 将启动 Watchdog。其核心参数:

  • internalLockLeaseTime :默认 30000ms ,即锁的默认过期时间。可通过 Config.setLockWatchdogTimeout(long timeout) 全局调整。
  • 续期间隔internalLockLeaseTime / 3 = 10秒。每 10 秒 Watchdog 会向 Redis 发送续期命令。

Watchdog 的实现依赖于 Netty 的 TimerTask 或 JDK 的 ScheduledExecutorService。核心类是 RedissonLock 中的内部类 ExpirationEntry,其记录了当前线程的重入计数和对应的 Timeout 任务句柄。当锁被初次获取时,会调用 scheduleExpirationRenewal(threadId) 方法:

java 复制代码
private void scheduleExpirationRenewal(long threadId) {
    ExpirationEntry entry = new ExpirationEntry();
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
    if (oldEntry != null) {
        oldEntry.addThreadId(threadId); // 重入线程计数
    } else {
        entry.addThreadId(threadId);
        renewExpiration(); // 启动续期
    }
}

renewExpiration() 方法内部创建定时任务:

java 复制代码
private void renewExpiration() {
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) return;
    Timeout task = commandExecutor.getConnectionManager().newTimeout(timeout -> {
        ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
        if (ent == null) return;
        Long threadId = ent.getFirstThreadId();
        if (threadId == null) return;
        // 向 Redis 发送续期 Lua 脚本
        RFuture<Boolean> future = renewExpirationAsync(threadId);
        future.onComplete((res, e) -> {
            if (e != null) {
                log.error("Can't update lock " + getName() + " expiration", e);
                return;
            }
            if (res) {
                // 续期成功,递归调用自己,再次调度
                renewExpiration();
            }
        });
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    ee.setTimeout(task);
}

续期 Lua 脚本

lua 复制代码
-- KEYS[1]: 锁Key
-- ARGV[1]: 过期时间 30000ms
-- ARGV[2]: 客户端标识

-- 当前锁仍由该客户端持有
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    -- 重置过期时间
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end;
return 0;

启动条件 :仅当 leaseTime == -1 时,在 tryAcquireAsync 加锁成功后调用 scheduleExpirationRenewal(threadId)。若用户显式指定了 leaseTime(如 lock(10, TimeUnit.SECONDS)),则不会启动 Watchdog,锁将在到达租约时间后自动释放,适用于执行时间严格可控的短任务。

注意:Watchdog 的续期依赖客户端活性。如果客户端进程长时间 Full GC 挂起(超过 30s),Watchdog 无法续期,锁将过期释放。这是 Redisson 无法避免的,也是后续红锁争议的根源之一。

2.4 unlock():可重入释放与 Pub/Sub 通知

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

lua 复制代码
-- KEYS[1]: 锁Key
-- KEYS[2]: Redisson 的 Channel 名称 (用于通知)
-- ARGV[1]: 解锁消息 (0)
-- ARGV[2]: 过期时间 (30000ms)
-- ARGV[3]: 客户端标识

-- 锁不存在
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]);
    redis.call('publish', KEYS[2], ARGV[1]);
    return 1;
end;

当锁被完全释放时,Redis 会向 KEYS[2] 指定的频道发布一条消息。Redisson 的 LockPubSub 机制会收到通知,从而唤醒那些调用了 lock() 而在自旋等待(实质是 semaphore.acquire())的线程,使其重新竞争锁。这比无脑轮询高效得多。

2.5 lockInterruptiblytryLock 差异

  • lockInterruptibly() :在等待锁的过程中可响应线程中断 InterruptedException。其内部基于 semaphore,通过 countDownLatch.await() 的可中断版本实现。
  • tryLock(waitTime, leaseTime, unit) :支持最大等待时间 waitTime,超时返回 false。若指定 leaseTime,则无 Watchdog;否则同样会触发 Watchdog。
方法 等待策略 可中断 自动续期(Watchdog)
lock() 无限等待 是 (leaseTime=-1)
lockInterruptibly() 无限等待
tryLock(0, leaseTime, unit) 仅尝试一次 取决于 leaseTime
tryLock(wait, leaseTime, unit) 等待 wait 时长 取决于 leaseTime

内部实现细节 :当 tryAcquire 返回 false 时,当前线程会订阅该锁的 Pub/Sub 频道,然后进入一个 while(true) 循环,不断调用 tryAcquiresemaphore.tryAcquire(waitTime) 直到获取锁或超时。一旦 Pub/Sub 收到释放锁的消息,semaphore.release() 将线程唤醒,重新抢锁。这种"发布-订阅 + 信号量"组合极大地减少了无效轮询。


3. RedLock(红锁)原理与 RedissonRedLock 实现

单点 Redis 锁存在单点故障:一旦实例宕机,所有锁定信息将丢失。即使使用哨兵/Cluster(第 5 篇详述),主从异步复制可能导致锁信息在故障转移时丢失,破坏互斥性。RedLock (红锁) 算法试图通过在多个完全独立的 Redis 实例上取得"多数派"共识来规避这个问题。

3.1 红锁多实例顺序加锁算法

RedLock 假设部署 5 个独立的 Redis 实例(通常为不同物理机,不使用主从复制),客户端执行以下步骤:

  1. 获取当前时间戳 (T1)
  2. 依次向 N 个实例申请锁,使用相同的 Key 和随机 Value。对每个实例的请求都设置一个极短的超时时间(例如 5-50ms,远小于锁总 TTL)。若某个实例不可达或超时,立刻尝试下一个。
  3. 计算总耗时 :当前时间 (T2) 减去 T1,得到 elapsed = T2 - T1
  4. 判定加锁成功 :当且仅当成功加锁的实例数 ≥ N/2 + 1 (即超过半数),并且 elapsed < 锁的总体有效时间 (TTL) 时,认为加锁成功。此时锁的有效剩余时间为 TTL - elapsed
  5. 若失败:向所有已加锁的实例发送 Lua 解锁命令,释放锁。
sequenceDiagram participant C as 客户端 participant R1 as Redis-1 participant R2 as Redis-2 participant R3 as Redis-3 participant R4 as Redis-4 participant R5 as Redis-5 C->>C: 获取 T1 C->>R1: SET lock X NX PX 30000 (超时10ms) R1-->>C: OK C->>R2: SET lock X NX PX 30000 (超时10ms) R2-->>C: OK C->>R3: SET lock X NX PX 30000 (超时10ms) R3-->>C: 超时或失败 C->>R4: SET lock X NX PX 30000 (超时10ms) R4-->>C: OK C->>R5: 无需请求 (已得到3个成功) C->>C: 获取 T2,耗时 < TTL Note over C: 3 ≥ 3,加锁成功!

四层说明

  • 参与角色:客户端与 5 个完全独立的 Redis 实例。
  • 时序流程:客户端串行请求各实例,超时极短;半数以上成功且总耗时合理,判定成功;否则释放所有。
  • 关键决策点 :过半成功是基础;总耗时校验 至关重要,它确保锁的实际有效时间不会被"请求耗时"蚕食,防止锁在客户端认为有效时实际已到期。
  • 生产影响:RedLock 试图在牺牲性能(多次网络往返)和可用性(需要多台独立实例)的代价下,换取比单点锁更高的容错性。然而其对时钟漂移、GC 停顿等假设的脆弱性引发了巨大争议。

3.2 RedissonRedLock 实现

Redisson 提供了 RedissonRedLock 来组合多个 RLock 实例。

java 复制代码
Config config1 = new Config(); 
config1.useSingleServer().setAddress("redis://192.168.1.1:6379");
RedissonClient client1 = Redisson.create(config1);

Config config2 = new Config();
config2.useSingleServer().setAddress("redis://192.168.1.2:6379");
RedissonClient client2 = Redisson.create(config2);

Config config3 = new Config();
config3.useSingleServer().setAddress("redis://192.168.1.3:6379");
RedissonClient client3 = Redisson.create(config3);

RLock lock1 = client1.getLock("myLock");
RLock lock2 = client2.getLock("myLock");
RLock lock3 = client3.getLock("myLock");

// 组合成红锁
RLock redLock = new RedissonRedLock(lock1, lock2, lock3);

try {
    // 尝试加锁,等待100s,租约10分钟,均不开启Watchdog
    boolean isLocked = redLock.tryLock(100, 600, TimeUnit.SECONDS);
    if (isLocked) {
        // 处理业务
    }
} finally {
    redLock.unlock();
}

RedissonRedLock 内部实现了 lock()tryLock(),逻辑为遍历所有 RLock,根据过半成功规则返回。需要注意的是,红锁通常不依赖 Watchdog,因为每个子锁都设置了明确的 leaseTime

3.3 红锁的依赖假设

RedLock 的安全性建立在以下三个假设之上:

  1. 时钟同步 :所有实例和客户端的时钟漂移在可控范围内,elapsed 计算准确。
  2. GC 停顿可忽略:客户端不会因 GC 或网络问题被暂停超过 TTL。
  3. 无共享资源:各 Redis 实例完全独立,无主从复制等可能产生数据丢失的机制。

这些假设在现实中的脆弱性是红锁争议的根源。


4. 红锁争议:Martin Kleppmann 的分析

分布式系统大神 Martin Kleppmann 在其著名文章《How to do distributed locking》中对 RedLock 提出了尖锐批评,核心观点是:RedLock 并不能在分布式环境中提供其声称的安全性,因为它依赖的假设(时钟、GC 停顿)在现实中不成立。

4.1 GC 停顿引发的竞态条件

RedLock 的安全性严重依赖"锁过期后,其他客户端一定能感知到并获取锁"这一假设。但 Kleppmann 指出,客户端可能因长时间的 Stop-The-World GC 停顿 而被冻结,导致锁在客户端不知情的情况下过期。

攻击序列

  1. 客户端 A 成功获取了 5 个实例中的 3 个锁(过半,TTL=30s)。
  2. 客户端 A 随后进入长时间的 Full GC,暂停了 35 秒。
  3. 在此期间,A 持有的所有锁均已过期释放。
  4. 客户端 B 此时申请锁,成功在 5 个实例上获取锁(过半)。
  5. 客户端 B 修改了共享存储中的数据。
  6. 客户端 A 的 GC 结束,它仍然认为自己持有锁,并基于过期数据修改存储,导致 B 的写入被覆盖,产生数据冲突。
sequenceDiagram participant A as 客户端A participant R as Redis实例组 participant B as 客户端B participant Store as 存储系统 A->>R: 红锁加锁成功 (TTL=30s) Note over A: 进入 Full GC,暂停 35s R-->>R: A 的锁自动过期 B->>R: 红锁加锁成功 B->>Store: 写入数据 (版本基于 B 的视角) Note over A: GC 结束,A 仍认为持有锁 A->>Store: 写入脏数据 (覆盖 B 的写入) Note over Store: 数据不一致!

四层说明

  • 参与角色:客户端 A、B,独立 Redis 实例组,以及最终需要保护的共享存储系统。
  • 时序流程:A 加锁 → GC 暂停超过 TTL → 锁释放 → B 加锁写数据 → A 恢复写脏数据。
  • 关键决策点:GC 停顿使锁租约的真实过期时刻与客户端感知的时刻分离。Redis 侧锁已失效,但客户端线程不检查"我是否还持有锁"。
  • 生产影响 :此场景证明单靠锁服务本身的过期机制无法保证互斥性,因为 锁的持有者(客户端)本身可能故障 。解决之道在于让存储系统参与防护。

4.2 Fencing Token:从存储层防护

Kleppmann 提出的解决方案是引入 Fencing Token (栅栏令牌) 。每当锁服务颁发一个锁时,同时返回一个全局单调递增的数字作为 Token。客户端在访问共享存储时,必须携带这个 Token。存储系统记录下"当前已处理的最大 Token",并拒绝任何携带小于该 Token 的请求(类似乐观锁的版本号)。在上例中:

  • A 获取锁时获得 Token=1。
  • A GC 停顿,锁过期。
  • B 获取锁时获得 Token=2。
  • B 携带 Token=2 写入存储,存储记录 maxToken=2
  • A 苏醒后携带 Token=1 写入,存储发现 1 < 2,拒绝写入,数据安全。

这要求锁服务本身支持生成单调递增的 Token,并且存储系统必须配合校验。Redis 自身难以原生提供全局递增 Token(需额外维护),这正是 etcd/ZooKeeper 等具备 全局递增 Revision/ZXid 机制的系统更适合做严格分布式锁的原因。

4.3 Antirez 的反驳与社区共识

Redis 作者 Antirez 撰文《Is Redlock safe?》回应,核心观点:

  • 长时间 GC 停顿本身是小概率事件,且可通过 setvbuf 等手段降低。
  • 若要求绝对的互斥性,任何带过期时间的锁(包括 etcd)都不安全,因为时钟漂移和网络延迟也会导致类似问题。
  • RedLock 是为了提供"高性能、可接受的正确性"的锁,而不是为金融交易设计的绝对锁。

目前社区共识:RedLock 适用于效率提升型场景,而非正确性依赖型场景。如果锁的丢失仅导致重复操作(可幂等处理),RedLock 可接受;若可能导致数据损坏,必须使用 etcd/ZK + fencing token 方案。


5. 替代方案:etcd/Consul + fencing token

当业务场景要求金融级数据一致性,不能容忍极低概率的竞态时,应转向基于 Raft/Paxos 等强一致性协议的协调服务。

5.1 etcd 分布式锁与 Fencing Token

etcd 使用 Raft 共识算法,提供强一致性 KV 存储。其事务和租约机制天然适合实现安全锁。

关键原语

  • Lease (租约):客户端可与 etcd 创建租约(带 TTL),并周期性续约。若客户端崩溃,租约到期后自动释放与之绑定的 Key。
  • Transaction (事务) :利用 if-not-exists 语义原子创建 Key,实现加锁。
  • 全局 Revision :etcd 的每个写操作都会导致一个全局、单调递增的 ModRevision。这正是实现 Fencing Token 的完美载体。

加锁流程 (Java jetcd 示例)

java 复制代码
// 1. 创建带 TTL 的租约
long leaseId = leaseClient.grant(30).get().getID();

// 2. 事务加锁:创建 key "/lock/order",并绑定租约
ByteSequence lockKey = ByteSequence.from("/lock/order", StandardCharsets.UTF_8);
Txn txn = kvClient.txn()
    .If(new Cmp(lockKey, Cmp.Op.PUT, CmpTarget.createRevision(0))) // 如果 Key 不存在
    .Then(Op.put(lockKey, ByteSequence.from("unique-value"), PutOption.newBuilder().withLeaseId(leaseId).build()))
    .Else(Op.get(lockKey));
TxnResponse txnResp = txn.commit().get();

long fencingToken;
if (txnResp.isSucceeded()) {
    // 加锁成功,获取创建的 Revision 作为 fencing token
    fencingToken = txnResp.getPutResponses().get(0).getHeader().getRevision();
} else {
    // 加锁失败,可从 Else 响应中获取当前锁信息
}

解锁kvClient.delete(lockKey),或直接让租约过期。

Fencing Token 校验 :存储系统需记录 maxProcessedRevision。客户端携带 fencingToken 写数据时,存储层进行 if token > maxProcessedRevision 校验,更新并执行写入;否则拒绝。

sequenceDiagram participant A as 客户端A participant Etcd as etcd participant B as 客户端B participant Store as 存储 (含Token校验) A->>Etcd: 创建 Lease,事务加锁 -> 返回 Token=33 Note over A: 进入 GC 停顿,Lease 过期 Etcd-->>Etcd: 清理 A 的锁 B->>Etcd: 创建 Lease,事务加锁 -> 返回 Token=35 B->>Store: 写入 (Token=35) Store->>Store: maxToken=33 -> 更新为 35,写入成功 Note over A: GC 结束 A->>Store: 写入 (Token=33) Store-->>A: 拒绝!33 < 35

四层说明

  • 参与角色:客户端 A、B,etcd 集群,带 Token 校验的存储层。
  • 时序流程:A 取锁得 Token=33 → GC 停顿锁释放 → B 取锁得 Token=35 写存储 → A 用过期 Token 写被拒。
  • 关键决策点:etcd 内建全局 Revision 作为 Fencing Token,实现简单且可靠。存储层拒绝低 Token 是安全性的最后防线。
  • 生产影响:etcd 方案以增加运维复杂度和牺牲部分性能(Raft 共识耗时)为代价,换取了远高于 RedLock 的正确性保证。

5.2 ZooKeeper 临时顺序节点锁

另一种经典的 CP 锁实现是 ZooKeeper 的临时顺序节点方案:

  • 客户端在锁的 ZNode 下创建一个 临时顺序节点
  • 客户端获取该 ZNode 下所有子节点,检查自己是否序列号最小。若是,则获得锁;否则对前一个节点注册 Watcher,等待其删除。
  • 临时节点保证客户端崩溃后自动删除;顺序保证公平性。ZK 的 ZXid 也可作为 Fencing Token。

与 etcd 类似,ZK 提供强一致性保证,但同样受限于 CP 系统的可用性瓶颈。

5.3 场景化选型建议

需求场景 推荐方案 原因
高并发、性能敏感(如秒杀库存扣减),可接受极低概率撞锁 Redis 单点 / Redisson 吞吐量高,延迟低。通过 Watchdog 解决大部分续期问题。结合 Lua 无锁化可进一步降低依赖。
分布式任务调度,需保证任务不重复执行 Redisson (单点 + Watchdog) 任务执行时间不确定,Watchdog 可有效防止锁提前释放。Redis 主从概率性丢失可通过运维补偿。
金融记账、主键分配等要求强一致性 etcd / ZooKeeper + Fencing Token 必须杜绝任何并发写入。存储层校验 Fencing Token 提供端到端的正确性。
跨数据中心/跨云部署,对可用性要求极高 考虑不依赖分布式锁的最终一致性方案 网络分区是常态,CP 系统可能牺牲可用性(少数分区不可写)。更推荐幂等写入、无锁 CAS 设计。

6. 无锁化方案:原子操作与 Lua 脚本

并发控制的最高境界是无锁化。如果能利用 Redis 的单线程特性,通过原子命令和 Lua 脚本直接完成数据变更,则完全无需在应用层使用分布式锁,性能与健壮性达到最优。

6.1 INCR/DECR 原子计数

库存扣减最简单的无锁化方案是利用 Redis 的 String 类型自增/自减命令,它们是天然原子的。

java 复制代码
// 初始化库存 (线程安全,可执行一次)
redisCommands.set("stock:prod:1001", "1000");

// 扣减库存:DECR 命令,返回扣减后值
Long stock = redisCommands.decr("stock:prod:1001");
if (stock >= 0) {
    // 扣减成功,处理业务
} else {
    // 库存不足,需要回滚:INCR
    redisCommands.incr("stock:prod:1001");
    throw new BusinessException("库存不足");
}

注意 :此方案要求先 DECR 后判断,若不足需 INCR 回滚。在高并发下,"回滚"意味着短暂超卖但最终被纠正,库存会经历"先负后正"的过程,是否可接受需业务评估。对于秒杀等场景,通常可以接受最终的负库存再回滚,因为用户感知是库存已尽。

6.2 Lua 脚本保证复合操作原子性

若操作包含"判断 + 扣减"等复合步骤,Lua 脚本可一劳永逸保证原子性。

库存检查与扣减 Lua 脚本

lua 复制代码
local key = KEYS[1]          -- 库存 Key
local request = tonumber(ARGV[1]) -- 请求数量

local stock = tonumber(redis.call('get', key) or 0)
if stock >= request then
    redis.call('decrby', key, request)
    return 1  -- 成功
else
    return 0  -- 库存不足
end

Java 调用 (Lettuce)

java 复制代码
String script = "local stock = tonumber(redis.call('get', KEYS[1]) or 0) " +
                "if stock >= tonumber(ARGV[1]) then " +
                "redis.call('decrby', KEYS[1], ARGV[1]) return 1 else return 0 end";
Long result = commands.eval(script, ScriptOutputType.INTEGER, 
                            new String[]{"stock:prod:1001"}, String.valueOf(1));
if (result == 1L) {
    // 扣减成功
}

优势:一次网络往返,原子执行,无锁等待,无死锁风险。在高并发秒杀场景中,这是对数据库压力最小的方案之一。

6.3 RPOPLPUSH 安全队列消费

在消息队列或任务队列场景中,消费者从队列中取出消息进行处理,若处理过程中崩溃,消息将丢失。Redis 的 RPOPLPUSH(6.2.0+ 推荐 BLMOVE)提供了安全的消息消费模式。

bash 复制代码
# 从 processing_queue 尾部取出消息,并将其原子地推入 backup_queue 头部
RPOPLPUSH processing_queue backup_queue

消费者流程

  1. 调用 BRPOPLPUSH (阻塞版本) 从主队列取出消息,同时放入"处理中"备份队列。
  2. 处理业务逻辑。
  3. 处理成功后,LREM backup_queue -1 message 将消息从备份队列删除。
  4. 若消费者崩溃,消息仍安全存储在 backup_queue 中。可另起监控程序定期扫描备份队列,将滞留超时的消息重新 LPUSH 回主队列进行重试。

此模式无需锁,完全依赖 Redis 的原子命令保证了消息"至少一次消费",实现高可靠任务队列。

6.4 无锁化方案的局限性

无锁化虽好,但并非万能。其使用范围局限于单步原子操作Lua 脚本能完整封装的纯内存交互。若操作涉及多个外部系统(例如扣减库存后调用支付网关),则必须使用分布式锁保证整体原子性。此外,Lua 脚本执行时会阻塞 Redis,应避免耗时过长的逻辑。


7. 面试高频专题

本节以高度详细的方式回答面试中的高频问题,每个问题均包含:一句话总结详细原理解析多角度深度追问加分回答或实战要点

1. 分布式锁需要满足哪些核心要求?Redis 如何通过 SET NX PX 实现基础锁?

一句话回答 :互斥性、防死锁、可重入、锁续期;SET key value NX PX timeout 原子性地创建带过期时间的 Key,value 标识持有者。

详细原理解析

  • 互斥性NX 参数保证 Key 不存在时才能设置成功,若 Key 已存在,命令返回 nil。这就确保了同一时刻只有一个客户端能成功设置该 Key,获得锁。
  • 防死锁PX 参数为 Key 设置一个有限的生存时间(毫秒)。即使客户端崩溃、未主动解锁,Redis 也会在 TTL 到期后自动删除该 Key,其他客户端便可重新获取锁。这就从根本上避免了永久死锁。
  • 持有者标识value 字段必须唯一标识客户端和线程,通常为 UUID:threadId。这是安全解锁的基础------只有锁的持有者才能释放锁,避免误删其他客户端的锁。
  • 原子性保证SET ... NX PX 在单条命令中完成"检查是否存在、设置值、设置过期"三个动作,杜绝了旧方案 SETNX + EXPIRE 的两步非原子性引发的死锁风险。

多角度深度追问

  • 追问 1:为什么 value 必须具有全局唯一性?

    如果仅用简单的固定字符串(如 "locked"),当客户端 A 持有的锁因超时自动过期后,客户端 B 成功获取了同一把锁。此时 A 执行解锁时,无法区分锁是否为自己持有,会直接 DEL 删除,导致 B 的锁被误删,互斥性被破坏。通过 UUID:threadId 的 value,解锁 Lua 脚本可以"先 GET 比对,再 DEL",确保只有加锁者本人才能解锁。

  • 追问 2:如果不设置过期时间或设为 0,会发生什么?

    完全不设置过期时间,一旦持有锁的客户端崩溃,该锁将永久存在(死锁),其他所有客户端都无法再获取锁,导致业务完全停滞。这是生产环境中最严重的事故之一。因此,在基础实现中,PX 参数是强制性的安全底线。

  • 追问 3:SET NX PX 能否满足可重入要求?

    不能。当同一个线程第二次尝试获取同一个 Key 时,SET ... NX 会因为 Key 已存在而返回失败,线程会把自己阻塞住。可重入要求锁内部能够识别"当前持有者就是我",并允许再次进入并记录重入次数。这需要更复杂的数据结构和逻辑,正是 Redisson 等框架解决的范畴。

  • 追问 4:基础锁在业务时间超时后有什么风险?

    锁可能在业务逻辑尚未完成时自动释放,此时其他客户端成功获取锁并开始修改同一资源,导致数据错乱。典型场景:A 获取锁执行数据库写操作,因网络抖动延迟;锁过期,B 获取锁也开始修改;A 的网络恢复后继续执行写入,覆盖 B 的数据。因此,除非能保证业务执行时间严格小于锁过期时间,否则必须配合锁续期机制(Watchdog)。

加分回答

  • 原子命令演进历史 :在 Redis 2.6.12 之前,加锁需使用 SETNX mylock value 然后 EXPIRE mylock 30。若客户端在两条命令之间崩溃,将导致无过期时间的死锁。这也是为什么生产环境绝对禁止使用老旧的 SETNX 方案。
  • 生产最佳实践 :value 的生成可包含更多上下文,如机器 IP、时间戳等,便于排查死锁问题时通过 GET 命令快速定位是哪台机器、哪个线程持有锁。

2. Redisson 的 RLock 是如何实现可重入的?Hash 结构存储了什么?

一句话回答 :使用 Redis Hash,Key 为锁名,Field 为 UUID:ThreadId,Value 为重入次数;加锁时通过 hexists 判断自己是否已持有,是则 hincrby 递增。

详细原理解析

  • 数据结构选择:为什么不使用 String?String 只能存一个标量值,若要同时存储"持有者 ID"和"重入计数",必须采用 JSON 编码,但原子性更新 JSON 中的计数需要 CAS 或 Lua 脚本,不够简洁。Hash 结构天然支持多个 field,每个 field 可独立原子增减,完美契合需求。
  • 加锁 Lua 脚本解析
    1. exists(KEYS[1]) == 0:如果整个 Hash 不存在,说明锁空闲。执行 hincrby(KEYS[1], ARGV[2], 1)UUID:ThreadId 的 field 初始化为 1,然后 pexpire 设置整体过期时间。返回 nil 表示加锁成功。
    2. hexists(KEYS[1], ARGV[2]) == 1:如果锁存在且自己已经是持有者。执行 hincrby 将重入计数加 1,并重置过期时间。再次返回 nil 加锁成功。
    3. 以上两者都不成立:锁被他人占有。返回 pttl(KEYS[1]) 表示锁的剩余生存时间(毫秒),客户端据此决定等待时间或立即放弃。
  • 解锁 Lua 脚本解析 :先校验 hexists,再 hincrby -1,若计数归零则 del 删除整个 Key 并通过 publish 唤醒等待者。

多角度深度追问

  • 追问 1:Hash 的过期时间设置在哪里?是所有 field 共用一个 TTL 吗?

    Redis 的过期时间(TTL)是作用于整个 Key 的,无法对 Hash 内部的某个 field 单独设置过期时间。Redisson 通过在每次加锁或重入时对整个 Hash Key 调用 pexpire 重置过期时间。只要线程持续重入或 Watchdog 续期,这个 Key 就永不过期。

  • 追问 2:如果同一个线程多次重入,解锁时需要多少次 unlock?

    需要对称的 lock()unlock() 次数。每次 lock() 使 field 的 counter 加 1,unlock() 使其减 1。当 counter 减到 0 时,锁才被真正释放。这与 JUC 的 ReentrantLock 语义完全一致,因此一个线程内可以像使用本地锁一样使用分布式锁,例如递归调用或嵌套保护。

  • 追问 3:如果线程 A 持有锁,线程 B 在 lock() 中等待,A 解锁后 B 如何立即感知?

    Redisson 使用 Redis 的发布/订阅功能。当 A 完全释放锁时,解锁 Lua 脚本执行 publish channelName 0。所有正在等待这把锁的客户端都订阅了该 channel,收到消息后,信号量释放,B 被唤醒并重新执行加锁 Lua 脚本竞争锁。这比轮询(如每隔 500ms 尝试一次)更高效、延迟更低。

  • 追问 4:Hash 结构下,如何调试当前锁的持有状态?

    可以直接登录 Redis 执行 HGETALL lockKey,将看到形如 {UUID:thread1: 3, UUID:thread2: 1} 的结果,可知哪些线程持有锁及重入次数。再执行 PTTL lockKey 查看剩余过期时间。这在排查锁死锁或未释放问题时非常有用。

加分回答

  • 连接断开后的锁释放 :如果 RedissonClient 关闭(shutdown())或与 Redis 连接完全断开,Watchdog 自然停止,锁会在 30s 后自动过期,不会造成死锁。然而,显式指定 leaseTime 的锁也可能因客户端崩溃而到期释放。因此,Redisson 的锁不会永久死锁,但可能产生较短时间的"假死"状态(最多 30s)。
  • 并发度优化:高并发下大量线程竞争同一把锁,会瞬间全部订阅 Pub/Sub 频道,解锁时同时被唤醒,称为"惊群效应"。Redisson 通过信号量公平排序、随机退避等机制缓解,但无法完全消除。对于热点锁,应考虑无锁化设计或分段锁。

3. Redisson 的 Watchdog 机制是如何工作的?什么情况下不开启 Watchdog?

一句话回答 :Watchdog 是一个后台定时任务,每 internalLockLeaseTime/3 (10s) 通过 Lua 脚本续期 30s;当用户显式指定 leaseTime 时不启动。

详细原理解析

  • 触发条件 :在 tryAcquireAsync 中,若 leaseTime == -1(即用户未指定租约时间),获取锁成功后调用 scheduleExpirationRenewal(threadId) 启动 Watchdog。
  • 核心实现 :内部维护一个 EXPIRATION_RENEWAL_MAP,Key 是锁的 EntryName,Value 是 ExpirationEntry 对象,记录了线程 ID 和定时器句柄。renewExpiration() 方法递归调度自己,形成每 10s 执行一次的周期任务。每次执行时,调用 renewExpirationAsync(threadId) 向 Redis 发送续期 Lua 脚本。
  • 续期脚本hexists 检查当前线程是否仍持有锁,若是则 pexpire 重置过期时间为 30s。若 hexists 返回 0,表示锁已被释放或由于某种原因丢失,续期自动终止。
  • 停止时机 :当线程调用 unlock() 且计数器归零时,调用 cancelExpirationRenewal(threadId)EXPIRATION_RENEWAL_MAP 中移除 entry 并取消定时器。

多角度深度追问

  • 追问 1:为什么续期间隔是 internalLockLeaseTime/3(默认 10s),而不是接近过期时再续?

    这是为了提供安全冗余。如果在锁即将过期(如最后 1s)才续期,一旦此时网络闪断或 Redis 轻微阻塞,续期命令可能延迟或失败,导致锁被意外释放。每 10s 续一次,即便某次续期失败,还有下一个 10s 窗口重试。这种"提前量"设计在分布式环境中是必要的。

  • 追问 2:如果 Watchdog 线程因为 GC 停顿而暂停超过 30s 会怎样?

    续期失败,Redis 中的锁 Key 会在 30s 后过期自动删除。此时其他客户端可以获取锁。当原客户端 GC 结束后,它仍认为自己持有锁,并继续执行业务逻辑,可能导致并发冲突。这正是 Martin Kleppmann 指出的分布式锁的根本脆弱性:客户端自身的停顿无法由锁服务检测。因此,Redisson 的锁只能防止客户端崩溃造成的死锁,无法防止客户端"假死"。

  • 追问 3:为什么显式指定 leaseTime 会禁用 Watchdog?

    用户显式指定租约时间,通常意味着业务有明确的时间界限(如"此任务必须在 10s 内完成")。设计上希望锁能自动到期释放,而不被无限续期。如果此时仍开启 Watchdog,续期将破坏用户的意图,导致锁长时间不释放。同时,无 Watchdog 的锁资源消耗更小。

  • 追问 4:tryLock(waitTime, leaseTime, unit)leaseTime 设为 null 或负数会怎样?

    Redisson 将 null-1 视为未指定,从而自动启用 Watchdog。若传入 0,会立即失败,因为 leaseTime 为 0 的锁没有实际意义。

加分回答

  • 全局配置 :可通过 Config.setLockWatchdogTimeout(60000) 修改默认的 30000ms 为 60s。这会同时影响续期间隔(变为 20s)。生产环境中若业务逻辑普遍执行时间较长,可适度调大此值,但会增加死锁后其他线程的等待时间,需要权衡。
  • Watchdog 的资源开销 :每个持锁线程都会有一个定时器任务。大规模系统数万线程同时持有不同的锁,Watchdog 定时器会占用一定的内存和 CPU 资源。因此对于短任务,建议显式设置 leaseTime 以减少开销。

4. RedLock(红锁)的算法原理是什么?过半成功判定如何实现?

一句话回答:向 N 个独立 Redis 实例顺序加锁,每个设置短超时,若成功数 ≥ N/2+1 且总耗时 < 锁 TTL,视为成功;否则释放所有已得锁。

详细原理解析

  • 实例数量:建议 N=5,可容忍 2 个实例故障。数量为奇数易于形成多数派。
  • 短超时请求:每个实例的加锁请求设置极短的超时(如 1~50ms),避免向某个慢速或故障的实例等待过久,拉长总耗时。
  • 总耗时校验 :即使获得多数派,如果 elapsed = 加锁结束时间 - 开始时间 已经接近或超过 TTL,那么实际锁的有效时间(TTL - elapsed)可能为零或负值,锁实际已失效。因此,elapsed < TTL 是保证锁有效性的关键。
  • 失败回滚:如果未能在多数实例上加锁,必须向所有已成功加锁的实例发送 Lua 解锁脚本,释放部分持有的锁,防止残留锁阻塞其他客户端。

多角度深度追问

  • 追问 1:如果 5 个实例中,客户端 A 在 3 个实例加锁成功,耗时 200ms,但锁总 TTL 为 10s,有效时间还有 9.8s。此时客户端 B 尝试加锁,会发生什么?

    B 也会依次向 5 个实例请求,对于 A 已加锁的 3 个实例,SET NX 会返回失败(Key 已存在);对于另外 2 个实例,可能会成功。B 最多获得 2 个成功(<3 个),无法满足过半要求,加锁失败。因此互斥性得到保证。

  • 追问 2:RedLock 为何要求各实例完全独立,不能使用主从复制?

    主从复制是异步的。如果客户端 A 在主节点上成功加锁,该数据尚未复制到从节点,主节点就宕机了。哨兵提升从节点为新主节点,但新主节点中没有 A 的锁信息。此时客户端 B 可以在新主节点上加锁成功,导致 A 和 B 同时认为自己持有锁,破坏互斥性。这就是著名的"主从切换丢锁"问题。RedLock 通过完全独立、无复制的实例规避此风险。

  • 追问 3:如果客户端在加锁成功后,业务执行期间,部分实例的时钟发生漂移(比实际快),会有什么影响?

    时钟走得快的实例会提前将锁过期释放。如果漂移严重,可能导致过半实例的锁提前过期,此时其他客户端可能成功获取锁,再次形成并发。因此,RedLock 依赖 NTP 等时钟同步机制保持各节点时钟偏差在可接受范围内。

  • 追问 4:RedLock 的性能瓶颈在哪?

    串行请求多个实例增加了网络延迟(至少 2~3 个 RTT 之和)。每次加锁都需要多次网络往返,延迟明显高于单点 Redis。同时,5 个实例的运维成本和资源消耗也更高。

加分回答

  • RedLock 的学术争论 :Antirez 提出 RedLock 的目的是在"没有完全可靠的协调服务"的环境下,提供一个比单点锁更容错的方案。但 Martin Kleppmann 认为,如果连 etcd/ZK 这样的 CP 系统都不能承受,RedLock 的这种"弱多数派"机制在 GC 停顿面前同样不安全。这引出了分布式系统一个深刻的结论:在异步网络中,无法完全区分"节点故障"和"节点缓慢",任何依赖超时的锁方案在理论上都是不安全的。

5. 红锁存在什么争议?Martin Kleppmann 是如何论证红锁可能违反互斥性的?

一句话回答:客户端因 GC 停顿或网络延迟,在锁过期后仍认为自己持有锁,导致多个客户端同时写数据;红锁缺少 fencing token 机制从存储层防护。

详细原理解析

  • Kleppmann 构造了一个攻击序列:客户端 A 成功获取红锁 → A 进入长时间 GC 停顿(超过锁 TTL)→ 在此期间 Redis 中 A 的锁全部过期 → 客户端 B 获取红锁成功并修改共享资源 → A 的 GC 结束,它仍认为自己持有锁,继续修改共享资源,覆盖 B 的写入。问题根源在于,客户端 A 在 GC 期间已失去锁的持有权,但它没有任何机制来感知这一点。 锁服务单方面宣布锁过期,而原持有者浑然不知。
  • 这个场景不局限于 GC,网络延迟、操作系统调度暂停、虚拟机迁移等都可能导致类似效果。

多角度深度追问

  • 追问 1:Watchdog 能否解决这个 GC 停顿问题?

    不能。Watchdog 运行在客户端进程中,GC 停顿会同时冻结业务线程和 Watchdog 定时器。Watchdog 无法在此期间发送续期命令,所以锁依然会过期。Watchdog 解决的是"业务执行时间过长导致锁提前释放"的问题,却无法解决"客户端本身假死"的问题。

  • 追问 2:Kleppmann 认为分布式锁的正确用途是什么?

    他区分了两种用途:

    • 效率提升(Efficiency):用于避免重复工作,如防止多个客户端同时执行同一任务(幂等操作)。这类场景下锁丢失导致的重复执行通常可接受。
    • 正确性依赖(Correctness):用于保证共享资源的并发访问正确性,如金融记账、文件系统修改。这类场景下锁丢失将导致数据错误,不可接受。Kleppmann 认为 RedLock 最多适用于第一类场景。
  • 追问 3:什么是 fencing token?为什么它能够解决该问题?

    Fencing token 是由锁服务在授予锁时返回的一个全局单调递增的数字。共享资源服务器(如数据库、文件系统)记录下"最后处理的 token"。任何写请求必须携带 token,服务器拒绝所有 token 小于或等于已处理 token 的请求。即使旧客户端在 GC 后重新苏醒,其旧的 token 也会被拒绝。这实际将"锁正确性"的一部分责任转移给了资源服务器。

  • 追问 4:Antirez(Redis 作者)如何回应?

    Antirez 认为,如果苛刻要求绝对的正确性,即使是 ZK 或 etcd 实现的锁也无法完全避免类似问题,因为它们的 session 超时和心跳也是基于时间假设的。如果客户端停顿时间超过 session 超时,ZK 也会删除其临时节点,同样可能发生类似的并发。因此,他认为 RedLock 作为一种实用的工程方案,在大多数情况下已足够安全,且性能更优。

加分回答

  • 区分"锁的安全问题"与"数据一致性问题":此争论的本质在于,仅靠客户端侧锁无法提供端到端的一致性。fencing token 机制实际上是让锁服务与资源服务协同工作,这是分布式系统中实现强一致性的通用模式。
  • CAP 理论的体现:Redis 单点是 AP 系统(可用+分区容错),ZK/etcd 是 CP 系统(一致+分区容错)。RedLock 试图用多个 AP 节点构建一个"CP 幻觉",但被证明在特定故障下仍回归 AP 特性。若业务真正需要 CP,直接使用 CP 系统是更自然的选择。

6. 什么是 fencing token?如何通过 etcd/Consul 实现带 fencing token 的分布式锁?

一句话回答:锁服务返回全局递增 token,客户端读写存储必须携带 token,存储拒绝小于已处理最大 token 的请求。

详细原理解析

  • etcd 的天然优势 :etcd 的每个写操作都会分配一个全局唯一的 ModRevision,该值单调递增且跨整个集群一致。当客户端通过事务成功创建锁 Key 时,可以获得该 Key 的 CreateRevision 作为 fencing token。
  • 实现步骤
    1. 客户端创建 Lease(租约),并定期 keep alive。
    2. 执行事务:If(Key不存在) Then(Put(Key, value, Lease)) Else Get(Key)
    3. 若事务成功,从 PutResponse.Header.Revision 获得 fencing token。
    4. 业务操作携带 token 写入资源服务器。
    5. 资源服务器维护 maxToken,原子比较并更新:if token > maxToken: maxToken = token; execute write; else reject

多角度深度追问

  • 追问 1:fencing token 必须是全局递增吗?用随机 UUID 行不行?

    必须全局单调递增。随机 UUID 无法进行大小比较,只能通过集合判重实现"使用后即失效",但这要求资源服务器存储所有已用过 token 的集合,无法水平扩展且内存无限增长。递增数字只需要维护一个 maxToken,比较操作 O(1) 且状态极小。

  • 追问 2:Consul 如何实现 fencing token?

    Consul 提供 sessionKV Store,其 ModifyIndex 也是全局递增的,机制与 etcd 类似。使用 consul-api 可在 acquire 成功时获得 ModifyIndex 作为 token。

  • 追问 3:如果资源服务器不支持 token 校验,能否在应用层实现类似效果?

    可以,但安全性下降。应用层可以在获取锁后立即从 Redis 的 INCR 一个全局计数器获取 token,然后写入资源时附带此 token。但应用层的 token 生成与锁获取不是原子操作,存在微小窗口:A 获锁 → B 获锁(A 未及时生成 token)→ A 和 B 可能获得相同或错序的 token。因此,理想的 fencing token 必须由锁服务原子生成。

  • 追问 4:etcd 锁和 RedLock 在性能上有何差异?

    etcd 基于 Raft,每次加锁事务需要多数节点落盘确认,延迟通常在数毫秒到数十毫秒(受网络和磁盘影响)。RedLock 只需多次 Redis 内存操作,微秒级延迟。因此 RedLock 性能显著优于 etcd,适合对延迟敏感的缓存锁场景;etcd 适合对正确性要求严格的关键资源锁。

加分回答

  • Chubby 的启示:Google 的 Chubby 是带 fencing token 锁服务的鼻祖。它使用 Paxos 共识,并提供"lock sequencer"概念,正是 fencing token 的早期实践。etcd 深受 Chubby 影响。
  • 混合方案:一些系统设计为"读用 Redis,写用 etcd 锁",在保证读性能的同时,在关键写路径上使用强一致性锁,实现性能与正确性的平衡。

7. Redis 分布式锁 vs etcd 分布式锁:各自适用什么场景?

一句话回答:Redis 锁高性能、低延迟,适合高并发、可接受极低概率失败的场景;etcd 锁强一致、带全局 Token,适合金融、记账等绝对正确场景。

详细原理解析

  • Redis 方案特征:基于内存,单线程处理命令,锁操作 O(1) 且延迟在亚毫秒级。支持 Watchdog 续期,客户端 API 丰富(Redisson)。但 Redis 基于 AP 设计,主从异步复制,cluster 分片也不保证强一致,单点故障可能丢失最近写入的锁。适合如防止重复提交、缓存击穿防护、幂等任务分配等场景。
  • etcd 方案特征:基于 Raft,每次锁操作需多数派节点持久化日志,延迟毫秒级。提供 Lease 自动过期、全局递增 Revision 作为 fencing token。牺牲了部分性能,换取了强一致性保证。适合如分布式主键生成、金融账务协调、跨系统事务协调等场景。

多角度深度追问

  • 追问 1:在秒杀场景下如何选型?

    秒杀本质是高并发下的库存扣减,核心诉求是性能。推荐使用 Redis Lua 脚本无锁扣减,或结合 Redisson 锁进行分桶后的并行扣减。若使用 etcd 锁,数十毫秒的延迟会让吞吐量急剧下降,成为系统瓶颈。

  • 追问 2:如果业务无法接受任何锁丢失,但又要高性能怎么办?

    这种情况下,考虑将资源访问设计为"乐观并发控制 + 重试"模式。例如,使用数据库的版本号(version)进行 CAS 更新,失败则重试。这完全绕过了分布式锁,利用数据库的强一致事务保证。Redis 可以充当缓存,加速读取,但写入最终由数据库把关。

  • 追问 3:有没有同时兼顾两者的方案?

    可以在应用层实现两级锁:本地 JVM 锁 + 分布式锁。JVM 锁减少大部分同进程内的竞争,只有跨节点时才走到分布式锁。此外,使用 Redisson 的 MultiLock 可以组合多个 Redis 实例提升可靠性,但性能会成倍下降。

  • 追问 4:部署和维护 etcd 集群有哪些挑战?

    etcd 集群至少需要 3 个节点,运维复杂度较高,需要关注磁盘 IO 性能(Raft 日志落盘)、网络延迟、定期备份、升级兼容性等。相比之下,Redis 通常已有现成集群可用,运维成本低,这也是一些团队优先选择 Redis 锁的现实原因。

加分回答

  • 引用 CAP 理论精确选型 :Redis Cluster 是典型的 AP 系统(分区时保证可用性,牺牲一致性);etcd 是 CP 系统(分区时保证一致性,牺牲少数派可用性)。需要高可用、低延迟选 AP;需要严格正确性选 CP。在分布式锁的战场上,不存在同时满足 CP 和高性能的方案

8. 如何通过 Lua 脚本实现无锁化的库存扣减?与分布式锁方案相比有什么优势?

一句话回答 :将"判断库存 ≥ 请求量 → 扣减"逻辑写入 Lua 脚本,EVAL 原子执行;优势是无锁等待、无死锁、单次网络往返,吞吐量最高。

详细原理解析

  • Lua 脚本原子性 :Redis 以单线程执行 Lua 脚本,整个脚本从开始到结束不会被其他命令打断。因此,get stock; if stock >= num; decrby stock num 这三步操作构成一个原子事务。
  • 使用方式 :客户端通过 EVAL script numkeys key [key ...] arg [arg ...] 提交脚本和参数,获得成功/失败返回值。脚本需预注册或每次发送(建议使用 Redis 7 的 FUNCTION 管理脚本)。

多角度深度追问

  • 追问 1:Lua 脚本方案会超卖吗?

    不会。因为脚本内的 getifdecrby 是原子执行。但需注意:并发下多个脚本可能先后读到库存大于请求量,而只有第一个脚本的 decrby 能真正成功,后续脚本可能在判断通过后执行扣减时库存已不足?实际上,判断和扣减在同一原子脚本内,如果库存为 10,三个请求各请求 5,第一个脚本执行后库存变 5,第二个脚本读到的就是 5,只能成功一个。因此不会超卖。但可能出现"部分请求判断通过但库存已无"的情况,脚本应正确处理负值。

  • 追问 2:Lua 脚本执行时间过长会怎样?

    会阻塞整个 Redis 实例,其他所有命令排队等待。因此,Lua 脚本必须轻量、快速,严禁执行网络 IO 或复杂计算。库存扣减这类 O(1) 操作非常适合;若涉及大量数据扫描(如 KEYS *),则绝对禁止。

  • 追问 3:与 Redisson 分布式锁相比,无锁化 Lua 的性能提升有多大?

    分布式锁通常需要最少 3 次网络往返:加锁(SET)→ 执行命令(GET/DECR)→ 解锁(EVAL)。Lua 只需 1 次 EVAL 往返。在低延迟环境(如 0.5ms RTT),有锁方案至少 1.5ms,Lua 方案 0.5ms,吞吐量有数量级提升。此外,无锁避免了锁争抢导致的线程阻塞和上下文切换。

  • 追问 4:无锁化方案有什么局限性?

    只能用于操作可完全在 Redis 内原子完成的场景。一旦涉及外部数据库或服务调用,Lua 脚本无法覆盖,只能退回到分布式锁。此外,复杂的业务逻辑用 Lua 编写和维护成本高,排查困难。

加分回答

  • Redis Functions(Redis 7+)优势 :相比于 EVAL 每次传输脚本,FUNCTION 可以将脚本持久化在服务端,提供版本控制和更好的性能。在库存扣减场景中,可以将扣减逻辑封装为函数 check_and_dec,客户端只需 FCALL check_and_dec 1 stock:1001 2
  • 热点库存的分桶 :对于秒杀的热点商品,可以将单一库存 Key 拆分为 N 个 bucket,如 stock:prod:1:0stock:prod:1:9。扣减时随机选取一个 bucket 进行 Lua 扣减,进一步分散单 Key 的压力,提升并发能力。这是无锁化方案的经典扩展。

9. RPOPLPUSH 是如何实现安全队列消费的?为什么它不需要锁?

一句话回答 :利用 RPOPLPUSH 将消息从主队列原子地移动到备份队列,消费者崩溃后消息仍在备份队列,可被监控程序重入主队列。

详细原理解析

  • 原子移动语义RPOPLPUSH source destinationsource 列表的右端弹出一个元素,并将该元素原子地推入 destination 列表的左端。整个操作是原子的,不会有中间状态。
  • 可靠性模型 :消费者执行 BRPOPLPUSH processing_queue backup_queue,获取消息的同时将消息备份。消费者处理成功后,调用 LREM backup_queue -1 message 显式删除。若消费者崩溃,备份队列中留有未处理的消息。监控进程定期扫描 backup_queue,通过 LLENLRANGE 检查,将超时未删除的消息 RPOPLPUSH backup_queue processing_queue 重新投递回主队列。这就实现了"至少一次"消费保证。

多角度深度追问

  • 追问 1:如何保证消息不会被重复消费?

    无法绝对保证。如果消费者处理完业务但未执行 LREM 时就崩溃,消息会重新投递,导致重复消费。因此要求消费者业务逻辑实现幂等性(如根据唯一消息 ID 去重)。

  • 追问 2:BLMOVERPOPLPUSH 有何不同?

    Redis 6.2.0 引入 BLMOVE(阻塞版本),支持不同方向移动,如 BLMOVE source destination RIGHT LEFT,等价于 BRPOPLPUSH。推荐在新版本中使用 BLMOVE,更通用。

  • 追问 3:备份队列中的消息长期堆积怎么办?

    可能是消费者全部崩溃或处理能力不足。监控程序应设置安全阈值,告警并触发自动扩容或限流。此外,可以设置 EXPIRE 为备份队列设置整体 TTL 以防止无限堆积。

  • 追问 4:与 Redis Stream 的消费者组相比,RPOPLPUSH 方案有什么劣势?

    • 功能较弱:不支持消费者组、消息确认、消息持久化历史。
    • 错误处理:消息确认与业务处理非原子,可能丢消息或重复。
    • Redis Stream(详见后续系列)提供了更完备的消费者组、Pending 消息列表,是构建可靠消息队列的首选方案。

加分回答

  • 无锁化的精髓RPOPLPUSH 方案的精妙之处在于,将多步操作(取出 + 备份)设计为一个原子命令,从而避免了在应用层用锁保护这两个操作。这是对 Redis 命令能力的充分理解和利用,是架构设计上的减法。
  • 实际应用:Resque、Celery 等早期任务队列框架都采用了类似的原子备份模式。

10. Redisson 的 tryLocklock 有什么区别?leaseTime 参数影响什么?

一句话回答lock() 无限等待,不可中断;tryLock() 支持等待超时和中断;leaseTime 决定是否开启 Watchdog,指定值则无自动续期。

详细原理解析

  • lock() :内部调用 tryAcquire(-1, leaseTime, threadId)waitTime=-1 表示无限等待。它不响应线程中断(除非使用 lockInterruptibly())。未指定 leaseTime 时启用 Watchdog。
  • tryLock(long waitTime, long leaseTime, TimeUnit unit) :可以设置最大等待时间,超时后返回 false。若 leaseTime 指定为正值,锁将在到期后自动释放,不启用 Watchdog;若设为 -1,则行为同 lock()(无限租约+Watchdog)。
  • leaseTime 的角色 :它是锁的"硬租约"。若指定,锁的生命周期完全由该时间决定,到期必释。适合任务执行时间可预测的短任务。若不指定(-1),则由 Watchdog 动态续期,适合执行时间不可预测的长任务。

多角度深度追问

  • 追问 1:如果在持有锁期间手动调用 expire 命令会怎样?

    会破坏 Redisson 的逻辑。不应在外部直接操作 Redisson 管理的 Key。

  • 追问 2:tryLock 内部如何等待?是忙等(spin)吗?

    不是忙等。未立即获取锁的线程会订阅 Redis Pub/Sub 频道,并通过 Java 信号量 Semaphore 挂起。当收到释放锁的 Pub/Sub 消息或等待超时时,线程被唤醒,重新尝试获取锁。这大大降低了 CPU 和网络开销。

  • 追问 3:如果 waitTime 设为 0 会发生什么?
    tryLock(0, leaseTime, unit) 相当于"仅尝试一次",若锁空闲则立即获取,否则立即返回 false。这是非阻塞的抢锁模式。

  • 追问 4:多个线程同时调用 tryLock 等待,释放锁时哪个线程能获取?

    Redisson 不是严格公平锁。锁释放后,所有等待线程被 Pub/Sub 消息唤醒,并发竞争 tryAcquire,谁先发送 Lua 脚本到 Redis 谁获胜。若需要公平性,可使用 RedissonFairLock,它基于 Redis 队列实现公平调度,但性能较低。

加分回答

  • 内部等待队列 :Redisson 使用 LockPubSubSemaphore 的组合实现高效的等待唤醒。每次锁释放都会 publish 一次,但只有当前订阅了该频道的线程才会被唤醒。这防止了无关联线程的无效唤醒。
  • 生产最佳实践 :始终在 finally 块中解锁,且判断 isHeldByCurrentThread() 后再 unlock(),避免抛出 IllegalMonitorStateException

11. 生产环境中 Redis 分布式锁出现了死锁,可能是哪些原因导致的?如何排查?

一句话回答 :原因可能是未设过期时间、Redisson 显式指定 leaseTime 过小却未续期、解锁 Lua 误删、客户端崩溃未释放等;排查应查看 TTL、锁持有者 info、GC 日志。

详细原理解析

  • 死锁原因分类

    1. 未设置过期时间 :使用原生 SETNXSET NX 未加 PX 参数,一旦客户端崩溃,Key 永久存在。
    2. 租约过短且无 Watchdoglock(1, TimeUnit.SECONDS) 但业务逻辑执行超过 1s,锁自动释放,但客户端代码未检测锁状态,可能继续执行,这不严格算死锁,是并发冲突。真正的死锁是锁无法释放,即 leaseTime 设置过短可能导致逻辑错误,但 Key 本身会正常过期,不会死锁。
    3. 解锁异常 :解锁脚本或代码抛出异常,导致 unlock() 未执行。Redisson 解锁时若 value 不匹配会忽略,但若网络异常导致 DEL 未发送,则锁未释放。
    4. Watchdog 线程异常:极少数情况下 Watchdog 线程因 OOM 或其他错误终止,锁到期释放后可能被其他线程占据,但这不是死锁。
    5. Redis 内存淘汰 :如果内存满且 maxmemory-policyallkeys-lru 等,锁的 Key 可能被驱逐,导致大量线程同时获取锁,引发逻辑混乱。
  • 排查步骤

    1. TTL lockKey:查看锁剩余时间,若为 -1 表示无过期,即可能死锁。
    2. HGETALL lockKey:查看是哪个客户端(UUID:ThreadId)持锁,以及重入次数。可反向定位到持有者主机。
    3. 检查持锁客户端的日志、GC 日志,看是否存在长时间停顿或线程僵死。
    4. 使用 CLIENT LISTMONITOR 命令观察 Redis 命令流,确认是否有续期命令发送。
    5. 检查 Redis 内存使用和淘汰策略,确认 Key 是否被意外驱逐。

多角度深度追问

  • 追问 1:如何安全地强制释放一个死锁的锁?

    如果能确认持有者已宕机,可以直接 DEL lockKey。但危险性极高 ,因为可能发生误判(持有者只是 GC 停顿或网络分区)。Redisson 提供 forceUnlock() 方法,允许管理员强制解锁,但同样需谨慎。

  • 追问 2:使用 Redis Cluster 时,锁的 Key 发生迁移会怎样?

    在线迁移数据时,如果锁的 Key 被迁移到另一个节点,原节点的锁定状态会丢失,可能导致锁失效。因此,在 cluster 环境下使用分布式锁应避免对锁 Key 做 ReSharding,或使用 RedLock 跨分片方案。

  • 追问 3:死锁和活锁有什么区别?

    死锁是锁永远无法获取(被永久持有)。活锁是锁不断被获取和释放,但特定客户端始终获取不到(如不公平锁的饥饿)。Redisson 默认非公平锁可能出现活锁,可通过 RedissonFairLock 解决。

  • 追问 4:如果怀疑是 Redisson 的 bug,如何进一步深入?

    可以开启 Redisson 的 TRACE 日志,查看 tryAcquirerenewExpiration 等关键日志。使用断点或 arthas 动态追踪 EXPIRATION_RENEWAL_MAP 的状态。

加分回答

  • 分布式锁的监控:生产环境应建立对锁的监控指标:锁平均持有时长、最大持有时长、续期失败次数、等待锁的线程数。这些指标能帮助提前发现死锁或性能瓶颈。
  • 降级方案:关键业务应设计分布式锁的降级开关,在出现大面积死锁时能快速关闭锁逻辑,改为乐观并发控制或直接限流,保证核心链路可用。

12. (系统设计题)设计一个秒杀系统的库存扣减方案,要求支持百万级并发、防止超卖、尽可能少用锁,给出完整的 Redis 数据结构和操作流程,并分析极端场景下的竞态风险与解决策略。

回答结构

  1. 概述 :采用Redis Lua 脚本无锁扣减为主,辅以数据库最终一致性保证。

  2. 数据结构

    • stock:item:<itemId>:String 存储总库存。
    • order:set:<itemId>:Set 存储成功抢购的用户 ID,用于幂等。
  3. 核心流程

    • 用户请求到达,预校验(限流、黑名单等)。
    • 执行 Lua 脚本原子扣减库存,同时将用户 ID 加入 Set 防重。
    • 扣减成功返回唯一订单流水号(可由 Redis 生成),前端提示"排队中"。
    • 后台异步通过订单流水写入数据库,确认最终订单状态。
  4. Lua 脚本设计

    lua 复制代码
    local stock_key = KEYS[1]
    local order_set_key = KEYS[2]
    local user_id = ARGV[1]
    local request = tonumber(ARGV[2])
    
    -- 幂等检查:已购买用户直接返回
    if redis.call('sismember', order_set_key, user_id) == 1 then
        return -1 -- 重复请求
    end
    
    local stock = tonumber(redis.call('get', stock_key) or 0)
    if stock >= request then
        redis.call('decrby', stock_key, request)
        redis.call('sadd', order_set_key, user_id)
        return 1 -- 秒杀成功
    else
        return 0 -- 库存不足
    end
  5. 极端场景与解决

    • 脚本执行缓慢 :库存 Key 热点导致 Redis 单线程阻塞?通过分桶库存 ,将 stock:item:1 拆分为 10 个分片 stock:item:1:0...9,脚本内随机选一个分片扣减,负载分散。
    • Redis 宕机:引入哨兵/Cluster(第 5 篇),并设置合理的持久化策略(AOF+RDB)。但异步复制可能丢少量数据,允许部分超卖?可接受极少量超卖则通过数据库唯一约束兜底,或活动结束后对账退款。
    • 用户重复秒杀 :幂等 Set 防止;若内存过大,可改用 Bloom Filter 过滤,再做 Set 精确判断。
    • 消息积压:扣减成功但后续写数据库缓慢,可限流控制前端流量,配合消息队列削峰填谷。
  6. 总结:无锁 Lua 脚本方案最大化利用 Redis 原子性,极简高效。通过分桶和限流解决热点与并发压力,业务最终一致性兜底,是工业级秒杀系统的经典范式。

多角度深度追问

  • 追问 1:分桶后,如果某个桶的库存被抢光,而其他桶还有库存怎么办?

    客户端在脚本中增加轮转逻辑:若随机选到的桶库存不足,可尝试下一个桶(stock:item:1:(next_index))。但为防止脚本太复杂或执行时间过长,通常设定尝试上限(如 2-3 次)或由客户端重试。更好的方式是利用 Redis Cluster 的数据分片,将不同桶分布在多个节点,平衡负载。

  • 追问 2:如何保证 Set 防重的内存无限增长?

    秒杀活动结束后,可将 Set 转为过期或使用 EXPIRE 设置过期时间(如 7 天)。对于海量用户,可使用 HyperLogLog 近似去重或 Bloom Filter 预先过滤,再用 Set 精准判断,大幅降低内存。

  • 追问 3:如果 Lua 脚本中 decrby 后发现库存变负(逻辑错误),怎么办?

    成熟的脚本应在 decrby 后检查结果,如果 <0 则回滚 incrby 并返回 0。我给的示例中先比较再扣减,不会出现负值。但并发下,多个脚本可能同时读库存,比如库存=1,两个脚本都读到 1,第一个扣减后变 0,第二个再执行判断时库存可能是 0(扣减前已读得 1),比较仍通过?不,脚本是单线程顺序执行的,第一个脚本全部执行完毕(包括扣减)后,第二个脚本才开始执行,此时 get 会读到 0,判断不通过。所以安全。

  • 追问 4:该方案中数据库的作用是什么?

    数据库作为最终一致性兜底和持久化存储。Redis 中扣减成功只代表"抢购资格获取"。后续订单创建、支付等长流程依赖数据库事务。Redis 与数据库之间通过异步消息队列同步,保证最终一致。

加分回答

  • 多级库存:可在 Redis 和数据库之间再加一层本地缓存(如 Caffeine),应用启动时预热部分库存到本地,线程扣减本地库存,定时异步同步,极限压榨性能。但需处理本地库存与 Redis 的一致性问题,复杂度极高,一般只有顶级大厂在真正极端并发下使用。
  • 全链路压测:系统设计需包含全链路压测方案,模拟真实流量验证 Redis 脚本的正确性和分桶负载均衡效果。

分布式锁速查表

锁类型 实现方式 适用场景 优点 缺点 关键参数
基础 Redis 锁 SET NX PX + Lua 解锁 低并发、快速任务,无重入需求 简单直接,性能极高 不可重入,无续期,单点故障丢失锁 NX, PX
Redisson 单点锁 Redis Hash + Lua + Watchdog 大多数分布式业务,需要可重入和续期 完全 JUC 接口,可重入,自动续期,Pub/Sub 通知 强依赖客户端活性,主从切换可能丢锁 leaseTimeinternalLockLeaseTime=30s
RedLock(红锁) 多个独立 Redis 过半加锁 需要提高 Redis 容错性的场景 抵御单实例故障,较高可用性 性能较差,受 GC/时钟影响,无 Fencing Token 不安全 实例数 N=5,请求超时,TTL
etcd 锁 Raft + Lease + 事务 + Revision 金融、元数据管理等强一致性场景 强一致,全局 Token,自动过期 性能低于 Redis,运维复杂,CP 限制可用性 Lease TTLRevision
ZooKeeper 锁 临时顺序节点 + Watcher 分布式协调,如主节点选举 强一致,公平锁,自动释放 性能中等,写操作较慢,依赖 ZK 集群 会话超时,EPHEMERAL_SEQUENTIAL
无锁 Lua 脚本 EVAL 复合原子操作 秒杀库存扣减,计数器,简单状态流转 零锁等待,无死锁,最高吞吐量 只能用于纯内存操作,复杂业务逻辑难以完全脚本化 脚本执行时间
安全队列 (RPOPLPUSH) 原子备份队列 可靠消息消费,任务队列 无锁,保证消息不丢失 仅适用于列表,消费速度受单线程限制,需额外监控 备份队列 TTL,监控延迟

延伸阅读

  • Martin Kleppmann. 《How to do distributed locking》. 2016.
  • Salvatore Sanfilippo (antirez). 《Is Redlock safe?》. 2016.
  • Redisson 官方文档及 Wiki:github.com/redisson/re...
  • 钱文品(老钱). 《Redis 深度历险:核心原理与应用实践》. 分布式锁章节.
  • etcd 官方文档:etcd.io/docs/v3.5/l...
  • Google. 《The Chubby lock service for loosely-coupled distributed systems》. 2006.

本文以一条 SET 命令为起点,遍历了 Redisson 的 Watchdog 精髓、RedLock 的雄心与缺陷、直至 etcd 的 Fencing Token 守护。在这个旅程中,我们反复看到分布式系统设计中的核心命题:没有银弹,只有权衡。理解这些锁机制的内核与边界,将帮助我们在汹涌的并发潮中筑起坚固的堤坝,并在面试与系统设计时游刃有余。

相关推荐
二宝哥16 小时前
CentOS 7.9 系统下 Redis 5.0.4 单机安装与源码编译安装指南
redis·centos
ShiXZ2131 天前
Redis docker-compose 部署指南
redis·docker·eureka
青山木1 天前
Redis 缓存一致性全解:从 Cache Aside 到延迟双删、Canal+MQ 的进阶之路
java·数据库·redis·后端·缓存
YDS8291 天前
大营销平台 —— 活动SKU库存扣减业务及其一致性处理
java·spring boot·redis·rabbitmq·ddd
爬也要爬着前进2 天前
redis主从搭建
数据库·redis·缓存
用户095367515832 天前
在系统的学习 redis 前的疑惑
redis·go
吴声子夜歌2 天前
Redis 3.x——集群故障转移
java·数据库·redis·集群
明豆2 天前
Redis 分布式缓存生产实战
redis·分布式·缓存
海兰2 天前
【高速缓存】RedisVL 指南:从向量搜索到 AI 应用落地
人工智能·redis
weixin_727535622 天前
双Token认证体系深度拆解:Spring Security + JWT + Redis
redis·spring·wpf