资讯详情

资讯详情

大促高并发下的金融系统架构设计与稳定性保障实战

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万。再看执行计划果然typeALL全表扫描。修复其实特别简单把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里出现typeALL的一律打回重写。这可能是投入产出比最高的一条代码规范。2.3 连接池参数不合理数据库没挂是应用把连接放完了还有一次故障让我特别涨教训。大促当天数据库负载一切正常但大量请求报获取连接超时错误堆栈指向数据库连接池。当时的第一反应是数据库连接数不够但连上去看数据库端的连接数还远没到上限。继续排查才发现问题在应用端我们的连接池用的是HikariCPmaximum-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年走过来我愈发觉得金融架构这个领域没有一劳永逸的方案只有不断从一次一次故障中学习、修正、进化的过程。大促是对系统的大考也是对团队的大考。每一次踩坑只要真正挖到根因、真正落实到系统改造和机制完善上这个坑就没有白踩。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →