Each page has a certain inner layout that usually consists of the following parts:
page header

  • page header
  • an array of item pointers
  • free space
  • items (row versions)
  • special space

Page结构示意图:

page.png

pageinspect extension

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
test=# CREATE EXTENSION pageinspect;
CREATE EXTENSION

test=# select * from accounts;
id | client | amount
----+---------+--------
2 | bob | 100.00
3 | bob | 900.00
1 | alice | 800.00
4 | charlie | 100.00
(4 rows)

test=# SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('accounts',0));
lower | upper | special | pagesize
-------+-------+---------+----------
40 | 8032 | 8192 | 8192
(1 row)

test=# select * FROM heap_page_items(get_raw_page('accounts',0));
lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask2 | t_infomask | t_hoff | t_bits | t_oid | t_data
----+--------+----------+--------+--------+--------+----------+--------+-------------+------------+--------+--------+-------+--------------------------------------
1 | 8152 | 1 | 39 | 757 | 758 | 0 | (0,4) | 16387 | 1282 | 24 | | | \x010000000d616c6963650b0081e803
2 | 8112 | 1 | 37 | 757 | 802 | 0 | (0,5) | 16387 | 2306 | 24 | | | \x0200000009626f620b00816400
3 | 8072 | 1 | 37 | 757 | 802 | 0 | (0,6) | 16387 | 2306 | 24 | | | \x0300000009626f620b00818403
4 | 8032 | 1 | 39 | 758 | 802 | 0 | (0,7) | 49155 | 10498 | 24 | | | \x010000000d616c6963650b00812003
5 | 7992 | 1 | 37 | 802 | 0 | 0 | (0,5) | 32771 | 10754 | 24 | | | \x0200000009626f620b00816300
6 | 7952 | 1 | 37 | 802 | 0 | 0 | (0,6) | 32771 | 10754 | 24 | | | \x0300000009626f620b00818303
7 | 7912 | 1 | 39 | 802 | 0 | 0 | (0,7) | 32771 | 10754 | 24 | | | \x010000000d616c6963650b00811f03
8 | 7864 | 1 | 41 | 809 | 0 | 0 | (0,8) | 3 | 2562 | 24 | | | \x0300000011636861726c69650b00816400
9 | 7816 | 1 | 41 | 811 | 0 | 0 | (0,9) | 3 | 2050 | 24 | | | \x0400000011636861726c69650b00816400
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/*
* disk page organization
*
* space management information generic to any page
*
* pd_lsn - identifies xlog record for last change to this page.
* pd_checksum - page checksum, if set.
* pd_flags - flag bits.
* pd_lower - offset to start of free space.
* pd_upper - offset to end of free space.
* pd_special - offset to start of special space.
* pd_pagesize_version - size in bytes and page layout version number.
* pd_prune_xid - oldest XID among potentially prunable tuples on page.
*
* The LSN is used by the buffer manager to enforce the basic rule of WAL:
* "thou shalt write xlog before data". A dirty buffer cannot be dumped
* to disk until xlog has been flushed at least as far as the page's LSN.
*
* pd_checksum stores the page checksum, if it has been set for this page;
* zero is a valid value for a checksum. If a checksum is not in use then
* we leave the field unset. This will typically mean the field is zero
* though non-zero values may also be present if databases have been
* pg_upgraded from releases prior to 9.3, when the same byte offset was
* used to store the current timelineid when the page was last updated.
* Note that there is no indication on a page as to whether the checksum
* is valid or not, a deliberate design choice which avoids the problem
* of relying on the page contents to decide whether to verify it. Hence
* there are no flag bits relating to checksums.
*
* pd_prune_xid is a hint field that helps determine whether pruning will be
* useful. It is currently unused in index pages.
*
* The page version number and page size are packed together into a single
* uint16 field. This is for historical reasons: before PostgreSQL 7.3,
* there was no concept of a page version number, and doing it this way
* lets us pretend that pre-7.3 databases have page version number zero.
* We constrain page sizes to be multiples of 256, leaving the low eight
* bits available for a version number.
*
* Minimum possible page size is perhaps 64B to fit page header, opaque space
* and a minimal tuple; of course, in reality you want it much bigger, so
* the constraint on pagesize mod 256 is not an important restriction.
* On the high end, we can only support pages up to 32KB because lp_off/lp_len
* are 15 bits.
*/

typedef struct PageHeaderData
{
/* XXX LSN is member of *any* block, not only page-organized ones */
PageXLogRecPtr pd_lsn; /* LSN: next byte after last byte of xlog
* record for last change to this page */
uint16 pd_checksum; /* checksum */
uint16 pd_flags; /* flag bits, see below */
LocationIndex pd_lower; /* offset to start of free space */
LocationIndex pd_upper; /* offset to end of free space */
LocationIndex pd_special; /* offset to start of special space */
uint16 pd_pagesize_version;
TransactionId pd_prune_xid; /* oldest prunable XID, or zero if none */
ItemIdData pd_linp[FLEXIBLE_ARRAY_MEMBER]; /* line pointer array */
} PageHeaderData;

typedef PageHeaderData *PageHeader;

Record

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
test=# drop table t;
DROP TABLE
test=# CREATE TABLE t(
test(# id integer GENERATED ALWAYS AS IDENTITY, s text
test(# );
CREATE TABLE
test=# CREATE INDEX ON t(s);
CREATE INDEX
test=# begin;
BEGIN
test=*# INSERT INTO t(s) VALUES ('FOO');
INSERT 0 1
test=*# SELECT pg_current_xact_id();
pg_current_xact_id
--------------------
766
(1 row)

test=*# select * from heap_page_items(get_raw_page('t',0))\gx
-[ RECORD 1 ]-------------------
lp | 1
lp_off | 8160
lp_flags | 1
lp_len | 32
t_xmin | 766
t_xmax | 0
t_field3 | 0
t_ctid | (0,1)
t_infomask2 | 2
t_infomask | 2050
t_hoff | 24
t_bits |
t_oid |
t_data | \x0100000009464f4f

test=# SELECT '(0,'||lp||')' AS ctid,
test-# CASE lp_flags
test-# WHEN 0 THEN 'unused'
test-# WHEN 1 THEN 'normal'
test-# WHEN 2 THEN 'redirect to '||lp_off
test-# WHEN 3 THEN 'dead'
test-# END AS state,
test-# t_xmin as xmin,
test-# t_xmax as xmax,
test-# (t_infomask & 256) > 0 AS xmin_committed,
test-# (t_infomask & 512) > 0 AS xmin_aborted,
test-# (t_infomask & 1024) > 0 AS xmax_committed,
test-# (t_infomask & 2048) > 0 AS xmax_aborted
test-# FROM heap_page_items(get_raw_page('t',0));
ctid | state | xmin | xmax | xmin_committed | xmin_aborted | xmax_committed | xmax_aborted
-------+--------+------+------+----------------+--------------+----------------+--------------
(0,1) | normal | 766 | 0 | f | f | f | t
(1 row)

Reference:

【转】AntDB/PostgreSQL内部原理:表Page结构解析_postgresql对应antdb的版本-CSDN博客

确认 I/O 是否是瓶颈,需要从多个角度综合判断, 瓶颈可能体现在磁盘、文件系统甚至内核调度上。

1. 使用系统工具监控 I/O

Linux 常用:

  1. iostat

    1
    iostat -x 1
    • %util 高接近 100% → 磁盘基本饱和。
    • await 高 → 每次 I/O 延迟大。
    • r/sw/s → 每秒读写次数,衡量吞吐能力。
  2. iotop

    • 实时显示哪个进程在进行 I/O 以及占用的 I/O 带宽。
    • 可以确认是单个进程占用过多 I/O 还是多个进程平均分摊。
  3. vmstat

    1
    vmstat 1
    • bi/bo 字段表示每秒块设备读写量。
    • wa 字段表示 CPU 等待 I/O 的百分比,高的话说明 I/O 成为 CPU 等待瓶颈。
  4. dstat / perf stat

    • 更细粒度监控吞吐量、延迟和上下文切换。

2. 检查磁盘吞吐能力

  • 测试磁盘最大写入带宽:

    1
    dd if=/dev/zero of=/tmp/testfile bs=1M count=1024 oflag=direct
    • oflag=direct 避免缓存干扰。
    • 如果测试带宽接近你程序的写入量,磁盘可能就是瓶颈。
  • 同理,可以测试并发读写:

    1
    fio --name=randrw --rw=randrw --bs=4k --size=1G --numjobs=10 --runtime=60 --group_reporting
    • fio 可以模拟多线程读写负载,测出 IOPS 和吞吐量。

3. 观察系统行为

  • CPU vs I/O 时间

    • topperf top 中如果 CPU 很空闲,程序主要在等待 I/O → I/O 瓶颈。
  • 延迟累积

    • 如果程序写入文件很慢,但 CPU 使用低且磁盘高负载 → 瓶颈在 I/O。
  • 锁与等待

    • 并发写入时,文件系统锁、页缓存锁也会引入“假 I/O 瓶颈”,需要 perf traceblktrace 排查。

Ubuntu安装wordpress

  • 安装apache:

    1
    2
    3
    sudo apt update
    sudo apt install apache2 -y
    chown -R www-data:www-data /var/www/html
  • 安装php

    1
    2
    yum install -y php php-mysql php-json
    yum install php php-mysqlnd php-json
    1
    2
    sudo apt install php libapache2-mod-php php-mysql -y
    apt install php-cli php-curl php-gd php-mbstring php-xml php-zip -y
  • 安装database

    1
    2
    sudo yum install -y mariadb-server
    yum install -y mysql-server
    1
    apt install mysql-server -y

    修改密码:

    1
    2
    3
    4
    5
    6
    7
    ALTER USER 'root'@'localhost' IDENTIFIED BY '12345';

    CREATE DATABASE wordpress_db;
    CREATE USER 'wp_user'@'localhost' IDENTIFIED BY 'password';
    GRANT ALL PRIVILEGES ON wordpress_db.* TO 'wp_user'@'localhost';
    FLUSH PRIVILEGES;
    EXIT;
  • 安装wordpress

    1
    2
    3
    4
    5
    cd /var/www/html
    wget https://wordpress.org/latest.tar.gz
    tar -xvzf latest.tar.gz
    chown -R www-data:www-data /var/www/html/wordpress
    chmod -R 755 /var/www/html/wordpress
  • 编辑 Apache 默认虚拟主机配置

    1
    sudo nano /etc/apache2/sites-available/000-default.conf

    找到:
    DocumentRoot /var/www/html
    改成:
    DocumentRoot /var/www/html/wordpress

  • /etc/apache2/apache2.conf 添加:

    1
    2
    3
    4
    5
    <Directory /var/www/html/wordpress>
    Options Indexes FollowSymLinks
    AllowOverride All
    Require all granted
    </Directory>

    如果你希望支持 WordPress 的 .htaccess 功能,请保留 AllowOverride All

  • 固定连接

    1
    2
    3
    apache2ctl -M | grep rewrite
    a2enmod rewrite
    systemctl restart apache2

    在 WordPress 后台重新保存固定链接

  • 重启服务

    1
    systemctl restart apache2

从wal逻辑结构来看,WAL 可被描述为变长日志条目流。每个条目都包含有关特定操作的一些数据,并以标准标头作为前缀。该标头提供的信息包括但不限于:

  • 与条目(entry)相关的事务 ID
  • 解释条目的资源管理器
  • 用于检测数据损坏的校验和
  • 条目长度
  • 对前一个 WAL 条目的引用

    WAL 通常是按正向读取的,但某些工具(例如 pg_rewind)可能会反向扫描它

WAL 数据本身可以具有不同的格式和含义。例如,它可能是一段页片段(page fragment),需要替换某个页面中指定偏移处的一部分内容。相应的资源管理器(resource manager)必须知道如何解析并重放这一特定条目。针对表、各种索引类型、事务状态以及其他实体,PostgreSQL 都有独立的资源管理器来处理它们各自的 WAL。

WAL 文件会占用服务器共享内存中的特殊缓冲区。用于 WAL 的缓存大小由参数 wal_buffers 决定。默认情况下,这个大小会自动设为总缓冲区缓存(buffer cache)大小的 1/32。

WAL 缓存与缓冲区缓存(buffer cache)非常相似,但它通常以环形缓冲区(ring buffer)的方式运行:新的日志条目被添加到缓冲区的头部,而旧的条目则从尾部开始写入磁盘。如果 WAL 缓存太小,就会比必要的更频繁地进行磁盘同步操作。

在系统负载较低的情况下,插入位置(即缓冲区的头部)几乎总是与已经写入磁盘的条目位置(即缓冲区的尾部)保持一致。

1
2
3
4
5
demo=# SELECT pg_current_wal_lsn(), pg_current_wal_insert_lsn();
pg_current_wal_lsn | pg_current_wal_insert_lsn
--------------------+---------------------------
1/EBDC2CD8 | 1/EBDC2CD8
(1 row

在PostgreSQL 10之前,所有函数名称都包含XLOG首字母缩写词,而不是WAL。

为了引用某个特定的日志条目,PostgreSQL 使用一种特殊的数据类型:pg_lsn(日志序列号,Log Sequence Number,简称 LSN)。它表示从 WAL 起始位置开始,以字节为单位的 64 位偏移量。LSN 通常以两个十六进制数字表示,中间用斜杠(/)分隔。

我们创建一个表:

1
2
3
4
5
6
7
8
9
10
11
12
13
demo=# CREATE TABLE wal(id integer);
CREATE TABLE
demo=# INSERT INTO wal VALUES (1);
INSERT 0 1
启动一个事务,并记录下当前 WAL 插入位置的 LSN。

demo=# begin;
BEGIN
demo=*# SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
---------------------------
1/EBDDA4F8
(1 row)

现在执行一个任意命令,例如,更新一行数据。

1
2
demo=*# UPDATE wal SET id = id + 1;
UPDATE 1

页面的修改是在 RAM 中的缓冲区缓存(buffer cache)中进行的。这个更改也会记录在位于 RAM 中的 WAL 页面中。因此,插入的 LSN 会向前推进。

1
2
3
4
5
demo=*# SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
---------------------------
1/EBDDA5E8
(1 row)

为了确保修改后的数据页是在对应的 WAL 条目之后才被刷新到磁盘,数据页的页头会存储该页最新相关的 WAL 条目的 LSN。你可以使用 pageinspect 插件查看这个 LSN。

1
2
3
4
5
demo=*# SELECT lsn FROM page_header(get_raw_page('wal',0));
lsn
------------
1/EBDDA5B0
(1 row)

整个数据库集群只有一个 WAL,并且新的条目会不断地追加到其中。因此,存储在数据页中的 LSN 可能会比 之前某个时刻 pg_current_wal_insert_lsn() 返回的 LSN 更小。但如果系统中没有发生任何操作,这两个数值将会相同。

现在提交这个事务

1
2
3
4
5
6
7
8
9
demo=*# commit;
COMMIT
commit操作同样被日志记录,同时insert lsn再次改变

demo=# SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
---------------------------
1/EBDDC500
(1 row)

为了确保某个 CLog 页在对应的 WAL 条目写入磁盘之前不会被刷新到磁盘,必须追踪该页所对应的最新 WAL 条目的 LSN。但这种 LSN 信息是保存在内存(RAM)中的,而不是存在 CLog 页本身

某个时刻,WAL 日志条目会被写入磁盘;此时,才能把对应的 CLOG 和数据页从缓存中淘汰(evict)。如果必须更早淘汰这些缓存页,那么系统会发现这一点,并会先强制将对应的 WAL 条目写入磁盘。
如果你知道两个 LSN(日志序列号)的位置,就可以通过简单地相减计算这两者之间的 WAL 日志大小(以字节为单位)。只需将它们转换为 pg_lsn 类型即可进行减法运算

1
2
3
4
5
demo=# demo=# SELECT '1/EBDDC500'::pg_lsn - '1/EBDDA4F8'::pg_lsn;
?column?
----------
8200
(1 row)

在这个具体案例中,更新(update)和提交(commit)操作相关的 WAL 条目大约占用了几千字节。可以用相同的方法,估算某个工作负载在单位时间内产生的 WAL 日志量。
这些信息对设置检查点(checkpoint)参数非常重要。

参考书目

  1. Egor Rogov, PostgreSQL 14 Internals, https://postgrespro.com/community/books/internals

在磁盘上,WAL 被存储在 PGDATA/pg_wal 目录中,以单独的文件(或称为段)的形式存在。它们的大小由只读参数 wal_segment_size 指示。

对于高负载系统,增加段大小可能是有意义的,因为这可以减少开销。但这个设置只能在集群初始化时修改(通过 initdb –wal-segsize)。

WAL 记录会写入当前文件,直到该文件空间耗尽;此时 PostgreSQL 会开始写入一个新文件。

我们可以确定某条记录位于哪个文件中,以及它在该文件起始位置的偏移量。

1
2
3
4
5
demo=# SELECT file_name, upper(to_hex(file_offset)) file_offset FROM pg_walfile_name_offset('1/EBDDC500');
file_name | file_offset
--------------------------+-------------
0000000100000001000000EB | DDC500
(1 row)

该文件的名称由两部分组成。最高的八位十六进制数字(4个字节)表示用于从备份中恢复的时间线(timeline),而其余部分(8个字节)表示 LSN(日志序列号)的高位比特(LSN 的低位比特则体现在 file_offset 字段中)。

要查看当前的 WAL 文件,可以调用以下函数:

1
2
3
4
5
6
7
demo=# SELECT *
FROM pg_ls_waldir()
WHERE name = '0000000100000001000000EB';
name | size | modification
--------------------------+----------+------------------------
0000000100000001000000EB | 16777216 | 2025-07-28 18:41:49+08
(1 row)

现在让我们使用 pg_waldump 工具查看新创建的 WAL 记录的头部信息。该工具既可以按 LSN 范围(就像这个例子中那样)过滤 WAL 记录,也可以按特定的事务 ID 过滤。

pg_waldump 工具应以 postgres 用户身份运行,因为它需要访问磁盘上的 WAL 文件。

1
2
3
4
5
6
7
8
9
postgres@lavm-bar1guved6:/root$ pg_waldump -p /usr/local/pgsql/data/pg_wal -s  1/EBDDA4F8 -e 1/EBDDC500
rmgr: XLOG len (rec/tot): 49/ 109, tx: 0, lsn: 1/EBDDA4F8, prev 1/EBDDA4C0, desc: FPI_FOR_HINT , blkref #0: rel 1663/32814/376833 blk 0 FPW
rmgr: Heap len (rec/tot): 69/ 69, tx: 961, lsn: 1/EBDDA568, prev 1/EBDDA4F8, desc: HOT_UPDATE old_xmax: 961, old_off: 1, old_infobits: [], flags: 0x40, new_xmax: 0, new_off: 2, blkref #0: rel 1663/32814/376833 blk 0
rmgr: Standby len (rec/tot): 54/ 54, tx: 0, lsn: 1/EBDDA5B0, prev 1/EBDDA568, desc: RUNNING_XACTS nextXid 962 latestCompletedXid 960 oldestRunningXid 961; 1 xacts: 961
rmgr: XLOG len (rec/tot): 49/ 7777, tx: 961, lsn: 1/EBDDA5E8, prev 1/EBDDA5B0, desc: FPI_FOR_HINT , blkref #0: rel 1663/32814/2691 blk 19 FPW
rmgr: Standby len (rec/tot): 54/ 54, tx: 0, lsn: 1/EBDDC468, prev 1/EBDDA5E8, desc: RUNNING_XACTS nextXid 962 latestCompletedXid 960 oldestRunningXid 961; 1 xacts: 961
rmgr: Transaction len (rec/tot): 34/ 34, tx: 961, lsn: 1/EBDDC4A0, prev 1/EBDDC468, desc: COMMIT 2025-07-28 16:04:55.325979 CST
rmgr: Standby len (rec/tot): 50/ 50, tx: 0, lsn: 1/EBDDC4C8, prev 1/EBDDC4A0, desc: RUNNING_XACTS nextXid 962 latestCompletedXid 961 oldestRunningXid 962
postgres@lavm-bar1guved6:/root$
  1. FPI_FOR_HINT(全页镜像,为 Hint Bit)
    1
    rmgr: XLOG len (rec/tot): 49/109, tx: 0, lsn: 1/EBDDA4F8, prev 1/EBDDA4C0, desc: FPI_FOR_HINT , blkref #0: rel 1663/32814/376833 blk 0 FPW
  • rmgr: XLOG:表示这是 XLOG(日志)资源管理器记录。
  • FPI_FOR_HINT:全页镜像用于设置 Hint bit。为了避免 Hint bit 修改没有日志而导致数据页 checksum 校验失败,PostgreSQL 会把整个页面写入 WAL(FPW, Full Page Write)。
  • rel 1663/32814/376833 blk 0:指的是某个表的第 0 页(block 0),文件标识符是:数据库OID=32814,表OID=376833。
  • tx: 0:不是某个事务产生的,而是后台 hint bit 的写入。
  • FPW:全页写入。
  1. HOT_UPDATE(堆表中的更新)
    1
    rmgr: Heap len (rec/tot): 69/69, tx: 961, lsn: 1/EBDDA568, prev 1/EBDDA4F8, desc: HOT_UPDATE old_xmax: 961, old_off: 1, old_infobits: [], flags: 0x40, new_xmax: 0, new_off: 2, blkref #0: rel 1663/32814/376833 blk 0
  • rmgr: Heap:这是 Heap 表的更新记录。
  • HOT_UPDATE:表示使用了“Heap-Only Tuple”优化,即更新没有修改索引字段,所以新旧 tuple 都在一个页里。
  • tx: 961:由事务 961 发起。
  • old_off: 1 -> new_off: 2:第 1 个 tuple 更新为第 2 个位置的 tuple。
  • old_xmax: 961:原始 tuple 的删除者是当前事务。
  • new_xmax: 0:新 tuple 尚未被删除。
  • rel 1663/32814/376833 blk 0:仍然是这个表第 0 页
  1. RUNNING_XACTS(记录活跃事务信息)
    1
    rmgr: Standby len (rec/tot): 54/54, tx: 0, lsn: 1/EBDDA5B0, prev 1/EBDDA568, desc: RUNNING_XACTS nextXid 962 latestCompletedXid 960 oldestRunningXid 961; 1 xacts: 961
  • rmgr: Standby:这是为备机记录活跃事务信息。
  • nextXid: 962:下一个将被分配的事务 ID。
  • latestCompletedXid: 960:最后一个完成的事务。
  • oldestRunningXid: 961:最老的活跃事务。
  • 1 xacts: 961:当前只有一个活跃事务 961。
    这类记录有助于逻辑解码和备机恢复时判断哪些事务是已提交、未提交。
  1. FPI_FOR_HINT(另一个 hint bit 的全页写入)
    1
    rmgr: XLOG len (rec/tot): 49/7777, tx: 961, lsn: 1/EBDDA5E8, prev 1/EBDDA5B0, desc: FPI_FOR_HINT , blkref #0: rel 1663/32814/2691 blk 19 FPW
  • 又是一个 FPI_FOR_HINT,但这次是针对:
    rel 1663/32814/2691 blk 19:另外一个表的第 19 页。
  • 注意这次记录总长度达到了 7777 字节,很可能是完整的数据页写入(通常 8KB)。
  1. RUNNING_XACTS(再次记录活跃事务)

    1
    rmgr: Standby len (rec/tot): 54/54, tx: 0, lsn: 1/EBDDC468, prev 1/EBDDA5E8, desc: RUNNING_XACTS nextXid 962 latestCompletedXid 960 oldestRunningXid 961; 1 xacts: 961

    和之前类似,再次记录活跃事务 961。

  2. COMMIT(事务提交)

    1
    rmgr: Transaction len (rec/tot): 34/34, tx: 961, lsn: 1/EBDDC4A0, prev 1/EBDDC468, desc: COMMIT 2025-07-28 16:04:55.325979 CST
  • 事务 961 正式提交。
  • 提交时间 是 2025-07-28 16:04:55。
  1. RUNNING_XACTS(提交后活跃事务清空)
    1
    rmgr: Standby len (rec/tot): 50/50, tx: 0, lsn: 1/EBDDC4C8, prev 1/EBDDC4A0, desc: RUNNING_XACTS nextXid 962 latestCompletedXid 961 oldestRunningXid 962
  • 事务 961 已完成,现在没有活跃事务了。
  • nextXid 为 962,准备分配给下一个事务。

查看日志文件路径

1
2
3
4
5
demo=# SELECT pg_relation_filepath('wal');
pg_relation_filepath
----------------------
base/32814/376833
(1 row)

参考书目

  1. Egor Rogov, PostgreSQL 14 Internals, https://postgrespro.com/community/books/internals

存储引擎之所以能够快速定位数据,离不开索引。B树索引是历经考验、使用最广泛的一种索引结构。pg中的B树索引是为ordinal data types(可以比较和排序)设计的。

一 结构

每一个节点就是一个页(Page)。page的大小定义决定了索引node的容量;每个节点(node)由多个element组成(element包括 索引key 和 一个指针)。内部节点中的元素指向下一层的节点;叶子节点中的元素则指向堆中的元组。这种结构就是 PostgreSQL 中 B-Tree 索引的基础:内部节点用于导航,叶子节点保存指向真实数据的引用。

二 特性

  1. 有序(Orderable): 所有 B-Tree 索引按照给定的顺序存储值,支持 ASC/DESC 和 NULLS FIRST/LAST 等排序选项
  2. 叶子结点存储数据(key以及tuple的指针),内部结点存储key
  3. 每一层除了最有结点,均存储一个高键(high key):每个节点中最大的值。The first entry in this page contains the high key
  4. 叶子页之间有双向链表指针(左兄弟/右兄弟),用于范围扫描(BETWEEN、ORDER BY 等范围查询优化)。

三 多列索引

一个索引文件,存储多列键值组合:索引条目(index tuple)中存储这几列的值作为一个组合键。
多列索引的比较是逐列进行的,先比较第1列 a,如果相等,再比较第2列 b,依次类推。pg使用逐字段比较器(每列使用其数据类型对应的 < 运算符)逐列比较
默认情况下,索引值是按照升序(ASC)排列的,但如果需要,你也可以指定为降序(DESC)。如果索引是基于单列创建的,排序顺序通常无所谓,因为扫描可以沿任意方向进行。但在多列索引中,排序顺序就变得很重要了。

PostgreSQL 多列 B-tree 索引的匹配原则

  • PostgreSQL 的多列索引(比如 (a, b))是按列的 最左前缀(left-prefix)顺序构建的。
  • 能有效利用索引的条件,必须从第一列开始匹配,且满足索引的顺序关系。

PostgreSQL 多列索引中,当你只指定了“非第一列”的查询条件时,理论上有一种优化方法叫做 Skip Scan:
例如:

1
2
CREATE INDEX idx_ab ON mytable(a, b);
SELECT * FROM mytable WHERE b = 42;

这个时候由于 没有给出 a 的值,PostgreSQL 的 B-tree 无法用这个索引来直接查找。

但是,理论上如果第一列(a)的取值不多,比如只有 v1, v2, …, vn,查询可以被改写为多次扫描:

1
2
3
4
SELECT * FROM mytable WHERE a = v1 AND b = 42;
SELECT * FROM mytable WHERE a = v2 AND b = 42;
...
SELECT * FROM mytable WHERE a = vn AND b = 42;

每次都能利用索引 (a, b) 的“最左前缀”性质进行查找,然后再合并结果。这就是 Skip Scan 的思路。

PostgreSQL 当前 不支持 Skip Scan

四 include

B-tree 索引还可以通过 INCLUDE 子句扩展额外的列,这些列不参与查找,但可以包含在索引中。

1
CREATE INDEX idx_ab_inc ON t(a, b) INCLUDE (c, d);

这样可以使某些 SELECT 查询满足 Index-Only Scan(覆盖索引),避免回表. 类似于mysql 的聚族索引(和聚族索引不同的是:include属于冗余存储)

索引属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 SELECT p.name,
pg_index_has_property('flights_pkey', p.name)
FROM unnest(array[
'clusterable',
'index_scan',
'bitmap_scan',
'backward_scan'
]) p(name);

name | pg_index_has_property
------------------+-----------------------
clusterable | t
index_scan | t
bitmap_scan | t
backward_scan | t

clusterable

clusterable 表示索引是否支持用于 CLUSTER 操作。
CLUSTER 命令会按照指定的索引顺序,对表中的数据行进行物理重排,让表的数据页顺序与索引顺序一致。
这样可以提高基于该索引的扫描性能,因为数据的物理顺序和索引顺序相同,减少随机 I/O。

使用示例

1
CLUSTER my_table USING my_index;
  • 会根据 my_index 的顺序,重新排列 my_table 的物理存储。
  • 聚簇后的表,在按该索引扫描时性能更好

影响和注意点

  • 聚簇是一次性操作,执行后数据会按索引顺序存储,但后续的 INSERT、UPDATE 可能打乱这个顺序。
  • 如果表数据频繁更新,聚簇效果会逐渐减弱,需要定期重新执行 CLUSTER。
  • 聚簇对大表的操作比较重,执行时表会被锁。

index_scan

索引是否支持普通的 Index Scan(例如 WHERE id = 123)

bitmap_scan

Bitmap Scan 是 PostgreSQL 查询计划中的一种索引访问方法,主要用于当多个条件组合过滤时,或者单个索引扫描返回大量行时,提高访问效率的技术。
它分两步完成:

  1. Bitmap Index Scan:先扫描索引,找到所有符合条件的行的 TID(物理行指针),把它们用一个“位图”(bitmap)来表示;
  2. Bitmap Heap Scan:再根据这个位图去访问表的 heap 页,只读取需要的行,避免全表扫描。

为什么要用 Bitmap Scan?

  1. 当单个条件筛选出的行比较多时,普通索引扫描会频繁跳页,导致随机 I/O 增加。
  2. 多个条件联合过滤时,可以对多个索引分别做 Bitmap Index Scan,合并位图后再访问表。
  3. 通过先用位图标记符合条件的行,再按物理顺序访问表页,减少随机访问,提高缓存命中率。

优点:

  1. 适合返回大量结果的索引查询;
  2. 通过减少随机访问,降低 I/O;
  3. 支持多个索引结果合并,提高复杂查询效率。

缺点:

  1. 需要额外的内存存储位图,位图过大会消耗较多资源;
  2. 对于返回行很少的查询,普通索引扫描往往更快。

backward_scan

即索引扫描支持双向遍历:可以从索引的左端(最小键)开始向右扫描,也可以从索引的右端(最大键)开始向左扫描。例如 ORDER BY id DESC 时利用该索引

关于索引膨胀

索引可能会随着插入和删除不断膨胀,而不会自然收缩,需要通过重建或 REINDEX 来处理。

  1. 当需要向节点中插入数据而发现节点已满时,PostgreSQL 会先尝试“修剪”冗余数据(例如:删除已过期或无效的元组),希望通过回收空间来避免进一步拆分。
  2. 在 PostgreSQL 的 B-tree 实现中,节点一旦因为插入新数据而被拆分,就不会再被合并回来。哪怕后续通过 vacuum 操作清理了旧数据,节点中元素数量减少,也不会自动合并。
  3. 标准的 B-tree 数据结构理论上是支持合并操作的(比如删除数据后可合并空节点),但 PostgreSQL 的实现为了简化逻辑或出于性能原因,没有实现这一特性

参考书目

postgres internals 14

pgbench is a benchmarking tool bundled with PostgreSQL, designed to simulate a TPC-B-like workload, not a full TPC-C

1. Initialize the Test Database

This sets up the schema and populates data.

1
pgbench -i -s 10 mydb
  • -i: Initialize the database.
  • -s 10: Scale factor. Each scale unit ~100,000 rows in the pgbench_accounts table.
  • mydb: The database to test.

2. Run a Simple Benchmark Test

1
pgbench -c 10 -j 2 -T 60 mydb
  • -c 10: 10 concurrent clients.
  • -j 2: 2 threads.
  • -T 60: Run for 60 seconds.
  • mydb: Target database.

It will output something like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[postgres@iZ2ze4mflpfiplp0evcw8gZ root]$ pgbench -c 10 -j 2 -T 60 mydb
pgbench (18devel)
starting vacuum...end.
transaction type: <builtin: TPC-B (sort of)>
scaling factor: 10
query mode: simple
number of clients: 10
number of threads: 2
maximum number of tries: 1
duration: 60 s
number of transactions actually processed: 12841
number of failed transactions: 0 (0.000%)
latency average = 46.726 ms
initial connection time = 39.072 ms
tps = 214.013767 (without initial connection time)
[postgres@iZ2ze4mflpfiplp0evcw8gZ root]$

3. Run Custom SQL Scripts

You can benchmark with custom SQL transactions:

1
pgbench -f myscript.sql -c 10 -T 60 mydb

Where myscript.sql contains something like:

1
2
3
4
BEGIN;
SELECT * FROM pgbench_accounts WHERE aid = :aid;
UPDATE pgbench_accounts SET abalance = abalance + :delta WHERE aid = :aid;
END;

Use :variable for substitution. We can define variables using -D:

1
pgbench -f myscript.sql -D aid=12345 -D delta=50 -c 10 -T 60 mydb

之前学习过各种异象,总是记乱,整理成文档备份

为了更好理解相关异象,做如下数据准备:

1
2
3
4
5
6
7
CREATE TABLE accounts(
id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
client text,
amount numeric
);

INSERT INTO accounts VALUES(1, 'alice', 1000.00), (2, 'bob', 100.00), (3, 'bob', 900.00);

Lost Update

当两个事务读取同一表行,然后其中一个事务更新该行,最后另一个事务更新同一行而不考虑第一个事务所做的任何更改时,就会发生丢失更新异常。

Dirty Reads

当一个事务读到了其他事务尚未尚未提交的更改时,就会发生脏读异常

Phantom Reads anomaly

当同一个事务执行两个相同的查询返回一组满足特定条件的行,而另一个事务添加一些满足该条件的其他行并在这些查询之间的时间间隔内提交更改时,就会发生幻读异常。

Read skew

概念: 读到一部分老数据,又读到一部分新数据。

假设我们要在bob的账户之间转账
Transaction1

1
2
3
4
5
woodhead=# BEGIN;
BEGIN
woodhead=*# UPDATE accounts SET amount = amount - 100 WHERE id = 2;
UPDATE 1
woodhead=*#

与此同时,另一个事务2 开始循环bob的账户计算bob的账户结余(金额)
Transaction2

1
2
3
4
5
6
7
woodhead=# BEGIN;
BEGIN
woodhead=*# SELECT amount FROM accounts WHERE id = 2;
amount
--------
100.00
(1 row)

这个时候,事务1提交成功:

1
2
3
4
woodhead=*# UPDATE accounts SET amount = amount + 100 WHERE id = 3;
UPDATE 1
woodhead=*# commit;
COMMIT

事务2 开始读取第二个账户(读已提交的隔离状态下会读取到已提交的数据)

1
2
3
4
5
6
7
woodhead=*# SELECT amount FROM accounts WHERE id = 3;
amount
---------
1000.00
(1 row)
woodhead=*# commit;
COMMIT

最终结果:事务2读取到了1100块钱,因为它读到了不正确的数据,这种异常叫做读偏序

Write skew

概念: 基于同一份旧数据做决策,但修改了不同的数据。更精确:两个事务读到同一个快照,各自认为自己的修改是安全的,但合在一起破坏了约束。

我们定义如下一致性规则:只要总余额非负,就允许客户的部分账户出现负余额。

事务1获取总额

1
2
3
4
5
6
7
8
9
10
11
12
13
14
woodhead=# select * from accounts;
id | client | amount
----+--------+---------
1 | alice | 1000.00
2 | bob | 200
3 | bob | 700
(3 rows)
woodhead=# BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
woodhead=*# SELECT sum(amount) FROM accounts WHERE client = 'bob';
sum
-----
900
(1 row)

事务2同样获取总额

1
2
3
4
5
6
7
woodhead=# BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
woodhead=*# SELECT sum(amount) FROM accounts WHERE client = 'bob';
sum
-----
900
(1 row)

第一个事务合理地假设,它可以从某个账户扣除 600 元。

1
2
woodhead=*# UPDATE accounts SET amount = amount - 600.00 WHERE id = 2;
UPDATE 1

第二个事务得到同样的结论,但是从另一个账户扣除:

1
2
3
4
woodhead=*# UPDATE accounts SET amount = amount - 600.00 WHERE id = 3;
UPDATE 1
woodhead=*# commit;
COMMIT

事务1也提交,然后查询:

1
2
3
4
5
6
7
8
woodhead=*# commit;
COMMIT
woodhead=# SELECT * FROM accounts WHERE client = 'bob';
id | client | amount
----+--------+---------
2 | bob | -400.00
3 | bob | 100.00
(2 rows)

现在bob的总余额是负的,虽然每个事务单独运行都是正确的。这种异常叫写偏序

要在发生故障后恢复数据一致性(即执行恢复操作),PostgreSQL 需要向前回放 WAL 日志,并将其中表示丢失变更的记录应用到相应的数据页上。为了确定哪些变更丢失了,系统会将磁盘上数据页的 LSN(日志序列号)与 WAL 记录的 LSN 进行比较。但问题是,我们应该从哪里开始恢复?如果恢复起点选得太晚,那么在此之前已经写入磁盘的数据页将无法接收到所有应有的变更,最终导致无法修复的数据损坏。而从日志的起始位置开始恢复又不现实:不仅无法长期保存如此巨量的数据,也无法接受过长的恢复时间。因此,我们需要一个不断向前推进的检查点(checkpoint),从而可以从这个位置安全地开始恢复,同时删除所有更早的 WAL 记录。

创建检查点最直接的方式是:定期暂停系统所有操作,并将所有脏页强制刷新到磁盘。但这种方式显然是不可接受的,因为系统会因此暂停不定但相当长的时间。

正因为如此,PostgreSQL 将检查点的过程分摊到一段时间内完成,实际上构成了一个“区间”(interval)。检查点的执行是由一个特殊的后台进程负责的,这个进程叫做 checkpointer(检查点进程)

检查点开始(Checkpoint start):

checkpointer 进程会将所有可以立即写入磁盘的内容进行刷新,包括:

  • CLOG(提交日志)中的事务状态信息,
  • 子事务的元数据,
  • 以及其他一些结构。

检查点执行过程(Checkpoint execution):

检查点执行的大部分时间都耗费在将 脏页(dirty pages)刷新到磁盘上。

首先,在检查点开始时,所有当时处于“脏”状态的缓冲区(buffer)的页头会被打上一个特殊标记(tag)。这个过程非常迅速,因为它不涉及任何 I/O 操作,只是内存中的标记设置。

随后,checkpointer 会遍历所有缓冲区,并将带有该标记的页写入磁盘。这些页不会被驱逐出缓存(即它们仍然保留在缓冲池中),只是被刷盘,因此在这个过程中可以忽略使用计数(usage count)和 pin 计数(pin count)。

页面按 ID 顺序处理,以尽可能避免随机写入。为实现更好的负载均衡,PostgreSQL 会在多个表空间之间交替进行写入(因为它们可能位于不同的物理设备上)。

后端进程(backend)也可以将打了标记的缓冲页写入磁盘 —— 如果它们先访问到了这些页的话。无论由谁写入,缓冲区的标记都会在这个阶段被清除,因此每个缓冲页在此次检查点中只会被写一次。

很自然地,在 checkpoint 进行期间,缓冲区中的页面仍然可能被修改。但由于这些新的脏页没有被打上标记,checkpointer 会忽略它们。

检查点完成:

当在检查点开始时被标记为脏的所有缓冲页都已经写入磁盘后,检查点就被视为完成。从现在起(但不是在此之前!),本次检查点的起始位置将被作为恢复操作的新起点。在这个点之前写入的所有 WAL 日志都不再需要了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Time →
LSN:
┌──────────────────────────────────────────────────────────────────────┐
│ │
0 ──┴─────┬───────────────────────────────────────────┬────────────┬───────┘
│ │ │
▼ ▼ ▼
1 2 3
✔ 若 3 写入成功:恢复可从 2 开始(即 redo = 2)
✘ 若 3 写入失败:恢复只能从 1 开始(上次 checkpoint)

说明:
- 1:上一次 checkpoint 的起始 LSN(redo)
- 2:本次 checkpoint 开始时wallog最大LSN(新的redo点)
- 3:本次 checkpoint 完成后写入 WAL 的记录(记录了 redo=2)

最后,checkpointer 进程会创建一条表示检查点完成的 WAL 记录,并在其中标明此次检查点的起始 LSN。由于检查点在开始时不会写入任何日志,因此这个起始 LSN 可以是任意类型的 WAL 记录所属的 LSN。
此外,PGDATA/global/pg_control 文件也会被更新,以指向最近完成的检查点。(在此过程完成之前,pg_control 始终保留着上一个检查点的信息。)

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
=> UPDATE big SET s = 'FOO';
=> SELECT count(*) FROM pg_buffercache WHERE isdirty;
count
−−−−−−−
4119
(1 row)

=> SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
−−−−−−−−−−−−−−−−−−−−−−−−−−−
0/3E7EF7E0 (1 row)

=> CHECKPOINT;
=> SELECT count(*) FROM pg_buffercache WHERE isdirty;
count
−−−−−−−
0
(1 row)

=> SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
−−−−−−−−−−−−−−−−−−−−−−−−−−−
0/3E7EF890
(1 row)

最新的 WAL 条目与检查点完成有关(CHECKPOINT_ONLINE)。该检查点的起始 LSN 出现在 redo 之后;这个位置对应的是检查点开始时最新插入的 WAL 条目。

同样的信息也可以在 pg_control 文件中找到。

1
2
3
postgres$ /usr/local/pgsql/bin/pg_controldata \
-D /usr/local/pgsql/data | egrep 'Latest.*location' Latest checkpoint location: 0/3E7EF818
Latest checkpoint's REDO location: 0/3E7EF7E0

参考书目

  1. Egor Rogov, PostgreSQL 14 Internals, https://postgrespro.com/community/books/internals
0%