ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

金融级服务架构设计:从幂等、分布式事务到高可用实践

金融级服务架构设计:从幂等、分布式事务到高可用实践

金融行业的项目做多了,你会发现“金融服务”这四个字背后,其实是一个非常庞大且严苛的工程体系。它不像普通互联网业务那样“先上线再迭代”就行,而是从第一天起就要把账务准确性、资金安全、系统稳定性刻进骨子里。我这些年主导和参与过的几个金融级项目,踩过不少坑,也沉淀了一套自己的方法论。这篇就围绕“financial-services”这个主题,把我关于金融级服务架构设计的核心思路、实操细节和排障经验做个系统梳理,希望能给正在做或准备做金融系统的朋友一些参考。

1. 金融服务系统的整体设计与业务拆解

1.1 金融体系的核心矛盾:安全、一致、可用与性能

做金融系统,第一关要过的就是对“核心矛盾”的认知。普通电商系统可以把“性能优先”放在前面,能用缓存扛就扛,偶尔一个请求丢了还能重试。但金融系统完全不是这个逻辑。资金流转过程中,每一笔账都必须对得上,每一个状态变更都要有迹可循,任何一笔数据丢失、重复或错乱,都可能造成真金白银的损失,甚至引发合规风险。

所以金融系统的设计,本质上是在四个互相冲突的目标之间找平衡:数据安全、事务一致、系统可用、处理性能。

拿支付系统举例。用户发起一笔支付,你既要确保这笔请求被快速处理(性能),又要保证钱不会因为并发而扣两次(一致性),还要确保在突发流量下系统不挂(可用性),同时全程留痕以便审计(安全)。这四个目标在这个场景里是互相拉扯的。我见过一些刚转金融方向的同学,一上来就想着怎么把并发调上去,结果幂等没做好,压测阶段就出现重复扣款,这在实际生产中是不可原谅的事故。

一个成熟的金融系统,通常会把“一致性”摆在最前面,在此基础上再谈性能和可用性。这不是一句空话,而是要在你后续的每一次技术选型中都体现出来的原则。

1.2 业务域的划分:一套金融系统到底包含哪些部分

金融服务的覆盖面很广,但落到系统架构上,大致可以划分为几个核心业务域。

账户域是最底层的基础,它负责客户信息的登记、账户的开立、状态的维护。很多新人在做账户域时常犯一个错,就是把账户余额当成一个简单的字段直接更新。这在单机低并发下没问题,一旦并发上来,或者出现分库分表,余额字段的更新就会面临严重的锁冲突和数据一致性问题。后面我会详细讲这个。

账务域记录资金的变动明细,是真正体现金融严谨性的地方。这里涉及复式记账的建模思路,每一笔资金变动都要有对应的借贷分录,保证账务平衡。

支付域负责和外部渠道打交道,包括支付网关对接、路由选择、渠道限额控制。这一层的复杂度主要来自外部渠道的不确定性:渠道超时、掉单、回调延迟、返回结果不明确,都需要你的系统去适配和兜底。

清算和结算则是交易后处理阶段。清算是确认交易、计算应收应付的过程,结算是实际资金划拨的环节。这个过程通常涉及金融核心系统和外部银行的对接,往往在日终批量跑批,对数据准确性和批处理性能要求极高。

风控域横跨整个交易链路,从登录开始就在做风险评估。规则引擎、反欺诈模型、额度控制、可疑交易识别,都是风控域的职责。

1.3 为什么金融系统偏爱微服务加单元化的组合

很多资深架构师聊金融架构时必然提到微服务,但金融场景下的微服务,和普通业务系统的微服务有本质区别。

普通业务系统的微服务化,核心动机往往是“版本独立发布”“团队自治开发”。而金融系统的微服务化,首要动机是“每个核心模块都能独立扩展、独立容灾、独立审计”。比如账户服务挂了不能影响支付服务,风控服务抖动不能拖垮交易主链路,这是模块隔离的意义。

在实际落地中,我比较推荐按“业务能力”而不是“代码职责”来划分服务边界。一个常见的划分方式是把账户、账务、支付、清算、结算、风控分别独立成服务。如果团队规模小,至少也要把账务和风控独立出来,因为这两个域是金融系统稳定性的生命线。

单元化架构,则是我在做高并发金融系统时特别推荐的思路。它的核心是“每个单元都自包含完整的一套核心处理能力”,通过用户ID或商户ID做哈希分片,让某一用户的所有请求固定路由到特定单元内完成闭环。这么做的好处是数据库可以随单元水平扩展,流量可以被物理隔离,故障爆炸半径也被限制在一个单元里。单元化的代价是部署复杂度高、运维门槛高,但对于体量达到一定级别的金融平台,这笔成本是必须投入的。

2. 核心模块解析与实操要点

2.1 账户设计:余额字段不是你想改就能改

账户系统是金融系统中最容易被低估的技术点。很多项目早期为了快速上线,直接在账户表里放一个balance字段,每次交易就update一把。等业务量上来,问题就开始排队出现了。

先说说并发问题。两个请求同时读到余额1000元,两个都判断“够扣”,各自扣了800元,最后余额变成了200元。这在金融场景里叫作超扣,直接导致账户透支。解决思路不复杂,可以用乐观锁:update时带上原余额条件,影响行数为0说明冲突,重试或者报错。或者用悲观锁:select for update把行锁住,防止并发更新。两种方式各有取舍,悲观锁并发差,乐观锁重试率高,但都是可控的。

再说说另一个坑:直接把余额更新作为账务变动的唯一记录。这样一旦数据出问题,你连追溯的线索都不存在。一个合格的金融账户系统,必须有“账户”和“分录流水”两层结构。账户存当前余额和更新版本,流水记每一笔变动的原始凭证。真正产生资产变化的每一笔,都要在流水表里留一条完整记录,包含交易流水号、关联单号、变动前余额、变动后余额、变动方向等字段。

实操上,我建议账户更新和流水写入放在同一个数据库本地事务里,而不是先更新账户再异步记流水。拆成两个事务,一旦中间崩溃,账户数据就查不清了。我用过一个比较稳的思路:事务内先插入流水,再更新余额。极端情况下事务回滚,两个操作同时回滚,不会有单边数据;同时流水ID可以作为幂等键,防止重复交易。

2.2 账务建模:复式记账是金融系统的定海神针

刚到金融行业时,我对复式记账也是只知其名、不知其实。直到有一次排查一个账面资金缺口问题,我把某天的总资产变动明细拉出来和支付流水核对,怎么都对不上,就是因为有个外部的渠道退款没有同步在账务侧记账。从那以后,我是彻底理解了复式记账对金融系统的重要性。

复式记账的原则是:每一笔业务的资金变动,至少涉及两个账户,一个借方一个贷方,借贷金额始终相等。比如用户充值100元,资产账户(负债类)贷方加100,用户在平台的钱包(资产类)借方加100。两边同时入账,永不单边。

落到代码层面,这套逻辑不是一个简单的事务能搞定的,因为借贷双方可能落在不同的服务甚至不同的数据库实例里。业界的常见做法是:先通过幂等规则保证交易申请只被处理一次,然后使用分布式事务或本地消息表,把借贷记账的任务可靠地投递出去,每个记账任务都做单笔幂等校验,确保任何一侧都不会因为重试而重复入账。

账户科目的设计,建议从一开始就规划好:资产类、负债类和权益类的科目要分开,每个业务类型(充值、消费、退款、提现、手续费)都要映射到对应的科目上。这样出报表和做对账时才不会手忙脚乱。我见过不少团队一开始图省事,只记一个总额,结果业务种类一多,财务那边要明细,系统这边拿不出来,只能加班补数。

2.3 支付域:路由、限额与渠道容错的三角关系

支付网关对接是金融服务系统里“意外最多”的一环。

我经历过一次渠道故障排查,当时某行的支付接口响应突然从200ms飙升到15秒,第三方支付渠道返回了系统繁忙。用户侧的支付成功率从99.9%掉到了80%,客诉瞬间涌进来。当时我们系统的重试机制还不完善,导致大量交易被卡在中间状态,最后靠人工脚本逐个修正才收场。

那次事故之后,我对支付域的设计定了几条规矩,现在都还在用。

第一,路由和限额要动态化。每个渠道要配置优先级、权重、单笔限额、单日限额、成功率阈值。健康检查发现某个渠道成功率低于设定值,系统自动降级它的权重,把流量切到别的渠道。这种降级不能靠运维手动改配置,要能自动感知自动调整。

第二,外部接口调用必须设置总体超时和熔断机制。渠道调用量上去之后,熔断是保命的,不能把宝全押在对端。一般我会用连续失败次数加慢调用比例两个维度做熔断判断,达到阈值就快速失败,而不是继续傻等。

第三,对账补偿是支付域的终极兜底。每天要和各渠道进行交易流水对账,以我方系统记录的支付请求为基准,去比对渠道侧的交易结果,把不一致的找出来逐项分类处理:有单边、长短款、状态不一致等类型分别走对应的冲正和补账流程。

2.4 风控引擎:从规则引擎到实时决策

风控在金融系统里经常被当成一个“附加模块”,实际它应该贯穿整个交易链路。我见过最简单的风控,是在发起支付前查一个黑白名单,没有名单的就全部放行,这种力度在真实场景里几乎等于裸奔。

一个可用的风控引擎,首先需要分层的规则体系。设备层有设备指纹和注册信息校验,行为层有频次、时段、IP地址异常检测,交易层有单笔限额、累计限额、对手账户风险识别。规则引擎是这类系统的核心执行器,把规则配置抽成独立的配置中心,业务人员可以通过管理后台维护规则,而不是每次改规则都动代码。

在技术选型上,规则量不大的时候,用Drools这类引擎绰绰有余。规则量到了万级,就要考虑自研或者引入专门为金融风控设计的流式规则引擎。另外,机器学习模型的引入一定要小心,模型误杀率太高会直接影响用户体验和业务转化率,所以我会建议把模型的决策结果当作打分项,和规则结果融合后再给出最终建议,并且每个决策都要留下完整的特征快照,方便后续排查和模型迭代。

3. 关键技术选型与实现细节

3.1 数据库选型与分库分表策略

金融系统的数据库选型,没有银弹。传统的银行业务很多还在用Oracle或者DB2,这类数据库在事务处理和数据一致性上非常扎实,但扩容成本和运维复杂度也很高。互联网金融机构现在一般多采用MySQL作为OLTP主力,加上内存数据库做缓存和热点数据加速。

关于MySQL做金融主库,一个绕不开的话题就是分库分表。余额和流水这类核心数据的增速很快,单库单表在三五年内必到瓶颈,而金融数据又不允许轻率地做归档或者清理(监管要求保存期限通常很长),所以分库分表必须提前规划。

我比较常用的分片方案:按用户ID或商户ID做水平拆分。例如对用户ID做哈希取模分库,分片数要选质数,这样能让数据分布更均匀。对于流水表,除了按用户维度分片,可以考虑加一个按时间维度的分区,方便日终对账和数据清理时按时间范围批量操作。

分库分表之后,原来简单的数据库排序、分页查询、join都变成分布式问题了,这会让代码复杂度上一个台阶。所以分库分表是一把双刃剑,尽量在容量规划时才引入,不要一开始就为了“架构好看”而上。

3.2 分布式事务的取舍:TCC、SAGA与事务消息

金融系统中跨服务的数据一致性,很多情况下不能用单库事务解决。我最推荐的方案是事务消息加本地消息表,这也是我认为实操中比较稳妥的兜底方案。

以“用户充值”举例:交易服务收到充值请求,先做本地落库,把充值单标记为“待确认”,同时利用本地消息表把“执行充值”这个任务落一条记录。交易服务发送一条半消息给消息中间件,先不发真消息,发送待确认消息;本地事务提交成功后,再确认发送。如果本地事务失败,直接取消半消息。这就是RocketMQ事务消息的基本思路。

这种方式的优势在于:即使消息中间件或者后续的账务服务临时不可用,任务也还在本地消息表里,后台任务可以持续重试,重试过程中做幂等校验就行。比起TCC那种需要大量反向补偿代码的方案,事务消息对业务侵入性小得多,落地成本也更低。

当然,TCC也没有完全没有用武之地。如果业务场景确实需要强实时、弱最终一致,比如从账户A到账户B的即时转账,且两边数据都在同一个服务内管理,那TCC或本地事务依然是更好的选择。关键是按场景选工具,而不是拿着锤子看什么都像钉子。

3.3 幂等设计是金融系统的生命线

幂等这件事,怎么强调都不过分。金融网络不可避免地会有超时重试、消息重投、用户手动刷新、同一条请求被送入系统两次。没有幂等保护,这些情况就会变成重复扣款、重复退款、重复加积分。

一个通用的幂等方案是在接入层建立一个幂等表,以“业务类型+业务ID”为唯一约束。一个请求进来,先尝试插入幂等记录,插入成功才继续处理业务,插入失败说明是重复请求,直接返回上次处理结果。核心业务上,我一般还会在数据库层面的账户变动表中保留原交易流水号作为唯一索引,双保险防住极端并发下的重复入账。

用Redis做幂等也是一个办法,但要注意Redis本身可能丢数据、锁可能失效。纯用Redis做幂等会有极小概率把重复请求放进来,这是金融场景不能接受的。所以我的建议是:Redis用于快速判断,数据库唯一约束作为最终保证,两层结合才靠谱。

3.4 缓存和异步:什么样的数据适合放缓存,什么样的绝不能

金融系统同样离不开缓存,但放缓存的边界要非常清晰。

可以放缓存的数据有一类共同特点:可丢失、可容忍短暂不一致。比如用户的证件照片缓存、常用收款人列表、营销活动信息。即使缓存失效,从数据库重新加载即可,不会产生资金风险。

绝不能让缓存单独决策的业务包括:余额、冻结金额、交易状态这类直接影响资金的数据。不是说不能用缓存读这些数据,而是缓存只能加速“读取展示”,不能替代数据库作为“余额判断”和“状态变更”的依据。很多人为了减轻数据库压力,把余额直接放到Redis,用Redis来扣减,这个方案一旦Redis出现异常,账就完全失控了。我的做法是:热点账户可以做Redis预扣,但数据库的流水和余额更新必须在一个事务内完成,Redis只承担一部分展示热度,真正的金额变更权威始终是数据库。

异步化在金融系统中的应用也很广。比较典型的是把日志审计、通知推送、积分累计等非核心路径从同步调用中摘出来,交给消息队列异步处理。但注意,凡是涉及核心资金链路的步骤,尽量同步完成,不要为了性能把账务更新也异步化,否则会引入无法直观恢复的时序黑洞。

4. 安全与合规落地

4.1 数据加密的分层实践

金融系统保存的都是用户的敏感数据,身份证号、手机号、银行卡号、住址等等。对这些数据,静态加密和动态加密都要做到位。

静态防护上,数据库里的敏感字段不要明文存储。现在常用的做法是应用层加密后落库,密钥由独立的密钥管理系统(KMS)统一管理,定期轮换。分库分表后按用户维度存储,另一个容易被忽略的权限点是:运营、开发、DBA的账号不能直接查询生产环境的明文数据,所有查询都要走统一的敏感数据查看申请流程,并且全程审计。

动态防护上,对外提供的接口数据必须做展示脱敏和字段级授权。用户列表页只展示“138****5678”这样的形式,完整信息只在必要的业务场景下、经过鉴权后才返回。日志打点更是一个容易踩坑的暗雷。很多团队在实际开发时,为了排查问题方便,把完整的入参和出参打到了日志里,等于把用户的身份证号明文写到了文件里。如果你在金融行业这么干,基本等于给自己埋雷。

日志脱敏是一个基础设施级别的要求,我建议在统一的日志框架层面做一个切面,把所有包含敏感字段的日志自动脱敏后再输出。这样能拦截大多数“开发图省事”带来的泄露风险。

4.2 权限管理:最小权限原则要落到服务调用层面

金融系统的权限管理,不止管“谁能登录后台”,更要管“哪个服务能访问哪些数据”。

举个例子,账务查询服务对外提供的API,理论上只有内部合规系统可以调用,但如果你没有在服务间调用做鉴权,任何一个后渗透进来的攻击者拿到某台服务器的权限,都可以随意拉取全量账户明细,这种风险是很吓人的。

服务间调用的权限控制,可以从三层去做。第一层是网络隔离,按服务归属划分独立的VPC或子网,用安全组控制东西向流量。第二层是身份认证,每个服务都有独立的身份凭证,调用方要携带有效凭证,被调方校验后才放行。第三层是接口级别的访问控制,需要在API网关注册每个接口的调用方白名单。

最小权限原则的另一面,是后台管理系统的角色权限要细,颗粒度要到“一个操作员能看到哪些商户、哪些账户、哪些交易类型”的程度。而不是给一个超级管理员角色就让所有人随便点。

4.3 操作审计:每个动作都要能追溯

金融系统的可追溯性,是所有合规要求的底座。审计日志记录谁在什么时间对哪笔交易做了什么操作,这是刚需。

审计日志的要点有三个:

一是完整性,任何人的登录、查询、修改、授权、审批操作都要记录,包括操作者的IP、设备、操作对象、操作前后值。

二是防篡改。审计日志如果可以被修改,那审计就没有意义了。需要把审计日志写入独立的存储,并且做哈希链校验,前一条记录的哈希作为后一条的参数,任何中间记录被改动,链路就会断裂。

三是可检索。审计日志存储量通常都很大,需要设计合适的索引和归档策略,保证合规调查时可以快速检索到某一段时间、某个操作员或某笔交易相关的全部行为。

我在实际操作中见过审计日志形同虚设的案例:日志确实记录了,但格式混乱,时间字段用的是服务器本地时间,连时区都没有统一。等到真要查的时候,对着几千万行无法检索的日志消息手足无措,最后只能推倒重来。审计日志的格式定义,应该从第一天就纳入测试用例的验证范围。

5. 常见问题与排查技巧实录

5.1 重复支付的典型场景和处理思路

重复支付是支付系统最常见的线上事故之一。最常见的场景是用户在收银台点了多次“确认支付”,前端没有做按钮防抖,后端也没有做幂等,于是一笔订单生成了多笔支付单,用户被扣了多份钱。

这类问题的排查,第一件事就是找出一条请求对应哪些支付单。把接入层的请求日志和支付单表进行关联,看看同一个请求ID或者同一个订单号是否对应了多笔支付单。

解决思路要从几处堵住漏洞:前端做按钮置灰和请求序号校验;后端在创建支付单时使用“订单号+业务类型”的唯一索引;支付回调处理也做消费幂等,同一个渠道流水号只能被成功处理一次。只要这三层都做好,99%的重复支付问题都能提前遏止掉。

我实际处理过的一个极端案例是,渠道侧因为自身故障把同一笔支付回调发送了三次,而且间隔时间分布很怪:第一次和第二次隔了5分钟,第二次和第三次隔了2小时。如果支付回调处理逻辑不具备幂等能力,这三笔都会被当成新交易入账,账目立刻乱套。幂等在这类场景下,不是锦上添花,而是保命的关键。

5.2 账不平的系统性排查路径

“账不平”三个字,在任何一个金融团队里都是让人神经绷紧的词。日终对账发现总账余额对不上,或者财务提供的报表和系统流水总额不一致,就需要系统性排查。

我的排查路径基本是固定的。第一步,确认时间口径一致性。找出“不平”的时间范围,把订单表、支付流水表、账户流水表、清算报表的同一时间区间的数据全部拉出来,先保证对比的数据口径完全一致,这一步能排除大多数“假不平”。

第二步,做全量流水比对。交易系统记录的流水和渠道侧的对账文件逐笔比对,找出渠道有而系统没有的、系统有而渠道没有的、两边都有但金额不一致的,这是三大基本分类。

第三步,针对异常分类做账务处理。单边账要补账或冲正,金额不一致要找原始的请求参数和返回结果,判断是渠道扣多了还是系统记少了。这一步做完,账应该能平。如果还不能平,就需要查账务流水和账户余额之间在“状态更新”上是否有遗漏,比如某些科目更新成功但明细未登记,或者相反。

5.3 性能瓶颈的分析定位

金融系统到某个体量后,性能问题会周期性出现。遇到线上接口变慢,我建议不要上来就猜,而是先按层拆解。

接入层看网关和负载均衡的流量分布,有没有热点集中在某个节点或者说某个分片。应用层看接口RT分布和JVM GC情况,是不是某段时间GC飙升导致接口卡顿。数据层看慢SQL、锁等待、连接池打满。

慢SQL是金融系统性能问题里最多见的一类。很多时候不是因为业务有多复杂,而是SQL写法没注意索引利用。处理方式不复杂:打开慢查询日志,把执行时间超过阈值的SQL抓出来,explain分析执行计划,该加索引加索引,该改写改写。

还有一类隐蔽的性能问题来源于分库分表后的“跨分片查询”。例如某管理端页面提供了一个全量搜索功能,每次请求都要遍历所有分片,再在应用层做聚合排序,数据量大时这个操作会把数据库压垮。对这种需求,最稳妥的解法是引入专门的搜索服务,比如ElasticSearch,数据从MySQL同步过去,管理端查询走搜索服务,而不是继续在业务数据库上做全量查询。

5.4 数据不一致的修复和预防

金融服务中,数据不一致是一个不得不面对的现实问题。事务消息在极端情况下也可能出现消息丢失,异步任务也可能因为宕机导致某一步没有执行。

一个比较契合实践的做法是:从设计阶段就为每个核心交易链路配上“对账补偿机制”。日终跑批不是一个可选项,是必需项。对账程序发现差异后,要能自动生成处理任务并推送到人工处理池,由运营人员在系统里逐个确认处理方案,而不是靠手工改数据。

修复已经出现的数据不一致,务必遵守“先备份、再审慎、再操作”的原则。任何直接改数操作都要走变更审批流程,操作完成后还要复查前后状态,确保修复过程本身没有产生新的问题。这个原则听起来保守,但在金融系统里,它救过很多人。

6. 复盘与经验体会

做金融服务系统的这几年,我最大的体会是:这一行不缺聪明人,缺的是耐心和敬畏心。一个支付系统看起来很简单:用户点一下支付,钱从卡里出去,平台账号里到账。但真正的复杂性,永远藏在这些“看起来很简单”的背后。

我复盘自己踩过的坑,有几个心得想特别拿出来分享。

第一个是,核心架构的设计不要迁就短期快节奏。宁可前期多花两周把账户、流水、幂等这些底盘打扎实,也不要为了赶上线时间而走后门。技术债在金融业务里的利息是成倍增长的,任何核心模块的偷懒,都会在业务量上来之后变成大事故。

第二个是,测试环节一定要把异常场景当正常场景来测。渠道超时、重复回调、事务消息积压、微服务雪崩,这些都要出现在测试用例里。如果你的系统在面对这些场景时能保持数据一致,那线上出问题的概率会低很多。

第三个是,监控指标要尽量贴近业务。单纯的CPU、内存、接口RT这些技术指标不够,还要有“单边账数量”、“对账差异笔数”、“渠道退款失败率”、“交易幂等命中率”这些业务指标。技术故障随时可能发生,但能不能第一时间发现业务异常,就取决于你的监控面板有没有把业务指标纳入视野。

金融服务的本质是信任,而信任是技术团队用每一个细节的严谨堆出来的。希望这篇总结能帮你在设计或运营金融业务时少走一些弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进