简介:面向Java Web后端开发者,这是一套SpringBoot与MyBatis整合的项目示例,围绕高效微服务开发场景,引入Druid连接池与Swagger接口文档工具,可降低项目初期配置成本,统一数据持久层访问,并为API提供可视化调试入口。压缩包共159个文件,约1.81MB,内含Java源码、class编译文件、JS页面脚本及XML/Properties配置,整体目录结构清晰,适合导入IDE直接研读。从内容预览看,源码覆盖Controller、Service、MyBatis映射、RedisConfig、Swagger2、FastJson配置及文件上传等模块,基本囊括SpringBoot常用集成要点。其中Druid可对SQL执行进行监控,Swagger可在网页端在线调试接口,有助于观察数据库访问情况与API出入参格式。代码采用Controller-Service-Mapper分层,便于二次开发或迁移到实际项目。已有159人学习/下载,对正在搭建SpringBoot基础工程或需要参考Druid监控与Swagger整合的开发者具有直接借鉴价值。
1. springboot+mybatis:数据访问层最稳的组合,但别当偷懒工具用
Spring Boot 负责装配:数据源、连接池、事务管理器、Mapper 扫描全部交给自动配置;MyBatis 负责映射:表字段和 Java 属性对应、SQL 语句和接口方法绑定。两者拼起来,一个 Java 后端的数据访问层半小时内就能跑通,这也是现在多数中小项目和新外包团队的标准起手式。但很多人把 MyBatis 当成"少写 JDBC 的偷懒工具",等真遇到分页串到别的 SQL 上、事务悄悄失效、字段映射全 null 的时候,才发现手边连排查思路都没有。这篇笔记按我的落地习惯,从依赖选型、最小工程、CRUD 姿势、分页批量和坑位排查五条线走一遍,最后给出一套执行计划与压测验证的方法。适合刚接触这个组合的后端开发,也适合想系统梳理边界的熟手。
2. 搭起最小可运行工程:starter 选型、数据源配置与第一个 Mapper
2.1 依赖怎么选:mybatis-spring-boot-starter 是一切的起点
MyBatis 官方为 Spring Boot 提供了独立的起步依赖,它跟 Spring Boot 官方 starter 不是一回事。这个 starter 会拉进 mybatis、mybatis-spring、spring-boot-autoconfigure 三样东西,自动配置类负责创建 SqlSessionFactory、注册 SqlSessionTemplate、扫描带 @Mapper 注解的接口。你不需要自己写 @Bean 配置,但必须搞清楚版本对应关系:Spring Boot 2.x 用 2.x 系列的 starter,Spring Boot 3.x 必须用 3.x 系列,因为后者整个依赖体系从 javax 迁到了 jakarta,版本选错项目连启动都起不来。
<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.x</version> </dependency>这个版本变量我习惯留给父工程统一管理。注意一个细节:mybatis-spring-boot-starter 并没有被放进 spring-boot-dependencies 的 BOM 清单里,所以即使项目里有 spring-boot-starter-parent,也必须在 dependency 里显式写版本号,否则 Maven 会告诉你缺少版本。这个坑在多人协作的工程里非常典型——有人升级了 Spring Boot,MyBatis starter 没跟着升,最后启动报错。
数据库驱动用 MySQL 的话,当前推荐 com.mysql:mysql-connector-j。老教程里常见的坐标是 mysql:mysql-connector-java,那对应的是旧版驱动命名,新项目不要照抄。驱动版本交给 Spring Boot 管理就行,runtime 作用域足够,不需要在代码里直接引用。
2.2 最小工程三步走:数据源、实体类、Mapper 接口
先建好数据表,我习惯用一个最简单的 user 表说明。接着写 application.yml,最省事的最小配置文件长这样:
spring: datasource: url: jdbc:mysql://localhost:3306/demo_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplurl 里的几个参数不是可有可无的。serverTimezone 不写,数据库和 JVM 时区不一致时会报时间转换异常;allowPublicKeyRetrieval=true 在 MySQL 8 的 caching_sha2_password 认证方式下必须开,否则连本地库都会报连不上;useSSL=false 是本地开发省掉证书检查。driver-class-name 写 com.mysql.cj.jdbc.Driver,这是 MySQL 8 的全新驱动类。
mybatis 开头的配置是 MyBatis 自己的。mapper-locations 指向 XML 映射文件,否则接口和 XML 对不上;type-aliases-package 让 XML 里写 resultType 时能直接用类名小写别名;map-underscore-to-camel-case 负责把 order_no 这种列名自动映射到 orderNo 属性;log-impl 配成 StdOutImpl 会在控制台直接打印 SQL 和参数,排查问题时先开它。
实体类只是一个普通 POJO,字段对应表列。关键在 Mapper 接口:
@Mapper public interface UserMapper { User findByUsername(@Param("username") String username); }确保接口能被扫描,常见做法有两种:一种就是上面的 @Mapper 逐接口标注;另一种是在启动类上写 @MapperScan("com.example.demo.mapper"),整包注册。我一般用 @MapperScan,包路径一劳永逸,新接口不用记着加注解。但要注意它扫的是接口包,别把实体类放同一个包底下。
对应的 XML 文件放在 resources/mapper 目录里:
<mapper namespace="com.example.demo.mapper.UserMapper"> <select id="findByUsername" resultType="com.example.demo.entity.User"> SELECT id, username, email FROM user WHERE username = #{username} </select> </mapper>namespace 必须是接口的全限定名,id 必须是方法名,这一个对上号,运行期才找得到 SQL。resultType 可以直接写全限定类名,也可以因为上面配了 type-aliases-package 而简写成 user,但多模块项目里我宁愿写全限定名,少猜。别名冲突时它不报错,只按最后一个类匹配,这种黑匣子行为没有必要赌。
2.3 连接池参数:HikariCP 的这几个值别直接抄默认
HikariCP 是 Spring Boot 2.x 之后的默认连接池,它的默认值对小型项目很友好,但对并发稍高的服务并不合适。两个最关键的参数是 maximum-pool-size 和 connection-timeout。maximum-pool-size 默认 10,它要和 Tomcat 的 max-threads 联动。Tomcat 默认线程池 200,如果 200 个请求线程同时进来查库,连接池只有 10,那 190 个线程全部卡在 connection-timeout 上等,请求直接堆积。
粗估公式是:高峰并发线程数 × 单条 SQL 执行时间(秒),再留 20% 余量。单条 SQL 50ms 的服务,200 并发约需要 10 个连接,看起来数字不大,但前提是每条 SQL 都快。如果混进几条 500ms 的慢查询,连接瞬间被占满。所以真正要调的不是池子,而是慢 SQL,这是后话。
HikariCP 有几个参数行为跟直觉不一致。idle-timeout 默认 10 分钟,但它只在 minimum-idle 小于 maximum-pool-size 时才生效,池子恒定时这个参数等于没写;max-lifetime 默认 30 分钟,它必须小于数据库端的 wait_timeout,否则连接被 MySQL 主动断开后池里还在用;connection-timeout 默认 30 秒,对在线服务来说太长,生产我一般调到 10000 到 15000,宁可快速失败让上游重试,也不要让线程傻等 30 秒。
| 参数 | 常见生产值 | 说明 |
|---|---|---|
| maximum-pool-size | 10~50 | 与 Tomcat 线程数和 SQL 耗时联动 |
| minimum-idle | 与 maximum 相等 | 避免频繁建连,波动大时优先恒定池 |
| connection-timeout | 10000~15000ms | 超过即抛异常,别用默认 30 秒 |
| max-lifetime | 1800000ms | 应小于数据库 wait_timeout |
| idle-timeout | 600000ms | 仅当 min < max 时生效 |
这一章跑通后,接口能查到数据,控制台能看到 SQL,连接池也有基本概念。接下来就是写业务时躲不开的 CRUD 三种姿势。
3. CRUD 落地姿势:注解 SQL、resultMap 与参数传递的边界
3.1 注解式 Mapper:springboot+mybatis 简单 CRUD 的推荐写法
MyBatis 从 3.4 起支持在 Mapper 接口方法上直接写 SQL 注解。单表插入、按主键查、条件简单,用注解最省事,文件数量也最少。
@Mapper public interface UserMapper { @Select("SELECT id, username, email FROM user WHERE username = #{username}") User findByUsername(@Param("username") String username); @Insert("INSERT INTO user(username, email) VALUES(#{username}, #{email})") @Options(useGeneratedKeys = true, keyProperty = "id") int insert(User user); @Update("UPDATE user SET email = #{email} WHERE id = #{id}") int updateEmail(@Param("id") Long id, @Param("email") String email); @Delete("DELETE FROM user WHERE id = #{id}") int deleteById(@Param("id") Long id); }@Options(useGeneratedKeys = true, keyProperty = "id") 是插入后回填自增主键的关键。不用它,insert 方法执行完,user.getId() 还是 null,前端刚建完数据想拿主键做下一步操作就会踩空。keyProperty 写的是 Java 属性名,不是数据库列名,这一点写反的人不少。返回值 int 是受影响行数,不是主键。
这种写法我最多容忍到三个条件以内的查询。超过三个 if 条件、有批量操作、或多表关联,注解就撑不住了。MyBatis 注解里其实也能写<script>标签做动态 SQL,但字符串拼接出来的可读性很差,多人维护时基本靠猜,不如老老实实去 XML。另一个要留意的点是注解里如果出现特殊字符,比如大于号、小于号,必须转义,这在 XML 里同样存在。
3.2 XML 映射:复杂 SQL 的兜底方案,也是可控性的来源
XML 映射的核心价值在于 SQL 与 Java 代码分离。DBA 可以直接看 XML 评审 SQL,不用在 Java 字符串里翻。一个典型的带关联查询的 XML 写下来是这样:
<mapper namespace="com.example.demo.mapper.OrderMapper"> <resultMap id="OrderUserMap" type="com.example.demo.entity.Order"> <id property="id" column="id"/> <result property="orderNo" column="order_no"/> <association property="user" javaType="com.example.demo.entity.User"> <id property="id" column="uid"/> <result property="username" column="username"/> </association> </resultMap> <select id="selectOrderWithUser" resultMap="OrderUserMap"> SELECT o.id, o.order_no, u.id AS uid, u.username FROM orders o LEFT JOIN user u ON o.user_id = u.id WHERE o.id = #{id} </select> </mapper>resultMap 里的 id 标签标记主键列,result 标签映射普通列,association 处理"订单里嵌一个用户"这种一对一关系。注意 user 的主键列在查询里起了别名 uid,不然 u.id 和 o.id 都叫 id,结果集里后一个会覆盖前一个,这就是多表查询最常见的"查出来主键不对"的根源。
为什么不用 resultType 加驼峰映射?因为多表 JOIN 的情况下,user 表的列没法直接映射进 Order 里的 user 对象。一旦 JOIN,列名重复、嵌套对象、一对多集合,resultType 根本表达不了。我的习惯是:单表简单查询用 resultType,任何 JOIN 场景一律 resultMap,别硬拿驼峰映射去填坑。XML 顶部要不要写 DOCTYPE 取决于 MyBatis 版本,新版 starter 里已经内置了 XML Mapper 的 schema,复制粘贴老帖子的 DOCTYPE 反而可能报未知错误,建议直接省略。
3.3 参数传递与返回映射:五种高频翻车现场
参数问题在 MyBatis 里的报错信息很直白,但很多人第一次见还是会慌。
第一种,方法有多个参数但没加 @Param。接口写 selectByNameAndEmail(String name, String email),XML 里用了 #{name},运行报 Parameter 'name' not found。解决方式是每个参数都加 @Param,或者把参数封装成一个对象,我更推荐后者,参数超过三个时维护成本明显更低。
第二种,返回类型用了基本类型。int 类型的 count 在某些聚合条件下会返回 null,MyBatis 尝试把 null 赋给 int 直接抛异常。所有可能为 null 的返回都用包装类 Integer、Long,这是改起来最便宜的一条。
第三种,查询条件用了 ${parameter} 而不是 #{parameter}。${} 是字符串替换,直接拼 SQL,有注入风险;#{} 是预编译占位符,安全且能享受数据库预编译缓存。业务条件全部用 #{},唯一可以碰 ${} 的是 ORDER BY 排序字段这种无法参数化的地方,而且必须做白名单校验,不能直接透传前端传上来的列名。
第四种,数据库列是下划线,实体类属性是驼峰,没开 map-underscore-to-camel-case,结果 SELECT 出来全是 null。开了这个开关,user_name 能映射到 userName,但像 user_bind_info 这种三段式的列名,MyBatis 只做一层下划线转驼峰,映射出来是 userBindInfo 而不是预期的 userBindInfo,实际不会报错但拿不到值。所以复杂列名干脆写别名,别指望开关全兜住。
第五种,XML 里 resultType 写了这个实体类,但类的 setter 是私有的或没有。MyBatis 走的是 setter 反射,不认字段直接写入,除非你显式配置了字段映射。检查实体类 setter 是不是 public,这是很多人忽略的一步。
这五条基本覆盖了参数传递和返回映射的大部分线上事故。下一章把分页、动态 SQL 和批量操作这三个业务里最爱出问题的点单独拉开讲。
4. 分页、动态 SQL 与批量操作:参数设不对,全部白费
4.1 PageHelper 分页:springboot+mybatis 三个必调参数与"紧跟第一条 SQL"原则
分页插件底层原理是 MyBatis 拦截器:拦截 Executor 的 query 方法,拿到 startPage 塞进 ThreadLocal 的分页参数,改写即将执行的 SQL,在末尾追加 LIMIT 语句。理解了这一层,就能明白它最著名的坑:startPage 之后必须紧跟第一条 SQL 查询,中间插任何一条别的查询,分页参数都会串到那条 SQL 上。
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.x</version> </dependency>pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: count=countSqlhelper-dialect 告诉插件按 MySQL 的 LIMIT 语法改写,不配也能自动识别,但多数据源时显式声明更稳。reasonable=true 会把 pageNum 小于 1 的自动重置为 1,超过总页数时自动查最后一页。好处是前端翻页不会白屏,坏处是测试时经常发现"我明明传了 pageSize=100 但只返回 10 条",因为合理校验把超出允许范围的值也规范了。support-methods-arguments=true 允许从方法参数里直接取 pageNum 和 pageSize,不用显式调 startPage,老项目改造分页时很方便。
PageHelper.startPage(pageNum, pageSize); List<User> list = userMapper.selectList(); PageInfo<User> pageInfo = new PageInfo<>(list);这里有个使用习惯:分页结果塞进 PageInfo,页面上的 total、pageNum、pages 全从 PageInfo 拿。也可以把返回值强转成 Page 再 getTotal(),但 PageInfo 的字段更全,页面组件需要哪个字段都能对上。分页 SQL 的 count 查询是自动执行的,原 SQL 特别复杂时 count 也可能很慢,params 里配 count=countSql 是告诉插件"用原 SQL 包一层做 count",对绝大多数场景够了。
提示:我排查过很多分页串到别的查询的线上问题,真正原因都是 startPage 和执行查询之间夹了打印日志、调了别的方法这种事情。PageHelper 用 ThreadLocal 存参数,执行完会清理,但如果中途抛出异常,参数可能残留。遇到"这次分页没生效下次生效"的玄学问题,先检查异常路径上有没有没被 try-finally 保护的 startPage。
4.2 动态 SQL:where 与 foreach 的边界,写错就是全表扫描
动态 SQL 是 MyBatis 最值钱的能力。写一个多条件用户搜索,最常见的正确姿势是:
<select id="searchUsers" resultType="com.example.demo.entity.User"> SELECT id, username, email FROM user <where> <if test="username != null and username != ''"> AND username LIKE CONCAT('%', #{username}, '%') </if> <if test="email != null and email != ''"> AND email = #{email} </if> </where> </select>标签会自动去掉第一个条件前面的 AND 或 OR,所以子句里写不写开头的 AND 都安全。很多新手习惯写 WHERE 1=1 再拼 ,也能跑,但 1=1 是不必要的常量条件,写多了容易养成坏习惯,统一用 更干净。
里的 test 是 OGNL 表达式,字符串判空必须同时写 null 和空串两个条件,只写 null 时前端传个空字符串就会被拼进查询。数值类型注意 0 也是合法值,if test="state != null" 对 state=0 是成立的,别误写条件让 0 被当成空值过滤掉。
批量操作离不开 。最常见的是批量插入:
<insert id="batchInsert" parameterType="list"> INSERT INTO user(username, email) VALUES <foreach collection="list" item="item" separator=","> (#{item.username}, #{item.email}) </foreach> </insert>collection 在接口参数是 List 且没加 @Param 时固定写 list,加了 @Param("userList") 就写 userList,这两个单词的差异是报错重灾区。separator="," 生成的是一条多 VALUES 的 INSERT,比循环单条插入快一个数量级。但注意大批量时单条 SQL 太大,MySQL 的 max_allowed_packet 会截断,我一般按 500 条一批分片执行。
批量更新更麻烦:
<update id="batchUpdate"> <foreach collection="list" item="item" separator=";"> UPDATE user SET email = #{item.email} WHERE id = #{item.id} </foreach> </update>这种写法生成的是用分号连接的多个 UPDATE 语句,必须保证 JDBC 连接 url 带 allowMultiQueries=true,否则 MySQL 驱动会拒绝执行。没开这个参数时的报错也常被误判成 SQL 语法错误,实际是驱动层直接禁了多语句。
4.3 事务边界与分页测试:两个容易误判的小细节
先说事务。MyBatis 的 Mapper 方法本身不带事务,接口里的多条 SQL 默认是自动提交的。要保证多条 SQL 原子性,必须把事务放在 Service 层,用 @Transactional 标到 public 方法上,这一点放到第 5 章展开。
分页测试里有个细节。前面提到 reasonable=true 会把超范围页规范掉,测试时要验证"分页到底生效没有",别只看返回条数,应该看 SQL 日志里到底有没有出现 LIMIT 关键字。如果 PreparedStatement 里直接显示 LIMIT 10,分页就是生效的,返回条数少只是合理校验在起作用。另外,分页插件对子查询和 UNION 复杂语句的 count 生成有时不准,遇到分页结果总数不对的情况,优先怀疑 count 子句,可以直接打印出来看它统计的是什么。
5. springboot+mybatis 避坑指南:五个高频故障的排查路径
5.1 Invalid bound statement (not found) 的完整排查
现象:调用接口方法时抛 BindingException,异常信息里带着接口全限定名和方法名,比如 UserMapper.findById。
原因:MyBatis 在运行期把接口方法绑定到 XML 里的 SQL 语句,绑定失败无非三件事:XML 文件没有被加载、namespace 写错、id 与方法名不一致。
解决:第一步,确认 resources/mapper 目录下的 XML 确实被拷贝到了 classpath 下,target/classes/mapper 里没有这个文件就是构建没包含,检查 pom.xml 的资源过滤配置。第二步,确认 mybatis.mapper-locations 路径和文件实际位置匹配,写 classpath:mapper/*.xml 就要求 XML 在 mapper 目录下。第三步,XML 里 namespace 和 id 逐一核对,namespace 是接口全限定名,id 是方法名,多一个空格都对不上。如果这三步都检查过还报错,把 log-impl 调成 StdOutImpl 重启,看启动日志里 MyBatis 到底扫描到了哪些 XML。
5.2 驼峰映射不生效:配置开关不是万能的
现象:查询返回的实体对象整体不为空,但每个属性都是 null,SQL 里却明明 SELECT 了对应列。
原因:字段名下划线和 Java 属性驼峰对不上,同时 map-underscore-to-camel-case 没开,或者这条查询走了 resultMap。
解决:全局配置文件加 mybatis.configuration.map-underscore-to-camel-case: true。但记住一个关键点:一旦查询指定了 resultMap,驼峰开关对这条语句就不起作用,resultMap 会完全接管列到属性的映射。多表 JOIN 的查询,与其抱怨开关不生效,不如直接在 resultMap 里写全映射。还有一类隐性情况:SQL 里写了聚合函数,结果列名变成 SUM(order_amount),实体属性叫 totalAmount,驼峰开关对这类函数生成的列名不做处理,统一用别名 total_amount 或直接 resultMap。
5.3 事务不生效:自调用、可见性与被吞的异常
现象:方法标了 @Transactional,里面先做删除再插入,第二步抛异常,数据却只回滚了一半。
原因:最常见的有三种。第一种是同一个类内部方法调用,public 方法调了同类里另一个 @Transactional 方法,Spring 的代理没有经过,事务切面根本没触发。第二种是方法不是 public,Spring 的注解驱动对非 public 方法不代理。第三种是异常被 catch 住吞掉,事务只有看到异常从方法抛出才会回滚,catch 里打了日志不重新抛,SQL 全部提交。
解决:自调用改成把事务方法移到另一个 Service Bean 里,或者注入自身代理;方法保持 public;异常处理原则是 catch 后要么重新抛出,要么手动调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。另外 @Transactional 默认只回滚 RuntimeException,受检异常不会触发回滚,要显式写成 rollbackFor = Exception.class。如果在方法里同时操作了多个数据源,还要确认事务管理器是否配了对应数据源,默认的 DataSourceTransactionManager 只认主数据源。
5.4 连接池耗尽:先查慢 SQL,别急着调大池子
现象:日志高频出现 HikariPool-1 - Connection is not available, request timed out after 30000ms,请求大面积超时。
原因:不是连接池参数的问题,就是某个 SQL 长期占用连接。最典型的是 WHERE 条件大量使用 LIKE '%xxx' 或没走索引的查询,数据库 CPU 飙升,每一条 SQL 执行几百毫秒,10 个连接很快被占满。
解决:在 MySQL 里打开慢查询日志,定位慢 SQL,用 EXPLAIN 看执行计划,没有索引就建索引。这个阶段调大 maximum-pool-size 只能缓解几十秒,SQL 慢的本质不解决,池子调多大都会满。另一个被忽略的点是事务内做远程调用,连接在等待外部接口返回时一直持有,这种架构问题调连接池参数完全无效。排查时别只盯着 Hikari 的报错,把堆栈打出来看哪些线程持有连接、它们在等什么,比猜原因快得多。
5.5 多数据源时的 Mapper 扫描冲突
现象:项目里配了两个数据源,启动正常,但运行期总出现"找错 Mapper"或"使用的数据源不对"。
原因:一个 @MapperScan 把两个包都覆盖了,或者两个数据源的 SqlSessionFactory 各自扫描了对方包里的 Mapper。Spring 容器里 Mapper 注册到了同一个 Bean 容器,后扫描的会把先扫描的覆盖掉。
解决:每个数据源单独建一个 @Configuration 类,内部定义自己的 SqlSessionFactory,并给各自的 SqlSessionFactory 设置精确的 mapper-locations。@MapperScan 的 basePackages 缩小到具体子包,别用父包。多数据源下还有一个习惯:把 XML 文件按数据源分目录,比如 mapper/primary 和 mapper/secondary,配置和代码结构一一对应,出问题一眼能看出归属。这个场景里 @Mapper 注解和 @MapperScan 不要混用,统一用一种方式注册,否则扫描顺序带来的覆盖问题很难排查。
6. 把性能边界摸清楚:执行计划、二级缓存与压测验证
6.1 用 EXPLAIN 反推索引,而不是凭感觉加索引
拿到一条慢 SQL,先执行 EXPLAIN,关注 type 和 rows。
EXPLAIN SELECT id, username, email FROM user WHERE username = 'admin';type 为 ALL 就是全表扫描,为 ref 或 range 说明走了普通索引,为 const 说明命中了主键或唯一索引。看到 ALL 别急着加索引,先确认 WHERE 条件里的列有没有被函数包裹。WHERE DATE(create_time) = '2024-01-01' 这种写法,即使 create_time 有索引也用不上,改成范围查询 create_time >= '2024-01-01' AND create_time < '2024-01-02' 就行。联合索引的顺序遵守最左前缀原则,索引 (username, email) 能命中 WHERE username = ? AND email = ?,但单独 WHERE email = ? 就命中不了。
6.2 二级缓存:这个"后悔药"多数场景不该吃
MyBatis 的二级缓存按 namespace 隔离,开启只需要在 XML 里加一行<cache/>。缓存的是查询结果对象,但有个严重问题:多表 JOIN 的查询缓存归某个 namespace 管,一旦另一张关联表更新,这个 namespace 不会感知,读出来就是脏数据。缓存对象的序列化、失效策略、并发写都要额外配置,Spring Boot 集成没有对这块做默认优化。我的建议是:二级缓存解决的是"同一 SQL 高频重复查询"的问题,这种问题用 Redis 或本地 Caffeine 做业务缓存更可控,还能顺便控制失效维度。MyBatis 二级缓存只适合单表、读多写少、对脏数据容忍度高的场景,其余情况哪怕命中率再高也不建议开。
6.3 最小压测脚本:验证优化到底有没有效
优化前后没有数据对比,就等于没优化。用 ab 做一个最粗糙的并发验证:
ab -n 1000 -c 50 http://localhost:8080/api/user?username=admin看两个指标:Requests per second 和 Time per request (mean)。优化前跑一遍,加了索引或改了 SQL 再跑一遍,指标提升多少直接说话。同时观察 MySQL 的慢查询日志在三分钟窗口里是否还有新的慢 SQL 记录。整个 springboot+mybatis 的功夫,最后都落在"SQL 本身快不快"这一件事上,框架层面的配置都是辅助。
我自己的习惯是:每接手一个 springboot+mybatis 工程,第一件事不是看业务代码,而是先开 SQL 日志、看连接池参数、跑一遍主要列表页的慢查询,把这三个底摸清楚再动业务。这套组合的绝大多数事故,都发生在配置默认值和 SQL 编写习惯上。希望这篇笔记里梳理的选型和踩坑记录,能帮你把数据访问层从"能跑"推到"跑得稳",少走我当年绕过的弯路。
本文还有配套的精品资源,点击获取