ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

金融服务参考系统设计:从账户体系到对账闭环

金融服务参考系统设计:从账户体系到对账闭环

看到 financial-services 这个项目名,可能很多人第一反应是“范围也太大了”。金融服务的概念确实宽——支付、信贷、理财、保险全是这个范畴。但正因为大,才更需要有人先把骨架立起来。这个项目做的就是这件事:一套能跑通完整资金闭环的金融服务参考系统,覆盖账户、交易、支付渠道、清结算、对账和风控这几个最核心的板块。它不是某种银行内部系统的复刻,而是一份可运行、可学习、可扩展的设计蓝本,既能当后端工程师转型金融方向的入门教材,也能给架构师做生产系统设计时提供对照参考。我在支付和账务方向做了多年,踩过的坑不少,这次借着拆解 financial-services 项目,把那些常规文档不会写的东西一并讲透。

1. 项目定位与整体设计:financial-services 到底在做什么

1.1 金融服务系统绕不开的核心痛点

做了这么多年金融业务系统,我越来越觉得,它的难点从来不是业务功能有多复杂,而是几个非常基础但又极端苛刻的要求:资金安全、账实相符、全程可追溯、跨系统最终一致,以及随时可能出现的审计与合规要求。这几个词听起来抽象,落到代码里全是血泪。

拿最常见的场景举例:用户在 App 里发起一笔支付,资金从用户账户扣减,到平台内部户,再通过渠道打给商户。中间涉及用户账户、内部过渡账户、渠道网关、商户结算账户,四个环节只要有一环掉链子,就会出现“用户扣了钱但商户没收到”或者“商户收到了但平台账对不上”的问题。这类问题在金融系统里不是 bug 级别,而是事故级别。设计 financial-services 项目时,我把最核心的目标定为一点:任何一笔交易,在任何时点都能回答出“钱现在在哪、从哪来、要到哪去”,并且账目永远能对平。

除此之外,金融系统还有一个被很多初入行的工程师低估的点:审计要求。每一笔资金变动都要有完整流水,谁在什么时间通过什么接口动了哪个账户的多少钱,必须能查、能导、能解释。这意味着系统从一开始就不能只记录结果,还必须记录过程。

1.2 功能边界与模块划分

这个项目我没有做成一个堆满业务需求的庞然大物,而是按金融系统的天然边界拆成了几个独立模块。每个模块高内聚、低耦合,能独立演进,也可以单独摘出来用在别的项目里。

模块核心职责关键设计点
账户中心开户、余额维护、冻结/解冻、流水记录余额与流水分离,所有变动走流水
交易引擎下单、支付、退款、关单、状态流转状态机驱动,幂等优先
渠道网关对接支付渠道、发起收单/代付、处理回调渠道抽象统一,回调验签
清结算与对账日切、记账、渠道账单核对、差异处理双边记账,差错可追溯
风控中心频次限制、额度控制、黑名单、行为特征规则可配置,不阻塞主链路

这样拆的直接原因是:账户和交易属于核心资金链路,改动的风险等级最高,必须和渠道、风控这类外围模块隔离。渠道网关经常因为合作方调整而要改,如果它和账务代码耦合在一起,每一次渠道变更都可能影响资金安全。风控规则变化频繁,适合做成独立服务,用配置驱动而不是发版驱动。

架构上我采用的是传统且可靠的分层模式:接入层、业务层、核心资金层、基础数据层。没有刻意上微服务,因为对一个要讲清楚原理的参考项目来说,单体分层反而更容易理解;但在代码结构上已经把模块边界留好了,后续要拆成独立服务,基本是按包搬代码的事。

2. 核心模块设计与技术选型

2.1 账户体系:余额怎么存才安全

账户模块是整个系统里最不该出问题的地方。我见过不少初级系统把余额当普通字段用,比如在业务代码里直接读余额、减余额、再写回,看着简单,一旦并发一上来就全是问题。金融系统里必须守住两条铁律:不能用应用层计算替代数据库事务;任何余额变动都必须伴随流水记录,且两者在同一事务里提交。

这个项目的账户模型分了两层:账户主表和流水表。账户主表只存当前余额和冻结余额,代表“现在的结果”;流水表记录每一次变动,代表“过程”。任何时候账户余额对不上,都可以从流水表重建结果,这也是审计要求的落地方式。

资金流转链路里还有一类容易被忽略的账户:内部过渡账户。真实支付场景中,用户扣款和商户到账往往不是同一时刻完成的,中间隔着银行或支付渠道的处理时延。这个时间差里的资金必须落在某个明确归属的账户上,就是内部账户体系要解决的问题。我在项目里设计了三类内部户:收款过渡户(用户扣款后、清算前的资金池)、商户待结算户(清算后、可提现前的资金)、平台手续费户。这三类账户和用户账户一样受流水约束,保证每一分钱都有去处。

2.2 交易引擎:状态机、幂等与事务

交易引擎我直接用状态机来建模,没有比这个更清晰的方式。一笔支付订单的状态流转是这样的:创建后处于“待支付”,用户发起支付后进入“支付中”,支付成功进入“成功”,失败进入“失败”,超时则“关单”。退款订单独立存在,从“待退款”到“退款中”,最终到“退款成功”或“退款失败”。所有模块里,只有预先定义好的状态跃迁是合法的,状态字段的更新必须配合条件约束,防止出现“成功单被改成失败”这种事。

幂等是金融系统里最绕不开的话题。用户点一次支付按钮,前端可能会重试三次;渠道回调一个支付结果,也可能因为网络抖动推送两次。如果不做幂等,一次下单被创建成多笔订单,一笔支付成功被记账两次,账不平就是必然结果。项目里我在两端做了双保险:数据库唯一索引兜底,业务层幂等判断先行。唯一索引针对外部订单号、请求号和渠道单号,数据库层面杜绝重复;业务层幂等判断则保证重复请求直接返回第一次的结果,而不是报错。

事务设计上,原则很简单:能在一个本地事务里完成的事,绝不用分布式事务。账户扣减、记账、订单状态更新这三件事,在单体项目里完全可以放进同一个数据库事务。真正需要走最终一致的场景只有调用外部支付渠道这一步,网络调用不能占用数据库事务,否则长事务会拖垮整个库。所以这里把流程拆成两步:先本地锁住资金并记录“支付中”的流水,再异步调渠道;渠道回调来了之后再在另一个事务里确认成功并更新余额。

2.3 支付渠道层:抽象、对接与对账设计

渠道网关层做好了一件很重要的事:把支付渠道的差异挡在核心链路之外。无论是接微信、支付宝、银联还是某个私有化钱包,对外暴露的接口永远是统一的——创建支付、查询订单、申请退款、接收回调。渠道差异全部封装在各自的适配器里,核心交易引擎根本不感知具体渠道是谁。

这里有个新手很容易踩的坑:把渠道回调直接当接口处理。真实渠道的回调有几个特点:可能乱序,先通知支付失败再通知成功;可能重复,同一结果推好几次;可能在极端情况下延迟很久甚至丢失。所以回调处理必须遵循三个原则:验签之后再信内容;幂等之后再改状态;查单兜底之后才算闭环。

对账功能在这个项目里虽然做了简化,但它的位置是在金融系统里最高的。我设计了一个每日定时任务,从模拟渠道侧拉取“通道账单”,再和本地交易数据做比对,找出所有不一致的记录,包括本地有单渠道没单、渠道有单本地没单、金额不一致、手续费不一致四种情况。每类差异都有对应的处理状态和补偿动作,而不是简单地把差异列表丢给人看。对账跑完,系统才敢说今天的账是平的。

3. 实操过程与核心代码落地

3.1 数据库表结构设计

直接看关键表结构。账户主表把余额拆成了“可用余额”和“冻结余额”两个字段,两者之和等于账户总资产;变更类型字段配合流水表做审计。

CREATE TABLE `account` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '账户ID', `account_no` VARCHAR(32) NOT NULL COMMENT '账户编号', `user_id` VARCHAR(64) NOT NULL COMMENT '归属用户/商户ID', `account_type` TINYINT NOT NULL COMMENT '1-用户余额账户, 2-平台收入户, 3-商户账户, 4-冻结户', `balance` DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户总余额', `frozen_balance` DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结余额', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常, 2-冻结, 3-销户', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_account_no` (`account_no`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户主表';

流水表是金融系统的关键,任何余额变动都要写一条。我特意加了change_type和biz_no两个字段,前者说明变动的业务类型,后者关联订单号,保证审计链路可追溯。

CREATE TABLE `account_flow` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `account_no` VARCHAR(32) NOT NULL COMMENT '账户编号', `change_type` VARCHAR(32) NOT NULL COMMENT '变动类型: PAY_IN/PAY_OUT/FREEZE/UNFREEZE', `biz_no` VARCHAR(64) NOT NULL COMMENT '关联业务单号', `amount` DECIMAL(20,2) NOT NULL COMMENT '变动金额', `balance_after` DECIMAL(20,2) NOT NULL COMMENT '变动后余额', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_account_no_time` (`account_no`, `create_time`), UNIQUE KEY `uk_biz_type_no` (`biz_no`, `change_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户流水表';

另有两张核心订单表:支付订单和退款订单。支付订单上必须有一个业务唯一键,通常是渠道侧返回的渠道单号,这是对账和防重的基础。

CREATE TABLE `payment_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(64) NOT NULL COMMENT '本地订单号', `channel_order_no` VARCHAR(128) DEFAULT NULL COMMENT '渠道订单号', `user_id` VARCHAR(64) NOT NULL, `amount` DECIMAL(20,2) NOT NULL, `status` TINYINT NOT NULL COMMENT '1-待支付, 2-支付中, 3-成功, 4-失败, 5-关单', `channel` VARCHAR(32) NOT NULL COMMENT '渠道编码', `idempotent_key` VARCHAR(128) NOT NULL COMMENT '幂等键', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `pay_time` DATETIME DEFAULT NULL, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_idem` (`idempotent_key`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表';

3.2 核心交易流程实现

支付流程的核心逻辑是可以提炼成模板的。用户请求支付时,应用层先做幂等判断,再开事务锁余额、降余额、生成流水、更新订单状态,最后提交事务。注意这里并没有在这个事务里调渠道,只做资金锁定和状态推进。

@Transactional public PaymentResult pay(PayCommand cmd) { // 1. 幂等判断:重复请求直接返回旧结果 PaymentOrder existed = paymentOrderMapper.selectByUniqueNo(cmd.getIdempotentKey()); if (existed != null) { return buildResult(existed); } // 2. 校验订单状态,保证只有待支付单能继续 PaymentOrder order = paymentOrderMapper.selectByNoForUpdate(cmd.getOrderNo()); if (order.getStatus() != Constants.PAY_STATUS_INIT) { throw new BizException("订单状态不允许支付"); } // 3. 锁定账户行,扣减可用余额 Account userAccount = accountMapper.selectByNoForUpdate(cmd.getAccountNo()); if (userAccount.getBalance() - cmd.getAmount() < 0) { throw new BizException("余额不足"); } userAccount.setBalance(userAccount.getBalance() - cmd.getAmount()); accountMapper.updateById(userAccount); // 4. 写流水,余额和流水在一个事务里保证一致 accountFlowMapper.insert(buildFlow(userAccount, "PAY_OUT", cmd)); // 5. 订单状态推进到“支付中”,等待渠道结果 order.setStatus(Constants.PAY_STATUS_PAYING); paymentOrderMapper.updateById(order); return PaymentResult.pending(order); }

这里有两处实现细节非常关键。第一,账户查询和订单查询都用了ForUpdate,也就是行级锁。用户账户是热点数据,不加锁的话,两个并发请求同时读到相同余额,各自扣减后写回,就会导致资金凭空多出来。第二,余额扣减用的是“先查后改加行锁”,而不是UPDATE ... SET balance = balance - ?直接做条件更新。前者多一步校验逻辑,能给出更友好的错误提示,代价是锁持有时间稍长,在单机数据库场景下完全可接受。

支付渠道的确认环节放在回调里处理:验签通过、幂等判断通过后,开新事务把订单改成成功,同时把用户在支付时预扣的资金从过渡户释放到商户待结算户。这么设计的原因是,用户余额在发起支付时已经扣减,为了让商户能尽快到账,资金链路应当在支付结果确认后“无缝”进入清算环节。

3.3 并发控制与回调处理的细节

并发控制我同时用了数据库和 Redis 两层。数据库行锁保证资金账务的强一致,Redis 分布式锁用于防止回调与主动查单之间的重复补偿。比如支付成功回调正在改订单状态,同时定时任务主动查单也返回成功,两个请求可能同时尝试确认订单。解法是给订单确认逻辑加一个 Redis 锁,键就是订单号,拿到锁的请求才允许推进状态,另一个请求直接返回当前状态。

回调处理的完整链路是这样的:先验签,再做业务幂等判断,然后通过回调报文里的渠道订单号找到本地订单,加锁后判断订单当前状态。如果已经是成功,说明之前处理过,直接返回成功应答;如果还在支付中,才执行资金入账和状态更新。如果回调状态是失败,同样要走幂等判断,绝不能因为一次失败回调就把已经成功的订单改成失败。

这里还要处理回调乱序问题。模拟支付不会出现,但真实渠道里确实会发生“先收到成功回调、后收到失败回调”这种反常情况。我在项目里加了一条硬规则:订单状态一旦进入终态(成功或失败),就必须拒绝所有反向的状态变更。这条规则写在状态机层面的条件判断里,不依赖业务接口的自觉。

4. 常见问题、排查技巧与个人经验

4.1 对账不平的排查思路

对账是金融系统夜里最紧张的时刻,账平了皆大欢喜,账不平就要开始“破案”。我在项目里故意保留了几类常见差异场景,方便复现和排查。

差异类型典型原因排查与处理
本地有单,渠道无单本地请求未真正到达渠道,或渠道应答丢失先查本地订单状态,再查渠道流水,确认单边挂账后走补发或退款
渠道有单,本地无单回调丢失导致本地单未更新,或存在恶意伪造以渠道账单为准补登记,再核对用户是否实际扣款
金额不一致渠道侧手续费、优惠补贴导致实付金额与本地订单金额有差异逐笔比对原始报文,按差异类型挂账处理
手续费差异计费规则调整或渠道侧改价以渠道账单为准,差异进入手续费调整科目

排查顺序上,我建议从上到下按“时间窗口对齐、订单粒度匹配、金额逐笔核对”的流程走。先把本地和渠道的数据都按同一时间窗口拉出来,再做订单号交叉匹配,最后只对未匹配的订单做逐笔人工核对。很多新手一上来就对比总金额,发现总数对不上就慌了,其实总额差是结果,交叉匹配才能快速缩小范围到具体笔数。

对账差异处理的最终状态必须可追溯。项目里给每个差异单都设计了处理人和处理动作的记录,后续审计要问起来,能说明白每一笔差异为什么存在、为什么这样处理。

4.2 重复支付、超时与丢单处理

重复支付是我在测试阶段就刻意压测过的场景。用户在收银台点了一次没反应,再点一次,前端拦截没做好,两个请求几乎同时到达后端。没有幂等保护的情况下,这就会产生两笔订单、两次扣款。用上一节里的idempotent_key唯一索引后,第二次请求会撞唯一键,然后被引导到查询已有订单的逻辑,用户看到的是同一笔订单的支付结果,不会再造成重复扣款。

超时关单是另一个容易做错的地方。订单创建后一直没支付,如果状态永远停留在待支付,后续查单任务会觉得它还活着,反复向渠道发起查询。正确做法是配置关单策略:比如超时 30 分钟把订单状态置为关单,并且关单操作要检查渠道侧的真实状态,防止“本地已关单、渠道实际已支付”这种惨案。真实的金融系统里,这种状态不一致是资金风险的高发区,所以我个人的原则是:关单之前必须主动向渠道发起一次查询确认。

丢单处理依赖主动查单补偿。回调可能丢,但渠道侧订单状态不会消失。项目里跑了一个定时任务,每隔几分钟扫描所有停留在“支付中”的订单,向渠道发查询,把渠道返回的最新状态同步回来。这个任务我可以说是金融系统的“安全气囊”,平时看着没用,真出了事全靠它兜底。

4.3 性能优化与上线后的监控

性能优化这块,我踩过最大的坑就是给用户余额加缓存。看起来这是个很自然的优化,余额查询太频繁了,放个 Redis 能扛住很高的 QPS。但余额是资金数据,缓存一旦和数据库出现不一致,用户看到的就是“余额不对”。所以这个项目里我坚持一个原则:余额永远只从数据库读,缓存只能存一些非资金类的低风险数据,比如用户风控标签、渠道配置。

真正扛性能的地方在数据库和任务链路上。支付订单表按用户维度做了索引,流水表按账户加时间维度做了索引,高频查询基本都能走索引。对账任务和补单任务都放在凌晨低峰期跑,避免和白天支付高峰期抢数据库资源。渠道通知处理通过消息队列削峰,回调突然大量涌入时不会直接把数据库打爆。

上线后我必看的核心指标有三个:支付成功率(回调确认成功的单数/发起的支付数)、支付耗时(从发起到渠道通知到账的 P95)、对账差错率(差异单数/总交易数)。这三个指标任何一个异常,都比业务功能报错更值得警惕。支付成功率下降通常意味着渠道或回调链路有问题;对账差错率上升则直接指向资金风险,必须立刻介入。

4.4 一些写在最后的实战建议

这几年做金融系统,我最大的体会是:这个领域的核心竞争力不是代码写得有多花哨,而是边界感和敬畏心。边界感意味着知道哪些操作必须放进事务、哪些可以异步最终一致;敬畏心意味着每一个看似小概率的异常——回调丢失、重复通知、数据库宕机——都必须当成一定会发生的事来设计应对方案。

我自己踩过最惨的一个坑,是系统上线初期没有把主动查单补偿任务排上。当时觉得渠道回调稳定得很,没必要多此一举,结果一次渠道维护导致几千笔回调延迟了近一个小时,用户的支付状态卡在“支付中”没法推进。从那以后,我给自己定了一条规则:任何涉及外部渠道的支付流程,没有补偿任务就不允许上线。

最后分享一个很实用的小技巧:给所有对外接口的请求和响应都加上全局流水号,并且在日志里统一打印。排查问题的时候,从用户投诉到订单号,从订单号到流水号,从流水号到日志和渠道报文,一条链路拉下来,能节省大把时间。这个小习惯在金融系统里价值极大,因为金融问题的排查,比拼的往往不是分析能力,而是能不能用最短的时间找到那条出错的记录。

返回列表