精确一次投递
ArkFlow 默认提供至少一次投递。恢复时,在途消息会被重放,并可能多次投递给输出。对于不能容忍重复的 sink,ArkFlow 可通过事务性(transactional)Kafka 输出以可选项提供**精确一次(exactly-once)**投递(更准确地说,是实际一次(effectively-once))。本页说明它保证什么、如何配置,以及边界在哪里。
工作原理
为精确一次配置的 Kafka 输出使用事务性生产者:
init_transactions在connect()时调用一次;- 在每个待确认的批次之前,输出开启一个事务,发送批次中的每条消息,然后提交;
- WAL 游标前进(且数据源被提交)只在事务成功提交之后发生。
工作单元是一个 ack 范围 = 一次 write_batch 调用 = 一个 Kafka 事务。如果缓冲(内存、滚动/滑动/会话窗口,或 join)把若干输入消息聚合为一个输出批次,整个批次就是一个原子事务单元。以 isolation.level=read_committed 读取的下游消费者会原子地观察到每个批次——要么全部消息,要么全无。
任何失败时(需要中止的 commit_transaction 错误,或崩溃),该批次不被确认,WAL 游标不前进,该范围在恢复时重放——这将开启一个新的事务。
配置
在 Kafka 输出上用两个键启用精确一次:
output:
type: kafka
brokers:
- localhost:9092
topic:
type: value
value: orders-copy
exactly_once: true
transactional_id: arkflow-orders-copy-0 # stable across restarts; unique per producer
exactly_once: true开启事务性生产。transactional_id在启用exactly_once时必填。它必须跨重启稳定(由你负责)且每条流的生产者唯一。重启时 broker 用同一个 id 对先前的生产者 epoch 做隔离(fencing),并中止其在途(僵尸)事务,因此僵尸写入永远不会对read_committed消费者可见。
精确一次叠加在至少一次摄取之上,因此输入侧需要启用持久化,使读取与输出之间的崩溃不丢失数据:
durability:
enabled: true
path: "./data/wal-eos"
sync: group_commit
完整可运行示例见
examples/eos-kafka.yaml
(Kafka → Kafka,消费-变换-生产)。
真实的边界(请务必阅读)
Kafka 事务性输出消除了两类特定的重复来源:
- 事务内部分写入 —— 事务原子提交,因此
read_committed消费者永远不会看到部分批次; - 僵尸生产者重复 —— 稳定的
transactional_id会在重启间隔离(fence)掉过期的生产者 epoch。
当崩溃发生在生产者事务已提交之后、源偏移量提交之前时,它并不保证没有重复。源偏移量是异步提交的(受数据源自动提交间隔限制,如 Kafka 默认的 5 秒)。如果进程在这个窗口内崩溃,恢复时数据源会重新投递该范围,新的生产者再写一次,read_committed 下游消费者就会观察到重复行。
这类残留重复必须在下游吸收——通过去重键、业务级幂等,或幂等 sink(如 UPSERT)。请据此设计你的下游消费者。
这个残留窗口——生产者提交与源偏移量提交之间的崩溃——正是 L3 所关闭的。配置配对的 Kafka 输入与输出,让源偏移量通过 send_offsets_to_transaction 在生产者事务内部提交:
input:
type: kafka
brokers:
- localhost:9092
topics:
- orders
consumer_group: orders-copy-group
start_from_latest: false
transactional_offsets: true
output:
type: kafka
brokers:
- localhost:9092
topic:
type: value
value: orders-copy
exactly_once: true
transactional_id: arkflow-orders-copy-0
offset_commit_group: orders-copy-group # must name the input's consumer_group
- 输入侧
transactional_offsets: true把消费者组元数据注册到进程内注册表,并把ack()改为只推进内存 frontier——输入不再自行提交偏移量(其undo()补偿也只回退该 frontier);broker 组位点只随输出事务前进。 - 输出侧
offset_commit_group(要求exactly_once: true)从各批次的__meta_partition/__meta_offset列推导覆盖位点,折入与写入相同的事务。每分区的提交钳制到配对输入的连续确认前沿——逐分区min(批内最大偏移量 +1, 前沿 next),且不低于本生产者已发送过的值——图中其他分支(内容路由、扇出、过滤)仍在结算的位点因此永远不会被事务性跳过:任何时刻崩溃,至少重放未结算部分。钳制使位点提交比写入滞后一批(事务只能提交先前确认已结算的前沿),因此边界是至少一次、尾部至多一批重复:停机前最后写出的那批可能重放。源消费组的read_committed消费者仍然原子地观察到写入与位点推进;事务回滚时组位点不变,该范围被重放。
L3 的边界以显式报错而非静默降级来执行:
- 配对在启动期校验(fail-closed)——声明了
transactional_offsets: true的 Kafka 输入,其消费者组若从未被任何输出的offset_commit_group指名,组位点将静默无人提交(崩溃后组完全按auto.offset.reset重启,latest会跳过期间生产的全部记录)。流/作业的全部组件构建完成后,启动校验以配置错误拒绝该配置,错误指名组与两侧配置键。 - 进程内配对——输出通过进程内注册表解析被指名的组;输入与输出必须运行在同一个 ArkFlow 进程内。输入与输出跨节点的分布式作业不在覆盖范围(独立变更)。
offset_commit_group指名的组没有声明transactional_offsets的活输入时,写入失败。 - 单一输入主题——批次元数据只带分区不带主题,注册表按输入的主题列表路由;多主题订阅会被拒绝。
- 仅限 Kafka → Kafka——不含 Kafka 源元数据列的批次不贡献位点(写入照常进行);非 Kafka 源无法与事务性输出配对。这类批次不在 L3 保证之内:声明
transactional_offsets: true后输入不再自行提交位点,若后续没有携带元数据的事务推进组位点,重启会重放这些记录并造成重复输出——请保持 Kafka 输入与输出之间的管道不含丢弃元数据的处理器(如json_to_arrow这类重建批次的变换),否则在下游吸收重复。
完整可运行的 L3 示例见
examples/eos-kafka-l3.yaml
(examples/eos-kafka.yaml 为仅 L2 的形态)。
要求摘要
exactly_once: true要求非空的transactional_id;否则校验失败并给出明确错误。offset_commit_group要求exactly_once: true;否则 builder 拒绝该配置。被指名的组必须有同进程、声明了transactional_offsets: true的 Kafka 输入:事务性输入未被任何输出认领的配置启动即失败(配置错误);写入时组无活输入则以配置错误失败该次写入。- WAL 的对象存储
node_id与 Kafkatransactional_id是相互独立的配置值——互不派生。 - 事务性 Kafka 输出之外的其他输出保持当前默认的至少一次行为。其他 sink 的幂等适配器(SQL UPSERT 等)可在后续变更中添加。