
项目标题: MyBatis各种查询功能用到一定年头你就会发现MyBatis的查询功能说难不难说简单也绝不是写个select标签就完事。日常开发里动态条件、分页、多表嵌套、批量操作任何一个场景都能写出好几种不同写法而每种写法背后的性能和踩坑点都不一样。这篇文章把我实践下来觉得最值得掌握的查询功能系统梳理了一遍——从参数映射到动态SQL从分页到一对多嵌套再到线上排查时的常见问题希望能给刚接触MyBatis不久、或者用了很久但没系统整理过的同学一点参考。1. 查询功能全景拆解先搞清MyBatis查询到底干了哪几件事1.1 一个查询请求的完整链路很多人写MyBatis写了很久还是只看Mapper接口和XML至于请求进去之后发生了什么基本是黑盒。我在排查问题的时候发现理解这条链路比背任何API都值钱。一次查询从Mapper接口方法开始实际是走了这样一条路调用接口方法时MyBatis通过MapperProxy动态代理拦截把方法名和参数交给SqlSessionSqlSession再找对应的MappedStatement里面保存着XML里解析好的SQL内容。接着Executor执行器登场负责处理一级缓存、二级缓存、延迟加载这些逻辑再往下是StatementHandler它负责把#{}参数替换成?占位符并绑定参数值最终通过JDBC的PreparedStatement执行。查询结束后ResultSetHandler接过ResultSet按resultMap或resultType的配置把行数据映射成Java对象。这条链路解释了很多现象为什么分页插件能自动改写你的SQL因为它在Executor层做了拦截为什么二级缓存要关注序列化因为缓存里存的是映射后的对象为什么查询结果映射不对多半是ResultSetHandler这一段配置有问题。看问题的时候按这条链路逐级排查比瞎试配置高效得多。1.2 按使用场景给查询分类查询功能这四个字太笼统我习惯按场景拆成五类每一类的技术选型和排查路径都不一样单表条件查询输入若干条件输出对应的记录列表或单条记录。核心是参数传递、动态SQL、结果映射。多表关联查询一对一、一对多、多对多核心是association、collection、JOIN写法、嵌套查询和延迟加载。分页与统计查询分页要处理LIMIT/ROWNUM方言、总数COUNT、大数据量深分页统计则涉及GROUP BY、HAVING和聚合函数映射。批量与集合查询IN查询、批量按主键查、批量插入或更新时的查询辅助核心是foreach拼接和ExecutorType.BATCH。扩展查询场景加锁查询悲观锁/乐观锁、流式查询处理大结果集、动态表名和动态排序字段这类场景在业务系统里不多但遇到就是硬骨头。这么分类之后你会发现每一个具体查询问题都能归到某一类里定位起来就快了。1.3 为什么是MyBatis选型背后的权衡我服务过的几个项目清一色选了MyBatis不是跟风是明确权衡过。核心原因有三点第一SQL完全可见可控制复杂查询DBA可以直接拿去EXPLAIN优化这点特别对我们的场景因为我们的报表统计SQL动辄二十多行JOIN如果框架自动生成SQL出了问题根本没法查第二动态SQL能力足够强if、choose、foreach基本覆盖了所有条件拼接需求第三和现有技术栈契合团队所有人上手成本低。代价也要认清多表关联的映射要自己写数据库方言差异要自己处理缓存和分页都要依赖插件或自研。最后我们是在MyBatis之上封装了一层通用Mapper和分页插件才把重复劳动降下来。选型没有绝对好坏关键是你愿意为哪部分复杂度买单。2. 参数传递与结果映射查询开发里最容易被忽略的两个基本功2.1 参数传递的五种姿势与选择建议查询功能的第一步就是怎么把Java方法的参数传到SQL里。别小看这一步我见过有人因为参数传递方式混乱改需求的时候改到怀疑人生。先看标准的五种方式传参方式写法示例适用场景注意事项单参数User findById(Long id)XML里用#{id}最简单的主键/唯一键查询参数名可随意但建议保持语义化多参数ListUser findByNameAndStatus(String name, Integer status)XML用#{param1}/#{param2}或Param指定少量参数的组合查询不写Param就只能用arg0/param1这种魔法值可读性差POJO传参ListUser findByCondition(UserQuery query)XML用#{name}、#{status}条件数量多、有分组语义的查询查询对象和实体类分离会更好维护Map传参ListUser findByMap(MapString, Object params)XML用#{key}临时组合、动态表名、不确定结构类型安全差线上排查困难不推荐大范围使用Param多参数ListUser findByCondition(Param(name) String name, Param(status) Integer status)需要良好可读性的多参数查询推荐方式显式命名XML里引用清晰这里有个大坑多参数不写Param时MyBatis确实能运行但XML里要用arg0、arg1或param1、param2来引用。param1是MyBatis一直兼容的写法arg0是后来JDK8之后才稳定的两个混用会让人非常困惑。我的建议很直接所有多参数方法一律用Param显式命名没有例外。另一个容易踩的点是参数为null的情况。#{name}默认会把null传给JDBC但某些数据库驱动在未指定jdbcType时可能报错比如Oracle。稳妥做法是在可能为空的字段上写明jdbcTypeSELECT * FROM user WHERE name #{name, jdbcTypeVARCHAR}注意if标签判断参数是否为null或空字符串再决定要不要拼接条件这样既避免空条件导致的全表查询也避免SQL语法错误。2.2 结果映射resultType与resultMap按需选择参数传进去查询结果怎么变成Java对象这是第二个基本功。MyBatis有两种映射方式resultType和resultMap。resultType走的是自动映射MyBatis会把列名转成属性名然后通过反射赋值。默认情况下数据库列名user_name是映射不到Java属性userName的除非开启驼峰映射mybatis: configuration: map-underscore-to-camel-case: true这个配置我基本是必开的否则每一张表都要手写resultMap烦琐且容易错。开了驼峰映射之后只要你数据库设计遵循下划线命名规范80%的单表查询一行resultType搞定。resultMap则是完全手动的映射它的价值体现在多表关联、字段名差异大、需要构造器参数、需要TypeHandler处理特殊类型等场景。核心元素包括id、result、association、collection、discriminator。其中id不只是主键标识它还被MyBatis用来做去重判断在一对多映射里这一列配错会导致集合数据错乱。自动映射有三个级别NONE完全不映射PARTIAL不映射嵌套结果FULL连嵌套结果也自动映射。默认是PARTIAL所以嵌套对象还是要靠resultMap里的association和collection。我踩过一次坑用FULL配了嵌套查询结果关联对象里的下划线字段也能自动映射当时还以为是运气好后来才知道是级别问题。建议不要依赖FULL显式声明更靠谱。2.3 什么情况必须用resultMap虽然驼峰映射省了很多事但下面这些场景还就得老老实实写resultMap多表JOIN查询需要把多张表的列映射到不同对象里数据库列名和Java属性名完全没有对应关系需要嵌套对象一对一用association一对多用collection需要给枚举字段配TypeHandler需要把某些列映射成构造器参数。写resultMap时我有个习惯先画清楚对象结构再对照SQL列一一映射。不是所有列都要写能自动映射的可以省略但关联对象、类型转换这种必须显式标注。曾经有个同事图省事把一个三表JOIN的查询直接写resultTypeMap结果前端拿到一堆带下划线key的数据被迫在Service层做转换后来改成了resultMap代码量直接少了一半。3. 动态SQL实战把查询条件写活又不让SQL烂掉3.1 if where90%的条件查询用它就够了业务系统里最常见的查询就是多条件组合查询搜索框输入姓名、下拉框选状态、时间区间选范围这些条件可能有也可能没有。如果只用静态SQL就得为每一种可能组合写一条SQL累死且不可维护。if where是解决这个问题的经典搭配。select idfindByCondition resultTypeUser SELECT * FROM user where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if teststatus ! null AND status #{status} /if if testdeptId ! null AND dept_id #{deptId} /if /where /select注意where标签的作用它会在子元素有条件满足时自动在开头加上WHERE并且把多余的AND或OR去掉。很多人用不惯where喜欢在SQL里写WHERE 1 1然后再拼接。我理解这个写法看着直观但它有几个问题一是全表扫描的隐患依然存在虽然数据库优化器一般会忽略这种恒真条件但没必要赌二是代码review时观感不好三是万一条件都没匹配WHERE 1 1真的就把全表数据捞出来了生产上出过事故。where标签还有个隐藏行为如果所有条件都没匹配它生成的SQL里不会有WHERE关键字等于一次全表查询。所以在调用方要提前判断条件是否为空或者做好结果集限制。再来一个容易忽略的细节if test里的字符串判断。很多人只写了name ! null没写name ! 。前端传递空字符串时条件照样拼接结果查出来一个name 的记录线上反馈搜索没反应排查半天才发现是空字符串问题。我写if条件的习惯是字符串字段一律加上! 判断数值字段只判断! null。3.2 choose/when/otherwise多条件优先级查询有些业务场景不是条件叠加而是条件优先。比如优先按用户名精确查查不到再按手机号查都没有就返回列表。这种用if叠加是做不到的因为if会全部拼接这时要用choose。select idfindByPriority resultTypeUser SELECT * FROM user where choose when testusername ! null and username ! AND username #{username} /when when testphone ! null and phone ! AND phone #{phone} /when otherwise AND status 1 /otherwise /choose /where /selectchoose有点类似Java里的switch从上往下匹配第一个成立的when全部不成立走otherwise。这个标签在多条件互斥的场景下特别好用但要注意它的语义和if完全不同别混着用。我看过有人把choose当成多个if写结果只有第一个when生效怎么查都是条件漏了。3.3 foreachIN查询、批量查询的正确打开方式IN查询是日常高频操作比如按一批ID查用户、按一批订单号对账。手写IN肯定不行MyBatis提供了foreachselect idfindByIds resultTypeUser SELECT * FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /select这个写法本身很简单真正的问题是集合为空。如果ids是空List生成的SQL就是WHERE id IN ()MySQL会直接报语法错误。我一般会在调用层先判断CollectionUtils.isEmpty(ids)为空就返回空列表不打SQL。也有人把判断写在XML里if testids ! null and ids.size() 0 AND id IN foreach collectionids itemid open( separator, close) #{id} /foreach /if这个方法也能用但可读性差。我更偏向在Service层做空集合的短路处理职责更清晰。foreach还能配合批量插入和批量更新这时候要注意Executor类型。MyBatis默认是SIMPLE执行器每次SQL都重新预编译批量1000条插入要编译1000次性能很差。改成ExecutorType.BATCH后同一个PreparedStatement可以复用参数批量执行效率明显提升。但是BATCH模式下查询操作有一些行为差异比如批量没有即时刷新事务内要特别注意。我的建议是批量操作单独配置一个SqlSessionTemplate不要影响常规查询。3.4 trim底层逻辑搞懂了where和set都是它的语法糖where和set本质上都是trim的特定配置。trim有四个属性prefix在满足条件时添加前缀prefixOverrides去掉子句开头多余的指定内容suffix添加后缀suffixOverrides去掉末尾多余的指定内容。手写一个where等价实现trim prefixWHERE prefixOverridesAND |OR if testname ! nullAND name #{name}/if if teststatus ! nullAND status #{status}/if /trimset同理用于UPDATE语句自动去掉最后一个逗号trim prefixSET suffixOverrides, if testname ! nullname #{name},/if if teststatus ! nullstatus #{status},/if /trim我之所以建议大家花十分钟把trim搞懂是因为在动态SQL组合特别复杂的场景里直接上手trim比套where、set更灵活比如要拼接GROUP BY、ORDER BY、多个查询块的时候where和set就不够用了prefixOverrides和suffixOverrides反而能精准控制边界。理解了底层你看到任何一段动态SQL都不会觉得是黑魔法。3.5 bind与script动态SQL的两个周边技巧bind标签常被用来做模糊查询的拼串。不同数据库的字符串拼接函数不一样MySQL用CONCATOracle用||如果直接在SQL里写死LIKE CONCAT(%, #{name}, %)将来切库就得全部改。用bind可以提前拼接好模式串select idfindByName resultTypeUser bind namenamePattern value% name %/ SELECT * FROM user WHERE name LIKE #{namePattern} /select这样在MySQL和Oracle下都能跑因为拼接动作由MyBatis在Java层完成不依赖数据库方言。实测过这个写法迁移数据库时模糊查询完全不用改。注解方式虽然不推荐写复杂SQL但简单动态SQL用Select加script标签也能撑住Select(scriptSELECT * FROM user WHERE name #{name}if teststatus ! null AND status #{status}/if/script) ListUser findByAnnotation(Param(name) String name, Param(status) Integer status);这个用法适合SQL很简单、又不想建XML文件的场景。但SQL稍微复杂一点注解里的字符串拼接会让你疯掉无法格式化、无法注释、review困难。我的底线是超过三行SQL全放XML注解只服务单表简单查询。4. 分页、多表关联与嵌套查询高阶查询的实战重点4.1 手写分页搞懂limit和count才是真基本功分页功能看起来谁都会但真做起来坑不少。先看手写分页的核心select idfindPage resultTypeUser SELECT * FROM user where if testname ! nullAND name LIKE CONCAT(%, #{name}, %)/if /where ORDER BY id DESC LIMIT #{offset}, #{pageSize} /select开发时传入offset (currentPage - 1) * pageSize配合一个总数查询select idcountByCondition resultTypelong SELECT COUNT(*) FROM user where if testname ! nullAND name LIKE CONCAT(%, #{name}, %)/if /where /select这里的几个注意点值得记下来第一COUNT语句不能写COUNT(1)还是COUNT(*)的问题其实两者性能差不多关键是不要再Join一些无关表否则count的SQL会把JOIN的笛卡尔积也算进去数字直接翻倍。第二LIMIT是MySQL的方言Oracle要用ROWNUMSQL Server要用OFFSET ... FETCH如果项目要考虑多数据库支持手写分页就得抽象方言层。第三ORDER BY字段如果来自外部传入很容易被注入后面我会单独讲。手写分页的好处是透明、可控、不依赖插件但每次都要写两条SQL并且要手动计算offset在复杂查询场景下总数SQL和列表SQL的条件一旦没保持一致总数和列表就对不上。所以大项目里通常引入分页插件但基础功不能丢——插件出问题时能快速定位到它改写的SQL这才是手写分页的价值。4.2 PageHelper插件的正确使用姿势现在Java后端用PageHelper的非常多它通过拦截Executor自动改写SQL生成带分页方言的查询和COUNT查询。用法很简单PageHelper.startPage(pageNum, pageSize); ListUser users userMapper.findByCondition(query); PageInfoUser pageInfo new PageInfo(users);startPage只对紧随其后的第一条查询生效。这个不算冷知识但很多人踩坑比如在startPage和查询之间插了一个其他查询分页条件就跑到那条查询上去了业务SQL反而没有分页。更隐蔽的是在循环里调用startPage每次循环都会重新设置上下文但查询可能没执行完就被下一次循环覆盖最后结果完全不可控。我的规则是PageHelper.startPage和Mapper查询之间不写任何多余代码并且整个分页方法保持单一职责。PageHelper的另一个问题是COUNT语句的改写。遇到复杂的多表JOIN、GROUP BY、DISTINCT时它生成的COUNTSQL可能很重或者统计口径不对。我一般在PageHelper里设置count参数手动指定PageHelper.startPage(pageNum, pageSize, false); // 不需要count或者干脆把总数查询从列表查询中拆出来自己写彻底避开插件对复杂SQL的改写。做报表类分页时我几乎都是手写count因为报表SQL的统计条件和分页条件往往不是一回事。4.3 一对一查询association的两种实现方式对比一对一是查询功能里最常见的高阶场景查用户带出部门信息查订单带出用户信息。MyBatis里用association来处理实现方式有两种性能差异很大。方式一嵌套结果映射resultMap idUserWithDeptMap typeUser id propertyid columnid/ result propertyusername columnusername/ association propertydept javaTypeDept id propertyid columndept_id/ result propertydeptName columndept_name/ /association /resultMap select idfindUserWithDept resultMapUserWithDeptMap SELECT u.id, u.username, d.id AS dept_id, d.dept_name FROM user u LEFT JOIN dept d ON u.dept_id d.id WHERE u.id #{id} /select方式二嵌套查询resultMap idUserWithDeptMapLazy typeUser id propertyid columnid/ result propertyusername columnusername/ association propertydept javaTypeDept columndept_id selectfindDeptById/ /resultMap select idfindUserWithDeptLazy resultMapUserWithDeptMapLazy SELECT id, username, dept_id FROM user WHERE id #{id} /select方式一是一条SQL JOIN搞定数据一次性查出效率高但SQL复杂方式二是先查主对象再根据dept_id发起第二次查询会产生N1问题。如果我们查的是10个用户的列表方式二最多执行11条SQL1条主查询10条部门查询。这在列表页基本不可接受。我平时默认用嵌套结果映射除非关联子表数据量很大、且大多数场景不需要展示才考虑方式二配合延迟加载。另外association和collection里的column属性可以传多个列用column{idid, dept_iddept_id}这种写法但可读性一般能不用就不用。4.4 一对多查询collection的使用与数据去重一对多比一对一更容易踩坑典型场景是查订单列表同时带出每个订单的商品明细。一个订单有多个明细JOIN之后主订单行就会重复。resultMap idOrderWithItemsMap typeOrder id propertyid columnid/ result propertyorderNo columnorder_no/ collection propertyitems ofTypeOrderItem id propertyid columnitem_id/ result propertyproductName columnproduct_name/ result propertyquantity columnquantity/ /collection /resultMap select idfindOrderWithItems resultMapOrderWithItemsMap SELECT o.id, o.order_no, i.id AS item_id, i.product_name, i.quantity FROM orders o LEFT JOIN order_item i ON o.id i.order_id WHERE o.id #{id} /select这里最关键的细节是主对象的id列必须配置并且JOIN查询出来的结果集里主表的id列值要稳定。MyBatis在组装嵌套集合时靠主对象id判断当前记录是否属于上一个主对象如果id没配或者配错列每个明细行都会生成一个新的Order对象最终得到一堆重复主记录。我碰到过一次线上事故订单列表数量比真实订单多出几倍就是resultMap里漏配了id查出来的每个商品明细都被当成独立订单了。另外还要小心分页和一对多同时使用。如果先JOIN再分页COUNT(*)会统计JOIN后的明细行数导致总数偏大且LIMIT分页的行是明细行不是主记录行排序结果会乱。这种场景我通常先查主表分页再根据主表ID集合查子表最后在Service层组装对象虽然多写一点代码但可控性最高。一句话一对多和分页最好别在一条SQL里硬凑。4.5 延迟加载配置与实战建议延迟加载是解决N1问题的一个缓兵之计核心是先查主对象访问关联对象属性时才触发子查询。配置如下mybatis: configuration: lazy-loading-enabled: true aggressive-lazy-loading: falseaggressive-lazy-loading设为false很关键它表示按需加载关联属性如果为true访问主对象的任何一个属性可能只是读取主字段都会触发所有关联查询等于没延迟。association和collection上还可以用fetchTypelazy或fetchTypeeager覆盖全局配置。用延迟加载要注意几个坑。第一会话关闭后触发懒加载会报错因为子查询需要新的数据库连接而SqlSession已经关了。所以在Service层返回实体对象后如果Controller层访问了懒加载属性必须保证事务还在或者提前完成访问。第二懒加载生成的代理对象在序列化时可能出问题有些JSON框架序列化时会触发属性访问结果把隐藏的子查询全部引出来。第三延迟加载不是银弹它只是把N1从一次请求分散到多次请求总SQL次数并没有变少只是在不需要关联数据时省掉了多余查询。我的经验是接口明确需要关联数据就写JOIN的嵌套结果映射接口大概率不需要关联数据才考虑懒加载。不要为了写起来简单去懒加载性能问题不会因为延迟加载消失只会推迟爆发。4.6 查询中的锁与版本控制进阶扩展查询并不总是只读的有些场景需要在查询时锁定数据避免并发修改。MyBatis里用SELECT ... FOR UPDATE实现悲观锁select idfindByIdForUpdate resultTypeOrder SELECT * FROM orders WHERE id #{id} FOR UPDATE /select注意FOR UPDATE必须在事务中生效Spring里对应方法要加Transactional否则锁会在语句结束后自动释放等于白锁。另一个坑FOR UPDATE一旦执行事务结束前其他事务对这个行的读写都会被阻塞容易引发长时间锁等待所以要控制好持有锁的时间不要在锁内做远程调用或耗时操作。乐观锁则一般配合版本号字段查询时不锁更新时SET version version 1 WHERE id ? AND version ?影响行数为0说明版本冲突。MyBatis本身不强制提供乐观锁都是自己在SQL里控制。如果你在做一个高并发的电商系统这两种锁的适用边界要提前想清楚不然查询功能写完了并发一上来全是死锁和超时。5. 查询性能优化与线上问题排查实录5.1 慢查询定位从日志到执行计划遇到慢查询第一步是拿到MyBatis实际执行的SQL。MyBatis的日志里会打印预编译SQL和参数但PreparedStatement的日志显示的是?占位符不能直接丢到数据库执行。我的做法是把日志里的SQL复制出来把?手动替换成实际参数值注意字符串要加引号再去EXPLAIN。排查过的慢查询里最常见的三个原因第一WHERE条件里的字段没走索引比如LIKE %xxx%导致索引失效这种只能改需求或上全文索引第二在JOIN的关联字段上存在隐式类型转换比如关联字段是varchar而关联条件是数字MySQL会放弃索引第三查询字段过多SELECT *拉了一堆不需要的大字段比如TEXT类型导致行数据量巨大。MyBatis配置里可以限制日志输出但更根本的做法是写SQL时就不碰SELECT *。5.2 结果映射排查查出来全null、字段错位查询能跑通但返回的Java对象全是null这个问题的排查路径是固定的先看是不是没开map-underscore-to-camel-case开了之后user_name才能映射到userName再看是不是用了resultType但SQL查出来的列别名和属性不匹配比如多表JOIN时字段重名没有起别名id不知道是哪张表的再看resultMap里的column属性是不是拼错了这个错很难发现因为MyBatis不报错只是默默映射不上最后看数据库字段类型和Java类型是否匹配比如数据库CHAR类型返回的字符串可能带空格DECIMAL映射到Integer会丢精度。我给项目定的规矩查询多表JOIN必须给字段起别名映射配置必须带上column绝不依赖模糊匹配。这条规矩救过我好几次现在组里新人也少踩这类坑。5.3 动态SQL查询失败的典型错误动态SQL常见报错集中在三个地方第一个是多余AND/OR。前后条件都为空时拼接出来的SQL成了WHERE AND name ?。用了where能避免但如果你在某些复杂SQL里嵌套了两个where外层和里层的输出可能叠加出奇怪的结果要小心。第二个是foreach集合为空。IN ()直接数据库语法错误解决办法我在前面讲过Service层短路或XML里size() 0判断。第三个是参数类型不对导致的SQL异常。比如if teststatus ! null判断的是Integer但实际传入的是String类型参数MyBatis在OGNL表达式求值时可能自动把字符串转成数字放进条件判断看起来很智能但有时候能转有时候直接抛异常。遇到诡异的ognl.ParseException先检查test表达式里的类型匹配。5.4 分页count不对与大数据量深分页分页总数的错乱我在PageHelper和手写分页里都遇到过。PageHelper那边多是因为复杂JOIN和GROUP BY导致count改写错误解决办法是手动指定count SQL或自己查总数。手写分页出现总数不对八成是列表SQL和count SQL里的条件没同步比如列表SQL多了一个状态的if条件count SQL忘了加。深分页是大数据量场景的老大难LIMIT 1000000, 20MySQL要扫描前100万行再丢弃浪费大量IO。优化思路有几个延迟关联先查主表ID再回表查询完整字段WHERE id (SELECT id FROM user ORDER BY id LIMIT 1000000, 1) LIMIT 20避免一开始就扫描大字段游标分页/键集分页记住上一页最后一条的排序字段值下一页用WHERE id 上次最大id ORDER BY id LIMIT 20翻页快但需要维护游标状态范围分页按时间或ID区间切片适合导出和批处理。MyBatis本身不帮你解决深分页它只负责把SQL发出去。优化深分页还得靠DBA层面的设计。5.5 N1查询问题现象、定位、修复N1问题的现象非常典型前端请求一个订单列表接口接口返回10条订单但后端日志显示执行了11条甚至更多SQL。第一条查询订单列表后面10条分别查每个订单的明细或用户信息。根因就是association或collection用了嵌套查询每处理一条主记录就触发一次子查询主记录有N条就额外产生N次查询。定位方法很简单打开MyBatis的SQL日志统计一次请求总共执行了多少条SQL再对比主记录条数立刻就知道有没有N1。修复方案优先改成嵌套结果映射一条JOIN如果JOIN实在太重可以考虑用延迟加载降低无效子查询但要注意前面说的会话和序列化问题。如果业务确实需要批量加载MyBatis也有Options(fetchSize)和ExecutorType.BATCH可以辅助但核心还是少发SQL。5.6 ${}与排序注入动态表名、动态排序字段怎么安全实现动态SQL里#{}是预编译参数${}是字符串拼接两者有本质区别。前者能防SQL注入后者把用户输入直接拼进SQL风险极高。但真实业务里确实需要动态排序字段、动态表名。比如列表页按不同字段排序ORDER BY ${orderByColumn} ${orderByType}这个写法一旦直接把前端参数透传进来用户传一个id; DROP TABLE user--之类的内容后果不堪设想。我处理这种需求的方式是白名单校验。在Java层先校验排序字段是否在允许的字段集合里不在就拒绝或走默认排序字段排序类型同理只允许ASC或DESC两个值。private static final SetString ALLOWED_COLUMNS Set.of(id, create_time, update_time); String safeOrderBy ALLOWED_COLUMNS.contains(rawOrderBy) ? rawOrderBy : id;表名动态变化的场景分表、历史表切换也一样用白名单枚举表名而不是直接把前端传的表名拼进去。安全不是MyBatis的锅是使用者的责任${}不是不能用是只允许用在信任的、枚举过的值上。5.7 大批量查询的内存问题流式查询与ResultHandler一次性查询几十万行数据直接ListUser接收JVM内存瞬间飙升甚至OOM。MyBatis提供了流式查询的思路常见两种实现。一种是Cursor接口try (CursorUser cursor userMapper.scanAll()) { cursor.forEach(user - process(user)); }使用Cursor时要放在事务里并且连接不能提前关闭否则游标取不到数据。MyBatis对Cursor的实现是惰性拉取结果集不会全量加载到内存适合大规模导出。另一种是ResultHandler回调mapper.scanLargeData(new ResultHandlerUser() { Override public void handleResult(ResultContext? extends User resultContext) { User user resultContext.getResultObject(); process(user); if (resultContext.getResultCount() % 1000 0) { // 每1000条处理一次清空上下文或做批量落库 } } });ResultHandler里要注意控制节奏不要每行都触发远程调用或写库积攒一批再处理效率更高。流式查询不是银弹它的代价是连接占用时间长、事务长、可能锁表适合离线任务和导出场景不适合在线接口。如果你在线接口动不动扫全表先想想业务设计是不是就有问题。6. 一些实战体会项目做得越久越觉得MyBatis查询功能的技术含量不在会写标签而在知道什么时候用什么方案。我维护过一个老系统里面的Mapper XML动辄几百行一个select标签塞了二十多个if看着功能很全实际上每次改动都提心吊胆加一个条件不知道会影响多少调用方。后来我按场景把大查询拆成了多个独立方法详情查询、列表查询、导出查询、统计查询各写各的虽然代码量多了但每个方法清晰可控排查问题也快。几条实操建议可以分享给正在做类似工作的同学项目初始化就把mapUnderscoreToCamelCase、日志打印这些基础配置调好后面写查询会省太多事写SQL前先在数据库客户端里用真实数据跑一遍确认结果集和预期一致再翻译成Mapper文件能少走一半弯路动态SQL别贪多能用几个简单的if解决的就不要堆十几种choose和foreach的组合分页插件版本升级后一定回归测试它改写的SQL在不同数据库方言下行为差异很大一对多嵌套查询和分页同时出现时宁可多查一次也不要硬在一条SQL里做。MyBatis查询功能本身并不复杂复杂的是各种真实业务场景的组合。把这些组件吃透攒出自己的排查套路线上遇到问题才不会慌。动手写下一个查询之前不妨先想想它属于哪一类场景用哪种方案最合适——这个思考习惯比记住任何标签都有用。