在之前的文章中,我们讨论了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=−nlog2p/ln2,这里n是索引中列的个数, 待设置的位数是k=−log2p.
签名存储在索引中,以一个两个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。