问题引入

2015 年某日凌晨,一条明星离婚的消息引爆社交媒体。该明星的个人信息页是系统的热点数据,平时 QPS 超过 10 万。运维团队设置了 5 分钟的缓存过期时间,凌晨 2:17,这个 key 恰好过期了。

接下来的 30 秒内,超过 50 万请求同时打到数据库——连接池瞬间耗尽,大量请求排队等待,数据库 CPU 飙到 100%,随后是级联的雪崩:其他依赖同一数据库的模块也开始超时、重试、再超时。整个平台的响应时间从 50ms 飙升到 30 秒以上,持续 12 分钟。

事后复盘发现,这不是缓存雪崩(不是大量 key 同时过期),而是缓存击穿——单个热点 key 过期引发的灾难。而更深层的教训是:缓存不是 "加了就行",穿透、击穿、雪崩三种问题的本质不同,解决方案也截然不同;分布式锁不是 "SET 一个 key 就行",原子性、过期时间、续期机制每一个环节都可能是陷阱。

核心概念

缓存穿透:查询"不存在"

缓存穿透是指查询一个不存在的数据,缓存无法命中(因为没存过),请求直接打到数据库。

graph TD subgraph "缓存穿透" C1["客户端请求\nid=-1"] -->|查缓存| CACHE["Redis\nMISS"] CACHE -->|查数据库| DB["MySQL\nSELECT * FROM user WHERE id=-1\n返回 NULL"] DB -->|不缓存 NULL| CACHE C2["客户端请求\nid=-1"] -->|重复查询| CACHE2["Redis\nMISS"] CACHE2 -->|再次查数据库| DB end style CACHE fill:#f9f,stroke:#333,stroke-width:2px style DB fill:#f9f,stroke:#333,stroke-width:2px

读图导引:粉色节点展示了穿透的核心——缓存和数据库都不存在该数据,所以每次请求都会穿透到数据库。如果攻击者构造大量不存在的 id 发起请求,数据库会被压垮。

三种解决方案

  1. 布隆过滤器:在缓存之前加一层概率型过滤器,快速判断 "该 key 一定不存在" 或 "可能存在"
  2. 空值缓存:将数据库返回的 NULL 也缓存起来(如设置 60 秒过期),后续相同查询直接命中缓存
  3. 接口校验:在入口处做参数合法性校验(如 id > 0),拦截明显非法的请求

布隆过滤器:用概率换空间

布隆过滤器(Bloom Filter)是一个位数组 + k 个哈希函数组成的数据结构。

graph TD subgraph "布隆过滤器原理" E1["元素 x"] -->|hash1| B3["bit[3]=1"] E1 -->|hash2| B7["bit[7]=1"] E1 -->|hash3| B12["bit[12]=1"] E2["元素 y"] -->|hash1| B7 E2 -->|hash2| B15["bit[15]=1"] E2 -->|hash3| B22["bit[22]=1"] Q["查询 z"] -->|hash1→bit[3]=1\nhash2→bit[7]=1\nhash3→bit[20]=0| R["bit[20]=0\n一定不存在"] end style B7 fill:#f9f,stroke:#333,stroke-width:2px style R fill:#9f9,stroke:#333,stroke-width:2px

读图导引:粉色节点是 bit[7]——两个元素(x 和 y)的哈希都映射到了同一个位,发生了碰撞。绿色节点是查询结果——只要有一个哈希对应的位为 0,该元素一定不存在。

误判率公式

布隆过滤器的误判率(false positive rate)为:

复制代码
p ≈ (1 - e^(-kn/m))^k

其中 m 是位数组大小,n 是元素个数,k 是哈希函数个数。当 k = (m/n) * ln(2) 时,误判率最小:

复制代码
p ≈ (0.6185)^(m/n)

暗面:布隆过滤器有两个无法消除的限制:> 1. 无法删除:位数组被多个元素共享,删除一个元素可能误删其他元素的标记> 2. 误判率不可为零:只要空间固定、元素增加,误判率必然上升> > Redis 4.0 提供了 RedisBloom 模块,支持 Counting Bloom Filter(可删除)和 Cuckoo Filter(更低误判率),但代价是更多的内存开销。

缓存击穿:热点 key 的"死亡瞬间"

缓存击穿是指某个热点 key 在过期瞬间,大量并发请求同时打到数据库。

graph TD subgraph "缓存击穿时间线" T0["T0: key 存在\n缓存命中 100%"] -->|TTL 到期| T1["T1: key 过期\n缓存 MISS"] T1 -->|线程1| DB1["查数据库"] T1 -->|线程2| DB2["查数据库"] T1 -->|线程3| DB3["查数据库"] T1 -->|...| DBN["N 个线程同时查数据库"] end style T1 fill:#f9f,stroke:#333,stroke-width:2px style DB1 fill:#f9f,stroke:#333,stroke-width:2px style DBN fill:#f9f,stroke:#333,stroke-width:2px

读图导引:粉色节点展示了击穿的爆发点——key 过期的瞬间,所有请求同时穿透到数据库。与穿透不同,击穿查询的是 "存在的 key",只是在过期窗口期被放大。

解决方案

  1. 互斥锁(Mutex):缓存 miss 后,只允许一个线程查数据库并回写缓存,其他线程等待
  2. 逻辑过期(Logical TTL):不设置 Redis 的 TTL,而是在 value 里存一个逻辑过期时间,由后台线程异步更新
  3. 热点 key 预加载:在过期前主动刷新,避免过期窗口

缓存雪崩:级联崩塌

缓存雪崩是指大量 key 同时过期(如批量设置的固定 TTL),或者 Redis 集群宕机,导致数据库压力骤增。

graph TD subgraph "缓存雪崩" C1["Key1 TTL到期"] -->|同时| DB["数据库压力骤增"] C2["Key2 TTL到期"] -->|同时| DB C3["Key3 TTL到期"] -->|同时| DB C4["..."] -->|同时| DB DB -->|连接池耗尽| TIMEOUT["大量请求超时"] TIMEOUT -->|客户端重试| DB2["更大数据库压力"] end style DB fill:#f9f,stroke:#333,stroke-width:2px style TIMEOUT fill:#f9f,stroke:#333,stroke-width:2px

读图导引:粉色节点展示了雪崩的级联效应——大量 key 同时过期导致数据库被压垮,请求超时后客户端重试,进一步放大压力。

解决方案

方案 原理 适用场景
随机过期时间 在基础 TTL 上加随机偏移(如 300±30 秒) 批量导入数据
多级缓存 L1(本地 Caffeine)+ L2(Redis)+ L3(DB) 读多写少
熔断降级 数据库压力过大时返回默认值或错误码 保护核心链路
高可用架构 Redis Cluster / Sentinel 避免单点 防止集群宕机

分布式锁:SET NX EX 的原子语义

分布式锁是缓存架构中最常用的协调工具——防止缓存击穿的互斥查库、防止定时任务重复执行、分布式事务的串行化等。

Redis 实现分布式锁的核心命令:

bash 复制代码
SET resource_name my_random_value NX EX 30
  • NX:Only if Not eXists,key 不存在时才设置
  • EX 30:设置 30 秒过期时间
  • my_random_value:每个客户端的唯一随机值,用于释放时验证(防止误删别人的锁)
graph TD subgraph "分布式锁流程" A["Client1\nSET lock NX EX 30\nvalue=abc123"] -->|成功| B["获得锁"] C["Client2\nSET lock NX EX 30\nvalue=def456"] -->|失败| D["锁已存在"] B -->|执行业务| E["业务逻辑"] E -->|释放| F["Lua脚本:\nif value==abc123\n then DEL lock"] end style A fill:#9f9,stroke:#333,stroke-width:2px style C fill:#f9f,stroke:#333,stroke-width:2px

读图导引:绿色节点是 Client1 成功加锁——NX 保证原子性判断,EX 防止死锁。粉色节点是 Client2 加锁失败——锁已被占用。释放锁必须用 Lua 脚本保证 "判断 value + 删除" 的原子性。

为什么必须用原子命令?

早期方案是 SETNX lock 1 + EXPIRE lock 30 两条命令——如果执行完 SETNX 后进程崩溃,EXPIRE 没执行,锁就永远不会释放。

面试官视角:问 "释放锁为什么用 Lua 脚本" 的答案是:保证 "判断 value 是否等于自己的随机值" 和 "删除 key" 的原子性。如果先 GET 再 DEL,中间可能锁已经过期并被其他客户端获取,此时 DEL 会删掉别人的锁。

Redlock:多节点的多数派锁

单节点 Redis 的分布式锁在节点宕机时有风险——如果主节点在锁还没同步到从节点时宕机,从节点晋升为主后,锁就丢失了。

Redis 作者 antirez 提出的 Redlock 算法,通过多独立节点上的多数派来解决:

graph TD subgraph "Redlock 算法" C["客户端"] -->|获取锁| R1["Redis1\nSET lock NX EX 30"] C -->|获取锁| R2["Redis2\nSET lock NX EX 30"] C -->|获取锁| R3["Redis3\nSET lock NX EX 30"] C -->|获取锁| R4["Redis4\nSET lock NX EX 30"] C -->|获取锁| R5["Redis5\nSET lock NX EX 30"] R1 -->|成功| V["3/5 成功\n且总耗时 < TTL\n加锁成功"] R2 -->|成功| V R3 -->|成功| V R4 -->|失败| V R5 -->|失败| V end style V fill:#9f9,stroke:#333,stroke-width:2px style R4 fill:#f9f,stroke:#333,stroke-width:2px style R5 fill:#f9f,stroke:#333,stroke-width:2px

读图导引:绿色节点是加锁成功——在 5 个独立 Redis 节点上尝试获取锁,至少 3 个(N/2+1)成功且总耗时小于 TTL,才算加锁成功。粉色节点是失败的节点——不需要全部成功,多数派即可。

Redlock 的步骤

  1. 获取当前时间戳 T1
  2. 依次向 N 个独立 Redis 节点发送 SET key value NX EX TTL
  3. 计算总耗时 elapsed = now - T1
  4. 如果成功节点数 >= N/2+1 且 elapsed < TTL,加锁成功
  5. 锁的实际有效时间 = TTL - elapsed
  6. 释放时向所有节点发送 DEL(包括未成功获取的节点,清除可能的残留)

Martin Kleppmann 的质疑

分布式系统研究者 Martin Kleppmann 在《How to do distributed locking》中指出 Redlock 的安全隐患:

  • 时钟漂移:如果某个 Redis 节点的时钟比其他节点快,它可能提前过期锁,导致另一个客户端在锁"应该"还存在时获取成功
  • GC 停顿:客户端在获取锁后发生长时间 GC,锁已经过期但客户端以为还持有,继续操作会引发数据不一致
  • 网络延迟:获取锁的请求在网络中被延迟,到达时锁已经过期
graph TD subgraph "时钟漂移导致的问题" C1["Client1\n在 Node1 获取锁\nTTL=30s"] -->|Node1 时钟快 35s| E1["Node1 认为\n30s 后过期"] C2["Client2\n在 Node1 获取锁"] -->|实际只过了 25s\n但 Node1 认为已过期| S["Client2 加锁成功!\n两个客户端同时持有锁"] end style S fill:#f9f,stroke:#333,stroke-width:2px

读图导引:粉色节点展示了 Redlock 的脆弱边界——时钟漂移破坏了 TTL 的语义。Redlock 假设所有节点的时钟是同步的,但在真实分布式环境中,NTP 同步误差可能达到数十毫秒甚至秒级。

暗面:Redlock 的争议至今未休。antirez 认为在大多数工程场景中 Redlock 足够安全;Kleppmann 认为如果无法保证时钟同步和 GC 停顿控制,应该使用基于共识的系统(如 ZooKeeper、etcd)来实现分布式锁。工程上的折中方案是:Redlock + 看门狗续期 + 业务幂等——锁的安全性由多层机制共同保证。

Redisson 看门狗:锁的自动续期

Redisson 是 Redis 的 Java 客户端,它实现的分布式锁有一个核心机制——看门狗(Watch Dog)自动续期

graph TD subgraph "Redisson 看门狗机制" L["加锁成功\nTTL=30s"] -->|启动看门狗| W["后台线程\n每隔 10s 检查"] W -->|锁仍被持有| R["续期 TTL\n重置为 30s"] W -->|业务执行完| U["主动释放锁\n关闭看门狗"] W -->|业务崩溃\n未主动释放| E["看门狗检测不到续期\nTTL 到期后自动释放"] end style W fill:#f9f,stroke:#333,stroke-width:2px style R fill:#9f9,stroke:#333,stroke-width:2px style E fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点是看门狗线程——加锁成功后启动,默认每隔 lockWatchdogTimeout / 3(即 30/3=10 秒)检查一次。绿色节点是续期——如果业务还在执行,看门狗自动将锁的 TTL 重置为 30 秒。蓝色节点是业务崩溃后的自动释放——如果进程挂了,看门狗不再续期,TTL 到期后锁自动释放,避免死锁。

Redisson 加锁的核心逻辑

java 复制代码
RLock lock = redisson.getLock("myLock");
lock.lock();  // 默认 TTL = 30s,启动看门狗

try {
    // 业务逻辑,可能执行 60 秒
} finally {
    lock.unlock();  // 释放锁,关闭看门狗
}

看门狗续期源码逻辑(简化):

  1. lock() 成功后,启动一个 TimerTask,延迟 watchdogTimeout / 3 执行
  2. 任务执行时,发送 Lua 脚本给 Redis:如果锁还被当前线程持有,则将 TTL 重置为 watchdogTimeout
  3. 续期成功后,再次调度下一个 TimerTask
  4. unlock() 时,取消所有未执行的续期任务,然后释放锁

手动指定 TTL vs 看门狗

java 复制代码
// 方式1:手动指定 TTL,不看门狗
lock.lock(10, TimeUnit.SECONDS);  // 10 秒后自动过期,不续期

// 方式2:不指定 TTL,启用看门狗
lock.lock();  // 默认 30 秒,自动续期

暗面:看门狗也不是万无一失。如果 Redis 节点在续期请求发出后、响应返回前宕机,客户端可能以为续期成功但实际失败。Redisson 的处理是:续期操作是异步的,失败不会抛异常,锁将在 TTL 到期后释放——这意味着业务可能在中途失去锁。如果业务对锁的连续性要求极高,需要在业务层面做幂等或乐观锁校验

原理分析

缓存三问题的本质差异

graph TD subgraph "缓存三问题对比" P["穿透\n查询不存在的数据"] -->|原因| P1["恶意攻击\n爬虫批量查询"] B["击穿\n热点 key 过期"] -->|原因| B1["高并发 + TTL 到期"] A["雪崩\n大量 key 同时过期"] -->|原因| A1["批量设置相同 TTL\n或 Redis 宕机"] end style P fill:#f9f,stroke:#333,stroke-width:2px style B fill:#bbf,stroke:#333,stroke-width:2px style A fill:#9f9,stroke:#333,stroke-width:2px

读图导引:三种问题的根本原因不同——穿透是 "数据不存在",击穿是 "单个热点过期",雪崩是 "批量失效"。解决方案也不同:穿透用布隆过滤器拦截,击穿用互斥锁或逻辑过期,雪崩用随机 TTL 或多级缓存。

布隆过滤器的参数设计

假设需要存储 n=1 亿个元素,要求误判率 p < 1%:

复制代码
最优位数组大小 m = -n * ln(p) / (ln(2))^2
                  = -1e8 * ln(0.01) / 0.480
                  ≈ 1.44e8 * 4.605 / 4.605
                  ≈ 958MB

最优哈希函数数 k = m/n * ln(2)
                 = 9.58 * 0.693
                 ≈ 6.6 → 取 7

1 亿个元素、1% 误判率的布隆过滤器需要约 958MB 内存。如果误判率要求降到 0.1%,内存需要增加到约 1.4GB

graph LR subgraph "布隆过滤器空间效率" D1["直接存储 1 亿个\n64bit hash\n≈ 762MB"] -->|可查询| Q1["精确判断"] D2["布隆过滤器\nm=958MB, k=7\n≈ 958MB"] -->|可查询| Q2["1% 误判\n0% 漏判"] end style D2 fill:#f9f,stroke:#333,stroke-width:2px

读图导引:粉色节点展示了布隆过滤器的空间权衡——比直接存储所有 hash 值略大,但提供了 O(1) 的查询和固定的内存上限。关键是 "0% 漏判"(不会把存在的判断为不存在),代价是 "1% 误判"(可能把不存在的判断为存在)。

互斥锁防止击穿的实现细节

java 复制代码
public String getWithMutex(String key) {
    String value = redis.get(key);
    if (value != null) return value;

    // 缓存未命中,尝试获取互斥锁
    String mutexKey = "mutex:" + key;
    // SET mutex:key value NX EX 30
    boolean locked = redis.set(mutexKey, "1", "NX", "EX", 30);

    if (locked) {
        try {
            // 双重检查,防止获取锁期间其他线程已回写
            value = redis.get(key);
            if (value != null) return value;

            // 查数据库并回写缓存
            value = db.query(key);
            redis.setex(key, 3600, value);
        } finally {
            redis.delete(mutexKey);
        }
    } else {
        // 获取锁失败,短暂等待后重试
        Thread.sleep(50);
        return getWithMutex(key);  // 递归重试
    }
    return value;
}

这个实现有几个关键点:

  1. 双重检查:获取锁后再次查缓存,防止等待期间其他线程已回写
  2. 锁的 TTL:即使获取锁的线程崩溃,TTL 到期后锁自动释放
  3. 递归重试:没拿到锁的线程 sleep 后重试,最终会从缓存命中

多级缓存的架构设计

graph TD subgraph "多级缓存架构" C["客户端"] -->|1. 查本地| L1["L1: Caffeine\n本地缓存\nTTL=1min"] L1 -->|MISS| L2["L2: Redis\n分布式缓存\nTTL=10min"] L2 -->|MISS| L3["L3: MySQL\n数据库"] L3 -->|回写| L2 L2 -->|回写| L1 end style L1 fill:#9f9,stroke:#333,stroke-width:2px style L2 fill:#bbf,stroke:#333,stroke-width:2px style L3 fill:#f9f,stroke:#333,stroke-width:2px

读图导引:绿色节点是 L1 本地缓存——命中时延迟最低(微秒级),但每台机器独立,数据一致性较弱。蓝色节点是 L2 Redis——分布式共享,延迟毫秒级。粉色节点是 L3 数据库——最终数据源,延迟最高但最权威。

多级缓存的核心 trade-off:

  • L1 本地缓存:极快,但数据不一致(各节点缓存独立),适合读极多、一致性要求低的场景
  • L2 Redis 缓存:较快,一致性较好(所有节点共享),但网络开销比本地大
  • L3 数据库:最慢,但最权威

更新策略:数据库更新后,通过 Canal/MQ 异步刷新 L2,L2 过期后 L1 自然过期(或主动失效)。

实战/源码

布隆过滤器的 Redis 实现

bash 复制代码
# Redis 4.0+ 需要安装 RedisBloom 模块
# docker run -p 6379:6379 redis/redis-stack:latest

# 创建布隆过滤器,预期 100 万元素,0.1% 误判率
BF.RESERVE users 0.001 1000000

# 添加元素
BF.ADD users "user:1000"
BF.ADD users "user:1001"

# 查询
BF.EXISTS users "user:1000"   # 返回 1(可能存在)
BF.EXISTS users "user:9999"   # 返回 0(一定不存在)

Redisson 分布式锁的完整使用

java 复制代码
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);

RLock lock = redisson.getLock("order:lock:123");

// 尝试加锁,最多等待 10 秒,启用看门狗自动续期
boolean acquired = lock.tryLock(10, -1, TimeUnit.SECONDS);
if (acquired) {
    try {
        // 处理订单
        processOrder(123);
    } finally {
        lock.unlock();
    }
} else {
    throw new RuntimeException("获取锁失败");
}

缓存预热与随机过期

java 复制代码
// 批量加载缓存时,给每个 key 加上随机偏移
int baseTtl = 3600;  // 基础 1 小时
int randomOffset = ThreadLocalRandom.current().nextInt(300);  // 0~300 秒随机
redis.setex(key, baseTtl + randomOffset, value);

// 缓存预热:系统启动时异步加载热点数据
@PostConstruct
public void warmup() {
    List<String> hotKeys = getHotKeysFromConfig();
    executor.submit(() -> {
        hotKeys.forEach(this::loadIntoCache);
    });
}

常见问题

Q1:缓存穿透、击穿、雪崩的区别?

问题 触发条件 影响范围 核心解决
穿透 查询不存在的数据 单个/批量 key 布隆过滤器、空值缓存
击穿 热点 key 过期 单个 key 互斥锁、逻辑过期
雪崩 大量 key 同时过期 批量 key 随机 TTL、多级缓存、熔断

Q2:布隆过滤器的误判率怎么调?

增加位数组大小 m 或减少元素数 n。RedisBloom 的 BF.RESERVE 在创建时指定误判率和预期容量,一旦创建无法修改。如果实际元素超过预期容量,误判率会上升。

Q3:Redlock 在生产环境能用吗?

有争议。如果系统对锁的绝对安全要求极高(如金融扣款),建议使用 ZooKeeper(临时顺序节点 + watcher)或 etcd(基于 Raft 的 TTL 租约)。Redlock 更适合 "锁丢失的概率损失可接受" 的场景,配合看门狗续期和业务幂等,工程上足够安全。

Q4:看门狗续期失败怎么办?

Redisson 的续期是异步的,续期失败不会抛异常。锁会在 TTL 到期后自动释放。业务代码应该:

  1. 在锁内定期检查 lock.isHeldByCurrentThread()
  2. 如果锁已丢失,终止当前操作或重新竞争
  3. 关键业务配合数据库乐观锁(version 字段)做二次校验

Q5:多级缓存的一致性怎么保证?

经典的 "Cache-Aside" 模式:

  1. 读:先读缓存 → 未命中读 DB → 回写缓存
  2. 写:先写 DB → 删缓存(而不是更新缓存)

为什么写操作是 "删缓存" 而不是 "更新缓存"?因为并发写时,缓存更新的顺序可能与数据库不一致("写偏序"问题)。删除缓存让下一次读操作从 DB 加载最新值,虽然多一次 DB 查询,但保证了最终一致性。

总结

Redis 缓存实战与分布式锁的精髓,是在性能、一致性、可用性之间做精确的工程取舍:

  • 缓存穿透:用布隆过滤器在缓存前拦截"不存在"的请求,或用空值缓存减少数据库压力。布隆过滤器的误判率由 m/n/k 三个参数精确控制,但无法删除和零误判是其理论边界
  • 缓存击穿:用互斥锁保证只有一个线程查数据库并回写,其他线程等待或重试。锁的 TTL 必须设置,防止进程崩溃导致的死锁
  • 缓存雪崩:用随机过期时间打散批量失效,用多级缓存(L1 本地 + L2 Redis)隔离数据库压力,用熔断降级保护核心链路
  • SET NX EX:原子命令保证"判断+设值+过期"不可分割,释放锁必须用 Lua 脚本保证"判断value+删除"的原子性
  • Redlock:多独立节点的多数派锁,解决单点故障问题,但受时钟漂移和 GC 停顿的制约。工程上配合看门狗续期和业务幂等使用
  • Redisson 看门狗:后台线程自动续期,防止业务执行时间长于锁 TTL 导致的误释放。续期失败时锁自动释放,业务需做幂等或乐观锁校验

理解这些机制的关键,是追问:当锁在业务中途过期时,系统的行为是什么?当布隆过滤器的误判发生时,兜底策略是什么?当缓存雪崩触发熔断时,降级方案是否可接受? 从"知道怎么配置"到"懂得故障场景下的边界行为",是跨越中级和高级的分水岭。