長連接斷線重連是行情接入裡最容易被低估的一段代碼。多數團隊會認真實現「斷了就重連」,卻很少處理重連之後的狀態一致性——結果就是下游收到重複的 tick,指標被重複累加,告警被重複觸發,而排查時又很難復現。
這篇文章拆解三個最常見的訂閱狀態陷阱,並給出一個可用的重連狀態機。
為什麼重連一定會產生重複
要先接受一個前提:重連產生重複是正常的,去重才是應用層的責任。
原因在於 WebSocket 的語義。斷線發生時,服務端可能已經推送了若干幀但你沒收到;重連後重新訂閱,服務端會從當前時點繼續推。這兩段區間之間沒有「接著上次繼續」的機制,所以邊界處必然存在重疊。
好消息是 TIQEX 的 tick 幀帶有 at 字段(RFC3339 時間戳),這讓去重有據可依——用 (symbol, at) 作為冪等鍵即可。
陷阱一:把「連接成功」當成「訂閱成功」
這是最常見的錯誤。代碼大致長這樣:
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 實現
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 秒,避免在服務端故障時用重連握手持續消耗額度。
如何驗證沒有重複
改完之後需要可觀測的驗證手段,而不是靠肉眼看日誌:
- 統計唯一鍵比例:記錄收到的總幀數與去重後的唯一
(symbol, at)數量,比值應接近 1;如果顯著小於 1,說明去重生效但重複量大,需要檢查重連頻率。 - 統計重連次數:正常情況下一天應該只有個位數。如果頻繁重連,問題通常在心跳設置或網絡環境。
- 對賬下游指標:如果告警系統出現「同一價格同一時間觸發兩次」,說明去重沒覆蓋到下游。
常見問題
為什麼重連後要先收到 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 心跳;如果你還在糾結要不要上流式,可以對比 輪詢與流式 的差異。