Chapter 22 · Production Capstone: Design, Secure, Scale, Tune, and Recover MySQL
Build the Production Schema, Index Portfolio, Security Model, and Migration Pipeline
Implement the capstone schema, workload-derived indexes, least-privilege security, TLS/session contracts, and immutable expand/contract migrations with automated acceptance checks.
Learning outcomes
An ADR is useful only when the implementation enforces it. In this lesson ServiceHub turns the model into a production-oriented schema, derives an index portfolio from named workload paths, creates least-privilege roles, establishes secure-session expectations, and records versioned migrations. The lab also demonstrates a tempting but ineffective tuning move: adding a low-selectivity single-column index because a column appears in a WHERE clause.
Implement types, constraints, keys, and schema rules that match the capstone business invariants.
Derive composite indexes from predicate, ordering, join, and projection requirements rather than indexing every filtered column.
Use SHOW INDEX, EXPLAIN, EXPLAIN ANALYZE, and optimizer metadata to distinguish estimates from executed evidence.
Create least-privilege application/operator roles with TLS-aware account requirements and explicit session defaults.
Implement immutable ordered migrations with checksums and an expand/contract change that allows old and new application versions to overlap.
Seed enough business state to make plans meaningful
Optimizer evidence on five rows is useful for syntax but weak for plan engineering. The capstone seed remains laptop-sized, but it creates enough rows and skew to show why index order matters. The generated values are deterministic so repeated tests compare the same dataset.
USE servicehub_capstone;INSERT INTO sites(site_code,site_name) VALUES('NORTH','North Plant'),('HARBOR','Harbor Workshop'),('WEST','West Field');INSERT INTO assets(site_id,asset_tag,asset_type,metadata)SELECT s.site_id, CONCAT(s.site_code,'-',LPAD(n.n,4,'0')), CASE n.n % 3 WHEN 0 THEN 'PUMP' WHEN 1 THEN 'ROBOT' ELSE 'COMPRESSOR' END, JSON_OBJECT('criticality', CASE WHEN n.n % 10=0 THEN 'high' ELSE 'normal' END)FROM sites sJOIN ( WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n<200) SELECT n FROM seq) n;INSERT INTO work_orders(site_id,asset_id,status,priority,summary,scheduled_at,created_at)SELECT a.site_id,a.asset_id, CASE x.n % 8 WHEN 0 THEN 'CLOSED' WHEN 1 THEN 'IN_PROGRESS' ELSE 'OPEN' END, 1 + (x.n % 5), CONCAT('Inspection ',x.n,' for ',a.asset_tag), TIMESTAMP('2026-08-01 08:00:00') + INTERVAL (x.n % 1440) MINUTE, TIMESTAMP('2026-07-01 00:00:00') + INTERVAL x.n MINUTEFROM assets aJOIN ( WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n<20) SELECT n FROM seq) x;INSERT INTO work_order_events(work_order_id,event_type,actor,payload,occurred_at)SELECT work_order_id,'CREATED','seed',JSON_OBJECT('source','capstone'),created_atFROM work_orders;SELECT COUNT(*) AS assets FROM assets;SELECT COUNT(*) AS work_orders FROM work_orders;SELECT status,COUNT(*) FROM work_orders GROUP BY status ORDER BY status;If the recursive common table expression limit on a customized server prevents the seed, reduce the sequence or use the earlier course's number-table technique. The objective is repeatability, not an arbitrary row count.
Derive indexes from named access paths
The most important dispatch query filters by site and active status, then orders by scheduled time and needs a stable tiebreaker. A technician frequently loads event history by work order and occurrence time. The index portfolio therefore follows those shapes.
ALTER TABLE work_orders ADD INDEX idx_wo_site_status_schedule (site_id,status,scheduled_at,work_order_id), ADD INDEX idx_wo_asset_created (asset_id,created_at,work_order_id);ALTER TABLE work_order_events ADD INDEX idx_event_work_order_time (work_order_id,occurred_at,event_id);SHOW INDEX FROM work_orders;SHOW INDEX FROM work_order_events;EXPLAIN FORMAT=TREESELECT work_order_id,asset_id,priority,summary,scheduled_atFROM work_ordersWHERE site_id=1 AND status='OPEN'ORDER BY scheduled_at,work_order_idLIMIT 50;The leftmost columns match equality predicates first; the ordering columns follow. The primary key at the end provides deterministic order and is already present in InnoDB secondary records, though naming it in the index definition makes the intended ordering contract explicit.
Tempting but ineffective: add a standalone status index
status has only a few values and most ServiceHub rows are active during the seed. A learner may see WHERE status='OPEN' and create INDEX(status). That can be ineffective for the dispatch query because it does not narrow by site or provide the requested order.
CREATE INDEX idx_wo_status_only ON work_orders(status);EXPLAIN FORMAT=TREESELECT work_order_id,asset_id,priority,summary,scheduled_atFROM work_ordersWHERE site_id=1 AND status='OPEN'ORDER BY scheduled_at,work_order_idLIMIT 50;EXPLAIN ANALYZESELECT work_order_id,asset_id,priority,summary,scheduled_atFROM work_ordersWHERE site_id=1 AND status='OPEN'ORDER BY scheduled_at,work_order_idLIMIT 50;-- Remove the unjustified write/storage cost after evidence review.DROP INDEX idx_wo_status_only ON work_orders;EXPLAIN reports optimizer estimates and a planned access path. EXPLAIN ANALYZE actually executes the query and reports iterator timings and actual row counts. The lesson does not publish invented speedups; record what your machine measured and whether the composite index eliminates unnecessary scan/sort work.
Make schema and security acceptance machine-checkable
CREATE ROLE IF NOT EXISTS 'r_sh_app_rw', 'r_sh_app_ro', 'r_sh_operator';GRANT SELECT ON servicehub_capstone.sites TO 'r_sh_app_ro';GRANT SELECT ON servicehub_capstone.assets TO 'r_sh_app_ro';GRANT SELECT ON servicehub_capstone.work_orders TO 'r_sh_app_ro';GRANT SELECT ON servicehub_capstone.work_order_events TO 'r_sh_app_ro';GRANT SELECT,INSERT,UPDATE ON servicehub_capstone.work_orders TO 'r_sh_app_rw';GRANT SELECT,INSERT ON servicehub_capstone.work_order_events TO 'r_sh_app_rw';GRANT SELECT,INSERT,UPDATE ON servicehub_capstone.integration_requests TO 'r_sh_app_rw';GRANT PROCESS ON *.* TO 'r_sh_operator';DROP USER IF EXISTS 'sh_cap_app'@'127.0.0.1';CREATE USER 'sh_cap_app'@'127.0.0.1' IDENTIFIED BY 'Disposable-Capstone-Only!' REQUIRE SSL;GRANT 'r_sh_app_ro','r_sh_app_rw' TO 'sh_cap_app'@'127.0.0.1';SET DEFAULT ROLE 'r_sh_app_ro','r_sh_app_rw' TO 'sh_cap_app'@'127.0.0.1';SHOW GRANTS FOR 'sh_cap_app'@'127.0.0.1';SHOW CREATE USER 'sh_cap_app'@'127.0.0.1';SHOW VARIABLES LIKE 'have_ssl';SHOW VARIABLES LIKE 'require_secure_transport';REQUIRE SSL is an account policy, so the application must negotiate TLS. In the real deployment use CA verification/hostname verification, not encryption without identity. The disposable password is intentionally lab-only; production secrets belong in an external secret mechanism and rotation process.
-- Connect as sh_cap_app over TLS, then:SELECT CURRENT_USER(),CURRENT_ROLE();SHOW SESSION STATUS LIKE 'Ssl_cipher';DROP TABLE servicehub_capstone.sites;CREATE USER 'should_fail'@'localhost' IDENTIFIED BY 'x';-- Both administrative actions should be denied.-- A permitted business read should still succeed:SELECT work_order_id,status,summaryFROM servicehub_capstone.work_ordersORDER BY work_order_id LIMIT 3;Session initialization is part of the application contract
Pooled connections are reused. A previous request must not leave a surprising time zone, SQL mode, open transaction, or role state for the next request. Production connectors should reset/reinitialize sessions and verify critical assumptions after checkout when correctness depends on them.
SELECT @@session.autocommit, @@session.transaction_isolation, @@session.time_zone, @@session.sql_mode, @@character_set_connection, @@collation_connection, CURRENT_ROLE();SET SESSION time_zone = '+00:00';SET SESSION sql_mode = @@GLOBAL.sql_mode;ROLLBACK;SET autocommit = 1;Version migrations: expand first, contract later
An immutable migration record prevents “which SQL ran?” ambiguity. The first capstone evolution adds a nullable service-level deadline column. Old application code ignores it; new code can write it. A bounded backfill follows. The eventual NOT NULL or old-column removal is a later contract migration after every old application version is gone.
CREATE TABLE IF NOT EXISTS schema_migrations ( version VARCHAR(32) PRIMARY KEY, checksum CHAR(64) NOT NULL, owner VARCHAR(80) NOT NULL, status ENUM('APPLIED','FAILED') NOT NULL, applied_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)) ENGINE=InnoDB;ALTER TABLE work_orders ADD COLUMN response_due_at DATETIME(6) NULL, ALGORITHM=INSTANT;INSERT INTO schema_migrations(version,checksum,owner,status)VALUES ('022_002_expand_response_due', SHA2('ALTER TABLE work_orders ADD COLUMN response_due_at DATETIME(6) NULL ALGORITHM=INSTANT',256), 'servicehub-db','APPLIED');-- Bounded backfill: run repeatedly and checkpoint progress.UPDATE work_ordersSET response_due_at = scheduled_at - INTERVAL 30 MINUTEWHERE response_due_at IS NULLORDER BY work_order_idLIMIT 500;SELECT version,status,applied_at FROM schema_migrations ORDER BY version;SELECT COUNT(*) AS remaining_backfillFROM work_orders WHERE response_due_at IS NULL;The lab hashes the canonical migration statement so the ledger contains a deterministic value. A production migration runner should hash the immutable migration file bytes themselves, refuse edits to an already-applied version, apply new files in order, and run schema/security acceptance queries afterward.
Automated acceptance: reject drift before deployment
SELECT COLUMN_NAME,IS_NULLABLE,COLUMN_TYPEFROM information_schema.COLUMNSWHERE TABLE_SCHEMA='servicehub_capstone' AND TABLE_NAME='work_orders' AND COLUMN_NAME IN ('work_order_id','status','scheduled_at','response_due_at')ORDER BY ORDINAL_POSITION;SELECT INDEX_NAME,GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns_in_orderFROM information_schema.STATISTICSWHERE TABLE_SCHEMA='servicehub_capstone' AND TABLE_NAME='work_orders'GROUP BY INDEX_NAMEORDER BY INDEX_NAME;SHOW GRANTS FOR 'sh_cap_app'@'127.0.0.1';SHOW CREATE USER 'sh_cap_app'@'127.0.0.1';A migration pipeline should fail closed if the schema, index portfolio, account policy, or migration checksum differs from the reviewed state. “The SQL exited zero” is not enough.
Knowledge check
- Why was a single-column status index rejected?
- What does EXPLAIN ANALYZE add beyond EXPLAIN?
- Why use REQUIRE SSL for the application account?
- Why is response_due_at nullable in the expand step?
- What should an automated migration gate verify?
Reveal answers
- It did not match the full site+status+ordering access path and added write/storage cost without proven value.
- It executes the statement and reports actual iterator timing/row evidence in addition to estimates.
- It enforces encrypted transport for that account; production should additionally verify certificate identity.
- Old and new application versions can overlap while a bounded backfill runs; stricter constraints belong in a later contract step.
- Migration checksums/order plus expected schema, indexes, security/account policy, and application compatibility.
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