2960 字
约 9 分钟
17
S10-A09 · 数据脚本的可重复性:从一次性脚本到可重跑任务

「这个脚本上次跑出来的数不一样了,没人知道为什么。」于是只能再跑一次,挑一份「看起来合理」的交上去。这句话出现一次是偶然,出现三次说明脚本本身不可信。

可重复性不是「代码写得优雅」,而是同一个问题问两次,得到同一个答案,并且能说清第二次为什么和第一次不同。前一半是工程习惯,后一半才是决策依据。

判断一个脚本要不要变成可重跑任务,只有一个问题:它会被问第二次吗。 答案不确定时,按「会」来设计更便宜——因为返工的成本总在发现不一致的那天结算。


1. 三个前提与它们的价码

前提 失败时的症状 最便宜的修法
输入确定 同一份代码,两次结果差几个百分点 固定快照、时区、种子、排序
环境确定 换台机器结果就变 锁依赖、固定解释器、记录系统库
副作用可控 重跑一次,数据翻倍 幂等键、覆盖写、可回滚

三个前提的代价递增,排查难度也递增。输入不确定通常五分钟就能看出来——两次结果 diff 就露馅;副作用不可控的 bug 往往要等到对账那天才爆,而那时已经没人记得跑过几遍。

2. 一次性脚本与可重跑任务的分界线

判据 一次性脚本 可重跑任务
会被问第二次 否 是
结果进决策 不进 进
别人会跑 不会 会
出错需要回溯 不必 必须能复现
数据量可人工核对 可以 不行

分界线不是代码长度,也不是数据量,而是「第二次」这个动作。第二次意味着有人要复现你的判断,而人不会记得你当时随手加的过滤条件。

3. 输入确定:四件事

项 不固定的后果 固定方式
数据快照 上游今天多了一天数据 按分区与时间上界读,写进 manifest
时区 同一时刻算出两个「今天」 显式 TZ,全链路统一
随机种子 抽样每次不同,指标跟着抖 seed 固定并落盘
排序 去重、取首条的结果不稳 显式 order by 全排序键
// 四件事写进一份 manifest,随结果一起存档,事后对照就靠它
manifest = {
    "snapshot_upper": "2026-11-01T00:00:00Z",
    "tz": "UTC",
    "seed": 42,
    "sort_keys": ["order_id", "updated_at"],
    "code_version": git_rev,
}
json.dump(manifest, open("out/manifest.json", "w"), ensure_ascii=False)

排序这一项最容易被忽略:group by 之后取第一条,看起来稳定,实际取决于扫描顺序。加了索引、换了并行度,结果就变。

4. 环境确定的三层

层 典型漂移 固定手段 漂移后的症状
解释器 3.10 升到 3.12,默认行为变 写死 minor 版本 个别脚本才出错
依赖版本 pandas 升级改了默认 dtype lock 文件带 hash 数值差在小数位
系统库 glibc、ICU、时区库 镜像 tag 加 digest 换机器才复现
// 三层各留一份证据,出问题先比这三行,别先读代码
$ python -V && python -c 'import pandas,sys;print(pandas.__version__, sys.executable)'
$ pip freeze > out/req.lock && sha256sum out/req.lock
$ docker image inspect "$IMG" --format '{{.Id}}'

只锁 requirements.txt 不锁哈希,等于锁了一半:同名版本被重新发布的情况不多,但私有源覆盖是常态。

5. 副作用的三种与幂等化

副作用 重跑的后果 幂等化手段 残留风险
写库 行数翻倍、金额翻倍 唯一键加 upsert,或分区覆盖 唯一键选错会覆盖真数据
发消息 重复通知、重复扣款 业务幂等键加消费端去重 下游不去重则等于没做
改文件 半截文件被下游读到 临时文件加原子 rename 多文件之间仍不原子
// 覆盖写比追加安全:先按分区清,再插入,失败可整段重来
DELETE FROM dw.metric_daily WHERE dt = :dt;
INSERT INTO dw.metric_daily (dt, k, v) SELECT :dt, k, v FROM stage_metric WHERE dt = :dt;

写库的幂等判据只有一句:同一个分区跑两遍,行数不变。 做不到这一点,其余讨论都是修辞。

6. 重跑策略与各自的正确性代价

策略 适用条件 正确性代价 代价落在谁身上
全量重跑 数据量可承受、上游可重现 最低 时间与机器成本
断点续跑 长任务、外部接口限流 中:断点边界易重复或漏算 实现复杂度
增量补齐 数据量大、只补缺口 最高:口径随补丁漂移 之后的对账与解释
// 分区覆盖加水位线:单分区失败不影响其他分区,宁可重跑也不漏
for dt in missing_partitions(start, end):
    run_partition(dt)          // 只写这一个分区
    write_watermark(dt)        // 水位线落后于数据,重跑是安全的

增量补齐最危险的地方不在代码,在口径:实时链路和补数链路各有一套过滤条件,补完之后总数对不上,谁都说不清哪边是对的。所以补数脚本要么复用同一段 SQL,要么补完立刻做一次全量对齐。

7. 「结果可解释」比「结果正确」更实用

要素 记在哪 缺了之后的表现
每个数字的来源表 manifest 被问「这个数哪来的」答不出
口径:过滤、去重、币种 入参加一行说明 两个人算出两个数
版本戳:代码、数据、依赖 manifest 旧结果无法复现
中间产物 按分区落盘 只能整段重跑

「结果正确」需要外部权威,「结果可解释」只需要一条自洽的链条。多数数据脚本的真实要求是后者:评审要的不是绝对正确,是你能说清每个数字怎么来的。

// 每个输出列挂一行来源,评审时对着念即可
out = df.assign(
    src_table="ods.order",
    src_filter="status in ('PAID','SHIPPED') and dt between :s and :e",
    metric_def="sum(amount) group by dt, channel",
)

8. 时间依赖:最常见的不可重复来源

来源 症状 处理
now() / today() 昨天跑和今天跑结果不同 把当前时间作为入参注入
滚动窗口 窗口边界随运行时刻滑动 固定窗口上界到分区边界
夏令时切换 某天少一小时或多一小时 全链路 UTC,展示层再转本地
月末与账期 31 号与 1 号的结果不连续 用账期字段,不用日历月推算
数据延迟 跑得太早,尾部数据没到 水位线加等待条件
// 时间作为入参,脚本内部不读系统时钟,这是可重复的第一道闸
def run(as_of: datetime, tz: str = "UTC") -> None:
    start, end = window(as_of, tz)
    ...

凡是脚本里出现一次无参的「当前时间」,就有一个不可重复的分支。把它挪到入口参数,代价是一行签名,收益是全部重跑都可控。

9. 生命周期与升级信号

阶段 特征 升级信号
临时 跑一次就扔 第二次被要同一份数
定期 每周手工跑,靠人记 忘了跑导致下游等人
产品化 有调度、有告警、能回填 需要对外承诺时间或口径

升级信号不是「代码变丑了」,而是「有人开始依赖它的时间点」。依赖时间点一旦出现,手工触发就不再是可接受形态。

10. 度量

度量 定义 分母 读法
重跑一致率 同输入两次结果完全一致的次数 重跑次数 最直接的可重复性指标
手工干预次数 需要人工改参或补数据的次数 运行次数 偏高说明输入不干净
失败重试成功率 重试成功次数 / 重试次数 重试次数 偏低说明副作用不可控
回填耗时 补一个分区所需时间 分区数 决定历史能否补回来

这四个数不需要平台,脚本自己在末尾追加一行日志就够。区别在于有没有人看——没人看的度量只是日志。

11. 排错:六种「跑出来不一样」

现象 原因 处理
两次结果差一点 上游数据在两次之间变了 固定快照上界,先比 manifest
换机器结果不同 依赖或系统库漂移 比 lock 文件与镜像 digest
重跑后行数翻倍 追加写,无唯一键 改分区覆盖或 upsert
补历史后总数对不上 补数口径与实时不同 复用同一段 SQL,补完做全量对齐
定时任务偶发失败 上游未就绪就开始跑 水位线加等待与重试上限
只有某人能跑通 环境在他本机 容器化,入参化,落 manifest

12. 边界:什么时候不必产品化

场景 做到什么程度 理由
一次性探索分析 记下输入与结论 不参与决策,不需要复现
数据量小到可人工核对 输出明细加一行口径 人能复核就不必工程化
结果不进决策 留脚本与 manifest 出错成本低
有审计要求 全量留痕、可回填 需要追溯
上游本身不可重现 快照加说明,不承诺可重复 承诺不了的事别写进约定

最后一条值得单独说:可重复性是对下游的承诺。当上游是个随时变化的数据源,你能承诺的只是「用这份快照能复现」,而不是「明天还能算出同一个数」。把这两件事混同,就会得到一个永远修不好的脚本。


13. 自测

  1. 一个脚本只跑过一次,你怎么判断它该不该做成可重跑任务?
  2. 输入确定有四件事,哪一件最容易被「代码看起来没问题」掩盖?
  3. 为什么只锁 requirements.txt 不算环境确定?第三层漂移的症状是什么?
  4. 三种重跑策略里,哪一种的正确性代价最高?代价为什么由对账承担?
  5. 「结果可解释」与「结果正确」分别需要什么作为依据?
  6. 上游数据源不可重现时,你能对下游承诺什么,不能承诺什么?
S10-A09 · 数据脚本的可重复性:从一次性脚本到可重跑任务
https://www.clxhxhhr.top/posts/6273/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。