工程算法实战 | 数据库ORDER BY的底层:内存排序 → 外部归并 → 索引优化

你有没有好奇过:一句简单的 SELECT * FROM users ORDER BY age,数据库背后到底忙活了什么?


一个真实的故事

小明写了一条SQL:

sql 复制代码
SELECT * FROM orders ORDER BY create_time LIMIT 100;

数据量500万行,跑了一分钟还没出来。

DBA走过来看了一眼,说:"加个索引吧。" 加了 (create_time) 索引后,30毫秒出结果。

小明惊呆了:"加了索引就能快几万倍?排序不就是要排吗?索引怎么做到的?"

今天我们就从"数据太多放不下内存"开始,一层层揭开ORDER BY的底牌。


一、【直观想法】全部读进内存,快排

如果数据量不大,数据库的做法非常朴实:

  1. 把所有满足WHERE条件的行读进内存

  2. 申请一块内存区域(sort_buffer_size

  3. 用快速排序、堆排序等算法排好序

  4. 返回结果

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字段正好是索引列时:

  1. 直接从索引的最左边叶子节点开始

  2. 沿着叶子节点链表一路向右遍历

  3. 每读一行,回表拿到完整行(如果是覆盖索引则不用回表)

时间复杂度: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_sizemax_length_for_sort_data的配置。"

相关推荐
yexuhgu1 小时前
如何在 JavaScript 循环中动态构建 HTML 字符串
jvm·数据库·python
wang3zc1 小时前
使用BERTopic对名言数据集进行批量主题建模的完整实践指南
jvm·数据库·python
SZLSDH1 小时前
数字孪生IOC的“双引擎”架构:当业务编排遇上渲染管线,如何实现场景适配?
数据库·ai·架构·数字孪生·数据可视化·智能体
码界筑梦坊1 小时前
361-基于Python的空气质量气候数据分析预测系统
python·信息可视化·数据分析·flask·vue·毕业设计
m0_609160491 小时前
Go语言如何做协程调度_Go语言协程调度原理教程【实用】
jvm·数据库·python
2301_812539671 小时前
golang如何实现全量数据迁移_golang全量数据迁移实现详解
jvm·数据库·python
顾随1 小时前
(2)达梦数据库--SQl基础实践
前端·数据库·sql
小陈的进阶之路1 小时前
安集商城接口自动化项目架构介绍
python·自动化·pytest
zhaoyong2221 小时前
uni-app怎么获取短信验证码 uni-app接入短信平台流程【实战】
jvm·数据库·python