分布式系统商业场景训练营
这页专门训练“把分布式原理落到商业系统”的能力。分布式不是背 CAP、TCC、Saga、Seata、Redisson 这些词,而是面对订单、支付、库存、采集、搜索、缓存和消息这些真实链路时,能判断哪里会失败、失败后数据会变成什么状态、怎么重试、怎么补偿、怎么对账、怎么排查。
训练目标
学完这页,你要能做到:
- 解释远程调用超时为什么不是失败,也不是成功。
- 给写接口设计幂等键、唯一约束和状态机。
- 按业务风险选择 XA/JTA/2PC、TCC、Saga、本地消息表、事务消息、Seata 或最大努力通知。
- 解释 CAP、BASE 在注册中心、订单、搜索、缓存中的实际取舍。
- 设计订单、支付、库存、ES 同步、缓存更新、采集任务的最终一致方案。
- 解释分布式锁能做什么,不能做什么,为什么还要数据库约束兜底。
- 能按日志、消息、事务表、补偿任务和对账结果排查数据不一致。
分布式问题总图
flowchart TD
A["业务请求"] --> B["远程调用"]
B --> C{"成功、失败、超时"}
C --> D["幂等和状态机"]
D --> E["本地事务"]
E --> F{"是否跨服务一致"}
F -- "强一致" --> G["XA/JTA/2PC 或 TCC"]
F -- "最终一致" --> H["本地消息表、事务消息、Saga"]
H --> I["重试、死信、补偿"]
G --> I
I --> J["对账和告警"]初学者要先记住一句话:
分布式系统不是让故障消失,而是让每种故障都有可追踪、可重试、可补偿、可对账的闭环。
商业场景一:下单、锁库存、支付
业务目标
用户下单时常见步骤:
- 创建订单。
- 锁定库存。
- 使用优惠券。
- 创建支付单。
- 支付成功后更新订单。
- 发送通知、积分、搜索同步等后置动作。
拆成微服务后可能是:
flowchart TD
A["order-service<br/>订单库"] --> B["stock-service<br/>库存库"]
A --> C["coupon-service<br/>优惠券库"]
A --> D["pay-service<br/>支付库"]
A --> E["message-service<br/>通知"]
A --> F["search-sync<br/>ES"]这里没有一个天然的本地事务可以包住所有库,所以必须设计一致性方案。
远程调用的三种状态
调用库存服务锁库存:
flowchart TD
A["订单服务发送锁库存请求"] --> B["库存服务按orderNo执行或幂等复用"]
B --> C["响应超时或丢失"]
C --> D["调用方不能确认库存是否已锁定"]
D --> E["查询锁定流水、幂等重试或补偿"]调用方看到超时,只能说明“没有拿到结果”,不能说明“库存没有锁定”。如果直接重试,而库存接口没有幂等,就可能重复锁库存。
正确做法:
- 请求带
orderNo或bizNo作为幂等键。 - 库存服务写锁库存流水,唯一键保证同一订单只锁一次。
- 超时后先查询锁库存状态,或者重试幂等接口。
- 长期不确定时进入补偿任务和人工告警。
幂等设计 Demo
幂等表
create table idempotent_record (
id bigint primary key auto_increment,
biz_type varchar(64) not null,
biz_key varchar(128) not null,
status varchar(32) not null,
result_json text null,
created_at datetime not null,
updated_at datetime not null,
unique key uk_biz (biz_type, biz_key)
);订单状态机
create table t_order (
id bigint primary key auto_increment,
order_no varchar(64) not null,
status varchar(32) not null,
amount bigint not null,
version int not null default 0,
unique key uk_order_no (order_no)
);支付回调幂等处理
@Transactional
public void handlePayCallback(PayCallback callback) {
boolean first = idempotentRepository.tryInsert("PAY_CALLBACK", callback.payNo());
if (!first) {
return;
}
int updated = orderRepository.markPaid(
callback.orderNo(),
"WAIT_PAY",
"PAID",
callback.payNo()
);
if (updated == 0) {
Order order = orderRepository.findByOrderNo(callback.orderNo());
if (!"PAID".equals(order.status())) {
throw new IllegalStateException("订单状态不允许支付回调: " + order.status());
}
}
}核心点:
- 幂等记录用唯一键挡重复请求。
- 订单更新带前置状态,避免状态乱跳。
- 已支付订单再次收到回调,直接返回成功。
- 非法状态要告警,而不是静默吞掉。
如果不会这样设计,重复回调、MQ 重投、接口重试都可能造成重复扣款、重复发券或订单状态回退。
CAP 和 BASE 放到业务里怎么理解
CAP 不是背三选二,而是网络分区时的取舍。
flowchart TD
A["网络分区发生"] --> B{"还要继续接收写请求吗"}
B -- "继续" --> C["可用性更好<br/>可能短暂不一致"]
B -- "拒绝部分写" --> D["一致性更强<br/>可用性下降"]商业场景:
| 场景 | 更偏向 | 原因 |
|---|---|---|
| ZooKeeper 选主 | CP | 不能出现两个 Leader |
| Eureka 服务发现 | AP | 注册信息短暂不一致可接受 |
| 余额扣减 | C | 错账代价高 |
| 商品浏览量 | A | 短暂不准可接受 |
| ES 搜索结果 | 最终一致 | MySQL 是事实源,ES 是查询视图 |
| Redis 缓存 | 最终一致 | 缓存可重建,数据库才是事实源 |
BASE 不是“不管一致性”。BASE 允许短暂中间状态,但要求最后能靠重试、补偿、死信、对账和告警回到正确状态。
分布式事务选型决策树
flowchart TD
A["跨服务数据一致性"] --> B{"能否调整模型避免跨服务事务"}
B -- "能" --> C["合并事务边界或改成异步后置动作"]
B -- "不能" --> D{"是否必须立即强一致"}
D -- "是" --> E{"业务是否能资源预留"}
E -- "能" --> F["TCC"]
E -- "不能" --> G["XA/JTA/2PC 或 Seata XA/AT"]
D -- "否" --> H{"是否长流程多步骤"}
H -- "是" --> I["Saga"]
H -- "否" --> J["本地消息表或 MQ 事务消息"]
J --> K["幂等、重试、死信、补偿、对账"]
I --> K
F --> K
G --> K方案对比
| 方案 | 适合场景 | 优点 | 代价 |
|---|---|---|---|
| XA/JTA/2PC | 短事务、强一致、多资源支持 XA | 一致性强 | 锁时间长、性能低、资源要求高 |
| TCC | 库存、余额、券预留 | 业务可控,确认/取消明确 | 侵入大,要处理空回滚、悬挂、幂等 |
| Saga | 订单履约、审批、物流 | 适合长流程 | 补偿复杂,只能最终一致 |
| 本地消息表 | 订单后置通知、积分、ES 同步 | 落地稳,依赖少 | 有延迟,需要投递任务 |
| MQ 事务消息 | 本地事务和发消息一致 | 消息链路更自然 | 消费端仍要幂等和补偿 |
| Seata AT | SQL 场景快速接入 | 业务侵入较低 | undo_log、全局锁、SQL 兼容限制 |
| 最大努力通知 | 支付回调、三方通知 | 适合通知类 | 依赖重试和主动查询 |
本地消息表落地 Demo
本地消息表是商业项目里非常常用的最终一致方案。
表结构
create table local_message (
id bigint primary key auto_increment,
biz_type varchar(64) not null,
biz_key varchar(128) not null,
topic varchar(128) not null,
body text not null,
status varchar(32) not null,
retry_count int not null default 0,
next_retry_time datetime not null,
created_at datetime not null,
updated_at datetime not null,
unique key uk_biz_msg (biz_type, biz_key)
);创建订单时写消息
@Transactional
public void createOrder(CreateOrderCommand command) {
Order order = Order.create(command);
orderRepository.save(order);
LocalMessage message = LocalMessage.of(
"ORDER_CREATED",
order.orderNo(),
"order-created-topic",
JsonUtils.toJson(new OrderCreatedEvent(order.orderNo(), order.skuId()))
);
localMessageRepository.save(message);
}这一步用同一个本地事务保证:订单创建成功,消息记录也一定存在;订单创建失败,消息记录也不会提交。
投递任务
public void publishPendingMessages() {
List<LocalMessage> messages = localMessageRepository.findPending(100);
for (LocalMessage message : messages) {
try {
mqProducer.send(message.topic(), message.body());
localMessageRepository.markSuccess(message.id());
} catch (Exception ex) {
localMessageRepository.markRetry(message.id(), ex.getMessage());
}
}
}消费端幂等
@Transactional
public void consumeOrderCreated(OrderCreatedEvent event) {
boolean first = consumeLogRepository.tryInsert("ORDER_CREATED", event.orderNo());
if (!first) {
return;
}
stockService.lockStock(event.orderNo(), event.skuId());
}这套方案的关键不是“发了 MQ”,而是:
- 本地事务保证业务数据和消息记录一起提交。
- 后台投递失败可重试。
- 消费端必须幂等。
- 长期失败进入异常表、死信或告警。
- 对账任务能发现订单和库存状态不一致。
TCC 适合什么
TCC 是 Try、Confirm、Cancel。
以库存锁定为例:
flowchart TD
A["Try 锁定库存"] --> B{"所有参与者 Try 成功吗"}
B -- "是" --> C["Confirm 确认扣减"]
B -- "否" --> D["Cancel 释放锁定库存"]Try 阶段不是“试一下不落库”,而是要真实预留资源。例如:
update stock
set available = available - 1,
locked = locked + 1
where sku_id = 1001
and available >= 1;Confirm:
update stock
set locked = locked - 1,
sold = sold + 1
where sku_id = 1001
and locked >= 1;Cancel:
update stock
set available = available + 1,
locked = locked - 1
where sku_id = 1001
and locked >= 1;TCC 必须处理:
| 问题 | 含义 | 处理 |
|---|---|---|
| 幂等 | Confirm/Cancel 可能重复 | 分支事务表唯一键 |
| 空回滚 | Try 没执行,Cancel 先来了 | 记录空回滚并返回成功 |
| 悬挂 | Cancel 后 Try 又到 | Try 前检查是否已空回滚 |
| 超时 | Try 后长时间没 Confirm | 定时 Cancel 或事务协调器处理 |
Saga 适合什么
Saga 适合长流程,比如订单履约:
flowchart TD
A["创建订单"] --> B["锁库存"]
B --> C["生成支付单"]
C --> D["创建物流单"]
D --> E{"物流失败"}
E -- "是" --> F["取消支付单"]
F --> G["释放库存"]
G --> H["关闭订单"]Saga 的每一步都是本地事务,失败后执行补偿事务。它不适合资金强一致的核心扣款,但适合审批、履约、物流、通知、采集流程这种长链路。
不这样会怎样:
- 任意一步失败后,前面成功的动作没人撤销。
- 补偿动作如果不幂等,重复补偿会造成新的错误。
- 没有状态机,流程可能从失败又跳回成功。
Seata 怎么理解
Seata 不是分布式事务的全部,它是一个框架,提供 AT、TCC、Saga、XA 等模式。
AT 简化流程
flowchart TD
A["业务 SQL 执行前"] --> B["记录 before image"]
B --> C["执行业务 SQL"]
C --> D["记录 after image 和 undo_log"]
D --> E["本地事务提交"]
E --> F{"全局事务成功吗"}
F -- "成功" --> G["删除 undo_log"]
F -- "失败" --> H["根据 undo_log 反向补偿"]AT 模式好处是业务侵入低,但要注意:
- 依赖 SQL 解析和
undo_log。 - 有全局锁,热点数据会竞争。
- 不是所有复杂 SQL 都适合。
- 长事务会放大锁冲突。
最大努力通知
支付回调常见最大努力通知:
flowchart TD
A["支付平台通知商户"] --> B{"商户返回成功吗"}
B -- "是" --> C["停止通知"]
B -- "否" --> D["按规则重试通知"]
D --> E{"超过最大次数"}
E -- "否" --> A
E -- "是" --> F["商户主动查询或人工处理"]业务系统不能只依赖对方通知。正确做法:
- 回调接口幂等。
- 回调验签。
- 订单状态机更新。
- 定时主动查询支付平台状态。
- 对账文件或对账接口校验最终结果。
分布式锁边界
Redis 锁适合短时间互斥,不适合作为最终正确性的唯一保证。
正确释放锁:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end为什么锁不够:
- 锁可能过期。
- 业务执行可能超过租约。
- 网络抖动可能导致客户端误判。
- 主从切换可能造成锁状态丢失。
- 即使加锁失败,也必须保证数据库层不会写错。
关键业务要加兜底:
| 防线 | 作用 |
|---|---|
| Redis 锁 | 降低并发冲突 |
| 唯一索引 | 防重复写入 |
| 状态机 | 防非法状态跳转 |
| 乐观锁 | 防并发覆盖 |
| 幂等表 | 防重复请求 |
| 补偿对账 | 修复长期不一致 |
缓存一致性和 ES 一致性
Redis 缓存和 ES 索引都不是事实源。事实源通常是 MySQL、PostgreSQL、Oracle 或业务主库。
Redis 与数据库
flowchart TD
A["写请求"] --> B["更新数据库"]
B --> C["事务提交"]
C --> D["删除缓存"]
D --> E{"删除成功吗"}
E -- "是" --> F["后续读请求回源重建缓存"]
E -- "否" --> G["记录重试任务和告警"]MySQL 与 ES
flowchart TD
A["MySQL 变更"] --> B["MQ 或 Binlog CDC"]
B --> C["同步消费者"]
C --> D["写 ES"]
D --> E{"写入成功吗"}
E -- "是" --> F["搜索视图更新"]
E -- "否" --> G["重试、死信、补偿"]
G --> H["必要时按 MySQL 重建索引"]如果 ES 更新失败,不能静默丢弃。要记录事件、重试、死信、告警、补偿,必要时用别名重建索引。否则用户会在搜索里看到旧数据,而详情页看到新数据。
采集任务分布式设计
医疗数据采集常见链路:
flowchart TD
A["调度中心触发任务"] --> B["多个执行器分片"]
B --> C["读取医院数据"]
C --> D["原始数据落库"]
D --> E["清洗转换"]
E --> F["业务表入库"]
F --> G["发送资产变更消息"]
G --> H["同步 ES 和缓存"]关键设计:
- 调度分片只能提高并行度,不能替代幂等。
- 每条采集数据要有业务唯一键,例如
hospitalId + sourceId + recordNo。 - 原始数据最好先落库或落对象存储,避免处理中断后无法追溯。
- 清洗入库使用唯一约束防重复。
- 资产变更通过 MQ 同步 ES。
- ES 同步失败进入重试和死信。
- 定时对账发现 MySQL 和 ES 差异。
生产排查流程
数据不一致
flowchart TD
A["发现数据不一致"] --> B["确定事实源"]
B --> C["查业务状态机"]
C --> D["查本地事务是否提交"]
D --> E["查消息是否发送"]
E --> F["查消费日志和幂等表"]
F --> G["查重试、死信、补偿任务"]
G --> H["按事实源修复并补告警"]分布式事务卡住
flowchart TD
A["事务卡住"] --> B["查全局事务状态"]
B --> C["查分支事务状态"]
C --> D["查业务库锁等待"]
D --> E["查 undo_log 或事务日志"]
E --> F["查协调器和网络"]
F --> G["决定重试、补偿或人工介入"]MQ 堆积导致最终一致延迟
flowchart TD
A["消息堆积"] --> B["看生产 TPS 和消费 TPS"]
B --> C["看 Lag 分布"]
C --> D["判断是整体慢还是个别分区慢"]
D --> E["查消费者线程、DB、锁、慢消息"]
E --> F["扩容、限流、修慢查询、处理热点"]面试标准回答
分布式事务怎么选
我会先判断能不能避免跨服务事务,能通过调整领域边界或异步后置动作解决,就不要强行上分布式事务。必须跨服务时,如果是短事务且资源支持 XA,可以考虑 XA/JTA/2PC 或 Seata XA/AT;如果业务能做资源预留,比如库存、余额、券,适合 TCC;如果是履约、审批、物流这种长流程,适合 Saga;如果是订单后置通知、积分、ES 同步,通常用本地消息表或事务消息做最终一致。无论哪种方案,都必须有幂等、重试、补偿、死信、对账和告警。为什么超时不能直接判失败
远程调用超时只说明调用方没有收到响应,不代表下游没有执行。下游可能没收到请求,也可能已经执行业务但响应丢失。如果调用方直接按失败处理或直接无脑重试,就可能造成重复扣款、重复锁库存或状态不一致。正确做法是接口设计幂等键,支持按业务流水查询真实状态,并配合重试、补偿和对账。最终一致怎么保证不是“最终不一致”
最终一致不是不保证一致,而是允许短暂中间状态。成熟方案需要本地事务保证事实源正确,通过本地消息表、事务消息或 CDC 推动下游更新;消费端要幂等;失败要重试;超过阈值进死信或异常表;定时补偿和对账发现长期差异;重要差异要告警或人工处理。只有这些闭环都存在,最终一致才可靠。