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

一个账号服务多个租户:行情数据层的密钥隔离、配额分摊与故障围栏

当一个行情账号要服务多个客户、业务线或内部团队,数据访问层就从「取数函数」变成了多租户系统。本文给出四个隔离维度、日配额与 RPM 的双层分摊方法、WebSocket 连接预算的预分配做法,并附一段仅依赖标准库、可直接运行的租户配额网关代码。

多租户行情数据(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 很低(持续型)。只设一个阈值,总有一种场景会得到错误信号。

分摊策略有三种:

  1. 硬分配:每租户固定份额,之和不超过账户配额。可预测,但碎片化:租户 A 闲置的配额无法给租户 B 使用。
  2. 软分配(超卖):份额之和大于账户配额,赌各租户使用曲线不同步。成本最低,但热门时段互相挤压,需排队与优先级兜底。
  3. 双层配额:账户侧预留一成到两成作应急缓冲,其余按权重分配;租户超出份额时降级到低优先级队列,而不是直接拒绝。

生产环境通常用第三种,关键不在算法,而在把账户级上限当最后一道防线。典型 RPM 为免费版 30、标准版 120、专业版 600、企业版 3000,各档另有日配额与月度配额,以控制台为准(见 定价页 与 限流与错误码术语)。下面的网关把密钥路由、双层闸门与 429 退避收在一处,仅依赖标准库,可直接运行。

Python
# 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。因此必须在启动时完成连接预算分配,超卖时直接失败,而不是等运行期才发现有租户连不上。

Python
# 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 连接预算,最后建立租户账本与对账。前两步只需配置与少量中间件,换来的却是把「全站故障」降级为「单租户故障」。

查看各档位的配额、RPM 与 SLA →

文中接口路径、错误码、限流与配额档位均取自 tiqex.io 官方文档(更新于 2026-09-12)与定价页,访问日期 2026-09-23;示例代码与参数仅用于说明工程实践,不构成任何投资建议,也不承诺任何收益。