凌晨两点的告警电话,大促压测TP99直接飙到2.3秒,团队第一反应是查数据库,结果DBA翻了半天慢日志,发现数据库CPU只有12%——问题根本不在数据层。这是我在高并发系统性能优化实战中印象最深的一次。高并发系统性能优化是个听着很宽泛、做起来特别容易跑偏的领域,大多数人的第一反应是加机器、加Redis、上分库分表,结果往往是在错误的方向上疯狂堆成本。这篇内容立足我多年一线调优经验,讲清楚优化的真正起点、分层手段和那些很少有人写在文档里的坑,适合后端开发、架构师以及对高并发调优感兴趣的读者。你不需要一上来就背一堆参数,先掌握一套定位瓶颈的方法论,比学会一百个优化技巧都管用。
1. 先搞清楚你的系统到底卡在哪:瓶颈定位方法论
1.1 一次凌晨告警教我的事
先说个真实经历。之前带过一个电商交易链路,大促压测时接口TP99从平时的80ms直接飙到2.3s,告警半夜把我从床上拉起来。当时团队第一反应是数据库扛不住了,DBA翻慢日志翻了半天,发现数据库CPU只有12%,磁盘IO也没到瓶颈,虽然是有一批慢SQL,但每一条都是走主键的单行查询,怎么看都不像主因。最后用链路追踪一抓,问题居然出在网关层的JSON序列化——订单对象嵌套了十几层,字段多不说,里面还塞了几个巨大的list,每个请求进来要反序列化一次、转发出去再序列化一次,几百MB的请求体就这么被反复处理,GC压力也跟着飙上去。
这个案例让我一直记到现在。高并发系统性能优化的难点,往往不是"怎么优化",而是"往哪儿优化"。在错误的方向上优化,做得越深,浪费越大。从那以后,我给自己定了一条规矩:线上性能问题,没有全链路数据之前不许动代码。
1.2 性能瓶颈通常藏在四个位置
从我接触到的大量线上问题来看,高并发系统的性能瓶颈基本逃不出下面这四个位置:
| 瓶颈位置 | 典型表现 | 初步排查手段 |
|---|---|---|
| 网络链路 | 请求延迟波动大、跨机房RTT高、丢包重传 | ping、mtr、网关层耗时统计 |
| 接入/网关层 | 连接数打满、线程池拒绝、序列化耗时高 | 网关访问日志、线程池监控、profiler |
| 应用服务层 | CPU飙高、GC频繁、线程阻塞、内存溢出 | top、jstack、heap dump、火焰图 |
| 缓存与数据层 | 缓存命中率低、慢SQL、连接池耗尽、锁等待 | 缓存监控、慢日志、EXPLAIN、DB监控 |
很多人一遇到性能问题就去查数据库,是因为数据库最"显眼"。但实际经验告诉我,很多系统的瓶颈其实在更靠前的位置。我一般会先看整条链路的耗时分布——请求从进网关到应用、到缓存、到数据库,每个环节各花了多少时间。哪个环节耗时异常,就往哪里深挖。比如响应时间1秒,应用层只花了50ms,数据库只花了30ms,那问题八成在网络或客户端;反过来,如果应用层就占了900ms,那应该立刻去应用线程栈里看卡在哪个调用上。
1.3 用数据说话:链路追踪与压测的基本姿势
定位瓶颈光靠猜不行,必须有全局数据。我现在处理高并发问题的标准配置是:SkyWalking或者Zipkin做全链路追踪,每个请求生成一个traceId,从头到尾记录每一步耗时;Prometheus加Grafana做指标监控;压测用wrk或者JMeter做基础压力测试,必要时配合全链路压测平台做容量验证。
一个traceId贯穿网关、应用、缓存、数据库的调用链,一旦哪个环节耗时长,十分钟之内就能看到。这套东西不是高深技术,但在很多团队里真的很稀缺——大家习惯"感觉是这里慢"就去这里翻,翻了半天没结果。我处理问题从来是先在监控面板上把链路耗时分布截出来,拿数据说话,再决定往哪个方向使劲。没有这个习惯的人,往往会在定位阶段浪费掉整个优化项目一半的时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 应用层优化:线程模型、连接池与异步化的取舍
2.1 线程数不是越大越好:从Tomcat参数说起
很多刚接触高并发的同学有个根深蒂固的误区:线程池越大,并发处理能力越强。真实情况恰恰相反。线程是操作系统资源,一个8核机器上跑几千个线程,CPU时间全耗在线程切换上,真正干活的反而没多少。
IO密集型场景下,线程数有一个广泛使用的经验公式:
code复制线程数 ≈ CPU核数 × (1 + 平均等待时间 / 平均计算时间)
举个例子:一个请求平均在CPU上计算10ms,但要等下游接口返回50ms,等待/计算比是5,8核机器上可以配置大约8 × (1+5) = 48个线程。反过来,如果计算占了40ms、等待只有20ms,那8核机器配16个线程基本就够了,配200个反而会因上下文切换导致吞吐量下降。
在Tomcat这类容器里,maxThreads、acceptCount这些参数必须结合实际压测结果来调,不能拍脑袋填个2000。我见过太多因为maxThreads拉满导致线程堆积、内存暴涨的翻车案例。调线程池参数时,一定要同时观察CPU使用率和TP99,找到最佳平衡点,而不是一味求大。
2.2 连接池耗尽:一个被低估的故障源
应用与数据库之间、应用与应用之间的连接池,是高并发系统最容易爆的地方,也是很多优化项目容易漏掉的一环。我遇到过一个很典型的故障:一个服务的数据库连接池maxActive配了50,平时完全够用,但某次上游调用方改了重试策略,一秒内打过来几千个请求,数据库连接瞬间被占满,后续请求全部排队。更糟糕的是,数据库端有一条慢SQL执行了10秒,这期间所有连接都被它霸占着,其他正常请求全部超时。
连接池耗尽的核心问题不是池子不够大,而是池里的连接被慢请求霸占太久。优化方向应该是控制慢SQL、缩短超时时间,让连接快速释放。连接池相关的initialSize、maxActive、maxWait等参数要根据业务特点反复压测确定。我现在遵循的通用原则是:maxWait别设太长,宁可快速失败,也不要让请求无限排队。快速失败至少能让上游感知到容量不足,从而触发熔断降级,而不是让整个服务默默死掉。
2.3 异步化与削峰填谷:消息队列的正确用法
高并发系统里,不是所有请求都必须同步返回。像下单、支付回调、积分发放、发短信通知这类场景,完全可以把写操作丢进消息队列,由下游异步消费。这样做的好处非常明显:
- 削峰填谷:瞬时洪峰先落到队列,消费者按自己能力消费,避免打爆数据库;
- 解耦:核心链路不依赖非核心服务的可用性;
- 提升响应速度:主链路只需写库加发消息,不需要等所有下游都处理完。
但异步化绝对不是银弹。消息队列带来的最大代价有两个:一是幂等,消费端必须保证同一条消息处理多次不会出问题,否则重复消费会造成重复扣款、重复发券;二是顺序,同一用户的多个操作如果被不同消费者并发消费,可能会乱序。这块设计不好,系统照样出乱子。所以每次引入MQ,我都会额外问一句:消息丢失能不能接受,消息重复能不能接受,顺序乱了能不能接受。答案只要有一个"不能",对应的保障机制就得跟上。
2.4 容易被忽视的序列化与移动端弱网因素
序列化性能问题不只是前端才有,高并发后端一样会踩。前文提到的压测翻车,根因就是JSON序列化。高流量网关、微服务之间通信,建议优先选二进制序列化协议,比如Protobuf。它体积小、序列化和反序列化速度快,在大流量场景下收益非常明显。JSON当然可读性好,但性能和体积在动辄每秒上万次调用的系统里真的吃亏。如果暂时不能换协议,至少做到:去掉无用字段、避免超深嵌套、不要为了一个字段值把整个大对象返回给客户端。
移动端性能优化也是高并发系统常常被忽视的一环。移动网络波动大、弱网环境多,假如客户端一遇到超时就无脑重试,会把一个很小的故障放大成系统雪崩。正确做法是:接口做超时分级,重试加指数退避,列表页用分页而不是一次性拉全量,图片走CDN并开启自动压缩。网关层还要对重复请求做识别,防止客户端重试导致重复下单等问题。虽然这些点不在后端主链路上,但对整体系统稳定性的影响一点不小。
顺便说一句题外话:技术选型本身也是性能优化的一部分。我见过有人拿Julia写高并发网关。客观讲,Julia在数值计算、内存管理上有优势,但常规的IO密集型高并发服务,Java和Go的生态、框架、运维工具明显更成熟,不建议为了一点计算性能去换语言,导致整个团队的维护成本暴涨。
3. 缓存层:加个Redis不算缓存设计
3.1 缓存穿透、击穿、雪崩:三种故障的区分与应对
缓存是高并发系统性能优化里性价比最高的手段,但前提是会用。Redis挂掉或者被打垮的场景我没少见,问题通常出在三个典型的缓存故障上,而且很多人分不清这三个词。
- 缓存穿透:请求的数据在缓存和数据库里都不存在,每次请求都直接打到数据库。攻击者可以故意构造一批不存在的ID来拖垮数据库。对策有两个:用布隆过滤器提前拦截不存在的key;或者对空结果也做短期缓存,挡掉大部分穿透流量。
- 缓存击穿:某一个热点key到了过期时间,突然大量请求同时打到数据库。对策是互斥锁,只让一个请求去查库回填,其余请求等待;或者用逻辑过期,在value里保存过期时间,由后台异步刷新。
- 缓存雪崩:大量key在同一时间过期,或者Redis整机宕机,请求洪峰全部打到数据库。对策是过期时间加随机因子,别让一批key同时失效;部署形态上做集群加高可用,尽量不要让Redis成为单点。
这三种情况文字看着简单,实际排查时容易混淆。我自己的判断方法就一句话:穿透是"根本没有数据",击穿是"一个热点key过期",雪崩是"一大批key同时失效"。定位准了,方案才能选对。
3.2 缓存与数据库的一致性:先更新库还是先删缓存
缓存最常见的坑还有一个:缓存和数据库数据不一致。Cache Aside模式的标准做法是:读的时候先查缓存,没有则读库再回填;写的时候先更新数据库,再删缓存。为什么是删缓存而不是更新缓存?因为更新缓存存在并发窗口:两个并发写请求可能以不同的顺序更新数据库和缓存,最终留下一份旧数据。而删缓存之后,下一次读请求会重新从库里加载,天然对账。
更彻底的做法是用Canal订阅MySQL binlog,在数据变更时按变更事件去删缓存或刷新缓存。这种方式把缓存更新和业务代码解耦,对最终一致要求不高的场景很好用。但要记住一个核心认知:缓存和数据库的强一致,在分布式环境下理论上做不到,你只能追求最终一致,并且把不一致的窗口控制在尽量小。任何号称"绝对一致"的缓存方案,不是谎言就是过度设计。
3.3 多级缓存:本地缓存加分布式缓存的组合
高并发场景下,Redis也不是万能的。每多一次网络往返,就多一次延迟。对于超高读取量的热点数据,可以在应用进程内再加一层本地缓存,比如Caffeine,构成二级缓存。
本地缓存加Redis的组合,命中率几乎翻倍:本地缓存命中可能只要零点几毫秒,Redis命中要1到2毫秒,数据库命中就是几十毫秒起步了。但本地缓存有一个代价:每个实例各存一份,数据更新时要考虑广播失效;而且本地缓存占堆内存,不能存太多。所以适合放本地缓存的通常是改动频率很低、读取量极大的数据,比如商品基础信息、配置类数据、一些字典表。
另一个很多人会忘的环节是缓存预热。大促开始前,如果新集群刚扩容上线,缓存是空的,首批请求会全部穿透到数据库。所以要做预热:提前把热点数据加载到Redis,本地缓存也可以随服务启动时主动加载一遍,别等用户来压。这个细节我在后文还会专门展开,因为它是大促翻车的重灾区。
4. 数据库层:最后一公里的优化
4.1 索引设计:最廉价也最容易被忽略的优化
数据库往往是高并发链路里最脆弱的一环,也是优化收益最确定的一环。索引设计这话题老生常谈,但我在实际中看到的问题依然很多。几个高频翻车点:
- 建了索引但查询没走:比如where条件里对索引列做了函数操作或隐式类型转换,索引直接失效。字段类型是varchar,查询参数传了数字,MySQL会做类型转换,索引就白建了。
- 联合索引顺序写反:联合索引遵循最左前缀原则。你要查(a,b,c),联合索引就得按这个顺序建,换个顺序可能全靠不上。
- 覆盖索引没用起来:查询需要的所有列都在索引里,可以减少回表,查询速度会快一个量级。这也是我劝大家不要上来就
select *的原因之一。全字段查询意味着回表不可避免,还容易触发临时表和filesort。
下面给一个典型的SQL优化示例。假设有订单表order_info,高频查询是按user_id和时间段查订单列表:
sql复制-- 慢:select * 加范围查询,回表多、可能filesort
SELECT * FROM order_info WHERE user_id = 1001 ORDER BY create_time DESC LIMIT 20;
-- 快:联合索引(user_id, create_time) + 只查覆盖索引内的字段
SELECT id, order_no, amount, create_time
FROM order_info
WHERE user_id = 1001
ORDER BY create_time DESC
LIMIT 20;
联合索引(user_id, create_time)既能过滤用户,也能利用索引排序,配合覆盖索引列,基本就是这条SQL的最优方案了。
4.2 读写分离与分库分表的时机和代价
很多团队一遇到数据库性能问题就想分库分表,这在我来看是本末倒置。分库分表是最后手段,不是第一选择。我的判断顺序是:先做索引优化和慢SQL治理,再上缓存,再做读写分离,最后才考虑分库分表。
如果业务是典型的读多写少,读写分离就够用了:主库处理写和实时性要求高的读,从库处理报表、搜索类读,一份流量摊到多台机器。但要注意主从延迟问题,从库读到旧数据的情况要提前设计好。至于分库分表,代价很多人没想清楚:
- 跨库JOIN彻底别想了,业务逻辑要拆分到应用层;
- 分布式事务变复杂,通常要引入类似Seata的方案;
- 全局唯一ID要自己生成,不能依赖数据库自增;
- 分片键一旦选错,后续改起来等于重构。
所以我的经验是:能用缓存扛的用缓存扛,能用读写分离扛的用读写分离。确实单表超过千万级、写入压力异常明显、各种手段都试过压不下去的时候,再认真做分库分表。
4.3 SQL慢查询分析与执行计划
慢SQL治理是真功夫。线上数据库一般都会开慢查询日志,但很多人开了日志之后从不看。我的建议是:慢查询日志一定要配上告警,单条SQL执行超过阈值就推给开发者,让问题在源头暴露。
分析慢SQL最直接的工具是EXPLAIN,关键看几个字段:type(是否全表扫描)、key(实际用的索引)、rows(预估扫描行数)、Extra(是否有filesort、using temporary等)。看到全表扫描加上扫描行数几十万,基本就能断定这条SQL要优化了。除了单条SQL,还要看大事务。长事务持有锁的时间长,会让高并发系统出现大量锁等待。常见问题:一个事务里做了网络调用、批量更新了太多数据、事务完成后还做了一大堆非数据库操作。我见过一次事故:事务里调了第三方支付接口,第三方响应超时10秒,整个事务挂着10秒,把那段时间所有写订单的请求全部堵死。这种问题SQL层面根本看不出来,必须靠业务代码审查才能发现。
5. 压测、监控与容量规划:没有数据的优化是玄学
5.1 压测之前先定目标:QPS、RT、错误率
性能优化不能漫无目的。我接手一个系统,第一件事是跟业务方确认三个数:目标QPS、可接受的响应时间、错误率上限。这三个数不谈清楚,优化就没法验收。比如目标是支撑5万QPS、TP99在200ms以内、错误率小于0.1%。压测结果TP99到了500ms,那就是不合格;如果目标只是2万QPS,现在测出来已经3万,那就没必要为了再堆一倍的性能去改造架构。优化必须对齐业务目标,而不是为了炫技。
压测的时候,除了平均响应时间,一定要看TP95、TP99、TP999。平均值是最骗人的指标:100个请求里有99个1ms、1个10s,平均值才101ms,看起来挺好,实际上那一个请求已经让用户炸了。我评估一个系统的性能水平,基本以TP99为准,辅以错误率。
5.2 全链路监控:从入口到SQL
性能优化做完,最怕的是下次上线又退化回去,所以必须建立监控防线。我的监控体系一般分四层:
- 系统层:CPU、内存、磁盘IO、网络带宽、GC情况;
- 应用层:QPS、RT、线程数、连接池使用率、错误率;
- 中间件层:Redis命中率和延迟、MQ堆积数量、数据库连接数;
- 业务层:核心指标,比如下单量、支付成功率、转化率。
工具有很多,但核心思路只有一个:所有指标都要能下钻。从业务指标异常,一路下钻到某个Redis key的延迟,这个能力比选哪个监控产品重要得多。全链路追踪在这时候的价值不只在排查问题,更是日常优化判断的数据底座。没有这套底座,你做任何优化决策都是凭感觉。
5.3 限流、熔断与降级:高并发系统的兜底
很多人觉得高并发性能优化就是"把系统优化到能扛住所有流量",这是个误区。真实线上系统永远会有超出预期的流量。所以性能优化做到最后,必须加上兜底机制,保证系统在极限流量下不彻底崩掉。
限流:控制进入系统的流量,常用令牌桶和滑动窗口算法。比如网关层对某个接口设置单机1000 QPS,超出就返回"系统繁忙"或者排队等待。令牌桶适合应对突发流量,滑动窗口对统计更精确。熔断:当下游服务持续超时或异常率超过阈值,直接断开对下游的调用,快速失败,给下游喘息机会。可以把它理解成家用电闸:电流过载就跳闸,避免烧毁整屋电器。降级:非核心功能在压力大时主动关闭。比如大促时商品详情页的评论模块直接降级成静态数据,把资源留给下单主链路。这三样东西不是性能优化的最后一步,而是每一轮优化都要同步考虑的基础设施。没有兜底,再好的性能指标也经不起一次突发流量冲击。
6. 几个让我印象深刻的坑
6.1 缓存预热不足引发的"冷启动雪崩"
一次大促前扩容,新拉起来一批应用实例,本地缓存和Redis里的热点数据全是空的。开始放量的前十分钟,所有请求全部穿透到数据库,数据库连接池一度被打满。好在当时有熔断兜底,系统没有彻底崩掉,但延迟已经爆表了。后来我把缓存预热做成了发布流程的一环:新实例启动时先加载热点数据到本地缓存,同时把核心热key提前刷进Redis,并且对Redis做全量加增量预热。这个坑看起来小,但凡是新集群上线、缓存清空、或者大促前忘记预热,几乎都会重演。
6.2 连接池超时时间设置不当引发的雪崩
之前调过另一个服务的连接池,maxActive给得很大,maxWait却设成了30秒。表面上看起来很合理:池子够大、排队时间长一点没事。结果下游一个接口响应变慢,请求全都在池子里排队,30秒超时后又重试,新请求又塞进来,最终所有线程全部卡死,整个服务不可用。那之后我的原则是:宁可快速失败,也不要无限等待。maxWait一般设置在1到3秒,超过直接报错,让上游感知到问题后去做降级,而不是把故障憋在连接池里发展成雪崩。
6.3 GC调优的误区:先查代码,再调JVM
还有一个高频误区是:系统一慢就有人建议调JVM参数、换GC器。我见过一次从CMS换成G1之后,吞吐量反而下降。后来排查发现根本不是GC的问题,而是业务代码里有大量重复的字符串拼接和对象创建,每个请求产生几十个临时对象,GC频率很高。换成G1后晋升老年代的条件改变,反而导致更频繁的Full GC。正确顺序是:先用profiler看内存分配和对象分布,确认GC确实是主要瓶颈,再去调整JVM参数。大多数情况下,优化业务代码减少不必要的对象创建,比折腾JVM参数有效得多。
这些年做高并发系统性能优化,我最大的体会是:它从来不是某一个层面的孤军奋战,而是从定位瓶颈、分层优化到兜底监控的完整闭环。每次动刀之前,先问自己一句:证据在哪儿,目标是什么,验证手段是什么。做到这三点,大概率不会跑偏。最后再分享一个小技巧:任何优化上线之前,把压测报告、监控曲线、变更前后的对比数据存档留痕。半年后回看,你会感谢当初那个愿意多花十分钟做记录的自己。
