dimesum

首页 / 博客 / 工程

工程

只增账本让分摊余额保持精确

· 阅读约 6 分钟 ·

余额应当是一个可以丢弃并重建的投影,本文讲的就是让这件事变得安全的机制:合计为零的日记账、记入冲正的修改,以及按提交顺序投递的发件箱。

靠累加得来的余额,早晚会发生偏移。Dimesum 从不累加余额。每一笔支出都会写入一条复式记账日记账,其分录合计正好为零,你在群组页面看到的数字,只是基于这些分录的一个投影,我们可以用一条命令把它删除并重建。

这个说法是可以验证的。当资金流动的唯一途径是一条只增日记账时,错误的余额不再是难解之谜,而变成一条我们能直接运行的查询。

余额是投影,而不是你去累加的数字

Dimesum 的账本拥有三张表:journalspostings,以及以群组、成员和币种为键的 balances 投影。真相由分录承载。成员向群组投入资金时分录为正,消耗价值时为负,因此余额就是一个简单的 SUM(amount_minor)。投影只为速度而存在,并不具备权威性,它写在日记账自身的事务之内,而分录的完整让它随时可以丢弃。

两层各自校验同一个零,任何一层都不够

在 Go 里,buildPostings 只有在分录集合合计为零时才会开启事务。在 Postgres 里,一个延迟约束触发器会在提交时对每条日记账重新校验 SUM(amount_minor) = 0,因为分录是一行一行插入的,逐行校验会把每条日记账的第一条分录都拒绝掉。断言拦住的是计算逻辑里的缺陷。触发器拦住的是从不调用它的写入方。

第三层是权限授予。两张表上 ledger_app 角色的 UPDATEDELETETRUNCATE 权限都被收回,因此即便有缺陷的代码去尝试,也无法改写历史。

与累加式余额表相比,只增账本让哪些情况无法发生
缺陷类型累加式余额表只增账本
记了欠款方,却没给付款方入账永远静默地错着零和校验失败,写入被拒绝
某人的分摊变了,总额却没变余额悄悄偏移不平衡的日记账无法提交
「为什么我的余额是 412 元?」无从回答每一分钱都能追溯到某条日记账
生产环境里的紧急修补无迹可循的改动唯一的途径是一条新的、可审计的日记账

修改会记入一笔冲正,绝不做 UPDATE

修改一笔支出不会覆盖旧版本。账本消费一个 expense.amended 事件,在同一个事务里记入两条日记账:一条 EXPENSE_REVERSAL,其分录是对被替换版本逐条分录的精确取反,随后是针对新版本的一条全新 EXPENSE。删除操作在冲正之后即止。

同一个事务和这两条日记账同样重要。如果冲正单独提交,一个群组会有短暂的一刻,对一笔它其实仍欠着的支出显示为不欠。

一次支出修改会在同一个事务里记入一条冲正版本一的 EXPENSE_REVERSAL,以及针对版本二的一条新 EXPENSE;余额投影是对所有分录的求和。 一次事务 EXPENSE expense:7c1:v1 4 条分录 sum = 0 EXPENSE_REVERSAL expense:7c1:v1:reversal 每条分录取反 sum = 0 EXPENSE expense:7c1:v2 5 条分录 sum = 0 余额投影 = 每位成员、每种币种的 SUM(postings) 可丢弃:evenly rebuild-balances 会清空它并重放每一条分录
修改是追加:冲正标明它所取反的版本,替换版本则记在它旁边。

两个幂等键都是从版本推导出来的,而不是新造的:版本用 expense:<id>:v<n>,取反则用上一个版本的键加上 :reversaljournals.idempotency_key 是 UNIQUE 的,因此被重复投递的事件会发现自己的日记账已经在那里,于是什么也不做。正是这种推导让至少一次投递变得安全:重试会算出第一次尝试所用的那个键。

按提交顺序设定的水位线,让修改始终排在其支出之后

Dimesum 发布的每个事件,都和业务写入在同一个事务里写入一张 outbox 表。如果支出提交了,通告就存在。如果它回滚了,通告也随之消失。账本永远不会听说一笔并不存在的支出。

更难的一半是投递顺序。Id 采用 UUIDv7,按时间排序,但它们编码的是 id 被生成的时刻,而不是其事务提交的时刻。按 id 顺序读取的中继,可能会越过一个取了较早 id 却较晚提交的事务,于是一次修改抢在它所修改的支出前面。

业务写入和它的 outbox 行在同一个事务里提交;随后中继只投递 inserted 事务 id 低于 pg_snapshot_xmin 水位线的 outbox 行,并按事务 id 顺序进行。 一次事务 INSERT 支出行 INSERT outbox 行 主题 id (uuidv7) inserted_xid expense.created 019a-7f3 4101 expense.amended 019a-4c1 4102 expense.created 019a-1a8 4103 写入方 进行中 事件总线 账本 消费者 pg_snapshot_xmin(pg_current_snapshot())。位于其上方的行,是由已经提交 且没有更早事务仍在运行的事务写入的。下方的行则等待下一轮。 按 id 排序会先投递 019a-1a8,于是一次修改可能先于它所修改的 支出到达。按 inserted_xid 排序则不会:较晚的事件拥有较晚的 xid。
outbox 行与业务写入一同提交,中继按事务 id 顺序投递水位线以下的行。

修复只需一列和一个谓词。每个 outbox 行带有 inserted_xid xid8 DEFAULT pg_current_xact_id(),中继只读取 WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()) 的行,并按该 xid 排序(参见 PostgreSQL 事务 id 函数)。在因果上较晚的事件总是带着较晚的 xid,因为它必须先读到较早的那一行才能存在。

修改事件的消费者并不盲目相信这个顺序。如果一次修改的前驱还没有对应的日记账,它在尚新时会被重新投递,一旦超过两分钟的宽限期,就会被搁置交由人工处理。

金额是 int64 的最小货币单位,而指数并不总是二

每一笔金额都是一个 int64 的最小货币单位计数,加上一个 ISO 4217 代码,因此 1,234.56 元就是 {Minor: 123456, Currency: "CNY"}。整数天生精确,而非靠自律:没有任何可表示的值是半分钱,因此任何运算都不会悄悄产生一个。Python 边车会读取自身的 AST,一旦它的金额模块里出现 float 这个词,就让测试失败。

底下埋着的陷阱,是假设最小单位就是百分之一。JPY 根本没有最小单位,KWD 则有三位小数,所以一个以主单位报出、却施加到最小单位上的汇率,会差出一个数量级。以 2,000 日元乘以 0.58 为例:天真的乘积是 1160,在两位小数的币种里会被读成 11.60 元,而正确答案是 1,160。

WRITE_OFF 之所以是第五种日记账类型,是因为它的分录和结算相同

一次坏账核销记入的两条分录和一次结算相同:欠款方上升,债权方下降,金额一致。把它并入 SETTLEMENT 的方案被否决了,理由恰恰是两者的形态相同。「Asha 付给你 500 元」和「你免除了 Asha 的 500 元」是不同的事实,一个把二者混为一谈的动态流会声称有人付了钱,而其实没有人付。

于是 WRITE_OFF 在 2026-08-21 加入了日记账类型的 CHECK 约束,并拥有自己的分录。为它们命名占了一半的意义,因为一次复用 SETTLE_PAY 的坏账核销,会让每一条「究竟已经付了多少」的查询都静默地出错。

Dimesum 账本中的五种日记账类型,以及各自记入的分录
日记账类型记入时机分录
EXPENSE创建,或有新版本替换旧版本PAIDSHARE
EXPENSE_REVERSAL修改或删除上一条日记账的分录,取反
SETTLEMENT确认了一笔还款SETTLE_PAYSETTLE_RECV
SETTLEMENT_REVERSAL对方提出争议两条结算分录,取反
WRITE_OFF债权方放弃一项债权WRITE_OFF_FORGIVENWRITE_OFF_GRANTED

两个子命令把这些不变量变成一个定时任务

evenly verify-ledger 会在整个 schema 上重新证明这些不变量,一旦命中任何一项就以非零码退出。它的报告有四个字段,每个都应当为空:合计不为零的日记账、合计不为零的群组、与重新计算的 SUM(postings) 不一致的投影行,以及等待人工处理的被搁置事件。所有扫描共享同一个可重复读快照,因此在校验途中落地的日记账无法伪造出不一致。

每次扫描既按 id 分组,也按币种分组,它避免的失败是假阴性。一条 500 元的分录和一条负 500 日元的分录,在查询忽略币种列时会合计为零,于是一个双重损坏的账本反而显得干净。

evenly rebuild-balances [group-id] 既是修复手段,也是演练。它清空投影,并从分录重新计算每一行,像线上写入那样,为每一行标注最后一次改动该成员的日记账。它保证的性质是逐行相等:当一次重建与线上投影不一致时,以账本为准。

请按这个顺序运行

先校验,再重建。报告会为每一行偏移列出已存储的余额和重新计算的余额,而重建会覆盖已存储的值,所以先重建就会毁掉证据。

让余额可推导,偏移就变成一条查询

只增账本只有在你能证明它时,才配得上它的第二条日记账,所以要在需要它们的功能之前,先把校验器和重建做出来。没有人运行过的重建是一种指望,而不是一条退路。这周挑出你风险最高的资金投影,写下从源头重新计算它的查询,当两者不一致时给自己报警。

常见问题

分摊记账 App 里的只增账本是什么?

只增账本把每一个资金事件记录为一组合计为零的分录日记账,而且从不更新或删除任何一条。在 Dimesum 里,一笔支出、一次修改、一次结算、一次争议和一次坏账核销,各自都会追加一条新的日记账。余额随后通过对分录求和得出,因此每一分钱都能追溯到那个让它移动的事件。

只增账本怎么处理被修改过的支出?

一次修改会在同一个事务里记入两条日记账:一条 EXPENSE_REVERSAL 对原分录逐条取反,随后是一条承载替换版本的新 EXPENSE。什么都不会被改写,而删除只记入冲正。两条日记账都取用从支出版本推导出的幂等键,因此被重复投递的事件会发现它们已经在那里,什么也不会改变。

金额为什么要用整数而不是浮点数来存?

整数的最小货币单位天生精确,而二进制浮点数无法精确表示 0.1,会在一个群组的历史中不断漂移。Dimesum 把每一笔金额存为一个 int64 的最小货币单位计数,加上一个 ISO 4217 代码。指数由币种决定:JPY 根本没有最小货币单位,所以假设它是百分之一就是 100 倍的误差。

怎么确认分摊余额没有出现偏移?

运行 evenly verify-ledger,它会在整个 schema 上重新证明这些不变量,一旦命中任何一项就以非零码退出。它会报告合计不为零的日记账、合计不为零的群组、与重新计算的 SUM(postings) 不一致的投影行,以及被搁置的事件。把它设成每晚的定时任务,并用 evenly rebuild-balances 修复出问题的投影。

坏账核销为什么要用单独的日记账类型?

一次坏账核销记入的分录和一次结算完全相同,而这恰恰是日记账类型必须有所区别的原因。只有那个词区分开「Asha 付给你 500 元」和「你免除了 Asha 的 500 元」,一个把二者混为一谈的动态流会声称有人付了钱,而其实没有。它的分录被命名为 WRITE_OFF_FORGIVENWRITE_OFF_GRANTED,好让支付相关的查询保持正确。