Performance · 14

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.

71x
Throughput gain
10
Measured runs
139
x cheaper / request
3
Repeats each
01
Chapter One - The Problem

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 stepWhat happened
1. ProblemThe 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. HypothesisThe 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. MeasurementJFR CPU samples under load, per-request query counter, GC log, closed-loop and open-loop load runs - each step re-measured three times.
4. DiagnosisOne by one in the chapters below. Spoiler: 97% of baseline CPU samples are inside the database engine scanning the orders table.
5. OptimizationOne change per run, cumulative, in the order the evidence demanded.
6. VerificationA cumulative before/after table and a cost per million requests (chapter 11).
Throughput after each cumulative step (measured, 32 closed connections, median of 3)
Baseline 86 + index 691 + N+1 fix 1224 + keep-alive 263 + TCP_NODELAY 1762 + pool 4 to 8 1973 + virtual threads 2469 + Caffeine cache 5293 + heap 256 to 768 MiB 5214 + right-size 8 to 4 vCPU 6066 requests / second; laptop-class 12-core machine; absolute numbers will differ on yours
Key insight: A restaurant that is slow does not need a bigger dining room first. You find that the cook searches the whole pantry for each ingredient (missing index), makes eighty trips to the fridge per plate (N+1), and phones the supplier with a new call each time (no connection reuse). Fix the pantry first; the dining room was never the problem.
⚠ Common bugs & edge cases:
  • 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.
02
Chapter Two - Mental Model

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.

Cost per request formula
Cost per request: three numbers, one division $ / hour vCPU x price + GiB x price sustained RPS measured max x target utilisation requests / hour RPS x 3600 $ / 1M requests hourly cost / requests per hour x 1e6 Two ways to lower it: pay less per hour (right-size, Graviton, Spot) or serve more per hour (fix the code, cache, tune). The second usually dwarfs the first: the capstone gets most of its gain from a missing index and an N+1.
SignalWhat it means
$/hourSum 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 RPSYou 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.
Where cost levers live
LeverChangesTypical effectRisk
Fix code / DB / cacherequests per hour per instanceOften 10x or more (this capstone)Correctness (staleness, invalidation)
Right-sizeprice per hourProportional to the idle share you removeLatency tail if headroom vanishes
Graviton (arm64)price per hour and sometimes RPSOften a price/performance gain; must be load-testedNative libraries, multi-arch images
Savings Plans / reservedprice per hourDiscount for a commitmentLocked in; commit only to the proven baseline
Spot capacityprice per hourLarge discount for interruptible capacityInterruptions: only for tolerant workloads
Idle headroomprice per useful hourCost of N+1 capacity and low utilisationToo 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.

⚠ Common bugs & edge cases:
  • 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.
03
Chapter Three - The Rig

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.

Capstone topology
Test rig on one laptop-class machine (12 cores, 30 GB) LoadGen.java cores 8-11 closed 32 conns + open 40/s HTTP OrderService JVM (Spring Boot 3.3.4, Java 21), cores 0-7 Tomcat 16 thr Hikari 4 in-memory DB Caffeine HTTP client 200,000 orders, 600,000 items, 5,000 customers InventoryService stub :8081 (5 ms) seeded at start; index optional Deliberate flaws: no index on orders.customer_id, 81 queries per request, new TCP connection per inventory call, pool 4, 16 Tomcat threads, 256 MiB heap, 8 vCPU.
What each run measures
PhaseHowWhy
Warm-up8 s closed loop, discardedJIT compilation and caches must be warm before measuring
Closed loop32 virtual-thread clients, 15 s, each sends the next request when the previous returnsMaximum sustainable throughput at a fixed concurrency; gives RPS, avg, p95, p99, CPU cores, queries per request
Open loop40 requests/s for 20 s, latency timed from the INTENDED send timeLatency at a realistic fixed load; immune to coordinated omission
Repeatsevery step run 3 times with a fresh JVM; the table shows the medianRun-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.

Build and run one step (the exact commands used)
cd capstone && mvn -q -DskipTests package # run.sh LABEL "JVM OPTIONS" APP_CPUS ; behaviour is switched with environment variables APP_INDEX=true ./run.sh a1_1 "-Xmx256m" 0-7 # the whole experiment: 10 steps x 3 repeats, each step adds one change to the previous ./all.sh
⚠ Common bugs & edge cases:
  • 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.
04
Chapter Four - Step 1

Find the Hot Spot, Add the Index

Lab stepWhat happened
1. ProblemBaseline: 86 RPS, p99 923 ms at saturation; even at a gentle 40 requests/s the open-loop p99 is 133 ms.
2. HypothesisThe 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. Measurement10 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. DiagnosisOf 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. OptimizationONE 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. VerificationThroughput x8.0, p99 down from 923 ms to 125 ms; the DB share of CPU falls from 97% to 37%.
Real output: top CPU frames at baseline (python aggregation of jfr print)
p0.jfr samples 2431 97.2% embedded DB (org.h2) 1.7% Tomcat / framework 0.7% socket / HTTP client 0.3% JSON (Jackson) top 46.4% org.h2.mvstore.tx.TransactionMap$CommittedIterator.fetchNext() top 21.5% org.h2.mvstore.Cursor.hasNext() top 8.1% org.h2.expression.condition.Comparison.getValue(SessionLocal)
Evidence for the index step: 97% of baseline CPU is table scan
Where the CPU samples are (JFR ExecutionSample, 10 s under load, real data) baseline 97% + index 37% 27% 19% 15% + index + join 38% 26% 17% 17% DB (org.h2) Tomcat/framework socket / HTTP client JSON (Jackson)
RunRPSp95p99open p99CPUq/req$/1M
Baseline86663.9922.8133.14.381.0$1.747
+ index69197.3125.436.24.081.0$0.217
Key insight: This is the "find the 650 ms" move from the methodology page. Before the profile, the most tempting fixes were the N+1 (81 queries!) and the pool. The profile said otherwise: each query was expensive, so removing queries would have helped less than making each one cheap. We still fix the N+1, but next.
⚠ Common bugs & edge cases:
  • 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) serves WHERE 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.
05
Chapter Five - Step 2

Collapse the N+1 into One Query

Lab stepWhat happened
1. ProblemAfter 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. HypothesisPer-statement overhead (parsing, locking, connection hand-off, result mapping) now dominates; one join query would cut CPU and pool hold time.
3. MeasurementJFR 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. DiagnosisClassic 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. OptimizationONE change: replace the loops with a single statement joining a 20-row subquery on orders to order_items and products (app.fetch=join).
6. VerificationStatements 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.
Java - the loop that issues 81 queries
List<OrderView> heads = jt.query("select id, status, total, created_at from orders where customer_id = ? order by created_at desc limit 20", mapper, customerId); for (OrderView o : heads) { List<long[]> items = jt.query("select product_id, qty, price from order_items where order_id = ?", mapper2, o.id()); for (long[] it : items) { String name = jt.queryForObject("select name from products where id = ?", String.class, it[0]); // N x M ... } }
Java - one statement
jt.query(""" select o.id, o.status, o.total, o.created_at, i.product_id, p.name, i.qty, i.price from (select id, status, total, created_at from orders where customer_id = ? order by created_at desc limit 20) o join order_items i on i.order_id = o.id join products p on p.id = i.product_id order by o.created_at desc, o.id """, rowCallback, customerId);
Queries per request
baseline (N+1) 81.0 + join fetch 1.0 + cache (hit path) 0.0 measured by a counter inside the repository calls, per request
RunRPSp95p99open p99CPUq/req$/1M
+ index69197.3125.436.24.081.0$0.217
+ N+1 fix122451.673.714.64.51.0$0.123
⚠ Common bugs & edge cases:
  • 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.
06
Chapter Six - Step 3

HTTP Connection Reuse (and the Stall It Exposed)

Lab stepWhat happened
1. ProblemEvery 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. HypothesisReusing connections (HTTP keep-alive) removes handshakes and should raise throughput.
3. MeasurementChange 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. OptimizationONE more change on the (stub) server: -Dsun.net.httpserver.nodelay=true (TCP_NODELAY).
6. VerificationThroughput 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.
Nagle + delayed ACK on a persistent connection
Why keep-alive got SLOWER: small-write + delayed-ACK stall (consistent with the data; no packet capture taken) client server (stub) GET /inventory sleep 5 ms write 1: headers write 2: body (16 bytes) held by Nagle until write 1 is ACKed delayed ACK: waits ~40 ms ACK after ~40 ms write 2: body released With Connection: close the server closes right after writing, flushing the tail: no stall. Fix: TCP_NODELAY on the server (-Dsun.net.httpserver.nodelay=true).
RunRPSp95p99open p99CPUq/req$/1M
+ N+1 fix122451.673.714.64.51.0$0.123
+ keep-alive only263202.8300.868.70.91.0$0.571
+ TCP_NODELAY176235.960.510.33.71.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.

⚠ Common bugs & edge cases:
  • 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.
07
Chapter Seven - Steps 4 and 5

Pool Size and Virtual Threads

Lab stepWhat happened
1. ProblemWith 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. HypothesisConcurrency caps in series are now the limit: first the pool, then the thread count.
3. MeasurementHikari 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. DiagnosisMore 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. OptimizationTwo separate runs, one variable each (pool, then virtual threads).
6. VerificationPool 8: x1.12 throughput (1762 to 1973 RPS); virtual threads: a further x1.25 (2469 RPS), 36.0 ms p99.
Two caps in series
Concurrency caps in the request path (before/after, steps 4-5) before request threads: 16 DB connections: 4 pool 8 request threads: 16 DB connections: 8 pool 8 + virtual threads request threads: many DB connections: 8 The pool is the real gate; virtual threads only remove the Tomcat-thread cap in front of it.
RunRPSp95p99open p99CPUq/req$/1M
+ TCP_NODELAY176235.960.510.33.71.0$0.085
+ pool 4 to 8197330.842.69.03.91.0$0.076
+ virtual threads246925.136.012.14.81.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).

⚠ Common bugs & edge cases:
  • 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_connections minus a reserve.
  • On JDK 21, blocking inside synchronized pins 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.pending and the exception SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.
08
Chapter Eight - Step 6

Cache What Is Read Often and Changes Rarely

Lab stepWhat happened
1. ProblemEven at one query per request the service spends 4.8 cores for 2469 RPS, and most requests re-read the same hot customers.
2. HypothesisSkewed access plus data that tolerates 30 s of staleness means a cache removes most database work.
3. MeasurementCache hit counters exposed on /stats: hits and misses per run.
4. DiagnosisAbout 3% of customers receive about half of the traffic in this workload, so a small cache absorbs most reads.
5. OptimizationONE 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. VerificationHit ratio 97%, queries per request 0.0, throughput x2.1 (5293 RPS), open-loop p50 1.2 ms.
Hit ratio vs mean latency
Effective latency = h x t_cache + (1 - h) x t_db (hit ratio measured: 97%) measured ~97% hit ratio mean ms illustrative curve with t_db = 8 ms, t_cache = 0.2 ms
RunRPSp95p99open p99CPUq/req$/1M
+ virtual threads246925.136.012.14.81.0$0.061
+ Caffeine cache529315.326.59.83.70.0$0.028
⚠ Common bugs & edge cases:
  • 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).
09
Chapter Nine - Step 7

Give the Heap Room

Lab stepWhat happened
1. ProblemThroughput 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. HypothesisThe 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. DiagnosisMedian of 3 runs: 401 pauses totalling 1562 ms at 256 MiB.
5. OptimizationONE 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. VerificationPauses 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).
Real GC log lines, 256 MiB heap (run a7_2)
[6.293s][info][gc] GC(50) Pause Young (Normal) (G1 Evacuation Pause) 230M->111M(256M) 4.719ms [6.359s][info][gc] GC(51) Pause Young (Prepare Mixed) (G1 Evacuation Pause) 231M->114M(256M) 5.087ms [6.424s][info][gc] GC(52) Pause Young (Mixed) (G1 Evacuation Pause) 229M->116M(256M) 5.689ms [6.487s][info][gc] GC(53) Pause Young (Concurrent Start) (G1 Evacuation Pause) 230M->120M(256M) 4.935ms
Real GC log lines, 768 MiB heap (run a8_2)
[4.599s][info][gc] GC(10) Pause Young (Normal) (G1 Evacuation Pause) 475M->70M(768M) 32.402ms [4.886s][info][gc] GC(11) Pause Young (Normal) (G1 Evacuation Pause) 489M->84M(768M) 22.130ms [5.160s][info][gc] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 503M->97M(768M) 22.846ms [5.423s][info][gc] GC(13) Pause Young (Normal) (G1 Evacuation Pause) 517M->111M(768M) 23.257ms
GC pauses in the whole process lifetime (incl. seeding), 256 MiB vs 768 MiB heap
256 MiB heap: pause count 401 768 MiB heap: pause count 32 median of 3 runs; same code, same load 256 MiB: total pause ms 1562 ms 768 MiB: total pause ms 589 ms
RunRPSp95p99open p99CPUq/req$/1M
+ cache529315.326.59.83.70.0$0.028
+ heap 768 MiB521414.423.98.32.20.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.

⚠ Common bugs & edge cases:
  • 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.
10
Chapter Ten - Step 8

Right-Size and Price It

Lab stepWhat happened
1. ProblemThe service now serves 5214 RPS while using 2.2 of its 8 vCPUs: more than half the capacity we pay for is idle.
2. HypothesisHalf the cores deliver the same throughput; the bill should halve.
3. MeasurementPin 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. DiagnosisAt 4 cores the application used 1.9 cores, still below the allocation, with p99 under saturation 18.0 ms.
5. OptimizationONE change: 8 to 4 vCPU. Hourly price $0.325 to $0.165 (example prices).
6. VerificationThroughput 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.
RunRPSp95p99open p99CPUq/req$/1M
+ heap521414.423.98.32.20.0$0.029
+ right-size 8 to 4 vCPU606611.518.010.21.90.0$0.013
Cost per 1M requests (EXAMPLE prices, 60% target utilisation)
Baseline $1.747 + index $0.217 + N+1 fix $0.123 + keep-alive $0.571 + TCP_NODELAY $0.085 + pool 4 to 8 $0.076 + virtual threads $0.061 + Caffeine cache $0.028 + heap 256 to 768 MiB $0.029 + right-size 8 to 4 vCPU $0.013 example list prices (assumed): $0.040 per vCPU-hour, $0.0045 per GiB-hour; check current pricing
Levers that are not measured here (concept only)
LeverHow to evaluate itStatus in this page
Graviton (arm64)Rebuild a multi-arch image, repeat THIS load test on arm64 and divide price by RPSILLUSTRATIVE: not run here (see the AWS compute page)
Savings Plans / reservedCommit to the steady baseline you proved, not to peakConcept only: pricing varies by term and region
SpotOnly for fault-tolerant, interruptible workers (queue consumers, batch)Concept only
Idle headroomUtilisation target vs failure scenarios (lose an AZ)We assumed 60% target utilisation
⚠ Common bugs & edge cases:
  • 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.
11
Chapter Eleven - Results

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.

RunRPSp95p99open p99CPUq/req$/1M
Baseline
as shipped
86663.9922.8133.14.381.0$1.747
+ index
orders(customer_id, created_at)
69197.3125.436.24.081.0$0.217
+ N+1 fix
81 queries to 1 join
122451.673.714.64.51.0$0.123
+ keep-alive
reuse the inventory connection
263202.8300.868.70.91.0$0.571
+ TCP_NODELAY
fix the 40 ms stall it exposed
176235.960.510.33.71.0$0.085
+ pool 4 to 8
Hikari maximum-pool-size
197330.842.69.03.91.0$0.076
+ virtual threads
spring.threads.virtual.enabled
246925.136.012.14.81.0$0.061
+ Caffeine cache
30 s TTL, 10k entries
529315.326.59.83.70.0$0.028
+ heap 256 to 768 MiB
MaxRAMPercentage 25 to 75
521414.423.98.32.20.0$0.029
+ right-size 8 to 4 vCPU
taskset 0-3
606611.518.010.21.90.0$0.013
p99 latency under saturation (ms, lower is better)
Baseline 923 ms + index 125 ms + N+1 fix 74 ms + keep-alive 301 ms + TCP_NODELAY 60 ms + pool 4 to 8 43 ms + virtual threads 36 ms + Caffeine cache 26 ms + heap 256 to 768 MiB 24 ms + right-size 8 to 4 vCPU 18 ms closed loop, 32 connections: this is latency AT the knee, not at a comfortable load
The three outcomes
OutcomeBaselineFinalChange
Better latency (p99 at 32 connections)923 ms18 msx51 lower
Better latency (open loop, 40 req/s, p99)133.1 ms10.2 msx13 lower
Higher throughput per instance86 RPS6066 RPS (on HALF the vCPUs)x71
Lower cost per request (example prices)$1.747 / 1M$0.013 / 1Mx139 cheaper
Database work81 queries / req0 (cache hit path)about 99% of requests never reach the DB
Which change produced which gain
StepThroughput factor vs previousWhat it fixed
+ indexx8.03orders(customer_id, created_at)
+ N+1 fixx1.7781 queries to 1 join
+ keep-alivex0.21reuse the inventory connection
+ TCP_NODELAYx6.70fix the 40 ms stall it exposed
+ pool 4 to 8x1.12Hikari maximum-pool-size
+ virtual threadsx1.25spring.threads.virtual.enabled
+ Caffeine cachex2.1430 s TTL, 10k entries
+ heap 256 to 768 MiBx0.99MaxRAMPercentage 25 to 75
+ right-size 8 to 4 vCPUx1.16taskset 0-3
⚠ Common bugs & edge cases:
  • 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.
12
Chapter Twelve - Try It Yourself

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.

pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.4</version> </parent> <groupId>perf</groupId> <artifactId>orderservice</artifactId> <version>1.0</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
application.properties
server.port=8080 spring.datasource.url=jdbc:h2:mem:orders;DB_CLOSE_DELAY=-1 spring.datasource.hikari.maximum-pool-size=4 spring.datasource.hikari.minimum-idle=4 spring.datasource.hikari.connection-timeout=30000 server.tomcat.threads.max=16 spring.main.banner-mode=off logging.level.root=WARN # feature toggles (override with environment variables, e.g. APP_FETCH=join) app.fetch=nplus1 app.index=false app.keepalive=false app.cache=false
Java - OrderApp.java (app, seeding, stub, endpoint)
package perf; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.sun.net.httpserver.HttpServer; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.InetSocketAddress; import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.*; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicLong; @SpringBootApplication public class OrderApp { public static void main(String[] args) { SpringApplication.run(OrderApp.class, args); } static final int CUSTOMERS = 5_000, ORDERS_PER_CUSTOMER = 40, ITEMS_PER_ORDER = 3, PRODUCTS = 2_000; /** InventoryService stand-in: a separate HTTP server (port 8081) that takes 5 ms per call. */ @Bean(destroyMethod = "") HttpServer inventoryStub() throws Exception { HttpServer s = HttpServer.create(new InetSocketAddress(8081), 1024); s.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); s.createContext("/inventory", ex -> { try { Thread.sleep(5); } catch (InterruptedException ignored) { } byte[] b = "{\"inStock\":true}".getBytes(StandardCharsets.UTF_8); ex.getResponseHeaders().add("Content-Type", "application/json"); ex.sendResponseHeaders(200, b.length); ex.getResponseBody().write(b); ex.close(); }); s.start(); return s; } /** Creates the schema and seeds 5,000 customers, 200,000 orders, 600,000 items. */ @Bean ApplicationRunner seed(JdbcTemplate jt, @Value("${app.index}") boolean index) { return args -> { long t0 = System.nanoTime(); jt.execute("create table customers(id bigint primary key, name varchar(40))"); jt.execute("create table products(id bigint primary key, name varchar(40))"); jt.execute("create table orders(id bigint primary key, customer_id bigint not null, status varchar(10), total decimal(10,2), created_at bigint)"); jt.execute("create table order_items(id bigint primary key, order_id bigint not null, product_id bigint not null, qty int, price decimal(10,2))"); jt.execute("create index ix_items_order on order_items(order_id)"); List<Object[]> b = new ArrayList<>(); for (long i = 1; i <= CUSTOMERS; i++) b.add(new Object[]{i, "customer-" + i}); jt.batchUpdate("insert into customers values (?,?)", b); b.clear(); for (long i = 1; i <= PRODUCTS; i++) b.add(new Object[]{i, "product-" + i}); jt.batchUpdate("insert into products values (?,?)", b); b.clear(); Random r = new Random(42); long oid = 0, iid = 0; List<Object[]> items = new ArrayList<>(); for (long c = 1; c <= CUSTOMERS; c++) { for (int k = 0; k < ORDERS_PER_CUSTOMER; k++) { oid++; b.add(new Object[]{oid, c, "PAID", 10 + r.nextInt(500), 1_700_000_000L + r.nextInt(10_000_000)}); for (int j = 0; j < ITEMS_PER_ORDER; j++) { iid++; items.add(new Object[]{iid, oid, 1 + r.nextInt(PRODUCTS), 1 + r.nextInt(5), 5 + r.nextInt(100)}); } } if (b.size() >= 5000) { jt.batchUpdate("insert into orders values (?,?,?,?,?)", b); b.clear(); jt.batchUpdate("insert into order_items values (?,?,?,?,?)", items); items.clear(); } } if (!b.isEmpty()) { jt.batchUpdate("insert into orders values (?,?,?,?,?)", b); jt.batchUpdate("insert into order_items values (?,?,?,?,?)", items); } if (index) jt.execute("create index ix_orders_cust on orders(customer_id, created_at)"); jt.execute("analyze"); System.out.printf("SEEDED orders=%d items=%d in %d ms (index=%s)%n", oid, iid, (System.nanoTime() - t0) / 1_000_000, index); }; } } record ItemView(long productId, String product, int qty, double price) { } record OrderView(long id, String status, double total, long createdAt, List<ItemView> items) { } record Page(boolean inStock, List<OrderView> orders) { } @RestController class OrderController { private final JdbcTemplate jt; private final String fetch; private final boolean keepAlive, cacheOn; private final AtomicLong queries = new AtomicLong(), hits = new AtomicLong(), misses = new AtomicLong(); private final Cache<Long, Page> cache = Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(Duration.ofSeconds(30)).build(); OrderController(JdbcTemplate jt, @Value("${app.fetch}") String fetch, @Value("${app.keepalive}") boolean keepAlive, @Value("${app.cache}") boolean cacheOn) { this.jt = jt; this.fetch = fetch; this.keepAlive = keepAlive; this.cacheOn = cacheOn; System.setProperty("http.maxConnections", "256"); System.out.printf("CONFIG fetch=%s keepalive=%s cache=%s%n", fetch, keepAlive, cacheOn); } @GetMapping("/customers/{id}/orders") Page orders(@PathVariable long id) throws Exception { if (!cacheOn) return load(id); Page p = cache.getIfPresent(id); if (p != null) { hits.incrementAndGet(); return p; } misses.incrementAndGet(); p = load(id); cache.put(id, p); return p; } @GetMapping("/stats") Map<String, Long> stats() { return Map.of("queries", queries.get(), "hits", hits.get(), "misses", misses.get()); } private Page load(long customerId) throws Exception { boolean stock = inventory(); List<OrderView> out = new ArrayList<>(); if (fetch.equals("nplus1")) { queries.incrementAndGet(); List<long[]> ids = new ArrayList<>(); List<OrderView> heads = jt.query( "select id, status, total, created_at from orders where customer_id = ? order by created_at desc limit 20", (rs, n) -> new OrderView(rs.getLong(1), rs.getString(2), rs.getDouble(3), rs.getLong(4), new ArrayList<>()), customerId); for (OrderView o : heads) { queries.incrementAndGet(); List<long[]> its = jt.query("select product_id, qty, price from order_items where order_id = ?", (rs, n) -> new long[]{rs.getLong(1), rs.getInt(2), rs.getLong(3)}, o.id()); for (long[] it : its) { queries.incrementAndGet(); String name = jt.queryForObject("select name from products where id = ?", String.class, it[0]); o.items().add(new ItemView(it[0], name, (int) it[1], it[2])); } out.add(o); } } else { queries.incrementAndGet(); Map<Long, OrderView> byId = new LinkedHashMap<>(); jt.query(""" select o.id, o.status, o.total, o.created_at, i.product_id, p.name, i.qty, i.price from (select id, status, total, created_at from orders where customer_id = ? order by created_at desc limit 20) o join order_items i on i.order_id = o.id join products p on p.id = i.product_id order by o.created_at desc, o.id """, rs -> { OrderView o = byId.computeIfAbsent(rs.getLong(1), k -> { try { return new OrderView(rs.getLong(1), rs.getString(2), rs.getDouble(3), rs.getLong(4), new ArrayList<>()); } catch (java.sql.SQLException e) { throw new RuntimeException(e); } }); o.items().add(new ItemView(rs.getLong(5), rs.getString(6), rs.getInt(7), rs.getDouble(8))); }, customerId); out.addAll(byId.values()); } return new Page(stock, out); } private boolean inventory() throws Exception { HttpURLConnection c = (HttpURLConnection) URI.create("http://localhost:8081/inventory").toURL().openConnection(); c.setConnectTimeout(1000); c.setReadTimeout(2000); if (!keepAlive) c.setRequestProperty("Connection", "close"); try (InputStream in = c.getInputStream()) { in.readAllBytes(); } return true; } }
Java - LoadGen.java (closed loop + open model with coordinated-omission-correct latency)
import java.net.URI; import java.net.http.*; import java.nio.file.*; import java.time.Duration; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; /** usage: java LoadGen.java pid closedConns closedSecs openRate openSecs label */ public class LoadGen { static final HttpClient C = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).connectTimeout(Duration.ofSeconds(5)).build(); static final int CUSTOMERS = 5000; // skewed key choice: ~half of the traffic hits ~3% of customers (hot keys) static long pick(Random r) { double u = r.nextDouble(); return 1 + (long) (CUSTOMERS * u * u * u); } static long call(long id) throws Exception { HttpRequest q = HttpRequest.newBuilder(URI.create("http://localhost:8080/customers/" + id + "/orders")).timeout(Duration.ofSeconds(60)).build(); long t = System.nanoTime(); HttpResponse<byte[]> r = C.send(q, HttpResponse.BodyHandlers.ofByteArray()); if (r.statusCode() != 200) throw new RuntimeException("HTTP " + r.statusCode()); return System.nanoTime() - t; } static String stats() throws Exception { return C.send(HttpRequest.newBuilder(URI.create("http://localhost:8080/stats")).build(), HttpResponse.BodyHandlers.ofString()).body(); } static long field(String json, String k) { int i = json.indexOf("\"" + k + "\":") + k.length() + 3; int j = i; while (j < json.length() && Character.isDigit(json.charAt(j))) j++; return Long.parseLong(json.substring(i, j)); } static long cpuJiffies(String pid) throws Exception { String s = Files.readString(Path.of("/proc/" + pid + "/stat")); String[] f = s.substring(s.lastIndexOf(')') + 2).split(" "); return Long.parseLong(f[11]) + Long.parseLong(f[12]); } static double pct(long[] sorted, double p) { return sorted[(int) Math.min(sorted.length - 1, Math.ceil(p / 100 * sorted.length) - 1)] / 1e6; } public static void main(String[] a) throws Exception { String pid = a[0]; int conns = Integer.parseInt(a[1]); int secs = Integer.parseInt(a[2]); int rate = Integer.parseInt(a[3]); int osecs = Integer.parseInt(a[4]); String label = a[5]; // warm-up (closed, not recorded) runClosed(conns, 8, null); // closed-loop: max sustainable throughput at fixed concurrency long j0 = cpuJiffies(pid); String s0 = stats(); List<Long> lat = new CopyOnWriteArrayList<>(); long t0 = System.nanoTime(); runClosed(conns, secs, lat); double el = (System.nanoTime() - t0) / 1e9; long j1 = cpuJiffies(pid); String s1 = stats(); long[] l = lat.stream().mapToLong(x -> x).sorted().toArray(); double rps = l.length / el, cpu = (j1 - j0) / 100.0 / el; double qpr = (field(s1, "queries") - field(s0, "queries")) / (double) l.length; long h = field(s1, "hits") - field(s0, "hits"), m = field(s1, "misses") - field(s0, "misses"); double avg = Arrays.stream(l).average().orElse(0) / 1e6; System.out.printf("CLOSED %s conns=%d rps=%.0f avg=%.1fms p95=%.1fms p99=%.1fms cpu_cores=%.2f queries/req=%.1f cache_hit=%s%n", label, conns, rps, avg, pct(l, 95), pct(l, 99), cpu, qpr, (h + m) == 0 ? "n/a" : String.format("%.0f%%", 100.0 * h / (h + m))); // open model: constant arrival rate, latency measured from the INTENDED start time if (rate > 0) { int n = rate * osecs; long[] ol = new long[n]; AtomicInteger errs = new AtomicInteger(); long start = System.nanoTime() + 200_000_000L; try (var ex = Executors.newVirtualThreadPerTaskExecutor()) { Random r = new Random(7); for (int i = 0; i < n; i++) { long intended = start + (long) (i * 1e9 / rate); long id = pick(r); int idx = i; ex.submit(() -> { long w = intended - System.nanoTime(); if (w > 0) try { Thread.sleep(w / 1_000_000, (int) (w % 1_000_000)); } catch (InterruptedException e) { } try { call(id); ol[idx] = System.nanoTime() - intended; } catch (Exception e) { errs.incrementAndGet(); ol[idx] = Long.MAX_VALUE / 4; } }); } } Arrays.sort(ol); System.out.printf("OPEN %s rate=%d/s n=%d errors=%d p50=%.1fms p95=%.1fms p99=%.1fms max=%.1fms%n", label, rate, n, errs.get(), pct(ol, 50), pct(ol, 95), pct(ol, 99), ol[n - 1] / 1e6); } } static void runClosed(int conns, int secs, List<Long> out) throws Exception { long end = System.nanoTime() + secs * 1_000_000_000L; try (var ex = Executors.newVirtualThreadPerTaskExecutor()) { for (int c = 0; c < conns; c++) { int seed = c; ex.submit(() -> { Random r = new Random(seed); while (System.nanoTime() < end) { try { long d = call(pick(r)); if (out != null) out.add(d); } catch (Exception e) { } } }); } } } }
run.sh
#!/bin/bash # usage: run.sh LABEL "JVM_OPTS" APP_CPUS (config via exported env vars) # app is pinned to APP_CPUS, load generator + stub client to cores 8-11 D=$(dirname "$(readlink -f "$0")") LABEL=$1; JOPTS=$2; CPUS=${3:-0-7} CONNS=${CONNS:-32}; RATE=${RATE:-60} pkill -f orderservice-1.0.jar 2>/dev/null; sleep 1 taskset -c "$CPUS" java $JOPTS -jar "$D/target/orderservice-1.0.jar" > "$D/out/$LABEL.app.log" 2>&1 & PID=$! for i in $(seq 1 120); do grep -q "SEEDED" "$D/out/$LABEL.app.log" 2>/dev/null && break; sleep 1; done sleep 2 grep -E "SEEDED|CONFIG" "$D/out/$LABEL.app.log" taskset -c 8-11 java "$D/LoadGen.java" $PID $CONNS 15 $RATE 20 "$LABEL" | tee "$D/out/$LABEL.result" kill $PID; wait $PID 2>/dev/null
all.sh
#!/bin/bash # cumulative steps, each measured REPS times; results in out/<step>_<rep>.result D=$(dirname "$(readlink -f "$0")") cd "$D"; export RATE=${RATE:-40}; REPS=${REPS:-3} unset APP_FETCH APP_INDEX APP_KEEPALIVE APP_CACHE SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE SPRING_DATASOURCE_HIKARI_MINIMUM_IDLE SPRING_THREADS_VIRTUAL_ENABLED HEAP="-Xmx256m"; CPUS=0-7; NODELAY="" step() { # name for r in $(seq 1 $REPS); do ./run.sh "$1_$r" "$HEAP $NODELAY -Xlog:gc:file=$D/out/$1_$r.gc.log" $CPUS > out/$1_$r.full 2>&1 grep -E "CLOSED|OPEN" out/$1_$r.full done } step a0 export APP_INDEX=true; step a1 export APP_FETCH=join; step a2 export APP_KEEPALIVE=true; step a3 NODELAY="-Dsun.net.httpserver.nodelay=true"; step a4 export SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE=8 SPRING_DATASOURCE_HIKARI_MINIMUM_IDLE=8; step a5 export SPRING_THREADS_VIRTUAL_ENABLED=true; step a6 export APP_CACHE=true; step a7 HEAP="-Xms768m -Xmx768m"; step a8 CPUS=0-3; step a9

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.

⚠ Common bugs & edge cases:
  • 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.
13
Chapter Thirteen - Interview Angles

Capstone Questions

How did you decide what to fix first?

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.

A standard optimisation made things slower. How do you react?

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.

How do you compute cost per request?

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.

Why does a cache change the numbers so much?

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.

Why did the heap step lower CPU but not RPS?

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.

Are virtual threads worth enabling here?

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.

Which result should you distrust?

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 is right-sizing wrong?

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.

14
Chapter Fourteen - Summary

Cheat Sheet, Recap & Where Next

Cheat sheet
QuestionAnswer 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
Recap
  • 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.