「这个脚本上次跑出来的数不一样了,没人知道为什么。」于是只能再跑一次,挑一份「看起来合理」的交上去。这句话出现一次是偶然,出现三次说明脚本本身不可信。
可重复性不是「代码写得优雅」,而是同一个问题问两次,得到同一个答案,并且能说清第二次为什么和第一次不同。前一半是工程习惯,后一半才是决策依据。
判断一个脚本要不要变成可重跑任务,只有一个问题:它会被问第二次吗。 答案不确定时,按「会」来设计更便宜——因为返工的成本总在发现不一致的那天结算。
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. 自测
- 一个脚本只跑过一次,你怎么判断它该不该做成可重跑任务?
- 输入确定有四件事,哪一件最容易被「代码看起来没问题」掩盖?
- 为什么只锁
requirements.txt不算环境确定?第三层漂移的症状是什么? - 三种重跑策略里,哪一种的正确性代价最高?代价为什么由对账承担?
- 「结果可解释」与「结果正确」分别需要什么作为依据?
- 上游数据源不可重现时,你能对下游承诺什么,不能承诺什么?