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

部分訂閱:重訂閱丟失的,總是最後一批

重訂閱完成後日誌全是 connected,但最後一批標的從未真正訂閱成功。本文區分三種訂閱失敗形狀,給出集合比對代碼與批次邊界診斷腳本,並說明何時應把缺失窗口記為不可恢復。

部分訂閱:重訂閱丟失的,總是最後一批

一個 340 個標的的 watchlist,隨交易所在 02:00 一起中斷,02:11 重訂閱完成。每一行日誌都是 connected,每一批都顯示"已發送"。三天後對賬發現有兩個標的——而且始終是同樣兩個——從 02:00 起就沒有數據。它們是最後一批訂閱請求裡的最後兩個 code。沒有報錯,沒有告警,客戶端會一直"健康"下去,直到有人把"已確認的訂閱集合"和"請求過的訂閱集合"放到一起比。

這個故障有明確的形狀,而且不是斷線的形狀。連接是好的,重訂閱發出去了,大部分標的照常流動。壞掉的是一批,在尾部,不帶任何事件。

重連本身的機制——檢測斷開、退避、恢復狀態——WebSocket 重連最佳實踐已經講過;重連產生的重複 tick 及其成因,三個訂閱狀態陷阱已經講過。兩者都沒有講的,是夾在中間的那種失敗:重訂閱在部分批次上成功,其餘靜默丟失。這就是本文。

一句話版本:重訂閱是按批確認的。數確認數量只能證明"回來了一些",集合比對才能證明"全都回來了"。

訂閱為什麼是分批的

WebSocket 訂閱消息按套餐設上限:定價頁列出了各檔位的單連接標的數上限,超過上限的那一批會以 error 幀返回——WS_CONN_LIMIT 就是超出當前檔位連接或標的限額時流的返回碼。這個上限不是實現細節,它就是這類故障存在的原因。

幾百個標的的 watchlist 無論哪個檔位都沒法一條消息發完,客戶端必須拆分、發送、收集確認。每一個拆分點都是一個位置:這一批被接受、下一批沒有被接受——被限流丟掉、因容量被拒、或者丟在一個從未被按 error 解析的幀裡。丟掉至少一批的概率隨批次數上升,也就是說,watchlist 越大,這個故障越常見。

而且丟失在 watchlist 上不是均勻分佈的。第一批通常沒事:它剛進入發送循環,循環外圍的任何重試邏輯都會再觸發它。尾部不一樣。它是最後寫進去的、最先被假設成功的、最沒有人檢查的——因為循環跑完,代碼就進入恢復路徑了,而恢復路徑假設訂閱已經就位。

部分訂閱的三種形狀

形狀 觀察到的現象 含義 處置
被拒 error 幀回應這一批——WS_CONN_LIMIT,或超出套餐權限的錯誤碼 容量或配置問題 讀錯誤碼;重試會一模一樣地復現
部分 收到一些 subscribed 幀,但確認集合小於請求集合 一批發出後丟失 集合比對,只對缺失的 code 重新訂閱
靜默 確認完整,但沒有 tick 到達 超出訂閱狀態——是數據投遞停了 不在本文範圍;按錄製器一文做逐標的缺口檢查

三種形狀需要不同的處置,混在一起兩個方向都昂貴。把"被拒"當"部分"進循環重試,是拿連接配額去撞一個必然復現的失敗。把"部分"當"靜默"去查儲存,找的是一個還沒發生的空洞——因為那些標的壓根沒被訂閱上。

第一種至少給了一幀。第二種什麼都不給——這正是它能藏在三天健康日誌裡的原因,也是本文剩下的篇幅都在講它的原因。

比集合,不比數量

直覺的做法是數數:"發出去十二個,回來十一個確認。"數數幾乎是正確的想法,而"幾乎"正是它危險的地方——它說不出缺的是哪幾個,遇到交易所對同一個 code 確認兩次就崩潰,並且在有人想讓告警閉嘴的那一刻,退化成一個閾值("差不多都到了")。

經得起這些壓力的做法是集合比對:

Python
import json
import os
import time

import websocket  # pip install websocket-client

WS_URL = "wss://api.tiqex.io/api/v1/market/stream"
API_KEY = os.environ["TIQEX_API_KEY"]          # 只放服務端;嚴禁進倉庫
BATCH_SIZE = 50                                 # 單批標的數上限按套餐而定;從配置裡讀你那一檔的值


def chunk(codes: list[str], size: int) -> list[list[str]]:
    return [codes[i:i + size] for i in range(0, len(codes), size)]


def resubscribe(ws, market: str, codes: list[str], timeout_s: float = 5.0) -> dict:
    """分批發送訂閱,然後等到每一個請求的 code 都被確認。"""
    expected = set(codes)
    confirmed: set[str] = set()
    for batch in chunk(codes, BATCH_SIZE):
        ws.send(json.dumps({"action": "subscribe", "market": market,
                            "symbols": batch}))

    deadline = time.monotonic() + timeout_s
    while time.monotonic() < deadline and confirmed != expected:
        frame = json.loads(ws.recv())
        kind = frame.get("type")
        if kind == "subscribed":
            confirmed |= set(frame.get("symbols", []))  # 確認幀的字段名請以你檔位的流文檔為準
        elif kind == "error":
            # WS_CONN_LIMIT 或權限類錯誤碼,重試會一模一樣地復現
            return {"state": "rejected", "code": frame.get("code"),
                    "msg": frame.get("msg"), "confirmed": sorted(confirmed)}
        # 訂閱確認期間到達的 tick 幀是正常的;其他類型不是數據

    missing = expected - confirmed
    if missing:
        # 不要在只確認了一部分時宣佈成功
        return {"state": "partial", "missing": sorted(missing)}
    return {"state": "ok", "confirmed": len(confirmed)}

這個函數有三處關鍵性質。

它比的是集合,不是確認數量。請求十二個只回來十個確認,是部分成功,按數量核對會把它當通過。集合版本還順帶免疫重複確認:交易所把一個 code 確認兩次,集合不變。

它把 error 幀當作整個重訂閱的終點,而不是在循環裡繼續重試。由 WS_CONN_LIMIT 或權限類錯誤碼導致的拒絕,下一次嘗試會一模一樣地復現,而每一次盲目重試消耗的,都是真正要用這條連接的消費端需要的配額。

它容忍訂閱確認期間到達的 tick 幀。流不會因為你正在確認就暫停;在這裡假設嚴格的幀順序,會製造一種只在壓力下出現的間歇性故障——而壓力場景是遇見它最壞的時機。

為什麼缺的總在批次邊界上

開頭那個事故,看一眼位置就能診斷。兩個失效標的正好是最後一批的最後兩個 code,這個位置不是巧合。

如果標的隨機失效,原因在上游——交易所、鏈路、市場。如果它們在訂閱列表末尾連續地失效,原因就在分批循環裡:最後一批發出去了但確認沒回來,或者根本沒發出去。連續性就是指紋。

這個觀察把對賬從一份報告變成一個診斷器:

Python
import json
import urllib.request


def locate_lost_symbols(missing: list[str], codes: list[str],
                        size: int) -> list[dict]:
    """把每個缺失 code 映射到它在分批計劃中的位置。"""
    plan = {code: (i // size, i % size) for i, code in enumerate(codes)}
    out = []
    for code in missing:
        if code in plan:
            out.append({"code": code, "batch": plan[code][0],
                        "position": plan[code][1]})
    contiguous = (
        len({d["batch"] for d in out}) == 1
        and sorted(d["position"] for d in out)
        == list(range(len(out)))
    )
    note = ("尾批連續缺失:故障在發送循環裡"
            if contiguous else "離散缺失:排查上游或標的級問題")
    return out + [{"diagnosis": note}]


if __name__ == "__main__":
    import argparse

    p = argparse.ArgumentParser(description="端到端校驗重訂閱覆蓋情況")
    p.add_argument("--market", default="crypto")
    p.add_argument("--symbols", nargs="+", required=True,
                   help="如 BTCUSDT ETHUSDT ...(crypto 代碼,空格分隔)")
    args = p.parse_args()

    ws = websocket.create_connection(WS_URL, header=[f"X-API-Key: {API_KEY}"])
    result = resubscribe(ws, args.market, args.symbols)
    print(result)

    if result["state"] == "partial":
        # 在決定如何恢復之前,先用 REST 交叉核對這個洞
        req = urllib.request.Request(
            "https://api.tiqex.io/api/v1/market/crypto/quote"
            f"?symbols={','.join(result['missing'])}",
            headers={"X-API-Key": API_KEY})
        with urllib.request.urlopen(req, timeout=10) as resp:
            snapshot = json.load(resp)
        print("REST envelope:", snapshot.get("succ"), snapshot.get("code"))

        report = locate_lost_symbols(result["missing"], args.symbols, BATCH_SIZE)
        print(report[-1]["diagnosis"])

離散缺失和尾部連續缺失,成因不同,修法不同。上面這段代碼一遍掃描就把兩者分開,不用任何人去翻代碼。還有一種第三類:缺失的 code 在計劃里根本找不到——它從沒進過 watchlist,那是配置問題,不是傳輸問題。

洞已經超出可取範圍時

發現得早的部分訂閱,一次重訂閱就能修好。三天後才發現的,上面還疊了第二個問題:缺失的歷史窗口可能已經超出 REST 接口能返回的範圍——歷史深度按套餐而定,各檔位並不相同。

這改變了恢復的目標。重新訂閱之後,實時流恢復了,但缺失的歷史靠重試拿不回來——而重試恰恰是多數恢復循環在做的事。正確動作是把那個窗口標記為"不可恢復",寫進儲存、帶明確邊界,讓洞可審計而不是不可見。回補窗口的機制與邊界,錄製器一文已經講過;這裡只講決策規則:早於回補窗口的部分訂閱,是要記錄的數據質量事件,不是要重試的傳輸錯誤。

這也是部分確認必須在日誌裡醒目的原因。一條帶缺失 code 排序列表的 partial 記錄,離事故報告只有一次 grep 的距離。同樣的信息埋在成功路徑裡,就會把兩個標的的故障拖成三天。

證明它:五分鐘演練

一個從沒抓到過丟批的重訂閱檢查,只是一個假設。把故障人為造出來只要幾分鐘,就能把它變成測試:

  1. 準備一個大於你檔位單批標的數上限的 watchlist,讓分批循環真的跑起來。
  2. 注入故障:在發送前丟掉最後一批——在發送循環的副本里刪掉最後一個 chunk,或者加個開關讓循環跳過它。
  3. 運行 resubscribe,斷言返回 partial,且缺失的正是被注入的那幾個。
  4. 運行 locate_lost_symbols,斷言診斷結論是"尾部連續"。
  5. 只對缺失的 code 重新訂閱,斷言確認集合與請求集合相等。

第三步抓實現缺陷:同樣的輸入,按數量核對的版本會返回 ok。第五步把恢復路徑本身閉環——一個只補對一半的修復會留下一個更難看見的洞,因為檢查已經"通過"過一次了。

演練要定期跑。恢復路徑是代碼,代碼會腐壞:一次檔位調整、一次 watchlist 加載器重寫、一次平臺對標的數上限的修改,每一個都能讓一個通過的測試變成空洞地通過。

常見問題

重連成功但部分標的收不到 tick,是斷線嗎?

傳輸層不是。先把確認的訂閱集合和請求的集合比對——部分訂閱產生的正是這個症狀,且任何地方都不報錯。集合相等,故障才在訂閱狀態的下游,下一步才是逐標的的缺口檢查。

集合比對放在哪裡做——客戶端還是監控?

兩邊都做,分工不同。放在客戶端、內聯,作為"宣佈恢復完成"的條件——這是不讓洞打開的那道閘。放在監控裡,作為讀取儲存中確認集合的獨立檢查——因為客戶端裡那個檢查自己也可能靜默失效,而那正是最初的故障換了件外套回來。

單批標的數上限是平臺的細節,該寫死嗎?

不要。從配置裡讀,給一個默認值,並把拒絕錯誤碼當作權威——這個上限按套餐而定,它是你當前檔位的屬性,不是平臺的屬性。上限若變了,寫死的代碼會以令人困惑的方式失效——分出的批大小和假設的不一樣——而配置化的上限加集合比對會響亮且正確地失敗,並精確告訴你哪些 code 從未確認。

被拒和部分都意味著一些標的黑了,是同一起事故嗎?

不是,這個區別決定值班時怎麼處置。被拒是容量或權限問題:它會一模一樣地復現,所以修法是配置,不是重試。部分是投遞問題:只對缺失的 code 重新訂閱就能修。把被拒送進部分恢復的循環,會在原故障之上再加一層配額壓力。

部分訂閱最多能潛伏多久?

取決於你的健康信號能容忍多久。如果健康意味著"socket 開著、進程活著",可以無限期——那就是開頭的事故。如果健康包含 subscription_confirm_ratio——每次重連後確認數除以請求數——潛伏期就是一次重連。這個比值一行代碼就能算,它就是"你選擇看見的故障"和"對賬替你發現的故障"之間的區別。

下一步

下次重連之後,把確認的訂閱集合和請求的集合比對一次。如果相等,這篇文章對你沒有成本。如果不相等,你手裡已經有一份缺失 code 的清單——而且它已經存在了不止你以為的這麼久。

註冊即可獲得免費密鑰,這個檢查可以先在延遲數據上搭起來,再決定要不要升配,不需要信用卡。

獲取免費 API 密鑰,開始接入 →


本文引用的接口行為、幀類型(subscribed / tick / error)、錯誤碼(如 WS_CONN_LIMIT)與套餐權限,來自 tiqex.io 官方文檔——錯誤碼頁(/docs/errors)、認證頁(/docs/auth)、開發者中心(/developers)與 WebSocket 實時行情指南(/guides/websocket-live-quotes)——以及定價頁,均訪問於 2026-10-20。確認幀的準確字段名與各檔位標的數上限,請在生產使用前以你檔位的流文檔和控制台為準。字段與參數的可用性隨市場與套餐而異,採用本文任何校驗邏輯前,請先確認實際返回結構。文中內容僅用於說明工程實踐,不構成投資建議,也不構成對未來價格的預測。