MySQL & MariaDB in Depth Module 8 – Binary log and replication
Set up GTID replication with TLS
Build a MySQL 8.4 source and replica over TLS with auto-positioning, read SHOW REPLICA STATUS, and provision new replicas without stale GTIDs.
What you will learn
- Create a replication account and connect a replica over TLS with auto-positioning
- Build a new replica from a dump that seeds GTID_PURGED
- Read SHOW REPLICA STATUS and the performance_schema replication tables
- Use the 8.4 statement names and avoid the removed ones
Before you start
What you’ll learn · Before you start
What you will learn
- Create a replication account and connect a replica over TLS with auto-positioning
- Build a new replica from a dump that seeds GTID_PURGED
- Read SHOW REPLICA STATUS and the performance_schema replication tables
- Use the 8.4 statement names and avoid the removed ones
Before you start
On this page
A production MySQL system almost always has at least one replica following the primary server: a second server that reads the primary’s binary log and applies the same transactions, so a failed primary can be replaced in minutes. On MySQL 8.4 the GTID: A global transaction identifier: a transaction's source server UUID and a positive integer that counts up on that source. The same GTID stays with the transaction on every replica that applies it. More in “GTIDs: sets, tags and auto-positioning” gives every transaction a stable name, so a replica on GTID auto-positioning: The replication handshake turned on by SOURCE_AUTO_POSITION=1: the replica sends the GTIDs it has committed or already received (its gtid_executed plus its received set), and the source streams every transaction of its binary log whose GTID is not in that set. More in “GTIDs: sets, tags and auto-positioning” can ask the source “send me everything after what I already have” over a TLS connection, and reconnect to a new source after a failover with the same request.
You will build the whole set-up: a dedicated account that requires TLS, one CHANGE REPLICATION SOURCE TO statement that turns TLS and auto-positioning on, and the twelve SHOW REPLICA STATUS fields a running system reads first.
A replication account on its own, with TLS required
A replica logs in to its source as a MySQL user, so the first step is a user for that one job. The account needs no
schema privileges at all; the only grant it needs is REPLICATION SLAVE. Give the account its own name (never reuse
root), restrict its host to the hosts a replica may run on, and add REQUIRE SSL, which refuses any login that
does not use TLS. The whole account fits in two statements:
two_node.py
"""Source and replica on 127.0.0.1 with TLS and auto-positioning (MySQL 8.4.11).
Shows the whole set-up a replica needs:
- a least-privilege replication account that must use TLS (REQUIRE SSL),
- CHANGE REPLICATION SOURCE TO with SOURCE_AUTO_POSITION=1 and SOURCE_SSL=1,
- START REPLICA with USER and PASSWORD given at start time, so no password is stored in the connection metadata,
- the running state in SHOW REPLICA STATUS: Replica_IO_Running and Replica_SQL_Running Yes, Source_SSL_Allowed Yes,
Auto_Position 1 and Seconds_Behind_Source 0.
(learn-lint-allow inclusive: the removed statements are named as MySQL spelled them)
Also reproduces the removed 8.4 statements as syntax errors: CHANGE MASTER TO, START SLAVE, SHOW SLAVE STATUS, and
GRANT REPLICATION REPLICA. Last, a replica started with --super-read-only=ON refuses INSTALL PLUGIN (ERROR 1290).
"""
import re
import sys
import time
from mysql_session import Server
def wait_sbs_zero(replica, timeout=30):
"""Waits until SHOW REPLICA STATUS reads Seconds_Behind_Source 0. The field is a clock difference counted in
whole seconds, so right after the last transaction was applied it can still read 1 or more; the record shows the
settled value."""
deadline = time.time() + timeout
while time.time() < deadline:
_, out = replica._call(["mysql", f"--defaults-file={replica.client_cnf}", "-E", "-e", "SHOW REPLICA STATUS"])
m = re.search(r"Seconds_Behind_Source:\s*(\S+)", out)
if m and m.group(1) == "0":
return
time.sleep(0.5)
sys.exit("Seconds_Behind_Source did not settle at 0")
SRC = ["--server-id=1", "--gtid-mode=ON", "--enforce-gtid-consistency=ON", "--log-replica-updates=ON"]
REP = ["--server-id=2", "--gtid-mode=ON", "--enforce-gtid-consistency=ON", "--log-replica-updates=ON",
"--read-only=ON", "--super-read-only=ON", "--skip-replica-start=ON"]
with Server(options=SRC, label="source", port_label="<source-port>") as source, \
Server(options=REP, label="replica", port_label="<replica-port>") as replica:
source.up()
replica.up()
print("# 1. Create the replication account on the source. Least privilege (REPLICATION SLAVE) and REQUIRE SSL.") # learn-lint-allow inclusive: MySQL 8.4 still spells this privilege or statement so
source.write_file("repluser.sql",
"-- The privilege is still named REPLICATION SLAVE in 8.4; REPLICATION REPLICA is a syntax error.\n" # learn-lint-allow inclusive: MySQL 8.4 still spells this privilege or statement so
"CREATE USER 'repl'@'127.0.0.1' IDENTIFIED BY 'replpass' REQUIRE SSL;\n"
"GRANT REPLICATION SLAVE ON *.* TO 'repl'@'127.0.0.1';\n" # learn-lint-allow inclusive: MySQL 8.4 still spells this privilege or statement so
"GRANT REPLICATION REPLICA ON *.* TO 'repl'@'127.0.0.1';\n", show=True)
source.mysql("repluser.sql", errors=1)
print("# 2. Seed a row on the source so the replica has something to catch up to.")
source.write_file("seed.sql",
"CREATE DATABASE shop;\n"
"CREATE TABLE shop.t (id INT PRIMARY KEY, v INT);\n"
"INSERT INTO shop.t VALUES (1, 10), (2, 20);\n", show=False)
source.mysql("seed.sql", note="seed data on the source")
print("# 3. Point the replica at the source over TLS with auto-positioning.")
replica.change_source(source, user="repl", password="replpass",
auto_position=True, ssl=True, verify_ca=True)
print("# 4. Start the replica with USER/PASSWORD given at start time, so no password is stored in the metadata.")
replica.start_replica(user="repl", password="replpass")
replica.wait_replica_running()
replica.wait_caught_up(source)
wait_sbs_zero(replica)
print("# 5. Replication health: IO and SQL threads Yes, SSL allowed, auto-position on, 0 seconds behind.")
replica.replica_status(["Replica_IO_Running", "Replica_SQL_Running", "Source_SSL_Allowed",
"Auto_Position", "Seconds_Behind_Source", "Last_Error"])
print("# 6. The row the source wrote is on the replica too, read through its read-only session.")
replica.sql("SELECT id, v FROM shop.t ORDER BY id", db="shop")
print("# 7. The 8.4 names: the old statements are removed. One ERROR line per statement.")
replica.write_file("removed_names.sql",
"CHANGE MASTER TO SOURCE_HOST='127.0.0.1';\n" # learn-lint-allow inclusive: MySQL 8.4 still spells this privilege or statement so
"START SLAVE;\n" # learn-lint-allow inclusive: MySQL 8.4 still spells this privilege or statement so
"SHOW SLAVE STATUS;\n", show=True) # learn-lint-allow inclusive: MySQL 8.4 still spells this privilege or statement so
replica.mysql("removed_names.sql", errors=3)
print("# 8. super_read_only=ON blocks INSTALL PLUGIN on a running replica (ERROR 1290).")
replica.sql("INSTALL PLUGIN rpl_semi_sync_replica SONAME 'semisync_replica.so'",
errors=1, note="install plugins BEFORE you enable super_read_only") Output
# 1. Create the replication account on the source. Least privilege (REPLICATION SLAVE) and REQUIRE SSL. $ cat repluser.sql -- The privilege is still named REPLICATION SLAVE in 8.4; REPLICATION REPLICA is a syntax error. CREATE USER 'repl'@'127.0.0.1' IDENTIFIED BY 'replpass' REQUIRE SSL; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'127.0.0.1'; GRANT REPLICATION REPLICA ON *.* TO 'repl'@'127.0.0.1'; $ mysql -t -vvv --skip-comments --force < repluser.sql -------------- CREATE USER 'repl'@'127.0.0.1' IDENTIFIED BY 'replpass' REQUIRE SSL -------------- Query OK, 0 rows affected -------------- GRANT REPLICATION SLAVE ON *.* TO 'repl'@'127.0.0.1' -------------- Query OK, 0 rows affected -------------- GRANT REPLICATION REPLICA ON *.* TO 'repl'@'127.0.0.1' -------------- ERROR 1064 (42000) at line 4: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'REPLICA ON *.* TO 'repl'@'127.0.0.1'' at line 1 Bye # 2. Seed a row on the source so the replica has something to catch up to. -- seed data on the source $ mysql -t -vvv --skip-comments < seed.sql -------------- CREATE DATABASE shop -------------- Query OK, 1 row affected -------------- CREATE TABLE shop.t (id INT PRIMARY KEY, v INT) -------------- Query OK, 0 rows affected -------------- INSERT INTO shop.t VALUES (1, 10), (2, 20) -------------- Query OK, 2 rows affected Records: 2 Duplicates: 0 Warnings: 0 Bye # 3. Point the replica at the source over TLS with auto-positioning. $ mysql -t -vvv --skip-comments < _change_source.sql -------------- CHANGE REPLICATION SOURCE TO SOURCE_HOST='127.0.0.1', SOURCE_PORT=<source-port>, SOURCE_USER='repl', SOURCE_PASSWORD='replpass', SOURCE_AUTO_POSITION=1, SOURCE_SSL=1, SOURCE_SSL_CA='source-ca.pem' -------------- Query OK, 0 rows affected, 1 warning Bye # 4. Start the replica with USER/PASSWORD given at start time, so no password is stored in the metadata. $ mysql -t -vvv --skip-comments < _start_replica.sql -------------- START REPLICA USER='repl' PASSWORD='replpass' -------------- Query OK, 0 rows affected Bye # 5. Replication health: IO and SQL threads Yes, SSL allowed, auto-position on, 0 seconds behind. mysql> SHOW REPLICA STATUS\G Replica_IO_Running: Yes Replica_SQL_Running: Yes Source_SSL_Allowed: Yes Auto_Position: 1 Seconds_Behind_Source: 0 Last_Error: # 6. The row the source wrote is on the replica too, read through its read-only session. $ mysql -t shop -e "SELECT id, v FROM shop.t ORDER BY id" +----+------+ | id | v | +----+------+ | 1 | 10 | | 2 | 20 | +----+------+ # 7. The 8.4 names: the old statements are removed. One ERROR line per statement. $ cat removed_names.sql CHANGE MASTER TO SOURCE_HOST='127.0.0.1'; START SLAVE; SHOW SLAVE STATUS; $ mysql -t -vvv --skip-comments --force < removed_names.sql -------------- CHANGE MASTER TO SOURCE_HOST='127.0.0.1' -------------- ERROR 1064 (42000) at line 1: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'MASTER TO SOURCE_HOST='127.0.0.1'' at line 1 -------------- START SLAVE -------------- ERROR 1064 (42000) at line 2: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'SLAVE' at line 1 -------------- SHOW SLAVE STATUS -------------- ERROR 1064 (42000) at line 3: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'SLAVE STATUS' at line 1 Bye # 8. super_read_only=ON blocks INSTALL PLUGIN on a running replica (ERROR 1290). -- install plugins BEFORE you enable super_read_only $ mysql -t -e "INSTALL PLUGIN rpl_semi_sync_replica SONAME 'semisync_replica.so'" ERROR 1290 (HY000) at line 1: The MySQL server is running with the --super-read-only option so it cannot execute this statement
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 two_node.py
mysql_session.py
"""Throwaway MySQL 8.4.11 servers for the examples of this lesson.
Every session creates a server of its own: `mysqld --initialize-insecure` makes a new data directory inside a new
folder in the examples folder, the server starts on it, the session runs `mysql` client scripts and MySQL's programs
against it and prints what they printed, and then the server is stopped and the folder deleted, also when something
fails. The server is the pinned MySQL 8.4.11 from the mise installations next to the Python that runs this file;
Server(basedir=...) runs the programs of another unpacked MySQL tarball instead (dump_defaults.py uses it).
Safety: the server listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system
reports as unused; the X Protocol listener is off (--mysqlx=OFF); its Unix socket, temporary files, error log and
import/export folder all stay inside the run folder. Right after the first start the session gives root a password made
up for the run (never printed) and connects only with a client option file that holds it, so no password ever appears
on a command line. Every program starts with --no-defaults or --defaults-file, so no option file of the computer
(/etc/my.cnf, ~/.my.cnf) can change a result, and HOME points at the run folder, so ~/.mylogin.cnf is not read either.
A printed mysqld command shows the options that matter to the lesson; the session also passes, on the command line,
the folders it keeps inside the run folder (--basedir, --tmpdir, --log-error, --pid-file, --secure-file-priv) and
the port, which is why performance_schema.variables_info reports those variables as COMMAND_LINE.
learn:verify's run sandbox cannot read the home folder, where mise keeps MySQL, so these sessions are recorded with
LEARN_VERIFY_NETWORK=allow; they read only this folder and the toolchain and connect to nothing but 127.0.0.1.
The output is exactly what the programs printed, with these substitutions so that a record never shows the computer it
ran on and is the same on every run: the run folder is shown as ".", the folder of MySQL's programs is left out of
their paths and MySQL's own folder is shown as <basedir>, the computer's name is shown as "lab-host" and the operating-system user's name as "learner", the time a
statement took (the client's "(0.01 sec)") is left out, the time stamp SET PERSIST writes into mysqld-auto.cnf is
shown as <when it was set>, error-log lines lose their timestamp and thread number, and a session can name more values
that change between runs with my.mask(pattern, replacement), which then prints in their place. A client table whose cells
change is drawn again so that its borders still line up.
The sessions never print the password, and the run's port prints as <port>. A session stops with status 1 when a command that should have worked
fails, or when a script reports a different number of errors than the session expects, so a run that went wrong is
never recorded as if it had worked.
"""
import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path
MYSQL_VERSION = "8.4.11"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
PINNED = INSTALLS / "mysql" / MYSQL_VERSION
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
HOST = socket.gethostname().split(".")[0]
# 2026-10-17T16:50:01.123456Z 0 [ERROR] ... -> [ERROR] ...
LOG_PREFIX = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \d+ ", re.M)
def free_port():
"""A TCP port on 127.0.0.1 that nothing listens on now."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
class Server:
"""One throwaway server: initialise, start; stopped and deleted when the with block ends.
options: extra mysqld options for every start (for example "--sql-mode=" or "--character-set-server=latin1").
basedir: the folder of an unpacked MySQL tarball to run instead of the pinned 8.4.11.
masks: (pattern, replacement) pairs, as for mask().
"""
def __init__(self, options=(), redo=None, basedir=None, masks=(), label=None, port_label=None):
self.options = [str(o) for o in options]
self.redo = redo
self.basedir = Path(basedir).resolve() if basedir else PINNED
self.bin = self.basedir / "bin"
self.label = label # a stable name for this server in the record ("source", "replica")
self.port_label = port_label # the text that shows in place of this server's port
self.uuid = None
self.masks = []
for pattern, replacement in masks:
self.mask(pattern, replacement)
self.proc = None
def mask(self, pattern, replacement):
"""From now on every printed line shows text matching the regular expression as the replacement: for a value
that changes from run to run (an uptime, a time stamp), so that the recorded output stays the same."""
self.masks.append((re.compile(pattern), replacement))
# ---- the folder ------------------------------------------------------------------------------------------------
def __enter__(self):
if not (self.bin / "mysqld").exists():
sys.exit(f"no MySQL server program in {self.basedir}" if self.basedir != PINNED
else f"MySQL {MYSQL_VERSION} was not found next to this Python")
self.base = Path(tempfile.mkdtemp(prefix="my", dir=".")).resolve()
for sub in ("tmp", "files"):
(self.base / sub).mkdir(mode=0o700)
self.port = free_port()
self.password = secrets.token_urlsafe(24)
self.client_cnf = self.base / "client.cnf"
self._write_client_cnf(password=None)
self.env = {key: value for key, value in os.environ.items()
if not key.startswith("MYSQL") and key not in ("TZ", "HOME", "LANG", "LC_ALL")}
self.env.update({"HOME": str(self.base), "TZ": "UTC", "LANG": "en_US.UTF-8",
"MYSQL_TEST_LOGIN_FILE": str(self.base / "no-login-paths.cnf")})
for pattern in ("*.sql", "*.cnf", "*.csv", "*.txt"):
for f in Path(".").glob(pattern):
shutil.copy(f, self.base / f.name)
return self
def __exit__(self, *exc):
if self.proc and self.proc.poll() is None:
self.stop(quiet=True)
shutil.rmtree(self.base, ignore_errors=True)
def _write_client_cnf(self, password):
lines = ["[client]", "user=root", "host=127.0.0.1", f"port={self.port}", "protocol=TCP"]
if password:
lines.append(f"password={password}")
self.client_cnf.write_text("\n".join(lines) + "\n")
self.client_cnf.chmod(0o600)
def server_options(self):
b = self.base
return ["--no-defaults", f"--basedir={self.basedir}", f"--datadir={b / 'data'}", f"--tmpdir={b / 'tmp'}",
f"--log-error={b / 'mysqld.err'}", f"--pid-file={b / 'mysqld.pid'}",
f"--secure-file-priv={b / 'files'}",
*([f"--innodb-redo-log-capacity={self.redo}"] if self.redo else [])]
# ---- the server ------------------------------------------------------------------------------------------------
def initialize(self, show=False):
"""mysqld --initialize-insecure: a new data directory whose root account has no password yet."""
args = ["mysqld", *self.server_options(), "--initialize-insecure"]
code, out = self._call(args)
if show:
_show(["mysqld", "--no-defaults", "--initialize-insecure", "--datadir=./data"], out)
if code != 0:
print(self.log())
sys.exit(f"mysqld --initialize-insecure ended with status {code}")
def start(self, extra=(), defaults_file=None, expect_fail=False, show=False):
"""Starts mysqld on 127.0.0.1 and waits until it answers. With expect_fail, prints the error-log lines of a
start that must fail, and returns them."""
log = self.base / "mysqld.err"
before = log.read_text() if log.exists() else ""
opts = self.server_options()
if defaults_file:
opts[0] = f"--defaults-file={self.base / defaults_file}"
args = [str(self.bin / "mysqld"), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
"--socket=mysql.sock", "--mysqlx=OFF", *self.options, *[str(e) for e in extra]]
if show:
shown = ["mysqld", opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
"--port=$PORT", "--socket=mysql.sock", "--mysqlx=OFF", *self.options, *extra]
print("$ " + " ".join(_arg(a) if a != "--port=$PORT" else a for a in shown) + " &")
print()
with open(self.base / "mysqld.out", "a") as out:
self.proc = subprocess.Popen(args, stdout=out, stderr=subprocess.STDOUT, cwd=self.base, env=self.env)
for _ in range(600):
if self.proc.poll() is not None:
break
if self._ping():
break
time.sleep(0.1)
if self.proc.poll() is not None:
new = (log.read_text() if log.exists() else "")[len(before):]
lines = [l for l in self.clean(LOG_PREFIX.sub("", new)).splitlines() if "[ERROR]" in l]
if expect_fail:
print("\n".join(lines))
print(f"(mysqld ended with status {self.proc.returncode})")
print()
self.proc = None
return lines
print("\n".join(lines))
sys.exit(f"mysqld ended with status {self.proc.returncode} while starting")
if expect_fail:
sys.exit("mysqld started, but the session expected it to fail")
if not self._ping():
sys.exit("mysqld did not answer within 60 seconds")
return []
def _ping(self):
code, _ = self._call(["mysqladmin", f"--defaults-file={self.client_cnf}", "--connect-timeout=2", "ping"])
return code == 0
def up(self, extra=(), defaults_file=None, show=False):
"""Initialise, start (with start()'s options), and give root the run's password, quietly."""
self.initialize()
self.start(extra=extra, defaults_file=defaults_file, show=show)
return self.login()
def login(self):
"""Gives root the run's password (never printed) on a server started from a new data directory, and makes
every later client of the session use it. If this server has a label, its random server_uuid is masked to a
stable value so GTID sets read the same on every run. A server started with --super-read-only=ON (a replica)
blocks even root's ALTER USER, so super_read_only is turned off for the one password statement and restored.
The statement runs with sql_log_bin off, for two reasons: the binary log a lesson decodes then holds only the
lesson's own statements, and the replica keeps its own root password (each server of a run has a different one,
so a replicated ALTER USER would lock the session out of the replica)."""
sro = self.query("SELECT @@global.super_read_only") == "1"
if sro:
self.query("SET GLOBAL super_read_only=OFF")
self.silent("SET SESSION sql_log_bin = 0;\n"
f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
self._write_client_cnf(self.password)
if sro:
self.query("SET GLOBAL super_read_only=ON")
if self.label:
self.uuid = self.query("SELECT @@server_uuid")
self.mask(re.escape(self.uuid), f"<{self.label}-uuid>")
return self
def stop(self, quiet=False):
"""A clean shutdown (mysqladmin shutdown), then waits until the process has ended."""
code, out = self._call(["mysqladmin", f"--defaults-file={self.client_cnf}", "shutdown"])
if code != 0 and not quiet:
sys.exit(f"mysqladmin shutdown failed: {out}")
try:
self.proc.wait(timeout=120)
except subprocess.TimeoutExpired:
self.proc.terminate()
self.proc.wait(timeout=60)
self.proc = None
def restart(self, extra=(), defaults_file=None, show=False):
self.stop()
self.start(extra=extra, defaults_file=defaults_file, show=show)
# ---- running things --------------------------------------------------------------------------------------------
def _call(self, args, stdin=None, merge=True, clean=True):
"""Runs one of MySQL's programs in the run folder: (status, cleaned output). merge=False keeps only what it
wrote to standard output (its warnings on standard error are dropped). clean=False returns the output exactly
as the program wrote it, for a value the session feeds back into SQL (a GTID set, a file name): a masked value
is text for the record only and MySQL would reject it."""
args = [str(a) for a in args]
done = subprocess.run([str(self.bin / args[0]), *args[1:]], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT if merge else subprocess.DEVNULL,
text=True, cwd=self.base, env=self.env, input=stdin)
return done.returncode, (self.clean(done.stdout) if clean else done.stdout)
def output(self, *args, connect=False, status=0):
"""Runs one of MySQL's programs without printing anything and returns its standard output, for example the
SQL that mysql_tzinfo_to_sql writes, which the session saves with write_file(..., show=False) and runs next."""
args = [str(a) for a in args]
run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
code, out = self._call(run, merge=False)
if code != status:
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def clean(self, text):
"""The substitutions of the docstring. A table of the client whose cells change is drawn again with the
new column widths, so its borders still line up."""
lines, out, i = text.split("\n"), [], 0
while i < len(lines):
if lines[i].startswith("+-"):
j = i
while j < len(lines) and lines[j][:1] in ("+", "|"):
j += 1
block = lines[i:j]
done = [self._clean(l) for l in block]
out.extend(done if done == block else _redraw(block, self._clean))
i = j
else:
out.append(self._clean(lines[i]))
i += 1
return "\n".join(out)
def _clean(self, text):
text = text.replace(str(self.bin) + "/", "").replace(str(self.basedir), "<basedir>")
text = text.replace(str(self.base) + "/", "./").replace(str(self.base), ".")
text = re.sub(r" \(\d+\.\d\d sec\)", "", text)
text = re.sub(r'"Timestamp": \d+', '"Timestamp": <when it was set>', text)
text = re.sub(rf"\b{re.escape(HOST)}\b", "lab-host", text)
text = re.sub(rf"\b{re.escape(OS_USER)}\b", "learner", text)
text = re.sub(rf"(?<![\d.]){self.port}(?![\d.])", self.port_label or "<port>", text)
for pattern, replacement in self.masks:
text = pattern.sub(replacement, text)
return text
def log(self, pattern=None):
"""The error log without timestamps and thread numbers, optionally only the lines matching a pattern."""
text = self.clean(LOG_PREFIX.sub("", (self.base / "mysqld.err").read_text()))
if pattern:
text = "\n".join(l for l in text.splitlines() if re.search(pattern, l))
return text
def show_log(self, pattern, note=None):
"""Prints the error-log lines that match a pattern, as grep would."""
print(f"$ grep -E {_arg(pattern)} mysqld.err" + (f" # {note}" if note else ""))
print(self.log(pattern))
print()
def query(self, sql, db=None):
"""One value from one query, unprinted and UNMASKED (the session uses it to wait or to decide, and often
feeds it back into SQL: a masked value would be a syntax error). Use gtid_executed() or clean() for a value
that goes into the record."""
args = ["mysql", f"--defaults-file={self.client_cnf}", "-N", "-B", *([db] if db else [])]
code, out = self._call(args, stdin=sql, clean=False)
if code != 0:
sys.exit(f"the query failed: {self.clean(out)}")
return out.strip()
def silent(self, sql):
"""Runs a statement without printing it (used only for the run's password)."""
self.query(sql)
def mysql(self, script, errors=0, status=None, db=None, flags=("-t", "-vvv", "--skip-comments"), note=None, force=None):
"""Runs a client script from standard input and prints the command and its output. `errors` is how many
ERROR lines the script must print; with errors > 0 the client runs on past an error (--force) and then ends
with status 0, without --force it stops at the first error with status 1. The default flags print each
statement (-vvv) as a table (-t) without the script's comments (--skip-comments)."""
force = errors > 0 if force is None else force
shown = ["mysql", *flags, *(["--force"] if force else []), *([db] if db else [])]
args = ["mysql", f"--defaults-file={self.client_cnf}", *shown[1:]]
code, out = self._call(args, stdin=(self.base / script).read_text())
if note:
print(f"-- {note}")
print("$ " + " ".join(_arg(a) for a in shown) + f" < {script}")
if out.strip():
print(out.rstrip("\n"))
print()
found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
expected_status = (1 if errors and not force else 0) if status is None else status
if found != errors or code != expected_status:
sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
return out
def shell(self, *args, status=0, shown=None, connect=False):
"""Runs one of MySQL's programs and prints the command and its output. connect=True gives a client program
(mysqladmin, mysqlshow, mysql) the run's client option file, which the printed command leaves out."""
args = [str(a) for a in args]
run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
code, out = self._call(run)
_show(shown or args, out)
if code != status:
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def sql(self, statement, flags=("-t",), errors=0, db=None, note=None):
"""Runs one statement with mysql -e and prints the command and its output."""
shown = ["mysql", *flags, *([db] if db else []), "-e", statement]
code, out = self._call(["mysql", f"--defaults-file={self.client_cnf}", *shown[1:]])
if note:
print(f"-- {note}")
_show(shown, out)
found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
if found != errors or (code != 0) != (errors > 0):
sys.exit(f"{statement}: {found} errors and status {code}; the session expects {errors}")
return out
def write_file(self, path, text, show=True):
"""Writes a text file into the run folder (an option file, a script) and prints it, as cat would."""
(self.base / path).write_text(textwrap.dedent(text).lstrip("\n"))
if show:
self.show_file(path)
def show_file(self, path):
print(f"$ cat {path}")
print(self.clean((self.base / path).read_text()).rstrip("\n"))
print()
def listing(self, folder, prefix=""):
"""Prints the names in a folder of the run (sorted, folders with a slash), as `ls -p` would list them."""
path = self.base / folder
names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
print(f"$ ls -p {folder}" + (f" | grep '^{prefix}'" if prefix else ""))
print(textwrap.fill(" ".join(names), width=78, break_on_hyphens=False))
print()
return names
# ---- replication (two or three servers on 127.0.0.1) -----------------------------------------------------------
def gtid_executed(self, raw=False):
"""This server's @@global.gtid_executed as one line (no newlines). The value is masked for the record (the
server_uuid reads as <label-uuid>); raw=True gives the real set, which is what MySQL accepts in a statement."""
value = self.query("SELECT @@global.gtid_executed").replace("\n", "")
return value if raw else self.clean(value)
def track(self, other):
"""Teach this server's record to mask another server's port and server_uuid, so a statement that names the
other server (CHANGE REPLICATION SOURCE TO, SHOW REPLICA STATUS) reads the same on every run."""
if other.port:
self.mask(rf"(?<![\d.]){other.port}(?![\d.])", other.port_label or "<port>")
if other.uuid:
self.mask(re.escape(other.uuid), f"<{other.label}-uuid>")
self.mask(re.escape(str(other.base)), ".")
def change_source(self, source, user="repl", password="replpass", auto_position=True, ssl=False,
verify_ca=False, channel=None, delay=None, extra=(), show=True, run=True):
"""Builds and (run=True) runs CHANGE REPLICATION SOURCE TO on this replica, pointing at `source`. With
verify_ca the source's auto-generated ca.pem is copied into the replica's data directory and the replica
verifies the server certificate's issuer chains to it (OpenSSL's VERIFY_CA mode).
A relative SOURCE_SSL_CA is resolved relative to the replica's data directory, so the file is copied there,
not into the run folder.
VERIFY_IDENTITY (SOURCE_SSL_VERIFY_SERVER_CERT=1, which also checks the server certificate's SAN against the
host) is not wired up here: MySQL's --initialize-insecure auto-generates self-signed certificates whose
subject is "MySQL_Server_<version>_Auto_Generated_Server_Certificate" and whose SAN does not include
127.0.0.1, so VERIFY_IDENTITY would fail with "certificate verify failed". A production setup issues server
certificates whose SAN includes the host and then enables SOURCE_SSL_VERIFY_SERVER_CERT=1."""
self.track(source)
opts = ["SOURCE_HOST='127.0.0.1'", f"SOURCE_PORT={source.port}", f"SOURCE_USER='{user}'",
f"SOURCE_PASSWORD='{password}'"]
if auto_position:
opts.append("SOURCE_AUTO_POSITION=1")
if ssl:
opts.append("SOURCE_SSL=1")
if verify_ca:
shutil.copy(source.base / "data" / "ca.pem", self.base / "data" / "source-ca.pem")
opts.append("SOURCE_SSL_CA='source-ca.pem'")
if delay is not None:
opts.append(f"SOURCE_DELAY={delay}")
opts += [str(e) for e in extra]
stmt = "CHANGE REPLICATION SOURCE TO\n " + ",\n ".join(opts)
if channel:
stmt += f"\n FOR CHANNEL '{channel}'"
stmt += ";"
self.write_file("_change_source.sql", stmt + "\n", show=False)
if run:
if show:
self.mysql("_change_source.sql")
else:
self.query(stmt)
return stmt
def start_replica(self, user=None, password=None, channel=None, show=True):
"""START REPLICA, optionally with USER/PASSWORD given at start time (not stored in the metadata repository)."""
parts = ["START REPLICA"]
if user:
parts.append(f"USER='{user}' PASSWORD='{password}'")
if channel:
parts.append(f"FOR CHANNEL '{channel}'")
stmt = " ".join(parts) + ";"
if show:
self.write_file("_start_replica.sql", stmt + "\n", show=False)
self.mysql("_start_replica.sql")
else:
self.query(stmt)
def stop_replica(self, channel=None, show=False):
stmt = "STOP REPLICA" + (f" FOR CHANNEL '{channel}'" if channel else "")
if show:
self.sql(stmt)
else:
self.query(stmt)
def wait_replica_running(self, channel=None, timeout=30):
"""Waits until both replication threads report ON in performance_schema (deterministic, no fixed sleep)."""
where = f" WHERE CHANNEL_NAME='{channel}'" if channel else ""
for _ in range(timeout * 10):
io = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_connection_status{where}")
applier = self.query(f"SELECT SERVICE_STATE FROM performance_schema.replication_applier_status{where}")
if io == "ON" and applier == "ON":
return
time.sleep(0.1)
print(self.sql("SHOW REPLICA STATUS", flags=("-E",)))
sys.exit("the replica did not reach the running state in time")
def wait_caught_up(self, source, timeout=30):
"""Blocks until this replica has executed every transaction the source had committed when called."""
gset = source.gtid_executed(raw=True)
if not gset:
return
result = self.query(f"SELECT WAIT_FOR_EXECUTED_GTID_SET('{gset}', {timeout})")
if result != "0":
sys.exit(f"the replica did not catch up (WAIT_FOR_EXECUTED_GTID_SET returned {result})")
def replica_status(self, fields, channel=None, note=None, label="SHOW REPLICA STATUS"):
"""Prints only the named SHOW REPLICA STATUS fields, in the order given, vertically (phone-friendly)."""
stmt = "SHOW REPLICA STATUS" + (f" FOR CHANNEL '{channel}'" if channel else "")
code, out = self._call(["mysql", f"--defaults-file={self.client_cnf}", "-E", "-e", stmt])
if code != 0:
sys.exit(f"SHOW REPLICA STATUS failed: {out}")
values = {}
for line in out.splitlines():
m = re.match(r"\s*([A-Za-z_]+):\s?(.*)$", line)
if m:
values[m.group(1)] = m.group(2)
if note:
print(f"-- {note}")
print(f"mysql> {label}\\G")
for field in fields:
if field in values:
print(f" {field}: {values[field]}")
print()
return values
def _redraw(block, clean):
"""Draws a client table again after its cells were cleaned. The columns come from the first border line; the row
after it is the header (left-aligned); a column whose data cells were padded on the left (numbers) stays
right-aligned (an empty cell says nothing about its column)."""
cuts = [k for k, ch in enumerate(block[0]) if ch == "+"]
rows = [None if line.startswith("+") else [line[x + 1:y] for x, y in zip(cuts, cuts[1:])] for line in block]
header = next(k for k, r in enumerate(rows) if r is not None)
ncol = len(cuts) - 1
right = [any(r is not None and k != header and r[c].strip() and r[c].startswith(" ") for k, r in enumerate(rows))
for c in range(ncol)]
cells = [None if r is None else [clean(x.strip()) for x in r] for r in rows]
widths = [max(len(r[c]) for r in cells if r is not None) for c in range(ncol)]
drawn = []
for k, r in enumerate(cells):
if r is None:
drawn.append("+" + "+".join("-" * (w + 2) for w in widths) + "+")
else:
drawn.append("|" + "|".join(" " + (t.rjust(w) if right[c] and k != header else t.ljust(w)) + " "
for c, (t, w) in enumerate(zip(r, widths))) + "|")
return drawn
def _arg(a):
"""A command-line argument as a reader would type it: quotes only where the shell needs them."""
a = str(a)
if re.fullmatch(r"[\w@%+=:,./-]+", a):
return a
if not re.search(r"[\"$`\\!]", a):
return f'"{a}"'
return shlex.quote(a)
def _show(args, out):
print("$ " + " ".join(_arg(a) for a in args))
if out.strip():
print(out.rstrip("\n"))
print() Step 1 of the recorded run creates 'repl'@'127.0.0.1' with REQUIRE SSL and grants REPLICATION SLAVE. Then it
tries GRANT REPLICATION REPLICA, the natural-sounding name, and the server refuses it with ERROR 1064: the
privilege is still called REPLICATION SLAVE in 8.4. The newer REPLICA family of statements (CHANGE REPLICATION SOURCE TO, START REPLICA, SHOW REPLICA STATUS) does not extend to privileges.
Key idea
The replication account: A MySQL user whose only privilege is REPLICATION SLAVE and whose only job is to log in from a replica to read the source's binary log. Created with REQUIRE SSL, it is refused any login that does not use TLS. carries one privilege (REPLICATION SLAVE) and
REQUIRE SSL, and no access to any table: it can read the binary log and nothing else.
One CHANGE REPLICATION SOURCE statement, over TLS, with auto-positioning
Step 3 of the recorded run points the replica at the source. In 8.4 the one statement is
CHANGE REPLICATION SOURCE TO, with SOURCE_HOST, SOURCE_PORT, SOURCE_USER and SOURCE_SSL=1 for a TLS
connection, and SOURCE_AUTO_POSITION=1 for GTID auto-positioning. The example also sets SOURCE_SSL_CA to the
authority the server trusts (the server generates ca.pem in its data directory when it finds no certificates),
so the replica checks that the source’s certificate chains to that authority; SOURCE_SSL_VERIFY_SERVER_CERT=1
would also check that the certificate names the host.
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- CHANGE REPLICATION SOURCE. The replica is told who the source is, who to log in as, that the connection uses TLS and that it is on auto-positioning. Nothing travels yet; the settings land in the connection metadata.
- START REPLICA. The replica is started with USER and PASSWORD passed in at start time, so the password lives only in the running server's memory and never in the metadata repository on disk.
- TLS handshake and login. The replica opens a TLS connection to the source and checks the source's certificate against the certificate authority it was given (SOURCE_SSL_CA) before it sends the user name and password.
- Replica asks for the gap. The replica tells the source the GTIDs it has committed or received. The source answers with every transaction after them, in order. Nothing in the set is replayed; nothing is skipped.
- Caught up, both threads ON. The IO thread writes received transactions to the relay log; the applier reads it and commits them. When the applier has nothing left to apply, Seconds_Behind_Source is 0 and Replica_IO_Running and Replica_SQL_Running both read Yes.
Step 7 of the recorded run shows the older names, deprecated during 8.0 and removed in 8.4. CHANGE MASTER TO,
START SLAVE and SHOW SLAVE STATUS each return ERROR 1064: a script written for an older version runs into a
syntax error, not a silent misconfiguration.
Step 4 then runs START REPLICA USER='repl' PASSWORD='replpass'. Passing the password at start time keeps it in the
memory of the running replica only, so it never lands in the replica’s connection metadata on disk. The server
recommends this form itself: when a user name or password is stored with CHANGE REPLICATION SOURCE TO, it logs
warning MY-010897, which calls storing them in the connection metadata repository not secure.
Checkpoint
Check: The replication account and TLS · Auto-positioning and CHANGE REPLICATION SOURCE
SHOW REPLICA STATUS: twelve fields a running system cares about
Step 5 of the recorded run reads the state of the replica, and step 6 finds the source’s row on it. SHOW REPLICA STATUS prints dozens of fields, and twelve of them answer the first questions about a running replica. For the
receiver (IO) thread: whether the connection to the source is up (Replica_IO_Running), whether the channel is set
to use TLS (Source_SSL_Allowed), the current state of the thread (Replica_IO_State, usually “Waiting for source
to send event”), and its last error (Last_IO_Error). The applier (SQL) thread has its own: Replica_SQL_Running,
Last_SQL_Error and the delay from the source’s commit time, Seconds_Behind_Source, measured against the events
already received, so it reads 0 once the applier has caught up with the receiver. Auto_Position
is 1 when the connection is on auto-positioning, as in this run. Retrieved_Gtid_Set is what the IO thread has
received so far, and Executed_Gtid_Set is what the SQL thread has committed; the two match when the replica is
caught up and no apply is in flight.
Step 8 then shows a last catch of the first-time setup: a replica running with super_read_only=ON refuses
INSTALL PLUGIN with ERROR 1290. If semi-sync or any plugin a replica may need is installed only after the
read-only flag is on, the install itself cannot run. The habit is to install every plugin a replica may need
before the server reaches its read-only state, and the lesson’s two_node.py session reproduces that error on
purpose so the fix is remembered.
Checkpoint
Check: Reading SHOW REPLICA STATUS
Provisioning a replica: a dump, GTID_PURGED and the clone plugin, with the trade-off Pro: opens with a Pro, Premium or Ultimate pass
A replica starts empty. For it to catch up by replaying every transaction from the beginning of the binary log, the source would need to still have the binary log file that holds row 1, which almost no production server does: binary logs are rotated and purged. A new replica is therefore seeded from a snapshot of the source, and told, through GTID_PURGED, which transactions that snapshot already carries.
In this part:
- Seed the purged set from a consistent snapshot
2 runnable examples (SQL, mysql_session.py, provision.py) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Reading replication health from performance_schema, including a stopped-IO drill Pro: opens with a Pro, Premium or Ultimate pass
SHOW REPLICA STATUS is a snapshot of about forty fields in one long table. performance_schema holds the same
facts in four structured tables that update continuously, by channel and by worker, with microsecond timestamps
instead of whole seconds. A monitoring system reads them directly, and so does a human looking at a mystery lag.
In this part:
- Four PFS tables for channel health
1 diagram · 1 runnable example (mysql_session.py, status_fields.py) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: read a replica from its recorded sessions
The questions below come back to the account, the CHANGE REPLICATION SOURCE TO statement and the status fields,
weakest idea first:
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
On a server you look after
Run SHOW REPLICA STATUS\G on a replica and find the twelve fields of this lesson: both Running flags,
Source_SSL_Allowed, Auto_Position, Seconds_Behind_Source, the two Last_*_Error fields, Replica_IO_State,
Source_Host, Source_User and the two GTID sets.
Interview questions
Three questions go further than the set-up: a new replica whose GTID state is wrong, an account and a channel that missed TLS, and the choice between a dump and the clone plugin for terabytes of data.
-
Mid to senior
A fresh MySQL 8.4 replica has been loaded with mysqldump of the source and then started with CHANGE REPLICATION SOURCE TO ... SOURCE_AUTO_POSITION=1. The applier stops at once with a row conflict. How do you diagnose and fix the state, and how do you keep the next new replica from falling into the same trap?
-
Mid to senior
A code review of a replication set-up finds that the repl account has no REQUIRE SSL clause and that CHANGE REPLICATION SOURCE TO sets SOURCE_USER and SOURCE_PASSWORD without SOURCE_SSL=1. Nothing on the running server looks wrong. What is the risk, and what sequence of changes fixes it on a running pair without stopping writes?
-
Senior to staff
A 2.1 TB InnoDB database must be copied to a new replica in the same data centre, overnight. The schema has no MyISAM tables, the source carries about 4,000 writes a second, and the operations team has used mysqldump for years. Which provisioning path do you choose, and what is the plan, including the roll-back?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which grants does a replication account need on MySQL 8.4?
Only the privilege that lets it read the binary log, plus REQUIRE SSL so that it logs in over TLS alone; no table access at all. GRANT REPLICATION REPLICA fails with ERROR 1064 because that privilege kept its older name in 8.4.
-
Which statement connects an 8.4 replica over TLS with auto-positioning?
CHANGE REPLICATION SOURCE TO with SOURCE_HOST, SOURCE_PORT, SOURCE_USER, SOURCE_SSL=1 and SOURCE_AUTO_POSITION=1. SOURCE_SSL_CA makes the replica check the source's certificate chain; SOURCE_SSL_VERIFY_SERVER_CERT=1 also checks the host name in it.
-
Why give the replication password to START REPLICA rather than store it with CHANGE REPLICATION SOURCE TO?
START REPLICA USER=... PASSWORD=... keeps it only in the running replica's memory. A stored one goes into the connection metadata on disk, and the server warns about that with MY-010897.
-
What happens on 8.4 when a script uses the replication statement names deprecated during 8.0?
They were removed, so each one returns ERROR 1064: the script stops with a syntax error instead of misconfiguring anything. The replacements use the words SOURCE and REPLICA, as in START REPLICA.
-
Which SHOW REPLICA STATUS fields answer the first questions about a running replica?
Replica_IO_Running and Replica_SQL_Running, Source_SSL_Allowed, Auto_Position, Seconds_Behind_Source, Last_IO_Error and Last_SQL_Error, Replica_IO_State, Source_Host, Source_User, and Retrieved_Gtid_Set beside Executed_Gtid_Set.
-
What does Seconds_Behind_Source measure?
The applier's delay behind the source's commit times, measured against the events the replica has already received, so it reads 0 once the applier has caught up with the receiver.
-
Why install every plugin a replica may need before super_read_only is on?
With super_read_only=ON, INSTALL PLUGIN fails with ERROR 1290, so a semi-sync plugin left for later cannot be installed.
Key takeaways
- A replication account carries only
REPLICATION SLAVEandREQUIRE SSL; its password is passed toSTART REPLICAat start time, not stored in the connection metadata. - One
CHANGE REPLICATION SOURCE TO ... SOURCE_AUTO_POSITION=1, SOURCE_SSL=1is enough to connect a replica on 8.4;CHANGE MASTER TO,START SLAVEandSHOW SLAVE STATUSare removed and returnERROR 1064. - Plugins a replica may need go in before
super_read_onlyis on; afterwardsINSTALL PLUGINfails withERROR 1290. - The twelve
SHOW REPLICA STATUSfields to read first are the twoRunningflags,Source_SSL_Allowed,Auto_Position,Seconds_Behind_Source, the twoLast_*_Errorfields,Replica_IO_State,Source_Host,Source_User, and theRetrieved_Gtid_Set/Executed_Gtid_Setpair.
Terms in this lesson
- clone plugin
- A server plugin, mysql_clone.so, that copies a running donor server's data over the network into a recipient server: CLONE INSTANCE FROM removes the recipient's own schemas, tables and binary logs and writes the donor's data in their place.
- multi-source replication
- A set-up in which one replica reads from several sources at once, each on its own channel. Each channel keeps its own connection, relay log and applier, and is named by FOR CHANNEL '<name>' in CHANGE REPLICATION SOURCE TO, START REPLICA and SHOW REPLICA STATUS.
- relay log
- A replica's numbered files, in the binary log's format, holding the events the receiver (IO) thread fetched from the source until the applier runs them. A file rotates at max_relay_log_size (max_binlog_size when that is 0), and the applier deletes each file once it has run all its events.
- replication account
- A MySQL user whose only privilege is REPLICATION SLAVE and whose only job is to log in from a replica to read the source's binary log. Created with REQUIRE SSL, it is refused any login that does not use TLS.
- replication channel
- A named replication connection on a replica: one source, one connection, one relay log and one applier. Multi-source replication runs several channels in parallel, each addressed by FOR CHANNEL '<name>'.
- super_read_only
- A system variable: when ON, the server refuses client updates even from accounts with SUPER or CONNECTION_ADMIN, while replication threads still apply replicated transactions. Setting it ON also sets read_only ON. INSTALL PLUGIN and INSTALL COMPONENT are refused too, as they write to the mysql system schema.
References
- MySQL 8.4 Reference Manual: 19.1.3 Replication with GTIDs (Oracle Corporation)
- MySQL 8.4 Reference Manual: 19.1.2.3 Creating a User for Replication (Oracle Corporation)
- MySQL 8.4 Reference Manual: 15.4.2.1 CHANGE REPLICATION SOURCE TO Statement (Oracle Corporation)
- MySQL 8.4 Reference Manual: 15.4.2.9 START REPLICA Statement (Oracle Corporation)
- MySQL 8.4 Reference Manual: 15.7.7.37 SHOW REPLICA STATUS Statement (Oracle Corporation)
- MySQL 8.4 Reference Manual: 19.3.1 Configuring MySQL to Use Encrypted Connections (Oracle Corporation)
- MySQL 8.4 Reference Manual: 7.6.7 The Clone Plugin (Oracle Corporation)
- MySQL 8.4 Reference Manual: 29.12.11 Performance Schema Replication Tables (Oracle Corporation)
- MySQL 8.4 Release Notes (Oracle Corporation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress