第 53 章:MyBatis-Plus 入门
学习目标
- 完成 MyBatis-Plus 集成
- 掌握 BaseMapper 与 IService 通用方法
- 理解常用注解与逻辑删除
一、为什么用 MyBatis-Plus?
| 维度 | 原生 MyBatis | MyBatis-Plus |
|---|---|---|
| 单表 CRUD | 每张表手写 XML | 内置,零 SQL |
| 分页 | 手写 limit + count | 插件自动处理 |
| 条件构造 | 拼 XML 的 <if> 标签 | Lambda 链式 API |
| 主键生成 | 自己处理 | 内置雪花算法 |
| 逻辑删除 | 每条 SQL 加 deleted=0 | 注解一行搞定 |
| 自动填充 | 手动 set | 注解自动 |
| 侵入性 | - | 零侵入,原生 MyBatis 功能全保留 |
核心价值:把 80% 的重复 CRUD 代码消灭掉,让你专注复杂业务 SQL。
二、集成
依赖
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId> <!-- ① Boot 3 专用 -->
<version>3.5.5</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>⚠️ Spring Boot 3 必须用
mybatis-plus-spring-boot3-starter。用了老的mybatis-plus-boot-starter会因为javax→jakarta包名变更而启动失败。
配置
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/taskflow?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
username: root
password: ${DB_PASSWORD}
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml # ① XML 位置
type-aliases-package: com.taskflow.**.entity # ② 实体别名包
configuration:
map-underscore-to-camel-case: true # ③ user_name → userName
cache-enabled: false # ④ 关闭二级缓存(用 Redis 更可控)
# 开发环境打印 SQL,生产环境删掉这行
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
banner: false
db-config:
id-type: assign_id # ⑤ 默认雪花 ID
logic-delete-field: deleted # ⑥ 全局逻辑删除字段
logic-delete-value: 1
logic-not-delete-value: 0
update-strategy: not_null # ⑦ 更新时忽略 null 字段连接串参数
rewriteBatchedStatements=true很重要:不加的话 MySQL 驱动的批量插入其实是一条条发的,加了之后会合并成一条大 SQL,批量插入性能提升十倍以上。
启动类扫描
@SpringBootApplication
@MapperScan("com.taskflow.**.mapper") // ① 扫描 Mapper 接口
public class TaskflowApplication { }三、实体类注解
@Data
@TableName("sys_user") // ① 表名
public class User {
@TableId(type = IdType.ASSIGN_ID) // ② 主键:雪花算法
private Long id;
private String username; // ③ 无注解时按驼峰转下划线映射
@TableField("nick_name") // ④ 字段名不符合驼峰规则时显式指定
private String nickname;
@TableField(select = false) // ⑤ 查询时不返回(如密码)
private String password;
@TableField(exist = false) // ⑥ 数据库中不存在的字段
private String deptName;
@TableField(fill = FieldFill.INSERT) // ⑦ 插入时自动填充
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE) // ⑧ 插入和更新时都填充
private LocalDateTime updateTime;
@TableLogic // ⑨ 逻辑删除标记
private Integer deleted;
@Version // ⑩ 乐观锁版本号
private Integer version;
}主键策略
| 策略 | 说明 | 适用 |
|---|---|---|
ASSIGN_ID | 雪花算法(默认) | 推荐,分布式友好 |
AUTO | 数据库自增 | 单库单表 |
ASSIGN_UUID | UUID 字符串 | 需要无序 ID |
INPUT | 手动设置 | 业务主键 |
NONE | 无策略 | 特殊场景 |
为什么推荐雪花 ID 而不是自增?
- 分库分表时自增 ID 会冲突
- 自增 ID 暴露业务量(订单号 1001 → 竞争对手知道你只有 1000 单)
- 插入时不需要回表拿 ID
雪花 ID 的坑:
Long类型是 19 位,超过 JavaScript 的Number.MAX_SAFE_INTEGER(16 位),前端会丢精度。解决:序列化成字符串。
// 全局配置:Long 类型序列化为 String
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizer() {
return builder -> builder
.serializerByType(Long.class, ToStringSerializer.instance)
.serializerByType(Long.TYPE, ToStringSerializer.instance);
}
}四、BaseMapper:开箱即用
public interface UserMapper extends BaseMapper<User> {
// 空的!单表 CRUD 全都有了
}// 增
userMapper.insert(user);
// 删
userMapper.deleteById(1L);
userMapper.deleteBatchIds(List.of(1L, 2L, 3L));
userMapper.delete(new LambdaQueryWrapper<User>().eq(User::getStatus, 0));
// 改
userMapper.updateById(user); // 按 ID 更新非 null 字段
userMapper.update(user, wrapper); // 按条件更新
// 查
userMapper.selectById(1L);
userMapper.selectBatchIds(List.of(1L, 2L));
userMapper.selectList(wrapper);
userMapper.selectOne(wrapper); // ⚠️ 结果多于 1 条会抛异常
userMapper.selectCount(wrapper);
userMapper.selectPage(page, wrapper);
userMapper.selectMaps(wrapper); // 返回 List<Map>五、IService:Service 层增强
// ① 接口继承 IService
public interface UserService extends IService<User> {
UserVO getVOById(Long id); // 自己的业务方法
}
// ② 实现类继承 ServiceImpl
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
implements UserService {
@Override
public UserVO getVOById(Long id) {
User user = getById(id); // ③ 直接用父类方法
return UserConvert.INSTANCE.toVO(user);
}
}IService 相比 BaseMapper 多了什么?
// ① 批量操作(内部分批,默认 1000 一批)
userService.saveBatch(users);
userService.saveBatch(users, 500); // 指定批次大小
userService.updateBatchById(users);
userService.saveOrUpdateBatch(users);
// ② 存在则更新,不存在则插入
userService.saveOrUpdate(user);
// ③ 链式调用(最常用)
userService.lambdaQuery()
.eq(User::getStatus, 1)
.like(User::getUsername, "张")
.orderByDesc(User::getCreateTime)
.list();
userService.lambdaUpdate()
.eq(User::getId, 1L)
.set(User::getStatus, 0)
.update();
// ④ getOne 加 throwEx 参数,多条时不抛异常
userService.getOne(wrapper, false); // 返回第一条
// ⑤ 链式查询的便捷终端方法
userService.lambdaQuery().eq(...).one(); // 单条
userService.lambdaQuery().eq(...).list(); // 列表
userService.lambdaQuery().eq(...).count(); // 计数
userService.lambdaQuery().eq(...).exists(); // 是否存在
userService.lambdaQuery().eq(...).page(page); // 分页
saveBatch的性能真相:MyBatis-Plus 的saveBatch是循环执行单条 insert,只是共用一个 session。真正的高性能批量插入需要rewriteBatchedStatements=true(见上文)或自己写INSERT INTO ... VALUES (),(),()的 XML。
六、逻辑删除
@TableLogic
private Integer deleted; // 0=未删除,1=已删除效果:MyBatis-Plus 自动改写 SQL
-- 你写的:userMapper.deleteById(1L)
UPDATE sys_user SET deleted = 1 WHERE id = 1 AND deleted = 0;
-- 你写的:userMapper.selectById(1L)
SELECT * FROM sys_user WHERE id = 1 AND deleted = 0;
-- 你写的:userMapper.selectList(null)
SELECT * FROM sys_user WHERE deleted = 0;⚠️ 逻辑删除的三大坑
坑 1:唯一索引冲突
-- 用户名唯一索引
UNIQUE KEY uk_username (username)
-- 张三注册 → 删除 → 再注册 → ❌ 唯一索引冲突(老记录还在,只是 deleted=1)解决:
-- 方案 A:唯一索引包含删除标记(deleted 存删除时间戳而非 0/1)
UNIQUE KEY uk_username (username, deleted)
-- deleted 未删除时为 0,删除时设为当前时间戳 → 可重复删除同一用户名
-- 方案 B:不用逻辑删除,删除时把数据移到归档表坑 2:自定义 SQL 不生效
<!-- ❌ 手写的 SQL,MyBatis-Plus 管不着,会查出已删除数据 -->
<select id="selectCustom" resultType="User">
SELECT * FROM sys_user WHERE dept_id = #{deptId}
</select>
<!-- ✅ 必须手动加条件 -->
<select id="selectCustom" resultType="User">
SELECT * FROM sys_user WHERE dept_id = #{deptId} AND deleted = 0
</select>坑 3:数据量膨胀
删除的数据永远留在表里,几年后表里 90% 是垃圾数据,查询越来越慢。
对策:定时归档任务,把
deleted=1且超过 N 天的数据迁到归档表后物理删除。
七、乐观锁
@Version
private Integer version;@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}效果:
User user = userMapper.selectById(1L); // version = 1
user.setStatus(0);
int rows = userMapper.updateById(user);
// 实际 SQL: UPDATE sys_user SET status=0, version=2 WHERE id=1 AND version=1
// 如果期间被别人改过(version 已是 2),rows = 0,更新失败// 必须判断返回值!
if (userMapper.updateById(user) == 0) {
throw new BusinessException(ErrorCode.CONCURRENT_UPDATE, "数据已被他人修改,请刷新重试");
}⚠️ 乐观锁只在
updateById和update(entity, wrapper)时生效,lambdaUpdate().set()这种不带实体的更新不会走乐观锁。
八、必备插件配置
@Configuration
@MapperScan("com.taskflow.**.mapper")
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// ① 分页插件(必须第一个加,顺序有讲究)
PaginationInnerInterceptor page = new PaginationInnerInterceptor(DbType.MYSQL);
page.setMaxLimit(500L); // 单页上限,防止恶意大分页
page.setOverflow(false); // 页码超出最大页时不回到首页
interceptor.addInnerInterceptor(page);
// ② 乐观锁
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
// ③ 防全表更新删除(安全兜底)
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
return interceptor;
}
}③
BlockAttackInnerInterceptor强烈建议开启。它会阻止没有 WHERE 条件的 UPDATE / DELETE。真实事故:某开发写了userMapper.delete(new QueryWrapper<>())(wrapper 为空),把整张表删了。
九、本章小结
| 要点 | 关键 |
|---|---|
| Boot 3 | 用 mybatis-plus-spring-boot3-starter |
| 连接串 | rewriteBatchedStatements=true |
| 雪花 ID | Long 要序列化成 String,防前端精度丢失 |
| BaseMapper | 单表 CRUD 全包 |
| IService | 批量、链式、saveOrUpdate |
| 逻辑删除 | 注意唯一索引冲突、自定义 SQL 不生效 |
| 乐观锁 | 必须判断返回行数 |
| 插件 | 分页 + 乐观锁 + 防全表操作 |
动手练习
练习 1:基础题
创建 sys_user 表,配置 MyBatis-Plus,用 BaseMapper 完成增删改查,验证逻辑删除生效(数据没真删)。
练习 2:进阶题
模拟并发场景:两个线程同时读取同一条记录并修改,验证乐观锁能拦住其中一个。
下一章:第 54 章:条件构造器与分页 →