手上这个代号为 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体系。这里没有刻意追新,因为团队主力就是Java,Spring生态成熟,招人容易。Spring Cloud Alibaba里我们最依赖的是Nacos,既做注册中心也做配置中心。为什么不用Eureka?Eureka作为注册中心本身不差,但配置中心要另配一套Spring Cloud Config,还要自己搞服务端,运维成本抬高了。Nacos一个组件就能同时解决服务发现和配置刷新,控制台也直观,相对省事。
版本兼容性是这里最容易被坑的地方。Spring Cloud Alibaba和Spring Boot的版本对应关系非常严格,我们最初用某个中间版本,结果Nacos客户端和服务端版本兼容有问题,服务下线后心跳信息没及时清理,导致调用方偶尔拿到已下线的节点。后来固定成大家都验证过的版本组合,并在CI里加了版本约束检查,这个问题才没有再发生。下面这个依赖坐标是稳定组合,可以直接参考:
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> <version>2021.0.5.0</version> </dependency>2.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-ECB?GCM模式自带认证,能防止密文被篡改,ECB则存在明显的模式弱点,不同明文块对应相同密文块,数据量大的时候会泄露信息。这个选择不需要纠结。
写到日志里的内容也必须脱敏。我们封装了一个脱敏工具类,比如手机号只保留前3后4位,中间用星号代替;身份证号只保留前6后4位。日志切面在打点前统一处理,避免业务代码里到处散落脱敏逻辑。漏处理的地方一旦被打到ELK并同步给其他团队,后面的麻烦会非常大。日常开发中要注意一个坑:很多人只在Controller入参阶段脱敏,但异常堆栈或RPC参数打印时又会把完整对象打出来。所以统一用@Sensitive注解标记字段,在序列化层统一处理,比靠人的自觉可靠得多。
4.2 访问控制与审计日志
服务之间不是谁都能调谁。我们把调用方分成三类:外部客户端走网关,网关做OAuth2鉴权后附带JWT,JWT里包含用户身份和角色;内部服务之间通过服务名+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和limits,JVM参数和容器内存联动,写成下面这样:
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%触发P2;Redis慢查询次数超过阈值触发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和代码逻辑。很多时候问题不在你直觉认为的那一层。金融服务稳定性没有银弹,靠的是链路可观测、日志有依据、幂等兜底到位,这三件事做好了,绝大部分线上问题都能在一个小时内定位。