尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

MyBatis大字段查询引发慢SQL?selectByExampleWithBLOBs避坑指南

发布时间:2026/9/26 12:24:22

资讯中心
01
ARTICLE

MyBatis大字段查询引发慢SQL?selectByExampleWithBLOBs避坑指南

MyBatis大字段查询引发慢SQL?selectByExampleWithBLOBs避坑指南
1. 一次线上事故复盘列表页为什么突然卡了三秒先交代一下背景。前段时间接手了一个旧项目Spring Boot 2.x 搭配 MyBatis Generator 生成的通用 Mapper 代码。某个核心列表接口在压测时表现还不错QPS 大约 500 左右P99 延迟稳定在 500ms 以内。结果上线第二天运维那边报警说数据库慢查询暴增接口 P99 直接冲到 2.5 秒以上吞吐量掉了将近四成。查慢查询日志的时候发现一个很有意思的现象罪魁祸首是一条看起来非常普通的 SQL单表查询where 条件走了主键索引Limit 10 行。按道理这种 SQL 再怎么慢也不至于把数据库拖垮。直到我打开 EXPLAIN看到selectType: SIMPLE、type: ref、rows: 45230然后用SHOW PROFILE一看发现Sending data阶段耗时占了 92%才意识到问题根本不在于检索而在于取数。这条 SQL 长什么样呢就是 MyBatis Generator 自动生成的那堆方法里最不起眼的一个——selectByExampleWithBLOBs。很多项目里大家图省事列表查询直接用这个带 BLOB 的方法一把梭结果就是上面那场事故。这篇文章不打算讲什么高深原理就是把selectByExampleWithBLOBs这个“隐形炸弹”的引爆过程、排查手段、以及最终的改造方案完整地捋一遍。如果你项目里也用了 MyBatis Generator或者你正在犹豫列表查询到底该不该用带 BLOB 的 select 方法这篇内容值得你花五分钟看完。2. MyBatis 的 Example 方法族你以为的“省事”可能是在埋雷2.1 selectByExample 和 selectByExampleWithBLOBs 到底差在哪先说清楚最基本的概念。MyBatis GeneratorMBG生成代码时会针对每张表生成一套基于 Example 类的查询方法。最常见的三个查询方法是方法名查询字段范围适用场景selectByExample仅非 BLOB 字段列表展示、无大字段业务selectByExampleWithBLOBs全部字段包含 BLOB/TEXT 类型详情页、需要完整内容的场景selectByPrimaryKey全部字段仅按主键查询单行详情很多人对前两个方法的区别没啥概念潜意识里觉得“WithBLOBs 无非就是多查几个字段性能能差到哪里去”。但问题就出在这BLOB/TEXT 字段在 InnoDB 里的存储方式决定了它不能跟普通字段一样按常规方式读取。selectByExample生成的 SQL 大致是select id, user_name, age, status, create_time from user_info where (user_name ? and status ?) order by id desc limit 10而selectByExampleWithBLOBs生成的是select id, user_name, age, status, create_time, remark, avatar, content_blob from user_info where (user_name ? and status ?) order by id desc limit 10肉眼看上去就是多了两三个字段对不对问题在于这几个字段是TEXT或BLOB类型。当 InnoDB 遇到大字段时并不会像普通列一样老老实实地把所有数据都存在聚簇索引的行记录里而是有一套完全不同的存储策略。2.2 InnoDB 的大字段存储机制行溢出与外部存储InnoDB 默认页大小是 16KB聚簇索引的一个页通常要容纳多行数据。如果一个表的某一行里含了较大的 BLOB 字段把这整行塞进一个 16KB 的页里显然是不现实的。这时候 InnoDB 会采用行溢出策略如果某列的长度超过了阈值通常是页大小的一半即 8KBInnoDB 会把这一列的数据存到单独的溢出页中而在原行的记录里只保留一个 20 字节左右的指针或者叫局部前缀。但这里有一个关键细节即使 BLOB 数据没有超过 8KBInnoDB 也可能选择将它存在行内但这并不意味着性能没有影响。我做了一个最简单的基准测试拿一张 30 万行的业务表表里有一个remark字段是TEXT类型平均长度约 2KB最大 6KB。分别执行selectByExample不含 remark和selectByExampleWithBLOBs含 remark分页 limit 20测试结果查询方式平均耗时传输字节数缓冲池命中率selectByExample18ms约 3KB99%selectByExampleWithBLOBs86ms约 40KB87%耗时差了将近 5 倍还不是极端情况。如果 TEXT 字段更大更散差距还会继续扩大。原因其实不复杂第一聚簇索引的叶子页能承载的行数大幅减少。行内一旦塞了较大的变长字段一个 16KB 的页只能放下少数几行意味着同样一次范围扫描需要加载的页数量暴增。30 万行数据不含大字段时可能也就几千个页含大字段后可能膨胀到几万个页。第二溢出页的读取需要额外的 I/O 操作。即便是“行内存储”BLOB 超过一定阈值后仍然会触发二次读页。而且读取顺序与聚簇索引的顺序无关可能前一行读的是页 A下一行就要跳到页 Z 去这种随机 I/O 在机械硬盘时代是致命的在 SSD 上虽然好一些但依然显著增加了 IOPS 消耗。第三网络传输的放大效应。列表接口一般只需要展示少量字段比如名称、状态、时间结果你把用户根本不会在列表页看到的签名、头像、备注全文都捞出来了。20 行数据可能从 3KB 膨胀到 40KB放大十几倍。这在本地感觉不明显一旦到了跨机房、跨云的真实网络环境下延迟和带宽成本都会成倍增长。这也是为什么很多团队在数据库规范里会明确一条列表页查询不要 select 大字段如果非要展示单独二次查询或走缓存。2.3 被忽视的默认调用链还有一个更隐蔽的点MBG 生成的代码里selectByExample和selectByExampleWithBLOBs在某些版本里是有内部关联的。如果你在 service 层调用了selectByExampleWithBLOBs但你在 Example 的条件里只用了几个普通字段的等值条件MySQL 依然会执行全字段的扫描——它可不会替你聪明地“只查你用得上的字段”。更坑的是有些同事在写代码时混淆了这两个方法本意是“查询用户列表显示用户名和头像”结果因为看到 IDE 自动补全里selectByExampleWithBLOBs排在前面顺手就选了带 BLOB 的那个整个列表接口就被拖下水了。3. 从慢查询日志到火焰图一次完整的性能定位实录3.1 第一现场慢查询日志和数据库监控回到事故现场。线上出问题后我第一件事是拉慢查询日志。日志里很快锁定了这条 SQLSELECT id, user_name, age, status, remark, avatar, content_blob FROM user_info WHERE (user_name test_user and status 1) ORDER BY id DESC LIMIT 10耗时显示 2.1 秒扫描行数 45352 行。单看扫描行数4 万多行并不算多但耗时两秒就离谱了。这时候千万别急着去优化索引——先看它到底在慢在哪里。我用 MySQL 自带的分析工具继续追SET profiling 1; SELECT id, user_name, age, status, remark, avatar, content_blob FROM user_info WHERE user_name test_user AND status 1 ORDER BY id DESC LIMIT 10; SHOW PROFILE FOR QUERY 1;结果非常典型阶段耗时比例Sending data91.2%Statistics3.8%Creating sort index2.9%其他2.1%Sending data阶段耗掉九成时间基本可以判定**MySQL 已经完成行匹配正在把符合条件的数据从存储引擎里捞出来返回给客户端。**这个阶段慢通常就是“取数”环节出了问题而不是索引选择的问题。这里穿插一个排查心得很多人一看到 SQL 慢就复制到 Navicat 里加索引这其实是本末倒置。慢查询日志只告诉你“这条 SQL 慢”但到底慢在优化器解析、索引扫描、还是数据读取完全靠猜。用 profiling 才能把这个模糊的“慢”拆解成具体的阶段耗时定位才有依据。3.2 深入验证EXPLAIN 和页读取分析接着看执行计划EXPLAIN SELECT id, user_name, age, status, remark, avatar, content_blob FROM user_info WHERE user_name test_user AND status 1 ORDER BY id DESC LIMIT 10;结果type: refkey: idx_user_namerows: 45230。索引本身没问题条件列走了索引匹配了 4 万多行然后排序取前 10。关键点在于MySQL 需要先按索引把符合user_name test_user的所有主键找到并排序然后回表读取完整行数据。回表时因为行记录里带了 TEXT/BLOB 字段每个主键对应的一行可能分布在不同的数据页上而且部分大字段数据存储在溢出页中——于是 “取前 10 行” 这个操作变成了“在 4 万多行里做文件排序再随机读几十个页”。我在测试环境复现了这个场景用innodb_buffer_pool统计页读取情况SHOW STATUS LIKE Innodb_buffer_pool_read_requests; SHOW STATUS LIKE Innodb_buffer_pool_reads;同样的查询去掉 BLOB 字段后逻辑读次数从 5800 降到 180 次左右物理读基本清零——因为整张表的小字段数据几乎全在缓冲池里。而加了 BLOB 字段后因为数据页总大小膨胀缓存命中率骤降物理读次数爬到 400 多。这个数据直接说明了性能下降的本质**不是“查了 4 万行”慢而是“为了取这 10 行的完整字段把 4 万行对应的数据页几乎全扫了一遍外加大量随机盘片访问”。**这就是 80% 性能损耗的真相。3.3 火焰图的辅助验证如果用的是 Java 技术栈还应该从 JVM 侧再交叉确认一次。用 async-profiler 抓一下接口的热点通常会看到两类典型的调用栈如果瓶颈在 JDBC 驱动和 MySQL 通信层火焰图上是com.mysql.cj.jdbc相关的 packet 读写热点如果瓶颈在数据库端火焰图上的 Java 侧热点反而不明显GC 时间可能上升——因为大量 TEXT/BLOB 数据被读进堆里紧接着又被当成垃圾回收。我这边的情况是第二种。压测时 Full GC 次数从原来的每小时两三次涨到每分钟将近一次Young GC 更是多了一倍不止。原因也好理解列表接口原本每次请求返回 3KB 数据改版后变成 40KB翻了十几倍响应对象里全是字符串和 byte[]堆压力可想而知。到这里问题定位已经非常清楚了数据读取量放大 溢出页随机 I/O 堆内存压力飙升三重因素叠加把一个原本轻巧的列表查询硬生生拖成了“数据库级别的灾难”。4. 实战改造selectByExampleWithBLOBs 的正确打开方式4.1 能不用就不用列表查询彻底告别 BLOB 字段事故复盘完第一轮改造最简单粗暴把 service 层所有列表查询里误用的selectByExampleWithBLOBs全部改成selectByExample。这个操作收益很高改动量也小但对于真正需要 BLOB 字段的详情页查询还是要保留带 BLOB 的方法。改造后的伪代码大概是这样的// 老代码列表接口误用了带BLOB的方法 ListUserInfo list userInfoMapper.selectByExampleWithBLOBs(example); // 新代码列表只查非BLOB字段 ListUserInfo list userInfoMapper.selectByExample(example); // 详情页需要完整数据时再单独查 UserInfo detail userInfoMapper.selectByPrimaryKey(userId);这一步改造之后接口 P99 从 2.5 秒降回 420ms 左右数据库慢查询基本消失。效果显著但我知道这只是“治标不治本”——因为整个代码库里仍有很多地方在用带 BLOB 的查询只是暂时没踩到雷。有个小工具可以帮你快速排查哪些 Mapper 方法被调用了。项目里如果大量使用 MyBatis可以在 IDE 里全局搜索WithBLOBs把搜索结果过一遍凡是在列表、批量查询、分页查询里出现的调用全部列为改造对象。4.2 按需查字段把通用 Example 换成自定义查询如果觉得“要么全查、要么不查”太生硬还有第二种方案针对特定的业务场景在 Mapper XML 里写自定义 SQL只查你真正需要的字段。举例来说列表页其实只需要id, user_name, age, status, create_time这几个列那就自己写一个select idselectUserSummary resultTypecom.example.dto.UserSummaryDTO SELECT id, user_name, age, status, create_time FROM user_info where if testuserName ! null and userName ! AND user_name #{userName} /if if teststatus ! null AND status #{status} /if /where ORDER BY id DESC LIMIT #{limit} /select对应的 Mapper 接口方法ListUserSummaryDTO selectUserSummary(Param(userName) String userName, Param(status) Integer status, Param(limit) Integer limit);这种方案的本质思想是不要让 ORM 替你做字段选择的决定而是让业务显式声明“我要什么”。MyBatis Generator 的通用方法解决的是“零配置快速开发”但它默认查全部字段这件事在大字段场景下几乎必然成为性能瓶颈。改完之后最好在 DAO 层的测试里断言返回字段防止后续有人为了图省事又改回通用方法。4.3 大字段单独拆表一劳永逸的物理级方案上面的方案都是在“查询方式”上做文章但根本性的解法还是不要把 BLOB/TEXT 大字段和核心业务字段放在同一张表里。正常的设计是拆成两张表user_info核心业务字段 外键 user_iduser_info_extra存放remark、avatar、content_blob等大字段和 user_info 通过 user_id 一对一关联查询时列表走小表详情页再 join 或二次查询大表。这在数据库设计层面就规避了行溢出问题也让日常查询的页密度回到正常水平。拆表的代价是多一次 join 或一次额外查询以及代码里多一层数据组装。但以我个人的经验这个代价非常值得。特别是数据量过千万的场景BLOB 字段留在原表里会导致整张表的缓存效率极差全表扫描基本是噩梦热数据根本放不进缓冲池。4.4 躲不开的 BLOB从存储策略上减负有些业务场景确实绕不开大字段比如用户上传的图片 Base64、富文本编辑器内容、JSON 大字符串这种时候有两个缓解手段一是把 BLOB/TEXT 字段改成VARCHAR配合压缩存储。MySQL 5.7 及以上支持 InnoDB 的页压缩和表压缩可以在建表时用ROW_FORMATCOMPRESSED。压缩之后原本行溢出触发概率会降低页内能塞下的行数也更多能明显改善读取性能。代价是 CPU 压缩解压的开销实测下来对读多写少的场景是净收益。二是把 TEXT/BLOB 内容挪到对象存储或者 CDN数据库里只存一个 URL 或者摘要。这已经是很多现代应用的标配做法。富文本编辑器里的长文存到对象存储后数据库只留一个 key查询快、备份小、扩展还方便。如果你的系统已经接了云存储这个改造几乎是零成本的。5. MyBatis 面试里绕不开的关联坑分页插件和缓存聊到 MyBatis 性能就绕不开两个面试高频话题分页插件和缓存。其实它们和selectByExampleWithBLOBs的坑是强关联的这里一起说透。5.1 分页插件 PageHelper 使用时的隐性放大很多项目用 PageHelper 做分页。PageHelper 的原理是拦截执行 SQL改写 SQL 生成一个带LIMIT的查询同时再跑一条COUNT查询获取总数。问题来了如果你在业务层写的是userInfoMapper.selectByExampleWithBLOBs(example)然后套了一个PageHelper.startPage(1, 10)PageHelper 会老老实实把SELECT * 全字段 LIMIT 10交给 MySQL。BLOB 字段的读取放大效应完全没有被分页解决。更麻烦的是COUNT查询在含大字段的表上也不轻松。SELECT COUNT(*)理论上只需要读索引但如果 MySQL 优化器选择的执行计划不是走覆盖索引而是走了主键扫描那就会把大字段所在的聚簇索引页全部翻一遍COUNT 都能慢到几百毫秒。针对这个问题我的建议是两件事分页场景不要用带 BLOB 的通用方法这是铁律如果启用 PageHelper尽量让表有覆盖索引。把WHERE条件和ORDER BY涉及的列联合起来建索引让 COUNT 尽量走索引覆盖。PageHelper 还有一个坑它会自动检测 SQL 是否包含ORDER BY如果没有会追加ORDER BY id不同版本行为略有差异。如果你的表主键不是自增的而你又没有显式指定排序字段分页结果可能不稳定这个在日常开发里很容易被忽视。5.2 MyBatis 一级缓存和二级缓存的失效与放大MyBatis 的一级缓存是 SqlSession 级别的默认开启。Spring 集成环境下SqlSession 一般是每次请求新建一级缓存的生命周期极短大多数时候帮不上忙但也不至于出错。二级缓存则是 Mapper 级别的可以跨 SqlSession 共享。但有个常见的坑当你对某张表的任意数据做了 insert/update/deleteMyBatis 都会清空该表的二级缓存。回到我们的场景如果user_info表启用了二级缓存而查询时用的是selectByExampleWithBLOBs缓存里存放的是包含大字段的完整对象。这个对象占用的堆空间比普通 DTO 大得多而且因为包含 TEXT 内容序列化到 Redis 或本地磁盘的系统开销也大。最坏的情况是——缓存命中率不高、缓存对象又大反而把内存和带宽耗尽性能比不开启还差。所以在含 BLOB 字段的表上我的建议是谨慎开启二级缓存如果要开最好只缓存精简 DTO不要缓存完整的 Entity。另外提醒一个细节MyBatis 的二级缓存默认是PerpetualCache存本地内存。如果你用了 Redis 做二级缓存比如 mybatis-redis 这类扩展注意序列化方式。默认 JDK 序列化的 TEXT/BLOB 对象体积膨胀非常明显建议用 Protostuff 或 Kryo 这类紧凑序列化方案。5.3 缓存查询的隐性问题刷新时机还有一个和缓存相关但经常被忽略的点MyBatis 缓存是对Mapper命名空间维度的。如果你在 A Mapper 里查了user_info的数据然后在 B Mapper 里直接写了 update 语句更新同一张表跨命名空间B 的更新不会清空 A 的缓存下次查询 A 命中的就是脏数据。这个问题在大字段场景尤其坑——你以为缓存里放的是最新的大字段内容实际可能是几个小时后才刷新的旧值。处理方式一般是要么所有对该表的写操作都走同一个 Mapper要么干脆关闭二级缓存用业务自己的缓存方案比如 Caffeine 手动失效。6. 慢 SQL 排查工具箱一条 SQL 慢该从哪查起这个问题在面试里也很常见结合这次的实战场景我整理了一套自己的排查顺序从快到慢、从粗到细。第一步先看执行计划。慢查询日志里抓到 SQL 之后复制出来跑一遍 EXPLAIN。重点看type和rowstype如果是ALL、index优先考虑索引问题rows如果远大于实际返回行数说明筛选率有问题。第二步看 profiling 阶段耗时。这一步最容易被人跳过但其实收益极大。MySQL 8.0 直接用EXPLAIN ANALYZE就能看到每个算子实际消耗的时间和行数一台普通机器上直接跑就行EXPLAIN ANALYZE SELECT id, user_name, age, status, remark, avatar, content_blob FROM user_info WHERE user_name test_user AND status 1 ORDER BY id DESC LIMIT 10;EXPLAIN ANALYZE输出里会显示actual time一眼就能看出是索引扫描慢、还是回表取数慢。第三步检查网络和客户端。如果数据库端执行计划没问题、profiling 耗时也不高但应用层还是慢那就要看网络往返。比如 JDBC 连接池不够、SQL 传输字节数大、或者应用服务器和数据库之间的网络带宽不足。这时候可以用tcpdump抓包看 MySQL 协议包的流量或者用jdbcUrl里加useCompressiontrue压缩传输。第四步查服务端日志和监控。数据库的innodb_row_lock_current_waits、threads_running、buffer_pool_reads这些指标翻一遍确认是不是有并发锁竞争或者缓冲池不够。第五步考虑表结构和存储策略。如果以上都没问题那就是表设计本身的病。回到我们这次的案例——索引、执行计划、客户端全部正常唯独因为表里带了 BLOB/TEXT 字段行溢出导致数据页膨胀缓存命中率掉到头。这种问题靠调 SQL 和加索引解决不了必须从表结构设计层面解决。7. 常见问题速查表这些坑你可能正在踩为了避免你在实际开发里反复踩坑我把这次事故相关的问题整理成了一张速查表按“症状-原因-解法”的格式列出方便收藏。症状可能原因解决方案列表接口查询慢但 EXPLAIN 显示走索引Mapper 方法用了selectByExampleWithBLOBs读取了大字段改成selectByExample或自定义精简字段查询Sending data阶段耗时占比超 80%InnoDB 行溢出大字段存储在额外页中触发大量随机 I/O大字段拆表单独存储或启用压缩行格式分页查询第一页快、往后的页越来越慢PageHelper 的 COUNT 扫描了大量聚簇索引页为 COUNT 查询创建覆盖索引或用其他分页方案应用堆内存飙升GC 频繁列表中包含大字段对象响应体积膨胀十几倍列表接口禁止查询 BLOB/TEXT 字段详情页再查同一条 SQL 测试环境快、生产环境慢生产环境数据量增长缓冲池命中率下降行溢出页读取放大检查innodb_buffer_pool_size必要时拆表或加缓存开启二级缓存后命中率很高但接口还是慢缓存对象包含大字段序列化/反序列化开销大缓存 DTO 而不是 Entity或改用紧凑序列化方案Mapper 缓存命中脏数据多 Mapper 跨命名空间更新同一张表缓存未刷新统一写操作入口或关闭二级缓存这张表里的每一行都是我实际在项目中遇到并解决过的问题。尤其是前两行几乎可以当成 MyBatis 性能面试的“送分题”来准备。8. 我个人踩过几次坑之后的一些体会先说结论selectByExampleWithBLOBs这个方法本身没有错错的是在不该用的场景里用了它。从我这些年的经验看MyBatis Generator 生成的通用方法是把双刃剑。它确实省了建 Mapper XML 的功夫但也让很多人习惯了“一个方法查全表”的思维方式反而忽略了 SQL 的本质——查询效率取决于读取的数据量而不是写了多少行代码。当你发现一条 SQL 耗时几十毫秒和几百毫秒的差别根本不在索引、不在 SQL 写法而在于“多取了几个用不到的字段”时就会明白这条经验有多值钱。最后再分享一个小技巧如果你的项目还有大量地方在用selectByExampleWithBLOBs但短期没精力全部改造可以先在数据库层加一条规范——在 CI 或者代码评审阶段用脚本扫描 Mapper XML 文件里的select语句凡是select字段里包含blob或text类型列且语句没有where id ?这样明确的主键条件直接标记为“待优化项”。这个动作花不了多少时间但能在代码阶段就拦住很多性能炸弹。顺带说一句日常开发中我还是建议多看一眼 MyBatis 生成的 SQL 到底查了哪些列别把“ORM 自动生成”当作“自动做对”。工具帮我们省下来的时间得花一部分在理解它真正做了什么上面否则迟早要还回去。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。