全局hash

LockMethodLockHash:存储LOCK
LockMethodProcLockHash:存储PROCLOCK
LockMethodLocalHash:存储LOCALLOCK

同一个LOCK资源对象可以被多个不同的 PROCLOCK 持有,而这些 PROCLOCK 又分别属于不同的进程。

LOCKTAG

1
2
3
4
5
6
7
8
9
typedef struct LOCKTAG
{
uint32 locktag_field1; /* a 32-bit ID field */
uint32 locktag_field2; /* a 32-bit ID field */
uint32 locktag_field3; /* a 32-bit ID field */
uint16 locktag_field4; /* a 16-bit ID field */
uint8 locktag_type; /* see enum LockTagType */
uint8 locktag_lockmethodid; /* lockmethod indicator */
} LOCKTAG;
  • locktag_type
    标识这个锁是针对哪类资源的。每种 LockTagType 决定了 LOCKTAG 里后面几个字段(locktag_field1 ~ locktag_field4)是怎么解释的,比如:
    LOCKTAG_TRANSACTION:xid
    LOCKTAG_RELATION:dbOid + relOid
    LOCKTAG_TUPLE:dbOid + relOid + blockNum + offNum
  • locktag_lockmethodid
    • DEFAULT_LOCKMETHOD(id = DEFAULT_LOCKMETHOD)
      绝大多数用户可见的锁(relation, tuple, transactionid 等)都走它。
    • USER_LOCKMETHOD(id = USER_LOCKMETHOD)
      提供给 pg_advisory_lock() 一类的 advisory lock,用于用户自定义锁。

LOCK

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct LOCK
{
LOCKTAG tag;
LOCKMASK grantMask;
LOCKMASK waitMask;
dlist_head procLocks;
dclist_head waitProcs;
int requested[MAX_LOCKMODES];
int nRequested;
int granted[MAX_LOCKMODES];
int nGranted;
} LOCK;
  • waitProcs
    等待本LOCK的PGPROC链表
  • procLocks
    等待本LOCK的PROCLOCK链表
  • waitMask
    该资源上等待的锁类型
  • grantMask
    该资源上已经授予的锁类型

LOCKMODE

每种资源支持8种锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define NoLock					0
#define AccessShareLock 1 /* SELECT */
#define RowShareLock 2 /* SELECT FOR UPDATE/FOR SHARE */
#define RowExclusiveLock 3 /* INSERT, UPDATE, DELETE */
#define ShareUpdateExclusiveLock 4 /* VACUUM (non-FULL), ANALYZE, CREATE
* INDEX CONCURRENTLY */
#define ShareLock 5 /* CREATE INDEX (WITHOUT CONCURRENTLY) */
#define ShareRowExclusiveLock 6 /* like EXCLUSIVE MODE, but allows ROW
* SHARE */
#define ExclusiveLock 7 /* blocks ROW SHARE/SELECT...FOR UPDATE */
#define AccessExclusiveLock 8 /* ALTER TABLE, DROP TABLE, VACUUM FULL,
* and unqualified LOCK TABLE */

#define MaxLockMode 8 /* highest standard lock mode */

冲突矩阵

Requested Lock Mode ACCESS SHARE ROW SHARE ROW EXCL. SHARE UPDATE EXCL. SHARE SHARE ROW EXCL. EXCL. ACCESS EXCL.
ACCESS SHARE X
ROW SHARE X X
ROW EXCL. X X X X
SHARE UPDATE EXCL. X X X X X
SHARE X X X X X
SHARE ROW EXCL. X X X X X X
EXCL. X X X X X X X
ACCESS EXCL. X X X X X X X X

参考链接

postgres官方文档

如果后台进程(backend)需要从缓冲区中驱逐一个脏页(dirty page),它必须将这个页面写入磁盘。这种情况是不希望发生的,因为它会导致等待(例如 I/O 阻塞)——更好的方式是在后台异步地执行写入操作。

这一工作部分由 checkpointer 进程完成,但这仍然不够。因此,PostgreSQL 还引入了另一个名为 bgwriter(后台写入进程)的进程,专门用于后台写盘操作。

bgwriter 和驱逐(eviction)使用相同的缓冲区遍历算法,但有两个关键区别:

bgwriter 使用自己独立的时钟指针(clock hand),该指针从不会落后于驱逐的指针,通常还会超过它;
在遍历缓冲区时,bgwriter 不会降低页面的 usage count(使用计数)。
当一个缓冲页未被固定(unpinned)且其 usage count 为 0 时,如果它是脏的,bgwriter 就会将其刷新到磁盘。换句话说,bgwriter 在驱逐操作发生之前运行,主动地将那些很可能即将被驱逐的页面提前写入磁盘。

这样做的好处是:被选中驱逐的缓冲页很可能已经是干净的(clean),从而提高了驱逐操作的效率,避免了后台进程被迫同步写盘的代价。

总结

checkpointer 是“周期性清理工”,而 bgwriter 是“持续扫地工”。

checkpointer 负责最终的数据落盘一致性,而 bgwriter 提前清理“潜在垃圾”,让后台线程少“踩雷”。二者配合,保障了 PostgreSQL 的高并发性能和写入平滑性。

参考书目

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

CPU

top

1
top -H -p <PID>

只显示指定进程 的所有线程,并实时显示它们的 CPU、内存等使用情况。

1
pstack <PID>

perf

1
perf top -p <PID>

查看函数热点,采样一段时间

1
2
perf record -p <PID> -g -- sleep 10
perf report

strace

1
strace -ttT -p <PID>

火焰图

  • 安装perf

    1
    sudo apt-get install linux-tools-common linux-tools-$(uname -r)
  • 采样数据

    1
    sudo perf record -F 99 -p <PID> -g -- sleep 30

    -F 99 每秒采样 99 次
    -p 针对指定进程
    -g 采集调用栈(火焰图需要)
    – sleep 30 采样 30 秒

  • 生成调用栈

    1
    sudo perf script > out.perf
  • 下载 FlameGraph 工具

    1
    2
    git clone https://github.com/brendangregg/FlameGraph.git
    cd FlameGraph
  • 生成火焰图

    1
    2
    ./stackcollapse-perf.pl ../out.perf > out.folded
    ./flamegraph.pl out.folded > flamegraph.svg

IO

系统io

1
iostat -x 1

重点关注 await(平均等待时间)和 svctm(服务时间)。如果 await >> svctm,说明队列很长,设备忙不过来。

某个进程io

1
pidstat -d -p <PID> 1

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

0%