关于先持久化 WAL 再刷写数据页的标题卡,配有 OBSERVE、DECIDE、ACT(观察、判断、行动)面板。

WAL 让提交持久化不必等待所有数据页刷盘

预写日志允许数据库把事务变成持久状态,而不必立即刷出这个事务修改过的每一个表页与索引页。在 PostgreSQL 中,恢复某个页面所需的日志,必须在这个页面写入存储之前先持久化。正常同步提交还会等待提交记录完成刷盘。因此,缓冲区里的表变更与已经持久化的日志记录,沿着不同路径向前推进。 坏掉的样子 WAL 不是一张稍后再执行 SQL 的预约单。执行过程已经修改内存页面,日志记录描述的是恢复动作。有些日志来自最终没有提交的事务。恢复需要结合重做与事务状态,确保一个未完成事务不会仅因部分日志或数据页已经落盘,就变成其他查询可见的提交结果。把“日志存在”当成“事务已经成功”,会误解恢复后数据的可见性。 持久性设置会改变承诺。关闭同步提交后,成功响应可能先于日志持久化,崩溃时最近已经确认的事务因此可能消失。关闭文件同步的风险更大,系统故障后甚至可能留下不一致存储。整页写入解决的是另一类问题:在页面只写入一部分时,提供重新构造页面的材料。这些机制都无法弥补存储虚假确认同步,也不能补回所有持久副本同时丢失的数据。 客户端没有收到回复,又是另一种不确定性。服务端可能已经成功提交,连接随后才断开。恢复后事务仍然存在,客户端却从未看到成功,此时直接重试业务动作可能造成重复。因此,对需要保证业务操作唯一性的场景,应使用稳定操作标识并核对结果,不能把网络失败一概解释成数据库已经回滚。 快速验证 在 Linux 环境中,以非超级操作系统账户运行脚本,并准备 PostgreSQL 18 服务端与客户端程序。本次实际使用 PostgreSQL 18.6。脚本初始化隔离的临时集群,使用私有 Unix 套接字,并显式开启正常持久性设置。立即停止操作只针对它刚创建的集群。因为演练会故意打断数据库进程,应使用完整测试夹具,不要把路径替换成生产数据目录。 #!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-crash.XXXXXX) mkdir "$lab/socket" cluster=$lab/data writer_pid='' cleanup() { if test -f "$cluster/postmaster.pid"; then pg_ctl -D "$cluster" -m fast -w stop >/dev/null fi if test -n "$writer_pid"; then wait "$writer_pid" || :; fi } trap cleanup EXIT initdb -D "$cluster" --no-locale --encoding=UTF8 --auth-local=trust --auth-host=reject >/dev/null cat >> "$cluster/postgresql.conf" <<CONF listen_addresses = '' unix_socket_directories = '$lab/socket' port = 56432 fsync = on synchronous_commit = on full_page_writes = on checkpoint_timeout = '1h' CONF pg_ctl -D "$cluster" -l "$lab/server.log" -w -t 15 start >/dev/null export PGHOST=$lab/socket PGPORT=56432 PGDATABASE=postgres PGUSER=$(id -un) export PGCONNECT_TIMEOUT=2 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' CREATE TABLE marker (id integer PRIMARY KEY,value text); INSERT INTO marker VALUES (1,'base'); CHECKPOINT; SQL checkpoint_before=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT checkpoint_lsn FROM pg_control_checkpoint()') psql -X -v ON_ERROR_STOP=1 -qAt -c "INSERT INTO marker VALUES (2,'committed')" commit_end=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_current_wal_lsn()') test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_current_wal_flush_lsn()>='$commit_end'::pg_lsn")" = t PGAPPNAME=crash_uncommitted psql -X -v ON_ERROR_STOP=1 -qAt >"$lab/writer.log" 2>&1 <<'SQL' & BEGIN; UPDATE marker SET value='uncommitted' WHERE id=1; SELECT pg_sleep(120); COMMIT; SQL writer_pid=$! attempt=0 while test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT EXISTS (SELECT FROM pg_stat_activity WHERE application_name='crash_uncommitted' AND wait_event='PgSleep')")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || { echo 'uncommitted writer was not ready' >&2; exit 1; } sleep 0.1 done test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT checkpoint_lsn='$checkpoint_before'::pg_lsn FROM pg_control_checkpoint()")" = t echo committed_wal_flushed_without_new_checkpoint pg_ctl -D "$cluster" -m immediate -w stop >/dev/null if wait "$writer_pid"; then writer_pid='' echo 'interrupted writer unexpectedly succeeded' >&2 exit 1 fi writer_pid='' pg_ctl -D "$cluster" -l "$lab/server.log" -w -t 15 start >/dev/null grep -F 'redo starts at' "$lab/server.log" psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' SELECT id,value FROM marker ORDER BY id; DO $$ BEGIN IF (SELECT string_agg(id||':'||value,',' ORDER BY id) FROM marker) IS DISTINCT FROM '1:base,2:committed' THEN RAISE EXCEPTION 'crash recovery result mismatch'; END IF; END $$; SQL printf 'lab_directory %s\n' "$lab" 第一行以 base 值完成检查点。随后脚本提交第二行,并核对已经刷出的日志位置覆盖提交之后捕获的位置。另一个后端在事务里修改第一行,然后在提交之前进入休眠。通过统计视图观察该后端真实进入休眠状态,证明这项未完成更新确实执行了,而不是只把一条尚未送达数据库的命令放在客户端等待。 ...

八月 14, 2026 · 2 分钟 · XB Field Notes
文章标题旁的三个相连面板,依次标注观察、决策与行动。

死锁:两个写者在互相等待

死锁需要不兼容的等待关系形成一个环。一个事务持有行锁、另一个事务等待时,持锁者仍可能正常结束并放锁;双方各自握着对方必须获得的资源时,就无法沿当前路径完成。PostgreSQL 会检测这种循环,中止其中一个事务,让另一个继续。应用需要区分服务器明确拒绝事务与普通的慢等待,而不是把所有停顿都叫作死锁。 坏掉的样子 即使没有显式锁语句,更新行也会获取锁。因此,两段看起来简单的更新,如果以相反顺序访问两行,就可能互相等待。参与者更多、包含外键检查、触发器、建议锁或锁模式升级时,依赖环会更难看见。给某一段行编号排序,只能解决这部分访问顺序,不能据此证明整个业务路径再也没有死锁。 数据库检测到死锁时返回 40P01。应用不能依赖“后开始者必输”或“工作量少的一方会被中止”之类规则。锁等待超时、语句超时也可能先于死锁检测触发,返回另一种错误。因此必须查看实际错误码和服务器诊断,再判断发生了什么。没有看到死锁码,并不等于没有形成过循环;重试了一次,也不代表原来的取锁顺序已经修好。 快速验证 准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。甲创建两行数据,并显示死锁检测等待参数: CREATE SCHEMA deadlock_demo; CREATE TABLE deadlock_demo.items (id integer PRIMARY KEY,n integer); INSERT INTO deadlock_demo.items VALUES (1,0),(2,0); SHOW deadlock_timeout; 本次环境显示一秒。下面给语句十秒预算,让检测有时间发生,并确保没有更短的锁超时抢先干预。如果你的检测间隔更长,应相应调整实验预算。甲记录自己要增加的十,并先更新第一行,暂不提交: \set delta 10 SET statement_timeout='10s'; BEGIN; UPDATE deadlock_demo.items SET n=n+:delta WHERE id=1; 乙记录自己要增加的一百,先更新第二行,也保持事务打开: ...

八月 11, 2026 · 1 分钟 · XB Field Notes
标题卡旁有三个方框,分别标注 Observe、Decide 和 Act。

MVCC 根据可见性规则选择行版本

多版本并发控制让 PostgreSQL 保留不同的行版本,再判断查询可以看到哪些版本。普通读者可以继续读取较早已提交的版本,同时另一个事务写出新版本。这减少了读写之间的冲突,但不表示每个事务都永久拥有一幅固定画面。快照寿命取决于隔离级别,而且事务能看到自己此前的修改,即使其他事务尚看不到。把这些边界说清楚,才能理解读取结果为何既稳定又可能变化。 可见性跟随快照 在读已提交级别,普通读取通常每条语句取得一个新快照。PostgreSQL 的可重复读则复用事务第一条非事务控制语句建立的快照。仅仅发送 BEGIN,还没有建立实际读取使用的边界。因此,一个较早开始、较晚才第一次读取的事务,可能看到这两个时刻之间已经完成的提交。“事务启动时间”与“读取快照已经建立”不能随意混用。 更新会创建一个新行版本。可见性规则决定旧快照是否仍读取之前的版本,而新快照是否看到更新。元组物理上仍然存在,并不足以说明当前查询能看到它;SQL 也不是直接返回数据页上最新写下的字节。只有结合事务状态与快照,才能判断哪一版记录属于这次读取的结果。 事务自己的修改,是简单“历史照片”比喻的重要例外。即使快照早已建立,事务仍然可以读到之后由自己插入的记录。它看到的数据,可能同时包含其他事务的较早状态与自己的新工作。这种行为让事务能够实际完成读写操作,但不能把它误解为外部世界某一个已提交时刻的完整照片。理解这个例外,也能避免错误地认为自己的更新必须提交以后才可读。 坏掉的样子 普通读取取得的常规表锁不会阻塞正常行更新,但锁并没有消失。两个写者仍可能争用同一行;SELECT FOR UPDATE 会明确请求行锁;ACCESS EXCLUSIVE 表锁则与普通读者冲突。因此,MVCC 不承诺所有带有 SELECT 的命令都永远不等待。是否阻塞还需要看语句请求的锁以及当前其他操作。 旧快照还可能让读者需要的旧行版本继续保留。只要某个相关快照仍可能看到该版本,清理就不能直接移除它。空间压力取决于更新量、保留版本和清理进度,不只是打开了多少个事务。再启动一次清理,并不会让仍然活跃的快照忘掉自己的可见性要求;需要沿保留原因调查,而不是只盯住维护命令有没有运行。 快速验证 历史双会话测试保持原样,本次没有重跑。单用户后端不能证明并发快照行为。 在同一个可丢弃的 PostgreSQL 18 数据库中,打开甲、乙两个新的 psql -X -v ON_ERROR_STOP=1 会话,使用相同角色,显式事务之外保持正常自动提交。模式名称必须尚未被使用。按下面顺序执行,让乙在甲的读事务仍然打开时完成提交。 在乙中建立一条已经提交的原始记录。 CREATE SCHEMA mvcc_visibility_demo; CREATE TABLE mvcc_visibility_demo.items (id integer PRIMARY KEY,note text); INSERT INTO mvcc_visibility_demo.items VALUES (1,'old'); 在甲中通过读取建立可重复读快照。 BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT id,note FROM mvcc_visibility_demo.items ORDER BY id; 在乙中更新原记录、插入第二条记录,并查看已经提交的结果。 UPDATE mvcc_visibility_demo.items SET note='new' WHERE id=1; INSERT INTO mvcc_visibility_demo.items VALUES (2,'other'); SELECT id,note FROM mvcc_visibility_demo.items ORDER BY id; 在甲中重读,插入属于自己的第三条记录,再读一次并提交。 SELECT id,note FROM mvcc_visibility_demo.items ORDER BY id; INSERT INTO mvcc_visibility_demo.items VALUES (3,'mine'); SELECT id,note FROM mvcc_visibility_demo.items ORDER BY id; COMMIT; 甲提交后使用新语句再次读取,随后在乙中清理。 SELECT id,note FROM mvcc_visibility_demo.items ORDER BY id; DROP SCHEMA mvcc_visibility_demo CASCADE; PostgreSQL 18.6 实测中,甲第一次读取返回 (1,old)。乙不需要等待这个普通读者结束,就完成了更新和插入,并看到 (1,new) 与 (2,other)。甲再次读取时仍然只有 (1,old)。当甲插入自己的记录后,它能看到 (1,old) 和 (3,mine),但乙插入的第二行仍不属于甲的快照。这同时展示了他人提交的隔离与自身修改的可见性。 ...

八月 8, 2026 · 1 分钟 · XB Field Notes