跳到内容

元数据卡

  • 前置:数据库存储引擎(第4章 B+树)、消息队列(第17章)、分布式数据库(第16章)
  • 关键词:缓存、Cache-Aside、穿透、击穿、雪崩、Redis、多级缓存、淘汰策略
  • 代码语言:Java / Python / Shell

你的进度

数据堡垒的地下仓库越建越深。B+树一层层往上叠,LSM-Tree 在后台持续 compaction。但有个问题越来越明显:大部分查询翻来覆去就那么几件宝物——而每次都要下到地窖最深处取。老陈从工匠之都寄来一封信,只有一行字:

"在仓库门口放一张快取桌。"

你看了看地窖入口那条排成长龙的工匠队伍。每个人都要下十层才能拿到一枚金币。如果在大厅里建一个"常用物品快速柜"——今天开门的钥匙、日常用的工具券——大多数人根本不用下地窖。

这就是缓存。

你的任务

这一章带你理解缓存——为什么它比数据库本身更常用、更难做好。

你会学到:

  1. 缓存解决什么问题,不解决什么问题
  2. 四种缓存读写模式
  3. 淘汰策略和过期策略
  4. 最常见的三种缓存灾害(穿透、击穿、雪崩)
  5. 多级缓存架构
  6. 用 Redis 快速搭建一个缓存

破局 · 溯源

1. 为什么需要缓存——读多写少的现实

老陈从工匠之都寄来的信还摊在桌上,上面就一行字——"在仓库门口放一张快取桌。"你看着地窖入口那条排成长龙的工匠队伍,决定动手。翻开堡垒的访问日志,你发现了一个规律,印证了老陈的建议是完全正确的:

80% 的请求只命中 20% 的数据。上周最热门的宝箱("地龙鳞片"和"火焰结晶")被看了几万次,但它们静静地躺在 PostgreSQL 的第九层 B+树上。每次阅读都要走一遍 Buffer Pool → B+树查找 → 磁盘 IO 的完整链路。

这就是缓存出手的场景。

缓存的核心思想:把频繁读取的数据放到更快(但更小、更贵)的存储介质中。

在计算机系统里,这条原则从 CPU 的 L1/L2/L3 缓存延伸到内存缓存、分布式缓存、CDN 缓存——整个计算机体系就是一台巨大的"快慢层次"机器。你现在做的,不过是把这条原则应用到堡垒的仓库入口。

java
// 假设你有一个宝物查找服务
// 没有缓存时,每次都查数据库:
public Treasure findTreasure(String id) {
    return database.query("SELECT * FROM treasures WHERE id = ?", id);
    // 走 B+树 → Buffer Pool → 可能还要磁盘 IO
}

// 加上缓存后:
public Treasure findTreasure(String id) {
    // ① 先看快取桌
    Treasure cached = cache.get(id);
    if (cached != null) return cached;  // 缓存命中

    // ② 快取桌上没有,下地窖取
    Treasure treasure = database.query("SELECT * FROM treasures WHERE id = ?", id);

    // ③ 放到快取桌上,下次就不用下地窖了
    cache.put(id, treasure, Duration.ofMinutes(5)); // 5分钟后过期
    return treasure;
}

语言:Java 21 如何运行:假设你有一个缓存客户端(如 Redis 的 Jedis/Lettuce)和一个数据库客户端。这段代码的核心逻辑就两行:先查缓存再查库。 预期输出:第一次调 findTreasure("dragon-scale-001") 时走数据库,第二次调时走缓存——快了一个数量级。 你试试:把缓存时间从 5 分钟改成 5 秒,观察数据变更后的表现。

** Python 差异**

Python 的缓存库(如 cachetoolsredis-py)提供类似的装饰器模式。不同之处是 Python 的 @cache 装饰器可以在函数级别直接启用缓存,不需要手动查/写:

python
from functools import lru_cache

@lru_cache(maxsize=128)
def find_treasure(treasure_id: str):
    # 只有第一次会真正执行,后续直接返回缓存结果
    return database.query("SELECT * FROM treasures WHERE id = ?", (treasure_id,))

装饰器模式更简洁,但失去了对过期时间的精细控制。生产系统通常用 redis-py 手动控制。


2. 缓存读写模式——四种基本的放置方式

快取桌不是只管往上一放就行。放的方式决定了数据的一致性和系统复杂度。

模式一:Cache-Aside(旁路缓存)

这是最常用的模式。应用程序自己管理缓存和数据库之间的数据流动。

读请求 → 查缓存
  ├── 命中 → 直接返回
  └── 未命中 → 查数据库 → 写入缓存 → 返回

写请求 → 写数据库 → 删缓存(不是更新缓存)

为什么写操作删缓存而不是更新缓存? 因为更新缓存意味着你要保证缓存里的数据格式和数据库最新值一致。如果有两个并发写操作——A 把等级写成"金级",B 把等级写成"银级"——缓存可能拿到一个混乱的中间值。删除缓存让下一次读时自然重建,简单可靠。

java
public void updateTreasureLevel(String id, String newLevel) {
    // 1. 先写数据库
    database.execute("UPDATE treasures SET level = ? WHERE id = ?", newLevel, id);
    // 2. 删除缓存——下次读的时候会重新加载
    cache.delete(id);
}

模式二:Read-Through(通读缓存)

缓存库替你做"查数据库→写缓存→返回"这件事。你的代码只跟缓存打交道。

读请求 → 查缓存
  ├── 命中 → 直接返回
  └── 未命中 → 缓存自动加载数据库数据 → 返回

Read-Through 让代码更干净,但加载逻辑对应用程序是透明的——出现问题时更难排查。

模式三:Write-Through(通写缓存)

写操作先写缓存,缓存保证同步写入数据库。

写请求 → 写缓存 → 缓存写数据库 → 返回

优点是强一致性——缓存里的数据始终和数据库一致。缺点也很直接:写操作的延迟被缓存和数据库两次写拉高。

模式四:Write-Behind(回写缓存)

写操作只写缓存,后台异步写数据库。

写请求 → 写缓存 → 立即返回
                 → (后台异步)批量写数据库

这是性能最高的模式,但风险也最大——如果缓存服务在异步写完成前崩溃,数据就丢了。

模式一致性性能复杂度适用场景
Cache-Aside最终一致绝大多数场景,默认选择
Read-Through最终一致缓存加载逻辑复杂,想封装
Write-Through强一致对一致性要求极高
Write-Behind最终一致最高写频繁但能容忍少量丢失

3. 淘汰策略——快取桌满了怎么办

快取桌再大也有用完的时候。满了之后新数据放进来,旧数据必须请出去。选谁出去——这就是淘汰策略。

java
// 用 LinkedHashMap 实现一个最简单的 LRU 缓存
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int maxSize;

    public LRUCache(int maxSize) {
        // accessOrder=true 表示按访问顺序排序(最近访问的在最后)
        super(maxSize, 0.75f, true);
        this.maxSize = maxSize;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        // 当缓存超过最大容量时,自动移除最久未访问的条目
        return size() > maxSize;
    }
}

// 使用
LRUCache<String, Treasure> cache = new LRUCache<>(1000);
cache.put("dragon-scale-001", treasure);

语言:Java 21 如何运行:这是一个完整的 LRU 实现。LinkedHashMap + accessOrder=true + removeEldestEntry 三行代码实现了一个线程不安全的 LRU。 预期输出:缓存超过 1000 条时,最久未访问的条目会被自动移除。

常见的淘汰策略:

策略工作机制特点
TTL(过期时间)写入时设定存活时间,到点自动删除最简单的过期方式。但可能出现缓存"大范围同时过期"——这是雪崩的根源
LRU(最近最少使用)淘汰最久未访问的数据实现简单,适用于大多数场景。但偶发的大批量数据扫描会冲掉热点
LFU(最不经常使用)淘汰访问频率最低的数据能抵抗"一次扫描冲掉热点"的场景。但实现复杂,需要维护访问计数
FIFO(先进先出)淘汰最早写入的数据实现最简单。但可能把正在频繁访问的"老数据"淘汰掉

在 Redis 中,你可以通过 maxmemory-policy 配置选择策略:

# Redis 配置文件
maxmemory 2gb
maxmemory-policy allkeys-lru       # 所有键都用 LRU
# 可选:allkeys-lfu / allkeys-random / volatile-ttl / volatile-lru / noeviction

一个实用的组合建议:TTL 是基础防护——每个缓存项必须有过期时间,防止数据永久腐烂。LRU 或 LFU 作为容量满时的兜底策略。两者配合:TTL 保证数据不过期太久,LRU/LFU 保证热点数据不被挤走。


4. 三大缓存灾害——穿透、击穿、雪崩

这三个陷阱不是理论问题。每一座真实的数据堡垒都踩过它们。而且它们是递增的:穿透发生在单次,击穿发生在单点,雪崩发生在系统层。

击穿(Hotspot Invalidation)——快取桌上最热的一把钥匙突然消失

场景:一件极热门的宝物(比如"全服最强之剑"),缓存恰好在它被高频访问时过期。一瞬间,几千个请求同时发现缓存没命中,全部冲向数据库。

正常: 请求→缓存→返回
击穿: 第一个请求→缓存过期→查数据库
      第二个请求→缓存过期→查数据库  ← 同一秒内几千个请求一起查库
      → 数据库连接打满,响应变慢

解决:互斥锁(Mutex)。当缓存未命中时,只让一个线程去加载数据,其他线程等待。

java
public Treasure findTreasure(String id) {
    Treasure cached = cache.get(id);
    if (cached != null) return cached;

    // 只有一个线程能拿到锁
    String lockKey = "lock:treasure:" + id;
    if (redisLock.tryLock(lockKey, Duration.ofSeconds(3))) {
        try {
            // 双重检查——可能等待锁期间其他线程已经加载好了
            cached = cache.get(id);
            if (cached != null) return cached;

            Treasure treasure = database.query("SELECT * FROM treasures WHERE id = ?", id);
            cache.put(id, treasure, Duration.ofMinutes(5));
            return treasure;
        } finally {
            redisLock.unlock(lockKey);
        }
    } else {
        // 拿不到锁的线程等一会儿再试
        Thread.sleep(50);
        return findTreasure(id); // 递归重试
    }
}

穿透(Cache Penetration)——根本不存在的东西也在查

场景:有人不断查询一个根本不存在的宝物 ID(比如 treasure-id-9999999)。每次查缓存——没有。查数据库——也没有。于是每次都直接打到数据库上。如果攻击者用大量随机不存在的 ID 循环请求,数据库会直接被压垮。

解决:缓存空值。把"查不到"的结果也缓存起来,但设置一个很短的 TTL。

java
public Treasure findTreasure(String id) {
    Treasure cached = cache.get(id);
    if (cached != null) return cached;

    // 检查是否是"空值标记"
    if (cache.hasNullMarker(id)) {
        return null; // 之前已经查过,不存在
    }

    Treasure treasure = database.query("SELECT * FROM treasures WHERE id = ?", id);
    if (treasure == null) {
        // 数据库里也没有——缓存一个"空标记",60秒后自动过期
        cache.putNullMarker(id, Duration.ofSeconds(60));
        return null;
    }

    cache.put(id, treasure, Duration.ofMinutes(5));
    return treasure;
}

另一种更强的方案是 布隆过滤器(Bloom Filter)——用很小的内存空间判断"这个 ID 肯定不存在"。如果布隆过滤器说"不存在",连缓存都不用查。

雪崩(Cache Avalanche)——整个快取桌同时清空

场景:一大片缓存同时过期(比如所有缓存的 TTL 都设成了 60 分钟,整点一起过期)。或者缓存服务本身宕机了。数据库瞬间被几十倍的请求淹没,直接挂掉。

解决:把过期时间打散。不要用固定的 TTL,而是 TTL + 随机偏移。

java
// ✗ 糟糕:所有缓存同一时刻过期
cache.put(id, treasure, Duration.ofMinutes(60));

// ✓ 正确:随机偏移,避免同时过期
int baseTTL = 60;           // 基础 60 分钟
int randomOffset = (int)(Math.random() * 300);  // 0~5 分钟的随机偏移
cache.put(id, treasure, Duration.ofMinutes(baseTTL).plusSeconds(randomOffset));

还可以做二级缓存:本地缓存(如 Caffeine) + 分布式缓存(如 Redis)。即使 Redis 宕机,本地缓存还能撑一会儿。


5. 多级缓存——从快取桌到地窖的全链路

单一缓存扛不住所有场景。真实系统的缓存是分层级的:

第一层:本地缓存(Caffeine / Guava Cache)
  → 在应用程序进程内,零网络开销。适合放变动极少的配置和元数据
  → 容量小(每台机器几百 MB),且不同机器之间数据不一致

第二层:分布式缓存(Redis / Memcached)
  → 独立部署的服务,所有应用共享。适合放热点业务数据
  → 容量中等(几十 GB),有网络开销(1-5ms)

第三层:数据库 Buffer Pool / 存储引擎缓存
  → 数据库自己的缓存(MySQL 的 InnoDB Buffer Pool)
  → 放"被数据库层缓存的数据",应用层无法直接控制

第四层:CDN / 浏览器缓存(客户端侧)
  → 对 Web 服务而言,静态资源可以缓存在 CDN 或用户浏览器中
  → 零服务器成本,但完全不受你控制

多级缓存的读取顺序:本地缓存 → 分布式缓存 → 数据库。每一级都是下一级的"快取桌"。

何时使用多级缓存?

单机应用 → 本地缓存就够了,Redis 是多余的
小型后端 → 分布式缓存足矣,本地缓存会带来不一致问题
大型系统 → 多级缓存是必需品——Redis 挂了还有本地缓存保底

6. 快速上手:用 Redis 搭一个缓存层

Redis 不只是一个缓存。它支持字符串、哈希、列表、集合、有序集合等多种数据结构。但作为缓存使用时,最常用的是 SET/GETEXPIRE

bash
# 启动 Redis
docker run -d --name atlas-cache -p 6379:6379 redis:7-alpine

# 连接 Redis
redis-cli

# 设置一个缓存项,10分钟后过期
SET treasure:dragon-scale-001 '{"name":"地龙鳞片","level":"金级"}' EX 600
# OK

# 获取缓存项
GET treasure:dragon-scale-001
# "{\"name\":\"地龙鳞片\",\"level\":\"金级\"}"

# 查看还要多久过期
TTL treasure:dragon-scale-001
# (integer) 523  ← 还剩下 523 秒

# 手动删除(Cache-Aside 写模式中用到)
DEL treasure:dragon-scale-001

如何运行:打开终端,执行 docker run 命令启动 Redis,再用 redis-cli 连接。 预期输出SET 返回 OKGET 返回之前设置的值。 你试试:创建一个缓存项,设 10 秒过期。每 2 秒 GET 一次,观察它在 10 秒后消失。


常见陷阱

陷阱一:把缓存当数据库用

缓存里放的数据因为 TTL 过期、LRU 淘汰或服务重启而随时可能消失。不能假设缓存里的数据永久存在。缓存是加速度,不是保险箱。

陷阱二:缓存穿透被忽略

"查不到就不缓存"听起来合理,但攻击者可以用大量不存在的 ID 绕过你的缓存直接打数据库。空值缓存和布隆过滤器是防御底线。

陷阱三:热点缓存 Key 没有做本地缓存

如果一条数据每分钟被访问百万次(比如首页 Banner 配置),即使是 Redis 的网络延迟也会累积。对这种"超热"的数据,除了分布式缓存,必须在本地缓存里也存一份。

陷阱四:缓存预热被忽略

系统重启后缓存是空的。如果不做预热(主动把热点数据加载到缓存),重启后的前几分钟数据库会被大量缓存未命中打满。

陷阱五:Redis 默认配置不适合生产

# 至少改这三项:
maxmemory 4gb                   # 限制 Redis 最大内存
maxmemory-policy allkeys-lru    # 设置淘汰策略
requirepass your-strong-password # 设置密码,否则会被扫描攻击

通关挑战

  • 热身:说出 Cache-Aside 模式的读路径和写路径。解释为什么写操作要删缓存而不是更新缓存。
  • 挑战:用你熟悉的语言写一个支持 TTL 和 LRU 淘汰的内存缓存类。至少实现 getputdelete 和淘汰逻辑。
  • 观察(15 分钟):启动 Redis,插入一个有 TTL 的数据,用 redis-cliMONITOR 命令观察所有 Redis 命令的执行顺序。你试试能不能观察到缓存穿透的场景——分别查询存在和不存在的 key。
  • 排障:你的服务突然变慢了。你发现数据库连接数满了。进一步发现有一个热门的 API 端点最近改了缓存 TTL——从 5 分钟改成 5 秒。解释发生了什么,怎么修复。

验收标准

读完后你能:

  • 解释缓存解决了什么核心问题(读多写少、延迟差异)
  • 区分 Cache-Aside / Read-Through / Write-Through / Write-Behind 四种模式
  • 说出 LRU、LFU、TTL 的区别和适用场景
  • 识别缓存穿透、击穿、雪崩并给出具体解决方案
  • 在 Redis 中执行基本的缓存操作(SET/GET/DEL/EXPIRE)
  • 设计一个合理的多级缓存架构(本地 → 分布式 → 数据库)

常见卡点

  • Cache-Aside 的写操作为什么要删缓存而不是更新缓存:因为并发写时更新缓存可能导致缓存和数据库不一致。删缓存让下次读时自然重建,虽然多了一次读延迟,但保证了最终一致性。
  • 本地缓存和分布式缓存的数据一致性:本地缓存天然不一致——不同机器的本地缓存可能存着不同版本的数据。所以本地缓存只放"即使不一致影响也不大"的数据(如配置、静态元数据)。热点业务数据放 Redis。
  • 穿透、击穿、雪崩的区别:穿透是一个不存在的数据一直绕过缓存;击穿是一个热 key 过期瞬间大量请求打到 DB;雪崩是大面积缓存同时过期或缓存服务整体宕机。三个问题范围递增,解决方案也不同。

现在不需要理解

  • Redis 的 RDB / AOF 持久化细节——当你把 Redis 当缓存用时,持久化不是必需的(丢缓存不会丢数据)。
  • Redis Cluster 的分片和故障转移——分布式缓存集群是高阶话题。
  • Caffeine 的 W-TinyLFU 算法细节——比标准 LRU 更先进,但理解 LRU 就够起步。
  • 布隆过滤器的数学原理和假阳性率——只需要知道"它能判断肯定不存在"就够。

旅人笔记

缓存解决了一个简单但强大的核心问题:把数据放在离计算更近、更快的存储中。Cache-Aside 是默认选择,TTL + LRU 是最常用组合,穿透/击穿/雪崩是每座数据堡垒都会踩的三块砖。记住:缓存是加速度,不是保险箱——设计时永远假设缓存会消失,然后确保系统仍然能正常运行。

下一站预告

缓存解决了"热点数据读得快"的问题。但如果你要的不是"按 ID 精确查找",而是"搜关键词"或"模糊检索"呢?下一章,我们会在数据堡垒的地下建一座倒排索引图书馆——搜索系统。

Built with VitePress | Software Systems Atlas