PostgreSQL索引(10) – Bloom

PostgreSQL索引(10) – Bloom

本文翻译自Egor Rogov的英文博客,且已征得Egor Rogov的同意。译者做了少量修改。

审校:纪昀红,中国人民大学信息学院在读博士生。

在前面几篇文章中,我们讨论了通用索引引擎、AM的接口和六种AM(Hash索引、B-tree索引、GiST索引、SP-GiST索引、GIN索引、RUM索引、BRIN索引)。这篇文章将介绍Bloom索引。

1. 基本概念

一个经典的Bloom filter是一个数据结构,使用它可以快速的检查一个元素是否属于一个集合。过滤器被高度压缩,但是它可能会产生假阳性的结果:它可能会误认为一个元素在集合中存在,实际这个元素在集合中并不存在。如果它认为一个元素在集合中不存在,那么这个元素在集合中肯定不存在。

过滤器是一个由比特位组成的数组(也被称为签名),所有比特最初都被初始化成零。当向集合中添加一个元素时,我们会使用多个hash函数对这个元素计算hash值,把每个hash值对应的比特位置为1。所以,当一个元素对应的所有比特位都为1时,这个元素可能属于这个集合。但是当任意一位为0时,则这个元素肯定不属于这个集合。

通过改变签名的长度,我们可以在索引体积和假阳性的概率之间得到一个平衡。Bloom索引的应用非常广泛,相当多的宽表都在每个字段上使用过滤器。这种AM,和BRIN类似,也可以被看作是顺序扫描的加速器:索引返回的所有元组,都需要被recheck,但是有可能避免扫描大部分不满足条件的元组。

2. 结构

我们在GiST索引中讨论过签名。和这些树不同,Bloom filter是一个平坦的结构。它的第0页是元页面,后面所有的页面都是包含索引行的常规页面。每个索引行包含一个签名和一个指向表中行的TID,如下图所示:

2.1 参数

在创建Bloom索引时,可以指定每个签名的总长度,也可以指定每个字段需要将签名中多少位置1。

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

我们应该怎样选取合适的值呢?理论表明,给定一个假阳性的概率,签名的最优长度可以用$m=-n\log_2 p/\ln 2$估计,其中$n$是索引中字段的个数,签名中需要设置为1的位数$k$为$k=-\log_2 p$。

签名的长度会被向上取整到2字节的倍数。

需要注意几点:

  • 假阳性的概率p与一个filter相关,我们期望在一次扫描中得到Np个假阳性的结果。例如一个表有100万行,概率为0.01,平均来讲,可以观察到Rows Removed by Index Recheck的数量为10000。
  • Bloom filter是一个概率结构。只有在大量数据的场景下,这种概率才有意义。
  • 上面的估计基于一个理想的数学模型和一些假设。实际中,结果要糟糕一些。所以,不要过分依赖这个公式:它们仅仅提供了一种选取初始值的方法。
  • 对每个字段来说,这个AM允许我们选择在签名中置1的位数。实际中最优的位数与数据的分布有关系。如果想深挖,请参考这篇文章。但是也别忘了上一条。

2.2 更新

当向表中插入一行数据时,签名被计算出来:对所有被索引的字段,与它们对应的位全部被置为1。理论上来说,我们需要k个不同的hash函数,实际中,只需要连续生成k个随机数即可。随机数的种子有hash值决定。

当一行数据被删除时,整个签名全部被删除。

更新操作由两个操作组合而成:删除旧版本,插入新版本。

2.3 扫描

因为Bloom只能用来检查某个元素是否属于某个集合,所以Bloom索引支持的操作只有等值检查,就像Hash索引一样。

上文已经提到,Bloom索引是一个平坦的结构,所以在访问它的时候,总需要把它按顺序完整的读出来。在读取的过程中,将位图构造出来,这个位图用来扫描基表。

读取Bloom索引时,实际就是对索引进行全部扫描。为了防止扫描过程把一些有用的缓存换出,在读取时会使用一个小的缓冲区环,就像对基表进行全表扫描一样。

我们需要注意到,Bloom索引的体积越大,优化器对它越不感兴趣。这个依赖是线性的,这和树状的索引不同。

3. 示例

3.1 表

我们继续使用上一篇文章中的表flights_bi。表的大小约4GB,总共包含大约3000万行。表的定义如下:

\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                     |           |          | 
create extension bloom;

在BRIN索引中,我们使用了3个字段(scheduled_time, actual_time, airport_utc_offset)。因为Bloom索引不需要依赖数据在物理上有序,我们尝试在索引中使用表中几乎所有的字段。当然,我们也要排除时间字段(scheduled_time,actual_tim):因为它只支持等值查询,在一个精确的时间上做等值查询基本没有意义。虽然可以创建表达式索引,但我们还是不费力了。我们排除机场的地理坐标airport_coord:point类型还不被支持。

为了计算参数的时,我们把假阳性的概率设置为0.01,带入上面的公式,可以计算出,签名需要96个比特,建议对每个字段把签名中7位置1。索引估计为515MB(大约为基表大小的八分之一)。

虽然使用16位签名,索引的体积更小,但是这会导致50%的假阳性,这太差了。

3.2 操作符类

首先创建索引:

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.

不幸的是,它只支持两个操作符类:

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索引的操作符类:

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
 ...

我们创建两个操作符类:

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

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

point类型没有对应的hash函数,索引不能为它创建Bloom索引,也不能对这种类型使用hash连接。

再次尝试一下:

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,比估计的稍大一些。这是因为我们在估算时没有考虑页面上的其它开销。

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

3.3 查询

我们使用各种标准,对索引进行查询。

就像已经提到的那样,Bloom filter是一个概率性的数据结构,因此它高度依赖具体的场景。例如,我们查看与两个乘客相关的行。Miroslav Sidorov:

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:

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

在第一个查询中,只有40000个假阳性结果,但是在第二个查询中有4000000个。执行时间也不同。

下面使用乘客的id进行查询,而不是姓名:

explain(costs off,analyze)
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:

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

这两个查询的效率也有很大差距。

注意到,如果可以同时使用两个索引,则假阳性的概率可以降低到万分之一。

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

但是,Bloom索引根本不支持or。这是优化器的限制,而不是Bloom filter的限制。当然,其中一个选择就是读取两个索引,计算两个位图,然后求交集,但是代价很大。

4. 与BRIN和Hash索引的比较

很明显,Bloom索引与BRIN的应用场景有交集。它们都面对需要使用不同字段对大表进行扫描的场景,以损失精度换取空间上的压缩。

BRIN索引更加紧凑,可以快速排除一个区间上所有的元组,但是基于一个很强的假设:数据的顺序与物理位置相关。Bloom索引要大一些,但是它除了需要一个合适的Hash函数之外,没有任何假设。

和Bloom索引相似,Hash索引也只支持等值查询。Hash索引的精度比Bloom索引高,但是需要的空间也大。

5. 属性

和往常一样,看一下Bloom filter的属性,查询见第二篇文章。

AM层面的属性:

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

它支持多列索引。

某个索引层面的属性:

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

它支持位图扫描。

列层面的属性:

        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
Comments are closed.