休市提醒日股将于 2026-11-03 休市(文化日),距今天还有 5 天。全部公告
文章

WebSocket 重连后收到重复行情:订阅状态管理的三个陷阱

重连产生重复数据是正常的,去重才是应用层的责任。本文拆解三个订阅状态陷阱——把连接成功当订阅成功、重连不清状态、心跳与订阅超时混用,并给出可用的重连状态机与 Python 实现。

长连接断线重连是行情接入里最容易被低估的一段代码。多数团队会认真实现「断了就重连」,却很少处理重连之后的状态一致性——结果就是下游收到重复的 tick,指标被重复累加,告警被重复触发,而排查时又很难复现。

这篇文章拆解三个最常见的订阅状态陷阱,并给出一个可用的重连状态机。

为什么重连一定会产生重复

要先接受一个前提:重连产生重复是正常的,去重才是应用层的责任。

原因在于 WebSocket 的语义。断线发生时,服务端可能已经推送了若干帧但你没收到;重连后重新订阅,服务端会从当前时点继续推。这两段区间之间没有「接着上次继续」的机制,所以边界处必然存在重叠。

好消息是 TIQEX 的 tick 帧带有 at 字段(RFC3339 时间戳),这让去重有据可依——用 (symbol, at) 作为幂等键即可。

陷阱一:把「连接成功」当成「订阅成功」

这是最常见的错误。代码大致长这样:

Python
async with websockets.connect(URL, additional_headers=HEADERS) as ws:
    await ws.send(json.dumps({"action": "subscribe", "market": "crypto", "symbols": SYMBOLS}))
    print("已订阅")          # 错:这只是「已发送」,不是「已订阅」
    async for raw in ws:
        handle(raw)

问题在于:send 只保证消息进了本地发送缓冲区。订阅是否被服务端接受,要看后续的响应帧。TIQEX 会返回一条 type=subscribed 的确认帧,携带 market 与 symbols;如果订阅失败,则会收到 type=error 帧。

正确做法是等到确认帧再认为订阅生效。否则在订阅被拒(例如 MARKET_NOT_ENABLED 或 NO_SUBSCRIPTION)的情况下,代码会安静地卡在 async for 上,既不报错也不推数据,排查起来非常费时。

陷阱二:重连后不清理本地状态

重连后,很多实现会保留上一次连接累积的状态:去重缓存、聚合窗口、告警去抖计时器。这些状态在重连边界上已经失效了。

具体表现是:

  • 去重缓存不清:如果缓存按「最近 N 条」设计,重连后新数据的键可能与缓存里的旧键冲突,导致新 tick 被误判为重复而丢弃——这比重复更糟,因为它是静默丢数据。
  • 聚合窗口不清:重连前未完成的分钟线聚合会与重连后的数据拼接,产生错误的 OHLC。
  • 去抖计时器不清:跨越重连的告警计时会异常触发或漏触发。

原则很简单:重连后清空所有「与连接生命周期绑定」的状态,只保留业务层面的持久状态。 去重缓存应该带时间窗(比如只保留最近 5 秒的键),这样重连后自然过期,不需要额外清理逻辑。

陷阱三:心跳与订阅混在同一个超时里

心跳超时(多久没收到任何帧就判定断线)和订阅超时(发出 subscribe 后多久没收到确认帧就判定失败)是两件事,混用会导致两种误判:

  • 用较长的订阅超时做心跳:真实断线要等很久才被发现,期间数据静默丢失。
  • 用较短的心跳超时做订阅等待:在冷门市场或低波动时段,可能长时间没有 tick,于是被误判为订阅失败并反复重连——而重连握手是计入额度的,这会白白消耗配额。

正确做法是分开两个计时器:订阅确认用较短的固定超时(比如 10 秒),心跳用较长的静默超时(比如 60 秒),并且优先用协议层的 ping/pong 而不是靠数据帧间隔判断存活。

一个可用的重连状态机

把上面的原则落到状态上,重连逻辑就清晰了:

状态进入条件退出动作
CONNECTING发起连接握手成功 → SUBSCRIBING
SUBSCRIBING已发送 subscribe收到 subscribed → LIVE;超时或 error → BACKOFF
LIVE订阅已确认断开或心跳超时 → BACKOFF
BACKOFF等待重连退避结束 → CONNECTING(并清空连接级状态)

关键点是 BACKOFF 状态承担了「清理」职责:所有与连接绑定的状态都在这里重置,避免重连后带着过期状态运行。

Python 实现

Python
import asyncio, json, os, time
from collections import deque
import websockets

URL = "wss://api.tiqex.io/api/v1/market/stream"
API_KEY = os.environ["TIQEX_API_KEY"]
SYMBOLS = ["BTCUSDT", "ETHUSDT", "AAPL"]

SUBSCRIBE_TIMEOUT = 10      # 等待 subscribed 确认帧
IDLE_TIMEOUT = 60           # 静默超时(心跳)
DEDUP_WINDOW_SEC = 5        # 去重窗口

seen = {}                   # (symbol, at) -> 首次见到的时间


def is_duplicate(tick: dict) -> bool:
    """用 (symbol, at) 作为幂等键,配合滑动时间窗。"""
    key = (tick.get("symbol"), tick.get("at"))
    now = time.time()
    # 清理过期键,重连后无需额外清理逻辑
    for k, ts in list(seen.items()):
        if now - ts > DEDUP_WINDOW_SEC:
            del seen[k]
    if key in seen:
        return True
    seen[key] = now
    return False


async def run_once():
    async with websockets.connect(
        URL, additional_headers={"X-API-Key": API_KEY}, open_timeout=10,
    ) as ws:
        await ws.send(json.dumps({
            "action": "subscribe", "market": "crypto", "symbols": SYMBOLS,
        }))

        # 阶段一:等订阅确认,而不是假设订阅成功
        deadline = time.monotonic() + SUBSCRIBE_TIMEOUT
        while True:
            raw = await asyncio.wait_for(ws.recv(), timeout=SUBSCRIBE_TIMEOUT)
            frame = json.loads(raw)
            data = frame.get("data") or {}
            if not frame.get("succ"):
                raise RuntimeError(f"订阅失败 code={data.get('code')} msg={frame.get('msg')}")
            if data.get("type") == "subscribed":
                print("订阅已确认:", data.get("symbols"))
                break
            if time.monotonic() > deadline:
                raise TimeoutError("等待订阅确认帧超时")

        # 阶段二:处理数据帧,带去重与静默超时
        while True:
            raw = await asyncio.wait_for(ws.recv(), timeout=IDLE_TIMEOUT)
            frame = json.loads(raw)
            data = frame.get("data") or {}
            kind = data.get("type")
            if kind == "tick":
                tick = data["tick"]
                if not is_duplicate(tick):
                    print(tick["symbol"], tick["last"], tick["at"])
            elif kind == "error":
                print("流内错误:", data.get("code"), frame.get("msg"))


async def main():
    backoff = 1.0
    while True:
        try:
            await run_once()
            backoff = 1.0                    # 正常结束则重置退避
        except Exception as exc:             # noqa: BLE001
            print(f"连接中断: {exc}")
            seen.clear()                     # 连接级状态在此清理
            await asyncio.sleep(backoff)
            backoff = min(backoff * 2, 60)   # 指数退避,上限 60s
            print(f"{backoff:.0f}s 后重连")


asyncio.run(main())

这段代码对应了前面三个陷阱的处理:等确认帧、去重带时间窗、订阅超时与静默超时分离。重连退避上限设为 60 秒,避免在服务端故障时用重连握手持续消耗额度。

如何验证没有重复

改完之后需要可观测的验证手段,而不是靠肉眼看日志:

  1. 统计唯一键比例:记录收到的总帧数与去重后的唯一 (symbol, at) 数量,比值应接近 1;如果显著小于 1,说明去重生效但重复量大,需要检查重连频率。
  2. 统计重连次数:正常情况下一天应该只有个位数。如果频繁重连,问题通常在心跳设置或网络环境。
  3. 对账下游指标:如果告警系统出现「同一价格同一时间触发两次」,说明去重没覆盖到下游。

常见问题

为什么重连后要先收到 subscribed 才能处理数据?

因为 send 只表示消息已发出,不代表服务端接受了订阅。只有在收到 type=subscribed 确认帧后,才能确认 market 与 symbols 已生效。跳过这一步会让订阅失败变成静默故障。

去重应该用哪个字段做键?

用 (symbol, at)。tick 帧里的 at 是 RFC3339 时间戳,同一标的同一时刻的 tick 天然唯一,非常适合做幂等键。

重连会消耗额度吗?

会。建连握手计入每日额度与每分钟速率,握手之后的 tick 推送不计费。所以退避策略是必要的——不要用高频重连来「保活」。

静默超时设多少合适?

取决于标的的活跃度。冷门市场或非交易时段可能长时间没有 tick,静默超时设得太短会误判断线并反复重连。建议优先依赖协议层 ping/pong 判断连接存活,把静默超时设得宽松一些(例如 60 秒)。

订阅被拒时应该怎么处理?

按错误码区分:MARKET_NOT_ENABLED 或 NO_SUBSCRIPTION 属于权限问题,重连不会解决,应该立即告警并停止重试;INVALID_SYMBOL 或 UNKNOWN_SYMBOL 属于参数问题,应该修正符号后重新订阅。

下一步

把订阅确认、去重窗口与心跳超时这三件事分开处理,重复数据问题基本就消失了。订阅协议与错误码的完整定义见 WebSocket 流式接口文档;重连与心跳的更多实践可参考 断线重连指南 与 WebSocket 心跳;如果你还在纠结要不要上流式,可以对比 轮询与流式 的差异。