Redis
Redis 缓存:穿透 / 击穿 / 雪崩
结合 catfun 项目实际代码,每个主题挑一个最合适的落点:community 帖子缓存链路(
PostAppService.getPost+PostCacheService)。 帖子是项目里 Redis 用量最大的业务,内容缓存(Caffeine + Redis)、统计数缓存(Hash + Lua)齐全,穿透、击穿、雪崩三个问题都在同一条链路上真实存在。 ✅ 已实现:缓存穿透两道闸已落地(PostCachePort新增 4 个方法,PostCacheService实现布隆过滤器 + 空值占位,PostAppService接入getPost/incrementViewCount/createPost)。布隆过滤器启动时异步全量回填历史帖子 ID(分页只查 id 列),多实例由分布式锁 + done 标志保证只回填一次:抢到锁的实例执行回填,其余实例轮询等待 done 标志,回填完成前查询放行;新帖通过createPost实时登记。
0. 帖子缓存链路现状
| 缓存项 | Key | TTL | 写入 | 说明 |
|---|---|---|---|---|
| 帖子内容 | post:content:{id} |
5min 固定 | getPost DB 回填 |
置顶帖走 Caffeine 本地缓存 |
| 统计数 | post:stats:{id}(Hash) |
1h 固定 | Lua 原子 init/increment |
like / comment / collect / view 四个计数 |
| 浏览计数 | 同上 | 同上 | Lua incrementViewCount |
事件驱动,不存在的 postId 白查 DB |
现状隐患:getPost 里 hasContent && hasCount 双命中才只读缓存,任一 miss 就查 DB 回填——无空值缓存、无互斥重建、TTL 固定,三个问题全占:
// getPost 双 miss → 查一次 DB,回填内容 + 统计两个缓存
if (!hasContent || !hasCount) {
// 查 DB(帖子不存在 → 返回空,不写任何缓存)
// 回填 post:content + post:stats
}
一句话:穿透是”没有”(key 不存在,一直打 DB),击穿是”单个过期”(热点 key 恰好过期瞬间并发全打 DB),雪崩是”批量过期”(大量 key 同时失效)。
1. 缓存穿透 —— 布隆过滤器预过滤 + 空值缓存
场景:getPost 对不存在的 postId 查完不缓存任何东西,下次同样请求继续打 DB。穿透请求有两种形态:
- 从未存在的 ID:帖子 ID 是雪花 ID(字符串、不可预测),恶意客户端可随机构造不存在的
postId高频打getPost/incrementViewCount,每次请求穿透到 DB;getPost内fillUserInfo还会带着不存在的 postId 远程打 user 服务——一个垃圾请求放大成两次 DB/下游查询。 - 曾经存在但已删除的 ID:帖子被逻辑删除(
deleted = 1)后,DB 查无,但 ID 形态合法,同样每次都打 DB。
方案:两道闸配合,一道挡”从未存在”,一道挡”已删除/漏网”(实现:PostCachePort + PostCacheService)。
- 第一道闸:布隆过滤器预过滤(挡”从未存在”的 ID)。Redisson
RBloomFilter,key=post:id:bloom,预计 10w 元素、0.1% 误判率(约 0.2MB,数据存 Redis 多实例共享)。查询入口maybeExists(postId)为 false → 一定不存在,直接短路,零 DB 开销;true 只代表”可能存在”,仍走缓存/DB 完整链路。启动时异步全量回填历史帖子 ID(分页只查 id 列),新帖在createPost成功后addPostId实时登记。 - 第二道闸:空值占位缓存(挡”逻辑删除 / 布隆误判”的 ID)。
getPostDB 查无 → 写post:null:{id}占位(TTL 60s);入口在布隆之后先查占位 key,命中直接返回空,防止合法形态但已删除的 ID 反复打 DB。
为什么这里最合适:穿透的本质是”查了缓存和 DB 都没有,还不缓存这个没有”。帖子是公开可枚举探测的目标(ID 随机、量大、删除频繁),穿透收益最大。两道闸职责互补——布隆过滤器用极小内存(0.2MB)挡随机不存在 ID,空值缓存挡”曾经存在/已删除”的合法形态;TTL 60s 的占位成本极低。
面试核心内容
以下坑多数是 catfun 实现过程中真实踩过/修复过的(标注 🔴 的优先讲)。
布隆过滤器原理:本质是一段很长的位数组(每位 0/1)+ K 个哈希函数,专门回答”这个元素在不在”:
- 写入
add:元素过 K 个哈希函数,得到 K 个位置,全部置 1 - 查询
contains:元素过同样的 K 个哈希函数,检查这 K 个位置——- 有任意一位为 0 → 一定不存在(自己写入时这些位一定全 1,不可能是 0)
- 全部为 1 → 可能存在(也可能是其他元素把位占了,即哈希碰撞)
- 省内存的原因:不存元素本身,只存”指纹”(K 个位),10w 元素仅约 0.2MB
- 代价:有误判(碰撞),但一定不漏报(不在 → 100% false);且不能删元素(无法把某位清 0,可能被别的元素共享)
两道闸原理:
| 第一道闸:布隆过滤器 | 第二道闸:空值缓存 | |
|---|---|---|
| 挡什么 | 从未存在的 ID(攻击主力) | 逻辑删除 / 布隆误判的 ID |
| 原理 | 位数组 + K 个哈希函数,false 一定不存在 |
DB 查无 → 写 post:null:{id} 短 TTL 占位 |
| 成本 | 10w 元素 0.1% 误判率约 0.2MB | 60s TTL,极低 |
| 特性 | 一定不漏报、可能误报 | 防”曾经存在现在没了”的合法形态 |
布隆误判的 ID 是合法存量 ID,maybeExists=true 不能短路,必须走完整链路(缓存→DB),由空值缓存 + DB 兜底。
核心坑(按重要性排序):
-
🔴 布隆不能”只初始化不填数据”:
tryInit只分配位数组。历史数据不回填 → 所有存量 ID 被maybeExists直接短路返回空,上线即事故。本项目因此给仓储加了”只查 id 列”的轻量方法做全量回填。 -
🔴 回填完成前必须放行(
bloomReady标志):只有确认布隆已完整回填(本实例回填成功 或 检测到其他实例的 done 标志)才启用过滤;失败/超时/中断一律保持false(查询放行)。部分填充的布隆比无防护更危险:无防护只是”没过滤”,部分填充会把存量帖误判短路。 - 🔴 多实例协调:锁 + done 标志 + 接替机制:
- 分布式锁只让一个实例回填;done 标志其余实例轮询到即启用
- 接替:等待期间锁释放(执行者崩溃/失败)→ 抢锁接替回填,防”本轮回填无人完成”
- 约束:
waitTimeout(600s)> 锁leaseTime(300s),否则等待方在锁释放前就超时退出,白白放弃
-
锁必须带 leaseTime 防死锁:回填线程崩溃,锁自动释放,不阻塞其他实例。
-
布隆不能删元素:逻辑删除的 ID 永远留在布隆里 → 必须靠空值缓存兜底。这正是空值缓存 TTL 设 60s(短)而非永久的根本原因。延伸:计数布隆(空间大)、布谷鸟过滤器(支持删除)。
-
新数据必须同步
add:布隆是存量快照,新增数据不登记会被短路 → 新帖查询查不到(本项目createPost成功后回填)。 -
maybeExists=false才能短路:true只是”可能存在”,仍要走完整链路。 -
expected 容量是硬约束:位数组容量固定,实际元素超出后误判率急剧失控(非线性劣化),容量规划要留余量。
-
空值 TTL 两难:过长 → 真实数据恢复后同 key 读不到;过短 → 防不住高频攻击。60s 量级是常见平衡点。
- fpp 不要设太低:误判率减半 → 位数组约增 20% 且哈希次数变多,单次检查更慢;可接受的误判率 + 空值兜底才是正解。
追问延伸:
- “误判了怎么办” → 空值缓存兜底:误判的存量 ID 走 DB 查到后回填正常缓存
- “怎么删元素” → 布隆不支持,计数布隆 / 布谷鸟过滤器
- “分布式怎么用” → Redis 布隆(Redisson
RBloomFilter),数据存 Redis 多实例共享 -
“和 Redis Bitmap 区别” → Bitmap 精确集合,元素多时内存线性膨胀;布隆用概率换空间
2. 缓存击穿 —— 热帖内容缓存”互斥重建”
场景:置顶帖、爆款帖被集中访问,post:content:{id}(TTL 5min)过期瞬间,N 个并发请求同时 hasContent=false → 同时查 DB 回填(无互斥),DB 瞬间多 N 倍查询。统计数缓存虽已有 Lua 原子初始化,但内容缓存的重建没有锁。
方案:利用 RedisService.setIfAbsent(SETNX)做重建锁(key=post:rebuild:{id},TTL 3s)——只有一个请求真正查 DB 回填,其余请求短暂等待后重读缓存(200ms × 5 次 ≈ 1s),仍未命中降级查 DB 兜底防死锁(实现:PostCachePort.tryAcquireRebuildLock/releaseRebuildLock + PostAppService 的 readFromCache / rebuildFromDb / waitAndRetryCache)。
为什么这里最合适:击穿只发生在”单个热点 key 过期瞬间“,帖子链路里置顶帖/爆款帖的集中访问是项目最典型的热点形态(内容缓存 TTL 5min、访问量远超商户/商品)。setIfAbsent 已在 RedisService 就位(Redis 不可用时降级返回 true 放行),无需新依赖。
面试核心内容
以下坑多数是 catfun 实现过程中真实踩过/修复过的(标注 🔴 的优先讲)。
互斥重建原理:缓存 miss 时不让所有请求都查 DB,用重建锁只放一个请求查 DB 回填,其余请求等待后重读缓存。本项目用互斥重建锁:简单、可预期,热帖访问量大但重建频率低(5min 一次),等待 1s 内可接受。
核心坑(按重要性排序):
- 🔴 锁必须有 TTL 防死锁:重建线程崩溃/卡住时锁要自动释放,否则所有请求永久等待。本项目 TTL 3s(一次 DB 查询 + 回填远小于此)。
- 🔴 等待方必须有兜底,不能无限等:等待重试(约 1s)仍未命中说明重建线程失败/锁已释放 → 直接查 DB 兜底保证可用性(DB 压力已被锁挡住绝大部分)。
- 🔴 锁内复查,避免重复重建:抢到锁后先重查缓存是否已被别人重建完成,避免”锁刚释放又重查 DB”的重复浪费。
- 锁粒度按 key(帖子粒度),不能全局一把锁:全局锁会让所有帖子重建互相阻塞;
post:rebuild:{postId}每个帖子独立。 - finally 必须释放锁:重建成功/失败/异常都要释放,否则锁残留到 TTL 过期,期间其他请求只能走降级路径。
- 锁持有时间要短:锁内只做 DB 查询 + 缓存回填,不要在锁内做慢操作(如远程调用户服务)——锁内耗时直接决定等待者的阻塞时长。
- 🔴 SETNX 必须带过期时间且原子:
SETNX + EXPIRE两步不是原子的,实例崩溃会留死锁;必须用SET key val NX EX ttl一条命令(本项目setIfAbsent内置)。 - 与”空值缓存”区分:击穿锁是防重建风暴(key 存在但过期),空值缓存是防穿透(key 不存在);两个都用了但职责不同。
- 适用边界:击穿防护只在”并发读 + 缓存重建“场景有意义;写多场景优先考虑直接失效 + 懒加载,锁反而拖慢写入侧。
追问延伸:
- “锁没抢到怎么办” → 等待后重读缓存(本项目 200ms × 5 次);极端情况降级查 DB
- “重建期间用户读到什么” → 有旧值读旧值(置顶帖 Caffeine 本地缓存兜底);无旧值等重建完成,等不到就查 DB 兜底
- “为什么不直接用 Redisson 分布式锁” → 重建锁是短生命周期(3s)、非重入场景,SETNX 足够;Redisson 锁(看门狗、可重入)适合长任务(如布隆回填)
- “和逻辑过期比呢” → 逻辑过期不阻塞但实现复杂(异步重建 + 双写一致);互斥重建简单、代价是等待者阻塞 1s 内
- “多副本部署怎么办” → SETNX 天然跨实例互斥(Redis 是公共点),前提是锁 key 在同一 Redis
3. 缓存雪崩 —— 统计数缓存 TTL 随机抖动
场景:post:stats:{id} 的 TTL 原本固定 1h(随机抖动实现前)。高峰期(如午间浏览潮)大量帖子被首次访问,统计缓存被 Lua 集中初始化、写入时间相近 → 1 小时后这批 key 几乎同时过期 → 全部帖子同时触发 getPost 的 DB 回填 → DB 被打垮。
方案(已实现):统计数缓存的每次写入(初始化 + 每次增减续期)TTL 都取 1h + 0~10min 随机值(PostCacheService.statsTtlWithJitter(),覆盖 initCountHashIfAbsent / incrementCount / decrementCount / incrementCountWithInit / decrementCountWithInit),把过期时间点打散:
- 冷 key:批量初始化(Lua 集中写入)后各 key 过期点随机错开,不会同时过期
- 活跃 key:每次浏览/点赞/评论/收藏时续期且续期也带随机偏移——过期点随”最后活跃时间”滚动错峰,与随机抖动双保险
(内容缓存 TTL 暂未加抖动,如需可后续按 5min ± 30s 同步。)
为什么这里最合适:雪崩是”大量 key 同一时刻失效“,post:stats 固定 TTL + 高峰集中初始化是项目里唯一成规模的批量缓存;而商户、商品等列表尚未缓存,没有此风险。若 Redis 整体宕机,RedisService 已自带降级(get 失败返回空 → 走 DB),可再叠加 Caffeine 本地兜底(内容缓存已有,统计数可补)与 DB 限流,属兜底防线。
面试核心内容与坑
以下坑多数是 catfun 实现过程中真实踩过/修复过的(标注 🔴 的优先讲)。
随机抖动原理:给 TTL 加随机偏移,让过期时间点分散。固定 TTL 是”确定性同时过期”;1h ± 10min 随机后,批量初始化的 key 过期点均匀散开,同一时刻过期的 key 数量大幅下降。另一个配套机制是滚动续期:活跃 key 每次操作重置 TTL,过期点被推到”最后活跃 + TTL”,天然错峰。
核心坑(按重要性排序):
- 🔴 抖动必须覆盖”所有写入 TTL 的地方”,不只初始化:只给初始化加抖动、续期仍用固定 TTL 的话,活跃 key 续期后可能重新对齐到同一过期点(如午间潮集中浏览 → TTL 被统一重置到相近时刻)。本项目初始化 + 5 个增减方法全部带抖动。
- 🔴 随机值每次写入都要重新生成,不能复用:如果抖动值在应用启动时算一次、所有 key 共用同一偏移,等于没抖动——所有 key 还是同时过期。
- 抖动范围不能太小也不能太大:太小(±1min)错峰不明显;太大(±1h)让部分 key 过早过期 → 命中率下降、DB 压力上升。1h ± 10min(约 ±16%)是常见取值。
- 滚动续期救不了”从未被操作的冷 key”:续期只对活跃 key 生效;冷 key 从初始化到过期没有任何操作,防雪崩只能靠抖动本身(这也是必须做抖动的原因)。
- 错峰只能分散,不能消除 DB 总峰值:所有 key 最终仍在 1h~1h10min 内陆续过期,总回填量不变。Redis 宕机/极端雪崩要靠
RedisService降级 + Caffeine 本地兜底 + DB 限流,抖动不是最后防线。 - 🔴 Lua 脚本里的
EXPIRE是”每次执行都续期”:incrementCountWithInit这类脚本每次调用都执行EXPIRE(即使 key 已存在),不是”只在初始化时设置一次”——实现续期时必须意识到这一点。 - 非 Lua 的
HINCRBY + EXPIRE两步非原子:中间崩溃会漏掉续期(后果仅是提前过期,可接受);需要严格原子时并入 Lua。 - 与击穿/穿透是组合拳:雪崩错峰后,单个 key 过期瞬间仍可能触发击穿(重建风暴)——所以内容缓存还要互斥重建锁、不存在的数据还要空值缓存;三个方案互相补充,不是互斥选择。
追问延伸:
- “抖动具体怎么算” →
base + rand(0, range),本项目 3600 + 0~600s;每次写入重新随机 - “为什么不干脆永不过期 + 懒加载” → 统计数是热点读,永不过期需要 DB 变更时主动失效,一致性复杂度上升;抖动是低成本折中
- “多实例部署,随机值会不会重复” → 无所谓:抖动只需”错开”不要求唯一;重复概率低且影响有限
- “抖动和 Redis 集群分片有关吗” → 无关,抖动解决的是”时间维度集中”,与分片/扩缩容无关
- “DB 限流兜底怎么做” → 应用层信号量/连接池限流 + 拒绝策略;或 DB 侧连接数/慢查询保护
小结:三个问题,四道手段
| 问题 | 手段 | 落点 | 关键实现 |
|---|---|---|---|
| 缓存穿透 | 布隆过滤器预过滤 | getPost / incrementViewCount 入口 |
Redisson RBloomFilter(0.1% 误判) |
| 缓存穿透 | 空值占位缓存 | getPost DB 查无 |
post:null:{id},TTL 60s |
| 缓存击穿 | 互斥重建 | 热帖 post:content:{id} 过期瞬间 |
setIfAbsent 重建锁 |
| 缓存雪崩 | TTL 随机抖动 | post:stats:{id} 批量过期 |
1h ± 10min |