
不用多解释MyBatis 在国内 Java 服务端开发里基本上算是标配了。不管是刚入行的新人还是写了几年业务代码的老手每天都要跟它打交道。但绝大多数人对 MyBatis 的认知停留在会用的层面照着模板写 Mapper改改 XML能跑通就完事。一旦遇到性能瓶颈、SQL 报错、或者需要压榨数据库查询效率的场景往往束手无策。这篇博文我想聊点实在的。从最基础的 CRUD 出发把 MyBatis 的核心机制讲透再结合我实际项目中踩过的坑和优化案例说说怎么让查询性能真正提上去。整篇文章会配上一个完整的示例项目源码代码都能直接跑希望你读完能少走几步弯路。1. 从零搭建一个能打的基础CRUD层1.1 项目准备与目录设计先说项目结构。我用的是 Spring Boot 2.7.x MyBatis 3.5.x MySQL 8.0这套组合是目前中小型项目里最常见的搭配。如果你用的是 Spring Boot 3.x注意引入mybatis-spring-boot-starter版本选 2.3.x 以上兼容性完全没问题。项目依赖在pom.xml里加三样就够dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.0/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdcom.alibaba/groupId artifactIddruid-spring-boot-starter/artifactId version1.2.20/version /dependency目录设计上我习惯按业务模块分包而不是按技术层分包。举个例子一个用户模块就放一起com.example.demo ├── controller │ └── UserController.java ├── service │ ├── UserService.java │ └── impl │ └── UserServiceImpl.java ├── mapper │ ├── UserMapper.java │ └── UserMapper.xml ├── entity │ └── User.java └── config └── MybatisConfig.java这样做的最大好处是需求变更时能快速定位到相关文件。很多人习惯把 Controller 放一个包、Service 放一个包、Mapper 放一个包小项目无所谓但一旦业务复杂起来找文件就是灾难。按模块切分后一个需求的改动都能收敛在同一块目录下维护成本肉眼可见地降低。1.2 第一个CRUD从Mapper接口到XML实体类User.java按数据库字段对应定义public class User { private Long id; private String username; private String email; private Integer age; private LocalDateTime createTime; // getter/setter 省略 }Mapper 接口和 XML 是 MyBatis 里最容易让新手困惑的一环。先看接口public interface UserMapper { User selectById(Param(id) Long id); ListUser selectAll(); int insert(User user); int updateById(User user); int deleteById(Param(id) Long id); }再看 XMLmapper namespacecom.example.demo.mapper.UserMapper select idselectById resultTypecom.example.demo.entity.User SELECT id, username, email, age, create_time FROM user WHERE id #{id} /select select idselectAll resultTypecom.example.demo.entity.User SELECT id, username, email, age, create_time FROM user /select insert idinsert parameterTypecom.example.demo.entity.User useGeneratedKeystrue keyPropertyid INSERT INTO user(username, email, age) VALUES (#{username}, #{email}, #{age}) /insert update idupdateById UPDATE user SET username #{username}, email #{email}, age #{age} WHERE id #{id} /update delete iddeleteById DELETE FROM user WHERE id #{id} /delete /mapper关于useGeneratedKeystrue和keyPropertyid这里展开说。如果不加这两行插入后拿不到自增主键你只能再查一次。加了之后MyBatis 会在执行 insert 后把数据库生成的主键值回填到传入的 User 对象里直接在代码里user.getId()就能拿到新主键。这个细节在日常业务里极其重要比如创建订单后立刻需要订单号做后续操作少了这一步就得多一次查询。1.3 动态SQL摆脱拼接噩梦的起点动态 SQL 是 MyBatis 的灵魂。我用最常用的where和foreach举例。条件查询时用户可能传用户名、可能传年龄段、也可能什么都不传用拼接字符串会非常痛苦select idsearchUsers resultTypecom.example.demo.entity.User SELECT id, username, email, age, create_time FROM user where if testusername ! null and username ! AND username LIKE CONCAT(%, #{username}, %) /if if testminAge ! null AND age gt; #{minAge} /if if testmaxAge ! null AND age lt; #{maxAge} /if /where ORDER BY create_time DESC /select注意两个细节第一where标签会自动去掉第一个多余的 AND/OR不需要你手动处理。如果你用where但第一个条件没命中、第二个命中了生成的 SQL 不会出现WHERE AND这种语法错误。第二XML 里和必须用gt;和lt;转义否则 XML 解析直接报错。这一点我在项目评审里见过太多人踩坑了。条件多的时候也推荐写script标签配合注解写动态 SQL但我的习惯是复杂 SQL 一律用 XML注解只留给简单查询。批处理插入用foreach这个在性能优化里很关键后面会详细对比深浅。2. 核心配置与运行机制搞懂底层才能谈优化2.1 配置文件里的关键参数application.yml里 MyBatis 相关配置并不只是摆设每一项都有实际的性能含义。这是我项目里的配置mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true cache-enabled: true lazy-loading-enabled: true aggressive-lazy-loading: false log-impl: org.apache.ibatis.logging.stdout.StdOutImpl其中map-underscore-to-camel-case: true实现create_time到createTime的自动映射不用手写resultMap。这条配置对开发效率的提升是立竿见影的尤其是表字段多的时候省掉大量重复映射代码。lazy-loading-enabled和aggressive-lazy-loading是关联查询的懒加载开关。简单解释开启懒加载后查主表时不会立刻查关联表而是在访问关联属性时才触发子查询。aggressive-lazy-loading默认是 true会导致任何方法调用都触发懒加载必须手动改成 false 才能真正达到按需查询的效果。这个点面试经常问实际开发里也确实影响性能。2.2 生命周期与作用域MyBatis 里三个核心对象的作用域问题很多人面试能背但项目里确实容易用错。SqlSessionFactory是重量级对象整个应用生命周期只需要一个。构建它需要读取 MyBatis 主配置文件或通过Configuration对象设置。最佳实践是在 Spring Boot 里直接把创建交给框架管理不要自己手写工具类去创建。Configuration public class MybatisConfig { Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/*.xml)); org.apache.ibatis.session.Configuration configuration new org.apache.ibatis.session.Configuration(); configuration.setMapUnderscoreToCamelCase(true); configuration.setCacheEnabled(true); factoryBean.setConfiguration(configuration); return factoryBean.getObject(); } Bean public MapperScannerConfigurer mapperScannerConfigurer() { MapperScannerConfigurer configurer new MapperScannerConfigurer(); configurer.setBasePackage(com.example.demo.mapper); return configurer; } }SqlSession是线程不安全的每个线程必须用自己独立的实例。Spring 管理的 Mapper 代理对象会自动帮你在每次 getMapper 时绑定当前事务的 SqlSession这也是为什么实际开发中我们一般不会直接操作 SqlSession而是通过注入 Mapper 接口来使用。2.3 Mapper代理机制Mapper 接口没有实现类为什么直接Autowired就能调用这个原理很多人没搞清。实际上框架启动时通过MapperScannerConfigurer扫描接口为每个 Mapper 接口动态生成一个代理对象。调用接口方法时代理对象会根据方法名找到 namespace 匹配的MappedStatement也就是 XML 里的 SQL然后执行 SQL 并映射结果。这个过程的关键是 namespace 必须和接口全限定名完全一致XML 中每条 SQL 的 id 必须和方法名一致。所以复制粘贴 XML 时最容易出问题从别的模块复制 SQL忘记改 namespace结果方法调用的 SQL 根本不是你想执行的那条。排查方法很简单打印 SQL 日志看一眼实际执行语句就清楚了。3. 性能优化实战从慢查询到性能飙升3.1 N1查询问题一对多查询的性能杀手N1 问题指的是查询 1 条主记录后程序再循环执行 N 次子查询取关联数据。用户列表有 100 条就额外执行 100 次地址查询数据库连接被打爆一点都不奇怪。两个关联表用户表和订单表。先看错误示范resultMap idUserOrderMap typecom.example.demo.entity.User id propertyid columnid/ result propertyusername columnusername/ collection propertyorders ofTypecom.example.demo.entity.Order columnid selectcom.example.demo.mapper.OrderMapper.selectByUserId/ /resultMap select idselectUsersWithOrdersBad resultMapUserOrderMap SELECT id, username, email, age, create_time FROM user WHERE age gt; #{minAge} /select这种写法的collection标签里select属性指定的子查询会在遍历每行结果时触发一次100 个用户就是 100 次订单查询加上主查询一共 101 次 SQL。数据量一旦上来接口延迟直接干到几秒甚至超时。正确姿势是使用 JOIN 一次查出所有需要的数据。修改后的查询用JOIN关联订单表resultMap idUserOrderMap typecom.example.demo.entity.User id propertyid columnid/ result propertyusername columnusername/ result propertyemail columnemail/ result propertyage columnage/ collection propertyorders ofTypecom.example.demo.entity.Order id propertyid columnorder_id/ result propertyuserId columnuser_id/ result propertyamount columnamount/ result propertystatus columnstatus/ /collection /resultMap select idselectUsersWithOrders resultMapUserOrderMap SELECT u.id, u.username, u.email, u.age, o.id AS order_id, o.user_id, o.amount, o.status FROM user u LEFT JOIN orders o ON u.id o.user_id WHERE u.age gt; #{minAge} ORDER BY u.id /select注意两点第一JOIN查询结果集行数等于订单数MyBatis 会根据collection会自动把相同主键的多行聚合成一个用户对象。通常需要保证主表字段有明确顺序一般按主表主键ORDER BY来避免部分数据库分页时数据错乱。第二字段别名要想清楚不同表同名字段必须用别名区分否则映射结果会错位。JOIN 查询100 个用户只执行 1 次 SQL性能是数量级提升。3.2 批量插入优化从几百秒到几秒单条循环插入是另一个性能黑洞。业务场景导入 Excel一万行数据要插入数据库。新手写法是 for 循环里逐条调用 mapper.insert每条一次数据库交互10000 条就是 10000 次网络往返跑完得几分钟。优化方案是 MyBatis 的批量插入 SQLinsert idbatchInsert INSERT INTO user(username, email, age) VALUES foreach collectionlist itemitem separator, (#{item.username}, #{item.email}, #{item.age}) /foreach /insert对应 Mapper 接口int batchInsert(Param(list) ListUser users);这条 SQL 会把 10000 条记录的插入合并成一条 INSERT 语句执行时间从分钟级降到秒级。MySQL 对单条 SQL 长度有限制默认max_allowed_packet通常是 4MB 或 64MB保险做法是每批 500~1000 条分批提交。我用 1000 条每批实测万级数据插入时间在 2~5 秒之间这个优化对导入类业务是非常直观的提升。批量更新也类似通过foreach拼多条 UPDATE但注意 MySQL 的 CASE WHEN 写法效率比多条 UPDATE 更高语句也短而且只需一次网络往返。如果要更新不同字段可以在set中配合foreach。不过批量更新场景如果逻辑复杂建议先用 SQL 分析工具 EXPLAIN 看清楚写法对索引的影响再执行。3.3 分页查询优化LIMIT 深翻页的坑分页是最常见的查询场景也是问题藏得最深的环节。默认写法是LIMIT offset, size当 offset 到十万、百万级时MySQL 会扫描 offsetsize 行才丢弃前面的数据查询越来越慢。-- 深翻页极慢 SELECT * FROM user ORDER BY id LIMIT 100000, 20;优化办法是用延迟关联或者基于游标的分页。延迟关联的核心思想是先查主键再用主键查完整数据SELECT u.id, u.username, u.email, u.age, u.create_time FROM user u JOIN (SELECT id FROM user ORDER BY id LIMIT 100000, 20) tmp ON u.id tmp.id ORDER BY u.id;如果业务场景适合基于游标通常要求排序字段唯一且单调比如按 id 排序那改成WHERE id #{lastId} ORDER BY id LIMIT 20性能是最好的因为它是天然走主键索引范围扫描数据量再大也不怕。但如果要支持上一页逻辑游标方式就不太方便。实际项目里滚动加载适合游标分页传统页码跳转我用延迟关联兜底性能相比普通LIMIT有显著改善。MyBatis 的分页插件 PageHelper 很好用但它本质上改写了 SQL执行深分页时仍然会生成LIMIT offset, size。所以用了 PageHelper 不等于分页性能就高底层还是得靠 SQL 层面的优化。3.4 缓存策略实战一级缓存与二级缓存MyBatis 缓存是面试高频点也是性能优化的压轴戏。先讲清楚原理再谈怎么用。一级缓存是 SqlSession 级别的本地缓存默认开启。同一个 SqlSession 内执行相同的查询第二次不会走数据库直接返回缓存结果。注意Spring 管理下一个事务通常对应一个 SqlSession所以事务内的两次相同查询走一级缓存。但它有个大坑任何一次增删改操作都会清空一级缓存所以缓存命中率其实低得可怜不要指望它解决性能问题。二级缓存是 Mapper 级别的多个 SqlSession 共享在 XML 里加一行就能开启cache evictionLRU flushInterval60000 size512 readOnlytrue/参数里eviction是淘汰策略flushInterval是刷新间隔毫秒size是最大缓存对象数readOnly设为 true 能省掉序列化开销但前提是缓存对象不会被修改。一旦设置了二级缓存该 Mapper 下的所有查询结果默认都会缓存但是如果是按某个条件组合实现查询敏感的场景务必在对应 statement 上加useCachefalse。二级缓存生效有个前提查询操作所在的 SqlSession 必须提交或关闭后缓存才会写入。Spring 里就是事务提交后生效。如果事务回滚缓存不会被写入这点要注意。实际使用中我不太建议在核心业务表上开二级缓存。原因很简单缓存失效策略难控制。一旦有第三方系统直接改库、或者你用原生 SQL 做复杂聚合缓存数据就不可信了查出来的全是脏数据。真需要缓存优先考虑引入 Redis 这类独立缓存中间件让缓存生命周期可控。3.5 源码级优化思路减少不必要的动态SQL解析很多人不知道MyBatis 每次执行动态 SQL 时都要解析 XML 标签和 OGNL 表达式尤其foreach元素多的时候解析耗时不可忽略。MyBatis 对动态 SQL 做了缓存也就是说相同 MappedStatement 第一次解析后会有缓存整体影响较小。但你如果有大量构造不同的动态语句可以考虑把高频的小查询直接改成纯静态 SQL 或者用注解写固定语句减少每次执行的解析开销。另一个容易被忽视的点resultType 映射的性能。如果查询只返回单行或者少量数据不要图省事直接select *。宽表全字段查询的映射开销和网络开销都不是免费午餐。只取需要的字段既能减少 IO 也能减少映射工作量。这条规则对大数据量查询尤其明显。4. 常见问题与排查技巧实录4.1 高频报错速查表以下这几类问题是我在实际排障中遇到最多的整理成速查表方便大家直接对号入座报错信息根因解决方案Invalid bound statement (not found)Mapper 接口和 XML 的 namespace 或方法 id 不匹配检查 namespace 是全限定接口名id 和方法名一致而且 XML 在mapper-locations扫描路径内TooManyResultsException单结果方法返回多条数据检查 SQL 条件是否真的过滤到唯一记录或者改用List接收BindingException: Parameter xxx not found多参数方法没加Param多参数时每个参数都加ParamXML 里用#{paramName}引用BadSqlGrammarExceptionSQL 语法错误通常是表名、字段名写错或者 XML 转义问题打开 SQL 日志log-impl 配置查看打印的最终 SQL 语句到数据库客户端直接执行验证OutOfMemoryError: Java heap space查询结果集过大一次性映射全部数据增加 JVM 堆内存治标改用分页或流式查询治本ResultMap xx not foundresultMap 拼写错误或找不到定义检查resultMap和id是否完全一致并且同文件被正确加载4.2 SQL日志的正确打开方式排查 SQL 问题第一件事就是打开 SQL 日志。配置很简单mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这种方式直接打印到控制台适合开发环境。生产环境我建议配 logback 或 log4j2将 Mapper 包的日志级别设为 DEBUGlogging: level: com.example.demo.mapper: debug这样既能拿到完整 SQL 和参数又能避免把全项目日志打成海洋。一个常用的进阶技巧是使用IDEA MyBatis Log Free插件它能把 MyBatis 输出的预编译 SQL 和参数自动拼接成可执行 SQL直接复制到数据库客户端跑排查问题特别高效。4.3 源码导读建议标题里提到含源码所以最后快速讲一下拿到项目源码后怎么读。我的建议是先读主流程不要从头到尾一行行啃。推荐阅读顺序先跑起来项目用 Postman 或浏览器调几个接口把 CRUD 请求流程走通。对照UserMapper.xml跟UserMapper.java理解接口方法和 SQL 的映射关系。重点看UserController - UserService - UserMapper的调用链搞清楚参数怎么传递。再看application.yml的 MyBatis 配置项把每个配置项含义搞清楚。最后看优化相关的 SQL像批量插入、JOIN 结果映射、深分页优化在数据库里执行一遍看效果。源码在 GitHub 上我放在demo/mybatis-optimize-demo目录包含完整可运行的 Spring Boot 项目、建表 SQL 和测试数据。建议你拿到后先把application.yml里的数据库连接改成本地环境然后用schema.sql建库建表就能直接启动体验。4.4 面试高频题串联回顾这篇博文讲到的知识点基本把 MyBatis 面试高频题覆盖得七七八八了我把核心题型列一下方便你自己检验掌握程度MyBatis 和 MyBatis-Plus 有什么区别直白点说Plus 是 MyBatis 的增强版。MyBatis 需要手写 SQLPlus 提供通用 MapperBaseMapper和条件构造器简单 CRUD 不用写 SQL但复杂查询依旧要写 XML。Plus 内置了分页插件和乐观锁插件想省事就用 Plus想对 SQL 有绝对控制力、对查询边界有要求就用原生 MyBatis。#{}和${}有什么区别#{}是预编译参数占位会替换成?安全防注入${}是字符串直接拼接有 SQL 注入风险只建议用在表名、排序字段等必须动态拼接的地方而且必须手动做好白名单校验。一级缓存和二级缓存的区别与隐患一级缓存 SqlSession 级别默认开启作用范围小二级缓存 Mapper 级别跨 SqlSession可配置序列化和淘汰策略但一致性风险高核心数据慎用。Mapper 接口没有实现类Spring 是怎么注入的靠 JDK 动态代理MapperFactoryBean生成代理对象调用方法时根据方法名匹配 XML 中同 id 的 MappedStatement执行 SQL 完成持久化。MyBatis 分页原理PageHelper 通过 MyBatis 拦截器对Executor做拦截改写 SQL 为其追加LIMIT offset, size查询总数时执行 count 查询。因为拦截的是 SQL 字符串所以对结果集会再次封装返回 PageInfo。5. 写在最后一些实际踩坑的体会说实话MyBatis 本身不复杂复杂的是数据库、业务场景和性能之间的平衡。我在项目里见过太多人把慢查询甩锅给 MyBatis其实 MyBatis 只是翻译 SQL 的工具SQL 本身烂的话换什么 ORM 都救不了。有一点我一直强调任何优化都必须建立在可观测的基础上。没有 SQL 日志、没有慢查询统计、没有执行计划开口就是这里加缓存那里改多线程本质上都是碰运气。先把日志打开用EXPLAIN看执行计划定位真正的瓶颈再动手优化这才是靠谱的工程方法。最后分享一个小技巧真遇到诡异问题时直接用 MyBatis 的Configuration对象打印出已注册的 MappedStatement 清单能快速验证 XML 是否被正确加载。写PostConstruct方法启动时把加载结果输出一次节省排查时间。不要问为什么你遇到明明改了 XML 但行为没变的问题时会感谢这个技巧的。如果这套代码和思路对你有帮助可以拉下来跑一遍也可以按你自己的业务场景改造试试。数据库性能优化的路很长踩坑越多长进越快。