Posts 采集第一周:磁盘、僵尸连接和跨午夜的市场
Post
Cancel

采集第一周:磁盘、僵尸连接和跨午夜的市场

  6 月 6 号晚上,采集服务第一次在采集机上跑起来。它做的事情说起来很简单:连 Polymarket 的实时价格 relay,存 Chainlink 结算价的 tick;每个加密货币 Up/Down 市场单独开一条 CLOB WebSocket,存盘口快照、price_change 增量和成交;再定时去 gamma 拉市场元数据。技术栈是 NestJS + Postgres,数据先落库,每天再导出成压缩文件归档。

  到今天正好跑了三天多,记一下上线后遇到的问题。

price_change 一秒一千条

  一开始我想把原始帧 verbatim 全存下来,过滤和清洗留到打包查询时再做。上线还不到一个小时,就得改这个做法。

  price_change 是盘口增量推送,所有市场加起来实测大约每秒 1000 条,一天约 70GB。采集机的磁盘还和另一个项目的交易数据库共用,照这个速度不到两天就会写满,连别的项目也要受影响。当天晚上我就加了节流:每个 token 每秒最多存一条 price_change,快照、成交和认不出类型的帧照旧全存。被节流的条数单独计数,放在 /health 上。环境变量 BOOK_PRICE_CHANGE_MIN_INTERVAL_MS=0 可以恢复全量,不过得先准备好专门的存储。

  跑了 16 个小时再回头看数据,又发现两件事。

  盘口快照是按成交推送的,热门的 BTC 5 分钟市场一秒大约 10 个,每个都是完整深度(47×2 档左右,约 1.4KB),占了未节流时表体积的 52%。加一个每 token 每秒一个快照的节流之后只剩 28%。

  price_change 那个 1000ms 的节流有 75% 的时间是打满的,精度损失不小。改成 500ms 之后增量分辨率大概提高到 1.8 倍。两项一起算,每天的写入从约 6.6GB 降到约 4.4GB。后来又发现 price_change 的帧是市场级的,所以节流的键也从 token 改成了市场。

  节流参数原来在模块顶层读 process.env,执行到这里时 NestJS 的 ConfigModule 还没加载 .env。结果里面配的值一直没生效,用的全是默认值,也没有报错。我改成通过 ConfigService 读取;填了无效值就回落到默认值,不能变成 0 把全量打开。这几天我在不同脚本里一共遇到了三次「环境变量读早了」。

  节流语义统一成 keep-first-per-window:窗口内留第一条,其余丢掉并计数。成交流 last_trade_price 永远不节流。

price_ticks 的主键为什么带着价格

  价格 tick 表最早的主键是 (source, symbol, feed_ts_ms),目的是吸收 relay 重连之后的重放。上线第一天我就把 full_accuracy_value 也加进了主键:

1
2
3
PRIMARY KEY (source, symbol, feed_ts_ms, full_accuracy_value)
...
ON CONFLICT (source, symbol, feed_ts_ms, full_accuracy_value) DO NOTHING

  重放和修正不能混在一起。重放是同一秒、同一个值,应该去重;修正是同一秒、不同的值,两条都得留下。原来的主键会把修正当成重复,悄悄丢掉后到的那条。卖给别人的归档必须把修正前后的值都留下来。

  full_accuracy_value 是 Chainlink 给的全精度定点字符串(1e18 的整数),归档保留这个值,旁边那个 float 的 value 列用于查询。后面结算重算的时候必须用 BigInt 比,1e18 的整数已经超出 float 的精度了。

连接正常,但没有 tick

  6 月 7 号查数据时,我发现价格 tick 空了整整 64 分钟。日志里 WebSocket 一直开着,ping 也一直有回应,没有任何断线重连记录。TCP 连接没问题,但服务端的订阅已经死了,只是没有任何提示。

  正常情况下所有 topic 加起来一秒大约 13 个 tick,一分钟没有任何 tick 已经足够说明问题。于是在心跳里加了一个看门狗:连接处于打开状态,但距离上一个 tick 超过 60 秒,就主动断开,走原来那套重连加重新订阅的流程,并把 staleReconnects 计数暴露在 /health 上。

  静默不能只从「上一个 tick 的时间」开始算。服务刚启动时,如果订阅一上来就是死的,根本没有「上一个 tick」,看门狗也就永远不会触发。所以起算点取「上一个 tick 或服务启动时间」,这样冷启动时的僵尸也能抓到。

  重连的退避沿用了之前在别的项目上吃过亏的做法:连接要稳定活满 30 秒才重置退避,否则一路退避到 30 秒上限。open 一下就重置退避的话,一个抖动的连接会变成重连风暴。

  回头统计,看门狗部署之前(6 月 7 号 UTC 8 点左右)有四段断流:06-06 一段 4026 秒,06-07 三段分别是 613、7144、3860 秒。部署之后最长的一次空档是 85 到 122 秒。所以 06-06 和 06-07 只能算部分覆盖日,完整的日子从 06-08 开始算,对外也按这个口径说。

每日归档:Postgres 到 GitHub Release

  数据不能一直堆在数据库里,一是磁盘,二是卖的形态本来就是按天的文件。6 月 7 号写了导出脚本 scripts/export-daily.mjs,pm2 cron 每天 UTC 凌晨跑,导出前一天:

  • 价格按 source/symbol 出 csv.gz;
  • 盘口事件按市场系列和事件类型出 jsonl.gz,在当天的 id 范围里 keyset 分页读,不一次性拉进内存;
  • 和当天有重叠的市场元数据出一份 jsonl.gz。

  文件传到私有仓库的 GitHub Release,目录规范照着 data.binance.vision 来。git 树里只放 manifest 和 CHECKSUM。先把资产传到 draft release,再提交 manifest/CHECKSUM,最后 publish。这样当天的 tag 一定指向描述这些资产的那个 commit。重跑是幂等的,会清理过期资产和 stub,tag 也会重新同步,出问题直接重跑就行。空的一天不发布。

  首日只有半天数据,导出来是 83 个文件、315 万行、257MB。

  归档之后就可以清理数据库了。热数据保留 3 天,但删除是 fail-closed 的:manifest 存在、Release 已经发布、manifest 里列的每个资产都在,三个条件都满足才删这一天的 book_events 和 price_ticks;price_ticks 还只删 recv_ms 早于 manifest 生成时间的行,迟到的 tick 留到下一次重导。上线时当场验了一下:已归档的 06-06 通过,没归档的 06-05 被拒绝。市场元数据永久保留。

  另外每天还会随归档生成一份覆盖度报告,写每个币的 tick 间隔分布、超过 60 秒的断档窗口、盘口超过 120 秒的断档和 strike 填充率。断档就如实列出来,不藏。

strike_value 漏导出,是看样本内容才发现的

  Up/Down 市场的判定线 strike,是开盘那一秒(start_sec)的 Chainlink 价格,官方叫 priceToBeat。gamma 的元数据里没有 strike 这个字段,所以我在结算回填的时候从自己的 tick 里取边界那一秒,写进 markets.strike_value。取不到那一秒就留 NULL,绝不用邻近的价格顶上。

  但这一列是在导出脚本写完后才加到表里的,导出字段列表没有它。脚本正常跑完,文件、行数、校验和也都对,看不出异常。我检查样本文件内容时,才发现里面没有 strike。

  同一轮样本校验还抓出了另一个问题,就是下面这个跨午夜的市场。

跨午夜的市场

  采集器会提前大约 5 分钟订阅下一个时段的市场。所以一个恰好在第二天 00:00 UTC 开盘的市场,开盘前的那几分钟盘口事件会落在前一天的文件里。而导出市场元数据时我用的是「时段和当天有重叠」来筛选,这个市场的时段完全在第二天,于是被筛掉了。

  全量校验样本时,我发现 btc-updown-5m-1780790400 有 143 条盘口事件,同一天的 markets 文件里却找不到对应行。我把元数据筛选窗口向后延了 900 秒,让这种「开盘前事件在今天、市场本身属于明天」的市场也能导出来,并在数据说明文档里写清楚。

  补上 strike 和这些边界市场,再把 relay 里那份币安镜像价格从归档里拿掉(币安的历史数据在 data.binance.vision 上免费就能下,没有转卖的价值)之后,首日归档重新发了一版,变成 77 个文件、293 万行、255MB。

时区,两次

  采集机、数据库、所有数据时间戳和 cron 都是 UTC,我的开发机是 UTC+8。这几天因为这 8 小时误判了两次。

  一次是看到一批市场还没结算,以为是 Polymarket 那边积压了 24 小时,其实那些市场才刚结束几分钟,正常的结算延迟就是分钟级。另一次是查「今天」的数据发现是空的,第一反应是数据被删了,其实是日期按本地时间算错了。

  现在的规矩是,做任何时间推理之前先按 UTC 算「今天」和「昨天」,诊断查询里直接写 now() at time zone 'UTC',不靠脑子换算。

目前的检查方式

  .env 被忽略、订阅变僵尸、导出漏一列、跨午夜市场被筛掉,这几处都没有报错。我现在给每一种丢弃计数并放到 /health 上,另外检查样本文件内容和数据断档。


outcometick 工程笔记(1/21)

下一篇:给数据做每日巡检:8 项内检、3 个外锚和一次上游断供

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

《黑客与画家》读后感

给数据做每日巡检:8 项内检、3 个外锚和一次上游断供