「加个缓存吧」是性能优化的万能答案,也是线上事故的常见来源。这篇把模式讲清楚,再把坑标出来。
五种读写模式
1. Cache-Aside(旁路缓存)—— 最常用
读:先查缓存,没有就查数据库并回写。写:先更新数据库,再删除缓存。
async function getUser(id) {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.query('SELECT * FROM users WHERE id = ?', [id]);
if (user) await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 300);
return user;
}
async function updateUser(id, data) {
await db.query('UPDATE users SET name = ? WHERE id = ?', [data.name, id]);
await redis.del(`user:${id}`); // 删除而不是更新
}
为什么是「删除」而不是「更新」? 因为两个并发写请求更新缓存的顺序可能和数据库提交顺序不一致,删除则天然幂等,下次读会拿到最新值。
2. Read-Through
应用只跟缓存打交道,缓存自己负责回源数据库。很多框架的二级缓存就是这个模式,代码更干净,但可观测性差一些。
3. Write-Through
写的时候同时写缓存和数据库,缓存层保证两者都成功。适合读多写少、对一致性要求高的场景。
4. Write-Behind(回写)
写只落缓存,异步批量刷到数据库。性能最好,风险最高——缓存宕机会丢数据。适合点赞数、播放量这类可容忍少量丢失的计数场景。
5. Refresh-Ahead(预刷新)
缓存快过期时后台主动刷新,让请求永远命中新鲜数据。适合热点数据,代价是多消耗一点计算资源。
三种典型故障
故障一:缓存穿透(查不存在的数据)
攻击者用一个不存在的 ID 高频查询,缓存永远不命中,请求全部打到数据库。
方案 A:缓存空值
if (!user) {
await redis.set(key, '', 'EX', 60); // 短 TTL 的空值
return null;
}
方案 B:布隆过滤器
const exists = await bloom.mightContain(id); // 内存级判断,O(1)
if (!exists) return null; // 一定不存在,直接返回
方案 C:参数校验前置。ID 必须是正整数,长度超限直接拒绝——能挡掉一大半爬虫。
故障二:缓存雪崩(大量 key 同时失效)
所有 key 都设了 300 秒 TTL,整点一到集体失效,数据库瞬间被打满。
// ❌ 固定 TTL
await redis.set(key, value, 'EX', 300);
// ✅ 加随机抖动
const ttl = 300 + Math.floor(Math.random() * 120);
await redis.set(key, value, 'EX', ttl);
再加一层互斥重建,保证同一个 key 只有一个请求去回源:
async function getWithLock(key, loader) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lock = await redis.set(`${key}:lock`, '1', 'EX', 10, 'NX');
if (!lock) {
await sleep(50);
return getWithLock(key, loader); // 稍后重试,读别人写好的缓存
}
try {
const value = await loader();
await redis.set(key, JSON.stringify(value), 'EX', 300);
return value;
} finally {
await redis.del(`${key}:lock`);
}
}
故障三:缓存一致性丢失
经典并发场景:
T1 写请求:更新数据库 → (还没删缓存)
T2 读请求:读缓存(旧值)→ 写回缓存(旧值)
T1 写请求:删除缓存
结果:缓存里留下了旧值,且要等到 TTL 过期才恢复
缓解手段:
- 延迟双删:删除缓存 → 更新数据库 → 延迟 500ms 再删一次
- 订阅 binlog 异步失效(Canal 那套),把缓存失效和数据库提交解耦
- 给缓存加上版本号,写入时带版本,读到旧版本丢弃
但最重要的一句是:如果你的业务不能容忍短暂不一致,就不要把强一致的读放到缓存上。
三个容易被忽略的实践
1. 缓存 key 一定要有命名空间和版本
const KEY = (id) => `v2:user:${id}`;
改了数据结构只改 v2 → v3,不用去线上删一堆 key。
2. 大 key 是运维灾难
一个 key 存了几 MB 的 JSON,网络传输、序列化、淘汰都变慢,还会阻塞单线程的 Redis。
// ❌ 一次缓存整个列表
await redis.set('post:all', JSON.stringify(allPosts));
// ✅ 按需缓存单个对象 + 分页 key
await redis.set(`post:${id}`, JSON.stringify(post), 'EX', 600);
await redis.set(`post:list:page:${page}`, JSON.stringify(ids), 'EX', 60);
3. 监控命中率,而不只是看响应时间
// 每次访问都记录命中/未命中
metrics.counter('cache_access', { hit: cached ? 'yes' : 'no' });
命中率低于 70% 就要看看是不是 key 设计有问题,或者 TTL 太短导致缓存根本没用上。
一张决策表
| 场景 | 建议 |
|---|---|
| 用户信息、文章详情 | Cache-Aside + 300s TTL + 随机抖动 |
| 首页列表 | Cache-Aside + 60s TTL,允许短暂陈旧 |
| 点赞/播放计数 | Write-Behind,接受少量丢失 |
| 配置、字典表 | Refresh-Ahead,几乎不过期 |
| 强一致要求的余额 | 不要缓存,直接查数据库 |
小结
缓存本质上是用一致性换性能。所以先回答一个问题:这部分数据,旧 5 秒能接受吗?
- 能接受 → 放心加缓存,配合 TTL 抖动 + 互斥重建
- 不能接受 → 不要缓存,或者只缓存「不会变」的部分
把这个问题想清楚,上面的模式和故障处理都是自然推论。