1. 从一次接口变慢说起:项目里为何会冒出 withBLOBs 查询
先说结论:如果你的项目里用了 MyBatis Generator 自动生成 Mapper,那selectByExampleWithBLOBs这个方法大概率已经躺在你的代码里了。它不是不能用,而是很多人根本没搞明白,它和普通的selectByExample之间差了多少东西。我这次踩坑的结果很直接:一个列表接口的 P99 从正常的几十毫秒涨到四五百毫秒,数据库 CPU 被打满,整个服务的吞吐掉到原来的两成,相当于性能下降了 80%。问题从出现到定位,前后折腾了大半天。
事情是这样的。我们一个博客后台管理项目,文章表article里存了 Markdown 原始内容和渲染后的 HTML,字段是MEDIUMTEXT类型。这个表从建表之初就用 MyBatis Generator 生成了 Mapper 和 Example 全套文件,后台列表页一直用articleMapper.selectByExampleWithBLOBs(example)查文章列表。上线初期数据量只有几万条,每篇文章正文平均也就几 KB,压根看不出问题。等运营做了一两个月,库里的文章涨到几十万条,其中不少是带大量图片 base64 内容的长文,单条正文能到几百 KB,线上就开始出事了。
这个标题里说的"隐形炸弹",指的是 MyBatis Generator 在生成代码时,默认会同时生成一组方法,其中最容易让人掉坑的就是selectByExampleWithBLOBs。它和selectByExample的差异,用一句话说就是:前者把表里所有大字段(TEXT、BLOB、CLOB、LONGTEXT 等)全部查询出来,后者只查询普通业务列。很多人用 IDEA 等代码补全时,一眼扫过去看到方法列表里有selectByExample和selectByExampleWithBLOBs,顺手就选了带 BLOBs 的那个——因为名字看起来更"完整"、更像"全字段查询"。而恰恰是这个"顺手",成了性能爆炸的导火索。
1.1 MyBatis Generator 方法清单里藏着什么
只要你的 MySQL 表里有一个TEXT或BLOB类型字段,MyBatis Generator 生成的XxxMapper接口里至少会有这些查询方法:
| 方法 | 查询范围 | 典型使用场景 |
|---|---|---|
selectByPrimaryKey | 全字段(含大字段) | 按主键查单条详情 |
selectByExample | 非大字段列 | 条件查列表,不关心大字段内容 |
selectByExampleWithBLOBs | 全字段(含大字段) | 条件查列表,且必须拿到大字段内容 |
selectByExampleSelective | 非大字段列,且只查非 null 条件字段 | 动态条件查询列表 |
selectByExampleWithBLOBs的 Selective 变体 | 全字段,动态条件 | 很少人用 |
注意selectByPrimaryKey虽然也是全字段,但它只按主键查一条,数据量可控,危害反而不大。真正的问题是selectByExampleWithBLOBs这种"条件查询 + 全字段返回"的组合:一旦条件命中大量记录,结果集里每一条都带着大字段,数据量就不是线性增长,而是直接爆炸。
1.2 什么样的表结构容易踩中这个坑
MyBatis Generator 判断"是否生成 WithBLOBs 方法"的依据很简单:表里是否存在 JDBC 类型为BLOB、CLOB、LONGVARCHAR、LONGVARBINARY的列。落到 MySQL 上,常见的触发字段类型包括:
TEXT、TINYTEXT、MEDIUMTEXT、LONGTEXTBLOB、TINYBLOB、MEDIUMBLOB、LONGBLOB- 部分方言下的
CLOB、LONG VARCHAR
也就是说,只要表里有一个"内容"字段——博客正文、评论全文、JSON 配置、图片 base64 字符串、日志原文——生成器就会自动产出 withBLOBs 方法。而这类字段在业务系统里太常见了,所以踩坑的概率远比想象中高。特别是电商商品详情、CMS 内容管理、IM 聊天记录这类场景,几乎张张表都有大字段,风险面非常大。
1.3 为什么这个方法看起来如此无害
说实话,这方法从代码层面看确实"无害"。它就是一个标准的查询接口,参数是ArticleExample,返回List<Article>。不看生成的 XML 文件,你根本不知道底层 SQL 里select后面的列清单已经把content这个MEDIUMTEXT字段带上了。MyBatis Generator 对方法的命名设计其实有历史原因——早年 JDBC 规范里 BLOB 字段的读取方式比较麻烦,所以单独区分出 WithBLOBs 方法是有必要的。但到了今天,这个命名反而成了误导:它在 IDE 自动补全里看起来只是一个"更完整"的查询版本,很多人根本不知道它比selectByExample多了什么。
提示:判断一个 Mapper 方法是不是"带大字段"方法,不要看方法名,直接看对应的 XML 里的
resultMap和 SQL 列清单。ResultMapWithBLOBs这个 resultMap 名字就是最明显的信号。
2. 性能暴跌的底层逻辑:BLOB 字段查询的三重代价
为什么一个查询方法能把性能打到两折?很多人以为是"查询变慢了",其实单纯从数据库索引执行层面看,命中的行数可能没变,执行计划也完全一样。真正的性能损耗发生在三个层面:网络传输、内存占用、数据库侧的资源消耗。三者叠加在一起,才会出现"数据库 CPU 打满 + 应用 GC 频繁 + 连接池耗尽"这种连环车祸现场。
2.1 网络传输与连接池占用
假设你的列表接口按条件命中了 2000 条文章记录,每条记录除了标题、作者、时间等普通字段外,还带着一个平均 100 KB 的 content 字段。那么这 2000 条记录从 MySQL 传到应用服务器,传输量就是2000 x 100KB = 200MB。对比不查 content 的情况,假设普通字段总共只有 2KB 每条,传输量是 4MB。两者差了 50 倍。
网络传输变慢的直接后果是:数据库连接被这条 SQL 占用的时间变长。MySQL 连接池的并发能力是固定的,比如 HikariCP 默认maximum-pool-size是 10。一个慢查询占用连接 2 秒,意味着这 2 秒内该连接无法处理其他请求。当多个慢查询同时堆积,连接池迅速耗尽,后面的请求全部在getConnection()上排队等待。这时候表象就是"整个应用变慢了",而不是某个接口变慢,排查难度一下子大了很多。
2.2 内存与 GC 压力
传输只是第一层。数据到达应用服务器后,MyBatis 要把 ResultSet 映射成Article对象,content 字段对应的是String(或byte[])。这意味着每条 Article 对象在堆里占用的内存就不是几百字节,而是几十到几百 KB。2000 条记录,光这个列表查询就会在 Old 区新增 200MB 左右的可达对象。
如果这个接口的调用频率是每秒 10 次,大家可以在脑海里算一下,每秒会产生多大的对象压力。结果就是:
- Young GC 频率飙升,因为每次查询都要新建大量大对象
- 大对象分配走 TLAB 失败,直接进老年代,触发 Full GC 或者 CMS 并发标记周期
- GC 停顿时间一长,接口 RT 再次恶化,形成恶性循环
实际案例里,我们当时观察到 GC 每分钟的G1 Old Collection次数从每 15 分钟一次变成了每分钟 2 到 3 次,单次停顿最久到 800ms。应用层和数据库层的延迟叠加,最后表现在监控面板上就是一张惨不忍睹的 RT 曲线。
2.3 数据库与驱动层同样在放大开销
很多人容易忽略的还有 MySQL 服务器本身。当查询请求包含大字段时:
- MySQL 需要把
content从存储引擎读出来,经过 InnoDB Buffer Pool 时,大字段占用大量 Buffer Pool 空间,导致普通索引页被挤出,命中率下降; - 排序和临时表操作如果涉及大字段,磁盘临时表的大小会非常夸张;
- 返回给客户端时,MySQL 服务器也要在内部网络缓冲区中准备完整的结果集,
max_allowed_packet设置较小的情况下,甚至直接抛Packet for query is too large错误。
JDBC 驱动这一层也一样。MySQL Connector/J 在读取TEXT字段时,默认情况下会一次性把整个数据加载到内存中再映射,这会让ResultSet.next()的耗时长。如果对结果集做深度分页——比如LIMIT 100000, 20——MySQL 会读取前十万条记录的所有字段(包括 BLOB)然后丢弃,再取最后的 20 条,这中间浪费的 IO 和内存是不带 BLOB 字段查询的几十倍。
2.4 用数字复盘 80% 的由来
"性能下降 80%"不是一个魔法数字。它的来源很简单:在数据库层耗时不变的前提下,单接口吞吐从原来的QPS 100掉到QPS 20,就是正好降了 80%。因为数据库连接池的并发上限摆在那里,单次查询耗时涨了 5 倍,整体吞吐自然掉到原来的 20%。如果你的业务变量里还叠加了更多大字段、更大行数、更频繁的调用,那下降比例只会更夸张,到 90%、95% 都不是没可能。
3. 完整排查实录:从报警到定位我做了什么
我常说排查性能问题最重要的是"有步骤地缩小范围",不要上来就猜。这次问题从发现到定位,路径很清晰,分享出来供参考。
3.1 第一眼:监控面板的异常曲线
周五下午三点左右,值班告警弹出:订单接口组的P99从 60ms 涨到 500ms,错误率从 0.1% 升到 1.5%。打开 Grafana 一看,数据库 CPU 使用率直接飙到 95% 以上,连接数打到了上限。第一反应是"数据库被慢查询拖垮了",于是直奔慢查询日志。
3.2 慢 SQL 日志:一条 8 秒的 article 查询
MySQL 慢查询日志里躺着一堆select * from article where ...之类的语句,执行时间从 3 秒到 8 秒不等。结合文章表的索引情况,这些查询的where条件其实都命中了索引,执行计划显示rows也就几千。那为什么跑这么慢?关键在select后面的列里带着content。
在日志里翻到其中一条 SQL,select id, title, author_id, content, ... from article where author_id = 123 order by create_time desc limit 20。确实命中了idx_author_id索引,但因为要回表读取content字段,每次回表都要把几百 KB 的数据从磁盘拉出来,单条回表的成本被放大了几百倍。
3.3 手工验证:去掉 content 立即恢复
我做了个最直接的对照实验:把 SQL 里的content字段去掉,只查id, title, author_id, create_time,同样的where条件,执行时间从 8 秒变成 40 毫秒。这 200 倍差距一出,问题基本锁定——就是大字段查询。
再进一步验证:既然where条件没问题,那是不是深度分页导致的?我又把limit 0, 50和limit 100000, 20各跑了一次,结果前者 30ms,后者 1.2 秒。这两者叠加,问题就更明显了:带 BLOB 字段的查询一旦命中大量数据或做大偏移分页,性能绝对崩。
3.4 在代码中揪出真正的调用点
SQL 级别确认了,接下来就是去项目里搜索谁调用了这个查询。当时我用 IDEA 全局搜索selectByExampleWithBLOBs,搜出来 30 多处调用,逐个排除后发现重灾区集中在两个地方:
- 后台列表接口:为了省事,一个接口里同时查了列表数据,又对返回对象做了 JSON 序列化,content 字段被直接序列化到响应体里返回给前端;
- 定时任务批量导出:每天凌晨遍历全量文章做内容统计,直接用 withBLOBs 方法把 20 万篇全文捞到内存里,每次任务跑半小时以上,把数据库 IO 直接拉满。
这两个场景都是典型的"不需要大字段却查了大字段"。后台列表页根本不需要正文内容,只需要截断的前 200 字摘要;定时任务同样只需要统计正文长度或关键词次数,完全可以在 SQL 层用LENGTH(content)或LIKE处理。
4. 落地方案:把批量查询从深渊里拉回来
定位到问题之后,改起来其实不复杂,但要考虑不同业务场景,不能一刀切。我这里把方案分成了三个层次:应急止血、业务改造、结构性优化。
4.1 应急止血:把最直观的问题先解决
对于所有明确"不需要大字段"的接口,直接把selectByExampleWithBLOBs(example)改成selectByExample(example)。这是改动最小、收益最明显的操作。两种方法的入参一样是ArticleExample,返回类型从List<Article>变成List<Article>时,MyBatis Generator 生成的对象本身是同一个Article,只是 BLOB 字段(如 content)在这个返回集合里是 null。
这里有个细节要注意:selectByExampleWithBLOBs和selectByExample返回的都是Article类型,但底层的resultMap不同。前者用的是ResultMapWithBLOBs,后者用的是BaseResultMap。改完之后,代码里如果之前有article.getContent()的调用,拿到的会是 null,必须在改动前仔细 review 调用链,把依赖 content 的代码同步改成查详情接口。
我在上面的 30 处调用里逐一做了 review,真正需要 content 字段的只有两三处,其他全是误用。改完再压测,P99 从 500ms 回到了 65ms,数据库 CPU 从 95% 降到 20%,问题基本解除。
4.2 业务确实需要大字段怎么办
如果确认业务上就是需要大字段,有几种处理办法,按推荐程度排列:
第一,用主键查详情,不批量查。列表页只展示标题、摘要、作者、时间,点击进入详情页时,用主键调selectByPrimaryKey或者自定义selectById方法。单条记录哪怕是 1MB 的正文,一次查一条也不会造成系统性风险。
第二,SQL 层做裁剪。如果列表页需要显示正文字数的前 100 个字,在 SQL 里直接写LEFT(content, 200)或者SUBSTRING(content, 1, 200),只返回裁切后的字符串。这样既拿到了需要的预览文本,又避免了传输完整大字段。注意 MySQL 的LEFT函数是字符级别截断的,可以直接用,不用担心把中文截断成乱码。
第三,抽一个独立的轻量查询方法。如果你的项目确实有"批量查询文章正文摘要"的需求,比如 CMS 的编辑列表要显示每篇文章的纯文本预览,不要用 Generator 生成的方法凑合,直接在 Mapper XML 里新增一个selectSummaryByExample,列清单里只放id, title, LEFT(content, 200) AS content_preview, ...,这样语义清晰、效果直观,也方便后续维护。
4.3 结构性优化:垂直拆分和延迟加载
如果项目规模不小,大字段带来的问题反复出现,我建议做一层结构上的调整,而不是一直靠 SQL 补救。
垂直拆表是根治方案。把文章表拆成article_main(id、title、author_id、summary、create_time 等高频查询字段)和article_content(article_id、content、version 等低频大字段),列表查询永远只查article_main,详情页两个表 join 或者分两次查询。这个方案彻底斩断了"列表误查大字段"的可能性,是很多大型内容系统的主流做法。缺点是需要改表、改代码、做数据迁移,工作量较大,适合在项目相对稳定时推进。
MyBatis 懒加载是折中方案。如果你暂时不想拆表,可以在 XML 的resultMap里给 content 字段配置懒加载:
<resultMap id="ResultMapWithBLOBs" type="Article"> <result column="content" property="content" jdbcType="LONGVARCHAR" fetchType="lazy"/> </resultMap>前提是 MyBatis 配置里开启了懒加载开关:
mybatis: configuration: lazy-loading-enabled: true这样调用selectByExampleWithBLOBs时,content 字段不会立即加载,只有在代码里真正article.getContent()时才会发一条 SQL 去查询。懒加载的坑在于潜在 N+1 问题:如果你在循环里对 100 条记录都调用了getContent(),会额外产生 100 条查询 SQL,得不偿失。所以懒加载只适合"偶尔取一两条 content"的场景,循环批量取大字段必须配合批量查询。
还要注意大分页。不管用哪个方案,limit 100000, 20这种写法都要尽量避免。可以用游标分页(where id > 上次最大id order by id limit 20)替代,或者时间字段做范围滚动。带大字段的分页查询,深偏移的代价会成倍放大。
5. 工程化兜底:让团队里不再有人踩同一个坑
一次排查解决不了长期问题。真正要把这类"隐形炸弹"排干净,需要在工程化层面做几件事,让问题从"靠人肉 review"变成"靠机制拦截"。
5.1 从生成器配置上把关
MyBatis Generator 允许做很多细粒度控制。最粗暴有效的方式是让生成器直接不生成 withBLOBs 方法。可以通过 Table 配置里的ignoreColumn把大字段从生成器视野里剔除,或者写一个自定义插件,在生成完成后扫描 Mapper 接口文件,删除WithBLOBs方法相关代码。
不过我更推荐一个折中做法:不要删方法,而是在生成器配置时将 BLOB 列通过columnOverrides指定为普通类型:
<table tableName="article" domainObjectName="Article"> <columnOverride column="content" jdbcType="OTHER" javaType="java.lang.String" typeHandler="com.example.typehandler.LongStringTypeHandler"/> </table>这样content列在生成时不再是 BLOB 类型,selectByExampleWithBLOBs就不会生成,所有查询方法默认带上的是普通字段列。如果你确实有需要读 content 的场景,就自己单独写查询方法,不用 Generator 的默认方法。
5.2 SQL 审计与日志监控
在开发环境接入p6spy或类似工具,打印真实执行的 SQL 和参数。同时配置 MyBatis 的慢 SQL 日志拦截器,对执行时间超过 200ms 的语句输出完整信息到专门的日志文件里,方便开发阶段就能发现问题。
生产环境的 MySQL 侧,把long_query_time调低到 1 秒或 500ms,并开启log_queries_not_using_indexes,慢查询日志要定期归档分析。另一个很实用的做法是在监控平台配置一条告警规则:当Com_select的扫描行数与返回行数比值大于某个阈值时,直接推送告警。
5.3 代码审查规范
在团队开发规范里加一条硬性约束:列表和批量查询接口,一律禁止使用selectByExampleWithBLOBs和selectByExampleWithBLOBs的 Selective 变体;如果需要大字段,必须走主键查询或显式的自定义 SQL。使用 Lombok 的项目还要特别留意@ToString、@Data和日志埋点——在 AOP 里打印返回结果时,如果对象里含 content 这种大字段,日志文件可能一夜之间膨胀几个 GB,序列化本身也会消耗大量 CPU。
5.4 一个容易被忽视的隐性坑:结果对象的复用
很多人改完selectByExampleWithBLOBs之后,发现代码里并没有直接调用getContent(),但线上内存还是居高不下。这时要排查是不是把Article对象塞进了缓存,比如 Redis。虽然没调getContent(),但对象已经被完整填充,序列化进 Redis 时大字段照样会被写入。这种"