在“闪电达”快递公司,快递员小凯有一枚工牌「ID 7」。按公司规矩,他每送一件包裹都要在仓库的签收登记簿上盖一个流水号——0、1、2、3……一路递增。如果网络不好,小凯把同一件包裹重送了一遍,仓管员翻翻登记簿:这流水号我签过啦!直接拒绝。这就是幂等:不管同一个动作发生几次,结果都像只发生一次。 但客户刘女士要搬家,下单买了一套家具——桌子、椅子、茶几,分三个仓库发货。她要求:要么三件一起送到,要么一件都别送,绝不接受“只到两件”的尴尬。这就催生了组合订单(事务):由总调度中心统一协调三个仓库,先各自确认“货齐、能发”,再统一放行;只要有仓库说不行,全部撤回,就像没发生过。 这套机制,正是 Kafka 幂等生产者与事务要解决的核心问题。接下来我们走进这家“快递公司”,看工牌、流水号、调度台账如何一环扣一环,最终支撑起流处理场景里的“恰好一次(Exactly-Once)”承诺。

岗位关联

掌握幂等与事务,你就能为实时数仓、支付链路设计“不重复、不丢失、不错乱”的数据管道——这是流式工程师从“能跑通”到“敢上线”的关键分水岭。

目录
  1. 可视化演示
  2. 幂等生产者原理
  3. 幂等去重机制
  4. 事务核心概念
  5. 事务协调器
  6. 两阶段提交
  7. 隔离级别
  8. 实战配置
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。

本节配有交互动效(进入视口后自动加载,约 3KB)。

系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T

幂等生产者原理

闪电达快递规定,每个快递员领一枚唯一工牌。小凯的工牌是「ID 7」。他送每一件包裹前,都要在包裹上盖一个不断递增的流水号:0、1、2、3……为什么?因为网络会撒谎——仓库明明签收了,回执却在路上丢了,小凯以为没送到,就把同一件包裹再送一遍。没有流水号,仓库分不清“这是新的”还是“刚才送过的”;有了工牌加流水号,每一件包裹都有了独一无二的身份。

在 Kafka 里,生产者的 retries 机制就是“小凯的重试”。网络抖动、broker 响应超时,客户端会重发同一条消息,如果 broker 不做防护,同一个 offset 会被写入两次,下游消费就会看到重复数据。幂等(idempotence)的含义是:同一个操作无论执行多少次,产生的结果都等同于只执行一次。

实现的关键是让每条消息携带二元组(PID, seq)。PID(Producer ID)是生产者向 broker 申请后拿到的全局唯一编号,相当于工牌号;seq 是同一 PID 下从 0 开始的单调递增序号,相当于流水号。开启 enable.idempotence=true 后,客户端自动维护这两个值,无需应用层写任何去重代码。需要特别留意:去重只在“单个分区内”生效,因为序号在每个分区上独立校验。

所以幂等生产者的第一课是:给每个快递员发工牌,给每件包裹盖流水号。有了这两样,后面的“防重复签收”才有据可依。

消息唯一身份:key=(PID, seq),seq0=0,seqi+1=seqi+1\text{消息唯一身份}:\quad \text{key}=(\text{PID},\ \text{seq}),\qquad \text{seq}_{0}=0,\quad \text{seq}_{i+1}=\text{seq}_{i}+1
通俗说明:每条消息像包裹上贴了“工牌号+流水号”双重标签,同一个人送出的第 0、第 1、第 2 件……顺序清清楚楚,谁也冒充不了。
  1. PID 身份生产者第一次启动时向 broker 发送 InitProducerId 请求,broker 分配一个全局唯一的 Producer ID,相当于快递公司给新员工发工牌。
    PID=InitProducerId(producer)\text{PID}=\text{InitProducerId}(\text{producer})
  2. 序列号递增同一 PID 下,每条新消息的序号比上一条大 1,从 0 开始永不回头。
    seqi+1=seqi+1\text{seq}_{i+1}=\text{seq}_{i}+1
  3. 二元组唯一PID 与 seq 组合起来唯一标识一条消息,重复发送时二元组不变,从而可以被精准识别。
    (PID, seq)⇒消息唯一(\text{PID},\ \text{seq}) \Rightarrow \text{消息唯一}

幂等去重机制

仓库的桌上摆着一本签收登记簿。仓管员不靠记忆,只认两样东西:工牌号(PID)和最近签收的流水号。运单送进门,先翻登记簿——如果流水号正好是“上次加一”,正常签收;如果小于等于上次的,说明这件已经签过字了,直接回一句“收到了”,但绝不重复入库。

broker 端确实为每个分区维护了一个生产者状态缓存 producerState,记录(PID → lastSeq)。新消息到达时,先按 PID 找到对应分区的登记簿再做判断。重复消息不会被写入日志,但会返回成功,让生产者以为“发送完成”——这正是幂等“对外结果一致”的体现:发送方和接收方都认为成功了,实际只写了一次。

三种判定逻辑要记牢:序号等于登记簿最大号加一,正常写入;序号小于等于登记簿最大号,判定重复并丢弃;序号竟然大于最大号加一,说明中间有消息真的丢了,重试也补不回来,broker 会抛出 OutOfOrderSequenceException 并拒绝继续接收。

登记簿的哲学是:宁可相信“已经签过”,也不放任何一件“说不清来路”的包裹进门。但注意,这本登记簿是按仓库(分区)分开的——西城仓库的流水号,管不到东城仓库。所以幂等只能保证单分区。当一套家具要分三个仓库协调时,就轮到事务登场了。

判定={写入,seq=S+1重复丢弃,seq≤S乱序异常,seq>S+1\text{判定}=\begin{cases} \text{写入}, & \text{seq}=S+1 \\ \text{重复丢弃}, & \text{seq}\le S \\ \text{乱序异常}, & \text{seq}>S+1 \end{cases}
通俗说明:S 是登记簿上“最近签收的流水号”。包裹序号正好接上,收;序号是旧的,拒;序号跳号了,报警。
  1. 正常续号新流水号恰好等于登记簿最大号加一,说明是下一件包裹,写入分区。
    seq=S+1⇒写入\text{seq}=S+1\Rightarrow \text{写入}
  2. 重复拦截新流水号不大于 S,说明这件已签收过,丢弃但返回成功。
    seq≤S⇒丢弃\text{seq}\le S\Rightarrow \text{丢弃}
  3. 乱序告警流水号跳过中间值,说明前面消息丢失,抛出异常。
    seq>S+1⇒OutOfOrderSequenceException\text{seq}>S+1\Rightarrow \text{OutOfOrderSequenceException}

事务核心概念

刘女士在闪电达下单买了一套家具:桌子、椅子、茶几,分属三个仓库。她对客服说:要么三件一起送到,要么一件都别来。仓库各发各的没问题,但三件包裹要“同生共死”,就得有人统筹——这就是事务(Transaction)。

Kafka 事务解决的是“跨分区原子写入”:多个分区的多条消息作为一个整体提交,broker 要么全部标记为可见,要么全部标记为不可见。幂等只能保证单一分区内不重复,事务才能真正做到“要么全有要么全无”,让下游消费者永远看不到只写了一半的数据。

一个事务由三要素构成:transactional.id(事务 ID,相当于订单号,由应用配置并保证唯一)、事务协调器(统筹执行)、控制消息(标记事务结束)。客户端通过 beginTransaction / commitTransaction / abortTransaction 三个 API 划定事务边界,写完所有分区后统一提交或中止。

下单时承诺“成套送达”,执行时却需要有人专门盯着所有仓库。这个人就是总调度中心——事务协调器。它手里有一本台账,记录每一单的状态,下一节就看这本台账怎么管住全局。

事务:∀Pi∈T: 提交(Pi)或∀Pi∈T: 中止(Pi)\text{事务}:\quad \forall P_{i}\in T:\ \text{提交}(P_{i}) \quad \text{或} \quad \forall P_{i}\in T:\ \text{中止}(P_{i})
通俗说明:事务 T 涉及的所有分区,要么全部提交成功对外可见,要么全部中止对外不可见,不存在“桌子到了、茶几没到”的中间状态。
  1. 全提交事务 T 涉及的所有分区都写入成功,提交后这些数据对消费者可见,相当于三件家具同时送达。
    ∀Pi: 提交(Pi)\forall P_{i}:\ \text{提交}(P_{i})
  2. 全中止任意一个分区失败,整个事务中止,已写入部分对外不可见,如同从未发生。
    ∀Pi: 中止(Pi)\forall P_{i}:\ \text{中止}(P_{i})

事务协调器

总调度中心的座机几乎不停:谁下了组合订单、订单进行到哪一步、哪些仓库参与……全部记在一本厚厚的台账里。这本台账就是 Kafka 的事务日志,而记台账的人,就是事务协调器(Transaction Coordinator)。

协调器不是新起的服务,而是由 broker 兼任的。怎么决定由哪个 broker 当协调器?把 transactional.id 做哈希,映射到名为 __transaction_state 的内部分区上,该分区的 leader broker 就是协调器。这样设计的好处是:协调器和台账都分布在整个集群中,任何 broker 都可能成为某个订单的调度中心,不会单点崩溃。

事务日志记录每个事务的状态机:Empty(空)→ Ongoing(进行中)→ PrepareCommit / PrepareAbort(预提交/预中止)→ CompleteCommit / CompleteAbort(完成)。协调器宕机后,新的协调器从日志里读出状态继续工作,保证订单不会“烂尾”。这也是为什么事务日志必须多副本存储——台账丢了,全城都会乱套。

台账选在哪、由谁记,都有章法。那么协调器如何让三个仓库“同生共死”?答案就在两阶段提交:先分别问一遍“能发吗”,再统一说“发”或“撤回”。

coordinator(txnId)=leader(hash(txnId) mod N)\text{coordinator}(\text{txnId}) = \text{leader}\big(\text{hash}(\text{txnId}) \bmod N\big)
通俗说明:N 是事务日志主题的分区数。把订单号哈希后均匀分到 N 个台账分区上,每个台账分区由自己的 leader broker 负责,谁分区谁调度。
  1. 哈希找分区根据事务 ID 计算哈希值,对事务主题分区数取模,得到专属台账分区。
    part=hash(txnId) mod N\text{part}=\text{hash}(\text{txnId}) \bmod N
  2. 分区即调度台该分区的 leader broker 兼任这一事务 ID 的协调器,集中记录状态。
    coordinator=leader(part)\text{coordinator}=\text{leader}(\text{part})

两阶段提交

调度中心对那套家具采用了两步走。第一步,拨通三个仓库的电话:“桌子仓,订单 10086 能发吗?”……等三个仓库都回“能发”;第二步,调度中心再统一发指令:“盖章放行!”如果任何一个仓库说“不行”,就全部通知“撤回,就当没这单”。这就是两阶段提交。

Kafka 事务的两阶段也是这个节奏。阶段一,生产者在事务内把数据写入各个分区,同时协调器把事务状态登记为准备提交或准备中止,相当于先确认各仓库都能接收;阶段二,协调器向每个涉及的分区写入一条控制消息(marker)——COMMIT 或 ABORT,各分区根据标记将这批数据“放行”或“封存”。

细节上有三个关键点:① 提交请求由生产者发送 EndTxn(COMMIT/ABORT) 触发;② 协调器先在自己的事务日志中写 PREPARE_COMMIT,防止自己崩溃后忘记进度;③ 写完全部分区的 marker 之后,事务日志才更新为 COMPLETE_COMMIT。若中途协调器挂掉,新协调器会根据日志把剩余分区补齐标记,或全部中止。

两阶段的关键词是“先确认、后放行”。但放行之后还有问题:那些“派送中”和“已撤回”的包裹,客户能不能看到?这就引出了隔离级别。

EndTxn(COMMIT)⇒PREPARE_COMMIT⇒⋀p∈TCOMMIT markerp⇒COMPLETE_COMMIT\text{EndTxn}(\text{COMMIT}) \Rightarrow \text{PREPARE\_COMMIT} \Rightarrow \bigwedge_{p\in T} \text{COMMIT marker}_p \Rightarrow \text{COMPLETE\_COMMIT}
通俗说明:提交是一项接力:生产者喊“结单”→ 协调器在台账写上“准备结单”→ 各分区盖上“已结单”的章 → 台账最后记“结单完成”。任何一环断了,订单都要重新清算。
  1. 发起提交生产者调用 commitTransaction,向协调器发送 EndTxn 请求,通知“订单可以完结”。
    producer→EndTxn(COMMIT)coordinator\text{producer}\xrightarrow{\text{EndTxn(COMMIT)}} \text{coordinator}
  2. 预提交登记协调器先在事务日志写入 PREPARE_COMMIT,作为崩溃后的恢复依据。
    日志:PREPARE_COMMIT\text{日志}: \text{PREPARE\_COMMIT}
  3. 写入控制标记协调器向事务涉及的每个分区写入 COMMIT 控制消息,数据被正式放行。
    ∀p: marker(COMMIT, txnId, PID)\forall p:\ \text{marker}(\text{COMMIT},\ \text{txnId},\ \text{PID})
  4. 完结登记全部标记写完,事务日志更新为 COMPLETE_COMMIT,事务落幕。
    日志:COMPLETE_COMMIT\text{日志}: \text{COMPLETE\_COMMIT}

隔离级别

客户查物流时,闪电达有个规矩:只显示“已签收”的订单。还没送完的、已经撤回的,统统不显示——客户没必要为半途状态操心,也不该看到已撤回的订单。这个规矩在 Kafka 里叫隔离级别。

消费者有两个隔离级别可选。read_uncommitted(默认):来什么读什么,未提交、甚至已中止的数据都可能被读到;read_committed:只读已提交数据,自动跳过已中止事务写入的消息。前者像好奇的仓管随便翻账,后者像严谨的客服只回“已签收”。

为了实现 read_committed,broker 引入了 LSO(last stable offset,最后稳定偏移)。在 LSO 之前,所有消息的事务状态都已尘埃落定(已提交或已中止);LSO 之后可能还有进行中事务的数据。read_committed 消费者最多读到 LSO,遇到未完结的订单就耐心等待它提交或中止。注意:已中止事务的消息会被消费者跳过,但不会从磁盘消失——它们仍占用存储,只是被“封存”了。

隔离级别让客户永远只看到“尘埃落定”的订单。把工牌、流水号、总调度、两阶段、隔离级别串起来,就能兑现那句最高承诺——端到端恰好一次(Exactly-Once)。

read_committed:offset≤LSO⇒状态已定;m∈aborted⇒跳过\text{read\_committed}:\quad \text{offset} \le \text{LSO} \Rightarrow \text{状态已定};\qquad m \in \text{aborted} \Rightarrow \text{跳过}
通俗说明:LSO 是仓库里“已盖章订单”与“派送中订单”的分界线。严谨的客服只向客户播报分界线以内的物流状态。
  1. 稳定偏移分界LSO 之前的消息事务状态全部明确,消费者可以放心读取。
    LSO=max⁡{o:∀m≤o, state(m)∈{committed,aborted}}\text{LSO} = \max\{o : \forall m \le o,\ \text{state}(m)\in\{\text{committed},\text{aborted}\}\}
  2. 已中止跳过遇到已中止事务写入的消息,消费者直接跳过,不暴露给业务逻辑。
    m∈aborted⇒skipm \in \text{aborted} \Rightarrow \text{skip}

实战配置

闪电达最贵的服务叫“零差错专送”:从下单到签收,全程不重复、不丢失、不错乱。这个承诺不是某个环节做得好,而是工牌(幂等)、组合订单(事务)、客户过滤(隔离级别)层层配合的结果。

生产端配置三板斧:enable.idempotence=true 开启幂等(Kafka 2.5 及以上版本默认开启);设置唯一的 transactional.id 并调用 initializeTransactions();事务要求 acks=all,重试次数也要足够大。消费端要把 isolation.level 设为 read_committed,否则生产端再努力,消费者照样读到未提交数据。如果消费端要把 offset 提交到 Kafka,也应把“处理结果 + offset 提交”放入同一个事务,避免“处理了但没提交”或“提交了但没处理”。

真正实现端到端 Exactly-Once,还需要流处理框架配合。Flink 的 checkpoint 配合 Kafka 事务写出、Kafka Streams 的 EOS 机制,底层靠的都是这套能力。务必记住:事务只保护 Kafka 内部;如果下游还写了数据库、Redis,外部写入必须自己保证幂等(例如用唯一键去重),否则端到端承诺仍然破功。

一座快递公司的全部智慧:给每个快递员发工牌,给每件包裹盖流水号,给组合订单配调度中心和台账,给客户准备“只看已签收”的视图。Kafka 幂等生产者与事务,讲的正是这套朴素而严谨的秩序——理解了它,你就掌握了实时数据管道不掉链子的底层秘诀。

EOS=幂等生产⏟单分区去重+跨分区事务⏟原子提交+read_committed 消费⏟屏蔽未提交\text{EOS} = \underbrace{\text{幂等生产}}_{\text{单分区去重}} + \underbrace{\text{跨分区事务}}_{\text{原子提交}} + \underbrace{\text{read\_committed 消费}}_{\text{屏蔽未提交}}
通俗说明:端到端“恰好一次”是三段接力:发送端不重复、写入端不分家、消费端不偷看半成品。任何一段掉链子,全局承诺就失效。
  1. 生产端去重PID 加序列号消灭单分区内的重复写入。
    PID+seq⇒去重\text{PID}+\text{seq}\Rightarrow \text{去重}
  2. 写入端原子事务让多分区写入要么全部成功要么全部放弃。
    txn⇒原子提交\text{txn}\Rightarrow \text{原子提交}
  3. 消费端隔离read_committed 让下游只消费已提交数据。
    isolation.level=read_committed\text{isolation.level}=\text{read\_committed}

AI 应用场景

支付事件防重入特征管道

阶段:数据接入与特征计算

解决的问题:支付网关超时重试会导致同一笔 PaymentEvent 被重复投递,风控模型会把同一笔扣款计算两次,造成特征偏移和误判。传统做法是在应用层维护去重表,既慢又容易漏。开启幂等生产者后,PID + 序列号在源头就把重复事件拦截在 broker 之外。

具体案例:某支付平台每笔扣款产生一个 PaymentEvent,生产者开启 enable.idempotence=true,网关重试时同一事件携带相同序列号,broker 判定重复并丢弃。风控特征管道无需自己判重,直接消费即可,模型 AUC 稳定不漂移。

订单与库存的原子写入

阶段:推荐与促销系统触发下单动作

解决的问题:下单事件写入订单 topic 成功、库存扣减写入库存 topic 失败,会造成“超卖”和账实不符。用 Kafka 事务同时写两个 topic,下游分析任务只有读到完整的 COMMIT 才更新报表,永远不会看到只写了一半的订单。

具体案例:某电商大促中,秒杀服务用 transactional.id=seckill-{userId} 开启事务,同时写入订单事件和库存事件;若库存不足则 abortTransaction,两个 topic 均无残留数据。实时对账作业设置 read_committed,对账准确率从 99.2% 提升到 100%。

实时画像 Exactly-Once 更新

阶段:在线特征与画像服务

解决的问题:Flink 聚合作业在 checkpoint 恢复时重复写出画像数据,导致模型读到脏画像、推荐结果抖动。配合 Kafka 事务写出和 read_committed 消费,画像更新的每一步都“恰好一次”。

具体案例:某推荐团队用 Flink 消费用户行为流,聚合出实时兴趣画像后通过事务写回 Kafka 画像 topic;下游在线服务用 isolation.level=read_committed 读取,画像服务重启也不产生重复写入。外部 Redis 画像缓存则用“用户ID+时间窗口”作为唯一键做幂等覆盖。

Python 演示

python64 行
"""Kafka 幂等生产者与事务核心机制迷你模拟(纯逻辑演示)"""  class Broker:    """模拟 broker 端为每个分区维护的序列号登记簿"""     def __init__(self, name):        self.name = name        self.last_seq = -1  # 已接受的最大序列号, -1 表示还没签收过        self.accepted = []     def try_accept(self, seq):        """对序列号做幂等判定: accept / duplicate / out_of_order"""        if seq == self.last_seq + 1:            self.last_seq = seq            self.accepted.append(seq)            return "accept"        if seq <= self.last_seq:            return "duplicate"        return "out_of_order"  class Producer:    """模拟幂等生产者: 固定 PID + 单调递增序列号"""     def __init__(self, pid):        self.pid = pid        self.seq = -1  # 第一条消息的序号是 0     def send(self, broker, retry=False):        if not retry:            self.seq += 1  # 只有正常发送才推进序号        return broker.try_accept(self.seq)  def demo_idempotent():    """网络重试场景: 第 4 次发送重试第 3 次的旧序号"""    broker = Broker("订单分区")    p = Producer(pid=7)    for i, retry in enumerate([False, False, False, True, False, False]):        status = p.send(broker, retry=retry)        print(f"发送#{i+1}: PID={p.pid} seq={p.seq} retry={retry} -> {status}")  def demo_transaction():    """两阶段提交: 三个仓库全部 Prepare 成功才 Commit, 否则整套 Abort"""    txn = Producer(pid=10)    ok = True    for part in ("桌子", "椅子", "茶几"):        broker = Broker(part + "仓库")        status = broker.try_accept(txn.seq + 1)        if status == "accept":            txn.seq += 1            print(f"{part}仓库: Prepare 就绪")        else:            ok = False            print(f"{part}仓库: Prepare 失败")    action = "提交, 整套家具一次送达" if ok else "中止, 整单全部撤回"    print(f"事务{action}")  if __name__ == "__main__":    demo_idempotent()    demo_transaction()
目的:用纯 Python 模拟 Kafka 幂等生产者的“登记簿去重”和事务的两阶段提交逻辑,无需搭建真实集群即可直观理解核心机制。
  1. self.last_seq = -1登记簿初始值为 -1,表示还没签收过任何包裹,这样第一条 seq=0 恰好满足"last+1"。
  2. if seq == self.last_seq + 1:幂等判定的核心:只有“下一条”才入库,其他一律不放行。
  3. if seq <= self.last_seq: return "duplicate"已签收过的旧流水号直接判重,对应 broker 丢弃重复消息。
  4. self.seq = -1生产者序号从 -1 起步,首次正常发送 +1 变成 0,模拟序列号从 0 递增。
  5. if not retry: self.seq += 1重试时不推进序号,从而让旧序号暴露为重复。
  6. status = p.send(broker, retry=retry)模拟“网络超时后重发旧序号”的经典场景。
  7. status = broker.try_accept(txn.seq + 1)两阶段提交第一段:对每个分区 Prepare,确认都能接收。
  8. if status == "accept": txn.seq += 1分区确认后才推进事务序号,保证各分区序号连续。
  9. action = "提交..." if ok else "中止..."全部分区就绪才 Commit,否则整套 Abort,体现事务原子性。

常见误区

误区以为设置 `enable.idempotence=true` 后,从生产到消费的整条链路都不会出现重复,下游消费逻辑可以完全不做判重。

正解幂等只消除“同一生产者重试写入同一分区”的重复,相当于仓库拒绝重复签收同一流水号的包裹。但消费者重复消费、多分区消息重排、下游应用重复处理,都不在保护范围内。实现端到端不重复,还需要消费者自己维护偏移量提交与业务处理的原子性,或借助事务与外部系统幂等键。

为何常见:“幂等”这个名字听起来像全链路保证,容易让人误以为一个开关解决所有重复问题;实际上它只覆盖生产端很小的一段。

误区认为只要生产端配置了 `transactional.id` 并开启事务,所有消费者都会自动获得 read_committed 的隔离保证。

正解消费端必须显式设置 isolation.level=read_committed。默认的 read_uncommitted 会读到未提交事务的数据,甚至读到已中止事务写入后又被“封存”的消息。这就好比客户不主动设置“只看已签收”,物流 App 就会把派送中的订单也展示出来。

为何常见:把生产端配置错误地理解为会传递到消费端;实际上隔离级别是消费端的自主选择。

误区多个应用实例共用同一个 `transactional.id` 没什么大不了,反正只是给消息打个标签。

正解同一 transactional.id 在同一时刻只允许一个活跃生产者。新实例启动时会通过 epoch 递增“挤掉”旧实例,旧实例继续发送会抛出 ProducerFencedException。这就像一张订单号只允许一个快递员执行,另一个快递员拿同一单号来派送会被直接拒绝。

为何常见:以为事务 ID 只是标识,不知道它绑定 PID 与生产者 epoch,是“防覆盖”的锁。

误区以为设置了 `isolation.level=read_committed`,整条数据链路就自动变成 Exactly-Once 了。

正解端到端 Exactly-Once 是幂等生产者、跨分区事务、read_committed 消费,以及外部系统(数据库、Redis、文件)幂等写入的合力结果。如果消费者把 Kafka 消息结果写入 MySQL 时重复执行,数据库里照样出现重复行。LSO 只保证“读不到半成品”,不保证“下游只写一次”。

为何常见:把消费端隔离级别的效果无限放大为全局保证,忽略了 Kafka 之外的世界。

面试题

Q1. Kafka 幂等生产者通过什么标识实现单分区去重?

单选题 · easy

  • A.分区与偏移量组合
  • B.PID与序列号组合← 正确答案
  • C.主题与消息键组合
  • D.时间戳与消息键组合

答案:B。幂等去重靠“工牌+流水号”双重标签:PID 识别“是谁发的”,序列号识别“第几件”,二者组合才是消息的唯一身份。A 是消息的存储位置,C 用于决定分区归属,D 没有去重能力。

Q2. 幂等生产者能保证在哪个范围内不重复写入?

单选题 · easy

  • A.仅单个分区内← 正确答案
  • B.多个分区内
  • C.整个集群内
  • D.跨集群范围内

答案:A。登记簿按仓库(分区)分开,序列号在每个分区上独立校验,因此去重只作用于单分区。跨分区的一致性必须靠事务解决,正如一套家具要三个仓库协调,不能靠单仓库的登记簿。

Q3. broker 收到序列号比期望值大 1 以上的消息时会怎样?

单选题 · hard

  • A.接受并直接写入
  • B.视为重复并丢弃
  • C.抛出乱序异常← 正确答案
  • D.等待消息补发

答案:C。序列号跳过中间值说明前面的消息真的丢了,重试也补不回来,broker 会抛出 OutOfOrderSequenceException 拒绝继续接收。这就像流水号从 2 直接跳到 5,仓管员知道 3、4 丢了,必须报警而不是硬收。

Q4. 事务协调器把事务状态记录在哪里?

单选题 · mid

  • A.生产者本地文件
  • B.各个业务分区
  • C.事务日志主题← 正确答案
  • D.消费组偏移量主题

答案:C。协调器的“台账”是名为 __transaction_state 的内部主题,按 transactional.id 哈希分区存储事务状态。业务数据存在数据分区,偏移量存在 __consumer_offsets,都不负责保存事务状态。

Q5. 设置 transactional.id 开启事务时,会被自动启用的是?

单选题 · mid

  • A.幂等功能← 正确答案
  • B.数据压缩
  • C.分区扩展
  • D.消息排序

答案:A。事务是幂等的超集,设置事务 ID 时强制开启 enable.idempotence=true(高版本默认开启),因为事务依赖 PID 和序列号来识别属于自己的消息。压缩、分区扩展、排序都与事务无直接关系。

Q6. 关于事务提交时写入各分区的控制消息(marker),正确的有?

多选题 · mid

  • A.由协调器写入各分区← 正确答案
  • B.COMMIT 标记放行数据← 正确答案
  • C.ABORT 标记封存数据← 正确答案
  • D.消费者能直接读取

答案:ABC。控制消息由协调器在第二阶段写入各分区,COMMIT 放行、ABORT 封存,相当于给包裹盖“已签收”或“已退回”的章。控制消息会被消费者过滤,不会暴露给业务代码,因此 D 错误。

Q7. read_committed 消费者遇到已中止事务写入的数据会?

单选题 · hard

  • A.正常消费并处理
  • B.消费后自动回滚
  • C.自动跳过不消费← 正确答案
  • D.抛出读取异常

答案:C。read_committed 消费者通过 LSO 和 aborted 事务列表自动跳过已中止事务的消息,业务层完全感知不到这些“已撤回的订单”。D 错误,跳过是正常行为而非异常。

Q8. 事务协调器的职责包括哪些?

多选题 · easy

  • A.为生产者分配 PID← 正确答案
  • B.记录事务运行状态← 正确答案
  • C.超时自动中止事务← 正确答案
  • D.存储业务数据副本

答案:ABC。协调器负责处理 InitProducerId 分配 PID、在事务日志记录状态、对超时事务执行中止。业务数据存储在各数据分区,协调器不保存数据副本,D 错在混淆了台账与货仓。

Q9. 开启 Kafka 事务需要满足哪些前提?

多选题 · mid

  • A.开启幂等功能← 正确答案
  • B.设置唯一事务 ID← 正确答案
  • C.acks 必须设为 all← 正确答案
  • D.必须手动提交消费偏移

答案:ABC。事务要求幂等开启、transactional.id 全局唯一、acks=all 保证不丢消息。手动提交消费偏移是消费端策略,与生产端事务前提无关,D 是干扰项。

Q10. 两个生产者使用相同的 transactional.id 先后启动,旧生产者继续发送消息时会?

单选题 · extreme

  • A.自动切换新的生产者 ID
  • B.数据仍能正常提交事务
  • C.抛出生产者隔离异常← 正确答案
  • D.触发协调器重新选举

答案:C。新实例启动会递增 epoch,旧实例被 fencing,后续发送会收到 ProducerFencedException,防止两个“快递员”用同一订单号同时派送造成数据混乱。A 不会自动切换,B 数据无法提交,D 协调器不会因此重选。