MySql8.4 InnoDB Cluster 的构建方法

摘要

  • MySQL 官方高可用方案 InnoDB Cluster 的搭建、故障转移演练与日常运维
  • 本文基于mysql-8.4.11 LTSmysql-shell-8.4.10mysql-router-8.4.10
  • 组成:Group Replication(数据复制与选主)+ MySQL Shell(AdminAPI)(搭建与管理)+ MySQL Router(把写流量路由到当前 Primary)
  • 单节点/主从/双主的搭法见 MySql8.4单节点、主从、双主的构建方法;为什么不用 MHA 见 MySql-MHA的构建方法

常见面试题

0.先搞清楚这三个组件在干什么

MySql8.4单节点、主从、双主的构建方法 里的主从/双主,**主库挂了不会自动切**:要人工改应用连接串、人工提升从库。8.0 时代常用 MHA 补这一步,但 MHA 在 8.4 上已经不可用。

InnoDB Cluster 不是「主从外面再挂一个监控进程」,而是把选主做进了数据库内核:

组件 装在哪 干什么
Group Replication 每个 MySQL 节点(内置插件) 组内成员互相通信、按 Paxos 类协议对事务达成共识;成员失联时组内自己投票选出新 Primary
MySQL Shell (AdminAPI) 运维机器上一份就够 dba.* / cluster.* 命令搭集群、加减节点、切主、查状态,不用手写 Group Replication 参数
MySQL Router 贴着应用部署 读集群元数据,感知谁是 Primary;应用只连 Router 的固定端口,切主后 Router 自动把写流量指到新 Primary

和主从 + MHA 的关键区别

  • 选主是组内共识,不是外部进程探测。不需要一台单点 Manager,也不需要 SSH 免密去各节点捞 binlog。

  • 靠多数派(quorum)防脑裂。少数派分区会变成不可写,而不是两边都能写。

  • 应用不改连接串。不用 VIP 漂移脚本,Router 负责把连接送到当前 Primary。

  • 代价:至少 3 个节点;所有表必须有主键、必须 InnoDB;事务要小。

容错能力看节点数

集群要能继续写,必须有超过半数成员在线。所以:

节点数 能坏几个 说明
2 0 坏一个就没多数派,整个集群不可写。不要用 2 节点
3 1 最小可用生产配置
4 1 和 3 节点一样,多这一个只增加成本
5 2 要容忍 2 个故障才上 5
最多 9 组成员上限

所以节点数要用奇数,且至少 3 个。三个节点还应该放在不同的故障域(不同宿主机/机架/可用区),否则一台物理机挂掉就一起没了。

1.节点规划

1
2
3
4
node1:  10.250.0.11    MySQL 8.4.11 + MySQL Shell
node2: 10.250.0.12 MySQL 8.4.11 + MySQL Shell
node3: 10.250.0.13 MySQL 8.4.11 + MySQL Shell
app1: 10.250.0.21 应用服务器 + MySQL Router
1
2
3
4
5
vim /etc/hosts
10.250.0.11 node1
10.250.0.12 node2
10.250.0.13 node3
10.250.0.21 app1

端口要放通

端口 用途
3306 MySQL 客户端协议,Router 和 Shell 连这个
33060 MySQL X 协议
33061 Group Replication 组内通信,节点之间必须互通,这个最容易漏开
6446 / 6447 Router 对应用暴露的读写 / 只读端口

2.前置条件(不满足后面必然失败)

InnoDB Cluster 用的就是 Group Replication,所以要求完全一致:

要求 说明
所有表都是 InnoDB MyISAM / MEMORY 会导致组复制报错,先转换掉
所有表都有主键 组复制靠主键做冲突检测,没主键的表写不进去
GTID 开启 gtid_mode=ONenforce_gtid_consistency=ON,这两个不是默认值,必须显式配
binlog 为 ROW 8.4 默认就是 ROW,一般不用动
performance_schema 开启 状态监控依赖它,默认开启
server_id 唯一 三个节点不能相同
report_host 建议显式配 否则节点可能上报成一个别人连不上的主机名
  • 把这些先写进每个节点的 /etc/my.cnf,其它参数 AdminAPI 会帮你配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[mysqld]
# 每个节点不同
server-id = 11
# 显式上报自己的地址,避免元数据里记成解析不到的主机名
report_host = node1

# 组复制必需,这两个不是默认值
gtid_mode = ON
enforce_gtid_consistency = ON

# 8.4默认就是ROW,这里不用配,写出来只是提醒不要改成statement
# binlog_format = ROW

# 禁止使用非InnoDB引擎,提前拦住MyISAM建表
disabled_storage_engines = "MyISAM,BLACKHOLE,FEDERATED,ARCHIVE,MEMORY"
  • 检查一下有没有不合规的表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 非InnoDB的表
SELECT table_schema, table_name, engine
FROM information_schema.tables
WHERE engine != 'InnoDB'
AND table_schema NOT IN ('mysql','information_schema','performance_schema','sys');

-- 没有主键的表
SELECT t.table_schema, t.table_name
FROM information_schema.tables t
LEFT JOIN information_schema.table_constraints c
ON t.table_schema = c.table_schema
AND t.table_name = c.table_name
AND c.constraint_type = 'PRIMARY KEY'
WHERE c.constraint_name IS NULL
AND t.table_type = 'BASE TABLE'
AND t.table_schema NOT IN ('mysql','information_schema','performance_schema','sys');

3.安装 MySQL Shell 和 MySQL Router

  • MySQL Shell 装在三个 MySQL 节点(或至少一台运维机器)上

1
2
3
4
5
6
7
8
9
10
# 下载地址:https://dev.mysql.com/downloads/shell/
wget https://cdn.mysql.com/Downloads/MySQL-Shell/mysql-shell-8.4.10-linux-glibc2.28-x86-64bit.tar.gz
tar -zxvf mysql-shell-8.4.10-linux-glibc2.28-x86-64bit.tar.gz
mv mysql-shell-8.4.10-linux-glibc2.28-x86-64bit /usr/local/soft/mysqlsh

# 加入PATH
echo 'export PATH=$PATH:/usr/local/soft/mysqlsh/bin' >> /etc/bashrc
source /etc/bashrc

mysqlsh --version
  • MySQL Router 装在应用服务器上(下一步再 bootstrap)

1
2
3
4
5
# 下载地址:https://dev.mysql.com/downloads/router/
# 也可以用yum/apt仓库:yum install mysql-router-community
wget https://cdn.mysql.com/Downloads/MySQL-Router/mysql-router-8.4.10-linux-glibc2.28-x86_64.tar.xz
tar -Jxvf mysql-router-8.4.10-linux-glibc2.28-x86_64.tar.xz
mv mysql-router-8.4.10-linux-glibc2.28-x86_64 /usr/local/soft/mysqlrouter

Shell 和 Router 要选和 Server 相同的系列(8.4 LTS),但小版本号不需要和 Server 一致:它们是独立发版的,Server 已经到 8.4.11 时 Shell / Router 的 8.4 系列可能还停在 8.4.10,各自取所在系列的最新版即可。

4.检查并配置实例

AdminAPI 提供两个命令:checkInstanceConfiguration() 只检查,configureInstance() 会真的去改配置。

  • 先用 Shell 连上 node1,进 JS 模式

1
mysqlsh root@node1:3306
  • 检查当前实例是否满足要求

1
2
3
4
5
6
7
8
 MySQL  node1:3306 ssl  JS > dba.checkInstanceConfiguration('root@node1:3306')

Validating MySQL instance at node1:3306 for use in an InnoDB cluster...
This instance reports its own address as node1:3306
Checking whether existing tables comply with Group Replication requirements...
No incompatible tables detected
Checking instance configuration...
Instance configuration is compatible with InnoDB cluster

clusterAdmin 是什么账号

先说清楚这个账号的用途,否则下面的命令会一头雾水。

搭集群时你是用 root 连上去的,但 AdminAPI 之后要反复从一个节点去连另一个节点addInstance() 要连新节点、setPrimaryInstance() 要连目标节点、status() 要挨个去问成员状态。它用的不是你当前的 root,而是这个专门的账号,官方叫**「服务器配置账号」(server configuration account)**。

它需要元数据表的完整读写权限,外加 SUPERGRANT OPTIONCREATEDROP 等一整套管理员权限。不用自己去 GRANTconfigureInstance() 会自动授全。

配置实例并创建这个账号

  • 不传密码,Shell 会交互式提示你输入,这一步会把 Group Replication 需要的参数写进配置文件并持久化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 MySQL  node1:3306 ssl  JS > dba.configureInstance('root@node1:3306', {clusterAdmin: "'icadmin'@'10.250.%'"})

Please provide the password for 'root@node1:3306': ********

Configuring MySQL instance at node1:3306 for use in an InnoDB cluster...
This instance reports its own address as node1:3306

Missing the password for new account icadmin@10.250.%. Please provide one.
Password for new account: ********
Confirm password: ********

Creating user icadmin@10.250.%.
Account icadmin@10.250.% was successfully created.

The instance 'node1:3306' is valid for InnoDB Cluster usage.
  • 'icadmin'@'10.250.%' 是标准的 MySQL 账号格式:用户名 + 允许从哪些地址连。这里限定成内网段,比 % 安全

  • 也可以把密码写在参数里,但不推荐,它会留在 Shell 的命令历史里

1
2
// 图省事可以这么写,生产环境别这么干
MySQL node1:3306 ssl JS > dba.configureInstance('root@node1:3306', {clusterAdmin: "'icadmin'@'10.250.%'", clusterAdminPassword: 'Icadmin@123'})
  • 默认会套用 default_password_lifetime,管理账号被动过期会让运维操作突然失败,可以显式指定不过期

1
MySQL  node1:3306 ssl  JS > dba.configureInstance('root@node1:3306', {clusterAdmin: "'icadmin'@'10.250.%'", clusterAdminPasswordExpiration: 'NEVER'})

为什么三个节点都要执行一遍

  • 三个节点都跑一次,用户名和密码必须完全一样(在 node1 上就能远程配,也可以分别登上去配)

1
2
MySQL  node1:3306 ssl  JS > dba.configureInstance('root@node2:3306', {clusterAdmin: "'icadmin'@'10.250.%'"})
MySQL node1:3306 ssl JS > dba.configureInstance('root@node3:3306', {clusterAdmin: "'icadmin'@'10.250.%'"})

警告⚠️
这个账号不会自动同步到其它节点。 MySQL Shell 在执行 configureInstance() 时会关掉 binlog,所以建账号这个动作不进 binlog、也就不会被复制——必须在每个节点单独建一次。

而且此时集群还没建起来,三个节点之间本来就没有任何复制关系,指望它自己传过去是不可能的。

密码必须三个节点一致:AdminAPI 拿着你在当前会话里用的这套凭据去连其它节点,某个节点上的密码不一样,addInstance() 就会在那里认证失败。

setupAdminAccount() 的区别
后面第 10 节的 cluster.setupAdminAccount() 建的账号走 binlog、会自动复制到所有成员,所以那个只需要在一个节点上执行一次。
区别就在于执行时机:configureInstance() 跑在集群成立之前(无复制、且主动关了 binlog),setupAdminAccount() 跑在集群成立之后(有复制)。

如果提示需要重启(比如刚加的 gtid_mode),按提示重启对应节点的 mysqld 再重新执行检查。

  • 建完可以验证一下账号确实在每个节点上都有

1
2
3
4
5
6
mysql> SELECT user, host FROM mysql.user WHERE user = 'icadmin';
+---------+-----------+
| user | host |
+---------+-----------+
| icadmin | 10.250.% |
+---------+-----------+

5.创建集群

  • 用管理账号连上准备当第一个 Primary 的节点(种子节点)

1
mysqlsh icadmin@node1:3306
  • 创建集群

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 MySQL  node1:3306 ssl  JS > var cluster = dba.createCluster('myCluster')

A new InnoDB Cluster will be created on instance 'node1:3306'.

Validating instance configuration at node1:3306...
This instance reports its own address as node1:3306
Instance configuration is suitable.
NOTE: Group Replication will communicate with other members using 'node1:33061'.
Creating InnoDB Cluster 'myCluster' on 'node1:3306'...

Adding Seed Instance...
Cluster successfully created. Use Cluster.addInstance() to add MySQL instances.
At least 3 instances are needed for the cluster to be able to withstand up to
one server failure.
  • 此时只有一个节点,注意最后那句提示:要 3 个节点才能容忍一次故障

var cluster = ... 这个变量很重要
后面所有 cluster.xxx() 操作都靠它。如果 Shell 会话断了,重新连上后用 var cluster = dba.getCluster() 把集群对象取回来,不用重建集群。

6.加入其余节点

1
MySQL  node1:3306 ssl  JS > cluster.addInstance('icadmin@node2:3306')
  • 加节点时 Shell 会问怎么同步已有数据,给三个选项:

方式 说明
Clone(推荐) 用 Clone 插件把种子节点的数据整份物理克隆过来,新节点原有数据会被清空
Incremental recovery 走分布式恢复,从其它成员的 binlog 增量补齐,要求 binlog 没被清理掉
Abort 放弃
  • 新节点一般选 Clone,也可以直接在参数里指定,避免交互

1
2
MySQL  node1:3306 ssl  JS > cluster.addInstance('icadmin@node2:3306', {recoveryMethod: 'clone'})
MySQL node1:3306 ssl JS > cluster.addInstance('icadmin@node3:3306', {recoveryMethod: 'clone'})

警告⚠️
选 Clone 会清空目标节点上的所有数据,用种子节点的数据覆盖。给新机器用没问题,往一台有数据的实例上加之前一定要确认清楚。

  • 可以给节点起个易读的标签,便于后面运维时引用

1
MySQL  node1:3306 ssl  JS > cluster.addInstance('icadmin@node3:3306', {recoveryMethod: 'clone', label: 'node3'})

7.查看集群状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
 MySQL  node1:3306 ssl  JS > cluster.status()
{
"clusterName": "myCluster",
"defaultReplicaSet": {
"name": "default",
"primary": "node1:3306",
"ssl": "REQUIRED",
"status": "OK",
"statusText": "Cluster is ONLINE and can tolerate up to ONE failure.",
"topology": {
"node1:3306": {
"address": "node1:3306",
"memberRole": "PRIMARY",
"mode": "R/W",
"readReplicas": {},
"replicationLag": "applier_queue_applied",
"role": "HA",
"status": "ONLINE",
"version": "8.4.11"
},
"node2:3306": {
"address": "node2:3306",
"memberRole": "SECONDARY",
"mode": "R/O",
"readReplicas": {},
"replicationLag": "applier_queue_applied",
"role": "HA",
"status": "ONLINE",
"version": "8.4.11"
},
"node3:3306": {
"address": "node3:3306",
"memberRole": "SECONDARY",
"mode": "R/O",
"readReplicas": {},
"replicationLag": "applier_queue_applied",
"role": "HA",
"status": "ONLINE",
"version": "8.4.11"
}
},
"topologyMode": "Single-Primary"
},
"groupInformationSourceMember": "mysql://icadmin@node1:3306"
}
  • 重点看三个字段:primary 是谁、status 和每个成员的 status

  • 默认是 Single-Primary:一个 R/W,其余 R/O(Secondary 上 super_read_only=ON,想写也写不进去)

集群级 status 的含义

含义
OK 有多数派,且还能再坏至少一个
OK_PARTIAL 有成员掉了,但仍有容错余量
OK_NO_TOLERANCE 还能写,但再坏一个就完蛋,要赶紧修
OK_NO_TOLERANCE_PARTIAL 同上,且有成员不在组里
NO_QUORUM 失去多数派,不可写,只能读,需要人工介入
UNKNOWN 你连的这个节点自己就不在线,换个节点连
  • 要看更详细的信息(GTID 集、复制延迟、成员状态机)

1
MySQL  node1:3306 ssl  JS > cluster.status({extended: 1})
  • 也可以直接查 performance_schema,不依赖 Shell

1
2
3
4
5
6
7
8
9
mysql> SELECT MEMBER_ID, MEMBER_HOST, MEMBER_PORT, MEMBER_STATE, MEMBER_ROLE, MEMBER_VERSION
FROM performance_schema.replication_group_members;
+--------------------------------------+-------------+-------------+--------------+-------------+----------------+
| MEMBER_ID | MEMBER_HOST | MEMBER_PORT | MEMBER_STATE | MEMBER_ROLE | MEMBER_VERSION |
+--------------------------------------+-------------+-------------+--------------+-------------+----------------+
| 8a1b... | node1 | 3306 | ONLINE | PRIMARY | 8.4.11 |
| 9c2d... | node2 | 3306 | ONLINE | SECONDARY | 8.4.11 |
| ae3f... | node3 | 3306 | ONLINE | SECONDARY | 8.4.11 |
+--------------------------------------+-------------+-------------+--------------+-------------+----------------+

8.部署 MySQL Router

集群自己会选主,但应用怎么知道新 Primary 是谁?这就是 Router 的活。

bootstrap

Router 不需要手写配置,用 --bootstrap 连一次集群,它会把拓扑信息抓下来自动生成配置。

  • 在应用服务器上执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 系统级配置,生成到 /etc/mysqlrouter/mysqlrouter.conf
sudo /usr/local/soft/mysqlrouter/bin/mysqlrouter --bootstrap icadmin@node1:3306 \
--user=mysqlrouter \
--account=routeruser --account-create if-not-exists

# 输出大致如下
# MySQL Router configured for the InnoDB Cluster 'myCluster'
#
# After this MySQL Router has been started with the generated configuration
# $ mysqlrouter -c /etc/mysqlrouter/mysqlrouter.conf
#
# InnoDB Cluster 'myCluster' can be reached by connecting to:
#
# ## MySQL Classic protocol
#
# - Read/Write Connections: localhost:6446
# - Read/Only Connections: localhost:6447
#
# ## MySQL X protocol
#
# - Read/Write Connections: localhost:6448
# - Read/Only Connections: localhost:6449
  • 如果一台机器上要跑多个 Router 实例,用 --directory 生成自包含目录

1
2
sudo mysqlrouter --bootstrap icadmin@node1:3306 --directory /opt/myrouter --user=mysqlrouter
# 启动脚本在 /opt/myrouter/start.sh

这条命令里有三个不同的「用户」,别搞混

参数 是什么 密码
icadmin@node1:3306 MySQL 账号,就是第 4 节建的那个 clusterAdmin。bootstrap 这一次用它登录集群,读取拓扑、往元数据里注册自己 第 4 节 configureInstance() 时设的,bootstrap 会提示你输
--user=mysqlrouter 操作系统账号,不是 MySQL 账号。mysqlrouter 进程平时以这个身份运行 没有密码,它是个不能登录的系统账号
--account=routeruser MySQL 账号,Router 长期运行时用它连集群、刷新拓扑 bootstrap 过程中提示你输入并创建

--user:进程以哪个系统身份运行

官方原话是「a system login account, not a MySQL user listed in the grant tables」——它对应 /etc/passwd 里的用户,和 MySQL 权限表毫无关系。

  • 用 RPM / DEB 包装 Router 时会自动建好这个名叫 mysqlrouter 的系统用户;用 tar 包手工装的话要自己建

1
2
# -r 建系统账号,-s /bin/false 禁止登录,所以它根本没有密码
sudo useradd -r -s /bin/false mysqlrouter
  • 它的作用有两个:bootstrap 生成的所有文件(配置、keyring)都归它所有;运行时进程降权成它,不用 root 跑

  • 用 root 执行 bootstrap 时必须--user,否则直接报错

--account:Router 跑起来之后用哪个 MySQL 账号

bootstrap 用 icadmin 只是一次性的动作。做完之后 Router 要常驻,每隔几秒去刷新集群拓扑(谁是 Primary、谁掉线了),这时用的就是 --account 指定的账号。不能继续用 clusterAdmin——那是个近乎 root 的超级账号,而 Router 只需要读元数据的权限。

  • 不写 --account 会怎样:bootstrap 自动生成一个名字形如 mysql_router1_a8kd0s 的账号,配一个随机密码。省事,但一台机器 bootstrap 一次就多一个账号,久了 mysql.user 里全是这种垃圾

  • 写了 --account:用户名你定,多个 Router 可以共用一个账号,便于管理。代价是要自己记住这个账号

  • Router 实际只需要这些权限,--account-create 建账号时会自动授好

1
2
3
4
5
6
7
GRANT USAGE ON *.* TO `routeruser`@`%`;
GRANT SELECT, EXECUTE ON `mysql_innodb_cluster_metadata`.* TO `routeruser`@`%`;
GRANT INSERT, UPDATE, DELETE ON `mysql_innodb_cluster_metadata`.`routers` TO `routeruser`@`%`;
GRANT INSERT, UPDATE, DELETE ON `mysql_innodb_cluster_metadata`.`v2_routers` TO `routeruser`@`%`;
GRANT SELECT ON `performance_schema`.`global_variables` TO `routeruser`@`%`;
GRANT SELECT ON `performance_schema`.`replication_group_member_stats` TO `routeruser`@`%`;
GRANT SELECT ON `performance_schema`.`replication_group_members` TO `routeruser`@`%`;

--account-create 的三个取值

取值 行为
if-not-exists(默认) 账号存在就复用,不存在就创建。重复 bootstrap 不会失败,日常用这个
always 只在账号不存在时才 bootstrap 并创建它;账号已存在会直接报错退出
never 只在账号已存在时才 bootstrap 并复用;账号不存在会报错。适合账号由 DBA 预先建好的场景

警告⚠️
always 的语义是「确保这是个全新账号」,不是「不存在才建」——后者才是 if-not-exists。拿 always 去重跑 bootstrap(比如换了集群、想重新生成配置)会因为账号已存在而失败。

另外 --account-create 必须和 --account 一起用,且不能--account-host 同时出现。

--account 复用已存在的账号时要注意
bootstrap 不会给已存在的账号补权限,它只做检查,把缺失的权限打印到控制台,然后照样继续执行。结果就是 bootstrap 显示成功,Router 一跑起来却因为权限不足连不上元数据。
--strict 可以让权限检查不通过时直接失败退出,别让问题留到运行时。

密码是什么时候创建的,之后存在哪

bootstrap 过程中会出现两次密码交互:

1
2
3
4
5
6
7
8
sudo mysqlrouter --bootstrap icadmin@node1:3306 --user=mysqlrouter \
--account=routeruser --account-create if-not-exists

# 1) 登录集群用的,第4节建clusterAdmin时你设的那个
Please enter MySQL password for icadmin:

# 2) 现在为routeruser设置密码,这个账号和密码此刻才被创建出来
Please enter MySQL password for routeruser:
  • 第二个密码就是 routeruser 的密码,在 bootstrap 这一刻才创建,不需要你提前建账号

  • 只要带了 --account每次 bootstrap 都会提示输密码,哪怕 keyring 里已经存了

  • 输完之后你再也不用管它了:Router 把这个密码加密存进 keyring 文件,加密用的 master key 放在 mysqlrouter.key

1
2
3
4
5
6
7
# 系统级安装
/var/lib/mysqlrouter/keyring # 加密后的凭据
/etc/mysqlrouter/mysqlrouter.key # 解密用的master key

# 用 --directory 的自包含安装
/opt/myrouter/data/keyring
/opt/myrouter/mysqlrouter.key
  • 所以 systemctl start mysqlrouter 不用输任何密码,也不要去配置文件里找密码,明文密码不在里面

警告⚠️
mysqlrouter.key 是解开 keyring 的钥匙,它和 keyring 放在一起就等于把锁和钥匙挂在同一个钩子上。这两个文件必须只有 --user 指定的那个系统用户可读(包安装默认已经设好权限,别手工改宽)。安全要求高的场景用 --master-key-reader / --master-key-writer 接外部密钥管理,让 master key 不落盘。

Router 暴露的端口

端口 协议 去哪
6446 classic PRIMARY。可读可写,但所有语句都发给 Primary,不做读写分离
6447 classic SECONDARY,只读,轮询。发写操作会报错
6448 X 协议 PRIMARY
6449 X 协议 SECONDARY
6450 classic 读写分离端口,Router 解析事务类型自动分流(8.2 起默认生成,--disable-rw-split 可关掉)

启动并设置开机自启

1
2
3
sudo systemctl start mysqlrouter
sudo systemctl enable mysqlrouter
sudo systemctl status mysqlrouter

先建业务账号

前面建的 icadmin(管集群)和 routeruser(Router 自用)都不是给业务用的,业务账号要自己建。

  • 只在 Primary 上执行一次,会经 binlog 自动复制到其它成员

1
2
3
-- 连到当前Primary,或者直接连Router的6446
mysql> CREATE USER 'appuser'@'10.250.0.21' IDENTIFIED BY 'AppUser@123';
mysql> GRANT SELECT, INSERT, UPDATE, DELETE, EXECUTE ON mydb.* TO 'appuser'@'10.250.0.21';

警告⚠️
host 段要写 Router 所在机器的地址,不是应用机器的地址。

Router 是代理,MySQL 看到的连接来源是 Router 的 IP,不是原始客户端的 IP(MySQL Server 和 Router 都不支持 Proxy Protocol)。上面例子里 Router 装在 app1(10.250.0.21),所以授权写的是 'appuser'@'10.250.0.21'

如果按直觉写成应用容器的地址,会一直报 Access denied——而且日志里看到的来源 IP 是 Router 的,容易查半天。这也是官方推荐把 Router 和应用装同一台机器的原因之一:这样这个地址就是同一个,不用为 Router 单独放开一批授权。

  • 要排查某个连接的真实客户端 IP,走 SSL 连接时 Router 会把它塞进连接属性里

1
2
3
4
mysql> SELECT program_name, user, attr_value AS client_ip
FROM performance_schema.session_connect_attrs
JOIN sys.processlist ON conn_id = processlist_id
WHERE attr_name = '_client_ip';

应用怎么连

1
2
3
4
5
# 写流量(以及不方便拆分读写的场景)统一连6446
mysql -uappuser -p -h127.0.0.1 -P6446

# 只读流量连6447
mysql -uappuser -p -h127.0.0.1 -P6447
  • JDBC 连接串就是指向 Router,不再写任何数据库节点的 IP

1
jdbc:mysql://127.0.0.1:6446/mydb?useSSL=true&autoReconnect=false

连上 6446 之后,读写到底怎么走

这里有个很容易误解的地方:6446 叫「读写端口」,意思是「可读可写」,不是「自动读写分离」。

  • 6446 上的所有 SQL——包括 SELECT——都发给 Primary,Secondary 一点读流量都分不到

  • Router 在 6446 / 6447 上不解析 SQL,它只是个连接级的转发器,按端口决定去哪,不看你发的是什么语句

  • 所以连 6447 发写操作会直接报错(Secondary 上 super_read_only=ON),Router 不会帮你纠正

于是有两种用法:

做法 怎么用 适合
应用自己分流 写连 6446,读连 6447,业务代码或框架里配两个数据源 想精确控制哪些查询能容忍延迟
交给 Router 分流 统一连 6450,Router 按事务类型自动判断,读发 Secondary、写发 Primary 不想改代码。8.2 起 bootstrap 默认就会生成这个端口
  • 6450 还能按 session 临时改行为,比如强制这个会话全部走 Primary

1
2
3
4
mysql> ROUTER SET access_mode='read_write';

-- 让只读查询等到本会话最后一次写入被应用完,避免读到旧数据
mysql> ROUTER SET wait_for_my_writes=1;

写入是怎么到其它节点的

写进 Primary 之后会同步到另外两个节点,但机制和普通主从不一样,有个关键区别要知道:

  • 提交时:事务要被多数派认证通过才会给客户端返回成功。所以返回成功意味着这个事务已经被多数节点确认,Primary 立刻宕机也不会丢

  • 应用时:Secondary 把事务真正回放到自己的表里是异步的

所以「写完立刻从 6447 读」仍然可能读不到刚写的数据。这不是 bug,是组复制的正常行为。要避免就用上面的 wait_for_my_writes=1,或者这类查询直接走 6446。

顺带说个术语:InnoDB Cluster 里叫 Primary / Secondary,没有 master/slave 的说法。8.4 连 SQL 语句都改名了(SHOW REPLICA STATUS 之类),细节见 MySql8.4单节点、主从、双主的构建方法

警告⚠️
Router 自己是单点。 官方推荐的部署方式是把 Router 和应用放在同一台机器上(每个应用实例一个 Router),这样 Router 挂了只影响这一个应用实例。不要全公司共用一台 Router;如果一定要集中部署,就多台 Router + 前面加 LB/VIP。

9.故障转移演练

  • 先确认当前 Primary 是 node1,然后直接把 node1 的 mysqld 干掉

1
2
3
4
# 在node1上
sudo systemctl stop mysqld
# 或者更暴力一点,模拟宕机
# sudo kill -9 $(pidof mysqld)
  • 连到 node2 看集群状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
 MySQL  node2:3306 ssl  JS > var cluster = dba.getCluster()
MySQL node2:3306 ssl JS > cluster.status()
{
"clusterName": "myCluster",
"defaultReplicaSet": {
"name": "default",
"primary": "node2:3306",
"status": "OK_NO_TOLERANCE_PARTIAL",
"statusText": "Cluster is NOT tolerant to any failures. 1 member is not active.",
"topology": {
"node1:3306": {
"address": "node1:3306",
"memberRole": "SECONDARY",
"mode": "n/a",
"shellConnectError": "MySQL Error 2003: Could not open connection to 'node1:3306': Can't connect to MySQL server on 'node1:3306'",
"status": "(MISSING)"
},
"node2:3306": {
"address": "node2:3306",
"memberRole": "PRIMARY",
"mode": "R/W",
"status": "ONLINE"
},
"node3:3306": {
"address": "node3:3306",
"memberRole": "SECONDARY",
"mode": "R/O",
"status": "ONLINE"
}
},
"topologyMode": "Single-Primary"
}
}
  • primary 已经变成 node2,整个过程没有人工干预,通常在秒级完成

  • node1 的状态是 (MISSING),集群变成 OK_NO_TOLERANCE_PARTIAL:还能写,但再坏一个就失去多数派

  • 此时应用侧仍然连 6446,写操作照常成功

1
2
3
4
5
6
mysql -uappuser -p -h127.0.0.1 -P6446 -e "select @@hostname, @@super_read_only;"
+------------+-------------------+
| @@hostname | @@super_read_only |
+------------+-------------------+
| node2 | 0 |
+------------+-------------------+

警告⚠️
切主时已建立的连接会断开。 Router 只保证新连接被送到新 Primary,它不会把一个已经断掉的 TCP 会话「搬」过去,正在执行的事务会失败。应用必须能重连并重试,否则这一瞬间的请求照样报错。别拿实验室里测出来的秒数当 SLA:真实耗时取决于故障检测、选主、积压事务回放、Router 刷新元数据、客户端重试策略。

  • 把 node1 修好后启动 mysqld,它一般会自动重新加入

1
sudo systemctl start mysqld
  • 没自动加回来就手工 rejoin

1
2
MySQL  node2:3306 ssl  JS > cluster.rejoinInstance('icadmin@node1:3306')
MySQL node2:3306 ssl JS > cluster.status()

注意 node1 回来之后是 SECONDARY,不会自动抢回 Primary。想让它当主得手工切(见下一节)。

10.日常运维命令

主动切主(计划内维护)

1
2
// 把Primary切到node1,用于打补丁、重启前腾空某个节点
MySQL node2:3306 ssl JS > cluster.setPrimaryInstance('node1:3306')

摘除 / 加回节点

1
2
3
4
5
6
7
8
// 正常摘除
MySQL node1:3306 ssl JS > cluster.removeInstance('icadmin@node3:3306')

// 节点已经连不上了,强制从元数据里摘掉
MySQL node1:3306 ssl JS > cluster.removeInstance('icadmin@node3:3306', {force: true})

// 加回来
MySQL node1:3306 ssl JS > cluster.addInstance('icadmin@node3:3306', {recoveryMethod: 'clone'})

加只读副本(8.4 新能力)

8.4 的 AdminAPI 支持给集群挂异步只读副本:它不参与投票、不影响容错计算,纯粹用来扛读流量或跑报表。源节点挂了会自动切到别的成员。

1
2
3
4
5
6
7
8
// 默认从Primary复制
MySQL node1:3306 ssl JS > cluster.addReplicaInstance('icadmin@node4:3306')

// 让它从Secondary复制,避免给Primary加压
MySQL node1:3306 ssl JS > cluster.addReplicaInstance('icadmin@node4:3306', {label: 'report1', replicationSources: 'secondary'})

// 改复制源
MySQL node1:3306 ssl JS > cluster.setInstanceOption('node4:3306', 'replicationSources', 'secondary')

只读副本会出现在 cluster.status()readReplicas 里,Router 也能把只读流量路由到它。

补建管理账号

1
MySQL  node1:3306 ssl  JS > cluster.setupAdminAccount('icadmin2@10.250.%')

查看集群配置项

1
MySQL  node1:3306 ssl  JS > cluster.options()

单主 / 多主切换

1
2
MySQL  node1:3306 ssl  JS > cluster.switchToMultiPrimaryMode()
MySQL node1:3306 ssl JS > cluster.switchToSinglePrimaryMode('node1:3306')

警告⚠️
多主模式不是「性能更好的单主」。 多主下所有节点都能写,冲突靠事务认证阶段检测,撞了就回滚;而且多主不支持带级联约束的外键,还建议把隔离级别降到 READ COMMITTED。绝大多数业务应该老老实实用默认的单主模式。

11.故障恢复

场景一:失去多数派(NO_QUORUM)

3 节点挂了 2 个,剩下的这个凑不出多数派,集群不可写。这时需要人工告诉它「就用这个分区继续跑」:

1
2
3
// 连到还活着的那个节点
MySQL node1:3306 ssl JS > var cluster = dba.getCluster()
MySQL node1:3306 ssl JS > cluster.forceQuorumUsingPartitionOf('icadmin@node1:3306')

警告⚠️
这个命令是强行重定义多数派,绕过了正常的投票保护。执行前必须确认另外那些节点真的挂了,而不只是网络不通。如果它们其实还活着并且在另一侧也接受写入,就会造成脑裂,两份数据后面无法自动合并。

场景二:所有节点都挂了(完全停机)

比如整个机房断电后重启,三个 mysqld 都起来了但组复制没起来:

1
2
3
4
5
6
7
8
9
10
// 连到数据最新的那个节点(GTID最全)
MySQL node1:3306 ssl JS > var cluster = dba.rebootClusterFromCompleteOutage()

// 指定谁当Primary
MySQL node1:3306 ssl JS > var cluster = dba.rebootClusterFromCompleteOutage('myCluster', {primary: 'node2:3306'})

// 有节点连不上,先用剩下的把集群拉起来
MySQL node1:3306 ssl JS > var cluster = dba.rebootClusterFromCompleteOutage('myCluster', {force: true})
// 然后把缺的节点加回来
MySQL node1:3306 ssl JS > cluster.rejoinInstance('icadmin@node3:3306')

Shell 会检查你连的这个节点是不是 GTID 最全的那个,不是就报错。用 force 跳过这个检查意味着主动放弃另一个节点上多出来的事务,要清楚代价。

场景三:某个节点数据分叉了

节点上有集群里不存在的事务(比如被人在 Secondary 上强行写过),它会拒绝加入。做法是摘掉再用 Clone 重灌:

1
2
MySQL  node1:3306 ssl  JS > cluster.removeInstance('icadmin@node3:3306', {force: true})
MySQL node1:3306 ssl JS > cluster.addInstance('icadmin@node3:3306', {recoveryMethod: 'clone'})

12.几个必须知道的坑

从 Secondary 读可能读到旧数据

Secondary 是异步应用事务的,写完立刻去 6447 读,有可能读不到。这和 MySql8.4单节点、主从、双主的构建方法 里主从的读写分离是一样的问题。

8.4 在切主这一步做了保护:group_replication_consistency 的默认值从 8.0 的 EVENTUAL 改成了 BEFORE_ON_PRIMARY_FAILOVER。含义是新 Primary 在把旧 Primary 的积压事务回放完之前,不接受新的读写,避免应用切过去读到旧值。代价是切主耗时会随积压量增加。

1
2
3
4
5
-- 查看当前级别
mysql> SELECT @@group_replication_consistency;

-- 要求「读也必须看到之前所有已提交事务」,代价是读延迟上升
mysql> SET SESSION group_replication_consistency = 'BEFORE';

强一致读需求建议按 session 设置,不要全局拉到最高级别,否则整体延迟都会被拖高。

大事务会被拒绝

组复制要把事务在成员间达成共识,太大的事务会撑爆网络和内存。默认上限由 group_replication_transaction_size_limit 控制(默认 150MB),超了直接报错。

所以 DELETE FROM t WHERE ... 一把删掉几百万行这种操作在 InnoDB Cluster 上必须改成按主键分批、每批几千行单独提交。大表 DDL 同理,走 gh-ost 之类的影子表方案,别指望一条 ALTER 直接过去。

不能自己挂异步复制通道

InnoDB Cluster 不支持在成员上手工配置 AdminAPI 之外的复制通道。想挂只读副本用 addReplicaInstance(),不要自己去 CHANGE REPLICATION SOURCE TO

节点数要奇数,且分故障域

前面说过 2 节点和 4 节点都没意义。另外三个节点如果都在同一台宿主机上,那这套高可用只防 mysqld 进程挂,不防机器挂。

跨机房要用 ClusterSet

Group Replication 要求成员之间网络延迟低,不适合跨地域拉伸。跨机房容灾用 InnoDB ClusterSet:一个主集群 + 一个或多个从集群,集群之间异步复制。注意 ClusterSet 的跨集群切换是管理员手动触发的,紧急切换还可能丢未复制的事务,它解决的是容灾,不是又一层自动 failover。

13.和其它方案对比

方案 自动选主 一致性 节点数 说明
InnoDB Cluster 是,组内投票 组复制认证,切主不丢已确认事务 ≥3(奇数) 官方方案,8.4 首选
主从 + 半同步 半同步缩小丢数窗口,超时降级异步 ≥2 MySql8.4单节点、主从、双主的构建方法
主从 + MHA 尽量补齐 binlog,极端情况丢数 ≥3 8.4 上不可用,见 MySql-MHA的构建方法
Percona XtraDB Cluster Galera 准同步 ≥3 非官方,对一致性要求更高时可选
云数据库多可用区 平台保证 能上云优先

InnoDB Cluster 凭什么能自动切主?应用要改什么?

场景:从一主一从 + 人工切换,改造成 8.4 上的自动高可用。

1. 它和「主从 + MHA」的本质区别在哪?

  • 选主的位置不同。MHA 是外部 Perl 进程探测主库、再去各节点捞 binlog 补齐、然后提升从库;InnoDB Cluster 的选主做在 Group Replication 里,组内成员自己投票

  • 没有单点 Manager。MHA 的 Manager 本身是单点,还得给它做保活;InnoDB Cluster 不需要这个角色。

  • 防脑裂机制不同。组复制要求多数派才能写,少数派分区自动变不可写;MHA 靠二次检查和脚本,判断错了就可能双写。

  • 8.4 上 MHA 根本跑不起来:它发的 CHANGE MASTER TOSHOW SLAVE STATUS 已被删除。

2. 为什么至少要 3 个节点?2 个不行吗?

不行。要继续写必须有超过半数成员在线。2 节点时坏 1 个只剩 1 个,凑不出多数派,集群直接不可写——比单机还麻烦。3 节点能坏 1 个,5 节点能坏 2 个,所以节点数取奇数,并且要放在不同故障域。

3. 主库挂了,应用是怎么找到新主的?

  • 组内选出新 Primary,新 Primary 关掉 super_read_only 开始接写。

  • MySQL Router 读集群元数据,发现 Primary 换人,把 6446 端口的新连接指向新 Primary。

  • 应用不用改连接串、不用 VIP 漂移脚本,连的一直是 Router。

  • 已有连接会断,正在跑的事务会失败,所以应用必须能重连重试。Router 建议贴着应用部署,它自己是单点。

4. 切主的瞬间会读到旧数据吗?

8.4 默认不会。group_replication_consistency 默认值在 8.4 是 BEFORE_ON_PRIMARY_FAILOVER(8.0 是 EVENTUAL):新 Primary 回放完旧 Primary 的积压事务之后才对外服务,避免读到旧值。代价是切主耗时随积压量增加。
从 Secondary(6447)读仍然可能读到旧数据,那是正常的读写分离延迟,要强一致就走 6446 或按 session 设 group_replication_consistency='BEFORE'

5. 上这套方案,业务要付出什么代价?

  • 所有表必须是 InnoDB、必须有主键,否则写不进去。

  • 事务要小,默认超过 group_replication_transaction_size_limit(150MB)直接报错,大批量删改要分批。

  • 至少 3 台机器的成本;跨机房要用 ClusterSet,而且它的跨集群切换是手动的。

  • 多主模式不要随便开:冲突回滚、不支持级联外键,默认单主才是常规选择。

面试可背:InnoDB Cluster = Group Replication 选主 + Shell 管理 + Router 路由;至少 3 节点奇数、多数派才可写;选主在组内不靠外部 Manager;应用只连 Router 的 6446/6447,切主时连接会断所以必须重试;8.4 默认 BEFORE_ON_PRIMARY_FAILOVER 防止切主读到旧数据;前置条件是 InnoDB + 主键 + GTID,事务不能太大。