PostgreSQL 递归 CTE 性能问题

PostgreSQL recursive CTE performance issue

我试图理解两个查询在性能上的巨大差异。

假设我有两个 table。 第一个包含一组域的 A 记录:

                                Table "public.dns_a"
 Column |          Type          | Modifiers | Storage  | Stats target | Description
--------+------------------------+-----------+----------+--------------+-------------
 name   | character varying(125) |           | extended |              |             
 a      | inet                   |           | main     |              |             
Indexes:
    "dns_a_a_idx" btree (a)
    "dns_a_name_idx" btree (name varchar_pattern_ops)  

第二个 table 处理 CNAME 记录:

                              Table "public.dns_cname"
 Column |          Type          | Modifiers | Storage  | Stats target | Description
--------+------------------------+-----------+----------+--------------+-------------
 name   | character varying(256) |           | extended |              |             
 cname  | character varying(256) |           | extended |              |             
Indexes:
    "dns_cname_cname_idx" btree (cname varchar_pattern_ops)
    "dns_cname_name_idx" btree (name varchar_pattern_ops)  

现在我正在尝试解决 "simple" 问题,让所有域都指向同一个 IP 地址,包括 CNAME。

第一次尝试使用 CTE 效果不错:

EXPLAIN ANALYZE WITH RECURSIVE names_traverse AS (
    (
        SELECT name::varchar(256), NULL::varchar(256) as cname, a FROM dns_a WHERE a = '118.145.5.20'
    )
    UNION ALL
        SELECT c.name, c.cname, NULL::inet as a FROM names_traverse nt, dns_cname c WHERE c.cname=nt.name
)
SELECT * FROM names_traverse; 

                                                                               QUERY PLAN

------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 CTE Scan on names_traverse  (cost=3051757.20..4337044.86 rows=64264383 width=1064) (actual time=0.037..1697.444 rows=199 loops=1)
   CTE names_traverse
     ->  Recursive Union  (cost=0.57..3051757.20 rows=64264383 width=45) (actual time=0.036..1697.395 rows=199 loops=1)
           ->  Index Scan using dns_a_a_idx on dns_a  (cost=0.57..1988.89 rows=1953 width=24) (actual time=0.035..0.064 rows=14 loops=1)
                 Index Cond: (a = '118.145.5.20'::inet)
           ->  Merge Join  (cost=4377.00..176448.06 rows=6426243 width=45) (actual time=498.101..848.648 rows=92 loops=2)
                 Merge Cond: ((c.cname)::text = (nt.name)::text)
                 ->  Index Scan using dns_cname_cname_idx on dns_cname c  (cost=0.56..69958.06 rows=2268434 width=45) (actual time=4.732..688.456 rows=2219973 loops=2)
                 ->  Materialize  (cost=4376.44..4474.09 rows=19530 width=516) (actual time=0.039..0.084 rows=187 loops=2)
                       ->  Sort  (cost=4376.44..4425.27 rows=19530 width=516) (actual time=0.037..0.053 rows=100 loops=2)
                             Sort Key: nt.name USING ~<~
                             Sort Method: quicksort  Memory: 33kB
                             ->  WorkTable Scan on names_traverse nt  (cost=0.00..390.60 rows=19530 width=516) (actual time=0.001..0.007 rows=100 loops=2)
 Planning time: 0.130 ms
 Execution time: 1697.477 ms
(15 rows)         

上面的例子有两个循环,所以如果我做一个简单的外连接查询,我会得到更好的结果:

EXPLAIN ANALYZE 
SELECT * 
FROM dns_a a 
LEFT JOIN dns_cname c1 ON (c1.cname=a.name) 
LEFT JOIN dns_cname c2 ON (c2.cname=c1.name) 
WHERE a.a='118.145.5.20';

                                                                     QUERY PLAN

----------------------------------------------------------------------------------------------------------------------------------------------------
 Nested Loop Left Join  (cost=1.68..65674.19 rows=1953 width=114) (actual time=1.086..12.992 rows=189 loops=1)
   ->  Nested Loop Left Join  (cost=1.12..46889.57 rows=1953 width=69) (actual time=1.085..2.154 rows=189 loops=1)
         ->  Index Scan using dns_a_a_idx on dns_a a  (cost=0.57..1988.89 rows=1953 width=24) (actual time=0.022..0.055 rows=14 loops=1)
               Index Cond: (a = '118.145.5.20'::inet)
         ->  Index Scan using dns_cname_cname_idx on dns_cname c1  (cost=0.56..19.70 rows=329 width=45) (actual time=0.137..0.148 rows=13 loops=14)
               Index Cond: ((cname)::text = (a.name)::text)
   ->  Index Scan using dns_cname_cname_idx on dns_cname c2  (cost=0.56..6.33 rows=329 width=45) (actual time=0.057..0.057 rows=0 loops=189)
         Index Cond: ((cname)::text = (c1.name)::text)
 Planning time: 0.452 ms
 Execution time: 13.012 ms
(10 rows)

Time: 13.787 ms 

因此,性能差异大约是 100 倍,这正是让我担心的事情。 我喜欢递归 CTE 的便利,并且更喜欢使用它而不是在应用程序端做肮脏的把戏,但我不明白为什么 Index Scan using dns_cname_cname_idx on dns_cname c (cost=0.56..69958.06 rows=2268434 width=45) (actual time=4.732..688.456 rows=2219973 loops=2) 的成本如此之高。

我是否遗漏了一些关于 CTE 的重要信息,或者问题出在其他方面?

谢谢!

更新: 我的一个朋友发现了我错过的受影响的行数 Index Scan using dns_cname_cname_idx on dns_cname c (cost=0.56..69958.06 rows=2268434 width=45) (actual time=4.732..688.456 rows=2219973 loops=2),它等于 table 中的总行数,并且,如果我理解正确的话,它会无条件地执行全索引扫描,而且我不知道哪里遗漏了条件。

结果: 应用后 SET LOCAL enable_mergejoin TO false; 执行时间大大缩短。

EXPLAIN ANALYZE WITH RECURSIVE names_traverse AS (
    (
        SELECT name::varchar(256), NULL::varchar(256) as cname, a FROM dns_a WHERE a = '118.145.5.20'
    )
    UNION ALL
        SELECT c.name, c.cname, NULL::inet as a FROM names_traverse nt, dns_cname c WHERE c.cname=nt.name
)
SELECT * FROM names_traverse;
                                                                        QUERY PLAN                                                                        
----------------------------------------------------------------------------------------------------------------------------------------------------------
 CTE Scan on names_traverse  (cost=4746432.42..6527720.02 rows=89064380 width=1064) (actual time=0.718..45.656 rows=199 loops=1)
   CTE names_traverse
     ->  Recursive Union  (cost=0.57..4746432.42 rows=89064380 width=45) (actual time=0.717..45.597 rows=199 loops=1)
           ->  Index Scan using dns_a_a_idx on dns_a  (cost=0.57..74.82 rows=2700 width=24) (actual time=0.716..0.717 rows=14 loops=1)
                 Index Cond: (a = '118.145.5.20'::inet)
           ->  Nested Loop  (cost=0.56..296507.00 rows=8906168 width=45) (actual time=11.276..22.418 rows=92 loops=2)
                 ->  WorkTable Scan on names_traverse nt  (cost=0.00..540.00 rows=27000 width=516) (actual time=0.000..0.013 rows=100 loops=2)
                 ->  Index Scan using dns_cname_cname_idx on dns_cname c  (cost=0.56..7.66 rows=330 width=45) (actual time=0.125..0.225 rows=1 loops=199)
                       Index Cond: ((cname)::text = (nt.name)::text)
 Planning time: 0.253 ms
 Execution time: 45.697 ms
(11 rows)

如您所述,由于索引扫描,第一个查询很慢。

计划必须扫描完整的索引才能获得按 cname 排序的 dns_cname,这是合并连接所需的。合并连接要求两个输入 table 都按连接键排序,这可以通过对完整 table 进行索引扫描(如本例)或顺序扫描后跟明确排序。

您会注意到规划器严重高估了 CTE 评估的所有行数,这可能是问题的根源。对于更少的行,PostgreSQL 可能会选择嵌套循环连接,这样就不必扫描整个 table dns_cname.

这可能是可修复的,也可能是不可修复的。我可以立即看到的一件事是初始值 '118.145.5.20' 的估计值太高了 139.5 倍,这非常糟糕。您可以通过 运行 ANALYZE on dns_cname, perhaps after increasing the statistics target 列修复该问题:

ALTER TABLE dns_a ALTER a SET STATISTICS 1000;

看看这是否有所作为。

如果这不起作用,您可以手动将 enable_mergejoinenable_hashjoin 设置为 off 并查看具有嵌套循环连接的计划是否真的更好.如果您可以仅针对这个语句更改这些参数(可能使用 SET LOCAL)并通过这种方式获得更好的结果,那是您的另一种选择。