先聊点直接的。如果你用过原生 MyBatis,肯定经历过这种场面:每张表要写一个 Mapper 接口、一个 XML 文件、一堆增删改查标签,CRUD 还好说,遇到点动态 SQL 就要小心翼翼拼条件。代码写着写着就变成了“体力活”。MyBatis-Plus 就是来解决这个问题的,简单说它是 MyBatis 的增强工具,只做增强不做改变,把通用 CRUD、条件构造、分页、逻辑删除这些高频场景全部封装好了,让你省掉大量重复劳动。这篇快速入门向的内容,适合刚接触 MyBatis-Plus 或者想系统梳理一遍的开发者,也适合正在做项目选型、想评估这个框架值不值的同学。我会把核心原理、关键配置、代码实操和踩坑经验一起讲清楚,尽量让你看完就能在自己的项目里跑起来。
1. 项目整体设计与核心思路拆解
1.1 为什么用 MyBatis-Plus 而不是纯写 XML
我先说一个观点:MyBatis-Plus 不是要取代 MyBatis,它是把 MyBatis 里最容易重复、最容易出错的那些活替你干了。
回想一下,原生 MyBatis 里一个最简单的单表分页查询,你需要写 ResultMap、写<select>标签、写<where>动态条件、写<if>判空、再写 limit 语句。每加一张表,这套流程就复制一遍。如果后面表结构改个字段名,XML 里到处要找,脑子稍微一乱就容易漏。这种工作本身没有技术难度,但极其消磨精力。
MyBatis-Plus 的思路很简单:既然单表 CRUD 是高度范式化的,那就把通用操作预置好。它提供了一套通用的 BaseMapper 接口,里面有selectById、insert、deleteById、updateById、selectList等方法,还有一套条件构造器Wrapper,把动态 SQL 的拼接变成链式 API 调用。你不需要写任何 XML,就能完成单表的大部分操作。
这里要特别强调它的设计哲学,官方原话叫“只做增强不做改变”。这意味着你之前写的那些对应的自定义 Mapper 方法、XML 里的复杂关联查询,完全不受影响。它不会侵入你已有的代码结构,只是在一侧帮你搭好了地基。这个设计决策非常关键,因为它让 MyBatis-Plus 能在存量项目里平滑落地,不用推倒重来。
1.2 快速入门的功能范围界定
一个“快速入门”级别的内容,核心链路要覆盖这几块:依赖引入与配置、实体类映射、BaseMapper 通用 CRUD、Service 层封装、条件构造器、分页插件、常用进阶能力(逻辑删除、自动填充、乐观锁)。这一套下来,单表操作基本已经脱离手写 SQL 了。
不建议一上来就研究代码生成器、多租户、动态表名这些高级特性。它们确实很方便,但需要你对 MyBatis-Plus 的底层机制有足够的理解才能驾驭好。比如代码生成器生成的代码能不能直接用在生产环境,答案是不能,因为生成器只负责搭骨架,业务逻辑还得自己填。我在后面会详细讲这个判断。
这次实战我用的是 Spring Boot 3 + MyBatis-Plus 3.5.x 的组合,数据库用 MySQL 8。你如果用的是 Spring Boot 2.x 或者 2.x 之前的版本,版本对应关系要额外留意,后面的常见问题部分我会专门说。
2. 环境准备与核心配置实操
2.1 依赖引入与版本选型
如果你用 Maven,核心依赖就是一个:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.7</version> </dependency>注意 Starter 的差异。Spring Boot 3 用的是mybatis-plus-spring-boot3-starter,Spring Boot 2 用的是mybatis-plus-boot-starter。这两者的包名和坐标不同,混用会导致启动异常。
不建议直接扒最新版,以 3.5.7 为例,它在 Spring Boot 3 生态下表现很稳定,社区反馈的问题也基本都有解决方案。如果你项目里已经有 MyBatis 的相关依赖,需要先排除掉,避免和 MyBatis-Plus 内部自带的 MyBatis 版本冲突。所谓“只做增强”的实现方式,其实就是在 MyBatis 基础上做了一层拦截和包装,所以它内置了对应版本的 MyBatis,重复引入容易出问题。
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.7</version> <exclusions> <exclusion> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> </exclusion> <exclusion> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> </exclusion> </exclusions> </dependency>2.2 数据源与基础配置
数据源这块我直接用 Spring Boot 的默认配置,简单演示:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/mp_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: yourpasswordMyBatis-Plus 自带的一些核心配置项,写在mybatis-plus节点下:
mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 mapper-locations: classpath*:/mapper/**/*.xml逐项解释一下我为什么这样配:
log-impl这个配置很实用。开发环境把 SQL 日志打到控制台,你能看到每条 SQL 是怎么拼出来的,排查条件构造器或者分页问题的时候,全靠这份日志才能定位。生产环境务必关掉,否则日志量太大压垮磁盘 I/O。
map-underscore-to-camel-case默认其实就是开启的,但最好显式写出来。它的作用是把数据库的下划线字段名user_name自动映射到实体类的userName。光开这个还不够,实体类上的注解没写对,照样映射不上,后面我会演示正确写法。
logic-delete-field是指定逻辑删除字段的全局配置。只要实体类里有一个deleted字段,所有删除操作就会自动变成UPDATE ... SET deleted = 1,查询也会自动带上WHERE deleted = 0。一开始用 MyBatis-Plus 最容易踩的坑就是:明明删了数据,怎么还在;或者明明查不到数据,以为删掉了,结果只是被逻辑删了。所以逻辑删除字段的命名规范一定要在项目里统一好。
2.3 启动类扫描与 Mapper 注册
Spring Boot 启动类上要加@MapperScan,指定 Mapper 接口所在的包路径,不然 Spring 容器根本不知道这些接口的存在。
@SpringBootApplication @MapperScan("com.example.demo.mapper") public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }有人可能会问,能不能用@Mapper注解替代?可以。@MapperScan是批量扫描,@Mapper是逐个标注。项目里 Mapper 接口一多,还是@MapperScan省事,而且新加 Mapper 不用每次补注解。
到这里,基础环境就绪了。别急着写业务代码,先把数据库表建出来,表结构我沿用实际项目里最常见的用户表模型来设计。
3. 实体类映射与 BaseMapper 核心操作
3.1 表结构设计
建一张用户表,字段尽量覆盖常见的映射场景:主键、普通字段、下划线命名、逻辑删除、自动填充字段、乐观锁版本号。
CREATE TABLE `user_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `name` varchar(50) NOT NULL COMMENT '姓名', `age` int(11) DEFAULT NULL COMMENT '年龄', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标志,0-未删除,1-已删除', `version` int(11) NOT NULL DEFAULT '1' COMMENT '乐观锁版本号', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表';早期项目里我习惯用user做表名,后来发现user在部分数据库里是保留字,太容易出诡异问题了。从那以后统一规避,用user_info这类带后缀的表名。这里先埋个伏笔,后面讲@TableName的时候会对应上。
3.2 实体类注解详解
实体类这样写:
@Data @TableName("user_info") public class UserInfo { @TableId(type = IdType.AUTO) private Long id; private String name; private Integer age; private String email; @TableLogic private Integer deleted; @Version private Integer version; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }每个注解单独说下:
@TableName("user_info")用来指定实体类对应的数据库表名。如果不写,MyBatis-Plus 默认按实体类名驼峰转下划线去找表,UserInfo会去匹配user_info。这正好能对应上,但为了防止类名和表名出现偏差,建议显式写上。项目里如果有人把实体类命名为UserInfoVO但表还是user_info,不写注解就彻底匹配不上了。
@TableId(type = IdType.AUTO)标注主键,并且指定主键生成策略。AUTO就是数据库自增,这是最常用的。IdType.ASSIGN_ID用的是雪花算法生成分布式 ID,适合分布式场景,但是和数据库自增策略要匹配好,不能既用数据库自增又用雪花 ID,那样插入的时候会出主键冲突。实际上 MyBatis-Plus 3.x 里雪花算法是默认策略,所以如果数据库主键是自增,这个注解必须写,否则你会发现插入数据时它给你传了一个很长的雪花 ID 进去,然后数据库报错或者出现乱码数据。
@TableLogic标记逻辑删除字段。加上之后,别手动去写UPDATE ... SET deleted = 1这种 SQL,直接调deleteById,它自动完成逻辑删除。
@Version是乐观锁版本字段,配合乐观锁插件使用,后面单独讲。
@TableField(fill = FieldFill.INSERT)表示这个字段在插入时自动填充,FieldFill.INSERT_UPDATE表示插入和更新都自动填充。这个填充动作依赖 MetaObjectHandler 实现,不配置的话注解本身不会生效。
3.3 BaseMapper 通用 CRUD 演示
Mapper 接口一行代码都不用写:
public interface UserInfoMapper extends BaseMapper<UserInfo> { }继承BaseMapper<UserInfo>后,常用的方法就全有了。我列一下实际开发中使用频率最高的几个:
UserInfo user = userInfoMapper.selectById(1L); int insertCount = userInfoMapper.insert(user); int updateCount = userInfoMapper.updateById(user); int deleteCount = userInfoMapper.deleteById(1L); List<UserInfo> list = userInfoMapper.selectList(null);这里有个细节要提醒:insert方法传入的实体类,如果字段是null,SQL 里默认不会带上这个字段,也就是说它是动态插入。比如email字段为 null,生成的 SQL 是INSERT INTO user_info (name, age) VALUES (?, ?),email列会用数据库的默认值。这种「有值才插入、有值才更新」的策略,能省掉很多判空代码。但如果业务上有需要强制插入 null 的需求,比如数据库列没有默认值且不可为空,字段传 null 会导致插入失败,这时就要用到FieldStrategy的配置或者手动写 XML,后面问题排查里我会展开。
selectList(null)传 null 表示没有任何过滤条件,查询全表。一旦传入了条件构造器,它就会拼WHERE。
3.4 Service 层封装
实际开发里不会直接让 Controller 调 Mapper,中间还隔着一层 Service。MyBatis-Plus 也给了一套现成的 Service 封装:IService接口 +ServiceImpl实现类。
接口:
public interface UserInfoService extends IService<UserInfo> { }实现类:
@Service public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> implements UserInfoService { }你会发现ServiceImpl里已经内置了getById、save、saveBatch、list、page等方法,和BaseMapper的方法基本对应,但更贴合业务层语义。比如:
boolean saved = userInfoService.save(user); boolean updated = userInfoService.updateById(user); boolean removed = userInfoService.removeById(1L); UserInfo user = userInfoService.getById(1L); List<UserInfo> list = userInfoService.list();这里有个容易混淆的点:saveBatch很常用,但默认情况下它并不是真正的一条 SQL 批量插入,而是循环执行单条插入。要开启真正的批量插入,需要配置rewriteBatchedStatements=true,这个参数加在 JDBC URL 上:
jdbc:mysql://localhost:3306/mp_demo?rewriteBatchedStatements=true加了之后,MySQL 才会把多条INSERT语句重写成多值插入,性能提升非常明显。批量插入几千条数据的时候,有没有这个参数,耗时差别是数量级的。
4. 条件构造器与分页插件实战
4.1 QueryWrapper 和 LambdaQueryWrapper
条件构造器是 MyBatis-Plus 的灵魂。它的作用就是让你用 Java 代码拼出动态 SQL,替代 XML 里的那一堆<if>判断。
先看最原始的QueryWrapper:
QueryWrapper<UserInfo> wrapper = new QueryWrapper<>(); wrapper.eq("name", "张三") .gt("age", 18) .like("email", "@qq.com") .orderByDesc("create_time"); List<UserInfo> list = userInfoMapper.selectList(wrapper);这段代码拼出来的 SQL 大概是:
SELECT id,name,age,email,deleted,version,create_time,update_time FROM user_info WHERE deleted=0 AND name = '张三' AND age > 18 AND email LIKE '%@qq.com%' ORDER BY create_time DESC注意逻辑删除字段deleted=0是自动加的,你没写它,它也帮你加上了。这就是框架的“增强”,你不会感觉到它的存在,但它已经保证了查询不会把已删除的数据捞出来。
QueryWrapper用法简单,但是有个问题:条件里的列名都是字符串,写错了编译器不报错,运行期才炸。所以更推荐用LambdaQueryWrapper:
LambdaQueryWrapper<UserInfo> wrapper = Wrappers.lambdaQuery(); wrapper.eq(UserInfo::getName, "张三") .gt(UserInfo::getAge, 18) .like(UserInfo::getEmail, "@qq.com") .orderByDesc(UserInfo::getCreateTime);UserInfo::getName这种写法有两个好处:列名从实体类属性推导,拼写错误编译期就能发现;后续字段改名的时候,用 IDEA 全局重构也能一并处理,不会出现字符串漏改的情况。
条件构造器里我最常用的几个方法:eq等于、ne不等于、gt大于、ge大于等于、lt小于、le小于等于、like模糊匹配、in范围匹配、isNull判空、between区间。条件判断和拼接可以用and、or方法嵌套:
LambdaQueryWrapper<UserInfo> wrapper = Wrappers.lambdaQuery(); wrapper.eq(UserInfo::getDeleted, 0) .and(w -> w.eq(UserInfo::getAge, 20) .or() .like(UserInfo::getName, "张"));这段逻辑是:deleted=0 AND (age=20 OR name LIKE '%张%')。不加and包裹的话,or会直接拼接成deleted=0 AND age=20 OR name LIKE '%张%',优先级完全变了。条件构造器踩坑排行榜里,“or 优先级写错”绝对能排前三。
4.2 分页插件配置与原理
MyBatis-Plus 的分页是一个插件机制,基于 MyBatis 的拦截器实现。先配置拦截器:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }DbType.MYSQL指定数据库方言,插件才能拼出正确的分页语句。MySQL 是LIMIT ? , ?,PostgreSQL 是LIMIT ? OFFSET ?,SQL Server 是TOP那套写法,不能混用。
分页查询这样写:
Page<UserInfo> page = new Page<>(1, 10); LambdaQueryWrapper<UserInfo> wrapper = Wrappers.lambdaQuery(); wrapper.like(UserInfo::getName, "张") .orderByDesc(UserInfo::getCreateTime); Page<UserInfo> result = userInfoMapper.selectPage(page, wrapper); List<UserInfo> records = result.getRecords(); long total = result.getTotal(); long pages = result.getPages();执行这段代码时,MyBatis 会执行两条 SQL:一条是SELECT COUNT(*)统计总数,一条是带LIMIT的分页查询。total 就是第一行 count 的结果。分页插件内部的工作方式,是在你的 SQL 执行前拦截,改写成带 limit 的形式,并把 count SQL 自动担过去执行。
一个容易踩的坑是:自定义 SQL 想要分页,不能随便写。如果你的 XML 里有这样的查询:
<select id="selectUserPage" resultType="com.example.demo.entity.UserInfo"> SELECT u.* FROM user_info u LEFT JOIN user_order o ON u.id = o.user_id WHERE u.deleted = 0 </select>Mapper 接口返回类型写IPage<UserInfo>:
IPage<UserInfo> selectUserPage(Page<UserInfo> page, @Param("ew") Wrapper<UserInfo> wrapper);这种情况下分页插件才能正常工作。如果你把Page参数写在中间位置,或者返回类型写成List,分页大概率失效。原因为Page参数是要被拦截器识别并提取的,它得出现在方法参数里且能被 MyBatis 正确处理。凡是自定义的分页方法,建议参数顺序固定成(Page, Wrapper),XxxMapper.xml 里也不用手写 limit,插件会自动加。
4.3 自定义 SQL 搭配 Wrapper
实际项目里,单表 CRUD 靠 BaseMapper 就够,但是多表关联查询还是得写 XML。这时可以让你写的 SQL 和 Wrapper 结合使用:
<select id="selectUserOrderPage" resultType="com.example.demo.dto.UserOrderDTO"> SELECT u.id, u.name, o.order_no, o.amount FROM user_info u LEFT JOIN user_order o ON u.id = o.user_id ${ew.customSqlSegment} </select>Mapper 接口:
IPage<UserOrderDTO> selectUserOrderPage(Page<UserOrderDTO> page, @Param(Constants.WRAPPER) Wrapper<UserOrderDTO> wrapper);${ew.customSqlSegment}会被替换成WHERE条件片段,包括逻辑删除的自动拼接。这里一定要注意用${}而不是#{},因为 Wrapper 拼接出来的是一整段 SQL 条件,不是单个占位符。虽然customSqlSegment内部的值也是用占位符处理的,不会引入 SQL 注入风险,但使用这个机制时,传入的 Wrapper 必须唯一,千万别在接口里定义两个 Wrapper 参数,MyBatis-Plus 会直接报错。
5. 进阶能力:逻辑删除、自动填充与乐观锁
5.1 逻辑删除的原理与使用场景
物理删除是DELETE FROM user_info WHERE id = ?,逻辑删除是:
UPDATE user_info SET deleted = 1 WHERE id = ?MyBatis-Plus 配置了逻辑删除字段后,删除操作看起来还是deleteById,但实际执行的是 UPDATE 语句,所有查询会自动附加WHERE deleted = 0。
这个设计非常适合需要保留操作痕迹的场景,比如用户注销、订单作废、审批驳回。直接物理删除的话,后续想看历史记录就没了。但也要提醒一句,逻辑删除不是银弹。它会让表数据一直膨胀,而且deleted字段如果建了唯一索引,就会因为历史数据残留导致新数据插入失败。比如user_info表对email建了唯一索引,一个用户注销后,另一个用户想注册同一个邮箱,插入时因为旧记录deleted=1但还占着这个邮箱,唯一索引直接冲突。业务上应对这个问题的常见方案是把邮箱改成email + '_deleted_' + id再逻辑删,或者按时间去重。
5.2 自动填充:创建时间和更新时间
很多表都有create_time和update_time两个字段,以前每次插入和更新都要手动 new 一个LocalDateTime塞进去,写多了就是流水账。MyBatis-Plus 的自动填充功能可以把这段去掉。
配置一个 MetaObjectHandler:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }实体类里用@TableField(fill = FieldFill.INSERT)和@TableField(fill = FieldFill.INSERT_UPDATE)标记对应字段。这样无论通过insert还是updateById操作,时间字段都会自动填好。
需要注意,字段名一定要和实体类里的属性名一致,strictInsertFill第二个参数写的是实体属性名,不是数据库列名。我见过有人写成create_time,结果填充没生效,就是这个细节。
strict开头的方法还有个特性:只有当实体类里该字段为 null 时才填充。如果你手动给createTime设了值,框架不会覆盖。这个设计很合理,数据迁移、历史数据导入时可以手动指定时间。
5.3 乐观锁插件
乐观锁解决的是并发更新问题。经典场景:两个用户同时读取同一条数据,都去修改,后提交的人会覆盖先提交的人。
MyBatis-Plus 的乐观锁实现方式是版本号机制:
UPDATE user_info SET name = ?, version = version + 1 WHERE id = ? AND version = ?如果执行后影响行数为 0,说明期间数据被别人改过了,需要重试或者提示用户。
操作分两步走:第一步先配置插件:
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());第二步在实体类的 version 字段上加@Version注解。前面建表时已经准备好了version字段,对应实体类里也写了@Version。
使用的时候要注意:执行updateById时,实体类里的 version 值必须是从数据库查出来的旧值,不能是空。比如:
UserInfo user = userInfoMapper.selectById(1L); user.setName("新名字"); userInfoMapper.updateById(user);这样updateById才会在 WHERE 条件里带上version = 1,更新成功后 version 变成 2。如果user对象是前端传过来的,version 字段是 null,那乐观锁就不生效,变成普通的 UPDATE,又回到覆盖问题了。所以前端在编辑页拿到数据时,必须把 version 一起回传给后端。
6. 常见问题与排查技巧实录
6.1 表名映射失败的坑
实体类叫UserInfo,表叫user_info_t,查询时报Table 'mp_demo.user_info' doesn't exist。这种问题根源很简单:实体类没有加@TableName,MyBatis-Plus 按驼峰转下划线的默认规则去找表名,UserInfo转出来是user_info,和实际表名对不上。
排查方式:看控制台打印的 SQL 语句,从 SQL 里的表名就能确认它把哪张表当成了目标表。这种问题在项目里经常出现,因为大家复制实体类时很容易忘记改注解,解决方式就是在实体类上显式加@TableName("实际表名")。
6.2 逻辑删除字段没生效
现象是调用deleteById后,数据真的从数据库消失了。原因通常是实体类字段上没加@TableLogic,或者全局配置里logic-delete-field指定的字段名和实体类属性不一致。
检查顺序:先看实体类的deleted字段有没有@TableLogic;再看 yml 里logic-delete-field是不是deleted;最后看控制台 SQL,如果打印出来的是DELETE FROM,说明逻辑删除配置根本没生效,如果打印的是UPDATE ... SET deleted = 1,说明生效了。
6.3 分页查询 count 为 0 但记录正常返回
这个问题的现象很迷惑:数据查出来了,total 却是 0。常见原因是在自定义分页查询的 XML 里写了SELECT count(*) FROM ...之类的 count 语句,并且返回类型没配对,或者 SQL 里带了GROUP BY导致 count SQL 异常。另外,如果分页插件没配置,分页也不会生效,调用page方法时 getAll 方法会返回全部数据,不会真的分页。
排查时先看两个点:插件有没有加,DbType 有没有配对。再看日志里的 count SQL 长什么样。如果是SELECT COUNT(*) FROM user_info这种语句且结果量正常,插件本身没问题,问题在业务 SQL 上。
6.4 LocalDateTime 序列化格式不对
用 Spring Boot 默认的 Jackson 序列化,LocalDateTime字段返回给前端是数组格式,或者是一长串 ISO 字符串,不是项目约定的yyyy-MM-dd HH:mm:ss。这不是 MyBatis-Plus 的问题,但和它常用在一起,所以经常被一起提到。
解决方式是在配置里加一个全局的 Jackson 配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8或者自定义一个 Jackson 的LocalDateTimeSerializer注入到 ObjectMapper 里。我个人更推荐后者,因为spring.jackson.date-format对LocalDateTime不总是生效,它对java.util.Date才有效。稳妥方案是单独注册序列化器:
@Bean public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() { return builder -> { builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); builder.deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }; }6.5 批量插入性能差
saveBatch一千条数据要几秒钟,问题基本出在 JDBC URL 没配rewriteBatchedStatements=true。MySQL 默认不会把批量插入的语句合并成一条,而是逐条执行,网络往返次数多,性能自然差。把这个参数加上,SQL 会变成多条VALUES合并成一条,执行时间能缩短到原来的十分之一甚至更低。
6.6 LambdaQueryWrapper 字段名报错
UserInfo::getName这种写法如果有多个同名字段,或者实体类里用了继承结构,偶尔会出现字段名解析不对的情况。本质原因是 MyBatis-Plus 通过反射解析SerializedLambda来获取属性名,如果实体类里面属性名有歧义,或者 Lambda 表达式序列化失败,就会解析不到。
解决方式:尽量避免实体类之间的继承关系,或者在实体类上显式加@TableField来强指定列名。遇到解析异常,直接改用 QueryWrapper 先跑通业务,再回头排查 Lambda 解析逻辑。
7. 一些个人的使用体会
用 MyBatis-Plus 五六年了,中间也踩过不少坑,说几点最深的体会。
这里面最关键的是要克制住“什么都用 MP 做”的冲动。单表 CRUD 封得好,不代表复杂业务也适合硬套。多表关联、复杂子查询、特殊的分组统计,这些场景 MyBatis-Plus 没法优雅覆盖,硬用 Wrapper 去拼会有一种为了用而用的别扭感。我的经验是:简单单表操作直接上 BaseMapper + LambdaQueryWrapper,复杂查询老老实实写 XML,两者在一个项目里共存完全没问题。
第二个体会是配置规范一定要在项目初期定好。逻辑删除字段叫什么、主键策略用哪种、时间字段怎么处理、是否全局开启下划线转驼峰,这些约定早定早省心。中途改配置引发的不可控问题,远比架构设计问题多得多。比如线上跑了一年的表,突然要给逻辑删除字段加全局配置,存量数据的查询就全会改变语义,风险非常大。
再有个小技巧是,接手老项目时先看一眼 yml 的mybatis-plus配置和 Mapper 目录,比看十几个实体类更快判断出这个项目的 MyBatis-Plus 用到了什么程度。有没有分页插件、逻辑删除开没开、SQL 日志打不打,这些配置一出来,项目的技术现状基本心里有数了。
关于代码生成器,我想多说一句。自动生成的 Controller、Service、Mapper、实体类,能帮你省掉大量机械性的骨架代码,但生成的东西永远只是骨架。业务校验、事务边界、权限控制、缓存策略,这些核心逻辑生成器一概不会管。用过几次你就知道,生成器最适合的场景是快速搭一个新模块的开发脚手架,或者做原型验证。千万别把生成结果原封不动丢进生产代码里,你不去改那些地方,它们就会在某个业务高峰期悄然反噬。
MyBatis-Plus 的文档做得还不错,几乎每个注解和插件都有对应的使用说明,遇到不确定的细节先去翻官方文档,比在网上搜零散的博客要靠谱得多。不过文档偏功能罗列,真要理解设计意图和边界条件,还是得多看源码。比如最开始我也不明白为什么selectList(null)可以查全部,后来发现它的判断逻辑是 wrapper 为空就不过滤,这个行为跟普通方法的入参判空不一样,源码里才写得明明白白。有条件的话,把MybatisPlusInterceptor、PaginationInnerInterceptor、LogicDeleteInterceptor这几个核心类的源码翻一遍,你对 MyBatis-Plus 的理解会直接上一个层次。
最后再说个经验:别盲目升级版本。MyBatis-Plus 3.x 各小版本之间的行为差异其实不少,逻辑删除默认值、乐观锁实现方式、和 Spring Boot 版本的兼容性,都可能因为一个小版本变化而出问题。生产项目里,锁死一个经过验证的版本,比追新版本更稳。尤其是你还有其他框架依赖时,升级 MyBatis-Plus 之前一定要看它的 release notes 和依赖清单,否则启动报错找一天也找不到原因。
这篇快速入门可能没法覆盖到 MyBatis-Plus 的全部功能,比如多租户、动态表名、数据权限插件这些高级话题,但如果单表操作这一套你吃透了,项目里大概七八成的数据库访问代码都能轻松拿下了。剩下的高级功能,等你真遇到对应场景时再针对性地去查、去试,反而比一开始全学更高效。