设计分布式缓存系统
问题
如何设计一套高性能、高可用的分布式缓存系统?
答案
一、缓存系统整体架构
| 层级 | 介质 | 延迟 | 容量 | 生命周期 |
|---|---|---|---|---|
| L1 本地缓存 | JVM / 进程内存 | ~1μs | 小(MB 级) | 短 TTL(秒~分) |
| L2 分布式缓存 | Redis Cluster | ~1ms | 大(GB~TB) | 中 TTL(分~时) |
| 数据库 | MySQL | ~10ms | 无限 | 持久化 |
二、本地缓存设计
// L1 本地缓存 —— 使用 LRU + TTL
class LocalCache<T> {
// 存储结构:key → { value, expireAt }
private cache = new Map<string, { value: T; expireAt: number }>();
private maxSize: number;
private defaultTTL: number; // 毫秒
constructor(maxSize = 1000, defaultTTL = 30_000) {
this.maxSize = maxSize;
this.defaultTTL = defaultTTL;
}
get(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
// TTL 过期
if (Date.now() > entry.expireAt) {
this.cache.delete(key);
return null;
}
// LRU:删除再重新插入(Map 保持插入顺序)
this.cache.delete(key);
this.cache.set(key, entry);
return entry.value;
}
set(key: string, value: T, ttl?: number): void {
// 容量满时,淘汰最早的 key
if (this.cache.size >= this.maxSize) {
const oldest = this.cache.keys().next().value;
if (oldest !== undefined) this.cache.delete(oldest);
}
this.cache.set(key, {
value,
expireAt: Date.now() + (ttl ?? this.defaultTTL),
});
}
invalidate(key: string): void {
this.cache.delete(key);
}
}
三、分布式缓存选型
| 方案 | 特点 | 适用场景 |
|---|---|---|
| Redis Cluster | 哈希槽分片、主从复制 | 通用场景 |
| Redis Sentinel | 主从 + 自动故障转移 | 中小规模 |
| Memcached | 纯内存、多线程 | 简单 KV 缓存 |
| KeyDB | Redis 兼容、多线程 | 高吞吐 |
四、缓存一致性方案
核心难题
缓存与数据库的双写一致性是缓存系统设计的核心挑战。
推荐方案:Cache Aside(旁路缓存)
各方案对比:
| 方案 | 一致性 | 复杂度 | 说明 |
|---|---|---|---|
| Cache Aside | 最终一致 | 低 | 推荐,先写 DB 再删缓存 |
| Read/Write Through | 强一致 | 中 | 缓存层代理 DB 操作 |
| Write Behind | 弱一致 | 高 | 异步写 DB,可能丢数据 |
| 延迟双删 | 最终一致 | 中 | 删缓存→写 DB→延迟再删缓存 |
| Binlog 订阅 | 最终一致 | 中 | Canal 监听 binlog 自动删缓存 |
延迟双删实现:
async function updateWithDelayedDelete(
key: string,
updateFn: () => Promise<void>
) {
// 第一次删除缓存
await redis.del(key);
// 更新数据库
await updateFn();
// 延迟再次删除(覆盖并发读写期间的脏数据)
setTimeout(async () => {
await redis.del(key);
}, 500); // 延迟时间 > 主从延迟 + 一次读请求时间
}
五、热点 Key 处理
Key 打散:
// 将热点 key 分散到多个副本,避免单 Redis 节点压力过大
async function getHotKey(key: string, shards = 3): Promise<string | null> {
// 随机选一个分片
const shard = Math.floor(Math.random() * shards);
const shardKey = `${key}:shard:${shard}`;
return redis.get(shardKey);
}
async function setHotKey(key: string, value: string, shards = 3) {
// 所有分片都写入
const pipeline = redis.pipeline();
for (let i = 0; i < shards; i++) {
pipeline.set(`${key}:shard:${i}`, value, 'EX', 60);
}
await pipeline.exec();
}
六、缓存穿透 / 击穿 / 雪崩
| 问题 | 描述 | 解决方案 |
|---|---|---|
| 穿透 | 查询不存在的数据 | 布隆过滤器 + 空值缓存 |
| 击穿 | 热点 key 过期瞬间 | 互斥锁 + 永不过期 + 逻辑过期 |
| 雪崩 | 大批 key 同时过期 | 随机 TTL + 多级缓存 + 限流 |
// 互斥锁防击穿
async function getWithMutex(key: string): Promise<string | null> {
let value = await redis.get(key);
if (value !== null) return value;
const lockKey = `lock:${key}`;
// 尝试获取互斥锁(SETNX)
const locked = await redis.set(lockKey, '1', 'EX', 10, 'NX');
if (locked) {
try {
// 获得锁:从 DB 加载并回填缓存
value = await loadFromDB(key);
if (value !== null) {
// 随机 TTL 防雪崩
const ttl = 3600 + Math.floor(Math.random() * 600);
await redis.set(key, value, 'EX', ttl);
} else {
// 空值缓存防穿透
await redis.set(key, '', 'EX', 60);
}
} finally {
await redis.del(lockKey);
}
} else {
// 未获得锁:短暂等待后重试
await new Promise((r) => setTimeout(r, 100));
return getWithMutex(key);
}
return value;
}
七、缓存预热与监控
预热策略:
1. 启动时预热 —— 提前加载热点数据
2. 定时预热 —— CronJob 定期刷新即将过期的热点
3. 访问驱动 —— 首次 Miss 后自动加载
监控指标:
1. 命中率(Hit Rate) → 目标 > 95%
2. 延迟 P99 → 目标 < 5ms
3. 内存使用率 → 告警阈值 80%
4. 逐出率(Eviction) → 告警阈值 > 0(容量不足信号)
5. 连接数 → 监控峰值
常见面试问题
Q1: 多级缓存之间如何保证一致性?
答案:
- L1 → L2:L1 使用极短 TTL(5~30s),自动过期后从 L2 刷新
- L2 → DB:使用 Cache Aside 模式,写操作先写 DB 再删 L2
- 多实例 L1 同步:通过 Redis Pub/Sub 广播失效通知
// Redis Pub/Sub 广播 L1 失效
// 写操作时发布失效消息
await redis.del(key);
await redis.publish('cache:invalidate', key);
// 每个应用实例订阅频道,清除本地缓存
subscriber.subscribe('cache:invalidate');
subscriber.on('message', (channel, key) => {
localCache.invalidate(key);
});
Q2: 缓存和数据库双写,先删缓存还是先更新数据库?
答案:
推荐先更新数据库再删缓存:
| 顺序 | 风险 | 数据不一致窗口 |
|---|---|---|
| 先删缓存再写 DB | 并发读在写 DB 前回填旧数据 | 较长 |
| 先写 DB 再删缓存 | 删缓存失败导致旧数据残留 | 较短 |
先写 DB 再删缓存的不一致是极端小概率事件(缓存恰好过期 + 读请求在写之后回填),而先删缓存的不一致是高概率事件。
Q3: Redis Cluster 如何做容量规划?
答案:
1. 估算数据量:
单条 KV 平均大小 × 预估 Key 数量 × 1.5(Redis 内存开销系数)
2. 实例数量:
- 单节点建议 < 16GB(避免 fork 慢)
- 节点数 = 总容量 / 单节点容量
- 至少 3 主 3 从
3. 带宽估算:
QPS × 平均响应大小 = 网络带宽需求
4. CPU 估算:
单核 Redis 约 10w QPS(简单命令)
复杂命令(ZRANGEBYSCORE)需要更多 CPU
Q4: 如何处理大 Key?
答案:
| 类型 | 判断标准 | 处理方案 |
|---|---|---|
| 大 String | > 1MB | 压缩 / 拆分 / 存 OSS |
| 大 Hash | > 5000 field | 拆分为多个小 Hash |
| 大 List | > 10000 元素 | 分段存储 |
| 大 Set | > 10000 成员 | 拆分为多个子 Set |
发现方式:redis-cli --bigkeys、MEMORY USAGE key。