PostgreSQL索引(3) – Hash

PostgreSQL索引(3) – Hash

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

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

1. 结构

1.1 基本理论

很多现代编程语言都把Hash表作为一种内置的基本数据类型。从外部来看,Hash表就像普通的数组,数组的下标可以是任意的数据类型(比如,字符串),并非只能是一个整数。PG的Hash索引具有类似的结构,它是如何工作的呢?

通常来讲,一种数据类型的值域具有相当大的范围:一个text类型的列中可能会有多少个不同的字符串?但是,现实生活中,表中一个text类型的列中实际会有多个不同的值?通常情况下,并不是很多。

Hash的思想是把任意数据类型的一个值与一个整数相关联(从0到N-1,共N个值)。这个关联过程由Hash函数完成。得到的这个整数可以用作数组的下标,这个数组中存储了大量指向表中行的TID。这个数组中的元素被称作Hash表的桶 —— 一个桶中可以存储多个TID,如果相同的列值在表中不同行出现多次。

Hash函数将列值在不同的桶中分布的越均匀,则这个Hash函数越好。但是,即使是一个好的Hash函数,有时也会把不同的列值计算出相同的Hash值 —— 即发生了冲突。所以,一个桶中可能存储了不同列值的TID,因此,从Hash索引中查出的TID需要被重新检查。

随便举个例子:对字符串,我们可以使用什么Hash函数?假设桶的数量为256,我们可以用第一个字符(假设使用单字节编码)的asiic码值作为Hash值。这是一个好的Hash函数吗?当然不是:如果所有的字符串都以相同的字符开头,它们被存储到同一个桶,所有的值都需要被重新检查,Hash变得没有任何意义。假设把所有字符的asiic码值求和,再对256取模呢?这可能会好一些,但是距离预期还有很远。如果你对PG内部实现中的Hash函数感兴趣,请查看hashfunc.c中hash_any()函数的实现。

1.2 索引结构

我们回到Hash索引。对某个数据类型的一个值(索引列的值),我们的任务是快速找到它对应的TID。

当向索引中插入时,我们会对列值计算Hash值。PG中的Hash函数通常返回一个整型,它的范围大约为232 ≈ 42亿。桶的数量初始为2,随着数据量的增加而动态增加。桶的编号可以通过对Hash值做位运算得出,这个桶就是存放TID的桶。

但这还不够,因为不同的列值对应的TID可能会被存放到相同的桶中。我们应该怎么办呢?一种可能的实现方式是把原始的列值和TID一起存放在桶中,但这将大大增加索引所占的空间。因此,为了节省空间,桶中存放的是列值的Hash值,而不是直接存放列值。

当在索引中查找时,我们对列值计算Hash值,得到桶的编号。然后,遍历桶中的元素,仅返回相应Hash值得TID。这可以高效的完成,因为<Hash值, TID>被有序的存储。

然而,有时两个不同的key不仅被存储在相同的桶中,而且它们具有相同的Hash值。因此,AM会让索引引擎重新检查(索引引擎可以在检查可见性时顺便做这件事)索引条件,用来确认TID是否真的满足索引条件。

1.3 数据结构映射到页面

如果我们从缓冲区管理器的角度看一个索引,而不是从查询优化器和执行器的角度看,会发现所有的信息、所有的索引项都必须按一定规则存放在页面中。这样,索引页面可以被加载进缓冲区,也可以从缓冲区换出,就像管理表中的页面一样。

如上图所示,Hash索引共使用了4种页面(灰色矩形)。

  • 元页面 —— 第0页,存储索引的元信息
  • 桶页 —— 索引中主要的页,存储<Hash值, TID>键值对
  • 溢出页 —— 和桶页的结构相同,当一个桶页的空间不够时被使用
  • 位图页 —— 跟踪溢出页的使用情况,表示哪些溢出页空闲且可以被其它桶使用

索引页中向下的箭头表示TID,指向表中的行。

每次索引空间增加时,PG生成的桶数都是上次生成桶数的2倍。为了避免一次性分配大量页面,PG 10扩展空间时更加平缓。至于溢出页,则按需分配,被位图页跟踪。位图页也按需分配。

注意,Hash索引不能缩小体积。如果删除了一些索引行,已经被分配的页面不能归还给操作系统,但是可以在Vacuum之后用来存放新数据。减小索引体积的唯一方法是用reindex或vacuum full命令重建索引。

2. 示例

我们看一下Hash索引的创建。为了避免重新设计表结构,我们直接使用上篇介绍过的demo数据库,本次使用flights表。

create index on flights using hash(flight_no);
WARNING:  hash indexes are not WAL-logged and their use is discouraged
CREATE INDEX
explain (costs off) select * from flights where flight_no = 'PG0001';
                     QUERY PLAN                     
----------------------------------------------------
 Bitmap Heap Scan on flights
   Recheck Cond: (flight_no = 'PG0001'::bpchar)
   ->  Bitmap Index Scan on flights_flight_no_idx
         Index Cond: (flight_no = 'PG0001'::bpchar)
(4 rows)

PG 9.6及之前,Hash索引中的操作都不写XLOG,请注意创建Hash索引时PG的WARNING(PG 9.5和PG 9.6两个版本会报上面的WARNING,PG 9.4不报)。这导致宕机后Hash索引不能恢复,并且不能参与复制(replication)。而且,Hash索引远不如Btree索引具有通用性,它的效率也值得怀疑。所以实际中并不值得使用这种索引。

自从PG 10开始,Hash索引已经支持XLOG,并且对内存分配做了优化,并发场景更加高效。现在创建Hash索引,也不再打印上面的WARNING。可参考下面两篇博客:PostgreSQL 10 features: Hash indexesPostgreSQL Indexes: Hash Indexes are Faster than Btree Indexes?

3. Hash函数

每种数据类型使用的Hash函数被存放在系统表中。

select   opf.opfname as opfamily_name,
         amproc.amproc::regproc AS opfamily_procedure
from     pg_am am,
         pg_opfamily opf,
         pg_amproc amproc
where    opf.opfmethod = am.oid
and      amproc.amprocfamily = opf.oid
and      am.amname = 'hash'
order by opfamily_name,
         opfamily_procedure;
   opfamily_name    | opfamily_procedure 
--------------------+--------------------
 abstime_ops        | hashint4
 aclitem_ops        | hash_aclitem
 array_ops          | hash_array
 bool_ops           | hashchar
 bpchar_ops         | hashbpchar
 bpchar_pattern_ops | hashbpchar
 bytea_ops          | hashvarlena
 char_ops           | hashchar
 cid_ops            | hashint4
 date_ops           | hashint4
 enum_ops           | hashenum
 float_ops          | hashfloat4
 float_ops          | hashfloat8
 integer_ops        | hashint2
 integer_ops        | hashint4
 integer_ops        | hashint8
 interval_ops       | interval_hash
 jsonb_ops          | jsonb_hash
 macaddr8_ops       | hashmacaddr8
 macaddr_ops        | hashmacaddr
 name_ops           | hashname
 network_ops        | hashinet
 numeric_ops        | hash_numeric
 oid_ops            | hashoid
 oidvector_ops      | hashoidvector
 pg_lsn_ops         | pg_lsn_hash
 range_ops          | hash_range
 reltime_ops        | hashint4
 text_ops           | hashtext
 text_pattern_ops   | hashtext
 time_ops           | time_hash
 timestamp_ops      | timestamp_hash
 timestamptz_ops    | timestamp_hash
 timetz_ops         | timetz_hash
 uuid_ops           | uuid_hash
 xid_ops            | hashint4
(36 rows)

虽然这些函数没有在文档中说明,我们可以直接使用它们计算对应类型的Hash值。例如:

select hashtext('one');
 hashtext  
-----------
 127722028
(1 row)
select hashtext('two');
 hashtext  
-----------
 345620034
(1 row)

4. 属性

我们来看一下Hash索引的属性。查询语句见上一篇文章,这里直接展示结果。

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

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

        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

通过比较两个列值的Hash值的大小,无法推断出两个列值谁大谁小。因此,通常来讲,Hash索引仅支持等值查询。

select   opf.opfname AS opfamily_name,
         amop.amopopr::regoperator AS opfamily_operator
from     pg_am am,
         pg_opfamily opf,
         pg_amop amop
where    opf.opfmethod = am.oid
and      amop.amopfamily = opf.oid
and      am.amname = 'hash'
order by opfamily_name,
         opfamily_operator;
   opfamily_name    |                     opfamily_operator                      
--------------------+------------------------------------------------------------
 abstime_ops        | =(abstime,abstime)
 aclitem_ops        | =(aclitem,aclitem)
 array_ops          | =(anyarray,anyarray)
 bool_ops           | =(boolean,boolean)
 bpchar_ops         | =(character,character)
 bpchar_pattern_ops | =(character,character)
 bytea_ops          | =(bytea,bytea)
 char_ops           | =("char","char")
 cid_ops            | =(cid,cid)
 date_ops           | =(date,date)
 enum_ops           | =(anyenum,anyenum)
 float_ops          | =(real,real)
 float_ops          | =(double precision,double precision)
 float_ops          | =(real,double precision)
 float_ops          | =(double precision,real)
 integer_ops        | =(integer,bigint)
 integer_ops        | =(smallint,smallint)
 integer_ops        | =(integer,integer)
 integer_ops        | =(bigint,bigint)
 integer_ops        | =(bigint,integer)
 integer_ops        | =(smallint,integer)
 integer_ops        | =(integer,smallint)
 integer_ops        | =(smallint,bigint)
 integer_ops        | =(bigint,smallint)
 interval_ops       | =(interval,interval)
 jsonb_ops          | =(jsonb,jsonb)
 macaddr8_ops       | =(macaddr8,macaddr8)
 macaddr_ops        | =(macaddr,macaddr)
 name_ops           | =(name,name)
 network_ops        | =(inet,inet)
 numeric_ops        | =(numeric,numeric)
 oid_ops            | =(oid,oid)
 oidvector_ops      | =(oidvector,oidvector)
 pg_lsn_ops         | =(pg_lsn,pg_lsn)
 range_ops          | =(anyrange,anyrange)
 reltime_ops        | =(reltime,reltime)
 text_ops           | =(text,text)
 text_pattern_ops   | =(text,text)
 time_ops           | =(time without time zone,time without time zone)
 timestamp_ops      | =(timestamp without time zone,timestamp without time zone)
 timestamptz_ops    | =(timestamp with time zone,timestamp with time zone)
 timetz_ops         | =(time with time zone,time with time zone)
 uuid_ops           | =(uuid,uuid)
 xid_ops            | =(xid,xid)
(44 rows)

因此,Hash索引不能返回有序的结果(can_order、orderable)。基于相同的原因,Hash索引不处理NULL值;“相等”对NULL值来说没有意义(search_nulls)。

因为Hash索引不存储列值(仅存储列值的Hash值),因此它不支持Index Only Scan(returnable)。

Hash索引页也不支持多列索引(can_multi_col)。

5. 内部实现

从PG 10开始,可以使用pageinspect插件研究Hash索引的内部实现。

create extension pageinspect;

元页面(可以查出索引中的行数,被使用的桶的最大编号):

select hash_page_type(get_raw_page('flights_flight_no_idx',0));
hash_page_type 
----------------
 metapage
(1 row)
select ntuples, maxbucket
from hash_metapage_info(get_raw_page('flights_flight_no_idx',0));
 ntuples | maxbucket 
---------+-----------
   33121 |       127 
(1 row)

一个桶页(可以查出页面中live tuple和dead tuple的数量,dead tuple占用的空间可以被vacuum回收):

select hash_page_type(get_raw_page('flights_flight_no_idx',1));
hash_page_type
----------------
 bucket
(1 row)
select live_items, dead_items
from hash_page_stats(get_raw_page('flights_flight_no_idx',1));
live_items | dead_items
------------+------------
        407 |          0
(1 row)

如果不研究源代码,很难将所有字段的含义弄清楚。如果你愿意,你可以从这个README开始。pageinspect的文档在这里

Comments are closed.