凌晨两点零七分,我盯着监控大屏右上角的数字,那是一条几乎垂直向上的曲线。交易峰值每秒十一万笔,账务流水以三倍于交易量的速率在存储层滚动,数据库的CPU像被掐住脖子一样往上顶。这是16年金融架构生涯里又一个618的凌晨,也是我第一次在百亿交易规模的系统上亲眼看到“日9亿笔”这个数字稳稳落地。
这些年我踩过的坑,比很多人写过的架构方案都多。分布式架构、微服务拆分、单元化改造、缓存穿透、分布式事务、对账差账、扩容回滚……每一个关键词背后都是真实的事故报告和凌晨三点的紧急会议。这篇文章不聊理论,只聊我在百亿交易、618日9亿这种量级下,用真金白银换回来的踩坑全记录。适合正在做金融交易系统、支付系统,或者任何高并发强一致场景的同学参考,也适合那些觉得“架构就是画图”的同行冷静一下。
1. 从单体到单元化:16年架构演进路线图
1.1 我经历的三代核心系统
先交代背景。我入行时金融核心系统还是典型的单体架构,一套大而全的应用,连着Oracle数据库,跑在小型机上。那时候的架构很简单,简单到让人怀念——一个应用,一个库,出了问题重启、扩容靠加CPU。但简单也就意味着天花板低,当交易量从每天几百万笔涨到几千万笔时,数据库的连接数和单表数据量首先撑不住了。
第二代是服务化改造。我们花了两年时间,把交易、账务、风控、清结算拆成独立服务,数据库做读写分离,引入消息队列做异步解耦。这阶段最大的收获不是性能提升,而是让团队具备了“并发开发”的能力——十多个小组可以在不同服务上并行迭代,不再挤在一个代码库里互相踩脚。但服务化解决不了核心账务库的单点瓶颈,账务库还是那个唯一真源,所有服务最终都要回到它身上读写。
第三代才是单元化。这是金融行业应对海量交易比较成熟的一套打法,核心思想是“把流量和数据的处理能力水平复制成多个单元,每个单元都能独立处理一部分完整的业务请求”。说得直白些,以前是所有请求都打到一个机房的一套系统上,单元化之后,我们把用户按某种规则分片,每个分片的数据和计算能力在多个机房、多个单元里各自部署一份。用户请求进了网关,直接路由到属于他的那个单元,在单元内部完成绝大部分读写,只有跨单元的查询才走全局路由。
1.2 单元化架构不是炫技,是扩容刚需
很多团队一听单元化就觉得高大上,觉得是在追赶大厂潮流。我的实际体会是,做单元化不是撑面子,是被数据容量和数据库连接数活活逼出来的。
算一笔账:日9亿笔交易,按每笔交易产生主流水和明细流水多条记录计算,一天的账务数据增量就在几十亿行以上。传统单库单表的数据库,一个库能承载的连接数、事务并发和存储容量是有限的。即便分库分表,如果所有分片还共用一个数据库实例,数据库实例本身依然是瓶颈。单元化之后,每个单元有自己完整的数据库分片,单元之间物理隔离,扩容时加一个单元就是加一整套“应用+缓存+数据库”的部署能力,而不是只加几台应用服务器。
但单元化的代价非常大。数据分片规则一旦定错,后续数据迁移和查询会非常痛苦。我们当时把用户ID作为分片键,按单元维度做二级路由,规则看起来简单,实际落地时遇到一个麻烦——用户跨单元查询交易记录怎么办?解决方案是全局索引加异步复制:用户的核心数据在本单元,但他的基础档案和跨单元引用关系会异步同步到全局索引集群,查询时先查全局索引,再回本单元拉详情。这套机制听着简单,做起来涉及数据一致性、延迟、索引存储成本,任何一个环节没想清楚都容易埋雷。
1.3 架构演进的时机判断:老兵的建议
架构演进最大的坑不是技术选型,而是时机判断。我见过太多团队在交易量还没起来时,就照着大厂的终极形态设计系统,结果业务没等到那一天,团队先被复杂度和成本拖垮了。我的建议很朴素:当现有架构出现“明确的、可量化的瓶颈”时再演进,不要因为“别人都在做”而动手。
什么叫明确瓶颈?数据库CPU持续超过70%,连接数告警,单表数据量超过亿级后索引维护成本飙升,发布一次要协调十几个团队,线上故障定位超过半小时。这些信号出现两个以上,才说明架构真的该动了。反过来,如果只是交易量增长但系统还很稳,那说明当前架构的冗余度还没吃完,这时候优化的重点应该是“降低单笔交易成本”和“提升交付效率”,而不是盲目重构。架构师的价值不是把系统做成艺术品,而是在正确的时间做正确的事。
2. 618日9亿背后的容量与峰值治理
2.1 容量评估:从拍脑袋到压测闭环
618这种大促,系统能不能扛住,不是上线当天才知道的,而是提前两三个月就要做容量评估。早年间我们评估容量靠的是经验公式:日常峰值乘以大促系数再加安全冗余。这个方法在业务平稳时勉强够用,但大促系数怎么定?业务说增长30%,运营说增长100%,市场说翻两倍,最后拍板的人往往取个最大值的折中。现实是大促流量结构跟平时完全不同,这个系数根本不具备普适性。
后来我们改成“全链路压测拉动容量评估”的方式。每年大促前一个半月,组织一次全链路压测,用线上真实流量回放加模拟峰值流量混合加压,直接打到预发环境或隔离的压测环境。压测的目标不是看系统什么时候挂,而是找出每个链路的性能和容量拐点。比如账务数据库在2000 TPS时CPU是40%,到4000 TPS时CPU直接顶到95%,这个拐点意味着事务冲突开始指数级上升,必须提前做参数优化或扩容。
容量评估里最容易忽略的是“木桶的短板效应”。交易链路只是入口,压测一跑起来,发现最先挂掉的往往是那些不起眼的环节——对账文件处理、短信通知服务、消息堆积后的下游消费端。618当天如果用户支付成功但放款通知短信延迟六小时,投诉量会直接爆掉,这同样是事故。所以容量治理不是只看核心链路,要沿着用户请求画完整的调用链图,每个节点都要有容量水位监控。
2.2 热点冲突:锁、队列与最终一致
日9亿的交易量,意味着峰值每秒数万甚至十万级的并发请求。这个量级下,最怕的不是大流量,而是流量集中在少数热点上。比如某个爆款商品被秒杀,或者某个头部商家集中结算,所有请求都去读写同一行数据,数据库的行锁竞争会瞬间把TPS打到地板。
我们踩过最痛的一个坑,是资金账户余额更新的热点冲突。用户支付时扣账户余额,一笔交易更新一行,看似正常,但秒杀场景下几千个并发请求同时扣同一个用户或者同一个商户的余额,数据库行锁等待直接拉满,事务超时一堆。
解决热点冲突,常规路子是排队和异步化。我们最终采用的是“异步账务削峰”:交易请求进来后,先在内存队列里做请求合并和顺序化,同一账户的扣款请求被路由到同一个队列,由单线程顺序执行,写数据库的压力就从几千并发变成串行写。代价是资金账户的余额不再实时更新,而是秒级异步更新,这就是“最终一致性”在金融场景里最常见的体现。用户感知上,支付成功后余额延迟一两秒更新,这是可以接受的;但系统设计上,必须保证这短暂的延迟不会导致超卖、重复支付或者账务不平。
这里要特别提醒:异步化虽然解决了性能问题,却引入了对账复杂度。账务系统从“动一笔账立刻平”变成“异步记账,定时对平”,这个转变让很多金融背景的同学非常不安。我的经验是,异步化之前必须先设计好幂等和冲正机制,否则一旦消息丢失或重复消费,账目会乱到无法收拾。这个后面专门展开。
2.3 多级缓存与数据库保护
金融交易系统里,缓存是个危险又诱人的东西。诱人在于缓存能把热点数据的查询RT从几十毫秒降到微秒级,危险在于一旦缓存失效而流量还在,请求会瞬间穿透到数据库,形成缓存雪崩。我们618当天就发生过一次差点让核心库挂掉的事故。
那次是用户基础信息缓存,设置的过期时间是固定的凌晨两点集体失效。偏偏当天凌晨有一次版本发布,部分缓存节点重启,冷启动后缓存命中率从95%掉到40%,大量请求穿透到数据库,数据库连接池瞬间被打满,最后是靠着限流熔断和手动切流才稳住。事后复盘,问题出在“缓存过期策略过于集中”和“数据更新后缓存未主动预热”两个点上。
后来我们建立了一套数据库保护的三级机制。第一级是本地缓存,JVM内缓存热点数据,过期时间随机化,避免集体失效;第二级是分布式缓存,作为本地缓存和数据库之间的缓冲;第三级是数据库连接池的动态保护,设置最大等待时间,超过就直接快速失败,绝不无限制等待拖死整个应用。这套机制的核心逻辑是:系统可以短暂地让用户看到稍旧的缓存数据,但绝不能让数据库在无防护的情况下被流量冲垮。大促期间,数据库的健康比数据的实时性重要得多。
2.4 削峰填谷的取舍:让用户等3秒,但不让资金出错
618日9亿这个数字,如果全部靠“实时同步处理”硬扛,需要的机器资源是惊人的。后来我们想明白了一件事:交易请求的峰值是客观存在的,但很多环节并不需要绝对实时。下单要实时,支付要实时,但账户记账、积分累计、优惠券核销、对账文件生成这些环节,完全可以在峰值过后异步消化。这就叫削峰填谷。
我们的做法是消息队列削峰。支付成功后,交易系统发送一条“支付成功消息”到消息队列,下游记账、通知、营销等系统按自己的消费能力去拉取处理。峰值期间的百万条消息堆积在队列里,等流量回落后自然消化。这个方案在技术层面非常成熟,难点在于业务层面怎么说服产品和运营接受“结果延迟”——用户支付成功了,积分为什么没立刻到账?余额为什么还显示旧数字?
我的处理方式是把“实时反馈”和“最终结果”分开设计。对用户展示层,支付成功页立即显示成功状态;账户余额展示走异步刷新,配合前端轮询两三秒内更新;积分、优惠券等非核心资产放到“明细查询”里展示最终结果,并明确标注“T+1更新”。落地的时候,产品经理比架构师更紧张,但用户体验数据告诉我们,只要核心资金动作(扣款、退款、支付结果)是实时的,用户对非核心资产的延迟感知很弱。架构上的取舍,最终都要回到“用户真正关心什么”这个原点。
3. 资金安全的“保命设计”:一致性、幂等与对账
3.1 金融架构的底线:账不能错、钱不能多、单不能丢
做金融系统这么多年,我给团队立过一条铁律:你可以让请求慢一点,可以让系统短暂降级,但绝不能出现账务不平、重复扣款、交易丢单。性能和可用性是架构的颜面,资金安全是架构的命。
这三条底线落实到技术上,对应的分别是:强一致或可校验的最终一致、严格的幂等控制、不丢消息的可靠投递。每一条做起来都不轻松,但有一条最容易被新人忽略——幂等。日常开发中,网络抖动导致客户端重试是最常见的事,支付网关超时了,用户下意识多点了一下“确认支付”,如果系统没有幂等保护,同一个订单就可能被扣两次款。这类事故在金融行业属于最高级别的故障,处理流程非常痛苦,不光要退款,还要向监管写报告、向用户道歉、向公司高管解释。
幂等设计其实不复杂,核心就是一个唯一业务键加一张去重表。比如每一笔支付请求都带上业务订单号,账务系统记账前先查去重表,如果这个订单号已经处理过就直接返回之前的结果,不再重复记账。难点不在实现,而在于“所有入口都要做”。支付回调、退款请求、转账操作、账户开立、优惠券发放,凡是可能因重试而重复执行的接口,都必须做幂等。我在代码评审时看到过太多“这次先不加,等有问题再说”的论断,这种话在金融系统里就是埋雷。
3.2 分布式事务的选型与妥协
微服务化之后,一个业务操作往往跨越多个服务,交易系统扣了用户的款,账户系统要加余额,积分系统要加积分,营销系统要核销优惠券。这些操作要么全部成功,要么全部失败,这就涉及分布式事务。
业内方案很多:两阶段提交(2PC)、TCC(Try-Confirm-Cancel)、Saga、本地消息表、事务消息。我的选型经验是分场景、分等级。资金类核心操作,比如“扣款+记账”,采用TCC,保证强一致;非资金类操作,比如“支付成功+发通知+加积分”,采用本地消息表加消息队列的最终一致性方案,允许秒级延迟。
TCC的坑在于空回滚和悬挂问题。Try阶段成功但Confirm阶段失败,或者Try阶段超时后Cancel先到,这时候如果Cancel处理不当,很容易出现资金被错误退回的情况。我们的做法是给每个TCC事务生成全局事务ID,所有分支事务都记录事务状态,Cancel和Confirm都做幂等校验,只有Try成功的分支才允许Cancel,绝不盲目反向操作。
最终一致性的方案,核心是“本地消息表”。业务操作和消息写入放在同一个本地事务里,事务提交后通过一个定时任务将消息投递到消息队列,消费端处理成功后删除消息。这个方案的优点是不需要消息中间件支持事务消息,实现简单可靠;缺点是不能无限堆积,消费能力必须大于生产速度,且消息投递必须有重试和监控。我们618期间消息积压最严重时达到过千万级,但因为有本地消息表的可靠性保证,最终全部消费干净,账目一分不差。
3.3 幂等:重复请求是最大的隐形炸弹
前面提过幂等,这里我想展开讲讲实践中的细节。幂等不能只依赖数据库唯一索引,因为在分布式环境下,高并发时两个请求同时查到去重表都不存在记录,然后同时插入,唯一索引能防住一个,但那个被防住的请求怎么响应?这就需要把所有请求的状态收敛到一个“处理中”的中间态。
我们最终用的是“状态机+唯一索引”的双保险。每个核心单据(订单、流水、支付单)都有一个状态字段,从创建、处理中、成功、失败之间流转。请求进来先查状态,处理中就返回“请求已受理”,成功就返回成功结果,失败则判断是否可以重试。唯一索引作为最后的兜底,防止极端并发下状态检查失效。
这个设计还有一个隐藏好处:它天然支持了交易系统的“查询与操作分离”。用户支付后客户端频繁查询支付状态,系统不需要实时去账务系统查询,直接从状态字段就能准确回答,极大地减轻了下游压力。所以幂等设计做好之后,不只是安全,性能和用户体验也会提升。
3.4 对账体系:最后一道防线
任何人写的代码都有可能出Bug,再严谨的团队也做不到零缺陷,所以对账体系是金融系统最后一道防线。对账的本质很简单:找两个口径的数据,进行比较,差异部分触发告警和差错处理。但海量数据场景下,对账的设计非常讲究。
我们做了三层对账。第一层是实时对账:支付成功的交易,与资金渠道(银行、第三方支付)的回调结果做一致性校验,数据不一致立即告警,常见原因包括渠道延迟、回调丢失;第二层是T+1批量对账:第二天把系统内的交易流水和渠道提供的清算文件逐笔比对,金额、笔数、手续费都要平;第三层是业务级对账:把交易系统的订单、账务系统的流水、会计系统的科目余额三方比对,确保从业务数据到财务数据的链路没有任何缝隙。
早期T+1对账是纯靠批处理跑数,几天的数据量就要跑好几个小时。后来改成“分片并行+增量对账”之后,半小时内能完成全量比对。对账发现的差异,必须能自动生成差错单据进入差错处理流程,不能只是打个日志让人去查。因为一旦对账告警多了,团队会麻木,真正严重的问题反而被淹没。我给团队定的目标是:对账告警必须做到“每日清零”,当天的问题当天消化,绝不拖到第二天。
4. 压垮系统的往往不是流量:稳定性治理与踩坑实录
4.1 案例一:缓存雪崩引发的数据库连接风暴
这个事故发生在一次普通的版本发布后,流量并不大,却差点让核心库宕机,教训极其深刻。现象是发布后十分钟,数据库连接数飙升到历史最高值,大量交易请求超时,系统进入半瘫状态。
定位过程很曲折。先看应用日志,发现大量查询数据库超时;看数据库,发现活跃会话数暴涨,SQL都是同一个——查询用户基础信息。再查缓存,发现缓存命中率只有20%。进一步排查发现,版本发布时更新了用户基础信息表的结构,缓存服务做了部分节点的滚动重启,而代码里的缓存加载逻辑有个Bug:缓存Miss时不是先加分布式锁再回源数据库,而是所有并发请求都直接穿透到数据库。一次冷启动,几千个并发请求同时打到数据库,连接池瞬间被打爆。
这个事故给了我们两个教训。第一,缓存回源必须加“防击穿”设计——单飞模式(Singleflight)或者分布式锁,保证同一时刻只有一个请求回源数据库加载缓存,其他请求等待结果。第二,发布流程里必须包含“缓存预热”步骤,尤其是那些缓存节点需要重启的场景。后来我们把缓存预热做成了发布系统的一个固定环节,数据库连接数的尖峰再也没有出现过。
4.2 案例二:分库分表扩容,差点回不了头
分库分表是很多高并发系统的必经之路,但扩容的过程比想象中危险得多。我们的账务流水表在两年内数据量翻了近十倍,原计划单库64张表,结果单个库的容量和连接数都告急了,需要从单库扩容到四库。
一开始我们想用“双写方案”:新库和旧库同时写入,同步完成后再切流。这个方案听上去稳,但实现起来非常复杂——双写期间的任何数据不一致都会导致账目错误,而且旧库的历史数据要全量迁移到新库,数据量太大,迁移时间窗口根本不够。
后来我们换成了“灰度迁移+在线切换”的方案。核心思路是:新库提前建好表结构,先把最近一段时间的新数据通过同步工具从旧库实时复制到新库;然后通过开关控制流量,把一部分用户的读写切到新库,验证数据正确后再逐步扩大灰度范围;最后全量切到新库,旧库保留作为历史查询库只读使用。
这个方案的难点是切换期间的“分布式事务”问题。一个用户的数据可能一部分在旧库,一部分在新库,跨库查询和事务怎么办?我们的做法是:切换维度按用户分片,一个用户的所有数据要么在旧库要么在新库,绝不允许一个用户的数据分落在两个库。这个“最小数据单元”的原则非常重要,它让数据迁移从“全量混合”变成“按用户分批”,每一批都是完整可验证的。整个扩容过程耗时三个月,但线上交易完全没停过,切换当天零事故。
4.3 案例三:定时任务重复执行,同一个用户被扣了两次款
这是一个典型的“分布式环境下的定时任务事故”。我们的还款代扣系统,每天凌晨会跑一个定时任务,扫描当天应还款的用户并执行扣款。在单机时代,一个任务在一台机器上执行,天然不会重复。但微服务化之后,任务部署在多台机器上,如果任务框架没有分布式锁,同一时刻两台机器同时扫描同一批用户,就会重复扣款。
事故就是这么发生的。那是一个周末的凌晨,后台监控提示有异常退款操作,一查发现是还款任务在五台机器上同时执行了,同一个用户的同一笔还款被扣了两次,涉及几千个用户。虽然事后全部做了退款处理,但这属于最严重的资金安全事故,处理成本极高。
解决方案很成熟:用分布式锁保证任务同时在只有一个节点执行,或者用分布式任务调度平台(如XXL-Job、Elastic-Job),它们天然支持分片和唯一执行。但我要提醒的是,技术方案再成熟,也要在业务代码里做一层“防重保护”——执行扣款前先查还款记录,如果该用户当天已经还款成功就跳过。因为分布式锁可能出现极端情况下的失效(比如锁过期、网络分区),唯一能兜底的,还是业务状态本身。
4.4 故障定位三板斧:链路、日志、指标
在百亿交易的系统里,故障定位的难点不是没有数据,而是数据太多。一次普通的请求,经过网关、交易服务、账务服务、缓存、数据库、消息队列,可能产生上百条日志和几十个监控指标。没有高效的排查手段,故障半小时都定位不到根因。
我的三板斧是:全链路追踪、结构化日志、核心指标面板。全链路追踪解决的是“这个请求到底经过了哪些服务、用了多少时间”的问题,我们用的是SkyWalking,每个请求分配一个TraceId,贯穿所有服务的日志;结构化日志解决的是“日志能不能被机器高效检索”的问题,所有日志统一JSON格式,关键是包含TraceId、业务ID、时间戳、服务名;核心指标面板解决的是“全局态势判断”的问题,把每类服务的QPS、RT、错误率、CPU、内存、数据库连接数放在一个看板上,出现异常时先看哪个指标异常,再顺着链路往下查。
618大促期间,我要求所有核心研发都开着这个看板,异常出现第一件事不是翻日志,而是先看指标定位到具体服务和接口,再根据TraceId查日志找具体请求。这套方法在历次大促中救过我们很多次,有一次线上出现少量支付超时,十分钟内就通过链路追踪定位到一个第三方渠道的HTTP连接池配置过小,而不是像以前那样整个团队大海捞针。
5. 给后来者的几点真心话
5.1 不过度设计,架构要为业务留出进化空间
做了16年架构,我最大的转变是从“技术完美主义”变成“业务适配主义”。现在的我看到那些一上来就要上Service Mesh、要搞单元化、要搞多活的团队,都会劝他们冷静。架构不是越复杂越好,而是越匹配越好。一个日活几万的系统,单体加读写分离就足够;一个日活百万的系统,微服务加缓存就够;只有到了日交易量上亿的规模,单元化、多活、分库分表才是刚需。
但“不过度设计”不代表“不预留空间”。我的建议是:接口设计保持语义化,避免把业务逻辑写死到接口签名里;数据库设计预留扩展字段,避免未来加字段要锁表;模块边界按业务域划分,避免将来做微服务拆分时要推倒重来。架构演进是常态,好的架构不是一步到位设计出来的,而是留下了被重构、被演进的空间。
5.2 稳定性不是事后灭火,是前置投入
很多人理解稳定性就是监控、告警、应急预案,但真正做过大促系统的人会告诉你,稳定性是一个从开发到发布到运行的系统工程。前置投入体现在:代码评审要盯住并发、幂等、事务边界;测试环境要做故障注入演练,而不是只跑功能用例;发布要有灰度、有回滚预案、有流量控制;运行要有容量水位管理,而不是等告警响了再处理。
我在团队里推行过一个“稳定性预算”的概念:每个迭代周期,至少有20%的研发投入在稳定性相关的工作上,包括压测、演练、监控完善、技术债务清理。618这种大促,不是靠当天通宵盯屏盯出来的,而是靠前三个月的稳定性积累。真正的架构师,功夫在平时。
5.3 新人避坑清单
最后给新入行的同学一份实操避坑清单,都是我亲眼见过或者亲身踩过的问题:
- 任何涉及资金的操作,先想“这个接口被调用两次会怎样”,再做设计。
- 缓存不是银弹,缓存失效、缓存穿透、缓存雪崩的排查成本远高于收益,使用前先把三种场景的预案写好。
- 消息队列的消息消费,一定要做幂等,不要相信“这个消费者只消费一次”的鬼话。
- 数据库连接池的大小不是越大越好,默认配置顶多是个参考,必须根据实际压测结果调整。
- 写日志要想着“别人能不能看懂”,一个好日志的标准是:不查代码就能还原完整的请求上下文。
- 发布永远要有回滚方案,哪怕你非常有信心,没有回滚方案就是裸奔。
- 线上问题处理完毕,复盘报告里最重要的不是“怎么修复”,而是“为什么这个Bug在开发、测试、Review三道关口都没被发现”。
我在这个行业里见过的系统,从单体到分布式,从集中式到单元化,从日交易百万到百亿,技术的形态一直在变,但最基本的原则从没变过:敬畏数据、敬畏资金、敬畏线上。每一次事故背后都是一群人通宵达旦的代价,每一行代码背后都是真金白银的信任。这16年教会我的,不是怎么设计一个完美的系统,而是怎么在系统不完美的情况下,让它稳定地扛住一次次的峰值挑战,让用户相信,钱放在这里,是安全的。