devto 2026-09-09 원문 보기 ↗
As applications scale from hundreds of thousands to millions of records, poorly optimized database queries quickly become the primary bottleneck of modern web architectures. While hardware has gotten faster, an unindexed query forcing a full table scan across millions of rows will easily exhaust server CPU, lock connection pools, and degrade the user experience.
In this guide, we will explore practical, battle-tested strategies to diagnose and optimize slow SQL queries in relational database systems like MySQL and SQL Server, concluding with a real-world case study where a composite index slashed query latency by over 1,000%.
SELECT * in Production
One of the most common habits in early-stage development is querying all columns:
-- Avoid in production
SELECT * FROM orders WHERE customer_id = 4502;
TEXT, VARCHAR(MAX), blobs) transfers unnecessary bytes across database connection buffers.Only specify the exact columns your application logic or API needs:
SELECT order_id, total_amount, order_status, created_at
FROM orders
WHERE customer_id = 4502;
EXPLAIN
Before adding indexes blindly, always inspect how the database query planner executes your statement. In MySQL or PostgreSQL, prefix your query with EXPLAIN:
EXPLAIN SELECT order_id, total_amount
FROM orders
WHERE customer_id = 4502 AND order_status = 'COMPLETED';
type: Look for ALL (full table scan). You want to see ref, eq_ref, or range.rows: Represents the estimated number of rows examined. If this number is in the tens of thousands for a simple lookup, an index is missing.key: Indicates which index the query planner selected. If NULL, no index was used.Extra: Beware of Using filesort or Using temporary. This means the database is sorting records in memory or spilling intermediate batches to disk.When querying against multiple conditions in a WHERE clause, a composite index is often the best solution. However, column order inside the index matters due to the Leftmost Prefix Rule.
Suppose you run:
SELECT order_id, total_amount
FROM orders
WHERE customer_id = 4502 AND order_status = 'COMPLETED';
Creating an index as:
CREATE INDEX idx_orders_customer_status
ON orders (customer_id, order_status);
customer_id alone will use this index.customer_id AND order_status will use this index.order_status alone CANNOT use this index effectively.Rule of thumb: Put columns with higher selectivity (more unique values, like foreign keys) before columns with low cardinality (like status enums).
A frequent mistake that invalidates index usage is wrapping indexed columns in functions inside the WHERE clause:
-- Inefficient: Database must calculate DATE() for every row in the table
SELECT order_id FROM orders
WHERE DATE(created_at) = '2026-09-01';
Because created_at is wrapped inside DATE(), the query engine cannot use the B-Tree index on created_at.
Make the query SARGable (Search Argument Able) by comparing against an explicit range:
-- Efficient: Enables a fast index range scan
SELECT order_id FROM orders
WHERE created_at >= '2026-09-01 00:00:00'
AND created_at < '2026-09-02 00:00:00';
To see how index architecture behaves under real production pressure, consider a real-world high-throughput chat platform (such as a multi-tenant WhatsApp CRM).
In customer messaging feeds, a common query retrieves the latest 100 messages for an active conversation thread:
SELECT id, conversation_id, sender_phone, content, timestamp
FROM messages
WHERE conversation_id = 14205 AND is_hidden = 0
ORDER BY timestamp DESC, id DESC
LIMIT 100;
Initially, the messages table only possessed a primary key on id. Under production load:
type: ALL): With hundreds of thousands of messages across all tenants, the database had to inspect every single row in storage to locate records where conversation_id = 14205.Using filesort): Because the query orders by timestamp DESC, id DESC, MySQL allocated space in sort_buffer_size. As conversation histories grew, the sort buffer overflowed, spilling intermediate sorting runs to temporary disk files.We introduced an ordered composite index specifically targeting the filter equality, sort order, and tie-breaker:
CREATE INDEX messages_conversation_timestamp_id_idx
ON messages (conversation_id, timestamp, id);
| Execution Metric | Before (No Composite Index) | After (conversation_id, timestamp, id) |
Improvement |
|---|---|---|---|
Access Type (type) |
ALL (Full Table Scan) |
ref (Const point seek) |
$O(N) \rightarrow O(\log N)$ |
| Key Used | NULL |
messages_conversation_timestamp_id_idx |
Targeted Seek |
| Sorting Strategy |
Using filesort (Disk Temp Tables) |
None (In-tree index order) | 0 bytes sort memory |
| Index Traversal | Scanned entire table | Backward index scan + Early exit | Stops after 100 rows |
| Query Latency | ~1,800 ms | ~1.8 ms | ~1,000x faster (100,000%) |
conversation_id = 14205 in the B-Tree root in $O(\log N)$ time.timestamp and id, the engine executes a backward index scan. It does not sort a single byte in memory.LIMIT 100: Once the engine traverses the leaf nodes to collect 100 matching rows, it immediately terminates execution, ignoring the remaining millions of records in the table.JOIN over Correlated Subqueries
Correlated subqueries execute once for every row evaluated by the outer query ($O(N \times M)$ complexity).
-- Slow correlated subquery: executes subquery for every customer row
SELECT c.customer_name,
(SELECT SUM(o.total_amount) FROM orders o WHERE o.customer_id = c.customer_id) as total_spent
FROM customers c;
Converting this to a LEFT JOIN with GROUP BY allows the optimizer to execute an efficient hash join or index lookup:
-- Faster JOIN approach
SELECT c.customer_name, COALESCE(SUM(o.total_amount), 0) AS total_spent
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name;
conversation_id = ?), followed by sort columns (timestamp DESC), followed by deterministic tie-breakers (id DESC).EXPLAIN for Using filesort: If a high-frequency query shows Using filesort, your index is failing to satisfy the ORDER BY clause.SELECT statements but incurs an I/O write penalty on INSERT, UPDATE, and DELETE. Target your most frequent, latency-sensitive query paths first.By designing composite indexes that satisfy both filtering predicates and sorting criteria, you can routinely eliminate disk-bound filesorts and turn multi-second bottlenecks into sub-millisecond lookups.