PostgreSQL只读锁定实战:从参数机制到生产环境排错指南
2026/9/13 16:34:02 网站建设 项目流程

离上次被凌晨电话叫醒已经两个月了。电话那头是值班同事急促的声音:"数据库写不进去了,所有应用都在报只读事务错误。"作为一个常年跟 PostgreSQL 打交道的人,我第一反应不是慌,而是先问一句:"你现在执行一下 select pg_is_in_recovery() 看看返回什么?"

这句话基本能区分一半的场景。PostgreSQL 数据库实例出现只读现象,在真实生产环境中远比想象中常见:可能是连接串误配到了从库,可能是磁盘把 WAL 写崩了,也可能是运维主动把实例锁成只读来配合变更。这篇文章围绕 PostgreSQL 数据库实例的只读锁定展开,把被动排查和主动操作的完整链路都讲清楚,包括背后的参数机制、生产场景的实操流程,以及我踩过的几个印象深刻的坑。无论你是 DBA、后端开发还是刚接触 PostgreSQL 的新人,都能从里面找到直接能用的方法。

1. 先分清"被动只读"与"主动锁定":排查的第一步

1.1 pg_is_in_recovery() 返回 true:你连的是从库

PostgreSQL 的流复制架构里,备库在 hot_standby 参数开启时,可以对外提供只读查询服务。这个"天然只读"是复制协议决定的——备库通过回放主库传来的 WAL 日志来同步数据,任何直接写入都会破坏数据一致性,所以 PostgreSQL 在事务开始时就把所有事务强制标记为只读。

遇到这种被动只读,最常见的触发原因是连接配置问题。应用连到了备库端口,或者中间件负载均衡把写流量分到了备库。我见过一个小伙子排查了两个小时,最后发现是 JDBC URL 里少配置了一个参数,流量全被路由到了只读节点。最快的确认方式就是执行:

SELECT pg_is_in_recovery();

返回true说明当前节点是备库或正处于恢复状态;返回false才是可写主库。这一步 10 秒内就能完成,但它能避免你在错误的方向上浪费大量时间。顺带一提,在 psql 里执行\conninfo也能看到连接的是哪台主机、哪个端口,配合使用更稳妥。

1.2 磁盘爆满导致的"伪只读"

还有一种只读是披着羊皮的狼:数据库本身没被设置成只读,但底层磁盘满了,WAL 段文件写不进去,事务提交时崩溃,实例重启后进入 recovery 状态。此时对外表现就是"写什么都报只读错误",而且pg_is_in_recovery()可能返回true,很容易被误判成普通的只读故障。

判断方法不复杂:先df -h看数据目录和 WAL 目录的剩余空间,再看 PostgreSQL 日志里有没有could not write to fileNo space left on device这类关键字。遇到这种状况,第一优先级是清理磁盘空间,而不是去折腾只读参数。有同行跟我说他们"解锁只读"搞了半小时没效果,最后发现是归档目录被 WAL 堆满了,清理完瞬间恢复。这个案例我一直记着,因为太典型了。

1.3 主动锁定:运维为什么要故意把数据库变成只读

如果说被动只读是事故,那主动锁定就是有计划的"手术"。实际生产里,主动把数据库实例设置成只读通常出于三个目的:

  • 备份窗口保护:做物理备份或某些特殊逻辑备份时,锁住写入可以保证数据文件的一致性,避免备份快照读到一半的数据。
  • 主备切换静默:切换前把主库锁成只读,等备库追平 WAL 延迟后再提升,确保切换过程不丢数据、不产生脑裂。
  • 应用容错演练:测试应用在数据库不可写时的降级表现,提前暴露代码里那些不处理异常的隐患。

三种场景对锁定粒度和操作流程的要求不一样。只锁一个库,还是锁整个实例,取决于你希望"哪个范围的写入被拒绝",接下来的参数机制和实操细节会逐一说明。

2. transaction_read_only 参数体系:只读锁定背后的真正机制

2.1 核心参数与优先级规则

PostgreSQL 处理只读事务的核心参数有两个:default_transaction_read_only决定"新开的事务默认是不是只读",transaction_read_only是"当前事务实际的只读状态"。还有一个等价语法SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY,本质上也是在修改会话里的默认值。

default_transaction_read_only支持在多个层级设置,优先级从高到低是:

  1. 会话内 SET(包括客户端启动参数);
  2. 角色 + 数据库的双重限定(ALTER ROLE ... IN DATABASE);
  3. 数据库级(ALTER DATABASE);
  4. 角色级(ALTER ROLE);
  5. 实例级(postgresql.conf 或ALTER SYSTEM);
  6. 编译期内置默认值。

这个优先级规则是排错的关键。比如你明明改好了 postgresql.conf 里的参数,reload 之后新连接仍然只读,那就要怀疑是不是某个数据库或角色上单独设了值。用pg_settings视图可以看得一清二楚:

SELECT name, setting, source, sourcefile, sourceline FROM pg_settings WHERE name = 'default_transaction_read_only';

source字段会直接告诉你当前值来自哪里:defaultconfiguration filedatabaseuser还是session。我见过不少"参数改不动"的现场,最后都是在这个视图里找到了真相。

顺便说一句,和 Oracle 的ALTER TABLESPACE ... READ ONLY这类物理级只读不同,PostgreSQL 的只读锁定是事务级的逻辑控制。它不改变数据文件的物理状态,只是从事务开始就拒绝写操作。好处是切换灵活,代价是它更依赖连接和事务的行为——某个连接如果绕过了参数约束,锁定就会出现口子。

2.2 会话级与事务级只读的生效逻辑

SET default_transaction_read_only = on;只影响当前会话,而且只对"之后开启的新事务"生效。这条命令在事务块内外都能执行,但如果你当前正处在一个事务里,执行它不会改变这个已存在事务的读写属性。

SET TRANSACTION READ ONLY;则是直接给当前事务打上只读标记。它有两个硬性限制:必须在事务块内执行,而且必须在任何查询语句之前执行。如果先跑了一条 SELECT 再执行,会直接报错:

ERROR: transaction read-write mode must be set before any query

这个设计其实很合理——事务开始时要确定快照行为和锁的获取策略,中途切模式会让隔离级别与一致性语义变得不可控。理解了这一点,你就明白为什么实例级锁定时,已经在跑的长事务不会被中断,它们会按原模式继续跑完,新事务才进入只读状态。

2.3 状态检查的标准动作

我在生产环境排查只读问题,固定跑一组命令,顺序不能乱:

-- 第一步:判断节点角色 SELECT pg_is_in_recovery(); -- 第二步:查看默认事务模式及其来源 SELECT name, setting, source FROM pg_settings WHERE name = 'default_transaction_read_only'; -- 第三步:当前会话的事务只读状态 SHOW transaction_read_only; -- 第四步:事务块内的写入冒烟测试 BEGIN; CREATE TABLE _ro_test(id int); ROLLBACK;

第四步是关键。在只读模式下,CREATE TABLE会立刻收到ERROR: cannot execute CREATE TABLE in a read-only transaction;在可写模式下这条命令能正常执行,随后被 ROLLBACK 清掉,不会在系统里留下任何残留。相比直接对着业务表做 INSERT,这个测试方式在生产库上零风险。

3. 实例只读锁定的完整操作手册

3.1 实例级锁定:ALTER SYSTEM 与平滑 reload

把整个实例锁成只读,标准做法是修改default_transaction_read_only参数。我推荐用ALTER SYSTEM而不是手改 postgresql.conf,原因有两个:一是它会把设置写入postgresql.auto.conf,不需要手工管理文件路径;二是语法统一,回滚用RESET即可,不容易出错。

ALTER SYSTEM SET default_transaction_read_only = on; SELECT pg_reload_conf();

pg_reload_conf()触发的 reload 是平滑的,不会中断正在执行的查询,已打开的事务继续按原模式跑完。对于大多数连接来说,reload 之后新开启的事务就会使用新的默认值。

但这里有一个必须强调的点:reload 只能影响"没有在会话里显式覆盖过该参数"的连接。如果应用在连接初始化时执行过SET default_transaction_read_only = off,那 reload 对它无效,这个连接仍然可写。这个问题在后面的踩坑实录里我会专门展开。

提示:只读锁定只拦"新事务",已经在跑的长事务不会被强制中断。如果业务需要立即停写,必须配合 pg_terminate_backend 处理存量会话。

验证锁定是否生效,建议用带事务的写入测试:

BEGIN; INSERT INTO app_user(id, name) VALUES (999999, '__test__'); -- 期望输出:ERROR: cannot execute INSERT in a read-only transaction ROLLBACK;

如果确实报了只读错误,说明新的写事务已经被挡在门外了。

3.2 数据库级与角色级只读:按需收紧范围

并不是所有场景都需要锁整个实例。有时候只想保护某一个业务库,或者只限制某个应用账号的写权限,PostgreSQL 支持更细粒度的设置:

-- 指定数据库的所有新连接只读 ALTER DATABASE business_db SET default_transaction_read_only = on; -- 指定角色的所有新连接只读 ALTER ROLE app_user SET default_transaction_read_only = on; -- 角色仅连接指定数据库时才只读 ALTER ROLE app_user IN DATABASE business_db SET default_transaction_read_only = on;

这里有个高频误区:ALTER DATABASE ... SET并不是立即生效的。它只对"修改之后新建的连接"有效,已经存在的连接不受影响。所以在生产环境里,调整完参数后往往还要配合清理存量连接,否则会出现"一部分连接只读、一部分可写"的诡异状态,排错时很容易被表象带偏。

3.3 存量连接处理:让锁定真正闭环

处理存量连接是整个操作流程里最容易被忽略的环节。如果参数修改之前已经有长连接存在,这些连接不会自动断开,也不会自动切换新的默认值。我的标准处理流程分三步:

第一步,先看当前有哪些活跃事务,评估杀连接的代价:

SELECT pid, application_name, state, now() - xact_start AS transaction_age FROM pg_stat_activity WHERE xact_start IS NOT NULL AND pid <> pg_backend_pid() ORDER BY transaction_age DESC;

第二步,确认这些事务可以安全中断后,终止所有客户端连接:

SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE backend_type = 'client backend' AND pid <> pg_backend_pid();

第三步,让应用连接池自动重建新连接。新连接会从数据库级或实例级配置中读取只读参数,锁定才算真正闭环。

这里要多说一句:pg_terminate_backend会强制回滚目标连接上的事务,对应用来说相当于连接被服务端断开。如果是白天业务高峰期做这个操作,要提前跟业务方确认应用的连接池能否自动重连、重连后能否正确恢复。我一般建议把存量连接处理放到维护窗口内的低峰时段,宁可多等几分钟,也不要贸然杀连接。

3.4 解锁:比锁定更需要谨慎

解锁就是反向操作。全局级别的锁定用以下命令解除:

ALTER SYSTEM RESET default_transaction_read_only; SELECT pg_reload_conf();

如果是数据库级或角色级设置的,对应使用:

ALTER DATABASE business_db RESET default_transaction_read_only; ALTER ROLE app_user RESET default_transaction_read_only;

RESETSET off的区别值得单独说明:RESET是删除该层级的自定义设置,让参数回落到上级设置或内置默认值;SET off则是显式写入一个关闭值。绝大多数场景下应该用RESET,因为如果你的角色同时还依赖数据库级的on设置,SET off反而会覆盖那个on,制造一种"明明锁了但为什么没锁住"的混乱。

注意:RESET 和 SET off 语义不同。清除锁定建议使用 RESET,避免掩盖上级层级可能存在的 on 设置。

解锁后同样要回归验证:

SHOW default_transaction_read_only; -- 期望 off BEGIN; SELECT 1; COMMIT;

顺带分享我的习惯:实例级锁定时,把"修改参数、reload、杀连接、验证"四个步骤写进自动化脚本,每一步输出一行确认信息,避免手工操作遗漏。脚本里把pg_is_in_recovery()的状态也一并打印出来,切换场景下特别有用。

4. 实战场景:什么时候最需要只读锁定

4.1 备份窗口:锁住写入换来一致性快照

做物理备份时,虽然 PostgreSQL 有 WAL 归档机制能保证恢复一致性,但如果备份期间写入量很大,恢复时需要回放大量 WAL,恢复时间会明显拉长。某些特殊场景下,比如存储层快照配合每日备份,提前把实例锁成只读,可以让快照在任何时间点都保持一致性,省去复杂的 WAL 拼接验证。

我实际接触过一个客户,每晚 24 点整做存储快照,快照前 5 分钟通过脚本把数据库锁成只读,快照完成确认无误后再解锁。这套方案已经稳定运行了两年。关键点在于:只读锁定的时间窗口很短,对业务影响极小,但备份集的一致性验证成本几乎降为零。比起事后花半小时研究 WAL 是否连续,提前锁 5 分钟显然划算得多。

4.2 主备切换:静默写入后的安全切换

主备切换(switchover)是只读锁定最有价值的应用场景。完整流程我整理如下:

  1. 业务维护窗口开始,通知应用停止写入;
  2. 在主库执行ALTER SYSTEM SET default_transaction_read_only = on,并 reload;
  3. 观察备库回放延迟,通过pg_stat_replication确认replay_lsn追上了主库的最新flush_lsn
  4. 在备库执行SELECT pg_promote();提升为新主库;
  5. 切换访问入口(VIP 或连接配置)指向新主库;
  6. 旧主库降级为备库,并解除只读参数。

这套流程里,只读锁定起的是"兜底闸门"作用。即便有某个服务忘了停机,数据库层面也会拒绝它的写入,避免那些漏网之鱼在切换瞬间产生无法同步的数据。我在多个项目里验证过:这层保险平时看不见,但真到切换那一刻,它就是防止数据分叉的最后一道防线。

4.3 故障演练:用只读模式做廉价的故障注入

还有一个经常被忽略的场景:测试应用的容错能力。把数据库切成只读,让应用跑一遍核心链路,观察它在写入失败时是优雅降级还是直接崩溃。这比用网络故障模拟器的成本低得多,却同样能暴露大量真实问题。

我在一个金融项目里组织过这样的演练。有个报表模块在写入失败时抛了未捕获异常,导致整个查询线程挂掉,异步任务又没有重试限制,失败后不断重新入队,消息队列被越堆越满。这些问题在没有故障注入的日常环境里几乎不可能暴露。演练结束后,开发组对所有写路径都加了异常处理和熔断逻辑,后面再遇到数据库维护,应用侧的表现就从容多了。

5. 踩过的坑与经验教训

5.1 连接池残留会话让锁定形同虚设

我第一次做实例只读锁定时栽过一个不小的跟头。当时改了 postgresql.conf,reload 之后用新开的 psql 验证确实只读了,但业务系统仍然在持续写数据。排查了很久,最终发现问题出在连接池上。

应用通过中间件维护了一批长连接,这些连接是在参数修改之前建立的,已经在会话内确定了读写状态。单靠 reload 配置并不会重新连接或重置会话状态,应用通过这批老连接继续执行写入,锁定形同虚设。

从那以后我总结出一条铁律:改只读参数之后,必须主动处理存量连接,顺序很重要——先评估长事务,再终止连接,最后观察连接池的重建情况。不要反过来,否则老连接的问题会一直悬在那里。

5.2 只读模式下依然"漏网"的操作

只读锁定拦的是"本地表数据的变更",但并不是所有副作用都被拦截。以下几个操作在只读事务里仍然可以执行,容易让人产生疑惑:

  • 会话变量和参数设置:SETSHOWset_config()正常运行;
  • 会话级咨询锁:pg_advisory_lock()系列可以正常获取,因为咨询锁不改变表数据;
  • 通过dblink扩展连接到的远端库:本地事务只读并不会传递到远端连接,远端数据照样可以写;
  • 序列的currval():在会话内曾经调用过nextval()之后可以正常返回,但nextval()本身在只读事务里会报错。

理解这个边界很重要。如果你的业务里有函数通过dblink把数据同步到另一个库,只读锁定并不会阻止它。需要同步锁定目标库,或者在架构设计时,把这种跨库写操作的开关独立出来,单独由运维控制。

5.3 一个把参数设反了的事故复盘

最后分享一个真实的误操作。有次要安排主备切换,原计划是主库在切换前锁只读。结果因为同时在多个终端操作,我不小心在备库上执行了ALTER SYSTEM SET default_transaction_read_only = on

备库本来天然只读,再加这个参数毫无感觉,问题出在切换完成后:备库被提升为新主库,但那个参数还挂在postgresql.auto.conf里,导致切换后新主库一直处于只读状态,应用写不进去,业务直接停摆。

当时的排查过程值得复盘。我第一反应是看SHOW default_transaction_read_only,结果显示on,但潜意识里觉得"刚才不是才提升为主库吗,怎么会是 on"。后来通过pg_settings视图看到source = 'configuration file',再打开postgresql.auto.conf才发现那行设置。删掉并 reload 后恢复正常。

这个事故给我三个教训:第一,跨实例操作前必须用\conninfoSELECT inet_server_addr();确认连接目标;第二,提升主库后的检查清单里,必须包含"确认 default_transaction_read_only 为 off"这一项;第三,从库上没必要设置的参数就不要设置,一个多余的参数会在未来某个时刻咬你一口。

最后整理一个快速定位表,覆盖我在生产环境见过的高频场景:

症状最可能的原因排查动作
所有写入报 read-only transaction连到了从库,或实例级参数被设为 onpg_is_in_recovery()、查pg_settings.source
部分连接可写、部分不可写连接池残留老会话,或角色/数据库级参数pg_stat_activity,查角色和数据库设置
实例重启后写入全部失败磁盘写满或数据目录异常df -h、日志关键词
主备切换后新主库持续只读从库时代遗留的全局参数检查postgresql.auto.conf

写到这里,最想分享的体会是:只读锁定本身并不神秘,它只是一个开关,真正考验人的是它和连接池、从库、磁盘状态、参数层级这些因素交织在一起时的判断力。我现在的习惯是,所有涉及只读锁定的操作都走同一套脚本:先检查节点角色和参数来源,再执行锁定,验证写入失败,然后处理存量连接,最后回归验证。每一步都有输出。如果有一天你再被"数据库写不进去了"的电话叫醒,试着先问对方一句pg_is_in_recovery()的结果——很多问题从这一问开始,就有了答案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询