周末大乌龙:数据管道竟重复处理同一数据47次

2 阅读6分钟

数据管道因重试逻辑缺陷,致周六数据重复处理47次。根源是“回退处理上个成功日数据”。修复:移除回退、明确幂等、验证日期。教训:严谨幂等、周末测试、数据不符即报错。

译自:The Weekend Our Pipeline Processed the Same Data 47 Times

作者:Pradeep Kalluri

某个周一早上,我们的分析团队开始发现有些不对劲。根据仪表盘显示,周末客户交易量猛增了4,700%。不可能的数字随处可见。我们的欺诈检测系统标记了数千个可疑模式。执行团队已经开始提出疑问。

我带着沉重的心情拉取了日志。果然,我们的生产数据管道在周六下午到周一早上之间,处理周六的数据不是一次,不是两次,而是47次。

调查过程

第一个线索在 Airflow DAG (有向无环图) 历史记录中。整个周末,每隔几个小时,一个任务就会失败,触发重试,然后成功。正常行为,只是每次“成功”的重试都一次又一次地处理了同一日期的数据。

我开始深入挖掘我们的 PySpark 作业日志。执行时间戳讲述了故事:周六下午2点、周六下午5点、周六下午8点、周日上午、周日下午。每次运行在日志中都显示相同的执行日期,但都在重新处理周六的交易。管道应该是幂等的。我们花了数周时间构建重试逻辑,专门用于优雅地处理瞬时故障。然而,我们却有47份相同的数据存储在我们的 Snowflake 数据仓库中。

根本原因

我们的重试逻辑存在一个微妙但关键的错误。以下是本应发生的情况:

  1. 任务失败(网络超时、临时错误等)
  2. Airflow 触发重试。
  3. 任务以相同的执行日期重新运行。
  4. 数据被重新处理,去重逻辑处理它。

实际发生的情况是:

  1. 任务处理周六数据失败。
  2. 我们的“智能”回退逻辑启动:“如果当前日期失败,则处理上一个成功日期的数据。”
  3. 任务成功 — 再次处理周六的数据。
  4. 下一个预定运行:“处理周日数据,但如果失败,则回退到周六……”
  5. 周日处理失败(不同的问题)。
  6. 回退再次处理周六数据。
  7. 重复47次。

我们编写回退逻辑时,它看起来是合理的。“始终交付一些东西”感觉比“完全失败”更安全。我们没有意识到自己创建了一个循环,其中临时故障会导致我们反复处理陈旧数据。

调试过程

查找这个 bug 花了比预期更长的时间,因为管道在 Airflow 中显示“成功”。每个任务都带有绿色复选标记完成。数据正在进入 Snowflake。从工作流的角度来看,一切都很好。

突破口出现在我将日志中的执行日期与已处理文件中的实际数据日期进行比较时。它们不匹配。标记为“execution_date=2024-11-10”的任务正在处理来自“data date=2024-11-09”的数据。

一旦我看到这种差异,回退逻辑就变得显而易见。我找到了代码:

try: 
   data = load_data(execution_date) 
   except DataNotAvailableError: 
      logger.warning(f"Data for {execution_date} not available, using previous date")
      data = load_data(previous_successful_date)

这看起来是防御性的。但它违反了一个关键原则:执行日期和数据日期必须始终匹配。

修复

解决方案包含三个部分:

  1. 完全移除回退逻辑。 如果执行日期的数据不可用,任务就应该失败。句号。

没有巧妙的变通方法。

  1. 明确幂等性。 我们在 Snowflake 中添加了一个合并操作,使用执行日期作为去重键的一部分:
MERGE INTO target_table t 
USING source_data s 
ON t.transaction_id = s.transaction_id 
AND t.execution_date = s.execution_date 
WHEN MATCHED THEN UPDATE ... 
WHEN NOT MATCHED THEN INSERT ...
  1. 添加执行日期验证。 管道的每个阶段现在都验证它正在处理正确的日期:
def validate_execution_date(data, expected_date): 
   actual_date = data['date'].max() 
   if actual_date != expected_date: 
      raise ExecutionDateMismatchError(f"Expected {expected_date}, got {actual_date}"

恢复

清理47份相同的数据并非易事。我们不能简单地删除所有数据并重新处理。我们有46份重复副本,混杂着周日和周一的合法数据。

我们最终编写了一个清理脚本,通过交易ID和执行日期识别重复项,只保留周六每笔交易的第一次出现。它运行了六个小时,之后需要仔细验证。

我的经验教训

  • 幂等性需要严谨。 光说“我们的管道是幂等的”是不够的。每次重试、每次回退、每个“巧妙”的错误处理都需要维持这个保证:相同的输入 → 相同的输出。
  • 用周末数据进行测试。 我们的所有测试都运行在连续的工作日数据上。周六和周日的数据模式不同,交易量较低,交易类型不同,时间安排也不同。如果我们用周末数据进行测试,就会立即发现这个问题。
  • 在数据不匹配时大声报错。 执行日期和数据日期应始终匹配。当它们不匹配时,说明存在严重问题。快速失败可以防止46次不必要的重试。
  • 也监控成功的运行。 我们有针对失败的警报,但我们没有监控成功的运行是否处理了正确的数据。从那以后,我们添加了数据质量检查,以验证已处理数据的时间范围。
  • 警惕“防御性”代码。 回退逻辑看起来是合理的:始终交付一些东西而不是什么都不交付。但在数据管道中,交付错误的数据通常比完全不交付数据更糟糕。

事件后续

三个月后,我们的管道有了更好的监控、更清晰的错误消息,以及讽刺的是,更简单的重试逻辑。我们移除了“巧妙的”回退。任务要么带着正确的数据成功,要么明确失败。

这次事件让我们花费了一个周末的手动清理工作,并与利益相关者进行了一些关于数据质量的不舒服的对话。但它给整个团队上了一堂宝贵的课,让他们认识到“能工作”的代码和可靠的生产系统之间的区别。

如果你的重试逻辑包含“回退到”或“使用之前的数据”等短语,请仔细检查。你可能离自己的47倍事故只差一个失败的任务。