跳到主要内容

缓存与数据库一致性实战

问题

如何在实际项目中保证缓存(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);
}
}
为什么是删缓存而不是更新缓存?
  1. 避免并发覆盖:两个写请求可能交叉执行,导致缓存写入旧值
  2. 懒计算:删除后下次读时再构建,避免不必要的计算
  3. 简单可靠:删除是幂等操作,重复删除无副作用

方案 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 再删缓存,会有什么问题?

答案

极端场景下可能出现不一致:

  1. 缓存恰好过期
  2. 线程 A 读 DB 得旧值(此时线程 B 还没写 DB)
  3. 线程 B 写 DB + 删缓存
  4. 线程 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 兜底。大部分业务场景下,几秒的不一致是可以接受的。

相关链接