为什么幂等性在数据工程中如此重要

发布: (2025年12月14日 GMT+8 08:10)
10 min read
原文: Dev.to

Source: Dev.to

在数据工程中,失败是常态:作业崩溃、网络超时、Airflow 重试任务、Kafka 重放消息、回填重新运行数月的数据。在这个充满故障的世界里,幂等性 是保证数据正确、可信且可控的关键。

什么是幂等性?

如果一次运行或多次运行产生相同的最终结果,则该过程是幂等的。

  • 示例:处理 2025‑01‑01 数据的作业
    • 运行一次 → 正确结果
    • 运行两次 → 同样的正确结果
    • 运行十次 → 仍然是相同的结果

没有重复、没有膨胀、没有损坏。

为什么幂等性在分布式数据系统中很重要

现代流水线是分布式的:

  • Spark 作业可能因 executor 丢失而失败
  • Airflow(或 Dagster、Prefect)任务会自动重试
  • 云存储通常具有最终一致性
  • API 可能在请求中途超时

如果没有幂等性,重试会导致:

  • 数据双计数
  • 产生部分写入,破坏表格
  • 在“修复”失败时引入新 bug

幂等性把重试从风险转变为特性。编排器默认任务可以安全重试;如果你的任务不是幂等的,重试会悄悄引入数据错误,“绿色 DAG”会隐藏坏数据,调试几乎不可能。

回填

回填是不可避免的(逻辑变更、Bug 修复、迟到数据、模式演进)。有了幂等的流水线,你可以:

  • 自信地重新运行历史数据
  • 避免手动清理
  • 消除专门的回填代码路径

没有幂等性,每一次回填都是高风险,工程师害怕触碰旧数据,技术债务不断累积。

Exactly‑Once 与 At‑Least‑Once

  • Exactly‑once 保证复杂且成本高。
  • 分布式系统通常只提供 at‑least‑once 投递。

幂等性让你可以安全地接受 at‑least‑once 投递,通过优雅地处理重复数据。

设计幂等流水线

稳定的主键 & Upsert

使用稳定的主键(例如 order_iduser_id + event_time,或业务属性的哈希)。然后在读取时去重或在写入时合并:

MERGE INTO users u
USING staging_users s
ON u.user_id = s.user_id
WHEN MATCHED THEN UPDATE SET ...
WHEN NOT MATCHED THEN INSERT ...

优先使用 INSERT OVERWRITEMERGE,而不是盲目追加:

INSERT OVERWRITE TABLE sales PARTITION (date='2025-01-01')
SELECT * FROM staging_sales WHERE date='2025-01-01';

确定性转换

纯粹的转换:

  • 只依赖其输入
  • 每次产生相同的输出

避免使用非确定性函数:

  • CURRENT_TIMESTAMP
  • 随机 UUID 生成
  • 核心转换中调用外部 API

流式 & 增量作业

  • 保存 offset、watermark 或处理时间戳。
  • 设计同一窗口的重新处理为无操作(no‑op)。
  • 确保数据写入是幂等的、下游明确的,并受到严格控制。

副作用

将副作用(邮件、Webhook、API 调用)与数据转换分离。仅在最终状态成功写入后触发它们,并使副作用本身也是幂等的(例如使用去重键或请求 ID)。

实践中的注意事项

该做

  • ✅ 设计每个作业时假设它会被重试。
  • ✅ 使用覆盖或合并,而不是盲目追加。
  • ✅ 让作业确定且可重复。
  • ✅ 使用主键和去重逻辑。
  • ✅ 将回填视为一等公民。
  • ✅ 记录输入、输出和检查点。

不该做

  • ❌ 假设“此作业只会运行一次”。
  • ❌ 在没有防护的情况下追加数据。
  • ❌ 将副作用与转换混在一起。
  • ❌ 依赖执行顺序来保证正确性。
  • ❌ 在核心逻辑中使用非确定性函数。
  • ❌ 依赖人工清理重复数据。

如果重新运行你的流水线让你感到害怕,那它就不是幂等的。

幂等流水线设计检查清单

在设计评审、PR 评审和事后审计时使用此清单。回答核心问题:“如果这个流水线运行两次,结果仍然正确吗?”

1. 重试安全性

  • ⬜ 每个任务是否可以在无需手动清理的情况下重试?
  • ⬜ 如果作业中途失败并重新运行会怎样?
  • ⬜ 编排器(Airflow / Dagster / Prefect)是否会自动重试任务?
  • ⬜ 部分写入是否在重试时被清理或覆盖?
  • ⬜ 是否有明确的失败边界(每个分区、批次或窗口)?
  • 🚩 红旗:“我们从不重试这个作业。”

2. 确定性输入

  • ⬜ 输入是否明确限定(日期、分区、offset、watermark)?
  • ⬜ 输入源在重新处理时是否稳定?
  • ⬜ 是否以确定性的方式处理迟到记录?
  • ⬜ 是否防止重复读取重叠窗口?
  • 🚩 红旗:输入依赖 “now”、 “latest” 或隐式状态。

3. 写入策略

  • ⬜ 写入策略是 覆盖合并 还是 upsert
  • ⬜ 追加是否受到去重或约束的保护?
  • ⬜ 输出是否按确定性键(日期、小时、batch_id)分区?
  • ⬜ 单个分区是否可以安全重写?
  • 🚩 红旗:盲目的 INSERT INTO 或文件追加没有防护。

4. 记录身份

  • ⬜ 每个数据集是否有明确的主键或自然键?
  • ⬜ 去重逻辑是否明确且有文档?
  • ⬜ 键在重试和回填期间是否保持稳定?
  • ⬜ 去重是在读取时、写入时还是两者都进行?
  • 🚩 红旗:“不应该出现重复。”

5. 确定性转换

  • ⬜ 转换是否确定性?
  • ⬜ 是否避免使用 CURRENT_TIMESTAMP、随机 UUID 或其他非确定性函数?
  • ⬜ 是否将外部 API 调用排除在核心转换之外?
  • ⬜ 业务逻辑是否独立于执行顺序?
  • 🚩 红旗:每次作业运行时输出都会变化。

6. 增量逻辑

  • ⬜ offset、检查点或 watermark 是否可靠保存?
  • ⬜ 重新处理同一范围是否安全?
  • ⬜ “at‑least‑once” 投递是否得到正确处理?
  • ⬜ 流水线能否在不损坏数据的前提下回放历史数据?
  • 🚩 红旗:“我们无法回放这个 topic/table。”

7. 回填友好性

  • ⬜ 流水线是否可以针对任意历史范围运行?
  • ⬜ 回填逻辑是否与常规逻辑相同?
  • ⬜ 重新运行旧分区时是否能干净覆盖或合并?
  • ⬜ 下游消费者在回填期间是否受到保护?
  • 🚩 红旗:回填需要特殊脚本或手动 SQL。

8. 隔离的副作用

  • ⬜ 邮件、Webhook 或 API 调用是否与核心数据逻辑分离?
  • ⬜ 副作用是否仅在成功完成后触发?
  • ⬜ 副作用本身是否幂等(去重键、请求 ID)?
  • ⬜ 是否有防止双重通知的机制?
  • 🚩 红旗:副作用嵌入在转换步骤中。

9. 早期检测

  • ⬜ 重跑时行数是否保持一致?
  • ⬜ 数据质量检查是否可安全重跑?
  • ⬜ 是否监控重复、空值和漂移?
  • ⬜ 重跑和回填的血缘是否清晰?
  • 🚩 红旗:没有办法判断数据是否意外变化。

10. 文档与所有权

  • ⬜ 幂等行为是否有文档记录?
  • ⬜ 新工程师是否能安全地重新运行流水线?
  • ⬜ 恢复流程是否自动化,而非手动?

幂等性不仅是技术细节,更是一种设计哲学,使数据系统 更具弹性、更易运维、更低成本、更可信。在数据工程中,重新处理是不可避免的,失败是常态,幂等性决定了系统是脆弱的管道还是生产级别的系统。

Back to Blog

相关文章

阅读更多 »