SQL 查询性能优化:从执行时间到 IO、执行计划与数据访问路径
SQL 查询性能优化,不能只看“这条 SQL 执行了多少秒”。
一条 SQL 当前执行只需要 200ms,并不代表它是一条性能良好的 SQL。如果它为了返回 100 行数据,却读取了 100 万行、产生几十万次逻辑读,那么当数据量、并发量和缓存压力增加后,它很可能迅速退化。
因此,SQL 性能优化的核心不是单纯追求“当前执行得快”,而是减少数据库为得到最终结果所付出的资源成本。
可以把 SQL 查询性能抽象为:
[
Performance = f(ExecutionPlan, IO, CPU, Rows, Loops, Memory, Wait)
]
也就是说,SQL 性能主要由以下几个因素决定:
- 执行计划
- 逻辑 IO
- 物理 IO
- CPU 消耗
- 实际处理行数
- 算子执行次数
- 内存使用
- 锁和等待
一套好的 SQL 优化方法,应该围绕这些指标展开,而不是只看执行时间。
一、SQL 性能优化首先看什么
建议固定观察以下 7 个核心指标:
- Execution Time:执行时间
- CPU Time:CPU 时间
- Logical Reads:逻辑读取
- Physical Reads:物理读取
- Rows Read:读取行数
- Rows Returned:最终返回行数
- Operator Loops:算子执行次数
其中最容易被忽略、但又极其重要的是:
Logical Reads Rows Read Loops因为这三个指标更能反映 SQL 的真实成本。
例如:
返回数据:100 行 扫描数据:1,000,000 行那么数据访问效率为:
[
Efficiency =
\frac{RowsReturned}{RowsRead}
]
即:
[
Efficiency =
\frac{100}{1000000}
= 0.01%
]
这意味着数据库做了大量无效工作。
即使当前查询只运行 300ms,这条 SQL 依然值得优化。
二、不要只看执行时间
这是 SQL 性能分析中最重要的原则之一。
例如一条 SQL:
Elapsed Time: 200ms Logical Reads: 900000 Physical Reads: 0看起来只执行了 200ms。
但是Physical Reads = 0很可能只是因为数据已经存在 Buffer Cache 中。
数据库并没有真的从磁盘读取,而是直接从内存中读取了大量数据。
此时 SQL 的真实问题仍然存在:
Logical Reads = 900000随着以下条件发生变化:
数据量增加 并发增加 Buffer Cache 竞争增加 数据库内存压力增加查询性能可能变成:
200ms ↓ 1s ↓ 5s ↓ 10s所以应该区分两个概念:
执行时间 = 当前环境下 SQL 跑得快不快 IO / Rows / Loops = SQL 本身设计得好不好SQL 优化更应该关注第二个问题。
三、SQL Server 查询性能分析
SQL Server 提供了一套非常成熟的性能分析工具。
最常用的是:
setstatisticsioon;setstatisticstimeon;select...from...where...;setstatisticsiooff;setstatisticstimeoff;其中:
setstatisticsioon;用于观察 IO。
例如:
Table 'LSXHDMX'. Scan count 9, logical reads 928764, physical reads 0这里最值得关注的是:
logical reads 928764表示 SQL Server 从 Buffer Pool 中读取了大约 92 万个数据页。
SQL Server 一个 Page 默认 8KB。
所以大致访问的数据量为:
[
928764 \times 8KB
]
约等于:
[
7.1GB
]
这并不代表真的从磁盘读取了 7GB,而是表示 SQL Server 在内存页层面进行了如此大量的数据访问。
这通常说明存在:
大范围扫描 索引不合适 Nested Loop 被大量重复执行 过滤条件没有有效下推 关联顺序不合理 统计信息估算错误四、SQL Server 的 Statistics IO 怎么看
典型输出:
Table 'OrderItem'. Scan count 20, logical reads 100000, physical reads 0, read-ahead reads 200几个重要指标分别表示:
1. Logical Reads
logical reads表示数据库从 Buffer Pool 中读取的数据页数量。
这是 SQL Server 优化中最应该关注的指标之一。
通常来说:
在返回结果基本一致的情况下 Logical Reads 越少越好例如:
优化前:
logical reads = 500000优化后:
logical reads = 5000那么即使执行时间暂时变化不大,这通常也是一次非常成功的优化。
2. Physical Reads
表示数据库真正从存储设备读取的数据页数量。
例如:
physical reads = 0并不意味着查询成本很低。
它往往只意味着:
数据已经在内存中所以 SQL 优化不能只看 Physical Reads。
3. Scan Count
表示相关访问路径执行的次数。
例如:
Scan count = 1通常问题不大。
如果出现:
Scan count = 10000那么应该重点检查执行计划。
很多情况下是:
Nested Loop导致内部表被重复访问。
五、一定要看实际执行计划
SQL 优化不能只看 SQL 文本。
真正决定数据库如何执行的是:
Execution Plan也就是执行计划。
SQL Server 中建议直接查看:
Actual Execution Plan重点关注几个节点:
Table Scan Index Scan Index Seek Nested Loops Hash Match Merge Join Sort Key Lookup RID Lookup Spool六、Scan 不一定差,Seek 不一定好
很多开发人员会形成一种简单认知:
Index Seek = 好 Index Scan = 差 Table Scan = 很差实际上并不完全正确。
例如查询:
select*fromsaleswheresale_date>='2026-01-01';如果这条 SQL 最终需要返回表中 70% 的数据,那么:
Index Scan可能比:
Index Seek + 大量 Key Lookup更高效。
所以判断执行计划不能只看算子名字,而应该同时看:
Rows Cost IO Loops Lookup 次数优化的目标不是:
强制所有查询都变成 Index Seek而是:
让数据库用最低成本获取目标数据七、重点检查 Actual Rows 和 Estimated Rows
执行计划中一个非常关键的指标是:
Estimated Rows Actual Rows例如:
Estimated Rows = 100 Actual Rows = 1,000,000说明优化器估算严重错误。
数据库原本认为:
只有 100 行于是可能选择:
Nested Loop但实际数据是:
100 万行最终导致 Nested Loop 内层执行几十万甚至几百万次。
这种情况通常需要检查:
统计信息 参数嗅探 数据倾斜 临时表 表变量 表达式 关联条件因此执行计划分析中,一个非常重要的问题是:
优化器以为有多少行,实际上有多少行?
八、重点观察 Rows × Loops
很多慢 SQL 的真正问题,不是单次查询慢,而是某个操作执行了太多次。
例如:
Index Seek Actual Rows = 100 Actual Number of Executions = 10000那么实际处理量大约是:
[
100 \times 10000
1,000,000
]
也就是说,虽然执行计划上看只是一个普通的 Index Seek,但它被执行了一万次。
这是典型的:
Nested Loop 放大效应因此分析 SQL 时建议建立一个简单意识:
[
TotalWork
\approx
Rows
\times
Loops
]
很多性能问题都可以通过这个公式发现。
九、索引优化的核心不是“加索引”
SQL 性能问题出现后,最常见的处理方式就是:
加索引但这是一种非常危险的习惯。
正确的索引设计应该从查询模式出发。
例如:
selectproduct_code,quantity,amountfromsale_detailwhereshop_code='001'andsale_date>='2026-09-01'andsale_date<'2026-10-01';可以考虑索引:
createindexix_sale_detail_shop_dateonsale_detail(shop_code,sale_date)include(product_code,quantity,amount);这里:
shop_code sale_date用于定位数据。
而:
product_code quantity amount通过 INCLUDE 覆盖查询。
这样可以减少:
Key Lookup十、索引字段顺序非常重要
联合索引:
(a,b,c)并不等价于:
(c,b,a)通常可以按照下面的思路设计:
等值条件 ↓ 范围条件 ↓ 排序 / 分组 ↓ Include 字段例如:
whereshop_code=?andproduct_code=?andsale_datebetween?and?那么可能考虑:
(shop_code, product_code, sale_date)而不是简单按照数据库字段原来的顺序创建索引。
十一、避免对索引字段进行函数计算
典型错误:
whereconvert(varchar,rq,23)='2026-09-28'或者:
whereyear(rq)=2026这会导致数据库很难直接使用rq上的索引进行 Seek。
更推荐:
whererq>='2026-09-28'andrq<'2026-09-29'同样:
whereyear(rq)=2026应该考虑改成:
whererq>='2026-01-01'andrq<'2027-01-01'优化原则是:
尽量不要加工索引列,而应该加工查询参数。
十二、日期范围推荐使用左闭右开
日期查询非常容易写出问题。
例如:
whererqbetween'2026-09-01'and'2026-09-30 23:59:59'这种写法容易受到:
datetime datetime2 毫秒精度影响。
推荐统一使用:
whererq>='2026-09-01'andrq<'2026-10-01'即:
[
[startDate,endDate)
]
这种方式:
- 更准确
- 更容易使用索引
- 更容易生成动态 SQL
- 更容易统一报表日期逻辑
十三、减少无效数据读取
SQL 优化中最有效的方法之一不是“让数据库执行得更聪明”,而是:
让数据库少读数据例如:
select*fromsale_detail;如果实际上只需要:
selectspdm,sl,jefromsale_detail;那么应该避免select *。
尤其是在:
大宽表 LOB text json varchar(max)场景下,读取不需要的字段会明显增加:
IO 内存 网络传输 序列化成本十四、尽早过滤数据
例如:
select...from(select*fromsale_detail)awherea.shop_code='001';逻辑上可以工作,但复杂 SQL 中应该尽量让过滤条件尽早进入数据源。
理想结构:
select...fromsale_detailwhereshop_code='001';在复杂报表中尤其要关注:
Predicate Pushdown即过滤条件是否真正下推到最底层的数据访问节点。
优化目标应该是:
先减少数据 再关联 再聚合 再排序而不是:
先把所有数据关联起来 ↓ 最后再过滤十五、JOIN 是性能优化重点
大型业务 SQL 中,JOIN 往往是主要性能瓶颈。
例如:
ajoinbjoincjoindjoine最终性能取决于:
各表数据量 过滤条件 JOIN 字段索引 JOIN 顺序 统计信息 选择性 执行计划尤其要关注:
大表 JOIN 大表例如:
销售明细:5 亿行 库存流水:3 亿行 采购明细:1 亿行如果直接 Join:
salejoinstockjoinpurchase很容易出现数据量爆炸。
更好的办法通常是:
先按业务粒度分别聚合 ↓ 再 JOIN例如:
sale 先 group by shop + product stock 先 group by shop + product purchase 先 group by shop + product 最后三份结果 JOIN这样可以大幅降低 Join 数据量。
十六、GROUP BY 前尽量减少数据
例如:
selectshop_code,product_code,sum(quantity)fromsale_detailgroupbyshop_code,product_code;如果 sale_detail 有 5 亿条数据,那么 Group By 本身成本可能很高。
如果实际只统计最近 7 天:
whererq>=dateadd(day,-6,@endDate)andrq<dateadd(day,1,@endDate)一定要在 Group By 之前过滤。
即:
5亿 ↓ 先过滤 ↓ 500万 ↓ 再 GROUP BY而不是:
5亿 ↓ GROUP BY ↓ 最后再过滤十七、临时表不是坏东西
很多人认为:
一条 SQL 写完比:
临时表拆分更高级。
实际上对于复杂业务查询,这并不一定成立。
例如一个 SQL 同时存在:
销售 退货 库存 采购 价格 会员 促销 门店 商品全部写成一个巨大 CTE:
withaas(...),bas(...),cas(...),das(...)select...优化器可能很难得到稳定执行计划。
此时可以合理使用:
select...into#salefrom...createindexix_sale...on#sale(...);然后:
select...from#salejoin...临时表有几个重要价值:
中间结果物化 降低查询复杂度 重新生成统计信息 建立临时索引 限制数据规模 提高执行计划稳定性所以:
临时表应该被视为一种查询执行策略,而不是性能优化失败的标志。
十八、CTE 并不等于缓存
例如:
withsaleas(select...fromsale_detail)select...fromsale;很多开发人员误以为:
sale 已经执行一次并保存起来实际上 CTE 更多只是:
查询表达式数据库完全可能把它重新展开到最终执行计划中。
所以复杂查询中,如果同一个中间结果被反复使用,应该观察执行计划。
必要时使用:
临时表 物化视图 中间汇总表十九、ORDER BY 和 SORT 成本不能忽略
排序:
orderbyamountdesc看起来很简单。
但如果需要排序 1000 万行,成本可能非常高。
执行计划可能出现:
Sort并且发生:
Memory Spill即排序所需内存不足,将数据写入 TempDB 或磁盘。
因此应该关注:
排序行数 排序字段是否有索引 Memory Grant TempDB Spill很多报表 SQL 的真正瓶颈并不是 Join,而是:
大规模 Sort二十、分页查询需要特别设计
常见分页:
orderbyidoffset1000000rowsfetchnext20rowsonly;页数越深,性能越差。
因为数据库仍然可能需要处理前面的:
100 万行大型系统可以考虑:
Seek Pagination也叫:
Keyset Pagination例如:
whereid>@lastIdorderbyidfetchnext20rowsonly;这样性能更容易保持稳定。
二十一、避免 N+1 查询
SQL 优化不仅发生在数据库端。
ORM 系统经常出现:
查询订单 100 条 ↓ 每个订单查询一次明细 ↓ 101 次数据库访问也就是:
N + 1即使每条 SQL 只执行 5ms:
[
101 \times 5ms
]
也已经超过 500ms。
所以应用侧同样应该观察:
一次请求执行了多少 SQL而不仅仅是:
某一条 SQL 执行了多久二十二、MySQL 怎么分析 SQL
MySQL 8 推荐:
explainanalyzeselect...重点看:
actual time rows loops Table scan Index lookup Nested loop例如:
actual rows=10000 loops=1000说明实际处理规模约为:
[
10000
\times
1000
1000万
]
因此 MySQL 同样应该重点观察:
Rows Loops而不是只看执行时间。
二十三、PostgreSQL 怎么分析 SQL
PostgreSQL 推荐:
explain(analyze,buffers)select...其中:
Buffers: shared hit shared read非常重要。
可以近似理解:
shared hit ≈ SQL Server logical reads shared read ≈ 实际读取例如:
shared hit=900000 shared read=0和 SQL Server:
logical reads=900000 physical reads=0反映的是类似的问题。
二十四、Oracle 怎么分析 SQL
Oracle 可以通过:
setautotraceonstatistics;查看:
consistent gets physical reads rows processed其中:
consistent gets与 SQL Server:
logical reads非常类似。
更加专业的分析可以结合:
DBMS_XPLAN查看:
E-Rows A-Rows即:
Estimated Rows Actual Rows其核心思想与 SQL Server 完全一致。
二十五、建立统一的跨数据库性能模型
实际上不同数据库虽然命令不同,但底层问题高度一致。
可以统一成下面的分析模型:
SQL │ ▼ Execution Plan │ ┌──────────┼──────────┐ ▼ ▼ ▼ IO CPU Memory │ ▼ Rows × Loops │ ▼ Actual Work │ ▼ Execution Time其中:
Execution Time更像最终表现。
而:
IO Rows Loops Execution Plan才是根因。
因此可以把 SQL 优化过程抽象成:
[
SQL
\rightarrow
Plan
\rightarrow
IO
\rightarrow
Rows
\rightarrow
Loops
\rightarrow
Time
]
二十六、推荐的 SQL 性能优化流程
实际工作中,可以固定按照下面的顺序分析。
第一步:确认 SQL
首先保存原始 SQL。
不要一开始就修改 SQL,否则后面无法比较优化效果。
第二步:记录基准数据
记录:
执行时间 CPU 时间 Logical Reads Physical Reads 返回行数例如:
Elapsed Time = 3200ms CPU Time = 2800ms Logical Reads = 928764 Rows Returned = 2300第三步:查看实际执行计划
检查:
Table Scan Index Scan Index Seek Nested Loop Hash Match Sort Key Lookup Spool第四步:寻找最大成本节点
不要平均用力。
通常应该寻找:
读取数据最多的节点 执行次数最多的节点 行数估算偏差最大的节点 IO 最大的节点也就是寻找:
[
MaxCostOperator
]
第五步:检查 Rows × Loops
例如:
Rows = 5000 Loops = 10000那么这个节点很可能就是问题所在。
第六步:检查索引
确认:
过滤字段 Join 字段 排序字段 Group By 字段是否存在适合的索引。
第七步:检查数据是否可以提前减少
考虑:
提前 WHERE 提前 GROUP BY 拆临时表 减少 JOIN 减少字段第八步:重新执行 SQL
再次运行:
setstatisticsioon;setstatisticstimeon;获得新的指标。
第九步:对比优化前后
例如:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| Logical Reads | 928,764 | 21,430 |
| CPU | 2,800ms | 320ms |
| Elapsed | 3,200ms | 410ms |
| Rows | 2,300 | 2,300 |
这样的优化才是可以量化和验证的。
二十七、一个非常重要的优化原则
SQL 性能优化的核心目标可以浓缩成一句话:
用尽可能少的数据访问,得到相同的业务结果。
因此:
[
Performance
\propto
\frac{UsefulData}{TotalWork}
]
如果:
读取 100 万行 返回 100 行通常意味着查询效率较低。
如果:
读取 120 行 返回 100 行则查询的数据访问路径通常非常理想。
二十八、SQL 性能优化的三个层次
从架构角度,可以把 SQL 优化分成三个层次。
第一层:SQL 语句优化
例如:
WHERE JOIN GROUP BY ORDER BY 子查询 CTE 窗口函数第二层:数据库访问结构优化
例如:
索引 分区 统计信息 物化视图 临时表 汇总表第三层:系统架构优化
例如:
缓存 读写分离 报表库 OLTP / OLAP 分离 预计算 数据仓库 ClickHouse 异步统计如果一条报表每次查询都需要:
扫描 10 亿销售明细 + 扫描 5 亿库存流水 + 扫描 3 亿采购记录那么继续优化 SQL 本身的收益已经有限。
此时真正需要考虑的是:
数据模型和系统架构二十九、最后的判断标准
判断 SQL 是否优秀,不应该只问:
执行了多少秒?而应该连续问几个问题:
为了返回这些数据, 到底读取了多少数据? 实际处理了多少行? 某个算子执行了多少次? 执行计划是否稳定? 数据增长 10 倍后还能不能运行?真正优秀的 SQL 应该同时具备:
低 IO 低 CPU 低 Rows Read 低 Loops 执行计划稳定 数据量增长后性能可预测最终可以把 SQL 性能优化浓缩成一个模型:
[
SQLPerformance
f(
DataAccess,
ExecutionPlan,
IO,
Rows,
Loops,
CPU
)
]
其中最值得关注的是:
[
DataAccess
]
因为绝大多数 SQL 性能问题,本质上都可以归结为一句话:
数据库读取和处理了太多本来不应该处理的数据。
因此,SQL 查询优化真正的目标不是让数据库“跑得更快”,而是:
让数据库少做无效工作。