做技术这十几年,我参与过的项目里最有意思也最折磨人的,就是内部代号叫financial-services的金融服务中台项目。它不是一个单一系统,而是把支付、账户、清结算、风控这些金融能力全部“服务化”之后拼出来的一整套底座。这个项目最终支撑了多个业务线的资金流转,也让一批研发从“只会写CRUD”硬生生练成了“能讲清楚每一笔钱怎么走”的人。这篇文章就是对整个项目的一次复盘,从架构设计、核心链路落地,到对账不平、重复扣款这些线上事故的排查过程,全部摊开讲。无论你是准备做金融中台的技术负责人,还是刚接触账务系统的后端工程师,应该都能从这里找到一些能直接用的经验。
1. 项目概述与业务定位
1.1 核心需求解析
“financial-services”这个名字听起来很大,但真正把它拆开之后,核心其实就是两个字:服务。把金融能力拆成一个个可以被复用、被编排、被独立治理的服务单元。这背后有一个非常现实的业务痛点:多个业务线都在做支付、做记账、做对账,每家都自己接渠道、自己建表、自己写一套状态机,结果就是重复开发严重,口径经常对不上,出了问题互相扯皮。这个项目要解决的就是把散落在各个业务线里的金融能力收拢到一个统一的中台,新业务接入时不需要再从零做一套资金系统,直接调用中台服务就行。
围绕这个目标,我们梳理出几个关键需求。账户体系要统一,所有业务线共用一套开户、冻结、解冻、查询的接口;交易和账务要分离,交易记录负责描述用户做了什么,账务流水负责证明钱怎么变动;清结算要自动化,渠道日终文件到了之后能自动对账、自动轧差、生成结算单;风控要在交易链路上实时生效,可疑交易能在到达账务系统之前被拦截。这几点看起来并不复杂,但当交易量上来、业务规则开始膨胀的时候,每一块都会变成独立的深水区。
1.2 系统边界与应用场景
界定系统边界是我在这个项目上做得最早、也最受益的一件事。边界划清楚了,才不会出现业务方觉得“你们中台什么都能干,帮我做个报表吧”这种需求蔓延。financial-services 这个项目只负责跟资金相关的通用能力,具体来说包括账户生命周期管理、支付单与交易流水、账务复式记账、渠道清分与结算、交易风控、差错处理和自动对账。它不负责具体业务逻辑,比如电商的购物车、内容平台的会员权益、营销活动的优惠券计算,这些全部留在业务侧。
明确边界之后,应用场景就非常清晰了。电商业务接入时,只需要在用户下单后调用创建交易单接口,支付完成后回调通知业务方;企业钱包业务接入时,只需要开户和余额查询;营销发奖场景接入时,只需要使用转账接口。整个中台以 API 的方式对外提供服务,业务方不感知内部的账务怎么记、渠道怎么清,只感知“钱有没有到账”“余额对不对”。这种边界设计的好处是:中台团队可以专注打磨资金链路的稳定性和一致性,而不是被各种业务特性牵着走。
2. 总体架构设计与技术选型思路
2.1 为什么直接微服务拆分
项目立项时,团队内部曾讨论过要不要用单体应用快速上线,毕竟账务、支付、清结算这几个模块在代码层面确实可以放在一个仓库里。但我坚持从一开始就按微服务拆,理由很现实:金融场景的链路长、模块边界清晰,部分模块的性能和容灾要求差异巨大。支付服务需要抗住瞬时高并发,账务服务必须保证严格的事务一致性,风控服务则希望独立扩展且不影响主链路。如果做成单体,一旦风控模块的规则计算把 CPU 打满,支付和账务也会跟着遭殃,这是绝对不能接受的。
拆成微服务之后,每个团队可以各自负责一个领域,数据库也彻底分离开,互不干扰。这里要特别提醒一句:微服务不是拆得越细越好。我们一开始也拆出过“支付回调服务”“支付查询服务”这种按接口拆的粒度,结果服务数量翻倍、运维压力剧增,收益却没看到。后来统一按业务域收敛,只保留了账户、交易、账务、清结算、风控、对账总共六个核心服务,外加一个接入层网关。这个粒度既能把故障隔离,又不至于让一次支付链路调用十来个服务。
2.2 核心模块职责划分
六个核心服务的职责划分如下:
| 服务模块 | 核心职责 | 关键接口/能力 |
|---|---|---|
| 账户中心 | 用户/商户账户生命周期管理,余额查询与变动 | 开户、销户、冻结、解冻、余额快照 |
| 交易中心 | 支付单状态管理,渠道交互,回调处理 | 创建交易单、渠道下单、回调处理、状态查询 |
| 账务中心 | 复式记账,流水记录,余额更新 | 记账、流水查询、日终余额核对 |
| 清结算中心 | 渠道对账、手续费计算、结算单生成 | 渠道文件解析、对账、轧差、打款指令 |
| 风控中心 | 实时交易风险识别,黑白名单管理 | 规则引擎、限频、黑白名单、设备指纹 |
| 对账中心 | 内部与渠道之间的资金核对 | 差异明细生成、差错处理、差错重试 |
这种划分在实施过程中被验证是合理的。交易中心只关心“这笔支付成功了没有”,至于这笔钱怎么记到用户的余额里,它调用账务中心就行。账务中心不关心支付走的是微信还是支付宝,它只负责把一笔业务事件变成借贷两条流水。清结算中心也不关心业务形态,它只认渠道文件的格式,逐行逐字段比对。职责单一,代码的可读性和可维护性都会好很多。
2.3 关键依赖与基础组件选型
技术选型这件事,我们在项目初期花了不少时间讨论,核心围绕开发语言、RPC框架、存储、消息、分布式事务方案五个方面。
开发语言选了 Java 17,主要原因是团队积累多,生态里关于支付、加解密、规则引擎的成熟库也比较丰富。RPC 框架最终选了 Dubbo 而不是 Spring Cloud,理由很务实:中台内部是强依赖的 Java 体系,Dubbo 的直连性能和微秒级延迟表现更好,且与公司内部已有的注册中心、链路追踪系统能直接打通。Spring Cloud 全家桶在服务治理上更全面,但对我们这种以内网调用为主、外部流量统一走网关的场景来说,Dubbo 更轻、更直接。
存储方案是整个架构里最值得讲的部分。交易、账务相关的数据全部用 MySQL,并且做了分库分表。账务库单独使用一个物理集群,不跟交易库混跑,避免慢查询互相影响。热点数据用 Redis 做缓存,主要承载账户余额的热操作、风控限频计数、分布式锁。消息队列选了 Kafka,用于削峰填谷以及异步场景的解耦,比如支付成功后的清结算触发、风控异步名单更新。分布式事务没有用 Seata 那种全局事务框架,而是根据场景混合使用本地消息表和 TCC,原因我会在后面专门展开。
3. 金融服务核心链路落地实践
3.1 账务核心:流水与余额分离的底层设计
账务系统是整个项目里最不能出错的部分,因为它直接关系到“钱对不对得上”。我们设计的第一原则是:流水表是事实,余额表只是结果。任何一笔余额变动,都必须先写一条不可变的账务流水,再通过流水去更新账户余额。一定不能直接拿着业务传来的金额去 update 余额表,那样一旦扣款顺序乱了或者重复执行,余额就会出错且无法追溯。
流水表的核心字段包括账务流水号、账户编号、交易单号、业务类型、借贷方向、发生金额、变动前余额、变动后余额、创建时间、渠道编号。这张表只做插入,不做更新,也不做物理删除。余额表反而非常简单,只存账户编号和当前余额、累计收入、累计支出。每次更新余额时用“余额必须大于等于0”或者“版本号必须等于预期版本”作为一致性校验条件。这里我贴一下初始版本的核心建表语句,字段名我简化过:
CREATE TABLE account_balance ( account_id BIGINT PRIMARY KEY, balance DECIMAL(18,2) NOT NULL DEFAULT 0, total_income DECIMAL(18,2) NOT NULL DEFAULT 0, total_expense DECIMAL(18,2) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE account_flow ( id BIGINT AUTO_INCREMENT PRIMARY KEY, flow_no VARCHAR(64) NOT NULL UNIQUE, account_id BIGINT NOT NULL, trade_no VARCHAR(64) NOT NULL, biz_type VARCHAR(32) NOT NULL, direction TINYINT NOT NULL COMMENT '1=收入, 2=支出', amount DECIMAL(18,2) NOT NULL, before_balance DECIMAL(18,2) NOT NULL, after_balance DECIMAL(18,2) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_account_time (account_id, created_at), KEY idx_trade_no (trade_no) ) ENGINE=InnoDB;流水与余额分离这套设计的最大优势是:如果某天余额对不上了,可以通过重放流水来重建任意时刻的余额。实际运营中我们也遇到过线上脏数据,这时候不是去手工改余额,而是通过在对账系统修复流水,让余额表重新收敛。这里还有一个非常容易忽略的细节:before_balance 和 after_balance 两个字段必须都存下来,否则出问题时要重算历史余额,成本极高。我见过一些团队为了省存储不存这两个字段,事后排查对账差异时查得痛不欲生。
3.2 支付、清结算链路与状态机设计
支付链路是 financial-services 项目最核心的业务路径。一笔支付请求从进入到网关开始,经过交易中心、风控中心、支付渠道,最终回调账务中心记账,链路非常长。我们给交易单设计了一套严格的状态机:初始、处理中、成功、失败、已关闭、退款中、已退款。状态只能按固定方向迁移,比如处理中只能流向成功或失败,不能从成功回到处理中。这个约束在代码里通过状态机框架统一校验,不允许业务代码里随手 setStatus。
链路里的第一个关键点是幂等。用户点击支付按钮之后,网络抖动、前端重试、渠道回调重复通知都可能导致同一笔交易被提交多次。我们的做法是:交易中心在创建交易单时生成一个全局唯一的业务单号,所有下游处理都以这个单号作为幂等键。账务中心记账时,先查 trade_no 是否已经存在流水,存在就直接返回成功,不存在才真正写流水。光靠查询还不够,账务流水表的 trade_no 字段一定要建唯一索引,用数据库约束做兜底,否则高并发下两条相同单号的请求同时进来,查询都发现没有记录,就都会去写,造成重复记账。
// 账务中心幂等记账的核心逻辑,省略了事务边界外的细节 public LedgerResult book(BookRequest request) { boolean existed = accountFlowMapper.existsByTradeNo(request.getTradeNo()); if (existed) { return LedgerResult.alreadyExists(); } // 同一事务里插入流水并更新余额 return transactionTemplate.execute(status -> { accountFlowMapper.insert(buildFlow(request)); int updated = accountBalanceMapper.decreaseBalanceIfEnough( request.getAccountId(), request.getAmount(), request.getVersion()); if (updated == 0) { status.setRollbackOnly(); return LedgerResult.balanceNotEnough(); } return LedgerResult.success(); }); }清结算链路的设计相比支付链路稍微滞后一些,因为它不是实时路径,而是按日处理。渠道在日终提供交易文件,清结算中心拉取文件后先做格式校验、批次号校验,然后逐笔跟内部交易单比对。比对的维度包括渠道订单号、金额、手续费、交易时间、交易状态。比对通过的资金会进入待结算池,按照结算周期和结算规则生成结算单,最后把结算指令下发给资金划拨服务。在整个清结算流程里,最怕的是渠道文件来得晚或者文件格式变更,所以我们预留了手工上传和双人复核的后门,宁可让结算晚几个小时,也不能让错误的资金指令发出去。
3.3 风控与合规能力设计
风控在金融系统中的地位不是“要是能拦住就拦”,而是“必须拦住该拦的,同时不能误伤正常交易”。我们选择用规则引擎来实现第一层实时风控,而不是直接上机器学习模型。原因很现实:规则引擎的可解释性强,出问题的时候能明确知道是哪条规则拦截了交易,这对处理客诉和合规审计都极其重要。模型可以后续叠加,但基础防线必须建立在白盒规则上。
实时风控规则主要分三类。用户维度:账号是否在黑名单、登录设备是否异常、短时间内的下单频率是否超限。交易维度:单笔金额是否超过阈值、与历史消费习惯是否有明显偏离。渠道维度:同一渠道同一银行卡的调用频次、渠道侧最近是否存在高失败率。这些规则综合打分会得到一个风险等级,高风险直接拒绝,中风险跳转人工审核,低风险放行。风控服务独立部署,并且设置了超时降级开关,一旦风控响应超过 50 毫秒就自动放行并异步补审,避免因为风控拖垮整个支付链路。
合规这块,我们做的不仅是日志保存,还包括敏感数据加密、脱敏展示和权限审计。用户的手机号、身份证号、银行卡号在数据库里全部加密存储,返回给前端时统一脱敏;内部后台查询需要走审批流,且每次查询都会留下审计日志。另外,所有涉及资金变动的操作都要记录操作人、操作时间、IP 和设备信息,这既是安全要求,也是后续争议处理时最重要的证据。
4. 稳定性、安全性与高可用保障
4.1 高可用与容灾设计
金融系统对可用性的要求不是“尽量别挂”,而是“挂了也要快速恢复,且不能丢数据”。我们的架构从设计第一天就考虑了双机房部署。应用层无状态,可以通过负载均衡在两个机房间切换流量;数据库层采用主从架构,主库在 A 机房,从库在 B 机房,并且开启了半同步复制,确保主库提交的事务至少被一个从库接收后才返回成功。这样即使 A 机房整体故障,也能在短时间内把流量切到 B 机房,把数据损失降到最低。
除了机房层面的容灾,服务层面的降级设计同样重要。支付高峰时最怕的是外部依赖超时拖垮线程池。我们的做法是给所有外部依赖和内部服务调用都设置了超时时间和线程池隔离。风控服务挂了不能影响支付主流程,用快速降级让支付继续;对账服务挂了不能影响日间交易,用消息堆积让夜间慢慢消化;缓存 Redis 挂了不能影响余额查询,用开关直接降级到数据库。每条降级路径都经过压测验证,线上切流之前还专门做了故障演练。
4.2 金融级数据一致性方案
数据一致性是这个项目里讨论最多、踩坑最深的话题。账务更新这个动作本身是强一致要求,必须在同一个 MySQL 事务里保证流水插入和余额更新同时成功或同时失败,不能出现流水记了余额没扣的情况。这个要求通过本地事务就能满足,难的是跨服务的最终一致性。
交易中心的支付成功回调与账务中心的记账,本质上是两个服务之间的数据同步。我们最终采用了“事务消息 + 本地消息表”的方案。交易中心在本地事务里同时完成交易单状态更新和消息记录插入,然后通过一个定时任务把未确认的消息发送到 Kafka,账务中心消费消息后执行记账。整个过程里如果账务中心处理失败,消息会重试;如果重试多次还是失败,消息会进入死信队列,由人工介入处理。这个方案的优点是没有全局锁,性能损失小,而且通过消息表可以明确看到哪些消息处于未确认状态。
对于部分强实时性要求更高的场景,比如用户账户之间的即时转账,我们用了 TCC。Try 阶段冻结两个账户的对应金额,Confirm 阶段完成扣减与增加,Cancel 阶段释放冻结。TCC 的落地复杂度比消息表高不少,需要每个业务接口都实现对应的 confirm 和 cancel 方法,但它能提供更精确的事务边界和更好的实时一致性。实际选择时我们只对少数关键场景用了 TCC,其余一律走消息最终一致,避免过度设计导致维护成本失控。
4.3 安全与密钥管理实践
金融服务系统的安全防线,必须做到即使数据库被拖走,核心敏感数据仍然不可读。我们对用户敏感字段使用 AES-256 进行加密存储,密钥由独立的密钥管理系统统一管理,并定期轮换。接口层使用 RSA 签名防止报文被篡改,配合时间戳和随机数防止重放攻击。内部服务之间使用双向 TLS 互相认证,确保调用方确实是被信任的服务,而不是伪造的请求。
这里要特别提醒一个容易被忽视的点:日志和监控里的敏感信息。很多系统数据库加密做得很好,但日志里直接打印了完整的手机号和银行卡号,这就等于把加密的意义抹掉了。我们对日志框架做了统一的脱敏拦截器,手机号、身份证号、银行卡号等字段在进入日志输出流之前自动替换中间四位。这个工作早做比晚做好,等上线之后再去排查历史日志里的敏感信息,是一个非常痛苦且难以彻底完成的事情。
5. 常见问题与排查技巧实录
5.1 对账不平怎么排查
“对账不平”这四个字,是金融服务项目里最能让人血压飙升的词。说一个我们真实遇到过的情况:某天渠道文件显示成功 1020 笔,内部系统只有 1018 笔,差了两笔。第一反应是别慌,先核对批次号和文件日期。发现文件日期对应的是自然日,但内部系统的时间口径用的是交易发生时间,存在跨天边界差异。这只是最基本的一层,还有可能是渠道侧撤销了交易但回调没有到达,或者渠道文件里包含测试交易而我们内部没有。
对账差异可以简单分成三类:渠道多内部少、内部多渠道少、金额不一致。排查的时候按这个顺序查最快:先看交易状态,渠道成功而内部失败,查回调环节;再看金额,同样的订单号两边金额不同,查手续费计算;最后看时间,两边时间相差较大,查是否有时区或批次边界问题。建议把这几类差异整理成对照表,让值班同学照着表格定位,不要每次都用直觉去翻日志。
| 差异类型 | 可能原因 | 优先排查方向 |
|---|---|---|
| 渠道多,内部少 | 渠道回调丢失、内部幂等误拦截 | 回调日志、消息消费记录 |
| 内部多,渠道少 | 渠道撤销未同步、测试数据混入 | 撤销通知、渠道文件过滤条件 |
| 金额不一致 | 手续费、退款分批、优惠分摊 | 手续费计算逻辑、退款明细 |
5.2 幂等失效导致重复扣款
重复扣款是资金系统最严重的事故类型之一,我们踩过一次,教训极其深刻。当时有个聚划算式的抢购活动,用户在活动页疯狂点击支付按钮,前端做了节流,但同一个支付单在极端情况下还是同时进来了两个请求。问题出在早期版本里账务中心只依赖查询流水表来判断幂等,没有给 trade_no 建唯一索引。两个请求同时进入,都查询不到记录,都执行了记账,用户被扣了两次款。
排查过程很快,通过 traceId 定位到同一个 trade_no 对应了两笔账务流水,确认是幂等失效。修复方案分两步走:第一步立即给 trade_no 加唯一索引,从数据库层面彻底杜绝重复插入;第二步在交易中心入口处增加分布式锁,同样用 trade_no 作为锁的 key,锁内再做一次流水查询。这里我要提醒所有做资金系统的朋友:幂等的最终防线一定是数据库唯一约束,Redis 锁、分布式锁、应用层查询都存在失效的可能,只有唯一索引能在最极端的情况下兜住。
5.3 性能瓶颈与热点账户处理
系统上线一段时间后,我们遇到了一个经典的高并发问题:某个头部主播的直播间里有大量用户同时打赏,形成热点账户,所有打赏都要更新同一个主播的余额。MySQL 行锁竞争瞬间飙升,余额更新成功率下降,连带整个账务服务响应变慢。这时候才意识到,账务系统只考虑“正确”是不够的,还要考虑“在热点下依然正确”。
我们最终的方案是热点账户余额分片。把一个账户的余额拆成多个子账户,每个子账户有独立的余额和流水,入账时随机选择或轮询写入一个子账户,出账时从多个子账户汇总扣减。这个方案带来一个额外成本:查询总余额时需要聚合多个子账户,如果子账户数量太多,聚合开销会变大。所以我们对热点账户的动态识别也做了优化,只有交易量超过阈值才自动升级为分片模式,普通账户保持单余额表即可。另外,针对打赏这类一次写多读少的场景,我们还做了入账缓冲队列,先把入账请求打到 Kafka,由账务服务异步批量合并写入,进一步降低数据库压力。
6. 写在最后的实操体会
项目上线到现在,最深的感受是金融服务项目的难点从来不只是技术,而是对“数据可信”这四个字的敬畏。每一笔交易背后都是真实用户的资金,任何一次想当然的设计,都可能在线上变成事故。我的建议是:做账务系统时尽量把方案设计得“笨一点”,用最简单可靠的模型去承载业务,不要用花哨的技巧去换性能和复杂度;幂等、对账、审计这三件事,在项目第一天就要纳入设计,而不是等出问题之后再加。最后再分享一个小技巧,每次发布前,把核心资金链路的接口文档打出来,用笔把每一步的状态流转和数据变更画一遍,画不出来的地方就是风险所在。这个方法帮我们避免了很多次潜在的事故,也成了团队后来雷打不动的发布前常备动作。