02 · 数据访问:MyBatis-Plus
02 · 数据访问:MyBatis-Plus
本篇讲「对象」和「数据库表」如何对应,以及怎么用 MyBatis-Plus 优雅地读写数据。读完后你应该能回答:MyBatis-Plus 帮我们省掉了什么?哪些东西仍然需要我们自己写 SQL?分页、事务、关联查询分别是怎么落地的?
2.1 ORM 是什么
ORM(Object-Relational Mapping,对象关系映射)解决一个核心矛盾:Java 是面向对象的,数据库是关系型的(一张张表)。我们希望写业务代码时只操作 Java 对象,而不想每次都手写 Connection / PreparedStatement / ResultSet 那一套 JDBC 样板代码。ORM 框架在中间做翻译:
Java 对象 (SysUser) ORM 框架 数据库表 (sys_user)
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────────┐
│ Long id │ ───► │ 对象字段 ──► 列 │ ───► │ id BIGSERIAL │
│ String username │ │ SELECT/INSERT… │ │ username VARCHAR(64) │
│ String email │ │ 结果集 ──► 对象 │ ◄─── │ email VARCHAR(160) │
└─────────────────┘ └──────────────────┘ └──────────────────────┘为什么选 MyBatis(而不是 JPA / Hibernate)
Java 世界里最常见的两种持久层方案:
| 方案 | 思路 | 优点 | 缺点 | 适合 |
|---|---|---|---|---|
| JPA / Hibernate | 对象为中心,框架帮你生成 SQL | 几乎不用写 SQL,迁移数据库方便 | SQL 是「黑盒」,复杂查询/性能调优困难,N+1 容易踩坑 | 简单 CRUD、快速原型 |
| MyBatis / MyBatis-Plus | SQL 为中心,你掌控每条 SQL | SQL 完全可控、好优化、好调试 | 要手写一些 SQL/映射 | 对 SQL 性能敏感、SQL 复杂的业务 |
MyBatis-Plus(简称 MP)是在 MyBatis 之上的「增强」:它不改变 MyBatis「SQL 由你掌控」的本质,只是额外送了一套通用的单表 CRUD 方法和分页插件,让你不用为每个表都重复写 insert/selectById/updateById。本项目就属于这种情况——单表用 MP 的通用方法,多表关联用我们自己的 SQL。
扩展了解:Hibernate 的「脏检查」「一级/二级缓存」「延迟加载」很强大,但初学者容易被「明明没调用 save 却更新了数据库」「LazyInitializationException」这类现象搞糊涂。MyBatis 心智模型更简单:你调了方法,才会发 SQL。
2.2 实体与表映射(注解详解)
实体类(entity)就是「表的 Java 镜像」,一个字段对应一列。本项目所有实体都在 backend/src/main/java/com/yang/backend/entity/。
2.2.1 @TableName:类 → 表
// backend/src/main/java/com/yang/backend/entity/SysUser.java:9
@TableName("sys_user")
public class SysUser {
...
}@TableName("sys_user") 声明「这个类映射到数据库表 sys_user」。如果类名和表名恰好一样(且符合命名习惯),这个注解可以省略;但本项目表名都是 sys_ 前缀,所以必须显式写。
2.2.2 @TableId:主键与生成策略
// backend/src/main/java/com/yang/backend/entity/SysUser.java:12
@TableId(type = IdType.AUTO)
private Long id;@TableId 标记哪个字段是主键列 id。type 决定主键怎么产生,常见三种:
| IdType | 含义 | 本项目用在哪 |
|---|---|---|
AUTO | 主键由数据库自增(BIGSERIAL/AUTO_INCREMENT)生成,插入时不用我们提供 id | SysUser、SysRole、SysPermission(见各实体 :12) |
INPUT | 主键值由我们自己在插入前 set 进去 | 本项目未使用(注意:旧版文档曾误写为 INPUT,以源码为准) |
ASSIGN_ID | MP 自动生成雪花算法 ID(Long 或 String),不依赖数据库自增 | 本项目未使用,但分布式系统常用 |
为什么本项目用 AUTO?看 schema.sql:
-- backend/src/main/resources/db/schema.sql:2
id BIGSERIAL PRIMARY KEY,BIGSERIAL 是 PostgreSQL 的「自增列」。当我们 userMapper.insert(user) 时,代码里没有给 user.setId(...),数据库自己分配一个递增的 id,MP 再把生成的值回填到 user.getId()。这一点要和 SysSessionSetting 区分开:
// backend/src/main/java/com/yang/backend/entity/SysSessionSetting.java:11
@TableId
private Long id;这里只写了 @TableId 没写 type,用的是全局默认策略。本项目没配 id-type,默认就是 ASSIGN_ID(雪花算法)——而 sys_session_setting 表是 id BIGINT PRIMARY KEY(非自增,见 schema.sql:43),且其数据由 data.sql 固定写 id=1(data.sql:13-15),所以两者正好契合:单条配置记录,id 恒为 1,不走自增。
坑:如果实体写了
IdType.AUTO但表的列不是自增列,插入会报错;反过来表是自增列但实体用了INPUT又没手动 set id,也会报错。两者必须一致。
2.2.3 @TableField 与驼峰↔下划线映射
普通字段默认就按名字映射,但你可以用 @TableField("列名") 显式指定。本项目大量字段靠的是自动驼峰转下划线:
// SysUser.java:17
private String passwordHash; // Java 驼峰# backend/src/main/resources/application.yml:26
mybatis-plus:
configuration:
map-underscore-to-camel-case: true这条配置开启后,MP 把 passwordHash → 列 password_hash,createdAt → created_at,absoluteTimeoutMinutes → absoluteTimeoutMinutes(无下划线则原名)。对照 schema:
-- schema.sql:6,8
password_hash VARCHAR(128) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,如果不开启这个配置,你就得给每个字段加 @TableField("password_hash"),非常繁琐。
扩展了解:
@TableField(exist = false)可标记「这个字段不是数据库列」(比如前端要的临时计算字段),MP 生成 SQL 时会自动忽略它。本项目没用到,但很实用。
2.2.4 @TableLogic:逻辑删除(本项目未使用,需了解)
真正的删除是 DELETE FROM ...,数据永久消失。逻辑删除用一个标记列(如 deleted)把行「藏起来」,查询时自动拼接 WHERE deleted = 0。
@TableLogic
private Integer deleted;本项目没有用逻辑删除(实体里没有 deleted 字段,删除就是物理删),但你必须知道这个概念,因为很多生产系统要求「删了还能恢复」。
坑:若实体有
@TableLogic但表里没有对应列,或全局logic-delete-field配错,会导致所有查询都多一个WHERE deleted=0而归零结果。本项目保持物理删除,简单直接。
2.2.5 中间表实体:没有主键注解
// backend/src/main/java/com/yang/backend/entity/SysUserRole.java:5
@TableName("sys_user_role")
public class SysUserRole {
private Long userId;
private Long roleId;
...
}sys_user_role 是「用户↔角色」中间表,它没有 @TableId——因为它的主键是复合主键 (user_id, role_id)(见 schema.sql:33 PRIMARY KEY (user_id, role_id))。MP 的 BaseMapper 对没有 @TableId 的实体做 updateById/deleteById 会无所适从,所以这类中间表本项目不继承 BaseMapper,而是自己写 SQL(见 2.7)。
2.3 Mapper 接口与 BaseMapper 通用 CRUD
「Mapper」是数据访问层(DAO)的接口。继承 BaseMapper<T> 即可获得一整套单表 CRUD,零 SQL:
// backend/src/main/java/com/yang/backend/mapper/SysUserMapper.java
public interface SysUserMapper extends BaseMapper<SysUser> {
}你只声明接口,MP 在运行时用动态代理帮你实现。常用方法清单:
| 方法 | 作用 | 本项目真实调用位置 |
|---|---|---|
insert(T) | 插入一行,返回受影响行数 | UserService.createUser :102、registerUser :116 |
deleteById(id) | 按主键删除 | UserService.deleteUser :139 |
updateById(T) | 按主键更新(只更新非 null 字段) | UserService.updateUser :131 |
selectById(id) | 按主键查一行 | UserService.requireUser :172 |
selectOne(wrapper) | 按条件查一行 | UserService.findByUsername :81 |
selectList(wrapper) | 按条件查多行 | RoleService.listRoles :38 |
selectPage(page, wrapper) | 分页查询 | UserService.listUsers :62 |
selectCount(wrapper) | 统计行数 | UserService.ensureUsernameAvailable :188 |
selectByIds(ids) | 按多个主键批量查 | UserService.replaceRoles :164、RoleService.replacePermissions :93 |
一个最小调用示例(来自 UserService):
// backend/src/main/java/com/yang/backend/service/UserService.java:102
userMapper.insert(user); // 插入,数据库生成 id 并回填 user.getId()
// UserService.java:172
SysUser user = userMapper.selectById(id); // 主键查询
// UserService.java:139
userMapper.deleteById(user.getId()); // 物理删除关键点:
updateById是「动态 SQL」——只有你 set 了值的字段才会写进UPDATE,其余字段保持数据库原值。所以在updateUser里:124-130,只改了 displayName/email/enabled/updatedAt/passwordHash,username 因为没 set 就不会被改。
2.4 QueryWrapper / LambdaQueryWrapper:条件查询
当查询带条件(如按关键字搜索、按状态过滤)时,用 QueryWrapper 或 LambdaQueryWrapper 拼 WHERE 子句,而不用手写 SQL 字符串。
QueryWrapper:用字符串写列名,如.eq("username", x)——容易拼错列名且重构不友好。LambdaQueryWrapper:用方法引用写字段,如.eq(SysUser::getUsername, x)——编译期就能检查字段名对错,本项目全部用它。
真实例子:用户列表的关键字搜索
// backend/src/main/java/com/yang/backend/service/UserService.java:50
LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<SysUser>()
.orderByDesc(SysUser::getId);
if (StringUtils.hasText(keyword)) {
String like = keyword.trim();
wrapper.and(query -> query
.like(SysUser::getUsername, like)
.or()
.like(SysUser::getDisplayName, like)
.or()
.like(SysUser::getEmail, like));
}拆解:
.orderByDesc(SysUser::getId)→ORDER BY id DESC(最新注册排前面)。.like(字段, 值)→字段 LIKE '%值%',即模糊匹配。.or()→ 把前后条件用OR连接。.and(query -> ...)→ 把括号里的一整组条件用()包起来,避免和别的条件优先级冲突。生成的 SQL 类似:
SELECT * FROM sys_user
WHERE (username LIKE '%张%' OR display_name LIKE '%张%' OR email LIKE '%张%')
ORDER BY id DESC常用条件方法速查:
| 方法 | SQL | 含义 |
|---|---|---|
.eq(字段, 值) | col = val | 等于 |
.ne(字段, 值) | col <> val | 不等于 |
.like(字段, 值) | col LIKE '%val%' | 包含 |
.likeLeft / likeRight | %val / val% | 后缀/前缀匹配 |
.gt / ge / lt / le | > >= < <= | 比较 |
.in(字段, 集合) | col IN (...) | 范围 |
.isNull / isNotNull | col IS NULL | 空值判断 |
.orderByAsc / Desc | ORDER BY col | 排序 |
.and / or | () / OR | 嵌套与或 |
坑:
.like直接拼接用户输入会匹配到任意位置。如果要做「前缀搜索」(如用户名开头匹配)用.likeRight性能更好(能命中索引)。另外LIKE '%关键字%'通常走不了索引,数据量大时要考虑专门的全文检索方案(扩展了解:PostgreSQL 的tsvector/ILIKE+ 索引)。
其它真实调用
// UserService.java:81 按用户名精确查一个(登录用)
userMapper.selectOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, username.trim()));
// UserService.java:188 统计同名用户数(注册前校验唯一性)
Long count = userMapper.selectCount(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, username));
// RoleService.java:38 查全部角色并按 id 升序
roleMapper.selectList(new LambdaQueryWrapper<SysRole>().orderByAsc(SysRole::getId));2.5 分页插件(PaginationInnerInterceptor)
分页不是 Mapper 方法天生具备的能力——selectPage 只是一个普通方法签名,真正把「查全部」变成「查这一页」,靠的是分页拦截器在 SQL 执行前动手脚。
拦截器配置
// backend/src/main/java/com/yang/backend/config/MybatisPlusConfig.java
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(
new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
return interceptor;
}
}几个要点:
@Configuration+@Bean:把这个拦截器交给 Spring 容器管理。MP 启动时扫描容器里有没有MybatisPlusInterceptor类型的 Bean,有就挂到 SQL 执行链上。忘了加这个 @Bean,分页就不会生效(见下方坑)。DbType.POSTGRE_SQL:告诉拦截器「目标数据库是 PostgreSQL」,这样它生成的方言才对——PostgreSQL/MySQL 的分页关键字不同(PG 用LIMIT ? OFFSET ?,不同数据库写法有别)。本项目用 PostgreSQL,所以填这个。MybatisPlusInterceptor像个「拦截器链」,可addInnerInterceptor挂多个内部拦截器(乐观锁、多租户等),分页只是其中之一。
它到底改写了什么 SQL
你写的代码:
// UserService.java:62
Page<SysUser> result = userMapper.selectPage(Page.of(current, pageSize), wrapper);拦截器拦下这条查询后,实际发两条 SQL 给数据库:
-- 1) 改写的「分页数据查询」:在末尾追加 LIMIT / OFFSET
SELECT * FROM sys_user
WHERE (...) ORDER BY id DESC
LIMIT 10 OFFSET 0; -- 第 1 页,每页 10 条
-- 2) 额外发的「总数统计」:自动把 SELECT 列换成 COUNT
SELECT COUNT(*) FROM sys_user
WHERE (...);LIMIT 10= 每页条数(pageSize)OFFSET 0= 跳过前 N 条 =(page - 1) * pageSize- 第二条
COUNT(*)的结果放进result.getTotal(),前端做分页条要用。
拿到结果后本项目转成对外 DTO:
// UserService.java:63
List<UserResponse> records = result.getRecords().stream()
.map(this::toUserResponse).toList();
return new PageResult<>(records, result.getTotal(), result.getCurrent(), result.getSize());PageResult 是给前端的统一分页结构:records(本页数据)、total(总条数)、current(当前页)、size(每页大小)。
坑①:没注册分页拦截器 Bean,或
DbType配错 →selectPage会「假装分页」但实际返回全部行,total也可能不准。这是新手最高频的坑。
坑②:分页查询里如果自己写了LIMIT,会和拦截器追加的LIMIT冲突/叠加,要避免在 wrapper 里手写分页。
扩展了解:Page对象还能设置optimizeCountSql、searchCount(false)(只取数据不查总数,能省一次 COUNT 查询,列表接口首页常用)。
2.6 关联查询(多对多)与注解 SQL
用户↔角色↔权限是典型的多对多关系:
sys_user ──── sys_user_role ──── sys_role ──── sys_role_permission ──── sys_permission
(1 用户) 中间表 (1 角色) 中间表 (1 权限)本项目对这些多表 JOIN 查询,不用 BaseMapper,而是用 MyBatis 的注解 SQL 直接写在 Mapper 接口上:
// backend/src/main/java/com/yang/backend/mapper/SysUserRoleMapper.java:14
@Select("""
SELECT r.*
FROM sys_role r
JOIN sys_user_role ur ON ur.role_id = r.id
WHERE ur.user_id = #{userId}
ORDER BY r.id
""")
List<SysRole> selectRolesByUserId(@Param("userId") Long userId);// backend/src/main/java/com/yang/backend/mapper/SysRolePermissionMapper.java:14
@Select("""
SELECT p.*
FROM sys_permission p
JOIN sys_role_permission rp ON rp.permission_id = p.id
WHERE rp.role_id = #{roleId}
ORDER BY p.id
""")
List<SysPermission> selectPermissionsByRoleId(@Param("roleId") Long roleId);除了 @Select,中间表 Mapper 还用了 @Insert / @Delete:
// SysUserRoleMapper.java:23,26
@Delete("DELETE FROM sys_user_role WHERE user_id = #{userId}")
int deleteByUserId(@Param("userId") Long userId);
@Insert("INSERT INTO sys_user_role (user_id, role_id) VALUES (#{userId}, #{roleId})")
int insert(SysUserRole userRole);关键点解读
- 为什么关联查询放 Mapper 而不是 Service? 关联 SQL 本质是「数据访问细节」,归 Mapper 层管。Service 只关心「我要这个用户的角色列表」,不该拼 JOIN。
#{userId}与@Param:#{...}是 MyBatis 的占位符(预编译,防 SQL 注入)。方法参数必须加@Param("userId")才能用名字引用;否则单个参数时可省略,但多参数一定要加 @Param,否则报错(坑见下)。SELECT r.*+FROM ... JOIN:直接跨三张表取角色,比「先查 user_role 再循环查 role」少发很多 SQL(避免 N+1,见坑)。- 为什么这些 Mapper(SysUserRoleMapper、SysRolePermissionMapper)不继承
BaseMapper?因为它们没有@TableId单主键,且核心操作就是上面那几条自定义 SQL,BaseMapper提供的通用方法反而用不上。
真实调用链(一个用户有哪些权限):
// UserService.java:84
public List<String> listPermissionCodes(Long userId) {
return userRoleMapper.selectRolesByUserId(userId).stream()
.flatMap(role -> rolePermissionMapper.selectPermissionsByRoleId(role.getId()).stream())
.map(SysPermission::getCode)
.distinct().sorted().toList();
}坑:这里其实是「查角色 1 次 + 每个角色查权限 1 次」,若用户有 N 个角色就是 N+1 条 SQL。本项目角色数很少无妨;规模大时应改成一条 JOIN SQL 直接拿权限。
2.7 事务(@Transactional)
「创建用户」不是一步,而是两步:先写 sys_user,再写中间表 sys_user_role。这两步必须要么都成功,要么都失败——否则会出现「用户建了但没有角色」的脏数据。这就要用数据库事务。
ACID 回顾
| 性质 | 含义 |
|---|---|
| Atomic 原子性 | 一组操作要么全做、要么全不做 |
| Consistency 一致性 | 数据从一个合法状态变到另一个合法状态 |
| Isolation 隔离性 | 并发事务互不干扰 |
| Durability 持久性 | 提交后数据永久保存 |
本项目的事务方法
// UserService.java:93
@Transactional
public UserResponse createUser(UserCreateRequest request) {
...
userMapper.insert(user); // ① 写主表
replaceRoles(user.getId(), request.roleIds()); // ② 写中间表
return getUser(user.getId());
}// UserService.java:121
@Transactional
public UserResponse updateUser(Long id, UserUpdateRequest request) {
...
userMapper.updateById(user); // ① 更新主表
replaceRoles(user.getId(), request.roleIds()); // ② 重建中间表关系
return getUser(user.getId());
}// RoleService.java:48 / 60 / 73
@Transactional
public RoleResponse createRole(...) { ... roleMapper.insert(role); replacePermissions(...); }
@Transactional
public RoleResponse updateRole(...) { ... roleMapper.updateById(role); replacePermissions(...); }
@Transactional
public void deleteRole(Long id) { ... roleMapper.deleteById(...); }replaceRoles 里做了「先删后插」:
// UserService.java:158
private void replaceRoles(Long userId, List<Long> roleIds) {
userRoleMapper.deleteByUserId(userId); // 删旧关系
...
distinctRoleIds.forEach(rid -> userRoleMapper.insert(new SysUserRole(userId, rid))); // 插新关系
}如果「插新关系」时抛异常(比如 BadRequestException("存在无效角色 ID"),见 :165-167),整个 createUser/updateRole 方法内已执行的 insert(user)、deleteByUserId 全部回滚,数据库回到调用前的状态。这正是 @Transactional 的价值。
坑①:事务方法必须是被 Spring 管理的 Bean 的方法,且由「外部」调用才生效。同类内部
this.xxx()自调用会绕过代理、事务失效。
坑②:@Transactional默认只在运行时异常(RuntimeException)时回滚;受检异常(IOException 等)默认不回滚,需要rollbackFor = Exception.class。
坑③:事务内不要做远程调用、长耗时 IO——事务不提交连接就一直占着,容易拖垮连接池(见 2.8)。
扩展了解:@Transactional(propagation=...)控制「已有事务时怎么处理」(REQUIRED/REQUIRES_NEW 等),isolation控制隔离级别,生产调优常用。
2.8 数据源与连接池(HikariCP)
application.yml 里的 spring.datasource 配置了 JDBC 连接信息:
# backend/src/main/resources/application.yml:4
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/uas}
username: ${DB_USERNAME:uas}
password: ${DB_PASSWORD:uas123456}
driver-class-name: org.postgresql.Driver${DB_URL:默认值} 是「环境变量优先,没有就用默认值」的写法,方便在不同环境(开发/生产)切换数据库地址而不改代码。
扩展了解:只要引入了
spring-boot-starter-jdbc(或 data-jpa、mybatis 等间接引入),Spring Boot 默认帮你配好 HikariCP——目前 Java 界最快的数据库连接池。连接池的意义:新建数据库连接很慢(TCP + 认证),池子预先维护一批连接,业务线程「借」一个用完「还」回去,避免反复建连。一般不需要自己额外配置,调优时才动maximum-pool-size等参数。
2.9 初始化脚本:schema.sql 与 data.sql
项目每次启动会用这两个脚本初始化库表与基础数据,由 application.yml 控制:
# backend/src/main/resources/application.yml:15
spring:
sql:
init:
mode: always
schema-locations: classpath:db/schema.sql
data-locations: classpath:db/data.sqlmode: always:每次启动都执行(开发方便)。生产通常改never,改用 Flyway/Liquibase 做版本化迁移(扩展了解)。schema-locations:建表 DDL。data-locations:写预置数据 DML。
schema.sql:DDL(表结构)
-- backend/src/main/resources/db/schema.sql:1
CREATE TABLE IF NOT EXISTS sys_user (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
display_name VARCHAR(100) NOT NULL,
email VARCHAR(160) UNIQUE,
password_hash VARCHAR(128) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);要点:
BIGSERIAL PRIMARY KEY:自增主键,对应实体IdType.AUTO。VARCHAR(64) NOT NULL UNIQUE:username非空且唯一——这是「用户名不能重复」的数据库层面兜底(业务层ensureUsernameAvailable是应用层兜底,两层都有才稳)。BOOLEAN NOT NULL DEFAULT TRUE:默认启用。TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP:创建/更新时间默认当前时间(DB 兜底;应用层也会LocalDateTime.now()set 进去)。- 中间表用复合外键 + 级联删除:
-- schema.sql:30
CREATE TABLE IF NOT EXISTS sys_user_role (
user_id BIGINT NOT NULL REFERENCES sys_user(id) ON DELETE CASCADE,
role_id BIGINT NOT NULL REFERENCES sys_role(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);REFERENCES ... ON DELETE CASCADE 的含义:删用户时,它所有角色关系自动跟着删,不用你手动清中间表。同理删角色时,它的权限关系也自动清。
- 索引加速查询:
-- schema.sql:49
CREATE INDEX IF NOT EXISTS idx_sys_user_username ON sys_user(username);
CREATE INDEX IF NOT EXISTS idx_sys_user_role_user_id ON sys_user_role(user_id);
CREATE INDEX IF NOT EXISTS idx_sys_role_permission_role_id ON sys_role_permission(role_id);CREATE INDEX IF NOT EXISTS 让脚本可重复执行;这些索引正好对应「按用户名查」「按用户查角色」「按角色查权限」三类高频查询。
data.sql:幂等初始化数据
难点:脚本每次启动都跑,重复插入会报「主键/唯一键冲突」。本项目用 PostgreSQL 的 ON CONFLICT 解决:
-- backend/src/main/resources/db/data.sql:1
INSERT INTO sys_permission (code, name, description)
VALUES
('user:view', '查看用户', '查看用户列表和详情'),
('user:create', '创建用户', '创建新用户'),
...
ON CONFLICT (code) DO UPDATE
SET name = EXCLUDED.name,
description = EXCLUDED.description;ON CONFLICT (code) DO UPDATE:若code已存在,就改成「更新」而不是报错;EXCLUDED指「本次想插入的那行」。效果是「有则更新、无则插入」(upsert)。- 有些场景只要「有就跳过」:
-- data.sql:13
INSERT INTO sys_session_setting (id, absolute_timeout_minutes, idle_timeout_minutes)
VALUES (1, 480, 30)
ON CONFLICT (id) DO NOTHING;- 关联数据用
SELECT ... JOIN动态取 id(因为自增 id 不固定),并用ON CONFLICT DO NOTHING防重复:
-- data.sql:32
INSERT INTO sys_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM sys_role r
CROSS JOIN sys_permission p
WHERE r.code = 'admin'
ON CONFLICT DO NOTHING;坑:
ON CONFLICT是 PostgreSQL 专属语法,换 MySQL 要改成INSERT ... ON DUPLICATE KEY UPDATE。这也正是为什么分页拦截器要配DbType.POSTGRE_SQL——方言处处相关。
坑:脚本执行顺序有依赖(先插 permission/role,再插 role_permission 引用它们的 id)。本项目 data.sql 顺序已经安排好;自己加数据时务必注意先后。
2.10 整体数据流图
把上面所有部件串起来,一次「创建用户」请求的完整路径:
Controller
│ createUser(request)
▼
UserService @Transactional ──────────────┐ 事务边界
│ │
├─ userMapper.insert(user) ────────────┤→ sys_user (AUTO 自增回填 id)
│ │
├─ replaceRoles() │
│ ├─ userRoleMapper.deleteByUserId()─┤→ sys_user_role (删旧)
│ └─ userRoleMapper.insert(...) ────┤→ sys_user_role (插新)
│ │
└─ getUser() → toUserResponse() │
└─ userRoleMapper.selectRolesByUserId() ─→ @Select JOIN SQL
▼
成功提交 / 异常回滚 ────────────────────────┘2.11 常见坑总结
- 分页没生效,返回全部行:忘了在
MybatisPlusConfig注册MybatisPlusInterceptor的@Bean,或DbType配错。先查配置。 - camelCase ↔ 下划线对不上:确认
map-underscore-to-camel-case: true;字段名passwordHash必须对应列password_hash,拼写不一致会查不到/插入 null。 - 忘了
@Param:多参数注解 SQL(如@Select)里用#{xxx}时,方法参数必须@Param("xxx"),否则启动报Param 'xxx' not found。 - N+1 查询:在循环里对每个对象发一条 SQL(如 per-role 查权限)会爆量 SQL。能用一条 JOIN 就用一条。
- IdType 与表不一致:
AUTO要配自增列,INPUT要自己 set id,否则插入报错。 - 事务自调用失效:同类
this.method()内部调用@Transactional方法不生效,要拆到另一个 Bean 或通过代理调用。 - LIKE '%x%' 不走索引:大数据量关键字搜索要换方案(前缀搜用
likeRight,或全文索引)。 - SQL 注入:永远用
#{}占位符,不要拼接${}用户输入。本项目所有 SQL 都用#{},安全。 - schema/data 顺序与幂等:初始化脚本要
IF NOT EXISTS+ON CONFLICT,且注意表/数据的先后依赖。
2.12 小结与练习
小结
- MyBatis-Plus = MyBatis(SQL 你掌控)+ 通用单表 CRUD + 分页插件,平衡了「少写样板」与「SQL 可控」。
- 实体注解:
@TableName映射表,@TableId(IdType.AUTO)映射自增主键,@TableField/驼峰配置做列映射,@TableLogic(本项目未用)做逻辑删除。 BaseMapper提供insert/deleteById/updateById/selectById/selectOne/selectList/selectPage/selectCount/selectByIds。LambdaQueryWrapper用方法引用拼 WHERE,编译期安全。- 分页靠
PaginationInnerInterceptor改写 SQL 加LIMIT/OFFSET并补一条COUNT(*)。 - 多表关联用
@Select/@Insert/@Delete注解 SQL 写在 Mapper。 @Transactional保证「主表 + 中间表」要么都成功要么都回滚。schema.sql建表(约束/索引/级联),data.sql用ON CONFLICT幂等初始化;mode: always控制启动执行。
练习
- 在
entity/下任选一个类(如SysRole),对照db/schema.sql逐字段确认映射关系,并指出哪几个字段靠「驼峰→下划线」自动对应。 - 在
UserService.listUsers里,若keyword=null时生成的 SQL 是什么?若keyword="a@"又是什么?(提示:看:52的if与.like) - 把
listPermissionCodes(UserService.java:84)改写成一条 JOIN SQL(Hint:跨sys_user_role和sys_role_permission关联sys_permission,用DISTINCT)。思考改写后省了几条 SQL? - 在
RoleService里找一个@Transactional方法,画出「若replacePermissions抛BadRequestException时,已插入的sys_role行会不会残留」的推理过程,验证事务回滚。 - 试着给
sys_user加一个last_login_at字段:改实体类、改schema.sql、看 MP 是否自动识别(无需改 Mapper),再用updateById更新它。 - 思考:
selectRolesByUserId返回List<SysRole>,如果不用@Select注解,还有别的方式写这段 SQL 吗?(可以写在 XML 映射文件SysUserRoleMapper.xml里,用<select>标签,MyBatis 传统做法。)