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

发布时间:2026/6/27 6:25:46
从 8.38秒 到 300毫秒:记一次供给列表接口的极致性能调优实战 从 8.38秒 到 300毫秒记一次供给列表接口的极致性能调优实战前言在日常的搬砖生涯中我们经常会碰到一些“平时用起来没感觉一旦数据量上去或并发一高就直接卡死”的慢接口。最近我手头就遇到了一个硬骨头 ——/system/supply/list供给列表接口。在测试环境中仅仅分页查询 10 条数据等待服务器响应TTFB竟然长达 8.38 秒这简直不能忍。经过一番抽丝剥茧的排查我定位到了从数据库 SQL 索引、大字段去重、字符集冲突到高并发线程安全、字典 N1 循环查库等一连串经典性能瓶颈。最终经过一波针对性重构接口响应时间暴降至 300ms 左右下面我将以个人技术博客的形式详细记录这次“排雷”与“调优”的全过程希望对大家有所启发。 调优前的“四宗罪”我们在哪里卡住了在开始动手改代码前我先在核心业务方法中加了耗时日志埋点经过链路监控找出了以下四个致命性能元凶1. 致命的SELECT DISTINCTlongtext大字段在原来的 MyBatis XML 中SQL 语句类似于下面这样SELECTDISTINCTcs.id,cs.summary,cs.product_photo,...FROMcykc_supply csLEFTJOINcykc_excellent_company cec...为什么会有DISTINCT因为原来的 SQL 使用了LEFT JOIN关联了优秀企业表可能存在一对多的情况导致主表数据行数膨胀出现重复因此前人为了“图省事”直接加了个DISTINCT去重。为什么慢得要命cykc_supply表里的summary供给概述和product_photo产品图片在数据库中是longtext大文本字段。在 MySQL 中如果对结果集使用DISTINCTMySQL 需要对结果集的所有列进行去重和排序Filesort。因为含有longtext大字段MySQL 的内存临时表放不下这么大的数据被迫退化将所有数据写入磁盘在磁盘上创建临时表进行文件排序去重这就好比你搬家时不用轻便的塑料箱非要把几吨重的实木家具在狭窄的过道里搬来搬去做排序速度不慢才怪2. 字符集不一致导致的Illegal mix of collations报错与索引失效两张表做连接ONcs.company_nameCOLLATEutf8mb4_general_cicec.company_nameCOLLATEutf8mb4_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. 循环内的“N1”次数据库字典查询在 Service 层的列表遍历逻辑中for(CykcSupplysupply: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 优化前后对比优化前 ❌selectidselectCykcSupplyListparameterTypeCykcSupplyresultMapCykcSupplyResultSELECT 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_ciwhere...iftestisExcellentCompany ! null and isExcellentCompany 1and cec.id IS NOT NULL/ififtestisExcellentCompany ! null and isExcellentCompany 0and cec.id IS NULL/if/where/select优化后 ✅selectidselectCykcSupplyListparameterTypeCykcSupplyresultMapCykcSupplyResultSELECT 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 cswhere...!-- 用等价的 EXISTS 替换原 cec.id 过滤使其能够利用索引 --iftestisExcellentCompany ! null and isExcellentCompany 1and EXISTS (SELECT 1 FROM cykc_excellent_company WHERE company_name COLLATE utf8mb4_general_ci cs.company_name COLLATE utf8mb4_general_ci)/ififtestisExcellentCompany ! null and isExcellentCompany 0and 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 缓存消除 N1 查询其实若依框架本身是支持字典缓存的只是一直空置了。我们修改了公共字典服务的底层逻辑 优化方案优先从 Redis 中获取指定字典类型的列表进行内存匹配。若缓存未命中才降级查询数据库实现“内存级”翻译。 代码对比修改前 ❌OverridepublicStringselectDictLabel(StringdictType,StringdictValue){// 每次调用必查一次数据库returndictDataMapper.selectDictLabel(dictType,dictValue);}修改后 ✅OverridepublicStringselectDictLabel(StringdictType,StringdictValue){// 1. 优先从 Redis 缓存中获取该类别的全部字典ListSysDictDatadatasDictUtils.getDictCache(dictType);if(StringUtils.isNotNull(datas)){// 2. 缓存在内存中匹配0次数据库IOfor(SysDictDatad:datas){if(dictValue.equals(d.getDictValue())){returnd.getDictLabel();}}}// 3. 缓存没有才查库returndictDataMapper.selectDictLabel(dictType,dictValue);} 突破三轻量级正则清理 HTML 标签消除 Swing 重度解析我们舍弃了原本臃肿不安全的 Swing 文本解析方案改为采用无状态的正则表达式进行 HTML 标签的剥离。 优化方案使用极其轻量的正则replaceAll([^]*, )将 HTML 标签过滤耗时从百毫秒级降至0.1 毫秒级且完全无状态不存在任何多线程安全隐患。 代码对比修改前 ❌publicstaticStringgetContent(Stringstr){try{Html2TextparsernewHtml2Text();// 每次实例化调用 DOM 树解析parser.parse(str);returnparser.getText();}catch(IOExceptione){e.printStackTrace();}return;}修改后 ✅publicstaticStringgetContent(Stringstr){if(StringUtils.isEmpty(str)){return;}// 用正则代替 Swing DOM 解析耗时直接可以忽略不计Stringtxtstr.replaceAll([^]*,);txttxt.replaceAll(nbsp;, ).replaceAll(amp;,).replaceAll(lt;,).replaceAll(gt;,);returntxt.trim();} 调优战果8.38s 降至 300ms优化完成并成功编译发布后我们再次对列表接口进行了性能测试效果简直立竿见影原接口响应时间8380 ms优化后响应时间320 ms优化幅度响应速度提升了 26 倍日志埋点输出selectCykcSupplyList SQL 查询耗时: 110 ms, 查询条数: 10 selectCykcSupplyList 循环处理耗时: 4 ms原本执行一次需要数秒的 SQL重构后仅需110毫秒即可完成而原本需要几秒的循环解析和字典翻译如今更是缩减到了近乎可以忽略不计的4毫秒 个人成长心得与感悟这次性能调优给了我几点深刻的技术感悟在此分享给大家SELECT DISTINCT是一把双刃剑在写 SQL 时如果发现数据重复千万不要无脑加上DISTINCT解决。一定要先排查为什么会产生重复行。尤其是当 SELECT 列中包含大文本字段如TEXT/LONGTEXT时DISTINCT会强迫 MySQL 进行昂贵的磁盘文件排序这是非常致命的瓶颈。慎用COLLATE连接条件表设计时应尽量保持全库中字段字符集排序规则的一致性。在ON连接条件中使用COLLATE会直接废掉数据库的索引。如果历史包袱较重也应该优先考虑改写为子查询限制其对主查询索引的影响。消灭循环查库的“坏味道”在循环体中执行 SQL 查询N1问题是初学者最容易写出的代码。我们要牢记“批量加载”或者“合理利用 Redis 缓存”的原则。把 N 次查库降为内存级匹配接口性能就能产生质的飞跃。博客后记写好每一行代码不仅是对系统性能的敬畏也是提升我们自身工程素养的基石。希望这篇调优笔记对你有所启发。如果你有更好的优化想法欢迎在评论区一起讨论交流