分享本文

简要版本

当一个 Kafka 分区陷入沉默时,它是处理完了,还是只是慢了?从外部看,这两种情况毫无区别——而答案决定了你能否放心地基于手头的数据采取行动。Netflix、Uber 和 Walmart 都公开过各自的应对方式,而每种方式都要付出代价:多一条管道、牺牲一路并行度、或是在重放时放弃正确性。每一个都是扎实的工程方案。但没有一个去问 Kafka 那件 Kafka 早已知道的事。

我是在为一家财富 50 强企业构建库存流时遇到这个问题的:13 个 Kafka 主题,每个 3 个分区,每天数亿条事件,同一商品的所有事件必须严格按事件时间排序——从零开始读取七天的历史数据,然后直接接入实时峰值流量。最初的设计在所有环节之前设置了一个三十分钟的窗口,仍然需要第二个作业来清扫窗口遗漏的数据,而且仍有 5.5% 的记录以错误顺序输出。

最终上线的版本不靠时钟等待。它读取 Kafka 的偏移量。

以下是具体做法——以及为什么这个思路比 Kafka 还要古老。

人人都在做的取舍 / §01

这不是某一个团队的问题。四个一流的工程组织,四种合理的取舍:

  • Walmart将每个"商品–门店"组合固定到单一分区,从而确保只有一个消费者会更新它。通过固定键来保证顺序——它牺牲了并行度。
  • Uber运行一条独立的回填管道,在迟到和乱序事件之后重新计算,因为流式路径"可能因丢弃水位线之后到达的事件而造成不准确"。它付出了第二条管道的代价。
  • Netflix在回填 Flink 管道时发现,重放的历史数据产生了不同的顺序和失控膨胀的状态。它付出的是正确性和状态的代价。
  • Confluent官方对空闲 Kafka 分区的指导意见:要么等待它而停滞,要么忽略它并接受"分区重新活跃后出现大量迟到消息"。它付出的是停滞或迟到数据的代价。

每一种都合情合理。但共同点在于,它们都是从时间推断完整性——一个窗口、一个超时、一段等待——而分区偏移量明明白白地标示了数据的终点在哪里。这正是本方案所弥合的缺口。

数据流为何乱序 / §02

一个 Kafka 主题被切分为多个分区,每个分区就是一条独立的轨道。在分区内部,顺序是有保证的:先是第 1 条记录,然后是第 11 条,永远如此。而在分区之间,没有任何协调机制——读取它们的消费者永远不会在同一时刻处于同一位置。Kafka 完全兑现了它的承诺,消费者也完全兑现了它们的承诺,但合并后的输出对于任何关心顺序的下游来说仍然是乱序的。

对于同一件商品,不能在"入库"之前就处理"售出"。所以在管道释放任何数据之前,必须回答一个问题:截至此刻,我是否已经拿到了全部数据?如今几乎所有的答案都是一个计时器——窗口、空闲超时、对账作业。每一个都是对沉默的猜测。

一个古老的解法:报告,而非等待 / §03

历史上有先例,而且解法不是更精准的时钟。早期铁路采用时间间隔行车制:后续列车在固定间隔过后发车,于是线路"想必"已经空出。

1889 年 6 月,爱尔兰阿马(Armagh)城外,一列火车在陡坡上熄火停驶。后部车厢被解挂后沿坡滑落,撞上了后方跟进的列车。约八十人遇难。

那列后续列车在规定的二十分钟过后便驶离了阿马。二十分钟已经过去,线路想必已经空出。而阿马站的所有人都无从得知事实并非如此。

英国的回应不是一块更好的怀表。议会授权贸易委员会强制客运铁路实行绝对闭塞行车制:在前方信号楼确认区间空闲之前,任何列车不得进入该区间。"空出"不再是一段被等完的时长,而成为由人报告的事实。熄火的列车与已驶离的列车不再看起来一模一样,因为再没有人从沉默中解读任何含义了。

这就是全部的关键一步。读取 Kafka 的偏移量,就是那座信号楼。

不要从沉默推断完整性。去问那个本来就知道答案的系统。

怀表——时间间隔行车制
20 分钟已过 → 放行

放行依据是一段被等完的时长。熄火的列车与已驶离的列车看起来一模一样。

信号——绝对闭塞
前方信号楼确认"区间空闲" → 放行

放行依据是有人报告的事实。再没有人从沉默中解读任何含义了。

同样的转变,放在流式处理中
秒表 = 窗口 / 空闲超时
铁路区间 = 一个 Kafka 分区
"区间空闲" = 分区到达其末端偏移量
信号放行 = 水位线推进

实现——三个部分 / §04

原则很简短:只有当证据证明可以推进时,时间才推进。把它变为现实需要三个部分,而最难的部分并不在人们想当然的地方。

第一:定时器队列就是排序。每条记录都按其自身的事件时间存入键控状态,并为那个精确到毫秒的时间注册一个事件时间定时器。此时什么都不输出。当水位线到达该时间戳时,定时器触发,存放在那里的所有记录一起释放。Flink 按时间戳升序触发同一键的定时器,所以我们从不进行任何排序。这只是一个方法加一块状态——难点从来不在这里。

01 · 到达时

先存储,再为其自身的事件时间注册一个触发器。

state.put(t, list)
registerEventTimeTimer(t)
此时什么都不输出
02 · 触发时

时钟到达该时刻,读取该键,释放其下的全部记录。

onTimer(t)
collect(state.get(t))
B 和 C 一起释放
MapState⟨Long, List⟩ · 以 SKU 为键
事件时间记录定时器
10:00:18—已触发,已移除
10:00:26B、C即将到期
10:00:33D等待中
10:00:41E、F、G等待中

◆ Flink 的定时器队列按时间戳排序:定时器按升序触发。这本身就是排序。

第二:没有事件时间的流成为时钟。13 个主题中有一个根本没有事件时间——它只发布有效商品清单,供其他所有流做数据补全。所以它没有什么需要排序,而它能做到其他流做不到的事:基于处理时间发出水位线,始终是可能的最新时间戳。问题在于:管道的时钟取所有输入中的最小值,所以一条跑在"当前时刻"的流,在历史数据流尚未计入完毕时不会产生任何影响。我先构建了这一部分,结果毫无效果——它没有错,只是顺序错了。控制流只有在其他流脱离最小值之后才会起作用,而一条流只有在被标记为空闲时才会脱离最小值。

第三:由 Kafka 决定一条流何时处理完毕——而不是超时。Flink 内置的空闲判定是一个计时器:一段时间没有数据,就标记为空闲。但超时无法区分"正在启动"与"已经完成",而启动恰恰是最糟糕的情形——各连接以不同速度建立,一个主题暂时沉默,超时触发,其他流把时钟推过了它尚未读到的事件时间,于是它的第一条记录一到达就已经迟到。这不是延迟,而是对顺序保证的违反。所以超时必须去掉。启动时,在读取任何记录之前,作业先向 Kafka 查询每个分区的最后偏移量并记下来。只有当每一个分区都到达那个数字时,主题才算空闲——不是大多数,而是每一个。没有需要调校的时长,因为这道闸门依据的是来自 Kafka 的事实。

// once, at job startup
Map<TopicPartition, Long> endOffsets = consumer.endOffsets(partitions);

// as records arrive
if (offset + 1 >= startupEndOffsets.get(partition)) markCaughtUp(partition);

换来了什么 / §05

原来的窗口会扣住每条记录直到窗口关闭——仅读入历史数据就要三十分钟,而且每次重启都要重来一遍。现在,水位线一越过某条记录,它就立即被释放:平均不到 100 毫秒,其中已包含为避免乱序而暂扣的记录。第一分钟到达的记录,第一分钟就能释放。顺序性也是同样的轨迹:两层超时空闲判定下有 5.5% 的记录乱序;一层,1.5%;由 Kafka 启动偏移量判定空闲,零。窗口没了,清扫作业也没了——一个作业读完七天历史数据,然后直接转入实时运行。

之前之后
记录按序释放前的等待时间最长 30 分钟(窗口)平均 < 100 毫秒*
违反事件时间顺序的记录5.5%(超时空闲判定)0
需运行的作业数流式处理 + 清扫作业一个

*延迟在客户端端到端测得,基于每天数亿条事件。顺序性数据在我为本文重建的参考实现上测得。

两层超时空闲判定
5.5%
一层超时空闲判定
1.5%
以 Kafka 启动偏移量判定空闲
0

违反事件时间顺序释放的记录。基于为本文重建的参考实现。

它不承诺什么 / §06

确定性不等于未卜先知。对管道而言,一个事件只有落入 Kafka 之后才算存在。如果生产者扣住一条记录,而它在时钟已经越过其事件时间之后才落入,它会被立即输出、顺序错乱,并被违规计数器记录在案。没有任何流处理器能对尚看不见的事件排序。在这条边界之内,保证是精确的,而水位线滞后量决定了边界的宽度。

事件时间时钟 · 水位线 = 当前时刻 − 滞后量clock记录 A在时钟到达其时间之前落入按序释放记录 B在时钟已越过其时间之后才落入乱序输出 · 违规 +1

没有任何处理器能对尚看不见的事件排序。在边界之内,保证是精确的——水位线滞后量决定了边界的宽度。

同一个问题,无处不在 / §07

一旦看懂了它,你就会在每一个依据不同步到达的数据流来判定事实的系统中看到它。

一台河流水位计在洪水期间陷入沉默。是河水平稳,还是洪水已经淹到了传感器?2018 年,USGS 报告称飓风佛罗伦萨击垮了多个水文监测站——恰恰是最关键的那些。把"没有消息"当成好消息,是对这种沉默最错误的解读。

一套相机阵列。用多台相机捕捉一名运动员,每台相机都是一条独立的流,就像一个分区;在每台相机都报告了某一瞬间之前,你无法为那一瞬间融合出 3D 姿态。一台沉默的相机可能丢了帧,也可能正拍着一个纹丝不动的人。在 i3d.ai,这是同一个问题,每秒钟要回答几十次。

一个智能体。一个 AI 智能体调用了工具,却没有收到任何回音。智能体非常擅长把沉默当作成功——没有返回错误,那就以机器速度进入下一步。一个基于客户账户一小时前状态行事的智能体,就是一列按计时器发车的火车。解法正是铁路当年找到的那个:任何一步都不基于沉默推进;每一步都基于一份可以指认的确认推进。

风暴中的城市
一台河流水位计停止传输
沉默:是安静,还是消失?

是河水保持平稳——还是洪水刚刚淹到传感器?

运动中的身体
一台相机没有传回任何数据
沉默:是安静,还是消失?

是丢了一帧——还是运动员纹丝不动?

工作中的智能体
一次工具调用没有任何返回
沉默:是安静,还是消失?

是成功了、失败了——还是仍在运行?

任何一步都不基于沉默推进。每一步都基于一份可以指认的确认推进。

流式处理中最低成本的可靠性提升,不是更快的引擎,也不是更大的窗口。而是停止从沉默中推断"完成",转而去问那个本来就知道答案的系统。窗口为每条记录都等足最坏情形;偏移量检查则只等到证据所要求的那一刻,一毫秒也不多等。

精简版参考实现——生成器、处理器、追平跟踪器——是我接下来要发布的内容。

你的管道是在等一个时钟,还是在读取清单?

Jeffrey Patrick Carter · NeoSavant

史实依据

Walmart、Uber、Netflix 和 Confluent 的取舍均引自下列工程资料。绝对闭塞行车制的先例(1889 年《铁路管理法》)及其所取代的时间间隔制取自公开档案。Flink 与 Kafka 的机制引用项目自身的设计文档和源码。顺序性数据在生产作业的重建版本上测得;延迟数据在原生产系统的客户端端到端测得。

资料来源

Uber Engineering — Designing a Production-Ready Kappa Architecture for Timely Data Stream Processing

Netflix — Backfill Streaming Data Pipelines in Kappa Architecture(Data + AI Summit 2022)

Confluent — Why Is My Apache Flink Job Not Producing Results?

Apache Flink — FLIP-180: Adjust StreamStatus and Idleness definition · FLINK-35886: Idleness incorrectly detected · Generating Watermarks(DataStream) · InternalTimerServiceImpl(定时器触发顺序)

Apache Kafka — KIP-32: Add timestamps to Kafka message(CreateTime / LogAppendTime)

Wikipedia — 1889 年《铁路管理法》(绝对闭塞行车制) · 阿马铁路事故

USGS — Working to keep vital flood information flowing(飓风佛罗伦萨,2018 年)

Library of Congress — Jack Delano — Indiana Harbor Belt RR switchman with a fusee, 1943(档案号 2017878076)

觉得有用?分享给朋友!

Jeffrey Patrick Carter

Principal Streaming Engineer, NeoSavant

Jeffrey Patrick Carter is a Principal Streaming Engineer at NeoSavant, with 10+ production Flink and Kafka implementations — including Allstate and i3d.ai.

LinkedIn →