1. financial-services 到底是个什么项目
接到financial-services这个项目标题的时候,我其实一点都不意外。干过金融系统开发的都懂,这种命名在代码仓库里一抓一大把,它不是某个具体产品,而是一组服务的集合:开户、充值、提现、交易、支付路由、清算对账、风控拦截、消息通知、审计留痕,全都被塞进了这一个小小的英文词组里。
刚开始带这个项目时,我最关心的问题只有一个:资金不能错。这听起来像废话,但在金融系统里,性能可以慢一点、界面可以丑一点、架构可以土一点,唯独账不能错一笔。你给用户账户多扣了一分钱、少记了一条流水、重复处理了一笔提现申请,后面要花的返工时间,足够重构整个系统。
所以这篇内容想聊清楚的核心是:financial-services这类金融服务中台,到底在解决什么问题,领域怎么拆、模块怎么设计、分布式事务为什么能不用就不用、幂等怎么做才能真的防住重复请求、上线前要养成哪些保命习惯。适合正在做金融/交易类系统的工程师看,也适合那些刚接触微服务、想了解“互联网交易核心系统如何保证一致性”的同学。
这类项目通常不是从零发明的,而是长期演进出来的。我接手时的系统已经跑了两年,账务逻辑和业务逻辑纠缠在一起,一个开户接口里面同时调了账户、风控、营销、短信四个模块,每次改需求都像拆炸弹。后来重构,才真正理解了一个朴素道理:金融服务的复杂度不是来自功能多,而是来自“不能错”这个约束。把“不能错”变成架构的一部分,才是这类项目所有设计决策的出发点。
2. 核心模块与关键技术决策
2.1 六个中心模块的分工
我们最终把系统拆成了六个相对独立的中心,每个中心负责一条清晰的业务边界。
| 服务 | 核心职责 | 不能错的关键点 |
|---|---|---|
| 账户中心 | 余额查询、冻结/解冻、加减钱 | 余额不能为负,流水必须完整 |
| 交易中心 | 下单、撤单、交易状态流转 | 状态机严格推进,重复回调要防重 |
| 支付中心 | 对接渠道、发起支付、查询结果 | 回调签名校验,渠道结果以文件为准 |
| 清算中心 | 日终对账、手续费计算、差错处理 | 内部流水与渠道文件逐笔核对 |
| 风控中心 | 规则引擎、名单校验、限额限频 | 拦截决策必须记录,误杀和漏杀都要复盘 |
| 审计中心 | 日志脱敏存储、操作留痕、报表 | 日志不可篡改,敏感字段加密 |
账户中心是地基,所有加钱减钱的动作都在这里完成,其它模块只能通过接口操作账户,不允许直接改库。这个看似简单的规定,能让很多恶性事故从“源头”被切断。
2.2 金额与幂等:金融系统最容易翻车的地方
讲到钱,第一个铁律就是禁止用浮点数存金额。我见过不止一个初级工程师用double存余额,然后用户说充值 0.1 元结果变成了 0.09999999999999 元。这不是玄学,是 IEEE 754 浮点表示本身的误差。正确做法是用整数最小单位存储,人民币精确到分就是Long类型存“分”,如果业务有更小精度,就按“厘”或“毫”换算。展示层再除以 100。
我在代码里写过这样一个工具,所有金额相关运算都必须走它:
public class Money { private final long cents; public static Money of(String yuan) { return new Money(new BigDecimal(yuan) .movePointRight(2).longValueExact()); } public Money add(Money other) { return new Money(cents + other.cents); } public Money subtract(Money other) { return new Money(cents - other.cents); } // 不允许直接 new,不允许用 double 构造 }金额表达解决了,另一个高频翻车点就是幂等。用户在 App 上点了两次提现,前端重复提交,或者支付回调因为网络抖动被重复投递,如果没有幂等保护,用户账户就会被扣两次钱。我们用了两层防线:第一层是 Redis 的SETNX请求去重,第二层是数据库表里的唯一索引。
两层缺一不可。Redis 快但会过期,数据库慢但绝对可靠。我实测下来,Redis TTL 不能设置太短,曾经设成 30 秒,结果第一笔事务处理超过 30 秒,Redis 锁提前失效,第二笔重复请求进来了,最终靠数据库唯一索引兜住了。所以从那以后,幂等键 TTL 一律不少于 24 小时,同时数据库唯一索引当成最后一道保险丝。
幂等键的生成也很讲究,不能用登录态里的简单字段,要用业务请求方生成的requestId,或者由我们按“用户ID + 业务类型 + 业务单号”规范拼接,保证同一个逻辑操作产生同一个幂等键。两个接口即使参数完全一样,只要业务单号不同,也要视为不同请求,这是容易糊掉的地方。
2.3 分布式事务:能不用就不用
很多做微服务的团队,一上来就喜欢讨论分布式事务,好像没有 Seata 或 TCC 就做不了金融系统。但我的真实体会恰恰相反:能用本地事务解决的,绝不上分布式事务;能用“记录状态 + 对账兜底”的,绝不用强一致分布式事务。
早期我们的交易链路是这样的:用户发起充值,交易中心调支付渠道,渠道回调成功后,更新订单状态,同时调账户中心加钱。两个服务之间没有事务,怎么保证一致?有人第一反应是“TCC”,但 TCC 要侵入业务、写三套逻辑,还要处理空回滚、悬挂、幂等、防悬挂,落地成本高得吓人。
我们最终用的是“本地消息表 + 定时对账”的方案。交易中心在自己的库中维护一张event_message表,业务操作和事件写入处于同一个本地事务里。之后一个可靠异步任务把事件投递到 RocketMQ,账户中心消费后加钱,加完再做对账。如果账户中心消费失败,定时任务会重新投递;如果彻底链路中断,日终对账任务也会把差异揪出来,走人工或自动修复通道。
这套方案不是新东西,但它真实解决了问题。做金融项目,绝大多数场景追求的是最终一致而不是实时强一致,只要业务流程里留了记录、对账能发现问题、修复流程能闭环,就足够。
真正用到 Seata 的地方只有少数强一致场景,比如非常核心的内部账务调整。而且即使要上,也必须确认参与方不多、链路短、并发量可控。金融链路里任何一环阻塞,分布式事务的等待和回滚会让整个系统像堵车的十字路口,处理问题的复杂度远超它带来的“一致性闭环”。
3. 从零搭一个最小可运行工程
3.1 技术栈和版本怎么选
很多金融团队不敢轻易升级技术栈,这不是保守,是理性。我建议直接用当时稳定迭代一年以上的版本组合,我当时用的是Spring Boot 2.7 + Spring Cloud Alibaba 2021.0.5.0,对应的核心组件是 Nacos 注册中心、OpenFeign 远程调用、Sentinel 限流、RocketMQ 异步消息。
这套组合的好处,一是资料多,遇到问题随便一搜就有答案;二是组件之间没有明显的版本兼容坑;三是 Java 和 Spring 的生态让团队招人难度降低很多。如果你是给自己做实验项目,也可以直接上 Spring Boot 3 和更高版本的 Alibaba,但如果是公司生产环境,尽量避开刚发布不到半年的新版本。
3.2 数据库设计
项目里的核心表,我按“账户、流水、订单、幂等”四个维度来设计:
CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id VARCHAR(64) NOT NULL, balance BIGINT NOT NULL DEFAULT 0, -- 单位: 分 frozen_balance BIGINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, -- 乐观锁 created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_user_id (user_id) ); CREATE TABLE account_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id VARCHAR(64) NOT NULL, change_amount BIGINT NOT NULL, balance_after BIGINT NOT NULL, flow_type TINYINT NOT NULL, -- 充值/消费/退款... order_no VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL, KEY idx_user_time (user_id, created_at) ); CREATE TABLE trade_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, amount BIGINT NOT NULL, status TINYINT NOT NULL, -- 待支付/支付中/成功/失败/关闭 channel_code VARCHAR(32) NULL, UNIQUE KEY uk_order_no (order_no) );账户表里的version字段是经典乐观锁,扣款时先查版本号,UPDATE ... WHERE version = ?,如果不匹配就说明有并发,要么重试要么报错。账户流水和账户余额严格分离,所有余额变动必须先写流水,再更新余额;对账时流水总额和余额差一旦对不上,就说明代码里出了bug,绝对不能静默吞掉。
3.3 后端关键代码
账户扣款接口是这类系统的“心脏”,我贴一段简化版,重点看注释里的约束:
@Service public class AccountService { @Transactional public void deduct(String userId, long amount, String orderNo) { // 1. 流水先落库 accountFlowMapper.insert(userId, -amount, orderNo); // 2. 乐观锁更新余额,配合 where 条件保证不被并发覆盖 int rows = accountMapper.deductWithVersion(userId, amount); if (rows == 0) { throw new BizException("余额不足或账户并发冲突"); } // 3. 写审计日志 auditLogMapper.insert("ACCOUNT_DEDUCT", userId, orderNo); } }注意流水和余额更新在同一个@Transactional里,保证要么都成功,要么都回滚。如果拆成两个事务,中间一旦宕机,就会出现“流水记了、余额没减”或反过来,这是对账事故的温床。
幂等控制的代码也要放在事务入口前,思路是这样的:
// 第一层:Redis 去重 Boolean success = redisTemplate.opsForValue() .setIfAbsent("idempotent:" + orderNo, "1", Duration.ofHours(24)); if (Boolean.FALSE.equals(success)) { throw new BizException("重复请求"); } try { accountService.deduct(userId, amount, orderNo); } catch (Exception e) { redisTemplate.delete("idempotent:" + orderNo); throw e; }因为把 Redis 删除放在了catch里,所以即使事务回滚,幂等键也会被清理,下一次重试可以正常进入。这里的细节是:不能把删除逻辑放到finally里,否则事务成功也被删掉,重复请求就能再次穿透进来。
3.4 限流、线程池和压测
金融系统里,限流不是为了炫技,而是为了保住核心链路。我在交易入口用 Sentinel 配了一个最简单但最有效的规则:每个用户每秒最多 5 次下单,全局限流每秒 1000 次。这个数字不是拍脑袋定的,是根据订单峰值和数据库能力算出来的。
单台订单数据库支持 500 QPS,后面还有账户、清算等依赖,整体链路放大系数大概是 2.5 倍,所以入口限流就压在 200 QPS。用 Little's Law 算的话,平均耗时 100ms 的接口,支撑 200 QPS 理论需要 20 个并发线程,但考虑到连接池、GC 和网络开销,我会给线程池留 1.5 到 2 倍余量,也就是 40 个线程左右。
压测时我习惯分三步走:先单接口压测,确认接口本身没有瓶颈;再全链路压测,暴露依赖之间的连接池竞争;最后做“故障演练”,模拟支付渠道超时,看系统能不能走降级逻辑而不是拖死整个服务。实测下来,很多系统在单接口压测时表现很优秀,一上全链路就乱成一锅粥,数据库连接池被打满,链路超时连环触发,最后只有靠限流降级把这些“故障蔓延”挡在外面。
4. 常见问题与排查技巧
4.1 高频故障场景
做金融项目一年,下面这些问题我几乎每个月都会遇到一次,挨个记录了下来:
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| 扣款成功但用户没收到通知 | 消息发送失败,且未做补偿 | 本地消息表 + 定时任务重推 |
| 同一条提现请求扣了两次钱 | Redis 幂等键过期,数据库侧没兜底 | 幂等表唯一索引必须加,TTL 调长 |
| 金额出现 0.01 的尾差 | 用了 Double 做中间计算 | 全链路整数运算,换算统一走工具类 |
| 极端热点账户余额查询慢 | 一个爆款商品引发大量用户查余额 | 缓存预热 + 单账户维度限流 |
| 数据库连接池被打满 | 事务内调远程接口,长事务持有连接 | 禁止事务内远程调用,超时缩短 |
| 对账不平 | 渠道手续费/退款状态差/时区差 | 以渠道文件为准,自动 + 人工分级处理 |
| 用户 A 能查到用户 B 的订单 | 接口缺少资源归属校验 | 每个查询类接口强制校验 userId 归属 |
其中“事务内调远程接口”这条,是压测中暴露最多的问题。很多工程师写代码时图省事,在@Transactional方法里直接调了支付渠道或者消息发送,结果事务迟迟不提交,数据库连接被白白占着。尤其高并发时,连接池一满,后面的请求全部排队,最后雪崩。我的原则是:事务方法里只做本库操作,远程调用全部挪到事务提交之后。
4.2 排查三板斧
当线上出现“用户钱不对”这类问题时,我有一套固定的排查顺序,不猜、不乱试:
第一,先看链路日志。我们给每个请求分配了traceId,入口网关生成,通过 MDC 透传到所有子服务。查问题时按照traceId把整条链路的日志串起来,能很快定位到是哪一环出了节奏问题。
第二,看流水和余额。不管用户声称自己少了多少钱,先查他的账户流水表,逐笔核对每笔流水的change_amount和balance_after是否连续。账户流水就是金融系统的“黑匣子”,只要流水是连续的,钱的问题基本都能找到线索。
第三,看定时任务和对账结果。如果链路日志和流水都正常,那问题可能出在补偿机制上。检查当前重试队列里有没有积压的消息,以及日终对账单里差异记录是否已经走到了人工处理池。很多问题不是“当时出错”,而是“补偿来不及”,所以要格外关注积压数量这个指标。
排查这件事,最重要的不是技术多强,而是保持证据链完整。每一步操作都留了日志、都留了流水、都留了操作人,事后才能还原现场。我见过不少团队线上出了问题大家全靠猜,就是因为平时没做日志留痕,最后只能翻数据库恢复备份,代价极大。
5. 上线前值得坚持的几个习惯
5.1 可观测性:日志不只要“有”,还要“有用”
做金融系统,日志不仅是调试工具,更是审计依据。我们的规范是这样的:关键业务动作必须打印入参和出参,但密码、身份证、银行卡号等敏感字段要做脱敏处理;每个日志都要带上userId和traceId,方便事后按人检索;核心状态流转要打WARN级别以上日志,因为这些字段一旦值对了,错误几乎都是时间问题和链路问题。
我还做了一个额外动作:审计日志单独存库,不和应用日志混在一起。审计日志包含操作时间、操作人、操作类型、请求来源 IP、设备指纹、业务单号和业务结果。这样无论是安全审计还是用户投诉,都能快速调出完整的历史记录。这就像给系统装了一台永不删除的监控录像,平时没人看,但出大事时它就是唯一证据。
5.2 演练:宁可演练时出事,不要上线后出事
我们每个月都会做一次“混沌演练”类的活动,专门挑系统最脆弱的地方下手。最常见的两个是:支付渠道超时和数据库主库宕机。
支付渠道超时演练,看的是支付中心能不能快速切换到备用通道,用户侧能不能看到“支付处理中”而不是直接报错,以及恢复后补单机制能不能正确回补。数据库主库宕机演练,看的是只读从库能否扛住查询流量,写操作能否快速进入降级状态,以及最核心的“停止服务”策略能不能兜住不产生错账。
有同事问:演练成本这么高,能不能不做?我的回答是:做一次成本再高,也比真实故障时全公司加班十个小时低。上线前做演练,本质上是在提前预演最坏情况,真正遇到时团队就只需要执行方案,而不需要靠临场发挥——临场发挥是金融系统最危险的行为。
5.3 灰度发布:永远不要全量一把梭
金融系统对发布的要求比普通系统更苛刻。我们规定所有核心服务的发布都必须走灰度,规则很简单:先发布到一台机器,让内部测试账号流量打过来,观察 5 到 10 分钟;再通过注册中心把 5% 的流量引过来,观察日志和监控指标;确认没有异常后,再逐步扩展到 50%、100%。
灰度期间重点看四个指标:接口成功率、错误率、响应时间 P99、对账差异数。尤其是对账差异数,哪怕只出现了 1 笔差异,都要立刻停止灰度回滚。宁可发布慢一天,也不能让错误版本在用户侧持续产生坏账。
从实践来看,灰度发布配合可观测性和演练这三件事,能避免绝大多数“发布即事故”的场景。它们单独看都是流程细节,但一起用起来,就是一套防呆机制。
最后说点实在的
做了这些年金融项目,我最大的体会是:金融系统的核心不是架构高不高级,而是“账不能错”这三个字有没有被真正当成最高优先级。所有微服务拆分、分布式事务方案、幂等设计、对账机制,本质上都是在给“账不能错”服务。
我个人实操中最受用的三个习惯是:一切可回滚、一切可对账、一切留痕。代码可以重构,架构可以演进,但这三条底线一旦被打破,事故就会像雨后春笋一样冒出来。如果你也在做类似的financial-services项目,不妨先把这三个原则写进团队的开发规范里,它会帮你少走很多弯路。