1. 程式人生 > 實用技巧 >PostgreSQL中的索引(十)--Bloom

PostgreSQL中的索引(十)--Bloom

在之前的文章中,我們討論了PostgreSQL索引引擎和訪問方法的介面,以及雜湊索引、b-tree、GiST、SP-GiST、GIN、RUM和BRIN。現在我們來看看Bloom索引。

典型的布隆過濾器是一種資料結構,使我們能夠快速檢查集合中元素的成員關係。過濾器是非常緊湊,但允許存在錯誤:it can mistakenly consider an element to be a member of a set (**false positive**), but it is not permitted to consider an element of a set not to be a member (**false negative**

)。

過濾器是一個bits陣列(也稱為簽名),最初用0填充。選擇不同的雜湊函式,將集合的任何元素對映到簽名的bits數中。要向集合中新增一個元素,我們需要將簽名中的每個位設定為1。因此,如果一個元素對應的所有位都被設為1,那麼這個元素就可以是這個集合的成員,但是如果至少有一個位等於零,那麼這個元素就肯定不在這個集合中。

在DBMS中,我們實際上為每個索引行建立了單獨的過濾器。通常,索引中包含幾個欄位,這些欄位的值構成每行的元素集。

通過選擇簽名的長度,我們可以在索引大小和false positive(這裡名詞解釋見上文)概率之間權衡。
Bloom索引的應用範圍很大,如需要對每個欄位使用過濾器來查詢相當寬的表。與BRIN一樣,這種訪問方法可以看作是順序掃描的加速器:索引找到的所有匹配項都必須在表中重新檢查,但是可以避免重新檢查絕大多數行。

結構

我們已經在GiST訪問方法的上下文中討論了簽名樹。與這些樹不同的是,布隆索引是一種扁平結構。它由一個元資料頁,後面跟著帶有索引行的常規頁面組成。每個索引行包含一個簽名和對錶行(TID)的引用,如圖所示

建立和引數

建立Bloom索引時,會指定簽名的總大小(«length»),以及為索引中包含的每個欄位(«col1»-«col32»)設定的位數(bits):

create index on ... using bloom(...) with (length=..., col1=..., col2=..., ...);

指定bit的數量的方法看起來很奇怪:這些數字必須是運算子類的引數,而不是索引。問題是操作符類目前還不能引數化,儘管這方面的工作正在進行中。不幸的是,在這方面沒有進一步的進展。

我們如何選擇合適的值呢?理論表明,對於假設過濾器返回false positive的可能性為p,最優的簽名長度是m=−nlog2⁡p/ln⁡2,這裡n是索引中列的個數, 待設定的位數是k=−log2⁡p.

簽名儲存在索引中,以一個兩個bytes的整型陣列的形式,因此m的值可以被安全地設定為16。

在選擇概率p時,我們需要考慮索引的大小,它將近似等於(m/8+6)N,其中N是表中的行數,6是TID指標的位元組大小。

· false positive的概率p與一個過濾器有關,因此,我們希望在表掃描期間獲得Np的 false positive(當然是對返回很少行的查詢)。例如,對於一個具有一百萬行且概率為0.01的表,平均而言,在查詢計劃中,我們可以期望«Rows Removed by Index Recheck: 10000»。
·布隆過濾器是一種概率結構。僅當平均許多值時才講特定數字是有意義的,而在每種特定情況下,我們都能得到我們能想到的任何東西。
·以上估計是基於理想化的數學模型和一些假設。實際上,結果可能會更糟。因此,不要高估公式:它們只是為將來的實驗選擇初始值的一種方法。
·對於每個欄位,訪問方法使我們能夠選擇要設定的位數。有一個合理的假設,即最佳數量實際上取決於列中值的分佈。

更新

當在表中插入新行時,將建立一個簽名:對於所有索引欄位的值,其所有對應位都設定為1。 從理論上講,我們必須具有k個不同的雜湊函式,而實際上,偽隨機數生成器就足夠了,每次使用唯一的雜湊函式都可以選擇其種子。

常規的Bloom過濾器不支援元素刪除,但是Bloom索引不需要這樣做:刪除錶行時,整個簽名以及索引行都將被刪除。

通常,更新包括刪除過時的行版本和插入新的行版本(簽名是從頭開始計算的)。

掃描

由於Bloom過濾器只能執行的操作是檢查集合中元素的成員資格,因此Bloom索引支援的唯一操作是相等檢查(和在雜湊索引類似)。

正如我們已經提到的那樣,Bloom索引是平面的,因此在索引訪問過程中,始終會連續且完整地讀取它。 在閱讀過程中,將生成一個位圖,然後將其用於訪問表。

在常規索引訪問中,假定只需要讀取很少的索引行,此外,很快又再次需要它們,因此,將它們儲存在buffer cache中。 但是,讀取Bloom索引實際上是順序掃描。 為了防止將有用的資訊從buffer cache中逐出,請通過一個小的buffer ring進行讀取,這與順序掃描表的方式完全相同。

我們應該考慮到Bloom索引的大小越大,對計劃器的吸引力就越小。 這種依賴性是線性的,與樹狀索引不同。

示例

讓我們通過上一篇文章中的一個大的«flights_bi»表的例子來看看Bloom索引。提醒一下,這個表的大小是4 GB,大約有3000萬行。表的定義:

demo=# \d flights_bi
                          Table "bookings.flights_bi"
       Column       |           Type           | Collation | Nullable | Default 
--------------------+--------------------------+-----------+----------+---------
 airport_code       | character(3)             |           |          | 
 airport_coord      | point                    |           |          | 
 airport_utc_offset | interval                 |           |          | 
 flight_no          | character(6)             |           |          | 
 flight_type        | text                     |           |          | 
 scheduled_time     | timestamp with time zone |           |          | 
 actual_time        | timestamp with time zone |           |          | 
 aircraft_code      | character(3)             |           |          | 
 seat_no            | character varying(4)     |           |          | 
 fare_conditions    | character varying(10)    |           |          | 
 passenger_id       | character varying(20)    |           |          | 
 passenger_name     | text                     |           |          | 

讓我們先建立擴充套件:儘管Bloom索引包含在9.6版本開始的標準交付中,但預設情況下不可用。

demo=# create extension bloom;

上一次,我們可以使用BRIN(«scheduled_time»,«actual_time»,«airport_utc_offset»)對三個欄位建立索引。由於Bloom索引不依賴於資料的物理順序,因此讓我們嘗試在索引中包括表的幾乎所有欄位。但是,讓我們排除時間欄位(«scheduled_time»和«actual_time»):該方法僅支援比較相等性,但是對於任何人來說,查詢精確時間都不是一件感興趣的事(但是,我們可以在表示式上構建索引,將四捨五入時間到一天,但我們不會這樣做)。我們還必須排除機場的地理座標(«airport_coord»):還不支援«point»型別。

為了選擇引數值,讓我們將false positive的概率設定為0.01(請注意實際中我們會得到更多)。上面的n = 9和N = 30000000的公式給出了96bit的簽名大小,建議每個元素設定7位。索引的估計大小為515 MB(大約表的八分之一)。

(在最小簽名大小為16位的情況下,公式保證索引大小比原來小兩倍,但僅允許依賴於0.5的概率,這是非常差的。)

操作符類

建立索引

demo=# create index flights_bi_bloom on flights_bi
using bloom(airport_code, airport_utc_offset, flight_no, flight_type, aircraft_code, seat_no, fare_conditions, passenger_id, passenger_name)
with (length=96, col1=7, col2=7, col3=7, col4=7, col5=7, col6=7, col7=7, col8=7, col9=7);
ERROR:  data type character has no default operator class for access method "bloom"
HINT:  You must specify an operator class for the index or define a default operator class for the data type.

不幸的是,擴充套件只提供了兩個操作符類:

demo=# select opcname, opcintype::regtype
from pg_opclass
where opcmethod = (select oid from pg_am where amname = 'bloom')
order by opcintype::regtype::text;
 opcname  | opcintype
----------+-----------
 int4_ops | integer
 text_ops | text
(2 rows)

但幸運的是,為其他資料型別建立類似的類也很容易。Bloom訪問方法的操作符類必須恰好包含一個操作符—相等—和一個輔助函式—雜湊。找到任意型別所需的操作符和函式的最簡單方法是在系統目錄中查詢«hash»方法的操作符類:

demo=# select distinct
       opc.opcintype::regtype::text,
       amop.amopopr::regoperator,
       ampr.amproc
  from pg_am am, pg_opclass opc, pg_amop amop, pg_amproc ampr
 where am.amname = 'hash'
   and opc.opcmethod = am.oid
   and amop.amopfamily = opc.opcfamily
   and amop.amoplefttype = opc.opcintype
   and amop.amoprighttype = opc.opcintype
   and ampr.amprocfamily = opc.opcfamily
   and ampr.amproclefttype = opc.opcintype
order by opc.opcintype::regtype::text;
 opcintype |       amopopr        |    amproc    
-----------+----------------------+--------------
 abstime   | =(abstime,abstime)   | hashint4
 aclitem   | =(aclitem,aclitem)   | hash_aclitem
 anyarray  | =(anyarray,anyarray) | hash_array
 anyenum   | =(anyenum,anyenum)   | hashenum
 anyrange  | =(anyrange,anyrange) | hash_range
 ...

我們將使用以下資訊建立兩個缺失的類:

demo=# CREATE OPERATOR CLASS character_ops
DEFAULT FOR TYPE character USING bloom AS
  OPERATOR  1  =(character,character),
  FUNCTION  1  hashbpchar;

demo=# CREATE OPERATOR CLASS interval_ops
DEFAULT FOR TYPE interval USING bloom AS
  OPERATOR  1  =(interval,interval),
  FUNCTION  1  interval_hash;

沒有為點(«point»型別)定義雜湊函式,正因為如此,我們不能在這樣的欄位上構建Bloom索引(就像我們不能在這種型別的欄位上執行雜湊連線一樣)。

再次嘗試:

demo=# create index flights_bi_bloom on flights_bi
using bloom(airport_code, airport_utc_offset, flight_no, flight_type, aircraft_code, seat_no, fare_conditions, passenger_id, passenger_name)
with (length=96, col1=7, col2=7, col3=7, col4=7, col5=7, col6=7, col7=7, col8=7, col9=7);
CREATE INDEX

索引的大小為526 MB,比預期的要大一些。這是因為該公式沒有考慮頁面開銷。

demo=# select pg_size_pretty(pg_total_relation_size('flights_bi_bloom'));
 pg_size_pretty
----------------
 526 MB
(1 row)

查詢

我們現在可以使用各種標準執行搜尋,索引會支援的。

如前所述,Bloom filter是一種概率結構,因此其效率高度依賴於每一種特定情況。例如,讓我們看一下與兩位乘客Miroslav Sidorov有關的行:

demo=# explain(costs off,analyze)
select * from flights_bi where passenger_name='MIROSLAV SIDOROV';
                                            QUERY PLAN
--------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on flights_bi (actual time=2639.010..3010.692 rows=2 loops=1)
   Recheck Cond: (passenger_name = 'MIROSLAV SIDOROV'::text)
   Rows Removed by Index Recheck: 38562
   Heap Blocks: exact=21726
   ->  Bitmap Index Scan on flights_bi_bloom (actual time=1065.191..1065.191 rows=38564 loops=1)
         Index Cond: (passenger_name = 'MIROSLAV SIDOROV'::text)
 Planning time: 0.109 ms
 Execution time: 3010.736 ms

對於Marfa Soloveva:

demo=# explain(costs off,analyze)
select * from flights_bi where passenger_name='MARFA SOLOVEVA';
                                            QUERY PLAN
---------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on flights_bi (actual time=9980.884..10142.684 rows=2 loops=1)
   Recheck Cond: (passenger_name = 'MARFA SOLOVEVA'::text)
   Rows Removed by Index Recheck: 3950168
   Heap Blocks: exact=45757 lossy=67332
   ->  Bitmap Index Scan on flights_bi_bloom (actual time=1037.588..1037.588 rows=212972 loops=1)
         Index Cond: (passenger_name = 'MARFA SOLOVEVA'::text)
 Planning time: 0.157 ms
 Execution time: 10142.730 ms

在一種情況下,過濾器只允許4萬個false positives,而在另一種情況下允許多達400萬個false positives(«Rows Removed by Index Recheck»)。查詢的執行時間相應地有所不同。

下面是根據乘客ID而不是姓名搜尋相同行的結果。Miroslav:

demo=# explain(costs off,analyze)
demo-# select * from flights_bi where passenger_id='5864 006033';
                                           QUERY PLAN
-------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on flights_bi (actual time=13747.305..16907.387 rows=2 loops=1)
   Recheck Cond: ((passenger_id)::text = '5864 006033'::text)
   Rows Removed by Index Recheck: 9620258
   Heap Blocks: exact=50510 lossy=165722
   ->  Bitmap Index Scan on flights_bi_bloom (actual time=937.202..937.202 rows=426474 loops=1)
         Index Cond: ((passenger_id)::text = '5864 006033'::text)
 Planning time: 0.110 ms
 Execution time: 16907.423 ms

對於Marfa:

demo=# explain(costs off,analyze)
select * from flights_bi where passenger_id='2461 559238';
                                            QUERY PLAN
--------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on flights_bi (actual time=3881.615..3934.481 rows=2 loops=1)
   Recheck Cond: ((passenger_id)::text = '2461 559238'::text)
   Rows Removed by Index Recheck: 30669
   Heap Blocks: exact=27513
   ->  Bitmap Index Scan on flights_bi_bloom (actual time=1084.391..1084.391 rows=30671 loops=1)
         Index Cond: ((passenger_id)::text = '2461 559238'::text)
 Planning time: 0.120 ms
 Execution time: 3934.517 ms

效率的差異再次很大,而這一次Marfa更幸運。

注意,同時搜尋兩個欄位將做得更有效,因為一個false positive p的概率變成p*p

demo=# explain(costs off,analyze)
select * from flights_bi
where passenger_name='MIROSLAV SIDOROV'
  and passenger_id='5864 006033';
                                                     QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on flights_bi (actual time=872.593..877.915 rows=2 loops=1)
   Recheck Cond: (((passenger_id)::text = '5864 006033'::text)
               AND (passenger_name = 'MIROSLAV SIDOROV'::text))
   Rows Removed by Index Recheck: 357
   Heap Blocks: exact=356
   ->  Bitmap Index Scan on flights_bi_bloom (actual time=832.041..832.041 rows=359 loops=1)
         Index Cond: (((passenger_id)::text = '5864 006033'::text)
                   AND (passenger_name = 'MIROSLAV SIDOROV'::text))
 Planning time: 0.524 ms
 Execution time: 877.967 ms

但是,根本不支援使用布林«or»進行搜尋。 這是計劃器的限制,而不是訪問方法的限制。 當然,仍然可以選擇讀取索引兩次,構建兩個點陣圖並連線它們,但這對於選擇該計劃而言很可能太昂貴了。

跟BRIN以及Hash的相比較

Bloom和BRIN索引的應用領域明顯相交。 這些是大表,希望確保通過不同的欄位進行搜尋,但犧牲了搜尋精度以降低緊湊性。

BRIN索引更緊湊(例如,在我們的示例中為多達幾十兆位元組),並且可以支援按範圍進行搜尋,但是與檔案中資料的物理排序有關,因此存在很大的侷限性。 Bloom索引更大(數百兆位元組),但沒有限制,除了需要合適的雜湊函式可用。

像Bloom索引一樣,雜湊索引僅支援相等性檢查的操作。 雜湊索引確保了Bloom難以獲得的搜尋準確性,但是索引的大小要大得多(在我們的示例中,一個欄位只有一個千兆位元組,並且不能在多個欄位上建立雜湊索引)。

屬性

像往常一樣,讓我們看看Bloom的屬性。

訪問方法的屬性如下:

 amname |     name      | pg_indexam_has_property
--------+---------------+-------------------------
 bloom  | can_order     | f
 bloom  | can_unique    | f
 bloom  | can_multi_col | t
 bloom  | can_exclude   | f

顯然,訪問方法使我們能夠在多個列上構建索引。在一列上建立Bloom索引幾乎沒有意義。

以下索引層屬性可用:

     name      | pg_index_has_property
---------------+-----------------------
 clusterable   | f
 index_scan    | f
 bitmap_scan   | t
 backward_scan | f

唯一可用的掃描技術是點陣圖掃描。由於索引總是被完全掃描,因此實現按TID逐行返回行的常規索引訪問沒有意義。

        name        | pg_index_column_has_property 
--------------------+------------------------------
 asc                | f
 desc               | f
 nulls_first        | f
 nulls_last         | f
 orderable          | f
 distance_orderable | f
 returnable         | f
 search_array       | f
 search_nulls       | f

該方法甚至不能操作nulls。