=> CREATE TABLE hot(id integer, s char(2000)) WITH (fillfactor = 75);
=> CREATE INDEX hot_id ON hot(id);
=> CREATE INDEX hot_s ON hot(s);
如果 s 列只存储拉丁字母,那么每个行版本的大小固定为 2004 字节,另加 24 字节的头部。存储参数 fillfactor 设置为 75%。这意味着页面有足够空间容纳四个行版本,但只能插入三个。
现在插入一行,并多次更新它:
=> INSERT INTO hot VALUES (1, 'A');
=> UPDATE hot SET s = 'B';
=> UPDATE hot SET s = 'C';
=> UPDATE hot SET s = 'D';
此时页面中有四个行版本:
=> SELECT * FROM heap_page('hot',0);
ctid | state | xmin | xmax
-------+--------+-------+-------
(0,1) | normal | 820 c | 821 c
(0,2) | normal | 821 c | 822 c
(0,3) | normal | 822 c | 823
(0,4) | normal | 823 | 0 a
(4 rows)
=> UPDATE hot SET s = 'E';
=> SELECT * FROM heap_page('hot',0);
ctid | state | xmin | xmax
-------+--------+-------+------
(0,1) | dead | |
(0,2) | dead | |
(0,3) | dead | |
(0,4) | normal | 823 c | 824
(0,5) | normal | 824 | 0 a
(5 rows)
进行索引访问时,服务器可能得到 (0,1)、(0,2) 或 (0,3) 作为元组标识符。随后它会尝试从表页面读取对应的元组,却发现项指针处于 dead 状态,也就是该元组已经不存在,应当忽略它。同时,服务器还会修改索引页面中的指针状态,避免以后重复访问表页面2。
扩展用于查看索引页面的函数,增加“死”指针标志:(v. 13)
=> DROP FUNCTION index_page(text, integer);
=> CREATE FUNCTION index_page(relname text, pageno integer)
RETURNS TABLE(itemoffset smallint, htid tid, dead boolean)
AS $$
SELECT itemoffset,
htid,
dead -- начиная с v.13
FROM bt_page_items(relname,pageno);
$$ LANGUAGE sql;
=> SELECT * FROM index_page('hot_id',1);
itemoffset | htid | dead
------------+-------+------
1 | (0,1) | f
2 | (0,2) | f
3 | (0,3) | f
4 | (0,4) | f
5 | (0,5) | f
(5 rows)
目前索引页面上的所有指针都处于活动状态。但第一次通过索引访问表后,指针状态就会改变:
=> EXPLAIN (analyze, costs off, timing off, summary off, buffers off)
SELECT * FROM hot WHERE id = 1;
QUERY PLAN
-----------------------------------------------------------
Index Scan using hot_id on hot (actual rows=1.00 loops=1)
Index Cond: (id = 1)
Index Searches: 1
(3 rows)
=> SELECT * FROM index_page('hot_id',1);
itemoffset | htid | dead
------------+-------+------
1 | (0,1) | t
2 | (0,2) | t
3 | (0,3) | t
4 | (0,4) | t
5 | (0,5) | f
(5 rows)
PostgreSQL 用一种称为 HOT 更新3(Heap-Only Tuple update)的优化来避免多余的索引条目。这种更新可以在以下三种情况下进行:
表上没有创建任何索引(这种情况下,任何更新都是 HOT 更新);
表上存在索引,但被更新的列没有被任何索引使用;
被更新的列被某个索引使用,但其值没有发生变化。
如果某列以任何方式出现在 CREATE INDEX 命令中,就认为该列被索引使用:索引可以直接建立在该列上,也可以建立在包含该列的表达式上,或者该列作为非键列出现在 INCLUDE 索引中。唯一的例外是 BRIN 索引:其中没有指向表行的引用,因此不会妨碍 HOT 更新。(v. 16)(第 642 页)
执行 HOT 更新时,索引页面中只有一个条目,它指向表行的第一个版本。同一行的其他版本如果仍位于这个表页面中,则通过各版本头部的 ctid 指针连接成一条链。
不被任何索引引用的行版本会标记 Heap-Only Tuple 位。如果某个行版本位于这条链中,则会标记 Heap Hot Updated 位。
如果索引扫描访问表页面时发现某个行版本带有 Heap Hot Updated 标志,就知道不能停在这里,而要沿着更新链继续向后遍历。当然,在把结果返回给客户端之前,服务器会检查这样读到的所有行版本的可见性。
下面删除一个索引并清空表,看看 HOT 更新如何工作。
=> DROP INDEX hot_s;
=> TRUNCATE TABLE hot;
为方便起见,我们重新定义 heap_page 函数,在输出中增加三个字段:ctid 以及与 HOT 更新有关的两个位:
=> DROP FUNCTION heap_page(text,integer);
=> CREATE FUNCTION heap_page(
relname text,
pageno integer
)
RETURNS TABLE(
ctid tid,
state text,
xmin text,
xmax text,
hhu text,
hot text,
t_ctid tid
)
AS $$
SELECT (pageno,lp)::text::tid AS ctid,
CASE lp_flags
WHEN 0 THEN 'unused'
WHEN 1 THEN 'normal'
WHEN 2 THEN 'redirect to '||lp_off
WHEN 3 THEN 'dead'
END AS state,
t_xmin || CASE
-- f пригодится позже
WHEN (t_infomask & 256+512) = 256+512 THEN ' f'
WHEN (t_infomask & 256) > 0 THEN ' c'
WHEN (t_infomask & 512) > 0 THEN ' a'
ELSE ''
END AS xmin,
t_xmax || CASE
WHEN (t_infomask & 1024) > 0 THEN ' c'
WHEN (t_infomask & 2048) > 0 THEN ' a'
ELSE ''
END AS xmax,
CASE WHEN (t_infomask2 & 16384) > 0 THEN 't' END AS hhu,
CASE WHEN (t_infomask2 & 32768) > 0 THEN 't' END AS hot,
t_ctid
FROM heap_page_items(get_raw_page(relname,pageno))
ORDER BY lp;
$$ LANGUAGE sql;
重复执行插入和更新操作:
=> INSERT INTO hot VALUES (1, 'A');
=> UPDATE hot SET s = 'B';
页面中出现了一条 HOT 更新链:
Heap Hot Updated 位(hhu)表示需要沿着 ctid 链继续前进;
Heap Only Tuple 位(hot)表示没有索引引用这个行版本。
=> SELECT * FROM heap_page('hot',0);
ctid | state | xmin | xmax | hhu | hot | t_ctid
-------+--------+-------+------+-----+-----+--------
(0,1) | normal | 831 c | 832 | t | | (0,2)
(0,2) | normal | 832 | 0 a | | t | (0,2)
(2 rows)
继续更新时,这条链会增长,但只能在页面范围内增长:
=> UPDATE hot SET s = 'C';
=> UPDATE hot SET s = 'D';
=> SELECT * FROM heap_page('hot',0);
ctid | state | xmin | xmax | hhu | hot | t_ctid
-------+--------+-------+-------+-----+-----+--------
(0,1) | normal | 831 c | 832 c | t | | (0,2)
(0,2) | normal | 832 c | 833 c | t | t | (0,3)
(0,3) | normal | 833 c | 834 | t | t | (0,4)
(0,4) | normal | 834 | 0 a | | t | (0,4)
(4 rows)
此时索引中仍然只有一个引用,它指向这条链的头部:
=> SELECT * FROM index_page('hot_id',1);
itemoffset | htid | dead
------------+-------+------
1 | (0,1) | f
(1 row)
HOT 更新链不会越过页面边界,因此遍历整条链不需要访问其他页面,也不会降低性能。
表的使用统计会跟踪更新类型。在这个例子中,7 次 UPDATE 操作中有 3 次是 HOT 更新:
=> SELECT n_tup_upd, n_tup_hot_upd
FROM pg_stat_all_tables
WHERE relid = 'hot'::regclass;
n_tup_upd | n_tup_hot_upd
-----------+---------------
7 | 3
(1 row)
5.3 HOT 更新中的页内剪枝
HOT 更新中的页内剪枝是一个特殊但很重要的情况。
在前面的例子中,已经超过了 fillfactor 阈值,因此下一次更新应当触发页内剪枝。但这一次页面中包含一条 HOT 更新链。由于索引引用着链头,链头必须始终留在原位;其他指针则可以释放,因为可以确定没有外部引用。
=> SELECT * FROM heap_page('hot',0);
ctid | state | xmin | xmax | hhu | hot | t_ctid
-------+---------------+-------+-------+-----+-----+--------
(0,1) | redirect to 4 | | | | |
(0,2) | normal | 835 c | 836 c | t | t | (0,3)
(0,3) | normal | 836 c | 837 | t | t | (0,5)
(0,4) | normal | 834 c | 835 c | t | t | (0,2)
(0,5) | normal | 837 | 0 a | | t | (0,5)
(5 rows)
下一次更新又会触发页内剪枝:
=> UPDATE hot SET s = 'H';
=> SELECT * FROM heap_page('hot',0);
ctid | state | xmin | xmax | hhu | hot | t_ctid
-------+---------------+-------+------+-----+-----+--------
(0,1) | redirect to 5 | | | | |
(0,2) | normal | 838 | 0 a | | t | (0,2)
(0,3) | unused | | | | |
(0,4) | unused | | | | |
(0,5) | normal | 837 c | 838 | t | t | (0,2)
(5 rows)
=> BEGIN ISOLATION LEVEL REPEATABLE READ;
=> SELECT 1;
现在在第一个会话中执行几次更新:
=> UPDATE hot SET s = 'I';
=> UPDATE hot SET s = 'J';
=> UPDATE hot SET s = 'K';
此时所有行版本都还能放在同一个页面中:
=> SELECT * FROM heap_page('hot',0);
ctid | state | xmin | xmax | hhu | hot | t_ctid
-------+---------------+-------+-------+-----+-----+--------
(0,1) | redirect to 2 | | | | |
(0,2) | normal | 838 c | 839 c | t | t | (0,3)
(0,3) | normal | 839 c | 840 c | t | t | (0,4)
(0,4) | normal | 840 c | 841 | t | t | (0,5)
(0,5) | normal | 841 | 0 a | | t | (0,5)
(5 rows)
下一次更新时,页面将没有足够空间,而页内剪枝也无法释放任何空间:
=> UPDATE hot SET s = 'L';
=> COMMIT; -- снимок больше не нужен
=> SELECT * FROM heap_page('hot',0);
ctid | state | xmin | xmax | hhu | hot | t_ctid
-------+---------------+-------+-------+-----+-----+--------
(0,1) | redirect to 2 | | | | |
(0,2) | normal | 838 c | 839 c | t | t | (0,3)
(0,3) | normal | 839 c | 840 c | t | t | (0,4)
(0,4) | normal | 840 c | 841 c | t | t | (0,5)
(0,5) | normal | 841 c | 842 | | t | (1,1)
(5 rows)
在版本 (0,5) 中可以看到指向页面 1 的引用 (1,1):
=> SELECT * FROM heap_page('hot',1);
ctid | state | xmin | xmax | hhu | hot | t_ctid
-------+--------+------+------+-----+-----+--------
(1,1) | normal | 842 | 0 a | | | (1,1)
(1 row)
不过,这个引用不会被使用,因为版本 (0,5) 没有设置 Heap Hot Updated 位。至于版本 (1,1),现在可以从包含两个条目的索引访问它。两个条目分别指向各自 HOT 链的头部:
=> SELECT * FROM index_page('hot_id',1);
itemoffset | htid | dead
------------+-------+------
1 | (0,1) | f
2 | (1,1) | f
(2 rows)