GoWind Ledger 拆解:单式记账的余额一致性------confirm、补偿,以及没进事务的那些缝隙
本文回答一个问题:记账 App 的"账户余额"到底是算出来的还是存出来的?选了"存出来的",删除、修改、确认这些操作要付出什么代价?
GoWind Ledger(风行记账,单式记账:支出/收入/转账/调整四类流水)的核心设计是冗余余额列 ------账户表上存 balance,流水增删改时手工维护它。这篇拆它的余额维护机制(confirm 确认流、RefundBalance 补偿、copy-then-delete 更新),也如实拆它的三条裂缝------一条没能回滚的调整流水、两段没进事务的余额操作。设计取舍与已知缺陷放在一起讲,比只讲光鲜面有用得多。
一、余额从哪来:三种方案与本题的选择
记账后端回答"余额是多少"有三条路:
- 实时求和:余额 = SUM(流水),永远一致,但账户列表页每打开一次就全表聚合,流水多了不可行;
- 冗余余额列 :余额存账户行上,写流水时同步增减------读 O(1),代价是写路径必须严格自律;
- 快照+增量:定期快照 + 之后增量求和,复杂度居中。
GoWind Ledger 选了方案 2。这个选择把全部一致性压力压到了写路径------下面所有机制都是为了回答"增删改流水时,余额列怎么不错"。
二、confirm 机制:未确认的流水不动余额
流水有一个 confirm 生命周期(默认 false):创建时可以记为"待确认",确认操作(ConfirmBalance)才真正动账户余额。四类流水的确认动作各不相同:
go
case FLOW_TYPE_EXPENSE: Account.UpdateOneID(...).AddBalance(-amount)
case FLOW_TYPE_INCOME: Account.UpdateOneID(...).AddBalance(amount)
case FLOW_TYPE_TRANSFER:
// 源账户减、目标账户加(按换算后的金额)
case FLOW_TYPE_ADJUST:
Account.UpdateOneID(...).SetBalance(amount) // 唯一用 Set 的类型
confirm 的产品语义是"这笔账还没实发生"(信用卡未出账、报销未到账),技术语义是余额的生效闸门 ------统计与预算口径都带 confirm=true 过滤,未确认流水活在另一个账本里。
四类动作里 ADJUST(调整余额)是异类:它不增减而是 SetBalance 直接设定。这个差异在后面会变成一条真实的裂缝。
三、补偿与它的裂缝
删除或修改已确认流水时,余额走 RefundBalance 反向补偿------支出删了加回来、转账删了双向冲回。补偿机制的裂缝有三条,按严重程度排:
裂缝一:ADJUST 无法回滚。 RefundBalance 遇到 ADJUST 流水时只有一行代码:
go
r.log.Warnf("cannot properly refund ADJUST flow id=%d", flow.ID)
原因是结构性的:ADJUST 的语义是"把余额设为 X",这是一个破坏性操作 ------它抹掉了余额与历史流水的可推导关系。设完之后,"删掉这条调整流水,余额应该变回多少"这个问题没有答案 (除非把 ADJUST 之前的全部流水重算一遍)。这不是实现偷懒,是"Set 语义进入流水系统"的必然代价:流水系统的每一条记录都必须是可逆运算,破坏性设定不属于这里。合格的修法是 ADJUST 落库时记录"调整前的余额"字段,或者干脆把调整也建模为一笔差额流水------两条路代码都没走,裂缝就这么留着,注释诚实。
裂缝二:转账的两腿不在一个事务里。 TRANSFER 的确认要对源账户 AddBalance(-amount)、目标账户 AddBalance(+convertedAmount),这是两条独立的 Exec,中间没有 Tx 包裹 ;confirm 流程(改 confirm 标志 → ConfirmBalance)同样是两步。第一腿成功第二腿失败,就会出现"钱从源账户扣了、目标账户没到账"的单腿账。核心服务里能找到 ent 的事务 API(基座其他项目大量使用),但记账主链路这几处没用上------修复本身只需要把三步包进一个 Tx 闭包,属于"知道就能修"的问题,知道才是关键。
裂缝三(轻微):手工调整的补偿是完整的。 作为对照,AdjustBalance(用户在账户页手工改余额)反而做对了:改余额与生成一条 ADJUST 审计流水在同一个事务里,建流水失败则回滚余额------"改余额必留痕"靠事务保证,不留痕就不改。同一个代码库里补偿事务的正确写法与缺失并存,说明缺的不是能力是覆盖。
四、copy-then-delete:为什么改流水不是 UPDATE
修改一条流水的实现不是字段级 UPDATE,而是"复制后删除":锁死旧流水的账本/类型/confirm 语义 → 按新内容建一条全新流水 → 迁移附件 → 旧流水先 RefundBalance 补偿再删除。
理由藏在复合一致性里:一条流水带着分类关联、标签关联(各带拆分金额)、余额补偿 三组派生数据,原地修改意味着"逐字段 diff 出什么变了、对应地增量修正派生数据"------分支多到必然漏。复制删除把问题变成"旧的全额冲回 + 新的全额生效",补偿逻辑只有一条路径,不依赖 diff 的完备性。代价是流水 ID 会变、创建时间要特殊处理------在"ID 稳定"与"补偿路径唯一"之间,记账场景选了后者是合理的。
这背后是一个可迁移的判断:当一个实体的派生数据多到原地更新需要写 diff 引擎时,"删旧建新"就是更简单正确的更新语义。
五、统计口径:converted_amount 与"README 与实现的差距"
多币种的统计口径设计是对的:流水同时存 amount(原始币种)与 converted_amount(换算到账本本位币),所有统计、预算、报表只聚合 converted_amount------把"不同币种的金额不能相加"这个约束固化在列的语义上,聚合 SQL 永远不会犯跨币种相加的错误。
但这条线上有一条要诚实记录的差距:README 宣称"跨币种转账自动计算换算金额",实际服务端创建链路不做换算 (Flutter 表单页也是直接 convertedAmount = amount),Convert RPC 实际只服务汇率计算器页面;汇率表是进程内存的(重启即失、无历史快照),流水确认时的换算率与当前率不区分。"原始币种 + 本位币列"的双轨骨架立住了,换算链路的血肉还没长全------从 v0 到合格的多币种记账,差的是换算时点的固化 (确认时快照汇率)与汇率表的持久化。
预算模块有一组现成的对照案例:schema 里预算有 used_amount 缓存列(注释"定期从流水汇总更新"),但全库没有任何定期回写它的任务 ,实际进度走的是实时查询(confirm/include/周期区间过滤)。缓存列写了没人读------它是"当初以为需要缓存、后来发现实时查询够用"的化石。这个化石反而是个好教材:缓存是优化,先证明实时不够用,再引入缓存与它的失效机制。
六、结语
这篇的视角与前几篇不同------它拆的是一个自知未完成的实现。但"未完成"的部分恰恰最有信息量:
- 流水系统的每条记录必须是可逆运算 ------
SetBalance进流水的那天,就注定了"无法正确回滚"这行 Warnf; - 补偿路径唯一优于字段级 diff------copy-then-delete 用 ID 变化换来了补偿逻辑的不分支;
- 双列口径(原始币种 + 本位币)要配换算时点的固化------只立骨架不固化汇率,多币种统计的可信度悬在内存里;
- 以及最重要的一条:转账两腿进事务,是一个下午就能修完、且必须修的事。
仓库(余额机制在 backend/app/core/service/internal/data/balance_flow_repo.go 与 internal/service/balance_flow_service.go):
- GitHub:github.com/tx7do/go-wi...
- Gitee:gitee.com/tx7do/go-wi...