
压测时TPS从220涨到1850响应时间从850ms降到95ms。你可能会以为我做了什么重活其实总结下来就两句话降频——让该少来的请求别来别同时来降维——让来了一次的请求做的事更少、更简单。这也是这个项目最核心的两个思路。我先说下这个项目的背景。线上是个电商交易系统大促场景下瞬间流量会冲到平时的几十倍最典型的问题就是详情页接口、订单列表接口、库存扣减这些热点调用。热点一涌上来数据库连接池瞬间被打满慢SQL拖垮整个应用紧接着就是雪崩式的超时和重试放大。整个系统像是堵在早高峰的路口谁都想挤过去结果谁都在原地不动。这个优化项目就是奔着这两个方向去的第一降低单位时间内系统必须处理的请求量第二降低单个请求在系统内部需要走过的复杂度。说白了前者管住流量入口后者减轻请求处理负担。这套思路适合做后端服务、中间件、数据层的朋友尤其是经历过线上大促、压测、突发流量导致系统告警的人。下面我把这套做法的来龙去脉、实操过程和踩过的坑完整拆一遍。1. 先把概念说透降频与降维到底在优化什么很多人一听降频先想到CPU降频、硬件降压但在高并发系统优化里完全是另一回事。我在这个项目里定义的降频是治理请求的节奏——不是让每个请求变慢而是把单位时间内的请求处理次数降下来让流量曲线变得更平滑。降维则是降低每个请求的处理复杂度——数据少查几层、逻辑少绕几圈、计算量少几个量级。1.1 降频不是限流这么简单核心是让请求不再重复先看一个最常见的场景。商品详情页接口前端每滚动一次就触发一次请求每个用户一分钟可能刷新几十次加上内部服务调用链上的多次叠加一个详情接口在高峰期每秒能被打出上万次请求。但这些请求里真正关心的数据大部分是一样的商品基本信息、库存、价格、活动标签可能每分钟才变动一次。把所有请求都打到数据库纯属浪费。降频的第一层做法就是缓存把热点数据从数据库搬到内存或Redis里。Redis单机QPS能到10万以上数据库单库打个两三千就容易出问题差距有几个量级。请求不再穿透到存储层数据库那边自然就轻松了。这不叫限制用户而是把重复的计算挡在最前面。降频的第二层做法是合并。比如用户下单后要发多个消息通知没有优化时每个通知单独走一次RPC、单独写一次MQ高峰期这些调用数量非常惊人。如果做成批量接口攒五十条请求合并成一次调用吞吐量直接就上来了。降频的第三层才是限流和削峰。限流不是把用户挡在外面而是当流量超过系统承载上限时主动丢弃或排队一部分请求保证大部分请求能正常完成。大促秒杀场景里把写请求先扔进消息队列消费端按数据库能承受的速率慢慢消化这就是削峰填谷。1.2 降维是去掉白做的功让系统处理得更轻降维这个概念听起来有点玄我换个说法大家就明白了。一个请求从进入到返回背后可能经历了网关鉴权、参数校验、N次数据库查询、N次RPC调用、各种对象转换和序列化。这些步骤里有多少是必须的我见过一个订单列表接口查询一条订单数据后在for循环里又去查用户表、查商品表、查优惠券表、查物流表单机压测就直接把数据库CPU拉满。降维要做的就是把这些绕路的计算去掉。数据层做冗余去掉复杂的多表关联查询代码层去掉多层循环嵌套把N1次查询合并成一次数据结构上做瘦身去掉用不到的大字段。简单来说同样的输入输出中间路径越短越好。举一个直观例子。报表统计需要按天计算订单量原来每次查询都对订单表做group by几千万条数据表上做分组聚合一个报表接口跑几秒钟。降维后的做法是每天凌晨用定时任务把原始订单聚合成统计表用户在页面上查的直接是提前算好的结果。这个思路的本质就是把运行时计算变成事前计算。所以整个项目的技术路线很清晰先降频控制并发压力再降维降低单次请求的成本。两者配合起来系统在高并发下才能稳定扛住。2. 从流程设计开始降频缓存、合并、限流三板斧降频最好在入口和流程设计阶段就做掉不要等流量压到数据库头上再想办法。这个项目里我把它拆成了三个层次缓存挡流量、合并减调用、限流保底线。每个层次都有对应的技术选型和参数考量。2.1 缓存挡流量哪些数据用什么策略缓存缓存是最直接的降频手段但加缓存不是拍拍脑袋就完事得先分清数据类型。判断标准很简单数据读多写少、允许短时间不一致、实时性要求不高的数据优先缓存。以这个项目为例具体分类是这样的数据类型特点缓存策略商品基础信息读多写少变化频率低Redis String缓存TTL 30分钟后台变更时主动删除缓存商品库存读多写多一致性要求高缓存写操作异步落库Redis预扣库存用户购物车数据量大仅本人访问Redis Hash缓存TTL 3天修改时直接更新活动配置低频变更JVM本地缓存 Redis双级缓存TTL 10分钟这里重点说下双级缓存。热点活动配置每次请求都要读如果全部走Redis请求量大时还是要吃几毫秒的网络IO和序列化开销。本地缓存如Caffeine放在应用内存里读的时候无网络开销几乎零成本。我当时的做法是Caffeine缓存1分钟Redis缓存10分钟后台变更配置时主动删除两个缓存。这个方案让活动配置接口的QPS从2万压测下降时CPU占用几乎没有波动。加缓存最怕三个问题缓存穿透、缓存击穿、缓存雪崩。穿透是一个绕开缓存直接查库的请求比如查一个不存在的商品ID每次都会打到底层数据库。解决方法是把不存在的Key也缓存一个空值TTL设短一些或者加布隆过滤器。击穿是某个热点Key过期时大量请求同时落到数据库重建缓存瞬间压垮数据库。解决方法是互斥锁——只有第一个请求去数据库取数据并重建缓存其他请求短暂等待后直接读缓存。雪崩是大量Key同时过期导致大面积流量涌入数据库解决方法是TTL加随机偏移让过期时间分布均匀。2.2 合并请求批量处理把小流量变成大流量高并发场景下上游服务对下游的调用如果是分散的、一次的会产生大量的RPC开销。比如订单服务要经过扣库存、发短信、加积分、推送营销如果每个环节单独调用一次下游服务一个订单就要产生4次RPC一万个订单就是4万次调用。合并的做法是通过消息队列把同步调用改异步多个消息攒一批再统一处理。比如一个促销活动用户下单后要记录访问日志和埋点数据这些对实时性要求不高完全可以先写到本地内存队列或直接发MQ由消费端批量聚合成5秒一刷或100条一刷再批量写到日志存储。这样原本每秒几千次的文件IO或数据库写操作被压缩成每秒几十次批量操作。在参考这个项目时我强烈建议多关注批量接口的设计。有些RPC框架本身就支持Batch接口比如把多个请求装在一个列表参数里向下游一次发起。批量操作的收益是线性的假设单条写耗时2ms100条分开写要200ms100条合并成一批写网络和圈复杂度接近常数整体耗时可能只要15ms。这也是很多高吞吐网关会做批量转发的原因。2.3 限流熔断守住系统的最后防线缓存和合并能消掉大部分流量但总有突发流量超出预期比如活动提前开闸、外部恶意刷接口、爬虫集中抓取。这时候必须有限流熔断机制兜底否则故障只是时间问题。限流工具上这个项目用的Sentinel里面内置了多种配额算法计数器、滑动窗口、令牌桶、漏桶。我重点说下令牌桶和漏桶的区别令牌桶允许一定的突刺流量桶里有令牌时可以直接放行适合应对流量尖峰的场景漏桶强制请求匀速通过不管上游怎么打下游收到的请求速率都是固定的适合保护数据库这类对并发有严格要求的组件。实际落地时不同接口限流阈值怎么定不能拍脑袋得压测。比如对订单创建接口先用压测工具把机器打到CPU 80%左右记录这个状态下的TPS再乘以0.6到0.8的安全系数作为限流阈值。比如压测得到5000 TPS线上限流值就设在3000到4000给系统留出冗余应对GC暂停、网络抖动等意外情况。熔断也很关键。当下游服务错误率连续达到阈值比如5秒内错误率超过50%直接熔断不再调用下游快速失败并返回兜底数据。比如商品价格服务挂了返回缓存里的一个兜底价格不让用户看到接口报错。这个项目里我用Sentinel设置了熔断规则静默期结束后允许少量请求试探下游是否恢复能恢复就继续执行不能恢复再重新熔断。3. 在数据与代码层面做降维让一次请求做更少的事流量入口管住了接下来是请求处理本身的瘦身。我见过很多系统表面上看缓存也加了、限流也配了结果性能还是拉胯原因就出在每个请求的内部处理太重。降维这一步就是把请求路径上的冗余计算给删掉。3.1 索引优化与SQL改写让查询不再全表扫描数据层的降维通常是最先能看到收益的。我们团队当时是服务高峰期慢查询频发大多数慢SQL的根因就是索引没建对或者SQL写的方式导致索引失效。这个项目里我们对订单表做了几个常见优化查询条件里有多个字段比如status、create_time、seller_id建立联合索引顺序按区分度从高到低排列严格遵循最左前缀原则。禁止对索引列使用函数比如WHERE DATE(create_time)2024-01-01这样索引会失效改成范围查询WHERE create_time 2024-01-01 AND create_time 2024-01-02。避免隐式类型转换比如user_id是varchar类型查询时传了数字MySQL会做隐式转换导致索引失效。避免SELECT *只取需要的列配合覆盖索引减少回表。这里要给一个具体的优化案例。原订单列表有一段SQLWHERE user_id ? ORDER BY create_time DESC LIMIT 10数据量到几百万行以后每次执行要60-100ms。检查发现user_id的索引是有的但排序还要走文件排序。优化方式是建立联合索引(user_id, create_time)这样索引天然按create_time有序省去排序步骤执行时间降到5ms以内。慢SQL是排查过的状态我建议每个团队都在MySQL打开慢查询日志阈值设在1秒再配合mysqldumpslow工具定期分析Top 10 SQL。这个动作看着简单但能持续帮助找到最拖后腿的查询。3.2 解决N1查询从循环查库到批量查一次代码层面的降维我遇到最多的问题是N1查询。典型的伪代码如下// 问题代码先查订单列表再循环查用户信息 ListOrder orders orderMapper.selectByUserId(userId); for (Order order : orders) { User user userMapper.selectById(order.getUserId()); order.setUserName(user.getName()); }如果有100个订单就要查101次数据库。随着订单量增加数据库连接和查询开销成倍增长。正确做法是先用一次查询拿到所有订单取出所有userId集合再用WHERE user_id IN (...)批量查询用户最后在内存里用HashMap做关联把一个list拼装出完整数据。// 优化代码批量查询一次内存组装 ListOrder orders orderMapper.selectByUserId(userId); ListLong userIds orders.stream().map(Order::getUserId).distinct().collect(Collectors.toList()); ListUser users userMapper.selectByIds(userIds); MapLong, User userMap users.stream().collect(Collectors.toMap(User::getId, Function.identity())); orders.forEach(order - { User user userMap.get(order.getUserId()); if (user ! null) { order.setUserName(user.getName()); } });这个改动看起来不起眼但对性能的影响是数量级的。100个订单的查询从101次数据库交互降到2次响应时间从200ms降到20ms以内数据库连接池的压力也大幅缓解。这也是为什么说降维的本质是用一次查询换批量数据再用内存换计算。3.3 数据结构瘦身序列化、大字段、分页深翻页数据层的另一个降维方向是让每个请求传输和处理的字节数更少。接口返回时不要豪迈地把所有字段都返回给前端很多字段前端根本不用白白浪费带宽和序列化时间。我们当时在订单详情接口上砍掉了十几个冗余字段单接口响应体从50KB降到8KBQPS直接提升40%上下这就是典型的减负收益。JSON序列化也是有成本的GC压力和CPU占用都不小。如果对性能要求极高可以在内部链路里把JSON换成Protobuf。我在压测中发现大数据量列表接口改用Protobuf后序列化耗时比JSON少了三分之二带宽占用也降到原来的四分之一。但对大部分业务系统来说JSON够用重点是别返回用不到的数据。再补一个深分页问题。LIMIT 100000, 20这种写法MySQL会把前面的10万行全部查出来再丢弃越翻越慢。优化的两个办法延迟关联SELECT * FROM orders o INNER JOIN (SELECT id FROM orders ORDER BY id LIMIT 100000, 20) t ON o.id t.id子查询只查主键再关联回原表。游标式翻页记住上一页最后一条记录的create_time和id下一页直接用WHERE (create_time, id) (?, ?) ORDER BY create_time DESC, id DESC LIMIT 20。这个方案最推荐无论翻到多少页性能都恒定。4. 实操过程订单列表接口的完整优化实录理论部分说得再多不如完整走一遍实操。这个项目里最典型的优化对象是订单列表接口它是大促期间数据库连接池爆掉的元凶之一。我按时间线把整个过程记录下来包括踩过的坑。4.1 定位问题压测、监控、日志三板斧优化之前我逼着团队先做了一轮压测和监控不然一切优化都是白费功夫。压测工具用的是开源的wrk和JMeter监控指标重点盯四个TPS、响应时间、数据库连接池使用率、GC频率。这一轮跑下来问题暴露得很清楚接口TPS压到220左右就上不去了继续加压响应时间直线拉长从200ms飙到3秒。数据库连接池使用率到100%大量线程在等待获取连接。慢查询日志里出现大量订单表查询单条耗时集中在2-5秒。GC频率虽然不至于频繁FGC但Young GC次数明显偏高对象创建太频繁。这组数据显示瓶颈在数据库和连接池而不是应用代码的CPU。后来配合全链路追踪工具SkyWalking看调用链路发现一次订单列表请求内部要调动十几条SQL其中订单主表一次、用户表循环N次、订单明细表循环N次、优惠券表循环N次。这就同时踩中了降频没做好和降维没做好两个坑。4.2 分步优化缓存、批量、异步、索引四连招接下来按优先级逐步实施每一步都通过压测验证效果。第一步给热点查询加Redis缓存。订单列表前几页是最高频的查询给前5页的查询条件做Redis缓存Key设计为user_id status page pageSizeTTL设5分钟并加随机偏移。这一步直接把高峰期的数据库查询量降了一半多。第二步N1查询改批量查询。把所有循环中的单条查询改成IN批量查询再内存组装。这一步对响应时间作用最明显原本一次请求几十条SQL变成几条SQL。第三步写操作异步化。订单列表接口本身是读操作但列表加载时附带了一堆状态流转和统计更新比如记录用户最后访问时间、累计访问次数。这些调用全部改成发MQ由消费端异步处理。第四步SQL加联合索引。给订单表加(user_id, status, create_time)联合索引解决了排序和过滤的双重开销。索引命名遵循统一约定方便运维管理。优化前后的压测数据对比如下指标优化前优化后变化TPS2201850提升约8倍平均响应时间850ms95ms降低89%P99响应时间1.8s210ms降低88%数据库QPS约6000约800降低87%数据库连接池使用率100%30%健康这个结果验证了降频和降维的核心价值数据库QPS降下来了意味着下游压力小得多响应时间降下来了前端体验好得多。最直观的收益是大促当天订单接口没再出现过一次数据库连接池告警。4.3 实操中的几个关键细节与注意事项优化过程中的细节比理论重要得多。有几个点我必须单独拿出来说。第一个是缓存Key的粒度。订单列表缓存如果把整个用户维度的订单都缓存起来一旦用户下了新订单缓存要么等TTL过期要么主动精确删除。我见过有人把粒度做得太粗一有新订单就把所有用户列表缓存清空结果缓存形同虚设。正确做法是只缓存查询参数稳定的场景并保证数据变化后能精准识别哪些Key受影响。第二个是消息队列异步化后的幂等性和顺序问题。异步化之后消息可能被重复消费也可能乱序。我在这个项目里吃过亏重复异步任务导致用户的累计访问次数翻倍。后来对所有MQ消费任务都加了唯一业务ID和状态幂等判断消费前先查一下是否已处理。顺序问题则用分区键解决把同一用户的订单消息固定路由到同一个分区消费端保证顺序严格执行。第三个是缓存与数据库的一致性。我采用的是Cache Aside模式读的时候先读缓存读不到再读数据库并回填写的时候先更新数据库再删除缓存。删除缓存比更新缓存更安全因为更新缓存存在并发写覆盖问题。即使删除缓存失败还有TTL兜底最终能保持一致。第四个是索引不是越多越好。每个索引都有写入成本订单表是高频写表加索引要非常克制。我见过一个表被加了十几个索引结果写操作被拖垮。索引只加到真正被慢查询使用的列组合上上线前用EXPLAIN看执行计划确认不会误用索引以后再保留。5. 常见问题与排查技巧实录优化做得多了会有一些反复踩坑的共性问题。我挑几个典型的记录下来给后来人当参考。5.1 加缓存之后性能反而更差我见过有人加了Redis缓存之后接口QPS反而下降排查发现每个请求都多了一次Redis网络往返而命中的Key根本没几个。这种情况往往发生在数据更新频繁导致缓存命中率极低、缓存Key设计不合理导致key数量爆炸、Redis服务本身出现了大Key热Key问题。排查经验先看缓存命中率低于50%就要考虑是不是白加了。再看Redis节点的slowlog有没有大Key操作阻塞。最后看网络Redis和应用若不在同机房单次网络开销可能拖垮整体性能。缓存不是万能药命中率上不来的场景不如不加。热Key问题单独说。某个单Key的访问量极大Redis单分片被热点打满其他分片闲着。解决办法有几个热Key做多副本缓存即同一份数据复制到多个Key里分散读压力或者把热Key从Redis搬到应用本地缓存再或者对数据做分片把一个Key拆成多个逻辑分片按请求哈希路由到不同分片。5.2 降频降维之后用户体验反而变差了这是一个经常被忽略的副作用。有人在接口上加了一个限制同一个用户在300毫秒内的重复请求直接丢弃结果用户连续快速点击下单时部分请求被丢掉导致用户以为自己没点成功。降频和降维必须考虑业务语义。只对可降级的请求做限制关键写操作绝不能轻易丢。像搜索列表、推荐列表这种读接口降频丢请求问题不大因为用户刷新后还能看到结果但下单、支付、退款这类操作即使频率异常也不能直接丢弃应该排队处理或者提示用户稍后重试。削峰填谷用在写操作上要保证用户的每个请求最终都能得到结果只是延迟了一点。5.3 如何防止过度优化优化做多了也会出现过犹不及的情况。团队曾经为了减少查询次数把业务上根本不需要多级关联的数据强行放到一个宽表里结果存储膨胀了三倍写一次数据要处理大量冗余字段后来不得不回滚。我现在的原则是用数据说话不臆想性能问题。先让监控确认瓶颈在哪再针对瓶颈做优化优化完必须压测对比收益小于预期的方案直接放弃。性能优化的目标是解决瓶颈不是把所有代码都改得面目全非。一个系统只要能稳定支撑当前业务量和3-5倍的计划增量就不需要为了优化而优化。5.4 排查高并发性能问题时的经验顺序最后分享一下排查性能问题的顺序这套顺序帮我少走了很多弯路。先看网络层排除带宽打满、跨机房延迟、RPC超时重试。再看数据库层查慢查询日志、连接池使用率、锁等待大多数性能问题都出在这一层。再看应用层用Arthas或JProfiler看线程栈、CPU热点、GC日志。最后才是各种复杂的架构级调整。还有一个容易忽略的点很多性能特征是凌晨流量低的表现白天一上来就出问题。压测和优化要覆盖真实流量高峰不能在低峰期测得数据就自欺欺人。团队当时每周都会在预发环境跑一次全链路压测用流量回放工具模拟大促状态保证代码改动上线前就能暴露性能问题。最后分享一下实际优化中的心得做了这么多轮高并发优化最深刻的一个体会是性能优化的本质不是快而是稳和省。稳是指在洪峰流量下服务不挂省是指同样的一批机器能支撑更多的业务量。降频和降维这两个方向一个管流量一个管算力组合起来就能同时做到既稳定又高效。如果你所在的项目也正面临高并发下的性能问题我建议从最小的一个接口开始练手找出它一天的调用量、平均响应时间、数据库查询次数然后想想哪些调用是重复的、哪些数据是不该实时算的、哪些代码路径是被冤枉走多圈的。一步步来把优化做成可以量化的工程动作而不是玄学。最后再分享一个小技巧每次做性能优化之前先写下一个可量化的目标数字比如把订单接口P99压到300ms以内或把数据库读取量降一半。目标越具体你越清楚优化什么时候该停、该交棒出去还是继续挖。性能优化永远是循环迭代的事不存在终极形态但每一个合理的降频、每一次有效的降维都会让系统在下一轮洪峰到来时多一分从容。