Apache Flink 死信队列模式:处理毒消息而不中断流
流式系统通常以两种方式之一出现故障:
- 响亮的故障:基础设施崩溃时。
- 安静的故障:一条坏记录不断重播,直到管道实际上陷入瘫痪。
第二种故障模式更加危险,因为它往往始于一些小问题:格式错误的 JSON、意外的 schema 变更、缺少必填字段,或是下游超时未被正确处理。
在 Apache Flink 中,一个未处理的异常就可能触发重启。如果恢复后,同一条有害消息仍然留在 Kafka 中,作业会再次读取它、再次失败、再次重启,从而进入循环。此时,管道在技术上处于“恢复中”,但运行上已经宕机。这正是生产环境的 Flink 作业从一开始就需要 死信队列(Dead Letter Queue, DLQ) 策略的原因。
一个正确的 DLQ 模式应做到三件事:
- 隔离坏记录,使其不影响好记录。
- 捕获足够的失败上下文,以便后续调试。
- 保留重播能力,使得被隔离的记录在根本原因修复后能够重新处理。
任何做不到这三点的方法都不是真正的 DLQ,而是要么静默丢数据,要么延迟宕机。
在本文中,我将介绍 Apache Flink 1.18 中最实用的 DLQ 模式:
- 侧输出作为核心 DLQ 原语
- 使用指数退避重试处理瞬时故障
- 按错误类型分层 DLQ 路由
- Kafka 和 S3 的 Sink 模式
- 指标与告警
- 使用专用重播作业进行重播
- 侧输出模式的 PyFlink 版本
目标很简单:一条坏消息永远不应静默消失,也永远不应静默阻塞流。
为什么毒消息会破坏本应健康的管道
毒消息是指任何持续处理失败的记录。典型例子包括:
- 格式错误的 JSON
- 不兼容的 schema 版本
- 缺少必填字段
- 无效的业务值
- 触发意外代码路径的记录
- 导致下游编排调用反复失败的消息
如果没有 DLQ 处理,故障路径通常如下:
- 记录进入管道。
- 反序列化或校验抛出异常。
- 算子失败。
- Flink 从最近一次检查点重启。
- 同一条记录被再次消费。
- 同样的异常再次发生。
这个循环可能无限持续下去,结果可以预见:
- 吞吐量降至零
- 下游消费者饥饿
- 检查点恢复无济于事
- 值班工程师因一条记录引发的问题而被召唤
这也就是为什么 DLQ 处理不仅是一种错误处理的便利手段,它是一种核心的可靠性模式。
Flink 中的 DLQ 应该长什么样
在流式架构中,DLQ 是一个持久化的目标位置,用于存放无法成功处理的记录。在 Flink 中,DLQ 记录通常应包括:
- 原始负载
- 错误类型
- 错误消息
- 堆栈跟踪或简化的失败上下文
- 失败时间戳
- 源元数据(如主题、分区或偏移量)——如果可用
这些信息之所以重要,是因为一个 DLQ 只有在后来有人能回答两个问题时才有用:
- 这条记录为什么失败?
- 问题修复后,如何安全地重播它?
如果只记录异常,就会丧失重播能力;如果只存储负载,就会丢失调试上下文;如果直接丢弃记录,则两者都失去。
因此,设计目标不是“捕获异常”,而是可持久、可观察、可重播的故障处理。
模式 1:使用侧输出作为核心 DLQ 原语
Flink 中最自然的 DLQ 机制是侧输出。侧输出允许一个算子向多个流发送记录:
- 主流:成功记录
- 一个或多个侧流:失败、迟到数据或被隔离的记录
这使得它成为 DLQ 路由的正确原语。
定义 DLQ 信封和输出标签
重点在于,DLQ 记录不仅仅是被丢弃的负载,而是一个信封,保留足够的信息以供分类和重播。
在 ProcessFunction 内部路由失败
这是最小可行的 DLQ 模式,已经解决了最重要的操作性问题:坏记录不再阻挡好记录。
连接主流和 DLQ 流
如果只做一件事,就做这个。侧输出应作为 Flink 中 DLQ 的默认基础。
模式 2:在升级到 DLQ 之前重试瞬时故障
并非所有故障都应立即进入 DLQ。有些故障是瞬时的:
- 下游服务临时不可用
- 数据库调用超时
- 外部 API 被限流
- 网络依赖短暂不稳定
如果把这些都直接发送到 DLQ,就会产生噪声,埋没真正有问题的记录。更好的模式是:
- 对瞬时故障进行有限次数的重试。
- 使用指数退避。
- 仅在重试用尽后升级到 DLQ。
使用 KeyedProcessFunction 和定时器进行重试
为什么这在 Flink 中特别有效
这个模式在 Flink 中比其他许多流处理器更强大,因为定时器和状态是可检查点的。这意味着:
- 重试计数器在重启后依然存在
- 待处理事件在重启后依然存在
- 定时重试在恢复后继续执行
换句话说,重试工作流本身是容错的,这正是处理长期运转流中瞬时故障时所需要的。
模式 3:按故障类型拆分 DLQ
管道成熟后,单一 DLQ 主题通常变得过于粗糙。Schema 故障、业务验证故障、重试用尽故障和未知异常都混在一起,使得分类变慢,重播更困难。
更好的模式是对故障进行分类,并路由到不同的 DLQ 流。
定义故障层级
按异常类路由
为每个层级定义一个输出标签
独立 Sink 每个层级
这使得 DLQ 在操作上更有用,而不仅仅是技术上的正确。例如:
- Schema 故障可路由给生产者团队
- 业务规则故障可输入数据质量工作流
- 未知故障可触发更高严重级别的告警
模式 4:根据恢复计划选择 DLQ Sink
记录被路由到 DLQ 流后,需要一个持久化的目标。实践中,最常见的两种选择是 Kafka 和对象存储。
Kafka DLQ Sink
Kafka 适合以下需求:
- 近乎实时的检查
- 流式重播
- 与现有消费者的操作集成
S3 DLQ Sink
对象存储更适合以下需求:
- 长保留期
- 低成本隔离
- 使用 Spark 或 Athena 进行批量重播
- 按日期或错误类型分区的存储
生成环境中一个实用的模式是:
- 使用 Kafka 进行短期操作处理
- 使用 S3 进行长期隔离和重播
这样既保证了快速响应,也保留了持久的历史。
模式 5:监控 DLQ 率,而不仅仅是作业运行时间
一个没人监控的 DLQ 只不过是改了个名的积压队列。仅看作业运行时间是不够的:一个 Flink 作业可以在保持绿色状态的同时,悄悄将 10% 的流量路由到 DLQ,这仍然是一个生产事件。
在算子内部添加指标
基于 DLQ 率告警
一个有用的告警是 DLQ 吞吐量与成功吞吐量的比率:
经验法则:
- 超过 1% 通常表明 schema 漂移或生产者问题
- 超过 5% 通常表明更广泛的系统性问题
具体阈值取决于管道,但原则不变:将 DLQ 率作为一级健康信号进行监控。
模式 6:使用专用重播作业进行重播
只有当重播成为可能时,DLQ 才完整。最干净的设计是一个单独的 Flink 作业,它从 DLQ 主题中读取记录,并通过主处理逻辑重新路由它们。
重播作业示例
为什么重播应是独立作业
将重播与主管道分离,可以带来:
- 独立扩缩容
- 独立调度
- 更清晰的检查点行为
- 更安全的操作控制
它还允许您按照自己的节奏处理积压:
- 在非高峰时段
- 降低并行度
- 或在需要快速追赶时最大化并行度
这种分离使得主管道保持稳定,同时使恢复变得可行。
PyFlink 版本:相同模式,相同原则
如果您的团队使用 PyFlink,相同的侧输出模式同样适用。语法变了,但设计原则不变:好记录继续运行,坏记录被隔离并持久化。
生成环境检查清单
在交付 Flink 管道之前,请验证以下内容:
这个清单值得在代码审查或部署就绪检查中自动化。DLQ 处理太重要了,不能仅靠约定。
关键要点
如果您在生产环境中构建 Flink 管道,最安全的默认做法是:
- 使用侧输出进行 DLQ 路由
- 在升级之前重试瞬时故障
- 将故障分类到不同的 DLQ 流
- 将 DLQ 记录持久化到 Sink
- 导出 DLQ 指标
- 通过专用作业进行重播
核心规则很简单:一条坏消息永远不应静默消失,也永远不应静默阻塞流。这正是将 DLQ 处理从防御性编码技巧转变为真正可靠性模式的关键。
环境说明
本文示例针对:
- Apache Flink 1.18
- Java 17
- PyFlink 1.18
一些实现说明:
- 重制定时器模式要求在
KeyedProcessFunction之前对流进行 keyBy - RocksDB 通常是处理较大重试状态时更安全的状态后端
- HashMap 状态后端适合较小、延迟敏感的工作负载
AT_LEAST_ONCE通常足以用于 DLQ Sink
最后思考
毒消息在流式系统中并不罕见,它们是不可避免的。真正的问题在于一条坏记录是否能让一个本应健康的管道宕机。通过在 Flink 中设计正确的 DLQ,答案是:不会。
流继续向前。好记录继续处理。坏记录被隔离。告警被触发。重播仍然可能。在根本原因被修复的同时,管道保持运行。
这就是在测试环境中能工作的流与能在生产环境中生存的流之间的区别。
DZone 贡献者表达的观点是其个人观点。