MySQL MGR Cluster
MGR (MySQL Group Replication) provides multi-node state synchronization, supporting automatic member management and fault detection. It is recommended as the primary HA solution for production environments.
This guide describes how to build a Single-Primary mode cluster based on native Group Replication across three nodes and configure MySQL Router for transparent access.
Cluster Node Planning
| Server IP | Host Role | Services Deployed |
|---|---|---|
| 10.206.0.2 | Primary (Bootstrap Node) | MySQL, MySQL Router, MySQL Shell |
| 10.206.0.3 | Secondary | MySQL, MySQL Router, MySQL Shell |
| 10.206.0.4 | Secondary | MySQL, MySQL Router, MySQL Shell |
MySQL Service Deployment
This section covers the steps for deploying MySQL via binary packages. To ensure basic cluster consistency, the following steps must be fully executed on each node in the cluster.
-
Download the installation package
wget https://pdpublic.mingdao.com/private-deployment/offline/common/mysql-8.4.10-linux-glibc2.17-x86_64.tar.xz -
Extract the package and move it to the installation path
tar -xvf mysql-8.4.10-linux-glibc2.17-x86_64.tar.xzmv mysql-8.4.10-linux-glibc2.17-x86_64 /usr/local/mysql -
Add MySQL to the
PATHenvironment variableecho 'export PATH=/usr/local/mysql/bin:$PATH' > /etc/profile.d/mysql.shsource /etc/profile.d/mysql.sh -
Create the system user and required directories
useradd -U -r -s /sbin/nologin mysqlmkdir -p /data/mysqlmkdir -p /data/logs/mysqlchown -R mysql:mysql /data/mysql /data/logs/mysql /usr/local/mysql/ -
Create the
/etc/my.cnfconfiguration fileKey Configuration PointsBefore writing the following content to the server, ensure you modify these parameters based on the actual environment of the current node:
server-id: Must be unique for each node; sequential values are recommended (for example, 1, 2, and 3).report_host: Enter the actual intranet IP address of the current server.group_replication_local_address: Enter the actual intranet IP address of the current server. The default port is 33061.group_replication_group_seeds: Enter the list of IP addresses and ports for all three nodes in the cluster.
cat > /etc/my.cnf <<'EOF'[mysqld]user = mysqlbasedir = /usr/local/mysqldatadir = /data/mysqlsocket = /usr/local/mysql/mysqld.sockpid-file = /usr/local/mysql/mysqld.pidlog-error = /data/logs/mysql/mysqld.logserver-id = 1bind-address = 0.0.0.0port = 3306mysqlx_port = 33060skip-name-resolve = ONmax_connections = 5000default_storage_engine = InnoDBinnodb_buffer_pool_size = 2Gcharacter_set_server = utf8mb4collation_server = utf8mb4_0900_ai_cislow_query_log = 1slow_query_log_file = /data/logs/mysql/mysql-slow.loglong_query_time = 1log_bin = /data/mysql/mysql-binsync_binlog = 1binlog_expire_logs_seconds = 2592000# --- GTID & Replication ---gtid_mode = ONenforce_gtid_consistency = ONlog_replica_updates = ONreplica_parallel_workers = 4replica_preserve_commit_order = ON# --- Group Replication (MGR) ---report_host = 10.206.0.2report_port = 3306plugin_load_add = 'group_replication.so'group_replication_group_name = "c9f6d3f2-7b21-4e5a-9c87-3a0e9f0a43d2"group_replication_bootstrap_group = OFFgroup_replication_start_on_boot = OFFgroup_replication_local_address = "10.206.0.2:33061"group_replication_group_seeds = "10.206.0.2:33061,10.206.0.3:33061,10.206.0.4:33061"group_replication_single_primary_mode = ONgroup_replication_enforce_update_everywhere_checks = OFFgroup_replication_recovery_get_public_key = ONsuper_read_only = ON[client]port = 3306socket = /usr/local/mysql/mysqld.sock[mysql]default-character-set = utf8mb4EOF -
Configure the systemd management file
cat > /etc/systemd/system/mysql.service <<'EOF'[Unit]Description=MySQL Database ServerDocumentation=man:mysqld(8) http://dev.mysql.com/doc/After=network.targetWants=network-online.targetAfter=network-online.target[Service]Environment=MYSQLD_PARENT_PID=1User=mysqlGroup=mysqlType=forkingPIDFile=/usr/local/mysql/mysqld.pidExecStart=/usr/local/mysql/bin/mysqld --defaults-file=/etc/my.cnf --daemonizeExecStop=/bin/kill -s SIGTERM $MAINPIDLimitNOFILE=102400LimitMEMLOCK=infinityRestart=on-failureOOMScoreAdjust=-500TimeoutSec=0[Install]WantedBy=multi-user.targetEOF -
Initialize MySQL
mysqld --no-defaults --initialize --datadir=/data/mysql/ --user=mysql --log-error=/data/logs/mysql/mysqld.log -
Start MySQL
systemctl daemon-reloadsystemctl enable mysqlsystemctl start mysql -
Log in to MySQL and change the initial password
Log in using the temporary password generated during initialization:
mysql -uroot -p$(grep 'temporary password' /data/logs/mysql/mysqld.log | awk '{print $NF}')After logging in, run the following commands in MySQL:
-- Change the current root user passwordALTER USER USER() IDENTIFIED BY '123456';-- Create a root user that allows remote connectionsSET SQL_LOG_BIN=0;CREATE USER 'root'@'%' IDENTIFIED BY '123456';GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;SET SQL_LOG_BIN=1;- The root user password set in the command is
123456; a strong password must be used in actual deployments. - If the password contains special characters, only
-or_are allowed. Characters such as@ ! # &are prohibited to avoid compatibility issues.
- The root user password set in the command is
Build MGR Cluster
- All-node Preparation: Creating the synchronization user, configuring channel credentials, and other basic configuration steps must be performed on all nodes.
- Bootstrapping and Grouping: Cluster initialization (Bootstrap) is performed only on the first node. The other nodes only perform the join operation.
-
Create the replication synchronization user
Run the following commands on each node to create the account used for Group Replication synchronization:
SET SQL_LOG_BIN=0;CREATE USER 'repl'@'%' IDENTIFIED BY '123456';GRANT REPLICATION SLAVE, CONNECTION_ADMIN, BACKUP_ADMIN, GROUP_REPLICATION_STREAM ON *.* TO 'repl'@'%';SET SQL_LOG_BIN=1;- The password for the Group Replication synchronization account
replset in the command is123456; a strong password must be used in actual deployments. - If the password contains special characters, only
-or_are allowed. Characters such as@ ! # &are prohibited to avoid compatibility issues.
- The password for the Group Replication synchronization account
-
Configure the Group Replication recovery channel
Run the following command on each node to configure the Group Replication recovery channel credentials. Replace the password with the actual password of the synchronization account
repl:CHANGE REPLICATION SOURCE TO SOURCE_USER='repl', SOURCE_PASSWORD='123456' FOR CHANNEL 'group_replication_recovery'; -
Start and initialize the MGR group
The MGR cluster must be bootstrapped by one node before the other nodes can join. Follow the sequence below carefully:
-
A. First node (bootstrap initialization): Run the following commands to enable bootstrap mode and initialize the cluster:
SET GLOBAL group_replication_bootstrap_group=ON;START GROUP_REPLICATION;SET GLOBAL group_replication_bootstrap_group=OFF; -
B. Other nodes (join the existing cluster): After the first node has been initialized successfully, run the following command on the remaining nodes to join the group:
START GROUP_REPLICATION;
-
-
Check the group member information
SELECT * FROM performance_schema.replication_group_members;The normal status should show all nodes with
MEMBER_STATE = ONLINE, with only onePRIMARYnode and the others asSECONDARYnodes. -
Change the
group_replication_start_on_bootparameter in/etc/my.cnftoONRun this on each node. This parameter is set to
OFFduring initialization to prevent bootstrap conflicts. After the cluster is established, enable it so that nodes automatically rejoin the MGR replication group after a restart:sed -ri 's|group_replication_start_on_boot.*|group_replication_start_on_boot = ON|g' /etc/my.cnfgrep group_replication_start_on_boot /etc/my.cnf -
Perform a rolling restart and verify node status
Run the following commands on each node in sequence. After restarting one node, confirm that its status has returned to
ONLINEbefore proceeding to the next node:# 1. Restart the servicesystemctl restart mysql# 2. Check node status (ensure MEMBER_STATE is ONLINE for all currently active nodes)mysql -uroot -p123456 -e 'SELECT * FROM performance_schema.replication_group_members;'
Enable InnoDB Cluster
- All-node Operations: Download and install the MySQL Shell (
mysqlsh) package on all nodes. - Single-node Operations: Initialize InnoDB Cluster only once on the first node (Primary).
-
Download the mysqlsh installation package
wget https://pdpublic.mingdao.com/private-deployment/offline/common/mysql-shell-8.4.10-linux-glibc2.17-x86-64bit.tar.gz -
Extract the package and move it to the installation path
tar -xvf mysql-shell-8.4.10-linux-glibc2.17-x86-64bit.tar.gzmv mysql-shell-8.4.10-linux-glibc2.17-x86-64bit /usr/local/mysql-shell -
Add mysqlsh to the
PATHenvironment variableecho 'export PATH=/usr/local/mysql-shell/bin:$PATH' > /etc/profile.d/mysql-shell.shsource /etc/profile.d/mysql-shell.sh -
Connect to the PRIMARY node (first node only)
mysqlsh --uri root@10.206.0.2:3306 -p123456 -
Initialize and create InnoDB Cluster
var cluster = dba.createCluster('hap-mysql', {adoptFromGR: true}) -
View cluster status
cluster.status()- After logging in again, run
var cluster = dba.getCluster()before usingcluster.status().
- After logging in again, run
MySQL Router Deployment
To provide HA for the access layer, perform the following steps separately on each node in the cluster.
-
Download the installation package
wget https://pdpublic.mingdao.com/private-deployment/offline/common/mysql-router-8.4.10-linux-glibc2.17-x86_64.tar.xz -
Extract the package and move it to the installation path
tar -xvf mysql-router-8.4.10-linux-glibc2.17-x86_64.tar.xzmv mysql-router-8.4.10-linux-glibc2.17-x86_64 /usr/local/mysql-router -
Add MySQL Router to the
PATHenvironment variableecho 'export PATH=/usr/local/mysql-router/bin:$PATH' > /etc/profile.d/mysql-router.shsource /etc/profile.d/mysql-router.sh -
Initialize mysqlrouter
mysqlrouter --bootstrap root:123456@10.206.0.2:3306 --user=mysql --report-host=10.206.0.2Note:
- The IP address after
--bootstrapis the Primary node IP, which is used to read the cluster topology. Use the same Primary address on all cluster nodes. - The IP address after
--report-hostis the intranet IP of the current node. Change it to the actual IP of each node.
- The IP address after
-
Increase the MySQL Router maximum connection limit
sed -i '/^\[DEFAULT\]$/a max_total_connections=5000' /usr/local/mysql-router/mysqlrouter.conf -
Configure permissions
chown -R mysql:mysql /usr/local/mysql-router -
Configure systemd
cat > /etc/systemd/system/mysqlrouter.service <<'EOF'[Unit]Description=MySQL Router ServiceDocumentation=man:mysqlrouter(1) https://dev.mysql.com/doc/mysql-router/en/Wants=network-online.targetAfter=network-online.target[Service]Type=simpleUser=mysqlGroup=mysqlWorkingDirectory=/usr/local/mysql-routerExecStart=/usr/local/mysql-router/bin/mysqlrouter -c /usr/local/mysql-router/mysqlrouter.confRestart=on-failureRestartSec=2LimitNOFILE=102400OOMScoreAdjust=-500TimeoutSec=0[Install]WantedBy=multi-user.targetEOF -
Start mysqlrouter
systemctl daemon-reloadsystemctl enable mysqlroutersystemctl start mysqlrouter -
Test the connection
Verify the connection through MySQL Router's read-write port 6446 on the current node:
mysql -h 127.0.0.1 -P 6446 -u root -p123456
MySQL MGR Auto Recovery
If half or more nodes in an MGR cluster fail simultaneously, the cluster automatically enters a Complete Outage (OFFLINE/RECOVERING) state to prevent a split-brain condition. Even if the node service processes remain normal, MGR cannot normally form the group again without manual intervention.
The purpose of the auto-recovery script is to periodically detect this condition and, when the cluster is completely interrupted, select the node with the latest GTID progress to bootstrap recovery and provide self-healing. Complete the following steps on each node.
-
Create the directory and grant permissions
# 1. Create the log directorymkdir -p /data/logs/mysql# 2. Ensure that the directory is owned by the mysql user (or adjust it for your actual running user)chown -R mysql:mysql /data/logs/mysql# 3. Set directory permissionschmod 755 /data/logs/mysql -
Create the core detection and recovery script
/usr/local/bin/mysql-auto-recover.shNote: Be sure to check and modify the parameters in the
Core Configurationsection below, such as the password and node IP list, so that they match your actual deployment environment.cat > /usr/local/bin/mysql-auto-recover.sh <<'EOF'#!/bin/bashset -euo pipefail # Strict mode# ============================ Core Configuration ============================MYSQL_CLI="/usr/local/mysql/bin/mysql" # MySQL client file pathMYSQL_SHELL="/usr/local/mysql-shell/bin/mysqlsh" # MySQL Shell file pathMYSQL_USER="root" # MySQL database administrator usernameMYSQL_PASS="123456" # MySQL database administrator passwordCLUSTER_NAME="hap-mysql" # MGR (InnoDB Cluster) nameLOG_FILE="/data/logs/mysql/mysql-auto-recover.log" # Auto-recovery script log pathLOCK_FILE="/data/logs/mysql/mysql-auto-recover.lock" # Process exclusive lock file pathNODES=("10.206.0.2" "10.206.0.3" "10.206.0.4") # IP addresses of all MGR cluster nodes, space-separated and matching the cluster planLOG_KEEP_DAYS=180 # Local log retention period in daysTRIES=3 # Consecutive retry count for failure confirmationSLEEP_SEC=2 # Wait/buffer time between failure-detection retries, in seconds# ============================ Log Functions ============================# Unified log format: [YYYY-MM-DD HH:MM:SS] [LEVEL] [PID] message# Levels: INFO (normal event) / WARN (abnormal but recoverable) / ERROR (recovery failure requiring attention)_log() {local level="$1"; shiftprintf '[%s] [%-5s] [pid=%d] %s\n' "$(date '+%F %T')" "$level" "$$" "$*" >> "$LOG_FILE"}log_info() { _log "INFO" "$@"; }log_warn() { _log "WARN" "$@"; }log_error() { _log "ERROR" "$@"; }# Add a unified prefix to each stdout/stderr line from external processes such as mysqlsh before writing it to disk.# Empty lines and known low-value warning noise are filtered to keep the log clean and readable.log_pipe() {local tag="$1"local linewhile IFS= read -r line; do[ -z "$line" ] && continuecase "$line" in*"Using a password on the command line"*) continue ;;*"Cannot set LC_ALL to locale"*) continue ;;esac_log "INFO" "[$tag] $line"done}# ============================ Basic Functions ============================# Concurrency control: ensure that only one detection process runs at a time.exec_lock() {exec 9>"$LOCK_FILE"if ! flock -n 9; thenlog_warn "Another instance is already running; exiting."exit 0fi}# Log rotation and cleanup: split logs by day and point the main log path to the current day's file through a symbolic link.rotate_logs() {local log_file="$1"local keep_days="${2:-180}"local log_dir base_name today today_loglog_dir=$(dirname "$log_file")base_name=$(basename "$log_file" .log)today=$(date +%F)today_log="${log_dir}/${base_name}_${today}.log"# Create today's log file and point the main log path to it when it does not exist.if [ ! -f "$today_log" ]; then: > "$today_log"ln -sfn "$today_log" "$log_file"fi# Repair the symlink if the main log is not a symlink or does not point to today's file.if [ ! -L "$log_file" ] || [ "$(readlink -f "$log_file" 2>/dev/null || true)" != "$today_log" ]; thenln -sfn "$today_log" "$log_file"fi# Clean up expired logs in the current directory only to avoid accidental deletion.find "$log_dir" -maxdepth 1 -name "${base_name}_*.log" -type f -mtime +"$keep_days" -exec rm -f {} \; 2>/dev/null || true}check_node_alive() {local node="$1"if "$MYSQL_CLI" -h "$node" -u"$MYSQL_USER" -p"$MYSQL_PASS" \--connect-timeout=3 -e "SELECT 1;" >/dev/null 2>&1; thenreturn 0elselog_warn "Node $node is unreachable."return 1fi}check_all_nodes_alive() {local ok=0for node in "${NODES[@]}"; doif check_node_alive "$node"; thenok=$((ok+1))fidoneecho "$ok"}# Return value: non-negative integer = ONLINE member count; -1 = query failed (different from a real 0).get_cluster_online_count() {local resultresult=$("$MYSQL_CLI" -u"$MYSQL_USER" -p"$MYSQL_PASS" \--connect-timeout=3 -N -e \"SELECT COUNT(*) FROM performance_schema.replication_group_members WHERE MEMBER_STATE='ONLINE';" 2>/dev/null) || {echo "-1"return 0}if [[ "$result" =~ ^[0-9]+$ ]]; thenecho "$result"elseecho "-1"fi}# Count committed transactions in gtid_executed. Supported forms:# 1) Single transaction: uuid:1000 -> 1# 2) Range: uuid:1-1000 -> 1000# 3) Multiple ranges: uuid:1-100:200-500 -> 100 + 301 = 401# 4) Multiple sources: uuid1:1-100,uuid2:1-200 -> 100 + 200 = 300# Key point: split by ":" and skip the UUID segment to avoid counting digits or hyphens in UUIDs as transactions.# Use the total transaction count rather than the maximum sequence number to select the primary node.# This correctly handles nodes with the same highest range but different lower-range sizes# (for example, 1-53986:1000005-1000010 versus 1-53988:1000005-1000010).count_gtid_transactions() {local gtid="$1"[ -z "$gtid" ] && { echo 0; return 0; }echo "$gtid" | awk 'BEGIN { total = 0 }{# Remove all whitespace characters.gsub(/[ \t\r\n]/, "", $0)# Multiple sources are separated by commas.n = split($0, sources, ",")for (i = 1; i <= n; i++) {# Each source has the form uuid:range[:range...]; the first UUID segment must be skipped.m = split(sources[i], parts, ":")for (j = 2; j <= m; j++) {seg = parts[j]if (seg ~ /^[0-9]+$/) {# Single transaction.total += 1} else if (seg ~ /^[0-9]+-[0-9]+$/) {# Closed interval [start, end]; transaction count = end - start + 1.k = split(seg, range, "-")start = range[1] + 0end = range[k] + 0if (end >= start) total += (end - start + 1)}}}print total}'}find_primary_node() {declare -A TX_MAPlocal max_tx=0local primary=""local node gtid txfor node in "${NODES[@]}"; dogtid=$("$MYSQL_CLI" -h "$node" -u"$MYSQL_USER" -p"$MYSQL_PASS" \--connect-timeout=3 -N -e \"SELECT @@GLOBAL.gtid_executed;" 2>/dev/null) || gtid=""tx=$(count_gtid_transactions "$gtid")TX_MAP[$node]=$txlog_info "Node $node GTID: executed=[$gtid] transactions=$tx"if [ "$tx" -gt "$max_tx" ]; thenmax_tx=$txprimary=$nodefidone# If all node GTIDs are 0 (a new empty cluster or all queries failed), use the first node by default.[ -z "$primary" ] && primary="${NODES[0]}"log_info "Primary candidate elected: $primary (transactions=$max_tx)"echo "$primary"}# Check whether the current host IP matches the target node (supports multiple NICs and exact matching).is_local_host() {local target="$1"local ipfor ip in $(hostname -I 2>/dev/null); do[ "$ip" = "$target" ] && return 0donereturn 1}do_recovery() {local my_ip rcmy_ip=$(hostname -I | awk '{print $1}')log_info "This node ($my_ip) is the recovery target; invoking reboot_cluster_from_complete_outage."set +e# Set LC_ALL / LANG to C.UTF-8 to avoid mysqlsh locale warnings on systems without en_US.UTF-8.# Redirect all output to log_pipe so that it is written with the [MYSQLSH] prefix.# Use PIPESTATUS[0] to capture the mysqlsh exit code itself rather than log_pipe's 0.timeout 60 env LC_ALL=C.UTF-8 LANG=C.UTF-8 \"$MYSQL_SHELL" --no-wizard --py \--user="$MYSQL_USER" --password="$MYSQL_PASS" \--host=127.0.0.1 <<PYEOF 2>&1 | log_pipe "MYSQLSH"import systry:cluster = dba.reboot_cluster_from_complete_outage('$CLUSTER_NAME', {'force': True})print("Cluster rebooted successfully.")except Exception as e:print("Recovery failed:", e)sys.exit(1)PYEOFrc=${PIPESTATUS[0]}set -eif [ "$rc" -eq 0 ]; thenlog_info "Cluster reboot completed successfully."elif [ "$rc" -eq 124 ]; thenlog_error "Cluster reboot timed out after 60 seconds; will retry on next cycle."elselog_error "Cluster reboot failed (exit_code=$rc); will retry on next cycle."fi}# ============================ Main Flow ============================# Lock first, then rotate logs to avoid concurrent instances racing while updating symlinks or logs.exec_lockrotate_logs "$LOG_FILE" "$LOG_KEEP_DAYS"MY_IP=$(hostname -I | awk '{print $1}')log_info "===== MGR health check started (host=$MY_IP) ====="# Declare a complete cluster outage only after all nodes are reachable and ONLINE member count is 0 for TRIES consecutive checks.is_outage=falseconsecutive=0for attempt in $(seq 1 "$TRIES"); doreachable=$(check_all_nodes_alive)online_count=$(get_cluster_online_count)if [ "$reachable" -eq "${#NODES[@]}" ] && [ "$online_count" = "0" ]; thenconsecutive=$((consecutive + 1))log_warn "Attempt $attempt/$TRIES: outage signal confirmed ($consecutive/$TRIES) [reachable=$reachable, online=$online_count]"if [ "$consecutive" -ge "$TRIES" ]; thenis_outage=truebreakfielseif [ "$consecutive" -gt 0 ]; thenlog_info "Attempt $attempt/$TRIES: outage signal reset [reachable=$reachable, online=$online_count]"ficonsecutive=0fi# Do not sleep after the last attempt to avoid unnecessary waiting.[ "$attempt" -lt "$TRIES" ] && sleep "$SLEEP_SEC"doneif [ "$is_outage" = true ]; thenlog_warn "Complete cluster outage detected; initiating recovery procedure."PRIMARY_NODE=$(find_primary_node)if is_local_host "$PRIMARY_NODE"; thendo_recoveryelselog_info "This node ($MY_IP) is not the recovery target ($PRIMARY_NODE); standing by."fielselog_info "Cluster is healthy or precondition not met; no action taken."filog_info "===== MGR health check finished ====="EOFGrant execution permissions:
chmod +x /usr/local/bin/mysql-auto-recover.sh -
Configure the systemd service unit
Create a Service file defining how to run the script above.
cat > /etc/systemd/system/mysql-auto-recover.service <<'EOF'[Unit]Description=MySQL MGR Auto-recovery ServiceAfter=network.target mysql.service[Service]Type=oneshotExecStart=/usr/local/bin/mysql-auto-recover.shUser=rootTimeoutSec=0EOF -
Configure the systemd timer
Create a Timer file to run the script once every minute.
cat > /etc/systemd/system/mysql-auto-recover.timer <<'EOF'[Unit]Description=Run MySQL MGR Recovery check every minute[Timer]OnCalendar=*-*-* *:*:00Unit=mysql-auto-recover.servicePersistent=true[Install]WantedBy=timers.targetEOF -
Load the configuration and start the timer
# Reload the systemd configurationsystemctl daemon-reload# Enable and immediately start the timersystemctl enable --now mysql-auto-recover.timer -
Common commands
The following commands are commonly used to manage the auto-recovery service and timer:
# View the timer's next execution timesystemctl list-timers --all | grep mysql-auto-recover# View the live operation logtail -f /data/logs/mysql/mysql-auto-recover.log# Manually trigger a recovery check immediatelysystemctl start mysql-auto-recover.service# View the timer service statussystemctl status mysql-auto-recover.timer# Temporarily stop auto-recovery monitoringsystemctl stop mysql-auto-recover.timer