Part 10 · 10 chapters · ~20 min

Operating MySQL in production

Everything in this part is a thing that has taken a production database down somewhere. Schema changes that locked a table for an hour, a backup nobody had ever restored, a collation change that broke an index, a partition scheme that made every query slower. This is the operational half of being trusted with a database.

100

Schema changes and INSTANT DDL

Not all ALTERs are equal. MySQL has three algorithms, and knowing which one your change uses is the difference between a one-second deploy and an outage.

AlgorithmWhat it doesTable copy?Blocks writes?
INSTANT 8.0+Metadata-only change. Milliseconds regardless of table size.NoNo
INPLACERebuilds in place, or modifies the index. Concurrent DML is recorded and applied at the end.UsuallyBrief lock at start and end
COPYCreates a new table, copies every row, swaps.YesYes — the whole time

What can be INSTANT

  • ADD COLUMN — 8.0.12+, with caveats below
  • DROP COLUMN — 8.0.29+
  • Rename a column, change a column's default, extend a VARCHAR within the same length-byte size (≤255 to ≤255, or >255 to >255)
  • Add or drop a virtual generated column, change ENUM/SET by appending values at the end
  • Set an index visible or invisible (chapter 38)
two INSTANT traps
Extending a VARCHAR across the 255-byte boundary is a full rebuild. VARCHAR(100) → VARCHAR(200) is instant; VARCHAR(200) → VARCHAR(300) copies the whole table, because the length prefix grows from one byte to two.

Instant columns accumulate. Each instant ADD COLUMN creates a new "row version" in the table metadata, and there is a hard limit of 64. Past that, further instant adds fail and you must rebuild. Check information_schema.innodb_tables.total_row_versions.
run it
-- ALWAYS state your requirement. MySQL then FAILS FAST
-- rather than silently choosing a table copy.
ALTER TABLE users ADD COLUMN nickname VARCHAR(64),
  ALGORITHM=INSTANT;
--   error if it cannot be instant → you learn before you deploy

ALTER TABLE users ADD INDEX idx_nick (nickname),
  ALGORITHM=INPLACE, LOCK=NONE;
--   error if it would block writes

-- How many instant row versions has this table used up?
SELECT name, total_row_versions
FROM information_schema.innodb_tables
WHERE name LIKE 'yourdb/%'
ORDER BY total_row_versions DESC;

-- And ALWAYS bound the wait (chapter 52):
SET SESSION lock_wait_timeout = 5;
101

gh-ost and pt-online-schema-change

When the change cannot be INSTANT or INPLACE — and on a 500GB table even INPLACE can run for hours and generate enormous redo — you use an external tool. Both build a shadow copy and swap it in, but they differ in one architecturally important way.

pt-online-schema-changegh-ost
Change captureTriggers on the original tableReads the binary log
Load on the primaryEvery write fires a trigger, inside the same transactionNone from capture; it tails the binlog
ThrottlingPauses on replica lagPauses on lag, load, or a flag file — pausable and resumable mid-run
Can run on a replicaNoYes — migrate on a replica, then promote
Existing triggersConflicts (pre-8.0 allowed one trigger per event)No conflict
MaturityVery mature, part of Percona ToolkitBuilt at GitHub for exactly this problem
why triggerless matters
With pt-osc, every INSERT on the original table synchronously fires a trigger that writes to the shadow table — inside the user's transaction. That doubles the write work and extends lock duration, so a migration on a busy table adds latency to every write for its entire duration.

gh-ost's binlog approach moves that work off the critical path entirely. It is the better default now, and the ability to throttle interactively — or abort by touching a file — makes it far safer to run during business hours.
run it
gh-ost \
  --host=primary.db --database=shop --table=orders \
  --alter="ADD COLUMN shipped_at DATETIME NULL, ADD INDEX idx_s (shipped_at)" \
  --max-load="Threads_running=25" \
  --critical-load="Threads_running=100" \
  --max-lag-millis=1500 \
  --chunk-size=1000 \
  --throttle-flag-file=/tmp/ghost.throttle \
  --postpone-cut-over-flag-file=/tmp/ghost.postpone \
  --allow-on-master \
  --initially-drop-ghost-table \
  --execute

# While it runs:
#   touch /tmp/ghost.throttle     → pause copying immediately
#   rm /tmp/ghost.throttle        → resume
#   rm /tmp/ghost.postpone        → perform the cut-over now
#
# The postpone flag is the important one: the copy finishes
# whenever it finishes, and YOU choose the moment of the
# brief cut-over lock — e.g. 3am, not mid-day.
102

Connections and pooling

From chapter 9: each connection is a thread with its own buffers. The arithmetic in chapter 75 shows why max_connections=5000 is not a solution.

The pool sizing formula people get wrong

code
-- the naive version
pool_size × app_instances  <  max_connections

-- worked example that kills a server
pool = 20, instances = 40 (autoscaled)  →  800 connections
   + background workers, cron, migrations, admin sessions
   + a deploy running double instances briefly
   → 2000+ against a max_connections of 1000

Two rules that prevent this. First, a pool should be small — a database can only usefully execute as many queries as it has cores, so cores × 2 per instance is a sane starting point, not 50. Second, leave headroom: set max_connections well above your computed peak, because the failure mode is total (nobody can connect, including you).

the reserved connection
MySQL reserves one connection slot for a user with CONNECTION_ADMIN (or SUPER), so an admin can still get in when max_connections is exhausted. Make sure your break-glass account has it — and test that you can actually use it, before you need it at 3am. admin_address/admin_port gives you a dedicated listener for this, which is better still.
103

Backups and point-in-time recovery

MethodTypeRestore speedNotes
mysqldumpLogical (SQL)Slow — re-inserts and rebuilds every indexPortable, human-readable, version-independent. Fine to a few hundred GB.
mysqlpumpLogicalSlowParallel dump, but indexes are still rebuilt on restore. Deprecated in 8.0.34.
XtraBackupPhysicalFast — copies filesPercona. Hot backup, streaming, incrementals. The standard for large databases.
Clone pluginPhysicalFastBuilt into 8.0. Provisions a new replica in one command.
Filesystem/EBS snapshotPhysicalVery fastMust be atomic and crash-consistent. Combine with FLUSH TABLES WITH READ LOCK or a filesystem that guarantees it.

Point-in-time recovery

A backup restores you to the moment it was taken. To get to 14:32:07 — one minute before someone ran DELETE without a WHERE — you replay binary logs on top.

run it
# 1. Restore the most recent full backup.
xtrabackup --copy-back --target-dir=/backup/full-2026-09-21

# 2. Find where that backup ended.
cat /backup/full-2026-09-21/xtrabackup_binlog_info
#   binlog.000042   19384756   3E11FA47-...:1-104721

# 3. Find the exact bad statement in the binlogs.
mysqlbinlog --verbose --base64-output=DECODE-ROWS \
  --start-datetime="2026-09-21 14:25:00" \
  binlog.000042 | grep -n -B5 -A5 "DELETE FROM orders"

# 4. Replay everything UP TO that point, and no further.
#    By GTID (preferred — exact, no off-by-one):
mysqlbinlog --exclude-gtids='3E11FA47-...:104999-105000' \
  binlog.000042 binlog.000043 | mysql

#    Or by position:
mysqlbinlog --start-position=19384756 \
            --stop-position=20481923 \
  binlog.000042 | mysql

# 5. VERIFY before letting traffic back in.

# ── The rule that matters more than any of this ──
# An untested backup is not a backup. Schedule a real
# restore to a scratch host monthly and time it. The
# metric that matters is RESTORE time, not backup time.
104

Observability

performance_schema instruments the server itself — waits, stages, statements, memory, locks — into in-memory tables. sys is a set of views over it that are actually readable. Learn sys first.

run it
-- ═══ WHAT IS SLOW ═══
-- Ranked by total time consumed, which is what actually matters —
-- a 5ms query run 2 million times beats a 3s query run twice.
SELECT LEFT(query, 80) AS q, db, exec_count,
       total_latency, avg_latency, rows_sent_avg, rows_examined_avg
FROM sys.statement_analysis
ORDER BY total_latency DESC LIMIT 15;

-- The single best signal of a missing index:
-- rows examined per row returned.
SELECT LEFT(query,80) AS q, exec_count,
       rows_examined_avg, rows_sent_avg,
       ROUND(rows_examined_avg / NULLIF(rows_sent_avg,0)) AS waste_ratio
FROM sys.statement_analysis
WHERE rows_sent_avg > 0
ORDER BY waste_ratio DESC LIMIT 10;
--   ratio of 1 is perfect. 10000 means scanning for one row.

-- ═══ FULL SCANS ═══
SELECT * FROM sys.statements_with_full_table_scans LIMIT 10;
SELECT * FROM sys.schema_tables_with_full_table_scans LIMIT 10;

-- ═══ INDEX HYGIENE ═══
SELECT * FROM sys.schema_unused_indexes;      -- check Uptime first!
SELECT * FROM sys.schema_redundant_indexes;

-- ═══ WHAT IS THE SERVER WAITING ON ═══
SELECT * FROM sys.waits_global_by_latency LIMIT 10;
SELECT * FROM sys.io_global_by_file_by_bytes LIMIT 10;

-- ═══ THE SLOW LOG, DIGESTED ═══
-- SET GLOBAL slow_query_log = ON;
-- SET GLOBAL long_query_time = 0.5;
-- SET GLOBAL log_queries_not_using_indexes = ON;  -- noisy; short bursts only
--   then:  pt-query-digest /var/log/mysql/slow.log | head -60
105

The configuration that actually matters

MySQL has over 600 system variables. Fifteen of them account for nearly all real-world impact. Everything else should stay at its default until you have evidence.

innodb_buffer_pool_size50–75% of RAM The single most important setting. On a dedicated server, 70% is a good target. Leave room for per-connection buffers (ch 75) and the OS.
innodb_redo_log_capacityseveral GB Too small causes periodic total write stalls (ch 73). Size it to hold an hour of writes. Dynamic since 8.0.30.
innodb_flush_log_at_trx_commit1 Durability. Only lower it as a documented, deliberate trade (ch 63).
sync_binlog1 The other half of durability. Both must be 1 for the guarantee to hold.
innodb_flush_methodO_DIRECT Stops double-caching pages in the OS and the buffer pool. Default on Linux since 8.0.14.
innodb_io_capacity / _maxmatch your disk Defaults of 200/2000 are spinning-disk numbers. On NVMe, 2000–8000 / double that. Ch 71.
max_connectionspeak + headroom Not "as high as possible" — multiply by per-connection buffers before setting it.
innodb_thread_concurrency0 Leave at 0 (unlimited). This is a legacy knob that reliably makes things worse.
transaction_isolationRR or RC READ-COMMITTED reduces gap locking on write-heavy workloads (ch 60). A real decision, not a default to copy.
character_set_serverutf8mb4 Default since 8.0. On an upgraded server, verify it — utf8 is a trap (ch 106).
sql_modekeep STRICT Never remove STRICT_TRANS_TABLES. Without it, bad data is silently truncated instead of rejected.
slow_query_log + long_query_timeON, 0.5s You cannot fix what you cannot see. The overhead is negligible.
innodb_buffer_pool_instancesleave default 8.0 picks sensibly. Only touch it with measured mutex contention.
table_open_cachewatch the status var Raise if Table_open_cache_misses grows steadily. Matters with many tables.
super_read_onlyON on every replica Prevents errant transactions (ch 92). Not optional.
the anti-pattern
Copying a my.cnf from a blog post. Most such files contain tuning for MySQL 5.5 on spinning disks, frequently including query_cache_size (removed in 8.0), and innodb_thread_concurrency set to some number that throttles your server. Start from the defaults and change only what you can justify with a measurement.
106

Character sets and collations

MySQL's utf8 is not UTF-8. It is utf8mb3: a maximum of three bytes per character, which covers the Basic Multilingual Plane and nothing else. Emoji, many CJK extension characters, and some historic scripts need four bytes and simply cannot be stored.

The correct charset is utf8mb4, default since 8.0. On any server upgraded from 5.x, verify rather than assume.

Collations

CollationBehaviourUse
utf8mb4_0900_ai_ciUnicode 9.0, accent-insensitive, case-insensitiveThe 8.0 default. Right for most text.
utf8mb4_0900_as_csAccent- and case-sensitiveWhen 'a' and 'A' must differ.
utf8mb4_binRaw byte comparisonTokens, hashes, identifiers.
utf8mb4_general_ciFast, and incorrect for many languagesLegacy only. Do not choose it for new work.
a mixed collation silently kills your indexes
Join two columns with different collations and MySQL must convert one side to compare them. A function on a column is not sargable (chapter 74), so the index cannot be used and you get a full scan — with no error and no warning. This is a genuinely common cause of "the same query is fast on one table and slow on another".
run it
-- Anything still on the 3-byte lie?
SELECT table_schema, table_name, column_name,
       character_set_name, collation_name
FROM information_schema.columns
WHERE character_set_name IS NOT NULL
  AND character_set_name <> 'utf8mb4'
  AND table_schema NOT IN
      ('mysql','sys','information_schema','performance_schema');

-- More than one collation in the database = latent join problems.
SELECT collation_name, COUNT(*) AS cols
FROM information_schema.columns
WHERE table_schema = DATABASE()
GROUP BY collation_name;

-- The conversion (rebuilds the table — use gh-ost on a big one):
-- ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4
--   COLLATE utf8mb4_0900_ai_ci;
107

JSON columns

MySQL's JSON type stores a parsed binary format, not text. That means no re-parsing on read and O(log n) lookup of a key within a document — but it also means the whole document is rewritten on any update, so a partial update of a large document is expensive.

Indexing JSON

You cannot index a JSON column directly. Two routes:

code
-- 1. a generated column, then index it (works everywhere)
ALTER TABLE events
  ADD COLUMN user_id BIGINT
AS (payload->>'$.user_id') STORED,
  ADD INDEX idx_uid (user_id);

-- 2. a functional index (8.0.13+) — no extra column
CREATE INDEX idx_uid ON events (
  (CAST(payload->>'$.user_id' AS UNSIGNED))
);

-- 3. arrays need a multi-valued index (8.0.17+, ch 36)
CREATE INDEX idx_tags ON events (
  (CAST(payload->'$.tags' AS CHAR(32) ARRAY))
);

Note ->> (unquoting extract) versus -> (extract). -> returns a JSON value, so a string comes back with its quotes; ->> returns the raw value. Comparing a -> result to a plain string silently fails to match.

when JSON is the right call
Genuinely sparse or user-defined attributes, third-party API payloads you want to keep verbatim, and event data whose shape varies. Not for fields you filter, join or sort on regularly — those are columns, and pretending otherwise trades a schema migration today for a permanent performance tax.
108

Partitioning

A partitioned table is split into several physical sub-tables, transparently to SQL. It is widely misunderstood as a performance feature; it mostly is not.

What it doesWhat it does not
Instant bulk delete. ALTER TABLE ... DROP PARTITION removes a month of data in milliseconds, instead of a DELETE that takes hours and bloats undo.Make queries faster in general. Pruning only helps if the partition key is in the WHERE clause.
Partition pruning. A query filtered on the partition key touches only relevant partitions.Replace indexing. Each partition still needs its own indexes.
Smaller per-partition indexes, which can improve cache locality.Help queries that do not filter on the key — those now scan every partition, which is slower than one table.
the constraint that decides it
Every unique index — including the primary key — must contain every column of the partition key. So partitioning by created_at means your primary key must become (id, created_at), and a UNIQUE(email) constraint becomes impossible.

That restriction disqualifies partitioning for most OLTP tables. The clear win is time-series data with a retention policy: partition by month, drop the oldest partition on a schedule, never write a DELETE again.
109

The security surface

Privileges

8.0 split the monolithic SUPER privilege into ~40 dynamic privileges — CONNECTION_ADMIN, REPLICATION_APPLIER, SYSTEM_VARIABLES_ADMIN and so on. That makes real least-privilege possible: an operator who can kill connections need not also be able to change system variables.

Roles (8.0+) let you grant a named bundle rather than maintaining per-user grants — the practical way to keep this manageable.

run it
-- Who has dangerous privileges?
SELECT grantee, privilege_type
FROM information_schema.user_privileges
WHERE privilege_type IN
  ('SUPER','FILE','PROCESS','SHUTDOWN','GRANT OPTION',
   'SYSTEM_VARIABLES_ADMIN','CONNECTION_ADMIN')
ORDER BY grantee;

-- Accounts reachable from anywhere.
SELECT user, host, plugin, account_locked, password_expired
FROM mysql.user WHERE host = '%';

-- Accounts with NO password. There should be none.
SELECT user, host FROM mysql.user
WHERE authentication_string = '' AND plugin NOT IN ('auth_socket');

-- Roles, the maintainable approach:
CREATE ROLE app_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO app_rw;
GRANT app_rw TO 'svc_orders'@'10.0.%';
SET DEFAULT ROLE app_rw TO 'svc_orders'@'10.0.%';

-- Is traffic encrypted?
SHOW STATUS LIKE 'Ssl_cipher';
-- ALTER USER 'svc'@'%' REQUIRE SSL;   -- enforce per account

Encryption at rest and auditing

TDE encrypts tablespace files using a two-tier key system — a master key from a keyring plugin encrypts per-tablespace keys, so rotating the master key does not require re-encrypting data. It protects against stolen disks, not against a compromised application: a valid connection still reads plaintext.

The audit log plugin (Enterprise, or the MariaDB/Percona equivalents) records connections and statements for compliance. Budget for its overhead and its volume before enabling it broadly.

That is MySQL as an operator sees it. Part 11 turns the whole module around: you build a storage engine yourself, in TypeScript, and find out which parts you actually understood.