An endpoint takes 80 milliseconds on your laptop. In production, for your largest customer, it takes nine seconds and occasionally times out. Nothing changed in the code. What changed is the amount of data — and that pattern almost always points to the same culprit: the N+1 query problem.
At Riverstream Consultancy Services I tracked down and fixed 20+ slow SQL queries and N+1 patterns using Hibernate profiling and MySQL EXPLAIN plans. Average API response time improved by 40%, peak-hour database CPU dropped by around 35%, and client production tickets fell by half. This is the playbook I used.
N+1 patterns fixed
API response
DB CPU
What N+1 looks like in Spring Data JPA
Here is a perfectly normal-looking entity and service. Relationships are lazy — as they should be — and the service maps entities to a summary DTO.
For 100 orders this runs one query to load the orders, then one per order for the customer and one per order for the items: 201 queries. For 10,000 orders it's 20,001. The code didn't get slower. The data got bigger — which is exactly why this bug shows up for your biggest customer first.
How to spot it before production does
Turn on SQL logging and Hibernate statistics in development. The tell-tale sign is the same SELECT repeated with a different ID each time.
Better still, make it impossible to reintroduce. An integration test can assert on the number of statements a service method executes:
Fix 1: JOIN FETCH when you need the entities
Tell Hibernate to load the associations in the same query. Since Hibernate 6, duplicate root entities from a collection join are removed automatically, so you no longer need DISTINCT for that purpose.
Fix 2: @EntityGraph for derived queries
If you prefer Spring Data's derived query methods, an entity graph does the same job without writing JPQL:
Fix 3: batch fetching as a safety net
Batch fetching doesn't remove the extra queries, but it collapses them: instead of one query per order, Hibernate loads associations for many orders at once using an IN (…) clause. It's a good global default, and it's the right tool when you page over a collection.
Fix 4: a DTO projection when you only need a few columns
The summary endpoint doesn't need full entities at all. A projection query returns exactly the columns you need, in a single round-trip, with no entities to manage:
Pageable makes Hibernate load everything and paginate in memory, logging only a warning. Set spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true so it fails loudly instead, and use batch fetching for paged lists. Two List collections in one fetch: Hibernate throws MultipleBagFetchException; fetch one collection and batch-load the other, or model one as a Set. Open Session in View: Spring Boot enables spring.jpa.open-in-view by default, which lets lazy loading happen silently during JSON serialization. Set it to false so N+1 surfaces as an error in development instead of a slowdown in production.
Don't forget the database itself
Fixing the query count is half the job. Run EXPLAIN on the remaining queries and make sure foreign keys used in joins — like order_items.order_id — are indexed. A single query doing a full table scan can be slower than the hundred it replaced.