“The product page gets slower every time the catalogue team adds products. No single query is slow.”
case name: Death by a thousand queries
- Service
- shoplite-api · GET /products
- Impact
- /products p99 grew from 40 ms to 380 ms as the catalogue grew to 200 products
- Detected by
- Weekly latency review
- Time to resolve
- 1 h (slow-burn)
Skills you'll use on this case
count() and structural queriesspan metrics from TempoN+1 queries00 It starts
Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.
01 The investigation
- 10:00
REPORT
Weekly review: /products p99 has grown every week for a month
/products p99 (ms), weeklyp99 - 10:08
DEAD END
Postgres slow-query log (Case 2.4): nothing over 1 ms
Every query is fast. The problem isn't any single query, it's HOW MANY there are. Neither a slow-query log nor the database metrics can show that.
- 10:14
QUERY
Find /products traces with lots of database spans
trace:rootNameis an intrinsic: the name of the trace's root span.| count() > 50keeps traces where more than 50 spans match the selector. That's a question about trace SHAPE, which only traces can answer.TraceQL· Tempo{ trace:rootName = "GET /products" && span.db.system = "postgresql" } | count() > 50resultTrace ID Root span Matched spans Duration 6d1e0c2b9a7f4e3c8b5a2d1f0e9c8b7a GET /products 201 352ms ... - 10:16
FINDING
One list query, then one stock query PER product, one after another
The waterfall is unmistakable: a staircase of tiny identical spans. Each takes ~1.5 ms including the network round trip, and 200 of them in sequence is 300 ms. It grows linearly with the catalogue.
GET /products (200 products)352 ms totalshoplite GET /productsshoplite352 mspg.query:SELECT productsshoplite3 mspg.query:SELECT stock (1)shoplite2 mspg.query:SELECT stock (2)shoplite2 mspg.query:SELECT stock (3)shoplite1 ms… 196 more identical spans …shoplite336 mspg.query:SELECT stock (200)shoplite2 ms - 11:02
RESOLVED
Replaced with one JOIN: 2 queries total, p99 back to 35 ms
Root cause
The N+1 query pattern: fetch a list (1 query), then fetch related data for each item (N queries), sequentially. Each query was fast, so query-level monitoring saw nothing; the cost was round trips multiplied by catalogue size. The trace's shape, hundreds of identical sibling spans, made it obvious.
02 The concepts behind it
Trace shape is information
Beyond 'which span is longest', the SHAPE of a trace tells a story: a staircase of identical siblings means N+1 or missing batching; a wide fan of parallel calls means fan-out (and a latency floor set by the slowest one); gaps between spans mean time in your own code (add manual spans); repeated sibling calls to the same dependency mean retries (Case 3.2).
TraceQL beyond attribute matching
Spanset pipelines: { ... } | count() > 50, | avg(duration) > 100ms. Structural operators: { A } >> { B } (B is a descendant of A), { A } > { B } (direct child), { A } ~ { B } (siblings). Intrinsics: name, duration, status, kind, trace:rootName, trace:duration. These let you search for PATTERNS across millions of traces, not just for attribute values.
Span metrics: metrics generated from traces
Tempo's metrics-generator (and the OTel Collector's spanmetrics connector) turns every span into RED metrics: call counts and duration histograms per service, span name, and kind, written to Prometheus. You get metrics for things nobody instrumented explicitly, such as 'database calls per second from shoplite', including the ratio that exposes N+1 continuously.
03 The fix
01One query instead of N+1
A JOIN fetches everything in one round trip. For ORMs, the equivalent is eager loading (
include,JOIN FETCH,select_related/prefetch_related) or a DataLoader-style batch. Switch the scenario off; the lab's normal path has no N+1.obs-lab/shoplite/server.jsadd to filejs const { rows } = await db.query( "select p.id, p.name, p.price, s.qty as stock from products p left join stock s on s.product_id = p.id order by p.id", );terminal$ curl -s -X POST localhost:8080/admin/chaos -H 'content-type: application/json' -d '{"scenario":"n-plus-one","enabled":false}'── expected output ──{"active":[]}
04 Make sure it never surprises you again
01Turn on Tempo's span metrics
Tempo remote-writes generated metrics into Prometheus, which must accept remote writes: add
--web.enable-remote-write-receiverto Prometheus's command. Theservice-graphsprocessor also produces the service dependency map Grafana draws.obs-lab/tempo/tempo.ymladd to fileyaml metrics_generator: registry: external_labels: { source: tempo } storage: path: /var/tempo/generator/wal remote_write: - url: http://prometheus:9090/api/v1/write send_exemplars: true overrides: defaults: metrics_generator: processors: [service-graphs, span-metrics]02Database calls per request — graph it, alert on a jump
Normally ~1.3 for ShopLite (1 per product listing, 2 per checkout). N+1 shows up immediately as this ratio climbing with catalogue size.
PromQL· Prometheussum(rate(traces_spanmetrics_calls_total{service="shoplite", span_kind="SPAN_KIND_CLIENT", span_name=~"pg.query.*"}[5m])) / sum(rate(http_request_duration_seconds_count{job="shoplite"}[5m]))result{} 1.31
05 Your turn: write the query
Run each one against your lab before revealing the answer. Share your own version in the comments; there's usually more than one correct query.
Write a TraceQL query for checkout traces where the payments call is a DESCENDANT of the checkout span and took over 500 ms.
Using span metrics, write the p95 duration of every span name in the payments service.
06 Interview questions from this case
What is the N+1 query problem and how do you detect it?
What are span metrics and why are they useful?