晚上八点,运维值班的同学电话打进来,语气已经有点绷不住了:账户服务的数据库连接池被打满,核心账务系统CPU持续在99%徘徊,营销活动页面的用户越滚越多,支付交易开始大面积超时。那晚我们被迫在网关层做了限流,活动页面挂出“系统繁忙”的公告,内部收到好几个渠道方的投诉工单。也就是从那一刻起,我彻底意识到一件事——这家金融机构的老核心,真的到了必须动手术的时候。
今天想聊的,不是“金融科技趋势”这类空泛的东西,而是过去两年间,我亲身参与的一整套核心账务系统现代化改造。我们把它从传统单体结构拆成分布式的、单元化的服务集群,期间踩过账务模型的坑、分布式事务的坑、灰度切换的坑,也积累了一些教科书上不会写的实战判断。如果你也在金融服务行业做系统建设,无论是银行、支付、消费金融还是保险,这篇文章里的大部分问题,你都大概率会遇到。
1. 传统单体核心:打不赢“双11式”洪峰的老底子
1.1 一个真实事故:营销活动把核心打蒙了
先复盘一下开头那场事故,因为它的根因非常典型。那天晚上的营销活动其实并不复杂,就是消费达标抽红包,规则简单、入口醒目,用户一进来就疯狂点击“参与抽奖”按钮。抽奖接口不长,但它背后要校验账户状态、查询余额、冻结奖品资金、写流水、更新积分,一整条链路下来要调用账务核心的十几个接口。
问题出在账务核心的数据库上。我们的老核心是典型的集中式架构,账户余额就存在一张大表里,转账、消费、冻结全都会去更新这一行。并发一上来,行锁竞争立刻飙升,数据库的锁等待时间从几毫秒膨胀到几百毫秒。更惨的是,日终批处理任务在晚上八点半启动,批任务扫描全量账户跑利息、计提、归集,大量读写操作和联机交易抢同一个数据库的资源。
那晚的监控曲线非常难看:数据库活跃会话数从几十冲到几百,连接池被耗尽,应用层开始排队,超时又触发重试,重试又加重数据库压力,典型的雪崩循环。最后我们只能靠人工限流止损,但这治标不治本,下一次活动、下下一次大促,同样的事情还会发生。
1.2 单体核心的“稳”和“贵”是一体两面
老一辈金融系统的工程师都清楚,集中式架构最大的优点就是稳。数据在一个库里,事务在本地就能完成ACID,资金安全天然可控。这个架构逻辑上极其简洁:一次扣款就是一个update语句,要么成功要么失败,不存在跨节点的一致性问题。
问题在于,这种“稳”是靠堆硬件换来的。垂直扩容是单体核心唯一的出路:CPU不够就加CPU,内存不够就加内存,磁盘不够就换更大的存储设备。业务量翻一倍,你就得买一台更贵的主机;业务量再翻一倍,整个团队就要开始焦虑:这个级别的机器,再往上已经很难买了。
我见过不少金融机构的核心系统还跑在物理大机上,机柜里的设备动辄投资几千万。可这种投资的边际效益越来越低:大机再快,单库的并发连接数、事务吞吐量都有物理上限,到了一定规模,钱花了,瓶颈还是卡在那里。
1.3 三座大山:弹性、发版、链路
除了容量问题,老架构还有三座大山压得开发团队喘不过气。
第一是弹性。传统核心的容量是静态的,上线前评估好峰值容量,买好设备,之后就固定了。但金融服务现在具有很强的潮汐效应:月初发工资代发、节假日消费高峰、电商大促期间支付渠道冲量,流量可能瞬间是平时的十倍。静态架构要么按峰值配资源造成巨大浪费,要么干脆不够用,没有第三条路。
第二是发版。单体核心代码量巨大,模块之间耦合极深。改一个存储过程,可能影响十几条业务链;加一个字段,所有下游消费方都得跟着改。我们当时的发版节奏基本是三周一个版本,但真正能上线的功能很少,大部分时间都花在回归测试上,因为“核心系统”三个字本身就意味着不能出错。
第三是链路。业务需求是叠加出来的,早期加功能,都是在一个项目里加几个接口。几年下来,一个扣款流程里嵌了几个内部模块调用、几个外部渠道回调,谁也说不清。排查一个问题,从应用到数据库,从账务到清算,全靠人肉翻日志,链路像一团乱麻。
所以,当业务方提出要支持“亿级用户规模”“秒级响应”这类目标时,我们清楚老核心已经不具备改造的底子了,只能重建。
2. 账务中心拆分:先把“钱”这件事想清楚
2.1 从余额表到流水账:账务模型重构
很多人以为拆核心就是把大单体拆成微服务、把数据库分库分表,技术选型定了就能开工。真正动起来才发现,最难的从来不是技术,而是把“钱”这件事重新想清楚。
老核心的账务模型是余额驱动的。账户表里有“余额”字段,每次交易直接更新余额,查询余额就是查这一行。这个模型简单可靠,但有一个致命缺陷:并发更新余额必然产生行锁。同一个账户哪怕发生十笔并发交易,也得排队一笔一笔更新。
新模型我们改成流水驱动、余额为辅。账户余额仍然保留,但它是从交易流水计算出来的快照值,通过异步对账保证余额和流水最终一致。核心事实是流水,每一笔交易都是不可变的一行记录;余额只是一个缓存性质的加速查询手段。
这个转变听起来简单,落地时牵扯到很多细节。比如冻结和扣款要分开处理:冻结资金不能直接改余额,而是在“可用余额”上扣减,同时记录冻结流水;真正消费完成后再出账,把余额和可用余额一起变。更关键的是,所有操作都要能对冲、能冲正,这样即使系统异常,资金也不会凭空消失。
2.2 拆服务先拆业务:交易、会计、账户各管一段
老核心把交易逻辑、账务逻辑、会计逻辑全揉在一个项目里。拆新系统时我们花了整整一个月做业务建模,最终把账务链路拆成了三个核心服务。
交易服务负责受理和校验:检查账户状态、校验交易参数、判断业务规则,它完全不关心资金怎么记账。账务服务才是真记账的地方:处理冻结、解冻、扣款、入账,维护账户余额和流水。会计引擎单独拆出来,负责生成会计分录,把每笔业务翻译成借贷两条分录,确保会计恒等式永远平。
拆完之后最直观的感受是,每个服务可以独立发版、独立伸缩、独立配容量。大促前我只扩容账务服务和交易服务,没必要把整条链路全量扩一遍。这看似是运维层面的便利,其实是整个架构从“单体巨石”走向“可编排积木”的关键一步。
2.3 读路径怎么办:实时余额缓存与异步对账
拆分模型的过程中,我们被问得最多的一个问题是:余额既然是快照,那用户查余额准不准?
答案是准,但要靠机制保证。新系统里余额查询走缓存:账户余额变更时同步更新缓存,并异步追加流水。正常情况下缓存和数据库是一致的;极端情况下缓存抖动、更新丢失,我们靠一个对账任务在毫秒级延迟内把差异拉平。
对账任务是这套体系里最不能省的部分。它周期性地扫描当天的交易流水,重新计算每个账户的应余余额,和缓存中的当前余额做差,发现不一致就告警并自动修正。我们把这套机制叫作“余额守护”。上线初期它确实抓出过几个bug,比如某个扣款场景漏发了缓存更新事件,余额显示偏大,守护任务在十几秒内就发现并自动纠正了。对用户来说最多是刷新后余额变了,资金安全完全不受影响。
3. 单元化与数据分片:让核心真的能“横着长”
3.1 为什么要走到单元化这一步
服务拆完之后,我们以为问题解决了。但实际上,拆分只能解决单机扩容问题,解决不了数据层的容量上限。你可以给账务服务开五十个Pod,但它后面的数据库还是那一台,所有Pod连的是同一个库。连接数、CPU、IO、内存,全卡在这一个点上。
打个比方,拆微服务等于把一个大厨房隔成几个小厨房,每个厨房做不同的菜;但不分库就等于所有小厨房共用同一个水龙头。局部的并发是分流了,水龙头的流量一点没减。
真正要“横着长”,必须让数据和流量一起分片。这就是单元化架构的核心思路:把整个系统复制成N个完全独立的“单元”,每个单元只负责一部分客户的数据和请求,单元之间尽量不互相调用。
3.2 客户维度路由与单元内闭环
分片的粒度我们选了客户维度,而不是账号维度。这是踩过坑之后的教训。最初我们按账号哈希分片,结果发现一个客户开十个账户,十个账户可能落在五个不同的单元里。跨单元转账、合并对账单、汇总资产这些操作全都变成分布式事务,复杂度直接爆炸。
换成客户维度之后,一个客户的所有账户天然落在同一个单元。转账操作在这个单元内部就能闭环,不需要跨单元协商。对整个系统来说,每个单元是一个自包含的迷你核心,有自己独立的数据库、缓存、消息队列和应用集群。
路由层负责按客户号计算哈希,把请求分发到对应单元。我们实现了一个轻量级的路由组件,嵌入在网关层,根据路径参数里携带的客户号做一致性哈希。一致性哈希的好处是增加单元时只需要迁移少量数据,而不是全量重新分布。
3.3 分片键选错会怎样:一次真实的教训
分片键这件事,我们曾经差点在错误的方向上一路走到黑。早期方案选择用手机号做分片键,理由是用户登录和查询都从手机号开始,路由起来很直接。
但金融服务有一个特殊属性:手机号是会变的。用户更换手机号,意味着他的数据归属单元要整体迁移。这个迁移可不只是挪几行数据,而是牵连账户、流水、存款、持仓在一大堆表之间按新哈希重新分布,稍有不慎就丢数据。我们评估后发现迁移成本实在太高,最终改成了客户号分片。客户号由系统生成,终身不变,天生的稳定路由键。
这个教训也验证了一个原则:分片键选的不是“访问最频繁的字段”,而是“生命周期最稳定的字段”。访问问题可以用缓存解决,数据搬家的问题无法用缓存解决。
3.4 全局流水号与发号器的设计细节
分片之后还面临一个看似小、实际很麻烦的问题:流水号怎么生成。
以前是单库自增主键,简单省事,分片后这个方案直接失效。两个单元可能同时生成同一个流水号,资金流水一对齐就乱了。我们引入了全局发号器,基于数据库号段模式实现:发号器预分配一批连续号段给每个单元,单元内部从本地号段里取号。
现在每个单元不必每次请求都远程取号,本地直接递增就行,性能几乎没有损耗。同时我们还在流水号里嵌入了单元标识字段,光看流水号就能判断这笔交易发生在哪个单元,排错时非常有帮助。
4. 分布式事务的取舍:放弃强一致后的那些事
4.1 拆分带来的天然矛盾
账务系统拆开之后,最让人心里没底的,是原本一个本地事务就能搞定的操作,现在要跨服务、跨库、跨网络。
举一个最简单的场景:用户绑定银行卡,需要从资金账户里冻结一笔保证金,然后在会员账户里增加一笔权益记录。老系统里这两个操作在一个库、一个事务里,要么全成功,要么全回滚。新系统里资金账户在单元A,会员服务在单元B,跨库跨服务,事务语义被彻底打破。
我们面临三条路:TCC、可靠消息、SAGA。这套组合拳今天我可以直接告诉你结论:没有任何一种方案能覆盖所有场景,必须按资金风险等级分类处理。
4.2 资金强相关场景:TCC是底线
涉及真实资金扣减和冻结的场景,我们全部采用TCC模式。Try阶段做资源预留:冻结资金、锁定额度;Confirm阶段真正扣减资金、写入流水;Cancel阶段释放冻结、恢复额度。
这个模式有一个核心优点:每个阶段都是幂等操作,配合全局事务ID做防重,数据不会重复、不会错乱。实际操作中我们给每个TCC事务加了一张事务控制表,记录每个事务的当前状态:初始、Try成功、Confirm完成、Cancel完成。任何阶段异常,都能从控制表里找到具体状态,做人工介入或者自动恢复。
TCC的代价是业务侵入性很强。每个参与方都要实现Try、Confirm、Cancel三个方法,代码量明显增加。但考虑到资金安全,这笔成本必须花。
4.3 资金弱相关场景:可靠消息是我的默认方案
TCC不能覆盖所有场景,比如交易成功后要发积分、要推送通知、要异步生成历史明细归档。这些场景如果也走TCC,等于为了一个最终能一致的目标,牺牲了太多系统性能。
我们的默认方案是本地消息表加MQ。核心思路是:把业务写入和消息写入放在同一个本地事务里。业务操作成功,消息表里就多一条待发送记录;业务操作失败,消息不会产生。扫表程序轮询待发送消息,投递到MQ,消费者收到后处理,处理成功后回调确认。万一消息发送失败,消息表里的记录会一直重试,不会丢失。
这套方案保障的是最终一致性,不保证强一致。它最大的优点是实现成本低、对业务侵入小,而且消息表天然具备审计功能,出了事还能回溯。
4.4 对账兜底:无论如何都要有最后防线
还有一点必须强调:再完善的分布式事务方案,也不能保证百分百不丢数据。我们的最终防线是日终对账。
每天凌晨三点,对账任务从清算文件、交易流水、会计分录三个角度做交叉核对。哪个时间段的交易流水和清算记录对不上,就自动触发缺失重放。这一步过去两年里帮我们抓住了三次线上问题,其中一次是TCC控制表脏数据导致的资金重复入账,如果不是对账兜底,问题可能会在月结时才被发现。
做分布式账务系统,永远要把“即使出了bug也不能丢钱”当作设计底线。对账就是这条底线的重锤。
5. 真正难的不是技术,是切换
5.1 并行运行:新老系统必须“双跑对账”
新系统开发完成之后,真正艰难的工作才刚刚开始。把新老两套核心放在一起跑,逐笔对比结果,确认新系统的账务处理和老系统完全一致。
我们搭建了一套“双跑验证”通道。线上真实的交易流量会同时打到新老两套系统上,新系统处理的每一笔结果都不会影响真实资金,只用于对比。两边处理完成后,一个独立的比对模块按交易流水号,逐笔核对:交易金额是否一致、余额变动是否一致、会计分录是否一致,任何差异都会告警。
这个阶段整整跑了三周,抓出来十几个差异。大部分是取整规则的细微差别:老系统四舍五入,新系统银行家舍入,差几分钱;还有一笔差异是冻结顺序不同导致的余额变动时间点不一致。这些差异在单笔层面微乎其微,但如果不提前发现,真实切流后会酿成大麻烦。
5.2 灰度切流的节奏:先读后写、按白名单分批
并行验证通过后,我开始切真实流量。切流的节奏绝不能一把梭,而是按“灰度白名单”分批推进。
第一批只切查询流量:余额查询、流水查询、账户状态查询,全部走新系统。查询只读数据,风险最小,但能充分验证新系统在真实读写压力下的响应速度。
第二批切非资金交易:修改手机号、更新联系地址、开通电子账单,这类操作不动资金,即使出问题影响也可控。
第三批才切资金交易,而且是按客户白名单小步放开:先放一万个内部员工客户,观察一周;再放内部员工加部分白名单客户,观察两周;各项指标正常后,才逐步全网放开。资金交易的切流周期我们整整走了四十五天,这四十五天里每天最紧张的时刻是看对账报表是否有差异告警。
5.3 双写与回切:切过去了还要能“退”
灰度切流进入第三周时,我们做了一次完整回切演练。原因是:如果新系统运行过程中出现重大事故,必须能在短时间内切回老系统,而且数据不能丢、资金不能乱。
回切的基础是双写。在整个过渡周期内,每笔成功交易除了写入新系统,还会同步写入老系统的“扩展账务表”。这个扩展表不是老核心原来的账户表,而是平滑叠加的旁路数据,老系统自身仍然正常运行。
这样一旦需要回切,老系统的扩展表里已经有完整的交易数据,只需要把它合并进老系统的正式账务体系即可。我们演练过两次,整个过程大约需要三小时,能接受的业务停机时间窗口是四小时,刚好卡得过去。
5.4 一次回切演练暴露出的脏数据问题
第一次回切演练并不顺利。数据合并阶段,对账模块发现老系统扩展表里有753笔交易的金额方向反了。排查原因后发现,新系统某个转账接口在回写老系统扩展表时,把金额序列化的字段顺序搞反了——入账和出账对调了。
幸好这只是在老系统的旁路数据里,没有影响真实资金。但这个bug如果等到真实回切时才暴露,后果不堪设想。所以我的建议是:回切演练绝不能在系统稳定后只做一次,一定要在切流的不同阶段反复演练,而且每次都要故意注入故障,验证对账模块能不能及时发现。
切流上线后的前三个月,我们每一周都做一次完整的回切验证。看似折腾,但正是这种重复训练,给了运营团队足够的安全感。
6. 落地两年后的运维复盘
6.1 全链路追踪:分布式之后,排错靠的是“一根线”
单体核心时代排错很简单,日志都在一台机器上,grep一下就行。分布式之后,一次用户请求可能横跨网关、路由、交易服务、账务服务、会计引擎、缓存、MQ,七八个节点,没有链路追踪几乎寸步难行。
我们在全链路接入阶段做了三件事。第一,所有入口统一生成TraceId,并在HTTP调用、MQ消息、异步任务中透传。第二,每个服务在日志中嵌入TraceId,日志平台支持按TraceId聚合检索。第三,关键路径埋点,记录每一步的耗时和状态码。
这套体系上线后,最大的改变是排查问题的时间从小时级降到分钟级。有一次客户反馈转账成功但积分没到账,我用TraceId从网关一路查到积分服务,发现是MQ消息消费时序列化失败被静默吞掉了,定位到具体代码行,整个排查过程不到半小时。
6.2 慢SQL治理和大事务拆分
分布式系统的性能问题,八成以上最后都落在数据库上。我们后期花了大量精力做慢SQL治理。
新系统刚上线时,慢SQL数量一度很多。排查下来有几类典型问题:账务流水表忘记加必要索引,按时间范围查询直接全表扫描;部分报表查询把大量数据加载到应用内存再过滤;还有几个大事务,一个事务里处理了几千条流水,锁持有时间过长。
大事务是金融系统的隐形杀手。它不像慢SQL那样立刻暴露,但会在并发高峰期慢慢放大延迟,让人误以为只是网络抖动。我们后来给所有事务性接口立了一个规矩:单个事务处理的数据量必须控制在两百条以内,超了强制拆批。这个约束看起来简单,但有效倒逼了业务逻辑的拆解。
6.3 混沌工程:敢于主动制造故障
系统稳定运行半年后,我们的信心越来越足,但我开始有另一种担心:真正的故障从没来,来了我们真的能扛住吗?
于是组织了两次混沌演习。第一次模拟账务服务的一个Pod被kill -9,观察系统的自动恢复机制。结果是恢复成功了,但引发了重试风暴:网关层和交易服务短时间内发起大量重试,一下子把账务服务的新Pod打爆。这个教训让我们给所有重试逻辑加了退避策略和最大重试次数限制。
第二次模拟缓存集群整体故障,观察账户余额查询的降级路径是否正常。结果发现降级逻辑写得有问题,一个异常分支竟然走了死循环,个别查询接口直接卡死。这两次演习暴露的问题,如果等真实故障来检验,代价会大得多。
混沌工程在金融服务领域听起来像“引狼入室”,但它恰恰是让系统真正变稳的最快路径。主动制造可控的故障,远好过被动等待不可控的事故。
6.4 监控指标:不要只看平均延迟,要看TP999
最后聊聊监控指标。很多团队喜欢拿“平均响应时间”说事,但平均值的迷惑性非常大。一次调用耗时10毫秒,九十九次耗时50毫秒,平均下来49.5毫秒,看起来非常健康。可真正影响用户体验的是那百分之一的尾部延迟,可能已经达到两秒。
我们监控体系的核心指标全部改为TP999和错误率。账务服务、交易服务的TP999必须控制在500毫秒以下,超过就触发预警。容量水位监控则是按单元维度做的,每个单元的CPU、内存、数据库连接使用率、MQ积压量都单独监控,任何一个单元接近阈值,都能提前扩容或者切流量。
曾经有一次促销活动前,监控发现某个单元的数据库连接数使用率达到85%,我们提前把该单元的读流量切了一部分到其他单元,整个过程业务无感。事后复盘,如果按平均延迟看,当时数据根本不会触发预警。
做完这套改造,我的一个强烈感受是:金融服务的核心系统,真正重要的不只是“技术先进”,而是“可预测”。你可以在一天之内把容量预测得准、把故障定位得快、把业务恢复得稳,用户感受到的就是安全感和信任感。这套经验,说到底也不是银行专属,任何处理大规模资金交易的互联网金融机构,逻辑都是通的。