1. 项目刚启动我先把“financial-services”从口号变成范围清单1.1 项目名太泛时先别急着选型接到这个任务时团队里对 financial-services 的理解五花八门。有人觉得是一个支付网关有人觉得是用户钱包还有人想直接做成一个聚合多个金融业务的开放平台。如果一开始不统一认知后面从接口设计到数据库表结构都会反复返工。我当时做的最重要的一件事就是拉着产品、运营和研发一起画了一张业务能力地图把项目范围收敛成五张清单账户服务、交易服务、清算服务、通知服务、审计服务。这个过程比预想的要难。因为“金融业务服务”这个词天然会让人把什么都往里面装比如积分、优惠券、会员等级。我的处理方法是设定一个判断依据这个功能上线后是否会造成资金余额或账务流水变化只要答案是“会”才允许进入 financial-services 的范围内如果只是营销展示或信息查询就放到外围系统。用这个标准过滤后项目范围清晰了很多团队也终于知道哪些服务算核心哪些只是辅助。从结果看这个收敛非常值得。资金类系统的任何改动都可能牵扯对账、审计、退款范围越大回归成本越高。与其上线前匆匆忙忙塞功能不如一开始就把不该做的部分明确挡在外面。后来我接手其他项目时也会先做这一步哪怕多花一周时间也比后期拆服务省力得多。1.2 三条边界规则先写进技术方案范围清单确认后我把三条边界规则写进了技术方案里并要求所有接口评审时逐条对照。第一条是“余额只以账务流水加总为准”。业务数据库里可以展示用户余额但不能直接 update 那个展示字段任何金额变化都必须经过账务模块生成流水。第二条是“外部渠道的回调不是命令而是输入”。回调到达后要先落库、再校验、最后推动状态机流转绝不能让回调直接修改订单状态。第三条是“所有资金接口的请求都要有唯一的业务幂等键”。这个幂等键贯穿整个调用链从 HTTP Header 到 MQ 消息体再到数据库表确保同一次操作重复请求多次也只会生效一次。这三条规则当时看起来有些严甚至被同事吐槽“过度设计”。但项目上线后它们成了排查问题的主要抓手。凡是符合规则的路径出问题后都可以通过流水和事件表还原凡是绕过规则的临时改动几乎都会在后续对账中暴露出来。所以我把这三条规则看得比任何中间件选型都重要。1.3 技术选型不追新先看团队维护能力很多项目在技术选型时会陷入“什么新用什么”的误区。financial-services 这个项目我只用了 Java、MySQL、Redis、MQ 这些非常常规的组件。Java 的优势在于生态成熟、并发处理能力强而且团队熟悉遇到问题能快速定位MySQL 用于核心账务数据稳定性和事务能力是首选Redis 承担幂等和热点数据缓存MQ 负责解耦异步任务和事件通知。我特别强调过不要在这个项目里引入没有团队维护经验的新组件。金融场景的核心不是在单点技术上有多炫而是当链路出现问题时有足够多的人能看懂、能修。如果选了一个团队只有一两个人会用的存储那人一离职整个系统对其他人来说就变成了黑盒。选型会议上我有句原话能让大多数同事在半小时内上手的技术才适合做资金类系统的基础设施。2. 账务模型设计用流水记录一切而不是直接改余额2.1 为什么我坚持“流水为纲”刚开始设计表结构时最简单粗暴的方案就是账户表加三个字段total_amount、available_amount、frozen_amount业务代码直接 update。这个方案写起来确实快但并发场景下很容易出问题。比如用户同时下单和退款两个操作同时读余额、扣余额最终可能把可用余额扣成负数而且没有任何中间痕迹可以回溯。financial-services 里我换成了“流水为纲”的模型。账户表只存账户标识、状态、开户时间等基础信息真正的余额来源是账务流水表。每次资金变动都先插入一条流水记录变动前金额、变动后金额、变动原因、业务单号、操作时间余额快照表则定期汇总流水结果用来加速查询。任何余额修正都必须生成一条冲正流水不允许直接改余额字段。这个模型在开发阶段确实多写了一些代码但它带来了两个很实在的好处。第一每一笔钱的来源和去向都清晰可查第二并发问题从根源上被规避因为所有余额计算都基于流水线性回放互不影响。后来做对账时我只需要把流水加总和渠道侧账单比对就能定位差异。2.2 业务账户与资金账户分层账务模型里最容易忽略的是账户分层。如果所有钱都堆在同一张余额表里充值、补贴、待结算、手续费混在一起对账时完全无从下手。我把账户拆成两层一层叫业务账户面向用户另一层叫资金账户面向内部清算。业务账户记录用户视角的余额比如可用余额、红包余额、冻结金额资金账户记录资金流转的内部科目比如渠道在途资金、待清算商户款、平台手续费。每一笔交易发生时业务账户和资金账户会同时记录对应的变动构成一笔完整的借贷账。这样用户余额看起来增加了 100内部资金账上也能看到这 100 元是来自渠道在途还是平台自有资金。下面是一个简化的分层示例账户层示例账户作用业务账户用户余额账户、优惠账户面向用户展示记录可用资金资金账户渠道在途户、待结算户、手续费户内部清算与对账反映资金归属冲正账户错误分录冲正、退款暂存处理异常调整保证流水完整这里不建议把冲正记录只做成一条负数流水而是应该单独标记冲正原因和原流水号。否则后续统计时正负数互相抵消很难看出业务全貌。2.3 事务范围怎么划本地事务优先Saga 兜底资金写库的事务范围设计一直有争议。一种极端是把所有操作包进一个本地大事务账户表、流水表、订单表、MQ 消息表全部更新一旦 commit 失败全部回滚。另一种极端是完全不用本地事务全靠消息补偿。我的选择是核心的两到三个表放在一个本地事务里比如“插入订单 插入账务流水 更新账户快照”更长的链路则拆成 Saga 模式每一步都有对应的补偿操作。这个折中的原因是本地大事务很容易造成数据库锁竞争尤其在整点活动时热点用户的行锁会明显拖慢吞吐而完全不用事务又会让数据处于无法预测的中间状态。一个具体的例子用户支付 100 元本地事务只需要把“订单状态改成已支付”和“插入一笔支付流水”绑定至于后续的分账、通知、发送回执都属于可以异步执行的动作哪怕失败也可以靠 Saga 回滚或者重试恢复。如果强行把分账也写进同一个本地事务链路长度会急剧增加任何外部依赖抖动都会导致支付主链路失败。3. 交易链路上的幂等、状态机与可靠消息3.1 幂等键的正确形态业务语义比 UUID 重要资金系统的第一道防线就是幂等。很多团队做接口时习惯给请求加一个 UUID然后放进数据库唯一索引以为这样就算幂等了。但真实场景里同一个退款单可能由用户、客服、系统重试三个入口发起它们携带的 UUID 不同但业务语义完全一致。如果只按 UUID 判重这三笔请求都会进入处理流程就可能出现重复退款。更稳妥的方案是“业务单号 操作类型”组合幂等键。例如退款单号 REFUND20250101001 加操作类型 refund_apply组成唯一的业务幂等键。后端处理时会先查这个组合键如果存在且处理完成直接返回第一次结果如果存在但处理中返回“处理中”并让上游稍后重试如果不存在则插入幂等记录并继续执行。这套逻辑可以有效覆盖渠道重试、前端重复提交、内部定时任务补偿等常见重复场景。另外幂等键的存储不能只依赖 Redis。Redis 可以飞速挡住大部分重复请求但过期时间一过重复请求可能漏到数据库。所以我在底层还会建一张幂等记录表用唯一索引做长期兜底。Redis 挡热点数据库保证最终不重复两层一起才算完整。3.2 状态机把并发写冲突收敛到一个入口交易链路中的订单状态如果被多个分支同时修改是最容易出现脏数据的场景。比如支付回调线程发现支付成功但另一个超时取消线程在同一时刻把订单置为已取消最后结果变成用户付了钱、订单却是取消状态。要解决这个问题光靠乐观锁还不够更关键的是让状态变更只有一个入口。我在交易服务里引入了显式状态机。订单状态被限定为几组创建、待支付、支付中、支付成功、支付失败、退款中、退款成功、退款失败。每个状态可以流转到哪些状态在代码里用一张映射表写死所有外部输入包括支付回调、主动查询结果、用户取消请求、后台退款操作都先进入一个统一的处理器由处理器判断当前状态是否允许目标状态再执行更新。这个设计的价值不只是防止并发写冲突它还把业务规则变成了可读可测的代码。状态机表里每个流转都对应明确的触发事件和动作测试时只需把每个边界状态都造出来就能验证是否会出现非法流转。上线后处理客诉时我也通常先在状态机里查一下订单当前状态基本十秒内能判断问题出在哪个环节。3.3 Outbox 模式业务成功和消息投递必须绑定交易成功后常常要发消息给下游系统比如通知结算服务、通知运营平台。这里有个经典问题如果先提交业务事务再发 MQMQ 发送失败怎么办多数人会选择重试但重试逻辑稍写不严谨就会出现业务成功但通知一直没发出去的情况或者反过来消息发出去了但业务事务回滚下游白忙一场。我采用 outbox 模式把这个绑定关系交给数据库事务。业务主事务里除了写订单和流水还会写一条 outbox 消息记录字段包括消息 ID、业务单号、消息内容、状态、创建时间。事务提交后后台有一个投递组件轮询 outbox 表把状态为待投递的记录发送到 MQ发送成功后把状态改成已投递。如果 MQ 暂时不可用消息仍然安全地待在数据库表里等组件恢复后继续投递。实现时有几个容易踩的细节outbox 表要设置唯一索引避免投递组件重复处理投递组件要支持批量拉取不能一次性把全表数据都 load 到内存消息内容最好用 JSON 或 Protocol Buffers 结构化的方式保存方便下游消费和排查已经投递成功的记录需要定期清理避免表无限膨胀影响性能。4. 支付渠道适配把外部系统的不确定性隔离在边界4.1 渠道抽象层每个渠道一个适配器业务不感知差异支付渠道是资金链路中外部依赖最强的一环。不同渠道的接口模型、签名方式、回调格式、错误码差异很大如果业务代码直接调用渠道 SDK后续每接一个渠道都要动一遍核心代码。我在项目里要求所有渠道调用必须经过统一接口核心业务只依赖一个内部定义的支付请求对象和支付结果对象。实现时分成三层。渠道接入层负责和具体渠道通信处理协议、签名、证书、解密渠道策略层负责路由、超时、重试、降级业务服务层只调用统一接口。这样接新渠道时只需要新增一个适配器和一个策略配置核心业务代码一行都不用改。实际项目里我同时接了三个渠道新增第四个渠道时只花了一天就完成联调测试原因就是抽象边界合理。但渠道抽象也不能过度。有些团队会把所有渠道的差异都设计成通用配置结果配置项越来越多最后比写适配器代码还难懂。我自己的原则是公共能力抽象成框架渠道特有差异留在适配器内部不要把 config 做成“万能参数文件”。4.2 回调事件表原始报文和场景标识一定要留下渠道回调是资金系统最不稳定的输入。回调可能重复、延迟、乱序甚至同一个订单会先收到支付成功回调过几分钟又收到退款成功回调如果不处理时序状态会乱。我的标准做法是所有回调先落一张回调事件表再异步处理避免在回调线程里直接修改业务数据。回调事件表至少要包含这些字段事件 ID、渠道标识、渠道单号、业务单号、回调类型、原始报文、接收时间、处理状态、处理时间。原始报文尤其重要因为真实渠道有时候会返回一些文档里没写的字段或者因为上线新版本改变了报文格式没有原始报文很难定位问题。另外回调处理线程需要对同一个业务单号做串行化我使用的是基于 Redis 的分布式锁锁的粒度是业务单号防止同一笔订单的两个回调并发修改状态。回调乱序时的处理策略是先落库再交给状态机判断不能流转的事件放进延迟队列稍后再试。很多团队一遇到乱序就直接人工改库这是最危险的做法。应该让系统设计本身允许乱序通过状态机把事情放到正确的时机再执行。4.3 支付超时默认主动查询谨慎自动冲正超时处理也是经典难点。订单创建后渠道迟迟没有回调这时候要不要自动关闭我的答案很明确资金场景下默认不自动冲正而是先主动查询渠道交易状态。因为渠道可能已经扣款成功只是回调没到如果业务侧自动关闭用户会面临“钱扣了但订单取消”的糟糕体验。主动查询也不是一次就完事。我设计了一个延迟队列订单超过设定时间比如 15 分钟未收到回调时向渠道发起查询如果查询结果是成功就推进订单到支付成功如果查询结果是失败或者渠道无法确认则把订单放入待人工处理队列同时告警通知运营。只有对于金额极小、且明确允许自动关闭的场景我才会打开自动取消开关。这个策略看起来多了一步异步查询但它把风险控制在了系统内部。实际运行中因回调延迟而需要查询的订单占比通常只有 1% 到 2%给渠道增加的查询压力很小却避免了大量用户投诉和退款纠纷性价比非常高。5. 安全与审计让每一笔资金操作都有证据链5.1 敏感数据可逆加密、不可逆哈希、脱敏展示要分开金融项目里敏感数据加密是个绕不开的要求但我不建议做成“全字段加密”。全字段加密最直接的问题是查询和排序功能会被废掉明明只是想查一个手机号却必须把整张表扫一遍再解密。更合理的做法是按数据用途分类处理。第一类是业务中必须使用的数据比如手机号、身份证号存储时使用可逆加密算法展示时统一脱敏查询时按加密后的密文精确匹配。第二类是仅用于验证密码等场景的数据比如支付密码、登录密码使用不可逆哈希算法不存原文不存密文防止泄露后被反推。第三类是日志类数据比如渠道流水号、订单号可以明文存储但访问权限要严格控制不能随便导出。我还让团队在日志框架里统一做了一次脱敏处理凡是打印手机号、银行卡号、身份证号的地方默认只输出前三位和后四位。这个工作看起来琐碎但一旦日志泄露到第三方或者同事误发到群里脱敏能避免很多不必要的麻烦。资金项目中安全不是某一个系统的任务而是每一行日志、每一条 SQL 都要遵守的规范。5.2 审计事件表把操作前后快照和时间线留下来审计日志最容易流于形式。很多系统所谓的审计就是 log.info 打一条文字内容既不结构化也会因为日志滚动被覆盖根本无法支撑事后追溯。我在 financial-services 里设计了一张审计事件表专门记录资金类操作的关键信息。表里每条记录包含操作人、来源 IP、设备标识、业务单号、请求幂等键、操作类型、操作前关键字段快照、操作后关键字段快照、操作结果、错误信息、操作时间。操作前后快照是关键少了它只能知道“谁改了什么”没法知道“改之前是什么状态”。如果只记录最终值退一步说一旦被多次修改就很难确定是哪一步写错了。审计事件表的写入不放在主链路事务里通过异步方式写但必须有重试和失败告警。资金系统的审计数据不能丢所以我对这个表的写入队列做了持久化避免服务重启后丢失。查询时只给审计管理员开放权限普通运维也看不到完整明文信息。这个表还要按天分区避免单表过大影响写入性能。5.3 权限控制角色、数据范围、操作类型三维限制后台管理页面是资金系统最容易被攻击的薄弱环节。我见过不少系统登录后所有员工都能查询全部用户订单甚至能发起退款。这在金融系统里是不可接受的。权限模型不能只看“能不能登录”还要看“能看哪些数据”“能做什么操作”。我用了角色、数据范围、操作类型三维控制。角色决定能访问哪些菜单和功能数据范围决定能看到哪些商户、哪些用户、哪些区域的数据操作类型决定能否提交、审核、导出、调账。对于资金调账、退款、手工补单这类风险操作还强制走双人复核流程第一人提交后进入待审核状态第二人审核通过后才真正执行审核时会展示操作前后数据对比和风险提示。这套权限体系上线后减少了大量“误操作”客诉。以前运营可能不小心点错导致退款失败现在有了复核人出错概率大幅下降。即使出现了错误审计表也能精确还原是谁提交、谁审核、哪一步出了问题而不是互相扯皮。6. 线上运行监控、容量与故障复盘6.1 业务指标比系统指标更能预警故障我见过很多项目的监控大盘全是 CPU、内存、QPS看起来都很健康结果用户已经在投诉支付不了。原因很简单系统指标只能反映“服务还活着”不能反映“业务是否正常”。对于资金系统必须把业务指标和系统指标放在一起看。我上线后的监控分为两层。系统层包括 CPU、内存、磁盘、网络、GC、连接池使用率业务层包括支付成功率、支付失败量、回调积压数、对账差异笔数、退款处理时长、幂等命中量。业务层指标里回调积压数是最敏感的告警项一旦积压明显上升通常意味着某个渠道回调处理速度跟不上需要立刻介入。这里有个容易被忽略的指标幂等命中量。它反映上游重复请求的比例。如果这个数字突然升高很可能不是系统 bug而是某个上游系统新增了重试机制或者是渠道回调出现了异常重发。虽然幂等机制保证了结果正确但命中量升高往往预兆着整体流量异常值得关注。6.2 容量评估按峰值三倍核算重点保护连接池资金系统的流量特征通常是短期脉冲式上涨。例如整点抢购、节假日活动流量可能在几分钟内冲到平时的十几倍。如果容量规划按日均值来做肯定会出问题。我给这个项目定的规格是核心链路按最近三个月峰值 TPS 的 1.5 到 3 倍设计宁可平时浪费一点资源也不能在关键活动时打满。数据库连接数是最容易被忽视的瓶颈。很多系统在流量上涨时应用层线程池加了很多但数据库连接池没有同步扩大结果请求全部阻塞在获取连接上整个应用像死掉一样。我的做法是给数据库连接池预留 1.5 倍峰值余量同时设置快速失败和等待队列告警。Redis 的连接数也按类似方式评估并且把核心业务缓存放在独立集群避免和普通业务缓存互相影响。容量评估不能只算平均值还要考虑“最坏场景的时间窗口”。比如整点发券时所有用户在同一秒操作那一秒的 TPS 就是压测基准。我会把这部分测试数据单独拿出来而不是看一天的总量除以秒数否则会被平均值骗过去。6.3 一次回调积压事故给我的教训项目上线大约第三周时我们遇到一次线上事故。某支付渠道的回调突然增多我们的回调消费线程处理不过来导致大量订单停留在“支付中”状态用户侧一直显示未支付。排查时发现根因有两个回调线程池配置过小处理逻辑里有一次远程调用远程调用响应慢占用了大量线程。那次复盘让我意识到资金系统的线程池设计必须考虑任务类型。回调处理任务带有 IO 操作不能按 CPU 密集型服务的线程数来设置。后来我把所有外部调用都包了一层带超时和熔断的客户端并且为回调任务单独设置线程池核心线程数按峰值回调 QPS 的 2 倍配置最大线程数再留若干余量。同时给线程池加了队列饱和告警一旦排队超过阈值就立刻报警。故障复盘时我还坚持一个原则复盘不是找责任人而是找系统设计漏洞。如果一次事故只能靠某个人的细心来避免那说明系统是不健壮的。要把人肉检查点全部自动化哪怕只是加一条告警也比事后追责有价值。7. 项目复盘哪些决策关键哪些是弯路7.1 回头看最正确的三个决定整个项目做下来最正确的三个决定都是“慢工出细活”的类型。第一个是坚持流水为纲的账务模型虽然开发时多写了不少代码但后来所有对账、排查、退款都靠流水还原几乎没有出现过“钱不知道去哪了”的情况。第二个是回调事件表独立于业务表所有渠道通知先落库再异步处理这样遇到渠道乱序、重复回调都能有据可查。第三个是权限控制和审计事件表从第一天就开始设计而不是上线后补省掉了后续大量改造。这三个决定在当时都有人觉得过度设计。现在再回头看它们不是过度而是资金类系统的基本盘。资金系统的容错空间比普通业务小得多多一道保险多花的时间远小于出事后人力排查的成本。7.2 几条可供复制的弯路教训也有一条明显走的弯路最初我沿用了普通微服务的最佳实践把账户、余额、流水、支付、退款全部拆成独立服务。结果一笔交易要跨四个服务才能完成日志分散排查一次客诉往往要打开三个系统翻日志。后来我合并成三个服务交易主链路集中在两个服务内排查效率提升非常明显。微服务的拆分依据不应该是“名字不同就拆开”而应该是“独立扩展和独立部署的价值是否大于调用成本”。另一条弯路是审计日志最初用文本日志存储后来才迁移到审计事件表。文本日志方便日常看但无法支撑复杂查询和长期归档而且滚动日志会覆盖关键记录。如果项目已经需要资金审计不要犹豫直接上结构化的审计表。还有一条是幂等键最初用了请求里的随机 UUID后来改成业务单号加操作类型组合键。UUID 作为幂等键只能应对最简单的重复请求无法覆盖真正的业务语义重复。这个改动虽然只动了很小的一块代码却在后续减少了很多重复扣款的潜在事故。7.3 如果重新开始我会优先补上渠道模拟器如果再给我一次机会我会在项目早期就把渠道模拟器做得更完整。当时我们是先完成业务开发再接入真实渠道联调结果很多异常场景比如超时、重复回调、乱序回调、金额不一致都是在真实渠道里才暴露出来的。如果一开始就有一个能模拟这些异常情况的渠道模拟器业务代码的健壮性可以提升很多上线后的故障也会少很多。渠道模拟器的核心不只是“模拟成功响应”更要能模拟失败、超时、重复回调、乱序回调、金额不一致、签名错误。越是真实世界里概率低但破坏力大的场景越值得在测试环境反复演练。这样上线前就能确认状态机是否都能正确处理幂等是否能挡住重复补偿任务是否能兜底。后来我在其他项目里把渠道模拟器放到了“必做清单”里不再只是可有可无的测试工具。最后说一个小经验做金融业务服务不要等项目上线后再考虑审计和监控。从第一行代码开始就把流水、幂等、审计、告警这些基础能力按照最终要求搭好后面所有业务功能都会自然而然地落在安全框架内。这个项目结束后我个人最大的体会是金融场景拼的不是逃逸速度而是稳定性和可追溯性把这两件事做到位比任何花哨的架构都管用。
