ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

金融系统服务化拆分实践:账户、交易、风控与清结算的架构演进

金融系统服务化拆分实践:账户、交易、风控与清结算的架构演进 今年的精力主要耗在了一个代号叫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 个人体会对金融系统要有敬畏心踩了这么多坑之后最大的体会其实是心态上的做金融系统永远不要相信“应该没问题”。交易链路里任何一个“应该不会发生”的分支早晚都会在线上发生。你唯一能做的就是把每一个异常分支都显式处理掉把每一次资金变动都留底账把每一笔差异都暴露出来而不是悄悄抹平。另外人工补账入口一定不能省。无论系统设计得多完善线上总会有极端场景需要人工介入。我们预留了一个手动调账工单流程所有的补账操作必须经过审批、双人复核、留痕三个步骤。调账入口看起来“不自动化”但在关键时刻能救团队一命。如果这个项目还能往下走我下一步会重点补两块一是把风控的决策解释能力做出来让用户申诉时有据可查二是把对账系统的差异处理流程进一步自动化减少人工干预。这些方向都不需要颠覆现有架构但每一块都能让系统更稳、更让人省心。
返回列表