MyBatis Plus相关问题

总结摘要
MyBatis-Plus相关问题

针对MyBatis-Plus相关问题,我准备了一个“一句话原理 + 一句话源码 + 一句话项目/场景”的结构化回答,体现深度同时展现实战能力。

基础使用

MyBatis-Plus 相比 MyBatis 有哪些增强功能?

核心增强:开箱即用的 CRUD 与通用 Service

一句话原理:MyBatis-Plus 通过提供通用 MapperBaseMapper)和通用 ServiceIService),让开发者仅需继承接口即可获得数十种单表 CRUD 方法,无需编写任何 SQL 或 XML,开发效率提升 60% 以上。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 传统 MyBatis:需要手动编写 Mapper 接口和 XML
public interface UserMapper {
    User selectById(Long id);        // 需要在 XML 写 SQL
    int insert(User user);            // 需要在 XML 写 SQL
    int updateById(User user);        // 需要在 XML 写 SQL
    int deleteById(Long id);          // 需要在 XML 写 SQL
}

// MyBatis-Plus:仅需继承 BaseMapper,无需任何 SQL
public interface UserMapper extends BaseMapper<User> {
    // 自动拥有以下方法(无需编写):
    // userMapper.selectById(1L)
    // userMapper.insert(user)
    // userMapper.updateById(user)
    // userMapper.deleteById(1L)
    // userMapper.selectList(null)
    // userMapper.selectBatchIds(Arrays.asList(1,2,3))
    // 等等 20+ 个基础 CRUD 方法
}

// 通用 Service 层增强(官方推荐)
public interface UserService extends IService<User> {
    // 继承 IService 后自动拥有 save、saveBatch、update、list 等方法
}

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
    // 无需额外实现,直接使用 baseMapper 和父类方法
}

// 使用示例:批量插入
List<User> userList = new ArrayList<>();
// 填充数据...
userService.saveBatch(userList); // 自动分批插入避免 SQL 过长

项目场景:在若依框架开发的养老管理系统中,90% 的业务操作都是单表 CRUD。通过继承 BaseMapperIService,开发一个用户管理模块只需定义实体类,无需编写任何 Mapper XML,开发时间从 2 小时缩短到 20 分钟,团队协作效率大幅提升。

条件构造器:类型安全的动态查询

一句话原理:MyBatis-Plus 提供条件构造器Wrapper)和 Lambda 表达式支持,以链式编程方式构建动态查询条件,不仅避免了字符串硬编码,还能在编译期检查字段有效性,彻底告别 SQL 拼接的烦恼。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 传统 MyBatis:动态 SQL 需要在 XML 中使用 <if> 标签
// <select id="selectByCondition" resultType="User">
//   SELECT * FROM user WHERE 1=1
//   <if test="name != null"> AND name = #{name}</if>
//   <if test="age != null"> AND age > #{age}</if>
// </select>

// MyBatis-Plus:使用 QueryWrapper 链式调用
// 方式1:普通 Wrapper(字符串字段名,有写错风险)
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("name", "张三")
       .gt("age", 18)
       .like("email", "@qq.com")
       .orderByDesc("create_time");
List<User> users = userMapper.selectList(wrapper);

// 方式2:Lambda 表达式(推荐,类型安全)
LambdaQueryWrapper<User> lambdaWrapper = new LambdaQueryWrapper<>();
lambdaWrapper.eq(User::getName, "张三")      // 编译期检查字段存在性
             .gt(User::getAge, 18)
             .like(User::getEmail, "@qq.com")
             .orderByDesc(User::getCreateTime);
List<User> users = userMapper.selectList(lambdaWrapper);

// 复杂条件示例:动态组合
LambdaQueryWrapper<User> dynamicWrapper = new LambdaQueryWrapper<>();
dynamicWrapper.eq(StringUtils.isNotBlank(name), User::getName, name)  // 只有name不为空才加入条件
              .ge(age != null, User::getAge, age)
              .in(CollectionUtils.isNotEmpty(roleIds), User::getRoleId, roleIds);
              
// 嵌套查询示例
wrapper.and(w -> w.eq("status", 1).or().eq("status", 2))
       .nested(w -> w.like("name", "张").like("remark", "VIP"));

项目场景:在用户管理后台的高级查询功能中,用户可能输入多个筛选条件(姓名、年龄范围、注册时间、会员等级等),且条件动态组合。使用 Lambda 条件构造器,代码清晰易读,且完全避免 SQL 注入风险,维护性远超传统 XML 拼接。

内置插件生态:分页、乐观锁、逻辑删除等

一句话原理:MyBatis-Plus 内置了分页插件(物理分页)、乐观锁插件(版本号控制)、逻辑删除(标记删除)、性能分析插件(慢 SQL 监控)等生产级插件,通过简单配置即可开箱即用,解决企业开发中的通用痛点。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// ============ 1. 分页插件配置 ============
@Configuration
public class MybatisPlusConfig {
    
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 分页插件(支持多种数据库)
        PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
        paginationInterceptor.setMaxLimit(1000L); // 防止单页过大
        paginationInterceptor.setOverflow(true);  // 溢出后自动跳到首页
        interceptor.addInnerInterceptor(paginationInterceptor);
        
        // 乐观锁插件
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        
        // 防止全表更新/删除插件
        interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
        
        return interceptor;
    }
}

// 分页查询使用示例
@GetMapping("/page")
public IPage<User> pageUsers(@RequestParam(defaultValue = "1") Integer current,
                              @RequestParam(defaultValue = "10") Integer size) {
    // 创建分页对象
    Page<User> page = new Page<>(current, size);
    
    // 条件构造器
    LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
    wrapper.orderByDesc(User::getCreateTime);
    
    // 执行分页查询(自动生成 count 查询和 limit 语句)
    return userMapper.selectPage(page, wrapper);
}

// ============ 2. 乐观锁支持 ============
@Entity
public class Product {
    @Version  // 乐观锁注解
    private Integer version;
    
    private String name;
    private BigDecimal price;
}

// 更新时会自动带上版本号校验:UPDATE product SET name=?, price=?, version=version+1 WHERE id=? AND version=?
// 如果版本号不匹配,更新失败(避免并发覆盖)

// ============ 3. 逻辑删除 ============
@Entity
@TableName("user")
public class User {
    @TableLogic  // 逻辑删除注解
    private Integer deleted;  // 0-未删除,1-已删除
}

// 之后的所有 deleteById 都会自动变为 UPDATE user SET deleted=1 WHERE id=?
// 所有 select 都会自动加上 AND deleted=0

// ============ 4. 自动填充(创建时间/更新时间) ============
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
    
    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, "createTime", Date.class, new Date());
        this.strictInsertFill(metaObject, "updateTime", Date.class, new Date());
    }
    
    @Override
    public void updateFill(MetaObject metaObject) {
        this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date());
    }
}

项目场景:在电商系统的商品管理中,分页查询是标配功能,MyBatis-Plus 的分页插件自动生成物理分页 SQL,无需手动编写 LIMIT 语句;库存扣减时通过乐观锁插件防止超卖;用户删除采用逻辑删除,保留历史数据便于恢复;创建时间和更新时间通过自动填充统一维护,代码简洁且规范。

代码生成器:从数据库到 Controller 一键生成

一句话原理:MyBatis-Plus 内置代码生成器AutoGenerator),根据数据库表结构自动生成 Entity、Mapper、Service、Controller 以及前端页面代码,支持自定义模板和策略配置,快速搭建项目基础架构。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 代码生成器示例
public class CodeGenerator {
    
    public static void main(String[] args) {
        // 数据源配置
        DataSourceConfig dataSourceConfig = new DataSourceConfig.Builder(
            "jdbc:mysql://localhost:3306/my_db", "root", "password")
            .build();
        
        // 全局配置
        GlobalConfig globalConfig = new GlobalConfig.Builder()
            .outputDir(System.getProperty("user.dir") + "/src/main/java")
            .author("技术团队")
            .dateType(DateType.ONLY_DATE)
            .commentDate("yyyy-MM-dd")
            .disableOpenDir()  // 生成后不打开输出目录
            .build();
        
        // 包配置
        PackageConfig packageConfig = new PackageConfig.Builder()
            .parent("com.example.project")
            .moduleName("system")
            .entity("entity")
            .mapper("mapper")
            .service("service")
            .serviceImpl("service.impl")
            .controller("controller")
            .build();
        
        // 策略配置
        StrategyConfig strategyConfig = new StrategyConfig.Builder()
            .addInclude("user", "role", "permission")  // 需要生成的表
            .addTablePrefix("t_", "sys_")  // 表前缀过滤
            .entityBuilder()
            .enableLombok()                 // 启用 Lombok
            .enableChainModel()              // 链式模型
            .enableTableFieldAnnotation()    // 启用字段注解
            .controllerBuilder()
            .enableRestStyle()               // RestController 风格
            .enableHyphenStyle()              // 路径使用连字符
            .mapperBuilder()
            .enableBaseResultMap()            // 生成通用的 resultMap
            .enableBaseColumnList()           // 生成通用的 columnList
            .build();
        
        // 执行生成
        AutoGenerator generator = new AutoGenerator(dataSourceConfig);
        generator.global(globalConfig)
                .packageInfo(packageConfig)
                .strategy(strategyConfig)
                .execute();
        
        System.out.println("代码生成完成!");
    }
}

项目场景:在新建微服务项目时,通常有 20+ 张业务表需要编写基础代码。通过代码生成器,一键生成所有 Entity、Mapper、Service、Controller,并自动配置好 Swagger 注解、分页支持、乐观锁等,原本需要 3 天的工作量缩短到 10 分钟,且保证了代码风格的统一性。

完整实战指南

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
 * MyBatis-Plus 增强功能总结表
 */
public class MybatisPlusEnhancements {
    
    // ============ 增强功能对比表 ============
    // 功能                MyBatis                       MyBatis-Plus
    // 基础CRUD            需要手动写SQL/XML              BaseMapper 自动提供
    // 条件查询             XML动态SQL拼接                 Wrapper 链式调用
    // 分页                手动写LIMIT                    分页插件物理分页
    // 乐观锁              手动编写version检查              @Version 注解
    // 逻辑删除            手动写UPDATE                    @TableLogic 注解
    // 自动填充            手动set时间                     MetaObjectHandler
    // 代码生成            无                              代码生成器一键生成
    // 性能监控            无                              性能分析插件
    // 防止误操作          无                              全表删除阻断插件
    
    // ============ 使用建议 ============
    // 1. 单表基础操作:BaseMapper + IService
    // 2. 动态查询:LambdaQueryWrapper(类型安全)
    // 3. 多表关联:回退到自定义 SQL(@Select 注解或 XML)
    // 4. 批量操作:saveBatch、updateBatch
    // 5. 通用字段:自动填充(创建时间、更新时间)
    // 6. 并发控制:乐观锁(@Version)
    // 7. 数据安全:逻辑删除(@TableLogic)
    
    // ============ 注意事项 ============
    // 1. 复杂多表关联查询不建议强求用 Wrapper,应写自定义 SQL
    // 2. 注意逻辑删除和唯一索引的冲突问题
    // 3. 分页插件需要正确配置数据库方言
    // 4. 乐观锁更新失败需要业务层重试
}

/**
 * 增强功能总结表
 * 
 * 增强维度          具体功能                         开发效率提升
 * CRUD简化          BaseMapper/IService             减少60%代码量
 * 条件构造器        Wrapper + Lambda                 类型安全,消除SQL拼接
 * 插件生态          分页/乐观锁/逻辑删除              企业级功能开箱即用
 * 代码生成器        AutoGenerator                     项目搭建提速90%
 * 注解支持          @TableField/@Version              配置化替代硬编码
 * 
 * 适用场景:
 * - 管理后台(90%单表操作)
 * - 快速原型开发
 * - 标准化CRUD业务
 * - 需要统一规范的团队
 * 
 * 慎用场景:
 * - 复杂报表查询(多表关联)
 * - 大数据量批量操作
 * - 已有深度定制MyBatis的遗留系统
 */

// 面试金句
// "MyBatis-Plus 对 MyBatis 的增强就像'从手动挡汽车升级到自动驾驶':
//  BaseMapper 是'自动巡航'(常用CRUD不用写SQL),
//  条件构造器是'智能导航'(动态条件链式调用,不会走错路),
//  分页插件是'自动泊车'(一句代码完成分页),
//  代码生成器是'一键启动'(表结构直接生成全套代码)。
//  在养老项目中,我们用 MyBatis-Plus 后,开发效率提升明显,
//  基础CRUD代码几乎为零,团队能更专注于核心业务逻辑的实现。
//  但遇到复杂多表查询时,我们仍会回归原生 MyBatis 编写 SQL,
//  这就是'增强而非改变'的设计精髓"

如何使用 MyBatis-Plus 实现分页查询?

核心原理:物理分页拦截器

一句话原理:MyBatis-Plus 通过分页插件PaginationInnerInterceptor)实现物理分页,在执行查询前自动拦截 SQL,根据数据库方言(MySQL/Oracle等)动态拼接 LIMIT 语句,并自动生成 COUNT 查询获取总记录数,对业务代码完全透明。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
 * 分页插件核心源码解析
 */
public class PaginationInnerInterceptor implements InnerInterceptor {
    
    // 拦截查询方法,自动添加分页参数
    @Override
    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, 
                            RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
        // 1. 从参数中获取分页对象
        IPage<?> page = ParameterUtils.findPage(parameter).orElse(null);
        if (page == null) return;
        
        // 2. 获取原始SQL
        String originalSql = boundSql.getSql();
        
        // 3. 根据数据库方言生成COUNT查询SQL
        if (page.isSearchCount()) {
            String countSql = sqlParser.optimizeCountSql(originalSql);
            // 执行COUNT查询获取总记录数
            queryCount(executor, ms, parameter, boundSql, countSql, page);
        }
        
        // 4. 生成分页SQL(如 MySQL的 LIMIT)
        String pageSql = dialect.buildPaginationSql(originalSql, page.offset(), page.getSize());
        
        // 5. 替换原始SQL为分页SQL
        ReflectUtil.setFieldValue(boundSql, "sql", pageSql);
    }
}

// 配置分页插件
@Configuration
public class MybatisPlusConfig {
    
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 添加分页插件(指定数据库类型)
        PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
        paginationInterceptor.setMaxLimit(500L); // 单页最大限制,防止恶意请求
        paginationInterceptor.setOverflow(true); // 溢出后跳转到第一页
        paginationInterceptor.setOptimizeJoin(true); // 优化COUNT JOIN查询
        
        interceptor.addInnerInterceptor(paginationInterceptor);
        return interceptor;
    }
}

项目场景:在电商后台的用户管理列表中,需要展示百万级用户数据。通过分页插件,每页只查询20条数据,避免了全表查询的性能问题。同时,自动生成的COUNT查询能快速计算总页数,实现分页组件的渲染。

基础分页查询实现

一句话原理:使用 Page 对象封装分页参数(当前页、每页大小),调用 selectPage 方法即可获得包含当前页数据总记录数总页数等完整分页信息的 IPage 对象,支持链式条件和排序。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
/**
 * 基础分页查询示例
 */
@Service
public class UserService {
    
    @Autowired
    private UserMapper userMapper;
    
    // 1. 最简单的分页查询
    public IPage<User> getUserPage(int current, int size) {
        // 创建分页对象
        Page<User> page = new Page<>(current, size);
        
        // 执行分页查询(无条件)
        return userMapper.selectPage(page, null);
    }
    
    // 2. 带条件的分页查询
    public IPage<User> getUserPageWithCondition(int current, int size, String name, Integer status) {
        // 创建分页对象
        Page<User> page = new Page<>(current, size);
        
        // 创建条件构造器
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        wrapper.like(StringUtils.isNotBlank(name), User::getName, name)
               .eq(status != null, User::getStatus, status)
               .orderByDesc(User::getCreateTime);
        
        // 执行分页查询(自动拼接条件和分页SQL)
        return userMapper.selectPage(page, wrapper);
    }
    
    // 3. 自定义分页查询(多表关联)
    public IPage<UserVO> getUserVoPage(Page<UserVO> page, String deptName) {
        // 在XML中编写自定义SQL
        return userMapper.selectUserVoPage(page, deptName);
    }
}

// Controller层使用
@RestController
@RequestMapping("/users")
public class UserController {
    
    @Autowired
    private UserService userService;
    
    @GetMapping("/page")
    public Result<IPage<User>> pageUsers(
            @RequestParam(defaultValue = "1") Integer current,
            @RequestParam(defaultValue = "10") Integer size,
            @RequestParam(required = false) String name,
            @RequestParam(required = false) Integer status) {
        
        IPage<User> page = userService.getUserPageWithCondition(current, size, name, status);
        
        // 返回分页数据
        return Result.success(page);
    }
}

// 返回的JSON结构
{
    "records": [...],       // 当前页数据
    "total": 1000,          // 总记录数
    "size": 10,             // 每页大小
    "current": 1,           // 当前页码
    "pages": 100,           // 总页数
    "hasPrevious": false,   // 是否有上一页
    "hasNext": true         // 是否有下一页
}

项目场景:在若依框架开发的权限管理系统中,角色列表、用户列表、日志查询等30多个分页接口全部采用这种方式实现,代码高度统一,维护成本极低。通过统一的返回格式,前端分页组件可以直接渲染,无需额外处理。

高级分页特性实战

一句话原理:MyBatis-Plus 分页插件还支持多表关联分页动态排序分组查询自定义COUNT等高级特性,并能通过 Page 对象的 optimizeCountSql 属性优化COUNT查询性能。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
/**
 * 高级分页特性实战
 */
@Service
public class AdvancedPageService {
    
    // ============ 1. 多表关联分页 ============
    public IPage<UserRoleVO> getUserRolePage(int current, int size, String roleName) {
        Page<UserRoleVO> page = new Page<>(current, size);
        
        // 在XML中编写关联查询
        // <select id="selectUserRolePage" resultType="UserRoleVO">
        //   SELECT u.*, r.name as roleName 
        //   FROM user u 
        //   LEFT JOIN user_role ur ON u.id = ur.user_id
        //   LEFT JOIN role r ON ur.role_id = r.id
        //   WHERE r.name LIKE CONCAT('%', #{roleName}, '%')
        //   ORDER BY u.create_time DESC
        // </select>
        return userMapper.selectUserRolePage(page, roleName);
    }
    
    // ============ 2. 动态排序 ============
    public IPage<User> getPageWithSort(int current, int size, String sortField, boolean isAsc) {
        Page<User> page = new Page<>(current, size);
        
        // 方式1:使用Page对象设置排序
        page.addOrder(new OrderItem().setColumn(sortField).setAsc(isAsc));
        
        // 方式2:使用条件构造器排序
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        if (isAsc) {
            wrapper.orderByAsc(User::getCreateTime);
        } else {
            wrapper.orderByDesc(User::getCreateTime);
        }
        
        return userMapper.selectPage(page, wrapper);
    }
    
    // ============ 3. 优化COUNT查询 ============
    public IPage<User> getPageOptimizedCount(int current, int size) {
        // 关闭自动COUNT(适用于已知总数不需要查询的场景)
        Page<User> page = new Page<>(current, size, false); // 第三个参数表示是否查询总数
        
        // 或者自定义COUNT查询
        page.setOptimizeCountSql(false); // 关闭自动优化
        // 手动执行COUNT查询
        Long total = userMapper.selectCount(null);
        page.setTotal(total);
        
        return userMapper.selectPage(page, null);
    }
    
    // ============ 4. 分组分页查询 ============
    public IPage<Map<String, Object>> getGroupByPage(int current, int size) {
        Page<Map<String, Object>> page = new Page<>(current, size);
        
        // 使用条件构造器分组
        QueryWrapper<User> wrapper = new QueryWrapper<>();
        wrapper.select("dept_id, count(*) as user_count")
               .groupBy("dept_id")
               .having("count(*) > 5");
        
        return userMapper.selectMapsPage(page, wrapper);
    }
    
    // ============ 5. 并行分页(多个分页同时执行) ============
    @Transactional
    public Map<String, Object> multiPageQuery(int pageNo, int pageSize) {
        // 使用CompletableFuture并行执行两个分页查询
        CompletableFuture<IPage<User>> userPageFuture = CompletableFuture.supplyAsync(() -> 
            userMapper.selectPage(new Page<>(pageNo, pageSize), null));
            
        CompletableFuture<IPage<Role>> rolePageFuture = CompletableFuture.supplyAsync(() -> 
            roleMapper.selectPage(new Page<>(pageNo, pageSize), null));
        
        // 等待两个查询完成
        Map<String, Object> result = new HashMap<>();
        result.put("users", userPageFuture.join());
        result.put("roles", rolePageFuture.join());
        return result;
    }
}

项目场景:在报表系统中,需要分页展示各部门的员工统计数,使用分组分页查询直接返回统计结果;在仪表盘页面,需要同时分页展示用户列表和角色列表,使用并行分页将响应时间从200ms降低到120ms。

前端集成最佳实践

一句话原理:MyBatis-Plus 的分页返回格式(IPage)天然适配主流前端框架(Vue/React)的分页组件,通过统一的 recordstotalcurrentpages 字段,实现前后端无缝对接。

一句话源码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
/**
 * 前端集成最佳实践
 */
@RestController
@RequestMapping("/api/users")
public class UserApiController {
    
    @Autowired
    private UserService userService;
    
    // ============ 1. 标准分页接口 ============
    @GetMapping("/page")
    public Result<IPage<User>> page(
            @RequestParam(defaultValue = "1") Integer current,
            @RequestParam(defaultValue = "10") Integer size,
            @RequestParam(required = false) String keyword) {
        
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        wrapper.like(StringUtils.isNotBlank(keyword), User::getName, keyword)
               .or()
               .like(StringUtils.isNotBlank(keyword), User::getEmail, keyword);
        
        Page<User> page = new Page<>(current, size);
        return Result.success(userMapper.selectPage(page, wrapper));
    }
    
    // ============ 2. 前端Vue使用示例 ============
    /*
    // 前端Vue代码
    <template>
      <el-table :data="tableData.records">
        <!-- 表格列 -->
      </el-table>
      <el-pagination
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
        :current-page="tableData.current"
        :page-sizes="[10, 20, 50, 100]"
        :page-size="tableData.size"
        :total="tableData.total"
        layout="total, sizes, prev, pager, next, jumper">
      </el-pagination>
    </template>
    
    <script>
    export default {
      data() {
        return {
          tableData: {
            records: [],
            total: 0,
            size: 10,
            current: 1
          }
        }
      },
      methods: {
        async fetchData() {
          const { data } = await axios.get('/api/users/page', {
            params: {
              current: this.tableData.current,
              size: this.tableData.size,
              keyword: this.keyword
            }
          })
          this.tableData = data.data
        }
      }
    }
    </script>
    */
    
    // ============ 3. 统一返回格式封装 ============
    @Getter
    @Setter
    public class PageResult<T> {
        private List<T> list;      // 数据列表
        private long total;         // 总记录数
        private long pageNum;       // 当前页码
        private long pageSize;      // 每页大小
        private long pages;         // 总页数
        
        public static <T> PageResult<T> from(IPage<T> page) {
            PageResult<T> result = new PageResult<>();
            result.setList(page.getRecords());
            result.setTotal(page.getTotal());
            result.setPageNum(page.getCurrent());
            result.setPageSize(page.getSize());
            result.setPages(page.getPages());
            return result;
        }
    }
    
    @GetMapping("/page/v2")
    public Result<PageResult<User>> pageV2(
            @RequestParam(defaultValue = "1") Integer current,
            @RequestParam(defaultValue = "10") Integer size) {
        
        Page<User> page = new Page<>(current, size);
        IPage<User> userPage = userMapper.selectPage(page, null);
        return Result.success(PageResult.from(userPage));
    }
}

项目场景:在大型后台管理系统中,统一了分页接口的返回格式,前端封装了全局分页组件,传入API地址和查询参数即可自动处理分页逻辑。50+个分页页面使用了同一套代码,维护成本极低,新功能开发只需关注表格列的配置。

完整实战指南

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
/**
 * MyBatis-Plus 分页查询完整指南
 */
public class PageQueryCompleteGuide {
    
    /**
     * 1. 配置建议
     */
    public class ConfigSuggestions {
        // application.yml 配置
        // mybatis-plus:
        //   global-config:
        //     db-config:
        //       logic-delete-field: deleted   # 逻辑删除字段
        //   configuration:
        //     log-impl: org.apache.ibatis.logging.stdout.StdOutImpl  # 开发环境开启SQL日志
        //   mapper-locations: classpath*:/mapper/**/*.xml
    }
    
    /**
     * 2. 分页参数校验
     */
    public class PageParamValidation {
        
        @GetMapping("/page/safe")
        public Result<IPage<User>> safePage(
                @RequestParam(defaultValue = "1") Integer current,
                @RequestParam(defaultValue = "10") Integer size) {
            
            // 参数安全校验
            if (current < 1) current = 1;
            if (size < 1) size = 10;
            if (size > 500) size = 500; // 防止超大分页
            
            Page<User> page = new Page<>(current, size);
            return Result.success(userMapper.selectPage(page, null));
        }
    }
    
    /**
     * 3. 性能优化建议
     */
    public class PerformanceOptimization {
        
        // 1. 避免COUNT性能问题
        // - 对于大表,可以维护统计数据表
        // - 使用缓存存储总记录数
        
        // 2. 超大分页优化
        // - 使用游标分页(基于ID)替代偏移分页
        // 示例:SELECT * FROM user WHERE id > #{lastId} LIMIT #{size}
        
        // 3. 索引优化
        // - ORDER BY字段建立索引
        // - WHERE条件字段建立组合索引
        
        // 4. 使用select优化
        // LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        // wrapper.select(User::getId, User::getName); // 只查询必要字段
    }
    
    /**
     * 4. 常见问题解决
     */
    public class CommonProblems {
        
        // Q: 分页查询结果总数不正确?
        // A: 检查COUNT查询是否被优化,可设置page.setOptimizeCountSql(false)
        
        // Q: 多表关联分页结果重复?
        // A: 使用DISTINCT或在子查询中分页
        
        // Q: 分页插件不生效?
        // A: 检查插件配置是否正确,是否在MybatisPlusInterceptor中注册
        
        // Q: 前端传参类型转换错误?
        // A: 统一使用String接收再转换,或前端保证传参类型正确
    }
}

/**
 * 分页查询总结表
 * 
 * 组件                   作用                          使用场景
 * Page<T>                分页参数封装                   所有分页查询
 * PaginationInnerInterceptor 物理分页拦截器              必备配置
 * LambdaQueryWrapper<T>  条件构造器                     动态条件查询
 * IPage<T>               分页结果封装                   返回给前端
 * 
 * 执行流程:
 * 创建Page → 设置条件 → 调用selectPage → 拦截器拦截 → 执行COUNT → 生成分页SQL → 返回IPage
 * 
 * 最佳实践:
 * 1. 配置最大分页限制,防止恶意请求
 * 2. 使用Lambda条件构造器,类型安全
 * 3. 统一分页返回格式,前后端约定
 * 4. 对COUNT查询进行优化或自定义
 */

// 面试金句
// "MyBatis-Plus分页查询就像'智能图书馆管理员':
//  你只需要告诉他要'第几排的书'(current)和'几本书'(size),
//  他会自动去书架找书(分页SQL),同时告诉你总共有多少本书(total)。
//  在后台管理系统中,我们配置了分页插件后,所有列表页都变成了'自动寻书',
//  开发人员只需关注业务条件和字段映射,分页逻辑完全交给框架处理。
//  这就是为什么我们的开发效率能提升50%以上同时还能保证SQL性能"

什么是 ActiveRecord 模式?MyBatis-Plus 支持吗?

ActiveRecord模式核心原理

一句话原理:ActiveRecord是一种对象关系映射(ORM)模式,核心思想是一个对象既包含数据又包含行为——每个对象实例对应数据库表中的一行,对象本身直接提供save()update()delete()等持久化方法,将数据访问逻辑与领域模型合二为一。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
 * ActiveRecord模式与传统DAO模式对比
 */
// 传统DAO模式:数据与操作分离
public class User {  // 纯粹的POJO,只有属性
    private Long id;
    private String name;
    private Integer age;
    // getters/setters
}

public interface UserDao {  // DAO层负责操作
    int insert(User user);
    int updateById(User user);
    User selectById(Long id);
    int deleteById(Long id);
}

// 使用方式:需要两个组件协作
User user = new User();
user.setName("张三");
UserDao userDao = SpringContext.getBean(UserDao.class);
userDao.insert(user);  // 通过DAO操作

// ============ ActiveRecord模式:数据与操作合一 ============
// 实体类继承ActiveRecord基类,自身具备CRUD能力
public class User extends Model<User> {  // 继承Model类
    private Long id;
    private String name;
    private Integer age;
    
    // getters/setters(自动映射表字段)
    
    // 直接使用对象自身的方法进行操作
    public void doSomething() {
        // 业务逻辑
    }
}

// 使用方式:直接操作对象
User user = new User();
user.setName("张三");
user.insert();        // 对象自己插入自己 ✅

User found = new User().selectById(1L);  // 静态方式查询

User updateUser = new User();
updateUser.setId(1L);
updateUser.setName("李四");
updateUser.updateById();  // 对象自己更新自己

User deleteUser = new User();
deleteUser.setId(1L);
deleteUser.deleteById();  // 对象自己删除自己

项目场景:在Ruby on Rails框架中,ActiveRecord模式被发挥到极致。例如一个博客系统中,Post对象可以直接调用post.save保存到数据库,User.find(1)直接返回用户对象,开发效率极高。这种"对象即数据行"的直观模型,特别适合快速原型开发和CRUD密集型应用。

ActiveRecord vs 传统模式对比

一句话原理:ActiveRecord模式将数据实体数据访问对象(DAO)合二为一,而传统模式遵循单一职责原则,将两者分离;前者开发效率高但耦合度大,后者职责清晰但代码量多。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
 * 两种模式深度对比
 */
public class PatternComparison {
    
    // ============ 方案A:传统DAO模式(Data Mapper) ============
    // 优点:职责分离,业务层只依赖DAO接口,易于测试和替换
    // 缺点:需要维护Entity、Mapper接口、XML文件三套代码
    @Data
    public class UserEntity {
        private Long id;
        private String name;
    }
    
    @Mapper
    public interface UserMapper {
        @Insert("INSERT INTO user(name) VALUES(#{name})")
        int insert(UserEntity user);
    }
    
    @Service
    public class UserService {
        @Autowired
        private UserMapper userMapper;
        
        @Transactional
        public void register(UserEntity user) {
            userMapper.insert(user);
        }
    }
    
    // ============ 方案B:ActiveRecord模式 ============
    // 优点:极简开发,一个类搞定所有,特别适合快速迭代
    // 缺点:实体类与持久化逻辑耦合,单元测试需要真实数据库
    
    @Data
    @EqualsAndHashCode(callSuper = true)
    public class User extends Model<User> {  // 单继承
        private Long id;
        private String name;
    }
    
    // 直接在Controller中使用(甚至不需要Service层)
    @RestController
    public class UserController {
        
        @PostMapping("/users")
        public Result createUser(@RequestBody User user) {
            user.insert();  // 无需Service,直接操作
            return Result.success(user);
        }
        
        @GetMapping("/users/{id}")
        public User getUser(@PathVariable Long id) {
            return new User().selectById(id);
        }
    }
    
    // ============ 对比总结 ============
    // 传统模式:User + UserMapper + UserService + UserController
    // ActiveRecord:User + UserController(减少2-3个类)
}

项目场景:在开发一个简单的问卷调查系统时,有10个实体类需要CRUD。使用传统模式需要编写10个Entity、10个Mapper、10个Service,共30个类;使用ActiveRecord模式只需10个实体类,代码量减少三分之二,一周的工作量一天完成。但到了复杂的订单系统,由于事务和业务逻辑复杂,又回归到传统模式。

MyBatis-Plus对ActiveRecord的支持

一句话原理:MyBatis-Plus完全支持ActiveRecord模式,实体类只需继承Model<T>类,即可获得insert()update()delete()selectById()等CRUD方法,同时底层依然复用BaseMapper的能力,实现"实体即操作入口"的简洁体验。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/**
 * MyBatis-Plus ActiveRecord实现源码解析
 */
// ============ 1. 实体类继承Model ============
@Data
@EqualsAndHashCode(callSuper = true)  // 必须继承Model
@TableName("user")  // 表名映射
public class User extends Model<User> {  // 泛型为自身类型
    @TableId(type = IdType.AUTO)
    private Long id;
    private String name;
    private Integer age;
    private String email;
    
    // 可以重写pkVal()方法指定主键(可选)
    @Override
    protected Serializable pkVal() {
        return this.id;
    }
}

// ============ 2. 使用示例 ============
@SpringBootTest
public class ActiveRecordTest {
    
    @Test
    public void testActiveRecord() {
        // 1. 插入操作
        User user = new User();
        user.setName("张三");
        user.setAge(25);
        user.setEmail("zhangsan@example.com");
        user.insert();  // 插入成功后,id会自动回填
        
        // 2. 查询操作
        User found = new User().selectById(user.getId());
        assertEquals("张三", found.getName());
        
        // 3. 更新操作
        found.setAge(26);
        found.updateById();  // 根据主键更新
        
        // 4. 条件查询(结合条件构造器)
        List<User> userList = found.selectList(
            new QueryWrapper<User>().lambda()
                .eq(User::getAge, 26)
        );
        
        // 5. 删除操作
        found.deleteById();
    }
}

// ============ 3. Model类源码简析 ============
public abstract class Model<T extends Model<?>> {
    
    // 底层调用BaseMapper的方法
    public boolean insert() {
        SqlSession sqlSession = sqlSession();
        try {
            // 获取当前实体对应的BaseMapper
            BaseMapper<T> mapper = sqlSession.getMapper(BaseMapper.class);
            return mapper.insert((T) this) > 0;
        } finally {
            sqlSession.close();
        }
    }
    
    public T selectById(Serializable id) {
        // 内部调用BaseMapper.selectById()
    }
    
    public boolean updateById() {
        // 内部调用BaseMapper.updateById()
    }
    
    public boolean deleteById() {
        // 内部调用BaseMapper.deleteById()
    }
    
    // 还有其他方法:selectList、selectCount、insertOrUpdate等
}

// ============ 4. 进阶:多数据源支持 ============
// 通过MapperProvider支持多数据源场景
public class User extends BaseEntity implements ActiveRecord<User, Integer> {
    // 多数据源时指定使用哪个MapperProvider
    @Override
    public Mapper<User, Integer> baseMapper() {
        return MapperProvider.<User, Integer, Mapper<User, Integer>>getInstance("userMapperProvider")
                .baseMapper(entityClass());
    }
}

项目场景:在若依框架开发的轻量级管理系统中,对于字典、部门、岗位等基础数据表,采用ActiveRecord模式简化代码。例如DictData类继承Model后,在Controller中直接使用dictData.insert()保存字典项,省去了Service层和Mapper的重复代码。对于核心业务表(如订单、支付),则继续使用传统Service+Mapper模式保证事务边界清晰。

完整实战指南

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
/**
 * MyBatis-Plus ActiveRecord完整实战指南
 */
public class ActiveRecordGuide {
    
    /**
     * 1. 配置要求
     */
    @Configuration
    public class ActiveRecordConfig {
        
        @Bean
        public MybatisPlusInterceptor mybatisPlusInterceptor() {
            MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
            interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
            return interceptor;
        }
        
        // 无需额外配置,引入mybatis-plus-boot-starter后自动支持ActiveRecord
    }
    
    /**
     * 2. 实体类最佳实践
     */
    @Data
    @EqualsAndHashCode(callSuper = true)
    @TableName("sys_role")
    public class Role extends Model<Role> {
        
        @TableId(type = IdType.AUTO)
        private Long id;
        
        private String roleName;
        
        private String roleCode;
        
        @TableField(fill = FieldFill.INSERT)
        private Date createTime;
        
        // 指定主键字段(优化反射性能)
        @Override
        protected Serializable pkVal() {
            return this.id;
        }
        
        // 可以添加业务方法
        public boolean isAdmin() {
            return "admin".equals(this.roleCode);
        }
    }
    
    /**
     * 3. 使用场景建议
     */
    public class UsageScenarios {
        
        // ✅ 适合使用ActiveRecord的场景:
        // - 简单单表CRUD(字典、配置、日志)
        // - 快速原型开发
        // - 微服务中的基础数据服务
        // - Controller层直接处理简单操作
        
        // ❌ 不适合使用ActiveRecord的场景:
        // - 复杂事务处理(需要Service层协调)
        // - 多表关联复杂查询
        // - 业务逻辑复杂的领域模型
        // - 需要单元测试mock的场景
    }
    
    /**
     * 4. 注意事项
     */
    public class Precautions {
        
        // 1. 实体类必须继承Model<T>,且T为自身类型
        // 2. 必须重写pkVal()或使用@TableId注解指定主键
        // 3. ActiveRecord方法默认开启新事务(非事务性)
        // 4. 需要事务时仍需使用@Service + @Transactional
        // 5. 单元测试需要真实数据库,无法轻松mock
        
        // 示例:事务仍然需要Service层
        @Service
        public class RoleService {
            
            @Transactional
            public void batchInsert(List<Role> roles) {
                roles.forEach(Role::insert); // 每个insert独立事务?这里需要看具体配置
                // 推荐:通过Service批量插入
                roleMapper.insertBatch(roles);
            }
        }
    }
    
    /**
     * 5. 面试必备
     */
    public class InterviewQA {
        
        // Q: ActiveRecord模式和传统DAO模式哪个好?
        // A: 没有绝对好坏,取决于场景。简单CRUD用AR,复杂业务用DAO
        
        // Q: MyBatis-Plus的ActiveRecord如何实现?
        // A: 通过Model类封装BaseMapper调用,每个实体对象持有Mapper引用
        
        // Q: ActiveRecord会影响性能吗?
        // A: 几乎无影响,最终调用的是和BaseMapper相同的SQL
        
        // Q: 为什么Ruby on Rails如此推崇ActiveRecord?
        // A: 符合"约定优于配置"哲学,开发效率极高
    }
}

/**
 * 总结表
 * 
 * 模式           核心思想                      优点               缺点
 * 传统DAO        数据与操作分离                职责清晰、易测试    代码量大
 * ActiveRecord   数据与操作合一                开发效率高、直观    耦合度高
 * 
 * MyBatis-Plus支持:✅ 通过继承Model<T>实现
 * 
 * 最佳实践:
 * - 基础数据表:ActiveRecord
 * - 核心业务表:传统Service+Mapper
 * - 快速原型:ActiveRecord
 * - 复杂系统:混合使用
 */

// 面试金句
// "ActiveRecord模式就像'全能选手'——每个运动员(实体对象)自己就会游泳、跑步、跳高(增删改查),
//  而传统DAO模式就像'分工协作'——运动员只管比赛,教练(Service)负责战术,队医(DAO)负责治疗。
//  在快速开发管理中,我用ActiveRecord让字典、配置等基础数据类'自己管理自己',
//  代码量减少了60%;但在订单系统中,我仍然坚持传统DAO模式,让事务边界更清晰。
//  MyBatis-Plus最聪明的地方在于,它允许我们在同一个项目中自由选择两种模式,
//  既享受了ActiveRecord的便捷又不失传统模式的严谨"

如何使用 MyBatis-Plus 的代码生成器?

原理与优化

MyBatis-Plus 的 SQL 注入器是如何工作的?

SQL注入器核心原理

一句话原理:MyBatis-Plus的SQL注入器是一个运行时SQL生成引擎,在MyBatis初始化阶段扫描实体类信息,根据预定义模板动态生成CRUD SQL语句并注入到Mapper接口中,实现"零SQL编写"的自动化能力。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
 * SQL注入器核心工作原理
 */
// 1. 注入器接口定义
public interface ISqlInjector {
    // 注入方法到Mapper接口
    void inspectInject(MapperBuilderAssistant builderAssistant, Class<?> mapperClass);
}

// 2. 默认实现:DefaultSqlInjector
public class DefaultSqlInjector extends AbstractSqlInjector {
    
    @Override
    public List<AbstractMethod> getMethodList(Class<?> mapperClass) {
        // 获取所有需要注入的SQL方法
        List<AbstractMethod> methodList = new ArrayList<>();
        
        // 添加基础CRUD方法
        methodList.add(new Insert());               // insert
        methodList.add(new Delete());               // delete
        methodList.add(new Update());               // update
        methodList.add(new SelectById());           // selectById
        methodList.add(new SelectList());           // selectList
        methodList.add(new SelectCount());          // selectCount
        methodList.add(new SelectMaps());           // selectMaps
        methodList.add(new SelectObjs());           // selectObjs
        
        // 添加批量操作
        methodList.add(new InsertBatch());          // insertBatch
        
        // 添加逻辑删除相关
        methodList.add(new LogicDelete());
        methodList.add(new LogicSelectById());
        
        return methodList;
    }
}

// 3. 单个SQL方法定义(以Insert为例)
public class Insert extends AbstractMethod {
    
    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        // 动态生成SQL语句
        String sql = String.format(
            "<script>INSERT INTO %s %s VALUES %s</script>",
            tableInfo.getTableName(),
            tableInfo.getInsertSqlColumns(),  // 自动生成列名
            tableInfo.getInsertSqlProperty()   // 自动生成参数占位符
        );
        
        // 创建MappedStatement并注入到MyBatis配置中
        return addInsertMappedStatement(mapperClass, modelClass, 
            "insert", sql, method -> method);
    }
}

项目场景:在快速开发平台中,当定义一个新的实体类User并继承BaseMapper后,SQL注入器自动在启动时生成insertselectById等20+个SQL语句并注入到UserMapper中。开发人员无需编写任何SQL,即可获得完整的CRUD能力,这就是为什么项目启动后Mapper接口突然"拥有"了那么多方法的原因。

SQL注入器的工作流程

一句话原理:SQL注入器在Spring容器启动时,遍历所有继承BaseMapper的接口,通过反射获取实体类信息(表名、字段、注解),使用模板方法模式动态生成SQL并注册到MyBatis的MappedStatement集合中,整个过程对开发者完全透明。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/**
 * SQL注入器完整工作流程源码解析
 */
// 1. MyBatis-Plus启动入口:MybatisPlusAutoConfiguration
@Configuration
public class MybatisPlusAutoConfiguration {
    
    @Bean
    @ConditionalOnMissingBean
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
        MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean();
        factory.setDataSource(dataSource);
        
        // 关键步骤:设置SQL注入器
        factory.setSqlInjector(new DefaultSqlInjector());
        
        return factory.getObject();
    }
}

// 2. MybatisSqlSessionFactoryBean初始化
public class MybatisSqlSessionFactoryBean {
    
    private ISqlInjector sqlInjector;
    
    protected SqlSessionFactory buildSqlSessionFactory() throws IOException {
        Configuration configuration = getConfiguration();
        
        // 遍历所有Mapper接口
        for (Class<?> mapperClass : mapperInterfaces) {
            if (ClassUtils.isInterface(mapperClass)) {
                // 注册Mapper接口
                configuration.addMapper(mapperClass);
                
                // 关键:调用SQL注入器注入方法
                if (sqlInjector != null) {
                    sqlInjector.inspectInject(
                        configuration.getMapperRegistry().getMapperBuilderAssistant(),
                        mapperClass
                    );
                }
            }
        }
        return sqlSessionFactory;
    }
}

// 3. 抽象SQL注入器的模板方法
public abstract class AbstractSqlInjector implements ISqlInjector {
    
    @Override
    public void inspectInject(MapperBuilderAssistant builderAssistant, Class<?> mapperClass) {
        // 获取当前Mapper对应的实体类
        Class<?> modelClass = extractModelClass(mapperClass);
        
        // 解析实体类信息(表名、字段、注解)
        TableInfo tableInfo = TableInfoHelper.initTableInfo(builderAssistant, modelClass);
        
        // 获取需要注入的所有方法
        List<AbstractMethod> methodList = getMethodList(mapperClass);
        
        // 遍历生成并注入SQL语句
        for (AbstractMethod method : methodList) {
            method.inject(builderAssistant, mapperClass, modelClass, tableInfo);
        }
    }
    
    protected abstract List<AbstractMethod> getMethodList(Class<?> mapperClass);
}

// 4. 单个方法的注入过程(以SelectById为例)
public class SelectById extends AbstractMethod {
    
    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        // SQL模板:SELECT 字段列表 FROM 表 WHERE 主键=#{主键}
        String sql = String.format(
            "<script>SELECT %s FROM %s WHERE %s=#{%s}</script>",
            tableInfo.getSelectColumns(),
            tableInfo.getTableName(),
            tableInfo.getKeyColumn(),
            tableInfo.getKeyProperty()
        );
        
        // 创建MappedStatement并返回
        return this.addSelectMappedStatementForTable(mapperClass, sql, modelClass, tableInfo);
    }
}

// 5. 最终MyBatis配置中的效果
// 通过debug可以看到configuration对象中的mappedStatements
// key: "com.example.mapper.UserMapper.selectById"
// value: MappedStatement对象包含动态生成的SQL

项目场景:在排查问题时,通过设置mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl,可以在控制台看到SQL注入器生成的SQL语句。例如当调用userMapper.selectById(1L)时,输出的SQL正是注入器在启动时生成并注册的,而非运行时动态解析,这就是为什么MyBatis-Plus性能与传统MyBatis几乎没有差异的原因。

自定义SQL注入器实战

一句话原理:通过继承DefaultSqlInjector并重写getMethodList方法,可以添加自定义SQL方法替换现有实现,实现如insertOrUpdateBatchdeleteAll等通用方法的自动注入,让团队内所有Mapper统一获得扩展能力。

一句话源码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
/**
 * 自定义SQL注入器完整实战
 */
// ============ 1. 定义自定义方法 ============
public class InsertOrUpdateBatch extends AbstractMethod {
    
    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        // 生成批量插入或更新的SQL语句
        String sql = String.format(
            "<script>INSERT INTO %s %s VALUES %s ON DUPLICATE KEY UPDATE %s</script>",
            tableInfo.getTableName(),
            tableInfo.getInsertSqlColumns(),
            buildBatchInsertSql(tableInfo),
            buildUpdateOnDuplicateSql(tableInfo)
        );
        
        return this.addInsertMappedStatement(mapperClass, modelClass, 
            "insertOrUpdateBatch", sql, method -> method);
    }
    
    private String buildBatchInsertSql(TableInfo tableInfo) {
        // 生成: <foreach collection="list" item="item" separator=",">
        //        (#{item.property1}, #{item.property2})
        //       </foreach>
        return "<foreach collection='list' item='item' separator=','>(" +
            tableInfo.getInsertSqlPropertyBatch("item") + ")</foreach>";
    }
    
    private String buildUpdateOnDuplicateSql(TableInfo tableInfo) {
        // 生成ON DUPLICATE KEY UPDATE语句
        List<String> updates = new ArrayList<>();
        for (TableFieldInfo field : tableInfo.getFieldList()) {
            updates.add(field.getColumn() + "=VALUES(" + field.getColumn() + ")");
        }
        return String.join(",", updates);
    }
}

// ============ 2. 自定义注入器 ============
public class CustomSqlInjector extends DefaultSqlInjector {
    
    @Override
    public List<AbstractMethod> getMethodList(Class<?> mapperClass) {
        // 先获取默认的所有方法
        List<AbstractMethod> methodList = super.getMethodList(mapperClass);
        
        // 添加自定义方法
        methodList.add(new InsertOrUpdateBatch());  // 批量插入或更新
        
        // 添加物理删除所有(绕过逻辑删除)
        methodList.add(new DeleteAll());
        
        // 添加随机查询一条记录
        methodList.add(new SelectRandomOne());
        
        // 添加根据ID列表更新指定字段
        methodList.add(new UpdateBatchByIds());
        
        return methodList;
    }
}

// ============ 3. 自定义删除所有方法 ============
public class DeleteAll extends AbstractMethod {
    
    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        String sql = String.format("DELETE FROM %s", tableInfo.getTableName());
        return this.addDeleteMappedStatement(mapperClass, sql);
    }
}

// ============ 4. 配置自定义注入器 ============
@Configuration
public class MybatisPlusConfig {
    
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
    
    @Bean
    public ISqlInjector customSqlInjector() {
        return new CustomSqlInjector(); // 使用自定义注入器
    }
}

// ============ 5. 使用自定义方法 ============
@Mapper
public interface UserMapper extends BaseMapper<User> {
    // 无需定义任何方法,注入器会自动注入:
    // - insertOrUpdateBatch(List<User> list)
    // - deleteAll()
    // - selectRandomOne()
    // - updateBatchByIds(List<User> list)
}

// 使用示例
@Service
public class UserService {
    
    @Autowired
    private UserMapper userMapper;
    
    public void testCustomMethods() {
        // 使用批量插入或更新
        List<User> users = new ArrayList<>();
        users.add(new User().setName("张三").setAge(25));
        users.add(new User().setName("李四").setAge(26));
        userMapper.insertOrUpdateBatch(users); // 自动生成的SQL
        
        // 随机查询一条记录
        User random = userMapper.selectRandomOne();
        
        // 删除所有(物理删除)
        userMapper.deleteAll();
    }
}

项目场景:在数据迁移项目中,需要批量插入或更新百万级数据。通过自定义insertOrUpdateBatch方法,配合MySQL的ON DUPLICATE KEY UPDATE,实现了一条SQL批量处理,性能提升10倍。同时将该方法注入到所有Mapper中,团队内其他成员直接使用,无需重复开发。

完整实战指南

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
/**
 * SQL注入器完整实战指南
 */
public class SqlInjectorGuide {
    
    /**
     * 1. 预定义方法列表
     */
    public class PredefinedMethods {
        // 插入类:insert、insertBatch
        // 删除类:deleteById、deleteByMap、delete、deleteBatchIds、deleteAll
        // 更新类:updateById、update
        // 查询类:selectById、selectBatchIds、selectByMap、selectOne、selectCount
        //        selectList、selectMaps、selectObjs、selectPage、selectMapsPage
        // 逻辑删除类:logicDeleteById、logicDeleteByMap、logicDelete
    }
    
    /**
     * 2. 调试技巧
     */
    public class DebugTips {
        
        // 1. 查看注入的SQL语句
        // application.yml
        // mybatis-plus:
        //   configuration:
        //     log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
        
        // 2. 断点位置
        // - AbstractSqlInjector.inspectInject() 查看注入过程
        // - AbstractMethod.inject() 查看单个方法注入
        // - Configuration.mappedStatements 查看所有已注入的SQL
        
        // 3. 查看所有注入的方法名
        public void printAllMethods(ApplicationContext context) {
            SqlSessionFactory sqlSessionFactory = context.getBean(SqlSessionFactory.class);
            Configuration configuration = sqlSessionFactory.getConfiguration();
            
            Collection<String> mappedStatementNames = configuration.getMappedStatementNames();
            mappedStatementNames.stream()
                .filter(name -> name.contains("UserMapper"))
                .forEach(System.out::println);
        }
    }
    
    /**
     * 3. 注意事项
     */
    public class Precautions {
        
        // 1. 方法名冲突:自定义方法不要与预定义方法重名
        // 2. 事务管理:注入器生成的方法默认不开启事务,需在Service层加@Transactional
        // 3. 性能考虑:启动时一次性注入,不影响运行时性能
        // 4. 兼容性:自定义SQL要考虑数据库方言
    }
    
    /**
     * 4. 面试必备
     */
    public class InterviewQA {
        
        // Q: SQL注入器什么时候执行?
        // A: Spring容器启动时,MyBatis初始化阶段
        
        // Q: 注入器生成的SQL会变化吗?
        // A: 不会,一次性生成并缓存,运行时直接使用
        
        // Q: 如何覆盖默认方法?
        // A: 自定义Injector,在getMethodList中替换对应类
        
        // Q: 注入器的原理和MyBatis的MapperScanner有什么区别?
        // A: MapperScanner负责注册Mapper接口,注入器负责向Mapper添加方法
    }
}

/**
 * SQL注入器总结表
 * 
 * 组件                作用                             关键类
 * ISqlInjector       注入器接口                        定义注入行为
 * AbstractSqlInjector 抽象模板                         遍历Mapper生成方法
 * DefaultSqlInjector  默认实现                         提供基础CRUD方法
 * AbstractMethod      单个SQL方法模板                  生成具体SQL
 * TableInfo           表信息封装                        实体类元数据
 * 
 * 执行流程:
 * 扫描Mapper → 解析实体类 → 获取方法列表 → 生成SQL → 注入到Configuration
 * 
 * 扩展方式:
 * 继承AbstractMethod → 继承DefaultSqlInjector → 配置到MybatisPlus
 */

// 面试金句
// "SQL注入器就像'预制菜中央厨房':
//  在餐厅开门营业前(应用启动),中央厨房就根据菜单(实体类)把每道菜(SQL语句)预处理好,
//  客人点菜时(方法调用),直接端出预制菜(执行预生成SQL)。
//  我们在项目中扩展了中央厨房的'菜谱',增加了'批量自助餐'(insertOrUpdateBatch)
//  和'清理餐桌'(deleteAll)等特色菜,所有餐厅分店(所有Mapper)都能享用。
//  正是这个机制让MyBatis-Plus既保持了运行时性能又实现了'零SQL'的开发体验"

如何自定义一个全局通用方法?

核心原理:继承SQL注入器 + 自定义AbstractMethod

一句话原理:通过继承AbstractMethod创建自定义SQL方法,再继承DefaultSqlInjector重写getMethodList将该方法添加到注入列表中,最后在MyBatis-Plus配置中启用自定义注入器,即可实现全局通用方法的自动注入,所有Mapper自动获得该方法。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/**
 * 自定义全局方法核心原理
 */
// ============ 1. 定义自定义方法类 ============
public class InsertOrUpdateBatch extends AbstractMethod {
    
    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        // SQL模板:使用ON DUPLICATE KEY UPDATE实现批量插入或更新
        String sql = String.format(
            "<script>INSERT INTO %s %s VALUES %s ON DUPLICATE KEY UPDATE %s</script>",
            tableInfo.getTableName(),              // 表名
            tableInfo.getInsertSqlColumns(),       // 字段名列表
            buildBatchValuesSql(tableInfo),        // 批量值部分
            buildUpdateSql(tableInfo)               // 更新部分
        );
        
        // 创建MappedStatement并注入
        return this.addInsertMappedStatement(mapperClass, modelClass, 
            "insertOrUpdateBatch", sql, method -> method);
    }
    
    // 生成批量值部分:<foreach> (#{item.name}, #{item.age}) </foreach>
    private String buildBatchValuesSql(TableInfo tableInfo) {
        return "<foreach collection='list' item='item' separator=','>(" +
            tableInfo.getInsertSqlPropertyBatch("item") + ")</foreach>";
    }
    
    // 生成更新部分:name=VALUES(name), age=VALUES(age)
    private String buildUpdateSql(TableInfo tableInfo) {
        List<String> updates = new ArrayList<>();
        for (TableFieldInfo field : tableInfo.getFieldList()) {
            updates.add(field.getColumn() + "=VALUES(" + field.getColumn() + ")");
        }
        return String.join(",", updates);
    }
}

// ============ 2. 定义自定义注入器 ============
public class CustomSqlInjector extends DefaultSqlInjector {
    
    @Override
    public List<AbstractMethod> getMethodList(Class<?> mapperClass) {
        // 获取默认的所有方法
        List<AbstractMethod> methodList = super.getMethodList(mapperClass);
        
        // 添加自定义方法
        methodList.add(new InsertOrUpdateBatch());   // 批量插入或更新
        methodList.add(new DeleteAll());              // 物理删除所有
        methodList.add(new SelectRandomOne());        // 随机查询一条
        
        return methodList;
    }
}

// ============ 3. 配置自定义注入器 ============
@Configuration
public class MybatisPlusConfig {
    
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
    
    @Bean
    public ISqlInjector customSqlInjector() {
        return new CustomSqlInjector();  // 使用自定义注入器
    }
}

项目场景:在数据迁移项目中,需要将Excel中的10万条数据导入数据库,要求"有则更新,无则插入"。通过自定义insertOrUpdateBatch方法,所有Mapper立即获得批量插入更新能力,一条SQL处理1000条数据,性能提升20倍,且无需在每个Mapper中重复编写SQL。

完整实现:批量物理删除方法

一句话原理:通过自定义DeleteAll方法,绕过逻辑删除直接执行物理删除,适用于日志清理、测试数据清除等场景,该方法会注入到所有Mapper中,实现全局统一的删除所有功能。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
/**
 * 自定义批量物理删除方法完整实现
 */
// ============ 1. 定义自定义方法 ============
public class DeleteAll extends AbstractMethod {
    
    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        // 生成SQL:DELETE FROM 表名
        String sql = String.format("DELETE FROM %s", tableInfo.getTableName());
        
        // 创建MappedStatement(返回类型为int)
        return this.addDeleteMappedStatement(mapperClass, sql);
    }
}

// ============ 2. 定义自定义方法 ============
public class DeleteByIds extends AbstractMethod {
    
    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        // 生成SQL:DELETE FROM 表名 WHERE id IN 
        String sql = String.format(
            "<script>DELETE FROM %s WHERE %s IN (%s)</script>",
            tableInfo.getTableName(),
            tableInfo.getKeyColumn(),
            "#{ids, typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler}"
        );
        
        return this.addDeleteMappedStatement(mapperClass, sql);
    }
}

// ============ 3. 自定义注入器 ============
public class CustomSqlInjector extends DefaultSqlInjector {
    
    @Override
    public List<AbstractMethod> getMethodList(Class<?> mapperClass) {
        List<AbstractMethod> methodList = super.getMethodList(mapperClass);
        
        // 添加物理删除相关方法
        methodList.add(new DeleteAll());      // 删除所有
        methodList.add(new DeleteByIds());    // 根据ID列表批量删除
        
        // 注意:如果实体类配置了逻辑删除,这些方法会绕过逻辑删除
        // 因此建议只在特定场景使用
        
        return methodList;
    }
}

// ============ 4. 使用示例 ============
@Mapper
public interface LogMapper extends BaseMapper<Log> {
    // DeleteAll 和 DeleteByIds 方法自动注入
}

@Service
public class LogCleanupService {
    
    @Autowired
    private LogMapper logMapper;
    
    @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点
    @Transactional
    public void cleanupExpiredLogs() {
        // 删除30天前的日志
        LocalDateTime expireTime = LocalDateTime.now().minusDays(30);
        QueryWrapper<Log> wrapper = new QueryWrapper<>();
        wrapper.lt("create_time", expireTime);
        
        // 使用自定义方法批量删除
        logMapper.deleteByIds(getExpiredLogIds(expireTime));
        
        // 或者直接删除所有(危险操作,慎用)
        // logMapper.deleteAll();
    }
    
    private List<Long> getExpiredLogIds(LocalDateTime expireTime) {
        // 查询过期日志ID
        return logMapper.selectList(new QueryWrapper<Log>()
            .select("id")
            .lt("create_time", expireTime))
            .stream()
            .map(Log::getId)
            .collect(Collectors.toList());
    }
}

项目场景:在日志清理系统中,每天需要删除大量过期日志。如果每条日志单独删除,性能极差;如果使用逻辑删除,会导致表数据无限膨胀。通过自定义deleteByIds方法,一次SQL删除1000条日志,配合定时任务,既保证了性能又实现了物理清理。

高级扩展:自定义随机查询方法

一句话原理:通过自定义SelectRandomOne方法,生成随机排序查询SQL,适用于"随机推荐"、“抽奖"等业务场景,该方法可注入到需要随机查询的Mapper中,统一实现随机获取一条记录的功能。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
/**
 * 自定义随机查询方法
 */
// ============ 1. 定义随机查询方法 ============
public class SelectRandomOne extends AbstractMethod {
    
    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        // 根据数据库类型生成随机排序SQL
        String sql;
        DbType dbType = getDbType();
        
        switch (dbType) {
            case MYSQL:
                sql = String.format("SELECT %s FROM %s ORDER BY RAND() LIMIT 1", 
                    tableInfo.getSelectColumns(), tableInfo.getTableName());
                break;
            case ORACLE:
                sql = String.format("SELECT %s FROM %s ORDER BY DBMS_RANDOM.VALUE FETCH FIRST 1 ROWS ONLY", 
                    tableInfo.getSelectColumns(), tableInfo.getTableName());
                break;
            case POSTGRE_SQL:
                sql = String.format("SELECT %s FROM %s ORDER BY RANDOM() LIMIT 1", 
                    tableInfo.getSelectColumns(), tableInfo.getTableName());
                break;
            default:
                throw new UnsupportedOperationException("不支持的数据库类型: " + dbType);
        }
        
        return this.addSelectMappedStatementForTable(mapperClass, sql, modelClass, tableInfo);
    }
    
    private DbType getDbType() {
        // 获取当前数据库类型(可以从配置或环境变量获取)
        return DbType.MYSQL;
    }
}

// ============ 2. 自定义注入器 ============
public class CustomSqlInjector extends DefaultSqlInjector {
    
    @Override
    public List<AbstractMethod> getMethodList(Class<?> mapperClass) {
        List<AbstractMethod> methodList = super.getMethodList(mapperClass);
        
        // 为实体类添加随机查询方法
        methodList.add(new SelectRandomOne());
        
        // 添加根据权重随机查询
        methodList.add(new SelectRandomByWeight());
        
        return methodList;
    }
}

// ============ 3. 根据权重随机查询 ============
public class SelectRandomByWeight extends AbstractMethod {
    
    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        // 假设实体类有权重字段weight,根据权重比例随机选择
        String sql = String.format(
            "<script>SELECT %s FROM %s ORDER BY weight * RAND() DESC LIMIT 1</script>",
            tableInfo.getSelectColumns(), tableInfo.getTableName()
        );
        
        return this.addSelectMappedStatementForTable(mapperClass, sql, modelClass, tableInfo);
    }
}

// ============ 4. 使用示例 ============
@Mapper
public interface ProductMapper extends BaseMapper<Product> {
    // 自动获得 selectRandomOne() 方法
}

@Service
public class RecommendService {
    
    @Autowired
    private ProductMapper productMapper;
    
    @Autowired
    private BannerMapper bannerMapper;
    
    public Product recommendProduct() {
        // 随机推荐一个商品
        return productMapper.selectRandomOne();
    }
    
    public Banner randomBanner() {
        // 随机获取一个横幅广告
        return bannerMapper.selectRandomOne();
    }
}

项目场景:在电商APP首页的"猜你喜欢"板块,需要随机展示商品。通过自定义selectRandomOne方法,所有需要随机查询的Mapper都自动获得该能力,无需在每个Mapper中重复编写SQL。同时根据权重随机查询实现了广告位的权重分配,代码复用率100%。

完整实战指南

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
/**
 * 自定义全局方法完整指南
 */
public class CustomMethodGuide {
    
    /**
     * 1. 方法定义模板
     */
    public abstract class CustomMethodTemplate extends AbstractMethod {
        
        // 需要实现的方法
        @Override
        public abstract MappedStatement injectMappedStatement(
            Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo);
        
        // 常用工具方法
        protected String getTableName(TableInfo tableInfo) {
            return tableInfo.getTableName();
        }
        
        protected String getKeyColumn(TableInfo tableInfo) {
            return tableInfo.getKeyColumn();
        }
        
        protected String getSelectColumns(TableInfo tableInfo) {
            return tableInfo.getSelectColumns();
        }
        
        protected boolean hasLogicDelete(TableInfo tableInfo) {
            return tableInfo.isWithLogicDelete();
        }
    }
    
    /**
     * 2. 注入器扩展点
     */
    public class InjectorExtensionPoints {
        
        // 可以通过条件注解控制哪些Mapper使用哪些方法
        @Target(ElementType.TYPE)
        @Retention(RetentionPolicy.RUNTIME)
        public @interface CustomMethods {
            boolean enableDeleteAll() default false;
            boolean enableRandomSelect() default false;
        }
        
        public class ConditionalSqlInjector extends DefaultSqlInjector {
            
            @Override
            public List<AbstractMethod> getMethodList(Class<?> mapperClass) {
                List<AbstractMethod> methodList = super.getMethodList(mapperClass);
                
                // 根据注解条件添加方法
                if (mapperClass.isAnnotationPresent(CustomMethods.class)) {
                    CustomMethods annotation = mapperClass.getAnnotation(CustomMethods.class);
                    
                    if (annotation.enableDeleteAll()) {
                        methodList.add(new DeleteAll());
                    }
                    if (annotation.enableRandomSelect()) {
                        methodList.add(new SelectRandomOne());
                    }
                }
                return methodList;
            }
        }
    }
    
    /**
     * 3. 注意事项
     */
    public class Precautions {
        
        // 1. 方法名不要与现有方法冲突
        // 2. 考虑数据库兼容性(不同数据库SQL方言)
        // 3. 注意逻辑删除的影响(自定义方法应明确是否绕过逻辑删除)
        // 4. 性能测试:自定义方法同样享受MyBatis的一级/二级缓存
        
        // 示例:带逻辑删除判断的随机查询
        public class SelectRandomOneWithLogic extends AbstractMethod {
            
            @Override
            public MappedStatement injectMappedStatement(
                    Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
                
                String sql;
                if (tableInfo.isWithLogicDelete()) {
                    // 自动加上逻辑删除条件
                    sql = String.format(
                        "SELECT %s FROM %s WHERE %s=0 ORDER BY RAND() LIMIT 1",
                        tableInfo.getSelectColumns(), tableInfo.getTableName(),
                        tableInfo.getLogicDeleteFieldInfo().getColumn()
                    );
                } else {
                    sql = String.format(
                        "SELECT %s FROM %s ORDER BY RAND() LIMIT 1",
                        tableInfo.getSelectColumns(), tableInfo.getTableName()
                    );
                }
                
                return this.addSelectMappedStatementForTable(
                    mapperClass, sql, modelClass, tableInfo);
            }
        }
    }
    
    /**
     * 4. 面试必备
     */
    public class InterviewQA {
        
        // Q: 自定义方法能覆盖默认方法吗?
        // A: 可以,在getMethodList中替换同名方法类即可
        
        // Q: 如何让不同Mapper拥有不同的自定义方法?
        // A: 通过注解或标记接口进行条件判断
        
        // Q: 自定义方法的性能如何?
        // A: 和手写SQL完全一致,因为启动时已编译
        
        // Q: 支持多数据源吗?
        // A: 可以,在方法中获取当前数据源方言
    }
}

/**
 * 自定义方法总结表
 * 
 * 步骤         操作                        关键代码
 * 1           继承AbstractMethod           重写injectMappedStatement
 * 2           继承DefaultSqlInjector       重写getMethodList添加方法
 * 3           配置注入器                     @Bean ISqlInjector
 * 4           使用                          Mapper直接调用
 * 
 * 应用场景:
 * - 批量操作(批量插入/更新/删除)
 * - 特定查询(随机查询、树形查询)
 * - 物理删除(绕过逻辑删除)
 * - 数据库方言适配
 */

// 面试金句
// "自定义全局方法就像'给所有Mapper发统一装备':
//  首先设计'装备图纸'(继承AbstractMethod定义SQL),
//  然后建立'军工厂'(自定义SqlInjector)批量生产,
//  最后给所有'士兵'(Mapper)装备上(注入配置)。
//  在日志系统中,我们给所有Mapper配发了'定时炸弹'(DeleteAll方法),
//  用于每周自动清理过期日志;在推荐系统中,配发了'随机抽奖器'(SelectRandomOne),
//  实现商品随机推荐一个方法全局受益这就是注入器的魅力"

你遇到过 MyBatis-Plus 的哪些性能问题?如何优化?

N+1查询问题:懒加载带来的性能灾难

一句话原理:在关联查询中,如果使用MyBatis-Plus的懒加载特性或循环中调用selectById,会产生N+1次查询(1次主查询 + N次子查询),随着数据量增长,性能呈线性恶化。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/**
 * N+1查询问题演示与优化
 */
// ============ 问题代码:循环中单条查询 ============
@Service
public class OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    @Autowired
    private UserMapper userMapper;
    
    public List<OrderVO> getOrdersWithUser() {
        // 1. 查询所有订单(1次查询)
        List<Order> orders = orderMapper.selectList(null);
        
        // 2. 循环查询每个订单的用户信息(N次查询)
        List<OrderVO> result = new ArrayList<>();
        for (Order order : orders) {  // 假设有100个订单
            User user = userMapper.selectById(order.getUserId()); // 100次查询!
            result.add(new OrderVO(order, user));
        }
        return result;
    }
}

// ============ 问题代码:懒加载导致N+1 ============
public class Order {
    private Long userId;
    
    @TableField(exist = false)
    private User user;
    
    public User getUser() {
        if (user == null) {
            // 懒加载:每次访问都会触发查询
            user = userMapper.selectById(this.userId);
        }
        return user;
    }
}

// 遍历时会触发N+1
orders.forEach(order -> System.out.println(order.getUser().getName()));

// ============ 优化方案1:使用JOIN查询 ============
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
    
    // 自定义JOIN查询,一次性获取所有数据
    @Select("SELECT o.*, u.name as userName, u.email as userEmail " +
            "FROM order o LEFT JOIN user u ON o.user_id = u.id")
    List<OrderVO> selectOrdersWithUser();
}

// ============ 优化方案2:使用IN查询批量获取 ============
public List<OrderVO> getOrdersWithUserOptimized() {
    // 1. 查询所有订单(1次)
    List<Order> orders = orderMapper.selectList(null);
    
    // 2. 收集所有用户ID
    Set<Long> userIds = orders.stream()
        .map(Order::getUserId)
        .collect(Collectors.toSet());
    
    // 3. 批量查询用户(1次)
    List<User> users = userMapper.selectBatchIds(userIds);
    Map<Long, User> userMap = users.stream()
        .collect(Collectors.toMap(User::getId, Function.identity()));
    
    // 4. 内存组装(0次额外查询)
    return orders.stream()
        .map(order -> new OrderVO(order, userMap.get(order.getUserId())))
        .collect(Collectors.toList());
    
    // 优化效果:从101次查询 -> 2次查询
}

// ============ 优化方案3:使用@MapKey注解 ============
public interface UserMapper extends BaseMapper<User> {
    
    @MapKey("id")  // 以ID为key返回Map
    Map<Long, User> selectUserMap(@Param("ids") Collection<Long> ids);
}

项目场景:在订单报表导出功能中,需要导出1000条订单及其用户信息。原始代码使用循环查询,产生了1001次数据库查询,导致页面超时。优化后改为一次订单查询 + 一次用户批量查询,响应时间从15秒降低到0.5秒,用户体验大幅提升。

分页大字段查询:SELECT * 的性能陷阱

一句话原理:MyBatis-Plus默认的selectPage会查询所有字段,当表中有大字段(如TEXT/BLOB)时,即使分页也会传输大量无用数据,导致网络IO和内存占用过高,影响查询性能。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/**
 * 大字段查询问题演示与优化
 */
@Entity
@TableName("article")
public class Article {
    private Long id;
    private String title;
    private String summary;
    
    @TableField("content")
    private String content;  // 大字段,存储文章内容(可能几万字)
    
    private Date createTime;
}

// ============ 问题代码:默认分页查询 ============
@Service
public class ArticleService {
    
    public IPage<Article> getArticlePage(int current, int size) {
        Page<Article> page = new Page<>(current, size);
        // ❌ 问题:会查询所有字段,包括content大字段
        return articleMapper.selectPage(page, null);
        // 实际SQL: SELECT id, title, summary, content, create_time FROM article LIMIT ?
    }
}

// ============ 优化方案1:查询指定字段 ============
public IPage<Article> getArticlePageOptimized(int current, int size) {
    Page<Article> page = new Page<>(current, size);
    
    // 使用条件构造器指定查询字段
    LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<>();
    wrapper.select(Article::getId, Article::getTitle, 
                   Article::getSummary, Article::getCreateTime); // 排除content字段
    
    return articleMapper.selectPage(page, wrapper);
    // 优化后SQL: SELECT id, title, summary, create_time FROM article LIMIT ?
    // 数据传输量减少90%
}

// ============ 优化方案2:使用自定义VO ============
public class ArticleVO {
    private Long id;
    private String title;
    private String summary;
    private Date createTime;
    // 不包含content字段
}

public IPage<ArticleVO> getArticleVOPage(int current, int size) {
    Page<ArticleVO> page = new Page<>(current, size);
    return articleMapper.selectArticleVOPage(page);
}

@Mapper
public interface ArticleMapper extends BaseMapper<Article> {
    
    IPage<ArticleVO> selectArticleVOPage(Page<?> page);
    // XML中定义只查询必要字段的SQL
}

// ============ 优化方案3:分表存储大字段 ============
@Entity
@TableName("article")
public class Article {
    private Long id;
    private String title;
    private String summary;
    private Date createTime;
    // content字段分离到另一张表
}

@Entity
@TableName("article_content")
public class ArticleContent {
    private Long articleId;
    private String content;  // 大字段单独存储
}

// 查询时先查主表分页需要内容时再查详情

项目场景:在CMS系统的文章列表页,每页显示20篇文章。默认查询会拉取20篇文章的完整内容(可能几十万字),网络传输达到20MB。优化后只查询标题、摘要等字段,传输量降到50KB,页面加载速度从3秒提升到0.2秒。

逻辑删除带来的索引失效

一句话原理:使用MyBatis-Plus的逻辑删除功能时,所有查询都会自动加上deleted=0条件。如果deleted字段没有索引或选择性太差,会导致全表扫描,特别是与范围查询组合时,性能急剧下降。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
 * 逻辑删除索引问题演示与优化
 */
@Entity
@TableName("user")
public class User {
    @TableId
    private Long id;
    private String name;
    private Integer age;
    private String email;
    
    @TableLogic  // 逻辑删除字段
    private Integer deleted;  // 0-未删除,1-已删除
}

// ============ 问题代码:逻辑删除导致索引失效 ============
public List<User> getActiveUsersByAge(int minAge, int maxAge) {
    LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
    wrapper.between(User::getAge, minAge, maxAge);
    
    // 实际执行的SQL会自动加上 AND deleted=0
    // SELECT * FROM user WHERE age BETWEEN ? AND ? AND deleted=0
    
    // 如果deleted字段没有索引,即使age有索引,也可能全表扫描
    return userMapper.selectList(wrapper);
}

// ============ 优化方案1:为deleted字段创建组合索引 ============
// 创建组合索引 (deleted, age)
// CREATE INDEX idx_deleted_age ON user(deleted, age);

// ============ 优化方案2:使用枚举值提高选择性 ============
public enum DeletedEnum {
    NORMAL(0, "正常"),
    DELETED(1, "已删除");
    
    private final int value;
    // 构造函数等
}

// 选择性从1/2提高到具体业务含义

// ============ 优化方案3:针对特定查询绕过逻辑删除 ============
public interface UserMapper extends BaseMapper<User> {
    
    // 自定义SQL,不使用逻辑删除过滤
    @Select("SELECT * FROM user WHERE age BETWEEN #{minAge} AND #{maxAge}")
    List<User> selectUsersByAgeWithoutLogic(@Param("minAge") int minAge, 
                                            @Param("maxAge") int maxAge);
}

// ============ 优化方案4:分区表设计 ============
// 按deleted字段分区
// CREATE TABLE user (
//     id INT,
//     name VARCHAR(50),
//     deleted TINYINT
// ) PARTITION BY LIST(deleted) (
//     PARTITION p_normal VALUES IN (0),
//     PARTITION p_deleted VALUES IN (1)
// );

项目场景:在用户管理系统中,需要频繁查询活跃用户(未删除)的年龄分布。由于deleted字段没有索引,每次查询都扫描500万行数据。优化后添加组合索引(deleted, age),查询从全表扫描变为索引范围扫描,耗时从3秒降到30毫秒。

批量操作性能:逐条提交的事务开销

一句话原理:MyBatis-Plus的saveBatch默认是逐条插入,每插入一条都会提交一次事务,产生大量事务日志和网络往返。当数据量较大时,性能远低于JDBC批处理。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
/**
 * 批量操作性能问题演示与优化
 */
// ============ 问题代码:默认的saveBatch ============
@Service
public class DataImportService {
    
    @Autowired
    private UserMapper userMapper;
    
    public void importUsers(List<User> userList) {
        // 问题:默认的saveBatch是伪批量,内部还是循环插入
        userService.saveBatch(userList);  // 假设有10000条数据
        
        // 实际执行了10000次INSERT,10000次事务提交
        // 性能极差
    }
}

// MyBatis-Plus默认实现
public boolean saveBatch(Collection<T> entityList, int batchSize) {
    // 内部还是循环调用save
    for (T entity : entityList) {
        save(entity);  // 逐条插入
    }
    return true;
}

// ============ 优化方案1:开启rewriteBatchedStatements ============
// application.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true
    # rewriteBatchedStatements=true 是关键
    
// 使用MyBatis-Plus的批量方法(需要高版本)
userService.saveBatch(userList, 1000);  // 分批插入,每批1000条

// ============ 优化方案2:自定义批量插入方法 ============
public class InsertBatch extends AbstractMethod {
    
    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, 
            Class<?> modelClass, TableInfo tableInfo) {
        
        // 生成真正的批量插入SQL
        String sql = String.format(
            "<script>INSERT INTO %s %s VALUES %s</script>",
            tableInfo.getTableName(),
            tableInfo.getInsertSqlColumns(),
            buildBatchValuesSql(tableInfo)
        );
        
        return this.addInsertMappedStatement(mapperClass, modelClass, 
            "insertBatch", sql, method -> method);
    }
    
    private String buildBatchValuesSql(TableInfo tableInfo) {
        return "<foreach collection='list' item='item' separator=','>(" +
            tableInfo.getInsertSqlPropertyBatch("item") + ")</foreach>";
    }
}

// ============ 优化方案3:使用原生JDBC批处理 ============
@Autowired
private JdbcTemplate jdbcTemplate;

public void batchInsertWithJdbc(List<User> users) {
    String sql = "INSERT INTO user(name, age) VALUES(?, ?)";
    
    jdbcTemplate.batchUpdate(sql, users, 1000, (ps, user) -> {
        ps.setString(1, user.getName());
        ps.setInt(2, user.getAge());
    });
}

// ============ 优化方案4:并行批量插入 ============
public void parallelBatchInsert(List<User> users) {
    int processors = Runtime.getRuntime().availableProcessors();
    List<List<User>> partitions = Lists.partition(users, 1000);
    
    // 并行处理
    partitions.parallelStream().forEach(batch -> {
        userService.saveBatch(batch);  // 注意线程安全
    });
}

项目场景:在数据迁移项目中,需要导入100万条用户数据。使用默认的saveBatch需要30分钟。优化后开启rewriteBatchedStatements,并使用自定义批量插入方法,时间缩短到2分钟,性能提升15倍。

完整优化指南

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
/**
 * MyBatis-Plus性能优化完整指南
 */
public class PerformanceOptimizationGuide {
    
    /**
     * 1. 优化点速查表
     */
    public class OptimizationChecklist {
        // 问题类型             优化方案                          预期效果
        // N+1查询              用JOIN或IN查询替代循环              90%性能提升
        // 大字段分页            SELECT指定字段                      传输量降90%
        // 逻辑删除索引          建立组合索引                         查询快100倍
        // 批量操作              rewriteBatchedStatements          时间降90%
        // 慢SQL日志            开启慢SQL监控                       及时发现
        // 一级缓存              默认开启,注意事务边界               减少重复查询
        // 二级缓存              配置@CacheNamespace                 跨Session缓存
    }
    
    /**
     * 2. 监控工具配置
     */
    @Configuration
    public class MonitorConfig {
        
        // 开启慢SQL日志
        @Bean
        public PerformanceInterceptor performanceInterceptor() {
            PerformanceInterceptor interceptor = new PerformanceInterceptor();
            interceptor.setMaxTime(1000);  // 超过1秒的SQL记录日志
            interceptor.setFormat(true);    // 格式化SQL
            return interceptor;
        }
        
        // application.yml配置
        // mybatis-plus:
        //   configuration:
        //     log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
        //   performance:
        //     max-time: 1000
    }
    
    /**
     * 3. 索引优化建议
     */
    public class IndexOptimization {
        
        // 1. 查询条件字段建索引
        // 2. 排序字段建索引
        // 3. 逻辑删除字段建索引(或组合索引)
        // 4. 联合索引遵循最左前缀原则
        
        // 示例:常用查询的组合索引
        // CREATE INDEX idx_user_status_age ON user(status, age);
    }
    
    /**
     * 4. 缓存配置
     */
    @Configuration
    public class CacheConfig {
        
        // 开启二级缓存
        // application.yml
        // mybatis-plus:
        //   configuration:
        //     cache-enabled: true
        
        // 实体类启用缓存
        @Data
        @CacheNamespace(eviction = FifoCache.class, flushInterval = 60000, size = 512)
        public class User {
            // 字段定义
        }
    }
}

/**
 * 性能优化总结表
 * 
 * 问题             诊断方法                 优化手段                   场景
 * N+1查询          SQL日志观察               IN查询/JOIN              列表关联
 * 大字段查询        查看网络传输量              SELECT指定字段           列表页
 * 逻辑删除索引      EXPLAIN分析               组合索引                  过滤删除数据
 * 批量操作慢       监控事务提交次数            rewriteBatchedStatements  数据导入
 * 内存溢出         堆dump分析                 分页优化/流式查询         大结果集
 */

// 面试金句
// "MyBatis-Plus的性能问题就像'城市交通拥堵':
//  N+1查询是'私家车太多'(每个订单自己开车查用户),解决方案是'地铁'(IN查询);
//  大字段分页是'卡车占道'(一次拉太多货),解决方案是'分车运输'(SELECT指定字段);
//  逻辑删除索引失效是'没设红绿灯'(全表乱跑),解决方案是'交警指挥'(建索引);
//  批量操作慢是'收费站逐个缴费',解决方案是'ETC通道'(rewriteBatchedStatements)。
//  在实际项目中,通过优化这些场景,我们的查询性能提升了10倍以上,
//  原来30分钟的数据导入现在只要2分钟系统从'拥堵'变成了'畅通'。"

MyBatis-Plus 的分页插件原理是什么?

核心原理:SQL拦截重写

一句话原理:MyBatis-Plus分页插件基于MyBatis的Interceptor(拦截器)机制,在执行SQL前拦截Executorquery方法,通过动态SQL重写技术,将原始查询SQL改写为分页SQL(添加LIMIT语句)和COUNT查询SQL(生成总数统计),对业务代码完全透明。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
 * 分页插件核心拦截器源码解析
 */
public class PaginationInnerInterceptor implements InnerInterceptor {
    
    // 核心拦截方法:在查询前执行
    @Override
    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, 
                           RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
        // 1. 从参数中提取分页对象
        IPage<?> page = ParameterUtils.findPage(parameter).orElse(null);
        if (page == null) return;  // 没有分页参数,直接返回
        
        // 2. 处理COUNT查询(如果需要)
        if (page.isSearchCount()) {
            // 2.1 生成COUNT查询SQL
            String countSql = this.optimizeCountSql(boundSql.getSql());
            
            // 2.2 创建COUNT查询的BoundSql
            BoundSql countBoundSql = new BoundSql(ms.getConfiguration(), countSql,
                boundSql.getParameterMappings(), parameter);
            
            // 2.3 执行COUNT查询
            Long count = this.queryCount(executor, ms, parameter, countBoundSql);
            page.setTotal(count);
        }
        
        // 3. 生成分页SQL
        String pageSql = this.dialect.buildPaginationSql(boundSql.getSql(), 
            page.offset(), page.getSize());
        
        // 4. 通过反射替换原始SQL
        ReflectUtil.setFieldValue(boundSql, "sql", pageSql);
    }
    
    // 优化COUNT查询:去掉ORDER BY、简化子查询等
    private String optimizeCountSql(String originalSql) {
        // 移除ORDER BY子句(对COUNT查询无用)
        originalSql = removeOrderBy(originalSql);
        
        // 处理复杂查询(如UNION、DISTINCT等)
        if (originalSql.contains("UNION") || originalSql.contains("DISTINCT")) {
            return "SELECT COUNT(1) FROM (" + originalSql + ") TEMP";
        }
        
        return "SELECT COUNT(1) FROM (" + originalSql + ") TEMP";
    }
}

项目场景:在用户管理系统的列表页,每次点击分页都会调用userMapper.selectPage(page, wrapper)。分页插件自动拦截原始SQL SELECT * FROM user WHERE age > 18,生成COUNT查询统计总数,并改写为分页SQL SELECT * FROM user WHERE age > 18 LIMIT 0,10,整个过程对业务代码完全透明,开发人员只需关注业务条件。

分页SQL生成:数据库方言适配

一句话原理:分页插件通过**方言(Dialect)**机制适配不同数据库,根据配置的数据库类型(MySQL/Oracle/PostgreSQL等)生成对应的分页SQL,例如MySQL的LIMIT offset, size、Oracle的ROWNUM、PostgreSQL的OFFSET LIMIT

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/**
 * 数据库方言适配源码解析
 */
public interface IDialect {
    // 根据数据库类型生成分页SQL
    String buildPaginationSql(String originalSql, long offset, long limit);
}

// ============ MySQL方言实现 ============
public class MySqlDialect implements IDialect {
    
    @Override
    public String buildPaginationSql(String originalSql, long offset, long limit) {
        StringBuilder sql = new StringBuilder(originalSql);
        sql.append(" LIMIT ").append(offset).append(",").append(limit);
        return sql.toString();
    }
}

// ============ Oracle方言实现 ============
public class OracleDialect implements IDialect {
    
    @Override
    public String buildPaginationSql(String originalSql, long offset, long limit) {
        // Oracle 12c+支持OFFSET FETCH
        if (offset > 0) {
            return originalSql + " OFFSET " + offset + " ROWS FETCH NEXT " + limit + " ROWS ONLY";
        }
        
        // 旧版本使用ROWNUM嵌套
        return "SELECT * FROM (SELECT TMP.*, ROWNUM ROW_ID FROM (" + originalSql + 
               ") TMP WHERE ROWNUM <= " + (offset + limit) + ") WHERE ROW_ID > " + offset;
    }
}

// ============ PostgreSQL方言实现 ============
public class PostgreSqlDialect implements IDialect {
    
    @Override
    public String buildPaginationSql(String originalSql, long offset, long limit) {
        return originalSql + " LIMIT " + limit + " OFFSET " + offset;
    }
}

// ============ SQL Server方言实现 ============
public class SqlServerDialect implements IDialect {
    
    @Override
    public String buildPaginationSql(String originalSql, long offset, long limit) {
        // SQL Server 2012+支持OFFSET FETCH
        return originalSql + " OFFSET " + offset + " ROWS FETCH NEXT " + limit + " ROWS ONLY";
    }
}

// ============ 方言工厂 ============
public class DialectFactory {
    
    public static IDialect getDialect(DbType dbType) {
        switch (dbType) {
            case MYSQL:
            case MARIADB:
                return new MySqlDialect();
            case ORACLE:
                return new OracleDialect();
            case POSTGRE_SQL:
                return new PostgreSqlDialect();
            case SQL_SERVER:
                return new SqlServerDialect();
            default:
                throw new UnsupportedOperationException("不支持的数据库类型: " + dbType);
        }
    }
}

项目场景:公司同时有MySQL和Oracle两套环境,通过分页插件的方言配置,同一套代码可以在两种数据库上正确执行分页。例如MySQL生成LIMIT语句,Oracle生成ROWNUM嵌套查询,开发人员无需关注底层差异。

COUNT查询优化:智能处理复杂SQL

一句话原理:分页插件对COUNT查询进行智能优化,自动移除ORDER BY子句(不影响总数)、处理DISTINCTUNION等复杂查询、合并简单子查询,确保COUNT查询性能最优。

一句话源码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/**
 * COUNT查询优化源码解析
 */
public class CountSqlParser {
    
    // 优化COUNT查询的核心方法
    public String optimizeCountSql(String originalSql) {
        // 1. 解析SQL为JSQLParser对象
        Select select = (Select) CCJSqlParserUtil.parse(originalSql);
        PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
        
        // 2. 移除ORDER BY子句(对COUNT查询无用)
        if (plainSelect.getOrderByElements() != null) {
            plainSelect.setOrderByElements(null);
        }
        
        // 3. 处理DISTINCT查询
        if (plainSelect.getDistinct() != null) {
            // 保持DISTINCT,但简化字段列表
            plainSelect.setSelectItems(Collections.singletonList(
                new SelectExpressionItem(new LongValue("1"))));
        }
        
        // 4. 处理GROUP BY查询
        if (plainSelect.getGroupBy() != null) {
            // 返回COUNT(DISTINCT groupField)形式
            return buildGroupByCountSql(originalSql);
        }
        
        // 5. 处理UNION查询
        if (select instanceof UnionSelect) {
            return "SELECT COUNT(1) FROM (" + originalSql + ") TEMP";
        }
        
        // 6. 简化查询:直接包装一层COUNT
        return "SELECT COUNT(1) FROM (" + select.toString() + ") TEMP";
    }
    
    // 针对GROUP BY的优化
    private String buildGroupByCountSql(String originalSql) {
        // 返回分组字段的COUNT(DISTINCT)
        return "SELECT COUNT(DISTINCT " + getGroupByFields(originalSql) + 
               ") FROM (" + originalSql + ") TEMP";
    }
}

// ============ 使用示例:复杂SQL的COUNT优化 ============
// 原始查询
String sql = "SELECT u.id, u.name, o.order_no " +
             "FROM user u " +
             "LEFT JOIN order o ON u.id = o.user_id " +
             "WHERE u.age > 18 " +
             "ORDER BY u.create_time DESC";

// 优化后的COUNT查询(移除了ORDER BY)
// SELECT COUNT(1) FROM (SELECT u.id, u.name, o.order_no 
//                       FROM user u 
//                       LEFT JOIN order o ON u.id = o.user_id 
//                       WHERE u.age > 18) TEMP

// 带DISTINCT的COUNT优化
String distinctSql = "SELECT DISTINCT user_id FROM order WHERE amount > 100";
// 优化后:SELECT COUNT(1) FROM (SELECT DISTINCT user_id FROM order WHERE amount > 100) TEMP
// 或进一步优化为SELECT COUNT(DISTINCT user_id) FROM order WHERE amount > 100

项目场景:在报表系统中,有一个复杂的多表关联查询用于生成销售报表,原始查询包含ORDER BY和多个JOIN。分页插件的COUNT优化自动移除了无用的ORDER BY,将COUNT查询时间从2秒降低到0.1秒,避免了因COUNT过慢导致的分页接口整体超时。

完整实战指南

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
/**
 * 分页插件完整实战指南
 */
public class PaginationPluginGuide {
    
    /**
     * 1. 配置最佳实践
     */
    @Configuration
    public class PaginationConfig {
        
        @Bean
        public MybatisPlusInterceptor mybatisPlusInterceptor() {
            MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
            
            // 添加分页插件
            PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor();
            
            // 设置数据库类型(自动识别也可)
            paginationInterceptor.setDbType(DbType.MYSQL);
            
            // 单页最大限制(防止恶意请求)
            paginationInterceptor.setMaxLimit(500L);
            
            // 溢出处理:超过总页数时是否跳转到第一页
            paginationInterceptor.setOverflow(true);
            
            // 优化COUNT JOIN:自动优化JOIN查询的COUNT
            paginationInterceptor.setOptimizeJoin(true);
            
            interceptor.addInnerInterceptor(paginationInterceptor);
            
            return interceptor;
        }
    }
    
    /**
     * 2. 源码调试点
     */
    public class DebugPoints {
        // 断点1: PaginationInnerInterceptor.beforeQuery()
        // 查看分页拦截全过程
        
        // 断点2: DialectFactory.getDialect()
        // 查看方言选择
        
        // 断点3: CountSqlParser.optimizeCountSql()
        // 查看COUNT优化逻辑
    }
    
    /**
     * 3. 性能监控
     */
    @Slf4j
    @Component
    public class PaginationMonitor {
        
        @EventListener(ApplicationReadyEvent.class)
        public void monitorPagination() {
            log.info("分页插件已启用,配置信息:");
            log.info("- 最大分页限制: {}", paginationInterceptor.getMaxLimit());
            log.info("- 溢出处理: {}", paginationInterceptor.isOverflow());
            log.info("- 数据库方言: {}", paginationInterceptor.getDbType());
        }
        
        // 查看分页执行计划
        public void analyzePageExplain(Page<?> page, QueryWrapper<?> wrapper) {
            // 可以在测试环境执行EXPLAIN分析SQL
            String originalSql = getSql(wrapper);
            String pageSql = new MySqlDialect().buildPaginationSql(originalSql, 
                page.offset(), page.getSize());
            
            log.info("分页SQL: {}", pageSql);
            // 执行EXPLAIN pageSql 分析索引使用情况
        }
    }
    
    /**
     * 4. 常见问题解决方案
     */
    public class CommonProblems {
        
        // 问题1:COUNT查询结果不正确
        // 原因:COUNT优化过度或JOIN导致笛卡尔积
        // 解决:设置page.setOptimizeCountSql(false) 或 手动写COUNT
        
        // 问题2:分页查询慢(即使有索引)
        // 原因:深度分页问题(LIMIT 100000,10)
        // 解决:使用游标分页(基于ID)替代偏移分页
        
        // 问题3:多表关联COUNT重复
        // 原因:JOIN导致COUNT值包含重复记录
        // 解决:使用DISTINCT或子查询分页
        
        // 问题4:分页插件不生效
        // 原因:未正确配置Interceptor或使用了自定义Executor
        // 解决:检查MybatisPlusInterceptor是否注册
    }
    
    /**
     * 5. 面试必备
     */
    public class InterviewQA {
        
        // Q: 分页插件如何实现物理分页?
        // A: 拦截Executor.query,重写SQL添加分页语句
        
        // Q: COUNT查询如何优化?
        // A: 移除ORDER BY,简化子查询,处理UNION/DISTINCT
        
        // Q: 为什么深度分页(LIMIT 100000,10)慢?
        // A: 数据库需要扫描前100010条数据
        // 解决方案:基于游标的分页(where id > lastId limit 10)
        
        // Q: 分页插件支持哪些数据库?
        // A: MySQL、Oracle、PostgreSQL、SQL Server等主流数据库
    }
}

/**
 * 分页插件原理总结表
 * 
 * 阶段          操作                        关键类
 * 拦截          beforeQuery拦截              PaginationInnerInterceptor
 * 方言适配      根据数据库生成分页SQL           IDialect实现类
 * COUNT优化     移除无用子句,简化查询          CountSqlParser
 * 结果处理      封装Page对象返回               IPage接口
 * 
 * 执行流程:
 * 业务调用 → 拦截器拦截 → 获取分页参数 → 执行COUNT → 生成分页SQL → 执行查询 → 返回Page
 * 
 * 优化建议:
 * 1. 设置maxLimit防止恶意分页
 * 2. 为排序字段建立索引
 * 3. 深度分页用游标方案
 */

// 面试金句
// "MyBatis-Plus分页插件就像'智能SQL改写器':
//  它默默监听你的SQL(拦截器),
//  发现你要分页,立刻化身'SQL魔术师':
//  先变出一个'计数分身'(COUNT查询),
//  再把原SQL加上'笼头'(LIMIT/ROWNUM),
//  最后把结果整整齐齐装进'Page盒子'。
//  在报表系统中,它每天处理上万次分页请求,
//  自动优化COUNT查询,让复杂报表也能秒级响应。
//  理解它的原理就能知道为什么分页慢以及如何优化"