1. 项目概述:为什么要做“financial-services”这套东西
做金融服务的业务系统,跟做普通互联网应用完全不是一回事。普通应用挂了,用户刷新一下还能用;金融服务系统挂了,光是合规问责就能让你焦头烂额。我最初接手这个代号为“financial-services”的项目时,团队对它的定位就四个字:稳定、可追溯。它不是某个单一业务模块,而是一整套面向金融业务场景的基础服务体系,覆盖账户、交易、风控、清算对账这几个核心链路。
先说清楚它能解决什么问题。做过支付或者信贷系统的人都有体会,业务发展到一定程度,最痛苦的不是功能开发,而是各个系统之间口径不一致。账务系统说这笔交易成功了,风控系统却说这笔交易有风险需要拦截,清算系统那边又因为两边数据不一致对不上账。这套“financial-services”项目的核心目标,就是把这些金融通用能力统一收敛到一个中台层,对外提供标准化的服务接口,对内实现全链路的数据可追踪。
适合谁来参考这套方案?如果你是做互联网金融、电商支付、信贷风控、或者传统银行数字化转型的技术负责人或核心开发,这篇内容可以给你一个比较完整的落地参考。即使是刚入行的同学,也可以通过这篇文章了解金融级系统在架构设计时到底在关注什么。
整个项目的落地过程中,我踩了不少坑,也积累了一些从文档里学不到的实操经验。这篇文章不打算讲那些虚的理论,全部是实际干活时总结出来的东西,从整体设计思路到核心模块实现,再到上线后的运维排查,争取一次讲透。
2. 整体方案设计:金融级系统的三层架构思路
2.1 为什么不能直接在一个单体应用里做金融服务
很多团队刚启动金融业务时,图省事直接把账户、订单、支付、对账全部写在一个应用里。业务量小的时候确实没毛病,但金融业务有它自己的特殊性,最典型的是资金安全要求极高和对账逻辑极其复杂。一旦交易量上来,任何一个子模块的故障都可能拖垮整个链路,而且出了问题之后,由于各模块共用数据库,根本无法快速定位是哪个环节产生了脏数据。
所以“financial-services”在最初设计时就确定了微服务化的方向。我采用了一种相对务实的微服务拆分方式,不是按照简单的“按业务域拆”,而是按照“稳定程度”和“变更频率”两条轴线来划分:
- 高稳定低变更的核心账务服务,独立部署,禁止随意改动
- 高变更低稳定的渠道接入层,频繁迭代,与核心服务彻底隔离
- 中间层的风控决策、额度管理、交易网关,做逻辑隔离但共享部分基础数据
这样做的好处很直接。渠道接入层可能一周发版两三次,而核心账务服务可能半年都不会变一次。如果它们在一个进程里,每次渠道层的发布都要重新启动整个应用,核心账务服务就被迫跟着承担发布风险。拆开之后,核心链路非常稳定,渠道层的频繁迭代也不会影响资金安全相关的主流程。
2.2 核心模块划分与数据流走向
整个“financial-services”项目划分了六个核心子模块,数据流转方向是单向依赖的,不允许反向调用:
| 模块名称 | 核心职责 | 依赖关系 |
|---|---|---|
| 账户中心 | 管理客户账户状态、余额、冻结/解冻 | 无底层依赖 |
| 交易引擎 | 处理交易路由、交易状态机流转 | 依赖账户中心 |
| 风控决策 | 实时风险评分、规则拦截 | 依赖账户中心和交易数据 |
| 支付网关 | 对接外部渠道,统一报文转换 | 依赖交易引擎 |
| 清算对账 | 渠道对账文件解析、差错处理 | 依赖支付网关流水 |
| 通知中心 | 交易结果异步通知、回调重试 | 依赖交易引擎 |
举例说明数据的走向。一笔用户发起的支付请求,首先进入支付网关,网关只做协议转换,然后调用交易引擎创建交易单。交易引擎创建成功后,调用风控决策服务做实时检查,风控返回通过后再调用账户中心完成资金扣减。扣减成功之后,交易引擎更新交易状态为成功,同时发送一条消息到通知中心,通知中心负责把结果推送给商户。所有环节都产生标准化的流水事件,清算对账模块在T+1日通过流水与渠道文件进行核对。
这套设计在逻辑上画起来很简单,但真正落地时最考验人的是交易引擎的状态机设计。支付交易的状态不能只简单分成成功和失败,至少要有初始化、处理中、成功、失败、部分成功、已退款、已关闭这几个状态,而且每个状态之间的转换条件必须严格定义。我在后面会专门展开讲这块。
3. 核心模块的实操细节:从账户到风控的完整落地
3.1 账户中心的“三户模型”设计
账户模型是金融系统的地基。“financial-services”项目参考了银行核心系统中常用的“三户模型”,也就是客户、账户、额度三者的分离。
最初我们踩过一个大坑,把客户基本信息、账户余额、可用额度全部存在一张表里,结果遇到一个客户名下多个账户的场景,每次计算总资产都要全表扫描,而且冻结金额和可用余额的计算逻辑混乱,经常出现账实不符。后来完全重构为三张独立的表:
- 客户表:只存身份信息,身份证号、姓名、手机号等,不包含任何资金相关字段
- 账户表:关联客户ID,记录账户类型、账户状态、币种、账面余额
- 额度表:关联账户ID,记录授信额度、已用额度、可用额度、冻结额度
关于余额的计算,这里分享一个非常重要的经验。账面余额和可用余额必须分字段存储,不能靠“账面余额减去冻结金额”实时算出来。如果实时计算,在高并发场景下会出现严重的性能问题,而且一旦出现数据不一致,排查起来非常困难。正确的做法是:每次资金变动都会通过事务同时更新账面余额和可用余额,两个字段的更新在同一个数据库事务里完成,再加一张流水表记录每一笔变动的前值后值。
资金操作的核心SQL逻辑大概是这样的:
-- 扣减金额,带条件更新防止并发超扣 UPDATE account SET available_balance = available_balance - #{amount}, book_balance = book_balance - #{amount} WHERE account_id = #{accountId} AND available_balance >= #{amount} AND status = 'ACTIVE';这个SQL的精髓在于金额大于0这个条件。如果影响行数为0,说明余额不足或者账户状态异常,程序直接抛异常终止,不允许后续流程继续。这样做可以天然防住并发情况下同一个账户同时被多笔交易扣款的超扣问题,而不是靠数据库锁。
3.2 交易引擎:状态机是核心中的核心
交易引擎是整个“financial-services”最复杂的模块。每一笔交易从生到死都要经历明确的状态流转,我们直接引入了状态机框架,而不是用if-else硬写状态判断。
以一笔支付交易为例,状态流转如下:
- 交易请求到达,创建交易单,状态为“初始化”
- 调用风控预检,通过后状态变为“处理中”
- 调用账户中心扣款成功,状态变为“成功”
- 如果扣款成功但后续通知失败,状态保持“成功”,但通知状态单独标记为“待重试”
- 任何一步异常,状态变为“失败”,同时发起自动冲正处理
这里最难处理的是“部分成功”的边界场景。举个例子,用户支付100元,账户扣款成功了,但渠道商那边返回超时,实际上渠道已经扣款成功。这种状态如果处理不好,就会造成资金不一致。我们的方案是引入对账补偿机制:交易状态先标记为“未知”,写明是“扣款成功但渠道确认未知”,交由T+1日的对账任务去确认最终状态。这也是为什么我说交易状态不能只有成功和失败,这套状态机在代码层面用配置驱动,每笔交易的状态变更都会写入一张流水表,完整记录状态迁移路径,便于后续审计和问题定位。
3.3 风控决策:规则引擎加实时特征计算
风控模块在“financial-services”里承担着交易安全阀的角色。这个模块的设计目标不是拦截所有可疑交易,而是在最小化误杀的前提下,把高风险交易挡在门外。
我们采用了两层风控架构。第一层是规则引擎,处理实时性要求最高的黑白名单和固定规则,比如单笔限额、单日累计限额、黑名单手机号、IP异常等;第二层是模型引擎,基于机器学习模型做实时风险评分,模型输入是过去30分钟的特征聚合结果。
规则引擎的实现我们选用了Drools,虽然学习曲线有点陡,但胜在规则热更新非常方便。业务人员在管理后台配置好规则后,规则文件推送到风控服务,服务在内存中完成规则重新编译加载,整个过程不需要重启应用,对于大促等特殊时期的临时限额调整非常实用。
一个典型的限额规则代码示例:
rule "SingleTransactionLimit" when $tx: Transaction(amount > 50000) then $tx.setRiskLevel("HIGH"); $tx.addRiskTag("单笔超限额"); end实时特征计算这块使用的是Flink做流式计算。每笔交易事件实时进入Flink计算窗口,统计当前用户在5分钟、30分钟内累计交易金额和次数,并把结果写入Redis。风控决策时直接从Redis获取特征值,一般控制在5毫秒内拿到全部特征数据。
说实话,整个项目里,风控模块的沟通成本是最高的。风控策略同事和技术团队的思考方式差异很大,策略同事关注的是规则的覆盖率和误杀率,技术团队关注的延迟和吞吐量。后来我们建立了定期的策略回测机制,每次规则调整前,先用历史交易数据回测,确保新规则不会大面积误伤正常交易。
4. 清算对账与系统韧性的工程落地
4.1 对账系统的“差额为什么总是查不出来”之痛
所有金融系统都必须过对账这一关。对账的核心诉求,就是拿内部的交易流水与外部渠道返回的结算文件逐笔核对。但实际做对账最痛苦的不是对不上,而是两边都能对上却存在隐匿的差异,比如渠道文件里有一笔手续费扣了,我们这边没记录;或者我们系统里某笔交易是成功的,渠道返回的文件里根本没有这笔记录。
在“financial-services”项目中,对账模块采用了分阶段的核对机制:
- 笔数核对:先统计两边各自的总笔数和总金额,如果这两个大数对不上,直接进入差异明细比对
- 逐笔核对:以渠道文件为基准,按第三方交易流水号关联内部交易流水号,找出两边不一致的记录
- 差错处理:对无法自动匹配的记录,生成差错单,进入人工处理流程或自动冲正流程
这里有个从实操中总结的经验:内部流水号和渠道流水号一定要分开存放。很多团队图省事,直接把内部流水号作为渠道流水号存起来,结果渠道那边因为重试导致流水号变化时,我们这边对账就彻底对不上了。正确的做法是,内部流水号由我们系统自行生成,渠道流水号关联渠道返回的字段,两个字段分别是独立存储的。
对账文件的解析也有讲究。渠道方返回的文件格式五花八门,有CSV、Excel、固定长度的TXT等等。一开始我们为每种格式写解析器,维护成本极高。后来统一做了一层适配器,将不同格式的文件全部解析为标准Java对象的列表,后续的核对逻辑只针对标准对象,新增渠道时只需要写一个适配器,不需要动核对逻辑。
批次对账任务推荐用分布式任务调度平台,因为每日对账的数据量大,逐笔核对在单机上跑经常出现内存溢出。我们当时用的方案是分片处理,把一天的数据按渠道维度拆分成多个子任务,每个子任务处理一个渠道,并行执行,最终汇总各分片的结果生成对账报表。
4.2 恢复能力:冲正、重试与幂等设计
金融系统不能假设所有外部调用都会成功,所以必须在下层实现“带补偿的恢复能力”。
首先是超时重试。调用外部支付渠道扣款时,如果第一次请求超时了,我们不能立刻判定失败,因为可能渠道那边实际已经扣款成功了。这种情况下的标准做法是查询订单状态,而不是直接重试扣款。如果查询结果也是超时,就标记为“未知状态”,交给对账任务去确认。因为如果一个超时请求实际扣款成功,而我们又发起一笔新的扣款请求,用户会被扣两次钱,这是原则性事故。
其次是幂等设计。所有涉及资金操作的接口都必须支持幂等,客户端的重试请求如果带着相同的业务流水号,服务端要直接返回上次的处理结果,而不是重复处理。我们通过数据库的唯一索引来兜底实现幂等,交易流水号在交易表上建唯一索引,重复插入时数据库抛异常,程序捕获异常后去查询已存在的交易记录返回。
重试机制也需要注意积压问题。如果外部渠道响应很慢,重试队列里会堆积大量任务,要给每个重试任务设置最大重试次数,超过次数的进入死信队列,触发告警人工介入。我见过太多系统因为重试任务无限堆积,最终把下游数据库连接池打满,导致整个应用雪崩的案例。
4.3 缓存与数据库一致性的务实取舍
在高并发金融场景下,不可能所有数据都直接查数据库,缓存是绕不开的手段。但缓存和数据库的一致性问题如果不处理好,同样会出大事故。
拿账户余额来说,我们采用的是“数据库为主、缓存为辅”的策略。读余额时优先读缓存,缓存没有时查数据库,并回填缓存;写余额时直接操作数据库,然后删除缓存。这里选择删除缓存而不是更新缓存,是为了避免并发下先更新缓存再更新数据库时,两条更新语句顺序不一样导致缓存里存了旧值的问题。
不过需要注意,删除缓存也可能存在缓存击穿的风险。某个热点账户的缓存刚被删除,瞬间大量读请求直接打到数据库,数据库压力剧增。我们的方案是将热点账户标记出来,对这些账户的缓存删除操作改为延迟双删,先删除缓存,再更新数据库,隔一段时间再删除一次缓存,确保任何并发场景下都不会读到旧值。
5. 上线后的常见问题与排查技巧实录
5.1 问题速查表:我遇到过的那些经典故障
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 交易全部超时 | 数据库连接池被占满 | 查看连接池活跃数,慢SQL日志 | 扩容,优化慢SQL,增加熔断 |
| 对账不平且找不到原因 | 内部流水号和渠道流水号映射错误 | 对比两边原始流水,查映射关系 | 重建映射,增加校验任务 |
| 风控规则变更后误杀率飙升 | 规则条件配置错误 | 检查规则生效版本,跑策略回测 | 快速回滚规则版本 |
| 账户余额莫名变负 | 并发超扣,条件更新没生效 | 查看账户流水,分析扣款顺序 | 重新设计扣款SQL,加余额条件 |
| 缓存中余额为旧值 | 缓存删除失败 | 查看当日删除缓存错误日志 | 启动延迟双删,并做补偿清理 |
| 渠道回调重复通知 | 幂等设计缺失 | 查交易日志,确认重复消费 | 增加唯一索引和去重表 |
5.2 一个典型的数据库连接池耗尽排查过程
有一次线上告警,支付成功率突然大幅下跌。先看监控面板,交易引擎的P99延迟从80毫秒飙到了4秒多,数据库活跃连接数直接打满。进一步追查,发现是一个外部渠道的响应时间从200毫秒恶化到了8秒,而这笔调用的线程一直在等待渠道返回,持有的数据库连接无法释放。请求持续涌入,新的请求拿不到数据库连接,于是排队等待,整个支付链路都被拖垮了。
这个问题表面看是渠道方的问题,实际暴露了我们的设计缺陷:调用外部渠道等待响应的过程中,数据库连接被白白占用。后来我们做了两项改造,一是把外部渠道调用改成异步化,发起调用后立刻释放数据库连接,通过回调或轮询结果的方式更新交易状态;二是给所有外部调用加上了超时熔断,超过2秒直接快速失败,不再继续等待。
这个案例给我留下的印象很深。做金融系统,不能只盯着自己的代码逻辑,还要考虑外部依赖出问题时系统的整体韧性。外部渠道是不可控因素,但可以让它造成的爆炸半径最小化。
5.3 性能压测必须要注意的几点
“financial-services”在上线前做了三轮压测,这里分享几个压测时容易忽略的细节问题。
压测数据一定要用真实的脱敏数据,不能自己造一批非常规整的数据。因为真实数据分布不均匀,少数热点账户会承担大部分流量,如果压测数据都是均匀分布的,就测不出热点账户引起的性能问题。
压测时数据库慢查询阈值要调到比较敏感的水平,比如超过100毫秒的SQL全部记录下来。金融系统的日常请求量很大,平时很难发现的问题在压测中会集中暴露。当时我们就通过压测发现了一个索引缺失的SQL,平时数据量小看不出来,压测时执行时间从30毫秒恶化到3秒,加了一个联合索引后问题彻底消失。
还有一个就是压测时要观察主机的CPU、内存、磁盘IO、网络带宽等基础设施指标,不能只看应用的延迟和吞吐量。有时候应用的性能瓶颈根本不在应用层,而是磁盘IO已经满了,这时候换更强的CPU也无济于事。
6. 后续扩展方向与个人感受
“financial-services”这套项目上线运行之后,生产环境表现比较稳定,核心链路全年可用性保持在99.95%以上。不过金融业务的需求是不断演进的,目前团队在做两个方向的扩展。
第一个方向是实时对账。目前的T+1日对账虽然稳定,但在一些大额交易场景下,用户和业务方都希望能够更快地确认资金状态。我们正在把对账周期从T+1缩到T+0,也就是当天交易在半小时后进行准实时对账。这需要改造渠道方的文件推送机制,同时增加准实时批量查询渠道交易状态的调度任务。
第二个方向是算法风控的深化。规则引擎解决了已知的风险问题,对新出现的风险模式覆盖有限。我们正在把用户行为序列数据引入特征计算,比如通过用户的历史操作路径来判断当前交易是否存在异常。这个方向的数据工程量不小,但是对降低欺诈损失的效果很明显。
做金融系统这几年,我个人最大的体会是:不要炫技。金融系统不需要用最前沿的架构,但一定要用最稳的方案。很多团队喜欢一上来就上Service Mesh、单元化部署这些看起来很牛的方案,但如果没有足够的运维能力和故障演练经验,复杂架构反而会在出问题时成为排查的障碍。先把基础能力打牢,把状态机设计清楚,把对账和幂等做好,把监控告警完善,这套系统的可靠性自然就上来了。架构上的“好”,是在一次又一次的故障中磨出来的,不是设计出来的。