ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot整合Mybatis实战:分页、缓存、多数据源与踩坑全解析

Spring Boot整合Mybatis实战:分页、缓存、多数据源与踩坑全解析 1. 从一次分页慢查询说起Spring Boot里Mybatis的真实使用姿势去年我接手了一个老项目用户反馈后台列表打开要三四秒。翻代码发现Mapper接口里写着ListUser selectPage(Param(page) int page, Param(size) int size)XML里拼了个limit #{page}, #{size}虽然SQL能跑但count查询是单独写死的一条压根没走索引。更麻烦的是项目里同时存在三种风格的SQL——注解、XML、还有直接用SqlSessionTemplate的后来人维护起来极其痛苦。这个场景我估计很多人都不陌生。Spring Boot整合Mybatis看起来简单——依赖一引、注解一标、SQL一写——但真正落地的时候各种细节问题就冒出来了。这篇文章就基于我用Spring Boot Mybatis做了多个项目的实际经验把从自动装配、Mapper绑定、动态SQL、缓存到第三方组件集成的完整链路拆开讲一遍。不管你是刚接触Spring Boot的新人还是已经在用Mybatis但偶尔被坑一把的选手这篇文章应该都能给你一些参考。先说一个大前提Spring Boot官方和Mybatis官方对集成这件事的定位是不同的。Spring Boot讲究自动配置Mybatis的mybatis-spring-boot-starter则是在这个机制上做了适配。你引入依赖后能直接Autowired一个Mapper接口那是因为框架在启动阶段帮你把一大堆东西装配好了。理解了这个装配过程很多玄学问题就都不是玄学了。2. 自动装配背后的关键环节SqlSessionFactory与Mapper代理的生成逻辑mybatis-spring-boot-starter的自动配置核心是MybatisAutoConfiguration。它干了几件重要的事构建SqlSessionFactory、注册SqlSessionTemplate、扫描Mapper接口并注册为Bean。2.1 SqlSessionFactory的构建依赖哪些配置SqlSessionFactory的构建需要DataSource。Spring Boot默认帮你配置了数据源如果只引入了一个DataSource的话然后MybatisAutoConfiguration会把DataSource、GlobalConfig、Configuration这些信息塞进SqlSessionFactoryBean。这里有三个配置项是大家最容易忽略的mybatis: mapper-locations: classpath*:mapper/**/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: truemapper-locations如果没配置默认会去找classpath*:com/example/demo/mapper/**/*.xml这种路径很多人XML确实放在了这个默认位置所以没问题但一旦你改了包名或者把XML挪到了resource目录下就很容易出现Mapper接口存在但找不到SQL的报错。type-aliases-package配合map-underscore-to-camel-case是最省事的组合——数据库字段的user_name会自动映射到实体类的userName不用写一堆resultMap。我当时排查慢查询问题时顺手把项目的mybatis.configuration配置加了log-impl: org.apache.ibatis.logging.stdout.StdOutImpl开了SQL打印这才发现count查询压根没走索引。建议所有人在开发环境都把这个日志打开观察SQL的实际执行情况。2.2 Mapper接口是怎么变成Bean的MapperScan注解或者Mapper注解是入口。Spring Boot启动时MapperScannerConfigurer会把指定包下的所有接口扫描出来然后为每个接口生成一个MapperFactoryBean。这个FactoryBean做的事情很关键它通过SqlSessionTemplate拿到Configuration对象然后调用configuration.addMapper()注册这个Mapper接口。从这里就能理解一个常见问题——为什么有时候Mapper接口能注入但调用方法时报Invalid bound statement (not found)。这个异常的本质是接口注册到了Mybatis里但MapperStatement没有注册进去。MapperStatement的注册依赖XML文件或者注解上的SQL语句如果你的XML路径没被mapper-locations扫到或者接口方法上既没注解也没对应的XML片段那运行时就会出现这个错误。我印象很深的一次线上问题是同事把一个Mapper接口从com.example.mapper挪到了com.example.db.mapper但XML文件还在老目录下MapperScan也只扫了老的包名。结果就是接口能注入但所有方法一调用就报Invalid bound statement。解决办法很简单——要么把XML也移过去要么把MapperScan的扫描范围改大但从这个案例能看出Mapper接口、XML路径、扫描包名三者必须严格保持一致。2.3 一个容易被忽略的Configuration对象mybatis.configuration这个前缀下能配置一堆Mybatis原生属性比如cache-enabled、lazy-loading-enabled、default-executor-type等。但要注意如果你在代码里直接有个Bean返回org.apache.ibatis.session.Configuration它会覆盖掉配置文件里的configuration相关设置因为MybatisAutoConfiguration里有条件注解检测到自定义的Configuration Bean后会使用你的配置而忽略mybatis.configuration下的配置项。这点在老项目改造时特别容易踩。我当时想通过配置文件统一开启驼峰映射但项目里有个MybatisPlusConfig的类返回了MybatisPlusConfiguration直接导致配置文件里的驼峰设置没生效查了半天才反应过来——排查配置不生效问题时先看看代码里有没有手动创建的Configuration Bean。3. 分页查得慢背后的SQL注入风险三种动态SQL写法的性能差异回到开头的分页案例。那个项目的Mapper接口里直接写了selectPage方法XML中拼接了limit参数这种做法有几个问题。3.1 为什么分页要单独写countMybatis原生分页需要我们手动处理count查询。很多人写分页只写select * from t_user limit x, y然后total在前端写死或者不传这在小数据量下没问题但一旦数据量上来前端表格的分页条数就会对不上。正确做法是先查count再查列表但这两个查询要放在一个事务里保证数据一致性。select idcountByCondition resultTypelong SELECT COUNT(*) FROM t_user WHERE status #{status} /select select idselectPage resultTypeUser SELECT * FROM t_user WHERE status #{status} ORDER BY create_time DESC LIMIT #{offset}, #{size} /select这样写看起来没问题但你会发现业务复杂后每个分页查询都要写两个SQL而且count(*)查询的WHERE条件必须和列表查询完全一致一旦漏掉一个条件排查起来非常痛苦。3.2 PageHelper到底帮你做了什么后来我引入了PageHelper它通过拦截器在运行时拦截到带Page参数的Mapper方法然后自动改写SQL生成count查询和分页查询。用起来很简单PageHelper.startPage(page, size); ListUser list userMapper.selectByCondition(status); PageInfoUser pageInfo new PageInfo(list);但原理要清楚PageHelper.startPage()后第一次执行的查询会被拦截所以你不能在startPage和Mapper方法调用之间做别的SQL操作。项目里出现过诡异的分页数据错误排查后发现是有人在startPage后面插了一段缓存查询结果PageHelper把那段缓存查询当成了目标SQL表都查错了。还有一点PageHelper的count查询是自动生成的它会在你原SQL外面包一层SELECT COUNT(*) FROM (原SQL)如果原SQL里有ORDER BYcount查询里也会有这个排序略微浪费性能。数据量大时可以手写countSQL覆盖掉默认行为。3.3${}和#{}的分水岭那段时间我还看了项目里已有的SQL写法发现不少历史代码用了${}直接拼接表名或者排序字段。比如select idselectDynamic resultTypeUser SELECT * FROM t_user ORDER BY ${orderByColumn} ${orderByDir} /select这种写法能实现动态排序但排序字段没法用#{}预编译只能靠${}拼接这就引入了SQL注入风险。我处理这类需求的方案是加白名单在Java代码里对传入的排序字段做合法性校验只允许传递白名单内的字段名排序方向也限定为ASC或DESC两个枚举值。private static final SetString ALLOWED_COLUMNS Set.of(create_time, login_count); public void setOrder(String column, String dir) { if (!ALLOWED_COLUMNS.contains(column)) { throw new IllegalArgumentException(非法排序字段); } // dir 只接受 ASC / DESC }一句话总结能走预编译的用#{}必须动态拼接的用${}但要加白名单校验。4. 从数据对不上到数据错乱一级缓存与二级缓存的完整复盘缓存问题是我排查时间最长的一个坑。项目上线运行了两个月突然有用户反馈列表数据刷新后时有时无主键相同的记录出现了不同的字段值。4.1 一级缓存线程级别的一个隐形状态Mybatis一级缓存是默认开启的作用域是SqlSession。在Spring Boot集成中每次Mapper方法调用实际上都用的是SqlSessionTemplate管理的SqlSession而SqlSessionTemplate默认会对每次操作创建新的SqlSession除非外层有Transactional把操作包在同一个SqlSession里。所以正常情况下不同请求之间不会共享一级缓存但在同一个事务内部相同查询会直接走缓存Transactional public void updateUser() { User user userMapper.selectById(1L); // 第一次查询走DB user.setName(新名字); userMapper.updateById(user); User dbUser userMapper.selectById(1L); // 同一事务可能返回缓存中的旧对象 // 此时 dbUser 的 name 可能还是旧值因为一级缓存没失效 }这个问题在多个项目里都出现过——事务内先查后改再查拿到的数据是改之前的。处理方法很简单对数据时效性要求高的事务查询不走一级缓存或者把缓存作用域改成STATEMENT级别每次执行都清缓存mybatis: configuration: local-cache-scope: statement不过全局关闭一级缓存会影响所有查询性能权衡下来我在项目里是单独给那些事务内先查后改再查的Mapper方法加了一个特殊配置在XML中设置flushCachetrue来强制清缓存。4.2 二级缓存分布式环境下的重灾区二级缓存默认是关闭的但在某个项目里之前的人为了提升性能在UserMapper.xml里加了一行cache /这个配置一开所有通过该Mapper执行的查询结果包括关联查询都会被缓存到JVM堆内但问题来了——项目部署了多个节点节点之间缓存不共享。某个节点更新了数据它本地的二级缓存会失效但其他节点的二级缓存还保留着旧数据读出来就是脏数据。更隐蔽的问题是cache /默认的缓存实现是PerpetualCache它存储的是Java对象引用。你查询返回的List对象如果被外部修改了缓存里的对象也跟着变了——这会导致架子上的数据被悄悄污染下次查的时候返回的就是被改过的对象。查那个线上问题时我是用两段代码定位的先查Redis确认数据没问题然后在一个节点上连续两次请求接口发现返回结果完全一致但另一个节点上结果不同——典型的节点间缓存不一致。最后处理方式是彻底关掉二级缓存统一用Redis做缓存层。Mybatis的二级缓存设计上更适合单机应用微服务或集群环境下别用。4.3 什么时候我会推荐开启二级缓存也不是说二级缓存完全不能用。如果项目满足这几个条件开起来效果确实不错单机部署、读多写少、对数据一致性要求只能接受秒级延迟而且查询结果不会因为联表查询产生数据膨胀。比如一个字典表Mapper一天更新几次查询量巨大这种场景开二级缓存可以显著降低DB压力。但要注意Mapper的cache配置一旦开启所有通过该Mapper执行的结果都会被缓存包括那些关联查询、动态SQL的查询你要确保这个Mapper不参与强一致性的业务链路。我处理字典表时的做法是建一个单独的DictMapper只在它上面加二级缓存其他业务Mapper一律不开。5. 集成第三方组件的组合坑PageHelper与多数据源、时序库的兼容问题项目后期引入了金仓V8和TDengine多数据源的需求一来Mybatis的配置复杂度直接上升一个级别。这里分享几个我踩过的兼容性坑。5.1 PageHelper在多数据源下为什么会失效PageHelper的拦截器默认绑定一个SqlSessionFactory如果你配置了多个数据源每个数据源有各自的SqlSessionFactory。PageHelper只注册到了其中一个上另一个数据源的查询自然不会被分页拦截。我当时在项目里用的是AbstractRoutingDataSource做动态数据源切换发现PageHelper时好时坏——主数据源能分页切换到从库后分页就用不了。后来查了PageHelper源码发现它的PageInterceptor在初始化时会和SqlSessionFactory绑定多数据源场景下需要为每个数据源单独配置一次PageHelper的拦截器。处理方案分成两种如果你用的是MybatisPlus它自带分页插件也是同样的问题——每个SqlSessionFactory都要注册插件。如果坚持用PageHelper可以自定义一个ConfigurationCustomizer在Mybatis的Configuration初始化时统一给所有SqlSessionFactory添加拦截器Bean public ConfigurationCustomizer pageHelperConfigCustomizer() { return configuration - { PageInterceptor interceptor new PageInterceptor(); Properties props new Properties(); props.setProperty(helperDialect, mysql); props.setProperty(reasonable, true); interceptor.setProperties(props); configuration.addInterceptor(interceptor); }; }这个方式实测下来比在XML或者声明式配置里逐个指定更加稳妥因为ConfigurationCustomizer会被应用到所有SqlSessionFactory上。5.2 多数据源下的事务边界多数据源的事务管理是个高频问题。Spring Boot默认只配置了一个DataSourceTransactionManager你要是直接Transactional只有默认数据源的事务会生效另一个数据源的写操作不会回滚。这意味着如果一个业务里需要同时更新MySQL的数据和金仓V8的数据一旦第二个数据源操作失败第一个数据源已经提交的事务就回不来了。我在项目里的应对策略是尽量避免跨数据源的一个大事务把操作拆成两步靠本地消息表或者定时任务最终对齐如果确实要一个事务建议引入JtaTransactionManager配合Atomikos等分布式事务中间件但这是重量级方案非必要不建议引入。5.3 集成TDengine时的类型映射问题TDengine是时序数据库和MySQL的字段映射差异很大。Mybatis原生支持的基础类型映射里timestamp到java.time.LocalDateTime的映射是有的但TDengine返回的Timestamp类型在某些版本驱动下会映射成byte[]这就很坑了。我们项目集成TDengine时在Mapper的XML中是这样处理的resultMap idSensorDataMap typecom.example.entity.SensorData result columnts propertyts jdbcTypeTIMESTAMP javaTypejava.time.LocalDateTime/ /resultMap指定jdbcType和javaType可以绕开大部分类型推断问题。另外TDengine的SQL方言和MySQL有些差异比如分页写法、时间窗口聚合函数这些没法通过Mybatis层面统一——需要针对TDengine单独建一套XML并在mapper-locations里分别配置mybatis: mapper-locations: - classpath*:mapper/mysql/**/*.xml - classpath*:mapper/tdengine/**/*.xml别把两套环境的SQL混在一个目录下否则你排查SQL问题时会被方言差异折磨到怀疑人生。6. 贯穿始终的实践经验Mybatis在Spring Boot项目里的几条铁则做完这个项目的技术复盘我把这些零散的踩坑经验汇总成几条自查清单每次新项目用到Spring Boot Mybatis时我都会过一遍。6.1 Mapper方法入参尽量只用Param我见过不少同事在Mapper接口里按顺序传参数User selectUser(String name, int age);对应的XML里用#{0}、#{1}去引用参数。这种写法在参数少的时候能跑通但一旦改代码调整了参数顺序SQL引用的就是错误的值而且编译器不会帮你发现。强烈建议统一用Param装饰所有参数让XML里的参数名和Java代码对应上User selectUser(Param(name) String name, Param(age) int age);对应的XML里用#{name}和#{age}清晰且不容易错。6.2 XML和接口方法名的关系是绑定不是约定Mybatis绑定Mapper接口和XML映射文件的规则是接口的全限定名 方法名 XML中的namespace id。所以这个方法名不能重载——同一个接口里不允许出现两个同名不同参数的方法。如果你确实需要类似重载的能力就拆成两个不同名的方法比如selectByStatus和selectByStatusAndType。6.3 尽量让XML的resultType用别名而不是全限定名配置了type-aliases-package之后XML里可以直接写resultTypeUser。但要注意包名冲突——如果两个包下有同名实体类别名会冲突Mybatis会启动报错。遇到这种情况给其中一个实体加上Alias注解手动指定别名别指望框架帮你自动处理。6.4 启动时开启SQL打印上线时关闭开发环境一定要打印SQL我觉得这是排查速度最快的手段。配置方式logging: level: com.example.mapper: debug针对Mapper包单独开debug日志比在mybatis-configuration里全局开StdOutImpl更干净而且输出格式更友好。线上环境建议保持info级别否则并发高的时候日志压力也很可观。6.5 事务注解不要打在Mapper接口上Spring事务可以标在Mapper接口方法上但实际生效是有前提的——必须通过代理对象调用才能进入事务。如果某个调用链里通过this直接调用了Mapper方法事务注解不会起作用。规范是事务只标注在Service层方法上Mapper接口保持纯粹的数据访问角色。这个规则还有一层考虑如果事务打在Mapper接口上那么所有调用该Mapper方法的业务入口都会纳入事务范围事务粒度太粗容易产生长事务占用连接时间变长影响系统吞吐。Service层事务可以按业务边界来控制粒度这是架构上的合理性不只是规范问题。7. 脚本化排查遇到Mybatis异常时我建议按这个顺序定位很多新手遇到Mybatis报错就慌了其实大部分问题都有迹可循。这里整理一个排查链路你按顺序过一遍能节约大量时间。第一步看SQL日志是否打印。如果logging.level.com.example.mapper已经设成debug但没有任何SQL输出通常是Mapper接口没有被扫描到或者SQL没绑定上。用MapperScan明确指定扫描包名同时确认XML文件确实被打包进classpath了在IDEA里看target/classes目录。第二步看是不是参数绑定失败。报错Parameter xxx not found一般是XML里用了#{xxx}但Mapper接口的方法参数没加Param或者参数名在编译时被混淆了。解决办法就是用Param显式标注。第三步看结果映射是否出错。报了Could not set property xxx一般是实体类字段和resultMap里的column对不上。检查驼峰映射是否开启或者resultMap里是否漏配了某些字段。第四步如果涉及关联查询报错N1 Select那是关联查询策略的问题。Mybatis默认按需加载关联对象如果你在循环里查主表再查关联表会产生大量SQL。优化方案是使用collection和association一次性查询或者用fetchTypeeager加载。第五步如果是动态SQL拼接问题报错信息里通常会有There is no getter for property named xxx。这条本质还是参数绑定问题回到第二步检查。8. 工具层面的补充IDEA里的Mybatis插件到底帮了什么忙再聊点提升效率的工具向内容。我在IDEA里一直用MyBatisX或者老的Free Mybatis plugin它最实用的功能是在Mapper接口方法旁边直接跳转到对应XML片段反过来也能从XML跳回接口方法。这个看起来不起眼但项目大了之后找映射关系是日常最高频的操作手翻文件效率太低了。还有一个功能是生成XML模板。新建Mapper接口后插件能一键生成对应的XML骨架包括resultMap、select标签然后你在里面补充业务SQL就行。不过要提个醒生成的resultMap默认会把所有字段都列出来和实体类字段一一对应如果你的实体类有继承关系或者复合字段记得手动调整。另外IDEA 2026版本里配置Spring Boot服务启动端口的问题很多人都问过。其实Spring Boot的启动端口默认在src/main/resources/application.properties或application.yml里用server.port配置如果想要用IDEA的Run Configuration临时指定端口可以在Program arguments里加--server.port8081这个方式会覆盖配置文件里的值。如果发现改了server.port不生效大概率是classpath下存在多个配置文件或者运行环境里有SERVER_PORT环境变量把配置值覆盖了。9. 一个老生长谈但必须说清楚的问题Spring Boot版本和Mybatis的兼容性这几年Spring Boot版本迭代很快有阵子从2.7升到3.xMybatis也升级到了mybatis-spring-boot-starter的3.0版本。很多人升级之后发现启动直接报错Property sqlSessionFactory or sqlSessionTemplate are required原因是Spring Boot 3.x使用了Jakarta EE命名空间Mybatis starter 3.x也做了相应适配但如果你项目里还用了mybatis-plus它的版本也要同步升到3.5.3以上否则会有冲突。升级时还有一个隐藏问题JDK 17里反射访问受限Mybatis的MetaObject在处理某些实体类时会碰到InaccessibleObjectException。解决方案是加--add-opens java.base/java.langALL-UNNAMED这类JVM参数或者升级到修复过的Mybatis 3.5.x版本。我升级时的建议顺序是先确认业务代码里有没有用到Mybatis内部API比如SqlSessionTemplate直接调用selectList有的话先改成Mapper接口调用然后升级mybatis-spring-boot-starter再升级数据库驱动最后跑一遍全量测试。别想着一步到位分层升级出问题好定位。另外Spring Boot 2.7里mybatis.configuration的某些配置如果和自定义Bean产生了覆盖关系前面提到的升级到Spring Boot 3.x之后这个覆盖逻辑基本没变所以排查方向是一致的。Spring Boot版本不是你随便说太高了就不用的理由慢一点、稳一点才是王道。10. 写在最后与其到处抄配置不如学会看报错很多人学Spring Boot Mybatis喜欢从网上复制粘贴一段配置运行成功了就以为自己会了。但项目真正复杂起来之后配置只是最表层的东西。你在生产环境遇到的问题往往不是配置问题而是数据和代码之间的映射关系、SQL的性能边界、多数据源和缓存的一致性这些问题。真遇到问题的时候我的建议是先把报错信息完整读三遍——Mybatis的报错信息其实都很直白不是Invalid bound statement就是Parameter not found多数情况下问题就出在这两个点然后再去翻对应的XML片段检查namespace、id、parameterType、resultType最后再看日志里的SQL输出。这个流程能解决90%的日常问题。至于那些为什么我的分页不生效、为什么这个查询走了缓存、为什么升级Spring Boot版本后连不上数据库——大部分都能归到本文讲的某一类原因上。你把这些底层机制理解透了遇到问题就不慌了。
返回列表