在副本上读取自己的写入
摘要
本文讨论了数据库副本中的读取自己的写入问题,并重点介绍了PostgreSQL 19的新命令WAIT FOR LSN作为解决方案,以提高一致性和减少延迟。
<p><a href="https://lobste.rs/s/vq68cd/read_your_own_writes_off_primary">评论</a></p>
查看缓存全文
缓存时间: 2026/09/02 11:51
# 读取你自己的写操作,独立于主节点
来源:https://boringsql.com/posts/read-your-own-writes/
你的 API 接收了变更请求,返回 201 Created 或 200 OK,系统已保存用户的修改。但就在用户点击的瞬间,变更却从应用中消失了,几秒后才重新出现——这还算幸运的情况。没有报错,变更只是暂时不存在了。
在服务端渲染应用的时代,这不成问题:要么状态作为单个请求的一部分被管理,要么用户操作太慢,赶不上系统的处理速度。现代应用改变了这一切。它并行触发变更、使缓存失效、唤醒状态管理,整个过程在毫秒内完成。你的前端反应越灵敏,就越可能抢先在副本节点处理完毕前读取数据。
如果再加上协作产品,情况会更糟:通过 WebSocket 发送的每一次变更都会使每个队友的浏览器标签页和设备上的状态失效,而所有这些设备又都指向相同端点上的相同副本节点。
常见的变通方法包括:
- 将读取固定到主节点(你通常想避免这种做法)
- 添加延迟等待
- 在 Redis 中设置标志
你的应用不得不扮演交通指挥员的角色,通过超时和 Redis 标志来推测复制延迟。副本节点其实清楚自己的状态;只是你的代码无从询问。
PostgreSQL 19 提供了询问的方法。在备用节点上执行:
```sql
WAIT FOR LSN '0/554D1B78';
```
备用节点会阻塞直到重放该位置的数据,然后返回并执行下一条语句。读者只需知道这一点,就能理解下面的数字。
关于更多细节,我的朋友 Gülçin Yıldırım Jelínek 上周撰文详述(https://clickhouse.com/blog/postgresql-19-wait-for-read-your-writes):为何它必须是顶级命令而非函数、该规则如何防止自死锁,以及它所依据的 2016 年提案。详细内容请参阅她的文章;那正是我未能写完的部分,为我节省了大量工作。
以下是我将该语句置于真实流量中测量的结果。
## 副本节点延迟的成因
https://boringsql.com/posts/read-your-own-writes/#what-makes-a-replica-lag
副本延迟是多种因素的综合体现,且各因素可独立观察。其中三项可通过一个查询呈现:
```sql
SELECT application_name, write_lag, flush_lag, replay_lag, pg_current_wal_lsn() - replay_lsn AS bytes_behind
FROM pg_stat_replication;
```
`write_lag` 和 `flush_lag` 是备用节点网络与磁盘机制的体现——WAL 段通过网络传输并写入存储。在同一可用区内,交互仅需零点几毫秒;但跨区域网络下,此时间会持续增加。这是延迟的下限。
`replay_lag` 才是问题的核心。传输 WAL 通常简单;应用 WAL 才是让变更对查询可见的关键。恢复进程是单个启动进程逐条重放记录,尽管预取可以提前缓存 I/O,但无法同时应用两条记录。它必须与机器上的其他任务竞争 CPU 和 I/O,而你的变更可能排在更昂贵的操作之后:批量导入、索引维护,任何产生 WAL 速度超过单个进程重放能力的操作。这就是延迟从毫秒跳升到秒级的原因。
第四个因素是副本节点上读取流量的形态——讽刺的是,这正是你试图达成的目标。粗略地说(以下数据为示意性,非来自配套仓库):
| 读取负载情况 | 重放延迟 | 示例 |
|----------------------------------|----------|------------------------------|
| 无客户端 (pgbench -S) | 5 ms | 25 ms |
| 48 客户端 | 1134 ms | 副本等待最多 `max_standby_streaming_delay`(默认 30 秒)后终止阻塞重放的查询 |
读取后端与启动进程竞争 CPU 和 I/O,恢复进程甚至可能与长时间运行的查询直接冲突,阻塞任何会使该查询快照失效的记录重放。仅仅一个分析查询就可能暂停重放长达半分钟。
你将读取流量成功分流到副本节点的程度越高,引入的延迟就越大。
## 路由策略实测
https://boringsql.com/posts/read-your-own-writes/#the-routing-strategies-measured
现在让我们将常见的变通方法与 `WAIT FOR` 进行实测对比。工作负载很简单:在主节点插入一行,然后像 Web 请求一样读回它。使用 8 个并发 worker 重复此操作 1000 次,针对一个无额外延迟的本地流式副本。
所有测试均在 `postgres:19beta3` 上运行,同一主机(Apple Silicon)上的两个容器:一个主节点和一个异步流式副本。所有数据来自配套仓库(https://github.com/boringSQL/read-your-writes)。
两个节点均通过回环地址通信,因此延迟值是理论下限:真正的跨可用区副本会在此基础上增加往返时间。结果的*形态*易于复现;精确时序则不然。
| 路由策略 | 陈旧读次数 / 总读次数 | 由副本服务的读次数 | p50 | p95 | p99 |
|------------------------------|----------------------|--------------------|--------|--------|--------|
| 读取主节点(粘性路由) | 0 / 1000 | 0 | 1.9 ms | 3.1 ms | 4.3 ms |
| **直接读取副本** | 992 / 1000 | 1000 | 1.8 ms | 2.5 ms | 9.2 ms |
| 等待 50 ms 后读取副本 | 0 / 1000 | 1000 | 53.9 ms| 57.5 ms| 62.0 ms|
| `WAIT FOR` 后读取副本 | 0 / 1000 | 1000 | 2.8 ms | 4.2 ms | 6.4 ms |
这些数据我不得不重新运行多次,因为第二行看起来难以置信——直到你深入思考。副本与主节点位于同一台机器,通过回环地址通信,而朴素读取仍会错过 99.2% 的最新数据。
最后一行是 `WAIT FOR` 的表现:每次读取都正确服务,仅比直接读取主节点慢一到两毫秒(每个百分位数),这相当于两次额外的往返:一次到主节点获取提交 LSN,一次在副本上等待。
## 你不应使用同步提交
https://boringsql.com/posts/read-your-own-writes/#you-shall-not-synchronously-commit
有一个看似简单的修复方法可以避免这一切:让主节点等待备用节点,这样你就无需回头检查。它几乎有效——而这正是问题所在。
将备用节点加入 `synchronous_standby_names` 可将 600 次读取中的陈旧次数从 593 次降低到 **3 到 17 次之间**(取决于运行情况)。这种修复能通过你编写的每项测试,却在生产环境中失败,因为 `synchronous_commit = on` 等待的是备用节点*刷写* WAL,而非*重放*它,而你的读取可能恰好发生在这两个时刻之间。
真正能弥合差距的设置是 `remote_apply`,它使主节点上的每次提交都等待备用节点完成重放:包括批处理作业、数据迁移以及所有永远不会从副本读取的写入操作。`WAIT FOR` 将成本转移到唯一需要它的读取操作上,在需要数据的时刻,以可控的方式实现。
## 超时是路由预算
https://boringsql.com/posts/read-your-own-writes/#the-timeout-is-a-routing-budget
```sql
WAIT FOR LSN 'lsn' [ WITH ( option [, ...] ) ]
```
其中选项可为:
```
MODE 'mode'
TIMEOUT 'timeout'
NO_THROW
```
`MODE` 指定备用节点需要完成 WAL 处理的哪个阶段。`standby_replay` 是默认且此处唯一有用的选项:它等待记录重放完成并对查询可见。其他选项会提前停止,如刷写到磁盘(`standby_flush`)或仅写入(`standby_write`),或改为在主节点等待(`primary_flush`)。
`TIMEOUT` 是读取保证从数据库功能转变为系统架构选择的关键。该选项不控制正确性。超时意味着“副本延迟过大,我无法等待”,正确的响应是改为读取主节点。
以下测试通过 `recovery_min_apply_delay` 将副本延迟固定在 50 ms,因此始终有数据可供等待:
| TIMEOUT | 陈旧读次数 / 总读次数 | 由副本服务的读次数 | 回退到主节点的次数 | p50 |
|------------|----------------------|--------------------|--------------------|--------|
| `500ms` | 0 / 400 | 400 | 0 | 53.4 ms|
| `20ms` | 0 / 400 | 0 | 400 | 24.0 ms|
| `5ms` | 0 / 400 | 0 | 400 | 7.3 ms |
正确性从不动摇。改变的是读取流向的位置及其成本。在预算范围内,延迟转化为*副本上*的等待时间,主节点完全不受影响;只有当延迟超过架构所能接受的范围时,读取才会被回退。
这种回退正是好消息的终点。你最初将读取分流到副本节点的目的是减轻主节点负载,而集群范围的延迟事件会在同一时刻使所有等待者超时。系统随后做出与其设计初衷相反的行为:将本应被保护的主节点淹没在流量洪峰中。
这种突发流量甚至会超过你的正常并发水平,因为将读取从 3 ms 延长到 500 ms 的预算,会在相同到达速率下使更多请求同时处于飞行状态。在许多设计中,让请求失败比返回陈旧结果更好。这个决定正是 `TIMEOUT` 成为架构问题而非调优旋钮的原因。
第二个问题是**等待中的读取会持有连接**,整个超时期间皆如此。以每秒数千次读取和宽裕的预算计算,你会在最不需要连接耗尽的时候耗尽连接。请根据连接池大小来规划超时预算。
最后一点(在 Gülçin 的文章 https://clickhouse.com/blog/postgresql-19-wait-for-read-your-writes 中提及)是故障转移。`WAIT FOR` 仅对正在重放 WAL 的节点有意义,因此新提升的主节点会返回 `not in recovery`。
## 实现方法
https://boringsql.com/posts/read-your-own-writes/#implementing-it
你可能根本不需要这些。如果你的读取确实能容忍陈旧数据,那是一种合理的架构选择而非缺陷。当你遇到问题时就会知晓。
每个能将读取路由到副本节点的应用或框架,都已经提供了一个粘性窗口版本的解决方案:固定到主节点 N 秒、使用延迟、或维护标志。`WAIT FOR` 用事实取代了猜测,并保持其他路由逻辑不变。
四个步骤:
1. **提交后**,在主节点上,通过 `SELECT pg_current_wal_flush_lsn()` 获取 LSN。
2. **存储 LSN** 在会话所在的任何位置。它是一个标识 WAL 位置的字符串(如 `0/3F8A120`),并非机密,但任何能读取它的人都能推算出主节点的写入速度。
3. **读取前**,在副本节点上执行 `WAIT FOR LSN '...' WITH (MODE 'standby_replay', TIMEOUT '...', NO_THROW)`。这是顶级语句——不能在函数或 DO 块内;普通的 READ COMMITTED 事务块即可。`NO_THROW` 将错误转换为可分支的状态行。
4. **使用结果**。`success` 表示可以在该备用节点上执行读取。`timeout` 或 `not in recovery` 表示应读取主节点、使请求失败,或返回带有陈旧标记的副本数据(以便乐观应用写入的客户端可以保留该数据)。
将步骤 4 的失败率暴露为指标。这个数字能告诉你预算设置是否接近现实,也是熔断器在预算失灵时应触发的信号。
技术上还有第四种可能:**等待可能被终止**。`NO_THROW` 将状态转换为行,但连接并非永生。备用节点上的等待会话仍可能受恢复冲突影响,其中一些冲突会终止所有后端进程,无论其状态如何(https://www.postgresql.org/docs/19/sql-wait-for.html)。重放表空间删除就是文档中的示例。
### 应记录哪个 LSN
https://boringsql.com/posts/read-your-own-writes/#which-lsn-to-record
步骤 1 说明是*提交后*。一个诱人的捷径是在事务内获取 LSN,以节省一次往返:
```sql
BEGIN;
INSERT INTO messages(author, body) VALUES ('Radim', 'Hello Postgres!');
-- 这是错误的!
SELECT pg_current_wal_insert_lsn();
COMMIT;
```
该位置来自*事务内部*,在提交记录写入之前。在单个空闲会话中,差距很小:内部为 `0/554D1B50`,提交后为 `0/554D1B78`——相差 40 字节。`pg_walinspect` 可以告诉你这些字节包含什么:
```sql
postgres=# SELECT start_lsn, end_lsn, record_length, resource_manager, record_type
FROM pg_get_wal_records_info('0/554D1B50','0/554D1B78');
-[ RECORD 1 ]----+-------------
start_lsn | 0/554D1B50
end_lsn | 0/554D1B78
record_length | 34
resource_manager | Transaction
record_type | COMMIT
```
这是一条记录:`Transaction/COMMIT`,34 字节填充了 40 字节的空隙。
现在观察副本重放这一段。它应用 INSERT 记录,行落入页面,但不可见——因为尚未有任何通知告诉备用节点该事务已提交。下一条记录才做这件事,而它正是你的 LSN 所未达到的位置。
在事务内位置等待是等待行存在;在提交后位置等待是等待行生效。
## 令牌不止于会话
https://boringsql.com/posts/read-your-own-writes/#the-token-doesn-t-stop-at-the-session
LSN 是一个短字符串,因此它可以传递:响应头、作业负载上的字段、交给其他服务的值。分布式系统本就携带此类一致性令牌,而这里它恰好是 WAL 位置。这有两个后果:
如果你从多个来源接收令牌,保留较大的那个——因为它是唯一同时保证较小令牌也有效的令牌。不要将它们作为字符串比较;转换为 `::pg_lsn` 并让 Postgres 进行排序。
这不是修复陈旧副本的通用方案。`WAIT FOR` 只涵盖你持有 LSN 的那些写操作。在你读取之前片刻提交的并发写入,仍会在副本处理到时显示,与之前一样。
## 示例实现
https://boringsql.com/posts/read-your-own-writes/#sample-implementation
整个模式约三十行代码:两个连接池、一个验证过的令牌、一个分支。以下代码除语法外并不特定于 Go;它就是上述四个步骤,每个步骤对应一个函数。
`CaptureLSN` 是步骤 1,唯一重要的是它在哪个连接池上运行:主节点上,提交后执行。`Replayed` 是步骤 3 和 4 的组合,将所有可能答案折叠为一个布尔值——因为从应用角度看只有两种结果:该备用节点有你的写入,或你需要读取主节点。`PoolFor` 是路由决策,仅三行代码,因为它本身就很简单。
步骤 2 被刻意省略。只有你能决定令牌在写入请求和读取请求之间存放的位置:会话、签名 Cookie、客户端回传的响应头、作业负载上的字段。代码仅假设某处会传入 LSN。
```go
// WAIT FOR 不接受绑定参数,因此两个操作数都是内插的,且必须先验证。
// LSN 通常在到达这里之前已经历 Cookie 往返;预算常来自配置,与受信数据不同。
var (
lsnRE = regexp.MustCompile(`^[0-9A-Fa-f]{1,8}/[0/9A-Fa-f]{1,8}$`)
budgetRE = regexp.MustCompile(`^[0-9]{1,6}(ms|s)$`)
)
// CaptureLSN 在主节点上运行,提交后执行。在此之前或在其他连接上执行,
// 将得到一个与你的写入无关的位置。
func CaptureLSN(ctx context.Context, primary *pgxpool.Pool) (string, error) {
var lsn string
// 刷写,而非插入:在 synchronous_commit=on 时,这已处于或超过你的提交记录位置,
// 而不会包含其他后端仅插入的 WAL。
err := primary.QueryRow(ctx, "SELECT pg_current_wal_flush_lsn()::text").Scan(&lsn)
return lsn, err
}
// Replayed 询问一个副本是否已追赶到 lsn,在预算内。
// 除 success 外的任何答案(timeout、提升、副本失效)都意味着“读取主节点”,
// 因此所有失败路径都返回 false 而非 error。
func Replayed(ctx context.Context, replica *pgxpool.Pool, lsn, budget string) bool {
if !lsnRE.MatchString(lsn) || !budgetRE.MatchString(budget) {
return false
}
conn, err := replica.Acquire(ctx)
if err != nil {
return false
}
defer conn.Release()
var status string
err = conn.QueryRow(ctx, fmt.Sprintf(
"WAIT FOR LSN '%s' WITH (MODE 'standby_replay', TIMEOUT '%s', NO_THROW)",
lsn, budget,
)).Scan(&status)
if err != nil {
return false
}
return status == "success"
}
// PoolFor 根据预算和副本响应返回应使用的连接池。
// 副本超时或提升时回退到主节点;正常情况下使用副本。
func PoolFor(ctx context.Context, primary, replica *pgxpool.Pool, lsn, budget string) *pgxpool.Pool {
if Replayed(ctx, replica, lsn, budget) {
return replica
}
return primary
}
```
此示例省略了指标暴露和熔断逻辑,但你可以看到决策位置:在执行读取之前立即评估。
相似文章
Postgres LISTEN/NOTIFY 实际上可扩展
这篇博文驳斥了 Postgres LISTEN/NOTIFY 无法扩展的传言,展示了如何优化它以实现每秒6万次写入,延迟为毫秒级。
rqlite如何(以及为何)掌控SQLite的预写日志
本文介绍了rqlite(一种分布式SQLite数据库)如何掌控SQLite的预写日志(WAL),从而实现对Raft共识的高效快照,通过将WAL作为增量状态来避免完整的数据库复制。
让Postgres队列实现可扩展性
一篇详细的技术博文,解释如何使用SKIP LOCKED和适当的事务隔离级别来扩展基于PostgreSQL的队列,实现每秒3万次工作流执行。
PostgreSQL 19 中的内核异步读取(io_uring)
PostgreSQL 19 通过 io_uring 引入了内核异步读取功能,实现了直接的异步缓冲 I/O,从而在不使用专用工作进程的情况下提高性能。
禁用 Postgres FPW 实现写入性能 5 倍提升
本文介绍了 Databricks 的 Lakehouse 架构如何通过禁用全页写入(FPW)并利用无状态计算与分布式存储,使 Postgres 的写入吞吐量提升 5 倍。