我第一次把它写进主从切换预案是在一个凌晨的变更窗口里。脚本依次执行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_onlyON时写 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_onlyON对这条业务链路形同虚设。我遇到过不止一次——主从切换时给老主库设了read_only结果业务写入流量还是能进来因为连接池里用的账号是带SUPER权限的。所以生产环境里业务账号绝不应该拥有 SUPER 权限。这条不仅是为了安全也直接影响 read_only 能否起到保护作用。如果希望连SUPER用户也拦下来你需要的是它的兄弟变量super_read_only我放到第 2.4 节单独讲。2.3 第二个被放行的人复制通道的 SQL 线程在从库上开启read_onlyON并不会影响主从复制。复制通道里的 SQL 线程旧称 SQL_THREAD在应用主库传来的 binlog 事件时是不受read_only限制的。这意味着你可以放心地在从库上长期开启read_only主库来的数据照常写入但普通业务账号直接连到从库写数据会被拒绝。这恰恰是从库保护的标准姿势从库常开 read_only不会影响复制但能挡住手工误写和应用串写。有个细节值得注意如果你启用了并行复制、多线程复制read_only同样不会阻塞任何复制工作线程的事件应用。所以不用因为开了只读而担心复制报错。2.4 第三个被放行的人临时表的写入假设你有一个普通账号已经拥有CREATE TEMPORARY TABLES权限。在read_onlyON的情况下它仍然可以创建、写入、删除自己的临时表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_onlyON之后所有“改变数据库状态”的操作都会被拦截。实际上read_only的设计目标非常聚焦它防的是数据变更不是结构变更。对于CREATE TABLE、ALTER TABLE、DROP TABLE这类 DDL如果用户本身具备对应权限在read_onlyON时依然可以执行。从 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_onlyON时包括 SUPER 权限用户在内所有客户端的写入都会被拦截。但它仍然不会拦复制线程。生产环境的推荐组合是SET GLOBAL read_only ON; SET GLOBAL super_read_only ON;先开read_only再开super_read_only。因为super_read_only有个特性当read_onlyOFF时它是无法独立开启的必须保证read_onlyONsuper_read_only才能设置成功。这两者的组合效果我用一张表总结场景read_onlyONread_onlyON super_read_onlyON普通用户写入拦截拦截SUPER 用户写入放行拦截复制线程应用 binlog放行放行临时表写入放行放行如果你在管理一套严格的环境比如容灾备库、分析型从库我建议直接上组合拳彻底堵死“拥有超级权限的账号误写”这条路。3. 我平时会在哪些场景用这条命令3.1 从库和容灾库的日常保护最常见的用法就是在从库上长期开启read_only。很多公司的主从架构里从库承担读流量、报表查询、数据分析。如果从库没有只读保护一个手滑的UPDATE就能把从库数据改歪然后复制链路报错DBA 半夜起来处理。我的标准做法是-- 在从库上执行 SET GLOBAL read_only ON; SET GLOBAL super_read_only ON;这样从库对普通业务账号是只读的但复制线程依然能正常应用主库过来的变更。即使有人直接连到从库执行写操作也会被拒绝而不会污染从库数据。有个运维细节要注意很多初始化脚本、配置管理工具可能带着read_onlyOFF的默认配置下发。比如你用某些自动化平台批量管理数据库实例时它可能每次上线都会把配置文件和全局变量同步一遍。如果某个从库被重新拉起来配置里没带read_only它就会悄悄变回可写状态。所以在自动化平台的实例配置模板里要把read_only和super_read_only一起钉死。3.2 主从切换时的“停车挡”在做主从切换时老主库需要先停止写入确认没有新事务进来再把流量切到新主库。这时候read_only就是一个非常理想的“停车挡”。常规流程是在老主库上设置read_onlyON和super_read_onlyON。等待当前未提交的事务完成并确认复制位点不再推进。把应用流量切到新主库。确认新主库写入正常之后把老主库降级为从库重新挂到新主库下面。在这个流程中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_onlyOFF结果数据库以只读状态运行了几个小时应用侧开始报写失败才被监控发现。建议在变更脚本里加上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_onlyON之前已经开始并且事务内已经执行了写语句但还没提交事务可以正常 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要区分开。如果应用本身配置了readOnlytrue的数据库连接驱动会在本地拦截写语句根本不会发送到 MySQL而read_onlyON的拦截发生在 MySQL 服务端两者的排查路径完全不同。4.4 持久化与重启问题SET GLOBAL是动态变量重启后失效。如果你希望实例一启动就处于只读状态有两个办法在 my.cnf / my.ini 的[mysqld]段写入read_only1和super_read_only1。使用 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_only1而漏掉super_read_only1。有些团队的从库模板只配置了前者结果管理员账号还能写入被保护对象打了个大半折扣。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 TABLESDDL 仍能执行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 导致切换失败也经历过因为没处理长事务导致切换后出现额外写入。这些坑一次两次踩下来你就知道这套“庖丁解牛”式的理解有多重要了。最后分享一个小习惯每次变更结束后我会把执行过的语句、验证结果、当时的复制位点、活跃事务情况全部贴进变更记录。几周后如果有人问我“当时为什么这么切”我可以直接翻出完整上下文而不是靠模糊的记忆。数据库运维这件事细心比聪明值钱得多。