跳转到主要内容

7 性能剖析

使用 PostgreSQL 日志、pg_stat_statements、auto_explain 和性能剖析器找出高代价查询,并将测量结果与整体工作负载代价联系起来。

1. 什么是性能剖析

前几章讨论了查询如何工作、执行计划由哪些构件组成,以及哪些因素会影响规划器在不同计划之间的选择。这是最困难也最重要的部分。理解这些机制之后,逻辑和常识通常就足以发挥很大作用:你可以判断查询是否在高效执行,以及什么措施可能改善它。

但怎样才能找到真正值得优化的查询?

任何优化任务——不只是数据库优化——都从性能剖析(profiling)开始,即使我们不总是使用这个词。我们把有问题的任务拆分成更小的部分,并测量每个部分占总工作量的多少。知道每个部分执行了多少次也很有用。

某个组件在总代价中所占的份额越大,优化它的潜在收益就越大。在实践中,优化工作本身的代价也很重要:理论上很大的收益,可能需要付出高昂代价,或者伴随较大风险才能获得。

有时某个组件单独看运行得很快,但执行次数太多。例如,ORM 生成的查询就常有这种情况。此时,更有用的问题可能是:这个操作是否真的应该执行这么多次?改变应用架构通常并不容易,但它带来的收益可能远大于调优一次查询调用。

对数据库整体活动进行性能剖析,对于管理员也很有价值:管理员可能并不是在调查某一个投诉,而是在寻找系统资源消耗最大的部分。监控系统应当能展示这样的性能概况。

Andrey Zubkov 的 pg_profile 扩展就是完成这项工作的工具之一。它建立在 PostgreSQL 统计信息视图之上,并定期保存活动快照。比较不同快照,既可以发现性能问题,也可以发现问题出现时所处的条件。监控架构、工具和指标选择的完整讨论超出了本书范围。

Postgres Professional 还出版了 Alexey Lesovsky 的俄语书籍《Monitoring PostgreSQL》,对这一主题进行了深入讨论。不过,在调查一个具体问题时,性能剖析的范围应当限定为重现该问题所需的操作。

为了解决具体问题,性能剖析应当这样建立:测量结果只覆盖重现该问题所需的那些操作。

2. 测量什么

应该使用什么单位来表示一个组件的代价?对于终端用户来说,最有意义的度量是响应时间:从执行某个动作到得到结果,经过了多少秒。

在这个意义上,查看 I/O 的数量可能更方便,也就是读取和写入的页面数量。这个度量更加稳定:在数据和配置参数相同的情况下,它不依赖硬件特性或服务器当前负载。

研究符合现实的数据量也很重要。Postgres.ai 的 Database Lab Engine 等工具能够快速创建轻量级数据库克隆(thin clone),在不复制全部存储的情况下重现生产规模的行为时,这会很有帮助。

I/O 通常能较好地反映执行查询所需的工作量,因为大量时间都花在读取和处理数据页面上。不过,这个度量对终端用户来说意义不大。

3. 应用性能剖析

性能问题经常出在数据库中:一个糟糕的执行计划就可能让运行时间相差几个数量级。但情况并不总是如此。缓慢的客户端连接、序列化、应用服务器工作、客户端渲染、排队、远程服务调用以及网络传输,都可能占据用户响应时间的主要部分。因此,完整的性能剖析应当覆盖客户端、应用服务器、网络和数据库。

除非系统的每个组件都提供有用的遥测信息,否则很难获得这样的端到端性能剖析。至少要测量总响应时间,并将它与数据库工作所占用的时间进行比较。在认定数据库是瓶颈之前,还要检查明显的网络延迟以及 PostgreSQL 之外所花费的时间。

从现在开始,假定问题已经定位在数据库中。

4. PL/pgSQL 性能剖析

一个用户动作通常会执行多条 SQL 语句。要找到值得优化的语句,我们需要包含语句级细节的剖析结果。

服务器端应用还可能包含过程代码,而不只是 SQL。

对于 PL/pgSQL 代码,Jan Wieck 的 PL Profiler 可以剖析脚本和实时会话。它会生成 HTML 报告,还可以用火焰图表示调用关系。

Pavel Stěhule 的 plpgsql_check 是另一个有用的扩展。它可以对 PL/pgSQL 代码执行额外检查,发现一些性能问题,并提供性能剖析功能。

5. 查询性能剖析

PostgreSQL 提供两条获取已执行 SQL 剖析结果的主要内置途径:服务器日志记录,以及累积的语句统计信息,后者最常通过 pg_stat_statements 获得。

服务器日志

配置参数可以启用语句及其执行时长的日志记录。log_min_duration_statement 是常见的起点。

怎样才能在日志中准确识别属于某个用户动作的查询?在一个会话中修改 log_min_duration_statement 只会影响同一个会话;性能专家没有标准方式从另一个会话为任意后端修改该参数。可以过滤共同的日志消息流,只选择所需的消息。为此,可以在 log_line_prefix 中加入额外的标识信息,例如进程 ID、会话 ID、用户、数据库或应用名称。连接池会让情况更加复杂。在函数内部执行的嵌套 SQL 又是另一个问题。auto_explain 模块可以记录计划;如果设置了 auto_explain.log_nested_statements,还可以记录嵌套语句的计划。

繁忙服务器的日志可能变得非常庞大。pgBadger 等外部工具可以汇总和分析 PostgreSQL 日志数据。

在有助于把用户可见的工作与数据库活动关联起来时,应用代码也可以发出自己的关联标识符和诊断消息。

语句统计信息

第二种方法是使用 pg_stat_statements 扩展收集语句执行统计信息。这里的“统计信息”与第六章“统计信息”中的含义不同。规划器统计信息描述数据分布,帮助预测未来的执行。pg_stat_statements 记录的是实际发生的工作的聚合信息。收集的查询执行统计信息包括以页面数量表示的 I/O 信息。

由于不同查询的数量可能非常多,存储空间由 pg_stat_statements.max 限制。当表填满时,执行频率较低的语句条目的信息会被丢弃。

除了常量不同之外,如果查询具有相同的语法解析树,就会被视为同一个查询。需要记住,这些查询可能有不同的执行计划,执行时间也可能不同。

当 compute_query_id 设置为 auto 或 on 时,PostgreSQL 核心会计算查询标识符。这样,不同扩展获取的信息就可以使用统一标识符进行关联。

启用 pg_stat_statements 扩展,我们将使用它来建立性能剖析:

CREATE EXTENSION pg_stat_statements;
CREATE EXTENSION
ALTER SYSTEM SET shared_preload_libraries =
  'pg_stat_statements';
ALTER SYSTEM

然后必须重启服务器。

postgres$ pg_ctl restart -l /home/postgres/logfile
postgres$ /usr/local/pgsql/bin/psql demo

在下面的示例中,我们还要收集嵌套语句:

SET pg_stat_statements.track = 'all';
SET

创建一个只包含待检查字段的紧凑视图:

CREATE VIEW statements_v AS
SELECT
  queryid,
  toplevel,
  substring(regexp_replace(query,' +',' ','g') FOR 55)
    AS query,
  calls,
  round(total_exec_time)/1000 AS time_sec,
  shared_blks_hit + shared_blks_read +
    shared_blks_written AS shared_blks
FROM pg_stat_statements
ORDER BY total_exec_time DESC;
CREATE VIEW

现在考虑一份报告:按飞机型号和票价舱等显示乘客数量。先定义一个函数,统计某一飞机和票价舱等对应的乘客数量:

CREATE FUNCTION qty(
  airplane_code text, fare_conditions text
) RETURNS bigint
AS $$
  SELECT count(*)
  FROM timetable tt
    JOIN boarding_passes bp ON bp.flight_id = tt.flight_id
    JOIN seats s ON s.airplane_code = tt.airplane_code
                AND s.seat_no = bp.seat_no
  WHERE tt.airplane_code = qty.airplane_code
    AND s.fare_conditions = qty.fare_conditions;
$$ STABLE LANGUAGE sql;
CREATE FUNCTION

然后以此为基础创建一个表函数:

CREATE FUNCTION report()
RETURNS TABLE(
  model text,
  economy bigint, comfort bigint, business bigint
)
AS $$
DECLARE
  r record;
BEGIN
  FOR r IN
    SELECT a.airplane_code, a.model
    FROM airplanes a
    ORDER BY a.model
  LOOP
    report.model := r.model;
    report.economy := qty(r.airplane_code, 'Economy');
    report.comfort := qty(r.airplane_code, 'Comfort');
    report.business := qty(r.airplane_code, 'Business');
    RETURN NEXT;
  END LOOP;
END;
$$ STABLE LANGUAGE plpgsql;

重置语句统计信息并执行报告:

SELECT pg_stat_statements_reset();
SELECT * FROM report();
          model          | economy | comfort | business
-------------------------+---------+---------+----------
 Aerobus A320neo         |  120803 |       0 |    24494
 Aerobus A330-900neo     |  337923 |   42242 |    43775
 Aerobus A350-1000       |  736214 |       0 |   115244
 Aerobus A350F           |       0 |       0 |        0
 Boeing 737 MAX 7        |  556344 |       0 |    61743
 Boeing 767-300F         |       0 |       0 |        0
 Boeing 777-300ER        | 2238243 |  329509 |   205873
 Boeing 787-9 Dreamliner |  613540 |   68505 |   156712
 Bombardier CRJ700       |   91626 |   21181 |    10604
 Embraer E170            |  191837 |       0 |    16006
(10 rows)

现在检查性能剖析结果:

SELECT * FROM statements_v \gx
-[ RECORD 1 ]----------------------------------
queryid     | -3009688052632882778
toplevel    | t
query       | SELECT * FROM report()
calls       | 1
time_sec    | 28.311
shared_blks | 9417875
-[ RECORD 2 ]----------------------------------
queryid     | -3792749405278051211
toplevel    | f
query       | SELECT count(*)                  +
            |  FROM timetable tt               +
            |  JOIN boarding_passe
calls       | 30
time_sec    | 28.292
shared_blks | 9416655
-[ RECORD 3 ]----------------------------------
queryid     | 704897965492952962
toplevel    | f
query       | SELECT a.airplane_code, a.model  +
            |  FROM airplanes a                +
            |  ORDE
calls       | 1
time_sec    | 0
shared_blks | 4
-[ RECORD 4 ]----------------------------------
queryid     | -3948231919979109674
toplevel    | t
query       | SELECT pg_stat_statements_reset()
calls       | 1
time_sec    | 0
shared_blks | 0

有三个条目值得关注:

  • 顶层的 report() 调用;

  • qty 中的查询,执行了 30 次;

  • 驱动 PL/pgSQL 循环的查询。

几乎所有执行时间和缓冲区工作都来自 qty 查询。这个重复工作是显而易见的目标:报告应当围绕一条面向集合的查询重新组织。

6. 单个查询的性能剖析

性能剖析确定值得优化的语句后,我们还需要查询内部的性能剖析结果。EXPLAIN ANALYZE 可以提供这样的结果。这个结果由计划节点组成。但计划是一棵树,而不是一个扁平列表;解读时间时,这一点很重要。

对于每个节点,actual time、rows 和 loops 会告诉我们执行过程中发生了什么。

EXPLAIN 的 buffers 选项显示 I/O 数量;如果启用了 track_io_timing,它还会显示 I/O 操作花费的时间。

对于计划诊断,基数估计至少和运行时间同样重要。比较估计的 rows 与 actual rows 时,要把 loops 考虑进去。计划树底部的较大误差可能向上传播,并扭曲连接方法、访问路径、聚合和内存决策。

应该从估计首次严重出错的最深节点开始,而不是责怪每个继承该误差的父节点。

良好的基数估计并不能保证计划良好——代价参数和代价模型仍然可能有误——但较大的基数错误是最先需要调查的问题之一。

有时查询运行时间太长,无法实际使用 EXPLAIN ANALYZE。这时只能满足于普通的 EXPLAIN,并在信息不完整的情况下尝试理解低效的原因。

现在把乘客报告重写成一条 SQL 查询并检查它:

EXPLAIN (analyze, costs off, timing off)
WITH rf AS MATERIALIZED (
  SELECT f.flight_id, r.airplane_code
  FROM flights f
    JOIN routes r ON r.route_no = f.route_no
                 AND r.validity @> f.scheduled_departure
), t AS (
  SELECT rf.airplane_code,
    count(*) FILTER
         (WHERE s.fare_conditions = 'Economy') AS economy,
    count(*) FILTER
         (WHERE s.fare_conditions = 'Comfort') AS comfort,
    count(*) FILTER
         (WHERE s.fare_conditions = 'Business') AS business
  FROM rf
    JOIN boarding_passes bp ON bp.flight_id = rf.flight_id
    JOIN seats s ON s.airplane_code = rf.airplane_code
                AND s.seat_no = bp.seat_no
  GROUP BY rf.airplane_code
)
SELECT a.model,
  coalesce(t.economy, 0) AS economy,
  coalesce(t.comfort, 0) AS comfort,
  coalesce(t.business, 0) AS business
FROM airplanes a
  LEFT JOIN t ON a.airplane_code = t.airplane_code
ORDER BY a.model;

正如下一页清楚显示的,大型计划并不总是适合以文本形式处理。为了更清楚地查看计划,可以使用第三方工具;其中一个是 explain.tensor.ru。

注意,消除重复读取后,查询执行时间减少了多少,读取的页面数量也少了多少:

                         QUERY PLAN
------------------------------------------------------------
 Sort (actual rows=10.00 loops=1)
   Sort Key: ((ml.model ->> current_setting('bookings.la...
   Sort Method: quicksort  Memory: 25kB
   Buffers: shared hit=13059 read=37154
   CTE rf
     -> Hash Join (actual rows=37671.00 loops=1)
         Hash Cond: (f.route_no = r.route_no)
         Join Filter: (r.validity @> f.scheduled_departure)
         Rows Removed by Join Filter: 115016
         Buffers: shared hit=397 read=5
         -> Seq Scan on flights f (actual rows=37671.00 ...
             Buffers: shared hit=368
         -> Hash (actual rows=2030.00 loops=1)
             Buckets: 2048  Batches: 1  Memory Usage: 145kB
             Buffers: shared hit=29 read=5
             -> Seq Scan on routes r (actual rows=2030.0...
                 Buffers: shared hit=29 read=5
 ...
                   Buffers: shared hit=13059 read=37153
                   -> Hash Join (actual rows=5982418.00 ...
                       Hash Cond: ((rf.airplane_code = s...
                       Buffers: shared hit=13059 read=37153
                       -> Hash Join (actual rows=5982418...
                           Hash Cond: (bp.flight_id = rf...
                           Buffers: shared hit=13057 rea...
                           -> Seq Scan on boarding_passe...
                               Buffers: shared hit=12660...
                           -> Hash (actual rows=37671.00...
                               Buckets: 65536 (originall...
                               Buffers: shared hit=397 r...
                               -> CTE Scan on rf (actual...
                                   Storage: Memory  Maxi...
                                   Buffers: shared hit=3...
                       -> Hash (actual rows=1741.00 loop...
                           Buckets: 2048  Batches: 1  Me...
                           Buffers: shared hit=2 read=8
                           -> Seq Scan on seats s (actua...
                               Buffers: shared hit=2 read=8
 Planning:
   Buffers: shared hit=5 read=1
 Planning Time: 0.287 ms
 Execution Time: 1388.015 ms
(54 rows)

为了获得参照,统计相关主表的堆页数量:

SELECT sum(relpages)
FROM pg_class
WHERE relname IN ('flights', 'routes', 'boarding_passes',
  'seats', 'airplanes');
  sum
-------
 50212
(1 row)

对于需要全部行的查询,这个数字可以作为粗略的上限估计:如果处理的页面明显更多,可能说明同一批数据被扫描了多次。

在这个例子中,计划已经接近最优。它仍然可以改进,但幅度远不会像之前那样大。

参考资源