前言
Redis 是十分常见的 In-Memory 数据库,工作中时不时会接触到它,但很少从头系统地了解它。因此通过复现一些案例来加深理解。
为什么需要缓存
缓存就是把常访问的数据放在能够快速获取的地方,在这个案例中:Redis。与常见数据库相比,由于 Redis 使用内存作为存储介质,因此天然具备“快一个数量级的读取优势”,相较于其他常见数据库如:Postgres、Mongo。
缺点是内存不像硬盘适合长期保存数据,但无论是提高速度、降低延迟、降低负载或减少成本……缓存都是一个非常好的选择。
| 层级 | 典型访问时间 |
|---|---|
| CPU L1 Cache | 1 ns |
| CPU L2 Cache | 4 ns |
| CPU L3 Cache | 40 ns |
| Main Memory | 100 ns |
| SSD | 100,000 ns |
| HDD | 10,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);}为什么是删除而不是更新缓存?
删除是幂等操作,不需要担心并发更新问题。
为什么必须先更新数据库再删除缓存?
如果反过来,在并发情况下会出现问题:
- A 请求写入,删除缓存
- B 请求读取,发现缓存 miss
- B 查询数据库读取旧数据
- B 将旧数据写回缓存
- A 更新数据库为新数据
- 结果:缓存仍然是旧数据
哪些情况仍可能出现数据不一致?
- 并发读写: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)- 大量查询没有缓存的东西
大量请求访问数据库中不存在的数据,绕过缓存直接打到数据库。
解决方案:
- 缓存空值 (Cache Null Value): 即便资料库查不到,也把这个 Key 存进 Redis,值设定为 null 或特定标记,并给一个很短的过期时间(TTL),下次再查同一个 ID,Redis 就会直接挡下来。
- 布隆过滤器 (Bloom Filter): 在请求碰到 Redis 之前,先经过一层布隆过滤器。它可以非常高效率的计算「这个 ID 绝对不存在」还是「可能存在」。如果绝对不存在就直接拒绝请求。
缓存击穿(Cache Breakdown)- 某热点资料缓存突然过期
某个热点缓存失效,瞬间大量请求打到数据库。
解决方案:
- 互斥锁 (Mutex Lock / Redis SETNX): 发现缓存失效时,不要让所有请求都去查 DB。只让第一个抢到锁的请求去查 DB 并写回缓存,其他没抢到锁的请求就等待再重试读取缓存。
- 逻辑过期 (Logical Expiration): 就是 Refresh-Ahead ,不设定 Redis 原生的 TTL,而是把过期时间写在 Value 里。发现缓存「逻辑上」过期时,先回传旧资料,并在背景非同步去更新资料。
缓存雪崩(Cache Avalanche) - 大量缓存同時過期
多个缓存同时过期导致所有访问都打在资料库上瞬间把资料库压垮。
- TTL 加上随机乱数 (Random Jitter): 在设定过期时间时,不要设死(例如都是 3600 秒),而是加上一个随机值(例如 3600 + Math.random() * 300 秒),让缓存失效的时间错开。
- 高可用架构 (High Availability): 部署 Redis Sentinel 或 Redis Cluster,确保一台机器挂了还有备援。
- 限流与降级 (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