1. 大促前夜:那些平时看不见的问题,为什么总在流量洪峰时集中爆发
做了十几年金融核心系统,我最深的体会是:日常平稳运行的系统,不代表它真的健康。很多隐患平时就埋在代码和架构里,只是业务量没到那个临界点,它们一直潜伏着。一旦618、双11这种流量洪峰打过来,所有问题会在同一时间集中引爆,像极了平时不生病、一病就是大病的身体。
先说一个我印象特别深的例子。某年618备战,我们做容量评估,按照往年增长曲线估算峰值QPS大概在3万左右,系统压测也测过,4万QPS下各项指标都还正常。结果大促当天,一个我们完全没预料到的运营活动突然爆量,核心下单接口QPS直接冲到6万。
平时3万QPS跑得好好的系统,在6万流量下暴露出一堆问题:某个Redis热key阻塞了缓存集群、一批慢SQL把数据库连接池打满、依赖的第三方风控接口超时雪崩。这时候你才意识到,系统能不能扛住流量,不是看压测报告里的数字,而是看它在真实流量下的短板在哪里。压测是自己出的题,真实流量是别人出的题,后者永远更难。
这种状况其实有非常清晰的规律可循。金融交易系统和普通互联网应用的一个关键区别在于:交易链路长、状态强一致、监管要求高。一个下单请求要经过网关、鉴权、库存、账户、支付、风控、清算等多个环节,任何一个环节出问题,整个链路都会被拖住。流量翻倍,链路里所有组件的压力几乎都是同步翻倍的,而你最薄弱的那个环节,决定了整个系统的上限。
所以大促架构设计的第一性原则,不是把某个点做到极致,而是找出链路中每一个可能成为瓶颈的点,提前拆掉它。用木桶理论来理解再贴切不过:你的系统吞吐量取决于最短的那块板,不是最长的那块。
2. 值得写进履历的几个真实踩坑:缓存穿透、慢SQL与连接池耗尽
2.1 缓存穿透:一次恶意请求把数据库打挂的完整链路
那年618,大促刚开始半小时,监控突然报警:数据库CPU飙升到95%,慢查询数暴涨,紧接着下单接口超时率急剧上升。第一反应是流量太大了,赶紧扩容数据库,但扩容之后问题并没有缓解,新加的资源也被迅速耗尽。
排查链路是这样的:先看数据库慢查询,发现大量重复的、查询不存在的订单号的SQL在疯狂执行,每秒几千次在打订单表。这些订单号有一个共同特征——完全不规则,明显不是正常用户生成的。再看Redis,缓存命中率从正常的95%以上骤降到30%左右。到这里基本可以定位:缓存穿透。
所谓缓存穿透,就是查询一个不存在的数据。正常业务逻辑是先查缓存,缓存没有就查数据库,再把结果回填缓存。但如果查询的是一个必然不存在的数据(比如伪造的订单号),缓存永远不可能命中,每次请求都会打到数据库。如果这种请求量足够大,数据库就会被拖垮。因为我们当时的缓存逻辑没有对"查不到"的结果做特殊处理,空结果不会被缓存,所以恶意请求可以无限穿透。
修复方案很简单,也很有代表性:
缓存空结果:即使查询结果为空,也把这个key缓存起来,设置一个较短的过期时间(比如3分钟),这样同一个不存在的数据短时间内不会反复穿透到数据库。这个方案要注意防止缓存雪崩,所有key的过期时间要加随机抖动,避免同时失效。
布隆过滤器前置拦截:在缓存层之前加一层布隆过滤器(Bloom Filter),把所有合法订单号预先放入过滤器。查询时先判断订单号是否存在,不存在直接返回,根本不进缓存和数据库。布隆过滤器的特点是判断"不存在"是100%准确的,判断"存在"有一定误判率,但可以用在"绝对不存在的请求直接拦截"这个场景。
参数校验前置:对请求参数做严格的格式校验,明显不符合规则的订单号直接拒绝。
这套组合拳打完之后,数据库负载立刻降了下来。后来复盘,根本原因在于我们过度信任了请求来源,没有把"系统面对的是恶意流量"这个前提纳入设计。从那以后,我把防御性设计的第一条定为:永远假设请求是不可信的,尤其是对外暴露的接口。
2.2 一条漏了索引的SQL,大促当天让核心交易多耗了400毫秒
另一个印象深刻的坑,是一条加了函数操作的SQL把索引废掉了。业务侧有个需求,要按创建时间查询当天的交易记录,开发同学写的是:
SELECT * FROM trade_order WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-06-18'这条SQL在订单量小的时候跑得飞快,不到50毫秒。可大促当天,订单表数据量到了几亿,这条SQL变成了全表扫描,因为对索引字段使用函数会导致索引失效。MySQL的优化器在这种情况下不会走索引,即使create_time上有索引也没用。大促期间这条SQL直接把数据库IO打满,核心下单链路的耗时从平均200毫秒飙到600毫秒以上。
排查过程花了将近40分钟,因为是慢慢恶化的,不是突然爆发。先是看到接口耗时曲线缓缓上升,然后查慢查询日志,发现这条SQL排在最前面,扫描行数高达8000万。再看执行计划,果然type=ALL,全表扫描。
修复其实特别简单,把SQL改成范围查询:
SELECT * FROM trade_order WHERE create_time >= '2024-06-18 00:00:00' AND create_time < '2024-06-19 00:00:00'改完执行计划立刻变成了range扫描,单次查询耗时从几秒降回几十毫秒。
这个坑的本质是开发同学不了解索引使用的底层机制。函数操作、隐式类型转换、前导通配符,这三件事是索引失效最常见的三个原因。我后来在团队里立了一条规矩:所有SQL上线前必须过执行计划审查,EXPLAIN里出现type=ALL的一律打回重写。这可能是投入产出比最高的一条代码规范。
2.3 连接池参数不合理:数据库没挂,是应用把连接放完了
还有一次故障让我特别涨教训。大促当天数据库负载一切正常,但大量请求报"获取连接超时",错误堆栈指向数据库连接池。当时的第一反应是数据库连接数不够,但连上去看,数据库端的连接数还远没到上限。
继续排查才发现问题在应用端:我们的连接池用的是HikariCP,maximum-pool-size设置的是200,大促流量上来之后,200个连接被瞬间占满,后面进来的请求只能排队等待空闲连接。正常情况下这也能接受,因为单个请求的数据库操作都在毫秒级,连接释放很快。但大促期间有部分慢查询占住了连接不放,单个操作执行了2到3秒,导致连接池里的连接周转不过来。
问题出在连接池的三个参数没有合理配置。HikariCP有几个关键参数:maximum-pool-size(最大连接数)、minimum-idle(最小空闲连接数)、connection-timeout(获取连接的超时时间)、leak-detection-threshold(连接泄漏检测阈值)。我们当时把maximum-pool-size设得过大,以为越大吞吐越高,但实际上连接池的连接数是和CPU核数相关的。每个连接对应一个JDBC连接,内部有socket、有缓冲区,连接数过多反而增加上下文切换成本,MySQL官方推荐的公式是CPU核数 * 2 + 有效磁盘数,但这个数值在SSD时代已经不太适用了,我们后来按实际压测调到100左右。
更关键的调整是加了连接泄漏检测:设置leak-detection-threshold为3000毫秒,一旦有连接占用超过3秒且没有归还,日志里会捕获完整的堆栈,能直接定位到是哪个业务代码把连接攥在手里不放。这在排查问题时太有价值了。
大促前还应该重点检查一个细节:连接池的健康检查间隔。如果数据库发生过故障切换,应用和数据库之间的连接可能已经失效了,但连接池不知道,还会把坏连接分配给业务线程。HikariCP的connection-test-query和validation-timeout要配好,确保拿到的连接都是可用的。
3. 大促高并发下的交易一致性:幂等、对账与分布式事务的取舍
金融系统做架构设计,和普通电商系统的思路有一个巨大差异:电商在极端情况下可以牺牲一部分一致性换取可用性,金融系统则不行。用户的余额、交易记录、清算报表,每一笔都不能错,错了就是资金事故。
这就引申出一个核心问题:在百亿交易规模、单日9亿的高并发场景下,如何保证交易的一致性?我的经验是用三层机制来兜底,从技术层面减少出错概率,从机制层面确保出错后能发现、能修复。
3.1 幂等设计:为什么金融接口必须天然支持重复请求
任何金融接口,都必须做成幂等的。所谓幂等,就是同一个请求执行一次和执行多次,结果是一样的。为什么必须这样?因为在大促场景下,网络抖动是常态,请求方拿不到响应会主动重试;消息队列可能重复投递;分布式调用链中某一步超时后,重试逻辑会把同一个请求再送一遍。如果你的接口不是幂等的,同一笔交易可能被扣两次款。
实现幂等最常见的方案是唯一键约束 + 状态机。比如创建交易订单,可以用业务订单号作为唯一键,插入数据库时利用唯一索引保证同一条订单只会创建一次。INSERT的时候如果碰到唯一键冲突,直接返回已存在的订单,而不是报错。
关键细节在于:幂等性的检查必须放在事务的最前面,而且要保证检查-写入这个动作是原子性的。我们踩过一个大坑:最初幂等检查是先SELECT查一次,查不到再INSERT,结果高并发下有大量重复请求同时通过检查,同时执行INSERT,虽然唯一索引会挡住后插入的,但已经产生了大量数据库无谓写入和锁竞争。后来改成直接用INSERT IGNORE或INSERT ... ON DUPLICATE KEY UPDATE,利用数据库自身的唯一约束来做判断,效率和可靠性都提升了一个量级。
再比如账户扣款,不能只检查余额够不够,还要检查这笔业务请求是不是已经执行过。我们当时的做法是单独建一张幂等表,主键就是请求的唯一流水号,先插入幂等表,插入成功才真正执行扣款逻辑。重复的请求插入幂等表时就冲突了,直接返回上次的执行结果。
3.2 对账机制:即使出了问题,也要能在第二天开门前发现
百亿交易规模下,再完善的架构也无法做到零故障,所以比"不出错"更重要的是"出错了能快速发现、快速修复"。对账机制就是这个兜底网。
金融系统里有两类对账:内部对账和外部对账。
内部对账是指自己的系统之间核对,比如订单系统和支付系统核对、支付系统和账户系统核对。具体的做法是:每个系统在完成核心操作后,往MQ里发一条操作流水,对账服务消费这些流水,按订单维度组装成完整的交易链路,检查每一步的状态是否匹配。比如订单状态是"已支付",那账户系统必须有对应的扣款流水,支付系统必须有成功的支付回调。
外部对账则是和银行、渠道方核对。银行每天会提供前一日的交易清算文件,我们拿这个文件和自己的交易系统做比对,逐笔核对金额、状态、手续费。有对不上的,系统自动标记差错,由人工处理。
大促期间对账还有个特殊之处:时间窗口极短。6月19日凌晨,618还没完全结束,6月18日的清算数据就已经开始出来了,要求在对账系统跑批完成之前,所有交易必须落库且状态正确。如果对账跑批跑不完,银行那边无法清算,直接影响资金归集。为了压缩跑批时间,我们把对账从原来的离线跑批改成准实时流式对账,交易数据一边产生,一边就对账,大促结束时大部分数据已经核对完了,剩下的增量数据跑批只要十几分钟就能完成。
3.3 分布式事务取舍:能不用两阶段提交就不用
金融系统里一谈到多系统数据一致性,很多人第一反应是Seata AT模式、两阶段提交(2PC)。但我个人的建议是:核心链路里尽量不要用强一致分布式事务方案,能通过业务设计规避的,就用业务设计规避。
原因很简单:2PC的性能太差了。它需要事务协调者把所有分支事务全部锁住、等待各自prepare成功后再统一提交,在百亿交易规模下,这种同步阻塞的模型根本扛不住高并发。而且2PC对网络故障的容忍度很差,协调者挂了,所有分支事务全都卡住,恢复起来非常痛苦。
实际落地中,我们用得最多的是本地消息表 + 定时补偿的方案。核心流程是:
- 事务开始时,业务操作和消息写入同一个数据库事务里,保证业务操作成功,消息一定落库。
- 一个异步任务不断扫描消息表,把待发送的消息投递到MQ。
- 下游消费消息执行自己的业务,执行成功后回调更新消息状态。
- 定时任务扫描长时间未完成的消息,重新投递,直到成功为止。
这套方案性能远优于2PC,核心数据库只要处理一次本地事务就行,不需要跨系统加锁。代价是最终一致性,从发起交易到下游系统完成账务处理,中间可能有一段延迟,但金融业务里有很大一部分场景是允许这种秒级甚至分钟级延迟的。真正需要强一致的核心操作,比如从用户银行卡扣款,我们会通过银行接口的同步调用+状态查询来完成,协议的保证级别比应用层事务更可靠。
4. 注册中心雪崩、配置漂移与大促架构的容量规划
4.1 一次注册中心雪崩引发的服务大面积不可用
微服务架构下,服务之间通过注册中心(当时我们用的是Nacos)互相发现。正常情况下这套机制很稳定,但大促流量上来后,发生过一次让我后怕的故障。
起因是一个服务实例因为负载过高频繁心跳超时,被注册中心摘除。摘除后,所有调用方的本地缓存里还留着这个实例的地址,继续往它发请求,结果请求超时。超时后调用方触发重试,把请求转发到其他实例,其他实例负载瞬间升高,也出现心跳超时,接二连三被摘除。整个服务集群在几分钟内被"踢空",大量请求找不到可用实例,直接失败。
这就是典型的注册中心雪崩。根源在于:服务被摘除后,调用方没有及时感知,还是按本地缓存的老地址发请求,重试又加剧了服务端的压力。
解决方案分了几个层次:
- 调用端快速失败:设置合理的超时时间和重试次数,不要无限重试。我们当时的配置是连接超时500ms,读超时1s,重试次数不超过1次。
- 服务端自我保护:开启服务熔断和限流,当某个实例的请求量超过设定阈值时,直接拒绝新请求,而不是硬扛。被摘除的服务快速重启、快速注册。
- 注册中心参数调优:心跳间隔、超时次数、摘除延迟都调大一些,避免短暂负载波动就触发摘除。核心服务的健康检查改成更宽松的方式,不只是心跳,还包含一个轻量的HTTP探活接口。
这个故障给我的最大启示是:微服务架构解决了模块化、伸缩性的问题,但也引入了分布式系统特有的故障传播问题。一个节点的异常,经过注册中心、重试机制、负载均衡层层放大,可能变成整个集群的雪崩。架构师在设计微服务系统时,必须明确每一条依赖链路的故障隔离策略,否则微服务化带来的不是稳定性,而是更复杂的故障模式。
4.2 配置漂移:明明改了配置,为什么线上还是旧逻辑
还有一类问题非常隐蔽,就是配置漂移。简单说,运维在配置中心里改了一个参数,但某个服务的本地缓存没有刷新,或者配置发布的顺序不对,导致部分实例用了新配置、部分实例还在跑旧逻辑。在大促这种需要频繁调参的场景下,配置漂移引发的故障比代码Bug还难排查,因为现象极其诡异——同样的接口,一部分请求返回新行为,一部分返回旧行为。
有一次大促期间我们要紧急调整某一个风控策略的阈值,运维在Nacos上改了配置,但只改了默认分组,没有改到实际部署的服务对应的分组。结果生产上部分服务实例收到了新配置,部分没有收到,风控规则不一致,大量正常用户的交易被误判拦截,客服电话被打爆。
排查这类问题,最关键的是日志里一定要带上配置版本号。每次发布配置时自动生成一个版本号,服务启动或刷新配置时,把当前生效的版本号打到日志里,配合统一的traceId,就能精确定位每个请求使用的是哪个版本的配置逻辑。另一个做法是把配置的元数据放在注册中心里,发布配置时自动带上一个全局递增的版本号,各服务实例定期拉取,版本不一致的实例在监控大盘上直接标红。
经验之谈:在大促之前,所有配置变更必须走完整的审核、发布、验证流程,严禁线上直接改配置。大促期间必须改,也要先发公告、确认影响范围、分批发布,每发布完一批就观察监控指标,确认无异常再发下一批。
4.3 容量规划:如何计算大促需要的真实资源
容量规划是大促准备期最核心的工作。很多团队的做法是根据经验拍脑袋,"去年用了100台机器,今年流量涨50%,那就扩到150台",这种粗放的方式不是不行,但会产生巨大的资源浪费,更重要的是无法应对突发的流量结构变化。
我的做法分四步:
第一步:算链路。把核心交易链路上每个环节的QPS预估出来。区分读请求和写请求,读请求可以做缓存、走只读副本,写请求要落库、要保证一致性,能分流的场景就分流。比如商品详情这种读多写少的场景,把缓存命中率做到98%以上,QPS的绝对值再高也不会太紧张;但下单、支付这种写请求,每笔都要走数据库,必须按真实写入量来规划数据库资源。
第二步:压测到极限。压测不是"看看能不能扛住预估流量"就完了,而是要把系统压到崩,找到每个环节的真实容量上限。比如缓存层在什么QPS下开始evict,数据库在什么并发下锁竞争加剧,消息队列在什么积压量下消费端开始跟不上。这些极限值才是容量规划的分子分母。
第三步:留冗余。按预估峰值的1.5到2倍来准备资源。这个冗余不是浪费,是在应对计划外的突发情况。大促期间的运营活动存在太多不可控变量,可能提前预告的流量只来了预计的六成,也可能临时一个热点事件就让流量冲上预估值的两倍。
第四步:压测验证弹性伸缩。在大促前一定要演练"系统突发扩容"的流程。K8s的HPA配好没有?扩容策略是否合理?新扩容的Pod多长时间能注册到注册中心并开始接流量?这些都要提前验证。真正的大促不允许你慢慢等Pod滚动更新,必须一键触发、分钟级扩容到位。
5. 16年金融架构生涯沉淀下来的几条铁律,现在都还在用
5.1 缓存不是银弹:所有缓存方案都要考虑三种雪崩场景
缓存是应对高并发最常用的手段,但也是问题最多的手段。我总结下来,缓存方案必须回答三个问题:
缓存击穿(某个热点key过期的一瞬间,大量请求同时打到数据库):解决思路是热点数据不设置过期时间,或者使用单飞(singleflight)机制,让同一个key的并发查询合并成一次数据库查询。
缓存雪崩(大量key同时过期,导致数据库压力瞬间飙升):解决思路是过期时间加随机偏移量,避免同一时刻大规模过期。
缓存一致性(数据库更新了,缓存还是旧值):解决思路是更新数据库之后,先删缓存而不是更新缓存,等下一次查询时再回填。删除缓存的动作还要考虑失败的情况,配套延迟双删或者订阅Binlog异步刷新。
这三个场景,每一个我都见过真实的生产事故。缓存设计得好,是整个系统的加速器;设计得不好,就是隐藏的定时炸弹。
5.2 链路追踪是全链路排查的前提,越早做越省力
大促期间的故障排查,最怕的就是"不知道这个请求经历了什么"。没有链路追踪系统的时候,我们排查一个跨系统问题,要在各个系统里翻日志、比时间戳、人工串联,一次问题定位经常要两三个小时。上了全链路追踪(我们用的是SkyWalking)之后,整个请求从网关到最后的数据库操作,一个traceId就串起来了,大促期间的排查效率提升了几个数量级。
具体到大促场景,最好能用链路追踪数据生成全链路拓扑图,直观看到哪一个环节耗时最长、哪个环节的错误率最高。压测的时候,这个拓扑图能帮你快速定位瓶颈是网关层、业务层还是存储层。不要舍不得这个投入,大促之前没有链路追踪系统,就等于蒙着眼睛在雷区里跑步。
5.3 预案不是写完就完了,一定要排练
每一年的618、双11,我们在大促前一周都会做故障演练,把所有可能出现的故障场景列成清单,逐个演练。数据库主从切换、缓存集群宕机、消息队列积压、核心服务被降级……每一个场景都有明确的响应步骤和责任人,演练就是验证这些步骤真的能跑通。
有一个演练让我记忆深刻:模拟某核心数据库节点宕机。执行预案的时候才发现,某个流程里的脚本因为半年前改过一次服务名,变量名已经不匹配了,执行直接报错。如果这个问题不是在大促前发现,而是在大促当天数据库真宕机的时候才发现,后果不敢想。预案的价值不在于"写了",而在于"演练过、确定能执行"。
5.4 限流降级的阈值要提前算好,别等系统崩了再想怎么救
很多团队把限流降级看作"最后一道防线",但实际上它应该是"日常与极端之间的缓冲层"。在大促系统里,限流降级不是可选的,而是必须的,而且阈值必须在压测阶段就算好。
算阈值的基本逻辑是:找出核心资源(比如数据库的最大QPS、连接池的最大并发数)的容量上限,然后设置一个低于上限的安全阈值。比如数据库能承受2000 QPS的写入,那限流阈值就设在1500 QPS,超过的部分先后排队、再丢弃、再降级。排队是为了削峰填谷,丢弃是因为瞬时流量超过系统处理能力的部分,与其让它拖垮整个系统,不如主动放弃一部分非关键请求,保护核心交易。
降级的优先级也要提前定好:哪些接口可以降级(比如查询类的、非实时的),哪些接口绝对不能降级(比如支付、退款、账户变更)。优先保证资金安全相关的核心链路,牺牲体验类的边缘功能,这是金融系统在大促期间最基本的取舍原则。个人经验:把"可降级清单"写进应急预案并同步所有人,比临时拍脑袋要靠谱得多。
5.5 复盘要有颗粒度:不要总结"思路",要还原"代码"和"时间线"
最后想说一下复盘这件事。很多团队做复盘,最后写出来的东西是"要加强监控"、"要优化架构"这种正确的废话,对后续工作的指导意义几乎为零。
我习惯的复盘格式是:按时间线还原事故发生的完整过程。每一个时间节点发生了什么,当时监控系统看到了什么,值班同学做了什么判断,为什么做这个判断,有没有预案指导,预案为什么执行成功了或失败了,最后是怎么解决的。这种颗粒度的复盘,才能暴露出真正值得改进的细节。
比如之前提到的缓存穿透事故,如果复盘只写"要加强缓存防御",下一次其他人还是会踩类似的坑。但如果把排查链路完整还原出来——首先看到的是数据库CPU飙升,然后是缓存命中率下降,然后是慢查询集中在不存在的订单号,最后定位到缓存穿透——整个推理逻辑就是可复制的,下一次遇到类似问题,新人也能沿着这个思路去排查。
16年走过来,我愈发觉得,金融架构这个领域没有一劳永逸的方案,只有不断从一次一次故障中学习、修正、进化的过程。大促是对系统的大考,也是对团队的大考。每一次踩坑,只要真正挖到根因、真正落实到系统改造和机制完善上,这个坑就没有白踩。