Posts 采集拆到两台机器,以及预打包那把复审了五轮的文件锁
Post
Cancel

采集拆到两台机器,以及预打包那把复审了五轮的文件锁

  这两周主要做了两件事:把采集从一台旧机器迁到两台新机器上,然后给全量数据包加了每晚预打包。迁移这件事 6 月下旬就在计划,因为旧机器迟早要被回收。

选址:WebSocket 推送延迟

  一开始我拿 TCP 连接和 TTFB 比几个机房。Polymarket 前面有 Cloudflare,各地结果都差不多,看不出区别。后来我直接量 WebSocket 推送延迟,也就是源站发出数据到我收到的时间差,差距马上出来了:Polymarket CLOB 的源站在欧洲,欧洲收盘口 p50 是 9ms,香港 99ms、美东 76ms、东京 116ms;Predict.fun 的源站在东京,东京接收只要 2 到 4ms。

  单机选址没法兼顾两个平台,最后拆成两台:欧洲主机跑中心 PostgreSQL、Polymarket 采集、所有 cron 管线和交付 bot;东京那台只跑 Predict.fun 采集,通过 SSH 隧道写主机的数据库。两台机器用同一份构建,模块靠环境变量开关(PRICES_ENABLED / BOOKS_ENABLED / PREDICT_ENABLED 这些)。默认全开,只有字面量 false 才关闭。巡检也加了一项,通过隧道检查东京采集进程的健康。

零断档切换

  迁移时要保证实时流不断,断一分钟的数据就补不回来了。

  我让新旧两边先双跑。两套采集写不同的库,互不冲突。先启动新采集,和旧采集并行跑一段;确认新采集健康后,记下切换时刻 T_cut,再停旧采集。最后把两个库沿着 T_cut 拼起来。

  价格表有天然主键,重叠的行直接去重。盘口表麻烦,主键是自增 id,两边各自分配,一拼就撞。做法是:

  1. 恢复完旧库的 dump 之后,立刻把新库每张表的 max id 记下来并冻结,这是水位线。
  2. 起新采集之前,把新库的序列拨到「旧库当前 max id + 1000 万」,留出一段保留带。新采集写出来的行 id 全在保留带之上。1000 万够盘口事件用好几天。
  3. 双跑,新采集健康后记 T_cut,停旧采集。
  4. 删掉新库里 id 在保留带之上、而且 recv_ms 早于 T_cut 的行(这段时间以旧库为准),再把旧库里 id 大于水位线、recv_ms 早于 T_cut 的行补拷过来。

  这样 T_cut 之前只用旧库数据,之后只用新库数据,每一段都只有一个写入方。切换中途出问题也能随时退回去,因为确认新采集健康之前,旧采集一直没停。

  这套脚本复审了八轮,抓出来的几个问题:

  • 补拷一开始是按 recv_ms 时间窗口算的,但 recv_ms 是收到数据的时刻,不是这行在数据库里可见的时刻。采集是先缓冲再批量提交的,一行 recv_ms 早于 dump 时间的数据,完全可能在 dump 那个快照里还看不见。按时间窗口补拷就会漏掉它。改成按 id 水位线:每张表只有一个 flusher,id 是单调的,id 大于水位线的就是 dump 之后才可见的。
  • 水位线可能被污染。如果有哪个采集进程被意外提前启动,写了几行进新库,再去读 max id 就不对了,后面会悄悄跳过一批旧库的行。所以水位线在恢复完成的那一刻冻结成文件,拨序列之前还要断言「新库当前 max id 等于冻结值」。
  • 重跑不安全。补拷脚本的 DELETE 原来是「id 大于水位线且早于 T_cut」,而上一轮补拷过来的旧库行也在这个区间里,重跑一次就会把它们删掉。旧库还在的时候重跑能自愈,旧库一旦销毁就不可逆了。改成以保留带的起点为锚:只删 id 大于等于保留带起点的行,那是新采集独有的区段,旧库的行永远够不着。
  • 拨序列那个脚本的头注释还写着改造前的顺序(先停旧采集再拨序列),照着注释做就是一个真的断档。改了注释,还加了一个预检:本机旧采集的 /health 不通就拒绝执行。
  • cron 型的 pm2 app,pm2 stop 之后 cron_restart 到点还会把它拉起来,要停必须 pm2 delete。

  7 月 22 号切换,当天归档前的巡检冒烟零红,F(结算重算)2544/2544 全对。

pm2 首次启动会立刻跑一次

  第二天还是出了一次事故,原因是 pm2 的一个行为:cron 型的 app 第一次 pm2 start 时会立即执行一次,不等 cron 时间到。

  迁移时我先把所有 app 在新机器上 start,然后马上 stop 掉那些还不该跑的 cron 任务。其中一个是每晚构建派生数据的任务,它的工作方式是先 DELETE 掉那一天的台账和旧行,全部算完再插回去。它被 start 以后立刻开始跑,一分钟后被 stop 的 SIGINT 打断:DELETE 已经提交了,重插还没发生。这一天的数据刚在迁移时手工重建过,又被删出了缺口,第二天归档后的巡检报红。

  重建那一天、幂等重导归档、再跑一次巡检,当天就转绿了。操作顺序也改了:这类重建放在 pm2 start 之后做;stop 打断过构建任务后,要核对台账是否完整。

全量包要打 40 分钟

  迁移验收时测了一次全量交付:一个 16G 的 Polymarket 全量包,本地缓存全部命中,不用向 GitHub 发一个请求,但暂存 11 分钟,加上打 tar、传 R2,一共还是要 39 分钟左右。到 7 月 28 号,全量包已经 18.4GB、9107 个文件,现打要 40 分钟左右,客户等不起。

  于是加了一个 cron app chainlink-data-prebuild,每天 03:30 UTC(在归档和两次巡检之后)把两个平台各自最全的那个包预先打好放到 R2。bot 命中这两个预设时,只需要给已经存在的对象签一个预签名 URL,几秒就出链接。

  是否能直接发预打包,通过每个成员文件的 sha256,也就是内容指纹来检查,不看日期范围。只有预打包和现打内容逐文件一致才发送。对不上时会告诉操作者原因,并回落到现打。我还改了两个上限:MAX_BUNDLE_BYTES 从 20GB 提到 60GB,磁盘预检仍会检查实际可用空间。R2 分片上传的 SDK 默认每片 5MB、最多 10000 片,相当于对象最大 50GB,我把分片改成 16MB。

复审了八轮,前五轮都在对象 key 和锁上

  预打包这个功能复审了八轮,修了 11 个真问题,最后 138 个单测全过。前五轮的问题集中在两个地方:R2 上的对象 key 怎么起,和那把防止两个 18GB 任务同时跑的文件锁。按轮次记一下:

  1. 第一版 key 只由日期范围决定。某天被重导后重建,新 tar 会覆盖同一个 key,而客户手里还拿着指向它的 7 天预签名链接,下到的字节和我告诉他的 sha256 对不上。改成 key 里带内容指纹。同一轮还改了删旧版本的计时:原来按上传时间算,可如果某个包连续很多天都是最新的(比如归档停了几天),最后一天刚签出去一条 7 天的链接,归档一恢复,这个对象按上传时间一算早就超期,立刻被删,客户拿到 404。改成按「被取代的时刻」计时,维护一个 retired 台账。
  2. 加了 --force 重跑同样内容时,key 还是一样的,还是会覆盖。tar 不是字节稳定的产物,manifest 里的生成时间、tar 的元数据每次都不一样。于是 key 里再加一个 buildId,任何一次上传都落到新 key 上,物理上不可能覆盖已经签发出去的对象。锁这边,最早只用 process.kill(pid, 0) 判断持锁进程是否还活着,而 pid 会被复用,一个残留的锁可能永远看起来像「正在构建」,卡死预打包。这一轮给锁文件记下 argv[1],读锁时和 /proc/<pid>/cmdline 比对,再加一个 6 小时的年龄上限兜底。
  3. 上一版在真实部署下会反过来出错。pm2 fork 模式里,process.argv[1] 是 pm2 的 ProcessContainerFork.js,而 /proc 里的 cmdline 被 process.title 改成了真实脚本路径,两者文本必然对不上,正在跑的构建会被判成残留锁并被夺走,正是这把锁要防的事。生产上首跑时锁文件里的 argv1 确实就是 pm2 的那个文件。身份改用 /proc/<pid>/stat 的第 22 个字段 starttime,这是内核数据,和怎么启动的无关,pid 被复用时 starttime 一定不同。身份判断不出来(没有 /proc、旧格式的锁)时一律当成活锁,这个方向最多多等一会儿。
  4. 拿锁的写法是先 open(wx) 建一个空文件,await 读完 /proc 再写内容。中间这个窗口里,竞争者读到空 JSON,判成残留,删掉重建,两个进程都以为自己拿到了锁。改成先写完整的临时文件,再用 link() 原子发布,EEXIST 就算输,锁文件一出现就是完整内容。同一轮还发现读锁时年龄检查排在身份检查前面:一个身份明确匹配、确实还活着的持有者,只因为跑超过 6 小时就会被夺锁,等于自动 fail-open。顺序反过来,身份能确认就以身份为准,年龄上限只用来裁决身份确认不了的锁。
  5. 释放锁是「先读、再按路径删」。如果持有者走的是身份不可确认的那条路、又因为超龄被别人接管,它就可能在读和删之间删掉后继者的锁。我没有重写整套锁机制,而是按前提收窄:只有身份可确认时才按路径删锁。此时读锁的人一定能看到原持有者还活着,不会有后继者;身份不可确认时直接不释放,靠年龄上限自然过期。最坏是跳过一晚预打包,不会释放别人的锁。拿锁失败也不再静默退出,而是发一条 Telegram,否则残留锁导致的连夜跳过没人看得见。

  后面两轮处理锁没有覆盖的路径:直接用 /link 打全历史范围,打的是同一个 18GB 的包,却绕过了锁,所以大包现打也要等待正在跑的预打包;用于强制现打的 fresh 参数又在预设路径上被提前剥掉了,传不到现打那一步。

上线第二天

  7 月 29 号,我自己从 Telegram 收到预打包的 predict 全量包时,发现了三个问题:

  • 发出来的是约 600 字符的原始链接,不是短链。短链 Worker 的目标白名单只配了现打包的 deliveries/ 前缀,预打包的对象在另一个前缀下,Worker 返回 400,调用方 fail-open 退回原始链接,没有任何告警。白名单改成逗号分隔的多前缀,比较方式也从原始字符串的 startsWith 换成结构化的 URL 比较,因为 /prebuilt/%2e%2e/internal/x 以前缀开头,规范化之后却指向别处。
  • predict 包的范围标签用了 Polymarket 的起始日 06-08,而 predict 数据最早是 06-12,对外多说了 4 天(内容是对的,那 4 天本来就没有 predict 文件)。加了按平台的起始日,取 06-12 而不是首个完整日 06-13,保留当天 66% 覆盖的数据,只把标签改准。
  • 更深一层:服务门控和 cron 的「已是最新」判断都只比内容指纹,而包名和范围标签是给客户看的声明,可以在内容一字节不变的情况下改变。只比指纹的话,旧标签的包会一直被服务,cron 也永远不会重建它。抽出一个 sameBundle() 同时比指纹、tar 名和范围标签,两处共用。

  同时把预签名链接的最长有效期从 7 天降到 3 天(SigV4 协议上限仍是 7 天,3 天是我定的策略),旧版本保留期跟着降,稳态少存 4 份约 19.5GB 的全量包,从 156GB 降到 78GB。链接过期后,客户再来要一次就行。


outcometick 工程笔记(5/21)

上一篇:归档放在 GitHub Release 上,先后撞上的两个上限

下一篇:结算源换成 TWAP,以及一次把看门狗调快反而亏了的实验

This post is licensed under CC BY 4.0 by the author.

归档放在 GitHub Release 上,先后撞上的两个上限

结算源换成 TWAP,以及一次把看门狗调快反而亏了的实验