一次 Cache Invalidation Bug 差点让我们的系统崩溃——以及我们之后的改动

发布: (2025年12月5日 GMT+8 09:57)
6 min read
原文: Dev.to

Source: Dev.to

🎬 环境搭建

事故前一晚,我们升级了 Aurora MySQL 引擎版本。
一切看起来都正常——没有警报,也没有红色提示。

第二天早上大约 上午 8 点,我们的日常任务启动——负责:

  • 删除过期的 “主数据” 缓存
  • 从数据库重新获取最新的主数据
  • 将其重新写回缓存

这个主数据集是应用正常运行的关键,如果缓存没有预热,数据库会被大量请求冲击。


💥 爆炸

在引擎升级后,Lambda 中的一个特定查询突然开始耗时 30 秒以上
我们的 Lambda 设置了 30 秒超时,于是 cacheInvalidate → cacheRebuild 流程失败:

  • 缓存保持为空。
  • 每个用户请求都导致 缓存未命中
  • 所有请求直接击中数据库。
  • Aurora CPU 飙升至 99 %
  • 应用响应整体卡顿。

典型的 缓存抢夺(cache stampede)

我们最终触发了 故障转移,幸运的是同一个查询在新写节点上运行约 28.7 秒,刚好低于 Lambda 超时,争取了几分钟的恢复时间。

当晚我们发现真正的罪魁祸首:该查询需要一个新索引,升级后执行计划发生了变化。我们通过热修复创建了索引,数据库恢复稳定。更深层的问题在于我们的缓存失效策略。

🧹 我们原来的缓存失效方式:先删后盼

最初的流程是:

  1. 删除已有的缓存键
  2. 从数据库获取最新数据
  3. 将其写回缓存

如果第 2 步失败,整个系统就会崩溃。我们的 Lambda 未能获取到新数据,导致缓存一直为空。

🔧 我们的改动(以及推荐做法)

1. 在拥有新数据之前绝不删除缓存

我们把流程反转:

  • 获取 → 验证 → 更新缓存
  • 只有在已经拿到新数据的情况下才删除旧缓存

这样就消除了 “缓存空窗期”。

2. 使用 “过期滚动” 代替粗暴删除

如果刷新任务失败,我们现在:

  1. 重命名键
    "Master-Data""Master-Data-Stale"
  2. 保留旧值可用
  3. 发送内部通知,让团队进行调查

即使数据库慢或不可用,系统仍然可以提供 某些 数据。虽然不是最理想的方案,但能防止崩溃。

3. API 层在新数据不可用时返回过期数据

API 逻辑改为:

  1. 尝试读取 "Master-Data"
  2. 若未命中,尝试重建(仅在允许的情况下)
  3. 若重建失败 → 返回过期数据

避免了级联故障。

4. 添加 Redis 分布式锁防止缓存抢夺

如果没有锁,多个 API 节点或 Lambda 可能会同时尝试重建,重新冲击数据库。使用 Redis 锁后:

  • 只有 一个 请求获得锁并执行重建。
  • 其他请求 直接访问数据库,而是返回过期数据或等待锁持有者重新填充缓存。

Node.js – 获取分布式锁 (Redis)

// redis.js
const { createClient } = require("redis");

const redis = createClient({
  url: process.env.REDIS_URL
});
redis.connect();

module.exports = redis;

获取与释放锁

// lock.js
const redis = require("./redis");
const { randomUUID } = require("crypto");

const LOCK_KEY = "lock:master-data-refresh";
const LOCK_TTL = 10000; // 10 seconds

async function acquireLock() {
  const lockId = randomUUID();

  const result = await redis.set(LOCK_KEY, lockId, {
    NX: true,
    PX: LOCK_TTL
  });

  if (result === "OK") {
    return lockId; // lock acquired
  }

  return null; // lock not acquired
}

async function releaseLock(lockId) {
  const current = await redis.get(LOCK_KEY);

  if (current === lockId) {
    await redis.del(LOCK_KEY);
  }
}

module.exports = { acquireLock, releaseLock };

使用示例

const { acquireLock, releaseLock } = require("./lock");

async function refreshMasterData() {
  const lockId = await acquireLock();

  if (!lockId) {
    console.log("Another request is refreshing. Returning stale data.");
    return getStaleData();
  }

  try {
    const newData = await fetchFromDB();
    await saveToCache(newData);
    return newData;
  } finally {
    await releaseLock(lockId);
  }
}

5. 为刷新过程添加可观测性

我们现在记录:

  • 查询执行时间
  • 缓存刷新时长
  • 锁获取指标
  • 当刷新时间超过阈值时触发告警

目标是在超时发生前捕获慢查询。

📝 关键要点

  • 引擎升级可能会显著改变执行计划。在重大数据库变更后务必对关键查询进行基准测试。
  • 缓存失效策略必须假设 刷新可能会失败
  • 提供 过期但仍有效的数据 往往比返回错误更好。
  • 分布式锁 对防止缓存抢夺至关重要。

🚀 最后思考

这次事故让人紧张,但收获颇丰。缓存问题很少在普通流量下显现——它们往往在系统最繁忙时爆发。如果你的应用中存在类似 “先删后刷新” 的模式,请在它审查你之前先审查它。

Back to Blog

相关文章

阅读更多 »

AI 驱动开发平台

🤔 让我彻夜难眠的问题 想象一下:你在 GitHub 上发现了一个超棒的开源项目。它有 10,000 多个 issue,数百名贡献者,……