Cost per Request & the End-to-End Capstone
One deliberately inefficient Spring Boot service, one plain-Java load generator, ten runs. Starting from 86 requests per second and a 923 ms p99, we apply the series fixes one at a time - index, N+1, HTTP connection reuse, pool, virtual threads, cache, heap, right-sizing - and record real before/after numbers for each. Then we turn throughput into dollars per million requests using clearly labelled example prices.
A Service That Is Wrong Six Ways
Everything on the earlier pages was taught on a slice of one service. This page puts the slices back together. OrderService is a Spring Boot 3.3.4 / Java 21 application with an in-memory relational database seeded with 200,000 orders and 600,000 order lines, an InventoryService reached over HTTP, and the endpoint GET /customers/{id}/orders that returns a customer's 20 most recent orders with their items.
As shipped it is wrong in six independent ways: no index on orders.customer_id, an N+1 (one query for the orders, one per order for items, one per item for the product name: 81 queries per request), a fresh TCP connection to the inventory service on every call, a connection pool of 4, 16 Tomcat threads, and a 256 MiB heap (the JVM default inside a 1 GiB container, MaxRAMPercentage 25). All of it is running on 8 vCPUs.
| Lab step | What happened |
|---|---|
| 1. Problem | The baseline sustains 86 requests/s with 32 concurrent clients, an average of 369 ms and a p99 of 923 ms, while burning 4.3 CPU cores - about 50 ms of CPU per request. |
| 2. Hypothesis | The time is not where the code looks busy. Most of the cost is the database doing something avoidable, and the rest is a stack of small wastes (connections, pool, GC). |
| 3. Measurement | JFR CPU samples under load, per-request query counter, GC log, closed-loop and open-loop load runs - each step re-measured three times. |
| 4. Diagnosis | One by one in the chapters below. Spoiler: 97% of baseline CPU samples are inside the database engine scanning the orders table. |
| 5. Optimization | One change per run, cumulative, in the order the evidence demanded. |
| 6. Verification | A cumulative before/after table and a cost per million requests (chapter 11). |
- Treating this as a benchmark of Spring or of the embedded database. It is a teaching rig: the shape of the gains is general, the numbers are not.
- Absolute numbers come from a laptop-class 12-core machine with the load generator on the same box. They will differ on your hardware; trust the method and the ratios, not the digits.
From Latency to Dollars
Performance work ends in money. The unit that lets you compare a code change with an instance-type change is cost per request: the hourly price of the capacity you run divided by the requests that capacity serves in an hour.
| Signal | What it means |
|---|---|
| $/hour | Sum of vCPU-hours and GiB-hours (plus load balancer, storage, data transfer in a real bill). Example list prices used here: $0.040 per vCPU-hour and $0.0045 per GiB-hour. These are assumed example prices - check current pricing for your region and service. |
| Sustained RPS | You never run at the measured maximum. We assume a 60% target utilisation, which leaves headroom for spikes, deploys and one failed AZ (see the hockey-stick curve on the capacity page). |
| $ per 1M requests | = hourly price / (0.6 x measured RPS x 3600) x 1,000,000. Baseline 8 vCPU + 1 GiB = $0.325 per hour. |
| Lever | Changes | Typical effect | Risk |
|---|---|---|---|
| Fix code / DB / cache | requests per hour per instance | Often 10x or more (this capstone) | Correctness (staleness, invalidation) |
| Right-size | price per hour | Proportional to the idle share you remove | Latency tail if headroom vanishes |
| Graviton (arm64) | price per hour and sometimes RPS | Often a price/performance gain; must be load-tested | Native libraries, multi-arch images |
| Savings Plans / reserved | price per hour | Discount for a commitment | Locked in; commit only to the proven baseline |
| Spot capacity | price per hour | Large discount for interruptible capacity | Interruptions: only for tolerant workloads |
| Idle headroom | price per useful hour | Cost of N+1 capacity and low utilisation | Too little headroom = outages |
Order matters: committing to a Savings Plan before fixing an N+1 locks in the waste. Optimise, then right-size, then commit.
- Comparing instance prices instead of cost per request: a cheaper instance that serves half the traffic is more expensive.
- Using peak throughput as the denominator. Use the sustained throughput you will actually plan for.
How We Measure: One Variable at a Time
Every number on this page came from the rig below. It runs on one machine; the application is pinned to cores 0-7 with taskset, the load generator to cores 8-11, so they do not steal CPU from each other.
| Phase | How | Why |
|---|---|---|
| Warm-up | 8 s closed loop, discarded | JIT compilation and caches must be warm before measuring |
| Closed loop | 32 virtual-thread clients, 15 s, each sends the next request when the previous returns | Maximum sustainable throughput at a fixed concurrency; gives RPS, avg, p95, p99, CPU cores, queries per request |
| Open loop | 40 requests/s for 20 s, latency timed from the INTENDED send time | Latency at a realistic fixed load; immune to coordinated omission |
| Repeats | every step run 3 times with a fresh JVM; the table shows the median | Run-to-run noise is large (see below) |
Table legend (used in every chapter): RPS = requests per second with 32 closed-loop connections; p95/p99 = milliseconds under that saturation; open p99 = milliseconds at a fixed 40 requests/s; CPU = cores used by the JVM; q/req = database statements per request; $/1M = example-price cost per million requests at 60% utilisation.
Customer ids follow a skewed distribution (1 + 5000 x u³, so about half of the traffic hits roughly 3% of customers) so that the cache step has realistic hot keys. Hit ratios reported later depend on that choice.
- Run-to-run noise is real. The baseline alone measured 64, 86, 88 requests/s across its three repeats (a separate JFR-instrumented baseline run reached 133). Single runs would have made some steps look better or worse than they are; the median of 3 narrows but does not remove this.
- The load generator shares the machine and uses 32 connections. Above about 4,000 requests/s the closed loop is limited by concurrency (32 / latency), not by the application, so differences between the last three steps are within noise; the CPU and cost columns still tell the story.
- An in-memory embedded database sits inside the JVM heap and competes with the application for CPU and memory. A real Aurora or RDS adds a network hop, but also its own CPU: the SHAPE of every fix still holds, the magnitudes do not.
Find the Hot Spot, Add the Index
| Lab step | What happened |
|---|---|
| 1. Problem | Baseline: 86 RPS, p99 923 ms at saturation; even at a gentle 40 requests/s the open-loop p99 is 133 ms. |
| 2. Hypothesis | The server is CPU-bound in the database (4.3 of 8 cores busy, yet throughput is tiny), probably scanning the orders table because customer_id has no index. |
| 3. Measurement | 10 s JFR recording at full load: -XX:StartFlightRecording=delay=22s,duration=10s,settings=profile,filename=p0.jfr, then jfr print --events jdk.ExecutionSample aggregated by top frame. |
| 4. Diagnosis | Of 2,431 CPU samples, 97.2% are inside org.h2; the top frames are the table-scan iterator and the filter comparison (below). Only 0.3% is JSON. |
| 5. Optimization | ONE change: create index ix_orders_cust on orders(customer_id, created_at), so the query for one customer's newest orders becomes an index range read already in sort order. |
| 6. Verification | Throughput x8.0, p99 down from 923 ms to 125 ms; the DB share of CPU falls from 97% to 37%. |
| Run | RPS | p95 | p99 | open p99 | CPU | q/req | $/1M |
|---|---|---|---|---|---|---|---|
| Baseline | 86 | 663.9 | 922.8 | 133.1 | 4.3 | 81.0 | $1.747 |
| + index | 691 | 97.3 | 125.4 | 36.2 | 4.0 | 81.0 | $0.217 |
- Fixing the visible smell first. The N+1 was the loudest anti-pattern; with the scan still in place, removing it alone gives little (the same scan runs once instead of 21 times, but it still dominates).
- Column order in a composite index matters:
(customer_id, created_at)servesWHERE customer_id = ? ORDER BY created_at DESC LIMIT 20;(created_at, customer_id)would not. - Production check: run the real query plan on production-sized data (see the database page). An index that helps on 200,000 rows may be ignored or unnecessary on 2,000.
Collapse the N+1 into One Query
| Lab step | What happened |
|---|---|
| 1. Problem | After the index the service sustains 691 RPS but each request still issues 81 statements (1 for the orders, 20 for the items, 60 product lookups). |
| 2. Hypothesis | Per-statement overhead (parsing, locking, connection hand-off, result mapping) now dominates; one join query would cut CPU and pool hold time. |
| 3. Measurement | JFR again (p1.jfr): the database engine is still 37% of samples but now spread over many small operations; the repository counter shows 81 queries per request. |
| 4. Diagnosis | Classic N+1: the item and product lookups run inside a loop over the 20 orders. Pool connections are held for all 81 round trips. |
| 5. Optimization | ONE change: replace the loops with a single statement joining a 20-row subquery on orders to order_items and products (app.fetch=join). |
| 6. Verification | Statements per request 81 to 1; throughput x1.8 over the indexed run; open-loop p99 at 40/s 36.2 ms to 14.6 ms. |
| Run | RPS | p95 | p99 | open p99 | CPU | q/req | $/1M |
|---|---|---|---|---|---|---|---|
| + index | 691 | 97.3 | 125.4 | 36.2 | 4.0 | 81.0 | $0.217 |
| + N+1 fix | 1224 | 51.6 | 73.7 | 14.6 | 4.5 | 1.0 | $0.123 |
- With an embedded database the per-query round trip is cheap, so this understates the real win: against a networked database (about 0.5-1 ms per round trip within an AZ, more across AZs) 81 queries cost roughly 40-80 ms of pure waiting. That figure is a rule-of-thumb estimate, not measured here.
- JPA hides the same bug: a lazy association touched in a loop produces the same N+1. Count statements per request in tests (a Hibernate statistics or datasource-proxy assertion) so it cannot return.
- A join can multiply rows (orders x items) - fine here, harmful with two collection joins; use batch fetching or two queries then.
HTTP Connection Reuse (and the Stall It Exposed)
| Lab step | What happened |
|---|---|
| 1. Problem | Every request calls the InventoryService with Connection: close, so each call opens and closes a TCP connection. JFR shows 17-19% of CPU in socket/HTTP-client frames. |
| 2. Hypothesis | Reusing connections (HTTP keep-alive) removes handshakes and should raise throughput. |
| 3. Measurement | Change ONE switch (app.keepalive=true) and rerun all three repeats. |
| 4. Diagnosis (surprise) | Throughput fell from 1224 to 263 RPS and the open-loop median rose from 6.8 ms to 47.8 ms, with CPU use collapsing to 0.9 cores: the threads are waiting, not working. A median near 5 ms of work + 40 ms is the classic signature of Nagle's algorithm meeting delayed ACK: the stub writes headers and body in two small writes, the second is held until the first is acknowledged, and the client delays that ACK by about 40 ms. Closing the connection after each response had been flushing the tail and hiding the stall. |
| 5. Optimization | ONE more change on the (stub) server: -Dsun.net.httpserver.nodelay=true (TCP_NODELAY). |
| 6. Verification | Throughput 1762 RPS (x6.7 over the keep-alive-only run, x1.4 over the join-only run); open-loop median back to 6.6 ms. |
| Run | RPS | p95 | p99 | open p99 | CPU | q/req | $/1M |
|---|---|---|---|---|---|---|---|
| + N+1 fix | 1224 | 51.6 | 73.7 | 14.6 | 4.5 | 1.0 | $0.123 |
| + keep-alive only | 263 | 202.8 | 300.8 | 68.7 | 0.9 | 1.0 | $0.571 |
| + TCP_NODELAY | 1762 | 35.9 | 60.5 | 10.3 | 3.7 | 1.0 | $0.085 |
This is the most instructive result of the capstone: a textbook optimisation made things worse, and only measuring told us. The evidence is consistent with the Nagle/delayed-ACK interaction, but I did not capture packets here, so treat the mechanism as the likely explanation and the numbers as the fact. The stall comes from the way my stub writes its response; real services and proxies often set TCP_NODELAY already, which is why you rarely see it - until you meet a server that does not.
- Never roll out a keep-alive or pooling change without a before/after run: the gain is not guaranteed and a latency regression can hide behind "it is a standard best practice".
- Diagnose waiting vs working: CPU fell to 0.9 cores while latency rose, which says "blocked on something", not "code got slower".
- Keep-alive against a real ALB has its own trap: the app keep-alive timeout must exceed the ALB idle timeout (60 s default) or you get sporadic 502s (see the AWS compute and Spring tuning pages).
- Set all three client timeouts (connect, read, overall) on the pooled client; keep-alive makes a stuck connection survive longer.
Pool Size and Virtual Threads
| Lab step | What happened |
|---|---|
| 1. Problem | With the earlier waste gone the service does 1762 RPS using 3.7 cores: not CPU-saturated. 32 clients compete for 4 database connections and 16 request threads. |
| 2. Hypothesis | Concurrency caps in series are now the limit: first the pool, then the thread count. |
| 3. Measurement | Hikari metrics (hikaricp.connections.pending, acquire time) are the proper evidence in production; here we vary the setting and compare. Step 4: pool 4 to 8 via SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE=8. Step 5: spring.threads.virtual.enabled=true (Boot 3.2+, Java 21). |
| 4. Diagnosis | More connections let more queries run in parallel (the app has cores 0-7); virtual threads then remove the 16-thread cap in front of the pool, while the pool remains the bounded gate. |
| 5. Optimization | Two separate runs, one variable each (pool, then virtual threads). |
| 6. Verification | Pool 8: x1.12 throughput (1762 to 1973 RPS); virtual threads: a further x1.25 (2469 RPS), 36.0 ms p99. |
| Run | RPS | p95 | p99 | open p99 | CPU | q/req | $/1M |
|---|---|---|---|---|---|---|---|
| + TCP_NODELAY | 1762 | 35.9 | 60.5 | 10.3 | 3.7 | 1.0 | $0.085 |
| + pool 4 to 8 | 1973 | 30.8 | 42.6 | 9.0 | 3.9 | 1.0 | $0.076 |
| + virtual threads | 2469 | 25.1 | 36.0 | 12.1 | 4.8 | 1.0 | $0.061 |
Read the noise before the ratios: the three repeats of the virtual-thread step ranged from 2128 to 3248 RPS, so treat its gain as probably positive and modest, not as a precise number. Virtual threads help when platform threads are the cap and the work is blocking IO; they do not make CPU-bound work faster and a bounded database pool still needs its own limit (a Semaphore or pool size).
- Raising the pool until the problem disappears. The right size comes from the database (cores, IO) and the connection budget: instances x pool must stay below
max_connectionsminus a reserve. - On JDK 21, blocking inside
synchronizedpins a virtual thread to its carrier (fixed in JDK 24, JEP 491); JDBC drivers and libraries that synchronise internally can erase the gain. - Diagnose pool starvation with
hikaricp.connections.pendingand the exceptionSQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.
Cache What Is Read Often and Changes Rarely
| Lab step | What happened |
|---|---|
| 1. Problem | Even at one query per request the service spends 4.8 cores for 2469 RPS, and most requests re-read the same hot customers. |
| 2. Hypothesis | Skewed access plus data that tolerates 30 s of staleness means a cache removes most database work. |
| 3. Measurement | Cache hit counters exposed on /stats: hits and misses per run. |
| 4. Diagnosis | About 3% of customers receive about half of the traffic in this workload, so a small cache absorbs most reads. |
| 5. Optimization | ONE change: a Caffeine cache in front of the whole page load (maximumSize 10_000, expireAfterWrite 30 s). It also skips the inventory call on a hit. |
| 6. Verification | Hit ratio 97%, queries per request 0.0, throughput x2.1 (5293 RPS), open-loop p50 1.2 ms. |
| Run | RPS | p95 | p99 | open p99 | CPU | q/req | $/1M |
|---|---|---|---|---|---|---|---|
| + virtual threads | 2469 | 25.1 | 36.0 | 12.1 | 4.8 | 1.0 | $0.061 |
| + Caffeine cache | 5293 | 15.3 | 26.5 | 9.8 | 3.7 | 0.0 | $0.028 |
- The hit ratio is a property of the workload, not of the cache: our skewed keys give about 97%; uniformly random keys over 5,000 customers would produce a far lower ratio with this size and TTL. Measure on production-shaped traffic.
- Staleness is now a product decision: a new order is invisible for up to 30 s. Make it explicit and add targeted invalidation if users must see their own writes.
- Cache stampede on expiry: with 30 s TTLs on hot keys, many requests can reload simultaneously. See the caching page for single-flight and jittered TTLs (Caffeine's loading cache coalesces concurrent loads per key).
Give the Heap Room
| Lab step | What happened |
|---|---|
| 1. Problem | Throughput is high but the JVM spends 3.7 cores for 5293 RPS, and the GC log is busy: heap capped at 256 MiB, which is simply what a JVM gets by default in a 1 GiB container (MaxRAMPercentage = 25). |
| 2. Hypothesis | The live set (the in-memory database, about 200 MiB) nearly fills the heap, so young collections happen constantly and do more work (the GC log shows about 206M live after a concurrent mark, against a 256M maximum). |
| 3. Measurement | -Xlog:gc:file=... for every run; count pauses and sum their duration. |
| 4. Diagnosis | Median of 3 runs: 401 pauses totalling 1562 ms at 256 MiB. |
| 5. Optimization | ONE change: -Xms768m -Xmx768m, equivalent to -XX:MaxRAMPercentage=75 on a 1 GiB container (leaving 256 MiB for metaspace, stacks, code cache and the rest - see the AWS compute page for the measured non-heap breakdown). |
| 6. Verification | Pauses 401 to 32, total pause 1562 to 589 ms; CPU used 3.7 to 2.2 cores for about the same throughput (5293 vs 5214 RPS). |
| Run | RPS | p95 | p99 | open p99 | CPU | q/req | $/1M |
|---|---|---|---|---|---|---|---|
| + cache | 5293 | 15.3 | 26.5 | 9.8 | 3.7 | 0.0 | $0.028 |
| + heap 768 MiB | 5214 | 14.4 | 23.9 | 8.3 | 2.2 | 0.0 | $0.029 |
Notice what did not change: throughput (5293 vs 5214 RPS, inside the noise) and the tail. The win is CPU: roughly 1.5 fewer cores for the same work. That is exactly the kind of gain that only shows up when you read CPU alongside RPS - and it is what lets the next step halve the instance.
- The gain depends on the live set. The pause count and total are for the whole process lifetime including seeding; the seeding allocation is identical in both runs.
- Do not set -Xmx to the container limit: heap is only part of the footprint (metaspace, thread stacks, direct buffers, code cache). Keep a margin or the kernel OOM-kills the container.
- Do not tune collectors before reading the log: the pause count here was the symptom; the root cause was a heap too small for its live set.
Right-Size and Price It
| Lab step | What happened |
|---|---|
| 1. Problem | The service now serves 5214 RPS while using 2.2 of its 8 vCPUs: more than half the capacity we pay for is idle. |
| 2. Hypothesis | Half the cores deliver the same throughput; the bill should halve. |
| 3. Measurement | Pin the JVM to 4 cores (taskset -c 0-3; the JVM then sees 4 processors and sizes its pools accordingly) and rerun the identical test. |
| 4. Diagnosis | At 4 cores the application used 1.9 cores, still below the allocation, with p99 under saturation 18.0 ms. |
| 5. Optimization | ONE change: 8 to 4 vCPU. Hourly price $0.325 to $0.165 (example prices). |
| 6. Verification | Throughput 6066 RPS (no loss within noise: the closed loop here is concurrency-limited, see chapter 3), cost per 1M requests $0.029 to $0.013. |
| Run | RPS | p95 | p99 | open p99 | CPU | q/req | $/1M |
|---|---|---|---|---|---|---|---|
| + heap | 5214 | 14.4 | 23.9 | 8.3 | 2.2 | 0.0 | $0.029 |
| + right-size 8 to 4 vCPU | 6066 | 11.5 | 18.0 | 10.2 | 1.9 | 0.0 | $0.013 |
| Lever | How to evaluate it | Status in this page |
|---|---|---|
| Graviton (arm64) | Rebuild a multi-arch image, repeat THIS load test on arm64 and divide price by RPS | ILLUSTRATIVE: not run here (see the AWS compute page) |
| Savings Plans / reserved | Commit to the steady baseline you proved, not to peak | Concept only: pricing varies by term and region |
| Spot | Only for fault-tolerant, interruptible workers (queue consumers, batch) | Concept only |
| Idle headroom | Utilisation target vs failure scenarios (lose an AZ) | We assumed 60% target utilisation |
- Right-sizing from average CPU. Size from the peak you must survive plus failure headroom, and re-check tail latency after the change: a smaller instance is a smaller buffer for GC and JIT bursts.
- The cost figures use assumed example prices and exclude load balancer, data transfer, storage and the database. Real totals will be larger; the ratios between steps are what carry over.
The Cumulative Before/After
The complete table, every row the median of three fresh-JVM repeats. Each row changes exactly one thing relative to the row above, except that the row for keep-alive (regression) and TCP_NODELAY (repair) are two halves of one story.
| Run | RPS | p95 | p99 | open p99 | CPU | q/req | $/1M |
|---|---|---|---|---|---|---|---|
| Baseline as shipped | 86 | 663.9 | 922.8 | 133.1 | 4.3 | 81.0 | $1.747 |
| + index orders(customer_id, created_at) | 691 | 97.3 | 125.4 | 36.2 | 4.0 | 81.0 | $0.217 |
| + N+1 fix 81 queries to 1 join | 1224 | 51.6 | 73.7 | 14.6 | 4.5 | 1.0 | $0.123 |
| + keep-alive reuse the inventory connection | 263 | 202.8 | 300.8 | 68.7 | 0.9 | 1.0 | $0.571 |
| + TCP_NODELAY fix the 40 ms stall it exposed | 1762 | 35.9 | 60.5 | 10.3 | 3.7 | 1.0 | $0.085 |
| + pool 4 to 8 Hikari maximum-pool-size | 1973 | 30.8 | 42.6 | 9.0 | 3.9 | 1.0 | $0.076 |
| + virtual threads spring.threads.virtual.enabled | 2469 | 25.1 | 36.0 | 12.1 | 4.8 | 1.0 | $0.061 |
| + Caffeine cache 30 s TTL, 10k entries | 5293 | 15.3 | 26.5 | 9.8 | 3.7 | 0.0 | $0.028 |
| + heap 256 to 768 MiB MaxRAMPercentage 25 to 75 | 5214 | 14.4 | 23.9 | 8.3 | 2.2 | 0.0 | $0.029 |
| + right-size 8 to 4 vCPU taskset 0-3 | 6066 | 11.5 | 18.0 | 10.2 | 1.9 | 0.0 | $0.013 |
| Outcome | Baseline | Final | Change |
|---|---|---|---|
| Better latency (p99 at 32 connections) | 923 ms | 18 ms | x51 lower |
| Better latency (open loop, 40 req/s, p99) | 133.1 ms | 10.2 ms | x13 lower |
| Higher throughput per instance | 86 RPS | 6066 RPS (on HALF the vCPUs) | x71 |
| Lower cost per request (example prices) | $1.747 / 1M | $0.013 / 1M | x139 cheaper |
| Database work | 81 queries / req | 0 (cache hit path) | about 99% of requests never reach the DB |
| Step | Throughput factor vs previous | What it fixed |
|---|---|---|
| + index | x8.03 | orders(customer_id, created_at) |
| + N+1 fix | x1.77 | 81 queries to 1 join |
| + keep-alive | x0.21 | reuse the inventory connection |
| + TCP_NODELAY | x6.70 | fix the 40 ms stall it exposed |
| + pool 4 to 8 | x1.12 | Hikari maximum-pool-size |
| + virtual threads | x1.25 | spring.threads.virtual.enabled |
| + Caffeine cache | x2.14 | 30 s TTL, 10k entries |
| + heap 256 to 768 MiB | x0.99 | MaxRAMPercentage 25 to 75 |
| + right-size 8 to 4 vCPU | x1.16 | taskset 0-3 |
- The ordering is not sacred: another order gives different intermediate numbers, because every fix changes where the bottleneck is. We ordered by evidence: the profile pointed at the scan first.
- A 40-70x headline from one rig is a teaching device. The transferable result is the method (evidence, one change, re-measure) and the rank order of fixes: remove work (index, N+1, cache) beats tune runtime (pool, GC) beats buy less hardware.
- Caching hides the earlier fixes in the throughput numbers: once 97% of requests hit the cache, the database tuning only matters on the miss path - which sets your tail when entries expire.
The Complete Capstone Source
The complete project that produced the numbers. Everything is plain Java 21 and Spring Boot 3.3.4; the load generator is a single file you run with java LoadGen.java. Project layout: pom.xml, src/main/java/perf/OrderApp.java, src/main/resources/application.properties, LoadGen.java, run.sh, all.sh.
Run it: mvn -q -DskipTests package && ./all.sh. Expect the first ten steps to take about 50 minutes at three repeats; set REPS=1 to shorten. Your numbers will differ; compare the ratios.
- Do not run the load generator and the application on the same cores: pin them apart (
taskset) or the generator steals the CPU you are measuring. - Seed data size matters. With a tenth of the rows the missing index barely hurts; always test at production-shaped data volume.
Capstone Questions
Profile first. A 10 s JFR recording showed 97% of CPU samples in the database scan, so the index came before the louder N+1.
Trust the measurement, find the waiting (CPU fell, latency rose), and form a mechanism hypothesis (Nagle + delayed ACK), then test it with one flag. Do not roll back blindly or ship on faith.
Hourly cost of the capacity divided by sustained requests per hour (measured max x target utilisation), then x 1,000,000. State the prices as assumptions.
It removes the database and the downstream call from most requests; throughput is then limited by the cheap path. The hit ratio depends on key skew and TTL, so measure it with realistic traffic.
The closed loop was concurrency-limited at that point; the extra heap removed most young-GC work, which showed up as ~1.5 fewer cores in use.
They lifted the 16-thread cap in front of a bounded pool: a modest, noisy gain. They do not help CPU-bound work and the pool still needs a limit; on JDK 21 beware pinning.
Single runs and the absolute numbers: noise between identical repeats was large, the DB is in-process and the load generator shares the box. Use ratios and repeats.
When it removes the headroom needed for GC/JIT bursts, deploys or the loss of an AZ, or when the savings are smaller than the risk. Verify the tail after shrinking.
Cheat Sheet, Recap & Where Next
| Question | Answer from the capstone |
|---|---|
| What gave the biggest gain? | The missing index (x8.0), then removing the N+1 (x1.8), then the cache (x2.1) |
| What backfired? | Keep-alive without TCP_NODELAY on my stub: x0.21 throughput |
| What saved money without changing code? | Right-sizing 8 to 4 vCPU after the heap fix reduced CPU |
| How is it proven? | Same load, 3 fresh-JVM repeats, median, one variable per row |
- Measure, find the bottleneck (JFR said 97% in the database), change one thing, prove it.
- Remove work before tuning the runtime; tune the runtime before buying hardware.
- Read CPU alongside throughput: the heap fix changed CPU, not RPS.
- A best-practice change can regress; the keep-alive step lost throughput until TCP_NODELAY was added.
- Cost per request = hourly price / sustained requests per hour; labelled example prices only.
- Everything here is from one laptop-class machine: reuse the method, not the digits.
- Previous: Scaling & Observability
- Next: Scenario Q&A
- Related: Measure First · Database Performance · Caching · AWS Compute & the JVM · Scaling & Observability
- Hub: Performance hub (symptom, cause, tool, fix)