多租戶行情數據(multi-tenant market data)是平臺型產品的常見形態:一家公司持有一個行情賬號,把數據分發給多個客戶、業務線或內部團隊。跨過這一步,數據訪問層就不再是「取數函數」,而是需要身份、容量、故障域三重隔離的基礎設施。多數團隊的第一版是全平臺共用一把 API key,直到某租戶打滿配額、或某把 key 洩漏需緊急吊銷,才發現改造窗口已很窄。本文給出四個隔離維度、兩層限流閘門的分攤方法、一段可直接運行的租戶配額網關代碼,以及把 X-Request-Id 變成租戶賬本的做法。
四個隔離維度,缺一個都會在下游顯形
設計目標不是「把請求發出去」,而是保證任意一個租戶的行為都不會改變其他租戶看到的結果。這句話可拆成四個可驗證的維度。
| 維度 | 隔離手段 | 缺失後的典型症狀 |
|---|---|---|
| 身份 | 每租戶獨立 API key,獨立輪換與吊銷 | 一把 key 洩漏,全體租戶同時停服 |
| 授權 | key 級 scope 與市場開通狀態 | 低檔租戶訪問到未購買的市場數據 |
| 容量 | 日配額與 RPM 分別分攤,WS 連接預算預分配 | 一個租戶吃掉賬戶配額,其餘租戶被 429 |
| 觀測 | 每請求記錄 X-Request-Id 與租戶標識 | 超限時無法定位到具體租戶與調用方 |
四者實現成本並不均等:身份與觀測基本只是約定加幾行中間件,容量分攤需要真實的分攤算法與賬本,授權取決於上游是否支持 key 級 scope。
密鑰與授權:一個租戶一把 key
鑑權文檔建議「為 dev、staging、production 分別創建獨立密鑰,洩漏後在控制台立即輪換與吊銷」,並支持請求頭 X-API-Key(也接受 Authorization: Bearer)。再推進一步,就是多租戶的做法:按租戶發 key,而不是按環境複用 key。
原因有三。其一,key 是吊銷的最小粒度:某租戶密鑰被寫進公開前端倉庫,只需吊銷那一個;全平臺共用一把,處置就是全站停服。其二,key 是限流的計數單位:RATE_LIMIT_EXCEEDED 是按賬號的每分鐘請求數超限,KEY_RATE_LIMIT_EXCEEDED 是按 key 的每分鐘請求數超限;後者只有在密鑰按租戶切分時才生效,也才讓「限制某個租戶而不影響其他租戶」成立。其三,key 是授權與審計的掛載點:API_KEY_SCOPE_DENIED 表示權限範圍不足,MARKET_NOT_ENABLED 表示所屬套餐未開通該市場,按租戶切分後這兩類拒絕自然變成租戶級;IP_DENIED / GEO_DENIED 則給出第二個維度,為每租戶固定出口 IP,異常調用在日誌裡可直接歸因。輪換實操見 REST 接口鑑權指南。
配額分攤:兩道閘門,三種策略
先確定計量點。按站點文檔口徑,REST 的 quote / kline 請求與 WebSocket 握手計入日配額與每分鐘請求數,握手之後的 tick 推送不按條重複計費。這決定了分攤對象:請求次數與握手次數,不是消息條數;按消息數記賬,賬目永遠與上游對不上。
其次,日配額與 RPM 是正交約束,不能互相抵扣:RPM 決定瞬時併發與界面體驗,日配額決定成本上限。一個租戶可能 RPM 打滿而日配額充裕(突發型),也可能日配額耗盡而 RPM 很低(持續型)。只設一個閾值,總有一種場景會得到錯誤信號。
分攤策略有三種:
- 硬分配:每租戶固定份額,之和不超過賬戶配額。可預測,但碎片化:租戶 A 閒置的配額無法給租戶 B 使用。
- 軟分配(超賣):份額之和大於賬戶配額,賭各租戶使用曲線不同步。成本最低,但熱門時段互相擠壓,需排隊與優先級兜底。
- 雙層配額:賬戶側預留一成到兩成作應急緩衝,其餘按權重分配;租戶超出份額時降級到低優先級隊列,而不是直接拒絕。
生產環境通常用第三種,關鍵不在算法,而在把賬戶級上限當最後一道防線。典型 RPM 為免費版 30、標準版 120、專業版 600、企業版 3000,各檔另有日配額與月度配額,以控制台為準(見 定價頁 與 限流與錯誤碼術語)。下面的網關把密鑰路由、雙層閘門與 429 退避收在一處,僅依賴標準庫,可直接運行。
# tenant_gateway.py — 多租戶配額網關(僅標準庫)
import json, time, urllib.error, urllib.request
from collections import defaultdict
API = "https://api.tiqex.io/api/v1/market/quote"
TENANT_KEYS = {"acme": "tk_acme_xxx", "globex": "tk_globex_xxx"}
# Daily quota and RPM are orthogonal: split separately.
TENANT_QUOTA = {"acme": {"daily": 200_000, "rpm": 200},
"globex": {"daily": 60_000, "rpm": 60}}
_used = defaultdict(int)
_window = {}
_cooldown = {}
def gate(tenant):
"""None to pass, else the block reason."""
now = time.time()
if now < _cooldown.get(tenant, 0):
return "cooldown"
if _used[tenant] >= TENANT_QUOTA[tenant]["daily"]:
return "DAILY_LIMIT_EXCEEDED"
start, n = _window.get(tenant, (now, 0))
if now - start >= 60:
_window[tenant] = (now, 1)
elif n + 1 > TENANT_QUOTA[tenant]["rpm"]:
return "RATE_LIMIT_EXCEEDED"
else:
_window[tenant] = (start, n + 1)
return None
def http_get(codes, api_key):
req = urllib.request.Request(
API + "?codes=" + codes,
headers={"X-API-Key": api_key, "Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, dict(r.headers), json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), json.loads(e.read().decode() or "{}")
def call_quote(tenant, codes):
reason = gate(tenant)
if reason:
return {"tenant": tenant, "ok": False, "reason": reason}
status, headers, body = http_get(codes, TENANT_KEYS[tenant])
rid = headers.get("X-Request-Id")
if status == 429: # per-key and per-account backoff differ
wait = int(headers.get("Retry-After") or 1)
_cooldown[tenant] = time.time() + wait
return {"tenant": tenant, "ok": False, "reason": body.get("code"),
"retryAfter": wait, "requestId": rid}
if status >= 400 or not body.get("succ"):
return {"tenant": tenant, "ok": False,
"reason": body.get("code") or status, "requestId": rid}
_used[tenant] += 1 # a WS handshake counts once too
return {"tenant": tenant, "ok": True, "requestId": rid, "data": body.get("data")}
if __name__ == "__main__":
# Offline self-test: stub the transport.
http_get = lambda codes, api_key: (200, {"X-Request-Id": "req_demo"},
{"succ": True, "data": [{"code": codes}]})
for tenant in ("acme", "globex"):
print(call_quote(tenant, "EURUSD,XAUUSD"), "daily =", _used[tenant])
兩個細節值得單獨指出。gate() 在本地攔掉明顯超限的請求,目的不是省配額,而是保護自己的服務:本地攔截比等上游 429 更快。另外,只有成功請求計入本地賬本,但 WebSocket 握手必須計一次——一次握手就是一次配額消耗,重連風暴會把日配額悄悄吃光。網關還應記錄每租戶握手頻率,超閾值時直接拒絕重連並進入冷卻。
故障圍欄:讓尖峰留在自己的艙壁裡
配額分攤解決「誰用多少」,故障圍欄解決「誰出錯時影響誰」。最常見的兩類事故都不是上游故障,而是租戶側的流量形態:重試風暴與連接佔滿。
針對重試風暴,網關要為每租戶維護獨立冷卻窗口,強制指數退避加隨機抖動,並尊重 Retry-After。退避狀態不跨租戶共享,否則一個人的抖動會降低所有人的有效吞吐。
針對連接佔滿,必須意識到 WebSocket 連接與訂閱標的上限是賬戶級的。文檔給出的近似檔位為免費版 1 條連接 / 10 個標的、標準版 3 / 25、專業版 10 / 50、企業版 50 / 200,觸頂時出現 WS_CONN_LIMIT。因此必須在啟動時完成連接預算分配,超賣時直接失敗,而不是等運行期才發現有租戶連不上。
# ws_budget.py — split the account-level WS caps across tenants up front
PLAN_WS = {"free": (1, 10), "growth": (3, 25), "pro": (10, 50), "enterprise": (50, 200)}
def allocate(plan, weights):
"""Raise on oversell rather than at runtime."""
max_conn, max_sym = PLAN_WS[plan]
total = sum(weights.values())
out, used_conn, used_sym = {}, 0, 0
for tenant, w in weights.items():
conn = max(1, round(max_conn * w / total))
sym = max(1, round(max_sym * w / total))
out[tenant] = {"connections": conn, "symbols": sym}
used_conn, used_sym = used_conn + conn, used_sym + sym
if used_conn > max_conn or used_sym > max_sym:
raise ValueError("oversold: reduce weights or upgrade the plan")
return out
if __name__ == "__main__":
print(allocate("pro", {"acme": 5, "globex": 3, "initech": 2}))
圍欄的最後一塊是降級階梯。租戶配額耗盡或上游受限時,可按順序退讓:實時推送 → 延遲數據(響應中 delayed 為真)→ 本地緩存快照 → 明確拒絕。紅線是降級必須顯式:返回舊值時同時返回 timestampMs 與 delayed,讓調用方自己判斷能否使用。靜默降級與靜默失效沒有區別,都把數據問題推到更難排查的下游。
計量與對賬:把 X-Request-Id 變成租戶賬本
隔離做完,還需要證明它有效。每個響應都帶 X-Request-Id 響應頭,這是把上游一次調用與本地一次業務動作連起來的唯一線索。最小可用的租戶賬本只需四列:租戶標識、端點、狀態碼與信封 code、X-Request-Id。
告警要按租戶維度切分。文檔建議把 401、403、429 分開告警,多租戶場景下還應按租戶聚合:某租戶 403 突升,通常是套餐或 key scope 被改動;某租戶 429 持續上升,說明份額不足或客戶端缺少退避;401 集中在單個租戶,幾乎可以斷定密鑰輪換沒有同步到對方環境。
最後是對賬。本地賬本與服務端用量的允許偏差應明確設定,超出後按三個方向排查:WebSocket 重連握手是否漏計、後臺回補任務是否繞過網關直接調用、健康檢查是否在消耗配額。接入前先用 配額測算工具 把量級算清,避免分攤係數拍腦袋;市場清單對照 加密貨幣行情覆蓋 等頁面確認。
常見問題
一個 API key 能給多個租戶共用嗎?
技術上可以,架構上不建議。共用 key 會讓三個粒度同時退化:吊銷從「一個租戶」變成「全體」,限流從「按 key」變成「按賬號」,審計從「哪個客戶」變成「哪個平臺」。多開幾把 key 的隔離成本幾乎為零。
租戶超出份額時,應該拒絕還是降級?
按調用場景區分。交互式界面的實時刷新適合降級:返回延遲數據並顯式標記,體驗保持連續。批量回補與夜間批處理適合拒絕並排隊:它們對時效不敏感,對數據完整性敏感,返回部分結果反而更糟。
WebSocket 推送不計費,怎麼給租戶核算用量?
核算對象是握手次數與訂閱標的數據量,不是消息條數。「每租戶連接數 × 訂閱標的數 × 在線時長」可作為成本分攤的近似指標,它與實際佔用的連接資源直接相關。這正是連接預算必須提前分配的原因。
怎麼在不停機的情況下把租戶遷移到新密鑰?
雙 key 並行:先為新 key 開通同等 scope,把該租戶流量灰度切到新 key,觀察它的 401 與 403 是否歸零,確認 X-Request-Id 全部來自新 key 後再吊銷舊 key。舊 key 全程可用,任何一步異常都能立刻回切。
建設順序是明確的:先按租戶切分密鑰並補齊 scope 授權,再落地日配額與 RPM 的雙層分攤,然後分配 WebSocket 連接預算,最後建立租戶賬本與對賬。前兩步只需配置與少量中間件,換來的卻是把「全站故障」降級為「單租戶故障」。
文中接口路徑、錯誤碼、限流與配額檔位均取自 tiqex.io 官方文檔(更新於 2026-09-12)與定價頁,訪問日期 2026-09-23;示例代碼與參數僅用於說明工程實踐,不構成任何投資建議,也不承諾任何收益。