第二章 缓存与 Redis 场景(第23-50题)
23. 加了缓存后数据库反而更频繁被打挂(缓存三大问题辨析)
【考察内容】缓存三兄弟是后端面试出现率最高的缓存题,几乎必考
【题目】你们给数据库加了缓存,但线上还是出现了几次数据库被打挂的事故:一次是大量查询根本不存在的 ID 直接打到数据库;一次是某个爆款 key 刚过期,瞬间请求全部穿透;还有一次是凌晨大量 key 一起过期,数据库瞬间崩了。请区分这三种情况,并分别给出解法。
【参考答案】缓存加了反而更打挂 DB,先辨析三大根因再治:
- 容量估算(步进):假设 DB 读容量 1 万 QPS,业务峰值 5 万 QPS,缓存命中率若 95% 则回源 = 5万×5% = 2500 QPS,安全;若命中率掉到 70%,回源 = 5万×30% = 1.5 万 QPS,直接打穿 DB。命中率从 95%→70%,DB 压力放大约 6 倍。
- 三大问题辨析:① 穿透:查询的数据在缓存和 DB 都不存在,每次打 DB——解:空值缓存、布隆过滤器、参数校验;② 击穿:热点 key 过期瞬间,大量请求同时回源——解:互斥重建、逻辑过期、热点永不过期;③ 雪崩:大批 key 同时过期或缓存集群整体故障——解:TTL 加随机打散、集群高可用、限流+熔断。
- 其他真凶:缓存与 DB 一致性双删/订阅风暴导致重建;大 key 导致节点异常;代码 bug 未走缓存;热点 key 单节点打满。
- 失败与降级:回源风暴时对 DB 入口限流、返回兜底数据/排队;本地缓存紧急开启短 TTL;监控命中率、DB QPS、RT,命中率跌破 90% 立即告警、跌破 80% 必须排查。
- 口径:缓存不是“加了就快”,而是有命中率前提的流量转化器;命中率崩了,缓存层反而放大回源。
【原理溯源】
- 三者本质是「缓存失效后流量去哪」的三种不同失效形态:穿透 = key 本就不该存在,缓存对它永远无效,每一次请求都是「白走一趟再打 DB」;击穿 = 单个热点 key 在某一瞬间失效,同一时刻的并发请求全部在缓存侧「撞空」;雪崩 = 成批 key 在同一时刻失效,或缓存整体不可用,失效规模从「一个」放大到「一片」。判断口诀:看「失效的是谁、失效了多少、数据该不该存在」。
- 为什么缓存永远打不中就等于没有缓存? 缓存的收益来自命中率:
DB QPS ≈ 业务 QPS × (1 - 命中率)。穿透场景命中率恒为 0,DB 完全裸奔;且因为「每次都是 miss」,攻击者可以用很低成本制造持续高压。 - 为什么空值缓存有效? 它把「不存在」也变成一种可缓存的结果:首次 miss 仍会打 DB,但后续相同 key 在 TTL 内直接命中 null,把重复穿透压掉。代价是:①多占一点内存;②如果该 key 随后真的被创建,TTL 内会短暂读到空——所以 TTL 必须短(通常 30s–5min)。
- 为什么布隆过滤器适合防穿透? 它在 DB 之前用极小空间回答「key 是否可能存在」:说「不存在」一定正确(可直接拦截),说「存在」可能误判(多放一次)。穿透攻击大量使用「保证不存在的 ID」,正中布隆过滤器「一定不存在」的强项。代价是标准版不可删、有误判率,需要预估容量。
- 为什么击穿必须「只让一个请求回源」? 击穿的杀伤力来自 N 并发同时回源。互斥锁(SETNX)把 N 次回源压成 1 次 + N-1 次等待;逻辑过期把「同步重建」改成「先返回旧值 + 异步重建」,彻底消除同步等待。两者的本质都是:消灭「同一 key 的并发回源」,只是在一致性和延迟上取舍不同。
- 为什么雪崩要打散 TTL 而不是只靠高可用? 打散 TTL 解决「批量过期」这一诱因;高可用解决「缓存整体宕机」这一更严重的诱因。两者是不同故障模式,不能互相替代。多级缓存(本地 + Redis)是在 Redis 整体不可用时的第二道隔离墙。
【选型判断树】
数据库被缓存流量打挂,先问三个问题:
1) 查的数据是否存在?
2) 失效的是单个 key 还是一批?
3) 缓存本身还在吗?
数据根本不存在(ID 非法/已删除/恶意遍历)
→ 穿透:参数校验 → 空值缓存 → 布隆过滤器 → 限流
数据存在,且是单个热点 key 过期瞬间
→ 击穿:强一致 → 互斥锁(Redisson)
→ 高性能可容忍旧值 → 逻辑过期
→ 极热且可后台更新 → 永不过期 + 异步刷新
大量 key 同时过期 / Redis 宕机
→ 雪崩:TTL 加随机 → 集群/哨兵 → 本地缓存兜底 → DB 限流降级【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:40 | 定性 | 「这题本质是三种缓存失效形态:穿透、击穿、雪崩。先区分,再对症」 |
| 0:40–2:30 | 三兄弟辨析 | 每种:定义一句 → 失效机理一句 → 主解法两条(穿透/空值+布隆;击穿/互斥锁+逻辑过期;雪崩/TTL随机+高可用) |
| 2:30–3:40 | 为什么有效 | 空值把「不存在」变成可缓存结果;互斥锁把 N 次回源压成 1 次;TTL 随机打散过期时刻 |
| 3:40–4:30 | 兜底与边界 | 布隆误判只多放行不误杀;击穿/雪崩都还要 DB 限流;Redis 宕机靠本地缓存+降级 |
| 4:30–5:00 | 收尾 | 「一句话:先分清失效的是不存在的数据、单个热点、还是成批 key,再分别用拦截、单飞、打散+高可用」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 空值缓存 TTL | 30s – 5min | 太长会挡住新数据上线 |
| 布隆过滤器误判率 | 常配 0.1%–1% | 空间与误判率的权衡 |
| 亿级 key 布隆空间 | 1 亿元素:误判 1%→约 120MB、0.1%→180MB、0.01%→240MB(m=−n·ln p/(ln2)²,每元素约 9.6~19 bit) | 空间极省,1GB 是冗余上限口径 |
| 逻辑过期异步重建 | 通常 1 个后台线程池 | 防止重建风暴 |
| TTL 随机打散幅度 | base + random(0, 300s) | 大促类可加大 |
| DB 保护线 | 连接池使用率 > 80% 开始限流 | 缓存失效时的最后防线 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「击穿和雪崩到底怎么一句话区分?」 → 击穿是单个热点 key 在过期瞬间被高并发同时 miss;雪崩是成批 key 同时失效或缓存整体不可用。一个是「点」,一个是「面」。
L2|「布隆过滤器说 key 存在,就一定存在吗?误判了怎么办?」 → 不一定。布隆过滤器「一定不存在」是对的,「可能存在」可能误判。误判的后果是多穿透一次到 DB(查无结果),不会返回错误数据。工程上再叠一层空值缓存兜住误判后的重复穿透。
L3|「已经雪崩了,Redis 没挂,DB 快扛不住了,你先做什么?」 → 先止血再根治:①网关/应用层对回源加限流与熔断,保 DB 不死;②紧急对热点 key 设物理不过期或本地缓存预热;③观察是否是 TTL 批量到期——是则临时延长一批热点 key 的 TTL;④事后根治:TTL 随机化、多级缓存、预热。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出穿透/击穿/雪崩三个名词及大致区别 |
| 80 分 | 三种情况各自给出 2 条以上解法,并说清「不存在/单热点/批量」的判定 |
| 95 分 | 讲清空值与布隆各自的原理与代价;击穿时能对比互斥锁 vs 逻辑过期;主动区分雪崩的两种诱因(批量过期 vs 缓存宕机)并给出对应防线 |
【关联题】
- 同一知识簇(缓存三防深挖): 第 24 题(穿透)→ 第 25 题(击穿)→ 第 26 题(雪崩)→ 第 44 题(布隆误判与删除)→ 第 50 题(组件内置三防)
- 一致性侧: 第 27 题(缓存一致性)、第 38 题(更新顺序)
- 容量与可用性: 第 34 题(过期与淘汰)、第 35 题(集群高可用)、第 47 题(预热)、第 48 题(内存治理)
- 上游基础: 第 32 题(单线程为什么快)——理解「慢命令/大 key 阻塞」的前提
【自测】
- 判断对错:缓存击穿和缓存雪崩是一回事,只是严重程度不同。 参考答案:错。击穿是单个热点 key 过期瞬间;雪崩是大量 key 同时过期或缓存整体不可用。诱因和解法都不同。
- 用户恶意刷一批肯定不存在的商品 ID,你会优先上哪三层防护?每层挡什么? 参考答案:①参数校验挡明显非法 ID;②布隆过滤器在 DB 前拦截「一定不存在」;③空值缓存挡住误判/漏网后的重复穿透;再叠限流保 DB。
- 逻辑过期方案里,如果异步重建线程池满了会怎样?怎么防? 参考答案:重建任务被拒绝或积压,旧值会一直被返回直到重建成功。需限制并发重建数、对重建失败重试,并监控「逻辑过期但未重建」的比例。
24. 大量查询不存在的商品 ID,每次都打到数据库(缓存穿透)
【考察内容】缓存穿透防护与算法原理
【题目】线上发现:大量请求查询不存在的商品 ID(可能是恶意遍历,也可能数据已删),缓存永远不命中,每次请求都穿透到数据库,DB 压力暴涨。你如何解决?如果引入布隆过滤器,讲讲它的原理和适用场景。
【参考答案】缓存穿透:查不存在的数据,缓存永远不命中,每次打 DB:
- 容量估算(步进):假设攻击/爬虫按不存在 ID 洪水查询,峰值 2 万 QPS,DB 只能扛 5000 → 超额 1.5 万 QPS 会把连接池打满,连带正常用户超时。目标:不存在请求在缓存/过滤层被挡住,回源接近 0 或仅首次。
- 方案:① 缓存空值:查询为空时写
null/空标记,TTL 30s–5min(防短时重复穿透);② 布隆过滤器:预加载全量合法 ID,请求先过滤,拦截绝大多数不存在 ID(有误判,需配合空值);③ 参数校验:非法 ID 格式直接拒绝;④ 入口限流与黑名单。 - 适用:空值缓存实现简单,适合常规业务;布隆适合 ID 空间大、不存在比例高的场景。
- 失败与降级:布隆误判放行少量不存在 ID → 空值缓存兜底;布隆内存不足 → 分片或改空值为主;Redis 挂 → 本地布隆/全局限流;DB 已被压住 → 该接口降级限流。
- 监控:空值缓存命中量、布隆拦截率、DB 中“查空”SQL 占比。
【原理溯源】
- 为什么叫「穿透」而不是「击穿」? 关键不在流量大小,而在数据是否存在。不存在的 key 永远不会被业务成功回填成「有效缓存」(除非你显式缓存空值),所以缓存层对这类请求的命中率恒为 0——流量「穿透」缓存直达 DB。如果只是「忘了回填」,那是工程 bug,不是穿透。
- 为什么空值缓存有效,代价是什么? 把查询结果 null 也当成一种缓存值写入。因果链:miss → 查 DB 无果 → 写
key → NULL, TTL 短→ 后续同 key 在 TTL 内直接命中,不再打 DB。代价:①占用少量内存;②新数据创建后 TTL 内可能读到旧空值——所以 TTL 必须短,并在「数据真正创建」时主动删空值缓存。 - 布隆过滤器的因果链: 位数组初始全 0 → 插入 key 时对其做 k 个独立哈希,把对应 k 个位置 1 → 查询时同样算 k 个下标,只要有一个 0,key 一定未插入过(哈希确定性保证);若全为 1,可能是真插入过,也可能是其他 key 碰巧把这 k 位都置 1 → 这就是「假阳性」误判。误判率随位数组变满而上升,所以必须按目标容量预估 m 和 k。
- 为什么标准布隆不能删? 置 1 的位可能被多个 key 共享。把某一位清 0 可能误删其他 key 的存在证据,导致「假阴性」——把存在的 key 判成不存在,这比假阳性危险得多(会直接漏过合法请求)。因此要么不可删,要么用计数布隆/布谷鸟过滤器。
- 为什么穿透场景优先用布隆而不是无限扩容 DB? 穿透流量的特征是「key 空间极大且大量不存在」,在 DB 侧无论怎么扩都扛不住恶意遍历;布隆在接入层用 O(k) 且几乎零网络开销把绝大部分非法 key 拦掉,是成本最低的结构性防护。
【选型判断树】
大量 miss 打到 DB:
├─ key 明显非法(格式错、负 ID、越权 ID)
│ → 参数校验直接拒绝(最便宜,优先做)
├─ key 合法但业务上可能不存在(商品下架/未创建)
│ ├─ 不存在比例高、key 总量可控(千万级)
│ │ → 空值缓存(实现简单,立刻见效)
│ ├─ 不存在请求量大、key 空间大(亿级/恶意遍历)
│ │ → 布隆过滤器(前置拦截)+ 空值缓存兜底误判
│ └─ 数据会删除/更新频繁
│ → 布隆需定期重建 或 换布谷鸟过滤器;空值 TTL 更要短
└─ 以上都做了仍有洪峰
→ 网关限流 + DB 熔断判断口诀: 先拦截明显非法,再缓存「不存在」,量大上布隆,最后限流兜底。
【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「穿透的本质是查询了不存在的数据,缓存永远 miss,DB 裸奔」 |
| 0:30–2:00 | 防护分层 | 校验 → 空值 → 布隆 → 限流;每层一句话原理+代价 |
| 2:00–3:30 | 布隆原理 | 位数组 + k 哈希;说「不存在一定对,存在可能错」;空间/误判率关系 |
| 3:30–4:30 | 工程细节 | 删除问题与变体(计数/布谷鸟);空值 TTL 短;数据删除后如何处理过滤器 |
| 4:30–5:00 | 收尾 | 「穿透不是流量问题,是『数据不该存在却反复查』问题,核心是把不存在也变成可拦截/可缓存的结果」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 空值缓存 TTL | 30s – 5min | 配合业务创建频率 |
| 布隆误判率目标 | 0.1% – 1% | 再低空间成本陡增 |
| 位数组大小 m | m ≈ -n·ln(p)/(ln2)² | n=预计元素数,p=误判率 |
| 哈希函数个数 k | k = (m/n)·ln2 | 通常 5–10 个 |
| 亿级 key 空间 | 120~240MB(误判 1%~0.01%) | 比存全量 key(1 亿个 key 含 SDS/dictEntry 约 6~8GB)省约 1~2 个数量级 |
| 查询复杂度 | O(k),与总量无关 | 适合放在应用本地 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「布隆过滤器说不存在就一定不存在?说存在就一定存在?」 → 说「不存在」一定不存在;说「存在」可能误判。误判只发生在「存在」方向,后果是多查一次 DB,不会返回错误数据。
L2|「商品删除了,布隆过滤器怎么办?」 → 标准布隆不能精确删。方案:①定期全量重建(每日/每周,双过滤器交替切换);②计数布隆(位改计数器,删除时减);③布谷鸟过滤器(支持删除);④业务侧删除时同步删空值缓存,布隆只作粗过滤。
L3|「空值缓存和布隆过滤器该怎么组合?会不会互相打架?」 → 组合而非互斥:布隆挡「确定不存在」的大头;空值缓存挡「布隆放行但 DB 也没有」的漏网与误判。顺序是:布隆说不存在 → 拦截;布隆说可能存在 → 查缓存 → miss → 查 DB → 有则回填,无则写空值。两者一个在查询前、一个在回源后,职责不同。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出空值缓存和布隆过滤器两种方案 |
| 80 分 | 讲清布隆「位数组+多哈希」原理,以及「一定不存在 vs 可能存在」的方向性 |
| 95 分 | 能给出 m/k 估算思路;说清为什么不能删、有哪些变体;能设计「布隆+空值」组合顺序;主动提限流兜底 |
【关联题】
- 同一知识簇: 第 23 题(三兄弟辨析)、第 44 题(布隆误判与删除)、第 25 题(击穿)、第 26 题(雪崩)
- 工程落地: 第 50 题(通用组件内置防穿透)、第 39 题(限流兜底)
- 上游基础: 第 31 题(数据结构)、第 32 题(单线程阻塞后果)
【自测】
- 为什么空值缓存的 TTL 要设得很短? 参考答案:避免数据真的被创建后,用户在 TTL 内仍读到空值。TTL 短则不一致窗口短。
- 布隆过滤器把存在的 key 判成不存在,会有什么后果? 参考答案:标准布隆不会发生「假阴性」。若用可删除变体误清了位,可能出现假阴性——合法请求被拦截,这是必须避免的,所以删除机制要非常谨慎。
- 一个恶意攻击者专门生成你库中存在的合法 ID 轮询,布隆还有用吗? 参考答案:对「存在的 ID」布隆全放行,防不住。这类攻击应靠限流、风控、鉴权,而不是穿透防护。
25. 爆款商品缓存刚过期,瞬间请求全部打到 DB(缓存击穿)
【考察内容】击穿治理的两种主流方案对比
【题目】某爆款商品的缓存 key 在凌晨 0 点过期(价格切换),之后 1 秒内涌入 10 万请求全部穿透到数据库,DB 连接被打满。热点 key 过期瞬间的流量洪峰怎么挡?互斥锁和逻辑过期两个方案各自怎么实现、怎么选?
【参考答案】缓存击穿:热点 key 过期瞬间,并发请求全部打到 DB:
- 容量估算(步进):题干该热点接口 1 秒内涌入 10 万请求(QPS 10 万),Redis 单点该 key 过期,50ms 内可能涌入 5000 个 并发回源(10万×0.05),都打同一条 SQL → 连接池被打满、慢查询排队,RT 飙升甚至拖垮库(普通 SELECT 是快照读,不加行锁;压垮点是连接与 CPU)。DB 该表容量假设 2000 QPS,瞬间放大数倍即危险。
- 方案:① 互斥重建:分布式锁(Redisson)只允许一个请求查 DB 回填,其余等待或返回旧值/兜底;② 逻辑过期:value 中存过期时间,过期时后台异步刷新,前端仍可读旧值;③ 热点不过期:超高热 key 永不过期,由后台更新;④ 本地缓存分担。
- 选择:可短暂陈旧用逻辑过期;必须新鲜用互斥锁;极端热用不过期+后台刷新。
- 失败与降级:锁等待超时 → 返回兜底/旧值,不要无限等;DB 真慢 → 触发熔断与限流;回填失败 → 重试与告警。
- 预防:TTL 加随机(如 3600±300s)避免集体到期;监控热 key 与回源 QPS。
【原理溯源】
- 为什么热点 key 过期会打出 10 万 QPS 到 DB? 正常路径是
QPS_db ≈ QPS × (1-命中率)。key 存在时命中率接近 100%,DB 几乎无压;key 过期瞬间命中率掉到 0,同一毫秒内所有在途请求全部 miss,形成「齐步走」回源。这不是流量变大了,是缓存挡板突然消失。 - 互斥锁为什么能把 10 万回源压成 1 次? 用
SET lock NX EX让并发请求抢锁:只有一个线程拿到锁去查 DB 并回填,其余请求自旋等待或退避后重读缓存。因果链:并发 miss → 抢锁 → 单线程回源 → 回填 → 释放 → 其余请求命中缓存。代价是锁等待增加 P99 延迟,且锁必须设过期防死锁。 - 逻辑过期为什么几乎无阻塞? 它取消了「物理过期时刻」——缓存永不过期,过期语义存在 value 里。查询时若已逻辑过期:立刻返回旧值(用户无感),同时投递一个异步任务重建。因果链:逻辑过期 → 同步路径零等待 → 异步路径只被触发有限次(可用单飞合并)。代价是旧值会多活一段时间,属于最终一致。
- 为什么生产常用「逻辑过期 + 互斥锁」组合? 逻辑过期负责用户体验(不阻塞),互斥锁负责防止「同一 key 被成百上千个异步任务同时重建」——两个问题正交:一个是同步路径要不要等,一个是异步路径要不要合并。
- 为什么热点 key 更好的策略是「永不过期 + 后台刷新」? 对真正极热的 key,任何「过期瞬间」都是人造风险。物理永不过期 + 定时/按访问频次后台刷新,把「失效时刻」从请求路径上彻底拿掉。
【选型判断树】
热点 key 过期洪峰:
├─ 业务能容忍短暂旧值(秒级)?
│ ├─ 能 → 逻辑过期(主) + 异步重建限流(推荐默认)
│ └─ 不能(价格/库存刚切换)
│ ├─ 并发可控 → 互斥锁同步回源
│ └─ 并发极高 → 互斥锁 + 本地缓存挡一层 + DB 限流
├─ key 是长期热点(日活爆款)?
│ → 永不过期 + 后台刷新(消灭过期瞬间)
└─ 重建可能失败?
→ 锁必须有 TTL;失败重试 + 旧值/降级数据兜底【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「击穿是单个热点 key 过期瞬间,高并发同时 miss,流量齐步打 DB」 |
| 0:30–2:00 | 互斥锁 | SETNX NX EX;单线程回源;强一致;有等待;必须设过期 |
| 2:00–3:20 | 逻辑过期 | 物理不过期;先返回旧值;异步重建;最终一致;零阻塞 |
| 3:20–4:20 | 怎么选 | 一致性优先→锁;性能优先→逻辑过期;极热→永不过期+刷新 |
| 4:20–5:00 | 风险 | 锁不设过期会死锁;重建失败要有重试;Redisson 看门狗续期 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 互斥锁 TTL | 5s – 30s | 需覆盖一次 DB 回源 |
| 锁等待超时 | 200ms – 1s | 超时可降级返回旧值 |
| 逻辑过期后旧值存活 | 数百 ms – 数秒 | 取决于异步重建延迟 |
| 异步重建线程 | 小池 2–8 线程 + 队列 | 防止重建风暴 |
| 热点判定 | 单 key QPS > 总量 1% 或 > 5000 | 视集群规模 |
| 刷新周期(永不过期) | 30s – 5min | 按价格变更频率 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「互斥锁为什么必须设置过期时间?」 → 防止持有锁的进程崩溃/网络分区后锁永不释放,造成死锁。所以加锁必须是原子的 SET NX EX,不能先 SETNX 再 EXPIRE。
L2|「逻辑过期方案,重建失败了会怎样?」 → 旧值会一直被返回,数据越来越旧。必须:重建失败重试(有上限)、超过阈值告警、极端时切换为互斥锁强制重建,或直接返回降级数据。
L3|「用 Redisson 实现互斥锁和裸写 SETNX 有什么本质区别?」 → Redisson 在正确性细节上做了封装:①加锁原子(SET NX EX);②解锁用 Lua 保证「比对 value + 删除」原子,防止误删他人锁;③看门狗默认 30s 锁并每 10s 续期,解决「业务执行超过锁时长」;④可重入(Hash + 重入计数)。裸写 SETNX 极易在解锁原子性和续期上踩坑。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出互斥锁、逻辑过期两种方案名称 |
| 80 分 | 两种方案各自的实现流程、优缺点、选型标准说清楚 |
| 95 分 | 讲清「N 并发回源」的成因;指出锁必须 NX EX 原子且可续期;逻辑过期要防重建风暴;能提出「极热 key 永不过期+后台刷新」的终局方案 |
【关联题】
- 同一知识簇: 第 23 题(三兄弟)、第 26 题(雪崩)、第 27 题(一致性)、第 47 题(预热与重建风暴)
- 锁机制深挖: 第 36 题(分布式锁实现)、第 37 题(看门狗)
- 热 key 治理: 第 13 题(热 Key)、第 45 题(本地缓存)
- 组件落地: 第 50 题(通用缓存组件)
【自测】
- 逻辑过期和「物理 TTL + 互斥锁」最本质的区别是什么? 参考答案:逻辑过期取消了物理失效时刻,查询永不因过期而阻塞,代价是短暂旧值;互斥锁保留物理过期,用锁把并发回源压成 1 次,强一致但有等待。
- 为什么互斥锁方案里,其他请求可以选择「返回旧值」而不是等待? 参考答案:热点场景下等待会拖高 P99 甚至拖垮线程池;返回旧值(若业务允许)可以立刻释放线程,体验更好。这是「一致性 vs 可用性」的局部取舍。
- 判断对错:只要加了互斥锁,DB 就绝对安全。 参考答案:错。锁只减少「同一 key」的并发回源;若大量不同 key 同时失效(雪崩),或锁本身故障,仍会打挂 DB。还需要限流与多级缓存。
26. 凌晨大量缓存同时过期,数据库瞬间被打挂(缓存雪崩)
【考察内容】雪崩治理
【题目】某天凌晨 3 点,缓存里一大批 key 同时过期(很多业务都把过期时间设成了同一时刻),数据库瞬间被穿透流量打挂,服务大面积告警。这类“雪崩”怎么预防?已经发生了怎么快速恢复?
【参考答案】缓存雪崩:大批 key 同时失效,或缓存层整体不可用:
- 容量估算(步进):假设平时命中 95%,峰值 5 万 QPS 时 DB 只回源 2500;雪崩时命中→0,回源 5 万,为 DB 容量(1–2 万)的 2.5–5 倍,必挂。必须把“雪崩概率”当成容量问题管理,而不是小概率事件。
- 预防:① TTL 加随机数,分散过期;② 大促前预热热点,避免刚上线全量回源;③ 缓存集群高可用(主从/集群/多可用区),避免整体挂;④ 互斥与限流兜底。
- 运行时保护:回源到 DB 的并发用互斥/信号量限制;网关对该接口限流;超阈值直接返回兜底数据/排队页。
- 失败与降级:缓存集群故障 → 读从库/本地缓存+全局限流(阈值调低到 DB 可承受,如 3000 QPS)——「从库」要说是谁的从库:Redis 自己的从节点同属这个故障域(主从一起挂或复制中断时读它没意义),可用的是 DB 从库;且要先按第 119 题那样算清降级后的量(本地只挡 30% 时落 DB 的 QPS 与 DB 实测能力差几倍),再决定限流阈值与只保哪些接口;核心只保写与少数读;非核心接口降级。
- 恢复:缓存恢复后逐步放量预热,避免瞬间回流再次打挂;对账检查因降级丢弃的请求。
- 监控:命中率突降告警(如 1 分钟内 <80%)、DB QPS/RT/错误率联动。
【原理溯源】
- 雪崩的两个诱因本质不同,不能混为一谈:
- 诱因 A:批量 TTL 到期。 根因是「过期时刻高度相关」——同一段代码写死了
EXPIRE 3600,同批写入的 key 就会同秒过期。因果链:写入时间相关 + TTL 相同 → 过期时刻聚簇 → 瞬间 miss 率从 1% 跳到 50%+ → DB QPS 被放大数十倍。 - 诱因 B:缓存整体不可用(宕机/网络/集群故障)。这时不是「一部分 key 失效」,而是缓存层整个消失,100% 流量打 DB,通常比 A 更致命。
- 诱因 A:批量 TTL 到期。 根因是「过期时刻高度相关」——同一段代码写死了
- 为什么 TTL 加随机值能治诱因 A? 它打破「过期时刻相关性」:即使写入时间相同,过期时间也在一个区间内均匀分布,miss 流量从「洪峰」变成「缓坡」,DB 看到的是接近稳态的回源流量。随机幅度应大于一次回填耗时的若干倍(通常分钟级)。
- 为什么多级缓存能治诱因 B? 本地缓存是进程内的,Redis 宕机时它仍然有效。因果链:Redis 挂 → 第一层 miss 率上升,但热点仍可被本地缓存挡住 → DB 只承受「本地也 miss」的子集。代价是各实例数据不一致,需要短 TTL 或广播失效。
- 为什么必须有限流降级作为最后防线? 预防手段(TTL 随机、高可用)降低故障概率,但无法把概率降到 0。一旦发生,保 DB 活着比返回准确数据更重要——限流保护连接池,降级返回静态/旧数据,服务可用性优先。
- 预热为什么能防雪崩? 大促时流量是「已知的陡增」。预先把热点加载进缓存,避免活动开始瞬间的「冷缓存集体回源」——那在效果上等同于一次人造雪崩。
【选型判断树】
雪崩风险治理:
├─ 预防
│ ├─ 批量过期 → TTL = base + random,禁止写死固定 TTL
│ ├─ 缓存宕机 → 集群/哨兵 + 本地缓存层
│ ├─ 大促流量 → 提前预热 + 错峰刷新
│ └─ 极热数据 → 永不过期 + 后台更新
└─ 应急(已经发生)
├─ 先保 DB → 网关/连接池限流、熔断
├─ 再保体验 → 降级返回旧数据/静态页
└─ 后恢复 → 逐步放量、补预热、修 TTL 规则【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:40 | 定性 | 「雪崩是成批 key 同时失效或缓存整体不可用;和击穿的单 key 不同」 |
| 0:40–2:20 | 两个诱因 | A 批量 TTL(相关过期);B 缓存宕机(整层消失)——分别对应解法 |
| 2:20–3:40 | 四件套 | TTL 随机、高可用、多级缓存、限流降级;补预热 |
| 3:40–4:30 | 应急顺序 | 先限流保 DB → 降级 → 再恢复缓存 |
| 4:30–5:00 | 收尾 | 「雪崩治理核心是打破过期相关性 + 给缓存层做冗余 + 给 DB 留活路」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| TTL 随机幅度 | +0 ~ 300s | 大促可到 0~1800s |
| 本地缓存 TTL | 1s – 60s | 多级缓存第一层 |
| 限流触发 | DB 连接池使用率 > 70%–80% | 留出缓冲 |
| 降级开关响应 | 秒级手动/自动 | 事故应急 |
| 预热启动时间 | 活动前 30min – 2h | 视数据量 |
| 哨兵故障转移 | 通常 10s–30s 级 | 期间需降级 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「TTL 用 24 小时,是不是就不会雪崩?」 → 不会减轻,只会把雪崩推迟到 24 小时后同一时刻。关键不是 TTL 长短,而是同批 key 的过期时刻是否聚集。必须加随机扰动。
L2|「Redis 整个挂了,本地缓存还能顶多久?」 → 取决于本地 TTL 与命中率。本地缓存 TTL 短(如 10s)则很快全量打 DB;TTL 长则数据更旧。工程上:热点 key 本地 TTL 适当拉长 + 降级数据 + DB 限流,三者配合撑过故障转移窗口(通常 10–30s)。
L3|「限流降级时,怎么决定返回什么?」 → 分级:①可降级接口返回静态/缓存快照;②可延迟功能直接关闭(如推荐流);③核心交易链路保 DB、砍次要字段与关联查询;④页面层显示兜底文案。原则是牺牲非核心保核心,而不是所有接口一律失败。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出 TTL 加随机、集群高可用 |
| 80 分 | 分清批量过期与缓存宕机两种诱因,并给出多级缓存、预热、限流 |
| 95 分 | 讲清「过期时刻相关性」是根因;应急顺序是先保 DB 再恢复;能设计降级分级策略 |
【关联题】
- 同一知识簇: 第 23 题(三兄弟)、第 25 题(击穿)、第 47 题(预热)、第 45 题(本地缓存)
- 高可用: 第 35 题(集群方案)、第 46 题(主从延迟)
- 容量: 第 34 题(过期淘汰)、第 48 题(内存治理)
- 限流: 第 39 题(分布式限流)
【自测】
- 为什么生产禁止
EXPIRE key 3600这种写死 TTL? 参考答案:同批写入的 key 会在同一时刻过期,造成雪崩。应改为3600 + random。 - 缓存宕机雪崩和批量过期雪崩,解法上最大的不同是什么? 参考答案:批量过期靠 TTL 打散;宕机靠高可用和本地缓存层——前者是时间分布问题,后者是可用性冗余问题。
- 已经雪崩、DB CPU 100%,你按什么顺序处理? 参考答案:①限流/熔断保护 DB;②开降级返回兜底数据;③恢复/重建缓存与预热;④事后修 TTL 规则和监控。
27. 改了数据库价格,用户看到的还是旧价格(缓存一致性)
【考察内容】缓存一致性是缓存类最高频考点
【题目】运营修改了商品价格(数据库已更新),但用户端看到的还是旧价格——缓存没更新。你怎么设计“更新数据库 + 更新缓存”的流程,保证数据最终一致?各个方案各自的坑是什么?
【参考答案】改了 DB 价格,用户仍看到旧价:典型的缓存一致性问题:
- 背景估算(步进):价格更新 QPS 很低(可能每天几百次),但读 QPS 上万。不能为了极致一致取消缓存;目标:不一致窗口控制在秒级~分钟级,资金/结算价则不允许用陈旧缓存做权威。
- 策略:① 先更新 DB,再删除缓存(最常用)+ TTL 兜底;② 更新频繁时用 延迟双删(更新后休眠一小段再删一次)或删除失败重试;③ 订阅 binlog(Canal)异步刷新/删除缓存,对业务无侵入;④ 强一致字段不缓存或 TTL 极短,结算时读 DB。
- 为什么删除而不是更新缓存:更新缓存有多写竞态与无用计算;删除让下次读时重建,逻辑更简单。
- 失败与降级:删除失败 → 重试队列+TTL 过期;binlog 延迟 → 监控消费位点;用户看到旧价下单 → 以订单/结算服务校验的权威价为准,旧缓存只影响展示。
- 监控:缓存与 DB 抽样对比差异率、平均不一致时长;运营改价后可主动刷新接口。
【原理溯源】
- 为什么是「删缓存」而不是「更新缓存」? 并发写场景下,「更新缓存」会互相覆盖:线程 A 读旧值、线程 B 写新值并更新缓存,随后 A 才把旧值写回缓存 → 缓存长期脏。删除缓存则不同:它不写入任何具体值,让缓存失效;下次读请求从 DB 回填当前真实值。因果链:删除 → 缓存无状态 → 回填必然来自最新 DB → 规避写覆盖。
- 为什么推荐「先更新 DB,再删缓存」? 对比两个顺序的不一致窗口:
- 先删缓存再更新 DB:删后、DB 更新前,并发读 miss → 从 DB 读到旧值 → 回填缓存。若回填发生在 DB 更新之后且第二次删除之前,脏值可长期存在(直到 TTL)。
- 先更新 DB 再删缓存:DB 已是新值,删缓存前的读可能命中旧缓存(毫秒级窗口),删除后即恢复一致。窗口短且可自愈。
- 结论:后者不一致概率更低、恢复更快,是工程默认。
- 为什么删除失败必须有补偿? 「删除」和「更新 DB」不在同一事务里:DB 提交成功后、删缓存前进程崩溃/网络失败,缓存就永久脏(直到 TTL)。因果链:跨系统无法原子 → 必须补偿。手段:重试队列、MQ、订阅 binlog(Canal)在 DB 变更事件后异步删缓存。
- binlog 订阅为什么更可靠? 它以 DB 的提交事实 为唯一真相源:只要 DB 变更成功,binlog 一定产生(MySQL 保证),Canal 消费后删缓存。不依赖应用层「记得发删除」,天然覆盖应用崩溃场景。代价是延迟(毫秒~秒级)和额外组件。
- 为什么最终一致是合理目标? 跨进程/跨存储无法在性能可接受前提下做到强一致(要强一致就得分布式事务或串行化读)。缓存的本质是「用可容忍的旧数据换延迟与吞吐」,所以目标应是:不一致窗口极短 + 必然自愈 + 关键业务可旁路。
【选型判断树】
缓存一致性怎么选:
├─ 数据是否强一致(钱、库存扣减结果)?
│ └─ 是 → 核心读路径直连 DB / 读主库,缓存只做旁路加速非关键读
├─ 一般业务数据(商品、资料、配置)
│ └─ Cache Aside:先更 DB,再删缓存
│ ├─ 删除可能失败 → 重试 + binlog 异步删
│ └─ 仍有并发回填旧值 → 见延迟双删 / 版本号(第 28 题)
├─ 写多读少且要简单
│ └─ 可考虑 Write Through(同步写 DB)
└─ 可容忍丢失(计数、日志类)
└─ Write Behind(第 29 题)【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「缓存一致性推荐 Cache Aside:读旁路、写先更库再删缓存,目标是最终一致」 |
| 0:30–2:00 | 读写流程 | 读:cache miss → DB → 回填;写:update DB → delete cache |
| 2:00–3:20 | 两个关键选择 | 为什么删不更新(覆盖问题);为什么先库后删(窗口更短) |
| 3:20–4:20 | 失败补偿 | 删除失败重试 / MQ / Canal binlog;短 TTL 自愈 |
| 4:20–5:00 | 收尾 | 「缓存不能和 DB 做成强一致事务,工程上用短窗口+必然自愈逼近一致」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 删除失败重试 | 3–5 次,间隔指数退避 | 仍失败进补偿队列 |
| binlog 异步删延迟 | 通常 10ms–1s | 取决于 Canal 消费 |
| 兜底 TTL | 热点 1–10 分钟;普通可更长 | 权衡一致性与命中率 |
| 「先删后更」脏值存活 | 可达一个 TTL 周期 | 为什么不用这个顺序 |
| 「先更后删」不一致窗口 | 毫秒级(删缓存前) | 可接受 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「能不能更新缓存而不是删除?」 → 可以但不推荐。并发下两个线程更新缓存可能旧值覆盖新值;删除让缓存无状态,回填一定来自最新 DB。只有在写极不频繁、且能保证串行时,更新缓存才可接受。
L2|「延迟双删解决的是删除失败吗?」 → 不是。删除失败要靠重试/binlog。延迟双删解决的是「先删缓存再更新 DB」或并发读在窗口内把旧值回填的问题——是时序竞态问题,不是失败补偿问题。两者经常被混为一谈。
L3|「如何做到接近强一致?」 → ①关键读直连 DB 或读主库;②写后 N 秒内强制读主(会话粘滞/读己之写);③缓存 value 带版本号,回填时比对;④分布式锁把「更新 DB + 删缓存」串行化(代价高,少用)。一般业务不值得为强一致付出这些成本。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出先更 DB 再删缓存 |
| 80 分 | 讲清「删 vs 更新」「先库 vs 先删」两个选择的理由,并给出失败补偿 |
| 95 分 | 区分「删除失败」与「并发回填旧值」两类问题;能说清 binlog 方案为何更可靠;指出强一致场景应绕过缓存而非硬做 |
【关联题】
- 同一知识簇: 第 28 题(延迟双删)、第 38 题(更新顺序)、第 29 题(四种策略)、第 49 题(一致性监控)
- 多级缓存一致性: 第 17 题(多级缓存)、第 119 题(三级缓存一致性)、第 45 题(本地 vs Redis)
- 组件落地: 第 50 题(通用组件写路径)
- 主从相关: 第 46 题(写后读不到)
【自测】
- 为什么「先删缓存再更新 DB」不一致窗口更大? 参考答案:删后、DB 更新前的并发读会把旧值回填,且可能发生在 DB 更新之后,导致脏值长期存在。
- 删除缓存失败了,最可靠的兜底是什么? 参考答案:订阅 MySQL binlog(Canal),以 DB 提交为真相源异步删缓存;辅以重试和短 TTL。
- 判断对错:只要用 Cache Aside,缓存和 DB 就一定一致。 参考答案:错。Cache Aside 是最终一致,删除失败或极端并发仍有窗口,需要补偿与 TTL。
28. 先改库再删缓存,旧数据还是被写回了缓存(延迟双删)
【考察内容】缓存一致性方案的深入理解
【题目】更新商品价格的流程是“先改数据库、再删缓存”,但并发下仍出问题:一个请求刚删完缓存,另一个并发请求已把旧价格写回缓存,用户继续看到旧价。怎么解决?常说的“延迟双删”是怎么做的?它又有什么缺陷?
【参考答案】先改库再删缓存,旧数据仍被写回——经典竞态,用延迟双删 + 兜底:
- 竞态过程(步进估算):读线程 A 缓存 miss、读到旧 DB 值准备回填 → 写线程 B 更新 DB 并删缓存 → A 的“重建逻辑”用读到的旧 DB 快照(或删除前读的数据)写回缓存 → 新值被旧值覆盖。时间窗通常几毫秒~几十毫秒,高并发下每天可发生多次。
- 延迟双删:B 更新 DB 后删除缓存,延迟一小段时间(略大于一次读的耗时,如 500ms–1s)再删一次,覆盖写回竞态。实现可用延迟队列/
MQ/Spring事件。 - 更稳方案:① 订阅 binlog 删缓存(最终以 DB 变更事件为准);② 给缓存值带版本号,重建时 CAS/仅当版本更新才写入;③ 重建时重新查 DB(不要用请求内旧变量);④ 必要时读写分布式锁(代价高,少用)。
- 失败与降级:延迟删除失败 → TTL 兜底+重试;仍短暂不一致 → 业务允许范围内的展示误差;价格/账户以 DB/订单校验为准。
- 口径:一致性是概率与延迟的权衡;展示类允许秒级不一致,交易类必须权威读。
【原理溯源】
- 为什么「先更 DB 再删缓存」仍可能写回旧值? 严格时序上的经典竞态:
- 读请求 R1 缓存 miss,去查 DB,读到旧价格;
- 写请求 W 完成「更新 DB + 删除缓存」;
- R1 才把查到的旧价格回填缓存。 结果:DB 是新价,缓存是旧价,且会一直脏到 TTL 过期。延迟双删的「第二次删除」就是想覆盖步骤 3 的回填。
- 延迟双删的因果链(本题口径:先更 DB 再删缓存): 更新 DB → 第一次删 → 等待 Δt → 第二次删,第二次删就是为了清掉窗口期内被 R1 回填的旧值。Δt 必须 大于一次「DB 查询 + 缓存回填」的耗时,否则第二次删发生在回填之前,删了也没用。另有"先删缓存再更新 DB"的变体,它的双删是 删→更库→Δt→再删;两种写法的第二次删都必须落在回填之后,这才是双删成立的唯一条件。
- 为什么它只是概率性方案? 若 R1 的回填延迟超过 Δt,或第二次删除后又有更早的读请求回填,脏值仍会出现。它把不一致概率大幅降低,但没有消除竞态本身——这是和 binlog 方案的本质差异。
- 为什么 binlog + 异步删除更可靠? 它不猜时序,而是以「DB 已提交」为触发点删除缓存。只要最终消费了该 key 的变更事件,就会再删一次。即便应用层回填了旧值,binlog 删除仍可纠正(前提是删除晚于回填;若删除先于回填,仍需短 TTL 兜底——所以实践中 binlog 删除常配较短 TTL 或版本号校验)。
- 版本号方案为何能根治回填旧值? 缓存 value 带单调递增版本/时间戳。回填前比对:只有 DB 版本 ≥ 缓存版本才允许写入。旧读请求的回填因版本更小被拒绝。这从「值是否过期」升级为「值是否够新」,是更根本的解法。
【选型判断树】
先更库再删缓存仍出现旧值回填:
├─ 不一致可容忍(秒级、低概率)
│ └─ 现有 Cache Aside + 短 TTL 即可,不必上双删
├─ 需要显著降低概率
│ └─ 延迟双删(Δt > 一次回源耗时)
│ └─ 仍要重试保障第二次删除成功
├─ 需要可靠纠正
│ └─ Canal binlog 异步删(以 DB 为真相源)
└─ 需要从机制上防旧值覆盖
└─ 缓存带版本号/时间戳,回填时校验【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:40 | 定性 | 「这是 Cache Aside 下的并发回填竞态:旧读回填发生在更新之后」 |
| 0:40–1:40 | 竞态时序 | 画三步:读旧 DB → 写完成删缓存 → 读回填旧值 |
| 1:40–2:50 | 延迟双删 | 更库、删、延迟、再删;Δt 怎么定;为什么只是概率方案 |
| 2:50–4:00 | 更可靠方案 | binlog 异步删;版本号校验;短 TTL 兜底 |
| 4:00–5:00 | 缺陷与选型 | 双删延迟难定、删除会失败;强一致数据干脆不走缓存 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 延迟双删 Δt | 500ms – 1s(常见) | 必须 > 一次回源+回填耗时 |
| 回源耗时量级 | 5–50ms(缓存 miss 查 DB) | Δt 的下限依据 |
| binlog 删除延迟 | 10ms – 1s | 仍建议配短 TTL |
| 版本号 | DB 版本列 / 更新时间戳 | 回填前比对 |
| 兜底 TTL | 1–5 分钟 | 自愈时间 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「延迟双删的延迟时间怎么定?」 → 应大于一次「读 DB + 写缓存」的最大耗时,并留余量。线上常见 500ms–1s。更稳妥的是用 binlog,不依赖猜时长。
L2|「延迟期间用户读到什么?」 → 多数时候读到旧值(缓存里的或刚回填的),属于可容忍窗口。若价格等场景不可接受,关键读应直连 DB 或带版本号。
L3|「Canal 方案和双删能叠加吗?还有必要双删吗?」 → 可叠加,常见做法是:应用层先删一次(快速收敛)+ binlog 删除(可靠兜底)。若只保留一种,优先 binlog——它不依赖应用时序,且覆盖进程崩溃场景。双删在 binlog 齐备后往往可退役。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道延迟双删是删两次,中间隔一段时间 |
| 80 分 | 能画出旧值回填的竞态时序,说清 Δt 依据,并指出概率性缺陷 |
| 95 分 | 区分「回填竞态」与「删除失败」;给出 binlog/版本号等更根本方案;说清何时可不用双删 |
【关联题】
- 同一知识簇: 第 27 题(Cache Aside)、第 38 题(更新顺序)、第 49 题(一致性监控)
- 更底层机制: 第 119 题(多级缓存一致性)、第 17 题(多级缓存)
- 补偿通道: 第 40 题(延时任务)可类比「延迟再删」的实现手段
【自测】
- 延迟双删的第二次删除,必须发生在什么之后、什么之前? 参考答案:必须发生在「可能的旧值回填」之后;理想上在业务可接受的不一致窗口结束之前。所以 Δt 要大于回填耗时。
- 为什么版本号方案比延迟双删更根本? 参考答案:双删靠「多删一次碰运气」,版本号靠「旧值根本写不进去」——从概率纠正变为机制拒绝。
- 判断对错:上了延迟双删就再也不用担心缓存不一致。 参考答案:错。极端并发、删除失败、Δt 设短都会导致仍不一致,还需要补偿与 TTL。
29. 缓存读写有四种经典策略,怎么给业务选?(缓存策略选型)
【考察内容】缓存策略全景
【题目】架构评审会上,有人提到缓存读写有 Cache Aside、Read Through、Write Through、Write Behind 四种策略,团队讨论该用哪种。请讲清四种策略的机制、各自适合什么业务(比如强一致要求 vs 写多读少),以及你们为什么最终选了某一种。
【参考答案】缓存读写策略选型,按一致性×读写形态:
- 四种经典策略:① 旁路缓存(Cache Aside):读 miss 查库回填;写先更库再删缓存——最常用,实现简单;② 读写穿透(Read/Write Through):应用只和缓存交互,缓存内部同步/异步加载与回写 DB;③ 写回(Write Behind):只写缓存,异步批量落库,性能最好,丢失风险高;④ 只读缓存/全量预加载:配置与字典类。
- 容量估算示例(步进):读 5 万、写 500 的商品业务 → Cache Aside + 命中率 95%;写密集日志类不适合“每次写都更缓存”;写回适合购物车/浏览历史(可接受少量丢失)。
- 选型判断:能否接受短暂不一致?接受 → 删缓存+TTL;不能接受 → 不缓存权威字段或强一致组件;读极大写极小 → 缓存优先;写极大 → 削峰异步,缓存少碰。
- 失败与降级:任一策略都要有缓存故障时直连 DB 的降级开关;删除/回填失败重试与对账。
- 口径:没有银弹;互联网业务默认 Cache Aside + 删除失败重试 + TTL,再按特殊场景换型。
【原理溯源】
四种策略的本质差异在「谁负责回源 / 谁负责落库 / 同步还是异步」:
策略 应用读谁 应用写谁 谁查 DB 谁写 DB 时机 Cache Aside 缓存(miss 则应用查 DB) DB(再删缓存) 应用 应用 同步 Read Through 只读缓存 (写通常另配) 缓存组件 — 同步 Write Through 可只读缓存 只写缓存 组件回源 组件同步写 DB 同步 Write Behind 只读缓存 只写缓存 组件 组件异步批量写 异步 为什么 Cache Aside 最常用? 读写路径都在应用手里,和任意 DB/缓存组合兼容,不需要缓存中间件支持「loader」或「同步写」。代价是业务代码要自己写对「先更库再删缓存」。互联网团队更愿意用代码控制换取灵活性。
为什么 Write Through 写延迟高? 每次写都要「写缓存 + 同步写 DB」串行完成才能返回,写 RT 被 DB 拖住。它换来的是「写完成即持久化」,适合写不多但不能丢的配置类数据。
为什么 Write Behind 最快也最危险? 写只进缓存(内存),后台批量合并刷盘——写路径没有磁盘 DB,吞吐极高。风险:缓存进程/机器宕机时未刷盘的数据永久丢失;批量写还可能乱序,需要版本控制。所以只适合「可丢、可最终一致」的数据(浏览计数、点赞数、行为日志)。
Read Through 和 Cache Aside 的读路径差异: 前者「缓存组件内嵌 loader,对应用透明」,后者「应用自己写 if-miss-then-load」。功能等价,差别在封装层次与侵入性。组件化平台倾向 Read Through,灵活业务倾向 Cache Aside。
一致性强度排序(写路径): Write Through(同步落库)≥ Cache Aside(删后回填)> Write Behind(异步落库)。性能大致相反。
【选型判断树】
选缓存策略:
├─ 数据丢了是否可接受?
│ ├─ 不可接受 → 禁止 Write Behind
│ └─ 可接受(计数/日志/行为)→ Write Behind 性能最优
├─ 读写谁更频繁?
│ ├─ 读多写少,互联网默认 → Cache Aside
│ └─ 写也不少,且希望应用简单
│ ├─ 可容忍同步写延迟 → Write Through
│ └─ 不能 → 回到 Cache Aside 或 Write Behind(若可丢)
└─ 是否有统一缓存中间件要屏蔽细节?
└─ 是 → Read/Write Through(组件内嵌 loader/落库)【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「四种策略的差异在:谁回源、谁落库、同步还是异步」 |
| 0:30–2:30 | 逐个讲 | 每种一句机制 + 一句适用 + 一句代价 |
| 2:30–3:40 | 对比维度 | 一致性强度 vs 写性能 vs 应用侵入性 |
| 3:40–4:30 | 落地推荐 | 「我们默认 Cache Aside;可丢数据用 Write Behind;组件平台可上 Read Through」 |
| 4:30–5:00 | 收尾 | 「没有最好,只有和数据丢失容忍度、写 QPS、团队封装能力匹配的方案」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| Write Behind 刷盘间隔 | 100ms–5s | 批量合并 |
| Write Behind 丢失窗口 | 等于刷盘间隔+故障瞬间 | 评估可丢窗口 |
| Write Through 写延迟 | ≈ DB 写延迟(ms 级) | 同步落库 |
| Cache Aside 读路径 | 1 次 Redis(命中)或 +1 DB | miss 时 |
| 点赞/浏览计数 | 适合 Write Behind | 允许少量丢失 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「Write Through 和 Write Behind 一字之差,差在哪?」 → Through 是同步写穿到 DB,写完成即持久化;Behind/Back 是异步先写缓存再批量刷 DB,更快但可能丢。
L2|「点赞数为什么适合 Write Behind?」 → ①允许最终一致和少量丢失(丢几个赞业务可接受);②写入频率极高,同步写 DB 会拖垮写路径;③后台可合并计数,减少 DB 写放大。
L3|「你们线上为什么最终选 Cache Aside?」 → 团队用的是自建 Spring + Redis,没有强缓存中间件做 Through;数据大多不可接受静默丢失(禁止 Behind);读多写少,删缓存成本低;需要在代码层精确控制一致性策略(TTL、补偿)。这三条决定了 Cache Aside 是性价比最高的默认。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能列出四种策略名称并大致区分 |
| 80 分 | 讲清各自机制、适用场景、一致性/性能取舍 |
| 95 分 | 能用「谁回源/谁落库/同步异步」统一框架对比;给出本团队默认选择及三条理由;指出 Write Behind 的丢失窗口量级 |
【关联题】
- 同一知识簇: 第 27 题(Cache Aside 细节)、第 38 题(更新顺序)、第 28 题(延迟双删)
- 多级与位置: 第 45 题(本地 vs Redis)、第 17 题(多级缓存)、第 119 题(三级一致性)
- 组件设计: 第 50 题(通用缓存组件)
【自测】
- 哪种策略写性能最高?代价是什么? 参考答案:Write Behind。代价是缓存宕机可能丢未刷盘数据,且一致性最弱。
- Cache Aside 和 Read Through 的读路径本质差别是什么? 参考答案:前者应用自己在 miss 时查 DB;后者缓存组件内嵌 loader 自动回源,对应用透明。
- 核心交易流水缓存,能用 Write Through 吗? 参考答案:可以(同步落库较安全),但更常见是交易路径直连 DB 或 Cache Aside + 强补偿,避免缓存成为写路径必经点。
30. 某业务 Redis 操作卡顿,查到一个超大 key(大 Key 治理)
【考察内容】大 Key 是 Redis 线上事故头号元凶之一,必考
【题目】线上偶发 Redis 命令超时、集群节点 CPU 抖动,排查发现某个业务的 key 里存了几十万条数据(大 key)。大 key 会带来哪些问题?怎么提前发现?发现了怎么处理?
【参考答案】大 Key 治理:发现→拆分/瘦身→预防:
- 容量估算(步进):Redis 大 Key 一般指 String >10KB(>1MB 强治理),或集合元素数 >5000 关注、>10 万严重。假设一个 hash 有 50 万元素,单次 HGETALL 可能返回数 MB,耗时几百 ms,阻塞单线程服务,引发该分片上所有 key 的 RT 抖动。节点带宽 1Gbps 时,若多个客户端同时读大 Key,瞬间打满网卡。
- 发现:
redis-cli --bigkeys(采样)、RDB 离线分析、云厂商大 key 扫描、延迟监控与慢日志;业务侧标注可能的大对象(购物车、关注列表、全量排行榜)。 - 治理:① 拆分:hash 拆成
cart:{uid}:0..N分页/分桶;② 瘦身:去掉无用字段、压缩、外置到 DB/对象存储,Redis 只存索引;③ 避免全量命令:HGETALL → HSCAN/HGET 必要字段;④ 非核心可迁到本地缓存或专用存储。 - 失败与降级:迁移过程双读/双写灰度;删除大 key 用 UNLINK 异步,避免 DEL 阻塞;紧急时业务降级不再读全量。
- 预防:容量规范(单 key 建议 <10KB)、集合元素上限、上线前评审;监控单节点带宽与慢查询。
【原理溯源】
- 为什么大 key 在单线程 Redis 里是「核弹」? Redis 主线程同一时刻只做一件事。操作一个几百 MB 的 value:网络读入、反序列化、内存分配、命令执行、序列化、网络写出——任一步都会独占主线程,期间所有其他客户端请求排队。因果链:单线程无并行 → 大 key 操作耗时放大为全局阻塞 → 上游超时 → 重试 → 更堵。
- 为什么删除大 key 特别危险?
DEL是同步删除:要遍历释放该 key 的全部内存/结构,几十万元素可能阻塞数百毫秒甚至秒级。UNLINK把释放工作交给后台线程,主线程只做逻辑删除,几乎不阻塞。所以生产禁 DEL 大 key,用 UNLINK 或分批 HDEL/SPOP。 - 为什么大 key 会拖垮主从和持久化? 主从首次全量同步要传输 RDB,其中包含大 value;部分重同步/命令传播时,对大 key 的写命令也会被放大。AOF 重写和 RDB fork 后写大对象会拉长耗时。大 key 把「单点阻塞」扩展为「复制链路和持久化链路一起抖」。
- 为什么集群会内存倾斜? Cluster 按 key 哈希分片,一个 key 只落在一个节点。超大 key 会让该节点内存/CPU 显著高于其他节点,形成木桶短板——扩容加节点也分不走这个 key,必须拆 key。
- 为什么 String 存大 JSON/大 Hash 不如拆开? 结构内部:整读整写是 O(value 大小);拆成小 field 或多个 key 后,业务可以只读写需要的片段,把 O(大) 变成 O(小)。这是用数据模型换性能,比加机器根本。
【选型判断树】
发现大 key:
├─ 是否正在造成阻塞/超时?
│ ├─ 是 → 紧急:UNLINK 或 SCAN+分批删;必要时从库顶、摘流量
│ └─ 否 → 择期治理
├─ 业务如何访问?
│ ├─ 整存整取且可拆 → 按业务维度拆多个 key
│ ├─ 只需部分字段 → 拆 Hash field / 子 key,按需 HGET
│ └─ 元素无序列表 → 拆多个小 List/ZSet,按时间或 ID 分段
├─ 是否只是序列化膨胀?
│ → 换更紧凑编码(MessagePack/Protobuf)或去掉冗余字段
└─ 预防
→ 写入前校验大小;组件层限 value;监控 bigkeys 周期任务【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「大 key 的危害根因是单线程:一个 key 的操作会阻塞所有人」 |
| 0:30–2:00 | 危害链 | 阻塞主线程 → 接口超时;带宽;主从同步;内存倾斜 |
| 2:00–3:00 | 发现 | --bigkeys / --memkeys / 慢日志 / 监控 |
| 3:00–4:20 | 处理 | 拆分模型;UNLINK/分批删;压缩序列化 |
| 4:20–5:00 | 预防 | 写入限制 + 周期扫描 + 告警 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 大 key 判定(String) | > 10KB 需关注,> 1MB 强治理 | 可按业务调 |
| 大集合判定 | 元素数 > 5000 关注,> 10 万严重 | hash/zset/list |
| 慢日志阈值 | > 10ms 记入,> 100ms 告警 | 单线程下很致命 |
| UNLINK vs DEL | 大对象优先 UNLINK | 后台线程释放 |
| 分批删除 | 每批 100–1000 元素,间隔 10ms+ | 防止阻塞 |
| 集群单 key 上限 | 迁移友好:<10MB;治理红线:>100MB 必须拆 | 这是迁移/删除耗时口径;正文「>10KB 关注、>1MB 强治理」是发现与治理口径,两者量级不同、不可互相换算 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「大 key 为什么不能直接 DEL?」 → DEL 同步释放全部内存,单线程下会阻塞所有请求。用 UNLINK(异步释放)或 SCAN 分批删。
L2|「一个 50 万元素的 Hash 怎么拆?」 → 按业务维度分片:如 hash:order:{userId} 拆成 hash:order:{userId}:{shard},shard=field(订单号/元素序号)%N——按 userId 取模对同一个用户是常量,50 万元素仍全落在同一个 shard,等于没拆;或改多个小 key。原则是拆完后单次操作元素量下降两个数量级,且业务可只访问需要的 shard。
L3|「大 key 治理过程中服务不能停,你怎么做灰度?」 → ①双写:新拆分结构与旧 key 同时写;②读切流:先从新结构读,miss 回退旧 key;③观察命中与延迟;④全切后 UNLINK 旧 key;⑤回滚开关保留一段时间。禁止一把梭直接改写。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出大 key 会慢、要拆 |
| 80 分 | 讲清单线程阻塞传导,给出 bigkeys 发现与 UNLINK 删除 |
| 95 分 | 说清主从/持久化/内存倾斜等次生危害;能设计拆分与灰度双写方案;主动提监控预防 |
【关联题】
- 同一知识簇: 第 32 题(单线程)、第 13 题(热 Key——热是访问频率,大是体积)、第 48 题(内存治理)、第 35 题(集群倾斜)
- 数据结构: 第 31 题(结构选型)
- 组件: 第 50 题(写入限制)
【自测】
- 大 key 和热 key 的区别与叠加风险? 参考答案:大 key 是体积大,热 key 是访问频。既大又热时阻塞最严重——操作耗时 × 高频 = 主线程几乎常驻在该 key 上。
- 为什么 UNLINK 比 DEL 安全? 参考答案:UNLINK 将真正的内存释放交给后台线程,主线程快速返回;DEL 同步释放,会阻塞。
- 用 String 存一个 2MB 的用户对象 JSON,每次改一个字段整读整写,有什么问题? 参考答案:读写放大、网络占用大、阻塞时间长。应拆 Hash 按字段读写,或只存变化部分。
31. 点赞/排行榜/购物车/关注/未读数,各用什么结构存储(数据结构选型)
【考察内容】数据结构选型是 Redis 场景题最高频题型
【题目】五个需求摆在你面前:①记录用户是否点过赞(亿级用户);②游戏积分实时排行榜;③购物车(用户→多商品→数量);④用户的关注列表;⑤聊天未读数(要求原子 +1)。分别用 Redis 的哪种数据结构实现?为什么?
【参考答案】数据结构选型按“读写模式 + 查询维度”:
- 常见业务对位:① 点赞/计数:
String + INCR或 hash 分桶;② 排行榜:ZSET(score=积分);③ 购物车:hash(field=商品,value=数量);④ 关注/粉丝:set(差集、共同关注)或zset存关注时间;⑤ 未读数:hash或多个 counter;⑥ 社交 feed 点赞名单量大时用 bitmap/布隆或外置。 - 容量估算(步进):假设 1 亿用户未读,每用户 5 个会话 key,每个 hash 100B → 约 50GB,需集群与拆分;ZSET 百万元素单 key 内存可达数十 MB,属于大 key,要分榜/分页/裁剪。写热点:明星点赞 INCR 可达数万 QPS,需分桶聚合(
like:{id}:{shard}再求和)。 - 选型原则:要范围排名 → ZSET;要集合运算 → SET;要对象字段 → HASH;要极简计数 → STRING;要存在性 → BITMAP/Bloom。
- 失败与降级:热 counter 打满节点 → 本地聚合+定时写回;ZSET 更新延迟可接受最终一致;Redis 挂 → 计数以 DB 对账修复。
- 口径:先说查询形态,再选结构;结构选错会导致只能全量扫描或改业务。
【原理溯源】
选型的核心不是「功能能实现」,而是「哪个结构的命令原生支持该操作且原子」:
需求 核心操作 匹配结构 关键命令 为什么 点赞状态 判存在/防重 String/BitMap/Set SETNX / SETBIT / SADD 去重 + O(1) 判定 排行榜 按分排序取 TopN ZSet ZINCRBY / ZREVRANGE 底层跳表按 score 有序 购物车 用户→多商品→数量 Hash HINCRBY / HGETALL field=SKU,value=数量 关注列表 集合运算 Set SINTER / SUNION 共同关注=交集 未读数 原子累加 Hash/String HINCRBY / INCR 单线程原子 为什么排行榜必须是 ZSet 而不是 List? List 只有下标有序,没有「按分数排序并更新某人分数」的原生操作;若用 List 自己排序,每次分数变化都要全量重排,O(N) 且非原子。ZSet 用「哈希表(member→score)+ 跳表(按 score 排序)」,更新 O(logN),取 TopM 为 O(logN+M)。
为什么购物车用 Hash 而不是 String 存 JSON? String JSON 每次改数量都要 GET 整包 → 反序列化 → 改 → 序列化 → SET,无法原子改单个商品,且并发覆盖。Hash 的 HINCRBY 直接对某 field 原子 ±,HGETALL 一次取全车,结构与业务模型一一对应。
为什么关注关系用 Set? 关注/粉丝天然是集合;「共同关注」「我关注的人也关注了谁」需要交并差。Set 的 SINTER/SUNION/SDIFF 原生支持,且结果可以是新 Set 或直接返回列表。
点赞为什么有时用 BitMap? 亿级用户的「是否点过赞」是 1-bit 信息。BitMap 把用户 ID 映射到位,1 亿用户只需约 12MB。代价是只能回答「点没点」,不能枚举「谁点了」(要遍历)。按查询形态选:只判存在 → BitMap/String;要列表/交集 → Set。
Redis 单线程为什么让 INCR/HINCRBY 天然原子? 无并发交错,命令队列串行执行。所以计数器不需要应用层加锁——这是选 Redis 做限流、未读数、秒杀计数的底层原因。
【选型判断树】
需求落结构:
├─ 只要「是否」?(点赞、签到、在线)
│ ├─ 用户基数大、只判存在 → BitMap / String+SETNX
│ └─ 还要列表或交集 → Set
├─ 要「排序 TopN」?(积分榜、销量榜)
│ → ZSet(score=指标)
├─ 要「一个主体下多个字段」?(购物车、用户属性)
│ → Hash
├─ 要「集合运算」?(关注、共同好友、标签)
│ → Set(有序取范围才用 ZSet)
├─ 要「原子计数」?(未读、限流、库存预扣)
│ → INCR / HINCRBY
├─ 要「先进先出队列」?(异步任务、可靠消费)
│ → List(LPUSH + BRPOP / BRPOPLPUSH 可靠队列)
└─ 要「定长最近列表」?(最近浏览、用户动态)
→ List(LPUSH + LTRIM 截断,LRANGE 按下标读)【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「选型标准是:哪个结构的原生命令能原子完成核心操作」 |
| 0:30–3:00 | 五需求逐个对应 | 点赞→String/Set/BitMap;榜→ZSet;车→Hash;关注→Set;未读→HINCRBY |
| 3:00–4:00 | 讲两个「为什么」 | 榜为何不能用 List;车为何不能用 String JSON |
| 4:00–4:40 | 底层一句 | ZSet=哈希+跳表;单线程保证 INCR 原子 |
| 4:40–5:00 | 收尾 | 「结构匹配业务模型,原子性来自原生命令+单线程」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| BitMap 1 亿用户 | 约 12MB | 1 bit/人 |
| ZSet Top100 | O(logN+100) | 毫秒内 |
| ZSet member 内存 | 约 20–80 字节 | 亿级约几 GB |
| Hash 小对象 | < 100 field 较优 | 过大变大 key |
| Set 交集 100 万×100 万 | 可能秒级 | 注意复杂度 |
| INCR 吞吐 | 单实例 10 万+/s | 简单命令 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「点赞亿级用户,为什么有人用 BitMap?」 → 每用户 1 bit,1 亿仅约 12MB,且 SETBIT/GETBIT O(1)。适合只判「点没点」;若要「谁点赞了」则应使用 Set 或倒排到业务库。
L2|「ZSet 底层是什么?为什么更新和取 TopN 都快?」 → 哈希表(O(1) 找 member→score)+ 跳表(按 score 有序,插入删除查找 O(logN))。ZINCRBY 改 score 后调整跳表;ZREVRANGE 从最高分向下取 M 个,复杂度 O(logN+M)。
L3|「关注列表用 Set,粉丝也用 Set,共同关注怎么算?慢吗?」 → SINTER follow:me follow:he。复杂度与较小集合大小相关。若一方是大 V(粉丝百万级)会慢——应对:限制对超大集合做交集、只算前 N、或离线预计算共同关注。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 五个需求能对上五个常见结构 |
| 80 分 | 每个能说出关键命令和「为什么是它不是别的」 |
| 95 分 | 讲清 ZSet 双结构、BitMap 容量优势、String JSON 的并发覆盖问题;能提醒大集合交集的性能陷阱 |
【关联题】
- 同一知识簇: 第 32 题(单线程原子性)、第 41 题(排行榜实战)、第 42 题(GEO/底层 ZSet)、第 43 题(购物车 Hash)
- 大 key: 第 30 题(集合过大)
- 限流计数: 第 39 题(INCR/ZSet 滑窗)
【自测】
- 「共同关注」用什么命令?两个结构是什么? 参考答案:SINTER,两边都是 Set(我关注的 / 他关注的)。
- 为什么用 List 做实时积分榜不合适? 参考答案:List 无法按 score 原子更新某人名次;要全量重排,非原子且 O(N)。
- 购物车为什么推荐 Hash 而不是 String JSON? 参考答案:Hash 的 HINCRBY 可原子改单个商品数量,避免整包读写与并发覆盖,结构与「用户→多 SKU→数量」同构。
32. 面试官:Redis 单线程,凭什么支撑 10 万+ QPS?(Redis 高性能原理)
【考察内容】Redis 为什么快是必考送分题,但要答出层次
【题目】有人质疑:Redis 是单线程模型,怎么可能扛得住 10 万+ QPS?请从 IO 模型、数据结构、内存操作、上下文切换等角度解释 Redis 为什么快,并说明单线程模型带来的限制。
【参考答案】Redis 单线程却能 10 万+ QPS,本质是内存 + IO 多路复用 + 协议简单:
- 容量估算(步进):单次简单命令服务端处理约 1–10 微秒;若 10 万 QPS,则服务端占用 ≈ 10万×5µs = 0.5 CPU 秒/秒,远低于 1 核饱和,说明单线程不是瓶颈。真正限制通常是网卡与客户端 RTT:1KB×10万 = 100MB/s ≈ 800Mbps,已经贴近千兆网卡上限(1Gbps≈125MB/s),要跑满这个量级需万兆网卡(1.25GB/s)或聚合带宽——带宽是比 CPU 更早撞到的墙。
- 高性能原因:① 纯内存操作,无磁盘等待(持久化另有子进程);② 事件驱动 epoll/kqueue,单线程避免锁与上下文切换;③ 命令大多 O(1)/O(logN),高效数据结构;④ 协议简单、流水线 pipeline 减少 RTT;⑤ 单线程保证命令原子性,业务无需锁竞争心智负担。
- 何时不够:大 key、复杂命令、fork 导致 COW 内存压力、网卡打满;此时需要集群分片、从库读、或用更高版本多线程 IO(Redis 6+ IO 线程处理网络,命令执行仍单线程)。
- 失败与降级:慢命令阻塞 → 清理慢查询/禁用危险命令;带宽打满 → 压缩、拆分、就近本地缓存。
- 口径:不是“单线程快”,而是瓶颈不在 CPU 串行,在内存与 IO 调度方式;单线程还带来简单可预测的性能模型。
【原理溯源】
- 为什么“单线程”反而快? 不是“线程少所以快”,而是单线程消除了两类开销:① 多线程保护共享数据必须加锁,锁竞争本身就是成本;② 线程切换要保存/恢复上下文(寄存器、栈),高并发下切换开销可能超过业务本身。Redis 的操作都是内存级(纳秒到微秒),一旦引入锁和切换,开销占比会急剧上升——所以单线程在这里是收益大于代价的选择。
- 为什么 epoll 能扛住大量连接? 对比三代 IO 模型:
select:每次调用要把全部 fd 集合从用户态拷贝到内核态,返回后还要线性遍历找就绪的,复杂度 O(n),且 fd 上限 1024;poll:去掉了 fd 数量上限,但仍需线性遍历,O(n);epoll:用红黑树管理 fd(增删 O(log n)),用就绪链表存放已就绪事件,epoll_wait直接返回就绪链表,取就绪事件 O(1),且不需要每次拷贝全量 fd。- 结论:连接数越多,epoll 相对 select/poll 的优势越明显——这正是 Redis 单线程能同时处理海量连接的原因。
- 为什么 6.0 只让网络 IO 多线程,不让命令执行多线程? 因为瓶颈不在命令执行而在网络读写(尤其大 value 的解析与回包)。让命令执行多线程会引入锁与执行顺序问题——Redis 很多命令(
INCR、SETNX)的语义依赖“原子执行”,多线程要么加锁(抵消收益),要么改变语义(破坏兼容性)。所以只把“读请求 + 写响应”并行化。 - 单线程带来的真实限制: ① 任一慢命令(
KEYS *、大 key 的HGETALL、SORT)会阻塞全部请求;② 无法利用多核做命令执行,单机 QPS 有天花板;③ 因此必须靠集群分片横向扩展,而不是单机加核。
【选型判断树】
需要更高吞吐时怎么办?先定位瓶颈:
├─ 慢命令 / 大 key 阻塞 → 治理慢命令、拆大 key(第 30 题)
├─ 热 key 打满单节点 → 本地缓存 + 读写分离 + key 打散(第 13 题)
├─ 网络带宽打满 → 6.0+ 开 IO 多线程、pipeline、压缩 value
├─ 单核 CPU 打满 → 集群分片(第 35 题)
└─ 内存不足触发淘汰 → 治理内存 + 扩容(第 48 题)
通用原则:Redis 靠“分片”扩展,不靠“单机加核”。【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “Redis 快不是单点原因,我会从内存、IO 模型、单线程机制三个层面讲,并纠正一个常见误解” |
| 0:30–2:30 | 三个层面 | 内存(无磁盘 IO)→ 单线程(免锁免切换)→ epoll(IO 多路复用);数据结构作为补充 |
| 2:30–3:30 | 纠正误解 | 主动说“严格说 Redis 不是完全单线程,6.0+ 网络 IO 是多线程的” |
| 3:30–4:30 | 讲限制 | 慢命令阻塞、单核天花板、必须靠分片扩展 |
| 4:30–5:00 | 收尾 | “一句话:Redis 的快来自‘内存 + 免锁 + epoll’三件套,它的扩展方式是分片而不是加核” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 单机 QPS | 约 10 万 | 简单命令;pipeline 可更高 |
| epoll 就绪事件获取 | O(1) | vs select/poll 的 O(n) |
| fd 上限 | select 1024;epoll 无硬上限 | epoll 的核心优势之一 |
| 慢命令判定 | slowlog-log-slower-than 默认 10ms 记入慢日志;生产常加严到 >1ms 即告警 | 单线程下会阻塞所有请求 |
| 大 key 判定 | String > 10KB;集合元素 > 5000 | 需拆分 |
| IO 多线程 | 默认关闭,需显式配置 io-threads | 6.0+ 特性 |
| 单机内存建议 | ≤ 16GB(RDB fork 时可能翻倍) | 与持久化开销相关 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|“那为什么不用多线程执行命令,不是能利用多核吗?” → 因为会引入锁与执行顺序问题。Redis 很多命令的语义依赖“原子执行”,多线程要么加锁(抵消收益),要么改变语义(破坏兼容性)。而实际瓶颈多在网络 IO,所以 6.0 只并行化了 IO。
L2|“epoll 和 select 到底差在哪?为什么 epoll 更快?” → 三点:① select 每次要把全量 fd 从用户态拷到内核态,epoll 只在注册时拷贝一次;② select 返回后要线性遍历 O(n) 找就绪的,epoll 直接返回就绪链表 O(1);③ select 有 1024 fd 上限,epoll 没有。所以连接数越大,epoll 优势越明显。
L3|“如果线上真出现一个慢命令把 Redis 卡住了,你怎么处理?” → 先止血:SLOWLOG 定位慢命令,必要时 CLIENT KILL 掉来源;用 redis-cli --bigkeys / --hotkeys 找大 key 与热 key。再根治:拆大 key、把 KEYS 换成 SCAN、大 hash 改成分批取、对热 key 加本地缓存。最后加防线:客户端/代理层做命令白名单与超时熔断。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出“内存 + 单线程 + IO 多路复用”三点 |
| 80 分 | 能解释单线程为什么反而快(免锁、免切换),并说出 6.0 IO 多线程 |
| 95 分 | 能讲清 epoll vs select 的三点差异;能说出单线程的真实限制与应对(慢命令阻塞、靠分片扩展);能给出慢命令的排查处置路径 |
【关联题】
- 同一知识簇(Redis 性能与治理): 第 30 题(大 Key 治理)、第 13 题(热 Key 治理)、第 48 题(内存治理)、第 33 题(持久化选型)、第 35 题(集群方案)
- 对比参照: 第 86 题(Kafka 高吞吐原理)——同属“单机为什么能扛”类问题,可对比记忆
- 上游基础: 第 31 题(数据结构选型)
【自测】
- 判断对错:Redis 是单线程的,所以不可能利用多核 CPU。 参考答案: 错。6.0+ 网络 IO 是多线程的;且可通过部署多个实例 / 集群来利用多核。
KEYS *为什么在生产环境禁用?替代命令是什么? 参考答案: 它遍历全部 key 且是 O(n) 阻塞操作,单线程下会卡住所有请求;替代用SCAN(游标分批、非阻塞)。- 简述 epoll 比 select 快的两个原因。 参考答案: ① fd 只在注册时拷贝一次,select 每次调用都要全量拷贝;② 就绪事件通过就绪链表直接返回 O(1),select 需线性遍历 O(n)。
33. Redis 重启后数据全丢了,老板要求持久化(RDB/AOF 选型)
【考察内容】持久化机制与数据安全权衡
【题目】某次机器宕机重启后,Redis 里的数据丢了不少(配置里只开了默认持久化),业务受损。你负责给 Redis 配置持久化:RDB 和 AOF 各是什么机制?怎么搭配才能“尽量少丢数据”又“不拖慢性能”?
【参考答案】RDB/AOF 选型按“丢数据容忍 × 性能”:
- 容量估算(步进):假设内存 20GB 数据,RDB fork 写一份约 20GB 快照,磁盘 IO 与 COW 峰值内存要预留;AOF 体积可达数据量的 1–3 倍(与写入量相关),QPS 5 万 × 200B = 10MB/s 级写入,按 86400 s/天算日增约 864GB(未计 rewrite 前的膨胀),必须靠
auto-aof-rewrite-percentage及时重写,否则磁盘会先被打满。 - RDB:定时快照,恢复快、体积小,两次快照之间数据会丢;适合备份与容灾、可丢几分钟数据的缓存场景。
- AOF:追加写命令;
everysec每秒刷盘约丢 1 秒;always最安全性能最差;no依赖 OS。适合把 Redis 当存储、可丢窗口要小的场景。 - 推荐组合:RDB + AOF everysec 一起开;重要业务再配合主从/集群与复制缓冲。
- 失败与降级:AOF rewrite 期间压力大 → 低峰执行;磁盘满 → 告警并暂停写;恢复演练要定期做(很多团队备份了却没演练过);纯缓存数据也可接受清空,直接从 DB 重建(预热限流)。
- 口径:先问“丢多少秒可以接受、恢复要多快”,再选机制。
【原理溯源】
- RDB 的因果链: 触发条件满足(N 秒内 M 次修改)→
fork出子进程 → 子进程利用写时复制(COW)读内存生成快照 → 父进程继续服务。丢失窗口 = 两次快照间隔;fork 瞬间若内存大,可能造成短暂阻塞与内存峰值(COW 下写会复制页)。 - AOF 的因果链: 每个写命令追加到 AOF 缓冲 → 按
appendfsync策略刷盘:always:每条命令 fsync,最多丢 0,性能最差;everysec:每秒 fsync,最多丢 1 秒,工程默认;no:交给 OS,丢多少不可控。 文件会无限变大,所以需要 AOF 重写:后台根据当前内存数据生成最小命令集,替换旧文件。
- 为什么「双开」是主流? 两者互补:AOF 丢得少但恢复慢、文件大;RDB 恢复快、适合备份与复制,但丢得多。重启时优先加载 AOF(更全),RDB 作为冷备与快速拉起手段。
- 为什么纯缓存可以关持久化? 缓存数据可从 DB 重建,持久化的收益是「重启后不用回源」,代价是磁盘 IO、fork 开销、磁盘占用。当重建成本低时,关掉更划算。
- 为什么大内存机器要警惕 fork? fork 要复制页表,内存越大越慢;COW 下父进程大量写会触发页复制,内存可能近似翻倍。经验上单实例内存建议 ≤16GB,或用 4.0+ 的混合持久化降低重写成本。
【选型判断树】
数据丢了能不能接受?
├─ 能(纯缓存,可回源重建)
│ └─ 关闭持久化 或 仅 RDB 做冷备
├─ 不能(计数/会话/队列/业务态)
│ └─ AOF everysec(默认)+ RDB 双开
│ ├─ 一秒都不能丢 → AOF always(性能换安全,慎用)
│ └─ 文件太大/恢复慢 → 开混合持久化 + 定期 rewrite
└─ 高可用要求高
└─ 持久化只是底线,还要主从/集群(第 35 题)【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「RDB 快照、AOF 日志;一个丢得多恢复快,一个丢得少恢复慢」 |
| 0:30–2:20 | 两种机制 | RDB:fork+COW;AOF:追加+fsync 策略+重写 |
| 2:20–3:30 | 怎么选 | 双开;纯缓存可关;敏感数据 everysec |
| 3:30–4:20 | 代价 | fork 阻塞与内存翻倍;AOF 文件与重放耗时 |
| 4:20–5:00 | 收尾 | 「持久化解决的是单机掉电,高可用解决的是机器没了——两件事都要」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 默认 RDB 规则 | 900s 内 1 次 / 300s 内 10 次 / 60s 内 10000 次(按上游 redis.conf 逐版核过:2.8 / 3.0 / 6.0 是 save 900 1 / 300 10 / 60 10000;6.2 起改成 save 3600 1 / 300 100 / 60 10000;7.0 起再合并写成一行 save 3600 1 300 100 60 10000。所以「6.2 及以前」要改成「6.0 及以前」,「7.x 改为三行」要改成「6.2 改为三行、7.x 合成一行」) | save 配置,须按所部署版本核对 |
| AOF everysec 丢失 | ≤ 1 秒写入 | 工程默认 |
| AOF always | 接近 0 丢失 | 吞吐可能降一个量级 |
| 单实例内存建议 | ≤ 16GB | 控制 fork/COW |
| 恢复速度 | RDB ≪ AOF(AOF 要重放) | 混合持久化可折中 |
| AOF rewrite | 文件增长 100% 可触发 | auto-aof-rewrite-percentage |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「AOF 一定比 RDB 好吗?」 → 不是。AOF 丢得少,但文件大、恢复慢、重写有开销。备份和快速拉起更依赖 RDB。生产通常双开。
L2|「AOF 重写会阻塞主线程吗?」 → 重写本身在子进程/后台执行;但 fork 瞬间可能短暂阻塞,且重写期间写压力会导致 COW 内存上升。应避开高峰执行 BGREWRITEAOF。
L3|「从库还要开持久化吗?」 → 建议开。主库全挂且从库被提升为主时,从库若有持久化可避免「提升后又重启丢数据」。纯缓存从库可关闭以省资源。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出 RDB 快照、AOF 日志,建议都开 |
| 80 分 | 讲清丢失窗口、fsync 策略、恢复速度差异,按数据敏感度选型 |
| 95 分 | 说清 fork/COW 成本;AOF 重写原理;纯缓存可关持久化;持久化与高可用的边界 |
【关联题】
- 同一知识簇: 第 35 题(高可用/主从)、第 32 题(单线程/fork 影响)、第 48 题(内存)
- 数据丢失对比: 第 46 题(主从延迟也是「一致性窗口」问题)
【自测】
- AOF everysec 最多丢多少数据? 参考答案:约 1 秒内的写命令。
- 为什么说 RDB 恢复比 AOF 快? 参考答案:RDB 是内存二进制镜像直接加载;AOF 要重放全部写命令。
- 判断对错:持久化配置好了,单点 Redis 就安全了。 参考答案:错。持久化只解决进程/机器重启后数据找回,不解决高可用与自动切换,还需要主从/哨兵/集群。
34. 内存 8G 用满、key 过期却不释放,怎么办(过期与淘汰策略)
【考察内容】内存管理机制
【题目】线上 Redis 内存 8G 已经用满,业务反映写入开始报错;同时有人疑惑“有些 key 明明设了过期时间,内存却一直不降”。请讲清 Redis 的过期删除策略与内存淘汰策略的区别,以及你们该怎么配置。
【参考答案】内存用满 key 不释放:分清过期删除与内存淘汰:
- 容量估算(步进):实例 8G,已用 90%(7.2G)。假设写入 50MB/s、过期速率若 < 写入,则内存持续上涨。Redis 只在“过期抽样 + 内存满时淘汰”场景处理,不会立刻释放每一个到期 key。
- 过期删除策略:惰性删除(访问时删)+ 定期删除(每 100ms 抽样一批过期 key,周期由
server.hz控制)。因此长期不访问的过期 key 可能仍占内存,直到定期删除抽到或内存满触发淘汰。 - 内存淘汰(maxmemory-policy):达到 maxmemory 时按策略淘汰:共 8 种:
noeviction(默认,写命令报错)、allkeys-lru、allkeys-lfu、allkeys-random、volatile-lru、volatile-lfu、volatile-random、volatile-ttl(后四种只作用于设了 TTL 的 key);纯缓存实例用allkeys-*(volatile-* 在没设 TTL 时会退化成报错)。生产缓存常用allkeys-lru或allkeys-lfu;把 Redis 当存储则noeviction+ 告警扩容。 - 治理:确认是否真的该过期但没设 TTL;大 key/碎片(内存碎片率);
MEMORY USAGE分析;必要时MEMORY PURGE/重启整理碎片;集群扩容。 - 失败与降级:
noeviction导致写失败 → 业务报错,要监控;淘汰过猛命中率下降 → 回源 DB,需限流兜底。
【原理溯源】
- 两个机制回答的是不同问题,必须分开:
- 过期删除:「TTL 到了的 key 什么时候真正从内存消失?」
- 内存淘汰:「内存满了,该踢掉哪些(可能还没过期的)key?」
- 为什么不能「到期立刻删」? 若每个 key 精确定时删除,需要海量定时器,CPU 不可接受。Redis 用:
- 惰性删除:读写时检查,过期则删。零额外扫描成本,但从不被访问的过期 key 会一直占内存——这正是「设了过期内存却不降」的主因。
- 定期删除:每 100ms 抽样一部分带 TTL 的 key,删掉已过期的;若过期比例仍高则继续。折中 CPU 与内存回收。
- 为什么默认 noeviction 在缓存场景很危险? 内存满后写命令直接报错,而不是踢掉冷数据。业务若没处理错误会失败。缓存场景应显式改为
allkeys-lru或allkeys-lfu,用「牺牲缓存」换「写入可用」。 - LRU vs LFU 的本质差异: LRU 看「最近是否用过」——一次性扫描大量冷 key 可能把热点踢掉;LFU 看「使用频率」——刚被扫过一次的 key 不会因「最近」而留下。热点保护场景 LFU 更稳,但实现更重(需要计数器)。
- volatile- 系列为什么常不够用?* 它们只在「设置了 TTL 的 key」里淘汰。若缓存里混有大量无 TTL key(错误配置),volatile 系列可能无 key 可踢,仍然写失败。所以纯缓存集群更常用
allkeys-*。
【选型判断树】
内存满了 / 写报错:
├─ 先分清是「过期没删」还是「数据真的多」
│ ├─ 大量过期 key 残留 → 确认定期删除正常;必要时 UNLINK 清理
│ └─ 有效数据超容量 → 走淘汰或扩容
├─ 业务类型?
│ ├─ 纯缓存(可丢)→ allkeys-lru / allkeys-lfu
│ ├─ 业务数据(不可丢)→ noeviction + 扩容/集群;禁止 allkeys
│ └─ 混有 TTL 与无 TTL → 优先 allkeys,避免 volatile 无候选
└─ 热点怕被清 → allkeys-lfu;或本地缓存 + Redis 双层【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:40 | 定性 | 「过期删除管 TTL 到期;内存淘汰管满了踢谁——两套机制」 |
| 0:40–2:00 | 过期删除 | 惰性 + 定期;解释「设了过期内存不降」 |
| 2:00–3:20 | 淘汰策略 | noeviction / LRU / LFU / volatile-*;缓存默认 allkeys-lru/lfu |
| 3:20–4:20 | 选型与坑 | 不可丢数据不能开 allkeys;LFU 保护热点 |
| 4:20–5:00 | 收尾 | 「先确认过期回收,再按可丢性配置淘汰,最后才是扩容」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 定期删除周期 | 每秒 server.hz(默认 10)次,即约每 100ms 一轮;每轮最多占用约 25% 的时间片(是单轮时长上限,不是常态 CPU 占用) | 抽样,非全量 |
| 惰性删除 | 仅访问时 | 冷 key 会残留 |
| 缓存淘汰建议 | allkeys-lru 或 allkeys-lfu | 业务缓存 |
| maxmemory | 物理内存的 50%–75% | 留复制/fork 缓冲 |
| 内存告警 | 80% 预警 / 90% 告警 | 与第 48 题同口径 |
| OOM 行为 | noeviction 时写失败、读仍可 | 错误要处理 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「为什么用了惰性删除,内存还是不降?」 → 从不被访问的过期 key 不会被惰性删除碰到;若定期删除抽样也未覆盖到,会长期占内存。可主动 SCAN 清理,或确认 maxmemory-policy 与定期删除正常。
L2|「LRU 和 LFU 怎么选?」 → 访问模式接近「最近使用即可能再用」用 LRU;有稳定热点、怕被一次性冷扫描冲掉用 LFU。Redis 的 LRU/LFU 都是近似算法(采样),不是全局精确。
L3|「淘汰会不会把正在用的热 key 踢掉?」 → 可能,尤其 LRU + 采样。防护:①lfu;②本地缓存扛热点;③关键 key 不要依赖 Redis 单层;④监控命中率,命中率骤降要排查是否被误淘汰。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出有过期删除和内存淘汰两种东西 |
| 80 分 | 讲清惰性+定期、主流淘汰策略,并能按缓存/业务数据选型 |
| 95 分 | 解释「过期不释放」根因;对比 LRU/LFU;指出 noeviction 对缓存的危害与 maxmemory 留白 |
【关联题】
- 同一知识簇: 第 48 题(内存治理)、第 26 题(雪崩/TTL)、第 33 题(fork 与内存)、第 30 题(大 key 占内存)
- 容量扩展: 第 35 题(Cluster)
【自测】
- 「设了 TTL 但内存不降」最常见原因是什么? 参考答案:惰性删除只在访问时触发;冷 key 残留。还有可能是数据量本身超过容量。
- 纯缓存 Redis 应该用什么淘汰策略? 参考答案:allkeys-lru 或 allkeys-lfu,避免 noeviction 导致写失败。
- noeviction 下内存满会怎样? 参考答案:写命令返回 OOM 错误,读仍可服务。必须让业务处理该错误或提前扩容。
35. Redis 单节点宕机业务就断,要搞高可用(集群方案选型)
【考察内容】Redis 高可用体系是必考大题
【题目】现在 Redis 是单节点,上次宕机所有依赖缓存的业务全部不可用。团队想上高可用方案,听到三个名词:主从复制、哨兵、Cluster。它们各自解决什么问题?数据量大(几百 G)和只求不丢服务两种场景分别怎么选?
【参考答案】Redis 高可用按业务容忍度选型:
- 容量估算(步进):单节点约 10 万 QPS、内存假设 25GB 可用。业务峰值若 15 万 QPS 或数据 40GB,则单节点能力不够,必须分片集群;若只要求“挂了能切”,数据量不大,主从+哨兵即可。故障切换时间目标秒级~30 秒内。
- 方案对比:① 主从复制:读扩展、数据备份,但主挂不能自动切;② 哨兵(Sentinel):自动选主切换,适合中小规模一主多从;③ Cluster 集群:数据分片 + 高可用(主从分片),水平扩展容量与吞吐,是大厂默认。
- 客户端:Cluster 感知客户端(Jedis/Lettuce/Redisson)处理 MOVED/ASK;业务要接受跨 slot 不支持多 key 事务(或用 hash tag)。
- 失败与降级:主从切换瞬间可能短时不可用 → 客户端超时重试+本地缓存兜底;集群节点挂 → 从升主,监控 failover;脑裂/复制延迟 → min-replicas-to-write 等参数;极端时降级为本地缓存/DB 限流。
- 口径:先估 QPS 与数据量决定是否分片,再定切换自动化级别;预案必须演练,不是画架构图。
【原理溯源】
三者是递进关系,解决的问题不同:
方案 解决什么 不解决什么 主从 读扩展、数据备份 主挂自动切换、写扩展、容量 哨兵 主挂自动故障转移 单机内存上限、写扩展 Cluster 容量水平扩展、写扩展、高可用 跨 slot 事务/多 key 原子 为什么主从不能自动切换? 主从只是数据复制关系,没有「谁来判定主挂了、谁来提升从」的协调者。哨兵补上这个角色:多个哨兵独立探测,达到 quorum 后选举新主并通知客户端。
Cluster 为什么用 16384 个 slot? 把 key 空间均匀映射到固定数量的槽,槽再分配到节点。扩容时迁槽而不是重哈希全部 key。CRC16(key)%16384 路由,客户端或代理按槽表转发。16384 是「心跳包大小 vs 槽粒度」的工程折中。
为什么 Cluster 下多 key 操作受限? 不同 key 可能在不同节点,无法在单节点事务里原子执行。解法:
{hashtag}让相关 key 落在同一 slot;或改设计避免跨 key 原子。脑裂为什么危险? 网络分区时旧主仍在收写,哨兵已把从提升为新主;分区恢复后旧主降级,其上「多写的未同步数据」被丢弃——表现为用户写成功后数据消失。防护:
min-replicas-to-write+min-replicas-max-lag,副本不足或延迟过大时拒绝写。
【选型判断树】
要高可用 / 扩展:
├─ 只是读多,写少,数据量单机放得下
│ └─ 主从(若还要自动切 → 哨兵)
├─ 要自动故障转移,数据量 < 单机内存上限
│ └─ 哨兵(3 节点起)
├─ 数据量大(几十 G~几百 G+)或写 QPS 单机不够
│ └─ Cluster(分片 + 每片主从)
│ └─ 需要多 key 原子 → hash tag 或改模型
└─ 不可接受脑裂丢写
└─ min-replicas-to-write / max-lag;关键写走 DB 或强一致组件【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「主从管读扩展,哨兵管自动切换,Cluster 管容量与写扩展」 |
| 0:30–2:30 | 三者机制 | 复制方向与延迟;哨兵 quorum;slot 路由 |
| 2:30–3:30 | 场景选型 | 不丢服务→哨兵;几百 G→Cluster |
| 3:30–4:20 | 坑 | 脑裂、跨 slot、主从延迟 |
| 4:20–5:00 | 收尾 | 「先问数据量和写扩展,再问要不要自动切换」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| slot 数量 | 16384 | Cluster 固定 |
| 哨兵节点 | ≥3,奇数 | quorum 通常 2 |
| 故障判定 | down-after-milliseconds 常 5–30s | 太小易误切 |
| 主从延迟 | 通常 ms 级,网络差可到秒 | 见第 46 题 |
| 单分片内存 | 建议 ≤ 16GB(4.0+ 混合持久化、低写压可放到 32GB) | 同第 33 题口径:控 fork/COW 与恢复时长 |
| 脑裂防护 | min-replicas-to-write=1 | 按副本数调整 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「哨兵和 Cluster 有什么区别?能不能一起用?」 → 哨兵解决「主挂了自动换」,数据仍在单机;Cluster 解决「数据切多片+高可用」。Cluster 自带主从切换,一般不需要再套哨兵。传统哨兵适合数据量可控场景。
L2|「Cluster 下为什么不能随便 MGET 十个 key?」 → 十个 key 可能落在不同 slot/节点,无法单节点原子执行。要么 hash tag 强制同槽,要么客户端拆成多次网络往返(非原子)。
L3|「脑裂具体怎么发生,配置怎么防?」 → 分区中旧主仍可写但不可同步;哨兵已选新主。恢复后旧主数据被丢弃。防:旧主在「同步副本数不足或延迟过大」时拒绝写(min-replicas-to-write / min-replicas-max-lag)。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出主从、哨兵、Cluster 三个名词 |
| 80 分 | 三者解决的问题与选型场景清晰;说清 slot 路由 |
| 95 分 | 讲清脑裂与 min-replicas;跨 slot 限制与 hash tag;能按「数据量 × 写扩展 × 自动切换」三维选型 |
【关联题】
- 同一知识簇: 第 46 题(主从延迟)、第 108 题(一致性哈希——另一种分片思路)、第 48 题(扩容)、第 33 题(持久化)
- 一致性理论: 第 97 题(CAP)
【自测】
- 数据 200GB,单机放不下,该上什么? 参考答案:Cluster,按 slot 分片到多节点,并为每片配主从。
- 只要求「主挂了能自动切换」,数据只有 5GB,选什么? 参考答案:哨兵。数据量可控,核心诉求是自动故障转移。
- Cluster 里
{user:1001}:a和{user:1001}:b为什么能在同一节点? 参考答案:hash tag{user:1001}参与槽计算,相同 tag 的 key 落同一 slot。
36. 多实例扣库存还是超扣了,要用分布式锁(分布式锁实现)
【考察内容】分布式锁是 Java 后端社招必考,考的是“正确性细节”
【题目】系统多实例部署后,用单机 synchronized 锁库存已经不生效,出现超扣。改用 Redis 实现分布式锁后,又出了新问题:锁忘了释放导致死锁、锁被别的线程误删、主从切换后锁丢失。请给出 Redis 分布式锁的正确实现,并指出所有常见坑。
【参考答案】多实例扣库存超扣,用分布式锁 + 条件扣减双保险:
- 容量估算(步进):库存 1000,峰值扣减 QPS 2 万,若靠应用内锁或无锁
read-modify-write,并发下超卖几乎必然。分布式锁把同一库存的并发扣减串行化,代价是吞吐上限被锁内耗时锁死:上限 = 1 ÷ 临界区耗时——临界区 1ms 只有 1000 TPS、5ms 只有 200 TPS(见第 5 题口径),要撑到 2 万 QPS 必须把锁内压到 50µs 以内。而锁内含一次 DB 条件更新(下面第 2 步)根本不可能做到 µs 级,所以同一 key 的扣减必须绕开全局串行:Redis 单线程内用DECR/Lua 原子预扣(无显式锁、由单线程保证串行且每次 µs 级),锁只用于"防重复下单"这类低频判定,不能用来扛 2 万 QPS 的扣减主路径。 - 实现:① Redis
SET lock:key unique NX PX 30000获取;② 业务扣减(优先 DB 条件更新WHERE num>0);③ finally 用 Lua 比对 unique 再 DEL,防误删他人锁;④ Redisson 提供可重入与看门狗续期。 - 为什么锁之外还要条件更新:锁可能失效(进程 GC、网络、时钟)、可能误实现;DB
WHERE num>0是最后权威防线,锁只是降低冲突,不是正确性的唯一来源。 - 失败与降级:锁获取失败 → 快速失败或排队;Redis 锁服务挂 → 降级为 DB 原子扣减+调低限流;业务超时关单要释放占用并回补。
- 口径:分布式锁解决“互斥窗口”,条件更新解决“最终正确”,两者都要有。
【原理溯源】
- 为什么单机锁在多实例下失效?
synchronized只在本 JVM 进程内互斥,多个应用实例各有各的锁对象,对共享库存的临界区毫无保护。分布式锁的本质是:把「谁持有锁」这件事放到所有实例都看得见的地方(Redis/ZK/DB)。 - 为什么加锁必须是
SET NX EX一条命令?SETNX不带过期 → 进程崩溃锁永存(死锁);先SETNX再EXPIRE两步 → 两步之间崩溃仍死锁。Redis 单命令原子,SET key val NX EX seconds一次完成「抢锁+设过期」。 - 为什么解锁要用 Lua「比对 + 删除」? 若先 GET 比对再 DEL,两步之间锁可能已过期并被别人拿到,你的 DEL 会删掉别人的锁。Lua 在 Redis 内原子执行,保证「是我的才删」。
- 为什么 value 必须唯一? 否则无法区分「我的锁」和「别人的锁」,解锁就无法防误删。UUID/线程 ID 是身份凭证。
- 为什么主从切换会丢锁? 主写锁成功但尚未复制到从就宕机,从被提升后没有这条锁 → 其他客户端仍能抢到。这是异步复制的固有问题。RedLock 试图用多节点多数派写缓解,但仍有争议;强正确场景更常用 ZooKeeper/etcd(基于共识日志)。
- Redisson 相对手写的优势在哪? 把正确性细节产品化:原子加锁、Lua 解锁、看门狗续期、可重入、阻塞等待、公平锁选项。手写极易漏掉其中某一项。
【选型判断树】
要分布式互斥:
├─ 精确性要求多高?
│ ├─ 允许极小概率重复 → Redis 单节点/主从锁 + 看门狗(性价比最高)
│ ├─ 资金/库存强正确 → **不要用锁扛主路径**:库存类 2 万 QPS 时第 1 条已判「锁临界区哪怕 1ms 也只有 1000 TPS」,RedLock 五节点多数派单单加锁就 ~0.9ms,反而把吞吐压到约 1100 TPS。正解是「Redis 原子预扣 + DB 条件更新为权威」,锁只做低频防重;ZK/etcd 锁仅用于低频、长临界区、且要求不丢锁的协调场景
│ └─ 仅防重复点击 → 幂等 token 也许就够(第 110 题)
├─ 临界区耗时是否可控?
│ ├─ 可控且短 → 固定 leaseTime
│ └─ 不可控 → 必须看门狗续期
└─ 是否需要可重入/可中断/公平?
└─ 用 Redisson,不要手写【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「分布式锁要把持锁状态放到共享存储;核心是原子加锁、原子解锁、防死锁、防误删」 |
| 0:30–2:00 | 正确实现 | SET NX EX;value=UUID;Lua 解锁 |
| 2:00–3:00 | 三大坑 | 死锁(无过期)、误删(非原子/不唯一)、提前释放(无续期) |
| 3:00–4:00 | 主从丢锁 | 异步复制窗口;RedLock/ZK;业务幂等兜底 |
| 4:00–5:00 | 收尾 | 「主路径走原子预扣+DB 条件更新为权威,Redisson 只做低频防重;强一致不等于上更重的锁」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 默认锁时长(Redisson) | 30s | 看门狗续期 |
| 看门狗周期 | 锁时长 1/3,即 10s | 续期检查 |
| 业务临界区建议 | 上限=1÷临界区耗时:1ms→约 1000 TPS、5ms→约 200 TPS;库存类万级 QPS 需 ≤50µs(含 DB 写不可能做到) | 所以锁扛不了扣减主路径,只能低频防重 |
| RedLock 节点 | 通常 5 个独立主 | 多数派加锁 |
| 等待锁超时 | 业务相关,如 3–10s | 避免无限等 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「为什么 SETNX 不能直接用?」 → 不带过期会死锁;且常见写法「先 setnx 再 expire」非原子。应使用 SET key val NX EX seconds。
L2|「看门狗怎么工作?为什么进程挂了不会死锁?」 → 未显式 leaseTime 时,Redisson 启动后台任务每 10s(30s 的 1/3)检查并续期。进程崩溃 → 看门狗线程消失 → 不再续期 → 锁到期自动释放。
L3|「主从切换锁丢了怎么办?业务上怎么兜底?」 → ①接受 Redis 锁概率性失效,在业务层做幂等/版本号/DB 条件更新(update stock set n=n-1 where n>0);②强一致且低频、长临界区、要求不丢锁才用 ZK/etcd(高并发扣减主路径上它同样受串行化限制,不是「更重所以更强」);③不要把 Redis 锁当成资金安全的唯一防线。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出 SETNX + 过期 + 唯一 value |
| 80 分 | 加锁解锁都原子(SET NX EX / Lua);能指出三大坑 |
| 95 分 | 讲清主从丢锁与业务幂等;能推荐 Redisson 看门狗;不把分布式锁神化 |
【关联题】
- 同一知识簇: 第 37 题(看门狗)、第 38 题(用锁做串行化)、第 110 题(接口幂等——锁的互补手段)
- 底层: 第 35 题(主从/切换)、第 32 题(原子性来源)
【自测】
- 解锁为什么必须 Lua? 参考答案:保证「判断 value 是自己的」和「删除」原子,防止误删他人锁。
- 锁没设置过期时间会怎样? 参考答案:持有者崩溃后锁永存,死锁。
- 判断对错:Redis 分布式锁可以 100% 保证互斥。 参考答案:错。主从切换、时钟漂移、RedLock 争议等都可能导致极小概率失效,强正确要业务幂等兜底。
37. 加了过期时间的锁,业务没跑完锁先没了(看门狗机制)
【考察内容】分布式锁框架实现原理
【题目】给 Redis 锁设置过期时间后,新的麻烦出现了:业务逻辑执行超过锁的过期时间,锁被提前释放,另一个实例拿到锁,两个实例同时改数据。换用 Redisson 后这个问题消失了——它说的“看门狗”机制到底是怎么工作的?
【参考答案】加了过期时间的锁,业务没跑完锁先释放——需要看门狗续期 + 安全解锁:
- 容量估算(步进):锁 TTL 若设 5s,而业务长事务偶发 10–30s(慢 SQL、下游抖动),则有概率锁先过期,其他实例进入造成临界区并发。假设高峰 100 个持锁事务、5% 超过 TTL,则约 5 次/窗口 的互斥失效风险,对库存/账户不可接受。
- 看门狗机制:Redisson Watchdog 默认每 TTL/3 续期一次(默认锁 TTL 30s → 每 10s 检查客户端存活并续期回 30s);业务结束手动 unlock 停止续期。原理:持有者还活着就延长互斥,死亡则不续,锁最终过期,死锁可自愈。
- 工程要点:① 不要用固定 TTL 硬扛长业务,也不能 TTL 过大导致死锁时间过长;② 可重入锁支持嵌套;③ 解锁必须校验持有者(Lua);④ 关键路径尽量缩短持锁范围,锁内别调不确定的慢下游。
- 失败与降级:看门狗所在实例宕机 → 锁到期自动释放(正确性优先于活性);续期失败告警并让业务失败回滚;锁竞争激烈时用分段锁/库存分桶降低热点;Redis 锁不可用 → DB 条件更新兜底。
- 口径:锁的正确性 = “互斥窗口覆盖业务临界区”,TTL 静态设置很难覆盖动态 RT,所以要自动续期。
【原理溯源】
- 锁时长与业务时长的矛盾: 过期时间是死锁的保险丝,但写死了就会「业务未完成锁先没了」。看门狗用动态续期化解:锁时长只作为「持有者失联后的最长存活时间」,而不是「最长持有时间」。
- 为什么续期间隔取锁时长的 1/3? 要容忍一次续期失败(网络抖动/Redis 短暂不可用)后仍有机会重试。30s 锁、10s 一续,失败一次还有 20s 缓冲。间隔太密浪费 CPU,太疏增加「来不及续就过期」风险。
- 为什么进程宕机不会死锁? 看门狗是进程内的后台线程;进程死则线程死,无人续期,锁在最多一个锁时长后自动释放。这是「租约(lease)」模型:持有者必须持续证明自己活着。
- 为什么指定 leaseTime 后看门狗不工作? 显式租约意味着用户自己承诺「业务一定在 T 内结束」。框架不再越权续期,避免「用户以为 T 后必释放,结果被无限续」的语义冲突。
- 底层为什么用 Hash + Lua? Hash 存
field=线程唯一标识 → value=重入次数,支持同线程可重入加锁(计数+1)与解锁(计数-1,归零才删)。加锁/续期/解锁的「读-判-写」都在 Lua 里原子完成。
【选型判断树】
锁过期策略:
├─ 临界区耗时能否精确预估?
│ ├─ 能且稳定 → 显式 leaseTime(略大于 P99 业务耗时)
│ └─ 不能(下游 RPC/DB 可能抖)→ 看门狗自动续期(默认)
├─ 是否必须在某时刻一定释放?
│ └─ 是(对账窗口等)→ leaseTime + 业务超时熔断
└─ 实现方式
└─ 优先 Redisson;手写需自己实现续期线程与 Lua【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「问题是固定过期 vs 业务不确定时长;解法是租约+续期」 |
| 0:30–2:00 | 机制流程 | 默认 30s;每 10s 续期;unlock 停止;宕机不续 |
| 2:00–3:00 | 为什么不死锁 | 进程内线程消失 → 不续 → 锁到期 |
| 3:00–4:00 | leaseTime 分支 | 显式指定则不续期;语义差异 |
| 4:00–5:00 | 收尾 | 「看门狗把 TTL 从『业务时限』变成『失联时限』」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 默认锁时间 | 30s | Redisson |
| 续期周期 | 10s(1/3) | 可容忍一次失败 |
| leaseTime | 用户指定 | 关闭看门狗 |
| 重入计数 | Hash value | 归零才真正解锁 |
| 续期失败处理 | 下一周期再试;过期则锁失效 | 可告警 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「看门狗是任务执行完才释放锁吗?」 → 不是。unlock 时释放;若一直不 unlock,看门狗会一直续,直到进程退出。它不感知「业务是否逻辑结束」,只感知「持有者是否还持有/还活着」。
L2|「手动指定 leaseTime 会怎样?还要看门狗吗?」 → 不续期。必须自己保证业务在 leaseTime 内完成,否则锁提前释放导致并发。不确定耗时就不要指定,用默认看门狗。
L3|「续期时 Redis 挂了怎么办?会不会两把锁?」 → 续期失败会重试;若锁最终过期,其他客户端可加锁,此时若原业务还在跑,就可能出现并发——这是 Redis 锁的固有窗口。业务上用幂等/条件更新兜底,不要只靠锁。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道看门狗会自动延长锁时间 |
| 80 分 | 说清 30s/10s、宕机不续、leaseTime 关闭续期 |
| 95 分 | 讲清租约模型与 1/3 间隔的原因;Hash+Lua+可重入;指出续期失败窗口与业务兜底 |
【关联题】
- 同一知识簇: 第 36 题(分布式锁实现)
- 互补机制: 第 110 题(幂等)
【自测】
- 为什么续期间隔是锁时长的 1/3? 参考答案:为续期失败预留重试窗口,平衡开销与安全。
- 指定了 leaseTime,业务又超时了怎么办? 参考答案:锁会按 leaseTime 释放,可能并发。要么别指定,要么业务内做超时控制与幂等。
- 判断对错:看门狗可以防止死锁,所以锁可以不设过期。 参考答案:错。看门狗本身依赖「有过期时间且会续期」;不设过期就没有租约语义,进程崩溃会死锁。
38. “先改库还是先删缓存”——顺序之争(更新顺序)
【考察内容】缓存一致性最经典一问
【题目】团队对缓存更新顺序吵起来了:A 说先删缓存再改库,B 说先改库再删缓存。两种顺序分别会踩什么坑(并发下会出现什么脏数据)?最终推荐哪种?为什么?
【参考答案】“先改库还是先删缓存”按一致性权衡选:
- 常见顺序:① 先更 DB,再删缓存(推荐默认):窗口期最多是“旧缓存被读到”,TTL 兜底;② 先删缓存,再更 DB:删除后到更新完成前,读 miss 会把旧 DB 数据写回缓存,不一致窗口更容易拉长,一般不推荐单用;③ 延迟双删 / binlog 订阅:作为①的强化。
- 容量与影响估算(步进):写 QPS 低、读 QPS 高时,策略选择影响“展示错误率”。假设读 2 万 QPS,不一致窗口 100ms,则约 2000 次读可能看到旧值;把窗口压到 10ms(快速删+短 TTL)错误曝光降一个量级。
- 例外:写多读少且极度怕脏读 → 可短暂加分布式读写锁(代价高);缓存为权威的计数等 → 用 Redis 原子命令,不走“删缓存”心智。
- 失败与降级:删除失败 → 重试队列 + TTL;仍不一致 → 交易/资金读 DB 权威值;监控差异抽样。
- 口径:互联网业务的务实选择是 “更新 DB + 删除缓存 + TTL + 失败重试”;强一致字段直接不缓存。
【原理溯源】
- 先删缓存再更 DB 的脏读时序:
- W:DEL cache;
- R:miss → 读 DB 得旧值 → 回填 cache=旧值;
- W:UPDATE DB=新值。 若 2 发生在 3 之后(或回填晚于 3),缓存长期为旧值,且直到 TTL 或下次写才会修——不一致窗口可达分钟级。
- 先更 DB 再删缓存的时序:
- W:UPDATE DB=新值;
- R:命中 cache=旧值(极短窗口);
- W:DEL cache → 下次读回填新值。 不一致只存在于 1–3 之间,通常毫秒级;但"删除后必然收敛"要加两个前提——删除真的成功(失败要靠重试/binlog 补偿),且 2 的读没有在 3 之后才回填(否则旧值会一直脏到 TTL,见第 28 题回填竞态)。
- 为什么删除优于更新? 更新缓存要「写入一个具体值」。并发写时:A 计算出旧值准备写缓存,B 已写新值,A 再写就把新值覆盖成旧值。删除不写值,让下一次读从 DB 拿当时的真实值,从机制上避开覆盖。
- 为什么这不是强一致方案? 跨 DB 与 Redis 无法在性能可接受时做分布式事务。目标是把不一致窗口压到毫秒级并保证必然自愈(删除或 TTL)。要强一致:关键读直连 DB,或写后读主。
- 和「延迟双删」的关系: 若采用「先删再更」,延迟双删可补救回填竞态;若采用「先更再删」,双删通常非必须,重点是删除失败补偿(重试/binlog)。顺序问题与失败问题是两个维度。
【选型判断树】
更新顺序:
├─ 默认互联网场景
│ └─ 先更 DB → 再删缓存(Cache Aside)
├─ 若因历史原因必须「先删再更」
│ └─ 加延迟双删 + 短 TTL + binlog
├─ 删除可能失败
│ └─ 重试队列 / Canal 异步删
└─ 强一致要求(钱、库存终态)
└─ 不要赌缓存顺序:读主库/直连 DB + 版本号【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 结论先行 | 「推荐先更库再删缓存;先删后更窗口更大」 |
| 0:30–2:20 | 两个时序 | 分别画出并发读回填导致的脏数据路径 |
| 2:20–3:20 | 为什么删不更新 | 覆盖问题;删除让回填来自最新 DB |
| 3:20–4:20 | 失败与强一致 | 删除失败补偿;强一致绕开缓存 |
| 4:20–5:00 | 收尾 | 「选窗口短、可自愈的顺序,而不是理想化地追求零窗口」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 先更后删不一致窗口 | 毫秒级 | 可接受 |
| 先删后更脏值存活 | 可达一个 TTL(分钟) | 不可接受的来源 |
| 删除失败重试 | 3–5 次 + 异步补偿 | 必须有 |
| binlog 纠正延迟 | 10ms–1s | 真相源驱动 |
| 兜底 TTL | 1–10 分钟 | 自愈 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「先更库再删缓存,删之前用户读到旧价,可以吗?」 → 一般业务可以,窗口毫秒级。价格展示场景若不可接受,应缩短 TTL、写后主动删、或关键读直连 DB。
L2|「更新缓存为什么不好?」 → 并发写会旧值覆盖新值;且更新逻辑若依赖旧值计算(如 +1),还可能算错。删除+回填把「写缓存」变成「读时从 DB 取真值」。
L3|「有没有办法做到顺序无关的强一致?」 → 有代价更高的路:①缓存带版本,只允许新版本覆盖;②分布式锁串行化「更库+删缓存」;③直接读 DB。多数业务不值得,用「先更后删+补偿+TTL」已足够。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道推荐先更库再删缓存 |
| 80 分 | 能分别描述两种顺序的并发脏数据时序 |
| 95 分 | 讲清「删 vs 更新」的原因;区分顺序问题与失败问题;强一致场景给绕行方案 |
【关联题】
- 同一知识簇: 第 27 题(Cache Aside)、第 28 题(延迟双删)、第 29 题(四种策略)、第 49 题(监控)
- 锁: 第 36 题(串行化手段)
【自测】
- 画出「先删缓存再更 DB」产生长期脏缓存的三步时序。 参考答案:DEL cache → 并发读 miss 读 DB 旧值并回填 → UPDATE DB 新值。若回填在更新后,缓存长期旧。
- 为什么不推荐「更新缓存」? 参考答案:并发下旧值可能覆盖新值;删除让下次读从 DB 回填最新值。
- 推荐顺序下删除失败怎么办? 参考答案:重试 + binlog 异步删 + 短 TTL 自愈。
39. 每个用户每分钟最多 10 次,多实例怎么统一计数(分布式限流)
【考察内容】分布式限流的工程实现
【题目】业务规则:每个用户每分钟最多调用某接口 10 次,超了直接拒绝。系统是多实例部署,单机计数不共享。怎么用 Redis 实现多实例统一的计数限流?滑动窗口怎么落地?为什么用 Lua?
【参考答案】分布式限流计数:多实例统一额度,必须共享状态 + 原子操作:
- 容量估算(步进):每用户每分钟最多 10 次,假设 DAU 500 万、高峰 10% 同时活跃 = 50 万用户在线,每个用户窗口一个计数 key,Redis 内存 ≈ 50万×100B ≈ 50MB,完全可行。QPS:高峰读写计数可能 5–10 万级,接近单节点上限时要分片。
- 实现:① Redis
INCR+EXPIRE(固定窗口,有临界突刺,实现最简单;但两条命令非原子——INCR 后崩溃会让 key 永不过期、该用户被永久限流,要么用SET key 1 EX 60 NX+ 后续INCR,要么一段 Lua,要么只在 INCR 返回 1 时补 EXPIRE 并接受残留风险);② 滑动窗口 ZSet(score=时间戳,更平滑);③ Lua 保证“读-判-写”原子;④ 云/中间件 Sentinel 也可下发集群规则。 - 本地+分布式组合:先本地粗限流(保护 Redis),再分布式精确总量;避免每个请求都打 Redis。
- 失败与降级:Redis 挂 → 降级单机限流(阈值=总量/实例数),宁可略紧不可全开;时钟问题影响窗口 → 以 Redis 服务端时间为准;误杀调阈值动态配置。
- 清理:TTL 设为窗口长度;key 设计
rate:{user}:{minute}控制基数。
【原理溯源】
- 为什么单机计数不行? 负载均衡下每个实例只看到自己那份流量,N 个实例各自限 10 次,用户实际能打 10N 次。限流阈值必须定义在用户全局上,计数器就要共享存储——Redis 单线程原子计数是天然选择。
- 固定窗口为什么有边界突刺? 窗口按「整分钟」切开。用户在 59.5s 用满 10 次,在 00.0s 新窗口又是 10 次——1 秒内 20 次。根因是窗口对齐与流量突发不匹配,不是计数错误。
- 滑动窗口如何消除突刺? 记录每次请求的时间戳,统计的是「最近 60s 内」而不是「当前这一个整分钟」。任何 60s 滑动区间内都不会超过阈值。ZSet 的 score=时间戳,
ZREMRANGEBYSCORE删窗外,ZCARD计窗内。 - 为什么必须 Lua? 一次判定需要「删旧 → 数量 → 决定 → 添加」多步。若在应用里多次调用 Redis,两次调用之间其他请求会插入,导致超卖限额。Lua 在 Redis 内单线程原子执行整段逻辑,等价于一个临界区。
- 令牌桶为什么更适合带突发的业务? 固定/滑动窗口限制的是「次数」,令牌桶限制的是「平均速率 + 桶容量突发」。桶满可突发放行,空则匀速恢复——更接近「每分钟 10 次但允许前 3 秒用 5 次」这类真实产品语义。
- 为什么还要网关层限流? 应用内 Redis 限流已经走到业务代码,连接建立、鉴权、序列化都发生了。网关/入口层能更早拒绝,保护下游一切资源;两层形成纵深。
【选型判断树】
限流方案:
├─ 规则是「固定时间窗内次数」且能接受边界突刺
│ └─ INCR + EXPIRE(最简单,**但两条命令非原子:INCR 后进程崩溃会留下永不过期的 key,计数从此卡死不降**——要么 `SET key 1 EX win NX` 起步+INCR,要么 Lua 里 INCR 后判返回值==1 才补 EXPIRE)
├─ 不能接受边界突刺
│ └─ ZSet 滑动窗口(Lua 原子)
├─ 规则是「平均速率,允许一定突发」
│ └─ 令牌桶 / 漏桶(Lua 维护时间与余量)
├─ 维度是用户/接口/IP?
│ └─ key 设计带上维度;避免大 key(用户极多时按 user%shard 分桶)
└─ 入口层
└─ 网关限流 + 应用内限流双层【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「多实例必须共享计数;先讲固定窗口,再讲它为什么不够」 |
| 0:30–1:40 | 固定窗口 | INCR+EXPIRE;边界突刺例子(59s+00s) |
| 1:40–3:00 | 滑动窗口 | ZSet + 时间戳 + Lua 原子删/数/添 |
| 3:00–4:00 | 令牌桶与工程 | 平均速率;网关+应用双层 |
| 4:00–5:00 | 收尾 | 「选型看是否容忍突刺、要不要突发容量;原子性靠 Lua」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 常见用户限流 | 10–100 次/分钟 | 按业务 |
| 固定窗口键 TTL | 60s(窗口长度) | 与窗口对齐 |
| 滑窗 ZSet member | 每请求一个 UUID | 窗外自动删 |
| 滑窗内存 | 单用户约 N×几十字节 | 阈值低时很小 |
| Lua 优势 | 一次 RTT 完成判定 | 避免竞态 |
| 网关限流 | 全局/接口维度 | 先于业务 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「为什么不用数据库计数?」 → 限流在最热路径上,DB 的连接与事务开销扛不住;Redis 内存原子计数延迟亚毫秒级,是共享计数的标准件。
L2|「ZSet 滑窗会不会内存无限涨?」 → 每次都删窗外 member,窗口内数量 ≤ 阈值。若攻击者狂刷,达到阈值后不再 ZADD,内存有上界。仍建议配 maxmemory 策略。
L3|「Lua 脚本报错或超时怎么办?限流会不会放行失控?」 → 脚本执行失败应 fail-close(当作超限)或本地降级限流,不能 fail-open 放任。同时监控脚本错误率;Redis 不可用时网关层仍应有限流兜底。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能用 INCR+过期实现固定窗口 |
| 80 分 | 指出边界突刺,给出 ZSet 滑窗 + Lua |
| 95 分 | 讲清原子性为何必须 Lua;令牌桶与窗口的语义差;多层限流与 fail-close |
【关联题】
- 同一知识簇: 第 31 题(INCR/ZSet)、第 32 题(原子性)、第 50 题(组件内置)
- 保护下游: 第 23 题(缓存穿透时的限流兜底)、第 48 题(内存)
【自测】
- 固定窗口的边界突刺是什么?举例。 参考答案:分钟交界前后各用满阈值,导致极短时间内放行双倍流量。如 59s 与 00s 各 10 次。
- 滑动窗口为什么用 Lua? 参考答案:删旧、计数、添加必须原子,否则并发请求会互相插入导致超限。
- 用户量极大时,限流 key 有什么隐患? 参考答案:key 数量爆炸与大 ZSet。可按用户散列分桶、缩短 TTL、或对非核心维度降级为固定窗口。
40. 订单 30 分钟未支付要自动关单(延时任务实现)
【考察内容】延时任务实现是电商场景最高频设计题之一
【题目】需求:订单 30 分钟未支付自动取消,已支付的不取消。你准备用 Redis 实现延时队列(不想引入额外组件),怎么设计?和“定时扫描数据库”“MQ 延迟消息”比,各自的优劣是什么?
【参考答案】订单 30 分钟未支付自动关单:延时触发 + 幂等 + 可靠:
- 容量估算(步进):假设日订单 100 万,支付转化 70%,则约 30 万单/日 需要超时关单,平均约 3.5 单/s,高峰支付前创建订单峰值可能 500–2000 TPS,对应 30 分钟后有集中关闭波峰(若整点营销创建,波峰可更高)。
- 实现方案:① 延迟队列:RocketMQ 延迟消息 / RabbitMQ TTL+死信 / 时间轮;② 定时任务扫表:
WHERE status=CREATED AND expire_at<now,批量关单(要分片与索引);③ Redis ZSet+轮询(score=到期时间戳,ZRANGEBYSCORE 取到期)——单 key 装不下本题量级:按峰值 500–2000 TPS×1800s =90 万~360 万成员,只算 30% 未支付也有 27 万~108 万,正撞第 30 题「集合元素 >10 万即严重」的大 key 红线,且每秒轮询同一 key 会造成分片热点;必须按到期时间分片(如expire_slot=ts%64拆 64 个 ZSet)并给「多实例抢同一批」的抢锁消费(Lua ZREM 返回被取走的成员)。 - 选型:延迟消息实时性好、解耦;扫表实现简单、最终可靠,常作为兜底。但选型要先按题干前提分两支,别一上来就把 MQ 当首答:题干已排除额外组件(「不想引入 MQ」)→ 首答就是 Redis ZSet 分片+扫表兜底,MQ 只作「若能引入会更实时解耦」的对比与后续演进;若允许引入组件 → 两者可并存(消息主路径 + 扫表补偿)。
- 失败与降级:消息丢失 → 扫表兜底;消费失败重试+死信告警;关单必须幂等(唯一状态迁移);关单同时要回补库存/优惠券;用户刚好在关单瞬间支付 → 状态机校验,支付回调以订单状态为准,冲突则退款或人工。
- 监控:延迟关闭成功率、超时未关堆积量、回补库存对账差异。
【原理溯源】
- 为什么不能用普通定时 Thread.sleep? 应用重启/发布线程就没了,延时任务丢失。分布式下每个实例各有各的定时器,还会重复触发。所以延时必须落在共享、可恢复的存储或消息系统上。
- ZSet 延时队列的因果链: 写入时 score=触发时间戳 → 时间到后
ZRANGEBYSCORE key 0 now取出 → 业务处理 →ZREM。Redis 保证按 score 有序,O(logN+M) 取到期元素。注意:取出与处理非事务,要「处理成功再删」或「先删再处理+失败回队」,配合幂等。 - 为什么过期监听不可靠? key 的物理删除由惰性/定期触发,过期事件在真正删除时才发,可能延迟;Redis 重启/事件总线丢消息也会丢事件。它不是为可靠投递设计的,只适合「丢了也无所谓」的场景。
- 为什么 MQ 延迟消息更适合生产? Broker 持久化、投递有重试与死信、消费位点可管理。RocketMQ 支持定时/延迟消息(新版本可任意时刻),RabbitMQ 用 TTL+死信模拟。代价是引入组件与延迟粒度限制。
- 为什么扫表仍然要留着当兜底? 任何异步通道都可能丢消息、积压或故障。扫表以 DB 状态为真相(create_time < now-30min 且 status=待支付),是最笨也最不容易「漏单」的一层。双保险:MQ 管时效,扫表管正确。
- 时间轮是什么? 把时间分成轮次格子,定时器挂到对应格,指针转动触发。O(1) 添加/取消,适合进程内海量短延时。但同样面临进程退出丢失,需配合持久化。
【选型判断树】
延时关单:
├─ 能否引入 MQ?
│ ├─ 能 → 延迟消息为主 + DB 扫表兜底;**不能(题干即此前提:不想引入额外组件)→ Redis ZSet 分片 + 扫表兜底 才是推荐项**,MQ 方案只作对比与后续演进,别把题干排除的手段当首答
│ └─ 不能 → Redis ZSet 队列 + 扫表兜底
├─ 延迟精度要求?
│ ├─ 秒级足够 → 上述皆可
│ └─ 毫秒级且进程内 → 时间轮(需持久化)
├─ 丢了是否可接受?
│ └─ 不可接受(资金/库存)→ 必须以 DB 状态扫表兜底
└─ 已支付校验
└─ 关单前再查一次支付状态,防已支付被关【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「延时任务要可恢复、可去重;不能靠本地 sleep」 |
| 0:30–2:00 | Redis ZSet | score=到期时间戳;按 expire_slot=ts%64 分片(单 key 装不下本题量级);轮询取到期;多实例用 Lua ZREM 原子抢占再处理 |
| 2:00–3:00 | 为何不用过期监听 | 删除才发事件、可能丢 |
| 3:00–4:00 | MQ 与扫表 | MQ 可靠时效;扫表保正确 |
| 4:00–5:00 | 收尾 | 「生产双保险:MQ 主通道 + 扫表兜底;关单前复查支付」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 订单支付超时 | 常见 15–30 分钟 | 按品类 |
| ZSet 轮询间隔 | 1s | 与精度/DB 压力权衡 |
| RocketMQ 延迟 | 旧版 18 个等级;新版可任意 | 版本差异 |
| 扫表间隔 | 1–5 分钟 | 兜底 |
| 扫表 SQL | status=待支付 and create_time < 阈值 | 索引要建对 |
| 重复关单 | 必须幂等 | 支付成功竞态 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「ZSet 方案订单处理失败怎么办?」 → 保留在队列中重试(重试次数上限后进死信/人工);或先 ZREM 再处理,失败则重新 ZADD。必须与业务关单幂等配合。
L2|「为什么不能只靠 Redis 过期事件?」 → 事件与物理删除耦合,可能延迟或丢失;Redis 故障期间更无保障。订单关闭是资金相关状态机,不能建立在 best-effort 上。
L3|「用户在第 29 分 59 秒支付成功,关单任务已经扫到了订单怎么办?」 → 关单前再查一次支付状态(或用状态机 CAS:update order set status=已取消 where id=? and status=待支付)。已支付则放弃关单。支付回调与关单的竞态要用条件更新化解。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能用 ZSet score=时间做延时队列 |
| 80 分 | 讲清取出/删除/失败重试;对比扫表与 MQ |
| 95 分 | 指出过期监听不可靠;按题干前提选型(排除 MQ 时答 ZSet 分片+Lua 抢占+扫表兜底);处理支付竞态(CAS/复查) |
【关联题】
- 同一知识簇: 第 31 题(ZSet)、第 29 题(一致性)、第 110 题(幂等)
- MQ 侧: 第 86 题(Kafka)可对比消息可靠投递思想(本章业务用 RocketMQ 更常见)
【自测】
- ZSet 延时队列中,为什么强调「处理成功再 ZREM」? 参考答案:单消费者时处理成功再删可重试(先删后处理若失败任务就丢了);但多实例并行消费必须反过来——用 Lua
ZREM原子抢占(返回被取走的成员)再处理,否则两个实例同时 ZRANGEBYSCORE 取到同一批就是重复消费。两种做法都以消费端幂等为前提。 - 过期监听适合什么场景? 参考答案:丢了影响很小的会话清理、缓存统计等,不适合订单关闭。
- 生产上如何防止「已支付订单被自动关闭」? 参考答案:关单前复查支付状态 + DB 条件更新(仅待支付可改为已取消)+ 支付回调与关单幂等。
41. 游戏积分榜要实时更新、秒级查 TopN(排行榜设计)
【考察内容】排行榜是 ZSet 应用最经典场景题
【题目】游戏要做实时积分排行榜:玩家分数随时变化,榜单要实时刷新,还要支持 Top100 秒级查询、分页查看、按名次查人。怎么用 Redis 实现?日榜和总榜分别怎么处理?
【参考答案】实时积分榜:ZSET 为主,热点与容量要打散:
- 容量估算(步进):假设玩家 500 万上榜,ZSET 每元素约 64–100B → 单榜内存约 320–500MB(5e6×64B≈320MB;与第 31 题【关键数字】表「ZSet member 约 20–80B、亿级约几 GB」同一量级口径,本题表只写"member 数十 B"),属于大 key,可能阻塞节点。更新 QPS 若高峰 5 万,单 key ZADD 会打满单分片。TopN 查询(如 Top100)极频繁,读 QPS 可能 10 万级。
- 设计:① 分榜:按赛季/分区分片;日榜与总榜要分开设计:日榜按天建 key(
rank:day:{yyyymmdd})+当日 TTL、跨天自然滚动不需清理;总榜独立 long-lived key 且写入与日榜同源(避免两套计数对不上);两者都从同一份明细聚合作业回填,榜的口径(含不含退款/去刷量)必须一致,客户端路由;② 写聚合:本地攒批后定时 ZADD,降低中心 ZSET 压力——攒批周期必须 ≤ 题干允许的刷新粒度:若「实时刷新」指秒级(≤1s),1–5s 的攒批就已违约,要缩到 100–200ms 或改逐条 ZADD(此时中心 ZSet 写压力=峰值更新量,只能靠按分片摊开扛);若业务能接受分钟级,则攒批 1s+TopN 缓存 1–5s 才成立。先声明刷新口径,再选攒批参数;③ 热点玩家更新可分段后再合并;④ TopN 缓存(每 1–5s 刷新 Top100 到另一个小 key)。 - 查询:
ZREVRANK用户名次、ZREVRANGETopN;分页榜用范围命令。 - 失败与降级:Redis 挂 → 展示上次缓存榜或 DB 快照;更新延迟可最终一致;恶意刷分走风控;清理赛季数据用 UNLINK。
- 一致性:分数来源服务端计算,防客户端伪造;并列分数用时间戳/用户 id 打破平局保证稳定排序。
【原理溯源】
- 为什么 ZSet 是排行榜标配? 三个原生能力正好命中:①按 score 排序(跳表);②ZINCRBY 原子加减分;③ZREVRANGE/ZRANK 高效取区间与名次。用 List/Hash 都要自己排序,O(N) 且难保原子。
- 复杂度为什么能「秒级」? ZINCRBY 为 O(logN);ZREVRANGE top100 为 O(logN+100)。百万用户也是亚毫秒到毫秒级,与「全量排序再截取」有本质区别。
- 日榜为什么要独立 key? 时间维度不同,排名不可混。
rank:day:20260911每天新建,TTL 可设 2 天后自动清;总榜单独一把 key。避免「用一个 key 再过滤日期」的复杂查询。 - 同分如何「先到先得」? ZSet 只按 score 排。把 score 编码成
分数*K + 时间因子,让同分者时间早者 score 更优。代价是 double 精度:53 位有效整数,K 过大或分数过大时低位时间被丢掉,排序错乱。必须评估量级,或拆两个字段用应用层二次排序。 - 为什么要落库快照? Redis 是易失层。榜单若承载运营奖励,必须周期性(如每 5 分钟/每日终)把 TopN 或全量写入 DB,供审计与恢复。这是「缓存做加速,DB 做真相」。
【选型判断树】
排行榜:
├─ 用户规模
│ ├─ 万级以下 → ZSet 单 key 轻松
│ ├─ 百万级 → ZSet 仍可,注意大 key 访问模式(只取 TopN,不全量 ZRANGE)
│ └─ 亿级+每人都要名次 → 考虑分片/离线预计算,不要对全量频繁 ZRANK
├─ 榜单维度
│ └─ 日/周/总用不同 key;周期 key 可 TTL
├─ 同分规则
│ └─ 仅按分 → 默认;先到先得 → score 编码(注意 double 精度)
└─ 持久化
└─ 周期快照落 DB + 从库/集群防丢【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「ZSet:score=分数,原生排序+原子加减」 |
| 0:30–2:00 | 核心命令 | ZINCRBY / ZREVRANGE / ZRANK;复杂度 |
| 2:00–3:00 | 日榜总榜 | 日榜按天拆 key+当日 TTL(跨天自然滚动);总榜 long-lived、与日榜同源 |
| 3:00–4:00 | 同分与精度 | score 编码;double 53 位陷阱 |
| 4:00–5:00 | 落地 | 落库快照;大榜只取 TopN |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| TopN | 常 100/200/500 | 产品定义 |
| ZREVRANGE top100 | O(logN+100) | 毫秒内 |
| 亿级 ZSet 内存 | 约数 GB | member 数十 B |
| double 整数精度 | 53 位 ≈ 9e15 | score 编码上限 |
| 日榜 TTL | 当日滚动(跨天自然失效);若要回看历史榜,另按 {date} 存快照 1–3 天 | 榜本身不需清理;总榜不设 TTL,须与日榜同源 |
| 快照落库 | 每 1–15 分钟或日终 | 防丢 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「用 List 能做排行榜吗?」 → 能存有序数组但不能按 score 原子更新某人名次,任何分数变化都要重排,O(N) 且并发难控。必须 ZSet。
L2|「分数相同怎么排?」 → 按 member 的字典序(ZSet 排序是 score→member,确定的,不是插入序,也不是实现分支)。要「先到先得」就把时间编进 score,注意 double 精度不要爆。
L3|「玩家掉线时分数更新失败,榜不准了怎么办?」 → 分数变更应走可靠路径:先落 DB/消息,再更新 Redis;或以 DB 为源定期重放。榜是展示层,账目正确优先于实时。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道用 ZSet 和 ZREVRANGE |
| 80 分 | 讲清 ZINCRBY/ZRANK、日榜按天 key+TTL 与总榜 long-lived 同源、口径一致(含不含退款/去刷量)、落库 |
| 95 分 | 同分编码与 double 精度陷阱;海量用户只取 TopN;以 DB 为账目真相 |
【关联题】
- 同一知识簇: 第 31 题(数据结构选型)、第 30 题(大 key)、第 42 题(GEO 也基于 ZSet)
- 原子性: 第 32 题
【自测】
- 实时加分用什么命令?为什么原子? 参考答案:ZINCRBY。Redis 单线程执行,命令天然原子。
- 为什么日榜要单独 key? 参考答案:时间维度隔离,避免混榜;可用 TTL 自动清理历史。
- score 编码时间戳时最大的坑? 参考答案:double 只有 53 位整数精度,编码后超过约 9e15 会丢精度导致乱序。
42. App 要“查看附近的人/门店”,按距离排序(LBS 实现)
【考察内容】LBS 场景实现
【题目】社交 App 要上线“附近的人”,本地生活 App 要“附近的门店”,都是“给我当前位置,返回按距离排序的附近对象”。数据量百万级、更新频繁,怎么用 Redis 实现?地理位置怎么存储和计算距离(两问都要答:存储见下;距离由服务端算——GEOSEARCH ... WITHDIST 或 GEODIST 返回米(内部按球面/Haversine 计算),GEOPOS 取回经纬度再自算也可以;geohash 前缀只能判邻近,不能当距离用)?
【参考答案】附近的人/门店:GEO + 范围检索 + 索引兜底:
- 容量估算(步进):假设城市门店 20 万、附近用户查询峰值 1 万 QPS。
GEOSEARCH(或 GEORADIUS)复杂度与范围内元素相关;若 3km 内有 500 家店,单次返回可控。全城所有点放一个 key 会成大 key(20万×~60B ≈ 12MB+),需按城市/网格分 key。 - 方案:① Redis GEO(geohash 存储):
GEOADD更新坐标,GEOSEARCH按距离排序取 TopN;② 数据量大或复杂筛选 → PostGIS/MySQL 空间索引/专用 LBS 服务,Redis 缓存热门网格结果;② 地理网格预计算:按geohash前缀分桶,只查邻近 9 个桶。 - 性能:结果可短 TTL 缓存;用户位置抖动导致缓存命中低,可网格量化(四舍五入到网格中心)。
- 失败与降级:Redis 挂 → 降级查 DB 空间索引+限流;无坐标 → 默认城市热门列表;隐私合规要脱敏与授权。
- 口径:先估点密度与 QPS,决定用纯 GEO 还是网格+缓存+DB。
【原理溯源】
- 为什么不用「全量算距离再排序」? 百万点每次请求都要算距离,CPU 与延迟不可接受。GeoHash 先把二维邻近变成一维前缀邻近,用索引快速圈出候选,再精确算距离——典型的「粗筛 + 精滤」。
- GeoHash 如何把「附近」编码出来? 经纬度各在区间上二分,交替取 bit,拼成字符串。精度越高(字符串越长)网格越小。同一网格内的点前缀相同,前缀相同 ≈ 距离近。边界上的邻居可能前缀不同,所以要查周围 8 个网格。
- 为什么 GEO 底层是 ZSet? GeoHash 编码成 52 位整数作为 score,member 为元素。ZSet 的有序性支撑范围查询,现有集群/持久化能力直接复用。这也是
GEOADD的成员其实是 ZSet member 的原因。 - 为什么按城市拆 key? 单 key 塞下全国点位会变成超大 key:内存倾斜、单节点瓶颈、范围查询慢。按
geo:city:xxx切分后,单 key 元素量可控,还能按业务路由到不同分片。 - 位置高频更新怎么办? 「附近的人」类数据时效性强:写入时带 TTL(如 15 分钟在线),用户下线自动过期;或定期清理陈旧坐标。否则离线用户会长期占榜、污染结果。
【选型判断树】
LBS:
├─ 数据量与更新频率
│ ├─ 十万级、更新不高 → Redis GEO 单城 key
│ ├─ 百万级 → 按城市/网格分 key
│ └─ 千万+、复杂多边形/筛选 → ES/PostGIS 等空间索引
├─ 查询形态
│ ├─ 半径 TopN → GEORADIUS / GEOSEARCH COUNT
│ └─ 多条件过滤 → 先 GEO 粗筛再业务过滤,或换 ES
└─ 时效
└─ 在线场景 TTL + 频繁 GEOADD 更新【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「Redis GEO:GeoHash 一维化 + ZSet 范围查」 |
| 0:30–2:00 | 命令与结构 | GEOADD / GEOSEARCH;底层 ZSet score=geohash |
| 2:00–3:10 | GeoHash 原理 | 二分编码、前缀邻近、边界 8 格 |
| 3:10–4:20 | 工程 | 按城市分 key 防大 key;TTL 清理 |
| 4:20–5:00 | 扩展 | 海量上 ES/PostGIS |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| GeoHash 精度 | 约 11 位 → 米级 | Redis 内部 52 bit |
| 单 key 建议 | 城市级,避免全国一把 | 防大 key |
| 在线 TTL | 5–30 分钟 | 附近的人 |
| GEOSEARCH COUNT | 20–100 | 产品页大小 |
| 边界问题 | 查 9 宫格 | 相邻前缀不同 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「GEO 的底层数据结构是什么?」 → ZSet。score 是 GeoHash 编码的 52 位整数,member 是元素。因此 GEO 命令的「圈候选」语义落在 ZSet 之上——但 ZSet 的 score 是 52 位 geohash,它的序不等于距离的序;真实距离由服务端按球面/Haversine 算(GEOSEARCH ... WITHDIST/GEODIST 返回米),geohash 前缀只能判邻近、不能当距离用。
L2|「GeoHash 有什么经典问题?」 → 边界:两个物理相邻的点可能分属不同前缀。解法是查询目标及其周围 8 个格子,合并结果再按真实距离排序。
L3|「全国门店都放一个 GEO key 行不行?」 → 不推荐。会形成超大 key,单节点瓶颈、内存倾斜、慢查询。应按城市/大区拆分,必要时再哈希分片。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道 GEOADD/GEORADIUS,并能说清距离由服务端算(GEOSEARCH ... WITHDIST/GEODIST 返回米) |
| 80 分 | 讲清 GeoHash 前缀思想与 ZSet 底层 |
| 95 分 | 边界 8 格;按城市分 key 防大 key;知道何时该上 ES 等空间索引 |
【关联题】
- 同一知识簇: 第 31 题(ZSet)、第 30 题(大 key)、第 41 题(排行榜)
- 容量: 第 48 题、第 35 题(Cluster 分片)
【自测】
- GEO 底层是什么结构? 参考答案:ZSet,score 为 GeoHash。
- 为什么 GeoHash 要查周围网格? 参考答案:边界上物理相近的点前缀可能不同,需合并邻格避免漏点。
- 「附近门店」为什么建议按城市拆 key? 参考答案:避免单 key 过大导致内存倾斜与慢查询,并便于按区域路由。
43. 购物车刷新不丢、登录态多机共享(Redis 业务应用)
【考察内容】Redis 业务应用面
【题目】两个需求:①用户加购的商品,换设备刷新后购物车要还在;②用户登录后,请求被负载均衡到任意一台机器都要认识他。分别怎么用 Redis 实现?用什么数据结构?和 JWT 方案比怎么选?
【参考答案】购物车不丢 + 登录态共享:用 Redis 作为跨实例业务状态:
- 容量估算(步进):假设 500 万用户购物车,平均 8 个商品,hash 估算每车 500B–2KB → 内存约 2.5–10GB,需要集群分片。登录态 Token:DAU 500 万、每 session 1KB → 约 5GB,可设 TTL 减少。峰值读写可能数万 QPS,Redis 合适。
- 设计:① 购物车:
hash cart:{uid},field=skuId,value=数量/选中状态;合并离线与登录后的车用服务端逻辑;② 登录态:JWT 可无状态,或 Redis 存 session/refresh token;③ 多机共享的验证码、防重 Token 也可放 Redis。 - 一致性与持久:购物车是用户资产级体验数据,可短中期持久:定期快照到 DB/异步落库;Redis 淘汰策略选对业务友好的(带 TTL 的仅过期车可清)。
- 失败与降级:Redis 挂 → 购物车降级为 DB/本地(可能丢最近修改),登录态优先切换备用集群;关键下单动作服务端再校验库存,不信仅缓存的状态。
- 口径:这类场景 Redis 存的是“会话/购物体验状态”,允许短暂不一致,但要做好内存规划与备份。
【原理溯源】
购物车为什么必须「服务端持久」? 本地 Cookie/Storage 换设备即丢,且无法做服务端勾稽(凑单、限购)。放 Redis 后以 userId 为键,任意设备/实例读同一份数据。
为什么用 Hash 而不是 String JSON? 「用户 → 多个 SKU → 数量」与 Hash 的 field/value 同构。HINCRBY 可对单个商品原子 ±1,避免「GET 整包 → 改 → SET」的并发覆盖与带宽放大。
Session 为什么要进 Redis? 应用扩容后 Session 若只在本机内存,负载均衡会把用户打到不认识他的机器(登录态丢失)。共享存储让所有实例看到同一 Session。
JWT 为何「无状态」? 用户信息+签名放在令牌里,服务端验签即可,不必查共享存储。好处是水平扩展简单;代价是签发后难主动失效(除非黑名单),且令牌体积大、不能存敏感明文。
Redis Session vs JWT 的本质取舍:
维度 Redis Session JWT 服务端状态 有 无(或仅黑名单) 主动踢下线 容易(删 key) 难(需黑名单) 多端/开放 API 一般 更自然 依赖 Redis 可用性 高 低 为什么购物车也常要「未登录合并」? 未登录加购在本地,登录后合并到服务端 cart:{userId}。合并策略要幂等(以 SKU 为 field 做 HSETNX/HINCRBY),防止重复提交翻倍。
【选型判断树】
登录态放哪:
├─ 传统 Web、需要服务端踢人/会话管理
│ └─ Redis Session
├─ 移动端 / 开放 API / 多语言客户端
│ └─ JWT + 短有效期 + refresh
├─ 需要强制下线的 JWT
│ └─ JWT + Redis 黑名单(token 版本号/jti)
└─ 购物车
└─ 服务端 Hash(以 userId 为 key),未登录合并【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「两个问题都是『状态放哪才能多机共享』」 |
| 0:30–1:40 | 购物车 | Hash cart:user;HINCRBY 原子;对比 String JSON |
| 1:40–3:00 | Session | 存 Redis 共享;TTL |
| 3:00–4:10 | JWT 对比 | 无状态 vs 可踢下线;移动端选择 |
| 4:10–5:00 | 收尾 | 「Web 会话用 Redis Session;跨端用 JWT+黑名单」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| Session TTL | 30 分钟–7 天滑动 | 按安全策略 |
| JWT access 过期 | 15 分钟–2 小时 | 短令牌 |
| refresh 过期 | 7–30 天 | 换新 access |
| 购物车 field 数 | 通常 < 100 | 大车可分页 |
| 购物车保留 | 登录用户长期;可转 DB | Redis + 落库 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「购物车用 String 存 JSON 有什么问题?」 → 无法原子改单商品数量;并发下互相覆盖;每次改都整包读写,带宽与延迟差。Hash 按 field 操作才是正确模型。
L2|「JWT 泄露了怎么办?」 → 短有效期 + refresh 轮换;敏感操作二次验证;对已泄露 jti 加黑名单。无法像 Session 那样「全局立刻失效」,这是无状态的代价。
L3|「Redis Session 挂了是不是所有人都掉线?」 → 是。需要 Redis 高可用(哨兵/集群);极端时可降级到本地会话或强制重新登录。可用性设计要包含这一依赖。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 购物车 Hash;Session 存 Redis |
| 80 分 | 讲清结构匹配与共享存储原因,并能对比 JWT |
| 95 分 | 主动踢下线、黑名单、未登录合并、Redis 可用性依赖 |
【关联题】
- 同一知识簇: 第 31 题(Hash)、第 35 题(Session 依赖高可用)、第 45 题(本地 vs 分布式)
- 安全相邻: 第 110 题(幂等,与防重复加购相关)
【自测】
- 为什么分布式环境不能把 Session 只放本机? 参考答案:负载均衡会把用户路由到无此 Session 的实例,登录态丢失。
- JWT 最大的工程痛点是什么? 参考答案:签发后难以主动失效,需要黑名单/版本号才能支持强制下线。
- 购物车加购为什么用 HINCRBY 而不是 HSET? 参考答案:HINCRBY 原子累加数量,支持「再加一件」;HSET 是覆盖为固定值。
44. 布隆过滤器会误判还不能删,生产上怎么用(误判与删除)
【考察内容】布隆过滤器的工程细节
【题目】你们用布隆过滤器挡缓存穿透,但它有两个天生缺陷:会误判(把不存在的说成存在)、不支持删除。生产环境里这两个问题分别怎么处理?误判会造成什么后果?有支持删除的变体吗?
【参考答案】布隆过滤器会误判、不能删:生产这样用:
- 容量估算(步进):布隆误判率公式与容量相关:预计 1 亿元素、误判率 1%,约需 ~958Mbit ≈ 120MB;若要 0.1% 则内存显著上升。插入容量越满,误判率越高,必须按 实际元素数 1.5–2 倍 预留。
- 用法:① 穿透防护:合法 ID 预加载,请求先过滤,误判只会放行少量不存在 ID,再由空值缓存挡住;② 去重场景(爬虫 URL):误判会漏抓(当成已存在),要评估业务能否接受。
- 误判与删除:标准布隆不能删;要删除可选 Counting Bloom(每格改计数器,空间约为普通布隆的 4 倍)或 Cuckoo Filter(
CF.DEL支持真删除,且空间通常比 Counting Bloom 更省)。注意 RedisBloom 模块里只有CF.*(Cuckoo)支持CF.DEL,BF.*不提供删除;选型时把「是否要删」当成决定性的分叉点。 - 工程方案:Redis 模块 RedisBloom/Redis Stack;自建则分片布隆;定期重建(数据版本化,双 buffer 滚动切换)。
- 失败与降级:布隆异常 → 降级为空值缓存+限流;重建期间双布隆并行;监控误判率与填充率,填充过高提前扩。
【原理溯源】
- 误判方向为什么「安全」? 标准布隆只有假阳性(不存在→可能判存在),没有假阴性(存在→不会判不存在)。假阳性后果 = 多一次 DB 查询;假阴性后果 = 合法请求被拒,业务错误。所以工程上把「不存在」方向当成可优化项,把「存在」方向当成正确性底线。
- 误判率为何随数据增多而上升? 置 1 的位越来越多,随机 k 位全为 1 的概率上升。必须按目标 n 和 p 预计算 m、k;若实际数据超过设计容量,p 会迅速恶化——布隆过滤器对超容量非常敏感。
- 为什么标准布隆不能删? 一位被多个 key 共享。清 0 可能抹掉其他 key 的存在证据 → 引入假阴性。这是正确性红线,所以宁可不可删。
- 计数布隆的代价: 每位变计数器(如 4 bit),空间涨 4 倍+;计数回绕仍可能出错。用空间换可删。
- 布谷鸟过滤器的机制差异: 每个元素有两个候选位置,插入时若冲突则「踢出」已有元素到其另一候选位。支持删除、空间往往更省、查询更快;但插入踢出有上限,接近满时可能插入失败,需扩容或重建。
- 为什么生产仍要空值缓存? 布隆放行后 DB 可能仍无此数据(误判或真实不存在)。空值缓存把这次穿透也吸收掉,避免「误判导致的重复打 DB」。
【选型判断树】
过滤器选型:
├─ 数据几乎只增不删(商品库、用户库)
│ └─ 标准布隆 + 定期重建
├─ 需要删除(会下架、会注销)
│ ├─ 删除不频繁 → 计数布隆 或 定期重建
│ └─ 删除频繁且要更省空间 → 布谷鸟过滤器
├─ 数据量不确定/会长期超设计
│ └─ 可扩容的分布式布隆(RedisBloom)或分层
└─ 任何情况
└─ 空值缓存兜底误判【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「两个缺陷:误判只在存在方向;标准版不可删」 |
| 0:30–1:40 | 误判 | 后果=多一次 DB;可接受;用 m/k 控制 |
| 1:40–3:00 | 删除 | 为何不能删;计数布隆;布谷鸟 |
| 3:00–4:10 | 工程 | 重建、冷热分层、空值兜底 |
| 4:10–5:00 | 收尾 | 「按增删模式选变体,用空值吃掉误判」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 误判率目标 | 0.1%–1% | 再低空间陡增 |
| m 估算 | m=-n·ln(p)/(ln2)² | 设计容量 n |
| k 估算 | k=(m/n)·ln2 | 通常 5–10 |
| 重建周期 | 小时–天级 | 视删除频率 |
| 计数布隆空间 | ≈ 标准的 4–8 倍 | 4bit 计数 |
| 布谷鸟装载因子 | 通常 < 80%–90% | 过高插入失败 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「误判会让用户看到错误商品吗?」 → 不会。误判只放行一次查询,DB 无数据则仍返回空。错误展示来自「缓存了错误值」,不是布隆误判。
L2|「数据删了布隆怎么办?」 → 标准布隆重建;计数布隆减计数;布谷鸟直接删除。生产常见是「每日全量重建 + 双过滤器原子切换」。
L3|「重建过滤器期间请求怎么办?」 → 新旧双过滤器并行:重建用新位数组,完成后原子替换指针;期间旧过滤器继续服务。避免「删了再建」的空窗导致全量穿透。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道误判和不可删 |
| 80 分 | 说清误判方向与后果;给出计数/布谷鸟/重建 |
| 95 分 | 讲清为何不能删(假阴性红线);容量设计与超容风险;双过滤器切换与空值兜底 |
【关联题】
- 同一知识簇: 第 24 题(穿透)、第 23 题(三防)、第 50 题(组件开关)
- 相关算法: 第 31 题(位运算/BitMap 可对比)
【自测】
- 布隆过滤器误判的方向性是什么? 参考答案:只会把不存在误判为存在(假阳性),不会把存在误判为不存在。
- 为什么需要删除时优先考虑布谷鸟而不是标准布隆? 参考答案:标准布隆清位会引入假阴性;布谷鸟原生支持删除且空间往往更优。
- 超过设计容量会怎样? 参考答案:置位变满,误判率快速上升。必须按 n、p 预留容量或使用可扩容实现。
45. 同一份数据,放进程内还是放 Redis(缓存位置选型)
【考察内容】缓存选型判断力
【题目】同样是缓存,有的数据适合放应用进程内(Caffeine),有的必须放 Redis。给下面的数据分类并说明理由:商品详情、用户 Session、库存、排行榜、系统字典配置、热点新闻。各自放哪一层?为什么?
【参考答案】数据放进程内还是 Redis,看共享范围 × 一致性 × 失效成本:
- 容量估算(步进):假设热点数据集 2GB、读 QPS 10 万。若 50 个实例都放本地全量,内存 50×2GB=100GB,成本高但 RT 最好(<1ms);放 Redis 一份即可,但每次读增加网络往返(同机房 0.1–1ms 量级,含序列化与应用开销常到 1–2ms)且 Redis 要扛 10 万 QPS(需集群/多副本)。
- 选本地缓存:数据只读或极低变更、可接受秒级陈旧、热点集中(配置、字典、热点商品基础信息)、实例数×本地内存可控。
- 选 Redis:多实例必须看到同一份状态(购物车、session、分布式锁、排行榜)、数据量超过单机内存、需要集中限流与原子运算。
- 常见组合:本地缓存 + Redis 多级:本地挡 30%–70% 读,Redis 挡大部分剩余,DB 只回源少量;变更走失效广播。
- 失败与降级:本地缓存陈旧 → 短 TTL+版本号;Redis 挂 → 本地缓存延长 TTL 兜底展示,关键写直连 DB+限流;本地内存 OOM 风险要限额与淘汰。
- 口径:“谁能容忍不一致、谁来承担失效协调成本” 决定位置;状态共享优先 Redis,纯热点读优先本地。
【原理溯源】
本地 vs 分布式的本质差异是「共享语义」和「一致性传播」:
维度 Caffeine 本地 Redis 分布式 延迟 微秒级 毫秒级(网络) 作用域 单实例 集群全局 容量 受堆内存限制 可水平扩展 一致性 多实例各自一份,易不一致 全局一份,易管理 故障 随进程生灭 需高可用 为什么字典/配置适合本地? 几乎只读、变更极少、所有实例可以短暂不一致。本地缓存把最热路径的 Redis 网络 RTT 去掉;变更时广播失效或拉长本地 TTL+版本号。
为什么 Session/库存/排行榜必须 Redis? 它们的正确性依赖「所有实例看到同一份数据」。放本地会出现:A 机已登录 B 机不认识;A 机扣了库存 B 机还可扣——直接正确性事故。
为什么热点新闻常「本地+Redis」两级? 读 QPS 极高但一致要求低。本地挡 90%+ 流量,Redis 提供跨实例共享的中层,DB 只扛 miss。这是用「可容忍的秒级不一致」换延迟与 Redis 压力。
多级缓存的一致性代价: 更新时要失效多层:先删 Redis,再广播各实例失效本地,或依赖短本地 TTL 自愈。层数越多,一致窗口越难控——所以不是所有数据都该上多级,只给极热且可容忍旧值的数据用。
【选型判断树】
缓存放哪层:
├─ 数据是否要求跨实例一致?(Session/库存/锁/计数)
│ └─ 是 → 必须 Redis(或 DB),禁止只放本地
├─ 是否极热且允许秒级旧值?(详情页/字典/热文)
│ └─ 是 → 本地 Caffeine(短 TTL) + 可选 Redis 层
├─ 数据量是否很大?
│ └─ 是 → Redis/DB,本地只缓存热点子集
└─ 变更频率
└─ 高频变更 → 少用本地层;低频变更 → 本地+广播失效【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「选型四维:共享性、延迟、容量、一致性」 |
| 0:30–2:20 | 逐个分类 | 字典/热文→本地;Session/库存/榜→Redis;详情可两级 |
| 2:20–3:30 | 为什么 | 共享语义决定能不能本地;热路径决定该不该本地 |
| 3:30–4:20 | 多级代价 | 失效传播、不一致窗口 |
| 4:20–5:00 | 收尾 | 「要共享上 Redis,要极致延迟且可旧才上本地」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| Caffeine 延迟 | < 1ms,通常微秒级 | 无网络 |
| Redis 延迟 | 0.1–1ms 量级 | 同机房 |
| 本地 TTL | 1s–5min | 越短越一致 |
| 本地容量 | 建议堆的 5%–20% | 防 Full GC 压力 |
| 多级命中 | 本地可挡 90%+ 热点流量 | 极热场景 |
| 失效广播 | 秒级 | 或靠 TTL |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「Caffeine 和 HashMap 有什么区别?为什么要用它?」 → Caffeine 提供基于 W-TinyLFU 的高性能淘汰、过期、统计,内存可控。裸 HashMap 无淘汰会 OOM,也无 TTL。
L2|「本地缓存怎么失效?」 → ①短 TTL 自愈;②更新时消息广播各实例 invalidate;③版本号,读到旧版本则丢弃。权衡复杂度与一致要求。
L3|「为什么库存绝不能只放本地缓存?」 → 多实例各持旧库存会导致超卖——这是资金/履约级事故。库存的「全局唯一真相」必须在共享存储(Redis 预扣 + DB 事务)。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能区分本地和 Redis 各自适用 |
| 80 分 | 按共享性/延迟/一致性给六类数据分类并说明 |
| 95 分 | 讲清多级缓存一致性代价;给出失效手段;强调库存类禁止纯本地 |
【关联题】
- 同一知识簇: 第 17 题(多级缓存设计)、第 119 题(三级一致性)、第 13 题(热 Key 本地化)、第 27 题(Cache Aside)
- 性能: 第 32 题(网络 vs 内存)
【自测】
- 系统字典配置放哪?为什么? 参考答案:本地 Caffeine 为主。只读、变更少、允许短暂不一致;可加广播失效。
- 用户 Session 放本地为什么不行? 参考答案:负载均衡到其他实例会丢失登录态,必须共享存储。
- 多级缓存最大的工程代价是什么? 参考答案:更新时的失效传播与一致窗口变大,需要广播或短 TTL。
46. 刚下单的用户刷新页面查不到订单(主从延迟)
【考察内容】读写分离的一致性问题
【题目】Redis 做了主从读写分离后出现新问题:用户刚加购物车,立刻刷新,购物车是空的——从库还没同步完。主从同步有延迟,怎么保证“刚写的马上能读到”?有哪些方案、各自代价是什么?
【参考答案】刚下单刷新查不到:主从/读写分离延迟问题:
- 容量估算(步进):假设写主库下单 1000 TPS,读打到从库。主从延迟常见 几十毫秒到几秒,极端(大事务/网络抖动)可达几十秒。用户刷新间隔往往 <1s,延迟 500ms 就会有明显“查不到”投诉。
- 方案:① 写后读主:加购成功后短时间窗口(如 3–5s)内,该用户的购物车查询强制走主库,或用 session 粘滞;② 关键查询路由:购物车、「我的」这类写完就要看的读走主库,商品列表、推荐位等只读流量走从库;③ 结果缓存写后失效:加购后主动删除该用户的购物车缓存并短暂绑定主库;④ 降低复制延迟——先分清是哪套主从:题干是 Redis 主从,Redis 没有并行复制开关,能做的是拆大 key、避免慢命令阻塞复制积压、按
master_repl_offset差值监控、必要时换异步/减少 replica;若换成 MySQL 主从才是并行复制(slave_parallel_workers/按组提交)、避免大事务、从库规格与网络。⑤ 各方案代价配齐:①写后读主=主库读压力上升、全员读主会退化为单点;②关键查询路由=路由逻辑复杂、易漏配;③写后缓存失效=引入缓存与 DB 的一致性成本、缓存挂时要有兜底;④降延迟=只能缩窗、消不掉竞态。 - 业务兜底:下单响应直接把订单号与状态返回前端,不依赖再次查从库;提示“支付/订单状态可能稍有延迟”。
- 失败与降级:主库压力大时不能全员读主 → 只对“写后 3 秒内 + 同用户”读主;延迟告警,超阈值自动切路由策略。
- 口径:这是复制延迟与路由策略问题,不是缓存 bug;用“写后读路径可控”换一致性体验。
【原理溯源】
- 为什么「刚写的读不到」? 主从复制是异步的:主库执行写并返回客户端成功时,从库可能还没收到/执行该命令。因果链:异步复制 → 读从库可能落后 → 写后立刻读从 = 经典「读己之写」不一致。
- 为什么不能默认「全部读主」? 那等于放弃读写分离的意义(读扩展)。要区分数据与路径:写后短窗口的同一用户会话读主,其余读从——这是「会话粘滞读主」的思想。
- 半同步为什么能缩小窗口? 主库等至少一个从库收到并写入 relay log、回 ACK 后再返回(AFTER_SYNC 下不等回放),写成功的定义从「本地落盘」升级为「已被一个从库持久保存」。注意它保证的是不丢,不是从库已可读到——从库对外可见仍需回放;代价是写 RTT 增加一个网络往返,吞吐下降。
- 为什么「写后写缓存」有补偿作用? 写路径若把最新值同时写入 Redis,读路径优先读缓存,则不依赖从库是否同步。缓存成为写读之间的「高速道」。代价是引入缓存一致性问题(删失败、过期)。
- 和 MySQL 读写分离的同一性: 问题本质相同(异步复制 + 读从),解法同构但有分工:「读到旧值」只能靠路由(写后读主、会话粘滞、缓存中转)或干脆取消读写分离;半同步解决的是另一个问题(主库宕机丢已提交写),它 AFTER_SYNC 下不等从库回放,读从库照样可能是旧值——别拿它当本方案的解法;延迟检测摘除防的是「越慢越读」的正反馈。
【选型判断树】
写后读不到:
├─ 业务能否接受秒级旧?
│ ├─ 能 → 监控延迟 + 自然自愈(多数场景)
│ └─ 不能(订单页、支付结果、购物车)
│ ├─ 同用户短窗口 → 写后读主 / 会话内强制主库
│ ├─ 全局都要新 → 取消读写分离(统一读主);**别用半同步解这题**——半同步只保证主库不丢已提交写,AFTER_SYNC 下不等从库回放,读从库照样可能是旧值(见上面【原理溯源】)
│ └─ 可用缓存中转 → 写后写缓存,读缓存优先
└─ 运维
└─ **Redis 主从:监控 `master_repl_offset` 差值**,超阈值摘读权重(`Seconds_Behind_Master` 是 MySQL 侧指标,别套到本题语境)【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「根因是异步复制延迟,不是 bug」 |
| 0:30–2:00 | 机制 | 主写从读;复制滞后;读己之写问题 |
| 2:00–3:30 | 方案 | 读主窗口、写后缓存、延迟监控与摘除(半同步只用于「防丢数据」,不解决读旧值) |
| 3:30–4:20 | 取舍 | 扩展 vs 一致;不是所有读都要强一致 |
| 4:20–5:00 | 收尾 | 「按会话/数据关键性分流读主读从」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 典型主从延迟 | 常见几十毫秒~几秒;极端(大事务/网络抖动)可达几十秒 | 告警阈值要按这个分布定,不能只按「异常才秒级」 |
| 写后读主窗口 | 1–5 秒 | 或会话内标记 |
| 半同步开销 | 写延迟 +1 RTT | 吞吐下降 |
| 延迟告警 | > 1s 关注,> 5s 摘除 | 按业务 |
| 缓存中转 TTL | 30s–5min | 写后可见性 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「怎么实现写后读主?」 → ①业务标记:本次请求/会话带「刚写」标志,DAO 路由主库;②中间件 hint(ShardingSphere 等);③按 userId 粘滞短时间。
L2|「主从延迟为什么会突然变大?」 → 大事务/大 key 同步、从库机器负载、网络抖动、RDB 重传。要监控 master_repl_offset 差值并告警。
L3|「Redis 集群模式下还有这个问题吗?」 → 有。Cluster 分片仍有主从,异步复制问题同构。业务上的「写后读主/缓存中转」策略依然适用。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道有主从延迟,建议读主 |
| 80 分 | 讲清异步复制成因,给出读主窗口/写后缓存/延迟监控与摘除(能把半同步正确归位到「防丢数据」是加分) |
| 95 分 | 按数据关键性分流;写后缓存补偿;能联系 MySQL 读写分离的同类问题与读写路由代价 |
【关联题】
- 同一知识簇: 第 35 题(主从/集群)、第 27 题(缓存一致性)、第 29 题(策略)
- 理论: 第 97 题(CAP)
- 多级: 第 45 题
【自测】
- 「刚写的读不到」根因是什么? 参考答案:主从异步复制存在延迟,从库尚未应用该写。
- 写后读主的代价是什么? 参考答案:主库读压力上升,读写分离收益下降;只应短窗口/关键路径使用。
- 判断对错:上了读写分离就一定能提高一致性。 参考答案:错。读写分离提高的是读扩展,往往降低一致性(异步延迟)。
47. 大促前缓存要提前备好,重建时不能挤爆 DB(预热与重建风暴)
【考察内容】缓存生命周期管理
【题目】大促前,核心商品数据需要提前从数据库加载到缓存(预热),否则活动一开始就是缓存穿透。预热怎么做?如果缓存又过期了,多个请求同时发现缓存为空、同时回源重建数据库,怎么防止这种“重建风暴”?
【参考答案】缓存预热与重建风暴:大促前把热点数据提前装进缓存:
- 容量估算(步进):假设热点商品 10 万条,每条 2KB,共约 200MB,预热数据量本身不大;但若上线后 5 万 QPS 同时 miss,回源 DB 会形成风暴。预热目标:开赛前热点命中率 >95%,回源压到 <2500 QPS。若从零重建且不加限制,DB 可能在几秒内被打满。
- 预热方法:① 启动时批量从 DB/备份加载热点列表(按访问统计排序);② 大促前人工/任务预导;③ 灰度切流,实例先预热完成再接流量;④ 逻辑过期/互斥重建,避免热点同时 miss。
- 防重建风暴:同 key 只放行 1 个线程重建(互斥/singleflight),其余等待或读兜底;10–50 并发是接口维度的回源上限,不是同 key 的上限,两个闸门别混写;TTL 随机化;本地缓存先挡一层;重建任务分批限速,避免拖垮从库。
- 失败与降级:预热任务失败 → 不直接全量放流,保留限流并告警;预热数据与 DB 不一致 → 短 TTL + 主动失效;预热拖垮从库 → 限速加载、分批、错峰执行;开赛瞬间命中率仍低 → 临时只保核心接口。
- 验证:预热后压测 miss 路径;监控启动后命中率爬坡时间与 DB QPS 曲线。
【原理溯源】
- 为什么大促必须预热? 活动流量是「已知陡增」。若冷启动,所有热点 key 第一次访问都 miss,瞬时回源 = 人造雪崩。预热把 miss 成本提前、摊到低峰期。
- 预热数据从哪来? 不能拍脑袋:①离线统计历史 TopN;②运营活动清单;③实时预测(加购、收藏)。预热对象必须是「即将到来的热点」,而不是全量。
- 重建风暴的因果链: 热点 key 过期 → 第一秒 5 万请求同时 miss → 若无合并,5 万次查 DB → 连接池打满。与击穿同一问题,发生在「预热失效之后」尤其危险。
- 互斥锁 / singleflight / 逻辑过期如何对症?
- 互斥锁:跨进程,只放一个回源;
- singleflight:进程内合并同 key 并发,适合单机热点;
- 逻辑过期:取消失效瞬间,旧值顶住、异步重建。 三者可组合:进程内 singleflight + 跨进程 Redis 锁。
- 为什么预热 TTL 也要随机? 同一批预热若 TTL 相同,会在同一时刻集体过期,把雪崩推迟到那一刻。
base + random仍是必做。 - 为什么预热要分批执行? 一次性从 DB 拉全量热点会把预热本身变成 DB 尖峰。按批次+限速,避免「为了防雪崩先打出一个雪崩」。
【选型判断树】
预热与重建:
├─ 活动前
│ ├─ 确定热点清单 → 分批预热写 Redis
│ ├─ TTL = base + random
│ └─ 预热任务本身限速,防打挂 DB
├─ 活动中 key 过期
│ ├─ 单机并发 → singleflight
│ ├─ 多机并发 → Redis 互斥锁
│ └─ 可容忍旧值 → 逻辑过期
└─ 兜底
└─ DB 限流 + 降级;本地缓存【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「预热把 miss 摊到低峰;重建风暴是过期瞬间的并发回源」 |
| 0:30–2:00 | 预热怎么做 | 热点来源、分批限速、TTL 随机 |
| 2:00–3:30 | 防重建风暴 | 锁 / singleflight / 逻辑过期 |
| 3:30–4:20 | 组合与监控 | 进程内合并 + 跨进程锁;回源 QPS 告警 |
| 4:20–5:00 | 收尾 | 「生命周期:预热—运行—过期重建—降级,每段都要有主」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 预热提前量 | 30 分钟–2 小时 | 视数据量 |
| 预热批次 | 1000–10000 key/批,间隔可控 | 防 DB 尖峰 |
| 预热 TTL | 活动时长 + buffer + random | 覆盖活动 |
| 锁 TTL | 3–10s | 覆盖一次回源 |
| singleflight | 同 key 仅 1 次回源 | Go 常用 |
| 回源告警 | 回源 QPS 突增 | 及时发现风暴 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「预热的数据源不准怎么办?」 → 预热只是降低 miss 率,不是 100% 命中。运行时仍要靠击穿/穿透防护兜底;可滚动更新热点清单。
L2|「singleflight 和 Redis 锁的区别?」 → singleflight 合并本进程内同 key 并发;Redis 锁解决跨进程。多实例部署两者常叠加。
L3|「预热写 Redis 挂了一半,活动开始会怎样?」 → 半冷启动:部分热点 miss,回源压力集中。应预热完成校验(抽样命中率)、失败重跑,并准备降级开关。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道提前加载热点、用锁防并发回源 |
| 80 分 | 讲清热点来源、分批、TTL 随机;锁/逻辑过期/singleflight |
| 95 分 | 意识到预热自身可能打挂 DB;组合进程内+跨进程合并;有校验与降级 |
【关联题】
- 同一知识簇: 第 25 题(击穿)、第 26 题(雪崩)、第 48 题(容量)、第 23 题(三防)
- 锁: 第 36 题;组件:第 50 题
【自测】
- 为什么预热 TTL 也要加随机? 参考答案:避免同批 key 同时过期,造成二次雪崩。
- singleflight 解决什么范围的问题? 参考答案:单进程内相同 key 的并发回源合并;跨进程仍需分布式锁。
- 预热任务如何避免自己打挂 DB? 参考答案:分批、限速、低峰执行,必要时从离线宽表而非在线库读取。
48. Redis 内存涨到 90%,写入开始报错(内存治理)
【考察内容】容量治理与运维
【题目】监控显示 Redis 内存使用率持续上涨,已到 90%,部分写入开始报 OOM 错误。业务不能停,你怎么处理?先做什么、后做什么?淘汰策略、扩容、大 key 治理、数据分层怎么排优先级?
【参考答案】Redis 内存涨到 90%、写入报错:扩容与治理并行:
- 容量估算(步进):实例 8G,90% = 7.2G。假设日增 500MB 且无淘汰/无 TTL,不足 2 天就满。需立即判断:是真业务增长,还是过期未删/无 TTL/大 key/碎片。
- 紧急处理:① 开/确认
maxmemory-policy(缓存常用allkeys-lru);② 清理过期与临时 key、UNLINK 大 key;③ 业务侧临时降级(关非必要缓存);④ 只读或限制写入保护实例——用这一步之前要先对题干「业务不能停」表个态:若「不能停」不允许拒写,就不能拿只读保实例,改按 key 前缀只停低价值写入(日志/统计类回写)、核心写走条件更新+排队,同时紧急扩内存/加从库分摊读;若允许短时只读,则只读是最快止血,但要给出时长上限与恢复判据(内存回落到安全线、evicted/slowlog 归零)。两种口径都得写,别让「保护实例」变成隐性停服务。 - 根因治理:补齐 TTL;拆分大 key;数据分级:热数据 Redis,冷数据 DB/SSD 存储;监控碎片率;无界增长结构(list 无 trim)要加上限。
- 扩容:垂直升配或水平 Cluster 分片;迁移采用在线扩分片,客户端需支持。
- 失败与降级:内存满且 noeviction → 写报错,应用要捕获并降级走 DB/本地缓存;扩容过程搬迁抖动 → 低峰执行+限流;淘汰导致命中率下降 → DB 限流保护。
【原理溯源】
- 为什么 90% 就开始出事? maxmemory 逼近后:若 noeviction,写失败;若开淘汰,则可能误踢热点。同时 RDB/AOF 重写、主从全量同步需要额外内存,高水位下极易雪上加霜。经验上应把 maxmemory 设为物理内存的 50%–75%,给 fork/COW/复制留白。
- 为什么先分「垃圾」还是「真多」? 处置完全不同:垃圾(无 TTL、已过期未删、错误 key 前缀)应清理;真多(业务数据合法增长)应扩容或分层。误判会导致「清完又满」或「盲目扩容」。
- 为什么清理必须 SCAN 分批? KEYS 会阻塞单线程;大批量 DEL 大 key 同样阻塞。SCAN + UNLINK 分批是安全清理的标准动作。
- 为什么淘汰策略是应急而不是根治? 淘汰牺牲缓存命中,DB 压力上升,可能引发二级故障。它买时间,不买容量。根治仍是扩容、拆大 key、缩 TTL、冷热分层。
- Cluster 扩容为什么能不中断? 新节点加入 → 迁移部分 slot → 客户端感知拓扑更新。单个 slot 迁移期间该槽短暂不可用,但对全局是渐进的。这是水平扩容量的正解,而不是加内存条(单机有上限且 fork 成本高)。
- 冷热分层的经济性: Redis 内存贵。把低频数据放更便宜的介质(SSD/磁盘库/对象存储),Redis 只留热数据。用「命中率换成本」。
【选型判断树】
内存 90%+:
├─ 1. 应急止血
│ ├─ 确认 maxmemory-policy(缓存→allkeys-lru/lfu)
│ ├─ 拒绝非核心写入 / 降级
│ └─ 开始 SCAN 清理明显垃圾 key
├─ 2. 定性
│ ├─ 大 key 主导 → 拆分/UNLINK(第 30 题)
│ ├─ 僵尸/无 TTL 堆积 → 清理 + 规范写入
│ └─ 合法增长 → 3
└─ 3. 根治
├─ 数据量可预期 → Cluster 加分片
├─ 冷数据多 → 缩 TTL / 下沉
└─ 监控阈值 80%/90% 常态化【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「先止血再定性再根治;区分垃圾堆积与容量不足」 |
| 0:30–1:40 | 应急 | 淘汰策略、限写、分批清理 |
| 1:40–3:00 | 定性原因 | 大 key / 僵尸 key / 真增长 |
| 3:00–4:20 | 根治 | 扩容、分层、TTL 规范 |
| 4:20–5:00 | 收尾 | 「淘汰买时间,扩容与分层买容量」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| maxmemory 建议 | 物理内存 50%–75% | 留 fork/复制 |
| 预警/告警 | 80% / 90% | 可调 |
| 清理批次 | SCAN count 100–1000 | 配 UNLINK |
| 大 key | String>10KB,集合>5000 | 治理 |
| slot 迁移 | 在线渐进 | Cluster 扩容 |
| 淘汰策略 | 缓存 allkeys-lru/lfu | 业务数据禁止 |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「能不能直接 FLUSHALL?」 → 绝对不能。会清掉全部数据,事故扩大。只清理确认无用的 key 前缀,且分批。
L2|「Cluster 扩容会不会中断服务?」 → 新节点迁 slot 时,迁移中的槽短暂停顿,整体可控。客户端要支持拓扑刷新(MOVED/ASK)。
L3|「如何防止再次冲到 90%?」 → ①容量规划与增长预测;②写入规范(必 TTL、限大小);③大 key 周期扫描;④分层与缩 TTL;⑤阈值告警与自动扩容预案。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道开淘汰、清 key |
| 80 分 | 先定性再处置;给出 SCAN/UNLINK、Cluster 扩容 |
| 95 分 | 讲清 maxmemory 留白与 fork;淘汰只是应急;有防复发体系 |
【关联题】
- 同一知识簇: 第 34 题(过期淘汰)、第 30 题(大 key)、第 35 题(Cluster)、第 33 题(fork 内存)
- 防雪崩: 第 26 题
【自测】
- maxmemory 为什么不要设成物理内存 100%? 参考答案:要为 fork/COW、复制缓冲、持久化重写留内存,否则易被 OOM killer 或阻塞。
- 清理大量 key 为什么不能用 KEYS? 参考答案:KEYS 阻塞单线程;应用 SCAN 游标分批。
- 淘汰策略能根治内存不足吗? 参考答案:不能,只是牺牲命中率买时间。根治靠扩容、拆分、分层与 TTL 规范。
49. 怎么主动发现缓存和数据库悄悄不一致了(一致性监控)
【考察内容】一致性保障体系的完整性
【题目】缓存一致性方案做得再好,线上仍可能出现“缓存和数据库悄悄不一致”(历史 bug、补偿失败)。如何设计一套主动发现不一致的监控?定时对账怎么做?发现不一致后怎么修复?
【参考答案】主动发现缓存与 DB 不一致:抽样对比 + 变更链路审计:
- 容量估算(步进):全量对比不现实(读 5 万 QPS 的商品逐条对 DB 会打挂库)。务实做法:按 0.1%–1% 抽样 或只对比“近 N 分钟变更过”的 key。假设变更 1000 key/分钟,抽 1% = 10 次/分钟额外读,对 DB 几乎无压力;资金/价格这类强一致字段把比例提到 5%–10%,也才 50–100 次/分钟,仍在预算内。
- 手段:① 定时对账任务:对比热点/资金相关字段;② 监听 binlog 与缓存删除事件是否闭环(删除失败率);③ 双读 diff:灰度少量请求同时读缓存与 DB,只打日志不给用户;④ 业务埋点:下单/改价后检查缓存版本或主动失效确认。
- 指标体系:不一致率、平均不一致时长、删除失败队列堆积、binlog 消费延迟、抽样差异 top key。
- 失败与降级:发现批量不一致 → 触发缓存批量失效/重建(限速执行);对账任务本身要错峰限流,避免反向伤害 DB;强一致业务以 DB 为准并告警人工介入;对账任务挂了要有备用定时与告警,不能静默失效。
- 口径:一致性监控不是“全量实时”,而是高价值字段、变更窗口、可闭环的删除链路;先保证资金与交易类,再覆盖展示类。
【原理溯源】
- 为什么「方案做得好」仍要监控? 分布式下补偿可能失败、代码可能漏分支、运维可能误操作。一致性方案降低概率,监控把残余概率变成可发现事件。没有监控的一致性是「希望它一致」。
- 对账为什么是抽样而不是总全量? 全量比对 DB 与 Redis 成本高(遍历 key、查 DB),可能本身打挂系统。抽样+热点优先+低峰执行,在发现能力与成本间平衡。
- binlog 比对为什么一石二鸟? 每一次 DB 变更都应对应一次缓存删除/更新。订阅 binlog 后:既可校验「删没删」,也可在缺失时补删。它以 DB 为真相源,覆盖应用漏发删除的场景。
- 为什么「删除失败」是最危险信号? 成功删缓存,下一次读会回填新值;删除失败则旧值长期驻留。所以删除失败率是领先指标,应在发生时就告警,而不是等对账发现结果。
- 版本号为何优于裸值比对? 裸值可能因序列化格式、字段顺序、默认值不等价而误报。版本单调递增,比对简单可靠:缓存版本 < DB 版本即不一致。
- 修复原则为什么是「以 DB 为准」? 缓存是衍生数据,DB 是系统记录。修复动作应是「删缓存让其重建」或「写入 DB 的新值」,绝不能反过来用缓存覆盖 DB(除非缓存是唯一写入口且有版本证明)。
【选型判断树】
一致性监控:
├─ 写路径可观测吗?
│ └─ 先打点:更新 DB / 删缓存 成功失败率(最低成本)
├─ 是否有 Canal/binlog?
│ ├─ 有 → 事件驱动比对+补删(推荐主通道)
│ └─ 无 → 定时抽样对账
├─ key 是否巨大?
│ └─ 热点优先抽样,冷数据低频
└─ 发现不一致
└─ 删缓存 / 回填 DB 值;短 TTL;修 bug【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「方案降概率,监控抓残余;DB 是真相源」 |
| 0:30–2:00 | 发现手段 | 删除失败打点、抽样对账、binlog 比对、版本号 |
| 2:00–3:20 | 为什么这样设计 | 全量太贵;删除失败是领先指标 |
| 3:20–4:20 | 修复 | 以 DB 为准,删或回填;TTL 自愈 |
| 4:20–5:00 | 收尾 | 「一致性 = 正确方案 + 补偿 + 监控对账」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 抽样比例 | 单次 0.1%–1%(资金类 5%–10%) | 按「每天累计」另算时区间会大一个量级 |
| 对账时段 | 低峰(凌晨) | 减少业务影响 |
| 热点对账频率 | 分钟–小时级 | 价格类更勤 |
| 删除失败告警 | > 0 即关注,突增告警 | 领先指标 |
| binlog 延迟 | 秒内 | 比对通道 |
| 不一致修复 SLA | 分钟级(缓存删后自愈) | 加 TTL |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「怎么发现不一致?」 → ①删缓存失败监控;②抽样对比 DB 与缓存(含版本);③binlog 驱动校验;④用户反馈只作最后一道。
L2|「全量对账会不会压力大?」 → 会。应抽样、分片、低峰执行;热点 key 提高频率,冷 key 降低。必要时只比对「最近变更的 key」。
L3|「对账发现不一致,以谁为准?为什么不能用缓存修 DB?」 → 以 DB 为准。缓存是副本,可能被错误回填;除非架构上缓存是唯一写入点且带权威版本,否则禁止缓存反向覆盖 DB。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道要对账、用户反馈不算数 |
| 80 分 | 给出抽样对账与 binlog;修复以 DB 为准 |
| 95 分 | 删除失败作为领先指标;版本号比对;成本控制(抽样/热点优先);与写路径监控闭环 |
【关联题】
- 同一知识簇: 第 27 题(Cache Aside)、第 28 题(双删)、第 38 题(顺序)、第 50 题(组件埋点)
- binlog 通道: 与 Canal 方案同源
【自测】
- 最容易导致缓存不一致的失败点是什么? 参考答案:更新 DB 成功后删除缓存失败。应单独监控删除失败率。
- 为什么对账要抽样? 参考答案:全量遍历与比对成本过高,可能影响业务;抽样+热点优先更经济。
- 发现不一致后的标准修复动作? 参考答案:以 DB 为准删除缓存(或回填新值),依赖短 TTL 与下次读自愈;同时修根因。
50. 所有业务共用一套缓存组件,内置三防(通用缓存组件设计)
【考察内容】通用缓存组件设计(缓存三防的工程落地)
【题目】公司要求封装一个通用缓存组件给所有业务方用(屏蔽 Redis 细节),并且必须内置防穿透、防击穿、防雪崩能力,业务方不用自己处理。你如何设计这个组件的 API 和内部机制?不同业务对一致性要求不同,怎么兼顾?
【参考答案】通用缓存组件内置“三防”(穿透/击穿/雪崩),让业务少踩坑:
- 容量估算(步进):组件服务数百接口,假设总读 20 万 QPS,命中率 95% → 回源 1 万 QPS;组件自身要保证:单 key 热点不打爆、回源有并发上限(接口维度 10–50 并发;同一 key 只放 1 个 loader——两个闸门别混写,否则 1 万个 key 同时过期时「每 key 50 并发」等于没有闸门)。组件网关自身 QPS 预算要覆盖峰值 2 倍以上。
- 三防内置:① 穿透:可选空值缓存 + 布隆过滤器插件,TTL 与误判策略可配置;② 击穿:热点互斥(分布式锁/单飞)+ 逻辑过期模式,重建失败返回兜底;③ 雪崩:TTL 加随机、集群探活、回源限流与熔断降级钩子(业务可注入 fallback)。
- 统一能力:命名规范与序列化;指标(命中率、回源 RT、重建次数、三防触发次数)自动上报;防误用(禁止大 value、必须 TTL、禁止 KEYS);热更开关与灰度。
- 失败与降级:组件故障不影响主流程——降级直连 DB(业务限流开关);熔断器打开时返回业务 fallback;灰度发布组件,保留旧版本回滚;布隆/本地缓存异常时自动退回空值+限流路径。
- 口径:好组件把“正确性默认项”做进框架,而不是每个业务自己手写一遍还容易写错;业务只关心“读这个 key”,三防是平台责任。
【原理溯源】
为什么要封装组件而不是让业务各写各的? 三防逻辑容易写漏、写错、不一致。组件把「正确默认」产品化:业务只提供 key 与 loader,防护、监控、降级由平台统一。这是「用平台约束换全局正确性」。
get(key, loader) 为什么是核心抽象? 它把「读缓存 + miss 回源 + 回填」收成一次调用,业务不再手写 if-miss。loader 内是 DB 查询;组件在 loader 外包一层锁/空值/逻辑过期。API 面小,内部策略可换。
三防如何在组件内分层?
- 穿透:loader 返回 null 时写空值缓存(短 TTL);可选布隆前置。
- 击穿:同 key 并发 miss 时,Redis 锁或进程内 singleflight,只跑一个 loader。
- 雪崩:写入 TTL = base + random;热点统计自动延长 TTL。
一致性要求不同时如何兼顾? 配置化而不是一刀切:
模式 适用 行为 强一致偏好 价格、库存展示 短 TTL、优先删缓存、写后删失败重试更激进 最终一致 详情、资讯 逻辑过期、可稍长 TTL 只读配置 字典 本地层 + 广播失效 通过 CachePolicy注解/配置切换策略,而不是分叉多套组件。为什么必须有监控与降级? 组件是全局咽喉:命中率、回源 QPS、loader 耗时、删除失败率必须可见。Redis 故障时自动降级为限流直连 DB,避免「缓存挂了业务全挂」。没有降级的组件是单点放大器。
如何保证业务方真的走组件? ①代码规范与 CR;②包装官方客户端,业务依赖里不暴露裸 Jedis/Lettuce;③Redis 网络 ACL 限制来源。技术手段比口头约定可靠。
【选型判断树】
组件设计决策:
├─ API
│ └─ get(key, loader, policy) + put/delete;禁止业务裸操作 Redis
├─ 防穿透
│ ├─ 默认空值缓存
│ └─ 大流量不存在查询 → 打开布隆开关
├─ 防击穿
│ ├─ 强一致 → 互斥锁回源
│ └─ 高性能 → 逻辑过期 / singleflight
├─ 防雪崩
│ └─ TTL+random 必开;热点自动加长
├─ 一致性
│ └─ 写路径统一先更库后删缓存;失败重试+binlog 可插拔
└─ 运行时
└─ 埋点、限流、降级开关、动态配置【口述骨架】(5 分钟)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「把三防和一致性做成平台默认,业务只写 loader」 |
| 0:30–2:00 | API 与三防 | get(key,loader);空值/锁/TTL 随机 |
| 2:00–3:10 | 一致性分级 | policy 配置;统一先更库后删 |
| 3:10–4:10 | 可观测与降级 | 命中率、回源、失败率;故障直连 DB |
| 4:10–5:00 | 落地 | 包装客户端;动态开关;灰度 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 空值 TTL | 30s–5min | 组件默认可配 |
| 回源锁 TTL | 3–10s | 防击穿 |
| TTL 随机 | ±10%–30% 或 +0–300s | 防雪崩 |
| 命中率目标 | 核心接口 > 95% | 低于则报警 |
| 删除失败重试 | 3–5 次 + 异步队列 | 一致性 |
| 降级 | Redis 错误率超阈值自动开 | 保 DB |
| 回源 QPS | 峰值 × (1-命中率) | 命中率 95%→70% 回源放大约 6 倍 |
| Redis 单机 | 约 10 万 QPS | 简单命令;热 key 需打散 |
| 缓存命中率目标 | > 90% | 低于 80% 需排查 |
【追问链】(三层)
L1|「业务方绕过组件直接用 Redis 怎么办?」 → 依赖管理上不暴露底层客户端;网络/ACL 限制;CR 与扫描禁止裸用。最好从架构上让裸客户端不可得。
L2|「不同业务一致性要求冲突,组件会不会越改越复杂?」 → 用策略模式(policy)而不是 if 业务名。核心只提供几种标准档位;特殊业务自定义扩展点,而不是改内核。
L3|「组件自身会不会成为故障放大器?」 → 会,所以必须:超时与熔断、降级直连 DB、线程池隔离、动态关闭重逻辑(锁/布隆)、全量可观测。组件升级要灰度。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出封装 get + 三防手段 |
| 80 分 | loader 抽象、三防内置机制、写路径一致性 |
| 95 分 | 策略化多档一致性;监控降级;防绕过;把组件当产品设计(可观测、灰度、可关) |
【关联题】
- 三防原理: 第 23–26 题、第 44 题(布隆)
- 一致性: 第 27–28、38 题;监控:第 49 题
- 位置选型: 第 45 题;热 key:第 13 题
【自测】
- get(key, loader) 中 loader 返回 null 时组件应做什么? 参考答案:写入空值缓存(短 TTL),防止穿透;可选布隆前置。
- 组件如何防击穿? 参考答案:同 key 并发 miss 时用 Redis 锁或 singleflight,保证只有一个 loader 回源。
- Redis 整体故障时组件该怎么办? 参考答案:熔断降级为限流直连 DB(或返回降级数据),避免缓存层把业务拖死。