Splitting write traffic from read traffic keeps the primary MySQL server focused on transactions that must land in one place, while replica nodes absorb reporting, search, and other read-heavy workloads. That reduces contention on the primary and gives applications a stable pair of database endpoints instead of hard-coding individual hosts.
This layout combines file-and-position replication with HAProxy TCP frontends. The source server writes binary log events, replicas replay those events to stay current, and HAProxy exposes one port for writes and a separate port for read-only sessions because it routes whole TCP connections rather than inspecting SQL statements.
Replication is asynchronous, so the read endpoint can return slightly stale data during bursts, long transactions, or link problems. The procedure uses current MySQL 8.4 syntax such as SHOW BINARY LOG STATUS, --source-data, CHANGE REPLICATION SOURCE TO, START REPLICA, and SHOW REPLICA STATUS; if the topology already uses GTID, keep the GTID method instead of switching to manual log coordinates.
Common MySQL config paths include /etc/mysql/mysql.conf.d/mysqld.cnf on Debian or Ubuntu and /etc/my.cnf.d/mysql-server.cnf on RHEL, Rocky Linux, AlmaLinux, or SUSE. HAProxy commonly uses /etc/haproxy/haproxy.cfg.
$ sudoedit /etc/mysql/mysql.conf.d/replication.cnf
[mysqld] server-id = 1 log-bin = mysql-bin binlog-format = ROW
Every server in the topology needs its own server-id value. If the source only listens on 127.0.0.1, remote replicas will not be able to connect.
$ sudo systemctl restart mysql
Use the installed unit name such as mysql or mysqld.
Related: How to manage MySQL or MariaDB service with systemctl in Linux
$ mysql --user=root --password --execute "SHOW BINARY LOG STATUS\G" *************************** 1. row *************************** File: mysql-bin.000003 Position: 2557 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set:
$ mysql --user=root --password mysql> CREATE USER 'repl'@'db-replica-1.example.net' IDENTIFIED BY 'strong_repl_password'; mysql> CREATE USER 'repl'@'db-replica-2.example.net' IDENTIFIED BY 'strong_repl_password'; mysql> GRANT REPLICATION SLAVE ON *.* TO 'repl'@'db-replica-1.example.net'; mysql> GRANT REPLICATION SLAVE ON *.* TO 'repl'@'db-replica-2.example.net'; mysql> FLUSH PRIVILEGES;
Use replica-specific hostnames or IP addresses instead of '%' for the replication account.
$ mysqldump --user=root --password --databases appdb --single-transaction --routines --triggers --events --source-data=2 > /var/tmp/mysql-ha-seed.sql
Replace appdb with the database set that the application uses. Use --all-databases only when the replicas should be seeded with every application database on the source.
--single-transaction keeps InnoDB tables consistent, but it does not protect non-transactional tables such as MyISAM.
$ scp /var/tmp/mysql-ha-seed.sql root@db-replica-1.example.net:/var/tmp/ $ scp /var/tmp/mysql-ha-seed.sql root@db-replica-2.example.net:/var/tmp/
$ sudoedit /etc/mysql/mysql.conf.d/replica.cnf
[mysqld] server-id = 2 relay-log = replica1-relay-bin
Change both server-id and relay-log on every replica so they stay unique. Enable read_only and super_read_only after the seed import and replica startup so the restore is not blocked.
$ sudo systemctl restart mysql
Use the installed unit name such as mysql or mysqld.
Related: How to manage MySQL or MariaDB service with systemctl in Linux
$ mysql --user=root --password < /var/tmp/mysql-ha-seed.sql
Import into an empty or disposable dataset only, because restoring a seed dump over unrelated existing data can create conflicts or silent drift.
-- CHANGE REPLICATION SOURCE TO SOURCE_LOG_FILE='mysql-bin.000003', SOURCE_LOG_POS=2557;
The dump header is the safest source for file and position values because --source-data records the coordinates that match the snapshot.
$ mysql --user=root --password mysql> CHANGE REPLICATION SOURCE TO -> SOURCE_HOST='db-primary.example.net', -> SOURCE_PORT=3306, -> SOURCE_USER='repl', -> SOURCE_PASSWORD='strong_repl_password', -> SOURCE_LOG_FILE='mysql-bin.000003', -> SOURCE_LOG_POS=2557, -> GET_SOURCE_PUBLIC_KEY=1;
GET_SOURCE_PUBLIC_KEY=1 is needed when the replication account uses the default caching_sha2_password plugin and the channel is not already protected with TLS or a configured public key file.
$ mysql --user=root --password --execute "START REPLICA;"
$ mysql --user=root --password --execute "SHOW REPLICA STATUS\G" *************************** 1. row *************************** Replica_IO_State: Waiting for source to send event Source_Host: db-primary.example.net Source_User: repl Source_Port: 3306 Source_Log_File: mysql-bin.000003 Replica_IO_Running: Yes Replica_SQL_Running: Yes Seconds_Behind_Source: 0 Last_IO_Error: Last_SQL_Error: ##### snipped #####
If either thread reports No, fix the replication error before putting HAProxy in front of the replicas.
$ mysql --user=root --password --execute "SET PERSIST read_only=ON; SET PERSIST super_read_only=ON;"
Applying the guardrails after the restore keeps the seed import writable while still making later replica restarts come back in read-only mode.
$ sudoedit /etc/haproxy/haproxy.cfg
defaults
mode tcp
timeout connect 5s
timeout client 1m
timeout server 1m
frontend mysql_write
bind *:3306
default_backend mysql_primary
backend mysql_primary
option tcp-check
server mysql-primary db-primary.example.net:3306 check
frontend mysql_read
bind *:3307
default_backend mysql_replicas
backend mysql_replicas
balance roundrobin
option tcp-check
server mysql-replica-1 db-replica-1.example.net:3306 check
server mysql-replica-2 db-replica-2.example.net:3306 check
Run HAProxy on a separate load balancer host, or choose ports that do not collide with a local mysqld listener. Current MySQL 8.4 keeps caching_sha2_password as the default account plugin and disables mysql_native_password by default, so a plain TCP health check is the safest default. HAProxy can use option mysql-check user <name> only when the backend check account is deliberately configured with an auth method that the probe supports.
Tool: HAProxy Backend Config Generator
$ sudo haproxy -c -V -f /etc/haproxy/haproxy.cfg Configuration file is valid
$ sudo systemctl restart haproxy
A single HAProxy node is still a single point of failure, so add a redundant pair or upstream failover mechanism if the database endpoint must survive load balancer host loss.
$ sudo ss -lntp 'sport = :3306 or sport = :3307' State Recv-Q Send-Q Local Address:Port Peer Address:Port Process LISTEN 0 2048 0.0.0.0:3306 0.0.0.0:* users:(("haproxy",pid=1528,fd=7)) LISTEN 0 2048 0.0.0.0:3307 0.0.0.0:* users:(("haproxy",pid=1528,fd=8))
$ mysql --host db-lb.example.net --port 3306 --user appuser --password --execute "SELECT @@hostname AS backend, @@read_only AS read_only;" +---------------+-----------+ | backend | read_only | +---------------+-----------+ | db-primary-01 | 0 | +---------------+-----------+
$ mysql --host db-lb.example.net --port 3307 --user appuser --password --execute "SELECT @@hostname AS backend, @@read_only AS read_only;" +---------------+-----------+ | backend | read_only | +---------------+-----------+ | db-replica-01 | 1 | +---------------+-----------+
Repeated connections to the read endpoint can rotate across different replicas because balancing happens per TCP session.
Use db-lb.example.net:3306 for statements that must reach the primary node.
Use db-lb.example.net:3307 for reads, and keep replication lag in mind when strict read-after-write consistency is required.