Files
test-repo/docs/zh-cn/appendix/cache-design.md
T

27 KiB
Raw Blame History

系统缓存设计:从原理到实战 (Interactive Guide to Caching)

💡 学习指南:本章节带你深入理解后端系统的"加速器"——缓存。我们将从最基础的"为什么要缓存"讲起,一步步掌握多级缓存架构、缓存模式、以及实战中的坑与解决方案。

0. 引言:看不见的"加速器"

你刷朋友圈时,为什么几秒钟就能加载出几百张图片? 你查询订单时,为什么瞬间就能看到几个月前的数据?

这背后都有一个功臣:缓存 (Cache)

如果数据库是"仓库",那缓存就是"柜台"。 常用的商品(热数据)放在柜台上,随拿随用;不常用的商品才需要去仓库里找。

0.1 为什么要缓存?

只有一个理由:

存储介质 访问延迟 吞吐量 典型用途
L1 CPU 缓存 ~1 ns 极高 寄存器、变量
内存 (Redis) ~100 ns 热点数据、会话
SSD 数据库 ~1 ms 持久化存储
HDD 数据库 ~10 ms 归档存储

关键点:缓存的本质是用空间换时间,通过在更快的存储介质中保留数据副本,减少访问慢速存储的次数。


1. 第一步:理解缓存的本质

1.1 局部性原理 (Locality Principle)

缓存之所以有效,是因为两个神奇的观察:

  1. 时间局部性 (Temporal Locality)

    • 如果你现在访问了某个数据,未来很可能再次访问它
    • 例子:一个用户登录后,接下来几分钟的每次请求都需要查询他的用户信息。
  2. 空间局部ity (Spatial Locality)

    • 如果你访问了某个数据,很可能访问它附近的数据
    • 例子:浏览商品列表时,通常会翻到下一页(相邻的商品)。

1.2 缓存的生命周期

一个缓存条目(Cache Entry)的一生:

  1. 写入 (Write):首次访问数据时,从数据库读取并存入缓存。
  2. 命中 (Hit):后续访问直接从缓存返回(快!)。
  3. 过期 (Expiration):超过设定时间(TTL),标记为过期。
  4. 淘汰 (Eviction):缓存满了,需要腾空间给新数据。

关键点:好的缓存设计需要平衡命中率Hit Ratio)和内存占用


2. 单机缓存 vs 分布式缓存

2.1 本地缓存 (Local Cache)

缓存和应用在同一个进程里。

  • 优点
    • 极快(没有网络开销)。
    • 简单(就是一个 Map/Dictionary)。
  • 缺点
    • 容量有限(受限于单机内存)。
    • 不一致(每个实例的缓存独立)。
  • 典型实现
    • Java: Caffeine、Guava Cache
    • Go: bigcache、ristretto
    • Python: functools.lru_cache
// Java Caffeine 示例
Cache<String, User> userCache = Caffeine.newBuilder()
    .maximumSize(10_000)           // 最多存 1 万条
    .expireAfterWrite(10, TimeUnit.MINUTES)  // 10 分钟过期
    .build();

// 使用
User user = userCache.get(userId, key -> {
    // 缓存没命中,从数据库查
    return database.getUserById(key);
});

2.2 分布式缓存 (Distributed Cache)

缓存是一个独立的服务,应用通过网络访问。

  • 优点
    • 容量巨大(可以集群扩展)。
    • 一致性好(所有实例共享同一份缓存)。
  • 缺点
    • 有网络延迟(通常 1-5 ms)。
    • 需要额外维护缓存服务。
  • 典型实现Redis、Memcached
# Python + Redis 示例
import redis

r = redis.Redis(host='localhost', port=6379)

def get_user(user_id):
    # 先查缓存
    cached = r.get(f'user:{user_id}')
    if cached:
        return json.loads(cached)

    # 缓存未命中,查数据库
    user = db.query(f'SELECT * FROM users WHERE id = {user_id}')

    # 写入缓存,过期时间 10 分钟
    r.setex(f'user:{user_id}', 600, json.dumps(user))
    return user

关键点:现代系统通常组合使用——本地缓存做第一道防线,分布式缓存做第二道防线。


3. 多级缓存架构 (Multi-Level Caching)

真实的系统通常是"多层防御":

用户请求
    ↓
浏览器缓存 (Cache-Control)
    ↓ (未命中)
CDN 缓存 (静态资源)
    ↓ (未命中)
负载均衡器
    ↓
应用服务器 (本地缓存: Caffeine)
    ↓ (未命中)
分布式缓存 (Redis)
    ↓ (未命中)
数据库 (MySQL / PostgreSQL)

3.1 每一层的特点

层级 存储介质 典型容量 响应时间 适用场景
浏览器缓存 用户磁盘 ~100 MB ~0 ms 静态资源(图片、CSS、JS
CDN 缓存 边缘节点 TB 级 ~10 ms 静态文件、API 响应
本地缓存 应用内存 ~1 GB ~1 ms 极热点数据(配置、白名单)
Redis 缓存 Redis 集群 ~100 GB ~5 ms 热点数据(用户信息、商品)
数据库 SSD/HDD TB ~ PB ~50 ms 持久化存储

关键点:每一层都是上一层的"保护伞",逐级过滤请求,最终打到数据库的流量可能只有原来的 1%


4. 缓存模式 (Caching Patterns)

4.0 为什么需要缓存模式?

问题场景

当有大量请求访问内部系统时,如果每个请求都需要操作数据库(例如查询操作),对于那种基本不变化的数据来说,每次都去数据库查询会极大地消耗性能。

尤其是在海量数据操作时,如果都从 DB 加载,这是在挑战用户的耐性。

生活中的例子

想象你要去小区里了解某个人在不在家。当没有通讯工具时:

  • 没有缓存:每次都要经过小区保安,再到具体单元楼,最终到这家门口,才知道在不在家。
  • 有缓存:如果换一个优秀的保安,他知道当前小区特定的家里是否有人,直接问保安就知道了,无需跑冤枉路。

这个"优秀保安"就是缓存。每次访问时先访问缓存,就能极大提高访问效率和系统性能。

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

最常用的模式,由应用代码直接控制缓存。

读取流程

1. 应用读取缓存
   ↓ 命中?
   ├─ 是 → 直接返回数据
   └─ 否 → 读取数据库
            ↓
         将数据写入缓存
            ↓
         返回数据

代码示例

def get_user(user_id):
    # 1. 先查缓存
    cached = cache.get(f'user:{user_id}')
    if cached:
        return cached

    # 2. 缓存未命中,查数据库
    user = db.query(f'SELECT * FROM users WHERE id = {user_id}')

    # 3. 将数据写入缓存
    if user:
        cache.set(f'user:{user_id}', user, ttl=600)

    return user

更新流程

1. 应用更新数据库
   ↓
2. 删除缓存(不是更新!)

代码示例

def update_user(user_id, new_data):
    # 1. 更新数据库
    db.execute('UPDATE users SET ... WHERE id = ?', user_id)

    # 2. 删除缓存(而不是更新)
    cache.delete(f'user:{user_id}')

    # 为什么删除而不是更新?
    # 因为并发更新时,更新缓存的顺序可能和数据库不一致!

关键点

  • 删除而非更新:避免并发写入导致缓存和数据库不一致
  • 延迟双删:为了极致一致性,可以在更新前再删一次
  • 最灵活:应用代码完全控制缓存逻辑

常见问题:会不会有脏数据?

场景:一个查询操作发现缓存没数据,准备去查 DB。此时另一个写操作更新了 DB 并删除了缓存,第一个操作从 DB 拿到的还是老数据并写入缓存。

解答:这种情况出现的概率极低!

  • 写操作需要锁表
  • 数据库写入比读取慢
  • 同等条件下,查询操作先返回,写操作再返回

4.2 Read-Through (读穿透)

缓存服务负责与数据库交互,应用代码只和缓存打交道。

工作原理

应用请求 → 缓存服务
            ↓
         缓存命中?
         ├─ 是 → 直接返回
         └─ 否 → 缓存服务自己加载 DB 数据
                  ↓
               更新缓存
                  ↓
               返回数据

代码示例

# 应用代码只需要
user = cache.get(user_id)  # 缓存库自动处理数据库查询

# 不需要手写:
# if not cached:
#     user = db.query(...)
#     cache.set(user_id, user)

对比 Cache-Aside

特性 Cache-Aside Read-Through
谁负责加载 应用代码 缓存服务
代码复杂度 需要手写缓存逻辑 简洁,只需调用 get
灵活性 高(完全控制) 低(依赖缓存库实现)
适用场景 通用场景 读多写少,缓存逻辑标准化

优点

  • 代码简洁,缓存逻辑对业务透明
  • 统一的缓存加载策略

缺点

  • 灵活性差,缓存库和数据库强绑定
  • 需要特殊的缓存库支持

4.3 Write-Through (写穿透)

更新时同时写缓存和数据库,由缓存服务负责同步。

工作原理

应用写请求 → 缓存服务
              ↓
           更新缓存
              ↓
           同步更新数据库
              ↓
           返回成功

代码示例

# 应用代码只需要
cache.set(user_id, user)  # 缓存库自动同步到数据库

# 不需要手写:
# db.update(user)
# cache.set(user_id, user)

关键点

  • 缓存和数据库同步更新,强一致性
  • 写入性能受数据库影响(相对较慢)

对比 Cache-Aside

特性 Cache-Aside Write-Through
写操作 先写 DB,再删缓存 同时写缓存和 DB
一致性 最终一致 强一致
写入性能 高(异步删缓存) 低(同步写 DB
缓存更新 懒加载(读时更新) 主动更新

优点

  • 数据一致性最好
  • 读取时总能命中缓存

缺点

  • 写入延迟高
  • 需要特殊的缓存库支持

4.4 Write-Behind (异步写回) 最快

更新时只写缓存,缓存服务异步批量更新数据库。

工作原理

应用写请求 → 缓存服务
              ↓
           更新缓存(立即返回)
              ↓
           ⚡ 异步批量写数据库(后台进行)

代码示例

# 应用代码只需要
cache.set(user_id, user)  # 立即返回,不等待数据库

# 缓存服务会在后台批量写入:
# while True:
#     batch = cache.get_dirty_entries()
#     db.batch_update(batch)

性能对比

模式 写入延迟 吞吐量 数据一致性
直接写 DB ~50 ms ~1000 QPS 强一致
Write-Through ~50 ms ~1000 QPS 强一致
Cache-Aside ~50 ms ~1000 QPS 最终一致
Write-Behind ~1 ms ~100,000 QPS 可能丢失

优点

  • 写入极快(毫秒级响应)
  • 吞吐量极高(十万级 QPS
  • 减少数据库 IO(批量写入)

缺点

  • 数据可能丢失(缓存崩了,数据就没了)
  • 缓存和数据库不一致(异步延迟)

适用场景

  • 秒杀系统(库存扣减)
  • 点赞数、浏览量(可接受少量丢失)
  • 计数器、统计信息
  • 订单、支付(绝对不能丢)

4.5 四种模式对比总结

模式 谁控制缓存 读取策略 写入策略 一致性 性能 使用频率
Cache-Aside 应用代码 懒加载 先写 DB,删缓存 最终一致 最常用
Read-Through 缓存服务 自动加载 先写 DB,删缓存 最终一致
Write-Through 缓存服务 自动加载 同时写缓存和 DB 强一致
Write-Behind 缓存服务 自动加载 只写缓存,异步写 DB 可能丢失 极高

选择建议

  • 大多数场景:使用 Cache-Aside,灵活且成熟
  • 读多写少:考虑 Read-Through,简化代码
  • 强一致性要求:考虑 Write-Through
  • 海量写入,可接受丢失:使用 Write-Behind

5. 缓存的"坑"与解决方案

5.1 缓存穿透 (Cache Penetration)

问题:查询一个不存在的数据(如恶意请求 id=-1),缓存没有,数据库也没有。导致每次请求都直接打到数据库。

解决方案

  1. 布隆过滤器 (Bloom Filter)
    • 在缓存前加一层过滤器,快速判断"这个 id 肯定不存在"。
    • 100% 判断不存在,但可能有误判(说不存在实际存在)。
# 布隆过滤器示例
from pybloom_live import BloomFilter

# 预热:把所有有效的 user_id 放进去
bf = BloomFilter(capacity=1000000, error_rate=0.001)

for user_id in all_valid_user_ids:
    bf.add(user_id)

def get_user(user_id):
    # 第一道防线:布隆过滤器
    if user_id not in bf:
        return None  # 肯定不存在,直接返回

    # 第二道防线:缓存
    cached = cache.get(f'user:{user_id}')
    if cached is not None:
        return cached

    # 第三道防线:数据库
    user = db.get_user(user_id)
    if user:
        cache.set(f'user:{user_id}', user)
    else:
        # 即使数据库没有,也缓存一个空值(防止穿透)
        cache.set(f'user:{user_id}', NULL, ttl=60)
    return user
  1. 缓存空对象
    • 查询不存在时,缓存一个 NULL 值(TTL 设置短一点,如 5 分钟)。

5.2 缓存击穿 (Cache Breakdown)

问题:某个热点数据过期(如微博热搜),瞬间几百万请求同时打到数据库。

解决方案

  1. 互斥锁 (Mutex Lock)
    • 只允许一个线程查数据库,其他线程等待。
import threading

lock = threading.Lock()

def get_user(user_id):
    cached = cache.get(f'user:{user_id}')
    if cached:
        return cached

    # 缓存未命中,尝试获取锁
    if lock.acquire(blocking=False):
        try:
            # 只有拿到锁的线程才查数据库
            user = db.get_user(user_id)
            cache.set(f'user:{user_id}', user, ttl=600)
            return user
        finally:
            lock.release()
    else:
        # 没拿到锁,等待一下再重试
        time.sleep(0.01)
        return get_user(user_id)  # 递归重试
  1. 逻辑过期 (Logical Expiration)
    • 不设置 TTL,而是在 value 里存一个过期时间字段。
    • 查询时发现"逻辑过期",异步更新缓存,同时返回旧数据。

5.3 缓存雪崩 (Cache Avalanche)

问题:大量缓存同时过期(如系统重启后,所有缓存都在 00:00:00 过期),数据库瞬间被打爆。

解决方案

  1. 随机 TTL
    • 避免同时过期,TTL 加上随机值。
import random

ttl = 600 + random.randint(-60, 60)  # 600 ± 60 秒
cache.set(f'user:{user_id}', user, ttl=ttl)
  1. 缓存预热

    • 系统启动时,主动加载热点数据到缓存。
    • 使用定时任务,提前刷新即将过期的热点数据。
  2. 熔断降级

    • 当数据库压力过大时,暂时停止更新缓存,直接返回降级数据(如"系统繁忙,请稍后再试")。

6. 缓存的一致性策略

缓存是副本,副本和主本(数据库)可能不一致。如何保证一致性?

6.1 数据更新流程

假设你要更新用户信息:

# 方案 1:先更新数据库,再更新缓存
db.update(user)
cache.set(user)  # ⚠️ 问题:如果缓存更新失败,就不一致了

# 方案 2:先删除缓存,再更新数据库
cache.delete(user)
db.update(user)  # ⚠️ 问题:删除和更新之间,有并发读,读到了旧数据并写回缓存

# 方案 3:先更新数据库,再删除缓存(推荐)
db.update(user)
cache.delete(user)  # ✅ 最佳实践

为什么删除而不是更新?

假设两个线程同时更新:

时间 线程 A 线程 B 数据库 缓存
1 读 user (age=20) 20 20
2 读 user (age=20) 20 20
3 更新 age=25 25 20
4 更新 age=30 30 20
5 写缓存 (age=25) 30 25
6 写缓存 (age=30) 30 30

如果是删除缓存,则不存在这个问题。

6.2 延迟双删 (Delayed Double Deletion)

为了极致一致性,可以在更新数据库前后都删除缓存:

def update_user(user_id, new_data):
    # 1. 第一次删除缓存
    cache.delete(f'user:{user_id}')

    # 2. 更新数据库
    db.update(user_id, new_data)

    # 3. 延迟几百毫秒后,再次删除缓存
    # (为了删除在步骤 1-2 之间被写入的旧数据)
    time.sleep(0.5)
    cache.delete(f'user:{user_id}')

6.3 订阅 Binlog (Canal / Debezium)

最完美的方案:把缓存更新从应用代码中剥离

  • 监听 MySQL 的 Binlog(变更日志)。
  • 数据库更新后,异步消费 Binlog,更新/删除缓存。
  • 优点:代码解耦,最终一致性保证。

7. 实战:设计一个高性能缓存系统

7.1 需求分析

我们要设计一个"商品详情页"的缓存系统:

  • 读多写少100 次浏览,1 次编辑。
  • 热点集中20% 的商品占 80% 的访问。
  • 可接受短时不一致:价格延迟 1 秒更新没问题。

7.2 架构设计

客户端
    ↓
[本地缓存: Caffeine]
    - 容量: 1000 个商品
    - TTL: 30 秒
    - 用途: 极热点商品(如秒杀活动)
    ↓ (未命中)
[分布式缓存: Redis Cluster]
    - 容量: 100 万个商品
    - TTL: 5 分钟
    - 用途: 所有商品数据
    ↓ (未命中)
[数据库: MySQL]
    - 持久化存储

7.3 代码实现

@Service
public class ProductService {

    // 本地缓存
    private final Cache<String, Product> localCache;

    // Redis 客户端
    @Autowired
    private RedisTemplate<String, Product> redisTemplate;

    // 数据库
    @Autowired
    private ProductMapper productMapper;

    /**
     * 三级缓存查询
     */
    public Product getProduct(String productId) {
        // L1: 本地缓存
        Product product = localCache.getIfPresent(productId);
        if (product != null) {
            return product;
        }

        // L2: Redis 缓存
        product = redisTemplate.opsForValue().get("product:" + productId);
        if (product != null) {
            localCache.put(productId, product);  // 回填本地缓存
            return product;
        }

        // L3: 数据库
        synchronized (this) {  // 防止缓存击穿
            // 双重检查
            product = redisTemplate.opsForValue().get("product:" + productId);
            if (product != null) {
                return product;
            }

            // 查数据库
            product = productMapper.selectById(productId);
            if (product == null) {
                // 缓存空对象(防止缓存穿透)
                redisTemplate.opsForValue().set(
                    "product:" + productId,
                    NULL_PRODUCT,
                    5,
                    TimeUnit.MINUTES
                );
                return null;
            }

            // 写入缓存(带随机 TTL,防止雪崩)
            int ttl = 300 + ThreadLocalRandom.current().nextInt(-30, 30);
            redisTemplate.opsForValue().set("product:" + productId, product, ttl, TimeUnit.SECONDS);
            localCache.put(productId, product);

            return product;
        }
    }

    /**
     * 更新商品(Cache-Aside 模式)
     */
    public void updateProduct(Product product) {
        // 1. 更新数据库
        productMapper.updateById(product);

        // 2. 删除缓存(而不是更新)
        redisTemplate.delete("product:" + product.getId());
        localCache.invalidate(product.getId());

        // 3. (可选)延迟双删
        CompletableFuture.runAsync(() -> {
            try {
                Thread.sleep(500);
                redisTemplate.delete("product:" + product.getId());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }
}

7.4 监控与调优

@RestController
public class CacheMetricsController {

    @Autowired
    private Cache localCache;

    @GetMapping("/cache/stats")
    public Map<String, Object> getCacheStats() {
        CacheStats stats = localCache.stats();

        return Map.of(
            "hitRate", stats.hitRate(),              // 命中率(目标: > 90%
            "hitCount", stats.hitCount(),            // 命中次数
            "missCount", stats.missCount(),          // 未命中次数
            "evictionCount", stats.evictionCount(),  // 淘汰次数
            "averageLoadPenalty", stats.averageLoadPenalty()  // 平均加载耗时 (ns)
        );
    }
}

关键指标

  • 命中率 (Hit Rate)> 90% 为优秀。
  • 平均加载耗时 (Average Load Penalty):未命中时加载数据的平均时间,越小越好。
  • 淘汰次数 (Eviction Count):过高说明缓存容量不足。

8. 总结与学习路线

缓存设计是后端系统的"核心技能",掌握它能让你的系统性能提升 10-100 倍

8.1 核心知识点

知识点 重要程度 难度 实战频率
多级缓存架构 极高
Cache-Aside 模式 极高
缓存穿透/击穿/雪崩
布隆过滤器
缓存一致性
分布式锁
缓存监控与调优

8.2 学习路线

  1. 入门1-2 天):

    • 理解缓存的本质和局部性原理。
    • 使用 Redis 做简单的键值缓存。
    • 掌握 Cache-Aside 模式。
  2. 进阶1 周):

    • 实现多级缓存(本地缓存 + Redis)。
    • 解决缓存三大问题(穿透、击穿、雪崩)。
    • 学习布隆过滤器、分布式锁。
  3. 实战2-4 周):

    • 设计一个高并发的商品详情页缓存系统。
    • 接入监控系统,实时观测缓存命中率。
    • 压测验证性能提升。
  4. 深入(持续):

    • 学习 Redis 高可用(哨兵、集群)。
    • 研究热点数据的自动识别与预热。
    • 探索一致性哈希、缓存分片算法。

8.3 推荐资源

  • 书籍
    • 《Redis 设计与实现》(Huangz
    • 《高性能 MySQL》(第 5 章:缓存)
  • 文章
  • 工具
    • Redis Desktop Manager (Redis 可视化)
    • JMeter (压测工具)

9. 名词速查表 (Glossary)

名词 全称 解释
Cache - 缓存。存储数据副本的快速存储层,用于加速访问。
Hit Ratio - 命中率。缓存命中的请求数占总请求数的比例(目标: > 90%)。
TTL Time To Live 生存时间。缓存条目的过期时间。
Cache Penetration - 缓存穿透。查询不存在的数据,导致请求直接打到数据库。
Cache Breakdown - 缓存击穿。热点数据过期,瞬间大量请求打到数据库。
Cache Avalanche - 缓存雪崩。大量缓存同时过期,数据库压力骤增。
Bloom Filter - 布隆过滤器。空间效率高的概率型数据结构,用于判断"一个元素是否在一个集合中"。
Eviction - 淘汰。缓存满了时,删除旧数据为新数据腾空间。
LRU Least Recently Used 最近最少使用。常见的缓存淘汰策略。
Cache-Aside - 旁路缓存。应用代码直接操作缓存和数据库的模式。
Read-Through - 读穿透。缓存库自动从数据库加载数据。
Write-Through - 写穿透。写入缓存时同步写入数据库。
Write-Behind - 异步写回。写入缓存后异步批量写数据库,性能高但可能丢失数据。
Consistent Hashing - 一致性哈希。分布式缓存中用于数据分片的算法。
Local Cache - 本地缓存。与应用在同一进程内的缓存(如 Caffeine)。
Distributed Cache - 分布式缓存。独立服务,通过网络访问(如 Redis)。