简介:面向Java后端开发者与Spring生态学习者的整合实践示例,围绕Spring Boot 3与MyBatisPlus的协同配置与数据访问层搭建展开,适合正在做课程设计、练手项目或需要初始化新工程的初、中级开发者参考。压缩包共24个文件、约85KB,以7个Java源码及对应class文件为核心,辅以yaml配置、properties连接参数、pom.xml依赖声明与mvnw构建脚本,完整呈现从依赖引入、数据源配置到Mapper、Service、Controller分层的工程骨架,src/main与src/test分工清晰。已有1435人学习下载。读者可对照包内代码理解@TableId、@TableField实体映射、BaseMapper通用CRUD、QueryWrapper与UpdateWrapper条件构造及主键与时间字段自动填充的落地写法;也可直接导入IDE运行调试,省去从零搭建环境的时间。
1. 一次升级引发的全员 Mapper 注入失败
去年帮某团队处理一个后台系统升级,原环境是 JDK 8 + Spring Boot 2.3 + MyBatis-Plus 3.3,目标是把整套东西挪到 JDK 17 + Spring Boot 3。改完 pom 启动,控制台干干净净,没有任何异常栈,但每一个@Autowired的 Mapper 都变成红色波浪线,运行时报No qualifying bean of type 'xxxMapper'。代码一行没动,SQL 一句没改,问题出在依赖坐标和自动装配机制上。Spring Boot 3 换到了 Spring Framework 6,javax.*全部迁移到jakarta.*,自动配置的注册方式也从spring.factories改成了AutoConfiguration.imports。老的mybatis-plus-boot-starter没有跟着迁,扫描器根本没被加载。这篇就按我实际落地的顺序,把 Spring Boot 3 与 MyBatis-Plus 的整合拆成可复现的步骤,顺带把版本、分页、Wrapper、批量写入这几个容易翻车的点讲透。
2. 依赖与版本矩阵:Boot 3 下 starter 必须换坐标
2.1 为什么老 starter 在 Boot 3 里会静默失效
Spring Boot 3 的自动配置加载入口是META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,这是一个纯文本文件,每行一个自动配置类全限定名。Spring Boot 2.x 时代用的是META-INF/spring.factories里的EnableAutoConfiguration键。框架在 3.0 里保留了读spring.factories的兼容逻辑一段时间,但对第三方 starter 的自动配置类不再兜底。MyBatis-Plus 的MybatisPlusAutoConfiguration如果还挂在旧文件里,Boot 3 就完全看不见它,SqlSessionFactory不会被创建,MapperScannerConfigurer也不会注册,最终表现就是 Mapper 注入失败,而不是启动报错。
另一层是命名空间。Spring Framework 6 把 Java EE 相关 API 整体迁到 Jakarta EE 9+,javax.servlet、javax.annotation、javax.validation全部变成jakarta.*。老 starter 内部依赖的mybatis-spring2.x 还在编译期引用旧包,即使强行排除版本冲突把类加载进来,运行时也会因为NoClassDefFoundError: javax/...崩掉。所以换 starter 不是可选项,是硬门槛。
常见做法是直接用mybatis-plus-spring-boot3-starter,它内部把mybatis-spring抬到了 3.0.x 分支,自动配置文件也换成了新格式。这一点在选型阶段就要确认,不要指望靠排除依赖加手写@Bean绕过,那样后面每个插件都得自己注册,维护成本会滚雪球。
2.2 版本矩阵与 pom 落地写法
版本搭配有两条底线:JDK 至少 17,mybatis-plus-spring-boot3-starter至少 3.5.5。低于这个版本线的 starter 虽然坐标叫 boot3,内部依赖的 mybatis-spring 可能还停在 2.x,跑起来照样炸。我一般会先把三个版本锁定,再往下写业务代码。
| 组件 | 建议版本线 | 说明 |
|---|---|---|
| JDK | 17 及以上 | Boot 3 的最低编译与运行基线 |
| Spring Boot | 3.2.x / 3.3.x | 两者对 MP 的兼容性都稳定 |
| mybatis-plus-spring-boot3-starter | 3.5.5 及以上 | 内部 mybatis-spring 已到 3.0.x |
| mybatis-spring | 3.0.x | 由 starter 传递,不要手动降级 |
| 数据库驱动 | 按库选择 | MySQL 用mysql-connector-j,不再用旧mysql-connector-java |
<!-- pom.xml 关键片段 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.5</version> </parent> <properties> <java.version>17</java.version> <mybatis-plus.version>3.5.7</mybatis-plus.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 注意 artifactId 是 spring-boot3,不是 spring-boot --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> <!-- MySQL 驱动,Boot 3 时代坐标已变更 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>这段 pom 的逻辑很直白:Boot 3 的父工程统一管理 Spring 生态版本,MyBatis-Plus 单独指定版本号,驱动交给 Boot 的依赖管理给默认版本。参数上唯一需要盯的是mybatis-plus.version,团队里几个人协作时把它写进 properties,别让某个人本地用了 3.5.3 的旧坐标还能编译通过——那种情况往往是本地仓库残留了旧 jar,换台干净机器就复现不了。
提示:升级已有项目时先执行一次
mvn dependency:tree | grep mybatis,确认mybatis-spring落在 3.0.x。如果看到 2.x 混在里面,说明有别的依赖把老版本拽进来了,优先排查分页助手、多数据源之类的第三方包。
2.3 最小可跑的 application.yml
依赖对了,配置项也得跟着 Boot 3 的写法走。数据源默认还是 HikariCP,连接池参数挂在spring.datasource.hikari下;MyBatis-Plus 的配置挂在mybatis-plus下,映射文件位置、驼峰转换、主键策略这些都能在这里统一定义。
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/demo_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true username: demo password: demo_pwd hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 mybatis-plus: # XML 映射文件位置,多模块项目要写成 classpath*: mapper-locations: classpath*:/mapper/**/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true # 开发期打开,生产环境请改用 logging.level 控制 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: banner: false db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0逐项说一下容易踩的地方。mapper-locations用classpath*:前缀而不是classpath:,是因为打成 jar 后多模块资源需要通配扫描,单模块用哪个都行,但统一写成星号能省掉以后拆模块的返工。map-underscore-to-camel-case默认就是 true,显式写出来是为了防止有人从老项目复制配置时把它关了。id-type: assign_id对应雪花算法,分布式场景下比数据库自增更省心,代价是 ID 是 19 位长整型,前端 JS 接收时要做字符串化处理,这个坑后面还会提。rewriteBatchedStatements=true是 MySQL 驱动侧的批处理开关,不加的话saveBatch依然是逐条发送,性能差距能到十倍以上。
URL 里的serverTimezone不能省。MySQL 8 驱动对时区校验比较严格,容器里 UTC 时区跑起来,不指定会报The server time zone value is unrecognized。字符集参数也建议显式写,避免不同环境默认值不一致导致中文乱码排查半天。
3. 实体、Mapper、Service 三层落地:从建表到分页查询
3.1 建表语句与实体注解的对应关系
先把表结构定下来,后面所有代码都围绕它展开。这里用一张订单表做例子,字段覆盖主键、业务字段、审计字段和逻辑删除标记。
CREATE TABLE t_order ( id BIGINT NOT NULL COMMENT '主键,雪花ID', order_no VARCHAR(32) NOT NULL COMMENT '订单号', user_id BIGINT NOT NULL COMMENT '下单用户', amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消', create_time DATETIME NOT NULL COMMENT '创建时间', update_time DATETIME NOT NULL COMMENT '更新时间', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1已删除', PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_status (user_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';实体类用 Lombok 收敛 getter/setter,注解只加必要的几个。@TableName指定表名,@TableId声明主键策略,@TableField处理那些名字对不上的字段(比如deleted配了逻辑删除后,插入时不该由业务代码赋值)。
package com.example.demo.entity; import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; @Data @TableName("t_order") public class Order { @TableId(type = IdType.ASSIGN_ID) private Long id; private String orderNo; private Long userId; private BigDecimal amount; private Integer status; // 插入时自动填充,更新时不覆盖 @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; // 逻辑删除标记,配合全局配置生效 @TableLogic private Integer deleted; }这里的关键点在于@TableId的type和全局id-type保持一致。如果全局配了assign_id,实体上又写了AUTO,以实体为准,结果是数据库自增和雪花算法打架,插入时 ID 为空报错。审计字段的fill属性要和控制器的填充处理器配合,只写注解不写处理器,字段永远是 null。@TableLogic加上去之后,所有通过 MP 生成的 SQL 都会自动追加deleted = 0条件,删除语句变成UPDATE ... SET deleted = 1,这是逻辑删除的标准姿势。
3.2 Mapper 接口与扫描路径的两种写法
Mapper 层不需要写实现,继承BaseMapper就拿到了单表 CRUD 的全部方法。自定义 SQL 再往里加方法名,和 XML 或者注解对应。
package com.example.demo.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.demo.entity.Order; import org.apache.ibatis.annotations.Param; import java.util.List; public interface OrderMapper extends BaseMapper<Order> { // 自定义方法,SQL 写在 XML 里 List<Order> selectByUserAndStatus(@Param("userId") Long userId, @Param("status") Integer status); }扫描路径有两种配法,选一种就行。第一种是在启动类上打@MapperScan,包名写 Mapper 所在目录;第二种是每个 Mapper 接口上打@Mapper,数量少的时候省事,几十个接口的时候就很啰嗦。我一般用第一种,把扫描配置抽到一个独立的配置类里,和分页插件放一起。
package com.example.demo.config; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.mybatis.spring.annotation.MapperScan; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration @MapperScan("com.example.demo.mapper") public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 分页插件必须显式注册,否则 IPage 查询不带 LIMIT PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); // 单页上限,防恶意大分页 pagination.setOverflow(false); // 页码超界时返回空集而非首页 interceptor.addInnerInterceptor(pagination); // 乐观锁插件,配合实体上的 @Version 使用 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }@MapperScan的包名一定要覆盖到所有 Mapper,多模块项目里每个模块的 Mapper 包都要列进去,用数组形式写多个。分页插件的DbType参数不能瞎填,Oracle、PostgreSQL 生成的方言不一样,填错了 SQL 语法直接报错。setMaxLimit是兜底参数,对外接口传size=100000时会被截断到 500,这个值按业务调整,报表导出场景可以放行到几千,但别关掉。setOverflow(false)控制页码越界行为,false 表示返回空列表,true 表示回到第一页,两种都有人用,接口语义上返回空更符合直觉。
注意:
MybatisPlusInterceptor是个容器,多个内部拦截器有顺序要求。分页插件建议放在最后添加,多租户、数据权限这类改写 SQL 的插件放前面,顺序反了会导致 count 语句算错。
3.3 Service 层与分页查询的完整调用链
Service 层继承ServiceImpl后,save、getById、page这些方法直接可用。分页查询的返回类型是IPage<T>,内部封装了records、total、current、size四个核心字段。
package com.example.demo.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.example.demo.entity.Order; import com.example.demo.mapper.OrderMapper; import com.example.demo.service.OrderService; import org.springframework.stereotype.Service; @Service public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements OrderService { @Override public IPage<Order> pageByUser(Long userId, long current, long size, Integer status) { // 页码从 1 开始,size 超过插件上限会被截断 Page<Order> page = new Page<>(current, size); LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Order::getUserId, userId) .eq(status != null, Order::getStatus, status) .orderByDesc(Order::getCreateTime); return this.page(page, wrapper); } }这段代码里有三个细节值得说。new Page<>(current, size)的 current 从 1 开始计数,传 0 会被当成第一页,但前端分页组件通常从 1 开始,联调时对齐一下。eq的重载方法第一个参数是布尔条件,status != null为 false 时这个条件整个不拼,这就是条件构造器的动态 SQL 能力,比在 XML 里堆<if>干净。orderByDesc用的方法引用,编译期就能校验字段名,比字符串写"create_time"安全,重构时改字段名不会漏。
分页的实际执行是两条 SQL:一条SELECT COUNT(*)算总数,一条带LIMIT的查询取数据。数据量大且不需要精确总数时,可以在Page构造里传searchCount=false关掉 count 查询,前端改成「加载更多」的交互。这个开关在百万级表上能省掉一次全表扫描,效果立竿见影。
3.4 自动填充处理器与逻辑删除的联动
审计字段的填充逻辑集中在MetaObjectHandler里,所有实体的create_time、update_time都在这一处赋值,业务代码不用碰。
package com.example.demo.config; import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; import org.apache.ibatis.reflection.MetaObject; import org.springframework.stereotype.Component; import java.time.LocalDateTime; @Component public class AuditMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { // strictInsertFill 只在字段为 null 时填充,不覆盖业务已赋的值 this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { // 更新时强制刷新 updateTime,忽略传入值 this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }strictInsertFill和setFieldValByName的区别在于前者会检查字段是否已经声明了对应的fill策略,没声明就跳过,避免误填到不该填的字段上。更新场景用strictUpdateFill,它只在FieldFill.UPDATE或INSERT_UPDATE标记的字段上生效。这套机制和逻辑删除配合时要注意:逻辑删除走的是UPDATE语句,会触发updateFill,所以被删除的记录update_time也会刷新,如果业务上要求保留删除前的时间戳,就别在删除路径上用 MP 的removeById,改成自定义 SQL 绕过填充。
逻辑删除的查询过滤是自动的,但有两个例外。一是手写 XML 的 SQL 不会自动加deleted = 0,需要自己写条件;二是@TableLogic字段在select投影里默认不返回,如果前端需要展示删除状态,得在 XML 里显式列出来。这两个点在联调阶段经常被当成 bug 报上来,其实是设计如此。
4. Wrapper 与自定义 SQL:条件构造器的能力边界
4.1 LambdaQueryWrapper 的链式写法与嵌套条件
单表查询用 Wrapper 能覆盖九成场景,但嵌套OR和括号组合是最容易写错的地方。举个例子:查「用户 A 的已支付订单」或者「金额大于 1000 的任意订单」,这个条件如果用扁平写法拼,SQL 会变成user_id = ? AND status = ? OR amount > ?,运算符优先级导致前半段的AND只约束到 status,结果完全不对。
// 错误写法:OR 会把前面的条件拆散 LambdaQueryWrapper<Order> wrong = new LambdaQueryWrapper<>(); wrong.eq(Order::getUserId, userId) .eq(Order::getStatus, 1) .or() .gt(Order::getAmount, new BigDecimal("1000")); // 正确写法:用 and(Consumer) 把前两个条件包成一个整体 LambdaQueryWrapper<Order> right = new LambdaQueryWrapper<>(); right.and(w -> w.eq(Order::getUserId, userId).eq(Order::getStatus, 1)) .or() .gt(Order::getAmount, new BigDecimal("1000"));and(Consumer)和or(Consumer)接受一个 lambda,内部的条件会被括号包裹。嵌套层级再深也一样处理,and(w -> w.eq(...).or(x -> x.eq(...)))这种写法能拼出任意复杂的布尔表达式。生成出来的 SQL 用日志打出来核对一遍,比盯着代码猜靠谱。
常用方法还有几个要记牢:in处理集合参数,集合为空时整个条件不拼(MP 内部做了判空),但有些版本对空集合的处理是拼IN (),稳妥起见在外面加判空;like默认是%value%,前后都模糊,走不了索引,数据量大的表换成likeRight拼value%;select可以指定返回字段,避免SELECT *把大字段(比如 text 类型的详情)也拉回来。
4.2 自定义 XML 里接 Wrapper 的写法
多表联查或者复杂聚合绕不开手写 SQL,但条件部分还是想复用 Wrapper 的动态拼接能力。MP 提供了${ew.customSqlSegment}占位符,把 Wrapper 生成的条件片段原样注入。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.demo.mapper.OrderMapper"> <select id="selectPageWithUser" resultType="com.example.demo.dto.OrderUserVO"> SELECT o.id, o.order_no, o.amount, o.status, u.nickname FROM t_order o LEFT JOIN t_user u ON u.id = o.user_id ${ew.customSqlSegment} </select> <select id="selectByUserAndStatus" resultType="com.example.demo.entity.Order"> SELECT * FROM t_order WHERE user_id = #{userId} AND status = #{status} AND deleted = 0 ORDER BY create_time DESC </select> </mapper>对应的 Mapper 方法签名要用@Param(Constants.WRAPPER)标注参数名,否则占位符解析不到。
import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.toolkit.Constants; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.apache.ibatis.annotations.Param; IPage<OrderUserVO> selectPageWithUser(Page<OrderUserVO> page, @Param(Constants.WRAPPER) Wrapper<Order> wrapper);${}是字符串拼接,不是预编译占位符,所以customSqlSegment里不能有外部传入的裸值。Wrapper 本身的参数值是通过#{}绑定的,安全性有保障,但别把用户输入直接拼进apply方法里,那是注入的入口。customSqlSegment生成的内容自带WHERE关键字(有条件下),所以 XML 里不要再写WHERE,否则语法报错。这个坑我第一次用的时候踩了,报错信息是You have an error in your SQL syntax near 'WHERE WHERE',一看就明白。
分页插件对自定义 SQL 的支持同样是自动的,只要方法第一个参数是IPage类型,插件就会接管 count 语句的生成。但多表 JOIN 的 count 优化要注意:默认的 count 语句是把整个SELECT子句包一层SELECT COUNT(*) FROM (...),JOIN 大表时很慢,可以在PaginationInnerInterceptor里调setOptimizeJoin(true),插件会尝试去掉不影响计数的 LEFT JOIN。
4.3 分页 count 与多数据源场景的取舍
分页的 count 查询在复杂 SQL 下经常成为性能瓶颈。除了前面说的searchCount=false,还有一个思路是自定义 count 语句:在 XML 里写一个selectCount方法,PaginationInnerInterceptor会优先使用同名的 count 查询。这个技巧在报表类接口里很有用,count 只查主表,不用管 JOIN 出来的明细。
多数据源场景下,@DS注解来自动态数据源组件,不在 MP 本身的能力范围内。整合时要注意分页插件的DbType是全局的,如果两个数据源一个是 MySQL 一个是 PostgreSQL,分页方言会串。解决办法是注册多个MybatisPlusInterceptor实例配合不同的SqlSessionFactory,或者在PaginationInnerInterceptor里重写findIDialect方法按数据源动态判断。后者代码量大但灵活,前者配置简单适合库类型统一的场景。
提示:Wrapper 和 XML 混用时,
resultType里的 VO 类如果字段名和查询列名对不上,靠map-underscore-to-camel-case只能救回下划线转驼峰这一种情况。别名和字段差异大的,老老实实写<resultMap>,别指望自动映射。
5. 排查清单:整合期最容易翻车的五个点
5.1 Mapper 注入失败但启动无异常
现象:项目能启动,日志无报错,调用 Service 时报No qualifying bean of type 'com.example.demo.mapper.OrderMapper'。
原因:starter 坐标还是老的mybatis-plus-boot-starter,Boot 3 读不到新格式的自动配置,MapperScannerConfigurer没注册。另一种可能是@MapperScan的包名写错,或者多模块项目里 Mapper 在另一个 jar 里,扫描路径没覆盖到。
解决:先mvn dependency:tree确认依赖是mybatis-plus-spring-boot3-starter,再检查@MapperScan的包路径是否精确到 Mapper 所在包。多模块场景把每个模块的 Mapper 包都列进数组,比如@MapperScan({"com.example.order.mapper", "com.example.user.mapper"})。确认无误后还不行,就在启动类上加@Mapper逐个标注试一次,能跑通说明是扫描配置问题,不是依赖问题。
5.2 分页查询返回全量数据
现象:调分页接口,IPage.getTotal()有值,但getRecords()返回了表里所有记录,SQL 日志里没有LIMIT。
原因:MybatisPlusInterceptor没有注册,或者注册了但没往里加PaginationInnerInterceptor。还有一种容易忽略的情况是自定义 SQL 的方法签名第一个参数不是IPage类型,插件识别不到分页意图。
解决:检查配置类里interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL))是否存在。自定义分页方法的第一个参数必须是Page<T>或IPage<T>,泛型要对上返回类型,参数位置不能挪到第二个。
5.3 雪花 ID 传到前端精度丢失
现象:后端日志里 ID 是1836472910384721921,前端拿到的变成1836472910384721900,末尾几位被抹平,再拿这个 ID 查详情查不到。
原因:JavaScript 的Number类型安全整数上限是 2^53-1,19 位的雪花 ID 超了,JSON 反序列化时精度截断。
解决:全局配置 Jackson 把Long序列化成字符串。加一个Jackson2ObjectMapperBuilderCustomizer,把Long.class和long.class的序列化器换成ToStringSerializer。局部方案是在实体 ID 字段上打@JsonSerialize(using = ToStringSerializer.class),但字段多了容易漏,全局配置更稳。
5.4 逻辑删除后唯一索引冲突
现象:删除一条订单(逻辑删除,deleted置 1),再用同样的order_no插入新订单,报Duplicate entry。
原因:唯一索引uk_order_no只建在order_no上,逻辑删除的记录还占着这个值,数据库层面认为重复。
解决:把唯一索引改成联合索引(order_no, deleted),删除时deleted置为记录 ID 而不是 1,保证同一个订单号可以有多条已删除记录。或者删除时把业务字段改名(比如order_no拼上时间戳后缀),但这会污染数据。前一种方案更干净,代价是删除逻辑要改,不能直接用removeById,得走自定义更新。
5.5 批量插入慢到怀疑人生
现象:循环调save()插一万条数据花了三分多钟,换saveBatch()只快了一点,还是几十秒。
原因:JDBC URL 没开rewriteBatchedStatements=true,MySQL 驱动把批量操作拆成单条发送。另一个原因是事务没包住,每条 insert 自动提交一次。
解决:URL 里加rewriteBatchedStatements=true,Service 方法上加@Transactional,saveBatch的批次大小默认 1000,数据量特别大时调到 500 左右反而更快,因为单包体积受max_allowed_packet限制。实测一万条从三分多钟降到两秒以内,差距就是这么夸张。
6. 进阶:SQL 可观测性与批量写入的三个生产习惯
到了生产环境,整合能不能用得住,看的不是 CRUD 会不会写,而是出问题时能不能快速定位。我一般会先处理 SQL 日志:开发期用log-impl: StdOutImpl把完整 SQL 和参数打出来,生产期换成logging.level.com.example.demo.mapper: debug,只输出 Mapper 包下的语句,避免日志被刷爆。两套配置的差别在于前者直接打到标准输出,后者走日志框架,能接入日志收集。
# 生产环境推荐配置 logging: level: com.example.demo.mapper: debug mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl第二个习惯是给更新操作加乐观锁。实体上加@Version字段,配置类里注册OptimisticLockerInnerInterceptor,更新时 MP 会自动把version加进WHERE条件并自增。并发更新同一行时,后到的请求影响行数为 0,业务层判断返回值决定重试还是报错。这个机制在库存扣减、订单状态流转里几乎是标配,不加的话只能靠数据库行锁硬扛。
第三个习惯是批量写入的姿势选择。小数据量(几百条)用saveBatch足够;上万条的分批提交,批次大小控制在 500 到 1000;再大的量级走insert into ... values (...),(...)的手写 foreach,单条 SQL 塞几千行,配合rewriteBatchedStatements=true效果最好。三种方式我在不同项目里都用过,选错的代价是接口超时和数据库连接池被打满。
// 手写批量插入的 Mapper 方法 int insertBatch(@Param("list") List<Order> list);<insert id="insertBatch"> INSERT INTO t_order (id, order_no, user_id, amount, status, create_time, update_time, deleted) VALUES <foreach collection="list" item="item" separator=","> (#{item.id}, #{item.orderNo}, #{item.userId}, #{item.amount}, #{item.status}, #{item.createTime}, #{item.updateTime}, 0) </foreach> </insert>这个方法要注意两点:id必须由业务层先生成好(用 MP 的IdWorker.getId()),XML 里不会触发主键回填;单条 SQL 的 values 数量受max_allowed_packet约束,默认 4MB 左右,字段多的时候一批别超过两千行。
从那以后我每次接新项目,整合完第一件事就是把分页、乐观锁、逻辑删除三个插件一次性配齐,再写一个覆盖增删改查分页的冒烟测试跑通,才敢往下写业务。这套顺序帮我省掉了至少三次上线前的返工。希望帮到你。
本文还有配套的精品资源,点击获取