ETL管道
批量导入到PostgreSQL(默认存储),使用Python(uv, ruff, pytest)。本地转换:polars 2.x 或 DuckDB 2.0。
流程
- 声明粒度:一行表示什么,由哪个自然键标识。没有键,就没有幂等加载;先定义一个键(或内容哈希)。
- 选择加载模式(见下)。选择增量策略。最后选择编排器。
- 编写运行逻辑,使得重新运行任何窗口都能得到相同的最终状态。验证它:在测试中运行两次,断言行数和校验和不变。
- 在首次生产运行前添加可观测性检查(见下)。
- 对照末尾的清单进行审查。
幂等加载模式
- 行具有稳定的自然键且可能变化:
INSERT ... ON CONFLICT (key) DO UPDATE(Postgres)或MERGE。添加WHERE target.hash IS DISTINCT FROM excluded.hash,使未更改的行不会造成更改。 - 数据作为一个完整的分区(天、批次ID)到达,并作为一个单元重写:在一个事务中删除-插入该分区。提交删除与插入分离会在崩溃时留下空洞。
- 整个表重建:加载到临时表,验证,然后交换(在一个事务中重命名)。读者永远不会看到半加载的表。
- 仅追加的事实,无键:添加一个确定性的
batch_id/ 源文件哈希,并使加载拒绝或替换已加载的批次ID。 - 绝不要盲目
INSERT然后再去重。在去重运行之前,重复项会到达仪表板。 - 通过
COPY加载到临时/暂存表,然后进行一次基于集合的更新插入。从Python逐行更新插入的速度慢10-100倍。
增量加载
- 在源表上使用单调递增且有索引的列作为水印(updated_at, sequence id)。将其存储在状态表中,与加载在同一事务中写入,绝不要放在旁边的文件中。
- 读取
watermark - lookback(例如1到24小时),而不是> watermark。源提交不按时间戳排序;lookback加更新插入可以吸收延迟行。根据测量到的延迟大小来设置lookback,而不是猜测。 - 水印列不可靠(更新时不更新,时区混合,软删除缺失):定期进行全量对账(键集差异或按分区计数+校验和),而不是信任它。
- 源端的硬删除对水印不可见。明确决定:通过键差异对账,或接受并记录。
- CDC(Postgres逻辑复制,Debezium风格日志读取器):仅当需要删除、亚秒级延迟或源无法按时间戳查询时使用。它会引入slot、保留风险(空闲slot会填满源磁盘)以及排序/快照处理。否则坚持使用水印。
回填
- 每个任务接收
--start和--end(半开区间[start, end),UTC),并据此推导所有内容。转换内部不要使用now()。 - 回填是对旧窗口的正常任务运行。如果需要不同的代码,则正常任务不是幂等的;修复它。
- 按分区分割,独立运行分区,记录每个分区的状态,以便在分区40失败时可以从40恢复。
- 通过相同的更新插入/分区替换回填到同一目标。检查是否重复计数:在每个窗口前后执行
count(*)和count(distinct key)。 - 对源API/DB进行限流;回填是自我造成的负载峰值。
重试、超时、“恰好一次”
- 跨网络不存在恰好一次的传递。构建至少一次传递加上幂等写入;结果是有效一次。
- 仅对幂等步骤进行重试,使用指数退避和抖动、尝试次数上限,以及对每个网络调用设置硬超时(连接和读取分别设置)。对429/5xx/超时重试,不对4xx重试。
- 遵守
Retry-After。没有全局速率限制的重试会放大故障。 - 发送电子邮件、收费或发布到webhook的步骤不是幂等的:使用在调用前存储的幂等键进行保护。
- 最终失败时以非零退出并告警;绝不要吞掉异常以保持调度为绿色。
Schema漂移
- 落地层宽松(将原始负载存储为JSON/文本以及加载元数据)。类型层严格,在类型/必填字段违反时大声失败。
- 新列:允许添加并发出告警。删除、重命名或重类型的列:使运行失败并显示差异;不要强制转换为NULL。
- 使用显式schema(polars schema、pydantic或DuckDB
DESCRIBE)在边界处进行验证,一次完成,不要分散在转换中。
抓取到数仓
- 两个阶段。阶段1获取并存储原始响应,不变且不可变(对象路径以源、获取时间、内容哈希为键)。阶段2将原始数据解析成表。
- 解析器错误随后通过重新解析存储的原始数据来修复,无需重新获取,无封禁风险,也不丢失历史。
- 记录URL、抓取时间戳、HTTP状态码、解析器版本到每个解析行。解析是原始字节加上解析器版本的纯函数。
- 抓取失败和空/被阻止的页面(验证码、带有登录墙的200响应)通过内容检查检测,而不仅仅通过状态码。
可观测性(最低要求)
- 记录每一步:读取行数、写入行数、拒绝行数、持续时间、窗口开始/结束、运行ID。结构化(JSON)行。
- 断言不变量:输出与输入的行数在容忍范围内;对通常非空的窗口返回零行导致运行失败。静默的零行成功是最常见的未检测故障。
- 心跳:在成功时和开始时ping外部健康检查;错过ping时告警(捕获cron未触发、主机宕机)。失败也ping失败端点。
- 在新鲜度上告警(加载的最大时间戳早于阈值),而不仅仅在任务错误上告警。
- 维护运行日志表(运行ID、窗口、状态、计数);它也是你的回填账本。
选择编排器
- Cron(或systemd timer)+ 一个脚本:适合一到几个独立任务,无跨任务依赖,团队能读取日志。默认选择。需要上述心跳和避免重叠运行的锁(
flock)。 - n8n:低负载,SaaS API之间的粘合剂,webhook触发,非开发者编辑流程。不适合大批量、重型转换或代码审查;将重逻辑放在它调用的Python脚本中。
- Airflow 3.x:多个DAG,跨任务依赖,一流的回填、重试和SLA,审计UI。真正的运营成本(调度器、元数据库、worker);对于少量任务不值得。Airflow 2到3的升级是带有破坏性变更的迁移;在复制代码片段前检查版本。对于Airflow上的dbt,使用Astronomer Cosmos。
- dlt:用于API或数据库摄取,自动处理schema推断、增量状态和合并/替换配置。用它代替手动编写分页提取+水印+更新插入代码;仍然对其配置应用上述规则。
- 容器:secrets作为Compose secrets从
/run/secrets/<name>读取,绝不要使用env vars或env_file;将发布的端口绑定到127.0.0.1或保留在内部网络上。
供应商技能
当任务需要dbt或Airflow的特定功能(模型、测试、DAG编写、操作符、Cosmos配置)时,不要临时发挥:使用通过此仓库的 de-vendors 技能拉取进来的官方供应商技能,仅当需要检查哪个版本是当前版本时,才回退到 references/landscape-2026-11.md 中的版本。
审查清单
- 声明了键和粒度;重新运行相同窗口产生相同的状态(已测试)。
- 删除和插入在一个事务中;水印随加载一起提交。
- 存在lookback;对延迟数据和删除有明确答案。
- 超时、带退避的有界重试、非幂等步骤已保护。
- 原始落地不可变;解析可分离且可重新运行。
- 记录了每一步的行数;存在零行和新鲜度告警;已配置心跳。
- Schema漂移在类型层大声失败。
- 编排器的选择基于任务数量和依赖关系,而不是习惯。
具体SQL和Python骨架:references/patterns.md。来自实际使用的教训:references/gotchas.md。