# 账本系列（一）：复式记账模型

> 大多数金融系统的起点都差不多。你在做一个钱包，用户有 100 美元，他给朋友转了 30 美元，于是你在一行上写 balance = balance - 30，在另一行上写 balance = balance + 30。

作者 Shodipo Ayomide，发布于 2026年6月30日。网页版本：https://blog.shodipoayomide.com/zh/ledger-series-part-i-the-double-entry-model

![账本系列（一）：复式记账模型](https://blog.shodipoayomide.com/banners/ledger-series-part-i-the-double-entry-model.png)

大多数金融系统的起点都差不多。你在做一个钱包，用户有 *100 美元*，他给朋友转了 *30 美元*，于是你在一行上写 `balance = balance - 30`，在另一行上写 `balance = balance + 30`。余额就成了钱包上存着的一个数字，每次更新一下，就像礼品卡上的总额。它能跑，于是你就这样推上了生产环境。

后来公司做大了，你有了借记卡/信用卡、加密货币通道、法币出款、手续费、退款、好几家支付服务商，还有一家钱包服务商。整个金融系统散落在不同团队负责的各个服务和数据表里。然后某个季度，财务告诉你有 *40,000 美元* 对不上账，找不到了。

严格来说，钱并没有丢。它就在你的银行账户里，每一分钱都在。丢掉的是归属，或者说出处，也就是“*这笔钱属于谁*”的记录。数据库里的 `balance` 列存着一个数字，却没有存下它背后的完整故事，所以当这个数字和现实对不上时，你根本没办法回放它是怎么变成这样的。每个做过核心金融系统的工程师都有某个版本的这种恐怖故事，不信你去问！

解决办法不只是设计一个考虑更周到的 `balance` 列。而是一个银行和传统金融系统已经用了很多年、今天每个正经账本依然赖以运行的模型：复式记账。

## 为什么单个数字行不通

把钱存成一个可变的数字，也就是单式记账，会因为三个主要原因出问题，而且它们往往在凌晨 2:45 冒出来。

你没法追溯到底发生了什么，因为没有清晰的历史。`balance = 70` 完全没有说明是哪些资金变动产生了它，它只是一个数字。当它出错时，你找不到任何东西可以让你一路审计回去。

没有另一方。从钱包里减去 `$30`，并不会记录这 *30 美元* 去了哪里。钱的来源和去向只存在于碰巧执行的那一行应用代码里，而不在核心系统数据里。

再者，面对必然会/可能会发生的竞态，它毫无招架之力。两个请求同时读到 `balance = 100`，各自减去 *30*，又各自写入 *70*。钱动了两次，这一列看起来却仍然是对的。

## 三个核心原则

复式记账账本用一份只追加（append only）的资金变动记录取代了那个可变的数字，它由三个对象构成。

1. 账户（Account）：它就是一个独立的价值池。用户的钱包、公司的现金、你收取的手续费，每一个都是一个账户。

2. 分录（Entry）：它是针对单个账户的一笔借记或贷记。一条分录必须只写入一次，永远不能修改或删除。每条分录都是价值流入或流出单个账户的永久记录，“*不可变*”。

3. 交易（Transaction）：它是一组作为整体一起移动的分录，要么全部成功，要么全部不成功。正是这个对象把你的整个系统维系在一起。

你不会直接写分录。你写的是交易，由交易带着它的分录。写入本身在单个数据库事务里执行，所以要么全部成功，要么全部不成功，`tx, _ := db.Begin(); tx.Exec(...); tx.Commit()`。比如，*Alfred* 向 *Tobi* 转 *30 美元*，就是一笔包含两条分录的交易，一条从 *Alfred* 那里扣，一条给到 *Tobi*，它们要么都提交，要么都不提交。永远不应该存在这样一种有效状态：*Alfred* 已经被借记，而 *Tobi* 始终没有被贷记。这种原子化的分组就是保障。

## 借记与贷记

接下来这部分会把相当多的人绕晕。借记不是减法，贷记也不是加法。它们只是资金变动唯一可能发生或采取的两个方向，而某个方向到底让账户 *增加 ↑* 还是 *减少 ↓*，取决于它落在哪种类型的账户上。为了理清楚，你可以再读一遍“三个核心原则”这一节

就像上面说的，每个账户都属于少数几种可能的类型之一，而类型决定了它的正常余额方向，也就是让余额增加的那一侧。所以你只需要和两类打交道。第一类是借方余额账户，即资产和费用。借记让它们增加，贷记让它们减少。第二类是贷方余额账户，即负债、所有者权益和收入。贷记让它们增加，借记让它们减少。

一旦把它套到钱包的例子上，区别就清楚了。用户放在你这里的 *100 美元* 不是你的钱，而是你欠他们的钱，所以在你的账上它是一项负债，贷方余额。你代他们实际持有的那些美元是一项资产，借方余额。

现在，充值就说得通了。比如用户充值 *100 USDC*，有两件事同时成立：你多持有了 *100 USDC*，资产增加，这是一笔借记；你多欠用户 *100 USDC*，负债增加，这是一笔贷记。发生了一次资金变动，两条分录，而且它们是平衡的。

![充值 100 USDC 的两条分录](https://i.ibb.co/BHc5VCQW/Screenshot-2026-06-30-at-11-44-10-AM.png)

借记和贷记并不是互相拉扯的对立面。它们是对同一笔 *100 美元* 的两种视角，在同一时间、同一笔交易里被记录下来。

用物理来类比，还有另一种看待和理解这一切的方式，那就是著名的牛顿第三运动定律“*每一个作用力，都有一个大小相等、方向相反的反作用力*”

在任何一笔交易里，借记和贷记都必须相等。每一次都是，零例外。Σ 借记 − Σ 贷记 = 0

力从来不会单独出现，它总是和一个大小相等、方向相反的孪生兄弟一起到来。在账本这个场景里，价值的运作方式一模一样。它永远不会只落在一个账户上，另一个账户上必然有一条大小相等、方向相反的分录，两者一起让账目保持平衡。

你借记到托管账户的 *100 USDC* 和你贷记给用户的 100 USDC 大小相等，但它们不是同一样东西，一个是你现在持有的资产，另一个是你现在欠下的负债。所以这里并没有什么东西在移动，而是两边同时增长，一项新资产和一项等额的新负债一起诞生，这正是账目能保持平衡的原因。

## 铁律

在每一笔交易中，借记总额必须等于贷记总额。不平衡的交易不允许存在于任何地方。

正是这条规则，把“永远不丢钱”从一种愿望变成了你所构建系统的一个关键属性。价值不可能离开一个账户却不到达另一个账户，否则交易就无法平衡。你的托管资产不应该和你欠用户的钱慢慢对不上，因为每一次资金变动都会同时记录两边。出了问题时，账本不会悄悄塞给你一个错误的余额，而是直接拒绝这次写入。

实践中，账本的核心就是这一个检查。这里，金额以最小货币单位（美分，或 wei）存储为非负整数，方向由 `side` 字段而不是正负号来表示：

```typescript
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 账户等等，最后反而会成为最慢的读取。所以你不用重新累加。你把余额存起来，每条分录落地的那一刻就更新它，并让它和日志保持同步。日志依然是唯一的事实来源。存下来的那个数字其实只是一个必须和日志保持一致的缓存。含义上是推导出来的，实践中是存储下来的，两者兼有，而不是二选一。

同样的思路画成一张图，一笔交易展开成两条平衡的分录，分别落在两个账户上：

![两条分录，两个账户](https://i.ibb.co/xKQv3d4S/Screenshot-2026-06-30-at-2-04-51-PM.png)

## 为什么这是地基

单式记账系统要求你信任一个数字。复式记账系统拒绝让一个数字孤零零地立着，余额不是你声称的东西，而是分录强制让它成立的东西。这和唯一索引让重复数据变得不可能，而不只是一件“你不应该做”的事，是同一个道理，平衡规则让丢钱变成一次无法提交的写入，而不是你几个月甚至几年后才发现的差额，到那时你的牌照已经在听证会上被质疑了。这就是几乎所有金融系统都这样构建的原因。

第一部分也只是骨架。真正的账本还必须处理尚未结算的资金，比如一笔等待区块确认的加密货币充值，一笔可能失败、需要退回给发起用户的提现。在第二部分，我们会把这个模型放到真实的资金变动上，看着一个余额如何变成三个。
