Performance · 08

Database Performance

The case: a request takes 800 ms and 650 of it is the database. Instead of buying a bigger instance, this lab finds the 650 ms with real, runnable experiments: statements per request (N+1), the missing index, OFFSET vs keyset paging, batch inserts and HikariCP pool sizing, all timed against an embedded database on a 12-core laptop-class machine. PostgreSQL plans and AWS tooling are explained from documented behaviour and clearly labelled illustrative.

11
Chapters
4
Measured labs
15
Diagrams
3
Programs to run
01
Chapter One - The Problem

Find the 650 Milliseconds

OrderService endpoint GET /customers/{id}/orders returns a customer's order history. In production its p50 is 800 ms. The product owner says "the database is slow, buy a bigger instance". Before spending money we do what the whole series teaches: measure, locate the bottleneck, fix one thing, prove it.

Latency budget of the slow request
The case: one GET /customers/7/orders takes 800 ms - where is the time? (case-study budget, illustrative) database 650 ms other 100 ms app 30 ms JSON 20 ms This 800 ms budget is the lab's scenario (same case as the Measure First page). It is a stated case, NOT a measurement. Rule from the methodology page: attribute the budget before touching anything. The biggest slice is 81 % database, so a 2x faster JSON library would save 10 ms at best (Amdahl). The rest of this page is how to find, inside the 650 ms, which statements, which plan, which pool wait - and to shrink them with measured before/after numbers. Illustrative decomposition of the 650 ms a trace might show: 101 statements (N+1) + one full scan + waiting for a pooled connection. Chapters 3-7 reproduce each of these effects at small scale with real timings.

All timings below come from programs run in this sandbox against H2 2.2.224, an embedded in-process database (and HikariCP 5.1.0 in front of it), on a 12-core laptop-class machine that was shared with other jobs (load average 20+), so absolute values are noisy. H2 is not PostgreSQL: its planner, caches and storage engine differ, and in-process means no network; where the network matters we add it explicitly and say so. Query-plan walk-throughs for PostgreSQL are labelled illustrative. What transfers is the shape: statements per request, scan vs index, offset vs keyset, pool size vs throughput.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

p50 800 ms for a screen that shows 100 orders. The service has 100 orders per customer and 200,000 orders in total; orders.customer_id has no index (a classic - the foreign key was declared but the index was never created), and the entity mapping has a lazy items collection that the serialiser touches.

Step 2 · Hypothesis

Three independent suspects, each testable on its own: (a) the number of statements per request (N+1), (b) the cost of each statement (missing index, bad plan), (c) waiting for a connection (pool or long transactions). We test one at a time so each gain is attributable.

Step 3 · Measurement

In order of cheapness: count statements per request (Hibernate statistics or a datasource proxy), then ask the database what it spends time on (pg_stat_statements, Performance Insights / Database Insights), then read the plan of the single worst statement (EXPLAIN (ANALYZE, BUFFERS)), then check the pool (hikaricp.connections.pending).

Step 4 · Diagnosis

Chapters 3 to 7 each find one cause, with real numbers. The order matters: the cheapest fix with the largest payoff first (usually: eliminate repeated work, then add the missing index, then size the pool).

Step 5 · Optimization

One change per chapter: batch fetch, index, keyset pagination, batch writes, pool size.

Step 6 · Verification

Each chapter ends with a before/after table from the same harness. A final "Try it yourself" chapter contains the complete code.

Key insight: The database is a library with a clerk. N+1 is walking to the desk 101 times for 101 books instead of handing over one list. A missing index is the clerk reading every spine to find one title. A small pool is a single clerk with a long queue; a huge pool is 200 clerks bumping into each other in the same aisle.
02
Chapter Two - Mental Model

One Query, Seven Places to Wait

A SQL statement is not "a call" - it is a small pipeline with a pool in front of it and a network in the middle. Every stage can be the slow one, and the fixes differ completely (code, index, pool, instance size). The first job is therefore diagnostic, not remedial.

The app asks the pool for a connection. If all maximumPoolSize connections are leased, the thread waits (up to connectionTimeout, 30 s by default) and gets SQLTransientConnectionException when it expires.
▶
A busy pool turns into waiting
The statement crosses the network. Same-AZ round trips are typically a fraction of a millisecond; a hundred sequential statements pay that a hundred times.
▶
N statements = N round trips
The server parses and plans (or reuses a plan). The planner picks a scan strategy from table statistics; stale statistics produce bad plans - the symptom is estimated rows far from actual rows.
▶
Bad estimates give bad plans
The executor runs the plan against the buffer cache. A sequential scan reads every row; an index scan reads a few pages. Whatever is not in memory is read from storage (EBS / Aurora storage), orders of magnitude slower.
▶
Scans touch every row
Rows return and the ORM hydrates objects. Cost scales with rows and columns returned: select what you need.
▶
Rows x columns = hydration cost
One query through the stack
What one query costs on its way through the stack App build SQL, bind Pool getConnection() Network ~0.5 ms RTT Parse + plan plan cache? Execute scan or index Buffer cache memory or disk Return rows hydrate rows Where each stage gets slow, and the metric that shows it: Pool all connections busy: requests wait hikaricp.connections.pending, .acquire Network N statements x RTT: N+1 multiplies it statements per request, trace spans Parse + plan missing/stale statistics: wrong plan EXPLAIN (ANALYZE): estimated vs actual rows Execute sequential scan, sort spill, lock wait pg_stat_statements, wait events Buffer cache cold data reads from storage EXPLAIN BUFFERS: read vs hit; ReadIOPS Return rows too many rows/columns, entity hydration rows returned, response bytes

Structure diagram with the metric that exposes each stage; no measured values.

Diagnosis flow
Where to look first: a diagnosis flow for "the database is slow" Is DB CPU / load high (AAS above vCPUs)? No: app waits elsewhere pool pending? long tx? statements per request? Yes: find top statements pg_stat_statements by total time, not mean Many fast statements? calls huge, mean tiny: N+1 / chatty code Few slow statements? EXPLAIN ANALYZE: scan, sort, misestimate -> index Fix in code: batch fetch, join fetch, projection, cache (page 09) Always change one thing, re-measure the same request, and keep the baseline.

Judgement flow, not a measurement.

Cross-reference: How Spring Data and Hibernate generate these statements (persistence context, fetch types, @EntityGraph) is covered in Spring Data JPA; transaction boundaries and propagation in Transactions. This page measures their performance consequences.
03
Chapter Three - Lab One - N+1 Queries

Statements per Request, 101 to 2 to 1

The most common and most expensive database mistake in ORM code: fetch a list of parents with one statement, then touch a lazy association on each parent and issue one more statement per parent. 1 + N statements. It hides in development (tiny data, zero network) and explodes in production.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

One page view of an order history issues 101 statements (1 for the orders, 100 for the items of each). Log line from Hibernate statistics (spring.jpa.properties.hibernate.generate_statistics=true) prints at the end of each session something like "... nanoseconds spent executing 101 JDBC statements" - the number to watch is the statement count, not the time.

Step 2 · Hypothesis

Cost = statements × (round trip + parse + execute). With a 0.5 ms same-AZ round trip, 101 statements cost at least 50 ms of pure network before any database work, so the two-statement version should be about 25x faster on the network share alone.

Step 3 · Measurement

DbLab.java (chapter 10) seeds H2 with 2,000 customers, 200,000 orders and 600,000 order items, then runs the same request three ways via plain JDBC exactly as an ORM would issue the SQL. It adds a configurable busy-wait per statement to simulate network round trip (H2 is in-process so its real RTT is ~0):

shell
java -cp out:h2-2.2.224.jar DbLab 0.5 # 0.5 ms simulated RTT, orders.customer_id NOT indexed java -cp out:h2-2.2.224.jar DbLab 0.5 index # same, with the index java -cp out:h2-2.2.224.jar DbLab 0 index # no simulated network
Step 4 · Diagnosis
real output
== orders.customer_id index: no | simulated network round trip per statement: 0.5 ms == N+1 : 69.57 ms/request, 101 statements batched IN : 24.32 ms/request, 2 statements single join : 12.25 ms/request, 1 statements == orders.customer_id index: YES | simulated network round trip per statement: 0.5 ms == N+1 : 51.81 ms/request, 101 statements batched IN : 1.82 ms/request, 2 statements single join : 0.84 ms/request, 1 statements == orders.customer_id index: YES | simulated network round trip per statement: 0.0 ms == N+1 : 2.57 ms/request, 101 statements batched IN : 0.96 ms/request, 2 statements single join : 0.44 ms/request, 1 statements
Statements and where they go
Same data, two access patterns (customer 777 has 100 orders) app database N+1: 101 stmts SELECT ... FROM orders WHERE customer_id = 777 SELECT ... FROM order_items WHERE order_id = 1 SELECT ... FROM order_items WHERE order_id = 2 ... 98 more round trips, each paying RTT + parse + execute Batched: 2 stmts SELECT ... FROM orders WHERE customer_id = 777 SELECT ... FROM order_items WHERE order_id IN (1, 2, ... 100) Join fetch / EntityGraph: 1 statement, but a wider result (order columns repeat per item row).

Mechanism: the number of round trips is the lever.

With the 0.5 ms simulated round trip and the index in place: N+1 takes 51.8 ms; batching takes 1.8 ms (28x faster); a single join takes 0.84 ms (62x). Look at the in-process row: N+1 is only 2.6 ms there because the network is absent - that is exactly why this bug passes code review and local tests and then fails in AWS. The statement count is the stable diagnostic; wall-clock hides it.

Step 5 · Optimization

Pick the fix by the access pattern. Each is one variable:

FixHow (Hibernate 6 / Spring Data JPA)StatementsWatch out for
Join fetchselect o from Order o join fetch o.items where o.customerId = :id1Row multiplication; with pagination Hibernate warns HHH90003004 "firstResult/maxResults specified with collection fetch; applying in memory" (HHH000104 in Hibernate 5) - paging happens in the JVM!
@EntityGraph@EntityGraph(attributePaths = "items") on the repository method1Same row multiplication; fetching two collections: MultipleBagFetchException: cannot simultaneously fetch multiple bags
Batch fetchspring.jpa.properties.hibernate.default_batch_fetch_size=100 or @BatchSize(size = 100)1 + ceil(N/100)The least intrusive fix: queries stay lazy; matches our "batched IN" run
DTO projectionconstructor expression or interface projection selecting only needed columns1Read-only, no entity lifecycle; best for list screens
Cachecache the aggregate (page 09)0 on hitInvalidation cost
Java - batch fetch in application.yml and an explicit join-fetch alternative
# application.yml spring: jpa: properties: hibernate: default_batch_fetch_size: 100 # lazy collections load 100 parents per IN (...) query generate_statistics: true # temporary: prints statement counts per session // repository public interface OrderRepository extends JpaRepository<Order, Long> { @EntityGraph(attributePaths = "items") // one statement, orders x items rows List<Order> findByCustomerId(long customerId); @Query("select new com.example.OrderSummary(o.id, o.status, o.total, size(o.items)) " + "from Order o where o.customerId = :customerId") // no entity, no lazy loading at all List<OrderSummary> summaries(long customerId); }
Step 6 · Verification
Measured before/after
Measured ms per request (101 / 2 / 1 statements), lower is better 0.5 ms RTT, no index N+1 69.57 ms batched IN 24.32 ms single join 12.25 ms 0.5 ms RTT, index N+1 51.81 ms batched IN 1.82 ms single join 0.84 ms in-process (RTT 0), index N+1 2.57 ms batched IN 0.96 ms single join 0.44 ms H2 2.2.224 in-process; RTT simulated by a busy-wait per statement; 100 timed requests each after 20 warm-up.

Measured on the in-process H2 harness; shape (statements per request) transfers, absolute values do not.

Variant (0.5 ms RTT, index present)Statementsms/requestvs N+1
N+110151.81baseline
Batched IN (size 100)21.8228x faster
Single join10.8462x faster

Note the first block of the output (no index): even the single join takes 12.25 ms there because the scan of orders dominates - the next chapter. The two problems compound, which is why you fix them one at a time and re-measure.

⚠ Production gotchas: N+1
  • Found only in production. Add a statement-count assertion to integration tests (a datasource proxy such as datasource-proxy / Hypersistence Utils, or Hibernate statistics) so a new N+1 fails CI.
  • Open-in-view triggers N+1 in the serialiser. spring.jpa.open-in-view defaults to true (Boot logs "spring.jpa.open-in-view is enabled by default..."): lazy loads fire while Jackson serialises, holding the pooled connection for the whole response. Turn it off and fetch what the use case needs.
  • Join fetch + paging. The warning means Hibernate fetched all rows and sliced in memory. Page the parent ids first, then fetch children with IN (:ids) (or use batch fetching).
  • Batching hides, not fixes, per-row work in a loop that calls a service which calls a repository - the statements are separate sessions. Trace the whole request.
04
Chapter Four - Lab Two - The Missing Index

Scan vs Index, 122x

An index is a second, sorted copy of some columns that lets the database find rows without reading the table. The biggest single win in most slow-query investigations, and also the easiest to get wrong.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

After fixing N+1, the first statement of the request (select ... from orders where customer_id = ?) is the slowest by far. Nobody created an index on the foreign-key column: PostgreSQL does not create indexes for foreign keys automatically (unlike primary keys / unique constraints), and neither does H2.

Step 2 · Hypothesis

The statement does a sequential/table scan of the whole table. At 500,000 rows (the numbers grow linearly with table size) it costs tens of milliseconds per call, and CPU on the database instance rises with traffic.

Step 3 · Measurement

Ask the database for its plan and its row-examination counts, then time the query. H2 prints the access path with EXPLAIN ANALYZE:

Java (DbLab2) - the exact calls
explain(c, "EXPLAIN ANALYZE SELECT id, status, total FROM orders WHERE customer_id = 777"); double ms = time(c, "SELECT id, status, total FROM orders WHERE customer_id = 777", 50);

(In PostgreSQL the equivalent is EXPLAIN (ANALYZE, BUFFERS) in psql; with Spring you can find the statement from pg_stat_statements.)

Step 4 · Diagnosis
real H2 output - before the index
FROM "PUBLIC"."ORDERS" /* PUBLIC.ORDERS.tableScan */ /* scanCount: 500001 */ WHERE "CUSTOMER_ID" = CAST(777 AS BIGINT) no index : 49.991 ms/query

tableScan and scanCount: 500001: the engine visited every row to return 250. The ratio of rows examined to rows returned (500,001 : 250 = 2,000 : 1) is the number to hate.

Step 5 · Optimization
SQL - the one change
CREATE INDEX idx_orders_customer ON orders(customer_id); -- PostgreSQL on a live table: CREATE INDEX CONCURRENTLY idx_orders_customer ON orders(customer_id);

CONCURRENTLY (PostgreSQL) avoids blocking writes during the build at the cost of a slower build and a possible invalid index if it fails - check pg_index.indisvalid. With Flyway/Liquibase, run it outside the migration transaction.

Step 6 · Verification
real H2 output - after the index
CREATE INDEX on 500,000 rows took 1719 ms (writes pay this price forever after) FROM "PUBLIC"."ORDERS" /* PUBLIC.IDX_ORDERS_CUSTOMER: CUSTOMER_ID = CAST(777 AS BIGINT) */ /* scanCount: 251 */ WHERE "CUSTOMER_ID" = CAST(777 AS BIGINT) with index : 0.409 ms/query
One query, scan vs index (measured)
WHERE customer_id = 777 on 500,000 rows (H2, log scale of ms) no index (table scan) 49.991 ms index on customer_id 0.409 ms Speed-up 122x (49.99 / 0.409). Scan examined 500,001 rows ("scanCount: 500001"); index examined 251. Cost: CREATE INDEX took 1,719 ms on 500k rows and every later INSERT/UPDATE of customer_id maintains it. Bars drawn on a log-ish scale so the small one is visible; read the printed values.

H2 2.2.224 in-process, 500,000 rows, customer 777 has 250 rows.

How a B-tree finds a key
B-tree index on orders(customer_id): find 777 in 3-4 page reads root page [ 500 | 1000 | 1500 ] internal page [ 100 | 250 | 400 ] internal page [ 600 | 777 | 900 ] internal page [ 1100 | 1300 ] leaf pages sorted keys leaf: key 777 -> row pointers leaf pages sorted keys Leaves are linked: range scans and ORDER BY ... LIMIT walk neighbouring leaves. Depth ~ log(rows).

Mechanism diagram; page layouts vary by engine.

BeforeAfter
Rows examined500,001251
Time per query49.99 ms0.409 ms (122x)
Cost to build-1.7 s on 500k rows; extra work on each write
Composite, covering and partial indexes - what to remember
IdeaRuleExample for OrderService
Composite orderEquality columns first, then range / sort columns; an index on (a, b) serves queries on a and on (a, b), not on b alone(customer_id, created_at DESC) for "latest orders of a customer"
Covering indexInclude the selected columns so the engine never touches the table (PostgreSQL 11+: INCLUDE (status, total); look for "Index Only Scan")List screens that show only id/status/total
Partial indexIndex only the rows you query (PostgreSQL, SQL Server)WHERE status = 'OPEN' when 1 % of orders are open
SelectivityAn index on a low-cardinality column (status, boolean) rarely helps aloneDo not index status by itself
SargabilityWrapping the column in a function or mismatched type defeats the indexWHERE lower(email) = ? needs a functional index; WHERE customer_id = '777' with an implicit cast may scan

Honest surprise from our run: we also created a three-column index (customer_id, created, id) for ORDER BY created DESC LIMIT 10. H2's planner kept using the single-column index, and the timing barely moved (0.494 ms vs 0.444 ms) because each customer has only 250 rows to sort. Composite indexes pay off when the sorted slice is large; never assume - read the plan.

⚠ Production gotchas: indexes
  • Indexing everything. Each index slows inserts/updates and uses memory; drop unused ones (pg_stat_user_indexes.idx_scan = 0).
  • Stale statistics. After a bulk load the planner may still think the table is empty and choose a poor plan: run ANALYZE (PostgreSQL autovacuum usually does).
  • Foreign keys without indexes also make DELETE on the parent scan the child table and can cause lock waits.
  • Testing on small data. A scan on 1,000 rows takes a microsecond; the missing index only shows at production volume. Load realistic row counts in a staging database.
05
Chapter Five - Lab Three - Reading Query Plans

EXPLAIN ANALYZE, Estimated vs Actual

The plan is the database's explanation of how it will (or did) execute a statement. We showed H2's compact form in chapter 4. PostgreSQL, the usual production engine on RDS/Aurora, prints a richer tree. Everything in this chapter is typical PostgreSQL output written to teach the reading skill; it is illustrative, not captured from a run in this sandbox.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

After adding the index, one report query ("orders of customers in a region") is still slow, and it is not obvious why: the index exists.

Step 2 · Hypothesis

The planner chose a sequential scan or a bad join order because its row estimates are wrong.

Step 3 · Measurement
psql - run the statement and see reality, not just the estimate
EXPLAIN (ANALYZE, BUFFERS) SELECT o.id, o.total FROM orders o JOIN customers c ON c.id = o.customer_id WHERE c.region = 'EU' AND o.created_at > now() - interval '7 days';

ANALYZE actually executes the statement (wrap writes in a transaction you roll back), so never run it on a destructive statement without care. Plain EXPLAIN shows only the estimate.

Step 4 · Diagnosis
typical PostgreSQL output (illustrative)
Hash Join (cost=212.40..11890.12 rows=250 width=16) (actual time=2.101..41.690 rows=25000 loops=1) Hash Cond: (o.customer_id = c.id) Buffers: shared hit=3010 read=612 -> Seq Scan on orders o (cost=0.00..11034.00 rows=250 width=24) (actual time=0.031..38.412 rows=25000 loops=1) Filter: (created_at > (now() - '7 days'::interval)) Rows Removed by Filter: 475000 Buffers: shared hit=2400 read=612 -> Hash (cost=190.20..190.20 rows=1 width=8) (actual time=0.9..0.9 rows=1 loops=1) -> Index Scan using idx_customers_region on customers c (cost=0.28..190.20 rows=1 width=8) (actual rows=1) Index Cond: (region = 'EU'::text) Planning Time: 0.31 ms Execution Time: 41.9 ms
The tree, annotated
Reading an EXPLAIN plan tree (typical PostgreSQL shape - ILLUSTRATIVE) Hash Join actual time=2.1..41.7 rows=25000 loops=1 Seq Scan on orders o est rows=250 ACTUAL rows=25000 <- misestimate Hash (Index Scan on customers c) est rows=1 actual rows=1 loops=1 Read bottom-up: leaves produce rows, parents combine them. Compare "est" (planner guess) with "actual": a 100x gap means stale statistics or correlated columns - the plan was chosen for a different table. loops=N means the node ran N times: Nested Loop with an inner Seq Scan x1000 loops is the N+1 of query plans. Buffers: shared hit = pages found in memory; read = fetched from storage (slow). Rows Removed by Filter = wasted work. Numbers in this diagram are an illustration of a typical plan, not output from a database run here.

Illustrative diagram of the output above.

What you seeWhat it meansWhat to do
Seq Scan + big Rows Removed by FilterReading the table to throw rows awayIndex on the filter column(s)
estimated rows 250, actual 25000Planner under-estimates 100x; chose a plan for 250 rowsANALYZE; extended statistics for correlated columns; rewrite the predicate
Nested Loop with inner scan, loops=25000Right for few outer rows, disastrous for manyGive the inner side an index or let the planner pick Hash Join (fix the estimate)
Buffers: read highData not in cache: storage latencyLarger instance memory, better indexes, hot/cold data split
Sort Method: external merge DiskSort spilled to diskIndex that provides order; raise work_mem for the session carefully
Index Only Scan with Heap Fetches lowCovering index worksKeep the table vacuumed
Step 5 · Optimization

For this plan: create (created_at) (or a partial/composite index matching the filter) and refresh statistics. One change, re-run EXPLAIN (ANALYZE, BUFFERS), confirm the node became an Index Scan/Bitmap Heap Scan and Rows Removed by Filter fell to zero.

SQL
CREATE INDEX CONCURRENTLY idx_orders_created ON orders(created_at); ANALYZE orders;
Step 6 · Verification

Compare Execution Time and Buffers before and after, on the same data and a warmed cache (run twice; the first run pays cold-cache reads). Record both in your pull request. We do not quote a speed-up here because no PostgreSQL ran in this lab.

⚠ Production gotchas: plans
  • Tuning on an empty dev database. The planner picks different plans at different sizes; use a production-sized copy with pg_dump-restored statistics or realistic generated data.
  • Parameter sniffing / generic plans. A prepared statement reused with a skewed parameter (customer_id with 100,000 orders vs 3) can run a plan that suits only one of them. Compare the plan for the slow parameter value specifically.
  • auto_explain (auto_explain.log_min_duration) captures plans of slow statements in production without you reproducing them.
  • Reading only cost. It is a unitless estimate. Trust actual time and rows.
06
Chapter Six - Lab Four - Pagination and Batch Writes

OFFSET vs Keyset, Batch Inserts

Two more request-shaping patterns that are pure access-pattern changes: how you page through big result sets and how you write many rows.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

"Page 5,000" of the order list takes seconds while page 1 is instant, and the nightly import of 480,000 orders runs for an hour.

Step 2 · Hypothesis

OFFSET n forces the database to produce and discard the first n rows, so cost grows with depth. Row-by-row inserts pay per-statement and per-commit overhead 480,000 times.

Step 3 · Measurement

DbLab2.java (chapter 10) times ORDER BY id LIMIT 20 OFFSET n against WHERE id > :last ORDER BY id LIMIT 20 at several depths, and inserts rows with and without batching/transactions.

Step 4 · Diagnosis
real output
insert 20,000 rows, one statement each, autocommit : 980 ms ( 49.0 us/row) insert 480,000 rows, batches of 1000, 1 tx : 2549 ms ( 5.3 us/row) OFFSET 0 LIMIT 20 : 0.027 ms OFFSET 1,000 LIMIT 20 : 0.172 ms OFFSET 100,000 LIMIT 20 : 12.065 ms OFFSET 400,000 LIMIT 20 : 44.977 ms keyset id > 0 LIMIT 20 : 0.020 ms keyset id > 1,000 LIMIT 20 : 0.016 ms keyset id > 100,000 LIMIT 20 : 0.016 ms keyset id > 400,000 LIMIT 20 : 0.015 ms

OFFSET cost grows linearly with depth (0.17 ms at 1,000, 45 ms at 400,000: about 3,000x worse than the keyset query at the same position). Keyset stays flat because the primary-key index jumps straight to id > last.

Step 5 · Optimization
Java / SQL - keyset pagination and JDBC batching
// keyset: the client sends the last id (or (created_at, id) pair) it saw @Query("select new com.example.OrderSummary(o.id, o.status, o.total) from Order o " + "where o.id > :lastId order by o.id") List<OrderSummary> next(long lastId, Pageable page); // Pageable only carries LIMIT, not OFFSET # application.yml: Hibernate JDBC batching spring.jpa.properties.hibernate.jdbc.batch_size: 50 spring.jpa.properties.hibernate.order_inserts: true spring.jpa.properties.hibernate.order_updates: true # PostgreSQL driver: rewrite batches into multi-row INSERTs # jdbc url parameter: reWriteBatchedInserts=true

Hibernate cannot batch inserts for entities whose id is GenerationType.IDENTITY (it needs the generated key after each insert); use a sequence with allocationSize > 1 for write-heavy tables. For very large imports use PostgreSQL COPY or a bulk loader instead of ORM entities.

Step 6 · Verification
OFFSET vs keyset (measured)
Latency of "next page" vs how deep you are (500,000 rows, page size 20) 0 1k 100k 400k OFFSET value / id of last row seen (log-ish x axis) 0 10 20 30 40 50 ms OFFSET 400,000: 44.98 ms 12.07 ms keyset: 0.015-0.020 ms at every depth

H2 2.2.224, 500,000 rows, page size 20; averages over 20-2,000 repetitions.

Insert throughput (measured)
Insert 480,000 rows: per-row autocommit vs batched (us per row, lower is better) one statement per row, autocommit 49.0 us/row batches of 1000, one transaction 5.3 us/row In-process H2: no network and no fsync per commit. On a real server each autocommit adds a round trip and a WAL flush, so the real gap is far larger than the 9x measured here. Direction and mechanism are the point.

In-process H2; real servers add round trips and WAL flushes per commit.

TechniqueCost shapeTrade-off
OFFSET paginationO(offset): 45 ms at 400kRandom page access, simple UI
Keyset paginationO(1) (index seek): 0.015 msNo "jump to page 5,000"; needs a stable total order (add id as tie-breaker)
Per-row autocommit insert49 us/row (in-process)Simplest
Batched, one transaction5.3 us/row (9x)Bigger transactions: lock hold time, memory, all-or-nothing
⚠ Production gotchas: paging and bulk writes
  • Counting for "page X of Y". count(*) on a large table scans too. Use "load more", an approximate count, or a cached count.
  • Keyset without a unique tie-breaker skips or repeats rows with equal sort keys: order by (created_at, id) and compare the pair.
  • Huge batches in one transaction hold locks and bloat the WAL/undo; use batches of a few thousand and commit between.
  • Measuring inserts in-process only. The commit (fsync to EBS / Aurora storage quorum) is where real servers spend time; test against the real engine.
07
Chapter Seven - Lab Five - Connection Pools, Locks and Long Transactions

HikariCP: Bigger Is Not Faster

HikariCP (Spring Boot's default pool) limits how many statements run at once from one JVM. Its size is the single most mis-tuned database setting, usually in the wrong direction ("bigger is better"). This lab shows why not, using the real library.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

Under load, OrderService starts failing with a wall of exceptions and latency jumps from milliseconds to 30 seconds. The database CPU graph is only 40 %.

Step 2 · Hypothesis

Two competing theories: the pool is too small (requests wait for connections), or it is too large and the real limit is the database. They need opposite fixes, so we measure with a model of a database that has a hard capacity.

Step 3 · Measurement

PoolLab.java runs HikariCP 5.1.0 over H2 with 200 closed-loop clients. Each "query" holds a connection for 20 ms, taking one of 8 permits (8 DB cores) for that time, so the database can do at most 8 / 0.020 = 400 queries/s however many connections are open. We vary maximumPoolSize only.

shell
for s in 4 8 16 50 200; do java -cp out:$CP PoolLab size $s 6; done java -cp out:$CP PoolLab exhaust # the exception text
Step 4 · Diagnosis
real output
pool= 4 throughput= 187 q/s avg= 327.1 ms (pool wait 305.9 + in-db 21.2) p50= 20.7 p99=5246.0 ms pool= 8 throughput= 387 q/s avg= 296.4 ms (pool wait 275.8 + in-db 20.6) p50= 20.3 p99=4021.6 ms pool= 16 throughput= 396 q/s avg= 237.1 ms (pool wait 197.1 + in-db 40.0) p50= 20.5 p99=4698.8 ms pool= 50 throughput= 371 q/s avg= 291.3 ms (pool wait 172.8 + in-db 118.5) p50= 21.3 p99=4574.3 ms pool=200 throughput= 375 q/s avg= 338.5 ms (pool wait 51.4 + in-db 287.1) p50= 20.9 p99=3948.8 ms
Throughput saturates where the database does
Throughput vs HikariCP pool size (200 closed-loop clients, DB capacity 400 queries/s) 0 100 200 300 400 dashed = DB capacity: 8 cores / 20 ms = 400 q/s 187 q/s pool 4 theory 200 387 q/s pool 8 theory 400 396 q/s pool 16 theory 400 371 q/s pool 50 theory 400 375 q/s pool 200 theory 400 Theory = min(pool / 0.020 s, 400). Measured 93-99 % of it. Beyond pool 8 the DB is the limit; extra connections add nothing.

Measured; closed loop with 200 clients; the database is simulated as 8 permits x 20 ms.

Throughput scales with the pool until the database saturates: pool 4 gives 187 q/s (theory 200), pool 8 gives 387 (theory 400). Beyond that, pool 16, 50 and 200 give 396, 371 and 375 - no gain. The extra connections just queue inside the database:

The queue moves, it does not disappear
Where a request's average time goes as the pool grows (completed requests, ms) pool 4 wait 306 + in-DB 21 pool 8 wait 276 + in-DB 21 pool 16 wait 197 + in-DB 40 pool 50 wait 173 + in-DB 118 pool 200 wait 51 + in-DB 287 waiting for a Hikari connection inside the database (queueing for a core) Bigger pool: the wait moves from the pool into the database. Same total, more load on the DB and worse tail.

Averages over completed requests (see the caveat below on starvation).

Step 5 · Optimization

Size the pool from the database, not from the incoming traffic. The HikariCP maintainers' guidance (from their "About Pool Sizing" article, based on PostgreSQL experience) is a small starting point, then measure:

rules of thumb
pool_size ~ (database_cores x 2) + effective_spindle_count # HikariCP wiki starting point; tune by measuring total connections = instances x maximumPoolSize <= max_connections - reserve (admin, replicas, migrations) max throughput from one pool = pool_size / average_connection_hold_time
application.yml
spring: datasource: hikari: maximum-pool-size: 20 # default 10; size from DB cores and hold time minimum-idle: 20 # fixed-size pool: avoids resize storms (Hikari advice) connection-timeout: 2000 # fail fast: default is 30000 ms max-lifetime: 1500000 # shorter than DB/LB idle limits; default 1,800,000 leak-detection-threshold: 20000 # log a stack trace if a connection is held > 20 s
Step 6 · Verification
Pool sizeThroughputAvg pool waitAvg in-DB timeVerdict
4187 q/s306 ms21 mspool-limited
8387 q/s276 ms21 mssaturates DB: right-sized
16396 q/s197 ms40 msno gain
50371 q/s173 ms119 msDB queue grows
200375 q/s51 ms287 msall queueing inside the DB

Caveat from the run: p50 was about 20 ms in every configuration while p99 was 4-5 seconds. With 200 closed-loop clients competing for few connections some callers repeatedly lose the race (Hikari does not guarantee FIFO hand-off), so averages over completed requests understate the pain of the starved ones. In production this shows up as a long tail, not a slow median - exactly why you watch p99 and hikaricp.connections.acquire, not averages.

What exhaustion looks like - real exception text
real output - pool of 2, two slow requests hold both, a third asks (connection-timeout 1000 ms)
java.sql.SQLTransientConnectionException: orders-pool - Connection is not available, request timed out after 1000ms (total=2, active=2, idle=0, waiting=0) pool state at that moment: active=2 idle=0 waiting=0 total=2

In production the default timeout is 30 seconds, so every request thread waits half a minute for a connection and then fails with SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms (Hikari 5 appends the pool counters in parentheses, as shown). All Tomcat workers pile up waiting, and the service looks dead although CPU is low - the earlier chapters' queue, relocated. Metrics to alert on (Micrometer, with Boot's Hikari binding): hikaricp.connections.pending > 0 for seconds, hikaricp.connections.acquire p99, hikaricp.connections.timeout (count), hikaricp.connections.active = max.

Long transactions: the hidden pool hog
Hold time, not pool size, sets throughput
A transaction that holds a connection across a remote call (illustrative arithmetic) DB connection HTTP call to PaymentGateway (300 ms) connection leased from begin to commit = ~400 ms, of which ~100 ms is database work Little's Law on the pool: max transactions/s = pool size / hold time = 10 / 0.4 s = 25 per second. Move the remote call outside the transaction (hold ~0.1 s): 10 / 0.1 = 100 per second with the same pool - 4x. The pool never needed to be bigger; the transaction needed to be shorter. Durations in this diagram are example values chosen for the arithmetic; the formula, not the numbers, is the lesson.

Arithmetic illustration with example durations.

A @Transactional method that calls the PaymentGateway holds a connection for the whole HTTP call. Combined with open-in-view the connection is held through response rendering too. Diagnose in the database: select state, wait_event_type, now() - xact_start, query from pg_stat_activity where state = 'idle in transaction' (a session that opened a transaction and is doing nothing = application code waiting on something else). Guard with idle_in_transaction_session_timeout, keep remote calls outside transactions (outbox pattern, see Transactions), and lock in a consistent order to avoid deadlocks (ERROR: deadlock detected). For hot rows prefer optimistic locking (@Version) or SELECT ... FOR UPDATE SKIP LOCKED for work queues.

⚠ Production gotchas: pools
  • Pool per instance times instances. 20 pods x pool 50 = 1,000 connections against an Aurora/RDS instance whose max_connections may be a few hundred (the RDS default is derived from instance memory - check the parameter group). Use RDS Proxy or PgBouncer to multiplex, or shrink the pool.
  • Raising the pool to "fix" timeouts. If the database is the bottleneck, it increases the queue inside it and makes everything slower (our 50/200 rows).
  • Virtual threads. They remove the request-pool limit and so increase pressure on this pool: set connection-timeout and a bulkhead, see Spring, HTTP & Network.
  • Connection leaks. A connection never returned drains the pool silently; leak-detection-threshold logs the stack trace of the leaker.
08
Chapter Eight - Lab Six - Read Replicas and Seeing Inside Aurora/RDS

Scaling Reads, pg_stat_statements, Database Insights

Once statements and pool are in order, the remaining levers are scaling reads out, and - on AWS - seeing inside the managed database. This chapter is explanation and how-to: nothing in it was measured here (no AWS access, no PostgreSQL).

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

Report endpoints and list screens (95 % reads) share the writer with order placement. During business hours the writer's CPU is 85 %, and a report run makes checkout slow.

Step 2 · Hypothesis

Read traffic can move to replicas, if the data can tolerate replication lag, leaving the writer for transactions.

Step 3 · Measurement

Before redesigning, quantify the read share and the lag tolerance: statements by type in pg_stat_statements, CloudWatch CPUUtilization, DatabaseConnections, ReadIOPS/WriteIOPS, and for replicas ReplicaLag (RDS) / AuroraReplicaLag (Aurora).

Step 4 · Diagnosis
Database load by wait type
Database load in Performance Insights / Database Insights style (ILLUSTRATIVE shape) max vCPU (e.g. 8) CPU IO wait (storage) Lock wait 8 0 AAS time (10 buckets) AAS (average active sessions) above the vCPU line = sessions queueing for CPU. Colours show WHAT they wait on. Synthetic chart to teach the reading; not an export from a real instance.

Illustrative.

RDS Performance Insights plots DB load as average active sessions (AAS) against the instance's vCPU count and breaks it down by wait event, SQL statement, user and host. Performance Insights is being superseded by CloudWatch Database Insights (check the current AWS documentation for modes, dates and availability); the reading skill is the same: AAS above vCPU means sessions are waiting; the dominant colour tells you whether to look at SQL (CPU), storage/indexes (IO), or transactions (Lock).

typical pg_stat_statements query (PostgreSQL 13+ column names; illustrative output shape)
SELECT calls, round(total_exec_time::numeric, 1) AS total_ms, round(mean_exec_time::numeric, 2) AS mean_ms, rows, left(query, 70) AS query FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 5; calls | total_ms | mean_ms | rows | query --------+----------+---------+--------+---------------------------------------------------------------------- 912040 | 842113.2 | 0.92 | 912040 | select ... from order_items where order_id = $1 <- N+1: tiny mean, huge calls 8112 | 301442.7 | 37.16 | 2.0e6 | select ... from orders where customer_id = $1 <- missing index: big mean 240 | 52210.0 | 217.54 | 240 | select ... from orders o join customers c ... <- report query

Sort by total time, not mean: a 0.9 ms statement called a million times beats one 200 ms report. That one ordering is how you separate "N+1" (calls huge, mean tiny) from "missing index" (mean big) at a glance. The extension must be in shared_preload_libraries; on RDS/Aurora enable it in the parameter group.

Step 5 · Optimization

Route read-only transactions to a replica. In Spring, the usual pattern is a routing DataSource keyed on the current transaction's read-only flag:

Java - route @Transactional(readOnly = true) to the reader endpoint
class ReadWriteRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "reader" : "writer"; } } @Bean DataSource dataSource(DataSource writer, DataSource reader) { ReadWriteRoutingDataSource routing = new ReadWriteRoutingDataSource(); routing.setTargetDataSources(Map.of("writer", writer, "reader", reader)); routing.setDefaultTargetDataSource(writer); routing.afterPropertiesSet(); return new LazyConnectionDataSourceProxy(routing); // delays connection checkout until the first statement, } // so the read-only flag is known when routing happens

Two cautions: the flag is set when the transaction starts (so LazyConnectionDataSourceProxy or equivalent is required), and replica lag means "read your own write" flows must use the writer. Other levers: add a cache (page 09) before adding replicas - it is cheaper per request.

Step 6 · Verification

Check writer CPU / AAS drop and reader load rise by the expected share, AuroraReplicaLag stays within your tolerance during the peak hour, and no endpoint returns stale data it must not. No numbers are claimed here; run it against your cluster.

Read replica topology
Read replicas: scale reads, accept lag (Aurora-style topology, illustrative) OrderService readOnly routing Writer endpoint cluster.cluster-xxx... Reader endpoint cluster.cluster-ro-xxx... Writer instance writes + strong reads Reader 1 replica lag? Reader 2 replica lag? replication Read-your-writes: a user who just placed an order and is routed to a lagging reader sees "no such order". Route by need: transactional / just-written reads to the writer, reports and lists to readers; watch AuroraReplicaLag. Topology illustration; lag values are workload dependent and were not measured here.

Illustrative.

Slow-query logging and other switches
ToolWhat it givesHow
log_min_duration_statement (PostgreSQL)Logs every statement slower than N msRDS parameter group, e.g. 500; ship logs to CloudWatch
auto_explainLogs the actual plan of slow statementsauto_explain.log_min_duration; use log_analyze carefully (overhead)
MySQL slow query loglong_query_time, slow_query_log=1RDS MySQL parameter group
statement_timeout, lock_timeoutKill runaway statements and long lock waitsPer role/database; protects the pool
CloudWatch RDS metricsCPUUtilization, DatabaseConnections, FreeableMemory, ReadLatency/WriteLatency, DiskQueueDepthEnhanced Monitoring for OS-level view
RDS Proxy / PgBouncerConnection multiplexing for many small clientsWhen instances x pool exceeds max_connections
⚠ Production gotchas: scaling reads and watching the database
  • Replica lag surprise. A user is redirected after "create order" to a list page served by a reader that has not seen it yet. Pin to the writer for a short window after writes or read from the writer for that user.
  • Long queries on replicas can conflict with replication (PostgreSQL hot standby cancels queries: "canceling statement due to conflict with recovery").
  • Failover changes endpoints. DNS TTL and connection lifetime decide how quickly pools follow (see the DNS note on the Spring page).
  • Sampling blindness. Performance Insights is sampled; very short statements can be under-represented. Cross-check with pg_stat_statements.
09
Chapter Nine - Try It Yourself

The Complete Programs

Everything measured on this page came from these programs, run in a scratch directory outside the repository. Prerequisites: Java 21; h2-2.2.224.jar (Maven Central com.h2database:h2:2.2.224); HikariCP 5.1.0 and slf4j-api 2.0.x for the pool lab.

shell
javac -cp h2-2.2.224.jar -d out DbLab.java DbLab2.java javac -cp h2-2.2.224.jar:HikariCP-5.1.0.jar:slf4j-api-2.0.16.jar -d out PoolLab.java java -cp out:h2-2.2.224.jar DbLab 0.5 index # N+1 vs batched vs join, 0.5 ms simulated RTT java -cp out:h2-2.2.224.jar DbLab2 # index, plans, paging, batch inserts java -cp out:h2-2.2.224.jar:HikariCP-5.1.0.jar:slf4j-api-2.0.16.jar PoolLab size 16 6
DbLab.java - N+1 vs batched vs join
Java - ran in chapter 3
import java.sql.*; import java.util.*; /** * Lab 8: OrderService data access against an embedded in-process H2 database (version 2.2.224). * Every statement below is issued exactly as an ORM would issue it. The "network" between app and * database on a real deployment (~0.5 ms same-AZ round trip) is NOT present in-process, so we add it * explicitly with a configurable per-statement sleep and say so in the output. */ public class DbLab { static final int CUSTOMERS = 2_000, ORDERS = 200_000, ITEMS_PER_ORDER = 3; static boolean WITH_INDEX; static long rtNanos; // simulated network round trip per statement static void rt() { if (rtNanos > 0) { long t = System.nanoTime() + rtNanos; while (System.nanoTime() < t) Thread.onSpinWait(); } } public static void main(String[] a) throws Exception { double rtMs = Double.parseDouble(a[0]); rtNanos = (long) (rtMs * 1e6); WITH_INDEX = a.length > 1 && a[1].equals("index"); try (Connection c = DriverManager.getConnection("jdbc:h2:mem:orders;DB_CLOSE_DELAY=-1;OPTIMIZE_REUSE_RESULTS=0")) { seed(c); System.out.printf("== orders.customer_id index: %s | simulated network round trip per statement: %.1f ms ==%n", WITH_INDEX ? "YES" : "no", rtMs); long customer = 777; // ---- 1. N+1: one query for the orders, then one per order for its items for (int i = 0; i < 20; i++) nPlusOne(c, customer); // warm-up long t0 = System.nanoTime(); int stmts = 0; for (int i = 0; i < 100; i++) stmts = nPlusOne(c, customer); double nPlus1 = (System.nanoTime() - t0) / 1e6 / 100; System.out.printf("N+1 : %7.2f ms/request, %3d statements%n", nPlus1, stmts); // ---- 2. batch: IN (...) for items (what @BatchSize / Hibernate default_batch_fetch_size does) for (int i = 0; i < 20; i++) batched(c, customer); t0 = System.nanoTime(); for (int i = 0; i < 100; i++) stmts = batched(c, customer); double batch = (System.nanoTime() - t0) / 1e6 / 100; System.out.printf("batched IN : %7.2f ms/request, %3d statements%n", batch, stmts); // ---- 3. one join (join fetch / EntityGraph) for (int i = 0; i < 20; i++) joined(c, customer); t0 = System.nanoTime(); for (int i = 0; i < 100; i++) stmts = joined(c, customer); double join = (System.nanoTime() - t0) / 1e6 / 100; System.out.printf("single join : %7.2f ms/request, %3d statements%n", join, stmts); } } static void seed(Connection c) throws SQLException { try (Statement s = c.createStatement()) { s.execute("CREATE TABLE orders(id BIGINT PRIMARY KEY, customer_id BIGINT NOT NULL, status VARCHAR(12), created TIMESTAMP, total DECIMAL(10,2))"); s.execute("CREATE TABLE order_items(id BIGINT PRIMARY KEY, order_id BIGINT NOT NULL, sku VARCHAR(20), qty INT)"); s.execute("CREATE INDEX idx_items_order ON order_items(order_id)"); // FK index on items exists; orders.customer_id has none (yet) } c.setAutoCommit(false); try (PreparedStatement po = c.prepareStatement("INSERT INTO orders VALUES (?,?,?,CURRENT_TIMESTAMP,?)"); PreparedStatement pi = c.prepareStatement("INSERT INTO order_items VALUES (?,?,?,?)")) { long item = 0; for (long o = 0; o < ORDERS; o++) { po.setLong(1, o); po.setLong(2, o % CUSTOMERS); po.setString(3, "PAID"); po.setBigDecimal(4, java.math.BigDecimal.valueOf(10 + o % 90)); po.addBatch(); for (int k = 0; k < ITEMS_PER_ORDER; k++) { pi.setLong(1, item++); pi.setLong(2, o); pi.setString(3, "SKU-" + k); pi.setInt(4, 1 + k); pi.addBatch(); } if (o % 5000 == 4999) { po.executeBatch(); pi.executeBatch(); } } c.commit(); } c.setAutoCommit(true); if (WITH_INDEX) try (Statement s = c.createStatement()) { s.execute("CREATE INDEX idx_orders_customer ON orders(customer_id)"); } } static int nPlusOne(Connection c, long customer) throws SQLException { int stmts = 0, rows = 0; List<Long> ids = new ArrayList<>(); rt(); stmts++; try (PreparedStatement p = c.prepareStatement("SELECT id, status, total FROM orders WHERE customer_id = ?")) { p.setLong(1, customer); try (ResultSet r = p.executeQuery()) { while (r.next()) ids.add(r.getLong(1)); } } for (long id : ids) { rt(); stmts++; try (PreparedStatement p = c.prepareStatement("SELECT sku, qty FROM order_items WHERE order_id = ?")) { p.setLong(1, id); try (ResultSet r = p.executeQuery()) { while (r.next()) rows++; } } } return stmts; } static int batched(Connection c, long customer) throws SQLException { int stmts = 0; List<Long> ids = new ArrayList<>(); rt(); stmts++; try (PreparedStatement p = c.prepareStatement("SELECT id, status, total FROM orders WHERE customer_id = ?")) { p.setLong(1, customer); try (ResultSet r = p.executeQuery()) { while (r.next()) ids.add(r.getLong(1)); } } for (int from = 0; from < ids.size(); from += 100) { // batch size 100 List<Long> chunk = ids.subList(from, Math.min(ids.size(), from + 100)); String in = String.join(",", Collections.nCopies(chunk.size(), "?")); rt(); stmts++; try (PreparedStatement p = c.prepareStatement("SELECT order_id, sku, qty FROM order_items WHERE order_id IN (" + in + ")")) { for (int i = 0; i < chunk.size(); i++) p.setLong(i + 1, chunk.get(i)); try (ResultSet r = p.executeQuery()) { while (r.next()) { } } } } return stmts; } static int joined(Connection c, long customer) throws SQLException { rt(); try (PreparedStatement p = c.prepareStatement( "SELECT o.id, o.status, o.total, i.sku, i.qty FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.customer_id = ?")) { p.setLong(1, customer); try (ResultSet r = p.executeQuery()) { while (r.next()) { } } } return 1; } }
DbLab2.java - indexes, plans, pagination, batch inserts
Java - ran in chapters 4 and 6
import java.sql.*; import java.util.*; /** Lab 8b: index vs no index, EXPLAIN ANALYZE, OFFSET vs keyset pagination, batch insert. H2 2.2.224, in-process. */ public class DbLab2 { static final int CUSTOMERS = 2_000, ORDERS = 500_000; public static void main(String[] a) throws Exception { try (Connection c = DriverManager.getConnection("jdbc:h2:mem:o2;DB_CLOSE_DELAY=-1;OPTIMIZE_REUSE_RESULTS=0")) { try (Statement s = c.createStatement()) { s.execute("CREATE TABLE orders(id BIGINT PRIMARY KEY, customer_id BIGINT NOT NULL, status VARCHAR(12), created TIMESTAMP, total DECIMAL(10,2))"); } // ---- batch insert: row-by-row autocommit vs one transaction with addBatch long t0 = System.nanoTime(); c.setAutoCommit(true); try (PreparedStatement p = c.prepareStatement("INSERT INTO orders VALUES (?,?,?,CURRENT_TIMESTAMP,?)")) { for (long o = 0; o < 20_000; o++) { bind(p, o); p.executeUpdate(); } } double single = (System.nanoTime() - t0) / 1e6; c.setAutoCommit(false); t0 = System.nanoTime(); try (PreparedStatement p = c.prepareStatement("INSERT INTO orders VALUES (?,?,?,CURRENT_TIMESTAMP,?)")) { for (long o = 20_000; o < ORDERS; o++) { bind(p, o); p.addBatch(); if (o % 1000 == 999) p.executeBatch(); } p.executeBatch(); } c.commit(); c.setAutoCommit(true); double batch = (System.nanoTime() - t0) / 1e6; System.out.printf("insert 20,000 rows, one statement each, autocommit : %8.0f ms (%6.1f us/row)%n", single, single * 1000 / 20_000); System.out.printf("insert %,d rows, batches of 1000, 1 tx : %8.0f ms (%6.1f us/row)%n", ORDERS - 20_000, batch, batch * 1000 / (ORDERS - 20_000)); System.out.println("(in-process: no network and no fsync per commit; on a real server each autocommit adds a round trip + WAL flush)"); System.out.println(); String q = "SELECT id, status, total FROM orders WHERE customer_id = 777"; System.out.println("-- Q: " + q); explain(c, "EXPLAIN ANALYZE " + q); System.out.printf("no index : %8.3f ms/query%n", time(c, q, 50)); t0 = System.nanoTime(); try (Statement s = c.createStatement()) { s.execute("CREATE INDEX idx_orders_customer ON orders(customer_id)"); } System.out.printf("CREATE INDEX on %,d rows took %.0f ms (writes pay this price forever after)%n", ORDERS, (System.nanoTime() - t0) / 1e6); explain(c, "EXPLAIN ANALYZE " + q); System.out.printf("with index : %8.3f ms/query%n", time(c, q, 2000)); // ---- composite index and ORDER BY ... LIMIT String q2 = "SELECT id, status, total FROM orders WHERE customer_id = 777 ORDER BY created DESC, id DESC LIMIT 10"; System.out.printf("%n-- Q2: %s%n", q2); System.out.printf("index on (customer_id) only : %8.3f ms/query (sorts the customer's rows)%n", time(c, q2, 2000)); try (Statement s = c.createStatement()) { s.execute("CREATE INDEX idx_orders_cust_created ON orders(customer_id, created DESC, id DESC)"); } explain(c, "EXPLAIN ANALYZE " + q2); System.out.printf("index on (customer_id, created, id) : %8.3f ms/query%n", time(c, q2, 2000)); // ---- pagination: OFFSET vs keyset on the primary key System.out.println(); for (long off : new long[]{0, 1_000, 100_000, 400_000}) { String sql = "SELECT id, status, total FROM orders ORDER BY id LIMIT 20 OFFSET " + off; System.out.printf("OFFSET %,8d LIMIT 20 : %8.3f ms%n", off, time(c, sql, off > 100_000 ? 20 : 200)); } for (long after : new long[]{0, 1_000, 100_000, 400_000}) { String sql = "SELECT id, status, total FROM orders WHERE id > " + after + " ORDER BY id LIMIT 20"; System.out.printf("keyset id > %,8d LIMIT 20 : %8.3f ms%n", after, time(c, sql, 2000)); } } } static void bind(PreparedStatement p, long o) throws SQLException { p.setLong(1, o); p.setLong(2, o % CUSTOMERS); p.setString(3, "PAID"); p.setBigDecimal(4, java.math.BigDecimal.valueOf(10 + o % 90)); } static double time(Connection c, String sql, int reps) throws SQLException { for (int i = 0; i < Math.min(reps, 30); i++) run(c, sql); long t0 = System.nanoTime(); for (int i = 0; i < reps; i++) run(c, sql); return (System.nanoTime() - t0) / 1e6 / reps; } static int run(Connection c, String sql) throws SQLException { int n = 0; try (Statement s = c.createStatement(); ResultSet r = s.executeQuery(sql)) { while (r.next()) n++; } return n; } static void explain(Connection c, String sql) throws SQLException { try (Statement s = c.createStatement(); ResultSet r = s.executeQuery(sql)) { while (r.next()) System.out.println(r.getString(1)); } } }
PoolLab.java - HikariCP sizing and exhaustion
Java - ran in chapter 7
import com.zaxxer.hikari.*; import java.sql.*; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; /** * Lab 8c: HikariCP 5.1.0 in front of an in-process H2. The database "server" is modelled as 8 cores: * each query holds one of 8 permits for 20 ms (DB CPU time). That gives the database a hard capacity of * 8 / 0.020 = 400 queries/s, like a real instance with 8 vCPUs, no matter how many connections are open. * java PoolLab size 50 5 -> maximumPoolSize=50, 5 s measurement * java PoolLab exhaust -> show the real Hikari exhaustion exception text */ public class PoolLab { static final Semaphore DB_CORES = new Semaphore(8); static HikariDataSource ds(int size, long timeoutMs) { HikariConfig cfg = new HikariConfig(); cfg.setJdbcUrl("jdbc:h2:mem:pool;DB_CLOSE_DELAY=-1"); cfg.setMaximumPoolSize(size); cfg.setMinimumIdle(size); cfg.setConnectionTimeout(timeoutMs); cfg.setPoolName("orders-pool"); return new HikariDataSource(cfg); } public static void main(String[] a) throws Exception { if (a[0].equals("exhaust")) { exhaust(); return; } int size = Integer.parseInt(a[1]), secs = Integer.parseInt(a[2]); int clients = 200; try (HikariDataSource ds = ds(size, 30_000)) { long warmEnd = System.nanoTime() + 1_500_000_000L, end = warmEnd + secs * 1_000_000_000L; ConcurrentLinkedQueue<long[]> samples = new ConcurrentLinkedQueue<>(); // {total, waitForConn, inDb} try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < clients; i++) ex.submit(() -> { while (System.nanoTime() < end) { long t0 = System.nanoTime(); try (Connection c = ds.getConnection()) { long t1 = System.nanoTime(); DB_CORES.acquire(); // queue for a DB core try { Thread.sleep(20); } finally { DB_CORES.release(); } // 20 ms of "query work" long t2 = System.nanoTime(); if (t0 >= warmEnd) samples.add(new long[]{t2 - t0, t1 - t0, t2 - t1}); } catch (Exception e) { } } return null; }); } long[][] s = samples.toArray(new long[0][]); long[] tot = Arrays.stream(s).mapToLong(x -> x[0]).sorted().toArray(); double avgWait = Arrays.stream(s).mapToLong(x -> x[1]).average().orElse(0) / 1e6; double avgDb = Arrays.stream(s).mapToLong(x -> x[2]).average().orElse(0) / 1e6; System.out.printf("pool=%3d throughput=%5.0f q/s avg=%6.1f ms (pool wait %6.1f + in-db %5.1f) p50=%6.1f p99=%6.1f ms%n", size, s.length / (double) secs, Arrays.stream(tot).average().orElse(0) / 1e6, avgWait, avgDb, tot[tot.length / 2] / 1e6, tot[(int) (tot.length * .99)] / 1e6); } System.exit(0); } static void exhaust() throws Exception { try (HikariDataSource ds = ds(2, 1000)) { CountDownLatch held = new CountDownLatch(2); ExecutorService ex = Executors.newCachedThreadPool(); for (int i = 0; i < 2; i++) ex.submit(() -> { // two slow requests hold both connections for 3 s try (Connection c = ds.getConnection()) { held.countDown(); Thread.sleep(3000); } catch (Exception e) { } return null; }); held.await(); try (Connection c = ds.getConnection()) { // the third request has to wait System.out.println("unexpected: got a connection"); } catch (SQLException e) { System.out.println(e.getClass().getName() + ": " + e.getMessage()); } HikariPoolMXBean mx = ds.getHikariPoolMXBean(); System.out.printf("pool state at that moment: active=%d idle=%d waiting=%d total=%d%n", mx.getActiveConnections(), mx.getIdleConnections(), mx.getThreadsAwaitingConnection(), mx.getTotalConnections()); ex.shutdownNow(); } System.exit(0); } }
Make it yours: Change CUSTOMERS/ORDERS to your table sizes, the simulated round trip to your real same-AZ RTT (measure it with a trivial query), and the DB core count and service time in PoolLab to your instance. The shape of each curve is the result; the absolute numbers are the machine's.
10
Chapter Ten - Interview Angles

Questions You Should Be Able to Answer

Scenario questions with the answers interviewers look for.

  • Q: An endpoint takes 800 ms and 650 ms of it is in the database. Walk me through your investigation.
    Count statements per request first (N+1 shows as 100+ statements of ~1 ms), then top statements by total time in pg_stat_statements / Database Insights, then EXPLAIN (ANALYZE, BUFFERS) on the worst one, then pool wait (hikaricp.connections.acquire/pending). Fix one thing, re-measure.
  • Q: What is the N+1 problem and how do you fix it in Hibernate?
    One query for parents plus one per parent for a lazy association. Fix with join fetch / @EntityGraph (1 statement, row multiplication, paging caveat), default_batch_fetch_size / @BatchSize (1 + N/size statements), or a DTO projection. We measured 101 vs 2 vs 1 statements: 51.8 / 1.8 / 0.84 ms with a 0.5 ms round trip.
  • Q: Why does N+1 not show up on a developer laptop?
    No network and tiny tables: statements cost microseconds. We saw 2.6 ms in-process vs 51.8 ms with a 0.5 ms RTT. Assert statement counts in tests.
  • Q: The query has an index but is still slow. Why?
    Possible reasons: the planner did not use it (stale statistics, function on the column, implicit cast, low selectivity), the index does not match the sort/filter, the statement returns too many rows, or the time is elsewhere (lock wait, pool wait). Read the plan, compare estimated vs actual rows.
  • Q: Composite index column order?
    Equality columns first, then range/sort. (a, b) serves a and (a, b) but not b alone. A covering index adds the selected columns so no table access is needed.
  • Q: Why is OFFSET pagination slow for deep pages?
    The database must produce and discard offset rows: we measured 45 ms at offset 400,000 vs 0.015 ms for keyset. Keyset needs a unique stable ordering and cannot jump to arbitrary pages.
  • Q: How big should the connection pool be?
    Small, derived from DB cores and hold time (starting point: cores x 2 + spindles), and total connections across all instances must fit max_connections. We saw throughput saturate at the DB capacity (pool 8 of 400 q/s) and larger pools only lengthen the queue inside the database.
  • Q: What does "Connection is not available, request timed out after 30000ms" mean?
    All pooled connections were leased for 30 s (the default connectionTimeout) while a thread waited. Causes: pool too small for the hold time, long transactions (remote calls inside @Transactional, open-in-view), leaks, or a slow database. Metrics: hikaricp.connections.pending, .active = max.
  • Q: How do you make a bulk import faster?
    JDBC batching (hibernate.jdbc.batch_size, sequence ids not IDENTITY, reWriteBatchedInserts for pgjdbc), larger transactions in sensible chunks, COPY for very large loads. We measured 49 us/row vs 5.3 us/row in-process.
  • Q: When would you add a read replica and what bites?
    When reads dominate and the data tolerates lag: reports, lists. Bites: replica lag (read-your-writes), query conflicts on hot standbys, routing complexity, failover endpoints. Consider caching first.
11
Chapter Eleven - Summary

Cheat Sheet and Recap

Symptom to fix cheat sheet
SymptomEvidenceFixMeasured here
100+ statements per requestHibernate statistics, trace spans, pg_stat_statements callsBatch fetch / join fetch / projection51.8 -> 1.8 -> 0.84 ms
One statement slow, big scanEXPLAIN: Seq Scan, Rows Removed by FilterIndex (check the plan changes)49.99 -> 0.409 ms (122x)
Deep pages slowLatency grows with OFFSETKeyset pagination45 ms -> 0.015 ms
Bulk write slowStatements/s, WAL, round tripsBatching, bigger tx chunks, COPY49 -> 5.3 us/row
Connection is not availablepending > 0, active = max, timeout counterShorter transactions, right-sized pool, RDS Proxysaturation at pool 8 of 400 q/s
Writer CPU high, reads dominateAAS > vCPU, read-heavy statementsCache, then replicasnot measured
Latency fine at p50, awful at p99Pool wait tail, lock waits, cold cache readsTimeouts, fairness, short txp99 4-5 s vs p50 20 ms in pool lab
∑ Database performance - what to remember
  • Attribute the budget first. If 81 % is database time, only database work can move the needle (Amdahl).
  • Statements per request is the stable metric. Wall-clock hides N+1 on fast networks.
  • Read the plan, trust actual rows. Estimated vs actual is the first thing to compare.
  • An index is a trade: 122x on reads here; 1.7 s build and extra write cost.
  • Keyset beats OFFSET for deep paging; batch writes beat row-by-row.
  • Pool size is set by the database and the hold time, not by traffic. Bigger pools queue inside the DB.
  • Keep transactions short: never hold a connection across a remote call.
  • On AWS: Performance Insights / Database Insights show AAS vs vCPU by wait type; pg_stat_statements sorted by total time finds N+1 and missing indexes. Those parts were not run here.
Related pages: