确认 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

要在发生故障后恢复数据一致性(即执行恢复操作),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

1. 安装编译工具

ASan 是 GCC 和 Clang 内建的功能,无需额外安装 ASan,只需要你的编译器支持即可。GCC ≥ 4.8 / Clang ≥ 3.1 就支持 ASan

1
2
3
4
5
6
7
8
9
10
11
root@lavm-bar1guved6:~# clang --version
Ubuntu clang version 14.0.0-1ubuntu1.1
Target: x86_64-pc-linux-gnu
Thread model: posix
InstalledDir: /usr/bin

root@lavm-bar1guved6:~# gcc --version
gcc (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
Copyright (C) 2021 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE

2. how to use

使用gcc

1
gcc -fsanitize=address -g your_file.c -o your_program

使用cmake

1
2
3
4
5
6
7
8
9
10
11
12
13
# 编译阶段(C/C++)加 ASan 插桩
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fsanitize=address -fno-omit-frame-pointer -g")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address -fno-omit-frame-pointer -g")

# 链接阶段链接 libasan
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -fsanitize=address")

#or
cmake -DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g" \
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address" \
-DCMAKE_BUILD_TYPE=Debug \
......

使用 Makefile

可以在 CFLAGS 中添加:

1
2
CFLAGS += -fsanitize=address -g -fno-omit-frame-pointer
LDFLAGS += -fsanitize=address

使用 LSAN(泄漏检测)

GCC 和 Clang 中 ASan 自动包含 LeakSanitizer(LSan),但某些情况下要确保:

1
-fsanitize=address -fno-omit-frame-pointer

加上 -fno-omit-frame-pointer 可以让调用栈更完整。

3. 运行程序

编译好的程序可以直接运行:

1
./your_program

4. 设置环境变量

1
export ASAN_OPTIONS=detect_leaks=1:halt_on_error=0:symbolize=1:quarantine_size=1024:log_path=/home/postgres/asan/asan.log

5. Example

1
2
3
4
5
6
7
8
9
=================================================================
==791265==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 1024 byte(s) in 1 object(s) allocated from:
#0 0x7f6fcee65887 in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:145
#1 0x55adcdfee87a in main /home/postgres/codes/sample/epoll_server.c:54
#2 0x7f6fcebb1d8f in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58

SUMMARY: AddressSanitizer: 1024 byte(s) leaked in 1 allocation(s).

在发生故障(例如停电、操作系统错误或数据库服务器崩溃)时,RAM 中的所有内容都将丢失;只有写入磁盘的数据会保留下来。要在故障后启动服务器,您必须恢复数据一致性。如果磁盘本身已损坏,则必须通过备份恢复来解决相同的问题。

理论上,您可以始终保持磁盘上的数据一致性。但实际上,这意味着服务器必须不断地将随机页面写入磁盘(尽管顺序写入成本更低),并且此类写入的顺序必须保证在任何特定时刻都不会损害一致性(这很难实现,尤其是当您处理复杂的索引结构时)。

与大多数数据库系统一样,PostgreSQL 采用了一种不同的方法。

服务器运行时,部分当前数据仅存在于 RAM 中,其写入永久存储的操作被推迟。因此,服务器运行时存储在磁盘上的数据始终是不一致的,因为页面从不会一次性全部刷新。但是,RAM 中发生的每个更改(例如在缓冲区缓存中执行的页面更新)都会被记录下来:PostgreSQL 会创建一个日志条目,其中包含在需要时重复此操作所需的所有基本信息。

页面修改相关的日志条目必须先于修改后的页面本身写入磁盘。这就是日志名称的由来:预写式日志(write-ahead log),简称 WAL。这项要求保证了在发生故障时,PostgreSQL 可以从磁盘读取 WAL 条目并重放它们,以重复那些已完成但结果仍在 RAM 中且在崩溃前未写入磁盘的操作。

保留预写式日志通常比将随机页面写入磁盘更高效。WAL 条目构成一个连续的数据流,即使是硬盘驱动器 (HDD) 也能很好地处理。此外,WAL 条目通常比页面大小更小。

为了在发生故障时避免数据不一致,所有可能破坏数据一致性的操作都需要记录下来。具体来说,以下操作会记录在 预写式日志 (WAL) 中:

  • WAL 记录的操作
  1. 缓冲区缓存中的页面修改:由于写入是延迟的,这些修改需要记录下来以备恢复。
  2. 事务提交和回滚:事务状态的变化发生在 CLOG 缓冲区中,不会立即写入磁盘,因此需要记录。
  3. 文件操作:当添加或删除表时,文件和目录的创建与删除等操作必须与数据更改同步,所以也要记录。
  • WAL 不记录的操作
  1. unlogged表相关操作
  2. 临时表上的操作:由于临时表的生命周期仅限于创建它们的会话,所以它们的操作不会被记录。

    在 PostgreSQL 10 之前,哈希索引的操作也不会被记录。它们的主要目的是将哈希函数与不同的数据类型匹配。

除了用于崩溃恢复之外,WAL 还可以用于从备份进行时间点恢复以及数据复制。

参考书目

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