系统设计面试:分布式缓存架构设计(缓存雪崩/穿透/击穿与高可用方案)

缓存是系统设计面试中出现频率最高的话题之一。本文完整梳理分布式缓存的核心设计:缓存策略选型、三大经典问题(雪崩/穿透/击穿)的根因与解法、高可用架构,以及面试中的常见追问答法。


1. 为什么需要缓存?

面试开场先建立直觉:

1
2
3
4
5
6
7
没有缓存:
User → API Server → Database(磁盘 I/O,毫秒级)

有缓存:
User → API Server → Redis(内存,微秒级)
↓ Cache Miss
→ Database → 写回 Redis

典型收益

  • 数据库 QPS 从 10k 降至 1k(90% 请求命中缓存)
  • P99 延迟从 50ms 降至 2ms
  • 数据库成本降低 80%

代价:引入数据一致性问题、缓存层本身的可用性问题。


2. 缓存策略选型

2.1 Cache-Aside(旁路缓存)— 最常用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def get_user(user_id):
# 1. 先查缓存
user = redis.get(f"user:{user_id}")
if user:
return user
# 2. Cache Miss → 查数据库
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
# 3. 写入缓存,设置 TTL
redis.setex(f"user:{user_id}", ttl=3600, value=user)
return user

def update_user(user_id, data):
db.update("UPDATE users SET ... WHERE id = ?", user_id, data)
redis.delete(f"user:{user_id}") # 删除缓存,而不是更新

为什么删除而不是更新缓存?

  • 更新缓存在并发写时有竞态:两个写请求可能导致缓存存储了旧数据
  • 删除是幂等的,下次读时自然重建,更安全

2.2 Write-Through(写穿)

写操作同时写数据库 + 缓存,读操作只读缓存。

1
2
Write: App → Cache → DB(同步)
Read: App → Cache(永不 Miss)

优点:数据一致性强;缺点:写延迟高(等待 DB 写完)

2.3 Write-Behind(写回)

写操作只写缓存,异步批量刷到 DB。

1
Write: App → Cache(立即返回)→ 异步 → DB

优点:写性能极高;缺点:有数据丢失风险(缓存宕机前未落盘)

面试选型口诀

  • 读多写少 → Cache-Aside
  • 写多读少、强一致 → Write-Through
  • 写多、允许丢失少量数据 → Write-Behind

3. 三大经典问题

3.1 缓存雪崩(Cache Avalanche)

定义:大量缓存在同一时刻过期,所有请求同时打到数据库,导致数据库崩溃。

1
2
3
4
5
时刻 T:10万个 key 同时过期

所有请求 → DB(直接打满)

DB 崩溃 → 服务不可用

解法

方案 实现 效果
TTL 加随机抖动 TTL = base_ttl + random(0, 300s) 分散过期时间
永不过期 + 异步更新 后台线程主动刷新热点 key 彻底避免 Miss
多级缓存 L1(本地内存)+ L2(Redis) DB 压力分层
熔断降级 DB 被打满时返回降级数据 兜底保护
1
2
3
4
5
6
7
import random

def set_cache(key, value, base_ttl=3600):
# 加 ±10% 随机抖动,避免同一批 key 同时过期
jitter = random.randint(-360, 360)
ttl = base_ttl + jitter
redis.setex(key, ttl, value)

3.2 缓存穿透(Cache Penetration)

定义:查询一个数据库中根本不存在的数据,缓存永远 Miss,每次都打到 DB。

1
2
3
4
5
攻击者:不断请求不存在的 user_id=-1

缓存:Miss(因为不存在)

DB:每次都被查询 → 被打垮

解法

方案一:缓存空值

1
2
3
4
user = db.query(user_id)
if user is None:
redis.setex(f"user:{user_id}", ttl=60, value="NULL") # 缓存空值
return None

缺点:攻击者可以用不同 ID 绕过(内存浪费)。

方案二:布隆过滤器(推荐)

1
2
3
4
5
6
7
8
9
# 启动时将所有合法 user_id 写入布隆过滤器
bloom_filter = BloomFilter(capacity=10_000_000, error_rate=0.001)
for uid in db.all_user_ids():
bloom_filter.add(uid)

def get_user(user_id):
if user_id not in bloom_filter:
return None # 直接拒绝,不查 DB
return cache_aside(user_id)

布隆过滤器特性:

  • 空间效率极高(1000万个元素约 1.2MB)
  • 不存在的一定不存在(无假阴性)
  • 可能误判存在(假阳性率可控,如 0.1%)

3.3 缓存击穿(Cache Breakdown/Hotspot)

定义:单个热点 key 突然过期,大量并发请求同时 Cache Miss,瞬间打穿数据库。

1
2
3
4
5
热点 key "flash_sale:1001" 过期

100万个请求同时 Miss

100万个请求同时查 DB → DB 崩溃

注意:击穿 vs 雪崩的区别:击穿是单个热点 key,雪崩是大量 key 同时过期

解法

方案一:互斥锁(Mutex Lock)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def get_hot_item(item_id):
value = redis.get(f"item:{item_id}")
if value:
return value

# 争抢分布式锁
lock_key = f"lock:item:{item_id}"
if redis.set(lock_key, "1", nx=True, ex=5): # 只有一个请求能拿到锁
try:
value = db.query(item_id)
redis.setex(f"item:{item_id}", 3600, value)
return value
finally:
redis.delete(lock_key)
else:
# 其他请求等待后重试
time.sleep(0.05)
return get_hot_item(item_id)

方案二:逻辑过期(永不真正过期)

1
2
3
4
5
6
7
8
9
10
11
def get_hot_item(item_id):
cached = redis.get(f"item:{item_id}")
if cached:
data, expire_time = parse(cached)
if time.now() < expire_time:
return data
# 逻辑过期,异步更新,返回旧数据(不阻塞)
if acquire_lock(item_id):
thread_pool.submit(refresh_cache, item_id)
return data # 返回旧值,用户几乎无感知
return None

4. 缓存一致性

4.1 读写一致性问题

最常见的双写不一致场景:

1
2
3
线程 A:更新 DB(user.age=30)
线程 B:更新 DB(user.age=31),更新 Cache(age=31)
线程 A:更新 Cache(age=30)← 覆盖了 B,Cache 存的是旧值!

解法:Delete > Update

  • 写操作:先更新 DB,再删除缓存(不更新)
  • 依赖 TTL 或下次读时重建缓存
  • 即使有短暂不一致,也是”最终一致”

4.2 延迟双删

对于极端并发场景,删缓存后可能有请求把旧数据回填:

1
2
3
4
5
def update_user(user_id, data):
redis.delete(f"user:{user_id}") # 第一次删除
db.update(user_id, data)
time.sleep(0.1) # 等待可能的旧读请求完成
redis.delete(f"user:{user_id}") # 第二次删除(延迟双删)

4.3 强一致方案:Canal + MQ

对强一致要求的场景,用 binlog 订阅替代手动删缓存:

1
DB 写入 → binlog → Canal → MQ → 消费者删除/更新缓存

优点:业务代码无需处理缓存;缺点:延迟(通常 100ms 内)。


5. Redis 高可用架构

5.1 三种部署模式对比

模式 可用性 数据容量 适用场景
单机 受单机内存限制 开发/测试
Sentinel(哨兵) 受单机内存限制 中小规模
Cluster(集群) 水平扩展 大规模

5.2 Redis Sentinel(哨兵)

1
2
3
4
5
6
7
8
9
10
11
           ┌──────────┐
│ Sentinel1 │
└─────┬─────┘
│ 监控
┌──────────────▼──────────────┐
│ Master(Primary) │
└──┬──────────────────────┬──┘
│ 异步复制 │ 异步复制
┌────▼───┐ ┌────▼───┐
│Replica1│ │Replica2│
└────────┘ └────────┘

故障切换流程:

  1. Sentinel 检测 Master 失联(默认 30s)
  2. 多数 Sentinel 投票确认(Quorum = 2/3)
  3. 选举一个 Replica 提升为新 Master
  4. 客户端通过 Sentinel 获取新 Master 地址

5.3 Redis Cluster

数据按 16384 个哈希槽分片,每个节点负责一段槽位:

1
2
3
4
5
Node1: slots 0-5460      (1/3 数据)
Node2: slots 5461-10922 (1/3 数据)
Node3: slots 10923-16383 (1/3 数据)

计算槽位:slot = CRC16(key) % 16384

一致性哈希 vs 哈希槽

  • 哈希槽:扩缩容时迁移槽位,影响范围可控
  • 一致性哈希:扩缩容只影响相邻节点,更适合自研场景

6. 热点 Key 专项处理

当单个 Key 的 QPS 超过 Redis 单节点承受能力(通常 10万 QPS):

方案:Key 散列(Key Sharding)

1
2
3
4
5
6
7
8
9
10
HOT_KEY_SHARDS = 100

def get_hot_key(key):
shard = random.randint(0, HOT_KEY_SHARDS - 1)
shard_key = f"{key}:shard:{shard}"
return redis.get(shard_key)

def set_hot_key(key, value, ttl=3600):
for i in range(HOT_KEY_SHARDS):
redis.setex(f"{key}:shard:{i}", ttl, value)

读时随机选一个分片,写时更新所有分片,将单 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 上做取舍。