绝大多数记账应用把余额存在钱包表的 balance 字段里。写入一条支出时,代码里要做两件事:插入这笔交易,然后更新余额。这两件事必须同时成功,否则账就不对了。
这在单机单线程时没问题。但一旦涉及同步、导入、批量对账、并发写入,维护余额字段就变成了一场持续的防御战——每次改动都要考虑「余额更新了吗、更新对了吗、有没有漏掉某个路径」。
换个思路:让余额成为派生值
复式记账的做法是:一条账目不是一个金额,而是一组 Leg(腿)。每个 Leg 是一个「袋子 + 金额」。核心约束只有一条——同一笔交易里,每个币种的 Leg 求和必须为零。
LedgerCore — Leg 求和为零
01// 一条账目 = 一组求和为零的 Leg02// 午饭 38 元,用招行卡支付:03transaction(term: .expense) {04 leg(from: .asset(.cmbCard), amount: -38.00)05 leg(from: .expense(.dining), amount: +38.00)06} // sum == 0 ✓0708// 余额不存库,永远派生:09// balance = 期初 + Σ 该袋子下所有 Leg余额因此变成了一个查询结果,而不是一个需要维护的状态。写入交易时只需要保证 Leg 求和为零——这是可以在内核层用一个校验门强制的事情。
袋子模型:十一类,够用且不膨胀
资产、负债、证券、加密持仓、待对账、分账中间袋……十一类袋型覆盖了个人记账里会遇到的场景。袋型是固定的枚举,不允许用户自定义——这是刻意的收敛,避免数据模型随需求无限膨胀。
代价是什么
- 查询余额要聚合计算,比读一个字段慢(靠索引与缓存兜)
- 数据量大时聚合成本上升,需要按时间窗分段
- 对开发者要求更高:每次写入都必须构造合法的 Leg 组合,不能随手改个数字
我们用一个「写起来更麻烦」的模型,换掉了「余额对不上」这类问题的整类可能性。对记账工具来说,这笔交易很值。