系统设计:分布式缓存架构
缓存是高性能系统的核心组件。如何从单机缓存演进到分布式缓存?如何处理好一致性与高可用?
为什么需要缓存?
系统的性能瓶颈
| 操作 | 耗时量级 |
|---|---|
| CPU 寄存器访问 | < 1ns |
| L1/L2/L3 缓存 | 1-10ns |
| 主内存访问 | 100ns |
| SSD 随机读取 | 100μs |
| 网络请求(同机房) | 0.5ms |
| 磁盘顺序读取 | 1ms |
| 数据库查询(简单) | 10ms |
| 跨机房网络 | 50ms |
规律:每往下走一层,延迟增加一个数量级。
缓存的收益
无缓存: 读请求 → 应用 → 数据库(10ms)
有缓存: 读请求 → 应用 → Redis(0.5ms) → miss → 数据库(10ms)
假设命中率 90%:
平均延迟 = 0.9 * 0.5ms + 0.1 * 10.5ms = 1.5ms(相比 10ms 提升 6.7 倍)
三种缓存模式
模式一:Cache-Aside(旁路缓存,最常用)
读:
1. 先查缓存 → 命中直接返回
2. 未命中 → 查数据库 → 写入缓存 → 返回
写:
1. 先写数据库
2. 删缓存(不是更新缓存!)
为什么写操作是删缓存而不是更新缓存?
考虑并发场景:
T1 读数据A(缓存miss) T2 更新数据A
T1 从DB读到旧值A=1
T2 写入DB: A=2
T2 更新缓存: A=2
T1 写入缓存: A=1 ← 脏数据!缓存被旧值覆盖
如果是删缓存:
T1 读数据A(缓存miss) T2 更新数据A
T1 从DB读到旧值A=1
T2 写入DB: A=2
T2 删除缓存
T1 写入缓存: A=1 ← 仍有短暂不一致,但概率更低
Cache-Aside 的优缺点:
- ✅ 实现简单,与业务代码解耦
- ✅ 缓存故障不阻塞业务(可降级到数据库)
- ❌ 存在短暂不一致(最终一致性)
模式二:Read/Write Through
读:
1. 应用 → 缓存层 → 命中返回
2. 未命中 → 缓存层自动从DB加载 → 写入缓存 → 返回
写:
1. 应用 → 缓存层 → 先写缓存
2. 缓存层同步写数据库 → 返回
特点:缓存层抽象为一个存储中间件,应用不直接访问 DB。
- 需要缓存层支持(如 Redis 本身不支持,需要中间件如 CacheLib)
模式三:Write Behind(异步写回)
写:
1. 应用 → 缓存层 → 只写缓存,立即返回
2. 缓存层异步批量写回数据库
- ✅ 写入极快(纯内存操作)
- ❌ 一致性强依赖于写回策略,宕机可能丢数据
- 适用于:写入量极大、允许短暂不一致的场景(如计数器、日志)
缓存的三大问题
问题一:缓存穿透(Cache Penetration)
现象:大量请求查询一个不存在的数据,缓存和数据库都没有,每次都打到数据库。
原因:
- 恶意攻击(如伪造大量不存在的用户 ID)
- 业务逻辑缺陷(如参数校验不严)
解决方案:
- 布隆过滤器(推荐)
import mmh3
import bitarray
class BloomFilter:
def __init__(self, size, hash_count):
self.size = size
self.hash_count = hash_count
self.bit_array = bitarray.bitarray(size)
self.bit_array.setall(0)
def add(self, item):
for i in range(self.hash_count):
idx = mmh3.hash(item, i) % self.size
self.bit_array[idx] = 1
def check(self, item) -> bool:
for i in range(self.hash_count):
idx = mmh3.hash(item, i) % self.size
if not self.bit_array[idx]:
return False
return True
# 初始化时将所有有效 ID 加入布隆过滤器
# 查询时先查布隆过滤器:
# - 返回 False:一定不存在,直接拒绝
# - 返回 True:可能存在,继续查缓存/数据库
- 缓存空值
# 查询数据库未命中时,将 key 缓存为特殊值(如 "null"),设置短 TTL
def get_user(user_id):
cache_key = f"user:{user_id}"
value = redis.get(cache_key)
if value == "__NULL__":
return None
if value:
return json.loads(value)
db_value = db.query("SELECT * FROM users WHERE id = %s", user_id)
if db_value:
redis.setex(cache_key, 3600, json.dumps(db_value))
else:
redis.setex(cache_key, 60, "__NULL__") # 空值短缓存
return db_value
问题二:缓存击穿(Cache Breakdown)
现象:某个热点 key 失效的瞬间,大量并发请求同时打到数据库。
解决方案:
- 互斥锁(Mutex)
import threading
mutex_cache = {}
def get_with_mutex(key, load_fn):
value = redis.get(key)
if value:
return value
# 加互斥锁
if key not in mutex_cache:
mutex_cache[key] = threading.Lock()
with mutex_cache[key]:
# 双重检查
value = redis.get(key)
if value:
return value
# 唯一一个去数据库加载
value = load_fn()
redis.setex(key, 3600, value)
return value
- 逻辑过期(永不过期)
# 缓存不设置 TTL,而是异步线程定时刷新
# value 中嵌入逻辑过期时间
{
"data": {...}, # 真实数据
"expire_at": 1735689600 # 逻辑过期时间戳
}
# 读取时发现逻辑过期,异步触发刷新,但返回旧数据
问题三:缓存雪崩(Cache Avalanche)
现象:大量 key 同时失效,或者 Redis 宕机,导致所有请求涌向数据库,DB 瞬间被打垮。
原因:
- 批量设置相同的 TTL
- Redis 集群故障
- 缓存服务重启
解决方案:
- 随机 TTL
import random
ttl = 3600 + random.randint(-300, 300) # 基础 1h,浮动 ±5min
redis.setex(key, ttl, value)
- 多级缓存
用户请求 → 本地缓存(Caffeine/Guava) → Redis → 数据库
↓ miss ↓ miss
P99 < 1ms P99 < 5ms
- Redis 高可用
- 主从复制 + 哨兵(Sentinel)自动故障转移
- Redis Cluster 分片,避免单点
- 持久化(RDB + AOF)
- 熔断降级
- 当数据库负载过高时,快速失败返回默认值或简化数据
Redis Cluster 架构
数据分片(Sharding)
Redis Cluster 使用 哈希槽(Hash Slot) 分片:
16384 个哈希槽(0-16383)
key 的槽位 = CRC16(key) % 16384
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Master A │ │ Master B │ │ Master C │
│ slots 0-5k │ │ slots 5k-10k│ │ slots 10k-16k│
│ Slave A1 │ │ Slave B1 │ │ Slave C1 │
└─────────────┘ └─────────────┘ └─────────────┘
为什么是 16384 个槽?
- CRC16 产生 16 位值(65536),取 16384(2^14)是均衡性和通信效率的折中
- 集群节点间交换槽位信息时,用 bitmap 表示,16384 位 = 2KB,传输效率高
一致性哈希 vs 哈希槽
| 特点 | 一致性哈希 | Redis 哈希槽 |
|---|---|---|
| 节点变化影响 | 仅相邻节点 | 需手动迁移槽位 |
| 重新平衡 | 自动(虚拟节点) | 手动(redis-trib) |
| 均匀性 | 依赖虚拟节点数 | 固定 16384 份,均匀 |
| 故障转移 | 需额外实现 | 内置 Sentinel |
一致性哈希适用场景:Memcached 等无内置集群的缓存,客户端实现分片。
缓存一致性如何保证?
最终一致性方案
写操作:
1. 更新数据库
2. 删除缓存
3. (可选)发送缓存失效消息到 MQ,异步确保删除
读操作:
1. 查缓存 → 命中返回
2. 未命中 → 查数据库 → 写缓存 → 返回
强一致性方案(极少使用)
使用分布式锁或 2PC:
- 性能极差,违背缓存初衷
- 通常只在金融交易等极端场景使用
延迟双删
# 降低不一致窗口
redis.delete(key) # 先删缓存
db.update(data) # 再更新数据库
time.sleep(500ms) # 等待主从同步
redis.delete(key) # 再次删除缓存
面试答题框架
第一步:明确场景(30秒)
我需要了解:读多写少还是读写均衡?数据一致性要求?QPS 量级?
第二步:选择缓存模式(1分钟)
绝大多数场景推荐 Cache-Aside:简单、鲁棒、与业务解耦。
第三步:讲解三大问题(3分钟)
穿透 → 布隆过滤器/空值缓存;击穿 → 互斥锁/逻辑过期;雪崩 → 随机 TTL/多级缓存/高可用。
第四步:高可用架构(2分钟)
Redis Cluster(哈希槽分片)+ 主从复制 + 哨兵自动故障转移。
第五步:一致性取舍(1分钟)
缓存追求最终一致性,强一致性方案成本过高,通常用延迟双删降低不一致窗口。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。