缓存与数据库一致性实战
问题
如何在实际项目中保证缓存(Redis)和数据库(MySQL)之间的数据一致性?
答案
一、一致性问题的本质
缓存和数据库是两个独立的数据源,没有分布式事务支持,无法做到强一致。所有方案本质都是在一致性和性能之间做权衡。
二、四种主流方案对比
| 方案 | 一致性 | 性能 | 复杂度 | 推荐场景 |
|---|---|---|---|---|
| Cache Aside | 最终一致 | 高 | 低 | 大多数场景 |
| 延迟双删 | 最终一致 | 高 | 中 | 写入并发高 |
| Binlog 订阅 | 最终一致 | 高 | 中 | 对一致性要求较高 |
| 读写锁 | 强一致 | 低 | 高 | 金融等强一致场景 |
三、方案详解
方案 1:Cache Aside(旁路缓存)
class CacheAsideService {
async get(key: string): Promise<string | null> {
// 1. 先查缓存
const cached = await redis.get(key);
if (cached !== null) return cached;
// 2. 缓存未命中,查数据库
const data = await db.query('SELECT * FROM products WHERE id = ?', [key]);
if (!data) return null;
// 3. 回填缓存(随机 TTL 防雪崩)
const ttl = 3600 + Math.floor(Math.random() * 600);
await redis.set(key, JSON.stringify(data), 'EX', ttl);
return JSON.stringify(data);
}
async update(key: string, data: any): Promise<void> {
// 1. 先更新数据库
await db.query('UPDATE products SET ? WHERE id = ?', [data, key]);
// 2. 再删缓存
await redis.del(key);
}
}
为什么是删缓存而不是更新缓存?
- 避免并发覆盖:两个写请求可能交叉执行,导致缓存写入旧值
- 懒计算:删除后下次读时再构建,避免不必要的计算
- 简单可靠:删除是幂等操作,重复删除无副作用
方案 2:延迟双删
解决 Cache Aside 在读写并发时的短暂不一致:
场景:读请求在写 DB 前回填了旧缓存
时间线:
1. 缓存过期 → 线程 A 查 DB 得到旧值 v1
2. 线程 B 写 DB 新值 v2
3. 线程 B 删缓存
4. 线程 A 将旧值 v1 写入缓存 → 不一致!
async function updateWithDoubleDelete(key: string, data: any) {
// 第一次删缓存
await redis.del(key);
// 更新数据库
await db.update(data);
// 延迟第二次删缓存
// 延迟时间 > 一次数据库读 + 缓存回填时间
setTimeout(async () => {
await redis.del(key);
}, 500);
}
优化:使用消息队列保证延迟删除的可靠性
async function updateWithMQDelete(key: string, data: any) {
await redis.del(key);
await db.update(data);
// 通过消息队列延迟删除,即使服务重启也能执行
await mq.publishWithDelay('cache:delete', { key }, 500);
}
方案 3:Binlog 订阅(Canal)
// Canal 消费者:监听 MySQL 变更,自动删缓存
async function handleBinlogEvent(event: BinlogEvent) {
const { table, type, data } = event;
// 根据表名和操作类型删除对应缓存
if (table === 'products') {
const key = `product:${data.id}`;
await redis.del(key);
}
if (table === 'users') {
const key = `user:${data.id}`;
await redis.del(key);
}
}
优点:
- 与业务代码完全解耦
- 不会遗漏(所有 DB 变更都会产生 binlog)
- 适合微服务架构(多个服务写同一张表)
缺点:
- 引入额外组件(Canal)增加运维成本
- 有延迟(通常 < 1 秒)
四、删除失败的兜底方案
// 消息队列 + 重试机制
async function safeDeleteCache(key: string) {
try {
await redis.del(key);
} catch (error) {
// 发到重试队列
await mq.publish('cache:retry-delete', {
key,
retries: 0,
maxRetries: 5,
});
}
}
// 重试消费者
async function handleRetryDelete(msg: RetryMessage) {
try {
await redis.del(msg.key);
} catch (error) {
if (msg.retries < msg.maxRetries) {
// 指数退避
const delay = Math.pow(2, msg.retries) * 1000;
await mq.publishWithDelay('cache:retry-delete', {
...msg,
retries: msg.retries + 1,
}, delay);
} else {
await alertService.send(`缓存删除永久失败: ${msg.key}`);
}
}
}
五、方案选型决策
常见面试问题
Q1: 先更新 DB 再删缓存,会有什么问题?
答案:
极端场景下可能出现不一致:
- 缓存恰好过期
- 线程 A 读 DB 得旧值(此时线程 B 还没写 DB)
- 线程 B 写 DB + 删缓存
- 线程 A 把旧值写入缓存
但这需要读请求比写请求慢(极罕见),且持续时间仅为一个 TTL 周期。实际中发生概率极低,是性价比最高的方案。
Q2: 为什么不推荐先删缓存再更新 DB?
答案:
因为不一致概率更高:
1. 线程 A 删缓存
2. 线程 B 读缓存 Miss → 查 DB 旧值 → 写入缓存
3. 线程 A 更新 DB
→ 缓存是旧值,直到 TTL 过期才能修复
这个场景在高并发下很容易发生,而「先更新 DB 再删缓存」的不一致场景概率低得多。
Q3: 实际项目中你用的哪种方案?
答案:
| 场景 | 方案 |
|---|---|
| 大部分业务 | Cache Aside + 合理 TTL |
| 对一致性敏感 | Cache Aside + MQ 重试删除 |
| 微服务 + 多写入源 | Binlog 订阅(Canal) |
| 金融场景 | 不使用缓存,或读写锁 |
核心思路:接受最终一致性,通过 TTL 兜底。大部分业务场景下,几秒的不一致是可以接受的。