Spring Boot / Advanced: observability and resilient releases / Lesson 14
Performance, query shape, and capacity
Investigate a Spring Boot request through application, persistence, executor, pool, and JVM costs using comparable evidence instead of universal tuning rules.
On this page
Lesson goals
- Separate latency, throughput, and resource saturation.
- Review JPA fetch plans and bounded result shapes.
- Measure realistic work without changing correctness guarantees.
Define the regression before changing a pool
Record the endpoint/operation, workload mix, result size, parameter class, latency distribution, throughput, and error rate. Application time can include filter work, queueing, connection acquisition, database execution, serialization, downstream calls, and result consumption. A fast service method measured alone does not explain the complete request. Separate startup/JIT warmup from comparable steady-state work.
Increasing server threads or database pool size can amplify downstream saturation rather than reduce it. Trace which resource is scarce and which operations hold it. A read/write transaction held across slow serialization or remote I/O can create contention even when its SQL plan is indexed. Performance evidence must retain the same authorization, consistency, durability, and output contract.
Choose the data graph required by one use case
Lazy traversal can create N-plus-one queries when a list response accesses one relationship per row. A projection, entity graph, or reviewed fetch query can address a particular shape, but none is a universal “eager everything” prescription. The fragment selects a to-one customer relationship for a contextual order mapping and still requires bounded stable pagination and DTO mapping. No database query was run.
Fetching a collection together with pagination can have significant provider/dialect consequences, including in-memory work or unsupported combinations. Inspect actual generated queries, result multiplicity, and count queries on the intended database. A correct-looking JSON page does not prove only one efficient query occurred. Avoid leaking a lazy entity graph through serialization merely to postpone an explicit read-model decision.
package training.catalog;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
interface OrderReadRepository extends JpaRepository<OrderHeader,Long> {
@EntityGraph(attributePaths="customer")
Page<OrderHeader> findByTenantId(String tenantId, Pageable pageable);
}
OrderHeader must actually map customer. Bounded paging, trusted scope, DTO conversion, and generated SQL need separate validation; no speed or query-count result is claimed.
Preserve multiplicity while avoiding redundant work
The local Java model requests item1 twice and item2 once. It loads distinct keys into a synthetic result map, then reconstructs the original request sequence, preserving the repeated item. It reports two modeled lookup calls and the three intended outputs. This validates request planning only, not a JPA bulk query or a network speedup.
A real batched repository operation still needs parameter and memory bounds, missing-key behavior, stable result reconstruction, and authorization. Replacing a list by a set may accidentally change a legitimate duplicate/order contract. An unbounded IN query can exceed resource budgets. Compare actual statement counts and work on the intended stack rather than equating fewer Java calls with a guaranteed proportional latency gain.
public class Main {
public static void main(String[] args) throws Exception {
var requested=java.util.List.of(1,1,2);
var fetched=new java.util.HashMap<Integer,String>();
int loads=0;
for(int id:new java.util.LinkedHashSet<>(requested)) { loads++; fetched.put(id,"item-"+id); }
var result=requested.stream().map(fetched::get).toList();
System.out.println("lookup_calls "+loads);
System.out.println(result);
}
}
lookup_calls 2
[item-1, item-1, item-2]
Two distinct synthetic loads reconstruct three outputs, retaining request order and multiplicity. It does not execute a repository.
Correlate pools, allocation, and downstream waiting
Inspect connection-pool active/waiting counts, executor queueing, database statement latency, request sizes, allocation rate, GC pauses, and downstream timeouts over matched windows. A full pool may be a symptom of slow work or leaked ownership, not a reason to make it arbitrarily large. Keep connection and transaction lifetimes explicit and verify cleanup after failure/cancellation.
Profiling and recording tools have overhead and may capture sensitive payloads. Use approved isolated or operational procedures, never attach to configured services for a lesson. Repeated tiny stopwatch runs can be dominated by JVM optimization or cache state. No universal throughput or percentile result is fabricated from the synthetic planning model. A correct query plan also does not explain time spent waiting for a connection.
Verify one hypothesis and keep a rollback criterion
Change a justified fetch shape, bound, index, or capacity setting and verify identical business results under representative authorized testing. Include common/rare parameters, failures, and write-heavy periods. Record the runtime, dependency versions, data distribution, and metric definitions. An improvement to a rare read that harms every write may be a poor workload tradeoff.
Do not disable security, transactions, durability, or validation merely to improve a benchmark without acknowledging the changed service. Keep configuration and migrations reversible through reviewed recovery. A useful performance note says which work was reduced, which assumptions remain, what evidence supports the effect, and what would trigger rollback. That is more durable knowledge than one impressive timing screenshot.
Common errors and fixes
A fast repository test did not explain slow HTTP
- Why it happens
- Queueing, acquisition, serialization, or downstream work was omitted.
- How to fix it
- Measure the whole request and correlate component evidence.
- Prevent it next time
- Keep metric scope and workload comparable.
Eager collection fetching broke paging assumptions
- Why it happens
- Fetch multiplicity/provider behavior was ignored.
- How to fix it
- Choose a reviewed projection/fetch design and inspect actual SQL/results.
- Prevent it next time
- Test pages with several children per parent.
A larger pool amplified failures
- Why it happens
- Downstream capacity stayed fixed while admitted concurrency increased.
- How to fix it
- Repair the measured bottleneck and bound admission.
- Prevent it next time
- Test saturation and cleanup.
Check your understanding
1. Why can replacing requested ids[1,1,2] by a set change a response contract?
Show worked solution
The input may intentionally require repeated positions and order. Fetching distinct values can reduce redundant work, but reconstruct the expected result sequence and define missing-key handling. Do not silently turn a multiset/list question into unique-entity output.
2. A pool is full and CPU is low. What should be investigated before increasing it?
Show worked solution
Connection ownership, long transactions, database/downstream waiting, queueing, and failure cleanup. Low CPU does not prove spare downstream capacity, and more concurrent requests can worsen contention. Correlate the same incident window and test one bounded hypothesis.
Key takeaways
- Optimize complete work under an unchanged correctness contract.
- Fetch shape and response multiplicity need deliberate tests.
- Pool size is one capacity choice, not a universal latency remedy.