MyBatis查询操作实战:从基础配置到动态SQL与性能优化
1. 从“查”开始:MyBatis查询操作的核心价值与常见误区
如果你刚接触MyBatis,或者已经用它写过不少增删改,但总觉得查询这块儿用起来有点“别扭”——要么是结果映射总出问题,要么是动态SQL写得不够优雅,再或者性能上总觉得差点意思——那你来对地方了。查询,作为数据持久层最核心、最高频的操作,其实现方式直接决定了应用的数据访问效率和代码的可维护性。很多人把MyBatis的查询简单地理解为“写个SQL,返回个List”,这其实错过了它设计上的许多精妙之处。
我见过不少项目,查询代码写得像“面条”,各种if判断嵌套在Java代码里拼接SQL字符串,不仅难以维护,SQL注入的风险也悄然滋生。也有的项目,过度依赖“自动映射”,导致数据库字段名的一个小小改动就引发运行时异常。MyBatis的强大,在于它在“便捷”和“灵活”之间找到了一个平衡点:它用XML或注解帮你管理SQL,用结果映射处理复杂的对象关系,用动态SQL应对多变的查询条件,同时又让你对最终执行的SQL拥有完全的控制权。今天,我们就抛开那些笼统的概念,直接深入到三种最典型查询场景的肌理中:查询所有、查询单行、条件查询。我会结合我踩过的坑和优化经验,让你不仅知道怎么写,更明白为什么这么写,以及怎么写更好。
2. 基石搭建:MyBatis查询环境的核心配置与Mapper定义
在动手写查询之前,确保你的“工作台”是稳固的。很多查询时遇到的诡异问题,根源往往在配置阶段就埋下了。这里没有太多炫技的东西,但每一步都至关重要。
2.1 数据源与SqlSessionFactory:查询的发动机
一切始于SqlSessionFactory,它是MyBatis的“发动机工厂”。通常我们在Spring Boot项目中通过配置类或application.yml来定义它。核心是数据源和Mapper接口的扫描路径。
# application.yml 示例 spring: datasource: url: jdbc:mysql://localhost:3306/your_database?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: # 重要:指定Mapper XML文件的位置。如果Mapper接口和XML文件在同一包下,且命名相同,可以省略。但显式指定更安全。 mapper-locations: classpath:mapper/*.xml # 重要:配置类型别名包,这样在XML里就不用写全限定类名了 type-aliases-package: com.example.demo.entity configuration: # 开启驼峰命名自动映射。这是处理数据库字段名(user_name)到Java属性名(userName)转换的利器。 map-underscore-to-camel-case: true # 建议开启,可以在控制台打印执行的SQL,调试神器。 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl注意:
map-underscore-to-camel-case是一个非常有用的配置,但它不是万能的。当你的查询涉及复杂的联表、结果集包含同名字段,或者你使用了resultMap进行自定义映射时,这个配置可能会失效或产生冲突。我的经验是,对于简单的单表查询可以依赖它,但对于复杂查询,显式定义resultMap是更稳妥的做法。
2.2 Mapper接口与XML的绑定:契约的签订
MyBatis的核心思想之一是将接口方法与SQL语句绑定。假设我们有一个User实体和一个UserMapper接口。
// User.java @Data // 使用Lombok简化代码 public class User { private Long id; private String userName; // 对应数据库 user_name private Integer age; private String email; } // UserMapper.java @Mapper // Spring Boot中标识这是一个MyBatis Mapper接口 public interface UserMapper { // 方法1:查询所有用户 List<User> selectAll(); // 方法2:根据ID查询单个用户 User selectById(@Param("id") Long id); // 方法3:条件查询用户列表 List<User> selectByCondition(User user); }接口定义好了,SQL在哪里?有两种主流方式:XML和注解。对于简单的、固定的SQL,注解非常简洁;但对于动态SQL,XML的可读性和灵活性远胜于注解。考虑到我们后续要深入动态SQL,这里统一使用XML方式。在resources/mapper/目录下创建UserMapper.xml。
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.demo.mapper.UserMapper"> <!-- 后续的SQL定义将在这里进行 --> </mapper>关键点是namespace属性,它必须完全对应你的Mapper接口的全限定名。这是MyBatis将XML中的SQL语句与接口方法连接起来的桥梁。如果这里写错,你会遇到经典的“Invalid bound statement (not found)”错误。
3. 查询所有数据:selectAll的实践与性能隐忧
“查询所有”听起来最简单,但如果不加思考,它可能成为性能的“黑洞”。我们先从基础实现开始。
3.1 基础实现与结果映射
在UserMapper.xml的<mapper>标签内,添加我们的第一个查询语句。
<!-- 1. 查询所有用户 --> <select id="selectAll" resultType="User"> SELECT id, user_name, age, email FROM user </select>id="selectAll": 必须与UserMapper接口中的方法名selectAll一致。resultType="User": 指定返回值类型。因为我们配置了type-aliases-package,所以可以直接写类名User。MyBatis会创建User对象的列表(List<User>)并将查询结果自动映射进去。
在Service层调用它:
@Service public class UserService { @Autowired private UserMapper userMapper; public List<User> getAllUsers() { return userMapper.selectAll(); } }看起来完美,对吧?但这里有几个新手容易忽略的细节:
- 字段匹配:
resultType依赖自动映射。确保SQL查询的列名(如user_name)能通过驼峰规则(或完全匹配)映射到User对象的属性(userName)。如果数据库有create_time字段而实体类没有,没关系,多出的字段会被忽略。但反之,如果实体类有某个属性而SQL没查询对应的列,该属性将为null。 - SQL注入:在这个简单的例子里,SQL是静态的,没有参数,所以没有注入风险。但请记住这个原则:永远不要通过字符串拼接的方式来构造查询所有语句中的条件,比如
SELECT * FROM user WHERE name = ‘” + name + “‘,这是大忌。
3.2 “查询所有”的陷阱与分页考量
SELECT * FROM user在测试环境几十条数据时跑得飞快,但在生产环境,用户表可能有百万、千万行。一次性加载所有数据到内存,会导致:
- 内存溢出(OOM): 应用服务器内存被撑爆。
- 数据库压力: 巨大的结果集传输占用大量网络IO和数据库连接时间。
- 响应缓慢: 用户前端等待时间极长。
所以,真正的“查询所有”在业务中极少见。更常见的需求是“分页查询所有”。在MyBatis中,我们有几种方式实现分页:
方案一:使用MyBatis-Plus等插件(推荐)MyBatis-Plus内置了强大的分页插件,配置简单,功能强大。
方案二:使用PageHelper(国内流行)这是一个非常方便的第三方分页插件,通过拦截器实现,几乎零侵入代码。
方案三:手动编写分页SQL如果你需要极致的控制或处于一个非常简单的环境,可以手动传参。
<!-- UserMapper接口添加方法 --> List<User> selectAllByPage(@Param("offset") Integer offset, @Param("pageSize") Integer pageSize); <!-- 对应的XML --> <select id="selectAllByPage" resultType="User"> SELECT id, user_name, age, email FROM user LIMIT #{offset}, #{pageSize} </select>实操心得: 对于大多数项目,我强烈推荐使用MyBatis-Plus。它的分页配置一行代码搞定,并且能自动优化
count查询,支持多种数据库方言。自己手写分页SQL,不仅容易出错(比如分页参数计算错误),而且在不同的数据库(MySQL的LIMIT、Oracle的ROWNUM、PostgreSQL的LIMIT/OFFSET)上需要写不同的SQL,维护成本高。记住,selectAll在实际编码中,几乎总是和limit成对出现。
4. 精确获取单条记录:selectById的细节与空值处理
根据主键ID查询单条记录,是仅次于查询列表的高频操作。它通常用于详情查看、数据编辑前的回显等场景。
4.1 基础实现与@Param注解
在UserMapper.xml中继续添加:
<!-- 2. 根据ID查询单个用户 --> <select id="selectById" resultType="User"> SELECT id, user_name, age, email FROM user WHERE id = #{id} </select>这里的#{id}是一个占位符,MyBatis会使用预编译语句(PreparedStatement)来设置参数,有效防止SQL注入。它对应接口方法User selectById(@Param(“id”) Long id);中的参数。
@Param(“id”)注解在这里起到了关键作用:它显式地告诉MyBatis,这个参数在SQL中的名字叫做“id”。如果方法只有一个参数,并且你在XML中就用这个参数名,有时可以省略@Param。但我的强烈建议是:始终使用@Param注解来明确参数名。这能避免很多因参数名编译后丢失(特别是在JDK8以上,使用了-parameters参数除外)导致的诡异问题,也让代码意图更清晰。
4.2 单条查询的边界情况与结果处理
这个方法返回的是单个User对象,而不是List。这里有几个重要的边界情况需要处理:
查询结果为空(无此ID): MyBatis会返回
null。调用方必须做好空值判断,否则后续的user.getUserName()就会抛出NullPointerException。User user = userMapper.selectById(99999L); if (user == null) { throw new BusinessException("用户不存在"); } // 安全地使用user查询结果不止一条: 如果你的
id字段不是主键或唯一约束,理论上可能返回多行。MyBatis在这种情况下会抛出TooManyResultsException异常。这通常意味着你的表设计或查询条件有问题。确保where条件能唯一定位一条记录。使用
resultMap进行复杂映射(进阶): 当查询需要关联其他表,或者字段映射关系复杂时,resultType就不够用了。这时需要定义resultMap。
假设每个User拥有多个Order,我们想在一次查询中获取用户及其所有订单(一对多)。这虽然超出了“查询单行”的范畴,但展示了resultMap的强大。
首先,定义Order实体和扩展的User实体(包含订单列表)。
<!-- 定义一个名为 UserWithOrdersResultMap 的 resultMap --> <resultMap id="UserWithOrdersResultMap" type="User"> <id property="id" column="id"/> <result property="userName" column="user_name"/> <result property="age" column="age"/> <result property="email" column="email"/> <!-- collection 处理一对多关系 --> <collection property="orderList" ofType="Order"> <id property="orderId" column="order_id"/> <result property="orderNo" column="order_no"/> <result property="amount" column="amount"/> </collection> </resultMap> <!-- 使用 resultMap 而不是 resultType --> <select id="selectUserWithOrdersById" resultMap="UserWithOrdersResultMap"> SELECT u.*, o.order_id, o.order_no, o.amount FROM user u LEFT JOIN `order` o ON u.id = o.user_id WHERE u.id = #{id} </select>注意: 这种联表查询在数据量大时需谨慎,可能产生“N+1”查询问题。对于大数据量关联,分步查询(使用
<association>/<collection>的select属性)通常是更好的选择,但这属于更高级的优化话题。对于简单的selectById,resultType足矣。
5. 动态条件查询:selectByCondition与动态SQL的精髓
条件查询是业务系统中最灵活、最复杂的部分。用户可能根据姓名、年龄范围、邮箱等多种条件组合筛选,而且这些条件可能为空。这就是动态SQL大显身手的地方。
5.1<if>标签:构建灵活的WHERE子句
回到我们的UserMapper.selectByCondition(User user)方法。我们希望实现:如果user对象的某个属性不为空,则将其作为过滤条件。
<!-- 3. 条件查询用户列表 --> <select id="selectByCondition" resultType="User" parameterType="User"> SELECT id, user_name, age, email FROM user WHERE 1=1 <if test="userName != null and userName != ''"> AND user_name LIKE CONCAT('%', #{userName}, '%') </if> <if test="age != null"> AND age = #{age} </if> <if test="email != null and email != ''"> AND email = #{email} </if> </select>parameterType="User": 可以省略,MyBatis通常能自动推断。WHERE 1=1: 这是一个“小技巧”。目的是让后面的<if>标签都能统一地用AND开头,避免第一个条件为空时SQL出现WHERE AND的错误。虽然有些人觉得不优雅,但在纯<if>标签的场景下非常实用。<if test="...">:test属性内是OGNL表达式,用于判断条件是否成立。userName != null and userName != ''是常见的判空和非空字符串写法。
调用示例:
User queryCondition = new User(); queryCondition.setUserName("张"); // 查询姓名包含“张”的用户 queryCondition.setAge(25); // 同时年龄等于25 List<User> users = userMapper.selectByCondition(queryCondition); // 生成的SQL: SELECT ... FROM user WHERE 1=1 AND user_name LIKE '%张%' AND age = 255.2<where>,<set>,<trim>:更优雅的动态SQL标签
WHERE 1=1毕竟是个取巧的办法。MyBatis提供了更专业的<where>标签来处理这个问题。
<select id="selectByConditionV2" resultType="User"> SELECT id, user_name, age, email FROM user <where> <if test="userName != null and userName != ''"> AND user_name LIKE CONCAT('%', #{userName}, '%') </if> <if test="age != null"> AND age = #{age} </if> <if test="email != null and email != ''"> AND email = #{email} </if> </where> </select><where>标签会做两件事:
- 只有当其内部至少有一个条件成立时,才会插入
WHERE关键字。 - 会自动去除掉紧跟其后第一个条件的
AND或OR。这样我们就不用写1=1,并且每个条件前都可以放心地加AND。
类似地,<set>标签用于动态更新语句,会自动处理末尾的逗号。<trim>标签则更通用,可以自定义前缀、后缀以及要覆盖的字符串,用于处理更复杂的场景。
5.3<choose>, <when>, <otherwise>:实现分支选择
有时我们的逻辑不是简单的“如果…就加上”,而是“多选一”。例如,按照优先级查询:先按姓名精确匹配,如果没找到再按姓名模糊匹配,最后按邮箱匹配。
<select id="selectByConditionV3" resultType="User"> SELECT id, user_name, age, email FROM user <where> <choose> <when test="userName != null and userName != ''"> user_name = #{userName} <!-- 优先精确匹配 --> </when> <when test="userName != null and userName != ''"> user_name LIKE CONCAT('%', #{userName}, '%') <!-- 其次模糊匹配 --> </when> <otherwise> email = #{email} <!-- 最后匹配邮箱 --> </otherwise> </choose> <if test="age != null"> <!-- 其他条件可以额外附加 --> AND age = #{age} </if> </where> </select><choose>类似于Java中的switch-case,只会执行第一个满足条件的<when>,或者执行<otherwise>。
5.4 模糊查询与性能注意
注意上面我们用到了LIKE CONCAT(‘%’, #{name}, ‘%’)来进行模糊查询。这是防止SQL注入的正确写法(使用#{}占位符)。直接写LIKE ‘%${name}%’是危险的,因为${}是字符串替换,会有注入风险。
但是,前导模糊查询(LIKE ‘%xxx’)是无法使用数据库索引的,会导致全表扫描,数据量大时性能极差。如果业务允许,尽量使用后导模糊查询(LIKE ‘xxx%’),这样可以利用索引。或者考虑引入Elasticsearch等全文检索引擎。
6. 避坑指南:MyBatis查询中的典型问题与排查思路
即使掌握了语法,在实际开发中你还是会遇到各种各样的问题。下面是我总结的几个高频“坑点”和排查思路。
6.1 “Invalid bound statement (not found)” 错误排查
这是MyBatis新手遇到最多的错误,意思是“找不到绑定的SQL语句”。排查路径如下:
- 检查Mapper接口与XML的namespace: 确保XML中
<mapper namespace=”…”>的值完全等于Mapper接口的全限定名(包括包名),一个字母都不能错。 - 检查方法名与SQL ID: 确保XML中
<select id=”…”>的值与接口方法名一致。 - 检查XML文件位置与配置: 确保
UserMapper.xml文件位于mybatis.mapper-locations配置的路径下(如classpath:mapper/)。在Maven项目中,确保XML文件放在src/main/resources对应的目录下,而不是src/main/java。 - 检查编译结果: 清理项目并重新编译(
mvn clean compile),确保XML文件被打包到最终的target/classes或build/classes目录下。 - 检查IDEA等IDE的配置: 有时IDE的“资源过滤”可能有问题,可以尝试
Rebuild Project。
6.2 参数传递与@Param注解的玄机
- 单个基本类型参数: 可以不用
@Param,在XML中直接用#{参数名}或#{_parameter}引用。但为了清晰,建议都用@Param。 - 多个参数:必须使用
@Param,否则在XML中只能通过#{arg0},#{arg1}或#{param1},#{param2}来访问,可读性极差。 - 传递对象: 如
selectByCondition(User user),在XML中可以直接使用对象的属性名,如#{userName},MyBatis会通过OGNL表达式user.userName来获取值。 - 传递Map: 在XML中直接使用Map的key,如
#{nameKey}。这在动态条件非常多时偶尔有用,但不如对象直观。
6.3 结果映射失败:属性为null的常见原因
- 数据库字段名与对象属性名不匹配: 这是最常见的原因。检查是否开启了
map-underscore-to-camel-case,或者是否在resultMap中正确配置了映射。 - SQL查询未返回该列: 检查你的
SELECT语句,是否漏掉了某个字段。特别是当你用了SELECT *,但后来数据库表增加了字段,而实体类未更新时,不会报错,但新增字段不会被映射。 - 类型不匹配: 数据库是
TINYINT(1),Java中用Boolean接收;数据库是BIGINT,Java中用Integer接收。这会导致映射失败或精度丢失。 - 嵌套对象映射问题: 在使用
association或collection进行复杂映射时,如果嵌套对象的属性映射没配好,整个嵌套对象都可能为null。
调试技巧: 开启MyBatis的SQL日志(配置log-impl: StdOutImpl),仔细核对打印出的SQL语句和参数,看是否和你预期的一致。也可以临时将resultType改为map,看查询返回的List<Map<String, Object>>里到底有哪些键值对。
6.4 动态SQL中的test表达式陷阱
<if test=”…”>中的test是OGNL表达式,它和Java语法有些微差别:
- 判断字符串是否为空:
name != null and name != ‘’。注意单引号。 - 判断集合是否为空:
list != null and list.size() > 0。 - 注意与(and)和或(or): 要用
and和or,而不是&&和||。 - 调用静态方法:格式为
@全限定类名@方法名(参数),如@java.util.Objects@equals(str1, str2),但这样写很繁琐,尽量避免。
7. 性能优化与进阶思考:让查询飞起来
写出来能用的SQL只是第一步,写出高性能的SQL才是进阶之路。
7.1 善用索引与避免全表扫描
这是数据库层面的优化,但需要在写MyBatis SQL时时刻谨记:
- 为
WHERE子句、ORDER BY子句、JOIN关联字段建立索引。 - 避免在索引列上使用函数或计算,如
WHERE YEAR(create_time) = 2023会导致索引失效。应改为范围查询WHERE create_time >= ‘2023-01-01’ AND create_time < ‘2024-01-01’。 - 谨慎使用
OR,可能导致索引失效,考虑用UNION改写。 - 像前面提到的,避免前导模糊查询
LIKE ‘%xxx’。
7.2 分页查询的深度优化
简单的LIMIT offset, size在offset非常大时(比如第100万条开始),性能会急剧下降,因为MySQL需要先扫描并丢弃前面的大量记录。
优化方案:
- 使用索引覆盖扫描 + 子查询(适用于有自增主键或有序字段的表):
SELECT * FROM user WHERE id >= (SELECT id FROM user ORDER BY id LIMIT 1000000, 1) LIMIT 10; - 记录上一页的最大ID(适用于“上一页/下一页”式分页):
这种“游标分页”方式性能极佳,但不支持直接跳转到任意页码。SELECT * FROM user WHERE id > #{lastMaxId} ORDER BY id LIMIT 10;
7.3 关联查询的“N+1”问题与解决方案
这是ORM框架的一个经典问题。假设你要查询10个用户,以及每个用户的所有订单。如果你先查询用户列表(1次查询),再循环为每个用户查询订单(N次查询),这就是N+1次查询,性能极差。
MyBatis的解决方案:
- 嵌套结果映射(一次查询,联表): 就像前面
resultMap中<collection>的例子,一次SQL联表查询出所有数据。缺点是当关联数据很多时,结果集会有大量冗余数据(用户信息重复),可能影响传输和内存。 - 嵌套查询(分步查询): 在
resultMap中配置<collection>时,使用select属性指定另一个Mapper方法。
这样,首先执行查询用户的SQL。只有当程序真正访问<resultMap id="UserWithOrdersLazyResultMap" type="User"> <id property="id" column="id"/> <!-- ... 其他基础字段映射 ... --> <collection property="orderList" ofType="Order" select="com.example.demo.mapper.OrderMapper.selectByUserId" column="id"/> </resultMap> <select id="selectUserByIdWithLazyOrders" resultMap="UserWithOrdersLazyResultMap"> SELECT * FROM user WHERE id = #{id} </select>user.getOrderList()时,MyBatis才会执行selectByUserId去查询订单。这实现了“懒加载”。你可以通过配置fetchType=”lazy”或fetchType=”eager”来控制加载行为。嵌套查询可以避免数据冗余,但会产生多次数据库往返(1+N次),如果N很大且你确实需要所有关联数据,性能可能更差。需要根据数据量和业务场景权衡。
7.4 考虑使用MyBatis-Plus等增强工具
对于日常开发,MyBatis-Plus(MP)能极大提升效率。它内置了通用Mapper,像selectById、selectList(条件查询)这些方法你都不用写了。它的QueryWrapper或LambdaQueryWrapper提供了一种更符合Java习惯的、类型安全的方式来构建动态查询条件,避免了在XML中写大量<if>标签。
// 使用MyBatis-Plus的LambdaQueryWrapper示例 LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.isNotBlank(user.getUserName()), User::getUserName, user.getUserName()) .eq(user.getAge() != null, User::getAge, user.getAge()) .eq(StringUtils.isNotBlank(user.getEmail()), User::getEmail, user.getEmail()); List<User> list = userMapper.selectList(wrapper);代码更简洁,而且编译时就能检查属性名是否正确,避免了XML中属性名拼写错误到运行时才发现的问题。当然,MP并不能完全替代原生MyBatis在复杂SQL和极致优化上的灵活性,但它能覆盖80%的日常场景,让开发更聚焦于业务逻辑。
查询是MyBatis的起点,也是最能体现其设计哲学的地方。从简单的select *到复杂的动态SQL与结果映射,每一步都需要理解其背后的原理和权衡。记住,没有最好的写法,只有最适合当前场景的写法。多思考数据库执行计划,多观察生成的SQL日志,不断从业务需求和性能瓶颈中寻找平衡点,你就能越来越得心应手地驾驭MyBatis,让它成为你手中高效、可靠的数据访问利器,而不是bug和性能问题的来源。
