Database NotesRohit’s interview study guide
Chapter 12

Scaling, Replication and Operations

Running MySQL beyond one box: scaling up and out, binlog replication and its flavours, replica lag, partitioning and sharding, connection limits, backups with point-in-time recovery, failover, and caching in front of the database.

10 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Scaling up vs scaling out

Added

When one database server can't keep up, you have two directions. Scaling up (vertical) means a bigger machine: more RAM so the working set fits in memory, faster disks, more cores. Scaling out (horizontal) means more machines: read replicas that copy the data and share the reads, and eventually sharding, where each server holds only part of the data and takes part of the writes.

The mental model is a busy restaurant kitchen. Scaling up is a bigger kitchen with a faster stove — nothing about how you cook changes, but there's a biggest kitchen money can buy. Scaling out is opening more kitchens: copies of the menu (replicas) can serve more diners, but if every order must also update one shared stock book (writes), the copies don't help; you'd have to split the stock book by region (sharding), and then "how much flour do we have in total?" means phoning every kitchen.

For a relational database, scaling up is the first move and goes further than people expect — a single modern server with hundreds of gigabytes of RAM handles a lot of traffic. The cheapest "scale-up" of all is often making sure the hot data fits in the InnoDB buffer pool, MySQL's in-memory page cache:

bp-scan.sh
#!/bin/bash
# Scan bp_test (1,000,000 rows, 234 MB) three times, reporting time and InnoDB disk reads for each scan.
cd "$(dirname "$0")/docker"
for i in 1 2 3; do
docker compose exec -T sec sh -c 'MYSQL_PWD=study mysql -uroot ptdemo -N 2>/dev/null' <<'SQL'
SELECT VARIABLE_VALUE INTO @r0 FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_pages_read';
SET @t = NOW(6);
SELECT COUNT(*) INTO @n FROM bp_test WHERE payload LIKE 'y%';
SELECT VARIABLE_VALUE INTO @r1 FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_pages_read';
SELECT CONCAT('buffer pool ', @@innodb_buffer_pool_size DIV 1048576, ' MB: scan ', ROUND(TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) / 1000), ' ms, pages read from disk: ', @r1 - @r0);
SQL
done
# Run it with the default 128 MB buffer pool, then grow the pool online and run it again:
#   SET GLOBAL innodb_buffer_pool_size = 1024 * 1024 * 1024;
Result
buffer pool 128 MB: scan 157 ms, pages read from disk: 10389
buffer pool 128 MB: scan 135 ms, pages read from disk: 9781
buffer pool 128 MB: scan 139 ms, pages read from disk: 9781
buffer pool 1024 MB: scan 141 ms, pages read from disk: 6738
buffer pool 1024 MB: scan 74 ms, pages read from disk: 0
buffer pool 1024 MB: scan 71 ms, pages read from disk: 0
What’s happening
  1. bp_test has 1,000,000 rows of about 230 bytes: 234 MB, roughly 15,000 InnoDB pages of 16 KB. It runs on a separate MySQL 9.7 server started for this chapter, so changing global settings doesn't disturb anyone else.
  2. With the Docker image's default 128 MB buffer pool, the table doesn't fit. Every scan has to read about 9,800 pages from disk again: the pool keeps only part of the table, and a scan pushes out what the previous scan loaded. Each scan takes about 140 ms.
  3. SET GLOBAL innodb_buffer_pool_size resizes the pool online (MySQL 5.7.5+), no restart. The first scan after growing it still reads 6,738 pages, because those pages weren't in memory yet.
  4. From then on the whole table is in RAM: 0 pages read from disk and the scan time halves to about 72 ms. Here the "disk" is a fast laptop SSD with the operating system's file cache behind it; on a cloud volume each page read costs far more, so the gap is usually much bigger.
  5. That's scaling up in miniature: same query, same schema, more memory. The usual guidance is to size innodb_buffer_pool_size at 50–75% of a dedicated server's RAM (or let MySQL do it with innodb_dedicated_server = ON).
AdvancedScale up, replicas or shards compared
Scale up (bigger server)Read replicasSharding
Helps readsyesyes, add more replicasyes
Helps writesyes, up to the biggest machineno: every replica applies every writeyes: each shard takes its share
Application changesnoneroute reads, cope with replication lagroute every query by shard key; no cross-shard joins or transactions
Operational costlowmoderatehigh: resharding, backups and schema changes per shard
Limitthe largest instance you can buywrite throughput of one servercoordination and complexity
AdvancedThe usual order of moves

The order most teams follow, cheapest first: fix the queries and indexes (see Finding slow queries), make sure the working set fits in the buffer pool, cache what's read often and changes rarely, scale the server up, add read replicas for read-heavy traffic, move analytics to a separate system, partition very large tables for easier maintenance, and only then shard. Each step adds less benefit per unit of complexity than the one before.

#Replication: binlog, source and replicas

Added

Replication keeps copies of a database on other servers. In MySQL it's built on the binary log (binlog): every committed transaction that changes data is written to the binlog on the source (called the master until MySQL 8.0.22). Each replica (formerly slave) connects, streams the binlog, and re-applies the same changes to its own copy.

The mental model is a ship's logbook copied by clerks. The captain (source) writes every change into the log as it commits. Each clerk (replica) has two jobs: a receiver thread (IO thread) copies new log pages into its own notebook (the relay log), and applier threads (SQL threads, several workers since 8.0) carry out the entries in order. The clerks never talk to the captain about what to do, only about where they are in the log.

Since MySQL 8.0 the binlog is on by default, and since 5.7.7 it defaults to row format: it records the before and after image of each changed row, not the SQL text. With GTIDs (global transaction identifiers, source_uuid:sequence) every transaction has a name, so a replica can simply ask "send me everything I haven't executed" instead of tracking file names and byte offsets.

These examples run on two MySQL 9.7 servers started for this chapter, source and replica, each with gtid_mode = ON. That's real replication between two separate mysqld processes, though both run on one laptop.

SQL
-- on the source
CREATE USER 'repl'@'%' IDENTIFIED BY 'Repl-pass-123';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
SHOW VARIABLES WHERE Variable_name IN ('log_bin', 'binlog_format', 'server_id', 'gtid_mode', 'binlog_expire_logs_seconds');

-- on the replica
CHANGE REPLICATION SOURCE TO
  SOURCE_HOST = 'source',
  SOURCE_PORT = 3306,
  SOURCE_USER = 'repl',
  SOURCE_PASSWORD = 'Repl-pass-123',
  SOURCE_AUTO_POSITION = 1,
  SOURCE_SSL = 1;
START REPLICA;
SET GLOBAL super_read_only = ON;
SHOW REPLICA STATUS\G
Result
+----------------------------+---------+
| Variable_name              | Value   |
+----------------------------+---------+
| binlog_expire_logs_seconds | 2592000 |
| binlog_format              | ROW     |
| gtid_mode                  | ON      |
| log_bin                    | ON      |
| server_id                  | 1       |
+----------------------------+---------+
Note (Code 1759): Sending passwords in plain text without SSL/TLS is extremely insecure.
Note (Code 1760): Storing MySQL user name or password information in the connection metadata repository is not secure and is therefore not recommended. Please consider using the USER and PASSWORD connection options for START REPLICA; see the 'START REPLICA Syntax' in the MySQL Manual for more information.
             Replica_IO_State: Waiting for source to send event
                  Source_Host: source
           Replica_IO_Running: Yes
          Replica_SQL_Running: Yes
        Seconds_Behind_Source: 0
           Source_SSL_Allowed: Yes
           Retrieved_Gtid_Set: 98f018fc-c975-11f1-a296-ce36bc0880eb:1-7
            Executed_Gtid_Set: 98f018fc-c975-11f1-a296-ce36bc0880eb:1-7,
df722497-c975-11f1-831a-ee8805be8d05:1-5
                Auto_Position: 1
What’s happening
  1. The replica logs in to the source as an ordinary account with the REPLICATION SLAVE privilege (the privilege kept its old name). The source has the binlog on in ROW format, a unique server_id (every server in a topology needs a different one), GTIDs on, and keeps binlogs for 30 days (2592000 seconds).
  2. CHANGE REPLICATION SOURCE TO stores where to connect. SOURCE_AUTO_POSITION = 1 means "use GTIDs": the replica sends the set of transactions it already has and the source streams everything else. SOURCE_SSL = 1 encrypts the stream. The two notes are standard warnings about the password being stored in the replica's metadata tables.
  3. START REPLICA starts the receiver and applier threads. SHOW REPLICA STATUS (shown with selected fields) has the two lines everyone checks first, Replica_IO_Running: Yes and Replica_SQL_Running: Yes, and Seconds_Behind_Source: 0.
  4. The GTID sets tell the story: the replica retrieved and executed source transactions 98f0…:1-7, and also has df72…:1-5, its own transactions from initialisation. With GTIDs you can compare any two servers' gtid_executed to see exactly what each has.
  5. super_read_only = ON stops anyone, even accounts with admin privileges, from writing to the replica directly, which would make it drift from the source. The replication threads themselves are exempt.
AdvancedA change, end to end

Now a change on the source, followed through the binlog to the replica:

SQL
-- on the source
SHOW BINARY LOG STATUS;
UPDATE products SET price = 849.00 WHERE id = 1;
SHOW BINARY LOG STATUS;
SHOW BINLOG EVENTS IN 'binlog.000002' FROM 7379;

-- on the replica
SELECT @@hostname, id, name, price FROM products WHERE id = 1;
UPDATE products SET price = 1.00 WHERE id = 1;
Result
+---------------+----------+--------------+------------------+-------------------------------------------+
| File          | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set                         |
+---------------+----------+--------------+------------------+-------------------------------------------+
| binlog.000002 |     7379 |              |                  | 98f018fc-c975-11f1-a296-ce36bc0880eb:1-23 |
+---------------+----------+--------------+------------------+-------------------------------------------+
+---------------+----------+--------------+------------------+-------------------------------------------+
| File          | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set                         |
+---------------+----------+--------------+------------------+-------------------------------------------+
| binlog.000002 |     7742 |              |                  | 98f018fc-c975-11f1-a296-ce36bc0880eb:1-24 |
+---------------+----------+--------------+------------------+-------------------------------------------+
+---------------+------+-------------+-----------+-------------+--------------------------------------------------------------------+
| Log_name      | Pos  | Event_type  | Server_id | End_log_pos | Info                                                               |
+---------------+------+-------------+-----------+-------------+--------------------------------------------------------------------+
| binlog.000002 | 7379 | Gtid        |         1 |        7458 | SET @@SESSION.GTID_NEXT= '98f018fc-c975-11f1-a296-ce36bc0880eb:24' |
| binlog.000002 | 7458 | Query       |         1 |        7544 | BEGIN                                                              |
| binlog.000002 | 7544 | Table_map   |         1 |        7611 | table_id: 94 (shop.products)                                       |
| binlog.000002 | 7611 | Update_rows |         1 |        7711 | table_id: 94 flags: STMT_END_F USE_SQL_FOREIGN_KEY_F               |
| binlog.000002 | 7711 | Xid         |         1 |        7742 | COMMIT /* xid=55 */                                                |
+---------------+------+-------------+-----------+-------------+--------------------------------------------------------------------+
+------------+----+--------------+--------+
| @@hostname | id | name         | price  |
+------------+----+--------------+--------+
| replica    |  1 | ThinkBook 14 | 849.00 |
+------------+----+--------------+--------+
ERROR 1290 (HY000): The MySQL server is running with the --super-read-only option so it cannot execute this statement
What’s happening
  1. The sample schema was loaded into a shop database on the source and replicated (GTIDs 8 to 23). SHOW BINARY LOG STATUS (the 8.4+ name for SHOW MASTER STATUS) gives the current binlog file and byte position: binlog.000002 at 7379.
  2. The one-row UPDATE commits as GTID …:24 and moves the position to 7742: 363 bytes of binlog for one changed row.
  3. SHOW BINLOG EVENTS lists what those bytes are: a Gtid event naming the transaction, BEGIN, a Table_map saying which table the following row events refer to, one Update_rows event, and an Xid event that is the commit.
  4. On the replica, a moment later, the ThinkBook costs 849.00. Writing to the replica is refused by super_read_only with ERROR 1290.
  5. In MySQL 8.4 and later the old statements are gone: SHOW MASTER STATUS is a syntax error (ERROR 1064 … near 'MASTER STATUS'). Their replacements — CHANGE REPLICATION SOURCE TO, START REPLICA, SHOW REPLICA STATUS — have existed since 8.0.22/8.0.23, so the new names work on any recent 8.0.
AdvancedWhat a row event really contains

mysqlbinlog (not included in the official Docker image's minimal server package, so this chapter's servers add it from MySQL's own signed client RPM) decodes the row event. Excerpt:

Shell
mysqlbinlog --base64-output=DECODE-ROWS -vv --start-position=7379 --stop-position=7742 /var/lib/mysql/binlog.000002
Result
# at 7379
#261016 15:47:25 server id 1  end_log_pos 7458 CRC32 0xbf6ad8fb 	GTID	last_committed=18	sequence_number=19	rbr_only=yes	original_committed_timestamp=1792165645988176	immediate_commit_timestamp=1792165645988176	transaction_length=363
SET @@SESSION.GTID_NEXT= '98f018fc-c975-11f1-a296-ce36bc0880eb:24'/*!*/;
# at 7458
#261016 15:47:25 server id 1  end_log_pos 7544 CRC32 0x2ccf68e8 	Query	thread_id=14	exec_time=0	error_code=0
BEGIN
/*!*/;
# at 7544
#261016 15:47:25 server id 1  end_log_pos 7611 CRC32 0xe2a87769 	Table_map: `shop`.`products` mapped to number 94
# at 7611
#261016 15:47:25 server id 1  end_log_pos 7711 CRC32 0x1eafbce1 	Update_rows: table id 94 flags: STMT_END_F USE_SQL_FOREIGN_KEY_F
### UPDATE `shop`.`products`
### WHERE
###   @1=1 /* INT meta=0 nullable=0 is_null=0 */
###   @2='ThinkBook 14' /* VARSTRING(320) meta=320 nullable=0 is_null=0 */
###   @3=2 /* INT meta=0 nullable=0 is_null=0 */
###   @4=899.00 /* DECIMAL(10,2) meta=2562 nullable=0 is_null=0 */
###   @5=12 /* INT meta=0 nullable=0 is_null=0 */
### SET
###   @1=1 /* INT meta=0 nullable=0 is_null=0 */
###   @2='ThinkBook 14' /* VARSTRING(320) meta=320 nullable=0 is_null=0 */
###   @3=2 /* INT meta=0 nullable=0 is_null=0 */
###   @4=849.00 /* DECIMAL(10,2) meta=2562 nullable=0 is_null=0 */
###   @5=12 /* INT meta=0 nullable=0 is_null=0 */
# at 7711
#261016 15:47:25 server id 1  end_log_pos 7742 CRC32 0xa9444cf9 	Xid = 55
COMMIT/*!*/;
What’s happening
  1. --base64-output=DECODE-ROWS -vv turns the binary row event into readable pseudo-SQL with column types. Columns are numbered (@1 … @5) because the binlog doesn't store column names by default (binlog_row_metadata = MINIMAL).
  2. The WHERE part is the before image: the whole old row, price 899.00. The SET part is the after image: price 849.00. With the default binlog_row_image = FULL, every column is logged even though only one changed.
  3. The replica doesn't re-run UPDATE … WHERE id = 1. It finds the row by primary key from the before image and writes the after image. That's why row-based replication is deterministic (no NOW() or RAND() surprises) and why every replicated table should have a primary key: without one, the replica has to scan the table to find each row.
  4. last_committed=18 sequence_number=19 are dependency markers: transactions whose writes don't overlap can be applied in parallel by the replica's worker threads (replica_parallel_workers is 4 by default since 8.0.27).
  5. Every event has a timestamp, server id and position. That's what makes the binlog useful beyond replication: point-in-time recovery, auditing what changed, and change data capture tools such as Debezium.
AdvancedThe replication threads you can see

On the replica you can see the threads from the mental model; on the source, the replica's connection:

SQL
-- replica
SELECT USER, COMMAND, STATE FROM performance_schema.processlist WHERE USER = 'system user' ORDER BY ID;
-- source
SELECT USER, HOST, COMMAND, STATE FROM performance_schema.processlist WHERE USER = 'repl';
Result
+-------------+---------+----------------------------------------------------------+
| USER        | COMMAND | STATE                                                    |
+-------------+---------+----------------------------------------------------------+
| system user | Daemon  | Suspending                                               |
| system user | Connect | Waiting for source to send event                         |
| system user | Query   | Replica has read all relay log; waiting for more updates |
| system user | Query   | Waiting for an event from Coordinator                    |
| system user | Connect | Waiting for an event from Coordinator                    |
| system user | Connect | Waiting for an event from Coordinator                    |
| system user | Connect | Waiting for an event from Coordinator                    |
+-------------+---------+----------------------------------------------------------+
+------+---------------------+------------------+-----------------------------------------------------------------+
| USER | HOST                | COMMAND          | STATE                                                           |
+------+---------------------+------------------+-----------------------------------------------------------------+
| repl | 192.168.147.3:49250 | Binlog Dump GTID | Source has sent all binlog to replica; waiting for more updates |
+------+---------------------+------------------+-----------------------------------------------------------------+
What’s happening
  1. Waiting for source to send event is the receiver (IO) thread, connected to the source and idle because it's caught up.
  2. Replica has read all relay log is the coordinator of the applier, and the four Waiting for an event from Coordinator threads are its parallel workers.
  3. On the source, the replica appears as an ordinary connection from repl running the Binlog Dump GTID command: the source's dump thread pushes new binlog events to it as they're written.
  4. Nothing in this picture waits for the replica before the source commits. That's what "asynchronous" means, and it's the subject of the next topic.
AdvancedPitfall: a replica is not a backup

#Asynchronous, semi-synchronous and group replication

Added

The question that separates the three is when does COMMIT return to the application?

  • Asynchronous (the default): as soon as the source has written the transaction to its own binlog and committed. Replicas get it some milliseconds later. Fastest, but if the source dies before a replica has received the last transactions, failing over loses them.
  • Semi-synchronous: the source waits until at least one replica has received the transaction into its relay log and acknowledged it. The replica hasn't necessarily applied it, but it has a copy, so a failover doesn't lose it. Each commit costs a network round trip.
  • Group Replication: a group of servers (up to 9) agree on every transaction with a consensus protocol. A transaction commits only when a majority of members accept it, and conflicting transactions are rejected. Members detect failures and elect a new primary automatically. It's the engine behind InnoDB Cluster.

Mental model: async is posting a letter and walking away; semi-sync is waiting at the counter until one branch confirms it received a copy; group replication is a committee that votes, and nothing is official until most members have signed.

Semi-sync ships as a pair of plugins. Here it is on the chapter's two servers, with the wait time cut to 3 seconds:

SQL
-- source
INSTALL PLUGIN rpl_semi_sync_source SONAME 'semisync_source.so';
SET GLOBAL rpl_semi_sync_source_enabled = ON;
SET GLOBAL rpl_semi_sync_source_timeout = 3000;   -- ms to wait for a replica ACK
-- replica (super_read_only briefly off to install the plugin)
INSTALL PLUGIN rpl_semi_sync_replica SONAME 'semisync_replica.so';
SET GLOBAL rpl_semi_sync_replica_enabled = ON;
STOP REPLICA IO_THREAD; START REPLICA IO_THREAD;   -- reconnect as a semi-sync replica

-- source: one commit with the replica connected...
SET @t = NOW(6);
INSERT INTO payments (amount, at) VALUES (10.00, NOW(6));
SELECT 'replica connected' AS scenario, TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) / 1000 AS commit_ms;
-- ...then on the replica: STOP REPLICA IO_THREAD; and two more commits on the source
SET @t = NOW(6);
INSERT INTO payments (amount, at) VALUES (20.00, NOW(6));
SELECT 'replica gone, first commit' AS scenario, TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) / 1000 AS commit_ms;
SET @t = NOW(6);
INSERT INTO payments (amount, at) VALUES (30.00, NOW(6));
SELECT 'replica gone, next commit' AS scenario, TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) / 1000 AS commit_ms;
SHOW STATUS WHERE Variable_name IN ('Rpl_semi_sync_source_status', 'Rpl_semi_sync_source_yes_tx', 'Rpl_semi_sync_source_no_tx');
Result
+-------------------+-----------+
| scenario          | commit_ms |
+-------------------+-----------+
| replica connected |    3.6090 |
+-------------------+-----------+
+----------------------------+-----------+
| scenario                   | commit_ms |
+----------------------------+-----------+
| replica gone, first commit | 3004.5040 |
+----------------------------+-----------+
+---------------------------+-----------+
| scenario                  | commit_ms |
+---------------------------+-----------+
| replica gone, next commit |    4.2700 |
+---------------------------+-----------+
+-----------------------------+-------+
| Variable_name               | Value |
+-----------------------------+-------+
| Rpl_semi_sync_source_no_tx  | 2     |
| Rpl_semi_sync_source_status | OFF   |
| Rpl_semi_sync_source_yes_tx | 2     |
+-----------------------------+-------+
What’s happening
  1. With the replica connected, the insert's commit waits for the replica's acknowledgement: about 3.6 ms in total here, on one machine; across availability zones each commit pays a network round trip on top. A SHOW STATUS at this point showed Rpl_semi_sync_source_yes_tx: 2, the commits acknowledged so far (the CREATE TABLE payments and this insert).
  2. The replica's receiver thread is then stopped, so nobody can acknowledge. The next commit waits the full timeout: 3,004 ms, then the source gives up waiting and commits anyway. The error log says why: [Warning] [MY-011153] [Repl] Timeout waiting for reply of binlog ….
  3. After a timeout the source switches semi-sync off (Rpl_semi_sync_source_status: OFF) and carries on asynchronously, so the next commit is fast again (4.3 ms). no_tx counts the 2 commits that were not acknowledged by any replica.
  4. When the replica reconnected, the next commit was acknowledged and the status went back to ON by itself (yes_tx became 3).
  5. The lesson: semi-sync's guarantee silently lapses whenever no replica answers within rpl_semi_sync_source_timeout (10 seconds by default). It trades availability for durability only up to that timeout, so monitor Rpl_semi_sync_source_status and no_tx.
AdvancedInternals: AFTER_SYNC makes semi-sync lossless
AdvancedOld vs new: semisync_master plugin names
AdvancedGroup Replication and InnoDB Cluster

Group Replication wasn't set up for this page: a meaningful demo needs at least three members and a router, so what follows is from the MySQL manual rather than a sandbox run.

  • Each member executes a transaction locally, then broadcasts its write set (the primary keys it changed) to the group. A Paxos-based protocol delivers write sets to all members in the same order, and each member certifies them: if two transactions changed the same row concurrently, the one ordered first wins and the other is rolled back with an error on its own member.
  • Single-primary mode (the default) takes writes on one member and elects a new primary automatically if it fails. Multi-primary accepts writes everywhere at the cost of more certification conflicts.
  • A group of 3 tolerates 1 failed member, 5 tolerate 2: a majority must remain to make progress, which is what prevents split brain.
  • Requirements: InnoDB tables, a primary key on every table, GTIDs on, and a low-latency network between members.
  • InnoDB Cluster packages it: Group Replication for the data, MySQL Shell's dba API to set it up, and MySQL Router to send each application connection to the current primary or to secondaries. InnoDB ClusterSet adds asynchronous replicas of a whole cluster in another region.
AsyncSemi-syncGroup Replication
COMMIT waits fornothing1+ replica to receive (not apply)a majority to agree
Data loss on primary crashthe last few transactionsnone, unless it fell back to asyncnone
Automatic failoverno (external tool)no (external tool)yes
Write latencylowest+ one round trip+ consensus round trip, certification
Typical useread replicas, cross-region copiesprimary + standby in one regionInnoDB Cluster, self-managed HA

#Read replicas and replication lag

Added

The most common reason to add replicas is to spread reads: the application sends writes to the source and reads to one of several replicas, typically through a proxy, a read-only endpoint, or two connection pools in the code. This works because most applications read far more than they write.

The price is replication lag: a replica is always slightly behind, from milliseconds to — under a big transaction or a struggling replica — minutes. Any read from a replica may be stale. The classic bug is read-your-own-writes: a user saves their profile, the next page loads from a replica, and their change "didn't save".

The mental model is a newspaper with several printing plants. The newsroom (source) files a story and moves on; each plant prints it when the wire arrives. A reader who phones in a correction and immediately buys a paper from a plant will often get yesterday's edition.

read-your-writes.mjs
// Write on the source, read straight back from a replica: how often is the read stale?
import mysql from "mysql2/promise";

const source  = await mysql.createConnection({ host: "127.0.0.1", port: 3411, user: "root", password: "study", database: "shop" });
const replica = await mysql.createConnection({ host: "127.0.0.1", port: 3412, user: "root", password: "study", database: "shop" });

await source.query("CREATE TABLE IF NOT EXISTS profile (id INT PRIMARY KEY, bio VARCHAR(50))");
await source.query("REPLACE INTO profile VALUES (1, 'v0')");

async function trial(i, { waitForGtid }) {
  const bio = `v${i}`;
  await source.query("UPDATE profile SET bio = ? WHERE id = 1", [bio]);          // the user saves their profile
  if (waitForGtid) {
    const [[{ gtids }]] = await source.query("SELECT @@global.gtid_executed AS gtids");
    const [[{ w }]] = await replica.query("SELECT WAIT_FOR_EXECUTED_GTID_SET(?, 1) AS w", [gtids]);
    if (w !== 0) throw new Error("replica did not catch up within 1 s");
  }
  const [[row]] = await replica.query("SELECT bio FROM profile WHERE id = 1");    // ...and the page reloads from a replica
  return row?.bio === bio;               // no row at all also counts as stale
}

for (const waitForGtid of [false, true]) {
  let stale = 0;
  const t0 = performance.now();
  for (let i = 1; i <= 500; i++) if (!(await trial(i, { waitForGtid }))) stale++;
  const ms = (performance.now() - t0) / 500;
  console.log(`waitForGtid=${waitForGtid}: ${stale} of 500 reads were stale, ${ms.toFixed(2)} ms per write+read`);
}
await source.end();
await replica.end();

// Three runs:
// waitForGtid=false: 499 of 500 reads were stale, 1.91 ms per write+read
// waitForGtid=true: 0 of 500 reads were stale, 2.53 ms per write+read
// waitForGtid=false: 498 of 500 reads were stale, 1.77 ms per write+read
// waitForGtid=true: 0 of 500 reads were stale, 2.74 ms per write+read
// waitForGtid=false: 497 of 500 reads were stale, 1.70 ms per write+read
// waitForGtid=true: 0 of 500 reads were stale, 3.12 ms per write+read
What’s happening
  1. Each trial updates the profile on the source, then immediately reads it from the replica, the way a "save, then redirect to the profile page" flow does when reads are routed to replicas.
  2. Without waiting, about 499 of 500 reads were stale. Replication here takes well under a millisecond, but the read arrives even sooner: the UPDATE's COMMIT has returned, the binlog event is on its way, and the replica hasn't applied it yet. A lag that small is invisible in Seconds_Behind_Source (which reports whole seconds) yet breaks read-your-writes almost every time.
  3. The fix used here is GTID-based waiting: after the write, ask the source for its gtid_executed (which includes our transaction), then run WAIT_FOR_EXECUTED_GTID_SET(gtids, 1) on the replica. It returns 0 as soon as the replica has applied all of them, or 1 after the 1-second timeout.
  4. With the wait, 0 of 500 reads were stale, for about 1 ms extra per write+read. In production you'd capture the GTID of your own transaction without an extra query (session_track_gtids = OWN_GTID sends it back in the OK packet, for drivers that expose it) and only wait for reads that must see the user's own write.
  5. The simpler alternatives: send reads to the source for a few seconds after a user writes (a "sticky primary" flag in their session), or read critical paths from the source always and use replicas for pages that tolerate staleness (lists, search, reports).
AdvancedPitfall: transactions split across servers
AdvancedLag from one big transaction

Lag is mostly about how long the replica needs to apply what the source just committed. A replica can't start applying a transaction until the source has committed it, so a transaction that takes a second on the source arrives as a second of work all at once:

Shell
#!/bin/bash
# lag-demo.sh: one big transaction on the source, then watch the replica catch up.
cd "$(dirname "$0")/docker"
src()  { docker compose exec -T source  sh -c "MYSQL_PWD=study mysql -uroot shop -N -e \"$1\" 2>/dev/null"; }
repl() { docker compose exec -T replica sh -c "MYSQL_PWD=study mysql -uroot shop -N -e \"$1\" 2>/dev/null"; }
NEW=$(src "SELECT MAX(counter) + 1 FROM events")
src "SELECT CONCAT('source:  start  ', TIME(NOW(3))); UPDATE events SET counter = $NEW; SELECT CONCAT('source:  commit ', TIME(NOW(3)))"
for i in 1 2 3 4; do
  repl "SELECT CONCAT('replica: ', TIME(NOW(3)), '  rows with counter = $NEW: ', SUM(counter = $NEW)) FROM events"
  docker compose exec -T replica sh -c 'MYSQL_PWD=study mysql -uroot -E -e "SHOW REPLICA STATUS" 2>/dev/null' | grep -o 'Seconds_Behind_Source: .*'
  sleep 0.5
done
Result
source:  start  15:50:20.137
source:  commit 15:50:20.787
replica: 15:50:20.871  rows with counter = 4: 0
Seconds_Behind_Source: 0
replica: 15:50:21.573  rows with counter = 4: 0
Seconds_Behind_Source: 1
replica: 15:50:22.330  rows with counter = 4: 500000
Seconds_Behind_Source: 0
replica: 15:50:23.031  rows with counter = 4: 500000
Seconds_Behind_Source: 0
What’s happening
  1. events has 500,000 rows. One UPDATE changes all of them in a single transaction: 650 ms on the source, committed at 20.787.
  2. The row events for 500,000 rows (before and after images, as in the previous topic) then reach the replica, which must apply them. 84 ms after the commit the replica still shows 0 updated rows, and 0.8 s later still 0.
  3. Between 21.573 and 22.330 the replica's transaction commits and all 500,000 rows change at once — the replica applies a transaction atomically too, so readers see either none or all of it. Total lag: about 1.5 s for a 0.65 s transaction; a single transaction is applied by one worker, so parallel replication can't help it.
  4. Seconds_Behind_Source said 0 on the first check while the replica was clearly behind, then 1. It's computed from the timestamp of the event currently being applied, so it's coarse, can read 0 at the start of a big transaction, and reads NULL when replication is stopped. For accurate lag, write a heartbeat row with the time on the source every second and compare on the replica (what pt-heartbeat does), or use performance_schema.replication_applier_status_by_worker timestamps.
  5. The practical rule: batch big changes (like the purge_log procedure in Stored procedures) so no single transaction makes every replica stall.
AdvancedA delayed replica on purpose

You can also make a replica lag deliberately. SOURCE_DELAY = N makes the applier wait until N seconds after the source committed each transaction:

SQL
-- replica
STOP REPLICA SQL_THREAD;
CHANGE REPLICATION SOURCE TO SOURCE_DELAY = 5;
START REPLICA SQL_THREAD;
-- source, then the replica after 1 s, 5 s and 8 s
INSERT INTO payments (amount, at) VALUES (50.00, NOW(6));
SELECT TIME(NOW()) AS source_time, MAX(id) FROM payments;
SELECT TIME(NOW()) AS replica_time, MAX(id) FROM shop.payments;   -- with SQL_Delay lines from SHOW REPLICA STATUS
Result
+-------------+---------+
| source_time | MAX(id) |
+-------------+---------+
| 15:50:37    |       5 |
+-------------+---------+
+--------------+---------+
| replica_time | MAX(id) |
+--------------+---------+
| 15:50:38     |       4 |
+--------------+---------+
                    SQL_Delay: 5
          SQL_Remaining_Delay: 5
    Replica_SQL_Running_State: Waiting until SOURCE_DELAY seconds after source executed event
+--------------+---------+
| replica_time | MAX(id) |
+--------------+---------+
| 15:50:42     |       4 |
+--------------+---------+
                    SQL_Delay: 5
          SQL_Remaining_Delay: 1
    Replica_SQL_Running_State: Waiting until SOURCE_DELAY seconds after source executed event
+--------------+---------+
| replica_time | MAX(id) |
+--------------+---------+
| 15:50:45     |       5 |
+--------------+---------+
                    SQL_Delay: 5
          SQL_Remaining_Delay: NULL
    Replica_SQL_Running_State: Replica has read all relay log; waiting for more updates
What’s happening
  1. The replica already has payment 5 in its relay log (the receiver is not delayed), but the applier holds it back: Waiting until SOURCE_DELAY seconds after source executed event, SQL_Remaining_Delay: 5.
  2. Four seconds later it's still not applied, with 1 second remaining.
  3. At 15:50:45, more than 5 seconds after the source committed, payment 5 is applied and the state returns to normal.
  4. Why do this? A replica delayed by an hour is a time machine: when someone drops a table, you stop the delayed replica before the DROP reaches it, apply events up to just before it with START REPLICA UNTIL SQL_BEFORE_GTIDS = '…', and copy the table back. It's much faster than restoring a full backup. Never send application reads to it.

#Partitioning

Added

Partitioning splits one table into several physical pieces on the same server, according to a rule on a column: ranges of dates, a list of regions, a hash of an id. To queries it's still one table. Underneath, each partition is a separate InnoDB tablespace (order_events#p#p2025_03.ibd), and MySQL reads only the partitions a query can need — partition pruning.

The mental model is a filing cabinet with one drawer per month. Finding March's paperwork means opening one drawer; throwing away 2023 means tipping out the 2023 drawers, not leafing through every folder. But a question like "everything about order 3", which isn't organised by month, still means opening every drawer.

That's also the honest summary of what partitioning is for in MySQL: mostly data lifecycle — dropping or archiving old data instantly — and sometimes queries that always filter on the partitioning column. It is not a way to spread load across servers; that's sharding.

SQL
CREATE TABLE order_events (
  id         BIGINT NOT NULL AUTO_INCREMENT,
  order_id   INT NOT NULL,
  event      VARCHAR(20) NOT NULL,
  created_at DATETIME NOT NULL,
  PRIMARY KEY (id, created_at)              -- the partitioning column must be in every unique key
)
PARTITION BY RANGE COLUMNS (created_at) (
  PARTITION p2025_01 VALUES LESS THAN ('2025-02-01'),
  PARTITION p2025_02 VALUES LESS THAN ('2025-03-01'),
  PARTITION p2025_03 VALUES LESS THAN ('2025-04-01'),
  PARTITION p_future VALUES LESS THAN (MAXVALUE)
);
INSERT INTO order_events (order_id, event, created_at)
SELECT id, status, ordered_at FROM orders;
INSERT INTO order_events (order_id, event, created_at) VALUES (1, 'created', '2025-01-30 10:00:00');

SELECT PARTITION_NAME, TABLE_ROWS FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'ch11' AND TABLE_NAME = 'order_events' ORDER BY PARTITION_ORDINAL_POSITION;
EXPLAIN FORMAT=TRADITIONAL SELECT * FROM order_events
WHERE created_at >= '2025-03-01' AND created_at < '2025-04-01';
EXPLAIN FORMAT=TRADITIONAL SELECT * FROM order_events WHERE order_id = 3;
Result
+----------------+------------+
| PARTITION_NAME | TABLE_ROWS |
+----------------+------------+
| p2025_01       |          1 |
| p2025_02       |          3 |
| p2025_03       |          3 |
| p_future       |          2 |
+----------------+------------+
+----+-------------+--------------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table        | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+--------------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | order_events | p2025_03   | ALL  | NULL          | NULL | NULL    | NULL |    3 |    33.33 | Using where |
+----+-------------+--------------+------------+------+---------------+------+---------+------+------+----------+-------------+
+----+-------------+--------------+-------------------------------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table        | partitions                          | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+--------------+-------------------------------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | order_events | p2025_01,p2025_02,p2025_03,p_future | ALL  | NULL          | NULL | NULL    | NULL |    9 |    11.11 | Using where |
+----+-------------+--------------+-------------------------------------+------+---------------+------+---------+------+------+----------+-------------+
What’s happening
  1. RANGE COLUMNS (created_at) routes each row by date: anything before February goes to p2025_01, and so on. p_future … LESS THAN (MAXVALUE) catches everything later, so an insert can never fail with "no partition for value".
  2. The primary key is (id, created_at), not just id. MySQL requires every unique key to include the partitioning columns, because it enforces uniqueness one partition at a time. That's a real modelling cost: id alone is no longer guaranteed unique by the database.
  3. The 8 sample orders plus one January event land in their months: 1, 3, 3, and the two April orders in p_future.
  4. The March query's plan shows partitions: p2025_03: pruned to one partition, so the other three are never touched.
  5. The query on order_id can't be pruned — the rule is about dates — so it lists all four partitions. If most of your queries don't filter on the partitioning column, partitioning makes them slightly slower, not faster (more tablespaces to visit, and every index is split per partition).
AdvancedLimits: unique keys and foreign keys
AdvancedPartition maintenance: the real reason to partition
SQL
ALTER TABLE order_events REORGANIZE PARTITION p_future INTO (
  PARTITION p2025_04 VALUES LESS THAN ('2025-05-01'),
  PARTITION p_future VALUES LESS THAN (MAXVALUE)
);
ALTER TABLE order_events DROP PARTITION p2025_01;
ANALYZE TABLE order_events;
SELECT PARTITION_NAME, TABLE_ROWS FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'ch11' AND TABLE_NAME = 'order_events' ORDER BY PARTITION_ORDINAL_POSITION;
SELECT COUNT(*) AS rows_left, MIN(created_at) FROM order_events;
Result
+-------------------+---------+----------+----------+
| Table             | Op      | Msg_type | Msg_text |
+-------------------+---------+----------+----------+
| ch11.order_events | analyze | status   | OK       |
+-------------------+---------+----------+----------+
+----------------+------------+
| PARTITION_NAME | TABLE_ROWS |
+----------------+------------+
| p2025_02       |          3 |
| p2025_03       |          3 |
| p2025_04       |          2 |
| p_future       |          0 |
+----------------+------------+
+-----------+---------------------+
| rows_left | MIN(created_at)     |
+-----------+---------------------+
|         8 | 2025-02-01 10:00:00 |
+-----------+---------------------+
What’s happening
  1. REORGANIZE PARTITION p_future INTO (…) splits the catch-all into April plus a new catch-all. Only p_future's rows are rewritten. A monthly job (a scheduled event is a good fit) does this ahead of time so new months always have a partition.
  2. DROP PARTITION p2025_01 deletes January by deleting its tablespace file: no row-by-row delete, almost no undo log, no long locks. The January event is gone and the oldest row is now February's.
  3. ANALYZE TABLE refreshes the statistics; right after the REORGANIZE, TABLE_ROWS showed 0 for every partition. TABLE_ROWS is an estimate from statistics, not a count.
  4. That's the retention pattern: partition by time, add new partitions in advance, drop expired ones — keeping "the last 90 days" costs one metadata operation a day instead of a huge nightly DELETE.
AdvancedDELETE vs DROP PARTITION on a million rows

How much faster is dropping a partition? The same million log rows, one table plain and one partitioned at February 1st, on this chapter's separate server:

SQL
SET @t = NOW(6);
DELETE FROM log_plain WHERE created_at < '2025-02-01';
SELECT 'DELETE ... WHERE' AS method, ROW_COUNT() AS rows_removed, TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) / 1000 AS ms;
SET @t = NOW(6);
ALTER TABLE log_parted DROP PARTITION p_old;
SELECT 'DROP PARTITION' AS method, (SELECT 1000000 - COUNT(*) FROM log_parted) AS rows_removed,
       TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) / 1000 AS ms;
Result
+------------------+--------------+----------+
| method           | rows_removed | ms       |
+------------------+--------------+----------+
| DELETE ... WHERE |       535679 | 808.6690 |
+------------------+--------------+----------+
+----------------+--------------+--------+
| method         | rows_removed | ms     |
+----------------+--------------+--------+
| DROP PARTITION |       535679 | 8.6920 |
+----------------+--------------+--------+
What’s happening
  1. Both tables hold the same 1,000,000 rows, one every 5 seconds from January 1st; 535,679 of them are in January.
  2. DELETE … WHERE removes them row by row: 809 ms, writing undo for every row, and — on a server with replicas — a 535,679-row event that every replica must apply (see the lag example above).
  3. DROP PARTITION removes the same rows in 9 ms, about 90 times faster, and replicates as one small DDL statement.
  4. NOW(6) returns the time the statement started, so each ms column measures from just before the operation to the start of the SELECT; the COUNT(*) isn't included.

#Sharding

Added

Sharding splits the data across separate database servers, each holding a subset of rows — all of customer 1's data on shard 1, customer 2's on shard 0, and so on. Unlike partitioning, each shard is a full independent MySQL server with its own CPU, memory and disks, so writes scale: N shards take roughly N times the write load.

The shard key decides where every row lives, and it's the most important decision in the design. Pick a key that most queries include (tenant ID in a SaaS app, customer ID in a shop) so a request touches one shard, with enough distinct values to spread data and load evenly.

The mental model is splitting a phone book into volumes A–F, G–M, N–S, T–Z. Looking up "Patel" opens one volume. But "everyone who lives on Main Street" means searching every volume, and moving to five volumes means reprinting most of the books.

Real sharding across separate machines can't be run in this sandbox. As a stand-in, the example uses two separate MySQL servers on one machine (this chapter's source and sec containers) and routes rows in Node by customer_id % 2. The routing logic and its consequences are the real thing; latency, failures and resharding at scale are not shown.

shard-router.mjs
// Application-level sharding by customer_id across two separate MySQL servers.
import mysql from "mysql2/promise";

const shards = [
  mysql.createPool({ host: "127.0.0.1", port: 3411, user: "root", password: "study" }), // server "source"
  mysql.createPool({ host: "127.0.0.1", port: 3413, user: "root", password: "study" }), // server "sec"
];
const shardFor = (customerId) => shards[customerId % shards.length];        // the shard key decides the server

for (const db of shards) {
  await db.query("DROP DATABASE IF EXISTS shard_demo");
  await db.query("CREATE DATABASE shard_demo");
  await db.query(`CREATE TABLE shard_demo.orders (
    id BIGINT PRIMARY KEY, customer_id INT NOT NULL, total DECIMAL(10,2) NOT NULL, KEY (customer_id))`);
}

// The same orders as the sample schema (id, customer, total). IDs must be unique across shards,
// so they come from the application here, not from AUTO_INCREMENT.
const orders = [[1, 1, 957], [2, 1, 88], [3, 2, 1048], [4, 3, 1299], [5, 3, 127], [6, 4, 249], [7, 5, 1016], [8, 2, 116]];
for (const [id, customerId, total] of orders) {
  await shardFor(customerId).query("INSERT INTO shard_demo.orders VALUES (?, ?, ?)", [id, customerId, total]);
}

for (const [i, db] of shards.entries()) {
  const [[{ host, n, customers }]] = await db.query(
    "SELECT @@hostname AS host, COUNT(*) AS n, GROUP_CONCAT(DISTINCT customer_id ORDER BY customer_id) AS customers FROM shard_demo.orders");
  console.log(`shard ${i} (${host}): ${n} orders, customers ${customers}`);
}

// 1. A query that includes the shard key goes to exactly one server.
const [maya] = await shardFor(2).query("SELECT id, total FROM shard_demo.orders WHERE customer_id = ? ORDER BY id", [2]);
console.log("customer 2 (one shard):", maya.map((r) => `#${r.id} ${r.total}`).join(", "));

// 2. A query without the shard key must ask every shard and merge the answers in the application.
const t0 = performance.now();
const partials = await Promise.all(shards.map((db) =>
  db.query("SELECT customer_id, SUM(total) AS revenue FROM shard_demo.orders GROUP BY customer_id ORDER BY revenue DESC LIMIT 2")));
const top = partials.flatMap(([rows]) => rows)
  .sort((a, b) => b.revenue - a.revenue).slice(0, 2);
console.log(`top 2 customers (scatter-gather over ${shards.length} shards, ${(performance.now() - t0).toFixed(1)} ms):`,
  top.map((r) => `customer ${r.customer_id} = ${r.revenue}`).join(", "));

await Promise.all(shards.map((db) => db.end()));

// shard 0 (source): 3 orders, customers 2,4
// shard 1 (sec): 5 orders, customers 1,3,5
// customer 2 (one shard): #3 1048.00, #8 116.00
// top 2 customers (scatter-gather over 2 shards, 0.5 ms): customer 3 = 1426.00, customer 2 = 1164.00
What’s happening
  1. shardFor(customerId) is the whole routing layer: even customer IDs go to the server whose hostname is source, odd ones to sec. Every query must go through it, which is why sharding is an application (or proxy) concern, not something MySQL does for you.
  2. Order IDs come from the application. Two independent servers would each happily issue AUTO_INCREMENT id 1, and the same id would mean two different orders. Real systems use IDs that are unique without coordination (UUIDv7, Snowflake-style IDs), or per-shard auto_increment_offset/auto_increment_increment.
  3. The eight orders split 3 / 5: customers 2 and 4 on shard 0, customers 1, 3 and 5 on shard 1. With so few keys the split is uneven; with millions of customers a hash spreads them evenly, unless one tenant is much bigger than the rest (a hot shard).
  4. "Customer 2's orders" includes the shard key, so it's a single-shard query: one server, one ordinary indexed lookup.
  5. "Top 2 customers by revenue" has no shard key, so it scatters to every shard and gathers the partial answers in Node. It's correct here only because each customer lives entirely on one shard, so each shard's per-customer sums are complete; a per-shard LIMIT would give wrong answers for groups that span shards. Joins across shards, foreign keys across shards and transactions across shards don't exist at all — you'd need a distributed transaction or a saga (see Data ownership and distributed transactions on the Node site).
AdvancedAdvice: shard last, and with tooling
AdvancedResharding: why key % N hurts

The day two shards aren't enough, customer_id % 2 becomes % 3, and most rows are suddenly on the wrong server:

reshard.mjs
// How many keys change shard when you go from 2 to 3 shards?
const keys = Array.from({ length: 100_000 }, (_, i) => i + 1);

const moduloMoved = keys.filter((k) => k % 2 !== k % 3).length;

// Directory approach: keys map to 12 fixed "virtual buckets", and a lookup table maps buckets to servers.
const bucketOf = (k) => k % 12;
const before = new Map(Array.from({ length: 12 }, (_, b) => [b, b % 2]));        // 12 buckets on 2 servers
const after = new Map(before);
for (const b of [2, 5, 8, 11]) after.set(b, 2);                                     // hand 4 buckets to the new server
const bucketMoved = keys.filter((k) => before.get(bucketOf(k)) !== after.get(bucketOf(k))).length;

console.log(`key % N, 2 -> 3 shards: ${(100 * moduloMoved / keys.length).toFixed(1)}% of keys move`);
console.log(`12 buckets, 4 reassigned: ${(100 * bucketMoved / keys.length).toFixed(1)}% of keys move`);
console.log("servers after:", [0, 1, 2].map((s) => `${s}: ${[...after.values()].filter((v) => v === s).length} buckets`).join(", "));

// key % N, 2 -> 3 shards: 66.7% of keys move
// 12 buckets, 4 reassigned: 33.3% of keys move
// servers after: 0: 4 buckets, 1: 4 buckets, 2: 4 buckets
What’s happening
  1. With plain modulo, a key stays put only if k % 2 === k % 3, which holds for 2 of every 6 consecutive keys. Two thirds of all rows must be copied to a different server, while the system keeps running.
  2. The fix is indirection. Hash keys into a fixed, larger number of virtual buckets (12 here; real systems use hundreds or thousands), and keep a small table saying which server owns each bucket.
  3. Adding a server moves whole buckets: 4 of the 12 go to the new server and nothing else changes. One third of the keys move — the minimum possible, since the new server should end up with a third of the data — and each server ends with 4 buckets.
  4. Consistent hashing (a hash ring) reaches the same goal differently. Either way, moving a bucket still means copying its rows, catching up with changes made during the copy, and switching routing atomically; that's the hard part tools like Vitess (MySQL sharding, used by YouTube, Slack and PlanetScale) automate.

#Connection pooling

Added

Every client connection to MySQL is a thread on the server with its own memory (sort buffers, join buffers, network buffers), and opening one costs a TCP handshake, a TLS handshake and authentication — several round trips before the first query. The server also has a hard cap, max_connections (151 by default), and once it's reached, new connections are refused.

A connection pool keeps a fixed set of open connections and lends them to requests: borrow, run the query, give it back. The mental model is a taxi rank: a few cabs (connections) serve a long queue of passengers (requests) because each ride is short; you don't buy a new car per passenger, and the size of the rank, not the length of the queue, decides how many cars are on the road. Pools live in your application (mysql2's createPool, see mysql2: connections and pools) or in a proxy between the application and the database (ProxySQL, MySQL Router, Amazon RDS Proxy).

This topic is about the server side: what happens when clients ask for more connections than the server allows. On this chapter's separate server, max_connections is lowered to 5:

too-many.mjs
// Open connections one by one without closing them, until the server refuses.
import mysql from "mysql2/promise";

const conns = [];
for (let i = 1; i <= 8; i++) {
  try {
    conns.push(await mysql.createConnection({ host: "127.0.0.1", port: 3413, user: "web", password: "Web-pass-123" }));
    console.log(`connection ${i}: ok`);
  } catch (err) {
    console.log(`connection ${i}: ${err.code} (${err.errno}) ${err.message}`);
  }
}
const admin = await mysql.createConnection({ host: "127.0.0.1", port: 3413, user: "root", password: "study" });
const [rows] = await admin.query(
  "SHOW GLOBAL STATUS WHERE Variable_name IN ('Threads_connected', 'Max_used_connections', 'Connection_errors_max_connections')");
console.log(Object.fromEntries(rows.map((r) => [r.Variable_name, r.Value])));
await Promise.all([...conns, admin].map((c) => c.end()));

// connection 1: ok
// connection 2: ok
// connection 3: ok
// connection 4: ok
// connection 5: ok
// connection 6: ER_CON_COUNT_ERROR (1040) Too many connections
// connection 7: ER_CON_COUNT_ERROR (1040) Too many connections
// connection 8: ER_CON_COUNT_ERROR (1040) Too many connections
// {
//   Connection_errors_max_connections: '3',
//   Max_used_connections: '6',
//   Threads_connected: '6'
// }
What’s happening
  1. SET GLOBAL max_connections = 5 was run first, and web is an ordinary account with only SELECT on one schema.
  2. The first five connections succeed and are kept open, like five application instances each holding a connection, or one leaky pool.
  3. The sixth, seventh and eighth are refused with ER_CON_COUNT_ERROR (1040) Too many connections. To an application this looks like the database is down, even though it's idle: every request that needs a new connection fails.
  4. Yet root still got in, as the sixth: MySQL reserves one extra connection beyond max_connections for an account with the CONNECTION_ADMIN privilege (or SUPER), so an administrator can always log in to investigate. Don't run your application as such an account, or it will take that last slot too.
  5. Connection_errors_max_connections counts the refusals (3) and Max_used_connections is the high-water mark (6). Graph both: if the high-water mark approaches max_connections, you're one traffic spike away from this error.
AdvancedPitfall: idle connections closed by the server
AdvancedTooling: ProxySQL, MySQL Router, RDS Proxy
AdvancedSizing: the arithmetic that bites

The server's limit is shared by every client process. Each application instance has its own pool, so:

Output
total connections = instances × pool size per instance (+ admin tools, replicas, cron jobs)

  4 pods × 10  =  40   fine
 20 pods × 10  = 200   over the default max_connections of 151 → ERROR 1040 during a scale-out
500 Lambda invocations × 1 = 500   see "Connection limits in serverless"

Raising max_connections is not a free fix. Each connection uses memory for its buffers, and more active connections than CPU cores just means more context switching and lock contention: throughput peaks with a fairly small number of connections actually running queries (often a few times the core count) and falls after that. That's why a well-sized pool is small, and why requests should queue in the pool rather than open more connections. Watch Threads_running (actually executing) separately from Threads_connected (open). Server-side, thread_cache_size keeps finished threads around for reuse (9 here, auto-sized), and MySQL Enterprise's thread pool plugin caps concurrently running statements.

#Backups: logical, physical and point-in-time recovery

Added

A backup strategy has to answer two questions: how much data can you afford to lose (the recovery point objective, RPO) and how long can you be down while restoring (the recovery time objective, RTO).

There are two kinds of backup. A logical backup is SQL or data files that recreate the data — CREATE TABLE plus INSERTs from mysqldump, or MySQL Shell's parallel dump. It's portable across versions and easy to restore one table from, but slow to restore at large sizes, because every row is inserted and every index rebuilt. A physical backup copies InnoDB's data files themselves — Percona XtraBackup, MySQL Enterprise Backup, the clone plugin, or a disk snapshot. It's fast to restore at any size, but tied to the server version.

A nightly backup alone gives you up to a day of data loss. Point-in-time recovery (PITR) closes the gap: restore the last backup, then replay the binary log from the moment the backup was taken up to just before the disaster. The mental model is a save game plus the replay: the backup is last night's save; the binlog is a recording of every move since, which you can replay up to the second before the mistake — or replay everything except the mistake.

Here's a full PITR on this chapter's source server. First a backup, then a day's work, including a mistake:

Shell
mysqldump --single-transaction --routines --triggers --events --source-data=2 --databases shop > /backup/shop-full.sql
grep -n -E "CHANGE REPLICATION SOURCE|GTID_PURGED|^-- (MySQL dump|Server version|Dump completed)" /backup/shop-full.sql
Result
Warning: A partial dump from a server that has GTIDs will by default include the GTIDs of all transactions, even those that changed suppressed parts of the database. If you don't want to restore GTIDs, pass --set-gtid-purged=OFF. To make a complete dump, pass --all-databases --triggers --routines --events. 
1:-- MySQL dump 10.13  Distrib 9.7.2, for Linux (aarch64)
5:-- Server version	9.7.2
24:SET @@GLOBAL.GTID_PURGED=/*!80000 '+'*/ '98f018fc-c975-11f1-a296-ce36bc0880eb:1-3053';
30:-- CHANGE REPLICATION SOURCE TO SOURCE_LOG_FILE='binlog.000002', SOURCE_LOG_POS=502687000;
305:-- Dump completed on 2026-10-16 15:53:06
What’s happening
  1. --single-transaction takes the dump inside one REPEATABLE READ transaction, so it's a consistent snapshot of all InnoDB tables without locking them; writes carry on during the dump. Without it, mysqldump locks each table while dumping it, and tables are dumped at different moments.
  2. --routines --triggers --events include stored programs (triggers are on by default; routines and events are not, which catches people out). --databases shop adds CREATE DATABASE.
  3. --source-data=2 writes the binlog position of the snapshot as a comment: binlog.000002, position 502687000. That's where PITR starts replaying.
  4. With GTIDs on, the dump also records GTID_PURGED: transactions 1-3053 are included in the backup. The warning is about that line, and is expected for a single-database dump; a restore onto a server that already has some of those GTIDs needs --set-gtid-purged=OFF.
  5. The whole file is 11 KB for this tiny database. mysqldump output is plain SQL, so it's readable, diffable and portable to other MySQL versions — and slow to restore when it's hundreds of gigabytes, because every row is re-inserted and every index rebuilt.
AdvancedThe day's work and the mistake

Then a day's work after the backup, including a mistake:

SQL
-- Business as usual after the 03:00 backup...
INSERT INTO orders (customer_id, status, ordered_at) VALUES (6, 'paid', '2025-04-10 09:00:00');
INSERT INTO order_items VALUES (LAST_INSERT_ID(), 3, 1, 799.00);
UPDATE products SET stock = stock - 1 WHERE id = 3;
-- ...then someone runs a fix without a WHERE clause.
UPDATE products SET price = 0;
-- ...and life goes on: another order arrives before anyone notices.
INSERT INTO orders (customer_id, status, ordered_at) VALUES (5, 'pending', '2025-04-10 09:30:00');
SELECT id, name, price, stock FROM products ORDER BY id;
Result
+----+---------------------------------------+-------+-------+
| id | name                                  | price | stock |
+----+---------------------------------------+-------+-------+
|  1 | ThinkBook 14                          |  0.00 |    12 |
|  2 | MacBook Air                           |  0.00 |     5 |
|  3 | Pixel 10                              |  0.00 |    19 |
|  4 | iPhone 17                             |  0.00 |     0 |
|  5 | SQL Performance Explained             |  0.00 |    50 |
|  6 | Designing Data-Intensive Applications |  0.00 |    35 |
|  7 | USB-C Charger                         |  0.00 |   100 |
|  8 | Noise-cancelling Headphones           |  0.00 |     8 |
+----+---------------------------------------+-------+-------+
What’s happening
  1. Three good transactions run after the backup: an order, its item, and a stock decrement for the Pixel (20 → 19).
  2. Then UPDATE products SET price = 0 with no WHERE zeroes every price. MySQL's sql_safe_updates would have refused it in a session that had it on (see Safe updates and the dangers of a missing WHERE).
  3. One more good order arrives before anyone notices, so simply going back to "before the mistake" would lose it.
  4. The bad update reached the replica within milliseconds too — it showed 0.00 for every product. The goal: get back every good change since the backup, without the bad one.
AdvancedFinding the bad transaction in the binlog

The binlog since the backup's position, filtered to the interesting lines:

Shell
mysqlbinlog --base64-output=DECODE-ROWS -v --start-position=502687000 /var/lib/mysql/binlog.000002 \
  | grep -E "^# at |GTID_NEXT=|^### (UPDATE|INSERT|DELETE)|Update_rows|Write_rows|end_log_pos.*Xid" | grep -v AUTOMATIC
Result
# at 198
# at 502687000
SET @@SESSION.GTID_NEXT= '98f018fc-c975-11f1-a296-ce36bc0880eb:3054'/*!*/;
# at 502687079
# at 502687164
# at 502687222
#261016 15:53:16 server id 1  end_log_pos 502687272 CRC32 0xe367143a 	Write_rows: table id 138 flags: STMT_END_F USE_SQL_FOREIGN_KEY_F
### INSERT INTO `shop`.`orders`
# at 502687272
#261016 15:53:16 server id 1  end_log_pos 502687303 CRC32 0xed8f0867 	Xid = 4871
# at 502687303
SET @@SESSION.GTID_NEXT= '98f018fc-c975-11f1-a296-ce36bc0880eb:3055'/*!*/;
# at 502687382
# at 502687459
# at 502687521
#261016 15:53:16 server id 1  end_log_pos 502687574 CRC32 0x47d90f29 	Write_rows: table id 137 flags: STMT_END_F USE_SQL_FOREIGN_KEY_F
### INSERT INTO `shop`.`order_items`
# at 502687574
#261016 15:53:16 server id 1  end_log_pos 502687605 CRC32 0xa3814357 	Xid = 4872
# at 502687605
SET @@SESSION.GTID_NEXT= '98f018fc-c975-11f1-a296-ce36bc0880eb:3056'/*!*/;
# at 502687684
# at 502687770
# at 502687837
#261016 15:53:16 server id 1  end_log_pos 502687929 CRC32 0xa5fd7bce 	Update_rows: table id 140 flags: STMT_END_F USE_SQL_FOREIGN_KEY_F
### UPDATE `shop`.`products`
# at 502687929
#261016 15:53:16 server id 1  end_log_pos 502687960 CRC32 0xb1b6719d 	Xid = 4873
# at 502687960
SET @@SESSION.GTID_NEXT= '98f018fc-c975-11f1-a296-ce36bc0880eb:3057'/*!*/;
# at 502688039
# at 502688125
# at 502688192
#261016 15:53:16 server id 1  end_log_pos 502688832 CRC32 0x10803919 	Update_rows: table id 140 flags: STMT_END_F USE_SQL_FOREIGN_KEY_F
### UPDATE `shop`.`products`
### UPDATE `shop`.`products`
### UPDATE `shop`.`products`
### UPDATE `shop`.`products`
### UPDATE `shop`.`products`
### UPDATE `shop`.`products`
### UPDATE `shop`.`products`
### UPDATE `shop`.`products`
# at 502688832
#261016 15:53:16 server id 1  end_log_pos 502688863 CRC32 0xd5945954 	Xid = 4875
# at 502688863
SET @@SESSION.GTID_NEXT= '98f018fc-c975-11f1-a296-ce36bc0880eb:3058'/*!*/;
# at 502688942
# at 502689027
# at 502689085
#261016 15:53:16 server id 1  end_log_pos 502689135 CRC32 0x2b4421d3 	Write_rows: table id 138 flags: STMT_END_F USE_SQL_FOREIGN_KEY_F
### INSERT INTO `shop`.`orders`
# at 502689135
#261016 15:53:16 server id 1  end_log_pos 502689166 CRC32 0xf71a4c5f 	Xid = 4877
What’s happening
  1. Five transactions follow the backup position, GTIDs 3054 to 3058, in commit order: the order insert, its item, the one-row stock update, the bad update, the late order.
  2. GTID 3057 is the culprit: a single Update_rows event containing eight UPDATE row images — one per product — which is what a missing WHERE looks like in a row-based binlog.
  3. In a real incident you search by time (--start-datetime/--stop-datetime), by table, or by the size of the event; the timestamps (#261016 15:53:16) and the -v row images (with -vv, the old and new prices) tell you exactly what each transaction changed.
AdvancedRestore and replay, skipping one transaction

The recovery runs on a fresh server (restore, a new MySQL 9.7 with GTIDs on), so the damaged source stays untouched for investigation until the restored copy is verified:

Shell
# 1. load the backup
mysql -h restore -uroot < /backup/shop-full.sql
# 2. replay the binlog from the backup's position, skipping only the bad transaction
mysqlbinlog --start-position=502687000 --exclude-gtids="98f018fc-c975-11f1-a296-ce36bc0880eb:3057" \
  /var/lib/mysql/binlog.000002 | mysql -h restore -uroot
# 3. check the result on the restore server
mysql -h restore -uroot -t -e "
  SELECT id, name, price, stock FROM shop.products ORDER BY id;
  SELECT id, customer_id, status FROM shop.orders WHERE id >= 9;
  SELECT @@gtid_executed;"
Result
+----+---------------------------------------+---------+-------+
| id | name                                  | price   | stock |
+----+---------------------------------------+---------+-------+
|  1 | ThinkBook 14                          |  849.00 |    12 |
|  2 | MacBook Air                           | 1299.00 |     5 |
|  3 | Pixel 10                              |  799.00 |    19 |
|  4 | iPhone 17                             | 1099.00 |     0 |
|  5 | SQL Performance Explained             |   39.00 |    50 |
|  6 | Designing Data-Intensive Applications |   49.00 |    35 |
|  7 | USB-C Charger                         |   29.00 |   100 |
|  8 | Noise-cancelling Headphones           |  249.00 |     8 |
+----+---------------------------------------+---------+-------+
+----+-------------+---------+
| id | customer_id | status  |
+----+-------------+---------+
|  9 |           6 | paid    |
| 10 |           5 | pending |
+----+-------------+---------+
+--------------------------------------------------------------------------------------------+
| @@gtid_executed                                                                            |
+--------------------------------------------------------------------------------------------+
| 98f018fc-c975-11f1-a296-ce36bc0880eb:1-3056:3058,
c12fa525-c979-11f1-b627-464f76089e7d:1-5 |
+--------------------------------------------------------------------------------------------+
What’s happening
  1. Loading the dump recreates shop as of the backup, and its SET @@GLOBAL.GTID_PURGED='+…:1-3053' tells the new server that those transactions are already included. Right after this step the Pixel's stock was 20 and the last order was 8: the snapshot.
  2. mysqlbinlog --start-position=502687000 replays everything after the snapshot. Because each replayed transaction carries its original GTID, nothing is applied twice even if the start position overlapped the dump.
  3. --exclude-gtids=…:3057 drops exactly the bad transaction. The prices are back (the ThinkBook's 849.00 is from an earlier change that was in the backup), the Pixel's stock is 19 (the good decrement was replayed), and both later orders, 9 and 10, are there.
  4. gtid_executed shows the result precisely: 1-3056:3058, everything except 3057. Without GTIDs you do the same with byte positions: replay --stop-position=502687960 (where 3057 starts), then again from --start-position=502688863 (where it ends).
  5. The usual alternative is "replay up to the moment before the mistake" (--stop-datetime or --stop-position), which also throws away good work done after it. Skipping one transaction is only safe when later transactions don't depend on it — here the later order didn't read prices, so it is.
AdvancedOld vs new: mysqlpump and MySQL Shell dumps
AdvancedPractice: test restores and keep binlogs
AdvancedPhysical backups with the clone plugin

The clone plugin (MySQL 8.0.17+, in Community) copies a running server's InnoDB data files consistently, locally or from a remote donor:

SQL
INSTALL PLUGIN clone SONAME 'mysql_clone.so';
SET @t = NOW(6);
CLONE LOCAL DATA DIRECTORY = '/clones/source-20261016';
SELECT TIMESTAMPDIFF(MICROSECOND, @t, NOW(6)) / 1000 AS clone_ms;
SELECT STATE, BEGIN_TIME, END_TIME, GTID_EXECUTED FROM performance_schema.clone_status;
Result
+----------+
| clone_ms |
+----------+
| 140.8970 |
+----------+
+-----------+-------------------------+-------------------------+---------------+
| STATE     | BEGIN_TIME              | END_TIME                | GTID_EXECUTED |
+-----------+-------------------------+-------------------------+---------------+
| Completed | 2026-10-16 15:54:14.266 | 2026-10-16 15:54:14.405 |               |
+-----------+-------------------------+-------------------------+---------------+

Then a second mysqld started on the copy (mysqld --datadir=/clones/source-20261016 --port=3399 …) and queried:

Result
+--------+--------------------------+--------+---------------+
| @@port | @@datadir                | orders | macbook_price |
+--------+--------------------------+--------+---------------+
|   3399 | /clones/source-20261016/ |     10 |          0.00 |
+--------+--------------------------+--------+---------------+
What’s happening
  1. CLONE LOCAL DATA DIRECTORY copies every InnoDB file into a new directory while the server keeps running, then applies the redo generated during the copy so the result is consistent: 128 MB in 141 ms here.
  2. The copy is a complete data directory (ibdata1, mysql.ibd, undo tablespaces, shop/…). Starting a server on it worked immediately, with no import step, which is why physical restores are fast at any size.
  3. The clone was taken after the mistake, so the MacBook is 0.00 in it too. A physical backup is a snapshot like any other: PITR still means "restore the snapshot, then replay the binlog", and binlogs aren't part of the clone.
  4. CLONE INSTANCE FROM user@host:port pulls a copy from another server over the network, which is the standard way to build a new replica or rebuild a broken one (see the next topic). For scheduled hot physical backups of big servers, Percona XtraBackup (free) and MySQL Enterprise Backup (commercial) add incremental backups, compression and streaming to object storage.

#High availability and failover

Added

High availability means the database keeps serving when a server dies. With replication, that's done by failover: detect that the source is gone, pick the most up-to-date replica, promote it (make it writable, stop it replicating), point the application at it, and turn the survivors into its replicas.

The mental model is a relay team with a substitute on the bench. The substitute has been running alongside (replicating), so they can take the baton quickly — but if the runner fell a few steps before the hand-off point, those steps (unreplicated transactions) are lost, and if the original runner gets back up and keeps running, you have two people with batons (split brain).

The hard parts aren't the SQL. They're deciding that the source is really dead (not just slow or cut off from the monitor), making sure it stays dead (fencing), and knowing what was lost. Here's a failover done by hand on this chapter's two servers, with the worst case built in: the link between source and replica breaks just before the source crashes.

SQL
-- replica: simulate the network between source and replica failing
STOP REPLICA IO_THREAD;
-- source: a customer's order commits successfully...
INSERT INTO orders (customer_id, status, ordered_at) VALUES (1, 'paid', NOW());
SELECT LAST_INSERT_ID() AS committed_order_id, @@global.gtid_executed AS source_gtids;
-- ...and then the source crashes:  docker compose kill source

-- replica: promote it
STOP REPLICA;
RESET REPLICA ALL;                     -- forget the old source entirely
SET GLOBAL super_read_only = OFF;
SET GLOBAL read_only = OFF;
SELECT @@hostname AS new_source, @@read_only;
SELECT MAX(id) AS last_order FROM shop.orders;
INSERT INTO shop.orders (customer_id, status, ordered_at) VALUES (3, 'pending', NOW());
SELECT LAST_INSERT_ID() AS new_order_id, @@global.gtid_executed AS gtids;
Result
+--------------------+---------------------------------------------+
| committed_order_id | source_gtids                                |
+--------------------+---------------------------------------------+
|                 11 | 98f018fc-c975-11f1-a296-ce36bc0880eb:1-3059 |
+--------------------+---------------------------------------------+
+------------+-------------+
| new_source | @@read_only |
+------------+-------------+
| replica    |           0 |
+------------+-------------+
+------------+
| last_order |
+------------+
|         10 |
+------------+
+--------------+---------------------------------------------------------------------------------------+
| new_order_id | gtids                                                                                 |
+--------------+---------------------------------------------------------------------------------------+
|           11 | 98f018fc-c975-11f1-a296-ce36bc0880eb:1-3058,
df722497-c975-11f1-831a-ee8805be8d05:1-6 |
+--------------+---------------------------------------------------------------------------------------+
What’s happening
  1. The order commits on the source as order 11, GTID …:3059, and the application tells the customer "paid". Replication is asynchronous, and the link is down, so the replica never receives it. Then the source container is killed: a crash, no clean shutdown.
  2. Before promoting, SHOW REPLICA STATUS on the replica showed Retrieved_Gtid_Set: …:1-3058: it has everything up to 3058. With several replicas, this is how you pick the most up-to-date one.
  3. STOP REPLICA and RESET REPLICA ALL remove the replication configuration, and turning off super_read_only and read_only makes the replica writable: it is now the source.
  4. The new source's last order is 10. Order 11 — committed and confirmed to the customer — doesn't exist here. That's the async failover trade-off, made visible: whatever the old source committed but hadn't sent is gone. Semi-sync exists to close this gap.
  5. The application, now writing to the new source, creates a different order with AUTO_INCREMENT id 11 (customer 3), under the new server's own GTID df72…:6. Two different orders now exist with id 11 on two servers.
AdvancedThe old source comes back

The killed server restarts with order 11 (customer 1) still on its disk. Make it a replica of the new source:

SQL
-- old source, restarted
SET GLOBAL super_read_only = ON;
SELECT @@global.gtid_executed AS old_source_gtids;
CHANGE REPLICATION SOURCE TO SOURCE_HOST = 'replica', SOURCE_USER = 'repl',
  SOURCE_PASSWORD = 'Repl-pass-123', SOURCE_AUTO_POSITION = 1, SOURCE_SSL = 1;
START REPLICA;
SELECT WORKER_ID, LAST_ERROR_NUMBER, LAST_ERROR_MESSAGE
FROM performance_schema.replication_applier_status_by_worker WHERE LAST_ERROR_NUMBER <> 0;
Result
+---------------------------------------------+
| old_source_gtids                            |
+---------------------------------------------+
| 98f018fc-c975-11f1-a296-ce36bc0880eb:1-3059 |
+---------------------------------------------+
+-----------+-------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| WORKER_ID | LAST_ERROR_NUMBER | LAST_ERROR_MESSAGE                                                                                                                                                                                                                                                                                                                                            |
+-----------+-------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|         1 |              1062 | Worker 1 failed executing transaction 'df722497-c975-11f1-831a-ee8805be8d05:6' at source log binlog.000002, end_log_pos 505649840; Could not execute Write_rows event on table shop.orders; Duplicate entry '11' for key 'orders.PRIMARY', Error_code: 1062; handler error HA_ERR_FOUND_DUPP_KEY; the event's source log binlog.000002, end_log_pos 505649840 |
+-----------+-------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
What’s happening
  1. The old source's gtid_executed is …:1-3059. Transaction 3059 exists only here: an errant transaction from the new topology's point of view.
  2. With auto-positioning it asks the new source for everything it lacks, which includes df72…:6, the new order 11. Applying it hits the old order 11 already on disk: Duplicate entry '11' for key 'orders.PRIMARY', and replication stops. Had the two orders had different ids, replication would have carried on silently with the servers holding different data.
  3. This is why an old source must never rejoin as-is after an unplanned failover. You either discard it, or rebuild it from the new source and handle the lost transactions (here, order 11 for customer 1) by hand: find them with GTID_SUBTRACT(old_gtids, new_gtids), read them from the old server's binlog, and decide with the business whether to re-apply them.
AdvancedRebuilding the old source by cloning

The quickest way to rebuild it is the clone plugin, pulling a fresh copy from the new source:

SQL
-- old source (clone plugin installed on both servers)
SET GLOBAL super_read_only = OFF;
SET GLOBAL clone_valid_donor_list = 'replica:3306';
CLONE INSTANCE FROM 'root'@'replica':3306 IDENTIFIED BY 'study' REQUIRE SSL;
-- after the restart
SELECT @@hostname, @@server_id, @@global.gtid_executed AS gtids;
SELECT id, customer_id, status FROM shop.orders WHERE id = 11;
SET GLOBAL super_read_only = ON;
CHANGE REPLICATION SOURCE TO SOURCE_HOST = 'replica', SOURCE_USER = 'repl',
  SOURCE_PASSWORD = 'Repl-pass-123', SOURCE_AUTO_POSITION = 1, SOURCE_SSL = 1;
START REPLICA;
Result
ERROR 3707 (HY000): Restart server failed (mysqld is not managed by supervisor process).
+------------+-------------+---------------------------------------------------------------------------------------+
| @@hostname | @@server_id | gtids                                                                                 |
+------------+-------------+---------------------------------------------------------------------------------------+
| source     |           1 | 98f018fc-c975-11f1-a296-ce36bc0880eb:1-3058,
df722497-c975-11f1-831a-ee8805be8d05:1-6 |
+------------+-------------+---------------------------------------------------------------------------------------+
+----+-------------+---------+
| id | customer_id | status  |
+----+-------------+---------+
| 11 |           3 | pending |
+----+-------------+---------+
What’s happening
  1. The first attempt, with super_read_only still on, failed with ERROR 1290 … --super-read-only and Clone failed to drop all user tables: a clone replaces all data on the recipient, which counts as a write.
  2. The second attempt copied the new source's data and then tried to restart the server itself. ERROR 3707 … not managed by supervisor process only means mysqld couldn't restart itself inside this Docker container; the clone had completed, and starting the container again brought the server up on the cloned data.
  3. The rebuilt server's GTIDs now match the new source exactly (…:1-3058 plus df72…:1-6), and order 11 is customer 3's. The lost order is gone from every server.
  4. Replication then started cleanly in the new direction: an insert on the new source appeared on the rebuilt server (MAX(id) 12), with Source_Host: replica, both threads Yes. The roles are swapped: what was replica is the source.
AdvancedWhat automates this in production

None of these were run here; they're the tools that do the steps above for you:

  • InnoDB Cluster (Group Replication + MySQL Router): members elect a new primary by majority, and Router sends connections to it. No lost transactions, no manual promotion.
  • Orchestrator (open source, from GitHub): watches an async/semi-sync topology, picks the best replica, promotes it and re-points the others; widely used with ProxySQL in front.
  • Managed services: Amazon RDS Multi-AZ (synchronous block-level standby, DNS failover in a minute or two), Aurora (shared storage, failover to a reader in tens of seconds), Cloud SQL HA, Azure Database for MySQL with zone-redundant HA.
  • Fencing and split brain: a failover tool must make sure the old source can't accept writes — revoke its IP, shut it down, or set it read-only — and the application must stop sending writes to it. Group Replication avoids split brain by requiring a majority; a two-node setup can't tell a dead peer from a network partition.
  • Clients: after failover, connections to the old server fail. Applications need to reconnect and retry (idempotently), and short DNS TTLs or a proxy hide the change.

#Caching in front of the database

Added

A cache keeps copies of query results somewhere faster than the database — usually Redis or the application's own memory — so repeated reads don't reach MySQL at all. It cuts latency for expensive queries and, often more importantly, takes load off the database so it has headroom for writes.

The standard pattern is cache-aside: on a read, look in the cache; on a hit, return it; on a miss, query the database and store the result with a TTL. On a write, update the database and then delete the cached entry so the next read fetches fresh data. The mental model is the notes on your desk: you answer from the sticky note when you have one, go to the archive when you don't, and throw the note away when you learn it's out of date.

The Node site covers the patterns in depth (Cache-aside with a TTL, Cache stampedes), and Redis and Caching has the Redis side. Here, measured against MySQL: what a cache actually saves, and the bug it introduces.

cache-aside.mjs
// Cache-aside in front of MySQL: the product page query, from MySQL vs from Redis.
import mysql from "mysql2/promise";
import { createClient } from "redis";

const db = mysql.createPool({ host: "127.0.0.1", port: 3307, user: "root", password: "study", database: "ch11", connectionLimit: 4 });
const redis = createClient({ url: "redis://127.0.0.1:6380" });
await redis.connect();

const PRODUCT_SQL = `
  SELECT p.id, p.name, p.price, c.name AS category,
         COALESCE(SUM(oi.quantity), 0) AS units_sold
  FROM products p
  JOIN categories c        ON c.id = p.category_id
  LEFT JOIN order_items oi ON oi.product_id = p.id
  WHERE p.id = ?
  GROUP BY p.id, p.name, p.price, c.name`;

let dbQueries = 0;
async function getProduct(id) {
  const key = `ch11:product:${id}`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);                     // hit: no database work at all
  dbQueries++;
  const [[row]] = await db.query(PRODUCT_SQL, [id]);         // miss: ask MySQL...
  await redis.set(key, JSON.stringify(row), { EX: 60 });     // ...and keep the answer for 60 s
  return row;
}

async function updatePrice(id, price) {
  await db.query("UPDATE products SET price = ? WHERE id = ?", [price, id]);
  await redis.del(`ch11:product:${id}`);                     // invalidate after the write commits
}

await redis.del(await redis.keys("ch11:product:*").then((k) => (k.length ? k : ["ch11:none"])));

async function time(label, fn, n = 2000) {
  const t0 = performance.now();
  for (let i = 0; i < n; i++) await fn(1 + (i % 8));
  const ms = performance.now() - t0;
  console.log(`${label}: ${(ms / n * 1000).toFixed(0)} µs per lookup`);
}
await time("MySQL every time", (id) => db.query(PRODUCT_SQL, [id]));
await time("cache-aside     ", getProduct);
console.log(`cache-aside made ${dbQueries} MySQL queries for 2000 lookups`);

// An expensive report: a full scan over 464,321 log rows on another server.
const reports = mysql.createPool({ host: "127.0.0.1", port: 3413, user: "root", password: "study", database: "ptdemo" });
const REPORT_SQL = "SELECT DATE(created_at) AS day, COUNT(*) AS n FROM log_parted GROUP BY day ORDER BY n DESC LIMIT 1";
async function busiestDay() {
  const cached = await redis.get("ch11:report:busiest-day");
  if (cached) return JSON.parse(cached);
  const [[row]] = await reports.query(REPORT_SQL);
  await redis.set("ch11:report:busiest-day", JSON.stringify(row), { EX: 300 });
  return row;
}
await redis.del("ch11:report:busiest-day");
for (const label of ["report, cache miss", "report, cache hit "]) {
  const t0 = performance.now();
  const row = await busiestDay();
  console.log(`${label}: ${(performance.now() - t0).toFixed(1)} ms`, row);
}
await reports.end();

console.log("before:", await getProduct(3));
await db.query("UPDATE products SET price = 749.00 WHERE id = 3");          // write WITHOUT invalidating
console.log("stale: ", await getProduct(3));
await updatePrice(3, 749.00);                                                 // write + invalidate
console.log("fresh: ", await getProduct(3));
await updatePrice(3, 799.00);

await redis.quit();
await db.end();

// MySQL every time: 128 µs per lookup
// cache-aside     : 81 µs per lookup
// cache-aside made 8 MySQL queries for 2000 lookups
// report, cache miss: 50.3 ms { day: 2025-02-01T05:00:00.000Z, n: 17280 }
// report, cache hit : 0.1 ms { day: '2025-02-01T05:00:00.000Z', n: 17280 }
// before: {
//   id: 3,
//   name: 'Pixel 10',
//   price: '799.00',
//   category: 'Phones',
//   units_sold: '1'
// }
// stale:  {
//   id: 3,
//   name: 'Pixel 10',
//   price: '799.00',
//   category: 'Phones',
//   units_sold: '1'
// }
// fresh:  {
//   id: 3,
//   name: 'Pixel 10',
//   price: '749.00',
//   category: 'Phones',
//   units_sold: '1'
// }
What’s happening
  1. The product page query joins three small tables. Against MySQL every time it takes about 128 µs per lookup; through the cache, 81 µs. On a tiny dataset that MySQL serves from memory, a cache barely helps latency — a Redis round trip isn't free either. What it does change is load: 8 MySQL queries (one miss per product) instead of 2,000.
  2. The report is different: a GROUP BY over 464,321 rows on another server takes 50 ms on a miss and 0.1 ms on a hit, 500 times faster. Caches pay off most for expensive queries that many users ask with the same parameters.
  3. Notice the report's day: from MySQL it's a JavaScript Date; from the cache it's the string '2025-02-01T05:00:00.000Z', because JSON has no date type. Code that calls row.day.getTime() works on a miss and crashes on a hit. (The 05:00Z is mysql2 turning the DATE into local midnight in Toronto.) Serialise deliberately, or rehydrate types when reading from the cache.
  4. The stale read: updating the Pixel's price straight in the database, without touching the cache, leaves the old 799.00 being served — for up to the 60-second TTL, or forever without one. Every code path that writes the data must invalidate, including admin tools and batch jobs that bypass the application.
  5. updatePrice writes the database first, then deletes the key, and the next read repopulates it with 749.00. Deleting rather than updating the cache avoids races where two writers set the cache in the wrong order; a short TTL bounds the damage of any invalidation you miss.
AdvancedOld vs new: the query cache is gone
AdvancedPitfall: what not to cache, and stampedes

SQL and MySQL written fresh; MongoDB built from Rohit’s “mongoDB interview” doc. Every query was run on MySQL 9.7, MongoDB 9.0, Redis 8.10 or PostgreSQL 18.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.