开头:那张对不齐的表
去年 11 月我接手一个订单列表查询接口。压测环境 40 核 64G,单机 QPS 压到 800 就上不去了,P99 从空载的 120ms 一路涨到 2.3s。前一拨人已经调过一轮 JVM,堆从 8G 加到 16G,G1 的 -XX:MaxGCPauseMillis 从默认 200 调到 100,最后收益大概 3%——GC 日志倒是干净了,QPS 纹丝不动。
我没接着调。我把压测时的火焰图和 APM 上的耗时打点拉到一起对齐,发现一件很别扭的事:APM 显示业务逻辑耗时 800ms,但火焰图里 CPU 采样时间只有 300ms 出头。差的这 500ms 是 wall clock,线程没在算,在等。等 IO 还是等锁,这两个方向的解法完全是两码事,但至少在那一刻我确定了——问题不在 GC,堆再大也没用。
说个可能得罪人的观点。我认为性能调优的收益是极度不均匀的,而且大部分人做的顺序是反的。我给自己的顺序是:观测偏差 → 数据量 → 精度 → 参数。前三层动一下就是几倍的差距,第四层吭哧吭哧调半天,通常是 3% 到 8%。我见过一个团队花了两周调 JVM 和内核参数,收益 3%,隔壁一个 N+1 查询顺手改掉,接口直接快了四倍。你说这俩谁更像在干活。
第一层:先怀疑你的监控工具
「观测偏差」这个词听着玄,其实特简单:你用来量性能的工具,本身在消耗性能。
我们那个接口埋点是 Spring AOP 切的,每次进出打一行 JSON 日志,15 个字段。单次 fastjson 序列化大概 0.8ms,压测下一天写 2000 万行。看着不多对吧?问题是 logback 默认的 FileAppender 是同步的,每写一行就是一次 write syscall,压测时 CPU 有 12% 左右耗在这上头,而且写日志的线程会阻塞业务线程。
改成 AsyncAppender 之后收益立竿见影:
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>8192</queueSize>
<discardingThreshold>0</discardingThreshold>
<neverBlock>true</neverBlock>
<appender-ref ref="FILE"/>
</appender>
这里有两个坑得说清楚。queueSize 默认只有 256,你压测 QPS 一上去队列秒满;discardingThreshold 默认是队列到 80% 时开始丢 TRACE/DEBUG/INFO 级别的日志,丢日志在排查问题时是致命的,所以我直接设 0 表示永不丢弃。neverBlock 设 true 之后队列满了业务线程不阻塞而是直接丢,这个取舍看场景,我线上是 false(宁可慢也不丢),压测环境 true。
这一层改完 QPS 从 800 到 1150 左右,收益 40% 上下,成本一个配置文件。顺便说个反常识的:监控不只是慢,它有时候会撒谎。那个 APM 的 800ms 里大概有 60ms 是它自己的字节码增强开销,而且它把等待远程调用的时间也算进了业务方法,导致你以为瓶颈在代码里。
第二层:数据量,21 次 SQL 收成 1 次
日志收拾干净之后,火焰图上明晃晃地出现了 MySQL 的调用栈。查下来是典型的 N+1:订单列表返回 20 条,每条订单去查一次用户昵称、再查一次商品名,20 × 2 + 1 = 41 次查询。别笑,这个接口的历史比我在这家公司的时间都长。
改法没什么技术含量,把循环里的单条查询收成一次 IN 查询。但有两个细节值得抠。
一是 MySQL 的 rewriteBatchedStatements=true 只对 INSERT/UPDATE/DELETE 的批量生效,对 SELECT 无效。我见过有人在 JDBC url 上加了它然后期待 IN 查询变快,等半天没动静,最后发现方向就错了。
二是 IN 的列表别无限长。MySQL 8.0 对 IN 列表没有硬性上限,但 eq_range_index_dive_limit 默认是 200,超过这个数优化器就不做 index dive 了,退化成估算,执行计划可能突然变差。我的习惯是批量查的时候切成 100 一批。
这一层改完 QPS 从 1150 到 1900 左右,收益 65%,成本大概半天工时加一轮回归。
第三层:降精度,收益最大但也最容易被跳过
如果说前面两层是技术活,这一层的本质是产品决策,所以纯技术视角的人会自动跳过它,这也是我觉得最可惜的地方。
接口里有个字段叫「预计送达时间」,是实时调第三方物流接口算的,超时设的 3 秒,失败降级返回空。20 条订单里有 17 条调不通或者超时——第三方那个接口本身 P99 就 800ms,我们并发一上去它直接限流。
解决的思路不是「怎么把第三方调快」,而是「这个字段真的需要实时吗」。我跟产品聊了十分钟,结论是这个时间本来就是估算值,用户下单后 5 分钟内刷新一次足够了。于是改成异步预热写 Redis,TTL 300 秒,接口只读缓存。
精度从「实时」降到「5 分钟内」,QPS 从 1900 直接干到 3400。这一层的收益比前面两层加起来还大,而它靠的不是任何技术手段。
我后来把这事抽象成一句话:能靠降精度解决的性能问题,不要用技术手段去解。同理的还有:分页从每页 50 条降到 20 条、搜索从精确匹配降到分词粗排、报表从实时算改成 T+1 跑批。这些「产品降级」的收益往往是 2 到 10 倍,而且不留技术债。代价也有,你得说服产品,而且降级之后要留个后门——比如加个 ?precise=1 参数给运营用,流量小的时候走实时。
第四层:参数,最后再说
到这一步才是常规意义上的调优。我把实际改过的列一下,你可以当参考,但别直接抄,场景差别很大。
| 项目 | 调优前 | 调优后 | 说明 |
|---|---|---|---|
| JVM 堆 | -Xmx8g | -Xmx24g | 机器 64G,堆占 37%,别越过 32G 那条线,否则指针压缩关闭 |
| G1 暂停目标 | 默认 200ms | 100ms | 不是硬保证,设太小反而增加 GC 频率 |
| Tomcat maxThreads | 200 | 500 | 阻塞型业务,线程数 ≈ 核数 × (1 + 等待时间/计算时间) |
| HikariCP maximumPoolSize | 10 | 32 | 默认 10 偏小,但也不是越大越好,公式同上 |
| innodb_buffer_pool_size | 默认 128M | 32G | 物理内存 50%~75% 是老经验,仍要按实际数据量算 |
| ulimit -n | 1024 | 65535 | 连接一多就 too many open files,太经典了 |
这一层全部加起来,QPS 从 3400 到 3650 左右,收益 7%。跟前面三层比一下就知道我在说什么了。
关于 G1 那个 MaxGCPauseMillis 多嘴一句:它是个软目标,G1 会通过调整年轻代大小去逼近,但做不到保证。我见过设成 20ms 结果 GC 频率翻三倍的,young GC 从 2 秒一次变成 0.6 秒一次,单次暂停是短了,总吞吐掉一大截。
几个我踩过的坑,写在这免得你重复
第一,别在压测环境用和生产不一样的依赖版本。我们那次压测的 MySQL 驱动是 8.0.22,生产是 5.1.49,rewriteBatchedStatements 在老版本上行为不一样,本地压得飞快上线就拉胯,这种问题能查一整夜。
第二,火焰图要配合 wall clock 采样看。CPU 采样的火焰图看不到等锁和等 IO 的时间,如果一个问题表现为「CPU 不高但就是慢」,你得用 async-profiler 的 -e wall 模式,不然永远找不到。
第三,QPS 和 RT 是两条曲线。QPS 到 3400 之后我继续加压,QPS 不涨了但 RT 从 80ms 涨到 900ms,这就是典型的利特尔法则饱和点,继续堆流量只会让排队变长,不会让吞吐变高。很多所谓「性能优化」是在这个点上瞎调,其实该做的是限流。
第四,每改完一层都要压一次、记一次数。我上面那些数字不是回忆出来的,是每次改完跑到稳定状态记下来的。性能调优这件事的记忆极其不可靠,你觉得「改完快了不少」和实际测出来的经常差很远。
最后回到开头那个观点。如果你手上有个慢接口,我建议你先别急着打开 GC 日志,按「观测 → 数据量 → 精度 → 参数」这个顺序过一遍,八成在前两层就能拿到你想要的数字。真到调参数那一步,说明你已经没别的招了,那也认了。