qinghuan
← 消息队列 / 全部笔记

单元 109 / 消息队列

P1 · 重点提升L3 · 深入

Kafka offset 与幂等:一次处理的边界究竟在哪里

分清读到、业务完成和提交恢复位置,再检查 Kafka 幂等与事务究竟覆盖哪些操作、哪些还要自己保证。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能画出处理与提交的两种故障窗口,并为外部数据库写入增加业务幂等。

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

为什么学它

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 还用于恢复和隔离旧生产者,必须管理好稳定性与唯一活动实例,不能随意给多个并行实例共用。

正在加载流程图…

提交跟随实际读取序列的完成前缀,不能把稀疏 offset 当作没有空洞的整数计数器。
查看流程图源码(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["崩溃后可能跳过未完成业务"]

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

  1. poll 后按分区登记真实返回记录与下一位置元数据。
  2. 工作线程完成业务后回报记录身份及当前分配代次。
  3. 只推进最早未完成记录之前的前缀。
  4. 由管理消费者的线程提交合法检查点并处理结果。
  5. 故障恢复通过业务幂等覆盖已处理但尚未提交进度的记录。

03对照实现代码

ClassicKafkaConsumer 的同步提交并不执行业务 (新标签页)↗

Apache Kafka 4.1.0 · ClassicKafkaConsumer#commitSync(Map, Duration)

原创 Java 局部模型,展示显式提交映射被复制并交给协调器的边界。它不实现 Kafka 协议、分配代次校验或数据库事务,也不代表新消费组实现的内部调用链。

核心机制代码 · java
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 超时,只说明客户端没有在规定时间内拿到成功结果,服务端仍可能已经保存了提交位置。使用异步提交时,还要处理新旧请求及回调的先后关系:在较新的位置已经提交后,盲目重提旧失败回调里的位置,可能让进度倒退。位置通常应由一个线程管理,只向前推进,并确认请求仍属于当前这次分区分配;换成异步方法本身并不能解决这些问题。

代码先调用谁,再调用谁

  1. 应用计算每个分区安全的下一处理位置
  2. KafkaConsumer 将请求委托给当前内部实现
  3. ClassicKafkaConsumer.commitSync 获取调用保护并处理元数据
  4. ConsumerCoordinator.commitOffsetsSync 执行并等待协调器提交
  5. 应用记录结果;失败或重启后依靠幂等处理可能重放的业务

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(n);m 次完成总 O(m log n+n);查询 O(1)额外空间 O(n)

每次完成用二分查找 O(log n),完成标记写入常数;cursor 在全部操作中最多推进 n 次。数组克隆与校验各扫描一次。重复完成不会回退游标,也不会产生额外线性扫描。

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

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

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

Main.java · 完整可运行程序 · Java
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
  • 外部数据库副作用

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

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

基础必做

  1. 作业 01代码推演

    某分区本次拉取位置为 30,提交记录为 24,批次实际包含 24、26、29。为逐条成功、部分失败和重启三种情况记录位置含义。

    提交内容
    进度状态表
    怎样算完成
    • 分别跟踪当前位置与持久提交
    • 解释位置的下一条语义

    练到的知识点当前位置 · 已提交 offset · 下一条位置

  2. 作业 02面试场景

    生产者重连后重试发送,同时业务代码也对同一付款事件调用两次 send。列出生产者幂等与业务幂等分别需要识别的身份。

    提交内容
    两类重复时间线
    怎样算完成
    • 明确发送请求与业务事件粒度
    • 覆盖进程重启

    练到的知识点至少一次 · 生产者幂等

  3. 作业 03原理问答

    管理员手工移动消费组位置后,应用仍保留旧的本地检查点。分别定义两份进度的权威范围,列出重新开始处理前必须核对的状态。

    提交内容
    进度来源与恢复清单
    怎样算完成
    • 区分运维重置和应用正常推进
    • 记录分区及代次

    练到的知识点已提交 offset

综合应用

  1. 作业 04原理问答

    事务生产者写入两条记录后中止,随后另一个事务提交。分别说明隔离读取需要观察的记录状态及推进进度的证据。

    提交内容
    事务日志示意
    怎样算完成
    • 包含未决和已终结事务
    • 注明消费者隔离配置

    练到的知识点Kafka 事务 · read_committed

  2. 作业 05设计评审

    消费者更新本地订单表并发送外部通知。规定订单、通知和 Kafka 进度三者的状态,列出各动作之间发生失败时的可恢复条件。

    提交内容
    状态矩阵与恢复协议
    怎样算完成
    • 覆盖提交结果未知
    • 不把 Kafka 事务范围扩展到未参与资源

    练到的知识点外部数据库副作用

  3. 作业 06编程实践

    用本地 Java 事件脚本模拟批次并发完成、进度提交与重启,加入处理成功但提交结果丢失的情况,记录每次可安全声明的完成范围。

    提交内容
    源码、轨迹和安全性检查
    怎样算完成
    • 测试记录编号存在间隔
    • 业务副作用和进度分别记账

    练到的知识点当前位置 · 已提交 offset · 下一条位置 · 至少一次 · 外部数据库副作用

  4. 作业 07设计评审

    消费者在数据库内完成扣款,但数据库故障切换丢失了一小段已确认数据。评审外部副作用恢复时还需何种持久性假设。

    提交内容
    跨资源故障评审
    怎样算完成
    • 不只分析正常进程崩溃
    • 明确哪个组件的承诺被依赖

    练到的知识点外部数据库副作用

  5. 作业 08编程实践

    写本地重启检查器,输入消费进度、业务流水与去重记录三份快照,识别彼此不一致的样本并输出待人工核实信息。

    提交内容
    程序与快照样本
    怎样算完成
    • 覆盖人为回退进度和业务缺行
    • 不从缺行直接猜测安全重发结论

    练到的知识点已提交 offset · 外部数据库副作用

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

  1. 作业 09设计评审

    团队宣称“启用 exactly-once 后重放不会重复发券”。设计一组能检验该说法适用范围的实验,优先使用本地发券记录替代真实外发服务。

    提交内容
    实验计划与证据分级
    怎样算完成
    • 逐项标明参与事务的资源
    • 区分单次成功演示与故障覆盖

    练到的知识点生产者幂等 · Kafka 事务 · read_committed · 外部数据库副作用

思维拓展

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

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

    研究能否仅靠消费组 offset 证明“每个订单恰好产生一次外部退款”。构造日志记录完全一致但外部结局不同的执行历史。

    提交内容
    不可区分历史与证据需求
    怎样算完成
    • 明确消费者实际可见信息
    • 反例不依赖篡改日志

    练到的知识点已提交 offset · 外部数据库副作用

完成后,再检验一次

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

继续阅读

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

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