全部文章

账本系列(二):余额与不可变性

·
2026年9月15日
·
阅读约 17 分钟
·
••• 次浏览
·
••• 次点赞

本文译自英文原文。 阅读英文原文

账本系列(二):余额与不可变性

这个“账本系列”的第一部分停在了一个更简单的世界里,每一笔资金变动一记录下来就结算完毕。现在的问题是,真实的钱不是这样运作的。比如 Tobi 是你的一个用户,他在你的应用里有 100 USDC,另有 50 USDC 卡在一笔尚未确认的充值上,还有一笔 30 USDC 的提现正在发往一个外部钱包。这个时候他的余额应该显示多少,他实际能花的又是多少?

取决于应用把什么算进去,它可以显示 100、120 或 150,而他能花多少,诚实的答案是 70。已经记录的、预期会到的、当下能花的,这三者之间的差距,就是第二部分要讲的内容。

为什么一个余额已经不够用了

复式记账模型在这里没有任何变化。每一笔资金变动依然是平衡的,分录依然只写一次、永不改动。变的是,钱现在会在不同的状态里停留一段时间。一笔银行卡支付要好几天才能结算,一笔加密货币充值在区块确认数达到某个设定值之前一直处于未确认状态,一笔出款正在服务商那里路上走着,可能会失败,谁也不知道。

第一部分的单一余额只能描述已经到账的钱。它没有办法描述尚未结算或者还在途中的钱,所以只维护一个数字的系统,最后要么把用户拥有的钱藏了起来,要么把还没进托管的钱交了出去。系统还必须记录处于待定状态的钱。

三种余额

一个能对在途资金建模的账本会用三个数字来回答,而它们依然都只是求和,求的是分录以及仍在等待入账的充值。

  1. 已入账(Posted):这是 Tobi 所有账户上全部已结算资金变动的总和,包括他的钱包,以及所有存放着为他的提现而预留资金的账户,是最终的事实。Tobi 的已入账余额是 100 USDC。

  2. 待定(Pending):这是最常让人困惑的一个,因为“待定余额”被用来指两种不同的东西。一种是单纯的待定变动,+50 USDC 流入、-30 USDC 流出,在途 80 USDC,净额 +20。另一种是已入账余额加上这些变动,100 + 50 - 30 = 120 USDC,也就是余额最有可能最终落定的数字。这是两个不同的问题,永远不要把它们混在同一个接口里。

  3. 可用(Available):这是 Tobi 现在真正能花的钱。已入账的值减去流出的值,100 - 30 = 70 USDC。尚未结算的流入资金不算可用,在这个钱包里,链上那 50 USDC 在结算之前,对“花钱”这件事来说是不存在的。

这里的可用余额只有一个数字,是因为这个钱包只执行一套策略。很多系统会把它拆开,Tobi 能花多少和能提多少最终变成两个不同的数字。一笔银行卡充值可以马上在应用内消费,却在好几天内都不能用于付款,否则有人用一张盗来的卡充值,同一时刻就以加密货币的形式提走,最后背上拒付的是你。也有些公司反其道而行,给予临时入账,根据某些参数在资金结算之前就让它可用,即时到账就是这么实现的,在美国,Regulation CC 甚至规定了银行必须遵守的时间表,明确存入的资金何时变为可用。商户余额还会在这些之上再叠加现金缓冲和风险冻结。

不过临时入账是有代价的。充值一出现你就给 Tobi 入账,DR deposits_in_transit 50 / CR tobi:wallet 50,他把钱花了,这笔充值又因为某个奇怪的原因失败了,现在他的钱包是负数。所以账本必须在设计上允许特定账户出现负余额,并把它作为 Tobi 现在欠你的一笔应收款来跟踪。你的欺诈损失和信用风险正是从这里来的,所以你要事先决定哪些账户类型允许变成负数。有些账本系统,比如我们自己构建的那个,就有一个专门干这件事的标志位 debits_must_not_exceed_credits,在某个账户上设置它之后,任何会让该账户借记超过贷记的转账都会被拒绝。

跟着钱走

现在,把 Tobi 的资金变动一笔一笔拆开看。和第一部分一样,每一笔依然被记录为一笔平衡的交易。

他那笔 50 USDC 的充值在获得一个确认时被检测到。你也知道,这些币已经上链,但还没有达到最终性,网络仍然可能丢掉包含它们的区块,换上另一个,这就是所谓的“重组”(reorg),这是你能看到但还没真正持有的钱。此时账本上什么都不入账。充值有它自己的一份只追加的历史,而不是一笔入账,关于它的任何变化,一个新的确认、一个新的状态,都是新的一行,Tobi 的待定余额就从那里读出这 50。没有任何分录碰到托管账户或他的钱包。托管账户要和链上已确定的余额进行对账,而这正是审计师和储备证明(proof of reserve)系统按标准会去核查的东西。

现在的余额:已入账 100,待定变动 +50,可用 100。

只有当链认定这笔充值已经最终确定,并且你的托管服务商对这笔充值本身的记录也与之一致时,它才会入账。要逐笔核对充值。如果你只和服务商的总余额比较,另一笔同时到账的充值可能让总额看起来是对的,而这 50 其实仍然下落不明。然后它就是一笔交易:

DR custody       50
CR tobi:wallet   50

现在,在同一次提交里,充值的历史记下它已入账的那一行,这 50 从待定转入可用。现在的余额:已入账 150,待定变动 0,可用 150。没有任何东西被修改,账本只是刚刚才得知这笔充值,而那时它已经是最终的了。

“最终”是什么意思取决于具体的链。十二个确认是工作量证明那一套。在 Ethereum 权益证明下,真正的信号是 finalized 检查点,两到三个 epoch,正常情况下大约 15 分钟。Solana 有自己的承诺级别(commitment level),而自从它的 Alpenglow 升级在 2026 年 10 月上线之后,confirmed 和 finalized 的含义已经一样了。在 Base 或 OP 这样的 rollup 上,排序器确认你的交易只是第一步,L1 最终性要大约 20 分钟之后才到。最终性最后变成了你为每条链设定的一个下限,每笔充值都应该记录下它是按哪个下限结算的,这样某天某条链出问题时,你能确切知道哪些充值是按哪条规则入账的。如果服务商宣布一笔充值已完成,而它仍低于你的下限,你就把它挂起等待审核,什么都不入账。如果你用的是 Fireblocks 这样的系统,直接在它的控制台里配置就行。

现在,最终性之前发生的重组是简单的情况,充值并没有失败。这笔交易通常会回到内存池(mempool),再落进后面的某个区块,所以充值只是退回未确认状态,确认数重新开始计。如果这时把它标记为失败,那么当它再次落地时,会发生两种情况之一:要么你的系统忽略它,Tobi 永远没有入账,而托管账户里躺着一笔你不欠任何人的币;要么它看起来像一笔全新的充值,Tobi 被入账两次。只有当一笔充值被证明确实已经没了,它才算失败,比如在 Ethereum 上,同一发送方的另一笔交易用掉了那个 nonce,而且那笔交易已经最终确定,即便如此也没有什么需要冲销的,因为账本从未见过它。

第二种情况正是充值的键(key)如此重要的原因。一笔交易可能携带好几笔 USDC 转账,所以键由链、交易哈希以及该转账在交易内部的位置组成。在 Ethereum 上,那就是它在交易回执里的位置。这种情况下不要去用 logIndex,它是在整个区块范围内计数的,所以一旦来自被丢弃区块的交易落进另一个区块,它就会变。确保每笔充值只入账一次的唯一约束,应该放在入账它的那笔账本交易上。放在历史表上的话,它会拒绝第二条确认记录,因为那里的每一行都带着同一个键。

回到 Tobi,他的 50 到账了。现在他要提现 30 USDC。在调用任何服务商之前,这笔钱先被预留:

DR tobi:wallet        30
CR tobi:withdrawals   30

现在的余额:已入账 150,待定变动 -30,可用 120。已入账仍然是 150,因为它读取的是 Tobi 所有的账户,钱包降到了 120,提现账户多了 30。预留是一笔真实的复式记账,这就是为什么可用余额在提现入账之前就下降了,这 30 虽然还没离开大楼,但已经名花有主。

你可能注意到了,充值在最终确定之前一直不上账本,而提现在任何钱离开之前就上了账本。归根结底在于它们各自对可用余额做了什么。冻结会减少 Tobi 能花的钱,所以它必须和余额检查一起提交,否则两个请求可能会同时花掉同一笔 30。待定的充值还没有增加任何他能花的钱,所以它可以在账本外面等到最终确定。临时入账是唯一的例外,这就是为什么在那种情况下充值会立刻入账。

服务商送达,出款完成:

DR tobi:withdrawals   30
CR custody            30

现在的余额:已入账 120,待定变动 0,可用 120。完成这一步只是清掉预留,托管账户付给通道,钱包保持不动。如果入账时再扣一次可用余额,Tobi 付了 30,却会眼看着 150 变成 90。

现在,我是故意把这笔提现写得这么干净的,预留 30,结算 30。真实的提现很少这样。gas 要等交易被打包后才知道,服务商会收取他们自己的手续费,而且 USDC 转账的 gas 是用 ETH 支付的,这是另一种资产。所以你冻结的金额和最终结算的金额几乎从来不一致,你要预留一个上限,也就是 30 加上手续费可能的最大值,比如 2:

DR tobi:wallet        32
CR tobi:withdrawals   32

然后在一笔交易里结算实际金额,并释放剩余部分:

DR tobi:withdrawals      32
CR custody            30.00
CR revenue:fees        0.40
CR tobi:wallet         1.60

在途期间可用余额下降了 32,最后落在 119.60,提现账户的净额仍然是零。网络实际收取的 gas 是你的费用,由你自己的 ETH 在另一笔独立的资金变动中支付,DR expense:gas / CR treasury:eth,它自己就能平衡,因为第一部分的检查是按资产进行的。这 0.40 现在是你的收入,所以它也要从客户托管里划出去,DR treasury:usdc 0.40 / CR custody 0.40。托管账户只放客户的钱。如果你从里面付 gas,每一笔提现都会吃掉一点其他所有人的钱,而第一个暴露问题的地方会是你的储备定时任务,也就是按资产比较你持有的和你欠用户的,结果对不上、少了。

银行卡通道让这件事更难:

提现失败时会发生什么

这件事有三种走向,其中只有两种算失败。

简单的错误:待定的提现在入账之前被拒绝,可能是地址没通过筛查。预留被释放:

DR tobi:withdrawals   30
CR tobi:wallet        30

可用余额回到 150,已入账余额从来没动过,因为钱一直都在。

代价高昂的那种:出款已经入账,链上也已确认,几天后接收它的交易所又把它退了回来。这笔退回本身是一笔新的资金变动,而在链上它只是又一笔转入,所以它和任何充值走同一条路径,有自己的历史、经过你的最终性下限、与它所退回的那笔具体出款匹配,然后才入账。实际到账多少就入账多少,退回资金的交易所可能会扣下自己的手续费,所以出去的是 30,回到你这里的也许只有 29.50:

DR custody       29.50
CR tobi:wallet   29.50

原来的出款留在历史里,这笔退回也一样。

然后还有第三种,也就是让大多数人亏钱的那种:服务商超时了,没人知道这笔出款到底有没有到账。你不能把未知状态当成失败。当成失败处理,释放那 30,结果它还是上链了,就这样你付了两次钱,恭喜你!

真正的标准做法是,未知的结果一直保持待定,直到服务商给出答复。

不过有些服务商永远不会答复,这取决于他们的系统是怎么构建的。所以未知状态也不能永远停在待定里,它要沿着一个阶梯往下走,每一级要么用一笔账本交易把它解决掉,要么交给下一级:

  1. 直接向服务商查询状态,也就是“重新查询”(requery),不要只是干等 webhook。
  2. 把它和服务商的记录以及链本身进行匹配,这由你选择。
  3. 核销,并在你的后台系统上走审批。

而要想知道某样东西卡住了,清算账户就不能只是一个数字。比如 Tobi 发起了两笔 30 的提现,一笔结算了,另一笔一直没有。tobi:withdrawals 显示 30,而因为两笔都是 30,这个数字里没有任何信息能告诉你卡住的是哪一笔。所以每一条结算或释放某样东西的分录,都要指向它所关闭的那条具体分录,这就是未清项匹配(open item matching),直接借鉴自应收账款,任何超过你所用通道 SLA 仍未关闭的项目都会触发告警。

历史保持不变

回头看看上面 Tobi 的钱经历的一切,没有任何一条已有分录被更新过,充值历史里也没有任何一行被更新过。状态之所以变化,是因为新的交易叠加在旧交易之上,而这就是本文的后半部分。

这类系统天生就要处理更正,一条多写了一个零的分录,一笔记错了账户的资金变动。凌晨 2:45 最大的诱惑就是来一条 UPDATE。你应该用一笔更正交易来修数据,而不是去更新某一行。更正必须是原交易借贷方向翻转后的镜像,并回指原交易,同时带上原因和来源,这样别人查看时,这条记录能自己说清楚来龙去脉:

function reverse(original: Txn, reason: string, source: string): Txn {
  return {
    id: randomUUID(),
    referenceId: original.id,
    effectiveAt: original.effectiveAt,
    reason,
    source,
    entries: original.entries.map((e) => ({
      accountId: e.accountId,
      side: e.side === "DR" ? "CR" : "DR",
      amountMinor: e.amountMinor,
      assetId: e.assetId,
    })),
  };
}

每个字段都是按名字逐一复制的,这是故意的,如果用 ...e 把原分录展开,更正最后也会带上原分录的 id 和 timestamps。这两笔交易加起来净额为零,最终余额永远不会掩盖它是怎么来的。客服可以回放 Tobi 的历史,先是充值自己的那几行,然后是账本,按顺序看到充值、确认、预留、释放。你的数据库也像第一部分的规则那样为此兜底,对分录表的 UPDATE 或 DELETE 一律被拒绝!这一点没什么可犹豫的。

按现在的写法,reverse() 有一件事拦不住,那就是把同一笔交易冲销两次,运行两次,Tobi 就会拿回两次钱。一笔交易的所有冲销加起来,永远不能超过原交易。一个简单的唯一索引可以阻止第二次全额冲销,但它没法做加法,所以一次全额冲销加一次部分冲销仍然会溜过去。真正的检查要读取已经冲销了多少,并把新的冲销作为一笔原子交易写入,而这是第三部分要解决的问题,我们到时候再聊。

存储的余额必须一致

第一部分说过,存储的余额是一个必须和日志保持一致的缓存。有了三种余额,风险就更高了,因为批准花钱靠的就是这个缓存。用来批准提现的余额必须反映已提交的预留和入账,而且它必须在写入分录的同一次提交里一起变化,否则中间的一次读取就会看到已经被许诺出去的钱。

第一部分的那个测试依然成立,丢掉所有存储的数字,重放账本加上充值历史,你会得到同样的三种余额,而待定部分总是可以和对链的一次全新扫描进行核对。有了上面提到的检查点,重放从最后一个经过验证的检查点开始,而不是从有史以来写下的第一条分录开始,这正是让这个测试在大规模下依然可行的关键。当存储的数字和重放出来的数字不一致时,对账任务会比较两者,并在出现偏差时告警。然后你要找出到底是哪一个在撒谎。如果分录是对的、缓存是错的,你就从分录重建缓存,这种修复里不应该出现任何新的资金变动。如果分录本身是错的,你就像上面那样入账一笔更正交易。无论哪种情况,在再次信任这个数字之前,你都要先调查。这个告警没有商量的余地。

附注:存储的余额是一个和,而和需要空间。USDC 在 Ethereum 上有 6 位小数,在 Stellar 上有 7 位,ETH 有 18 位。有符号 64 位整数的上限大约是 9.22 × 10¹⁸,换算成 wei 只有约 9.22 ETH,一笔像样的充值就够了。Postgres 的 bigint 正好就是这个 64 位整数,和第一部分 TypeScript 里的 bigint 是同一个词,上限却天差地别。使用 128 位的金额,这也正是我们的做法,在 Postgres 里这意味着 scale 为 0 的 numeric,在 TypeScript 里坚持用 bigint 而不是 number,并把每种资产的精度和资产存在一起,30000000 在你知道它是 6 位小数的 USDC 之前毫无意义。

至于提交本身在高负载下如何保持安全,锁之类的东西,那是第三部分的内容。

当时的余额是多少?

每一笔资金变动都有两个时钟,一个是它何时生效,一个是你的系统何时记录了它。一笔银行卡结算可能昨天生效、今天才到,服务商的 webhook 可能晚好几个小时才来。今天去重建昨天的余额,里面就会包含系统昨天还没听说过的资金变动。那个数字是你现在对昨天的了解,而不是你当时所知道的。

更正和退回也都必须各自选一个生效日期。对记账错误的更正沿用原交易的创建日期,因为原交易从来就没有成立过,这就是上面的 reverse() 要复制 original.effectiveAt 的原因。退回用的是当天的日期,因为那笔出款确实发生了,钱也确实是后来才回来的。一旦把这两者搞反,那些日子里每一个时点余额都会错得离谱,再让你付出一次代价。

然后还有已关账的会计期间。一个期间一旦关账,对账单已发出、报表已提交、利息已计提,一条在该期间内生效的迟到分录就不能再改变那些数字了。你要在当前开放的期间里把它作为一笔跨期调整入账,生效日期指回过去,传统银行把这叫作起息日(value date),即从何时开始计算,以及记账日(booking date),即何时入账。而一条倒起息的分录意味着利息或手续费是按错误的余额算出来的,所以重新计算的结果也要作为一笔新的调整交易入账。

这依然无法保证的事

这里的模型是对的,我们有三种余额、只追加的历史、保持一致的缓存。现在两笔各 80 USDC 的提现相隔一毫秒到达。两者都读到可用余额 120,都通过了检查,都完成了预留。120 里出去了 160,而历史只会如实记下你是怎么亏掉的。

上面的内容没有一样能阻止这件事,而上面的内容也没有一样是错的。余额和历史描述的是钱,它们不守护写入,两个请求仍然可以读到同一个可用余额,并且都在其中完成写入。让这次写入具备原子性,也就是检查和预留要么一起提交,要么都不提交,这就是第三部分要讲的内容,事务与原子性。

•••
分享本文:
系列
账本系列

获取最新动态

将最新文章直接发送到你的收件箱。