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返回ListArticle。不看生成的 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。在日志里翻到其中一条 SQLselect 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返回类型从ListArticle变成ListArticle时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_mainid、title、author_id、summary、create_time 等高频查询字段和article_contentarticle_id、content、version 等低频大字段列表查询永远只查article_main详情页两个表 join 或者分两次查询。这个方案彻底斩断了列表误查大字段的可能性是很多大型内容系统的主流做法。缺点是需要改表、改代码、做数据迁移工作量较大适合在项目相对稳定时推进。MyBatis 懒加载是折中方案。如果你暂时不想拆表可以在 XML 的resultMap里给 content 字段配置懒加载resultMap idResultMapWithBLOBs typeArticle result columncontent propertycontent jdbcTypeLONGVARCHAR fetchTypelazy/ /resultMap前提是 MyBatis 配置里开启了懒加载开关mybatis: configuration: lazy-loading-enabled: true这样调用selectByExampleWithBLOBs时content 字段不会立即加载只有在代码里真正article.getContent()时才会发一条 SQL 去查询。懒加载的坑在于潜在 N1 问题如果你在循环里对 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 tableNamearticle domainObjectNameArticle columnOverride columncontent jdbcTypeOTHER javaTypejava.lang.String typeHandlercom.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 时大字段照样会被写入。这种
