GoWind Ledger 拆解:单式记账的余额一致性——confirm、补偿,以及没进事务的那些缝隙

GoWind Ledger 拆解:单式记账的余额一致性------confirm、补偿,以及没进事务的那些缝隙

本文回答一个问题:记账 App 的"账户余额"到底是算出来的还是存出来的?选了"存出来的",删除、修改、确认这些操作要付出什么代价?

GoWind Ledger(风行记账,单式记账:支出/收入/转账/调整四类流水)的核心设计是冗余余额列 ------账户表上存 balance,流水增删改时手工维护它。这篇拆它的余额维护机制(confirm 确认流、RefundBalance 补偿、copy-then-delete 更新),也如实拆它的三条裂缝------一条没能回滚的调整流水、两段没进事务的余额操作。设计取舍与已知缺陷放在一起讲,比只讲光鲜面有用得多。


一、余额从哪来:三种方案与本题的选择

记账后端回答"余额是多少"有三条路:

  1. 实时求和:余额 = SUM(流水),永远一致,但账户列表页每打开一次就全表聚合,流水多了不可行;
  2. 冗余余额列 :余额存账户行上,写流水时同步增减------读 O(1),代价是写路径必须严格自律;
  3. 快照+增量:定期快照 + 之后增量求和,复杂度居中。

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/周期区间过滤)。缓存列写了没人读------它是"当初以为需要缓存、后来发现实时查询够用"的化石。这个化石反而是个好教材:缓存是优化,先证明实时不够用,再引入缓存与它的失效机制。


六、结语

这篇的视角与前几篇不同------它拆的是一个自知未完成的实现。但"未完成"的部分恰恰最有信息量:

  1. 流水系统的每条记录必须是可逆运算 ------SetBalance 进流水的那天,就注定了"无法正确回滚"这行 Warnf;
  2. 补偿路径唯一优于字段级 diff------copy-then-delete 用 ID 变化换来了补偿逻辑的不分支;
  3. 双列口径(原始币种 + 本位币)要配换算时点的固化------只立骨架不固化汇率,多币种统计的可信度悬在内存里;
  4. 以及最重要的一条:转账两腿进事务,是一个下午就能修完、且必须修的事。

仓库(余额机制在 backend/app/core/service/internal/data/balance_flow_repo.go 与 internal/service/balance_flow_service.go):

相关推荐
喵个咪42 分钟前
GoWind Shop 拆解:订单超时关单——一个事务里的四联动
后端·架构·go
我命由我123451 小时前
Python 开发 - Python 虚拟环境
运维·开发语言·后端·python·http·https·java-ee
梦影_1 小时前
MySQL进阶教程(一)—— 储存过程与触发器(上)
开发语言·数据库·后端·sql·mysql·中间件
喵个咪1 小时前
GoWind ERP 拆解:一个 ERP 里的三种一致性等级
后端·架构·go
Eloudy1 小时前
全文 - 第03章 - 架构概述 - InfiniBand Specification release 1.4
架构·gpu
子兮曰1 小时前
PHP漏洞危机,你的网站升级了吗?
前端·后端·php
陈随易2 小时前
搞钱点子之生态鱼缸
前端·后端·程序员
ModStart3 小时前
通知:两个致命紧急PHP漏洞需要修改
后端
神也畏惧菠萝3 小时前
Coze 工作流数据库 Day06:架构与高级循环控制的深度技术分析
数据库·架构·llm·coze·coze数据库·数据库流程结构