做金融服务的系统,和做普通互联网应用最大的区别,不是高并发、不是微服务,而是你对每一笔钱、每一条记录、每一个失败分支的态度。这个项目的代号就叫 financial-services,它不是一个单一产品,而是一组面向交易账户、支付通道、账单清分和商户管理的后端服务集合。我带着不到十个人的小组,用大约四个月时间从零搭起来,中间踩的坑、做的取舍、验证过的方案,都值得记录下来。这篇文章适合正在做支付、做账户、做交易系统的后端开发,也适合想了解金融业务系统里那些“看不见的约束”的产品经理和测试同学。
1. 项目整体设计与架构选型
1.1 为什么选择微服务而不是单体
一开始所有人都在争论一个问题:团队这么小,业务还没跑起来,要不要上微服务?我的结论是要,但要控制粒度。金融服务和普通内容系统有一个本质差异:账务、交易、渠道、商户管理这些域的稳定性要求和变更频率完全不同。比如交易接口经常要适配新渠道,账务模块却很少改。如果全塞在一个单体里,每次改动都要整个应用回归,出一次事故就可能影响所有业务。
微服务带来的隔离不是物理上的,而是责任边界上的。账户余额归账户服务管,交易流水归交易服务管,每一层只允许操作自己拥有的数据。这样做之后,哪怕支付通道适配服务挂了,用户至少还能查余额、走退款入口;哪怕商户服务大版本重构,也不会影响存量交易的写入。对金融项目来说,故障爆炸半径比极端性能更重要。
当然也要承认,微服务增加了部署和排查成本。所以我们在拆分时用了一个很简单的判断标准:是否在资金链路中存在独立的生命周期。比如“订单”有独立状态机,“支付单”有独立状态机,“账务分录”有独立幂等键,能拆则拆。像“短信通知”这种和资金无关的,就放在网关层或者公共组件里,不单独建服务,避免为了拆而拆。
1.2 服务拆分与核心模块
最终定下来的服务比我预想的少,只有六个核心模块,外加一个网关和一个任务调度中心。模块清单大致是这样:
| 服务名 | 职责 | 核心表 |
|---|---|---|
| gateway-api | 统一入口、认证鉴权、限流 | - |
| account-service | 客户账户、余额、冻结与解冻 | account、account_balance_log |
| transaction-service | 交易单、支付单、退款单状态机 | transaction_order、payment_order、refund_order |
| ledger-service | 账务分录、科目汇总、会计期间 | ledger_entry、ledger_summary |
| payment-adapter-service | 渠道适配、回调验签、渠道对账 | payment_channel_config、channel_callback_log |
| merchant-service | 商户资质、费率、结算账号 | merchant、merchant_settlement_config |
交易主流程是这样的:用户在商户侧发起支付 → gateway路由到transaction-service生成交易单 → 调用payment-adapter-service选择渠道发起支付 → 渠道回调落到payment-adapter-service验签 → 再通过MQ通知transaction-service更新支付单 → 成功后调用account-service入账 → 同时写一条ledger-service的账务分录。
这里有一个容易犯的错误:一开始我认为入账和更新支付单应该放在同一个服务里事务性地完成。后来发现不行,因为渠道回调的延迟是不确定的,如果同步阻塞等入账,回调接口很容易超时,渠道方会重试导致乱序。所以我们把“更新支付状态”和“资金入账”拆成两个独立事务,中间用本地消息表保证最终一致性。这个设计后面会在交易链路里展开讲。
1.3 技术栈与中间件选型
技术选型没有追求新鲜,主要看团队熟练度和长期维护成本。最终是:Java 17 + Spring Boot 3.x,存储用 MySQL 8.0(InnoDB),缓存用 Redis 6.x,消息队列用的是 RabbitMQ。为什么没用 Kafka?因为核心业务消息量没那么大,RabbitMQ 的管控界面更直观,团队上手快。但渠道回调日志和业务监控数据会走Kafka的另一个集群,这是两个用途,不要混在同一个MQ集群。
数据库落库这块我选了 MyBatis-Plus 而不是 JPA。理由有点个人化:资金场景下复杂SQL非常多,比如“按商户、按时间分页查交易流水,并且要left join多个分表”,MyBatis的XML写起来更可控,出问题也好排查。账户余额更新需要原子性SQL,MyBatis的@Update注解直接写原句,不会因为ORM缓存搞出幺蛾子。
Redis在这里的定位是“加速层”和“幂等层”,不是事实源。所有余额相关的强一致数据都以MySQL为准。Redis里存的只是热点账户的实时余额缓存,供展示和试算;真正扣减余额时,必须走到数据库那行UPDATE。缓存只做读加速,不做写屏障。
2. 交易链路中的关键细节
2.1 幂等性设计,这关过不了就别上线
做支付系统,每天最少蹄一次“重复通知”。用户多点了一次支付按钮,渠道方超时重试回调,MQ消费端重复投递等等。幂等如果不做,订单金额会被加两次,账户余额会凭空多出一笔。
我的方案是“Redis前置加DB唯一索引兜底”。对所有涉及资金写操作的请求,都要求客户端传一个幂等键。如果是支付回调,就取支付单号outTradeNo。在服务入口处,先用Redis的setIfAbsent占位,如果占位失败说明这个键已经处理过,直接返回上一次的处理结果。
String redisKey = "idem:" + outTradeNo; Boolean first = stringRedisTemplate.opsForValue() .setIfAbsent(redisKey, "1", Duration.ofMinutes(30)); if (Boolean.FALSE.equals(first)) { // 已经处理过,直接查库返回现有状态 PaymentOrder order = paymentOrderMapper.selectByOutTradeNo(outTradeNo); return order; }但Redis不是绝对的。Redis万一宕机或者键过期了,重复回调依然可能打到数据库。所以数据库层必须再加一道保险:交易单表中的out_trade_no字段建唯一索引。插入时的DuplicateKeyException会被捕获并转成幂等响应。
这里有一个细节:占位后不能立刻进入业务逻辑,应该先在当前事务里查询一次是否存在。因为setIfAbsent本身是写Redis,不会自动回滚,如果业务失败但没删除占位键,会导致同一个幂等键在30分钟内被永久拒绝。所以我的习惯是:先占位,再查库,再把真正的业务插入和状态更新放在一个事务里;如果事务执行失败,主动删除Redis占位键,让重试可以重新进来。
2.2 资金账户余额更新的原子性技巧
余额更新可以说是整个系统里风险最高的代码。我见过很多初版方案是先SELECT balance FROM account WHERE id = ?,然后在Java代码里算一下result,再UPDATE account SET balance = ?。这种“先查再改”在高并发下必出问题:两次请求同时读到同样的余额,各自扣款,最后一次更新覆盖前一次,最终账就不平了。
正确做法是直接用数据库原子表达式:
UPDATE account SET balance = balance - #{amount}, version = version + 1 WHERE account_no = #{accountNo} AND balance >= #{amount}这条SQL让数据库自己完成“扣减和防透支”。受影响行数如果为1,说明扣减成功;如果为0,说明要么账户不存在,要么余额不足。在业务代码里必须区分这两种情况,我通常先查一次账户是否存在,如果存在再执行上面这个更新,然后把返回行数等于0映射为“余额不足”。
关于事务边界,扣减余额和插入资金流水必须放在同一个本地事务里。我见过有人先更新余额,再异步去写流水,结果余额更新成功但流水没写,最终对账只能靠人工补单。正确做法是在同一个@Transactional方法里,先更新账户表,再插入流水表,顺序无所谓,但一定要在同一个数据库事务内。
还有一个坑:热点账户的余额更新,比如某个大商户的结算账户,每秒可能几千笔入账,单行UPDATE会有锁竞争。我们后来做了账户分桶,把一个逻辑账户拆成多个子账户,每个子账户自己私有一个余额字段,入账时轮询写入其中一个子账户,查询汇总时对多个子账户的余额求和。这个方案能支撑更高并发,但取余额和分析时要考虑最终一致性,不是所有场景都适合,需要按实际业务决定。
2.3 外部支付渠道对接的统一封装
金融服务的系统永远不会只对接一个渠道。不同渠道的支付参数、签名算法、回调字段、退款处理完全不一样。如果每个渠道都写成一套独立接口,业务层会被活活拖死。所以我专门建了一个payment-adapter-service,把支付动作抽象成统一的领域模型。
对外暴露的核心方法只有几个:submitPay(channelCode, payRequest)、queryPayStatus(channelCode, payQuery)、submitRefund(channelCode, refundRequest)。每个渠道一个XXXChannelAdapter实现类,通过工厂模式按channelCode路由。
渠道回调处理稍微复杂。渠道方回传的报文要先在adapter里做验签,验签通过后再统一转换成我们内部的CallbackEvent对象,然后发送到MQ。绝对不能在回调HTTP方法里直接改业务状态,因为HTTP断开、超时等情况你无法控制。正确顺序是:接收回调 → 异步验签 → 落库到channel_callback_log → 投递MQ → 事务消费者处理。这样渠道方即使重复回调,我们也能通过幂等键去重。
实际踩过的一个坑是回调响应体结构不一致。有些渠道要求你返回success,有些要求返回状态码200 OK,还有一些要求返回带签名的XML。必须为每个渠道单独实现响应生成器,而且验签和生成响应时用的密钥要放在配置中心加密存储,不能写死在代码里。
3. 安全与合规落地经验
3.1 敏感数据加密与脱敏,不能只靠数据库权限
金融系统里的敏感字段远比普通系统多:手机号、银行卡号、证件号、商户结算账号,甚至部分地址信息。很多人以为只要数据库访问受限就安全了,实际上经常是日志泄露、接口返回超卖、或者运维导出数据时泄露的。
我的做法是把敏感字段分为“存储密文”和“展示脱敏”。存储密文使用AES/GCM模式加密,密钥放在独立的配置中心,不随代码仓库走。加密字段在数据库里存储为Base64字符串,比如手机号字段phone_cipher。查询时按需解密,比如后台编辑场景才解密,列表页一律只返回脱敏后的值。
脱敏规则是通用工具类搞定,规则很简单:手机号保留前3后4;银行卡保留前6后4;证件号只留首位和末位。我写了一个MaskUtils,在Controller出参时统一处理,避免业务代码里到处散落脱敏逻辑。
有一个很容易忽略的点:日志脱敏。很多团队只在接口返回值上做脱敏,但打印日志时却把完整的请求参数打了出来。我在网关层和各个服务日志配置里,对敏感字段做了正则替换,比如"phone": "138..."这种模式直接替换成"phone":"***"。实际上日志里的问题比数据库泄露更常见,你不一定知道哪个日志平台会留存多久。
3.2 审计日志不能省,出了事你就知道它多值钱
资金系统的审计日志和普通操作日志不是一个概念。普通日志是排查错误用的,审计日志是“重新现场”用的。每一笔资金操作,必须能回答这些问题:谁在什么时间通过哪个IP哪个接口发起了什么请求,请求前后账户余额分别是什么,最终处理结果是什么。
我们使用AOP注解@AuditLog标注在所有资金写操作上。切面会在方法执行前后分别采集参数快照和返回快照,然后异步写入到独立的审计日志表。这里所谓“独立”,是指审计日志的写入不能和业务事务放在同一个数据库事务里。如果业务事务回滚,审计日志必须仍然留下“一次失败尝试”的记录,这样你才能知道有人试图转账但失败了。
审计日志不是简单的文本,而是结构化JSON。字段包括:request_id、operator_id、action_type、target_account、before_snapshot、after_snapshot、result_code、created_at。查询时按request_id串联整个调用链路。
这里提醒一下:审计日志也是数据库表,如果量大要分表。我按月份做分区,同时设置归档策略,线上热数据保留6个月,超过6个月的自动归档到冷存储。审计日志的保留时长一定要提前定好,不然后面想查半年前的数据时,冷数据没有格式化,基本等于白存。
3.3 访问控制与权限模型:最小权限不只是口号
运营后台如果只要登录就能提现、退款、改结算费率,那系统离出事就不远了。我们的权限模型是经典的RBAC:用户、角色、权限三级。
授权在服务层做,网关只负责认证出用户身份,把用户ID放在请求头里传递。服务层通过一个权限校验组件去判断用户是否拥有对应的权限点。比如merchant:settle:update表示修改商户结算配置,account:freeze表示冻结资金。这种细粒度配置才能避免“一个人能操作所有商户”的粗放授权。
敏感操作还加了二次校验。退款、调账、解冻这类操作,必须通过动态口令或二次输入密码验证。我们在网关层设置了“敏感动作需要临时token”规则,这个token由认证服务单独签发,有效期两分钟。
内部服务之间的调用我没有完全信任内网环境。每个内部服务都分配一个服务账号,请求时用HMAC签名,接收方校验签名后才能处理。防止有人从内网绕过网关直接请求账户服务。这样做的好处是,即使某个服务被攻破,攻击者也只能以该服务的权限去访问其他服务,不能直接操作所有资金接口。
4. 部署、监控与故障排查
4.1 环境隔离与灰度发布
金融系统对环境隔离的要求非常严格,至少要有开发、测试、预发、生产四个环境。预发环境必须连接独立数据库,不能复用测试库,更不能连生产库。我见过有人在预发验证一个查询,随手把测试数据写到了生产库的某个表里,导致生产环境出现脏数据。
资金操作相关的服务在发布时,我坚持使用“商户维度灰度”。什么意思?就是先从一个内部测试商户号开始,路由到新版本的服务集群,确认交易正常后,再将白名单商户比例扩大到1%、5%、20%,最后全量发布。实现方式是在网关层根据商户号做条件路由,如果商户号在灰度名单里,就把请求转发到标注了gray=true的服务实例。
因为资金系统的发布回滚成本很高,一旦数据写错,不能通过简单的版本回滚来修复,所以灰度不能只按节点比例,要按业务维度控制。实测下来,商户维度灰度比机器维度灰度更能及时发现业务逻辑问题。
4.2 关键指标监控与告警
监控不能只盯着CPU和内存,金融系统更核心的是业务指标。我上线第一天就定了四个业务核心指标:每分钟交易金额、支付成功率、支付回调超时率、账务对账差异金额。这四个指标任何一个异常,都要直接拉响告警。
具体的监控方式是这样:业务代码里用Micrometer埋点,把指标推送到Prometheus,Grafana出图。比如支付回调超时率,是payment_callback_timeout_total / payment_callback_total。阈值设置为大于5%持续五分钟告警,因为正常情况回调超时率应该在0.5%以下。
| 指标 | 阈值 | 响应动作 |
|---|---|---|
| 支付成功率 | 低于99%持续3分钟 | 检查渠道配置、网络、密钥 |
| 回调超时率 | 高于5%持续5分钟 | 检查MQ积压、消费者线程池 |
| 交易金额当日环比波动 | 超过50% | 排查渠道侧异常或营销活动 |
| 对账差异金额 | 大于0即告警 | 进入对账排查流程 |
| MQ消费积压数 | 超过10000 | 扩容消费者、检查死信队列 |
另外,所有日志必须带上traceId,从网关开始生成,通过请求头透传给下游服务。排查故障时直接按traceId查所有服务日志,不然资金链路一长,找问题能找到崩溃。
4.3 常见故障场景处理
有一个典型的故障:数据库连接池耗尽。症状是所有接口突然大量超时,数据库CPU又正常。排查发现是某个渠道对账任务在跑大量慢查询,把连接池占光了。解决方法是业务服务和批量任务分开用独立数据源,批量任务走专门的只读库。另外慢SQL定期监控,特别是like查询和全表扫描。
另一个典型故障是MQ消息重复消费。虽然做了幂等,但有时候幂等键本身的获取出问题,比如Redis里的占位键被误删。处理原则是绝不能直接改数据库,而是先看消费端的日志,找到消息的唯一键,用键去数据库查对应记录是否已存在,再决定是丢弃消息还是重放。
缓存和数据库一致性问题也值得说。我们在账户展示上用了Redis缓存,但设计原则是“先更新数据库,再删除缓存”,而不是先更新缓存。如果先更新Redis,数据库事务回滚了,缓存里就是脏数据。先更新数据库后删缓存,即使删除失败,最多也就是多一次旧缓存命中,可以用短TTL兜底。我们没有用复杂的延迟双删,因为业务场景允许极短时间内的缓存不一致。
5. 踩坑记录与实操心得
5.1 对账不平的排查方法
对账是资金系统的最后一道防线,也是对团队耐心最大的考验。我们的对账流程是:每天凌晨拉取渠道侧账单文件,解析后和本地交易单按outTradeNo比对。比对逻辑分三步:先对总数,再对金额,最后对状态。
总数对不上时,第一件事是查“本地有但渠道没有”和“渠道有但本地没有”,也就是所谓的“长款”和“短款”。我踩过的坑里,最多的一种原因是回调延迟。渠道侧账单按T日生成,本地可能存在T日日终后支付才成功的情况,那么这笔应该算在T+1,不能直接判定为差异。
金额对不上时,优先查退款单。因为退款金额和原订单金额互转时,如果渠道侧优惠券抵扣导致实际退款金额与本地记录不一致,就会出现分位差异。后来我在退款接口里增加了“渠道实退金额回填”字段,对账允许1元以内差异,超过就必须人工介入。
这里分享一个排查经验:遇到对账不平,不要一上来就翻代码,而是先把差异台账导出来,按格式整理成“序号、交易号、本地状态、渠道状态、本地金额、渠道金额、差异类型”,再逐笔分析。大部分差异都能归到那几个常见原因里。如果盲目改数据,只会越改越乱。
| 差异类型 | 常见原因 | 处理动作 |
|---|---|---|
| 本地有、渠道无 | 支付请求未到达渠道或渠道撤销 | 查支付订单状态和渠道请求日志 |
| 渠道有、本地无 | 回调丢失或本地事务回滚 | 用渠道单号反查本地,无法匹配则人工补单 |
| 金额差异 | 退款部分成功、优惠券分摊 | 回填渠道实付金额,允许小额差异 |
| 状态不一致 | 回调成功但本地状态未更新 | 比对状态机,补偿更新 |
5.2 分布式事务的取舍
我在项目开始时差点引入Seata分布式事务,后来权衡了很久还是放弃了。资金主链路里的核心操作基本都集中在MySQL的同一个库上,本来就可以用本地事务解决。需要跨服务的只有“更新支付单状态”和“账户入账”这种异步关联,而这类场景用“本地消息表 + 消息队列 + 消费者幂等”就能保持最终一致。
分布式事务真正让人头疼的不是性能,而是排查复杂度。资金系统一旦同时存在多个分布式事务分支,定位一个不一致的账目会变得极其困难。我的选择是尽可能减少数据被拆散的机会:账户和交易库放在同一个MySQL实例上,需要强一致的写操作走同一个事务;外部渠道对接天然跨系统,只能接受最终一致,但要增加补偿任务。
如果你真的引入了分布式事务框架,一定要保证每一笔事务状态可查询、可重试、可人工介入,不要过度依赖自动回滚,自动回滚往往会把脏数据消除掉,但也可能把正常数据一起回滚掉。
5.3 一个实用小技巧:交易流水导出不卡库
运营同学每周都要导交易流水,一开始我们直接在MySQL主库上SELECT * FROM transaction_order WHERE ...,数据量一大,导出任务会把主库IO打满,线上交易跟着慢。后来我们改成了异步导出任务,从只读从库读取,用流式查询每次fetch 1000条,直接写文件流到OSS。
try (Statement stmt = conn.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { stmt.setFetchSize(1000); try (ResultSet rs = stmt.executeQuery(sql)) { while (rs.next()) { // 逐行写入CSV文件流 } } }同时给导出任务加上“按时间分片”的策略。比如一次导出30天,系统会按天拆成30个小任务,每个小任务独立拉取、独立生成文件,最后合并压缩。这样即使某一小片失败,重跑也只需要重跑那一片,不会整个任务作废。
导出的文件里包含资金和商户信息,属于敏感数据。我在CSV文件生成后,会对文件做一次行级脱敏,再对压缩包做AES加密,下载链接有效期设成15分钟。运营人员拿到文件后,即使泄露出去,也不能直接用。
做一个金融服务系统,我的体会是:风口上的技术固然吸引人,但真正把项目推向成功的是那些“无聊”的细节。每一次幂等键的生成、每一行余额更新的SQL、每一张对账差异表,都是你在这条路上积累的护城河。如果你也正在做类似的项目,希望这篇文章能帮你少踩几个坑。最后再分享一个小技巧:任何涉及余额变动的接口,从设计第一天开始就要加入“自动恢复”的钩子,哪怕架构再简单,也一定要为对账留出后路。这比任何炫酷的设计都管用。