前言:不要纸上谈兵,让真实的压测数据说话(本文由ai总结并编写,本人负责review,内容为实操演变,测试数据,并非生产,主要是提供一个思路和方案,请勿直接照抄)
在讨论高并发系统时,我们常常会听到各种高大上的架构名词:微服务、分库分表、哨兵集群。但在真实的业务演进中,架构从来不是被提前“顶层设计”出来的,而是被真实的线上瓶颈和宕机事故硬生生“逼”出来的。
为了探究系统在物理极限下的真实表现,我在本地搭建了一个基础的 Spring Boot + JPA 单体项目,并在极其苛刻的硬件条件(HDD 机械硬盘)下,向 MySQL 单表中灌入了 300 万条真实的商品测试数据。
本篇文章将使用 K6 压测工具,通过一份极其真实的压测报告,带你重走一遍系统调优的“排雷”之路:从无索引时的 50% 失败率宕机,到单机 MySQL 的物理极限,再到初尝 Redis 却导致性能骤降 4 倍的诡异翻车,最终通过异步解耦完成百万级数据的性能涅槃。
特别说明:硬件环境的“刻意受限” 本文所有的压测数据,均在一台“古董级”的机器(奔腾 T3500,仅分配了 2GB 内存 和一块老旧的 160GB 机械硬盘)上跑出。
为什么不用高配服务器?坦白说,在现代化的 NVMe SSD 和几百 GB 内存的加持下,完全可以轻松抗住千万级的数据,硬件的暴力性能往往会掩盖掉许多糟糕的 SQL 和架构设计缺陷。我刻意在一台资源极度受限的机器上向 MySQL 单表灌入 300 万数据,正是为了放大底层的磁盘 I/O 瓶颈和内存限制,让真实的物理短板暴露无遗。
第一章:深渊开局——HDD 遇上全表扫描的物理绝望
在项目初期,由于数据量小,我们并没有为表结构设计复杂的二级索引,分页逻辑也采用了最基础的 findAll。当数据量暴增到 300 万时,我们进行了第一轮 10 并发的基准压测。
【压测现场 1:无索引裸奔】
压测仅仅持续了一分钟,系统直接被拖垮。根据 K6 报告,系统的 HTTP 失败率飙升至 50.00%,出现了大量 504 Gateway Time-out。
平均响应时间 (Avg): 30,189 ms
P99 响应时间: 59,404 ms (将近 1 分钟)
最大响应时间 (Max): 60,000 ms
性能复盘:
在 HDD 机械硬盘上,全表扫描和为了分页而执行的 COUNT(*),意味着磁头需要在磁盘上进行海量的物理寻道操作。仅仅 10 个并发请求,就彻底耗尽了磁盘的 I/O 吞吐能力,导致 HikariCP 连接池迅速枯竭,后续一半的请求直接超时熔断。这揭示了一个残酷的物理现实:没有索引的百万级数据表,等同于一颗随时引爆的定时炸弹。
第二章:触碰天花板——单机数据库调优的物理极限
面对接近瘫痪的系统,我们进行了第一轮自救:建立核心查询字段的“瘦索引”,并放弃了 JPA 默认的低效分页,改用了“先查 ID 再回表”的深分页优化逻辑。
核心代码展示:深分页的终极妥协
Java
// ProductRepository.java (核心优化片段)
/**
* 深分页极限优化:
* 1. 内部子查询只查 id,完美利用覆盖索引(Using index),避免回表和海量 I/O。
* 2. 外部主查询拿到这 10 个 id 后,再去聚簇索引拿完整数据。
*/
@Query(value = "SELECT * FROM product p INNER JOIN " +
"(SELECT id FROM product ORDER BY id LIMIT :offset, :size) AS temp " +
"ON p.id = temp.id", nativeQuery = true)
Page<Product> findPageV2WithoutName(@Param("offset") int offset,
@Param("size") int size,
Pageable pageable);
【压测现场 2:瘦索引 + 延迟关联优化后】
再次启动压测,系统活了过来。HTTP 失败率降至 0.00%。
平均响应时间 (Avg): 1,390 ms
P99 响应时间: 10,998 ms
性能复盘:
通过覆盖索引,我们让 MySQL 避开了主键树的庞大体积,将一次查询的耗时从 60 秒硬生生拉回了 1.3 秒。然而,我们依然看到 P99 响应时间高达 11 秒左右。在机械硬盘上,任何需要回表读取数据的随机 I/O 依然是致命的。1.3 秒的平均耗时,已经是这台单机 MySQL 的物理天花板。要想实现毫秒级响应,必须跨越存储介质的限制。
第三章:意料之外的翻车——为什么引入 Redis 反而更慢了?
既然磁盘是瓶颈,我们顺理成章地引入了 Redis 建立纯内存防线。我们采用了经典的旁路缓存模式(Cache Aside):查不到 Redis 就查库,查到了再回写 Redis。
原本以为系统会瞬间起飞,但现实却狠狠地给了我一记耳光。
【压测现场 3:同步缓存陷阱】
推上缓存代码后,监控面板的数据让人大跌眼镜:
平均响应时间 (Avg): 4,928 ms
P99 响应时间: 39,893 ms (飙升近 4 倍!)
性能复盘:
为什么加了 Redis 反而慢得离谱?排查链路后,发现了两大元凶:
1. 极低的缓存命中率(压测脚本的锅):
为什么命中率会是 0%?当我回头检查 K6 的测试脚本时,真相大白。为了图省事,我使用了一个完全随机的 ID 生成策略:
JavaScript
// K6 压测脚本 (错误示范:导致 0% 命中率的元凶)
export default function () {
// 在 1 到 300 万之间产生纯随机数,导致每次请求的都是冷数据
let id = Math.floor(Math.random() * 3000000) + 1;
let res = http.get(`http://localhost:8081/api/products/${id}`);
}
真实的互联网流量绝对不是纯随机的,而是符合“二八定律”(80% 的人看 20% 的热点商品)。这种纯随机压测,导致流量全部穿透到了 MySQL,Redis 不仅没挡住压力,反而增加了多余的网络通信开销。
2. 同步序列化拖垮了主线程:
主线程在从 MySQL 拿到结果后,需要将庞大的实体对象进行 JSON 序列化,并同步写入 Redis。在磁盘 I/O 已经让线程极度疲惫的前提下,同步的 CPU 计算(序列化)和网络写操作,彻底堵死了 Tomcat 的业务线程,导致后续请求产生严重的排队踩踏。
第四章:架构师的微操——异步解耦与极限涅槃
定位到问题后,破局的思路变得非常清晰。我们需要对系统进行一次关键的“微操”:异步解耦。
我们将“对象序列化”和“写入 Redis”的动作从主线程中剥离。数据库一出结果,主线程立刻返回给前端,缓存的构建由自定义线程池在后台默默完成。
核心代码展示:异步缓存防线的设计
首先,配置专属的缓存处理线程池,防止高并发写 Redis 撑爆内存:
Java
// AsyncConfig.java
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "cacheExecutor")
public Executor cacheExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("CacheAsync-");
executor.initialize();
return executor;
}
}
接着,将写缓存逻辑剥离到独立服务中,让主请求能够“阅后即焚”,瞬间返回:
Java
// CacheTaskService.java
@Service
public class CacheTaskService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 指定线程池,让序列化和网络请求在后台静默完成
@Async("cacheExecutor")
public void writeCacheAsync(String key, Object value, long timeout, TimeUnit unit) {
redisTemplate.opsForValue().set(key, value, timeout, unit);
}
}
【压测现场 4:异步缓存终极版】
在修改了压测脚本(模拟热点数据访问)并完成异步解耦后,我们打出了终极的一轮压测:
平均响应时间 (Avg): 613.51 ms
P99 响应时间: 2,064.82 ms
详情接口单次耗时: 降至 35 ms 级别。
技术总结:
通过异步写缓存,我们将整体 Avg 响应时间从近 5000 毫秒的泥潭拉回到了 613 毫秒。这证明了在高并发系统中一个铁律:不要让任何非核心的同步操作阻塞你的主业务链路。
终章:架构的妥协与新世界的伏笔
至此,我们的 Spring Boot + MySQL + Redis 架构已经榨干了单机的性能潜力,把一个原本一压就宕机的系统拉回了可用状态。
但是,细心的读者可能会发现,即便有了 Redis,由于复杂的条件检索和分页排序无法被有效缓存,列表深分页的耗时依然需要 1-3 秒。
关系型数据库 + 键值对缓存,天生就不是为了解决“海量数据多维检索”而生的。 当单机的优化走到尽头,真正的架构师知道什么时候该引入新的兵种。
面对千万级数据最后的两块硬骨头——“深分页”与“模糊搜索”,我们需要进行一次降维打击。如果有机会,在下一篇博客中,我将彻底抛弃在 MySQL 中的内耗,正式引入搜索引擎 Elasticsearch (ES)以及其他的组件。我将带大家实战演示,如何利用“倒排索引”的魔法,将最后的延迟瞬间抹平到 50 毫秒以内。注:如果引入es等组件,就无法使用目前使用老掉牙配置,奔腾t3500和2g内存了
附:核心压测数据概览 (供展示参考)以及最后的压测报告简易版

评论区