架設高可用的 MySQL Cluster 不只要讓多台 Slave 去接收 Master 的 Binlog 同步資料,還要做到當 Master Crush 時,Slave 能自動接替成為 Master 同時 Client 送出的寫入請求也能自動轉換。
那麼要如何建立多個 Slave 去監聽 Master 的 Binlog?
server-id : 該 server 的唯一識別號,主要用於 cluster 彼此識別身份,例如 slave 在拉取 binlog 時也會記錄該 binlog 是從哪個 server-id 來的,需要額外設定而不用 ip 或者 hostname 的原因在於如果 server 更換機器 ip & hostname 是可能改變的。
log-bin:開啟 binlog 。
log-bin-basename:指令 binlog 檔名的前綴,預設是 binlog,也可以透過指定 /foldera/folderb/mybinlog
來指定 binlog 儲存路徑,可將 binlog 儲存在不同硬碟避免影響查詢寫入的硬碟效能。
binlog-format : 設定 binlog 格式,例如 ROW。
gtid-mode:使用 gtid 作為 binlog 進度追蹤。
enforce_gtid_consistency:確保所有寫入 Master 的指令都是 gtid-safe 也就是重複執行一定會產生一樣的值,例如 UPDATE … LIMIT
不指定 ORDER BY
就不是 gtid-safe 。
log_replica_updates:當 server 為 slave 時 replay master 資料後會寫入其 binlog,當 slave 升級成 master 時,可以讓其他 slave 繼續接收 binlog 資料。
binlog_expire_logs_seconds:binlog 檔案多久要過期,避免硬碟塞爆。
bind-address**:**設定 server 傾聽的 ip 位置,建議為 0.0.0.0 才能讓 slave 透過 public ip 連進來。
而 Slave 的設定跟 Master 一樣,只是多了 read-only 確保他不能處理寫入請求。
services:
mysql1:
container_name: mysql1
image: mysql/mysql-server:8.0
platform: linux/amd64
networks:
- mysql_cluster
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_ROOT_HOST: "%"
MYSQL_DATABASE: local_test
MYSQL_USER: test
MYSQL_PASSWORD: test
volumes:
- ./data/mysql1:/var/lib/mysql
ports:
- 127.0.0.1:3306:3306
command:
[
"--server-id=1",
"--log-bin=mysql1-bin",
"--binlog-format=ROW",
"--gtid-mode=ON",
"--enforce-gtid-consistency=ON",
"--log-replica-updates=ON",
"--binlog-expire-logs-seconds=604800",
]
mysql2:
container_name: mysql2
image: mysql/mysql-server:8.0
platform: linux/amd64
networks:
- mysql_cluster
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_ROOT_HOST: "%"
MYSQL_DATABASE: local_test
MYSQL_USER: test
MYSQL_PASSWORD: test
volumes:
- ./data/mysql2:/var/lib/mysql
ports:
- 127.0.0.1:3307:3307
command:
[
"--read_only=ON",
"--port=3307",
"--server-id=2",
"--log-bin=mysql2-bin",
"--binlog-format=ROW",
"--gtid-mode=ON",
"--enforce-gtid-consistency=ON",
"--log-replica-updates=ON",
"--binlog-expire-logs-seconds=604800",
]
networks:
mysql_cluster:
driver: bridge
執行 docker compose -f ./mysql-cluster.yaml up -d
啟動 server 後,需要進入 slave db 設定 Master DB 連線資訊:
CHANGE REPLICATION SOURCE TO
SOURCE_HOST = 'mysql1',
SOURCE_PORT = 3306,
SOURCE_USER = 'root',
SOURCE_PASSWORD = 'secret',
SOURCE_AUTO_POSITION = 1;
START REPLICA;
SOURCE_AUTO_POSITION=1
代表使用 gtid 同,可執行 SHOW SLAVE STATUS;
檢查 Slave 同步狀況:
(作者產圖)
確認 Slave IO & SQL Thread 執行中,就沒問題了!
從頭建立 Cluster 簡單,但在既有的 Cluster 加入新 Slave 就有新的挑戰了。
當 Master Binlog 過期遺失後,新加入的 Slave 無法透過 Binlog 同步到完整資料時該怎麼辦?
第一個方法是邏輯備份,透過 mysqldump
將 Master DB 所有資料轉換成 SQL 指令輸出到特定檔案:
mysqldump -h 127.0.0.1 -u root --password=secret
--single-transaction \ <= 使用 snapshot transaction 讀資料
--quick \ <= 使用批次處理避免一次載入大量資料到記憶體
--skip-lock-tables \ <= 明確指定不要 Lock Table
--set-gtid-purged=ON \ <= 產生 GTID_PURGED 指令
local_test > backup.sql
執行後,會在 backup.sql
裡面看到 Master DB 將 Schema 以及資料轉成 CREATE TABLE
和 INSERT
的指令。
(作者產圖)
SQL_LOG_BIN=0
是避免執行下面指令時把資料寫入 binlog,由於是用 mysqldump
還原資料而不是 binlog replay,所以將 backup.sql
內容同步到 binlog 會造成 Cluster 內有相同 binlog 內容但不同 gtid 的情況。
設定 GTID_PURGED
為mysqldump
拉資料當下 Master 已完成的 GTID Set,讓 Slave 在同步完 backup.sql
內容可以直接啟動 Replica 同步後續 binlog 資料,避開重複資料。
邏輯備份較耗時且資料量大時產生的 backup.sql 內容會特別大且複雜,備份過程也會消耗 master db 的 CPU。
第二個方法是物理備份,直接複製 MySQL datadir 底下資料,但備份方式不是單純執行 cp
指令就好,因為在複製的過程中,資料仍不斷在更新,單純複製會發生資料不一致的問題,例如 複製完前半段包含 id=100
的資料,隨後複製後半段 id=200
資料,此時同時更新了 id = 100 & id = 200
資料會導致 id=100
為舊資料 id=200
為新資料。
為了解決這個問題需要使用 Percona XtraBackup
工具,其備份資料的也包含 redo log 內容,在備份完後,透過指令去 Replay redo log 內容就能讓資料更新到最新狀態。
# 使用 percona-xtrabackup 從 master 複製檔案到 backup folder 中
docker run --rm \
--network compose_mysql_cluster \
--user=root \
-v ./data/mysql4:/backup \
--volumes-from mysql1 \
percona/percona-xtrabackup:8.0 \
xtrabackup --backup \
--target-dir=/backup \
--host=mysql \
--user=root \
--password=secret --no-lock
# replay redo log 更新資料到最新狀態
docker run --rm \
--network compose_mysql_cluster \
--user=root \
-v ./data/mysql4:/backup \
--volumes-from mysql1 \
percona/percona-xtrabackup:8.0 \
xtrabackup --prepare \
--target-dir=/backup \
--host=mysql \
--user=root \
--password=secret --no-lock
物理備份限制是版本跟設定要一致,避免出現對資料格式不兼容的情況,但備份速度比邏輯備份快上很多。
orchestrator 是一個 MySQL Cluster 管理工具,提供 GUI 管理 MySQL Server 的網路關係,並提供 Server 狀態追蹤以及 Auto Fail Over 的功能。
(圖來源: https://github.com/openark/orchestrator)
啟動 orchestrator 前要設定好配置:
基礎配置 - 連上 MySQL Server 的通用帳號密碼,以及 Orchestrator 用的 DB 配置,Orchestrator 會用額外 DB 來儲存 Cluster 資訊
"MySQLTopologyUser": "root", => 連上 mysql server 通用帳號
"MySQLTopologyPassword": "secret", => 連上 mysql server 通用密碼
"DefaultInstancePort": 3306, => 連上 mysql server 預設 port
"MySQLOrchestratorHost": "orchestrator_db", => orchestrator 儲存 cluster 資訊的 db host
"MySQLOrchestratorDatabase": "orchestrator", => orchestrator 儲存 cluster 資訊的 db name
"MySQLOrchestratorUser": "root", => orchestrator 儲存 cluster 資訊的 db 帳號
"MySQLOrchestratorPassword": "secret", => orchestrator 儲存 cluster 資訊的 db 密碼
"MySQLOrchestratorPort": 3309, => orchestrator 儲存 cluster 資訊的 db port
"ListenAddress": ":3000", => orchestrator admin gui port number
"InstancePollSeconds": 5,
"UnseenInstanceForgetHours": 1,
"HTTPAuthUser": "admin", => admin 帳號
"HTTPAuthPassword": "secret", => admin 密碼
服務發現配置 - Orchestrator 會透過 Master DB 主動發現 Slave,可在 GUI 畫面上設定一個 Master DB 連線 host & port ,並用兩種方式發現 Slave:
DiscoverByShowSlaveHosts
參數為 True - Orchestrator 會執行 show slave hosts
指令找 slaveDiscoverByShowSlaveHosts
參數為 False - Orchestrator 會執行 select substring_index(host, ‘:’, 1) as slave_hostname from information_schema.processlist where command IN (‘Binlog Dump’, ‘Binlog Dump GTID’)
Query 找 slave
當獲得 Master & Slave Host 後,透過 HostnameResolveMethod & MySQLHostnameResolveMethod 參數將 Host 解析成 IP:
SHOW SLAVE STATUS
指令儲存 Slave 與 Master 關連,但如果 Orchestrator 是用 IP 連上 Master,但 Slave 是用 container (e.g mysql1) 連上 Master,資料比對會有問題,因此要透過該參數設定解析 MySQL IP 轉成 mysql1,例如設定參數為 report_host 會執行 @@**global**.report_host
獲取 MySQL 環境變數中 report_host 設定。Auto Failover 配置 - Orchestrator 會定期檢查 Master 狀態,當有連線問題,且其他 Slave 也與他失聯後,就會啟動 Auto Failover:
reset slave all
& set read_only=0
指令將 slave 換成 master另外也可以設置 Hook 來通知 Orchestrator 正在執行 Fail Over:
"PreFailoverProcesses": [
"echo 'Will recover from {failureType} on {failureCluster}' >> /tmp/recovery.log"
],
"PostFailoverProcesses": [
"echo '(for all types) Recovered from {failureType} on {failureCluster}. Failed: {failedHost}:{failedPort}; Successor: {successorHost}:{successorPort}' >> /tmp/recovery.log"
],
可在 Cluster 前架設一個 Proxy SQL,透過 Proxy 自動分流,當 Master 替換成不同 Server,Proxy 也能自動偵測改變分流路線,程式端完全不需要修改配置。
Proxy SQL 除了分流 SQL 到不同 SQL Server 之外,還提供了:
Proxy SQL 本身自帶 SQLite 資料庫,會將 Cluster 連線資訊以及分流規則紀錄在裡面,此外也有很多系統參數可以微調行為,可以參考 https://proxysql.com/documentation/global-variables/。
以下提供基礎配置:
# 配置 sqlite 儲存路徑
datadir="/var/lib/proxysql"
# 配置 proxy sql admin 帳號密碼,以及模擬 mysql 介面的入口點
# 使用者透過登入 admin 帳號來調整 proxy sql 設定
admin_variables =
{
admin_credentials="admin:admin"
mysql_ifaces="0.0.0.0:6032"
}
# 設定 mysql server 監控用的帳號密碼
mysql_variables =
{
monitor_username="root"
monitor_password="secret"
}
# 設定叢集資訊,hostgroup 1 為 master 2 為 slave,設定完後
# proxysql 會定時監控 hostgroup 內的 mysql server read_only 參數
# 並調整到正確的 host group id
mysql_replication_hostgroups =
(
{
writer_hostgroup=1
reader_hostgroup=2
comment="cluster1"
}
)
# 設定 mysql server 連線資訊,hostgroup 可以都設定成 1
# 等 proxysql 透過 read_only 參數自行調整
mysql_servers =
(
{
address="mysql1"
port=3306
hostgroup=1
max_connections=200
},
{
address="mysql2"
port=3307
hostgroup=1
max_connections=200
},
{
address="mysql3"
port=3308
hostgroup=1
max_connections=200
}
)
# proxy sql 連上 mysql_servers 的帳號
# application server 同時也會用該組帳號連上 proxysql
# 再由 proxysql forward sql 到後面的 mysql server
mysql_users =
(
{
username = "root"
password = "secret"
default_hostgroup = 1
max_connections=1000
default_schema="information_schema"
active = 1
}
)
# 定義 query 分流規則,match_pattern 可以用 regex 去寫
# 不用擔心每次分流都要通過一次 regex match 影響效能
# proxy sql 會 cache 起來
mysql_query_rules =
(
{
rule_id=1
active=1
username="root"
match_pattern="^SELECT .* FOR UPDATE$"
destination_hostgroup=1
apply=1
},
{
rule_id=2
active=1
username="root"
match_pattern="^SELECT"
destination_hostgroup=2
apply=1
},
{
rule_id=3
active=1
username="root"
match_pattern="^INSERT|^UPDATE|^DELETE"
destination_hostgroup=1
apply=1
}
)
透過該配置啟動 Proxy SQL 後,可透過指令進入 ProxySQL Admin 查看連線狀況,並模擬 Client 連上 ProxySQL 的操作:
# 執行該指令進如 proxy sql admin 環境
docker exec -it proxysql mysql -h 127.0.0.1 -P 6032 -u admin -padmin --prompt "ProxySQL Admin>"
# 確認 Proxy SQL 有連上 MySQL Server 並正確解析 read_only 參數
ProxySQL Admin> SELECT * FROM monitor.mysql_server_read_only_log ORDER BY time_start_us DESC LIMIT 3;
+----------+------+------------------+-----------------+-----------+-------+
| hostname | port | time_start_us | success_time_us | read_only | error |
+----------+------+------------------+-----------------+-----------+-------+
| mysql1 | 3306 | 1749881831474244 | 768 | 0 | NULL |
| mysql3 | 3308 | 1749881831474126 | 963 | 1 | NULL |
| mysql2 | 3307 | 1749881831474044 | 879 | 1 | NULL |
+----------+------+------------------+-----------------+-----------+-------+
# 執行該指令模擬 application server 連上 proxy sql
docker exec -it proxysql mysql -h 127.0.0.1 -P 6033 -u root -psecret --prompt "ProxySQL Client>"
# 模擬 application server 發送 SQL 指令
ProxySQL Client> INSERT INTO local_test.orders (user_id, product_id) VALUES (1, 5);
ProxySQL Client> SELECT * FROM local_test.orders;
+----+---------+------------+---------------------+
| id | user_id | product_id | created_time |
+----+---------+------------+---------------------+
| 1 | 1 | 5 | 2025-06-14 06:20:54 |
+----+---------+------------+---------------------+
# 回到 admin 環境查詢 query 分流狀況
ProxySQL Admin> SELECT * FROM stats_mysql_query_digest ORDER BY sum_time DESC;
+-----------+--------------------+----------+----------------+--------------------+-----------------------------------------------------------------+------------+------------+------------+----------+----------+----------+-------------------+---------------+
| hostgroup | schemaname | username | client_address | digest | digest_text | count_star | first_seen | last_seen | sum_time | min_time | max_time | sum_rows_affected | sum_rows_sent |
+-----------+--------------------+----------+----------------+--------------------+-----------------------------------------------------------------+------------+------------+------------+----------+----------+----------+-------------------+---------------+
| 2 | information_schema | root | | 0xA4E457FBB3707B3B | select * from local_test.orders | 3 | 1749881995 | 1749882069 | 63229 | 2055 | 34286 | 0 | 1 |
| 1 | information_schema | root | | 0xF54FD08CEA17AFB4 | insert into local_test.orders (user_id,product_id) values (?,?) | 1 | 1749882054 | 1749882054 | 22081 | 22081 | 22081 | 1 | 0 |
+-----------+--------------------+----------+----------------+--------------------+-----------------------------------------------------------------+------------+------------+------------+----------+----------+----------+-------------------+---------------+
# 當原本 master 掛掉,orchestrator 執行 auto fail-over 後
# proxy sql 會自動偵測修改 read only flag
ProxySQL Admin> SELECT * FROM monitor.mysql_server_read_only_log ORDER BY time_start_us DESC LIMIT 3;
+----------+------+------------------+-----------------+-----------+-------+
| hostname | port | time_start_us | success_time_us | read_only | error |
+----------+------+------------------+-----------------+-----------+-------+
| mysql3 | 3308 | 1749882285238268 | 419 | 1 | NULL |
| mysql2 | 3307 | 1749882285238193 | 443 | 0 | NULL |
+----------+------+------------------+-----------------+-----------+-------+
# 回到 Client 端同一個連線
ProxySQL Client>INSERT INTO local_test.orders (user_id, product_id) VALUES (1, 6);
ProxySQL Client> SELECT * FROM local_test.orders;
+----+---------+------------+---------------------+
| id | user_id | product_id | created_time |
+----+---------+------------+---------------------+
| 1 | 1 | 5 | 2025-06-14 06:20:54 |
| 3 | 1 | 6 | 2025-06-14 06:25:54 |
+----+---------+------------+---------------------+
以下是完整 docker compose 以及 config 檔案,下面的 image 是在 arm 架構下 build 的,如果是 amd (e.g intel) 就要另外找 image。
services:
mysql1:
container_name: mysql1
image: mysql/mysql-server:8.0
platform: linux/amd64
networks:
- mysql_cluster
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_ROOT_HOST: "%"
MYSQL_DATABASE: local_test
MYSQL_USER: test
MYSQL_PASSWORD: test
volumes:
- ./data/mysql1:/var/lib/mysql
ports:
- 3306:3306
command:
[
"--port=3306",
"--server-id=1",
"--log-bin=mysql1-bin",
"--binlog-format=ROW",
"--gtid-mode=ON",
"--enforce-gtid-consistency=ON",
"--log-replica-updates=ON",
"--binlog-expire-logs-seconds=604800",
"--report-host=mysql1",
"--report-port=3306",
"--slave-preserve-commit-order=ON",
]
mysql2:
container_name: mysql2
image: mysql/mysql-server:8.0
platform: linux/amd64
networks:
- mysql_cluster
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_ROOT_HOST: "%"
MYSQL_DATABASE: local_test
MYSQL_USER: test
MYSQL_PASSWORD: test
volumes:
- ./data/mysql2:/var/lib/mysql
ports:
- 3307:3307
command:
[
"--read_only=ON",
"--port=3307",
"--server-id=2",
"--log-bin=mysql2-bin",
"--binlog-format=ROW",
"--gtid-mode=ON",
"--enforce-gtid-consistency=ON",
"--log-replica-updates=ON",
"--binlog-expire-logs-seconds=604800",
"--report-host=mysql2",
"--report-port=3307",
"--slave-preserve-commit-order=ON",
]
mysql3:
container_name: mysql3
image: mysql/mysql-server:8.0
platform: linux/amd64
networks:
- mysql_cluster
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_ROOT_HOST: "%"
MYSQL_DATABASE: local_test
MYSQL_USER: test
MYSQL_PASSWORD: test
volumes:
- ./data/mysql3:/var/lib/mysql
- ../backup.sql:/var/data/backup.sql
ports:
- 3308:3308
command:
[
"--read_only=ON",
"--port=3308",
"--server-id=3",
"--log-bin=mysql3-bin",
"--binlog-format=ROW",
"--gtid-mode=ON",
"--enforce-gtid-consistency=ON",
"--log-replica-updates=ON",
"--binlog-expire-logs-seconds=604800",
"--report-host=mysql3",
"--report-port=3308",
"--slave-preserve-commit-order=ON",
]
orchestrator:
image: vicxu/orchestrator:latest
container_name: orchestrator
ports:
- "3000:3000" # Web UI
- "3030:3030" # REST API
volumes:
- ./orchestrator.conf.json:/etc/orchestrator.conf.json
environment:
ORCHESTRATOR_API_PASSWORD: secret
networks:
- mysql_cluster
depends_on:
- orchestrator_db
orchestrator_db:
image: mysql/mysql-server:8.0
container_name: orchestrator_db
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_ROOT_HOST: "%"
MYSQL_DATABASE: orchestrator
MYSQL_USER: orchestrator
MYSQL_PASSWORD: orchestrator_password
volumes:
- ./data/orchestrator_db:/var/lib/mysql
networks:
- mysql_cluster
ports:
- "3309:3309"
command: ["--port=3309"]
proxysql:
image: proxysql/proxysql:3.0.1
container_name: proxysql
restart: always
volumes:
- ./proxysql.cnf:/etc/proxysql.cnf
- ./data/proxysql:/var/lib/proxysql
ports:
- "6032:6032" # admin
- "6033:6033" # client
networks:
- mysql_cluster
networks:
mysql_cluster:
driver: bridge
{
"Debug": true,
"MySQLTopologyUser": "root",
"MySQLTopologyPassword": "secret",
"DefaultInstancePort": 3306,
"MySQLOrchestratorHost": "orchestrator_db",
"MySQLOrchestratorDatabase": "orchestrator",
"MySQLOrchestratorUser": "root",
"MySQLOrchestratorPassword": "secret",
"MySQLOrchestratorPort": 3309,
"ListenAddress": ":3000",
"AuthenticationMethod": "basic",
"HTTPAuthUser": "admin",
"HTTPAuthPassword": "secret",
"InstancePollSeconds": 5,
"UnseenInstanceForgetHours": 1,
"DiscoverByShowSlaveHosts": true,
"HostnameResolveMethod": "none",
"MySQLHostnameResolveMethod": "report_host",
"PromotionIgnoreHostnameFilters": [],
"RecoverMasterClusterFilters": [
"*"
],
"RecoverIntermediateMasterClusterFilters": [
"*"
],
"ApplyMySQLPromotionAfterMasterFailover": true,
"PreventCrossDataCenterMasterFailover": false,
"PreventCrossRegionMasterFailover": false,
"FailMasterPromotionOnLagMinutes": 0,
"DelayMasterPromotionIfSQLThreadNotUpToDate": false,
"FailMasterPromotionIfSQLThreadNotUpToDate": true,
"MasterFailoverLostInstancesDowntimeMinutes": 10,
"DetachLostReplicasAfterMasterFailover": true,
"MasterFailoverDetachReplicaMasterHost": true,
"PostponeReplicaRecoveryOnLagMinutes": 0
}
datadir="/var/lib/proxysql"
admin_variables =
{
admin_credentials="admin:admin"
mysql_ifaces="0.0.0.0:6032"
}
mysql_variables =
{
monitor_username="root"
monitor_password="secret"
}
mysql_replication_hostgroups =
(
{
writer_hostgroup=1
reader_hostgroup=2
comment="cluster1"
}
)
mysql_servers =
(
{
address="mysql1"
port=3306
hostgroup=1
max_connections=200
},
{
address="mysql2"
port=3307
hostgroup=1
max_connections=200
},
{
address="mysql3"
port=3308
hostgroup=1
max_connections=200
}
)
mysql_users =
(
{
username = "root"
password = "secret"
default_hostgroup = 1
max_connections=1000
default_schema="information_schema"
active = 1
}
)
mysql_query_rules =
(
{
rule_id=1
active=1
username="root"
match_pattern="^SELECT .* FOR UPDATE$"
destination_hostgroup=1
apply=1
},
{
rule_id=2
active=1
username="root"
match_pattern="^SELECT"
destination_hostgroup=2
apply=1
},
{
rule_id=3
active=1
username="root"
match_pattern="^INSERT|^UPDATE|^DELETE"
destination_hostgroup=1
apply=1
}
)