原文: MVCC in PostgreSQL — 1. Isolation
系列文章索引
也许每个接触过数据库的人都知道事务的存在,都接触过ACID,也听说过隔离级别。但是也有这样的观点,这些都是理论上的东西,在实践中没有什么用。因此,我将花一些时间来讲一下为什么这个真的很重要。
如果应用程序从数据库中获取了不正确的数据或者向数据库中写入了不正确的数据,你会相当的不开心。
但是什么是“正确”的数据?众所周知,像NOT NULL和UNIQUE这样的完整性约束可以在数据库级别被创建。如果数据能够满足完整性约束(DBMS保证这一点),那么数据就是完整的。
“正确性”和”完整性”是一回事吗?不完全是,不是所有的约束都可以在数据库级别指定。有一些约束太过于复杂,例如,一次操作同时覆盖了多个表。甚至即使一个约束可以在数据库上定义,但是因为某些原因没有去定义,但并不意味着可以违反此约束。
所以说,”正确性”要大于”完整性”,但是我们仍然不知道这具体指什么东西。我们不得不承认,一个“黄金标准”是如果一个程序被正确编写了,那么它就不会出错。但对于那些违反了正确性但是没有违反完整性的程序,DBMS是无法知晓的,并且不会报错。
此外,我们用一致性(consistency )来代替完整性。
让我们来假设一个应用程序执行操作序列的顺序始终是正确的,如果应用程序正确执行了操作序列,那么DBMS做了什么工作呢。
首先,事实上正确的执行操作序列会暂时的破坏数据的一致性,奇怪的是,这是正常的。一个老套但是又清晰的例子是从一个账户向另外一个账户转账:一致性规则看起来像这样子:转账的过程不会改变账户上的资金总额(这种规则很难在SQL中来指定为一个完整性约束,因此,需要在程序中来指定这个约束,对DBMS来说则是不可见的)。一次转账分为两个部分:从一个账户上扣钱,然后在另外一个账户上加钱。第一个操作破坏了数据的一致性,第二个操作又恢复了数据的一致性。
好的实践是在完整性约束上来实现这个规则
如果第一个操作被执行了但是第二个操作没有被执行呢?事实上,不用多说:第二个操作可能会发生电力故障、服务崩溃或者除以了0等情况导致未能执行。很明显这时候一致性被破坏了,这是不被允许的。通常在应用层面解决这种问题是很麻烦的。然而,幸运的是,没有必要在应用层面解决这些问题:它是数据库的工作。为了能够做到这一点,DBMS必须知道两个操作是一个不可分割的一个整体。这就是事务了。
事情变得有趣了:DBMS知道这些操作组成了一个事务,它通过确保事务的原子性来维护一致性,并且能够在不知道具体的一致性规则下就能够做到这一点。
还有更加微妙的一点:几个互相分离的事务,它们分别运行正常,但是一起运行的话就会失败。这是因为操作的顺序被混合了:你不能假设一个事务中的所有操作先被运行,然后再去运行另一个事务的所有操作。
关于同时运行的另一点说明。事务可以在一个具有多核处理器以及多个磁盘的系统上同时运行,但是同样的在分时运行模式下的操作系统内也同样存在:例如在一个时间片内运行一个事务,然后在另一个时间周期内运行另外一个事务。有时候会用并行(concurrent )来说明这个。
当分别正常运行的事务一起运行会出异常时,被称为并行运行异常。
举一个简单的例子:如果一个应用程序希望从数据库中获取到正确的数据,它至少不能看到其它未提交的事务的数据。否则,你不仅不能得到一致性的数据,还可能看到数据库中未出现的数据(如果事务被取消了)。这种称之为脏读。
还有一些其它的更加复杂的情况,我们稍后会讲到。
当然不能禁止并发执行,否则还谈什么性能。但是又不能获取到不正确的数据。
DBMS再一次来拯救了。你可以让事务执行的就像它们是一个接着一个运行的一样,换句话说,彼此隔离。事实上,DBMS可以混合执行操作,但是可以确保并行运行的结果和顺序执行的结果是一致的。这就消除了可能出现的异常情况。
事务是运行应用程序指定的一系列操作将数据库从一个状态转移到另外一个状态(一致性consistency),前提是事务已经完成(原子性atomicity),并且不受其它事务的影响(隔离性isolation)
这个定义结合了ACID的前三个字母,它们之间的关系很密切,只讲其中一个的话是没有意义的。事实上,也很难将D(持久性durability)分离开来讲。的确,如果数据库在崩溃的时候,仍有一些未提交的修改,这时候就需要做一些事情来恢复数据的一致性。
但是实现事务的隔离是一项很难的技术,并且会降低系统的吞吐量。因此,在实践中,经常会使用弱化的隔离,这种隔离级别可以避免一些问题,但是不是所有的都可以避免。这意味着确保数据一致性的部分工作将有应用程序来负责了。因为这个原因,所以去理解数据库使用的是哪种隔离级别就非常重要了:它能够保证什么,不能够保证什么,以及在这种条件下如何能够写出正确的代码。
SQL标准中关于隔离的四种级别有很长的描述。这些级别定义了在事务在这种级别下同时运行时,允许哪些异常不允许哪些异常。因此,在讨论级别时,有必要去了解一下异常。
需要强调的是,我们这部分讲的是标准,就是理论,实践在很大程度上是基于这种理论的,但同时与理论也会有一定的出入。因此,这里的所有的例子都是根据理论推测的。他们在客户的账户上使用同样的操作:这个具有示范性的意义,但是与现实的银行业务所使用的方式毫无关系。
我们首先从丢失更新开始讲。当两个事务同时读取表中的同一行数据,然后一个事务更新了这一行数据,然后第二个事务也更新了同样的行,但是没有考虑到第一个事务更新的结果,就会发生这种异常。
举个例子,两个事务都想要给同一个账户增加100元。第一个事务读取了当前的余额(1000元),然后第二个事务读取了同样的值(1000元)。第一个事务将余额(1000元)增加了100然后写到了数据库中。第二个事务做了同样的操作,将1000元增加了100元然后写入到了数据库中。最后,客户损失了100元。
SQL标准不允许在任何隔离级别下丢失更新。
脏读是我们前面已经提到过的东西。当一个事务读取到另一个事务还未提交的修改时,会发生这种异常。
例如,第一个事务将所有的钱从客户的一个账户转移到另外一个账户,但是没有提交修改。另外一个事务读取账户余额,得到了余额时0元,然后拒绝让客户提取现金。这时候第一个事务被终止了撤销了修改,因此这个余额0就是数据库中从来不存在的一个值。
SQL标准中允许在未提交读级别下发生脏读。
不可重复读通常发生在当一个事务读取同一行数据两次,在这两次之间,第二个事务修改并提交了这行数据,导致第一个事务在第二次读取时得到了一个不同的结果。
例如,让一致性规则来禁止客户的余额变成负数。第一个事务想要从用户的账户中扣除100元。它检查了当前的值,得到了1000,然后决定开始扣除。同时,第二个事务将余额修改为了0并提交了修改。如果第一个事务再次检查余额,它就会得到0(但是它已经决定扣除了,因此这个账户就成了负数)。
SQL规范中允许在未提交读级别和提交读级别出现不可重复读
当一个事务根据相同的条件读取了一组数据两次,然后在两次读取之间,第二个事务增加了一些符合条件的数据。然后第一个事务第二次将会读取到和第一次读取的不同的数据集。
例如,让我们用一致性规则来禁止客户有超过三个以上的账户。第一个事务想要建立一个新账户,检查了客户当前账户的数量是2,然后决定开设新账户。同时,第二个事务同样为这个客户开设了一个新账户并且提交了修改。如果第一个事务重新检查账户的数量,它就变成了3(由于第一个事务已经决定为客户创建新账户了,此时账户的数目就变成了4)。
SQL标砖允许在未提交读、提交读和可重复读级别发生幻读。
SQL标准定义了另外一个级别-串行。这种级别不允许任何异常。这不等同于禁止丢失更新、脏读、不可重复读、幻读,而是不允许全部异常。
问题是,已知的异常情况要比标砖中列出的多得多,而且还有很多未知的异常情况。
串行级别必须能够阻止所有的异常。它意味在这个级别,应用程序的开发者完全不需要考虑并发问题。如果事务能够分别按照操作的序列正确运行,那么它们在并发运行的时候也是正常的。
最后我们提供一个表格来表明什么样的级别下允许什么样的异常。为了清晰起见,我们增加了SQL标准中没有提到的最后一列。
丢失更新 | 脏读 | 不可重读读 | 幻读 | 其它异常 | |
---|---|---|---|---|---|
未提交读 | - | 允许 | 允许 | 允许 | 允许 |
提交读 | - | - | 允许 | 允许 | 允许 |
可重复读 | - | - | - | 允许 | 允许 |
串行 | - | - | - | - | - |
为什么SQL标准中要在众多的异常中突出这些异常呢?
似乎没有人确切的知道这个。但是有可能的是在那个时候,实践领先于理论。其它的异常也不是随便能想到的。
此外,隔离被认为是要建立在锁上的。广泛使用的两阶段锁定协议(2PL)背后的想法是,在执行过程中,事务锁定它要处理的行,然后在完成以后释放锁。简单的说,一个事务使用的锁越多,与其它事务的隔离就越好。但是系统的性能也会受到很大的影响,因为事务不是一起工作了,而是要排队等待处理相同的行。
我的感觉是,仅仅是锁的数量,就能说明不同的隔离级别的差异
如果一个事务锁定了要修改的行,使其不能够被别的事务更新,但是可以读取,我们就到了未提交读级别。不会丢失更新,但是能够读取到未提交的数据。
如果一个事务锁定了要修改的行,使其不能够被别的事务更新和读取,我们就到了提交读级别。不能够读到未提交的数据,但是能够读到不同的值。
如果一个事务锁定了既要读取又要更新的行,使其不能够被别的事务更新和读取,我们就到了可重复读级别。每次读取返回同样的值。
但是在串行级别会存在一个问题:不能锁定一个没有存在的行。因此,幻读仍然可能存在。另外一个事务可能会增加(但是不能删除)一条符合查询条件的行,并且这行记录在重新查询到时候会被查询到。
因此,实现串行级别,通常的锁是不够的-你需要锁定条件(谓词)而不是行。这样的锁被称为谓词锁。到目前为止,这种锁还没有在任何系统中实现。
随着时间的推移,事务管理中的基于锁的协议逐渐被基于快照隔离的协议替换。它的思想是每一个事务都在某个时间点一致的数据快照上工作。只有那些在创建快照前提交的变化才会进入到快照。
这种隔离避免了脏读。尽管可以在PostgreSQL中指定未提交读,但是其工作方式和提交读是一样的。因此,后面我们也不会提到未提交读这个级别。
PostgreSQL 实现了这个协议的多版本变体。多版本并发的思想是同一行的多个版本可以在DBMS中共存。这允许你使用现有的版本来建立一个数据快照,并且使用更少的锁。实际上,只有对同一行的后续修改才会被锁定。所有的其它操作都是并行的:写事务不会锁定只读事务,只读事务不会去锁定任何东西。
通过使用数据快照,PostgreSQL中的隔离级别比标准要求的更加严格。可重复读级别不仅不允许不可重复读,也不允许幻读(尽管不提供完全隔离),这是在不损失效率的情况下提供的。
丢失更新 | 脏读 | 不可重复读 | 幻读 | 其它异常 | |
---|---|---|---|---|---|
读未提交 | — | — | 允许 | 允许 | 允许 |
提交读 | — | — | 允许 | 允许 | 允许 |
可重复读 | — | — | — | — | 允许 |
串行 | — | — | — | — | — |
我们将在后面的文章中讨论多并发版本是如何实现的,现在我们以用户的眼光来观察这三个隔离级别(如你所知,最有趣的是隐藏在 “其他异常现象 “的背后)。为了做到这个,我们创建一个用户表,Alice和Bob每个人有1000元,但是Bob有两个账户。
1 | CREATE TABLE accounts( |
1 | INSERT INTO accounts VALUES |
很容易证明不能读取脏数据。我们启动一个事务,默认情况下,使用的是提交读级别。
1 | => BEGIN; |
1 | transaction_isolation |
更确切的说,默认级别是由参数控制的,如果需要的话,可以修改它。
1 | => SHOW default_transaction_isolation; |
1 | default_transaction_isolation |
因此,在一个打开的事务中,我们从一个账户中扣钱,但是不提交事务。这个事务将会看到自己的变化。
1 | => UPDATE accounts SET amount = amount - 200 WHERE id = 1; |
1 | id | number | client | amount |
在另一个会话中,我们以提交读级别启动另外一个事务。为了区分两个事务,第二个事务的命令将会增加一个| 来做标记·
在另一个会话中,运行下面的命令
1 | | => BEGIN; |
1 | | id | number | client | amount |
正如期待的那样,看不到另外一个事务的修改。
现在我们来提交第一个事务,然后在第二个事务中重新执行相同的查询
1 | => COMMIT; |
1 | | => SELECT * FROM accounts WHERE client = 'alice'; |
1 | | id | number | client | amount |
1 | | => COMMIT; |
查询获取到了不一样的值,这就是不可重复读异常,是提交读级别允许的。
结论:在一个事务中,你不能基于前一个操作所获取的数据做结论,因为数据会在两个操作之间改变。这里有一个例子,其变体经常在程序代码中出现,以至于被认为是一个典型的反模式
1 | IF (SELECT amount FROM accounts WHERE id = 1) >= 1000 THEN |
在检查和更新的间隔中,其它事务可以以任何方式来改变账户的状态,所以这样的检查就没有保证了。如下所示
1 | IF (SELECT amount FROM accounts WHERE id = 1) >= 1000 THEN |
不要自欺欺人地认为这种巧合不会发生–它肯定会发生。
如何正确的编写代码呢?下面有几个选项:
不要写代码
这不是玩笑。例如,这种情况,可以用完整性约束来检查
ALTER TABLE accounts ADD CHECK amount >= 0;
现在就不需要检查了,只需要运行这个操作。如果有必要,可以处理试图违反完整性约束时出现的异常
使用单条SQL语句
一致性问题出现在在两个操作的间隔期间另外一个完成的事务改变了数据。如果只有一个操作,就没有时间间隔了。
PostgreSQL有足够的技术可以用一条SQL语句解决复杂的问题。我们可以关注一下常见的表表达式(CTE),其余可以使用 INSERT/UPDATE/DELETE语句。另外也可以使用NSERT ON CONFLICT语句,实现了这样的逻辑:“插入,如果记录存在了就更新”
自定义锁
最后的办法时对所有的必要的行(SELECT FOR UPDATE) 甚至整个表(LOCK TABLE)手动设置一个独占锁,这个是有效的,但是会损失多版本并发控制带来的好处。一些操作将会被顺序执行,而不是并行执行。
下面是查询获得结果的各个必经阶段
应用程序和postgres服务器建立连接。应用程序提交查询到服务器然后等待服务器返回结果
解析阶段parse stage检查通过应用程序查询提交的查询,检查语法是否正确,然后创建查询树
重写系统rewrite system采用解析阶段创建的查询树,然后从存储在系统目录里查找可以应用的规则。它执行规则主题中给出的转换。
重写系统中的一个应用是视图的实现。每当有一个针对视图的查询,重写系统会根据视图的定义将用户的查询修改为基本表的查询。
计划器/优化器planner/optimizer采用查询树去创建一个将要输入给执行器的查询计划。它先创建所有能达到查询目的的可能路径。例如,如果有一个关系上的索引被查询到,会有两条可以扫描的路径。一种是简单的顺序扫描,另外一种是使用索引的扫描。然后,会评估每条路径的花费去选择代价最小的那条路径。代价最小的那条路径就会被展开成一个完整的可供执行器执行计划。
执行器递归查询树上的步骤并以计划展示的方式来检索行。执行器使用存储系统storage system来扫描关系、执行排序、连接、评估质量并最终返回结果。
postgresql是使用一个简单的”process per user” 客户端/服务区模型的系统。在这个模型中,有一个客户端进程刚好被被连接到一个服务器进程上(即一个客户端进程都有一个对应的服务器进程)。由于我们无法知道会建立多少个客户端,因此,必须存在一个主进程,每一次有链接建立的时候,主进程都会创建一个对应的服务器进程。这个主进程叫做postgres,负责监听TCP/IP端口来接受连接。每当postgres检测到一个新连接的时候,就会生成一个新的服务器进程。服务器任务在并发执行期间互相通过信号量(sempahore)和共享内存(shared memory)来确保数据的完整性。
客户端进程可以是任何一个使用标准postgres协议的程序。许多客户端都是基于C语言的libpq库,也有一些独立实现的协议,例如jdbc驱动。
一旦连接建立,客户端进程就可以发送查询到后端。查询使用纯文本被发送,客户端也不做解析。服务端解析查询、创建执行计划、执行执行计划和通过建立的连接向客户端返回结果。
解析阶段包含两个部分
解析器必须检查纯文本的查询语句的语法是否正确。如果语法是正确的,那么解析树就会被建立和向后传递,否则就返回一个错误。解析器和词法分析器lexer使用了众所周知的unix工具bison和flex。
lexer在scan.l中被定义,负责去识别标记符(例如SQL关键字)。对于每一个被发现关键字和标识符,都会产生一个token传递给解析器
解析器在gram.y中被定义,由一系列的语法规则和每当触发规则时执行的动作组成。这些动作所对应的代码被使用来构建解析树。
scan.l用程序flex转换成C源码文件,gram.y用bison转换成gram.c文件。在执行完转换以后,可以使用普通的C编译器来创建解析器。不要对生成的C文件进行任何修改,因为它们会在下一次调用flex和bison时被覆盖。
gram.y中的内容超出了这个文档的内容。如果想要了解里面的语法规则,有许多书和文档可以参考。
解析阶段创建的解析树仅仅使用关于SQL语法结构的固定规则。它没有去查询系统目录,因此无法了解要求的操作具体语义。在解析完成后,转换过程采用parser返回的树作为输入,并进行必要的语义解释来了解那些表、函数、操作符被引用。然后建立一个存储了这些信息的查询树。
一个关于将原始解析(即创建解析树)和 语义分析分来的理由是系统目录查询只能在一个事务里去做,我们不希望接收一个查询字符串后就立即开启一个事务。原始解析阶段足以识别事务控制命令(例如BEGIN,ROLLBACK等),然后不需要进一步的解析就可以被正确的执行。一旦知道的是处理的是一个实际的查询(例如SELECT或者UPDATE),如果不在事务中就可以开启一个事务了。只有这样,才能调用转换过程。
个人理解,由于查询系统目录必须要开启一个事务,开启事务又需要有对应的开销。但是不是所有的查询都需要去查询系统目录,例如,如果是一个BEGIN或者ROLLBACK这样的语句,转换过程也就不需要去查询系统目录了。一旦返现执行的语句是SELECL或者UPDATE这些实际的查询,那么就需要查询系统目录来找到对应的表、函数、操作符的引用,此时再开启事务也不迟。综上,将两者分开来,可以减小事务的粒度。
被转换过程创建的查询树大部分和原始解析树很相似,但是有很多细节上的不同。例如,一个FuncCall节点在解析树上代表一个从语法上看起来像一个函数调用的东西。它或许被转换为一个FuncExpr节点或者Aggref节点取决于引用的名字对应的是一个普通的函数或者是一个聚合函数。此外,关于字段和表达式结果对应的实际数据类型的信息也会被添加到查询树上。
PostreSQL支持一个强大的规则系统rule system来规范视图和模糊视图更新。通常PostreSQL的规则系统有两个实现组成
查询重写在第40章有一些细节的讨论,这个地方就不再详细讲。我们将会仅指出重写系统的输入和输出都是查询树,也就是说,树中语义细节的表示形式和级别没有变化。重写可以被认为是宏拓展的一种形式。
计划器/优化器的任务是创建一个最佳的执行计划。一个给出的SQL查询(进一步的,一个查询树)可以用很多种方式被执行,每一个都能得到相同的结果。如果在计算上可行,查询优化器将会评估这些可能的执行计划,最终被选择出一个被期待运行最快的计划。
在一些情况中,检查每一个可能的计划将会花费大量的时间和内存空间。尤其是一个查询包含了大量的连接操作符。为了在一个合理的时间内去判断一个合适(不一定最优)的查询计划,当连接符的数量超过阈值的时候,PostgreSQL会使用Genetic Query Optimizer 。
计划器的搜索程序实际上工作时候的数据结构叫做路径paths,是一个计划的缩减表示,里面仅包含一个计划器需要做出决定的信息。在决定了代价最小的路径后,一个完整的计划树被构建然后被传递给执行系统。这代表期待的执行计划已经有了足够的信息让执行器去运行它。本章的剩下部分将会忽略路径和计划的不同。
计划/优化器通过扫描每一个查询中使用的每一个独立的关系来生成计划。可能的计划取决于每一个关系中可用的索引。总有一个可能是在一个关系上进行顺序扫描,因此,一个顺序扫描的计划总是被建立。假设一个关系上定义了一个索引,然后查询包含了限制关系relation.attribute OPR constant
(即属性 操作符 常量值,例如 where a = 1),如果relation.attribute匹配一个B-tree索引的一个key,OPR是索引的操作符列表中的一个的话,另一个使用索引来扫描关系的计划也会被创建。如果有更多的索引存在而且查询中的限制也匹配索引中的key的话,更多的计划会被考虑。索引扫描计划也为那些具有ORDER BY条件相匹配的查询或者可能对合并连接有用的书讯生成计划。
如果一个查询要求连接一个或更多的关系,在找到所有扫描单个关系的计划之后,将会考虑连接关系的计划。有三个可用的策略
当查询涉及两个以上的关系时,最终结果必须由一个树状的连接步骤建立,每个步骤有两个输入。计划器测试不同的可能的连接序列去发现代价最小的一个。
如果一个查询使用了比geqo_threshold少的关系,就会使用穷尽的搜索来找到最好的连接序列。计划器会优先考虑在where存在的对应关系的连接(比如 where rel1.attr1 = rel2.attr2)。没有其它任何选择的情况下,才考虑没有连接条件的连接组,就是指两个没有任何连接条件的关系。计划器为每个联接对生成所有可能的计划,并选择(估计)代价最小的一个
放超过geqo_threshold的值以后,连接序列通过启发式heuristics被决定。
最终的计划树由基本关系上的顺序或者索引扫描组成,加上所需的嵌套循环、合并、哈希连接节点,以及辅助的步骤,例如排序节点和聚合函数计算节点。大部分的计划节点类型有额外的能力去做选择(丢弃不符合条件的行)和投影(根据给定的列值计算出一个派生的列集,即在需要时对标量表达式进行评估)。计划器的一个责任是将FROM上的选择条件和计算要求的输出表达式附加到计划树的最合适的节点
执行器采用被计划器/优化器创建的计划,递归的处理并提取出要求的行的结果。这本质上是一种需求拉管道机制。每一次计划节点被调用,它必须传递一到多行,或者报告已经传递完行。
为了提供一个实际的例子,考虑顶部节点是MergeJoin节点。在进行任何合并之前,必须先获取两行(每个子计划一个)。所以执行器递归的调用它自己去处理子计划(从左树附加的子计划开始)。新的顶部节点(左侧子计划的顶部节点)假如是一个排序节点,需要再次递归以获取一个新的输入行。Sort的子节点获取是一个seqScan节点,代表从表中的实际读取。这个节点的执行导致执行器从表中读取一行数据返回给调用节点。Sort节点将会重复调用它的子节点直到获取所有需要排序的行。当所有的行被获取完后,Sort节点开始执行排序,最终返回它的第一条输出行,即按顺序排列的第一个。它保留剩下的行,以便在相应后续的请求时可以按排列顺序交付他们
MergeJoin以相同的方法从右计划中取出第一行。然后比较两行来看是否可以连接。如果可以的话,它就会给调用者返回一个连接行。在下一次调用(如果当前的两行不能够连接,那就立即),它进行到当前表的下一行或者另外一个表的下一行(取决于比较的结果),再次检查匹配。最终,其中的一个子计划执行完毕,MergeJoin节点返回NULL去指示不能再形成更多的连接行了
复杂的查询可以涉及到多个层次的计划节点,但是基本方法时一样的。每一个节点在被调用的时候,计算和返回它的下一个输出行。每一个节点也负责执行任何被附加在计划器上的选择或者投影表达式。
执行器机制被使用来计算四个基本的SQL查询:SELECT … INSERT、UPDATE和DELETE。对于SELECT,顶级的执行器只需要把计划树返回的每一行数据发送到客户端。INSERT … SELECT,UPDATE和DELETE是一种在特殊的叫做ModifyTable的顶级计划节点之下的SELECT。
SELECT … INSERT输出结果给ModifyTable去插入。对于UPDATE,计划器安排每一个计算出的包括所有更新的字段的值的行,加上原始目标行的TIP(tuple ID或者行ID),这个数据被传递给ModifyTable被使用来创建一个新的被更新的行,然后标记旧的行被删除。对于DELETE,计划实际只返回TID,然后ModifyTable简单的使用TID去查找每一个目标行,然后标记删除。
一个简单的INSERT…VALUES命令创建一个由单个Result节点组成的简单计划树,该树仅计算一个结果行,并将结果行发给ModifyResult去插入数据。
]]>下面的安装过程基于干净的Ubuntu20.04系统,其它linux系统安装的步骤应该也大同小异。
首先更新一下系统中的软件包
1 | apt update -y |
然后下载postgres的最新代码
1 | git clone https://github.com/postgres/postgres.git |
安装编译代码所需要的依赖,目前主要是下面几个
1 | sudo apt install -y zlib1g-dev libreadline-dev gcc libc6-dev |
依赖安装成功以后,到postgres源码目录中运行编译的命令
1 | ./configure |
编译contrib文件夹
1 | cd contrib |
用root权限创建用户
1 | adduser postgres |
然后创建数据库文件夹
1 | mkdir -p /usr/local/pgsql/data |
切换到postgres用户,并初始化数据库文件夹以及启动数据库
1 | su postgres |
看到下面的输出说明启动成功
1 | waiting for server to start.... done |
切换回root用户,连接数据库
1 | export PATH=$PATH:/usr/local/pgsql/bin #这一步可以配置到~/.bashrc来使其永久生效 |
创建测试数据库
1 | postgres=# create database testdb; |
回到postgres源码的/contrib目录下,下载gevel
1 | git clone git://sigaev.ru/gevel |
进入到gevel并编译安装
1 | cd gevel |
这时候编译会报错
报错信息中也告诉应该怎么修改了,根据错误信息来修改
主要修改的地方是 gevel.c的1660行和2004行,都是将BTreeInnerTupleGetDownLink修改为BTreeTupleGetDownLink
修改完后重新编译和安装gevel
1 | make USE_PGXS=1 |
这时候仍然会有出错信息,如下图所示
无视此信息,将此目录下编译出的的gevel.so复制到/usr/local/pgsql/lib目录下
1 | cp gevel.so /usr/local/pgsql/lib |
然后运行下面命令在testdb数据库中创建相应的函数
1 | psql -U postgres -d testdb < gevel.sql |
看到下面的输出,说明安装成功了
1 | SET |
最后我们来测试一下gevel是否生效
连接到testdb数据库,运行下面命令创建表,再插入测试数据后建立gist索引
1 | create table reservations(during tsrange); |
然后查看索引的内部结构
1 | select level, a from gist_print('reservations_during_idx') as t(level int, valid bool, a tsrange); |
可以看到索引的内部信息了
]]>原文: Indexes in PostgreSQL — 6 (SP-GiST)
系列文章索引
我们已经讨论了PostgreSQL的索引引擎、访问方法接口以及三个索引方法:哈希索引、B树和GiST。这篇文章中,我们将讨论SP-GiST。
首先,关于这个名字要说一下。名字中的”GiST”暗指其和同名的GiST索引方法有相似的地方。相似的地方是指:两者都是通用搜索树,都提供了一个构建不同索引方法的通用框架。
“SP”主要是指空间分区(space partitioning)。这里的空间我们通常会理解成我们习惯上所认为的空间,比如说一个二维的平面。但是我们将看到这里的空间可以是任何值域。
SP-GiST适用于那些空间可以被递归的分割成非相交区域的结构(这一点和GiST不同)。这类树包含四叉树(comprises quadtrees)、K维树 (k-D trees)以及弧形树( radix trees)。
所以,SP-GiST的思想是将值域划分为彼此不覆盖的子域,而子域有可以被进一步分割。像这样的分割会导致不平衡树(不像B树和GiST树)
不相交的特性简化了插入和搜索过程的策略。另一方面,作为一项规则,诱导的树是低分枝的。例如,一个四叉树通常有四个子节点(不像B树。子节点的数目可以是上百个)和更高的深度。像这样的树很适合在内存中工作,但是索引是存储在磁盘上的。因此为了减少IO操作的数量,必须将节点打包成页,但是高效的做到这一点并不容易。此外,由于分支的深度不同,在索引中找到不同数值的时间也可能不同。
这个访问方法和GiST一样,做一些底层的工作(并发访问、锁、日志以及纯粹的搜索算法),同时提供了专门的简化接口,来方便增加新的数据和分区算法。
SP-GiST内部的节点存储了对子节点的引用;可以给每一个引用定义一个标签。此外,一个内部节点可以存储一个被叫做prefix的值。实际上这个值不强制一定是前缀,它可以是任意一个满足所有子节点的谓词。
SP-GiST的叶子节点存储了一个索引类型的值和一个对表行的引用。被索引的数据本身(索引键)可以作为这个索引类型的值,但不是必须的:一个缩短类型的值会被存储。
此外,叶子节点可以被分组为列表。因此,一个内部节点不仅可以引用一个值,也可以引用整个列表
请注意,叶子节点的前缀、标签以及值都有自己的数据类型,是彼此独立的。
和GiST一样,为搜索定义的主要函数是一致性函数。这个函数在一个树节点上被调用,并且返回一组子节点,这些子节点的值和搜索的谓语一致。对于一个子节点,一致性函数决定了这个节点中的索引值是否符合搜索谓词。
搜索从根节点开始,一致性函数决定有必要访问的子节点。该算法会在找到的每个子节点上重复运行。该搜索是深度优先的。
在物理层面,索引节点被打包成页,以便在IO层面能让节点的工作更有效率。请注意,一个页面可以包含内部节点和叶子节点,但是不能同时包含。
四叉树可以索引平面上的点。思想是根据中心点将平面划分为四个象限,这颗树的分支深度可以是不同的,其深度取决于当前象限内点的密度。
下面是一个样例数据,其数据来源是用openflights.org上的数据对 demo database做了扩充。
首先将平面划分为四个象限
然后再分割每个象限
接着分割,直到得到最后的分区
让我们来提供一个例子来说明更多的细节。这种情况下我们的分区像下图所示。
象限的编号如左图所示。为了明确期间,子节点的象限编号也是按照这个顺序。这种例子下,一个可能的索引结构如下图所示。每个内部节点最多可以引用四个节点,每个引用都可以用象限的编号作为标签。但是实际的实现中没有标签,因为存储四个引用的固定数组会更加方便。
1 | postgres=# create table points(p point); |
在这个例子中,默认使用的操作符类是”quad_point_ops”,其包含下面这些操作
1 | postgres=# select amop.amopopr::regoperator, amop.amopstrategy |
1 | amopopr | amopstrategy |
作为例子,我们来看查询select * from points where p >^ point ‘(2,7)’将被怎么执行
我们从根节点开始应用一致性函数去选择哪些节点可以被下沉。对于操作符>^,函数将点(2,7)与中心点(4,4)比较以后,选择出可能包含寻求的点数的象限,这个例子中,是第一和第四象限。
在第一象限的节点中,我们再次使用一致性函数来选择出可能的子节点。中心点是(6,6),我们需要再次寻找子节点的第一象限和第四象限。
第一象限的叶子节点是包含了(8,6)和(7,8)的列表,其中只有(7,8)是符合要求的。对第四象限的引用都是空的。
(4,4)节点的第四象限也是空的,因此搜素就完成了。
1 | postgres=# set enable_seqscan = off; |
1 | QUERY PLAN |
我们可以使用“gevel”插件来看SP-GiST索引的内部情况。
让我们以示例数据为例
1 | demo=# create index airports_coordinates_quad_idx on airports_ml using spgist(coordinates); |
首先,我们获取索引的一些统计信息
1 | demo=# select * from spgist_stats('airports_coordinates_quad_idx'); |
1 | spgist_stats |
然后,我们输出索引数本身
1 | demo=# select tid, n, level, tid_ptr, prefix, leaf_value |
1 | tid | n | level | tid_ptr | prefix | leaf_value |
但是请记住,”spgist_print”输出的不是叶子节点中的所有值,而是列表中的第一个。因此,这里展示的是索引的结构而不是全部的内容。
同样是平面上的点,我们也可以使用另外一种分区方法。
让我们先画一条横着的线经过第一个被索引的点,这条直线将平面分割维上下两个部分。下一个索引的点就处在这两个部分的一个。通过第二个点,画一条竖着的线,将这个部分再次划分为左右两个部分。然后我们再画一条水平的线通过下一个点,再画一条垂直的线经过下一个点,如此反复。
这样建立的树所有内部的节点都只有两个子节点的引用,这两个引用都可以通向下一个内部节点或者通向叶子节点的列表。
这种方法可以很容易的推广到K维空间,因此在文献中,这些树也被称为k-dimensional (k-D trees)
以机场的例子解释该方法
首先将平面划分为上下两个部分
然后将每个部分分割为左右两个部分
为了使用这样的分区,我们需要在创建索引的时候明确的指定操作符类“kd_point_ops”
1 | postgres=# create index points_kd_idx on points using spgist(p kd_point_ops); |
这个操作符类包含和默认的”quad_point_ops”完全相同的操作符。
在查看树结构的时候,我们需要考虑到,在这种情况下,前缀只是一个坐标,而不是一个点。
因为是垂直或者水平分割的,因此前缀只需要一个数字就可以表示。
1 | demo=# select tid, n, level, tid_ptr, prefix, leaf_value |
1 | tid | n | level | tid_ptr | prefix | leaf_value |
我们也可以用SP-GiST来为字符串实现一个弧度树。弧度树的思想是要索引的字符串并不完全存储在一个叶子节点中,而是通过串联的存储在这个节点以上直到根部的值来获取到。
假设我们需要索引几个地址:”postgrespro.ru”,”postgrespro.com”,”postgresql.org”和”planet.postgresql.org”
1 | postgres=# create table sites(url text); |
树的结构如下所示:
数字的叶子节点存储了所有子节点的前缀。例如,在”stgres”这个节点的子节点中的值都以 “p” + “o” + “stgres”为前缀。
与四叉树不同的是,每一个指向子节点的指针中都使用了额外的一个字符(更精确的来说是两个字节,但是这并不重要)作为标签。
“text_ops “运算符类支持类似B-树的运算符: “equal”, “greater”, and “less”:
1 | postgres=# select amop.amopopr::regoperator, amop.amopstrategy |
1 | amopopr | amopstrategy |
带标记的运算符的区别在于,他们操作的是字节而不是字符。
有的时候,用弧度树的形式表示可能会比B树更加紧凑的,因为数值不是完全存储的,而是通过树的下降来重建字符串。
考虑到这个查询:select * from sites where url like ‘postgresp%ru’。它可以通过索引来执行
1 | postgres=# set enable_seqscan = off; |
1 | QUERY PLAN |
实际上索引是用来寻找大于等于postgresp并且小于postgresq的值,然后从结果中选择匹配的值。
首先一致性函数必须决定出要下降到p的哪个子节点。显然,需要下降到”p “+”o “+”stgres”
对于 “stgres “节点,需要再次调用一致性函数来检查。 这次下降到”postgres “+”p “+”ro”。
对于”ro.”节点以及所有的叶子节点,一致性函数将返回TRUE,因此索引方法返回了两个值:”postgrespro.com “和 “postgrespro.ru”。然后在过滤阶段从他们中选择匹配的值。
在查看树状结构的时候,需要将数据类型考虑在内
1 | postgres=# select * from spgist_print('sites_url_idx') as t( |
原文中并没有,下面的结果是自己运行后结果。 但是由于原文中插入的数据过少,展示出的结果只有一个层级,因此我在里面又插入了几千条记录得出的内部树结构。node_label的值是字符的ASCII码,例如112是p、 119 是 w。
1 | tid | allthesame | n | level | tid_ptr | prefix | node_label | leaf_value |
然后来看一下 SP-GiST 的属性
1 | select a.amname, p.name, pg_indexam_has_property(a.oid,p.name) |
1 | amname | name | pg_indexam_has_property |
spgist不支持用来排序也不能做唯一约束。此外,这样的索引也不能建立在多个字段上,但是允许用来做唯一约束。
下列的索引属性是可以用的
1 | select p.name, pg_index_has_property('sites_url_idx'::regclass,p.name) |
1 | name | pg_index_has_property |
这里与GiST的区别在于,SP-GiST不支持聚类。
最后,下面是列属性
1 | select p.name, |
1 | name | pg_index_column_has_property |
不支持排序,这是可以预见的。到目前为止,SP-GiST中还没有用于搜索近邻的距离运算符。最有可能的是,这个功能将在未来得到支持。
SP-GiST可以用于纯索引扫描,至少对于所讨论的运算符类来说是这样。正如我们所看到的,在某些情况下,索引值被明确地存储在叶子节点中,而在其他情况下,这些值是在树的下降过程中逐部分重建的。
为了不使情况复杂化,到目前为止我们还没有提到NULL。从索引属性中可以看出,NULL是被支持的
1 | postgres=# explain (costs off) |
1 | QUERY PLAN |
然而,NULL对于SP-GiST来说是一个陌生的东西。所有来自 “spgist “操作符类的操作符都必须是严格的:只要操作符的任何参数是NULL,就必须返回NULL。方法本身确保了这一点。NULL不会被传递给操作符。
但是,为了使用只扫描索引的访问方法,NULL必须被存储在索引中。它们被存储在在一个单独的有自己根的树中。
除了点和为了字符串使用的弧度树以外,还有一些其它的基实现了SP-GiST的访问方法。
本文所使用的云服务器配置为 1核CPU 1G内存 25G SSD硬盘,其中SSD硬盘用测试软件测试的顺序写的速率是120M每秒。
即使是一个有经验的人也会犯一些会造成很大麻烦的失误。因此在应用本篇文章的建议之前,请牢记以下几点
这里有3个应该经常查看的配置。如果没有调整的话,可能很快就出现问题。
innodb_buffer_pool_size 这是在安装使用InnoDB时应该首先查看的一个配置。缓冲池是数据和索引被缓存的地方:让它尽可能大来确保数据库在大多数读操作的情况下使用的是内存而不是磁盘。通常的设置是5-6GB(8GB内存)。20-25GB(32GB内存),100-120GB(128GB内存)
innodb_log_file_size redo日志的大小。redo日志用来确保写入是快速而且持久的,并且能够从故障中恢复。在MySQL5.1之前,这个很难调整,因为这个值设置大的话可以保证性能,但是设置的小一点可以进行更快的故障恢复。幸运的是,在MySQL5.5版本以后,崩溃恢复的性能有了很大的提高,即可以同时拥有好的写入性能和快速的故障恢复。在MySQL5.5以后,总的redo日志被限制为了4GB(默认有两个日志文件)。这个限制在MySQL5.6以后被取消。
设置成innodb_log_file_size = 512M应该就可以提供足够的写入空间。如果可以预测到应用是写入密集型的,而且使用的是MySQL5.6,那么应该从innodb_log_file_size=4G开始设置。
注:InnoDB处理的是内存中的数据,即对数据的修改和更新都是在内存中进行,因此,为了能够在数据库崩溃或者系统故障之后可以恢复数据,需要将更改记录到redo日志中。因此,简单来说,redo日志越大,InnoDB能够在内存中操作的更改块就越多,如果内存的更改块的大小(checkpoint_age)快要超过redo日志的大小(异步点)的时候,InnoDB就会尝试刷新内存中的块来防止checkpoint_age超过innodb_log_file_size,频繁的刷新会影响到数据库的查询性能。显然,如果redo日志的大小设置的比较小的话,而且写入更新操作比较多的话,checkpoint_age就可能会频繁的到达异步点。不过redo日志也不是设置的越大越好,设置的大的话,会导致数据库的崩溃恢复时间变长。详情请参阅InnoDB Flushing: Theory and solutions
max_connections 如果经常遇到了“太多链接”错误,那就是max_connections设置的太小了。如果程序没有正确的关闭与数据库的连接,那么这个情况就会经常发生。这时候可能需要设置比默认的151大的多的连接数。 max_connections设置的很高(比如1000或者更多),会造成服务器的无响应。在应用程序级别设置连接池或者线程池可能会对此又帮助。
我们先来看一下数据库默认的innodb_buffer_pool_size设置
从上图中,可以看到默认的innodb_buffer_pool_size是128M,默认的innodb_log_file_size是48M。
先用下面的脚本插入一些准备数据。
1 | sysbench --db-driver=mysql --time=300 --threads=10 --report-interval=1 --mysql-host=127.0.0.1 --mysql-port=3306 --mysql-user=root --mysql-password=123456 --mysql-db=test --tables=20 --table_size=1000000 oltp_read_write --db-ps-mode=disable prepare |
然后用下面命令来对数据库进行测试
1 | sysbench --db-driver=mysql --time=60 --threads=10 --report-interval=1 --mysql-host=127.0.0.1 --mysql-port=3306 --mysql-user=root --mysql-password=123456 --mysql-db=test --tables=20 --table_size=1000000 oltp_read_write --db-ps-mode=disable run |
测试结果如下图
从上图中,大概可以看出每秒的事务数是150,每秒的查询数是3000左右。
然后我们修改innodb_buffer_pool_size的值,再次进行测试
1 | set global innodb_buffer_pool_size = 1073741824; // 设置为了1G |
这个地方一定要小心,如果设置的太大,mysql可能会吃掉所有的可用内存,然后导致机器卡死。
测试结果如下图
修改以后每秒事务数是220左右,每秒查询数大概在4500左右。与修改之前相比,性能已经有了50%的提升。
这里只说明怎么设置一个最佳的值。一个粗略的经验法则是日志的大小应该足够大,最多能容得下一个小时的日志。基于这个经验法则来配置innodb_log_file_size的大小。在服务器运行的高峰时期执行下面的查询。
1 | mysql> pager grep sequence |
根据log sequence number的值来计算每小时写入日志的大小
1 | mysql> select (10887232498 - 10823613000) / 1024 / 1024 * 60 as MB_per_Hour; |
然后计算出距离这个值最近的一个2的n次方的值4096,由于默认有两个日志文件,所以实际设置的值应该是一半,即2048M,即如下设置
1 | innodb_log_file_size=2048M |
本节参考连接How to calculate a good InnoDB log file size
InnoDB在MySQL5.5以后是默认的存储引擎,使用的频率也要比其它的存储引擎要多。这就是为什么要小心配置的原因。
innodb_file_per_table 这个参数用来告诉InnoDB是将表和索引存储在一个共享的表空间中(innodb_file_per_table = OFF
) ,还是将每个表存储在一个.ibd文件中 (innodb_file_per_table= ON
)。每个表对应一个文件,可以在表被删除、截断或者重建的时候回收空间。对于一些例如压缩之类的高级属性也需要这个特性。当然,这个对提升数据库的性能没有任何帮助。只有在表的数目特别多的时候(例如超过1万),可能才不需要每个表一个文件。
在MySQL5.6版本以后,这个开关默认是ON。
innodb_flush_log_at_trx_commit 这个值默认是1代表完全符合ACID特性,此时对于数据库的每一个更新操作都会记录到redo日志并刷新到磁盘上,因此也是最可靠的,当然,在具有慢速磁盘的系统上可能会有大量开销。在数据安全性比较重要时,设置成1是最合适的选择,例如在master节点上。将其设置为2代表每次提交事务都会记录日志,但是同步到磁盘的操作则是每秒一次,对于副本节点来说,这是一个很好的选择。 将其设置为0,则是每秒写一次日志和刷新到磁盘中,此时数据库的速度会更加快,但是系统崩溃的时候也更容易丢失数据。
innodb_flush_method 这个设置了怎么把数据和磁盘刷新到硬盘上的方法,当您拥有带有电池保护的回写缓存的硬件 RAID 控制器时,这个值一般设置为 O_DIRECT。其它情况下设置为fdatasync(默认值)。可以用sysbench工具来测试选择哪一个值更好一点。
innodb_log_buffer_size:这是尚未提交的事务的缓冲区大小。 默认值 (1MB) 通常是没问题的,但是一旦您的事务中包括了大的 blob/文本字段,缓冲区就会很快被填满并触发额外的 I/O 负载。 查看 Innodb_log_waits 状态变量,如果它不是 0,则增加 innodb_log_buffer_size的值。
1 | mysql> SHOW GLOBAL STATUS where variable_name like 'Innodb_log_waits'; |
query_cache_size 查询缓存是一个众所周知的瓶颈,即使在中等程度的并发下,也可能会被影响到。因此最好将其设置为0来禁用查询缓存。如果使用了查询缓存,而且将其设置的比较大,在并发比较高的情况下,查询缓存区中可能会有很多的缓存数据,此时对于表中任何插入、更新、删除都会导致查询缓存中的相关数据被锁定并刷新。查询缓存越大,用于锁定、刷新的时间就越多,此时,大多数命中查询缓存的查询可能都会卡在“等待查询缓存锁定”状态中,从而影响了数据库的吞吐量。
log_bin:如果您希望服务器充当复制主机,则必须启用二进制日志记录。如果是这样,不要忘记将server_id为唯一值。当您希望能够进行时间点恢复时,它对于单个服务器也很有用:恢复最新的备份并应用二进制日志。一旦创建,二进制日志文件将永远保留。因此,如果您不想用完磁盘空间,您应该使用PURGE BINARY LOGS清除旧文件或设置expire_logs_days
为指定在多少天后自动清除日志。
但是,二进制日志不是没有代价的,因此如果您不需要(例如在不是主服务器的副本上),建议将其禁用。
skip_name_resolve: 当客户端连接时,服务器会进行主机名解析,当 DNS 很慢时,建立连接也会变慢。因此,建议启动服务器skip-name-resolve
以禁用所有 DNS 查找。唯一的限制是这些GRANT
语句只能使用 IP 地址,因此在将此设置添加到现有系统时要小心。
原文: Indexes in PostgreSQL — 5(GiST)
系列文章索引
在先前的文章中,我们讨论了PostgreSQL的索引引擎、访问方法接口,和两个方法问方法:哈希索引和B树索引。这篇文章,我们将讨论Gist索引。
GiST是“generalized search tree(广义搜索树)”的缩写。这是一个平衡的搜索树,和前面讨论的”b-tree”类似。
它们有什么不同呢?”btree”索引和比较语义严格有关:支持“大于”、“小于”、“等于”操作符就是它全部的能力了(但是非常有用)。然后,许多现在的数据库存储一些没有比较意义的数据类型:地理数据、文本文档以及图像。
GiST索引方法可以处理这些数据类型。它允许定义一个将任意类型的数据分布在平衡树上的规则,和定义一来让部分操作符能够使用的方法。例如,GiST索引可以“容纳”R-tree树来支持空间数据的相对位置运算符(在左侧、在右侧、包含等等),或者“容纳”RD-tree来支持相交和包含操作符。
由于PostgreSQL的拓展性,可以在数据库中从头开始创建一个新的访问方法:为此,必须实现和索引引擎相关的接口。但是不仅需要考虑好索引的逻辑,还需要实现将数据结构映射到页、高效的实现锁机制以及支持预写日志(WAL)。所有的这些都需要开发者具有很高的技能水平和很多的人力。GiST通过接管一些底层的问题并提供自己的接口来简化任务:即那些和技术无关而和应用领域相关的功能。从这个意义上来说,我们可以把GiST看作是构建新的访问方法的框架。
Gist是一个索引框架,可以通过实现这个框架来支持任意的数据类型以及对应的任意操作符。PostgreSQL中内置了一些实现,来支持空间地理对象等对象。
GiST是一个由节点页面组成的高度平衡树。节点中由若干个索引行组成。
叶子节点中的每一行,通常情况下,包含了一些谓词(布尔表达式)和一个指向表行(TID)的引用。索引的数据必须满足这些谓语。
内部节点中的每一行包含了一个谓语和指向下一个节点的引用,所有子树上被索引的数据都必须满足这个谓语。换句话说,内部节点的谓语涵盖了所有子节点行的谓语。GiST这一重要的特定取代了B树的简单排序。
在GiST树中搜索使用专门的一致性函数”consistent”)-由接口定义的一个函数,用来支持操作符族中的其支持的操作符。
一致性函数究竟是什么?因为Gist不像Btree那样具有大于、小于之类的严格语义,对操作符的支持也更加灵活,即可以支持任何功能的操作符,比如包含、相交、在左侧、在右侧等等。具体怎么做呢?在B-tree中提到了策略编号,在B树访问方法中,策略编号对应的操作符的功能是提前规定好的,例如1对于小于、2对应小于等于。但是在Gist访问方法中,并没有规定哪个策略编号对应什么样的功能,是一种更加灵活的方式:可以在操作符类中随意定义一个策略编号对应一个操作符(这告诉了索引引擎这个操作符是支持索引的),然后索引引擎在条件中遇到这个操作符的话,就会通过一致性函数来调用这个操作符对应的函数。具体怎么调用呢,我们看一下一致性函数的参数列表(internal, data_type, smallint, oid, internal),第一个参数是索引树上的值,第二个data_type就是条件谓词(索引字段 操作符 表达式)中的表达式,第三个smallint类型的参数是条件谓词(索引字段 操作符 表达式)中操作符在操作符类中对应的策略编号,最后一个internal则是recheck标记。也就是说,索引引擎在树上搜索的时候,会把当前搜索的值和表达式以及操作符对应的策略编号传递给我们自定义的一致性函数,然后我们自定义的一致性函数在内部就可以根据传递来的策略编号来调用此操作符对应的函数。下面是一个一致性函数的例子
1 | Datum my_consistent(PG_FUNCTION_ARGS) |
一致性函数会在索引的每一行上调用,来判断这一行上的谓词是否和搜索的谓词(“索引字段 操作符 表达式”)一致。对于内部节点的行,这个函数实际上决定是否需要下降到子树,对于叶节点的行,这个函数决定索引的数据是否匹配谓词。
搜索从根节点开始,一致性函数负责去找出进入到哪些子节点是有意义的,哪些没有意义。然后会在每一个进入的子节点上重复这个算法。如果到了叶节点,一致性算法选择的行会作为结果的一部分返回。
搜索时深度优先的:算法首先尝试到达叶节点。这样在可能的情况下尽早的返回第一个结果。(或许用户只对几个值而不是全部值感兴趣)。
让我们再次强调一下,一致性函数不需要和“大于”、“小于”、“等于”操作符有关系。一致性函数的语义可能是完全不同的,因此不能假定索引以某种顺序返回值。
我们不会讨论GiST中插入值和删除值的算法:这些操作需要更多的接口函数。然而有一个重要的点:当一个值被插入索引的时候,这个值在树中的位置会被选择为一个尽可能少的拓展父节点的行的谓词的位置(理想情况下是不拓展)。但是当一个值被删除的时候,其父节点的行的谓词不会被减少。父节点谓词减少只会在这样的情况下发生:一个页面分裂成了两个(当一个页面没有足够的空间去插入一个新的索引)或者索引被重新构建(通过REINDEX或者VACUUM FULL)。因此,Gist的效率对于频繁变化的数据会随着时间的推移而降低。
下面,我们将会讲到一些数据类型的列子和GiST有用的属性
我们将通过平面上的点的示例来说明上述的内容(我们也可以为其它空间实体构建相似的索引)。常规的B树不适合这种数据类型,因为在点上没有定义比较操作符。
R-tree的想法是把平面分割成矩形,这些矩形覆盖了所有被索引的点。一个索引行存储一个矩形,谓词像这样定义:“寻找的点在给定的矩形内”
R-tree的根节点有几个最大的矩形组成(可能相交)。子节点是一些父节点中小的矩形,覆盖了所有的索引点。
理论上,叶子节点必须包含被索引的点,但是又因为所有索引行上的数据类型必须相同,因此,叶子节点上存储的也是矩形,但是“收缩”成了一个点。
为了可视化这种结构,我们提供了包含3个层级的R树的图片。这些点是飞机的坐标。
第一级:可以看到有两个很大的矩形
第二级:大的矩形被分割成了小的区域
第三级: 每个矩形包含了可以放进一个索引页数量的点。
现在我们先考虑一个非常简单的“一层”例子
1 | create table points(p point); |
随着拆分,索引的结构看起来如下图所示:
这个索引可以被使用来加速这样的查询,例如:“找到包含在一个给定矩形中的所有点”。这个条件可以被写成这样:p <@ box ‘(2,1),(6,3)’ (“points_ops”族中的操作符<@意思是包含在)
1 | set enable_seqscan = off; |
1 | QUERY PLAN |
操作符(索引字段 <@ 表达式,索引字段是一个点,表达式是一个矩形)的一致性函数这样被定义。对于一个内部行,如果其矩形和表达式定义的矩形相交,就返回”yes”。对于叶子行,如果这个点(一个收缩的矩形)被表达式定义的矩形包括就返回”yes”。
搜索首先从根节点开始。矩形(2,1)-(7,4)和(1,1)-(6,3)相交,但是不和(5,5)-(8,8)相交,因此,不需要下降到第二个子树上。
在到达叶节点以后,我们遍历这个节点包含的三个值,并返回其中的(3,2)和(6,3)作为结果。
1 | select * from points where p <@ box '(2,1),(7,4)'; |
1 | p |
不幸的是,没有像“pageinspect”这样的可以查看GiST索引的拓展。但是有一个没有包含在发行版中的拓展”gevel”,点击这里查看安装文档
如果上面的都做好了,就会有三个有用的函数。首先,我们可以得到一些分析:
1 | select * from gist_stat('airports_coordinates_idx'); |
1 | gist_stat |
从上面的信息可以看出 索引的大小是690个页,一共有四级:根节点和两个内部节点,第四级是叶子节点。
实际上,8000个点的索引会比这个小很多。这里为了看的更清楚一点,创建索引的时候指定了10%的填充因子(fillfactor)。
什么是填充因子。填充因子的范围是10到100,在创建表和索引的时候都可以指定填充因子,其作用是指定对页的填充程度。对于表来说,当填充因子设置为50的时候,那么插入操作只会把表页填充到50%,然后每页上的剩余空间将保留用于放置更新的行。这就让UPDATE操作可以把更新的行版本和原来的行放在同一个页面上,从而提高更新的效率,即不需要再去找别的页面了。对于索引来说,当填充因子设置为50的时候,那么创建期间和向右侧拓展索引期间,叶页也只会填充到50%。如果填充满的话,大量的更新操作会造成索引的频繁拆分,进而影响索引的效率。设置的比较小的话,更新操作会填充页中的剩余部分,不会造成频繁的拆分索引。参考资料1、参考资料2
然后,我们来看索引树的输出
1 | select * from gist_tree('airports_coordinates_idx'); |
1 | gist_tree |
最后,我们我们可以输出存储在索引行中的数据。注意以下细微差别:必须要将函数返回的结果强制转换为我们需要的类型。在这种情况下,数据类型是”BOX”。例如,下面是最顶层的5行数据
1 | select level, a from gist_print('airports_coordinates_idx') as t(level int, valid bool, a box) where level = 1; |
1 | level | a |
前面讨论的操作符符(例如谓词p <@ box ‘(2,1),(7,4)’中的<@)可以被称为搜索操作符,因为它们在查询中都是被指定为搜索条件
还有另外一种类型的操作符:排序操作符。它们被用来指定在ORDER BY子句中的排序顺序,而不是传统的指定列名。下面是一个这样查询的例子。
1 | select * from points order by p <-> point'(4,7)' limit 2; |
1 | p |
这里的表达式 p <-> point ‘(4,7)’ 使用的是排序操作符<->,用来表示一个到另外一个的距离。这个查询的意思就是返回距离点(4,7)最近的两个点。这样的搜索被称为k-NN (最近邻居搜索)。为了支持这种类型的搜索,访问方法必须定义一个额外的距离函数,距离操作符也必须被包括在对应的操作符类中。下面的查询显示了运算符和它们的类型(“s”:搜索,”o”:排序)。
1 | select amop.amopopr::regoperator, amop.amoppurpose, amop.amopstrategy |
1 | amopopr | amoppurpose | amopstrategy |
这里显示了策略的数量,并解释了它们的含义。很明显,这里的策略比”btree”的要多,其中只有一部分被点使用。对于其它数据类型,也可以定义不同的策略。
为索引的元素调用的距离函数必须计算出从表达式中定义的值到给定的元素的距离。对于一个叶子元素来说,这只是到索引值的距离。对于内部元素来说,这个函数必须返回到子叶子节点的最小距离。由于遍历所有的子行非常昂贵,这个函数允许乐观的低估距离,但是会增加搜索的代价。然而,绝对不能高估距离,这会影响索引的工作。
距离函数可以返回任何一种可以排序的数据类型的值(为了排序值,PostgreSQL将使用”btree”访问方法的排序语义)
对于平面上的点,(x1,y1) 、(x2,y2)两点之间的距离等于横轴坐标轴差和纵轴坐标轴差的平方和的平方根。从点到边界的距离可以被认为这个点到此矩形的最小距离。如果这个点在矩形内,则这个距离是0。计算这个点很容易,不需要去遍历子点,而且这个距离肯定不会大于到任意一个子点的距离。
让我们来看一下上面查询的搜索算法。
搜索从根节点开始。这个节点包括两个矩形。到 (1,1)-(6,3)的距离是4,到(5,5)-(8,8)是1.0。
子点按照距离增加的顺序遍历,这样,我们首先下降到距离最近的节点的子节点,并计算到子节点中每个点的距离。
这些信息足够去返回最开始的两个点(5,5)和(7,8)。既然我们知道到位于(1,1)-(6,3)中的点的距离为4.0甚至更大,因此我们不需要下降到第一个子节点去搜索。但是如果我们想要找到第三个点呢?
1 | select * from points order by p <-> point '(4,7)' limit 3; |
1 | p |
尽管第二个子节点包含了这些所有的点,但是我们还是需要查看第一个子节点。因为到(8,6)的距离是4.1,这个比到(1,1)-(6,3)的距离小(4.0<4.1),说明(1,1)-(6,3)的子节点中可能包含更小的点。
对于内部行来说,这个例子说明了对距离函数的要求。因为低估了到第二行的距离(实际为4.5),因此降低了搜索的效率(算法不必要的去检查了额外的节点),但是没有破坏算法的正确性。
直到最近,GiST还是唯一能够处理排序运算符的访问方法
2019年3月,PostgreSQL12中为SP-GiST添加了k-NN的支持。B-tree的补丁仍然在开发中。
另外使用GiST访问方法的例子是intervals索引,例如时间区间(tsrange)。和上面不同的是内部节点不是外包矩形而是外包区间,
我们来看一个简单的例子。我们出租一个小屋,并且把出租的区间存在表里。
1 | create table reservations(during tsrange); |
下面的查询可以使用索引来加速,例如
1 | select * from reservations where during && '[2017-01-01, 2017-04-01)'; |
1 | during |
1 | explain (costs off) select * from reservations where during && '[2017-01-01, 2017-04-01)'; |
1 | QUERY PLAN |
因为测试的数据量比较小,需要设置 set enable_seqscan = off
&& 操作符表示的是两个区间存在交集。因此查询需要返回所有和查询的区间存在相交的区间。对于这样的操作符,一致性函数需要确定给定的区间是否和内部或者叶子节点中的行相交。
但是请注意,返回的结构并没有特定的顺序。尽管区间也支持比较操作符,我们也可以对区间使用btree索引。但是对于上面使用了GiST这个例子而言,是没有排序的效果的(因为也没有指定Order By)。
1 | select amop.amopopr::regoperator, amop.amoppurpose, amop.amopstrategy |
1 | amopopr | amoppurpose | amopstrategy |
不过由于区间(interval)支持B树,我们在查询的时候可以指定order by来指定返回的结果按照指定的顺序返回。这是因为PostgreSQL在执行Order by的时候,会自动使用Btree中定义的比较语义。换言之,如果一个数据类型不支持Btree索引,它就不支持Order by子句。详情点击这个查看官方说明
我们可以验证一下本篇文章中提到的两种数据类型 point 和 interval
1 | select distinct am.amname from pg_opclass opc, pg_opfamily opf, pg_am am |
1 | amname |
1 | select distinct am.amname from pg_opclass opc, pg_opfamily opf, pg_am am |
1 | amname |
从上面结果中可以看出 interval支持btree,而point不支持btree
1 | select * from reservations where during && '[2017-01-01, 2017-04-01)' order by during desc |
1 | during |
1 | select * from points where p <@ box '(2,1),(7,4)' order by p; |
1 | ERROR: could not identify an ordering operator for type point |
我们同样使用gevel来查看内部结构。只需要换一下数据类型就可以了
1 | select level, a from gist_print('reservations_during_idx') as t(level int, valid bool, a tsrange); |
1 | level | a |
GiST可以用来支持排除约束
排除约束可以确定任何两个表行中的特定字段在对选择的操作符不会彼此对应。例如,如果是”equals“操作符,这就是唯一约束:对于指定的字段,可以确保这个字段不会重复。
排序约束是被索引支持的,就像唯一约束一样。我们可以选择任意的操作符,只要满足下面的条件:
下面是一些操作符的可以用的策略列表(请记住,操作符可以是不同的名字,并且不一定被所有的数据类型支持)
请注意,我们可以在排除约束的时候使用equals操作符,但是这是不合理的,因为,unique约束更加有效。这也是为什么在B-tree中我们没有讲排除约束的原因
下面我们提供一个例子,这个例子中,使用了&&排他约束,即不允许插入相交的数据
1 | alter table reservations add exclude using gist(during with &&); |
创建了排他约束后,我们增加一行数据
1 | insert into reservations(during) values ('[2017-06-10, 2017-06-13)'); |
然后,我们在尝试插入一条和这条数据相交的数据,就会出错
1 | ERROR: conflicting key value violates exclusion constraint "reservations_during_excl" |
让我们考虑一种更复杂的情况。我们的业务扩大了,我们有好几间小屋可以出租了
1 | alter table reservations add house_no integer default 1; |
由于不同小屋的出租区间是可以相交的,因此我们需要更改一下前面的排除约束,把小屋的编号也要考虑进去。问题在于,数值类型不支持GiST索引
1 | alter table reservations add exclude using gist(during with &&, house_no with =); |
1 | ERROR: data type integer has no default operator class for access method "gist" |
在这种情况下,”btree_gist“拓展就很有用了。GiST既然可以支持任何操作符,那么我们为什么不教它支持”更大“、”更小“和”相等“操作符呢?
1 | create extension btree_gist; |
1 | ALTER TABLE |
现在我们仍然无法预定第一间小屋的已经预定日期的相交日期
1 | insert into reservations(during, house_no) values ('[2017-05-15, 2017-06-15)', 1); |
1 | ERROR: conflicting key value violates exclusion constraint "reservations_during_house_no_excl" |
但是我们可以预定另外一间小屋的
1 | insert into reservations(during, house_no) values ('[2017-05-15, 2017-06-15)', 2); |
注意,尽管GiST也可以实现”更大“、”更小“、”相等“等比较操作符,但是这个是B树擅长的东西。因此,只有在十分有需要使用GiST的时候,才值得使用这种技术。
我们先介绍一下PostgreSQL的全文检索(如果你已经知道了的话,可以跳过这个章节)
全文检索的目的是从文档中搜索那些匹配查询的文档(如果有很多匹配的文档,那么找到最合适的文档也很重要。但是这里我们不会讲到这一点)。
对于搜索目的,一个文档被转换为一个特殊类型”tsvector”,其中包含了”词条“和它们在文档中的位置。词条是被转换的适合用来搜索的词。例如,单词通常被转换为小写,词尾也被截断(即复数形式的es或者被动形式的ed)。
1 | select to_tsvector('There was a crooked man, and he walked a crooked mile'); |
1 | to_tsvector |
我们可以看到有一些词语( “there”, “was”, “a”, “and”, “he”,这些词语也叫做stop words)被丢弃了,因为它们出现的太频繁,搜索它们没有意义。这些转换是可以配置的,但是那是另外一说了。
搜索条件是另外一种类型”tsquery”。大概来说,查询条件是由几个词条用”&”、”|”、”!”等连接词连接起来的。我们也可以使用括号来表示优先级。
1 | select to_tsquery('man & (walking | running)'); |
1 | to_tsquery |
全文搜索只有一种操作符”@@”可以使用
1 | select to_tsvector('There was a crooked man, and he walked a crooked mile') @@ to_tsquery('man & (walking | running)'); |
1 | ?column? |
1 | select to_tsvector('There was a crooked man, and he walked a crooked mile') @@ to_tsquery('man & (going | running)'); |
1 | ?column? |
这些信息已经足够我们讲这一篇的内容了。在GIN索引中我们将会更加深入的讲全文检索。
对于全文检索来说,首先,表中需要存储一个”tsvector”类型的字段(避免每次搜索都需要执行代价昂贵的转换),其次,这一行需要建立一个索引。一个有用的索引就是GiST。
1 | create table ts(doc text, doc_tsv tsvector); |
当然,在最后一步,把这一过程由触发器来做更加合适。
1 | select * from ts; |
1 | -[ RECORD 1 ]---------------------------------------------------- |
怎么组织索引呢?不能直接使用R树,因为没有办法为文档类型定义一个边界。但是可以对此方法来做一些修改来使其能够应用到集合上,即所谓的RD-tree。在这里,一个集合被理解为是一个词组的集合,但是一般情况下,一个集合可以被表示为任意的东西。
RD树的一个思想是用边界集来代表一个边界矩形,即一个包含子集中所有元素的集合。
一个重要的问题出现了,怎么在索引行中表示集合。一个最直接的想法是列举集合中的所有元素。如下图所示
针对这个例子,用来获取满足doc_tsv @@ to_tsquery(‘sit’)条件的记录,我们可以下沉到那些只包含”sit”词组的节点。
这种表示方法有一个很明显的问题。一个文档中的词组的数量可以非常的大,因此索引行可以会有一个非常的尺寸来达到Toast模式,这会让索引变得低效。即使每个文档词组都很少,这些词组集合的合集也会非常大:越接近根节点的索引行越大。
这种表示方法对于别的数据类型可能有效,但是不适合全文索引。全文索引使用了另外一种更加紧凑的解决方案-被称之为签名树。使用过Bloom过滤器的人很熟悉这个。
每一个词组都可以表示成这样一个签名:其中一个比特位是1其余为都是0的定长的比特串。其中1的位置取决于这个词组的哈希运算的值。
文档的签名是文档中所有词组的签名OR计算的和。
让我们假设词组的签名如下
1 | could 1000000 |
上面假设签名的长度是7,而词组的数量是11,因此不可避免的会出现哈希碰撞,即两个词组的签名是一致的。
则根据词组的签名,可以计算出文档的签名如下:
1 | Can a sheet slitter slit sheets? 0001101 |
索引树可以被表示为如下形式
这种方法的优点很明显:索引行都具有相同的小尺寸,因此索引是很紧凑的。但是负面影响也很明显:牺牲了准确度。
让我们看一下同样的条件 doc_tsv @@ to_tsquery(‘sit’)。先计算出sit的签名为 0010000,一致性函数必须返回签名中包含这个比特位的所有节点。
比起前面的图,可以看出黄色的节点更多了,这意味着在搜索的过程中更多的节点被检索了,其中包含了一些假阳性(将不符合要求的节点判定为符合要求的节点)的无效节点。在这里,我们也检索到了”whoever”词组,因为这个词组和”sit”词组都有同样的签名。重要的是,这种匹配下不会发生假阴性(将符合要求的节点判定为不符合要求的节点),这意味着,不会错过需要的值。
初次之外,即使是不同的文档也会出现相同的签名。在我们的例子中,就有这样的例子。“I slit a sheet, a sheet I slit”和“I slit sheets”的签名都是0001100。即使叶子索引行中没有存储”tsvector”的值,仍有可能出现假阳性(被错误的命中)。
当然,这种情况下,索引方法可以要求索引引擎去表中重新检查结构,因此对于使用者来说看不到这种假阳性的数据。但是搜索的性能会受到影响。
事实上,在当前的实现中,签名的长度是124个字节而不是例子中的7位,因此上面的例子的出现的问题将会大大的减少。但是在现实中,被索引的文档也会更多。为了有效的减少假阳性的数量,索引的实现变的有些取巧:被索引的”tsvector”如果不是非常大的话(一页的1/16,如果是8KB页的话,则是半kb),”tsvector”将会取代签名,被存储在索引行中。
为了去看在实际数据中索引是如何工作的,我们用打包的”pgsql-hackers”邮件做示例。这个例子中使用的版本包含了带有发送日期、主题、作者和文本的356125条信息
1 | fts=# select * from mail_messages order by sent limit 1; |
1 | -[ RECORD 1 ]------------------------------------------------------------------------ |
增加和填充tsvector列,并且在此列上创建索引。这里我们将三个值(subject、author、text)增加到一个向量中来表明文档中不一定只有一个字段,可以是包含多个字段的。
1 | fts=# alter table mail_messages add column tsv tsvector; |
1 | NOTICE: word is too long to be indexed |
1 | fts=# create index on mail_messages using gist(tsv); |
正如看到的,有一部分的词因为太大了被丢弃了。但是索引最终被创建了,并且可以在后面的搜索查询中使用到。
1 | fts=# explain (analyze, costs off) |
1 | QUERY PLAN |
可以看到有898行记录匹配查询条件,索引方法通过recheck过滤掉了7859条记录。这表明降低准确度对效率产生了负面影响。
为了分析索引中的内容,我们将再次使用”gevel”拓展:
1 | fts=# select level, a |
1 | level | a |
在索引行中被存储的的”gtsvector”实际上是签名,也可能是”tsvector”。如果是tsvector的话,则会输出其包含的词库的数量,否则就输出签名中真假位的数量。
很明显,在根节点中,签名退化为 “全一”,也就是说,这个索引节点变得没有用,不具备选择性。(还有一个几乎是没有用的,只有四个false位)。
让我们看看GiST访问方法的属性
1 | select a.amname, p.name, pg_indexam_has_property(a.oid,p.name) |
1 | amname | name | pg_indexam_has_property |
Gist不支持排序和唯一约束。但是索引可以建立在多个列上,并且用于排除约束。
下列的索引属性是可以用的
1 | name | pg_index_has_property |
而最有趣的属性是列的属性。有些属性是独立于操作符类的。
1 | name | pg_index_column_has_property |
(不支持排序;索引不能用来搜索一个数组;支持NULL)。
对于剩余的两个属性,”distance_orderable” 和 “returnable”,将取决于使用的操作符类。例如,对于点:
1 | name | pg_index_column_has_property |
第一个属性说明距离操作符是可以用来搜索最近的邻居的。第二个属性说明索引可以用来使用index-only扫描。虽然叶子索引行存储的是矩形而不是一个点,但是访问方法仍然可以返回所需要的东西。
对于 间隔(intervals) 类型
1 | name | pg_index_column_has_property |
间隔类型没有定义距离函数,因此不能搜索最近的邻居。
对于全文搜索
1 | name | pg_index_column_has_property |
因为叶子行可以指包含签名而不包含数据本身,因此不支持仅索引扫描。这个损失是微小的,一般来说并不会对tsvector感兴趣。
最后,我们将提到除了上面讨论的几种类型以外的支持GiST的数据类型
在标准类型中,有一个为了IP地址使用的inet。剩下的类型都是通过数据库插件来支持的。
]]>原文: Indexes in PostgreSQL — 4(Btree)
系列文章索引
我们已经讨论了PostgreSQL中的索引引擎,访问方法接口和其中的一个访问方法-哈希索引。这一章我们将讨论B树索引,这是最流行和最广泛使用的索引。这篇文章很长,请耐心阅读。
B树索引类型,作为”btree”访问方法的实现,适合那些可以排序的数据。就是说,必须为这些数据类型定义“大于”、“大于等于”、“小于”、“小于等于”、“等于”这些操作符。注意有时候可以对相同的数据进行不同的排序,这让我们回到了操作符族的概念。
B树的索引行被打包在页面里。在叶子节点,这些行包含的数据有索引的键和指向表中行数据的指针。在内部节点中,每一行都包含了索引的子页面和这个子页面的最小值。
B树有几个重要的特征:
下面是一个简单的索引一个integer字段的例子
索引最开始的一个页面是metapage,这个页面链接到了根节点。内部的节点位于根节点的下方,叶子节点位于最低一行。叶子节点向下的箭头代表指向表行(TID)的引用。
我们先来看在树中用”索引字段=表达式”来搜索一个指定值。这里,我们来搜索49
搜索是从根节点开始的,我们需要决定下降到哪个子节点。根据根节点的值是(4,32,64),我们可以计算出子节点的范围。49大于32小于64,因此我们需要下降到第2个子节点。下一步,同样的过程被递归执行,知道我们到达可以获取到TIDs的叶子节点。
实际上会有很多细节会让这些看起来简单的问题复杂化。例如,索引可以包含非唯一的键值,甚至可以有很多相等的键值,这些相等的键值可以超过一个页面。回到我们的例子,看起来我们应该从内部的49节点下降到这个值引用的叶子节点。但是,从图中可以看出,这个会让我们跳过前一个叶子节点的49。因此,一旦我们发现内部节点有一个精确匹配的值,我们必须从左下降一个位置,然后在底层中从左到右查看索引行来搜索对应的键值。
另一个复杂因素是在搜索过程中,其它进程可能会更改数据:可以重新构建树,可以拆分页面。所有算法都是为了这些能够并发执行而设计的,这些更改不会互相干扰,也尽可能的不使用额外的锁。但是我们这里不展开说这个。
这里简单提一下:正如前面所说的,PostgreSQL的B树内一个叶子节点都有以双向链接来链接两个相邻的页面。同时,B树中的每个页面都记录了这个页面的最大值。还以上面的搜索43为例,假设说当前搜索到了第二层,也就是(32,43,49)这个节点,按照前面所讲述的那样,应该下降到(43,49)这个节点。但是假设在下降的过程中,(32,43,49)节点发生了分裂,分裂成了(32,43,47,49),对应的子节点也分裂成了(32,42)、(43,46)、(47,48)、(49,62),这个时候,搜索下降到了(43,46)节点上,这个节点上没有49这个键值。但是由于这个节点上记录了分裂后的最大键值46,因此可以比较要搜索的值和这个页面上的最大值46,因为49比46大,因此就可以知道这个节点发生了分裂,搜索就可以沿着向右的指针去相邻的页面去查找49。
当根据”索引字段<=表达式”或者”索引字段>=表达式”这样的条件进行搜索的时候,我们首先在索引树中根据”索引字段=表达式”来找到对应的表达式的值,然后以相应的的顺序顺着叶子节点遍历到最后。
下图描述了n<=35的过程
“大于”和“小于”搜索的过程和这个差不多,但是会丢弃那个相等的值。
当根据范围条件“表达式1 <= 索引字段 <= 表达式2”搜索的时候,我们会先根据条件“索引字段=表达式1”来找到对应的值,然后顺着叶子节点往下走直到满足“索引字段<=表达式2”为止;反之亦然。
下面描述了 23 <= n <= 64的过程
让我们来看一个查询计划的例子吧。和原来一样,我们使用一个demo数据库,这次我们使用aircrafts_data表。它只包含了9行,因此计划器不会使用索引,因为整个表可以放到一页里。但是为了讲明白我们的目的,这张表还是很有趣的。
1 | select * from aircrafts_data; |
1 | aircraft_code | model | range |
1 | create index on aircrafts_data(range); |
我下载的示例中,原文中使用的是aircrafts表。但是我下载的例子中,aircrafts是视图不是表。
(或者可以精确的用“create index on aircrafts using btree(range)”,但是B-tree是默认的索引)
根据相等搜索:
1 | explain(costs off) select * from aircrafts_data where range = 3000; |
1 | QUERY PLAN |
根据不等式搜索
1 | explain(costs off) select * from aircrafts_data where range < 3000; |
1 | QUERY PLAN |
根据范围搜索
1 | explain(costs off) select * from aircrafts_data where range between 3000 and 5000; |
1 | QUERY PLAN |
让我们再强调一次,对于任何类型的扫描(index、index-only和位图),”btree”访问方法都返回有序的数据。这些在前面的图中都能看出来。
因此,如果表中有一个建立在排序条件上的索引,优化器将会考虑两个选项:索引扫描整个表-这个很容易返回排序的数据;顺序扫描整个表,然后对结果进行排序。
在创建索引的时候,我们可以精确的指定排序的顺序。例如,我们通过下面的方法来按照航班的range降序进行索引
1 | create index on aircrafts_data(range desc); |
在这种情况下,较大的值会出现左侧的树中,较小的树会出现在右侧的树中。如果我们可以从任意一个方向来遍历索引的值,为什么还要指定排序的顺序呢?
我们先来创建一个多列索引
这个地方没有直接使用原文中的例子,因为数据库的版本(原文中是9.6版本,我是用的是最新的13版本)以及Demo的示例数据都有了变化,原文中的运行结果已经无法在新版本中复现。但是下面的例子同样可以说明问题。
1 | create index on aircrafts_data(aircraft_code,range); |
然后我们可以使用这个索引来获取两列降序的数据
1 | select aircraft_code,range from aircrafts_data order by aircraft_code,range; |
1 | aircraft_code | range |
1 | explain(costs on) select aircraft_code,range from aircrafts_data order by aircraft_code,range; |
1 | QUERY PLAN |
我们也可以执行一个查询输出降序的结果
1 | explain(costs on) select aircraft_code,range from aircrafts_data order by aircraft_code desc,range desc; |
1 | QUERY PLAN |
但是如果我们想获取获取一列升序一列降序的数据呢。
1 | explain(costs on) select aircraft_code,range from aircrafts_data order by aircraft_code asc,range desc; |
1 | QUERY PLAN |
注:这个地方和原文中的输出不一致,原文中这个地方的执行计划是顺序扫描,这里是使用了索引查询,然后又进行了排序。
从上面的计划中可以看出,是先执行了索引扫描,因为我们指定的返回顺序和索引中的顺序不一致,因此对结果又重新进行了排序,增加了重新排序的时间成本。
在这种情况下,如果想要加快查询速度,在创建索引的时候可以指定排序的顺序
1 | create index on aircrafts_data(aircraft_code asc,range desc); |
1 | explain(costs on) select aircraft_code,range from aircrafts_data order by aircraft_code desc,range asc; |
1 | QUERY PLAN |
使用多列索引的另一个问题是索引中列的顺序。对于B树来说,这个顺序非常重要。页中的数据将按照第一个字段在前,然后是第二个字段这样的顺序来组织的。
如下图所示,在创建索引的时候指定的是 aircrafts_v(class,model),其B树的内部结构如下图所示
实际上这么小的索引肯定都放在根页面里的,但是这里为了能够清楚一点,将其分布在了几个页面里。
很明显可以看出,像”class = 3”或者”class = 3 and model = ‘Boeing 777-300’”这样的搜索会非常高效。
但是,使用”model = ‘Boeing 777-300’”这样的谓词搜索效率就会降低:从根节点开始,我们无法决定下降到哪一个节点,因此,我们必须全部下降。这不意味着索引不能这么使用-但是这样是有问题的。假如说我们有3个种类的飞机,每个飞机中有大量的型号。那么我们将不得不搜索大约1/3的索引。这可能比全表扫描有效,也可能没有。
如果我们创建索引的时候改变一下顺序,即aircrafts_v(model,class),那么索引就会变成下面的样子。
使用这个索引,谓词”model = ‘Boeing 777-300’”的搜索效率就会大大提升。当然,对于谓词”class = 3”的搜索就没有帮助了。
“btree”访问方法会索引NULL值,同时支持IS NULL和IS NOT NULL
让我们看一下flight表,这个表中有NULL值。
1 | create index on flights(actual_arrival); |
1 | explain(costs off) select * from flights where actual_arrival is null; |
1 | QUERY PLAN |
NULL值位于叶节点的一端或者另一端取决于索引是怎么创建的(NULLS FIRST或者NULLS LAST).如果查询中包含排序,那么这一点就比较重要了:如果SELECT命令在其子句ORDER BY指定的NULL顺序和创建索引指定的NULL顺序一致的话,该索引就能生效了。
在下面的例子中,索引和ORDER子句指定的NULL顺序是一致的,因此索引是生效的。
1 | explain(costs off) select * from flights order by actual_arrival NULLS LAST; |
1 | QUERY PLAN |
这个例子,索引和ORDER子句指定的NULL顺序不一致的,因此索引不能生效。
1 | explain(costs off) select * from flights order by actual_arrival NULLS FIRST; |
1 | QUERY PLAN |
如果上面的语句想使用索引,就必须在创建索引的时候指定NULL顺序
1 | create index flights_nulls_first_idx on flights(actual_arrival NULLS FIRST); |
NULL值需要单独处理时因为NULL是不可排序的,就是说,NULL和其它值的比较的结果是没有定义的。
1 | \pset null NULL |
1 | ?column? |
正如前面所说的,B树的核心就是为了那些可以排序的数据使用的。那么不能排序的NULL值显然是和B树的概念背道而驰。但是,NULL值太重要了,重要的以至于我们不得不专门为NULL值设置例外。
由于可以对NULL进行索引,因此,即使没有任何条件的查询也可以使用索引(因为索引中肯定包含所有的表行的信息)。如果查询的数据需要排序,而索引的顺序和其要获取的排序相同的话,计划员可能会选择使用索引,因为这个就不需要进行单独的排序了。
接下来我们来看一下”Btree”访问方法属性。
1 | amname | name | pg_indexam_has_property |
正如我们看到的,B-tree支持数据排序、唯一性约束-目前只有B树访问方法支持这两个属性。也支持多列索引,其它访问方法可能也支持这个。我们下次将讨论对EXCLUDE约束的支持,这并不是没有原因的。
1 | name | pg_index_has_property |
B树索引支持两种获取值的技术:索引扫描和位图扫描。访问方法可以“向前”或者“向后遍历树”。clusterable的作用在第二节-索引属性有提到。简单来说就是支持根据索引的顺序对指定的表进行物理上的重排序。
1 | name | pg_index_column_has_property |
前4个属性解释了某个特定的列的值支持的排序。在上面的例子中,值支持按升序和nulls_last排序。但是正如前面演示的那样,其它的排序也是可以支持的。
search_array 表示索引支持下面这样的表达式
1 | explain(costs off) select * from aircrafts where aircraft_code in ('733','763','773'); |
1 | QUERY PLAN |
“returnable”属性表示支持仅索引扫描,这是因为索引的行存储索引值本身。这里有必要讲一下基于 B 树的覆盖索引
正如前面讨论的那样,一个覆盖索引包含了查询中需要的值,因此几乎不需要访问表本身。唯一索引也可以用作覆盖索引。
但是假设如果我们想要向唯一索引中添加所需要的额外的列,那么这个新的组合索引可能就不能保证原来的唯一约束了。这时候就需要在同一个列上建立两个索引,一个是唯一索引,用来做完整性约束;另外一个是非唯一索引,用来支持覆盖的。
这一段稍微绕一些。具体来说,就是,我们本来是想保证字段a不能重复,那么就需要对a建立一个唯一索引,这个唯一索引可以保证a不会重复。但是假设我们在查询的时候,需要同时查询a和b,如果这时候我们想使用覆盖索引,那么就需要把b也添加到索引中,即建立一个唯一联合索引。但是这时候a的唯一性就无法保证了。因为此时,(1,2)和(1,3)都是合法的,但是显然,没有保证a的唯一性。为了解决这个问题,就需要对a建立两个索引,一个只有a的用来保证唯一性的索引,一个用来做覆盖索引的非唯一索引。建立两个索引显然增加了维护索引的开销。
在原文作者的公司里,有一个大牛改进了”btree”方法,可以让额外的、非唯一的列可以包含在唯一索引中,这个补丁在PostgreSQL 11的时候被提交。
我目前使用的版本是postgreSQL 13。
下面我们来看一下例子:
1 | \d bookings; |
1 | Table "bookings.bookings" |
在这个表中,有一个常规的B树主键索引。我们新创建一个带有额外列的唯一索引
1 | create unique index bookings_pkey2 on bookings(book_ref) INCLUDE (book_date); |
然后,用我们新创建的索引替换原来的索引
1 | begin; |
1 | \d bookings |
1 | Table "bookings.bookings" |
现在这个索引就可以既作为唯一索引,又可以作为覆盖索引了。例如:
1 | explain(costs off) select book_ref, book_date from bookings where book_ref = '059FC4'; |
1 | QUERY PLAN |
1 | select * from bookings limit 1; |
1 | book_ref | book_date | total_amount |
1 | insert into bookings values('00000F','2018-07-05 00:12:00+00',265700.00); |
1 | ERROR: duplicate key value violates unique constraint "bookings_pkey2" |
感谢Docker可以让我快速启动一个postgresql 10的数据库实例,在这个实例里,测试一下下面的命令
1 | select version() |
1 | version |
1 | create unique index bookings_pkey2 on bookings(book_ref) INCLUDE (book_date); |
1 | ERROR: syntax error at or near "INCLUDE" |
可以看出,向唯一索引中添加额外的索引在10以及10以前的版本中都是不支持的
众所周知,但是也很重要的一个事情是,对于大型表,最好是在没有索引的情况下先加载数据,在加载数据完成后再去创建对应的索引。这样速度不仅快,而且很可能创建的索引也会变小。
这是因为Btree索引树的创建过程比按行把值插入树是一个更加高效的过程。粗略的来说,创建过程会先对表中所有可用的数据进行排序,然后创建这些数据的叶节点,然后内部的页面在这些叶子节点上逐渐向上构建,直到整个树收敛到根部为止。
补充一下新数据插入过程:当一个存在的叶子页面不能放下新的元组,就会有一个新的叶子页面被增加到B树索引中。页面拆分操作会把一部分数据移到新的页面上,从而为原来将要溢出的数据腾出空间。页面拆分也要在父页面上插入新的子页面的连接,从而导致父页面也可能会发生分裂。页面拆分用一种递归的方式向上级联。当根页面也不能放下新的下级连接的时候,就会发生根节点拆分。这将会通过在原来的根页面的上一级创建一个新的根页面来增加一个新的层级。
该过程的速度取决于可用RAM的大小,这个大小取决于“maintenance_work_mem”的限制。对于唯一索引来说,除了“maintenance_work_mem”,还要为其分配”work_mem”大小的内存。
前面我们提到过对于哈希索引来说,PostgreSQL必须直到对于不同的数据类型运用哪个哈希函数。同样的,PostgreSQL也必须直到怎么去比较值,这个被用来排序、分组、合并连接等等。PostgreSQL不会把这些信息绑定在操作符名字上(例如 > < =),因为用户可以定义自己的数据类型,并且可以给不同的操作符对应不同的名字。”btree”访问方法使用的运算符族定义了运算符的名称。
例如,这些比较运算符用于“bool_ops”运算符系列:
1 | select amop.amopopr::regoperator as opfamily_operator, |
1 | opfamily_operator | amopstrategy |
在这里,我们可以看到5个比较运算符。但是正如前面所说的那样,我们不应该依赖它们的名称。但是为了知道每个运算符对应的比较,pg引入了策略编号的概念。每个策略编号对应一个比较
一些操作符族包含多个运算符来实现同一个策略,例如,“integer_ops”为策略1包含了下面的操作符
1 | select amop.amopopr::regoperator as opfamily_operator |
1 | opfamily_operator |
由于这个原因,优化器可以不需要类型转换就可以比较同一个运算符系列中包含的不同类型的值。
这篇文章提供了为创建一个复数类型,并创建其对应的操作符类用来支持该类型数据的排序的示例。这个例子使用的是C语言,在要求效率的情况下,这是一个很合理的选择。但是作为演示目的,下面我们使用纯SQL语句来实现,以便更好的理解比较语义。
我们先创建一个具有实部和虚部的复数类型:
1 | create type complex as (re float, im float); |
然后我们创建一个包含此数据类型的表,然后增加一些数据。
1 | create table numbers(x complex); |
现在问题是,在没有定义怎么排序的情况下,数据库怎么对复数类型进行排序
事实证明,PG已经为我们新的数据类型定义了默认的排序操作符
1 | select * from numbers order by x; |
1 | x |
默认情况下,复合类型的排序是按照其内部字段的顺序进行的:第一个字段先被比较,然后是第2个,一次类推。这个和逐字符比较字符串的方式大致差不多。但是我们可以定义新的排序方式,例如,将复数看作是向量,并按照其模数(长度)进行排序。模数的计算是其坐标平方和的平方根。为了这么做,我们先定义一个函数来计算模数。
1 | create function modulus(a complex) returns float as $$ |
然后我们使用这个辅助函数来定义5个比较运算函数。
1 | create function complex_lt(a complex, b complex) returns boolean as $$ |
然后需要创建对应的运算符。为了说明他们不需要叫 “>” “<”之类的名字(即PostgreSQL不会根据操作符的名字来判断其是不是比较操作),我们为这些操作符起一些奇怪的名字。
1 | create operator #<#(leftarg=complex, rightarg=complex, procedure=complex_lt); |
此时,我们可以比较值了
1 | select (1.0,1.0)::complex #<# (1.0,3.0)::complex; |
除了5个运算符以外,”btree”还必须定义一个辅助函数。如果第一个值小于、等于或者大于第2个值,它必须返回-1、0或者1
1 | create function complex_cmp(a complex, b complex) returns integer as $$ |
辅助函数是访问方法在内部需要使用的方法/函数。例如,在B树上搜索某个值的时候,需要确定下降到当前内部页面的哪个子节点上。就需要用辅助函数来将搜索的值和内部页面上的各个索引值进行比较了。
现在就可以创建操作符类了。(创建操作符类会自动创建一个同名的操作符族)
1 | create operator class complex_ops |
现在排序按照我们期待的那样工作了
1 | select * from numbers order by x; |
1 | x |
当然它也支持Btree索引了。
我们可以使用”pageinspect”拓展来查看B-tree的内部结构
1 | create extension pageinspect; |
1 | select * from bt_metap('ticket_flights_pkey'); |
1 | magic | version | root | level | fastroot | fastlevel | oldest_xact | last_cleanup_num_tuples | allequalimage |
这里面最有趣的就是索引级别了,上百万的数据行只需要2级就可以了(不包含根)
第164个块(页面)的统计信息
1 | select type, live_items, dead_items, avg_item_size, page_size, free_size from bt_page_stats('ticket_flights_pkey',164); |
1 | type | live_items | dead_items | avg_item_size | page_size | free_size |
也可以看到块(页面)内部的数据
1 | select itemoffset, ctid, itemlen, left(data,56) as data from bt_page_items('ticket_flights_pkey',164) limit 5; |
1 | itemoffset | ctid | itemlen | data |
第一行数据和技术相关,指定了这一块所有元素的上限(这个我在本篇文章开头的并发搜索中提到过)。数据本身从第二行开始,即最左边的子节点的数据是163,接着是232,一次往后类推。
现在,遵循良好的传统,阅读代码和README是有意义的。
另外一个很有用的拓展是amcheck,这个插件在pg 10及以上版本可以使用。这个拓展能够检查B树中数据的逻辑一致性,使我们能够提前检测故障。
]]>原文: Indexes in PostgreSQL — 3(Hash)
系列文章索引
第一篇文章讲了Postgesql中的索引引擎,第二篇文章讲了Postgesql中的访问方法的接口,现在我们准备讨论具体的索引种类,让我们从哈希索引开始。
许多现在的编程语言都将哈希表作为一种基本的数据类型。从外部来看,一个哈希表看起来像一个常规的数组,它的索引可以是任何数据类型(例如字符串),而不仅仅是数字。PostgreSQL中的哈希索引的结构与此类似。那么它是怎么工作的呢?
通常,数据类型允许的值的范围是非常大的:例如在一个数据类型为text的列中可以有多少个不同的字符串呢?同时,在某个表的文本列中实际存储了多少个不同的值呢?通常是没有那么多的。
散列的思想是把任意一个数据类型的值和一个小的数字(从0到N-1)关联起来。像这样的关联就叫做哈希函数。通过哈希函数获取的数字可以用来被作为一个常规数组的索引,而这个数组中存放了表的行(TIDS)的引用。这个数组中的元素被称为哈希表桶(hash table bucket)。如果不同的行的索引的键值一样的话,这些行的TID都会被存储在同一个桶中。
哈希函数哈希的结果越均匀越好。但是再好的哈希函数也可能对不同的数据源计算出相同的结果-这个称为哈希碰撞。所以,一个桶可以存储对应不同键值的行的TID,因此,从索引中获取的值也需要被重新检查。
仅仅作为一个例子:我们能想到一个怎么样的字符串的哈希函数。让我们把桶的数量设置为256。然后,我们可以把字符串的第一个字符(这里只考虑单字节的字符)的编码作为桶的编号。这是一个好的哈希函数吗?显然不是。如果所有的字符串都是以相同的字符开始的,那么它们就会全部落在同一个桶里。因此数据就不均匀,所有的值都必须被重新检查,哈希就没有意义了。如果我们把所有字符的编码加起来然后除以256取模呢?这会好得多,但不是最理想的。如果你对PostgreSQL内部的哈希函数还兴趣的话,可以看一下hashfunc.c中hash_any()的定义
让我们回到哈希索引。对于某种数据类型(索引键)的值,我们的任务是快速找到匹配的 TID。
当插入到索引中的时候,我们先通过哈希函数来计算key值。PostgreSQL中的哈希函数总是返回一个integer值,这个范围是 2^32,约等于400万个值。存储桶的数量初始是2,然后会动态的增加来适应数据的大小。桶编号可以使用位运算从哈希编码中计算出来,计算出来的结果就是我们要放置TID的桶。
即哈希函数返回的值不是放置TIDs的桶的编号,而是要通过位运算来计算出桶编号。
但是这还不够,因为匹配不同键的TID可以放进同一桶中。我们该怎么做呢?除了放置TID以外,也可以在桶中存储被索引的键值,但是这会增加索引的大小。为了节省空间,桶里面存储了键值的哈希编码。
当通过索引搜索的时候,我们先通过哈希函数计算出键值的哈希编码,然后通过哈希编码计算出桶编号。现在仍需要遍历这个桶里的内容,然后返回匹配哈希码的TID。由于存储的”Hash code - TID”是有序的,因此这个过程可以高效完成。
然而,两个不同的键值不仅可能出现在同一个桶中,甚至还可能会有相同的哈希编码-没有人能够消除这种冲突。因此,访问方法会要求通用索引引擎重新根据条件来检查每一个TID对应的数据(引擎可以和可见性检查一起执行此操作)。
如果我们从缓冲区缓存管理器的角度而不是从查询计划和执行的角度来看待索引的话,我们就能发现所有的信息和所有的索引行都必须被打包成页。这样的索引页被存储在缓冲区缓存,并且以和表页完全相同的方式从缓冲区中被驱逐。
哈希索引,如图所示,使用四种类型的页面(灰色矩形)
向下箭头表示TID,即对表行的引用。
每次索引增加的时候,PostgreSQL会马上创建上次创建数量的两倍数量的存储桶。为了避免一次分配这种潜在的大量页面,在第10次分配的时候,会分成4次去完成。至于溢出页,它们只在被需要的时候去分配,并且被位图页跟踪。位图页也是根据需要分配的。
注意哈希索引的大小不能减小。如果我们删除了一些索引行,被分配的页面也不会返还给操作系统,但是可以在VACUUMING操作后重新被新数据使用。减小索引大小的唯一选项是通过REINDEX重新构建索引或者使用VACUUM FULL命令。
参考资料
Re-Introducing Hash Indexes in PostgreSQL
让我们看看怎么创建哈希索引。这里我们不再自己创建表,而是使用航空运输演示数据库。这次我们使用的是航班表
1 | create index on flights using hash(flight_no); |
如果postgreSQL是10以前的版本,创建哈希索引会出现下面的警告
1 | WARNING: hash indexes are not WAL-logged and their use is discouraged |
这是因为10以前的版本中,哈希索引不支持WAL日志,这意味着哈希索引在数据库崩溃后不能恢复,必须重新建立索引,因此不鼓励使用。但是10以后的版本解决了这个问题。
1 | explain (costs off) select * from flights where flight_no = 'PG0001'; |
1 | QUERY PLAN |
但是为什么哈希索引几乎从PostgreSQL诞生到9.6版本之前都是无法使用的呢?事情是这样的,数据库管理系统广泛使用散列算法(尤其是哈希连接和哈希分组),然后系统必须知道将哪个散列函数应用到哪个数据类型。但是这种对应关系不是静态的,不能一劳永逸的设置,因为PostgreSQL允许动态添加新的数据类型。这种关系是通过哈希的访问方法被存储起来,表现为辅助函数和操作符族的关联。操作符和操作符族
1 | select opf.opfname as opfamily_name, |
1 | opfamily_name | opfamily_procedure |
尽管上面右列中的函数没有写在文档里,但它们可用于计算对应数据类型值的哈希码。
1 | select hashtext('one'); |
1 | hashtext |
现在我们来看看哈希索引的属性,这里我们还用上一篇讲到的查询方法来查询
1 | amname | name | pg_indexam_has_property |
散列函数不保留顺序关系,从一个哈希值小于另外一个哈希值也无法得出键值的大小关系。因此,哈希索引只能用来进行相等比较:
1 | select opf.opfname AS opfamily_name, |
1 | opfamily_name | opfamily_operator |
因此哈希索引无法返回有序数据,出于同样原因,哈希索引不会操作NULL:等于操作对于NULL值没有意义。
既然哈希索引不存储键值,因此也不能用于index-only
哈希索引也不支持多列索引
从版本 10 开始,可以通过“pageinspect”扩展查看哈希索引内部结构。
1 | create extension pageinspect; |
然后可以从mainPage中查询元组数量和最大桶数
1 | select hash_page_type(get_raw_page('flights_flight_no_idx',0)); |
1 | hash_page_type |
1 | select ntuples, maxbucket |
1 | ntuples | maxbucket |
一个bucket中活元组和死元组(可以被vacuumed)的数量
1 | select hash_page_type(get_raw_page('flights_flight_no_idx',1)); |
1 | hash_page_type |
1 | select live_items, dead_items |
1 | live_items | dead_items |
但是,如果不检查源代码,几乎不可能弄清楚所有可用字段的含义。如果你想这样做,你应该从 README 开始
]]>系列文章索引
在第一篇文章中,我们提到了访问方法必须提供关于它自身的信息。这一篇我们来看一下访问方法接口的内部。
访问方法的所有属性都存储在pg_am表中。我们可以从这个表中列出所有的可用的访问方法
1 | select amname from pg_am; |
1 | amname |
尽管顺序扫描也可以称为是一种访问方法,但是由于历史原因,不在这个表里。
在PostgreSQL 9.5和以前的版本中,每一个属性都是pg_table中一个单独的字段。从9.6版本开始,这些属性需要用一些特殊的函数来查询,而且分为了几个层面。
访问方法层和索引层被分开,是为了着眼于未来,而截至到现在,基于同一个访问方法的所有索引都具有相同的属性。
下面五个属性是索引方法的属性(以btree为例)
1 | select a.amname, p.name, pg_indexam_has_property(a.oid,p.name) |
1 | amname | name | pg_indexam_has_property |
can_order 访问方法允许我们在创建索引的时候,为索引指定排序顺序。(目前仅btree支持)
can_unique 支持唯一约束和主键。 (目前仅btree支持)
can_multi_col 一个索引可以建立在多个列上
can_execude 支持排除约束
can_include 创建索引的时候可以使用INCLUDE条件
下列是索引的属性(以第一篇文章建立的索引作为例子)
1 | select p.name, pg_index_has_property('t_a_idx'::regclass,p.name) |
1 | name | pg_index_has_property |
CLUSTER会对指定的表根据索引信息重新进行物理排序(即磁盘上的存储顺序)。CLUSTER是一次性操作,即在进行CLUSTER操作以后,对之后更新的数据不会重新进行物理上的排序。
在第一篇文章中讲过查看物理排序和逻辑排序相关性的命令
1 | select attname, correlation from pg_stats where tablename = 't'; |
1 | attname | correlation |
由于t表中的顺序是随机插入的,因此字段a的相关性非常的低,几乎没有相关性。但是如果我们对表t用索引t_a_idx执行一下CLUSER命令
1 | cluster t using t_a_idx; |
再次查看相关性
1 | select attname, correlation from pg_stats where tablename = 't'; |
1 | attname | correlation |
可以看到,a的相关性变成了1
最后是列的属性
1 | select p.name, |
1 | name | pg_index_column_has_property |
我们讨论了一些关于属性的细节。某些属性特定于指定的索引方法。我们将会在讲到这些访问方法的时候来讨论这些属性。上面的内容可以在官方文档-函数信息中找到对应的描述
除了所描述的访问方法公开的属性以外,还需要知道访问方法能够支持哪些数据类型和哪些运算符。为此,PostgreSQL引入了操作符类和操作符族的概念。
一个操作符类包含了一组最小的运算符(可能还有辅助函数),用于索引操作特定的数据类型。
操作符类可以被包括在操作符族中。而且,一个常见的操作符类通常包含几个拥有同样语义的操作符类。例如,”integer_ops”族包含了bigint数据类型的”int8_ops”、integer数据类型的”int4_ops”和smallint数据类型的”int2_ops”,这些数据类型具有不同的大小,但是有相同的意义。
1 | select opfname, opcname, opcintype::regtype |
1 | opfname | opcname | opcintype |
另外一个例子,”datetime_ops”族中包含了几个操作日期的操作符类。
1 | select opfname, opcname, opcintype::regtype |
1 | opfname | opcname | opcintype |
一个操作符族可以包含一些额外的操作符来比较不同类型的值。操作符类组成成一个族可以让计划器为那些不同数据类型的谓词来使用索引。操作符族也可以包含一些其它的辅助方法。
在大多数情况下,我们不需要知道关于操作符族和操作符类的信息。通常我们都是创建索引,然后默认使用某一个操作符类。
然而我们可以显式的指定操作符类。这是一个说明什么时候需要明确指定操作符类的例子:在排序规则(COLLATE)和C不同的数据库中,一个常规的索引不支持LIKE操作符。
COLLATE 简单的理解就是可排序数据类型的排序规则,目前内置的可排序数据类型只有text、varchar、char,具体可以点击查看官方文档
1 | show lc_collate; |
1 | lc_collate |
从上面结果中可以看出默认排序规则是en_US.utf8,不是C。 这时候我们在t(b)上创建索引,然后尝试使用LIKE查询
1 | create index on t(b); |
1 | QUERY PLAN |
如果我们在创建索引的时候显式指定操作符类是text_pattern_ops,如下面所示
1 | create index on t(b text_pattern_ops); |
1 | QUERY PLAN |
可以看到这时候LIKE查询也走了索引。
在讲述后面的内容之前,我们先来验证另外一件事情,即排序规则是C的情况下,LIKE查询是否走索引
1 | --- 在字段b的后面加了collate "C"来指定b使用C排序 |
1 | QUERY PLAN |
从查询计划中可以看出,b like ‘A%’被转换为了 b >= ‘A’::text 和 b < ‘B’::text,这一点不难理解,想象一下英文词典上的排序,以A开头的单词是不是排在了A的后面(>=A)和B的前面(<B)
在这篇文章的结尾,我们提供了系统目录中和运算符类以及运算符族直接相关的表的简化图。
这里有上述表中的全部具体描述
系统目录能够让我们在不查看文档的情况下找到许多问题的答案。例如,某种访问方法可以操作哪些数据类型
1 | select opcname, opcintype::regtype |
1 | opcname | opcintype |
一个操作符类包含哪些操作符
1 | select amop.amopopr::regoperator |
1 | amopopr |
系列文章索引
这个系列的文章主要关注PostgreSQL中的索引。
每一个主题都可以从不同的方便来讲述。这里我们将会讨论使用数据库管理系统的开发人员感兴趣的几个问题:有哪些可以用的索引,为什么会有这么多不同种类的索引,怎么使用它们去加快查询速度。这些主题可以用几句话概括,但是我们希望好奇的开发者可以对内部的细节感兴趣,了解这些内部细节可以让你尊重他人的判断,也可以做出自己的决定。
开发一个新类型的索引不在这个系列范围之内,这需要有C语言编程的知识,而且相对于应用开发者,这更倾向于系统开发者的专业知识。出于同样的原因,我们不讨论编程接口,只关注有关怎么使用索引的内容。
在这篇文章中,我们将讨论与DDMS核心相关的通用索引引擎和单个索引访问方法(index access methods)的职责分配。在下篇文章中,我们将讨论访问方法接口(interface of the access method)和一些像类、操作符家族之类的关键概念。在这些虽然长但是很必要的内容后面,我们将会讨论几种索引的结构和应用:Hash、B-tree、GiST、SP-GiST、Gin、RUM、BRIN、Bloom。
在PostgreSQL中,索引是一个主要用来加速数据获取的特殊的数据库对象。它们是辅助结构:每一个索引都会根据数据中的信息被删除或者重新创建。你可能偶尔听过这样的言论:数据库可以在没有索引的情况下工作,只不过运行的慢一点。然而,这并不完全对,索引还用于强制执行一些完整性约束。
到目前为止,PostgreSQL9.6内置了6种不同类型的索引,由于9.6版本的重大变化,还有多种索引可以通过添加拓展来获取。所以期待在将来新的类型的索引吧。
尽管不同类型的索引(也成为访问方法)之间都有差异,但是它们最终都会有一个key(例如被索引的列的值)和包含这个key的行相关联。每一行都被元组TID标识,TID是由文件中块的编号和这一行在这一块中的位置组成的。这说明,根据已知的键和一些相关的信息,我们可以快速读取那些包含我们感兴趣的信息的行,而无需扫描整个表。
这里的 块(Block) 和 页(Page) 是同一个东西,其默认大小为8kb。TID(8,100)代表的意思就是第9个Page的第101个item。什么是Page?
重要的是理解索引是以一定的维护成本来加快数据访问速度的。对于索引的数据的每一个操作,无论是插入、删除、还是更新,对应的索引也要更新,而且其更新是在同一个事务里的。但是,更新没有建立索引的表的字段不会导致索引的更新。这种技术被称为HOT(Heap-Only Tuples)
通用的索引引擎可以很容易让我们向系统中添加一个新的访问方法。其主要任务是从访问方法中获取TIDs,并且和它们一起工作。其中索引引擎负责的工作有
在执行查询时候,会用到索引引擎。索引引擎会根据在优化阶段创建的计划来被调用。优化器需要经过整理和评估执行查询的不同方式,因此需要了解可能要用到的不同访问方法的能力。这个方法是否能够以所需的顺序返回数据,或者我们应该排序吗,这个方法能够用来搜索NULL值吗,这些都是优化器经常要解决的问题。
不仅优化器需要知道关于访问方法的相关信息。在创建索引的时候,系统也必须决定索引是否可以建立在表的哪几行上,索引是否可以确保唯一性。
因此每一个访问方法都必须提供有关自身的必要信息。在9.6版本以前,这些信息是记录在pg_am表中的。从9.6开始,这些数据被放在了一些特殊函数的内部(在下一篇文章中会讲到这个)。
剩下 的就是访问方法的任务了
预写日志 (WAL) 是确保数据完整性的标准方法。其核心概念是对数据文件(表和索引所在的位置)的更改必须仅在这些更改被记录后写入,即在描述更改的日志记录已刷新到永久存储之后。如果我们遵循这个过程,我们不需要在每次事务提交时将数据页刷新到磁盘。因为日志记录是顺序存储的,同步日志的成本远低于刷新数据页的成本。
索引引擎能够使数据库能够统一的使用各种访问方法,但是同时兼顾它们的特性。
我们可以不同的方式来使用索引提供的TIDs。让我们考虑下面这个例子
1 | create table t(a integer, b text, c boolean); |
我们创建了包含3个字段的表,第一个字段是从1到100000的数字,并在这个字段上创建了索引。第2个字段上是除去不可打印字符的ASCII字符串,第3个字段大概有10%是true其余的是false。这些行以随机的顺序被插入到表中。
让我们先尝试用”a=1”来查询一个值。请注意这里的条件就是“索引字段 操作符 表达式”,其中操作符是”相等”,表达式是”1”。在大多数情况下,对于要使用的索引,其条件必须和这个相似
1 | explain (costs off) select * from t where a = 1; |
1 | QUERY PLAN |
在这种情况下,优化器决定使用索引扫描。使用索引扫描,访问方法一个接一个的返回TID直到匹配的最后一行。索引引擎依次访问TID指示的行,获取行版本,根据多版本并发规则来检查其可见性,最后返回获取的数据。
注,本节可以参考这个博客进行理解:Bitmap Scan In PostgreSQL
索引扫描在处理少数行的时候可以工作的很好。但是当行数增加的时候,经常会出现多次访问同一个Page。因此,优化器切换到Bitmap扫描
多次访问同一个表页面是指,比如访问方法先返回的TID为(1,1),这时候索引引擎需要去加载第2个Page(编号从0开始,后同)去读取第2个Item;然后访问方法返回第二个TID为(2,2),索引引擎就需要加载第3个Page,去读取第3个item;最后访问方法返回第三个TID为(1,2),索引引擎就需要重新加载第2个Page去读取第3个item。这样就出现了重复IO读取。
看下面的例子
1 | explain (costs off) select * from t where a <= 100; |
1 | QUERY PLAN |
访问方法首先会返回所有匹配条件的的TID(Bitmap Index Scan node),然后根据这些TID来构建行版本位图。然后从表中读取行版本,每一个Page只会被读取一次。
比如说位图中记录了(1,1) (1,2),索引引擎就加载第2个Page,然后读取第2个和第3个Item,即对同一个Page只需要读取一次。
注意第2步,条件会被重新检查。这是因为检索到的行数可能会非常大,从而超过RAM的大小(通过work_mem参数限制,默认为4MB)。这种情况下,bitmap只记录那些最少包含一条匹配数据的Page(例如对于(1,1) (1,2),只记录一个1),这样的话,”有损”的Bitmap所需要的空间就会变少。但是当读取Page的时候,我们需要根据条件来重新检查这个Page中包含的行。注意,即使检索的行比较小,然后位图是“精确”位图(即精确记录了每一个TID,而不是只记录了Page),”Recheck Cond”在计划中仍然会存在,虽然没有被真正执行。参考资料
如果查询条件中包含多个被索引的字段,位图扫描允许同时使用多个索引(如果优化器认为这样能够提升查询效率)。对于每一个索引条件,都会为其建立行版本的位图,然后对其执行按位布尔运算,如果是AND,执行布尔乘法,OR的话执行布尔加法。
例如
1 | create index on t(b); |
1 | QUERY PLAN |
这里有一个BitmapAnd 节点通过按位And操作连接了两个位图
位图扫描能够是我们避免重复访问同一个数据页。但是如果数据页中的数据的物理排序方式和索引记录完全相同呢?当然,我们不能依赖页面中数据的物理顺序。如果需要排序数据,我们必须在查询的时候明确指定ORDER BY子句。
但是,实际上“几乎所有的”数据都被排序的情况也是很有可能出现的:例如,如果行按照被需要的顺序添加并且不再改变。在这种情况下,构建一个位图就是多余的,一个常规的索引扫描就可以了(除非我们考虑加入多个索引的可能性)。
如果物理排序和索引记录的排序完全相同,即索引扫描时候返回的TID是有序的,比如依次返回(1,1)、(1,2)、(2,1)等等,这样,索引引擎基本上也是对每个Page只需要读取一次。
所以当选择一个访问方法时,计划器会查找一个特殊的统计,这个统计显示了物理行排序和逻辑排序的相关性。
1 | select attname, correlation from pg_stats where tablename = 't'; |
1 | attname | correlation |
上表就是通过analyze命令得出的。接近1表示高度相关,接近0表示无序分布。如果相关度比较高的话,相同的查询条件下,计划器可能就会选择使用索引扫描。
这里补充一个例子
1 | create table t2(a integer, b text, c boolean); |
和t表不同的是,这里插入的顺序是按照id的顺序插入的。
查看相关性
1 | select attname, correlation from pg_stats where tablename = 't2'; |
1 | attname | correlation |
可以看到物理行排序和逻辑排序的相关性是1,就是完全相关的。此时再来执行下面的查询语句
1 | explain (costs off) select * from t2 where a <= 100 |
1 | QUERY PLAN |
可以看到,优化器使用了索引扫描,而不是位图扫描。
我们应该注意到在非选择性条件(条件的选择性低)下,优化器更喜欢使用全表扫描。如下面的例子
1 | explain (costs off) select * from t where a <= 40000; |
1 | QUERY PLAN |
这是因为条件的选择性越高,即匹配的行数少,索引就越有效。检索行数多,会增加读取索引页的成本。
顺序扫描比随机扫描更快。这尤其适用于机械硬盘,将磁头转到磁道上的机械操作要比读取数据本身花费的时间多得多。对于SSD,这种差异就比较小了。用“seq_page_cost”和“random_page_cost”这两个参数可以调整相应的设置。这两个参数既可以全局设置,也可以在表空间中设置,这样就可以适应不同的硬盘了。
用下面命令可以查看当前两个参数的值
1 | show seq_page_cost; |
查询结果分别是1和4,即顺序扫描的代价是1,随机扫描的代价是4.
如果我们用下面的命令将random_page_cost值也设置为1
1 | set random_page_cost to 1; |
再执行下面的例子
1 | explain (costs off) select * from t where a <= 40000; |
1 | QUERY PLAN |
可以看到,这时候使用了索引扫描而不是顺序扫描了。
通常,访问方法的主要任务是返回匹配行数据的TID,以便索引引擎能够读取必要的数据。但是如果索引中已经包含了查询的所有数据呢?这样的索引叫做覆盖,在这种情况下,优化器会使用index-only扫描
1 | vacuum t; |
1 | QUERY PLAN |
index-only这个名字给人的感觉是索引引擎不访问表,而是仅仅从访问方法中获取到所有的必要信息。但是事实并非如此,因此PostgreSQL中的索引不存储使我们能够判断行的可见性的信息。因此,访问方法返回的符合搜索条件的行的版本,而不管它们在当前事务中的可见性如何。
个人理解有两种情况会出现不可见的情况,第一种是大多数关系型数据库中都有多版本并发控制,即更新、删除操作都不会马上立即删除该行的旧版本,因为此行此时可能对别的事务仍然是可见的,其中对于更新来说会增加此行的新版本。但是旧版本对于后来的事务就是可不见了。第二种,是后面事务新增的行对先前开启的未结束的事务不可见。
然而,如果索引引擎每次都需要查看表的可见性,那么这种方法就和常规的索引扫描没有任何不同了。
为了解决这个问题,对于表,PostgreSQL维护了一个可见性映射(Visibility Map,以_vm结尾的数据库文件),在这个映射中,vacuuming 标记了只包含对所有事务可见的元组(近段时间没有更新的数据)的页面,以便所有的事务不管开始时间和隔离级别都能看到这些数据。如果索引返回的标识符和这些页面相关,就可以避免可见性检查。
因此,定期执行vacuuming 可以提高覆盖索引的效率。而且,优化器会考虑无效元组的数量,如果预测可见性检查的成本很高的话,会决定不使用仅索引扫描。
我们可以使用EXPLAIN ANALYZE来查看强制访问表的次数
1 | explain (analyze, costs off) select a from t where a < 100; |
1 | QUERY PLAN |
在这个情况下,不需要去访问表(Heap Fetches: 0),因为刚刚进行了vacuuming。通常来说,这个数字约接近0越好。
这里我们来做一个简单实验。
先执行下面的命令
1 | explain (analyze, costs off) select a from t where a < 1000; |
1 | QUERY PLAN |
可以看出此时使用的使仅索引扫描,而且Heap Fetches为0
然后执行下面的更新命令,
1 | update t set a = a + 1; |
此条更新命令会使行版本数量增加一倍,此时再执行下面的语句
1 | explain (analyze, costs off) select a from t where a < 1000; |
1 | QUERY PLAN |
可以看到,Heap Fetches的数量变成了1946。
由于默认情况下,autovacuum是开启状态,即数据库会自动执行vacuum,因此,不断执行同样的命令,可以看到Heap Fetches逐渐变小,直到变为0为止。
快速多次执行更新命令
1 | update t set a = a + 1; |
由于每次执行都会导致无效的行版本变多,如果无效的行版本变多,优化器就会放弃使用覆盖索引
1 | explain (analyze, costs off) select a from t where a < 1000; |
1 | QUERY PLAN |
如上,可以看出经过若干次更新以后,优化器使用了位图扫描。
NULL值在关系型数据库中表示一个不存在的或者未知的值。
但是特殊的值需要特殊的处理。一个常规的布尔表达式就开始具有了三元性;NULL值是否应该小于或者大于一个常规的值(这需要一个特殊的排序结构:NULLS FIRST或者NULLS LAST);聚合函数是否应该考虑NULL值;计划器也需要特殊的统计。
从索引的角度来说,也不清楚是否需要对NULL值进行索引。如果NULL值没有被索引的话,索引就会更加紧凑一些。但是如果NULL值被索引的话,我们就能够针对像”索引字段 IS [NOT] NULL”这样的条件使用索引,而且也可以对没有条件的查询使用覆盖索引(因为没有条件的话,查询必须返回包括NULL值在内的所有行)。
对于每一个获取方法,开发者必须做出决定是否索引NULL值。但是作为一条规则,NULL值会被索引。
为了支持多字段条件,可以使用多列索引。例如,我们可以在我们的表中创建一个包含两个字段的索引
1 | create index on t(a,b); |
优化器可能更倾向于使用这个联合索引而不是使用连接位图,因为此时我们不需要辅助操作(例如BitmapAnd)就可以很容易的获得TIDs。
1 | explain (costs off) select * from t where a <= 100 and b = 'a'; |
1 | QUERY PLAN |
多列索引也可以在只使用索引中从第一个字段开始的若干个字段作为条件的时候来加速查询,例如对于多列索引(a,b),用a来做查询条件;对于多列索引(a,b,c),用a或者a、b做查询条件。如下
由于已经在a上创建了索引,此时测试a会优先使用其单列索引。因此重新创建一个(b,a)索引,然后来测试只有b条件下的执行情况
1 | create index on t(b,a); |
1 | QUERY PLAN |
一般情况下,如果条件查询中没有包含第一个字段,则索引不会生效。但是某些情况下,计划器可能会认为使用索引要比顺序扫描快。这一点在BTREE一章会具体展开。
我们已经提到了搜索条件必须看起来像“索引字段 操作符 表达式”。下面的例子,将不会使用索引,因为条件中使用的是包含字段名称的表达式,而不是字段本身。
1 | explain (costs off) select * from t where lower(b) = 'a'; |
如果不太可能重写查询,可以在表达式上建立索引(功能索引)
1 | create index on t(lower(b)); |
1 | QUERY PLAN |
功能索引没有建立在表的字段上,而是在一个任意的表达式上。优化器将会在像“索引表达式 操作符 表达式”这种查询的时候考虑使用功能索引。如果索引的表达式计算的代价很昂贵的话,那么索引的更新也会消耗相当多的资源。
另外就是,PG为被索引的表达式收集了单独的统计信息。我们可以通过”pg_stats”通过索引名称来了解这个统计信息
1 | \d t |
1 | Table "public.t" |
1 | select * from pg_stats where tablename = 't_lower_idx'; |
1 | schemaname | tablename | attname | inherited | null_frac | avg_width | n_distinct | |
如果有有必要,可以像修改常规的表的字段那样,去修改直方图篮子的数量(但是请注意,列的名字取决于被索引的表达式)。
1 | \d t_lower_idx |
1 | Index "public.t_lower_idx" |
1 | alter index t_lower_idx alter column "lower" set statistics 69; |
有时候只需要对表的一部分进行索引。这通常于高度不均匀的分布有关:通过索引可以很容易找到一个不经常出现的值,但是如果去找一个经常在表中出现的值,计划器可能会更倾向于使用全表扫描。
1 | create index on t(c); |
1 | QUERY PLAN |
1 | explain (costs off) select * from t where not c; |
1 | QUERY PLAN |
因为有99%的C值为假,因此,优化器会使用全表扫描来查找C值。
索引的大小是87个页面(这个数量会根据数据库的版本等原因而不同)
1 | select relpages from pg_class where relname='t_c_idx'; |
1 | relpages |
但是既然C列只有1%的值为真,因此索引中的99%的数据从来不会被使用(即c为false的情况)。这种情况下,我们可以使用局部索引:
1 | create index on t(c) where c; |
此时索引的大小被减到了2(这个数量会根据数据库的版本等原因而不同,但是比上面的值笑了很多)
1 | select relpages from pg_class where relname='t_c_idx1'; |
1 | relpages |
有时候,索引的大小会显著影响到索引的性能
如果访问方法以某种顺序返回了行标识符,这个可以为优化器提供额外的选项来执行查询。
我们可以扫描表然后排序数据
1 | set enable_indexscan=off; |
1 | QUERY PLAN |
上面是先全表扫描了t,然后根据a来排序
但是通过索引,我们可以很容易的按照顺序来读取数据。
1 | set enable_indexscan=on; |
1 | QUERY PLAN |
这个在我用的PostgreSQL 13.3没有复现此结果,我测试的结果仍然是全表扫描。如下图所示
目前所有的访问方法只有”Bree”树支持返回排序数据,这一点我们后续会讨论这个索引。
通常构建一个索引需要对表的share锁。这个锁允许从表中读取数据,但是禁止在构建索引的时候去修改表这一个
我们可以验证这一点。下面是例子
在会话1中执行下面查询
1 | BRGIN; |
因为在我们的例子中,创建索引的速度很快,所以这里使用了事务。
然后在会话2中来查询锁
1 | select mode, granted from pg_locks where relation = 't'::regclass; |
1 | mode | granted |
可以看到在表T上有一个共享锁。然后别忘了关闭会话1中的事务。
如果我们的表很大,而且又很频繁的进行插入、更新、删除等操作的时候,代价就会变的很大。因为修改数据的进程需要等待索引去释放锁。
这种情况下,我们可以使用并发创建一个索引
注意是并发不是并行,并发和并行是不同的意思,举个例子,一个人做a,一个人同时做b,这个叫做并发;两个人同时做a这件事情,这个叫并行。
1 | create index concurrently on t(a); |
这个命令将表锁定在SHARE UPDATE EXCLUSIVE 模式下,在这种模式下,允许读取和更新数据(但是禁止更新表结构、以及在此表上并发清理、分析或者建立另外一个索引)。
当然这么做有其不好的一面:
索引速度会变慢,因为其需要遍历两次表;并且需要等待修改数据的并行事务完成。
在并发索引构建中,索引实际上是在一个事务中进入系统目录,然后在另外两个事务中进行两次表扫描。在每次表扫描之前,索引构建必须等待已修改表的现有事务终止。在第二次扫描之后,索引构建必须等待在第二次扫描之前具有快照的任何事务终止。然后最后可以将索引标记为可以使用,并且 CREATE INDEX 命令终止。然而,即便如此,索引也可能无法立即用于查询:在最坏的情况下,只要在索引构建开始之前存在事务,就不能使用它
并发构建索引时,可能会出现死锁或者违反唯一约束。然后,尽管此索引是无效的,但是页已经被建立了,。这样的索引必须被删除并且重新建立。无效的索引会在\d命令输出的结果中被标记为INVALID。下面的查询会返回这些无效索引的完整列表
1 | select indexrelid::regclass index_name, indrelid::regclass table_name |
1 | index_name | table_name |
这个不一定能复现。
1 | FROM golang AS build |
可以用下面命令查看golang支持的系统和处理器指令集架构
1 | go tool dist list |
下面是输出结果
1 | aix/ppc64 |
可以看出其支持的系统和架构组合的还是比较多的。一般对于个人计算机来说,其指令集一般都是amd64的,而手机终端以及树莓派的处理器指令集都是Arm的。
下面是在windows平台上用powshell分别编译windows、linux以及macos的可执行文件。
1 | # 编译linux下的可执行文件 |
下面是在linux平台上分别编译windows、linux以及macos的可执行文件。
1 | # 编译windows下的可执行文件 |
但是我们也知道,HTTP默认的端口是80,HTTPS默认的端口是443,如果我们想让用户不需要指定端口,只通过不同的域名就可以访问到我们同一个服务器上不同Docker容器运行的服务,就需要在宿主机上安装一个Nginx服务,通过nginx的反向代理来将不同的域名反向代理到不同的服务上。
此外,如果我们想使用HTTPS,则必须拥有域名对应的证书。现在最流行的免费证书是Letsencrypt,虽然说证书的有效期只有三个月,但是可以借助Letsencrypt提供的Certbot工具来实现快到期自动续签,不需要担心证书会失效。
Docker的一个强大之处是其及其丰富的生态系统,不仅有各种各样的服务镜像,还有一些有趣实用的工具镜像。今天介绍的就是其中的nginx-proxy镜像和acme-companion镜像,这两个镜像配合使用,可以实现自动反向代理容器中运行的服务和自动签发证书,可以说是十分的方便和实用了。
nginx-proxy容器的作用通过设置反向代理,将别的容器中的服务通过80端口或者443端口暴露在公网上。nginx-proxy容器会监视别的容器的启动,一旦发现别的容器里有VIRTUAL_HOST(指定了Host)和VIRTUAL_PORT(指定了容器中服务监听的端口)这两个环境变量,会自动对其添加相应的反向代理配置中。
首先需要启动nginx-proxy容器。在启动之前,先创建/data/nginx目录。也可以修改这个目录为自己需要的目录。创建好目录以后,就可以输入下面命令来启动nginx-proxy了。
1 | sudo docker run --detach \ |
启动以后,如果我们看到下面的文字,说明IPv4 forwarding是禁用状态,需要先把IPv4 forwarding打开,否则,就无法从公网上访问我们的docker容器内的服务了
1 | IPv4 forwarding is disabled. Networking will not work |
打开也非常简单,只需要修改/etc/sysctl.conf文件中的net.ipv4.ip_forward,将其值设置为1,然后用下面命令重启网络
1 | systemctl restart network |
重启网络之后,为了保险起见,最好用sudo docker rm -f nginx-proxy删除掉容器以后,重新启动该容器。
容器启动成功后,可以看到其已经正常运行了
我们可以运行一个服务来简单测试一下nginx-proxy是否像预期那样的工作。
首先,我将grafana.lixf.ink解析到了这台服务器的IP上。对此步有疑问的请参考这里
然后,使用下面命令运行一个grafana。从命令中可以看出,并没有将容器的然和端口映射到宿主机的端口上,只是额外添加了两个环境变量VIRTUAL_HOST和VIRTUAL_PORT来告诉nginx-proxy想使用的域名是grafana.lixf.ink,容器中服务的端口是3000。
1 | sudo docker run --detach \ |
容器启动成功后,在浏览器中访问http://grafana.lixf.in即可访问到grafana。
acme-companion容器的作用是自动为域名签发证书,而且还会在证书快到有效期之前自动续发新的证书,从而保证证书永不过期。和nginx-proxy容器一样,acme-companion容器会监视别的容器的启动,一旦发现别的容器里有LETSENCRYPT_HOST(指定了要签发证书的域名)和LETSENCRYPT_EMAIL(指定了邮箱)这两个环境变量,就会为指定的域名签发对应的证书。签发了证书之后,acme-companion容器会自动修改nginx-proxy中相应的配置,使HTTPS和证书生效。
要注意的是,和nginx-proxy不同的是,nginx-proxy不一定非要在公网环境下才能使用,而acme-companion是必须在公网环境中才能使用的。一方面原因是letsencrypt服务器需要通过访问域名来验证域名所有权,另一个更直接的原因是acme-companion无法访问到letsencrypt服务器,就更不用说签发证书了。
好了,啰嗦了这么多,其实运行命令只有下面一条
1 | sudo docker run --detach \ |
这时候执行docker ps,可以看到我们nginx-proxy和acme-companion在运行了
我们可以运行一个服务来简单测试一下acme-companion是否像预期那样的工作。这里我同时启动grafana和wordpress来进行测试
1 | docker run --detach \ |
等待两个容器启动成功后,我们可以分别在浏览器中访问grafana.lixf.ink和blog.lixf.ink,可以看到,连个地址都是HTTPS协议了。
]]>下面是程序运行的截图,其中包含了我写的一段简单的测试代码,运行起来也没什么问题
由于我是用docker安装的,因此安装起来也非常简单
1 | docker run -d --name theiaide --restart=always -p 3000:3000 -v /data/theia/projects:/home/project:cached theiaide/theia-full |
等容器启动以后,就可以在浏览器通过 ip:3000 访问在线编辑器了。但是这时候也仅仅是能访问而已,如果我们尝试创建文件夹或者文件,是不能创建成功的。
这是因为权限问题导致的,容器内部并不是以root用户运行的容器的主进程,而是以theia用户运行的,因此,需要修改一下/data/theia/projects的权限。
使用如下命令进入到容器内部
1 | docker exec -it theiaide /bin/bash |
然后执行下面命令查看所有的用户,并找到theia用户,查看其用户ID
1 | cat /etc/passwd |
可以看到其用户ID是1000
在新版的linux系统中,新建的普通用户,其用户ID一般都是从1000开始
然后,可以使用下面的命令来更改/data/theia/projects文件夹所属的用户
1 | sudo chown -R 1000 /data/theia/projects |
更改了文件夹所属的用户以后,此时就应该可以正常的创建文件以及文件夹了
由于需要加载一个2M的bundle.js文件以及别的一些静态的资源,如果带宽比较小,比如现在主流的1M带宽,可能需要等待20到30秒编辑器才能完全加载完毕。这里我的解决办法是到容器里面把lib目录里的内容全都拷贝了出来,然后上传到了阿里云的CDN里,然后再替换掉index.html里的bundle.js的地址。这个中间还涉及到了跨域的问题,有兴趣的话可以自己尝试一下
theia是没有账号密码机制,任何一个知道地址的人都可以直接使用,甚至操作我们的代码。由于我使用了nginx进行了反向代理,因此在nginx的配置文件中增加了用户的认证。这个比较简单,可以参考这个网址
配置了以后的效果如图。这样就可以在一定程度上防止别人未经授权使用我们的编辑器
下面分别提供通过https协议和ssh协议克隆仓库设置代理的方法
如果我们本地有可以科学上网的http或者socket5代理的话,可以进行如下设置(只需要设置一个就行了)
1 | git config --global http.proxy http://127.0.0.1:10809 #走http代理 |
设置完后再克隆,就可以发现拉取仓库的速度有了明显的提升(具体取决于代理的速度和当前网络的速度)。
此时,如果我们通过ssh协议克隆仓库,会发现克隆速度还是很慢(几乎是0)。如下图所示
这种情况下需要给ssh协议配置代理。
配置通过ssh协议克隆仓库的话,需要socket5代理。windows用户在 C:\Users\用户名.ssh\config 文件中增加下面的内容 (没有的话自行创建)
1 | Host github.com |
Linux在~/.ssh/config文件中增加下面内容
1 | Host github.com |
此时再克隆,可以看到速度明显有了提示
npm在国内拉取仓库时,也会特别慢,可以参考下面的配置来设置代理
1 | npm config set proxy http://127.0.0.1:10809 |
postgres支持的几个默认的分区方法
Range Partitioning
范围分区是根据表中的某个或者某几个字段进行分区,例如 1-10 11-20
List Partitioning
通过显式的列出表中的键值对来进行分区
Hash Partitioning
通过为每个分区指定一个除数和余来进行分区。即每一行的hash值除以除数然后得到余,根据余值来放到对应的分区里
分区使用示例
通过指定partitioning by 创建分区表
1 | CREATE TABLE measurement ( |
分别创建每一个分区
1 | CREATE TABLE measurement_y2006m02 PARTITION OF measurement FOR VALUES FROM ('2006-02-01') TO ('2006-03-01'); |
如果插入一条不对应任何分区的数据,那么插入会报错,应该手动创建对应的分区
创建索引
1 | CREATE INDEX ON measurement (logdate); |
创建索引是非必须的,分区表上的索引和约束条件都是虚拟的,实际上的索引位置和约束条件都在分区中
确定 数据库配置中 enable_partition_pruning 的值不是禁用状态
分区维护
删除某个分区的数据
1 | DROP TABLE measurement_y2006m02; # 快速删除数百万条数据,需要在主表上加ACCESS EXCLUSIVE |
增加新的分区
1 | CREATE TABLE measurement_y2008m02 PARTITION OF measurement FOR VALUES FROM ('2008-02-01') TO ('2008-03-01') TABLESPACE fasttablespace; |
先创建表,再创建分区表
1 | CREATE TABLE measurement_y2008m02 (LIKE measurement INCLUDING DEFAULTS INCLUDING CONSTRAINTS) TABLESPACE fasttablespace; |
创建索引
1 | CREATE INDEX measurement_usls_idx ON ONLY measurement (unitsales); |
同样的方式可以用在primary和unique上
1 | ALTER TABLE ONLY measurement ADD UNIQUE (city_id, logdate); |
分区表限制
分区表的幕后是分区和分区表是继承关系,但并不是可以使用所有的继承特性。尤其是分区不能有分区表中不存在的字段,分区表和分区也并不能继承其它表。
不能使用的继承特性有
当分区表上没有任何分区的时候可以使用ONLY来增加约束,当存在分区的时候,使用ONLY会报错。替代的可以在分区上添加和删除(如果父表中没有)约束 后续再看
分区表本身没有任何数据,使用TRUNCATE ONLY会报错
跳过了使用继承的分区,后面再看
分区修剪(Partition Pruning)
分区修剪是一种提高性能的查询优化技术。
例如
1 | SET enable_partition_pruning = on; -- the default |
如果没有分区修剪,那么pg会扫描分区表的所有的分区来计算数量。如果开启了分区修剪,计划器会查看每个分区的定义来提前排除掉不需要查询的分区。
分区修剪是基于分区键的而不是基于索引的,因此是否对分区键建立索引取决于你需要查询分区的一大部分还是一小部分,如果是一大部分,那么建立索引是没有用的。
分区修剪不仅在计划阶段有效,在执行阶段也有效
分区约束排除(Constraint exclusion)
Constraint exclusion是一种类似于分区修剪的查询优化技术。主要是利用CHECK约束实现,在计划阶段有效,比分区修剪慢
默认情况下constraint_exclusion的值是partition,这个值表示约束排除仅工作在继承分区表上,on代表在所有的查询上都检查check约束
注意点:
最佳实践
最关键一点是你选择的分区的列(或者多列)。通常最好的选择是那些经常出现在where中的列。与分区绑定约束兼容的where可以用来修剪不需要的分区。然而,由于primary key和 unique约束也可能做出其它选择。删除数据也是一个计划分区策略的一个考虑因素。
选择分区的数量也是一个重要的因素。分区太少导致索引仍然很大和数据的局部性仍然很低。分区太多,会导致更长的查询计划时间和查询计划和执行期间的更高的内存占用。同时,在设置分区的数目的时候,也需要为将来考虑。
当预期一个分区会变的很大的时候采用子分区是有用的。而如果采用多列范围分区会导致分区的数目变多,因此要加以限制
考虑查询计划和执行阶段的开销是重要的。如果查询能够允许查询计划器修剪大部分分区,那个查询计划器能够处理几千个分区的查询,否则的话,计划时间就会变长内存消耗也会变高,对UPDATE和DELETE同样如此。另一个原因是,服务器的内存消耗会随着时间的推移而显著增长,尤其是大量会话都涉及到分区的情况,这是因为每一个涉及到分区查询的会话都会把分区元数据信息加载到自己的本地内存中
数据库负载类型,warehouse(数据仓库 OLAP On-line Analytical Processing)类型使用大量分区要比OLTP(On-line Transaction Processing 在线事务交易)更有意义。因为,在数据仓库类型中,查询计划时间不重要,因为大部分时间都在查询执行时间。根据数据库的负载类型早做决定。
OLAP 在线分析处理,查询一般都非常复杂,因此执行时间会比较长,主要是提供报表之类的功能
OLTP 在线事务处理,短时间内会有大量的插入、删除、更新语句,要求非常快的查询处理,
1 | create schema myschema; #创建一个新的schema |
在写sql语句的时候,可以通过schema.table来区分对应的schema里的表
查看schema
1 | select nspname from pg_catalog.pg_namespace; |
1 | \dn #在psql中执行,但是看不到系统级别的schema |
一般在写sql语句的时候不会特意指定schema,如果没有指定schema的话,数据库会根据search_path的值来查找schema里的表
查看search_path的值
1 | show search_path; #默认值为 $user,public |
修改search_path
1 | set search_path to myschema,public #将search_path的值设置成mychema和public |
此时,如果用如下sql语句创建表(没有指定schema),pg数据库会在search_path中第一个存在的schema创建对应的表
1 | create table testdb(id int); #由于search_path的值是myschema,public 所以此时表被创建在myschema中 |
可以直接指定schema让表创建在指定的schema中
1 | create table public.testdb1(id int); #直接在pubic中创建testdb1 |
此时如果用 \d 查看当前数据的表,可以看出testdb1在public中,但是没有看见public下有testdb,这是因为pg已经在位置靠前的schema中发现testdb了,会忽略靠后的schema中相同名字的表。但是这个表是真实存在的,可以通过在表名前面加scheme前缀来进行查询和更改。
安全策略
约束每个用户只使用自己私有的schema,先使用下面语句禁止别的用户操作public schema,然后为每个用户创建和用户名同名的schema
1 | REVOKE CREATE ONSCHEMA public FROM PUBLIC |
通过设置 ALTER ROLE ALL SET search_path = “$user” 将所有用户的search_path设置成$user
保持默认 仅数据库只有一个用户或者少数几个受信任的用户的时候使用
用聚集函数来计算各地区的员工工资总和
1 | select city,sum(salary) from empsalary group by city; |
用窗口函数来计算工资总和
1 | SELECT id,name,city,salary, sum(salary) OVER (partition by city) FROM empsalary; |
值得注意的是,在聚集函数中,SELECL后面不能有没有出现在ORDER BY后面的属性,而窗口函数是可以的
窗口函数的调用会始终在窗口函数和参数的后面加一个OVER,在语法上就将窗口函数和其它函数区分开来。OVER子句决定了如何分割查询后的行以便于窗口函数来处理。其中,OVER中的 PARTITION BY子句决定了如何将数据分区,ORDER BY子句则可以控制窗口函数处理行的顺序
例如下面的例子是根据工资进行排名
1 | SELECT name,salary,rank() OVER (ORDER BY salary desc) FROM empsalary; |
使用rank()函数时,排名会出现终端,例如,存在两个第一名,那么加下来的名次就是第三名,如果需要连续的名次,可以使用dense_rank()
分区函数的几个特性
和聚合函数相比,聚合函数只能输出每组的单一行,而窗口函数可以输出每一行包含其它属性的结果
聚合函数操作的对象是 query查询(where、group by、having之后)的结果,例如如果一行记录不满足where条件,那么这条记录对窗口函数不可见
如果顺序不重要,可以省略order by,如果分区不重要,可以省略partition by,相当于全表了
另外一个关于窗口函数重要的事情是,对于每一行,在其分区内都有一组叫做window frame的行。有一些窗口函数(例如sum)仅在window frame的行上起作用,而不是整个分区。默认情况下,如果指定了order by,那么frame是由从分区开始到当前行,然后加上根据order by指定的顺序和当前行相等的行。如果order by是空的,默认的frame由分区内的所有行组成。(这个是postgres的默认设置,可以更改window frame的行为)
第4条我们来看例子来理解
如果OVER子句中没有指定ORDER BY
1 | SELECT salary, sum(salary) OVER () FROM empsalary; |
查询的结果是
因为此时没有指定order by,所以每一行在分区内对应的window frame包含了当前分区的所有行,然后对于sum的计算,就是当前分区内所有行的salary的总和。(因为也没有指定partition by,所以window frame包含了整张表)
如果OVER子句中指定了ORDER BY
1 | SELECT salary, sum(salary) OVER (ORDER BY salary) FROM empsalary; |
查询的结果是
这时候因为指定了order by,所以每一行在分区内对应的windows frame是不一样的。例如,第3、4行在分区内对应的window frame就应该是(1,2,3,3),所以其sum的值是9
]]>最近工作和数据库有关,就借着这个机会好好学一下postgresql。
像其它的大多数关系型数据库一样,postgresql支持聚集函数(aggregate function).聚集函数从多行记录中计算出一个单一的结果。例如count,sum,avg.max,min等等
下面的例子是查询最高温度
1 | SELECT max(temp_lo) FROM weather; |
如果需要知道最高温度对应的城市,也许会尝试下面的查询语句
1 | SELECT city FROM weather WHERE temp_lo = max(temp_lo) |
但是这个查询是错误的,因为聚集函数是在where之后才执行的。但是我们可以通过子查询来实现这样的要求
1 | SELECT city FROM weather WHERE temp_lo = (SELECT max(temp_lo) FROM weather) |
聚集函数通常和GROUP BY一起使用,例如下面的例子,可以查询每个城市的最高温度
1 | SELECT city, max(temp_lo) FROM weather GROUP BY city |
可以通过HAVING来过滤结果,例如下面的查询最高气温小于40度的城市
1 | SELECT city, max(temp_lo) FROM weather GROUP BY city HAVING max(temp_lo) < 40 |
聚合函数中 where 和 having 区别
where发生在分组和聚合之前,控制了哪些数据需要分组和聚合,这也是为什么where条件中不能使用聚合函数的原因
having则相反,发生在分组和聚合之后,所以having一般都包含了聚合函数。虽然也允许在having中不使用聚合函数,但是放在where中更加高效