ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

金融系统架构重构实战:领域建模、幂等与对账设计

金融系统架构重构实战:领域建模、幂等与对账设计 financial-services这个项目名字第一眼看过去平平无奇就像工厂里的生产部一样朴实。但只要你真的做过金融系统就会明白这两个词背后装了多少让人头大的事情账户、支付、清算、对账、营销、风控、合规……每一块拎出来都能写一本“血泪史”。我这篇文章想讲的就是这样一个项目——一个传统金融机构里跑了好几年的老系统在经过几轮业务堆叠之后被我们整建制重构成金融服务域的过程。这篇文章适合谁看如果你是做后端开发、架构设计或者正在接手一个金融/支付/账务相关的老系统想搞清楚这类项目是怎么拆、怎么落地的那这篇内容应该能帮你省掉不少摸索时间。我也会把踩过的坑、试过的方案、最终留下的配置直接列出来你可以照着思路去评估自己手上的系统不保证百分之百复用但至少能让你知道从哪儿下手、哪些地方要格外小心。1. 先把“financial-services”这个名字拆开它到底在做什么1.1 名字背后的真实业务场景金融服务业覆盖面极广从银行到保险、从支付到资管都属于这个范畴。但我们做工程的人最关心的不是牌照边界而是这些业务落到系统上它们到底在干嘛。拆开来看绝大多数金融服务的线上化核心就这几件事账户的建立与管理、资金流的发起与转移、账务的登记与核算、业务行为的合规记录。如果你接到一个项目叫financial-services大概率不是让你从零做一个央行级别的核心系统而是把某个金融机构内部散落的账户、支付、产品、渠道能力收拢到一个相对统一的域里面。这种项目在真实工作中特别常见因为早年很多金融机构的系统是按“业务线”建的理财一套、信贷一套、代发一套、收单一套……每一套都有自己的用户体系、账户体系、流水体系。表面上看起来业务跑得欢实际上数据是割裂的、流程是断头的、出了差错账很难平。所以我接到这个项目后的第一反应是这不止是一个技术重构更是一次业务域梳理。你要做的不是把代码重写一遍而是把散落的能力重新归位让账户是账户、交易是交易、产品是产品彼此之间有清晰的边界和契约。1.2 这类项目真正要解决的核心问题这类项目表面上叫金融服务平台升级或系统整合但其本质要解决三个层面的问题。业务层面不同系统对同一个客户、同一笔交易的定义不统一。比如A系统叫客户编号B系统叫用户IDC系统干脆用手机号做主键。这种不一致在单体系统里勉强凑合但只要涉及跨系统核对立刻变成灾难。金融系统里资金是不能出错的对不上账就意味着钱可能错位这是所有问题里最要命的。技术层面老系统大多是单体架构数据库直接连核心表接口文档缺失动不动就是几百上千行的存储过程。最麻烦的是很多老系统的状态流转是隐式的藏在代码的if-else里面外人根本捋不清楚。这种系统改起来风险极大牵一发动全身。合规层面金融服务对审计要求极高所有资金操作都要有完整的痕迹链谁在什么时间做了什么操作、资金从哪笔账户流到哪笔账户、系统为什么判定这笔交易合法这些都要能回溯。老系统往往日志不完整数据变更没有留痕这在合规审查的时候就是致命伤。所以我在项目一开始就定了基调这不是一个纯技术项目必须把业务梳理和技术改造同步推进。所有模块的拆分必须以业务边界为基准而不是以代码结构为基准。后面我们所有的设计都是围绕这个基调展开的。2. 整体设计思路架构选型背后的取舍2.1 为什么不能照搬互联网电商那套架构很多从互联网大厂转到金融行业的同学上手就喜欢画微服务架构图网关、注册中心、配置中心、分布式事务、消息队列……这套组合拳在电商场景下确实没问题但放到financial-services这个场景有几个地方必须降级处理。最核心的区别在于电商系统的容错策略是重试金融系统的容错策略是对账。你在淘宝下单失败重新提交一单就行顶多产生一笔重复订单人工或者系统兜底取消掉。但在金融服务里一笔扣款失败后盲目重试可能导致用户被重复扣款这就是资金事故。所以金融系统的第一原则不是高可用优先而是一致性优先、可追溯兜底。这直接导致了架构设计上的差异。在支付和账务链路里我大量的精力不是放在接口吞吐量上而是放在幂等控制、状态机约束和对账机制上。比如一笔转账请求用户点了三次提交系统必须保证资金只转一次三次请求返回同一个结果。这种需求在电商里也做但在金融里它是红线不是优化项。另外金融场景对数据一致性的要求让分布式事务变成了一把双刃剑。用Seata这类方案全局锁性能损耗大跨系统协调复杂不用分布式事务纯靠最终一致性又担心链路断裂导致账目不平。我的取舍是核心账务链路尽量不跨库、不跨服务。能在一个事务边界里完成的资金操作绝不拆成两个分布式调用必须在不同的服务间流转的就用本地消息表对账任务来保证最终一致。2.2 领域建模按业务边界拆不按技术便利拆financial-services到底要拆成多少个服务这是项目组里争论最多的问题。有些人主张按资源拆分账户一个服务、流水一个服务、产品一个服务简单清晰。但我做过的项目告诉我这种拆法在建初期好看建完后每个人都在写跨服务调用因为业务行为天然不是按资源来组织的。举个例子用户购买理财产品这个行为涉及余额冻结、产品份额登记、持仓更新、交易流水生成、收益计算参数联动。如果你把账户、产品、流水拆成三个独立服务那么一次购买就要跨三次服务调用其中任何一次失败都要处理回滚。而这类长链路恰恰是把系统搞复杂、把排查搞崩溃的源头。所以我的建模思路是以业务能力为边界把高频强一致的操作放进同一个模块里。理财购买是一个能力、账户出入金是一个能力、对账处理是一个能力。每个能力模块内部可以有多个数据库表甚至可以有独立的存储但对外暴露的是一组高内聚的操作接口。这种拆分方式的好处是业务链路短、事务边界清晰、出现问题只需要在一个模块内部排查不用在几十个服务之间跳来跳去。为了让大家直观感受我放一张我们最终领域划分的表。这个表是我们和业务方对了好几轮才定下来的也是整个项目最核心的设计交付物之一领域模块核心职责关键数据主要服务账户域客户账户生命周期、余额管理账户表、余额流水表、冻结明细表account-service交易域交易受理、订单状态流转、幂等控制订单表、交易明细表、幂等记录表trade-service清结算域资金清算、结算批次、差错处理清算批表、结算明细表、差错登记表settlement-service产品域产品定义、定价参数、上下架产品表、利率/价格参数表product-service客户域客户信息、证件、资质、风险等级客户主数据、证件表、风险评级表customer-service对账域内外对账、长短款处理、差异报告对账批次、差异明细、调整记录表reconciliation-service这个划分原则其实很简单让每个模块能独立解释一个业务流程而不是让一个业务流程辗转于五个模块之间。后续所有的开发、测试、故障排查都基于这张表展开。3. 实操过程与核心环节实现3.1 工程结构与关键配置架构落地的第一步是建工程。我以交易域为例讲讲我们最终采用的工程结构。技术栈是Java 17、Spring Boot 3.2、MyBatis-Plus、MySQL 8.0、Redis和RabbitMQ。这套选型不花哨但在金融项目里足够稳社区资料多出了问题好找人。交易域的核心代码结构大概长这样trade-service ├── src/main/java/com/example/trade │ ├── controller # 暴露给网关的接口 │ ├── application # 应用服务层承载用例编排 │ ├── domain # 领域模型订单、交易、状态流转 │ ├── infrastructure # 数据访问、MQ、缓存、外部客户端 │ └── common # 统一响应、异常、工具类 ├── src/main/resources │ ├── mapper # MyBatis XML文件 │ └── application.yml └── pom.xml这里我要强调一下domain层的价值。很多同学做后端喜欢把业务逻辑写在service里service一长就是几千行业务规则散落在各个方法中没有人能说清楚一笔订单的完整状态机。我们这次强制要求状态流转逻辑放在domain层用状态机模式来管理。以订单状态为例我们定义了这么一张流转表当前状态允许的操作目标状态INIT提交PROCESSINGPROCESSING成功回调SUCCESSPROCESSING失败回调FAILEDPROCESSING超时未回调CLOSEDSUCCESS撤销REVOKEDFAILED重试提交PROCESSING状态机的代码实现不复杂但必须放在domain层保证任何上层逻辑都不能绕过状态机直接修改订单状态。这是金融系统的命根子状态不可跳跃、不可回退除了撤销每一步都要有依据。3.2 核心场景实现幂等设计与资金操作资金操作最怕重复幂等设计是金融后端的基本功。我拿一个典型场景——用户余额充值——来说说我们怎么做的。用户从前端发起充值请求时网关会生成一个全局唯一的请求流水号requestId。这个流水号不是数据库自增ID而是由发号器生成的、带业务标识的UUID。请求到达trade-service后第一件事不是扣钱或加钱而是查幂等表。幂等表结构大概是这样的CREATE TABLE idempotent_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL COMMENT 请求唯一标识, biz_code VARCHAR(32) NOT NULL COMMENT 业务类型, user_id VARCHAR(32) NOT NULL COMMENT 用户ID, request_body TEXT COMMENT 请求报文, response_body TEXT COMMENT 响应报文, status TINYINT NOT NULL COMMENT 0处理中 1成功 2失败, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_request_id (request_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;处理逻辑分三步先插入幂等记录利用唯一索引防止并发重复再执行业务操作最后更新幂等记录状态。如果插入时冲突说明是重复请求直接查旧记录返回旧结果不再执行资金操作。这个方案的巧妙之处在于它把重复请求和业务逻辑完全解耦了。无论请求是用户手抖、网络重试、还是MQ重复消费只要requestId一致最终都只会执行一次资金操作。实测下来这个方案在并发1000次相同请求的压力下依然只有一个请求真正入账。3.3 对账方案用对账兜底一切意外即便有了幂等、事务和状态机资金系统依然可能出现账实不符。原因可能是第三方渠道返回了未知状态、数据库主从延迟导致读到旧数据、或者半夜某个任务挂了没有完成结算。这时候靠什么兜底靠对账。我们的对账方案分三级。第一级是内部对账每天晚上定时任务扫描交易流水和账户余额变动流水核对每笔交易产生的借贷金额是否平衡。这里有金融领域最经典的一个恒等式期初余额 本期转入 - 本期转出 期末余额。只要这个等式不成立立刻触发告警由专人介入排查。第二级是渠道对账对接微信、支付宝、银联等外部渠道的账单文件把我们的交易流水和渠道账单按交易日逐笔比对。比对规则是我们有而渠道没有的交易标记为长款渠道有而我们没有的交易标记为短款。长短款必须当天处理不能滚到下一个工作日。第三级是总分核对每天核对客户明细账和总账科目的汇总值是否一致。这一级看起来最笨但恰恰最能发现问题——很多坑都是在总分不平的时候被揪出来的。对账任务的调度我们用的xxl-job每天凌晨2点跑。如果发现差异会生成差异明细表并自动通知对账组。这里给个建议对账不是为了找bug而是为了建立信任。当你向业务方证明系统每一分钱都对得上后面所有的变更都会好做很多。4. 常见问题与排查技巧实录4.1 我踩过的几个真实故障再好的设计也扛不住时间的考验。上线半年内我们遇到了不少有意思的问题挑几个有代表性的说说。第一个是并发扣款超扣。现象是某个客户的账户余额变成了负数但业务上这是不允许的。排查结果是扣款接口用了先查余额、再更新的逻辑两个并发请求同时读到余额100元分别扣了80元和50元最后余额变成-30元。解决办法是改用原子更新UPDATE account SET balance balance - #{amount} WHERE account_id #{id} AND balance #{amount}把判断和扣减合并成一条SQL让数据库行锁来保证并发安全。这条SQL至今我还留着每次有人跟我讨论乐观锁我都会拿这个例子说事。第二个是MQ重复消费导致重复入账。消息队列本身是at-least-once语义也就是说在极端情况下同一条消息会被投递多次。我们的某个下游服务在消费消息时没有做幂等结果用户充值消息被消费了两次账户余额翻倍。这个问题在测试环境很难复现因为需要正好赶上消费者重启或者网络抖动。修复方式就是前面说的幂等表所有的MQ消费者在执行业务前先查幂等记录。第三个是状态回跳问题。我们的订单状态机里没有很严谨地控制状态更新的SQL条件导致两个线程同时更新同一笔订单时后更新的把先更新的状态覆盖了。比如一笔订单已经从PROCESSING变成SUCCESS但另一条请求又把状态改回了PROCESSING。修复方式是在更新语句里加上状态条件UPDATE trade_order SET status #{newStatus} WHERE order_id #{orderId} AND status #{expectStatus}。这个技巧俗称CAS更新在状态机场景下非常实用。我把这些问题的特征和解决方案整理成一个表方便大家排查时参考问题现象根因解决方案排查手段余额变负数先查后更新并发超扣原子更新SQL按account_id查余额流水重复入账MQ重复消费无幂等幂等表唯一索引按requestId查幂等记录状态回跳更新无状态条件CAS条件更新查订单变更日志无法关单超时任务和回调竞争状态机分布式锁查任务执行日志和回调日志4.2 数据一致性与分布式事务的取舍关于分布式事务我在前面提过一个大原则核心账务链路尽量不跨服务。但现实中总有绕不开的场景比如开立账户的同时要初始化产品持仓这两个操作分属不同的库。我们最终选用的方案是本地消息表最终一致性。简单描述一下流程事务A在自己的数据库里写入业务数据和一条消息记录两者在同一个本地事务中提交然后一个异步任务把消息记录投递到MQ事务B消费消息执行自己的业务操作执行成功后将消息标记为已消费如果执行失败消息会进入重试队列重试多次仍然失败则进入死信队列由人工介入处理。这个方案的取舍是它放弃了强实时的一致性但换来了极高的稳定性和可排查性。因为在金融场景里晚几秒钟一致是可以接受的但数据错了还查不出来是不能接受的。每个环节都有消息记录可以查出问题能定位到具体哪一步这对运维来说太重要了。至于Seata这类分布式事务框架我的建议是除非你的团队有非常深入的理解否则不要轻易在生产环境的资金链路中使用。分布式事务锁粒度大、性能损耗高、协调器一旦出故障恢复也复杂。很多看起来必须用分布式事务的场景重新梳理一下业务边界后都可以用本地消息表解决。4.3 金融服务系统里的安全与审计细节金融项目上线前安全测试和审计检查是躲不掉的。这里说几个我们当时做了、后来发现特别有用的点。第一敏感字段加密。客户手机号、证件号、银行卡号不能明文存数据库。我们用的方案是应用层加密Java侧用AES加密后存库读取时按需解密。查询条件里的手机号需要支持精确匹配所以我们额外加了一张映射表存手机号的哈希值和密文的对应关系查询时先按哈希定位再解密读取。第二全链路操作日志。不只是登录日志关键是资金操作的日志。谁在什么时间对哪个账户做了什么操作操作前的余额快照、操作后的余额快照、请求流水号、返回结果全部记录下来。这些日志写到独立的日志库保留至少三年谁也不准手动删。第三分权分责。开发、运维、审核人员有不同的权限边界。开发人员能看到测试库数据但生产日志的查询权限单独管控生产环境的数据变更必须走审批流程变更脚本由DBA统一执行。这些看起来不像是技术活但少了任何一个项目上线后审计这一关都过不去。5. 上线效果与沉淀下来的经验5.1 性能表现与稳定性复盘项目上线三个月后我们做了一次复盘。核心交易接口的TP99从重构前的800ms降到了260ms主要得益于去掉了大量跨服务调用和数据库慢查询。整个金融服务域的每日交易处理量从原来的20万笔提升到60万笔系统没有出现一次资金差错。最让我欣慰的是对账环节——连续90天内部对账和总分核对全部平账这在老系统时代是根本不敢想的事。当然这些数字不全是技术优化的功劳。业务方在梳理过程中也理清了很多历史冗余规则删掉了不少不知道谁加的但没人敢动的逻辑。系统变快一半是因为代码变好一半是因为业务变清晰。5.2 几条个人体会体会一金融系统的架构设计本质是在设计风险边界。哪里可以允许短暂不一致、哪里必须强一致、哪里出了问题靠对账兜底、哪里出了问题必须实时阻断这些判断比任何一门中间件技术都重要。体会二状态机和幂等是金融后端的左膀右臂。把这两个基础做扎实至少能避免一半以上的资金类故障。如果一个团队还没有建立状态流转表和统一的幂等方案我强烈建议先把这两件事做了再谈业务创新。体会三日志和对账是金融系统的安全网。代码写错了可以修数据错了可以调账但前提是你得知道错在哪里。一个完整的日志链路加上一个靠谱的对账机制是金融系统最值得投入的隐形基建。体会四做这类项目沟通比编码更消耗精力。你花在对齐业务定义上的时间往往比写代码的时间多一倍。不要不耐烦这恰恰是价值的来源——能把混乱的规则理清楚本身就是技术能力的一部分。如果你也在接手类似的金融服务类项目我的建议很简单先从业务梳理开始建立清晰的领域边界再把幂等、状态机、对账这三样基础设施做扎实最后才谈优化和扩展。稳永远比快重要。
返回列表