读完这篇,你能做什么
能设计数据库效果与去重记录原子提交的消费者,并安全处理重复和历史重放。
学习理由、准备要求与知识点
为什么学它
消息系统通常允许重复投递;业务层去重与可重放设计决定故障恢复是否安全。
先读这些内容
知识点清单
- 事件唯一 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["拒绝、等待或进入人工调查"]按顺序看,每一步做了什么
- 验证事件的身份范围、模式版本及规范化负载。
- 在持久事务中竞争唯一幂等键。
- 首次处理将业务作用和结果一起提交。
- 重复处理核对已提交结果;冲突进入明确错误路径。
- 提交后确认投递,重放沿用同一业务身份并限速审计。
03对照实现代码
Spring Framework v6.2.8 · TransactionTemplate#execute
以下原创模型只表达开始、业务回调、回滚或提交的结构;不是 Spring 事务管理器实现。真实源码还有传播行为、事务管理器适配、回滚异常及资源绑定处理。
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 所有异常后返回“成功”,这样事务管理器可能无法知道应回滚。若业务决定只读已有结果,必须先证明冲突记录已经完成,而不是用吞异常实现表面上的幂等。
代码先调用谁,再调用谁
- 消息监听器解析并验证业务身份
TransactionTemplate.execute获取事务状态- 回调使用同一资源登记 Inbox 并修改业务
- 事务管理器提交,或根据失败执行回滚
- 事务完成后再向 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 与原始回执;余额只等于各首次成功操作的累计结果,失败和重复调用均不增加新的业务作用。
单次只访问一个 Map 键并进行固定次数整数运算;字符串键散列和比较仍依赖键长度。保存原回执使状态随唯一事件数增长,不能宣称去重是恒定空间。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
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 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能设计数据库效果与去重记录原子提交的消费者,并安全处理重复和历史重放。
本篇重点加练
业务写入与去重事务的共同边界、事件版本的时间语义决定重放是否仍然安全。
- 同一数据库事务
- 版本与乱序处理
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
同一订单事件被通知、积分两个处理器消费,且租户可能使用相同事件编号。定义去重记录的身份范围与字段契约。
- 提交内容
- 复合身份设计
- 怎样算完成
- 区分事件与处理器执行
- 覆盖相同 ID 不同负载
练到的知识点事件唯一 ID · 消费去重表
- 作业 02代码推演
消费流程包含登记去重、修改余额、提交和确认四步。对每个相邻步骤间的退出设计观察记录,说明重启后需要核对什么。
- 提交内容
- 故障时间线与核对表
- 怎样算完成
- 事务范围明确
- 覆盖提交结果未知
练到的知识点同一数据库事务 · 提交后确认
- 作业 03代码推演
去重记录写在数据库 A,业务数据写在数据库 B,两者都使用本地事务。为各提交结果组合建立状态表,不假定方法上有事务注解就共享提交。
- 提交内容
- 跨库状态矩阵
- 怎样算完成
- 区分同连接和同方法
- 包含提交结果未知
练到的知识点同一数据库事务
- 作业 04原理问答
用户资料事件包含版本号但允许多个来源独立生成编号。评审版本可比较的范围与相同版本不同内容的处理需求。
- 提交内容
- 版本域与冲突契约
- 怎样算完成
- 明确版本生成者
- 不比较无共同顺序的编号
练到的知识点版本与乱序处理
综合应用
- 作业 05面试场景
同一“设置用户等级”事件重复到达,与同一“增加积分”事件重复到达。比较业务语义,并为暂时失败和规则拒绝分别设计重试资格。
- 提交内容
- 操作语义与失败分类
- 怎样算完成
- 不能只根据 HTTP 方法判断幂等
- 尝试预算有上限
练到的知识点业务语义幂等 · 有界重试
- 作业 06设计评审
离线重放从一周前位置开始,但当前投影已经到版本 20。设计游标、业务版本和检查点的关系,评估乱序旧事件。
- 提交内容
- 重放状态图
- 怎样算完成
- 区分源进度和业务版本
- 包含断点恢复及重复批次
练到的知识点重放游标 · 版本与乱序处理
- 作业 07编程实践
用本地数据库或明确标注的持久模拟器实现积分事件消费者,输入重复、乱序、坏格式和中途退出事件,输出逐事件处理账本。
- 提交内容
- 程序、事件文件与重放报告
- 怎样算完成
- 检查副作用与去重记录共同状态
- 说明模拟器不覆盖的数据库并发行为
练到的知识点事件唯一 ID · 消费去重表 · 同一数据库事务 · 提交后确认 · 业务语义幂等 · 有界重试 · 重放游标 · 版本与乱序处理
- 作业 08设计评审
消费投影已升级新规则,旧去重表仍表示旧规则处理成功。制定“事件处理完成”需要关联哪些处理版本和事务事实。
- 提交内容
- 处理身份与迁移评审
- 怎样算完成
- 区分重建投影和再次执行业务
- 包含旧新处理器并行
练到的知识点同一数据库事务 · 版本与乱序处理
- 作业 09编程实践
在本地数据库建立双版本投影演示,对同一事件序列运行两套规则并中途重启,核对每套投影自己的完成与冲突记录。
- 提交内容
- 代码、迁移脚本及对账结果
- 怎样算完成
- 资源与业务事实仅限虚构数据
- 事务范围以实际连接证据说明
练到的知识点同一数据库事务 · 版本与乱序处理
进阶挑战 · 掌握前置后选做
- 作业 10设计评审
历史事件格式升级,运营要求重建一个全新统计投影且不能污染原消费者进度。设计重放任务身份、版本转换和验收口径。
- 提交内容
- 重建方案与对账清单
- 怎样算完成
- 区分重新计算与再次执行业务动作
- 旧格式缺字段有处理规则
练到的知识点事件唯一 ID · 消费去重表 · 业务语义幂等 · 重放游标 · 版本与乱序处理
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 11思维拓展 · 反例构造
构造同一事件从未重复投递,但乱序仍导致最终状态错误的历史,用来审查“做了去重就能安全消费”的命题。
- 提交内容
- 反例与缺失条件
- 怎样算完成
- 输入事件身份各不相同
- 说明事件是增量还是完整状态
练到的知识点同一数据库事务 · 版本与乱序处理
- 作业 12思维拓展 · 跨域迁移
把消息重放迁移到模型实验数据流水线,特征提取版本升级后需要重算。重新定义事件已处理、输出版本与原始样本身份。
- 提交内容
- 跨域处理协议
- 怎样算完成
- 区分业务副作用与可重建结果
- 包含同样本不同处理版本
练到的知识点同一数据库事务 · 版本与乱序处理
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读实践指南英文
RabbitMQ:Reliability Guide (新标签页)↗
阅读连接故障与重复投递,设计重试、幂等和恢复后的验证。
- 实践参考文档英文
RabbitMQ:Consumer Acknowledgements and Publisher Confirms (新标签页)↗
阅读生产确认、消费确认和重投递,识别发送成功与业务成功之间的空窗。
- 深入参考文档英文
MySQL 8.4:CREATE TABLE (新标签页)↗
阅读列类型、空值、主键与唯一约束,把业务不变量落实到表结构。
- 深入参考文档英文
MySQL 8.4:事务隔离级别 (新标签页)↗
比较 RC、RR 与锁定读行为,明确并发实验采用的事务前提。
- 深入参考文档英文
Spring TransactionTemplate 6.2.8 源码 (新标签页)↗
阅读 execute 的回调、提交、回滚及异常边界。
- 深入参考文档英文
MySQL 8.4:主键与唯一约束 (新标签页)↗
核对唯一冲突、语句回滚与事务存储引擎的行为。
- 深入参考文档英文
Idempotent Consumer 模式原始说明 (新标签页)↗
阅读处理事件 ID 与业务事务共同提交的模式。
- 深入参考文档英文
KafkaConsumer 4.1 API (新标签页)↗
比较业务提交与消费位置提交之间的重放窗口。