MyBatis:性能优化实战 - 从 SQL 优化到索引设计

一. 引言

在企业级应用开发中,数据访问层的性能直接决定了系统的整体响应速度。根据权威性能测试报告,数据库操作通常占据应用程序响应时间的 70% 以上,其中不合理的 SQL、缺失的索引和低效的 MyBatis 配置是导致性能问题的三大主因。

MyBatis 作为半自动化 ORM 框架,既保留了 SQL 的灵活性,又提供了对象映射能力,但这种灵活性也带来了性能优化的复杂性。很多开发者虽然掌握了 MyBatis 的基本使用,却在面对大数据量、高并发场景时束手无策:慢查询导致接口超时、连接池耗尽引发系统雪崩、索引失效造成全表扫描等问题屡见不鲜。

本文将从实战角度出发,系统讲解 MyBatis 性能优化的完整方案:从 SQL 编写优化、索引设计原则,到缓存机制配置、执行计划分析,最终通过真实案例展示如何将查询响应时间从秒级优化到毫秒级,帮助开发者构建高性能的数据访问层。

二. SQL 优化:从低效到高效的蜕变

1. 避免全表扫描:精准定位数据

全表扫描(Full Table Scan)是性能杀手之一,当表数据量超过 10 万条时,全表扫描的耗时会呈指数级增长。

反面案例:

java 复制代码
<!-- 错误:未加条件的全表查询 -->
<select id="selectAllUsers" resultType="User">
    SELECT * FROM user
</select>
<!-- 错误:使用函数导致索引失效 -->
<select id="selectByUsername" resultType="User">
    SELECT * FROM user WHERE SUBSTR(username, 1, 3) = 'adm'  <!-- 无法使用username索引 -->
</select>

优化方案:

  • 添加必要的查询条件,限制返回数据量
  • 避免在 WHERE 子句中对字段进行函数操作
  • 使用分页查询处理大量数据
java 复制代码
<!-- 优化:带条件的分页查询 -->
<select id="selectUsersByCondition" resultType="User">
    SELECT id, username, email, create_time 
    FROM user 
    WHERE status = #{status} 
      AND create_time >= #{startTime}
    LIMIT #{offset}, #{pageSize}  <!-- 分页限制 -->
</select>

MyBatis 分页插件优化:

使用 MyBatis-Plus 的分页插件,自动处理分页逻辑并避免全表扫描:

java 复制代码
// 配置分页插件
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
    return interceptor;
}
java 复制代码
// 分页查询使用
Page<User> page = new Page<>(1, 10);  // 第1页,每页10条
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("status", 1);
Page<User> resultPage = userMapper.selectPage(page, queryWrapper);

2. 优化 JOIN 操作:减少关联层级

多表关联查询是业务开发中常见场景,但过多的 JOIN 操作会显著降低查询性能,尤其是关联大表时。

优化原则:

  • 控制 JOIN 表数量,最多不超过 3 张表
  • 关联字段必须建立索引
  • 避免使用 SELECT *,只查询必要字段

反面案例:

java 复制代码
<!-- 错误:多表JOIN且查询所有字段 -->
<select id="selectOrderDetail" resultType="OrderDetailVO">
    SELECT * 
    FROM order o
    JOIN order_item oi ON o.id = oi.order_id
    JOIN product p ON oi.product_id = p.id
    JOIN user u ON o.user_id = u.id
    JOIN address a ON o.address_id = a.id
    WHERE o.id = #{orderId}
</select>

优化方案:

java 复制代码
<!-- 优化:减少JOIN表并指定查询字段 -->
<select id="selectOrderDetail" resultType="OrderDetailVO">
    SELECT 
        o.id, o.order_no, o.total_amount, o.create_time,
        u.id AS user_id, u.username,
        a.receiver, a.phone, a.address
    FROM order o
    JOIN user u ON o.user_id = u.id
    JOIN address a ON o.address_id = a.id
    WHERE o.id = #{orderId}
</select>
java 复制代码
<!-- 子查询获取订单项(按需加载) -->
<select id="selectOrderItems" resultType="OrderItemVO">
    SELECT 
        oi.id, oi.product_id, oi.quantity, oi.unit_price,
        p.name AS product_name
    FROM order_item oi
    JOIN product p ON oi.product_id = p.id
    WHERE oi.order_id = #{orderId}
</select>

关联查询替代方案:

  • 分步查询:先查主表,再根据主表 ID 批量查询子表(利用 MyBatis 的@Batch注解)
  • 冗余字段:对高频查询的关联字段进行冗余,减少 JOIN 操作
  • 使用中间表:预先计算关联结果,适用于非实时数据场景

3. 动态 SQL 优化:避免拼接陷阱

MyBatis 的动态 SQL 功能(、、等)非常强大,但使用不当会导致性能问题。

常见问题:

  • 循环拼接 IN 条件时,元素过多导致 SQL 过长
  • 动态条件过多导致 SQL 解析耗时增加
  • 重复的条件判断导致 SQL 冗余

优化示例:

批量操作优化:

java 复制代码
<!-- 错误:大量元素的foreach导致SQL过长 -->
<delete id="batchDelete">
    DELETE FROM user WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</delete>
java 复制代码
<!-- 优化:分批次删除或使用批量删除语句 -->
<delete id="batchDelete">
    DELETE FROM user WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")" 
             size="1000">  <!-- 限制批次大小 -->
        #{id}
    </foreach>
</delete>

动态条件复用:

java 复制代码
<!-- 定义SQL片段复用条件 -->
<sql id="baseQueryCondition">
    <where>
        <if test="status != null">AND status = #{status}</if>
        <if test="keyword != null">
            AND (username LIKE CONCAT('%', #{keyword}, '%') 
                 OR email LIKE CONCAT('%', #{keyword}, '%'))
        </if>
        <if test="startTime != null">AND create_time >= #{startTime}</if>
        <if test="endTime != null">AND create_time <= #{endTime}</if>
    </where>
</sql>
java 复制代码
<!-- 复用SQL片段 -->
<select id="selectByCondition" resultType="User">
    SELECT id, username, email FROM user
    <include refid="baseQueryCondition"/>
    ORDER BY create_time DESC
</select>

<select id="countByCondition" resultType="int">
    SELECT COUNT(1) FROM user
    <include refid="baseQueryCondition"/>
</select>

使用 choose 标签减少判断分支:

java 复制代码
<select id="selectUser" resultType="User">
    SELECT * FROM user
    <where>
        <choose>
            <when test="id != null">AND id = #{id}</when>
            <when test="username != null">AND username = #{username}</when>
            <otherwise>AND status = 1</otherwise>
        </choose>
    </where>
</select>

4. 避免 N+1 查询问题

N+1 查询是 MyBatis 关联查询中常见的性能陷阱:当查询 N 条主表数据后,每条主表数据又触发一次子表查询,导致总共 N+1 次数据库交互。

问题示例:

java 复制代码
// 1. 查询所有订单(1次查询)
List<Order> orders = orderMapper.selectAll();

// 2. 遍历订单,查询每个订单的明细(N次查询)
for (Order order : orders) {
    List<OrderItem> items = orderItemMapper.selectByOrderId(order.getId());
    order.setItems(items);
}
// 总计:1 + N 次查询

解决方案:

关联查询一次性加载:

java 复制代码
<resultMap id="orderWithItemsMap" type="Order">
    <id column="id" property="id"/>
    <result column="order_no" property="orderNo"/>
    <!-- 一对多关联,一次性加载 -->
    <collection property="items" ofType="OrderItem">
        <id column="item_id" property="id"/>
        <result column="product_id" property="productId"/>
        <result column="quantity" property="quantity"/>
    </collection>
</resultMap>

<select id="selectOrdersWithItems" resultMap="orderWithItemsMap">
    SELECT 
        o.id, o.order_no,
        oi.id AS item_id, oi.product_id, oi.quantity
    FROM order o
    LEFT JOIN order_item oi ON o.id = oi.order_id
    WHERE o.status = #{status}
</select>
MyBatis 延迟加载 + 批量查询:
<!-- 配置延迟加载 -->
<settings>
    <setting name="lazyLoadingEnabled" value="true"/>
    <setting name="aggressiveLazyLoading" value="false"/>
</settings>

<!-- 订单结果映射 -->
<resultMap id="orderMap" type="Order">
    <id column="id" property="id"/>
    <result column="order_no" property="orderNo"/>
    <!-- 延迟加载订单项 -->
    <collection property="items" 
                select="com.example.mapper.OrderItemMapper.selectByOrderIds"
                column="id" fetchType="lazy"/>
</resultMap>

<!-- 订单项Mapper -->
<select id="selectByOrderIds" resultType="OrderItem">
    SELECT * FROM order_item WHERE order_id IN
    <foreach collection="list" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>

优化效果:将 N+1 次查询减少为 2 次查询(1 次主表查询 + 1 次批量子表查询)。

三. 索引设计:数据库性能的基石

1. 索引类型与适用场景

MyBatis 的性能优化离不开合理的索引设计,不同类型的索引适用于不同场景:

创建示例:

java 复制代码
-- 主键索引(通常在建表时指定)
ALTER TABLE `user` ADD PRIMARY KEY (`id`);
-- 唯一索引
ALTER TABLE `user` ADD UNIQUE INDEX `idx_username` (`username`);
-- 普通索引
ALTER TABLE `order` ADD INDEX `idx_status` (`status`);
-- 联合索引(按查询频率排序字段)
ALTER TABLE `order` ADD INDEX `idx_status_create_time` (`status`, `create_time`);
-- 全文索引
ALTER TABLE `article` ADD FULLTEXT INDEX `idx_content` (`content`);

2. 联合索引设计原则

联合索引是优化多条件查询的关键,但设计不当会导致索引失效,需遵循以下原则:

  • 最左前缀匹配原则:
    联合索引(a, b, c)等效于(a)、(a,b)、(a,b,c)三个索引,where 条件中必须包含最左字段a才能使用该索引。
java 复制代码
// 有效使用联合索引(a,b,c)的查询
WHERE a = ?  // 使用(a)部分
WHERE a = ? AND b = ?  // 使用(a,b)部分
WHERE a = ? AND b = ? AND c = ?  // 使用全部索引
// 无法使用联合索引的查询
WHERE b = ?  // 缺少最左字段a
WHERE a = ? AND c = ?  // 中间字段b缺失,只能使用(a)部分
  • 字段顺序原则:
    区分度高的字段放前面(如用户 ID 比状态字段区分度高)
    频繁查询的字段放前面
    范围查询字段放最后(范围查询后的字段无法使用索引)
java 复制代码
-- 推荐:区分度高的user_id放前面,范围查询create_time放最后
CREATE INDEX idx_user_status_time ON `order`(user_id, status, create_time);

-- 优化查询
SELECT * FROM `order` 
WHERE user_id = 100 
  AND status = 1 
  AND create_time > '2024-01-01';
  • 避免冗余索引:
    若已存在联合索引(a,b),则无需单独创建(a)索引,避免维护成本增加。

3. 索引失效的十大陷阱

即使创建了索引,以下情况也会导致索引失效,需特别注意:

  • 使用函数或表达式操作索引字段:
java 复制代码
WHERE SUBSTR(username, 1, 3) = 'adm'  -- username索引失效
WHERE price * 1.2 > 100  -- price索引失效
  • 隐式类型转换:
java 复制代码
-- 字段类型为varchar,查询用数字,导致全表扫描
WHERE phone = 13800138000  -- phone索引失效
-- 正确写法:WHERE phone = '13800138000'
  • 使用 NOT IN、!=、<> 操作符:
java 复制代码
WHERE status != 1  -- status索引失效
  • 使用 OR 连接非索引字段:
java 复制代码
-- name无索引,导致id索引也失效
WHERE id = 100 OR name = 'test'
  • LIKE 以 % 开头的模糊查询:
java 复制代码
WHERE username LIKE '%admin'  -- 索引失效
-- 可以使用:WHERE username LIKE 'admin%'(前缀匹配)
  • 联合索引不满足最左前缀:
java 复制代码
-- 联合索引(a,b,c),缺少a导致索引失效
WHERE b = 1 AND c = 2
WHERE 子句中使用 IS NULL/IS NOT NULL:
WHERE email IS NULL  -- 可能导致索引失效(视数据库版本而定)
  • 查询条件包含全表扫描更优的情况:

    当查询结果超过表数据量 30% 时,数据库可能选择全表扫描而非索引。

  • 使用 ORDER BY 时字段顺序与索引不一致:

java 复制代码
-- 索引为(status, create_time),排序字段顺序不一致
WHERE status = 1 ORDER BY create_time DESC, id ASC  -- 部分失效
  • 更新频繁的字段创建过多索引:
    每个索引都会增加写入操作的开销,写入频繁的表应控制索引数量。

4. MyBatis 与索引协同优化

MyBatis 的配置需与索引设计协同,才能发挥最大性能:

  • ResultMap 优化:
    只映射必要字段,避免 SELECT * 导致的额外 I/O:
java 复制代码
<!-- 优化:只映射需要的字段 -->
<resultMap id="userBaseMap" type="User">
    <id column="id" property="id"/>
    <result column="username" property="username"/>
    <result column="status" property="status"/>
</resultMap>
<select id="selectUserList" resultMap="userBaseMap">
    SELECT id, username, status FROM user WHERE status = #{status}
</select>
  • 避免使用useGeneratedKeys影响索引:
    对于非自增主键(如雪花算法 ID),关闭useGeneratedKeys减少不必要的查询:
java 复制代码
<insert id="insertUser" parameterType="User" useGeneratedKeys="false">
    INSERT INTO user(id, username, email) VALUES(#{id}, #{username}, #{email})
</insert>
  • 分页查询与索引结合:
    分页查询的 ORDER BY 字段应包含在索引中,避免文件排序:
java 复制代码
<!-- 索引:(status, create_time) -->
<select id="selectUserPage" resultType="User">
    SELECT id, username, create_time 
    FROM user 
    WHERE status = #{status}
    ORDER BY create_time DESC  <!-- 排序字段在索引中 -->
    LIMIT #{offset}, #{pageSize}
</select>

四. 缓存机制:减少数据库访问的关键

1. 一级缓存优化(SqlSession 级别)

MyBatis 一级缓存默认开启,作用范围为 SqlSession(会话),在同一个会话中多次执行相同查询会命中缓存。

工作原理:

缓存 key:由 SQL 语句、参数、RowBounds、环境等组成

缓存失效:执行 INSERT/UPDATE/DELETE 操作或调用clearCache()时

优化实践:

java 复制代码
// 优化:同一事务中复用查询结果
@Service
public class OrderService {
    @Autowired
    private OrderMapper orderMapper;

    @Transactional
    public OrderDTO getOrderDetail(Long orderId) {
        // 第一次查询:从数据库获取
        Order order = orderMapper.selectById(orderId);

        // 业务处理...

        // 第二次查询:命中一级缓存,无需访问数据库
        Order orderAgain = orderMapper.selectById(orderId);

        return convertToDTO(order);
    }
}

注意事项:

  • 一级缓存不能跨 SqlSession 共享,分布式环境下无效
  • 长事务中一级缓存会占用大量内存,需及时清理
  • 避免在循环中执行相同查询,应批量查询后在内存中处理

2. 二级缓存配置(Mapper 级别)

二级缓存作用范围为 Mapper 接口,可跨 SqlSession 共享,适合查询频繁、更新较少的数据(如字典表、商品分类)。

配置步骤:

开启全局二级缓存:

java 复制代码
<settings>
    <!-- 开启二级缓存 -->
    <setting name="cacheEnabled" value="true"/>
</settings>

在 Mapper.xml 中配置缓存:

java 复制代码
<!-- UserMapper.xml -->
<mapper namespace="com.example.mapper.UserMapper">
    <!-- 配置二级缓存 -->
    <cache 
        eviction="LRU"  <!-- 淘汰策略:LRU(最近最少使用) -->
        flushInterval="60000"  <!-- 自动刷新时间(毫秒) -->
        size="1024"  <!-- 最大缓存对象数 -->
        readOnly="false"/>  <!-- 是否只读 -->

    <!-- 配置statement是否使用缓存 -->
    <select id="selectById" resultType="User" useCache="true">
        SELECT * FROM user WHERE id = #{id}
    </select>

    <!-- 写操作需刷新缓存 -->
    <update id="updateById" flushCache="true">
        UPDATE user SET username = #{username} WHERE id = #{id}
    </update>
</mapper>

实体类实现序列化:

java 复制代码
public class User implements Serializable {  // 二级缓存要求实体可序列化
    private Long id;
    private String username;
    // ...
}

缓存淘汰策略:

  • LRU(Least Recently Used):移除最近最少使用的对象(默认)
  • FIFO(First In First Out):按插入顺序移除对象
  • SOFT:基于软引用,内存不足时移除
  • WEAK:基于弱引用,随时可能被垃圾回收

3. 第三方缓存集成(Redis)

MyBatis 默认二级缓存基于内存,不适合分布式环境,推荐集成 Redis 作为分布式缓存。

集成步骤:

引入依赖:

java 复制代码
<dependency>
    <groupId>org.mybatis.caches</groupId>
    <artifactId>mybatis-redis</artifactId>
    <version>1.0.0-beta2</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

配置 Redis 缓存:

java 复制代码
# redis.properties
redis.host=localhost
redis.port=6379
redis.timeout=2000
redis.password=
redis.database=0
redis.keyPrefix=mybatis:cache:
redis.expire=3600  # 缓存过期时间(秒)

在 Mapper 中使用 Redis 缓存:

java 复制代码
<mapper namespace="com.example.mapper.DictMapper">
    <!-- 使用Redis缓存 -->
    <cache type="org.mybatis.caches.redis.RedisCache"/>

    <select id="selectByType" resultType="Dict" useCache="true">
        SELECT * FROM dict WHERE type = #{type}
    </select>
</mapper>

分布式缓存最佳实践:

  • 缓存热点数据(访问频率高、更新频率低)
  • 设置合理的过期时间,避免缓存雪崩
  • 对缓存 key 进行统一命名规范,便于管理
  • 实现缓存预热机制,避免缓存穿透

4. 缓存失效问题与解决方案

缓存失效是导致性能下降的常见原因,需针对性解决:

缓存穿透:

问题:查询不存在的数据,导致缓存失效,每次都访问数据库。

解决方案:缓存空结果,设置短期过期时间(或者使用布隆过滤器)。

java 复制代码
public User selectById(Long id) {
    User user = cache.get(id);
    if (user != null) {
        return user;  // 命中缓存
    }

    user = userMapper.selectById(id);
    if (user == null) {
        // 缓存空结果,设置5分钟过期
        cache.set(id, NULL_VALUE, 300);
    } else {
        cache.set(id, user, 3600);
    }
    return user;
}

缓存击穿:

问题:热点 key 过期瞬间,大量请求穿透到数据库。

解决方案:互斥锁或热点数据永不过期。

java 复制代码
public User getHotUser(Long id) {
    User user = cache.get(id);
    if (user != null) {
        return user;
    }

    // 获取互斥锁
    String lockKey = "lock:user:" + id;
    if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS)) {
        try {
            // 再次检查缓存
            user = cache.get(id);
            if (user == null) {
                user = userMapper.selectById(id);
                cache.set(id, user, 86400);  // 热点数据缓存1天
            }
        } finally {
            // 释放锁
            redisTemplate.delete(lockKey);
        }
    } else {
        // 未获取到锁,重试
        Thread.sleep(100);
        return getHotUser(id);
    }
    return user;
}

缓存雪崩:

问题:大量缓存同时过期,导致数据库压力骤增。

解决方案:过期时间加随机值,避免同时过期。

java 复制代码
// 设置随机过期时间(1小时±10分钟)
int baseExpire = 3600;
int random = new Random().nextInt(1200) - 600;  // -600到600秒
cache.set(key, value, baseExpire + random);

五. 执行计划分析:定位性能瓶颈

1. 解读 EXPLAIN 执行计划

EXPLAIN 命令是分析 SQL 性能的利器,通过它可以查看 SQL 的执行计划,识别全表扫描、索引失效等问题。

使用方法:在 SQL 前加上 EXPLAIN 关键字:

java 复制代码
EXPLAIN 
SELECT id, username FROM user 
WHERE status = 1 AND create_time >= '2024-01-01'
ORDER BY create_time DESC;

关键字段解读:

常见 Extra 优化点:

  • Using filesort:需要额外排序,优化:使排序字段包含在索引中
  • Using temporary:使用临时表,优化:避免 GROUP BY 或 DISTINCT 操作,或添加合适索引
  • Using where; Using index:覆盖索引,最佳状态
  • Using index condition:索引下推,良好状态

2. MyBatis 日志配置:查看真实执行 SQL

MyBatis 默认不会输出完整 SQL,需配置日志查看实际执行的 SQL 语句和参数,便于分析优化。

配置方式(application.yml):

java 复制代码
logging:
  level:
    # 配置Mapper接口所在包的日志级别为DEBUG
    com.example.mapper: DEBUG

日志输出示例:

java 复制代码
DEBUG [main] com.example.mapper.UserMapper.selectById - ==>  Preparing: SELECT id, username, email FROM user WHERE id = ? 
DEBUG [main] com.example.mapper.UserMapper.selectById - ==> Parameters: 1001(Long)
DEBUG [main] com.example.mapper.UserMapper.selectById - <==      Total: 1

进阶:集成 p6spy 查看完整 SQL:

对于需要查看带参数的完整 SQL(如调试动态 SQL),可集成 p6spy:

引入依赖:

java 复制代码
<dependency>
    <groupId>p6spy</groupId>
    <artifactId>p6spy</artifactId>
    <version>3.9.1</version>
</dependency>

配置数据源:

java 复制代码
spring:
  datasource:
    driver-class-name: com.p6spy.engine.spy.P6SpyDriver
    url: jdbc:p6spy:mysql://localhost:3306/mybatis_demo?useSSL=false
    username: root
    password: 123456

配置 spy.properties:

java 复制代码
appender=com.p6spy.engine.spy.appender.Slf4JLogger
logMessageFormat=com.p6spy.engine.spy.appender.MultiLineFormat
databaseDialectDateFormat=yyyy-MM-dd HH:mm:ss

配置后可直接查看带参数的完整 SQL,便于复制到数据库客户端执行 EXPLAIN 分析。

3. 慢查询日志分析

启用 MySQL 慢查询日志,记录执行时间超过阈值的 SQL,针对性优化:

开启慢查询日志:

java 复制代码
# my.cnf 配置
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1  # 执行时间超过1秒的SQL视为慢查询
log_queries_not_using_indexes = 1  # 记录未使用索引的查询

分析慢查询日志:

使用 mysqldumpslow 工具分析:

java 复制代码
# 查看最耗时的10条慢查询
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# 查看访问次数最多的10条慢查询
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log
  • 结合 MyBatis 定位问题:

根据慢查询日志中的 SQL,在 MyBatis 的 Mapper 中找到对应方法,检查:

  • 是否使用了合适的索引
  • 是否存在 N+1 查询
  • 动态 SQL 是否生成了冗余条
  • 是否查询了不必要的字段或表

六. 实战案例:订单查询性能优化

1. 问题场景

某电商平台订单查询接口响应缓慢,高峰期耗时超过 3 秒,数据库 CPU 使用率经常达到 90% 以上。该接口主要功能是根据用户 ID、订单状态、时间范围等条件查询订单列表,并返回订单明细和商品信息。

原始实现:

java 复制代码
<!-- OrderMapper.xml -->
<select id="selectOrders" resultType="OrderVO">
    SELECT 
        o.*, 
        oi.*, 
        p.name AS product_name, 
        p.price AS product_price
    FROM order o
    LEFT JOIN order_item oi ON o.id = oi.order_id
    LEFT JOIN product p ON oi.product_id = p.id
    WHERE 1=1
    <if test="userId != null">AND o.user_id = #{userId}</if>
    <if test="status != null">AND o.status = #{status}</if>
    <if test="startTime != null">AND o.create_time >= #{startTime}</if>
    <if test="endTime != null">AND o.create_time <= #{endTime}</if>
    ORDER BY o.create_time DESC
</select>

问题分析:

  • 使用SELECT *查询所有字段,包括大量不必要字段
  • 多表 JOIN 导致查询复杂,无法有效使用索引
  • 未分页,当订单数量大时返回数据过多
  • 动态条件未优化,可能导致索引失效

2. 优化步骤

步骤 1:SQL 重构与索引优化

精简查询字段,只返回必要信息

添加分页限制,避免大量数据返回

创建合适的索引:

java 复制代码
-- 订单表索引:优化查询条件和排序
CREATE INDEX idx_user_status_time ON `order`(user_id, status, create_time);
-- 订单项索引:优化关联查询
CREATE INDEX idx_order_id ON order_item(order_id);

优化后的 SQL:

java 复制代码
<select id="selectOrders" resultType="OrderVO">
    SELECT 
        o.id, o.order_no, o.total_amount, o.create_time, o.status,
        oi.id AS item_id, oi.product_id, oi.quantity, oi.unit_price,
        p.name AS product_name
    FROM order o
    LEFT JOIN order_item oi ON o.id = oi.order_id
    LEFT JOIN product p ON oi.product_id = p.id
    <where>
        <if test="userId != null">AND o.user_id = #{userId}</if>
        <if test="status != null">AND o.status = #{status}</if>
        <if test="startTime != null">AND o.create_time >= #{startTime}</if>
        <if test="endTime != null">AND o.create_time <= #{endTime}</if>
    </where>
    ORDER BY o.create_time DESC
    LIMIT #{offset}, #{pageSize}
</select>

步骤 2:解决 N+1 查询问题

将订单主表和订单项查询分离,先查询主表,再批量查询订单项:

java 复制代码
@Service
public class OrderServiceImpl implements OrderService {
    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private OrderItemMapper itemMapper;

    @Override
    public PageResult<OrderVO> queryOrders(OrderQuery query) {
        // 1. 查询订单主表(分页)
        int total = orderMapper.countOrders(query);
        if (total == 0) {
            return PageResult.empty();
        }

        List<OrderVO> orderVOs = orderMapper.selectOrderMains(query);
        List<Long> orderIds = orderVOs.stream()
            .map(OrderVO::getId)
            .collect(Collectors.toList());

        // 2. 批量查询订单项(1次查询)
        List<OrderItemVO> items = itemMapper.selectByOrderIds(orderIds);

        // 3. 内存中组装数据
        Map<Long, List<OrderItemVO>> itemMap = items.stream()
            .collect(Collectors.groupingBy(OrderItemVO::getOrderId));

        orderVOs.forEach(vo -> vo.setItems(itemMap.getOrDefault(vo.getId(), Collections.emptyList())));

        return new PageResult<>(total, orderVOs);
    }
}

步骤 3:添加二级缓存

对于用户查询自己的订单历史,添加二级缓存减少数据库访问:

java 复制代码
<!-- OrderMapper.xml -->
<mapper namespace="com.example.mapper.OrderMapper">
    <!-- 配置二级缓存,过期时间30分钟 -->
    <cache type="org.mybatis.caches.redis.RedisCache"
           eviction="LRU"
           flushInterval="1800000"
           size="5000"/>

    <!-- 查询订单主表,使用缓存 -->
    <select id="selectOrderMains" resultType="OrderVO" useCache="true">
        SELECT id, order_no, total_amount, create_time, status
        FROM order
        <where>
            <if test="userId != null">AND user_id = #{userId}</if>
            <!-- 其他条件 -->
        </where>
        ORDER BY create_time DESC
        LIMIT #{offset}, #{pageSize}
    </select>

    <!-- 写操作刷新缓存 -->
    <update id="updateOrderStatus" flushCache="true">
        UPDATE order SET status = #{status} WHERE id = #{id}
    </update>
</mapper>

步骤 4:执行计划验证

使用 EXPLAIN 分析优化后的 SQL:

java 复制代码
EXPLAIN
SELECT id, order_no, total_amount, create_time, status
FROM order
WHERE user_id = 1001 AND status = 2
ORDER BY create_time DESC
LIMIT 0, 10;

优化后执行计划关键指标:

java 复制代码
type: ref(使用了索引)
key: idx_user_status_time(使用了预期的联合索引)
rows: 10(扫描行数少)
Extra: Using where; Using index; Using filesort(无全表扫描)

3. 优化效果对比

七. 监控与持续优化

1. 集成 SpringBoot Actuator 监控

通过 SpringBoot Actuator 监控 MyBatis 关键指标,及时发现性能问题:

添加依赖:

java 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

配置监控端点:

java 复制代码
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,mybatis
  metrics:
    tags:
      application: order-service
  endpoint:
    health:
      show-details: always

自定义 MyBatis 监控指标:

java 复制代码
@Component
public class MyBatisMetricsMonitor {
    private final MeterRegistry meterRegistry;

    public MyBatisMetricsMonitor(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        // 注册MyBatis执行时间指标
        Timer.builder("mybatis.execution.time")
            .description("MyBatis SQL execution time")
            .register(meterRegistry);
    }

    // 记录SQL执行时间
    public <T> T recordExecutionTime(Supplier<T> supplier, String mapper, String method) {
        Timer.Sample sample = Timer.start(meterRegistry);
        try {
            return supplier.get();
        } finally {
            sample.stop(Timer.builder("mybatis.execution.time")
                .tag("mapper", mapper)
                .tag("method", method)
                .register(meterRegistry));
        }
    }
}

2. 性能测试与基准对比

定期进行性能测试,建立基准线,确保优化效果持续:

使用 JMH 进行微基准测试:

java 复制代码
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
public class OrderMapperBenchmark {
    private SqlSession sqlSession;
    private OrderMapper orderMapper;
    private OrderQuery query;

    @Setup(Level.Trial)
    public void setup() {
        // 初始化MyBatis环境
        SqlSessionFactory factory = new SqlSessionFactoryBuilder()
            .build(Resources.getResourceAsStream("mybatis-config.xml"));
        sqlSession = factory.openSession();
        orderMapper = sqlSession.getMapper(OrderMapper.class);

        // 准备测试数据
        query = new OrderQuery();
        query.setUserId(1001L);
        query.setStatus(2);
        query.setPageSize(10);
    }

    @Benchmark
    public List<OrderVO> testSelectOrders() {
        return orderMapper.selectOrders(query);
    }

    @TearDown(Level.Trial)
    public void teardown() {
        sqlSession.close();
    }
}

关键测试指标:

  • 平均响应时间(Average Time)
  • 吞吐量(Throughput)
  • 内存分配(Allocation Rate)
  • 垃圾回收次数(GC Count)

3. 持续优化策略

性能优化是一个持续过程,需建立长效机制:

1、代码审查制度:

新增 SQL 必须附带 EXPLAIN 执行计划分析

禁止使用 SELECT * 和全表扫描

关联表数量不超过 3 张

2、定期索引优化:

每周分析慢查询日志,优化低效索引

使用 pt-index-usage 工具分析索引使用情况

移除长期未使用的冗余索引

3、缓存策略调整:

根据业务变化调整缓存过期时间

监控缓存命中率,低于 80% 需优化

定期清理缓存碎片

4、数据库扩容准备:

当单表数据量接近 1000 万时,准备分库分表

提前规划读写分离架构

建立数据归档机制,迁移历史数据

总结

MyBatis 性能优化是一项系统工程,需要从 SQL 编写、索引设计、缓存配置到执行计划分析全方位入手。本文通过实战案例展示了如何将一个响应缓慢的接口优化到毫秒级,核心在于:

1、减少数据库访问:通过合理的缓存策略和批量操作,降低数据库压力

2、提高查询效率:优化 SQL 结构,设计高效索引,避免全表扫描

3、避免性能陷阱:警惕 N+1 查询、索引失效、缓存穿透等常见问题

4、建立监控体系:通过监控指标和性能测试,持续发现并解决问题

性能优化没有银弹,需要结合具体业务场景,平衡开发效率和运行性能。我们应养成 "性能意识",在代码编写阶段就考虑性能影响,而非等到系统出现问题后再补救。

相关推荐
PawSQL4 小时前
智能SQL优化工具 PawSQL 月度更新 | 2025年10月
数据库·人工智能·sql·sql优化·pawsql
烤麻辣烫5 小时前
黑马程序员苍穹外卖(新手)Day1
java·数据库·spring boot·学习·mybatis
llxxyy卢5 小时前
SQL注入之堆叠及waf绕过注入(安全狗)
数据库·sql·安全
水冗水孚7 小时前
效能工具(九)之编写nodejs脚本使用get-video-duration批量读取视频时长,并生成sql语句修复数据库表字段值
sql·node.js
云枫晖7 小时前
Webpack系列-构建性能优化实战:从开发到生产
前端·webpack·性能优化
float_六七8 小时前
SQL中的NULL陷阱:为何=永远查不到空值
java·前端·sql
nvd118 小时前
GKE连接私有Cloud SQL疑难问题排查实录
数据库·sql
Dev7z8 小时前
MySQL 错误 1046 (3D000) 是因为在执行 SQL 语句时 没有选择当前数据库
数据库·sql·mysql
IT小哥哥呀9 小时前
MyBatis 性能优化指南:Mapper 映射、缓存与批量操作实战
缓存·性能优化·mybatis·数据库优化·批量插入·分布式系统·sql性能