
大多数金融系统的起点都差不多。你在做一个钱包,用户有 100 美元,他给朋友转了 30 美元,于是你在一行上写 balance = balance - 30,在另一行上写 balance = balance + 30。余额就成了钱包上存着的一个数字,每次更新一下,就像礼品卡上的总额。它能跑,于是你就这样推上了生产环境。
后来公司做大了,你有了借记卡/信用卡、加密货币通道、法币出款、手续费、退款、好几家支付服务商,还有一家钱包服务商。整个金融系统散落在不同团队负责的各个服务和数据表里。然后某个季度,财务告诉你有 40,000 美元 对不上账,找不到了。
严格来说,钱并没有丢。它就在你的银行账户里,每一分钱都在。丢掉的是归属,或者说出处,也就是“这笔钱属于谁”的记录。数据库里的 balance 列存着一个数字,却没有存下它背后的完整故事,所以当这个数字和现实对不上时,你根本没办法回放它是怎么变成这样的。每个做过核心金融系统的工程师都有某个版本的这种恐怖故事,不信你去问!
解决办法不只是设计一个考虑更周到的 balance 列。而是一个银行和传统金融系统已经用了很多年、今天每个正经账本依然赖以运行的模型:复式记账。
把钱存成一个可变的数字,也就是单式记账,会因为三个主要原因出问题,而且它们往往在凌晨 2:45 冒出来。
你没法追溯到底发生了什么,因为没有清晰的历史。balance = 70 完全没有说明是哪些资金变动产生了它,它只是一个数字。当它出错时,你找不到任何东西可以让你一路审计回去。
没有另一方。从钱包里减去 $30,并不会记录这 30 美元 去了哪里。钱的来源和去向只存在于碰巧执行的那一行应用代码里,而不在核心系统数据里。
再者,面对必然会/可能会发生的竞态,它毫无招架之力。两个请求同时读到 balance = 100,各自减去 30,又各自写入 70。钱动了两次,这一列看起来却仍然是对的。
复式记账账本用一份只追加(append only)的资金变动记录取代了那个可变的数字,它由三个对象构成。
账户(Account):它就是一个独立的价值池。用户的钱包、公司的现金、你收取的手续费,每一个都是一个账户。
分录(Entry):它是针对单个账户的一笔借记或贷记。一条分录必须只写入一次,永远不能修改或删除。每条分录都是价值流入或流出单个账户的永久记录,“不可变”。
交易(Transaction):它是一组作为整体一起移动的分录,要么全部成功,要么全部不成功。正是这个对象把你的整个系统维系在一起。
你不会直接写分录。你写的是交易,由交易带着它的分录。写入本身在单个数据库事务里执行,所以要么全部成功,要么全部不成功,tx, _ := db.Begin(); tx.Exec(...); tx.Commit()。比如,Alfred 向 Tobi 转 30 美元,就是一笔包含两条分录的交易,一条从 Alfred 那里扣,一条给到 Tobi,它们要么都提交,要么都不提交。永远不应该存在这样一种有效状态:Alfred 已经被借记,而 Tobi 始终没有被贷记。这种原子化的分组就是保障。
接下来这部分会把相当多的人绕晕。借记不是减法,贷记也不是加法。它们只是资金变动唯一可能发生或采取的两个方向,而某个方向到底让账户 增加 ↑ 还是 减少 ↓,取决于它落在哪种类型的账户上。为了理清楚,你可以再读一遍“三个核心原则”这一节
就像上面说的,每个账户都属于少数几种可能的类型之一,而类型决定了它的正常余额方向,也就是让余额增加的那一侧。所以你只需要和两类打交道。第一类是借方余额账户,即资产和费用。借记让它们增加,贷记让它们减少。第二类是贷方余额账户,即负债、所有者权益和收入。贷记让它们增加,借记让它们减少。
一旦把它套到钱包的例子上,区别就清楚了。用户放在你这里的 100 美元 不是你的钱,而是你欠他们的钱,所以在你的账上它是一项负债,贷方余额。你代他们实际持有的那些美元是一项资产,借方余额。
现在,充值就说得通了。比如用户充值 100 USDC,有两件事同时成立:你多持有了 100 USDC,资产增加,这是一笔借记;你多欠用户 100 USDC,负债增加,这是一笔贷记。发生了一次资金变动,两条分录,而且它们是平衡的。

借记和贷记并不是互相拉扯的对立面。它们是对同一笔 100 美元 的两种视角,在同一时间、同一笔交易里被记录下来。
用物理来类比,还有另一种看待和理解这一切的方式,那就是著名的牛顿第三运动定律“每一个作用力,都有一个大小相等、方向相反的反作用力”
在任何一笔交易里,借记和贷记都必须相等。每一次都是,零例外。Σ 借记 − Σ 贷记 = 0
力从来不会单独出现,它总是和一个大小相等、方向相反的孪生兄弟一起到来。在账本这个场景里,价值的运作方式一模一样。它永远不会只落在一个账户上,另一个账户上必然有一条大小相等、方向相反的分录,两者一起让账目保持平衡。
你借记到托管账户的 100 USDC 和你贷记给用户的 100 USDC 大小相等,但它们不是同一样东西,一个是你现在持有的资产,另一个是你现在欠下的负债。所以这里并没有什么东西在移动,而是两边同时增长,一项新资产和一项等额的新负债一起诞生,这正是账目能保持平衡的原因。
在每一笔交易中,借记总额必须等于贷记总额。不平衡的交易不允许存在于任何地方。
正是这条规则,把“永远不丢钱”从一种愿望变成了你所构建系统的一个关键属性。价值不可能离开一个账户却不到达另一个账户,否则交易就无法平衡。你的托管资产不应该和你欠用户的钱慢慢对不上,因为每一次资金变动都会同时记录两边。出了问题时,账本不会悄悄塞给你一个错误的余额,而是直接拒绝这次写入。
实践中,账本的核心就是这一个检查。这里,金额以最小货币单位(美分,或 wei)存储为非负整数,方向由 side 字段而不是正负号来表示:
type Entry = { accountId: string; side: "DR" | "CR"; amountMinor: bigint; assetId: string };
function assertBalanced(entries: Entry[]) {
if (entries.length === 0) throw new Error("empty txn");
const net = new Map<string, bigint>();
for (const e of entries) {
if (e.amountMinor <= 0n) throw new Error(`bad amount: ${e.accountId} ${e.amountMinor}`);
const delta = e.side === "DR" ? e.amountMinor : -e.amountMinor;
net.set(e.assetId, (net.get(e.assetId) ?? 0n) + delta);
}
for (const [asset, balance] of net) {
if (balance !== 0n) throw new Error(`unbalanced: ${asset} off by ${balance}`);
}
}这里有两点比看上去更重要。平衡是按资产检查的,所以一笔同时涉及美元和比特币的交易必须在每种资产上各自平衡,一种货币永远不能悄悄地替另一种货币结算。另外,金额用的是 bigint,而不是浮点数,因为浮点数和钱是另一个恐怖故事,改天再讲。
还有一点值得注意:那段代码里没有任何地方碰到 balance 列。代码只检查分录是否平衡,从不在任何地方写入余额。而这恰恰是正确的出发点,余额从来不是你直接设置的事实。你不会写 balance = 500。你把分录加起来,debits - credits,再按账户的正常余额方向去解读。分录才是真相。余额只是你向它提出的一个问题。
这里还有一个容易搞错的地方。余额由分录定义,并不意味着每次读取都要把它们重新加一遍。如果你这么做,每次都是在对一个账户的全部历史求和,那是 O(n),而 n 只会越来越大。每笔交易都会碰到的那个账户,比如你的 treasury 账户等等,最后反而会成为最慢的读取。所以你不用重新累加。你把余额存起来,每条分录落地的那一刻就更新它,并让它和日志保持同步。日志依然是唯一的事实来源。存下来的那个数字其实只是一个必须和日志保持一致的缓存。含义上是推导出来的,实践中是存储下来的,两者兼有,而不是二选一。
同样的思路画成一张图,一笔交易展开成两条平衡的分录,分别落在两个账户上:

单式记账系统要求你信任一个数字。复式记账系统拒绝让一个数字孤零零地立着,余额不是你声称的东西,而是分录强制让它成立的东西。这和唯一索引让重复数据变得不可能,而不只是一件“你不应该做”的事,是同一个道理,平衡规则让丢钱变成一次无法提交的写入,而不是你几个月甚至几年后才发现的差额,到那时你的牌照已经在听证会上被质疑了。这就是几乎所有金融系统都这样构建的原因。
第一部分也只是骨架。真正的账本还必须处理尚未结算的资金,比如一笔等待区块确认的加密货币充值,一笔可能失败、需要退回给发起用户的提现。在第二部分,我们会把这个模型放到真实的资金变动上,看着一个余额如何变成三个。
将最新文章直接发送到你的收件箱。