结算系统永不出错的理论点

799 阅读4分钟

都是之前业务上踩过的坑,总结了一些经验,给后续要做订单、钱包、资金、对账相关的小伙伴一些方法论。如果感觉自己没到那个 level,不要接这些棘手得活,这种事出错了后果很严重。当然如果接下来了就好好干,仔细读我下面的说的注意点,有可能会救你命。

所有订单操作的分布式锁

基本操作,订单的任何写操作,不允许同时进行。像加价、退款、结算、申诉等等,任何改动了订单状态和金额的地方,全部不允许同时操作。controller 层锁加好,全用订单号做 key。

不要直接修改数据库

比如同时来两个加价,在非常极端的场景下,执行类似

update order set amount = ? where order_no = ?

就可能只加了一次价,加价还算好的,出了问题别人找过来不会有损失,退款就麻烦了,金额少减了相当于业务亏钱。

这种情况除了 controller 层做各种校验外,到数据库那一层还要额外校验,重新封装调用。

比如退款,到了数据库减钱这一层,要再次校验订单、结算单、退款单等,在修改各个表之前,先用 select for update 锁住行,再修改完后提交事务。

再提醒一遍,对外提供的接口或者 service 封装的方法,不要直接用使用updateById。

所有可能亏钱的地方要额外注意

比如上面说的退款,如果少退了业务就会损失钱,所以要再加个数据库锁保证库里的数据不能出问题。

还有付款、加价,一般是在支付结果回到回调里面,要想想并发同时重复调用,会不会多给别人加钱?

提现也是,会不会多次给别人打款?打款失败退回,会不会给别人多退钱?

如果用了资金流水表,任何入账加余额的地方,也要多想想,并发同时重复调用怎么办,数据库可以报错,业务可以宕机,账一定不能出错。

不要用资金流水表做业务

付款表,退款表订单的相关的,不要用流水表做业务,这样每次处理只改状态不动资金。

流水表只在用户钱包那里用,流水表的业务尽量少。

之前有个系统为了方便统计系统的账,给虚拟了一个系统用户,每次完单就给系统用户加钱,最后量大了不是数据库崩就是系统崩溃或是结算崩。

考虑到同时、重复调用的场景

一些重要的接口一定要考虑到同时调用到关键步骤的问题,假设 controller 层所有的校验都失败了,同时两个线程都跑去数据库改数据,怎么办?

所以数据库锁也是必要的,如果只允许执行一次,可以用唯一键做。

比如资金流水表,子业务类型 + 订单号的唯一键,收入是大业务类型,订单结算手续费收入是子类型,一个订单只允许一次订单手续费收入,后续别的收入场景用不同的收入子类型。

如果是改某一条数据的场景,最好在方法调用前后加一道分布式锁,再用 select for update 做,比如给加价后改动结算表加价金额,或者上面说的加流水后给用户资金表加余额。

核心思想是在代码顺序执行的过程中,在数据库那里,不能有重复和并发。

如果数据量超过数据库性能上限,考虑用 Kafka 做异步结算,设计好 Kafka 消息的分区键(Partition Key),保证资金相关的操作不会并发。那是另一个大的设计,后面有时间想想再写篇文章。

总是对账

任何让订单终态的场景,结算、售后、申诉完成、全额退款等,都写个对账逻辑保险点,万一出了问题,至少能早点发现。提现最好也加个对账。

对账对什么呢?

首先是入账,对一下所有的支付和订单的金额是不是对得上,还要分角色对,比如对用户来说,入账是来自资金流水的,那就对一下这笔订单给用户的钱,和资金里面用户到账的钱是不是一致的。

出账也是一样的,总体来说就是对各 个表之间的数据是不是一致的。

提现也对一下,扣用户的钱和三方转账的钱能不能对的上。

三方支付、退款回调的时候查一下状态和金额,对账的时候再查一遍,防止三方回调的信息不对。

对账是最后一道兜底,万一前面的搞错了,至少在对账的时候能发现问题。