做金融系统这些年,我最深的感触就是:单子看起来不难,难的是把每一分钱都算得明明白白。前段时间我在团队里推进了一个代号为financial-services的核心项目,把散落在各业务线的账户、支付、记账、对账能力全部收拢到一个统一服务里。这篇文章就围绕这个项目,把设计思路、核心数据结构、关键链路和踩坑经验一次讲透。不管你是后端开发、系统架构师,还是金融业务的产品经理,只要对资金服务体系感兴趣,应该都能从里面找到可复用的东西。
1. 项目定位:为什么单独做一个金融服务模块
1.1 背景与痛点:账目分散,人人对不齐
事情要从几个月前的线上事故说起。我们当时有电商、内容打赏、B2B分账三条业务线,各自维护着一套支付和账务逻辑。表面上看互不影响,实际上每个月财务对账都是一场灾难:账单口径不统一,有的业务按订单维度记账,有的按支付单维度记账;资金流水散落在不同的数据库里,想看一个用户的资产全貌得查五六个系统;更麻烦的是,同一笔交易在业务线A那边已经显示成功,在业务线B这边却因为网络抖动漏了回写,导致账面不一致。每次对账都要拉好几个人,导出一堆Excel,人工逐条比对。一旦有一个数字对不上,排查成本高得吓人。
后面我们痛定思痛,决定做一个统一的金融服务模块,也就是financial-services。这个项目要解决的核心问题有三个:第一,让资金账户成为唯一可信的数据源,所有业务线都不许再自己维护余额表;第二,把交易流水、记账分录、渠道对账这些基础能力沉淀成平台公共服务;第三,通过统一的资金链路,让财务、风控、审计都能基于同一份数据做分析和监督。说白了,就是把“钱”这件事从业务逻辑里彻底拆出来,单独立项、单独治理。
1.2 领域边界:哪些该管,哪些不该管
很多团队做这类系统会犯同一个错误:什么都往金融服务里塞。今天业务说要做优惠券抵扣,明天产品说要支持积分抵现,后来说不定还有贷款和理财。最后这个模块会膨胀成一个四不像。我们在项目启动时重中之重就是划边界。
financial-services只负责资金账户、交易流水、支付渠道交互、会计记账、对账以及相关风控事件。订单、商品、库存、用户资料、优惠规则这些,通通不归它管。订单系统记录的是“用户想买什么、价格多少”,金融服务记录的是“资金如何流动、账上还剩多少”。两者通过事件和API联动,但不共享事务。举例来说,用户在商城下单后,订单系统创建订单,financial-services创建支付单并锁定额度;订单取消时,业务系统发起解冻请求,金融模块负责把冻结资金还回去。整个过程中,业务系统不需要知道钱存在哪张表里,只需要调用接口,拿到结果。
这条边界为什么重要?因为它决定了系统的稳定性和可演进性。业务可以快速创新,哪怕订单流程改十遍,资金模块只需要保持接口稳定;反过来,金融模块内部做数据库分库、更换支付渠道、调整记账逻辑,业务侧也无感知。这是整个架构里我认为最关键的第一步。
1.3 整体架构思路:内部按领域拆,对外统一收口
financial-services在物理上是一个可独立部署的微服务,但代码内部没有做成大泥球,而是按领域拆成几个相对独立的模块:账户域、交易域、账务域、渠道适配域、对账域。每个域有自己的数据表和核心逻辑,域之间通过本地事务、事件总线或者简单的API互相协作。
对外只暴露一组收敛的接口,比如创建支付单、确认支付、发起退款、查询账户流水、同步对账结果。所有外部系统统一走网关,不让他们直接摸到内部表。这个设计在后期给了我们巨大弹性:比如渠道适配域新增一个支付通道,不需要改动交易域的代码;账务域要增加一套新的会计科目,也只需要在账务域内部操作。遇到极端情况,哪怕渠道域挂了,账户查询和记账能力仍然可用。
需要注意的是,我不建议在这类系统上一开始就上分布式事务中间件。金融场景虽然对一致性要求高,但很多所谓“强一致”的诉求,其实可以通过“本地事务 + 消息表 + 对账兜底”来解决。我们后面在关键链路上就是这么做的,效果很稳。
2. 核心设计与数据模型
2.1 账户模型:一套能支撑所有业务的钱包
账户是资金服务的基石。我们设计账户表时考虑了多个业务线的通用性,核心字段大致是这样的:
account_id:账户唯一标识,全局友好ID或UUIDuser_id:所属用户/商户标识,业务系统传入account_type:账户类型,比如用户钱包、商户结算户、平台手续费户、冻结专户currency:币种,同一用户在不同币种下有不同账户balance:可用余额,单位是分frozen_balance:冻结余额,单位也是分status:账户状态,正常、冻结、挂失、销户等version:乐观锁版本号,并发控制用
账户状态机要提前定义好。我们规定只有“正常”状态的账户才能发生资金变动;“冻结”状态下可以入账但不能出账;“挂失”状态下入账出账都暂停;“销户”是终态,不允许再发生任何交易。状态迁移必须通过后台权限操作或特定业务流程触发,不能直接update。
这里有一个容易踩的坑:不要为了省事把所有资金都放在主账户里,像提现中的金额、担保交易的冻结金额、平台暂收的税费,都应该有独立的账户类型或者明确的冻结标记。否则财务问你“用户账上有500块,为什么能提现的只有300块”时,你单靠一张balance字段根本解释不清楚。
2.2 流水与余额:为什么不能直接update余额
很多新手做账户系统,第一反应是“用户付款时执行update account set balance = balance - 100,收款时加100”。这个做法在业务量小的时候也能跑,但隐患极大:一旦更新丢失、重复执行或者出现脏读,账面数字就会悄悄出错,而且很难回溯。我们从头就定了一条铁律:余额表只是流水表的一个物化视图,所有资金变动必须先写流水,再更新余额。
流水表设计成追加写,修改和删除都被禁止。每条流水记录包括流水ID、账户ID、交易单号、业务类型、变动方向、变动金额、变动前余额、变动后余额、唯一幂等号、创建时间。为什么要有“变动前余额”和“变动后余额”?因为这是事后审计和对账的重要依据。如果哪天发现余额不对,就可以按账户和时间范围回放流水,逐笔算出应该剩多少,再和当前余额比对,很快就能定位是哪笔出了问题。
更新余额时不能简单set balance = balance - 100,要带条件:
update account set balance = balance - 100, version = version + 1 where account_id = ? and balance >= 100 and version = ?;受影响行数为0,说明要么余额不足,要么版本冲突,要么账户状态不允许。这时候业务侧要根据返回值决定是失败还是重试。这个写法比单独查询再更新要可靠得多,也把“余额不足”的判断推给了数据库。
2.3 幂等性与事务:跨系统调用的老大难
在金融场景里,幂等不是可选项,是必须项。你永远不知道上游会重试几次,支付渠道的回调可能重复推送,消息队列也可能保证at-least-once。所以每个资金操作必须带一个全局唯一的请求标识。
我们单独建了一张幂等表,核心字段是请求ID、业务类型、目标交易单号、处理状态、请求参数摘要、返回结果摘要。处理流程是这样:收到请求后先查幂等表,如果有记录,直接返回上一次的结果;如果没有,在本地事务里插入幂等记录并处理业务。这个唯一约束要落在数据库层面:
alter table idempotent_record add unique key uk_request (request_id, biz_type);事务边界越短越好。比如支付确认接口,我们需要保证“更新交易单状态 + 写资金流水 + 更新账户余额 + 记录幂等结果”在一个本地事务里完成。这四个动作都落在同一个数据库实例里,就可以用本地事务搞定。后续需要通知下游时,不要在同一事务里发外部请求,而是写一张内部事件表,由异步任务消费后推送。这样外部系统抖动不会拖垮主链路,也不会因为回调失败导致本地事务回滚。
至于更加复杂的分阶段交易,我们用了类似Saga的思想:先冻结,再状态流转,最后确认或解冻。每一步都有补偿动作,任何一步失败都能通过冲正恢复。
2.4 会计视角:借贷分录不止是记账
财务和审计对账目有自己的一套语言:借贷分录。技术团队刚开始不理解为啥要搞这些,觉得流水表已经够用了。后来财务说“你们这个流水看不懂,我要的是每一笔变动对应哪个会计科目,能不能让我月底一键出报表”,我们才意识到会计引擎的重要性。
于是我们加了分录表,记录每笔业务产生的借贷项。以用户充值100元为例:
- 借:银行存款 100元
- 贷:客户资金 100元
以用户消费10元为例:
- 借:客户资金 10元
- 贷:平台收入 10元
所有分录通过一个批次号关联,一笔业务至少两条分录,借贷方向金额必须平衡。这个设计初期会增加不少工作量,但后期价值巨大:财务可以直接从这个表做试算平衡,审计可以顺着批次号追溯到原始交易单,风控也可以基于科目余额做异常检测。我的建议是,即使你的系统还不需要对外报送报表,也应该在核心账务设计阶段把借贷分录建起来,不然后面想补,成本至少翻倍。
3. 关键链路实战:从下单到结算
3.1 典型流程:冻结、支付、确认、通知
我拿一个最常见的场景举例:用户在商城下单,用账户余额支付。整个链路是这样的:
- 商城系统调用
financial-services的预支付接口,创建支付单,传入订单号、用户ID、金额、业务类型。 - 金融模块校验用户账户状态、余额、风控限额,然后创建一笔待支付交易单。
- 对可用余额做冻结:可用余额减少100,冻结余额增加100。这一步实际上是把资金锁定,保证后续支付有保障。
- 支付渠道侧如果不需要跳转,直接调用内部支付确认;如果需要走外部渠道,则引导用户完成支付。
- 外部支付成功后,渠道异步回调
financial-services,验签通过后,把支付单状态更新为“成功”。 - 交易模块执行真正的资金清算:把冻结余额减少100,并对收款方账户增加对应金额;如果业务涉及分账,还要在商户子账户间做批量分配。
- 写分录、写流水、广播支付成功事件,商城系统收到事件后更新订单状态。
这个流程看着不复杂,但每一步都有细节。比如第3步“冻结”和第6步“清算”之间,如果用户取消订单,我们要做解冻:可用余额加回来,冻结余额减掉。解冻的原单要关联到最初的冻结流水,不能凭空操作。
3.2 接口定义与关键参数
核心接口我们尽量保持简单。以预支付为例,请求参数大概是这样的:
{ "requestId": "382771652100001", "bizType": "PURCHASE", "bizOrderNo": "ORDER202501010001", "userId": "10002345", "payeeAccountId": "20001111", "amount": 10000, "currency": "CNY", "channelCode": "BALANCE", "notifyUrl": "https://api.example.com/callback/payment" }这里amount是整数,单位是分,10000表示100元。为什么用整数不用浮点数?原因很简单,浮点数在二进制中无法精确表示十进制小数,0.1+0.2不等于0.3,这在账务系统里是绝对不能容忍的。后端在入参校验时直接把金额按字符串传输,数据库字段用BIGINT,展示层再转成元。
接口响应里除了交易单号,还必须返回一个状态字段。用户的直觉是支付要么成功要么失败,但在资金服务里中间态非常常见。我们要定义清楚:ACCEPTED表示请求已受理,PROCESSING表示处理中,SUCCESS表示成功,FAILED表示失败。很多问题出在调用方把一个非终态当成成功来处理,所以文档里要反复强调:只有SUCCESS才是真正成功,其他状态都需要继续查询或者等待回调。
3.3 事务边界与并发控制
交易系统对并发的要求很高,同一个账户同时发起多笔支付,不能出现超扣。我们的处理方式是在更新余额时使用条件更新,并且给账户行加行锁,必要时直接用select ... for update把账户行锁住。锁定之后再做余额判断、流水写入、状态更新,全部在本地事务内完成。
要注意的是,如果一个事务里同时操作多个账户,比如极速转账场景需要同时扣减A账户和增加B账户,一定要按照账户ID排序后再加锁。如果两次转账方向相反,却按调用顺序加锁,很容易出现死锁。固定排序这个习惯我们踩过坑之后定了死规矩,代码审查时也会专门查这一点。
行锁虽然稳妥,但会影响并发吞吐。后来我们做了一些优化:对于热点账户,比如红包总账户、平台佣金账户,不再每次实时更新主余额,而是先把资金变动写入一个待入账队列,由后台批量合并更新。查询余额时,用主余额加上待入账流水实时计算。这个方案能在流量高峰扛住压力,但复杂度高了不少,适合到瓶颈再上。
3.4 金额精度与舍入规则
除了用整数存储,还要注意跨币种和汇率转换时的舍入。比如用户用人民币充值的余额,去购买一个以美元计价的商品,中间需要做汇率换算。我们定的规则是:所有汇率取实时牌价,计算过程用BigDecimal,保留小数点后四位;最后的支付金额四舍五入到分,舍入模式用RoundingMode.HALF_UP。多出来的差额不能扔,要记一个汇兑损益科目,否则最后总账会差出几毛钱,对账怎么都平不了。
另一个精度坑是优惠券、积分和现金同时存在时,支付金额拆分的粒度。我们约定每一条资金流水只能记录一种资金类型,一笔支付如果混合了现金、积分和优惠券,就要拆成多条流水、多条分录,分别记账。这样财务能看清每笔钱从哪来、到哪去。
4. 支付渠道接入与外部系统协同
4.1 渠道适配层设计
做支付系统离不开对接各种支付渠道:银行直连、第三方支付、聚合支付等。每个渠道的接口风格差异很大,有XML有JSON,有同步有异步,有RSA签名也有HMAC。如果业务代码里直接写死某个渠道的SDK,后续切换或者新增渠道都会很痛苦。
我们在financial-services里加了一个渠道适配层,定义统一出口:
public interface ChannelAdapter { ChannelResult preCreate(ChannelRequest request); ChannelResult query(ChannelQuery query); ChannelResult refund(ChannelRefund refund); ChannelNotify parseNotify(HttpServletRequest request); }每个渠道一个实现类,把不同渠道的报文转换、签名验签、异常处理都封装在实现类内部。业务层只依赖ChannelAdapter接口,不感知具体渠道。渠道参数,像商户号、证书路径、回调地址,放在配置中心或者数据库表里,可以动态切换,不需要发版。这样新增渠道时,主要工作就是写一个Adapter和做联调。
渠道的接口经常不稳定,所以我们给每个外部调用设计了超时和重试。超时时间一般设为3秒到5秒,重试次数不超过2次,而且要带退避。像退款这类高危操作,我们甚至不自动重试,而是先落一个“待处理”状态,由后台任务轮询渠道结果,再由人工介入。
4.2 回调验签与防重放
渠道回调是资金入账的关键入口,安全上绝对不能马虎。以常见场景为例,渠道用私钥对回调参数签名,我们用公钥验签,验签失败直接拒绝并返回错误码。验签通过后,还要判断回调里的金额、商户订单号、渠道流水号是否和我们本地的一致,防止中间人篡改回调数据。
防重放同样重要。渠道网络抖动时会重复推送同一个回调,我们的处理方式是:在数据库里记录每个渠道通知的唯一ID,处理完成后落一条标记;重复通知进来时,如果标记已存在,就不再做任何资金操作,直接返回成功告诉渠道“已经收到”。这一步必须在写资金流水之前判断,不然同一笔支付可能被入账两次。
回调处理接口本身要快,不要在回调线程里做太多外部IO。我们规定回调里只做验签、幂等判断、状态更新和写一条“已接收”的消息,真正的资金清算和业务通知都丢给异步任务去做。这样即使渠道在短时间并发回调,服务也能扛住。
4.3 对账与差错处理
对账是资金系统的最后一道防线。每天凌晨,渠道侧会生成前一日的交易账单文件,我们下载下来和本地流水逐笔比对。比对维度包括本地交易单号、渠道流水号、交易金额、交易时间、交易状态。我先把对账的几种结果列出来:
| 本地 | 渠道 | 处理方式 |
|---|---|---|
| 有交易 | 有交易,金额一致 | 标记已对账 |
| 有交易 | 有交易,金额不一致 | 进入差错池,自动告警,人工处理 |
| 有交易 | 无交易 | 本地悬挂,先查渠道订单状态,渠道确认没有则发起原路退回或冲正 |
| 无交易 | 有交易 | 渠道悬挂,检查是否漏单,补充入账或联系渠道处理 |
对账文件解析时,最容易忽略的一点是时区和对账口径。渠道账单的时间大多是渠道侧的系统时间,可能和本地时间有时差,所以对账不要按“本地创建时间”当天匹配,建议按渠道账单中的支付完成时间来分区。否则月底最后一天凌晨的交易,本地记在今天,渠道账单记在昨天,对出来全是差异,会把人逼疯。
5. 安全、合规与风控
5.1 敏感数据保护:加密、脱敏、不能打日志
金融服务天然是敏感数据聚集地。手机号、身份证号、银行卡号、用户姓名,这些字段在数据库里不能明文存储。我们的做法是对外展示时不落地明文,数据库里用AES-256加密,密钥放在专用的密钥管理系统里,定期轮换。接口返回给前端时,数据要脱敏,比如手机号只显示前三位和后四位,银行卡号只显示后四位。
日志和监控系统里更不能出现明文敏感信息。我们在框架层做了过滤器,如果日志输出内容匹配到手机号或银行卡号正则,会自动替换成掩码。这个规则写进了代码规范,联调阶段就执行,避免上线后泄露。还有一个容易漏的地方是依赖的第三方SDK也会打日志,要用自己的SLF4J配置把它们输出级别调高或者过滤掉。
5.2 权限与数据隔离:内部人也不能随便看
金融系统最大的风险往往不是外部攻击,而是内部权限控制不到位。我们把后台用户分为运营、财务、客服、技术和审计等角色,基于RBAC做权限控制。客服可以查询用户账户和流水,但是看不到完整身份证号和银行卡号;财务可以看对账汇总和会计凭证,但无权修改交易状态;技术只保留运维权限,不授予业务数据增删改权限。
所有敏感操作都要留痕。我们建了一张操作审计表,记录谁在什么时间从哪个IP、用哪个接口、对哪条数据做了怎样的变更,变更前后的关键字段都存下来。这样一旦出现权限滥用或者误操作,可以快速定位。审计日志不能存在业务库里,要单独归档,防止被业务事务连带删除或覆盖。
5.3 风控与限额:交易前先问一次风控
资金服务必须和风控联动。我们约定所有出款类交易在冻结资金前,先调用风控接口做实时检查。风控的规则包括单笔限额、单日累计限额、短时间内高频交易、黑白名单、设备维度风险评分等。以单日累计限额为例,风控会根据账户ID统计当天已交易总额,加上本次请求金额,超过阈值就直接拒绝,并返回明确的拒绝码。
风控服务不能成为支付链路的单点故障,否则一个规则超时就会挡住所有交易。我们在调用风控时设置了短超时,比如300毫秒,超时之后走降级策略:放行小额、拦截大额。这个策略是业务定的,既保证用户体验,又不会让资金风险过大。风控规则的变更要灰度发布,先在少量流量上实验,没有问题再全量放开。
5.4 审计与合规:先出方案后动代码
做金融相关系统,合规不是可有可无的。虽然不同机构的要求不一样,但大方向是一致的:交易数据要完整、不可篡改,凭证要有足够的留存期限,敏感操作要可追溯,核心账务逻辑要经过财务确认。我们在项目里立了一条内部制度:凡是涉及资金流转、账户变动的新功能,必须先写出合规方案,说明数据项、账务处理、审计需求,财务和法务评审通过后才允许动代码。
这条规矩一开始大家都觉得拖节奏,后来避免了好几次返工。有一次产品提了一个很常见的即时到账需求,正常情况下从主账户扣款给商户入账就结束了,但法务提出这种模式可能涉及“二清”风险,最后我们把流程改成了“先进入待结算账户,再按照结算周期转入商户账户”。如果不是提前评审,代码写完了再改,代价会大很多。
6. 常见问题与排查技巧实录
6.1 重复回调导致重复入账怎么办
这个是我见过最多的线上问题。渠道因为网络超时重发了回调,或者消息队列重投了支付成功事件,如果代码里没有做幂等,就会给用户重复入账。我们的解决方式分两层:接口层根据回调通知ID去重,资金层根据业务请求ID幂等。核心代码逻辑是:
int updated = updateTransactionStatusSuccess(txId, channelTxId, fromStatuses); if (updated == 1) { writeAccountFlow(...); sendNotifyEvent(...); }fromStatuses一般包含“待支付”“处理中”,如果这个字段已经被更新成“成功”,updated就会是0,直接返回成功,不执行资金操作。这里不能用if (status == SUCCESS) return先查询再更新,因为并发时两个请求都查到PENDING,都会继续处理,必须用一条带条件的UPDATE语句来保证原子性。
6.2 账不平如何快速定位
如果财务说余额对不上,不要慌也不要急着改数据。先做这几步:第一,用流水表按账户汇总某个时间段的金额变动,比对上期余额和本期余额,看差异出现在哪个账户;第二,查这个账户在这个时间段内的所有流水,重点看有没有缺少流水直接改余额的情况;第三,检查是不是有重复入账或者漏单,可以通过幂等表关联交易单号定位;第四,检查舍入差异和平台补贴类操作有没有单独记科目。
我常用一条SQL做初步筛查:
select account_id, sum(amount) as total from account_flow where create_time >= '2025-01-01' and create_time < '2025-02-01' group by account_id;把结果和期初余额加总的结果对比,能快速圈定问题账户。然后再把该账户的流水逐条列出来人工核对,会比大海捞针高效得多。记住:永远不要先改余额,一定要先找到原因,否则问题会越补越乱。
6.3 热点账户与性能瓶颈
业务做活动时,总有一些账户会被大量请求命中,比如红包总账户、平台补贴账户。单行更新成为瓶颈,数据库锁等待飙升,接口超时率直线上升。我们当时有两个选择:分桶和异步合并。分桶就是把大账户拆成多个子账户,请求随机分散到不同的子账户,查询时汇总。这个方案能大幅度提升并发,但会让对账和余额查询变复杂,跨子账户的转账还有可能涉及分布式事务。
异步合并更简单,先把资金变动写入待处理流水表,主账户余额不去实时更新,由后台任务批量聚合后更新。查询时用主余额加待入账流水实时计算。我们最终在补贴账户上采用了异步合并,牺牲了一点点实时性,换来了稳定吞吐。这个方案适合“高频小额”的场景,不适合需要严格实时余额的场景,取舍要看业务。
6.4 连接池与超时设置
金融服务的数据库连接池被打满,通常不是流量真的那么大,而是某个慢查询占住了连接。我们给数据库连接池设置了比较小的最大连接数,同时开了监控,统计活跃连接数、等待队列长度、获取连接耗时。如果获取连接的平均耗时超过50毫秒,就要警惕连接池不足;如果活跃连接数长期接近上限,优先排查慢SQL。
慢SQL我们靠statement_timeout兜底,比如普通查询超过2秒直接中断,避免一个烂查询拖垮整个服务。外部HTTP调用也全部设置连接超时和读取超时,不能依赖默认等待。支付链路里最怕的是线程卡在第三方接口上,线程一满,后面的请求全部排队。正确做法是异步化外部调用,或者至少用超时短一点的线程池隔离。
我个人在项目上线后养成的一个习惯是:每周固定做一次数据一致性抽检,随机挑出若干个账户,根据流水回放余额,和线上余额表比对。这个看起来很笨的办法,救过我们不止一次。做金融系统,最重要的不是那些花哨的技术,而是对每一分钱保持敬畏。所有设计、所有代码,都应该围绕“不丢单、不重复、可追溯”这三件事来转。只要守住这三条底线,系统再复杂也不会出大乱子。