ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis性能陷阱:selectByExampleWithBLOBs优化实战

MyBatis性能陷阱:selectByExampleWithBLOBs优化实战 1. 项目背景与问题定位1.1 这个炸弹到底藏在哪先说结论如果你在用 MyBatis Generator 自动生成的代码那selectByExampleWithBLOBs这个方法大概率就在你的 DAO 接口里躺着。平时没什么存在感但一旦被业务代码在某条高频查询链路上调用了性能问题立刻暴雷。我第一次被这个坑绊倒是接手一个老项目的时候。线上告警显示某个订单查询接口的 P99 延迟从 200ms 一路飙到 1100ms数据库的 CPU 占用率从 30% 跳到 85%吓得运维小哥直接把慢查询日志甩我脸上。查了一圈罪魁祸首就是一行看似无辜的代码ListOrderDO orders orderMapper.selectByExampleWithBLOBs(example);就这么一行把一个接口活活拖垮了。说白了MyBatis Generator 会根据数据库表结构生成两种查询风格——selectByExample和selectByExampleWithBLOBs。光看名字就能猜出区别后者把所有字段都查出来包括那些体积巨大的 BLOB 字段比如合同扫描件、JSON 大文本、图片 Base64 等。问题是很多同事在写业务代码时根本没注意方法名里多了个WithBLOBs随手一点就选了这个“全家桶”方法。等你发现的时候慢 SQL 已经刷了满屏。1.2 性能下降80%是怎么算出来的我拿自己维护的一个订单系统做了个实测订单表 18 个字段其中 2 个是TEXT类型一个存收货备注另一个存物流轨迹的完整 JSON——单条最长能到 30KB。业务上每次只需要订单状态、金额和创建时间这三个字段但因为我图省事用了selectByExampleWithBLOBs结果一次查询把 18 个字段全拖回来。同样的查询条件同样返回 500 条订单两种方法的数据量差异直接决定了性能鸿沟对比项selectByExampleselectByExampleWithBLOBs查询字段数3 个核心字段全部 18 个字段单次返回数据量约 12KB约 4.8MB平均响应时间85ms412ms峰值响应时间120ms890ms网络传输耗时占比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 idselectByExampleWithBLOBs parameterTypecom.example.OrderExample resultMapBaseResultMap select if testdistinct distinct /if include refidBase_Column_List_With_BLOBs / from order_info if test_parameter ! null include refidExample_Where_Clause / /if if testorderByClause ! null order by ${orderByClause} /if /select重点在include refidBase_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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;然后我在 Mapper 中通过 Generator 生成代码直接调用了selectByExampleWithBLOBs(Example)查询某个时间段内的订单列表。复现场景设置查询最近 7 天、某个用户的所有订单返回 500 条左右数据。这算是一个典型的管理后台查询场景。3.2 实验数据与对比分析为了把问题看得更清楚我分别统计了三种写法的耗时selectByExampleWithBLOBs查全部字段selectByExample查非 BLOB 字段自定义查询只查 id、order_no、status、amount、create_time 五个字段连续执行 50 次取中位数得到的结果如下查询方式平均耗时返回结果集大小内存占用增量selectByExampleWithBLOBs486ms约 9.6MB82MBselectByExample91ms约 15KB1.8MB自定义按需查询73ms约 8KB0.9MB看到没selectByExampleWithBLOBs比selectByExample整整慢了 4 倍多比自定义查询更是慢了将近 6 倍。如果业务接口本身就是个高频接口再叠加前面说的 GC 压力线上表现就不仅仅是接口慢了——整个应用的响应都开始受影响。我顺手打开 MySQL 的通用日志和performance_schema确认了一下SQL 执行本身的耗时都在 20ms 以内执行计划也完全一致都用到了idx_user_ididx_create_time的索引。剩下的 400 多毫秒全部花在了“把结果集通过 MySQL 协议传回客户端”这个阶段。从结果集大小来看13KB 变成 9.6MB差了 700 多倍。响应时间差了 5 倍多。这两组数字结合起来可以说明一件非常朴素的事这个场景下字段冗余是性能问题的绝对主因而不是 SQL 写法或索引设计。为了确认网络耗时占比我还专门在应用服务器上执行了 tcpdump 抓包。结果也印证了我的判断selectByExampleWithBLOBs的 TCP 会话中传输数据包的占比大约是selectByExample的 30 倍。80% 的性能下降一点都不夸张甚至在某些极端情况下比如单条 TEXT 字段特别大下降幅度会更大。4. 完整解决方案与实操步骤4.1 方案一直接用 selectByExample 替换最简单的修复方式如果业务上根本不需要 BLOB 字段直接把方法调用从selectByExampleWithBLOBs改成selectByExample。// 修改前踩坑版 ListOrderDO orders orderMapper.selectByExampleWithBLOBs(example); // 修改后修复合规 ListOrderDO 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 接口中新增一个方法ListOrderBriefDO selectBriefListByExample(Param(example) OrderExample example);XML 里这么写select idselectBriefListByExample parameterTypecom.example.OrderExample resultTypecom.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 refidExample_Where_Clause / /if if testorderByClause ! 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 idDetailResultMap typecom.example.OrderDO extendsBaseResultMap result columnremark jdbcTypeLONGVARCHAR propertyremark selectselectRemarkByPrimaryKey columnid/ result columntrack_json jdbcTypeVARCHAR propertytrackJson selectselectTrackJsonByPrimaryKey columnid/ /resultMap这相当于给大字段加了一个“按需加载开关”。列表查询时不会带上这两个字段只有当你真正调用orderDO.getRemark()时MyBatis 才会再去执行一次selectRemarkByPrimaryKey把数据查回来。不过延迟加载并不是银弹它有个很现实的坑如果你在业务代码里不小心遍历列表时访问了 BLOB 字段会瞬间产生 N 次额外查询形成经典的 N1 问题。这就需要在代码审查时格外小心确保大字段只在明确需要的场景下被触发。而且还要在 MyBatis 配置中开启懒加载全局开关settings setting namelazyLoadingEnabled valuetrue/ setting nameaggressiveLazyLoading valuefalse/ /settings第二个参数aggressiveLazyLoading务必设置为 false否则只要你访问了任意一个字段所有延迟加载字段都会被一次性加载。从我个人的经验看小型项目用方案一和方案二就够了大表的方案三更稳妥。如果你正在设计新的表结构建议干脆把 BLOB 字段物理隔离到独立表中从根源上避免这类问题反复出现。5. 优化后的性能验证与注意事项5.1 优化效果实测我把方案二落地到了那个出过事故的订单接口上然后把优化前后的数据整理成一张对比表这也是我这个季度汇报里最好看的一页数据指标优化前优化后提升幅度P99 响应时间1120ms182ms83.7%平均响应时间486ms73ms84.9%数据库 CPU 占用85%22%63%单次查询网络传输量9.6MB8KB99.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-implorg.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就是这样一枚标准的隐形炸弹看着人畜无害炸起来直接能让你加班两周。以后写代码时多看一眼方法名多问一句“这个字段真的需要吗”很多事故其实根本不会发生。
返回列表