提到"financial-services"这个名字,很多做后端的老哥第一时间想到的可能是支付、账户、清算这老三样。但我今天想聊的,不是那种只有大型机构才碰得起的庞然大物,而是一个从零开始搭建、能给自己公司业务兜底的金融服务系统项目。它不堆概念,不搞花活,就是把账户、交易、对账、风控这些金融系统最核心的环节拆开揉碎,设计成你能看懂、能复刻、甚至能拿到生产环境里试用的模样。
这个项目适合谁?适合准备做金融类业务但不知道从哪里下手的技术团队,适合想搞清楚"余额到底该怎么存""分布式事务在金融场景里怎么落地"的后端工程师,也适合那些被业务方逼着上微服务、自己心里却没底的架构师。我把整个项目从头到尾做了一遍,这里边有取舍,有踩坑,也有实打实的经验。这篇文章我把核心设计、关键代码、常见事故全部摊开讲,希望能让你少走几个月的弯路。
1. financial-services 整体设计思路拆解
1.1 为什么叫 financial-services,而不是"支付系统"或"账务系统"
一开始起名的时候,我纠结过一阵。"payment-service"太窄,因为除了支付,还要管账户、管分账、管对账、管风控;"accounting-system"又偏财务软件,没法体现服务的分布式属性。最终定名financial-services,是因为它本质上不是一个单体能搞定的系统,而是一组围绕"资金流转"的微服务集合。这个命名方式也直接决定了后续的拆分逻辑:按业务域切服务,而不是按页面切服务。
一个真正能抗事儿的金融服务系统,至少要覆盖这几个能力域:
| 服务模块 | 核心职责 | 典型接口 |
|---|---|---|
| account-service | 账户生命周期、余额变更、冻结解冻 | 开户、查询余额、变更余额 |
| transaction-service | 交易流水登记、支付/退款/转账 | 创建交易、确认交易、撤销交易 |
| risk-service | 规则引擎、黑白名单、频控 | 交易风控校验 |
| reconciliation-service | 对账、差错处理、调账 | 生成对账文件、差异核对 |
| api-gateway | 统一鉴权、路由、限流 | 外部统一入口 |
| notification-service | 通知回调、消息触达 | 回调三方、站内信、短信 |
这个服务划分不是拍脑袋定的,而是根据一条完整交易链路的职责边界切出来的:用户发起交易 → 网关鉴权 → 风控放行 → 交易创建 → 账户扣款 → 流水落库 → 通知回调 → 定时对账。每一环只干一件事,耦合度降到最低。
1.2 这项目到底解决了什么问题
金融业务和普通互联网业务最大的区别在于:错了不能改,只能冲正。普通业务表数据错了,UPDATE 一下就行;资金类的数据错了,直接改账就是重大事故,必须通过一笔反向交易来校正。这就导致整个系统的设计逻辑和普通 CRUD 完全不同。
我最初做这个项目,是因为一个现实需求:公司要做一款聚合收款工具,需要让商户入驻、发起收款、查看每日对账单。但我翻遍开源社区,能找到的要么是过于简单的 demo,要么是嵌套在巨型电商项目里的支付模块,拿不出来单独立用。于是我决定自己写一套。目的很明确:在满足业务需求的前提下,把金融级的安全规范、数据强一致、可审计性做进骨子里,而不是缝个补丁上去。
1.3 从一个普通交易看系统全貌
先用一个实际场景走一遍:商户 A 的账户给商户 B 的账户转 100 元。
前端请求先到 api-gateway,网关做基本鉴权后路由到 transaction-service。transaction-service 收到请求后,先调用 risk-service 做实时风控(检查双方账户状态、金额是否异常、交易频次是否超限),通过后发起分布式事务:account-service 扣减 A 的余额并增加 B 的余额,同时 transaction-service 落一条交易流水。两边都成功,事务消息才会确认。最后向商户回调通知,推一条消息到 notification-service。
这整个过程,用户在页面上感知到的只是"转出成功",但系统内部要保证:要么 A 扣钱且 B 到账,要么两边都不变,绝对不允许出现 A 扣了钱 B 没收到的情况。这就是金融系统设计的地基——原子性。
2. 账户与账务:金融系统的灵魂所在
2.1 余额到底怎么存?永远不要把余额当数字字段
这个坑我见过太多人踩了。表里一个balance DECIMAL(12,2)字段,然后扣款时UPDATE account SET balance = balance - 100 WHERE id = ?,看起来没问题,但在高并发下必出事。
问题在哪?并发扣款时丢失更新。如果两个请求同时读到余额是 100 元,各自扣 50,最终余额可能变成 50 或者 0,而不是预期的 0(假设余额应该被扣成 0)。解决办法有两种,我推荐组合使用:
第一种是乐观锁,表里加version字段,更新时带上版本号:
UPDATE account SET balance = balance - 100, version = version + 1 WHERE id = 1001 AND version = 5如果影响行数为 0,说明版本冲突,要么重试要么返回失败,业务层再决定进一步处理。这是最简单也最可靠的方案。
第二种是专用扣款条件,直接在 SQL 里判断扣款后余额不小于 0:
UPDATE account SET balance = balance - 100 WHERE id = 1001 AND balance >= 100影响行数为 0 就说明余额不足,直接返回"余额不够"的提示。这个方案省一次查询,也不用维护 version 字段。
但请注意,这不意味着项目里到处都是这种裸 SQL。金融系统里的余额变更必须收敛到账务引擎里统一处理,不允许业务方各自去 UPDATE 余额。我在项目里做了一个 accounting-core 组件,所有余额变更都走这个组件,它内部统一做锁、做流水、做幂等。这样规则才不会被铺开的业务代码冲散。
2.2 账户表设计的正确姿势
账户表不是只有余额。真实环境里需要考虑"可用余额"与"冻结余额"的分离。举个例子:用户在电商下单,货款要先冻结,确认收货后才能真正到商家账户里。如果只有一个余额字段,这笔钱的状态就无处安放。
我实际的表结构大致是这样:
CREATE TABLE `account` ( `id` bigint NOT NULL AUTO_INCREMENT, `account_no` varchar(32) NOT NULL COMMENT '业务账户号', `user_id` bigint NOT NULL COMMENT '所属用户ID', `account_type` tinyint NOT NULL COMMENT '1-用户账户 2-商户账户 3-内部账户', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1-正常 2-冻结 3-注销', `balance` decimal(12,2) NOT NULL DEFAULT 0 COMMENT '当前余额', `available_balance` decimal(12,2) NOT NULL DEFAULT 0 COMMENT '可用余额', `frozen_balance` decimal(12,2) NOT NULL DEFAULT 0 COMMENT '冻结余额', `currency` char(3) NOT NULL DEFAULT 'CNY', `version` int NOT NULL DEFAULT 0, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` 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;这里藏着两个设计要点。第一,业务账户号account_no要单独建立唯一键,不能让数据库自增 ID 直接暴露给业务方,否则可通过 ID 遍历撞出别人的账户。第二,account_type区分用户账户和商户账户,内部账户是给平台记账用的(比如手续费归集账户),没有这个字段,后面做对账会非常痛苦。
2.3 流水与余额:为什么永远要"先流水后余额"
余额是结果,流水是过程。任何一笔余额变动,都必须先落流水,再更新余额。这个顺序不能反。
我设计了一套"流水驱动余额"的机制:每一次余额变更,先在account_flow表里插入一条流水记录,记录账户号、变动金额、变动前余额、变动后余额、业务单号、业务类型;然后再去更新账户表的余额。为什么这么干?因为流水是只增不改的,它是审计的基石。一旦余额出了问题,你可以通过流水完整重建余额轨迹,而不是面对一个不知道被谁改了的数字发呆。
流水表还要注意索引设计。按账户号查流水是最常见的操作,所以(account_no, created_at)联合索引必须有。注意如果账务量大,流水表建议做按月分表或者按账户号哈希分表,单表流水超过一千万后,按时间查历史明细会很吃力。
2.4 幂等:金融系统里最容易被忽视的魔鬼细节
网络超时、消息重投、用户疯狂点击"提交"按钮……这些都是幂等要解决的场景。如果用户提交了一笔转账,前端网络超时导致重复请求,你的系统处理了两次,那就是资金事故。
幂等设计有三层防线,缺一不可:
- 业务唯一键:每次交易生成唯一
request_no(或者叫幂等键),在交易表里建唯一索引。首攻插入,重复请求插入失败直接返回原结果。 - 防重表:单独建一张幂等表,存业务单号和请求摘要,配合唯一键使用。比直接在主表上加唯一键更轻量,因为幂等表的记录可以定期清理。
- 数据库层兜底:就算上面两层都被绕过了,账户变动 SQL 本身要带条件,保证不会出现重复扣款或负余额。
我在项目里选择的是第一种:在transaction表里直接构建uk_request_no。理由是这个系统里交易请求就是最核心的主键,没必要再走一层防重表增加复杂度。如果你的业务里幂等判断的东西五花八门,比如支付回调、退款回调、授信申请,各种来源都有独立的单号,那就老老实实建防重表。
3. 分布式环境下怎么保证钱不出错
3.1 本地事务做不到,分布式事务怎么选
单体时代,一个@Transactional就完事了——扣 A 的款、加 B 的钱、插一条流水,要么全部成功要么全部回滚。但微服务拆分后,账户服务和交易服务是分开的,各自有各自的数据库,这就绕不开分布式事务。
分布式事务的方案我用过几种,最后选了 RocketMQ 的事务消息方案。为什么不用 Seata 的 AT 模式?AT 模式侵入性小,性能也不错,但它依赖全局锁,在高频交易场景下容易成为瓶颈,而且它对数据库的连接池、SQL 写法都有隐性要求。事务消息则更贴合"确认某个业务动作成功后再发消息"的场景。
核心逻辑是这样的:transaction-service 先向 RocketMQ 发送一条半消息(half message),此时消息不可见;然后执行本地事务——把交易记录写好、更新账户余额;本地事务成功后,RocketMQ 确认该消息可以被消费;如果本地事务失败,RocketMQ 会自动走回查接口去确认事务状态,然后决定是继续发消息还是删除消息。
@Transactional public void executeLocalTransaction() { transactionMapper.insert(transactionDTO); accountService.deductBalance(accountNo, amount); // 本地事务成功 }而本地事务执行中如果宕机了,RocketMQ 的回查机制会查到本地事务表里没有对应记录,就会把半消息删除,避免下游消费到错误数据。这套机制的微妙之处在于:下游消费消息时,上游本地事务一定已经成功了,这就从源头解决了消息不可靠导致的分布式数据不一致。
3.2 本地消息表:低配但依然有效的兜底方案
如果团队规模小、不想引入消息中间件,本地消息表是老牌方案,到今天依然值得掌握。逻辑是:业务接口里先把业务数据和消息记录放在同一个本地事务里写入,保证"业务成功一定消息表有记录";然后由定时任务捞消息表里 status=0 的记录,逐条发送到下游,发送成功就置 status=1;下游收到消息要带上业务单号做幂等,确保重复消息不重复处理。
这个方案的优点是非常简单,不依赖任何中间件就能实现最终一致。缺点是:需要自己写定时任务、自己管理消息重发和清理,无法做到实时投递,会有秒级延迟。在小流量的内部系统里,我依然认为本地消息表是一个很稳的选择。
3.3 对账:所有分布式方案的最后一道保险
不管用事务消息还是 Seata,理论上都可能出现极端情况:消息丢失、网络分区、数据库主从延迟,导致两边数据不一致。这时候就要靠对账来收口。
我在 reconciliation-service 里写了三类对账任务:
- 内部对账:每天凌晨跑一次,把交易流水表的总额和账户流水表的总额做比对,看是否平账。
- 渠道对账:每天拉取支付渠道的结算文件,逐笔核对订单金额、手续费、状态。
- 差错处理:对账发现差异后,自动生成预调账记录,由人工确认后执行送账。
注意对账任务本身不要追求实时,凌晨低峰期跑即可。重点是差错处理流程要闭环,不能只出个报表然后就丢在那儿没人管。
3.4 高可用设计:系统可以挂,账不能乱
很多团队做金融系统的时候,盯着高并发、高性能抠细节,却忽略了"系统挂了以后怎么恢复"这件事。我个人的观点是:金融系统的高可用和普通系统不一样,核心目标是快速恢复,而不是永不宕机。
具体落到项目里就是几个方面:
- 账户服务全部走主库读写,不做读写分离。因为余额的强一致读必须看到最新的值,不能因为主从延迟读出一个旧余额,然后出现重复支付的问题。
- 关键服务全部做多副本部署,Nacos 里配权重,保证单台机器挂了之后流量能被其他实例接管。
- 交易发往 MQ 之前,先落一张本地 event 表,等 MQ 确认后再删除 event 记录。如果 MQ 不可用,定时任务会捞 event 表兜底重发。
- 数据库定时备份,至少保留最近 7 天的全量 binlog。真出现灾难事故时,能够恢复到最近一分钟的状态。
其中"读写不分离"在很多人看来有点反直觉,会让主库压力很大。但你要想清楚:账户表的查询模式是点查,通过account_no查一条记录,加上索引后压力并不夸张。相比于主从延迟带来的那一堆资金安全问题,这个取舍非常划算。
4. 实操落地:从设计到能跑的核心过程
4.1 搭建项目骨架
整个项目采用 Maven 多模块结构,父工程下挂 6 个子模块,每个服务独立打包、独立部署。技术栈如下:
基础框架:Spring Boot 3 + Spring Cloud Alibaba 2022注册配置:Nacos Server 2.xORM:MyBatis-Plus 3.5.x分布式锁:Redis + Redisson消息队列:RocketMQ 4.9调度框架:XXL-Job 2.4数据库:MySQL 8.0
这些选型都是久经考验的组合,不追求新潮,只追求稳。比如 RocketMQ 的事务消息在金融场景下已经用了很多年,踩坑资料丰富;Redisson 的看门狗机制可以让分布式锁避免因业务执行过长而提前释放的尴尬。
4.2 核心代码:账户变更服务的骨架
账户变更是整个系统的核心入口,我实现了两种操作模式:deduct和adjust。deduct是扣款,adjust是余额调整(比如人工调账、冻结)。这两个模式的逻辑几乎一致,区别只是参数里的bizType不同。
@Service public class AccountServiceImpl implements AccountService { @Resource private AccountMapper accountMapper; @Resource private AccountFlowMapper accountFlowMapper; @Resource private RedissonClient redissonClient; @Override public boolean changeBalance(AccountChangeRequest request) { String lockKey = "account:lock:" + request.getAccountNo(); RLock lock = redissonClient.getLock(lockKey); boolean isLocked = false; try { isLocked = lock.tryLock(3, 10, TimeUnit.SECONDS); if (!isLocked) { throw new BizException("账户繁忙,请稍后重试"); } // 第一步:流水先行 AccountFlow flow = new AccountFlow(); flow.setAccountNo(request.getAccountNo()); flow.setChangeAmount(request.getAmount()); flow.setBizType(request.getBizType()); flow.setRequestNo(request.getRequestNo()); flow.setStatus("INIT"); accountFlowMapper.insert(flow); // 第二步:幂等判断 int rows = accountMapper.deductBalance( request.getAccountNo(), request.getAmount(), request.getVersion() ); if (rows == 0) { throw new BizException("余额不足或版本冲突"); } // 第三步:更新流水状态 flow.setStatus("SUCCESS"); accountFlowMapper.updateFlowStatus(flow); return true; } finally { if (isLocked) { lock.unlock(); } } } }这里有三点值得展开说:
第一,为什么先插流水再更新余额?因为流水是"事实记录",余额是"派生状态"。万一流水写完、余额更新失败,可以通过流水表触发冲正;反过来就不行,你会得到一个"余额变了但没有流水"的账户,这是审计上的致命缺陷。
第二,分布式锁粒度是账户维度,而不是用户维度。一个用户可能有好几个账户,锁用户会锁死无关账户的变动;锁账户则能精确定位到具体的资金池,并发度更高。
第三,tryLock的等待时间和释放时间要反复压测。我在压测中发现等待 3 秒对于大部分场景足够,但如果抠款链路里还嵌套了外部渠道调用,就需要调大等待和时间。锁的过期时间一定不能设短,否则业务还在执行,锁先过期了,另一个线程进来操作同一个账户,账就乱了。
4.3 支付回调的幂等处理
引入渠道(比如微信支付、支付宝)后,回调处理是事故高发区。网络抖动会导致渠道重复回调,而且不同的回调时间可能只差几百毫秒。
我的处理方案是:先查交易单状态,如果已经是"PAY_SUCCESS"则直接返回成功,不再执行后续逻辑;否则在本地事务里更新交易单状态、给账户加钱、插入流水。因为整个更新是在事务里,数据库的行锁会保证同一时间只有一个线程能完成状态从"PAYING"到"PAY_SUCCESS"的转变。
@Transactional public void handlePayCallback(String requestNo, String channelOrderNo, BigDecimal amount) { Transaction tx = transactionMapper.selectByRequestNo(requestNo); if (tx == null || "PAY_SUCCESS".equals(tx.getStatus())) { return; } // 加钱 accountService.changeBalance(AccountChangeRequest.builder() .accountNo(tx.getMerchantAccountNo()) .amount(amount) .bizType("PAY_IN") .requestNo(requestNo) .build()); // 更新交易状态 transactionMapper.updateStatus(requestNo, "PAY_SUCCESS"); }注意:这里在事务里通过selectByRequestNo查询后并没有FOR UPDATE,可能有人会问会不会有幻读。实际上 MySQL InnoDB 默认是可重复读隔离级别,这里的加钱操作走了 account 表的主键点查,本身就有行锁保护;交易状态更新走 request_no 的唯一索引,同样能锁住这一行。但如果你不加唯一索引,只靠普通索引,那确实需要SELECT ... FOR UPDATE来保证串行化。
4.4 定时对账任务怎么落地
对账任务我用 XXL-Job 实现,每天凌晨 02:00 触发一次。任务分为两步:
第一步,从本地交易表统计每日交易汇总,然后从渠道侧拉取对账单文件,解析成平台的数据结构;第二步,组装成一个 TreeMap,以"平台订单号"为 key,逐个比对金额和状态。比对结果分三类:一致、平台有小渠道无、渠道有小平台无。第三类是最危险的,说明有交易在途丢失,需要立刻告警。
对账任务本身不复杂,难的是处理"差异"。我设计了conciliation_diff表,每条差异记录都有独立的diff_no,并提供一个人工处理界面,支持三种操作:忽略、调账、重试。所有操作都记操作日志,因为对账是一个必须可审计的动作,不能悄咪咪地把钱调平。
5. 实战中踩过的坑与排查技巧
5.1 并发扣款导致的死锁
上线后第一次压测,我就遇到了死锁。两个账户互转的场景下,线程 A 持有账户 1 的锁,等待账户 2 的锁;线程 B 同时持有账户 2 的锁,等待账户 1 的锁——死锁就出现了。
排查方式很直观,先看 MySQL 的SHOW ENGINE INNODB STATUS,里面会把死锁相关的事务和 SQL 直接列出来,然后找到对应方法。解决方法是统一加锁顺序:对转账涉及的多个账户先做排序,始终按照账户号从小到大依次加锁。这样就不会出现互相等待的情况,死锁自然消失。
5.2 对账不平,第一反应不要调账
某天凌晨对账任务报了两笔差异,运营那边很着急,恨不得马上人工把钱调平。我的建议是:先查这三样东西,再决定要不要调账。
- 查渠道侧订单状态:是不是回调还没到、或者渠道做了退款。
- 查本地流水表:是不是回调到了但幂等判断出了问题,导致钱没有加上去。
- 查消息队列消费情况:是不是消费者挂了,或者消费时抛异常被重试卡住。
大多数对账不平都是"消息延迟"和"状态位没有及时更新"导致的,等几分钟或者重新推送一次可能就平了。真正确认资金损失后才走调账流程,否则调账本身就会制造新的差异。
5.3 余额查询性能突然飙慢
有个商户反馈查余额的接口越来越慢,我看了一眼慢查询日志,SQL 长这样:
SELECT * FROM account_flow WHERE account_no = 'xxx' ORDER BY id DESC LIMIT 20;走的是uk_account_no单列索引。问题出在 ORDER BY id 上:虽然account_no有索引,但在同一个账户的流水很多时,MySQL 需要把所有匹配行取出来再排序,而不是直接通过索引顺序取回 20 条。快速修复方案是把索引从单列改成(account_no, id)联合索引,这样索引天然有序,ORDER BY id DESC LIMIT 20就不再需要 filesort。修改后接口响应从 300ms 降到了 10ms 左右。
5.4 资金安全测试怎么设计
金融系统的测试不是"用例覆盖率高就行",关键是设计边界条件和异常场景:
- 负数金额:接口层和账务层都要校验,不能出现
-1这种金额。 - 金额精度:统一用 BigDecimal,禁止用 double;数据库里全部用 DECIMAL(12,2)。如果需要更细的厘级计费,建议 DECIMAL(18,6),展示层再转成两位。
- 超时与重试:MQ 消息重投、HTTP 超时、数据库连接断开的场景都要用故障注入工具测一遍。
- 余额不足:不但要测"正好够了"的情形,还要测"差一分钱不够"的边界。
- 并发扣款:同一账户同时扣款 100 次,最终余额必须精确等于理论值。
这些用例我习惯用 Mock 数据先做一次"数据演练",再拉到联调环境跑。资金相关测试,宁可慢,不可漏。
5.5 常见问题速查表
| 症状 | 排查思路 | 处理方案 |
|---|---|---|
| 重复收到支付回调 | 查交易单状态是否已为成功 | 幂等处理,已成功直接返回 |
| 账户余额为负 | 查扣款 SQL 是否有余额条件 | SQL 增加balance >= amount判断 |
| 分布式事务不一致 | 查 MQ 回查记录、查流水表 | 走定时对账,差异走差错流程 |
| 消息丢失 | 查 event 表、查 MQ 死信队列 | 定时任务捞 event 重发 |
| 对账差异 | 确认渠道侧状态、查流水、查消费状态 | 延迟后重跑,仍不平则人工确认 |
| 查询响应慢 | 看慢 SQL、看索引执行计划 | 加联合索引、优化查询条件 |
| 死锁 | 查看 InnoDB 死锁日志 | 统一账户加锁顺序 |
6. 一点实操体会
做完整个financial-services项目,我最大的一点点体会是:金融系统的复杂度从来不是靠引入更牛的框架来降级的,是靠把每条链路的边界想清楚来降级的。RocketMQ、Redis、Nacos 都是工具,真正难的判断在于——某个账户要不要加锁、流水表和余额表谁先写、回调重复来了怎么处理、对账差异什么时候该人工介入。这些决策没有银弹,只有完全理解了资金的流转过程,你才能设计出既不会出大事故、又能高效运转的系统。
如果你正准备做类似的系统,我的建议是先别急着写代码,用一个下午把业务链路从头到尾走一遍,把每个环节的失败场景列出来,你会发现自己对架构的理解完全不一样。文章里提到的建表 SQL、核心代码片段,在我的实际项目里都经过压测和线上环境验证,但复制粘贴到你的业务里之前,一定把它吃透了再落地。毕竟,金融系统的账本上没有"试试看"这三个字。