跳过正文

用复式记账重建交易系统的正确性

·4162 字·9 分钟
月半杰瑞
作者
月半杰瑞
软件工程师·独立开发者
目录

过去几年都在做平台型交易系统,处理订单交易背后的资金流,比如电商平台,平台方需要撮合用户,商家,物流三方的交易,就需要在用户支付时收款,在商家发货/物流履约之后进行结算。这样的场景下就面临一个问题:怎么快速判定,整个系统当前的每一笔资金处理都是正确的?

这里的正确性指:

  • 从用户收款的时候,钱不会多收、错收、漏收
  • 给商家/物流结算的时候,钱也不会多打、错打、漏打

最终一致性及其局限
#

一般情况下,交易平台的订单、账务清结算、支付的数据都分布在不同的业务系统,甚至还会跨团队。所以最开始,我以为正确性判断这个问题,应该是一个典型的分布式系统数据一致性问题,对电商平台业务来说,因为CAP定理1的存在以及业务高可用性的要求,BASE2已经成为了一种基本的设计哲学,也有TCC、Saga及相关变种的成熟最终一致性方案,我们用的最多的,就是通过引入中间状态,通过幂等+重试来保证不重不漏。

比如支付流程,下单时会创建一个支付单,置为「待支付」,然后等第三方支付机构/系统确认收款成功之后回调,然后更新支付单状态为「已支付」,再继续后续流程。这样通过状态机来保证数据达成最终一致的方式在系统里随处可见

这样的场景处理多了,就会产生一种思维惯性,认为只要计算正确,并维护数据达成最终一致,那么这个交易的处理就是正确的。而且本身交易系统离钱近,比较敏感,上线前的开发测试都会比较谨慎,工程纪律良好的情况下一般不会出现问题。但随着时间的推移,系统变得越来越复杂,人员犯错概率上升,也越来越容易故障。

我们系统就出现过这样一次资损事故,用于保证幂等的规则因为业务需求变更而修改,正常链路已经做好了兼容,但是重试的链路被忽视,导致正常流程结算了之后,重试的流程又重复结算了一遍。最终一致性只能保证系统在异步、失败和重试之后,最终收敛到某个状态,但如果重试/补偿机制本身出了问题,那么系统也可能稳定地收敛到一个错误结果。

而且故障来源各种各样,没有工程师能保证系统100%不出问题,软件系统的故障是一种必然,工程师能做的,就是尽早发现,尽早修复。要发现问题,就需要增加对账能力。但最终一致性的设计偏向也让局部对账没那么实时。考虑到重试一般是小时或者天的级别,所以对账一般也是离线T+1去拉数据来进行比对。

有没有更加实时的方法?最终一致性的设计,本质上是允许数据在不同时间节点上达成一致,显然无法解决时效的问题。

复式记账与业务数据建模
#

时间维度无法回答这个问题,空间维度上有没有解法呢?复式记账法3或许是其中一个手段。复式记账强调的是:有借必有贷,借贷必相等。任意时刻通过校验会计恒等式来判断,一旦出现不相等说明系统在钱的处理上必定有问题。

之前我一直以为电商交易系统和银行系统不一样,只有银行系统才需要遵循这个规则,但电商平台本质上也是撮合交易,交易的背后是钱和商品/服务的交换,这套处理钱的记账法发展了500年足见普适性和可靠,所以电商平台应该可以,或者说也应该要遵循这个规则。

比如

流程 业务数据 复式记账
用户下单 商品 100,运费 10,优惠券 20,用户实付 90 用户虚拟账户资金 -90
平台营销账户 -20
平台待清分账户 +110
商家履约 订单状态流转 商家应付账户 +80;平台佣金应收 +20;平台待清分资金 -100
物流履约 订单状态流转 物流应付账户 +10;平台待清分资金 -10
平台结算 商家应得80、物流应得10、平台佣金20 商家应付账户 -80,商家可提现账户 +80;
物流应付账户 -10,物流可提现账户 +10
商家/物流提现 商家提现80, 物流提现10 商家可提现账户 -80,平台银行存款 -80;
物流可提现账户 -10,平台银行存款 -10

其实就是把交易环节的业务数据建模成一套账本,每一次账户余额的变更,都对应一次A账户余额到B账户的转移,总体上金额是恒等的。这样

用户虚拟账户 + 平台营销账户 = 平台清分账户 = 商家应付账户 + 物流应付账户 + 平台佣金应收 = 商家可提现账户 + 物流可提现账户 + 平台佣金账户

把中间环节消除最后就得到:用户虚拟账户 + 平台营销账户 = 商家可提现账户 + 物流可提现账户 + 平台佣金账户

即 用户虚拟账户 - 商家可提现账户 - 物流可提现账户 = 平台佣金账户 - 平台营销账户

这样假如某次逻辑出现问题,比如又出现了一次重复结算,那么商家应付账户余额会增加,而用户账户没有对应的余额,公式不相等,马上发现问题并介入结算过程进行拦截。 这样就不需要等到T+1才能发现,否则可能钱都已经提走了

理想与现实的差距
#

账本模型非常合理,然而事实上,我们并没有这么做:

核心原因在于业务从0-1的时候,初始产研团队缺乏这种财务的专业视角,大家关注的重点还是业务的核心流程本身,关心的还是业务能不能跑起来。所以最开始的版本,几乎没有专门抽象出“账户”,只把流程中需要的字段给记录到业务单据上。

比如

流程 业务数据 业务单据
用户下单 商品 100,运费 10,优惠券 20,用户实付 90 创建订单/支付单,记录
用户实付 90(商品 100,运费 10,平台优惠券 20)
商家履约 订单状态流转 -
物流履约 订单状态流转 -
平台结算 商家应得80、物流应得10、平台佣金20 创建商家/物流结算单
记录结算给商家80
记录结算给物流10
商家/物流提现 商家提现80, 物流提现10 创建商家/物流提现单
记录商家提现的80
记录物流提现的10

这也应该是绝大多数业务起步时正常的模样——面向业务流程开发,不会面向资金建模。这样的模式,在快速迭代的时候也非常方便,需求来了就把相关金额字段都堆到业务单据上,比如又加了什么优惠,就订单上加一个优惠字段。当需要从财务视角算钱的时候,再通过SQL去把相应的业务单据拉出来算出原本隐含着的数据,流程能跑起来,但在资金视角,确实也不是一个好的数据模型

更大的问题在于,当业务和产研都基于这个模式形成了自己的认知和SOP,习惯了现状之后,就没有人有想法和动力去扭转这种模式,特别是这种短期成本大于收益,长期收效不明显的改动。这里也包括我,也是偶然突发奇想,和ChatGPT询问下业界的实现,才把自己的认知扭转过来

在认知没有到位的时期,就做出了很多能跑起来,但总觉得差点意思儿的方案,比如

  • 票据系统,需要把相关金额信息都收集起来开票,因为缺乏一个统一的账本,在排期压力下就想着去各个业务单据上去取数,捞数据做聚合的逻辑就变得很重
  • 对账系统,因为只有业务单据,所以对账的目标,也是根据单据的状态和信息是否匹配,来判断是否能达成最终一致性

如果有账本模型
#

如果有了账本模型,比如还是这个流程

流程 业务数据 业务单据 复式记账
用户下单 商品 100,运费 10,优惠券 20,用户实付 90 创建订单/支付单,记录
用户实付 90(商品 100,运费 10,平台优惠券 20)
用户账户资金 -90
平台营销账户 -20
平台待清分账户 +110
商家履约 订单状态流转 - 商家应付账户 +80;平台佣金应收 +20;平台待清分资金 -100
物流履约 订单状态流转 - 物流应付账户 +10;平台待清分资金 -10
平台结算 商家应得80、物流应得10、平台佣金20 创建商家/物流结算单
记录结算给商家80
记录结算给物流10
商家应付账户 -80,商家可提现账户 +80;
物流应付账户 -10,物流可提现账户 +10
商家/物流提现 商家提现80, 物流提现10 创建商家/物流提现单
记录商家提现的80
记录物流提现的10
商家可提现账户 -80,平台银行存款 -80;
物流可提现账户 -10,平台银行存款 -10

有账户表

账户类型枚举 账户类型 账户ID
user 用户账户 user_1
merchant_payable 商家应付账户 merchant_payable_2
merchant_withdraw 商家可提现账户 merchant_withdraw_2
shipping_payable 物流应付账户 shipping_payable_3
shipping_withdraw 物流可提现账户 shipping_withdraw_3
plarform_clear 平台待清分账户 plarform_clear
platform_commission 平台佣金账户 platform_commission
platform_promotion 平台营销账户 platform_promotion
platform_bank 平台银行存款账户 platform_bank

假如有这么一个订单order_001,对应就会有这些账本记录,资金流向:贷方->借方

业务流程 记录ID 交易单据类型 交易单据ID 账户类型 账户ID 借方金额 贷方金额 交易时间 描述
用户下单 1 订单 order_001 用户账户 user_account_a 90 2026-04-25 10:01:00 用户实付
用户下单 2 订单 order_001 平台营销账户 platform_promotion 20 2026-04-25 10:01:00 平台优惠券
用户下单 3 订单 order_001 平台待清分账户 plarform_clear 110 2026-04-25 10:01:00 平台待清分
商家履约 4 订单 order_001 商家应付账户 merchant_payable_2 80 2026-04-25 10:01:00 商家应得
商家履约 5 订单 order_001 平台佣金账户 platform_commission 20 2026-04-25 10:01:00 佣金收入
商家履约 6 订单 order_001 平台待清分账户 plarform_clear 100 2026-04-25 10:01:00 平台待清分
物流履约 7 订单 order_001 物流应付账户 shipping_payable_3 10 2026-04-25 10:01:00 物流应得
物流履约 8 订单 order_001 平台待清分账户 plarform_clear 10 2026-04-25 10:01:00 平台待清分
平台结算 9 订单 order_001 商家应付账户 merchant_payable_2 80 2026-04-25 10:01:00
平台结算 10 订单 order_001 商家可提现账户 merchant_withdraw_2 80 2026-04-25 10:01:00
平台结算 11 订单 order_001 物流应付账户 shipping_payable_3 10 2026-04-25 10:01:00
平台结算 12 订单 order_001 物流可提现账户 shipping_withdraw_3 10 2026-04-25 10:01:00

提现flow也是类似这样流程,不再赘述

因为资金数据粒度已经具体到了费用项,所以

  • 票据系统,直接根据账户id 就可以把特定账户指定时间段内,需要的费用项的信息拉出来,给特定的用户开票
  • 对账系统,关注焦点也从业务单据的状态费用两两是否一致,转移到了账本数据SUM(借方) == SUM(贷方)是否恒等, 更符合对账这一概念本身

回到最初的问题:怎么快速判定,整个系统当前的每一笔资金处理都是正确的?目前的答案是账本模型,本质上只是把业务单据里横行扩展的资金数据,改成了纵向扩展的账本数据,极大地提高系统灵活性的同时,也满足了资金系统的要求,经过推演,愈发认为这才是正确的处理方式。


小结:

以上则是过去这段时间的一些思考与整理,ChatGPT在这个过程中提供了不少助力。以前是Google一个问题后,需要自行理解多篇文章,然后纠结这些内容怎么和我的想法关联起来,时不时就被差异给卡住,卡顿感极其明显,以至于老早就知道复式记账,但始终没能理解如何正确融合进系统里

ChatGPT的一大改变是,它能顺着我的想法来推演,在不断追问的过程中,把一些卡顿的思路捋顺。在AI时代,学习方式也发生了彻底的颠覆


  1. CAP定理:任何一个分布式系统不能同时保证一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance) ↩︎

  2. 基本可用(Basically Available)、软状态(Soft State)、最终一致性(Eventual Consistency) ↩︎

  3. 1494年,意大利数学家、教士卢卡·帕西奥利(Luca Pacioli)在威尼斯出版《算术、几何、比例和比率概要》,在其中全面系统地阐述了这个记账系统 ↩︎