依赖韧性讲清楚了超时重试和熔断降级;异步与削峰引入了消息队列的 at-least-once 语义。两者有一个共同前提:操作必须幂等。重试可能导致同一请求被执行多次,消息可能被重复消费,副本同步可能在网络抖动后重放——任何”至少一次”的保障都依赖幂等作为安全网。
幂等之上是数据一致性。单机事务用 ACID 兜底;跨服务、跨库的操作没有全局事务管理器,强一致需要付出极高代价,最终一致往往是更务实的工程选择——但”最终一致”不等于”随便不一致”,定时对账与幂等补偿是让数据最终收敛的工程闭环。
本篇将幂等实现手段、分布式事务模型与最终一致性三个主题串联起来,覆盖从单次接口调用到跨服务长流程事务的完整一致性防线。
TL;DR
- 幂等是”同一操作执行多次与执行一次结果相同”——重试、异步消费、Failover 都依赖幂等作为安全网。
- 实现手段:唯一约束(最简单)、幂等键 + 去重表(带响应缓存)、状态机流转(防止状态回退)、乐观锁版本号——按场景选最轻量的。
- 消息幂等消费:at-least-once 下必须在消费者侧做去重;Kafka 生产端幂等保证 Broker 侧不重复,端到端 EOS 还需消费者幂等。
- 分布式事务没有银弹:2PC 强一致但可用性差;TCC 柔性但代码量大;Saga 异步但需补偿;本地消息表(outbox)实现简单、落地成功率最高——绝大多数场景优先选 Outbox + Saga。
- 最终一致不是最终随便:定时对账 + 幂等补偿 + 数据修复是让系统收敛的工程闭环。
- CAP 视角:在 C 与 A 之间的选择取决于业务对”读到旧数据”和”操作被拒绝”哪个更不能接受,而非技术偏好。
Table of contents
Open Table of contents
1. 为什么幂等是分布式系统的地基
分布式系统中,一次请求可能遭遇三种结果:成功、失败、未知。网络超时意味着请求已发出,但服务端是否处理完毕无法得知。调用方面对”未知”只有两个选择:放弃(损失可用性)或重试(引入重复执行风险)。
一句话:分布式系统里”至少一次”和”最多一次”是两个极端;只有幂等才能让”至少一次”在业务层面等价于”恰好一次”。
以支付扣款为例:用户点击”确认支付”,请求超时后前端重试,服务端已经扣款成功但响应丢失——naive 重试导致重复扣款。解法不是”不重试”(那样可用性差),而是让扣款操作具备幂等性:同一笔支付,无论执行几次,账户余额只减少一次。
幂等不只服务于重试,以下场景都依赖幂等:
| 场景 | 幂等的作用 |
|---|---|
| 超时重试 | 防止重复写入(扣款、创建订单) |
| 消息 at-least-once | 防止消费者重复处理同一条消息 |
| Failover 切换 | 防止切换后新实例重复执行未完成操作 |
| 副本日志重放 | 防止日志回放引起数据多次应用 |
| 定时任务补偿 | 防止对账重试把已修复的数据再次覆盖 |
2. 幂等的定义与常见误区
严格定义:若操作 f 满足 f(f(x)) = f(x),则 f 是幂等的——执行第二次与第一次的结果完全相同,包括副作用。
2.1 天然幂等与天然非幂等
以下操作无需额外设计即是幂等的:
- GET / SELECT 查询:只读,无副作用。
- PUT(覆盖式更新):将资源设置为固定值,多次设置结果相同(如
SET stock = 100)。 - 按主键删除:目标不存在时删除无副作用,第二次与第一次结果等价。
- 幂等数学运算:
max(x, v)、min(x, v)、按位 OR / AND 等。
以下操作天然非幂等:
- POST(追加写入):每次执行创建新记录——创建订单、发起扣款。
- INCREMENT / DECREMENT:
UPDATE account SET balance = balance - 100执行两次扣 200。 - 带副作用的推送:发送短信、触发支付——每次调用都产生新的外部副作用。
2.2 幂等 vs 去重 vs 幂等键
- 去重(Deduplication):识别并丢弃重复请求,是实现幂等的一种手段,但不是唯一手段。
- 幂等键(Idempotency Key):调用方在请求中携带的全局唯一标识符,服务端用它识别重复请求;通常是 UUID 或由业务系统生成的幂等 ID。
- 幂等是结果层面的保证;去重和幂等键是实现机制。
3. 幂等实现手段
3.1 唯一约束(最简单)
在数据库对关键业务字段加唯一索引,重复插入触发数据库层面的约束异常,捕获后返回已有记录即可:
-- 幂等键唯一索引
CREATE UNIQUE INDEX idx_request_id ON payment_orders (request_id);
-- 幂等插入:冲突时取已有记录
INSERT INTO payment_orders (request_id, amount, status)
VALUES (?, ?, 'PENDING')
ON DUPLICATE KEY UPDATE request_id = VALUES(request_id);
适用:单库、插入类操作。局限:只适合单库场景;需要返回上次响应时,还需额外查询已有记录。
3.2 幂等键 + 去重表(含响应缓存)
显式维护一张幂等去重表,记录每个幂等键的处理状态与响应结果:
CREATE TABLE idempotency_keys (
idempotency_key VARCHAR(64) PRIMARY KEY,
status ENUM('PROCESSING', 'DONE') NOT NULL DEFAULT 'PROCESSING',
response_body TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expire_at DATETIME -- 应用层定期清理过期记录
);
处理流程:
public PaymentResult processPayment(String idempotencyKey, PaymentRequest req) {
// 1. 原子占位:INSERT,已存在则捕获异常
try {
idempotencyKeyRepo.insert(idempotencyKey, "PROCESSING");
} catch (DuplicateKeyException e) {
IdempotencyRecord record = idempotencyKeyRepo.findByKey(idempotencyKey);
if ("DONE".equals(record.getStatus())) {
return deserialize(record.getResponseBody()); // 返回缓存响应
}
throw new RequestInProgressException(idempotencyKey); // 处理中,稍后重试
}
// 2. 执行实际业务逻辑
PaymentResult result = doPayment(req);
// 3. 原子更新:写结果 + 标记 DONE(同一事务)
idempotencyKeyRepo.updateDone(idempotencyKey, serialize(result));
return result;
}
幂等键流程:重试命中去重表后直接返回缓存响应,业务服务不会被二次调用。
关键细节:步骤 1(占位)与步骤 3(完成)必须原子——分开执行存在竞态。TTL 应 ≥ 最大重试周期 × 2~3 倍。若使用 Redis 存储,用 SET key value EX ttl NX(原子 set-if-not-exists)替代数据库去重表,兼顾性能与 TTL 管理。
3.3 状态机流转(防止状态回退)
对有生命周期的业务对象(订单、支付单),用状态机约束流转方向,防止同一状态被重复触发:
// 状态机更新:只有当前为 PROCESSING 才更新为 PAID
public void markPaid(Long orderId) {
int updated = orderRepo.compareAndUpdateStatus(orderId, "PROCESSING", "PAID");
if (updated == 0) {
return; // 当前不是 PROCESSING:重复调用安全忽略
}
triggerPostPaymentEvents(orderId);
}
UPDATE orders SET status = 'PAID'
WHERE id = ? AND status = 'PROCESSING';
状态机流转不需要幂等键,天然支持幂等:状态已经是目标状态,重复流转无效果。适合有明确有限状态的业务对象。
3.4 乐观锁版本号
在记录上维护 version 字段,每次更新时校验并递增;并发或重复请求因版本不匹配而失败,只有第一次操作能成功:
UPDATE account
SET balance = balance - 100, version = version + 1
WHERE id = ? AND version = ? AND balance >= 100;
适合低冲突、读多写少场景。高并发写冲突频繁时失败率高,应改用数据库行锁。
3.5 手段对比
| 手段 | 适用场景 | 实现复杂度 | 跨库支持 |
|---|---|---|---|
| 唯一约束 | 单库、插入类操作 | 低 | 否 |
| 幂等键 + 去重表 | 通用接口、需返回缓存结果 | 中 | 是(存 Redis) |
| 状态机流转 | 有生命周期的业务对象 | 低 | 是 |
| 乐观锁版本号 | 低冲突更新 | 中 | 是 |
| 分布式锁 | 高冲突、不可重入场景 | 高 | 是 |
4. 消息幂等消费
4.1 at-least-once 下的去重
Kafka 默认交付语义是 at-least-once:消息一定到达,但可能因 rebalance、Consumer 崩溃或 Offset 提交失败而被重复投递。消费者必须在业务逻辑中实现幂等消费。
常见方案:
- 幂等键去重:以消息的唯一标识(
messageId、业务主键)为幂等键,消费前检查是否已处理。 - 状态机防重:检查目标记录的状态,已处于目标状态则跳过。
- 先处理后提交 Offset:处理成功后再提交 Offset;若提交失败,下次重新消费时幂等逻辑保证安全。
@KafkaListener(topics = "payment-events")
public void consume(ConsumerRecord<String, String> record) {
String messageId = record.key();
if (processedRepo.exists(messageId)) {
log.info("Duplicate message, skip: {}", messageId);
return;
}
// 业务逻辑 + 标记已处理,在同一事务内
try (Transaction tx = dataSource.beginTransaction()) {
doProcess(record.value());
processedRepo.markDone(messageId);
tx.commit();
}
// enable.auto.commit=false,手动提交 Offset
consumer.commitSync();
}
4.2 Kafka 事务边界
Kafka 的生产端幂等(enable.idempotence=true)保证单分区内消息不重复不乱序;Kafka 事务(transactional.id)保证跨分区原子写入。但 Kafka 事务不覆盖外部系统(数据库),端到端恰好一次还需消费者侧幂等。详细的 EOS 实现见 Kafka 可靠性篇。
一句话:Kafka 内部的 EOS 保证消息不丢不重地到达消费者;但消费者是否幂等地处理,是消费者自己的责任,不在 Kafka 承诺范围内。
5. 分布式事务
5.1 为什么不能到处用 2PC
**2PC(两阶段提交)**通过协调者统一 Prepare(锁定资源)和 Commit(提交),实现强一致——但代价是:
- 可用性差:Prepare 阶段所有参与者持有锁,协调者宕机则所有参与者阻塞等待,整条链路不可用。
- 性能低:跨服务的分布式锁持有时间长,高并发下吞吐量极低。
- 协调者 SPOF:生产级 2PC 需要单独处理协调者单点、网络分区与日志持久化问题,运维复杂。
结论:2PC 适合数据库内部(如 MySQL XA 在单实例内的跨存储引擎事务),不适合跨服务分布式场景。
5.2 分布式事务模型对比
| 模型 | 一致性 | 可用性 | 适用场景 | 代码复杂度 |
|---|---|---|---|---|
| 2PC | 强一致 | 低(协调者 SPOF) | 数据库内部 XA | 低(框架处理) |
| 3PC | 强一致(2PC 改进) | 略好但仍低 | 理论研究为主,生产极少用 | 高 |
| TCC | 柔性一致 | 高 | 金融核心链路、库存扣减 | 高(Try/Confirm/Cancel 三套接口) |
| Saga | 最终一致 | 高 | 长流程业务(下单→支付→发货) | 中(补偿逻辑) |
| 本地消息表(Outbox) | 最终一致 | 高 | 通用、跨服务异步协作 | 低(侵入性小) |
| 最大努力通知 | 最终一致(弱) | 高 | 通知类、可容忍丢失的场景 | 低 |
分布式事务没有银弹:一致性越强,实现复杂度越高、可用性越低;绝大多数业务场景应优先选 Saga + Outbox。
5.3 TCC(Try-Confirm-Cancel)
TCC 把一次事务拆成三个阶段:Try(预留资源,不实际扣减)、Confirm(确认并实际扣减,必须幂等)、Cancel(回滚预留,必须幂等)。
// 库存服务 TCC 示例
public boolean tryReserve(String reserveId, int quantity) {
// 预留:available_stock -= quantity, reserved_stock += quantity
return inventoryRepo.tryReserve(reserveId, quantity);
}
public boolean confirm(String reserveId) {
// 确认:reserved_stock -= quantity(幂等:已 CONFIRMED 则跳过)
return inventoryRepo.confirm(reserveId);
}
public boolean cancel(String reserveId) {
// 回滚:available_stock += quantity(空回滚时安全返回 true)
return inventoryRepo.cancel(reserveId);
}
适用:对一致性要求高、可接受较高代码复杂度的金融核心链路。主要难点:Cancel 必须处理 Try 未执行的”空回滚”;Confirm/Cancel 都必须幂等。
5.4 Saga
Saga 把长事务拆成一组本地事务的顺序执行,每步成功后触发下一步,失败后触发补偿操作回滚已完成的步骤:
正向:下单 → 锁定库存 → 扣减余额 → 安排发货
补偿:取消订单 ← 释放库存 ← 退还余额
Saga 有两种编排方式:
- 编排式(Choreography):每个服务完成后发布事件,下一个服务监听事件触发;去中心化,耦合低,适合步骤较少的流程。
- 协调式(Orchestration):中央协调器按顺序调用各服务,失败时反向调用补偿;可观测性好,适合复杂长流程。
关键要求:每一步的补偿操作必须幂等且可重试;Saga 只保证最终一致,中间状态对外短暂可见。
5.5 本地消息表(Outbox Pattern)
Outbox 是落地成功率最高的分布式事务模式:
- 业务操作 + 写消息记录在同一本地事务内——数据库事务保证业务数据与消息记录的原子性。
- **消息中继(Message Relay)**轮询 outbox 表,将消息发布到消息队列(Kafka / RocketMQ)。
- 消费者从队列消费并执行后续操作,幂等处理重复投递。
CREATE TABLE outbox_messages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
topic VARCHAR(128) NOT NULL,
payload TEXT NOT NULL,
status ENUM('PENDING', 'SENT') DEFAULT 'PENDING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@Transactional
public void createOrder(OrderRequest req) {
Order order = orderRepo.save(new Order(req)); // 业务写入
outboxRepo.save(new OutboxMessage( // 同一事务写消息
"order-created",
toJson(OrderCreatedEvent.from(order))
));
// 事务提交:业务数据 + outbox 消息原子落地
}
优点:不依赖分布式协调器,本地事务保证原子性;Message Relay 可用 Debezium CDC 替代轮询;消费者幂等处理即可应对重复投递。Outbox 适用于绝大多数”本服务写完再通知下游”的跨服务协作场景。
Outbox Pattern 核心:业务操作与消息记录在同一本地事务原子提交,彻底消除”业务成功但消息未发”的不一致窗口。
5.6 国内常用落地:Seata 与 RocketMQ 事务消息
上面几种模型在国内工程实践中往往落到两个成熟框架上,可作为 Outbox 的对照来理解:
- Seata AT 模式:对业务近乎零侵入的自动补偿方案。执行 SQL 前后由框架拦截并记录数据前后镜像(undo log),一阶段直接提交本地事务,二阶段若需回滚则用 undo log 反向补偿。本质是框架托管的 Saga——省掉手写补偿逻辑,但依赖全局锁保证写隔离,高并发热点行上锁竞争明显,且回滚基于行镜像,与业务强耦合的复杂逻辑不易自动还原。
- Seata TCC 模式:即 §5.3 的 Try/Confirm/Cancel,由 Seata 负责事务上下文传播与二阶段驱动,一致性和性能都更好,代价是三套接口要自己写、空回滚与悬挂要自己防。
- RocketMQ 事务消息:可以看作消息中间件内建的 Outbox。生产者先发”半消息”(对消费者不可见),本地事务提交成功后再 Commit 让消息可见,失败则 Rollback;若生产者宕机,Broker 会回查本地事务状态来决定投递与否。相比自建 outbox 表 + 中继,它把”业务落库 + 消息可靠投递”的原子性收敛进了 MQ,省去中继进程,但把事务回查逻辑绑定到了 RocketMQ。
一句话:Seata AT 用”自动补偿”换低侵入,RocketMQ 事务消息用”半消息 + 回查”把 Outbox 内建进 MQ——选型时问自己愿意为一致性接受多少侵入性与运维复杂度。
6. 最终一致性与对账补偿
最终一致性不意味着”最终随便”。系统需要主动机制确保数据最终收敛——定时对账发现差异、幂等补偿修复差异、下一轮对账验证收敛,三步构成一个持续运转的闭环。
对账补偿闭环:对账→比对→补偿→收敛验证形成回路,补偿以原始 event_id 作幂等键,未收敛则升级人工——“最终一致”靠这个闭环兜底,不是靠祈祷。
6.1 定时对账
对核心数据(账户余额、库存数量、订单状态)定期做数据对账:从各服务拉取数据,与源头数据比对,找出不一致记录。
对账流程:
1. T 时刻快照:从支付服务、账户服务、订单服务各拉一份数据
2. 比对:支付成功但订单未更新 → 差异记录写入告警队列
3. 幂等补偿:对差异记录发起补偿操作(状态修复、金额调整)
4. 记录对账日志,分析差异根因与收敛速度
6.2 幂等补偿与数据修复
补偿操作本身必须幂等——对账可能因超时多次触发,幂等保证多次补偿不产生额外副作用。设计要点:
- 空补偿安全:若业务操作完全未执行,补偿应安全地无效。
- 补偿幂等键:用原始业务操作的 ID 作为补偿的幂等键。
- 补偿顺序性:有依赖关系的步骤,补偿顺序应与正向流程相反。
| 差异类型 | 修复策略 |
|---|---|
| 支付成功,订单未 PAID | 幂等触发 markPaid,写审计日志 |
| 库存已扣,订单已取消 | 幂等退还库存,补偿操作 |
| 消息已发出,下游未消费 | 消息中继重发,消费者幂等处理 |
| 两侧数据冲突(无法自动判断) | 告警 + 人工介入,记录差异报告 |
7. CAP/BASE 视角下的取舍
CAP 定理:分布式系统在一致性(C)、可用性(A)、分区容忍(P)三者中,网络分区(P)不可避免,系统必须在 C 与 A 之间取舍。
- CP 系统(强一致,牺牲可用性):ZooKeeper、etcd——分区时拒绝写入,确保不读到过期数据。适合配置中心、分布式锁。
- AP 系统(高可用,牺牲强一致):Cassandra、DynamoDB——分区时允许读写,最终一致收敛。适合用户行为数据、商品浏览记录。
BASE(Basically Available, Soft state, Eventually consistent)是 AP 系统的设计哲学:基本可用(允许有限降级)、软状态(允许副本短暂不一致)、最终一致(数据最终收敛)。
一句话:选择 CP 还是 AP,是业务对”读到旧数据”和”操作被拒绝”哪个更不能接受——同一个系统的不同数据可以使用不同的一致性策略。
对账余额不允许有误差 → CP;商品推荐列表延迟几秒更新无所谓 → AP。这是业务决策,不是技术偏好。
8. 反模式
幂等相关:
- naive 重试非幂等写:POST 创建订单失败后直接重试,不携带幂等键,导致重复创建。
- SELECT 判存在再 INSERT 的竞态:并发下两次 SELECT 都返回”不存在”,触发重复插入——应使用唯一约束或原子 INSERT,而非应用层先查后写。
- 幂等键 TTL 过短:重试窗口超过 TTL,幂等键已过期,重试被当作新请求处理,保护失效。
分布式事务相关:
- 到处用 2PC:跨多个服务的链路全用 2PC,任一参与者宕机导致整条链路阻塞,可用性灾难。
- Saga 补偿未幂等:补偿操作被重复触发,导致重复退款、重复归还库存。
- outbox 消息中继单点无 HA:中继进程宕机导致消息堆积不被消费,下游长期不一致。
最终一致性相关:
- 不做对账:异步流程出错无人发现,数据差异持续累积,直到月底财务对账才集中爆发。
- 补偿不记录日志:每次补偿都是”盲操作”,无法追溯差异根因,无法判断系统是否收敛。
小结
- 幂等是分布式系统的安全网:重试、异步、Failover 都依赖幂等;最简实现是唯一约束,通用实现是幂等键 + 去重表,有状态对象用状态机流转。
- 消息幂等消费是 at-least-once 语义的必要配套;Kafka EOS 只保证 Broker 侧不重复,消费者侧幂等是业务层责任。
- 分布式事务没有银弹:强一致的 2PC 可用性差,TCC 代码量大,Saga + Outbox 是绝大多数业务场景的务实选择——简单、可落地、容错好。
- 最终一致不是最终随便:定时对账 + 幂等补偿 + 结构化日志是让系统数据持续收敛的工程闭环。
- CAP/BASE 不是口号,而是每次数据架构决策的实质性约束——在 C 与 A 之间的选择,应由业务需求决定,而非技术偏好。
系列其他篇: