记录一次心跳的修复(2026-09-10)

最危险的不是进程死了,而是它还活着,却已经很久没说过一句话

这天前后处理了几件看起来不相干的事:换掉一个 VLM 模型、给一个本地转发进程补 timeout、修交易 worker 的心跳、核正一份自动判定、读一份策略审计。等它们在记录里排到一起才发现,说的其实是同一件事——每一个长期运行的环节,都得持续向系统证明自己还活着。一旦它不再说话,系统不会立刻崩,它只会安静地做出一个错误的判断。

下面按当天实际处理的顺序记。


从一条 130 秒的规律开始

监控里最先扎眼的是这个数:交易 worker 每隔约 130 秒就重连一次,节律几乎不抖。

反复核过之后,这个 130 秒不是巧合——它刚好等于连接守卫的超时阈值 + 平均一次重连所需的耗时。也就是说,重连不是网络断了,是被守卫"误判"成断了:它等了太久没等到该有的时间戳刷新,就按超时处理,断线,重连,然后 130 秒后再来一遍。日志里这种由"陈旧"引发的心跳超时,一次会话里数到了 3885 次。

顺着 _last_success_ts 往下挖

连接守卫的判定本身很简单:当 _last_success_ts 比 HL_CONN_HEARTBEAT_SEC 更老,就触发超时。所以问题不在守卫,在于这个时间戳为什么没被刷新。

把静态代码顺着读下来,链条是这样的:

1
2
3
4
连接守卫判定:  now - _last_success_ts > HL_CONN_HEARTBEAT_SEC  →  触发超时
_last_success_ts 的更新点: 只有 _get_mid_price()
_get_mid_price() 的执行条件: 只在 signals 非空的 if 分支里跑
signal daemon 心跳过期 → signals 为空 → 该分支不进入 → 时间戳不刷新

结论就出来了:上游 signal daemon 心跳过期,导致 signals 为空,_get_mid_price() 那条分支永远进不去,时间戳一直不刷新,守卫于是报出一次假超时。 这是一条"只有信号为空时才会走"的失效路径。

此前其实已经修过一轮——在价格查询的 missing-symbol 分支上补了一个 record_success()(7 行),用来兜住"健康但为空"的响应。但那种兜法管不到这里:当 signals 整体为空时,_get_mid_price() 连函数都进不去,分支里的兜底自然也不会执行。前一次的补丁没有覆盖这一次的场景,这也是差点被忽略的地方。

三个选项里,选最便宜的那个

剖析完之后,按"实现成本 + 结构正确性"排了三个选项:

  1. 最低成本:在 main loop 每次 tick 的顶部加一行心跳 ping,调一次 _info.all_mids() 并把 _conn_guard.record_success() 刷新掉。这一下能同时消掉主循环心跳超时、HL 重连和 watchdog kill。
  2. 中等:直接把连接心跳超时阈值抬高,减少误判频率——治标。
  3. 结构性:单独开一个 P2,去查 signal daemon 心跳为什么会过期。

选的是第 1 个,第 3 个单独挂账。理由很直白:根因不是超时太短,而是时间戳的刷新点没被走到;抬高阈值只是让错误晚一点暴露。 顺手核了一遍 diff,确认只动了该动的分支,没有把原来正确的逻辑一起改掉。

修完之后,先把话收回来

修复上线后跑了 30 分钟的 soak 观察。自动判定给出的初判是 FIX_PARTIAL——看起来只修好了一半。

但把原始日志逐条对完之后发现,这是分类器的 bug:它用的粗匹配把 signal daemon 的超时也算进了 HL 心跳里,于是"目标错误"的计数被另一个守护进程的超时污染了。剔掉这类噪声再看,目标路径是 100% 消除,主循环心跳超时、HL 重连、watchdog kill 全部归零。

判定于是从 FIX_PARTIAL 改正为 FIX_WORKS。留在账上的还有一条:signal daemon 心跳过期本身没解决,交给 P2。

另一头:一个不会再说话的服务

同一天还有一条平行的线。OpenVikingService 的 VLM 换了模型,从 volcengine/doubao-seed-2-0-pro-260215 换成 litellm/openai/MiniMax-M3,目的是省 token。改的是 ov.conf 里的模型项,改完重启服务。

麻烦在于,这台机器上的 **SYSTEM DNS 解析不了 api.minimaxi.com**。直连是连不上的,只能绕:让请求先走一个本地转发进程。

mx_http_proxy 就是这条绕路方案:一个 0.0.0.0:8443 的明文 HTTP 监听,转给 https://api.minimaxi.com,并把 Host 头改写成 api.minimaxi.com——这样上游的 TLS SNI 和路由才对得上。客户端的 base_url 指向 http://127.0.0.1:<port>/v1,extra_headers 里带上 Host: api.minimaxi.com。

这个设计有一个必须记住的前提:转发进程得一直活着。 它一死,所有 VLM 调用立刻断掉,而且是手动的,没人会自动把它拉起来。

那天它就真的死了。旧进程 PID 3424 跑了约 4.5 小时之后,在 15:28 前后退出。捞代理日志时看到 5 次 proxy_err(60 秒读超时),其中几次正撞上 M3 生成大文件——大响应偶尔要 70–80 秒,而 urllib 默认的 timeout 只有 60 秒,于是超时、报错,WM update 的 tool_call 跟着超时。

所以这次不只是重启,顺手把补丁也打了:urllib timeout 从 60 秒改成 180 秒。新进程在 15:33:50 起来,PID 13828,探针 PONG_180 在 2.6 秒内返回,说明新阈值已经生效。修完再核一遍,6 次 M3 调用里最大的响应 20048 字节、花了 36 秒,离 180 秒的预算还很远,proxy_err 计数归零。

这是它第三次因为单点故障出事。自动拉起还没接(记成 P0),备选是 WinSW 包装,或者登录时触发的计划任务,两条路都要提权。

心跳过期的源头

回头看那条 signal daemon。它当天重启过一次,PID 6216,在跑,输出三类信号:factor_ic、market_flow、cvd_momentum(其中 market_flow 按此前的 MAINNET_VERDICT 基本已经退役)。但从 08:49 开始,它调 HL API 时会间歇性地抛 ConnectionResetError(10054),可能造成偶发的 0 信号写入。

这就是前面那条链的源头:它一旦写不出信号,trader 那边的时间戳就不刷新,心跳就假超时。 两头其实是同一件事。

早上的那本账

技术活之外,当天早上还过了一遍策略审计:按策略拆 PnL、看信号触发频率、核 signal 与 trader 的状态一致性、清 orphan 仓位。翻出来的最重一条是 P1:仓位同步 bug——数据库里的开仓记录和链上对不上,sim_equity 与 chain_equity 之间有明显背离。P0 的几项确认已完成。口径上也顺手钉了一条:仓位状态不能只看数据库,必须和链上交叉核。


最后

这天的收获分三层。

最表面的一层,是三件具体的事:换掉一个模型、补一个 timeout、修一处心跳。它们各有一个 PID、一个端口、一个数值,改完都能验。

中间一层,是那条把它们串起来的线:出问题的地方,都是某个环节停止了"说话"。trader 的心跳超时,是它没收到信号;forwarder 的单点故障,是它不再响应;signal daemon 的过期心跳,是它写不出数据。系统很少因为某个进程死掉而崩溃,它更容易因为某个进程沉默而判错。

最里面一层,是那条被我改回来的判定。

自动工具给的初判是 FIX_PARTIAL。如果我当时收工,这份记录就会写"只修好了一半",然后带着一个其实已经修好的目标路径继续往上叠。是逐条对原始日志之后,才发现那个"一半"是分类器把别的守护进程的超时算了进来。工具会替你算,但结论得自己对一遍——这笔账和代码本身无关,却比任何一处补丁都值钱。

而且修完并不算完。forwarder 现在还是手动重启,心跳是接回来了,可它自己还不会跳。一次心跳,和一直在跳,是两回事——这是当天留下的、下一件要做的事。