从微服务到多副本:分布式一致性为什么越来越难
从微服务到多副本:分布式一致性为什么越来越难
上一篇我们从线程、进程、单库三个层次看了一致性问题。那三层的共同特点是:系统里通常还有一个相对明确的权威状态源。
线程级别,权威状态是进程内存。单库级别,权威状态是数据库。
但进入微服务和分布式系统后,问题开始变化:
一次业务可能跨多个服务。
一次写入可能涉及 MySQL、Redis、MQ、ES 和第三方系统。
同一份数据可能有多个副本。
网络、节点、消息、缓存都可能失败或延迟。一致性问题还是那句话:
一致性问题 = 多个执行单元 + 共享或复制的状态 + 缺少可靠同步机制只是这里的执行单元更远,状态源更多,失败模式也更多。
本文继续看后三层:
多服务级别:服务 A 调服务 B,各自有事务
多资源级别:MySQL + Redis + MQ + ES + 第三方系统
多机级别:副本、分片、集群、网络分区多服务级别:服务 A 调服务 B,各自有事务
最常见的问题是下单。
一个简单的电商下单流程可能是:
订单服务:创建订单
库存服务:扣库存
优惠券服务:核销优惠券
积分服务:增加积分
支付服务:发起支付如果这些操作都在一个单体应用里,并且都访问同一个数据库,那么还可以用本地事务包起来。
但拆成微服务以后,每个服务都有自己的事务:
订单服务本地事务提交成功
库存服务本地事务提交成功
优惠券服务调用失败这时系统处于尴尬状态:订单已经创建,库存已经扣了,但优惠券没核销。没有一个天然的数据库事务能同时管理这几个服务。
这就是分布式事务问题的起点。
最优先的方案:收敛事务边界
工程上第一反应不应该是立刻上分布式事务框架,而是先问:
这几个强一致写操作能不能收敛到一个服务里?
能不能由一个服务持有核心状态?
能不能减少跨服务写操作?比如订单创建和库存扣减如果强一致要求很高,可以让订单服务直接持有订单库存表,或者由库存服务提供一个原子扣减接口,订单服务只根据扣减结果继续推进。
很多所谓分布式事务问题,其实是服务边界拆得太碎导致的。
面试里可以这样说:
我会先看一致性要求和服务边界。如果是强一致核心链路,优先考虑把写操作收敛到一个本地事务或一个权威服务里。只有在业务边界确实无法收敛时,才考虑 TCC、Saga、Seata、本地消息表或 MQ 最终一致。
TCC:显式预留、确认、取消
TCC 分三步:
Try:预留资源
Confirm:确认提交
Cancel:取消释放扣库存可以设计成:
Try:冻结库存
Confirm:扣减冻结库存
Cancel:释放冻结库存TCC 的优点是控制力强,适合账户、库存、额度这类资源预留场景。缺点也明显:业务侵入很强,每个参与方都要实现 Try、Confirm、Cancel,并处理空回滚、幂等、悬挂等问题。
Saga:长事务拆成一组本地事务和补偿动作
Saga 的思路是:每一步都是本地事务,失败后执行反向补偿。
创建订单 -> 扣库存 -> 扣优惠券 -> 发起支付
如果扣优惠券失败:
补偿库存
取消订单它适合流程长、强一致要求没那么高、允许补偿的业务。比如旅游订单、审批流、跨系统工单。
Saga 的关键不是“失败就回滚数据库”,而是业务补偿。尤其是财务场景,很多数据不能物理删除或简单回滚,只能冲正、撤销、补记一条反向流水。
本地消息表:先保证消息一定能发出去
本地消息表的核心是把“业务写入”和“消息记录”放在同一个数据库事务里:
本地事务:
写订单表
写 outbox 消息表
后台任务:
扫描 outbox
投递 MQ
投递成功后标记 sent这样至少保证:只要订单写成功,对应消息就一定在本地表里,不会出现“订单成功但消息完全丢失”的情况。
消费者侧必须幂等,因为消息可能重复投递。
MQ 最终一致:异步推进业务
MQ 最终一致常见于:
订单创建成功后,发消息通知库存服务扣库存
支付成功后,发消息通知订单服务改状态
用户注册后,发消息创建积分账户这种方案的重点不是“用了 MQ 就一致”,而是要补齐几个条件:
生产者确认:消息真的进入 MQ。
消费者确认:业务处理成功后再 ACK。
重试机制:临时失败可以再次消费。
死信队列:多次失败后隔离处理。
幂等设计:重复消息不会重复扣减。
补偿任务:长期异常的数据能被扫描修复。面试回答时要主动提一句:
MQ 能解决解耦和异步,不天然解决一致性。一致性来自可靠投递、消费确认、幂等、重试、死信和补偿这一整套机制。
多资源级别:MySQL + Redis + MQ + ES + 第三方系统
多服务是事务边界变多;多资源是状态源变多。
典型场景:
写 MySQL 后删除 Redis 缓存
写 MySQL 后同步 ES 索引
支付回调后更新订单、发 MQ、加积分
用户修改资料后同步搜索、推荐、风控这些资源不是同一个事务系统。MySQL 可以提交,Redis 可以失败,MQ 可以超时,ES 可以延迟,第三方支付可能重复回调。
这里最容易犯的错是“双写幻想”:
updateMysql();
updateRedis();
updateEs();
sendMq();这段代码看起来顺序清楚,但任意一步失败都会留下不一致状态。
MySQL 和 Redis:缓存一致性
最常见的问题是:
先写库,再删缓存
先删缓存,再写库
先写库,再写缓存没有一种简单顺序能在所有并发和失败场景下完美。
工程上常用的是:
读:先读缓存,缓存没有再读数据库并回填。
写:先写数据库,成功后删除缓存。
兜底:缓存设置过期时间。
增强:延迟双删、binlog 订阅、缓存重建互斥。为什么通常是删除缓存,而不是更新缓存?
因为缓存里的数据可能是复杂聚合结果,更新逻辑容易和读逻辑不一致。删除缓存后让下一次读重新构建,通常更稳。
MySQL 和 ES:搜索索引一致性
ES 通常不是权威数据源,而是查询加速层。权威数据还在 MySQL。
常见方案:
业务写 MySQL
记录 outbox 消息或发送 MQ
消费者同步 ES
失败重试
定时校验 MySQL 和 ES 差异如果一致性要求更高,可以用 binlog/CDC:
MySQL binlog -> Canal/Debezium -> MQ -> ES Consumer -> Elasticsearch这种方案减少业务代码侵入,但仍然是最终一致。ES 可能延迟,消费者可能失败,消息可能重复,所以消费者仍然要幂等。
MySQL 和 MQ:消息可靠性
最危险的点是:
数据库事务提交成功,但消息发送失败。
消息发送成功,但数据库事务回滚。解决思路有几类:
本地消息表 / Outbox
事务消息
业务状态扫描补偿
binlog 驱动消息其中 Outbox 是非常通用的工程方案。它牺牲一点实时性,换来更清晰的可靠性。
第三方系统:不能假设别人能回滚
支付、银行、短信、物流、外部 SaaS 都属于第三方系统。
这些系统通常不会参与你的本地事务,也不会因为你的数据库回滚而自动撤销操作。
比如财务场景:
你不能简单删除一条已经发生的财务流水。
更合理的是补一条反向流水,形成冲正。所以多资源一致性里,补偿不是技术细节,而是业务建模。
面试里可以这样说:
涉及第三方系统时,我不会把它当成数据库事务的一部分。要先确认对方接口是否幂等、是否支持查询、是否支持撤销或冲正。工程上通常用本地状态机记录调用状态,通过重试、查询确认和补偿流水保证最终一致。
多机级别:副本、分片、集群、网络分区
再往上就是典型分布式系统。
这一层的问题不只是“多个服务写同一个状态”,而是“同一份状态本身有多个副本”。
例如:
MySQL 主从复制
Redis 主从复制和哨兵切换
Kafka 多副本
Elasticsearch 分片和副本
注册中心集群
配置中心集群
分布式 KV 存储只要有副本,就会有同步延迟。只要有网络,就会有丢包、超时、重试、分区。只要有故障转移,就会有新旧主切换时的一致性问题。
主从复制:读写分离带来的不一致
MySQL 主从读写分离很常见:
写请求 -> 主库
读请求 -> 从库问题是主从复制有延迟。用户刚修改昵称,立刻查询个人信息,如果读到了从库,可能还是旧昵称。
常见解决方案:
写后短时间内读主库。
关键业务读主库。
根据主从延迟动态决定是否读从库。
业务上接受短暂最终一致。这就是典型的“读己之写”问题:用户自己刚写的数据,下一次读应该读到。
Redis 主从和哨兵:故障转移不是零成本
Redis 主从复制通常是异步的。主节点写成功后,还没来得及同步到从节点,主节点宕机,哨兵把某个从节点提升为新主。这时未同步的数据可能丢失。
所以 Redis 可以做很多高性能场景,但如果拿它做强一致账本,就要非常谨慎。
Redis 分布式锁也有类似问题。单节点 Redis 锁简单高效;但如果 Redis 主从切换时锁数据丢失,可能出现两个客户端都认为自己拿到了锁。
所以面试里说 Redis 锁时,不要只说 set nx ex。要补充:
锁值要唯一,释放锁要校验 value。
加锁和过期要原子。
业务时间超过 TTL 时要续约。
释放锁要用 Lua 保证原子性。
多实例和主从切换场景下要评估安全性。
强一致锁可以考虑 ZooKeeper / etcd。Kafka 副本:多数派和 ISR
Kafka 的数据可靠性不是因为“写了 MQ 就不会丢”,而是因为它有分区、副本、leader、ISR、ACK 等机制。
生产者发送消息时,常见参数是 acks:
acks=0:发出去就算成功,性能高,可靠性低。
acks=1:leader 写入成功就返回。
acks=all:ISR 副本都确认后返回,可靠性更高。这里的核心思想是:写入成功的定义要和副本确认策略绑定。
但可靠性提高通常会牺牲延迟和吞吐。这就是分布式系统里经常出现的取舍。
共识:为什么多数派有用
etcd、ZooKeeper、很多分布式数据库会用 Raft / Paxos 这类共识算法。
共识算法解决的不是“让所有节点永远都不出错”,而是在节点故障、网络延迟、消息乱序的情况下,让集群对某个值或某条日志的顺序达成一致。
多数派有一个关键性质:
任意两个多数派集合一定有交集。如果一个值已经被多数派确认,那么下一轮多数派里至少有一个节点知道这个值。这样可以避免两个完全不相交的群体各自决定不同结果。
这也是为什么强一致系统通常要牺牲一部分可用性。网络分区时,如果某一侧拿不到多数派,它就不能随便对外承诺写成功。
CAP 到底在说什么
CAP 不是说系统平时只能三选二,而是说发生网络分区时,不能同时保证一致性和可用性。
C:一致性,读到的是最新写入或明确失败。
A:可用性,每个请求都能得到非错误响应。
P:分区容忍,网络分区时系统仍然继续运行。分布式系统里网络分区无法彻底避免,所以 P 基本不能放弃。真正的选择是:
CP:分区时宁愿拒绝部分请求,也不返回不一致数据。
AP:分区时尽量响应请求,之后再修复数据。ZooKeeper、etcd 更偏 CP。很多缓存、DNS、最终一致系统更偏 AP。
从面试题看一致性层级
面试官问一个场景时,可以先判断它在哪一层:
Spring 单例 Bean 成员变量是否安全?
线程级别,共享 JVM 堆。
synchronized 能不能防止多个服务重复执行定时任务?
不能。它只锁当前 JVM,多个服务实例是进程级/服务级问题。
两个服务访问同一个数据库,算不算分布式事务?
看一次业务是否跨多个独立事务边界。
写 MySQL 后怎么同步 Redis / ES?
多资源一致性,通常是最终一致、MQ、binlog、补偿。
Redis 主从切换会不会丢数据?
多副本一致性,要看复制方式和故障切换窗口。
Kafka 怎么保证消息不丢?
多副本和 ACK 策略,还要看生产者重试、消费者 ACK 和幂等。这个判断比直接背答案更重要。因为同一个“扣库存”问题,在不同层级下答案完全不同:
单 JVM 内扣库存:
synchronized / AtomicInteger / 本地锁
多个 JVM 扣库存:
数据库行锁 / 乐观锁 / Redis 原子扣减
多个服务跨库扣库存:
TCC / Saga / MQ 最终一致 / 补偿
库存有多个副本:
主从复制 / 共识 / 分片路由 / 最终一致第二篇小结
从微服务到多副本,一致性问题越来越难,是因为系统逐步失去单一权威状态源:
多服务级别:
一次业务跨多个服务,每个服务有自己的本地事务。
多资源级别:
一次写入跨 MySQL、Redis、MQ、ES、第三方系统,没有统一提交点。
多机级别:
同一份数据有多个副本,网络延迟、节点故障、主从切换都会制造不一致窗口。工程上不要一听“不一致”就急着套方案。先问四件事:
谁是权威状态源?
一致性要求是强一致还是最终一致?
失败后能不能补偿?
重复、延迟、乱序、部分成功时怎么处理?如果能回答清楚这四个问题,大部分分布式面试题就不再是零散知识点,而是同一条主线上的不同场景。