我第一次把它写进主从切换预案,是在一个凌晨的变更窗口里。脚本依次执行SET GLOBAL read_only = ON;、检查复制状态、然后把流量切到新主节点。当时根本没多想——就五个单词的 SQL,能有什么花头?直到第二天业务方拿着截图来找我:“我们连的明明是个只读副本,为什么INSERT偶尔还能成功?”
这句话把我问住了。回去翻文档、看源码、做本地实验,才发现SET GLOBAL read_only = ON;背后牵扯的远不止一个开关:权限边界、事务行为、复制通道、DDL 与 DML 的差异、持久化机制,全部挤在这条简短的语句里。哪怕你是天天跟 MySQL 打交道的 DBA,也未必每条边界都摸过。
这篇就按“庖丁解牛”的思路,把这条命令从头到尾拆一遍。如果你是 MySQL 运维、后端开发,或者刚接手一套不太熟悉的主从架构,这篇适合当操作手册来读。我尽量把原理和实操串在一起讲,避免“知道命令但不知道后果”的状态。
1. 先把命令的骨架拆开看
1.1 语法:同一条命令,三种写法
SET GLOBAL read_only = ON;在 MySQL 里至少有三种等价写法:
-- 最常见的写法 SET GLOBAL read_only = ON; -- 使用 @@ 前缀 SET @@GLOBAL.read_only = 1; -- 数字写法,1 等同于 ON SET GLOBAL read_only = 1;我个人习惯用ON/OFF,因为语义更明确,读脚本时不容易误判。1/0的写法在职场的自动化脚本里很常见,两者完全等价,没有性能差异。
执行环境是 MySQL 5.7 或 8.0 均可,我的验证环境是 MySQL 8.0.32。这条命令是动态变量,不需要重启实例就能生效,但要注意:它默认不会自动写入配置文件。如果实例重启,这个设置会丢失。后面我会专门讲持久化的问题。
1.2 权限:不是谁都能拨这个开关
在执行SET GLOBAL read_only = ON;时,MySQL 会先做权限校验。在 MySQL 8.0 中,要求当前用户具备SYSTEM_VARIABLES_ADMIN权限;在 MySQL 5.7 以及更早版本中,对应的是SUPER权限。简单说,日常用的 root 账户、具备管理员权限的 DBA 账户,通常可以直接执行。
有个容易踩的坑:如果你用普通开发账号执行,会直接报权限错误:
ERROR 1227 (42000): Access denied; you need (at least one of) the SYSTEM_VARIABLES_ADMIN or SUPER privilege(s) for this operation很多团队的自动化平台会用一个“专用管理账号”去执行这类变更,如果这个账号权限没有给到SYSTEM_VARIABLES_ADMIN,变更任务就会静静失败。我见过不止一次团队抱怨“怎么开关没生效”,最后发现是权限不足,根本没执行成功。所以在搭建自动化流程时,第一件事就是确认执行账号的权限矩阵。
1.3 “GLOBAL”这个关键字意味着什么
很多系统变量同时有全局值和会话值。比如sql_mode,你用SET GLOBAL改,只影响之后新建的连接;老连接继续沿用自己会话里已有的值。这好理解,因为大多数变量在连接建立时会把全局值“拷贝”一份到会话里。
但read_only不太一样。它不是一个在连接建立时就固定下来的会话快照,而是一个全局状态。当你执行SET GLOBAL read_only = ON;的那一刻,服务器就会在后续每个数据变更语句的执行路径上检查这个全局值。换句话说,它对当前已经建立的连接同样立刻生效——会话不需要重连,下一个INSERT/UPDATE/DELETE就会被拦下来。
这个细节很多人都会搞错。我曾经看到一个排查文档里写着“改完 read_only 后需要等存量连接断开才生效”,其实是错的。真实行为是:存量连接的下一个写语句会立即失败,除非这个连接后续没有再发写语句,否则不受影响。
2. read_only 到底拦住了什么、放过了什么
2.1 核心拦截逻辑:目标是 DML
官方文档对read_only的定义很明确:当它设置为ON时,服务器会禁止客户端对非临时表执行更新操作,包括INSERT、UPDATE、DELETE这类 DML 语句。这句话听起来简单,但边界非常多。
我列过一张图来帮助理解,按“用户类型”和“语句类型”两个维度去判断。普通业务账号在read_only=ON时,写 DML 会失败;但下面的几类情况会被放行。
我先用表格给你一个全局视图,后面再逐个拆解:
| 操作类型 | 普通用户 | SUPER/SYSTEM_VARIABLES_ADMIN 用户 | 复制线程 |
|---|---|---|---|
| 非临时表的 INSERT/UPDATE/DELETE | 被拦截 | 放行 | 放行 |
| 临时表的写入(有权限时) | 放行 | 放行 | 放行 |
| CREATE/ALTER/DROP 等 DDL | 不被 read_only 拦截,受权限系统约束 | 放行 | 放行 |
| 已有事务的 COMMIT | 可提交 | 可提交 | 放行 |
2.2 第一个被放行的人:SUPER 权限用户
read_only的名字听起来像“只读”,但它本质上是一道给“普通用户”设置的软墙。拥有SUPER或SYSTEM_VARIABLES_ADMIN权限的管理员账号,仍然可以正常执行写入操作。
这带来一个非常实际的隐患:如果你的业务中间件连接 MySQL 时使用的是 root 或具备管理权限的账号,那read_only=ON对这条业务链路形同虚设。我遇到过不止一次——主从切换时给老主库设了read_only,结果业务写入流量还是能进来,因为连接池里用的账号是带SUPER权限的。所以生产环境里,业务账号绝不应该拥有 SUPER 权限。这条不仅是为了安全,也直接影响 read_only 能否起到保护作用。
如果希望连SUPER用户也拦下来,你需要的是它的兄弟变量super_read_only,我放到第 2.4 节单独讲。
2.3 第二个被放行的人:复制通道的 SQL 线程
在从库上开启read_only=ON,并不会影响主从复制。复制通道里的 SQL 线程(旧称 SQL_THREAD)在应用主库传来的 binlog 事件时,是不受read_only限制的。这意味着你可以放心地在从库上长期开启read_only,主库来的数据照常写入,但普通业务账号直接连到从库写数据会被拒绝。
这恰恰是从库保护的标准姿势:从库常开 read_only,不会影响复制,但能挡住手工误写和应用串写。
有个细节值得注意:如果你启用了并行复制、多线程复制,read_only同样不会阻塞任何复制工作线程的事件应用。所以不用因为开了只读而担心复制报错。
2.4 第三个被放行的人:临时表的写入
假设你有一个普通账号,已经拥有CREATE TEMPORARY TABLES权限。在read_only=ON的情况下,它仍然可以创建、写入、删除自己的临时表:
CREATE TEMPORARY TABLE tmp_user (id INT PRIMARY KEY); INSERT INTO tmp_user VALUES (1); -- 这条语句不受 read_only 拦截 UPDATE tmp_user SET id = 2;这背后的逻辑是:临时表本身就是会话级的私有数据,它不会影响全库的一致性,所以 MySQL 将临时表的写操作画在了拦截范围之外。我最初知道这个行为时也愣了一下,后来想想也合理——很多存储过程、复杂查询都依赖临时表做中间计算,如果临时表写入也被拦截,业务影响面就太大了。
但要注意,这条“放行”的前提是用户拥有CREATE TEMPORARY TABLES权限。如果账号没有这个权限,临时表照样建不出来。
2.5 DDL 并不归 read_only 管
这是另一个常见的误区。很多人以为read_only=ON之后,所有“改变数据库状态”的操作都会被拦截。实际上,read_only的设计目标非常聚焦:它防的是数据变更,不是结构变更。对于CREATE TABLE、ALTER TABLE、DROP TABLE这类 DDL,如果用户本身具备对应权限,在read_only=ON时依然可以执行。
从 MySQL 官方视角看,read_only 的拦截逻辑主要落在 DML 上,DDL 交给权限系统去管。所以如果你指望用 read_only 挡住某个权限过大的账号去 DROP 表,那是一厢情愿。真正防 DDL 必须靠收紧账号权限,或者使用更严格的审计和风控机制。
我曾在一次维护中亲眼看到同事设了read_only后,随手DROP TABLE删掉了一张测试表,当时大家都以为只读状态下不可能删成功。教训就是:不要把 read_only 当止痛药,它只解决部分问题,权限设计才是基础。
2.6 read_only 与 super_read_only 的分工
MySQL 5.7.8 开始引入了super_read_only。它和read_only的关系可以理解成“加强版”:当super_read_only=ON时,包括 SUPER 权限用户在内,所有客户端的写入都会被拦截。但它仍然不会拦复制线程。
生产环境的推荐组合是:
SET GLOBAL read_only = ON; SET GLOBAL super_read_only = ON;先开read_only,再开super_read_only。因为super_read_only有个特性:当read_only=OFF时,它是无法独立开启的;必须保证read_only=ON,super_read_only才能设置成功。
这两者的组合效果,我用一张表总结:
| 场景 | read_only=ON | read_only=ON + super_read_only=ON |
|---|---|---|
| 普通用户写入 | 拦截 | 拦截 |
| SUPER 用户写入 | 放行 | 拦截 |
| 复制线程应用 binlog | 放行 | 放行 |
| 临时表写入 | 放行 | 放行 |
如果你在管理一套严格的环境,比如容灾备库、分析型从库,我建议直接上组合拳,彻底堵死“拥有超级权限的账号误写”这条路。
3. 我平时会在哪些场景用这条命令
3.1 从库和容灾库的日常保护
最常见的用法就是在从库上长期开启read_only。很多公司的主从架构里,从库承担读流量、报表查询、数据分析。如果从库没有只读保护,一个手滑的UPDATE就能把从库数据改歪,然后复制链路报错,DBA 半夜起来处理。
我的标准做法是:
-- 在从库上执行 SET GLOBAL read_only = ON; SET GLOBAL super_read_only = ON;这样从库对普通业务账号是只读的,但复制线程依然能正常应用主库过来的变更。即使有人直接连到从库执行写操作,也会被拒绝,而不会污染从库数据。
有个运维细节要注意:很多初始化脚本、配置管理工具可能带着read_only=OFF的默认配置下发。比如你用某些自动化平台批量管理数据库实例时,它可能每次上线都会把配置文件和全局变量同步一遍。如果某个从库被重新拉起来,配置里没带read_only,它就会悄悄变回可写状态。所以在自动化平台的实例配置模板里,要把read_only和super_read_only一起钉死。
3.2 主从切换时的“停车挡”
在做主从切换时,老主库需要先停止写入,确认没有新事务进来,再把流量切到新主库。这时候read_only就是一个非常理想的“停车挡”。
常规流程是:
- 在老主库上设置
read_only=ON和super_read_only=ON。 - 等待当前未提交的事务完成,并确认复制位点不再推进。
- 把应用流量切到新主库。
- 确认新主库写入正常之后,把老主库降级为从库,重新挂到新主库下面。
在这个流程中,read_only能拦住来自应用侧的绝大部分写入。但仍然要提醒:如果应用账号具备SUPER权限,它管不住。最好的做法是切换前先和应用团队确认连接账号,并检查是否存在长事务。我一般会执行以下检查:
-- 查看当前有哪些正在执行的事务 SELECT * FROM information_schema.innodb_trx; -- 查看哪些线程在运行 DML SELECT id, user, host, db, command, time, state, info FROM information_schema.processlist WHERE command <> 'Sleep';如果发现有非管理员账号的长事务还在跑,可以先等它结束,或者评估是否需要 kill。踩过坑的朋友应该知道:直接设 read_only 不会自动中断已经开启的写事务,它只拦截“新的写语句”。这个“事务窗口”问题我单独在 4.2 节展开。
3.3 备份与数据校验的只读窗口
有些逻辑备份工具,比如老牌mysqldump的--single-transaction模式,并不能保证所有引擎的数据完全一致,尤其当库里混着 MyISAM 表时。为了得到一个稳定的快照,运维有时会先开启read_only,再执行备份。
这种场景下,read_only起到的作用是防止备份过程中普通业务写入改变数据状态。备份完成后,再把它关掉:
SET GLOBAL read_only = OFF; SET GLOBAL super_read_only = OFF;这里有一个容易忽略的操作顺序:备份窗口结束后,一定要恢复原状。有些团队的变更脚本会在执行结束时漏了兜底的read_only=OFF,结果数据库以只读状态运行了几个小时,应用侧开始报写失败,才被监控发现。建议在变更脚本里加上trap或者defer,确保无论脚本正常结束还是报错退出,都会执行恢复逻辑。
3.4 配合 SQL 限流与故障止血
当线上出现异常写入,比如死循环 UPDATE、误上线了糟糕的数据修复脚本,DBA 的紧急止血手段之一就是先开read_only。它能以最快速度阻止所有普通账号的写入,给排查争取时间。
但止血不能只靠 read_only。因为如果异常写入来自具备SUPER权限的账号,它依然能穿透。更稳妥的止血组合是先开super_read_only,再查 processlist,定位到具体会话后精准 kill。我见过一些团队在故障复盘时只写了“开启只读止血”,没有意识到 SUPER 账号仍然能写,导致第二次止血失败。所以止血预案里,read_only和super_read_only永远是成对出现。
4. 实操细节、坑与排查清单
4.1 验证是否生效
设置完成后,至少用两种方式确认:
-- 方法一:SHOW VARIABLES SHOW VARIABLES LIKE 'read_only'; SHOW VARIABLES LIKE 'super_read_only'; -- 方法二:SELECT @@GLOBAL SELECT @@GLOBAL.read_only, @@GLOBAL.super_read_only;如果你的账号没有足够权限,可以通过performance_schema查看:
SELECT variable_name, variable_value FROM performance_schema.global_variables WHERE variable_name IN ('read_only', 'super_read_only');我见过一个低级错误:有人把SET GLOBAL read_only = ON;写在了某个存储过程或脚本里,但连接的会话开启了事务,导致设置虽然执行成功,后续逻辑却因为事务隔离级别看不到预期的系统变量值。严格来说这不是 read_only 本身的问题,而是对“系统变量读取时机”理解不到位。所以确认时要单独开一个干净会话执行SELECT @@GLOBAL.read_only,不要复用正在事务中的连接。
4.2 正在运行的长事务会怎样
这是大家问得最多的一个问题:我执行了SET GLOBAL read_only = ON;,但库里还有一个未提交的写事务,它会被强制回滚吗?
答案是不会。read_only只拦截“下一个新写语句”的执行,它不会去中止已经开启的事务。具体来说:
- 如果一个事务在
read_only=ON之前已经开始,并且事务内已经执行了写语句但还没提交,事务可以正常 COMMIT,写结果也会落盘。 - 但同一个事务里如果再执行新的
INSERT/UPDATE/DELETE,就会立即失败,报 1290 错误。
这个“事务窗口”会造成一个很隐蔽的现象:你明明开启了只读,提交之后却发现在切换前的几秒钟,依然有少量数据写进了老主库。这不是 read_only 失效,而是你设置只读的瞬间,已经有一个执行到一半的事务正在收尾。
我在做主从切换时会专门留一点时间,设置 read_only 后等待几秒,然后检查information_schema.innodb_trx里没有活跃的写事务,再做下一步。有时候还需要配合KILL掉一些长时间持有的空闲连接,因为它们虽然不写,但可能持有表锁,影响切换后的业务。
4.3 报错信息长什么样
当普通用户试图在只读模式下写入时,错误信息如下:
ERROR 1290 (HY000): The MySQL server is running with the --read-only option so it cannot execute this statement注意,这里错误信息里写的是--read-only option,因为这条命令最早是作为 mysqld 的启动参数存在的。即使你是用SET GLOBAL临时开启的,报错文案依然沿用启动参数的说法。很多开发同学看到 “--read-only option” 会以为 DBA 改了启动配置,其实这只是历史遗留的文案。
应用层的表现通常是:连接池里的连接忽然开始抛SQLException,提示Cannot execute statement in a read-only transaction。注意,这句话有时是驱动层的报错,有时是服务端返回的 1290,要区分开。如果应用本身配置了readOnly=true的数据库连接,驱动会在本地拦截写语句,根本不会发送到 MySQL;而read_only=ON的拦截发生在 MySQL 服务端,两者的排查路径完全不同。
4.4 持久化与重启问题
SET GLOBAL是动态变量,重启后失效。如果你希望实例一启动就处于只读状态,有两个办法:
- 在 my.cnf / my.ini 的
[mysqld]段写入read_only=1和super_read_only=1。 - 使用 MySQL 8.0 的
SET PERSIST语法。
SET PERSIST的写法是:
SET PERSIST read_only = ON; SET PERSIST super_read_only = ON;它会将设置写入数据目录下的mysqld-auto.cnf文件,实例重启后自动加载。但我在实际使用中会额外注意一个坑:SET PERSIST在部分 MySQL 版本上,对于 read_only 这类变量的持久化行为会有细微差异。稳妥的做法是两条命令分开执行,先SET PERSIST保证重启后生效,再SET GLOBAL保证当前实例立即生效。如果你只用SET PERSIST_ONLY,那它只会写入配置文件,当前实例不会变化。
还有一个经验:不要在配置文件里只写read_only=1而漏掉super_read_only=1。有些团队的从库模板只配置了前者,结果管理员账号还能写入,被保护对象打了个大半折扣。
4.5 容易忽略的“隐藏通道”
read_only的拦截逻辑并不能覆盖所有写入路径。除了前面提到的 SUPER 用户、复制线程、临时表、DDL 之外,以下几个通道也值得留意:
- 使用
CREATE TABLE ... AS SELECT创建新表并写入数据时,它既涉及 DDL 又涉及 DML。作为 DDL,它可能不被 read_only 拦截,但实际会写入数据。具体行为在不同版本、不同存储引擎下可能有差异,需要在预发布环境实测。 - 通过存储过程、触发器、事件调度器触发的写入,取决于执行者身份。如果存储过程定义者是具备 SUPER 权限的管理账号,那么普通用户调用它时,写入依然可能成功。
- 一些特殊语句,比如
ANALYZE TABLE、OPTIMIZE TABLE,在只读模式下也不会被完全拦截,因为它们属于维护操作,MySQL 默认放行。
这些“隐藏通道”并不是 read_only 设计上的漏洞,而是它的设计边界。它本来就是一道软性保护,不是坚不可摧的墙。如果你需要绝对可靠的结构性保护,还是要回到账号权限、网络隔离和数据安全策略上。
5. 常见错误速查和最后一点经验
5.1 典型错误与排查路径
我整理了实际运维中比较容易遇到的几种情况:
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 执行 SET GLOBAL 报权限错误 | 当前账号没有 SYSTEM_VARIABLES_ADMIN 或 SUPER 权限 | 使用具备管理权限的账号,或在权限矩阵中补充对应权限 |
| 设置了 read_only 但业务还能写 | 业务连接账号具备 SUPER 权限 | 检查应用账号权限,收紧 SUPER,并开启 super_read_only |
| 从库开了 read_only 但复制报错 | 复制线程不受 read_only 影响,报错通常与其他原因有关 | 单独查看复制状态,确认 SQL 线程和应用错误 |
| 重启后只读失效 | 只用了 SET GLOBAL,未持久化 | 改用 SET PERSIST 或写入配置文件 |
| 临时表还能写 | 属于正常行为,read_only 不拦截临时表 | 若业务依赖临时表,不需要担心;若需要严格拦截,要从权限层面控制 CREATE TEMPORARY TABLES |
| DDL 仍能执行 | read_only 不拦截 DDL | 收紧账号 DDL 权限,必要时启用审计 |
5.2 我的检查清单
执行 read_only 相关变更前,我习惯按这个清单过一遍:
- 确认执行账号具备管理权限,但业务账号绝对没有 SUPER 权限。
- 查看当前活跃事务和长事务,预留“事务窗口”的收尾时间。
- 同时设置 read_only 和 super_read_only,不要只开前者。
- 在独立会话中验证
SELECT @@GLOBAL.read_only和@@GLOBAL.super_read_only。 - 用普通业务账号测试一次写入,确认确实返回 1290。
- 如果变更脚本会自动或手动关闭只读,确认恢复逻辑有兜底。
这套清单看起来繁琐,但每次变更前花两分钟跑一遍,能避免后半夜的紧急电话。
5.3 最后几句体己话
如果你问我个人在实际操作中的体会是什么,我会说:SET GLOBAL read_only = ON;是一把非常方便但不算完美的软锁。它的价值在于快速、可逆、覆盖面广,但它永远替代不了合理的账号权限设计和操作规范。我经历过因为只开 read_only 没开 super_read_only 导致切换失败,也经历过因为没处理长事务导致切换后出现额外写入。这些坑一次两次踩下来,你就知道这套“庖丁解牛”式的理解有多重要了。
最后分享一个小习惯:每次变更结束后,我会把执行过的语句、验证结果、当时的复制位点、活跃事务情况全部贴进变更记录。几周后如果有人问我“当时为什么这么切”,我可以直接翻出完整上下文,而不是靠模糊的记忆。数据库运维这件事,细心比聪明值钱得多。