记一次线上分页排序乱序问题

0 阅读5分钟

Mysql 分页排序出现跨页重复数据问题

1. 触发版本

MySQL 8.0

2. 问题描述

优化列表分页查询接口时,将默认的无排序条件查询修改为按照时间(create_at)条件排序,由于 order by 与 limit 混用时未加唯一限定排序条件,导致分页结果中出现了跨页重复的数据。

3. 问题复现

3.1 复现数据脚本

-- 1. 创建测试表
DROP TABLE IF EXISTS test_pagination;
CREATE TABLE test_pagination (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    status VARCHAR(20),
    created_at DATETIME,
    updated_at DATETIME
);

-- 2. 创建存储过程
DELIMITER $$

DROP PROCEDURE IF EXISTS generate_pagination_test_data$$
CREATE PROCEDURE generate_pagination_test_data(
    IN total_rows INT,          -- 总记录数
    IN batch_size INT,          -- 批次大小(相同时间的记录数)
    IN start_date VARCHAR(19),  -- 开始时间
    IN end_date VARCHAR(19)     -- 结束时间
)
BEGIN
    DECLARE i INT DEFAULT 0;
    DECLARE batch_count INT DEFAULT 0;
    DECLARE batch_time DATETIME;
    DECLARE start_time DATETIME;
    DECLARE end_time DATETIME;
    DECLARE total_seconds INT;
    DECLARE interval_seconds INT;
    DECLARE total_batches INT;
    DECLARE current_batch INT DEFAULT 0;

    -- 所有变量声明
    SET start_time = STR_TO_DATE(start_date, '%Y-%m-%d %H:%i:%s');
    SET end_time = STR_TO_DATE(end_date, '%Y-%m-%d %H:%i:%s');
    SET total_seconds = TIMESTAMPDIFF(SECOND, start_time, end_time);

    -- 计算总批次数和批次时间间隔
    SET total_batches = CEIL(total_rows / batch_size);
    SET interval_seconds = FLOOR(total_seconds / total_batches);
    IF interval_seconds < 1 THEN SET interval_seconds = 1; END IF;

    -- 设置初始批次时间
    SET batch_time = start_time;
    SET current_batch = 0;

    -- 性能优化设置
    SET autocommit = 0;
    SET unique_checks = 0;
    SET foreign_key_checks = 0;

    -- 开始生成数据
    WHILE i < total_rows DO
        -- 如果批次计数器为0,表示开始新批次
        IF batch_count = 0 THEN
            -- 为这个批次生成一个时间
            SET batch_time = DATE_ADD(start_time,
                INTERVAL current_batch * interval_seconds +
                FLOOR(RAND() * 60) SECOND  -- 加一些随机偏移
            );
            SET current_batch = current_batch + 1;
        END IF;

        -- 插入记录(同批次记录时间相同)
        INSERT INTO test_pagination (status, created_at, updated_at)
        VALUES (
            CASE
                WHEN RAND() < 0.6 THEN 'active'
                WHEN RAND() < 0.9 THEN 'pending'
                ELSE 'deleted'
            END,
            batch_time,
            batch_time
        );

        -- 更新计数器
        SET i = i + 1;
        SET batch_count = batch_count + 1;

        -- 如果达到批次大小,重置批次计数器
        IF batch_count >= batch_size THEN
            SET batch_count = 0;
        END IF;

        -- 每5000条提交一次
        IF i % 5000 = 0 THEN
            COMMIT;
        END IF;
    END WHILE;

    -- 最终提交
    COMMIT;

    -- 恢复设置
    SET autocommit = 1;
    SET unique_checks = 1;
    SET foreign_key_checks = 1;

END$$

DELIMITER ;

-- 3. 调用存储过程生成数据
-- 生成1万条数据,每批次7条相同时间,时间从2026-01-01到2026-01-01
CALL generate_pagination_test_data(
    10000,      -- 总记录数
    7,          -- 批次大小(相同时间的记录数)
    '2026-01-01 00:00:00',
    '2026-01-01 23:59:59'
);

-- 4. 清理存储过程
DROP PROCEDURE IF EXISTS generate_pagination_test_data;

3.2 复现条件

  1. 执行分页查询 (第3页, 每页10条)

    select * from test_pagination order by created_at limit 20, 10
    
image-20260217193615660
  1. 执行分页查询(第4页, 每页10条)

    select * from test_pagination order by created_at limit 30, 10
    

    image-20260217193730349

结果中 ID 为 31 的数据再次出现,存在跨页数据重复问题。

4. 问题分析

4.1 问题溯源

与前端联调时发现分页数据重复、主键 ID 顺序不稳定,与未加排序条件前的结果不一致,定位到核心问题为ORDER BY + LIMIT的使用方式不正确所导致。

4.2 核心原理

MySQL 5.6+ 对 ORDER BY ... LIMIT n 场景做了优化

  • 若排序无法利用索引有序特性,会启用优先队列(Priority Queue) 进行排序优化。不再对全量数据排序,而是维护一个大小为limit n的堆结构,遍历数据时仅将符合条件的数据插入堆中,遍历完成后直接取堆内数据返回。
  • 当排序字段(created_at)存在重复值时,MySQL 无法保证相同时间的记录选取顺序固定,对相同时间的记录随机保留最终导致分页结果乱序、重复。

4.3 历史逻辑分析

迭代前未显式指定ORDER BY条件时,MySQL 会按聚簇索引的物理存储顺序进行隐式排序(看似无重复),实际开发过程中中不应该依赖该特性,所有排序查询都需要显式指定ORDER BY条件。

4.4 文档资料

来源:MySQL 8.0 官方文档 - ORDER BY 优化

核心内容:

If you combine LIMIT *row_count* with ORDER BY, MySQL stops sorting as soon as it has found the first row_count rows of the sorted result, rather than sorting the entire result. If ordering is done by using an index, this is very fast. If a filesort must be done, all rows that match the query without the LIMIT clause are selected, and most or all of them are sorted, before the first row_count are found. After the initial rows have been found, MySQL does not sort any remainder of the result set.

One manifestation of this behavior is that an ORDER BY query with and without LIMIT may return rows in different order, as described later in this section.

5. 解决方案

ORDER BY的排序条件末尾补充具有唯一性的排序字段(主键 ID、唯一索引等),确保相同排序字段值的记录排序顺序固定,彻底解决分页重复问题。

5.1 优化后的式例

-- 优化前
SELECT * FROM test_pagination ORDER BY created_at LIMIT 20, 10;

-- 优化后
SELECT * FROM test_pagination ORDER BY created_at, id LIMIT 20, 10;

5.2 优化原则

使用排序字段 + 唯一特性字段组合排

  • 使用created_at字段排序满足业务侧按时间展示需要。
  • 时间相同的记录按照主键值进行二次排序,避免分页乱序。

6. 避坑指南

  1. 所有分页查询的ORDER BY条件必须包含唯一字段(主键 / 唯一索引),避免分页乱序;
  2. 避免依赖 MySQL 隐式排序特性(无ORDER BY时按照物理存储顺序排序),显式指定排序条件;