缓存是系统性能的第一杠杆:同样的数据,从内存读是微秒级,从数据库读是毫秒级,从远端读是十毫秒级——差着两个数量级。分布式缓存系统把「热数据」从数据库搬到多台机器的内存里,用一致性哈希路由、多副本容灾、与数据库的双写协同,为上层业务提供低延迟、高并发、高可用的读服务。本文按照系统设计面试的标准答题结构,设计一个生产级的分布式缓存系统。
一句话:分布式缓存的核心矛盾是「快」与「一致」——设计上把「多读少写、可容忍短暂不一致」的数据放进缓存,用一致性哈希找节点、用双写策略保最终一致、用多副本保可用。
一、需求澄清与量级估算
1.1 需求澄清
面试官给出题目「设计一个分布式缓存系统」后,先通过提问明确边界:
- 数据模型:KV 缓存(字符串/哈希/列表),还是更丰富的结构?是否需要排序、计数?
- 一致性要求:缓存与数据库能否短暂不一致?能否容忍旧数据?
- 容量规模:总缓存容量、单 key 大小、平均/峰值 QPS?
- 过期策略:是否支持 TTL?淘汰策略(LRU/LFU)?
- 高可用:宕机如何恢复?是否需要持久化?
- 访问模式:读多写少还是写读均衡?是否有热点 key?
- 使用方式:业务直接连还是经过代理层?
明确假设(面向面试的合理假设):
| 需求项 | 假设 |
|---|---|
| 数据模型 | KV + 常用数据结构(hash/list/set/zset) |
| 一致性 | 缓存最终一致,允许秒级短暂不一致 |
| 规模 | 1000 节点、总容量 10 TB、峰值 1000 万 QPS |
| 过期 | 支持 TTL + LRU/LFU 淘汰 |
| 高可用 | 主从 + 自动故障转移,可重启恢复 |
| 访问 | 读多写少,偶发热点 key |
1.2 量级估算
| 指标 | 估算值 | 推导 |
|---|---|---|
| 节点数 | 1000 台 | 每台 16G 内存,共 10~16 TB |
| 峰值 QPS | 1000 万 | 缓存命中为主,读放大后打到 DB 的应被滤掉 |
| 平均时延 | P99 < 1ms | 同机房内存访问 |
| 缓存命中率 | 95%+ | 读多写少的典型曲线 |
| 单 key 平均 | ~1KB | 用户/商品/会话等对象 |
| 写 QPS | 100 万 | 数据更新/失效 |
| 热点 key | 少数 key 占 30% 流量 | 爆款商品、热搜词 |
一句话:1000 万 QPS 全打在内存上才现实——缓存系统的一切设计(分片、副本、代理、命中率)最终都是为了「把流量留在内存、把数据库保护起来」。
二、高层架构设计
┌──────────────────────────┐
│ 业务服务集群 │
│ (读路径 / 写路径) │
└────────────┬─────────────┘
│ 缓存读写请求
▼
┌─────────────────────────────────────────────────────────┐
│ 缓存代理层 (Cache Proxy, 无状态) │
│ ① 一致性哈希路由 ② 连接复用/协议解析 ③ 请求合并 │
│ ④ 慢查询/热 key 感知 ⑤ 故障节点摘除 │
└────────────┬────────────────────────────────────────────┘
│ 一致性哈希定位到分片
▼
┌─────────────────────────────────────────────────────────┐
│ 缓存数据层 (分片集群) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Shard 0 │ │ Shard 1 │ │ Shard 2 │… │ Shard N │ │
│ │ 主+从 │ │ 主+从 │ │ 主+从 │ │ 主+从 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└────────────┬────────────────────────────────────────────┘
│ 数据同步/故障转移
▼
┌─────────────────────────────────────────────────────────┐
│ 管控面:元数据(分片映射/路由表) 监控(命中率/延迟/容量) │
│ 一致性协同:DB Binlog 订阅 → 失效/回写缓存 │
└─────────────────────────────────────────────────────────┘
整体拆为四层:
- 代理层:无状态入口,一致性哈希路由、连接复用、故障摘除、指标采集。
- 数据层:按分片组织的缓存节点,主从结构,每分片独立。
- 管控面:分片映射与路由表下发、监控告警、扩缩容编排。
- 协同面:与数据库的一致性协同(Binlog 订阅失效/回写)。
2.1 一致性哈希路由
缓存集群扩容缩容频繁,路由算法要让「加减节点时影响面最小」:
一致性哈希:
把 key 哈希到 0~2^32 环上,节点也哈希到环上
key 顺时针找到的第一个节点即为归属节点
加节点:只影响该节点逆时针到前一个节点之间的 key
减节点:该节点的 key 迁移到下一个节点
虚拟节点:
每个物理节点映射 100~200 个虚拟节点,打散到环上
解决「节点少时数据倾斜」与「节点下线时负载不均衡」
数据迁移:
加/减节点 → 重算受影响 key → 新节点缺失的 key 回源 DB 重建
路由表版本化下发,代理层原子切换
;; 伪代码:一致性哈希路由
(defn route-key [key ring]
(let [h (hash key)]
;; 环上找第一个 >= h 的虚拟节点
(->> (sorted-map ring) ; 环:虚拟节点哈希 → 物理节点
(first-key-after h)
(physical-node))))
;; 扩容:新节点上线后,只迁移落入新区间的 key
一句话:一致性哈希 + 虚拟节点让「扩缩容的扰动」从 O(N) 降到 O(1/N)——绝大多数 key 的路由不动,只有相邻区间的 key 需要迁移,这是缓存集群能平滑扩容的数学保证。
三、核心组件设计
3.1 缓存与数据库一致性
缓存与数据库是两套数据,任何双写都可能不一致。主流方案:
Cache Aside(旁路缓存)——最常用:
读:先查缓存 → 未命中则查 DB → 回填缓存 → 返回
写:先写 DB → 再删缓存(不是更新缓存)
为什么「写后删缓存」而非「写后更新缓存」:
更新缓存是「写两处」,并发下容易写乱(后写覆盖先写)
删缓存让「下次读」回源重建,天然收敛到 DB 最新值
潜在不一致窗口:
读未命中 → 查 DB(旧) → 回填
写 DB(新) → 删缓存
读回填了旧值 → 缓存短暂旧 → 靠 TTL 或二次删兜底
;; 伪代码:Cache Aside 读
(defn get-cached [key]
(if-let [v (cache/get key)]
v
(let [v (db/get key)]
(cache/set key v :ttl 300) ; 回填 + TTL
v)))
;; 伪代码:Cache Aside 写(先 DB 后删缓存)
(defn update-user [user]
(db/update user)
(cache/del (str "user:" (:id user))))
结论:缓存一致性的工程共识是「Cache Aside + 写后删缓存」——它不追求无窗口,而是把窗口缩到 TTL 内并用「删缓存」让脏数据尽快被下一次读覆盖;追求更严时用「延迟双删」或 Binlog 订阅。
3.2 延迟双删与 Binlog 订阅
对一致性要求更高的场景,用两种增强手段:
延迟双删(先删再删):
写 DB → 删缓存 → 等待约 100ms(给读回填一个完成窗口)→ 再删一次
把「读回填旧值」这个窗口内的脏缓存二次清除
Binlog 订阅(最终一致最稳):
写 DB → MySQL Binlog 捕获变更 → MQ → 消费者删缓存/重建缓存
优点:业务代码不感知缓存,主数据变更统一驱动缓存
缺点:多引入一套 Binlog 消费链路,延迟在秒级
选型:
- 常规业务:Cache Aside + 删缓存 + TTL 兜底(够用)
- 一致性敏感:延迟双删 或 Binlog 订阅
- 强一致要求的数据:根本不该进缓存(走 DB)
3.3 高可用与容灾
缓存宕机会把流量打到数据库,必须多副本 + 自动转移:
主从复制:
每分片 1 主 N 从,主写从读(读写分离)
从节点异步复制主节点数据,读扩展 + 容灾
故障转移:
哨兵/集群控制器监控主节点心跳
主挂 → 提升从节点为新主 → 代理层路由表摘除故障主节点
客户端重试 + 路由表更新,秒级恢复
持久化(可选):
缓存本可丢(丢了回源重建),但为「快速恢复 + 防雪崩」可开 AOF
取舍:AOF 保数据恢复速度,牺牲一点写性能
一句话:缓存的高可用靠「主从 + 自动转移 + 回源重建」三件套——即使整个分片挂了,丢失的 key 由 DB 重建,代价只是短暂命中率下降,系统不雪崩。
3.4 热点治理
少量 key 承载大量流量,单分片会成为瓶颈:
热点识别:
代理层统计 key 访问频次 → 识别 topN 热点 key
热点 key 下发到各业务节点本地缓存(JVM 缓存副本)
本地缓存副本:
热点 key 在业务本地缓存一份(TTL 极短,如 1~5 秒)
读命中本地 → 不经过缓存集群 → 分散热点压力
写更新 → 删本地(广播或靠短 TTL 自然过期)
多副本缓存:
把热点 key 复制到多个分片(key#1 / key#2 / ...)
代理层按随机取副本,摊薄单分片压力
;; 伪代码:热点 key 本地缓存兜底
(defn get-with-local [key]
(if (hot-key? key)
(or (local-get key) ; 命中本地副本
(let [v (cache/get key)]
(local-set key v :ttl 2)
v))
(cache/get key)))
要点:热点治理的三板斧是「识别 → 打散 → 兜底」——识别热点 key,把访问打散到业务本地缓存或多副本分片,DB 与缓存集群都不用硬扛单点。
四、深入权衡
4.1 缓存穿透、击穿与雪崩
| 故障 | 现象 | 根治方案 |
|---|---|---|
| 穿透 | 查询不存在的 key,每次都打 DB | 布隆过滤器挡不存在 key + 空值缓存 |
| 击穿 | 热点 key 过期瞬间,并发打 DB | 互斥锁重建 + 热点不过期 |
| 雪崩 | 大量 key 同时过期/节点宕机,DB 被打垮 | 过期时间加随机 + 多副本 + 限流降级 |
穿透防护:
布隆过滤器(内存位图)判断 key 是否存在,不存在直接返回
空值也缓存(TTL 短),防止恶意 key 反复穿透
击穿防护:
热点 key 重建加分布式锁:只有一个线程回源 DB,其余等待
逻辑过期:缓存里存「过期时间」,后台异步刷新,热 key 永不物理过期
雪崩防护:
TTL 加随机抖动(300s ± 60s),避免集中失效
缓存节点多副本,单点宕机不引起全局失效
降级:缓存大量不可用时,DB 侧限流 + 熔断
结论:穿透、击穿、雪崩本质都是「DB 被不该打的流量打」——布隆过滤器挡穿透、互斥锁挡击穿、TTL 抖动与多副本挡雪崩,三套手段各管一摊,通常组合使用。
4.2 一致性 vs 可用性
| 方案 | 一致性 | 实现成本 | 适用 |
|---|---|---|---|
| 只删缓存 + TTL | 最终一致,窗口秒级 | 低 | 大部分读多写少场景 |
| 延迟双删 | 窗口缩到百毫秒 | 中 | 价格/库存等敏感读 |
| Binlog 订阅回写 | 最终一致,延迟秒级 | 高 | 主数据变更驱动的强协同 |
| 强一致(先更新缓存) | 无窗口但易写乱 | 高 | 几乎不用,违背缓存初衷 |
一句话:缓存本质是「用一致性换性能」——接受「短暂旧数据」换「低延迟高吞吐」是分布式缓存的契约;要强一致的数据就别进缓存,进了缓存就认最终一致。
4.3 命中率与容量
缓存命中率决定数据库压力,容量决定成本:
命中率提升:
- TTL 设置合理(过短命中率低,过长脏数据久)
- 热点提前预热(开服前回填热 key)
- 淘汰策略匹配访问模式:读多写少用 LRU,稳定热门用 LFU
容量规划:
容量 = 峰值读请求 × 单 key 大小 × 保留时长
超容量 → 淘汰(LRU 驱逐)或加节点
监控淘汰率:淘汰率突增说明容量不足,需扩容
五、总结
分布式缓存系统的骨架是「分片 + 副本 + 代理 + 一致性协同」:一致性哈希与虚拟节点把 key 均匀打散到分片并支撑平滑扩缩容,代理层做无状态路由、热点感知与故障摘除,主从结构与自动故障转移保证可用性,Cache Aside + 写后删缓存配合延迟双删或 Binlog 订阅实现最终一致。防护体系上,布隆过滤器挡穿透、互斥锁挡击穿、TTL 抖动与多副本挡雪崩、本地缓存与多副本打散热点。最终,缓存系统以「可容忍的短暂不一致」换来「百倍于数据库的读吞吐」,成为保护数据库、提升系统性能的第一道也是最重要的一道防线。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。