超越偏移滞后:PB级Apache Hudi数据湖管道的队列时间计算
DataHot 速览
Twilio的数据湖支撑全公司分析、报表和机器学习,使用Apache Hudi从Kafka导入数据,截至2025年Q4每月处理超5万亿条记录,2025年网络星期一峰值达每秒1290万条消息。文章指出Kafka偏移滞后只反映消费者落后程度,不代表数据年龄,容易导致数据新鲜度SLA违规。作者提出time-in-queue指标:从最新Hudi提交文件读取Kafka检查点,测量该消息时间戳与当前时间的差,无需改动现有管道。该算法可回溯提交历史以处理缺失检查点,部署后时间延迟成为一等SLA指标,管道所有者可按管道自定义数据新鲜度警报。
为什么值得关注:本文解决了大规模数据湖管道中Kafka offset lag与真实数据年龄混淆的常见痛点,给出了一种可落地的数据新鲜度测量方法,对负责数据管道SLA和可观测性的工程师极具参考价值。
本文目录 22 节
- 关键要点
- 问题
- 将滞后重新思考为时间
- Hudi 中偏移量跟踪的工作原理
- 算法如何工作
- 快速示例
- 核心算法(Java)
- 从 S3 获取 Hudi 检查点
- 在 Kafka 中 seek 到检查点
- 边缘情况和注意事项
- 生产者之间的时钟偏差
- 多分区主题
- 多个 Hudi 表写入器和 Kafka 检查点
- 修复
- 更新后的算法
- 上限为七天。
- 上述算法是生产环境中多次迭代的结果。当我们首次部署它时,我们立即遇到了未预料到的边缘情况。每次迭代要么在指标中产生误报,要么静默失败。以下是我们学到的经验。
- ,我们最终开始回答正确的问题,证明有时缺失的指标比幻觉值更好。
- 个提交,默认 100)解决了这个问题。该算法现在跳过没有检查点键的提交,并找到最新的有效提交。我们还开始将提交深度(即我们必须回溯多少提交)作为单独的指标报告。高提交深度是主管道在延迟指标超过阈值之前就出现问题的领先指标。
- )表示缺失的时间戳。当算法遇到时间戳为 -1 的记录时,它会尝试从中计算延迟并发布垃圾指标。修复方法是显式检测 -1 时间戳并跳过该管道的报告。与纪元情况一样,沉默比误导性的数字更有用。
- “SLA 比率”不是行业术语。相反,它是我们在生产环境中做出的设计选择。我们最初的 SLA 指标是二元的,要么违反要么没有。问题在于它只在 SLA 已经错过之后才触发。工程师想知道管道何时达到其阈值的百分之七十到八十,以便在客户受到影响之前
- )配对,而不是仅依赖固定的 SLA 阈值。
译文
AI 逐段翻译关键要点
- Kafka Offset 滞后告诉你消费者落后了多少,而不是告诉你数据有多旧。对于 Apache Hudi 管道,这两个测量是完全不同的东西。混淆它们会导致数据新鲜度 SLA 违规。
- 时间在队列指标是通过从 S3 中的最新 Hudi 提交文件读取 Kafka 检查点,在 Kafka 主题中寻求到该偏移量,并测量该消息与当前时间之间的时间戳差值来计算的。不需要更改生产者、消费者或现有管道基础设施。
- 该算法必须处理最新 Hudi 提交不包含检查点元数据的情况。例如,当并行的遗留管道进行了最近的提交时,要求算法遍历提交历史以找到包含检查点元数据的最接近的提交。
- 部署后,基于时间的滞后成为一流的数据合同指标。管道所有者可以为每个管道定义自定义的新鲜度 SLA,并在湖数据超过其阈值时收到警报。
- 偏移量监控和时间滞后监控是互补的。同时运行两者可以给你一个完整的管道健康状况图,而单独的任何一个指标都无法提供。
问题
在Twilio,数据湖是公司产品线(消息、电子邮件、语音等)中分析、报告和机器学习的基础。内部团队依赖这些数据来理解产品使用情况、驱动业务决策,并为机器学习模型(如异常或欺诈检测)提供动力。为这个湖提供数据的管道使用Apache Hudi Delta Streamer将来自 Kafka 的数据落地,截至 2025 年第四季度,每月处理超过五万亿条记录,跨自托管 Kafka 集群,在2025 年黑色星期五达到每秒 1290 万条消息的峰值。在那种规模下,我们意识到给管道所有者一种精确的方式来定义和执行自定义的新鲜度 SLA 是至关重要的。我们寻找一种可操作的信号,而不给实时管道增加任何开销。
传统的消费者滞后指标,如消费者偏移量滞后(records-lag-max)甚至 Hudi 的kafkaDelayCount看起来都很好。似乎消费者跟上了 Kafka,但下游分析团队不断报告有时延迟数小时的陈旧数据。问题不在于 Kafka 吞吐量,而在于可见性差距。Hudi Delta Streamer 管理自己的检查点,这些检查点与表数据一起存储在 S3 中,并且与 Kafka 的消费者组偏移量跟踪是分开的。像Burrow这样的标准滞后监控工具跟踪消费者组提交的偏移量,而 Hudi 默认情况下不会填充这些,因此它们不知道 Hudi 是否实际上已经将该数据提交到湖中。
我们真正需要回答的问题是:最新的 Hudi 提交与当前位于 Kafka 主题中的消息相比落后了多少?
将滞后重新思考为时间
Hudi 作业与 Kafka 主题中的消息相比落后了多少?换句话说,我们想要计算并报告自 Hudi 成功提交后第一条未消费的消息到达 Kafka 主题以来已经过了多久。
Hudi 中偏移量跟踪的工作原理
HoodieStreamer(前身为 HoodieDeltaStreamer)利用一种检查点机制来精确跟踪已经摄入的数据,并防止重新处理这些相同的数据。对于 Kafka 源,此检查点表示已经成功处理并提交到存储的精确主题偏移量(每个分区)或时间戳。
- 对于检查点存储,检查点直接嵌入在
.hoodie提交文件中,作为deltastreamer.checkpoint.key。 - 在弹性方面,在失败或重新启动时,HoodieStreamer 读取最新的提交文件,检索此键,并从它离开的精确偏移量继续读取。
- Kafka 偏移量存储为字符串:
topicName,0:offset0,1:offset1。
从这个事件时间线中,我们可以使用 Apache Hudi SDK 找到最新的提交,并提取最后成功写入湖的每个分区的偏移量。
使这种方法实用的是它不需要管道本身提供任何新东西。Hudi 已经提交到 S3 的偏移量和 Kafka 消息上已有的时间戳足以计算真实的数据新鲜度。我们用一个称为指标报告器的外部作业来计算。指标报告器纯粹是一个外部观察者,它读取系统已经产生的工件,既不需要新的仪器,也不需要更改生产者。
图 1. 采用指标报告器的高层数据管道示例。
摄入路径细节如下面的图 2 所示。Hudi Delta Streamer 将数据及其偏移量检查点提交到 S3。指标报告器读取 Hudi 时间线以找到最新的提交偏移量。
图 2. 指标报告器从 Hudi 时间线读取偏移量。
算法如何工作
指标报告器在生产中每十五分钟运行一次。对于每个管道,报告器完成以下任务:
- 从 S3 的活动时间线中获取最新的 Hudi 提交。按时间倒序遍历提交,最新的在前,找到包含
deltastreamer.checkpoint.key的最近的提交。这种方法为我们提供了最后成功提交批次的每个分区的偏移量。这是 Hudi 已经消费并提交到湖中的确切偏移量。 - 在 Kafka 的每个分区中寻求到检查点偏移量。此寻求将消费者定位在 Hudi 尚未提交到湖的第一条消息处。该消息仍然在 Kafka 中,是在上一次成功的 Hudi 写入之后到达的。
- 读取该消息并获取其时间戳 X。该时间戳记录了数据到达 Kafka 的时间。它一直坐在那里,等待被下一次 Hudi 运行拾取。
- 计算滞后:
currentTimestamp - X = 该数据等待了多长时间 - 如果延迟超过阈值,则上限为七天。此上限防止不活动或停止的管道产生无界值。如果在搜索深度内未找到有效检查点,报告器将完全抑制该指标,而不是发布误导性值。
快速示例
为澄清此方法,这里提供一个使用真实数字的完整示例。考虑一个名为 orders-events 的主题,具有三个分区。最新的 Hudi 提交包含此检查点:
orders-events,0:1200,1:980,2:1450
这些是待读取的偏移量。Hudi 已提交分区 0 上直到偏移量 1199 的所有消息,分区 1 上直到 979,分区 2 上直到 1449。报告器将每个分区 seek 到其检查点偏移量,并轮询下一条记录。它检索每个分区的一个候选记录:
| 分区 | Seek 到偏移量 | 记录时间戳 |
| 0 | 1200 | 45 分钟前 |
| 1 | 980 | 12 分钟前 |
| 2 | 1450 | 45 分钟前 |
所有分区中最早的时间戳是四十五分钟前。这是当前等待提交到数据湖的最旧消息。延迟为四十五分钟。如果管道的 slaInMinutes 是三十,则 SLA 比率为 45/30 = 1.5,上限为 1.0,即完全违反。比率始终上限为 1.0,因为管道不能超过完全违反。使用分级比率是一种设计选择;参见“实际结果”一节了解原因。
以下代码分三部分介绍实现:核心算法(涵盖完整解决方案)、从 S3 获取 Hudi 检查点(通过解析时间线直到配置的最大深度)以及 seek 到 Kafka 偏移量以读取第一条尚未消费的消息。
核心算法(Java)
在下面的代码中,HoodieResult 是一个小的包装器,包含提交元数据和解析后的每个分区的偏移量,kafkaClient 包装标准 Kafka Consumer,而 nextRecord 执行下面显示的 seek 和 poll。
// Step 1: Read Hudi timeline from S3 and extract checkpoint offsets
HoodieResult hudiResult = findLatestCommitWithCheckpoint(tableName, tableBasePath, maxCommitDepth);
final Map<TopicPartition, Long> checkpointOffsets = hudiResult.getPartitionToCheckpoint()
.entrySet().stream()
.collect(Collectors.toMap(
e -> new TopicPartition(topic, e.getKey()),
Map.Entry::getValue
));
// Step 2 & 3: Seek to checkpoint offset and read the next available message
final Optional<ConsumerRecord<String, String>> nextRecord = kafkaClient.nextRecord(topic, checkpointOffsets);
// Step 4: Compute time-in-queue
nextRecord.ifPresent(record -> {
long currentTimeMs = OffsetDateTime.now(Clock.systemUTC()).toInstant().toEpochMilli();
Duration lag = Duration.ofMillis(Math.max(0L, currentTimeMs - record.timestamp()));
long lagSeconds = lag.getSeconds();
// emit lagSeconds to your metrics system
});从 S3 获取 Hudi 检查点
关键是遍历 Hudi 活动时间线并找到包含检查点元数据的最新提交。我们使用 Apache Hudi SDK 的 HoodieTableMetaClient 从 S3 读取 .hoodie/ 时间线目录。HoodieTableMetaClient 在底层使用 Hadoop 的 S3A filesystem 来访问 S3。它需要 Hadoop 配置,我们从报告器已运行的 Spark 会话中获取。实际上没有 Spark 数据处理;Spark 在这里纯粹是运行时环境,为 Hudi SDK 提供 S3 访问层。
public static HoodieResult findLatestCommitWithCheckpoint(String datasetName, String basePath, int maxCommits) {
// getActiveTimeline initializes a HoodieTableMetaClient against the table's S3 path and returns its active timeline.
HoodieActiveTimeline timeline = getActiveTimeline(basePath);
HoodieTimeline commits = timeline.getCommitsTimeline().filterCompletedInstants();
// maxCommits is read from env var MAX_COMMIT_DEPTH, defaults to 100
int depth = Math.min(maxCommits, commits.countInstants());
// n=0 is the most recent commit, iterates in reverse chronological order
for (int n = 0; n < depth; n++) {
HoodieInstant commit = commits.nthFromLastInstant(n).get();
HoodieCommitMetadata metadata = HoodieCommitMetadata.fromBytes(
timeline.getInstantDetails(commit).get(), HoodieCommitMetadata.class);
// CHECKPOINT_KEY = "deltastreamer.checkpoint.key"
if (metadata.getMetadata(CHECKPOINT_KEY) != null) {
return new HoodieResult(metadata, commit.getTimestamp(), datasetName, n);
}
}
return new HoodieResult(-1); // no checkpoint found
}检查点字符串格式为:topicName,0:offset0,1:offset1,... 每个分区一个条目。重要的是,Hudi 将这些存储为待读取偏移量,即下次运行的恢复点,而不是最后消费的偏移量。因此,如果 Hudi 最后提交了偏移量 1199 的消息,则存储 1200。Kafka 消费者 seek 到偏移量 1200 时,定位在第一条尚未提交到数据湖的消息上。
在 Kafka 中 seek 到检查点
分配分区,将每个分区 seek 到其检查点偏移量,并返回它们之间最早记录。
// Assign partitions and seek to checkpoint offsets
consumer.assign(checkpointOffsets.keySet());
checkpointOffsets.forEach(consumer::seek);
// Poll and find the earliest record across all partitions
ConsumerRecord<String, String> earliest = null;
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
for (ConsumerRecord<String, String> record : records) {
if (earliest == null || record.timestamp() < earliest.timestamp()) {
earliest = record;
}
}
// earliest.timestamp() is X — the timestamp of the first unconsumed message上面解释的三个代码片段是整个算法。让快乐路径工作起来是容易的部分;使指标在生产中可靠需要处理一系列边缘情况,下面将介绍。
边缘情况和注意事项
生产者之间的时钟偏差
由于延迟指标使用生产者设置的消息时间戳,系统时钟提前的生产者可能使延迟显得人为偏低(甚至为负)。该算法使用 Math.max(0L, currentTimeMs - record.timestamp()) 将延迟下限设为零。在下限之前持续出现负原始值是值得单独警告的信号。它们表明需要调查的时钟偏差。
多分区主题
在规模上,Kafka 主题有许多分区。Hudi 中存储的检查点每个分区有一个偏移量(如上例所示)。算法将每个分区 seek 到其各自的检查点偏移量,并轮询可用的下一条记录。此方法为每个分区提供一个候选消息。然后我们取所有分区中时间戳最早的记录,而不是平均值或最新值。该消息代表仍在等待提交到数据湖的最旧数据,即最坏情况下的延迟,是 SLA 执行的关键数字。平均值会隐藏慢分区。使用最新值会隐藏卡住的分区。
多个 Hudi 表写入器和 Kafka 检查点
在生产中,一个 Hudi 表可以有多个写入器,并非所有写入器都提交 Kafka 检查点元数据。这种情况通常在迁移期间出现,当旧管道和新管道同时写入同一张表,而旧管道正在退役时。
这正是我们遇到的情况。我们的旧设置使用两条管道。一条从 Kafka 读取并写入原始数据到 S3。第二条从该 S3 输出读取,转换数据,并写入 Hudi 表。
第二条管道的数据源是 S3,而非 Kafka。因此它从未在其提交中嵌入 deltastreamer.checkpoint.key。我们的新框架直接从 Kafka 读取并写入同一张 Hudi 表。其提交包含检查点键。
在迁移重叠期间,两条管道都在写入同一张表。当旧管道进行最新提交时,原始算法未找到检查点元数据。它没有可 seek 的内容,因此延迟默认为七天上限,在延迟图中产生虚假峰值,这些不是真实的数据新鲜度故障。
图 3. 此流程显示共享 Hudi 表上的多个写入器。
修复
根本原因是原始算法只查看最新提交。真正的解决方案是停止假设最新提交是正确的。我们将算法改为按逆时间顺序回退时间线,最多 MAX_COMMIT_DEPTH 次提交,直到找到实际包含 deltastreamer.checkpoint.key。因为只有我们新的 Kafka 来源的管道嵌入了这个键,回溯会跳过遗留管道的提交,并落在一个有效的检查点上。这个解决方案在没有外部状态的情况下防止了虚假的尖峰。
如果在搜索深度内没有找到带有检查点的提交,报告器会完全抑制该指标,而不是发布可能误导的值。缺失的指标在我们的警报系统中会显示为“无数据”警报,这比看起来真实但实际上不真实的数字更诚实的信号。
图4. 反向遍历 Hudi 时间线以找到有效的检查点。
更新后的算法
从 Hudi 时间线获取最新提交,并按逆时间顺序回溯。找到包含 deltastreamer.checkpoint.key; 的最新提交; 如果未找到,则搜索到 MAX_COMMIT_DEPTH
- 深度,完全抑制报告。
- 在每个分区中寻找该偏移量。
- 读取所有分区中的最早消息,并获取时间戳 X。
- lag = currentTimestamp - X。
上限为七天。
实际结果指标报告器通过 EventBridge 在 EMR Serverless 上每十五分钟运行一次。每个管道通过其 onboarding YAML 配置中的 slaInMinutes
metadata:
# alert if data is more than 30 minutes stale
slaInMinutes: 30 字段定义自己的 SLA 阈值:如果省略,流式管道默认为六十分钟,批处理管道默认为 1440 分钟(二十四小时)。报告器在内部将 slaInMinutes 转换为秒。SLA 违规报告为 lagSeconds / slaThresholdSeconds
,这是介于 0.0 和 1.0(上限为 1.0)之间的比率,其中 1.0 是完全违规。0.7 的管道处于其阈值的百分之七十。仪表板显示向违规的进展,而不是从绿色到红色的二元翻转。无需更改任何现有管道;报告器纯粹是一个观察层。
上述算法是生产环境中多次迭代的结果。当我们首次部署它时,我们立即遇到了未预料到的边缘情况。每次迭代要么在指标中产生误报,要么静默失败。以下是我们学到的经验。
纪元时间戳陷阱这个问题很难追踪。我们的第一次迭代将延迟计算为 latestKafkaRecord.timestamp() - hudiCommitTimestamp,假设差距会随着 Hudi 落后而简单扩大。我们没有意识到,当 Hudi 无法找到带有检查点元数据的提交时,它不会出错。相反,它静默地默认为纪元零的时间戳,19700101000000000。这个字符串,格式为 yyyyMMddHHmmssSSS,转换为毫秒零,导致我们的指标爆炸成一个天文数字、无意义的数字。我们现有的 Math.max(0L,<value>) 下限在这里毫无用处。我们艰难学到的教训是,我们必须明确防范纪元默认值,并调整我们的逻辑。通过切换到 currentTime - timestamp_of_first_unconsumed_message
,我们最终开始回答正确的问题,证明有时缺失的指标比幻觉值更好。
最新提交并不总是正确的提交最初的算法只是读取 Hudi 时间线中最新的提交。在迁移重叠期间,当遗留管道和新管道都写入同一张表时,最新的提交通常是遗留管道的,没有检查点键。改为基于深度的回溯(最多 MAX_COMMIT_DEPTH
个提交,默认 100)解决了这个问题。该算法现在跳过没有检查点键的提交,并找到最新的有效提交。我们还开始将提交深度(即我们必须回溯多少提交)作为单独的指标报告。高提交深度是主管道在延迟指标超过阈值之前就出现问题的领先指标。
缺少 Kafka 时间戳Kafka 允许生产者省略消息时间戳。发生这种情况时,记录不会携带空值。Kafka 用哨兵值 -1(ConsumerRecord.NO_TIMESTAMP
)表示缺失的时间戳。当算法遇到时间戳为 -1 的记录时,它会尝试从中计算延迟并发布垃圾指标。修复方法是显式检测 -1 时间戳并跳过该管道的报告。与纪元情况一样,沉默比误导性的数字更有用。
SLA 作为比率,而不是二元
“SLA 比率”不是行业术语。相反,它是我们在生产环境中做出的设计选择。我们最初的 SLA 指标是二元的,要么违反要么没有。问题在于它只在 SLA 已经错过之后才触发。工程师想知道管道何时达到其阈值的百分之七十到八十,以便在客户受到影响之前进行调查。切换到 0.0-1.0 的比率给团队提供了领先信号,允许他们在 0.7 设置警告,在 1.0 设置严重警报,而不是一个全有或全无的阈值。这种方法反映了 SRE 跟踪错误预算消耗率的实践,即观察预算消耗速度而不是仅在预算耗尽后才做出反应。
后续计划
- 如果您想应用此模式,您需要三样东西:
访问您的 Hudi 表的.hoodie/ - S3 目录
- 一个 Kafka 客户端来寻找任意偏移量
一个定期运行报告器的调度器。核心算法适用于任何写入 S3 的 Hudi Delta Streamer 管道。如果您在 Delta Lake
上使用结构化流,Spark 将 Kafka 偏移量存储在流检查点目录中,而不是表元数据本身,但原理是相同的:找到您框架持久化最后一个已提交的 Kafka 偏移量,定位到该位置,并计算时间戳差值。在我们的路线图上,我们正在评估将此方法用于我们的 Iceberg 管道,因为我们正在从 Hudi 迁移。我们还探索将这个指标与开源异常检测库(如 Prophet 或 Luminaire
)配对,而不是仅依赖固定的 SLA 阈值。
指标报告器会干扰HoodieStreamer的消费者组吗?不会。报告器使用专用的消费者组ID,与HoodieStreamer自己的组完全分离。它还设置了enable.auto.commit=false,因此永远不会向Kafka提交偏移量。它只读取并丢弃。不存在重新平衡或干扰摄取管道的风险。
如果某个主题分区为空怎么办?如果consumer.poll()在定位到检查点偏移后没有返回任何记录,报告器会将其视为空主题并报告零滞后。它不会挂起。五百毫秒的轮询超时限制了等待时间,并且显式处理了空结果。滞后默认为零,并且仍然会报告给指标系统。
这篇内容对你有用吗?
反馈只用于改善内容筛选,不等同于收藏