数据不一致排查
问题
线上发现缓存和数据库数据不一致、主从数据不同步等数据不一致问题,如何排查和修复?
答案
一、数据不一致的常见场景
二、缓存与数据库不一致
问题场景:更新数据库后删缓存失败,导致缓存是旧数据。
解决方案:
// 方案 1:重试机制 + 消息队列保证最终一致
async function updateWithRetry(key: string, data: any) {
// 1. 先更新数据库
await db.update(data);
// 2. 删除缓存,失败则发到重试队列
try {
await redis.del(key);
} catch (err) {
// 发送到消息队列异步重试
await mq.publish('cache:invalidate', { key, retries: 0 });
}
}
// 消费者:异步重试删除缓存
async function handleCacheInvalidate(msg: { key: string; retries: number }) {
try {
await redis.del(msg.key);
} catch (err) {
if (msg.retries < 3) {
// 指数退避重试
await mq.publishWithDelay('cache:invalidate', {
key: msg.key,
retries: msg.retries + 1,
}, 1000 * Math.pow(2, msg.retries));
} else {
// 告警人工介入
await alert(`缓存删除失败: ${msg.key}`);
}
}
}
// 方案 2:Binlog 订阅(Canal)自动删缓存
// Canal 监听 MySQL binlog → 解析变更 → 删除对应缓存
// 优点:与业务代码解耦,不会遗漏
三、主从不一致
问题场景:写入主库后立即从从库读取,读到旧数据。
| 原因 | 延迟范围 | 解决方案 |
|---|---|---|
| 主从复制延迟 | 毫秒~秒 | 关键读走主库 |
| 大事务 | 秒~分钟 | 拆分大事务 |
| 从库负载高 | 不定 | 增加从库 |
// 关键场景强制读主库
async function getOrderAfterCreate(orderId: string) {
// 刚创建的订单,短时间内走主库查询
const order = await db.query(
'SELECT * FROM orders WHERE id = ?',
[orderId],
{ useMaster: true } // 强制主库
);
return order;
}
// 通用方案:写后设置标记,短时间内走主库
async function readAfterWrite(userId: string, queryFn: () => Promise<any>) {
const flag = await redis.get(`read_master:${userId}`);
if (flag) {
return queryFn({ useMaster: true });
}
return queryFn({ useMaster: false });
}
async function writeData(userId: string, updateFn: () => Promise<void>) {
await updateFn();
// 设置标记,3 秒内该用户读走主库
await redis.set(`read_master:${userId}`, '1', 'EX', 3);
}
四、并发写入导致不一致
解决方案:乐观锁
-- 使用 version 字段做乐观锁
UPDATE accounts
SET balance = balance - 30, version = version + 1
WHERE id = 1 AND version = 5;
-- 或直接用条件更新(CAS 风格)
UPDATE accounts
SET balance = balance - 30
WHERE id = 1 AND balance >= 30;
五、数据校验与修复
-- 对比两个库的数据差异
-- 方案 1:行数对比
SELECT COUNT(*) FROM db_old.orders;
SELECT COUNT(*) FROM db_new.orders;
-- 方案 2:聚合值对比
SELECT SUM(amount), MAX(updated_at) FROM db_old.orders;
SELECT SUM(amount), MAX(updated_at) FROM db_new.orders;
-- 方案 3:pt-table-checksum(行级校验)
-- 计算每个 chunk 的 checksum 并比对
常见面试问题
Q1: 缓存与数据库一致性最推荐哪种方案?
答案:
| 场景 | 推荐方案 |
|---|---|
| 一般业务 | Cache Aside + 重试 |
| 金融级一致性 | 不用缓存,直接查 DB |
| 大流量 + 最终一致 | Binlog 订阅删缓存(Canal) |
大多数场景采用 Cache Aside + 消息队列重试删除即可满足要求。
Q2: 如何快速判断数据不一致的根因?
答案:
- 看时间差:不一致数据是最新的还是延迟的?延迟 = 主从延迟或缓存过期
- 看操作日志:binlog / audit log 确认写入是否成功
- 看并发:是否有多个写入同一数据?→ 并发冲突
- 看缓存:直接查 Redis 和 DB 对比 → 缓存不一致
- 看从库:
SHOW SLAVE STATUS查延迟 → 主从不一致
Q3: 分布式场景如何保证跨库数据一致性?
答案:
| 方案 | 一致性 | 性能 | 复杂度 |
|---|---|---|---|
| 分布式事务(Seata) | 强一致 | 低 | 高 |
| 本地消息表 | 最终一致 | 高 | 中 |
| TCC | 强一致 | 中 | 高 |
| Saga | 最终一致 | 高 | 中 |
大部分业务场景选择最终一致性,使用本地消息表或 Saga 模式。