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

PostgreSQL执行计划调整与性能调优

2022-01-176.0k 阅读

PostgreSQL执行计划基础

执行计划概述

在深入探讨执行计划调整与性能调优之前,我们先来理解什么是执行计划。PostgreSQL的执行计划是数据库在执行查询语句时所规划的步骤序列。它决定了如何从存储中获取数据、如何对数据进行操作(如排序、连接等)以满足查询需求。

执行计划对于查询性能至关重要。一个优化良好的执行计划可以让查询在短时间内完成,高效利用系统资源(如CPU、内存和磁盘I/O)。相反,一个糟糕的执行计划可能导致查询运行缓慢,甚至耗尽系统资源。

例如,考虑一个简单的查询:

SELECT * FROM users WHERE age > 30;

PostgreSQL在执行这个查询时,需要决定是全表扫描 users 表,还是利用索引快速定位符合条件的记录。这一决策就体现在执行计划中。

查看执行计划

在PostgreSQL中,可以使用 EXPLAIN 关键字来查看查询的执行计划。例如,对于上述查询:

EXPLAIN SELECT * FROM users WHERE age > 30;

EXPLAIN 命令会返回查询执行计划的详细描述。输出结果的每一行都代表一个执行步骤,并且包含有关该步骤的信息,如操作类型(例如 Seq Scan 表示全表扫描,Index Scan 表示索引扫描)、涉及的表、估计的成本等。

下面是一个 EXPLAIN 输出的示例:

                          QUERY PLAN
-------------------------------------------------------------
 Seq Scan on users  (cost=0.00..25.00 rows=100 width=10)
   Filter: (age > 30)
(2 rows)

在这个例子中,我们看到PostgreSQL计划对 users 表进行全表扫描(Seq Scan),估计成本为 0.00..25.00,预计返回100行,每行宽度为10字节。Filter 子句显示了应用于扫描结果的过滤条件。

除了 EXPLAIN,还可以使用 EXPLAIN ANALYZEEXPLAIN ANALYZE 不仅会显示执行计划,还会实际执行查询,并在输出中包含实际执行时间和其他统计信息。例如:

EXPLAIN ANALYZE SELECT * FROM users WHERE age > 30;

输出可能如下:

                                                          QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------
 Seq Scan on users  (cost=0.00..25.00 rows=100 width=10) (actual time=0.010..0.011 rows=5 loops=1)
   Filter: (age > 30)
   Rows Removed by Filter: 95
 Planning time: 0.050 ms
 Execution time: 0.020 ms
(5 rows)

这里,actual time 显示了实际执行时间,rows 显示了实际返回的行数,Rows Removed by Filter 显示了在过滤过程中被移除的行数。Planning time 是生成执行计划的时间,Execution time 是整个查询的执行时间。

影响执行计划的因素

统计信息

PostgreSQL依赖统计信息来生成执行计划。这些统计信息描述了表和索引的内容,例如表中的行数、列的唯一值数量、数据分布等。

统计信息不准确或过时可能导致执行计划不佳。例如,如果统计信息显示表中只有很少的行,而实际上表中有大量数据,PostgreSQL可能会选择全表扫描而不是更高效的索引扫描。

可以使用 ANALYZE 命令更新表的统计信息。例如:

ANALYZE users;

这将重新收集 users 表的统计信息,让PostgreSQL能够基于更准确的数据生成执行计划。

索引

索引是影响执行计划的关键因素之一。索引可以加快查询速度,特别是在过滤条件和连接条件中使用的列上。

假设有如下查询:

SELECT * FROM orders WHERE customer_id = 123;

如果在 orders 表的 customer_id 列上有索引,PostgreSQL可能会选择使用索引扫描来快速定位符合条件的记录,而不是全表扫描。

创建索引的语法如下:

CREATE INDEX idx_orders_customer_id ON orders (customer_id);

在选择创建索引时,需要谨慎考虑。虽然索引可以加速查询,但也会增加写入操作(如 INSERTUPDATEDELETE)的成本,因为每次数据更改时,索引也需要更新。

查询语句结构

查询语句的结构也会影响执行计划。例如,复杂的 JOIN 操作、子查询、OR 条件等都可能导致执行计划变得复杂。

考虑以下查询:

SELECT * FROM products
JOIN categories ON products.category_id = categories.category_id
WHERE products.price > 100 AND categories.name = 'Electronics';

这个查询涉及到 JOIN 操作和过滤条件。PostgreSQL需要决定如何执行 JOIN(例如,使用嵌套循环连接、哈希连接还是排序合并连接),以及如何应用过滤条件。

在某些情况下,重写查询语句可以得到更优的执行计划。例如,对于包含子查询的查询,可以尝试将其重写为 JOIN 形式。

配置参数

PostgreSQL的配置参数也会对执行计划产生影响。一些关键的配置参数包括 shared_bufferswork_memmaintenance_work_mem 等。

shared_buffers 决定了PostgreSQL可以在内存中缓存的数据量。增加 shared_buffers 可以减少磁盘I/O,提高查询性能,特别是对于经常访问的数据。

work_mem 用于排序和哈希表操作。如果查询涉及大量排序或哈希操作,适当增加 work_mem 可以提高性能。

可以通过修改 postgresql.conf 文件来调整这些参数。例如,要增加 shared_buffers

shared_buffers = '2GB'  # 根据系统内存情况调整

修改配置参数后,需要重启PostgreSQL服务使更改生效。

执行计划调整策略

索引优化

分析现有索引

在进行索引调整之前,首先要分析现有索引的使用情况。可以使用 pg_stat_activitypg_stat_statements 视图来查看哪些查询正在执行以及它们是否使用了索引。

例如,通过 pg_stat_statements 可以查看每个查询的执行次数、平均执行时间等信息:

SELECT query, calls, total_time, rows
FROM pg_stat_statements
ORDER BY total_time DESC;

如果发现某个频繁执行的查询没有使用索引,可以进一步分析原因,可能是索引不存在,或者索引不适合该查询。

创建合适的索引

基于查询分析结果,创建合适的索引。除了普通索引,PostgreSQL还支持多种类型的索引,如 B - Tree 索引、Hash 索引、GiST 索引、GIN 索引等。

B - Tree 索引适用于范围查询和排序操作,是最常用的索引类型。例如:

CREATE INDEX idx_employees_salary ON employees (salary);

Hash 索引适用于等值比较,但不支持范围查询。例如:

CREATE INDEX idx_customers_email ON customers USING hash (email);

GiST 索引和 GIN 索引适用于处理复杂的数据类型和查询,如全文搜索、空间数据等。例如,对于全文搜索,可以创建 GIN 索引:

CREATE INDEX idx_articles_search ON articles USING gin(to_tsvector('english', content));

避免冗余和无效索引

冗余索引是指多个索引覆盖了相同或相似的列组合,这会增加存储和维护成本。例如,如果已经有一个 CREATE INDEX idx_orders_customer_product ON orders (customer_id, product_id);,再创建 CREATE INDEX idx_orders_customer ON orders (customer_id); 可能就是冗余的,因为前一个索引已经包含了 customer_id 列,可以满足只基于 customer_id 的查询。

无效索引是指从未被使用过的索引。可以通过分析查询日志和系统统计信息来识别无效索引,并考虑删除它们。例如:

DROP INDEX idx_unused;

查询重写

简化子查询

子查询有时会使执行计划变得复杂。例如,考虑以下子查询:

SELECT product_id, price
FROM products
WHERE price > (SELECT AVG(price) FROM products);

可以将其重写为 JOIN 形式:

SELECT p1.product_id, p1.price
FROM products p1
JOIN (SELECT AVG(price) AS avg_price FROM products) p2
ON p1.price > p2.avg_price;

这种重写通常可以使执行计划更简单、更高效。

优化 JOIN 操作

在处理 JOIN 操作时,选择合适的 JOIN 类型很重要。PostgreSQL支持 Nest Loop JOINHash JOINMerge JOIN

Nest Loop JOIN 是最基本的 JOIN 类型,它通过嵌套循环的方式将两个表连接起来。适用于小表与大表的连接,且小表可以在内存中完全加载。例如:

SELECT *
FROM small_table
JOIN large_table ON small_table.id = large_table.small_table_id;

Hash JOIN 适用于大数据集的连接。它先在内存中构建一个哈希表,然后通过哈希表查找进行连接。例如:

SELECT *
FROM table1
JOIN table2 ON table1.key = table2.key;

Merge JOIN 适用于两个已排序的表进行连接。如果表已经按连接列排序,Merge JOIN 可以高效地进行连接操作。例如:

SELECT *
FROM sorted_table1
JOIN sorted_table2 ON sorted_table1.id = sorted_table2.id;

通过分析表的大小、数据分布和连接条件,可以选择最合适的 JOIN 类型,从而优化执行计划。

处理 OR 条件

OR 条件在查询中可能导致执行计划不佳。例如:

SELECT * FROM users WHERE age > 30 OR email = 'example@example.com';

如果 ageemail 列上都有索引,PostgreSQL可能无法有效地利用这些索引。一种解决方法是将 OR 条件拆分成两个查询,并使用 UNION 合并结果:

SELECT * FROM users WHERE age > 30
UNION
SELECT * FROM users WHERE email = 'example@example.com';

这样可以让PostgreSQL分别使用相应的索引,提高查询性能。

统计信息更新与维护

定期执行 ANALYZE

为了确保执行计划的准确性,定期执行 ANALYZE 是必要的。可以使用数据库的定时任务功能(如 cron 脚本)来定期运行 ANALYZE。例如,在Linux系统上,可以创建一个 cron 任务:

0 2 * * * psql -U your_username -d your_database -c 'ANALYZE'

这个任务会在每天凌晨2点对指定数据库执行 ANALYZE 操作。

部分 ANALYZE

除了对整个表执行 ANALYZE,还可以进行部分 ANALYZE。部分 ANALYZE 只更新表中部分数据的统计信息,适用于表中数据分布不均匀,且部分数据经常变动的情况。

例如,对于一个按日期分区的表,可以只对最近更新的分区执行 ANALYZE

ANALYZE ONLY partition_table WHERE partition_date >= '2023 - 01 - 01';

配置参数调整

内存相关参数

shared_buffers:如前文所述,shared_buffers 决定了PostgreSQL可以在内存中缓存的数据量。一般来说,建议将 shared_buffers 设置为系统内存的25% - 40%,但具体数值需要根据系统负载和可用内存进行调整。例如,对于一个具有16GB内存的服务器,可以将 shared_buffers 设置为 4GB

shared_buffers = '4GB'

work_memwork_mem 用于排序和哈希表操作。如果查询经常涉及大量排序或哈希操作,可以适当增加 work_mem。例如,将 work_mem 设置为 64MB

work_mem = '64MB'

但要注意,work_mem 设置过大可能会导致系统内存不足,影响其他进程的运行。

I/O相关参数

effective_cache_size:这个参数告诉PostgreSQL系统中有多少内存可用于磁盘I/O缓存。它影响PostgreSQL对全表扫描和索引扫描的成本估算。一般来说,可以将 effective_cache_size 设置为系统内存减去操作系统和其他应用程序所需的内存。例如:

effective_cache_size = '12GB'

checkpoint_timeoutcheckpoint_segmentscheckpoint_timeout 控制两次检查点之间的时间间隔,checkpoint_segments 控制检查点之间的WAL(Write - Ahead Log)段数。适当调整这两个参数可以平衡数据安全性和I/O性能。例如:

checkpoint_timeout = 30min
checkpoint_segments = 16

较短的 checkpoint_timeout 和较少的 checkpoint_segments 可以提高数据安全性,但可能会增加I/O负担;反之则可以减少I/O,但可能会在崩溃恢复时需要更长时间。

性能调优实践案例

案例一:全表扫描优化

假设有一个电商系统,其中有一个 orders 表,包含大量订单数据。有一个查询需要获取某个客户的所有订单:

SELECT * FROM orders WHERE customer_id = 123;

当前执行计划显示为全表扫描:

                          QUERY PLAN
-------------------------------------------------------------
 Seq Scan on orders  (cost=0.00..1000.00 rows=100 width=100)
   Filter: (customer_id = 123)
(2 rows)

分析发现 customer_id 列上没有索引。通过创建索引:

CREATE INDEX idx_orders_customer_id ON orders (customer_id);

再次查看执行计划:

                          QUERY PLAN
-------------------------------------------------------------
 Index Scan using idx_orders_customer_id on orders  (cost=0.29..8.31 rows=1 width=100)
   Index Cond: (customer_id = 123)
(2 rows)

可以看到,执行计划从全表扫描变为索引扫描,查询性能得到显著提升。

案例二:复杂 JOIN 优化

有一个新闻网站系统,包含 articles 表和 comments 表。需要查询所有文章及其评论数量:

SELECT a.article_id, a.title, COUNT(c.comment_id) AS comment_count
FROM articles a
LEFT JOIN comments c ON a.article_id = c.article_id
GROUP BY a.article_id, a.title;

原始执行计划显示 JOIN 操作成本较高。通过分析,发现 articles 表和 comments 表在连接列 article_id 上没有合适的索引。

首先在 comments 表的 article_id 列上创建索引:

CREATE INDEX idx_comments_article_id ON comments (article_id);

再次查看执行计划,JOIN 成本有所降低,但仍不理想。进一步分析发现,由于 LEFT JOIN 的特性,comments 表中的大量空值也参与了统计。可以通过在子查询中先过滤掉空值,再进行 JOIN

SELECT a.article_id, a.title, sub.comment_count
FROM articles a
LEFT JOIN (
    SELECT article_id, COUNT(comment_id) AS comment_count
    FROM comments
    WHERE comment_id IS NOT NULL
    GROUP BY article_id
) sub ON a.article_id = sub.article_id;

经过这样的优化,执行计划更加高效,查询性能得到大幅提升。

案例三:配置参数优化

在一个数据库服务器上,系统负载较高,查询性能逐渐下降。通过分析发现,shared_buffers 设置过小,导致频繁的磁盘I/O。

当前 shared_buffers 设置为 1GB,系统内存为 8GB。将 shared_buffers 调整为 2GB

shared_buffers = '2GB'

重启PostgreSQL服务后,再次运行一些关键查询,发现查询性能有了明显提升,磁盘I/O次数减少。同时,观察到系统内存使用更加合理,没有出现内存不足的情况。

另外,发现一些排序操作频繁的查询执行缓慢。检查 work_mem 参数,当前设置为 32MB。将其调整为 64MB

work_mem = '64MB'

调整后,这些查询的执行时间明显缩短,系统整体性能得到改善。

在实际的性能调优过程中,需要综合考虑各种因素,通过不断地分析、调整和测试,才能找到最适合的优化方案,提高PostgreSQL数据库的性能。