一次 WebSocket 行情断线复盘:为什么连接恢复后还要做快照校准和 K 线回补

摘要:下午开盘前 WebSocket 行情断了一次,几秒后重连成功。日志显示连接正常,心跳续上,一切看起来都没问题。事后对账发现有些品种少了几秒到十几秒的数据——连接恢复不等于数据恢复。本文把这次断线重连拆成三层恢复框架:连接层、状态层、数据层,记录复盘过程中四个关键时间戳的发现、REST ticker 快照校准和 REST kline 聚合回补的具体做法,以及为什么三层恢复有严格的依赖顺序。读完你会拿到一份可直接落地的空窗处理方案和通用状态机骨架。


一、事故复盘:重连成功,对账发现少了几秒

image.png

下午开盘前,监控脚本的 WebSocket 心跳超时告警响了。日志显示:

[13:02:03] WebSocket heartbeat timeout
[13:02:05] WebSocket reconnecting
[13:02:08] WebSocket connected
[13:02:08] subscription restored

从断线到重连成功,前后五秒。心跳恢复,订阅恢复,日志里没有任何异常。按照当时的监控标准,这次断线已经“处理完毕”。

收盘后做数据对账,问题浮出来了。有三个品种在 13:02:03 到 13:02:12 之间少了几秒到十几秒的数据。查程序日志,没有漏接记录——那些数据根本没到过这一端。服务端没有为这次断线自动补发历史,客户端也没有主动去拉。

这次事故的根因不是重连代码写得不对,而是监控标准停在连接层。 连接恢复被等同于数据恢复,通道修好被等同于数据完整。中间那几秒的空窗,没人记录、没人回补、没人标记。

复盘后我们把一次断线重连拆成了三层恢复。每一层恢复的内容不同,忽略任何一层都可能留下隐患。三层之间有严格的依赖顺序:通道没恢复,快照拉不到;快照没校准,回补窗口定不准。


二、三层恢复:连接、状态、数据

层级 恢复内容 重连后能看到什么 看不到什么 这次事故中暴露的问题
连接层 物理通道 WebSocket 握手成功,心跳恢复 断线期间通道是空白的 日志显示重连成功就认为一切正常
状态层 当前行情快照 重连后第一条行情的价格和状态 断线前的状态是否已经变化 直接把断线前后的价格连起来用,没做快照校准
数据层 断线期间的历史 重连后的实时推送 断线期间成交了几笔、盘口变动了几次 没有回补,也没有标记空窗区间

大多数行情库的日志只覆盖到第一层。这次事故就是典型案例:连接日志干干净净,重连耗时也在正常范围内,但第二层和第三层完全没处理。重连后第一条价格直接接上了断线前最后一条,中间空窗被当成“这段时间价格没变”。

三层恢复,每一层回答一个不同的问题:连接层回答“通道还在吗”,状态层回答“现在是什么状况”,数据层回答“中间漏了什么”。三层都答完,这次重连才算真正处理完。


三、复盘关键发现:四个时间戳必须分开存

image.png

复盘时发现一个之前被忽略的细节:本地检测到断线的时刻,和行情数据流实际中断的时刻,不是同一回事。把两者混在一起算空窗长度,结论会偏。

这四个字段分属两条时间线,必须分开记录:

字段 含义 记录时机 属于哪条时间线
disconnect_detected_at 本地检测到断线的时间 心跳超时或连接断开时写入 本地检测时间
last_seen_ts 断线前最后一条行情数据的 timestamp 断线时从本地缓存读取 行情数据时间
reconnected_at 本地 WebSocket 重连成功时间 重连握手完成后写入 本地检测时间
first_seen_ts 重连后第一条行情数据的 timestamp 重连后第一条数据到达时写入 行情数据时间

disconnect_detected_atreconnected_at 之间的差值是本地感知到的断线时长。last_seen_tsfirst_seen_ts 之间的差值是行情流本身的中断时长。两者通常不一致——心跳超时阈值的设置、网络延迟、服务端检测时间,都会让本地感知的断线区间和行情实际中断区间存在偏差。把这两条时间线分开存,复盘时才能精确还原空窗的真实边界。

没有时间戳的空窗,复盘时就是一笔糊涂账。 四个字段写入后,空窗区间就有了明确的边界。后续做回补、做分析、做健康标记,都以这个区间为基准。


四、重连后的三步处理

复盘后我们把重连成功后的处理标准化为三步。顺序不能乱:先定位当前状态,再回补历史缺口,最后标记异常区间。

第一步:快照校准

重连后第一条行情可能与断线前最后一条之间存在跳跃。事故中有一个品种,断线前最后一条价格 100,重连后首条快照 102。这两块钱的差值可能包含中间三笔未收到的成交,真实涨幅可能是 1.5 而不是 2。直接把 100 和 102 连起来算涨跌幅,结论就偏了。

处理方式:通过 REST ticker 快照接口拉取当前行情快照,作为重连后的新起点。快照校准的目的是确认“现在是什么状态”,不是补回断线期间的变化。 断线前的价格和状态不再直接参与后续计算。

第二步:K 线回补

空窗期间的分钟级或小时级 K 线,通过 REST kline 接口拉回来。回补前要确认三件事:

  • 回补 interval 是否与下游看板、指标计算或本地聚合周期一致。
  • 回补的 symbol 和断线前是否完全一致。
  • 回补的数据和断线前最后一条 K 线之间有没有重叠或二次缺口。

回补时用 symbol + interval + last_seen_ts + first_seen_ts 组合去重,避免同一空窗区间被重复拉取和写入。回补是对齐时间序列的缺口,不是填补数据的完整。

第三步:健康标记

空窗区间标记为“待核验”,不按正常连续数据参与指标计算、告警判断或自动化流程。分析时可以把空窗区间单独剔除,也可以带着标记看整体趋势——但标记不能省略。没有标记的空窗,复盘时就是静默的数据污染。


五、K 线回补的边界:补得回 OHLC,补不回逐笔过程

image.png

事故中我们通过 REST kline 把空窗期间的分钟 K 线补回来了,时间序列看起来重新连续了。但复盘时一个同事问:如果那几秒里有一笔大单成交后又撤了,K 线能看出来吗?

不能。K 线回补有自己的边界:

回补方式 能补回什么 补不回来什么
K 线回补 OHLC 四个聚合点、成交量 盘口变化过程、价格触点的确切时间、大单先后次序

是否需要补回那些缺失的信息,取决于分析框架依赖的是聚合结果还是过程细节。这次事故中我们的分析只依赖分钟 K 线,所以回补粒度够用。但如果哪天需要复盘那几秒的逐笔过程,REST kline 做不到——需要确认数据源是否提供逐笔历史查询接口。

K 线回补不等于逐笔数据完整恢复。 接口按粒度返回数据,使用者判断粒度是否满足当前分析需求。这两层职责不能混淆。


六、通用状态机骨架

以下是这次复盘后沉淀下来的断线重连处理状态机骨架。这不是生产代码,不绑定任何特定数据源的端点或字段。

from enum import Enum
from datetime import datetime, timezone
from typing import Optional


class ConnState(Enum):
    CONNECTED = "connected"
    DISCONNECTED = "disconnected"
    RECONNECTING = "reconnecting"


class GapRecovery:
    """断线空窗处理状态机骨架。通用工程示例,非生产代码。"""

    def __init__(self):
        self.state = ConnState.CONNECTED
        self.disconnect_detected_at: Optional[datetime] = None
        self.last_seen_ts: Optional[int] = None
        self.reconnected_at: Optional[datetime] = None
        self.first_seen_ts: Optional[int] = None
        self.stream_healthy = True
        self.gap_review_required = False

    def on_disconnect(self, last_ts: int):
        """断线时调用:同时记录本地检测时间和最后一条行情时间戳。"""
        self.state = ConnState.DISCONNECTED
        self.disconnect_detected_at = datetime.now(timezone.utc)
        self.last_seen_ts = last_ts
        self.stream_healthy = False

    def on_reconnecting(self):
        """进入重连状态。"""
        self.state = ConnState.RECONNECTING

    def on_reconnected(self, first_ts: int):
        """
        重连成功后调用。三步处理顺序不能乱:
        先校准当前状态,再回补历史缺口,最后标记空窗区间。
        """
        self.state = ConnState.CONNECTED
        self.reconnected_at = datetime.now(timezone.utc)
        self.first_seen_ts = first_ts

        # 三步处理,顺序不能乱
        self._calibrate_state()       # 1. 快照校准
        self._backfill_klines()       # 2. K 线回补
        self._mark_gap_for_review()   # 3. 健康标记

    def _calibrate_state(self):
        """快照校准:通过 REST ticker 获取当前行情快照作为新起点。"""
        # 实际实现:调用 REST ticker 接口
        # 不与断线前价格直接做差值计算
        pass

    def _backfill_klines(self):
        """
        K 线回补:通过 REST kline 接口拉取断线期间的聚合历史。
        回补前检查 interval 一致性和 symbol 一致性,
        用 symbol + interval + last_seen_ts + first_seen_ts 去重。
        """
        pass

    def _mark_gap_for_review(self):
        """
        空窗区间标记为待核验。
        连接恢复了,但这段空窗仍需要核验。
        """
        self.stream_healthy = True
        self.gap_review_required = True

    @property
    def gap_duration_sec(self) -> Optional[float]:
        if self.disconnect_detected_at and self.reconnected_at:
            return (self.reconnected_at - self.disconnect_detected_at).total_seconds()
        return None

状态机要点

  • 断线时同时记录 disconnect_detected_atlast_seen_ts,分属本地检测时间和行情时间两条线。
  • 重连成功后按顺序执行:快照校准 → K 线回补 → 健康标记。
  • stream_healthy = True 表示连接和推送已恢复;gap_review_required = True 表示空窗区间仍需核验——两者独立表达。
  • 回补去重用 symbol + interval + last_seen_ts + first_seen_ts 组合键,而非简单的起止时间。
  • 空窗区间始终标记为“待核验”,不按正常连续数据参与指标计算、告警判断或自动化流程。

七、验证链路:用 TickDB 实际跑一遍三种接口

上面是方法论和状态机。下面用 TickDB 作为数据入口,实际跑一遍三种接口,看看快照校准、K 线回补和 WebSocket 订阅各自能做什么、不能做什么。TickDB 同时提供 REST、WebSocket 和 MCP 三种入口,在断线重连场景里,WebSocket 负责实时订阅,REST ticker 负责快照校准,REST kline 负责聚合回补——三个入口各司其职。

1. REST ticker:重连后做快照校准

image.png

WebSocket 重连后,第一件事不是立刻把断线前后的价格连起来算,而是先通过 REST ticker 重新取一次当前快照,确认“现在是什么状态”。

使用 TickDB REST /v1/market/ticker,Header 使用 X-API-Key,请求 AAPL.US。返回结构里重点观察 codemessagedata[]symboltypelast_pricetimestamp

这张截图能证明的不是“行情一定连续”,而是:

  • 快照查询可以返回结构化字段;
  • 重连后可以用当前快照重新建立状态基准;
  • last_pricetimestamp 需要作为字段被核对,而不是直接默认可信。

快照校准回答“现在是什么状态”,不是补回断线期间的变化。

2. REST kline:对空窗做聚合级回补

image.png

K 线回补只能补聚合结果,不能补回逐笔过程。它适合恢复时间序列的连续性,但不能还原断线期间每一笔成交和盘口变化。

使用 TickDB REST /v1/market/kline,参数包括 symbolintervallimit。返回结构里重点观察 codemessagedata.symboldata.intervaldata.klines[]、每根 K 线里的 time/open/high/low/close/volume

截图能证明:

  • K 线接口可以按 interval 返回聚合历史;
  • 回补前必须确认 symbol 和 interval;
  • 回补后还要检查是否存在重叠、二次缺口或粒度不匹配;
  • K 线回补不是逐笔恢复,补得回 OHLC,补不回逐笔过程。

3. WebSocket ticker:实时订阅入口

image.png

WebSocket 是实时数据流入口。它负责持续推送当前订阅的数据,但它不是断线历史自动回放入口。

使用 TickDB WebSocket 实时入口,连接成功后会返回 cmd="connected",订阅 ticker 后返回结构为 cmd="ticker" + datadata 里可以观察 symboltypelast_pricetimestamp

截图能证明:

  • WebSocket 可以作为实时订阅入口;
  • 返回结构是 cmd + data
  • 订阅成功不等于断线期间数据会被自动补回;
  • 客户端仍然需要自己记录空窗、做快照校准、做 K 线回补和健康标记。

八、TickDB 在这类系统中的工程位置

经过三种接口的验证,TickDB 在断线重连场景里的价值不是自动修复空窗,而是提供三类可组合的数据入口:

  • WebSocket:实时订阅入口,用于持续接收行情。断线检测和空窗记录的起点。
  • REST ticker:快照查询入口,用于重连后做当前状态校准。返回结构化字段供逐项核对。
  • REST kline:聚合历史入口,用于对空窗做 K 线级回补。按 interval 返回 OHLC 数据。

TickDB 不自动修复断线空窗。真正的空窗处理逻辑仍然在使用者侧:记录四个时间戳、做快照校准、做 K 线回补、标记空窗区间、决定这段数据是否能参与后续指标、告警或自动化流程。行情接入层提供的是数据入口,不是自动诊断。


九、常见问题

Q1:WebSocket 重连成功,为什么数据还是少了?

重连只恢复了传输通道。不能默认服务端会为客户端断线期间自动补发历史。是否支持补发或回放,以具体数据源文档和实测为准。空窗期间的数据必须主动记录和回补。

Q2:重连后第一步应该做什么?

记录 reconnected_atfirst_seen_ts,然后立刻通过 REST ticker 快照接口拉取当前行情快照作为新起点。不要直接把重连后第一条数据和断线前最后一条做差值计算——中间可能有未收到的成交,差值不代表真实市场波动。

Q3:K 线回补能补回什么,补不回什么?

能补回分钟/小时/日级别的 OHLC 聚合点和成交量。补不回盘口变化过程、每笔成交的确切时间、大单先后次序。如果分析只需要 OHLC,K 线回补就够了;如果需要过程细节,需确认缺失的信息是否影响分析结论。

Q4:怎样防止空窗被误当成市场平静?

用四个时间戳划定空窗边界,空窗区间内的所有数据标注为“待核验”,不按正常连续数据参与指标计算、告警判断或自动化流程。分析时可以选择剔除空窗区间,也可以带着标记看整体趋势——但不能不标记。

Q5:TickDB 能自动处理断线空窗吗?

不能。TickDB 提供 WebSocket、REST ticker、REST kline 这些数据入口,不替使用者自动完成空窗诊断。空窗的记录、回补粒度的判断、健康标记的写入,都需要使用者在自己的监控脚本中实现。


十、发布前检查清单


你现在的监控里,重连成功之后有没有记录空窗的起止时间戳?本地检测时间和行情时间有没有分开存?评论区聊聊你的处理方式。

📡 本文以 TickDB 作为统一行情 API 接入示例:WebSocket 用于实时订阅,REST ticker 用于快照校准,REST kline 用于聚合级回补。本文仅讨论行情监控的工程方法,不构成投资建议。文中截图仅展示调用链路与字段结构,不用于讨论价格涨跌,不构成实时报价或投资建议。

标签: WebSocket, 行情断线重连, 数据空窗, K线回补, 状态校准, TickDB

posted @ 2026-07-07 12:53  Walter先生  阅读(6)  评论(0)    收藏  举报