Posts discovery 静默停摆后的盘口回补和二级采集点
Post
Cancel

discovery 静默停摆后的盘口回补和二级采集点

  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)

上一篇:付费回测失败没有告警:超时分类、回放预算和重排队

下一篇:归档从 T+2 提前到次日:出货编排与导出水位

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

付费回测失败没有告警:超时分类、回放预算和重排队

盈亏被算成 0:步骤顺序、0 金额转账和过宽的 partialHistory