DeepSeek总结的 waddler,一个 Go 语言编写的从 YAML 文件运行的 ETL 管道 存储库https://github.com/mehrabr/waddler来源https://mehrabr.com/projects/data%20engineering/2025/05/27/building-waddler.html构建 waddler或者我们真的需要为此使用 Spark 吗作者:Mehrab Rahman发布日期:2025年5月28日阅读时间:12分钟我从事数据工程工作已经有一段时间了主要处理比较重量级的东西Spark 集群、Databricks这类基础设施成本高昂且需要一个团队来维护。最近我一直在想这些复杂性中有多少是真正承重的又有多少只是我们出于习惯而附带使用的。今年早些时候我在德克萨斯州的一个客户现场工作处理一个本应相当普通的数据管道这时一个在 MotherDuck 工作的朋友提出了一个让我印象深刻的观点大多数运行在 Spark 集群上的分析工作负载在单台机器上也能运行得很好而且运行速度更快、成本更低并且不需要维护任何基础设施。这基本上就是 MotherDuck 的全部理念我发现自己想通过实际构建一些东西来检验这一点。所以我开始了一个小项目来寻找答案。结果就是 waddler一个 Go 语言编写的二进制文件它使用 DuckDB 作为查询引擎从 YAML 文件运行 ETL 管道。没有 Python 环境没有集群没有编排平台。我想记录下构建这个项目的过程因为我仍然不完全确定自己做出的所有选择是否正确而且这个过程本身可能值得思考。有意从小处着手我一直很欣赏极简的代码项目也许这种欣赏超出了纯粹理性的范畴。约束能够揭示很多问题也让你了解自己真正知道什么。当你剥离掉你习惯使用的框架、样板代码和抽象时你会很快发现你理解的哪些部分是扎实的哪些部分实际上依赖于你未曾意识到的工具。老实说我通过这种方式学得最好构建一个足够小、能完全装进脑子里的东西然后观察它在哪里变得复杂。我就是这样开始这个项目的。今年早些时候在德克萨斯州项目工作时我利用业余时间搭建了一个快速的 Python 管道作为草稿读取一些 CSV 文件用 pandas 做一些转换写出 Parquet 文件。结构清晰有提取器、转换器、加载器和管道运行器还有 pytest 覆盖。整个代码舒适地放在几个文件中。它运行得很好但构建它让我意识到一件有点尴尬的事我在转换器中做的大部分事情用 SQL 写就好了。过滤掉负数、将一列转为大写、分组求和、降序排序。在 pandas 中这可能需要十五行代码和四五个每次都要查的方法调用。而在 SQL 中它只是一个 SELECT 语句而我已经会 SQL 了。所以我一直无法停止思考的问题是我到底为什么要把它转换成 pandaspandas 给了我什么数据库给不了我的东西DuckDB 是对这个问题的一个奇怪而有趣的答案我以前用过 DuckDB但主要是作为 Parquet 文件上的查询工具而不是作为在其上构建东西的基础。我越深入了解它就越意识到它和我脑子里给它归的类不是一回事。让我豁然开朗的比较是SQLite 是一个文件中的关系数据库针对应用程序中常见的事务模式进行了优化即大量的小型读写、索引查找等。DuckDB 是一个文件中的分析数据库针对数据工程中常见的模式进行了优化宽表、聚合、跨不同格式文件的连接。这些在 SQLite 中会很痛苦而在 Spark 中仅仅为了开始就需要启动一个集群。真正让我惊讶的是 DuckDB 在文件级别处理了多少事情。你不需要将 CSV 加载到 DuckDB 中然后查询你直接查询 CSV 文件DuckDB 会推断出模式惰性读取它并用向量化引擎处理它。Parquet、JSON、托管在 S3 上的文件也是如此。我总以为会遇到需要自己做些管道工作的步骤结果就是……没有。GROUP BY ALL也非常好用。你不再需要在GROUP BY子句中列出每个非聚合列只需说ALLDuckDB 就会推断出来。小事一桩但它减少了用户编写 SQL 时的一整类拼写错误当你的目标用户不是自己写 SQL 的数据工程师时这一点非常重要。DuckDB 是每个 ETL 工作负载的正确选择吗显然不是我很好奇在实践中它的实际上限在哪里。文档说它可以在笔记本电脑上处理数百 GB 的数据这对我来说听起来是对的但我还没有真正对它进行压力测试。对于我所针对的小型组织用例它似乎是正确的选择。有谁在实际工作负载中将其推向极限了吗为什么选择 Go以及这真的是正确的选择吗我在这件事上犹豫不决。我当时已经有了可用的 Python 版本。我很熟悉 Python。回到 Go 意味着要重新学习我已经生疏的东西自上次我构建独立工具以来模块系统已经发生了很大变化我不得不重新学习一些标准库模式比如log/slog以及database/sql接口如何与非标准驱动如go-duckdb协作。推动我选择 Go 的是分发问题。一个 Python 工具意味着用户必须拥有 Python并且是正确的 Python 版本还要有虚拟环境而且pip install必须能够工作。对于我设想的用户——一个在食物银行工作的志愿数据协调员手里有 CSV 文件但没有工程背景——这条依赖链有很多故障点。一个 Go 二进制文件就是一个文件。你下载它并运行它。这个过程要简单得多。Go 还迫使我用 Python 不会真正要求的方式仔细思考结构。cmd/目录放二进制文件internal/放你不想导出的包testdata/放测试夹具这些是社区已经达成的约定我认为它们是好的但 Go 并不强制执行它们所以你必须有意选择它们。我遵循了它们主要是因为我正在构建一个我希望其他工程师会阅读的东西而看起来符合人们预期的代码是有价值的。CGO 要求是如果可能我会重新考虑的一件事。DuckDB 的 Go 驱动静态链接了 C 库这意味着你需要一个 C 编译器来构建而且交叉编译会很快变得复杂。我在 README 中包含了特定平台的构建说明但通过 GitHub Releases 提供预构建的二进制文件才是真正的解决方案我还没有做到那一步。如果你找到了一个用于跨平台发布 CGO 二进制文件的干净 CI 工作流我真的很想知道这是剩下的主要摩擦点。设计YAML 作为用户界面waddler 的核心在于用户编写 YAML 文件而不是代码。一个管道看起来像这样name:monthly-donor-reportsources:-name:donationstype:csvpath:data/donations_2024.csv-name:donorstype:csvpath:data/donors.csvtransform:|SELECT d.donor_id, dn.name, ROUND(SUM(d.amount), 2) AS total_donated, CASE WHEN SUM(d.amount) 1000 THEN major WHEN SUM(d.amount) 100 THEN regular ELSE small END AS donor_tier FROM donations d JOIN donors dn USING (donor_id) WHERE d.amount 0 GROUP BY ALL ORDER BY total_donated DESCoutput:type:parquetpath:output/donor_report.parquettransform字段就是 SQL。DuckDB 将每个源注册为一个命名视图因此 SQL 可以直接引用donations和donors。当管道运行时它会创建一个内存中的 DuckDB 连接注册视图运行转换并写入输出。运行之间不会持久化任何内容。我仔细考虑过是否要让transform字段成为指向.sql文件的路径而不是内联 SQL或者是否支持某种更高级别的 DSL。我目前选择内联 SQL 是因为它将所有内容保持在一个文件中而且目标用户是能够编写 SELECT 语句但可能无法驾驭多文件项目结构的人。但我不完全确定这是否正确对于包含大量 CTE 的复杂转换内联 YAML 很快就会变得笨拙。一个transform_file: transforms/clean_donors.sql选项与内联 SQL 并存似乎是自然的补充或者可能两者都有如果提供了文件则优先使用文件。好奇这里是否有人有强烈的意见。包结构以及一个我仍在思考的决定Go 的内部逻辑分为五个包config负责 YAML 模式和验证engine包装 DuckDB 连接source将源类型映射到CREATE VIEW语句loader处理输出runner编排一切。source包是我对可扩展性最不确定的地方。目前添加一个新的源类型只需在source.go的 switch 语句中添加一个 case其他什么都不用改我喜欢这样。但随着源类型数量的增长——SQLite、MySQL可能最终还有 Excel 文件——switch 语句开始感觉形态不对了。一种插件式的模式每个源类型在init()中注册自己可能扩展性更好但会增加间接性使代码更难理解。在这个规模下我不确定我更倾向于哪种权衡。engine包是一个薄接口这意味着你可以用模拟对象测试config和runner包这很好也是一个刻意的选择。但这也意味着engine接口将 DuckDB 特定的关注点比如ExportParquet泄漏到了本应是抽象的东西里。我一直在反复思考正确的答案是一个更通用的接口将 DuckDB 特定的东西推入loader还是说这对于一个永远会使用 DuckDB 的东西来说是过度设计了。目前倾向于可能是过度设计了。验证规则我没有预料到的事情在构建运行器的中途我意识到我一直担心的故障模式不是错误的 SQLDuckDB 驱动会立即捕获它并给出清晰的错误信息。而是静默的错误数据。因为有人在源头上重命名了一列而导致连接返回零行。因为退款输入了错误的符号而导致总计变为负数。一个技术上有效但包含垃圾数据的 Parquet 输出文件。所以我添加了一个validate块validate:-name:no negative totalssql:SELECT COUNT(*)FROM ({transform}) WHERE total_donated 0expect:0-name:at least one major donorsql:SELECT COUNT(*)FROM ({transform})expect_min:1{transform}占位符在运行时替换为管道的实际 SQL因此验证查询针对相同的结果集运行而无需将其物化两次。如果任何规则失败管道会在写入任何内容之前停止你会得到一个错误而不是一个可能一周都不会被注意到的损坏的输出文件。我对这个结果相当满意。它足够简单非工程师也能表达他们的期望“永远不应该出现负数”但对于细微的数据质量检查来说它也足够可组合。我仍然未决定的一点是失败的验证应该是硬性停止还是应该有一个warn_only模式记录日志并继续在某些情况下你想知道问题存在但仍然希望得到输出。感觉这应该是一个标志但我不确定我是否想鼓励明知会输出坏数据的管道。你怎么看调度因为最后一公里总是最难的我在关于小型组织数据基础设施的对话中反复看到的一个模式他们有一个每周一更新的电子表格或 CSV他们希望在周一早上之前处理完并可以查询而团队中唯一懂技术的人不知道如何配置 cron 作业也不知道云调度器的成本是多少。所以我添加了一个schedule字段和一个serve子命令。schedule:0 6 * * *waddler serve pipeline.yml这会阻塞并在 cron 调度上运行管道直到你终止它。将其放入 systemd 服务或廉价 VPS 上的 Docker 容器中它将无限期运行。实现使用了robfig/cron进行表达式解析并使用select {}阻塞主 goroutine这只是一个标准的 Go 习惯用法表示“保持运行直到被终止”。但是cron 语法是面向用户的调度界面吗对于我目标受众来说可能不是。一个想要“每周一早上 7 点”运行的非技术用户必须弄清楚那是0 7 * * 1这并不明显而且很容易出错。像schedule: daily或schedule: weekly这样的命名预设似乎更可用同时为需要的人保留完整的 cron 语法。可能会在后续版本中添加这个在实现方面这是一个小改动。项目现状Waddler 在 MIT 许可下托管在 GitHub 上。使用以下命令安装CGO_ENABLED1goinstallgithub.com/mehrabr/waddler/cmd/waddlerlatest当前的源类型CSV、JSON、Parquet、Postgres、MotherDuck。输出类型Parquet、CSV、MotherDuck支持追加或替换模式。examples/目录包含用于捐赠报告、Postgres 到 Parquet 迁移和定时同步的工作管道。我仍然真正感到不安的事情是复杂转换的内联 SQL 与文件引用、插件式源注册与当前的 switch 语句、硬性验证失败与仅警告模式、是否值得添加自然语言调度预设以及预构建的 CGO 二进制文件。如果你对这些问题中的任何一个有过思考或者从构建类似项目中获得了见解我真的很想听听。关于我最初提出的更广泛的问题——企业数据工程中有多少复杂性是真正必要的——我认为 waddler 是一个小小的证据表明答案比我们通常认为的要少。waddler 是否是 DuckDB 之上的正确抽象或者是否存在更好的抽象我不确定。但小项目总能教会你比你预期更多的东西而这个项目已经做到了。