上一篇讨论了跨服务状态如何可靠收敛:本地事务产生事实,事实可靠外发,消费端幂等处理,状态机继续推进,定时任务和对账负责补偿与验证。本篇把视线收回到第一步:本地事务究竟应该产生什么样的事实。
如果本地账务写错了,Outbox、消息队列和 CDC 只会把错误更可靠地传播出去。余额更新但没有流水、借贷分录不平、两个并发扣款覆盖彼此、手续费在不同服务被舍入两次,这些问题都不是“消息最终有没有送达”能够解决的。
本文面向正在设计支付、账户、清算或结算服务的后端开发者。重点是账务系统的工程模型,不替代所在法域、机构或产品适用的会计准则;科目体系、确认时点和会计政策必须由财务与合规角色确认。读完后,读者应该能回答三个问题:一笔业务如何变成可验证的账务事实,金额如何在系统内保持一致,以及发生差错时如何留下可审计、可逆的修复路径。
一、本地账务事实不是一张余额表
本节先统一对象。很多资损代码并不是不会写 SQL,而是把业务单、账户、科目、流水、分录和余额混成了同一个概念。
1. 业务单、账户和科目各自回答不同问题
- 业务单回答“用户请求了什么”。例如支付单、退款单、转账单,它记录业务意图、参与方、业务状态和外部关联号。
- 账户回答“谁拥有或承担这笔价值”。它可以是用户可见的资金账户、机构的备付金账户、渠道清算账户,也可以是系统内部的过渡账户。
- 会计科目回答“这笔价值在账务分类上属于什么”。科目通常带有资产、负债、权益、收入或费用等分类,以及机构定义的层级编码。账户和科目可以是一对一,也可以通过账户类型映射到同一组科目,不能把二者默认当成一回事。
- 账务流水回答“业务系统观察到了一次什么动作”。流水应带业务单号、请求号、来源、金额、币种和处理结果,方便按业务视角追踪。
- 会计分录回答“这次动作如何影响一组科目”。一条分录至少包含一个借方行和一个贷方行,可能包含多行借贷。
- 余额回答“在某个切点上账户的净额是多少”。余额是分录的可查询视图或性能快照,不应成为唯一事实源。
一个支付服务可能先创建业务单,再在本地事务中写入一组分录和账户余额快照。业务单的 SUCCESS 不能替代分录;余额的当前值也不能证明历史上每一次变化都符合规则。
2. 流水和分录不能互相代替
流水是业务语言,分录是账务语言。一笔退款流水可以对应“减少客户应付负债、减少渠道应收、确认手续费调整”等多行分录。反过来,一次日终计提也可能没有用户可见的订单,但仍然需要完整分录。
把所有东西只存成一张“余额变更表”会丢失至少三类信息:变更的业务原因、借贷两边的对应关系,以及之后如何冲正。把分录当成唯一事实、把流水当成业务索引,通常比让余额表承担所有职责更容易审计;但最终的物理表拆分要结合吞吐、查询和监管留存要求设计。
二、复式记账是本地不变量,不是财务术语装饰
复式记账的工程价值是给每个账务事件增加一条可验证的守恒约束:同一记账事件的借方总额必须等于贷方总额。OpenStax 对双重记账的说明也把“每笔交易至少影响两个账户,借贷合计相等”作为基本规则,具体账户分类和正常余额仍需按机构会计政策解释,不能把“借方就是增加”当成通用规则。
1. 用一个转账例子看借贷平衡
假设客户 A 向客户 B 转账 80.00 CNY,平台收取 1.00 CNY 手续费。以下只是示例科目,真实科目编号必须由财务确定:
| 分录行 | 借贷 | 科目 | 金额 | 含义 |
|---|---|---|---|---|
| 1 | 借 | 客户 A 资金负债 | 80.00 | 平台对 A 的应付减少 |
| 2 | 贷 | 客户 B 资金负债 | 80.00 | 平台对 B 的应付增加 |
| 3 | 借 | 客户 A 资金负债 | 1.00 | 平台收取手续费 |
| 4 | 贷 | 手续费收入 | 1.00 | 确认收入 |
转账和收费可以属于同一个业务动作,也可以按会计政策拆成两个分录事件。无论如何拆分,每个可过账事件都必须各自借贷平衡;不能因为“最终总额看起来对”而让中间分录暂时不平。
2. 账务数据的最小结构
下面的结构是逻辑模型,不绑定某个数据库产品。journal_entry 是分录头,journal_line 是分录行,account_balance 是可重建的余额快照;真正发布前还需要补充租户、账务日、币种、版本和留存字段。
CREATE TABLE journal_entry ( entry_id VARCHAR(64) PRIMARY KEY, business_id VARCHAR(64) NOT NULL, entry_type VARCHAR(32) NOT NULL, accounting_date DATE NOT NULL, currency CHAR(3) NOT NULL, status VARCHAR(16) NOT NULL, rule_version VARCHAR(32) NOT NULL, created_at TIMESTAMP NOT NULL, UNIQUE (business_id, entry_type));
CREATE TABLE journal_line ( line_id BIGINT PRIMARY KEY, entry_id VARCHAR(64) NOT NULL, account_id VARCHAR(64) NOT NULL, direction CHAR(1) NOT NULL, amount_minor DECIMAL(38, 0) NOT NULL, currency CHAR(3) NOT NULL, line_no INT NOT NULL, UNIQUE (entry_id, line_no), CHECK (amount_minor >= 0), CHECK (direction IN ('D', 'C')));数据库的 CHECK 可以阻止负金额和非法方向,但“同一 entry_id 的借贷合计相等”是跨行不变量,不能假设普通行级约束会自动完成。过账服务应在同一本地事务中生成完整分录、校验借贷和币种,再把状态从 DRAFT 变成 POSTED;数据库触发器、存储过程或独立的过账边界可以作为实现手段,但都需要配套测试和故障恢复。示例中的 (business_id, entry_type) 唯一键只适用于“一笔业务效果对应一个分录事件”的模型;分期入账、分拆记账或多次调整应使用更具体的 effect_id、批次号或分录类型约束。
3. 余额是视图,余额快照也要可验证
余额表的存在是为了避免每次查询都扫描全部分录,而不是为了省掉分录。常见做法是:分录成功过账后,在同一事务中更新余额快照并增加版本号,随后用离线任务按分录重算余额,比较快照与重算结果。
对单币种账户,可以把每行分录映射为一个带符号的变动量;对借方余额正常的资产类科目和贷方余额正常的负债类科目,符号规则不同。不要在通用代码里写死“借方加、贷方减”,应让科目类型或账户方向提供明确的符号策略。
三、金额表示:把币种、单位和精度绑定在一起
ISO 4217 为货币提供三位字母代码、三位数字代码,并描述某些货币的最小单位关系。它解决的是“币种如何标识”,不是“所有金额都保留两位小数”。数据库字段和金额对象必须同时保存币种,不能让 100 在不同接口中靠上下文猜测是元、分还是日元。
1. 选择金额的内部表示
常见的内部表示有两种:
- 最小货币单位整数:例如把
80.00 CNY存成8000。它适合币种小数位固定、业务只允许最小单位结算的账户余额和分录金额,比较、加减和唯一性都清晰。 - 定标十进制数:例如使用数据库
DECIMAL或 JavaBigDecimal保存汇率、利率、税率和中间计算结果。它适合小数位受规则影响、需要延后舍入的计算。
两者可以并存,但边界要明确:账户余额和已过账分录使用什么单位,计算服务是否允许更高内部精度,最终落账在哪一步舍入,都应写进接口契约和规则版本。不要在一个接口中把整数解释成最小单位,在另一个接口中解释成主单位。
Java 官方文档将 BigDecimal 定义为任意精度的十进制定点数,并要求除法在结果不精确时明确指定舍入策略或处理异常。它并不意味着业务自动正确:精度、规模、舍入模式和输入构造仍需要由业务规则控制。
// 计算中间结果时保留内部精度,落账边界才舍入。BigDecimal principal = new BigDecimal("80.00");BigDecimal rate = new BigDecimal("0.0275");BigDecimal fee = principal.multiply(rate);BigDecimal bookedFee = fee.setScale(2, RoundingMode.HALF_UP);不要使用 new BigDecimal(0.1) 这种从二进制浮点构造十进制的写法,也不要先转成 double 再转回十进制。接口输入应按字符串解析,解析时验证币种允许的小数位、最大绝对值和是否允许负数。
2. 金额对象必须带币种
Money 不应只是一个 decimal 字段。至少要包含:
currency:ISO 4217 代码或机构定义的内部代码;amount:明确是最小单位整数还是定标十进制;scale或币种小数位规则;sign的使用边界:业务金额是否允许负数,分录方向是否单独表达。
金额相加前必须验证币种一致。汇率换算必须显式写出来源币种、目标币种、报价方向、有效时间和舍入规则,不能让一个通用 add 方法悄悄把不同币种相加。
3. 精度、规模和溢出是生产约束
金额字段的精度不能只按当前交易规模估计。要考虑账户累计余额、批量清算总额、手续费中间值和汇率计算放大后的位数;同时设置最大绝对值,避免异常输入造成溢出或资源消耗。数据库、应用语言、序列化格式和报表系统的精度应一致验证,尤其要防止数据库截断而应用层没有感知。
四、舍入、手续费和汇率:规则必须显式、可重放
舍入不是显示层格式化,而是会改变账务金额的业务规则。一旦舍入后的金额被过账,之后不能只凭原始金额重新猜出当时采用的模式和精度。
1. 只在约定边界舍入
一个可审计的计算链通常区分三类金额:
- 输入金额:用户或外部渠道提交的原始值,保留原始字符串和解析结果。
- 计算金额:利率、费率、汇率等计算的高精度中间值,按规则保留足够精度。
- 入账金额:在合同或清算规则要求的边界按明确模式舍入,成为分录金额。
每做一次舍入,就可能产生一分钱的差异。把每一行订单先舍入,再求总额,与把总额算完后舍入,不一定得到相同结果。规则必须回答“按行、按订单、按批次还是按会计事件舍入”,并把差额如何分配写清楚。
2. 手续费规则要能解释每一分钱
手续费至少需要记录费率、最低收费、封顶、承担方、计费基数、税费是否包含、舍入模式和规则版本。例如订单分摊手续费时,先计算高精度总手续费,再根据分摊顺序把剩余最小单位分配给指定行;不能依赖数据库查询顺序或浮点累加顺序。
手续费的负数、退款和部分退款也要单独定义:退款是否退手续费,已收手续费能否冲回,部分退款如何按比例或按原始行分摊。代码中只写一个 amount.multiply(rate),无法表达这些边界。
3. 汇率要记录报价事实
汇率不是一个永远有效的常量。一次换汇或跨币种入账至少应记录:
- 来源币种和目标币种;
- 买入价、卖出价或中间价的定义;
- 报价方向,避免把
CNY/USD和USD/CNY互换; - 汇率来源、报价时间、有效窗口和报价编号;
- 计算精度、最终舍入和汇兑差额科目。
外部报价超时后不能用一个“最新缓存值”静默替代,除非业务规则明确允许。已经入账的金额如果因汇率错误需要修复,应通过差额分录或冲正重做,而不是直接覆盖原金额。
4. 会计日期不是服务器时间
至少区分事件发生时间、系统接收时间、结算时间和会计日期。跨时区、跨零点、夏令时、批次截止和节假日都会影响会计日期。过账请求应明确使用哪个时钟和时区,保存原始时间与解析后的会计日期;不能让运行节点的本地时区决定账务归属。
已关闭的会计期间通常不能直接改写历史分录。若业务发现跨期错误,需要由财务规则决定冲正、补差或调整分录,并保留原始期间、调整原因和审批信息。
五、并发控制:不变量必须在数据库边界成立
“先查余额,再扣余额”不是并发控制。两个请求都读到 100,各自判断 100 >= 80 后写回 20,可能产生丢失更新;如果使用加减更新却不带条件,又可能把余额扣成负数。
1. 选择一种明确的扣款策略
- 行锁:在本地事务中锁定账户行,读取余额、校验并更新。适合热点不高且需要串行化单账户变更的场景。
- 乐观锁:带
version条件更新,更新失败就重新读取并按业务规则重试。适合冲突可接受、事务不宜长时间持锁的场景。 - 条件更新:使用
UPDATE account_balance SET balance = balance - :amount WHERE account_id = :id AND balance >= :amount,以受影响行数判断余额是否足够。它避免了应用层先查后写,但仍需和分录、幂等记录放在同一事务中。 - 单写者或分片串行化:按账户分区,让同一账户的变更经过同一个顺序队列。吞吐、故障恢复和热点账户处理需要单独评估。
分布式锁可以作为流量控制或跨资源协调手段,但不能替代数据库唯一约束和本地事务。锁过期、网络分区或客户端暂停都可能让两个执行者同时认为自己持有锁。
2. 一次过账要保持一组原子不变量
过账边界至少要同时处理:业务效果幂等记录、分录头、分录行、余额快照、业务状态和版本信息。任何一部分失败都不能让其他部分单独提交。跨服务通知应在本地事务提交后通过 Outbox 或其他可靠外发机制推进,不能把远程调用塞进这段数据库事务。
下面的伪代码展示约束顺序,金额和科目规则已在进入过账服务前完成校验:
BEGIN
if exists(effect where business_id = request.id and effect_type = 'DEBIT'): return existing_result
lock account_balance(request.account_id)assert balance >= request.amount
insert effect(request.id, 'DEBIT')insert journal_entry(request.id, accounting_date, rule_version, 'DRAFT')insert balanced journal_lines(debit, credit)assert sum(debit) == sum(credit)update account_balance and versionupdate journal_entry set status = 'POSTED'insert outbox_event(request.id, 'ACCOUNTING_POSTED')
COMMIT实际实现要处理重复请求、锁等待、事务超时、重试和异常回滚。DRAFT 分录如果没有发布,就不应被余额查询和对账当成已生效事实;POSTED 后也不应通过普通更新改写金额。
六、可审计、可逆的账务:纠错不是删除历史
金融系统不可避免会发现差错。可靠的模型不是假设永远不会错,而是让每个修复动作都能说明“改了什么、为什么改、由谁批准、如何回到原始事实”。
1. 已过账记录采用追加而不是覆盖
原始分录一旦进入已过账状态,通常只允许追加冲正分录、补差分录或调整分录。冲正应关联原分录编号,金额、币种和科目由规则明确,不能把原行更新成另一笔交易来“抹平”历史。
业务表可以有当前状态字段用于查询,但状态变化应留下状态历史或事件日志。审计信息至少包含业务请求号、分录编号、操作者或系统身份、原因、审批单号、规则版本、来源渠道、链路标识和时间。
2. 流水、余额和分录要能互相重建
定期校验至少覆盖三组关系:
- 订单与业务流水:每个成功业务效果都有唯一流水,失败或未知状态不应伪装成成功。
- 流水与分录:每个需要记账的流水都能关联完整借贷分录,金额和币种一致。
- 分录与余额:按账户、币种和会计日期重算的余额,与余额快照及报表汇总一致。
如果查询层只保留余额而丢失原始分录,系统无法证明余额为什么是这个数,也无法在并发或舍入差错后可靠重建。数据留存周期、脱敏方式和访问权限要满足适用的监管与隐私要求,这属于机构合规设计,不应由技术团队自行假设。
七、常见资损案例:它们为什么不是消息问题
下面的案例都可能发生在单机本地事务已经“成功”的系统中,因此不能只靠消息重试和 CDC 解决。
| 现象 | 根因 | 应建立的约束 |
|---|---|---|
0.1 + 0.2 产生不可预期的小数差异 | 二进制浮点参与金额运算 | 最小单位整数或十进制定标数,禁止浮点落账 |
| 元、分混用,金额放大或缩小 100 倍 | 接口和数据库没有统一单位契约 | Money 携带币种与单位,边界做转换并记录 |
| 订单总手续费与行手续费相差几分钱 | 在不同层重复舍入或分摊顺序不确定 | 统一计算边界、舍入模式、余数分配和规则版本 |
| 重试后重复扣款 | 使用传输消息 ID 而非业务唯一键 | 唯一索引、幂等记录、状态机三层约束 |
| 余额对了,但流水缺失 | 余额和流水分开提交或只更新余额 | 分录、余额、流水在本地事务内原子过账 |
| 两次并发扣款都成功,余额异常 | 先查后写、没有锁或条件更新 | 行锁、乐观版本或条件更新,按受影响行数判断 |
| 跨零点交易被记入错误账务日 | 使用服务器时区或混淆接收时间和事件时间 | 显式时区、截止规则、会计日期和规则版本 |
| 外部扣款成功但本地金额没有记录 | 远程调用超时后被当成失败,缺少未知态和对账 | 保留 UNKNOWN,主动查询并通过外部对账发现 |
| 错账被直接改成“正确值” | 覆盖历史,失去原始事实和审批链 | 追加冲正或补差,原分录不可变 |
| 汇率方向写反或使用过期报价 | 没有保存报价方向、时间和来源 | 记录报价事实,校验币种方向和有效窗口 |
这些案例有一个共同点:可靠性不是把某个组件配置成“至少一次”就结束,而是先让本地账务模型能够表达和验证正确事实。
八、落地检查清单
上线前可以把下面的检查项拆进设计评审、代码评审、测试和运行值班手册。
账务模型
- 业务单、业务流水、账户、科目、分录头、分录行和余额的职责已区分。
- 每一种资金动作都有明确的借贷模板,借贷总额在过账前校验相等。
- 科目方向、正常余额、可用余额、冻结余额和账面余额的定义已写清楚。
- 分录、流水、余额快照和业务状态的本地原子边界已确认。
- 原始分录可以按账户、币种、会计日期重算余额。
金额与规则
- 每个接口和字段都注明币种、主单位或最小单位、允许小数位和最大范围。
- 没有使用二进制浮点保存或计算落账金额。
- 舍入模式、舍入时机、按行或按总额、手续费分摊和余数处理已固定。
- 汇率保存来源、方向、报价时间、有效期、精度和规则版本。
- 事件时间、接收时间、结算时间和会计日期的关系已测试跨时区和跨零点场景。
- 数据库、应用、序列化和报表链路的精度及溢出边界已验证。
并发与幂等
- 业务唯一键和数据库唯一索引可以识别同一业务效果,而不是只识别一次传输。
- 余额扣减采用行锁、乐观锁、条件更新或单写者模型之一,并测试冲突重试。
- 余额校验与更新在同一个本地事务中完成,禁止先查后写。
- 处理中的、成功的、失败的和未知的状态转移有明确白名单。
- 重复请求、乱序回调、并发退款和超时重试都不会产生第二次资金效果。
审计与运行
- 已过账分录不可普通更新或删除,冲正、补差和调整有独立权限与审批号。
- 每个账务事实都能关联业务单号、请求号、操作者、规则版本和链路标识。
- 有内部对账:流水、分录、余额和订单可以相互核对。
- 有外部对账:渠道、银行或清算方的账单能够进入差错池并驱动补单、冲正或人工复核。
- 有按账户和会计日重算余额的任务,任务结果可告警但不直接覆盖原始事实。
- 备份恢复、重放、重复消费、数据库主备切换和部分提交场景有演练记录。
结语:先证明钱在本地是对的
分布式一致性解决的是“事件别丢、别重、最终能到”;账务模型解决的是“事件里究竟应该传递什么金额和事实”。只有账户、科目、分录、余额和流水之间的不变量先成立,可靠外发、消费幂等、状态机收敛和对账补偿才是在修复正确系统,而不是在扩大错误。
这篇文章不展开四类相邻问题:金额和业务规则算错、并发覆盖、外部渠道不一致,以及人为操作、权限和安全错误。它们需要分别建立计算规则、并发模型、外部对账和安全审计的防线;不能用“复式记账”一句话替代。
参考资料
- IFRS Conceptual Framework for Financial Reporting,用于核对资产、负债、确认和计量等会计概念的范围;本文没有把它当成具体机构的科目政策。
- OpenStax:Double-Entry Accounting,用于核对复式记账中借贷平衡和账户影响的基础定义。
- ISO 4217 Currency Codes,用于核对货币代码及最小单位信息的标准来源。
- Java
BigDecimalAPI,用于核对十进制精度、除法和舍入模式的实现边界。 - Java
RoundingModeAPI,用于核对不同舍入方向的定义和负数示例。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时








