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 分钟;框架 A,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 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;2 万 QPS 需 ≤50µs、1 万 QPS 需 ≤100µs(含 DB 写都不可能做到) | 所以锁扛不了扣减主路径,只能低频防重 |
| RedLock 节点 | 通常 5 个独立主 | 多数派加锁 |
| 等待锁超时 | 业务相关,如 3–10s | 避免无限等 |
【追问链】(三层)
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 看门狗;不把分布式锁神化 |
【关联题】
【自测】
- 解锁为什么必须 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 分钟;框架 A,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 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 | 归零才真正解锁 |
| 续期失败处理 | 下一周期再试;过期则锁失效 | 可告警 |
【追问链】(三层)
L1|「看门狗是任务执行完才释放锁吗?」 → 不是。unlock 时释放;若一直不 unlock,看门狗会一直续,直到进程退出。它不感知「业务是否逻辑结束」,只感知「持有者是否还持有/还活着」。
L2|「手动指定 leaseTime 会怎样?还要看门狗吗?」 → 不续期。必须自己保证业务在 leaseTime 内完成,否则锁提前释放导致并发。不确定耗时就不要指定,用默认看门狗。
L3|「续期时 Redis 挂了怎么办?会不会两把锁?」 → 续期失败会重试;若锁最终过期,其他客户端可加锁,此时若原业务还在跑,就可能出现并发——这是 Redis 锁的固有窗口。业务上用幂等/条件更新兜底,不要只靠锁。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道看门狗会自动延长锁时间 |
| 80 分 | 说清 30s/10s、宕机不续、leaseTime 关闭续期 |
| 95 分 | 讲清租约模型与 1/3 间隔的原因;Hash+Lua+可重入;指出续期失败窗口与业务兜底 |
【关联题】
【自测】
- 为什么续期间隔是锁时长的 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)。顺序问题与失败问题是两个维度。
元理论对应: 原理 4「时间差、多副本视图与一致性窗口」+原理 6「一致性定价与按业务分级购买」(题群见《核心考点与元理论》§4.7+§6.7)
【选型判断树】
更新顺序:
├─ 默认互联网场景
│ └─ 先更 DB → 再删缓存(Cache Aside)
├─ 若因历史原因必须「先删再更」
│ └─ 加延迟双删 + 短 TTL + binlog
├─ 删除可能失败
│ └─ 重试队列 / Canal 异步删
└─ 强一致要求(钱、库存终态)
└─ 不要赌缓存顺序:读主库/直连 DB + 版本号【口述骨架】(5 分钟;框架 C,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 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 分钟 | 自愈 |
【追问链】(三层)
L1|「先更库再删缓存,删之前用户读到旧价,可以吗?」 → 一般业务可以,窗口毫秒级。价格展示场景若不可接受,应缩短 TTL、写后主动删、或关键读直连 DB。
L2|「更新缓存为什么不好?」 → 并发写会旧值覆盖新值;且更新逻辑若依赖旧值计算(如 +1),还可能算错。删除+回填把「写缓存」变成「读时从 DB 取真值」。
L3|「有没有办法做到顺序无关的强一致?」 → 有代价更高的路:①缓存带版本,只允许新版本覆盖;②分布式锁串行化「更库+删缓存」;③直接读 DB。多数业务不值得,用「先更后删+补偿+TTL」已足够。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道推荐先更库再删缓存 |
| 80 分 | 能分别描述两种顺序的并发脏数据时序 |
| 95 分 | 讲清「删 vs 更新」的原因;区分顺序问题与失败问题;强一致场景给绕行方案 |
【关联题】
【自测】
- 画出「先删缓存再更 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 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 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 完成判定 | 避免竞态 |
| 网关限流 | 全局/接口维度 | 先于业务 |
【追问链】(三层)
L1|「为什么不用数据库计数?」 → 限流在最热路径上,DB 的连接与事务开销扛不住;Redis 内存原子计数延迟亚毫秒级,是共享计数的标准件。
L2|「ZSet 滑窗会不会内存无限涨?」 → 每次都删窗外 member,窗口内数量 ≤ 阈值。若攻击者狂刷,达到阈值后不再 ZADD,内存有上界。仍建议配 maxmemory 策略。
L3|「Lua 脚本报错或超时怎么办?限流会不会放行失控?」 → 脚本执行失败应 fail-close(当作超限)或本地降级限流,不能 fail-open 放任。同时监控脚本错误率;Redis 不可用时网关层仍应有限流兜底。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能用 INCR+过期实现固定窗口 |
| 80 分 | 指出边界突刺,给出 ZSet 滑窗 + Lua |
| 95 分 | 讲清原子性为何必须 Lua;令牌桶与窗口的语义差;多层限流与 fail-close |
【关联题】
【自测】
- 固定窗口的边界突刺是什么?举例。 参考答案:分钟交界前后各用满阈值,导致极短时间内放行双倍流量。如 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 是整点营销的脉冲上界(第 1 条的 30 万单/日摊算只有约 3.5 TPS,即便集中在 2 小时也才约 42 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 分片 + Lua 抢占 + 扫表兜底,MQ 只作对比与后续演进
├─ 延迟精度要求?
│ ├─ 秒级足够 → 上述皆可
│ └─ 毫秒级且进程内 → 时间轮(需持久化)
├─ 丢了是否可接受?
│ └─ 不可接受(资金/库存)→ 必须以 DB 状态扫表兜底
└─ 已支付校验
└─ 关单前再查一次支付状态,防已支付被关【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 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 | 收尾 | 「按题干能否引入组件分两支:不能→Redis ZSet 分片+扫表兜底;能→延迟消息主路径+扫表补偿;两支都关单前复查支付状态」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 订单支付超时 | 常见 15–30 分钟 | 按品类 |
| ZSet 轮询间隔 | 1s | 与精度/DB 压力权衡 |
| RocketMQ 延迟 | 4.x:18 个固定档位;5.x:可指定任意到点时刻(默认秒级精度、最长 24h) | 按版本分两支答 |
| 扫表间隔 | 1–5 分钟 | 兜底 |
| 扫表 SQL | status=待支付 and create_time < 阈值 | 索引要建对 |
| 重复关单 | 必须幂等 | 支付成功竞态 |
【追问链】(三层)
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 题(缓存策略选型,含 Write Behind 的丢失窗口)、第 110 题(幂等)
- MQ 侧: 第 86 题(Kafka)可对比消息可靠投递思想(本章业务用 RocketMQ 更常见)
【自测】
- ZSet 延时队列中,为什么强调「处理成功再 ZREM」? 参考答案:单消费者时处理成功再删可重试(先删后处理若失败任务就丢了);但多实例并行消费必须反过来——用一段 Lua 先
ZRANGEBYSCORE取到期成员、再ZREM删掉(整段原子执行,才能把被取走的成员返回给自己;单独ZREM只返回删除个数,拿不到成员)再处理,否则两个实例同时 ZRANGEBYSCORE 取到同一批就是重复消费。两种做法都以消费端幂等为前提。 - 过期监听适合什么场景? 参考答案:丢了影响很小的会话清理、缓存统计等,不适合订单关闭。
- 生产上如何防止「已支付订单被自动关闭」? 参考答案:关单前复查支付状态 + DB 条件更新(仅待支付可改为已取消)+ 支付回调与关单幂等。
41. 游戏积分榜要实时更新、秒级查 TopN(排行榜设计)
【考察内容】排行榜是 ZSet 应用最经典场景题
【题目】游戏要做实时积分排行榜:玩家分数随时变化,榜单要实时刷新,还要支持 Top100 秒级查询、分页查看、按名次查人。怎么用 Redis 实现?日榜和总榜分别怎么处理?
【参考答案】实时积分榜:ZSET 为主,热点与容量要打散:
- 容量估算(步进):假设玩家 500 万上榜,ZSET 每元素约 64–100B → 单榜内存约 320–500MB(5e6×64B≈320MB),属于大 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,跨天自然滚动不需清理(要给次日零点前查榜留余量时,最多把 TTL 放到当日+1 天,别按 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 分钟;框架 A,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 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 分钟或日终 | 防丢 |
【追问链】(三层)
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 为账目真相 |
【关联题】
【自测】
- 实时加分用什么命令?为什么原子? 参考答案: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 元素量可控,还能按业务路由到不同分片。 - 位置高频更新怎么办? 「附近的人」类数据有时效性,但要说清 Redis 的 TTL 是 key 级:GEO 数据存在一个 ZSet 里,
GEOADD没有 EX/PX 参数,ZSet 成员也没有独立过期时间(Redis 7.4 的逐字段 TTL 只作用于 Hash)。所以不能「给每个用户设 TTL」,只能:① 用另一张 score=最后上报时间的 ZSet,定时ZRANGEBYSCORE捞出超时成员、回到 GEO key 里ZREM;② 或每用户一个带 TTL 的独立 key,代价是不能再用一次GEOSEARCH扫全城。否则离线用户会长期占榜、污染结果。
【选型判断树】
LBS:
├─ 数据量与更新频率
│ ├─ 十万级、更新不高 → Redis GEO 单城 key
│ ├─ 百万级 → 按城市/网格分 key
│ └─ 千万+、复杂多边形/筛选 → ES/PostGIS 等空间索引
├─ 查询形态
│ ├─ 半径 TopN → GEORADIUS / GEOSEARCH COUNT
│ └─ 多条件过滤 → 先 GEO 粗筛再业务过滤,或换 ES
└─ 时效
└─ 在线场景:score 记上报时间 + 定时清理陈旧坐标(成员没有逐条 TTL)【口述骨架】(5 分钟;框架 A,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 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;定时 ZRANGEBYSCORE+ZREM 清陈旧坐标 |
| 4:20–5:00 | 扩展 | 海量上 ES/PostGIS |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| GeoHash 位数(Redis 内部 52 bit 的等效精度) | 约 11 位 → 米级 | 52 bit=经纬各 26 bit |
| 单 key 建议 | 城市级,避免全国一把 | 防大 key |
| 在线时效 | 5–30 分钟无上报即清理 | 靠定时清理,GEO/ZSet 成员没有逐条 TTL |
| GEOSEARCH COUNT | 20–100 | 产品页大小 |
| 边界问题 | 查 9 宫格 | 相邻前缀不同 |
【追问链】(三层)
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 等空间索引 |
【关联题】
【自测】
- 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 分钟;框架 A,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 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 + 落库 |
【追问链】(三层)
L1|「购物车用 String 存 JSON 有什么问题?」 → 无法原子改单商品数量;并发下互相覆盖;每次改都整包读写,带宽与延迟差。Hash 按 field 操作才是正确模型。
L2|「JWT 泄露了怎么办?」 → 短有效期 + refresh 轮换;敏感操作二次验证;对已泄露 jti 加黑名单。无法像 Session 那样「全局立刻失效」,这是无状态的代价。
L3|「Redis Session 挂了是不是所有人都掉线?」 → 是。需要 Redis 高可用(哨兵/集群);极端时可降级到本地会话或强制重新登录。可用性设计要包含这一依赖。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 购物车 Hash;Session 存 Redis |
| 80 分 | 讲清结构匹配与共享存储原因,并能对比 JWT |
| 95 分 | 主动踢下线、黑名单、未登录合并、Redis 可用性依赖 |
【关联题】
【自测】
- 为什么分布式环境不能把 Session 只放本机? 参考答案:负载均衡会把用户路由到无此 Session 的实例,登录态丢失。
- JWT 最大的工程痛点是什么? 参考答案:签发后难以主动失效,需要黑名单/版本号才能支持强制下线。
- 购物车加购为什么用 HINCRBY 而不是 HSET? 参考答案:HINCRBY 原子累加数量,支持「再加一件」;HSET 是覆盖为固定值。
44. 布隆过滤器会误判还不能删,生产上怎么用(误判与删除)
【考察内容】布隆过滤器的工程细节
【题目】你们用布隆过滤器挡缓存穿透,但它有两个天生缺陷:会误判(把不存在的说成存在)、不支持删除。生产环境里这两个问题分别怎么处理?误判会造成什么后果?有支持删除的变体吗?
【参考答案】布隆过滤器会误判、不能删:生产这样用:
- 容量估算(步进):布隆误判率公式与容量相关:预计 1 亿元素、误判率 1%,约需 ~958Mbit ≈ 120MB;若要 0.1% 则内存显著上升。插入容量越满,误判率越高,必须按 实际元素数 1.5–2 倍 预留。
- 用法:① 穿透防护:合法 ID 预加载,请求先过滤,误判只会放行少量不存在 ID,再由空值缓存挡住;② 去重场景(爬虫 URL):误判会漏抓(当成已存在),要评估业务能否接受。
- 误判与删除:标准布隆不能删;要删除可选 Counting Bloom(每格改计数器,按 4bit/格算是普通布隆(1bit/格)的 4 倍;用 8bit 计数则 8 倍)或 Cuckoo Filter(
CF.DEL支持真删除,且空间通常比 Counting Bloom 更省)。注意 Redis 的概率型结构里只有CF.*(Cuckoo)支持CF.DEL,BF.*不提供删除;选型时把「是否要删」当成决定性的分叉点。 - 工程方案:Redis 的概率型结构(Redis 8 起 BF./CF. 已内置于 Redis Open Source,更早版本才要挂 RedisBloom/Redis Stack 模块);自建则分片布隆;定期重建(数据版本化,双 buffer 滚动切换)。
- 失败与降级:布隆异常 → 降级为空值缓存+限流;重建期间双布隆并行;监控误判率与填充率,填充过高提前扩。
【原理溯源】
- 误判方向为什么「安全」? 标准布隆只有假阳性(不存在→可能判存在),没有假阴性(存在→不会判不存在)。假阳性后果 = 多一次 DB 查询;假阴性后果 = 合法请求被拒,业务错误。所以工程上把「不存在」方向当成可优化项,把「存在」方向当成正确性底线。
- 误判率为何随数据增多而上升? 置 1 的位越来越多,随机 k 位全为 1 的概率上升。必须按目标 n 和 p 预计算 m、k;若实际数据超过设计容量,p 会迅速恶化——布隆过滤器对超容量非常敏感。
- 为什么标准布隆不能删? 一位被多个 key 共享。清 0 可能抹掉其他 key 的存在证据 → 引入假阴性。这是正确性红线,所以宁可不可删。
- 计数布隆的代价: 每位变计数器(如 4 bit),空间涨 4 倍+;计数回绕仍可能出错。用空间换可删。
- 布谷鸟过滤器的机制差异: 每个元素有两个候选位置,插入时若冲突则「踢出」已有元素到其另一候选位。支持删除、空间往往更省、查询更快;但插入踢出有上限,接近满时可能插入失败,需扩容或重建。
- 为什么生产仍要空值缓存? 布隆放行后 DB 可能仍无此数据(误判或真实不存在)。空值缓存把这次穿透也吸收掉,避免「误判导致的重复打 DB」。
【选型判断树】
过滤器选型:
├─ 数据几乎只增不删(商品库、用户库)
│ └─ 标准布隆 + 定期重建
├─ 需要删除(会下架、会注销)
│ ├─ 删除不频繁 → 计数布隆 或 定期重建
│ └─ 删除频繁且要更省空间 → 布谷鸟过滤器
├─ 数据量不确定/会长期超设计
│ └─ 容量会增长的布隆(Redis 8 起 BF.* 已内置;它是单机结构,跨机靠分片)或分层
└─ 任何情况
└─ 空值缓存兜底误判【口述骨架】(5 分钟;框架 C,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「两个缺陷:误判只在存在方向;标准版不可删」 |
| 0:30–1:40 | 误判 | 后果=多一次 DB;可接受;用 m/k 控制 |
| 1:40–3:00 | 删除 | 为何不能删;计数布隆;布谷鸟 |
| 3:00–4:10 | 工程 | 定期重建、可扩容/分片布隆(Redis 8 起 BF.* 内置,跨机靠分片)、空值兜底 |
| 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% | 过高插入失败 |
【追问链】(三层)
L1|「误判会让用户看到错误商品吗?」 → 不会。误判只放行一次查询,DB 无数据则仍返回空。错误展示来自「缓存了错误值」,不是布隆误判。
L2|「数据删了布隆怎么办?」 → 标准布隆重建;计数布隆减计数;布谷鸟直接删除。生产常见是「每日全量重建 + 双过滤器原子切换」。
L3|「重建过滤器期间请求怎么办?」 → 新旧双过滤器并行:重建用新位数组,完成后原子替换指针;期间旧过滤器继续服务。避免「删了再建」的空窗导致全量穿透。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道误判和不可删 |
| 80 分 | 说清误判方向与后果;给出计数/布谷鸟/重建 |
| 95 分 | 讲清为何不能删(假阴性红线);容量设计与超容风险;双过滤器切换与空值兜底 |
【关联题】
【自测】
- 布隆过滤器误判的方向性是什么? 参考答案:只会把不存在误判为存在(假阳性),不会把存在误判为不存在。
- 为什么需要删除时优先考虑布谷鸟而不是标准布隆? 参考答案:标准布隆清位会引入假阴性;布谷鸟原生支持删除且空间往往更优。
- 超过设计容量会怎样? 参考答案:置位变满,误判率快速上升。必须按 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 分钟;框架 C,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 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 |
【追问链】(三层)
L1|「Caffeine 和 HashMap 有什么区别?为什么要用它?」 → Caffeine 提供基于 W-TinyLFU 的高性能淘汰、过期、统计,内存可控。裸 HashMap 无淘汰会 OOM,也无 TTL。
L2|「本地缓存怎么失效?」 → ①短 TTL 自愈;②更新时消息广播各实例 invalidate;③版本号,读到旧版本则丢弃。权衡复杂度与一致要求。
L3|「为什么库存绝不能只放本地缓存?」 → 多实例各持旧库存会导致超卖——这是资金/履约级事故。库存的「全局唯一真相」必须在共享存储(Redis 预扣 + DB 事务)。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能区分本地和 Redis 各自适用 |
| 80 分 | 按共享性/延迟/一致性给六类数据分类并说明 |
| 95 分 | 讲清多级缓存一致性代价;给出失效手段;强调库存类禁止纯本地 |
【关联题】
【自测】
- 系统字典配置放哪?为什么? 参考答案:本地 Caffeine 为主。只读、变更少、允许短暂不一致;可加广播失效。
- 用户 Session 放本地为什么不行? 参考答案:负载均衡到其他实例会丢失登录态,必须共享存储。
- 多级缓存最大的工程代价是什么? 参考答案:更新时的失效传播与一致窗口变大,需要广播或短 TTL。
46. 刚下单的用户刷新页面查不到订单(主从延迟)
【考察内容】读写分离的一致性问题
【题目】Redis 做了主从读写分离后出现新问题:用户刚加购物车,立刻刷新,购物车是空的——从库还没同步完。主从同步有延迟,怎么保证“刚写的马上能读到”?有哪些方案、各自代价是什么?
【参考答案】刚下单刷新查不到:主从/读写分离延迟问题:
- 容量估算(步进):假设写主库下单 1000 TPS,读打到从库。主从延迟常见 几十毫秒到几秒,极端(大事务/网络抖动)可达几十秒。用户刷新间隔往往 <1s,延迟 500ms 就会有明显“查不到”投诉。
- 方案:① 写后读主:加购成功后短时间窗口(1–5 秒,主库吃不住就缩到 1–2 秒)内,该用户的购物车查询强制走主库,或用 session 粘滞;② 关键查询路由:购物车、「我的」这类写完就要看的读走主库,商品列表、推荐位等只读流量走从库;③ 结果缓存写后失效:加购后主动删除该用户的购物车缓存并短暂绑定主库;④ 降低复制延迟——先分清是哪套主从:题干是 Redis 主从,Redis 没有并行复制开关、复制本身已是异步(没有「退回异步」这一档,
WAIT是显式等待命令),能做的是拆大 key、避免慢命令阻塞输出缓冲与复制积压、按master_repl_offset差值监控、必要时减少 replica 数或摘掉慢从库的读权重;若换成 MySQL 主从才是并行复制(replica_parallel_workers/按组提交;8.0.27 起默认就是 4,从库回放默认已并行,slave_parallel_workers为废弃别名)、避免大事务、从库规格与网络。⑤ 各方案代价配齐:①写后读主=主库读压力上升、全员读主会退化为单点;②关键查询路由=路由逻辑复杂、易漏配;③写后缓存失效=引入缓存与 DB 的一致性成本、缓存挂时要有兜底;④降延迟=只能缩窗、消不掉竞态。 - 业务兜底:下单响应直接把订单号与状态返回前端,不依赖再次查从库;提示“支付/订单状态可能稍有延迟”。
- 失败与降级:主库压力大时不能全员读主 → 只对“写后 3 秒内 + 同用户”读主;延迟告警,超阈值自动切路由策略。
- 口径:这是复制延迟与路由策略问题,不是缓存 bug;用“写后读路径可控”换一致性体验。
【原理溯源】
为什么「刚写的读不到」? 主从复制是异步的:主库执行写并返回客户端成功时,从库可能还没收到/执行该命令。因果链:异步复制 → 读从库可能落后 → 写后立刻读从 = 经典「读己之写」不一致。
为什么不能默认「全部读主」? 那等于放弃读写分离的意义(读扩展)。要区分数据与路径:写后短窗口的同一用户会话读主,其余读从——这是「会话粘滞读主」的思想。
半同步为什么缩不了这个窗口? 主库等至少一个从库收到并写入 relay log、回 ACK 后再返回(AFTER_SYNC 下不等回放),写成功的定义从「本地落盘」升级为「已被一个从库持久保存」。注意它保证的是不丢,不是从库已可读到——从库对外可见仍需回放;代价是写 RTT 增加一个网络往返,吞吐下降。
为什么「写后写缓存」有补偿作用? 写路径若把最新值同时写入 Redis,读路径优先读缓存,则不依赖从库是否同步。缓存成为写读之间的「高速道」。代价是引入缓存一致性问题(删失败、过期)。
和 MySQL 读写分离的同一性: 问题本质相同(异步复制 + 读从),解法同构但有分工:「读到旧值」只能靠路由(写后读主、会话粘滞、缓存中转)或干脆取消读写分离;半同步解决的是另一个问题(主库宕机丢已提交写),它 AFTER_SYNC 下不等从库回放,读从库照样可能是旧值——别拿它当本方案的解法;延迟检测摘除防的是「越慢越读」的正反馈。
【选型判断树】
写后读不到:
├─ 业务能否接受秒级旧?
│ ├─ 能 → 监控延迟 + 自然自愈(多数场景)
│ └─ 不能(订单页、支付结果、购物车)
│ ├─ 同用户短窗口 → 写后读主 / 会话内强制主库
│ ├─ 全局都要新 → 取消读写分离(统一读主);别用半同步解这题——半同步只保证主库不丢已提交写,AFTER_SYNC 下不等从库回放,读从库照样可能是旧值(见上面【原理溯源】)
│ └─ 可用缓存中转 → 写后写缓存,读缓存优先
└─ 运维
└─ Redis 主从:监控 `master_repl_offset` 差值,超阈值摘读权重(`Seconds_Behind_Source` 是 MySQL 侧指标,别套到本题语境)【口述骨架】(5 分钟;框架 A,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 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 | 写后可见性 |
【追问链】(三层)
L1|「怎么实现写后读主?」 → ①业务标记:本次请求/会话带「刚写」标志,DAO 路由主库;②中间件 hint(ShardingSphere 等);③按 userId 粘滞短时间。
L2|「主从延迟为什么会突然变大?」 → 大事务/大 key 同步、从库机器负载、网络抖动、RDB 重传。要监控 master_repl_offset 差值并告警。
L3|「Redis 集群模式下还有这个问题吗?」 → 有。Cluster 分片仍有主从,异步复制问题同构。业务上的「写后读主/缓存中转」策略依然适用。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道有主从延迟,建议读主 |
| 80 分 | 讲清异步复制成因,给出读主窗口/写后缓存/延迟监控与摘除(能把半同步正确归位到「防丢数据」是加分) |
| 95 分 | 按数据关键性分流;写后缓存补偿;能联系 MySQL 读写分离的同类问题与读写路由代价 |
【关联题】
【自测】
- 「刚写的读不到」根因是什么? 参考答案:主从异步复制存在延迟,从库尚未应用该写。
- 写后读主的代价是什么? 参考答案:主库读压力上升,读写分离收益下降;只应短窗口/关键路径使用。
- 判断对错:上了读写分离就一定能提高一致性。 参考答案:错。读写分离提高的是读扩展,往往降低一致性(异步延迟)。
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,瞬时回源 = 人造雪崩。术语口径:题干说的「缓存穿透」按第 23 题的定义其实是冷启动并发回源(击穿形态、雪崩量级)——穿透专指查本就不存在的数据,别把三个名字当同义词背。预热把 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 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 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 突增 | 及时发现风暴 |
【追问链】(三层)
L1|「预热的数据源不准怎么办?」 → 预热只是降低 miss 率,不是 100% 命中。运行时仍要靠击穿/穿透防护兜底;可滚动更新热点清单。
L2|「singleflight 和 Redis 锁的区别?」 → singleflight 合并本进程内同 key 并发;Redis 锁解决跨进程。多实例部署两者常叠加。
L3|「预热写 Redis 挂了一半,活动开始会怎样?」 → 半冷启动:部分热点 miss,回源压力集中。应预热完成校验(抽样命中率)、失败重跑,并准备降级开关。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道提前加载热点、用锁防并发回源 |
| 80 分 | 讲清热点来源、分批、TTL 随机;锁/逻辑过期/singleflight |
| 95 分 | 意识到预热自身可能打挂 DB;组合进程内+跨进程合并;有校验与降级 |
【关联题】
【自测】
- 为什么预热 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 分片(在线扩分片、迁移 slot,客户端需支持拓扑刷新);垂直升配只当短期止血,单机内存有上限且大内存 fork/COW 成本更高,不是正解。
- 失败与降级:内存满且 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 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 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 | 业务数据禁止 |
【追问链】(三层)
L1|「能不能直接 FLUSHALL?」 → 绝对不能。会清掉全部数据,事故扩大。只清理确认无用的 key 前缀,且分批。
L2|「Cluster 扩容会不会中断服务?」 → 新节点迁 slot 时,迁移中的槽短暂停顿,整体可控。客户端要支持拓扑刷新(MOVED/ASK)。
L3|「如何防止再次冲到 90%?」 → ①容量规划与增长预测;②写入规范(必 TTL、限大小);③大 key 周期扫描;④分层与缩 TTL;⑤阈值告警与自动扩容预案。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道开淘汰、清 key |
| 80 分 | 先定性再处置;给出 SCAN/UNLINK、Cluster 扩容 |
| 95 分 | 讲清 maxmemory 留白与 fork;淘汰只是应急;有防复发体系 |
【关联题】
【自测】
- 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 分钟;框架 C,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | 「方案降概率,监控抓残余;DB 是真相源」 |
| 0:30–2:00 | 发现手段 | 删除失败打点、抽样对账、binlog 比对、版本号 |
| 2:00–3:20 | 为什么这样设计 | 全量太贵;删除失败是领先指标 |
| 3:20–4:20 | 修复 | 以 DB 为准,删或回填;TTL 自愈 |
| 4:20–5:00 | 收尾 | 「一致性 = 正确方案 + 补偿 + 监控对账」 |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 抽样比例 | 每轮变更 key 的 0.1%–1%(资金类 5%–10%) | 基准是每分钟变更 key 数(第 1 条例:1000/分钟×1%=10 次/分钟),换成按天累计要重算 |
| 对账时段 | 低峰(凌晨) | 减少业务影响 |
| 热点对账频率 | 分钟–小时级 | 价格类更勤 |
| 删除失败告警 | > 0 即关注,突增告警 | 领先指标 |
| binlog 延迟 | 秒内 | 比对通道 |
| 不一致修复 SLA | 分钟级(缓存删后自愈) | 加 TTL |
【追问链】(三层)
L1|「怎么发现不一致?」 → ①删缓存失败监控;②抽样对比 DB 与缓存(含版本);③binlog 驱动校验;④用户反馈只作最后一道。
L2|「全量对账会不会压力大?」 → 会。应抽样、分片、低峰执行;热点 key 提高频率,冷 key 降低。必要时只比对「最近变更的 key」。
L3|「对账发现不一致,以谁为准?为什么不能用缓存修 DB?」 → 以 DB 为准。缓存是副本,可能被错误回填;除非架构上缓存是唯一写入点且带权威版本,否则禁止缓存反向覆盖 DB。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道要对账、用户反馈不算数 |
| 80 分 | 给出抽样对账与 binlog;修复以 DB 为准 |
| 95 分 | 删除失败作为领先指标;版本号比对;成本控制(抽样/热点优先);与写路径监控闭环 |
【关联题】
【自测】
- 最容易导致缓存不一致的失败点是什么? 参考答案:更新 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);热更开关与灰度。API 与策略(题干第一、二问):对外只给
get(key, loader, policy)与put/invalidate,业务不裸操作 Redis;「一致性要求不同」靠 policy 分档而不是组件内一刀切——强一致档不缓存或只缓存版本号、常规档删缓存+TTL、只读档容忍快照,由业务按数据性质选档。 - 失败与降级:组件故障不影响主流程——降级直连 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 分钟;框架 A,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 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 |
【追问链】(三层)
L1|「业务方绕过组件直接用 Redis 怎么办?」 → 依赖管理上不暴露底层客户端;网络/ACL 限制;CR 与扫描禁止裸用。最好从架构上让裸客户端不可得。
L2|「不同业务一致性要求冲突,组件会不会越改越复杂?」 → 用策略模式(policy)而不是 if 业务名。核心只提供几种标准档位;特殊业务自定义扩展点,而不是改内核。
L3|「组件自身会不会成为故障放大器?」 → 会,所以必须:超时与熔断、降级直连 DB、线程池隔离、动态关闭重逻辑(锁/布隆)、全量可观测。组件升级要灰度。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出封装 get + 三防手段 |
| 80 分 | loader 抽象、三防内置机制、写路径一致性 |
| 95 分 | 策略化多档一致性;监控降级;防绕过;把组件当产品设计(可观测、灰度、可关) |
【关联题】
【自测】
- get(key, loader) 中 loader 返回 null 时组件应做什么? 参考答案:写入空值缓存(短 TTL),防止穿透;可选布隆前置。
- 组件如何防击穿? 参考答案:同 key 并发 miss 时用 Redis 锁或 singleflight,保证只有一个 loader 回源。
- Redis 整体故障时组件该怎么办? 参考答案:熔断降级为限流直连 DB(或返回降级数据),避免缓存层把业务拖死。