Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

7 队列、流与事件驱动

本章回答

一条消息「发出去了」到底承诺了什么?为什么消费端明明处理成功,重启后同一条消息又来一次?分区、消费者组、offset 提交和再平衡之间是什么关系?Kafka 的 acks、ISR、幂等生产者和事务各自保证到哪一步,又在哪一步必须交给业务去重?

读完你会

能区分队列与日志的语义并选出正确模型;能画出三种投递语义的失败窗口;能根据分区键说出顺序保证的范围;能读懂 consumer lag 和 rebalance 的状态;能写出 outbox + 消费者去重的可运行代码;能设计一条可重放、可演进、不会二次扣款的事件。

第 6 章的最后停在 outbox:业务行和消息行在同一个本地事务里提交,消费端用去重表挡住重复。那还是在「一次请求、一个数据库」的范围内。本章把镜头拉远:一次状态变化被写成事件之后,它会被多少个消费者、以什么顺序、重复多少次地读到?

一个支付成功事件 payment.charged 通常有四个消费者:记账、通知、风控、数据仓库。记账 consumer 停机两小时,恢复后要从两小时前的位置继续;通知 consumer 发版后从旧 offset 重放,可能给用户再发一条短信;风控要求同一张卡的事件严格有序;数仓只关心最终结果,重复和乱序都能靠聚合消化。同一条事件,四种「次数」和「顺序」要求。

队列和日志是两种不同的答案。队列把消息当任务,ack 之后删除;日志把消息当事实,追加之后保留,消费者用 offset 表示自己读到哪。选错模型,后面所有补偿都是在给语义打补丁。本章先讲清这两种模型,再讲投递语义、分区与消费者组、offset 提交、Kafka 的复制与事务,最后落到 outbox、CDC、事件溯源,以及一条 offset 提交失败导致的二次扣款。

路线是:7.1 队列与日志;7.2 三种投递语义;7.3 分区、顺序与消费者组;7.4 offset 提交与再平衡;7.5 ISR、acks、幂等生产者与事务;7.6 outbox、CDC 与事件溯源。后半程是四个必须单独成节的事:重复投递与乱序(7.7)、consumer lag 与 rebalance 的观测(7.8)、LinkedIn 的 Kafka 规模与运维(7.9),以及 offset 提交失败后的二次扣款(7.10)。最后是事件设计清单、陷阱和出口检查。

一句话先放在这里:消息系统的语义不是配置出来的,是生产者、broker、消费者三方行为组合出来的。

7.1 队列与日志:删除 vs 保留

先看两个方向相反的工单。

工单 A:一个发邮件的任务队列,消息的可见性超时是 30 秒,处理函数偶尔要跑 45 秒。超时后 broker 把同一条消息投给另一个 worker,两个 worker 都发了邮件。用户收到两封。

工单 B:一个 Kafka 消费者组停机了五天,topic 的保留期是三天。恢复时 consumer 的 offset 已经落在日志起点之前,客户端报 OffsetOutOfRange,按策略重置到 latest,五天的事件被跳过。下游报表少了一周的数据。

一个多了,一个少了,但问的是同一个问题:消息被消费之后,还应该存在多久? 队列的回答是「ack 就删」,日志的回答是「到期才删」。这个差异决定了消费进度归谁管、能不能重放、慢消费者会看到什么。

维度队列(SQS、RabbitMQ)日志(Kafka、Pulsar、Kinesis)
基本单位一条消息一个分区内的一条记录
生命周期ack/delete 后删除追加后按保留策略删除或压实
消费进度broker 记录每条消息的投递/确认状态消费者组自己提交 offset
顺序单队列近似 FIFO;多消费者竞争单分区严格按 offset
重放只能重投未 ack/DLQ 的消息重置 offset 即可重放(保留期内)
扩展方式增加竞争消费者增加分区
慢消费者的后果积压;可见性超时后重复投递lag 增长;超过保留期则数据缺口
典型失败重复消费、死信堆积重复消费、offset 越界、分区倾斜

这张表里最重要的是第三行:队列的进度在 broker,日志的进度在消费者。队列可以在超时后把消息再给你一次,因为它知道你没 ack;日志不会主动找你,它只在你回来时告诉你「你上次说到 4,但日志现在从 100 开始了」。

定义 7.1.1 · 队列与日志

队列(queue)把每条消息当作一个待办任务:broker 持有消息状态,消费者 ack 后删除;未 ack 的消息在可见性超时后重新投递。日志(log)把每条记录当作一个不可变的事实:只能追加,按分区内的 offset 排序,broker 按保留策略删除整段,消费者组通过提交 offset 声明自己的进度。

两者的核心差异不是「有没有分区」,而是消费进度由谁拥有:队列由 broker 拥有,日志由消费者拥有。进度归谁,谁就负责重复与丢失的语义。

日志里的 offset 是一个 64 位整数,表示记录在分区内的位置。记录一旦写入,offset 就固定;新记录追加在末尾。消费者提交的 committed offset 是「下一条要读的位置」,不是「最后一条读过的位置」。如果处理完 offset 5 之后提交,提交的值是 6。

日志、消费者 offset 与重复投递 消息 0–7 按顺序写入;消费者处理到 5,但 offset 只提交到 4,崩溃后 5 被重投。 日志、消费者 offset 与重复投递 分区日志(append-only) msg 0 offset 0 msg 1 offset 1 msg 2 offset 2 msg 3 offset 3 msg 4 offset 4 msg 5 offset 5 msg 6 offset 6 msg 7 offset 7 已提交 offset = 4 已处理到 5,但提交前崩溃 重启后从 5 重投 msg 5 再来一次 副作用可能翻倍 至少一次 = 允许重复;有效一次 = 业务去重 + 原子提交。
读图:「处理成功」和「offset 提交成功」是两个动作;两者之间崩溃,就会重复。把业务写与 offset 放在同一个事务里,或者让消费幂等,才能把重复变成无害。

图中消费者处理到 msg 5,但只提交到 4。按图里的「最后处理位点」口径,4 及之前已确认,重启从 5 开始读,msg 5 被再处理一次。Kafka API 的 committed offset 是「下一条要读」,同一个状态提交的值是 5;两种口径差一位,读监控、日志和文档时必须先确认用的是哪一种,否则会多算或少算一条。「处理成功」和「提交成功」是两个动作,两者之间有一个无法消除的窗口;这个窗口就是本章后面所有去重逻辑的起点。

还有三个位置需要区分,它们经常被混为一谈:

位置含义谁维护
LEO(log end offset)分区末尾下一条记录的 offsetbroker
HW(high watermark)已复制到 ISR 的记录位置;消费者只能读到 HW 之前broker
committed offset消费者组下一条要读的位置consumer group
LSO(last stable offset)事务隔离下,read_committed 消费者能读到的最远位置broker

LEO - committed offset 就是常说的 lag。它不是「有多少条没处理」,而是「有多少条还没提交」;处理了但没提交的记录也算在 lag 里。这个区别在 7.8 会用到。

保留策略不是一个运维参数,而是日志对外契约的一部分。Kafka 的 retention.ms/retention.bytes 按段(segment)删除,段文件名就是该段的 base offset,例如 00000000000000000123.log 表示这个段从 offset 123 开始。删除以段为单位,所以实际保留时间可能比配置多出一个段。消费者停机时间一旦超过保留期,恢复时就会遇到 OffsetOutOfRange;此时 auto.offset.reset 决定它是跳到最早还是最新,两种都会留下数据缺口。

日志还有另一种保留方式:压实(compaction)。它不按时间删除,而是对每个 key 只保留最后一条记录,更早的版本在后台被清理;删除某个 key 则写一条 value 为空的墓碑(tombstone),过一段时间连墓碑也被清理。压实适合「用日志保存一张表的当前状态」,例如 Kafka 里保存消费者偏移的 __consumer_offsets,或者事件溯源里的快照流。压实之后 offset 会出现空洞,所以 offset 不能当消息 ID 用。

保留方式保留什么适合风险
按时间删除最近 N 天/小时的所有记录事件流、日志采集停机超过 N 天就丢数据
按大小删除最近 N GB吞吐大、价值密度低流量突增会提前删掉旧数据
压实每个 key 的最后一条状态表、changelog、事件溯源快照历史版本不可重放;offset 有洞
分层存储热数据在 broker,冷数据在对象存储长保留、低成本重放重放延迟高;依赖版本

队列也有「保留」,只是表达方式不同:可见性超时、消息 TTL、死信队列。它们的共同点是由 broker 决定消息还能不能被看到;日志则把「还能看多久」写进了保留策略,消费者必须自己保证在窗口内读完。

ack 不等于处理成功

队列的 ack 只表示「这条消息可以删了」,不表示业务处理成功。很多实现把 ack 放在业务事务提交之前,进程崩溃后消息已删、业务没做,形成丢失。日志的 commit 同理:它只表示「这个消费者组读到这里了」,不表示副作用已经落库。ack/commit 是投递层的确认,不是业务层的确认;两者之间必须有一个幂等或事务边界来兜底。

7.1.1 什么时候该选哪一个

两种模型没有优劣,只有匹配。把业务问题压缩成五个问题,答案会指向其中一边:

问题偏向队列偏向日志
一条消息只该被一个 worker 处理?是否(多个消费者组各自读全量)
处理完还需要回看/重放?很少经常
需要严格的全序还是按 key 有序?单队列 FIFO分区内有序
消费者出错后,重投递由谁负责?broker(可见性超时/DLQ)消费者(offset + 幂等)
峰值吞吐靠什么扩?增加竞争消费者增加分区

典型的错配是把「任务」放进日志:任务要求「一个任务只执行一次」,日志却允许多个消费者组各读一遍;要做对,必须给任务加唯一 ID、去重表和完成状态,等于在日志上重建队列。反过来,把「事件流」放进队列:事件需要回放给新消费者、需要保留一周做审计,队列 ack 就删,做不到;只能靠 DLQ 和额外的归档表拼出来。

一个折中的判断方法是问「这条消息的价值在消费之后是否还存在」。任务完成后价值就结束了,队列合适;事件是事实,消费之后仍然对审计、重放、新消费者有价值,日志合适。如果一条消息既需要「只做一次」又需要「永久可回放」,通常要用日志 + 幂等消费,而不是队列 + 归档。

7.2 投递语义:最多一次、至少一次、有效一次

讨论「消息会不会丢」之前,先接受一个事实:发送方无法区分「消息没到」和「消息到了但确认丢了」。这是第 1 章 unknown 状态在消息系统里的翻版。面对这个不确定性,只有两种选择:

  • 不重试:最多一次。确认丢了就丢了,至少不会重复。
  • 重试:至少一次。确认丢了就再发,重复由接收方处理。

「恰好一次投递」在不可靠网络上做不到;工程上说的 exactly-once 指的是恰好一次效果(effectively once):消息可能被投递多次,但副作用只发生一次。这个区别不是文字游戏,它决定了你在哪一层写去重代码。

定义 7.2.1 · 三种投递语义

最多一次(at-most-once):每条消息最多被处理一次,可能丢失,不会因重投而重复。至少一次(at-least-once):每条消息至少被处理一次,不丢,但可能重复。有效一次(effectively-once):消息仍可能被投递多次,但业务效果只发生一次;靠消费者幂等、去重表或「业务写 + offset 提交」的原子事务实现。

「有效一次」的作用域是 (消费者, 目标存储) 这一对,不是整个系统。同一个事件被记账、通知、风控三个消费者各读一次,是设计如此;每个消费者各自保证自己的效果一次。

语义生产者做什么消费者做什么崩溃时典型场景
最多一次发完不等确认,或不重试先提交 offset 再处理丢消息指标采集、可丢的日志
至少一次失败重试,直到确认先处理再提交 offset重复消息订单、支付、审计
有效一次重试 + 幂等生产者幂等处理,或业务写与 offset 同事务不丢不重(效果)记账、库存、扣款

表中「先提交再处理」和「先处理再提交」只差一行代码,语义却相反。很多团队说「我们用的是至少一次」,代码却是自动提交 + 异步处理,实际是最多一次:offset 已经提交,异步任务还在队列里,进程一挂就丢了。

重复不只来自消费者。把一条消息从产生到消费拆开,至少有七个位置会产生重复:

阶段重复机制谁来兜底
生产者发送发送超时后重试,broker 其实已写入幂等生产者,或事件 ID 去重
broker 确认写入成功,ack 在回程丢失同上
outbox relay发布成功,标记 published_at 失败消费端按 event_id 去重
CDC 快照全量快照与增量日志重叠消费端按主键 + 版本去重
消费者处理处理成功,offset 提交失败消费端去重表
再平衡分区转给新消费者,从旧 offset 重来消费端去重表
人工重放为了修数据重置 offset重放隔离 + 幂等

丢失的位置同样多:acks=0/1 时 leader 崩溃、自动提交早于处理、捕获异常后继续提交、保留期短于停机时间、生产者缓冲区满后丢弃、进程退出前没有 flush。投递语义是三方行为的组合,不是 broker 的一个开关。

一个常见的误解是「Kafka 是至少一次,所以我的应用也是至少一次」。Kafka 只保证「在保留期内,已提交 offset 之后的记录还能读到」;consumer 完全可以用自动提交把它变成最多一次,也可以用「处理成功但提交失败」把它变成重复。broker 提供的是原材料,语义是应用组装出来的。

# 最多一次:先提交,再处理
for msg in consumer:
    consumer.commit(msg.offset + 1)
    handle(msg)              # 这里崩溃 -> 消息永远不会再来

# 至少一次:先处理,再提交
for msg in consumer:
    handle(msg)              # 这里崩溃 -> 消息会从旧 offset 重来
    consumer.commit(msg.offset + 1)

两段代码的差别只有一行,但第二段要求 handle 是幂等的,否则「至少一次」就变成「至少两次副作用」。第 1 章的幂等键、第 6 章的去重表,在这里重新出现;消息系统只是把这个问题从「一次 RPC」放大成「一条流」。

有效一次不是「只投递一次」

即使 broker 只把消息放进分区一次,消费者仍可能在「处理成功、提交失败」的窗口里重读它。有效一次从来不是投递层的属性,而是「投递层允许重复 + 业务层拒绝重复效果」的组合。任何声称「我们保证 exactly-once 投递」的系统,要么在偷换「投递」和「效果」的定义,要么把作用域限制在了它自己管理的存储里。

7.2.1 重复不是小概率

「重复率很低」是很多团队不做幂等的理由。把绝对值算出来,这个理由通常站不住。假设一个 topic 每天 1 亿条事件,处理成功率 99.9%,每天有 10 万条失败;失败重试和 rebalance 重放加起来,只有 1% 的处理会产生重复效果,也是每天 1000 次。对支付来说,1000 次重复扣款不是「小概率」,是事故。

rebalance 会把重复集中放大。一个每秒 1 万条的分区,滚动重启触发 5 分钟的重放窗口,涉及约 300 万条记录;如果其中 0.1% 的处理器不幂等,就是 3000 次重复。重复的规模取决于「一次重放涉及多少条」和「处理器有多不幂等」,而不是取决于「重复率」。

场景重放记录数不幂等比例重复效果
单条失败重试11%0.01 次
一批 500 条重放5001%5 次
5 分钟分区重放(1 万/s)3,000,0000.1%3,000 次
全量重放一天(1 亿条)100,000,0000.01%10,000 次

这张表解释了两件事。第一,重放的批次越大,幂等越重要;单条重试时可以靠人工发现,百万级重放只能靠代码。第二,去重表的容量要按「最大重放窗口」估算,而不是按「日常重复率」估算;如果去重记录只保留 7 天,而一次补数重放跨越 30 天,前面的重复就挡不住。

7.3 分区、顺序与消费者组

「Kafka 只保证分区内有序」这句话几乎每篇文档都有,但真正要回答的是两个工程问题:你的分区键是什么?这个键会不会变?

分区是 Kafka 里所有事情的单位:顺序、并行度、复制、保留、压实、offset 都以分区为边界。一个 topic 有 N 个分区,生产者按 key 把记录映射到其中一个:

partition = hash(key) % num_partitions

同一个 key 在分区数不变时总是落到同一个分区,因此同一 key 的记录按写入顺序排列。跨 key、跨分区没有全局顺序;消费者组里不同消费者各自处理不同分区,也无法知道另一个分区读到哪。

维度由分区决定直接后果
顺序同一分区内按 offset跨分区无顺序;换 key 就换顺序
并行度一个分区同时只给组内一个消费者消费者数超过分区数会空闲
复制每个分区独立选 leader/ISR副本故障以分区为单位
保留每个分区独立按时间/大小删除冷分区和热分区的保留不同步
压实每个分区独立按 key 压实同一 key 必须落在同一分区才有意义
offset每个分区独立编号offset 不能跨分区比较

分区键的选择直接决定「顺序保证的范围」。如果支付事件用 user_id 做 key,同一用户的事件有序,不同用户之间无序;如果风控需要「同一张卡」有序,就必须用 card_id,而不能指望 user_id。如果两个事件需要相互比较先后(比如 order.created 和 order.cancelled),它们必须用同一个 key,通常是 order_id。

一个经常被忽略的事实是:增加分区会改变 key 到分区的映射。hash(key) % 4 和 hash(key) % 5 对同一个 key 通常给出不同结果,原来在同一分区里的两个事件会被拆到两个分区,顺序保证当场断裂。Kafka 不能减少分区,增加分区也不迁移已有数据;已经写入的记录留在旧分区,新记录按新映射走。于是同一 key 的历史和未来可能落在不同分区,消费者看到的是两段各自有序、彼此无序的流。

这件事的工程含义是:分区数要在建 topic 时按未来一到两年的峰值定,不能等不够了再加。如果确实要扩,正确做法是接受「迁移窗口内该 key 无序」,在消费端用版本号/序号拒绝旧事件,或者双写新旧 topic 做一次受控迁移。直接把分区数从 4 改成 8,然后假设顺序还在,是很多「偶发乱序」工单的根因。

topic payments.charged(4 个分区)

P0: [offset 100][101][102] ...   ──▶  consumer C1(ledger-1)
P1: [offset 200][201] ...        ──▶  consumer C1
P2: [offset 300][301][302] ...   ──▶  consumer C2(ledger-2)
P3: [offset 400] ...             ──▶  consumer C2

group ledger:C1 拥有 P0/P1,C2 拥有 P2/P3
group notify:C3 拥有 P0–P3(不同组,独立 offset)

消费者组(consumer group)是分区分配的容器。同一个组内,一个分区同时只能被一个消费者拥有;不同组之间互不影响,各自读到全量消息、各自维护 offset。这就是为什么「加一个消费者」有时能扩容、有时只是多一个空闲进程:并行度的上限是分区数。

现象原因处理
加了消费者,lag 不降消费者数 ≥ 分区数,多出的空闲加分区(接受重映射)或减少消费者
一个分区 lag 很高,其他接近 0分区键倾斜,热 key 落在一个分区加盐/拆 key/独立 topic;加消费者无效
所有分区 lag 同时涨下游变慢或消费者处理能力不足先看下游,再谈扩容
频繁 rebalance消费者进出、max.poll.interval 超时、分区变化静态成员、合作式分配、调批大小
同一 key 的事件乱序分区数变了,或用了 null key恢复原分区数/固定 key;消费端版本号

热点分区值得单独说。假设一个 topic 有 12 个分区,key 是 tenant_id,其中一个租户占了 60% 的流量,那么这个分区会一直 lag,其他 11 个分区闲着。加消费者没用,因为瓶颈是一个分区;加分区也没用,因为同一个租户仍然落在一个分区。可选的做法是:给热 key 加后缀拆成多个子分区(但会破坏该租户的全局顺序)、给大租户单独建 topic、或者在业务层做预聚合。没有一种做法能同时保住「单 key 全局有序」和「无限并行」,必须选一个。

rebalance 的触发条件比想象中多:消费者加入/离开、消费者崩溃、max.poll.interval.ms 超时(处理一批太久)、订阅的 topic 分区数变化、订阅关系变化、coordinator 切换。每次 rebalance 都意味着分区所有权转移,未提交的进度可能被重放。7.4 会把这条时间线拆开。

7.3.1 分区数怎么估

分区数不是拍脑袋定的,它同时受三个下界约束:

partitions >= ceil(峰值写入 MB/s / 单分区安全写入 MB/s)
partitions >= 期望的消费者并行度
partitions >= 峰值 key 基数 / 单分区可接受的 key 数   # 防止热分区

第三个下界经常被忽略。如果 key 的基数只有 100,分区开到 64,看起来并行度很高,实际上大部分 key 还是会挤在少数分区;反过来,key 基数很大但流量集中在前 1% 的租户,也要按「最热 key 的流量」而不是「平均流量」来估。

一个算例:峰值写入 60 MB/s,单分区在 acks=all、3 副本、压缩后能稳定吃 8 MB/s,消费者希望 12 个并行,那么:

max(ceil(60 / 8), 12) = max(8, 12) = 12
加 30% 余量:ceil(12 * 1.3) = 16

最终定 16 个分区。这个数字要写进容量文档,和「为什么不是 8、不是 64」一起评审。单分区 8 MB/s 不是常数,它取决于消息大小、压缩率、副本同步、磁盘和网络;用自己的流量压测一遍,不要抄别人的数字。

分区太少分区太多
并行度不够,消费者空闲元数据膨胀,controller 压力大
热 key 集中,单分区 lagrebalance 变慢,恢复时间变长
单分区文件大,保留/压实变慢每分区批次变小,吞吐下降
扩容时要重映射 key打开文件句柄、内存和监控成本上升

分区数的调整只有「增加」一个方向,而且增加的那一刻改变 key 映射。所以它更像「建表时选主键」,而不是「运行时调线程池」;宁可一次多留 30% 余量,也不要在业务高峰做重映射。

7.4 Offset 提交与再平衡

offset 提交是消费者唯一能在崩溃后留下的东西。处理结果在数据库里,进度在 coordinator 里;两者不在一个事务里,就一定有窗口。

先看自动提交。enable.auto.commit=true 时,客户端每隔 auto.commit.interval.ms(默认 5 秒)提交一次,提交的是最近一次 poll 返回的记录的末尾,而不是「已经处理完的记录」。如果处理是异步的、或者一批记录处理到一半失败,自动提交可能把没处理完的记录也提交掉:

poll -> [100, 101, 102, 103]
处理 100 成功,101 失败
自动提交 offset=104        # 102、103 永远不会再被这个组读到

这就是「自动提交 + 异步处理」等于最多一次的原因。自动提交不是错,错在把它和处理进度当成同一件事。

手动提交把控制权拿回来,但有三种写法,语义不同:

写法提交时机崩溃时语义代价
每条同步提交handle 返回后 commitSync重放当前条至少一次每条一次 RPC,吞吐低
每批同步提交整批 handle 后 commitSync重放整批至少一次批内重复,吞吐好
异步周期提交handle 后 commitAsync可能提交了更大的 offset可能丢/重复快,但失败不重试
事务提交业务输出与 offset 同事务原子回滚或提交Kafka 内有效一次延迟高,仅限 Kafka

异步提交有一个隐蔽的坑:commitAsync(offset=104) 失败后,下一次 commitAsync(offset=110) 成功,104–109 就被跳过了。异步提交不会自动重试失败的那一次;后一次成功的大 offset 会覆盖前一次,中间段就永远不会被重放。所以失败的那一批只能靠业务幂等或重新处理来兜底;在 rebalance 的回调里必须用同步提交,否则分区已经转走,异步提交会直接失败。

定义 7.4.1 · 已提交 offset 与处理位点

已提交 offset(committed offset)是消费者组向 coordinator 声明的「下一条要读的位置」,持久化在 __consumer_offsets 里,跨进程、跨重启有效。处理位点(processed position)是应用实际完成副作用的最后一条记录,通常只存在于应用内存或业务表里。

两者一致时,重启不会重复;两者不一致时,重复或丢失就发生了。所有「至少一次」的重复,本质上都是「处理位点领先于已提交 offset」。

再平衡把这个问题放大。考虑下面这条时间线:

t0  C1 拥有 P0,已提交 offset=100,poll 到 100..109
t1  C1 处理 100(扣款成功),101..109 还在本地队列
t2  发版/GC/下游慢,C1 超过 max.poll.interval.ms 没有 poll
t3  coordinator 认为 C1 失联,generation 42 -> 43,P0 分配给 C2
t4  C1 处理完 109,调用 commitSync(110)
t5  coordinator 拒绝:generation 已变(CommitFailedException)
t6  C2 从已提交 offset=100 开始,100..109 全部重放
t7  如果 handle 不幂等,100 被扣第二次

注意 t4 的提交被拒绝,不是因为网络,而是因为 C1 已经是一个「僵尸消费者」:它失去了分区所有权,却还在处理旧数据。Kafka 用 generation 来 fencing 这种提交,这是对的;但fencing 只能挡住提交,挡不住已经发生的副作用。扣款在 t1 已经完成,t5 的拒绝只是让这个事实暴露得更晚。

max.poll.interval.ms 是这条时间线上最容易被忽略的参数。它约束的是「两次 poll 之间的最长时间」,不是「一条记录的处理时间」。如果一批 500 条记录、每条 100 ms,一批就要 50 秒;默认 5 分钟的间隔看似够,但下游一抖动就会超。超时后 consumer 被踢出组,分区转走,处理到一半的批次变成重复。调小 max.poll.records 比调大 max.poll.interval.ms 更安全:前者让每批更短,后者只是把炸弹推迟。

再平衡本身也在进化。早期的 eager 协议会让所有消费者先放弃全部分区,再重新分配,整个组停摆几秒到几分钟;cooperative sticky 只迁移需要移动的分区,其他分区继续消费。静态成员(group.instance.id)让滚动重启的消费者在 session.timeout.ms 内保留成员身份,避免每次重启都触发一轮 rebalance。这些机制减少的是停摆时间,不是重复本身;重复仍然要靠幂等处理。

正确的消费循环(示意)

while running:
    records = consumer.poll(500ms)
    for r in records:
        handle(r)                    # 必须幂等
    consumer.commitSync()            # 处理完才提交

on_partitions_revoked(parts):
    consumer.commitSync()            # 尽力而为;失败就交给下游去重
    drain_local_queue()              # 停止接收新活,处理完手上的

这段伪代码有两个细节:提交在处理之后,以及 revoke 时先提交再清空本地队列。第二点经常被漏掉:分区被收走时,本地队列里还有没处理完的记录,如果直接退出,这些记录会在新消费者那里重放;如果先处理完再提交,重放的是「已提交 offset 之后」的部分,重复更少。

如果输入和输出都在 Kafka,可以用事务把「写输出」和「提交 offset」合成一个原子操作:sendOffsetsToTransaction 把 consumer offset 写进 producer 的事务,commitTransaction 同时生效。这是 7.5 要讲的内容。但即使这样,外部数据库或 HTTP 副作用仍然不在事务里,还是需要业务幂等。

提交失败不是异常,是设计输入

commitSync 抛 CommitFailedException、超时、或者进程在提交前被 kill,都是正常运行的一部分。设计时应该假设「每次处理都可能被重放」,而不是假设「提交一定成功」。把提交失败当异常日志打出来,然后继续跑,是很多重复扣款的直接原因。

7.4.1 提交失败的四种形态

「提交失败」不是一个单一事件,它对数据的影响取决于失败发生在哪一步:

形态表现offset 可能已提交?重放范围后果
generation 冲突CommitFailedException否上次已提交 offset 之后重复,需幂等
提交超时/响应丢失客户端不知道成功与否可能上次已知 offset 之后重复,需幂等
进程在提交前被 kill无返回否当前批次重复,需幂等
revoke 时先提交后清队列提交成功,队列未处理完是不重放丢失,最危险
异步提交乱序大 offset 覆盖小 offset是中间段不重放丢失,需单调提交

前三行是「至少一次」的重复,后两行是「最多一次」的丢失。很多人只盯着 CommitFailedException,却忽略了 revoke 回调里的顺序:如果在 onPartitionsRevoked 里先 commitSync()、再清空本地队列,那些还没处理的记录已经被 offset 越过了,新消费者不会再来一遍。提交的时机比提交的 API 更重要。

异步提交的乱序是另一个隐蔽的丢失源:commitAsync(104) 还在路上,commitAsync(110) 已经成功,broker 只保留较大的 110,104–109 永远不会被重放。修法是维护一个单调递增的「已提交位点」,只在成功回调里推进,失败时不要提交更大的 offset;或者在 rebalance 时用同步提交兜底。

7.5 Kafka 的 ISR、acks、幂等生产者、事务

先回答一个常见疑问:acks=all 为什么还会丢数据?

因为 acks=all 等的是当前 ISR 里的所有副本,不是「配置的 replication factor 个副本」。ISR(in-sync replicas)是「跟得上 leader 的副本集合」,判断标准是副本最后一次成功 fetch 的时间与 leader 的差距(replica.lag.time.max.ms)。如果一个 follower 因为磁盘慢、网络抖动或 GC 落后太多,它会被踢出 ISR;此时 ISR 可能只剩 leader 自己,acks=all 就退化成 acks=1。

acks等什么leader 崩溃时吞吐适用
0不等待,发出去就算成功可能丢最高可丢的指标/日志
1leader 本地写入未复制的部分丢失高可容忍少量丢失
all(-1)ISR 全部写入ISR 内的数据不丢较低订单、支付、审计

min.insync.replicas 是把 acks=all 变成有意义承诺的配套参数。它规定「至少要有多少个 ISR 才接受写入」;当 ISR 小于这个值时,producer 收到 NotEnoughReplicas,写入失败。这是用可用性换持久性:副本不足时宁可拒绝写,也不接受可能丢的写。典型配置是 replication factor=3、min.insync.replicas=2、acks=all,容忍一个 broker 故障;如果三个副本在同一个机架或同一个可用区,一次机架掉电仍然可能带走多数派。

ISR 状态acks=all 的实际含义结果
3 副本都在 ISR等 3 个副本写入容忍 1 个故障
2 副本在 ISR等 2 个副本写入容忍 1 个故障,但余量变小
只剩 leader等 leader 本地写入与 acks=1 相同
ISR < min.insync.replicas拒绝写入可用性下降,但不丢已确认写

还有一个更隐蔽的丢数据入口:unclean leader election。当 leader 挂掉、ISR 里没有可用的副本时,如果允许「非 ISR 副本」当 leader,集群能恢复可用,但会丢掉未同步的记录。unclean.leader.election.enable=false 是大多数业务 topic 的默认选择;把它设成 true,等于接受「可用性优先于已确认写」。

HW(high watermark)是消费者能读到的位置。leader 把记录写入本地日志后,要等 ISR 副本都 fetch 到,才推进 HW;consumer 只能读到 HW 之前的记录。这保证了「消费者读到的数据不会因为 leader 切换而消失」。事务场景下还有 LSO(last stable offset):read_committed 消费者只能读到 LSO 之前、且事务已提交的记录,未提交事务之后的记录一律不可见。

定义 7.5.1 · ISR 与 high watermark

ISR 是与 leader 保持同步的副本集合,按「最后一次成功 fetch 的时间」判定,不是按配置的副本数。HW 是 ISR 都已复制的日志位置,消费者只能读到 HW 之前。LSO 是事务隔离下 read_committed 消费者可见的上界。

acks=all 的承诺是「写入当前 ISR」,不是「写入所有配置副本」;min.insync.replicas 才是防止 ISR 缩水后承诺失效的闸门。

幂等生产者解决的是另一类重复:producer 发送超时后重试,broker 其实已经写入。开启 enable.idempotence=true 后,producer 启动时从 broker 拿到一个 PID(producer id),并为每个分区维护单调递增的 sequence;broker 按 (PID, partition, sequence) 去重,重复的批次直接丢弃,同时保证分区内的顺序。

它有三个边界:

  1. 只在同一个 producer 会话内有效。producer 重启会拿到新 PID,之前的 sequence 记录不再匹配;跨会话的重复仍然要靠业务去重。
  2. 只覆盖 Kafka 内部的写入。它不知道你的数据库、HTTP 调用有没有重复。
  3. 有配置前提:需要 acks=all、retries>0、max.in.flight.requests.per.connection<=5。在不开启幂等时,max.in.flight>1 加上重试会导致批次乱序;开启幂等后,broker 会拒绝乱序的 sequence,producer 也会保序。

事务把范围再扩大一步。给 producer 配置 transactional.id 后,initTransactions() 会向 broker 注册并 fence 掉同 ID 的旧实例(epoch 递增),然后可以:

producer.initTransactions()
producer.beginTransaction()
producer.send(topicA, record1)
producer.send(topicB, record2)
producer.sendOffsetsToTransaction(offsets, groupMetadata)
producer.commitTransaction()        # 或 abortTransaction()

sendOffsetsToTransaction 把消费者 offset 和输出记录放进同一个事务:要么输出可见且 offset 前进,要么都不可见且 offset 不动。消费者设置 isolation.level=read_committed 后,只能读到已提交事务的记录,未提交的记录被 LSO 挡住。这就是 Kafka 里「读-处理-写」的有效一次。

保证机制作用域不覆盖什么
重试不重复写入幂等生产者(PID + sequence)单个 producer 会话、单个分区跨会话、跨系统副作用
多分区原子写事务(transactional.id)Kafka 的多个分区 + offset数据库、HTTP、邮件
消费不重复read_committed + LSOKafka 内部外部存储的重复
端到端有效一次上述机制 + 业务幂等整条链路需要业务自己实现

这张表的最后一行是关键:Kafka 的事务不是分布式事务。它不能把「写数据库」和「发消息」包在一起,也不能让外部支付接口参与提交。它能做的是「Kafka 内部的输出和 offset 原子」,把跨系统的部分留给 outbox 和幂等(7.6、7.10)。

长事务还有一个运行时代价:未提交事务会挡住 LSO,read_committed 消费者读不到它之后的记录,lag 看起来正常但消费停住。事务超时(transaction.timeout.ms)和 transaction.max.timeout.ms 要匹配,否则 broker 会主动 abort,producer 收到 InvalidTxnTimeout。这类问题在 7.8 的 lag 读数里表现为「lag 不涨也不降」,只看 lag 很容易误判。

副本数不是可用性

replication factor=3 只说明「有 3 份」,不说明「3 份在不同的故障域」。如果 3 个副本在同一台物理机、同一个机架或同一个可用区,一次掉电就能带走全部。Kafka 的 rack awareness(broker.rack)让副本分散到不同机架;云上要把「机架」映射到不同的可用区。副本的故障域比副本数更重要。

7.5.1 ISR 为什么会缩水

ISR 的判定是「follower 最近一次成功 fetch 距现在多久」,超过 replica.lag.time.max.ms 就被踢出。能让 follower 停止 fetch 的原因有四类:

原因现象排查入口
follower 磁盘慢fetch 到了但写不进日志,fetch 线程阻塞磁盘 await、kafka-log-dirs.sh --describe
网络带宽/丢包跨 AZ 复制跟不上,fetch 超时网卡吞吐、重传、跨 AZ 延迟
follower GC/CPU 饥饿fetch 线程拿不到 CPU,心跳还在但数据不前进GC 日志、CPU throttling
leader 写入过慢follower 没有新数据可 fetch,时间戳也会老化leader 磁盘、ISR 抖动

注意第三行:心跳正常不代表 ISR 正常。broker 之间有心跳,但 ISR 看的是数据 fetch 的进度;一个 GC 停顿的 follower 可能还在回心跳,却已经几秒没有 fetch,照样会被踢出。所以「broker 活着」和「副本同步」是两个监控项。

Kafka 用时间而不是字节数判断落后,是有原因的。按字节判断时,一条大消息就能让副本「看起来落后很多」,产生误报;按时间判断更接近「这个副本还能不能赶上」。LinkedIn 的公开材料也提到,他们把副本落后的判断从字节改成了时间,避免大消息导致的假告警。监控 ISR 要看时间维度的持续性,不要看某一个瞬间的字节差。

ISR 缩水时,acks=all 的承诺跟着缩水:3 副本变 2 副本还能写,变 1 副本就只等 leader;如果 min.insync.replicas=2,1 副本时 producer 会收到 NotEnoughReplicas,写入失败。这不是 bug,是设计:宁可让写入失败,也不让客户端以为写成功了、实际上只在一个随时会挂的 leader 上。

7.6 Outbox、CDC 与事件溯源

第 6.7 节已经讲过 outbox 解决双写:业务行和消息行在同一个本地事务里提交,relay 负责把消息搬出去。这一节换一个视角:outbox 是一张日志表,relay 是一个生产者,consumer offset 是另一张日志的位置。一条事件从数据库到消费者,至少经过三段日志:数据库的 WAL/binlog、outbox 表、Kafka 的分区日志。每一段都有自己的顺序、保留和重复窗口。

先看表结构。下面是 PostgreSQL 版本,重点是约束:

CREATE TABLE orders (
  order_id      TEXT PRIMARY KEY,
  account_id    TEXT NOT NULL,
  amount_cents  BIGINT NOT NULL,
  status        TEXT NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE outbox (
  id            BIGSERIAL PRIMARY KEY,
  event_id      UUID NOT NULL UNIQUE,
  aggregate_id  TEXT NOT NULL,
  aggregate_seq BIGINT NOT NULL,
  event_type    TEXT NOT NULL,
  payload       JSONB NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at  TIMESTAMPTZ,
  UNIQUE (aggregate_id, aggregate_seq)
);

CREATE INDEX outbox_pending ON outbox (id) WHERE published_at IS NULL;

CREATE TABLE processed_events (
  consumer     TEXT NOT NULL,
  event_id     UUID NOT NULL,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (consumer, event_id)
);

四个约束各有作用:event_id 唯一,保证同一条事件不会被业务重复写入;(aggregate_id, aggregate_seq) 唯一,保证同一个聚合的事件序号不重复,消费端可以用它拒绝旧事件;outbox_pending 是部分索引,只索引未发布的行,relay 扫描不会随着历史增长而变慢;processed_events 的主键是 (consumer, event_id),不同消费者各自去重,互不干扰。

下面是一个可运行的 SQLite 版本,演示三件事:outbox 与业务同事务、relay 在「发布成功、标记失败」时重发、消费者在「处理成功、提交 offset 失败」后重放但不重复扣款。代码只用标准库,直接 python3 outbox_demo.py 即可运行。

import json, sqlite3, uuid

SCHEMA = """
CREATE TABLE orders (
  order_id TEXT PRIMARY KEY, amount_cents INTEGER NOT NULL, status TEXT NOT NULL
);
CREATE TABLE outbox (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  event_id TEXT NOT NULL UNIQUE,
  aggregate_id TEXT NOT NULL,
  aggregate_seq INTEGER NOT NULL,
  event_type TEXT NOT NULL,
  payload TEXT NOT NULL,
  published_at TEXT,
  UNIQUE (aggregate_id, aggregate_seq)
);
CREATE TABLE processed_events (
  consumer TEXT NOT NULL, event_id TEXT NOT NULL,
  PRIMARY KEY (consumer, event_id)
);
CREATE TABLE balances (
  account_id TEXT PRIMARY KEY, balance_cents INTEGER NOT NULL
);
CREATE TABLE offsets (
  consumer TEXT NOT NULL, topic TEXT NOT NULL, next_offset INTEGER NOT NULL,
  PRIMARY KEY (consumer, topic)
);
"""

def connect():
    conn = sqlite3.connect(":memory:")
    conn.executescript(SCHEMA)
    conn.execute("INSERT INTO balances VALUES ('acct-1', 0)")
    conn.commit()
    return conn

def place_order(conn, order_id, amount_cents):
    event_id = str(uuid.uuid4())
    with conn:                              # 业务行 + outbox 行,同一个事务
        conn.execute("INSERT INTO orders VALUES (?, ?, 'created')",
                     (order_id, amount_cents))
        conn.execute(
            "INSERT INTO outbox (event_id, aggregate_id, aggregate_seq, event_type, payload) "
            "VALUES (?, ?, 1, 'order.created', ?)",
            (event_id, order_id, json.dumps({"order_id": order_id,
                                             "amount_cents": amount_cents})),
        )
    return event_id

def relay(conn, topic, crash_after_publish=False):
    rows = conn.execute(
        "SELECT id, event_id, event_type, payload FROM outbox "
        "WHERE published_at IS NULL ORDER BY id"
    ).fetchall()
    for row in rows:
        topic.append({"event_id": row[1], "type": row[2],
                      "payload": json.loads(row[3])})
        if crash_after_publish:             # 发布成功,标记失败
            raise RuntimeError("relay crashed after publish")
        with conn:
            conn.execute("UPDATE outbox SET published_at = datetime('now') "
                         "WHERE id = ?", (row[0],))
    return len(rows)

def consume(conn, topic, consumer, commit_offset=True):
    row = conn.execute(
        "SELECT next_offset FROM offsets WHERE consumer = ? AND topic = 'orders'",
        (consumer,),
    ).fetchone()
    pos = row[0] if row else 0
    handled = 0
    for event in topic[pos:]:
        with conn:                          # 去重记录 + 副作用,同一个事务
            cur = conn.execute(
                "INSERT INTO processed_events (consumer, event_id) VALUES (?, ?) "
                "ON CONFLICT DO NOTHING", (consumer, event["event_id"]))
            if cur.rowcount == 1:
                conn.execute(
                    "UPDATE balances SET balance_cents = balance_cents + ? "
                    "WHERE account_id = 'acct-1'",
                    (event["payload"]["amount_cents"],))
                handled += 1
            if commit_offset:                # 模拟提交失败时置 False
                conn.execute(
                    "INSERT INTO offsets (consumer, topic, next_offset) "
                    "VALUES (?, 'orders', ?) ON CONFLICT (consumer, topic) "
                    "DO UPDATE SET next_offset = excluded.next_offset",
                    (consumer, pos + 1))
        pos += 1
    return handled

conn = connect()
place_order(conn, "order-1", 100)
topic = []
try:
    relay(conn, topic, crash_after_publish=True)
except RuntimeError as exc:
    print("relay:", exc)
relay(conn, topic)                          # 重试:同一条事件再发一次
print("topic copies:", len(topic))
print("handled, commit failed:", consume(conn, topic, "ledger", commit_offset=False))
print("handled, replay:", consume(conn, topic, "ledger"))
print("balance:", conn.execute("SELECT balance_cents FROM balances").fetchone()[0])

一次运行的输出:

relay: relay crashed after publish
topic copies: 2
handled, commit failed: 1
handled, replay: 0
balance: 100

怎么读:

  • topic copies: 2:relay 在发布后崩溃,重试又发了一次。重复发生在生产端,这是 outbox 的已知代价。
  • handled, commit failed: 1:第一遍处理成功,扣了 100;但 commit_offset=False 模拟提交失败,offset 没有前进。
  • handled, replay: 0:第二遍从 offset 0 重放,processed_events 里已经有这个 event_id,副作用被跳过。重复被消费端挡掉。
  • balance: 100:一次订单只扣一次。

这个 demo 没有模拟「relay 标记成功但消息没发出去」——那需要 broker 端配合。真实系统里 relay 的 publish 是至少一次:先发后标记,发成功标记失败会重发;先标记后发,发失败就丢。Outbox 只能保证「业务数据和消息记录一起提交」,不能保证「消息只发一次」。

CDC(change data capture)把「写 outbox 表」换成「读数据库日志」。Debezium 之类的连接器订阅 MySQL binlog / PostgreSQL logical replication,把每一行变更转成事件;outbox event router 可以把 outbox 表的插入直接路由成 topic 消息。它的好处是业务代码不用改,坏处是表结构变成了事件契约:加一列、改类型、删字段,所有下游都要跟着变。

CDC 也不是「没有重复」。全量快照和增量日志之间有一个重叠窗口:快照读到某行时,binlog 可能已经包含这行的更新,连接器需要按位点/GTID 去重;切换、重启、schema 变更都会重放一段。CDC 的语义仍然是至少一次,消费端仍然要去重。

方案事件来源业务侵入延迟重放主要风险
Outbox 轮询业务库的 outbox 表中(多一张表)轮询间隔从 outbox/topic 重放表膨胀、relay 重复
CDC数据库 WAL/binlog低(不改代码)低从日志位点重放schema 耦合、快照重叠
事件溯源事件日志本身高(改写模型)低全量重放 + 快照版本演进、快照一致性

事件溯源(event sourcing)走得最远:它不把事件当成「数据库变更的通知」,而把事件当成唯一的事实来源。状态不直接写,而是由事件折叠(fold)出来:state = fold(events)。订单的当前状态是 created、paid、shipped 这些事件依次应用的结果;要查当前状态,可以重放,也可以定期做快照。

这带来两个好处:完整的审计历史,以及「回到任意时刻」的能力。也带来两个硬问题:

  1. schema 演进。事件一旦写入就不能改,字段改名、类型变化、语义修正都要靠版本化和 upcaster(读旧版本、转新版本)。不像数据库可以 ALTER TABLE 回填,事件日志里的旧事件永远在那里。
  2. 重放副作用。重放 order.created 时不能再发一次货、再扣一次款。纯投影(读模型)可以重放,副作用必须隔离到单独的、幂等的处理器里。

事件溯源常和 CQRS 一起出现:写侧只追加事件,读侧维护投影。投影的更新必须是幂等的,因为它会被重放;投影的位点也要持久化,否则每次重启都从头折叠。Kafka 的压实 topic 常被用作「状态 changelog」:每个 key 保留最新状态,重启时先读 changelog 再继续消费。压实和事件溯源是互补的,不是同一件事:前者保留每个 key 的最新值,后者保留全部历史。

重放一条历史 topic,最常用的命令是重置消费者组的 offset:

# 先 dry-run,确认要重放的范围
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --group ledger-replay-20261019 --topic payments.charged \
  --reset-offsets --to-earliest --dry-run

# 确认后执行;只影响这个组,不影响在线组
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --group ledger-replay-20261019 --topic payments.charged \
  --reset-offsets --to-earliest --execute

重放的前提是:目标组是新组或已停用的组,在线组不能被重置;消费者是幂等的;重放产生的副作用要么被关闭,要么写到隔离的环境。重放不是「再读一遍」,而是「再执行一遍」;如果把 handle 里的扣款、发货、发短信原样跑一遍,就是一次人为的重复副作用。

CDC 不是 exactly-once

CDC 连接器读的是数据库日志,日志里的每一次变更都会被投递一次。连接器重启、位点提交失败、快照与增量重叠,都会导致重复。CDC 只是把「业务代码写 outbox」换成了「数据库日志自动产出」,至少一次的语义没有变。

Outbox 与 CDC 的三个误解
  1. 「有了 outbox 就不会重复」:outbox 只保证业务行和消息行同事务,relay 仍然是至少一次;重复必须在消费端去掉。
  2. 「CDC 不需要业务改造,所以没有顺序问题」:CDC 按日志顺序读,但消费端多实例、分区重平衡都会打乱处理顺序;顺序要落到分区键上。
  3. 「事件溯源就是把所有表改成 topic」:事件溯源改的是写模型和一致性边界;没有快照、版本化和幂等投影,只会得到一个又大又慢的日志。

7.6.1 outbox 表的增长与清理

outbox 是一张只增不减的表:每次业务写都多一行,relay 发布后只是把 published_at 填上,不会删行。一个月后,relay 的 WHERE published_at IS NULL 还能走部分索引,但表的体积、备份和 vacuum 成本会一直涨。

清理要满足两个约束:已发布的行至少保留到「不会再被重放」的窗口,未发布的行一行都不能删。如果 outbox 同时承担审计职责,已发布的行要归档到冷存储,而不是直接删。

-- 分批删除,避免长事务和锁表
DELETE FROM outbox
WHERE id IN (
  SELECT id FROM outbox
  WHERE published_at IS NOT NULL
    AND published_at < now() - interval '7 days'
  ORDER BY id
  LIMIT 10000
);

这条语句要循环执行到影响行数为 0。配套的索引是 published_at,不是 id:

CREATE INDEX outbox_published_at ON outbox (published_at);

比清理更重要的是监控「未发布的积压」。它和 consumer lag 是两回事:outbox 积压说明 relay 没把消息发出去,下游可能一条都没收到。

SELECT count(*) FILTER (WHERE published_at IS NULL)          AS pending,
       max(now() - created_at) FILTER (WHERE published_at IS NULL) AS oldest_pending
FROM outbox;

oldest_pending 超过「业务可接受的最大发布延迟」就该告警,而不是等到下游发现数据少了。relay 的 lag 是生产端的 lag,consumer 的 lag 是消费端的 lag;两个都要看,只看一个会漏掉一半的链路。

7.7 失败模式:重复投递与乱序

重复和乱序经常被当成两个独立的问题:重复靠去重表,乱序靠分区键。但在真实事故里,它们会叠加成第三种错误:一条旧事件被重投,落在一条更新的事件之后,把状态改回去。去重表只能挡住「同一个 event_id」,挡不住「不同 event_id、但语义更旧」的事件。

看一条订单取消的时间线:

时间事件/动作订单状态
t0写入 e1 order.created(ord-1, seq=1)created
t1用户取消,写入 e2 order.cancelled(ord-1, seq=2)created
t2消费者处理 e1,下游创建订单成功,但响应超时created
t3消费者把 e1 放进重试队列,继续处理 e2cancelled
t4重试 worker 再处理 e1,调用创建订单created(复活)
t5用户看到一个已取消的订单又出现了created

t2 的重复是「处理成功、响应超时」,t4 的重放本身没有错——至少一次语义下它必然发生。错的是 t4 的处理逻辑没有检查「这个订单是否已经取消」,也没有比较 seq。重复是触发器,乱序是放大器:如果 t4 的 e1 带着 seq=1,而消费者已经见过 seq=2,一个简单的 seq <= last_seq 判断就能把它丢掉。

重复对「增量写」和「绝对值写」的破坏方式不同,乱序则相反:

写入方式重复投递乱序
增量 balance -= 100错:扣两次通常可交换,但依赖顺序的业务状态不行
绝对值 balance = 500通常无害:写同一个值错:旧值覆盖新值
绝对值 + 版本号/CAS无害:版本不匹配就拒绝安全:旧版本被拒绝
去重表 + 增量安全:同一 event_id 只应用一次仍可能错:不同 id 的旧事件会通过

这张表说明:去重和顺序是两个不同的问题,不能互相替代。去重表解决「同一条事件被应用两次」;版本号/序号解决「旧事件覆盖新状态」。两者都有的消费者,才能同时挡住重复和乱序。

消费者遇到失败时,重试策略会直接改变顺序:

策略顺序代价适用
原地阻塞重试保持分区内顺序队头阻塞,lag 增长;可能触发 max.poll.interval少量瞬时错误
重试 topic打乱顺序需要按 key 分区 + 版本号/状态机长延迟、大量重试
死信队列移出主流程需要人工/自动回放;回放要幂等毒消息、schema 错误
暂停分区保持顺序整个分区停摆依赖恢复后一次性重放

「原地阻塞重试」看起来最安全,但它会把一条毒消息变成整个分区的停摆:后面的消息都等着,lag 一直涨,最后触发 rebalance,未提交的批次又被重放。重试 topic 把主流程解放出来,代价是顺序;如果重试 topic 不按原 key 分区,同一订单的 created 和 cancelled 会落到不同分区,乱序更严重。重试 topic 也必须用同一个分区键,否则你只是把乱序从主 topic 搬到了重试 topic。

例 7.7.1 · 同一订单的两个 key

订单服务发布两个事件,但用了不同的 key:

  • order.created 用 order_id=ord-1 做 key,落到 P2;
  • order.cancelled 用 user_id=u-9 做 key,落到 P0。

消费者组有两个消费者,C1 拥有 P2,C2 拥有 P0。用户取消得很快,C2 先处理 cancelled:它在本地状态里找不到 ord-1,于是把取消记录成一个「待定取消」或者直接丢弃;随后 C1 处理 created,订单变成 active。取消先到、创建后到,订单复活。

修复不是「让消费者等一等」,而是让两个事件用同一个 key(order_id),保证它们进同一个分区、按写入顺序处理。如果历史数据已经用了两个 key,消费端必须能处理「取消先到」:把取消写成墓碑,等 created 到达时立即应用并标记为 cancelled,而不是假设事件一定按业务顺序到达。

顺序修复通常有三种手段,按成本从低到高:

  1. 版本号/序号:每个聚合维护单调 seq,消费者保存 last_seq;seq <= last_seq 直接跳过,seq == last_seq + 1 应用,出现空洞则缓存或回退重放。缓存要有上限,否则一个永远不来的事件会让内存无限增长。
  2. 状态机:cancelled 是终态,迟到的 created 只能记录为异常事件,不能把状态改回 active。状态机比序号更懂业务,但需要为每个聚合类型单独设计。
  3. 数据库 CAS:UPDATE orders SET status='cancelled', version=version+1 WHERE id=? AND version=?,版本不匹配就拒绝。这把顺序检查下沉到存储层,适合跨消费者、跨服务的场景。

三种手段都不能替代去重:版本号挡住旧事件,去重表挡住同一事件;一个事件可能既旧又重复。

顺序保证的范围就是分区键的范围

不要问「Kafka 有没有顺序」,要问「哪些事件用了同一个 key,这个 key 的分区数会不会变」。如果两个事件需要比较先后,它们必须在同一个分区;如果它们用了不同的 key,任何消费者端的等待都无法恢复它们原本的因果顺序。

7.7.1 生产者重试也会乱序

乱序不只发生在消费端。producer 开启重试、但没开幂等时,max.in.flight.requests.per.connection > 1 允许第二个批次先成功,第一个批次的重试后到,日志里的顺序就反了:

producer                          broker 日志
batch1(seq=1) --超时,进入重试-->   []
batch2(seq=2) --成功------------>   [batch2]
batch1(seq=1) --重试成功-------->   [batch2, batch1]   # 顺序反了

同一个 key 的两条记录,如果分别落在 batch1 和 batch2,消费者看到的顺序就是 batch2 → batch1。这不是消费端能修的:日志本身已经反了,所有从头读的消费者都会看到反序。修法只有两个:把 max.in.flight 降到 1(吞吐下降),或者开启幂等生产者(broker 按 (PID, partition, sequence) 拒绝乱序/重复的批次)。

这也解释了为什么 7.5 说幂等生产者同时保证「不重复」和「分区内有序」:它不只是去重,还给每个批次一个单调序号,让 broker 能判断「这个批次来晚了」。如果业务依赖同一 key 的严格顺序,幂等生产者不是可选项,是前提。

7.8 观测:consumer lag 与 rebalance

lag 是流系统里最容易被误读的指标。它是「位置差」,不是速率,也不是延迟。100 万的 lag 在一个每秒 10 万条的 topic 上是 10 秒,在一个每秒 100 条的 topic 上是近 3 小时;只看绝对值,等于没看。

最常用的一条命令是 kafka-consumer-groups.sh --describe:

kafka-consumer-groups.sh --bootstrap-server broker-1:9092 \
  --describe --group billing-ledger
GROUP            TOPIC              PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG    CONSUMER-ID                                       HOST         CLIENT-ID
billing-ledger   payments.charged   0          184220          184220          0      consumer-billing-ledger-1-6f3a...                 /10.0.3.17   consumer-billing-ledger-1
billing-ledger   payments.charged   1          90104           90574           470    consumer-billing-ledger-1-6f3a...                 /10.0.3.17   consumer-billing-ledger-1
billing-ledger   payments.charged   2          120071          120071          0      consumer-billing-ledger-2-9c11...                 /10.0.3.18   consumer-billing-ledger-2
billing-ledger   payments.charged   3          -               152200          -      -                                                 -            -

怎么读:

列含义异常信号
CURRENT-OFFSET该分区已提交的 offset- 表示这个组从未提交过,或 offset 已过期
LOG-END-OFFSET分区末尾下一条记录的 offset(LEO)持续增长是正常的;突然下降说明日志被截断/重建
LAGLEO - CURRENT-OFFSET- 不是 0,是「不知道」
CONSUMER-ID当前拥有该分区的消费者- 表示没有活跃成员,分区无人消费
HOST / CLIENT-ID消费者实例的位置与逻辑名多行指向同一实例,说明分区分配不均

逐行看这份输出:

  • P0 的 lag 是 0,消费者是 ledger-1,健康。
  • P1 的 lag 是 470,且 ledger-1 同时拥有 P0/P1。如果 470 在几分钟内不降,说明这个消费者处理不过来,或者卡在某个下游。
  • P2 的 lag 是 0,消费者是 ledger-2,健康。
  • P3 的 CURRENT-OFFSET 和 LAG 都是 -,CONSUMER-ID 也是 -:这个分区没有提交过 offset,也没有活跃消费者。它可能是新加的分区,也可能是组已经空了。- 不是 0;如果把它当 0,会误判为「没有积压」。

再看组状态:

kafka-consumer-groups.sh --bootstrap-server broker-1:9092 \
  --describe --group billing-ledger --state
GROUP            COORDINATOR (ID)          ASSIGNMENT-STRATEGY  STATE               #MEMBERS
billing-ledger   broker-3:9092 (3)          range                Stable              2
STATE含义什么时候该紧张
Stable成员稳定,分区已分配正常
PreparingRebalance成员正在加入/退出,等待重新分配超过几十秒,通常有成员卡在 poll
CompletingRebalance正在下发分配方案通常很短;长时间停留说明 coordinator 或成员异常
Empty组没有成员,offset 仍保留计划内停机正常;计划外出现就是 consumer 全挂了
Dead组已被删除,offset 也过期需要重建组/重置 offset

#MEMBERS 是当前成员数。把它和 --members 的分配放在一起看,才能发现「成员在、但分区没分到」的情况:

kafka-consumer-groups.sh --bootstrap-server broker-1:9092 \
  --describe --group billing-ledger --members --verbose
GROUP            CONSUMER-ID     HOST         CLIENT-ID      #PARTITIONS  ASSIGNMENT
billing-ledger   ...             /10.0.3.17   ...            2            payments.charged(0,1)
billing-ledger   ...             /10.0.3.18   ...            2            payments.charged(2)

这里 ledger-2 只分到 P2,P3 没有出现在它的 ASSIGNMENT 里;结合上一条命令,P3 没有 owner。这不是「分配不均」,而是「P3 没人要」——可能是消费者只订阅了部分分区,或者 rebalance 还没完成。--members 的输出比 lag 更能暴露「谁在干活、谁在闲着」。

最后看分区副本:

kafka-topics.sh --bootstrap-server broker-1:9092 \
  --describe --topic payments.charged
Topic: payments.charged  PartitionCount: 4  ReplicationFactor: 3
  Partition: 0  Leader: 1  Replicas: 1,2,3  Isr: 1,2,3
  Partition: 1  Leader: 2  Replicas: 2,3,1  Isr: 2,3
  Partition: 2  Leader: 3  Replicas: 3,1,2  Isr: 3,1,2
  Partition: 3  Leader: 1  Replicas: 1,2,3  Isr: 1,2,3

P1 的 Isr 只有 2,3,比 Replicas 少一个:有一个副本落后了。如果这个 topic 配置了 min.insync.replicas=2,P1 还能写;如果配置是 3,P1 的写入会被拒绝。Isr 变短是 acks=all 退化的前兆,比 lag 更值得告警。

把上面的读数归纳成一张判断表:

读数正常异常先查什么
lag 稳定且接近 0消费跟得上——
lag 单调上升—消费速率 < 生产速率消费者 CPU、下游延迟、分区倾斜
lag 锯齿后回落发版/扩缩容导致的短暂 rebalance每分钟都在锯齿rebalance 频率、max.poll.interval
lag 很高但完全平坦—消费者卡死或分区无 ownerCONSUMER-ID、线程栈、下游连接
单个分区 lag 高—热 key / 分区倾斜key 分布、分区数
lag 为 0 但下游延迟/重复—提交早于处理,或重放提交策略、幂等记录
lag 为 -新组首次启动前组空了、offset 过期--state、auto.offset.reset
Isr < Replicas短暂落后副本故障/磁盘慢broker 日志、磁盘、网络
PreparingRebalance 长时间—成员卡在 pollGC、下游超时、max.poll.records

rebalance 本身也要观测。除了 --state,consumer 日志里的 Revoke previously assigned partitions、(Re-)joining group、Coordinator moved 都是信号;统计每小时的出现次数,比事后看 lag 更能定位「为什么老是抖」。JMX 里的 assigned-partitions、commit-latency-avg、records-lag-max 可以配合看,但先看 --describe 的分区级 lag,它比聚合指标更能指出是哪个分区、哪个消费者。

lag 的绝对值没有意义

判断 lag 是否危险,要同时看三个东西:趋势(在涨还是在降)、速率(生产速率和消费速率)、分布(是所有分区都涨,还是某一个)。一个稳定的 10 万 lag 可能完全健康;一个从 0 开始每分钟涨 100 的 lag 才是事故前兆。

7.8.1 把 lag 换算成时间

lag 只有换算成「追上要多久」才有决策价值。假设当前 lag 是 L,消费速率是 c 条/秒,生产速率是 p 条/秒,那么追平时间近似为:

Tcatch-up=Lc−p,c>p T_{\text{catch-up}} = \frac{L}{c - p}, \quad c &gt; p

如果 c <= p,分母为零或负数,lag 永远不会自己降下来——这时加消费者、限流生产、或者降级下游,必须做其中一个,否则积压只会越来越大。

lag消费速率 c生产速率 p追平时间判断
470100/s80/s23.5 s正常波动
10,0001,000/s500/s20 s可接受
100,0001,000/s900/s1,000 s ≈ 16.7 min接近告警线
100,0001,000/s1,000/s永不必须干预
1,000,0005,000/s4,000/s1,000 s ≈ 16.7 min绝对数字大,但可控

这张表说明两件事。第一,同样的 10 万 lag,追平时间可以从 20 秒到无穷,只看 lag 会得出完全相反的结论。第二,消费速率和生产速率的差比绝对值更重要;c 只比 p 大 10% 时,任何一点抖动都会让差变成负数。

聚合 lag 还会掩盖单分区故障。一个 12 分区的 topic,11 个分区 lag 为 0、1 个分区 lag 为 100 万,总 lag 是 100 万;如果只看总和,会以为「整体还行」,实际上那个分区可能已经卡死。告警要同时看 max(partition_lag) 和「lag 超过阈值的分区数」,而不是只看 sum(lag)。

7.9 真实案例:Kafka 在 LinkedIn 的规模与运维

2019 年 10 月 8 日,LinkedIn 工程博客发表了 Jon Lee 和 Wesley Wu 的《How LinkedIn customizes Apache Kafka for 7 trillion messages per day》。公开的数字是:100 多个 Kafka 集群、4,000 多个 broker、100,000 多个 topic、700 万个分区,每天处理超过 7 万亿条消息;一些大集群超过 140 个 broker,单个集群托管 100 万个 replica。

这些数字本身不是重点,重点是它们带来的约束。把公开材料里的做法按「约束 → 机制」拆开:

规模/约束LinkedIn 的公开做法工程含义
700 万分区定制 controller,减少 UpdateMetadataRequest 的内存占用;缩短 broker 启停时间分区不是免费的并行度;元数据会压垮 controller
100,000+ topic创建 topic 时强制最小副本;按应用计量与配额(Bean Counter)topic 数量是运维成本;创建时就要约束
4,000+ broker、100+ 集群按业务隔离集群;Brooklin 做跨集群复制爆炸半径按集群切;复制是独立的一层
单集群 100 万 replicaCruise Control 自动做分区迁移和自愈;broker 上下线走维护模式手工 reassign 不可扩展;rebalance 必须自动化
每天 7 万亿条broker 级配额、按应用计量(Bean Counter);Avro + schema registry吞吐要能被归因和限流;schema 是共享契约
频繁发版-li 发布分支贴近上游;认证覆盖 rebalance、部署、滚动重启、稳定性、降级变更要按「会不会触发 rebalance」来验证

表中最后一行值得展开。LinkedIn 的 Kafka 分支不是 fork,而是在 Apache Kafka 的 release branch 上打补丁,补丁尽量 upstream-first;每个内部版本发布前要过一轮认证,认证项包括 rebalance、部署、rolling bounce、稳定性和 downgrade。这不是「大厂才需要」的流程,而是「Kafka 的变更会影响分区分配」这个事实的后果:一次 broker 重启、一次分区迁移、一次版本升级,都可能触发 rebalance,而 rebalance 会重放未提交的批次。把 rebalance 当成发布验证的一等公民,比事后查 lag 更划算。

从公开材料里能提炼出四条可迁移的做法,和规模无关:

  1. 分区预算是容量决策,不是创建 topic 时的随手填。分区数决定并行度,也决定 controller 元数据、rebalance 时长和恢复时间;LinkedIn 的 700 万分区需要定制 controller,普通团队即使只有几千个分区,也应该把「分区总数」当成一个要监控的配额。
  2. 保留和复制是按 workload 定的。活动追踪、指标、数据库 CDC 的保留和副本要求不同,混在一个集群里只能取最严格的那个,成本会失控;按用途隔离集群,才能让每个集群的 retention/replication 匹配它的 SLO。
  3. rebalance 要自动化,不要靠 SRE 手工 reassign。broker 故障是常态,Cruise Control 这类工具持续看磁盘、网络、CPU,自动迁移分区;没有自动化,集群越大,人工操作越慢、越容易出错。
  4. lag 要看行为,不要只看阈值。LinkedIn 开源的 Burrow 用滑动窗口看 offset 提交的趋势,把消费者判成 good/degraded/stopped,而不是「lag > 10000 就告警」。阈值告警在流量有季节性的系统里会大量误报。

7.9.1 元数据:700 万分区压垮 controller

Kafka 的 controller 负责维护 topic/partition/replica 的元数据,并把变更下发给 broker。分区数越多,controller 的内存、UpdateMetadataRequest 的体积、broker 启动时拉取元数据的时间都会增长。LinkedIn 的公开材料提到,大集群出现过 controller 内存压力和 controller 故障,甚至级联失败;他们通过复用 UpdateMetadataRequest 对象、减少日志、缩短 broker 启停时间等补丁来缓解。

这说明「分区数」不只是并行度参数,还是元数据预算。一个中等规模的集群,如果有几千个 topic、每个 topic 几十个分区,分区总数很容易到十万级;这时 controller 的堆、元数据下发延迟、broker 启动时间都要纳入容量规划。监控至少要有:

指标为什么
controller 堆使用与 GC 停顿元数据对象多,GC 会拖慢下发
UpdateMetadataRequest 大小/延迟元数据膨胀会拖慢 broker
broker 启动到可服务的时间重启变慢会拉长发布窗口
分区总数、topic 总数提前发现「创建 topic 不回收」

7.9.2 复制、保留与成本

7 万亿条/天是一个吞吐数字,乘以保留期和副本数才是存储成本。一个粗略的算法:

每天字节数 = 平均写入带宽 × 86400
总存储     = 每天字节数 × 保留天数 × 副本数 × 1.3(索引/段开销)

假设平均写入 1 GB/s、保留 7 天、3 副本:1 GB/s × 86400 ≈ 86.4 TB/天,乘 7 天再乘 3 约 1.8 PB。这个数字随保留期线性增长,所以「保留久一点」不是免费的运维选项,而是容量和成本的决策。

LinkedIn 的做法是按 workload 分集群:活动追踪、指标、数据库 CDC 的保留、副本和延迟要求不同,混在一起只能取最严格的那个。保留期的下界是「最大消费者停机时间 + 全量重放时间」,上界是预算;超过下界之后,每多一天都要问「这一天真的会被重放吗」,否则就是在为「可能有用」付钱。副本也是同理:3 副本是常见默认,但冷数据、可重建的派生数据可以用更低的副本数,前提是接受重建时间。

7.9.3 把 rebalance 当成发布对象

LinkedIn 的发布认证包含 rebalance、部署、rolling bounce、稳定性和 downgrade。这个列表值得抄,因为它覆盖的正是「变更如何影响分区分配」:

  • rebalance:滚动重启消费者,观察重复处理、lag 峰值、分区分配是否均匀;
  • rolling bounce:逐台重启 broker,观察 ISR 是否反复缩水、producer 是否收到 NotEnoughReplicas;
  • downgrade:新版本回退到旧版本,元数据和事务状态是否兼容;
  • stability:在持续写入下跑一段时间,看 controller、GC、磁盘是否稳定。

大多数团队不需要定制 Kafka 分支,但需要一个「发布前跑一遍 rebalance」的检查项。rebalance 不是异常,是发布的一部分;把它当成异常,就会在每次发版时被同一个重复扣款打中。

事实边界

本节只引用 LinkedIn 工程博客的公开数字和做法:集群/broker/topic/分区的规模、controller 与维护模式、Cruise Control/Brooklin/schema registry/Bean Counter、发布认证项。公开材料讲的是规模与运维机制,不是一次事故;本节不编造时间线,也不把「LinkedIn 的做法」当成所有团队的默认配置。原文链接:How LinkedIn customizes Apache Kafka for 7 trillion messages per day。

7.10 重试导致重复副作用:offset 提交失败后的二次扣款

7.4 的时间线不是假设。把场景换成一个钱包服务:它消费 payments.charged,调用支付服务商(PSP)扣款,然后写本地账本。某次滚动发版,consumer 超过了 max.poll.interval.ms,再平衡发生,offset 提交被拒;新 consumer 从旧 offset 重放,用户被扣了两次。

时间消费者/系统动作持久化状态
t0C1poll 到 500..509,已提交 offset=500committed=500
t1C1处理 500:插入 charges 行(自增 ID),用 uuid4() 生成 K1,调用 PSP 扣款成功一条 charge,PSP 已扣
t2C1写账本行,准备提交 offset账本一条
t3C1发版/GC,超过 max.poll.interval.ms 没有 poll分区即将被收走
t4coordinatorgeneration 42→43,P0 分配给 C2C1 成为僵尸
t5C1commitSync(501) 返回 CommitFailedExceptioncommitted 仍是 500
t6C2从 500 重放,再处理 500又一条 charge
t7C2生成新的 K2,调用 PSP 扣款PSP 扣第二次
t8对账一个订单两条 charge、两笔 PSP 扣款用户投诉

根因不是「offset 提交失败」——它是必然发生的;根因是幂等键在每次处理时重新生成,以及 charges 表没有以业务意图为唯一键。K1 和 K2 是两个不同的键,PSP 无法知道它们对应同一笔订单;本地两条 charge 记录也让对账看起来像两笔合法扣款。

修复要分四层,缺一层都会留下窗口:

层做法挡住什么
事件用 event_id 或 order_id 作为稳定键,重试不变外部系统能把两次调用识别为同一次
本地表charges(order_id) 主键,event_id 唯一同一订单不会插入第二条 charge
状态机pending → succeeded/failed;pending 时先查 PSP,不盲目再扣崩溃在「PSP 成功、本地未更新」的窗口
提交副作用完成后再提交 offset;提交失败可安全重放重复处理不再产生重复效果

表结构:

CREATE TABLE charges (
  order_id     TEXT PRIMARY KEY,
  event_id     UUID NOT NULL UNIQUE,
  amount_cents BIGINT NOT NULL,
  status       TEXT NOT NULL CHECK (status IN ('pending', 'succeeded', 'failed')),
  idem_key     TEXT NOT NULL UNIQUE,
  psp_ref      TEXT,
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

order_id 主键是业务意图的粒度:一个订单只允许一条扣款记录。event_id 唯一是消费去重的粒度:同一条事件重放不会插入第二行。idem_key 是传给 PSP 的键,和 order_id 一样稳定;status 让「已经扣了但没记账」和「还没扣」区分开;psp_ref 是对账的锚点。

处理逻辑的关键是「先占位,再调用,失败可查」:

def handle(event, conn, psp):
    order_id = event["payload"]["order_id"]
    key = event["event_id"]                  # 稳定,不随重试变化
    row = conn.execute(
        "INSERT INTO charges (order_id, event_id, amount_cents, status, idem_key) "
        "VALUES (%s, %s, %s, 'pending', %s) "
        "ON CONFLICT (order_id) DO NOTHING RETURNING order_id",
        (order_id, event["event_id"], event["payload"]["amount_cents"], key),
    ).fetchone()
    if row is None:                          # 这个订单已经有一条 charge
        status, ref = conn.execute(
            "SELECT status, psp_ref FROM charges WHERE order_id = %s", (order_id,)
        ).fetchone()
        if status == "succeeded":
            return ref
        return psp.query(key)                # pending:先查,不盲目再扣
    ref = psp.charge(key, order_id, event["payload"]["amount_cents"])
    conn.execute(
        "UPDATE charges SET status = 'succeeded', psp_ref = %s WHERE order_id = %s",
        (ref, order_id),
    )
    return ref

这段代码有两个必须理解的点。第一,INSERT ... ON CONFLICT DO NOTHING 是原子占位:两个并发重放只有一个能插入成功,另一个走查询分支。第二,psp.charge 在数据库事务之外调用;如果 PSP 成功、进程在 UPDATE 前崩溃,记录停在 pending,下次重放会走 psp.query(key),用同一个键拿回原来的扣款结果,而不是再扣一次。状态机把「未知」变成了可查询的 pending,而不是靠重试猜。

query 返回 not_found 时才允许用同一个键重新 charge;如果 PSP 不支持按幂等键查询,就把 pending 记录交给对账任务,而不是在消费循环里盲目重试。没有查询能力的幂等键,只是一个「可能重复」的键。

对账是最后一道防线。即使前四层都做了,也可能有历史脏数据或代码 bug;每天跑一次「一个订单多笔扣款」的查询:

SELECT order_id, COUNT(*) AS charges, SUM(amount_cents) AS total
FROM psp_charges
WHERE created_at >= now() - interval '1 day'
GROUP BY order_id
HAVING COUNT(*) > 1;

对账发现重复后,退款/冲正本身也要幂等:用 order_id 作为退款键,先查是否已经退过,再调用 PSP。否则「修复重复」会制造新的重复。

例 7.10.1 · 一次重放,两笔扣款

某钱包服务在发版期间触发 rebalance。订单 ord-8891 的事件被处理两次:

  • 第一次:charges 插入一条自增 ID 的记录,PSP 用 K1 扣 128 元成功;账本写一条;提交 offset 失败。
  • 第二次:新 consumer 重放,charges 又插入一条(没有 order_id 唯一约束),PSP 用 K2 扣 128 元成功;账本再写一条。

对账时发现同一订单两条 charge、两笔 PSP 流水,共 256 元。修复后:

  • charges.order_id 主键,第二次插入冲突,走查询分支;
  • PSP 的键固定为 event_id,第二次调用即使发生,PSP 也返回第一次的结果;
  • 账本用 (consumer, event_id) 去重,不再追加第二条;
  • 提交失败后的重放只增加日志,不增加钱。

修复后的第一次重放仍会「执行」一次 handle,但 handle 是幂等的:效果只有一次。有效一次不是「不重放」,而是「重放不产生第二次效果」。

offset 提交失败不是根因

提交失败只是暴露了「处理位点领先于提交位点」这个事实。真正决定用户是否被扣两次的,是幂等键是否稳定、业务表是否有唯一约束、外部调用是否可查询。把 CommitFailedException 当成异常去重试提交,而不去修幂等,下一次 rebalance 还会再来。

7.11 事件设计清单

事件一旦发布,就是一份公开契约。消费者会按字段解析、按类型分支、按 event_id 去重;你改一个字段名,可能让三个团队当天上线失败。设计事件时,先把信封(envelope)定下来,再谈 payload。

{
  "event_id": "01J9Z8K3Q7YV2M6T4R1X5B8N0C",
  "type": "payment.charged",
  "version": 3,
  "occurred_at": "2026-10-19T09:31:02.114Z",
  "producer": "payments-api/2.14.0",
  "aggregate_id": "order-8891",
  "aggregate_seq": 7,
  "partition_key": "order-8891",
  "correlation_id": "trace-4f2a9c...",
  "causation_id": "01J9Z8K3Q7YV2M6T4R1X5B8N0B",
  "payload": {
    "order_id": "order-8891",
    "amount_cents": 12800,
    "currency": "CNY"
  }
}

信封里的字段各有职责,不能互相替代:

#字段/决策为什么合格的样子
1event_id去重、追踪、幂等键的候选全局唯一、写入时生成、永不改变
2type消费者按类型路由过去式事实:order.paid,不是 payOrder
3versionschema 演进整数,单调递增;消费者能处理未知版本
4occurred_at业务发生时间UTC,带时区;不是入库时间
5producer排障与归属服务名 + 版本号
6aggregate_id一致性边界订单号/账户号,和业务主键一致
7aggregate_seq顺序与旧事件拒绝每个聚合单调递增;有空洞时消费者能识别并等待/回退
8partition_key顺序保证的范围通常等于 aggregate_id;不要为 null
9payload业务事实自包含、可读、不含内部自增 ID
10schema跨语言契约Avro/Protobuf + registry;有兼容性检查
11幂等键外部副作用去重event_id 或业务键,重试不变
12保留/重放消费者能追多久保留期 ≥ 最大停机 + 重放时间;有归档
13PII合规与成本最小化、脱敏/加密、可删除
14删除语义压实 topic 的墓碑删除事件有明确 tombstone,消费者会处理
15大小上限broker/网络/内存默认 < 1 MB;大对象用 claim-check
16归属与 DLQ出问题谁处理有 owner、有死信队列、有回放手册

这张表里最容易被跳过的是第 7、8、12 项。没有 aggregate_seq,消费端只能用 occurred_at 猜顺序,而时钟不可靠;没有稳定的 partition_key,同一聚合的事件会散到不同分区;没有保留/重放设计,消费者停机一周后只能面对 OffsetOutOfRange。事件设计不是 JSON 字段设计,而是「身份、顺序、重放、演进」四件事的契约。

事件、命令、快照经常被混用,语义不同:

类型语义例子谁决定结果
命令(command)请求做一件事,可能被拒绝charge_order接收方
事件(event)已经发生的事实,不可撤销order.paid产生方
快照(snapshot)某个时刻的完整状态order.snapshot.v3聚合/投影

把命令当事件发,会让下游以为「请求一定成功」;把事件当命令发,会让下游重复执行。事件用过去式,命令用祈使句,命名是最便宜的类型检查。

分区键的规则可以压缩成四句话:

  1. 同一聚合的所有事件用同一个键,通常是 aggregate_id;
  2. 键一旦发布就不要改;要改就双写新旧 topic,做受控迁移;
  3. 有序流程不要用 null key;null key 的 sticky 分区没有 key 级顺序;
  4. 键的基数要足够高,避免把大部分流量压到一个分区;热 key 要单独设计。

schema 演进要按「旧消费者读新事件」和「新消费者读旧事件」两个方向检查:

变更兼容性做法
新增可选字段双向兼容给默认值;旧消费者忽略
新增必填字段破坏不要做;改成可选 + 服务端默认
删除字段对新消费者破坏先标记 deprecated,观察使用量再删
改字段类型破坏新增字段 + 双写/双读
改枚举值可能破坏消费者保留 unknown 分支,不要直接 raise
改语义破坏新增 type 或 version,不要原地改

保留和重放要一起设计。保留期至少覆盖「消费者最长停机时间 + 全量重放时间」;超过这个窗口的数据要归档到对象存储,并保留可重放的格式。压实 topic 适合保存「每个 key 的当前状态」,不适合保存「所有历史事件」;墓碑的保留时间(delete.retention.ms)要大于消费者的最大停机时间,否则删除事件会先消失,消费者永远不知道某个 key 被删了。

事件不是通知

「订单更新了,自己去查」不是事件,是通知:它把一致性责任推给了消费者,消费者还要猜「更新了什么、顺序是什么、查到的是不是新的」。事件应该携带足够自包含的事实(金额、状态、发生时间),让消费者不依赖回查就能做出决定;回查只能作为补充,不能作为主路径。

7.12 本章陷阱清单

12 条高频错误
  1. 把 offset 当消息 ID:offset 只在单个分区内有意义,压实后还会出现空洞;跨分区不可比较。事件 ID 要独立生成。
  2. 以为「Kafka 是至少一次」应用就至少一次:自动提交 + 异步处理实际是最多一次;语义由提交策略决定,不由 broker 决定。
  3. 先提交 offset 再处理:进程在处理前崩溃,消息永远不会再来;只有可丢的指标才适合这种写法。
  4. 处理成功、提交失败后不幂等:这是最常见的重复来源;提交失败是常态,必须假设每次处理都会被重放。
  5. 用处理时生成的 UUID 当幂等键:每次重试都是新键,外部系统无法识别为同一次;键要在首次调用前确定并复用。
  6. 增加分区后假设顺序还在:hash(key) % N 变了,同一 key 的历史和未来落在不同分区;要么接受乱序并用版本号,要么做受控迁移。
  7. 用 null key 还指望同一用户有序:null key 走 sticky 分区,同一用户的事件可能落在任意分区;有序流程必须显式指定 key。
  8. 消费者数超过分区数:多出的消费者分不到分区,只是空闲;并行度上限是分区数,加消费者不等于扩容。
  9. 把 acks=all 当不丢:ISR 缩水后 all 等于 1;min.insync.replicas=1、同机架副本、unclean leader election 都会让已确认写丢失。
  10. 认为 Kafka 事务覆盖数据库/HTTP 副作用:事务只覆盖 Kafka 的分区和 offset;外部写要靠 outbox、幂等键或对账。
  11. 重试 topic 不按原 key 分区:主流程不再阻塞,但同一聚合的事件在重试 topic 里乱序;重试也要用同一个分区键。
  12. 保留期短于最大停机时间:消费者恢复时 offset 已越界,重置到 earliest/latest 都会留下数据缺口;保留期是契约,不是运维细节。

7.13 本章出口检查

检查项达标线没达标去哪
队列 vs 日志能说出进度归谁、怎么重放、慢消费者的后果7.1
三种投递语义能画出丢失/重复窗口,说明有效一次的作用域7.2
分区与顺序给定 key 和分区数,能说出顺序范围与扩分区的后果7.3
消费者组能解释消费者数/分区数关系,以及热点分区为什么加消费者无效7.3
offset 提交能区分自动、同步、异步、事务四种提交的语义7.4
再平衡能画出僵尸消费者 + 提交被拒 + 重放的时间线7.4
ISR 与 acks能解释 acks=all 为什么仍可能丢,min.insync.replicas 挡住什么7.5
幂等生产者与事务能说出两者的作用域和三个边界7.5
Outbox/CDC/事件溯源能对比事件来源、侵入性、重放方式和主要风险7.6
lag 观测能从 --describe 读出 lag、owner、无提交 offset 的分区7.8
重复扣款修复能给出稳定键 + 唯一约束 + 状态机 + 对账四层7.10
事件设计16 项清单里至少能答出 14 项7.11
即时自测 · 7

Q1 一个任务队列要求「每个任务至少执行一次,慢任务不能被另一个 worker 重复执行」。选队列还是日志?说明慢任务的处理方式。

Q2 自动提交 + 异步处理,实际投递语义是什么?给出一种最小修复。

Q3 一个 topic 有 4 个分区,消费者组有 6 个消费者,key 是 user_id。加消费者能降 lag 吗?把分区加到 8 有什么风险?

Q4 acks=all、replication factor=3、min.insync.replicas=2。一个 follower 落后被踢出 ISR,写入还能继续吗?第二个 follower 也落后时呢?

Q5 offset 提交失败后重放,为什么「去重表 + 稳定幂等键」必须同时存在?只做其中一个会怎样?

答案

A1 选队列更直接:队列的可见性超时 + ack 机制天然适合「任务」语义,慢任务需要把可见性超时设得比最大处理时间长,或者让 worker 定期续期(heartbeat/change visibility),避免超时后被另一个 worker 抢走。如果任务需要重放、审计、多消费者,选日志,但要用 offset 提交 + 去重表实现「至少一次 + 有效一次」;日志没有可见性超时,慢任务只会让 lag 增长,不会自动重复。

A2 最多一次。自动提交把 poll 返回的末尾 offset 提交了,异步任务还没处理完;进程崩溃后这些消息不会再被读到。最小修复:关闭自动提交,处理完成后再 commitSync;如果必须异步,把任务持久化到 outbox/任务表,并让「任务落库」与「offset 提交」原子化,消费端再按任务 ID 去重。

A3 不能。4 个分区最多 4 个活跃消费者,另外 2 个分不到分区,只是空闲。把分区加到 8 会改变 hash(user_id) % N,同一用户的历史和未来可能落在不同分区,顺序保证断裂;需要双写迁移、版本号/序号,或者接受乱序并让消费端幂等。

A4 还能继续。ISR 变成 {leader, follower2},大小是 2,仍满足 min.insync.replicas=2,acks=all 等这两个副本;但容错余量从 1 变成 0。第二个 follower 也落后时,ISR 只剩 leader,小于 2,producer 收到 NotEnoughReplicas,写入失败;这是用可用性换持久性,避免接受可能丢的写。

A5 去重表挡的是「同一个 event_id 被处理两次」,稳定幂等键挡的是「重复调用外部系统」。只有去重表:如果第一次处理在写去重记录前就调用了 PSP 并崩溃,重放会再次调用,外部系统仍可能扣两次。只有稳定键:PSP 能去重,但本地账本、消息、通知仍可能重复写。两者作用在不同层,必须同时存在,再加上对账兜底。

母本对应

母本章节本书落点程度说明
谢希仁《计算机网络》第 8 版第 6 章 应用层7.1、7.2背景只借用「消息/协议/端到端」的视角;队列与日志的语义不在母本范围
张彦飞《深入理解 Linux 网络》第 4–7 章7.8背景连接、超时、收发路径只引用,不展开内核实现
《Python 网络编程》并发与客户端工程7.6、7.10精选借用工程风格;outbox、消费者去重代码为本书重写
《Linux 系统与内核》第 4 篇4.1–4.117.8交叉引用明确不重复 TCP/IP、socket、epoll、HTTP
本书自编 + Kafka 论文/官方文档Kafka 论文、KIP、consumer group 文档7.1–7.6、7.8完整只引用机制与事实,不写 Kafka 全配置手册
LinkedIn 工程博客 2019-10-08《How LinkedIn customizes Apache Kafka for 7 trillion messages per day》7.9完整只引用公开规模、工具与发布流程,不编造事故

这一章没有一一对应的母本章节:谢希仁和张彦飞讲的是网络分层与内核路径,不覆盖队列/日志语义、消费者组、outbox 和事件溯源。本书自编的部分集中在「机制 → 失败窗口 → 观测命令 → 修复」这条链上;Kafka 的 ISR、事务、幂等生产者等事实来自论文与官方文档,代码和事故场景是按工程实践重写的,不复制文档长段,也不把 LinkedIn 的规模当成默认配置。

下一站:消息把「一次状态变化」变成了可重放的流,但还有一类数据不在你的数据库里:对象、缓存和 CDN。进入 8 对象存储、缓存与 CDN,看不可变对象、条件写、缓存失效和回源风暴如何接住这条流的下游。