bigywater.tech
PostgreSQL · 1

PostgreSQL UPDATE 之后旧行还在吗:用 pageinspect 看 xmin、xmax 和 ctid,DELETE 与 ROLLBACK 各留下什么,只更新非索引列 1000 次表仍是 1 页

“PostgreSQL 的 UPDATE 之后旧行还在吗、DELETE 为什么不立刻释放空间” 都要从行版本说起。这篇在本机自己起一个 PostgreSQL 16.15,关掉 autovacuum,用 pageinspect 直接看堆页里每个行指针的 xmin、xmax、t_ctid 和标志位,看 UPDATE、DELETE、ROLLBACK 各留下了什么。

732 → 733
一次 UPDATE 前后行的 xmin:新版本是 733 号事务插入的,旧版本 xmax 填成 733
2 个行指针
UPDATE 之后表面 1 行,堆页里实际有 2 个行指针
1000 次 / 1 页
只更新非索引列 1000 次,全部 HOT 更新,表仍是 8192 字节

一行的系统列

实测 · 本机执行输出
    SQL> create table t(id int primary key, v text)
      CREATE TABLE
    SQL> insert into t values (1, 'a')
      INSERT 0 1
    SQL> select ctid, xmin, xmax, * from t
      ctid  | xmin | xmax | id | v 
      -------+------+------+----+---
       (0,1) |  732 |    0 |  1 | a
      (1 row)
    SQL> select lp, lp_flags, t_xmin as xmin, t_xmax as xmax, t_ctid, array_to_string((heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags, ',') as flags from heap_page_items(get_raw_page('t', 0)) order by lp
      lp | lp_flags | xmin | xmax | t_ctid |               flags                
      ----+----------+------+------+--------+------------------------------------
        1 |        1 |  732 |    0 | (0,1)  | HEAP_HASVARWIDTH,HEAP_XMAX_INVALID
      (1 row)

ctid、xmin、xmax 是每一行隐藏的系统列;pageinspect 能看到堆页里的行指针和标志位。

UPDATE

实测 · 本机执行输出
    SQL> update t set v = 'b' where id = 1
      UPDATE 1
    SQL> select ctid, xmin, xmax, * from t
      ctid  | xmin | xmax | id | v 
      -------+------+------+----+---
       (0,2) |  733 |    0 |  1 | b
      (1 row)
    SQL> select lp, lp_flags, t_xmin as xmin, t_xmax as xmax, t_ctid, array_to_string((heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags, ',') as flags from heap_page_items(get_raw_page('t', 0)) order by lp
      lp | lp_flags | xmin | xmax | t_ctid |                              flags                              
      ----+----------+------+------+--------+-----------------------------------------------------------------
        1 |        1 |  732 |  733 | (0,2)  | HEAP_HASVARWIDTH,HEAP_HOT_UPDATED
        2 |        1 |  733 |    0 | (0,2)  | HEAP_HASVARWIDTH,HEAP_XMAX_INVALID,HEAP_UPDATED,HEAP_ONLY_TUPLE
      (2 rows)
  表面上只有 1 行,但堆页里有 2 个行指针:
    SQL> select count(*) as 行指针数 from heap_page_items(get_raw_page('t', 0))
      行指针数 
      ----------
              2
      (1 row)

插入新版本,旧版本的 xmax 填成更新它的事务号,t_ctid 指向新版本。

DELETE

实测 · 本机执行输出
    SQL> create table d(id int primary key, v text)
      CREATE TABLE
    SQL> insert into d values (1, 'x')
      INSERT 0 1
    会话 A:begin isolation level repeatable read; select count(*) from d -> 1
    SQL> delete from d where id = 1
      DELETE 1
    会话 B:delete from d where id = 1(已提交)
    会话 A(事务还没结束):select count(*) from d -> 1
    新会话 C:select count(*) from d -> 0
    会话 A 提交之后:select count(*) from d -> 0
    SQL> select lp, lp_flags, t_xmin as xmin, t_xmax as xmax, t_ctid, array_to_string((heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags, ',') as flags from heap_page_items(get_raw_page('d', 0)) order by lp
      lp | lp_flags | xmin | xmax | t_ctid |                         flags                          
      ----+----------+------+------+--------+--------------------------------------------------------
        1 |        1 |  735 |  736 | (0,1)  | HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_KEYS_UPDATED
      (1 row)
  行还在堆页里(xmax 已经是删除它的事务号),只是没有人能再看到它,等 VACUUM 来回收

只填 xmax;删除之前开始的 repeatable read 事务仍然看得到这行。

ROLLBACK

实测 · 本机执行输出
    SQL> create table r(id int primary key, v text)
      CREATE TABLE
    SQL> insert into r values (1, 'keep')
      INSERT 0 1
    SQL> select ctid, xmin, xmax, * from r
      ctid  | xmin | xmax | id |  v   
      -------+------+------+----+------
       (0,1) |  738 |  739 |  1 | keep
      (1 row)
    SQL> select lp, lp_flags, t_xmin as xmin, t_xmax as xmax, t_ctid, array_to_string((heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags, ',') as flags from heap_page_items(get_raw_page('r', 0)) order by lp
      lp | lp_flags | xmin | xmax | t_ctid |                                       flags                                       
      ----+----------+------+------+--------+-----------------------------------------------------------------------------------
        1 |        1 |  738 |  739 | (0,2)  | HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID,HEAP_HOT_UPDATED
        2 |        1 |  739 |    0 | (0,2)  | HEAP_HASVARWIDTH,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID,HEAP_UPDATED,HEAP_ONLY_TUPLE
      (2 rows)
  回滚后页里仍然有 2 个版本;读 r 时可见的只有 xmin 已提交的那个版本(ctid (0,1))

回滚的版本留在页里,靠标志位表示无效,不需要撤销。

HOT 更新

实测 · 本机执行输出
    SQL> create table u(id int primary key, v int) with (fillfactor = 100)
      CREATE TABLE
    SQL> insert into u values (1, 0)
      INSERT 0 1
  更新 1000 次之后:
    SQL> select count(*) as 行指针数, count(*) filter (where lp_flags = 1) as 正常指针, count(*) filter (where lp_flags = 2) as 重定向指针, count(*) filter (where lp_flags = 0) as 未使用指针 from heap_page_items(get_raw_page('u', 0))
      行指针数 | 正常指针 | 重定向指针 | 未使用指针 
      ----------+----------+------------+------------
            204 |      188 |          1 |         15
      (1 row)
  表大小:更新前 8192 字节,更新后 8192 字节;页数 1
    SQL> select n_tup_upd, n_tup_hot_upd, n_dead_tup from pg_stat_user_tables where relname = 'u'
      n_tup_upd | n_tup_hot_upd | n_dead_tup 
      -----------+---------------+------------
            1000 |          1000 |        187
      (1 row)

只更新非索引列时,旧版本在同一页内被剪枝,页不会无限变大。

分步演示

行版本实测
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / PostgreSQL 16.15(Homebrew);每个实验自己 initdb 一个数据目录,端口 9261 起,只监听 127.0.0.1,不碰本机已有的 5432 上的实例;命令行工具统一用 LC_ALL=C 取英文原文,路径换成 ;计时用 time.perf_counter 或 EXPLAIN ANALYZE 的 Execution Time,数字每次运行会有小幅抖动;实验结束后整个数据目录删除。本篇关掉了 autovacuum,用 pageinspect 的 heap_page_items 和 heap_tuple_infomask_flags 观察堆页;事务号(732 等)是新建实例上的实际值,换一个实例数字会不同
    • 没有验证:autovacuum 开着时旧版本被回收的时机(见本系列后面的文章);TOAST 大字段的行版本;分区表、临时表、unlogged 表的区别;索引里的版本;PostgreSQL 其他大版本(这里只有 16.15)的行为差异;事务号回卷(wraparound)和冻结;Linux 上的表现
    京ICP备2026067502号-1京公网安备11010502063487号