Master-master là mô hình MySQL mà ai cũng muốn và ít ai vận hành đúng: hai máy, cả hai đều nhận ghi, máy này hỏng thì máy kia vẫn chạy. Dựng thì chỉ mất mười phút. Cái khó nằm ở ngày replication dừng giữa đêm vì hai bên cùng ghi một bản ghi. Bài này dựng master-master trên Ubuntu 24.04 với MySQL 8.0 và GTID, rồi cố tình làm hỏng nó để bạn biết cách sửa trước khi chuyện đó xảy ra thật.
Mô hình
Ung dung (ghi vao node nao cung duoc)
| |
db1 192.168.1.21 <======> db2 192.168.1.22
server_id 1 server_id 2
id tu tang: 1,3,5... id tu tang: 2,4,6...
replication hai chieu, GTID, SSL
Thực chất master-master chỉ là hai kênh replication thông thường đặt ngược chiều nhau: db1 là replica của db2, và db2 là replica của db1. Có ba thứ làm cho vòng lặp này chạy được:
- GTID. Mỗi giao dịch mang một định danh toàn cục. Giao dịch nào node đã có thì nó bỏ qua, nhờ vậy một thay đổi không chạy vòng tròn mãi giữa hai máy.
- auto_increment so le. db1 chỉ sinh id lẻ, db2 chỉ sinh id chẵn, nên hai máy cùng INSERT cũng không bao giờ trùng khoá chính.
- binlog_format=ROW. Replication gửi đi chính các dòng đã thay đổi, không gửi câu lệnh. Kết quả vì thế không phụ thuộc vào
NOW(),RAND()hay thứ tự thực thi.
Bước 1: cài đặt và cấu hình
sudo apt install -y mysql-server
Tạo file /etc/mysql/mysql.conf.d/zz-master-master.cnf. Tên bắt đầu bằng zz- để file này được đọc sau cùng và ghi đè cấu hình mặc định. Đây là file cho db1:
# Cau hinh MySQL master-master (db1 <-> db2). Hai node chi khac server_id,
# auto_increment_offset va bind-address. Sua file nay phai sua tren CA HAI node.
[mysqld]
server_id = 1
bind-address = 127.0.0.1,192.168.1.21
mysqlx = OFF
# --- binlog + GTID: nen tang cua replication hai chieu ---
log_bin = /var/log/mysql/mysql-bin
binlog_format = ROW
binlog_row_image = FULL
gtid_mode = ON
enforce_gtid_consistency = ON
log_replica_updates = ON
binlog_expire_logs_seconds = 604800 # giu binlog 7 ngay: node kia tat toi 7 ngay van bat kip duoc
sync_binlog = 1
innodb_flush_log_at_trx_commit = 1
# --- ghi o CA HAI node: hai node sinh khoa tu tang so le, khong bao gio trung ---
auto_increment_increment = 2
auto_increment_offset = 1
# --- replica ---
relay_log = /var/log/mysql/relay-bin
relay_log_recovery = ON
replica_parallel_workers = 4
replica_preserve_commit_order = ON
skip_replica_start = OFF
# --- tai nguyen: may 16 GB RAM, con chay nginx ---
innodb_buffer_pool_size = 8G
max_connections = 500
Trên db2, đổi ba dòng: server_id = 2, bind-address = 127.0.0.1,192.168.1.22 và auto_increment_offset = 2. Tiếp theo comment dòng bind-address mặc định trong /etc/mysql/mysql.conf.d/mysqld.cnf (nếu không, nó sẽ ghi đè cấu hình của bạn), rồi systemctl restart mysql. Kiểm tra lại:
sudo mysql -e "SELECT @@server_id, @@gtid_mode, @@auto_increment_offset, @@bind_address"
Có ba tham số hay bị bỏ qua nhưng quyết định chuyện sống còn:
binlog_expire_logs_secondslà khoảng thời gian tối đa một node được phép vắng mặt mà vẫn tự bắt kịp. Quá hạn này, binlog cần thiết đã bị xoá, và bạn phải dựng lại node đó từ đầu.sync_binlog=1vàinnodb_flush_log_at_trx_commit=1đảm bảo giao dịch đã commit không bị mất khi mất điện. Nếu mất, hai node sẽ lệch nhau ở đúng những giao dịch đó.relay_log_recovery=ONkhiến replica bỏ relay log hỏng sau sự cố và lấy lại từ node nguồn, thay vì dừng lại chờ người sửa.
Bước 2: user replication và hai kênh
Chạy trên cả hai node, mỗi node trỏ SOURCE_HOST sang node còn lại:
RESET MASTER; -- chi tren may MOI cai: xoa binlog/GTID cu
SET sql_log_bin = 0; -- user nay tao rieng tung node, khong replicate
CREATE USER 'repl'@'192.168.1.%' IDENTIFIED BY 'mat-khau-dai-ngau-nhien' REQUIRE SSL;
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'192.168.1.%';
SET sql_log_bin = 1;
CHANGE REPLICATION SOURCE TO
SOURCE_HOST = '192.168.1.22', -- tren db2 thi la 192.168.1.21
SOURCE_USER = 'repl', SOURCE_PASSWORD = 'mat-khau-dai-ngau-nhien',
SOURCE_AUTO_POSITION = 1, SOURCE_SSL = 1,
SOURCE_CONNECT_RETRY = 10, SOURCE_RETRY_COUNT = 86400;
START REPLICA;
Một vài lưu ý cho đoạn lệnh trên:
SOURCE_AUTO_POSITION = 1nghĩa là dùng GTID: bạn không phải tra tên file binlog hay vị trí như cách làm cũ.SOURCE_SSL = 1là bắt buộc. MySQL 8 dùng plugin xác thựccaching_sha2_password, nên kết nối không mã hoá sẽ bị từ chối. Ubuntu đã tự sinh sẵn chứng chỉ khi cài đặt.RETRY_COUNTđể lớn: khi node kia tắt, replica cứ 10 giây thử kết nối lại một lần, đủ trong khoảng mười ngày, thay vì bỏ cuộc sau vài phút.
Kiểm tra trên từng node:
sudo mysql -e "SHOW REPLICA STATUS\G" | grep -E "Source_Host|Running:|Seconds_Behind|Last_.*Error:"
# Source_Host: 192.168.1.22
# Replica_IO_Running: Yes
# Replica_SQL_Running: Yes
# Seconds_Behind_Source: 0
Từ giờ, user, database và bảng chỉ cần tạo trên một node, vì lệnh sẽ tự replicate sang node kia. Tạo cùng một thứ trên cả hai node là cách nhanh nhất để làm replication dừng.
Bước 3: script kiểm tra sức khoẻ
Master-master hỏng một cách lặng lẽ: replication dừng, nhưng cả hai node vẫn nhận ghi, và dữ liệu cứ thế lệch dần. Vì vậy phải có thứ gì đó theo dõi nó. Script dưới đây trả exit code 0 khi khoẻ và 1 khi có lỗi, nên cắm được vào bất kỳ hệ thống giám sát nào. Khi chạy qua timer mỗi phút, nó chỉ ghi log khi trạng thái thay đổi:
#!/bin/bash
# Kiem tra replication MySQL master-master tren node nay.
# mysql-repl-check.sh -> in trang thai, exit 0 = khoe, 1 = co van de
# mysql-repl-check.sh --log -> (timer goi) chi ghi log khi trang thai THAY DOI hoac dang loi
LOG=/var/log/mysql-repl-check.log
STATE=/run/mysql-repl-check.state
LAG_WARN=60 # giay
ST=$(mysql -e 'SHOW REPLICA STATUS\G' 2>&1)
get() { echo "$ST" | sed -n "s/^ *$1: //p" | head -1; }
IO=$(get Replica_IO_Running); SQL=$(get Replica_SQL_Running); LAG=$(get Seconds_Behind_Source)
SRC=$(get Source_Host); IOERR=$(get Last_IO_Error); SQLERR=$(get Last_SQL_Error)
GTID=$(mysql -N -e 'SELECT @@gtid_executed' 2>/dev/null | tr -d '\n')
if [ -z "$IO" ]; then MSG="LOI: khong doc duoc trang thai replica ($ST)"; OK=1
elif [ "$IO" != Yes ] || [ "$SQL" != Yes ]; then MSG="LOI: nguon=$SRC IO=$IO SQL=$SQL io_err='$IOERR' sql_err='$SQLERR'"; OK=1
elif [ "$LAG" != NULL ] && [ "$LAG" -gt "$LAG_WARN" ]; then MSG="CANH BAO: tre ${LAG}s so voi $SRC"; OK=1
else MSG="OK: nguon=$SRC IO=Yes SQL=Yes tre=${LAG}s"; OK=0; fi
if [ "$1" = --log ]; then
KEY=$(echo "$MSG" | cut -d: -f1)
PREV=$(cat "$STATE" 2>/dev/null)
if [ "$OK" = 1 ] || [ "$KEY" != "$PREV" ]; then echo "$(date '+%F %T') $MSG" >> "$LOG"; fi
echo "$KEY" > "$STATE"
else
echo "$(hostname) $MSG"
echo " gtid_executed: $GTID"
fi
exit $OK
# /etc/systemd/system/mysql-repl-check.timer
[Timer]
OnBootSec=2min
OnUnitActiveSec=1min
[Install]
WantedBy=timers.target
Cố tình làm hỏng: xung đột khi ghi cả hai node
auto_increment so le chỉ bảo vệ khoá chính. Nó không bảo vệ được các giá trị UNIQUE khác như email hay tên đăng nhập. Để tái hiện xung đột, ta tạm dừng luồng nhận của cả hai node, giả lập hai giao dịch xảy ra gần như cùng lúc:
-- ca hai node
STOP REPLICA IO_THREAD;
-- db1
INSERT INTO shop.u(email, tu) VALUES ('[email protected]', @@hostname); -- id 1
-- db2
INSERT INTO shop.u(email, tu) VALUES ('[email protected]', @@hostname); -- id 2
-- ca hai node
START REPLICA IO_THREAD;
Replication lập tức dừng cả hai chiều:
db1 LOI: nguon=192.168.1.22 IO=Yes SQL=No ... Worker 1 failed executing transaction
'675a7361-...:2002' ... Duplicate entry '[email protected]' for key 'u.email', Error_code: 1062
Lúc này mỗi node giữ bản ghi của riêng mình. Nếu không ai để ý, mọi thay đổi sau đó chỉ nằm trên node đã nhận ghi, và hai bên ngày càng lệch nhau.
Sửa đúng cách
Khi dùng GTID, sql_replica_skip_counter không còn tác dụng. Cách chuẩn là tiêm một giao dịch rỗng mang đúng GTID của giao dịch lỗi: MySQL coi như đã áp dụng giao dịch đó rồi và đi tiếp. Script hỗ trợ:
#!/bin/bash
# Bo qua giao dich replication dang loi tren node nay bang cach tiem mot giao dich RONG
# mang dung GTID do (cach chuan khi dung GTID; sql_replica_skip_counter khong dung duoc).
# mysql-repl-skip.sh -> chi in giao dich dang loi va loi gi
# mysql-repl-skip.sh --yes -> bo qua giao dich do va START REPLICA
# CHU Y: bo qua = du lieu cua giao dich do KHONG duoc ap dung tren node nay.
# Phai tu doi chieu va sua du lieu sau do (xem tai lieu, muc xu ly xung dot).
set -euo pipefail
ROW=$(mysql -N -e "SELECT APPLYING_TRANSACTION, LAST_ERROR_NUMBER, LAST_ERROR_MESSAGE
FROM performance_schema.replication_applier_status_by_worker
WHERE LAST_ERROR_NUMBER <> 0 LIMIT 1")
[ -z "$ROW" ] && { echo "Khong co giao dich nao dang loi."; exit 0; }
GTID=$(echo "$ROW" | cut -f1); [ -z "$GTID" ] && GTID=$(echo "$ROW" | grep -oE "[0-9a-f-]{36}:[0-9]+" | head -1)
echo "Giao dich loi : $GTID"
echo "Ma loi : $(echo "$ROW" | cut -f2)"
echo "Chi tiet : $(echo "$ROW" | cut -f3 | cut -c1-400)"
[ "${1:-}" = --yes ] || { echo; echo "Chay lai voi --yes de bo qua giao dich nay."; exit 1; }
mysql -e "STOP REPLICA; SET GTID_NEXT='$GTID'; BEGIN; COMMIT; SET GTID_NEXT='AUTOMATIC'; START REPLICA;"
echo "$(date '+%F %T') da bo qua $GTID" >> /var/log/mysql-repl-check.log
sleep 2; /usr/local/bin/mysql-repl-check.sh
Quy trình gồm bốn bước:
- Quyết định giữ bản ghi của bên nào. Đây là quyết định nghiệp vụ, không script nào làm thay được. Ví dụ ở đây giữ bản ghi của db1.
- Bỏ qua giao dịch lỗi trên từng node: chạy
mysql-repl-skip.shđể xem, sau đómysql-repl-skip.sh --yes. - Sửa dữ liệu trên node “thua” với
sql_log_bin=0:SET sql_log_bin = 0; DELETE FROM shop.u WHERE id = 2; INSERT INTO shop.u VALUES (1, '[email protected]', 'db1'); SET sql_log_bin = 1;Nếu quên tắt binlog, lệnh
DELETEsẽ được replicate sang db1, nơi không có dòng id 2. Kết quả là một lỗi mới, 1032. - Đối chiếu: chạy
CHECKSUM TABLE shop.utrên hai node, kết quả phải giống nhau.
Kết quả kiểm thử
| Kịch bản | Kết quả |
|---|---|
| Ghi vào cả hai node | db1 sinh id 1, 3; db2 sinh id 4, 6; cả hai bên thấy đủ 4 dòng |
| Hai node cùng ghi đồng thời, mỗi bên 2.000 dòng | 4.004 dòng trên mỗi bên, CHECKSUM TABLE khớp, không có lỗi |
| Tắt MySQL trên db2 trong lúc db1 INSERT, UPDATE, DELETE | db2 bật lại thì bắt kịp trong vài giây, checksum khớp |
| Reboot db2 | MySQL và replication tự chạy lại, không cần can thiệp |
| Cố tình ghi trùng giá trị UNIQUE | Replication dừng cả hai chiều với lỗi 1062; xử lý theo quy trình trên thì dữ liệu hội tụ |
Nên ghi vào cả hai node không?
Có thể, nhưng nên có kỷ luật. Kinh nghiệm thực tế:
- Chia vùng ghi nếu có thể: mỗi ứng dụng hoặc mỗi site ghi vào một node cố định, node kia làm dự phòng nóng. Bạn vẫn có đủ lợi ích của master-master mà gần như không bao giờ gặp xung đột.
- Dữ liệu có ràng buộc UNIQUE tự nhiên (email, mã đơn hàng) nên đi qua một node duy nhất.
- DDL chỉ chạy trên một node, và chờ replicate xong mới làm bước tiếp theo.
- Mọi bảng đều phải có khoá chính. Thiếu khoá chính, replication kiểu ROW phải quét toàn bảng cho từng dòng thay đổi, và độ trễ tăng vọt.
- Replication không phải là backup. Một lệnh
DROP TABLEchạy nhầm sẽ có mặt trên node kia chỉ sau vài mili giây. Vẫn phải backup hằng đêm và lưu ra ngoài cụm.
Nếu ứng dụng thật sự cần ghi ở nhiều nơi cùng lúc mà không muốn lo xung đột, đó là lúc nên tìm hiểu MySQL Group Replication hoặc Galera. Hai giải pháp này kiểm tra xung đột ngay lúc commit, đổi lại phải có ít nhất ba node. Với hai máy chủ, master-master cộng một script giám sát tốt vẫn là lựa chọn gọn và đáng tin.
