接上文 《百万订单踩坑记-迷信框架》 。上次是框架的坑,这两次事故是我们自己埋的:一个是财务流水,一个是脚本履约。
一张账户表,一张流水表
财务的设计很简单,一张账户表,一张流水表。
流水表核心字段
| 字段 | 说明 |
|---|---|
| userId | 用户 ID |
| platformId | 平台 ID |
| ioValue | 收支金额 |
| oldValue | 变动前余额 |
| newValue | 变动后余额 |
账户表核心字段
| 字段 | 说明 |
|---|---|
| userId | 用户 ID |
| balanceValue | 账户余额 |
| freezeValue | 冻结金额 |
| pointValue | 积分 |
余额每动一次,就写一条流水。谁的钱、怎么变的、变完剩多少,事后都能对出来。
这里有个现在看挺骚的操作:系统里会有一个财务账号,专门用来做财务核对、算账,提现等一系列操作。用户下一单跑腿,流水表里会写这个 user_id 支出了多少钱;对应的,财务账号的 user_id 上就有一笔收入。为了让财务核对得更明细,用户下单对自己只是一条支出流水,对财务账号却可能是多条收入:服务费一条、打包费一条,分开记。
为什么这样设计,因为这是前任留下来的。一开始量不大,这些流水都是同步写,没出过问题,所以也没人去动它。直到有一次过节。
节日当天
也是某个节日当天,用户反馈下跑腿单特别慢。我接到反馈赶紧查,先看服务器,CPU 都很低,不像被打满的样子;那问题大概率在数据库,一看,果然有大量的锁等待。
原因就在写流水这一步。写流水要先锁住这个账号的余额行,对余额做操作,然后再落流水。用户账号各锁各的,互不相干;可财务账号全站只有一个 user_id,所有请求都往这一行上挤。更麻烦的是,一个用户操作会产生多条财务流水,每条都要走一遍「锁行、改余额、写流水」。用户一多,全在争同一行的锁,只能排队,一个一个来。
找到原因,立马把财务流水的代码注释掉,发布上去,就正常了。这个操作挺粗暴,但节日当天保命要紧,财务的账可以事后再对,系统卡死不行。
后期优化
节日过后,把这块补了回来:财务流水不再同步写,改成先推到一个 Redis 队列,后面用脚本消费。这里刻意用的是单队列单消费。流水不要求实时,消费慢一点没关系;单消费者能保证同一时刻只有一个进程去动财务账号那一行,就不会再挤成一团。
同步:订单完成 → 扣用户余额 → 流水任务入队 → 接口返回
异步:流水任务出队 → 单消费者 → 财务账号
脚本履约
外卖订单有个履约的概念:订单在我们平台上的操作,要把相关状态回传给外卖平台。比如订单在我们这儿完成了,就得把完成状态同步过去,平台才知道这单履约了。
一开始是脚本履约,每分钟跑一次,把待回传的订单捞出来同步。当时看也合理:回传不要求秒级,一分钟一次够了。
同样是节日期间,客户找过来说店铺被关了。查下来才知道,订单量上来之后,脚本一轮跑不完,下一轮又压上来,根本跑不完。订单大量积压,很多早该回传的状态一直没传过去,平台那边看到的就是这些订单迟迟没履约,判定违约,店就关了。
临时解决
先用 Swoole 起了多个进程,每个进程跑一部分订单,速度一下就上来了。但轮询这个模式没变,量再涨,还是会积压。
后期解决
解法还是异步延迟队列。订单进入某个状态时,按业务算一个大概的完成时间,推一条延迟消息进去,到点由消费者去做回传。单个订单自己盯着自己的时间点,不用等下一轮脚本扫到它。
总结
财务系统这东西,说大能做很大,说小也能很小。我们初期就是怎么简单怎么来,两笔债都是那时候埋下的,等到节日才集中还上。
你们现在的财务系统是怎么做的,有没有踩过类似的坑,欢迎留言聊聊。