尧图精选

MyBatis实战指南:从JDBC到动态SQL与缓存机制

🕒 发布时间:2026/9/14 3:45:37 📁 来源:尧图网络
1. 开篇为什么是 MyBatis以及它凭什么能扛住真实业务我最早写 Java 后端那会儿用的还是最原始的 JDBC。每次要查一张表先 Class.forName 注册驱动再 DriverManager.getConnection 拿连接接着拼 Statement、executeQuery、循环 ResultSet 封装对象最后还要在 finally 里把六个资源挨个 close。写一个方法大概要 40 行代码而且这里面只要错一步基本上就是 Connection leak 或者空指针的下场。更要命的是SQL 和 Java 代码完全耦合在一起业务一复杂改个查询条件就得动 Java 代码重新编译。那时候我就在想能不能有人把这一套样板代码全部收走让我只写 SQL 和接口MyBatis 解决的就是这个问题。它不是一个“全自动”的 ORM不像 Hibernate 那样把 SQL 都替你生成好而是把 SQL 的完整控制权还给你同时在底层把连接管理、参数绑定、结果集映射、异常处理这些脏活累活全部包掉。简单说你定义一个 Mapper 接口写一个 XML 文件或者注解里面写上你自己的 SQLMyBatis 就能自动完成参数传入、执行语句、把结果集映射成 Java 对象这一整套流程。这套设计在真实业务里有多好用我举一个最直观的例子你接手一个遗留系统里面有一张几十个字段的表生产环境数据量几千万行线上查询条件千奇百怪。用 Hibernate 你大概率会被它的懒加载、N1 查询、缓存不一致搞得焦头烂额用 JDBC 你光写映射代码就写到手酸而 MyBatis 就是让你把 SQL 原样写出来哪里不对改哪里查出来什么就映射成什么清清楚楚、明明白白。坦白说网上关于 MyBatis 增删改查的教程一抓一大把但很多就是“复制一份官方文档的例子跑通就完事”。我这篇不讲那些花架子我把这几年在实际项目里用 MyBatis 的完整细节、踩过的坑、调优过的经验和排查问题的方式都整理出来力求让刚入门的朋友能照着做出来一套能干活的东西让工作两三年的朋友也能在里面找到一些平时没太注意的细节。如果你正准备学 MyBatis或者正在用 MyBatis 做项目却总觉得哪里没吃透这篇应该能帮到你。2. MyBatis 的“增删改查”到底在替你做些什么很多人学 MyBatis 只记住了“写接口、写 XML、调方法”这九个字但根本不清楚框架在底层做了什么。先把这个最核心的机制搞明白后面的内容你才不会学着学着就卡住。2.1 从 JDBC 到 MyBatis到底省掉了什么JDBC 时代一个最普通的查询方法长这样public User findById(Long id) { Connection conn null; PreparedStatement ps null; ResultSet rs null; User user null; try { conn DriverManager.getConnection(url, username, password); ps conn.prepareStatement(SELECT * FROM user WHERE id ?); ps.setLong(1, id); rs ps.executeQuery(); if (rs.next()) { user new User(); user.setId(rs.getLong(id)); user.setName(rs.getString(name)); user.setEmail(rs.getString(email)); // 如果字段有二十个这里就写二十行 } } catch (SQLException e) { // 处理异常 } finally { // 关掉 rs、ps、conn还得判断非空 } return user; }这段代码的问题非常明显每次查询都要重复写连接、建语句、取结果、关资源。只要表的字段一变这二十行 set 代码全要跟着变。如果你在一个有几十张表、几百个查询方法的系统里你就能明白什么叫“增删改查写成体力活”。MyBatis 做的事情就是把这四步全部抽象成了框架的默认行为用SqlSessionFactory统一管理数据库连接从池里取连接、归还连接都由框架处理用XML或注解里的 SQL 语句替换掉 Java 代码里拼接的字符串用parameterType和resultType的映射规则自动完成参数绑定和结果集到对象的转换用MapperProxy动态代理帮你把“调接口方法”和“执行 XML 里的 SQL”绑定起来你写的接口根本没有实现类MyBatis 在运行期给你动态生成。我第一次看到 Mapper 接口不需要写实现类的时候说实话有点懵一个接口怎么就能直接注入使用后来才明白这是 Java 动态代理的经典应用。你调用userMapper.findById(1L)的时候实际进入的是MapperProxy的invoke方法它会根据你调用的方法名找到对应的MappedStatement也就是 XML 里那条 SQL 及其参数、返回值的完整定义然后执行并返回结果。这就是整个 MyBatis 最核心的“接口绑定”机制。2.2 项目里最常用的分层方式既然框架解决的是“数据库操作”这一层那真实项目中一般怎么组织和它搭档这里我用最主流也最稳妥的“三层结构”来搭Controller 层接口层接收前端请求做参数校验和简单的格式转换调用 Service 层。Service 层业务层处理业务逻辑事务控制放在这一层它负责调 Mapper 接口。Mapper 层持久层只负责和数据库打交道一个接口对应一张表或者一个业务域的一组 SQL。以一个用户管理的模块为例Mapper 接口大致长这样Mapper public interface UserMapper { User selectById(Long id); ListUser selectList(UserQuery query); int insert(User user); int updateById(User user); int deleteById(Long id); }这里面有几点需要注意第一Mapper注解是让 Spring 容器能扫描到这个接口并生成代理对象如果你用的是 Spring Boot也可以直接在启动类上加MapperScan(com.example.mapper)一次性扫描整个包省得每个接口都打注解。第二方法名建议见名知意select开头代表查询、insert开头代表插入、update开头代表修改、delete开头代表删除这样代码可读性高很多后续维护的人看到方法名就知道这条 SQL 是干嘛的。2.3 XML 文件里那几个必须配清楚的标签既然 Mapper 接口没有实现类那 SQL 写在哪答案就是同包下的 XML 文件或者直接写在接口方法上用注解。真实项目里我强烈建议用 XML 方式来组织 SQL尤其当你的查询条件、动态判断比较多的时候注解里的字符串写法非常痛苦SQL 一长连缩进和换行都难保证。XML 文件的结构如下?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.mapper.UserMapper select idselectById resultTypecom.example.entity.User SELECT * FROM user WHERE id #{id} /select /mappernamespace必须写接口的全限定名这是 MyBatis 用来关联接口和 XML 的钥匙。select标签的id必须和接口方法名一致resultType或resultMap用来指定结果怎么映射成对象。这两样配错任何一个启动的时候通常不会报错但你一调接口就会看到Invalid bound statement (not found)这个经典报错。我刚带团队的时候接手的同事经常出这种问题排查半天最后发现就是 namespace 少写了一个字母或者 XML 没被编译到 target 目录里。还有个细节XML 文件默认不会被打包进 jar你必须确保 Maven 配置里包含了 mapper 文件的资源路径不然项目启动后也会报“绑定语句找不到”。这个等会儿在搭建步骤里我会再提到。3. 快速搭建一套能跑的 MyBatis 环境在动手写增删改查之前先得把环境跑起来。我用的是 Spring Boot MyBatis 这个组合这也是目前 Java 后端岗位最主流的搭配。搭建过程不需要多花哨但每一步都有讲究。3.1 Maven 依赖引入多不要多少不要少Spring Boot 项目如果用的是mybatis-spring-boot-starter那依赖配置非常简洁dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency这里有个小细节mybatis-spring-boot-starter2.x 版本对应 Spring Boot 2.x到了 Spring Boot 3.x 就要用 3.0.x 以上的版本否则启动时会出现兼容问题。另外如果你的数据库是 MySQL 8 及以上连接驱动用com.mysql.cj.jdbc.Driver别再用老的com.mysql.jdbc.Driver否则会有一个Loading class com.mysql.jdbc.Driver. This is deprecated的警告。3.2 application.yml 里的关键配置以下是我常用的配置片段spring: datasource: url: jdbc:mysql://localhost:3306/test_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里每一行都有它的价值。mapper-locations用来指定 XML 文件的位置我习惯放在resources/mapper目录下。type-aliases-package可以让你在 XML 里写resultTypeUser而不是一长串com.example.entity.User少写很多字。map-underscore-to-camel-case是最实用的一项它能把数据库下划线字段user_name自动映射成 Java 属性的驼峰userName省掉你写一堆resultMap的时间。log-impl设置为 StdOutImpl 后每次执行 SQL 都会在控制台打印完整的预编译语句和参数列表开发阶段排查问题神器没有之一。顺便说一句开发环境打印日志也有讲究。如果你的项目用的是 Logback 而不是标准输出可以把log-impl换成org.apache.ibatis.logging.slf4j.Slf4jImpl然后配置日志级别为 debug这样既能看清 SQL 又不会把标准输出搞得一团乱。3.3 建一张测试表和实体类假设我们要做一个“用户管理”的模块表结构简单一点CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, email varchar(100) DEFAULT NULL COMMENT 邮箱, age int(11) DEFAULT NULL COMMENT 年龄, create_time datetime DEFAULT NULL COMMENT 创建时间, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;对应 Java 实体类public class User { private Long id; private String username; private String email; private Integer age; private LocalDateTime createTime; // getter/setter 省略实际开发用 Lombok 的 Data 就好 }实体类的属性类型要注意数据库bigint对应 Java 的Longdatetime对应LocalDateTime或Datevarchar对应Stringint对应Integer。现在主流的写法是用LocalDateTime替代老的java.util.Date配合 Jackson 的jsr310模块能很方便地处理前后端的时间格式。4. CRUD 实操把四个操作、五种场景的细节抠干净环境搭好之后就到了重头戏——增删改查。这里我不仅写“能跑”的代码还会把参数传递、主键回填、动态 SQL、批量操作这些真实业务里躲不开的点一并说清楚。4.1 查询单条查询、列表查询和模糊搜索单条查询是最简单的接口方法User selectById(Long id)XML 里写select idselectById resultTypeUser SELECT * FROM user WHERE id #{id} /select查询列表通常会有很多筛选条件最规范的做法是单独建一个UserQuery查询对象把可能的查询条件都放进去select idselectList resultTypeUser SELECT * FROM user where if testusername ! null and username ! AND username LIKE CONCAT(%, #{username}, %) /if if testemail ! null and email ! AND email #{email} /if if testage ! null AND age #{age} /if /where ORDER BY id DESC /select这里需要重点解释where和if的配合逻辑。where标签会自动处理掉第一个条件前面的AND也就是当username是空值、只有email有值时最终 SQL 是WHERE email ?不会出现WHERE AND email ?这种语法错误。而if里的 test 写的是 OGNL 表达式username ! null and username ! 是最常见的判空写法注意是and而不是虽然 XML 里需要转义很麻烦但直接写and最稳妥。模糊搜索这里我用了LIKE CONCAT(%, #{username}, %)而不是LIKE %${username}%。原因是#{}在 MyBatis 里会被解析成预编译参数占位符?能有效防止 SQL 注入而${}是直接字符串拼接一旦用户在输入框里传个 OR 11过去你的查询条件就被改了后果不堪设想。所以大家在 XML 里能用#{}就绝对不要用${}。那${}什么时候用比如你要动态传入表名、排序字段名这类不能作为参数占位符的 SQL 片段时才用它而且必须保证传入的值来自白名单不能直接来自用户输入。4.2 插入主键回填是必须学会的技能新增操作的常规写法insert idinsert parameterTypeUser useGeneratedKeystrue keyPropertyid INSERT INTO user (username, email, age, create_time) VALUES (#{username}, #{email}, #{age}, #{createTime}) /insert单看 SQL 确实很简单但我见过太多新手在这里翻车——数据库表里明明设置了自增主键插入完成后却拿不到这个新记录的 id。原因就是你漏掉了useGeneratedKeystrue和keyPropertyid。这两行的作用就是告诉 MyBatis“插入成功后把数据库生成的自增主键值回填到传入的 User 对象的 id 属性上。”也就是说调用userMapper.insert(user);之后立刻就能用user.getId()拿到新记录的 id。在真实开发里这个返回值太常用了。比如你在新增一个订单的时候要先插入订单主表拿到订单 id然后再往订单明细表里插入多条明细每条明细都得带上这个订单 id。如果拿不到主键你就只能再查一次数据库白多一次 IO。还有个细节create_time这类字段建议在 Java 代码里设置好值再传进去而不是依赖数据库的DEFAULT CURRENT_TIMESTAMP。原因很简单这样你在插入完成后打印日志、做后续业务处理时都可以直接使用这个时间值不用再从数据库查一遍。4.3 修改从单条更新到批量更新的扩展思路按主键修改是基础操作update idupdateById parameterTypeUser UPDATE user set if testusername ! nullusername #{username},/if if testemail ! nullemail #{email},/if if testage ! nullage #{age},/if /set WHERE id #{id} /update这里同样用到了动态 SQLset标签会自动去掉最后一个条件后面的逗号。为什么要这么做因为真实业务里前端提交过来的表单常常只包含用户修改过的字段如果你不管三七二十一直接UPDATE user SET username?, email?, age? WHERE id?那些值为 null 的字段会把数据库里的旧数据覆盖掉这绝对是不可原谅的生产事故。举一个我踩过的真实例子用户在自己的个人信息页只改了手机号但你的更新语句把所有字段都更新了一遍结果把用户的头像路径更新成了 null。第二天用户反馈“头像怎么没了”你查日志才发现是更新语句把所有字段都覆盖了。从那以后我养成了一个习惯所有更新操作必须配合if判断或者至少明确区分“全字段更新”和“动态更新”两种方法。批量更新是另一个高频需求。比如后台要批量审核数据、批量上下架商品一种方案是循环调单条 update但几百条数据就要访问几百次数据库性能上非常难看。更普遍的做法是在代码里拼一个批量更新语句update idbatchUpdateByIds UPDATE user SET age #{age} WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /updateJava 方法签名int batchUpdateByIds(Param(ids) ListLong ids, Param(age) Integer age);foreach是 MyBatis 动态 SQL 里最高频的标签之一它的 collection 对应传入参数名item 是循环变量名open、separator、close 负责拼出IN (1, 2, 3)的格式。注意一点IN后面的元素数量不要超过 1000因为一部分数据库对IN列表的长度有限制如果超过会报语法错误或性能剧降真实项目里我会在代码层面对 id 集合做分批处理。4.4 删除单个删除和批量删除的注意事项单条删除delete iddeleteById DELETE FROM user WHERE id #{id} /delete批量删除delete iddeleteByIds DELETE FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /delete删除操作看起来最简单但数据安全上的坑一点也不少。第一如果你在删除之前需要校验数据是否存在比如“这个用户已经有订单了不能删”那这个校验逻辑要么放在 Service 层要么在 SQL 里加AND条件过滤。第二生产环境我强烈建议不要轻易物理删除数据而是用逻辑删除加一个deleted字段删除时执行UPDATE user SET deleted 1 WHERE id ?查询时统一过滤deleted 0。原因不是别的是怕数据不可恢复你永远不知道某条删除的数据哪天会被用来做反查、审计、对账。4.5 参数传递Param 和 POJO 到底怎么选在 Mapper 接口方法里参数传递有两种主流方式。单参数且参数是基本类型或 String 时XML 里可以直接用#{任意名字}比如User selectById(Long id)XML 里写#{id}或#{value}都能取到因为只有一个参数时 MyBatis 不关心你写的名字。但多个参数时就不一样了比如ListUser selectByNameAndAge(String name, Integer age);这个时候如果 XML 里简单写#{name}、#{age}运行时会报Parameter name not found. Available parameters are [arg1, arg0, param1, param2]。正确做法是用Param给每个参数起名ListUser selectByNameAndAge(Param(name) String name, Param(age) Integer age);这样 XML 里就能通过#{name}和#{age}访问了。当参数特别多比如超过三个我建议把它们封装成一个查询对象比如UserQuery或者用 Map 传参。对象传参可读性最高XML 里直接写属性名Map 传参灵活性高但代码可读性差不推荐给别人维护。5. 缓存机制一级缓存、二级缓存与实际场景的取舍标题是增删改查但缓存几乎是 MyBatis 面试必问、工作必踩的扩展话题。很多同学写 CRUD 写了一年可能都不知道 MyBatis 默认开了缓存直到在一次迭代里“查不到刚更新的数据”才发现是自己对缓存的理解出了问题。5.1 一级缓存SqlSession 级别的私藏MyBatis 的一级缓存默认开启作用范围是一次 SqlSession。在 Spring 集成环境下每次方法调用通常对应一个 SqlSessionSqlSession 结束缓存就清空所以一级缓存的实际存在感往往不强。但你要理解它的机制同一个 SqlSession 里执行同一条 SQL相同的 SQL 语句和相同的参数第二次查询会直接从缓存返回不再访问数据库。这个机制在真实开发里会带来一个隐患如果你在同一个 SqlSession 里先查询再把这条数据给改了但缓存没有失效那后续再查就可能读到旧值。当然MyBatis 在更新操作后会清掉一级缓存但如果你在同一事务里先查询、再通过另一个 SqlSession 修改数据库比如绕开 MyBatis 直接改库一级缓存还是会有脏数据的可能。所以我的建议是别在一级缓存上做太多文章理解成“MyBatis 天然防重查”就好真正要注意的是二级缓存。5.2 二级缓存跨 SqlSession 的共享但高风险二级缓存的粒度是 Mapper 的 namespace也就是每个 Mapper 文件一个缓存区域。默认不开启你需要手动配置。开启方式很简单在 XML 里加一个cache/标签然后实体类实现Serializable接口。从性能上看二级缓存确实能帮你减少大量重复查询的数据库压力特别是哪些基本不变化的数据比如字典表、地区表。但它的风险同样明显最典型的就是“脏数据”。如果同一张表被多个 Mapper 操作或者你在一个 Mapper 里更新了数据而另一个 Mapper 里还缓存着旧数据就会造成数据不一致。这个坑一旦踩到排查起来非常痛苦因为问题是间歇性的、跟缓存命中时机有关。我在实际项目里的策略是默认不开二级缓存只有遇到那种“不常变、多线程并发查询量极大、全表数据量又小”的数据才单独开缓存并且要求所有对这张表的写操作都通过同一个 Mapper 完成。否则宁可不要这个性能提升也不去背数据不一致的黑锅。5.3 生产环境 Mysql 查询慢先看一眼缓存和 SQL有段时间我们有个列表页接口响应非常慢排查后发现其实数据量不大SQL 也走了索引问题出在每次请求都会做大量重复查询。当时的优化手段不只是开二级缓存而是把热门查询接口的数据用 Redis 做了一层缓存MyBatis 本身保持最简配置。对于绝大多数 Web 项目来说加一个中间层缓存比在 ORM 里开二级缓存要可控得多。这也是一个架构层面的取舍问题——能用中间件解决的问题别堆在数据库访问层里。6. MyBatis 面试里真正会考的隐藏考点很多同学准备 MyBatis 面试题背了一堆定义和特性但面试官稍微深挖一下就不会了。我以过来人的身份讲几个最容易被问住的点。6.1 #{} 和 ${} 的区别这是最经典的问题没有之一。#{}是预编译占位符MyBatis 会把它替换成?然后通过PreparedStatement设置参数安全且能防 SQL 注入。${}是字符串直接拼接简单粗暴但有注入风险。真实开发里能用#{}的地方绝对不要用${}。那${}能干什么动态表名、动态排序字段比如ORDER BY ${orderBy}因为?不能用在表名或列名位置。但这种场景必须限制传入值比如前端只能传白名单里的create_time、id等字段绝不能直接把用户输入丢进去。6.2 接口没有实现类它是怎么被执行起来的这个问题考察的是动态代理机制。Spring Boot 在启动时通过MapperScan扫描到所有 Mapper 接口并为每个接口生成一个MapperFactoryBean。当你注入这个接口并调用方法时实际调用的是 JDK 动态代理生成的代理对象。代理对象会找到接口全限定名 方法名对应的MappedStatement再把方法参数传入并执行 SQL。理解这个过程你才能明白为什么 namespace 必须精确、为什么方法名不能随便改、为什么重载方法容易出问题。6.3 MyBatis 和 MyBatis-Plus 怎么选现在很多新项目直接用 MyBatis-Plus它相当于 MyBatis 的增强工具包内置了通用 CRUD、条件构造器、分页插件等能力。如果你在做的是后台管理系统这类以简单 CRUD 为主的系统用 Plus 可以节省大量开发时间——你不用再为每张表写一遍一样的 insert/update/selectById。但如果你面对的是复杂报表统计、超大 SQL 优化、多表联查非常多的系统直接写 MyBatis XML 反而更可控。我的看法是MyBatis 是基本功Plus 是效率工具二者不是完全替代关系。面试如果你能把这个“什么场景选什么”讲清楚比单纯背概念加分得多。7. 常见问题与排查技巧实录最后这部分我把自己在这些年真实项目里遇到过的问题整理成了一张速查表每一个都是实际踩过的坑不是从文档里抄的。遇到问题的时候先照着查一圈大概率能救急。问题现象根本原因解决方案启动后调接口报Invalid bound statement (not found)Mapper 接口和 XML 没有正确绑定通常是 namespace 不匹配、方法 id 不对或 XML 没被打进 classpath检查 namespace 是否为接口全限定名、方法名是否一致检查 Maven 是否有 mapper 资源打包配置查询结果全是 nullJava 实体属性和数据库字段不一致开启map-underscore-to-camel-case: true或者用resultMap显式映射插入后拿不到自增主键忘了配置useGeneratedKeys和keyProperty在 insert 标签上加上useGeneratedKeystrue keyPropertyid多参数方法调用报参数找不到没有用Param给多参数命名给每个参数加Param注解XML 里用注解名访问SQL 语句看起来没问题但报语法错误动态 SQL 多了或少了 AND、逗号等问题使用whereset标签不要在if里手动拼 AND/逗号每次查询都很慢但没有慢 SQL一级二级缓存命中率低或大量重复查询先分析日志考虑 Redis 缓存或优化查询粒度中文乱码数据库连接 URL 缺少编码参数url 里加characterEncodingutf8并确保表和连接都使用 utf8mb4除了这些问题我最后再说一个调优技巧开发阶段把log-impl打开生产环境关掉。SQL 打印在开发时能帮你一眼看出 SQL 拼得对不对、参数传得对不对但生产环境开启会平白增加大量日志输出影响性能还容易把敏感数据打出来。这个习惯我从第一次进项目组挨过骂之后就再也没忘过。8. 结尾一次 CRUD 之外的体感分享说实话增删改查这个东西本身并不难任何一个有一点编程基础的人花一两天就能照着教程跑通。但我想说的是真正难的不是写出能跑的代码而是搞清楚每一步背后的机制以及遇到问题时怎么快速定位。你用 MyBatis 写增删改查不能只停在“会调方法”这个层面最好能理解 Mapper Proxy 是怎么生成的、参数是怎么绑定的、结果集是怎么映射的、缓存是怎么失效的。这些知识网格一旦建立起来你排查问题的速度会快好几倍。我在带新人的时候很喜欢让他们做一件事在一个空项目里不用任何代码生成器手动把一张表的增删改查从接口、XML、测试全部写一遍然后故意制造几个问题比如 namespace 写错、字段映射失效、参数没加 Param再让他们自己 debug 排查。经历过这一轮的人之后写代码很少再犯低级错误。如果你现在还在学习阶段强烈建议你也这么来一遍。最后再分享一个小技巧花一个下午把官方文档里 Dynamic SQL 的章节完整读一遍。我在这个行业里这么多年发现大量联调问题、bug 都出在动态 SQL 的细节上而官方文档写得非常清楚只是很多人从来没耐心逐字读过。把文档吃透比到处找教程有用得多。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →