笔者按(免责与背景说明):
本文源于笔者简历中一个“将 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 分钟?
看到全表扫描和文件排序,第一反应当然是加索引。于是我为 status 和 created_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 内存和机械硬盘上跑出了极快的查询速度。
核心心法解析:
彻底拆解 OR 条件:使用
UNION ALL代替复杂的OR,防止优化器代价评估(CBO)失效而退化为全表扫描。强制先查后连:
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 的极限压测数据,均由笔者在本地测试机上一行行敲击、一步步执行并真实验证的。但是语句可能有问题,因为数据是通过脚本生成的,我没办法并且没有太多时间去判断关联性和准确性。
受限于单机测试环境与模拟数据的分布特征,部分结论在极其复杂的生产环境中可能会有局限性甚至偏差。本文的核心目的,是希望为大家提供一种**“从底层原理解构调优”的推演思路**,而非提供直接粘贴即可用的“银弹”。
代码无绝对,架构皆权衡。文中的方案如有纰漏,或各位大佬有更优雅的见解,非常欢迎在评论区拍砖探讨、共同进步!
评论区