当一个 Kafka 生产者调用 send() 之后,会发生什么?
这听起来像是一个简单的问题。生产者创建一条记录,把它发送给 Kafka,然后收到一个确认。
然而,在这些步骤之间可能发生相当多的事情。
生产者可能会等待更多记录以组成一个批次。它必须选择一个分区。它可能会压缩这个批次。如果 broker 很慢,生产者可能会耗尽缓冲区空间。如果一个请求失败了,它可能会重试。如果第一个请求其实已经写入成功,而重试又成功了,那么 Kafka 可能会收到同一个批次两次。
Kafka 模拟器 v1.1 聚焦于这些生产者语义。
本次发布新增了 10 个新场景,使完整课程达到 125 个场景中的 23 个。
v1.1 包含了什么?
十个场景中有八个聚焦于生产者。其余两个扩展了日志剖析(log-anatomy)专题:读取到高水位线,以及日志起始偏移量(log start offset)。
生产者场景涵盖五个方面:
- 使用
linger.ms和batch.size的批处理, - 生产者分区器,
- 压缩,
- 使用
buffer.memory和max.block.ms的背压, - 重试与幂等性。
让我们逐一来看。
使用 linger.ms 和 batch.size 的批处理
Kafka 生产者并不一定会把每条记录都放在单独的请求中发送。
被分配到同一个分区的记录可以被收集进一个批次。生产者会在批次填满或配置的等待时间到期时把它发送出去。
正是在这里,两个生产者设置变得重要:
batch.size限制一个批次的大小,linger.ms允许生产者在发送之前等待更多记录。
这里的权衡在于延迟与吞吐量之间。
更大的批次通常意味着更少的请求和更好的压缩效果。然而,等待批次填满可能会增加单条记录的延迟。
新场景让这一行为变得可见。你可以看到记录何时进入一个批次、是什么导致了批次被发送,以及更改配置会如何影响客户端产生的请求。
生产者分区器
在生产者能够创建一个特定于分区的批次之前,它必须先选择一个分区。
对于带键的记录,键被用来选择分区。因此,具有相同键的记录通常会被分配到同一个分区。
对于没有键的记录,行为则有所不同。生产者可以使用其分区策略对记录进行分组,而不是根据键来计算分区。
模拟器会逐步展示这两种情况:
- 生产者收到记录,
- 分区器选择一个分区,
- 记录被添加到相应的批次中,
- 批次被发送到该分区的 leader。
这让人更容易看清为什么分区会同时影响顺序和批处理。
压缩是按批次进行的
Kafka 生产者的压缩是应用于记录批次的,而不是独立地应用于每一条记录。
这个区别很重要。
当一个批次包含多条相似的记录时,压缩通常更为有效。一个只包含单条小记录的批次,从压缩中获得的收益可能非常有限。而一个包含重复数据的更大批次,则可以被压缩得高效得多。
因此,压缩与批处理配置息息相关。像 batch.size 和 linger.ms 这样的设置,不仅会影响请求频率,还会影响最终批次的形态及其可压缩性。
新场景展示了未压缩和压缩后的批次大小,从而把这层关系明确地呈现出来。
使用 buffer.memory 和 max.block.ms 的背压
当一个应用产生记录的速度快于 Kafka 能够接受的速度时,会发生什么?
生产者无法在内存中无限量地保留未发送的记录。它使用一个有界的缓冲区,通过 buffer.memory 进行配置。
当缓冲区没有可用空间时,一次 send() 调用可能会阻塞,等待内存被释放。
它不会永远等待下去。最大阻塞时间由 max.block.ms 控制。
这给了我们两种可能的结果:
- 足够多的数据被发送并确认,从而释放出缓冲区空间,
- 在超时之前始终没有空间可用,于是发送失败。
模拟器会直接追踪生产者缓冲区。你可以看到内存在记录被添加时被分配、在请求完成时被释放。
这在与一个慢 broker 或吞吐量配额结合使用时尤其有用。你无需再把生产者背压当作一个抽象的配置问题,而是可以亲眼看着缓冲区被填满,并看到究竟是哪一次发送被阻塞了。
重试与重复问题
假设生产者向一个 broker 发送了一个批次。
broker 成功写入了这个批次,但确认在到达生产者之前丢失了。
从生产者的视角来看,这个请求失败了。它无法知道 broker 是否写入了数据。因此进行重试是合理的。
然而,如果第一个请求已经写入成功,重试可能会把同一个批次再追加一次。
这就是由模糊故障所导致的重复问题。
重试场景展示了两个请求:
- 原始请求到达 broker,
- 确认丢失了,
- 生产者重试该批次,
- broker 第二次收到了相同的记录。
在没有幂等性的情况下,两份副本都可能被追加进去。
幂等生产者语义
幂等生产者让 Kafka 能够识别出被重试的批次。
Kafka 会为生产者分配一个身份标识,并为每个分区独立地追踪序列号。当一个批次带着 broker 已经处理过的序列号被重试时,broker 可以对它进行确认,而不再重复追加这些记录。
这里的重要区别在于重试与真正的重复之间的区别。
两条记录可能包含完全相同的键和值,但它们仍然可能是应用有意发送的两条独立记录。Kafka 绝不能移除这样的记录。
幂等性去重的是生产者协议层面的重试。它并不会基于内容对应用数据进行去重。
新场景展示了:
- 生产者 ID,
- 分区序列号,
- 成功的幂等重试,
- 真正的重复记录,
- 乱序序列的检测。
为什么 max.in.flight.requests.per.connection 很重要?
一个生产者可以同时有多个请求在等待响应。
这提升了吞吐量,但也带来了潜在的顺序问题。
假设生产者先发送批次 A,然后发送批次 B。批次 A 暂时失败,而批次 B 成功了。如果 A 在 B 已经被写入之后才被重试,那么这些记录可能会以不同的顺序被追加。
幂等性使用序列号来检测无效的顺序。
模拟器会明确地展示这一点。你可以看到 broker 期望的序列号、传入批次所携带的序列号,以及 broker 拒绝一个乱序请求的那一刻。
检查生产者的内部状态
这些新场景在很大程度上依赖模拟器的 检查器(inspector),它暴露了生产者和分区的内部状态,包括:
- 重试计数器,
- 生产者序列号,
- broker 序列号,
- 缓冲区分配。
通常情况下,这些数值都隐藏在生产者指标、日志和协议行为的背后。而在模拟器中,你可以在向前推进或向后回退一个场景时直接检查它们。
生产者模型修复
新增这些场景也要求我们让现有的生产者模型更加精确。
重试是按批次计数的
Kafka 重试的是包含记录批次的 produce 请求。
因此,一个失败的请求应当为整个批次消耗一次重试,而不是为其中的每一条记录各消耗一次重试。
模拟器现在遵循这个模型。失败批次中的每一条记录都会被再次发送,而重试计数器只前进一次。
缓冲区内存被精确释放
生产者缓冲区内存现在会随着确认的到达而被释放。
这修复了这样一类情况:即便被确认的记录本应已经释放了它们的内存,模拟器却仍可能报告背压。
同样的逻辑也适用于 max.block.ms 超时。当一次被阻塞的发送超时后,它的缓冲区分配会被清理掉。
acks=all 是按分区追踪的
Kafka 的持久性是为每个分区独立定义的。
因此,一个向多个分区写入的生产者必须等待正确的那个分区满足其确认要求。另一个分区上的进展绝不能确认一个不相关的请求。
模拟器现在会为每个分区和主题分别追踪这一状态。
生产者重置会清除幂等性状态
broker 会存储与一个幂等生产者相关联的去重状态。
当一个被模拟的生产者被移除或重置时,这个状态也必须被移除。否则,一个新创建的生产者的记录可能会被错误地归类为旧的重试。
现在的重置行为会清除相应的 broker 端状态。
回退会恢复重试状态
场景可以被一步一步地向前推进和向后回退。
回退现在会重建每一轮模拟过程中所使用的重试计数器。因此,无论场景是在向前还是向后移动,可见的生产者状态都是一致的。
演练场改进
本次发布还包含了生产者模型之外的若干修复。
消费者组再平衡
演练场中的消费者组获得了若干正确性修复:
- sticky 分配器现在会最小化分区的移动,而不是从头开始重新分配,
- 一个离开时间超过
session.timeout.ms的静态成员,会像其他任何成员一样被驱逐, - 更改一个主题的分区数现在会触发一次正常的再平衡,而不会扰乱成员状态,
- 分区绝不会被分配给一个已经不再存活的成员。
主题配置与压实
Kafka 从不减少一个主题的分区数。收缩分区会删除数据并破坏基于键的分区机制,因此 kafka-topics --alter 会以 InvalidPartitionsException 拒绝这种操作。
演练场现在遵循同样的规则。减少分区数的操作会被一个带有教学意义的错误拒绝,而不是悄悄地丢弃分区。
压实(compacted)主题的行为也变得更加精确:
- 一个墓碑(tombstone)会一直保留到它自己的
delete.retention.ms宽限窗口到期为止, - 无法向一个压实主题生产没有键的记录,
- 在
compact,delete模式下,时间保留策略现在会独立于压实来回收过期的记录。
分享链接会保留完整的场景
演练场会把它的各个动作序列化到 URL 中,以便一个场景可以被分享给另一个人。
编解码器现在会包含所有的配置更改,包括分区数的更新。如果一个链接请求的是来自后续发布包的集群类型,现在会回退到一个受支持的拓扑,而不是渲染出一个无法使用的集群。
因此,打开一个分享链接应当能够重建出最初构建的那个场景。
交互修复
我们还修正了若干较小的演练场问题:
- 实体标识符在一个实体被移除并重新创建之后不再发生冲突,
- 生产者的放置在更宽的 broker 网格上能够正确工作。
安全响应头
站点现在会发送 Content Security Policy 和 Permissions Policy。
为什么从生产者开始?
Kafka 的各项保证通常是从 broker 的视角来讨论的:复制、ISR 成员关系、高水位线,以及 leader 故障转移。
然而,一个应用所观察到的保证,同样也取决于生产者。
它使用哪种确认模式?它会重试吗?是否启用了幂等性?可以有多少个请求同时在途?当它的本地缓冲区被填满时会发生什么?
这些设置决定了一次写入是快速的、持久的、有序的、重复的、被阻塞的,还是被拒绝的。
这正是第一个较大的 Kafka 模拟器场景包聚焦于生产者的原因。
一旦批处理、确认、重试和幂等性都变得可见,后续那些涵盖复制、持久性和事务的场景就会有一个更清晰的基础。
打开 Kafka 模拟器,选择生产者专题,然后一步一步地尝试这些故障场景。