跳到主要内容

设计分布式缓存系统

问题

如何设计一套高性能、高可用的分布式缓存系统?

答案

一、缓存系统整体架构

层级介质延迟容量生命周期
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 缓存
KeyDBRedis 兼容、多线程高吞吐

四、缓存一致性方案

核心难题

缓存与数据库的双写一致性是缓存系统设计的核心挑战。

推荐方案: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: 多级缓存之间如何保证一致性?

答案

  1. L1 → L2:L1 使用极短 TTL(5~30s),自动过期后从 L2 刷新
  2. L2 → DB:使用 Cache Aside 模式,写操作先写 DB 再删 L2
  3. 多实例 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 --bigkeysMEMORY USAGE key

相关链接