Chapter 22 · Production Capstone: Design, Secure, Scale, Tune, and Recover MySQL
Load Test, Capture Plans, Tune Queries/Server, and Establish Performance Baselines
Generate a representative workload, capture plans and percentile latency, fix the highest-impact query/index issue first, and establish a reproducible performance baseline before server tuning.
Learning outcomes
A production performance claim is meaningful only when the workload, dataset, cache state, concurrency, duration, client behavior, and server version are recorded. This lesson creates a repeatable ServiceHub load test, captures query plans and server evidence, fixes the highest-impact access-path problem first, and then asks whether any server setting still has evidence behind it. The goal is not to manufacture a dramatic speedup; it is to establish a baseline that another engineer can reproduce.
Generate a representative but disposable dataset and preserve its seed/configuration artifacts.
Measure throughput, p50/p95/p99 latency, errors, and server-side statement evidence under named concurrency.
Capture EXPLAIN/EXPLAIN ANALYZE evidence before and after a query/index change on the identical dataset.
Distinguish query/schema bottlenecks from memory, I/O, connection, temp-work, and redo pressure before changing server variables.
Publish a baseline record containing residual bottlenecks and uncertainty rather than only the best run.
Define the experiment before running it
server: MySQL Community Server 8.4.10 LTSschema migration version: capture from schema_migrationsclient: Python + MySQL Connector/Python version recorded at runtimedataset: ServiceHub seed script + row counts + distribution queryworkload mix: 70% dispatch reads, 20% event writes, 10% detail/history readsconcurrency levels: 1, 8, 32 (reduce on small machines; keep them recorded)warm-up: 30 seconds, excluded from measured statisticsmeasured duration: 120 seconds per runruns: >= 3 per configuration/cache conditioncache condition: warm; cold-cache experiments labeled separatelyreported: throughput, p50, p95, p99, max, error rateserver evidence: statement digests, connections, temp work, buffer reads, redo, I/O waitschange policy: one hypothesis-driven change at a timeWarm versus cold cache changes what is being tested. Most long-running production workloads are not “cold” after every request. Do not restart MySQL merely to make one configuration appear faster unless cold-start behavior is the requirement.
Create a query that the current portfolio does not serve perfectly
The core index portfolio was derived from known dispatch and history paths. The load test adds an operational query: “find high-priority open work across all sites ordered by schedule.” The current site-leading index may not be ideal because this query does not constrain site_id. That is a realistic way a new workload reveals an index gap after deployment.
USE servicehub_capstone;EXPLAIN FORMAT=TREESELECT work_order_id,site_id,asset_id,priority,scheduled_atFROM work_ordersWHERE status='OPEN' AND priority>=4ORDER BY scheduled_at,work_order_idLIMIT 100;EXPLAIN ANALYZESELECT work_order_id,site_id,asset_id,priority,scheduled_atFROM work_ordersWHERE status='OPEN' AND priority>=4ORDER BY scheduled_at,work_order_idLIMIT 100;SELECT INDEX_NAME,CARDINALITYFROM information_schema.STATISTICSWHERE TABLE_SCHEMA='servicehub_capstone' AND TABLE_NAME='work_orders'ORDER BY INDEX_NAME,SEQ_IN_INDEX;Save the plan text as an artifact. The optimizer may choose differently as data and statistics change, so the acceptance test should focus on observed rows, sort/scan work, latency distribution, and resource impact—not on freezing one exact human-readable plan forever.
A deterministic load-test harness
import getpass, json, random, statistics, timefrom collections import defaultdictfrom concurrent.futures import ThreadPoolExecutorimport mysql.connectorHOST = "127.0.0.1"PORT = 3306USER = "sh_cap_app"PASSWORD = getpass.getpass("Disposable capstone password: ")DURATION = 30.0 # Increase for real baseline runs.WARMUP = 5.0 # Keep warm-up outside measurements.CONCURRENCY = 8 # Record every change to this value.CFG = dict(host=HOST, port=PORT, user=USER, password=PASSWORD, database="servicehub_capstone", ssl_disabled=False, autocommit=True, connection_timeout=5)QUERIES = { "dispatch": ("""SELECT work_order_id,site_id,asset_id,priority,scheduled_at FROM work_orders WHERE status=%s AND priority>=%s ORDER BY scheduled_at,work_order_id LIMIT 100""", ("OPEN", 4)), "history": ("""SELECT event_id,event_type,actor,occurred_at FROM work_order_events WHERE work_order_id=%s ORDER BY occurred_at,event_id LIMIT 50""", None),}def percentile(values, q): if not values: return None s = sorted(values) i = min(len(s)-1, max(0, round((len(s)-1)*q))) return s[i]def worker(seed, stop_at, warmup_until): rng = random.Random(seed) lat = defaultdict(list); errors = 0; ops = 0 cnx = mysql.connector.connect(**CFG) cur = cnx.cursor() try: while time.perf_counter() < stop_at: kind = "dispatch" if rng.random() < 0.8 else "history" sql, params = QUERIES[kind] if kind == "history": params = (rng.randint(1, 1000),) t0 = time.perf_counter() try: cur.execute(sql, params); cur.fetchall() except mysql.connector.Error: errors += 1 continue ms = (time.perf_counter()-t0)*1000 if time.perf_counter() >= warmup_until: lat[kind].append(ms); ops += 1 finally: cur.close(); cnx.close() return lat, errors, opsstart = time.perf_counter(); warmup_until = start + WARMUP; stop = warmup_until + DURATIONwith ThreadPoolExecutor(max_workers=CONCURRENCY) as ex: results = [f.result() for f in [ex.submit(worker, i, stop, warmup_until) for i in range(CONCURRENCY)]]all_lat = defaultdict(list); errors = total_ops = 0for lat, err, ops in results: errors += err; total_ops += ops for k, vals in lat.items(): all_lat[k].extend(vals)report = {"concurrency": CONCURRENCY, "duration_s": DURATION, "throughput_ops_s": total_ops/DURATION, "errors": errors, "error_rate": errors/max(1, total_ops+errors), "latency_ms": {}}for k, vals in all_lat.items(): report["latency_ms"][k] = {"count": len(vals), "p50": percentile(vals,.50), "p95": percentile(vals,.95), "p99": percentile(vals,.99), "max": max(vals) if vals else None}print(json.dumps(report, indent=2))The short default duration keeps the lesson practical. A production baseline uses longer runs and repeats, and records CPU/I/O/network behavior outside MySQL as well. No password is embedded in source code and SQL values are bound parameters.
Correlate client results with MySQL evidence
SELECT DIGEST_TEXT,COUNT_STAR,SUM_ROWS_EXAMINED,SUM_ROWS_SENT, ROUND(SUM_TIMER_WAIT/1000000000000,3) AS total_secondsFROM performance_schema.events_statements_summary_by_digestWHERE SCHEMA_NAME='servicehub_capstone'ORDER BY SUM_TIMER_WAIT DESCLIMIT 10;SHOW GLOBAL STATUS WHERE Variable_name IN('Threads_connected','Threads_running','Connections','Aborted_connects', 'Created_tmp_tables','Created_tmp_disk_tables', 'Innodb_buffer_pool_reads','Innodb_buffer_pool_read_requests', 'Innodb_os_log_written','Innodb_log_waits');SELECT EVENT_NAME,COUNT_STAR, ROUND(SUM_TIMER_WAIT/1000000000000,3) AS total_secondsFROM performance_schema.file_summary_by_event_nameORDER BY SUM_TIMER_WAIT DESCLIMIT 10;Cumulative status counters need interval deltas. Capture them before and after each measured run, then divide differences by elapsed time where a rate is meaningful. A high cumulative value since boot is not evidence that the current test caused the problem.
Fix the highest-impact query/index issue first
CREATE INDEX idx_wo_status_priority_scheduleON work_orders(status,priority,scheduled_at,work_order_id);ANALYZE TABLE work_orders;EXPLAIN FORMAT=TREESELECT work_order_id,site_id,asset_id,priority,scheduled_atFROM work_ordersWHERE status='OPEN' AND priority>=4ORDER BY scheduled_at,work_order_idLIMIT 100;EXPLAIN ANALYZESELECT work_order_id,site_id,asset_id,priority,scheduled_atFROM work_ordersWHERE status='OPEN' AND priority>=4ORDER BY scheduled_at,work_order_idLIMIT 100;This composite index has a subtle tradeoff: after equality on status, the range on priority can limit how fully the index satisfies ordering by scheduled_at. That is exactly why evidence matters. If sorting remains significant, test an alternative index/order/query shape on the same data rather than claiming this index must win. Keep only the portfolio justified by the real workload.
Wrong approach: tune server memory before proving a server bottleneck
Raising the buffer pool, sort buffers, or temporary-table limits can hide a poor query while increasing memory risk. The capstone change order is deliberate: correctness → query/schema/index → connection/workload shape → server resources. Only if the repeated test and OS/MySQL evidence show sustained memory/I/O/redo pressure should a server variable become the hypothesis.
| Signal | Possible interpretation | Do not conclude yet |
|---|---|---|
| high rows examined per call | poor selectivity/access path | that buffer pool is too small |
| disk temp-table rate rises | query creates spillable temp work | that temptable_max_ram must be raised |
| Innodb_log_waits increases during writes | redo capacity/write pressure deserves investigation | that durability should be weakened |
| Threads_running persistently high + CPU saturation | concurrency/CPU bottleneck possible | that max_connections should be increased |
| buffer-pool physical reads spike after restart | cache warming | that steady-state cache is undersized |
Publish the baseline honestly
Run ID / timestamp: ______________________________Server + client versions: _________________________Schema migration/checksum: _______________________Dataset row counts/distribution: __________________Concurrency / duration / warm-up: _________________Cache state: _____________________________________Throughput: ______________________________________Read p50 / p95 / p99: ____________________________Write p50 / p95 / p99: ___________________________Error rate: ______________________________________Top statement digests: ___________________________CPU / storage latency / queueing: _________________Temp-table / redo / buffer deltas: _______________Change tested: ___________________________________Before/after effect: ______________________________Residual bottleneck / uncertainty: _______________Decision: keep / revert / retest __________________The capstone passes only if another person can explain what was tested. A screenshot of one fast query is not a performance baseline.
Knowledge check
- Why exclude warm-up from measured latency?
- Why report p95/p99 instead of average alone?
- Why are cumulative status counters sampled twice?
- What should be tuned first when a statement examines far more rows than it returns?
- Why keep residual bottlenecks in the report?
Reveal answers
- Warm-up is a different cache/initialization phase; mixing it with steady-state results obscures what was measured.
- Tail percentiles expose slow-request behavior hidden by an average.
- The interval delta attributes activity to the measured window more accurately than a since-start total.
- Its query/schema/index access path before broad server-memory changes.
- A defensible baseline records limitations and uncertainty rather than only favorable results.
Authoritative references
- MySQL Community Server 8.4 Downloads
- MySQL 8.4 — InnoDB Storage Engine
- MySQL 8.4 — EXPLAIN and EXPLAIN ANALYZE
- MySQL 8.4 — Performance Schema
- MySQL 8.4 — Backup and Recovery
- MySQL 8.4 — Point-in-Time Recovery
- MySQL 8.4 — Replication
- MySQL 8.4 — Group Replication
- MySQL Shell 8.4 — InnoDB Cluster and Router sandbox
- MySQL Router 8.4
- MySQL 8.4 — Security
- MySQL 8.4 — Upgrade and downgrade guidance