缓存失效这件事,比想象中难

一、三个必须搞清的概念
一、缓存命中不了,性能反而更差。 加了缓存之后每次请求要多查一次缓存、还要做序列化反序列化。缓存命中率低的时候,这部分开销是纯亏。
二、失效比写入难。 写一次数据很难出错,因为有返回值、有报错。删掉缓存的逻辑往往藏在某个角落,永远不会被执行到——但正是它导致用户看到旧数据。
三、缓存过期时间和业务允许的不一致时间无关。 设 1 小时过期,意思是性能上 1 小时更新一次;业务上如果要求 5 分钟内看到更新,这个方案就是错的。这是产品决定,不是技术决定。
二、常见的几种缓存策略
一、Cache-Control。 告诉浏览器和中间缓存该怎么处理这个响应:
Cache-Control: max-age=3600 # 一小时内不用再问服务器
Cache-Control: no-cache # 每次都要问,但服务器可以返回 304
Cache-Control: no-store # 完全不缓存,敏感数据用
no-cache 和 no-store 的区别常被搞混。 no-cache 是"可以存但每次要验证",no-store 是"连存都不要存"。存密码页面的响应要用 no-store。
二、ETag。 服务器给每个资源算一个标识,客户端下次带上这个标识问服务器"没变吧",没变就返回 304,响应体是空的。适合内容会变但大部分时候不变的情况,比如文章页。
三、强制刷新。 Ctrl+F5 忽略缓存重新拉,这个在联调时经常救命。
三、缓存失效的几个坑
一、更新了数据但忘了删缓存。 这是最常见的。
// 只写了更新,忘了删
async function updateOrder(id, data) {
await db.order.update(id, data);
}
结果就是:数据库里是新的,页面上是旧的,而且刷新多少次都没用(因为缓存还在)。
解法是把删缓存固定成更新函数的一部分,不给忘的机会:
async function updateOrder(id, data) {
await db.order.update(id, data);
await cache.del(`order:${id}`); // 跟更新绑死
}
二、删了列表缓存没删详情缓存。 改完一个商品的名称,列表页是新的,点进详情还是旧名字。因为两个缓存都要清,而只清了一个。
三、先删缓存再更新数据库。 顺序反了会有并发问题:删完缓存、数据库还没改,这时另一个请求读到了旧值又写回了缓存。永远先改数据库,再删缓存。
四、缓存穿透。 查一个不存在的 key,缓存里没有,去数据库查也没有,于是每次都打数据库。恶意刷的话数据库会挂。
解法是把"查不到"也缓存起来,存一个空值标记,值设短一点:
const val = await cache.get(key);
if (val !== null) return val === 'NULL' ? null : JSON.parse(val);
const db = await query(key);
await cache.set(key, db ? JSON.stringify(db) : 'NULL', db ? 300 : 60);
return db;
五、缓存雪崩。 大量 key 同一时刻过期,同时打向数据库。解法是把过期时间加随机:
const ttl = 300 + Math.floor(Math.random() * 60);
四、什么时候不该用缓存
一、几乎不变的配置。 地区列表、枚举值这类东西,本来就不怎么变,放内存里就够,不需要分布式缓存。
二、写多读少的数据。 缓存是为读设计的,写得比读还多的数据用缓存只会增加无效开销。
三、要求实时准确的地方。 余额、库存这类数据,缓存带来的不一致不可接受,宁可慢一点。
四、刚开始的项目。 早期数据量小、请求量小,缓存带来的复杂度完全覆盖不了收益。等真的慢了再加,代码结构不需要提前为缓存改造太多。
五、一句总结
缓存不是"让系统变快的东西",是"用数据可能不准,换性能"的交易。
所以做决定的时候,要能回答三个问题:允许多长时间的不准?命中率预计多少?失效逻辑在哪个函数里? 三个都答得上来,再动手。
- 点赞
- 收藏
- 关注作者
评论(0)