#DevOps

数据库复制延迟:原因、监控与缓解

读写分离后用户"写完读不到"——这是复制延迟。本文讲清延迟为什么产生(异步复制网络/磁盘/事务)、怎么监控(pg_stat_replication)、怎么缓解(读写一致性窗口/同步复制/读主库关键路径),以及应用层的"写后读一致性"方案。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年1月17日·更新于 2026年7月23日

数据库复制延迟:原因、监控与缓解

先抛一个你大概率踩过的坑。

用户在控制台点了"保存",前端拿到 200,立刻跳到列表页——结果新数据不在。刷新一次,没有;再刷新一次,有了。你打开日志,写入成功,副本查询也成功,没有任何报错。QA 给你提了个 bug:「保存后列表不更新,偶发,概率约 30%」。

这不是缓存问题,也不是事务没提交。这是复制延迟(replication lag)——主库已经写入,副本还没同步过来,而你的读请求恰好路由到了副本。

读写分离是性价比最高的数据库扩展手段,但它有一个甩不掉的副作用:只要复制是异步的,主副本之间就存在时间差。这个时间差可大可小,小到毫秒级(感觉不到),大到几十秒(用户已经开始骂街)。

这篇文章把复制延迟彻底讲透:它从哪儿来、怎么监控、怎么缓解,以及最关键的——在应用层怎么做到「用户永远能读到自己的写」。


一、读写分离的"副作用":为什么写完读不到

经典的读写分离架构长这样:

text
                    ┌─────────────────┐
        写请求 ───▶ │   主库 (Primary) │
                    │   读写在主       │
                    └────────┬────────┘
                             │ 流复制 (WAL stream)
                  ┌──────────┴──────────┐
                  ▼                     ▼
         ┌────────────────┐    ┌────────────────┐
读请求 ─▶ │  副本1 (Replica)│    │  副本2 (Replica)│
         │   只读          │    │   只读          │
         └────────────────┘    └────────────────┘

思路很简单:写都走主库,读分散到多个副本。主库扛写入压力,副本扛读压力,水平扩展读吞吐。

但这里有一个隐含假设:副本的数据是"最新的"。问题是,这个假设在异步复制下并不成立。

完整的「写后读」时间线是这样的:

text
t0  应用向主库发起 INSERT         ──▶ 主库写入 WAL
t1  主库事务提交,返回成功         ──▶ 应用拿到 200
t2  应用向副本发起 SELECT         ──▶ 副本还没收到这条 WAL
t3  副本收到 WAL,apply           ──▶ 数据"出现"了

t1 到 t3 之间的窗口,就是复制延迟。如果 t2 落在这个窗口里,用户就读不到自己刚写的数据。窗口越大,踩坑概率越高。

为什么大家都用异步复制而不是同步?因为同步复制要求主库等副本确认收到才能向客户端返回成功,这会让每次写的延迟翻倍甚至更多,副本一旦抖动,主库写入直接卡住。异步复制牺牲了一致性,换来了写入的低延迟和高可用——这笔交易在大多数场景下是划算的,但代价就是本文讨论的延迟问题。

记住一个结论:读写分离 + 异步复制 = 必然存在复制延迟。 你能做的不是消灭它,而是把它控制在业务可接受范围内,并让用户感知不到。


二、延迟从哪儿来:异步复制的延迟来源拆解

要缓解延迟,先得知道延迟花在哪儿了。PostgreSQL 的流复制(streaming replication)链路可以拆成三段:

text
主库                 网络                 副本
┌─────┐  WAL 生成    ┌─────┐  WAL 传输    ┌─────┐  WAL apply
│ WAL │ ──────────▶  │     │ ──────────▶  │     │ ──────────▶ 数据可见
│ 缓冲│              │ 网段│              │接收 │
└─────┘              └─────┘              │ apply│
                                         └─────┘

延迟可能堆叠在任何一个环节,常见的来源有这么几类:

1. 网络传输延迟

主库和副本之间的网络是延迟的第一大来源。同城机房通常 1ms 以内,跨可用区 2~5ms,跨地域则可能几十到上百毫秒。如果副本和主库不在一个可用区,光网络往返就够你喝一壶。更糟的是网络抖动——偶尔的丢包重传会让延迟瞬间飙升。

2. 副本的磁盘 I/O

副本收到 WAL 之后要落盘并 apply,这个过程依赖副本自己的磁盘性能。如果副本的 IOPS 或吞吐跟不上主库(比如用了更便宜的磁盘、或副本上跑了重查询占满 I/O),WAL 就会在副本侧堆积,apply 迟迟追不上。这是副本侧堆积,和主库无关。

3. 大事务 / 长事务

这是延迟飙升的头号元凶。一个 DELETE FROM logs WHERE created_at < ... 删几百万行,主库生成一个巨大的 WAL 数据流,副本要花同样的力气去 apply,期间完全没法同步后续的小事务。更隐蔽的是长事务——副本上跑了一个 SELECT ... FOR UPDATE 或一个跑了几分钟的报表查询,它持有的快照会阻止副本 apply 与之冲突的 WAL,导致 apply 进程一直等待。

4. 锁冲突

副本上的读查询和 apply 进程之间存在锁竞争。PostgreSQL 的热备(hot standby)默认是 max_standby_streaming_delay = 30s,意思是副本上的查询和 apply 冲突时,apply 最多等 30 秒,超时就取消那个查询。反过来,如果配置成无限等待,apply 就会被查询无限阻塞——延迟就上来了。

5. 主库写入突发

主库突然来一波高并发写入(批量导入、大促、补偿任务),WAL 生成速率远超副本的 apply 速率,副本被动追赶,延迟自然累积。

怎么定位是哪一段? 看 pg_stat_replication 的几个字段(下一节细讲),再结合主副本两边的 I/O 监控、网络监控。一个经验:如果 sent_lsn 和 replay_lsn 差距大,是副本侧慢(apply 或 I/O);如果 write_lsn 和 sent_lsn 差距大,是网络慢。


三、监控:用 pg_stat_replication 把延迟看清楚

你没法缓解你看不见的东西。复制延迟必须上监控、上告警,否则就是裸奔。

PostgreSQL 给了一把趁手的刀:pg_stat_repation。注意:这个视图要在主库上查,每一行代表一个连接上来的副本。

sql
SELECT
    application_name,
    client_addr,
    state,
    sync_state,
    sent_lsn,       -- 主库发到的 LSN
    write_lsn,      -- 副本写盘的 LSN
    flush_lsn,      -- 副本刷盘的 LSN
    replay_lsn,     -- 副本 apply 的 LSN(数据可见)
    write_lag,      -- write 阶段的延迟
    flush_lag,      -- flush 阶段的延迟
    replay_lag      -- replay 阶段的延迟
FROM pg_stat_replication;

延迟有两种度量,都要看:

字节差(最准):主库当前 WAL 位置减去副本的 replay_lsn。直接反映副本"落后了多少字节":

sql
SELECT
    application_name,
    pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes
FROM pg_stat_replication;

lag_bytes 是当下这一刻的真实差距,监控它最稳。但字节差有个问题——1MB 的落后对低写入负载可能是几十秒,对高写入负载可能只是 100ms。所以还要看时间差。

时间差(replay_lag):理论上直接告诉你落后了多少秒。但它有个坑:在低写入负载下不准。原因是它衡量的是"上一条 WAL 的 apply 时间差",如果主库很久没新写入,这个值会失真甚至显示 NULL。所以 replay_lag 在高写入场景下参考价值大,低写入场景还是看字节差。

实战监控:两者都采,画两张图:

  • 副本 lag 字节差(gauge,实时)
  • 副本 lag 时间差(gauge,实时)

告警阈值要按业务定。我的经验值:

业务类型告警阈值(warn)告警阈值(critical)
强一致(金融、订单)1s / 10MB5s / 100MB
一般业务(用户中心)5s / 50MB30s / 500MB
宽松(日志、统计)30s / 500MB5min / 2GB

别只看主库侧。副本自己也要监控,关键是这两条:

sql
-- 副本上执行:看自己 apply 到哪了
SELECT pg_last_wal_receive_lsn(),   -- 收到的最新 LSN
       pg_last_wal_replay_lsn(),    -- apply 的最新 LSN
       pg_last_xact_replay_timestamp(); -- 最后一笔事务的 apply 时间

now() - pg_last_xact_replay_timestamp() 就是副本视角下的延迟秒数。如果这个值持续增长,说明副本 apply 跟不上,得查副本的 I/O、长事务、锁冲突。

一个容易被忽略的监控盲点:复制连接断开。副本如果连不上主库(网络分区、主库重启),pg_stat_replication 里那行直接消失,延迟看起来是 0(因为没数据了)。所以监控里一定要有一条「复制连接是否存在」的健康检查,别被"没数据=没延迟"骗了。


四、缓解策略一:读关键路径走主库

最朴素、也最有效的方案:刚写入的数据,读的时候直接走主库。

逻辑是这样的:用户写入后的短时间内(比如 1~2 秒),他大概率会立刻去读这条数据(跳列表、看详情、回显)。这段时间内副本可能还没同步,那就别赌,直接读主库。等过了这个窗口,副本基本追上了,再把读路由回副本,享受读写分离的好处。

text
写入发生 ─────[ 关键窗口 N 秒 ]─────▶ 时间
              │
              ▼
         这段时间内的读 → 主库
         之后的读      → 副本

实现上,常见两种粒度:

会话级:用户做完一次写操作后,给当前会话打一个标记「最近有写入」,N 秒内这个会话的所有读都走主库。N 秒后标记清除,恢复读副本。这种方案实现简单,但粒度粗——整个会话的读都被"牵连",哪怕它们读的根本不是刚写的那张表。

对象级:精确到表或行。记录"哪些表在 N 秒内被写过",只有读这些表的请求才走主库,读其他表照常走副本。粒度细,但要维护一张"热表"表,复杂度高一些。

关键参数 N 怎么选?看你的 P99 复制延迟。如果监控显示副本 99% 的情况下 1 秒内追平,N 就设 1.5~2 秒(留个安全冗余)。设太大浪费主库算力,设太小还是有概率踩坑。

这个方案的代价:主库要扛一部分读。所以它只适合「写后立刻读」这种关键路径,不能滥用。原则是:只有"用户感知一致性"敏感的读,才走主库;报表、统计、列表分页这种不敏感的,照常副本。


五、缓解策略二:读写一致性窗口

这个方案是策略一的精细化版本,思路更系统。

核心思想:应用层维护一个"写入时间戳",读的时候判断如果距离上次写入没超过阈值,就强制走主库。

伪代码:

typescript
class ReadWriteConsistency {
  private lastWriteAt: Map<SessionId, number> = new Map();
  private readonly WINDOW_MS = 2000; // 一致性窗口

  afterWrite(sessionId: string) {
    this.lastWriteAt.set(sessionId, Date.now());
  }

  route(sessionId: string): 'primary' | 'replica' {
    const last = this.lastWriteAt.get(sessionId);
    if (!last) return 'replica';
    if (Date.now() - last < this.WINDOW_MS) return 'primary';
    return 'replica';
  }
}

每次写操作完成后调用 afterWrite,每次读之前调用 route 决定走哪个节点。

这个方案相比策略一的优势在于:它把"何时回到副本"完全交给时间窗口控制,行为可预测、可调参。窗口越大一致性越强(但主库读越多),窗口越小吞吐越好(但踩坑概率越高)。你可以按业务给不同的表/接口配不同的窗口。

进阶玩法:让窗口跟着副本实际延迟动态调整。如果监控发现副本当前延迟 500ms,窗口就设 1 秒;如果副本延迟飙升到 3 秒,窗口自动放大到 5 秒。这需要把副本延迟指标实时推给应用层(通过监控系统的 API、或共享缓存),实现稍重,但效果最好——既不浪费主库算力,又能在副本抖动时自动收紧一致性。

坑提醒:Date.now() 用的是应用服务器的时间,如果应用服务器和数据库服务器时钟不同步,窗口判断会偏。集群里务必上 NTP/chrony 做时钟同步,别让时钟漂移破坏你的窗口逻辑。


六、缓解策略三:同步复制(synchronous_commit)

前面都是"绕开延迟"的方案。如果你想真的消灭延迟(至少消灭"读不到"的可能性),上同步复制。

PostgreSQL 通过 synchronous_commit 和 synchronous_standby_names 配置:

sql
-- postgresql.conf (主库)
synchronous_commit = on                  # 默认就是 on,但事务级可覆盖
synchronous_standby_names = 'FIRST 1 (replica1)'
sql
-- 事务级别精细控制(更灵活)
BEGIN;
SET LOCAL synchronous_commit = on;       -- 这笔事务要求同步复制
INSERT INTO orders ... ;
COMMIT;                                  -- 主库会等 replica1 确认收到 WAL 才返回成功

FIRST 1 (replica1) 的意思是:主库提交事务时,必须等至少 1 个指定副本(replica1)确认收到 WAL,才向客户端返回成功。这样从客户端视角看,返回成功时副本已经有了数据,后续读副本一定能读到。

代价是什么?

  1. 写延迟增加。每次提交多一次主副本之间的网络往返。同机房可能加 1~2ms,跨机房可能加几十毫秒。高并发写入场景下,这个延迟会显著拉低写入吞吐。
  2. 可用性下降。如果 replica1 挂了或网络断了,主库的写入会被卡住(一直等不到确认)。这是同步复制最大的风险——一致性换可用性,典型的 CAP 取舍。生产实践里常用 FIRST 1 (replica1, replica2) 配多个候选副本,挂一个还有其他,降低风险。
  3. 延迟没消失,只是被"前置"了。同步复制不是让复制变快,而是让"复制完成"成为提交的一部分——你感知不到延迟,是因为你在提交时已经为它付了时间代价。

我的建议:不要全局开同步复制。绝大多数业务不需要。正确做法是事务级精细控制——只有真正强一致要求的事务(支付、扣款、关键状态变更)才 SET LOCAL synchronous_commit = on,其他事务照常异步。这样把代价集中在真正需要的地方。

text
支付事务        ──▶ synchronous_commit = on   (慢,但强一致)
发帖事务        ──▶ synchronous_commit = off  (快,容忍秒级延迟)
日志写入        ──▶ synchronous_commit = off  (快,无所谓)

七、缓解策略四:因果会话与 sticky session

前面几个策略解决的是"全体用户读得到",但很多时候我们真正在乎的其实更弱:用户自己能读到自己的写(read-your-writes consistency)。别人晚几秒看到无所谓,但用户刚发完朋友圈,自己却看不到,这就很出戏。

这是一个比"全局强一致"弱得多、也便宜得多的保证。核心手段是因果会话(causal session):同一个会话内的读,保证能看到这个会话之前的写。

实现上最简单的形态就是 sticky session(粘性会话):一旦某个会话做过写操作,后续这个会话的所有读都路由到主库(或者在副本追上之前路由到主库)。

text
用户会话 A:  写 ─▶ 读 ─▶ 读 ─▶ 读    (全部主库,保证读得到自己的写)
用户会话 B:  读 ─▶ 读 ─▶ 读          (一直副本,享受读写分离)

sticky session 的实现要点:

  1. 会话识别。用 cookie、JWT、session id 把同一用户的请求归到同一逻辑会话。
  2. 状态标记。会话发生写操作后,打上"粘性"标记(比如设一个短期 cookie rw_sticky=1; Max-Age=3)。
  3. 路由决策。带粘性标记的请求读走主库,标记过期后回副本。

相比策略一(读关键路径走主库),sticky session 的粒度是"会话",更符合"读自己的写"这个语义;相比同步复制,它不用牺牲主库写入性能,只是把部分会话的读留在主库。

更精细的版本:sticky session 也可以和副本延迟联动——主库写入返回时带上当前的 WAL 位置(pg_current_wal_lsn()),会话后续读副本时,先检查副本的 replay_lsn 是否已经超过这个位置,超过了就读副本,没超过就读主库。这就做到了"刚好粘性到副本追上为止",不浪费一点主库算力。代价是每次读副本前多一次 pg_last_wal_replay_lsn() 查询,可以根据场景取舍。


八、应用层方案:版本号与时间戳比对

最后一层防线放在应用层,思路是不信任任何路由,靠数据自己说话。

核心:给关键记录加一个版本号或更新时间戳,读的时候比对版本,判断读到的数据是否"足够新"。

版本号方案:

表结构加一列 version,每次更新递增。写操作返回新版本号给客户端,客户端后续读时带上这个版本号,副本返回的数据版本如果小于客户端持有的版本,说明副本太旧——触发回退到主库重读。

typescript
// 写入后,客户端持有 version = 100
const res = await fetchFromReplica(`/api/order/${id}`);
if (res.version < 100) {
  // 副本太旧,回退主库
  return await fetchFromPrimary(`/api/order/${id}`);
}

时间戳方案:用 updated_at 字段,逻辑一样。客户端持有写入时的时间戳,读到的 updated_at 早于这个时间戳就回退。

这个方案的好处是完全在应用层闭环,不依赖数据库复制机制,对任何数据库都适用(MySQL、PG、MongoDB 都行)。坏处是每次读都要带版本判断逻辑,且需要客户端能"记住"自己持有的版本——通常结合会话或请求上下文实现。

一个工业级的组合方案:把前面所有策略叠加起来,形成多层防御。

text
请求进来
   │
   ▼
[sticky session?] ──是──▶ 读主库
   │否
   ▼
[一致性窗口内?] ──是──▶ 读主库
   │否
   ▼
[版本号比对] ──副本太旧──▶ 读主库
   │副本够新
   ▼
读副本  ✅ 享受读写分离

每一层都是兜底,能落到"读副本"这一步的,都是确认安全了的请求。这是大厂常见做法——不指望单一策略兜住所有情况,而是分层降级。


九、别追求零延迟:按业务容忍度选策略

讲了一堆方案,最后泼盆冷水:别追求零延迟。

零延迟意味着全同步复制 + 全主库读,代价是写入延迟翻倍、主库成为单点瓶颈、可用性下降。这套配置只有金融核心、支付清算这类"一致性就是生命"的系统才值得付出。绝大多数业务,根本不需要。

正确的姿势是按业务容忍度分级选策略:

业务场景可接受延迟推荐策略
支付、扣款、对账0(必须强一致)同步复制(事务级)+ 关键读走主库
订单、用户资料<1s读写一致性窗口 + sticky session
内容发布、社交动态秒级读关键路径走主库(窗口 N 秒)
日志、报表、搜索索引分钟级纯异步复制,读副本,接受延迟
分析型查询分钟~小时级读离线副本,甚至批量同步

几个判断维度:

  • 用户能看到这个数据吗? 看不到(内部统计、异步任务),延迟随便;能看到,按下面继续判断。
  • 用户会立刻读吗? 会(写完跳转列表),要保证读自己的写,上 sticky session 或一致性窗口;不会(写入后过会儿才看),普通异步即可。
  • 读不到会出事吗? 会(扣款后查余额),强一致,同步复制或读主库;不会(少看一条评论),容忍延迟。

一个常见的过度设计:所有读都走主库。我看到不少团队为了"图省事、避免延迟问题",干脆放弃读写分离,全部读写都打主库。结果是主库很快成为瓶颈,然后不得不分库分表、上更多复杂方案——其实本来读写分离就能解决的。读写分离的价值是真实的,别因为怕延迟就放弃它,而是要学会和延迟共处。

最后,复制延迟不是 bug,是分布式系统的物理特性。光速就那么快,跨机房的 WAL 传输就是需要时间。你做的所有工程努力,不是在违背物理,而是在业务可接受的成本下,把延迟的负面影响降到用户感知不到的程度。这才是务实的目标。


小结

  1. 读写分离 + 异步复制 = 必然有延迟。复制延迟不是故障,是取舍的结果——用一致性换写入性能和高可用。你能做的是控制它、让用户感知不到,而不是消灭它。

  2. 延迟来源是分段的。网络传输、副本磁盘 I/O、大事务/长事务、锁冲突、写入突发,都会堆叠延迟。定位时看 pg_stat_replication 的 LSN 差,判断是网络慢还是副本慢,对症下药。

  3. 监控必须双端覆盖。主库查 pg_stat_replication 看每个副本的字节差和时间差,副本查 pg_last_wal_replay_timestamp() 看自己 apply 到哪了。两者都要告警,还要监控复制连接本身的存在性——连接断了延迟看起来反而是 0。

  4. 缓解策略分层叠加。读关键路径走主库(最简单)、读写一致性窗口(可调参)、同步复制(最强但最贵)、因果会话/sticky session(性价比最高的"读自己的写"保证)、版本号比对(应用层兜底)。大厂实践是多层防御,不依赖单一方案。

  5. 按业务容忍度选策略,别追求零延迟。金融要强一致就上同步复制;社交能容忍秒级延迟就用 sticky session + 窗口;日志报表随便异步。把成本花在真正需要一致性的地方,而不是全局铺强一致。

金句:复制延迟不是你要消灭的敌人,而是你要管理的指标。真正的高手,不是把延迟压到零,而是在业务感知不到延迟的前提下,把系统吞吐和成本做到最优。

Related

相关推荐