每天凌晨,导出脚本 export-daily.mjs 把前天的前一天从 PostgreSQL 导成 csv.gz / jsonl.gz,中间留一天修数据。每个文件都作为资产传到归档仓库的 GitHub Release(tag 是 daily-<日期>),然后把 manifest 和 CHECKSUM 提交进仓库,最后发布。交付走 Telegram bot:客户要哪段数据,bot 就按 manifest 从 Release 下载文件,逐个校验 sha256,打成 tar 传到 R2,再把预签名 URL 发出去。
这套东西一直跑得挺好,直到 7 月先后撞上了 GitHub 的两个上限。
第一个:单个 Release 最多 1000 个资产
7 月 1 号,归档后的巡检报红:manifest 读不到,累计 K 线表里也没有那一天。原因是导出 06-29 这天时,在上传第 1001 个资产那一步,GitHub 返回了 422 file_count limited to 1000 assets per release,脚本中途抛错,后面写 manifest、写 K 线那几步都没走到。
这次是导出脚本出了问题。Predict.fun 的盘口当时按 category_slug 导出,每个市场一个文件。BTC/ETH/BNB 的 5 分钟和 15 分钟市场每天有几百个槽位,光盘口就有大约 819 个文件。再算上 Polymarket 文件、K 线和快照,一天超过 1180 个资产。ETH 和 BNB 原来只有日级市场,后来多了密集的 5m/15m 槽位。06-28 还是 672 个,06-29 第一次越过 1000。
我改成按 <ASSET>-<INTERVAL> 聚合,BTC-5M、BTC-15M、BTC-DAILY 每个系列一个文件。每一行仍然带着 market_id 和 category_slug,信息没少。category_slug 为空的市场兜底放到 MARKET-<id>,不丢数据。聚合后,盘口从 819 个文件降到 11 个以内,一天总资产约 217 个。
上传逻辑也改了两处:
- 列已有资产改成分页请求
/releases/{id}/assets?per_page=100&page=N,不再用 release 对象里内嵌的assets数组。内嵌的那个不保证完整,而后面有一步是「删掉不在清单里的旧资产」,拿一个残缺的清单去做删除是很危险的。非 200 的响应(包括 404)一律抛错。 - 在删除和上传之前先做预检:文件数超过 1000 或者 basename 有重复,直接抛错,不去碰一个已经发布的 release。
导出本身是幂等的,重跑一次 node scripts/export-daily.mjs 2026-06-29:分页删掉那 1000 个旧资产,传上 217 个新的,提交 manifest,发布,巡检转绿。
历史数据还是旧格式
这个修复只对之后的数据生效。06-12 到 06-28 这 17 天,每天还是 470 到 518 个盘口文件,一共 7965 个。7 月 14 号 Predict.fun 的数据单独开卖以后,这批旧格式的文件开始变成问题:客户拿到的 tar 里文件数爆炸,格式也和后面的不一致。
7 月 18 号写了一个重聚合的迁移脚本 reaggregate-predict-orderbook.mjs。数据库里那段时间的原始数据早就按保留策略清掉了,只能从现有的 Release 文件重新合并。合并规则直接复用导出脚本里的 predictOrderbookSeries,行逐字搬运不做任何改写,按 recv_ms 排序,并且断言读入行数、旧文件行数、新文件行数三者相等,不等就抛。默认是 dry-run,加 --execute 才真写。
真写时要改已经发布的 Release,我把流程写成可以从任意中间状态恢复:上传新文件,修改 manifest 和 CHECKSUM,提交推送,把 tag 指到新的 HEAD,最后才删掉 manifest 之外的旧盘口文件。任何一步崩了,重跑都会对账到目标状态。工作区不干净时,启动和删除前都会拒绝执行。这个脚本复审了五轮。
跑完的结果:盘口文件 7965 变成 269,全量 predict 从 8462 个文件变成 766 个,少了 11 倍。每天行数守恒,重跑全部 skip。
第二个:每小时 5000 次请求
同一天,我在处理另一个问题:全量 predict 的交付太慢了。stageArchivedFiles 原来是一个一个串行地下载、校验,06-12 到 07-16 一共大约 8462 个文件,要花 2 个小时左右。
我先加了一个有界并发池 mapConcurrent(默认 8 路),配套三件事:
makePacer做全局节流,所有并发 lane 共享,默认 75ms 一个请求,大约每分钟 800 个,压在 GitHub 的 secondary rate limit(大约每分钟 900)下面。withRetry只对 403、429、5xx 和网络错误重试,遵守Retry-After(秒数和 HTTP-date 两种格式都要认)和x-ratelimit-reset,最多等 60 秒。sha256 不匹配立即抛,不重试。- 并发数和节流间隔都可以用环境变量
DELIVERY_CONCURRENCY/GITHUB_MIN_INTERVAL_MS调。
按这个吞吐,全量 predict 从 2 小时降到了 11 分钟左右。可真跑全量的时候,请求很快被 403 了,响应头里 x-ratelimit-remaining=0。
GitHub 给这个 token 的主限额是每小时 5000 个请求,全量 predict 有 8462 个资产,一个小时下不完。以前串行大约一秒一个,拖了两个小时,每个滚动窗口碰巧都没超过 5000。加并发后,很快用完了额度。
最后还是得避免重复请求 GitHub。归档资产不可变,同一个 sha256 永远对应同样的字节,所以每个资产只从 GitHub 下载一次:
- 本地缓存按内容寻址,路径是
<cache>/<sha256>。 - 命中时仍然复验 sha256,文件坏了就当未命中重新下载,能自愈。未命中时下载、校验,再原子 rename 进缓存。
- 交付先把文件分成命中和未命中两组,全部命中就是零 GitHub 请求、纯本地拷贝,秒级完成。
- 碰到主限额(remaining 为 0)时
withRetry立即抛,不在那里空等;secondary 限流照旧退避重试。 - 开打之前先估算要发多少请求,超过剩余额度就直接失败,提示先预热。
- 预热脚本
warm-asset-cache.mjs在后台按每小时 5000 的节奏往缓存里灌,断点续传,幂等,撞到限额就等重置再继续。
前面那个重聚合脚本能「读缓存零下载」,也是因为缓存已经先预热好了。
预签名链接太长
R2 的预签名 URL 大约 600 个字符。我要把链接转发给客户,走短信、二维码或者表单,太长了根本发不出去。
我用一个免费的 Cloudflare Worker 做了短链,代码在仓库的 shortlink/ 下面:POST /put 带 Bearer token 存进 KV、返回短码,GET /d/<code> 302 跳转过去。链接从大约 600 个字符缩到大约 40 个。几个设计点:
- 短码是一个完整 uuid 的 128 bit,不可枚举。
- 跳转目标必须以配置好的交付 URL 前缀开头(R2 的 origin、桶和
deliveries/前缀),没配置就一律拒绝。不这样做的话,一旦 token 泄露,这个 Worker 就是一个开放重定向。POST 存的时候和 GET 跳的时候各校验一次。 - 过期时间最多 7 天。
- 调用方是 fail-open 的:没配 Worker、或者 Worker 出任何错,
shortenUrl返回 null,bot 照样发原始链接,绝不因为短链挡住交付。
一开始 bot 同时发短链和原始链接,因为 Workers KV 是最终一致的,最长可能 60 秒才能读到,我不想让付费客户点开是个 404。第二天改成默认只发短链,命令里带 raw / 直链 / 长链 才附上原始长链。
tmpfs 装不下全量包
还有一个跟磁盘有关的。打包的临时目录默认在 /tmp,而采集机的 /tmp 是一个大约 7.8GiB 的 tmpfs,也就是内存盘。全量 Polymarket 包大约 15GB,根本放不下,磁盘预检 checkBundleFits 会直接拒绝。
解决办法是给交付 bot 和订阅 API 的 pm2 配置加上 TMPDIR,指到 200G 的数据盘上。预检读的也是同一个 tmpdir(),所以两边自动一致。写在 .env 里不会生效,因为 config.mjs 用的是 dotenv.parse,只解析不回写 process.env,而 os.tmpdir() 读的是进程环境变量。
1000 个资产的上限通过聚合文件解决,5000 次请求的上限则靠缓存减少重复下载。
outcometick 工程笔记(4/21)