TiDB 受限只读模式(Restricted Read Only)端到端测试全解析:readonlytest 环境搭建、用例剖析与实现原理
【免费下载链接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.项目地址: https://gitcode.com/GitHub_Trending/ti/tidb
TiDB 的tidb_restricted_read_only全局变量可以让整个集群"最终进入只读状态",连 SUPER 权限用户也无法写入,是云上运维、灾备切换、数据迁移等场景下的关键安全开关。本指南以仓库中 tests/readonlytest/README.md 描述的端到端(E2E)测试为主线,完整讲解如何手动搭建双 TiDB 实例测试环境、运行go test验证只读行为,并深入剖析测试背后的源码实现(变量定义、规划期拦截、提交期二次校验)与权限模型,帮助你既会跑测试,也能看懂 TiDB 只读模式的底层原理。
一、测试背景:TiDB 的受限只读模式是什么
在理解测试之前,先明确被测对象。TiDB 提供了两个与只读相关的全局系统变量(定义于 pkg/sessionctx/vardef/tidb_vars.go):
tidb_restricted_read_only:TiDB 特有的受限只读开关,一旦开启,集群对所有用户(包括拥有 SUPER 或 CONNECTION_ADMIN 权限的用户)最终进入只读状态,只有被显式授予RESTRICTED_REPLICA_WRITER_ADMIN动态权限的账号可以继续写入;tidb_super_read_only:TiDB 对 MySQLsuper_read_only的变体实现,与 MySQL 版本存在差异,只读检查同样作用于普通 DML,但权限门槛相对更低。
根据仓库中的设计文档 docs/design/2021-06-23-restricted-read-only.md,tidb_restricted_read_only的设计动机是:TiDB 原本的read_only/super_read_only只是"名义存在"而未真正生效;而 MySQL 在开启只读时可能因存在表锁或正在提交的事务而失败或阻塞,TiDB 的受限只读不构建在这两个变量之上,而是独立实现"最终只读"语义:开启操作立即返回成功,随后通过 PD 将变更广播到集群内所有 TiDB 节点。
两个变量在 pkg/sessionctx/variable/sysvar.go 中注册,默认值均为OFF(见 pkg/sessionctx/vardef/tidb_vars.go),并各自维护一个进程内原子布尔值(tidb_vars.go)供读写路径即时读取。
而 tests/readonlytest 正是这套机制的唯一 E2E 验证阵地——它不依赖 mock,而是连接真实 TiDB 集群,用真实 SQL 逐条验证只读模式下的行为矩阵。
二、测试总体设计:三个账号、两台 TiDB
目录结构
tests/readonlytest/ ├── BUILD.bazel # Bazel 构建目标 ├── README.md # 环境搭建与运行说明 ├── main_test.go # TestMain:测试框架初始化与 goroutine 泄漏检查 └── readonly_test.go # 核心测试:4 个测试函数三个连接角色
测试通过三种身份建立连接(见 readonly_test.go 的createReadOnlySuite),模拟真实运维场景中的三类人:
| 角色 | 连接对象 | 权限 | 在测试中的职责 |
|---|---|---|---|
root(s.db) | 127.0.0.1:4001 | 超级用户 | 控制端:执行SET GLOBAL开关只读、创建测试用户、执行授权 |
u1(s.udb) | 127.0.0.1:4002 | 仅test.*库上全部权限 | 普通业务账号,验证只读模式下被拦截 |
r1(s.rdb) | 127.0.0.1:4002 | test.*全部权限 + 全局RESTRICTED_REPLICA_WRITER_ADMIN | 模拟复制写入者,验证可以绕过只读限制 |
两个普通账号均由 root 在测试启动时动态创建:
-- u1:普通用户 create user 'u1'@'%' identified by 'password'; grant all privileges on test.* to 'u1'@'%'; -- r1:复制写入者(关键差异在于多了一行全局动态权限) create user 'r1'@'%' identified by 'password'; grant all privileges on test.* to 'r1'@'%'; grant RESTRICTED_REPLICA_WRITER_ADMIN on *.* to 'r1'@'%';为什么需要两台 TiDB 实例
这是本测试最容易被忽略的设计点:tidb_restricted_read_only是全局变量,变更通过 PD 广播到所有 TiDB 节点,具有"最终一致"特性(极端情况下,如节点与 PD 失联,延迟可达约 30 秒,见设计文档)。用 root 在 4001 上执行SET GLOBAL后,需要在另一台4002 上的会话中验证该变更是否被广播生效,才能真正证明"整集群只读"而非"单节点只读"。这正是 readonly_test.go 中tidb_a_port(默认 4001)与tidb_b_port(默认 4002)两个参数存在的原因。
三、环境搭建:手动部署双 TiDB 实例集群
测试目前未接入自动化流水线(README 明确说明 "The test is not yet automated"),需要手动准备环境。准备步骤:
- 在本机(localhost)部署一个完整的 TiDB 集群(TiKV + PD + 2 个 TiDB Server),2 个 TiDB Server 端口分别为4001和4002。读者可以参照仓库 README.md 或使用本地已有的 TiUP 等方式部署多实例;当前仓库未提供针对该测试的启动脚本,故手动配置。
- 确保 root 账号可登录(默认无密码),测试会自动完成建库用户、授权、建表等准备工作,无需手工初始化
test库内容。
由于测试内部已通过 root 自动创建u1、r1账号并完成授权,手动阶段只需保证:两台 TiDB 可达、root 可登录、端口与默认值一致(或通过 flag 覆盖)。
四、运行测试
在 tests/readonlytest 目录下直接执行:
go test预期输出(README 记录的示例):
$ go test OK: 2 passed PASS ok github.com/pingcap/tidb/tests/readonlytest 2.150s需要说明的是:README 编写时期记录的期望是 2 个测试通过;而当前仓库的 readonly_test.go 已扩展为4 个测试函数(TestRestriction、TestRestrictionWithConnectionPool、TestReplicationWriter、TestInternalSQL),其中TestInternalSQL使用testkit.CreateMockStore的内存 mock 存储,其余 3 个依赖真实集群。实际运行时应以本仓库代码为准。
常用命令行参数
测试通过 Go 标准库flag暴露了三个可调参数(readonly_test.go):
| Flag | 默认值 | 含义 |
|---|---|---|
-passwd | ""(空) | TiDB root 密码 |
-tidb_a_port | 4001 | 第一台(控制端)TiDB 监听端口 |
-tidb_b_port | 4002 | 第二台(被测端)TiDB 监听端口 |
示例(若 root 有密码或端口不同):
go test -args -passwd='your-root-passwd' -tidb_a_port=4001 -tidb_b_port=4002Bazel 方式
仓库为测试声明了 Bazel 目标 tests/readonlytest/BUILD.bazel:go_test名为readonlytest_test,timeout = "short"且flaky = True(标记为偶发不稳定),依赖pkg/kv、pkg/testkit、pkg/testkit/testsetup等内部包以及 testify 等外部依赖。在 Bazel 工作区内可用对应 target 运行,但 README 记录的官方执行方式仍是go test。
五、测试用例深度解析
5.1 TestRestriction:核心只读行为矩阵
这是最核心的用例(readonly_test.go),完整覆盖"开启 → 拦截 → 关闭"的闭环:
开启前基线:普通用户u1可以正常create table、insert、update。
开启后(root 执行set global tidb_restricted_read_only=1),依次验证:
- 变量广播生效:在 4002 的
u1、r1会话中查询,tidb_restricted_read_only与tidb_super_read_only均为ON——印证"开启 restricted 会联动开启 super"的源码行为(sysvar.go,SetGlobal中同时写入TiDBSuperReadOnly); - DDL 被拒:
create table t(a int)报Error 1836: Running in read-only mode; - 点更新被拒:
update t set b=2 where a=1同样报 1836; - 插入被拒:
insert into t values (2, 3)同样报 1836; - 禁止降级 super:在 restricted 开启时尝试
set global tidb_super_read_only=0,报Error 1105: can't turn off tidb_super_read_only when tidb_restricted_read_only is on——这是 sysvar.go 中Validation逻辑直接产生的冲突错误; - 普通账号无权改全局变量:
u1、r1尝试关闭 super 时均报Error 1227: Access denied; you need (at least one of) the SUPER or SYSTEM_VARIABLES_ADMIN privilege(s) for this operation——修改该全局变量要求SUPER或SYSTEM_VARIABLES_ADMIN权限; - FLASHBACK CLUSTER 被拒:
flashback cluster to timestamp ''报 1836——回滚类高危操作同样纳入只读管控; - 管理类语句放行:
admin show ddl jobs、admin show slow recent 1执行成功——只读模式仍然允许管理诊断查询; - 关闭 restricted 不会自动关闭 super:root 执行
set global tidb_restricted_read_only=0后,两个节点上 restricted 变为OFF,但tidb_super_read_only仍为ON; - 显式关闭 super:此时 root 再执行
set global tidb_super_read_only=0才成功,集群完全恢复可写。
这个用例是对只读语义最完整的"验收清单",后续 3 个用例则从不同维度补充边界。
5.2 TestRestrictionWithConnectionPool:连接池场景下的延迟拦截
真实业务通常通过连接池访问 TiDB,连接池会复用长连接。该用例(readonly_test.go)用s.udb.Conn()取出一条复用连接,后台协程每 50ms 执行一次insert,主协程等待 1 秒后开启 restricted 只读,随后断言:插入操作在 10 秒内必然命中Error 1836。
该用例验证了两个重要事实:
- 只读检查发生在语句执行层面(而非连接建立层面),因此已复用的长连接同样会被拦截;
- 开启只读后,新提交的写操作会稳定、快速地失败,不会出现"漏网"写入。
5.3 TestReplicationWriter:复制写入者豁免
只读模式必须给数据同步留一条"活路",这就是RESTRICTED_REPLICA_WRITER_ADMIN的意义。该用例(readonly_test.go)让拥有该权限的r1账号通过连接池后台持续insert,然后 root 开启 restricted 只读并验证:
- SUPER 用户 root 自己执行
insert被拒(报 1836)——证明 restricted 只读连 SUPER 都不放过; r1的持续写入全程无报错——拥有RESTRICTED_REPLICA_WRITER_ADMIN的复制写入者不受只读影响。
设计文档特别强调:该权限检查即使在 SEM(安全增强模式)未开启时也强制生效,即 SUPER 用户也必须被显式授予该动态权限才能绕过只读,不会因 SEM 关闭而自动继承(对应 pkg/planner/optimize.go 中使用HasExplicitlyGrantedDynamicPrivilege的原因——它明确排除隐式权限继承)。
5.4 TestInternalSQL:内部 SQL 豁免
TiDB 自身的后台任务(如统计信息更新)不能因为集群只读而瘫痪。该用例(readonly_test.go)用kv.WithInternalSourceType构造内部执行上下文,在tidb_restricted_read_only与tidb_super_read_only均为ON时,通过ExecuteInternal直接向mysql.stats_top_n系统表写入数据,断言不报错。
源码侧对应 pkg/planner/optimize.go 与 pkg/session/session.go 中共同的守卫条件:!sessVars.InRestrictedSQL && (RestrictedReadOnly || VarTiDBSuperReadOnly)——内部 SQL(InRestrictedSQL=true)直接豁免只读检查,这正是测试用例成立的机制。
六、错误消息与错误码速查
测试文件顶部集中定义了三条关键错误消息常量(readonly_test.go),可直接作为运维排障时的对照表:
| 常量 | 错误文本 | 触发场景 | 对应错误码 |
|---|---|---|---|
ReadOnlyErrMsg | Error 1836: Running in read-only mode | 只读模式下执行 DDL/DML/Flashback 等写操作 | 1836(pkg/errno/errcode.go),消息定义于 pkg/parser/mysql/errname.go |
ConflictErrMsg | Error 1105: can't turn off tidb_super_read_only when tidb_restricted_read_only is on | restricted 开启时尝试单独关闭 super | 1105(通用错误) |
PriviledgedErrMsg | Error 1227: Access denied; you need (at least one of) the SUPER or SYSTEM_VARIABLES_ADMIN privilege(s) for this operation | 无 SUPER/SYSTEM_VARIABLES_ADMIN 权限时修改全局变量 | 1227(访问拒绝) |
七、底层实现原理:只读检查的两道关卡
结合测试行为反推源码,TiDB 的只读限制在规划期和提交期各设一道关卡。
第一道关卡:规划期白名单(Optimize 阶段)
每个 SQL 在生成执行计划时,若集群处于只读状态,会调用 allowInReadOnlyMode(入口见 optimize.go)判断是否放行。判断逻辑依次为:
- 特权旁路:会话拥有显式授予的
RESTRICTED_REPLICA_WRITER_ADMIN动态权限 → 直接放行(复制写入者); - 语句白名单:以下语句类型直接放行,确保只读模式"可进可出":
SET语句(否则无法关闭只读开关)ANALYZE TABLE(统计信息维护)USE、SHOW(查询与诊断)CREATE/DROP BINDING(SQL 绑定管理)PREPARE、BEGIN、ROLLBACKCOMMIT:仅当当前事务为只读事务时放行,否则直接回滚
- 兜底判定:其余语句交由
core.IsReadOnlyInternal判断是否为只读查询(覆盖explain、prepare/execute等场景)。
第二道关卡:提交期二次校验(doCommit)
规划期检查无法覆盖一种竞态:一条长事务在只读开启前已经规划并执行写操作,只读开启后才尝试提交。因此 pkg/session/session.go 的doCommit在真正提交前会再次检查:若集群处于只读状态且会话不是内部 SQL、也未显式持有RESTRICTED_REPLICA_WRITER_ADMIN,则回滚事务并返回ErrSQLInReadOnlyMode(即错误码 1836)。测试中的TestReplicationWriter之所以断言 SUPER 用户写入失败,正是由这道提交期检查兜底。
最终一致与生效边界
最后需要强调两个边界(来自设计文档与源码,均为仓库事实):
SET GLOBAL开启后立即返回成功,但整集群生效是最终的:变更通过 PD 广播,正常情况下各节点很快收到;节点与 PD 失联等异常情况下,生效延迟最坏可达约 30 秒,期间个别节点可能仍短暂可写;- 只读检查对内部 SQL(
InRestrictedSQL会话)一律豁免,这是统计信息、GC 等后台任务能在只读集群中正常运转的前提。
八、小结
tests/readonlytest是理解 TiDB 受限只读模式的最佳入口:它用真实双节点集群验证了"最终只读"语义、tidb_super_read_only联动与互斥规则、RESTRICTED_REPLICA_WRITER_ADMIN特权旁路、连接池/内部 SQL 等边界行为。若你需要在生产环境实施"集群级只读"(例如迁移前的写保护),可直接复用本文的验证矩阵:先set global tidb_restricted_read_only=1并确认所有节点tidb_super_read_only联动为ON,再以业务账号验证写入报 1836、以复制账号验证写入正常,最后按相反顺序(先关 super 再关 restricted)恢复。
想要进一步深入,可以依次阅读:readonly_test.go、设计文档、变量注册与联动逻辑、规划期白名单 与 提交期校验。
【免费下载链接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.项目地址: https://gitcode.com/GitHub_Trending/ti/tidb
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考