读完这篇,你能做什么
能画出处理与提交的两种故障窗口,并为外部数据库写入增加业务幂等。
学习理由、准备要求与知识点
为什么学它
offset 提交与业务副作用不天然原子,Kafka 的幂等和事务保证也有系统边界。
先读这些内容
知识点清单
- 当前位置
- 已提交 offset
- 下一条位置
- 至少一次
- 生产者幂等
- Kafka 事务
read_committed- 外部数据库副作用
运行前先确认Kafka 4.1 消费提交与事务模型,源码固定 Kafka 4.1.0。Java 17 算法只跟踪一个已登记批次的完成前缀,真实客户端还应保存 TopicPartition、leader epoch 与分配代次。
01先弄懂它是什么
Kafka offset 表示某个分区里的日志位置,不是业务已处理条数。要分清读到哪里、提交了哪里,以及业务真正完成到哪里。poll 已读到 100 并交给线程池,不代表此前所有订单都已写入数据库。恢复检查点应根据从前往后已经可靠完成的那一段确定,不能只选见过的最大 offset 或最后完成的编号。
通常提交的是下一条待处理的位置。整批都完成后应使用客户端提供的 nextOffsets 等信息;批中还有没完成的记录时,不能越过最早未完成者。offset 数字可能因日志压缩、事务控制记录或被过滤的事务内容而有空缺。因此“连续完成”指已经读取的记录按顺序都完成,不要求中间每个整数都对应一条记录。
幂等生产者解决某些生产会话中协议重试导致的重复写入,不能去重应用主动再次调用 send 产生的同一业务事实。Kafka 事务可以把写入 Kafka 的输出记录和输入消费 offset 纳入同一事务边界,配合 read_committed 让下游观察已提交事务。外部数据库、邮件、支付 API 不会因此自动加入 Kafka 事务,它们仍需要自己的幂等与一致性协议。
文中这几个词,先认一认
- 读取位置
- 客户端接下来准备交付记录的位置;poll 之后它可能已经走在业务实际完成之前。
- 提交位置
- 故障恢复时继续消费的位置,通常记录下一条还需要处理的记录位置。
- 完成前缀
- 从已登记记录的开头起,逐条都完成的那一段,不是要求 offset 数字逐一连续。
- 事务隔离读取
read_committed不交付未提交或已中止的事务记录,但不证明外部数据库等动作已经完成。
02看看它怎么工作
假设本批真实返回 offset 为 [5,8,10],next position 为 12。任务 8 先完成时,5 尚未完成,可提交位置仍是 5;5 完成后,最早未完成为 10,所以可提交 10;10 完成后才能推进到客户端给出的 12。等待不存在的 6、7、9 会永久卡住,直接提交最大完成值加一则可能跳过 5。必须同时保存已读取顺序和每项完成状态。
外部数据库处理常采用先提交业务,再提交 Kafka offset。若中间失败,恢复会再次处理已经提交的业务,所以需要数据库唯一约束、条件更新或业务幂等记录。若颠倒顺序,提交 offset 后数据库失败,恢复会从更后面开始,导致业务遗漏。这个两步故障窗口不能靠把 commitSync 改成 commitAsync 消除,它们改变的是等待与错误处理方式,不改变两个系统缺少共同事务的事实。
如果输入和输出都在 Kafka,可以开始事务、写入输出,再把下一条输入位置及消费组信息一起加入事务后提交。失败时要按 SDK 规则中止或处理不可继续的错误,不能所有异常都直接重试。下游使用 read_committed 才会按事务提交结果读取;长时间未决定的事务也可能挡住之后的可见进度。
transactional.id 还用于恢复和隔离旧生产者,必须管理好稳定性与唯一活动实例,不能随意给多个并行实例共用。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["poll:实际记录 5、8、10;下一位置 12"] --> B["登记批次与分配代次"]
B --> C["8 先完成"]
C --> D["5 未完成:可提交 5"]
D --> E["5 完成:可提交 10"]
E --> F["10 完成:可提交 12"]
F --> G["提交检查点"]
G --> H["重启从已提交位置恢复"]
E --> X["若先提交 12 再完成 10"]
X --> Y["崩溃后可能跳过未完成业务"]按顺序看,每一步做了什么
- poll 后按分区登记真实返回记录与下一位置元数据。
- 工作线程完成业务后回报记录身份及当前分配代次。
- 只推进最早未完成记录之前的前缀。
- 由管理消费者的线程提交合法检查点并处理结果。
- 故障恢复通过业务幂等覆盖已处理但尚未提交进度的记录。
03对照实现代码
Apache Kafka 4.1.0 · ClassicKafkaConsumer#commitSync(Map, Duration)
原创 Java 局部模型,展示显式提交映射被复制并交给协调器的边界。它不实现 Kafka 协议、分配代次校验或数据库事务,也不代表新消费组实现的内部调用链。
import java.util.*;
import java.util.function.Predicate;
final class CommitBoundaryModel {
static void commit(Map<String, Long> nextPositions,
Predicate<Map<String, Long>> coordinator) {
Map<String, Long> snapshot = new HashMap<>(nextPositions); // 固定本次提交输入。
for (long next : snapshot.values())
if (next < 0) throw new IllegalArgumentException("negative position");
if (!coordinator.test(snapshot)) // 这里只观察协调器提交结果。
throw new IllegalStateException("commit not confirmed");
// 此方法没有查询业务数据库,也不知道工作线程是否已完成。
}
}真实同步提交入口先获取消费者调用保护并确保状态可用,再检查组信息,将提交映射复制给协调器并等待结果。映射复制使一次请求使用明确输入,但复制不会把错误的业务检查点变成正确值。调用方若传入越过未完成记录的位置,协议层通常无法判断你的数据库业务还没有成功。
方法还处理 leader epoch 元数据并记录提交耗时,说明 offset 不是脱离日志历史的孤立整数。日志截断和分区领导者变化时,epoch 能为位置解释提供额外上下文。教学模型使用字符串映射只是为了让提交边界独立可读;真实实现应使用 TopicPartition 与 OffsetAndMetadata,保留客户端提供的元数据。
无参数 commitSync 使用客户端已消费位置,并不自动等待你自行创建的工作线程。如果 poll 之后异步处理仍在运行,无参数提交可能领先实际业务。只有严格顺序处理完全部返回记录等前置条件满足时,才可以把客户端位置直接作为业务完成位置。异步架构通常需要显式位置映射。
commitSync 超时,只说明客户端没有在规定时间内拿到成功结果,服务端仍可能已经保存了提交位置。使用异步提交时,还要处理新旧请求及回调的先后关系:在较新的位置已经提交后,盲目重提旧失败回调里的位置,可能让进度倒退。位置通常应由一个线程管理,只向前推进,并确认请求仍属于当前这次分区分配;换成异步方法本身并不能解决这些问题。
代码先调用谁,再调用谁
- 应用计算每个分区安全的下一处理位置
KafkaConsumer将请求委托给当前内部实现ClassicKafkaConsumer.commitSync获取调用保护并处理元数据ConsumerCoordinator.commitOffsetsSync执行并等待协调器提交- 应用记录结果;失败或重启后依靠幂等处理可能重放的业务
04动手写一个例子
稀疏 offset 与乱序完成:维护批次安全提交点
输入严格递增的真实 offset 数组和该批次结束后的 nextPosition。任意顺序调用 complete(offset),查询 safePosition:有未完成记录时返回最早未完成的 offset,否则返回 nextPosition。重复完成幂等;未知 offset 拒绝。
- 输入 1
offsets=[5,8,10],next=12;完成8- 输出 1
safe=5
- 输入 2
继续完成5、10- 输出 2
safe依次为10、12
给每个真实记录建立一个布尔完成标记,另用 cursor 指向数组中最早未完成的位置。记录数 n 决定状态长度,offset 的数值跨度不决定内存量,因而 [5,1000000] 不需要分配百万格数组。收到完成事件时,用二分搜索将日志位置映射到数组下标。
完成标记设为真后,从 cursor 开始连续跳过已经完成的项。某个后面的任务先完成,只会留下一个真标记,不会让游标跨越前面仍为假的项。每个下标一生最多被游标经过一次,所以虽然单次 complete 可能推进很多格,整个批次推进成本仍为 O(n),无需每次从头扫描。
当游标到达末尾,返回输入的 nextPosition,而不是最后一个 offset 加一。这保留客户端已经越过控制记录等空洞的语义,也避免在极值附近随意做加一。这个批次模型假定没有更早批次未完成;推广到多批次时必须以分区为单位把批次串成同一条已登记序列,不能分别取各批最大进度。
为什么这样写是对的cursor 之前的所有已登记记录均完成;cursor 若未到末尾,所指记录一定未完成。safePosition 永不越过这条最早未完成记录。
每次完成用二分查找 O(log n),完成标记写入常数;cursor 在全部操作中最多推进 n 次。数组克隆与校验各扫描一次。重复完成不会回退游标,也不会产生额外线性扫描。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
static final class Window {
private final long[] offsets;
private final boolean[] done;
private final long nextPosition;
private int cursor;
Window(long[] input, long nextPosition) {
Objects.requireNonNull(input);
if (nextPosition < 0) throw new IllegalArgumentException("next");
offsets = input.clone(); // 不让外部修改已登记顺序。
for (int i = 0; i < offsets.length; i++)
if (offsets[i] < 0 || (i > 0 && offsets[i] <= offsets[i - 1]))
throw new IllegalArgumentException("strict increasing offsets");
if (offsets.length > 0 && nextPosition <= offsets[offsets.length - 1])
throw new IllegalArgumentException("next must follow batch");
this.nextPosition = nextPosition;
done = new boolean[offsets.length];
}
synchronized void complete(long offset) {
int index = Arrays.binarySearch(offsets, offset); // 稀疏位置映射为紧凑下标。
if (index < 0) throw new IllegalArgumentException("unknown offset");
done[index] = true; // 重复完成同一项是幂等操作。
while (cursor < done.length && done[cursor]) cursor++;
}
synchronized long safePosition() {
return cursor < offsets.length ? offsets[cursor] : nextPosition;
}
}
static void eq(long expected, long actual) {
if (expected != actual) throw new AssertionError(expected + " != " + actual);
}
public static void main(String[] args) {
Window w = new Window(new long[]{5,8,10}, 12);
w.complete(8); eq(5, w.safePosition()); // 不跨越未完成的 5。
w.complete(5); eq(10, w.safePosition()); // 不等待不存在的 6、7、9。
w.complete(10); eq(12, w.safePosition());
w.complete(8); eq(12, w.safePosition()); // 重复事件不回退。
eq(7, new Window(new long[0], 7).safePosition());
Window edge = new Window(new long[]{Long.MAX_VALUE - 1}, Long.MAX_VALUE);
edge.complete(Long.MAX_VALUE - 1); eq(Long.MAX_VALUE, edge.safePosition());
boolean bad = false;
try { w.complete(9); } catch (IllegalArgumentException e) { bad = true; }
if (!bad) throw new AssertionError("unknown offset accepted");
System.out.println("PASS 7");
}
}这些特殊情况也要试一试
- offset 数值不连续时,只跟踪真正读取的记录。
- 重复完成不得回退或重复推进。
- 空批次的下一位置可能已因控制记录等原因推进,仍需结合完整消费上下文。
- 未知 offset、重复输入 offset 和非法
nextPosition必须拒绝。 - 此模型是单批次,不能忽略更早在途批次直接提交它的结果。
进阶选读 · 多分区异步流水线的代次隔离与有界背压
场景一个 poll 同时取得多个分区,业务耗时差异很大,且处理中发生分区撤销。
做法由单一管理线程为每分区维护记录顺序、批次 nextOffsets、完成标记和 generation。工作者回报 (partition,generation,offset),旧代次结果只用于业务审计,不推动当前提交。达到在途上限时 pause 相应分区,完成后 resume;撤销时按策略停止、等待或放弃并允许幂等重放。
复杂度总状态 O(I),I 为所有分区受限在途记录数;若用哈希定位记录,完成查找通常 O(1),有序前缀推进总 O(I)。协议交互、提交批次与业务耗时另外计量。
好处与代价异步提高独立任务并行度,却引入前缀阻塞、在途内存与代次状态。若业务必须严格分区顺序,串行处理通常更易证明正确。
05项目里怎么用
先定义你的“一次”是什么:一次协议写入、一次 Kafka 到 Kafka 的事务处理,还是一次真实扣款。幂等生产者可以减少客户端重试造成的重复,但上游把同一订单事件主动发两遍仍需业务识别。外部副作用最好由接收方支持稳定幂等键及结果查询,不能仅在发送方内存里记一个成功标志就宣称已覆盖所有故障。
消费消息并修改数据库时,可以把 eventId 的唯一记录和业务修改放进同一个事务,重复收到事件就读取已有结果。如果 offset 也存入这份数据库,要明确重启时以哪份位置为准。分区换人处理时,还要让数据库拒绝旧实例继续写入,例如用正确的事务锁定、条件更新或由资源端检查的 fencing 代次。
租约可以协调分工,但到期不会停止旧线程,不能单独当成写入保护。若数据库和 Kafka 各存一份位置,却没有协调规则,恢复时就可能不知道该从哪里继续。
验证 exactly-once 边界要注入重复发送、发送确认丢失、业务提交后进程终止、事务中止和重平衡等不同故障,按业务 ID 比较最终作用次数与日志结果。只运行一次正常流程看到一条输出,不能证明失败场景也只产生一次作用。对未决事务与最老未完成记录设置告警,才能避免系统看似在线却长期不再向前推进。
容易踩的坑
- 把最大完成 offset 加一作为安全进度,跨越未完成项。
- 假定所有 offset 整数都对应可见业务记录,等待永久不存在的位置。
- 开启幂等生产者后仍认为应用主动重发会自动去重。
- 将 Kafka 事务扩展解释成外部数据库或支付平台的共同事务。
- 旧分区代次的异步回调推进新分配进度。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 同步业务后手动提交 | 顺序与故障窗口容易理解。 | 吞吐受单批处理时间影响,仍可能重放。 | 业务量适中且下游具有幂等约束。 |
| 异步工作池与前缀提交 | 可并行独立业务,读取与处理解耦。 | 需要代次、有界在途、前缀跟踪。 | 团队能维护清晰的并发与恢复协议。 |
| Kafka 输入输出事务 | 协调 Kafka 输出与消费位置的可见性。 | 需要事务配置、read_committed 和故障恢复纪律。 | 主要状态变化处于 Kafka 事务边界内。 |
| 数据库事务内业务去重 | 把外部业务作用与去重原子提交。 | 唯一索引与保留窗口增加成本。 | 主要副作用发生在可事务化的数据库。 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01为什么 [5,8,10] 不能用整数连续性跟踪?
展开参考答案与推导
日志可见记录并不保证 offset 连续;应跟踪真实读取序列,数值空洞不是未完成任务。
02开启 enable.idempotence 能否去重两次主动 send?
展开参考答案与推导
不能据此保证。协议重试去重与业务主动重发不同,需要稳定业务事件标识或其它事务边界。
03Kafka 事务为何不能自动覆盖发邮件?
展开参考答案与推导
邮件服务未加入同一个 Kafka 事务;需要外部幂等接口、结果记录或可接受的补偿语义。
换个情况,还会处理吗
01数据库已提交,commitSync 超时,下一步如何处理?
展开参考答案与推导
保留业务结果和提交未知状态;重启可能重放同事件,用数据库幂等判定避免重复副作用,不能回滚或再次无条件执行业务。
02read_committed 消费者看不到日志尾部,可能不是没数据吗?
展开参考答案与推导
可能有未决事务限制最后稳定位置;检查事务年龄、生产者状态与消费隔离级别,不能只比较物理日志尾部。
03某旧异步提交失败,直接重试旧位置是否总安全?
展开参考答案与推导
不总安全。新位置可能已经提交,旧重试可能回退进度;管理线程应检查代次与单调提交状态并按当前有效进度处理。
写代码试一试
01增加只完成批次最后一项的测试。
- 输入 / 条件
offsets=[2,7,9],next=11,complete(9)- 输出 / 预期
safe=2
提示 后面的完成不允许跳过第一项。
展开参考答案与推导
同时验证 complete(2) 后为 7,complete(7) 后为 11,覆盖空洞与乱序两种边界。
02给 Window 加 generation 参数并过滤旧回调。
- 输入 / 条件
当前generation=2,回调generation=1、offset=5- 输出 / 预期
忽略旧回调,safe不变
提示 先校验代次,再写 done。
展开参考答案与推导
由分配管理线程维护代次,重分配新建窗口;旧回调可记录日志但不得改变新进度。
03将两个相邻批次组合为同一安全前缀。
- 输入 / 条件
批次A=[1,3],next=5;批次B=[5,8],next=10;B全完成,A未完成- 输出 / 预期
safe仍为1
提示 批次B的完成不能跨过A。
展开参考答案与推导
维护有序批次队列,仅头批次完成后才移除并考虑下一批;部分完成时返回该批首个未完成位置。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能画出处理与提交的两种故障窗口,并为外部数据库写入增加业务幂等。
本篇重点加练
持久消费进度与数据库副作用分属不同资源,需要明确它们共同支持的恢复承诺。
- 已提交 offset
- 外部数据库副作用
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01代码推演
某分区本次拉取位置为 30,提交记录为 24,批次实际包含 24、26、29。为逐条成功、部分失败和重启三种情况记录位置含义。
- 提交内容
- 进度状态表
- 怎样算完成
- 分别跟踪当前位置与持久提交
- 解释位置的下一条语义
练到的知识点当前位置 · 已提交 offset · 下一条位置
- 作业 02面试场景
生产者重连后重试发送,同时业务代码也对同一付款事件调用两次 send。列出生产者幂等与业务幂等分别需要识别的身份。
- 提交内容
- 两类重复时间线
- 怎样算完成
- 明确发送请求与业务事件粒度
- 覆盖进程重启
练到的知识点至少一次 · 生产者幂等
- 作业 03原理问答
管理员手工移动消费组位置后,应用仍保留旧的本地检查点。分别定义两份进度的权威范围,列出重新开始处理前必须核对的状态。
- 提交内容
- 进度来源与恢复清单
- 怎样算完成
- 区分运维重置和应用正常推进
- 记录分区及代次
练到的知识点已提交 offset
综合应用
- 作业 04原理问答
事务生产者写入两条记录后中止,随后另一个事务提交。分别说明隔离读取需要观察的记录状态及推进进度的证据。
- 提交内容
- 事务日志示意
- 怎样算完成
- 包含未决和已终结事务
- 注明消费者隔离配置
练到的知识点Kafka 事务 · read_committed
- 作业 05设计评审
消费者更新本地订单表并发送外部通知。规定订单、通知和 Kafka 进度三者的状态,列出各动作之间发生失败时的可恢复条件。
- 提交内容
- 状态矩阵与恢复协议
- 怎样算完成
- 覆盖提交结果未知
- 不把 Kafka 事务范围扩展到未参与资源
练到的知识点外部数据库副作用
- 作业 06编程实践
用本地 Java 事件脚本模拟批次并发完成、进度提交与重启,加入处理成功但提交结果丢失的情况,记录每次可安全声明的完成范围。
- 提交内容
- 源码、轨迹和安全性检查
- 怎样算完成
- 测试记录编号存在间隔
- 业务副作用和进度分别记账
练到的知识点当前位置 · 已提交 offset · 下一条位置 · 至少一次 · 外部数据库副作用
- 作业 07设计评审
消费者在数据库内完成扣款,但数据库故障切换丢失了一小段已确认数据。评审外部副作用恢复时还需何种持久性假设。
- 提交内容
- 跨资源故障评审
- 怎样算完成
- 不只分析正常进程崩溃
- 明确哪个组件的承诺被依赖
练到的知识点外部数据库副作用
- 作业 08编程实践
写本地重启检查器,输入消费进度、业务流水与去重记录三份快照,识别彼此不一致的样本并输出待人工核实信息。
- 提交内容
- 程序与快照样本
- 怎样算完成
- 覆盖人为回退进度和业务缺行
- 不从缺行直接猜测安全重发结论
练到的知识点已提交 offset · 外部数据库副作用
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
团队宣称“启用 exactly-once 后重放不会重复发券”。设计一组能检验该说法适用范围的实验,优先使用本地发券记录替代真实外发服务。
- 提交内容
- 实验计划与证据分级
- 怎样算完成
- 逐项标明参与事务的资源
- 区分单次成功演示与故障覆盖
练到的知识点生产者幂等 · Kafka 事务 · read_committed · 外部数据库副作用
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 反例构造
研究能否仅靠消费组 offset 证明“每个订单恰好产生一次外部退款”。构造日志记录完全一致但外部结局不同的执行历史。
- 提交内容
- 不可区分历史与证据需求
- 怎样算完成
- 明确消费者实际可见信息
- 反例不依赖篡改日志
练到的知识点已提交 offset · 外部数据库副作用
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
Kafka 4.1:KafkaConsumer (新标签页)↗
阅读传统消费组、poll、再均衡和 offset 提交,明确消费位置的含义。
- 实践参考文档英文
Apache Kafka 4.1:Producer Configs (新标签页)↗
阅读 acks、幂等与事务相关配置,核对可靠性前提与重试约束。
- 深入参考文档英文
Apache Kafka 4.1:Design (新标签页)↗
阅读日志、复制和消息交付语义,划分分区顺序与事务的保证范围。
- 深入参考文档英文
KafkaProducer 4.1 API (新标签页)↗
区分幂等生产者、应用重发与事务恢复。
- 深入参考文档英文
ClassicKafkaConsumer 4.1.0 源码 (新标签页)↗
定位显式与无参数
commitSync的区别及协调器调用。