缓存明明加了,为什么数据还是不一致?聊聊 4 个容易忽略的边界

缓存几乎是最常见的性能优化手段。

接口慢了,先加 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 不可靠",而是系统对副本生命周期定义不清。

先接受缓存只能提供有限一致性,再明确哪些数据允许旧、能旧多久、旧了会造成什么后果,最后用失效、版本和补偿机制把风险限制在可接受范围内。

如果你也在处理缓存一致性问题,也欢迎在微信公众号搜索「长安米粒贵」继续交流。

相关推荐
yunwei371 小时前
eBPF 开发实践:使用 eBPF 隐藏进程或文件信息
linux·后端·性能优化
高频因子挖掘机1 小时前
行情监控脚本重启后,怎样快速恢复而不重复拉取数据?
后端·github·api
aleafboat1 小时前
只管去写(day2) 用claude分析老项目技术栈
后端
vx_Biye_Design1 小时前
springboot宠物领养与救助平台64334-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·mysql·课程设计·宠物
用户7713970207061 小时前
.NET 依赖注入入门
后端
量化分析码农1 小时前
【Python量化系统工程实战 #03】任务跑挂了没人知道?用 60 行代码搭一套「监控 + 邮件 + 桌面推送」告警系统
后端
柠檬味拥抱1 小时前
基于YOLOv8的电梯内电瓶车检测识别(中英文双版) | 附完整源码与效果演示
后端
知守观1 小时前
HTML 转 PDF 方案实战:iTextPDF 与 wkhtmltopdf 踩坑复盘,附 Flying Saucer / openhtmltopdf 选型对比
java·后端
程序边界1 小时前
16个通道并行灌库是什么体验——KFS入库这点事儿(下)
后端