About this session
| Session | Details |
|---|---|
| Duration | 4–6 hours, including exercises (can be split over two days) |
| You will build | A production-style three-node cluster on separate servers, Router on the application host, monitoring queries, a backup routine, and a replica cluster in a second site with ClusterSet |
| You will learn | Design choices, instance preparation, cluster options, TLS and allow-lists, Router accounts and deployment, Group Replication monitoring and flow control, failure recovery procedures, backups and point-in-time recovery, rolling upgrades, ClusterSet switchover and failover |
| Prerequisites | The beginner session or equivalent; Linux administration; MySQL replication basics (binary logs, GTIDs); three Linux servers (four to six for the DR module) |
Warning
Several commands in Module 7 (
forceQuorumUsingPartitionOf,forcePrimaryCluster) can cause split brain or data loss if used at the wrong time. Practise them in the lab only, and make them part of a written runbook before you ever need them in production.
Module 1: design decisions
Decide these before you install anything:
| Decision | Recommendation | Why |
|---|---|---|
| Number of members | 3 (or 5 for two-failure tolerance) | Odd numbers; quorum needs a majority |
| Placement | One member per availability zone or failure domain, in one region | Every commit waits for a majority; cross-region latency slows every write |
| Mode | Single-primary | Multi-primary has conflict rules and limits (no SERIALIZABLE, foreign key cascades) that most apps don't need |
| Disaster recovery | ClusterSet with a replica cluster in another region | Asynchronous, so no write latency penalty |
| Router placement | On each application host (or as a sidecar) | No extra network hop; no single Router to lose |
| Read scaling | Secondaries, or read replicas added to the cluster | Read replicas don't take part in quorum |
Lab hostnames used below: db1, db2, db3 (one per zone), db4-db6 for the DR site, and app1 for the application.
Module 2: prepare the servers
Network
| Port | Used by |
|---|---|
| 3306 | MySQL classic protocol (clients, Router, AdminAPI) |
| 33060 | X Protocol (MySQL Shell, Router metadata) |
| 33061 | Group Replication member communication (when using the XCOM stack; the MySQL communication stack uses 3306) |
Open them between members, and 3306/33060 from Router hosts. Every member must resolve every other member's hostname the same way (DNS or /etc/hosts), and that name is what members advertise:
# /etc/my.cnf.d/server.cnf on db1 (adjust per host)
[mysqld]
server_id = 1
report_host = db1.example.internal
bind_address = 0.0.0.0
gtid_mode = ON
enforce_gtid_consistency = ON
log_replica_updates = ON
innodb_buffer_pool_size = 12G
innodb_redo_log_capacity = 4G
max_connections = 1000
disabled_storage_engines = "MyISAM,BLACKHOLE,FEDERATED,ARCHIVE,MEMORY"
disabled_storage_engines stops anyone creating non-InnoDB tables later.
Check and configure each instance
// mysqlsh --js, as an account with full privileges
dba.checkInstanceConfiguration('root@db1:3306')
dba.configureInstance('root@db1:3306', {clusterAdmin: "'clusteradmin'@'%'", restart: true})
configureInstance fixes required settings and creates a dedicated cluster admin account. Use that account (not root) for everything that follows. Repeat for db2 and db3.
Check the data for tables that will break Group Replication:
-- tables without a primary key
SELECT t.table_schema, t.table_name
FROM information_schema.tables t
LEFT JOIN information_schema.table_constraints c
ON c.table_schema = t.table_schema AND c.table_name = t.table_name AND c.constraint_type = 'PRIMARY KEY'
WHERE t.table_type = 'BASE TABLE' AND c.constraint_name IS NULL
AND t.table_schema NOT IN ('mysql','sys','information_schema','performance_schema');
-- non-InnoDB tables
SELECT table_schema, table_name, engine FROM information_schema.tables
WHERE engine <> 'InnoDB' AND table_schema NOT IN ('mysql','sys','information_schema','performance_schema');
Fix them first. An invisible auto-increment primary key (sql_generate_invisible_primary_key=ON) helps for new tables created by applications that don't define one.
Module 3: create a hardened cluster
\connect clusteradmin@db1:3306
var cluster = dba.createCluster('prodCluster', {
memberSslMode: 'VERIFY_IDENTITY', // TLS with certificate and hostname checks (needs proper certs); use REQUIRED in a lab
ipAllowlist: '10.10.1.0/24,10.10.2.0/24,10.10.3.0/24',
consistency: 'BEFORE_ON_PRIMARY_FAILOVER',
expelTimeout: 5,
autoRejoinTries: 3,
exitStateAction: 'OFFLINE_MODE'
})
cluster.addInstance('clusteradmin@db2:3306', {recoveryMethod: 'clone'})
cluster.addInstance('clusteradmin@db3:3306', {recoveryMethod: 'clone'})
cluster.status({extended: 1})
What the options do:
| Option | Effect |
|---|---|
memberSslMode | Encrypts member traffic. VERIFY_CA / VERIFY_IDENTITY also check certificates, which needs certificates from your CA on each member |
ipAllowlist | Only these networks can join the group |
consistency: BEFORE_ON_PRIMARY_FAILOVER | After a failover, the new primary holds new transactions until it has applied its backlog, so clients never read stale data from it |
expelTimeout | Seconds a member can be unreachable before it's expelled. Higher values ride out brief network blips but delay failover |
autoRejoinTries | How often an expelled member tries to rejoin by itself |
exitStateAction: OFFLINE_MODE | A member that leaves the group unexpectedly refuses client connections, instead of serving stale reads |
Check option names and allowed values with \help dba.createCluster in your Shell version. Newer versions also support certificate-based member authentication (memberAuthType).
Change options later with cluster.setOption() (whole cluster) or cluster.setInstanceOption() (one member), for example to set election priority:
cluster.setInstanceOption('db2:3306', 'memberWeight', 80) // preferred next primary
cluster.setInstanceOption('db3:3306', 'memberWeight', 20)
Module 4: MySQL Router in production
Create a dedicated account for Routers (don't bootstrap with the admin account):
cluster.setupRouterAccount('router_app1')
On app1, bootstrap as a system service with that account:
sudo mysqlrouter --bootstrap clusteradmin@db1:3306 \
--account router_app1 \
--user mysqlrouter \
--conf-bind-address 127.0.0.1 \
--name app1-router
sudo systemctl enable --now mysqlrouter
The bootstrap account is used once to register the Router; --account is what Router uses afterwards to read cluster metadata.
Points that matter in production:
- Bind to localhost when Router runs on the application host, so only that host's app can use it.
- TLS: Router can terminate client TLS and use TLS to the members; check
client_ssl_modeandserver_ssl_modein the generatedmysqlrouter.conf. - Connection pooling and read/write splitting (port 6450) exist in recent versions. Test your application with them before enabling.
- List and tidy Routers from Shell:
cluster.listRouters()
cluster.removeRouterMetadata('app-old::app-old-router') // routers that no longer exist
Application side: connection retries with backoff, and short transactions. After a failover, existing connections are closed; the driver must reconnect.
Module 5: monitoring
Health
SELECT MEMBER_HOST, MEMBER_STATE, MEMBER_ROLE, MEMBER_VERSION
FROM performance_schema.replication_group_members;
Alert when:
- Any member isn't
ONLINEfor more than a minute or two. - The number of
ONLINEmembers drops below 3 (you've lost failure tolerance). - No member is
PRIMARY.
Replication lag and flow control
SELECT MEMBER_ID,
COUNT_TRANSACTIONS_IN_QUEUE AS certify_queue,
COUNT_TRANSACTIONS_REMOTE_IN_APPLIER_QUEUE AS apply_queue,
COUNT_CONFLICTS_DETECTED AS conflicts
FROM performance_schema.replication_group_member_stats;
A growing apply_queue on one secondary means it can't keep up (slow disk, big transactions). When queues pass the flow control thresholds (group_replication_flow_control_applier_threshold, ..._certifier_threshold), Group Replication slows the primary down to let the secondary catch up. Users see that as slower writes, not as an error. Monitor the queues so you know why.
From Shell
cluster.status({extended: 1}) // includes transaction sets, versions, recovery info
cluster.describe() // topology
cluster.options() // effective options
Feed these into your monitoring through MySQL Enterprise Monitor, Percona Monitoring and Management, or Prometheus mysqld_exporter (which exposes the Group Replication tables).
Large transactions
Group Replication certifies whole transactions. A 5-million-row DELETE becomes one huge message that can stall the group or exceed limits. Cap and batch:
SHOW VARIABLES LIKE 'group_replication_transaction_size_limit';
-- batch big deletes
DELETE FROM shop.audit_log WHERE created_at < '2026-01-01' LIMIT 10000; -- repeat until 0 rows
Module 6: planned maintenance
Rolling restart or OS patching
- Patch secondaries one at a time. Before each, check the cluster is
OK; after each, wait for the member to beONLINE. - Switch the primary to a patched member:
cluster.setPrimaryInstance('db2:3306'). - Patch the old primary.
Rolling MySQL upgrade (for example 8.4.x to a newer 8.4.y)
- Upgrade MySQL Shell and MySQL Router first. They must be the same or a newer version than the servers.
- Then upgrade secondaries, switch over, upgrade the old primary.
- After all members run the new version, run
dba.upgradeMetadata()if Shell reports the metadata schema needs it. - During a mixed-version period, a member on a newer version can't be primary while older members exist in some cases; Group Replication picks the lowest-version member as primary. Keep the mixed period short.
Module 7: failure recovery
Practise each of these in the lab.
A member was expelled or restarted and didn't rejoin
cluster.status() // member shows (MISSING) or ERROR
cluster.rejoinInstance('clusteradmin@db3:3306')
If rejoin fails because the member has errant transactions (GTIDs the rest of the cluster doesn't have, usually from someone writing to it directly while it was out), don't try to patch GTID sets by hand. Re-provision it:
cluster.removeInstance('db3:3306', {force: true})
cluster.addInstance('clusteradmin@db3:3306', {recoveryMethod: 'clone'})
Then find out who wrote to it, and why super_read_only didn't stop them.
Lost quorum (majority down, minority still running)
Symptoms: status: NO_QUORUM, writes hang or fail. If the missing members really are down (and can't come back with diverging data), force the surviving partition to form a new group:
\connect clusteradmin@db1:3306
var cluster = dba.getCluster()
cluster.forceQuorumUsingPartitionOf('clusteradmin@db1:3306')
Caution
If the "missing" members are actually alive on the other side of a network split and still have quorum there, forcing quorum here creates two primaries. Confirm the other members are stopped or fenced (shut down, or firewalled) first.
Afterwards, rejoin or re-add the other members.
Complete outage (all members stopped, for example a power cut)
Group Replication doesn't start a cluster automatically when every member was down; there's no running group to rejoin. Start MySQL on all members, then:
\connect clusteradmin@db1:3306
var cluster = dba.rebootClusterFromCompleteOutage('prodCluster')
cluster.status()
Shell checks which member has the most transactions (GTID set) and refuses if you picked a less up-to-date one. Let it choose; don't override that check unless you understand the data loss.
Metadata out of step
If members were changed outside Shell (for example Group Replication started by hand):
cluster.rescan()
Module 8: backups and point-in-time recovery
High availability isn't a backup: a DROP TABLE replicates to every member in milliseconds.
- Take backups from a secondary to keep load off the primary.
- Options: MySQL Enterprise Backup, Percona XtraBackup (physical; fastest restore), or MySQL Shell dump utilities (logical; portable):
// logical dump from a secondary, parallel and compressed
\connect clusteradmin@db3:3306
util.dumpInstance('/backup/mysql/2026-10-27', {threads: 8, compression: 'zstd'})
- Keep binary logs long enough for point-in-time recovery (
binlog_expire_logs_seconds), and copy them off the servers. - Restore drill: restore the latest backup to a standalone server, apply binary logs up to just before the bad statement (
mysqlbinlog --stop-datetimeor by GTID), and extract or swap in the data. Test this quarterly.
To rebuild a whole cluster from a backup: restore to one server, dba.createCluster() on it, and add the others with clone.
Module 9: disaster recovery with InnoDB ClusterSet
A ClusterSet links the primary cluster to one or more replica clusters in other regions by asynchronous replication. Only one cluster accepts writes.
\connect clusteradmin@db1:3306
var cluster = dba.getCluster()
var cs = cluster.createClusterSet('prodSet')
// create the DR cluster on db4 (prepared like db1-3), then grow it
var dr = cs.createReplicaCluster('clusteradmin@db4:3306', 'drCluster', {recoveryMethod: 'clone'})
dr.addInstance('clusteradmin@db5:3306', {recoveryMethod: 'clone'})
dr.addInstance('clusteradmin@db6:3306', {recoveryMethod: 'clone'})
cs.status({extended: 1})
Router with a ClusterSet: re-bootstrap Routers (or bootstrap new ones) against the ClusterSet; by default they follow the primary cluster. You can pin a Router to a specific cluster:
cs.listRouters()
cs.setRoutingOption('app1-router', 'target_cluster', 'primary')
Planned switchover (both regions healthy)
cs.setPrimaryCluster('drCluster')
cs.status()
No data loss: Shell waits for the replica cluster to catch up, then swaps roles. Routers following primary move automatically.
Failover (primary region lost)
\connect clusteradmin@db4:3306
var cs = dba.getClusterSet()
cs.forcePrimaryCluster('drCluster')
Asynchronous replication means recent transactions may be lost. Check cs.status() lag regularly so you know how much. When the old region returns, its cluster is marked invalidated; bring it back as a replica:
cs.rejoinCluster('prodCluster')
If it has transactions the new primary doesn't have, it can't rejoin; you'll need to rebuild it (and decide what to do with those transactions).
Read replicas
For extra read capacity without affecting quorum, add read replicas (asynchronous replicas managed by the cluster):
cluster.addReplicaInstance('clusteradmin@reports1:3306')
Router can send read-only traffic to them, depending on routing options.
Exercises
- Set
memberWeightso thatdb3is the preferred next primary. Kill the primary'smysqldand confirmdb3takes over. - Run a single
INSERT ... SELECTthat copies 2 million rows, while watchingreplication_group_member_stats. Note the queue sizes and the effect on other writes. Then do it in batches of 10,000. - Firewall
db3off from the other two members for longer thanexpelTimeout. Watch it get expelled and, after removing the firewall rule, rejoin automatically withinautoRejoinTries. - Stop all three members. Bring the cluster back with
rebootClusterFromCompleteOutage(). Which member did Shell choose, and why? - Write to a secondary directly after turning off
super_read_only(lab only), then tryrejoinInstanceafter a restart. Read the error, and recover the member withclone. - Create the ClusterSet, perform
setPrimaryClusterto the DR cluster and back, timing how long your write loop sees errors. - Restore last night's dump to a standalone server and recover to a point 10 minutes before a deliberate
DROP TABLE.
Knowledge check
- Why should all members of one cluster be in the same region, and how do you protect against losing the region?
- What does
consistency: BEFORE_ON_PRIMARY_FAILOVERprotect against? - What is flow control, and how would users notice it?
- When is
forceQuorumUsingPartitionOf()safe, and what's the risk? - Why can't a member with errant transactions simply rejoin, and what's the clean fix?
- What's the difference between
setPrimaryCluster()andforcePrimaryCluster()? - In which order do you upgrade Shell, Router and the servers?
Answers
- Every commit waits for a majority of members, so cross-region latency would be added to each write. Protect against region loss with a ClusterSet replica cluster in another region (asynchronous).
- Reading stale data from a newly elected primary that hasn't yet applied all transactions it received before the failover.
- When a secondary's queues exceed thresholds, Group Replication throttles the primary so the secondary can catch up. Users see slower writes and higher latency, not errors.
- Only when the members outside the surviving partition are definitely down or fenced. If they're alive and still form a majority elsewhere, you get two primaries (split brain) and diverging data.
- Its GTID set contains transactions the group doesn't have, so joining would make the data inconsistent. Remove it and re-add it with
recoveryMethod: 'clone', after investigating who wrote to it. setPrimaryCluster()is a planned switchover that waits for the target to catch up (no data loss).forcePrimaryCluster()promotes a replica cluster when the primary is unavailable, accepting the loss of transactions that hadn't replicated.- MySQL Shell and MySQL Router first, then the server members (secondaries, switchover, old primary), then
dba.upgradeMetadata()if needed.
Summary
- Design first: three or five members in one region across zones, single-primary, ClusterSet for another region.
- Prepare instances with
configureInstance, a dedicated admin account, GTIDs, InnoDB-only tables with primary keys. - Harden with TLS, allow-lists and sensible
consistency,expelTimeoutandexitStateActionsettings. - Run Router next to the application with its own account; make applications retry.
- Monitor member state and Group Replication queues; batch large transactions.
- Know the recovery commands -
rejoinInstance, clone re-provisioning,forceQuorumUsingPartitionOf,rebootClusterFromCompleteOutage- and practise them. - Back up from a secondary, keep binary logs, and drill point-in-time recovery.
- Upgrade tools first, then members in a rolling order.
Further reading
- MySQL documentation: MySQL InnoDB Cluster, InnoDB ClusterSet, Group Replication (requirements, flow control, consistency), MySQL Router.
- On this site: the beginner session, the InnoDB Cluster setup quick reference, choosing MySQL high availability, cross-region async replica DR and MySQL 8.0 to 8.4 LTS upgrade.