Skip to content
Charles Shao
Go back

幂等与一致性:重试安全、分布式事务与最终一致

views

依赖韧性讲清楚了超时重试和熔断降级;异步与削峰引入了消息队列的 at-least-once 语义。两者有一个共同前提:操作必须幂等。重试可能导致同一请求被执行多次,消息可能被重复消费,副本同步可能在网络抖动后重放——任何”至少一次”的保障都依赖幂等作为安全网。

幂等之上是数据一致性。单机事务用 ACID 兜底;跨服务、跨库的操作没有全局事务管理器,强一致需要付出极高代价,最终一致往往是更务实的工程选择——但”最终一致”不等于”随便不一致”,定时对账与幂等补偿是让数据最终收敛的工程闭环。

本篇将幂等实现手段、分布式事务模型与最终一致性三个主题串联起来,覆盖从单次接口调用到跨服务长流程事务的完整一致性防线。

TL;DR

Table of contents

Open Table of contents

1. 为什么幂等是分布式系统的地基

分布式系统中,一次请求可能遭遇三种结果:成功、失败、未知。网络超时意味着请求已发出,但服务端是否处理完毕无法得知。调用方面对”未知”只有两个选择:放弃(损失可用性)或重试(引入重复执行风险)。

一句话:分布式系统里”至少一次”和”最多一次”是两个极端;只有幂等才能让”至少一次”在业务层面等价于”恰好一次”。

以支付扣款为例:用户点击”确认支付”,请求超时后前端重试,服务端已经扣款成功但响应丢失——naive 重试导致重复扣款。解法不是”不重试”(那样可用性差),而是让扣款操作具备幂等性:同一笔支付,无论执行几次,账户余额只减少一次。

幂等不只服务于重试,以下场景都依赖幂等:

场景幂等的作用
超时重试防止重复写入(扣款、创建订单)
消息 at-least-once防止消费者重复处理同一条消息
Failover 切换防止切换后新实例重复执行未完成操作
副本日志重放防止日志回放引起数据多次应用
定时任务补偿防止对账重试把已修复的数据再次覆盖

2. 幂等的定义与常见误区

严格定义:若操作 f 满足 f(f(x)) = f(x),则 f 是幂等的——执行第二次与第一次的结果完全相同,包括副作用

2.1 天然幂等与天然非幂等

以下操作无需额外设计即是幂等的:

以下操作天然非幂等

2.2 幂等 vs 去重 vs 幂等键


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 提交失败而被重复投递。消费者必须在业务逻辑中实现幂等消费。

常见方案:

  1. 幂等键去重:以消息的唯一标识(messageId、业务主键)为幂等键,消费前检查是否已处理。
  2. 状态机防重:检查目标记录的状态,已处于目标状态则跳过。
  3. 先处理后提交 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(提交),实现强一致——但代价是:

结论:2PC 适合数据库内部(如 MySQL XA 在单实例内的跨存储引擎事务),不适合跨服务分布式场景。

5.2 分布式事务模型对比

模型一致性可用性适用场景代码复杂度
2PC强一致低(协调者 SPOF)数据库内部 XA低(框架处理)
3PC强一致(2PC 改进)略好但仍低理论研究为主,生产极少用
TCC柔性一致金融核心链路、库存扣减高(Try/Confirm/Cancel 三套接口)
Saga最终一致长流程业务(下单→支付→发货)中(补偿逻辑)
本地消息表(Outbox)最终一致通用、跨服务异步协作低(侵入性小)
最大努力通知最终一致(弱)通知类、可容忍丢失的场景

分布式事务四方案:从 Outbox(低复杂度/最终一致)到 2PC(高复杂度/强一致)的取舍示意 分布式事务没有银弹:一致性越强,实现复杂度越高、可用性越低;绝大多数业务场景应优先选 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 有两种编排方式:

关键要求:每一步的补偿操作必须幂等且可重试;Saga 只保证最终一致,中间状态对外短暂可见。

5.5 本地消息表(Outbox Pattern)

Outbox 是落地成功率最高的分布式事务模式:

  1. 业务操作 + 写消息记录在同一本地事务内——数据库事务保证业务数据与消息记录的原子性。
  2. **消息中继(Message Relay)**轮询 outbox 表,将消息发布到消息队列(Kafka / RocketMQ)。
  3. 消费者从队列消费并执行后续操作,幂等处理重复投递。
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):业务表与 Outbox 表同一事务写入,中继异步投递,消费者幂等处理 Outbox Pattern 核心:业务操作与消息记录在同一本地事务原子提交,彻底消除”业务成功但消息未发”的不一致窗口。

5.6 国内常用落地:Seata 与 RocketMQ 事务消息

上面几种模型在国内工程实践中往往落到两个成熟框架上,可作为 Outbox 的对照来理解:

一句话:Seata AT 用”自动补偿”换低侵入,RocketMQ 事务消息用”半消息 + 回查”把 Outbox 内建进 MQ——选型时问自己愿意为一致性接受多少侵入性与运维复杂度。


6. 最终一致性与对账补偿

最终一致性不意味着”最终随便”。系统需要主动机制确保数据最终收敛——定时对账发现差异、幂等补偿修复差异、下一轮对账验证收敛,三步构成一个持续运转的闭环。

对账补偿闭环:定时对账拉取多源数据逐笔比对,差异记录进补偿队列由幂等补偿修复,无法自动判断的冲突升级人工,补偿后下一轮对账验证差异是否归零,形成闭环 对账补偿闭环:对账→比对→补偿→收敛验证形成回路,补偿以原始 event_id 作幂等键,未收敛则升级人工——“最终一致”靠这个闭环兜底,不是靠祈祷。

6.1 定时对账

对核心数据(账户余额、库存数量、订单状态)定期做数据对账:从各服务拉取数据,与源头数据比对,找出不一致记录。

对账流程:
1. T 时刻快照:从支付服务、账户服务、订单服务各拉一份数据
2. 比对:支付成功但订单未更新 → 差异记录写入告警队列
3. 幂等补偿:对差异记录发起补偿操作(状态修复、金额调整)
4. 记录对账日志,分析差异根因与收敛速度

6.2 幂等补偿与数据修复

补偿操作本身必须幂等——对账可能因超时多次触发,幂等保证多次补偿不产生额外副作用。设计要点:

差异类型修复策略
支付成功,订单未 PAID幂等触发 markPaid,写审计日志
库存已扣,订单已取消幂等退还库存,补偿操作
消息已发出,下游未消费消息中继重发,消费者幂等处理
两侧数据冲突(无法自动判断)告警 + 人工介入,记录差异报告

7. CAP/BASE 视角下的取舍

CAP 定理:分布式系统在一致性(C)、可用性(A)、分区容忍(P)三者中,网络分区(P)不可避免,系统必须在 C 与 A 之间取舍。

BASE(Basically Available, Soft state, Eventually consistent)是 AP 系统的设计哲学:基本可用(允许有限降级)、软状态(允许副本短暂不一致)、最终一致(数据最终收敛)。

一句话:选择 CP 还是 AP,是业务对”读到旧数据”和”操作被拒绝”哪个更不能接受——同一个系统的不同数据可以使用不同的一致性策略。

对账余额不允许有误差 → CP;商品推荐列表延迟几秒更新无所谓 → AP。这是业务决策,不是技术偏好。


8. 反模式

幂等相关

分布式事务相关

最终一致性相关


小结

系列其他篇


views
Share this post on:

Previous Post
发布与变更安全:灰度、蓝绿、金丝雀与回滚
Next Post
依赖韧性:超时重试、熔断降级与舱壁隔离