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.
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.
| Algorithm | What it does | Table copy? | Blocks writes? |
|---|---|---|---|
INSTANT 8.0+ | Metadata-only change. Milliseconds regardless of table size. | No | No |
INPLACE | Rebuilds in place, or modifies the index. Concurrent DML is recorded and applied at the end. | Usually | Brief lock at start and end |
COPY | Creates a new table, copies every row, swaps. | Yes | Yes — the whole time |
What can be INSTANT
ADD COLUMN— 8.0.12+, with caveats belowDROP COLUMN— 8.0.29+- Rename a column, change a column's default, extend a
VARCHARwithin the same length-byte size (≤255 to ≤255, or >255 to >255) - Add or drop a virtual generated column, change
ENUM/SETby appending values at the end - Set an index visible or invisible (chapter 38)
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.-- 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;
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-change | gh-ost | |
|---|---|---|
| Change capture | Triggers on the original table | Reads the binary log |
| Load on the primary | Every write fires a trigger, inside the same transaction | None from capture; it tails the binlog |
| Throttling | Pauses on replica lag | Pauses on lag, load, or a flag file — pausable and resumable mid-run |
| Can run on a replica | No | Yes — migrate on a replica, then promote |
| Existing triggers | Conflicts (pre-8.0 allowed one trigger per event) | No conflict |
| Maturity | Very mature, part of Percona Toolkit | Built at GitHub for exactly this problem |
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.
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.
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
-- 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).
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.Backups and point-in-time recovery
| Method | Type | Restore speed | Notes |
|---|---|---|---|
mysqldump | Logical (SQL) | Slow — re-inserts and rebuilds every index | Portable, human-readable, version-independent. Fine to a few hundred GB. |
mysqlpump | Logical | Slow | Parallel dump, but indexes are still rebuilt on restore. Deprecated in 8.0.34. |
| XtraBackup | Physical | Fast — copies files | Percona. Hot backup, streaming, incrementals. The standard for large databases. |
| Clone plugin | Physical | Fast | Built into 8.0. Provisions a new replica in one command. |
| Filesystem/EBS snapshot | Physical | Very fast | Must 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.
# 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.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.
-- ═══ 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 -60The 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.
utf8 is a trap (ch 106).STRICT_TRANS_TABLES. Without it, bad data is silently truncated instead of rejected.Table_open_cache_misses grows steadily. Matters with many tables.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.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
| Collation | Behaviour | Use |
|---|---|---|
utf8mb4_0900_ai_ci | Unicode 9.0, accent-insensitive, case-insensitive | The 8.0 default. Right for most text. |
utf8mb4_0900_as_cs | Accent- and case-sensitive | When 'a' and 'A' must differ. |
utf8mb4_bin | Raw byte comparison | Tokens, hashes, identifiers. |
utf8mb4_general_ci | Fast, and incorrect for many languages | Legacy only. Do not choose it for new work. |
-- 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;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:
-- 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.
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 does | What 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. |
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.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.
-- 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 accountEncryption 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.