Lesson 64 · 数据库与中间件实战
缓存设计:穿透、击穿、雪崩与一致性方案
缓存设计:穿透、击穿、雪崩与一致性方案
缓存穿透用布隆过滤器、击穿用互斥锁、雪崩用过期时间随机化。双写一致性:先删缓存还是先更新数据库?延迟双删怎么做?
缓存穿透——查一个根本不存在的数据
缓存穿透:客户端请求的 key 在缓存和数据库中都不存在,每次都要穿透到数据库。如果被恶意利用(大量请求不存在的 key),数据库会被打垮。
public User getUser(Long id) { String key = "user:" + id; String cached = redis.get(key); // 注意区分"key 不存在"和"key 存在但值为空" if (cached != null) { if ("NULL".equals(cached)) return null; // 之前缓存的空值 return JSON.parseObject(cached, User.class); } User user = db.selectById(id); if (user != null) { redis.set(key, JSON.toJSONString(user), 30, MINUTES); } else { // 缓存空值,设置较短的 TTL redis.set(key, "NULL", 5, MINUTES); } return user; }
缓存空值有什么缺点?什么时候该用布隆过滤器?
缓存空值的问题是:如果攻击者用大量不同的不存在的 key 来请求,Redis 里会存很多"NULL"值(虽然 TTL 短,但数量大时仍然占内存)。布隆过滤器更适合这种场景——它在 Redis 之前加一层,用极小的内存(位数组)判断 key 是否可能存在。判断"不存在"是 100% 准确的,可以直接拦截。
布隆过滤器 = 位数组(bit array)+ 多个哈希函数 写入:对 key 用 k 个哈希函数得到 k 个位置,全部置 1 BF.add("user:1001") → hash1("user:1001") = 3, hash2("user:1001") = 17, hash3("user:1001") = 42 → bitArray[3] = 1, bitArray[17] = 1, bitArray[42] = 1 查询:检查 k 个位置是否全为 1 BF.mightContain("user:9999") → hash1 = 5, hash2 = 23, hash3 = 42 → bitArray[5] = 0 → 肯定不存在!直接返回 null 特点:可能误判"存在"(false positive),但不会误判"不存在" Redis 有 RedisBloom 模块,也可用 Redisson 的 RBloomFilter
穿透 = 查不存在的数据。简单场景用缓存空值(TTL 2~5 分钟),高风险场景用布隆过滤器前置拦截。两者可组合使用。
缓存击穿——热点 key 过期的瞬间
缓存击穿:某个热点 key 突然过期,大量并发请求同时发现缓存 miss,全部涌向数据库,瞬间压垮 DB。
public User getUser(Long id) { String key = "user:" + id; String cached = redis.get(key); if (cached != null) return parse(cached); // 尝试获取分布式锁(SET NX EX) String lockKey = "lock:" + key; boolean locked = redis.set(lockKey, "1", NX, 10, SECONDS); if (locked) { try { // 双重检查:可能别的线程已经重建了缓存 cached = redis.get(key); if (cached != null) return parse(cached); User user = db.selectById(id); redis.set(key, toJSON(user), 30, MINUTES); return user; } finally { redis.del(lockKey); // 释放锁 } } else { // 没拿到锁 → 短暂等待后重试 Thread.sleep(50); return getUser(id); // 递归重试 } }
// 缓存值中嵌入过期时间 class CacheData<T> { T data; long expireTime; // 逻辑过期时间戳 } // 查询时发现逻辑过期 → 异步线程重建缓存,当前线程返回旧数据 if (System.currentTimeMillis() > cacheData.expireTime) { // 提交异步任务重建缓存(用线程池 / 协程) executor.submit(() -> rebuildCache(key, id)); } return cacheData.data; // 返回旧数据(可接受短暂过期)
| 方案 | 优点 | 缺点 |
|---|---|---|
| 互斥锁 | 保证一致性,不会返回旧数据 | 没拿到锁的线程要等待/重试,有延迟 |
| 逻辑过期 | 无阻塞,性能好 | 会返回过期数据,一致性弱;需要额外内存存过期时间 |
缓存雪崩——大面积 key 同时失效
缓存雪崩:大量缓存 key 在同一时间集中过期(或 Redis 宕机),所有请求都打到数据库。
方案 1:过期时间随机化 // 基础 TTL + 随机偏移量 → 避免集中过期 int baseTTL = 30 * 60; // 30 分钟 int randomOffset = ThreadLocalRandom.current().nextInt(60, 300); // 1~5 分钟随机 redis.set(key, value, baseTTL + randomOffset); 方案 2:多级缓存 // L1: 本地缓存(Caffeine/Guava) → L2: Redis 分布式缓存 // 即使 Redis 宕机,本地缓存还能扛一段时间 方案 3:限流 + 降级 // 缓存大面积失效时,对 DB 查询做限流(令牌桶 / 信号量) // 超出阈值的请求直接降级返回默认值或友好提示 方案 4:集群 + 高可用 // Redis Cluster / Sentinel 避免单点故障导致全部缓存失效
雪崩的根因是"集中失效"或"缓存不可用"。核心解法:① 随机 TTL 打散过期时间;② 多级缓存(本地 + Redis)兜底;③ 限流降级保护 DB;④ 高可用集群防宕机。
双写一致性——先删缓存还是先更新数据库?
这是面试中最高频的缓存一致性问题。核心矛盾:缓存和数据库是两个独立的数据源,无法做到原子性地同时更新。
方案 ③ "先更新DB再删缓存"真的万无一失吗?
有一个极端场景:缓存刚好过期 → 读请求查DB得到旧值 → 写请求更新DB并删缓存 → 读请求把旧值写入缓存。但这个场景要求"缓存恰好失效 + 读DB比写DB还慢",概率极低。如果对一致性要求极高,可以用延迟双删或消息队列重试删缓存。
延迟双删与最终一致性方案
public void updateUser(Long id, User user) { String key = "user:" + id; // 第一次删除缓存 redis.del(key); // 更新数据库 db.updateById(user); // 延迟第二次删除(异步) // 延迟时间 >= 读请求的耗时(通常 500ms ~ 1s) executor.schedule(() -> { redis.del(key); }, 1000, MILLISECONDS); }
方案:Canal 监听 binlog + MQ 异步删缓存 1. 业务代码正常更新 DB(无需关心缓存) 2. Canal(阿里开源)订阅 MySQL binlog 3. 解析出变更的表和 key → 发送 MQ 消息 4. 消费者收到消息 → 删除对应的 Redis key 5. 如果删除失败 → MQ 自动重试(保证最终一致) 优点: - 业务代码与缓存解耦,不需要手动维护一致性 - MQ 重试机制保证删除一定成功 - 适合中大型项目
"我们用的是 Cache Aside 模式:先更新DB再删缓存。对于一致性要求更高的场景,用延迟双删或者 Canal 监听 binlog 异步删缓存,配合 MQ 重试机制保证最终一致性。"
多级缓存架构——L1 本地 + L2 分布式
在高并发场景下,仅靠 Redis 可能成为瓶颈(网络开销、序列化开销)。多级缓存在应用进程内加一层本地缓存,进一步减少 Redis 的压力。
问题:多个应用实例各自有 L1 本地缓存,更新后如何同步? 方案:Redis Pub/Sub 广播 + MQ 1. 更新 DB 后,发送消息到 MQ(如 RocketMQ) 2. 所有应用实例订阅该 topic 3. 收到消息后,清除本地 L1 缓存中对应的 key 4. 下次读请求会重新从 L2/DB 加载 也可以用 Redis Pub/Sub: PUBLISH cache:invalidate "user:1001" 所有订阅者收到后清除本地缓存
缓存监控与运维最佳实践
| 监控指标 | 含义 | 告警阈值参考 |
|---|---|---|
| 命中率 (hit rate) | 缓存命中数 / 总请求数 | < 90% 需关注 |
| 内存使用率 | used_memory / maxmemory | > 80% 告警 |
| 驱逐数 (evicted_keys) | 因内存满而被淘汰的 key 数 | 持续增长需关注 |
| 连接数 | 当前客户端连接数 | 接近 maxclients 告警 |
| 慢查询 | SLOWLOG 中 O(N) 命令 | KEYS * / 大 key 操作 |
| 延迟 | PING 响应时间 | > 5ms 需关注 |
# 内存 maxmemory 8gb maxmemory-policy volatile-lru # 优先淘汰设了 TTL 的 key # 持久化 appendonly yes aof-use-rdb-preamble yes save 900 1 # 安全 rename-command FLUSHALL "" # 禁用危险命令 rename-command KEYS "" requirepass your_strong_password # 性能 tcp-backlog 2048 timeout 300 # 空闲连接超时 hz 50 # 事件循环频率(默认 10)
缓存三板斧:穿透(布隆过滤器/空值缓存)、击穿(互斥锁/逻辑过期)、雪崩(随机TTL/多级缓存/限流降级)。一致性:Cache Aside + 延迟双删或 Canal + MQ。这套组合拳打出去,缓存设计的问题基本就覆盖全了。
全篇回顾
- 缓存穿透:查不存在的数据 → 布隆过滤器 / 缓存空值
- 缓存击穿:热点 key 过期 → 互斥锁 / 逻辑过期
- 缓存雪崩:大面积 key 同时过期 → 随机 TTL / 多级缓存 / 限流
- 双写一致性:推荐 Cache Aside(先更新DB再删缓存)
- 延迟双删:删缓存 → 更新DB → sleep → 再删缓存
- 最终一致性:Canal 监听 binlog + MQ 异步删缓存
- 多级缓存:L1 本地(Caffeine)+ L2 Redis,广播清除 L1