MyBatis 进阶实战:插件开发与性能优化

MyBatis 作为 Java 主流 ORM 框架,多数开发者仅掌握基础 CRUD 与动态 SQL,但对其核心原理、插件开发、缓存机制、性能优化理解不足,导致代码冗余、查询效率低、扩展性差(如通用分页、数据脱敏无法统一实现)。

本文从 MyBatis 核心原理出发,深入讲解插件开发(拦截器)、缓存机制优化、SQL 执行优化、批量操作技巧,结合实战代码与场景示例,帮你突破 MyBatis 使用瓶颈,写出高效、可扩展的持久层代码。

一、核心认知:MyBatis 核心原理与执行流程

1. 核心架构

MyBatis 架构分为三层,职责清晰:

  • 接口层:通过SqlSession提供 CRUD 操作接口,面向开发者;
  • 核心层:包含配置解析、SQL 解析、参数映射、结果映射、插件拦截、缓存管理,是 MyBatis 核心;
  • 基础层:包含数据源管理、事务管理、日志管理,为核心层提供支撑。

2. 核心执行流程(以查询为例)

  1. 加载配置:读取 MyBatis 配置文件(mybatis-config.xml)、Mapper 接口与 XML 文件,解析为Configuration对象;
  2. 创建 SqlSession:通过SqlSessionFactory创建SqlSession,关联Executor(执行器);
  3. 生成代理对象:Mapper 接口通过 JDK 动态代理生成代理对象,拦截接口方法;
  4. 解析 SQL:代理对象根据方法名匹配 XML 中的 SQL 语句,解析动态 SQL,生成最终执行 SQL;
  5. 执行 SQL:Executor通过StatementHandler执行 SQL,ParameterHandler处理参数,ResultSetHandler处理结果集;
  6. 缓存处理:查询结果先查缓存(一级 / 二级),无缓存则执行数据库查询,结果写入缓存;
  7. 返回结果:将结果集映射为 Java 对象,返回给调用者。

3. 四大核心组件(插件拦截目标)

MyBatis 插件通过拦截四大核心组件的方法,实现功能增强:

  • Executor:执行器,负责 SQL 执行与缓存管理,可拦截queryupdatecommit等方法;
  • StatementHandler:语句处理器,负责 SQL 预编译、参数设置、结果集处理,可拦截prepareparameterize等方法;
  • ParameterHandler:参数处理器,负责将 Java 参数映射为 SQL 参数,可拦截setParameters方法;
  • ResultSetHandler:结果集处理器,负责将 SQL 结果集映射为 Java 对象,可拦截handleResultSets方法。

二、实战:MyBatis 插件开发(拦截器)

MyBatis 插件本质是 JDK 动态代理与责任链模式,通过自定义拦截器,可实现通用功能(如分页、数据脱敏、日志增强、权限控制),无需侵入业务代码。

1. 插件开发核心规范

  • 实现org.apache.ibatis.plugin.Interceptor接口,重写intercept(核心拦截逻辑)、plugin(生成代理对象)、setProperties(读取配置参数)方法;
  • @Intercepts@Signature注解指定拦截的组件、方法与参数;
  • 插件需注册到 MyBatis 配置中(XML / 注解方式),才能生效。

2. 实战 1:通用分页插件(拦截 Executor)

传统分页需在 XML 中写LIMIT语句,通用性差,通过插件拦截Executor.query方法,自动拼接分页 SQL,实现通用分页。

(1)自定义分页拦截器

java

运行

复制代码
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import java.util.Properties;

// 拦截Executor的query方法(两个重载方法)
@Intercepts({
    @Signature(
        type = Executor.class,
        method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
    ),
    @Signature(
        type = Executor.class,
        method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}
    )
})
public class PageInterceptor implements Interceptor {

    // 核心拦截逻辑
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 1. 获取拦截参数
        Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        Object parameter = args[1];
        RowBounds rowBounds = (RowBounds) args[2];
        BoundSql boundSql = ms.getBoundSql(parameter);

        // 2. 判断是否需要分页(自定义PageParam参数,包含pageNum、pageSize)
        if (parameter instanceof PageParam pageParam) {
            int pageNum = pageParam.getPageNum();
            int pageSize = pageParam.getPageSize();
            if (pageNum > 0 && pageSize > 0) {
                // 3. 生成分页SQL(拼接LIMIT,适配MySQL)
                String originalSql = boundSql.getSql();
                int offset = (pageNum - 1) * pageSize;
                String pageSql = originalSql + " LIMIT " + offset + ", " + pageSize;

                // 4. 重写BoundSql的SQL语句
                BoundSql newBoundSql = new BoundSql(ms.getConfiguration(), pageSql, boundSql.getParameterMappings(), parameter);
                // 5. 替换原参数中的BoundSql(适配第二个重载方法)
                if (args.length == 6) {
                    args[5] = newBoundSql;
                }
                // 6. 重写RowBounds为默认(避免MyBatis自带分页拦截)
                args[2] = RowBounds.DEFAULT;
            }
        }

        // 7. 执行原方法
        return invocation.proceed();
    }

    // 生成代理对象(默认实现,无需修改)
    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    // 读取插件配置参数(如数据库类型,适配MySQL/Oracle)
    @Override
    public void setProperties(Properties properties) {
        String dbType = properties.getProperty("dbType", "mysql");
        // 可根据数据库类型调整分页SQL拼接逻辑
    }
}
(2)分页参数类(PageParam)

java

运行

复制代码
import lombok.Data;

@Data
public class PageParam {
    private int pageNum; // 页码(从1开始)
    private int pageSize; // 每页条数
    // 可扩展:排序字段、排序方向等
}
(3)注册插件(MyBatis 配置)
① XML 方式(mybatis-config.xml)

xml

复制代码
<configuration>
    <plugins>
        <!-- 注册分页插件,配置数据库类型 -->
        <plugin interceptor="com.example.plugin.PageInterceptor">
            <property name="dbType" value="mysql"/>
        </plugin>
    </plugins>
</configuration>
② Spring Boot 注解方式

java

运行

复制代码
@Configuration
public class MyBatisConfig {
    @Bean
    public PageInterceptor pageInterceptor() {
        PageInterceptor interceptor = new PageInterceptor();
        Properties properties = new Properties();
        properties.setProperty("dbType", "mysql");
        interceptor.setProperties(properties);
        return interceptor;
    }
}
(4)使用方式(业务代码)

java

运行

复制代码
// Mapper接口
public interface OrderMapper {
    List<OrderDO> selectOrderList(PageParam pageParam);
}

// XML文件(无需写LIMIT,插件自动拼接)
<select id="selectOrderList" parameterType="com.example.param.PageParam" resultType="com.example.entity.OrderDO">
    SELECT id, user_id, product_id, count FROM `order`
</select>

// 服务层调用
@Service
public class OrderService {
    @Resource
    private OrderMapper orderMapper;

    public List<OrderDO> getOrderList(int pageNum, int pageSize) {
        PageParam pageParam = new PageParam();
        pageParam.setPageNum(pageNum);
        pageParam.setPageSize(pageSize);
        return orderMapper.selectOrderList(pageParam);
    }
}

3. 实战 2:数据脱敏插件(拦截 ResultSetHandler)

对敏感数据(如手机号、身份证号)进行脱敏处理(如手机号显示为 138****8000),通过拦截结果集处理方法,自动脱敏,无需修改业务代码。

(1)自定义脱敏注解(标记需要脱敏的字段)

java

运行

复制代码
import java.lang.annotation.*;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Sensitive {
    // 脱敏类型(手机号、身份证、姓名)
    SensitiveType type();
}

// 脱敏类型枚举
public enum SensitiveType {
    PHONE, // 手机号
    ID_CARD, // 身份证号
    NAME // 姓名
}
(2)脱敏拦截器

java

运行

复制代码
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.plugin.*;
import java.lang.reflect.Field;
import java.sql.Statement;
import java.util.List;
import java.util.Properties;

@Intercepts({
    @Signature(
        type = ResultSetHandler.class,
        method = "handleResultSets",
        args = {Statement.class}
    )
})
public class SensitiveInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 1. 执行原方法,获取结果集
        List<?> resultList = (List<?>) invocation.proceed();
        // 2. 对结果集进行脱敏处理
        for (Object result : resultList) {
            desensitize(result);
        }
        return resultList;
    }

    // 脱敏处理核心逻辑
    private void desensitize(Object result) throws IllegalAccessException {
        Class<?> clazz = result.getClass();
        // 遍历所有字段,判断是否有@Sensitive注解
        Field[] fields = clazz.getDeclaredFields();
        for (Field field : fields) {
            if (field.isAnnotationPresent(Sensitive.class)) {
                field.setAccessible(true); // 允许访问私有字段
                Sensitive sensitive = field.getAnnotation(Sensitive.class);
                Object value = field.get(result); // 获取字段值
                if (value instanceof String strValue && !strValue.isEmpty()) {
                    // 根据脱敏类型处理
                    String desensitizedValue = switch (sensitive.type()) {
                        case PHONE -> desensitizePhone(strValue);
                        case ID_CARD -> desensitizeIdCard(strValue);
                        case NAME -> desensitizeName(strValue);
                        default -> strValue;
                    };
                    field.set(result, desensitizedValue); // 设值脱敏后的值
                }
            }
        }
    }

    // 手机号脱敏:保留前3位+后4位,中间替换为****
    private String desensitizePhone(String phone) {
        if (phone.length() == 11) {
            return phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
        }
        return phone;
    }

    // 身份证脱敏:保留前6位+后4位,中间替换为****
    private String desensitizeIdCard(String idCard) {
        if (idCard.length() == 18) {
            return idCard.replaceAll("(\\d{6})\\d{8}(\\d{4})", "$1********$2");
        }
        return idCard;
    }

    // 姓名脱敏:保留姓,名替换为*
    private String desensitizeName(String name) {
        if (name.length() >= 2) {
            return name.substring(0, 1) + "*".repeat(name.length() - 1);
        }
        return name;
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {}
}
(3)使用方式

java

运行

复制代码
// 实体类标记敏感字段
@Data
public class UserDO {
    private Long id;
    @Sensitive(type = SensitiveType.NAME)
    private String userName; // 姓名脱敏
    @Sensitive(type = SensitiveType.PHONE)
    private String phone; // 手机号脱敏
    @Sensitive(type = SensitiveType.ID_CARD)
    private String idCard; // 身份证脱敏
}

// 注册插件(同分页插件,添加到MyBatis配置)
@Bean
public SensitiveInterceptor sensitiveInterceptor() {
    return new SensitiveInterceptor();
}

// 调用后自动脱敏:phone=13800138000 → 138****8000

三、实战:MyBatis 性能优化

1. 缓存机制优化(减少数据库查询)

MyBatis 提供两级缓存,合理使用可大幅减少数据库查询次数,提升性能。

(1)一级缓存(SqlSession 级别,默认开启)
  • 原理:同一SqlSession内,相同 SQL 查询会缓存结果,再次查询直接返回缓存,SqlSession关闭 / 提交后缓存失效;
  • 优化点:
  1. 避免频繁创建SqlSession(如 Spring 整合 MyBatis 时,SqlSession由 Spring 管理,默认单例);
  2. 读写分离场景下,避免一级缓存导致脏读(可关闭一级缓存,localCacheScope=STATEMENT)。
(2)二级缓存(Mapper 级别,需手动开启)
  • 原理:多个SqlSession共享同一 Mapper 的缓存,缓存存储在MapperStatement中,默认存储在内存(可配置第三方缓存如 Redis);
  • 开启方式:
  1. 全局配置开启(mybatis-config.xml):

xml

复制代码
<settings>
    <setting name="cacheEnabled" value="true"/> <!-- 默认true,可省略 -->
</settings>
  1. Mapper XML 开启(在对应 XML 中添加<cache>标签):

xml

复制代码
<mapper namespace="com.example.mapper.OrderMapper">
    <!-- 开启二级缓存,配置缓存参数 -->
    <cache 
        eviction="LRU" <!-- 缓存淘汰策略:LRU(最近最少使用) -->
        flushInterval="60000" <!-- 缓存刷新间隔(毫秒) -->
        size="1024" <!-- 缓存最大条目数 -->
        readOnly="true"/> <!-- 只读缓存,提升性能 -->
</mapper>
  • 优化点:
  1. 仅对高频查询、低频更新的数据开启二级缓存(如商品基础信息);
  2. 避免缓存大对象(如 List<Order>),占用内存过多;
  3. 复杂查询(多表联查)不建议开启二级缓存,易导致脏读;
  4. 整合 Redis 作为二级缓存,解决内存缓存容量有限、集群环境缓存不一致问题。

2. SQL 执行优化

(1)优化动态 SQL,避免冗余
  • <where>替代WHERE 1=1,自动处理 AND/OR 逻辑,减少无效 SQL;
  • <choose><when><otherwise>替代多个<if>,逻辑更清晰,避免冗余判断;
  • 批量操作使用<foreach>,避免循环单条执行(如批量插入、批量更新)。
(2)避免SELECT *,仅查询需要的字段
  • 减少数据传输量与结果集映射时间,同时避免触发不必要的字段脱敏、转换;
  • 示例:SELECT id, user_id, product_id FROM order 替代 `SELECT * FROM `order
(3)优化分页查询,避免LIMIT offset过大
  • 参考 JVM 调优中分页优化逻辑,用主键定位替代偏移量分页,结合 MyBatis 实现:

xml

复制代码
<select id="selectOrderByPage" parameterType="map" resultType="OrderDO">
    SELECT id, user_id, product_id FROM `order`
    WHERE id < #{lastId} <!-- 上一页最后一条数据的ID -->
    ORDER BY id DESC
    LIMIT #{pageSize}
</select>

3. 批量操作优化

(1)批量插入(MyBatis 批量插入技巧)
  • 低效方式:循环调用单条插入(频繁与数据库交互,性能差);
  • 高效方式:使用<foreach>拼接批量插入 SQL,减少数据库交互次数:

xml

复制代码
<insert id="batchInsertOrder" parameterType="java.util.List">
    INSERT INTO `order` (user_id, product_id, count, create_time)
    VALUES
    <foreach collection="list" item="item" separator=",">
        (#{item.userId}, #{item.productId}, #{item.count}, #{item.createTime})
    </foreach>
</insert>
  • 进阶优化:设置 JDBC 批量提交参数(rewriteBatchedStatements=true),MySQL 会优化批量插入性能:

yaml

复制代码
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/test?rewriteBatchedStatements=true
(2)批量更新(避免循环单条更新)
  • 方式 1:<foreach>拼接 CASE WHEN 语句,单条 SQL 完成批量更新:

xml

复制代码
<update id="batchUpdateOrder" parameterType="java.util.List">
    UPDATE `order`
    <set>
        <foreach collection="list" item="item" separator=" ">
            WHEN id = #{item.id} THEN
            count = #{item.count}, update_time = #{item.updateTime}
        </foreach>
    </set>
    WHERE id IN
    <foreach collection="list" item="item" open="(" close=")" separator=",">
        #{item.id}
    </foreach>
</update>
  • 方式 2:使用 MyBatis-Plus 的updateBatchById方法(简化批量更新代码)。

4. 其他性能优化点

  • 复用SqlSession:Spring 整合 MyBatis 时,默认使用SqlSessionTemplate,自动管理SqlSession生命周期,无需手动创建;
  • 优化参数映射:使用@Param注解明确参数名称,避免参数位置错误,提升映射效率;
  • 减少反射开销:MyBatis 默认使用反射映射结果集,可通过 ASM 字节码框架优化(MyBatis 3.4 + 已默认优化);
  • 定期清理缓存:二级缓存设置合理的刷新间隔,避免缓存数据过期导致脏读。

四、避坑指南

1. 坑点 1:二级缓存导致脏读

  • 表现:多表联查时,关联表数据更新后,二级缓存未刷新,返回旧数据;
  • 解决方案:1. 复杂联查不开启二级缓存;2. 配置flushCache="true",更新时刷新缓存;3. 用 Redis 缓存替代二级缓存,手动控制缓存失效。

2. 坑点 2:插件拦截方法错误,导致 SQL 执行异常

  • 表现:自定义插件拦截方法后,SQL 执行报错(如参数丢失、SQL 拼接错误);
  • 解决方案:1. 明确拦截的组件与方法签名(参数类型、顺序必须与原方法一致);2. 拦截逻辑中保留原方法核心逻辑,仅做增强;3. 调试时打印 SQL 与参数,定位问题。

3. 坑点 3:批量操作 SQL 过长,导致数据库报错

  • 表现:批量插入 / 更新时,拼接的 SQL 过长(超过数据库max_allowed_packet参数),触发报错;
  • 解决方案:1. 拆分批量数据(如每 100 条分一批);2. 增大数据库max_allowed_packet参数(如设置为 16M)。

4. 坑点 4:忽视 ResultMap 优化,导致性能损耗

  • 表现:使用resultType="map"或自动映射,导致字段类型转换频繁、冗余字段映射;
  • 解决方案:自定义ResultMap,明确字段映射关系,避免自动映射的冗余开销,同时解决字段名与属性名不一致问题。

5. 坑点 5:一级缓存导致读写分离脏读

  • 表现:主库更新数据后,从库未同步,一级缓存返回旧数据;
  • 解决方案:1. 关闭一级缓存(localCacheScope=STATEMENT);2. 读写分离场景下,查询走从库,更新走主库,且更新后清空对应缓存。

五、终极总结:MyBatis 进阶的核心是 "理解原理 + 按需扩展"

MyBatis 的强大之处在于其灵活性与可扩展性 ------ 基础 CRUD 满足日常需求,插件机制可实现通用功能扩展,缓存与 SQL 优化可大幅提升性能。

落地时需记住:

  1. 先懂原理:理解 MyBatis 执行流程与四大核心组件,再开发插件与优化;
  2. 插件按需开发:通用功能(分页、脱敏)用插件,避免过度开发导致维护成本上升;
  3. 性能优化优先:缓存优化、SQL 优化、批量操作优化是提升持久层性能的核心;
  4. 避坑关键:关注缓存一致性、插件拦截正确性、SQL 合理性,避免引入新问题。

通过本文内容,可突破 MyBatis 基础使用瓶颈,实现从 "会用" 到 "精通" 的跨越,写出高效、可扩展、稳定的持久层代码。

相关推荐
这个DBA有点耶6 小时前
NULL不是空——数据库里最反直觉的设计,90%新人踩过的坑
数据库·mysql·代码规范
用户8356290780516 小时前
Python 实现 PDF 文件加密与解密方法
后端·python
用户8356290780516 小时前
使用 Python 冻结与拆分 Excel 窗格教程
后端·python
这个DBA有点耶8 小时前
AI写的SQL跑崩了生产库,这锅谁背?
数据库·人工智能·程序员
镜舟科技8 小时前
Databricks 再提 LTAP,AI 时代的数据底座为何重回大一统叙事?
数据库·架构·agent
Databend9 小时前
从湖仓升级为 Agent 时代的数据控制面,Snowflake 和 Databricks 有哪些布局
大数据·数据库·agent
花椒技术10 小时前
直播间常驻子应用加载优化实践:从 1550ms 到 890ms
性能优化·直播·前端工程化
ClouGence12 小时前
SQL Server CDC 能放到 Always On 备库读吗?一文讲透原理与实践
数据库·sql server
Elasticsearch14 小时前
深入解析 simdvec:Elasticsearch 如何利用神经网络和视频编解码 CPU 指令实现向量搜索
elasticsearch
你好潘先生14 小时前
别再记命令了,用 yeero do 说句人话就能跑脚本,而且不烧 token
服务器·python·命令行