Understanding Redis Cache Types and Common Disasters

从头了解 Redis 缓存种类与常见灾难

前言

Redis 是十分常见的 In-Memory 数据库,工作中时不时会接触到它,但很少从头系统地了解它。因此通过复现一些案例来加深理解。

为什么需要缓存

缓存就是把常访问的数据放在能够快速获取的地方,在这个案例中:Redis。与常见数据库相比,由于 Redis 使用内存作为存储介质,因此天然具备“快一个数量级的读取优势”,相较于其他常见数据库如:Postgres、Mongo。

缺点是内存不像硬盘适合长期保存数据,但无论是提高速度、降低延迟、降低负载或减少成本……缓存都是一个非常好的选择。

层级典型访问时间
CPU L1 Cache1 ns
CPU L2 Cache4 ns
CPU L3 Cache40 ns
Main Memory100 ns
SSD100,000 ns
HDD10,000,000 ns

Cache Aside 模式 - 数据库优先,缓存辅助

一种简单常见的应用侧缓存模式是「Cache Aside」,意味着:

  • 读取:先查询 Redis
    • 命中:返回缓存
    • 未命中:查询数据库,再将结果写入 Redis
  • 写入:直接更新数据库,同时删除对应的 Redis 缓存,让下一次读取时重新填充
特点说明
✅ 实现简单逻辑清晰,应用层完全掌控缓存行为
✅ 容错性好Redis 挂掉时,系统仍可退回直接查询数据库
✅ 按需加载只有实际被查询的数据才会进入缓存,节省内存
⚠️ 首次必定 Miss冷啟動或缓存清除後,第一次請求延遲較高
⚠️ 短暂不一致从写入 DB 到删除缓存之间,存在极短暂的脏读(dirty read)窗口
async function main() {
// --- 读取 ---
// 第一次读取:Cache Miss,从 DB 载入
// 结果: {"id":1,"name":"Alice","email":"alice@example.com"}
const user = await getUser(1);
// 第二次读取:Cache Hit,直接从 Redis 回传
// 结果: {"id":1,"name":"Alice","email":"alice@example.com"}
const userCached = await getUser(1);
// --- 写入 ---
// 更新资料,缓存会被删除
await updateUser(1, { id: 1, name: "Alice Wu", email: "alice@example.com" });
// 再次读取:缓存已失效,重新从 DB 载入
const userUpdated = await getUser(1);
// 结果: {"id":1,"name":"Alice Wu","email":"alice@example.com"}
}

读取

async function getUser(userId: number): Promise<string | null> {
const cacheKey = `user:${userId}`;
// 1. 先查 Redis
const cached = await redis.get(cacheKey);
if (cached !== null) {
return cached;
}
// 2. Cache Miss:查资料库
const data = await queryDB(cacheKey);
if (data === null) return null;
// 3. 回填缓存,设定 TTL 避免永久占用记忆体
const TTL = 60; // 缓存 60 秒
await redis.set(cacheKey, data, "EX", TTL);
return data;
}

写入

async function updateUser(userId: number, newData: object): Promise<void> {
const cacheKey = `user:${userId}`;
const value = JSON.stringify(newData);
// 1. 更新数据库
await updateDB(cacheKey, value);
// 2. 删除旧缓存,让下一次读取重新填充
await redis.del(cacheKey);
}

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

删除是幂等操作,不需要担心并发更新问题。

为什么必须先更新数据库再删除缓存?

如果反过来,在并发情况下会出现问题:

  1. A 请求写入,删除缓存
  2. B 请求读取,发现缓存 miss
  3. B 查询数据库读取旧数据
  4. B 将旧数据写回缓存
  5. A 更新数据库为新数据
  6. 结果:缓存仍然是旧数据

哪些情况仍可能出现数据不一致?

  • 并发读写:A 读取旧缓存后,B 删除缓存
  • 缓存删除失败

可以尝试:重试机制、异步删除缓存、Double Delete

Read Through 模式 - 缓存优先,数据库辅助

Read Through 与 Cache Aside 的读取流程类似,最大的区别是:缓存填充的责任从应用层转移到了缓存层本身。应用程序只与缓存交互,由缓存决定何时访问数据库。

  • 读取:只查询缓存
    • 命中:返回缓存
    • 未命中:缓存层自动查询数据库并填充缓存
  • 写入:更新数据库,缓存依靠 TTL 自然过期(或结合 Write Through / Write Behind)
特性说明
✅ 应用逻辑简洁应用只需调用缓存接口,无需处理 Cache Miss 逻辑
✅ 按需加载与 Cache Aside 相同
⚠️ 首次必定 Miss冷启动或 TTL 过期后仍需查询数据库
⚠️ 实现耦合较高缓存层需要整合数据库逻辑
⚠️ 容错性较差缓存故障时需要额外降级机制
async function main() {
// --- 读取 ---
// 第一次读取:Cache Miss
// 缓存层自动向 DB 查询并填充,应用层无感知
// 结果: {"id":1,"name":"Alice","email":"alice@example.com"}
const user = await cache.get("user:1");
// 第二次读取:Cache Hit,直接从缓存回传
// 结果: {"id":1,"name":"Alice","email":"alice@example.com"}
const userCached = await cache.get("user:1");
// --- 写入 ---
// 直接更新资料库;缓存等待 TTL 自然过期
await db.update("users", { id: 1, name: "Alice Wu", email: "alice@example.com" });
// TTL 到期后再次读取:Cache Miss
// 缓存层重新向 DB 查询并填充
// 结果: {"id":1,"name":"Alice Wu","email":"alice@example.com"}
const userUpdated = await cache.get("user:1");
}

Write Through

写入时同时更新缓存和数据库,只有两者都成功才返回。数据一致性最高,但延迟较高。

Write Behind

先更新缓存并立即返回,再异步批量写入数据库。性能极高,但存在数据丢失风险。

Refresh-Ahead

在缓存过期前提前刷新数据,让请求尽量命中缓存,避免延迟。

  • 读取:只查询缓存
  • 命中且资料尚新:直接回传
  • 命中但接近过期:回传现有缓存,同时在背景非同步刷新
  • 未命中:退回查询数据库并填充缓存(行为同 Read Through)
  • 写入:更新数据库,缓存由背景刷新机制维持同步
const TTL = 60; // 缓存总存活时间(秒)
const REFRESH_THRESHOLD = 0.75; // 超过 75% 存活时间即触发刷新
async function getUser(id: number) {
const cached = await redis.get(`user:${id}`);
if (cached) {
const { data, cachedAt } = JSON.parse(cached);
const age = (Date.now() - cachedAt) / 1000;
// 缓存仍有效,但已进入刷新窗口 → 背景非同步更新,本次请求不等待
if (age > TTL * REFRESH_THRESHOLD) {
refreshInBackground(id);
}
// 无论是否触发刷新,本次一律回传现有缓存
return data;
}
// Cache Miss:同步查询 DB 并填充缓存
return await loadFromDB(id);
}
async function refreshInBackground(id: number) {
// 设置锁,避免多个请求同时触发重复刷新(race condition)
const lockKey = `lock:user:${id}`;
const acquired = await redis.set(lockKey, "1", { NX: true, EX: 10 });
if (!acquired) return;
try {
await loadFromDB(id); // 查询 DB 并写入缓存
} finally {
await redis.del(lockKey);
}
}
async function loadFromDB(id: number) {
const user = await db.findUser(id);
await redis.set(
`user:${id}`,
JSON.stringify({ data: user, cachedAt: Date.now() }),
{ EX: TTL }
);
return user;
}

缓存灾难

缓存穿透(Cache Penetration)- 大量查询没有缓存的东西

大量请求访问数据库中不存在的数据,绕过缓存直接打到数据库。

解决方案:

  1. 缓存空值 (Cache Null Value): 即便资料库查不到,也把这个 Key 存进 Redis,值设定为 null 或特定标记,并给一个很短的过期时间(TTL),下次再查同一个 ID,Redis 就会直接挡下来。
  2. 布隆过滤器 (Bloom Filter): 在请求碰到 Redis 之前,先经过一层布隆过滤器。它可以非常高效率的计算「这个 ID 绝对不存在」还是「可能存在」。如果绝对不存在就直接拒绝请求。

缓存击穿(Cache Breakdown)- 某热点资料缓存突然过期

某个热点缓存失效,瞬间大量请求打到数据库。

解决方案:

  1. 互斥锁 (Mutex Lock / Redis SETNX): 发现缓存失效时,不要让所有请求都去查 DB。只让第一个抢到锁的请求去查 DB 并写回缓存,其他没抢到锁的请求就等待再重试读取缓存。
  2. 逻辑过期 (Logical Expiration): 就是 Refresh-Ahead ,不设定 Redis 原生的 TTL,而是把过期时间写在 Value 里。发现缓存「逻辑上」过期时,先回传旧资料,并在背景非同步去更新资料。

缓存雪崩(Cache Avalanche) - 大量缓存同時過期

多个缓存同时过期导致所有访问都打在资料库上瞬间把资料库压垮。

  1. TTL 加上随机乱数 (Random Jitter): 在设定过期时间时,不要设死(例如都是 3600 秒),而是加上一个随机值(例如 3600 + Math.random() * 300 秒),让缓存失效的时间错开。
  2. 高可用架构 (High Availability): 部署 Redis Sentinel 或 Redis Cluster,确保一台机器挂了还有备援。
  3. 限流与降级 (Rate Limiting & Circuit Breaking): 如发现资料库压力太大,直接在应用层拒绝部分请求,或是关闭部分非核心功能,死保资料库存活。

总结

  • 缓存策略:
    • Cache Aside 以资料库为中心
    • Cache Through 以缓存为中心
      • Write Through 求稳
      • Write Behind 求快
      • Refresh-Ahead 空间换时间
  • 缓存灾难
    • 穿透:大量查询没有缓存的东西
    • 击穿:某热点资料缓存突然过期
    • 雪崩:大量缓存同时过期

延伸阅读

  • Cache Memory - Redis
  • Caching patterns - AWS
  • What Are Bloom Filters? - Spanning Tree