部分訂閱:重訂閱丟失的,總是最後一批
一個 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 確認兩次就崩潰,並且在有人想讓告警閉嘴的那一刻,退化成一個閾值("差不多都到了")。
經得起這些壓力的做法是集合比對:
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,這個位置不是巧合。
如果標的隨機失效,原因在上游——交易所、鏈路、市場。如果它們在訂閱列表末尾連續地失效,原因就在分批循環裡:最後一批發出去了但確認沒回來,或者根本沒發出去。連續性就是指紋。
這個觀察把對賬從一份報告變成一個診斷器:
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 的距離。同樣的信息埋在成功路徑裡,就會把兩個標的的故障拖成三天。
證明它:五分鐘演練
一個從沒抓到過丟批的重訂閱檢查,只是一個假設。把故障人為造出來只要幾分鐘,就能把它變成測試:
- 準備一個大於你檔位單批標的數上限的 watchlist,讓分批循環真的跑起來。
- 注入故障:在發送前丟掉最後一批——在發送循環的副本里刪掉最後一個 chunk,或者加個開關讓循環跳過它。
- 運行
resubscribe,斷言返回partial,且缺失的正是被注入的那幾個。 - 運行
locate_lost_symbols,斷言診斷結論是"尾部連續"。 - 只對缺失的 code 重新訂閱,斷言確認集合與請求集合相等。
第三步抓實現缺陷:同樣的輸入,按數量核對的版本會返回 ok。第五步把恢復路徑本身閉環——一個只補對一半的修復會留下一個更難看見的洞,因為檢查已經"通過"過一次了。
演練要定期跑。恢復路徑是代碼,代碼會腐壞:一次檔位調整、一次 watchlist 加載器重寫、一次平臺對標的數上限的修改,每一個都能讓一個通過的測試變成空洞地通過。
常見問題
重連成功但部分標的收不到 tick,是斷線嗎?
傳輸層不是。先把確認的訂閱集合和請求的集合比對——部分訂閱產生的正是這個症狀,且任何地方都不報錯。集合相等,故障才在訂閱狀態的下游,下一步才是逐標的的缺口檢查。
集合比對放在哪裡做——客戶端還是監控?
兩邊都做,分工不同。放在客戶端、內聯,作為"宣佈恢復完成"的條件——這是不讓洞打開的那道閘。放在監控裡,作為讀取儲存中確認集合的獨立檢查——因為客戶端裡那個檢查自己也可能靜默失效,而那正是最初的故障換了件外套回來。
單批標的數上限是平臺的細節,該寫死嗎?
不要。從配置裡讀,給一個默認值,並把拒絕錯誤碼當作權威——這個上限按套餐而定,它是你當前檔位的屬性,不是平臺的屬性。上限若變了,寫死的代碼會以令人困惑的方式失效——分出的批大小和假設的不一樣——而配置化的上限加集合比對會響亮且正確地失敗,並精確告訴你哪些 code 從未確認。
被拒和部分都意味著一些標的黑了,是同一起事故嗎?
不是,這個區別決定值班時怎麼處置。被拒是容量或權限問題:它會一模一樣地復現,所以修法是配置,不是重試。部分是投遞問題:只對缺失的 code 重新訂閱就能修。把被拒送進部分恢復的循環,會在原故障之上再加一層配額壓力。
部分訂閱最多能潛伏多久?
取決於你的健康信號能容忍多久。如果健康意味著"socket 開著、進程活著",可以無限期——那就是開頭的事故。如果健康包含 subscription_confirm_ratio——每次重連後確認數除以請求數——潛伏期就是一次重連。這個比值一行代碼就能算,它就是"你選擇看見的故障"和"對賬替你發現的故障"之間的區別。
下一步
下次重連之後,把確認的訂閱集合和請求的集合比對一次。如果相等,這篇文章對你沒有成本。如果不相等,你手裡已經有一份缺失 code 的清單——而且它已經存在了不止你以為的這麼久。
註冊即可獲得免費密鑰,這個檢查可以先在延遲數據上搭起來,再決定要不要升配,不需要信用卡。
本文引用的接口行為、幀類型(subscribed / tick / error)、錯誤碼(如 WS_CONN_LIMIT)與套餐權限,來自 tiqex.io 官方文檔——錯誤碼頁(/docs/errors)、認證頁(/docs/auth)、開發者中心(/developers)與 WebSocket 實時行情指南(/guides/websocket-live-quotes)——以及定價頁,均訪問於 2026-10-20。確認幀的準確字段名與各檔位標的數上限,請在生產使用前以你檔位的流文檔和控制台為準。字段與參數的可用性隨市場與套餐而異,採用本文任何校驗邏輯前,請先確認實際返回結構。文中內容僅用於說明工程實踐,不構成投資建議,也不構成對未來價格的預測。