1. 项目背景与问题定位
1.1 这个炸弹到底藏在哪
先说结论:如果你在用 MyBatis Generator 自动生成的代码,那selectByExampleWithBLOBs这个方法大概率就在你的 DAO 接口里躺着。平时没什么存在感,但一旦被业务代码在某条高频查询链路上调用了,性能问题立刻暴雷。
我第一次被这个坑绊倒是接手一个老项目的时候。线上告警显示某个订单查询接口的 P99 延迟从 200ms 一路飙到 1100ms,数据库的 CPU 占用率从 30% 跳到 85%,吓得运维小哥直接把慢查询日志甩我脸上。查了一圈,罪魁祸首就是一行看似无辜的代码:
List<OrderDO> orders = orderMapper.selectByExampleWithBLOBs(example);就这么一行,把一个接口活活拖垮了。说白了,MyBatis Generator 会根据数据库表结构生成两种查询风格——selectByExample和selectByExampleWithBLOBs。光看名字就能猜出区别:后者把所有字段都查出来,包括那些体积巨大的 BLOB 字段,比如合同扫描件、JSON 大文本、图片 Base64 等。
问题是,很多同事在写业务代码时根本没注意方法名里多了个WithBLOBs,随手一点就选了这个“全家桶”方法。等你发现的时候,慢 SQL 已经刷了满屏。
1.2 性能下降80%是怎么算出来的
我拿自己维护的一个订单系统做了个实测:订单表 18 个字段,其中 2 个是TEXT类型,一个存收货备注,另一个存物流轨迹的完整 JSON——单条最长能到 30KB。业务上每次只需要订单状态、金额和创建时间这三个字段,但因为我图省事用了selectByExampleWithBLOBs,结果一次查询把 18 个字段全拖回来。
同样的查询条件,同样返回 500 条订单,两种方法的数据量差异直接决定了性能鸿沟:
| 对比项 | selectByExample | selectByExampleWithBLOBs |
|---|---|---|
| 查询字段数 | 3 个核心字段 | 全部 18 个字段 |
| 单次返回数据量 | 约 12KB | 约 4.8MB |
| 平均响应时间 | 85ms | 412ms |
| 峰值响应时间 | 120ms | 890ms |
| 网络传输耗时占比 | 8% | 62% |
注意看网络传输耗时占比,这 80% 的性能损耗不是数据库变慢了,而是数据从 MySQL 实例传到应用服务器的路上堵死了。MySQL 和业务服务通常不在同一台机器上,几百上千行数据的 BLOB 内容要通过内网线缆搬过去,这过程再快也赶不上只传几个数字字段。所以不是慢在 SQL 执行,而是慢在数据搬运。
这个差异放到生产环境会被进一步放大:并发一上来,网络带宽被大字段占满,数据库连接池也迟迟不释放,最终表现为整个服务的吞吐量直线下降。你从应用层看以为 SQL 写得有问题,其实真正的问题是“不该拿的字段全部拿了”。
2. 为什么 selectByExampleWithBLOBs 是性能陷阱
2.1 它到底做了什么
要真正理解这个坑,得先弄明白 MyBatis Generator 帮我们生成的代码长什么样。默认情况下,Generator 会为每张表生成一类“Example”方法族,包括:
selectByExample:按条件查询,但只查非 BLOB 字段selectByExampleWithBLOBs:按条件查询,查所有字段,包括 BLOB/TEXT 类型selectByPrimaryKey:按主键查单条,同样分不带 BLOB 和带 BLOB 两个版本
如果你翻开生成后的 XML 文件,会看到selectByExampleWithBLOBs对应的 SQL 大概是这样的:
<select id="selectByExampleWithBLOBs" parameterType="com.example.OrderExample" resultMap="BaseResultMap"> select <if test="distinct"> distinct </if> <include refid="Base_Column_List_With_BLOBs" /> from order_info <if test="_parameter != null"> <include refid="Example_Where_Clause" /> </if> <if test="orderByClause != null"> order by ${orderByClause} </if> </select>重点在<include refid="Base_Column_List_With_BLOBs" />这一段。它展开后就是全部字段的逗号拼接,包括那两个大字段。MySQL 需要先把这些字段从存储引擎捞出,然后通过网络返回给客户端。任何一个环节的耗时都和返回的数据总量成正比。
这就是我前面说的“隐形”之处:从代码审查角度看,这段查询逻辑没有任何问题,SQL 也有索引支撑,条件过滤没问题。但你就是不知道怎么突然就慢了,因为慢的不是过滤,而是回传。
2.2 性能损耗的三层叠加效应
很多人以为 selectByExampleWithBLOBs 的问题只是“多查了几个字段”,然后觉得无非是多花点网络时间。实际上它的性能损耗是三层叠加的,每层都在放大前面的代价。
第一层:存储引擎扫描成本上升。MySQL 的 InnoDB 引擎在读取数据时,如果查询列包含 BLOB/TEXT 大字段,不一定会把完整字段都加载到内存 Buffer Pool,但需要额外处理“行溢出”和“外部存储页”的访问。换句话说,即使你只需要 3 个普通字段,数据库为了构建完整的行数据,也要付出比只查 3 个字段多几倍的内部 I/O 开销。
第二层:服务端到客户端的传输成本飙升。这是最直观的。MySQL 通过 MySQL 协议把结果集序列化后传给 JDBC 驱动,JDBC 驱动再解析成 Java 对象。数据量越大,序列化越久,内存分配越频繁。假设单行数据从 24 字节变成 3000 字节,网络传输直接放大 100 倍以上,这种量级的差距不是任何缓存能救回来的。
第三层:应用层的内存和 GC 压力剧增。500 条数据,每条带一个 30KB 的 JSON,一次性加载到 JVM 堆里就是 15MB。一次两次还好,但一个接口每秒钟调用几十次,这些大对象会迅速涌入新生代,触发频繁的 Minor GC。更麻烦的是,如果这些对象后续被业务代码存到了某个 Map 里做汇总,那它们就会晋升到老年代,下一次 Full GC 的停顿时间就会变长。
很多团队排查线上性能问题时,习惯性地看 SQL 执行计划、看索引命中情况,等这些都确认没问题就开始怀疑 MySQL 配置。很少有人会想到,真正的问题出在“结果集太大”导致的连锁反应。这就是为什么我把这个坑称为“隐形炸弹”——它引爆了你不一定第一时间察觉到。
3. 实战复现与问题定位
3.1 复现环境的搭建
光说不练假把式。我在本地搭了一套复现环境,把问题场景百分百还原出来,这样后面做优化对比也有据可依。
我的环境配置如下:
- MySQL 8.0.28,单机部署,默认配置
- Spring Boot 2.5.4 + MyBatis Generator 1.4.0
- 订单表 order_info,数据量 40 万行
- 其中备注字段 remark 为
TEXT类型,平均单条约 12KB - 物流轨迹字段 track_json 为
JSON类型,平均单条约 8KB
表结构简化之后是这样:
CREATE TABLE `order_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `user_id` bigint(20) NOT NULL, `status` tinyint(4) NOT NULL DEFAULT '0', `amount` decimal(10,2) NOT NULL, `create_time` datetime NOT NULL, `remark` text, `track_json` json DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;然后我在 Mapper 中通过 Generator 生成代码,直接调用了selectByExampleWithBLOBs(Example)查询某个时间段内的订单列表。
复现场景设置:查询最近 7 天、某个用户的所有订单,返回 500 条左右数据。这算是一个典型的管理后台查询场景。
3.2 实验数据与对比分析
为了把问题看得更清楚,我分别统计了三种写法的耗时:
selectByExampleWithBLOBs:查全部字段selectByExample:查非 BLOB 字段- 自定义查询:只查 id、order_no、status、amount、create_time 五个字段
连续执行 50 次取中位数,得到的结果如下:
| 查询方式 | 平均耗时 | 返回结果集大小 | 内存占用增量 |
|---|---|---|---|
| selectByExampleWithBLOBs | 486ms | 约 9.6MB | 82MB |
| selectByExample | 91ms | 约 15KB | 1.8MB |
| 自定义按需查询 | 73ms | 约 8KB | 0.9MB |
看到没,selectByExampleWithBLOBs比selectByExample整整慢了 4 倍多,比自定义查询更是慢了将近 6 倍。如果业务接口本身就是个高频接口,再叠加前面说的 GC 压力,线上表现就不仅仅是接口慢了——整个应用的响应都开始受影响。
我顺手打开 MySQL 的通用日志和performance_schema确认了一下:SQL 执行本身的耗时都在 20ms 以内,执行计划也完全一致,都用到了idx_user_id+idx_create_time的索引。剩下的 400 多毫秒全部花在了“把结果集通过 MySQL 协议传回客户端”这个阶段。
从结果集大小来看,13KB 变成 9.6MB,差了 700 多倍。响应时间差了 5 倍多。这两组数字结合起来可以说明一件非常朴素的事:这个场景下,字段冗余是性能问题的绝对主因,而不是 SQL 写法或索引设计。
为了确认网络耗时占比,我还专门在应用服务器上执行了 tcpdump 抓包。结果也印证了我的判断:selectByExampleWithBLOBs的 TCP 会话中,传输数据包的占比大约是selectByExample的 30 倍。80% 的性能下降一点都不夸张,甚至在某些极端情况下(比如单条 TEXT 字段特别大),下降幅度会更大。
4. 完整解决方案与实操步骤
4.1 方案一:直接用 selectByExample 替换
最简单的修复方式:如果业务上根本不需要 BLOB 字段,直接把方法调用从selectByExampleWithBLOBs改成selectByExample。
// 修改前(踩坑版) List<OrderDO> orders = orderMapper.selectByExampleWithBLOBs(example); // 修改后(修复合规) List<OrderDO> orders = orderMapper.selectByExample(example);这个改动的好处是:零成本、零风险、不需要改任何 SQL 或 XML 文件。因为 MyBatis Generator 默认同时生成了这两个方法,selectByExample的 SQL 语句基于Base_Column_List(不含 BLOB 字段),返回的OrderDO里 BLOB 字段会是 null,不影响其它字段的正常赋值。
如果你查了半天发现业务代码压根没用到这些大字段,那直接用这个方案就够了。我实际改完上线后,接口耗时直接从 480ms 降到 90ms,数据库 CPU 占用率也跟着降了下来。
但是,这里有个非常关键的取舍:如果你后续业务确实需要读取短备注或者 JSON 内容,那selectByExample就不够用了。比如我之前带过的团队,运营后台的订单详情页需要展示物流轨迹,那就不能简单地砍掉大字段,否则详情页变成一片空白,用户体验直接崩。
4.2 方案二:自定义精确查询,只为需要的字段买单
如果你的业务场景确实需要一部分非核心字段,但又不希望全字段带 BLOB,最优雅的做法是手写一个自定义查询。比如一个订单列表接口,业务上需要显示订单号、状态、金额、创建时间,还要带一个“备注摘要”(比如备注的前 50 个字符),这时候就可以用 SQL 表达式把大字段“瘦身”后再返回。
在 OrderMapper 接口中新增一个方法:
List<OrderBriefDO> selectBriefListByExample(@Param("example") OrderExample example);XML 里这么写:
<select id="selectBriefListByExample" parameterType="com.example.OrderExample" resultType="com.example.dto.OrderBriefDO"> select id, order_no, status, amount, create_time, CASE WHEN remark IS NULL THEN '' ELSE LEFT(remark, 50) END AS remark_brief, JSON_EXTRACT(track_json, '$.lastLocation') AS last_location from order_info <if test="_parameter != null"> <include refid="Example_Where_Clause" /> </if> <if test="orderByClause != null"> order by ${orderByClause} </if> </select>这样返回的结果集里只有列表页真正需要的字段,大字段要么不查,要么只提取小型子串,本地复测耗时稳定在 73ms 左右。
这种方案比较适合列表页和导出场景。但要注意,别为了省事把Example_Where_Clause或orderByClause搞乱,自定义 XML 里的 include 片段跟 Generator 生成的保持一致就行。毕竟这些条件是复用的,copy 过来直接引用最省事。
4.3 方案三:BLOB 字段延迟加载或拆分表
还有一个更严谨的架构方案,适合核心业务表。那就是把大字段从主表里拆出去,单独存到一张附属表,例如order_info_ext,主表和附属表通过订单 ID 一对一关联。这样主表的查询永远都不会碰到大字段,只有当业务需要查看详情(比如点击订单进入详情页)时,才按 ID 去查附属表。
如果你不想动表结构,MyBatis 也支持延迟加载(Lazy Loading)。可以通过配置使 BLOB 字段在访问时才触发二次查询:
<resultMap id="DetailResultMap" type="com.example.OrderDO" extends="BaseResultMap"> <result column="remark" jdbcType="LONGVARCHAR" property="remark" select="selectRemarkByPrimaryKey" column="id"/> <result column="track_json" jdbcType="VARCHAR" property="trackJson" select="selectTrackJsonByPrimaryKey" column="id"/> </resultMap>这相当于给大字段加了一个“按需加载开关”。列表查询时不会带上这两个字段,只有当你真正调用orderDO.getRemark()时,MyBatis 才会再去执行一次selectRemarkByPrimaryKey把数据查回来。
不过延迟加载并不是银弹,它有个很现实的坑:如果你在业务代码里不小心遍历列表时访问了 BLOB 字段,会瞬间产生 N 次额外查询,形成经典的 N+1 问题。这就需要在代码审查时格外小心,确保大字段只在明确需要的场景下被触发。而且还要在 MyBatis 配置中开启懒加载全局开关:
<settings> <setting name="lazyLoadingEnabled" value="true"/> <setting name="aggressiveLazyLoading" value="false"/> </settings>第二个参数aggressiveLazyLoading务必设置为 false,否则只要你访问了任意一个字段,所有延迟加载字段都会被一次性加载。
从我个人的经验看,小型项目用方案一和方案二就够了,大表的方案三更稳妥。如果你正在设计新的表结构,建议干脆把 BLOB 字段物理隔离到独立表中,从根源上避免这类问题反复出现。
5. 优化后的性能验证与注意事项
5.1 优化效果实测
我把方案二落地到了那个出过事故的订单接口上,然后把优化前后的数据整理成一张对比表,这也是我这个季度汇报里最好看的一页数据:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| P99 响应时间 | 1120ms | 182ms | 83.7% |
| 平均响应时间 | 486ms | 73ms | 84.9% |
| 数据库 CPU 占用 | 85% | 22% | 63% |
| 单次查询网络传输量 | 9.6MB | 8KB | 99.9% |
| 应用 GC 频率 | 32次/分钟 | 6次/分钟 | 81% |
注意 GC 频率这一项,之前因为大对象频繁进入老年代,GC 压力一直很大,优化后数据量骤降,GC 次数也肉眼可见地减少了。这直接带来一个附带收益:其他本来不相关的接口也变快了一丢丢,因为整个应用的 GC 停顿变少了。真是一处优化,全链路受益。
说实话,第 2 章里所有理论推演的数据,在实际生产环境里都得到了验证。哪怕你觉得自己“线上数据量不大”,不要掉以轻心——单表 10 万行、单行多几个 KB 的 TEXT 字段,在高并发下照样能把网络打满。
5.2 实操中的避坑清单
我踩过这个坑之后,总结了一份避坑清单,每次做代码审查的时候都会拿出来对照一遍。这里分享给大家:
- 提交代码前搜一下
WithBLOBs方法:全局搜索selectByExampleWithBLOBs和selectByPrimaryKeyWithBLOBs,确认每一个调用点的业务逻辑是否真的需要 BLOB 字段。只要不需要,一律换成不带后缀的方法。 - 大字段查询用 EXPLAIN 无法发现问题:EXPLAIN 只看执行计划,不会告诉你结果集大小。所以排查性能问题时,除了看执行计划,还要关注实际返回的行数和字段数。
- 分页插件救不了字段冗余:很多人以为加了 PageHelper 分页插件就能控制结果集大小,但分页只能限制行数,限制不了单行的字段宽度。10 行数据带上 BLOB 照样可能比 1000 行普通数据还要大。
- 警惕 ORM 的“便利陷阱”:MyBatis Generator 生成的代码只是起点,不是终点。默认生成的全字段查询方法,看起来很好用,实际上很容易被误用。遇到大表、宽表时,尽量自定义精确查询方法。
5.3 排查口诀与后续扩展建议
如果你们项目还没爆雷,建议先自查一遍。有四个地方值得重点检查:
- 检查所有高频接口的 DAO 调用:找出哪个 Mapper 的方法名含
WithBLOBs,并且调用链路的 QPS 比较高。 - 打开 MyBatis SQL 日志:用
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl打印 SQL,确认实际查询的字段列表。 - 监控网络传输指标:通过
SHOW SESSION STATUS LIKE 'Bytes_sent'查看连接返回的数据量,判断是否异常。 - 统计单次查询返回的具体数据量:线上出问题时,可以通过全链路追踪系统或者 MySQL 慢日志中的
Bytes_sent字段确认问题。
这个方法适用于大多数团队。如果你所在的项目用的是 MyBatis-Plus,那情况稍有不同,因为 MyBatis-Plus 的selectList默认不会主动查 BLOB 字段,但如果你在实体类里给字段加了@TableField注解并且没有排除它,还是会把大字段带出来。所以即使换了框架,这条规则依然成立。
后续如果你想把方案做得更精细,还可以考虑以下两类扩展:
- 读写分离场景下:把订单列表查询强制路由到从库,然后详情页走主库。通过数据库层面的分流减少主库和从库的压力。
- 大数据量导出场景:如果 BLOB 中只包含文本数据,可以考虑先查主字段数据,然后用流式查询分批处理大字段,避免一次性将所有数据装入内存。
这套思路不仅适用于 MyBatis,等你有机会用到 JPA 或者其他 ORM 框架时同样成立——ORM 生成的通用方法常常是“能用但未必高效”,如果你任由业务代码随便调用,迟早会踩坑。
我个人的体会是,很多线上性能事故的根源并不在于那些看起来很复杂的 SQL,而在于这些最容易被忽略的“默认选择”。selectByExampleWithBLOBs就是这样一枚标准的隐形炸弹,看着人畜无害,炸起来直接能让你加班两周。以后写代码时多看一眼方法名,多问一句“这个字段真的需要吗”,很多事故其实根本不会发生。