SQL 查询优化:读取执行计划并修复慢速查询(带计时)

  1. 首页
  2. 博客
  3. 数据库和 SQL

数据库与 SQL

SQL 查询优化:读取执行计划并修复慢速查询(带计时)

不要再猜测查询速度慢的原因。学习阅读执行计划,然后通过来自一百万行的真实表的前后测量来应用最重要的修复。

阅读约 9 分钟
SQL 查询优化:读取执行计划并修复慢速查询,并计时

要点速览

  • 从执行计划开始。它告诉您数据库实际上做了什么。
  • 查找大表的完整扫描,然后添加与您的过滤器和连接匹配的索引。
  • 避免将索引列包装在函数中,因为这可能会阻止有效的索引使用。
  • 测量之前和之后,因为每个数据库和数据集的行为都不同。

去年需要几毫秒的查询现在可能需要几分钟,仅仅是因为表增长了。 SQL 查询优化 与其说是巧妙的技巧,不如说是一个可重复的过程:找出数据库正在做什么,消除浪费的工作,并检查它是否有帮助。

我们在 SQLite 3.50 中生成的一百万行上测试了下面的每种技术。计时是使用内存数据库在一台笔记本电脑上运行的几次运行中最好的。它们与您的系统不匹配,并且 PostgreSQL、MySQL 和 SQL Server 都有自己的优化器和计划格式。关注每项改进的规模及其背后的原则。

结果一目了然

技术 之前 之后
过滤器列上的索引 73 毫秒 0.01毫秒
范围而不是列上的函数 96 毫秒 33毫秒
两个过滤器的复合索引 19 毫秒 2.2毫秒
键设置而不是深度 OFFSET 8.1 毫秒 0.01毫秒

测试设置

CREATE TABLE customers (
  id INTEGER PRIMARY KEY, name TEXT, city TEXT
);
CREATE TABLE orders (
  id INTEGER PRIMARY KEY, customer_id INT, status TEXT,
  created TEXT, total INT, note TEXT
);
-- 50,000 customers and 1,000,000 orders with random values

一个简单的优化过程

  1. 找到重要的查询。使用慢速查询日志或监控。每分钟运行 1000 次、时长为 200 毫秒的查询可能比 10 秒的月度报告更重要。
  2. 阅读计划。了解数据库实际上在做什么。
  3. 改变一件事。添加索引或重写部分查询。
  4. 根据实际数据再次测量。针对 100 个测试行的计划只能告诉您关于 100 万行的信息。
  5. 随着时间的推移进行观察。 计划可能会随着数据的增长和统计数据的变化而改变。

阅读执行计划

执行计划是数据库运行查询的逐步策略,包括它使用哪些索引以及它连接表的顺序。为查询添加前缀以查看它:

数据库 命令
SQLite 解释查询计划选择...
PostgreSQL 解释分析选择...
MySQL EXPLAIN SELECT ... 或 EXPLAIN ANALYZE
SQL服务器 SSMS中的实际执行计划

请注意,PostgreSQL 中的 EXPLAIN ANALYZE 确实运行查询,因此与 UPDATE 或 DELETE 一起使用时要小心。以下是首先要寻找的内容:

你看 含义 通常
扫描或顺序扫描 读取整个表 大桌子上的问题
搜索或索引查找 跳转到匹配的行 好
覆盖指数 仅从索引中回答 很好
临时 B 树或排序 额外的分类工作 值得检查

完整扫描不会自动变坏。如果查询需要大部分小表,扫描可能是最快的选择。在 PostgreSQL 的 EXPLAIN ANALYZE 中,还将估计行与实际行进行比较。巨大的差异通常意味着表统计数据已经过时。

添加正确的索引

此查询查找一位客户的订单:

SELECT id, total FROM orders WHERE customer_id = 123;

在添加索引之前,计划是全盘扫描:

SCAN orders
-- 20 rows in 73 ms

然后我们在过滤列上添加索引:

CREATE INDEX idx_orders_customer ON orders(customer_id);

SEARCH orders USING INDEX idx_orders_customer (customer_id=?)
-- 20 rows in 0.01 ms

同样的 20 行返回速度快了数千倍,因为数据库不再读取一百万行来查找它们。本文中没有其他技术给我们带来了更大的胜利。好的第一个候选列是 WHERE、JOIN 和 ORDER BY 中使用的列,特别是当它们将结果缩小到几行时。我们的 SQL 索引 指南解释了它们的工作原理。

不要将索引列包装在函数中

以下是查找 3 月订单的两种方法,索引位于 created:

-- Function on the column
SELECT id FROM orders WHERE substr(created, 1, 7) = '2026-03';
-- SCAN orders USING COVERING INDEX idx_orders_created
-- 84,961 rows in 96 ms

-- Plain range
SELECT id FROM orders
WHERE created >= '2026-03-01' AND created < '2026-04-01';
-- SEARCH orders USING COVERING INDEX idx_orders_created (created>? AND created<?)
-- 84,961 rows in 33 ms

两者都返回相同的行,但第一个花费的时间大约是前者的三倍。该计划说明了原因:使用列上的函数,数据库无法跳转到排序索引中的位置,因此它必须 SCAN 遍历每个条目。范围版本可以SEARCH直接到达正确的位置。能够以这种方式使用索引的条件称为sargable。

这些常见模式也会出现同样的问题:

避免 更喜欢
年份(创建)= 2026 何处创建 >= '2026-01-01' 并且创建 < '2027-01-01'
其中价格 * 2 > 100 哪里价格 > 50
哪里较低(电子邮件)= 'a@b.com' 基于函数的索引或不区分大小写的排序规则
名称类似于“%99”的地方 前缀搜索或全文搜索

我们也检查了最后一项。产生了前导通配符 SCAN customers,因为从第一个字母排序的索引无助于末尾的匹配。

综合指数

当查询过滤两列时,单列索引仅执行部分工作:

SELECT id FROM orders
WHERE status = 'paid'
  AND created >= '2026-03-01' AND created < '2026-03-08';

-- Using only the created index: 6,198 rows in 19 ms

CREATE INDEX idx_status_created ON orders(status, created);

-- SEARCH orders USING COVERING INDEX idx_status_created
--   (status=? AND created>? AND created<?)
-- 6,198 rows in 2.2 ms

列顺序非常重要。 (status, created) 上的索引就像电话簿一样,先按姓氏排序,然后按名字排序。您可以单独查找姓氏,但不能单独查找名字。我们对 status 的查询单独可以使用这个索引。单独对created进行过滤通常无法有效地使用它。根据经验,首先放置经过相等性测试的列,然后放置范围列。

该计划还提到覆盖索引,这意味着索引保存了查询所需的所有内容,因此不必读取表本身。仅选择您需要的列会使这种情况更有可能发生,这是要避免的另一个原因 SELECT *。

更快的分页

对深层页面使用 OFFSET 是一种隐藏成本,因为数据库仍然必须遍历所有跳过的行:

-- Page deep into the results
SELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 500000;
-- 8.1 ms

-- Keyset pagination: continue from the last id seen
SELECT id FROM orders WHERE id > 500000 ORDER BY id LIMIT 20;
-- 0.01 ms

无论您走多深,键集分页都保持快速。当添加新数据时,它还可以避免页面之间的行移动。代价是用户无法直接跳转到第 5,000 页,而这很少是真正的需要。在具有宽行或要跳过更多行的表上,间隙可能比我们的测试中大得多。

提示: 我们的计时在一台空闲机器上一次测量一个查询。在实际负载下,低效的查询会造成更大的损害,因为它还持有锁并与其他所有内容竞争内存、磁盘和 CPU。

编写更精简的查询

  • 仅选择您需要的列。 SELECT * 发送更多数据,并且可以防止索引覆盖查询。
  • 尽早过滤。 使用 WHERE 在分组之前删除行,HAVING 仅适用于聚合条件。请参阅SQL GROUP BY 和 HAVING。
  • 当您只需要几行时,请使用 LIMIT。
  • 当您不需要删除重复项时,首选 UNION ALL。我们的 UNION 与 UNION ALL 测试显示了这样可以节省什么。
  • 避免不必要的 DISTINCT。 它通常隐藏产生重复的连接。
  • 使用 EXISTS 进行存在性检查。它可以在第一个匹配处停止,正如我们的 SQL 子查询指南所解释的那样。
  • 注意 N+1 查询。 循环中每行一个查询(通常由应用程序代码引起)通常是真正的问题。单个连接或批量查询可以替代数百次往返。

加入

为您加入的列建立索引。我们为一个城市的客户提供了一份总支出报告,该计划正是您所希望的:

SELECT c.name, SUM(o.total)
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.city = 'Goa'
GROUP BY c.id;

-- SCAN c
-- SEARCH o USING COVERING INDEX idx_cover (customer_id=?)
-- 9,900 rows in 34 ms

它扫描客户,然后使用索引来查找每个客户的订单。如果没有 orders.customer_id 上的索引,数据库将不得不为每个客户做更多的工作。外键索引是您可以进行的最常见和最有价值的添加之一。要刷新您对基础知识的理解,请参阅SQL 连接解释。

当索引让你付出代价时

索引不是免费的,因此不要为每一列添加一个:

  • 写入速度较慢。 每次插入、更新和删除也必须更新每个索引。
  • 更多存储空间和内存。 大表上的索引可能会变大。
  • 对非选择性列没有什么好处。 如果大多数行匹配,则 true 或 false 列上的索引可能没有帮助。
  • 冗余。 如果 (a, b) 上已有索引,则 (a) 上的索引通常是不必要的。

定期检查使用情况统计信息,并删除不使用的索引。并使用 ANALYZE 或数据库的等效项保持统计信息最新,因为优化器依赖它们来选择一个好的计划。有时缓慢的部分是争用而不是查询。长时间运行的事务持有锁可能会使快速查询看起来很慢。

常见错误

  • 优化而不测量。许多自信的猜测最终被证明是错误的。
  • 使用微小数据进行测试。随着表的增长,计划和时间安排也会发生巨大变化。
  • 信任另一个数据库的提示。针对一个引擎的建议可能不适用于另一个引擎。
  • 盲目加索引。每加一个都会减慢写入速度。
  • 忽略第一次运行的影响。缓存使第二次运行更快,因此可以运行多次。

最后的话

大多数缓慢的查询都有常见的原因:缺少索引、列上的函数或获取的数据过多。以本周最慢的实际查询为例,对其运行 EXPLAIN,并在大型表上查找完整扫描。修复一件事,再次测量,并记录您所做的更改。

常见问题

什么是 SQL 查询优化?

它是使查询使用更少时间和资源的过程,通常通过检查执行计划、添加合适的索引和重写低效查询。

什么是执行计划?

它是数据库选择的运行查询的策略,包括它如何读取表、它使用哪些索引以及它加入的顺序。

如何找到慢查询?

启用数据库的慢查询日志或使用 PostgreSQL 中的 pg_stat_statements 等工具。然后关注总时间最长的查询。

添加索引总是能让查询更快吗?

不会。它有助于选择性读取,但会减慢写入速度并占用存储空间。如果大多数行符合条件,优化器也可能会忽略它。

SELECT * 对性能有影响吗?

可以的。它传输不需要的数据并可以阻止索引覆盖查询。仅列出您需要的列通常会更好。

继续学习

与Stack Quora合作

需要测试、WordPress 或 SEO 方面的帮助?

我们帮助团队交付更好的软件并发展他们的网站。告诉我们您正在做什么,我们将建议明确的下一步。

继续阅读

更多指南 测试、人工智能和现代开发

喜欢这篇文章吗?选择一个主题并直接跳至我们最新的教程。

人工智能

人工智能幻觉解释:语言模型为何会产生幻觉以及如何减少它

人工智能幻觉解释:它们是什么,为什么语言模型产生它们,常见类型,如何通过基础和提示来减少它们,以及我们进行的简单检查。

阅读文章 →
DevOps

环境变量解释:.env 文件、秘密和配置正确完成

使用我们运行的 Python 示例解释环境变量:读取和设置它们、为什么每个值都是字符串、.env 文件、进程继承、秘密和验证。

阅读文章 →
软件开发

API 速率限制解释:算法、标头和处理 429 错误

API 速率限制通过我们运行的模拟进行了解释:令牌桶、固定窗口、滑动窗口、429 响应、速率限制标头、带回退的重试和测试限制。

阅读文章 →
测试自动化

Python 中的模拟:unittest.mock、补丁以及导致测试谎言的错误

使用unittest.mock 在Python 中进行模拟,经过测试:模拟和return_value、side_effect、修补使用名称的位置、spec 和autospec、模拟日期以及常见错误。

阅读文章 →
软件测试

代码覆盖率解释:行覆盖率与分支覆盖率以及 100% 的真正含义

用真实的coverage.py运行来解释代码覆盖率:行、分支和条件覆盖率,阅读报告,为什么100%覆盖率仍然会错过错误,以及合理的目标。

阅读文章 →
数据库与 SQL

SQL 中的 DELETE、TRUNCATE 和 DROP:差异、回滚以及何时使用它们

DELETE、TRUNCATE 和 DROP 通过经过测试的 SQL 进行了解释:各自删除什么、回滚、触发器、身份重置、速度,以及 PostgreSQL、MySQL、SQL Server 和 SQLite 有何不同。

阅读文章 →
数据库与 SQL

SQL 视图解释:创建、使用和避免陷阱(附示例)

SQL 视图通过我们运行的查询进行解释:创建视图、实时结果、隐藏列、性能、可更新和物化视图、破坏的依赖关系和最佳实践。

阅读文章 →
软件开发

TypeScript 接口与类型:真正的差异,使用编译器进行测试

TypeScript 接口与类型,使用真实编译器进行检查:两者都可以做什么,只有一个可以做什么,声明合并,扩展与交集以及要遵循的简单规则。

阅读文章 →
软件开发

Python *args 和 **kwargs 解释:灵活的函数和清晰的示例

Python *args 和 **kwargs 用我们运行的代码进行了解释:打包和解包、参数顺序、仅关键字和仅位置参数、装饰器和常见错误。

阅读文章 →
软件开发

Python Lambda 函数:语法、实际用途以及何时不使用它们

Python lambda 函数用我们运行的代码进行了解释:语法、使用键排序、映射和过滤器、闭包、后期绑定陷阱,以及何时普通 def 是更好的选择。

阅读文章 →
人工智能

什么是人工智能代理?它们是如何工作的,只需运行一个简单的代理循环

什么是人工智能代理?关于代理循环、工具、内存和护栏的简单指南,以及我们运行的一个小型 Python 循环、真正的风险以及何时简单的聊天机器人是更好的选择。

阅读文章 →
DevOps

监控与可观察性:真正的区别,包括日志、指标和跟踪

监控与可观察性解释:已知和未知的故障、日志、指标和跟踪、检查事件演练、黄金信号、OpenTelemetry 和有用的警报。

阅读文章 →
DevOps

Docker Volumes 与 Bind Mounts:以正确的方式保留容器数据

Docker 卷与绑定挂载解释:为什么容器数据消失、命名卷、绑定挂载、tmpfs、Compose 示例、备份、权限以及何时使用它们。

阅读文章 →
测试自动化

TDD 与 BDD:主要区别、以两种方式构建的一个功能以及何时使用每种方式

TDD 与 BDD 的解释是通过一种双向构建的折扣功能:使用我们运行的代码进行红绿重构、Given-When-Then 场景、工具、权衡以及何时使用每种方案。

阅读文章 →
软件测试

用户验收测试 (UAT):流程、清单和真实示例

用户验收测试解释:谁运行它、它与 QA 有何不同、七步流程、UAT 测试用例模板、清单、签核标准和常见错误。

阅读文章 →
数据库与 SQL

SQL NULL 值解释:IS NULL、COALESCE、NULLIF 和要避免的陷阱

通过我们运行的查询解释了 SQL NULL 值:三值逻辑、IS NULL、COALESCE、NULLIF,聚合、连接和排序如何处理 NULL,以及它悄悄引起的错误。

阅读文章 →
数据库与 SQL

SQL CASE WHEN 语句:语法、实例和常见错误

SQL CASE WHEN 解释了我们运行的查询:简单和搜索表单、条件顺序、SUM 透视、COUNT 技巧、自定义排序、更新和要避免的 NULL 陷阱。

阅读文章 →
软件开发

什么是智威汤逊?通过工作示例解释 JSON Web 令牌

什么是智威汤逊? JSON Web Tokens 解释:这三个部分,在我们运行的代码中构建和篡改的令牌,JWT 与会话,存储,刷新令牌和安全错误。

阅读文章 →
软件开发

JavaScript 事件循环解释:调用堆栈、微任务和计时器以及示例

JavaScript 事件循环用我们运行的代码进行了解释:调用堆栈、任务和微任务队列、为什么 setTimeout 0 不是立即执行、阻塞、饥饿和面试测验。

阅读文章 →
软件开发

Python 生成器和 Yield 解释:通过真实示例节省内存

Python 生成器和yield 用我们运行的代码进行了解释:它们如何暂停和恢复、测量内存节省、管道、无限序列、yield from、发送和常见陷阱。

阅读文章 →
人工智能

什么是嵌入?向量和余弦相似度简单解释

什么是嵌入?关于向量、余弦相似度和语义搜索的简单语言指南,其中包含我们运行的一个小示例、常见用途、选择模型和已知限制。

阅读文章 →
DevOps

蓝绿部署与金丝雀部署:差异、权衡以及如何选择

蓝绿部署与金丝雀部署解释:每种部署的工作原理、成本、风险和回滚、我们测试的稳定用户分桶、数据库迁移、滚动更新以及如何选择。

阅读文章 →
测试自动化

不稳定的测试:造成这些问题的原因以及如何彻底解决它们

我们运行的演示解释了不稳定的测试:主要原因、如何确认和诊断、正确的时间修复、共享状态和日期以及隔离过程。

阅读文章 →
软件测试

负载测试与压力测试:差异、指标和 k6 示例

负载测试与压力测试的解释:各自证明什么、峰值和浸泡测试、重要的指标、为什么平均值会误导、k6 个示例以及如何计划测试。

阅读文章 →
软件测试

测试用例与测试场景:主要区别、示例以及何时使用它们

测试用例与测试场景的解释:清晰的定义、并排的表格、从场景到案例的密码重置功能、模板以及何时使用每个功能。

阅读文章 →
数据库与 SQL

SQL 查询优化:读取执行计划并修复慢速查询(带计时)

对一百万行进行 SQL 查询优化测试:读取执行计划、添加正确的索引、修复不可控制的过滤器、使用复合索引和键集分页。

阅读文章 →
数据库与 SQL

SQL UNION 与 UNION ALL:差异、示例和性能测试

SQL UNION 与 UNION ALL 通过我们运行的查询进行了解释:重复、都遵循的规则、真实的数据错误、测量的性能、INTERSECT 和 EXCEPT 以及何时使用它们。

阅读文章 →
软件开发

JavaScript Fetch API 教程:GET、POST、错误处理和超时

针对本地服务器测试的 JavaScript Fetch API 教程:GET 和 POST、为什么不抛出 404、可重用的帮助程序、查询字符串、超时、CORS 和常见错误。

阅读文章 →
软件开发

Python 文件处理:读取、写入、CSV、JSON 和 pathlib 以及示例

使用我们运行的代码解释了 Python 文件处理:打开模式、使用、读取大文件、CSV、JSON、pathlib、二进制文件、安全写入、错误处理和常见错误。

阅读文章 →
软件开发

Python 类和对象:带有实际示例的 OOP 教程

用我们运行的代码解释 Python 类和对象:__init__、方法、类与实例属性、继承、属性、dunder 方法、数据类和常见错误。

阅读文章 →
人工智能

什么是大语言模型?法学硕士如何运作,在没有炒作的情况下进行解释

什么是大语言模型?法学硕士用简单的语言解释:标记、嵌入、注意力、训练、温度和上下文窗口、一个微小的下一个单词演示和真正的限制。

阅读文章 →
DevOps

适合初学者的 Linux 命令:带有示例的基本终端指南

适合初学者的 Linux 命令,按任务分组:导航、文件、查看日志、grep、管道、权限、进程和档案,并附有实用的日志分析示例。

阅读文章 →
测试自动化

Selenium 与 Playwright:主要区别、代码比较以及选择

Selenium 与 Playwright 比较:架构、等待、浏览器和语言支持、调试、两种工具中的相同测试、迁移建议以及如何选择。

阅读文章 →
软件测试

等价划分:通过实例解释测试设计技术

等效分区解释:如何找到有效和无效分区,年龄、密码和优惠券的工作示例,我们运行的测试,以及为什么将其与边界配对。

阅读文章 →
软件测试

软件测试中的严重性与优先级:差异、矩阵和实例

软件测试中的严重性与优先级解释:清晰的定义、带有示例的四向矩阵、级别尺度、谁设置每个级别以及如何运行错误分类。

阅读文章 →
数据库与 SQL

SQL 子查询教程:类型、相关子查询和 EXISTS 以及示例

SQL 子查询教程,其中包含我们运行的查询:标量、IN、FROM 和 SELECT 子查询、相关子查询、EXISTS、NOT IN NULL 陷阱以及子查询与 JOIN 与 CTE。

阅读文章 →
数据库与 SQL

SQL 事务和 ACID 解释:COMMIT、ROLLBACK 和隔离级别

SQL 事务和 ACID 通过我们运行的银行转账进行了解释:COMMIT、ROLLBACK、保存点、锁、隔离级别、脏读、死锁和最佳实践。

阅读文章 →
软件开发

Git 命令备忘单:您实际使用的命令,以及示例

Git 命令备忘单按任务分组:设置、暂存、分支、远程、存储、历史记录和撤消错误,以及来自真实演示存储库的输出。

阅读文章 →
软件开发

JavaScript Promise 解释:then、catch、Promise.all 和常见错误

JavaScript 承诺用我们运行的代码进行了解释:状态、then 和 catch 链、错误处理、Promise.all、allSettled、race 和 any、事件循环和常见错误。

阅读文章 →
软件开发

Python 字典教程:方法、示例和常见模式

Python 字典教程,包含我们运行的代码:创建、读取和更新字典、关键方法、合并、理解、计数和分组模式以及常见错误。

阅读文章 →
人工智能

什么是 RAG?通过工作示例解释检索增强生成

什么是RAG?检索增强生成逐步解释:索引、分块、嵌入、检索、一个小型的 Python 工作示例、评估和常见故障。

阅读文章 →
DevOps

Terraform 初学者教程:从第一个文件到模块的基础设施即代码

适合初学者的 Terraform 教程:从没有云帐户开始,学习初始化、计划和应用,然后是变量、输出、状态、模块和安全的团队工作流程。

阅读文章 →
DevOps

Docker 与 Kubernetes:主要区别、它们如何协同工作以及您需要哪些

Docker 与 Kubernetes 的解释:各自的作用、同一个应用程序同时运行、Docker Compose 和 Swarm 的比较、dockershim 的变化以及如何决定您需要什么。

阅读文章 →
测试自动化

Pytest 初学者教程:夹具、参数化和真实示例

面向初学者的 pytest 教程,其中包含我们在 pytest 9.2 上运行的测试:断言、固定装置、参数化、conftest、标记、tmp_path、monkeypatch、有用的标志和 CI。

阅读文章 →
软件测试

软件测试生命周期 (STLC):6 个阶段、进入和退出标准、示例

软件测试生命周期解释:六个 STLC 阶段,包括进入和退出标准、可交付成果、有效的登录功能示例、STLC 与 SDLC 以及敏捷中的 STLC。

阅读文章 →
软件开发

Git merge 与 rebase 的区别:示例及如何选择

用真实的 log 输出、对比表、rebase 黄金法则和安全的团队工作流,讲清 Git merge 与 rebase 的区别。

阅读文章 →
软件开发

Python 列表推导式:语法、示例与何时避免使用

Python 列表推导式详解:语法、过滤、if-else、嵌套循环、实测耗时,以及何时普通 for 循环更合适。

阅读文章 →
数据库与 SQL

SQL JOIN 详解:附示例(INNER、LEFT、RIGHT、FULL)

通过示例表和查询,一步步讲解 SQL JOIN:INNER、LEFT、RIGHT、FULL、CROSS 和 SELF,并介绍常见错误与性能技巧。

阅读文章 →