1. 这个金融服务项目到底要解决什么问题
先说清楚一点:金融服务这个词听起来很大,但在实际工程里,它落到最后就是一系列需要被稳定、安全、高效地组装起来的业务能力——开户、余额查询、充值、提现、转账、交易记录、账单、风控拦截、数据分析。我这次分享的项目,就是围绕这些能力做的一个统一金融服务底座,项目名就叫 financial-services。
为什么会有这个需求?大部分公司在做涉及资金流转的业务时,几乎都会踩到同样的坑。第一是系统割裂:开户在 A 系统,支付在 B 系统,对账靠 Excel,C 系统的数据口径和 D 系统还对不上,每到月底财务对账就像打仗。第二是重复建设:每新启动一条业务线,都要重新对接一遍支付渠道,重新写一套风控判断逻辑,重新设计账户流水,开发成本翻着倍往上涨。第三是审计压力:金融服务不是普通业务,每一笔资金流向都必须有据可查、可以回溯,出了问题要能 5 分钟内定位到具体环节。
从这个角度说,financial-services 本质上就是解决"复用"和"规范"两个问题。我的做法是把金融业务的公共能力抽出来,做成一个平台,让各个业务方通过标准化接口来使用这些能力。
这篇文章适合谁看?我觉得主要是三类人:一类是正在做支付、钱包、信贷、理财这类涉及资金系统的后台工程师;一类是负责这类系统架构设计的架构师;还有一类是需要理解技术方案的 PM 或运营。就算你暂时还没碰过金融系统,读完也能明白这类系统从 0 到 1 的过程中,哪些点必须死磕,哪些坑必须避开。
2. 整体架构设计:为什么选了微服务加 API 网关的路线
金融服务系统的核心约束有三个:高可用、强一致、可审计。这三个约束直接决定了架构选型的方向。我不能像做一个普通内容管理系统那样,单体应用一把梭——那确实前期开发快,但业务量一上来就会暴雷,尤其对于资金链路,一次故障就可能带来巨大的业务损失。
我最终定的架构是:接入层用 API 网关,业务层拆成账户服务、支付服务、风控服务、账本服务、通知服务这五个核心域,数据层按域拆分数据库,服务之间通过消息队列做异步解耦,关键资金操作走同步 RPC 并配合本地事务表做最终一致。
为什么不用单体?我说一个自己切身体会过的场景:单体应用里,支付服务和账户服务在同一个进程,某个接口流量突增,拖垮了整个应用,结果账户查询也挂了,用户连自己有多少钱都看不到。这种"一荣俱荣、一损俱损"的紧耦合,在金融场景里是不可接受的。而且当团队规模变大,多人同时在一个代码库里改,光是代码合并就够喝一壶。
为什么微服务合适?因为金融业务的领域边界实在太清晰了。账户域关注余额和客户信息,支付域关注渠道和订单状态,风控域关注风险和决策,账本域关注每一分钱的来源和去向。这种天然的业务边界,几乎是照着微服务的设计理念长出来的。按域拆分之后,每个服务可以独立部署、独立扩容,支付服务流量大了,我就只扩支付服务,不需要连累账户服务跟着一起扩。
API 网关在这里不是可有可无的。它统一处理鉴权、限流、路由、参数校验,外面打进来的每次请求,都先过网关再到具体服务。网关层的限流尤其关键,后面我会专门讲配置思路。另外,网关还能做灰度发布的流量切分,把一部分请求切到新版本服务上,观察一段时间再全量,这对金融系统来说是保命功能。
基础设施这块,我用的都是比较成熟的技术栈:Docker 加 Kubernetes 做容器化和编排,日志统一收集到 Elasticsearch,监控用 Prometheus 加 Grafana。没有什么特别新鲜的东西,因为在金融服务里,稳定压倒一切,选最成熟、团队最熟悉的技术,永远比选最新的技术更靠谱。
注意:微服务不是银弹。如果你只是做一个课程 Demo 或者内部小工具,单体加单库完全没问题。微服务带来的复杂度是在业务规模上来之后才值得付出的,别为了架构而架构。
3. 核心模块拆解:五大服务的职责边界和关键设计
这一章是整个项目的核心。我会把每个服务的职责边界、数据设计、关键设计原理讲清楚,也包括我在设计过程中踩过的坑和推倒重来的地方。
3.1 账户服务:余额不能只存一个数字
账户服务只管"客户的钱包余额",要做的事情有四件:创建账户、查余额、余额变更、账户状态管理。
这里最核心的是余额变更。金融系统里,余额变更绝对不能用"先读出来、内存里扣减、再写回去"这种 naive 方式,那样一旦出错根本没有回溯的余地。正确做法是:每一次余额变动都产生一张流水记录,余额由流水累计而来,或者余额字段与流水双写并做对账校验。我采用的就是"余额加流水"双写模式。
账户表上有 balance 字段,每一笔变动同时写一条 ledger 流水。每次变更都在一个数据库事务里,同时更新余额和插入流水,并且让流水的金额逻辑上满足会计恒等式——所有流水的累加值等于当前余额。这样即使某天数据出现不一致,拿流水重算一遍就能发现问题。我用过几个项目之后,这个设计的价值体现得淋漓尽致。
账户状态设计上,常见的有 normal、frozen、closed 三种。冻结状态非常重要,可以用于司法冻结、风险冻结或者异常行为冻结。冻结期间不能做任何出金操作,但可以入账。我当时漏了冻结状态的设计,后来做风控接入时才发现必须要补上,不然风险账户照样能往外转钱,这是很严重的事故隐患。
3.2 支付服务:状态机和幂等是保命两条线
支付服务是金融系统里最容易出事故的地方。它要对接银行卡快捷支付、三方钱包、余额支付、转账汇款,每种渠道的参数签名、回调协议、对账格式都不一样,如果上层业务直接面对这些差异,代码会烂成一锅粥。
我的设计思路是渠道适配层。所有第三方支付渠道都封装成统一的接口,上层业务只调 pay()、query()、refund() 三个方法,具体渠道的差异全部下沉到适配层。这样要接入新渠道时,只需要新增一个渠道实现类,不影响上层逻辑。
支付订单的状态机也必须严格设计。一个支付订单的生命周期是:created -> paying -> paid -> settled,也可能走到 canceled、failed 或者 refunded。状态流转规则要写死,不允许乱跳,比如 paid 之后不能再回到 paying,refunded 只能从 paid 状态进入。这一步如果没做严,资金就会在订单状态里出现歧义,后续对账根本无从下手。
幂等性应该是支付服务最核心的工程问题。用户手抖点了两次"确认支付",或者客户端超时重试,这些都是常见场景。我的做法是:每个支付请求都要求调用方传一个 requestId,支付服务用 requestId 作为唯一键创建支付单,重复的 requestId 直接返回已有结果,绝不重复扣款。这个设计需要配套的存储,后面实操章节我会细讲。
3.3 风控服务:力度要像拧水龙头
风控服务不做资金操作,但它决定一笔资金操作能不能发生。它的核心能力是规则引擎和数据采集。
规则引擎我选用了开源 Drools,配合自研的规则配置后台。业务风控人员可以在后台配置规则,比如"单笔充值金额超过 5 万元需要人工审核""同设备 30 天内注册超过 3 个账户则标记风险"。规则改动不需要发版,保存后实时生效,这对快速响应新风险场景很重要。
数据采集是风控的基础。每一次登录、每一次支付、每一次修改手机号,都要异步上报事件流。风控服务消费这些事件,结合用户画像和规则,实时返回决策结果:通过、拒绝、人工审核、增强验证。
这里我踩过一个大坑:初期接入的风控数据源太多,规则配了几百条,结果误杀率高得离谱。用户正常充值也被拦截,客服电话被打爆。后面砍掉一半无效数据源,规则收敛到核心场景,误杀率立刻降下来。风控的力度真的要像拧水龙头一样,一点点调,调到刚好能拦住坏人,又不误伤好人的位置。
3.4 账本服务:每一分钱都要有来路和去向
账本服务是金融服务里最需要敬畏的模块,它负责记录每一笔资金的来龙去脉,支撑对账和审计。
我采用的是复式记账的思想。每一笔资金变动都涉及两个账户,一方记借方,一方记贷方,借贷金额必须相等。比如用户充值 100 元,那就是借:银行存管户 +100,贷:用户余额 +100。这里面最关键的地方在于:任何一笔入账都必然对应一笔出账,不会有"凭空多出来的钱"。这套思想来自会计学,用在工程上刚好可以对上"钱从哪里来、到哪里去"的审计需求。
流水号设计同样重要。我采用的规则是:日期加业务类型加随机数加序列号,例如 20250117103255001。流水号全局唯一,作为账本的幂等键,重复写入直接拒绝,不允许出现两条一模一样的流水。
日终对账是最能发现潜在问题的环节。每天晚上跑批任务,把账本流水、支付订单、渠道对账单这三方数据拉齐比对。逐笔核对金额、时间、状态,任何不一致就告警。这里我强烈建议不要只看"总金额相等",因为总额相等完全可能掩盖两笔互相抵消的错误。所以一定要逐笔匹配,才算真正对上了。
3.5 通知服务:小服务也有大门道
通知服务看起来偏基础,但绝不能被轻视。支付结果、风控拦截、账户变动提醒,都要靠它触达用户。用户能不能及时收到短信、看到推送,直接关系到对平台的信任感。
通知服务最关键的是通道隔离和重试策略。短信、邮件、推送如果共用一个线程池,某一个通道故障就会拖垮其他通道。我按通道分别建线程池,每个通道独立配置超时和重试次数。发送失败的消息进入死信队列,之后人工处理后重新投递。
这块有个细节:通道配置要支持动态调整。有一次短信通道因为运营商故障大面积超时,如果所有重试还是继续打给这个通道,反而会让问题恶化。所以重试策略里必须包含熔断机制,连续失败次数超过阈值就先停掉这个通道,等恢复后再自动放开。
4. 实操过程:搭建完整闭环的七个关键环节
这一章是操作记录。我会按一个服务从零到一的顺序,把关键步骤和核心配置完整列出来,也包括我在现场调试时的思考。
4.1 项目骨架和目录结构
第一步是搭代码仓库。我建议用 monorepo 结构,一个仓库管多个服务,方便统一管理代码规范和依赖版本。目录结构大概长这样:
financial-services/ ├── api-gateway/ ├── services/ │ ├── account/ │ ├── payment/ │ ├── risk/ │ ├── ledger/ │ └── notification/ ├── common/ │ ├── id-generator/ │ ├── idempotency/ │ └── mq-client/ └── deploy/ ├── k8s/ └── docker/common 下的基础组件是跨服务复用的,比如 ID 生成器、幂等组件、MQ 客户端。这些基础能力统一封装好,其他服务直接引用,避免每个服务各写一套,不然统一排查起来很痛苦。
4.2 数据库表结构设计
账户表和流水表是核心,这里我给出一份可复用的建表 SQL 和字段设计说明。
账户表:
CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_no VARCHAR(32) NOT NULL UNIQUE COMMENT '账户号', user_id VARCHAR(32) NOT NULL COMMENT '用户ID', balance DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '余额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-正常 1-冻结 2-关闭', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY idx_user_id (user_id) );注意余额字段用的是 DECIMAL(18,2),绝对不能看图方便用 float 或 double。浮点数在二进制里天然存在精度误差,0.1 加 0.2 都可能不等于 0.3,用在资金存储上是绝对不能接受的。DECIMAL 是定点数,没有这个问题,这是金融系统的红线。
流水表:
CREATE TABLE ledger_entry ( id BIGINT PRIMARY KEY AUTO_INCREMENT, entry_no VARCHAR(64) NOT NULL UNIQUE COMMENT '流水号', account_no VARCHAR(32) NOT NULL COMMENT '账户号', transaction_no VARCHAR(64) NOT NULL COMMENT '交易号', amount DECIMAL(18,2) NOT NULL COMMENT '变动金额', direction TINYINT NOT NULL COMMENT '1-入账 0-出账', balance_after DECIMAL(18,2) NOT NULL COMMENT '变动后余额', biz_type VARCHAR(32) NOT NULL COMMENT '业务类型', created_at DATETIME NOT NULL, KEY idx_account_no (account_no), KEY idx_transaction_no (transaction_no) );balance_after 这个字段我特意加上了,记录变动后的余额。这样查询某一时刻的账户余额时,不需要把所有流水重新累加一遍,直接取对应时间点的 balance_after 就行,对性能很有帮助。
4.3 幂等键的设计与落地
幂等键是金融接口的命根子。网关层对所有写接口强制要求幂等键,统一名为 Idempotency-Key 或 requestId。服务端用 Redis 做幂等,key 是 requestId,value 是处理结果,过期时间设 48 小时。处理流程是:请求进来先查 Redis,有值直接返回;没有值,创建业务单并写入 Redis。
这里有一个并发坑点:两个相同 requestId 的请求同时到达,可能同时没查到 Redis 值,然后就都去创建订单了,还是会重复。解决办法有两种:数据库层加唯一索引兜底,或者用 Redis 的 SET NX 命令抢锁。我在实际项目里用的是双保险——服务层用 SET NX 抢锁,数据库层用唯一索引兜底,两条链路同时保证,不赌概率。
4.4 服务间通信和数据一致性怎么做
服务间通信我用的是"同步 RPC 查询加异步消息解耦"的组合。关键资金链路,比如发起支付时要查账户状态,用同步 RPC 保证实时性;非关键链路,比如推送通知、异步对账,走 RocketMQ。
数据一致性方面,我在账本服务里维护了一张本地消息表 transaction_outbox。核心做法是:在同一个本地事务里,同时写业务表和 outbox 表,然后异步把 outbox 里的消息发出去。这样做的保证是:业务操作成功,消息就一定在;业务操作失败,消息一定不在。从源头规避了分布式事务的复杂性。
注意:金融系统里不要轻易上分布式事务方案,比如两阶段提交。协调开销大,参与者多了性能急剧劣化,对支付这类高并发链路很不友好。本地消息表加消息队列,再配合对账兜底,是实践中性价比最高的方案。
4.5 网关限流和灰度发布
网关层用 Sentinel 做限流,按接口维度配置每秒最大 QPS。支付接口的限流值我定的是正常业务峰值打八折,超过就直接返回"请求过于频繁,请稍后再试"。这个设计是为了防止某个业务方的大促活动把整个支付链路冲垮。上限值不好拍脑袋定,要基于平时监控数据来推算,宁可先松一点再逐步收紧,也不要上来就卡得很死。
灰度发布用 Kubernetes 的 Ingress 加 Service 版本流量切分。线上同时跑 v1 和 v2 两个版本,Ingress 配置权重逐步从 100:0 调到 0:100。灰度期间重点观察错误率和耗时,一旦异常立刻切回。
4.6 全链路日志追踪
金融系统的日志必须做到"按图索骥":拿到一个 requestId,就能查出这条请求经过了哪些服务,每一步耗时多少,返回了什么。我用 SkyWalking 做全链路追踪,日志框架统一打印 traceId,业务日志和调用链日志通过 traceId 关联。排查问题时,输入一个订单号就能拉出完整调用链,不用再翻遍几个服务的日志靠猜。
4.7 监控指标和告警阈值
监控指标里最核心的五个:接口 QPS、P99 耗时、错误率、余额变动对账差异笔数、消息队列积压量。我配置的告警阈值偏保守,因为金融系统宁可误报也不能漏报:
| 监控指标 | 告警阈值 | 说明 |
|---|---|---|
| 错误率 | 超过 0.5% 连续 5 分钟 | 超过说明系统稳定性有问题 |
| 消息积压量 | 超过 1000 条 | 说明消费端可能出问题 |
| P99 耗时 | 超过 800 毫秒 | 超过用户就要等了 |
| 对账差异笔数 | 大于 0 就告警 | 有差异必须立刻查,不能过夜 |
5. 上线后踩过的坑:问题与排查实录
这一章是最真实的部分,记录的都是我在跑这个项目时实际遇到、排查过的问题。有些问题看起来很简单,但背后教训很深。
5.1 余额更新丢更新问题
上线初期,账户服务出现过一个非常典型的余额被覆盖的 bug。两个并发请求同时扣款,A 请求读到余额 100,扣掉 30,写回 70;B 请求也读到 100,扣掉 50,写回 50。结果余额变成 50,实际应该剩 20。这就是典型的读改写并发问题,数据不对了,但程序没报错,很难第一时间发现。
排查过程其实有点曲折。最早看日志看不出异常,两个请求的返回都是成功。后来我直接在数据库层查余额,才发现不对。解决方式有两个层面:第一,把 UPDATE 语句改成原子条件更新,写成 UPDATE account SET balance = balance - 30 WHERE account_no = ? AND balance >= 30,用数据库原子操作代替读改写;第二,在应用层加 version 乐观锁,更新时带上 version 条件,版本不匹配就重试。这两层叠加之后,丢更新问题彻底解决。
这个坑给我的最深教训是:余额这种高频热点字段,不要放 Redis 里做扣减。Redis 做缓存可以,但把余额这种需要事务和审计的强一致数据放到 Redis,出问题后根本没法追溯。
5.2 支付回调时序问题
支付渠道的异步通知到达顺序,和本地订单的创建顺序不一定一致。我遇到过"回调先到,本地订单还没创建"的情况。最初的逻辑是:回调处理时先查订单,如果订单不存在就返回忽略,结果渠道重试时订单已创建,但回调已经被忽略了,然后就出现"支付成功但订单没更新"的尴尬状态。
解决方式是把回调处理改成幂等 upsert 逻辑:不管订单存不存在,都尝试插入或更新,存在就更新状态,不存在就插入一条记录,再触发后续流程。之后渠道无论重试多少次,最终状态都能对齐。这个案例说明,凡是外部系统的回调,处理逻辑必须容忍乱序和重复,用幂等设计来兜底。
5.3 对账发现渠道手续费差乱账
对账的时候发现,渠道账单和我们的账本总是差几毛钱,怎么都对不上。排查半天,发现是渠道手续费在作梗:用户支付 100 元,渠道实际给我们结算 99.6 元,手续费 0.6 元。我们的账本只记了 100 元入账,手续费没单独记录,所以账目对不上渠道的结算单。
解决方式是在账本里新增一个"手续费"科目,支付成功的订单拆成两笔流水:客户入账 100 元,手续费支出 0.6 元,渠道结算 99.4 元。拆分明细后,三方账单终于对齐了。这件事的启发是:对账不只是技术问题,更重要的是业务建模。业务模型不对,对账就永远对不上,再怎么调代码都没用。
5.4 消息重复消费导致重复通知
RocketMQ 的投递语义是 at-least-once,也就是至少一次,消息消费者可能在业务处理成功但还没提交 offset 时宕机,重启后会重新消费。结果就是用户收到两条一样的支付成功短信。
解决方式是在消费者业务处理前先查本地去重表,可以用 Redis 占位符或者数据库唯一键,已处理过的消息直接跳过。这个需求不只是通知服务需要,所有消费 MQ 的服务都应该默认做好幂等,把重复消费当成必然会发生的事来设计,而不是侥幸地觉得"应该不会重复"。
6. 最后说几个真正重要的经验
项目做到这个程度,我对金融服务系统有了几个比较深的认识,写在这里算是对这个项目的一个阶段性总结。
6.1 金融服务的第一原则是可追溯
如果让我概括金融服务系统工程里最核心的一件事,它不是高性能,也不是高可用,而是可追溯。资金操作必须有据可查,不能存在任何一笔无法解释的账。性能不够可以扩容,高可用不够可以加节点,但一笔说不清来龙去脉的资金流水就是事故。所以我做系统时,宁可多写几条流水,多记录几个操作日志,也不能让任何资金流转处于无迹可查的状态。
6.2 技术方案要能被业务解释清楚
做金融系统时间长了会发现,真正难的不是技术,是理解资金流动背后的业务逻辑。为什么账户要有冻结状态?为什么手续费要拆成独立科目?这些不是凭空拍脑袋想出来的,而是真实业务规则在驱动设计。如果一个技术方案不能用业务语言解释清楚,那这个方案大概率有问题。多站在业务视角问"为什么",能少写很多无用代码,也能让系统设计更贴合实际。
如果你也想动手做一个类似的金融服务项目,我建议先从账户服务和账本服务开始,把"充值、支付、对账"这个最小闭环跑通,再去扩展支付渠道和风控能力。这条路线我自己走过一遍,踩过的坑都写在上面了,跟着走会顺利不少。