过去几年都在做平台型交易系统,处理订单交易背后的资金流,比如电商平台,平台方需要撮合用户,商家,物流三方的交易,就需要在用户支付时收款,在商家发货/物流履约之后进行结算。这样的场景下就面临一个问题:怎么快速判定,整个系统当前的每一笔资金处理都是正确的?
这里的正确性指:
- 从用户收款的时候,钱不会多收、错收、漏收
- 给商家/物流结算的时候,钱也不会多打、错打、漏打
最终一致性及其局限 #
一般情况下,交易平台的订单、账务清结算、支付的数据都分布在不同的业务系统,甚至还会跨团队。所以最开始,我以为正确性判断这个问题,应该是一个典型的分布式系统数据一致性问题,对电商平台业务来说,因为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时代,学习方式也发生了彻底的颠覆