你有没有好奇过:一句简单的
SELECT * FROM users ORDER BY age,数据库背后到底忙活了什么?
一个真实的故事
小明写了一条SQL:
sql
SELECT * FROM orders ORDER BY create_time LIMIT 100;
数据量500万行,跑了一分钟还没出来。
DBA走过来看了一眼,说:"加个索引吧。" 加了 (create_time) 索引后,30毫秒出结果。
小明惊呆了:"加了索引就能快几万倍?排序不就是要排吗?索引怎么做到的?"
今天我们就从"数据太多放不下内存"开始,一层层揭开ORDER BY的底牌。
一、【直观想法】全部读进内存,快排
如果数据量不大,数据库的做法非常朴实:
-
把所有满足WHERE条件的行读进内存
-
申请一块内存区域(
sort_buffer_size) -
用快速排序、堆排序等算法排好序
-
返回结果
sql
-- 假设user表只有1000行
SELECT name, age FROM users ORDER BY age;
这个过程就叫filesort(名字吓人,其实很多时候在内存完成)。
什么时候会"只用内存"?
-
数据行总大小 <
sort_buffer_size(MySQL参数,默认256KB~几MB) -
或者优化器估算内存够用
但现实是:你的数据量可能远超内存。
二、【数据太多怎么办】外部归并排序
假设有10GB 的数据需要排序,但你只能拿出1GB内存。
你怎么排?
2.1 分治思想:排序 + 归并
第一步:分块排序
把10GB数据分成10个1GB的小块(称为"run")。每一块单独读进内存,用快排排好,然后写回磁盘。
原始数据: [5,3,8,1,9,2,7,4,6,0] (假设10个数字,内存只能放3个) 分块: 块1: [5,3,8] → 排序 → [3,5,8] 块2: [1,9,2] → 排序 → [1,2,9] 块3: [7,4,6] → 排序 → [4,6,7] 块4: [0] → 排序 → [0]
第二步:多路归并
这时内存里同时打开所有块的文件指针,每次从每个块头部取最小的数字,放入最终结果。
归并过程(4路归并): 块1: [3,5,8] 块2: [1,2,9] 块3: [4,6,7] 块4: [0] 取最小 → 0 (来自块4) 取最小 → 1 (来自块2) 取最小 → 2 (来自块2) 取最小 → 3 (来自块1) ... 最终得到完全排序的序列
这就是外部归并排序的核心。
2.2 实际数据库里的优化
-
优先队列(堆):不用每次比较所有块的头元素,用堆来取最小值,O(logK) per row
-
增大块大小:内存允许的情况下,块越大,块数越少,归并次数越少
-
多次归并:如果块太多(比如10000块),一次归并不了,就先归并成少量大块,再最终归并
2.3 代码模拟核心逻辑
python
import heapq
def external_sort(input_file, output_file, memory_lines=1000):
"""
模拟外部排序核心:分块排序 + 多路归并
"""
runs = []
# 阶段1:分块排序,写临时文件
with open(input_file, 'r') as f:
chunk = []
for line in f:
chunk.append(line)
if len(chunk) >= memory_lines:
chunk.sort()
tmp_file = f'tmp_{len(runs)}.txt'
with open(tmp_file, 'w') as tmp:
tmp.writelines(chunk)
runs.append(tmp_file)
chunk = []
if chunk:
chunk.sort()
tmp_file = f'tmp_{len(runs)}.txt'
with open(tmp_file, 'w') as tmp:
tmp.writelines(chunk)
runs.append(tmp_file)
# 阶段2:多路归并(用堆)
files = [open(run, 'r') for run in runs]
heap = []
for i, f in enumerate(files):
line = f.readline()
if line:
heapq.heappush(heap, (line, i))
with open(output_file, 'w') as out:
while heap:
val, idx = heapq.heappop(heap)
out.write(val)
next_line = files[idx].readline()
if next_line:
heapq.heappush(heap, (next_line, idx))
for f in files:
f.close()
面试时你能画出这个流程图,比背"外部排序"四个字强100倍。
三、【索引的秘密】B+树已经排好序了,不用再排
回到开头小明的例子:为什么加了(create_time)索引就秒出?
因为B+树的叶子节点本身就是按照索引键值有序的。
3.1 B+树长什么样
[50] / \ [30] [80] / \ / \ [10,20] [40] [60,70] [90,100] ↑ ↑ ↑ ↑ 按顺序链起来的叶子节点
叶子节点之间用双向指针连起来,形成了天然的有序链表。
3.2 有了索引,排序怎么做?
当你的ORDER BY字段正好是索引列时:
-
直接从索引的最左边叶子节点开始
-
沿着叶子节点链表一路向右遍历
-
每读一行,回表拿到完整行(如果是覆盖索引则不用回表)
时间复杂度:O(N),N是返回的行数,而不是全表行数。
sql
-- 假设create_time有索引
SELECT * FROM orders ORDER BY create_time LIMIT 100;
数据库执行:找到索引最左边的叶子,读100个叶子节点,回表100次。完事。
3.3 什么时候索引反而帮不上忙?
| 情况 | 原因 |
|---|---|
ORDER BY col1, col2 但索引是(col2, col1) |
顺序不匹配 |
ORDER BY col1 DESC, col2 ASC 但索引都是ASC |
方向不一致 |
WHERE col1 > 10 ORDER BY col2 且索引是(col1, col2) |
过滤字段和排序字段不是同一个前缀 |
| 数据量很小,优化器觉得全表扫描+filesort更快 | 优化器估算错误 |
3.4 用不用索引的决策
数据库优化器会估算两个代价:
-
方案A:走索引,避免排序。但回表可能很贵(随机IO)
-
方案B:全表扫描,用filesort。如果sort_buffer够大,纯内存排序很快
如果你发现明明有索引但没走,可以尝试force index,但更好的办法是调整sort_buffer_size或者改SQL。
四、实战演算:10GB数据,1GB内存
我们用数字说话:
| 步骤 | 操作 | 耗时估算 |
|---|---|---|
| 1 | 读10GB原始数据,分10个1GB块 | 10GB 顺序读:约10秒(SSD) |
| 2 | 每块内存排序(1GB数据快排) | 排序复杂度 O(NlogN),10块约20秒 |
| 3 | 写10个临时文件 | 10GB 顺序写:约10秒 |
| 4 | 10路归并,读回10GB | 10GB 随机读(多路同时读):可能30秒 |
| 5 | 写最终结果文件 | 10GB 顺序写:约10秒 |
总计:80秒左右。
这就是为什么大排序慢。如果建了索引,可能1秒内完成(前提是查询走索引且回表少)。
五、总结:排序优化的三板斧
1. 能走索引就走索引
-
让
ORDER BY字段成为索引的一部分 -
注意索引列顺序与
ORDER BY一致 -
尽量用覆盖索引,避免回表
2. 控制返回行数
-
加上
LIMIT,数据库可能用top-N堆排序算法,不用全排序 -
比如
ORDER BY id LIMIT 10,只需一个大小为10的堆,扫描一遍即可
3. 调整内存参数
-
增大
sort_buffer_size,减少外部归并的次数 -
但如果太大,多连接时总内存会爆,需谨慎
面试回答模板
"当数据量小于sort_buffer时,数据库直接在内存快排。数据量大了就用外部归并排序:分块排序后多路归并。如果有合适的索引,比如ORDER BY的字段正好是B+树的索引列,那么叶子节点已经有序,可以直接遍历,不需要额外排序。"
加一句实操建议:"我一般在慢查询里看到Using filesort,会先检查能不能加索引消除排序。如果不能,再看sort_buffer_size和max_length_for_sort_data的配置。"