资讯详情

资讯详情

MyBatis性能陷阱:selectByExampleWithBLOBs如何拖垮接口响应

每次提到MyBatis的性能问题大多数人的第一反应是SQL没走索引、嵌套查询循环、或者分页没优化。但有一个特别隐蔽的坑藏在MyBatis Generator自动生成的代码里——selectByExampleWithBLOBs。这个方法的坑在于它不会报错不会报警甚至上线初期一切正常可一旦数据量上来或者某个大字段真装了大内容数据库响应直接从几十毫秒膨胀到几百毫秒接口RT能掉80%以上。更恼火的是排查方向不对的话你很难把它跟“慢SQL”联系起来因为从日志看执行的确实是同一张表、同一个查询条件。这篇文章就把这个“隐形炸弹”彻底拆开讲它是什么、为什么慢、慢在哪一层、以及上线前怎么自查、上线后怎么救。1. 先看清这颗炸弹的真面目selectByExampleWithBLOBs从哪来的1.1 MyBatis Generator自动生成的三类查询方法用过MyBatis GeneratorMBG的人都知道只要配置了generateCriteria它会为每张表生成一堆现成的查询方法。常见的有selectByPrimaryKeyselectByExampleselectByExampleWithBLOBsselectByPrimaryKeyWithBLOBscountByExampledeleteByExample这里面的关键区别在于selectByExample默认只查询非BLOB字段而selectByExampleWithBLOBs会额外把BLOB、CLOB、LONGTEXT这类大字段也查出来。问题就在这。一张表假如有20个字段其中16个是普通字段4个是text、mediumtext或longtext类型当你调用selectByExample时生成的SQL只会SELECT这16个普通字段而换成selectByExampleWithBLOBsSQL就变成了SELECT全部20个字段包括那几个动辄几十KB甚至几MB的大文本列。很多团队用MBG时直接采用默认配置甚至压根没意识到底层生成了哪些方法。业务代码里一搜索“WithBLOBs”往往就是一大片——这就是炸弹埋下的过程。1.2 一个典型的错误使用场景举个实际例子。某个后台管理系统的“订单备注”功能订单表设计时为了存放运营同学的长篇备注加了一个remark字段类型是longtext。正常情况下单条备注几百字撑死一两KB。后来运营开始把整个沟通记录、截图OCR文本都往里面塞单条数据奔着几十KB去了。而列表页代码是这样的OrderExample example new OrderExample(); example.createCriteria().andStatusEqualTo(1); ListOrder orders orderMapper.selectByExampleWithBLOBs(example);这行代码一执行数据库会把所有status1订单的ID、订单号、金额、状态、时间、以及全部备注大文本都查出来再通过JDBC驱动传回应用服务器最后还要在Java堆里为每个字符串分配内存。数据量两千条时勉强能撑两万条呢两百万条呢就算只查最近一个月的两万条每个备注30KB传输量就是600MB。数据库服务器到应用服务器的网卡直接被打满应用服务器的GC也扛不住——这就是性能下降80%最简单粗暴的来源。注意selectByExampleWithBLOBs本身没有错错的是在“不需要大字段”的场景使用了它。工具类方法太多业务代码拿来就用几乎没人去看生成的SQL长什么样。2. 性能下降80%背后的三层原因2.1 第一层数据读取量暴涨这是最直观的第一层问题。性能下降的本质是查询的实际数据量比“应该查的数据量”大了几个数量级。拿上面那个订单列表来说只需要列表展示字段id、order_no、amount、status、create_time单行数据可能就500字节。但加上BLOB字段后单行数据变30KB膨胀60倍。数据库存储引擎的读取粒度是数据页默认16KB一行30KB的数据可能导致跨多个数据页存储本来一个数据页能装三十多行现在一页只能装半行。InnoDB的扫描成本、回表次数、缓冲池命中率全线恶化。直观算一笔账两万行订单查普通字段约10MB数据从存储引擎读取。两万行订单查带BLOB字段约600MB数据读取。60倍的读取量差距反映到网络传输和内存占用上就是RT从30ms涨到200ms再叠加并发应用线程池被拖垮整体吞吐量下降80%一点都不夸张。2.2 第二层JDBC驱动与网络传输放大问题很多人只关注数据库执行SQL的时间忽略了数据传输的时间。MySQL Connector/J在读取大字段时需要把整个LONGTEXT内容完整读取到内存中拼成java.lang.String对象。这里有两个隐含成本字符串的底层是char[]每个char占2字节一个30KB的UTF-8文本转成Java String要占60KB内存。JDBC驱动的RowData内部会为每一行的每个列创建ByteArrayInputStream或String大字段越多临时对象越多Young GC频率越高。更隐蔽的是网络层。数据库连接默认走TCP一个MySQL实例如果与应用服务器之间存在中间链路比如云数据库通过内网DNS、Proxy访问大字段查询的网卡流量会翻倍MySQL协议传输中字段长度超过一定阈值会分多个packet传输应用服务器的接收缓冲区如果设置太小会触发多次读事件增加系统调用次数。这些统统不算在数据库执行时间里面但在慢SQL分析里会以“CPU高、GC频繁、网卡流量大”的形式暴露出来。2.3 第三层应用层Java内存与GC压力这是最容易被忽略的一层。selectByExampleWithBLOBs把大字段全部加载到内存后紧接着业务代码一般只取其中几个普通字段去渲染页面或组装DTO。那些大文本成了“一次性垃圾”。假设接口QPS是100每次查询拉回2万行、每行带30KB大字段JVM堆里同时存活的垃圾就是100 QPS × 2万行 × 60KB 120GB/秒 的垃圾对象产生速率这个数字在绝大多数服务器上是直接不可接受的。Young GC会疯狂触发甚至每次Minor GC都因为存活对象太多晋升到老年代老年代一满就是Full GC接口直接卡死。所以排查这种问题的时候不要只盯着数据库监控还要看应用服务器的GC日志和堆内存。我见过一个案例慢SQL监控里排名第一的是一条毫秒级的普通查询但服务整体RT高得离谱——后来一查就是某条“毫秒级”的大字段查询每秒来一次把JVM堆直接打爆了。注意selectByExampleWithBLOBs的慢是链式的存储引擎读取慢、网络传输慢、JDBC解析慢、Java堆分配慢最终表现为接口性能下降。任何一层都可能先撑不住。3. 什么场景最容易被这颗炸弹炸到3.1 列表页分页查询误用大字段方法最典型的场景就是管理后台列表。表格要展示10列结果代码里调了selectByExampleWithBLOBs。分页插件如PageHelper虽然能帮你做LIMIT但SQL里的LIMIT只限制返回行数并不限制列。你仍然把那些用不上的大字段从数据库拉到内存里了。更差的情况是某些人会在循环里调用分页查询——比如先查出所有用户ID再逐个分页查详情。这种代码配合WithBLOBs性能直接雪崩。3.2 导出功能一次性拉全量导出Excel、导出CSV这类需求天然需要全量数据。如果代码里直接用selectByExampleWithBLOBs把整张表拉出来再转成一个ListMap内存马上就爆。正确做法是用selectByExample查主键普通字段然后在流式查询ResultHandlerfetchSize设置为Integer.MIN_VALUE里按需读取。但前提还是别把不需要的大字段拉出来。3.3 循环查询导致的N倍放大有些业务代码长这样for (Order order : orderList) { OrderDetail detail orderDetailMapper.selectByPrimaryKeyWithBLOBs(order.getId()); // 业务处理 }一次循环N次查询每次查全字段。如果N等于100并发10个请求就是1000次大字段查询。这种问题用数据库连接池监控一眼就能看出来但问题定位后修复时通常就是把WithBLOBs换掉。3.4 缓存穿透时的大字段回源用Redis做缓存时缓存击穿或穿透后大量请求同时回源查数据库。如果查询方法带了大字段回源一次就够数据库喝一壶的。这种场景下你真正该缓存的是接口所需的DTO字段而不是整行大字段对象。4. 正确使用姿势什么情况下才该用WithBLOBs4.1 唯一合理场景确实需要完整大字段内容既然这个方法设计出来了自然有它的用武之地。正经的场景包括详情页展示完整正文内容文章详情、合同文本、履历信息后台审核人员需要查看完整备注或历史记录定时任务需要处理某个大字段的内容做文本分析。在这些场景下使用selectByPrimaryKeyWithBLOBs或selectByExampleWithBLOBs是合理的——反正你要的就是这副完整数据一次拿全反而节省二次查询。4.2 设计表结构时的字段隔离思路高手在设计表的时候就会考虑这个问题。把所有大字段单独拆到一张扩展表主业务表只保留高频访问的普通字段。这样即使用错了查询方法也不会把大字段拉进来。比如订单表拆成order订单ID、订单号、金额、状态、创建时间等高频字段order_extra订单ID、备注大文本、图片URL、扩展JSON等低频大字段。两个表通过订单ID关联列表查询查order表详情查询再查order_extra。这种做法牺牲了一点查询便利性换来的却是主链路稳定性和可预测的性能边界。4.3 显式指定字段列绕过Generated方法如果表结构一时半会改不了那就别依赖MBG生成的Example方法。直接在Mapper接口里写一个自定义方法用注解或XML显式指定需要的列Select({ select id, order_no, amount, status, create_time, from orders, where status #{status} }) ListOrderSummary selectSummaryByStatus(Param(status) Integer status);好处有两个SQL意图明确后续走查一眼就能看出这个查询只取列表需要的字段返回对象可以单独定义避免和完整实体混淆。就算某天order表加了字段这个列表查询也不受影响。4.4 流式查询大字段时的fetchSize设置如果真的需要全量处理大字段比如数据迁移、定时统计用普通的selectByExampleWithBLOBs会把全部结果一次性装载到内存高概率OOM。这时候必须采用流式处理mapper.selectByExampleWithBLOBsWithRowHandler(example, resultContext - { Order order (Order) resultContext.getResultObject(); // 逐条处理不积压 });然后在XML或注解里配合设置fetchSizeOptions(fetchSize Integer.MIN_VALUE) Select(select * from orders where status #{status}) ListOrder selectLargeData(Param(status) Integer status);Integer.MIN_VALUE这个特殊值是MySQL JDBC驱动触发流式读取的约定值它会让驱动逐行从服务器读取而不是一次性缓存全部结果。注意这个值只对MySQL有效其他数据库如PostgreSQL要用别的配置。建议无论什么场景写完SQL后先用EXPLAIN看执行计划再实际打印一次PreparedStatement的完整SQL和执行参数确认SELECT的字段列表符合预期。5. 如何快速排查线上是否踩了这颗炸弹5.1 从慢查询日志定位MySQL的慢查询日志会记录执行时间超过long_query_time的SQL。搜出来的慢SQL大概率长这样SELECT id, order_no, amount, remark, create_time, ... FROM orders WHERE status ?注意SELECT字段列表里是否出现了remark、content、detail这类命名的大字段。如果业务代码没用selectByExampleWithBLOBsSQL里一般是不会出现这些字段的。5.2 从TCP抓包或数据库监控看返回包大小很多云数据库监控面板支持查看“返回行数”“返回字节数”这两个指标。如果发现某条查询的返回字节数异常巨大比如单次查询返回几十MB这就是大字段查询的典型特征。5.3 从GC日志倒推如果应用出现频繁的Young GC且GC后存活率极低说明Java堆里产生了大量短命大对象。用jmap -histo:live查看最占用内存的对象类型如果看到大量char[]和String再结合业务代码搜索一下WithBLOBs调用点基本就能锁定问题。5.4 用Arthas定位线上调用链阿里开源的Arthas可以直接在线上环境查看某个Mapper接口的调用栈。操作方法# 监控Mapper方法执行耗时 watch com.example.mapper.OrderMapper selectByExampleWithBLOBs {params, returnObj} -x 3 -n 5如果线上环境的Mapper方法调用频率和耗时都异常马上就能确认是哪条业务链路使用了这个方法。6. 常用工具与配置对照分页插件、缓存和它的关系6.1 PageHelper分页插件对WithBLOBs的影响PageHelper是常见的MyBatis分页插件它的原理是在执行查询前拦截SQL自动拼接LIMIT语句。如果你的代码写了PageHelper.startPage(pageNum, pageSize); ListOrder orders orderMapper.selectByExampleWithBLOBs(example);PageHelper只帮你限定行数字段列表一点不变。也就是说分页确实生效了但每页的大字段还是全部拉回来了。一页20条数据每条30KB也有600KB虽然能接受但完全没有必要。更隐蔽的是PageHelper的reasonable参数。当页码超出总页数时它会自动跳到最后一页。如果你的列表页默认查第一页某些用户翻到最后一页恰好最后一页只有几条数据其中某条大字段特别大直觉感受就是“某个页面特别卡”。6.2 MyBatis一级缓存和二级缓存的误区一级缓存默认开启作用范围是SqlSession。但一级缓存有个关键限制任何执行了更新操作INSERT、UPDATE、DELETE的Session会清空缓存。所以一级缓存对查询性能的改善有限尤其在高并发更新场景下。二级缓存默认关闭很多团队为了“优化性能”开启二级缓存但缓存对象是查询方法的完整结果集。如果缓存的是selectByExampleWithBLOBs返回的实体列表那等于把一堆大字段字符串全塞进缓存。缓存命中时性能确实快但缓存一旦被清空下次回源查询的巨大开销会直接冲击数据库缓存雪崩的风险反而更大。我个人的建议是涉及大字段的表不要开启MyBatis二级缓存或者至少不要让大字段查询方法成为缓存目标。真要缓存就单独为列表页所需的DTO字段建Redis缓存。6.3 Spring Boot项目中的常见配置陷阱Spring Boot集成MyBatis时有几个配置值得留意mybatis.configuration.map-underscore-to-camel-casetrue下划线转驼峰如果不开启查出来的列名和实体属性对不上结果集映射会出问题。mybatis.configuration.default-fetch-size全局fetchSize默认值建议设置为1000到2000之间太小会导致频繁网络往返太大会导致内存占用过高。mybatis.configuration.default-statement-timeout建议设置一个合理的超时时间比如10秒防止大字段查询把连接池占死。这些配置单独看都不起眼但配合大字段查询时一个不合理的超时设置可能让数据库连接的占用时间翻倍。7. 修复流程实录一次真实的性能排查与替换7.1 现象描述去年我接手的一个运营后台项目订单列表页持续卡顿。监控显示接口P99耗时从180ms涨到850ms数据库CPU正常但内网流量峰值达到200Mbps应用服务器FGC每10分钟一次。刚开始怀疑是不是索引失效但EXPLAIN看执行计划完全正常status字段索引也没问题。后来把SQL全部抓出来发现列表查询用的SQL赫然带着remark字段。代码里调用的是orderMapper.selectByExampleWithBLOBs(example)7.2 定位过程排查步骤记录一下先用SHOW FULL PROCESSLIST看当前正在执行的SQL发现同一个SQL出现多次每次执行1秒左右。打开MySQL general log只开5分钟把慢查询抓出来确认所有慢SQL都来自同一个Mapper方法。用Arthas的trace命令监控接口调用链找到慢方法的调用位置。翻了代码仓库的提交记录发现两个月前一次需求变更把selectByExample换成了selectByExampleWithBLOBs原因是为了在列表展示一个摘要字段当时直接用大字段做截断。7.3 解决方案修的时候没有粗暴地换回selectByExample因为需求确实需要在列表中展示摘要。做了三件事第一新建一个DTO对象OrderListVO只包含列表页需要的字段外加一个摘要字段remarkBrief。摘要字段在SQL层用LEFT(remark, 200)截取而不是在Java层截取。SELECT id, order_no, amount, status, create_time, LEFT(remark, 200) AS remark_brief FROM orders WHERE status #{status}这样既满足了列表展示摘要的需求又避免了全量大字段的传输。第二原详情页保留完整大字段查询改用selectByPrimaryKeyWithBLOBs针对单条记录查询开销可控。第三给列表查询单独建索引。原来的status单列索引改成(status, create_time)联合索引排序直接在索引内完成避免临时文件和filesort。7.4 修复效果改造后对比指标修复前修复后接口P99耗时850ms120ms内网流量峰值200Mbps12MbpsFGC频率每10分钟一次每2小时零次单次查询返回数据量30MB约0.5MB整体性能提升了85%左右和标题说的“下降80%”正好是反过来的关系——换掉这个隐形炸弹性能立刻回到了正常区间。经验遇到MyBatis相关性能问题第一步永远是把实际执行的SQL打出来看SELECT字段列表。很多问题不用分析器肉眼就能看出来。8. 踩坑速查表与自查清单8.1 常见问题速查表问题表象可能原因排查命令或工具解决方案接口RT变高DB CPU正常查询返回了大字段网络传输成为瓶颈SHOW FULL PROCESSLIST、SQL日志更换查询方法只取需要字段GC频繁老年代涨得快大量短命大字符串对象jmap -histo:live避免WithBLOBs流式处理分页接口越翻越慢大字段全部拉回LIMIT只限制行数打印完整SQL用DTO查询SQL层截取摘要缓存命中率高但内存还爆MyBatis二级缓存缓存了大字段列表查看缓存对象大小关闭二级缓存或改用Redis导出功能OOM全表大字段一次性加载堆转储分析流式查询 fetchSize设置数据库连接池耗尽大字段查询处理时间长连接被长期占用连接池监控拆分查询 缩短事务8.2 自查清单代码Review时重点排查这几个点搜索WithBLOBs关键字逐个检查调用场景是否真的需要大字段。检查selectByExample生成的SQL确认SELECT字段列表是否只有必要字段。列表页、分页查询、导出任务严禁使用通配符大字段查询。详情页单条读取大字段可以接受但要确认单条大字段的大小上限。新表设计时把大字段拆到独立扩展表。所有Mapper方法最好都能明确返回字段而不是SELECT *。8.3 补充一个冷知识LazyLoad能否救你MyBatis支持aggressiveLazyLoading和lazyLoadingEnabled配置理论上可以让大字段按需懒加载。但实际项目中我不推荐用这个特性救急原因有三懒加载需要在SqlSession打开期间访问字段才会触发查询一旦Session关闭再访问属性会报LazyInitializationException。懒加载导致的额外SQL可能形成N1查询反而比直接加载更慢。配置开关全局生效影响面大不适合单独针对某一个查询方法做精细化控制。所以遇到问题优先改SQL和代码结构而不是依赖框架的懒加载魔法。9. 从这颗炸弹延伸出去的三个思考9.1 自动生成代码是把双刃剑MyBatis Generator极大提升了开发效率但它生成的通用方法默认是“全都要”的取数思路。自动生成的代码必须经过人工Review尤其是涉及大字段的表。建议在项目里加一个自定义的MBG插件或脚本自动扫描WithBLOBs方法的使用点不准在Controller或Service层直接调用。9.2 性能问题的本质是数据体积问题这个案例的根本原因是数据体积失控。很多人一遇到性能问题就想着加缓存、加索引、加机器却忽视了一个基础事实从数据库到应用服务器再到Java堆数据搬运的每一环都有成本。能少传字节就少传字节。这个原则适用于所有ORM框架不只是MyBatisHibernate的Basic(fetch FetchType.LAZY)、Spring Data JPA的EntityGraph本质都是在控制数据获取的体积。9.3 给新人的两条实操建议第一写完任何查询第一时间打印实际执行的SQL。MyBatis可以配置日志级别为DEBUG把SQL和参数都打出来看一眼比事后分析监控省太多时间。第二凡是查询方法返回List实体都要问自己一句这个列表里的每个字段真的都要用吗如果是列表页大概率只需要几个展示字段。我在实际处理过的项目里靠“少取字段”这一个原则优化的查询性能收益往往比加索引还明显。索引解决的是数据库内部怎么找数据的问题而少取字段解决的是数据怎么搬的问题——后者通常才是真正的瓶颈。最后再分享一个小细节如果你在代码里用IDE的Find Usage搜索selectByExampleWithBLOBs调用点超过五个那基本可以确认这颗炸弹已经在响了。不用怀疑上线前检查清单里加上这一条能帮你提前拆掉好多雷。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →