TT Lab
开始
学习 学习路径 课程

数据流水线

一定会中途挂掉 — 运行台账与原子替换

在 TT Lab 中继续学习

一句话总结

管道一定会在中途挂掉。挂掉之后留下的,是写到一半的产出,还是记着做到哪里的记录,决定了第二天早上的局面。

为什么需要它

凌晨 3 点,结算作业挂掉了。早上上班一看,结果文件是有的,大小也像那么回事,可数字不对。打开文件,最后一行断在了中间——写到一半的时候,进程没了。

这时会出第二个事故。想着“直接重新跑一遍就行”,于是重新运行,结果当天的销售额被统计成了两倍。没有人知道上一次运行做到了哪里,而重新运行的这一次又是从头到尾全部做了一遍。

第三个事故更悄无声息。挂掉的那次运行留下的临时文件,被下一步的文件列表一并抓了进去,同一个分片被加了两次。没有人会看到任何错误。

这三个问题都不是代码写错造成的,而是出在只考虑了成功情形而写出的代码上。

工作原理

防护措施有三个。

第一,原子替换。不直接写目标文件。先在同一目录下用临时名字写完,再改名变成目标文件。在 Python 中是 os.replace。底层调用的是 rename(2),它的文档明确写道:如果新名字已经存在,就会被原子地替换,“其他进程查找该名字时,不存在它消失的瞬间”。所以读取方无论何时去看,看到的要么是完整的旧文件,要么是完整的新文件,不可能看到写到一半的状态。

不过有个前提。临时文件和目标必须位于同一个文件系统内。同一份文档还写明,如果两个路径位于不同的挂载点,就会失败(EXDEV)。先写到 /tmp 再移到结果目录的代码,在开发机上跑得好好的,到了生产环境却挂掉,原因就在这里。所以临时文件要创建在目标文件的旁边。

临时名字也有规则。如果读取方用 part-*.json 来匹配文件列表,临时名字就不能被它匹配到。可以以点号开头,或者加上别的后缀。

第二,运行账本。把一次运行记成一行。什么时候开始的,做完了哪些分片,成功还是失败,如果失败,死在了哪里。只追加,不修改。这样之后才能追溯“昨天的那次运行”。SQLite 的原子提交文档所讲的,说到底也是同样的结构——把写入途中的状态单独放着,全部完成后一次性改变标记。

第三,分片级标记。想在重新运行时不从头开始,就必须知道做到了哪里。可以另设一个标记文件,也可以直接把每个分片的产出本身当作标记。后者更安全——产出是被原子替换的,所以产出存在就意味着该分片一定已经完成。如果标记和产出各自为政,就可能出现只剩标记、产出却处于写到一半状态的情况。

나쁜 순서                         좋은 순서
  결과 파일을 열고 쓴다            임시 파일에 다 쓴다
  ... 여기서 죽음                  ... 여기서 죽어도 목적지는 멀쩡
  닫는다                           os.replace 로 바꿔 단다
  => 반쯤 쓰인 파일이 남는다        => 옛 파일 아니면 새 파일

在现场相遇的样子

第一,续跑和重新运行是两回事。重新运行是从头再跑一遍,续跑是跳过已经完成的分片。要支持续跑,“这个分片已完成”必须能被读取。光写在日志里是不够的。日志是给人看的,而续跑是程序要做的判断。

第二,总计要重新汇总。如果只把续跑的这一次处理过的部分相加得出总计,被跳过的分片就会漏掉。总计始终要重新遍历全部分片产出来得出。这样续跑的那次和一次性跑完的那次,答案才会一致。

第三,被加两次的地方通常是最后一步。分片处理是覆盖写入,做多少次结果都一样;但往当天的账簿里追加一行属于追加写入,每调用一次就会多一行。所以追加之前要看这次运行是否已经追加过。判断依据不是时间,而是运行的名字。如果按时间判断,就区分不了同一天运行了两次的情况。

第四,不知道死在哪里,就什么都做不了。如果账本里没有记录失败的分片名,下一个人能做的就只有从头重新运行。对一个耗时六小时的作业来说,这个差别很大。

实际工作中真正重要的事

下一项实验要做什么

把夜间结算的输入拆成分片,再一步步扩充运行器 runner.py。让它先用临时名字写分片产出、再改名替换,并故意在写入途中把它杀掉,确认目标文件完好无损。接着加上运行账本,在中间的分片处杀掉进程,查看账本里是否留下了失败的分片,然后从那个位置开始续跑。最后让同一次运行提交两次,当天的账簿也不会增加。评分器每次都会用不同的分片数和金额准备自己的输入,真正运行你的运行器,并且连杀掉之后留下了什么都会检查。