9 月 21 日到 25 日这几天,采集侧连着出了几件跟盘口有关的事:一个服务静默卡死了 10 个多小时;补数据时发现两台机器的 recv_ms 不能拿来对帧;新机器上最忙的 WebSocket 连接一天被服务端踢掉几百次。最后的结果是加了一个盘口二级采集点,专门补主机断连的窗口。
停摆的十个小时
09-21 19:00:15 UTC,老主机上的 gamma-discovery 停了。这个服务每 10 秒按确定性的 slug 去 gamma 接口探测新市场,探到就入库并交给盘口服务订阅。它停了以后没有新市场被订阅,已经订阅的市场到 19:31 陆续到期,Polymarket 的盘口从那时起就不再写入了。33 分钟后,共用同一个 undici 连接池的 settlement-backfill 也停了(19:33:50)。不走 undici 的服务全部正常。
当时没有 ERROR、WARN 日志,/health 一直报 ok: true。直到第二天 03:45 的日审计才发现出事,距 discovery 停摆约 8 小时 45 分钟。09-22 05:55:03 重启之后恢复。discovery 停摆接近 11 小时,盘口停写窗口约 10.4 小时。
损失还涉及市场元数据:discovery 停了,没有人建行;settlement-backfill 只会 UPDATE 已有行;恢复后的 discovery 只探当前和下一个槽位,不回头补。09-22 的回补记录按缺口期间结束的市场统计,有 1328 个市场在老主机上没有行。
根因没有定论
能确定的是 tick() 卡在了未完成的 await 上。防重入标志一直是 true,后面的定时调用直接跳过。我把卡点收窄到 fetch() 或 res.json(),排除了 pool.query、event loop 阻塞、setInterval 失效、绕过 finally 等可能;用同版本的 node v22.22.3 / undici 6.24.1 构造单请求故障,都能正常 reject。当时邻近有一个 xrp 请求失败,只能确认时间相邻,不能确认因果。根因仍未定;再出现时,重启前先抓 ss -tnp 中到 gamma 的连接、连接池计数、pg_stat_activity、当前 slug 和 fetch 各阶段时刻。
从监控曲线上能分出两种形状:activeMarkets 分批衰减到 0、marketsTracked 冻结不动,说明是发现端死了;connectedMarkets 一起掉而 activeMarkets 不变,说明是上游在踢连接。这次是前一种。
先让它能被看见
09-23 的改动(ec30be7)补了失败计数和活性检查。
一是失败路径全部计数。原来 HTTP 错误、空 body、解析失败、返回结构不对,全都是一句无声的 return null,所以事后说不出那次失败属于哪一类。现在按原因分成 10 个桶。非 2xx 的响应先把 body 读掉再丢弃,因为未消费的 body 会让 undici 一直占着连接。markets:[{}] 这种返回原来也会刷新「gamma 成功」的时间,而队列永远不前进,现在要按语义校验后才算成功。
二是加了 /live 和 /ready。/live 恒为 200,只说明进程还在;/ready 要求每个启用的采集器都确实在产出,否则 503 并列出原因。关键是 tick 的完成时刻写在 finally 里:卡住的 tick 永远推进不了它,而其他累计计数器在卡住时只是冻结,和「市场很安静」分不出来。按这个判据,09-21 那次 5 分钟内就会变成 503。
轮询探针连续失败 2 次发 Telegram,同一个问题 30 分钟内不重复发,恢复时再发一条。端点不可达和 503 都计为失败;Telegram 发送失败不记为已告警,以便下一轮重试。
从另一台机器补回来
当时准备迁移的新机跑着同一个构建,可以作为回补源。09-22 的跨机回补插入了 12,013,853 行盘口、upsert 1410 个市场。提交记录称,按当时「缺口期间结束」的口径,1328 个市场已有结算结果,其中 1294 个带 strike。
09-23 的监控提交及后来的项目笔记仍记录了 1377 个市场的元数据永久丢失,笔记的筛选条件按停摆期间开盘统计。它与 09-22 按结束时间统计的回补结果口径不同,现有素材不能逐市场对上这两组记录,因此这里不再写全部元数据已恢复。
这里有一个插曲:09-22 当天我还把这台新机当成测试机,做过一次 pm2 stop/start,丢了 27 秒数据。它当时还没接管流量,但它采到的数据已经是不可再生的了。
回补脚本的提交记录列了五轮对抗审查,下面几处影响插入结果。
跨机去重用内容指纹。 两台收到同帧的时间差,笔记记录 p50 2ms、p99 63ms。拷贝范围用开区间 (LO, HI),但 LO/HI 是老主机的接收时刻;同帧在新机晚到几毫秒,可能从缺口外漂进缺口内。若按源端 recv_ms 决定哪些行需要去重,就会漏掉边界重复。改成对整个待插入集合按内容指纹去重,实测有 10 行落在这个边界。
去重要按多重集合算。 上游偶尔会把同一帧真实地重放一次,用 EXISTS 去重会把第二条也删掉。改成 row_number() 配对:对方有两条、本机有一条,就只插一条。
重跑必须安全。 book_events 只有一个 BIGSERIAL 主键,INSERT 本身不幂等。靠 advisory lock、锁内复核缺口仍是空的、单事务提交来保证:失败整体回滚,不会留下补了一半的状态。台账存的是 slug、asset_id、事件时间、recv_ms、payload 的 md5 这些身份字段,不存自增 id,因为 prune 会删热表的行,而 id 从来不进客户拿到的 JSONL。
补入行没有新增来源列,来源和边界行身份保存在回补台账里。客户文档中的 recv_ms 仍表示我方采集机的接收时刻。
新机被踢了 280 次
09-24 新机升为主力之前,我发现它的 btc-5m 盘口连接被服务端关闭的次数是老主机的 14 倍,成交大约少 1%。关闭码是 1013,原因写的是 slow consumer: send buffer full,一天大约 280 次。
本机 Recv-Q 很小。换不同的 Cloudflare 接入点,各测 3 分钟:DUB 0 次,MAN/LHR 2 次,LAX 1 次,NRT 5 次。历史诊断据此把问题定位到接入路径,按 token 拆连接也没有改善;这些短窗口结果不能保证某个节点长期不会踢线。
问题是原来的重连逻辑把这种踢线当成连接抖动,按 2s/4s/8s 升级退避,每次踢线都变成最忙的市场在最忙的那几秒里出现几秒空档。改法(49d8199):
1
2
3
4
const slowConsumer = code === SLOW_CONSUMER_CLOSE_CODE && /slow consumer/i.test(input.reason);
if (slowConsumer && uptimeMs >= SLOW_CONSUMER_MIN_UPTIME_MS && (stable || fastStreak < SLOW_CONSUMER_FAST_BUDGET)) {
return { delayMs: SLOW_CONSUMER_RECONNECT_MS, attempts, fastStreak: stable ? 0 : fastStreak + 1, reason: 'slow-consumer' };
}
只认原因里确实写了 slow consumer 的 1013(单独的 1013 是通用的「稍后再试」);连接活过 5 秒才走 100ms 快速重连;30 秒稳定门槛内最多连续快重连 3 次,超过就回到原来的指数退避。握手后立刻被踢的不走快速路径,避免重连风暴。
快速重连无法取回踢线前未送达的缓冲帧,关闭事件之前也可能已经缺数据。当时出货的盘口按天整体换成老主机版本。
盘口二级采集点
09-24 写好、09-25 上线盘口二级采集点(bc242f8),用另一条连接补主机断连窗口。
主机和二级点都用 WsGapTracker 给每个市场记断连窗口:从断开前的最后一个市场事件,到重连后的第一个。进程重启时,按市场取上一条已写入的行作为窗口起点;缓冲溢出也记成窗口。台账先于数据:台账写不进去,就暂停写盘口,/ready 判红。
没有让两台机器双写主表:同一帧两台各有大约一半的概率先收到,写进同一张表后就分不清哪行来自哪台。主机只补自己记下的窗口。回补任务每 5 分钟跑一次:
- 两边的写入水位都越过窗口才处理;
- 逐市场证明二级点确实覆盖了窗口:前后 5 分钟内有数据,且它自己在这段时间没有断连;
- 受节流的事件类型只取窗口内,其余类型额外取边缘 ±5 秒;
- 按多重集合指纹去重;
- 按日历和实际发布状态,绝不写已经导出的日子;
- 结果分为 filled / no-rows / partial / unproven / skipped-exported / expired;初版对损失告警,09-25 调整了部分告警范围,见下文。
两台同时被踢
上线当天就发现了一个误判。主机和二级点在同一个 CF 节点后面,同一波流量会把两边同时踢掉。两边的断连窗口各按自己的接收时钟记,相距 38 到 55 毫秒、互不重叠,于是二级点被判为「完整覆盖」,窗口封成 filled。拿老主机对照一看,两台其实都缺帧,09-25 实测有 3 段、37 笔成交。
原因是被踢的那个 socket 在断开前已经滞后了几百毫秒,窗口起点记得比实际丢帧的时刻晚。实测真正的损失里两个窗口的距离不超过 404ms,所以判断重叠时两边各放宽 OWN_GAP_TOL_MS=2s。这样判出来的同时被踢没法补回,最后决定接受这部分损失:只记日志、标记已处理,不再每条都发 Telegram;clipped、expired、skipped-exported 这几类指向管线或二级点本身的问题,照旧告警。
当时还考虑过把二级点放到不同的 CF 接入路径,09-25 的记录里尚未实施。
strike 因为回填晚到而永久为空
同一时期还查出一个跟回填时序有关的问题,出在价格流上(价格流的二级点更早就有了)。strike 是在结算那一刻用主表的边界 tick 算的,之后不再重算。如果边界 tick 是结算之后才由二级点回填进来的(每 5 分钟一轮,滞后 5 到 10 分钟;另有每天 01:30 的按天兜底和手动回填),这个市场的 strike 就永远是空的,09-23 新机上有 88 个。改成结算服务定期重查已结算但 strike 为空的市场(9af378b),只碰还不可能出现在任何已发布文件里的市场;同时导出拒绝比约定滞后更新的日子,否则重算会让数据库和已发布的文件分叉。
09-24 是当时最后一个按整天替换盘口的日子。之后回补改按窗口处理;两边同时缺帧的窗口仍可能无法补全。
outcometick 工程笔记(17/21)