PostgreSQL 建立分割槽表以及優化
典型使用場景
隨著使用時間的增加,資料庫中的資料量也不斷增加,因此資料庫查詢越來越慢。
加速資料庫的方法很多,如新增特定的索引,將日誌目錄換到單獨的磁碟分割槽,調整資料庫引擎的引數等。這些方法都能將資料庫的查詢效能提高到一定程度。
對於許多應用資料庫來說,許多資料是歷史資料並且隨著時間的推移它們的重要性逐漸降低。如果能找到一個辦法將這些可能不太重要的資料隱藏,資料庫查詢速度將會大幅提高。可以通過DELETE
來達到此目的,但同時這些資料就永遠不可用了。
因此,需要一個高效的把歷史資料從當前查詢中隱藏起來並且不造成資料丟失的方法。本文即將介紹的資料庫表分割槽即能達到此效果。
資料庫表分割槽介紹
資料庫表分割槽把一個大的物理表分成若干個小的物理表,並使得這些小物理表在邏輯上可以被當成一張表來使用。
資料庫表分割槽術語介紹
-
主表
/父表
/Master Table
該表是建立子表的模板。它是一個正常的普通表,但正常情況下它並不儲存任何資料。 -
子表
/分割槽表
/Child Table
/Partition Table
這些表繼承並屬於一個主表。子表中儲存所有的資料。主表與分割槽表屬於一對多的關係,也就是說,一個主表包含多個分割槽表,而一個分割槽表只從屬於一個主表
資料庫表分割槽的優勢
- 在特定場景下,查詢效能極大提高,尤其是當大部分經常訪問的資料記錄在一個或少數幾個分割槽表上時。表分割槽減小了索引的大小,並使得常訪問的分割槽表的索引更容易保存於記憶體中。
- 當查詢或者更新訪問一個或少數幾個分割槽表中的大部分資料時,可以通過順序掃描該分割槽表而非使用大表索引來提高效能。
- 可通過新增或移除分割槽表來高效的批量增刪資料。如可使用
ALTER TABLE NO INHERIT
可將特定分割槽從主邏輯表中移除(該表依然存在,並可單獨使用,只是與主表不再有繼承關係並無法再通過主表訪問該分割槽表),或使用DROP TABLE
直接將該分割槽表刪除。這兩種方式完全避免了使用DELETE
時所需的VACUUM
額外代價。 - 很少使用的資料可被遷移到便宜些的慢些的儲存介質中
以上優勢只有當表非常大的時候才能體現出來。一般來說,當表的大小超過資料庫伺服器的實體記憶體時以上優勢才能體現出來
PostgreSQL表分割槽
現在PostgreSQL支援通過表繼承來實現表的分割槽。父表是普通表並且正常情況下並不儲存任何資料,它的存在只是為了代表整個資料集。PostgreSQL可實現如下兩種表分割槽
- 範圍分割槽 每個分割槽表包含一個或多個欄位組合的一部分,並且每個分割槽表的範圍互不重疊。比如可近日期範圍分割槽
- 列表分割槽 分割槽表顯示列出其所包含的key值
表分割槽在PostgreSQL上的實現
在PostgreSQL中實現表分割槽的步驟
-
建立主表。不用為該表定義任何檢查限制,除非需要將該限制應用到所有的分割槽表中。同樣也無需為該表建立任何索引和唯一限制。
CREATE TABLE almart ( date_key date, hour_key smallint, client_key integer, item_key integer, account integer, expense numeric );
-
建立多個分割槽表。每個分割槽表必須繼承自主表,並且正常情況下都不要為這些分割槽表新增任何新的列。
CREATE TABLE almart_2022_03_10 () inherits (almart); CREATE TABLE almart_2022_03_11 () inherits (almart); CREATE TABLE almart_2022_03_12 () inherits (almart); CREATE TABLE almart_2022_03_13 () inherits (almart);
-
為分割槽表新增限制。這些限制決定了該表所能允許儲存的資料集範圍。這裡必須保證各個分割槽表之間的限制不能有重疊。
ALTER TABLE almart_2022_03_10 ADD CONSTRAINT almart_2022_03_10_check_date_key CHECK (date_Key = '2022-03-10'::date); ALTER TABLE almart_2022_03_11 ADD CONSTRAINT almart_2022_03_10_check_date_key CHECK (date_Key = '2022-03-11'::date); ALTER TABLE almart_2022_03_12 ADD CONSTRAINT almart_2015_03_10_check_date_key CHECK (date_Key = '2022-03-12'::date); ALTER TABLE almart_2022_03_13 ADD CONSTRAINT almart_2022_03_10_check_date_key CHECK (date_Key = '2022-03-13'::date);
-
為每一個分割槽表,在主要的列上建立索引。該索引並不是嚴格必須建立的,但在大部分場景下,它都非常有用。
CREATE INDEX almart_date_key_2022_03_10 ON almart_2022_03_10 (date_key); CREATE INDEX almart_date_key_2022_03_11 ON almart_2022_03_11 (date_key); CREATE INDEX almart_date_key_2022_03_12 ON almart_2022_03_12 (date_key); CREATE INDEX almart_date_key_2022_03_13 ON almart_2022_03_13 (date_key);
-
定義一個trigger或者rule把對主表的資料插入操作重定向到對應的分割槽表。
--建立分割槽函式 CREATE OR REPLACE FUNCTION almart_partition_trigger() RETURNS TRIGGER AS $$ BEGIN IF NEW.date_key = DATE '2022-03-10' THEN INSERT INTO almart_2022_03_10 VALUES (NEW.*); ELSIF NEW.date_key = DATE '2022-03-11' THEN INSERT INTO almart_2022_03_11 VALUES (NEW.*); ELSIF NEW.date_key = DATE '2022-03-12' THEN INSERT INTO almart_2022_03_12 VALUES (NEW.*); ELSIF NEW.date_key = DATE '2022-03-13' THEN INSERT INTO almart_2022_03_13 VALUES (NEW.*); ELSIF NEW.date_key = DATE '2022-03-14' THEN INSERT INTO almart_2022_03_14 VALUES (NEW.*); END IF; RETURN NULL; END; $$ LANGUAGE plpgsql; --掛載分割槽Trigger CREATE TRIGGER insert_almart_partition_trigger BEFORE INSERT ON almart FOR EACH ROW EXECUTE PROCEDURE almart_partition_trigger();
-
確保postgresql.conf中的constraint_exclusion配置項沒有被disable。這一點非常重要,如果該引數項被disable,則基於分割槽表的查詢效能無法得到優化,甚至比不使用分割槽表直接使用索引效能更低。
表分割槽如何加速查詢優化
當constraint_exclusion
為on
或者partition
時,查詢計劃器會根據分割槽表的檢查限制將對主表的查詢限制在符合檢查限制條件的分割槽表上,直接避免了對不符合條件的分割槽表的掃描。
為了驗證分割槽表的優勢,這裡建立一個與上文建立的almart結構一樣的表almart_all,併為其date_key建立索引,向almart和almart_all中插入同樣的9000萬條資料(資料的時間跨度為2022-03-01到2022-03-30)。
CREATE TABLE almart_all ( date_key date, hour_key smallint, client_key integer, item_key integer, account integer, expense numeric );
插入隨機測試資料到almart_all
INSERT INTO almart_all select (select array_agg(i::date) from generate_series( '2022-03-01'::date, '2022-03-30'::date, '1 day'::interval) as t(i) )[floor(random()*4)+1] as date_key, floor(random()*24) as hour_key, floor(random()*1000000)+1 as client_key, floor(random()*100000)+1 as item_key, floor(random()*20)+1 as account, floor(random()*10000)+1 as expense from generate_series(1,300000000,1);
插入同樣的測試資料到almart
INSERT INTO almart SELECT * FROM almart_all;
在almart和slmart_all上執行同樣的query,查詢2015-12-15日不同client_key的平均消費額。
\timing explain analyze select avg(expense) from (select client_key, sum(expense) as expense from almart where date_key = date '2022-03-15' group by 1 ); QUERY PLAN ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- Aggregate (cost=19449.05..19449.06 rows=1 width=32) (actual time=9474.203..9474.203 rows=1 loops=1) -> HashAggregate (cost=19196.10..19308.52 rows=11242 width=36) (actual time=8632.592..9114.973 rows=949825 loops=1) -> Append (cost=0.00..19139.89 rows=11242 width=36) (actual time=4594.262..6091.630 rows=2997704 loops=1) -> Seq Scan on almart (cost=0.00..0.00 rows=1 width=9) (actual time=0.002..0.002 rows=0 loops=1) Filter: (date_key = '2022-03-15'::date) -> Bitmap Heap Scan on almart_2022_03_15 (cost=299.55..19139.89 rows=11241 width=36) (actual time=4594.258..5842.708 rows=2997704 loops=1) Recheck Cond: (date_key = '2022-03-15'::date) -> Bitmap Index Scan on almart_date_key_2022_03_15 (cost=0.00..296.74 rows=11241 width=0) (actual time=4587.582..4587.582 rows=2997704 loops=1) Index Cond: (date_key = '2022-03-15'::date) Total runtime: 9506.507 ms (10 rows) Time: 9692.352 ms explain analyze select avg(expense) from (select client_key, sum(expense) as expense from almart_all where date_key = date '2022-03-15' group by 1 ) foo; QUERY PLAN ------------------------------------------------------------------------------------------------------------------------------------------------------------------ Aggregate (cost=770294.11..770294.12 rows=1 width=32) (actual time=62959.917..62959.917 rows=1 loops=1) -> HashAggregate (cost=769549.54..769880.46 rows=33092 width=9) (actual time=61694.564..62574.385 rows=949825 loops=1) -> Bitmap Heap Scan on almart_all (cost=55704.56..754669.55 rows=2975999 width=9) (actual time=919.941..56291.128 rows=2997704 loops=1) Recheck Cond: (date_key = '2022-03-15'::date) -> Bitmap Index Scan on almart_all_date_key_index (cost=0.00..54960.56 rows=2975999 width=0) (actual time=677.741..677.741 rows=2997704 loops=1) Index Cond: (date_key = '2022-03-15'::date) Total runtime: 62960.228 ms (7 rows) Time: 62970.269 ms
由上可見,使用分割槽表時,所需時間為9.5秒,而不使用分割槽表時,耗時63秒。
使用分割槽表,PostgreSQL跳過了除2015-12-15日分割槽表以外的分割槽表,只掃描2015-12-15的分割槽表。而不使用分割槽表只使用索引時,資料庫要使用索引掃描整個資料庫。另一方面,使用分割槽表時,每個表的索引是獨立的,即每個分割槽表的索引都只針對一個小的分割槽表。而不使用分割槽表時,索引是建立在整個大表上的。資料量越大,索引的速度相對越慢。
管理分割槽
從上文分割槽表的建立過程可以看出,分割槽表必須在相關資料插入之前建立好。在生產環境中,很難保證所需的分割槽表都已經被提前建立好。同時為了不讓分割槽表過多,影響資料庫效能,不能建立過多無用的分割槽表。
週期性建立分割槽表
在生產環境中,經常需要週期性刪除和建立一些分割槽表。一個經典的做法是使用定時任務。比如使用cronjob每天執行一次,將1年前的分割槽表刪除,並建立第二天分割槽表(該表按天分割槽)。有時為了容錯,會將之後一週的分割槽表全部創建出來。
動態建立分割槽表
上述週期性建立分割槽表的方法在絕大部分情況下有效,但也只能在一定程度上容錯。另外,上文所使用的分割槽函式,使用IF
語句對date_key進行判斷,需要為每一個分割槽表準備一個IF
語句。
如插入date_key
分別為2015-12-10
到2015-12-14
的5條記錄,前面4條均可插入成功,因為相應的分割槽表已經存在,但最後一條資料因為相應的分割槽表不存在而插入失敗。
INSERT INTO almart(date_key) VALUES ('2022-03-10'); INSERT 0 0 INSERT INTO almart(date_key) VALUES ('2022-03-11'); INSERT 0 0 INSERT INTO almart(date_key) VALUES ('2022-03-12'); INSERT 0 0 INSERT INTO almart(date_key) VALUES ('2022-03-13'); INSERT 0 0 INSERT INTO almart(date_key) VALUES ('2022-03-14'); ERROR: relation "almart_2022_03_14" does not exist LINE 1: INSERT INTO almart_2022_03_14 VALUES (NEW.*) ^ QUERY: INSERT INTO almart_2022_03_14 VALUES (NEW.*) CONTEXT: PL/pgSQL function almart_partition_trigger() line 17 at SQL statement SELECT * FROM almart; date_key | hour_key | client_key | item_key | account | expense ------------+----------+------------+----------+---------+--------- 2022-03-10 | | | | | 2022-03-11 | | | | | 2022-03-12 | | | | | 2022-03-13 | | | | | (4 rows)
針對該問題,可使用動態SQL的方式進行資料路由,並通過獲取將資料插入不存在的分割槽表產生的異常訊息並動態建立分割槽表的方式保證分割槽表的可用性。
CREATE OR REPLACE FUNCTION almart_partition_trigger() RETURNS TRIGGER AS $$ DECLARE date_text TEXT; DECLARE insert_statement TEXT; BEGIN SELECT to_char(NEW.date_key, 'YYYY_MM_DD') INTO date_text; insert_statement := 'INSERT INTO almart_' || date_text ||' VALUES ($1.*)'; EXECUTE insert_statement USING NEW; RETURN NULL; EXCEPTION WHEN UNDEFINED_TABLE THEN EXECUTE 'CREATE TABLE IF NOT EXISTS almart_' || date_text || '(CHECK (date_key = ''' || date_text || ''')) INHERITS (almart)'; RAISE NOTICE 'CREATE NON-EXISTANT TABLE almart_%', date_text; EXECUTE 'CREATE INDEX almart_date_key_' || date_text || ' ON almart_' || date_text || '(date_key)'; EXECUTE insert_statement USING NEW; RETURN NULL; END; $$ LANGUAGE plpgsql;
使用該方法後,再次插入date_key
為2015-12-14
的記錄時,對應的分割槽表不存在,但會被自動建立。
INSERT INTO almart VALUES('2022-03-13'),('2022-03-14'),('2022-03-15'); NOTICE: CREATE NON-EXISTANT TABLE almart_2015_12_14 NOTICE: CREATE NON-EXISTANT TABLE almart_2015_12_15 INSERT 0 0 SELECT * FROM almart; date_key | hour_key | client_key | item_key | account | expense ------------+----------+------------+----------+---------+--------- 2022-03-10 | | | | | 2022-03-11 | | | | | 2022-03-12 | | | | | 2022-03-13 | | | | | 2022-03-13 | | | | | 2022-03-14 | | | | | 2022-03-15 | | | | | (7 rows)
移除分割槽表
雖然如上文所述,分割槽表的使用可以跳過掃描不必要的分割槽表從而提高查詢速度。但由於伺服器磁碟的限制,不可能無限制儲存所有資料,經常需要週期性刪除過期資料,如刪除5年前的資料。如果使用傳統的DELETE
,刪除速度慢,並且由於DELETE
只是將相應資料標記為刪除狀態,不會將資料從磁碟刪除,需要使用VACUUM
釋放磁碟,從而引入額外負載。
而在使用分割槽表的條件下,可以通過直接DROP
過期分割槽表的方式快速方便地移除過期資料。如
DROP TABLE almart_2021_12_15;
另外,無論使用DELETE
還是DROP
,都會將資料完全刪除,即使有需要也無法再次使用。因此還有另外一種方式,即更改過期的分割槽表,解除其與主表的繼承關係,如。
ALTER TABLE almart_2022_03_15 NO INHERIT almart;
但該方法並未釋放磁碟。此時可通過更改該分割槽表,使其屬於其它TABLESPACE,同時將該TABLESPACE的目錄設定為其它磁碟分割槽上的目錄,從而釋放主表所在的磁碟。同時,如果之後還需要再次使用該“過期”資料,只需更改該分割槽表,使其再次與主表形成繼承關係。
CREATE TABLESPACE cheap_table_space LOCATION '/data/cheap_disk'; ALTER TABLE almart_2021_12_15 SET TABLESPACE cheap_table_space;
PostgreSQL表分割槽的其它方式
除了使用Trigger外,可以使用Rule將對主表的插入請求重定向到對應的子表。如
CREATE RULE almart_rule_2022_03_31 AS ON INSERT TO almart WHERE date_key = DATE '2022-03-31' DO INSTEAD INSERT INTO almart_2022_03_31 VALUES (NEW.*);
與Trigger相比,Rule會帶來更大的額外開銷,但每個請求只造成一次開銷而非每條資料都引入一次開銷,所以該方法對大批量的資料插入操作更具優勢。然而,實際上在絕大部分場景下,Trigger比Rule的效率更高。
同時,COPY
操作會忽略Rule,而可以正常觸發Trigger。
另外,如果使用Rule方式,沒有比較簡單的方法處理沒有被Rule覆蓋到的插入操作。此時該資料會被插入到主表中而不會報錯,從而無法有效利用表分割槽的優勢。
除了使用表繼承外,還可使用UNION ALL
的方式達到表分割槽的效果。
CREATE VIEW almart AS SELECT * FROM almart_2022_03_10 UNION ALL SELECT * FROM almart_2022_03_11 UNION ALL SELECT * FROM almart_2022_03_12 ... UNION ALL SELECT * FROM almart_2022_03_30;
當有新的分割槽表時,需要更新該View。實踐中,與使用表繼承相比,一般不推薦使用該方法。
總結
- 如果要充分使用分割槽表的查詢優勢,必須使用分割槽時的欄位作為過濾條件
- 分割槽欄位被用作過濾條件時,
WHERE
語句只能包含常量而不能使用引數化的表示式,因為這些表示式只有在執行時才能確定其值,而planner在真正執行query之前無法判定哪些分割槽表應該被使用 - 跳過不符合條件分割槽表是通過planner根據分割槽表的檢查限制條件實現的,而非通過索引
- 必須將
constraint_exclusion
設定為ON
或Partition
,否則planner將無法正常跳過不符合條件的分割槽表,也即無法發揮表分割槽的優勢 - 除了在查詢上的優勢,分割槽表的使用,也可提高刪除舊資料的效能
- 為了充分利用分割槽表的優勢,應該保證各分割槽表的檢查限制條件互斥,但目前並無自動化的方式來保證這一點。因此使用程式碼造化建立或者修改分割槽表比手工操作更安全
- 在更新資料集時,如果使得partition key column(s)變化到需要使某些資料移動到其它分割槽,則該更新操作會因為檢查限制的存在而失敗。如果一定要處理這種情景,可以使用更新Trigger,但這會使得結構變得複雜。
- 大量的分割槽表會極大地增加查詢計劃時間。表分割槽在多達幾百個分割槽表時能很好地發揮優勢,但不要使用多達幾千個分割槽表。