- 引言 — 一件 600 年前的防 bug 装置
- 天真的设计为什么会丢钱
- 借方与贷方 —— 每一笔交易都被记两次
- 不可变的 append-only 记录
- 给账户与余额建模
- 「直接搞一个余额字段不行吗?」
- 对账 —— 确认账本是否为真
- 并发之下的一致性
- TigerBeetle —— 为账本而生的专用数据库
- 结语
- 参考资料
引言 — 一件 600 年前的防 bug 装置
搭建支付系统时,你迟早会撞上这个问题:「用户的余额该怎么存?」最天真的答案是「在账户表里放一个余额字段,钱进来就加,钱出去就减」。但这个天真的设计,会在真实业务中悄无声息、却确凿无疑地把钱弄丢。
处理钱的人们早在很久以前就解决了这个问题。这就是 15 世纪意大利商人与数学家卢卡·帕西奥利(Luca Pacioli)所系统化的复式记账(double-entry bookkeeping)。600 年后的今天,银行、卡组织、金融科技公司的账本依然运行在这套原理之上。原因很简单:复式记账本身就带着一套能捕获错误的结构。
本文从软件工程师的视角审视复式记账账本。我们会看借方与贷方为什么必须永远相等,为什么记录从不修改或删除、只能追加(append-only),账户与余额该怎样建模,「直接搞一个余额字段不行吗」这个诱惑为什么危险,对账与并发一致性怎样保证,以及像 TigerBeetle 这样的专用账本数据库为什么会出现。文中的 schema 与查询,你可以在本站的 SQL 游乐场和 Postgres 游乐场里亲手跑一遍,找找感觉。
天真的设计为什么会丢钱
先具体看看「一个余额字段」的设计为什么危险。想象这样一张表。
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
balance BIGINT NOT NULL -- 把当前余额存成一个数字
);
转账则这样处理。
UPDATE accounts SET balance = balance - 1000 WHERE id = 1; -- 转出方
UPDATE accounts SET balance = balance + 1000 WHERE id = 2; -- 转入方
表面上看似乎没问题。但这个设计有三个致命弱点。
第一,历史消失了。 只剩下一个余额数字,你无法知道「这个余额是怎么变成这个值的」。发生过哪些交易、什么时候动了多少钱,都无从追溯。在会计领域这是致命的 —— 因为这是一套无法审计(audit)的系统。
第二,对部分失败很脆弱。 如果进程在上面两条 UPDATE 之间挂掉,就会出现一边的钱已经扣了、另一边却没收到的状态。钱就这样蒸发进了空气里。用事务把它们包起来能挡住这个特定的问题,但仅凭这一点,下面的问题依然存在。
第三,没有办法验证。 当余额出错时,你甚至察觉不到它错了,因为根本没有一个可以对照的基准。哪怕 bug 或竞态条件让余额悄悄跑偏,系统也只会把那个错误的数字当成真相一直带下去。
复式记账账本从根本上解决了这三个问题。核心的思路转变就是这一句:不要存余额,存交易。余额从交易中算出来。
借方与贷方 —— 每一笔交易都被记两次
复式记账这个名字本身就是答案。每一笔交易都会被记录两次(double)—— 一次记作借方(debit),一次记作贷方(credit)。而在同一笔交易内,借方合计与贷方合计必须永远精确相等。
这条「借方合计 = 贷方合计」的规则,就是复式记账的心脏。钱不会凭空出现,也不会凭空消失。从哪里出来,就必然要进到哪里去。会计强制这条守恒定律的方式,正是借方与贷方的平衡。
举个例子,看 A 向 B 转 1000 的交易。这是一笔交易,用两行记录来表达。
交易 T1: A -> B 转账 1000
账户 借方(debit) 贷方(credit)
---- ----------- ------------
A - 1000 (1000 从 A 出去)
B 1000 - (1000 进入 B)
---- ----------- ------------
合计 1000 1000 <- 借方合计 == 贷方合计 (平衡!)
这里术语的方向很重要。在会计里,借方/贷方并不简单对应「增加/减少」,其含义会随账户类型(资产、负债等)而变化。但对设计软件账本的工程师来说,核心只有这一点:每笔交易由多条记录组成,这些记录的借方合计必须等于贷方合计。 用代码强制这个不变式(invariant),是复式记账账本的第一步。
这条规则的美妙之处在于自我验证。把系统里全部的记录加起来,借方总和与贷方总和必须永远相等。如果不相等呢?那就意味着某处存在 bug。仅仅是检测这个等式被打破的那一刻,就能尽早抓到漏钱的 bug。
不可变的 append-only 记录
复式记账的第二根支柱是不可变性(immutability)。账本的记录一旦写入,就绝不修改或删除。只能追加(append)新的记录。
为什么呢?因为会计记录是关于「发生过什么」的历史事实。已经发生的事无法改变。如果某笔交易记错了,不是去删掉那条记录,而是追加一笔把它冲销掉的新交易。这被称为红冲或调整分录(reversing/adjusting entry)。
修正一笔错误交易的方法:
(X) 对已有记录做 UPDATE 或 DELETE <- 绝对不行
(O) append 一笔抵消原交易的新交易 <- 正确做法
例: 若 T1 记错了
T2: 追加一笔与 T1 方向相反的记录来抵消它
T3: 重新追加一笔正确的交易
-> T1 的痕迹原样保留,只有最终余额变得正确
这条 append-only 原则带来了几项强大的性质。
- 完整的审计追踪(audit trail)。 每笔交易的历史都原样保存,因此任何一个余额都能从头重建、解释「为什么是这个值」。这在受监管的行业里是必备的性质。
- 可复现性。 余额随时可以通过对记录求和重新算出来。余额若可疑,从头重新累加一遍即可验证。
- 对竞态条件的抗性。 不是读出既有值再改(read-modify-write),而只是追加一行新记录,因此即便多笔交易同时涌入,也不会互相覆盖对方的记录。
append-only 账本在概念上与事件溯源(event sourcing)相通。不直接存储状态(余额),而是存储产生状态的那些事件(交易),状态则从这些事件中派生出来。事件日志成为真相的来源(source of truth)。
给账户与余额建模
现在来画一份实际的 schema。复式记账账本的最小组成是账户(accounts)与记录(entries)两张表。
-- accounts: 装钱的逻辑容器
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
currency TEXT NOT NULL -- 币种按账户固定
);
-- entries: 不可变的交易行。只能 INSERT
CREATE TABLE entries (
id BIGSERIAL PRIMARY KEY,
transaction_id BIGINT NOT NULL, -- 把属于同一笔交易的记录归拢在一起的 ID
account_id BIGINT NOT NULL REFERENCES accounts(id),
direction TEXT NOT NULL, -- 'debit' 或 'credit'
amount BIGINT NOT NULL CHECK (amount > 0), -- 永远为正,符号由方向表达
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
有几个设计要点值得说明。
第一,金额要存成整数。 绝对不能用浮点数(float)。0.1 + 0.2 算不出精确的 0.3 这种浮点数的性质,对钱来说是致命的。应该改存币种的最小单位(比如美分、元)的整数值。100 美元就存成整数 10000(美分),以此类推。
第二,金额始终保持为正,用方向(direction)来表达符号。 这样一来,「借方合计」与「贷方合计」就能分别聚合、方便验证是否平衡。
第三,没有余额字段。 余额不是一个存储值,而是一个从记录中计算出来的值。某个账户的余额,就是把属于该账户的全部记录按方向求和得到的。
-- 账户 42 的余额 = (进入该账户的) - (出去的)
SELECT
COALESCE(SUM(CASE WHEN direction = 'debit' THEN amount ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END), 0)
AS balance
FROM entries
WHERE account_id = 42;
插入一笔交易时,必须把互相平衡的多条记录放进同一个事务里一起插入。
BEGIN;
-- 交易 T1: 账户 1 -> 账户 2,金额 1000
INSERT INTO entries (transaction_id, account_id, direction, amount)
VALUES (1, 2, 'debit', 1000), -- 1000 进入转入账户
(1, 1, 'credit', 1000); -- 1000 从转出账户出去
-- 应用层(或某个约束)会验证这笔交易的 debit 合计 == credit 合计
COMMIT;
因为这两条 INSERT 属于同一个事务,要么两条都提交,要么两条都回滚。前面天真设计里「只有一边生效」的部分失败,从原理上就被杜绝了。想亲手跑一遍这类 schema 和事务,可以直接在 Postgres 游乐场里执行。
「直接搞一个余额字段不行吗?」
这里正面回答很多工程师会提出的问题。「每次都把所有记录加一遍,不会很慢吗?直接搞一个余额字段当缓存用不行吗?」
核心在于不要把余额字段当成真相的来源。为了性能而在某处预先算好(materialize)余额,这件事本身很常见也很合理。但它终究应该是从记录中派生出来的缓存。真相永远在 append-only 的记录日志里,余额缓存只是为了快速读取而存在的辅助物。
这个区分为什么重要,把两种设计放在一起比较就很清楚了。
| 视角 | 余额字段是真相 | 记录是真相(+ 余额缓存) |
|---|---|---|
| 历史追踪 | 不可能 | 完整的审计追踪 |
| 验证 | 没有可对照的基准 | 通过累加记录随时重新验证 |
| 部分失败 | 有钱蒸发的风险 | 靠事务从源头挡住 |
| 余额出错时 | 无从得知 | 靠重新计算来检测、恢复 |
| 性能 | 快 | 靠缓存也快,真相被保留 |
也就是说,答案不是「不要放余额字段」,而是「不要把余额字段错当成真相」。可以预先算好余额,但必须能随时从记录重建出它,并定期重新计算,验证缓存有没有和真相出现偏差。这正是天真设计与稳健账本之间的决定性差异。
实务中常见的做法,是给每个账户在每条记录旁一并记录一个「running balance(累计余额)」,同时强制这个值必须永远等于前一个余额加上本次记录的结果。这样就能同时获得快速查询与可验证性。
对账 —— 确认账本是否为真
复式记账账本的结构自带验证的能力,但要真正跑起这份验证,仍需要人为的工作。这就是对账(reconciliation)。在账本语境下,对账大致分两个层次进行。
内部一致性验证。 确认账本自身是否遵守了自己的规则。最基本的就是前面看到的全局等式:把系统里的全部记录加起来,借方总和必须等于贷方总和。
-- 全局不变式: 所有记录的 debit 合计 == credit 合计
SELECT
SUM(CASE WHEN direction = 'debit' THEN amount ELSE 0 END) AS total_debit,
SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END) AS total_credit
FROM entries;
-- 若两个值不同 -> 说明账本坏了的警报,需要立即排查。
另外,如果使用了余额缓存,就要定期把缓存的余额与从头累加记录得到的值做对照。一旦出现偏差,就重新计算缓存并追查原因。
外部对账。 即便内部账本自身是一致的,它是否与外部世界(银行账户、支付机构的清算明细)相符,是另一个问题。因此需要把账本的记录与银行流水或支付机构的清算文件做比对。前面支付系统那篇文章里提到的对账,正是这个层次。如果内部账本是「我们记录下的真相」,外部对账确认的就是「这份真相是否与实际的钱相符」。
设计良好的账本会每天自动跑这两种对账,并把不一致作为指标来观测。对账干净地对上,是系统没有丢钱的最有力证据。而对账之所以可行,唯一的原因就是账本一开始就把每笔交易都不可变地保存了下来。
并发之下的一致性
即便多笔交易同时涌入,账本也必须保持一致性。这里说两个有代表性的并发问题。
第一,丢失更新(lost update)。 在天真的余额字段设计里,如果两笔交易同时读取余额、各自计算后再写入,后写入的会覆盖先写入的,导致一次更新凭空消失。复式记账的 append-only 方式从根本上对这个问题有抵抗力。它不是读出既有值再改(read-modify-write),而只是追加一行新记录,因此即便两笔交易同时到达,各自也只是插入自己的记录,不会互相覆盖。
第二,余额约束的原子性验证。「余额不能变成负数」这类约束在并发下很棘手。如果两笔提现同时各自判断「现在余额够用」并都继续执行,两者相加就可能超过余额。要防止这一点,必须对该账户的访问做串行化。常见的做法是给该账户所在的行加锁(例如 SELECT ... FOR UPDATE),让同一时刻只有一笔提现能验证并反映余额。
BEGIN;
-- 提现之前,锁定该账户以串行化并发提现
SELECT id FROM accounts WHERE id = 1 FOR UPDATE;
-- (在此处通过累加记录计算账户 1 的当前余额,确认是否足够)
-- 若足够,则 INSERT 平衡的记录
INSERT INTO entries (transaction_id, account_id, direction, amount)
VALUES (99, 2, 'debit', 500),
(99, 1, 'credit', 500);
COMMIT;
事务的隔离级别(isolation level)也很重要。更强的隔离(比如 serializable)能让数据库替你挡住这类异常现象,但竞争一旦频繁,就会带来性能成本以及(串行化失败时的)重试。这里再一次呼应前一篇文章强调过的原则:这类重试要安全,交易就必须是幂等的,因此要给每笔交易赋予唯一的 transaction_id 来防止重复插入。账本、幂等性、一致性就这样彼此咬合在一起。想直接观察这些锁与隔离级别的行为,可以在 SQL 游乐场和 Postgres 游乐场里实验。
TigerBeetle —— 为账本而生的专用数据库
到目前为止看到的账本,完全可以在通用关系型数据库(比如 Postgres)之上实现。实际上很多金融科技公司就是这样起步的。但当交易量变得极端庞大时,通用数据库就很难同时满足性能与一致性。就在这个节点上,出现了一种新的品类 ——账本专用数据库。其中的代表就是 TigerBeetle。
TigerBeetle 提出的问题意识是这样的:金融账本的工作负载非常特殊。绝大部分是「账户间转账」这种狭窄而重复的操作,每笔转账都会同时触及多个账户,而且最重要的是绝不能出错。通用数据库被设计成能妥善处理各种各样的查询,但正因为这份通用性,在这种特殊负载下会带来很大的开销。
从工程师的视角总结 TigerBeetle 的设计选择,大致如下。
- 领域特化。 不用通用 SQL,而是聚焦于「账户(account)」与「转账(transfer)」这两个账本原语。schema 是固定的,因此优化可以做到极致。
- 借方=贷方由数据库强制。 平衡规则由数据库核心保证,而不是由应用层保证。没配平的转账根本不会被提交。
- 为高吞吐而批处理。 转账不是逐笔处理,而是成批(batch)处理,目标是每秒数十万笔以上。
- 内置复制与共识。 为了金融数据的持久性,在核心中内置了基于共识协议的复制,即便节点挂掉,数据也不会丢失或出现不一致。
- 确定性设计与严苛测试。 被设计成确定性运行,通过仿真注入各种故障(磁盘错误、网络分区)来验证一致性。
这并不是说你必须用 TigerBeetle。核心的教训是这一点:账本是一种如此特殊、如此重要的工作负载,以至于值得专门为它打造一件工具,并被认真对待到这种程度。 大多数业务用 Postgres 上一套设计良好的复式记账账本就足以起步,等到规模变大之后,再去考虑这类专用工具。不管是哪一种,底层的原理都还是 600 年前那套复式记账,原封不动。
结语
一套永不丢钱的系统,秘诀不在花哨的技术,而在于把一项 600 年前的会计原理忠实地搬进软件里。不要存余额,存交易。把每笔交易记作借方与贷方两次,并强制它们的合计永远相等。记录绝不修改或删除,只能追加。余额从这些记录中计算出来,即便用了余额缓存,也不要把它错当成真相。
当这些原则一起运作时,账本就成了一套能自我验证的系统。借方与贷方的平衡揭露错误,append-only 的日志提供完整的审计追踪,对账每天确认真相,事务与锁则在并发之下依然守住一致性。规模变得极端庞大时,可以考虑像 TigerBeetle 这样的专用账本,但其底层的原理不会改变。
用一句贯穿支付系统三部曲的话来收尾。处理钱的系统,必须从结构上消灭出错的余地。 幂等性消灭重复,复式记账揭露错误,对账确认真相。这三者同时具备时,我们才能有底气地说这是一套「永不丢钱的系统」。
参考资料
- Martin Fowler: Event Sourcing — https://martinfowler.com/eaaDev/EventSourcing.html
- Modern Treasury: What is double-entry accounting? — https://www.moderntreasury.com/learn/double-entry-accounting
- TigerBeetle: The Financial Transactions Database — https://tigerbeetle.com/
- TigerBeetle Docs: Debit/Credit accounting — https://docs.tigerbeetle.com/coding/system-architecture/
- Square Engineering: Books, an immutable double-entry accounting database — https://developer.squareup.com/blog/books-an-immutable-double-entry-accounting-database-service/
- Wikipedia: Double-entry bookkeeping — https://en.wikipedia.org/wiki/Double-entry_bookkeeping
현재 단락 (1/132)
搭建支付系统时,你迟早会撞上这个问题:「用户的余额该怎么存?」最天真的答案是「在账户表里放一个余额字段,钱进来就加,钱出去就减」。但这个天真的设计,会在真实业务中悄无声息、却确凿无疑地把钱弄丢。