MySQL12 min read

MySQL Multi-Source Replication: Fan-In Without the Conflicts

Multi-source replication lets one replica fan in from many primaries with FOR CHANNEL syntax — but there is no conflict detection and no last-write-wins. Setup, per-channel monitoring, GTID bookkeeping, and when a pipeline beats fan-in.

We consolidated six regional order databases onto one reporting replica in an afternoon, which is the part of multi-source replication that demos well. The part that does not demo well arrived three weeks later, at 2 a.m., when one channel's applier thread stopped dead with error 1062, duplicate key, because two sources had been inserting into a shared reference table with the same auto-increment values. Multi-source replication is genuinely easy to set up and genuinely unforgiving about conflicts, and the gap between those two facts is where this article lives.

The short version: a channel is an independent replication stream with its own name, threads, and relay logs; fan-in is wonderful for consolidating writes that can never overlap; and MySQL gives you exactly zero conflict resolution, so overlapping writes are your design problem, not the database's. Then the operational reality: per-channel monitoring, filters, GTID bookkeeping, and when an honest data pipeline beats fan-in.

What is a replication channel, and when does fan-in make sense?

A channel is one complete replication stream — its own IO thread pulling from one source, its own relay logs, its own applier — and a replica can host many of them side by side, each named, each pointed at a different primary. Fan-in pays when several sources hold data you want to query or back up in one place and their writes are naturally disjoint: per-shard order systems rolling up to a reporting instance, regional databases consolidating for analytics, per-tenant schemas gathered onto a migration staging box. The common thread is disjointness. If two sources can write the same row of the same table, fan-in is the wrong tool no matter how convenient it looks, because nothing in the server will arbitrate that fight for you.

How do you configure channels with FOR CHANNEL?

Every replication command takes an optional FOR CHANNEL clause, and the channel name is the identity everything else keys off. On 8.0.23 and later the syntax says SOURCE and REPLICA; older releases say MASTER and SLAVE, and both spellings still parse. A minimal two-source fan-in with GTIDs:

CHANGE REPLICATION SOURCE TO
  SOURCE_HOST = 'eu-orders.internal',
  SOURCE_USER = 'repl',
  SOURCE_PASSWORD = 'secret',
  SOURCE_AUTO_POSITION = 1
FOR CHANNEL 'eu';

CHANGE REPLICATION SOURCE TO
  SOURCE_HOST = 'us-orders.internal',
  SOURCE_USER = 'repl',
  SOURCE_PASSWORD = 'secret',
  SOURCE_AUTO_POSITION = 1
FOR CHANNEL 'us';

START REPLICA FOR CHANNEL 'eu';
START REPLICA FOR CHANNEL 'us';

SHOW REPLICA STATUS FOR CHANNEL 'eu'\G

The mechanical requirements: each source needs a distinct server_id, GTIDs make life much easier because SOURCE_AUTO_POSITION removes per-channel file-and-position bookkeeping, and RESET REPLICA ALL FOR CHANNEL 'eu' wipes one channel's configuration and relay logs without touching the others. Each channel runs its own applier, so replica_parallel_workers is configured globally but consumed per channel — a busy channel can have workers applying transactions while an idle one has none.

What happens when two sources write the same row?

The channel that arrives second stops with an error, and nothing resolves it for you. There is no conflict detection in multi-source replication — no vector clocks, no timestamps compared, no last-write-wins. The applier executes transactions in arrival order, and when an insert collides with an existing key the applier dies with error 1062; an UPDATE or DELETE that finds no row dies with 1032. The "winner" is whichever transaction happened to apply first, and the loser's channel sits stopped until a human reconciles the data by hand — while the channels drift further apart with every minute you sleep. Prevention is the entire game, and the standard tools are auto_increment_increment and auto_increment_offset so sources mint disjoint IDs, application-level keyspaces such as ULIDs where you cannot coordinate numerics, and schema-level separation — replicate each source into its own schema and union them in views, which sidesteps collisions entirely. And leave replica_skip_errors alone in this topology: skipping 1062 on a fan-in replica does not resolve a conflict, it converts loud divergence into silent divergence.

How do you monitor each channel in performance_schema?

The replication_* tables are all keyed by CHANNEL_NAME, so per-channel health is one query, not six terminals of SHOW REPLICA STATUS. The ones I use constantly: replication_connection_configuration and replication_connection_status for the IO side — source host, service state, last heartbeat — replication_applier_configuration and replication_applier_status for the applier side, and replication_applier_status_by_coordinator plus replication_applier_status_by_worker for parallel appliers, where the last applied transaction and the exact error text live. A daily-driver health check:

SELECT cs.CHANNEL_NAME,
       cs.SERVICE_STATE AS io_state,
       ap.SERVICE_STATE AS applier_state,
       ap.LAST_ERROR_NUMBER,
       LEFT(ap.LAST_ERROR_MESSAGE, 120) AS last_error
FROM performance_schema.replication_connection_status cs
JOIN performance_schema.replication_applier_status ap
  ON ap.CHANNEL_NAME = cs.CHANNEL_NAME;

-- per-worker lag: source commit time against apply-finish time
SELECT CHANNEL_NAME,
       LAST_APPLIED_TRANSACTION AS last_applied_trx,
       TIMESTAMPDIFF(SECOND,
         LAST_APPLIED_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP,
         LAST_APPLIED_TRANSACTION_END_APPLY_TIMESTAMP) AS seconds_behind
FROM performance_schema.replication_applier_status_by_worker;

That seconds_behind is the honest per-channel lag — a transaction's original commit timestamp on the source against when it finished applying on the replica — and unlike NOW() minus the last apply time, it does not grow forever on an idle but fully caught-up source. Note that the coordinator table only fills in when parallel appliers are enabled, while the worker table carries the last-applied timestamps either way; the full lag-reading methodology, including heartbeat-based measurement, is in replication lag diagnosis. If performance_schema is not already on with sane consumers, the low-overhead setup gets you there without buying observability with CPU.

How do GTIDs and filters behave across channels?

GTIDs are global to the replica, filters are scoped to the channel, and confusing the two scopes is the classic multi-source mistake. @@GLOBAL.gtid_executed is one set — the union of everything every channel has ever applied, plus local transactions — while SHOW REPLICA STATUS per channel shows that channel's Retrieved_Gtid_Set, the portion that arrived from its own source. This is exactly what you want until you promote the fan-in replica or repoint a channel at a different source: the union set makes it easy to create errant transactions relative to the new source, the trap catalogued in GTID errant transactions, so compare sets before any topology change. Filters go the other way: CHANGE REPLICATION FILTER REPLICATE_DO_DB = (shop_eu) FOR CHANNEL 'eu' applies only to that channel, shows up in performance_schema.replication_applier_filters, and requires the channel stopped while you change it. Per-channel filters are how you keep fan-in replicas lean — replicate the schema that channel owns, ignore the rest — but remember they filter what applies, not what is pulled; the IO thread still ships the full binlog stream across the network.

When does a data pipeline beat fan-in?

As soon as you need anything fan-in does not do: conflict resolution, transformation, schema unification, or a destination that is not MySQL. Multi-source replication is a dumb pipe by design — same schema expected on both ends, conflicts fatal, no mapping, no enrichment. The moment the six regional databases have slightly different schemas, or the same reference rows exist everywhere, or the target is a warehouse rather than a MySQL replica, you are describing a CDC pipeline: Debezium into Kafka with stream processing to merge and deduplicate, or a managed ETL service doing the same job with more invoices and fewer config files. The replication-versus-CDC tradeoffs get a full treatment in MySQL CDC vs PostgreSQL logical decoding. My rule after the 2 a.m. incident: disjoint writes plus identical schema plus a MySQL destination means fan-in, happily; any deviation from those three means pipeline, and pretending otherwise is how you end up hand-merging primary keys at 2 a.m.

Where MonPG stands on MySQL

I build MonPG, so plainly: MonPG monitors PostgreSQL today, and MySQL support is in active development, not shipped. Multi-channel replicas multiply exactly the signals worth watching — per-channel applier state, lag that drifts on one source while the others keep up, error counts by channel — and rolling those replication_* tables into a single health view is squarely what the MySQL work is built to do. The MySQL monitoring (coming soon) page tracks it as it ships. Until then the same evidence-first monitoring runs on the PostgreSQL side, and more of these field notes live on the blog.