PostgreSQL BRIN 索引使用的那些坑
作者:@monouno,現實習於長亭科技
BRIN 索引(塊範圍索引,Block Range Indexes)是 PostgreSQL 9.5 版本新增的索引型別。該索引維護每一定範圍內資料塊的最大最小值和其他一些統計資料,當資料庫查詢時可根據索引的統計資訊篩選出不符合查詢條件的資料塊,以避免全表掃描,提高效能和減少 IO。和 BTree 索引比較所佔用的空間足夠小[1],因此 BRIN 索引一般用於線性相關較強欄位的精確和範圍查詢,如在一張很大的日誌表中通過 id 或時間查詢。
建立測試資料
建立資料表,只含有一個 id 欄位
CREATE TABLE example AS SELECT generate_series(1, 100000000) AS id;
資料表大小為 3.4G
\dt+ example List of relations Schema |Name| Type|Owner|Size| Description --------+---------+-------+----------+---------+------------- public | example | table | safeline | 3457 MB | (1 row)
建立索引
CREATE INDEX idx ON example USING brin(id) WITH (pages_per_range=1024, autosummarize=on);
索引大小為 56K
\dti+ idx List of relations Schema | Name | Type|Owner|Table| Size| Description --------+------+-------+----------+---------+-------+------------- public | idx| index | safeline | example | 56 kB | (1 row)
explain 一下 BRIN 索引使用情況
EXPLAIN ANALYZE SELECT * FROM example WHERE id = 492167; QUERY PLAN -------------------------------------------------------------------------------------------------------------------------------- Gather(cost=1016.26..807981.92 rows=1 width=4) (actual time=12.700..86.880 rows=1 loops=1) Workers Planned: 2 Workers Launched: 2 ->Parallel Bitmap Heap Scan on example(cost=16.26..806981.82 rows=1 width=4) (actual time=56.477..80.759 rows=0 loops=3) Recheck Cond: (id = 492167) Rows Removed by Index Recheck: 77141 Heap Blocks: lossy=496 ->Bitmap Index Scan on idx(cost=0.00..16.26 rows=230946 width=0) (actual time=0.377..0.377 rows=10240 loops=1) Index Cond: (id = 492167) Planning Time: 0.318 ms Execution Time: 86.950 ms (11 rows)
索引很小,嘗試使用 B-Tree 索引,體積會是 2.1G
,大約是資料本身的三分之二大小了。
CREATE INDEX idx_btree ON example (id); \dti+ idx_btree List of relations Schema |Name| Type|Owner|Table|Size| Description --------+-----------+-------+----------+---------+---------+------------- public | idx_btree | index | safeline | example | 2142 MB | (1 row)
BRIN 索引結構
BRIN 索引頁的儲存順序依次是 meta page
、 revmap pages
和 regular pages
。我們通過pageinspect 擴充套件可以很方便地分析 BRIN 索引的各個頁。
meta page
第一頁 meta page
儲存 BRIN 索引的元資料
SELECT * FROM brin_metapage_info(get_raw_page('idx', 0)); magic| version | pagesperrange | lastrevmappage ------------+---------+---------------+---------------- 0xA8109CFA |1 |1024 |1 (1 row)
其中 lastrevmapage
表示 revmap pages
最後一頁的下標,即從 meta page
的下一頁到 lastrevmapage
都是 revmap pages
。
revmap pages
接下來的 revmap 相當於一個目錄,儲存資料塊到索引記錄的對映關係,而且每一頁 revmap 的記錄數是固定的。
SELECT * FROM brin_revmap_data(get_raw_page('idx', 1)) LIMIT 5; pages ------- (2,1) (2,2) (2,3) (2,4) (2,5) (5 rows)
ofollow,noindex">下面的巨集 可以計算出一個數據塊在 revmap 中的位置,然後可以在 revmap 中查詢到索引的位置。
#define HEAPBLK_TO_REVMAP_BLK(pagesPerRange, heapBlk) \ ((heapBlk / pagesPerRange) / REVMAP_PAGE_MAXITEMS) #define HEAPBLK_TO_REVMAP_INDEX(pagesPerRange, heapBlk) \ ((heapBlk / pagesPerRange) % REVMAP_PAGE_MAXITEMS)
所以在掃描和更新索引時(比如 brininsert 等函式),可以簡單的計算出一個數據塊屬於哪一條索引記錄[2]。
如果對應塊索引還未被建立,那麼該項就是 (0, 0)
。隨著表資料行和索引記錄的不斷增加,索引的 revmap pages
也會向後擴充套件,為了給這騰出位置,PostgreSQL 會從前面開始將 regular pages
中的索引條目移到末尾,並更新和拓展 revmap
。
regular page
可以通過 brin_page_items
檢視索引記錄
SELECT * FROM brin_page_items(get_raw_page('idx', 2), 'idx') LIMIT 5; itemoffset | blknum | attnum | allnulls | hasnulls | placeholder |value ------------+--------+--------+----------+----------+-------------+--------------------- 1 |0 |1 | f| f| f| {1 .. 231424} 2 |1024 |1 | f| f| f| {231425 .. 462848} 3 |2048 |1 | f| f| f| {462849 .. 694272} 4 |3072 |1 | f| f| f| {694273 .. 925696} 5 |4096 |1 | f| f| f| {925697 .. 1157120} (5 rows)
其中 blknum
、 attnum
、 allnulls
、 hasnulls
、 value
分別表示起始塊數、欄位下標、是否全為空值、是否存在空值和塊範圍內欄位的最大最小值。這其中最重要的就是 value 這個欄位了。PostgreSQL 一般就是根據這個 value 值來判斷是否需要掃描這些資料塊。以第三個條目為例,它的 blknum
為 2048,說明是 2048 - 3072 資料塊儲存的資料範圍是 462849 .. 694272
。如果我們查詢的 SQL 是 WHERE id = 492167
,那在這些資料塊中再搜尋就足夠了。
BRIN 索引的 pages_per_range
可指定單條索引記錄所統計的資料塊範圍,預設為 128。值越小統計的粒度就越小,索引的過濾性越好,但索引也會越大。由於每篩選一次欄位 PostgreSQL 都要掃描全部的 BRIN 索引,所花費的時間也會變長,因此需要根據表的大小與應用場景去調整其值的大小。
當一些在索引條目邊界的行被刪除時,會使原有的索引條目失效,失效的索引條目需要重新統計。也可以通過 brin_desummarize_range
手動將一些索引條目失效。
我們遇到的問題
我們有一張日誌表需要不斷插入大量請求日誌,在使用者瀏覽日誌列表或是檢視日誌詳情時需要進行等值或範圍查詢,起初在對 BRIN 索引進行測試時,先對日誌表插入大量資料再建立索引進行查詢,或是將之前歸檔的日誌資料恢復再進行查詢均有著不錯的效能表現,但再進一步使用真實場景測試一段時間後發現日誌查詢變得非常慢,和之前的結果相差甚遠。
只要資料插入足夠快,索引就跟不上我
PostgreSQL 在插入或更新行時會更新已存在的索引條目,對應的索引條目不存在時則跳過。而在 vacuum
或顯式呼叫 brin_summarize_new_values
時才會為表中未統計的資料塊新增索引條目。從 PostgreSQL 10 開始新增 autosummarize
引數,開啟 autosummarize
後,當表不斷被插入新的行導致新增的資料塊大於 pages_per_range
時,將會自動統計這些新增的資料塊併為此插入新的索引條目。
autosummarize
並不會立即開始且都會成功,它嘗試在 AutoAacuumWork
的請求佇列中追加一項 AVW_BRINSummarizeRange
的任務,而這個任務便是呼叫 summarize_range
函式[3]。
if (!lastPageTuple) { boolrecorded; recorded = AutoVacuumRequestWork(AVW_BRINSummarizeRange, RelationGetRelid(idxRel), lastPageRange); if (!recorded) ereport(LOG, (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), errmsg("request for BRIN range summarization for index \"%s\" page %u was not recorded", RelationGetRelationName(idxRel), lastPageRange))); } else LockBuffer(buf, BUFFER_LOCK_UNLOCK);
請求佇列的長度 NUM_WORKITEMS
是固定的,預設為 256。在 autovacuum_work
執行 do_autovacuum
時處理這些任務[4]。
/* * Perform additional work items, as requested by backends. */ LWLockAcquire(AutovacuumLock, LW_EXCLUSIVE); for (i = 0; i < NUM_WORKITEMS; i++) { AutoVacuumWorkItem *workitem = &AutoVacuumShmem->av_workItems[i]; if (!workitem->avw_used) continue; if (workitem->avw_active) continue; if (workitem->avw_database != MyDatabaseId) continue; /* claim this one, and release lock while performing it */ workitem->avw_active = true; LWLockRelease(AutovacuumLock); perform_work_item(workitem); /* * Check for config changes before acquiring lock for further jobs. */ CHECK_FOR_INTERRUPTS(); if (got_SIGHUP) { got_SIGHUP = false; ProcessConfigFile(PGC_SIGHUP); } LWLockAcquire(AutovacuumLock, LW_EXCLUSIVE); /* and mark it done */ workitem->avw_active = false; workitem->avw_used = false; } LWLockRelease(AutovacuumLock);
當前 AutoVacuumWorkItemType
只有 AVW_BRINSummarizeRange
這一種,在 PostgreSQL 未來的版本很可能會繼續使用這一框架,新增更多來自 backend 的任務型別。
當請求佇列已滿且 autovacuum_work
來不及處理時 autosummarize
就會失敗。只要資料插入足夠快,索引就跟不上我,所以即便是開啟了 autosummarize
,在大量資料被不斷插入表中的情況下,請求佇列會被迅速佔滿,導致 autosummarize
失敗,出現大量錯誤日誌:
XXXX-XX-XX 09:39:55.832 UTC [67] LOG:request for BRIN range summarization for index "idx" page 58311 was not recorded
BRIN 索引需要定期被更新,否則就可能存在大量還未索引的記錄,還有資料更新也導致一些索引條目失效或統計出現偏差。在 BRIN 索引不完整時過濾效能變差,無論查詢的記錄是否在已存在的索引條目中,在 Heap bitmap index scan 之後仍需要重新 Recheck 未統計的資料塊,速度可能會變得非常緩慢,從原來的十幾毫秒延長到幾秒是有可能的,進而影響相關的業務系統。下面是一個比較極端的情況下的查詢。
EXPLAIN (analyze,buffers) SELECT * FROM example WHERE id > 100 AND id <= 2000; QUERY PLAN ------------------------------------------------------------------------------------------------------------------------- Bitmap Heap Scan on example(cost=12.03..50726.88 rows=1 width=37) (actual time=19.317..6047.938 rows=1900 loops=1) Recheck Cond: ((id > 100) AND (id <= 2000)) Rows Removed by Index Recheck: 39598741 Heap Blocks: lossy=330006 Buffers: shared hit=1 read=330007 ->Bitmap Index Scan on idx(cost=0.00..12.03 rows=15355 width=0) (actual time=19.085..19.085 rows=3301120 loops=1) Index Cond: ((id > 100) AND (id <= 2000)) Buffers: shared hit=1 read=1 Planning Time: 0.782 ms Execution Time: 6048.140 ms (10 rows)
對比使用 Parallel Seq Scan 的查詢:
EXPLAIN (analyze,buffers) SELECT * FROM example WHERE id > 100 AND id <= 2000; QUERY PLAN ------------------------------------------------------------------------------------------------------------------------------ Gather(cost=1000.00..584334.60 rows=1 width=37) (actual time=1.751..1645.756 rows=1900 loops=1) Workers Planned: 2 Workers Launched: 2 Buffers: shared hit=16219 read=317115 ->Parallel Seq Scan on example(cost=0.00..583334.50 rows=1 width=37) (actual time=1089.990..1635.938 rows=633 loops=3) Filter: ((id > 100) AND (id <= 2000)) Rows Removed by Filter: 13332700 Buffers: shared hit=16219 read=317115 Planning Time: 0.659 ms Execution Time: 1646.008 ms (10 rows)
autovacuum 為什麼也沒用
上面一節提到了問題可能是 AutoAacuumWork
佇列已滿,但是日常執行的 autovacuum
也應該可以實現相同的效果,為什麼也沒用呢。為了方便測試,我們可單獨將表執行 autovacuum
的相關閾值調低,其他保持則預設值:
ALTER TABLE example SET (autovacuum_vacuum_scale_factor = 0.0); ALTER TABLE example SET (autovacuum_vacuum_threshold = 100);
然後根據我們的業務場景,不斷在表中插入大量資料,然後觀察 pg_stat_user_tables
中記錄:
SELECT * FROM pg_stat_user_tables where relname = 'example'; -[ RECORD 1 ]-------+------------------------------ relid| 32824 schemaname| public relname| example seq_scan| 81 seq_tup_read| 202398405 idx_scan| 5 idx_tup_fetch| 198003205 n_tup_ins| 110000010 n_tup_upd| 0 n_tup_del| 0 n_tup_hot_upd| 0 n_live_tup| 110000000 n_dead_tup| 0 n_mod_since_analyze | 0 last_vacuum| last_autovacuum| last_analyze| last_autoanalyze| xxxx-xx-xx 08:31:25.114953+00 vacuum_count| 0 autovacuum_count| 0 analyze_count| 0 autoanalyze_count| 3
發現 last_autovacuum
一直為空,而 autoanalyze
能夠預期地按照一定頻率執行。原來在 do_autovacuum
函式執行時,大致可分為 dovacuum
、 doanalyze
和 doworkitems
等過程,而其中的 relation_needs_vacanalyze
函式將判斷關係表是否需要做 vacuum
或 analyze
。在僅插入的場景下,表的 n_dead_tup
很小(本例中沒有行被更新或刪除, n_dead_tup
為 0),如果只調整 autovacuum
的執行頻率等配置, dovacuum
也可能不會被觸發。
A table needs to be vacuumed if the number of dead tuples exceeds a threshold. This threshold is calculated as
當然,前面說明了 autosummarize
需要依賴 do_autovacuum
中的 doworkitems
來進行處理,如果 autovacuum
沒有執行,則 autosummarize
也是無效的。
Reference
[1]: PostgreSQL中BRIN和BTREE索引的比較
[2]: GitHub - brin_revmap.c
[3]: GitHub - brin.c