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 基础使用瓶颈,实现从 "会用" 到 "精通" 的跨越,写出高效、可扩展、稳定的持久层代码。

相关推荐
Yorlen_Zhang2 小时前
Python pytest assert 断言
python·servlet·pytest
xzl042 小时前
小智服务器intent_type 初始化为function_call过程
linux·前端·数据库
徐先生 @_@|||2 小时前
基于Spark配置+缓存策略+Junpyter Notebook 实现Spark数据加速调试
大数据·分布式·缓存·spark
悟能不能悟2 小时前
mysql主键递增,之前已经插入的id有1,2,3,4,5,手动插入的那条记录id=15,那后面让它自动生成主键,会是从15开始,还是从5开始
数据库·mysql
不搞数学的汤老师2 小时前
Redis 6.2 RPOP 命令带 count 参数导致的性能退化分析
redis
MoRanzhi12032 小时前
Pillow 图像几何变换与仿射操作
python·pillow·几何学·图片处理·几何变换·仿射操作·图像裁剪
代码丰2 小时前
实际例子理解Redis 缓存与 MySQL 数据一致性 以及常见的细节
redis·mysql·缓存
xzl042 小时前
小智服务器:设备的各种MCP消息、初始化响应、工具列表和工具调用响应
java·网络·python