Mission 04 · 60-90 min
One request, many queries
N+1, Hibernate lazy loading, batch fetching
Read lesson 04 first: One request, many queries (9 min)
You are reading the mission. Its ./lab commands run against a Java service, Postgres and k6 on your own machine: start dacospace locally to get Run buttons, live Grafana panels, load test results and a notebook. Setup on GitHub.
Scenario
You added the index in mission 03. The orders query went from tens of milliseconds to under one. And yet "my recent orders" still falls over at 50 requests per second. The query you fixed was not the one eating the time. Today you count what the endpoint really sends to Postgres.
1. Predict first (on paper)
Read OrderService.recentOrders() and OrderView.from(). The endpoint returns the last 20 orders of a
dealer, each with its items and each item's product name.
- How many SQL statements does one request send? Write a number before you measure.
- Which line of Java triggers the extra ones?
2. Count the queries
Reset the statistics, send exactly one request, then look:
./lab sql "SELECT pg_stat_statements_reset()"./lab load smoke./lab topsql
The smoke test sends more than one request, so divide. For a clean count, reset again and open
http://localhost:4180/api/dealers/42/orders?limit=20 once in your browser, then run ./lab topsql.
Write down three numbers: calls of the orders query, calls of the order_items query, calls of the
products query.
3. Find the slow one
The order_items query is called many times. How long does each call take (mean_ms)? Explain it:
./lab explain "SELECT * FROM order_items WHERE order_id = 1234"
Seq Scan again? Remember lesson 03: Postgres does not index foreign keys for you.
4. The 4 steps (on paper)
- What is broken: one sentence that names both problems you found.
- Numbers: queries per request × time per query = time the request holds a connection (
W). With a pool of 10, what isλ_max? - Path: controller →
recentOrders()→ orders query →OrderView.from()→ ... - What can break: which one of the two problems would still hurt if you fixed only the other?
5. Fix it
Two changes, one commit each:
- A Flyway migration (
V3__...sql) that indexes the column the items query filters on. - Make Hibernate load the items and products of all 20 orders with a few queries instead of 60.
You have three tools, pick one and be able to say why: a
join fetchquery, an@EntityGraph, or Hibernate's batch fetching (hibernate.default_batch_fetch_size). Careful:join fetchon a collection together with aLIMITmakes Hibernate paginate in memory. Look for that warning in the log if you choose it.
Restart the app, then measure again:
./lab topsql./lab load dealer-history 50./lab compare dealer-history
6. Evidence
| Before | After | |
|---|---|---|
| SQL statements per request | ||
| mean ms of the items query | ||
| dealer-history p95 at 50/s | ||
| dropped iterations |
7. In your own words
One sentence: why did the mission 03 index not make this endpoint fast?
Check question
A page shows 50 dealers, and for each dealer the name of its city's sales rep, loaded lazily. Without
batch fetching, how many queries does the page send? With default_batch_fetch_size: 25, how many?
Java corner
In Order.java, items is @OneToMany(fetch = FetchType.LAZY). Explain to yourself:
- What object does Hibernate put in the
itemsfield before you touch it? (Search "PersistentBag".) - Why does
OrderView.from()run inside@Transactional(readOnly = true)? What exception would you get without it, givenspring.jpa.open-in-view: false?