问题引入
2015 年某日凌晨,一条明星离婚的消息引爆社交媒体。该明星的个人信息页是系统的热点数据,平时 QPS 超过 10 万。运维团队设置了 5 分钟的缓存过期时间,凌晨 2:17,这个 key 恰好过期了。
接下来的 30 秒内,超过 50 万请求同时打到数据库——连接池瞬间耗尽,大量请求排队等待,数据库 CPU 飙到 100%,随后是级联的雪崩:其他依赖同一数据库的模块也开始超时、重试、再超时。整个平台的响应时间从 50ms 飙升到 30 秒以上,持续 12 分钟。
事后复盘发现,这不是缓存雪崩(不是大量 key 同时过期),而是缓存击穿——单个热点 key 过期引发的灾难。而更深层的教训是:缓存不是 "加了就行",穿透、击穿、雪崩三种问题的本质不同,解决方案也截然不同;分布式锁不是 "SET 一个 key 就行",原子性、过期时间、续期机制每一个环节都可能是陷阱。
核心概念
缓存穿透:查询"不存在"
缓存穿透是指查询一个不存在的数据,缓存无法命中(因为没存过),请求直接打到数据库。
读图导引:粉色节点展示了穿透的核心——缓存和数据库都不存在该数据,所以每次请求都会穿透到数据库。如果攻击者构造大量不存在的 id 发起请求,数据库会被压垮。
三种解决方案:
- 布隆过滤器:在缓存之前加一层概率型过滤器,快速判断 "该 key 一定不存在" 或 "可能存在"
- 空值缓存:将数据库返回的 NULL 也缓存起来(如设置 60 秒过期),后续相同查询直接命中缓存
- 接口校验:在入口处做参数合法性校验(如 id > 0),拦截明显非法的请求
布隆过滤器:用概率换空间
布隆过滤器(Bloom Filter)是一个位数组 + k 个哈希函数组成的数据结构。
读图导引:粉色节点是 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 在过期瞬间,大量并发请求同时打到数据库。
读图导引:粉色节点展示了击穿的爆发点——key 过期的瞬间,所有请求同时穿透到数据库。与穿透不同,击穿查询的是 "存在的 key",只是在过期窗口期被放大。
解决方案:
- 互斥锁(Mutex):缓存 miss 后,只允许一个线程查数据库并回写缓存,其他线程等待
- 逻辑过期(Logical TTL):不设置 Redis 的 TTL,而是在 value 里存一个逻辑过期时间,由后台线程异步更新
- 热点 key 预加载:在过期前主动刷新,避免过期窗口
缓存雪崩:级联崩塌
缓存雪崩是指大量 key 同时过期(如批量设置的固定 TTL),或者 Redis 集群宕机,导致数据库压力骤增。
读图导引:粉色节点展示了雪崩的级联效应——大量 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:每个客户端的唯一随机值,用于释放时验证(防止误删别人的锁)
读图导引:绿色节点是 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 算法,通过多独立节点上的多数派来解决:
读图导引:绿色节点是加锁成功——在 5 个独立 Redis 节点上尝试获取锁,至少 3 个(N/2+1)成功且总耗时小于 TTL,才算加锁成功。粉色节点是失败的节点——不需要全部成功,多数派即可。
Redlock 的步骤:
- 获取当前时间戳 T1
- 依次向 N 个独立 Redis 节点发送
SET key value NX EX TTL - 计算总耗时
elapsed = now - T1 - 如果成功节点数 >= N/2+1 且 elapsed < TTL,加锁成功
- 锁的实际有效时间 = TTL - elapsed
- 释放时向所有节点发送 DEL(包括未成功获取的节点,清除可能的残留)
Martin Kleppmann 的质疑:
分布式系统研究者 Martin Kleppmann 在《How to do distributed locking》中指出 Redlock 的安全隐患:
- 时钟漂移:如果某个 Redis 节点的时钟比其他节点快,它可能提前过期锁,导致另一个客户端在锁"应该"还存在时获取成功
- GC 停顿:客户端在获取锁后发生长时间 GC,锁已经过期但客户端以为还持有,继续操作会引发数据不一致
- 网络延迟:获取锁的请求在网络中被延迟,到达时锁已经过期
读图导引:粉色节点展示了 Redlock 的脆弱边界——时钟漂移破坏了 TTL 的语义。Redlock 假设所有节点的时钟是同步的,但在真实分布式环境中,NTP 同步误差可能达到数十毫秒甚至秒级。
暗面:Redlock 的争议至今未休。antirez 认为在大多数工程场景中 Redlock 足够安全;Kleppmann 认为如果无法保证时钟同步和 GC 停顿控制,应该使用基于共识的系统(如 ZooKeeper、etcd)来实现分布式锁。工程上的折中方案是:Redlock + 看门狗续期 + 业务幂等——锁的安全性由多层机制共同保证。
Redisson 看门狗:锁的自动续期
Redisson 是 Redis 的 Java 客户端,它实现的分布式锁有一个核心机制——看门狗(Watch Dog)自动续期。
读图导引:粉色节点是看门狗线程——加锁成功后启动,默认每隔 lockWatchdogTimeout / 3(即 30/3=10 秒)检查一次。绿色节点是续期——如果业务还在执行,看门狗自动将锁的 TTL 重置为 30 秒。蓝色节点是业务崩溃后的自动释放——如果进程挂了,看门狗不再续期,TTL 到期后锁自动释放,避免死锁。
Redisson 加锁的核心逻辑:
java
RLock lock = redisson.getLock("myLock");
lock.lock(); // 默认 TTL = 30s,启动看门狗
try {
// 业务逻辑,可能执行 60 秒
} finally {
lock.unlock(); // 释放锁,关闭看门狗
}
看门狗续期源码逻辑(简化):
lock()成功后,启动一个TimerTask,延迟watchdogTimeout / 3执行- 任务执行时,发送 Lua 脚本给 Redis:如果锁还被当前线程持有,则将 TTL 重置为
watchdogTimeout - 续期成功后,再次调度下一个
TimerTask unlock()时,取消所有未执行的续期任务,然后释放锁
手动指定 TTL vs 看门狗:
java
// 方式1:手动指定 TTL,不看门狗
lock.lock(10, TimeUnit.SECONDS); // 10 秒后自动过期,不续期
// 方式2:不指定 TTL,启用看门狗
lock.lock(); // 默认 30 秒,自动续期
暗面:看门狗也不是万无一失。如果 Redis 节点在续期请求发出后、响应返回前宕机,客户端可能以为续期成功但实际失败。Redisson 的处理是:续期操作是异步的,失败不会抛异常,锁将在 TTL 到期后释放——这意味着业务可能在中途失去锁。如果业务对锁的连续性要求极高,需要在业务层面做幂等或乐观锁校验。
原理分析
缓存三问题的本质差异
读图导引:三种问题的根本原因不同——穿透是 "数据不存在",击穿是 "单个热点过期",雪崩是 "批量失效"。解决方案也不同:穿透用布隆过滤器拦截,击穿用互斥锁或逻辑过期,雪崩用随机 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。
读图导引:粉色节点展示了布隆过滤器的空间权衡——比直接存储所有 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;
}
这个实现有几个关键点:
- 双重检查:获取锁后再次查缓存,防止等待期间其他线程已回写
- 锁的 TTL:即使获取锁的线程崩溃,TTL 到期后锁自动释放
- 递归重试:没拿到锁的线程 sleep 后重试,最终会从缓存命中
多级缓存的架构设计
读图导引:绿色节点是 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 到期后自动释放。业务代码应该:
- 在锁内定期检查
lock.isHeldByCurrentThread() - 如果锁已丢失,终止当前操作或重新竞争
- 关键业务配合数据库乐观锁(version 字段)做二次校验
Q5:多级缓存的一致性怎么保证?
经典的 "Cache-Aside" 模式:
- 读:先读缓存 → 未命中读 DB → 回写缓存
- 写:先写 DB → 删缓存(而不是更新缓存)
为什么写操作是 "删缓存" 而不是 "更新缓存"?因为并发写时,缓存更新的顺序可能与数据库不一致("写偏序"问题)。删除缓存让下一次读操作从 DB 加载最新值,虽然多一次 DB 查询,但保证了最终一致性。
总结
Redis 缓存实战与分布式锁的精髓,是在性能、一致性、可用性之间做精确的工程取舍:
- 缓存穿透:用布隆过滤器在缓存前拦截"不存在"的请求,或用空值缓存减少数据库压力。布隆过滤器的误判率由 m/n/k 三个参数精确控制,但无法删除和零误判是其理论边界
- 缓存击穿:用互斥锁保证只有一个线程查数据库并回写,其他线程等待或重试。锁的 TTL 必须设置,防止进程崩溃导致的死锁
- 缓存雪崩:用随机过期时间打散批量失效,用多级缓存(L1 本地 + L2 Redis)隔离数据库压力,用熔断降级保护核心链路
- SET NX EX:原子命令保证"判断+设值+过期"不可分割,释放锁必须用 Lua 脚本保证"判断value+删除"的原子性
- Redlock:多独立节点的多数派锁,解决单点故障问题,但受时钟漂移和 GC 停顿的制约。工程上配合看门狗续期和业务幂等使用
- Redisson 看门狗:后台线程自动续期,防止业务执行时间长于锁 TTL 导致的误释放。续期失败时锁自动释放,业务需做幂等或乐观锁校验
理解这些机制的关键,是追问:当锁在业务中途过期时,系统的行为是什么?当布隆过滤器的误判发生时,兜底策略是什么?当缓存雪崩触发熔断时,降级方案是否可接受? 从"知道怎么配置"到"懂得故障场景下的边界行为",是跨越中级和高级的分水岭。