侧边栏壁纸
  • 累计撰写 38 篇文章
  • 累计创建 8 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

架构突围:单表 300 万数据压测实录,从全线崩溃到 Redis 异步解耦

Administrator
2026-05-06 / 0 评论 / 0 点赞 / 31 阅读 / 0 字
温馨提示:
本文最后更新于2026-05-06,若内容或图片失效,请留言反馈。 部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

前言:不要纸上谈兵,让真实的压测数据说话(本文由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内存了


附:核心压测数据概览 (供展示参考)以及最后的压测报告简易版

演进阶段

平均耗时 (Avg)

P99 耗时

失败率

核心变化

1. 原始无索引

30,189 ms

59,404 ms

50.00%

全表扫描,连接池耗尽,大面积 504

2. 瘦索引+深分页优化

1,390 ms

10,998 ms

0.00%

覆盖索引避免回表,达到机械磁盘物理极限

3. 同步缓存 (翻车)

4,928 ms

39,893 ms

0.00%

缓存未命中穿透 + 序列化阻塞主线程

4. 异步缓存+热点预热

613 ms

2,064 ms

0.00%

异步线程池剥离写缓存逻辑,详情接口降至 35ms

0
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin

评论区