拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Spring Boot整合MyBatis实战:配置、分页、缓存与踩坑指南

做过几年Java后端对springboot和mybatis这个组合已经熟悉到闭着眼睛都能配项目了。Spring Boot把我最头疼的那堆Spring配置全部自动化MyBatis又保留了手写SQL的掌控感这两者搭在一起确实成了当下Java后端的主流选择之一。但这篇文章不是来讲官网文档那些基础内容的我想把实际项目里真正用出来的经验捋一遍整合配置要注意什么、注解SQL和XML到底怎么选、分页插件有哪些坑、缓存机制是怎么运转的以及那些高频报错背后的真实原因。希望能帮正在做springboot项目的朋友少走点弯路。1. Spring Boot整合MyBatis前先把选型和配置逻辑搞清楚1.1 技术选型为什么是MyBatis而不是JPA、Hibernate很多人一上来就纠结用Spring Data JPA还是MyBatis。我的观点很直接如果你的项目里复杂查询、多表联查、SQL调优场景多团队对数据库的掌控要求高那MyBatis就是更稳的选择。JPA确实方便它把数据库操作封装得很抽象增删改查几乎不用写SQL但这种抽象是有代价的——一旦碰到复杂的动态查询、报表统计这类场景要么写JPQL要么直接上原生SQL反而两头不讨好。打个不恰当的比喻Spring Data JPA像是一台自动挡车日常代步确实舒服MyBatis更像手动挡操作门槛高一点但你能精确知道车子在什么转速换挡复杂路况下更心里有数。MyBatis的核心理念是SQL由你掌控它替你管好参数映射、结果集映射这些脏活但SQL本身怎么写、怎么优化完全是你自己做主。这种模式特别适合那种SQL功底强的团队也适合需要对接复杂业务库的老系统改造项目。我参与过的几个项目里凡是报表类、统计类功能多的最终都选了MyBatis开发效率和后期维护都比JPA顺畅不少。当然如果你做的是一些纯CRUD的管理后台表结构简单团队也习惯了JPA那套用Spring Data JPA也完全没问题。选型从来不是比技术高低而是看团队和业务场景。1.2 依赖引入与基础配置照着这份做就行整合前的准备其实很简单核心就两步引入依赖、写配置。Maven项目的依赖长这样dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency这里有个细节很多人容易忽略mybatis-spring-boot-starter的版本要和Spring Boot版本匹配。Spring Boot 2.x对应的是mybatis-spring-boot-starter 2.xSpring Boot 3.x必须用3.x版本的starter因为Spring Boot 3把javax包迁移到了jakarta包版本不对会直接启动报错。如果你用Spring Boot 2.7这种版本就用2.3.x的starter稳定得很。配置这块我把最常用的放到application.yml里spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true先说数据库连接MySQL 8以后的驱动类名改成了com.mysql.cj.jdbc.Driver还在用com.mysql.jdbc.Driver会提示已废弃虽然暂时能跑但不建议在正式环境里留这种隐患。serverTimezoneAsia/Shanghai是时区参数很多朋友遇到时间字段差8小时的问题基本都是漏了这个参数。再说mybatis配置mapper-locations指定XML文件的位置我习惯把Mapper接口和XML分开XML统一放在resources/mapper目录下。type-aliases-package用来简化实体类的别名配了之后在XML里写parameterType或resultType时可以直接写类名不用写全限定名。map-underscore-to-camel-case是我必开的选项它能自动把数据库的下划线字段名映射成实体类的驼峰属性名比如user_name自动映射到userName省掉大量resultMap配置。注意map-underscore-to-camel-case只解决简单字段映射嵌套对象的映射还是需要显式写resultMap。2. 注解SQL和XML映射怎么选我的搭配思路2.1 注解SQL适合简单CRUD但别硬撑MyBatis支持在Mapper接口方法上用注解直接写SQL比如Select(SELECT * FROM user WHERE id #{id}) User findById(Integer id); Insert(INSERT INTO user(name, age) VALUES(#{name}, #{age})) Options(useGeneratedKeys true, keyProperty id) int insert(User user); Update(UPDATE user SET name #{name} WHERE id #{id}) int update(User user); Delete(DELETE FROM user WHERE id #{id}) int delete(Integer id);注解SQL的优点是轻量一个接口文件就把SQL和映射关系写完了不用翻目录找XML。我用它最多的场景是单表简单增删改查、以及一些只有两三个条件的简单查询。Options(useGeneratedKeys true, keyProperty id)这个配置很实用执行插入后MyBatis会把数据库自增主键回填到实体对象的id属性上省去再查一次的麻烦。但注解SQL的缺点也很明显复杂SQL写在Java字符串里非常痛苦。多条件动态查询要拼字符串拼接需要小心翼翼地加空格和判断条件稍微复杂一点整个方法就变成一坨不可维护的字符串。我自己踩过这个坑一个统计报表的SQL在注解里写了几十行还涉及到动态条件最后改动起来痛苦得不行。所以我的原则是超过三行的SQL、任何带动态条件的SQL、涉及多表关联的SQL一律放XML。2.2 XML映射文件实际项目的绝对主力重点说透XML映射文件是MyBatis最重要的部分。先看一个标准结构?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.demo.mapper.UserMapper resultMap iduserResultMap typeUser id propertyid columnid/ result propertyuserName columnuser_name/ result propertycreateTime columncreate_time/ /resultMap select idfindByCondition parameterTypemap resultMapuserResultMap SELECT * FROM user where if testname ! null and name ! AND user_name LIKE CONCAT(%, #{name}, %) /if if testage ! null AND age #{age} /if /where /select /mappernamespace必须写Mapper接口的全限定名select的id必须和接口方法名一致否则启动时或调用时会报Invalid bound statement (not found)。resultMap解决数据库字段和实体属性不一致的问题字段多的表我一般都维护一个resultMap比依赖自动驼峰转换更可控。这里必须重点说一个安全细节#{}和${}的区别。这是MyBatis使用中最关键的问题也是面试常客。#{}是预编译占位符MyBatis会把它替换成?然后用PreparedStatement绑定参数能有效防止SQL注入而${}是字符串拼接直接把值拼进SQL语句里存在注入风险。我从不在业务查询条件里用${}只在极少数需要动态传表名、排序字段的场景才用它比如select idqueryList resultTypeUser SELECT * FROM ${tableName} ORDER BY ${orderColumn} DESC /select这种场景下传进来的值必须经过白名单校验否则后门大开。XML里还有一个常见的坑大于号、小于号这类特殊字符直接写在SQL里会导致XML解析报错。以前写时间范围条件时写过WHERE create_time #{startTime}结果XML直接红了。解决办法是用 包裹或者使用转义字符![CDATA[ AND create_time #{startTime} AND create_time #{endTime} ]]2.3 多参数传递为什么必须用Param初用MyBatis的朋友经常遇到这种报错Parameter name not found. Available parameters are [arg1, arg0, param1, param2]。这背后是MyBatis的参数处理机制。当接口方法有多个参数时MyBatis会把它们封装成一个Map但又没有保留原始参数名所以默认只能用param1、param2或者arg0、arg1来取。解决方式最标准的就是加Param注解ListUser findByNameAndAge(Param(name) String name, Param(age) Integer age);加了之后XML里就能直接用#{name}、#{age}。为什么很多公司规范里强制要求多参数必须用Param除了避免报错更重要的是可读性。param1这种名字在半年后看代码时根本不知道代表什么Param相当于给参数起了个语义明确的名字。还有一个好处是它可以和动态SQL配合比如 里的判断条件用的就是Param里定义的名字。单参数场景就比较自由了单个简单类型可以直接在XML里写#{任意名字}比如#{id}、#{value}都可以。但为了统一规范我个人的习惯是即使单参数也尽量加Param这样接口方法看一眼就知道参数代表什么。3. 分页插件和动态SQL实战里最能提效的两个招式3.1 PageHelper分页插件的配置与用法含避坑指南分页是后台管理系统的刚需功能。MyBatis本身不带分页功能手写limit翻页虽然不难但每张表都要重复写一遍count查询和分页查询代码量大还容易在不同数据库之间翻车。PageHelper是我用得最多的分页插件它通过拦截器机制在你执行查询前自动改写SQL加上limit关键字同时帮你执行count查询。引入方式很简单dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependencyyml里可选地加上方言配置pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true使用方式更是简单分页查询前调用一次startPagePageHelper.startPage(pageNum, pageSize); ListUser userList userMapper.selectAll(); PageInfoUser pageInfo new PageInfo(userList);startPage之后紧跟的第一个查询会被拦截并分页。查询返回的List实际上是Page对象可以拿到total、pages这些信息我建议直接用PageInfo包装一下它会把分页信息和当前页数据统一封装好前端要的total、pageNum、pageSize、list全都有。注意startPage和要分页的查询之间绝对不能穿插其他查询操作。因为PageHelper是用了ThreadLocal保存分页参数的任何一步中间操作触发了一次查询这个分页参数就会被消耗掉导致后面的查询没有被分页还白白多执行一次count。这里分享几个我踩过的坑分页参数和查询之间用了Stream、forEach这类循环操作里面调了一次别的Mapper查询分页就失效了。更新操作也会消耗分页参数所以startPage后面最好紧跟要分页的查询中间什么都不要做。reasonable: true这个参数建议打开它会让页码越界时自动矫正到第一页或最后一页而不是返回空列表对接口的健壮性有帮助。分页插件和自定义拦截器混用时要注意执行顺序。如果项目里有自己写的MyBatis拦截器拦截器的执行顺序由Intercepts注解和配置顺序决定有时分页插件会拦截不到预期SQL这种问题不好排查最好在集成时就规划好。3.2 动态SQL多条件查询和批量操作的核心平时写接口最怕的是什么是查询条件不确定。用户可能只填了姓名也可能姓名加年龄加时间段全填了。用Java代码拼SQL字符串不仅丑还容易因为空格问题拼出错SQL。MyBatis的动态SQL就是解决这个问题的。先看一个多条件查询的典型写法select idsearchUsers resultTypeUser SELECT * FROM user where if testname ! null and name ! AND user_name LIKE CONCAT(%, #{name}, %) /if if testage ! null AND age #{age} /if if teststartTime ! null AND create_time #{startTime} /if /where ORDER BY create_time DESC /selectwhere标签有个很聪明的设计如果所有条件都不成立它不生成WHERE关键字如果第一个条件成立且前面还有个多余的AND它会自动把开头的AND去掉。这个特性的功劳在于你完全不用操心每个条件前是否要加AND动态SQL帮你处理了。批量插入也是一个高频场景。如果写Java循环一条条insert几千条数据能跑一会儿而且每次插入都是一次网络往返。foreach标签可以一次性拼出多条VALUESinsert idbatchInsert INSERT INTO user(name, age) VALUES foreach collectionlist itemitem separator, (#{item.name}, #{item.age}) /foreach /insertSQL层面这就变成了一句insert多values的语句性能提升非常明显。我实测过插入2000条数据单条循环插入要十几秒批量插入只要几百毫秒。但要注意MySQL的max_allowed_packet参数限制了单条SQL的最大包大小一次性插入太多数据会报PacketTooBigException建议每500条切一批。动态SQL还有choose、when、otherwise标签类似Java的switch-case适合那种如果传了A条件就用A查询否则用B查询的场景。还有set标签用于动态更新语句它会自动根据传入字段生成SET子句配合 判断哪个字段不为空再更新哪个字段比全字段update灵活得多。这些掌握后MyBatis的战斗力基本就拉满了。4. 缓存机制和SQL日志理解透了你排查问题会快很多4.1 一级缓存和二级缓存到底怎么运转的以及我的使用建议缓存能显著提升读多写少场景的性能但没搞清楚缓存机制就乱开反而会踩大坑。一级缓存是SqlSession级别的默认开启。同一个SqlSession内执行两次相同的查询第二次会直接命中缓存不再查数据库。但这个缓存在Spring Boot整合后就变得很微妙Spring管理的SqlSession生命周期很短每次Mapper操作基本都会新开一个SqlSession用完就关闭导致一级缓存实际能命中的机会很少除非你在同一个事务方法里连续执行两次查询。所以网上很多教程动辄把一级缓存说得神乎其神但在Spring Boot项目里它的作用真的很有限。二级缓存是Mapper级别的确切地说是namespace级别它跨SqlSession共享。开启方式是在Mapper XML里加一行cache/默认的二级缓存是本地内存缓存不是分布式的。这个特性在单机场景不错但放到多实例部署或者分布式环境下就会出现严重问题两个应用节点各自缓存一份数据某个节点更新了数据库另一个节点的缓存还是旧的。更麻烦的是如果一张表被多个Mapper操作而只有其中一个Mapper开了二级缓存很容易出现脏数据。我的使用建议是除非是那种数据变更频率极低、查询量极大、且确认单机部署的内网系统否则不要开MyBatis自带的二级缓存。我现在做的项目里凡是需要缓存的统一用Redis自己控制缓存逻辑缓存什么、什么时候失效、怎么更新都由业务代码显式控制。这样可控性高也不会发生MyBatis缓存和数据库数据不一致却查不出原因的情况。4.2 配置打印SQL日志两种方式我都会用排查SQL语句问题第一步永远是看实际执行的SQL长什么样。MyBatis打印SQL日志的配置我常用两种方式。第一种配置是MyBatis自带的日志实现mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这种方式会把SQL和参数直接打到控制台好处是配置简单缺点是没有日志级别控制输出比较粗暴。第二种是配置Mapper接口的日志级别logging: level: com.example.demo.mapper: debug这种方式输出是通过SLF4J走的会带上时间戳、线程名这种标准格式日志量比第一种方式更规范最重要的是可以配合logback一起控制输出。Comprehensive项目的统一日志体系建议用这种方式调试时把mapper包级别调到debug上线调回info。打开SQL日志后你会看到类似这样的输出 Preparing: SELECT * FROM user WHERE id ? Parameters: 1(Integer) Columns: id, name, age Row: 1, 张三, 28 Total: 1很多朋友遇到参数没生效、查不到数据的问题看一眼日志就能定位Preparing显示的执行SQL是否正确Parameters显示参数类型和值是否正常Total显示查到的行数。这个习惯很值——遇到MyBatis相关的诡异问题先开日志别急着猜。5. 常见报错与性能排查这些坑我都踩过5.1 高频报错速查表Invalid bound statement等我在群里回答过太多newbie问题MyBatis的报错种类不算多但每个都让新手头疼。这里整理一张我亲手排查过的速查表报错信息原因解决办法Invalid bound statement (not found)mapper-locations路径没配好或者XML的namespace/方法id和Mapper接口不匹配检查mapper-locations的路径检查namespace是否全限定名检查select id是否对应方法名Parameter xxx not found多参数方法没加Param加上Param并给每个参数命名java.lang.IllegalArgumentException: Result Maps collection already contains valueXML中有重复的id检查resultMap或select的id是否重复Could not find result map xxxresultMap类型引用错误检查resultMap的id和type属性LocalDateTime不能用MyBatis版本太老3.4.0之前不支持JSR-310时间类型升级到MyBatis 3.5或检查mybatis-spring-boot-starter版本TooManyResultsException接口方法返回类型是实体类但SQL却查出多条修改返回类型为List或SQL加limit 1Invalid bound statement是最经典的报错十个人有八个都遇到过。根源就三类一是classpath路径下根本没找到对应的XML文件比如mapper-locations写的classpath:mapper/*.xml但XML放在resources/mapper2目录下当然找不到二是XML文件找到了但namespace写错了MyBatis找不到对应Mapper接口三是select标签的id和接口方法名不一致方法匹配不上。排查顺序我建议先确认XML文件有没有编译到target/classes里再确认namespace对不对最后看id是否一致。5.2 性能问题N1查询和批量操作的优化MyBatis应用最常见的性能问题就是N1查询。典型场景是查了一个列表列表有100条数据然后循环遍历这100条数据在循环体内执行Mapper查询去关联查询每条数据的附加信息。表面上看代码逻辑没问题实际执行了101条SQL。虽然每一条都很快但加起来开销就大了数据库连接、网络往返全都在消耗。解决N1查询的办法有几个。简单场景用关联查询一次性把数据join出来比如left join关联用户表一条SQL拿到全部数据稍微复杂的场景用IN查询先查出主列表再根据主列表的ID集合一次性查出关联信息然后在内存里做映射也就是IN (1, 2, 3, 4...)这种形式实在解决不了的报表场景可以考虑用XML里的collection或association标签做嵌套结果映射。批量操作的优化我在动态SQL那一节提过这里再补充一点除了用foreach批量Insert批量Update也可以类似处理或者用case when的方式拼接成一条update语句。另外注意批量SQL执行前建议在jdbc连接串上加rewriteBatchedStatementstrue这个参数MySQL才能真正走批量执行路径不然即使用了foreach底层可能还是一堆单条执行。还有一个小排查技巧如果发现某个查询特别慢把打印出来的SQL复制到数据库客户端里用EXPLAIN看执行计划看有没有走全表扫描、索引是否生效。MyBatis本身不会帮你优化SQL但它把所有SQL都摊开给你看了这就是它比JPA有优势的地方——排查优化路径非常清晰。6. 简单理解MyBatis的自动装配回答面试题不慌6.1 MapperScan到底做了什么以及starter的自动配置逻辑很多面试官爱问Spring Boot如何整合MyBatis其实就是考察你知不知道mybatis-spring-boot-starter背后的自动装配原理。我们先从使用者的角度回忆一下整合MyBatis时你做了什么引入starter依赖配置数据源然后在启动类或配置类上加了MapperScan(com.example.demo.mapper)后面的Mapper接口就能直接注入使用了。就这么简单但背后流程其实挺多的。MapperScan这个注解的核心作用是扫描指定的包把包下所有Mapper接口都注册成Bean。具体实现是通过MybatisAutoConfiguration这个自动配置类配合MapperScannerConfigurer完成的。MyBatis的Mapper接口本身不能直接实例化为普通Bean因为它没有实现类所以MyBatis为每个Mapper接口创建了一个动态代理对象这个代理对象实现了Mapper接口调用方法时会路由到对应XML里的SQL语句。Spring Boot的自动配置其实就是在spring.factories里声明了MybatisAutoConfiguration它在容器中创建了SqlSessionFactory和SqlSessionTemplate这两个关键Bean。SqlSessionFactory负责加载mybatis-config全局配置、解析mapper-locations里的XML文件、构建出MappedStatement一条SQL对应的封装数据结构SqlSessionTemplate则是SqlSession的实现类所有Mapper代理最终都会通过它来执行SQL。所以整个链路是这样的MapperScan扫描包生成Mapper代理Bean代理执行方法时从SqlSessionFactory解析好的MappedStatement拿到SQL信息通过SqlSessionTemplate执行JDBC操作然后把结果集通过resultType或resultMap映射成实体对象返回。理解了这条链路面试时被问到MyBatis整合原理就不用背答案了。6.2 自定义配置入口ConfigurationCustomizer和TypeHandler自动配置帮你做了大部分事但它也预留了自定义扩展的入口。最常用的一个就是ConfigurationCustomizer它允许你在MyBatis的Configuration对象上做额外设置。比如我在项目里用它统一注册自定义TypeHandlerBean public ConfigurationCustomizer mybatisConfigurationCustomizer() { return configuration - { configuration.setMapUnderscoreToCamelCase(true); configuration.addTypeHandler(new MyJsonTypeHandler()); }; }TypeHandler是MyBatis里容易被忽视但很强大的功能。它负责Java类型和数据库JDBC类型之间的转换。比如数据库里存了一个JSON字符串实体类里想直接映射成一个List对象就可以写一个自定义TypeHandler在读写时自动做序列化和反序列化。另一个高频场景是枚举类型。实体类里的枚举属性数据库里存的是整数或字符串默认情况下MyBatis用EnumTypeHandler处理会存枚举名这往往不是你想要的样子。自定义TypeHandler可以让你指定数据库存枚举的code字段Java代码里用枚举对象。配置一次全局通用比在业务代码里反复做转换干净得多。还有一个很有用的配置是databaseIdProvider它可以让你按不同数据库写多套SQL通过databaseId属性指定。如果项目需要同时兼容MySQL和Oracle这个配置就特别有用了。Spring Boot整合MyBatis配置很灵活关键是你要知道自定义扩展的入口在哪里而不是只会用现成的默认配置。7. 最后再分享一点我自己的实操体会用mybatis-spring-boot-starter组合做了这么多年项目最大的体会是MyBatis的掌控感是它最大的价值。框架只是帮你管好了参数、结果映射、连接管理这些重复工作但每条SQL的写法、每个索引的选择、每次查询的优化都得自己心里有数。所以如果你刚开始学MyBatis建议从XML映射文件开始用先把#{}和${}的区别、resultMap的映射逻辑、动态SQL的标签用法这些基本功打扎实再考虑用注解SQL和一些高级技巧。还有一个个人习惯想分享配置里一定要开启map-underscore-to-camel-case但复杂的查询结果仍然建议显式写resultMap。前者是偷懒的捷径后者是稳定的保证。两者配合使用既能减少大量简单映射的配置量又能在多表联查时保证映射不出错。最后SQL日志一定要配好它是我排查问题和优化SQL时最依赖的工具。懂得用日志看MyBatis实际执行的SQL、看参数绑定是否正常你解决MyBatis问题的速度会比别人快很多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门