分页查询超亿级别的数据表缓慢,如何进行优化?

前言

当我们刚入职一家公司,刚接手一个老项目,在项目初期的架构中往往没有完善的架构设计和数据库设计,不足以支撑上亿级别的数据查询,更甚者可能几百万的数据量就已经可以令系统卡死,使用户体验极差。这样的场景在如今的程序员面试中也常常被问及,今天来探讨一下我的优化思路和方案。

以我们最熟悉的Mysql数据库为例

排查原因

SQL 复制代码
SELECT * FROM orders 
WHERE status = 'completed' 
ORDER BY create_time DESC 
LIMIT 1000000, 20;

上面是我们平时开发项目中最常用也是最经典的分页查询语句,我们执行以下数据库自带的EXPLAIN

SQL 复制代码
EXPLAIN SELECT * FROM orders 
WHERE status = 'completed' 
ORDER BY create_time DESC 
LIMIT 1000000, 20;

-- 结果显示:
-- rows: 1000020 -- 扫描了100万+行

从结果上来看,已经很明显指出查询缓慢原因:Mysql需要扫描前1000020行数据才能返回结果,而随着数据偏移量增大,响应时间会呈线性增长,从我实际接触到的经验出发,1亿条商品数据表第10000页的查询会耗时28秒,这是很难被用户接受的体验。

核心优化方案

游标分页(Cursor-based Pagination)

基于主键的游标分页

我们都知道,Mysql对于主键的查询和回表查询是非常快的,利于这一点我们从客户端和数据库两方面进行优化。

Java 复制代码
@Service
public class OrderService {
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 游标分页查询
     * @param lastId 上一页最后一条记录的ID
     * @param pageSize 页面大小
     * @return 订单列表
     */
    public List<Order> getOrdersByCursor(Long lastId, int pageSize) {
        if (Objects.isNull(lastId)) {
            // 第一页
            return orderMapper.selectFirstPage(pageSize);
        } else {
            // 后续页:基于上一页最后ID查询
            return orderMapper.selectNextPage(lastId, pageSize);
        }
    }
}
SQL 复制代码
-- 第一页
SELECT * FROM orders 
WHERE status = 'completed' 
ORDER BY id DESC 
LIMIT #{pageSize};

-- 后续页
SELECT * FROM orders 
WHERE status = 'completed' AND id < #{lastId} 
ORDER BY id DESC 
LIMIT #{pageSize};

游标分页的优势:

  • 时间复杂度O(1),性能稳定;

  • 无需跳过大量数据;

  • 响应时间一般能稳定控制在50ms以内;
    游标分页适用场景:

  • Feed流(瀑布式加载)、商品列表、消息列表等无限滚动场景

  • 无需跳转到指定页码的场景

复合索引游标分页

对于有复杂条件查询的场景,使用这种SQL查询方式更佳。

SQL 复制代码
SELECT * FROM orders 
WHERE status = 'completed' 
AND (create_time, id) < ('2024-01-01 10:00:00', 1000) ORDER BY create_time DESC, id 
DESC LIMIT 20;
-- 需要创建复合索引
CREATE INDEX idx_status_create_time_id 
ON orders(status, create_time DESC, id DESC);

覆盖索引优化法

当业务必须支持跳转到指定页码时(如后台管理系统),使用覆盖索引:

SQL 复制代码
-- 步骤1:先通过覆盖索引获取主键
SELECT id FROM orders 
WHERE status = 'completed' 
ORDER BY create_time DESC LIMIT 1000000, 20;
-- 步骤2:通过主键回表查询详细信息
SELECT * FROM orders WHERE id IN (/* 上面查询的结果 */);
Java 复制代码
/** 客户端实现方法 */
public List<Order> getPageWithCoveringIndex(int pageNum, int pageSize) {
    int offset = (pageNum - 1) * pageSize;
    // 第一步:获取主键列表(走覆盖索引)
    List<Long> ids = orderMapper.selectIdsOnly(offset, pageSize);
    if (Objects.isNull(ids) || ids.isEmpty()) {
        return Collections.emptyList();
    }
    // 第二步:回表查询详细信息
    return orderMapper.selectByIds(ids);
}

索引设计:

SQL 复制代码
-- 覆盖索引:包含WHERE条件 + ORDER BY字段 + 主键
CREATE INDEX idx_covering ON orders(status, create_time DESC, id);

覆盖索引设计相比传统分页查询性能提升

  • 从28秒 -> 200ms(实测数据)
  • 减少磁盘I/O,避免回表开销

延迟关联(Deferred Join) - 高级优化

SQL 复制代码
-- 延迟关联写法
SELECT o.* FROM orders o 
INNER JOIN ( 
    SELECT id FROM orders 
    WHERE status = 'completed' 
    ORDER BY create_time DESC LIMIT 1000000, 20 
) AS tmp ON o.id = tmp.id 
ORDER BY o.create_time DESC;

延迟关联原理:

  • 子查询只扫描索引,不回表
  • 外层查询只处理20条记录的回表操作
  • 避免了对100万+条记录的回表操作

数据归档与分区表

冷热数据分离

SQL 复制代码
-- 将冷数据归档到历史表
CREATE TABLE orders_history LIKE orders;
-- 归档一年前的数据
INSERT INTO orders_history;
SELECT * FROM orders WHERE create_time < DATA_SUB(now(), INTERVAL 1 YEAR);
DELETE FROM orders WHERE create_time < DATA_SUB(now(), INTERVAL 1 YEAR);

冷热数据分离效果:

  • 主表数据量从亿级别减少到千万级别
  • 分页查询性能可以提升10倍有余
  • 用户通常只查询最近数据

Mysql分区

SQL 复制代码
-- 按时间范围分区
CREATE TABLE orders_partitioned (
   id BIGINT PRIMARY KEY,
   user_id BIGINT NOT NULL,
   status VARCHAR(32),
   create_time DATETIME NOT NULL,
   amount DECIMAL(10,2)
) PARTITION BY RANGE (YEAR(create_time)) (
   PARTITION p2023 VALUES LESS THAN (2024),
   PARTITION p2024 VALUES LESS THAN (2025),
   PARTITION p2025 VALUES LESS THAN (2026),
   PARTITION p_current VALUES LESS THAN MAXVALUE
);
-- 查询时自动路由到对应分区
SELECT * FROM orders_partitioned
WHERE status = 'completed'
    AND create_time >= '2024-01-01'
ORDER BY create_time DESC
LIMIT 1000000,20;

缓存预加载策略

思路是将一些热门数据预先缓存到本地或者缓存服务中,从而提升性能。

Java 复制代码
/** 后台异步预加载 */
@Component 
public class PaginationCachePreloader { 
    @Scheduled(fixedRate = 300000) // 每5分钟 
    public void preloadPopularPages() { // 预加载热门用户的前100页数据 
        List<Long> hotUserIds = getUserHotList(); 
        for (Long userId : hotUserIds) { 
            for (int page = 1; page <= 100; page++) {
                CompletableFuture.runAsync(() -> {
                    List<Order> orders = orderService.getOrdersByCursor( getLastIdForPage(userId, page), 20 ); 
                    cacheService.set( "user_orders:" + userId + ":page:" + page, orders, Duration.ofHours(1) ); 
                }); 
            } 
        } 
    } 
}

数据表设计索引优化

索引设计原则

SQL 复制代码
-- 错误的索引设计
CREATE INDEX idx_status ON orders(status);           --单列索引
CREATE INDEX idx_create_time ON orders(create_time); --单例索引
-- 正确的复合索引设计
CREATE INDEX idx_optimal ON orders(status, create_time DESC, id);

索引设计黄金法则

  1. WHERE条件字段放在最前面
  2. ORDER BY字段紧随其后
  3. SELECT字段如果能被索引覆盖则更好
  4. 主键通常最为最后字段

索引选择性分析

SQL 复制代码
-- 分析字段选择性
SELECT
    COUNT(DISTINCT status) / COUNT(*) as status_selectivity,
    COUNT(DISTINCT create_time) / COUNT(*) as time_selectivity
FROM orders;
-- 高选择性字段应该放在复合索引前面
-- 如果status只有3个值(低选择性),考虑调整索引顺序

不同场景的方案选择

场景 推荐方案 理由
Web/App应用(Feed流、商品列表) 游标分页 性能最优,用户体验好
管理后台/报表系统(需跳页) 覆盖索引 + 延迟关联 兼容传统页码需求
历史数据查询 数据归档 + 分区表 根本性解决数据量问题
高频访问页面 缓存预加载 提供极致用户体验

实战性能对比

以下是根据我历年工作经验的总结得到的数据(仅供参考):

方案 100万偏移量 1000万偏移量 实现复杂度
传统LIMIT 2.8秒 28秒
游标分页 45ms 48ms
覆盖索引 200ms 220ms
延迟关联 180ms 210ms
数据归档后 50ms 55ms

总结

对于上亿及大数据量的分页查询优化,没有银弹,需要根据实际具体场景选择合适的方案,通过组合使用以上提到的方案,可以将原本需要几十秒甚至几分钟的查询优化到百毫秒以内,显著提升系统性能和用户体验。

如果您觉得这篇技术分享对您有用,请点赞收藏关注博主,不定时分享技术。愿您每天开心,工作顺利,家庭幸福。

相关推荐
KMDxiaozuanfeng4 小时前
卡梅德生物技术快报|SPR 技术应用|基于 SPR 亲和力的中药活性成分筛选系统实现与数据分析
科技·算法·面试·考试
yuki_uix4 小时前
CSS 里的"结界":BFC 与层叠上下文的渲染隔离逻辑
前端·面试
studyForMokey5 小时前
【Android面试】Java专题 todo
android·java·面试
张元清5 小时前
head.tsx 就是一个 React 组件:用 loader 数据动态生成 SEO meta
前端·javascript·面试
programhelp_6 小时前
WeRide OA 2026 高频真题分享 & 详细备战指南
经验分享·算法·面试·职场和发展
叶子2024227 小时前
电网面试回答
网络·面试·职场和发展
Wect7 小时前
深度解析前端性能优化
前端·面试·性能优化
网络安全实验室7 小时前
【程序人生】程序员接私活常用平台汇总_嵌入式开发外包平台
网络·python·学习·程序人生·web安全·面试·职场和发展
青衫码上行7 小时前
【从零开始学习JVM】字符串常量池
java·jvm·学习·面试·string