多租户行情数据(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;示例代码与参数仅用于说明工程实践,不构成任何投资建议,也不承诺任何收益。