ITPub博客

首页 > 数据库 > 数据库开发技术 > PostgreSQL Internals之路 Part-I 第5章 页裁剪和HOT(Heap-Only Tuple)更新

PostgreSQL Internals之路 Part-I 第5章 页裁剪和HOT(Heap-Only Tuple)更新

原创 数据库开发技术 作者:数据库杂记 时间:2024-09-10 06:24:20 31 删除 编辑


5. 页裁剪和HOT更新(Page Pruning and hot-update)

5.1 页裁剪

当堆中的页被读或更新时,PG会执行快速的页清理或裁剪操作。它发生在如下几种情况:

  • 前一个update操作在当前页没能找到足够的空间以容纳新元组。这个事件可以反映到页头里

  • 堆页通过fillfactor存储参数,允许包含更多的数据

    insert操作可以添加新行到当前页里,只有在该页未填到fillfactor百分比的情况下。其它剩余空间保留起来,是给update操作使用的。(默认情况下是不保留的)

页裁剪会移走那些在所有快照里都不可见的元组(超出数据库地平线的元组)。它永远也不会超出单个堆页面,但是执行的会非常快。指向裁剪的元组仍然在那里,因为它会被索引引用,而索引已经在一个不同的页面。

基于同样的原因,vm和fsm也会进行刷新。(因此,可恢复的空间主要是update,而不是insert)

既然页可以在读期间进行裁剪,那任何SELECT语句都可能导致页修改。这也是information 标志位推迟修改的一个原因。

我们来看看页裁剪实际上发生了什么。我们创建一个两列的表,并为其创建索引:

mydb=# CREATE TABLE hot(id integer, s char(2000)) WITH (fillfactor = 75);
CREATE TABLE
mydb=# CREATE INDEX hot_id ON hot(id);
CREATE INDEX
mydb=# CREATE INDEX hot_s ON hot(s);
CREATE INDEX

如果s列只包含一个latin字母,每个堆元组将有固定长度2004字节,再加上24字节的头。fillfactor存储参数设定为75%,意味着页可以为4个元组提供空间,但是只能插入大概3条数据。

我们试着插入一条记录,并做几次更新操作:

INSERT INTO hot VALUES (1, 'A');
UPDATE hot SET s = 'B';
UPDATE hot SET s = 'C';
UPDATE hot SET s = 'D';

这时,我们的页包含4个元组:

mydb=# SELECT * FROM heap_page('hot',0);
 ctid  | state  |  xmin  |  xmax
-------+--------+--------+--------
 (0,1) | normal | 1205 c | 1206 c
 (0,2) | normal | 1206 c | 1207 c
 (0,3) | normal | 1207 c | 1208
 (0,4) | normal | 1208   | 0 a
(4 rows)

正如所料,我们已经超出了fillfactor的阈值。你也能通过页大小和上限值---大于页大小的75%,6144字节。

mydb=# SELECT upper, pagesize FROM page_header(get_raw_page('hot',0));
 upper | pagesize
-------+----------
    64 |     8192

下一页的访问会触发页裁剪,会移除所有过期的元组。新的元组(0,5) 将被添加到空闲空间里头:// 此处有疑问?

mydb=# UPDATE hot SET s = 'E';
UPDATE 1
mydb=# SELECT * FROM heap_page('hot',0);
 ctid  | state  |  xmin  |  xmax
-------+--------+--------+--------
 (0,1) | normal | 1205 c | 1206 c
 (0,2) | normal | 1206 c | 1207 c
 (0,3) | normal | 1207 c | 1208 c
 (0,4) | normal | 1208 c | 1209
(4 rows)

mydb=# UPDATE hot SET s = 'E';
UPDATE 1
mydb=# SELECT * FROM heap_page('hot',0);
 ctid  | state  |  xmin  |  xmax
-------+--------+--------+--------
 (0,1) | normal | 1205 c | 1206 c
 (0,2) | normal | 1206 c | 1207 c
 (0,3) | normal | 1207 c | 1208 c
 (0,4) | normal | 1208 c | 1209 c
(4 rows)

mydb=# SELECT * FROM heap_page('hot',1);
 ctid  | state  |  xmin  | xmax
-------+--------+--------+------
 (1,1) | normal | 1209 c | 1210
 (1,2) | normal | 1210   | 0 a
(2 rows)

我们再看下:index页的情况:

mydb=# SELECT * FROM index_page('hot_s',1);
 itemoffset | htid
------------+-------
          1 | (0,1)
          2 | (0,2)
          3 | (0,3)
          4 | (0,4)
          5 | (1,1)
(5 rows)

另外一个index 也是类似:

mydb=# SELECT * FROM index_page('hot_id',1);
 itemoffset | htid
------------+-------
          1 | (0,1)
          2 | (0,2)
          3 | (0,3)
          4 | (0,4)
          5 | (1,1)
(5 rows)

索引扫描可以返回(0, 1), (0, 2), (0, 3)这样的元组标识。服务器尝试读相应的元组信息,但是发现指针指向死元组;这意味着该元组不存在,应该被忽略。同时,服务器也会更改索引页的指针状态,以避免重复的堆页访问。

我们把函数扩展以下,以显示索引页,以及该指针是否是死指针:

mydb=# DROP FUNCTION index_page(text, integer);
DROP FUNCTION
mydb=# CREATE FUNCTION index_page(relname text, pageno integer)
mydb-# RETURNS TABLE(itemoffset smallint, htid tid, dead boolean)
mydb-# AS $$
mydb$# SELECT itemoffset,
mydb$# htid,
mydb$# dead -- starting from v.13
mydb$# FROM bt_page_items(relname,pageno);
mydb$# $$ LANGUAGE sql;
CREATE FUNCTION

这样,再查下索引页:

mydb=# 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 | (1,1) | f
(5 rows)

所有的页是有效的,但是当第一次索引扫描发生以后,状态就会发生改变:

mydb=# EXPLAIN (analyze, costs off, timing off, summary off)
mydb-# SELECT * FROM hot WHERE id = 1;
               QUERY PLAN
-----------------------------------------
 Seq Scan on hot (actual rows=1 loops=1)
   Filter: (id = 1)
(2 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 | (1,1) | f
(5 rows)

Although the heap tuple referenced by the fourth pointer is still unpruned and has the normal status, it is already beyond the database horizon. That’s why this pointer is also marked as dead in the index.

提示,上边的结果我仍然重现不出来。

5.2 Hot updates

索引里头,如果保留所有元组的引用,效率将非常低。

起始时,每一行的修改都会触发该表上所有索引的更新:一旦新的元组出现,每个索引都会包含一个到新元组的引用,即使修改的字段并没有被索引。

接着,索引会累积历史元组的引用,这样它们会随同这些元组一起被裁剪。

当且创建更多的索引时,情况会变糟。

但是如果更新列并不是任何索引的一部分,这时真没有必要去创建包含相同键值的索引项。为避免这种冗余操作,PG提供了优化:Heap-Only元组更新。

如果这样的更新被执行,索引页将会每一行只提供一个索引项。这个索引项记录着最开始的第一个行版本,相同页中后边的所有版本号都会绑定一个ctid指针链表。

没被任何索引引用的行版本都被做一个 Heap-Only的元组标记,如果版本位于HOT链里,它就会做一个Heap热更新标识。

如果索引扫描访问一个堆页,并且发现Heap热更新标识,扫描就可以继续这个热更新链前行。显然,所有得到的行版本要进行可见性检查,在返回结果到客户端之前。

看看热更新是如何进行的,我们可以删除索引并truncate表:

DROP INDEX hot_s;
TRUNCATE TABLE hot;

方便起见,我们可以重新定义heap_page函数,把ctid和另两个bit列也输出来:

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
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和update操作:

INSERT INTO hot VALUES (1, 'A');
UPDATE hot SET s = 'B';

该页现在包含一个热更新:

  • 热更新标志位表示执行器将沿着ctid链接着走下去
  • Heap Only元组标志位意味着该元组不被任何索引引用

原文作者:数据库杂记;如有侵权,请联系管理员删除。

请登录后发表评论 登录
全部评论

注册时间:2019-04-22

  • 博文量
    276
  • 访问量
    160265