加密货币交易所八:账户系统

账户里显示 1000,提现 800 却被拒。一个合约账户在一个币种上同时有四个数,只有一个是真正记在账上的。这篇拆这四个数的关系、全仓和逐仓下可用余额为什么算法不同,以及为什么金额不能用浮点数存。

位置
第 08 篇 / 共 12 篇
预计
9 分钟

账户页面上写着 1000 USDT,你申请提现 800,被拒了。

这不是 bug。一个合约账户在一个币种上同时存在四个数,页面上显示的通常是其中最好看的那个,而提现走的是另一个。

名字 怎么来的 什么时候变
钱包余额 入金 − 出金 + 已实现盈亏 − 手续费 ± 已结算的资金费 只在成交、结算、出入金时变
未实现盈亏 按标记价格把所有仓位算一遍 标记价格每次更新
保证金余额 钱包余额 + 未实现盈亏 跟着标记价格动
可用余额 保证金余额 − 仓位占用保证金 − 挂单冻结保证金 上面任何一个动,它就动

只有第一行是账,后面三行都是算出来的。 这是整个账户系统最要紧的一条:数据库里落地的是钱包余额和一条条流水,另外三个数任何时刻都能从它们重新推出来。反过来做 —— 把保证金余额也存成一个字段、每次价格变动去更新它 —— 就等于让一个每秒变几百次的量参与持久化,既写不动,也一定会和流水对不上。

冻结不是把钱转走,是在同一个账户里换一个格子

挂一个限价单要占用保证金。这里发生的不是「转到系统账户」,而是同一个账户里的一次内部移动:

下一个需要 300 USDT 保证金的限价单
下单前 可用 1000 挂单冻结 0
下单后 可用 700 挂单冻结 300 ← 保证金余额没变
撤单后 可用 1000 挂单冻结 0 ← 原路回去

保证金余额(等式左边)自始至终没动过,动的只是它内部的构成。这就是复式记账在交易所里的样子:每一笔变动同时写两侧,任何时刻加起来必须平。

如果实现成「转到一个冻结账户」,那么撤单、部分成交、进程崩溃后恢复,每一处都多一个可能对不上的地方 —— 而「对不上」在这里的含义是有人的钱凭空多了或者少了。账不平的系统没法靠加监控救回来,只能重做记账模型。

回到开头那个 800。可用余额是 1000 里减掉仓位占用和挂单冻结之后剩下的,页面上那个 1000 多半是保证金余额。而且可用余额还不等于可提金额 —— 浮盈是按标记价格算出来的一个数,价格一动它就没了,能不能提走各家规则不同。

一个用户在每个币种上各有一行

多币种资产不是在用户表上加列,是一张以「用户 + 币种」为主键的表:

用户 ID 币种 可用余额 冻结余额
001 BTC 1.5 0.5
001 ETH 10 2
001 USDT 1000 500

旁边配一张币种表,存这个币种自己的属性:

币种 链上精度(小数位) 最小提现量
BTC 8 0.001
ETH 18 0.01
USDT(以太坊上) 6 10

上新币种因此是往这两张表里插数据,不是改表结构,也不用发版。

金额不能用浮点数存,ETH 连整数都要小心

上面那张表的「精度」一列不是显示位数,是这个币在链上的最小单位有多小。它直接决定了后端能用什么类型存金额。

先在控制台里跑一遍:

浏览器控制台,三行
0.1 + 0.2 === 0.3 // false
0.1 + 0.2 // 0.30000000000000004
Number.MAX_SAFE_INTEGER // 9007199254740991

第一行就足以否决掉「用 double 存余额」:0.1 和 0.2 在二进制里都是无限循环小数,加起来差在第 17 位。一天几千万笔累加下去,误差会积到用户看得见的位数上。

标准做法是用整数存最小单位1.5 BTC 存成 150000000 聪),或者用定点十进制类型。但整数也有上限,第三行那个数是 JavaScript 能精确表示的最大整数:

两个币种,两种结局
BTC 总量 21,000,000 × 10^8 = 2.1 × 10^15 < 9.007 × 10^15 放得下
ETH 1 个 ETH 就是 10^18 wei = 1.0 × 10^18 > 9.007 × 10^15 放不下

也就是说,全世界的比特币加起来用一个 double 的整数区间装得下,而一个 ETH 的 wei 数就已经超了。所以碰 ETH 及其之上的 ERC-20 时,Number 不能用,得换 BigInt 或者后端的定点十进制 —— 这不是「注意精度」这种提醒,是一个能算出来的硬边界。

USDT 的 6 位又提醒了另一件事:同一个符号在不同链上精度可能不同,币种表的主键得是「币种 + 链」,不能只有符号。

全仓和逐仓,可用余额是两套算法

这是账户系统里全仓逐仓差异真正落地的地方,其余的表述都是它的推论。

全仓下只有一个数,账户级:

全仓
保证金余额 = 钱包余额 + 所有仓位的未实现盈亏之和
可用余额 = 保证金余额 − Σ仓位占用保证金 − Σ挂单冻结

逐仓下每个仓位自己一份,互相看不见:

逐仓
某仓位的保证金余额 = 划给它的保证金 + 这个仓位的未实现盈亏
账户可用余额 = 钱包余额 − Σ已划出去的保证金 − Σ挂单冻结

差别用数字最清楚。账户里 10000 USDT,两个仓位:A 浮盈 +2000,B 浮亏 −1500。

同样的行情,两种模式
全仓 保证金余额 = 10000 + 2000 − 1500 = 10500
A 的浮盈直接顶住了 B 的浮亏,B 离强平线还很远
逐仓 给 A 划 3000,给 B 划 3000,钱包里剩 4000
A 的保证金余额 = 3000 + 2000 = 5000
B 的保证金余额 = 3000 − 1500 = 1500
A 那 2000 浮盈进不到 B 里,B 自己扛

接着让 B 再亏 1500。全仓下保证金余额变成 9000,B 照样活着;逐仓下 B 的保证金余额归零,B 被强平,损失封顶在 3000,A 和钱包里的 4000 一分不动。

「全仓强平风险更低」这句话只在这个意义上成立:它把强平推迟了,代价是真到强平那一步,赔的是整个账户。逐仓提前认输,换来的是亏损有上限。

模式切换因此不能在有持仓的时候随便做 —— 切过去之后可用余额是按另一套公式算的,同一个仓位的强平价会变。常见的做法是要求先平仓,或者至少先撤掉所有挂单。

并发:同一个账户的余额操作必须排队

余额是典型的热点行。同一个用户同时下五个单,五个请求都要读一次可用余额、减一次、写回去 —— 不加控制就会超发。

三种做法各有代价:

  • 悲观锁SELECT ... FOR UPDATE):正确,但把并发压成串行,热点账户会拖慢整个库。
  • 乐观锁(版本号,提交时比对):冲突少的时候快,冲突多的时候在重试上耗掉的比悲观锁还多。
  • 单账户串行:把同一个用户的所有余额操作路由到同一个处理线程,队列内天然有序,不需要锁。代价是这个用户的操作再也不能并行,而且路由本身成了一个必须高可用的组件。

第三种在交易所里最常见,因为它顺带解决了另一个问题:跨币种的死锁。一笔操作要同时动 BTC 和 USDT 两行,另一笔反着来,两边各拿到一半就互相等。解法是按固定顺序申请 —— 永远按币种 ID 从小到大拿锁,环就成不了。这条规则很短,但它必须写进代码规范,因为违反它的代码在测试环境里通常跑得好好的。

统一账户把效率和风险绑在了一起

把现货、杠杆、合约放进同一个保证金池(业内叫统一账户或组合保证金),好处很直接:现货持仓能当合约的保证金用,同一笔钱不用在几个子账户之间搬。

代价是风险敞口从此不能分开看。原来合约爆掉不影响现货,现在它们共用一个保证金余额,一次合约上的清算会把现货持仓一起卷进去。这套模式对做对冲的机构是净收益,对只做单边的散户则是把逐仓的保护也拆掉了 —— 所以交易所通常默认关闭,需要用户手动开启并签一次风险确认。

子账户是相反方向的设计:主账户下开多个隔离的子账户,各有独立的 API 密钥和权限,用来把不同策略的风险切开。它和统一账户不矛盾 —— 前者在账户之间划墙,后者在同一堵墙内打通产品。

这一层的代价

四个数意味着四个地方可能不一致。 页面、API、风控、清算如果各自算一遍可用余额,公式差一个括号就会出现「风控说够、下单说不够」。唯一稳的做法是这个公式只有一处实现,其余全部调它。

串行化换来的是正确,付出的是单账户吞吐。 一个高频机构账户的所有请求排在一条队列上,这条队列就是它的上限。想提高只能拆子账户,也就是把问题还给用户。

浮盈能当保证金但不能当钱。 允许用未实现盈亏开新仓,等于让一个还没兑现的数字撑起真实的风险敞口。价格回头的时候,新开的那些仓位会和原来的仓位一起进入强平判定,这条路径在 第七篇的清算流程 里是最常见的连锁反应来源。

冷热钱包分离是提现速度的上限。 大部分资金在冷钱包里,热钱包只留必要的流动性,所以「秒到账」在结构上就不可能对所有金额成立 —— 超过热钱包阈值的提现必须等一次人工或多签的转出。这是安全性直接换掉的用户体验,第十二篇 展开讲。

这篇是 加密货币交易所的第 8 篇。前一篇是 加密货币交易所七:清算系统,后一篇是 加密货币交易所九:市场数据系统