“financial-services”这个项目名,很多团队都在用,但我刚接手的时候,差点被这个名字“骗”了。它不是一份理财产品说明书,也不是简单的支付接口封装,而是一整套支持账户、交易、清算、风控、对账、审计的后端服务集群。如果你正准备做金融类系统,或者已经在做支付、账务相关开发,这篇博文应该能帮你少走几个月的弯路。我会把我在 financial-services 项目里的架构思路、核心模块设计、关键代码实现、部署踩坑,全都摊开来讲。
很多新人容易把“金融服务系统”想成一个巨大的单体系统,其实真正跑得稳的金融服务,是靠职责边界清晰的服务群协作完成的。我最早接手的那版代码就是典型“大泥球”:账户、订单、流水、通知全部揉在一个服务里,每次需求上线都像拆炸弹。后来我们花了三个多月做重构,才逐步从混乱中理出条理。这篇文章的思路,就是那次重构沉淀下来的。
1. 先弄清楚 financial-services 要解决什么问题
1.1 从一笔支付到一整套服务
如果只是给App加一个“充值”按钮,你可能只需要个接口。但一旦涉及真金白银,事情就完全不同。用户点“支付”之后,系统要做的事情至少包括:创建支付单、校验账户状态、冻结或扣减余额、调用支付渠道、接收渠道异步回调、更新订单状态、生成交易流水、更新账户余额、可能还要走风控策略、发送通知、最后日终对账。这些动作如果全部挤在一个接口里,任何一个环节出问题,都会导致整个链路雪崩或者账目错误。
我在设计 financial-services 的时候,第一件事就是把这条业务链路拆成独立服务。核心服务包括:用户服务、账户服务、交易服务、订单服务、风控服务、通知服务。它们之间通过消息或RPC通信,彼此不直接依赖内部表。这样做的好处很直接:账户的记账逻辑和交易的订单逻辑互不干扰,风控规则的升级不影响支付主链路,通知服务的抖动也不会拖垮核心交易。
当然,拆服务本身不是目的,衡量的标准是“变更频率”和“故障隔离”。比如订单服务几乎每个迭代都在改,账户服务一年也动不了几次。把它们拆开之后,账户服务可以做到很稳定,订单服务怎么折腾都在自己的范围内。这比试图用一个服务撑住全局要靠谱得多。
1.2 技术选型背后的实际考量
技术栈的选择不能光看热闹。我当时给 financial-services 定的基础栈是:Java 17 + Spring Boot 3.x + Redis + MySQL + RabbitMQ。有人问为什么不用 Kubernetes 全套?因为我们团队规模不大,要控制运维复杂度,先把业务跑对,再谈容器编排。
服务间通信我选的是同步RPC加异步事件的混合模式。比如下单时需要实时扣款,这种强一致性的操作必须同步调用;而支付成功后的通知、风控埋点、流水归档,则走MQ异步处理。这里有个原则:所有影响资金状态的写操作,走同步调用并在本地事务内完成;所有不依赖实时结果的周边逻辑,走异步。这个原则保住了核心链路的稳定性。
数据库选型上,账户和订单数据必须用MySQL这类关系型数据库,因为事务和行锁是账务一致性的底线。Redis只用来做缓存、分布式锁和限流计数,绝不用Redis存资金余额。之前见过一个事故,同事为了性能用Redis存余额,结果缓存过期造成余额丢失,后来花了整整一周对账才把钱找回来。这个教训太深刻了。
1.3 目录结构:先理清边界再动手写
模块划分清楚了,项目目录也要呼应服务边界。我在重构时采用了Maven多模块结构,顶层目录大致如下:
financial-services/ ├── account-service # 账户与余额管理 ├── transaction-service # 交易流水、记账 ├── order-service # 支付订单、业务订单 ├── payment-channel-service # 支付渠道接入 ├── risk-service # 风控规则引擎 ├── notification-service # 站内信、短信、App推送 ├── settlement-service # 日终清算、对账 └── common-library # 公共工具、基础实体、API协议每个服务内部再按 controller/service/repository/domain 分层。很多同学问“这种分包会不会太啰嗦”?我的看法是:金融项目里,代码的清晰度直接关系资金安全。宁可多几个目录,也不能让一个贫血模型的Service方法塞满几千行。
2. 账户体系与账务核心:最不能出错的部分
2.1 账户模型设计的三层分离
账户体系是 financial-services 里最基础也最不能出错的地基。我在设计时把一个用户的资产拆成三层:用户信息层、账户档案层、账户资产层。用户信息层只存userId、手机号、实名状态等静态数据;账户档案层记录账户类型、币种、状态;账户资产层才保存余额、冻结金额、可用余额。
有人觉得“余额不就是个数字吗,一个字段搞定”。这是金融系统的大忌。余额永远要拆成“总余额、冻结金额、可用余额”三个维度,其中可用余额等于总余额减冻结金额。这样设计支持了最常见的业务场景:用户发起提现时先冻结一笔钱,审核通过后解冻并扣减余额。如果只存一个余额字段,就无法表达“这笔钱被占用但还没划走”的中间状态。
账户表的关键字段也分享一下:
CREATE TABLE account ( id BIGINT AUTO_INCREMENT PRIMARY KEY, account_no VARCHAR(32) NOT NULL COMMENT '账户号,对外展示', user_id BIGINT NOT NULL, account_type TINYINT NOT NULL COMMENT '1-用户主账户 2-冻结账户 3-平台账户', currency CHAR(3) NOT NULL DEFAULT 'CNY', balance DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '账面总额', freeze_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '冻结金额', available_balance DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '可用金额', status TINYINT NOT NULL DEFAULT 1, version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_account_no (account_no) );这里我特意加了version字段,用乐观锁方案做并发的余额变更。金融系统里“用户同时发起多笔支付”太常见了,如果没有锁机制,余额很容易被覆盖成错误值。用乐观锁更新的SQL是下面的形式,每次扣减都必须校验版本号与金额:
UPDATE account SET balance = balance - #{amount}, freeze_amount = freeze_amount + #{amount}, available_balance = available_balance - #{amount}, version = version + 1 WHERE user_id = #{userId} AND version = #{expectVersion} AND available_balance >= #{amount};如果更新行数为0,说明版本不对或者可用余额不足,这时候就要重试或提示用户。这个方案比在应用层加synchronized靠谱得多,因为多实例部署时synchronized根本没约束力。
2.2 复式记账与交易流水
账户表是“果”,流水表才是“因”。在 financial-services 里我要求所有资金变动必须写流水,而且是复式记账风格的流水。也就是说一笔支付成功,至少会产生两条流水:一条记账户的余额减少(借方),一条记平台收入账户的余额增加(贷方)。两边的金额相等,方向相反,日终对账时才能保证“有借必有贷,借贷必相等”。
这里放一个简化的流水表设计:
CREATE TABLE account_flow ( id BIGINT AUTO_INCREMENT PRIMARY KEY, flow_no VARCHAR(64) NOT NULL COMMENT '全局唯一流水号', account_no VARCHAR(32) NOT NULL, counterpart_account_no VARCHAR(32) NOT NULL COMMENT '对方账户', amount DECIMAL(18,2) NOT NULL, balance_after DECIMAL(18,2) NOT NULL COMMENT '变动后余额', direction TINYINT NOT NULL COMMENT '1-入账 -1-出账', biz_type VARCHAR(32) NOT NULL COMMENT 'RECHARGE/WITHDRAW/PAYMENT...', biz_order_no VARCHAR(64) NOT NULL, status TINYINT NOT NULL, created_at DATETIME NOT NULL, KEY idx_account_time (account_no, created_at) );写流水时一个最容易踩的坑是“先改余额再写流水”。如果余额改成功了,流水写入失败,那账就对不上了。正确的做法是放在同一个本地事务里:先插入流水,再更新账户余额,通过数据库事务确保原子性。而且更新余额的操作要放在最后,这样能缩短行锁持有的时间。
我在记账引擎里还引入了一个“冲正”机制。一旦发现某笔交易状态异常,不是直接删掉旧的流水,而是生成一笔方向相反的冲正流水,把余额调整回来。金融系统里永远不要做物理删除,所有纠错都靠新记录来抹平。这个习惯保留下来,审计的时候才能把每一分钱的来龙去脉说清楚。
2.3 账户幂等与防重入
“幂等”这个词很多同学听过,但真正落地的时候容易想简单。账户扣减的幂等不是加个distinct就完事,而是要建立业务幂等键。我们在交易服务里有一张幂等记录表,以“业务类型+业务单号”做唯一索引,支付回调进来时先insert幂等记录,insert成功才继续后续记账;insert失败说明已经处理过,直接返回成功响应。
这里有个小细节:幂等记录的插入要和首次业务处理放在同一个本地事务里,否则还是会出现重复处理的缝隙。我遇到过这样一个真实案例:支付渠道同时推送了两条回调,两条消息都查不到处理记录,于是都进入记账流程。虽然最后靠唯一索引挡住了一条,但累死累活排查半天,让我意识到幂等不能在业务代码里靠if判断,一定要靠数据库约束兜底。
3. 支付链路与分布式事务的取舍
3.1 分布式事务不是只能“二阶段提交”
聊到支付一定绕不开分布式事务。不少新手上来就想用Seata或XA二阶段提交,觉得只有这样才能保证一致性。但金融场景里的支付链路,往往涉及远程调用支付渠道,而外部渠道根本不可能陪你玩二阶段提交。真正的业界常见方案是“本地消息表 + 消息队列 + 定时对账”的最大努力通知模型。
我的实现是这样的:交易服务在本地事务里更新订单状态并记录一条“待确认消息”到本地表,同时调用支付渠道。渠道回调成功后,业务在处理接口里完成实际账务更新。如果中间任何一步挂了,定时任务会捞取“待确认消息”重新发起处理或主动查询渠道订单状态。最终一致性的窗口期通常只有几秒,配合日终对账把错误兜住。
在这里,“业务处理接口是否幂等”决定了质量,而“定时任务能否主动查单”决定了恢复能力。我强烈建议每个订单都要有一个渠道侧的查询接口,因为回调可能永远不来,只有主动查单才能发现真相。
3.2 回调处理流程的关键细节
支付渠道的异步回调和用户的实际操作之间有时间差,系统必须处理好这个状态机。我把支付单的状态设计成:CREATED(已创建)、PROCESSING(处理中)、SUCCESS(成功)、FAILED(失败)、CLOSED(已关闭)、REFUNDING(退款中)。回调到达以后,不能无条件更新为成功,要先校验订单状态和金额是否一致。
一个很容易被忽略的地方是回调金额校验。渠道回调里带了支付金额,必须和本地订单金额做比对,不一致就要告警并进入人工复核。因为曾遇到过渠道返回“成功”但实际扣了用户两次钱的情况,如果没有金额校验,这笔错误会直接写进账里,后面对账才暴露,处理成本高很多。
还有一个细节:回调处理接口要让渠道侧能够重复调用。支付渠道经常因为网络超时重发多次回调,如果我们的接口没有幂等保护,重复入账会造成资金损失。我在回调入口就按“渠道类型 + 渠道订单号 + 回调时间”生成幂等键,先落到Redis,再去做后续处理。
3.3 高并发下的限流与削峰
支付链路的流量天然有高峰期,比如秒杀、红包、转账活动。如果不做防护,账户服务很容易被打垮。我在 financial-services 的入口网关层分别对“用户维度”和“接口维度”做了限流。用户维度限制单个userId每分钟最多能发起的支付请求数;接口维度限制整个交易服务的QPS上限。
限流算法用的是令牌桶。相比固定窗口,令牌桶能允许一定的突发流量,更适合支付这种波峰场景。当然,这里也有一个参数选择的经验:QPS阈值不能拍脑袋,要基于压测数据。我们在生产环境对账户服务做过压测,单实例大概是每秒800笔稳定写入,所以我给网关的限流值定在了单实例每秒600,留了三分之一的安全余量。
除了限流,削峰也很重要。我的做法是把支付请求的“后续动作”全部异步化。比如通知、营销、流水归档都不在支付成功的主线程里做,而是发到MQ由消费者处理。图一时方便在主线程里调用辅助服务,会白白拖长支付耗时。优化之后,接口的平均延迟从800毫秒降到了200毫秒左右。
4. 风控与监控:看不见的保护网
4.1 多维风控规则,不迷信AI
金融系统离不开风控。可一谈风控就想到人工智能和复杂模型,这其实是个误区。很多业务阶段,简单规则引擎已经能覆盖大部分风险,尤其是刚开始没有训练样本的时候。我在 financial-services 里实现了一套轻量级规则引擎,支持在后台动态配置规则集,规则按“维度、运算符、阈值”组合。
实际落地的风控维度包括:单笔限额、单日累计限额、交易频次、设备指纹、IP风险等级、银行卡BIN风险标记、历史黑名单等。规则引擎按优先级顺序匹配,命中高风险直接拦截,中等风险走二次验证(短信或人脸),低风险放行。拦截请求要记录完整上下文,包括用户ID、金额、渠道、设备、IP、命中的规则编号,方便后续人工复核。
这套规则引擎刚上线的时候效果就很好。比如我们拦截了大量小额高频套现特征,靠的是“单日提现次数超过5次”加“提现金额相同”两条简单规则的组合。所以做风控不是越复杂越好,而是先管住明显的风险,再逐步叠加模型和策略。
4.2 规则灰度发布与效果评估
风控规则误杀率太高,用户投诉马上就来;漏报率太高,资损直接出现。解决这个矛盾的方法不是“两头堵”,而是灰度发布和回放测试。
我在风控服务里给每条规则加了“决策分”:命中规则后并不直接拦截,而是记录一条“疑似风险事件”,再把事件的真实结果(比如后续是否发生投诉、是否被渠道风控拒了)反馈回来。系统定期用这些样本做规则命中准确率统计。准确率高于85%的规则提升为“拦截”,低于50%的规则降级为“观察”。这个机制让风控规则可以放心迭代,也方便应对业务变化。
另外,风控服务必须做到故障降级。如果风控服务挂了,不能把支付主链路也拖死。我在调用风控这里做了熔断处理:风控服务连续错误超过阈值时,默认放行,同时记录一条“风控降级”日志。天下没有完美的风控,只有不影响核心业务的风控。
4.3 监控告警的四个黄金指标
最后聊监控。金融系统的监控和其他系统一样,把握四个黄金指标就够了:延迟(Latency)、流量(Traffic)、错误(Errors)、饱和度(Saturation)。我在 financial-services 里用 Prometheus 采集指标,Grafana 展示,Alertmanager 负责告警。
核心服务的告警规则可以按下面几个维度配置:
| 指标 | 告警阈值 | 说明 |
|---|---|---|
| 支付接口平均延迟 | > 800ms 持续5分钟 | 可能数据库变慢或下游故障 |
| 支付接口错误率 | > 1% 持续2分钟 | 可能接口逻辑异常或渠道故障 |
| 账户余额更新失败数 | > 每分钟10次 | 可能乐观锁冲突过多 |
| MQ消费堆积数 | > 10000条持续10分钟 | 说明消费者处理能力不足 |
监控光有数据还不够,告警一定要能“可行动”。每次告警都要有对应的SOP,比如看到消息堆积告警,第一件事是查看消费者日志是否报错,第二是扩展消费者实例,第三是暂时停掉非核心消息的消费。没有SOP的告警,最终会被大家无视,等于白搭。
5. 部署实战与故障排查记录
5.1 Docker化与CI/CD流水线
服务都拆好了,部署也得跟上。我在项目里给每个服务都写了Dockerfile,基础镜像统一使用eclipse-temurin:17-jre-alpine,尽量保持镜像体积小。CI/CD用GitLab CI,在merge request合并到主干后自动触发构建、单元测试、镜像推送,之后发布到测试环境,手工确认后再点生产发布。
发布策略我推荐“滚动发布”。比如一个服务有三个实例,发布时一次只更新一个,并且利用金丝雀权重把10%的流量切到新版本上观察几分钟。确认无错误率上升和延迟波动后,再把剩余流量全部切过去。金融服务半点容错都没有,切流量失败要能快速回滚。
这套CI/CD流程看起来简单,但是帮我们解决了一个很大的痛点:发布不再需要提心吊胆,所有操作的日志和版本都有迹可循。在没有这套流程之前,我们甚至出现过“谁改了什么没人知道”的局面,审计起来非常麻烦。
5.2 对账:最后一道防线
再完美的分布式系统也难免出现消息丢失、重复、乱序。日终对账是 financial-services 最后一道安全网。每天凌晨,定时任务从所有账户余额和交易流水中拉取数据,生成一份“总账报表”,再和支付渠道结算文件横向比对。
对账的核心逻辑就是比较两边金额汇总是否一致。比如:当天所有支付单的成功金额总和,必须等于渠道结算文件的金额总和;当天用户余额变动总额必须等于平台收入变动总额。不一致时,会把差异明细生成对账异常单,推送给财务和开发人员处理。
对账看似简单,但我吃过不少苦头。最典型的是“时间口径不一致”:渠道按支付成功时间出报表,我们按记账时间出报表,跨天交易的归属就会不同。后来我在对账模块里统一用渠道侧的成功时间作为分账口径,两边才彻底对齐。这类细节建议做对账的团队提前定义清楚,不然天天都能看到差异。
5.3 三个印象最深的线上事故
分享三个排查记录,你们以后大概率也会遇到。
第一个是“余额被扣成负数”。当时排查发现用户并发访问了两次提现接口,由于缓存中余额数据没有实时同步,两次校验都通过了,最后在数据库层用乐观锁兜底才发现有一笔更新失败。我们立刻改了提现接口,每次提交从数据库读取最新余额再校验,而不是用Redis缓存值。
第二个是“支付回调丢失”。用户支付成功但我们系统一直显示“处理中”,排查后发现MQ消费者在处理回调时抛了异常,消息被重试到进入死信队列。我们没有监控死信队列,所以压根不知道。从那以后我加了一条死信队列告警规则,凡是超过10条就触发紧急告警。
第三个是“风控规则误伤了正常用户”。有个用户的设备指纹因为网络代理原因频繁变化,被风控判定为“异常设备”,连续被拦截了三次支付。后来我在规则里增加了“设备指纹变化但历史订单正常”的白名单修正逻辑,才解决。所以说,风控规则一定要多考虑真实设备环境的复杂性,不能只看单点特征。
6. 写在最后的几点体会
做 financial-services 这个项目,跨度从账户设计一路到部署监控,很多东西都是交过学费才慢慢明白的。我个人的核心体会是:金融系统最值钱的不是用了多新的技术,而是严谨的流程和兜底机制。哪怕你只解决了一个“回调重复”的小问题,背后省下的资损可能比引入几个新框架带来的价值都大。
如果你也想做一个类似的服务,我的建议是先画清楚业务状态机,再设计账户流水,最后才写接口。多花时间在对账和幂等上,远比纠结用微服务还是单体更重要。实在遇到搞不定的账务问题,就回到最原始的“借贷必须相等,流水一分不能缺”去推演,基本都能找出突破口。这套系统后续继续扩展也只是在既有框架上做加法,底座稳了,上面怎么长都不怕。