August 22, 2026 · Mohamed Akour
MySQL native replication on Azure Flexible Server: notes from a rebuild
I recently rebuilt a MySQL 8 replica that had been quietly broken, and then spent a week learning why it broke. The environment was Azure Database for MySQL — Flexible Server, three servers, each in a different Entra tenant, with a ~70 GB database underneath. Names, hosts and identifiers below are placeholders; everything else is exactly what happened.
Most of what follows is not in the replication chapter of the MySQL manual, because most of it comes from what a managed service takes away from you.
The shape of the problem
Production feeds two downstream copies:
┌──(managed migration service)──▶ copy-a
prod-mysql
= real prod └──(native GTID replication)─────▶ replica-b
The managed-migration hop to copy-a replicates schema once and then sits
there — useful for a one-time move, not a living copy. The replica-b leg is
native GTID replication and is the one this post is about. It used to hang off
copy-a — a replica of a replica of prod — which is how it drifted far enough
to need a rebuild. It now replicates directly from prod.
One thing worth saying before any of the technical detail: the server names
lied. The server with the customer-facing brand in its name was real
production; the one with prod in its name was a downstream copy; the
subscription named “Production” held the copy, not prod. Before anything
destructive I now confirm identity with:
SELECT @@GLOBAL.server_uuid;
and check it against a UUID written down in the runbook. A hostname is a label
someone chose in a hurry two years ago. A server_uuid is the server.
Azure takes SUPER away, and that changes the procedure
On Flexible Server nobody gets SUPER — not the admin account, not you.
Everything that would normally be a SET GLOBAL becomes a server parameter
change through the portal or az, which means a parameter’s scope is no longer
your decision and some of them require a restart. Four consequences hit the
rebuild directly:
SET @@GLOBAL.GTID_PURGEDin a dump fails with error 1227.mysqldump --set-gtid-purged=ONwrites that line, and the restore dies on it.START REPLICAis denied, also 1227. The replacement isCALL mysql.az_replication_start;.replica_exec_modeis read-only on the 8.0.21 build, soIDEMPOTENTis off the table.replica_skip_errorsisn’t exposed either, andsql_slave_skip_counteris incompatible with GTID auto-positioning. Net effect: you cannot skip a replication conflict. Every one gets repaired by hand, at the row level.- Replication is configured through stored procedures, and there are two of them. Use the GTID one:
CALL mysql.az_replication_change_master_with_gtid(
'prod-host', 'repl_user', '<password>', 3306, '<ca-cert>');
The other procedure, mysql.az_replication_change_master, takes seven
arguments and is file/position based. Passing '' and 0 for the file and
position does not silently enable auto-positioning — it starts from the
oldest binlog on the source and dies on pre-GTID anonymous transactions.
Point-in-time restore cannot seed a replica
This is the one that cost the most time, so it gets its own section.
The obvious way to seed a 70 GB replica on a managed platform is to let the platform do it: point-in-time restore prod into a new server, then attach that server to prod as a replica. It doesn’t work, and it fails in a way that looks like success.
A PITR-restored server comes up with gtid_mode=OFF and no GTID history.
gtid_executed, gtid_purged and mysql.gtid_executed are all empty. Turning
GTID on afterwards does not reconstruct the history — it just starts a new one.
So the restored server holds prod’s data at a known point in time while having
no idea which transactions that point corresponds to. You have a perfect
snapshot and no coordinates for it.
If you enable GTID and then set a position with az mysql flexible-server gtid reset, be very careful about which GTID set you use. A seed server that has
been running for a while has its own UUID in gtid_executed alongside prod’s.
Feed the whole set back and the replica starts life believing it has already
applied transactions prod has never heard of. Take only the prod-UUID
portion.
The working answer is to dump the real source.
The seeding procedure that worked
mysqldump \
--single-transaction \
--set-gtid-purged=ON \
--routines --events --triggers \
--databases appdb > appdb.sql
--single-transaction does not block writes on InnoDB — it takes the snapshot
inside a REPEATABLE READ transaction, no table locks. Users read and write
throughout. What it does cost the source is a sustained sequential read of the
whole dataset, so it is still a “run it out of hours” operation, not a free one.
Then read the start position out of the dump before restoring it:
grep -n "SET @@GLOBAL.GTID_PURGED" appdb.sql
Strip that line while streaming the restore, because it will be rejected:
grep -v "SET @@GLOBAL.GTID_PURGED" appdb.sql | mysql -h replica-host -u admin -p
Note grep -v into a pipe, not sed -i. In-place editing of a 45 GB file
needs a second 45 GB of free disk you probably don’t have.
Then set the position through the management plane, point replication at prod, and start:
az mysql flexible-server gtid reset \
--resource-group <rg> --server-name <replica> \
--gtid-set "<the value from the dump>"
CALL mysql.az_replication_change_master_with_gtid(...);
CALL mysql.az_replication_start;
Two things I’d do differently next time:
Test --source-data=2 first. Because Azure denies RELOAD,
--set-gtid-purged=ON reads gtid_executed a moment apart from the snapshot
itself. That gap produced a handful of duplicate-key (1062) and missing-row
(1032) errors on start-up that had to be repaired by hand. --source-data=2
captures the position atomically with the snapshot.
Verify row counts after the restore. The restore silently lost about 1,500
rows from the one table with a mediumblob column. Whole INSERT statements
vanished, the client exited 0, and the error log was empty. It was found by
comparing COUNT(*) across all 191 tables, and repaired by re-copying that one
table with --skip-extended-insert --max-allowed-packet=1G. A clean exit code
is not proof of a complete restore.
When the replica is also written to
This replica is not read-only. The application writes directly into its own log
tables on the replica, which means read_only must stay off — turning it on
“to make the replica a proper replica” breaks the app. Two structural
consequences follow.
First, the shared log tables have to be excluded from replication, or prod’s
inserts and the app’s inserts fight over the same AUTO_INCREMENT values and
the SQL thread halts on error 1062 constantly:
replicate_wild_ignore_table =
mysql.%,information_schema.%,performance_schema.%,sys.%,
appdb.app_logs_%
Second — and this is the trap — that filter is a blacklist. Any new database created on prod stops replication dead with “Unknown database” until someone adds it to the list. That is a permanent piece of coupling between whoever creates schemas on prod and whoever owns the replica.
Because the app writes here, divergence between the two servers can’t be
prevented, only detected. There’s a script of 191 exact COUNT(*) statements
that gets run against both servers and diffed.
binlog_row_image=MINIMAL and the strict applier
Replication also halted repeatedly on error 1364, “Field doesn’t have a default value”, applying perfectly ordinary inserts.
The cause is a combination: prod runs binlog_row_image=MINIMAL, and the
application overrides sql_mode per session. So the binlog row events omit
columns that are NOT NULL without a default, and a replica applying them under
a strict sql_mode rejects them. The fix in place is a deliberately relaxed
sql_mode on the replica:
sql_mode = ONLY_FULL_GROUP_BY,ERROR_FOR_DIVISION_BY_ZERO
Someone will eventually find that setting, decide it looks sloppy, and “fix” it.
It is load-bearing. The real fix is binlog_row_image=FULL on prod, which costs
binlog volume and needs a maintenance window.
I’ll also correct something I believed at the time: I’d assumed MINIMAL was
safe because every table has a primary key. That’s wrong. A primary key covers
row identification — which row to update. It says nothing about missing
columns in an insert.
Two deadlines on a stalled replica
Here is the part I’d want someone to tell me before I touched a stalled replica.
When the stall was found, the SQL thread was 1.68 million transactions behind, and reading a relay log whose corresponding binlog prod had already purged. Prod keeps five days of binlogs; the stall had gone unnoticed for longer.
Which means: the relay logs on the replica were the only surviving copy of those transactions. Anything that drops them — a reset, a rebuild, re-pointing the replica, a “let’s just start clean” — converts a ten-minute repair into a full 70 GB reseed. So the first command against a stalled replica is not a fix, it’s a comparison:
SHOW REPLICA STATUS\G -- Relay_Source_Log_File
against SHOW BINARY LOGS; on the source. If your file is still in the
source’s list, you have slack. If it isn’t, the relay logs are irreplaceable and
every subsequent decision changes.
The second deadline is quieter. relay_log_space_limit was 1 GiB and pegged at
it, which stalls the IO thread — it had stopped fetching several binlogs
behind where prod was writing. When prod eventually purges the file the IO
thread is parked on, it dies with error 1236, and that is also a full reseed.
There were about eight binlogs of headroom, roughly four days.
So a stalled replica has a repair window measured by the source’s binlog retention, and a second one measured by relay log space. Both were days, not weeks.
One pleasant surprise: replica_parallel_workers was 0 — single-threaded apply
— but measured throughput was about 110 transactions per second, which
cleared the 1.67 M backlog in roughly four hours. That was faster than an Azure
parameter change plus a restart would have been. Measure the actual apply rate
before assuming catch-up needs tuning.
The conflict that will keep coming back
Five days after the rebuild, replication stopped again on error 1062 — and this one is structural rather than a mistake.
There’s a small table that hands out ID ranges for other tables. It has an auto-increment primary key, and both prod and the replica-side app write to it. Most rows are update-in-place counters, which self-heal on catch-up. But one row is DELETE-then-INSERTed on every cycle, so it burns a fresh auto-increment value each time. Eventually both sides allocated the same one. Collision.
The tempting fix is to add that table to the ignore filter alongside the log tables. Don’t. Unlike log tables, this one hands out IDs for real business tables. Freezing its counters on the replica would let the replica-side app issue IDs that collide with prod’s — trading a loud replication error for silent data corruption.
So it gets repaired by hand, and will again:
-- on prod: confirm the id genuinely isn't there
SELECT * FROM id_allocator WHERE id = <n>;
-- on the replica: remove the locally-allocated row
DELETE FROM appdb.id_allocator WHERE id = <n>;
CALL mysql.az_replication_start;
Diff the whole table on both sides first. Rows that differ only in their counter value are just lag, not divergence.
What I’d actually change
The technical findings above are all downstream of one gap: nothing alerted on
Replica_SQL_Running going to No. Both incidents were found by a person
noticing something else. The second one had been broken for five days — most of
the binlog retention window — before anyone looked.
Every hard problem in this post existed only because the easy signal wasn’t
being watched. A five-line check on Replica_SQL_Running, Last_Error and
Seconds_Behind_Source would have turned a week of forensics into a Tuesday
afternoon.
The rest, in short:
- Confirm server identity by
server_uuid, never by hostname. - PITR is a backup, not a replication seed.
- Compare
Relay_Source_Log_Fileagainst the source’s binlogs before you touch a stalled replica. - A clean
mysqldumpexit code is not a complete restore. Count rows. - On a managed MySQL, assume every conflict must be repaired by hand, and write the repair recipe down the first time you work it out.