MyBatis查询上万条数据优化方式
作者:RyFit
当 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条──│ │ │ │ │ │ ...... │ │ │ │ └──────────┘ └──────────┘ └──────────┘
| 参数 | 默认值 | 含义 |
|---|---|---|
| fetchSize | 10(Oracle)/ 不限制(MySQL) | JDBC 每次从数据库读取的记录数 |
| 增大效果 | — | 减少往返次数,提高吞吐量 |
| 减小效果 | — | 降低单次内存占用,适合流式处理 |
2.2 对比数据
| fetchSize | 1 万条往返次数 | 10 万条往返次数 |
|---|---|---|
| 10(默认) | 1000 次 | 10000 次 |
| 100 | 100 次 | 1000 次 |
| 1000 | 10 次 | 100 次 |
| 10000 | 1 次 | 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,十万级用分页,百万级用游标 + 索引优化,高频查询加缓存。
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。
