凌晨两点半,我被一通电话从被窝里拽出来:核心列表接口的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,也可以配成SLF4J:
mybatis: 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。所以替换后要检查业务代码里有没有使用大字段属性,如果真用到了,就不能简单替换,得走下面的方案。
代码改造长这样:
// 修改前 List<Article> list = articleMapper.selectByExampleWithBLOBs(example); // 修改后 List<Article> list = articleMapper.selectByExample(example);这个改动虽然小,但一定要跑一遍相关功能的回归测试,尤其是那些把查询结果直接序列化返回前端的接口。因为大字段变成null之后,前端如果没做空值处理,可能引发NPE或页面显示异常。
4.2 长期良方:按需查询,让SQL只挑需要的内容
止血只是第一步,根治还得靠"按需查询"这一条准则。核心思路是:接口需要什么字段就查什么字段,不要把整行数据当默认选项。
我推荐两个落地方式。方式一,在MBG生成的XML里手动添加一个自定义查询:
<select id="selectSummaryByExample" resultMap="SummaryResultMap" parameterType="com.example.ArticleExample"> SELECT id, title, author, create_time, status FROM article <if test="_parameter != null"> <include refid="Example_Where_Clause" /> </if> ORDER BY create_time DESC </select>对应的示例代码可以完全复用MBG生成的Example对象,改动成本极低。方式二,在业务层定义专门的VO(视图对象),只包含需要的字段,避免把大字段带到网络传输层和缓存层。
4.3 场景化策略:延迟加载、列表/详情分离、缓存
不同业务场景有不同的处理思路:
- 列表页和详情页分离。列表查询一律不带大字段,点击进入详情时,再用
selectByPrimaryKeyWithBLOBs或自定义的selectDetailById查完整内容。这是内容类系统最常见的优化手法。 - 真正需要大字段的场景,用懒加载思路。MyBatis配置文件里加
lazyLoadingEnabled=true和aggressiveLazyLoading=false,配合<association>实现按需加载。不过要注意,如果你把整个查询结果放进了缓存,懒加载的效果会被缓存穿透,需要另外设计。 - 缓存策略上要小心。有人为了性能把列表接口的返回值缓存到Redis,但查询语句本身包含大字段时,构建缓存对象的过程依然要付出数据库和网络开销。而且缓存大量大字段数据,Redis内存会涨得很快,过期时还可能引发缓存雪崩。所以推荐顺序永远是:先减少查询字段,再加缓存。
4.4 MyBatis-Plus等替代方案中的"同类坑"
很多新项目直接用MyBatis-Plus,觉得它更省心。但MP有一个类似的坑:默认的selectList、selectById都是查询所有字段的,包括大字段。它没有拆分WithBLOBs,所以问题更隐蔽。解决办法是使用LambdaQueryWrapper显式指定列:
List<Article> articles = articleMapper.selectList( new LambdaQueryWrapper<Article>() .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。别嫌麻烦,这个动作能帮你避免很多个凌晨两点的报警电话。