理解哈希索引的运作原理需要分辨索引值与哈希值:
目标行A通过哈希函数F(A)=1011010111011……110100,该值为哈希值,共32位。
索引值取哈希值的后几位,如后四位,为0100,该值为4,说明目标行A在桶4里,桶的个数为2^4=16个。
所以索引值有很大概率冲突,但哈希值冲突的概率很小。
目录
Hash
架构
第一篇文章描述了PostgreSQL索引引擎,第二篇处理访问方法的接口,现在我们准备讨论具体类型的索引。让我们从Hash索引开始。
Hash
架构
一般理论
很多现代编程语言将哈希表作为基本数据类型。从外观上看,哈希表看着像一个常规数组,它可以索引任何数据类型(例如字符串)而不一定是整数。在PostgreSQL中 ,哈希索引的结构与此类似。它是怎么工作的呢?
通常情况下,数据类型的允许值范围非常大:在“text”类型的列中,我们可能会看到多少不同的字符串?同时,在某个表的text列中实际存储了多少不同的值?通常情况下,不是很多。
哈希的概念是将一个小数字(从0到N−1,总共N个值)和任何数据类型的值相关联,这样的关联称为哈希函数。得到的数字(即小数字)可以用作常规数组的索引,其中(常规数组中)将存储对表行(TID)的引用。该数组的元素称为哈希表存储桶——如果相同的索引值出现在不同的行中(就是对于不同的行目标,hash函数计算出来的索引值相同,因为虽然哈希值在pg中有32位,但索引值只会取哈希值的后几位,如后4位,所以不同的值计算出来的索引值有很大概率重复,但哈希值重复的概率很小),一个存储桶可以存储多个TID。
哈希函数按存储桶分配源值越均匀,效果越好。但是即使一个优秀的哈希函数有时也会为不同的源值产生相等的结果,这被称为冲突。所以,一个桶可以存储对应于不同键的TIDs,并且因此,从索引中获得的TIDs需要重新检查。
举个例子:我们能想到什么关于字符串的哈希函数?假设桶的数量为256,然后,以桶号为例,我们可以取第一个字符的代码(假设为单字节字符编码)。这是一个好的散列函数吗?显然不是:如果所有字符串都以同一个字符开头,那么所有字符串都将进入一个存储桶,因此均匀性是不可能的,所有值都需要重新检查,而散列将毫无意义。如果我们将所有字符的代码求和,然后对256取模呢?这会好得多,但远不理想。如果对PostgreSQL中此类哈希函数的内部结构感兴趣,请查看hashfunc.c中hash_any()的定义。
索引架构
让我们回到哈希索引。对于某个数据类型的值来说(一个索引键),我们的任务是快速找到匹配的TID。
当插入索引时,让我们计算键的哈希函数。PostgreSQL中的哈希函数总是返回“整数”类型,其范围包括2的32次方≈ 40亿个值。bucket的数量最初等于2,并动态增加以适应数据大小。可以使用位算法从哈希代码计算桶数(就是如果取哈希值后4位来确定值在哪个桶的话,那么桶的数量应该是2^4次方,即16个),这就是我们将要放TID的桶。
但这是不够的,因为匹配不同键的TID可以放在同一个桶中。我们该怎么办?除了TID之外,还可以将键的源值存储在bucket中,但这会大大增加索引大小。为了节省空间,桶存储键的哈希代码,而不是键。
在索引中搜索时,我们计算键的哈希函数,并得到桶号。现在,它仍然需要遍历桶的内容,并只返回具有适当哈希代码的匹配TID(即,比较完索引值后找到桶,再在桶中比较哈希值)。由于存储的“hash code-TID”对是有序的,所以这一点可以有效地完成(可以使用二分法)。 然而,两个不同的密钥不仅可能进入一个存储桶,而且可能具有相同的四字节哈希代码(就是32位的哈希值也可能相同)——没有人能消除冲突。因此,访问方法要求通用索引引擎通过重新检查表行是否匹配来验证每个TID(可见性检查可以与这个一起进行)。
将数据结构映射到页面
如果我们从缓冲区缓存管理器的角度而不是从查询规划和执行的角度来看索引,那么所有信息和所有索引行都必须打包到页面中。这样的索引页存储在缓冲区缓存中,并以与表页完全相同的方式从那里逐出。
如图所示,哈希索引使用四种页面(灰色矩形框):
- 元数据页面:第0页,包含索引内内容的信息。
- 桶页:索引的主要页,以“哈希代码-TID”对形式保存数据
- 溢出页:结构与bucket页面相同,当一个页面不足以容纳一个bucket时使用。
- 位图页:它跟踪当前清除的溢出页,这些溢出页可以重新用于其他存储桶。
从索引页元素开始的向下箭头表示TID,即对表行的引用。
每次索引增加时,PostgreSQL都会立即创建两倍于上次创建的存储桶(也就是页面翻倍,因为是通过将索引值新加一位来实现的)。为了避免一次分配这么多可能的页面,版本10使大小增加得更平稳。至于溢出页面,它们会在需要时分配,并在位图页面中跟踪,位图页面也会在需要时分配。
请注意,哈希索引的大小不能减小。如果我们删除一些索引行,一旦分配的页面将不会返回到操作系统,而只会在清空后重新用于新数据。减少索引大小的唯一选项是使用REINDEX或VACUUM FULL 命令从头开始重建索引。
示例(如何创建HASH索引)
让我们看看哈希索引是如何创建的。为了避免设计我们自己的数据表,从现在起我们将使用航空运输的演示数据库,这次我们将考虑航班表。
demo=# create index on flights using hash(flight_no);
WARNING: hash indexes are not WAL-logged and their use is discouraged
CREATE INDEX
demo=# 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)
当前哈希索引实现的不方便之处在于,使用该索引的操作没有记录在预写日志中(在创建索引时,PostgreSQL会发出警告)。因此,哈希索引在失败后无法恢复,并且不参与复制(流复制到备库)。此外,哈希索引的通用性远远低于“B-树”,其效率也值得怀疑,(而且不支持范围查询)。所以现在使用这样的索引是不符合实际的。
然而,一旦PostgreSQL第10版发布(最早将在今年秋天(2017年)),情况将发生转变。在这个版本中,哈希索引最终获得了对预写日志的支持;此外,内存分配将得到优化,并行工作效率更高。
【确实如此。因为PostgreSQL 10哈希索引得到了充分的支持,可以不受限制地使用。警告不再显示。】
哈希语义(对哪些数据类型可以使用哪些哈希函数)
但为什么哈希索引几乎从PostgreSQL诞生到9.6版本都不能从不可用中存活下来呢?(就是为什么一直不推荐用呢?)问题是DBMS广泛使用了哈希算法(特别是哈希连接和分组),系统必须知道将哪个哈希函数应用于哪个数据类型。但这种对应关系不是静态的,而且不能一劳永逸地设置,因为PostgreSQL允许动态添加新的数据类型。这是一种通过哈希访问方法来表示并存储辅助函数与运算符族之间的关联的(哈希访问方法可以表示辅助函数与运算符族之间的关系映射,而辅助函数里也包含哈希函数)。
demo=# 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
...
虽然这些函数没有文档记录,但它们可以用于计算适当数据类型的值的哈希代码。例如,“hashtext”函数如果用于“text_ops”操作符系列:
demo=# select hashtext('one');
hashtext
-----------
127722028
(1 row)
demo=# select hashtext('two');
hashtext
-----------
345620034
(1 row)
属性(哈希索引的属性与限制)
让我们看看hash index的属性,这个访问方法为系统提供关于自身的信息。上次我们提供了查询方法。现在,我们将不再详细讨论查询的结果:
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
散列函数不保留顺序关系:如果一个键的散列函数的值小于另一个键的值,则无法得出键本身是如何排序的任何结论。因此,通常哈希索引只能支持“等于”操作:
demo=# 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)
...
因此,哈希索引不能返回有序数据(“can_order”、“orderable”)。哈希索引不处理空值的原因相同:“equals”操作对空值(“search_NULLs”)没有意义。 由于散列索引不存储键(而只存储它们的散列码),因此它不能用于仅索引访问(“returnable”)。 这种访问方法也不支持多列索引(“can_multi_col”)。
内部构件(元页面、桶页面等包含的信息)
从版本10开始,可以通过“pageinspect”扩展 查看哈希索引内部。这就是它的样子:
demo=# create extension pageinspect;
元页面(我们得到索引中的行数和最大使用桶数):
demo=# select hash_page_type(get_raw_page('flights_flight_no_idx',0));
hash_page_type
----------------
metapage
(1 row)
demo=# select ntuples, maxbucket
from hash_metapage_info(get_raw_page('flights_flight_no_idx',0));
ntuples | maxbucket
---------+-----------
33121 | 127
(1 row)
一个bucket页面(我们得到了活元组和死元组(也就是那些可以被清空的元组)的数量):
demo=# select hash_page_type(get_raw_page('flights_flight_no_idx',1));
hash_page_type
----------------
bucket
(1 row)
demo=# 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开始。