系统设计:分布式缓存架构

分布式缓存架构设计详解:缓存模式(Cache-Aside、Read/Write Through、Write Behind)、一致性策略、缓存穿透/击穿/雪崩解决方案、Redis Cluster 与高可用架构。

系统设计:分布式缓存架构

缓存是高性能系统的核心组件。如何从单机缓存演进到分布式缓存?如何处理好一致性与高可用?


为什么需要缓存?

系统的性能瓶颈

操作耗时量级
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)
  • 业务逻辑缺陷(如参数校验不严)

解决方案:

  1. 布隆过滤器(推荐)
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:可能存在,继续查缓存/数据库
  1. 缓存空值
# 查询数据库未命中时,将 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 失效的瞬间,大量并发请求同时打到数据库。

解决方案:

  1. 互斥锁(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
  1. 逻辑过期(永不过期)
# 缓存不设置 TTL,而是异步线程定时刷新
# value 中嵌入逻辑过期时间
{
    "data": {...},      # 真实数据
    "expire_at": 1735689600  # 逻辑过期时间戳
}

# 读取时发现逻辑过期,异步触发刷新,但返回旧数据

问题三:缓存雪崩(Cache Avalanche)

现象:大量 key 同时失效,或者 Redis 宕机,导致所有请求涌向数据库,DB 瞬间被打垮。

原因:

  • 批量设置相同的 TTL
  • Redis 集群故障
  • 缓存服务重启

解决方案:

  1. 随机 TTL
import random

ttl = 3600 + random.randint(-300, 300)  # 基础 1h,浮动 ±5min
redis.setex(key, ttl, value)
  1. 多级缓存
用户请求 → 本地缓存(Caffeine/Guava) → Redis → 数据库
              ↓ miss              ↓ miss
            P99 < 1ms            P99 < 5ms
  1. Redis 高可用
  • 主从复制 + 哨兵(Sentinel)自动故障转移
  • Redis Cluster 分片,避免单点
  • 持久化(RDB + AOF)
  1. 熔断降级
  • 当数据库负载过高时,快速失败返回默认值或简化数据

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分钟)

缓存追求最终一致性,强一致性方案成本过高,通常用延迟双删降低不一致窗口。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页