Postgres統計信息的配置和校準

前言


對於大多數關係型數據庫,最核心的組件之一是優化器,優化器生成SQL的執行計劃,依賴於統計信息,也就是表中的數據分佈詳情,一般來說,優化器根據統計信息選擇執行計劃的算法本身不會有什麼問題(優化器的模式選擇除外,例如Oralce的CBO和RBO),問題往往出現在統計信息是否準確上,不準確的統計信息會導致優化器生成錯誤的、不合理的執行計劃。這個問題對於很多關係型數據庫的優化有着重要意義。postgres數據庫作爲近幾年最流行的數據庫之一,它在優化器和統計信息方面仍然比較容易存在這種問題。關鍵在於如何配置和調整統計信息的收集。

相關統計視圖介紹


  • pg_class中統計有每個表和索引中含有的總記錄數量——reltuples字段,以及每個表和索引對象在磁盤中佔用的數據塊數量——relpages字段。
SELECT relname, relkind, reltuples, relpages
FROM pg_class
WHERE relname LIKE 'tenk1%';

       relname        | relkind | reltuples | relpages
----------------------+---------+-----------+----------
 tenk1                | r       |     10000 |      358
 tenk1_hundred        | i       |     10000 |       30
 tenk1_thous_tenthous | i       |     10000 |       30
 tenk1_unique1        | i       |     10000 |       30
 tenk1_unique2        | i       |     10000 |       30
(5 rows)

relkind表示對象類型,r表示表,i表示索引。

出於效率考慮,reltuples和relpages字段並不會實時更新,會有一定的延遲。VACUUM(包括auto acuum後臺任務)和ANALYZE操作可以更新表的統計信息。對於reltuples的更新,VACUUM或者ANALYZE操作並不會對整個表進行掃描,而是根據已經掃描的表的一部分對reltuples的值進行增量更新,最後得到的是一個近似值。

  • pg_stats視圖記錄了以表列爲單位的更詳細的統計信息,例如視圖的n_distinct列,簡而言之,體現了該列所有值的選擇性,若大於零,表示該列不同值的總數(必定爲整數),若小於零,表示該列不同值的總數除以表的總行數的相反數(小於0且大於-1的實數),一般來說,小於0說明選擇性較好,即唯一性更好,更容易走索引。若想了解更多列含義請移步官方手冊pg_stats系統視圖介紹
SELECT attname, inherited, n_distinct,
       array_to_string(most_common_vals, E'\n') as most_common_vals
FROM pg_stats
WHERE tablename = 'road';

 attname | inherited | n_distinct |          most_common_vals
---------+-----------+------------+------------------------------------
 name    | f         |  -0.363388 | I- 580                        Ramp+
         |           |            | I- 880                        Ramp+
         |           |            | Sp Railroad                       +
         |           |            | I- 580                            +
         |           |            | I- 680                        Ramp
 name    | t         |  -0.284859 | I- 880                        Ramp+
         |           |            | I- 580                        Ramp+
         |           |            | I- 680                        Ramp+
         |           |            | I- 580                            +
         |           |            | State Hwy 13                  Ramp
(2 rows)

inherited列表示是否爲繼承列,f爲否,即爲表列本身,t爲是,表該列繼承自其他表列。

繼承是postgres表特有的功能之一,這裏暫不討論

統計信息的更新和相關配置參數


前面已經講到,ACUUM命令和auto acuum後臺進程以及ANALYZE命令都會更新統計信息。並且,更新有延遲且不準確。實際上準確程度是可以通過參數來調整控制的,準確性要求越高,更新統計花費時間則越長,則延遲時間越長,因此準確性和實時性不能同時保證,關鍵在於如何取捨。可以從總體上調整所有統計信息更新操作的準確性,也可以更細粒度地調整特定表或表列的統計信息的準確性。當然,ANALYZE和ACUUM命令也可以手動執行特定表或表列的統計信息的更新。auto acuum後臺任務只會在表數據發生較大批量DML語句後才執行。

  • default_statistics_target系統變量用於設置most_common_vals列和histogram_bound列中統計樣本的數量,默認值爲100,值越大,越準確,但統計信息更新所需時間更長。該變量可在會話或事務級別動態調整,影響的是當前會話或事務中的統計信息更新操作,但在系統級別,需要修改參數文件然後重啓才能生效。
postgres=# show default_statistics_target;
 default_statistics_target 
---------------------------
 100
(1 row)

會話級別修改

postgres=# set default_statistics_target=2000;
SET

事務級別修改

postgres=# set local default_statistics_target=2000;
WARNING:  SET LOCAL can only be used in transaction blocks
SET

更新表的統計信息

analyze road;

更新表的某列的統計信息

analyze road(name);
  • ALTER TABLE tbl_name ALTER COLUMN col_name SET STATISTICS integer命令形式用於設置特定表的列在更新統計信息時採集樣本的數量,和default_statistics_target參數一樣,只是作用範圍不一樣。
alter table road alter column road(name) set STATISTICS 2000;

創建多列統計


  • 什麼是多列統計?爲什麼使用多列統計?

    查詢中如果where條件中使用到了多個列時,往往容易遇到多列之間存在相關性而導致查詢緩慢的情況。優化器默認認爲列與列之間是相互獨立的,不存在相關性。什麼是相關性?對於表中一行,一個列出現某一個值時,對應的另一列的值從統計的角度並非隨機,存在某種關聯,正如那個經典的案例,買啤酒的客戶具有買紙尿褲的傾向性。像這種存在相關性的多個列,在業務數據表中是普遍存在的,再比如像這種有現實依據的,性別列和身高列,男性普遍高於女性。這就叫相關性。與相關性對應的概念就叫獨立性,優化器默認認爲任意兩列的值的分佈從統計學角度上是相互獨立的,基於一個錯誤的假設,得到的結果往往也是錯誤的,因此優化器容易生成錯誤的查詢執行計劃。如果發現SQL語句是由於列與列之間存在較強相關性導致的查詢緩慢,能解決這個問題的途徑就是,打破傳統,把你認爲具有相關性的兩列或多列聯合起來創建並更新統計信息,這就叫多列統計,這樣,樣本空間不再是一維,而是二維或多維。就拿二維來說,樣本爲兩列所有不同值的兩兩組合,在這樣的樣本空間上選取樣本統計出來的樣本分佈肯定是更加準確的。

  • 如何創建多列統計?
    語法:

postgres=# \h create statistics
Command:     CREATE STATISTICS
Description: define extended statistics
Syntax:
CREATE STATISTICS [ IF NOT EXISTS ] statistics_name
    [ ( statistics_kind [, ... ] ) ]
    ON column_name, column_name [, ...]
    FROM table_name

\h 用於查看SQL命令幫助,create statistics命令僅在postgres 10.0或以上版本支持。命令參數詳解可參閱官方文檔https://www.postgresql.org/docs/10/sql-createstatistics.html

  • 演示
    下面我們通過一個實驗來對演示多列統計信息的使用場景:

先創建一張表,並加載數據,然後手動收集統計信息(兩列值完全相等,兩列具有相關性的極端情況)

test=# CREATE TABLE t (a INT, b INT);
CREATE TABLE
test=# INSERT INTO t SELECT i % 100, i % 100 FROM generate_series(1, 10000) s(i);
INSERT 0 10000
test=# ANALYZE t;

然後分別執行下面兩個查詢,並查看執行計劃以及執行統計

test=# explain analyze select * from t where a=1;
                                           QUERY PLAN                                            
-------------------------------------------------------------------------------------------------
 Seq Scan on t  (cost=0.00..170.00 rows=100 width=8) (actual time=0.013..1.412 rows=100 loops=1)
   Filter: (a = 1)
   Rows Removed by Filter: 9900
 Planning time: 0.048 ms
 Execution time: 1.437 ms
(5 rows)

test=# explain analyze select * from t where a=1 and b=1;
                                          QUERY PLAN                                           
-----------------------------------------------------------------------------------------------
 Seq Scan on t  (cost=0.00..195.00 rows=1 width=8) (actual time=0.015..1.502 rows=100 loops=1)
   Filter: ((a = 1) AND (b = 1))
   Rows Removed by Filter: 9900
 Planning time: 0.072 ms
 Execution time: 1.529 ms
(5 rows)

explain analyze命令可用於對比實際執行的統計信息和優化器在生成執行計劃時預估的統計信息之間的區別。

在第一個查詢中,條件只有a=1,預估的rows=100,以及實際執行的結果rows=100,這是相當準確的,因爲查詢條件只有單列,單列的數據本身分佈很均勻,並且pg_stats系統視圖中有單列的統計信息。

而在第二個查詢中,使用了a=1 and b=1 兩個條件,查詢的結果實際上和第一個查詢完全相同,實際執行結果也是rows=100,但是預估值爲rows=1。

爲什麼第二個查詢預估的結果行會是1呢?因爲a、b兩列各自的統計信息中n_distinct均爲100,因此選擇性(selectivity)爲1%,在各列相互獨立的默認假設下,優化器計算出a=1且b=1的聯合概率爲1%X1%=0.01%,因此預估條件篩選出的結果行爲總行數的0.01%:10000*0.01%=1

test=# select tablename,attname,n_distinct from pg_stats where tablename like 't';
 tablename | attname | n_distinct 
-----------+---------+------------
 t         | a       |        100
 t         | b       |        100
(2 rows)

現在我們對a,b兩列創建多列統計,看看優化器的預估情況會如何變化

test=# create statistics stats_t_a_b on a,b from t;
CREATE STATISTICS
test=# 
test=# analyze t;
test=# explain analyze select * from t where a=1;
                                           QUERY PLAN                                            
-------------------------------------------------------------------------------------------------
 Seq Scan on t  (cost=0.00..170.00 rows=100 width=8) (actual time=0.019..1.483 rows=100 loops=1)
   Filter: (a = 1)
   Rows Removed by Filter: 9900
 Planning time: 0.111 ms
 Execution time: 1.522 ms
(5 rows)

test=# explain analyze select * from t where a=1 and b=1;
                                           QUERY PLAN                                            
-------------------------------------------------------------------------------------------------
 Seq Scan on t  (cost=0.00..195.00 rows=100 width=8) (actual time=0.023..1.665 rows=100 loops=1)
   Filter: ((a = 1) AND (b = 1))
   Rows Removed by Filter: 9900
 Planning time: 0.129 ms
 Execution time: 1.705 ms
(5 rows)

不出所料,創建多列統計並重新更新統計信息後,第一個查詢預估依然準確,第二個查詢的預估值和實際值也變得準確了,因爲測試數據分佈絕對均勻,實際生產環境很少有這樣的情況,不太可能出現預估和實際一模一樣,但我們要做的是,儘可能讓統計信息更準確,讓執行計劃更合理。

另外,如果想要查看已創建的多列統計信息的詳情,可以查看pg_statistic_ext系統視圖

test=# select stxname,stxkind,stxndistinct from pg_statistic_ext where stxname like 'stats_t_a_b';
   stxname   | stxkind | stxndistinct  
-------------+---------+---------------
 stats_t_a_b | {d,f}   | {"1, 2": 100}
(1 row)

stxndistinct列爲{"1, 2": 100},表示表中的第一列和第二列作的聯合,聯合之後的二維樣本空間的ndistinct,仍然爲100,因爲兩列的值完全相等。

pg_statistic_ext更多字段含義見官方手冊

統計信息校準生產案例


B表是生產環境的一張分區表,分區鍵爲時間字段按月分區,每個分區平均約300萬行,存放了最近2年的數據,數據總量上億。由於統計信息的不準確,導致查詢該表的語句在和另一張表A關聯的時候走了全表掃描。B表是被驅動表,由於表A上的條件能夠篩選出較少行(約15000行,A表使用了索引,沒有問題),因此表A爲驅動表,A和B使用字段do_id(訂單號)關聯,實際的執行結果爲50000行左右,從上億的數據量裏獲取50000行數據,卻走了全表掃描,讓人難以接受。從執行計劃裏的統計信息來看,預估的關聯結果爲將近920萬,do_id列本身是有索引的,因此斷定爲B表上do_id的統計信息非常不準確。

下面是截取的執行計劃的關鍵節點,tt子查詢即爲上述基於A表上的子查詢,cdc_tm_do_item分區表即爲上述B表

->  Hash Join  (cost=9955271.45..11817023.19 rows=9200732 width=257)
   Hash Cond: ((tt.do_id)::text = (tdi.do_id)::text)
    ->  Subquery Scan on tt  (cost=349867.12..353482.41 rows=16985 width=212)
    ->  Hash  (cost=7057951.26..7057951.26 rows=114411926 width=55)
                  ->  Append  (cost=0.00..7057951.26 rows=114411926 width=55)                         
                          ->  Seq Scan on cdc_tm_do_item_1801 tdi_3  (cost=0.00..294617.30 rows=6042930 width=54)
                          ...
                          ->  Seq Scan on cdc_tm_do_item_1912 tdi_26  (cost=0.00..10.10 rows=10 width=622)
                          ->  Seq Scan on cdc_tm_do_item_min tdi_27  (cost=0.00..10.10 rows=10 width=622)
  • 驗證猜測:

    在B表上選擇了一個分區進行驗證,按照do_id分組求和,發現少量訂單號出現次數特別多,從業務邏輯上講就是這些訂單中的商品數量非常多,但這樣的訂單是很少的,應該是B2B訂單,而大部分單個訂單的商品數量並不多(B2C),1~3個佔絕大多數(下圖中未體現)。因此數據的傾斜程度是比較嚴重的。


再查看了一下統計信息,n_distinct值約爲16萬,和實際值426萬相差二十幾倍

itldw=# explain analyze select count(*),do_id from tms.cdc_tm_do_item_1906 group by do_id  order by 1 desc;
                                                                            QUERY PLAN                                                                             
-------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Sort  (cost=723663.61..724070.27 rows=162664 width=19) (actual time=9330.948..9904.324 rows=4262016 loops=1)
   Sort Key: (count(*)) DESC
   Sort Method: quicksort  Memory: 529579kB
   ->  Finalize HashAggregate  (cost=707957.15..709583.79 rows=162664 width=19) (actual time=7518.696..8493.436 rows=4262016 loops=1)
         Group Key: do_id
         ->  Gather  (cost=672171.07..706330.51 rows=325328 width=19) (actual time=2781.572..5614.352 rows=4815026 loops=1)
               Workers Planned: 2
               Workers Launched: 2
               ->  Partial HashAggregate  (cost=671171.07..672797.71 rows=162664 width=19) (actual time=2761.308..3325.646 rows=1605009 loops=3)
                     Group Key: do_id
                     ->  Parallel Seq Scan on cdc_tm_do_item_1906  (cost=0.00..644443.38 rows=5345538 width=11) (actual time=0.023..1472.686 rows=4275688 loops=3)
 Planning time: 0.427 ms
 Execution time: 10126.092 ms
(13 rows)
  • 解決方法

會話級別修改default_statistics_target變量爲2000,然後執行analyze命令更新B表do_id列的統計信息

itldw=# set  default_statistics_target=2000;
SET
itldw=# analyze tms.cdc_tm_do_item_1906(do_id);
ANALYZE

然後驗證統計信息變化情況,n_distinct值變爲了-0.162037

itldw=# SELECT tablename,attname, n_distinct 
FROM pg_stats
WHERE tablename = 'cdc_tm_do_item_1906' and attname like 'do_id';
      tablename      | attname | n_distinct 
---------------------+---------+------------
 cdc_tm_do_item_1906 | do_id   |  -0.162037
(1 row)

然後對比一下統計值和實際值的差別,統計值爲207萬,和實際值靠近了許多。

itldw=# explain analyze select count(*),do_id from tms.cdc_tm_do_item_1906 group by do_id  order by 1 desc;
                                                                   QUERY PLAN                                                                   
------------------------------------------------------------------------------------------------------------------------------------------------
 Sort  (cost=1022283.12..1027479.28 rows=2078464 width=19) (actual time=12689.806..13178.947 rows=4262016 loops=1)
   Sort Key: (count(*)) DESC
   Sort Method: quicksort  Memory: 529579kB
   ->  HashAggregate  (cost=783393.96..804178.60 rows=2078464 width=19) (actual time=10358.070..11831.147 rows=4262016 loops=1)
         Group Key: do_id
         ->  Seq Scan on cdc_tm_do_item_1906  (cost=0.00..719258.64 rows=12827064 width=11) (actual time=0.059..6093.486 rows=12827064 loops=1)
 Planning time: 0.435 ms
 Execution time: 13508.033 ms
(8 rows)

前面只是對其中一個分區進行了效果和效率驗證,default_statistics_target=2000時,對B表的do_id列更新統計信息大約花費了20s左右。因此校準所有分區統計信息大概需要5~10分鐘。然後便開始對所有分區進行統計信息更新

itldw=# set  default_statistics_target=2000;
itldw=# analyze tms.cdc_tm_do_item(do_id);
ANALYZE

更新完之後,再檢查目標SQL語句的執行計劃:哈希關聯變成了嵌套循環,B表全表掃描變成了索引掃描
最終執行時間從原來的300s左右變成了2s多。

  ->  Sort  (cost=8857890.24..8892214.55 rows=13729723 width=257) (actual time=2355.325..2355.566 rows=4231 loops=1)
        ->  Nested Loop  (cost=362999.33..5540775.72 rows=13729723 width=257) (actual time=1922.736..2348.394 rows=4231 loops=1)
              ->  Finalize GroupAggregate  (cost=362999.20..366933.92 rows=19397 width=353) (actual time=1922.099..1952.705 rows=2505 loops=1)
              ->  Append  (cost=0.14..265.32 rows=140 width=56) (actual time=0.143..0.156 rows=2 loops=2505)
                    ->  Index Scan using cdc_tm_do_item_1801_do_id_idx on cdc_tm_do_item_1801 tdi_3  (cost=0.43..10.58 rows=5 width=54) (actual time=0.006..0.006 rows=0 loops=2505)
                    ...
                    ->  Index Scan using cdc_tm_do_item_1912_do_id_idx on cdc_tm_do_item_1912 tdi_26  (cost=0.14..0.15 rows=1 width=622) (actual time=0.000..0.000 rows=0 loops=2505)

因爲只是修改了會話級別的參數,default_statistics_target只會對當前會話的手工執行analyze命令生效,後續大批量的DML語句觸發的auto acuum後臺任務,仍然會按照default_statistics_target=100的系統默認值來更新統計信息,因此最新的經常DML的分區仍然容易再次出現不準確的情況。但我們可以使用alter table set statistics語句來更細粒度配置特定表列在更新統計信息時使用的統計參數(和default_statistics_target一樣):

alter table tms.cdc_tm_do_item alter column do_id set STATISTICS 2000;

這樣,就不用擔心後面B表do_id列的統計信息不準確了。

總的來說,一旦你發現某表某列統計信息不準確,就先調高會話級別default_statistics_target參數,手工更新一次該列的統計信息,然後使用alter table set statistics語句修改該列的統計配置參數即可。

附件


SQL文本:

SELECT
    tt.do_id AS 交貨單號,
    tt.ref_no AS 外部單據號,
    tt.create_do_time AS 創單時間,
    tt.order_type_name AS 單據類型,
    tt.platform AS 平臺,
    tt.warehouse AS 倉庫號,
    tt.warehouse_desc AS 倉庫名稱,
    tt.cust_name AS 工廠,
    tt.deliver_cubage AS 發貨體積,
    tt.deliver_weight AS 發貨重量,
    tt.sys_cubage AS 系統體積,
    tt.sys_weight AS 系統重量,
    tt.deliver_cubage - tt.sys_cubage AS 體積差異,
    tt.deliver_weight - tt.sys_weight AS 重量差異,
    COUNT (DISTINCT tdi.prod_name) AS 物料種類數,
    SUM (tdi.num) AS 物料數
FROM
    (
        SELECT
            td.do_id,
            td.ref_no,
            td.order_type_name,
            td.cust_name,
            td.vendee_name,
            td.send_name,
            td.carrier_name,
            td.create_do_time,
            w.platform,
            CASE
        WHEN w.warehouse_type = 'B2B' THEN
            w.warehouse_id
        ELSE
            w.deliver_warehouse_no
        END AS warehouse,
        w.warehouse_desc,
        w.warehouse_type,
        td.sum_cubage AS sys_cubage,
        td.sum_weight AS sys_weight,
        SUM (tdtt.cubage) AS deliver_cubage,
        SUM (tdtt.weight) AS deliver_weight,
        CASE
    WHEN td.sum_cubage = 0 THEN
        CASE
    WHEN SUM (tdtt.cubage) <= 0.1 THEN
        '否'
    ELSE
        '是'
    END
    ELSE
        CASE
    WHEN ABS (
        SUM (tdtt.cubage) - td.sum_cubage
    ) / td.sum_cubage > 0.1 THEN
        '是'
    ELSE
        '否'
    END
    END AS cubage_change,
    CASE
WHEN td.sum_weight = 0 THEN
    CASE
WHEN SUM (tdtt.weight) <= 0.1 THEN
    '否'
ELSE
    '是'
END
ELSE
    CASE
WHEN ABS (
    SUM (tdtt.weight) - td.sum_weight
) / td.sum_weight > 0.1 THEN
    '是'
ELSE
    '否'
END
END AS weight_change
FROM
    tms.cdc_tm_do td
JOIN tms.cdc_tm_do_trans_task tdtt ON td.do_id = tdtt.do_id
JOIN xconfig.warehouse w ON COALESCE (
    td.wm_houseid,
    td.deliver_warehouse_no
) = w.warehouse_id
WHERE
    do_flag = 'C'
AND w.warehouse_type IN ('B2B', 'DC')
AND td.create_do_time >= ('2019-07' || '-01') :: TIMESTAMP
AND td.create_do_time < ('2019-07' || '-01') :: TIMESTAMP + '1 month'
AND w.platform IN ('上海', '北京')
AND CASE
WHEN w.warehouse_type = 'B2B' THEN
    w.warehouse_id
ELSE
    w.deliver_warehouse_no
END IN ('B07B', '0001')
GROUP BY
    td.do_id,
    td.ref_no,
    td.order_type_name,
    td.cust_name,
    td.vendee_name,
    td.send_name,
    td.carrier_name,
    td.create_do_time,
    w.platform,
    CASE
WHEN w.warehouse_type = 'B2B' THEN
    w.warehouse_id
ELSE
    w.deliver_warehouse_no
END,
 w.warehouse_desc,
 w.warehouse_type,
 td.sum_cubage,
 td.sum_weight
    ) tt
JOIN tms.cdc_tm_do_item tdi ON tt.do_id = tdi.do_id
WHERE
    (tt.cubage_change = '是'
OR tt.weight_change = '是')
GROUP BY
    tt.do_id,
    tt.ref_no,
    tt.create_do_time,
    tt.order_type_name,
    tt.platform,
    tt.warehouse,
    tt.warehouse_desc,
    tt.cust_name,
    tt.deliver_cubage,
    tt.deliver_weight,
    tt.sys_cubage,
    tt.sys_weight;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章