杀掉 MySQL 主库后的两秒:ProxySQL + Orchestrator 异步复制故障切换实验
项目地址:,实验目录 20261019-mysql-async-replication-ha。
环境:MySQL 8.4.11、ProxySQL 2.7.3、Percona Orchestrator 3.2.6、Docker Engine 29.1.3 / Compose 2.40.3,跑在 Ubuntu 24.04.5 上。
这是给用过 MySQL、但没真正跑过一次故障切换的人做的 Lab。这里的“高可用”具体指两件事:应用始终只连一个地址;正在接收写入的服务器死掉之后,写入能恢复。
搭起来的拓扑
sysbench -> ProxySQL(应用唯一知道的地址)-> mysql-1(source)
mysql-2(replica)
mysql-3(replica)
Orchestrator 盯着三台。mysql-1 死掉时,Orchestrator 提升 mysql-2,并通知 ProxySQL 把写流量发到新主库。
./scripts/lab.sh deploy # 建整套环境
./scripts/lab.sh verify # 检查健康初始状态
./scripts/lab.sh teardown # 删容器、卷、网络
make deploy、make verify、make teardown 等价。每一步带预期输出的手工版本在 docs/manual-setup.md。
依赖:Linux 或 macOS(本文在 Ubuntu 24.04.5 上产出)、带 docker compose 插件的 Docker Engine 29.1.3、约 4 GB 空闲磁盘、2 核足够、curl 和 python3。首次运行要拉镜像;之后一次干净 deploy 大约一分钟。
端口与账号
| 用途 | 地址 | 账号 |
|---|---|---|
| 应用侧 MySQL(ProxySQL) | 127.0.0.1:16033 | sbuser/sbpass |
| ProxySQL admin | 127.0.0.1:16032 | radmin/radmin |
| ProxySQL 网页 | https://127.0.0.1:16080/ | stats/stats |
| Orchestrator 网页 | http://127.0.0.1:13000/ | 无登录 |
| mysql-1/2/3 | 127.0.0.1:13316 / :13317 / :13318 | root/mysql |
ProxySQL 网页必须用 https;用 http 访问 6080 会返回空响应。网页用 HTTP digest 的 stats/stats;radmin/radmin 只用于 16032 的 admin 端口。内置的 admin/admin 账号存在,但 ProxySQL 只接受容器内部连接,从宿主机连会报 ERROR 1040 ... can only connect locally。
其他账号密码与用户名相同:repl(复制)、orchestrator(故障切换工具)、monitor(ProxySQL 健康检查)。明文密码、不开 TLS,是刻意为之。
先弄清楚的几个概念
异步复制。 source 把变更写到自己的磁盘、告诉客户端“已提交”,副本之后才去拉这条变更并应用。副本通常落后零点几秒。如果 source 恰好在这个窗口里死掉,客户端被告知已提交的事务,可能在新 source 上不存在。生产环境会提到 semi-sync:等至少一个副本收到变更,才告诉客户端提交成功。
每条变更写进 binlog 并打上 GTID(server-uuid:1-16 这种)。副本记住自己应用了哪些 GTID,向 source 要剩下的。Lab 用 gtid_mode=ON 打开,副本用 SOURCE_AUTO_POSITION=1 挂上去。
MySQL 8.4 改了复制命令名:CHANGE REPLICATION SOURCE TO、SHOW REPLICA STATUS,MASTER/SLAVE 已移除。Orchestrator 页面上仍然写 “master”。
read_only / super_read_only。 read_only=ON 让普通账号的写失败;super_read_only=ON 对包括 root 在内的 SUPER 账号同样生效。Orchestrator 提升副本时会在内存里把这两个都关掉,但磁盘上的配置文件仍然写着 ON——重启那个容器,新 source 又会变回只读。
ProxySQL 的 hostgroup。
- 10:Writer。
read_only=0,写流量进这里。 - 20:Readers。
read_only=1,只有规则命中才走。 - 30:Quarantine。故障切换 hook 把死掉的 source 停在这里。
监控线程用 monitor 登录,逐台问 read_only:0 归到 10,1 归到 20。启动时三台都插在 10,monitor 会在一两秒内把副本挪到 20。monitor 同时做 ping,连续两次失败(shun_on_failures=2,每秒一次)就把服务器标成 SHUNNED。
一条查询规则按语句文本匹配:以 SELECT 开头的发往 hostgroup 20。两个 Lab 设置很关键:用户 sbuser 上 transaction_persistent=1(事务第一条语句选中服务器后,后续语句留在同一台;oltp_read_write 以 BEGIN 开头,落在 hostgroup 10,所以后面的 SELECT 也留在 writer 上);sysbench 加 --db-ps-mode=disable(默认的 prepared statement 会把语句固定到某台服务器,之后不再看文本)。
LOAD ... TO RUNTIME 让 admin 改动生效;SAVE ... TO DISK 让改动跨重启保留。
Orchestrator 的检测时序与 hook。 它登录每台 MySQL、画出拓扑、发现 source 不再应答。InstancePollSeconds=2 加 1 秒连接超时,大约两秒。然后:选一个副本(配置里 prefer mysql-2、accept mysql-3、must_not 其他任何主机,这样旧 source 即使重新出现也不会被选);关掉选中副本的 read_only/super_read_only;用 GTID 把存活副本指向新 source;执行 hook proxysql-failover-hook.sh。
Orchestrator 3.2.6 没有内置的“编辑 ProxySQL hostgroup”能力,hook 通过 PostMasterFailoverProcesses 配置。hook 以 radmin 写 ProxySQL admin 端口:把死掉的 source 停进 hostgroup 30,把新 source 插进 hostgroup 10。两次编辑之前都先跑 SAVE MYSQL SERVERS FROM RUNTIME,改的是 ProxySQL 实际在跑的状态,而不是 deploy 启动时写进去的行。
镜像是 Percona 构建的 Orchestrator 3.2.6;上游构建仍会发出 8.4 已移除的命令,发现不了这几台服务器。
sysbench 命令行是 oltp_read_write、4 线程、4 张 10000 行的表、每秒一行。健康时的一行长这样:
01:15:39 [ 582s ] thds: 4 tps: 871.04 qps: 17399.82 (r/w/o: 12178.57/3479.16/1742.08) lat (ms,95%): 6.91 err/s: 0.00 reconn/s: 0.00
字段依次是:时钟、已运行秒数、线程数、tps(BEGIN..COMMIT)、qps(含 BEGIN/COMMIT)、r/w/o、p95 延迟、err/s、reconn/s(跳变说明线程掉线又重连)。--mysql-ignore-errors=all 让 sysbench 把错误记下来继续跑;不加的话第一个错误就退出,永远看不到恢复那一行。健康运行约 770–900 tps,err/s 为 0,p95 约 7 ms。
搭建与初始校验
git clone https://github.com/ghkrzysztof-ksiazek/database-labs.git
cd database-labs/20261019-mysql-async-replication-ha
./scripts/lab.sh deploy
./scripts/lab.sh verify
当 mysql-1 在线、两个副本都在应用且 super_read_only=1、ProxySQL 唯一的 online writer 是 mysql-1、Orchestrator 认识 3 台、sysbench 在跑时,verify 打印 verify: steady state ok。故障切换之后这个检查会故意失败。
两个实现细节:副本是 source 数据目录的文件拷贝并删掉 auto.cnf——文件拷贝保住了 GTID 历史一致,后面的提升才安全;三个空 MySQL 容器会得到三段互不相关的 GTID 历史,Orchestrator 会拒绝提升。等待 MySQL 就绪看的是日志行 MySQL init process done. Ready for start up.,然后跑 SELECT 1;对官方镜像直接 ping 会过早成功,命中的是即将退出的临时服务器,下一条命令就报 ERROR 2002。
健康时的检查:Orchestrator 页面上 mysql-1 是顶部节点、读写,mysql-2 和 mysql-3 在它下面、只读、无延迟、GTID 开着。在 mysql-2 上 SHOW REPLICA STATUS 显示 Source_Host 是 mysql-1,两个线程列都是 Yes,Auto_Position 为 1。在 source 上 SHOW REPLICAS 列出两个副本。ProxySQL 的 runtime_mysql_servers 里 hostgroup 10 是 mysql-1 ONLINE,hostgroup 20 是 mysql-2 和 mysql-3 ONLINE。
实验 A:杀掉 source
预期:写入短暂停止,Orchestrator 提升 mysql-2,mysql-3 改为从 mysql-2 复制。sysbench 会出现几秒 0 tps、一波等于线程数的错误,然后流量恢复。ProxySQL 的 writer hostgroup 从 mysql-1 变成 mysql-2。
让 sysbench 继续跑,另开一个终端:
docker kill mysql-1
没有重启策略,容器停在 Exited (137)。同时观察 docker logs -f sysbench、Orchestrator 页面,以及:
docker exec proxysql mysql -h127.0.0.1 -P6032 -uadmin -padmin \
-e "SELECT hostgroup_id, hostname, status FROM runtime_mysql_servers ORDER BY 1,2"
docker logs orchestrator 2>&1 | tail
docker exec orchestrator tail -n 20 /var/log/proxysql-failover.log
Orchestrator 视角,逐秒看(21:59:18–21:59:21):mysql-1 变为 invalid;21:59:21 mysql-2 变成 src=-、rw,mysql-3 变成 src=mysql-2、repl=yes。
hook 日志 21:59:19–21:59:20:
detected DeadMaster ... pre-failover ...
hook start type=DeadMaster failed=mysql-1 successor=mysql-2
... fenced mysql-1 into hostgroup 30 ...
successor mysql-2 read_only=0 ...
published mysql-2 into writer hostgroup 10 ...
post-failover
同一时钟窗口的 sysbench:
21:59:16 tps 968.05 err/s 0.00 reconn/s 0.00
21:59:17 tps 653.84 err/s 0.00 reconn/s 4.00
21:59:18 tps 0.00
21:59:19 tps 0.00 err/s 4.03
21:59:20 tps 858.55 err/s 0.00
21:59:21 tps 1069.01
21:59:22 tps 1095.96
杀进程那一秒仍然完成了 654 个事务,4 条线程全部重连。接下来两秒什么也没完成。21:59:19 每条线程各记一个错误。21:59:20 流量回来。总共 4 个失败事务,每条连接一个。
hook 执行后,runtime 视图里 mysql-2 是 ONLINE writer、带 hook 写的注释,mysql-3 是 reader,mysql-1 不出现——runtime 不列 OFFLINE_HARD。admin 表里 mysql-1 在 hostgroup 30、OFFLINE_HARD 被隔离;没有任何规则路由到 30,OFFLINE_HARD 意味着 ProxySQL 会丢弃仍握在那台服务器上的连接。
被提升之后:mysql-2 上 SHOW REPLICA STATUS 为空(它已经是 source,read_only=0、super_read_only=0);mysql-3 上 Source_Host 是 mysql-2,两个线程 Yes,Seconds_Behind_Source 为 1(仍在追),Auto_Position 为 1。GTID 集合包含旧 source 的事务加上新 source 的。Orchestrator 是用 GTID 把副本移过去的,没有重建 mysql-1。新 source 上 SHOW REPLICAS 只列 mysql-3;mysql-1 没有在复制。
应用视角
按这套定时器,source 崩溃大约是两秒零吞吐、每条连接一个错误,之后应用重新开始写,没有人改过它的主机名或密码。它用的地址自始至终是 ProxySQL。
踩到的坑
hook 用 admin 登录,语句全被拒。 旧 hook 以 admin 登录,而 ProxySQL 只接受它自己的 localhost,每条语句都被 ERROR 1040 ... can only connect locally 拒绝;hook 不检查返回值,照样打了 “fenced”/“published”。实际让切换生效的是 ProxySQL 自己的 read_only monitor(把 mysql-2 挪进 hostgroup 10)和健康检查(shun 掉 mysql-1),不是 hook。Lab 现在有第二个 admin 用户 radmin,语句没生效时 hook 记 FAILED。
LOAD ... TO RUNTIME 加载了过期表。 登录修好之后,下一次运行反而更糟:每秒 112、然后 240 个错误,持续两秒。原因是 deploy 把三台服务器都装进 hostgroup 10,让 read_only monitor 去分拣,但 monitor 只改 runtime;admin 表里大家还都在 hostgroup 10。hook 一跑 LOAD MYSQL SERVERS TO RUNTIME,ProxySQL 就把那张过期表加载进来,mysql-3 当了大约一秒的 writer,写被它的 super_read_only 拒绝。hook 现在改任何东西之前先跑 SAVE MYSQL SERVERS FROM RUNTIME。任何在故障期间编辑 ProxySQL 的脚本都有同一个陷阱:先读运行态再写,并且逐条确认写入是否真的生效。
被提升的副本重启后又变回只读。 Orchestrator 关掉 read_only/super_read_only 只发生在内存里,磁盘配置文件仍是 ON。重启新 source 的容器,它会带着只读配置回来,写入立刻失败。
异步复制会丢已提交事务。 source 在“告诉客户端提交成功”和“副本收到这条变更”之间死掉,那笔事务在新 source 上就不存在。上面的 4 个失败事务是客户端明确看到错误的;落在 gap 里、客户端以为成功的事务不会出现在错误计数里。要缩这个窗口得上 semi-sync。
split brain 没有验证。 如果 source 是被网络分区而不是被杀掉,它仍然活着并继续接受写入,就可能出现两个可写服务器。hook 把旧 source 标成 hostgroup 30 的 OFFLINE_HARD,让 ProxySQL 不再用它,但这次实验里进程已经被 kill,没有做那种保留旧 source 可写的网络切断。
ProxySQL 和 Orchestrator 本身是单点。 这套拓扑里 ProxySQL 是应用唯一地址,它挂了应用就没有入口;Orchestrator 挂了就没人做提升,也没人通知 ProxySQL。两者各自都需要额外的冗余方案,这个 Lab 没有覆盖。