Help us learn about your current experience with the documentation. Take the survey.

理解 EXPLAIN 计划

PostgreSQL 允许你使用 EXPLAIN 命令获取查询计划。当你试图确定查询性能时,这个命令可能非常有价值。只要查询以它开头,你就可以直接在 SQL 查询中使用这个命令:

EXPLAIN
SELECT COUNT(*)
FROM projects
WHERE visibility_level IN (0, 20);

在 GitLab.com 上运行这个命令时,我们会得到以下输出:

Aggregate  (cost=922411.76..922411.77 rows=1 width=8)
  ->  Seq Scan on projects  (cost=0.00..908044.47 rows=5746914 width=0)
        Filter: (visibility_level = ANY ('{0,20}'::integer[]))

当只使用 EXPLAIN 时,PostgreSQL 实际上不会执行我们的查询,而是基于可用统计信息生成一个估计的执行计划。这意味着实际的计划可能会有很大差异。幸运的是,PostgreSQL 也提供了执行查询的选项。要做到这一点,我们需要使用 EXPLAIN ANALYZE 而不是单纯的 EXPLAIN

EXPLAIN ANALYZE
SELECT COUNT(*)
FROM projects
WHERE visibility_level IN (0, 20);

这会产生:

Aggregate  (cost=922420.60..922420.61 rows=1 width=8) (actual time=3428.535..3428.535 rows=1 loops=1)
  ->  Seq Scan on projects  (cost=0.00..908053.18 rows=5746969 width=0) (actual time=0.041..2987.606 rows=5746940 loops=1)
        Filter: (visibility_level = ANY ('{0,20}'::integer[]))
        Rows Removed by Filter: 65677
Planning time: 2.861 ms
Execution time: 3428.596 ms

正如我们所看到的,这个计划有很大不同,并且包含更多数据。让我们一步步讨论。

因为 EXPLAIN ANALYZE 会执行查询,所以使用写入数据或可能超时的查询时要小心。如果查询修改了数据,考虑将其包装在一个自动回滚的事务中,如下所示:

BEGIN;
EXPLAIN ANALYZE
DELETE FROM users WHERE id = 1;
ROLLBACK;

EXPLAIN 命令还接受其他选项,例如 BUFFERS

EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*)
FROM projects
WHERE visibility_level IN (0, 20);

这会产生:

Aggregate  (cost=922420.60..922420.61 rows=1 width=8) (actual time=3428.535..3428.535 rows=1 loops=1)
  Buffers: shared hit=208846
  ->  Seq Scan on projects  (cost=0.00..908053.18 rows=5746969 width=0) (actual time=0.041..2987.606 rows=5746940 loops=1)
        Filter: (visibility_level = ANY ('{0,20}'::integer[]))
        Rows Removed by Filter: 65677
        Buffers: shared hit=208846
Planning time: 2.861 ms
Execution time: 3428.596 ms

更多信息,请参考官方的 EXPLAIN 文档使用 EXPLAIN 指南

节点(Nodes)

每个查询计划都由节点组成。节点可以嵌套,并且从内到外执行。这意味着最内层的节点在外层节点之前执行。这可以最好地理解为嵌套函数调用,在它们展开时返回结果。例如,一个以 Aggregate 开始,接着是 Nested Loop,然后是 Index Only scan 的计划可以看作是以下 Ruby 代码:

aggregate(
  nested_loop(
    index_only_scan()
    index_only_scan()
  )
)

节点使用 -> 后跟节点类型来表示。例如:

Aggregate  (cost=922411.76..922411.77 rows=1 width=8)
  ->  Seq Scan on projects  (cost=0.00..908044.47 rows=5746914 width=0)
        Filter: (visibility_level = ANY ('{0,20}'::integer[]))

这里第一个执行的节点是 Seq scan on projectsFilter: 是应用于节点结果的额外过滤器。过滤器与 Ruby 的 Array#select 非常相似:它接受输入行,应用过滤器,并生成新的行列表。节点完成后,我们在其上执行 Aggregate

嵌套节点看起来像这样:

Aggregate  (cost=176.97..176.98 rows=1 width=8) (actual time=0.252..0.252 rows=1 loops=1)
  Buffers: shared hit=155
  ->  Nested Loop  (cost=0.86..176.75 rows=87 width=0) (actual time=0.035..0.249 rows=36 loops=1)
        Buffers: shared hit=155
        ->  Index Only Scan using users_pkey on users users_1  (cost=0.43..4.95 rows=87 width=4) (actual time=0.029..0.123 rows=36 loops=1)
              Index Cond: (id < 100)
              Heap Fetches: 0
        ->  Index Only Scan using users_pkey on users  (cost=0.43..1.96 rows=1 width=4) (actual time=0.003..0.003 rows=1 loops=36)
              Index Cond: (id = users_1.id)
              Heap Fetches: 0
Planning time: 2.585 ms
Execution time: 0.310 ms

这里我们首先执行两个单独的 “Index Only” 扫描,然后对这两个扫描的结果执行 “Nested Loop”。

节点统计信息

计划中的每个节点都有一组相关的统计信息,例如成本、产生的行数、执行的循环次数等。例如:

Seq Scan on projects  (cost=0.00..908044.47 rows=5746914 width=0)

在这里我们可以看到成本范围是 0.00..908044.47(我们稍后会讨论这一点),我们估计(因为我们使用的是 EXPLAIN 而不是 EXPLAIN ANALYZE)这个节点总共会产生 5,746,914 行。width 统计信息描述了每行的估计宽度(以字节为单位)。

costs 字段指定了节点的成本。成本由查询计划器的成本参数决定的任意单位来衡量。影响成本的因素取决于各种设置,如 seq_page_costcpu_tuple_cost 等。成本字段的格式如下:

STARTUP COST..TOTAL COST

启动成本说明了启动节点的成本,总成本描述了整个节点的成本。一般来说:值越大,节点越昂贵。

使用 EXPLAIN ANALYZE 时,这些统计信息还包括实际花费的时间(以毫秒为单位)和其他运行时统计信息(例如,实际产生的行数):

Seq Scan on projects  (cost=0.00..908053.18 rows=5746969 width=0) (actual time=0.041..2987.606 rows=5746940 loops=1)

在这里我们可以看到我们估计返回 5,746,969 行,但实际上返回了 5,746,940 行。我们还可以看到,仅这个顺序扫描就花了 2.98 秒的时间。

使用 EXPLAIN (ANALYZE, BUFFERS) 还能给我们提供关于过滤器删除的行数、使用的缓冲区数量等信息。例如:

Seq Scan on projects  (cost=0.00..908053.18 rows=5746969 width=0) (actual time=0.041..2987.606 rows=5746940 loops=1)
  Filter: (visibility_level = ANY ('{0,20}'::integer[]))
  Rows Removed by Filter: 65677
  Buffers: shared hit=208846

在这里我们可以看到我们的过滤器必须删除 65,677 行,并且使用了 208,846 个缓冲区。PostgreSQL 中的每个缓冲区是 8 KB(8192 字节),这意味着我们的上述节点使用了 1.6 GB 的缓冲区。这可真不少!

请注意,一些统计信息是每次循环的平均值,而另一些是总值:

字段名 值类型
Actual Total Time 每次循环平均值
Actual Rows 每次循环平均值
Buffers Shared Hit 总值
Buffers Shared Read 总值
Buffers Shared Dirtied 总值
Buffers Shared Written 总值
I/O Read Time 总值
I/O Read Write 总值

例如:

 ->  Index Scan using users_pkey on public.users  (cost=0.43..3.44 rows=1 width=1318) (actual time=0.025..0.025 rows=1 loops=888)
       Index Cond: (users.id = issues.author_id)
       Buffers: shared hit=3543 read=9
       I/O Timings: read=17.760 write=0.000

在这里我们可以看到这个节点使用了 3552 个缓冲区(3543 + 9),返回了 888 行(888 * 1),实际耗时为 22.2 毫秒(888 * 0.025)。总耗时中的 17.76 毫秒用于从磁盘读取数据,以获取不在缓存中的数据。

节点类型

有很多不同类型的节点,所以我们在这里只介绍一些更常见的。

所有可用节点及其描述的完整列表可以在 PostgreSQL 源文件 plannodes.h 中找到。pgMustard EXPLAIN 文档 也提供了对节点及其字段的详细探讨。

Seq Scan(顺序扫描)

对(一部分)数据库表的顺序扫描。这就像在数据库表上使用 Array#each。当检索大量行时,顺序扫描可能相当慢,所以最好避免在大表上使用这些。

Index Only Scan(仅索引扫描)

不需要从表中获取任何数据的索引扫描。在某些情况下,仅索引扫描可能仍需要从表中获取数据,在这种情况下,节点会包含一个 Heap Fetches: 统计信息。

Index Scan(索引扫描)

需要从表中检索一些数据的索引扫描。

Bitmap Index Scan 和 Bitmap Heap scan(位图索引扫描和位图堆扫描)

位图扫描介于顺序扫描和索引扫描之间。当我们从索引扫描中读取的数据太多,但又不足以执行顺序扫描时,通常会使用这些。位图扫描使用所谓的位图索引来执行其工作。

PostgreSQL 的源代码对位图扫描有以下说明:

位图索引扫描提供潜在元组位置的位图;它本身不访问堆。该位图由祖先位图堆扫描节点使用,可能经过中间的位图或(Bitmap Or)和/或位图与(Bitmap And)节点,以与其他位图索引扫描的结果组合。

Limit

对输入行应用 LIMIT

Sort

按照使用 ORDER BY 语句指定的方式对输入行进行排序。

Nested Loop(嵌套循环)

嵌套循环为前一个节点产生的每一行执行其子节点。例如:

->  Nested Loop  (cost=0.86..176.75 rows=87 width=0) (actual time=0.035..0.249 rows=36 loops=1)
      Buffers: shared hit=155
      ->  Index Only Scan using users_pkey on users users_1  (cost=0.43..4.95 rows=87 width=4) (actual time=0.029..0.123 rows=36 loops=1)
            Index Cond: (id < 100)
            Heap Fetches: 0
      ->  Index Only Scan using users_pkey on users  (cost=0.43..1.96 rows=1 width=4) (actual time=0.003..0.003 rows=1 loops=36)
            Index Cond: (id = users_1.id)
            Heap Fetches: 0

这里第一个子节点(Index Only Scan using users_pkey on users users_1)产生 36 行,并且执行一次(rows=36 loops=1)。下一个节点产生 1 行(rows=1),但重复 36 次(loops=36)。这是因为前一个节点产生了 36 行。

这意味着,如果各个子节点不断产生大量行,嵌套循环可能会很快减慢查询速度。

优化查询

了解了这些,让我们看看如何优化查询。让我们使用以下查询作为示例:

SELECT COUNT(*)
FROM users
WHERE twitter != '';

这个查询计算设置了 Twitter 用户的数量。让我们使用 EXPLAIN (ANALYZE, BUFFERS) 运行它:

EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*)
FROM users
WHERE twitter != '';

这会产生以下计划:

Aggregate  (cost=845110.21..845110.22 rows=1 width=8) (actual time=1271.157..1271.158 rows=1 loops=1)
  Buffers: shared hit=202662
  ->  Seq Scan on users  (cost=0.00..844969.99 rows=56087 width=0) (actual time=0.019..1265.883 rows=51833 loops=1)
        Filter: ((twitter)::text <> ''::text)
        Rows Removed by Filter: 2487813
        Buffers: shared hit=202662
Planning time: 0.390 ms
Execution time: 1271.180 ms

从这个查询计划中我们可以看到:

  1. 我们需要对 users 表执行顺序扫描。
  2. 这个顺序扫描使用 Filter 过滤掉了 2,487,813 行。
  3. 我们使用了 202,622 个缓冲区,相当于 1.58 GB 的内存。
  4. 我们花了 1.2 秒来完成所有这些。

考虑到我们只是在计算用户数量,这相当昂贵!

在我们开始做任何更改之前,让我们看看 users 表上是否有任何现有索引我们可以使用。我们可以在 psql 控制台中运行 \d users 来获取这些信息,然后滚动到 Indexes: 部分:

Indexes:
    "users_pkey" PRIMARY KEY, btree (id)
    "index_users_on_confirmation_token" UNIQUE, btree (confirmation_token)
    "index_users_on_email" UNIQUE, btree (email)
    "index_users_on_reset_password_token" UNIQUE, btree (reset_password_token)
    "index_users_on_static_object_token" UNIQUE, btree (static_object_token)
    "index_users_on_unlock_token" UNIQUE, btree (unlock_token)
    "index_on_users_name_lower" btree (lower(name::text))
    "index_users_on_admin" btree (admin)
    "index_users_on_created_at" btree (created_at)
    "index_users_on_email_trigram" gin (email gin_trgm_ops)
    "index_users_on_feed_token" btree (feed_token)
    "index_users_on_group_view" btree (group_view)
    "index_users_on_incoming_email_token" btree (incoming_email_token)
    "index_users_on_managing_group_id" btree (managing_group_id)
    "index_users_on_name" btree (name)
    "index_users_on_name_trigram" gin (name gin_trgm_ops)
    "index_users_on_public_email" btree (public_email) WHERE public_email::text <> ''::text
    "index_users_on_state" btree (state)
    "index_users_on_state_and_user_type" btree (state, user_type)
    "index_users_on_unconfirmed_email" btree (unconfirmed_email) WHERE unconfirmed_email IS NOT NULL
    "index_users_on_user_type" btree (user_type)
    "index_users_on_username" btree (username)
    "index_users_on_username_trigram" gin (username gin_trgm_ops)
    "tmp_idx_on_user_id_where_bio_is_filled" btree (id) WHERE COALESCE(bio, ''::character varying)::text IS DISTINCT FROM ''::text

在这里我们可以看到 twitter 列上没有索引,这意味着在这种情况下 PostgreSQL 必须执行顺序扫描。让我们尝试通过添加以下索引来解决这个问题:

CREATE INDEX CONCURRENTLY twitter_test ON users (twitter);

如果我们现在使用 EXPLAIN (ANALYZE, BUFFERS) 重新运行我们的查询,我们会得到以下计划:

Aggregate  (cost=61002.82..61002.83 rows=1 width=8) (actual time=297.311..297.312 rows=1 loops=1)
  Buffers: shared hit=51854 dirtied=19
  ->  Index Only Scan using twitter_test on users  (cost=0.43..60873.13 rows=51877 width=0) (actual time=279.184..293.532 rows=51833 loops=1)
        Filter: ((twitter)::text <> ''::text)
        Rows Removed by Filter: 2487830
        Heap Fetches: 26037
        Buffers: shared hit=51854 dirtied=19
Planning time: 0.191 ms
Execution time: 297.334 ms

现在获取我们的数据只需要不到 300 毫秒,而不是 1.2 秒。然而,我们仍然使用了 51,854 个缓冲区,大约是 400 MB 的内存。对于这样一个简单的查询来说,300 毫秒也相当慢。为了理解为什么这个查询仍然很昂贵,让我们看看以下内容:

Index Only Scan using twitter_test on users  (cost=0.43..60873.13 rows=51877 width=0) (actual time=279.184..293.532 rows=51833 loops=1)
  Filter: ((twitter)::text <> ''::text)
  Rows Removed by Filter: 2487830

我们从索引上的仅索引扫描开始,但我们仍然应用了一个 Filter 来过滤掉 2,487,830 行。为什么会这样呢?嗯,让我们看看我们是如何创建索引的:

CREATE INDEX CONCURRENTLY twitter_test ON users (twitter);

我们告诉 PostgreSQL 索引 twitter 列的所有可能值,甚至是空字符串。我们的查询使用 WHERE twitter != ''。这意味着索引确实改善了情况,因为我们不需要执行顺序扫描,但我们仍然可能遇到空字符串。这意味着 PostgreSQL 必须 对索引结果应用一个过滤器来去除这些值。

幸运的是,我们可以使用"部分索引"进一步改进这一点。部分索引是带有 WHERE 条件的索引,在索引数据时应用该条件。例如:

CREATE INDEX CONCURRENTLY some_index ON users (email) WHERE id < 100

这个索引只会索引满足 WHERE id < 100 的行的 email 值。我们可以使用部分索引将我们的 Twitter 索引更改为:

CREATE INDEX CONCURRENTLY twitter_test ON users (twitter) WHERE twitter != '';

创建后,如果我们再次运行查询,我们会得到以下计划:

Aggregate  (cost=1608.26..1608.27 rows=1 width=8) (actual time=19.821..19.821 rows=1 loops=1)
  Buffers: shared hit=44036
  ->  Index Only Scan using twitter_test on users  (cost=0.41..1479.71 rows=51420 width=0) (actual time=0.023..15.514 rows=51833 loops=1)
        Heap Fetches: 1208
        Buffers: shared hit=44036
Planning time: 0.123 ms
Execution time: 19.848 ms

这_好多了_!现在获取数据只需要 20 毫秒,我们只使用了大约 344 MB 的缓冲区(而不是原来的 1.58 GB)。这之所以有效,是因为现在 PostgreSQL 不再需要应用 Filter,因为索引只包含非空的 twitter 值。

请注意,你不应该在每次想要优化查询时都添加部分索引。每个索引都必须为每次写入更新,并且根据索引数据的数量,它们可能需要相当多的空间。因此,首先检查是否有任何现有索引可以重用。如果没有,检查是否可以稍微更改现有的索引以同时满足现有和新查询。只有在没有任何现有索引可以以任何方式使用的情况下,才添加新索引。

在比较执行计划时,不要只将时间作为唯一重要的指标。良好的时间是任何优化的主要目标,但它可能太不稳定而不能用于比较(例如,它很大程度上依赖于缓存的状态)。在优化查询时,我们通常需要减少我们处理的数据量。索引是使用更少的页面(缓冲区)来获取结果的方法,所以在优化过程中,查看使用的缓冲区数量(读取和命中),并努力减少这些数量。减少时间是减少缓冲区数量的结果。Database Lab Engine 保证计划在结构上与生产环境相同(并且缓冲区总数与生产环境相同),但缓存状态和 I/O 速度的差异可能导致不同的时间。

无法优化的查询

既然我们已经了解了如何优化查询,让我们看看另一个我们可能无法优化的查询:

EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*)
FROM projects
WHERE visibility_level IN (0, 20);

EXPLAIN (ANALYZE, BUFFERS) 的输出如下:

Aggregate  (cost=922420.60..922420.61 rows=1 width=8) (actual time=3428.535..3428.535 rows=1 loops=1)
  Buffers: shared hit=208846
  ->  Seq Scan on projects  (cost=0.00..908053.18 rows=5746969 width=0) (actual time=0.041..2987.606 rows=5746940 loops=1)
        Filter: (visibility_level = ANY ('{0,20}'::integer[]))
        Rows Removed by Filter: 65677
        Buffers: shared hit=208846
Planning time: 2.861 ms
Execution time: 3428.596 ms

查看输出,我们看到以下过滤器:

Filter: (visibility_level = ANY ('{0,20}'::integer[]))
Rows Removed by Filter: 65677

看到过滤器删除的行数,我们可能会想在 projects.visibility_level 上添加一个索引,以某种方式将这个顺序扫描 + 过滤器转换为仅索引扫描。

不幸的是,这样做不太可能改善任何情况。与某些人的想法相反,索引的存在_并不保证_PostgreSQL 实际会使用它。例如,当执行 SELECT * FROM projects 时,扫描整个表比使用索引然后从表中获取数据要便宜得多。在这种情况下,PostgreSQL 可能决定不使用索引。

其次,让我们思考一下我们的查询是做什么的:它获取所有可见性级别为 0 或 20 的项目。在上面的计划中,我们可以看到这产生了相当多的行(5,745,940),但这相对于总数是多少?让我们通过运行以下查询来找出:

SELECT visibility_level, count(*) AS amount
FROM projects
GROUP BY visibility_level
ORDER BY visibility_level ASC;

对于 GitLab.com,这会产生:

 visibility_level | amount
------------------+---------
                0 | 5071325
               10 |   65678
               20 |  674801

这里项目的总数是 5,811,804,其中 5,746,126 个是级别 0 或 20。这占整个表的 98%!

所以无论我们做什么,这个查询都会检索整个表的 98%。由于大部分时间都花在这上面,除了_不_运行它之外,我们真的没有太多可以改进的地方。

这里重要的是,虽然有些人可能会建议一看到顺序扫描就直接添加索引,但_更重要的是_首先理解你的查询是做什么的,它检索多少数据等等。毕竟,你不能优化你不理解的东西。

基数和选择性

我们之前看到我们的查询必须检索表中 98% 的行。数据库中有两个常用术语:基数和选择性。基数指的是表中特定列中唯一值的数量。

选择性是由操作(例如索引扫描或过滤器)产生的唯一值数量,相对于总行数。选择性越高,PostgreSQL 就越有可能使用索引。

在上面的例子中,只有 3 个唯一值:0、10 和 20。这意味着基数是 3。选择性也非常低:0.0000003%(2 / 5,811,804),因为我们的 Filter 只使用两个值(020)。选择性值如此之低,PostgreSQL 决定使用索引不值得,这并不奇怪,因为它几乎不会产生任何唯一的行。

重写查询

所以上面的查询无法真正按原样优化,或者至少优化不多。但如果我们稍微改变它的目的呢?如果我们不是检索所有 visibility_level 为 0 或 20 的项目,而是检索用户以某种方式交互过的项目呢?

在 GitLab 16.7 之前,GitLab 使用一个名为 user_interacted_projects 的表来跟踪用户与项目的交互。该表具有以下架构:

Table "public.user_interacted_projects"
   Column   |  Type   | Modifiers
------------+---------+-----------
 user_id    | integer | not null
 project_id | integer | not null
Indexes:
    "index_user_interacted_projects_on_project_id_and_user_id" UNIQUE, btree (project_id, user_id)
    "index_user_interacted_projects_on_user_id" btree (user_id)
Foreign-key constraints:
    "fk_rails_0894651f08" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
    "fk_rails_722ceba4f7" FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE

让我们重写我们的查询,将这个表 JOIN 到我们的项目上,并获取特定用户的项目:

EXPLAIN ANALYZE
SELECT COUNT(*)
FROM projects
INNER JOIN user_interacted_projects ON user_interacted_projects.project_id = projects.id
WHERE projects.visibility_level IN (0, 20)
AND user_interacted_projects.user_id = 1;

我们在这里做的是:

  1. 获取我们的项目。
  2. INNER JOIN user_interacted_projects,这意味着我们只保留在 user_interacted_projects 中有对应行的 projects 中的行。
  3. 将此限制为 visibility_level 为 0 或 20 的项目,以及 ID 为 1 的用户交互过的项目。

如果我们运行这个查询,我们会得到以下计划:

 Aggregate  (cost=871.03..871.04 rows=1 width=8) (actual time=9.763..9.763 rows=1 loops=1)
   ->  Nested Loop  (cost=0.86..870.52 rows=203 width=0) (actual time=1.072..9.748 rows=143 loops=1)
         ->  Index Scan using index_user_interacted_projects_on_user_id on user_interacted_projects  (cost=0.43..160.71 rows=205 width=4) (actual time=0.939..2.508 rows=145 loops=1)
               Index Cond: (user_id = 1)
         ->  Index Scan using projects_pkey on projects  (cost=0.43..3.45 rows=1 width=4) (actual time=0.049..0.050 rows=1 loops=145)
               Index Cond: (id = user_interacted_projects.project_id)
               Filter: (visibility_level = ANY ('{0,20}'::integer[]))
               Rows Removed by Filter: 0
 Planning time: 2.614 ms
 Execution time: 9.809 ms

在这里,获取我们的数据只需要不到 10 毫秒。我们还可以看到我们检索的项目要少得多:

Index Scan using projects_pkey on projects  (cost=0.43..3.45 rows=1 width=4) (actual time=0.049..0.050 rows=1 loops=145)
  Index Cond: (id = user_interacted_projects.project_id)
  Filter: (visibility_level = ANY ('{0,20}'::integer[]))
  Rows Removed by Filter: 0

在这里我们看到我们执行了 145 次循环(loops=145),每次循环产生 1 行(rows=1)。这比之前少得多,我们的查询性能好多了!

如果我们查看计划,我们还会看到我们的成本非常低:

Index Scan using projects_pkey on projects  (cost=0.43..3.45 rows=1 width=4) (actual time=0.049..0.050 rows=1 loops=145)

这里的成本只有 3.45,我们花了 7.25 毫秒来完成(0.05 * 145)。下一个索引扫描稍微昂贵一些:

Index Scan using index_user_interacted_projects_on_user_id on user_interacted_projects  (cost=0.43..160.71 rows=205 width=4) (actual time=0.939..2.508 rows=145 loops=1)

这里的成本是 160.71(cost=0.43..160.71),花了大约 2.5 毫秒(基于 actual time=.... 的输出)。

这里最昂贵的部分是作用于这两个索引扫描结果的"嵌套循环":

Nested Loop  (cost=0.86..870.52 rows=203 width=0) (actual time=1.072..9.748 rows=143 loops=1)

在这里,我们必须为 203 行执行 870.52 次磁盘页面获取,耗时 9.748 毫秒,在单个循环中产生 143 行。

这里的关键点是,有时你必须重写(部分)查询以使其更好。有时这意味着你必须稍微改变你的功能以适应更好的性能。

什么是不好的计划

这是一个很难回答的问题,因为"不好"的定义取决于你试图解决的问题。然而,在某些情况下最好避免一些模式,例如:

  • 大表上的顺序扫描
  • 过滤器删除大量行
  • 执行需要_大量_缓冲区的特定步骤(例如,GitLab.com 上需要超过 512 MB 的索引扫描)。

作为一般指导原则,目标是:

  1. 查询时间不超过 10 毫秒。我们在每个请求中花费在 SQL 上的目标时间大约是 100 毫秒,所以每个查询都应该尽可能快。
  2. 不使用相对于工作负载过多的缓冲区。例如,检索十行不应该需要 1 GB 的缓冲区。
  3. 不花费大量时间执行磁盘 I/O 操作。必须启用 track_io_timing 设置,这些数据才会包含在 EXPLAIN ANALYZE 的输出中。
  4. 在检索行而不聚合它们时应用 LIMIT,例如 SELECT * FROM users
  5. 不使用 Filter 过滤掉太多行,特别是如果查询不使用 LIMIT 来限制返回的行数。过滤器通常可以通过添加(部分)索引来移除。

这些是_指导原则_而不是硬性要求,因为不同的需求可能需要不同的查询。唯一的_规则_是,你_必须始终测量_你的查询(最好使用类生产环境的数据库),使用 EXPLAIN (ANALYZE, BUFFERS) 和相关工具,例如:

生成查询计划

有几种方法可以获取查询计划的输出。当然,你可以直接在 psql 控制台中运行 EXPLAIN 查询,或者遵循以下其他选项之一。

Database Lab Engine

GitLab 团队成员可以使用 Database Lab Engine,以及配套的 SQL 优化工具 - Joe Bot

Database Lab Engine 为开发人员提供生产数据库的克隆,而 Joe Bot 帮助探索执行计划。

Joe Bot 通过其网页界面提供。

使用 Joe Bot,你可以执行 DDL 语句(如创建索引、表和列),并为 SELECTUPDATEDELETE 语句获取查询计划。

例如,要在尚未在生产环境中存在的列上测试新索引,你可以执行以下操作:

创建列:

exec ALTER TABLE projects ADD COLUMN last_at timestamp without time zone

创建索引:

exec CREATE INDEX index_projects_last_activity ON projects (last_activity_at) WHERE last_activity_at IS NOT NULL

分析表以更新其统计信息:

exec ANALYZE projects

获取查询计划:

explain SELECT * FROM projects WHERE last_activity_at < CURRENT_DATE

完成后,你可以回滚你的更改:

reset

有关可用选项的更多信息,请运行:

help

网页界面包含以下执行计划可视化工具:

技巧与窍门

数据库连接现在在整个会话期间都保持,所以你可以对任何会话变量(如 enable_seqscanwork_mem)使用 exec set ...。这些设置会应用于所有后续命令,直到你重置它们。例如,你可以禁用并行查询:

exec SET max_parallel_workers_per_gather = 0

Rails 控制台

使用 Rails 7.1 的 explain 方法,你可以直接从 Rails 控制台生成查询计划:

pry(main)> Project.where('build_timeout > ?', 3600).explain(:analyze, :buffers, :verbose)
  Project Load (1.9ms)  SELECT "projects".* FROM "projects" WHERE (build_timeout > 3600)
   (pry):12
=> EXPLAIN for: SELECT "projects".* FROM "projects" WHERE (build_timeout > 3600)
Seq Scan on public.projects  (cost=0.00..2.17 rows=1 width=742) (actual time=0.040..0.041 rows=0 loops=1)
  Output: id, name, path, description, created_at, updated_at, creator_id, namespace_id, ...
   Filter: (projects.build_timeout > 3600)
   Rows Removed by Filter: 16
   Buffers: shared hit=1
 Planning:
   Buffers: shared hit=6
 Planning Time: 0.230 ms
 Execution Time: 0.033 ms
(9 rows)

进一步阅读

关于理解查询计划的更广泛指南可以在 Dalibo.org演示文稿中找到。

Depesz 博客也有一个很好的部分专门讨论查询计划。