一次 Cache Invalidation Bug 差点让我们的系统崩溃——以及我们之后的改动
Source: Dev.to
🎬 环境搭建
事故前一晚,我们升级了 Aurora MySQL 引擎版本。
一切看起来都正常——没有警报,也没有红色提示。
第二天早上大约 上午 8 点,我们的日常任务启动——负责:
- 删除过期的 “主数据” 缓存
- 从数据库重新获取最新的主数据
- 将其重新写回缓存
这个主数据集是应用正常运行的关键,如果缓存没有预热,数据库会被大量请求冲击。
💥 爆炸
在引擎升级后,Lambda 中的一个特定查询突然开始耗时 30 秒以上。
我们的 Lambda 设置了 30 秒超时,于是 cacheInvalidate → cacheRebuild 流程失败:
- 缓存保持为空。
- 每个用户请求都导致 缓存未命中。
- 所有请求直接击中数据库。
- Aurora CPU 飙升至 99 %。
- 应用响应整体卡顿。
典型的 缓存抢夺(cache stampede)。
我们最终触发了 故障转移,幸运的是同一个查询在新写节点上运行约 28.7 秒,刚好低于 Lambda 超时,争取了几分钟的恢复时间。
当晚我们发现真正的罪魁祸首:该查询需要一个新索引,升级后执行计划发生了变化。我们通过热修复创建了索引,数据库恢复稳定。更深层的问题在于我们的缓存失效策略。
🧹 我们原来的缓存失效方式:先删后盼
最初的流程是:
- 删除已有的缓存键
- 从数据库获取最新数据
- 将其写回缓存
如果第 2 步失败,整个系统就会崩溃。我们的 Lambda 未能获取到新数据,导致缓存一直为空。
🔧 我们的改动(以及推荐做法)
1. 在拥有新数据之前绝不删除缓存
我们把流程反转:
- 获取 → 验证 → 更新缓存
- 只有在已经拿到新数据的情况下才删除旧缓存
这样就消除了 “缓存空窗期”。
2. 使用 “过期滚动” 代替粗暴删除
如果刷新任务失败,我们现在:
- 重命名键
"Master-Data"→"Master-Data-Stale" - 保留旧值可用
- 发送内部通知,让团队进行调查
即使数据库慢或不可用,系统仍然可以提供 某些 数据。虽然不是最理想的方案,但能防止崩溃。
3. API 层在新数据不可用时返回过期数据
API 逻辑改为:
- 尝试读取
"Master-Data" - 若未命中,尝试重建(仅在允许的情况下)
- 若重建失败 → 返回过期数据
避免了级联故障。
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. 为刷新过程添加可观测性
我们现在记录:
- 查询执行时间
- 缓存刷新时长
- 锁获取指标
- 当刷新时间超过阈值时触发告警
目标是在超时发生前捕获慢查询。
📝 关键要点
- 引擎升级可能会显著改变执行计划。在重大数据库变更后务必对关键查询进行基准测试。
- 缓存失效策略必须假设 刷新可能会失败。
- 提供 过期但仍有效的数据 往往比返回错误更好。
- 分布式锁 对防止缓存抢夺至关重要。
🚀 最后思考
这次事故让人紧张,但收获颇丰。缓存问题很少在普通流量下显现——它们往往在系统最繁忙时爆发。如果你的应用中存在类似 “先删后刷新” 的模式,请在它审查你之前先审查它。