加密货币交易所 四:用户接入系统

网页登录靠密码加 TOTP,API 走的却是另一条完全不过 2FA 的路。这一篇把签名怎么算、限流的两把尺子分别量什么、本地订单簿怎么和推送流对齐这几件事写到能照着实现。

位置
第 04 篇 / 共 12 篇
预计
11 分钟

一笔下单请求从公网打进来,在碰到撮合引擎之前要过三道关:你是谁(认证)、你还能发几个请求(限流)、你能不能干这件事(授权)。三道关全在关键路径上,任何一道慢了,整条链都慢 —— 它们构成第二篇那张分层图里的接入层。

要紧的是这三道关对人和对程序完全是两套东西。讨论交易所安全的时候,大多数人想到的是登录页面上那个六位验证码 —— 而真正把钱搬走的那条路,一次都不会碰到它。

网页登录和 API 密钥是两条独立的入口

网页登录这条路很常规:密码用 bcrypt 或 Argon2 加盐哈希存着,验证通过之后要一个 TOTP(基于时间的一次性密码,Google Authenticator 那类应用生成的六位数),两步都过了才发访问令牌。移动端可以用指纹或面容替掉密码那一步,大额操作可以要求硬件令牌。这套流程没什么可讲的,任何一个需要登录的系统都长这样。

API 密钥这条路和它完全不相交。 程序拿着一对 key/secret 直接调接口,请求自带签名,不经过密码,也不经过 2FA。这是设计使然 —— 一个每秒下十次单的策略程序没法每次都去手抄验证码 —— 但它的直接后果是:2FA 保护不了 API 通道。密钥泄漏了,攻击者不需要你的密码,也不需要你的手机。

所以真正承担安全职责的是密钥自己带的那几个约束:

  • 权限位。 一对密钥可以是只读、可交易、可提现。只读密钥泄漏了,损失是别人看到你的持仓;可提现的密钥泄漏了,钱就没了。绝大多数策略程序只需要「可交易」,而默认开着「可提现」是一类反复出现的事故。
  • IP 白名单。 绑定之后,密钥只在指定 IP 上有效。这一条比前面所有认证手段加起来都管用,因为它把攻击面从「谁拿到了密钥」缩小到「谁既拿到了密钥又能从那台机器发包」。
  • 有效期。 到期自动失效,比指望用户主动清理靠谱。

设计接入层的时候,这三样的优先级应该高于再加一种登录因子。

签名让服务端确认这笔请求没被人改过

HTTPS 保证链路上没人偷看,但它不解决另一个问题:请求经过的每一个中间环节 —— 用户自己装的代理、公司的出口网关、某个被入侵的 SDK —— 都能改掉包体里的价格再转发。服务端只看到一个格式正确的请求,看不出它被动过。

签名解决的就是这一件事。以 Binance 的合约 API 为例,规则是:把 query string 和 request body 拼起来得到 totalParams,用 secret 做 HMAC SHA256,结果作为 signature 参数附上;API key 走 X-MBX-APIKEY 请求头。

这段可以直接贴进终端跑:

Binance 合约 API · 签名一笔限价买单(换成自己的 key 才能真发出去)
API_KEY="你的 apiKey"
API_SECRET="你的 secretKey"
# timestamp 必填,是毫秒;recvWindow 不填默认 5000
params="symbol=BTCUSDT&side=BUY&type=LIMIT&timeInForce=GTC&quantity=0.001&price=50000&recvWindow=5000&timestamp=$(($(date +%s) * 1000))"
signature=$(echo -n "$params" | openssl dgst -sha256 -hmac "$API_SECRET" | awk '{print $2}')
curl -X POST "https://fapi.binance.com/fapi/v1/order?$params&signature=$signature" \
-H "X-MBX-APIKEY: $API_KEY"

跑完你会看到一个 JSON,要么是订单回执,要么是一条错误。最常见的错误不是签名算错,是时间不对。 timestamp 是请求创建时的毫秒时间戳,recvWindow 是这个时间戳之后多少毫秒内请求还算有效,不传默认 5000。机器时钟慢了 6 秒,签名再正确也一律被拒。

这个时间窗不是麻烦,是重放攻击的保质期。签名本身挡不住重放 —— 攻击者抓到一个合法请求原样再发一遍,签名依然有效。加上时间戳之后,这个窗口被压到几秒;把 recvWindow 调小,窗口更短,代价是对时钟同步更敏感。这是一个明确的取舍,不是「越小越好」。

secret 从头到尾没有出现在请求里,它只在本地参与计算。服务端存着同一份 secret,收到请求后照同样的规则算一遍,对得上就认。

限流有两把尺子,分别量不同的东西

多数设计文档写一句「实施合理的速率限制」就过去了。实际上限流至少要分两个维度,而且两边的计数单位不一样。Binance 合约 API 的做法是:

按什么计 计什么 超了怎么样
REQUEST_WEIGHT IP 每个接口有自己的权重,查深度比查时间贵 429
ORDER 账户(UID) 下单、撤单的次数 429

分成两把尺子的原因很实在。查行情是读操作,压力落在网关和缓存上,按 IP 限最合理 —— 一台机器上的十个程序共享这个额度。下单是写操作,压力落在撮合引擎上,而撮合引擎关心的是「这个账户占了多少序列」,跟请求从哪个 IP 来没关系。用一把尺子量两件事,必然是要么放过刷单、要么误伤行情轮询。

用了多少不需要猜,响应头里就有:X-MBX-USED-WEIGHT-(intervalNum)(intervalLetter) 是这个 IP 当前用掉的权重,X-MBX-ORDER-COUNT-(intervalNum)(intervalLetter) 是这个账户当前的下单数。正确的客户端应该读这两个头来自我节流,而不是撞到 429 再退避。

撞了也不是最坏的。收到 429 之后还继续发,IP 会被自动封禁,返回 418,而且封禁时长对惯犯递增,从 2 分钟一直到 3 天。这个设计的意思很清楚:429 是一次提醒,418 是提醒之后你没听。

权限检查在下单的关键路径上

授权决定这个账户能不能干这件事:能不能下单、能不能撤别人的单、能不能提现、这个交易对对他开不开放。

粗粒度的用普通用户、VIP、管理员这种角色模型(RBAC)就够了。细的地方得看属性 —— 用户的 KYC 等级、所在地区、账户的风险状态、这个品种在他那儿是不是被限制了。这些条件组合起来没法穷举成角色,只能在检查的时候现算(ABAC)。第三方应用接入走 OAuth 2.0,用户授权之后第三方拿到的是有范围限制的令牌,而不是账号密码。

不管用哪种模型,工程上的约束是同一条:这次检查躺在每一笔下单的关键路径上。 每次都回数据库查一遍权限,等于给每笔订单加一次数据库往返。所以活跃用户的权限必须进分布式缓存。

代价是权限变更有延迟。风控刚把一个账户标成「禁止开新仓」,缓存里的旧结论还能再放行几秒。这几秒的口子怎么堵,取决于场景:提现这类操作宁可慢也要回源查,下单这类高频操作则接受这个延迟,靠撮合之后的风控兜底。没有一个缓存策略能同时满足这两边,得按操作分开定。

REST 拿状态,WebSocket 等变化

RESTful API WebSocket API
通信方向 客户端问一次,服务端答一次 建连之后服务端主动推
适合什么 下单、撤单、查账户、查历史 行情、订单状态变化、余额变化
拿到的是 当前完整状态的快照 自上次以来的变化
开销 每次请求一轮握手和鉴权 一次建连,之后只有数据
客户端要做的事 处理错误码、重试 心跳、断线重连、重连后重新对齐状态

用轮询取实时数据是在给自己造开销:为了 100 毫秒的时效性每秒查十次,其中九次返回的数据和上一次一样,权重却照扣。

反过来,用 WebSocket 取「当前状态」也不对。推送流给的是增量,你必须自己维护那份状态,而维护它比想象中难 —— 这就是下一节。

本地订单簿怎么和推送流对齐

这是接入层最容易写错的一段,也是唯一有标准答案的一段。问题在于:推送流给的是增量,你得先有一个起点;而拉快照和订阅流是两个独立的请求,它们之间必然有时间差。这段时间里发生的变化,处理不好就是一份永远对不上的本地订单簿。

Binance 的合约深度流给出了明确的步骤,每一步都在堵一个具体的洞:

  1. 先订阅流,把收到的事件缓存起来,不要急着用。先订阅是关键 —— 反过来先拉快照,两个动作之间的更新就永远丢了。
  2. 再拉一次 REST 深度快照,记下它的 lastUpdateId
  3. 丢弃所有 u < lastUpdateId 的缓存事件 —— 它们比快照还旧,快照里已经包含了。
  4. 第一个真正处理的事件必须满足 U <= lastUpdateIdu >= lastUpdateId。这个条件的意思是:这个事件的更新区间必须跨过快照那个点,否则中间有缺口。
  5. 之后每个新事件的 pu 必须等于上一个事件的 u,对不上说明中间丢了消息,整个流程从第 2 步重来。

字段的含义:U 是这个事件里第一条更新的 ID,u 是最后一条,pu 是上一个事件的 u。第 5 步这个 pu 字段是合约流特有的,它把「有没有丢包」从一个需要推测的问题变成了一次相等判断。

还有三条容易踩的:

  • 每条更新给的是那个价位的绝对数量,不是增量。 收到「10001 → 5」就把该价位设成 5,不是加 5。
  • 数量为 0 表示这个价位没了,要从本地簿里删掉。
  • 收到一条删除指令、而那个价位本来就不在你的本地簿里,是正常的,不是 bug,不要因此重建。

写对了这五步,你的本地订单簿和交易所的是逐档一致的,第五篇里那些成交和滑点的计算才有意义。写错了它会缓慢地漂移——最典型的症状是几个小时之后某个远端档位上留着一笔早就不存在的挂单,而近端看起来一切正常。

这套接入层的代价

签名把时钟变成了依赖。 服务器时间漂了几秒,所有签名请求一起失败,而错误信息只会说时间戳超出接收窗口,不会告诉你是你的机器慢了。NTP 从「运维的事」变成了「交易能不能下出去的事」。

限流额度是有限资源,得在自己的程序之间分配。 权重按 IP 计,那么同一台机器上的行情轮询、账户对账、下单程序共享一个池子。对账程序半夜跑一次全量查询,可能把下单程序挤到 429。这件事在代码里看不出来,只在同时跑的时候才发生。

WebSocket 的状态维护责任在客户端。 交易所推的是增量,一旦你的重连逻辑漏了重新对齐这一步,本地订单簿会静默地错下去 —— 没有报错,没有异常,只有基于它做出的决策慢慢变得离谱。这类 bug 的代价不由交易所承担。

开放 API 招来的是机构和算法交易者。 完整的接口覆盖、多语言 SDK、子账户这类给资金管理用的功能,本身就是流动性的招商手段,成交量是它的副产品。代价是这批客户对延迟和稳定性的要求,会反过来定义你的接入层能容忍多少抖动 —— 而这个标准比零售用户高一个量级。第一篇讲过标记价格为什么不等于最新成交价,做市商对这个差别的敏感度,就是这个量级差的一个具体例子。

这篇是 加密货币交易所的第 4 篇。前一篇是 加密货币交易所 三:核心业务概念和实现,后一篇是 加密货币交易所五:交易引擎