接手 financial-services 这个项目时,团队对它最朴素的期望就一句话:把所有金融业务里反复要用的能力沉淀成服务,别让业务线各自重复造账户、支付、对账的轮子。后来我们真的把它做成了一套面向B端的金融服务基础平台,覆盖账户、交易、结算、对账、风控、报表六大模块,对外提供统一的API接入体系。这篇文章不是那种只画架构图的博客,而是我在真实搭建过程中踩坑、取舍、复盘后的完整记录,特别适合正在规划金融中台、支付系统或企业内部财务中台的团队参考。
一开始,我身边不少人听到 financial-services 这个代号,第一反应都是问:这是要做支付宝还是做网银?其实都不是。这个项目更准确的定义是一层“能力底座”,把金融业务里最高频、最容易重复开发的能力抽象出来,用统一的方式提供出去。下面我会把整体定位、技术选型、核心模块落地、常见故障排查都串起来讲,里面用到的表结构和SQL都是实际跑过的简化版本,可以直接拿去当底稿。
1. 金融服务平台的定位:不是做一个业务,而是沉淀一组能力
1.1 为什么叫 financial-services,项目的边界到底在哪里
第一次评审这个项目时,产品问我:你直接叫“账户中心”不就行了,为什么要用这么宽泛的英文名。我说得很直接,因为账户只是其中一环,后面还有交易、结算、对账、风控,如果一开始就把名字局限在“账户中心”,后面每个新能力都得再新建一个系统,会重新回到竖井式架构。
financial-services 更像一个领域层的统称。它向上暴露能力,向下屏蔽差异。比如用户发起一笔充值,支付通道可能是微信、支付宝,也可能是银行卡代扣,但账户侧只需要一个统一的“入账”接口。渠道怎么接、报文怎么解析、回调怎么处理,都在服务内部消化。这种设计最大的好处是:业务方不会感知到渠道变化,渠道升级也不会影响业务方。
边界我们守住三条。第一,不做信贷审批、理财组合、风控模型这类业务决策,那是上层业务系统的活;第二,不做业务前台,比如用户页面、商户后台界面,那是应用层的事;第三,只输出底层能力和标准API,任何业务线接入都走同一套规范和协议。边界一旦模糊,系统就会越做越重,最后变成“大杂烩”,维护成本直线上升。
1.2 六大核心模块到底在解决什么问题
整个平台拆成六个模块,基本覆盖了金融基础服务的高频需求:
| 模块 | 核心职责 | 说明 |
|---|---|---|
| 账户模块 | 账户生命周期、余额与冻结管理 | 支持多币种、多账户类型 |
| 交易模块 | 交易状态流转、流水记录 | 对外统一交易入口 |
| 支付通道 | 对接支付渠道、回调处理 | 抽象统一支付模型,屏蔽渠道差异 |
| 结算模块 | 分账、结算单生成 | 面向资金方、商户的结算能力 |
| 对账模块 | 本地流水与渠道流水核对 | 每日自动执行并产出差异单 |
| 报表模块 | 业务与财务口径报表 | 兼顾内部审计与监管报送数据 |
这里有一个很关键的认知:账户和交易是底座,支付通道和结算更像适配层,对账和报表则负责兜底。很多团队习惯先把支付通道接好,回头再补账户和对账,结果上线之后发现账对不上,再反工改造账务系统,代价非常大。我在这个项目里把账户和交易放到了最优先的位置,支付通道反而是第二个迭代才接入的。
1.3 什么人需要关注这套方案
如果你是做SaaS平台、本地生活、电商、物流这类业务,需要给B端商户提供资金账户、交易结算能力,那这套方案几乎是刚需。其次,如果公司旗下有多个业务系统,每个系统都需要“加钱、扣钱、查余额”这类基础能力,那一个统一的账务服务能大幅度收敛风险和复杂度。另外,正在做企业钱包、跨境支付、金融科技基础服务的团队,也可以把 financial-services 的思路作为框架参考。
不过我也要给刚起步的团队泼一盆冷水:如果业务还处在非常早期,用户量小,每天交易量只有几百笔,完全没必要一上来就搞微服务和六大模块。先在一个单体应用里用一张流水表、一个条件更新扣款,反而更实际。等交易量和团队规模上来了,再逐步拆分微服务,这样的演进路径会稳妥不少。架构永远要匹配当下的问题,而不是解决想象中的明天。
2. 技术选型与架构设计:哪些决定最关键
2.1 拆不拆微服务,我最终的选择
这个问题我纠结了很久。坦率说,financial-services 这种平台天生适合微服务:账户、支付、结算、对账的负载特征完全不同,对账是定时批任务,交易是实时请求,全塞在一个单体里,扩缩容会很别扭。最终我选择了微服务架构,按模块拆成六个服务,对外通过API网关统一暴露,内部用RPC和消息队列通信。
但微服务的代价也不小。最明显的代价是分布式事务,一块硬骨头,后面我会单独讲。另一个代价是运维复杂度,配置中心、服务注册发现、链路追踪、监控告警,每一项都需要额外投入成本。如果团队只有两三个人,我建议至少把“账务服务”和“交易服务”合并,甚至先用模块化单体。等确实需要独立扩缩容,或者团队人员足够支撑时再拆。拆服务不该是为了简历好看,而是为了让系统演进更高效。
2.2 账户数据存储:用“流水账”思路替代“余额表”思路
金融系统的核心约束是:钱不能多,不能少,每一分钱都要能追溯。很多新手会把余额直接放在账户表里,扣款时直接 update 余额,这样能跑,但出了问题根本查不清。我的做法是“流水驱动余额”,账户表里保存余额快照,但所有余额变动必须写流水,并通过带条件的 update 保证不超扣。流水表不允许物理删除,只允许逻辑作废,这是审计和排查的底牌。
为什么不用实时汇总流水来算余额?性能扛不住。一个账户累计几十万条流水之后,汇总会很慢。所以用余额快照保证读性能,用流水保证可审计性。这里还有一个细节:余额字段和金额字段必须存“分”这个整数单位,不能用小数。用浮点存钱几乎一定会遇到精度问题,接口传参用字符串,数据库用 decimal 或 bigint,这是行业共识,不要试图挑战。
2.3 服务间协同:同步调用和异步消息怎么搭配
不是所有场景都适合同步调用。我的原则是:调用方必须立刻拿到结果,比如用户扣款后要展示是否成功,就用同步RPC;调用方只需要知道“已经受理”,后续可以慢慢处理,就用异步消息。举个例子,用户发起提现:提现接口会同步校验余额并冻结资金,然后把“申请已受理”返回给前端;真正向银行发起打款这个动作,由交易服务通过消息队列异步触发,避免银行接口响应慢阻塞用户操作。
但异步不是万能的。消息丢失、重复投递都是绕不开的问题。我们的做法是“本地消息表加定时任务补偿”,保证消息不丢,后面会在问题排查部分展开。总的节奏是:能同步的尽量同步,不能同步的用好“确认状态 + 定时补偿 + 对账兜底”,这样系统才能在各种异常情况下稳定运行。
3. 核心链路实操:账户、支付、对账、风控的落地细节
3.1 账户模块:余额更新的正确SQL写法和冻结设计
账户模块是金融系统中最不能出错的部分。先把最简单的扣款场景说清楚。假设账户表结构大致是:
CREATE TABLE account ( id BIGINT PRIMARY KEY, user_id VARCHAR(64) NOT NULL, currency CHAR(3) NOT NULL, balance BIGINT NOT NULL DEFAULT 0, frozen_balance BIGINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), UNIQUE KEY uk_user_currency (user_id, currency) );扣钱的时候不能先 select 再 update,必须用带条件的 update:
UPDATE account SET balance = balance - #{amount}, version = version + 1 WHERE user_id = #{userId} AND currency = #{currency} AND balance >= #{amount};如果返回的影响行数为0,说明要么账户不存在,要么余额不足。这个SQL天然做了一次乐观锁和余额校验,不需要额外加分布式锁。我经常跟同事说:数据库的行锁就够用了,不要一上来就上Redis分布式锁。锁越重,出现死锁和性能瓶颈的概率越高。
冻结设计主要是为下单、预售、提现这类场景准备的。提现发起时,先校验可用余额,然后把余额减少、冻结余额增加:
-- 提现发起:减少可用余额,增加冻结余额 UPDATE account SET balance = balance - #{amount}, frozen_balance = frozen_balance + #{amount} WHERE user_id = #{userId} AND balance >= #{amount}; -- 提现失败:释放冻结余额 UPDATE account SET balance = balance + #{amount}, frozen_balance = frozen_balance - #{amount} WHERE user_id = #{userId} AND frozen_balance >= #{amount};必须强调的是,所有余额操作都要在数据库事务里,并且先扣可用余额再增加冻结,顺序不要反。如果反过来,并发场景下其他事务可能读到不该出现的中间态,虽然事务回滚后最终会恢复,但容易引发连锁问题。写完SQL之后,还要配套写一个“资金平衡校验”的定时任务,定期比对每个账户的流水汇总余额和账户表余额快照,做到余额一旦不一致马上告警。
3.2 交易模块:用状态机避免资金状态“灵异跳变”
交易单是金融系统最核心的业务对象。我们给交易状态设计了一个不可逆的状态图:
- CREATED:交易单创建
- PROCESSING:支付处理中
- SUCCESS:支付成功
- FAILED:支付失败
- CLOSED:超时关闭
每个状态能流转到哪些状态,必须在代码里写死。比如 CREATED 可以到 PROCESSING,也可以到 CLOSED;PROCESSING 可以到 SUCCESS 或 FAILED;但 SUCCESS 绝对不允许直接再到 FAILED。如果渠道回调说“这笔单失败了”,而本地状态已经是 SUCCESS,那只能告警,不能改单。这是资金安全的第一道闸门。
状态机最好用“代码枚举加状态前置条件”双重控制。我们每次更新状态都会带上前置状态:
UPDATE transaction SET status = #{targetStatus}, updated_at = CURRENT_TIMESTAMP WHERE txn_id = #{txnId} AND status = #{expectedStatus};如果影响行数为0,说明状态已经被其他请求改了,需要丢弃请求或者走人工核对。我见过太多资金事故,都是因为有人图省事直接 update status 不判断旧状态,结果一笔单从成功被改成失败,随后又触发了退款,造成双重扣款。状态机这一步真的不能省。
3.3 对账模块:差异怎么查、账怎么挂
对账是金融系统最后的兜底。渠道账单每天凌晨通过文件或接口拉下来,与本地交易流水做全量核对。我们设计了对账批次表和差异表,每天定时跑批,按渠道、按日期分别执行。
| 对账结果 | 含义 | 处理建议 |
|---|---|---|
| 长款(本地无,渠道有) | 可能是渠道单边入账,本地漏了回调 | 先挂“待认领”,再查本地流水 |
| 短款(本地有,渠道无) | 本地交易成功但渠道没记录 | 紧急冻结资金,人工核查 |
| 金额不一致 | 双向都有可能异常 | 以实际资金流水为准,发起冲正 |
| 状态不一致 | 比如本地支付成功但渠道是失败 | 立即告警,禁止自动退款 |
对账脚本不能只对总金额,必须逐笔比对,否则两边总金额相等但明细完全不同,这类事故最难查。我们还给每笔差异自动生成差异单,推送到人工复核队列。复核界面要支持一键查询本地流水、渠道原始报文和回调记录。这样处理一单差异通常只需要几分钟,而不是翻半天日志。
3.4 风控与合规:金融级API必做的几道防线
这里先不谈复杂的规则引擎和风控模型,只说金融级API必须做的基础防线:
- 签名机制:所有API请求必须带签名,签名规则统一,密钥保存在KMS或配置中心,不能硬编码。
- 防重放:请求带上时间戳和nonce,服务端校验时间窗,并在Redis对nonce做短期去重。
- 敏感数据加密:卡号、手机号、身份证号必须加密存储,加密算法用AES-GCM,密钥定期轮换。
- 日志脱敏:日志里禁止出现明文敏感字段,统一做掩码处理。
- 访问控制:内部服务间调用使用mTLS或独立token,外部调用走网关统一鉴权。
- 审计日志:关键资金操作必须记录操作人、来源IP、请求ID、前后状态,并保留足够长的时间。
很多人觉得这些“基础防线”是安全部门的事,直到被一次恶意重放或者内部数据泄漏折腾到焦头烂额。我见过有团队把用户手机号直接打在日志里,被运维顺手拖到临时调试文档,后来被安全扫描扫出来,整个项目被要求停工整改。这些成本根本不是技术能弥补的,所以宁可一开始就做扎实。
4. 常见故障排查与踩坑实录
4.1 事务里发消息,消息到了但事务回滚了
这个坑在微服务架构下几乎人人都会踩一次。业务逻辑里先调用RPC扣款,然后发了一条MQ消息通知下游,结果后面某一步校验失败抛异常,事务回滚了,但消息已经发出去了,下游基于错误数据做了操作,资金就乱了。
解决这个问题,最稳的办法之一是本地消息表:在本地事务里先插入一条消息记录,和业务数据同库同事务;事务提交后,由一个定时任务扫描未发送的消息,投递到MQ;投递成功后把消息状态更新为已发送。这样能保证要么业务数据和消息一起成功,要么一起失败。TCC、SAGA这类分布式事务方案也有自己的场景,但相对重,对小团队学习成本高。我在这个项目里大部分场景用本地消息表就解决了,只在跨服务一致性要求极高的少数链路上用了SAGA。
4.2 高并发扣款,余额并发更新怎么解
账户余额扣款经常遇到“同一笔钱被两次并发消费”的问题。前面说的带条件 update 已经解决了一部分,但热点账户的行锁依然会让性能急剧下降。最典型的场景是平台账户给很多用户发奖励,所有人扣的都是同一个账户的钱,这一行会被大量并发事务争抢。
我们的做法是拆分子账户,或者叫“余额池”。把平台账户拆成若干个子账户,每个子账户有独立余额,按用户ID的hash分散到不同子账户。发奖励时先路由到对应的子账户,再执行条件更新。这需要提前设计好分账规则,后期改会很痛苦。另一种思路是把更新操作队列化,用一个写入队列把针对同一账户的请求串行化,业务上接受最终一致。这个方案实时性会差一点,我一般只在渠道回调这类异步场景里用。
4.3 支付回调丢了,怎么把单“找回来”
支付成功以后,渠道会异步回调我们的接口。但回调丢失、延迟、报文损坏,都是常有的事。不能只依赖回调更新订单状态,必须有一个主动对账的兜底。
我们做了三件事:第一,支付成功后保存渠道流水号和本地流水号的映射关系,方便反向查询;第二,提供主动查询接口,定时轮询渠道,把超时未更新的单拉回来;第三,每天对账,把所有不一致的单全部暴露出来。轮询和回调之间必须幂等,同一个单被查到两次不能重复更新。总之,回调是靠“等”,轮询和对账是靠“拉”,只有把“等”和“拉”结合起来,交易状态才靠谱。
4.4 本地消息表、TCC、SAGA到底怎么选
这是每次设计评审都会遇到的问题。我个人的经验是:
- 本地消息表:适合“业务操作加发消息”这种异步通知场景,实现简单,直接利用业务数据库事务,覆盖大部分场景。
- TCC:适合两阶段业务操作都需要实时反馈,且业务逻辑能拆成 try、confirm、cancel 三段的场景,实现复杂,要处理空回滚和悬挂问题。
- SAGA:适合长事务链,每一步都有明确的补偿动作,通常配合状态机引擎使用,运维要求高。
在我们项目里,本地消息表覆盖了大约八成场景,SAGA覆盖了少数重要链路,TCC几乎没有用到。另外,哪怕用了分布式事务框架,也一定要在业务层保留对账机制,因为框架只能保证一致性协议,不能保证资金层面的业务逻辑正确性。
4.5 日志里明文卡号,差点酿成大祸
这是我很想分享的一个真实经历。有一次定位支付失败的问题,我随手在日志里打印了完整的请求体,里面包含用户卡号。当时没在意,等几个服务联调完,日志平台里到处都是明文卡号,安全部门做数据扫描时直接标红。
后来做了两层补救:一是日志配置文件里对敏感字段做过滤,自定义了一个转换器,把卡号、手机号、身份证号统一替换成掩码;二是对历史日志做清理,防止日志平台继续留存敏感信息。这件事之后,我要求所有新接口文档必须声明敏感字段,日志打印时一律用脱敏对象。千万别觉得这是小事,金融项目一旦出事,处理的时间成本根本不是技术能弥补的。
5. 项目上线后的一些个人心得
5.1 上线顺序和可复用资产盘点
如果让我重新排上线顺序,我会建议:账户模块和交易模块先行,先把最核心的资金链路打通;然后加一个最简单的支付通道;再做对账模块;最后才考虑复杂的报表和风控规则引擎。这样做的理由是,账户和交易一旦稳定,后续接入支付渠道会很快,因为所有业务都复用同一套记账逻辑,不会被某个渠道的差异化设计拖累。
整个项目沉淀下来最有复用价值的资产有三块:第一,统一交易状态机,任何业务线接入后都能少走弯路;第二,对账差异处理规范,从批次生成到差异告警一条龙;第三,资金操作审计日志模板,在审计和合规审查时几乎无可替代。这些资产不需要依赖具体业务,可以一直沉淀和演进。
5.2 如果重做一次,哪些地方我会调整
第一,我会把“币种”这个字段的复杂度提早考虑清楚。刚开始我们只做了单一币种,汇率、多币种、交易币种和结算币种不一致的情况很少,结果后面接跨境业务时,发现交易流水里缺少原始币种和结算币种两个字段,需要大范围改造,非常被动。
第二,我会更早引入契约测试。金融系统的接口升级影响面特别大,每次改动都靠人工联调太脆弱。后来我们用了消费者驱动的契约测试,不同服务之间的接口兼容性才算真正稳定下来。
第三,我会把“上线前拨测”和“故障演练脚本”做成常态,而不是上线前临时写。资金系统的故障演练越早越好,最好第一次上线前就做一轮模拟渠道慢响应、渠道超时、回调丢失的拨测,把这些故障当成正式用例来跑。
5.3 给后来者的一些小工具和习惯
最后聊几个我自己用着特别顺手的小工具和习惯。第一,所有资金变动测试都通过造数脚本走流水驱动,不要直接改数据库余额,否则测试数据脏了很难排查。第二,准备一个“资金巡检SQL”库,定期扫描异常状态交易、余额不为零但状态为失效的账户、长时间未处理的对账差异。第三,敏感操作全部做成“带理由的变更记录”,哪怕只是后台人工调整一个订单金额,也要记录操作原因,否则事后解释不清。第四,监控里永远保留三类紧急告警:单笔金额超过阈值、单日累计退款超过阈值、差异单积压数量超标。这三类告警能帮你第一时间发现资金链路异常。
这些习惯看起来都很小,但在金融系统里,小习惯往往决定了是出大问题还是平安无事。