首页 > 数据库 > 数据库开发技术 > 再论CIC | 你真的搞懂HOT了吗
今天群里有位小伙伴提到之前关于CIC文章的一点疑惑,看样子CIC的确十分费解,借此机会,我再好好地慢慢地理一下,让各位理解地更加通透。
先简单回顾一下HOT (Heap Only Tuple) 机制,参照 Heap Only Tuple and Index-Only Scans
• 假如有个查询需要查询Key = 1000的值,那么会先根据Key = 1000查找索引,得到ctid = (5,1)
• 然后根据ctid访问行指针数组,找到line pointer 1
• 读取Tuple 1
• 然后根据HOT chain找到Tuple 2
这样的话,索引项就可以保持不动。不过两种情况下不能使用HOT:更新了索引键值或者新旧行跨了数据块。
有了HOT的知识储备之后,让我们先看一个简单的例子:
postgres=# create table t2(id int) with (fillfactor = 60);
CREATE TABLE
postgres=# create index t2_idx on t2(id);
CREATE INDEX
postgres=# alter table t2 add column info text;
ALTER TABLE
postgres=# \d t2
Table "public.t2"
Column | Type | Collation | Nullable | Default
--------+---------+-----------+----------+---------
id | integer | | |
info | text | | |
Indexes:
"t2_idx" btree (id)
postgres=# insert into t2 values(1,'test');
INSERT 0 1
postgres=# select lp,t_xmin,t_xmax,t_ctid,t_data from heap_page_items(get_raw_page('t2', 0));
lp | t_xmin | t_xmax | t_ctid | t_data
----+--------+--------+--------+----------------------
1 | 1164 | 0 | (0,1) | \x010000000b74657374
(1 row)
postgres=# select * from bt_page_items('t2_idx',1);
itemoffset | ctid | itemlen | nulls | vars | data | dead | htid | tids
------------+-------+---------+-------+------+-------------------------+------+-------+------
1 | (0,1) | 16 | f | f | 01 00 00 00 00 00 00 00 | f | (0,1) |
(1 row)
postgres=# update t2 set info = 'hello' where id = 1;
UPDATE 1
postgres=# select lp,t_xmin,t_xmax,t_ctid,t_data from heap_page_items(get_raw_page('t2', 0));
lp | t_xmin | t_xmax | t_ctid | t_data
----+--------+--------+--------+------------------------
1 | 1164 | 1165 | (0,2) | \x010000000b74657374
2 | 1165 | 0 | (0,2) | \x010000000d68656c6c6f
(2 rows)
postgres=# postgres=# select * from bt_page_items('t2_idx',1);
itemoffset | ctid | itemlen | nulls | vars | data | dead | htid | tids
------------+-------+---------+-------+------+-------------------------+------+-------+------
1 | (0,1) | 16 | f | f | 01 00 00 00 00 00 00 00 | f | (0,1) |
(1 row)
很明朗,并未更新索引键或者跨数据块 (指定了fillfactor),所以索引项保持不变,依旧只有一条数据。
那么再看个复杂点的例子,表上有两列,并且每一列上都有索引
postgres=# \d+ t1
Table "public.t1"
Column | Type | Collation | Nullable | Default | Storage | Compression | Stats target | Description
--------+---------+-----------+----------+---------+---------+-------------+--------------+-------------
id | integer | | | | plain | | |
id2 | integer | | | | plain | | |
Indexes:
"myidx" btree (id)
"myidx2" btree (id2)
Access method: heap
Options: fillfactor=60
postgres=# insert into t1 values(1,1);
INSERT 0 1
postgres=# update t1 set id = 99 where id = 1;
UPDATE 1
postgres=# select * from t1;
id | id2
----+-----
99 | 1
(1 row)
postgres=# select lp,t_xmin,t_xmax,t_ctid,t_data from heap_page_items(get_raw_page('t1', 0));
lp | t_xmin | t_xmax | t_ctid | t_data
----+--------+--------+--------+--------------------
1 | 1167 | 1168 | (0,2) | \x0100000001000000
2 | 1168 | 0 | (0,2) | \x6300000001000000
(2 rows)
postgres=# select * from bt_page_items('myidx',1);
itemoffset | ctid | itemlen | nulls | vars | data | dead | htid | tids
------------+-------+---------+-------+------+-------------------------+------+-------+------
1 | (0,1) | 16 | f | f | 01 00 00 00 00 00 00 00 | f | (0,1) |
2 | (0,2) | 16 | f | f | 63 00 00 00 00 00 00 00 | f | (0,2) |
(2 rows)
postgres=# select * from bt_page_items('myidx2',1);
itemoffset | ctid | itemlen | nulls | vars | data | dead | htid | tids
------------+-------+---------+-------+------+-------------------------+------+-------+------
1 | (0,1) | 16 | f | f | 01 00 00 00 00 00 00 00 | f | (0,1) |
2 | (0,2) | 16 | f | f | 01 00 00 00 00 00 00 00 | f | (0,2) |
(2 rows)
可以看到我只更新了id列,没有更新id2列,但结果是id列上的还是id2列上的索引,都产生了新的索引项。乍一看,按照HOT的原理,我只更新了id,那么第二列上的索引myidx2是不是就可以保持不变呢?但是事实并不是这样。
大家其实比较容易误解HOT机制中关于更新索引Key的,更加严格来说的话,应该是:
• Necessary Condition A: UPDATE does not change any of the index keys,其中任意一个索引字段的值发生了变化,则所有索引都需要新增版本。
所以,回到我们这个案例,不管是id列上的还是id2列上的索引,都产生了新的索引项。
那让我们再次回到CIC这个案例来,图解CIC的流程
假设有一个表tab,有两列,在col1上面有一个索引,key = a
postgres=# \d tab
Table "public.tab"
Column | Type | Collation | Nullable | Default
--------+---------+-----------+----------+---------
col1 | integer | | |
col2 | integer | | |

假设在col2上面使用create index concurrently的方式新建一个索引,第一阶段,仅仅是插入系统表,pg_class、pg_index,不允许其他事务插入,因为创建了索引之后,在系统表中插入了,对于其他后端进程就可见了。但是对于正在运行的进程来说,是不知道的,所以得必须等待所有tab表上的事务完成,然后才能开始第二阶段。
也就是pg_stat_progress_create_index中的waiting for writers before build。
不用说,新索引中key = b1,因为对于这个事务来说,b1才是可见的(b不可见,HOT链之前的数据)。所以此时这个新索引的版本是(a, b1, c1),但是这个时候,indisready还是false,是不允许插入的,所以一旦这个时候,假如有事务做了更新,首先做了HOT更新,变成了(a, b1, c2),然后又做了一个非HOT更新,那么会插入一条新的索引项,(a, b2, c3),其实这里严格来说,按照我们上面的结论,都是非HOT更新,因此index1也产生了一条新的索引项
那么到这一步位置,对于第一个索引来说是OK的,但是对于第二个使用create index concurrently方式建的索引来说,里面还没有b2(因为还不允许插入),假如此时按照HOT链来查找的话就出问题了,所以需要第三阶段来进一步处理。

第二阶段扫描结束后,会将pg_index.indisready标记为true,其它事务修改基表时,是需要维护索引的,但是indisvalid仍然为false(仍然不能用来查询)。至此第二阶段结束。
第三阶段,再次获取一次快照,插入对新快照可见但尚未在索引中的行,由于第二阶段结束之后,其他事务会维护索引了,所以我们处理一下第二阶段丢失的行即可。就变成了下面这个样子:

所以,这个时候索引基本就差不多了。其他后端进程也负责维护HOT。但是,假设第二阶段或第三阶段存在某个事务较老,他是可以看到 (a, b, c)的话 ,那么新索引就出问题了,索引里压根没有b,所以,CIC还要等待所有这些类似的老事务结束。
也就是pg_stat_progress_create_index中的waiting for old snapshots。
另外,在HOT中,还有一个pruning (剪枝) 的机制,因为HOT机制中,需要访问Tuple 1和Tuple 2,但是Tuple 1可能会在vacuum的时候被清理,这时就无法通过Tuple 1的ctid字段定位到Tuple 2了,为了解决这个问题,就引入了Pruning这个机制,将ctid重定向 (redirect),这样就直接访问所需元组即可。

什么时候会触发pruning呢?时机十分复杂
The pruning processing will be executed, if possible, when a SQL command is executed such as SELECT, UPDATE, INSERT and DELETE. The exact execution timing is not described in this chapter because it is very complicated. The details are described in the README.HOT file.
剪枝会在执行SELECT、UPDATE、INSERT和DELETE的时候均有可能执行
pruning的作用就是用于重定向,修剪line pointer
可以看到,Root line pointer(根)会保留,指向最新的
经过了剪枝pruning之后,line pointer 2便指向了line pointer 6
在HOT中还有一个Defragmentation的机制,暂且称之为碎片整理。碎片整理用于删除死的元组
PostgreSQL removes dead tuples if possible, as in the pruning process, at an appropriate time. In the document of PostgreSQL, this processing is called defragmentation. Fig. 7.5 despicts the defragmentation by HOT.
在上方,Defragmentation也就是将Tuple 2/Tuple 3/Tuple 4移除的过程。
此处可以让各位看个例子,可能会刷新各位的认知。
postgres=# create table test(id int,info text) with (fillfactor=60);
CREATE TABLE
postgres=# alter table test set (autovacuum_enabled = off);
ALTER TABLE
postgres=# insert into test select n,'test' from generate_series(1,100000) as n;
INSERT 0 100000
postgres=# create index t_idx on test using btree (id);
CREATE INDEX
postgres=# analyze test;
ANALYZE
postgres=# update test set info = 'hello' where id = 99;
UPDATE 1
postgres=# select lp,t_xmin,t_xmax,t_ctid,t_data from heap_page_items(get_raw_page('test', 0)) where lp = 99;
lp | t_xmin | t_xmax | t_ctid | t_data
----+--------+--------+---------+----------------------
99 | 1179 | 1182 | (0,112) | \x630000000b74657374
(1 row)
postgres=# select id from test where id = 99;
id
----
99
(1 row)
postgres=# select lp,t_xmin,t_xmax,t_ctid,t_data from heap_page_items(get_raw_page('test', 0)) where lp = 99;
lp | t_xmin | t_xmax | t_ctid | t_data
----+--------+--------+--------+--------
99 | | | |
(1 row)
各位可能发现了,当我做了select之后,第0个数据块的第99行数据被清理掉了,也就是原来的数据被清理掉了!被select这个动作清理掉了!😎这个就是Defragmentation(pruning)
不过defragmentation不会去处理死的索引元组。
Note that the cost of defragmentation is less than the cost of normal VACUUM processing because defragmentation does not involve removing the index tuples.
Thus, using HOT reduces the consumption of both indexes and tables of pages; this also reduces the number of tuples that the VACUUM processing has to process. Therefore, HOT has a good influence on performance because it eventually reduces the number of insertions of the index tuples by updating and the necessity of VACUUM processing.
回顾一下,这篇知识点较多:
• 最容易让人忽略的一点:任意一个索引字段的值发生了变化,则所有索引都需要新增版本,而不是我们理所当然认为的。
• pruning和defragmentation可能发生在任何时候,比如CRUD
• 正是HOT的存在,才让create index concurrently变得错综复杂,需要考虑的更多。对于我们日常运维来说,了解其中利弊即可
现在,各位应该能搞懂CIC了吧?再不理解我得去找块豆腐撞死了。
原文作者:PostgreSQL学徒;如有侵权,请联系管理员删除。