尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

金融服务平台架构实践:微服务、分布式事务与幂等设计

发布时间:2026/9/26 8:38:30

资讯中心
01
ARTICLE

金融服务平台架构实践:微服务、分布式事务与幂等设计

金融服务平台架构实践:微服务、分布式事务与幂等设计
手上这个代号为 financial-services 的项目是我去年带队从零搭起来的一套金融服务基础平台。它不是面向C端用户的App而是公司内部统一的资产域账户开立、余额变更、交易流水、支付渠道接入、对账通知这些能力全都在这一层收敛。当时要解决的问题很实在多条业务线各自维护账务逻辑数据口径经常对不上线上出问题时还容易互相牵连。做完这套平台以后新业务接入只需要调统一接口账户和资金状态不再需要业务线自己管。如果你是做金融业务后端、或者正打算把一套混乱的单体系统拆成服务这篇总结应该能帮你少踩几个坑。1. 项目背景与整体架构设计1.1 这到底是个什么项目先别被 financial-services 这个名字唬住。它不是要做一个大而全的银行核心系统而是更像一块“资金域中间件”。项目里主要包含四个核心服务账户服务负责开户、查户、冻结、余额变更交易服务负责交易单据的创建和状态流转支付服务负责对接微信、支付宝、银联这类第三方渠道通知服务负责回调、短信、站内信等事件触达。四个服务不直接调用业务方的内部接口统一通过API网关对外暴露内部走RPC或者消息队列。这个定位很关键。一开始业务方其实希望把所有账务逻辑都塞进来但我们坚持只做“资金相关的基础能力”不做业务决策。比如积分商城要不要给用户返积分那是业务规则不该放在资金服务里。资金服务只负责“账户扣减5元同时生成一笔扣款流水”并且保证这两件事要么都成功、要么都失败。边界清晰以后后续维护成本低很多业务再复杂也不会污染核心账务逻辑。1.2 为什么选择微服务架构选微服务不是因为赶时髦。当时单体服务里已经出现了两个很头疼的问题第一账户服务和支付回调逻辑耦合在一起支付渠道配置变更要重启整个应用经常影响正在交易的用户第二多个业务线对账口径混乱单体改一处就得全量回归。拆成微服务后账户、交易、支付可以独立部署支付渠道调整只需要发支付服务账户服务完全不受影响。不过微服务也是要付代价的。原来一次本地事务能搞定的事情现在要变成跨服务调用分布式事务的复杂度会明显上来。所以我们没有一上来就按业务线条拆很多个服务而是先拆出这四个刚需等团队能力和基础设施跟上之后再继续细拆。这里也给个建议如果团队人数少于10人或者没有完善的监控和CI/CD不要轻易拆微服务可以先做模块化单体把边界在代码层面理清等规模起来再拆。1.3 架构总览与核心模块划分先看整体链路。用户发起一笔充值请求先走到网关网关完成鉴权和限流后转发给交易服务交易服务生成交易单再调用支付服务创建支付请求支付服务带着签名参数重定向到第三方收银台。用户支付完成后第三方异步回调支付服务支付服务校验签名后修改支付单状态同时往Kafka发一条支付成功消息交易服务消费消息后把交易单置为成功通知服务再发短信和站内信。所有涉及余额变动的操作都会通过账户服务落库并记录流水。这套架构里每个模块的职责边界可以列成一张表来看服务核心职责主要数据账户服务开户、余额查询、冻结/解冻、加减余额、流水记录账户表、流水表交易服务交易单创建、状态机流转、对账交易表、状态变更表支付服务渠道对接、签名验签、回调接收、退款支付单表、渠道配置表通知服务回调通知、短信、站内信、消息重试通知任务表边界定清楚后每个服务内部再分层Controller只做参数校验和协议转换Service专注业务规则Infrastructure层统一封装数据库、Redis、MQ访问。这样后期替换组件时不需要动业务代码。2. 技术选型解析每个组件背后的取舍2.1 服务框架与注册中心选型服务框架我们选的是Spring Boot 2.7配合Spring Cloud Alibaba体系。这里没有刻意追新因为团队主力就是JavaSpring生态成熟招人容易。Spring Cloud Alibaba里我们最依赖的是Nacos既做注册中心也做配置中心。为什么不用EurekaEureka作为注册中心本身不差但配置中心要另配一套Spring Cloud Config还要自己搞服务端运维成本抬高了。Nacos一个组件就能同时解决服务发现和配置刷新控制台也直观相对省事。版本兼容性是这里最容易被坑的地方。Spring Cloud Alibaba和Spring Boot的版本对应关系非常严格我们最初用某个中间版本结果Nacos客户端和服务端版本兼容有问题服务下线后心跳信息没及时清理导致调用方偶尔拿到已下线的节点。后来固定成大家都验证过的版本组合并在CI里加了版本约束检查这个问题才没有再发生。下面这个依赖坐标是稳定组合可以直接参考dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-nacos-discovery/artifactId version2021.0.5.0/version /dependency2.2 数据库与缓存的组合思路数据存储我们选了PostgreSQL作为核心账务库Redis作为缓存和幂等存储。为什么不选MySQL不是说MySQL不行而是账务查询里有很多复杂的约束和窗口函数比如拉取某个账户某段时间内的日汇总流水PG的语法更顺手。另外PG对约束和索引支持够好适合账务这种对完整性要求极高的场景。如果你原有团队更熟MySQL用MySQL也没问题技术选型从来不是单纯比性能而是比团队能不能把它用透。分库分表是必须提前考虑的。账户表按用户ID哈希分成16个库保证同一个用户的数据都在同一分片这样后续分布式事务才能少跨库。流水表则是典型的按月分表因为流水只增不改按月归档很自然。我们用ShardingSphere做分库分表分片键统一用用户ID。分片策略不是越大越好16个库对当前数据量来说已经足够后续扩容可以用双倍扩容方案避免哈希迁移全量重算。缓存方面账户的昵称、状态这类不敏感字段可以放Redis但余额这种强一致字段我们坚决不缓存。早期有同事为了扛热点把余额也缓存了结果出现并发扣款时读到旧值导致超扣。后来对余额的所有操作都直接走数据库行锁和原子更新热点账户再加单机排队虽然牺牲了一点吞吐但正确性有保障。这个取舍在金融场景里没有商量余地。2.3 消息中间件与异步化设计消息队列我们用的Kafka理由很实在公司运维团队已有成熟的Kafka集群不需要额外维护一套。异步化的使用场景主要在三个地方交易状态变更通知、支付成功后的业务事件广播、以及对账文件的生成。把耗时操作放到消息链路里可以让接口响应时间从几百毫秒降到几十毫秒同时削峰。这里要注意Kafka的语义是至少一次消费方必须幂等。我们的实践是所有消费逻辑入口都先查事件的业务单据ID是否已处理过处理过则直接ACK跳过。比如交易服务消费“支付成功”消息时根据交易单号查状态如果已经是成功就直接返回。这样才能在消息重复、消费者重启等异常情况下保证账务不出问题。后面常见问题部分我会专门讲一个消息丢失案例。3. 核心模块实现与实操细节3.1 账户服务余额变更的并发控制账户服务是整个系统里最不能出错的地方。余额变更最核心的一段SQL长这样UPDATE account SET balance balance - #{amount}, updated_at now() WHERE id #{accountId} AND status NORMAL AND balance #{amount};这条SQL利用数据库行锁和条件更新保证扣款不会把余额扣成负数。每次调用都检查返回的影响行数如果是0说明账户状态不对或者余额不足业务层就要返回明确的错误码。这里有一个面试官常考的点为什么不先SELECT余额再UPDATE因为SELECT拿到的是瞬时的快照两个并发请求同时通过SELECT判断余额足够再执行UPDATE就会超扣。靠数据库的原子条件更新才能彻底规避并发窗口。除了余额表我们为每笔变更都写了一条流水记录。流水表字段包括账户ID、变更前余额、变更后余额、变动金额、业务单号、渠道类型、操作人。为什么要把变更前后都记下来因为对账和审计时需要能还原任意时刻的账户状态。流水表本身不做更新只做追加所以非常适合按月分表。为了防止业务方重复提交账户服务的扣款接口还接了一个幂等校验业务单号在Redis里SETNX成功才继续执行否则直接返回已有结果。3.2 交易服务分布式事务的落地一笔真实的交易往往要经历创建、处理中、成功、失败几个状态。交易表里维护一份状态机只有处理中状态可以流转到成功或失败成功不允许再变成失败。这可以防止极端场景下回调先到导致状态错乱。为什么状态机这么重要因为交易服务、支付服务、账户服务三个系统之间没有强一致事务只能靠状态机约定流程交易服务创建交易单处理中支付服务收到回调后先更新支付单为成功再发消息交易服务消费到消息后把交易单从处理中改成成功。账务操作和交易状态更新如何保持一致我们用了本地消息表的方案。账户服务扣款成功后在同一个数据库事务里写一条待发送的消息记录然后通过一个定时任务把消息发给Kafka。账户扣款和消息记录写入是同一个本地事务要么一起成功要么一起回滚。下游消费方收到消息后再去做各自的业务比如会计凭证生成、积分发放。这套方案比Seata的分布式事务要轻量性能损耗小只是需要接受最终一致性窗口的存在。对于资金类业务“最终一致”只要窗口可控、有对账兜底是完全可以接受的。3.3 支付对接第三方接口的幂等处理接入多个第三方支付渠道时每个渠道的回调和退款逻辑都不一样但有个规律是通用的必须校验签名必须幂等。支付回调我们一律不直接信任返回的支付结果而是先拼接待验签参数用渠道分配的公钥验签验签失败直接丢弃。验签通过后用商户订单号作为幂等键查支付单如果支付单已经是最终状态直接返回成功不再重复处理。回调接口的代码骨架大概是这样的String orderNo request.getParam(orderNo); String sign request.getParam(sign); if (!verifySign(orderNo, sign, channelConfig)) { log.warn(支付回调验签失败, orderNo{}, orderNo); return fail; } Boolean first redisTemplate.opsForValue().setIfAbsent(PAY_NOTIFY: orderNo, 1, 30, TimeUnit.SECONDS); if (!first) { return success; } payService.handleNotify(orderNo);这里用Redis的SETNX做短时防重可以挡住几乎同时到达的重复回调。但注意Redis防重不能完全替代数据库幂等如果回调处理时间超过30秒锁过期后第二个回调还会进来。所以把幂等键对应的状态靠数据库唯一索引和状态机再兜一层双保险。处理完回调后发送消息这条消息的发送也经过了重试和去重后续消费者不会重复改单。4. 数据安全与访问控制实践4.1 敏感字段加密与脱敏金融服务里手机号、身份证号这类敏感字段不能明文入库。我们统一使用AES-256-GCM算法加密密钥由KMS服务管理应用层每次启动从KMS拉取密钥缓存到本地内存定时轮换。为什么用AES-256-GCM而不是AES-ECBGCM模式自带认证能防止密文被篡改ECB则存在明显的模式弱点不同明文块对应相同密文块数据量大的时候会泄露信息。这个选择不需要纠结。写到日志里的内容也必须脱敏。我们封装了一个脱敏工具类比如手机号只保留前3后4位中间用星号代替身份证号只保留前6后4位。日志切面在打点前统一处理避免业务代码里到处散落脱敏逻辑。漏处理的地方一旦被打到ELK并同步给其他团队后面的麻烦会非常大。日常开发中要注意一个坑很多人只在Controller入参阶段脱敏但异常堆栈或RPC参数打印时又会把完整对象打出来。所以统一用Sensitive注解标记字段在序列化层统一处理比靠人的自觉可靠得多。4.2 访问控制与审计日志服务之间不是谁都能调谁。我们把调用方分成三类外部客户端走网关网关做OAuth2鉴权后附带JWTJWT里包含用户身份和角色内部服务之间通过服务名API Key做双向校验定时任务则走独立的内部凭证。每个服务都维护一份允许调用白名单不在白名单里的请求直接拒绝。这套机制看着简单但能把误调用和不安全的跨服务访问挡在门外。金融系统还必须有完整的审计日志。这里审计日志和业务流水不同它记录的是“谁在什么时间通过什么方式访问了什么资源”。我们在网关层对每个请求记录用户ID、IP、设备指纹、请求路径、请求参数摘要、响应状态码、耗时。服务间的RPC调用也会生成TraceID通过SkyWalking把链路串联起来。一旦出现资金纠纷或者安全事件可以快速定位影响范围。审计日志保存周期我们设置了至少一年按天归档到冷存储查询时通过ES索引。5. 部署、监控与性能优化5.1 容器化部署与CI/CD流程部署这块我们全部容器化每个服务构建成Docker镜像跑在Kubernetes集群里。CI用的GitLab CI流程分为五个阶段代码检查、单元测试、镜像构建、推送镜像、部署到测试/生产环境。生产环境部署时用ArgoCD做GitOps仓库里的配置变化会同步到集群回滚时直接把镜像tag切回上一版本。资源限制一定要配。很多微服务项目崩在“没限制内存”最后被OOMKilled。比如某个无状态服务一开始没写resources字段默认占用宿主机大量内存节点一出问题整个服务都被重新调度期间请求全部超时。我们后来给每个Pod都配置了requests和limitsJVM参数和容器内存联动写成下面这样resources: requests: memory: 1Gi cpu: 500m limits: memory: 2Gi cpu: 2000m同时把JVM的MaxRAMPercentage调成75.0保证堆内存不会超过容器limit导致被内核杀掉。这些细节初期不做线上扩容时一定会给你颜色看。另外还要注意Pod的优雅终止配置preStop钩子里加一个sleep让应用有机会处理完正在进行的请求否则每次发布都可能产生零星超时。5.2 监控指标与告警规则设置监控我们用的Prometheus Grafana每个服务暴露/metrics端点重点盯四类指标接口QPS与P99延迟、错误率、JVM内存与GC、数据库连接池使用率。告警规则不是越多越好我们只设了几条核心的交易成功率连续5分钟低于99.9%触发P0告警账户服务P99延迟超过2秒触发P1数据库连接池使用率超过80%触发P2Redis慢查询次数超过阈值触发P3。分级的好处是让值班人知道先处理什么。告警要带上下文。比如交易成功率告警我们会在告警消息里附上最近失败的渠道、错误码TOP5和TraceID示例这样值班人员不用先登录一堆系统查日志。刚开始我们的告警是纯指标收到告警还得手工查效率很低。后来把日志聚合平台和Prometheus关联起来告警消息自动带关联信息大晚上被叫起来的次数明显少了很多。5.3 压测中发现的热点账户问题上线前我们做了两轮全链路压测压出来一个非常典型的问题热点账户的余额更新成了性能瓶颈。模拟大V粉丝充值时十几个热点账户同时被高频扣款/加款数据库行锁竞争严重导致账户服务事务耗时飙升到好几秒。第一次压测直接把这个短板打爆了。优化思路分两层。第一层是纯技术优化把单账户余额变更操作改成轻量SQL并且为余额表的热点记录单独设置更短的锁等待时间避免事务长时间持有锁。第二层是业务规则调整对热点账户的余额更新做本地排队在应用内存里按账户ID取模分散到多个队列同一账户的更新串行执行不同账户可以并行。这样单账户热点请求不会全部堆到数据库锁上。优化后压测数据从P99 3.8秒降到了180毫秒效果非常明显。这个案例也说明金融场景里“热点账户”比普通高并发更难处理因为不能靠加缓存绕过余额强一致。6. 常见问题与排查技巧实录6.1 消息重复消费导致重复入账上线后遇到的第一起P0事故是支付成功消息被Kafka重复消费交易服务连续两次把一笔订单改成成功账户服务也连续两次加了余额。排查后确认不是Kafka配置问题而是消费者在执行业务逻辑时抛了异常消息重试机制又把它重新投递业务代码没有做幂等。修复方法是给每个消费处理入口加一张“消息消费记录表”唯一键是业务单号加消息类型。处理前先插入消费记录插入冲突就说明已处理过直接跳过。这个表插入和业务更新放在同一个本地事务里才能保证不重复。这个教训是任何消费逻辑都不能依赖“Kafka会保证不重复”这种假设Kafka只能保证不丢不能保证不重。类似的问题也会出现在第三方支付回调里所以幂等设计必须是一等公民而不是上线后补的补丁。6.2 缓存穿透、击穿与雪崩的处理账户信息查询里有相当高的读多写少场景缓存肯定要上。但我们发现如果业务方传一个不存在的用户ID来查账户请求会全部穿透到数据库这就是缓存穿透。当时数据库连接数被打满接口大面积超时。处理办法缓存空值并设置较短过期时间同时在网关层对可疑高频请求做限流。这里有个细节空值缓存的value要能区分“数据不存在”和“查询失败”否则会把临时故障也当成空数据缓存下来掩盖真实问题。击穿和雪崩我们也遇到过。击穿是某个热点key过期瞬间大量请求同时打数据库解决方式有两种逻辑过期或者互斥锁重建我们用的互斥锁方案因为实现简单且对业务透明。雪崩则是因为大量key同时设置了相同过期时间后来在原有过期时间上加了随机偏置比如5到10分钟内的随机值彻底打散过期时间点。6.3 接口偶发超时的链路排查还有一类很难查的问题接口偶发超时但看监控每个服务都正常。后来用SkyWalking看全链路发现超时几乎都发生在某个特定下游服务的连接池等待上。原来Feign默认连接池大小是50压测峰值一上来连接池排队虽然单次数据库查询很快但请求排队导致P99被拉高。把Feign的连接池参数调整为每个路由最大200、空闲保持300秒之后问题消失。从这个案例里我积累了一个排障习惯遇到偶发超时先看全链路Trace再看连接池和线程池最后才看SQL和代码逻辑。很多时候问题不在你直觉认为的那一层。金融服务稳定性没有银弹靠的是链路可观测、日志有依据、幂等兜底到位这三件事做好了绝大部分线上问题都能在一个小时内定位。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。