2026/10/12 7:08:51

MyBatis查询全攻略:从参数传递到动态SQL与分页映射实战

MyBatis查询全攻略:从参数传递到动态SQL与分页映射实战 1. 项目概述与核心价值1.1 为什么要专门聊MyBatis查询先说个背景。无论是用惯了JPA还是从JDBC时代走过来的人MyBatis在国内Java技术栈里的地位一直非常稳定。为什么稳定因为它的查询功能足够灵活、足够贴近SQL本身不像某些框架把SQL层层封装之后出了问题你连打印出来的语句都看不懂。MyBatis把SQL的控制权完全交还给你查询怎么写、参数怎么传、结果怎么映射全部在可控范围之内。我见过太多新人一上来就是select * from table where id ?然后发现业务复杂起来根本招架不住——多条件筛选、动态排序、分页、关联嵌套、一对多映射每个场景都要重新去查文档、问同事。这篇文章就把MyBatis查询功能从头到尾盘一遍从基础的参数传递、结果映射到动态SQL的复杂拼装再到分页游标和关联查询这种实战高频场景全部拆开讲清楚。不管你是刚接触MyBatis还是已经写了几年但一直靠复制粘贴过日子这篇文章都能帮你在查询这条线上补全体系。1.2 文章能解决什么问题这篇文章的核心定位不是教你怎么装环境、配数据源而是聚焦查询这一个动作把MyBatis里和查询相关的所有关键点串成一条线。看完之后你能搞清楚三件事第一参数是怎么从Java方法一路走到SQL语句里的第二动态SQL各个标签在什么场景下该用哪个、怎么配合第三遇到复杂映射和性能问题的时候排查的方向在哪里。文章中所有的代码示例都是可以直接拿去改的不依赖任何特定业务表结构。我尽量用最常见的用户表和订单表来演示这样你对照自己的项目也好替换。2. 查询参数传递的完整链路2.1 单参数传递的细节先讲最简单的单参数场景。Mapper接口里写一个方法User selectUserById(Long id);对应的XML里这样写select idselectUserById resultTypecom.example.User SELECT * FROM user WHERE id #{id} /select这个#{id}的取值逻辑很多人没细想过。它实际上是PreparedStatement的参数占位符MyBatis会通过反射从入参对象里取出对应的属性值。单参数时如果入参是基本类型或者String#{}里面的名字其实可以随便写因为MyBatis此时只有一个参数不管写#{id}还是#{userId}最终拿到的都是同一个值。但是为了方便阅读还是建议保持名字一致。这里有一个容易踩的坑如果单参数是Integer类型XML里用#{id}没问题但如果参数是nullSQL执行时会直接报参数类型错误吗分情况。如果SQL里只有普通条件判断不会报错因为JDBC驱动会处理null但如果SQL里有类似#{id} 0这种参与运算的地方null进去之后结果就是null连条件都不会生效。2.2 多参数传递的三种方式多参数是日常写查询最常碰到的场景。假设要按姓名和状态两个条件查询用户ListUser selectUsersByNameAndStatus(String name, Integer status);如果直接在XML里写#{name}和#{status}MyBatis会报错说找不到参数。原因是Java方法有多个参数时MyBatis默认不知道每个#{}对应哪个位置。解决方案有三种方案一用Param注解。这是最推荐的方式ListUser selectUsersByNameAndStatus(Param(name) String name, Param(status) Integer status);XML里照常写#{name}、#{status}语义清晰缺点是要在接口上多写几个注解。方案二封装成Map传参MapString, Object params new HashMap(); params.put(name, 张三); params.put(status, 1); mapper.selectUsersByMap(params);XML里对应#{name}、#{status}。这种方式的缺点是类型不安全Map里塞错了类型要到运行时才能暴露只适合临时快速写。方案三封装成实体对象UserQuery query new UserQuery(); query.setName(张三); query.setStatus(1); mapper.selectUsersByQuery(query);XML里直接#{name}、#{status}因为MyBatis会从实体对象里取属性。这是最安全、可维护性最好的方式尤其当查询条件超过三个的时候比Param一个个写清爽得多。2.3 SQL注入怎么防既然说到了参数传递就绕不开SQL注入。MyBatis里#{}是预编译占位符能有效防注入而${}是字符串拼接直接把值拼进SQL存在注入风险。什么时候才考虑${}只有两种场景一是动态表名比如按月分表user_202401这个没法用预编译占位符二是动态排序比如ORDER BY ${orderByColumn}因为排序字段不能参数化。其他场景能用#{}就别用${}。尤其是条件值、模糊查询的keyword一旦用了${}就是裸奔。模糊查询正确的写法是SELECT * FROM user WHERE name LIKE CONCAT(%, #{keyword}, %)或者先在Java层拼好%${keyword}%再传进来那也是错的应该是Java层拼% keyword %后再用#{}传入。总之${}慎用用了就要确保传入的值是可信的、经过白名单校验的。注意动态排序如果用${}建议在Java层做一层字段白名单判断只允许排序字段名在预设集合里出现否则用户的输入可能直接拼进ORDER BY。3. 结果映射从resultType到resultMap3.1 resultType的自动映射机制查询结果怎么变成Java对象是MyBatis查询功能里最基础但也最容易被忽略的一环。resultType指定的是结果对象的类型MyBatis会自动把SQL查询出来的列名和Java对象的属性名做映射。默认情况下列名和属性名需要完全一致或者开启驼峰映射mybatis: configuration: map-underscore-to-camel-case: true开启之后user_name会自动映射到userName。这个配置强烈建议打开因为绝大多数Java字段命名都是驼峰风格而数据库列名是下划线风格。自动映射也有失效的时候。比如SQL里用了count(*)列名是count(*)映射到Java对象的count属性就失败了。解决办法是取别名SELECT COUNT(*) AS count FROM user还有多表关联查询时两个表都有id列不取别名直接映射结果会把后者的id值覆盖到前者的属性上。这种问题特别隐蔽数据量大或者关联表多的时候很难排查出来。3.2 resultMap的核心配置当自动映射搞不定的时候就得用resultMap。最典型的场景是嵌套对象映射。比如查询订单同时要带出用户信息public class Order { private Long id; private String orderNo; private User user; // 关联的用户对象 }用resultMap这样配置resultMap idOrderResultMap typecom.example.Order id propertyid columnid / result propertyorderNo columnorder_no / association propertyuser javaTypecom.example.User id propertyid columnuser_id / result propertyname columnuser_name / result propertystatus columnuser_status / /association /resultMap这个association标签就是关键它告诉MyBatis查出来的user_id、user_name这些列不要往Order主对象上硬塞要单独组装成一个User对象挂到user属性下面。还有一种对比场景collection处理一对多。比如一个用户有多张订单resultMap idUserWithOrdersMap typecom.example.User id propertyid columnid / result propertyname columnname / collection propertyorders ofTypecom.example.Order id propertyid columnorder_id / result propertyorderNo columnorder_no / /collection /resultMap注意collection用的是ofType而不是javaType因为它泛型里的元素类型是Order而不是集合本身。3.3 嵌套查询与N1问题association和collection除了上面这种嵌套结果映射写法还有一种嵌套查询写法就是通过select属性关联执行另一条SQLassociation propertyuser columnuser_id selectcom.example.mapper.UserMapper.selectUserById /这种写法在XML里看起来很简洁查询主表时自动去查用户表。但它有一个非常严重的问题——N1查询。主表查出来10条订单就会额外执行10次用户查询性能和数据库压力都是灾难。如果系统并发量不高、数据量小偶尔用用还行一旦数据量上来这种写法会让你在性能排查时怀疑人生。我的建议是能用JOIN嵌套结果映射就别用嵌套查询。如果业务上确实需要分开查就在Java层手动做两次查询然后组装至少你把控制权握在自己手里不会出现一条主查询带出上百条辅助查询的局面。4. 动态SQL与查询条件的灵活拼装4.1 动态更新语句的动态SQL组合动态SQL是MyBatis查询功能里最能体现灵活性的部分。很多人写动态更新时最容易犯错只传了部分字段结果更新语句把不需要改的字段也覆盖成了空值。举例想根据传入的字段动态更新用户信息update idupdateUserDynamic parameterTypecom.example.User UPDATE user set if testname ! null and name ! name #{name}, /if if teststatus ! null status #{status}, /if if testemail ! null email #{email}, /if /set WHERE id #{id} /update这个set标签做了两件事自动去掉最后一个条件后面多余的逗号并且只在有字段更新时才生成SET子句。如果所有条件都是null生成的SQL就是UPDATE user WHERE id ?这种SQL在MySQL里会被直接拒绝因为语义上不合法。所以使用动态更新时WHERE条件要包含一个必传参数比如id否则很危险。4.2 动态查询条件的组合方式接着看查询条件。多条件筛选是页面列表最常用的功能——按姓名、状态、时间范围、类型组合查询每个条件都可能为空。实现方式有几种先看if标签的简单组合select idselectUsersByCondition resultTypecom.example.User SELECT * FROM user WHERE 1 1 if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if teststatus ! null AND status #{status} /if if testcreateTimeStart ! null AND create_time #{createTimeStart} /if /selectWHERE 1 1这个写法很多人觉得丑但它是为了防止只有后半段条件拼接后第一条是AND导致的语法错误。其实可以用where标签更优雅地解决select idselectUsersByCondition resultTypecom.example.User SELECT * FROM user where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if teststatus ! null AND status #{status} /if /where /selectwhere标签会自动识别子元素里第一个条件前面的AND或OR拼出来的SQL不会出现WHERE AND name ?这种错误。这个写法在实际项目里是最常用的。4.3 分支选择choose、when、otherwise有些场景不是简单的有则拼、无则跳过而是必须从多个条件里选一个优先级最高的。比如搜索时传了用户ID就按ID精确查没传ID但传了手机号就按手机号查都没传就按默认条件查最新注册用户。这个用if很难优雅地实现因为if是并列关系会同时生效多个条件。此时用chooseselect idselectUserWithPriority resultTypecom.example.User SELECT * FROM user where choose when testid ! null AND id #{id} /when when testphone ! null and phone ! AND phone #{phone} /when otherwise AND status 1 AND create_time DATE_SUB(NOW(), INTERVAL 7 DAY) /otherwise /choose /where /select这个逻辑等同于Java里的if-else if-else只会命中第一个满足条件的when后面的不再判断。用好它你可以把复杂的业务规则直接沉淀在SQL层Java代码变得更干净。4.4 foreach与批量查询foreach是处理集合参数的利器。最典型的场景是IN查询和批量插入。IN查询这样写select idselectUsersByIds resultTypecom.example.User SELECT * FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /selectcollection对应传入的集合参数名。如果Mapper方法里用了Param(ids) ListLong ids这里就填ids。如果没加Param且只有一个集合参数默认名字是list或collection这时collection填list。注意foreach拼接出来的IN列表是预编译参数占位符不是字符串拼接所以安全性没问题。但如果ids集合特别大比如上万条生成的SQL会非常长数据库的执行计划也可能退化。这种情况建议拆分成多批每批500-1000个ID。foreach还支持批量插入的写法insert idbatchInsertUsers INSERT INTO user(name, email, status) VALUES foreach collectionlist itemuser separator, (#{user.name}, #{user.email}, #{user.status}) /foreach /insert这个在数据量适中的时候几百条性能不错。但要注意MySQL对max_allowed_packet有默认限制超大批量插入还是建议用分批插入或直接走ExecutorType.BATCH。4.5 trim标签动态SQL的进阶工具where和set本质上都是trim标签的特殊形式。trim可以自定义前缀和后缀以及要去掉的前后缀字符。比如自己实现一个过滤条件拼接去除多余的ANDtrim prefixWHERE prefixOverridesAND |OR if testname ! nullAND name #{name}/if if teststatus ! nullAND status #{status}/if /trim这里的prefixOverrides会检查子元素拼出来的字符串如果是以AND或OR开头自动去掉这些关键字然后再在整体前加上WHERE后缀。suffixOverrides则对应去掉末尾多余的字符比如set场景里去掉最后的逗号。trim用得好的话可以写出很灵活的SQL拼接逻辑但它也是一把双刃剑——prefixOverrides和suffixOverrides的匹配规则是前缀匹配如果条件值本身的字符串内容恰好以这些关键字开头会误删。实际使用中建议还是优先用where和settrim只在它们无法满足自定义需求时再上。5. 分页查询与游标查询的实战对比5.1 分页查询的三种实现方式分页查询是所有业务系统都绕不开的功能。MyBatis里实现分页有三种层次的方式。第一种最原始自己拼LIMITpublic ListUser selectUsersByPage(Param(offset) int offset, Param(limit) int limit);select idselectUsersByPage resultTypecom.example.User SELECT * FROM user ORDER BY id DESC LIMIT #{offset}, #{limit} /select这种方式简单直观数据量小的时候完全够用。但要注意深分页的场景下offset非常大MySQL依然要先扫描并丢弃前面的数据性能会急剧下降。正确做法是改为基于游标或者基于ID的分页比如WHERE id #{lastId} ORDER BY id DESC LIMIT #{pageSize}。第二种是使用MyBatis官方提供的方法先统计总数再查列表// 你需要有一个查询列表的方法和一个统计总数的方法 long total userMapper.countUsers(condition); ListUser list userMapper.selectUsersByPage(condition, offset, limit);先count再select两步走虽多一次查询但能拿到总记录数方便前端渲染分页组件。这一步的count查询最好复用列表的查询条件否则总数和列表对不上。第三种是使用分页插件。国内用得最多的是PageHelper原理是在MyBatis执行器执行前拦截SQL自动加上count和limit然后从ThreadLocal里还原参数。它的好处是代码侵入性小——你只用写列表查询插件自动帮你完成分页。但它也有坑PageHelper的分页方法必须在查询方法调用前执行中间不能有其他数据库操作否则分页范围错乱遇到多数据源、嵌套查询时插件的行为不可预测最好自己验证依赖插件本质上是黑盒出了问题排查成本高我的意见是个人项目或者中小系统直接用LIMIT方案最简单可控如果一定要用PageHelper务必要在团队里定好规范避免跨方法的资源泄漏问题。5.2 游标查询大数据量导出场景的救星如果是导出百万级数据的场景用LIMIT分页或者一次性SELECT *都会导致内存溢出。MyBatis提供了Cursor接口可以用流式读取的方式逐批处理结果集Select(SELECT * FROM user WHERE status 1) CursorUser scanActiveUsers();调用方这样使用try (CursorUser cursor userMapper.scanActiveUsers()) { for (User user : cursor) { // 逐条处理或者批量攒够1000条后一次性写文件 } }游标查询的关键在于数据库连接必须保持开启状态结果集才会懒加载处理完全部数据后要关闭Cursor否则连接无法归还连接池。使用游标查询时还要注意MySQL的JDBC驱动需要在连接URL上设置useCursorFetchtrue并设置合适的fetchSize否则游标查询可能退化成一次性全量加载。实操中还有一个经验游标查询不要搭配复杂的JOIN和子查询因为数据库为了支持游标ResultSet往往需要临时表或者额外的排序性能不升反降。它最适合的场景是单表全量扫描配合简单的WHERE条件。5.3 count查询的性能优化分页必然涉及countcount查询的性能优化是很多项目忽略的点。SELECT COUNT(*) FROM user WHERE ...在数据量大时MySQL需要扫描满足条件的行数。如果WHERE条件里没有索引覆盖这个count可能比列表查询还慢。优化手段有三层。第一确保where条件里的过滤字段有合适的索引。第二SQL层面避免COUNT(column)优先用COUNT(1)或COUNT(*)这两者在MySQL 8.0之后几乎没有性能差别。第三如果业务对总数的实时性要求不高可以直接用缓存或者统计表避免每次都全表count。6. 关联查询与嵌套映射的取舍6.1 JOIN查询的写法与别名策略多表关联查询在MyBatis里很常见写法上和在SQL客户端里写区别不大但有几个MyBatis特有的注意点。第一个是列名冲突。两表JOIN时两边都有id、create_time这种列名如果不取别名MyBatis自动映射时很可能把后面的值覆盖前面的值。我见过一个案例订单表JOIN用户表订单ID正确映射了但订单状态被用户状态覆盖了数据看起来完全正常就偏差了那么一个字段排查了整整半天。从此我给自己定了个规矩多表查询的SQL里所有字段一律加表别名前缀并且在resultMap里指定column绝不依赖自动映射。第二个是JOIN方向的选择。INNER JOIN、LEFT JOIN在结果集上的区别不用多说但在MyBatis的resultMap里如果用了LEFT JOIN主表行对应的关联表字段可能都是null此时association映射会生成一个user为null的对象。如果你不希望出现null对象而是希望整行的关联字段也默认填充空值那就要在Java层面做空对象兜底。第三个是多个一对多JOIN导致的结果集行数膨胀。比如查用户同时关联订单和角色一条SQL里JOIN了两张一对多的表结果集行数会变成用户数乘以订单数乘以角色数。这不是MyBatis的问题是SQL的笛卡尔积效应。这种情况更合理的做法是拆成多次查询在Java里组装。6.2 collection映射实现一对多一对多查询最常见的就是查用户并带出他所有订单。前面提到了collection的写法这里补充一个细节collection映射默认情况下MyBatis会根据id判断哪些行属于同一条主记录。如果主表的id列没有在resultMap里用id标注MyBatis就无法正确区分哪些订单行属于同一个用户映射结果就会错乱出现订单归属到多个用户的情况。所以使用collection映射时主表主键一定要用id标签声明。这是新手最容易忽略的地方。id不只是在resultMap里标注一个主键它还承担着结果集分组的职责。6.3 一对多查询的分页陷阱如果业务需求是分页查用户每个用户带出最近5条订单很多人的第一反应是SQL里写LIMIT结果发现分页数据完全不对。因为JOIN之后的结果集里一个用户可能对应多行订单LIMIT是作用在物理行上的不是作用在用户维度上的。这种情况有几种处理方案第一种先分页查用户得到当前页的用户ID集合再用IN查询查出这些用户的订单在Java里做分组。这种方式SQL简单、逻辑清晰两次查询的性能也可控。第二种用子查询分页后再JOIN。SELECT u.*, o.id AS order_id, o.order_no FROM ( SELECT * FROM user ORDER BY id LIMIT #{offset}, #{size} ) u LEFT JOIN order o ON o.user_id u.id子查询先把用户分页查出来再关联订单。这样LIMIT作用在用户维度上数据才正确。不过子查询在MySQL里的执行计划并不总是最优数据量大时最好EXPLAIN一下。第三种如果用的是窗口函数MySQL 8.0支持可以在ROWNUMBER分组后再过滤适合复杂场景但是SQL写起来也比较复杂。实践中最推荐的还是第一种简单、直观、可控。7. 高级查询场景的实战技巧7.1 模糊查询的正确姿势模糊查询虽然简单但不同的数据库有不同的写法。MySQL里用LIKE CONCAT(%, #{keyword}, %)其实也就是前面说的防注入写法。PostgreSQL的话有ILIKE可以忽略大小写Oracle则是INSTR函数性能更优。除了写法差异模糊查询的性能问题才是关键。LIKE %keyword%无法利用索引大数据量下全表扫描是必然的。要是业务真的需要这种模糊匹配而且数据量上了百万就应该考虑引入搜索引擎或者全文索引而不是硬扛。7.2 排序功能的动态处理动态排序是另一个高频需求。前端传一个排序字段和排序方向后端拼到ORDER BY里。这里有两种做法安全写法是白名单映射// Java层把前端传的排序字段映射为数据库真实列名 MapString, String sortableColumns new HashMap(); sortableColumns.put(createTime, create_time); sortableColumns.put(id, id); sortableColumns.put(status, status);然后只用映射后的值拼进SQLselect idselectUsers resultTypecom.example.User SELECT * FROM user where !-- 各种条件 -- /where ORDER BY ${sortColumn} ${sortDirection} /select因为sortColumn来自代码里的白名单MapsortDirection只允许是asc或desc这样就不会有SQL注入风险。千万别把前端传的字符串直接拼到ORDER BY里哪怕你觉得排序字段还能有什么坏心思无效排序字段导致的报错和注入攻击都可能在线上等着你。7.3 时间范围查询的边界处理时间范围查询看似简单实际最容易出bug。比如查2024-01-01当天的数据如果直接写create_time 2024-01-01而create_time是datetime类型那你只能查到0点0分0秒那一条数据。正确的做法是AND create_time gt; #{startTime} AND create_time lt; #{endTime}或者用日期函数AND DATE(create_time) 2024-01-01但DATE(create_time)会让create_time列上的索引失效。更推荐的做法是把startTime设为2024-01-01 00:00:00endTime设为2024-01-02 00:00:00用左闭右开区间查。7.4 SQL片段复用与代码整洁性当多个查询都有一堆公共的WHERE条件时SQL会变得非常臃肿。MyBatis提供了sql标签来抽取公共片段sql iduser_condition if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if teststatus ! null AND status #{status} /if /sql使用时通过include引入select idselectUsersByCondition resultTypecom.example.User SELECT * FROM user where include refiduser_condition / /where /select这个功能在大型项目里的价值特别明显——统一的过滤逻辑只维护一份改一处全生效不会出现几个查询的过滤条件不一致的问题。需要注意的是include里的#{name}等参数引用是原样传递的不管这段SQL被引入了多少次参数名都保持统一。8. 常见问题与排查技巧实录8.1 高频异常与解决方案速查表格整理这些年我遇到的高频MyBatis查询异常每个都是真实场景里反复出现的。异常信息原因解决方案Invalid bound statement (not found)XML的namespace或id与Mapper接口方法不匹配检查namespace是否全限定类名一致id是否与方法名一致Parameter xxx not found. Available parameters are [...]多参数时未使用Param给参数加Param注解或封装成对象/MapTooManyResultsException期望返回单条结果但实际查询出多条检查SQL是否有JOIN导致结果集膨胀或调整业务逻辑ResultMap ... does not contain column ...resultMap里没配置但SQL查出了多余列要么在resultMap补上要么在SQL中去掉该列Cause: java.sql.SQLSyntaxErrorException生成的SQL语法错误通常是动态SQL没拼对打开SQL日志打印复制完整SQL到数据库客户端执行第一个异常在开发阶段出现频率最高十有八九是粗心导致的路径不匹配。排查技巧很简单确认XML文件在target/classes里有没有被打包进去。Maven项目如果忘记配置resourcesXML文件不会被拷贝到classes目录运行时就会报这个错。第二个异常在切换到多参数场景时容易出现尤其是重构的时候。我给你一个保底方案所有Mapper接口的方法只要超过一个参数一律使用Param或在代码里封装对象用这个习惯去写就不会再碰到这个报错。第三个TooManyResultsException要特别说一下。很多人以为是SQL的问题其实数据里某条用户ID有两条订单记录一下子查出了多条MyBatis无法从结果集里选一条所以直接抛异常。排查时把SQL拿去跑一下看返回行数就清楚了。8.2 线上查询慢怎么定位MyBatis项目里查询慢的排查路径一般是这样。第一步确认是不是MyBatis生成了预期外的SQL。打开MyBatis的SQL日志打印mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl或者用logging.level.your.mapper.packageDEBUG把mapper包下的所有SQL都打印出来。看SQL是否符合你的预期有没有多余的条件、笛卡尔积的JOIN。第二步拿到SQL后在数据库客户端执行用EXPLAIN看执行计划。重点关注type字段如果是ALL说明全表扫描需要检查WHERE条件是否走索引如果是range或ref说明用上了索引可以继续看rows和Extra字段。Extra里出现Using filesort、Using temporary往往代表排序或者分组的时候没有好好利用索引。第三步根据执行计划反推优化动作。常见的优化手段包括加复合索引把WHERE过滤与排序字段合并成一个复合索引改写SQL把子查询改成JOIN减少扫描行数在业务层面加更多过滤条件。8.3 动态SQL排查的独门技巧动态SQL拼出来的语句一旦报错第一反应不要去看XML和Java代码而是先把最终生成的SQL打出来。比如在XML里临时加一句话select idselectUsers resultTypecom.example.User SELECT * FROM user where if testname ! null AND name #{name} /if /where !-- 临时打印 -- /select其实不需要这么麻烦直接把日志级别调成DEBUG就能看到完整SQL。但有个细节MyBatis打印的SQL里#{}占位符已经被替换成了?参数值在后面的日志区单独打印。如果你在数据库客户端执行需要手动把?替换成实际参数值否则可能因为参数类型不匹配而误导排查方向。另一个技巧是把动态SQL的XML单独复制到一个极简的测试类里用硬编码参数跑一遍快速确认SQL拼接逻辑是否正确。这样比在Spring容器里反复重启快得多。8.4 resultMap映射不生效的排查思路映射不生效的表现往往是Java对象里的属性值为null但数据库查询结果是有值的。排查顺序如下先确认map-underscore-to-camel-case是否开启。没开启的话下划线列名无法自动映射到驼峰属性。然后看SQL别名是否和属性名匹配——开启了驼峰映射后SQL里的列名USER_NAME能映射到userName但user_name AS name这种别名会映射到name因为MyBatis按最终列名去映射。最后检查resultMap如果明确配置了resultMap那么自动映射在它的覆盖范围之外未手动配置的列就不会被映射。还有一个比较隐蔽的问题当结果类型是Map时MyBatis返回的是LinkedHashMap列名变成keyvalue是查询结果。此时如果你期待Map里的key是驼峰风格对不起它是原样SQL列名user_name就是user_name。9. 我的实操心得写到这里最后分享几个我在项目里沉淀下来的习惯。第一个习惯所有Mapper接口的多参数方法一律加Param不管是两个参数还是三个参数。这样XML侧就算以后改参数名IDE的全局搜索也能快速定位不用翻调用链。第二个习惯统一使用resultMap而不是resultType来处理多表JOIN查询的返回结果。虽然resultType在单表查询时很简洁但多表查询一旦列名冲突调试成本远高于写一个resultMap的时间。第三个习惯动态SQL里所有可空条件的判断都用xxx ! null and xxx ! 这套标准写法字符串类型加上非空判断数值类型只判断null。这样统一标准团队成员之间review代码也轻松。第四个习惯公司项目里禁用嵌套查询的association/collection写法。除非确认数据量极小或者接口调用频率极低否则一律用JOIN或者两次查询组装从根上杜绝N1问题。第五个习惯上线前务必把所有查询SQL在测试环境跑一遍用EXPLAIN确认执行计划。很多查询问题在数据量小的时候根本暴露不出来等上线后数据涨起来再排查代价就大了。我自己就吃过这个亏一张百万级的表没加索引的模糊查询硬生生把接口响应时间拖到了5秒最后加了个全文索引才算救回来。MyBatis查询功能远不止文章里提到的这些标签和技巧但把参数传递、动态SQL、结果映射、分页游标这几条主线打通日常开发里九成以上的查询需求你都能应付自如。剩下的那一成无非是业务独有的规则和极端场景到时候你已经有足够的体系去拆解和定位了。最后再补一个实用小技巧如果你的项目是Spring Boot MyBatis强烈建议把MyBatis的SQL日志单独定向到一个日志文件里不要和业务日志混在一起。这样排查问题时grep一下就能拎出来所有SQL比在启动日志里一行行翻爽太多了。