[翻译]使用部分索引来加速PostgreSQL

原文

本文经过原作者Dan Robinson同意翻译。(我在twitter发送推文和他互动)


你知道PostgreSQL支持表的部分索引吗?这使得读取部分索引是非常快速的,几乎没有什么索引开销。如果你想重复地分析那些匹配给定的 WHERE 子句的行数据,那部分索引是最好的索引数据的方式。这使得PostgreSQL非常适合于那种涉及预聚集额外的特别分析的工作流。在这一点上,我将通过一个非常适合使用部分索引的查询优化示例来实践下。

思考一下,具有以下模式的事件表:

1
2
3
4
5
6
7
CREATE TABLE event (
user_id BIGINT,
event_id BIGINT,
time BIGINT NOT NULL,
data JSON NOT NULL,
PRIMARY KEY (user_id, event_id)
)

每一个事件都与用户关联,并且有一个ID,一个time,以及一个 JSON 表示事件。
JSON包括页面路径,事件类型(例如:点击,查看页面,提交表单),以及其他任何描述事件的属性。

我们可以使用这张表来保存许多不同的事件,为了我们以后可以分析数据,让我们假设有许多自动跟踪并记录每次点击,查看页面以及表单提交的事件。我们可能想要一个内部的表盘可以显示一些高值指标,例如每周的注册数或者我们的每天收益数。与表盘相关的事件只是这个表的一个小的组成部分——在你的网站上最终购买的点击数占非常小的百分比。但它们被混合在表的其余部分,所以我们的“信噪比”比较低。

我们可能喜欢索引我们的数据来加快表盘的查询[1]。让我们从注册事件开始,我们定义一个表单提交到我们的 /signup 页面。获取9月份第一周的注册数可以这样子写:

1
2
3
4
5
6
SELECT COUNT(*)
FROM event
WHERE
(data->>'type') = 'submit' AND
(data->>'path') = '/signup/' AND
time BETWEEN 1409554800000 AND 1410159600000

在一个1000万个事件数据集里,有3000个是注册事件,并且没有任何索引,这条查询耗时45秒。

在每个单列里建立索引:混合

一个天真地认为提高这个性能的方法是:为每个相关事件的特性:(data->>'type'), (data->>'path'), and time 都创建一个单列索引。

我们使用这三个索引的位图索引扫描得出结果,如果该查询是有选择性的并且相关的索引部分都在内存的这可能会比较快速。的确,这些索引在适当的位置时,该查询在初始化时耗时200ms,随后再耗时20ms在执行合并数据集上——这显著改善了耗时45秒的顺序扫描。

但是这种索引策略有一些非常大的缺点:

  • 写开销. 我们需要在每个 插入/更新/删除 操作该表数据时都要写这三个索引的数据[2]。 对于在该例子里需要频繁写数据来说,这可能代价太高了。

  • 限制查询结果集。这种策略约束了我们定义高值事件类型的能力。如果我们需要一些比在JSON里其中之一的字段的范围更复杂的查询就无法工作了。 假如我们想要匹配一个正则,或者查看所有以/signup/开头,后面可以接任何字符的页面路径?

  • 磁盘使用。在我们测试的数据集合里,该表的大小高达 6660 MB,并且这三个索引一总占用1026MB,为了支持该表,我们需要大幅增加硬盘空间。[3]

进入部分索引

我们只是仅仅分析 0.03% 的组成注册事件的表数据,但是上面这种索引策略索引了所有行。我们希望能够高效地执行查询表的一小部分数据。类似像这种情况,最好的结果是使用部分索引。

如果我们索引一个不相关的列并且限制我们的索引是匹配注册定义事件的,PostgreSQL可以非常容易地确定注册事件的数据行在哪里,并且比在相关字段里建立完整的索引更高效地查询这些数据。特别注意,考虑索引time字段,但仅仅是匹配那些过滤好的注册事件的行。这是:

1
2
CREATE INDEX event_signups ON event (time)
WHERE (data->>'type') = 'submit' AND (data->>'path') = '/signup/'

使用这个索引,我们的执行测试查询初始化耗时200ms,并且紧接耗费2ms来执行,因此,如果我们经常执行这种查询,这会提高性能。更重要的是,部分索引解决了上面提到的三个组合的索引的三个缺点。

  • 它仅仅占用 96KB 空间, 这比完整索引所有这三个字段占用1026MB空间提升了10000 倍。

  • 当新行匹配我们过滤好的注册事件时,我们才需要写部分索引。由于这里有 0.03% 的注册事件,这种数据分布显著提高了写性能:实际上这是由于部分索引而免费得到的。

  • PostgreSQL只是允许那些完全表达式过滤的部分加入。我们定义索引的 WHERE 子句可以使用我们可能在一条查询里使用的任何行过滤表达式。我们使用这方式来匹配更多复杂事件的定义,比如正则表达式,函数结果,或者上面提到的前缀匹配的例子。

避免索引那些谓词结果为布尔值的数据

我见过的另一种方式是试图索引布尔表达式:

1
(data->>'type') = 'submit' AND (data->>'path') = '/signup/'

直接地,将time放在第二个字段,像这样:

1
2
CREATE INDEX event_signup_time ON event
(((data->>'type') = 'submit' AND (data->>'path') = '/signup/'), time)

这比以上两种方式更加糟糕,它产生的结果是PostgreSQL的查询计划不会理解我们的查询例子来约束那些在索引里第一个字段为true的行。这是因为,查询计划不知道 WHERE 子句:

1
WHERE (data->>'type') = 'submit' AND (data->>'path') = '/signup/'

是与下面我们索引的这个字段为必须为true的是相等的。

1
((data->>'type') = 'submit' AND (data->>'path') = '/signup/')

因此, 在我们的扫描里,使用该索引来限制事件的时间范围,但是它无论这个正则表达式是true还是false都会读取所有事件(译者注:这里指的是通过时间范围查出来的所有事件),加载完毕后才对每一行进行检查条件。[4]

因此,我们就会从磁盘里读取比实际需要的更加多的行,并且还要对每一个结果行执行重要的检查条件。在我们的数据集里,该查询开始执行时耗时25秒,之后再连续执行8秒。事实的真相是,这比仅索引time字段差一点,在执行上是等同的。

部分索引对于预先计算那些通过谓词来匹配表的子集来说是一个非常强大的方式。通过流量判断 #postgresql IRC 并没有被充分利用。相比完全索引,它们允许一个更好的谓词范围。它们明显地更加轻量级,需要更少地写操作以及更少的磁盘空间,特别是那些高选择性的过滤。如果你重复地查询一个表的一小部分行,那么部分索引应该是你的默认策略。

爱上PostgreSQL了吗?还有人可能知道更多关于部分索引的知识?发推给我 @danlovesproofs

对构建使强大的技术变得易用的系统感兴趣?向我们投递留言到 jobs@heapanalytics.com.

  • [1] 我们可能使用分表来解决这种情况,将高值事件和低值事件分到不同的子表,但是如果有许多不同的高值事件时这种方式就比较笨拙了,并且我们想要添加一个新类型的高值事件时,每次都需要重新分表。

  • [2] 我们可能得到一些通过优化只在堆元组上的免更新,但是,至少在每次的插入和删除时将需要写这三个索引。

  • [3] 我们可以索引这三个字段为一个组合索引,例如在:((data->>’type’), (data->>’path’), time)。这占用 755MB 空间,节约了 26% ,但这仍然是非常大的,其他的缺点也一样有。更重要的是,在这些数据上,索引会更少地应用到其他的查询,因此,如果我们正支持一些不同的同值事件,这可能不会节约我们的任何空间。

  • [4] 相应的查询计划:

1
2
3
4
5
6
7
QUERY PLAN
---------------------------------------------------------------------------------------------------------------
Aggregate (cost=820866.05..820866.06 rows=1 width=0)
-> Index Scan using event_signup_time on event (cost=0.43..820865.99 rows=27 width=0)
Index Cond: (("time" >= 1409554800000::bigint) AND ("time" <= 1410159600000::bigint))
Filter: (((data ->> 'type'::text) = 'submit'::text) AND ((data ->> 'path'::text) = '/signup/'::text))
(4 rows)