千万级数据分页查询,为啥越翻越慢

摘要:从一次"翻到第10000页需要30秒"的线上故障出发,深度剖析深分页的性能问题。通过LIMIT offset机制的原理图解、回表代价的计算、以及5种优化方案的完整代码和性能对比,揭秘为什么LIMIT 10000000, 20会扫描1000万行、以及如何用标签记录法、延迟关联、子查询优化等方案将查询时间从30秒降到0.1秒。配合时序图展示查询流程,给出不同场景下的最佳选型。


💥 翻车现场

周五下午,运营同学在群里@了哈吉米。

运营同学:@哈吉米 订单导出功能又卡死了!
哈吉米:什么情况?
运营同学:想导出最近的订单,翻到第10000页就一直转圈圈,等了5分钟都没反应!

哈吉米查看代码:

// 订单分页查询
@GetMapping("/order/page")
public Result pageOrders(Integer pageNum, Integer pageSize) {
    // pageNum=10000, pageSize=20
    int offset = (pageNum - 1) * pageSize;  // offset = 199980
    
    List<Order> orders = orderMapper.selectByPage(offset, pageSize);
    return Result.ok(orders);
}

Mapper

<select id="selectByPage" resultType="Order">
    SELECT * FROM `order` 
    ORDER BY create_time DESC 
    LIMIT #{offset}, #{pageSize}
</select>

实际SQL

SELECT * FROM `order` 
ORDER BY create_time DESC 
LIMIT 199980, 20;

-- 执行时间:32秒 😱

哈吉米:"卧槽,怎么这么慢?表才1000万数据啊!"

EXPLAIN分析:

EXPLAIN SELECT * FROM `order` ORDER BY create_time DESC LIMIT 199980, 20\G

*************************** 1. row ***************************
           type: index
            key: idx_create_time
           rows: 200000          ← 预估扫描20万行
          Extra: Using index

哈吉米:"明明走索引了,为什么还这么慢?"

下午,南北绿豆和阿西噶阿西来了。

南北绿豆:"这是深分页问题!LIMIT 199980, 20 需要扫描199980+20=20万行,然后扔掉前面的199980行!"
哈吉米:"???"
阿西噶阿西:"来,我画个图给你看。"


🤔 深分页的本质问题

LIMIT offset, size 的执行流程

sequenceDiagram
    participant Query as SQL查询
    participant Index as 索引idx_create_time
    participant PrimaryIndex as 主键索引
    participant Result as 结果集

    Note over Query: LIMIT 199980, 20
    Query->>Index: 1. 按create_time倒序扫描索引
    
    loop 扫描200000行
        Index->>Index: 读取第1行主键
        Index->>PrimaryIndex: 回表查询完整数据
        PrimaryIndex->>Index: 返回数据
        Note over Index: 第1行(扔掉)
        
        Index->>Index: 读取第2行主键
        Index->>PrimaryIndex: 回表查询完整数据
        Note over Index: 第2行(扔掉)
        
        Note over Index: ...扔掉199980行...
        
        Index->>Index: 读取第199981行主键
        Index->>PrimaryIndex: 回表查询完整数据
        PrimaryIndex->>Result: 第1条结果 ✅
        
        Note over Index: ...查询20行...
    end
    
    Result->>Query: 返回20条数据
    
    Note over Query,Result: 扫描了200000行<br/>回表了200000次<br/>只返回了20行

关键问题

LIMIT 199980, 20 的执行过程:
1. 按create_time倒序扫描索引
2. 回表199980次(查前面的数据)
3. 扔掉前面的199980行 ← 白白浪费
4. 继续回表20次(查真正需要的数据)
5. 返回这20行

总回表次数:200000次
有效数据:20行
浪费比例:99.99%

南北绿豆:"看到了吗?扫描了20万行,但只要20行,前面的199980行全部浪费!"

哈吉米:"卧槽,怪不得这么慢!"


为什么第1页很快,第10000页很慢?

测试对比

页数offset扫描行数执行时间
第1页0200.01秒
第100页198020000.05秒
第1000页19980200000.5秒
第10000页19998020000032秒

性能图

执行时间(秒)
   |
35 |                                    *  (第10000页)
30 |
25 |
20 |
15 |
10 |
 5 |                          *  (第1000页)
 0 |___*_____*________________|____________页数
     1    100               1000        10000

阿西噶阿西:"这就是深分页问题!offset越大,扫描的行数越多,性能越差!"


🚀 优化方案1:标签记录法(推荐⭐⭐⭐⭐⭐)

原理

核心思路:不用offset,而是记录上一页的最后一条记录ID,用WHERE id > last_id。

-- ❌ 传统分页
SELECT * FROM `order` ORDER BY id DESC LIMIT 199980, 20;

-- ✅ 标签记录法
SELECT * FROM `order` WHERE id < 上一页最后一条的id ORDER BY id DESC LIMIT 20;

代码实现

// 前端传参
@Data
public class PageRequest {
    private Long lastId;      // 上一页最后一条记录的ID
    private Integer pageSize; // 每页条数
}

// 后端查询
@GetMapping("/order/page")
public Result pageOrders(PageRequest request) {
    List<Order> orders;
    
    if (request.getLastId() == null) {
        // 第1页
        orders = orderMapper.selectFirstPage(request.getPageSize());
    } else {
        // 后续页
        orders = orderMapper.selectNextPage(request.getLastId(), request.getPageSize());
    }
    
    return Result.ok(orders);
}

Mapper

<!-- 第1页 -->
<select id="selectFirstPage" resultType="Order">
    SELECT * FROM `order` 
    ORDER BY id DESC 
    LIMIT #{pageSize}
</select>

<!-- 后续页 -->
<select id="selectNextPage" resultType="Order">
    SELECT * FROM `order` 
    WHERE id &lt; #{lastId}  <!-- 关键:WHERE id < last_id -->
    ORDER BY id DESC 
    LIMIT #{pageSize}
</select>

性能对比

-- 传统分页(第10000页)
SELECT * FROM `order` ORDER BY id DESC LIMIT 199980, 20;
-- 执行时间:32秒
-- 扫描行数:200000

-- 标签记录法
SELECT * FROM `order` WHERE id < 800020 ORDER BY id DESC LIMIT 20;
-- 执行时间:0.02秒
-- 扫描行数:20

-- 性能提升:1600倍!

EXPLAIN对比

EXPLAIN SELECT * FROM `order` WHERE id < 800020 ORDER BY id DESC LIMIT 20\G

type: range        ← 范围查询
key: PRIMARY
rows: 20           ← 只扫描20行
Extra: Using where

优缺点

优点

  • ✅ 性能极好(不管翻到第几页,都是0.02秒)
  • ✅ 扫描行数固定(只扫描pageSize行)

缺点

  • ❌ 只能向后翻页(不能跳页)
  • ❌ 不能显示总页数
  • ❌ 前端需要传递lastId

适用场景

  • 移动端列表(下拉加载更多)
  • 时间线(微博、朋友圈)
  • 消息列表

🚀 优化方案2:延迟关联(推荐⭐⭐⭐⭐)

原理

核心思路:先用覆盖索引查出ID,再回表查询完整数据。

-- ❌ 传统分页(回表20万次)
SELECT * FROM `order` ORDER BY create_time DESC LIMIT 199980, 20;

-- ✅ 延迟关联(只回表20次)
SELECT o.* FROM `order` o
INNER JOIN (
  SELECT id FROM `order` ORDER BY create_time DESC LIMIT 199980, 20
) t ON o.id = t.id;

原理图解

传统分页

1. 扫描索引idx_create_time,读取200000个主键
2. 回表200000次
3. 扔掉前面199980行
4. 返回20行

总IO:200000次回表

延迟关联

1. 子查询:扫描索引idx_create_time,读取200000个主键(覆盖索引,不回表)
2. 扔掉前面199980行
3. 返回20个主键ID
4. 主查询:拿着20个主键回表20次
5. 返回20行

总IO:20次回表

性能提升:200000 / 20 = 10000倍!

性能对比

-- 传统分页
SELECT * FROM `order` ORDER BY create_time DESC LIMIT 199980, 20;
-- 执行时间:32秒

-- 延迟关联
SELECT o.* FROM `order` o
INNER JOIN (
  SELECT id FROM `order` ORDER BY create_time DESC LIMIT 199980, 20
) t ON o.id = t.id;
-- 执行时间:2.1秒

-- 性能提升:15倍

优缺点

优点

  • ✅ 性能提升显著(10-20倍)
  • ✅ 支持跳页
  • ✅ 可以显示总页数

缺点

  • ⚠️ SQL稍复杂
  • ⚠️ 仍然需要扫描offset+size行(比方案1慢)

适用场景

  • PC端列表(需要跳页)
  • 后台管理系统

🚀 优化方案3:二次查询(推荐⭐⭐⭐)

原理

核心思路:先查ID范围,再查数据。

-- 第1次查询:只查ID(覆盖索引,快)
SELECT id FROM `order` ORDER BY create_time DESC LIMIT 199980, 1;
-- 假设返回:id = 800020

-- 第2次查询:根据ID范围查询
SELECT * FROM `order` 
WHERE id <= 800020 
ORDER BY id DESC 
LIMIT 20;

代码实现

@Service
public class OrderService {
    
    public List<Order> pageOrders(Integer pageNum, Integer pageSize) {
        int offset = (pageNum - 1) * pageSize;
        
        // 第1次查询:获取起始ID(覆盖索引)
        Long startId = orderMapper.selectIdByOffset(offset);
        
        if (startId == null) {
            return Collections.emptyList();
        }
        
        // 第2次查询:根据ID范围查询
        return orderMapper.selectByIdRange(startId, pageSize);
    }
}

Mapper

<!-- 第1次查询:只查ID -->
<select id="selectIdByOffset" resultType="Long">
    SELECT id FROM `order` 
    ORDER BY create_time DESC 
    LIMIT #{offset}, 1
</select>

<!-- 第2次查询:范围查询 -->
<select id="selectByIdRange" resultType="Order">
    SELECT * FROM `order` 
    WHERE id &lt;= #{startId} 
    ORDER BY id DESC 
    LIMIT #{pageSize}
</select>

性能对比

-- 传统分页
SELECT * FROM `order` ORDER BY create_time DESC LIMIT 199980, 20;
-- 执行时间:32秒

-- 二次查询
-- 第1次:0.8秒
-- 第2次:0.02秒
-- 总计:0.82秒

-- 性能提升:39倍

🚀 优化方案4:游标分页(适合数据导出)

原理

核心思路:用流式查询,不一次性加载所有数据。

@Mapper
public interface OrderMapper {
    
    // 流式查询(fetchSize控制每次取多少行)
    @Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = 1000)
    @Select("SELECT * FROM `order` ORDER BY id")
    void streamOrders(ResultHandler<Order> handler);
}

@Service
public class OrderExportService {
    
    public void exportOrders(OutputStream outputStream) {
        // 流式处理(不会一次性加载到内存)
        orderMapper.streamOrders(resultContext -> {
            Order order = resultContext.getResultObject();
            // 写入Excel或CSV
            writeToExcel(outputStream, order);
        });
    }
}

性能对比

方案内存占用执行时间适用场景
传统分页20万条 × 3KB = 600MB32秒❌ 不适合
游标分页1000条 × 3KB = 3MB8秒✅ 数据导出

优点

  • ✅ 内存占用低
  • ✅ 适合大数据量导出

缺点

  • ❌ 只能向前遍历(不能跳页)
  • ❌ 不适合用户翻页

🚀 优化方案5:ES + MySQL混合(终极方案⭐⭐⭐⭐⭐)

原理

核心思路:搜索走ES,详情走MySQL。

架构:
用户搜索 → ES(返回ID列表) → MySQL(根据ID批量查询详情)

代码实现

@Service
public class OrderSearchService {
    
    @Autowired
    private ElasticsearchClient esClient;
    
    @Autowired
    private OrderMapper orderMapper;
    
    public PageResult<Order> searchOrders(String keyword, Integer pageNum, Integer pageSize) {
        // 1. ES搜索(快)
        SearchResponse<OrderES> response = esClient.search(s -> s
            .index("order")
            .from((pageNum - 1) * pageSize)
            .size(pageSize)
            .query(q -> q
                .match(m -> m
                    .field("order_no")
                    .query(keyword)
                )
            )
            .sort(sort -> sort
                .field(f -> f.field("create_time").order(SortOrder.Desc))
            ), OrderES.class
        );
        
        // 2. 获取ID列表
        List<Long> ids = response.hits().hits().stream()
            .map(hit -> hit.source().getId())
            .collect(Collectors.toList());
        
        if (ids.isEmpty()) {
            return PageResult.empty();
        }
        
        // 3. MySQL批量查询详情(IN查询,走主键索引)
        List<Order> orders = orderMapper.selectByIds(ids);
        
        // 4. 按ES返回的顺序排序
        Map<Long, Order> orderMap = orders.stream()
            .collect(Collectors.toMap(Order::getId, o -> o));
        List<Order> sortedOrders = ids.stream()
            .map(orderMap::get)
            .collect(Collectors.toList());
        
        return PageResult.of(sortedOrders, response.hits().total().value());
    }
}

性能对比

方案第1页第10000页搜索能力
MySQL LIMIT0.01秒32秒
ES + MySQL0.05秒0.08秒

优点

  • ✅ 深分页性能稳定(不管第几页都是0.1秒内)
  • ✅ 搜索能力强(全文搜索、模糊搜索)
  • ✅ 支持跳页

缺点

  • ❌ 架构复杂(需要ES)
  • ❌ 数据同步(MySQL → ES)

适用场景

  • 电商商品搜索
  • 订单搜索
  • 日志查询

📊 五种方案完整对比

方案第1页第10000页支持跳页显示总数复杂度推荐指数
传统LIMIT0.01秒32秒
标签记录法0.01秒0.02秒⭐⭐⭐⭐⭐⭐⭐
延迟关联0.01秒2.1秒⭐⭐⭐⭐⭐⭐
二次查询0.01秒0.82秒⭐⭐⭐⭐⭐
ES + MySQL0.05秒0.08秒⭐⭐⭐⭐⭐⭐⭐⭐⭐

🎯 方案选型指南

场景1:移动端列表(下拉加载) → 标签记录法

// 前端
let lastId = null;

function loadMore() {
    axios.get('/order/page', {
        params: { lastId: lastId, pageSize: 20 }
    }).then(resp => {
        // 追加数据
        orders.push(...resp.data);
        // 更新lastId
        lastId = resp.data[resp.data.length - 1].id;
    });
}

场景2:PC端列表(需要跳页) → 延迟关联

-- 适合后台管理系统
SELECT o.* FROM `order` o
INNER JOIN (
  SELECT id FROM `order` ORDER BY create_time DESC LIMIT #{offset}, #{pageSize}
) t ON o.id = t.id;

场景3:数据导出 → 游标分页

// 流式导出,不占内存
orderMapper.streamOrders(resultContext -> {
    Order order = resultContext.getResultObject();
    writeToExcel(order);
});

场景4:搜索功能 → ES + MySQL

ES搜索 → 返回ID → MySQL查详情

🎓 面试标准答案

题目:深分页为什么慢?如何优化?

答案

为什么慢

LIMIT offset, size 需要扫描 offset + size 行,然后扔掉前面的 offset 行。

例如:LIMIT 199980, 20

  • 扫描:200000行
  • 回表:200000次
  • 返回:20行
  • 浪费:99.99%

优化方案

  1. 标签记录法(WHERE id > last_id)

    • 性能最好(0.02秒)
    • 只能向后翻页
  2. 延迟关联(子查询先查ID)

    • 减少回表次数
    • 支持跳页
  3. 二次查询(先查起始ID)

    • 性能中等
    • 支持跳页
  4. ES + MySQL(搜索+详情)

    • 性能稳定
    • 架构复杂

推荐方案

  • 移动端:标签记录法
  • PC端:延迟关联
  • 搜索:ES + MySQL

🎉 结束语

晚上9点,哈吉米把订单列表改成了标签记录法。

哈吉米:"从32秒优化到0.02秒,性能提升1600倍!"

南北绿豆:"对,深分页问题很常见,标签记录法是最优解。"

阿西噶阿西:"记住:不用offset,用WHERE id > last_id,性能提升几千倍!"

哈吉米:"还有延迟关联,先查ID再回表,也能大幅减少回表次数。"

南北绿豆:"对,根据场景选方案,没有银弹!"


记忆口诀

深分页回表多,offset越大越慢
标签记录用WHERE,性能提升上千倍
延迟关联先查ID,回表次数大减少
ES混合终极解,搜索分页都不愁


希望这篇文章能帮你彻底搞定深分页问题!记住:能用WHERE id > last_id就别用LIMIT offset!💪