0The incident
14:00 on Black Friday, traffic 8× normal. Checkout latency climbs from 120ms to over 30 seconds; requests start timing out. The database CPU is at 100%, but no single query looks slow. Revenue is dropping by the minute. The team's instinct — "add more app servers" — does nothing, because the bottleneck is the stateful database, and it's drowning in a flood of tiny queries.
14:00 traffic 8× · checkout p95 120ms → 4s 14:06 checkout p95 > 30s · timeouts begin · DB CPU 100% 14:09 add app servers → NO effect (the DB is the bottleneck, not the app) 14:14 APM shows checkout makes 51 queries/request (should be ~3) 14:20 found: N+1 on line_items → product · ship eager-load fix 14:26 checkout p95 back to 140ms · DB CPU 35% · recovered root cause: 1 hidden N+1 × 8× traffic = a self-inflicted database DDoS
💡 What an N+1 query is (and why it hides)
An N+1 is when loading a list runs 1 query for the list, then N more — one per item — instead of loading everything up front. Render an order with 50 line items and naïvely read each item's product, and you fire 1 + 50 = 51 queries for one page.
It hides because it's invisible at small scale: in dev with 2 line items it's 3 fast queries — imperceptible. In production on Black Friday with big carts and 8× traffic, that same code fires tens of thousands of tiny queries per second and buries the database. The bug didn't change; the data size and traffic did.
1Finding the N+1
The tell of an N+1 is the log: the same query repeated with different ids, back to back. APM (or Rails' log, or the bullet gem) makes it obvious once you look.
Order Load SELECT * FROM orders WHERE id = 1 LineItem Load SELECT * FROM line_items WHERE order_id = 1 Product Load SELECT * FROM products WHERE id = 11 -- (+1) Product Load SELECT * FROM products WHERE id = 12 -- (+1) Product Load SELECT * FROM products WHERE id = 13 -- (+1) ... (47 more identical-shape queries) ... Product Load SELECT * FROM products WHERE id = 60 -- (+1) ==> 51 queries for ONE checkout render
// ❌ N+1: .product runs a SELECT for EACH line item @order.line_items.each do |li| render_line(li.product.name, li.product.price_cents) // 1 query per item end // ✅ eager-load the association once — 2 queries total, regardless of size @order = Order.includes(line_items: :product).find(params[:id]) @order.line_items.each do |li| render_line(li.product.name, li.product.price_cents) // no extra queries end
2Reading the query plan & the shapes involved
Each individual query was fast — that's why nothing "looked slow." The problem was the count. Still, ShopFast checked with EXPLAIN that the per-product lookup used the primary-key index (it did), confirming the issue was volume, not a missing index.
EXPLAIN ANALYZE SELECT * FROM products WHERE id = 42; Index Scan using products_pkey on products Index Cond: (id = 42) rows=1 actual time=0.03..0.03 (fast!) -- verdict: the query is optimal. the BUG is running it 50× per request. -- 8× traffic × 50 extra queries = tens of thousands of round-trips/sec.
3The fix — and the capacity plan
The immediate fix was one line (includes). But the incident was also a capacity-planning failure: the load test never used realistic cart sizes, so the N+1 never showed. The full fix is code plus process.
AImmediate — eager-load the association▶
Goal: collapse 1+N into a constant number of queries.
- Add
.includes(orpreload/eager_load) for the association read in the loop —Order.includes(line_items: :product). Now it's 2 queries no matter how many line items. - Ship it — this alone took ShopFast's checkout from 30s+ back to ~140ms and DB CPU from 100% to 35%.
- Sweep for siblings — N+1s travel in packs. Check other list views (order history, cart, admin) for the same pattern.
BStructural — counts, caching & indexes▶
Goal: remove other per-row database work on the hot path.
- counter_cache for counts — store
line_items_counton the order instead of aCOUNT(*)per render. - Cache the rendered cart/line fragments (Redis) so repeat renders skip the DB entirely.
- Verify indexes on the foreign keys used in the loads (
line_items.order_id,line_items.product_id) so the eager-load'sIN (...)is fast.
CProcess — so it can't hide again▶
Goal: catch N+1s in CI and load-test with realistic data.
- Fail the build on N+1s — the
bulletgem (or query-count assertions in tests) flags "this action ran N queries" before it merges. - Load-test with production-shaped data — big carts, 8× peak traffic. The N+1 only appears with realistic sizes, so tiny fixtures hide it.
- Alert on query-per-request in APM — a checkout that suddenly makes 50 queries should page someone, not wait for Black Friday.
4Scorecard
Eight checks that turn "we got lucky" into "it can't recur."
includes/preload), not per-row queries.COUNT(*) per render.EXPLAIN.