WAL 写入流程(Write Flow)
前两节讲了 WAL 是什么、LSN 怎么表示位置。本节回答一个更工程化的问题:一条 WAL 记录从产生到落盘,中间经过了哪些环节、拿了哪些锁、谁在什么时候 fsync。搞清楚这条路径,才能理解为什么 synchronous_commit 能调、commit_delay 有什么用、什么情况下会出现「WAL 写入瓶颈」。
这是 WAL 全部内容中最贴近性能的一节。理解了写入流程,绝大部分 WAL 相关的性能问题都能自己推导出答案。
前置知识
阅读本节前,建议先了解:LSN(日志序列号)
基础概念
WAL 写入路径上有若干角色和同步原语,先统一术语:
| 术语 | 英文 | 说明 |
|---|---|---|
| WAL 记录生成 | Record Generation | 后端进程在修改数据页时同步生成对应的 WAL 记录 |
| WAL 缓冲区 | WAL Buffer | 共享内存环形缓冲区,暂存尚未写盘的 WAL 记录 |
| 插入锁 | WALInsertLock | 多把(默认 8 把)分段锁,控制并发向 WAL 缓冲区追加 |
| 写锁 | WALWriteLock | 单把互斥锁,串行化「写文件 + fsync」动作 |
| 刷盘 | Flush | 把 WAL 从内核页缓存 fsync 到持久存储 |
| 组提交 | Group Commit | 多个并发事务合并成一次 fsync |
| 提交等待 | Commit Wait | 提交时等待自己的 LSN 被刷盘完成 |
| WAL 写入进程 | WAL Writer | 后台进程,周期性把 WAL 缓冲区刷盘,避免后端自己等 |
| 同步提交 | Synchronous Commit | synchronous_commit 控制提交等待到哪个阶段 |
| 异步提交 | Asynchronous Commit | synchronous_commit = off,提交不等 fsync |
写入路径上的四个参与者
┌────────────────┐ ①追加记录 ┌──────────────┐
│ Backend 进程 │──────────────►│ WAL Buffer │
│ (执行 DML) │ │ (共享内存) │
└───────┬────────┘ └──────┬───────┘
│ │
│ ④ COMMIT 时等待 │ ②刷盘
│ 自己的 LSN 被刷完 ▼
│ ┌──────────────┐
│ │ pg_wal │
│ │ (磁盘) │
│ └──────────────┘
│ ▲
│ │ ③周期性刷盘
│ ┌──────┴───────┐
└──── 也可能自己触发刷盘 ──│ WAL Writer │
│ (后台进程) │
└──────────────┘四个参与者各司其职:
| 参与者 | 职责 | 是否阻塞用户事务 |
|---|---|---|
| Backend 进程 | 生成记录、追加到缓冲区、提交时触发并等待刷盘 | 提交时会等待 |
| WAL Buffer | 缓冲写入,把大量小写合并成顺序 I/O | 否 |
| WAL Writer | 周期性刷盘,减少后端自己 fsync 的次数 | 否 |
| pg_wal 文件 | 持久化落点 | 否 |
核心机制
第一步:生成 WAL 记录
WAL 记录不是事后补记的,而是在修改数据页的同一时刻由同一个后端进程生成:
UPDATE users SET name = 'bob' WHERE id = 1;
│
├─ 定位到目标堆页(shared_buffers 中)
│
├─ 准备新元组版本(含新的 xmin、ctid 等)
│
├─ 【关键】调用 XLogInsert() 为这次页修改生成 WAL 记录
│ ├─ 判断该页是否本检查点后首次修改
│ │ ├─ 是 → 附带 FPI 全页镜像(记录变长很多)
│ │ └─ 否 → 只记录增量修改
│ │
│ └─ 记录内容:rmgr=Heap / desc=UPDATE / blkref / 元组数据
│
├─ WAL 记录追加到 WAL Buffer(拿 WALInsertLock)
│
└─ 数据页在内存中变脏(此刻不写盘)一个必须理解的设计:WAL 记录与数据页修改是同一个后端进程在同一段代码里做的。这保证了「不会出现改了页却没记日志」的情况——两者在代码层面就是绑定的,不存在遗漏的可能。
第二步:插入 WAL 缓冲区(分段锁)
WAL 缓冲区是高并发写入的热点,PostgreSQL 用多把分段锁来降低竞争:
WAL Buffer(环形,默认约 shared_buffers/32,上限 16MB)
┌────────────────────────────────────────────────────────────────┐
│ 段0 │ 段1 │ 段2 │ 段3 │ 段4 │ 段5 │ 段6 │ 段7 │ │
│🔒L0 │🔒L1 │🔒L2 │🔒L3 │🔒L4 │🔒L5 │🔒L6 │🔒L7 │ │
└──▲───┴──▲───┴──────┴──────┴──────┴──────┴──────┴──────┴─────────┘
│ │
事务A 事务B ← 两个并发事务落在不同段,可以并行插入- 默认有 8 把插入锁(
NUM_XLOGINSERT_LOCKS,编译期常量),每把保护缓冲区的一段。 - 插入时只拿自己那段的锁,因此只要并发事务的 LSN 落在不同段,就能真正并行。
- 这一点是 PostgreSQL 在 WAL 写入上能撑高并发的关键:若只有一把全局锁,所有写入会串行化。
WAL 缓冲区的环形回收
WAL 缓冲区是环形的:写到末尾后会绕回开头。绕回之前必须确认「开头那段的内容已经刷盘」,否则会覆盖未持久化的数据。这个检查由 WALWriteLock 与刷盘位点共同保证,是插入路径上唯一可能被迫等待刷盘的点(wal_buffers_full 计数增加就发生在这里)。
第三步:刷盘(WALWriteLock 串行化)
把 WAL 从缓冲区写到磁盘需要拿 WALWriteLock(单把互斥锁),因此同一时刻只有一个进程在写 WAL 文件:
进程要刷盘
│
├─ 拿 WALWriteLock(拿不到就等,说明别人正在刷)
│
├─ 计算要刷的范围:从上次刷盘位点 到 本次请求的 LSN
│
├─ write() 到 pg_wal 段文件(顺序写)
│
├─ fsync() 强制落盘
│
├─ 更新 flush_lsn(此后所有 ≤ flush_lsn 的提交都算持久化)
│
└─ 释放 WALWriteLock,唤醒所有在等这个 LSN 的进程关键点:一次刷盘可以同时满足多个进程的等待。假设事务 A 要刷到 LSN=100、事务 B 要刷到 LSN=250,若 A 先拿到锁,它会把范围刷到 100 就返回——但 B 随后会再刷一次到 250。反之若 B 先拿到锁刷到 250,则 A 的 100 也被顺带满足,A 无需再刷。这就是组提交的自然形式。
第四步:提交等待与 synchronous_commit
事务提交时,后端会调用 XLogFlush(commit_lsn) 并等待自己的 LSN 落盘。等待到哪个阶段由 synchronous_commit 决定:
| 取值 | 等待到 | 掉电可能丢失 | 典型用途 |
|---|---|---|---|
off | 不等(数据留在 WAL 缓冲区/内核缓存) | 最近几百毫秒内已提交事务 | 批量导入、日志类写入 |
local | 本地 fsync 完成 | 不丢(本机) | 单实例 + 可接受备库延迟 |
on(默认) | 本地 fsync + 同步备库收到并写入 | 不丢(本机 + 备库) | 绝大多数场景 |
remote_write | 本地 fsync + 同步备库 write() 完成 | 不丢(本机);备库可丢 | 备库 fsync 慢时的折中 |
remote_apply | 本地 fsync + 同步备库已重放 | 不丢,且备库可读最新 | 需要读一致性备库 |
synchronous_commit = on
提交 ──► 本地 fsync ──► 等同步备库 flush ──► 返回成功
延迟 = 本地 fsync + 网络往返 + 备库 fsync
synchronous_commit = remote_write
提交 ──► 本地 fsync ──► 等同步备库 write() ──► 返回成功
延迟 = 本地 fsync + 网络往返
(省掉了备库 fsync 的等待,但备库掉电会丢这些事务)remote_write 与 remote_apply 的取舍
remote_write 省掉了「等备库 fsync」的时间,在网络 RTT 主导延迟的场景下收益明显;代价是备库操作系统崩溃时会丢事务(但备库进程崩溃不会丢)。remote_apply 反过来,要等备库重放完成,延迟最大,但保证了「同步备库上能立刻读到刚提交的数据」,适合读一致性要求高的场景。绝大多数生产环境用默认的 on 即可。
组提交:commit_delay 与 commit_siblings
当并发事务很多时,一次 fsync 可以同时满足多个事务。commit_delay 让「第一个到达的提交者」多等一小会儿,攒更多事务一起刷:
commit_delay = 0(默认)
t0 事务A 提交 ──► fsync ──► 返回 (1 次 fsync, 1 个事务)
t1 事务B 提交 ──► fsync ──► 返回 (1 次 fsync, 1 个事务)
t2 事务C 提交 ──► fsync ──► 返回 (1 次 fsync, 1 个事务)
总计:3 次 fsync
commit_delay = 100us, commit_siblings = 5
t0 事务A 提交 ─┐
t0+ 事务B 提交 ─┤
t0+ 事务C 提交 ─┼─► 攒够活跃事务 ──► fsync 一次 ──► 三个都返回
t0+ 事务D 提交 ─┤ (1 次 fsync, 4 个事务)
t0+ 事务E 提交 ─┘
总计:1 次 fsync两个参数的配合逻辑:
commit_siblings是门槛:只有当同时活跃的事务数 ≥ 该值,才考虑等待。默认 5,避免低并发时白白增加延迟。commit_delay是等待时长(微秒):等满这个时间或攒够一批就刷。
一个常见的误解是「commit_delay 会降低吞吐」。事实相反:它牺牲极小的单事务延迟,换取 fsync 次数的下降,在高并发小事务场景下吞吐通常提升。但在低并发场景下它纯属浪费——所以它默认是 0(关闭)。
WAL Writer 的角色
WAL Writer 是后台辅助进程,周期性把 WAL 缓冲区刷盘,作用是把刷盘工作从提交路径上「抢跑」出去:
没有 WAL Writer(假设)
事务A COMMIT ──► 攒了 100KB WAL ──► 后端自己 fsync(等 ~1ms)
事务B COMMIT ──► 攒了 100KB WAL ──► 后端自己 fsync(再等 ~1ms)
有 WAL Writer
WAL Writer 每 200ms 主动刷一次 ──► flush_lsn 已经推进了
事务A COMMIT ──► 发现自己的 LSN ≤ flush_lsn ──► 无需等待,立即返回 ✔判断逻辑是:提交时先比较自己的 LSN 与 flush_lsn,若已被刷过就直接返回。所以 WAL Writer 刷得越及时,提交时越可能「免费通过」。
# postgresql.conf
# WAL Writer 的刷盘节奏:间隔越短,提交时的"免等"概率越高
wal_writer_delay = 200ms
# 累积到这个量就强制 flush,避免 WAL 长时间滞留在内核页缓存
# 注意:0 表示关闭该行为,可能导致数据滞留
wal_writer_flush_after = 1MBwal_writer_delay 与 commit 延迟的关系
wal_writer_delay 决定的是「最坏情况下,一个事务要等多久才会被 WAL Writer 顺带刷盘」。但注意:事务提交时不会等 WAL Writer,它会自己触发刷盘。WAL Writer 的价值在于「提前把 WAL 刷掉」,让后续提交免等。因此它的延迟并不直接等于提交延迟,只是影响「免等命中率」。
一条 UPDATE 的完整时序
把上述所有环节串起来,看两个并发事务的实际交错:
时刻 事务 A(UPDATE + COMMIT) 事务 B(UPDATE + COMMIT)
────────────────────────────────────────────────────────────────────────
t1 生成 WAL 记录(含 FPI)
t2 拿 WALInsertLock#3,追加到缓冲区 生成 WAL 记录
insert_lsn = 0/A000100
t3 拿 WALInsertLock#5,追加缓冲区
insert_lsn = 0/A000280
t4 COMMIT → 请求 XLogFlush(0/A000100)
t5 拿 WALWriteLock
t6 write(0/A000000..0/A000100) + fsync
flush_lsn = 0/A000100
t7 释放 WALWriteLock,A 返回成功 ✔
t8 COMMIT → 请求 flush(0/A000280)
t9 发现 flush_lsn = 0/A000100 < 0/A000280
需要再刷 → 拿 WALWriteLock
t10 write + fsync 到 0/A000280
B 返回成功 ✔
总 fsync 次数:2(若 B 在 t5 前提交且被合并,则可能只需 1 次)关键观察:WALInsertLock 是分段并行的(t2/t3 并发),WALWriteLock 是全局串行的(t5/t9 互斥)。所以 WAL 写入的吞吐上限往往由「fsync 次数 × 单次 fsync 耗时」决定,而不是由插入锁决定。
相关参数
| 参数 | 默认值 | 生效方式 | 说明 |
|---|---|---|---|
synchronous_commit | on | reload + 会话 | 提交等待阶段:off/local/on/remote_write/remote_apply |
wal_buffers | -1(自动) | 重启 | 缓冲区大小,自动约 shared_buffers/32,上限 16MB |
wal_writer_delay | 200ms | reload | WAL Writer 刷盘间隔 |
wal_writer_flush_after | 1MB | reload | 累积该量强制 flush(IO 方向提示,0 关闭) |
commit_delay | 0 | reload | 组提交等待时长(微秒) |
commit_siblings | 5 | reload | 触发 commit_delay 的活跃事务数门槛 |
synchronous_standby_names | 空 | reload | 哪些备库算「同步备库」,决定 on 等谁 |
fsync | on | 重启 | 是否真正 fsync;关掉等于放弃持久性 |
# postgresql.conf
# —— 高频小事务、机械盘/网络存储(fsync 慢):组提交收益最大 ——
commit_delay = 500us # 实测调优,不要照搬;先测 fsync 平均耗时
commit_siblings = 5
# —— 让 WAL Writer 更积极,提高提交"免等"命中率 ——
wal_writer_delay = 200ms
wal_writer_flush_after = 1MB
# —— 缓冲区:写入压力大且并发高时可显式放大(需重启) ——
wal_buffers = 16MB
# —— 有同步备库时,按延迟敏感度选择等待级别 ——
synchronous_commit = on # 默认;跨机房可用 remote_write 降低延迟详细说明
观察提交延迟的构成
提交延迟 = fsync 等待 + 可能的锁等待。可以用 pg_test_fsync 先量化存储的 fsync 性能:
# 在数据库主机上测量单次 fsync 的真实耗时(需停止写入以获得准确值)
pg_test_fsync -s 5 -f /var/lib/pgsql/18/data5 seconds per test
O_DIRECT supported on this platform for open_datasync and open_sync.
Compare file sync methods using one 8kB write:
(in wal_sync_method preference order, except fdatasync is Linux's default)
open_datasync 1843.512 ops/sec 543 usecs/op
fdatasync 1856.221 ops/sec 539 usecs/op
fsync 1801.443 ops/sec 555 usecs/op
fsync_writethrough n/a
open_sync 1815.902 ops/sec 551 usecs/op
Compare file sync methods using two 8kB writes:
...结果解读:
fdatasync约 539 微秒/次,这就是「一次提交至少要等 0.54ms」的物理下限。存储的 fsync 越慢,组提交的价值越大。- 若这里显示几毫秒甚至几十毫秒(常见于云盘、机械盘、虚拟化环境),则单事务提交延迟一定很高,只能靠组提交或降级
synchronous_commit来提升吞吐。 open_datasync与fdatasync相近,说明该存储不支持写缓存优化。若某方法明显更快,可以把wal_sync_method显式设成它。
用视图观测 WAL 写入压力
PG14+ 的 pg_stat_wal 直接给出写入链路的健康指标:
-- WAL 写入链路的四个关键指标
SELECT wal_records,
wal_fpi,
pg_size_pretty(wal_bytes) AS wal_bytes,
wal_buffers_full,
stats_reset
FROM pg_stat_wal; wal_records | wal_fpi | wal_bytes | wal_buffers_full | stats_reset
-------------+---------+-----------+------------------+-------------------------------
1284300 | 5218 | 2148 MB | 184 | 2026-09-20 09:00:00.124511+08| 指标 | 含义 | 异常时的处置 |
|---|---|---|
wal_records | 记录条数 | 突增说明写入模式变碎(如逐行更新) |
wal_fpi | 全页镜像次数 | 占 WAL 大头;用 wal_compression 压缩 |
wal_bytes | 生成的 WAL 总量 | 换算成速率做容量规划 |
wal_buffers_full | 缓冲区不足被迫等待刷盘的次数 | 持续增长 → 增大 wal_buffers(需重启) |
wal_buffers_full 是唯一一个明确指向「写路径有瓶颈」的指标:它增加意味着插入 WAL 时发现了「缓冲区段尚未刷盘」而必须等待。
提交延迟的分型诊断
不同原因导致的提交延迟,特征完全不同:
| 现象 | 特征 | 可能原因 | 处置 |
|---|---|---|---|
| 提交延迟随并发升高 | P99 高,P50 正常 | fsync 竞争,组提交未生效 | 调大 commit_delay,检查 commit_siblings |
| 提交延迟恒定偏高 | P50 与 P99 接近,都高 | 存储 fsync 本身慢 | 换存储、wal_sync_method 优化,或降级 synchronous_commit |
| 提交延迟尖峰 | 偶发秒级延迟 | 检查点刷盘与 WAL fsync 争抢 I/O | 调 checkpoint_completion_target,WAL 与数据分盘 |
| 提交延迟随备库变化 | 主库延迟与备库状态相关 | 同步备库 fsync 慢或网络抖动 | 评估 synchronous_commit 级别、synchronous_standby_names |
| 写入吞吐上不去但延迟不高 | wal_buffers_full 增长 | WAL 缓冲区偏小 | 增大 wal_buffers(需重启) |
-- 区分"等锁"还是"等 fsync":看等待事件
SELECT wait_event_type, wait_event, count(*) AS sessions
FROM pg_stat_activity
WHERE state = 'active'
GROUP BY 1, 2
ORDER BY sessions DESC
LIMIT 10; wait_event_type | wait_event | sessions
-----------------+-----------------+----------
IO | WALWriteLock | 4
LWLock | WALWriteLock | 6
Client | ClientRead | 3- 出现
WALWriteLock等待说明多个进程在争「写 WAL 文件」这把串行锁,是 fsync 成为瓶颈的直接证据。 WALInsertLock的LWLock等待则说明缓冲区插入竞争激烈(并发极高或wal_buffers偏小)。
实战示例
示例 1:测量提交延迟与组提交效果
用一个完整的对比实验量化 commit_delay 的价值:
-- 准备:建一张简单表
DROP TABLE IF EXISTS commit_bench;
CREATE TABLE commit_bench (id bigserial PRIMARY KEY, note text);# 用 pgbench 生成一组高并发小事务的负载
# -c 32 个并发连接,-T 20 秒,每事务一次插入
pgbench -i -s 1 demo
pgbench -c 32 -j 4 -T 20 -f - demo <<'SQL'
INSERT INTO commit_bench (note) VALUES ('bench');
SQL-- commit_delay = 0(默认),32 并发插入
transaction type: stdin
scaling factor: 1
query mode: simple
number of clients: 32
number of threads: 4
duration: 20 s
number of transactions actually processed: 186432
latency average = 3.428 ms
latency stddev = 2.114 ms
initial connection time = 62.148 ms
tps = 9284.712043 (without initial connection time)# postgresql.conf:开启组提交后再测一次
commit_delay = 500us
commit_siblings = 5-- commit_delay = 500us,同样的负载
number of transactions actually processed: 264918
latency average = 2.414 ms
latency stddev = 1.687 ms
tps = 13201.443921 (without initial connection time)结果解读:
- TPS 从 9285 提升到 13201(约 +42%),同时平均延迟从 3.43ms 降到 2.41ms。这看似矛盾——加了等待反而更快?原因是:等待 500us 让更多事务合并成一次 fsync,每个事务平摊到的 fsync 时间大幅下降,净效果是延迟与吞吐双改善。
- 收益的前提是高并发(32 个连接同时提交)。
commit_siblings = 5保证低并发时不会白白等待。 - 这个实验也说明
commit_delay必须实测:它与存储的 fsync 耗时长(本例约 0.5ms 量级)强相关。fsync 很快的 NVMe 上收益会小得多。 - 别忘了这是个「有得有失」的调优:稳态延迟可能上升零点几毫秒。对延迟极敏感的场景应谨慎。
示例 2:验证 synchronous_commit 的取舍
用一个受控序列观察不同 synchronous_commit 级别对提交延迟的影响,并理解其语义边界:
-- 1) 记录基线:默认 synchronous_commit 与当前 flush 位点
SHOW synchronous_commit;
SELECT pg_current_wal_insert_lsn() AS insert_lsn,
pg_current_wal_flush_lsn() AS flush_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_insert_lsn(),
pg_current_wal_flush_lsn())) AS unflushed;
-- 2) 在关闭同步提交的会话里写入,观察"生成"与"刷盘"的差距
SET synchronous_commit = off;
INSERT INTO commit_bench (note)
SELECT 'bulk ' || g FROM generate_series(1, 50000) AS g;
SELECT pg_current_wal_insert_lsn() AS insert_lsn,
pg_current_wal_flush_lsn() AS flush_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_insert_lsn(),
pg_current_wal_flush_lsn())) AS unflushed_after_bulk;
-- 3) 恢复默认,再看一次(此时刷盘已被追平)
RESET synchronous_commit;
SELECT pg_current_wal_insert_lsn() AS insert_lsn,
pg_current_wal_flush_lsn() AS flush_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_insert_lsn(),
pg_current_wal_flush_lsn())) AS unflushed_after_commit;-- 步骤 1
synchronous_commit
--------------------
on
insert_lsn | flush_lsn | unflushed
------------+------------+-----------
0/A000100 | 0/A0000C8 | 56 bytes
-- 步骤 2(插入 5 万行后,flush 落后很多)
insert_lsn | flush_lsn | unflushed_after_bulk
------------+------------+----------------------
0/B8C4220 | 0/A0000C8 | 392 MB
-- 步骤 3(RESET 后执行一次提交,刷盘被追平)
insert_lsn | flush_lsn | unflushed_after_commit
------------+------------+------------------------
0/B8C42A0 | 0/B8C4220 | 128 bytes结果解读:
- 步骤 1 中
unflushed = 56 bytes,说明默认状态下 WAL 几乎实时刷盘(WAL Writer 在不停追)。 - 步骤 2 中
unflushed暴涨到 392MB:synchronous_commit = off下,插入产生的大量 WAL 只停留在缓冲区与内核缓存里,没有任何 fsync 把它们强制落盘。这正是「关闭同步提交」真实的行为——不是产生得更少,而是不强制刷盘。 - 步骤 3 的
RESET恢复了默认值,随后的一次提交触发 fsync,一句就把 392MB 全部刷平(flush_lsn追上了insert_lsn)。 - 风险边界:若在步骤 2 的状态下断电,那 392MB 里所有对应的已提交事务全部丢失。数据本身不会损坏(WAL 后半段缺失,重启后从检查点重放到最后一个有效记录即可),但事务会丢。这就是「
synchronous_commit = off不损坏数据但会丢事务」的实证。
示例 3:观察 WAL Writer 的「抢跑」效果
用一个序列验证 WAL Writer 能显著减少提交时的等待:
-- 1) 连续提交 100 次单行事务,测量总耗时与 flush 追进情况
SELECT pg_current_wal_insert_lsn() AS insert_lsn,
pg_current_wal_flush_lsn() AS flush_lsn;
DO $$
DECLARE
i int;
t0 timestamptz := clock_timestamp();
BEGIN
FOR i IN 1..100 LOOP
INSERT INTO commit_bench (note) VALUES ('tick ' || i);
-- 每个 INSERT 自动提交,触发一次 XLogFlush
END LOOP;
RAISE NOTICE '100 次自动提交耗时: %',
clock_timestamp() - t0;
END $$;
SELECT pg_current_wal_insert_lsn() AS insert_lsn,
pg_current_wal_flush_lsn() AS flush_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_insert_lsn(),
pg_current_wal_flush_lsn())) AS unflushed;-- 步骤 1
insert_lsn | flush_lsn
------------+------------
0/C0010A0 | 0/C0000E8
-- 步骤 2 输出
NOTICE: 100 次自动提交耗时: 00:00:00.192483
-- 步骤 3
insert_lsn | flush_lsn | unflushed
------------+------------+-----------
0/C04B8C0 | 0/C04B860 | 96 bytes结果解读:
- 100 次单行自动提交耗时约 192ms,平均每次约 1.9ms。若每次提交都要各自等一次 fsync(按示例 1 中 0.54ms 估算),最坏情况将接近 54ms——实际并非如此。
- 关键在
unflushed = 96 bytes:每次提交前,flush 位点几乎都已经被推进到自己附近。这说明 WAL Writer(每 200ms 刷一次)+ 前一次提交的刷盘,共同让后续提交频繁「免等」。 - 若把
wal_writer_delay改为10s,这个实验的耗时通常会明显增加,因为「免等命中率」下降,更多提交需要自己触发完整 fsync 并等待。 - 反过来,把
wal_writer_delay调得过小(如10ms)会让 WAL Writer 频繁 fsync,与提交路径争抢WALWriteLock,在低写入量下反而可能增加延迟——这个参数不是越小越好。
注意事项
synchronous_commit = off不等于fsync = off。前者只是不强制刷盘,掉电丢最近事务;后者跳过 fsync 调用,掉电可能导致数据目录损坏、无法恢复。绝不要为了性能把fsync关掉。
-- 会话级降级:只影响当前事务/会话,事务结束自动失效
BEGIN;
SET LOCAL synchronous_commit = off;
-- 批量导入 ...
COMMIT;# postgresql.conf
# 全局降级:仅用于确认可接受少量事务丢失的场景(如日志采集库)
# 注意:这是 synchronous_commit,不是 fsync
synchronous_commit = off
fsync = on # 必须保持 oncommit_delay在低并发下纯粹增加延迟。它由commit_siblings把关,但如果你的负载并发很低(如 < 5),设它没有意义,反而可能让延迟变差。wal_buffers是重启参数,自动值上限为 16MB。若wal_buffers_full持续增长,需要显式调大并重启。commit_delay的单位是微秒,不是毫秒。设成500是 0.5ms,设成500000是半秒——后者会造成灾难性延迟。synchronous_commit的生效范围可以细到事务级,用SET LOCAL只影响当前事务,这比全局调整安全得多。
-- 只对一次批量导入降级,其他并发事务不受影响
SET LOCAL synchronous_commit = off;- 同步复制下主库提交延迟包含备库的 fsync 时间。若备库存储较慢,主库延迟会明显上升,而
pg_stat_replication中的flush_lsn能反映这一点。 - 不要用轮询
pg_current_wal_flush_lsn()来「等 WAL 落盘」。这是观测手段,不是同步手段;真实等待由提交路径内部完成。 - 热备查询不产生 WAL,但
SET会话参数、临时表操作等在某些情况下仍可能触发表空间级别的 WAL 写入。 wal_writer_flush_after = 0会关闭 flush 提示,可能导致 WAL 长时间停留在内核页缓存,在操作系统崩溃时丢失更多。
最佳实践
- 先用
pg_test_fsync量化存储的 fsync 耗时,再决定是否启用commit_delay。fsync 越快,组提交的收益越小。 - 高并发小事务场景开启组提交,建议从
commit_delay = 200us起测,逐步调整;commit_siblings匹配典型并发度。
# postgresql.conf:高并发 OLTP 的起点配置
commit_delay = 200us
commit_siblings = 5
wal_writer_delay = 200ms
wal_writer_flush_after = 1MB- 只在明确可接受丢失的场景降级
synchronous_commit,且优先用SET LOCAL限定到具体事务或会话,避免全局影响。 - 把
wal_buffers_full纳入监控,它是写路径瓶颈最直接的信号;持续增长才考虑调大wal_buffers。 - 为 WAL 与数据目录使用不同物理设备,让顺序写(WAL)与随机写(数据页、检查点)不互相争抢 I/O。
- 监控
pg_stat_activity中的WALWriteLock等待事件,这是 fsync 竞争的直接证据。 - 有同步备库时用
remote_write换取延迟收益,只在需要读一致性时用remote_apply,并注意备库故障会阻塞主库提交。 wal_compression优先开lz4(PG15+),在写路径上省下的 I/O 是纯收益。- 不要用
commit_delay掩盖存储性能问题。若 fsync 耗时是几毫秒,先换存储或改用带断电保护的写缓存,再考虑参数调优。 - 压测时同时看 TPS 与 P99 延迟,而不是只看吞吐。组提交的收益往往体现在 P99 的显著下降,而不是平均值。
下一节
继续学习:Checkpoint(检查点)