qinghuan
← 消息队列 / 全部笔记

单元 110 / 消息队列

P0 · 必会主线L3 · 深入

消息消费幂等与重放:把重复投递变成可判断的业务状态

用固定事件编号认出重复操作,把去重记录与业务修改一起提交,再为重放、乱序和冲突规定处理方式。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能设计数据库效果与去重记录原子提交的消费者,并安全处理重复和历史重放。

学习理由、准备要求与知识点

为什么学它

消息系统通常允许重复投递;业务层去重与可重放设计决定故障恢复是否安全。

先读这些内容

知识点清单

  • 事件唯一 ID
  • 消费去重表
  • 同一数据库事务
  • 提交后确认
  • 业务语义幂等
  • 有界重试
  • 重放游标
  • 版本与乱序处理

运行前先确认通用消息消费模型,事务源码参考 Spring Framework 6.2.8,数据库约束参考 MySQL 8.4/InnoDB。Java 17 算法是单进程账本模型,生产需用持久事务与唯一约束替代内存状态。

01先弄懂它是什么

同一订单消息可能收到多次,幂等要保证重复处理不会重复产生业务效果,而不是保证永远收不到重复。写成状态变化,就是 f(f(S,e),e)=f(S,e),其中 e 必须始终表示同一次操作。余额加十本身不幂等;按版本设置目标状态较容易避免重复,但也要防旧版本盖过新版本。先说明怎样辨认同一次请求,别把两次合法但内容相同的操作误当重复。

发送超时后,发送方不一定知道对方是否已做完。数据库提交了但 ack 丢了,消息就可能再次投递;历史重放也会带回旧事件。eventId 应在事实首次产生时确定,重试继续用它;同一 ID 却带不同内容,应当报告冲突。业务事件、每次发送尝试和消费者收到的投递,需要分别记录,才能看清一件事被尝试了多少次。

去重前还要回答:这是哪个租户、哪种操作的哪一件事?例如积分发放和订单审计都收到 event-1,不应因为事件编号相同就互相挡住。可以把租户 tenant、处理操作 handler、事件编号 eventId 合起来作为唯一键。

再把请求内容按约定整理后计算摘要,与最终结果、处理版本一起保存,用来识别“编号相同但内容不同”。这些信息必须来自或经过可信业务上下文验证,不能让用户冒用别人的编号取走旧结果,或挡住别人的合法操作。

文中这几个词,先认一认

业务幂等键
多次重试仍能认出同一业务操作的固定标识,还要说明属于哪个租户和处理职责。
Inbox
消费者持久保存已经处理的事件和结果,常与业务修改放进同一数据库事务。
Outbox
生产者把待发送事件与业务数据一起提交,之后再由任务把事件发出去。
重放窗口
旧事件仍可能再次出现的时间范围,用来决定去重记录至少应保留多久。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

最危险的实现是先 SELECT 判断不存在,再执行业务,最后插入去重记录。两个消费者可以同时读到不存在,随后各自执行一次副作用。数据库唯一约束才是并发竞争的裁决点。对于同一数据库中的修改,可在一个事务里登记 Inbox、修改业务并保存结果;任何一步失败共同回滚,其他事务才能在重试时重新取得执行资格。

遇到唯一键冲突,先查清是哪种情况:相同操作已经完成、操作还在进行、有人用同一个键提交了不同内容,还是其他字段违反了约束。这些情况不能一律报成功,也不能把所有 DataIntegrityViolationException 都当作重复消息忽略。

还要检查事务状态:rollback-only 表示这笔事务已被标记为只能回滚,捕获异常也不会把标记自动撤销。若原事务已经回滚或带有这个标记,应在合适的新事务中读取已提交结果,不能继续假定原事务可以提交成功。

重放是一种受控数据处理任务。先按事件类型、时间段和业务范围确定集合,修复消费者或数据根因,再以原 eventId 重放,记录尝试批次及结果。若需求是重新执行一个合法的新业务动作,应建立新的操作身份并说明因果,不能为了绕过去重随手换 ID。保留期、消费者版本和历史模式兼容共同决定旧事件是否还能被正确理解。

正在加载流程图…

原子事务覆盖去重登记和本地业务作用;唯一冲突仍需核对范围、负载与完成状态。
查看流程图源码(Mermaid)
flowchart TD
 R["收到 eventId 与业务负载"] --> V["验证身份、模式与摘要"]
 V --> T["开启本地事务"]
 T --> I["尝试登记唯一 Inbox 键"]
 I --> N["首次事件"]
 I --> D["唯一冲突:核对已提交记录"]
 N --> B["执行业务条件修改并保存结果"]
 B --> C["共同提交"]
 D --> M["同操作同内容且已完成"]
 D --> X["内容冲突或状态未知"]
 M --> A["返回已有结果并确认投递"]
 C --> A
 X --> E["拒绝、等待或进入人工调查"]

按顺序看,每一步做了什么

  1. 验证事件的身份范围、模式版本及规范化负载。
  2. 在持久事务中竞争唯一幂等键。
  3. 首次处理将业务作用和结果一起提交。
  4. 重复处理核对已提交结果;冲突进入明确错误路径。
  5. 提交后确认投递,重放沿用同一业务身份并限速审计。

03对照实现代码

TransactionTemplate 如何围住本地业务回调 (新标签页)↗

Spring Framework v6.2.8 · TransactionTemplate#execute

以下原创模型只表达开始、业务回调、回滚或提交的结构;不是 Spring 事务管理器实现。真实源码还有传播行为、事务管理器适配、回滚异常及资源绑定处理。

核心机制代码 · java
import java.util.function.Supplier;
final class LocalTransactionModel {
    interface Tx {
        void begin();
        void commit();
        void rollback();
    }
    static <T> T execute(Tx tx, Supplier<T> body) {
        tx.begin(); // 真实环境获取的是事务状态及绑定到执行上下文的资源。
        T result;
        try {
            result = body.get(); // Inbox 与业务修改必须使用同一事务资源。
        } catch (RuntimeException | Error failure) {
            try { tx.rollback(); }
            catch (RuntimeException rollbackFailure) {
                failure.addSuppressed(rollbackFailure); // 模型保留原始失败及回滚失败。
            }
            throw failure;
        }
        tx.commit(); // 提交失败也必须传播,不能把回调返回视为已持久成功。
        return result;
    }
}

execute 会通过配置的事务管理器取得事务状态,运行回调里的应用代码,然后提交或处理失败。哪些操作能一起提交、一起回滚,取决于这个管理器实际管理的资源。把外部 HTTP 调用、另一个数据库的修改和当前 JDBC 操作都写进回调,不代表它们就自动组成一个事务。先列出真正参与同一事务的操作,才能判断重复消费会不会重复修改业务。

把 Inbox 插入与业务修改放在同一个回调还不够,二者必须实际使用同一事务关联的连接或资源。手工另开连接、错误地切换数据源、把关键修改交给异步线程,都可能绕过当前事务绑定。形式上两行代码挨在一起,不代表持久提交一定共同发生,集成测试要直接核对最终数据。

回调正常返回后仍有提交阶段,磁盘、连接或事务状态可能让提交失败。消费监听器应在真正的事务操作成功结束后 ack,而不是业务回调刚返回就确认。提交结果未知时也不能简单重做非幂等副作用,应利用业务键查询并恢复。教学模型把提交放在 try 回调之后,突出这个常被忽略的阶段。

真实事务模板对不同错误与回滚异常有更细的处理;示例只保留主要分支,不能据此替代框架的异常规则。调用者尤其要避免在回调里 catch 所有异常后返回“成功”,这样事务管理器可能无法知道应回滚。若业务决定只读已有结果,必须先证明冲突记录已经完成,而不是用吞异常实现表面上的幂等。

代码先调用谁,再调用谁

  1. 消息监听器解析并验证业务身份
  2. TransactionTemplate.execute 获取事务状态
  3. 回调使用同一资源登记 Inbox 并修改业务
  4. 事务管理器提交,或根据失败执行回滚
  5. 事务完成后再向 Broker 确认本次投递

04动手写一个例子

带结果重用与冲突检测的幂等账本

维护一个非负 long 余额。apply(id,delta) 首次调用应用增减并保存本次结果;相同 ID 与相同 delta 返回原结果,不再次修改余额;同 ID 不同 delta、余额不足或溢出均拒绝。每个失败操作不留下已完成记录。

输入 1
初始0;apply(a,+10);apply(b,-4);再次apply(a,+10)
输出 1
结果依次10、6、10;当前余额仍为6
输入 2
再次apply(a,+9)
输出 2
冲突异常,余额保持6

用 Map 把幂等键映射为不可变回执,回执保存 delta 与当时成功后的余额。先查回执:存在且 delta 相等就返回原结果,存在但不相等则报告冲突。重复操作的返回值可能与当前余额不同,例如后来已有另一笔扣减,仍应返回当初那次操作的结果,才能让重试获得稳定响应。

不存在回执时计算 nextBalance,但在改变任何状态前完成溢出和非负校验。Math.addExact 会在 long 溢出时抛出异常,避免一个很大的正数绕成负数后被错误解释。校验失败时余额和回执都保持原样;校验通过才创建回执并更新状态,构造一个清晰的状态转移点。

同步锁覆盖检查、计算、登记和余额更新,使多个线程不能基于同一旧状态同时推进。本模型只证明单进程中正常内存执行条件下的规则,不能抵抗进程断电或多个实例各有一份 Map。生产必须把相同不变量映射到数据库事务与唯一约束,而不是把 synchronized 当成跨机器互斥。

为什么这样写是对的每个已完成 ID 只对应一个确定 delta 与原始回执;余额只等于各首次成功操作的累计结果,失败和重复调用均不增加新的业务作用。

时间 通常散列假设下每次 apply 均摊 O(1)额外空间 O(u),u 为成功唯一操作数

单次只访问一个 Map 键并进行固定次数整数运算;字符串键散列和比较仍依赖键长度。保存原回执使状态随唯一事件数增长,不能宣称去重是恒定空间。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 46 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;

public class Main {
    record Receipt(long delta, long balanceAfter) {}
    static final class Ledger {
        private long balance;
        private final Map<String, Receipt> completed = new HashMap<>();
        synchronized long apply(String id, long delta) {
            if (id == null || id.isBlank()) throw new IllegalArgumentException("id");
            Receipt old = completed.get(id);
            if (old != null) {
                if (old.delta != delta) throw new IllegalArgumentException("payload conflict");
                return old.balanceAfter; // 返回原操作回执,不是当前余额。
            }
            long next = Math.addExact(balance, delta); // 溢出时尚未修改任何状态。
            if (next < 0) throw new IllegalStateException("insufficient balance");
            completed.put(id, new Receipt(delta, next));
            balance = next; // 同一锁中更新,外部看不到中间状态。
            return next;
        }
        synchronized long balance() { return balance; }
        synchronized boolean has(String id) { return completed.containsKey(id); }
    }
    static void eq(long expected, long actual) {
        if (expected != actual) throw new AssertionError(actual);
    }
    static void rejected(Runnable action) {
        boolean failed = false;
        try { action.run(); } catch (RuntimeException e) { failed = true; }
        if (!failed) throw new AssertionError("expected rejection");
    }
    public static void main(String[] args) {
        Ledger ledger = new Ledger();
        eq(10, ledger.apply("a", 10));
        eq(6, ledger.apply("b", -4));
        eq(10, ledger.apply("a", 10)); // 重复操作返回第一次的10。
        eq(6, ledger.balance());       // 但当前余额仍为6。
        rejected(() -> ledger.apply("a", 9));
        rejected(() -> ledger.apply("c", -7));
        rejected(() -> ledger.apply("d", Long.MAX_VALUE));
        rejected(() -> ledger.apply("", 1));
        eq(6, ledger.balance());
        if (ledger.has("c") || ledger.has("d")) throw new AssertionError("failed receipt");
        System.out.println("PASS 10");
    }
}

这些特殊情况也要试一试

  • 相同 ID 不同内容必须冲突,不能静默重用。
  • 返回原始回执与返回当前状态不是同一个接口语义。
  • 负余额与 long 溢出须在状态变更前检查。
  • 失败不写成功回执,允许修复条件后再尝试。
  • 内存记录丢失、多个进程与保留期清理不在模型保证中。
进阶选读 · 外部副作用的租约、fencing 与未知结果恢复

场景消费者拿到任务后调用支付接口,调用结果返回前进程失联,另一实例准备接管。

做法用持久状态机记录 NEW、PROCESSING、SUCCEEDED、FAILED 或 UNKNOWN,租约决定何时允许尝试接管,递增 fencing token 拒绝旧持有者的过期写。支付方仍需接受稳定幂等键或提供结果查询;仅有本地租约无法阻止网络中已经发出的旧支付请求。接管优先查询已发生的业务事实,再决定是否重试。

复杂度单条状态转移由索引键定位,数据库查找通常为 O(log N) 索引路径加锁等待;恢复扫描需按状态和租约截止时间建立索引并分批处理。

好处与代价租约降低永久占位风险,fencing 隔离旧执行者写入,但需要副作用接收方配合。若对方既无幂等也不可查询,无法仅靠发送端算法保证任意故障下恰好一次外部作用。

05项目里怎么用

去重记录保留期必须覆盖消息保留、死信驻留、备份恢复和人工重放的最大合理窗口。若消息能重放三个月而去重键一天后删除,旧事件第二天就可能再次生效。可以按业务生命周期归档完成记录,保留必要摘要与结果,避免无限增长;清理规则应写成可审查的业务策略,而不是任意 TTL。布隆过滤器可减少部分查询,但因存在假阳性,不能单独作为“不执行业务”的唯一裁决。

Outbox 和 Inbox 分别照顾消息的发送端和接收端。发送端在同一事务里保存业务数据和待发消息,投递任务发送后再记录结果;如果发送成功却没记下结果,后续重试就可能再次发送,所以接收端仍要防重复。接收端把业务修改和“已经处理过”的 Inbox 记录一起提交,避免重复生效。它们解决的是各自数据库里两类记录如何一起保存,网络调用结果暂时无法确定的问题仍然存在。

重放应先做范围统计和样本验证,限制并发及下游负载,并记录原事件、重放批次、消费者版本与处理结果。只比较“队列读了多少条”不足以核对业务,应该检查唯一事实数、重复数、内容冲突数与最终状态差异。失败数据修复后也要保留原始事件证据,避免覆盖历史让问题无法解释。

容易踩的坑

  • 先查询不存在再修改业务,缺少原子唯一约束。
  • 去重记录和业务修改使用不同事务或数据源。
  • 将所有数据库约束异常当作重复成功。
  • 重放时生成新 eventId 绕开保护,却没有定义新业务动作。
  • 去重 TTL 短于实际重放窗口。
  • 把布隆过滤器的“可能存在”当成确定已处理。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
数据库唯一键与本地事务并发裁决与业务作用可同库提交。额外索引、写入与保留管理。主要副作用位于一个事务数据库。
业务条件更新直接把状态机不变量落到更新条件。要设计版本、冲突和返回语义。订单状态转移、余额版本等明确规则。
Redis 临时去重延迟低,可分担部分访问。过期、持久性和与数据库原子边界更复杂。可接受一定窗口且有持久业务约束兜底。
接收方幂等 API最接近外部副作用的实际裁决点。依赖对方键范围、保留期及查询契约。支付、发券等跨系统动作。

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01幂等是否要求重复调用返回当前余额?

展开参考答案与推导

不要求。接口可定义返回原操作回执,这通常更适合重试;当前余额应通过独立查询获取。

02为什么唯一键仍需要事务?

展开参考答案与推导

唯一键裁决同键竞争,但去重记录与业务修改若分开提交,可能留下已登记却未作用或已作用却未登记的窗口。

03布隆过滤器为什么不能直接丢弃所有判断存在的消息?

展开参考答案与推导

存在假阳性,新的合法事件也可能被认为出现过;需要可靠存储进一步确认,不能用概率结果造成确定业务遗漏。

换个情况,还会处理吗

01处理已成功但 ack 丢失,重复消息如何返回?

展开参考答案与推导

按幂等键读取已完成记录并核对负载摘要,返回原结果后确认本次投递,不再次修改业务。

02同 ID 负载变化,是否选择较新的内容覆盖?

展开参考答案与推导

不应默默覆盖。相同业务身份内容冲突说明事件生成或调用契约有问题,应拒绝并调查;合法更正应有新的明确操作身份与因果。

03去重表增长过快,能否统一保留24小时?

展开参考答案与推导

先统计保留和重放窗口以及业务生命周期,再制定归档清理;任意缩短 TTL 会让历史重放再次生效。

写代码试一试

01验证失败扣减可以在余额补足后重试。

输入 / 条件
余额0,apply(x,-5)失败,apply(top,+10),再apply(x,-5)
输出 / 预期
最终余额5,x仅成功一次

提示 失败不得留下完成回执。

展开参考答案与推导

先断言 x 不在 completed,再充值并重试,最后重复 x 仍返回原回执且不再扣款。

02把幂等键扩成租户与处理器范围。

输入 / 条件
tenantA/points/e1 与 tenantB/points/e1
输出 / 预期
两条独立操作

提示 使用不可变复合键 record。

展开参考答案与推导

Map 键改为包含 tenant、handler、eventId 的 record,校验每字段并添加同事件跨租户与跨处理器测试。

03为重放结果做四类统计。

输入 / 条件
成功首处理2次、相同重复3次、内容冲突1次、业务失败1次
输出 / 预期
new=2,duplicate=3,conflict=1,failed=1

提示 分类来自业务裁决,不只来自 ack 数量。

展开参考答案与推导

apply 返回显式结果类型或异常分类,由调用方按本次尝试分类统计;结果保留业务 ID 以便最终对账。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能设计数据库效果与去重记录原子提交的消费者,并安全处理重复和历史重放。

本篇重点加练

业务写入与去重事务的共同边界、事件版本的时间语义决定重放是否仍然安全。

  • 同一数据库事务
  • 版本与乱序处理

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    同一订单事件被通知、积分两个处理器消费,且租户可能使用相同事件编号。定义去重记录的身份范围与字段契约。

    提交内容
    复合身份设计
    怎样算完成
    • 区分事件与处理器执行
    • 覆盖相同 ID 不同负载

    练到的知识点事件唯一 ID · 消费去重表

  2. 作业 02代码推演

    消费流程包含登记去重、修改余额、提交和确认四步。对每个相邻步骤间的退出设计观察记录,说明重启后需要核对什么。

    提交内容
    故障时间线与核对表
    怎样算完成
    • 事务范围明确
    • 覆盖提交结果未知

    练到的知识点同一数据库事务 · 提交后确认

  3. 作业 03代码推演

    去重记录写在数据库 A,业务数据写在数据库 B,两者都使用本地事务。为各提交结果组合建立状态表,不假定方法上有事务注解就共享提交。

    提交内容
    跨库状态矩阵
    怎样算完成
    • 区分同连接和同方法
    • 包含提交结果未知

    练到的知识点同一数据库事务

  4. 作业 04原理问答

    用户资料事件包含版本号但允许多个来源独立生成编号。评审版本可比较的范围与相同版本不同内容的处理需求。

    提交内容
    版本域与冲突契约
    怎样算完成
    • 明确版本生成者
    • 不比较无共同顺序的编号

    练到的知识点版本与乱序处理

综合应用

  1. 作业 05面试场景

    同一“设置用户等级”事件重复到达,与同一“增加积分”事件重复到达。比较业务语义,并为暂时失败和规则拒绝分别设计重试资格。

    提交内容
    操作语义与失败分类
    怎样算完成
    • 不能只根据 HTTP 方法判断幂等
    • 尝试预算有上限

    练到的知识点业务语义幂等 · 有界重试

  2. 作业 06设计评审

    离线重放从一周前位置开始,但当前投影已经到版本 20。设计游标、业务版本和检查点的关系,评估乱序旧事件。

    提交内容
    重放状态图
    怎样算完成
    • 区分源进度和业务版本
    • 包含断点恢复及重复批次

    练到的知识点重放游标 · 版本与乱序处理

  3. 作业 07编程实践

    用本地数据库或明确标注的持久模拟器实现积分事件消费者,输入重复、乱序、坏格式和中途退出事件,输出逐事件处理账本。

    提交内容
    程序、事件文件与重放报告
    怎样算完成
    • 检查副作用与去重记录共同状态
    • 说明模拟器不覆盖的数据库并发行为

    练到的知识点事件唯一 ID · 消费去重表 · 同一数据库事务 · 提交后确认 · 业务语义幂等 · 有界重试 · 重放游标 · 版本与乱序处理

  4. 作业 08设计评审

    消费投影已升级新规则,旧去重表仍表示旧规则处理成功。制定“事件处理完成”需要关联哪些处理版本和事务事实。

    提交内容
    处理身份与迁移评审
    怎样算完成
    • 区分重建投影和再次执行业务
    • 包含旧新处理器并行

    练到的知识点同一数据库事务 · 版本与乱序处理

  5. 作业 09编程实践

    在本地数据库建立双版本投影演示,对同一事件序列运行两套规则并中途重启,核对每套投影自己的完成与冲突记录。

    提交内容
    代码、迁移脚本及对账结果
    怎样算完成
    • 资源与业务事实仅限虚构数据
    • 事务范围以实际连接证据说明

    练到的知识点同一数据库事务 · 版本与乱序处理

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    历史事件格式升级,运营要求重建一个全新统计投影且不能污染原消费者进度。设计重放任务身份、版本转换和验收口径。

    提交内容
    重建方案与对账清单
    怎样算完成
    • 区分重新计算与再次执行业务动作
    • 旧格式缺字段有处理规则

    练到的知识点事件唯一 ID · 消费去重表 · 业务语义幂等 · 重放游标 · 版本与乱序处理

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 反例构造

    构造同一事件从未重复投递,但乱序仍导致最终状态错误的历史,用来审查“做了去重就能安全消费”的命题。

    提交内容
    反例与缺失条件
    怎样算完成
    • 输入事件身份各不相同
    • 说明事件是增量还是完整状态

    练到的知识点同一数据库事务 · 版本与乱序处理

  2. 作业 12思维拓展 · 跨域迁移

    把消息重放迁移到模型实验数据流水线,特征提取版本升级后需要重算。重新定义事件已处理、输出版本与原始样本身份。

    提交内容
    跨域处理协议
    怎样算完成
    • 区分业务副作用与可重建结果
    • 包含同样本不同处理版本

    练到的知识点同一数据库事务 · 版本与乱序处理

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗