账务系统核心设计:账户、账号、幂等并发和记账指令
上一篇主要讲虚户为什么存在,以及账务系统怎么用虚户、流水、冲正和调账回答“钱在哪里”。
这一篇继续往工程实现上走,但不展开完整清结算流程、对账体系和财务总账,只聚焦账务系统内部最核心的几件事:
- • 账户体系怎么设计;
- • 账号怎么设计;
- • 幂等和并发怎么保证;
- • 上游支付引擎怎么发起记账指令;
- • 批量结算场景下怎么做批量记账。
账务系统核心设计
账务系统看起来是在做余额增减,实际要解决四个问题。
第一,钱应该记在哪个账户上。
第二,这个账户怎么被稳定识别。
第三,同一笔业务重复请求时,不能重复入账。
第四,多笔交易同时操作账户时,不能把余额写乱。
所以账务系统的设计不能只围绕一张余额表展开,而要围绕“账户、账号、记账指令、流水、余额更新”这一整条链路展开。
账户体系设计
账户体系设计的第一步,是先把业务对象拆清楚。
在支付和信贷场景里,常见账户对象至少包括:
- • 用户账户;
- • 公司账户;
不同对象下面还会继续拆账户类型。
比如用户侧可能有可用账户、冻结账户。资方侧可能有放款资金账户、应收本金账户、利息收入账户。平台侧可能有服务费收入账户、营销补贴账户、垫资账户。渠道侧可能有待清算账户、手续费账户、差异挂账户。
账户拆分的目标不是越细越好,而是每一类资金状态都能被解释。
如果一笔钱还没从渠道清算回来,就不应该直接放到已入账账户;如果一笔钱只是平台给用户的补贴,就不应该和真实充值混在一起;如果一笔钱暂时找不到归属,就应该进入差异账户,而不是先随便记到平台收入。
所以账户体系设计可以用一句话概括:
账户不是余额字段,而是资金位置和资金状态的表达。
落地时,账户一般会同时具备几类属性:
账户归属:用户、商户、资方、平台、渠道
账户类型:可用、冻结、待清算、待入账、收入、成本、差异
账户性质:资产、负债、收入、成本/费用、权益
币种:IDR、PHP、USD、CNY 等
状态:正常、冻结、注销
业务系统不应该随意创建账户。更稳妥的方式是通过开户模板创建账户。比如某类用户注册时开哪些账户,某类资方接入时开哪些账户,某个渠道上线时开哪些清算户,都由模板控制。
这样后面记账时,支付引擎或账务系统只需要根据业务场景找到对应账户,而不是每次都临时判断。
账号设计
账户体系回答“有哪些账户”,账号设计回答“怎么稳定定位一个账户”。
账号最好是系统内部稳定、不复用、不随业务展示变化的标识。用户手机号、商户号、资方编码都可以作为查找条件,但不适合直接当账务账号。
一个常见账号可以由几类信息组合生成:
账户归属类型 + 账户类型 + 币种 + 序列号
真实系统里不一定要用这么长的明文账号,也可以用数字账号或雪花 ID。但无论怎么生成,都要能回到一张账户表里查询完整信息。
账号前缀可以帮助路由和排查,但不能成为唯一的账务规则来源。
比如看到账号前缀是 INCOME,可以辅助判断这是收入类账户。但真正决定余额增减方向的,仍然应该是账户表里的账户性质和记账规则配置,而不是靠解析字符串。
账户表里至少要有这些关键字段:
acct_no 账户号
owner_type 账户归属类型
owner_id 账户归属 ID
acct_type 账户类型
acct_nature 账户性质
currency 币种
status 账户状态
balance 当前余额
created_time 创建时间
updated_time 更新时间
如果有冻结金额,也可以单独建冻结账户,或者在同一账户上维护可用余额和冻结余额。两种方式都能做,关键是规则要统一,不要有的业务用冻结账户,有的业务直接改字段,最后导致流水解释不一致。
如何保证幂等和并发
首先如果是业务操作的时候我们会先罗一张受理记录表,根据交易流水号作为幂等键,数据库也有唯一索引进行兜底。单笔入账流程其实也是这样先插入受理记录表然后在生成记账指令和指令流水。指令其实也有process状态,对于更新时候会采用select for update避免不同流水并发改同一行造成覆盖。其实这种幂等都有一锁二判三在去做真正的业务操作。总的来说账务系统会接到上游的流水以后先去落一笔受理记录,记录一旦创建重试只查原记录结果不在执行余额变更。并发场景由于redis记录和数据库锁进行兜底。账户则是用数据库行级锁。
(补图)
常见设计有三个要点:
- • 记账指令、分录流水、余额更新放在同一个本地事务里;
- • 多账户更新按统一规则排序后加锁,避免死锁;
- • 余额更新带版本号或行锁,避免并发覆盖。
如果遇到平台收入户、渠道清算户这类热点账户,还要考虑拆分账户、分桶汇总、延迟汇总。但这是更后面的性能优化问题,不能一开始就用复杂方案掩盖基础账务模型不清楚的问题。
记账指令设计
在很多支付系统里,账务系统的上游一般是支付引擎。
支付引擎负责串联支付、还款、退款、分账、渠道回调、补偿任务等业务流程。它判断的是“业务事实是否成立”:用户还款成功、退款已受理、渠道清算到账、手续费应收等。
当一个业务事实确定后,支付引擎会把这个事实封装成记账指令,交给账务系统处理。
这里边界要划清楚:支付引擎不应该直接改账户余额,也不应该直接写账户流水。否则每个业务流程都会拥有一套自己的记账逻辑,后面做冲正、对账、排查时,很难解释同一笔钱到底按什么规则变动过。
更稳妥的方式是:
支付引擎表达业务事实
账务系统表达资金变化
所以记账指令不是简单的“给某个账户加多少钱”,而是业务事件到账务分录之间的标准接口。
这个模型可以拆成三层:
业务受理记录:记录谁请求了什么业务事实
记账指令:记录这次业务事实要触发什么账务动作
账户分录流水:记录最终哪些账户发生了借贷变化
第一层是业务受理记录。它关心来源系统、业务单号、交易流水号、业务类型、受理状态和幂等键。它的作用是先把上游请求收住,避免同一笔业务因为重试被重复处理。
第二层是记账指令。它关心指令号、业务场景、币种、金额、账户角色、记账原因、原指令号等。这里不一定要一开始就把账户号写死,很多场景更适合传账户角色,比如“用户可用账户”“平台服务费收入户”“渠道待清算户”,再由账务系统根据账户模型和规则解析成具体账户。
第三层是账户分录流水。它是最终入账结果,每条分录都要落到具体账户号、借贷方向、发生金额、入账前余额和入账后余额。指令可以被重试、查询和追溯,但余额是否真的变化,要以分录流水和账户余额更新为准。
这样设计的好处是,业务事实、记账请求和最终账务结果不会混在一起。上游可以关心“退款成功了吗”,账务系统可以关心“退款这件事应该冲掉哪些账户的余额”。
一条记账指令通常可以分成指令头和分录请求。
指令头描述这次记账来自哪里:
记账指令号
来源系统
业务单号
业务流水号
业务场景
币种
总金额
请求时间
指令状态
幂等键
原记账指令号
记账原因
分录请求描述这次记账准备影响哪些账户:
账户角色或账户定位信息
借贷方向
金额
账户类型
账户性质
分账项或费用项
备注
这里有两个设计细节。
第一,幂等键必须站在业务事实上设计,而不是站在接口请求上设计。比如同一笔还款成功通知重试十次,它们应该命中同一个幂等键;但同一笔订单先支付、后退款、再冲正,应该是不同的记账指令,并通过原指令号建立关联。
第二,分录请求最好表达“账户角色”,不要让上游到处拼具体账务账号。账号解析规则集中在账务系统里,后面账户体系调整、渠道新增、币种扩展时,才不会把改动扩散到所有业务流程。
记账指令的处理链路可以简化成:
支付引擎确认业务事实
-> 生成记账指令
-> 账务系统落受理记录并做幂等校验
-> 根据业务场景解析记账规则
-> 将账户角色解析成具体账户
-> 生成借贷分录
-> 在同一事务中更新余额、写入流水、更新指令状态
-> 返回记账结果
这条链路的核心是:上游不要越过账务系统直接动余额,账务系统也不要反过来理解完整业务流程。两边通过记账指令交接,边界清楚,后面的冲正、调账、对账才有共同依据。
批量记账设计
单笔记账解决的是“一笔业务事实怎么入账”,批量记账解决的是“一组有共同结算依据的业务事实怎么一起入账”。
很多人一听到批量记账,会直接理解成把单笔记账循环调用很多次。这样能跑,但不一定能解释清楚。真正的问题不是执行多少次 SQL,而是这一批账从哪里来、是否完整、每一行是否可追溯、部分失败时怎么处理、后面怎么和结算文件或对账结果对上。
在账务系统里,上游传递一笔批量记账数据,通常可以理解成一个批次流水号,或者一个 tradeFlowNo,下面挂多条记账明细。每条明细最终仍然会拆成借方和贷方分录,只是在执行余额更新时,可以先按账户汇总出净变动金额,再统一锁住涉及账户,批量更新余额、批量落流水。
所以批量记账不是单笔接口的简单放大版,而是要多一层批次模型。
一个常见的批量记账模型可以拆成三层:
批次受理记录:记录这批账来自哪个结算依据
批次明细记录:记录批次里的每一笔业务事实
记账指令/分录流水:记录每一笔最终怎么影响账户
批次受理记录关注的是“这一批是否完整可信”。常见字段包括:
批次号
来源系统
结算日期
结算文件号或清算批次号
总笔数
总金额
币种
批次状态
请求时间
幂等键
批次明细记录关注的是“这一行是否可以独立追溯”。常见字段包括:
批次号
明细行号
业务单号
交易流水号
渠道流水号
业务类型
金额
币种
明细状态
失败原因
对应记账指令号
批次状态不要只设计成成功和失败。批量任务通常需要经历受理、校验、处理中、部分成功、全部成功、全部失败、已冲正等状态。明细也要有自己的状态,因为批次成功不代表每一行都成功,批次失败也不一定代表每一行都没有产生过影响。
批量记账的处理链路可以简化成:
接收结算文件或批量请求
-> 校验批次号、总笔数、总金额、币种
-> 落批次受理记录
-> 解析并落批次明细
-> 为每条明细生成记账指令
-> 将每条指令拆成借贷分录
-> 按账户号聚合余额变动
-> 按统一顺序锁住涉及账户
-> 批量更新余额、批量写入流水
-> 更新明细、指令和批次状态
-> 输出对账和异常明细
这里最重要的是,不要把整个大批次放进一个数据库事务里。比如一个结算文件有几万行,如果全部放在一个事务里处理,锁时间会很长,失败回滚成本也很高。更稳妥的方式是批次整体有状态,明细逐条或按小分片入账,每个分片内部保证本地事务一致,最后再汇总批次结果。
批量记账一般会出现在几类结算场景里。
第一类是渠道清算入账。比如渠道每天给一份清算文件,里面包含当天成功支付、退款、手续费、差异金额。系统需要根据清算文件把待清算账户、渠道手续费账户、平台收入账户或差异账户做批量入账。
第二类是商户结算。平台按日或按周期给商户结算,把商户应收款、平台服务费、渠道手续费、退款扣回等项目汇总后记账。这个场景里批量记账不只是记一笔“打款给商户”,还要解释这笔结算金额由哪些交易组成。
第三类是资方或合作方结算。信贷场景里,还款本金、利息、服务费、担保费、代偿款可能要按合作方拆分。批量记账可以把同一结算周期内的多笔还款明细,归集到资方应收、平台收入、渠道成本等账户。
第四类是收入、成本和手续费的批量归集。比如每天把多笔支付手续费归集到渠道成本账户,把服务费归集到平台收入账户。这类场景通常会涉及热点账户,所以更要注意分片、汇总和锁顺序。
第五类是差异处理和补偿入账。对账后发现某些渠道流水晚到、金额不一致或状态不一致,可能需要按差异批次补记、冲正或调账。这个时候批次号和原业务流水的关联非常重要,否则后面很难解释“为什么今天补了昨天的钱”。
批量记账有几个特别容易踩坑的点。
第一,批次幂等和明细幂等要分开设计。批次号可以保证同一份结算文件不会重复导入,明细幂等键可以保证同一条交易不会重复入账。只做批次幂等不够,因为同一笔交易可能出现在补偿批次或差异批次里;只做明细幂等也不够,因为系统还需要知道这一批文件整体是否已经处理过。
第二,批次金额必须可校验。接收批次时至少要校验总笔数、总金额、币种和文件摘要。处理完成后还要能反查:成功多少笔、失败多少笔、成功金额多少、失败金额多少。否则批量记账会变成“导进去一堆流水”,但没人知道这一批到底有没有完整处理完。
第三,要提前定义部分失败策略。有些批次必须整体成功或整体失败,比如内部资金调拨批次;有些批次可以部分成功,比如清算文件入账,失败明细进入异常队列。这个策略不能等失败发生后临时决定,而应该写进批次类型和处理规则里。
第四,批量处理不要绕过单笔记账规则。每条明细最终仍然应该生成标准记账指令和分录流水,而不是为了性能直接批量改余额。批量是执行方式,记账规则仍然要统一。
第五,要控制热点账户和锁时间。结算类批量任务经常会集中更新平台收入户、渠道待清算户、商户结算户。处理时要按账户号排序加锁,必要时按账户、币种、商户或渠道分片处理,避免长事务和死锁。
第六,冲正和重跑要有依据。批量记账一定会遇到文件重传、明细修正、结算规则调整。系统要能按批次、按明细、按原记账指令追溯,支持只重跑失败明细,或者对已成功明细生成反向冲正,而不是直接删除流水重新导入。
所以批量记账的核心判断是:
批量不是为了省掉单笔账务规则,而是为了让一组结算依据、明细流水和最终分录可以一起被校验、追溯和对账。
总结
账务系统核心设计不是从余额字段开始的,而是从账户模型开始的。
账户体系决定钱可以放在哪里;账号设计决定系统怎么稳定找到这个账户;幂等和并发控制决定同一笔钱不会被重复记、也不会在高并发下写乱;记账指令决定支付引擎和账务系统之间如何交接业务事实;批量记账决定结算场景下,一组流水如何被完整校验、入账和追溯。
如果这些设计不清楚,后面再补对账、清结算、冲正、调账,都会变成查日志和人工改数。
一个可靠的账务系统,应该让每一笔资金变化都能回答四个问题:
- • 这笔变化来自哪个业务事实;
- • 影响了哪些账户;
- • 每个账户为什么增加或减少;
- • 如果请求重复或并发发生,系统如何保证结果仍然可信。
做到这一步,账务系统才不只是一个余额服务,而是支付系统里解释资金变化的核心账本。