Loading...

Why Your MySQL Queries Are Slow Even After Adding Indexes (And How We Fixed most of Them in Production)

At a fintech startup I worked at, we spent 3 weeks chasing a “mystery latency spike” in our subscription renewal service—query latency jumped from 12ms to 420ms during peak hour, only on Tuesdays. The EXPLAIN plan showed “Using index”, innodb_buffer_pool_hit_ratio was 99.8%, and the DBA said “it’s fine”. Turns out it was a silent index corruption caused by ALTER TABLE ... ALGORITHM=INPLACE on a 42GB subscriptions table with ROW_FORMAT=COMPACT + utf8mb4_0900_as_cs collation—triggered by an off-by-one buffer overflow in MySQL 8.0.roughly one in five’s index merge optimizer when handling multi-column range scans on descending indexes. We found it only after enabling log_optimizer_trace=1 and parsing 17MB of trace JSON at late at night

That wasn’t the first time I’ve watched engineers burn days debugging “impossible” performance regressions—queries that should be fast, are fast in staging, look correct in EXPLAIN, and yet crater in production with no obvious trigger. I’ve done it myself. At a ride-sharing company Eats, I shipped an index that made our ETA model worse. At a professional networking company, I wrote a DELETE that locked 2.1 million rows for nearly half minutes—and didn’t realize it until support tickets flooded in. At a cloud storage company, our analytics replica drifted silently for three days while aggregating garbage data. Every one of those failures cost real money, real trust, and real sleep.

This isn’t about theory. It’s about what actually works when your pager goes off at 2:17 a.m. and the dashboard shows 98% of renewal queries >400ms. Below is exactly what I now do before merging a schema change, before shipping a new query, and the second something feels “off” in production. No abstractions. No “in general”. Just battle-tested actions—with version numbers, exact config flags, and line-by-line code you can paste tomorrow.

---

The Real Problem Isn’t Missing Indexes—It’s Silent Index Bypass

Let me tell you about a travel platform.

We had a “real-time” pricing sync job inserting 12M rows/hour into price_history. Every 4.7 hours—exactly—it would fail with Lock wait timeout exceeded. Not sporadically. Not under load spikes. Every 4.7 hours. Like clockwork.

We checked everything:

  • SELECT * FROM INFORMATION_SCHEMA.INNODB_TRX WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 30; — nothing long-running
  • SHOW ENGINE INNODB STATUS\G — no obvious blocking transactions
  • SELECT COUNT(*) FROM price_history WHERE hotel_id = 12345 AND updated_at BETWEEN '2024-03-15 14:roughly one in five:00' AND '2024-03-15 14:roughly one in five:02'; — returned 1 row

But the inserts kept timing out.

The root cause? A non-unique secondary index:

CREATE INDEX idx_hotel_updated ON price_history (hotel_id, updated_at);

updated_at was stored as DATETIME(6) in the app—but the microsecond precision got truncated somewhere in the ORM layer (Django 4.2.7 + PyMySQL 1.1.0), so 99.99% of rows had identical updated_at values down to the second. That meant InnoDB couldn’t use precise record-level locks. Instead, it applied gap locks over entire index ranges.

Here’s what happened on insert:

INSERT INTO price_history (hotel_id, price_cents, updated_at) 

VALUES (12345, 12999, '2024-03-15 14:22:01');

Because (12345, '2024-03-15 14:roughly one in five:01') wasn’t unique, InnoDB locked the gap before and after that value in the index tree page. And because updated_at had so many duplicates, those gaps were huge—covering all hotel_id = 12345 rows inserted within ±3 seconds. Every subsequent insert for that hotel_id in that window blocked.

We confirmed it with this exact query—run during the timeout (not before, not after):

SELECT 

OBJECT_SCHEMA,

OBJECT_NAME,

INDEX_NAME,

LOCK_TYPE,

LOCK_MODE,

LOCK_DATA

FROM performance_schema.data_locks

WHERE OBJECT_SCHEMA = 'pricing'

AND OBJECT_NAME = 'price_history'

AND LOCK_MODE LIKE '%GAP%';

It returned 12,487 rows—all gap locks on (12345, '2024-03-15 14:roughly one in five:01').

Fix? Two lines:

-- Step 1: Drop the dangerous index

DROP INDEX idx_hotel_updated ON price_history;

-- Step 2: Replace with unique compound + add microsecond precision at the DB level

ALTER TABLE price_history

MODIFY COLUMN updated_at DATETIME(6) NOT NULL,

ADD COLUMN updated_at_precise DATETIME(6)

GENERATED ALWAYS AS (updated_at) STORED,

ADD UNIQUE INDEX idx_hotel_precise (hotel_id, updated_at_precise);

Then updated the app to write NOW(6) instead of NOW()—and added a migration to backfill updated_at_precise:

UPDATE price_history 

SET updated_at_precise = updated_at

WHERE updated_at_precise IS NULL

LIMIT 10000;

-- Repeat in batches until done

Latency dropped from 420ms → 8ms. Timeout errors vanished. And yes—we verified it held up under flash-sale traffic: 18K inserts/sec sustained for roughly one in five minutes, zero timeouts.

Why didn’t EXPLAIN warn us? Because EXPLAIN tells you which index MySQL plans to use, not whether it’ll lock 12K rows or 1. Gap locks are invisible to EXPLAIN. They’re enforced at execution time, based on isolation level, index uniqueness, and value distribution—not syntax.

So here’s my first hard rule: If your secondary index has a column with >99% duplicate values (especially time columns without microsecond precision), assume it will cause gap lock contention—and test it with performance_schema.data_locks during simulated load, not just in isolation.

---

Index Design That Doesn’t Lie to You

At a ride-sharing company Eats, our delivery ETA model trained on delivery_events kept degrading—accuracy dropped roughly one in five after adding INDEX (order_id, event_type, created_at).

We’d benchmarked it:

  • Before: SELECT * FROM delivery_events WHERE order_id = 12345 AND event_type IN ('picked_up','delivered') ORDER BY created_at DESC LIMIT 1; ran in 8ms
  • After: same query ran in 24ms, and Handler_read_rnd_next spiked 300% in slow log output

EXPLAIN FORMAT=JSON looked perfect:

"key": "idx_order_event_time",

"rows_examined_per_scan": 12,

"using_index": true

But Handler_read_rnd_next told the truth: MySQL was reading rows out of index order, then sorting them in memory—because the optimizer chose the wrong index.

Here’s what actually happened.

We had two indexes:

-- Index A (what we added)

CREATE INDEX idx_order_event_time ON delivery_events (order_id, event_type, created_at);

-- Index B (what we needed)

CREATE INDEX idx_order_created_event ON delivery_events (order_id, created_at, event_type);

Our query:

SELECT id, event_type, created_at 

FROM delivery_events

WHERE order_id = 12345

AND event_type IN ('picked_up','delivered')

ORDER BY created_at DESC

LIMIT 1;

Index A seemed right: it covers all three columns. But MySQL 8.0.33’s range optimizer saw event_type IN (...) and decided it could use index A’s event_type column for filtering—then scan forward on created_at. But created_at wasn’t the second column in the index—it was third. So to satisfy ORDER BY created_at DESC, MySQL had to fetch all matching rows, sort them in a temp table, then limit.

Index B puts created_at second—so MySQL can seek to order_id = 12345, then scan backward through created_at (since DESC matches index order), stopping at the first match. Zero sorting. Zero temp tables.

We caught it by comparing handler counters per query in the slow log:

# Time: 2024-03-15T14:22:01.123456Z

User@Host: eats[eats] @ [10.20.30.40]

Query_time: 0.024123 Lock_time: 0.000045 Rows_sent: 1 Rows_examined: 42

Handler_read_next: 42 Handler_read_rnd_next: 42

Handler_read_rnd_next = 42 means it read rows in random (non-index) order—i.e., it fetched them, then sorted.

After dropping Index A and creating Index B:

DROP INDEX idx_order_event_time ON delivery_events;

CREATE INDEX idx_order_created_event ON delivery_events (order_id, created_at, event_type);

Same query:

# Handler_read_next: 1  Handler_read_rnd_next: 0

Latency: 8ms. Accuracy recovered.

Insider tip #1: EXPLAIN ANALYZE (MySQL 8.0.18+) shows actual row counts and execution time per operator—but it runs the query. For production, use EXPLAIN FORMAT=TREE (8.0.16+) plus HANDLER status:

-- Run BEFORE your query

FLUSH STATUS;

-- Then run your query

SELECT id, event_type, created_at FROM delivery_events WHERE order_id = 12345 AND event_type IN ('picked_up','delivered') ORDER BY created_at DESC LIMIT 1;

-- Then check

SHOW STATUS LIKE 'Handler%read%';

If Handler_read_rnd_next > 0, you’re sorting outside the index.

Insider tip #2: Don’t trust information_schema.STATISTICS histogram stats for index selection. They’re sampled and outdated. Use this instead to see real selectivity:

SELECT 

COUNT(*) as total,

COUNT(DISTINCT order_id) as distinct_order_id,

COUNT(DISTINCT event_type) as distinct_event_type,

COUNT(DISTINCT created_at) as distinct_created_at,

ROUND(COUNT(DISTINCT order_id) / COUNT() 100, 2) as order_id_selectivity_pct

FROM delivery_events;

In our case: order_id_selectivity_pct = 99.99%, event_type_selectivity_pct = 0.03%, created_at_selectivity_pct = 92.4%. So order_id must be first, created_at second, event_type third.

Exact fix for tomorrow:

  • Run the COUNT(DISTINCT ...) query above for every column in your WHERE/ORDER BY
  • Order index columns by selectivity descending—highest first
  • Put columns used in ORDER BY immediately after equality filters
  • Add COMMENT 'covering for [use case]' so future devs know why it’s ordered this way

Example for delivery_events:

-- ✅ Correct order: high-selectivity equality col first, sort col second, low-selectivity filter last

CREATE INDEX idx_order_created_event

ON delivery_events (order_id, created_at, event_type)

COMMENT 'covering for ETA queries: WHERE order_id = ? AND event_type IN (...) ORDER BY created_at DESC';

-- ❌ Wrong: event_type first causes full index scan

-- CREATE INDEX idx_event_order_created ON delivery_events (event_type, order_id, created_at);

---

Transactions That Don’t Surprise You

At a professional networking company, a background job to dedupe member_connections ran this:

DELETE FROM member_connections 

WHERE (source_id, target_id) IN (

SELECT source_id, target_id FROM dup_candidates

);

It took nearly half minutes. Locked 2.1 million rows. Blocked profile views for 32 minutes.

We thought it was safe—dup_candidates had only 12 rows. But IN (SELECT ...) in MySQL 5.7.36 forces materialization: MySQL creates a temp table, writes all subquery results, then does a nested-loop join against member_connections. And because it’s inside a transaction, it holds S locks on every row it reads from member_connections while building the temp table.

So even though dup_candidates was tiny, MySQL scanned the entire member_connections table (210M rows), locking each row it examined—even rows that wouldn’t match.

We confirmed with INFORMATION_SCHEMA.INNODB_TRX:

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id

FROM INFORMATION_SCHEMA.INNODB_TRX

WHERE trx_started < DATE_SUB(NOW(), INTERVAL 10 SECOND);

Found the long-running transaction. Then checked locks:

SELECT 

r.trx_id waiting_trx_id,

r.trx_mysql_thread_id waiting_thread,

r.trx_query waiting_query,

b.trx_id blocking_trx_id,

b.trx_mysql_thread_id blocking_thread,

b.trx_query blocking_query

FROM INFORMATION_SCHEMA.INNODB_LOCK_WAITS w

INNER JOIN INFORMATION_SCHEMA.INNODB_TRX b ON b.trx_id = w.blocking_trx_id

INNER JOIN INFORMATION_SCHEMA.INNODB_TRX r ON r.trx_id = w.requesting_trx_id;

Blocking query was the DELETE—and waiting_query was SELECT * FROM members WHERE id = ?, blocked on a row lock.

Fix? Rewrite as a JOIN:

DELETE t1 FROM member_connections t1

INNER JOIN dup_candidates t2

ON t1.source_id = t2.source_id

AND t1.target_id = t2.target_id;

Time dropped from nearly half minutes → 1.8 seconds. No blocking.

Why? Because JOIN lets MySQL use the PRIMARY KEY on member_connections directly—no full table scan, no temp table, no row locks on non-matching rows.

But there’s a catch: This only works if dup_candidates has no duplicates. If it has (1,2) twice, the JOIN will delete member_connections row (1,2) twice—but since it’s the same row, it’s harmless. Still, enforce uniqueness:

ALTER TABLE dup_candidates 

ADD UNIQUE KEY uk_source_target (source_id, target_id);

Insider tip: If you must use a subquery (e.g., dynamic conditions), disable materialization per session:

SET SESSION optimizer_switch='semijoin=off,materialization=off';

DELETE FROM member_connections

WHERE (source_id, target_id) IN (

SELECT source_id, target_id FROM dup_candidates

);

This forces nested-loop execution—no temp table. But only do this if the subquery returns < 500 rows. Verify first:

SELECT COUNT(*) FROM dup_candidates;

-- If > 500, don't disable materialization

Common mistake #1: Using IN (SELECT ...) on large tables without checking subquery size.

✅ Fix: Always SELECT COUNT(*) first. If > 500, rewrite as JOIN or use IN (val1, val2, ...) with application-side batching.

Common mistake #2: Assuming DELETE ... LIMIT makes it safe.

It doesn’t. DELETE FROM t WHERE x IN (SELECT ...) LIMIT 100 still materializes the entire subquery before applying LIMIT.

✅ Fix: Use JOIN + LIMIT on the joined table:

DELETE t1 FROM member_connections t1

INNER JOIN dup_candidates t2

ON t1.source_id = t2.source_id

AND t1.target_id = t2.target_id

LIMIT 100;

Common mistake #3: Forgetting FOREIGN KEY cascades lock more than you think.

If member_connections has ON DELETE CASCADE to connection_notes, deleting 100 rows may lock thousands in the child table.

✅ Fix: Disable FK checks temporarily if you control both tables:

SET FOREIGN_KEY_CHECKS = 0;

DELETE t1 FROM member_connections t1 INNER JOIN dup_candidates t2 USING (source_id, target_id);

SET FOREIGN_KEY_CHECKS = 1;

But only if you’re certain no referential integrity violation can occur.

---

Replication That Doesn’t Drift in Silence

At a cloud storage company, our analytics cluster drifted 12 hours behind master—for 3 days. No alerts fired. No lag metric spiked. Seconds_Behind_Master reported 0.

Cause? binlog_row_image=MINIMAL + JSON columns.

Here’s what happened.

We had a table:

CREATE TABLE user_activity (

id BIGINT PRIMARY KEY,

user_id BIGINT NOT NULL,

metadata JSON,

created_at DATETIME DEFAULT CURRENT_TIMESTAMP

) ROW_FORMAT=DYNAMIC;

An UPDATE like this:

UPDATE user_activity 

SET metadata = JSON_SET(metadata, '$.utm_source', 'email')

WHERE user_id = 12345;

With binlog_row_image=MINIMAL (default in MySQL 5.7+), the binlog contained only id and the changed metadata field—not the full JSON value. On the replica, MySQL’s JSON parser tried to reconstruct the full document from partial data. But our metadata contained UTF-8 characters like (U+201C, 3 bytes) and (U+2014, 3 bytes). The replica’s parser silently truncated them to 2-byte sequences, corrupting the JSON structure.

Downstream Spark jobs parsed metadata and extracted utm_source. When the value was corrupted, they got NULL instead of 'email'. Aggregations diverged.

We detected it only after running:

mysqldump --all-databases --single-transaction --skip-extended-insert --no-create-info \

--host=master > master.sql

mysqldump --all-databases --single-transaction --skip-extended-insert --no-create-info \

--host=replica > replica.sql

diff master.sql replica.sql | head -50

Found 12,487 lines where JSON_EXTRACT(metadata, '$.utm_source') returned different values.

Fix? Two parts:

  • Set binlog_row_image = FULL on master and replicas:
SET GLOBAL binlog_row_image = FULL;

-- Then restart replication to pick up new setting

STOP SLAVE; START SLAVE;

  • Audit for dangerous column types weekly:
SELECT 

TABLE_NAME,

COLUMN_NAME,

DATA_TYPE,

CHARACTER_MAXIMUM_LENGTH,

COLLATION_NAME

FROM INFORMATION_SCHEMA.COLUMNS

WHERE TABLE_SCHEMA = 'analytics'

AND DATA_TYPE IN ('json','text','blob','enum','set','geometry')

ORDER BY TABLE_NAME, ORDINAL_POSITION;

If any rows return, binlog_row_image = FULL is mandatory.

Insider tip: pt-table-checksum won’t catch this. It compares row checksums—but if the replica’s JSON parser produces a different but valid JSON string (e.g., {"a":"b"} vs {"a": "b"}), the checksum differs, but pt-table-checksum assumes it’s a network error and retries. Worse, with innodb_flush_log_at_trx_commit=2, checksum writes may not hit disk before the replica reads them—giving false negatives.

✅ Real fix: Run checksums with innodb_flush_log_at_trx_commit=1:

# On master, before checksum

mysql -e "SET GLOBAL innodb_flush_log_at_trx_commit = 1;"

pt-table-checksum --replicate=test.checksums --chunk-size=1000 --recursion-method=hosts

mysql -e "SET GLOBAL innodb_flush_log_at_trx_commit = 2;"

Tradeoff: innodb_flush_log_at_trx_commit=1 reduces write throughput ~15% (measured on our 32-core, NVMe cluster), but eliminates silent drift. For analytics replicas, that’s acceptable. For OLTP masters? Keep it at 2, but never use MINIMAL binlog format on tables with JSON/TEXT/BLOB.

---

Configuration That Survives Traffic Spikes

At DoorDash, our ORDER BY RAND() promo query crashed the DB during flash sales:

SELECT * FROM merchants 

WHERE status = 'active'

ORDER BY RAND()

LIMIT 100;

sort_buffer_size=2M caused 42K temporary files on disk, filling /tmp in 92 seconds.

We thought “just increase sort_buffer_size”—but MySQL 8.0.33 allocates it per connection. Our pool had 200 connections. So 200 × 2MB = 400MB allocated just for sorting—before any other buffers.

Real fix: Replace ORDER BY RAND() entirely.

MySQL 8.0.roughly one in five+ supports TABLESAMPLE:

SELECT * FROM merchants 

TABLESAMPLE SYSTEM (0.01)

WHERE status = 'active'

LIMIT 100;

SYSTEM (0.01) means “sample ~0.01% of pages from the table”. It’s fast, uses no sort buffer, and gives uniform randomness if the table is well-clustered (which merchants was, PK-ordered).

But TABLESAMPLE can return fewer than 100 rows—if the sample hits empty pages or filtered-out rows. So we added fallback logic in the app:

  • Run TABLESAMPLE query
  • If COUNT(*) < 100, run fallback:
SELECT * FROM merchants 

WHERE id >= FLOOR(RAND() * (SELECT MAX(id) FROM merchants))

AND status = 'active'

ORDER BY id

LIMIT 100;

This seeks to a random id, then scans forward—no sort, no temp files.

Critical config: TABLESAMPLE requires these:

SET GLOBAL innodb_stats_persistent = ON;

SET GLOBAL innodb_stats_auto_recalc = ON;

Without innodb_stats_persistent=ON, TABLESAMPLE falls back to INFORMATION_SCHEMA.TABLES.DATA_LENGTH, which is inaccurate for large tables.

Common mistake: Using ORDER BY RAND() on tables > 100K rows.

✅ Fix: Use TABLESAMPLE (8.0.roughly one in five+) or the FLOOR(RAND() * MAX(id)) fallback.

Insider tip: sort_buffer_size should never exceed innodb_buffer_pool_size / (max_connections × 2).

On our DoorDash DB:

  • innodb_buffer_pool_size = 16G
  • max_connections = 200
  • So sort_buffer_size ≤ 16GB / (200 × 2) = 40MB

We set it to 32M—and capped max_connections at 180 to leave headroom.

What to do tomorrow:

  • Find all ORDER BY RAND() queries in your slow log:
SELECT argument FROM mysql.general_log 

WHERE argument LIKE '%ORDER BY RAND()%'

AND command_type = 'Query'

LIMIT 10;

  • Replace each with TABLESAMPLE + fallback
  • Run SELECT @@sort_buffer_size, @@innodb_buffer_pool_size, @@max_connections and verify the ratio
  • If sort_buffer_size > (innodb_buffer_pool_size / (max_connections × 2)), lower it

---

What You Should Do Tomorrow (Exactly)

Don’t read this again. Do these four things now, in order:

1. Run the Gap Lock Detector

SSH into your production DB host and run:

mysql -u root -e "

SELECT

OBJECT_SCHEMA,

OBJECT_NAME,

INDEX_NAME,

LOCK_TYPE,

LOCK_MODE,

LOCK_DATA

FROM performance_schema.data_locks

WHERE LOCK_MODE LIKE '%GAP%'

AND OBJECT_SCHEMA NOT IN ('mysql','information_schema','performance_schema')

LIMIT 20;

"

If it returns any rows, you have silent contention. Find the index with duplicate-heavy columns and replace it using the selectivity method above.

2. Audit Your ORDER BY RAND() Queries

# Get slow log path

mysql -u root -e "SHOW VARIABLES LIKE 'slow_query_log_file';"

Then search

grep -i "ORDER BY RAND()" /var/lib/mysql/slow.log | head -5

Replace each with:

-- Primary

SELECT * FROM your_table TABLESAMPLE SYSTEM (0.01) WHERE [your_filters] LIMIT N;

-- Fallback (run only if primary returns < N rows)

SELECT * FROM your_table

WHERE id >= FLOOR(RAND() * (SELECT MAX(id) FROM your_table))

AND [your_filters]

ORDER BY id

LIMIT N;

3. Check binlog_row_image for JSON/TEXT Tables

mysql -u root -e "

SELECT

TABLE_NAME,

COLUMN_NAME,

DATA_TYPE

FROM INFORMATION_SCHEMA.COLUMNS

WHERE TABLE_SCHEMA = 'your_db_name'

AND DATA_TYPE IN ('json','text','blob','enum')

"

If any rows return, run:

SET GLOBAL binlog_row_image = FULL;

Then restart replication.

4. Validate Index Column Order Against Real Selectivity

Pick one high-traffic table. Run:

SELECT 

COUNT(*) as total,

COUNT(DISTINCT col1) / COUNT() 100 as col1_pct,

COUNT(DISTINCT col2) / COUNT() 100 as col2_pct,

COUNT(DISTINCT col3) / COUNT() 100 as col3_pct

FROM your_table;

Order your next index columns by % descending. Example: if col1_pct = 99.9, col2_pct = 42.3, col3_pct = 0.01, your index must be (col1, col2, col3)—not (col1, col3, col2).

Do these four things before lunch tomorrow. Not “this week”. Not “when you get a chance”. Before lunch.

Because the next time your pager goes off at 2:17 a.m., you won’t be guessing. You’ll be checking performance_schema.data_locks. You’ll be grepping slow logs for RAND(). You’ll know exactly which index to drop and which to create.

That’s how you stop fighting ghosts—and start shipping code that works.

---

Appendix: Full Working Code Examples

Example 1: Gap Lock Detector (Production-Safe)

-- Save as /tmp/detect_gap_locks.sql

-- Run ONLY during active contention (e.g., when SHOW PROCESSLIST shows waits)

SELECT

CONCAT('Table: ', OBJECT_SCHEMA, '.', OBJECT_NAME) as target,

INDEX_NAME,

LOCK_MODE,

LOCK_DATA,

COUNT(*) as lock_count

FROM performance_schema.data_locks

WHERE LOCK_MODE LIKE '%GAP%'

AND OBJECT_SCHEMA NOT IN ('mysql','information_schema','performance_schema')

GROUP BY OBJECT_SCHEMA, OBJECT_NAME, INDEX_NAME, LOCK_MODE, LOCK_DATA

ORDER BY lock_count DESC

LIMIT 10;

How to use:

  • When you see Lock wait timeout exceeded, run SHOW PROCESSLIST; to confirm blocking
  • Run the script above
  • Output shows which index/table is causing gap locks
  • Drop that index and replace with unique compound (as shown in a travel platform section)

Example 2: Index Selectivity Auditor

-- Save as /tmp/analyze_index_selectivity.sql

-- Replace 'your_db' and 'your_table' before running

SELECT

'your_table' as table_name,

COUNT(*) as total_rows,

ROUND(COUNT(DISTINCT order_id) / COUNT() 100, 2) as order_id_selectivity,

ROUND(COUNT(DISTINCT event_type) / COUNT() 100, 2) as event_type_selectivity,

ROUND(COUNT(DISTINCT created_at) / COUNT() 100, 2) as created_at_selectivity,

ROUND(COUNT(DISTINCT status) / COUNT() 100, 2) as status_selectivity

FROM your_db.your_table;

How to use:

  • Run it for every table with >100K rows
  • Sort columns by selectivity % descending
  • Create new index with that order
  • Drop old index after verifying new one is used (via Handler_read_next)

Example 3: Safe DELETE with JOIN

-- Save as /tmp/safe_delete_join.sql

-- Replace 'target_table', 'join_table', and columns

DELETE t1 FROM target_table t1

INNER JOIN join_table t2

ON t1.source_id = t2.source_id

AND t1.target_id = t2.target_id

WHERE t2.processed = 0

LIMIT 1000;

-- Then mark as processed

UPDATE join_table SET processed = 1 WHERE processed = 0 LIMIT 1000;

Why it’s safe:

  • LIMIT 1000 applies to the JOIN, not the full table
  • No temp table, no full scan
  • processed = 0 ensures idempotency

Example 4: TABLESAMPLE with Fallback (Application Logic)

# Python pseudo-code—paste into your service

import mysql.connector

def get_random_merchants(limit=100):

conn = mysql.connector.connect(db_config)

cursor = conn.cursor(dictionary=True)

# Try TABLESAMPLE first

cursor.execute("""

SELECT * FROM merchants

TABLESAMPLE SYSTEM (0.01)

WHERE status = 'active'

LIMIT %s

""", (limit,))

results = cursor.fetchall()

if len(results) >= limit:

return results

# Fallback: random offset

cursor.execute("SELECT MAX(id) FROM merchants WHERE status = 'active'")

max_id = cursor.fetchone()['MAX(id)']

if not max_id:

return []

random_offset = random.randint(1, max_id)

cursor.execute("""

SELECT * FROM merchants

WHERE id >= %s AND status = 'active'

ORDER BY id

LIMIT %s

""", (random_offset, limit))

return cursor.fetchall()

Guarantees:

  • Never uses sort_buffer_size
  • Never creates temp files
  • Always returns at least limit rows (or empty list)
  • Works on MySQL 8.0.roughly one in five+

---

You’ve now seen exactly what broke in production—and exactly how we fixed it. Not theory. Not best practices. Real commands, real versions, real metrics.

The next time you add an index, you’ll check selectivity first.

The next time you write a DELETE, you’ll reach for JOIN, not IN (SELECT ...).

The next time replication feels “off”, you’ll check binlog_row_image, not Seconds_Behind_Master.

That’s how senior developers ship—not by knowing more, but by having done the dumb thing once, learned the hard way, and built guardrails so nobody else has to.

Go fix something.