目标:大促前把商品详情接口压到 8000 QPS
9 月 20 号拿到大促的容量需求:商品详情接口要扛 8000 QPS,P99 控制在 300 ms 以内。第一次压测跑下来只有 2000 QPS,P99 1.2 秒,差了 4 倍。
前后两周五轮优化,最后压到 8200 QPS、P99 178 ms。每一轮的数据和具体改动记一下。
压测环境
应用:4 台 8 核 16 G(K8s Pod,limit 8C/16G),JDK 11.0.12,G1
依赖:MySQL 8.0.25(16C/64G,主从)、Redis 6.2.5(4 分片集群)
压测机:3 台 JMeter 5.4.1 分布式,每台 500 线程,ramp-up 60 秒
接口:GET /api/sku/{skuId}/detail (随机 10 万个 SKU ID)
压测前先确认了压测机不是瓶颈:单独压一个静态接口能到 4 万 QPS,CPU 也没跑满。
第一轮基线:2000 QPS,卡在连接池
基线数据:
QPS: 2,041
P99: 1,240 ms
CPU: 65%
堆占用: 2.1 GB / 4 GB
Tomcat 繁忙线程: 200 / 200
CPU 才 65%,说明没有在算,是在等。用 Arthas 看线程状态分布:
$ thread --state BLOCKED -n 10
Threads Total: 412, NEW: 0, RUNNABLE: 68, BLOCKED: 187, WAITING: 121, TIMED_WAITING: 36
"http-nio-8080-exec-143" Id=412 BLOCKED on java.lang.Object@6f2b958e owned by "http-nio-8080-exec-88" Id=357
at com.alibaba.druid.pool.DruidDataSource.getConnectionDirect(DruidDataSource.java:1265)
at com.alibaba.druid.filter.FilterChainImpl.dataSource_connect(FilterChainImpl.java:5009)
...
187 个线程阻塞在等数据库连接。连接池配置是默认值:
spring:
datasource:
druid:
maxActive: 20 # 默认 20,4 台机器一共才 80 个连接
maxWait: 60000
20 个连接扛 200 个并发请求,剩下 180 个全在等。改:
spring:
datasource:
druid:
maxActive: 60
minIdle: 20
maxWait: 3000 # 等 3 秒拿不到就快速失败,别一直堵
testWhileIdle: true
validationQuery: SELECT 1
timeBetweenEvictionRunsMillis: 60000
连接数不是越大越好。我们这台是 8 核,一条 SQL 平均 18 ms,理论上 连接数 = 核数 * 2 + 磁盘数 左右比较合适,实际试了 40、60、100 三档,60 最好(100 的时候 MySQL 侧 CPU 反而涨了,上下文切换开销上来)。
QPS: 2,041 → 3,412 (+67%)
P99: 1,240 ms → 612 ms
CPU: 65% → 78%
第二轮:一条 SQL 扫了 12 万行
再压,瓶颈转到数据库。看慢查询:
$ mysqldumpslow -s t -t 5 /var/log/mysql/slow.log
Count: 84120 Time=48.21s (4056s) Lock=0.00s (0s) Rows=18.0
SELECT * FROM t_sku WHERE category_id = N AND shelf_status = N
ORDER BY sales_volume DESC LIMIT N
这条 SQL 平均 48 ms,占了接口耗时的绝大部分。EXPLAIN 看:
mysql> EXPLAIN SELECT * FROM t_sku WHERE category_id = 1001
AND shelf_status = 1 ORDER BY sales_volume DESC LIMIT 20\G
***************************
id: 1
select_type: SIMPLE
type: ref
key: idx_category
key_len: 8
rows: 124108
Extra: Using index condition; Using where; Using filesort
rows: 124108,扫了 12 万行排序再取 20 条。原索引只有 idx_category(category_id)。
加联合索引,把排序字段和过滤字段都包进去:
ALTER TABLE t_sku ADD INDEX idx_cat_status_sales
(category_id, shelf_status, sales_volume DESC);
mysql> EXPLAIN SELECT ... \G
key: idx_cat_status_sales
rows: 20
Extra: Using index condition
rows 从 12 万降到 20,Using filesort 消失。单条耗时从 48 ms 降到 1.4 ms。
顺带修了另一条:SELECT * 改成只查需要的 12 个字段,其中 description 是 TEXT,不查它之后网络传输从平均 42 KB 降到 3.1 KB。
QPS: 3,412 → 4,608 (+35%)
P99: 612 ms → 341 ms
数据库 CPU: 88% → 41%
第三轮:8 万个 field 的 Hash
数据库不是瓶颈了,Redis 顶上来了。看监控,Redis 侧 RT 从平时 0.8 ms 涨到 8.3 ms,而且有个分片 CPU 到 95%。
查大 key:
$ redis-cli --bigkeys --i 0.1
Biggest hash found so far 'sku:promo:all' with 81432 fields
Biggest string found so far 'category:tree' with 3145728 bytes
sku:promo:all 是一个 hash,把全量商品的促销信息塞在一个 key 里,8 万多个 field。每次查单个 SKU 的促销,代码里直接 HGETALL 拿全量再内存过滤(是的,历史代码):
// 优化前:每次请求都拉 3 MB 数据
Map<Object, Object> all = redisTemplate.opsForHash().entries("sku:promo:all");
PromoInfo promo = (PromoInfo) all.get(skuId.toString());
单次 HGETALL 返回 3.1 MB,Redis 侧要序列化、网络要传、客户端要反序列化,8.3 ms 就是这么来的。而且这个 key 只在一个分片上,导致分片热点。
两处改动:
// 1. 改成按 SKU 粒度的 key
PromoInfo promo = redisTemplate.opsForValue().get("sku:promo:" + skuId);
// 2. 热点数据加 Caffeine 本地缓存(促销信息变更频率低)
@Bean
public Cache<Long, PromoInfo> promoLocalCache() {
return Caffeine.newBuilder()
.maximumSize(20_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats()
.build();
}
public PromoInfo getPromo(Long skuId) {
return promoLocalCache.get(skuId, id -> {
PromoInfo p = redisTemplate.opsForValue().get("sku:promo:" + id);
return p == null ? PromoInfo.empty() : p;
});
}
本地缓存命中率 91.3%(10 万个 SKU 里头部的 2 万个占了绝大部分流量)。
QPS: 4,608 → 6,124 (+33%)
P99: 341 ms → 232 ms
缓存层平均 RT: 8.3 ms → 0.7 ms
Redis 最高分片 CPU: 95% → 38%
第四轮:GC 太频繁
再看 JVM。jstat 抓一轮:
$ jstat -gcutil 1 1000 10
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 98.21 87.44 62.18 94.12 91.03 18423 412.338 3 2.104 414.442
0.00 0.00 11.02 62.31 94.12 91.03 18425 412.752 3 2.104 414.856
10 秒里 Young GC 了 2 次,累计 18425 次、总耗时 412 秒——GC 占用了约 4.6% 的 CPU 时间。看 GC 日志里的单次耗时,平均 22 ms,最大 68 ms,已经是 P99 的重要组成部分。
分配速率:
$ jstat -gc 1 1s | awk '{print $13}' # 每秒 Young 区增长
834 MB/s
834 MB/s 的分配速率,4 GB 堆里 Eden 只有约 1.6 GB,两秒就填满一次。两个方向处理:
一是调 JVM 参数。原来用默认比例,改成显式设置,并把堆调大(机器有 16 G):
# 优化前
-Xms2g -Xmx2g -XX:+UseG1GC
# 优化后
-Xms6g -Xmx6g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:G1HeapRegionSize=8m
-XX:InitiatingHeapOccupancyPercent=45
-XX:+ParallelRefProcEnabled
-Xlog:gc*:file=/data/logs/gc.log:time,uptime:filecount=10,filesize=100m
二是减少分配。用 async-profiler 抓了个分配火焰图:
$ ./profiler.sh -d 60 -e alloc -f /tmp/alloc.svg 1
热点前三是:JSONObject.toJSONString(占 21%)、Stream.collect 的装箱(14%)、字符串拼接(9%)。改了三处:
// 1. StringBuilder 预设容量,避免扩容复制
StringBuilder sb = new StringBuilder(256);
// 2. 数值流避免装箱:IntStream 代替 Stream<Integer>
int total = items.stream().mapToInt(Item::getCount).sum(); // 不装箱
// 3. 那个 VO 转 JSON 的地方,复用 ObjectMapper 并去掉无用的字段拷贝
private static final ObjectMapper MAPPER = new ObjectMapper(); // 不要每次 new
QPS: 6,124 → 7,208 (+18%)
P99: 232 ms → 205 ms
Young GC 频率: 每 2.4 秒一次 → 每 11 秒一次
分配速率: 834 MB/s → 512 MB/s
GC 总耗时占比: 4.6% → 1.1%
第五轮:日志和序列化
剩下的从火焰图里找。CPU 火焰图显示 logback 的 AsyncAppender 之外还有 11% 花在日志格式化上,其中大量是 log.debug 的字符串拼接(虽然没输出,但参数是先拼接再判断的)。
// 优化前:不管 debug 开没开,字符串都会拼
log.debug("query sku detail, skuId=" + skuId + ", result=" + vo);
// 优化后:用占位符,日志级别不够时不会走到格式化
log.debug("query sku detail, skuId={}, result={}", skuId, vo);
日志配置也调了:
<!-- logback-spring.xml -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>4096</queueSize>
<discardingThreshold>0</discardingThreshold> <!-- 不丢 ERROR -->
<includeCallerData>false</includeCallerData> <!-- 关掉行号,这个很慢 -->
<appender-ref ref="FILE"/>
</appender>
另外把接口的响应序列化从 Jackson 默认配置换成了显式配置的复用实例(Spring Boot 默认的 ObjectMapper 每次序列化会创建 SerializerProvider,配置 JsonFactory 复用后这部分开销降了约 30%)。
QPS: 7,208 → 8,204 (+14%)
P99: 205 ms → 178 ms
差点漏掉的一环:容器里 JVM 看到的核数不对
第四轮调完 JVM 参数之后,QPS 卡在 6900 上不去了。CPU 利用率只有 71%,但吞吐就是不涨,而且延迟毛刺很多。翻监控的时候看到这个指标异常:
container_cpu_cfs_throttled_seconds_total (K8s cAdvisor 暴露)
5 分钟内累计 18.4 秒
CPU throttling。Pod 的 limit 是 8 核,但 JVM 以为自己有 96 核(宿主机是 96 核的物理机)。JDK 11 默认开启 UseContainerSupport,会读 cgroup 的 cpu.cfs_quota_us 来算可用 CPU 数,但那只影响 Runtime.availableProcessors() 的部分场景,GC 线程数和 ForkJoinPool 并行度依然按宿主机核数来。
$ jcmd 1 VM.flags | grep -i -E "ParallelGCThreads|ConcGCThreads"
-XX:ConcGCThreads=12
-XX:ParallelGCThreads=64 # 64 个 GC 线程在抢 8 核的配额
$ java -XshowSettings:system -version 2>&1 | grep "Processors"
Processors: 64
64 个 GC 线程在 8 核的配额里抢时间片,上下文切换和调度开销直接把性能吃掉了,还频繁触发 CFS 限流。
显式声明:
-XX:ActiveProcessorCount=8
-XX:ParallelGCThreads=8
-XX:ConcGCThreads=2
另外把 Pod 的 CPU limit 从 8 核提到 12 核(我们机器是 8 核 16 G 的规格,实际是 K8s 的 limit 设成了 8,节点本身有富余)。改完之后:
QPS: 6,908 → 7,208
P99: 228 ms → 205 ms
CPU throttling: 5 分钟 18.4 秒 → 0.3 秒
这个坑在容器化之后特别普遍,值得单独记一笔:容器里跑 JVM,务必显式设 -XX:ActiveProcessorCount 并核对 GC 线程数。JDK 11 的容器支持比 JDK 8u191 之前好很多,但不是全自动的。
最终结果
| 轮次 | 改动 | QPS | 增量 | P99 |
|---|---|---|---|---|
| 基线 | - | 2,041 | - | 1,240 ms |
| 1 | Druid maxActive 20 → 60 | 3,412 | +67% | 612 ms |
| 2 | 联合索引 + 去掉 SELECT * | 4,608 | +35% | 341 ms |
| 3 | 拆大 key + Caffeine 本地缓存 | 6,124 | +33% | 232 ms |
| 4 | 堆 2G→6G、G1 参数、减少分配 | 7,208 | +18% | 205 ms |
| 5 | 日志占位符 + 异步 Appender | 8,204 | +14% | 178 ms |
从 2041 到 8204,4.02 倍。
怎么判断压到头了
压到 8200 之后我们就停了,因为目标已经达成。判断"还能不能继续压"我一般用三个信号:
信号一:加并发 QPS 不涨,但延迟线性涨。 这说明系统已经饱和,再加压力只会排队。
并发 800 → QPS 8,204, P99 178 ms
并发 1200 → QPS 8,231, P99 402 ms ← QPS 几乎不动,延迟翻倍
并发 1600 → QPS 8,190, P99 886 ms
这条曲线画出来就是经典的"膝点"(knee)。继续压没有意义了。
信号二:某个资源到了 80% 以上。 我们的最终状态:
| 资源 | 8200 QPS 时 | 判断 |
|---|---|---|
| 应用 CPU | 78% | 接近饱和,还有一点余量 |
| MySQL CPU | 34% | 很健康 |
| Redis CPU | 29% | 很健康 |
| 网络出带宽 | 312 Mbps(千兆网卡 31%) | 没到瓶颈 |
| GC 耗时占比 | 1.1% | 很健康 |
瓶颈在应用 CPU,这符合预期——一个主要做数据聚合和 JSON 序列化的服务,就该是 CPU 密集。
信号三:错误率开始上升。 并发 1600 的时候错误率到了 0.7%,全是超时。这是系统开始崩的前兆,正常的容量规划要留 30% 到 50% 的余量。
最后我们给这个服务定的生产容量是峰值 8000 QPS,部署 6 个实例(压测用的是 4 个实例跑 8200,折算单机 2050,6 台给 12300 的理论上限,冗余 54%)。大促当天实际峰值 6,140 QPS,P99 143 ms,一切正常。
两个评估过但没做的优化
顺带记一下,免得以后有人又提起来。
HTTP 响应压缩(gzip)。 详情接口的响应体平均 18 KB,开 gzip 能压到 3 KB 左右,看起来很诱人。但我们评估后没开,两个原因:一是客户端全部在内网(App 走的是自有网关,带宽不是瓶颈),压缩省的是出口带宽,收益不在我们这;二是 gzip 会吃 CPU,而我们当时瓶颈正好就是 CPU。实测开了之后 QPS 从 8204 掉到 7100,降了 13%。结论:CPU 密集的服务开压缩要谨慎,先量一下压缩本身的开销。
# 实测:开启压缩前后
server.compression.enabled=false → QPS 8,204, 出口带宽 312 Mbps
server.compression.enabled=true → QPS 7,102, 出口带宽 58 Mbps
把计算下推到 Nginx / 短连接改长连接。 前者我们没有边缘计算资源,后者压测链路本来就是长连接(JMeter 默认开 Keep-Alive),没有收益。
这两条的共同点是:优化项本身没有对错,要看当前瓶颈在哪。瓶颈是 CPU 的时候,任何省带宽换 CPU 的操作都是亏的。这也是为什么每轮优化都要先量数据。
先到这
《一次大促压测:从 2000 QPS 到 8000 QPS 的优化之路》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。