侧边栏壁纸
  • 累计撰写 38 篇文章
  • 累计创建 8 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

从 36 分钟到 0.7 秒:战损级硬件下的千万级深分页 SQL 调优纪实

Administrator
2026-04-27 / 0 评论 / 0 点赞 / 40 阅读 / 0 字
温馨提示:
本文最后更新于2026-04-28,若内容或图片失效,请留言反馈。 部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

笔者按(免责与背景说明):

本文源于笔者简历中一个“将 5s 慢查询优化至 0.3s”的真实生产环境调优项目。为了向大家彻底剖析千万级深分页的底层原理,笔者在本地环境复刻了整套推演过程。

测试环境极其苛刻:Debian 13系统、Intel 奔腾双核 T3500、2G 内存,外加一块极其古老的机械硬盘(HDD)

虽然并非生产环境的真实跑分,但在这种战损级硬件下,数据库的 I/O 瓶颈和 CPU 调度会被无限放大,反而能让我们像拿着显微镜一样,看清 MySQL 优化器的每一个小动作。希望能给大家的日常 SQL 优化带来降维打击般的启发。

一、 风暴前夕:千万级测试数据的生成

在千万级数据下验证 SQL,用常规的 INSERT 跑完可能需要一整天。这里分享一个 DBA 常用的小技巧:利用 Python 脚本生成 CSV 文件,再通过 MySQL 的 LOAD DATA INFILE 流式导入,几分钟就能搞定 1000 万数据。

我们模拟一个经典的电商场景:users(用户表)、orders(订单表)、order_items(订单明细表,1 对 N 关系)。

1. Python 数据生成脚本

Python

import csv
import random
import uuid
from datetime import datetime, timedelta

# 配置数据量 (千万级建议:100W用户 -> 1000W订单 -> 3000W详情)
USER_COUNT = 1_000_000
ORDER_COUNT = 10_000_000
ITEMS_PER_ORDER_MAX = 3

def generate_csv():
    start_date = datetime(2023, 1, 1)
    
    # 1. 生成用户表 (users.csv)
    print("正在生成用户数据...")
    with open('users.csv', 'w', newline='') as f:
        writer = csv.writer(f)
        for i in range(1, USER_COUNT + 1):
            # id, username, level, created_at
            writer.writerow([i, f"user_{i}", random.randint(1, 5), start_date + timedelta(seconds=i)])

    # 2. 生成订单与明细 (orders.csv & order_items.csv)
    print("正在生成订单与详情数据 (流式处理中)...")
    with open('orders.csv', 'w', newline='') as f_o, open('order_items.csv', 'w', newline='') as f_i:
        o_writer = csv.writer(f_o)
        i_writer = csv.writer(f_i)
        
        item_id_counter = 1
        for o_id in range(1, ORDER_COUNT + 1):
            user_id = random.randint(1, USER_COUNT)
            order_no = uuid.uuid4().hex[:16]
            amount = round(random.uniform(10, 1000), 2)
            created_at = start_date + timedelta(seconds=random.randint(0, 31536000)) # 随机一年内
            
            # 写入订单: id, user_id, order_no, total_amount, status, created_at
            o_writer.writerow([o_id, user_id, order_no, amount, random.randint(0, 3), created_at])
            
            # 写入订单详情 (每个订单1-3个商品)
            for _ in range(random.randint(1, ITEMS_PER_ORDER_MAX)):
                # id, order_id, product_id, price, quantity
                i_writer.writerow([item_id_counter, o_id, random.randint(1, 5000), round(amount/2, 2), 1])
                item_id_counter += 1
            
            if o_id % 1000000 == 0:
                print(f"已完成 {o_id} 条订单数据...")

if __name__ == "__main__":
    generate_csv()
    print("所有CSV文件生成完毕!")

生成完毕后,在 MySQL 中建好数据库并执行导入以下脚本:

SQL

-- 1. 创建表结构
CREATE TABLE users (
    id INT PRIMARY KEY,
    username VARCHAR(50),
    level INT,
    created_at DATETIME
);

CREATE TABLE orders (
    id INT PRIMARY KEY,
    user_id INT,
    order_no VARCHAR(32),
    total_amount DECIMAL(10,2),
    status INT,
    created_at DATETIME
);

CREATE TABLE order_items (
    id INT PRIMARY KEY,
    order_id INT,
    product_id INT,
    price DECIMAL(10,2),
    quantity INT
);

-- 2. 导入数据 (在MySQL命令行执行)
-- 注意修改路径为你的本地路径
SET FOREIGN_KEY_CHECKS = 0;

LOAD DATA INFILE '/home/testsql/users.csv' INTO TABLE users FIELDS TERMINATED BY ',';
LOAD DATA INFILE '/home/testsql/orders.csv' INTO TABLE orders FIELDS TERMINATED BY ',';
LOAD DATA INFILE '/home/testsql/order_items.csv' INTO TABLE order_items FIELDS TERMINATED BY ',';

SET FOREIGN_KEY_CHECKS = 1;

二、 灾难重现:36 分钟的原始慢查询

在 B 端后台系统中,运营人员最喜欢的功能就是“翻页”。当他们试图翻到第 40001 页时(偏移量 80 万),系统发出了惨叫。

我们来看看这句未经优化的原始 SQL:

SQL

 SELECT 
	o.id,
	i.id,
    o.order_no,
    o.total_amount,
    o.status,
    o.created_at,
    u.username,
    u.level,
    i.product_id,
    i.price
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN order_items i ON o.id = i.order_id
WHERE o.status = 1 
ORDER BY o.created_at DESC
LIMIT 800000, 20;

执行耗时:36 分钟 45 秒。

通过 EXPLAIN 查看执行计划,两个致命词汇赫然在列:type: ALL(全表扫描) 和 Using filesort(文件排序)。

MySQL 面对近千万订单数据,像一个没有目录的盲人,不仅要从头摸到尾,还要把数据塞进原本就捉襟见肘的 2G 内存中进行强行排序。内存塞不下,只能借助缓慢的机械硬盘写临时文件,36 分钟已经是这台机器的极限。

三、 致命陷阱:加了索引,为何变成了 1 小时 6 分钟?

看到全表扫描和文件排序,第一反应当然是加索引。于是我为 statuscreated_at 加上了联合索引:

SQL

ALTER TABLE orders ADD INDEX idx_status_time (status, created_at);

自信满满地再次执行,结果让人大跌眼镜:执行耗时 56 分钟! 加了索引反而更慢了?

这正是无数开发者踩过的大坑——机械硬盘下的“海量回表”风暴

在 36 分钟的版本里,MySQL 走的是全表连续扫描,虽然慢,但它是顺序 I/O

而加了索引后,MySQL 发现可以走 idx_status_time。但为了拿到 LIMIT 800000, 20 需要的数据,它必须在索引树里先找出前 80 万条记录的主键 ID,然后拿着这 80 万个 ID,去原表里捞取其他几十个字段(即回表)。

这导致了足足 80 万次极其恐怖的随机磁盘 I/O!在那台老旧的机械硬盘上,磁头疯狂寻道,硬生生把时间拖成了一个多小时。

四、 架构师的十字路口:业务妥协 vs 极限压榨

面对这个物理极限,摆在我们面前的有两条调优路线。这不仅仅是 SQL 技术的较量,更是业务架构视角的权衡。

路线 A:死守业务兼容性的“覆盖索引 JOIN”(耗时:24 秒)

如果老业务代码强依赖查询结果“刚好是 20 行”,我们不能改变结果集的大小,只能让这 80 万次废弃操作全部在内存中进行,绝不碰磁盘。

SQL

SELECT 
	o.id,
	i.id,
    o.order_no,
    o.total_amount,
    o.status,
    o.created_at,
    u.username,
    u.level,
    i.product_id,
    i.price
FROM (
    -- 核心魔法:全覆盖索引 JOIN
    -- MySQL 在这里只需要扫描 idx_status_time 和 idx_order_id 这两棵极其轻量的索引树
    -- 就能在内存中算出那 80万个偏移量,最后精准锁定这 20 个主键组合
    SELECT o_idx.id AS order_id, i_idx.id AS item_id
    FROM orders o_idx
    JOIN order_items i_idx ON o_idx.id = i_idx.order_id
    WHERE o_idx.status = 1 
    ORDER BY o_idx.created_at DESC
    LIMIT 800000, 20
) AS temp
JOIN orders o ON temp.order_id = o.id
JOIN users u ON o.user_id = u.id
JOIN order_items i ON temp.item_id = i.id;

战绩:从 1 小时降至 24 秒。性能提升近 120 倍!

剖析:由于 idx_status_time 和外键索引天然包含主键,MySQL 只需在极其轻量的两棵索引树上完成 JOIN 和 80 万次的跳过。但因为依然要计算 80 万次笛卡尔积,33 秒几乎是双核 CPU 的算力物理极限。

路线 B:打破重组的“延迟关联”与业务纠偏(耗时:1.79 秒)

路线 A 虽然保住了 20 行的设定,但掩盖了一个业务 Bug:基于“商品明细”维度的分页,会把同一个订单腰斩在两页之间。

因此,另外的解法是:先对主表进行分页锁定订单,再去连表查明细。这样的缺点是每个页面的数据没法很好的分页,实际上分页是以订单数据来分的,所以这样的话,适合展示规定的订单数量,然后根据订单在展开详情的情况,会导致和之前的查询结果不一致,可能和具体的业务冲突

SQL

SELECT 
    o.order_no,
    o.total_amount,
    o.status,
    o.created_at,
    u.username,
    u.level,
    i.product_id,
    i.price
FROM (
    -- 核心:这个子查询只会扫描索引,不回表,不 JOIN
    SELECT id 
    FROM orders 
    WHERE status = 1 
    ORDER BY created_at DESC 
    LIMIT 800000, 20
) AS temp_o
JOIN orders o ON temp_o.id = o.id
JOIN users u ON o.user_id = u.id
JOIN order_items i ON o.id = i.order_id;

战绩1.79 秒!

架构收尾:此时数据库返回的是约 36 行记录(20个订单 × 平均1.8个商品)。最后在 Java 业务层通过 Stream API 的 groupingBy,在内存中瞬间折叠成 20 个完整的 OrderDTO 返回给前端。这才是真正优雅的系统闭环。

五、 终章:驯服优化器,指令级 SQL 的 0.7s 极限突破

1.79秒和24秒还能更快吗?在我的强迫症驱使下,为了追求极致的性能,我写下了最终的SQL。基于游标查询的SQL

首先我建立了两个可能对查询有利的索引

ALTER TABLE orders
ADD INDEX idx_status_created_id (status, created_at, id);
ALTER TABLE order_items
ADD INDEX idx_orderid_id (order_id, id);

第一版的游标查询

SQL

 SELECT
    o.id,
    i.id,
    o.order_no,
    o.total_amount,
    o.status,
    o.created_at,
    u.username,
    u.level,
    i.product_id,
    i.price
FROM (
    (
        -- 1) created_at 更早的结果
        SELECT
            o_idx.id AS order_id,
            i_idx.id AS item_id,
            o_idx.created_at
        FROM orders o_idx
        STRAIGHT_JOIN order_items i_idx
            ON i_idx.order_id = o_idx.id
        WHERE o_idx.status = 1
          AND o_idx.created_at < '2023-11-03 18:25:14'
        ORDER BY o_idx.created_at DESC, o_idx.id DESC, i_idx.id ASC
        LIMIT 20
    )
    
    UNION ALL
    
    (
        -- 2) created_at 相同,但 order_id 更小
        SELECT
            o_idx.id AS order_id,
            i_idx.id AS item_id,
            o_idx.created_at
        FROM orders o_idx
        STRAIGHT_JOIN order_items i_idx
            ON i_idx.order_id = o_idx.id
        WHERE o_idx.status = 1
          AND o_idx.created_at = '2023-11-03 18:25:14'
          AND o_idx.id < 7081307
        ORDER BY o_idx.id DESC, i_idx.id ASC
        LIMIT 20
    )
    
    UNION ALL
    
    (
        -- 3) created_at、order_id 都相同,但 item_id 更大
        SELECT
            o_idx.id AS order_id,
            i_idx.id AS item_id,
            o_idx.created_at
        FROM orders o_idx
        STRAIGHT_JOIN order_items i_idx
            ON i_idx.order_id = o_idx.id
        WHERE o_idx.status = 1
          AND o_idx.created_at = '2023-11-03 18:25:14'
          AND o_idx.id = 7081307
          AND i_idx.id > 14161664
        ORDER BY i_idx.id ASC
        LIMIT 20
    )
    
    ORDER BY created_at DESC, order_id DESC, item_id ASC
    LIMIT 20
) AS temp
JOIN orders o      ON o.id = temp.order_id
JOIN users u       ON u.id = o.user_id
JOIN order_items i ON i.id = temp.item_id
ORDER BY o.created_at DESC, o.id DESC, i.id ASC;

最后发现速度还不如之前的直接跨页执行时间2m 37s,于是有了以下的最终版

SELECT
    o.id,
    i.id,
    o.order_no,
    o.total_amount,
    o.status,
    o.created_at,
    u.username,
    u.level,
    i.product_id,
    i.price
FROM (
    -- A. 当前订单剩余的 item(如果上一页最后一条正好落在订单内部)
    (
        SELECT
            o_cur.id AS order_id,
            i_cur.id AS item_id,
            o_cur.created_at
        FROM orders o_cur FORCE INDEX (PRIMARY)
        STRAIGHT_JOIN order_items i_cur FORCE INDEX (idx_order_id)
            ON i_cur.order_id = o_cur.id
        WHERE o_cur.id = 7081307
          AND o_cur.status = 1
          AND o_cur.created_at = '2023-11-03 18:25:14'
          AND i_cur.id > 14161664
        ORDER BY i_cur.id ASC
        LIMIT 20
    )

    UNION ALL

    -- B. 后续订单(先取订单,再展开 item)
    (
        SELECT
            o_next.id AS order_id,
            i_next.id AS item_id,
            o_next.created_at
        FROM (
            SELECT
                o1.id,
                o1.created_at
            FROM orders o1 FORCE INDEX (idx_status_created_id)
            WHERE o1.status = 1
              AND (
                    o1.created_at < '2023-11-03 18:25:14'
                 OR (o1.created_at = '2023-11-03 18:25:14' AND o1.id < 7081307)
              )
            ORDER BY o1.created_at DESC, o1.id DESC
            LIMIT 20
        ) AS o_next
        STRAIGHT_JOIN order_items i_next FORCE INDEX (idx_order_id)
            ON i_next.order_id = o_next.id
        ORDER BY o_next.created_at DESC, o_next.id DESC, i_next.id ASC
        LIMIT 20
    )

    ORDER BY created_at DESC, order_id DESC, item_id ASC
    LIMIT 20
) AS temp
JOIN orders o      ON o.id = temp.order_id
JOIN users u       ON u.id = o.user_id
JOIN order_items i ON i.id = temp.item_id
ORDER BY o.created_at DESC, o.id DESC, i.id ASC;


战绩:0.7 秒! 在 2G 内存和机械硬盘上跑出了极快的查询速度。

核心心法解析:

  1. 彻底拆解 OR 条件:使用 UNION ALL 代替复杂的 OR,防止优化器代价评估(CBO)失效而退化为全表扫描。

  2. 强制先查后连B 块 中先强行用子查询 LIMIT 20 锁死仅有的 20 个订单,再用 STRAIGHT_JOIN 驱动关联。直接把后续表的扫描量从几十万压制到个位数。

技术债警示:这段代码虽然达到了 0.7s 的性能巅峰,但由于使用了 FORCE INDEX 强绑定索引,未来如果数据倾斜导致有更好的执行树,它也会执拗地走老路。这是为了追求极致性能而必须承担的维护成本。

六、 硬件升级与底层优化的权衡:加机器还是改代码?

在前面的推演中,我们始终在压榨这颗 2010 年产的奔腾 T3500。但在真实的工程实践中,面对性能瓶颈,架构师往往面临着两条路径:硬件资源的垂直扩展(Scale-up)底层代码的架构优化

很多时候,考虑到重构代码带来的测试成本和风险,升级硬件往往是企业的第一选择。那么,“加机器”真的能包治百病吗?

1. 硬件升级的“天花板”:当 $O(N)$ 遇到物理极限

  • 升级企业级 NVMe SSD(突破 I/O 边界):

    在我们的“踩坑阶段”,错误索引导致的海量随机回表在机械硬盘上跑了 1 小时。换上 SSD 后,随机读写(IOPS)百倍提升,这 1 小时可能瞬间降至几分钟甚至几十秒。这是“钞能力”最显著的地方。

  • 扩容大容量内存(消除磁盘排序):

    最初的 36 分钟全表扫描中,Using filesort 是极其致命的。如果将服务器内存从 2G 扩容至 256G,InnoDB Buffer Pool 就能在内存中(Sort Buffer)从容完成所有的排序计算,彻底消灭缓慢的磁盘临时文件。

  • 最核心的变量:CPU 单核性能(IPC 的降维打击)

    MySQL 默认是单线程处理单条标准查询的。

    • 老旧的 T3500:Penryn 架构,主频低,IPC(每时钟周期指令数)极其有限。

    • 最新的 AMD EPYC 9004 (Genoa):Zen 4 架构,主频翻倍,IPC 提升数倍,L3 缓存更是天壤之别。

      对比结论:如果你换上最新的 AMD 服务器,凭借恐怖的单核性能,原本在 T3500 上跑了 33 秒 的纯索引扫描,可能会被压缩到 1-2 秒。对于很多业务来说,这 1-2 秒已经通过了性能验收。

2. 架构优化的必要性:突破算法与物理的极限

然而,硬件升级并非万能。当我们面对类似**“千万级连表深分页”**这种问题时,硬件扩容的边际收益会急剧递减。这就是底层代码优化的真正价值所在:

  • 打破 $O(N)$ 的扫描魔咒: 无论内存多大、SSD 多快,只要业务逻辑是 LIMIT 800000, 20,MySQL 就必须老老实实地在内存中遍历并验证那 80 万个节点的数据可见性(MVCC)。硬件只能让“每次遍历”变快,而代码优化(如游标分页)则是直接消灭这 80 万次遍历,将 $O(N)$ 的时间复杂度降维到 $O(1)$

  • 规避单核性能天花板: 当一条 SQL 已经完全在内存中执行(如我们第三阶段的 33 秒版本),且受限于 MySQL 的单线程模型时,普通堆叠 CPU 核心数毫无意义。此时,只有通过重构 SQL(如强制提前 LIMIT、延迟关联),大幅削减进入 JOIN 环节的数据基数,才能真正实现从几十秒到 0.7 秒的质变。

3. 本章小结:成本与极限的平衡

硬件升级是在抬高系统的“下限”,它以极低的代码风险,消化了大量不合理 SQL 带来的资源浪费。对于 90% 的公司来说,买更好的机器是最经济的方案。

但当数据量继续增长到亿级,或者单核性能达到物理瓶颈时,底层优化(如延迟关联、游标分页)才是决定系统“上限”的唯一手段

写在最后

从 36 分钟的全表崩盘,到机械硬盘 1 小时的回表陷阱,再到 33s 的物理抗争,最后通过延迟关联和指令级重构达到 0.7s。

这一次在老古董机器上的推演告诉我:性能优化绝不仅仅是加个索引那么简单。它需要你懂 B+ 树的物理结构,懂优化器的脾气,懂硬件的短板,更需要你拥有打破业务原有的架构视野。

希望这段“战损版”机器上的实战笔记,能为你未来的慢查询调优带来一点底气。


博主的小提示: 本文的部分行文逻辑与理论润色在 AI 助手的辅助下完成。但请放心,文中出现的所有慢查询现象、EXPLAIN 执行计划剖析、以及最终 0.7s 的极限压测数据,均由笔者在本地测试机上一行行敲击、一步步执行并真实验证的但是语句可能有问题,因为数据是通过脚本生成的,我没办法并且没有太多时间去判断关联性和准确性。

受限于单机测试环境与模拟数据的分布特征,部分结论在极其复杂的生产环境中可能会有局限性甚至偏差。本文的核心目的,是希望为大家提供一种**“从底层原理解构调优”的推演思路**,而非提供直接粘贴即可用的“银弹”。

代码无绝对,架构皆权衡。文中的方案如有纰漏,或各位大佬有更优雅的见解,非常欢迎在评论区拍砖探讨、共同进步!

0
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin

评论区