缓存几乎是最常见的性能优化手段。
接口慢了,先加 Redis;数据库压力大,先加 Redis;QPS 上来了,还是加 Redis。
但缓存不是为了"让代码看起来更快",它引入的是一套新的数据副本。只要存在副本,就一定会遇到一致性问题。
很多团队使用的是经典 Cache Aside:
text
读:先查缓存,未命中再查数据库,然后写回缓存
写:先更新数据库,再删除缓存
这套模式简单有效,但它并没有自动解决并发一致性。线上依然可能出现:
- 数据库已经是新值,用户却继续读到旧值;
- 缓存被删除失败,旧数据长期存在;
- 更新和过期同时发生,旧请求把新值覆盖;
- 本地缓存和 Redis 同时存在,不同实例看到的数据不同。
下面聊聊 4 个最常见、也最容易被忽略的边界。
边界一:先删缓存再更新数据库,会把旧值重新写回来
先看一个典型的并发时序。
有两个请求,一个是读请求 R,一个是写请求 W。
text
R:查询缓存,未命中
W:更新数据库为新值
W:删除缓存
R:查询数据库,这里可能读到旧值
R:把旧值写回缓存
最终结果是:
text
数据库 = 新值
缓存 = 旧值
这个旧值可能要等到 TTL 到期才会消失。
如果使用"先更新数据库,再删除缓存",也会存在类似的竞争窗口,只是触发难度和时序不同:
text
R:缓存未命中,准备查数据库
R:查到旧值
W:更新数据库为新值
W:删除缓存
R:把之前查到的旧值写回缓存
所以,Cache Aside 的关键不是背一个固定顺序,而是要意识到:
缓存写入和数据库更新是两个独立操作,无法天然原子。
常见处理方式有三种。
第一,写操作在数据库事务提交后再删除缓存,不要把删除放在事务内部。
ts
await db.transaction(async tx => {
await tx.articles.update({
where: { id },
data: { title },
});
});
await cache.del(`article:${id}`);
第二,延迟双删只能作为兜底,不能当作严格一致性方案。
ts
await db.update(...);
await cache.del(key);
await sleep(500);
await cache.del(key);
它能降低旧值重新写回的概率,但无法覆盖网络延迟、进程暂停和消息积压等所有情况。
第三,如果需要更可靠的缓存失效,使用数据库 Binlog、事务日志或 Outbox 事件触发缓存删除。
text
数据库事务提交
-> 产生变更事件
-> 缓存失效消费者删除 Key
它把缓存失效从"业务代码必须记得做"变成了"由数据变更驱动",通常更适合核心数据。
边界二:缓存删除失败,业务代码却认为已经成功
删除缓存并不等于删除成功。
Redis 超时、网络抖动、权限变更、连接池耗尽,都可能让 DEL 失败。如果业务代码只写:
ts
await db.update(...);
await cache.del(key);
却把 cache.del 的异常吞掉,就会出现非常隐蔽的问题。
一种处理方式是让缓存失效失败可见:
ts
await db.updateArticle(id, data);
try {
await cache.del(`article:${id}`);
} catch (error) {
logger.error({
event: "cache_invalidation_failed",
resource: "article",
id,
error,
});
await invalidationRetryQueue.enqueue({ type: "article", id });
}
这里不是简单重试几次,而是把"删除缓存"变成一个可追踪、可补偿的任务。
更稳妥的设计是结合版本号:
ts
type CachedArticle = {
version: number;
data: Article;
};
写入缓存时带上数据库版本号,读取时如果发现缓存版本落后,就重新加载数据库。
ts
async function getArticle(id: string) {
const cached = await cache.get<CachedArticle>(`article:${id}`);
if (cached) {
const latestVersion = await cache.get<number>(`article:version:${id}`);
if (!latestVersion || cached.version >= latestVersion) {
return cached.data;
}
}
const article = await db.article.findUnique({ where: { id } });
await cache.set(
`article:${id}`,
{ version: article.version, data: article },
300,
);
return article;
}
版本号不能解决所有并发问题,但它让"缓存是否过期"有了可判断依据,而不是单纯依赖删除是否成功。
边界三:TTL 不是一致性策略,只是延迟暴露
很多人会用一句"缓存有 TTL,最终会一致"来结束讨论。
但需要先问清楚:
- TTL 是 5 秒、5 分钟,还是 24 小时?
- 在这段时间里,业务允不允许读到旧值?
- 旧值会不会影响支付、库存、权限等关键判断?
- 缓存更新失败后,多久能自动恢复?
TTL 更适合用来控制脏数据存在时间,而不是替代一致性设计。
例如:
- 商品介绍允许几秒钟旧值;
- 库存和余额通常不能依赖普通缓存做最终判断;
- 用户权限撤销后,旧缓存可能带来安全问题;
- 配置开关需要主动广播,而不是等 TTL 到期。
不同数据应该有不同的缓存策略:
| 数据类型 | 可用策略 |
|---|---|
| 商品描述、文章内容 | Cache Aside + TTL + 变更失效 |
| 用户权限、风控规则 | 短 TTL + 主动广播 + 版本校验 |
| 库存、余额 | 数据库或强一致存储作为最终裁决 |
| 配置开关 | 推送失效 + 本地版本号 |
| 热点榜单 | 定时计算 + 明确延迟窗口 |
缓存一致性不是"所有数据都强一致",而是先定义每一类数据的可接受延迟和错误后果。
边界四:多级缓存让失效变成广播问题
只使用 Redis 时,缓存失效通常是一次 DEL。
但如果还有本地缓存,情况会复杂很多:
text
请求 -> 本地缓存 -> Redis -> 数据库
服务有 50 个实例,每个实例都持有一份本地缓存。更新数据库后,即使删除了 Redis,没有收到通知的实例仍然可能继续返回旧值。
常见做法有两类。
第一,使用发布订阅广播失效消息。
text
配置中心 / MQ / Redis PubSub
-> 通知所有实例
-> 每个实例删除本地 Key
但要考虑:
- 实例离线后重新上线,如何同步版本?
- 消息丢失后,如何恢复?
- 广播风暴会不会影响正常流量?
- 本地缓存是否有版本号和过期时间?
第二,给数据增加全局版本号。
ts
type CacheEnvelope<T> = {
version: number;
expiresAt: number;
data: T;
};
本地缓存和 Redis 都保存版本号。读取时先检查版本,再决定是否使用本地副本。这样即使失效消息丢失,实例也能在后续请求中发现版本落后。
多级缓存的目标不是让每个实例永远第一时间更新,而是做到:
旧数据的生存时间有上界,且不会无限期覆盖新数据。
一套更稳妥的缓存更新流程
可以把核心数据的写流程设计成下面这样:
text
1. 数据库事务提交
2. 通过 Outbox 或 Binlog 产生变更事件
3. 缓存失效消费者删除 Redis Key
4. 广播本地缓存失效消息
5. 缓存读取时校验版本号和 TTL
6. 失效失败进入重试队列并记录告警
读流程则采用 Cache Aside,并增加并发保护:
ts
async function getArticle(id: string) {
const cached = await cache.get(`article:${id}`);
if (cached) return cached;
const lockKey = `lock:article:${id}`;
const locked = await cache.set(lockKey, "1", "NX", "PX", 3000);
if (!locked) {
await sleep(50);
return getArticle(id);
}
try {
const article = await db.article.findUnique({ where: { id } });
await cache.set(`article:${id}`, article, 300);
return article;
} finally {
await cache.del(lockKey);
}
}
这段代码也不是生产级的完整实现,至少还需要处理:
- 空值缓存,避免缓存穿透;
- 锁过期后的重复回源;
- 随机 TTL,避免批量过期;
- 请求取消和超时;
- 缓存序列化版本兼容;
- 敏感字段脱敏。
但它体现了一个重要原则:
缓存更新不是一次写入,而是一套包含失效、重试、版本和降级的生命周期。
最后检查这 7 项
- 明确数据库和缓存谁才是最终数据源
- 数据库事务提交后再处理缓存失效
- 缓存删除失败会重试、告警并保留上下文
- 缓存值带版本号或逻辑过期时间
- 多级缓存有可靠的广播或版本校验机制
- TTL 根据业务可接受的旧数据窗口设计
- 关键数据不依赖"缓存最终会过期"兜底
缓存一致性问题通常不是"Redis 不可靠",而是系统对副本生命周期定义不清。
先接受缓存只能提供有限一致性,再明确哪些数据允许旧、能旧多久、旧了会造成什么后果,最后用失效、版本和补偿机制把风险限制在可接受范围内。
如果你也在处理缓存一致性问题,也欢迎在微信公众号搜索「长安米粒贵」继续交流。