The Quest Begins (The "Why")
I still remember the night our API started to feel like a tired hobbit carrying the One Ring up Mount Doom. Every request that hit our rate‑limiting endpoint would linger for seconds, and the monitoring dashboard lit up like a Christmas tree gone rogue. The culprit? A simple table that stored a counter for each user and a timestamp bucket:
CREATE TABLE rate_limit (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
window_start TIMESTAMP NOT NULL,
hits INT NOT NULL DEFAULT 0
);
Our limiter did a SELECT SUM(hits) FROM rate_limit WHERE user_id = ? AND window_start >= NOW() - INTERVAL 1 MINUTE; then, if under the threshold, an UPDATE … SET hits = hits + 1. Sounds harmless, right? The problem was that as the table grew to millions of rows, the database had to scan the whole table for every request because there was no index that matched the WHERE clause. The query planner shrugged and did a full table scan, turning our slick service into a sluggish beast.
I spent three hours staring at EXPLAIN ANALYZE output, feeling like Neo dodging bullets in the Matrix—except the bullets were slow queries and the dodge was nowhere to be found. That’s when the realization hit: we needed the database to see the exact rows we cared about without leafing through every page.
The Revelation (The Insight)
The magic trick is a composite index on the two columns we filter by: (user_id, window_start). Think of it as a phone book sorted first by last name, then by first name. When you ask for “All Smiths whose first name starts with J”, you can jump straight to the Smith section and then scan only the J’s. No need to flip through the entire book.
Here’s what the index looks like in ASCII (a highly simplified B‑tree):
[ (user_id, window_start) ]
/ | \
(1, 10:00) (1, 10:01) (2, 10:00) (2, 10:01) …
/ \ / \ / \ / \
leaf1 leaf2 leaf3 leaf4 leaf5 leaf6 leaf7 leaf8
- The tree is ordered by
user_idfirst, thenwindow_start. - A lookup for a specific user and a time range becomes a range scan on a contiguous slice of the leaf nodes—O(log N) to find the start, then linear only over the matching rows.
- Deleting old entries (
DELETE FROM rate_limit WHERE window_start < NOW() - INTERVAL 5 MINUTES;) also becomes a rapid range delete because the same index pins the stale rows together.
That’s the core insight: match the index to the exact predicate pattern. A single‑column index on user_id alone would still force a scan of all that user’s rows to check the time window. A single‑column index on window_start would scatter the user’s rows all over the index, making the user filter expensive. Only the composite gives us both predicates in one shot.
Wielding the Power (Code & Examples)
Before – The Struggle
-- No helpful index
EXPLAIN ANALYZE
SELECT SUM(hits)
FROM rate_limit
WHERE user_id = 42
AND window_start >= NOW() - INTERVAL 1 MINUTE;
Output (simplified):
-> Filter: (user_id = 42) (cost=... rows=... )
-> Table scan on rate_limit (cost=... rows=10M...)
Every request touched millions of rows. The update that followed was just as pricey because it had to locate the same row via the primary key after the scan.
After – The Victory
-- Add the composite index
CREATE INDEX idx_user_window ON rate_limit (user_id, window_start);
Now the same query:
EXPLAIN ANALYZE
SELECT SUM(hits)
FROM rate_limit
WHERE user_id = 42
AND window_start >= NOW() - INTERVAL 1 MINUTE;
Yields:
-> Index range scan using idx_user_window (cost=... rows=... )
-> Filter: (window_start >= ...) (cost=... rows=...)
The planner jumps straight to the leaf block for user_id = 42, then walks forward only through the timestamps that fall inside the one‑minute window—often just a handful of rows. The UPDATE that increments hits becomes a quick point lookup on the same index, followed by a row‑level lock.
Common Traps to Avoid
| Trap | What happens | Fix |
|---|---|---|
Separate single‑column indexes (CREATE INDEX i_user ON rate_limit(user_id); CREATE INDEX i_win ON rate_limit(window_start);) |
The optimizer can only use one index per table in a simple query, so it picks whichever looks cheaper and still ends up scanning many rows. | Drop the singles and create the composite (user_id, window_start). |
Index on just window_start |
Good for purging old data, but the user_id filter forces a scan of all rows in that time range. |
Add user_id as the leading column. |
Forgetting to index the column used in DELETE |
Old‑data cleanup becomes a full table scan, causing spikes in CPU and latency. | Ensure the same composite index (or at least an index on window_start) exists for the purge job. |
Full‑cycle pseudo‑code (Node‑style, but the idea is language‑agnostic)
async function allowRequest(userId) {
const now = new Date();
const windowStart = new Date(now - 60_000); // 1 minute ago
// 1️⃣ Read current hits in the window
const [{ sum }] = await db.query(
`SELECT COALESCE(SUM(hits),0) AS sum
FROM rate_limit
WHERE user_id = ?
AND window_start >= ?`,
[userId, windowStart]
);
if (sum >= LIMIT) return false; // blocked
// 2️⃣ Increment (or insert) the counter for this window
await db.query(
`INSERT INTO rate_limit (user_id, window_start, hits)
VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE hits = hits + 1`,
[userId, windowStart, 1]
);
return true;
}
// Background job – purge stale buckets every minute
setInterval(async () => {
const cutoff = new Date(Date.now() - 5 * 60_000); // keep 5 min
await db.query(
`DELETE FROM rate_limit WHERE window_start < ?`,
[cutoff]
);
}, 60_000);
Notice how the same index (idx_user_window) serves both the read‑path and the purge‑path. No extra indexes, no extra complexity.
Why This New Power Matters
With the composite index in place, our rate‑limiter went from multiple seconds per request to sub‑millisecond latency, even under a synthetic load of 100 k RPM. The CPU usage on the DB node dropped by ~70%, and we could safely shrink the instance size, saving real dollars each month.
More importantly, the pattern is reusable: any time you have a filter that combines an equality predicate (like an ID, tenant, or category) with a range predicate (time, price, score), a composite index that leads with the equality column is almost always the win. It turns what would be a costly scan into a tight, predictable range walk.
Think of it as giving Neo the ability to see the underlying code of the Matrix—he no longer needs to dodge every bullet; he knows exactly where they’ll appear and can step aside with a single, graceful motion.
Your Turn – The Next Quest
Grab one of your own tables that currently suffers from a “WHERE x = ? AND y BETWEEN ? AND ?” query. Run EXPLAIN, see if it’s doing a full table scan, then add a composite index that leads with the equality column. Benchmark before and after—watch the latency drop and the throughput rise.
If you’ve got a war story about an index that saved the day (or a missed index that caused a midnight outage), drop it in the comments. Let’s keep sharing the tricks that turn our databases from sluggish beasts into swift, Neo‑like agents.
Happy indexing! 🚀
Top comments (0)