数据量很大,分页查询很慢的优化方案?

本文探讨了在面对大量数据库记录时,如何通过分页查询优化数据检索速度。介绍了传统limit分页的局限性,以及使用子查询、ID限定、临时表等高级策略提升效率的方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

当需要从数据库查询的表有上万条记录的时候,一次性查询所有结果会变得很慢,特别是随着数据量的增加特别明显,这时需要使用分页查询。对于数据库分页查询,也有很多种方法和优化的点。下面简单说一下我知道的一些方法。

准备工作

为了对下面列举的一些优化进行测试,下面针对已有的一张表进行说明。

  • 表名:order_history
  • 描述:某个业务的订单历史表
  • 主要字段:unsigned int id,tinyint(4) int type
  • 字段情况:该表一共37个字段,不包含text等大型数据,最大为varchar(500),id字段为索引,且为递增。
  • 数据量:5709294
  • MySQL版本:5.7.16

线下找一张百万级的测试表可不容易,如果需要自己测试的话,可以写shell脚本什么的插入数据进行测试。

以下的 sql 所有语句执行的环境没有发生改变,下面是基本测试结果:

select count(*) from orders_history;

返回结果:5709294

三次查询时间分别为:

  • 8903 ms
  • 8323 ms
  • 8401 ms

一般分页查询

一般的分页查询使用简单的 limit 子句就可以实现。limit 子句声明如下:

SELECT * FROM table LIMIT [offset,] rows | rows OFFSET offset

LIMIT 子句可以被用于指定 SELECT 语句返回的记录数。需注意以下几点:

  • 第一个参数指定第一个返回记录行的偏移量,注意从0开始
  • 第二个参数指定返回记录行的最大数目
  • 如果只给定一个参数:它表示返回最大的记录行数目
  • 第二个参数为 -1 表示检索从某一个偏移量到记录集的结束所有的记录行
  • 初始记录行的偏移量是 0(而不是 1)

下面是一个应用实例:

select * from orders_history where type=8 limit 1000,10;

该条语句将会从表 orders_history 中查询offset: 1000开始之后的10条数据,也就是第1001条到第1010条数据(1001 <= id <= 1010)

数据表中的记录默认使用主键(一般为id)排序,上面的结果相当于:

select * from orders_history where type=8 order by id limit 10000,10;

三次查询时间分别为:

  • 3040 ms
  • 3040 ms
  • 3018 ms

针对这种查询方式,下面测试查询记录量对时间的影响:

select * from orders_history where type=8 limit 10000,1;
select * from orders_history where type=8 limit 10000,10;
select * from orders_history where type=8 limit 10000,100;
select * from orders_history where type=8 limit 10000,1000;
select * from orders_history where type=8 limit 10000,10000;

三次查询时间如下:

  • 查询1条记录:3072ms 3092ms 3002ms
  • 查询10条记录:3081ms 3077ms 3032ms
  • 查询100条记录:3118ms 3200ms 3128ms
  • 查询1000条记录:3412ms 3468ms 3394ms
  • 查询10000条记录:3749ms 3802ms 3696ms

另外我还做了十来次查询,从查询时间来看,基本可以确定,在查询记录量低于100时,查询时间基本没有差距,随着查询记录量越来越大,所花费的时间也会越来越多。

针对查询偏移量的测试:

select * from orders_history where type=8 limit 100,100;
select * from orders_history where type=8 limit 1000,100;
select * from orders_history where type=8 limit 10000,100;
select * from orders_history where type=8 limit 100000,100;
select * from orders_history where type=8 limit 1000000,100;

三次查询时间如下:

  • 查询100偏移:25ms 24ms 24ms
  • 查询1000偏移:78ms 76ms 77ms
  • 查询10000偏移:3092ms 3212ms 3128ms
  • 查询100000偏移:3878ms 3812ms 3798ms
  • 查询1000000偏移:14608ms 14062ms 14700ms

随着查询偏移的增大,尤其查询偏移大于10万以后,查询时间急剧增加。

这种分页查询方式会从数据库第一条记录开始扫描,所以越往后,查询速度越慢,而且查询的数据越多,也会拖慢总查询速度。

 

使用子查询优化

这种方式先定位偏移位置的 id,然后往后查询,这种方式适用于 id 递增的情况。

select * from orders_history where type=8 limit 100000,1;
select id from orders_history where type=8 limit 100000,1;
select * from orders_history where type=8 and 
id>=(select id from orders_history where type=8 limit 100000,1) 
limit 100;
select * from orders_history where type=8 limit 100000,100;

4条语句的查询时间如下:

  • 第1条语句:3674ms
  • 第2条语句:1315ms
  • 第3条语句:1327ms
  • 第4条语句:3710ms

针对上面的查询需要注意:

  • 比较第1条语句和第2条语句:使用 select id 代替 select * 速度增加了3倍
  • 比较第2条语句和第3条语句:速度相差几十毫秒
  • 比较第3条语句和第4条语句:得益于 select id 速度增加,第3条语句查询速度增加了3倍

这种方式相较于原始一般的查询方法,将会增快数倍。

 

使用 id 限定优化

这种方式假设数据表的id是连续递增的,则我们根据查询的页数和查询的记录数可以算出查询的id的范围,可以使用 id between and 来查询:

select * from orders_history where type=2 
and id between 1000000 and 1000100 limit 100;

查询时间:15ms 12ms 9ms

这种查询方式能够极大地优化查询速度,基本能够在几十毫秒之内完成。限制是只能使用于明确知道id的情况,不过一般建立表的时候,都会添加基本的id字段,这为分页查询带来很多便利。

还可以有另外一种写法:

select * from orders_history where id >= 1000001 limit 100;

当然还可以使用 in 的方式来进行查询,这种方式经常用在多表关联的时候进行查询,使用其他表查询的id集合,来进行查询:

select * from orders_history where id in
(select order_id from trade_2 where goods = 'pen')
limit 100;

这种 in 查询的方式要注意:某些 mysql 版本不支持在 in 子句中使用 limit。

 

使用临时表优化

这种方式已经不属于查询优化,这儿附带提一下。

对于使用 id 限定优化中的问题,需要 id 是连续递增的,但是在一些场景下,比如使用历史表的时候,或者出现过数据缺失问题时,可以考虑使用临时存储的表来记录分页的id,使用分页的id来进行 in 查询。这样能够极大的提高传统的分页查询速度,尤其是数据量上千万的时候。

 

关于数据表的id说明

一般情况下,在数据库中建立表的时候,强制为每一张表添加 id 递增字段,这样方便查询。

如果像是订单库等数据量非常庞大,一般会进行分库分表。这个时候不建议使用数据库的 id 作为唯一标识,而应该使用分布式的高并发唯一 id 生成器来生成,并在数据表中使用另外的字段来存储这个唯一标识。

使用先使用范围查询定位 id (或者索引),然后再使用索引进行定位数据,能够提高好几倍查询速度。即先 select id,然后再 select *;

<think>好的,我现在需要帮助用户解决在使用MyBatis处理大数据量时的分组查询问题。用户之前已经得到了一个基于内存分组的方案,但现在数据量很大,可能面临性能问题。我需要思考如何优化这个场景。 首先,用户提到数据量很大,直接查询所有数据到内存再进行分组会导致内存溢出和性能下降。之前的方案虽然灵活,但适用于数据量适中的情况。现在需要针对大数据量进行调整。 接下来,我需要考虑可能的优化方向。通常处理大数据量可以从几个方面入手:SQL层面的优化分页处理,使用游标,或者数据库的分组功能。但用户需要的是将结果按age分组为Map结构,每个键对应多个name和note。原来的方案是在应用层分组,现在可能需要改为在数据库层进行分组,减少传输数据量。 不过,SQL中的GROUP BY通常用于聚合函数,如COUNT、SUM等,但用户需要的是将同一age的name和note收集起来。这可能需要使用数据库特定的聚合函数,比如MySQL的JSON_ARRAYAGG或GROUP_CONCAT,PostgreSQL的json_agg等。这样可以在SQL中直接生成每个age对应的name和note列表,减少传输的数据量,因为每个age只会返回一行数据,而不是多行。 接下来需要验证这种方法的可行性。例如,在MySQL中使用GROUP_CONCAT将name和note拼接成字符串,然后在Java中解析。但这样处理可能比较复杂,尤其是如果note字段包含特殊字符,可能会导致解析错误。或者使用JSON_ARRAYAGG,将结果转为JSON数组,再利用MyBatis或Java的JSON库解析成对象列表。这种方法更结构化,但需要数据库支持JSON函数。 另外,用户可能不想依赖特定数据库的函数,因为不同数据库的实现不同。这时候需要权衡是否接受数据库特定的解决方案,或者是否有其他方法。 另一个思路是分页处理,即分批查询数据,逐步构建Map。但分页按age分组的话,可能需要更复杂的逻辑,因为同一age的数据可能分布在不同的中,难以有效分页。因此可能不太适用。 使用游标(Cursor)逐行处理数据,可以降低内存占用,但需要在Service层中逐步处理每条记录,并添加到Map中。这样虽然减少了内存压力,但处理时间可能会增加,需要根据具体情况评估。 接下来需要比较不同方法的优缺点。例如,使用数据库的聚合函数可以减少传输的数据量,但增加了SQL的复杂性和对特定数据库的依赖。而使用游标则保持SQL简单,但处理时间可能较长,但内存占用较低。 用户可能需要具体的实现步骤,因此需要给出两种方案的示例:一种是基于数据库聚合函数的,另一种是使用游标分页处理的。 在SQL方案中,比如MySQL,使用JSON_ARRAYAGG来聚合name和note。然后,在MyBatis中处理这个JSON字符串,转换为List<NameNoteDTO>。这需要数据库支持,并且MyBatis的ResultMap需要自定义TypeHandler来处理JSON转换。 在分页或游标方案中,每次取一定数量的数据,逐步处理并分组到Map中,避免一次性加载所有数据到内存。但需要注意分页的排序和效率问题,以及游标的使用方式。 最后,需要提醒用户根据实际情况选择方案,如果数据量极大,可能需要结合数据库分区、索引优化,甚至考虑使用分布式处理框架,但可能超出了MyBatis的范畴。 总结,用户的问题是如何在数据量大的情况下优化分组查询,需要提供基于SQL聚合函数和使用游标/分页两种方案,并解释各自的优缺点,帮助用户做出选择。</think>当数据量很大时,直接通过 `SELECT * FROM user` 查询全表数据并在内存中分组会导致 **性能瓶颈**(内存溢出、响应缓慢)。以下是针对大数据量优化方案: --- ### 方案一:**数据库层分组 + JSON 聚合(推荐)** 直接在 SQL 中完成分组和字段聚合,减少数据传输量,适合大数据量场景。 以 **MySQL 8.0+** 为例,使用 `JSON_ARRAYAGG` 函数将 `name` 和 `note` 组合成 JSON 数组: #### 1. 修改 SQL 查询 ```sql SELECT age, JSON_ARRAYAGG(JSON_OBJECT('name', name, 'note', note)) AS name_note_list FROM user GROUP BY age; ``` #### 2. 定义结果映射的 DTO ```java public class AgeGroupDTO { private Integer age; private List<NameNoteDTO> nameNoteList; // 对应 name_note_list 字段 // Getter 和 Setter } ``` #### 3. 自定义 MyBatis TypeHandler 将数据库返回的 JSON 字符串转换为 `List<NameNoteDTO>`: ```java public class JsonToListTypeHandler extends BaseTypeHandler<List<NameNoteDTO>> { private final ObjectMapper objectMapper = new ObjectMapper(); @Override public void setNonNullParameter(PreparedStatement ps, int i, List<NameNoteDTO> parameter, JdbcType jdbcType) { // 无需实现写入逻辑(仅查询) } @Override public List<NameNoteDTO> getNullableResult(ResultSet rs, String columnName) throws SQLException { return parseJson(rs.getString(columnName)); } private List<NameNoteDTO> parseJson(String json) { try { return objectMapper.readValue(json, new TypeReference<List<NameNoteDTO>>() {}); } catch (JsonProcessingException e) { throw new RuntimeException("JSON 解析失败", e); } } } ``` #### 4. Mapper XML 配置 ```xml <resultMap id="ageGroupResultMap" type="com.example.AgeGroupDTO"> <result column="age" property="age"/> <result column="name_note_list" property="nameNoteList" typeHandler="com.example.handler.JsonToListTypeHandler"/> </resultMap> <select id="selectGroupedByAge" resultMap="ageGroupResultMap"> SELECT age, JSON_ARRAYAGG(JSON_OBJECT('name', name, 'note', note)) AS name_note_list FROM user GROUP BY age </select> ``` #### 5. 最终效果 调用 `selectGroupedByAge` 会直接返回 `List<AgeGroupDTO>`,每个对象包含 `age` 和对应的 `nameNoteList`,无需内存分组。 转换后的 Map 结构与原需求一致,但性能大幅提升。 --- ### 方案二:**分页查询 + 流式处理** 如果数据库不支持 JSON 聚合(如旧版本 MySQL),可通过分页查询逐步加载数据并分组,减少单次内存占用。 #### 1. 分页查询 SQL ```xml <select id="selectAgeNameNoteByPage" resultType="com.example.User"> SELECT age, name, note FROM user LIMIT #{pageSize} OFFSET #{offset} </select> ``` #### 2. 分页分组逻辑 ```java public Map<Integer, List<NameNoteDTO>> getGroupedByAgeInPages(int pageSize) { Map<Integer, List<NameNoteDTO>> resultMap = new HashMap<>(); int offset = 0; while (true) { List<User> users = userMapper.selectAgeNameNoteByPage(pageSize, offset); if (users.isEmpty()) break; users.forEach(user -> { resultMap.computeIfAbsent(user.getAge(), k -> new ArrayList<>()) .add(new NameNoteDTO(user.getName(), user.getNote())); }); offset += pageSize; } return resultMap; } ``` --- ### 方案三:**游标(Cursor)流式处理** 通过 MyBatis 游标逐行读取数据,适合超大数据量(百万级),内存占用极低。 #### 1. Mapper 接口 ```java @Select("SELECT age, name, note FROM user") @Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = 1000) Cursor<User> selectAllWithCursor(); ``` #### 2. 流式处理 ```java public Map<Integer, List<NameNoteDTO>> getGroupedByAgeWithCursor() { try (Cursor<User> cursor = userMapper.selectAllWithCursor()) { return cursor.stream() .collect( Collectors.groupingBy( User::getAge, Collectors.mapping( user -> new NameNoteDTO(user.getName(), user.getNote()), Collectors.toList() ) ) ); } catch (IOException e) { throw new RuntimeException("游标处理失败", e); } } ``` --- ### 对比总结 | 方案 | 优点 | 缺点 | 适用场景 | |---------------------|-------------------------------|---------------------------------------|------------------------| | 数据库层 JSON 聚合 | 性能最优,传输数据量最小 | 依赖数据库 JSON 函数 | 支持 JSON 的数据库 | | 分页查询 | 兼容所有数据库 | 分页排序需优化,多次查询可能性能下降 | 中等数据量(万级) | | 游标流式处理 | 内存占用极低,单次查询 | 处理速度较慢,需注意游标超时 | 超大数据量(百万级) | --- ### 终极建议 1. **优先选择方案一**(数据库聚合),性能最佳。 2. 若数据库不支持 JSON 聚合,则用 **方案三(游标)** 处理超大数据。 3. 为 `age` 字段建立索引可大幅提升所有方案的性能: ```sql CREATE INDEX idx_age ON user(age); ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

槐序二十四

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值