数据一致性设计
数据一致性是分布式系统的核心问题。它不是一句“保持一致”就能解决,而是要先明确:哪些数据必须立刻一致,哪些数据可以短暂不一致,哪些数据失败后可以补偿,哪些数据必须人工确认。
本页侧重业务状态机、补偿、对账和最终一致闭环。要深入理解多副本的一次读写、Quorum、版本时钟和冲突收敛,请继续阅读:
为什么会不一致
单库事务里,订单和订单明细可以一起提交:
mermaid
flowchart TD
A["开启本地事务"] --> B["写订单表"]
B --> C["写订单明细表"]
C --> D["提交事务"]分布式场景里,订单、库存、支付可能在不同服务:
mermaid
flowchart TD
A["订单服务创建订单"] --> B["订单库提交"]
B --> C["调用库存服务扣库存"]
C --> D{"库存服务失败或超时"}
D -- "失败" --> E["订单已创建但库存未扣"]
D -- "成功" --> F["继续支付流程"]只要有跨服务、跨数据库、跨消息系统,就可能出现部分成功、部分失败。
一致性不是只有一种
| 一致性要求 | 含义 | 商业例子 |
|---|---|---|
| 强一致 | 操作完成后所有地方立即一致 | 账户余额、支付记账 |
| 最终一致 | 允许短暂不一致,最终修复 | ES 索引、积分入账、消息通知 |
| 读己之写 | 用户自己写完后自己能立刻看到 | 修改头像、修改昵称 |
| 单调读 | 用户读到新状态后不再读到旧状态 | 订单已支付后不再显示待支付 |
| 因果一致 | 有因果关系的操作按顺序可见 | 先发评论,再显示评论回复 |
一致性设计的第一步是分类,而不是直接上分布式事务框架。
业务分级
| 数据 | 推荐一致性 | 说明 |
|---|---|---|
| 余额、账务流水 | 强一致 | 错了就是资金事故 |
| 库存最终扣减 | 强一致或强约束 | 不能超卖 |
| 订单状态 | 核心状态强约束,外围最终一致 | 支付、关闭、发货要有状态机 |
| 积分、优惠券到账 | 最终一致 | 可以延迟,但不能丢 |
| 短信、站内信 | 尽力而为 | 失败可重试或人工处理 |
| 搜索索引 | 最终一致 | MySQL 是事实来源,ES 可重建 |
| 报表统计 | 最终一致 | 可重跑、可修正 |
一致性工具箱
mermaid
flowchart TD
A["一致性问题"] --> B["本地事务"]
A --> C["唯一索引和状态机"]
A --> D["分布式事务"]
A --> E["可靠消息"]
A --> F["补偿任务"]
A --> G["对账"]
D --> D1["XA、TCC、Saga、Seata"]
E --> E1["本地消息表、事务消息、Outbox"]实际项目里很少只靠一种方案。比如支付成功改订单,可能同时用到:
- 支付流水唯一索引防重复。
- 订单状态机防状态倒退。
- MQ 通知订单服务。
- 消费失败重试。
- 定时对账补偿。
状态机是核心
订单状态流转:
mermaid
flowchart TD
A["WAIT_PAY"] --> B["PAID"]
A --> C["CLOSED"]
B --> D["SHIPPED"]
D --> E["FINISHED"]
B --> F["REFUNDING"]
F --> G["REFUNDED"]状态机要定义:
- 哪些状态可以流向哪些状态。
- 哪些操作允许重复。
- 哪些操作必须拒绝。
- 哪些状态是终态。
支付回调更新订单:
sql
update t_order
set status = 'PAID',
paid_at = now()
where order_no = ?
and status = 'WAIT_PAY';如果回调重复,第二次更新行数为 0,不会造成重复支付。
补偿机制
补偿不是简单“失败了再调用一次”。补偿要回答:
- 补偿哪条业务数据。
- 为什么失败。
- 重试几次。
- 下次什么时候重试。
- 最终失败谁处理。
补偿表设计:
sql
create table t_compensation_task (
id bigint primary key,
biz_type varchar(64) not null,
biz_key varchar(128) not null,
payload text not null,
status varchar(32) not null,
retry_count int not null default 0,
next_retry_time datetime not null,
last_error varchar(1024),
created_at datetime not null,
updated_at datetime not null,
unique key uk_biz_type_key (biz_type, biz_key)
);补偿任务执行逻辑:
java
public void executeCompensationTask(CompensationTask task) {
try {
if ("ORDER_PAID_NOTIFY".equals(task.bizType())) {
orderService.handlePaymentSuccess(task.bizKey());
}
task.markSuccess();
} catch (Exception ex) {
task.markRetry(ex.getMessage());
}
}对账机制
补偿解决已知失败,对账发现未知失败。
支付对账示例:
mermaid
flowchart TD
A["下载支付渠道账单"] --> B["读取本地支付流水"]
B --> C["按交易号匹配"]
C --> D{"金额和状态一致?"}
D -- "是" --> E["标记对账成功"]
D -- "否" --> F["写入差异表"]
F --> G["人工确认或自动修复"]差异表:
sql
create table t_reconcile_diff (
id bigint primary key,
channel varchar(32) not null,
trade_no varchar(128) not null,
local_status varchar(32),
channel_status varchar(32),
local_amount decimal(18, 2),
channel_amount decimal(18, 2),
diff_type varchar(64) not null,
status varchar(32) not null,
created_at datetime not null
);资金相关系统必须有对账。不要以为接口返回成功就永远正确。
最终一致标准流程
mermaid
flowchart TD
A["本地事务写业务数据"] --> B["写消息表或发送事务消息"]
B --> C["提交本地事务"]
C --> D["消息投递到 MQ"]
D --> E["消费者幂等处理"]
E --> F{"处理成功?"}
F -- "成功" --> G["标记完成"]
F -- "失败" --> H["重试、死信、补偿"]
H --> I["告警和人工处理"]这个流程的关键点:
- 业务数据和消息不能出现“业务成功但消息丢失”。
- 消费者必须幂等。
- 失败必须可见。
- 不能无限重试,要有最终处理出口。
常见一致性问题
| 问题 | 原因 | 解决方向 |
|---|---|---|
| 订单支付了还显示待支付 | 支付回调丢失或 MQ 消费失败 | 支付对账、回调重试、消息补偿 |
| 商品改价后搜索页还是旧价 | ES 同步失败 | MQ 重试、同步记录、定时补偿 |
| 重复发券 | 消息重复消费 | 唯一索引、消费日志、幂等表 |
| 库存超卖 | 并发扣减无约束 | 条件更新、库存冻结、TCC |
| 缓存旧值覆盖新值 | 并发更新缓存 | 删除缓存、延迟删除、版本号 |
代码 Demo:消费幂等
java
@Transactional
public void handleOrderPaid(OrderPaidEvent event) {
if (consumeLogRepository.exists(event.messageId())) {
return;
}
int updated = orderRepository.markPaid(event.orderNo(), event.paidAt());
if (updated > 0) {
pointsService.addPayPoints(event.userId(), event.orderNo());
}
consumeLogRepository.save(new ConsumeLog(event.messageId(), "ORDER_PAID"));
}这里有两层幂等:
consumeLog防止同一消息重复消费。markPaid用状态条件防止订单重复从待支付变成已支付。
本章小结
数据一致性设计的核心不是“用不用分布式事务”,而是先判断业务风险,再选择一致性级别和工具。
强一致适合资金、库存最终确认等核心链路;最终一致适合通知、索引、报表、积分等可补偿场景。不管选择哪种方案,都必须配套幂等、重试、补偿、对账和告警。
