当数据库服务正常运行时,wal文件持续不断的写入磁盘。这种写入时顺序的(sequential):几乎没有随机访问,所以即便时hdd硬盘也能处理这样的任务。由于这种负载和典型的数据文件访问非常不一样,值得为WAL文件设置单独的物理存储,并通过符号链接替换PGDATA/PG_WAL目录,该目录链接到mount的文件系统中的目录。

在某些情况下,需要同时写和读取WAL文件。第一种情况是崩溃恢复。第二个是流复制。 Walsender流程直接从文件中读取WALENTRIES。因此,如果必需的页面仍位于主服务器的OS缓冲区中(不在shared_buffer中),replica未接收WAL条目,则必须从磁盘中读取数据。但是访问仍然是顺序而不是随机的。

wal日志条目写入有以下两种模式:

  • 同步模式:在事务提交之前,必须把所有相关的 WAL 记录写入磁盘,否则不允许继续执行后续操作。
  • 异步模式:事务提交会立刻返回成功,相关的 WAL 记录则由后台进程稍后再写入磁盘。

当前使用的模式由参数:synchronous_commit 确定

同步模式:为了可靠地记录一次提交,仅仅将WAL条目传递给操作系统是不够的;你必须确保磁盘同步已经成功完成。由于同步涉及实际的I/O操作(这相当慢),因此最好尽可能少地执行它。

为此,完成事务并将WAL条目写入磁盘的后端可以进行一次小的暂停,该暂停由commit_delay参数定义。但是,只有当系统中至少有commit_siblings个活跃事务时,这种情况才会发生:在这次暂停期间,其中一些事务可能会完成,而服务器将设法一次性同步所有WAL条目。这很像为某人赶进来而按住电梯门。

默认情况下,没有暂停。仅针对执行大量短时OLTP事务的数据库系统修改commit_delay参数是有意义的。

在可能出现的暂停之后,完成事务的进程会将所有累积的 WAL 条目刷新到磁盘并执行同步操作(关键是要保存提交记录以及与该事务相关的所有前置记录;至于其他记录,则只是顺便写入,因为它们不会增加额外开销)。

从这一刻起,ACID 的持久性要求便得到保证——事务被认为已经可靠提交。这就是为什么默认使用同步模式的原因。

同步提交的缺点在于延迟更长(在同步完成之前,COMMIT 命令不会返回控制权),并且系统吞吐量较低,尤其对于 OLTP loads。

异步模式:要启用异步模式,必须关闭 synchronous_commit 参数。
在异步模式下,WAL 条目由 walwriter 进程写入磁盘,该进程在“工作—休眠”之间交替运行。休眠时长由 wal_writer_delay 参数决定(默认 200ms)。

当 walwriter 从休眠中唤醒时,它会检查缓存中是否存在新的、已经完全填满的 WAL 页面。如果存在,就将这些页面写入磁盘,同时跳过当前未写满的页面;否则,它会写入当前半空的页面,因为既然已经被唤醒了。

这种算法的目的在于避免同一个页面被多次刷盘,这在数据变更频繁的负载下能带来显著的性能提升。

虽然 WAL 缓存采用环形缓冲区(ring buffer)的形式,但 walwriter 在到达缓存的最后一页时会停止;在经过一次休眠后,下一轮写入循环会从缓存的第一页重新开始。因此,在最坏的情况下,walwriter 可能需要 三次循环才能处理某个特定的 WAL 记录:

第一次,它会写出缓存尾部的所有已填满页面;(当前位置之后的所有满块)
第二次,它回到开头;(当前位置之前的所有满块)
第三次,才会处理包含目标记录的那个未填满页面。
不过,在大多数情况下,只需要 一到两次循环 就能完成。

每当写入的数据量达到 wal_writer_flush_after 时,就会执行一次同步操作;在写入循环结束时,也会再次进行同步。

与同步提交相比,异步提交更快,因为它不需要等待物理写入磁盘完成。但可靠性有所下降:在发生故障前的 3 × wal_writer_delay 时间内提交的数据可能会丢失(默认值为 0.6 秒)

在实际应用中,这两种模式是互补的。

  • 同步模式 下,与长事务相关的 WAL 条目仍然可以 异步写入,以释放 WAL 缓冲区。
  • 反之,即使在 异步模式 下,如果某个 WAL 条目所在的页即将被 从缓冲区淘汰,该条目也会被 立即刷盘,否则系统无法继续正常操作。
    在大多数情况下,系统设计者必须在 性能和持久性之间做出权衡。

synchronous_commit 参数也可以针对特定事务进行设置。如果能够在应用层将所有事务分类为 绝对关键(如处理财务数据)或 非关键,就可以在只冒非关键事务丢失风险的前提下,提升整体性能

为了了解 异步提交 可能带来的性能提升,我们可以通过 pgbench 测试,比较两种模式下的 延迟(latency)和吞吐量(throughput)

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
postgres@lavm-bar1guved6:/root$ pgbench -i test
dropping old tables...
NOTICE: table "pgbench_accounts" does not exist, skipping
NOTICE: table "pgbench_branches" does not exist, skipping
NOTICE: table "pgbench_history" does not exist, skipping
NOTICE: table "pgbench_tellers" does not exist, skipping
creating tables...
generating data (client-side)...
vacuuming...
creating primary keys...
done in 1.54 s (drop tables 0.11 s, create tables 0.49 s, client-side generate 0.54 s, vacuum 0.18 s, primary keys 0.23 s).

postgres@lavm-bar1guved6:/root$ pgbench -T 30 test
pgbench (19devel)
starting vacuum...end.
transaction type: <builtin: TPC-B (sort of)>
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
maximum number of tries: 1
duration: 30 s
number of transactions actually processed: 3522
number of failed transactions: 0 (0.000%)
latency average = <strong>8.518</strong> ms
initial connection time = 4.025 ms
tps = <strong>117.400485</strong> (without initial connection time)

修改参数后跑异步模式:

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
postgres@lavm-bar1guved6:/root$ psql test
psql (19devel)
Type "help" for help.

test=# ALTER SYSTEM SET synchronous_commit = off;
ALTER SYSTEM
test=# SELECT pg_reload_conf();
pg_reload_conf
----------------
t
(1 row)

postgres@lavm-bar1guved6:/root$ pgbench -T 30 test
pgbench (19devel)
starting vacuum...end.
transaction type: <builtin: TPC-B (sort of)>
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
maximum number of tries: 1
duration: 30 s
number of transactions actually processed: 9510
number of failed transactions: 0 (0.000%)
latency average = <strong>3.154</strong> ms
initial connection time = 4.383 ms
tps = <strong>317.038330</strong> (without initial connection time)

异步模式 下,这个简单的基准测试显示出 显著更低的延迟(latency)和更高的吞吐量(tps)。当然,每个具体系统的数值会根据当前负载有所不同,但可以清楚地看出,对于 短事务,性能提升是相当明显的。

恢复参数:

1
2
3
4
5
6
7
8
9
10
11
postgres@lavm-bar1guved6:/root$ psql test
psql (19devel)
Type "help" for help.

test=# ALTER SYSTEM RESET synchronous_commit;
ALTER SYSTEM
test=# SELECT pg_reload_conf();
pg_reload_conf
----------------
t
(1 row)

参考书目

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

昨日和同事探讨了一下pg与mysql,发现对于mysql的innodb存储引擎了解甚少,正好周末,深入学习了解了一下,整理一下这两天学到的东西。

概念介绍

InnoDB:MySQL 的主要存储引擎
heap table:postgres数据库P表在物理上的存储(Heap File)

数据存储方式

innodb 底层存储

innodb是一个基于聚簇索引(Clustered Index)的存储引擎。

数据存储方式

  • 聚簇索引存储表数据
    • 表的主键索引就是数据本身的物理存储顺序。
    • 每个页(page)一般 16KB,页内数据按主键顺序排列。
    • 非主键列数据和行信息都在叶子节点。
  • 二级索引
    • 非主键索引存储的是主键值而不是行指针。
    • 查询非主键列时,需要先通过二级索引拿到主键,再去主键聚簇索引里查数据(回表,row lookup)。

页与数据组织

  • 数据按 B+ 树页组织。
  • 插入/更新可能导致页分裂。

postgres heap table

数据存储方式

  • 数据按插入顺序存放,没有聚簇索引。
  • 行标识符 TID(tuple id) 用于指向表的页和行。
  • 页(默认 8KB)内存储多行 tuple。
  • 没有聚簇索引,非索引扫描可能会更慢,但插入非常快。

索引

  • B+ 树、哈希、GIN、GiST 等索引都是附加结构,存储独立于表。
  • 回表操作是通过 TID 定位行。

对比

特性 InnoDB PostgreSQL 堆表
数据组织 聚簇索引(主键决定物理顺序) 堆表,顺序插入
主键访问 快(顺序扫描/范围查询) 需要 B+ 树索引或全表扫描
二级索引访问 回表(先索引后主键查行) 回表(索引 -> TID -> 行)
插入性能 如果主键顺序插入快,否则可能页分裂 快,顺序写入,几乎不分裂
更新/删除 支持 in-place 更新,但大行更新可能迁移 创建新行,旧行留存,需要 vacuum
MVCC Undo log + hidden columns xmin/xmax + heap tuple
表膨胀 自动管理页空间 容易膨胀,需要 vacuum
查询优化 聚簇索引优化范围查询 多依赖索引,或者全表扫描

因为pg的存储结构导致访问数据,多了许多随机io:从索引到数据文件。导致pg的查询性能不如innnodb

pg的一些优化

CLUSTER 命令(物理重排)

1
CLUSTER t USING idx_id;

把表物理顺序按照索引顺序重排 —— 效果类似 InnoDB 聚簇索引。
缺点:

  • 是离线操作;
  • 之后新插入的行会破坏顺序(除非周期性 recluster)

索引仅扫描(Index Only Scan)

如果查询的字段都在索引里且可见性检查通过(可见性 map 中标记为 all-visible),可以不访问堆表,直接从索引返回结果。

BRIN 索引

BRIN 全称是 Block Range INdex,是一种 非常轻量级的索引,设计理念与 B-Tree 不同:
不是存储每一行的索引它只存储堆表的一段范围(block range)内的最小值和最大值等摘要信息。每个 BRIN 索引条目覆盖 多个物理数据页(例如 128 个 8KB 页面 = 1MB 的行数据)。
通过这些范围信息,可以快速排除不可能匹配的块,再去 heap 查找具体 tuple。
简单理解:BRIN 是“粗粒度索引”,通过块范围(block range)而非单行建立索引
非常适合 大表 + 顺序或局部相关数据:大表 + 顺序或局部相关数据:

服务器启动时,第一个启动的进程是 postmaster(新版本为postgres)。postmaster 接着会生成 startup process(启动进程),startup process 负责在发生故障时进行数据恢复。

startup process 是一个短暂的、一次性的进程,它的主要职责是在数据库启动时执行崩溃恢复或归档恢复。它完成它的工作后,就会退出。

1
2
3
postgres@lavm-bar1guved6:/root$ pg_controldata -D /home/postgres/pgdata/ |grep state
Database cluster state: in production
postgres@lavm-bar1guved6:/root$

一个正常停止的服务器会处于“已关闭”(shut down)状态;而一个未运行的服务器却显示为“生产中”(in production)状态,则表明发生了故障。在这种情况下,启动进程(startup process)将自动从在同一个 pg_control 文件中找到的最新完成的检查点(checkpoint)**的起始 LSN 处开始进行恢复。

如果 PGDATA 目录中包含与备份相关的 backup_label 文件,则起始 LSN 位置会从该文件中获取。

在启动过程中,系统会从指定位置开始,逐一读取WAL(Write-Ahead Log,预写式日志)条目。如果数据页的 LSN(Log Sequence Number,日志序列号)小于当前读取到的 WAL 条目的 LSN,系统会将该 WAL 条目应用到数据页上。如果数据页的 LSN 已经大于 WAL 条目的 LSN,则不应应用该 WAL 条目;事实上,也绝不能应用,因为 WAL 条目被设计为必须严格按顺序重放。

然而,有些 WAL 条目是Full Page Image(FPI)。这类条目可以应用于页面的任何状态,因为它们会完全覆盖页面内容,无论页面原先是什么状态都不重要。因此,这种修改是幂等的(idempotent)——多次应用不会改变结果另一个幂等操作的例子是注册事务状态的变更:每个事务的状态在 CLOG(事务提交日志)中是通过设置特定位来表示的,这种设置不依赖于原来的位值。因此,不需要在 CLOG 页面中记录最近变更的 LSN(日志序列号),因为日志重放时只要设置一次这些位就够了,重复设置也不会有副作用最后,系统会执行一次 checkpoint(检查点),将恢复后的所有修改持久化到磁盘,此时 启动进程(startup process) 的任务就完成了。

WAL 日志条目会被应用到缓冲池(buffer cache)中的页面上,就像正常运行时对数据页的普通修改一样。

文件的恢复也遵循类似方式:例如,若某条 WAL 记录表明某个文件应该存在,但实际却缺失,系统就会重新创建这个文件。
一旦恢复完成,所有 unlogged relations会被它们对应的 初始化副本(init fork) 覆盖。

最后,系统会执行一次 checkpoint,将恢复后的所有修改持久化到磁盘,此时 启动进程(startup process) 的任务就完成了
在其经典形式中,恢复过程包含两个阶段:

  • roll-forward阶段:重放 WAL 日志,重复执行在崩溃时丢失的操作;
  • roll-back阶段:服务器中止那些在故障发生时尚未提交的事务。
    在 PostgreSQL 中,向后回滚是不需要的。恢复完成后,CLOG(事务状态日志)中对未完成事务既没有提交(commit)标记,也没有中止(abort)标记(这在技术上表示该事务处于活动状态),但因为可以确定该事务已经不再运行,所以系统会将其视为已中止(aborted)。

我们可以通过强制服务器以“立即模式”(immediate mode)停止来模拟故障:

1
2
3
4
5
postgres@lavm-bar1guved6:/root$ pg_ctl stop -m immediate
waiting for server to shut down.... done
server stopped
postgres@lavm-bar1guved6:/root$ pg_controldata -D /home/postgres/pgdata/ |grep state
Database cluster state: in production

当我们启动服务器时,启动进程会检测到之前发生了故障,因而进入恢复模式:

1
2
3
4
5
6
7
8
9
10
11
2025-08-04 10:23:31.860 CST [487414] LOG:  starting PostgreSQL 19devel on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0, 64-bit
2025-08-04 10:23:31.861 CST [487414] LOG: listening on IPv4 address "127.0.0.1", port 5432
2025-08-04 10:23:31.878 CST [487414] LOG: listening on Unix socket "/tmp/.s.PGSQL.5432"
2025-08-04 10:23:31.920 CST [487420] LOG: database system was interrupted; last known up at 2025-08-01 09:36:11 CST
2025-08-04 10:23:32.098 CST [487420] LOG: database system was not properly shut down; automatic recovery in progress
2025-08-04 10:23:32.125 CST [487420] LOG: redo starts at 0/01B75168
2025-08-04 10:23:32.125 CST [487420] LOG: invalid record length at 0/01B752A8: expected at least 24, got 0
2025-08-04 10:23:32.125 CST [487420] LOG: redo done at 0/01B75270 system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.01 s
2025-08-04 10:23:32.132 CST [487418] LOG: checkpoint starting: end-of-recovery fast wait
2025-08-04 10:23:32.152 CST [487418] LOG: checkpoint complete: wrote 0 buffers (0.0%), wrote 3 SLRU buffers; 0 WAL file(s) added, 0 removed, 0 recycled; write=0.006 s, sync=0.005 s, total=0.022 s; sync files=2, longest=0.005 s, average=0.003 s; distance=0 kB, estimate=0 kB; lsn=0/01B752A8, redo lsn=0/01B752A8
2025-08-04 10:23:32.155 CST [487414] LOG: database system is ready to accept connections

如果服务器正在正常关闭,postmaster 会先断开所有客户端连接,然后执行最后一次检查点操作,将所有脏页(未写入磁盘的修改页面)刷写到磁盘上。

看当前的WAL位置

1
2
3
4
5
test=# SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
---------------------------
0/01B75358
(1 row)

我们正常停止服务

1
2
3
postgres@lavm-bar1guved6:~$ pg_ctl stop
waiting for server to shut down.... done
server stopped

现在的数据库状态:

1
2
postgres@lavm-bar1guved6:~$ pg_controldata -D /home/postgres/pgdata/ |grep state
Database cluster state: shut down

在WAL的末尾,我们看到了表示最后一次checkpoint的CHECKPOINT_SHUTDOWN 条目

1
2
3
4
postgres@lavm-bar1guved6:~$ pg_waldump  -p /home/postgres/pgdata/pg_wal -s 0/01B75358
rmgr: XLOG len (rec/tot): 114/ 114, tx: 0, lsn: 0/01B75358, prev 0/01B75320, desc: CHECKPOINT_SHUTDOWN redo 0/01B75358; tli 1; prev tli 1; fpw true; wal_level replica; xid 0:758; oid 24576; multi 1; offset 0; oldest xid 746 in DB 1; oldest multi 1 in DB 1; oldest/newest commit timestamp xid: 0/0; oldest running xid 0; shutdown
pg_waldump: error: error in WAL record at 0/01B75358: invalid record length at 0/01B753D0: expected at least 24, got 0
postgres@lavm-bar1guved6:~$

最新的 pg_waldump 消息显示该工具已读取 WAL到末尾。

参考书目

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

一 相关结构体说明

1
2
3
4
5
6
7
8
9
10
typedef struct LWLock
{
uint16 tranche; /* tranche ID */
pg_atomic_uint32 state; /* state of exclusive/nonexclusive lockers */
proclist_head waiters; /* list of waiting PGPROCs */
#ifdef LOCK_DEBUG
pg_atomic_uint32 nwaiters; /* number of waiters */
struct PGPROC *owner; /* last exclusive owner of the lock */
#endif
} LWLock;
  • tranche
    每个 LWLock 都属于某个 tranche,tranche 是一个 int 类型的 ID,代表这把锁的用途

  • state (不同版本可能会有变化,本文对应版本:PostgreSQL 19devel(开发版,尚未正式发布的 19))

    • bit0..17:共享持有者计数(一个整数计数,不是位图)
    • bit18:独占锁哨兵位(LW_VAL_EXCLUSIVE = 1<<18)
    • bit29: LW_FLAG_LOCKED,是 wait list 自身的小锁(保护等待队列操作)
    • bit30: LW_FLAG_RELEASE_OK,表示当前可以在释放路径执行唤醒
    • bit31: LW_FLAG_HAS_WAITERS,表示等待队列里有 waiter
  • waiters
    等待队列的head和tail (procno)

二 Interface说明

2.1 LWLockAcquire

加锁,失败进入等待队列,直接加上锁的情况,可能会造成等待队列无效唤醒

acquire a lightweight lock in the specified mode
Side effect: cancel/die interrupts are held off until lock release.

1
2
3
4
5
6
7
8
9
10
11
12
if (mode == LW_EXCLUSIVE)
{
lock_free = (old_state & LW_LOCK_MASK) == 0;
if (lock_free)
desired_state += LW_VAL_EXCLUSIVE;
}
else
{
lock_free = (old_state & LW_VAL_EXCLUSIVE) == 0;
if (lock_free)
desired_state += LW_VAL_SHARED;
}

上述代码确认是否可以加锁

2.2 LWLockRelease

进行唤醒条件:

  • oldstate & LW_FLAG_HAS_WAITERS:等待队列非空。这个条件确认有进程在等待锁。
  • oldstate & LW_FLAG_RELEASE_OK: 允许唤醒。这个标志表示系统在上次唤醒后,已经将再次唤醒其他等待者的权限交给了被唤醒的进程。
  • (oldstate & LW_LOCK_MASK) == 0:锁已空闲。这个条件确认锁当前没有被任何进程占用(无论是独占模式还是共享模式,计数都为零)

三 分配与类型(默认值,版本相关)

  1. NUM_INDIVIDUAL_LWLOCKS:核心按表生成的单锁(lwlocklist.h),数量随版本变化,不是固定 56。
  2. NUM_BUFFER_PARTITIONS(默认 128):共享缓冲区 mapping hash 的分区锁 BufferMappingLock[i]
  3. NUM_LOCK_PARTITIONS(默认 16):heavyweight lock manager 哈希分区锁(LWTRANCHE_LOCK_MANAGER)。
  4. NUM_PREDICATELOCK_PARTITIONS(默认 16):谓词锁哈希分区锁。
  5. 扩展自定义:在 postmaster 启动阶段调用 RequestNamedLWLockTranche(name, n) 申请一组锁,重启后 GetNamedLWLockTranche 取基址。需要 shared_preload_libraries 的扩展通常属于此类。

全局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,用于用户自定义锁。

PROCLOCK

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct PROCLOCK
{
/* tag */
PROCLOCKTAG tag; /* unique identifier of proclock object */

/* data */
PGPROC *groupLeader; /* proc's lock group leader, or proc itself */
LOCKMASK holdMask; /* bitmask for lock types currently held */
LOCKMASK releaseMask; /* bitmask for lock types to be released */
dlist_node lockLink; /* list link in LOCK's list of proclocks */
dlist_node procLink; /* list link in PGPROC's list of proclocks */
} PROCLOCK;

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链表
  • waitMask
    该资源上等待的锁类型:目前有进程在等待的锁类型集合
  • procLocks
    进程对于一个LOCK的持有以及request情况;如果进程占有多个LOCK,则会有多个PROCLOCK
    • 一个 LOCK(资源)可以挂很多 PROCLOCK(不同后台进程),每个 PROCLOCK 只对应一个 PGPROC + 一个 LOCK。
    • 同一进程与同一资源只会有一个 PROCLOCK;它的 holdMask 可以同时包含多个锁模式位,且内部还有引用计数在 LOCALLOCK 里,不会创建多个 PROCLOCK
    • 资源侧用 LOCK.procLocks 链表串起所有持有/等待它的 PROCLOCK;进程侧在 PGPROC.myProcLocks(见 proc.h)里串起它关联的所有 PROCLOCK

      因此:一个资源可以被多个进程持锁;一个进程可以同时持有多个资源的锁;对同一资源如需多模式/多次持有,累加在同一个 PROCLOCK 的掩码/计数里。

  • grantMask
    该资源上已经授予的锁类型,LOCKMASK 每一位对应 “编号为 N 的锁模式是否已持有/等待”,位号就是 lockmode, bit0 保留不适用
  • requested[i]
    当前有多少个后台对模式 i 提出请求(已授予 + 正在等待)
  • nRequested:是总和

    “等待者数” = requested[i] - granted[i](或总等待 = nRequested - nGranted),依此可以知道有没有人还在等
    授予/释放锁时同步更新这些计数,并据此调整 grantMask/waitMask,决定是否要唤醒等待队列;

  • granted[i]
    当前有多少个后台成功得到了模式 i 的锁
  • nGranted:
    总和

LOCKMODE

每种资源支持8种锁(lockmode)

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 */

Table-Level Lock Modes

  • ACCESS SHARE (AccessShareLock)
    Conflicts with the ACCESS EXCLUSIVE lock mode only.

    The SELECT command acquires a lock of this mode on referenced tables

  • ROW SHARE (RowShareLock)
    Conflicts with the EXCLUSIVE and ACCESS EXCLUSIVE lock modes.

    SELECT FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, or FOR KEY SHARE

  • ROW EXCLUSIVE (RowExclusiveLock)
    Conflicts with the SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, and ACCESS EXCLUSIVE lock modes.

    The commands UPDATE, DELETE, INSERT, and MERGE acquire this lock mode on the target table.

  • SHARE UPDATE EXCLUSIVE (ShareUpdateExclusiveLock)
    Conflicts with the SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, and ACCESS EXCLUSIVE lock modes.This mode protects a table against concurrent schema changes and VACUUM runs.

    Acquired by VACUUM (without FULL), ANALYZE, CREATE INDEX CONCURRENTLY, CREATE STATISTICS, COMMENT ON, REINDEX CONCURRENTLY, and certain ALTER INDEX and ALTER TABLE variants (for full details see the documentation of these commands).

Conflicting Lock Modes

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
0%