系统设计面试:分布式缓存架构设计(缓存雪崩/穿透/击穿与高可用方案)
缓存是系统设计面试中出现频率最高的话题之一。本文完整梳理分布式缓存的核心设计:缓存策略选型、三大经典问题(雪崩/穿透/击穿)的根因与解法、高可用架构,以及面试中的常见追问答法。
1. 为什么需要缓存?
面试开场先建立直觉:
1 | 没有缓存: |
典型收益:
- 数据库 QPS 从 10k 降至 1k(90% 请求命中缓存)
- P99 延迟从 50ms 降至 2ms
- 数据库成本降低 80%
代价:引入数据一致性问题、缓存层本身的可用性问题。
2. 缓存策略选型
2.1 Cache-Aside(旁路缓存)— 最常用
1 | def get_user(user_id): |
为什么删除而不是更新缓存?
- 更新缓存在并发写时有竞态:两个写请求可能导致缓存存储了旧数据
- 删除是幂等的,下次读时自然重建,更安全
2.2 Write-Through(写穿)
写操作同时写数据库 + 缓存,读操作只读缓存。
1 | Write: App → Cache → DB(同步) |
优点:数据一致性强;缺点:写延迟高(等待 DB 写完)
2.3 Write-Behind(写回)
写操作只写缓存,异步批量刷到 DB。
1 | Write: App → Cache(立即返回)→ 异步 → DB |
优点:写性能极高;缺点:有数据丢失风险(缓存宕机前未落盘)
面试选型口诀:
- 读多写少 → Cache-Aside
- 写多读少、强一致 → Write-Through
- 写多、允许丢失少量数据 → Write-Behind
3. 三大经典问题
3.1 缓存雪崩(Cache Avalanche)
定义:大量缓存在同一时刻过期,所有请求同时打到数据库,导致数据库崩溃。
1 | 时刻 T:10万个 key 同时过期 |
解法:
| 方案 | 实现 | 效果 |
|---|---|---|
| TTL 加随机抖动 | TTL = base_ttl + random(0, 300s) |
分散过期时间 |
| 永不过期 + 异步更新 | 后台线程主动刷新热点 key | 彻底避免 Miss |
| 多级缓存 | L1(本地内存)+ L2(Redis) | DB 压力分层 |
| 熔断降级 | DB 被打满时返回降级数据 | 兜底保护 |
1 | import random |
3.2 缓存穿透(Cache Penetration)
定义:查询一个数据库中根本不存在的数据,缓存永远 Miss,每次都打到 DB。
1 | 攻击者:不断请求不存在的 user_id=-1 |
解法:
方案一:缓存空值
1 | user = db.query(user_id) |
缺点:攻击者可以用不同 ID 绕过(内存浪费)。
方案二:布隆过滤器(推荐)
1 | # 启动时将所有合法 user_id 写入布隆过滤器 |
布隆过滤器特性:
- 空间效率极高(1000万个元素约 1.2MB)
- 不存在的一定不存在(无假阴性)
- 可能误判存在(假阳性率可控,如 0.1%)
3.3 缓存击穿(Cache Breakdown/Hotspot)
定义:单个热点 key 突然过期,大量并发请求同时 Cache Miss,瞬间打穿数据库。
1 | 热点 key "flash_sale:1001" 过期 |
注意:击穿 vs 雪崩的区别:击穿是单个热点 key,雪崩是大量 key 同时过期。
解法:
方案一:互斥锁(Mutex Lock)
1 | def get_hot_item(item_id): |
方案二:逻辑过期(永不真正过期)
1 | def get_hot_item(item_id): |
4. 缓存一致性
4.1 读写一致性问题
最常见的双写不一致场景:
1 | 线程 A:更新 DB(user.age=30) |
解法:Delete > Update
- 写操作:先更新 DB,再删除缓存(不更新)
- 依赖 TTL 或下次读时重建缓存
- 即使有短暂不一致,也是”最终一致”
4.2 延迟双删
对于极端并发场景,删缓存后可能有请求把旧数据回填:
1 | def update_user(user_id, data): |
4.3 强一致方案:Canal + MQ
对强一致要求的场景,用 binlog 订阅替代手动删缓存:
1 | DB 写入 → binlog → Canal → MQ → 消费者删除/更新缓存 |
优点:业务代码无需处理缓存;缺点:延迟(通常 100ms 内)。
5. Redis 高可用架构
5.1 三种部署模式对比
| 模式 | 可用性 | 数据容量 | 适用场景 |
|---|---|---|---|
| 单机 | 低 | 受单机内存限制 | 开发/测试 |
| Sentinel(哨兵) | 高 | 受单机内存限制 | 中小规模 |
| Cluster(集群) | 高 | 水平扩展 | 大规模 |
5.2 Redis Sentinel(哨兵)
1 | ┌──────────┐ |
故障切换流程:
- Sentinel 检测 Master 失联(默认 30s)
- 多数 Sentinel 投票确认(Quorum = 2/3)
- 选举一个 Replica 提升为新 Master
- 客户端通过 Sentinel 获取新 Master 地址
5.3 Redis Cluster
数据按 16384 个哈希槽分片,每个节点负责一段槽位:
1 | Node1: slots 0-5460 (1/3 数据) |
一致性哈希 vs 哈希槽:
- 哈希槽:扩缩容时迁移槽位,影响范围可控
- 一致性哈希:扩缩容只影响相邻节点,更适合自研场景
6. 热点 Key 专项处理
当单个 Key 的 QPS 超过 Redis 单节点承受能力(通常 10万 QPS):
方案:Key 散列(Key Sharding)
1 | HOT_KEY_SHARDS = 100 |
读时随机选一个分片,写时更新所有分片,将单 Key 压力分散到 100 个 Key。
7. 面试常见追问
Q: 如何发现热点 Key?
A: Redis 4.0+ 支持
redis-cli --hotkeys命令(基于 LFU 策略);监控各 Key 的访问频率;也可在网关层统计请求 Key 分布。
Q: 缓存容量打满了怎么办?
A: 配置合理的淘汰策略。推荐
allkeys-lru(全局 LRU,所有 key 均可被淘汰)或volatile-lru(只淘汰有 TTL 的 key);同时监控内存使用率,在 80% 时扩容。
Q: Redis 和 Memcached 怎么选?
A: 选 Redis。Redis 支持丰富数据结构(Hash/ZSet/List)、持久化(RDB/AOF)、Lua 脚本原子操作、Cluster 模式;Memcached 仅在极高吞吐量 + 简单 KV 场景下有微小优势,实际项目中 Redis 覆盖绝大多数场景。
Q: 如何保证缓存和 DB 的强一致性?
A: 严格意义上的强一致(读总是读到最新写)需要分布式事务,代价极高;实践中通常接受”最终一致”(删缓存 + TTL 兜底,不一致窗口 < 1s);对真正强一致场景,读操作绕过缓存直接读 DB(缓存只用于降低 DB 压力,不用于强一致读)。
总结
| 问题 | 根因 | 核心解法 |
|---|---|---|
| 缓存雪崩 | 大量 key 同时过期 | TTL 加抖动 + 多级缓存 |
| 缓存穿透 | 查不存在的数据 | 布隆过滤器 |
| 缓存击穿 | 热点 key 突然过期 | 互斥锁 / 逻辑过期 |
| 数据不一致 | 并发写竞态 | 删除而非更新 + 延迟双删 |
| 热点 Key | 单节点 QPS 超限 | Key 散列到多个分片 |
面试核心:缓存问题的本质是”速度(内存)换一致性(磁盘是真相来源)”,每个方案都是在这个 Trade-off 上做取舍。