如果你用过Haishan DB的流复制,大概率遇到过这个报错:ERROR: canceling statement due to conflict with recovery。明明是只读查询,为什么会被“冲突”取消?本文带你一探究竟。
1 Hot Standby 的“既要又要”
Hot Standby 是备库在接收和回访 WAL 日志的同时,还能对外提供只读查询服务。这听起来很美好:一台机器既做容灾又分担读负载,物尽其用。但这里有个根本性的矛盾:
- WAL回放(由 Startup 进程负责)在修改数据——创建/删除表、清理死元组、分裂索引页……
- 只读查询(由普通 backup 进程负责)在读取数据——扫描表、持有 buffer pin、持有快照……
当回放需要做的事情,恰好和查询正在做的事情冲突了,怎么办?这就是 Hot Standby 冲突解决机制要回答的问题。Haishan DB 给出的答案是一个分级等待 + 最终取消的策略:先忍一会儿,实在不行就杀掉查询。
2 为什么不能“等查询自己结束”
你可能会想:让 WAL 回放等一等不就行了,等查询结束再回放。问题在于,主库不会因为你备库上有查询在跑就停下来。主库持续产生 WAL,备库持续接收。如果备库的回放停下来了:
- WAL堆积:接收了但不回放,存放日志的目录持续膨胀,可能撑爆磁盘
- 复制延迟扩大:备库越来越落后于主库,查询在备库上看到的数据版本越来越旧
- failover时恢复时间过长:主库故障时,备库必须把积压的WAL全部回放完才能promotion上线,积压越多切换越慢
所以 Haishan DB 的策略是:可以等,但不能无限等。
3 四种冲突类型
深入来看,Hot Stanby 的冲突主要分为四类。每一类都对应一个不同的场景。
3.1 快照冲突(Snapshot Conflict)
最容易遇到的一种。主库上执行了 VACUUM(或 autovauum),清理了一些死元组。对应的 WAL 记录回放到备库时,备库也需要移除这些死元组。
但此时,备库上可能有一个长查询持有快照(Snapshot),这个快照的 xmin 小于被清理元组的 xmax —— 换句话说,这个查询“还需要看到”这些即将被清理的行。如果回放强行清理了,查询就会看到不一致的数据。如果回放等待,查询可能跑很久。
Haishan DB 的做法是:在 max_standby_streaming_delay 时间内等待,超时后向查询发送 PROCSIG_RECOVERY_CONFLICT_SNAPSHOT 信号,查询报错退出。
3.2 锁冲突(Lock Conflict)
DDL操作触发。主库上执行了 ALTER TABLE、DROP TABLE、CREATE INDEX等需要 AccessExclusiveLock 的DDL。WAL回放到备库时,Startup 进程也需要获取同样的 AccessExclusiveLock。问题在于,备库上的只读查询虽然只持有 AccessShareLock(最轻量的表锁),但它和 AccessExclusiveLock 互斥。于是 Startup 进程在等待锁,而查询在悠闲地扫描表,等还是不等?
同样,在上限时间内等待,超时后取消查询。实现上有个有趣的细节:它不仅等,还会在等待超过 deadlock_timeout 后,向持锁 backend 发送 PROCSIG_RECOVERY_CONFLICT_STARTUP_DEADLOCK 信号,要求它们检查自己是否陷入了死锁 —— 因为 Startup 等查询释放锁、查询有可能被 Startup 阻塞,形成一个经典的环形等待。
3.3 Buffer Pin 冲突(Buffer Pin Conflict)
最难排查、也最容易造成死锁的一种。Haishan DB 的Buffer Pool中,每个页面在被读取时会被“pin”住(pin count + 1),读完后unpin。WAL回放时有时需要对页面做清理操作(比如 btree 索引页分裂的回放、VM页面的更新),但页面被某个查询pin住了。
回放进程必须等unpin。如果查询的pin是因为正在等另一个锁,而这个锁有需要WAL回放才能释放,这样死锁就形成了。
代码层面对于这种现象的处理方式比较特殊:它不针对某个特定的backend,而是向所有backend广播信号(PROCSIG_RECOVERY_CONFLICT_BUFFERPIN),让每个backend自己检查“是不是我pin住了这个页面”。如果是,主动报错或FATAL退出。死锁检查不做在第一时间,而是等到dead_timeout之后才触发,因为死锁极少发生,而检查成本较高。
3.4 表空间和数据库冲突
这两类比较直观:
- 表空间冲突:主库DROP TABLESPACE,备库上有查询在该表空间里有临时文件。回放时取消所有活跃查询,因为DROP TABLESPACE是非事务性的,不能等。
- 数据库冲突:主库DROP DATABASE,备库上所有连接挂在那个数据库上。不等待直接强制断开所有连接。
4 优雅的等待机制:退避与超时
上面反复提到了“在时限内等待”,具体是怎么等的?其中的实现逻辑就是:等待时间从1毫秒起步,每次翻倍,最大到1秒;每次醒来检查:当前时间是否超过了截止时间(截止时间 = 最后一次收到WAL的时间 + max_standby_streaming_delay)。
这是一个指数退避(exponential backoff)策略,好处是:
- 冲突通常很快就解决了(查询结束、unpin、释放锁),此时只需等1ms,几乎无感
- 如果冲突持续,休眠时间逐渐加长,避免CPU空转
- 一旦到了截止时间,不在犹豫,立即出手取消
相关GUC参数有两个:
- max_standby_streaming_delay(默认30s):通过流复制接收WAL时的等待上限
- max_standby_archive_delay(默认30s):通过归档文件恢复WAL时的等待上限
设置为-1表示“永远等下去”—— 这在某些场景下是合理的(宁可备库延迟也绝不杀查询),但风险是备库可能无限落后。
5 一个巧妙的预防机制:hot_standby_feedback
等冲突发生了再去解决,终究是被动的。Haishan DB还提供了一个主动预防的机制:hot_standby_feedback。
工作原理:备库定期把当前所有查询中最老的xmin(即最老的活跃事务ID)告知主库。主库的VACUUM在清理死元组时,会跳过那些仍然被备库xmin需要的行。这样,当WAL回放到备库时,就不会出现“需要清理但查询还需要看”的快照冲突了。
代价:如果备库上有一个“长查询”(比如跑8个小时的报表),hot_standby_feedback会让主库VACUUM在8小时内都不敢清理那些死元组,导致主库表膨胀。这是一个典型的空间换时间的权衡。
最佳实践:
- 开启hot_standby_feedback,但设置合理的statement_timeout和idle_in_transaction_session_timeout,避免出现超级长查询
- 如果备库只用于只读分析、对表膨胀敏感,可以不开启,转而调大max_standby_streaming_delay
6 实战建议
遇到“canceling statement due to conflict with recovery”怎么办?
第一步:判断是哪种冲突
从数据库日志(需要开启log_recovery_conflict_waits = on)中可以看到:
recovery still waiting after 30005.123 ms: recovery conflict on buffer pin recovery still waiting after 30012.456 ms:recovery conflict on snapshot |
不同冲突类型对应不同的解决思路。
第二步:分类施策
冲突类型 | 常见原因 | 解决方案 |
快照冲突 | 备库长查询 + 主库频繁VACUUM | 开启hot_standby_feedback;杀掉长查询 |
锁冲突 | 主库DDL操作 | 在业务低峰期做DDL;调大max_standby_streaming_delay |
Buffer pin冲突 | 备库某些查询pin住页面太久 | 检查是否有慢查询;设置statement_timout |
表空间/数据库冲突 | 主库删了表空间或数据库 | 操作前确认备库没有连接在使用 |
第三步:合理配置参数
# 推荐的一组设置 max_standby_streaming_delay = 60s # 给备库查询60秒的“逃生窗口” hot_standby_feedback= on # 主动预防快照冲突 log_recovery_conflict_waits = on # 记录冲突日志,方便排查 statement_timeout = 5min # 防止超级长查询 |
第四步:监控指标
关注这几个值来判断备库的健康状况:
- pg_stat_replication.replay_lag:备库回放延迟
- pg_stat_database_conflicts视图:按冲突类型统计冲突次数
- 备库日志中recovery still waiting的出现频率
7 总结
Hot Standby的冲突解决机制,本质上是在回答一个分布式系统中常见的问题:当多个操作需要互斥访问共享资源时,谁让步?
Haishan DB的设计哲学是务实且克制的:
- 不是“完美解决”冲突,而是“有节制地等待 + 有尊严地失败”
- 给DBA充分的控制权(多个GUC参数可调)
- 在设计和实践中不断迭代
理解这套机制,不仅能帮你排查“canceling statement due to conflict with recovery”这个经典报错,更能让你在设计高可用架构时,对备库的行为有清晰的预期。毕竟,知道系统“什么时候会杀掉你的查询”以及“为什么”,是每个后端工程师的安全感来源。





