从 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,更没必要去重!
🔄 优化方案:
- 移除
LEFT JOIN与SELECT DISTINCT:消除了大字段在磁盘上做 Filesort 排序的开销。 - 列级 EXISTS 子查询:用单行子查询代替 JOIN。
- 显式指定 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(" ", " ") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">"); return txt.trim(); }
📈 调优战果:8.38s 降至 300ms!
优化完成并成功编译发布后,我们再次对列表接口进行了性能测试,效果简直立竿见影:
- 原接口响应时间:8380 ms 😱
- 优化后响应时间:320 ms 😎
- 优化幅度:响应速度提升了 26 倍!
- 日志埋点输出:
原本执行一次需要数秒的 SQL,重构后仅需 110毫秒 即可完成;而原本需要几秒的循环解析和字典翻译,如今更是缩减到了近乎可以忽略不计的 4毫秒!selectCykcSupplyList SQL 查询耗时: 110 ms, 查询条数: 10 selectCykcSupplyList 循环处理耗时: 4 ms
💡 个人成长心得与感悟
这次性能调优给了我几点深刻的技术感悟,在此分享给大家:
SELECT DISTINCT是一把双刃剑:
在写 SQL 时,如果发现数据重复,千万不要无脑加上DISTINCT解决。一定要先排查为什么会产生重复行。尤其是当 SELECT 列中包含大文本字段(如TEXT/LONGTEXT)时,DISTINCT会强迫 MySQL 进行昂贵的磁盘文件排序,这是非常致命的瓶颈。- 慎用
COLLATE连接条件:
表设计时,应尽量保持全库中字段字符集排序规则的一致性。在ON连接条件中使用COLLATE会直接废掉数据库的索引。如果历史包袱较重,也应该优先考虑改写为子查询,限制其对主查询索引的影响。 - 消灭循环查库的“坏味道”:
在循环体中执行 SQL 查询(N+1问题)是初学者最容易写出的代码。我们要牢记“批量加载”或者“合理利用 Redis 缓存”的原则。把 N 次查库降为内存级匹配,接口性能就能产生质的飞跃。
博客后记:
写好每一行代码,不仅是对系统性能的敬畏,也是提升我们自身工程素养的基石。希望这篇调优笔记对你有所启发。如果你有更好的优化想法,欢迎在评论区一起讨论交流!

326

被折叠的 条评论
为什么被折叠?



