1. 项目到底在做什么:金融服务平台不只是写接口
"financial-services" 这个名字看起来挺宽泛,但如果是一个真实落地的项目,它背后必然对应着具体的业务实体。我这次做的这套系统,定位是为一家持有融资担保牌照的助贷机构搭建核心金融服务平台,覆盖从进件审批、合同签署、放款、还款、代扣、对账到贷后管理的完整资金链路。说白了,这套系统要回答三个问题:用户的资金怎么进来、怎么出去、账是怎么平的。
做过这类项目的人都懂,它跟普通互联网业务系统最大的区别在于:每一个动作都跟真金白银挂钩,每一次状态流转都必须有据可查。普通电商系统订单状态丢了还能靠用户重新下单补救,但金融服务平台的交易状态如果错了,轻则资金差错,重则监管问责。所以整个项目的核心不是"功能多",而是"稳"和"可追溯"。
这个项目适合谁参考?一类是要从零搭建类似资金系统的技术团队,另一类是已经在做但踩过坑、想看看别人怎么处理一致性和安全问题的人。我在这篇文章里不会只讲架构图,而是把选型原因、实现细节、避坑经验全部摊开讲,你直接可以拿来对照自己的项目。
2. 架构选型:微服务不是炫技,是为了把风险隔离
2.1 为什么要拆服务,而不是一个大单体
我见过不少金融类小团队,业务量不大,硬要拆十几个微服务,结果运维成本比业务开发还高。反过来,我也见过一笔放款链路直接从下单到账务全部写在一个模块里的,上线三年每次发版都胆战心惊。
这次我们的取舍逻辑很简单:按资金生命周期拆,不按团队组织拆。最终拆成六个核心服务:
| 服务名 | 核心职责 | 关键依赖 |
|---|---|---|
| gateway | 统一入口、路由、限流、鉴权 | Nginx、Spring Cloud Gateway |
| customer | 客户信息、账户体系、额度管理 | MySQL、Redis |
| loan | 借款申请、合同生成、放款指令 | MySQL、消息队列 |
| payment | 代扣/代付、渠道对接、回调处理 | 消息队列、HTTP渠道 |
| ledger | 会计记账、分账、总账余额 | MySQL分库、分布式事务 |
| risk | 规则引擎、黑名单、风控决策 | Redis、规则脚本 |
拆完以后最直观的感受是:支付渠道抖动导致回调堆积的时候,payment 服务自己扛,不会拖垮其他服务;账务系统需要核对时,ledger 单独做快照和试算平衡,不影响正在进行的交易。风险隔离才是拆服务的第一目的,至于水平扩容、独立发版这些,都是附带收益。
2.2 技术栈选型背后的理由
主语言选 Java 而不是 Go 或者 Python,不是因为 Java 有多先进,完全是因为金融行业的中间件生态和人才储备。分布式事务方案 Seata、消息中间件 RocketMQ、规则引擎 Drools,这些在 Java 生态里最成熟,出了问题能找到的人多,踩过坑的案例也多。
消息队列我们最终选了 RocketMQ 而不是 Kafka,原因很实际:Kafka 的核心优势是吞吐量,但金融场景更需要的是事务消息和消息轨迹。RocketMQ 的事务消息可以保证本地事务和消息发送的原子性,这在放款、还款这类必须"要么都成功、要么都失败"的场景里太关键了。Kafka 虽然也能用事务 API 实现类似效果,但配置和运维复杂度高出不少,没必要为了用而用。
存储层是 MySQL 分库分表双写 Redis。为什么不做读写分离?因为金融交易的数据必须每次读主库,读从库的延迟哪怕只有几十毫秒,在提现确认、余额查询这种场景都可能让用户看到不一致的数据。Redis 只用来扛热点,比如用户额度实时查询、渠道限流计数,不做持久化账本。
注意:账务数据不要用 Redis 缓存回写 MySQL 的方案。金融系统的账务核心必须落库以后才算数,缓存只能当读加速,一旦缓存和 DB 不一致,对账就永远对不平。
2.3 部署形态和容量评估
生产环境我们部署在三台物理机上的 Kubernetes 集群,每个服务双副本,支付服务和账务服务做了三副本。容量评估是按极端峰值来的——正常情况下单日交易量约 20 万笔,但年末大促的时候,放款高峰会到每秒 300 笔以上。
压测数据当时给了我一个教训:数据库连接池不要按平均值配。最开始账务服务连接池配了 50,压测时一追高峰,连接直接打满。后来改成最小 20、最大 200,按实际估算每秒事务数除以单连接处理能力来算,才稳住了。计算逻辑不复杂:单条 INSERT 加 UPDATE 在 2 毫秒内完成,单连接每秒能处理约 500 笔,那 200 个连接理论支撑每秒 10 万笔,已经远超峰值需求,但连接池必须留冗余,因为慢 SQL、锁等待都会拖长单连接占用时间。
3. 交易链路的设计:从一笔放款看懂全流程
3.1 放款链路的状态机设计
一笔放款从用户确认借款到资金到达用户银行卡,中间要经过十几个状态节点。我司的具体实现中,状态机的核心节点是这样设计的:
CREATE -> APPROVING -> APPROVED -> CONTRACT_SIGNING -> LOAN_DISPATCHING -> LOAN_SUCCESS -> REPAYING -> SETTLED -> LOAN_FAILED -> LOAN_CLOSED每个状态之间流转必须经过明确的命令,不允许直接跳变。比如从 LOAN_DISPATCHING 不可能直接到 REPAYING,必须先确认渠道返回成功、账务记账完成,才能推进到下一状态。
这个状态机的核心指导原则是:状态是流水线,不允许倒退。如果渠道返回失败,链路就走到 LOAN_FAILED,后续通过重试任务去恢复,而不是把状态改回 CREATE 重新跑。
3.2 幂等设计与重复回调处理
这是金融系统最容易翻车的地方。渠道回调、用户重复点击、系统重试,任何一个环节不做好幂等,资金结果就会重复入账。
我们的做法是给每笔交易生成全局唯一的transaction_id,在数据库里建唯一索引。所有关键操作(放款、还款入账、代扣)都先查这个 ID 有没有处理过,处理过直接返回上一次的结果。
具体加幂等控制的伪代码如下:
public LoanResult dispatchLoan(LoanDispatchRequest request) { // 先查幂等表,加分布式锁防并发 IdempotentRecord record = idempotentService.findById(request.getTransactionId()); if (record != null) { return LoanResult.of(record.getResultData()); } // 开始本地事务:记录幂等状态 -> 下发放款指令 -> 更新状态 return transactionTemplate.execute(status -> { idempotentService.markProcessing(request.getTransactionId()); try { DispatchResult result = paymentChannel.dispatch(request); if (result.isSuccess()) { loanStateMachine.transit(request.getLoanId(), LOAN_SUCCESS); idempotentService.markSuccess(request.getTransactionId(), result); } else { loanStateMachine.transit(request.getLoanId(), LOAN_FAILED); idempotentService.markFail(request.getTransactionId(), result); } return result; } catch (Exception e) { status.setRollbackOnly(); throw new BizException("放款处理失败", e); } }); }这个方案里有几个细节比较重要:
第一,幂等记录要先于业务数据落库。如果先把状态机状态改了再记幂等,一旦中间日志写失败,系统重启以后就不知道这笔交易到底处理了没。
第二,幂等记录的锁要短命。一开始我用 Redis 分布式锁,锁粒度过大导致压测时大量请求排队。后来改成数据库唯一索引加INSERT ... ON DUPLICATE KEY UPDATE,性能明显提升,因为锁范围从进程级别降到了数据库行级。
3.3 分布式事务:本地消息表和 TCC 的取舍
账务、贷款状态、渠道结果同步是强一致要求,但我们没有把整个链路包进一个全局事务,那样性能不可接受。最终方案是核心强一致用 Seata TCC,非核心最终一致用本地消息表。
TCC 用在"放款"这一最关键环节:
Try:检查余额并冻结(客户可用额度) Confirm:扣减冻结额度,渠道放款,账务入账 Cancel:释放冻结额度,登记失败流水TCC 的问题是编码量大,每个接口都要写三套逻辑。所以非核心环节,比如合同生成后的提醒通知、还款日预提醒,全部走本地消息表,通过 RocketMQ 异步发送,失败就定时重推。
注意:RocketMQ 的事务消息虽然能解决"业务成功但消息未发"的原子问题,但消息消费方的幂等依然要自己做。消息所有消费端的处理函数,第一步永远是查去重表。
4. 账务系统的设计与实现:记账平了才叫对
4.1 复式记账与总账分账
很多做业务系统的人容易把"流水表"当成账务系统,其实差的远了。流水表只是记录"发生了什么事",账务系统要回答"这笔钱从哪来、到哪去、账户余额是否正确"。所以我们的 ledger 服务核心是一套复式记账模型。
每一笔资金变动必然对应两行记录:借方 + 贷方。放款成功时:
| 科目 | 借贷方向 | 金额 |
|---|---|---|
| 应收账款-用户借款 | 借(增加) | 10000 |
| 银行存款-放款账户 | 贷(减少) | 10000 |
记账过程用数据库事务包裹,保证借方和贷方同时成功。每日终了还有一个总账试算平衡任务,把所有账户科目加总,校验借方总额等于贷方总额,任何一笔不平就报警。这个功能上线半年,曾经抓出过 2 笔因为代码并发 Bug 导致的漏记,让我觉得这个设计值回了成本。
4.2 分库分表与流水归档
账务流水表是增长最快的表,单季度就能积累数百万行。我们按用户 ID 哈希分 64 个库,每个库 128 张表。分表键不能改,所以查询一律带 userId,禁止不带任何条件扫描全表。
历史流水超过一年的定时归档到冷表,但查询需要能查到——我们给每个用户维护了一张"开账节点表",记录该用户流水从哪张表开始查。这方案比 ES 全文检索省事,毕竟金融查询基本都是"某个用户的某段时间流水",维度很固定。
4.3 每日对账的三个层次
对账是整个账务系统里最磨人但也最重要的部分。我们的对账机制分三层:
第一层,渠道对账:从支付渠道拉取结算单,与本地 payment 流水比对,找出渠道有而本地无、本地有而渠道无的差异记录。
第二层,内部账实核对:校验用户账户余额与账务系统科目余额是否一致。这一层能发现代码 Bug 或者脏数据导致的账户余额漂移。
第三层,总分核对:将流水总表的当日发生额与总账科目发生额汇总比对。
对账一定要做成自动触发、永不跳过。最开始我们按天跑,后来发现一旦某天对账失败,第二天补对上一天的数据往往要花好几倍时间。改成每小时跑一次轻量核对,每天做一次全量核对,出问题的窗口大大缩小了。
5. 金融级安全:权限、加密、审计一个都不能少
5.1 数据加密与脱敏的三层设计
金融平台的信息安全有两个维度:外部防攻击、内部防泄露。内部防泄露往往比外部攻击更容易被忽视。
我们的做法是字段级加密,手机号、身份证号、银行卡号、联系地址全部使用 AES-GCM 加密后再落库。加密密钥分层管理:数据库列加密用业务密钥,业务密钥由 KMS 统一托管,定期轮换。应用层读取后,在返回前端前进行脱敏,姓名显示成张**,手机号显示成138****1234。
这里有一个容易踩的坑:加密字段不能建普通索引。为了既加密又能查询,我们单独维护了一个identifier_mapping表,存加密后的哈希值(HMAC),查询时先用同样的 HMAC 算出哈希值再走索引。虽然多了一次表查询,但避免了全表解密。
5.2 操作审计与防篡改日志链
监管对金融系统的日志审计要求是:谁在什么时间对什么数据做了什么事,且记录不能被事后篡改。
我们用的是防篡改日志链方案:每条审计日志生成后,把上一条日志的摘要拼接进去,算出一个新的哈希,存到日志表的prev_hash字段。任何人想改前面某条日志,后面所有哈希全部对不上,一查便知。
def generate_audit_log(prev_hash, operator, action, target): content = f"{prev_hash}|{operator}|{action}|{target}" return hashlib.sha256(content.encode()).hexdigest()配合每日巡检任务,扫描是否有哈希断裂的日志段。这方案不能防内鬼删库,但能防"改数据不留痕",配合数据库 binlog 同步备份,基本覆盖了审计要求。
5.3 越权防护与敏感操作二次校验
越权问题是支付、账务类系统的高发安全漏洞。我们全局封装了一个数据权限过滤器:任何 API 请求必须携带当前登录用户 ID,所有查询和操作必须带上该用户的 tenant_id,SQL 强制拼接数据权限条件。
敏感操作比如放款、修改利率、代扣协议变更,还需要二次校验:用户在操作前输入短信验证码和支付密码,服务端校验通过后生成一个短时效的operation_token,业务接口必须同时校验该 token 才允许放行。这个机制上线后堵住了不少社工登录后的批量操作风险。
6. 上线以来最值钱的避坑经验
6.1 时间漂移导致的金额差错
这个坑说出来都觉得低级,但它确实真实发生过:应用服务器的系统时间比数据库快了两分钟,导致账务流水的记账日期错位。每日对账时发现 3 笔放款被算到了前一天,排查了整整一个下午。从那以后,所有应用容器在启动脚本里强制跑一次 NTP 时间同步命令,并且监控服务检测到时间偏移超过 30 秒就自动告警并摘除节点。
时间处理还有一条铁律:业务所有时间字段一律存 UTC 时间戳,展示层再转本地时区。因为渠道、核心账务、数据库可能分布在不同的时区,存储层统一 UTC 才能保证排序和区间查询不会错。
6.2 渠道异步回调的乱序问题
支付渠道的异步回调不是严格按照业务顺序到达的。我们遇到过:一笔还款的代扣操作,用户先发起还款成功,紧接着又主动还款——结果主动还款的回调先到,代扣成功的回调后到,系统按回调顺序处理,把用户账户状态从"已还清"改成了"多还一笔"。
解决方案是给每笔还款请求赋予递增的biz_seq,回调处理前先比较该请求的 biz_seq 是否大于当前已处理的最后 seq,只有严格递增才处理,否则丢弃并记录。这套机制我们称之为回调单调性校验,是所有异步结果处理的必修课。
6.3 缓存和数据库双写的坑
额度查询走了 Redis 加速,但额度扣减是一个典型双写场景。一开始用"先更新 DB 再删缓存",高峰期会出现片刻的脏读——一个用户在放款成功后立刻查余额,可能读到旧值。
后来改成"延迟双删"加版本号:更新 DB 后设置一个短 TTL,请求读取时先对比缓存中的版本号和 DB 最新版本号,不一致就回源查询。虽然性能从 5 毫秒降到 8 毫秒,但换来了一致性安全。在金融场景,宁可慢一点,不能错一点。
经验:凡是涉及金额、余额、状态这类强一致数据,不要缓存,或者只做很短的过期时间(5~10秒)。真正能扛住的方案是数据库行锁 + 乐观锁版本号。
6.4 批量代扣脚本的锁表噩梦
代扣场景的典型操作是跑批:从数据库读取所有到期还款的借款记录,逐笔发起代扣。上线前测试量小没发现问题,第一次真实跑批的时候,跑了 50 万条借款记录,把所有还款计划的 UPDATE 语句全部堆到一个表上,直接导致数据库连接数飙到上限,线上其他服务全部请求失败。
解决方式:跑批必须分页 + sleep 控制速率,每处理 500 条主动休眠 1 秒;所有批量 UPDATE 必须走主键索引,不允许UPDATE table SET status=xx WHERE status=xx这类不带主键条件的全表更新。同时做限流:处理速率控制在每秒最多 200 条,宁可跑批多花几分钟,也不能把数据库打挂。
6.5 清理历史分支的架构债务
项目上线半年后,我发现最痛苦的不是新功能开发,而是各种临时补丁、兼容分支把代码搞得很乱。比如为了兼容旧版本回调数据,loan 服务里堆了三版状态迁移逻辑,每次改动都要翻好几个分支的兼容代码。
后面专门花了一个迭代周期做清理:把旧数据的迁移脚本一次性跑完,代码层只保留当前唯一的状态流转路径,所有历史兼容逻辑全部移除。这个决定后来帮我省了大量时间——每一次改状态机都只需要看一条线,而不是三套分支。
7. 一些零散但非常要命的操作细节
7.1 配置管理一定要用配置中心
金融服务的很多开关不是程序常量,而是运行时可调的配置,比如渠道超时时长、代扣单笔限额、风控规则阈值。初期这些配置全部写在 application.yml 里,每次调整都要发版重启,耽误业务响应。
后来统一迁移到 Nacos 配置中心,业务配置、限流配置、开关配置全部动态刷新,并且变更留痕。上线之后最快的一次调整是渠道当天突发了网络抖动,一分钟内在配置中心把所有渠道调用超时从 3 秒调成 8 秒,无感知救回了一次批量放款。
7.2 监控告警必须覆盖到业务层面
基础监控(CPU、内存、磁盘)做完了,不等于监控做完。我补上了三类业务监控:交易成功率(每 5 分钟统计放款/还款/代扣失败率,超过阈值发告警)、积压监控(RocketMQ 消费积压数量超过 1 万告警)、账务平衡监控(每日试算不平衡秒级通知)。这些都直接绑定值班电话。
7.3 SQL 慢查询和连接池水位
有段时间支付服务频繁报"无法获取数据库连接",查下来发现是一条统计 SQLCOUNT(*)扫描了全表,每次执行耗时 20 秒,把连接池占光了。发现过程也是经典:DBA 侧看到慢查询日志,应用侧代码看不到,两者一对比才定位。
从此 SQL 上线前必须走慢查询预检,超过 500 毫秒的直接打回。账务类查询禁止SELECT *,必须显式声明查询字段,减少网络包和行锁持有时间。
8. 对后续演进的一点个人思考
financial-services 这类系统,核心壁垒永远不是技术框架,而是对业务风险的理解和对数据一致性的敬畏。技术栈可以换,架构可以重构,但账务逻辑、状态机、幂等设计这些东西一旦上线,就是身体的一部分,改一个字段都牵一发动全身。
我个人的体会是:做金融系统,慢就是快。功能上线可以晚一周,但数据校验、幂等逻辑、对账程序一个都不能省。因为线上事故的成本不是修复代码的时间,而是解决用户资金差错、安抚渠道、应对监管的整个链条。
最后分享一个实用习惯:每次发版前,把涉及资金状态的 SQL 变更全部在沙盒环境用双份数据跑一遍,一份当前数据、一份历史数据,专门看迁移逻辑是否破坏旧记录。这个习惯到目前为止帮我拦下了两次可能酿成资金差错的发布。
如果这篇文章对你有用,下次启动类似 financial-services 项目的时候,记得先把状态机画清楚、把幂等表建好、把对账脚本写好,再考虑加功能。