MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

PostgreSQL执行计划输出结果解读

2021-05-262.1k 阅读

一、PostgreSQL 执行计划基础

在 PostgreSQL 数据库中,执行计划(Execution Plan)是数据库优化器生成的一种描述查询如何执行的方案。当我们向 PostgreSQL 数据库发送一条 SQL 查询语句时,数据库的优化器会根据多种因素(如数据分布、索引情况、表结构等)来生成一个执行计划,这个计划决定了查询如何访问表中的数据、如何进行连接操作以及如何对数据进行排序等操作。

要获取 SQL 查询的执行计划,我们可以使用 EXPLAIN 关键字。例如,假设我们有一个简单的 employees 表,包含 idnamedepartment 等字段,我们想要查询某个部门的员工信息:

-- 创建示例表
CREATE TABLE employees (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    department VARCHAR(50)
);

-- 插入一些示例数据
INSERT INTO employees (name, department) VALUES
('Alice', 'HR'),
('Bob', 'Engineering'),
('Charlie', 'HR');

-- 使用 EXPLAIN 获取执行计划
EXPLAIN SELECT * FROM employees WHERE department = 'HR';

上述 EXPLAIN 语句的输出结果就是查询的执行计划。执行计划以一种树形结构呈现,从最顶层的节点开始,逐步向下展示每个操作步骤。

二、执行计划输出结构解读

2.1 通用结构

PostgreSQL 的执行计划输出通常具有以下通用结构:

  1. 节点类型:每个节点都有一个特定的类型,如 Seq Scan(顺序扫描)、Index Scan(索引扫描)、Nested Loop(嵌套循环连接)等。节点类型描述了该节点所执行的具体操作。
  2. 关联的表:显示该节点操作所涉及的表。例如,在 Seq Scan on employees 中,employees 就是该顺序扫描操作所针对的表。
  3. 条件:如果有过滤条件,会在节点信息中显示。例如,Index Scan using employees_department_idx on employees (cost=0.29..8.31 rows=1 width=32) filter: (department = 'HR'),这里的 filter: (department = 'HR') 就是过滤条件。
  4. 成本估计:PostgreSQL 使用成本模型来估计每个操作的开销。成本估计值包括启动成本(startup cost)和总运行成本(total cost)。例如,上述索引扫描节点中的 cost=0.29..8.310.29 是启动成本,8.31 是总运行成本。成本估计是优化器选择执行计划的重要依据之一。

2.2 常见节点类型解读

  1. Seq Scan(顺序扫描)
    • 描述:顺序扫描会逐行读取表中的每一条记录。它不依赖索引,适用于表数据量较小或者查询条件无法利用索引的情况。
    • 示例Seq Scan on employees (cost=0.00..15.00 rows=500 width=32)
    • 解读:这个输出表示对 employees 表进行顺序扫描,启动成本为 0.00,总运行成本为 15.00,估计会返回 500 行数据,每行数据宽度为 32 字节。由于顺序扫描需要读取表中的所有数据,所以成本相对较高,尤其是对于大表。
  2. Index Scan(索引扫描)
    • 描述:索引扫描利用索引来快速定位满足条件的数据行。当查询条件能够使用索引时,索引扫描通常比顺序扫描更高效。
    • 示例:假设我们在 department 字段上创建了索引 employees_department_idx,执行 EXPLAIN SELECT * FROM employees WHERE department = 'HR'; 可能得到 Index Scan using employees_department_idx on employees (cost=0.29..8.31 rows=1 width=32) filter: (department = 'HR')
    • 解读:此输出表明使用 employees_department_idx 索引对 employees 表进行扫描,启动成本为 0.29,总运行成本为 8.31,估计返回 1 行数据,每行宽度为 32 字节,过滤条件是 department = 'HR'。索引扫描通过索引快速定位到满足条件的行,减少了需要读取的数据量,因此成本通常较低。
  3. Nested Loop(嵌套循环连接)
    • 描述:嵌套循环连接是一种常见的表连接方式。它会对两个表进行嵌套循环操作,外层循环遍历一个表(驱动表),内层循环针对外层循环的每一行,遍历另一个表(被驱动表),并检查连接条件。
    • 示例:假设有 employees 表和 departments 表,employees 表中有 department_id 字段关联到 departments 表的 id 字段。执行 EXPLAIN SELECT * FROM employees JOIN departments ON employees.department_id = departments.id; 可能得到类似如下结果:
Nested Loop  (cost=0.29..18.32 rows=1 width=64)
  ->  Index Scan using employees_department_id_idx on employees  (cost=0.29..8.31 rows=1 width=32)
  ->  Index Scan using departments_pkey on departments  (cost=0.00..8.01 rows=1 width=32)
- **解读**:最顶层的 `Nested Loop` 节点表示使用嵌套循环连接。其下有两个子节点,第一个子节点是对 `employees` 表进行索引扫描,第二个子节点是对 `departments` 表进行索引扫描。外层循环可能是对 `employees` 表的扫描(根据优化器的选择),内层循环针对外层循环找到的每一个 `employees` 行,通过 `department_id` 在内层的 `departments` 表索引扫描中查找匹配的行。

4. Hash Join(哈希连接) - 描述:哈希连接适用于大数据量的表连接。它首先扫描一个表(通常是较小的表,称为构建表),并根据连接列构建一个哈希表。然后扫描另一个表(探测表),对于探测表的每一行,在哈希表中查找匹配的行。 - 示例:假设两个较大的表 orderscustomers,通过 customer_id 进行连接。执行 EXPLAIN SELECT * FROM orders JOIN customers ON orders.customer_id = customers.id; 可能得到类似如下结果:

Hash Join  (cost=1000.00..2000.00 rows=1000 width=100)
  Hash Cond: (orders.customer_id = customers.id)
  ->  Seq Scan on orders  (cost=0.00..1000.00 rows=10000 width=50)
  ->  Hash  (cost=500.00..500.00 rows=5000 width=50)
        ->  Seq Scan on customers  (cost=0.00..500.00 rows=5000 width=50)
- **解读**:顶层的 `Hash Join` 节点表示使用哈希连接方式。`Hash Cond` 显示了连接条件。下方的 `Seq Scan on orders` 是探测表的扫描操作,`Hash` 节点是构建哈希表相关的操作,其下的 `Seq Scan on customers` 是构建表的扫描操作。哈希连接在处理大数据量连接时,通常比嵌套循环连接更高效,因为它通过哈希表减少了比较的次数。

5. Sort(排序) - 描述:当查询中包含 ORDER BY 子句时,可能会出现 Sort 节点。它会对数据进行排序操作。 - 示例:执行 EXPLAIN SELECT * FROM employees ORDER BY name; 可能得到 Sort (cost=15.00..16.25 rows=500 width=32) Sort Key: name - 解读Sort 节点表示对 employees 表的数据进行排序,启动成本为 15.00,总运行成本为 16.25,估计有 500 行数据需要排序,排序键是 name 字段。排序操作可能会消耗较多的资源,尤其是对于大数据量的排序。

三、成本估计解读

3.1 成本模型基础

PostgreSQL 使用一个基于成本的优化模型来选择执行计划。成本模型试图估计每个操作的开销,主要考虑磁盘 I/O 成本、CPU 成本等因素。优化器会尝试找到一个总成本最低的执行计划。

成本估计值由启动成本(startup cost)和总运行成本(total cost)组成。启动成本表示操作开始执行所需的开销,例如打开文件、初始化索引扫描等操作的成本。总运行成本则包括启动成本以及操作执行过程中的所有成本,如读取数据行、进行比较操作等的成本。

3.2 影响成本估计的因素

  1. 数据分布:表中数据的实际分布情况会影响成本估计。例如,如果数据在某个字段上分布不均匀,索引扫描的效果可能会有所不同。如果大部分数据都集中在某个值上,那么顺序扫描可能在某些情况下反而更高效,即使存在索引。
  2. 索引情况:索引的存在与否以及索引的类型(B - tree、Hash 等)会对成本估计产生重大影响。B - tree 索引适用于范围查询和等值查询,Hash 索引则更适合等值查询。优化器会根据查询条件和索引类型来估计索引扫描的成本。
  3. 表统计信息:PostgreSQL 通过收集表的统计信息(如行数、字段的唯一值数量、数据的平均长度等)来进行成本估计。准确的统计信息对于优化器生成合理的执行计划至关重要。可以使用 ANALYZE 命令来更新表的统计信息。例如,执行 ANALYZE employees; 可以更新 employees 表的统计信息,使优化器在生成执行计划时能够更准确地估计成本。

四、解读示例与优化分析

4.1 单表查询示例

假设我们有一个 products 表,包含 idnamepricecategory 等字段。我们执行以下查询并分析其执行计划:

-- 创建示例表
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    price DECIMAL(10, 2),
    category VARCHAR(50)
);

-- 插入示例数据
INSERT INTO products (name, price, category) VALUES
('Product A', 10.99, 'Electronics'),
('Product B', 5.99, 'Clothing'),
('Product C', 15.99, 'Electronics');

-- 查询
EXPLAIN SELECT * FROM products WHERE category = 'Electronics' AND price > 12;

执行计划可能如下:

Index Scan using products_category_price_idx on products  (cost=0.29..8.31 rows=1 width=48) filter: (price > 12::numeric)

假设我们在 categoryprice 字段上创建了联合索引 products_category_price_idx。从执行计划中可以看到,优化器选择了索引扫描,这是因为查询条件能够有效地利用该索引。启动成本为 0.29,总运行成本为 8.31,估计返回 1 行数据。如果我们没有创建这个索引,优化器可能会选择顺序扫描,成本会大大增加。

4.2 多表连接示例

假设有 orders 表、customers 表和 products 表,它们之间的关系如下:

-- 创建表
CREATE TABLE customers (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100)
);

CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    price DECIMAL(10, 2)
);

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER,
    product_id INTEGER,
    quantity INTEGER,
    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (product_id) REFERENCES products(id)
);

-- 插入示例数据
INSERT INTO customers (name) VALUES
('Customer 1'),
('Customer 2');

INSERT INTO products (name, price) VALUES
('Product A', 10.99),
('Product B', 5.99);

INSERT INTO orders (customer_id, product_id, quantity) VALUES
(1, 1, 2),
(2, 2, 3);

-- 查询
EXPLAIN SELECT customers.name, products.name, orders.quantity
FROM customers
JOIN orders ON customers.id = orders.customer_id
JOIN products ON products.id = orders.product_id;

执行计划可能如下:

Nested Loop  (cost=0.29..21.32 rows=1 width=64)
  ->  Nested Loop  (cost=0.29..13.31 rows=1 width=32)
        ->  Index Scan using customers_pkey on customers  (cost=0.29..8.31 rows=1 width=16)
        ->  Index Scan using orders_customer_id_idx on orders  (cost=0.00..4.00 rows=1 width=16) Index Cond: (customer_id = customers.id)
  ->  Index Scan using products_pkey on products  (cost=0.00..8.01 rows=1 width=32) Index Cond: (id = orders.product_id)

从执行计划可以看出,优化器使用了嵌套循环连接。最外层的 Nested Loop 连接 customersorders 表,内层的 Nested Loop 连接 ordersproducts 表。每个连接操作都使用了索引扫描,这表明索引在多表连接中起到了优化作用。如果没有合适的索引,连接操作可能会使用顺序扫描,导致成本大幅上升。通过分析执行计划,我们可以进一步优化查询,例如添加更多的索引或者调整连接顺序,以降低执行成本。

五、执行计划与索引优化

5.1 索引对执行计划的影响

索引是影响 PostgreSQL 执行计划的关键因素之一。当存在合适的索引时,优化器通常会优先选择使用索引扫描,因为索引扫描可以快速定位满足条件的数据行,减少磁盘 I/O 操作,从而降低执行成本。

例如,对于查询 SELECT * FROM employees WHERE last_name = 'Smith';,如果在 last_name 字段上有索引,优化器可能会选择索引扫描,执行计划可能如下:

Index Scan using employees_last_name_idx on employees  (cost=0.29..8.31 rows=1 width=32) filter: (last_name = 'Smith')

然而,如果没有这个索引,优化器可能会选择顺序扫描,执行计划如下:

Seq Scan on employees  (cost=0.00..15.00 rows=500 width=32) filter: (last_name = 'Smith')

可以明显看出,索引扫描的成本(0.29..8.31)远低于顺序扫描的成本(0.00..15.00),尤其是当表数据量较大时。

5.2 索引优化策略

  1. 创建合适的索引:根据查询模式来创建索引。如果经常进行等值查询,如 WHERE column = value,可以创建 B - tree 索引。如果是大量的等值查询且数据分布均匀,Hash 索引可能更合适。对于范围查询,如 WHERE column BETWEEN value1 AND value2,B - tree 索引同样适用。例如,对于经常查询某个价格范围内产品的应用,可以在 price 字段上创建 B - tree 索引:CREATE INDEX products_price_idx ON products (price);
  2. 避免过多索引:虽然索引可以提高查询性能,但过多的索引也会带来负面影响。每个索引都需要占用额外的存储空间,并且在插入、更新和删除数据时,数据库需要同时更新索引,这会增加操作的开销。因此,要根据实际查询需求,只创建必要的索引。
  3. 联合索引:当查询条件涉及多个字段时,可以考虑创建联合索引。例如,对于查询 SELECT * FROM orders WHERE customer_id = 1 AND order_date > '2023 - 01 - 01';,可以创建联合索引 CREATE INDEX orders_customer_date_idx ON orders (customer_id, order_date);。联合索引的字段顺序很重要,一般将选择性高(即不同值较多)的字段放在前面,这样可以更有效地利用索引。

六、执行计划与表统计信息

6.1 表统计信息的作用

表统计信息对于 PostgreSQL 优化器生成准确的执行计划至关重要。这些统计信息包括表的行数、每个字段的唯一值数量、数据的平均长度等。优化器使用这些信息来估计每个操作的成本,从而选择最优的执行计划。

例如,假设优化器需要决定是使用顺序扫描还是索引扫描。如果表统计信息显示表行数较少,且索引的维护成本较高,优化器可能会选择顺序扫描。反之,如果表行数较多,且索引能够快速定位数据,优化器会选择索引扫描。准确的表统计信息可以帮助优化器做出更合理的决策。

6.2 更新表统计信息

可以使用 ANALYZE 命令来更新表的统计信息。ANALYZE 命令会扫描表中的数据,并收集相关的统计信息。例如,执行 ANALYZE products; 会更新 products 表的统计信息。

在实际应用中,应该定期执行 ANALYZE 命令,尤其是在数据量发生较大变化(如大量插入、删除数据)后。这样可以确保优化器始终基于准确的统计信息生成执行计划,从而提高查询性能。另外,ANALYZE 命令还可以对特定的索引进行统计更新,例如 ANALYZE VERBOSE products (price); 会详细显示对 productsprice 字段相关统计信息的更新情况。

七、执行计划与查询重写

7.1 查询重写的概念

查询重写是指对 SQL 查询语句进行改写,以改善其执行性能。通过分析执行计划,我们可以发现查询中存在的性能问题,并对查询进行重写,使优化器能够生成更高效的执行计划。

例如,某些复杂的子查询可能导致优化器选择较差的执行计划。我们可以将子查询改写为连接操作,或者使用 WITH 子句(CTE,Common Table Expressions)来优化查询结构。

7.2 查询重写示例

假设有一个查询,用于查找每个部门中工资高于该部门平均工资的员工:

SELECT e1.name, e1.salary
FROM employees e1
WHERE e1.salary > (
    SELECT AVG(e2.salary)
    FROM employees e2
    WHERE e2.department = e1.department
);

这个查询使用了子查询,执行计划可能显示子查询的执行成本较高。我们可以将其重写为连接操作:

SELECT e1.name, e1.salary, avg_salary.avg_sal
FROM employees e1
JOIN (
    SELECT department, AVG(salary) AS avg_sal
    FROM employees
    GROUP BY department
) avg_salary ON e1.department = avg_salary.department AND e1.salary > avg_salary.avg_sal;

通过这种重写,优化器可能会生成更高效的执行计划,因为连接操作在某些情况下比子查询更易于优化。执行 EXPLAIN 命令对比两种查询的执行计划,可以明显看到成本的变化和操作步骤的优化。

八、高级执行计划分析

8.1 并行执行计划

在 PostgreSQL 9.6 及更高版本中,支持并行查询执行。当查询涉及大表且服务器具有多个 CPU 核心时,并行执行可以显著提高查询性能。并行执行计划会将查询操作划分为多个并行的子任务,这些子任务可以同时在不同的 CPU 核心上执行。

例如,对于一个全表扫描操作,并行执行计划可能会将表数据划分为多个分区,每个分区由一个独立的进程或线程进行扫描,然后将结果合并。执行 EXPLAIN (VERBOSE, COSTS, TIMING, SUMMARY, BUFFERS, FORMAT JSON) 并查看 JSON 格式的执行计划输出,可以找到关于并行执行的详细信息,如并行度、并行子任务的类型等。

8.2 执行计划中的缓存使用

PostgreSQL 使用各种缓存来提高查询性能,如共享缓冲区缓存(用于缓存数据页)、查询结果缓存等。执行计划中虽然不会直接显示缓存的使用情况,但了解缓存机制有助于理解查询性能。

当查询的数据页已经在共享缓冲区缓存中时,磁盘 I/O 操作会大大减少,从而降低查询的执行成本。例如,如果一个表经常被查询,其数据页可能会一直保留在共享缓冲区缓存中,后续查询就可以直接从缓存中读取数据,而不需要从磁盘读取。通过合理配置缓存参数(如 shared_buffers 的大小),可以优化缓存的使用效率,进而提升查询性能。同时,一些查询结果缓存机制可以避免重复执行相同的查询,直接返回缓存的结果,进一步提高性能。然而,缓存的管理也需要权衡,因为缓存占用内存空间,如果设置过大可能会影响系统的其他性能。

通过深入理解 PostgreSQL 执行计划的输出结果,我们可以更好地优化数据库查询,提高数据库系统的性能和效率。无论是索引优化、查询重写还是合理利用缓存,都需要结合执行计划进行分析和调整。在实际应用中,不断地实践和优化是确保数据库高效运行的关键。