09-19 加了 stats worker,定时重算 PnL 并发布榜单。之后 /v1/periods 一直返回 []。发布校验要求未映射数量为零,计算又不断超内存,失败轮次还留下中间表和服务端查询。09-21 处理完这些问题,榜单第一次发布。
闸门卡在 underivableTokens
发布前有一个 preflight,其中一项是「所有出现过的 token 都能推导出所属市场」,要求 underivableTokens === 0。它一直不为零。
最初的调查把这些 token 当成无法推导的数据缺口。后来从铸造事件追到同一笔交易的拆分事件,才找到缺失的抵押品。
09-19 检查了窗口内从零地址铸造的 1,720 个未映射 token,全部能在同 tx 找到带 conditionId 的 PositionSplit,并读到抵押品。COLLATERALS 漏了 Polygon 原生 USDC 和 YU;原生 USDC 与 USDC.e 是不同合约。position id 的计算包含抵押品,少列地址就推导不出相应 token。原生 USDC 占这批未映射 token 的约 70%(1,206/1,720)。
只加地址还不够。有两处「已映射」的判断是按 condition 做的:map-conditions 的 alreadyMapped,以及 preflight 里的 pendingConditions。按 condition 判断,旧 condition 会被整体跳过,修复就成了空操作。两处都改成「每个已知抵押品都映射过才算映射过」。顺带也暴露出闸门和映射器口径不一致:闸门按 token 判,映射器按 condition 排队,所以闸门在等的那些 token,映射器从来没有排进队列。
补上抵押品后,这个数还是不为零。09-21 在真实窗口下量了一次:
| 项目 | 成交笔数 | 金额 |
|---|---|---|
| 解不出的 token(55 个) | 136 | $648.63 |
| 窗口内可计分成交 | 32,595,191 | $1,398,875,865 |
pm_unknown_v2(当时身份未知的交易所) | 205,058 | $23,486,761 |
解不出的部分占窗口可计分成交额的 0.000046%。另一次抽样用 gamma 解析出 57 个 conditionId,全部不在 conditions 表中,也没有被窗口内带 conditionId 的事件发现。项目笔记将这批归为覆盖开始前创建的市场;它们不会进入当时映射器的发现队列。这次抽样与表里的 55 个 token 不是同一个计数。
后来判定抽成 preflightOk / underivableShareOf,按占比判断是否发布。解不出的 token 使用合成 id unmapped:<tokenId>,标成 excluded,单独计数。这个改动解决了持续出现少量未映射 token 时,榜单一直无法发布的问题。
后续还有几次修复:09-25,待映射条件改为占比校验;09-28,成交额分子分母合进同一条查询;09-29,preflight 成交来源改读 fills_canonical,与计算侧保持一致。
stats_values 每轮死在 25.85 GiB
闸门放开后,计算本身开始 OOM。INSERT INTO stats_values 每轮都在 25.85 GiB 被杀,而且 32 秒内就冲上去了。
先量了 tok 与 conditions 的 join,只有 654 MB。max_threads 从 8 降到 4,也没有解决问题。溢写阈值设为 6 GiB 时,bal 聚合峰值 5.83 GiB,没有触发;降到 2 GiB 仍然失败。另一次尝试设 max_bytes_in_join,结果在 3.73 GiB 报 Limit for JOIN exceeded。它是硬上限,不负责触发溢写。
继续看失败堆栈,位置在 FillingRightJoinSide。当时设置是 join_algorithm = 'grace_hash,hash',需要确认究竟哪个 join 回退到了 hash。
我把 hash 从列表里删掉,只留 grace_hash,让那个 join 自己报错:
1
2
None of the algorithms enabled by the 'join_algorithm' setting [grace_hash]
can execute this ASOF LEFT JOIN
valueSql 里有两个 ASOF LEFT JOIN(对成交价格序列取某时点之前的最后价格),grace_hash 不支持 ASOF,于是它们一直静默回退到 hash,把右表整个建进内存。从榜单存在起,每一轮都是这样。
修法是 'grace_hash,full_sorting_merge,hash'。在服务器的 ClickHouse 26.8 上实测,ASOF 选中了 full_sorting_merge,它靠外部排序溢写,是这台机器上唯一既能做 ASOF 又能落盘的算法;普通 join 仍走 grace_hash;结果行数和纯 hash 完全一致。这一步从 32 秒变成 93 秒,峰值 25.85 降到 24.01 GiB,方向对了,幅度还不够。还有一对比例开关 max_bytes_ratio_before_external_sort / _group_by,出厂 0.5,意思是一个算子用掉可用内存的一半才开始落盘,我把两个都压到 0.08。
中间表从来没人删过
同一天发现,六张 per-run 中间表没有清理,失败运行积下了约 39 GB。后台合并与统计查询同时占用内存。服务器总内存与单条查询内存的差额,还可能包含其他查询和后台任务,不能全部归给某一张表的合并。
清理的顺序有讲究:runId 的下界是从这些表的 max(runId) 取的(防时钟回拨后复用),要先读下界再清,否则孤儿 run 的凭据就跟着一起删掉了。
客户端放弃了,服务端还在跑
stats worker 停了 45 分钟,INSERT INTO stats_nr_events 仍在服务端运行,占 8.35 GiB。这次客户端断开没有取消查询。显式取消之后,MemoryTracking 从 11.27 GiB 降到 2.02 GiB。
重查询加上 pmw-stats-<chain>- 前缀,computePnl 开头用 killLeftoverQueries 清理残留。长查询开启 HTTP 进度头,客户端超时从 10 分钟改为 1 小时。同一步在空闲机器上约 28 秒,忙时超过 600 秒,因此客户端超时之后仍要确认服务端查询已停止。
CTE 是内联的
剩下的内存问题大多出在同一处:ClickHouse 的 CTE 是内联展开的,没有物化。
余额聚合 bal 被多处引用。物化成每轮一份的 stats_balances 后,stats_points 峰值从 12–21 GiB 降到 3.42 GiB。改动用同一组 b0/b1 的结果行数和校验和对照;这种校验是概率检验,不能表述成逐行精确证明。
stats_values 按 cityHash64(tokenId) % 8 分批。估值会读互补 token 的价格,所以右侧 fpc 限到「本批 token ∪ 互补 token」,不能只切左侧输出点。不分批的对照会 OOM,改为对比 8 批与 24 批的结果,11,759,285 行的计数与校验和一致。
stats_per_token 分批后峰值从 26.12 升到 26.70 GiB。cvc、mb、tr 三个 CTE 每批重算,单独峰值分别为 13.87、12.66、10.14 GiB,后来都物化成表。
还有两处形状问题。wt 的分批谓词套在 CTE 外面,6 路 UNION DISTINCT 先把约 1500 万个 (wallet, tokenId) 全量去重,分批只切了结果;推进到每一支里才是真分批。nr_events 里有一个 NOT IN (SELECT ek FROM bad_ek),bad_ek 经由 used 又套着 560 万行的 cand,于是 cand 被反复重算:15 秒内 JoinProbeTableRowCount 增加 8,863 万。原样跑 3,357 秒没结束,去掉这个子查询 12 秒。这也解释了之前一直读不懂的现象:同一步有时 28 秒,有时永远跑不完。cand 物化成 stats_nr_cand。
定位时把每个 CTE 单独跑一遍,记录 system.query_log 的 memory_usage、耗时和 join 行数,再对照执行计划。仅凭耗时和内存大小,还不能判断是哈希表还是聚合。
mapper 和 stats 抢内存
当时 map-loop 每 60 秒运行一次,发现条件的查询约占 5.1 GiB,与约 18 GiB 的 recompute 同时运行时超出了服务器内存预算。
第一版让 map-loop 在 stats 持有 advisory lock 时跳过一轮。上线后发现 stats worker 是在进程启动时拿锁、退出时才放,锁被持有等于 worker 存在,映射就永远不跑了,pm2 上 mapper 却显示 online。改成查 system.processes 里有没有 pmw-stats-<chain>-% 的查询,它只在重语句执行期间为真。
第一次发布
09-21 首次发布时,stats_points 峰值约 3.4 GiB,stats_values 分 8 批、各批约 17.9 GiB,stats_per_token 分 8 批、各批约 5.2 GiB,pnlInsert 17.24 GiB / 58 秒。另加了 test/pnl-sql-shape.test.ts 检查 SQL 形状。TS 模板字符串中的未转义反引号会截断字符串,SQL 注释也要避开。
pmwallets 工程笔记(2/7)