先验知识:
签名、过滤器:为一行数据设置的过滤器位数,对应于一般的Bloom过滤器
误报率:假阳性/(假阳性+真阳性),误报率越低,索引越快
在之前的文章中,我们讨论了PostgreSQL索引引擎和访问方法的接口,以及B树、GiST、SP GiST、GIN、RUM和BRIN。但我们仍然需要看看Bloom索引。
Bloom
一般概念
经典的Bloom过滤器是一种数据结构,使我们能够快速检查集合中是否有指定元素。过滤器是高度紧凑的,它允许出现假阳性:它可能错误地认为元素是集合的成员(但其实不是);但是不会出现假阴性:以为一个元素是集合的成员,但其实不是。
过滤器是一个由m位组成的数组(也称为签名),最初由零填充。选择k个不同的哈希函数,将任一元素映射到签名的k位。要向集合中添加该元素,我们需要将签名中的这k位都设置为1。因此,如果与一个元素对应的所有位都为一,则该元素可能是该集合的一个成员,但如果至少有一位等于零,则该元素肯定不在该集合中。
对于DBMS,我们实际上为每个索引行构建了N个单独的过滤器。通常,索引中包含几个字段,这些字段的值构成了每行的元素集。
通过选择签名m的长度,我们可以在索引大小和误报概率之间找到折衷。Bloom索引的应用领域是大的、相当“宽”的表,可以在每个字段上使用过滤器进行查询。与BRIN一样,这种访问方法可以被视为顺序扫描的加速器:索引找到的所有匹配项都必须与表一起重新检查,但有可能完全避免考虑大多数行。
结构
我们已经在GiST访问方法的上下文中讨论了签名树(在索引树中不存字符串,而是通过哈希函数将字符串转换为哈希值,取哈希值后几位作为该字符串的签名)。与这些树不同,Bloom索引是一个扁平结构。它由一个元页面和一个带有索引行的常规页面组成。每个索引行包含一个签名和对表行的引用(TID),如图所示。
参数值的创建和选择
创建Bloom索引时,需指定签名的总大小(“长度”),以及为索引中包含的每个字段设置的位数(“col1”-“col32”):
create index on ... using bloom(...) with (length=..., col1=..., col2=..., ...);
指定位数的方法看起来很奇怪:这些数字必须是运算符类的参数,而不是索引。问题是,目前无法对运算符类进行参数化,尽管这方面的工作正在进行中。
【该功能(CommitTest条目)最终进入PostgreSQL 13。】
我们如何选择合适的值呢?该理论指出,给定滤波器返回假阳性的概率p,最佳签名位数(签名是一个索引行包含一个)可以估计为m=−nlog2p/ln 2,其中n是索引中的字段数,要设置的位数(这个是整个索引的大小)为k=−log2p。
签名以两字节整数数组的形式存储在索引中,因此m的值可以安全地四舍五入到16。
在选择概率p时,我们需要考虑索引的大小,它大约等于(m/8+6)N,其中N是表中的行数,6是以字节为单位的TID指针的大小。
需要注意的几点:
- 误报概率p与一个过滤器有关,因此,我们希望在表扫描期间得到Np误报(当然,对于返回多行的查询)。例如,对于一个有一百万行且概率为0.01的表,在查询计划中,平均而言,我们可以预期通过索引复查删除的行为10000。
- Bloom过滤器是一种概率结构。只有当平均相当多的值时,谈论特定的数字才有意义,而在每个特定的情况下,我们可以得到我们能想到的任何东西。
- 上述估算基于一个理想化的数学模型和一些假设。实际上,结果可能更糟。所以,不要高估公式:它们只是为未来实验选择初始值的一种手段。
- 对于每个字段,访问方法使我们能够选择要设置的位数。有一个合理的假设,即实际的最佳数量取决于列中值的分布。要深入研究,你可以阅读这篇文章(欢迎参考其他研究)。但请先重读上一条。
更新
在表中插入新行时,会创建一个签名:对于所有索引字段的值,其所有对应位都设置为1。理论上,我们必须有k个不同的散列函数,而实际上,伪随机数生成器就足够了,它的种子每次都是通过唯一的散列函数来选择的。
常规Bloom筛选器不支持删除元素,但Bloom索引不需要这样做:删除表行时,整个签名以及索引行都会被删除。
通常,更新包括删除过时的行版本和插入新版本(重新计算签名)。
扫描
由于Bloom过滤器唯一能做的就是检查集合中元素的成员身份,Bloom 索引支持的唯一操作就是相等性检查(就像在hash index中一样)。
正如我们已经提到的,Bloom索引是扁平的,所以在索引访问过程中,它总是连续地、完整地读取。在读取过程中,生成一个位图,然后使用该位图访问表。
在常规索引访问中,假设需要读取的索引行很少,而且很快就会再次需要它们,因此,它们存储在缓冲区缓存中。然而,读取Bloom索引实际上是一次顺序扫描。为了防止从缓存中逐出有用的信息,读取是通过一个小缓冲环完成的,与表的顺序扫描完全相同。
我们应该考虑到,Bloom索引的规模越大,它对规划器的吸引力就越小。这种依赖关系是线性的,与树型索引不同。
示例
表
让我们以前一篇文章中的一个大“flights_bi”表为例来看看Bloom索引。提醒一下,这个表的大小是4GB,大约有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 | | |
让我们首先创建扩展:尽管从9.6版开始的标准交付中包含Bloom索引,但它在默认情况下不可用。
demo=# create extension bloom;
上次我们可以使用BRIN对三个字段进行索引(“计划时间”、“实际时间”、“机场utc偏移”)。由于Bloom索引不依赖于数据的物理顺序,所以让我们尝试在索引中包含表的几乎所有字段。但是,让我们排除时间字段(“scheduled_time”和“actual_time”):该方法只支持相等的比较,但对精确时间的查询几乎没有人感兴趣(然而,我们可以在表达式上建立索引,将时间四舍五入到一天,但我们不会这样做)。我们还必须排除机场的地理坐标(“机场坐标”):展望未来,“点”类型不受支持。
要选择参数值,让我们将假阳性概率设置为0.01(记住,实际上我们会得到更高的假阳性概率)。将n=9,N=30000000带入上述公式得出签名大小为96位,并建议每个元素设置7位(就是每行都会创建一个96位的过滤器,而该行的每个字段将改变其中7位)。该索引的估计大小为515MB(约为表的八分之一)。
(由于最小签名大小为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 ……
我们将使用以下信息创建两个缺少的类(为character与interval创建需要的操作符类):
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;
没有为点(“点”类型)定义的哈希函数,正是因为这个原因,我们不能在这样的字段上构建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
索引的大小为526MB,略大于预期。这是因为公式没有考虑页面开销。
demo=# select pg_size_pretty(pg_total_relation_size('flights_bi_bloom')); pg_size_pretty ---------------- 526 MB (1 row)
查询
我们现在可以使用各种条件执行搜索,索引将支持它。
正如我们已经提到的,Bloom过滤器是一种概率结构,因此,效率在很大程度上取决于每个特定情况(如果有,则需要查很久才知道在哪,如果没有,则很快知道没有,而且与散列出来的位的情况有关,取决于冲突多不多,而冲突多不多又与索引的签名(即过滤器)位数以及每个元素设置的位数有关)。例如,让我们看看与两名乘客有关的行,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万个误报,而在另一种情况下(通过索引重新检查删除的行)最多允许400万个误报。查询的执行时间相应不同。
下面是按乘客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
效率又大不相同了,这一次玛法更幸运了。
请注意,由于假阳性概率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
但是,根本不支持使用布尔“或”进行搜索;这是规划器的限制,而不是访问方法的限制。当然,仍然可以选择读取两次索引,构建两个位图,然后将它们连接起来,但这很可能太昂贵,(导致)无法选择此计划。
与BRIN和Hash的比较
Bloom索引和BRIN索引的应用领域明显交叉。对于这些大型表格,需要确保按不同字段进行搜索,搜索精度被牺牲在紧凑性上。
BRIN索引更紧凑(例如,在我们的示例中最多可达几十兆字节),可以支持按范围搜索,但在文件中数据的物理排序方面有很大的限制。Bloom索引更大(数百兆字节),但除了需要合适的哈希函数之外没有任何限制。
与Bloom索引一样,Hash索引支持唯一的相等性检查操作。Hash索引确保了Bloom无法实现的搜索精度,但索引大小要大得多(在我们的示例中,在一个字段上创建的索引有1GB,不能在多个字段上创建哈希索引)。
属性
像往常一样,让我们看看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
这里只有false;该方法甚至不能处理空值。
最后
未来,当新的指数类型出现时,这一系列文章可能将继续下去,但现在是时候停止了。
我要感谢我的博士后专业团队的同事们(其中一些人是所讨论的许多访问方法的作者),他们阅读了草稿并提供了他们的意见。当然,我也非常感谢你们的耐心和宝贵的评论。你的注意力鼓励我达到这一点。非常感谢。
(这系列的文章翻译完了!)