系统设计:分布式缓存架构设计(缓存雪崩/穿透/击穿与高可用方案)
本文完整梳理分布式缓存的核心设计:缓存策略选型、三大经典问题(雪崩/穿透/击穿)的根因与解法、高可用架构,以及各方案背后的工程权衡。
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 压力,不用于强一致读)。
8. 真实系统中的缓存设计
上面讲的是通用模式。下面看几个生产级系统是如何设计缓存层的——它们都遵循同样的原则,但在细节上各有取舍。
8.1 kube-apiserver:Watch Cache
问题背景:Kubernetes 集群中所有组件(scheduler、controller-manager、kubelet)都需要频繁读取 Pod、Node、Service 等资源状态。如果每次 LIST/GET 都直接查 etcd,etcd 会被打垮。
解法:Watch Cache(观察缓存)
1 | etcd ──watch──▶ kube-apiserver 内存 Watch Cache ──▶ scheduler/controller/kubectl |
- kube-apiserver 启动时,从 etcd 全量 LIST 所有资源,加载到内存 Watch Cache
- 之后通过 etcd Watch 机制接收增量变更事件,实时更新内存快照
- 所有
kubectl get pods、list-watch 请求直接从内存返回,不再访问 etcd - 只有写操作(create/update/delete)才会写入 etcd
这是 Read-Through 缓存的变体:kube-apiserver 是缓存层,etcd 是数据源。
一致性保证:Watch Cache 通过 etcd revision(单调递增版本号)保证”不早于某版本”的一致性读。客户端可以携带 resourceVersion 指定最低一致性要求:
| resourceVersion | 语义 |
|---|---|
| 未指定 | 可能读到稍旧的缓存数据(最终一致) |
"0" |
从缓存任意版本读(最快) |
| 具体版本号 | 保证读到该版本之后的数据 |
收益:在大型集群(5000 节点)中,Watch Cache 可将 etcd 的读 QPS 从数万降至数百,etcd 只处理写请求和 Watch 连接。
8.2 etcd:MVCC + 内存索引
问题背景:etcd 是强一致的 KV 存储,底层用 boltdb(B+ 树,磁盘存储)。如果每次读都扫磁盘,延迟会很高。
解法:MVCC + treeIndex(内存 B 树索引)
1 | 写路径: |
treeIndex 的结构:
1 | // 每个 key 对应一个 keyIndex,记录所有历史版本 |
MVCC 的意义:etcd 不直接覆盖旧值,而是追加新版本。这样:
- Watch 可以从任意历史 revision 开始回放事件(不会丢事件)
- 读写互不阻塞(读旧版本,写新版本)
- 代价:需要定期 compaction 清理旧版本,否则 boltdb 无限增长
内存缓存的边界:treeIndex 全在内存,boltdb 靠操作系统 page cache 加速。etcd 本身不做应用层缓存(它就是缓存的数据源),它的设计目标是”小数据、强一致、高可靠”,而不是高吞吐读。
8.3 Linux 内核:Page Cache
最底层的缓存:操作系统的 Page Cache 是所有磁盘 IO 的第一道缓存。
1 | 应用层 read(fd) |
- Redis 的 RDB/AOF 持久化、etcd 的 boltdb 读取,都受益于 Page Cache
free -h中的buff/cache列就是 Page Cache 占用- 内存不足时,内核自动 LRU 淘汰冷页(匿名页靠 swap)
与应用层缓存的区别:Page Cache 对应用透明,无需修改代码;应用层缓存(Redis)可以存结构化数据、设置 TTL、做分布式共享。
8.4 对比:不同系统的缓存设计取舍
| 系统 | 缓存层 | 数据源 | 一致性模型 | 淘汰策略 | 核心权衡 |
|---|---|---|---|---|---|
| kube-apiserver | 内存 Watch Cache | etcd | 最终一致(revision 对齐) | 不淘汰(全量) | 全量缓存换低 etcd 压力 |
| etcd treeIndex | 内存 B 树索引 | boltdb(磁盘) | 强一致 | 不淘汰(全量) | 索引在内存,值在磁盘 |
| Redis | 内存 KV | 业务 DB | 最终一致(TTL + 手动失效) | LRU / LFU / TTL | 速度换一致性,支持分布式 |
| Linux Page Cache | 内存页 | 磁盘文件 | 强一致(写穿到磁盘) | LRU(内核自动) | 对应用透明,操作系统管理 |
| CDN | 边缘节点磁盘/内存 | 源站 | 最终一致(TTL / purge) | LRU + TTL | 地理分布,降低源站带宽 |
| DNS 本地缓存 | 本地内存 | 上游 DNS | 最终一致(TTL) | TTL 到期自动失效 | 减少 DNS 查询延迟 |
规律总结:
- 缓存层越靠近计算(内存 > 本地磁盘 > 远端网络),延迟越低,但容量越小、一致性越难保证
- 全量缓存(kube-apiserver Watch Cache)可以提供更强的一致性,但要求数据集能放进内存
- 增量更新(Watch/binlog/CDC)比定时轮询效率高得多,是生产系统的主流选择
- 强一致(etcd treeIndex)通过将 key→revision 索引放内存、value 放磁盘,在不牺牲一致性的前提下加速读操作
总结
| 问题 | 根因 | 核心解法 |
|---|---|---|
| 缓存雪崩 | 大量 key 同时过期 | TTL 加抖动 + 多级缓存 |
| 缓存穿透 | 查不存在的数据 | 布隆过滤器 |
| 缓存击穿 | 热点 key 突然过期 | 互斥锁 / 逻辑过期 |
| 数据不一致 | 并发写竞态 | 删除而非更新 + 延迟双删 |
| 热点 Key | 单节点 QPS 超限 | Key 散列到多个分片 |
核心理念:缓存问题的本质是”速度(内存)换一致性(磁盘是真相来源)”,每个方案都是在这个 Trade-off 上做取舍。