从 8.38秒 到 300毫秒:记一次供给列表接口的极致性能调优实战

从 8.38秒 到 300毫秒:记一次供给列表接口的极致性能调优实战

前言
在日常的搬砖生涯中,我们经常会碰到一些“平时用起来没感觉,一旦数据量上去或并发一高就直接卡死”的慢接口。
最近我手头就遇到了一个硬骨头 —— /system/supply/list 供给列表接口。在测试环境中,仅仅分页查询 10 条数据,等待服务器响应(TTFB)竟然长达 8.38 秒!这简直不能忍。
经过一番抽丝剥茧的排查,我定位到了从 数据库 SQL 索引、大字段去重、字符集冲突高并发线程安全、字典 N+1 循环查库 等一连串经典性能瓶颈。最终,经过一波针对性重构,接口响应时间暴降至 300ms 左右

下面我将以个人技术博客的形式,详细记录这次“排雷”与“调优”的全过程,希望对大家有所启发。


🔎 调优前的“四宗罪”:我们在哪里卡住了?

在开始动手改代码前,我先在核心业务方法中加了耗时日志埋点,经过链路监控,找出了以下四个致命性能元凶:

1. 致命的 SELECT DISTINCT + longtext 大字段

在原来的 MyBatis XML 中,SQL 语句类似于下面这样:

SELECT DISTINCT cs.id, cs.summary, cs.product_photo, ...
FROM cykc_supply cs
LEFT JOIN cykc_excellent_company cec ...
  • 为什么会有 DISTINCT
    因为原来的 SQL 使用了 LEFT JOIN 关联了优秀企业表,可能存在一对多的情况导致主表数据行数膨胀出现重复,因此前人为了“图省事”,直接加了个 DISTINCT 去重。
  • 为什么慢得要命?
    cykc_supply 表里的 summary(供给概述)和 product_photo(产品图片)在数据库中是 longtext 大文本字段
    在 MySQL 中,如果对结果集使用 DISTINCT,MySQL 需要对结果集的所有列进行去重和排序(Filesort)。因为含有 longtext 大字段,MySQL 的内存临时表放不下这么大的数据,被迫退化,将所有数据写入磁盘,在磁盘上创建临时表进行文件排序去重!
    这就好比你搬家时,不用轻便的塑料箱,非要把几吨重的实木家具在狭窄的过道里搬来搬去做排序,速度不慢才怪!

2. 字符集不一致导致的 Illegal mix of collations 报错与索引失效

两张表做连接:

ON cs.company_name COLLATE utf8mb4_general_ci = cec.company_name COLLATE utf8mb4_general_ci
  • 原委
    主表 cykc_supply 的公司名字段字符集是 utf8mb4_general_ci,而优秀企业表 cykc_excellent_company 的对应字段使用的是 utf8mb4_0900_ai_ci。字符集不一致时,MySQL 无法直接进行 = 比较,直接连表会抛出 Illegal mix of collations 错误。
  • 代价
    前人为了防止报错,在 ON 连接条件的两边都加上了 COLLATE utf8mb4_general_ci 做强制转换。
    这导致了两张表在该列上的所有数据库索引彻底失效! MySQL 无法再利用索引快速定位,每次 JOIN 都要做全表的字符集强制转换和扫描,CPU 直接拉满。

3. 循环内的“N+1”次数据库字典查询

在 Service 层的列表遍历逻辑中:

for (CykcSupply supply : cykcSupplyList) {
    if (supply.getAuditStatus() != null) {
        // 每次循环都查一次数据库!
        supply.setAuditStatusName(sysDictDataService.selectDictLabel("audit_status", String.valueOf(supply.getAuditStatus())));
    }
}

原本的 selectDictLabel 直接去查了数据库(没走缓存)。如果一页返回 10 条数据,就会额外执行 10 次 SQL 字典查询;若导出 1000 条数据,就会执行 1000 次 SQL!大量的网络 IO 开销直接把接口响应时间拖到了深渊。

4. 线程不安全的重量级 Swing HTML 解析器

原先在循环内用 Html2Text.getContent(summary) 来剔除 HTML 标签,其底层使用的是 Swing 的 ParserDelegator,且把拼接字符串的 StringBuffer s 声明为了全局静态单例变量。

  • 隐患
    不仅高并发时多线程数据会互相污染(A 看到 B 的数据),而且在高并发解析超长富文本时,很容易引发多线程竞争阻塞和巨大的 GC 压力,导致偶发性的卡死。

🛠️ 调优实战:逐个击破性能瓶颈

针对排查出来的“四宗罪”,我制定并执行了三层调优方案,下面是具体的优化逻辑和代码/SQL对比:

🎯 突破一:SQL 极致重构(消除大字段 Filesort 与恢复索引)

我们优化的核心指导思想是:“能不用 JOIN 就不用 JOIN,能不用 DISTINCT 就绝对不用 DISTINCT”

既然我们只需要判断 company_name 是否在优秀企业表 cykc_excellent_company 中存在,完全没必要做大表 LEFT JOIN,更没必要去重!

🔄 优化方案:
  1. 移除 LEFT JOINSELECT DISTINCT:消除了大字段在磁盘上做 Filesort 排序的开销。
  2. 列级 EXISTS 子查询:用单行子查询代替 JOIN。
  3. 显式指定 COLLATE 转换:为了解决字符集冲突,我们仅在子查询内部的条件中加入 COLLATE 转换。因为这是单行常量的 = 匹配,MySQL 可以完美避开全表扫描,继续利用右表的索引!
📝 SQL 优化前后对比:
  • 优化前 ❌

    <select id="selectCykcSupplyList" parameterType="CykcSupply" resultMap="CykcSupplyResult">
        SELECT DISTINCT cs.id, cs.supply_type, ..., cs.summary, ...,
               CASE WHEN cec.id IS NOT NULL THEN 1 ELSE 0 END AS isExcellentCompany
        FROM cykc_supply cs
        LEFT JOIN cykc_excellent_company cec 
               ON cs.company_name COLLATE utf8mb4_general_ci = cec.company_name COLLATE utf8mb4_general_ci
        <where>
            ...
            <if test="isExcellentCompany != null and isExcellentCompany == 1">and cec.id IS NOT NULL</if>
            <if test="isExcellentCompany != null and isExcellentCompany == 0">and cec.id IS NULL</if>
        </where>
    </select>
    
  • 优化后 ✅

    <select id="selectCykcSupplyList" parameterType="CykcSupply" resultMap="CykcSupplyResult">
        SELECT cs.id, cs.supply_type, ..., cs.summary, ...,
               <!-- 用子查询代替 JOIN,无 DISTINCT 去重负担 -->
               (SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END 
                FROM cykc_excellent_company 
                WHERE company_name COLLATE utf8mb4_general_ci = cs.company_name COLLATE utf8mb4_general_ci) AS isExcellentCompany
        FROM cykc_supply cs
        <where>
            ...
            <!-- 用等价的 EXISTS 替换原 cec.id 过滤,使其能够利用索引 -->
            <if test="isExcellentCompany != null and isExcellentCompany == 1">
                and EXISTS (SELECT 1 FROM cykc_excellent_company WHERE company_name COLLATE utf8mb4_general_ci = cs.company_name COLLATE utf8mb4_general_ci)
            </if>
            <if test="isExcellentCompany != null and isExcellentCompany == 0">
                and NOT EXISTS (SELECT 1 FROM cykc_excellent_company WHERE company_name COLLATE utf8mb4_general_ci = cs.company_name COLLATE utf8mb4_general_ci)
            </if>
        </where>
    </select>
    

🎯 突破二:全局字典服务接入 Redis 缓存(消除 N+1 查询)

其实若依框架本身是支持字典缓存的,只是一直空置了。我们修改了公共字典服务的底层逻辑:

🔄 优化方案:

优先从 Redis 中获取指定字典类型的列表进行内存匹配。若缓存未命中,才降级查询数据库,实现“内存级”翻译。

📝 代码对比:
  • 修改前 ❌

    @Override
    public String selectDictLabel(String dictType, String dictValue) {
        // 每次调用必查一次数据库
        return dictDataMapper.selectDictLabel(dictType, dictValue);
    }
    
  • 修改后 ✅

    @Override
    public String selectDictLabel(String dictType, String dictValue) {
        // 1. 优先从 Redis 缓存中获取该类别的全部字典
        List<SysDictData> datas = DictUtils.getDictCache(dictType);
        if (StringUtils.isNotNull(datas)) {
            // 2. 缓存在内存中匹配,0次数据库IO
            for (SysDictData d : datas) {
                if (dictValue.equals(d.getDictValue())) {
                    return d.getDictLabel();
                }
            }
        }
        // 3. 缓存没有才查库
        return dictDataMapper.selectDictLabel(dictType, dictValue);
    }
    

🎯 突破三:轻量级正则清理 HTML 标签(消除 Swing 重度解析)

我们舍弃了原本臃肿不安全的 Swing 文本解析方案,改为采用无状态的正则表达式进行 HTML 标签的剥离。

🔄 优化方案:

使用极其轻量的正则 replaceAll("<[^>]*>", ""),将 HTML 标签过滤耗时从百毫秒级降至 0.1 毫秒级,且完全无状态,不存在任何多线程安全隐患。

📝 代码对比:
  • 修改前 ❌

    public static String getContent(String str) {
        try {
            Html2Text parser = new Html2Text(); // 每次实例化调用 DOM 树解析
            parser.parse(str); 
            return parser.getText();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return "";
    }
    
  • 修改后 ✅

    public static String getContent(String str) {
        if(StringUtils.isEmpty(str)){
            return "";
        }
        // 用正则代替 Swing DOM 解析,耗时直接可以忽略不计
        String txt = str.replaceAll("<[^>]*>", "");
        txt = txt.replaceAll("&nbsp;", " ")
                 .replaceAll("&amp;", "&")
                 .replaceAll("&lt;", "<")
                 .replaceAll("&gt;", ">");
        return txt.trim();
    }
    

📈 调优战果:8.38s 降至 300ms!

优化完成并成功编译发布后,我们再次对列表接口进行了性能测试,效果简直立竿见影:

  • 原接口响应时间8380 ms 😱
  • 优化后响应时间320 ms 😎
  • 优化幅度:响应速度提升了 26 倍
  • 日志埋点输出
    selectCykcSupplyList SQL 查询耗时: 110 ms, 查询条数: 10
    selectCykcSupplyList 循环处理耗时: 4 ms
    
    原本执行一次需要数秒的 SQL,重构后仅需 110毫秒 即可完成;而原本需要几秒的循环解析和字典翻译,如今更是缩减到了近乎可以忽略不计的 4毫秒

💡 个人成长心得与感悟

这次性能调优给了我几点深刻的技术感悟,在此分享给大家:

  1. SELECT DISTINCT 是一把双刃剑
    在写 SQL 时,如果发现数据重复,千万不要无脑加上 DISTINCT 解决。一定要先排查为什么会产生重复行。尤其是当 SELECT 列中包含大文本字段(如 TEXT/LONGTEXT)时,DISTINCT 会强迫 MySQL 进行昂贵的磁盘文件排序,这是非常致命的瓶颈。
  2. 慎用 COLLATE 连接条件
    表设计时,应尽量保持全库中字段字符集排序规则的一致性。在 ON 连接条件中使用 COLLATE 会直接废掉数据库的索引。如果历史包袱较重,也应该优先考虑改写为子查询,限制其对主查询索引的影响。
  3. 消灭循环查库的“坏味道”
    在循环体中执行 SQL 查询(N+1问题)是初学者最容易写出的代码。我们要牢记“批量加载”或者“合理利用 Redis 缓存”的原则。把 N 次查库降为内存级匹配,接口性能就能产生质的飞跃。

博客后记
写好每一行代码,不仅是对系统性能的敬畏,也是提升我们自身工程素养的基石。希望这篇调优笔记对你有所启发。如果你有更好的优化想法,欢迎在评论区一起讨论交流!

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值