3. 数据一致性故障:分布式事务超时、主从数据同步延迟、缓存与数据库数据不一致、消息队列重复消费
数据一致性,说白了就是分布式系统里最让人头疼的问题。我做了这么多年运维,见过太多因为数据对不上而导致的线上事故。有的系统表面上跑得挺欢,一查数据库,嘿,账都对不上。今天咱们就把这四种典型场景掰开揉碎了讲清楚。
3.1 分布式事务超时
分布式事务,本质上是把多个本地事务拼成一个全局事务。但网络不是可靠的,节点也不是永远健康的。一旦某个参与者卡住了,整个事务就得等。等久了,就超时。
核心问题:两阶段提交(2PC)中,协调者等待参与者响应,如果参与者宕机或网络分区,协调者会一直阻塞,直到超时。
我在项目中遇到过这么个事:一个订单系统用了 Seata 做分布式事务,某天数据库连接池被打满,导致事务参与者迟迟不返回结果。协调者等了 60 秒,超时了,直接回滚。但回滚本身也需要通信,结果部分节点回滚成功,部分节点没收到回滚指令——数据就这么不一致了。
为什么会这样?说白了,2PC 的阻塞特性是硬伤。协调者单点,参与者同步阻塞,任何一个环节出问题,整个事务就悬在那。
我建议的应对策略:
- 设置合理的超时阈值:别用默认值。根据业务接口的 P99 延迟来定,一般建议 3-5 秒。
- 引入补偿机制:TCC(Try-Confirm-Cancel)模式比 2PC 更灵活。Try 阶段预留资源,Confirm 或 Cancel 阶段异步执行,不阻塞。
- 最终一致性方案:如果业务允许,别用强一致性。用本地消息表 + 定时任务扫描,保证最终能对上。
避坑指南:我曾经在一个支付系统里,把分布式事务的超时时间设成了 30 秒。结果高峰期事务堆积,数据库连接被占满,整个系统雪崩。后来改成 3 秒超时 + 异步补偿,再没出过事。
3.2 主从数据同步延迟
主从架构是标配,但延迟是常态。你想想看,主库写完数据,从库还没同步过来,这时候读从库,读到的是旧数据。用户刚下单成功,刷新页面发现订单没了——这种体验谁受得了?
主从延迟的常见原因:
| 原因 | 说明 | 排查手段 |
|---|---|---|
| 从库复制线程卡住 | SQL 线程或 IO 线程异常 | SHOW SLAVE STATUS 查看 Seconds_Behind_Master |
| 主库大事务 | 一个事务修改了上百万行数据 | 监控 binlog 大小,拆分大事务 |
| 从库硬件性能差 | 磁盘 IO 或 CPU 跟不上主库 | 对比主从的 QPS 和磁盘吞吐 |
| 网络延迟 | 主从跨机房部署 | ping 延迟,检查带宽 |
我个人习惯的做法是:强制读主库。对于一致性要求高的场景(比如支付、库存扣减),所有读写都走主库。从库只负责报表、搜索、历史数据查询这些可以接受延迟的业务。
注意:别指望 Seconds_Behind_Master 这个值完全准确。它只代表从库 SQL 线程落后主库 binlog 的时间,但主库可能还在写,实际延迟可能更大。我见过 Seconds_Behind_Master 显示 0,但数据就是查不到的情况——因为主库的 binlog 还没传到从库。
如果非要读从库,我建议加一层缓存。比如用 Redis 缓存刚写入的数据,设置 5 秒过期。这样即使从库延迟,用户也能从缓存读到最新数据。
3.3 缓存与数据库数据不一致
缓存和数据库不一致,是分布式系统里最常见的坑。你更新了数据库,但忘了更新缓存。或者先删缓存,再更新数据库,结果在删除缓存和更新数据库之间,另一个线程把旧数据又写进了缓存。
我总结了几种常见的不一致场景:
- 先更新数据库,再删除缓存:如果删除缓存失败,缓存里还是旧数据。
- 先删除缓存,再更新数据库:更新数据库期间,另一个线程读缓存发现没有,去数据库读到了旧数据,然后写回缓存。
- 双写并发:两个线程同时写数据库和缓存,顺序不一致导致数据错乱。
怎么解决?我个人推荐「延迟双删」策略:
// 伪代码示例
public void updateData(String key, Object value) {
// 1. 先删除缓存
redis.delete(key);
// 2. 更新数据库
database.update(value);
// 3. 延迟一段时间(比如 500ms),再次删除缓存
Thread.sleep(500);
redis.delete(key);
}
为什么要延迟?因为在这 500ms 内,可能有其他线程把旧数据写回了缓存。第二次删除能保证最终一致性。
避坑指南:我曾经在一个高并发场景下,延迟时间设得太短(100ms),结果还是出现了不一致。后来我根据业务接口的 P99 响应时间,把延迟设成了 1 秒,再没出过问题。具体延迟时间,建议压测后确定。
另一个更稳妥的方案是订阅 binlog。用 Canal 监听 MySQL 的 binlog,一旦数据变更,自动同步到缓存。这样业务代码里不用手动维护缓存,一致性由中间件保证。
3.4 消息队列重复消费
消息队列的「至少一次」语义,说白了就是保证消息不丢,但可能重复。重复消费的后果很严重:订单重复创建、积分重复发放、库存多扣。
重复消费的原因:
- 消费者处理完消息,还没来得及提交 offset,就宕机了。
- 网络抖动,broker 没收到 ack,重新投递消息。
- 消费者业务逻辑异常,重试机制导致重复执行。
核心解法:幂等性。不管消息来多少次,处理结果都一样。
我常用的幂等方案:
| 方案 | 实现方式 | 适用场景 |
|---|---|---|
| 唯一键约束 | 数据库表加唯一索引,重复插入报错 | 订单创建、支付记录 |
| 去重表 | 用消息 ID 做唯一键,先查后插 | 通用场景 |
| 状态机 | 业务状态只有特定方向流转,重复执行不改变状态 | 订单状态更新 |
| Redis 分布式锁 | 用消息 ID 加锁,处理完释放 | 高并发场景 |
我个人习惯用「去重表」方案。在业务数据库里建一张去重表,把消息的唯一 ID 作为主键。消费消息时,先尝试插入去重表,如果插入成功,说明是第一次消费,正常处理;如果插入失败(主键冲突),说明已经处理过了,直接跳过。
-- 去重表结构示例
CREATE TABLE message_dedup (
message_id VARCHAR(64) PRIMARY KEY,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 消费逻辑伪代码
public void consume(Message msg) {
try {
// 尝试插入去重表
dedupDao.insert(msg.getId());
// 插入成功,执行业务逻辑
businessService.process(msg);
} catch (DuplicateKeyException e) {
// 插入失败,说明已处理,直接忽略
log.warn("重复消息,跳过处理: {}", msg.getId());
}
}
注意:去重表需要定期清理,否则数据量会无限增长。我一般设置 7 天过期,用定时任务删除 7 天前的记录。
还有一种情况是消息乱序。比如订单先创建后取消,但消息队列里取消的消息先到了,创建的消息后到了。这时候如果直接处理,会出现「取消一个不存在的订单」的异常。我的做法是:在消息体里带上时间戳或版本号,消费者判断如果当前消息的版本号小于已处理的最大版本号,就丢弃。
数据一致性故障,说到底就两个核心:要么保证操作是幂等的,要么保证操作顺序是确定的。你想想看,只要做到这两点,大部分问题都能解决。我在实际工作中,遇到最多的其实是缓存和数据库不一致,其次是消息重复消费。分布式事务超时和主从延迟,反而因为大家重视,出事的频率低一些。
嗯,这里要注意:别想着用一个方案解决所有问题。不同场景用不同策略,该强一致就强一致,该最终一致就最终一致。灵活一点,系统才能稳。