ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MyBatis查询性能骤降80%?警惕selectByExampleWithBLOBs大字段陷阱

MyBatis查询性能骤降80%?警惕selectByExampleWithBLOBs大字段陷阱 凌晨两点半我被一通电话从被窝里拽出来核心列表接口的P99延迟从200ms直接飙到1.2s性能下降超过了80%。登录线上环境一查SQL慢查询日志里躺着一大批耗时数秒的SELECT语句而它们的共同特征是都调用了MyBatis Generator自动生成的selectByExampleWithBLOBs方法。这事在Java后端项目里太典型了只要用到MyBatis就迟早会撞上。先给不熟悉的朋友把问题说透selectByExampleWithBLOBs是MyBatis Generator下文简称MBG为带大字段BLOB、CLOB、TEXT等的表自动生成的查询方法。它的特点是会一次性把表里所有字段查出来包括那些动辄几百KB甚至几MB的大文本、大对象字段。表面上它和其他查询方法长得一模一样不报错、不提示但一旦数据量上来它就是埋在代码里的隐形炸弹。这篇文章我会从原理、定位到修复完整把这个问题讲明白适合正在用MyBatis、或者接手了老项目的后端开发。1. 先搞懂selectByExampleWithBLOBs是哪里冒出来的1.1 MyBatis Generator的方法族与BLOB的特殊待遇用MBG生成代码的同学应该都见过只要表里有TEXT、BLOB、CLOB这类大字段生成出来的Mapper接口和XML里就会出现一套双胞胎方法。以最常用的查询为例对比是这样方法名查询范围适用场景selectByPrimaryKey只查非BLOB字段列表、摘要信息查询selectByPrimaryKeyWithBLOBs查询所有字段含BLOB详情页、需要大字段内容时selectByExample按条件查询不含BLOB条件列表、分页列表selectByExampleWithBLOBs按条件查询含所有字段条件查询且必须带大字段updateByPrimaryKey更新非BLOB字段常规更新updateByPrimaryKeyWithBLOBs更新所有字段需要连大字段一起更新MBG在生成代码时会读取数据库元数据把JDBC类型为BLOB、CLOB、LONGVARCHAR、LONGVARBINARY这类字段单独归类。只要表里有这类字段它就会额外生成一组WithBLOBs版本的方法。这个设计的初衷是合理的某些场景确实需要完整数据所以给你一个显式的方法同时保留不查大字段的轻量方法避免每次都拖家带口。问题出在很多人根本没意识到这两个方法之间的性能差距随手一个自动补全就调用了selectByExampleWithBLOBs。1.2 隐形在哪一处误调全链路买单我排查过不少类似的问题发现这个陷阱特别容易踩中有几个现实原因。第一IDE的自动补全常常把WithBLOBs方法排在前面因为方法名只多了几个字母手滑概率极高。第二代码评审阶段很难发现大家review代码时关注的是业务逻辑不会逐个去核对SQL到底查了哪些字段。第三开发环境数据量小一条SQL查询返回几十行带不带大字段都感觉不到差异一到生产环境百万级数据量问题瞬间爆发。更要命的是这种问题常常不在第一个吃螃蟹的人身上爆发而是整个链路人人都受影响。大字段查询会占用数据库连接更久拖慢连接池的周转导致其他正常SQL也排队等待最终表现为接口大面积变慢甚至连接池被打满。这就不是单接口问题了是全局性的性能灾难。2. 性能掉80%的深层原理这样一条SQL到底干了什么很多文章只说不要查大字段但不解释为什么。我建议每个后端开发都搞明白背后的三层代价否则下次换个场景还是会踩坑。2.1 数据库侧大字段引发的存储与IO连锁反应以最常用的MySQL InnoDB为例。InnoDB默认页大小16KB当一行数据的总长度超过页容量的一定比例时就会出现行溢出off-page。大字段的实际内容会被存到独立的溢出页中原行数据里只保留一个20字节左右的指针。这就带来两个问题。第一查询大字段时InnoDB除了读取正常数据页还要额外读取溢出页一次查询可能要跨多个数据页完成。第二如果SQL中带有排序或分组操作数据库需要处理的数据量远超不含大字段的版本排序缓冲区、临时表的使用量都会剧增磁盘临时表的概率直线上升。举一个实际例子一个文章表有标题、发布时间、状态等普通字段还有一个TEXT类型的正文单篇正文平均5KB。普通字段每行也就200字节左右而带TEXT的整行数据实际上是5KB加200字节。在10万行数据的表里做条件查询查普通字段时InnoDB可能要读几百个数据页查TEXT时可能要读几万个页IO量差异是两个数量级。数据库这边的开销是所有性能问题的起点。2.2 网络与内存侧传输放大与堆内压力数据库查询结果最终要通过JDBC连接传到应用服务器。这个环节的放大效应特别直观。我做个简单估算。假设某列表接口一次查询1万行不含大字段时结果集大约2MB加上TEXT字段后按平均4KB算就是40MB。如果这个接口每秒被调用20次网络传输量就从40MB/s涨到800MB/s直接把你内网带宽和数据库出网带宽打满。更隐蔽的是Java堆内存压力。MyBatis结果映射时BLOB字段会转化为byte[]如果配置的是String类型那就是一个巨大的String对象。查询期间这些大对象会一次性涌入堆内存。如果接口并发稍高GC日志里会眼睁睁看到年轻代疯狂增长、老年代不停Full GC。你可能会发现CPU没怎么高但GC频繁得吓人整个应用像被卡住一样。2.3 ORM侧映射与转换的隐藏开销有人觉得数据库那边查出来就好了传过来之后反正我用不上BLOB字段是不是就没开销了不是。只要SQL里select了BLOB字段JDBC驱动就会把完整的大字段值取出来MyBatis的TypeHandler会把它解析并设置到POJO属性里。这个过程中哪怕你最终没在业务代码中读取这个属性它也已经实打实地在内存里走了一圈。还有一个容易被忽略的点ResultMap中如果映射了大字段MyBatis构造结果对象时需要做更多的类型转换工作虽然单行开销不大但架不住行数多。当查询行数上万时这部分CPU损耗会明显反映在接口耗时上。我在一次压测中对比过同样的查询条件带BLOB字段的版本TP99比不带的高出五六倍应用侧CPU也高了近30%。3. 定位与诊断如何确认你的系统也踩了这颗雷如果你现在怀疑项目里有selectByExampleWithBLOBs滥用的情况别急着改代码先用证据说话。我总结了一套从SQL到执行计划再到全链路监控的排查流程。3.1 打开SQL日志先看清每次查询的真面目MyBatis默认不会打印完整SQL需要先打开日志。在Spring Boot项目的application.yml里加这段配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl如果用的是logback也可以配成SLF4Jmybatis: configuration: log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl然后去日志里搜索Preparing:关键字就能看到实际执行的SQL。重点观察SQL中是否出现了大字段的列名。比如select id, title, create_time, content from article where ...这种如果content是TEXT字段而这个接口根本不需要它那就基本坐实了问题。这一步很简单但很多团队从来没有配过SQL在开发环境打印了一堆日志也没人看。日志是排查慢SQL的第一手资料建议默认开启到调试级别线上用logback动态开关控制。3.2 执行计划慢查询日志逐层锁定有了具体SQL下一步就是看执行计划。MySQL里直接在客户端执行EXPLAIN SELECT id, title, content, create_time FROM article WHERE status 1 ORDER BY create_time DESC LIMIT 0, 20;重点看这几列列名含义需要警惕的值type访问类型ALL表示全表扫描index也要留意rows预估扫描行数远大于实际返回行数说明过滤差Extra额外信息Using filesort、Using temporary说明有严重的排序或临时表开销不过要提醒一句EXPLAIN只能看到执行计划和预估行数看不出大字段带来的传输开销。所以光靠EXPLAIN还不够还要结合慢查询日志。打开MySQL的慢日志SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;然后去慢日志里统计哪些SQL频繁出现再把它们和代码里的Mapper方法对应起来。这个方法能帮你建立一个认知耗时高的SQL往往不是join太复杂而是select了不该select的大字段。3.3 实战案例复盘一次定位全过程拿我之前遇到的一个真实案例复盘。系统是一个内容管理后台列表页每次刷新要等两三秒用户反馈很多。我先看Druid监控发现一个SQL的平均执行时间是2.4s执行次数还特别频繁。顺着SQL去代码里搜发现调用链是ArticleMapper.selectByExampleWithBLOBs(example)而页面列表只需要文章的标题、状态、发布时间。再对比selectByExample方法SQL中少了大字段执行时间从2.4s降到180ms。改完之后接口P99从1.2s回到220ms整个应用GC频率也肉眼可见地降下来。这个案例最关键的一步是先用监控工具锁定了SQL再回到代码里找方法名。如果一开始就在代码里瞎翻很难快速找到问题源头。4. 修复与根治把性能拿回来的几种姿势定位到问题之后修复方案要分轻重缓急。我按止血—长效—场景化三层来给方案。4.1 立即止血替换为无BLOB的查询方法最快的办法是把selectByExampleWithBLOBs替换成selectByExample。注意两者返回类型不同前者返回的对象包含大字段属性后者返回的对象中大字段属性为null。所以替换后要检查业务代码里有没有使用大字段属性如果真用到了就不能简单替换得走下面的方案。代码改造长这样// 修改前 ListArticle list articleMapper.selectByExampleWithBLOBs(example); // 修改后 ListArticle list articleMapper.selectByExample(example);这个改动虽然小但一定要跑一遍相关功能的回归测试尤其是那些把查询结果直接序列化返回前端的接口。因为大字段变成null之后前端如果没做空值处理可能引发NPE或页面显示异常。4.2 长期良方按需查询让SQL只挑需要的内容止血只是第一步根治还得靠按需查询这一条准则。核心思路是接口需要什么字段就查什么字段不要把整行数据当默认选项。我推荐两个落地方式。方式一在MBG生成的XML里手动添加一个自定义查询select idselectSummaryByExample resultMapSummaryResultMap parameterTypecom.example.ArticleExample SELECT id, title, author, create_time, status FROM article if test_parameter ! null include refidExample_Where_Clause / /if ORDER BY create_time DESC /select对应的示例代码可以完全复用MBG生成的Example对象改动成本极低。方式二在业务层定义专门的VO视图对象只包含需要的字段避免把大字段带到网络传输层和缓存层。4.3 场景化策略延迟加载、列表/详情分离、缓存不同业务场景有不同的处理思路列表页和详情页分离。列表查询一律不带大字段点击进入详情时再用selectByPrimaryKeyWithBLOBs或自定义的selectDetailById查完整内容。这是内容类系统最常见的优化手法。真正需要大字段的场景用懒加载思路。MyBatis配置文件里加lazyLoadingEnabledtrue和aggressiveLazyLoadingfalse配合association实现按需加载。不过要注意如果你把整个查询结果放进了缓存懒加载的效果会被缓存穿透需要另外设计。缓存策略上要小心。有人为了性能把列表接口的返回值缓存到Redis但查询语句本身包含大字段时构建缓存对象的过程依然要付出数据库和网络开销。而且缓存大量大字段数据Redis内存会涨得很快过期时还可能引发缓存雪崩。所以推荐顺序永远是先减少查询字段再加缓存。4.4 MyBatis-Plus等替代方案中的同类坑很多新项目直接用MyBatis-Plus觉得它更省心。但MP有一个类似的坑默认的selectList、selectById都是查询所有字段的包括大字段。它没有拆分WithBLOBs所以问题更隐蔽。解决办法是使用LambdaQueryWrapper显式指定列ListArticle articles articleMapper.selectList( new LambdaQueryWrapperArticle() .select(Article::getId, Article::getTitle, Article::getStatus) .eq(Article::getStatus, 1) );如果你在项目里看到MP的Mapper方法一梭子查全表那基本可以断定存在和selectByExampleWithBLOBs一样的隐患。无论用哪种框架核心原则不变查询字段列表要和业务需求匹配。5. 常见问题速查与经验总结5.1 排查问题的自查清单我把每次排查时都会走一遍的检查项整理成清单直接照着做就行打开MyBatis SQL日志确认线上执行的SQL列了哪些字段。在产品代码里全局搜索WithBLOBs把所有调用点列出来逐个核对是否真的需要大字段。打开数据库慢查询日志把执行时间超过1秒的SQL导出关联到对应Mapper方法。如果用了Druid或SkyWalking一类的监控直接看Top SQL和连接池等待时间。对比测试同一查询条件分别用带BLOB和不带BLOB的方法跑一次记录响应时间和GC情况。这套流程走完问题基本就浮出水面了。如果数据量大到难以在测试环境复现可以把线上慢SQL拿到测试库用EXPLAIN ANALYZE跑一遍MySQL 8.0支持看实际耗时分布。5.2 常见错误与误区排查过程中有几个误区需要特别警惕。误区一认为问题只在BLOB字段真正存储大内容时才会出现。实际上即使TEXT字段全是空字符串只要SQL查询包含这个列数据库和JDBC驱动也会按大字段逻辑处理传输开销和行溢出判断一样存在。误区二想着靠分页插件解决。PageHelper这类分页工具只能控制返回行数控制不了字段宽度。SQL里只要带了BLOB字段数据库读取大字段、网络传输大字段的代价一点都不会少。我在文章开头说的那个生产事故SQL同样加了分页照样把连接池打满。误区三用selectByPrimaryKeyWithBLOBs查详情就觉得万事大吉。如果是批量详情查询比如传入几十个ID循环调用这个方法每行都带大字段加起来依然是一场灾难。这种场景要先查无BLOB字段的数据然后对真正需要的大字段做二次精准查询。5.3 一些实战中的小技巧最后分享几个我自己的小习惯。一是给MBG生成的XML加上注释在WithBLOBs方法旁边标注大字段查询非必要勿用给后来人留个提示。二是在代码规范的Checkstyle或Sonar规则里加入一条Mapper方法的调用点不允许在循环体内出现必要时改成批量查询。三是新表设计时尽量把大字段拆到单独的表里通过主键关联这样MBG生成代码时主业务表天然没有BLOB从根源上杜绝这类问题。我踩过几次坑之后现在接手任何MyBatis项目的第一件事就是在全项目里搜一遍WithBLOBs。别嫌麻烦这个动作能帮你避免很多个凌晨两点的报警电话。
返回列表