java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > MyBatis查询上万条数据优化

MyBatis查询上万条数据优化方式

作者:RyFit

这篇文章主要介绍了MyBatis查询上万条数据优化方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教

当 MyBatis 查询数据量超过万条时,响应速度急剧下降。本文深入分析 fetchSize 原理,并给出 6 种优化方案,覆盖 SQL 层面、JDBC 层面和架构层面。

一、问题现象

1.1 典型场景

// 查询上万条数据,响应缓慢
List<Map<String, Object>> list = userMapper.queryLargeData();
// 调用耗时:5s ~ 30s+
数据量默认配置耗时优化后耗时
1 万条~3s~0.3s
10 万条~30s~2s
100 万条超时/OOM~15s(需流式处理)

1.2 根因分析

MyBatis 默认通过 JDBC 取数据时,默认每次只取 10 条(fetchSize = 10)。

这意味着:

查询 1 万条数据 → 客户端与数据库往返 1000 次
查询 10 万条数据 → 客户端与数据库往返 10000 次

每次网络往返的延迟累积,导致查询耗时呈线性增长。

二、fetchSize 原理

2.1 JDBC 数据读取机制

┌──────────┐          ┌──────────┐          ┌──────────┐
│  客户端   │ ──query──▶│  数据库   │          │  数据库   │
│ (MyBatis)│ ◀──10条──│          │          │          │
│          │ ──next──▶│          │          │          │
│          │ ◀──10条──│          │          │          │
│          │  ......  │          │          │          │
└──────────┘          └──────────┘          └──────────┘
参数默认值含义
fetchSize10(Oracle)/ 不限制(MySQL)JDBC 每次从数据库读取的记录数
增大效果减少往返次数,提高吞吐量
减小效果降低单次内存占用,适合流式处理

2.2 对比数据

fetchSize1 万条往返次数10 万条往返次数
10(默认)1000 次10000 次
100100 次1000 次
100010 次100 次
100001 次10 次

结论: fetchSize 设为 10000 时,1 万条数据仅需 1 次往返,效率提升 1000 倍。

三、方案一:设置 fetchSize(最直接)

3.1 XML Mapper 配置

<select> 标签上添加 fetchSize 属性:

<!-- 查询上万条数据,设置 fetchSize 为 10000 -->
<select id="queryJhbms" fetchSize="10000" resultType="map">
    SELECT * FROM your_table
    WHERE status = 'ACTIVE'
</select>

3.2 注解方式

@Options(fetchSize = 10000)
@Select("SELECT * FROM your_table WHERE status = 'ACTIVE'")
List<Map<String, Object>> queryLargeData();

3.3 MyBatis-Plus 全局配置

mybatis-plus:
  configuration:
    default-fetch-size: 10000

或者在 Java 配置中:

@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
    MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean();
    factoryBean.setDataSource(dataSource);
    
    org.apache.ibatis.session.Configuration configuration = 
        new org.apache.ibatis.session.Configuration();
    configuration.setDefaultFetchSize(10000);
    factoryBean.setConfiguration(configuration);
    
    return factoryBean.getObject();
}

3.4 注意事项

关注点说明
内存占用fetchSize 越大,单次内存消耗越高,避免超过 JVM 堆内存
推荐值一般不超过 10000,建议 1000~5000 之间
数据库差异Oracle 默认 10,MySQL 驱动默认不限制(全量读取)
大字段影响包含 BLOB/TEXT 字段时,适当降低 fetchSize

四、方案二:分页查询(推荐,通用)

4.1 MyBatis-Plus 分页

// 分页查询,每页 1000 条
public void queryByPage() {
    int pageSize = 1000;
    int current = 1;
    long total;
    
    do {
        Page<Map<String, Object>> page = new Page<>(current, pageSize);
        Page<Map<String, Object>> result = userMapper.selectPage(page, null);
        
        List<Map<String, Object>> records = result.getRecords();
        // 处理当前页数据
        processBatch(records);
        
        total = result.getTotal();
        current++;
    } while ((current - 1) * pageSize < total);
}

4.2 手写分页 SQL

<select id="queryByPage" resultType="map">
    SELECT * FROM your_table
    ORDER BY id
    LIMIT #{offset}, #{pageSize}
</select>
public void queryByPageManual() {
    int pageSize = 1000;
    int offset = 0;
    List<Map<String, Object>> batch;
    
    do {
        batch = userMapper.queryByPage(offset, pageSize);
        processBatch(batch);
        offset += pageSize;
    } while (batch.size() == pageSize);
}

4.3 分页 vs fetchSize 对比

方案优点缺点
fetchSize一次 SQL,减少数据库负载内存消耗集中
分页查询内存可控,适合大数据量多次 SQL,总耗时可能更长

五、方案三:游标/流式查询(适合大数据量导出)

5.1 MyBatis 游标查询

@Select("SELECT * FROM your_table WHERE status = 'ACTIVE'")
@Options(fetchSize = Integer.MIN_VALUE)  // MySQL 流式读取
Cursor<Map<String, Object>> queryLargeDataCursor();
// 使用游标逐条处理,内存友好
try (Cursor<Map<String, Object>> cursor = userMapper.queryLargeDataCursor()) {
    cursor.forEach(row -> {
        // 逐行处理,内存仅保留一条数据
        processRow(row);
    });
}

5.2 MyBatis-Plus 流式查询

@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) {
    // ... 配置
    configuration.setDefaultFetchSize(1000);  // 不影响游标模式
    return factoryBean.getObject();
}
// 使用 MyBatis-Plus 的流式查询
public void streamQuery() {
    userMapper.selectList(
        new LambdaQueryWrapper<User>()
            .eq(User::getStatus, "ACTIVE"),
        resultContext -> {
            // 每行回调处理
            User user = resultContext.getResultObject();
            processUser(user);
        }
    );
}

5.3 MySQL 游标的特殊要求

MySQL 使用游标需要设置:

@Options(fetchSize = Integer.MIN_VALUE)  // 告诉 MySQL 驱动使用流式

或者 JDBC URL 配置:

jdbc:mysql://localhost:3306/db?useCursorFetch=true&defaultFetchSize=1000

六、方案四:SQL 层面优化

6.1 只查询必要字段

<!-- ❌ 不推荐:SELECT * -->
<select id="queryAll" resultType="map">
    SELECT * FROM your_table
</select>
<!-- ✅ 推荐:只查需要的字段 -->
<select id="queryNeeded" resultType="map">
    SELECT id, name, status, create_time 
    FROM your_table
</select>

6.2 使用索引覆盖

-- 确保查询的字段都在索引中,避免回表
-- 创建联合索引
CREATE INDEX idx_status_create ON your_table(status, create_time) INCLUDE (id, name);

-- 查询时只走索引,不访问表数据
SELECT id, name, status, create_time 
FROM your_table 
WHERE status = 'ACTIVE'
ORDER BY create_time;

6.3 避免 JOIN 过多表

<!-- ❌ 多表 JOIN 全量查询 -->
<select id="queryWithJoins" resultType="map">
    SELECT a.*, b.*, c.*
    FROM table_a a
    LEFT JOIN table_b b ON a.id = b.a_id
    LEFT JOIN table_c c ON a.id = c.a_id
    WHERE a.status = 'ACTIVE'
</select>
<!-- ✅ 拆分为多次查询 -->
<select id="queryMain" resultType="map">
    SELECT * FROM table_a WHERE status = 'ACTIVE'
</select>
<!-- 然后在业务层组装 -->

七、方案五:数据库端优化

7.1 合理使用索引

-- 查看查询计划
EXPLAIN SELECT * FROM your_table WHERE status = 'ACTIVE' ORDER BY create_time;

-- 关键指标
-- type: ALL(全表扫描)→ ref/range(索引扫描)
-- rows: 扫描行数(越小越好)
-- Extra: Using filesort(需要优化排序)

7.2 分区表

对于超大规模数据(百万级以上),考虑表分区:

-- 按时间范围分区
CREATE TABLE your_table (
    id BIGINT,
    name VARCHAR(100),
    create_time DATETIME
) PARTITION BY RANGE (YEAR(create_time)) (
    PARTITION p2022 VALUES LESS THAN (2023),
    PARTITION p2023 VALUES LESS THAN (2024),
    PARTITION p2024 VALUES LESS THAN (2025),
    PARTITION p_future VALUES LESS THAN MAXVALUE
);

7.3 批量处理代替逐条操作

-- ❌ 逐条 UPDATE(N 次 SQL)
UPDATE table SET status = 'DONE' WHERE id = 1;
UPDATE table SET status = 'DONE' WHERE id = 2;
-- ... N 次

-- ✅ 批量 UPDATE(1 次 SQL)
UPDATE table SET status = 'DONE' WHERE id IN (1, 2, 3, ..., 1000);

八、方案六:异步 + 缓存策略

8.1 异步查询

@Service
public class ReportService {
    
    @Async
    public CompletableFuture<List<Map<String, Object>>> generateReportAsync() {
        List<Map<String, Object>> data = userMapper.queryLargeData();
        return CompletableFuture.completedFuture(data);
    }
}

8.2 缓存中间结果

@Service
public class CachedQueryService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // 缓存查询结果,设置过期时间
    public List<Map<String, Object>> queryWithCache() {
        String cacheKey = "large:data:active";
        
        // 1. 尝试从缓存获取
        List<Map<String, Object>> cached = (List<Map<String, Object>>) 
            redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached;
        }
        
        // 2. 缓存未命中,查询数据库
        List<Map<String, Object>> data = userMapper.queryLargeData();
        
        // 3. 写入缓存,设置 5 分钟过期
        redisTemplate.opsForValue().set(cacheKey, data, 5, TimeUnit.MINUTES);
        
        return data;
    }
}

8.3 数据预热

@Component
public class DataWarmer implements CommandLineRunner {
    
    @Autowired
    private CachedQueryService cachedQueryService;
    
    @Override
    public void run(String... args) {
        // 应用启动时预热数据
        cachedQueryService.queryWithCache();
        log.info("Large data cache warmed up");
    }
}

九、方案对比与推荐

方案适用场景性能提升实现难度风险
① fetchSize万级数据,单次查询★★★★★★☆☆☆☆内存占用增加
② 分页查询任意数据量★★★★☆★★☆☆☆多次 SQL 开销
③ 游标查询十万级以上导出★★★★☆★★★☆☆连接长时间占用
④ SQL 优化所有查询★★★☆☆★★☆☆☆索引维护成本
⑤ 数据库优化长期稳定业务★★★☆☆★★★★☆架构改造成本高
⑥ 异步+缓存读多写少场景★★★★★★★★☆☆缓存一致性

推荐最佳实践流程

┌──────────────┐
│  查询数据量大  │
└──────┬───────┘
       ▼
┌──────────────────┐
│  是否需要实时数据? │
└──────┬──────┬─────┘
       │      │
    是(实时)  否(缓存)
       │      │
       ▼      ▼
┌────────────┐ ┌────────────┐
│ 万级数据:  │ │ 缓存预热   │
│ fetchSize  │ │ + 异步刷新 │
│ = 5000     │ └────────────┘
├────────────┤
│ 十万级数据: │
│ 游标流式    │
├────────────┤
│ 百万级数据: │
│ 分页+索引   │
└────────────┘

十、常见问题 FAQ

Q:fetchSize 设为 100000 会不会更快?

A:不一定。过大可能导致内存溢出(OOM),建议根据单行数据大小估算:

内存占用 ≈ fetchSize × 单行数据大小
例如:fetchSize=10000, 单行1KB → 约 10MB 内存占用

Q:MySQL 默认 fetchSize 是多少?

A:MySQL JDBC 驱动默认不限制,一次读取全部结果到客户端内存。如果不设 fetchSize,MySQL 反而可能更容易 OOM。

Q:游标查询有什么缺点?

A:游标会长时间占用数据库连接,如果处理慢可能导致连接超时。使用后务必关闭 Cursor(建议 try-with-resources)。

Q:分页查询深度分页(OFFSET 大)慢怎么办?

A:改用游标分页(基于上次查询的最后一个 ID):

-- ❌ 传统分页(深度分页慢)
SELECT * FROM table ORDER BY id LIMIT 100000, 10;
-- ✅ 游标分页(走索引)
SELECT * FROM table WHERE id > 100000 ORDER BY id LIMIT 10;

Q:MyBatis-Plus 的分页和 fetchSize 能一起用吗?

A:可以,但分页本身已经控制了数据量,fetchSize 作用不大。一般分页不设 fetchSize,大批量导出才设。

十一、总结

核心要点说明
默认 10 条往返JDBC 默认 fetchSize=10,万条数据往返 1000 次
设 fetchSize万级数据最直接,推荐 5000~10000
分页查询通用方案,内存友好
游标流式十万级以上数据导出首选
SQL 优化覆盖索引、避免 SELECT *、减少 JOIN
缓存策略读多写少场景,结合 Redis 缓存中间结果

一句话总结:

万级数据用 fetchSize,十万级用分页,百万级用游标 + 索引优化,高频查询加缓存。

以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

您可能感兴趣的文章:
阅读全文