分布式锁与 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 守护。在这个旅程中,我们反复看到分布式系统设计中的核心命题:没有银弹,只有权衡。理解这些锁机制的内核与边界,将帮助我们在汹涌的并发潮中筑起坚固的堤坝,并在面试与系统设计时游刃有余。

相关推荐
wljt10 小时前
Redis的5种数据类型
数据库·redis·缓存
燕-孑11 小时前
redis详解-进阶
数据库·redis·缓存
phltxy12 小时前
Redis 缓存
数据库·redis·缓存
小碗羊肉12 小时前
【Redis | 第一篇】Redis常见命令
数据库·redis·缓存
Devin~Y12 小时前
大厂Java面试实战:Spring Boot微服务、Redis缓存、Kafka消息队列与Spring AI RAG
java·spring boot·redis·kafka·mybatis·spring mvc·hikaricp
手握风云-12 小时前
Redis:不只是缓存那么简单(十二)
redis·缓存
半夜修仙13 小时前
Redis中List数据类型的常见命令
数据库·redis·缓存
Jul1en_13 小时前
【Redis】Sentinel 哨兵支持,附带 Docker 部署教程
redis·docker·sentinel
恣艺13 小时前
用Go从零实现一个高性能KV存储引擎:B+Tree索引、WAL持久化、LRU缓存的工程实践
开发语言·数据库·redis·缓存·golang