Written By ROMAN AGABEKOV | MAY 15, 2025
Last update | OCTOBER 22, 2026
What you observe | What to investigate first | What would support the change |
Many rows are read to return a small result | Filters and an index that matches them | Less work in the plan, with the same returned rows |
Filtering is reasonable, but sorting is expensive | The relationship between WHERE, ORDER BY, the index, and LIMIT | Fewer rows to sort, or an index-ordered access path |
A function on a filtered column prevents a useful lookup | An equivalent range condition or an appropriate expression index | The same boundaries and meaning, with a better access path |
A join produces a large intermediate result | Join relationships, indexes, and whether all matches are needed | Correct duplicates and NULL handling, with less intermediate work |
Individual statements are fast, but the endpoint is slow | Repeated calls, N+1 queries, and response size | Lower endpoint latency and fewer unnecessary calls |
The first run is slow, or the query waits under load | Cold data, locks, I/O, and resource contention | Evidence of the wait or resource bottleneck, rather than a guess about SQL |
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL,
created_at DATETIME NOT NULL,
total_cents INT UNSIGNED NOT NULL,
PRIMARY KEY (id),
KEY idx_orders_tenant (tenant_id)
) ENGINE = InnoDB; SELECT id, created_at, total_cents
FROM orders
WHERE tenant_id = 42
AND status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 20; EXPLAIN FORMAT=TREE
SELECT id, created_at, total_cents
FROM orders
WHERE tenant_id = 42
AND status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 20; -> Limit: 20 row(s) (cost=2385 rows=20) (actual time=87.7..87.7 rows=20 loops=1)
-> Sort: orders.created_at DESC, orders.id DESC, limit input to 20 row(s) per chunk (cost=2385 rows=149500) (actual time=87.7..87.7 rows=20 loops=1)
-> Filter: (orders.`status` = 'paid') (cost=2385 rows=149500) (actual time=0.911..81.6 rows=40000 loops=1)
-> Index lookup on orders using idx_orders_tenant (tenant_id=42) (cost=2385 rows=149500) (actual time=0.908..76 rows=100000 loops=1) CREATE INDEX idx_orders_tenant_status_created_id
ON orders (tenant_id, status, created_at DESC, id DESC); -> Limit: 20 row(s) (cost=9241 rows=20) (actual time=0.945..0.947 rows=20 loops=1)
-> Index lookup on orders using idx_orders_tenant_status_created_id (tenant_id=42, status='paid') (cost=9241 rows=83508) (actual time=0.945..0.946 rows=20 loops=1) Check | Before the change | After the change |
Returned result | 20 rows | The same 20 rows, values, and order |
Selected index | idx_orders_tenant | idx_orders_tenant_status_created_id |
Separate sort | Present | Absent |
Actual rows produced by the index lookup, one loop | 100,000 | 20 |
Ordinary SELECT median | 79.85 ms | 1.01 ms |
Ordinary SELECT p95 | 102.97 ms | 1.11 ms |
Query | Median before | Median after | p95 before | p95 after |
Paid orders, small tenant with 100 orders | 0.136 ms | 0.088 ms | 0.170 ms | 0.101 ms |
All statuses, tenant with 100,000 orders | 80.16 ms | 80.28 ms | 95.38 ms | 145.90 ms |
Write operation, 1,000 rows per batch | Median without candidate index | Median with candidate index |
Insert batch | 4.22 ms | 5.44 ms |
Update payment status | 5.64 ms | 7.82 ms |
SELECT id, created_at, total_cents
FROM orders
WHERE tenant_id = 42
ORDER BY created_at DESC, id DESC
LIMIT 20; SELECT id, created_at
FROM orders
WHERE tenant_id = 42
AND DATE(created_at) = '2026-09-14'; SELECT id, created_at
FROM orders
WHERE tenant_id = 42
AND created_at >= '2026-09-14 00:00:00'
AND created_at < '2026-09-15 00:00:00'; SELECT id, created_at, total_cents
FROM orders
WHERE tenant_id = 42
AND status = 'paid'
AND (
created_at < '2026-09-14 18:25:00'
OR (
created_at = '2026-09-14 18:25:00'
AND id < 93821
)
)
ORDER BY created_at DESC, id DESC
LIMIT 20; SELECT c.id, c.email
FROM customers AS c
WHERE c.tenant_id = 42
AND EXISTS (
SELECT 1
FROM orders AS o
WHERE o.tenant_id = c.tenant_id
AND o.customer_id = c.id
AND o.status = 'paid'
); SELECT id, created_at, total_cents
FROM orders
WHERE tenant_id = 42
AND status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 20; SELECT id, email
FROM customers
WHERE tenant_id = ?
AND id IN (?, ?, ?); Query group | Executions in the hour | Average execution time | Cumulative execution time |
Order-list lookup | 120,000 | 25 ms | 3,000 seconds |
Administrative report | 60 | 2 seconds | 120 seconds |
Pattern | Next investigation |
Long elapsed time with lock waits | The blocking transaction and why the wait exists |
Slow first run, faster repeat runs | Data and index reads, cache state, and the same query parameters |
Many queries slow at the same time | CPU, storage latency, memory pressure, and concurrency |
Database timing improves but endpoint timing does not | Application work, connection waits, network time, and repeated calls |