今年的精力主要耗在了一个代号叫financial-services的老项目上。这个仓库名最早是一个人写的单体应用,到后来变成六个团队共同维护的核心系统,中间经历了从混乱到收敛的全过程。这篇文章就当是项目复盘,把账户、交易、风控、清结算这几块从单体里拆出来的思路、踩过的坑、以及最后落地的方案讲清楚。金融系统跟普通业务系统最大的区别在于:它不只是CRUD,每一笔数据都跟钱挂钩,做错了不是查日志的问题,是资金损失的问题。所以这篇不仅写给做金融后端的同学,也想给做交易、支付、电商这类强资金链路的同行参考。
1. financial-services 项目到底在做什么
1.1 从仓库名看清系统边界
先说名字。financial-services看着像是一个笼统的工程目录,其实它背后承载的是金融业务中台里最核心的能力集合。我们当时没有用pay-core、account-center这类更具体的名字,是因为这个仓库在早期确实承担了太多职责:账户、支付、交易、风控、清结算、账单查询,全都塞在一起。后面做服务化拆分时才发现,这个命名反而成了一种提醒——它是一个服务集合,而不是某个单一模块。
所以如果你现在接手的是一个类似命名的老项目,第一步别急着看代码,先把服务清单拉出来。哪些接口在对外提供能力,哪些表在被多个模块读写,哪些定时任务在跑批,把它们归档成一张“服务与数据域”的对应表。这一步做完,系统边界基本就清晰了。我们当时花了两周时间做这件事,后面所有拆分决策都建立在这张表上。
1.2 核心需求不是功能,是资金安全
这类金融项目的需求池里通常会堆满各种业务功能,比如开户、充值、提现、转账、对账、报表,每个功能看着都很具体。但从系统设计的角度看,真正要解决的从来不是功能本身,而是三件事:资金安全、审计留痕、高可用。
资金安全意味着每一笔交易要么完全成功,要么完全失败,不允许出现中间状态。审计留痕意味着每一次余额变动、每一个风控决策、每一笔外部渠道请求,都要有快照可查。高可用意味着核心链路的SLA不是99.9%,而是接近99.99%,因为单次故障影响的不是一个用户,是一批用户的资金操作。
这三条约束直接决定了技术选型和架构设计的方向。比如我们用了严格的事务消息来保证跨服务的最终一致性,而不是靠定时扫表硬补;用复式记账而不是单边流水;用独立的风控模块而不是把规则写在交易代码里。后面讲拆解的时候都会展开。
1.3 技术选型的底层逻辑
当时团队的选型比较主流:Java 17 + Spring Cloud + MySQL分库分表 + Kafka + Redis。这套组合在金融业务里很常见,不是因为它们多前卫,而是因为资料多、社区活跃、坑都被人踩过。
数据库用了MySQL而不是PostgreSQL,主要是因为团队熟悉度,以及很多遗留存储过程迁移成本低。分库分表用ShardingSphere-JDBC,分片键统一选account_no或tenant_id。消息队列选Kafka,因为吞吐量足够,而且事务消息机制能配合本地事务做可靠投递。Redis承担了分布式锁、幂等令牌、短时热点查询这些工作,但所有余额类数据绝不允许落在Redis里当唯一事实来源,它只做缓存加速。
选型这个地方我踩过一个坑:早期为了追求性能,把账户余额直接放在Redis里做扣减,异步刷回MySQL。压测数据很好看,但一旦Redis宕机或者主从切换,余额数据就对不上,只能停机手工对账。后来彻底改成MySQL行锁保证余额一致性,Redis只做查询缓存,性能损失了一部分,但换来的是资金安全这条底线。
2. 模块拆解与服务边界
2.1 账户体系:户账分离是第一原则
账户模块是整个金融系统的地基。我们设计时遵循一个核心原则:户账分离。一个用户对应一个主账户,主账户下挂多张余额账簿,比如可用余额、冻结余额、在途余额。这个过程一定要拆开去建模,如果直接在账户表上做加减法,后面每加一个业务场景就得改表结构,非常痛苦。
具体落地上,账户表account只保存用户维度的信息和状态,不保存任何余额字段。余额全部放在account_balance表里,每条记录包含account_no、balance_type、amount、version这几个关键字段。所有余额变更都走balance_change_log,也就是流水账,每一条流水都有唯一的trade_no关联,方便追溯。
为什么坚持这个结构?因为金融系统里永远会出现“金额加不了但流水必须记”的场景。比如渠道回调延迟,交易状态还没确认,但账已经冻结了一部分。如果余额直接放在账户表一个字段里,这种中间状态根本没法表达。户账分离之后,冻结有冻结的账,在途有在途的账,每笔变动都有流水,对账时直接按流水重算余额,永远能对上。
2.2 交易引擎:状态机驱动而不是if-else
交易模块是业务最复杂的部分,状态多、流转路径多、异常分支多。我们第一版是用status字段加各种if判定的方法,处理到后面完全不可维护,加了新状态就要翻遍所有调用方。后来全部改成状态机驱动,交易状态、动作、事件全走统一配置。
一个典型的交易状态机包含这些状态:PENDING(已创建)、PROCESSING(处理中)、SUCCESS(成功)、FAILED(失败)、CLOSED(关闭)、CANCELLED(取消)。允许的流转路径在配置里显式声明,比如PENDING -> PROCESSING、PROCESSING -> SUCCESS、PROCESSING -> FAILED,不允许跳转或逆向。
状态机的实现并不复杂,关键在约束:状态不允许随意set,只能通过事件驱动。每个事件都有前置条件检查,比如“支付成功事件”要求订单必须是PROCESSING状态,否则直接拒绝。这样能挡住大量脏数据写入。我们用了一个轻量级的状态机组件,但核心逻辑还是自己写的,因为业务状态比通用框架能覆盖的情况要多得多。
2.3 风控模块:独立成服务,策略可编排
风控拆成独立服务而不是嵌入交易链路,这个决心下了很久。如果不拆,风控规则和交易代码会互相纠缠,改一条规则要发一次交易服务版本,风险极高。拆分后,交易流程通过同步或者异步方式调用风控服务,拿到决策结果再决定放行、拒绝还是转人工。
风控服务的核心是规则编排引擎。我们用了规则引擎来管理策略,规则包括设备指纹、行为频次、黑名单名单命中、额度管控、和历史交易比对。每笔交易进入风控后,按照策略优先级逐条执行,最后汇总裁决。关键指标是“误杀率”和“召回率”,误杀率太高会严重影响正常用户,召回率太低会让风险交易漏过去。
在接口设计上,我们后来改成了半同步模式:耗时低的黑白名单走同步调用,耗时高的复杂策略走异步回调。这样既保证了强风险命中的即时性,又不会让交易主链路因为风控超时而拖垮。
2.4 清结算:离线对账与日切设计
清结算是最容易在前期被忽略的模块,很多团队一开始觉得“交易成功钱就到了”,但实际上一笔交易从发起到资金真正可结算,中间的环节非常多。清结算模块负责三件事:日切、对账、差异处理。
日切需要一个明确的时间点,比如每天凌晨0点30分,系统把当天的交易数据进行封存,生成当天的清算批次。对账分为内部账对账和渠道对账,内部账是账务系统跟交易系统的核对,渠道对账是渠道账单跟本地记录的核对。差异处理是最麻烦的部分,我们后面用了专门的差异流水表,把每一笔差异按类型打标,比如金额不一致、状态不一致、通道重复入账、漏单,然后生成待处理任务,由人工或者自动补偿任务处理。
3. 关键实现细节与实操记录
3.1 余额扣减:乐观锁与行锁的正确打开方式
余额扣减是高并发交易下最容易出错的地方。我们踩过两个极端:一种是完全用数据库行锁,即SELECT ... FOR UPDATE,可靠但吞吐量上不去;另一种是乐观锁靠version字段控制,吞吐量高但冲突率高,热点账户基本一直在重试。
最后采用的方案是分层处理:普通账户的余额变更走乐观锁,通过UPDATE account_balance SET amount = amount - #{deduct}, version = version + 1 WHERE account_no = #{accountNo} AND balance_type = 'AVAILABLE' AND version = #{expectVersion}这种方式,用影响行数判断是否成功。对高并发热点账户,比如抽奖、秒杀场景涉及到的账号,单独走行锁加队列削峰策略,保证不会因为竞争激烈全部超时。
这里要特别注意:事务级别的资金扣减和业务状态更新必须在一个数据库事务里完成,跨库事务则用事务消息补偿。一开始我们试过“先扣款再发消息更新订单”,结果消息积压时出现用户钱扣了但订单还是待支付的情况,客服被骂惨了。后来所有资金变更操作都保持事务内强一致,跨模块操作只允许最终一致。
3.2 幂等设计:从接口层到存储层的三重保障
金融系统里所有写接口都必须幂等。我们做了一个幂等上下文的统一组件,核心思想是:每个请求必须带request_no,服务端根据这个编号做唯一性校验。
第一重保障是接口层:查Redis里有没有IDEMPOTENT:${request_no}这个key,有就直接返回上次的结果,没有就继续往下走。第二重保障是在处理过程中,对幂等表加唯一索引,插入冲突说明是重复请求,直接走旧结果返回。第三重保障是事务提交后写一个结果缓存,设置适当的过期时间,防止极端情况下第一重查不到但数据库已经有记录的情况。
这套三重保障看起来冗余,但实测下来非常稳。有一次促销活动并发翻了三倍,订单服务被大量重复调用,幂等组件扛住了所有重复流量,数据库一条脏数据都没产生。如果没有第三重保障,接口层一旦缓存过期,空查之后并发插入就会撞唯一索引,逻辑上没问题但错误日志会刷屏,影响排查效率。
3.3 对账不平的排查方法
对账不平是所有金融系统里最让人头疼的问题。我们对账脚本每天跑完会生成差异报表,刚开始跑的时候差异率在千分之一左右,看着不高,但每一笔都要人工处理。排查了两个月,把高频差异原因总结成了三类。
第一类是时区或日切时间差异。渠道方的交易日和我们这边不一样,导致跨天的交易被归到不同的账单批次里。对策是对账以渠道方账单为准,我们本地记录只做参照,对账范围里把channel_transaction_time作为第一排序条件。
第二类是精度问题。部分渠道手续费精度按两位小数,我们数据库字段保留四位小数,导致加总后出现零点零零几的差异。对策是对外展示统一两位小数,对内账务处理保留四位小数,对账时用差额阈值过滤,小于等于一分钱的差异自动平账并记录原因。
第三类是退款状态不一致。渠道显示退款成功,我们本地订单还是退款处理中,这类差异靠凌晨的自动状态同步任务去纠正。
3.4 事务消息与最终一致性的落地细节
跨服务调用的数据一致性问题,我们用事务消息来解决,而不是本地消息表扫表。没有用本地消息表的原因是:金融系统里事务边界太多,每个都建消息表会让数据库表数量爆炸,而且定时扫表会有分钟级延迟,不适合敏感链路。
事务消息的执行流程是:本地事务提交前发一条半消息给Kafka,本地事务提交后确认发送,本地事务回滚则取消半消息。Kafka有回查机制,会检查本地事务的执行结果,保证消息不会丢也不会多。我们在消费者端继续做幂等,消费成功后更新本地单据状态,整个过程看起来像异步,但实际数据能达到最终一致。
这个方案唯一的坑是要保证发送半消息和本地事务真的“同一个事务”,如果你把它们写在两个事务里,就会出现本地事务成功但半消息没发出去的情况。实现的时候Kafka原生接口已经支持事务,但我们没直接用它,因为会和当前数据库事务耦合太深。我们最终用的是本地事务内先插入一条tx_outbox记录,然后由后台进程统一发消息、标记发送状态,这个方案既保证可靠性,又方便对账排查。
3.5 可观测性:没有全链路追踪就没法排查资金问题
金融系统的排查效率直接取决于可观测性建设程度。我们在拆服务的同时把全链路追踪做起来了,每个请求生成一个trace_id,贯穿网关、交易服务、账户服务、风控服务、消息队列。日志里必须打trace_id,这样一次完整交易的所有日志可以串起来。
除了链路追踪,核心指标也要重点监控。账户服务里余额变更次数,交易服务里的状态机流转成功失败次数,风控服务的决策分布,消息队列的消费延迟,这些都要出监控大盘。告警规则宁可多不要少,关键是告警准确率。我们走了不少弯路,一开始告警太灵敏天天被叫起来,后来做了告警分级和静默策略:P0级(资金不平、大面积失败)电话通知,P1级(阈值超限)工作群通知,P2级(性能抖动)只记录不打扰人。
4. 常见问题与排查技巧实录
4.1 重复支付问题:幂等失效的实战复盘
有一次线上出现重复支付案例,用户点了一次支付按钮,结果扣了两次钱。当时排查过程很有代表性。最初从应用日志看,两个请求都带了同一个request_no,接口层幂等校验都放过了,说明Redis和幂等表没拦住。
深入排查发现,两个请求时间差在毫秒级,第一个请求还没走到插入幂等表那一步,第二个请求已经通过了接口层幂等检查。因为接口层检查是“查一次Redis,没有就继续”,两个并发请求都查到没有,自然都放进去了。解决方案是把接口层改成“先写一个Redis占位符,谁写成功谁继续”,配合幂等表唯一索引做兜底。这个问题给了我们很大教训:幂等不能靠查,要靠“写成功才代表通过”。
4.2 慢查询与锁等待:账户余额表的高频更新问题
账户余额表是更新频次最高的表,上线后很快就出现了死锁和慢查询。死锁的原因是两个并发事务分别持有了账户A和账户B的行锁,然后互相等对方释放。这类死锁除非用统一的加锁顺序,否则很难完全避免。我们的策略是:所有涉及多账户的操作,先按account_no排序再依次加锁,这样两个事务的加锁顺序一致,死锁自然消解。
慢查询则是另一个故事。由于每次余额更新都要查询流水、权重账、更新余额表,SQL数量多,执行计划走了全表扫。给热点字段加索引后解决了一部分,但后来发现排序列和时间范围查询也是罪魁祸首。我们在余额变更日志的account_no、create_time上建了联合索引,慢查询从每秒几十次降到几次。
4.3 风控误杀与回调延迟:机制与测试
风控模块上线后最影响体验的问题就是误杀。我们最初把“非常规时间段交易”和“高频小额交易”规则的权重设得过高,导致很多正常用户被拦截,尤其是海外用户时差问题更明显。后来我们把规则引擎改成了“评分制”,每个规则给分,累计分数超过阈值才拦截,并加入白名单机制:同一设备或账号连续成功交易10次以上自动进白名单。
回调延迟的问题主要在风控异步决策链路上。我们设计了超时控制:如果异步决策在500毫秒内没回调,先放行交易,事后异步追加风控结果。这样对用户体验友好,但需要配套事后审查机制,每天凌晨对“先放行后风控”的交易做回扫。
4.4 故障速查表
| 现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 余额扣减返回影响行数为0 | 乐观锁版本冲突 | 看日志中expectVersion和数据库当前version | 重试机制,最多重试3次,仍失败转人工 |
| 交易状态卡在PROCESSING | 下游回调丢失 | 查消息队列消费日志,恢复消费 | 事务消息重发,状态机转移到FAILED |
| 对账差异金额等于0.01 | 精度问题 | 查渠道账单字段精度 | 差异阈值过滤自动平账 |
| 订单成功但积分未到账 | 跨服务最终一致未完成 | 查事务消息发送状态 | 待确认消息回查,消费补偿 |
| 接口超时飙升 | 缓存击穿/热key | 观测Redis命中率和DB慢查询 | 热点key本地缓存+限流,非核心链路降级 |
5. 实操心得与避坑清单
5.1 拆分顺序:先拆数据,再拆服务
如果让我重新做一次拆分,我会在服务拆分之前先做数据拆分。我们之前顺序反过来了,先把服务拆开,但数据库还是共库共享,结果每个服务都直接操作同一张表,权限边界形同虚设。后面补做数据拆分时,既要改DAO层,又要做历史数据迁移,还要保证迁移期间不丢数据,成本比一开始做高出不少。
先拆数据的好处是,每个服务的数据边界明确了,服务之间的交互只能通过API,不能绕过服务直连数据库。我们在拆数据时做了完整的字段级映射表,哪些字段属于账户域、哪些属于交易域、哪些是共享只读维表,全部登记在案。这张表后来成为新同学上手的重要文档。
5.2 上线前必做的几件事
金融系统上线发布前,我们固定走一套发布检查流程,缺一项都不允许上线。
第一是全链路压测。压测不只是看QPS,更重要的是看核心链路的延迟分位数,尤其P99不能超过500毫秒。第二是主备切换演练。数据库、Redis、消息队列都要做主动切换演练,验证应用在依赖故障时的表现。第三是资金对账预演。发版前先跑一遍对账脚本,确保新旧版本的数据口径一致,不然上线后平不了账。
发布策略上,我们全部采用灰度发布。刚开始按节点灰度,后来按流量比例灰度,比如先把5%的读写流量切到新版本,观察半小时日志和监控没问题再逐步放大。切量的过程也是对账和幂等逻辑二次验证的过程,因为新旧版本会同时处理同一批请求。
5.3 个人体会:对金融系统要有敬畏心
踩了这么多坑之后,最大的体会其实是心态上的:做金融系统,永远不要相信“应该没问题”。交易链路里任何一个“应该不会发生”的分支,早晚都会在线上发生。你唯一能做的,就是把每一个异常分支都显式处理掉,把每一次资金变动都留底账,把每一笔差异都暴露出来而不是悄悄抹平。
另外,人工补账入口一定不能省。无论系统设计得多完善,线上总会有极端场景需要人工介入。我们预留了一个手动调账工单流程,所有的补账操作必须经过审批、双人复核、留痕三个步骤。调账入口看起来“不自动化”,但在关键时刻能救团队一命。
如果这个项目还能往下走,我下一步会重点补两块:一是把风控的决策解释能力做出来,让用户申诉时有据可查;二是把对账系统的差异处理流程进一步自动化,减少人工干预。这些方向都不需要颠覆现有架构,但每一块都能让系统更稳、更让人省心。