和你彻底讲清楚数据库深分页问题

摘要:从一次"翻到第5000页需要等待45秒"的用户投诉出发,深度剖析MySQL深分页的性能陷阱。通过LIMIT offset原理图解、回表代价的精确计算、以及5种优化方案的压测对比,揭秘为什么offset越大查询越慢、MySQL到底做了什么、以及如何将查询时间从45秒优化到0.05秒。配合时序图展示查询流程,对比电商、社交、后台管理等不同场景的最佳方案选型。


💥 翻车现场

周三下午,运营同学在群里发了一张截图。

运营同学:@哈吉米 后台订单导出功能卡死了!
截图内容:订单列表页面,进度条卡在"加载中...",已经等了3分钟。

哈吉米查看SQL日志:

SELECT * FROM `order` 
WHERE status = 1 
ORDER BY create_time DESC 
LIMIT 500000, 100;

-- 执行时间:45.23秒
-- 扫描行数:500100行

哈吉米:"卧槽,第5000页的查询要45秒?第1页才0.01秒啊!"

查看表数据量:

SELECT COUNT(*) FROM `order`;
-- 结果:1200万行

哈吉米:"1200万数据,分页查询第5000页就这么慢?"

用EXPLAIN分析:

EXPLAIN SELECT * FROM `order` 
WHERE status = 1 
ORDER BY create_time DESC 
LIMIT 500000, 100\G

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: order
         type: index
          key: idx_create_time
      key_len: 5
          ref: NULL
         rows: 500100          ← 需要扫描50万行
        Extra: Using where

哈吉米:"明明走索引了,为什么还要扫描50万行?"

南北绿豆和阿西噶阿西来了。

南北绿豆:"这就是深分页问题!LIMIT 500000, 100 的本质是:扫描50万行,扔掉49.98万行,只返回100行。"
哈吉米:"???"
阿西噶阿西:"来,我给你彻底讲清楚MySQL到底做了什么。"


🤔 MySQL到底做了什么?—— LIMIT的执行原理

LIMIT offset, size的执行流程

阿西噶阿西在白板上画了一个详细的流程图。

SELECT * FROM `order` ORDER BY create_time DESC LIMIT 500000, 100;

MySQL的执行步骤:

步骤1:扫描索引idx_create_time(倒序)
    ↓
读取第1行的主键:id=1000001
    ↓
回表:根据主键到聚簇索引查询完整数据
    ↓
【扔掉】第1行
    ↓
读取第2行的主键:id=1000002
    ↓
回表:查询完整数据
    ↓
【扔掉】第2行
    ↓
... 循环50万次 ...
    ↓
读取第500001行的主键:id=500001
    ↓
回表:查询完整数据
    ↓
【保留】第1条结果 ✅
    ↓
... 再查询100次 ...
    ↓
返回100条数据

总计:
- 扫描索引:500100次
- 回表查询:500100次
- 扔掉的数据:500000行
- 返回的数据:100行
- 浪费比例:99.98%

详细时序图

sequenceDiagram
    participant Query as SQL查询
    participant Index as 二级索引(create_time)
    participant Primary as 主键索引
    participant Result as 结果集(只要100行)

    Note over Query: LIMIT 500000, 100
    
    loop 扫描前500000行(丢弃)
        Query->>Index: 1. 读取第N行的主键
        Index->>Primary: 2. 回表(根据主键查完整数据)
        Primary->>Query: 3. 返回完整数据
        Query->>Query: 4. 丢弃这行(offset内)
    end
    
    rect rgb(255, 182, 193)
        Note over Query,Primary: 已经回表了500000次<br/>全部丢弃!
    end
    
    loop 扫描后100行(保留)
        Query->>Index: 5. 读取第500001-500100行的主键
        Index->>Primary: 6. 回表
        Primary->>Result: 7. 加入结果集
    end
    
    Result->>Query: 8. 返回100条数据
    
    Note over Query,Result: 总计回表:500100次<br/>有效数据:100行<br/>浪费比例:99.98%

南北绿豆:"看到了吗?关键问题是:扫描了50万行,回表了50万次,但全部扔掉了!"

哈吉米:"卧槽,这也太浪费了!"


性能数据实测

测试环境

  • 表:1200万行数据
  • 索引:idx_create_time
  • 硬件:8核16G,SSD

不同页数的性能对比

页数offset扫描行数回表次数执行时间数据传输
第1页01001000.01秒30KB
第10页900100010000.05秒300KB
第100页990010000100000.8秒3MB
第1000页999001000001000008秒30MB
第5000页49990050000050000045秒150MB
第10000页9999001000000100000092秒300MB

性能曲线

执行时间(秒)
    |
100 |                                          *  (第10000页)
 90 |
 80 |
 70 |
 60 |
 50 |                               *  (第5000页)
 40 |
 30 |
 20 |
 10 |                  *  (第1000页)
  0 |__*__*___________|_____________|____________页数
      1  100        1000          5000        10000

规律:性能随offset线性下降

阿西噶阿西:"offset每增加10倍,执行时间也增加约10倍,这是线性退化!"


🚀 优化方案1:子查询优化(延迟关联)

核心思想

问题根源:回表太多次(50万次)

优化思路:先用覆盖索引查出主键ID,再回表查询完整数据

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

-- ✅ 延迟关联(只回表100次)
SELECT o.* 
FROM `order` o
INNER JOIN (
    SELECT id FROM `order`       -- 子查询:覆盖索引,不回表
    ORDER BY create_time DESC 
    LIMIT 500000, 100
) t ON o.id = t.id;

执行流程对比

传统分页的IO操作

1. 扫描索引:500100次(读索引树)
2. 回表:500100次(读聚簇索引)
3. 丢弃:500000行
4. 返回:100行

总IO:500100 + 500100 = 1000200次

延迟关联的IO操作

子查询(覆盖索引):
1. 扫描索引:500100次(读索引树)
2. 不回表(只读ID,覆盖索引)
3. 丢弃:500000个ID
4. 返回:100个ID

主查询(根据ID回表):
1. 拿着100个ID回表:100次

总IO:500100(子查询) + 100(主查询) = 500200次

性能提升:1000200 / 500200 ≈ 2倍

性能对比

方案执行时间提升
传统分页45秒-
延迟关联18秒2.5倍

哈吉米:"只优化了一半?还有更好的方案吗?"

南北绿豆:"有!标签记录法能优化到0.05秒!"


🚀 优化方案2:标签记录法(游标分页)

核心思想

不用offset,用WHERE id > last_id

-- ❌ 传统分页(扫描50万行)
SELECT * FROM `order` ORDER BY id DESC LIMIT 500000, 100;

-- ✅ 标签记录法(只扫描100行)
SELECT * FROM `order` 
WHERE id < #{lastId}  -- lastId是上一页最后一条的ID
ORDER BY id DESC 
LIMIT 100;

为什么这么快?

查询:WHERE id < 800000 ORDER BY id DESC LIMIT 100

执行流程:
1. 在主键索引找到id < 800000的位置
2. 倒序扫描100行
3. 直接返回

总计:
- 扫描:100行
- 回表:0次(主键索引就是数据)
- 丢弃:0行

总IO:100次(仅扫描100行)

性能对比

方案扫描行数回表次数执行时间
传统分页50010050010045秒
延迟关联50010010018秒
标签记录法10000.05秒

性能提升:45秒 / 0.05秒 = 900倍!


完整代码实现

/**
 * 标签记录法分页
 */
@RestController
public class OrderController {
    
    @GetMapping("/order/page")
    public Result pageOrders(@RequestParam(required = false) Long lastId,
                             @RequestParam(defaultValue = "20") Integer pageSize) {
        
        List<Order> orders;
        
        if (lastId == null) {
            // 第1页
            orders = orderMapper.selectFirstPage(pageSize);
        } else {
            // 后续页(WHERE id < lastId)
            orders = orderMapper.selectNextPage(lastId, pageSize);
        }
        
        return Result.ok(orders);
    }
}

// Mapper
@Mapper
public interface OrderMapper {
    
    @Select("SELECT * FROM `order` ORDER BY id DESC LIMIT #{pageSize}")
    List<Order> selectFirstPage(@Param("pageSize") Integer pageSize);
    
    @Select("SELECT * FROM `order` WHERE id < #{lastId} ORDER BY id DESC LIMIT #{pageSize}")
    List<Order> selectNextPage(@Param("lastId") Long lastId, 
                               @Param("pageSize") Integer pageSize);
}

前端实现(下拉加载)

// Vue示例
export default {
    data() {
        return {
            orders: [],
            lastId: null,
            loading: false,
            noMore: false
        }
    },
    
    methods: {
        async loadMore() {
            if (this.loading || this.noMore) return;
            
            this.loading = true;
            
            const resp = await axios.get('/order/page', {
                params: {
                    lastId: this.lastId,
                    pageSize: 20
                }
            });
            
            const newOrders = resp.data;
            
            if (newOrders.length === 0) {
                this.noMore = true;
            } else {
                this.orders.push(...newOrders);
                // 更新lastId(最后一条的ID)
                this.lastId = newOrders[newOrders.length - 1].id;
            }
            
            this.loading = false;
        }
    },
    
    mounted() {
        // 监听滚动到底部
        window.addEventListener('scroll', () => {
            if (window.scrollY + window.innerHeight >= document.body.scrollHeight - 100) {
                this.loadMore();
            }
        });
        
        // 加载第1页
        this.loadMore();
    }
}

优缺点

优点

  • ✅ 性能极好(不管第几页都是0.05秒)
  • ✅ 扫描行数固定(只扫描pageSize行)
  • ✅ 适合移动端(下拉加载)

缺点

  • ❌ 只能向后翻页(不能跳页)
  • ❌ 不能显示总页数
  • ❌ 需要前端配合(传递lastId)
  • ❌ 不适合PC端(用户习惯跳页)

🚀 优化方案3:二次查询

核心思想

先查出起始ID,再根据ID范围查询

-- 第1次查询:获取起始ID(覆盖索引,快)
SELECT id FROM `order` 
ORDER BY create_time DESC 
LIMIT 500000, 1;
-- 假设返回:id=500001
-- 执行时间:3秒

-- 第2次查询:根据ID范围查询(走主键索引,快)
SELECT * FROM `order` 
WHERE id <= 500001 
ORDER BY id DESC 
LIMIT 100;
-- 执行时间:0.02秒

-- 总耗时:3.02秒

为什么第1次查询快?

第1次查询:SELECT id ...

执行流程:
1. 扫描索引idx_create_time:500001次
2. 不回表(覆盖索引,只读ID)
3. 丢弃前500000个ID
4. 返回1个ID

总IO:500001次(只扫描索引,不回表)

传统分页:
总IO:500100次扫描 + 500100次回表 = 1000200次

性能差距:1000200 / 500001 ≈ 2倍

代码实现

@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
public interface OrderMapper {
    
    @Select("SELECT id FROM `order` ORDER BY create_time DESC LIMIT #{offset}, 1")
    Long selectIdByOffset(@Param("offset") Integer offset);
    
    @Select("SELECT * FROM `order` WHERE id <= #{startId} ORDER BY id DESC LIMIT #{pageSize}")
    List<Order> selectByIdRange(@Param("startId") Long startId, 
                                 @Param("pageSize") Integer pageSize);
}

性能对比

方案执行时间提升
传统分页45秒-
二次查询3秒15倍

🚀 优化方案4:记录上次查询位置(书签法)

核心思想

记住上次查询到哪里了,下次从那里继续

/**
 * 书签分页(记录查询位置)
 */
@Service
public class BookmarkPagingService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    public PageResult<Order> pageOrders(String userId, Integer page, Integer pageSize) {
        String bookmarkKey = "bookmark:order:" + userId;
        
        if (page == 1) {
            // 第1页:清空书签
            redisTemplate.delete(bookmarkKey);
            
            List<Order> orders = orderMapper.selectFirstPage(pageSize);
            
            // 保存书签(最后一条的create_time和id)
            if (!orders.isEmpty()) {
                Order lastOrder = orders.get(orders.size() - 1);
                saveBookmark(bookmarkKey, lastOrder);
            }
            
            return PageResult.of(orders);
            
        } else {
            // 后续页:从书签位置继续
            Bookmark bookmark = getBookmark(bookmarkKey);
            
            List<Order> orders = orderMapper.selectFromBookmark(
                bookmark.getCreateTime(), 
                bookmark.getId(), 
                pageSize
            );
            
            // 更新书签
            if (!orders.isEmpty()) {
                Order lastOrder = orders.get(orders.size() - 1);
                saveBookmark(bookmarkKey, lastOrder);
            }
            
            return PageResult.of(orders);
        }
    }
    
    private void saveBookmark(String key, Order order) {
        Bookmark bookmark = new Bookmark();
        bookmark.setCreateTime(order.getCreateTime());
        bookmark.setId(order.getId());
        redisTemplate.opsForValue().set(key, JSON.toJSONString(bookmark), 10, TimeUnit.MINUTES);
    }
}

@Mapper
public interface OrderMapper {
    
    @Select("SELECT * FROM `order` ORDER BY create_time DESC, id DESC LIMIT #{pageSize}")
    List<Order> selectFirstPage(@Param("pageSize") Integer pageSize);
    
    @Select("SELECT * FROM `order` " +
            "WHERE (create_time < #{createTime}) " +
            "   OR (create_time = #{createTime} AND id < #{id}) " +
            "ORDER BY create_time DESC, id DESC " +
            "LIMIT #{pageSize}")
    List<Order> selectFromBookmark(@Param("createTime") Date createTime,
                                    @Param("id") Long id,
                                    @Param("pageSize") Integer pageSize);
}

优点

  • ✅ 性能稳定(每页都是0.05秒)
  • ✅ 支持上一页、下一页

缺点

  • ❌ 不能跳页
  • ❌ 需要Redis存储书签

🚀 优化方案5:ES + MySQL混合

核心思想

搜索走ES,详情走MySQL

@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<OrderDoc> response = esClient.search(s -> s
            .index("order")
            .from((pageNum - 1) * pageSize)  // ES的深分页性能好
            .size(pageSize)
            .query(q -> q
                .multiMatch(m -> m
                    .fields("order_no", "user_name")
                    .query(keyword)
                )
            )
            .sort(sort -> sort.field(f -> f.field("create_time").order(SortOrder.Desc)))
        , OrderDoc.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.selectBatchIds(ids);
        
        // 4. 按ES返回的顺序排序
        Map<Long, Order> orderMap = orders.stream()
            .collect(Collectors.toMap(Order::getId, o -> o));
        
        List<Order> sortedOrders = ids.stream()
            .map(orderMap::get)
            .filter(Objects::nonNull)
            .collect(Collectors.toList());
        
        return PageResult.of(sortedOrders, response.hits().total().value());
    }
}

ES的深分页为什么快?

ES的分页机制:
1. ES是分布式搜索引擎
2. 每个分片独立查询
3. 协调节点归并排序
4. 使用scroll或search_after API优化深分页

性能:
- 第1页:0.05秒
- 第5000页:0.08秒(仍然很快)
- 第10000页:0.1秒

性能对比

方案第1页第5000页第10000页
MySQL LIMIT0.01秒45秒92秒
ES + MySQL0.05秒0.08秒0.1秒

📊 方案选型指南

不同场景的推荐方案

场景推荐方案原因
移动端列表(下拉加载)标签记录法性能最好,只能向后翻
PC端列表(需要跳页)延迟关联支持跳页,性能中等
后台管理(跳页+导出)延迟关联平衡性能和功能
搜索功能ES + MySQL深分页性能好,搜索能力强
数据导出(全量)流式查询内存占用低
实时数据(如监控)只展示最近N条限制翻页深度

组合使用策略

@Service
public class SmartPagingService {
    
    public PageResult<Order> pageOrders(PagingRequest request) {
        // 策略1:前100页用延迟关联
        if (request.getPageNum() <= 100) {
            return delayJoinPaging(request);
        }
        
        // 策略2:100页以后,强制用标签记录法
        if (request.getLastId() != null) {
            return bookmarkPaging(request);
        }
        
        // 策略3:如果既要深分页又要跳页,建议用ES
        return Result.error("数据过多,请使用搜索功能");
    }
}

🎯 深分页的本质问题

哈吉米:"为什么深分页这么慢?有没有通用的优化思路?"

南北绿豆:"深分页的本质问题是:无效扫描。"

问题本质

LIMIT offset, size的本质:
1. 扫描offset + size行
2. 扔掉前offset行
3. 返回size行

问题:
- offset越大,无效扫描越多
- 回表次数 = offset + size
- 浪费比例 = offset / (offset + size)

示例:
LIMIT 500000, 100
- 扫描:500100行
- 浪费:500000行
- 浪费比例:99.98%

优化的核心思路

核心思路:减少无效扫描

方法1:不扫描前面的数据
→ 标签记录法(WHERE id > last_id)
→ 只扫描需要的100行

方法2:扫描但不回表
→ 延迟关联(子查询只查ID,覆盖索引)
→ 只回表100次

方法3:换工具
→ ES擅长深分页
→ 分布式搜索引擎

阿西噶阿西:"记住:深分页的根源是无效扫描,优化的核心是减少无效IO。"


🎓 面试标准答案

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

答案

为什么慢

LIMIT offset, size 的执行流程:

  1. 扫描索引:offset + size 行
  2. 回表:offset + size 次
  3. 丢弃:offset 行
  4. 返回:size 行

例如 LIMIT 500000, 100:

  • 扫描50万行,回表50万次,全部丢弃
  • 再扫描100行,回表100次,返回结果
  • 浪费比例:99.98%

性能数据

  • 第1页:0.01秒
  • 第100页:0.8秒
  • 第1000页:8秒
  • 第5000页:45秒

优化方案

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

  • 性能:0.05秒(所有页)
  • 缺点:只能向后翻
  • 适用:移动端

2. 延迟关联(子查询先查ID)

  • 性能:18秒 → 3秒(提升15倍)
  • 优点:支持跳页
  • 适用:PC端

3. 二次查询

  • 性能:45秒 → 3秒(提升15倍)
  • 优点:支持跳页

4. ES + MySQL

  • 性能:稳定在0.1秒内
  • 优点:深分页性能好
  • 缺点:架构复杂

5. 限制翻页深度

  • 只允许查询前100页
  • 超过100页,引导用户搜索

推荐方案

  • 移动端:标签记录法
  • PC端:延迟关联 + 限制深度(前100页)
  • 搜索:ES + MySQL

🎉 结束语

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

哈吉米:"用标签记录法后,不管翻到第几页都是0.05秒,性能提升900倍!"

南北绿豆:"对,深分页的核心是减少无效扫描,标签记录法是最优解。"

阿西噶阿西:"记住:能用WHERE id > last_id就别用LIMIT offset,性能差900倍。"

哈吉米:"还有延迟关联,虽然不如标签记录法,但支持跳页,适合PC端。"

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


记忆口诀

LIMIT offset扫描多,回表次数浪费高
标签记录用WHERE,只扫需要性能好
延迟关联先查ID,回表次数大减少
ES混合终极解,深分页稳定快
移动端标签PC延迟,搜索就用ES