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.
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.
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.
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.
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.
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).
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).
One change per chapter: batch fetch, index, keyset pagination, batch writes, pool size.
Each chapter ends with a before/after table from the same harness. A final "Try it yourself" chapter contains the complete code.
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.
maximumPoolSize connections are leased, the thread waits (up to connectionTimeout, 30 s by default) and gets SQLTransientConnectionException when it expires.Structure diagram with the metric that exposes each stage; no measured values.
Judgement flow, not a measurement.
@EntityGraph) is covered in Spring Data JPA; transaction boundaries and propagation in Transactions. This page measures their performance consequences.
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.
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.
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.
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):
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.
Pick the fix by the access pattern. Each is one variable:
| Fix | How (Hibernate 6 / Spring Data JPA) | Statements | Watch out for |
|---|---|---|---|
| Join fetch | select o from Order o join fetch o.items where o.customerId = :id | 1 | Row 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 method | 1 | Same row multiplication; fetching two collections: MultipleBagFetchException: cannot simultaneously fetch multiple bags |
| Batch fetch | spring.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 projection | constructor expression or interface projection selecting only needed columns | 1 | Read-only, no entity lifecycle; best for list screens |
| Cache | cache the aggregate (page 09) | 0 on hit | Invalidation cost |
Measured on the in-process H2 harness; shape (statements per request) transfers, absolute values do not.
| Variant (0.5 ms RTT, index present) | Statements | ms/request | vs N+1 |
|---|---|---|---|
| N+1 | 101 | 51.81 | baseline |
| Batched IN (size 100) | 2 | 1.82 | 28x faster |
| Single join | 1 | 0.84 | 62x 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.
- 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-viewdefaults totrue(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.
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.
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.
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.
Ask the database for its plan and its row-examination counts, then time the query. H2 prints the access path with EXPLAIN ANALYZE:
(In PostgreSQL the equivalent is EXPLAIN (ANALYZE, BUFFERS) in psql; with Spring you can find the statement from pg_stat_statements.)
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.
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.
H2 2.2.224 in-process, 500,000 rows, customer 777 has 250 rows.
Mechanism diagram; page layouts vary by engine.
| Before | After | |
|---|---|---|
| Rows examined | 500,001 | 251 |
| Time per query | 49.99 ms | 0.409 ms (122x) |
| Cost to build | - | 1.7 s on 500k rows; extra work on each write |
| Idea | Rule | Example for OrderService |
|---|---|---|
| Composite order | Equality 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 index | Include 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 index | Index only the rows you query (PostgreSQL, SQL Server) | WHERE status = 'OPEN' when 1 % of orders are open |
| Selectivity | An index on a low-cardinality column (status, boolean) rarely helps alone | Do not index status by itself |
| Sargability | Wrapping the column in a function or mismatched type defeats the index | WHERE 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.
- 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
DELETEon 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.
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.
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.
The planner chose a sequential scan or a bad join order because its row estimates are wrong.
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.
Illustrative diagram of the output above.
| What you see | What it means | What to do |
|---|---|---|
Seq Scan + big Rows Removed by Filter | Reading the table to throw rows away | Index on the filter column(s) |
| estimated rows 250, actual 25000 | Planner under-estimates 100x; chose a plan for 250 rows | ANALYZE; extended statistics for correlated columns; rewrite the predicate |
Nested Loop with inner scan, loops=25000 | Right for few outer rows, disastrous for many | Give the inner side an index or let the planner pick Hash Join (fix the estimate) |
Buffers: read high | Data not in cache: storage latency | Larger instance memory, better indexes, hot/cold data split |
Sort Method: external merge Disk | Sort spilled to disk | Index that provides order; raise work_mem for the session carefully |
Index Only Scan with Heap Fetches low | Covering index works | Keep the table vacuumed |
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.
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.
- 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_idwith 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. Trustactual timeandrows.
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.
"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.
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.
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.
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.
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.
H2 2.2.224, 500,000 rows, page size 20; averages over 20-2,000 repetitions.
In-process H2; real servers add round trips and WAL flushes per commit.
| Technique | Cost shape | Trade-off |
|---|---|---|
| OFFSET pagination | O(offset): 45 ms at 400k | Random page access, simple UI |
| Keyset pagination | O(1) (index seek): 0.015 ms | No "jump to page 5,000"; needs a stable total order (add id as tie-breaker) |
| Per-row autocommit insert | 49 us/row (in-process) | Simplest |
| Batched, one transaction | 5.3 us/row (9x) | Bigger transactions: lock hold time, memory, all-or-nothing |
- 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.
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.
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 %.
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.
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.
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:
Averages over completed requests (see the caveat below on starvation).
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:
| Pool size | Throughput | Avg pool wait | Avg in-DB time | Verdict |
|---|---|---|---|---|
| 4 | 187 q/s | 306 ms | 21 ms | pool-limited |
| 8 | 387 q/s | 276 ms | 21 ms | saturates DB: right-sized |
| 16 | 396 q/s | 197 ms | 40 ms | no gain |
| 50 | 371 q/s | 173 ms | 119 ms | DB queue grows |
| 200 | 375 q/s | 51 ms | 287 ms | all 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.
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.
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.
- Pool per instance times instances. 20 pods x pool 50 = 1,000 connections against an Aurora/RDS instance whose
max_connectionsmay 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-timeoutand a bulkhead, see Spring, HTTP & Network. - Connection leaks. A connection never returned drains the pool silently;
leak-detection-thresholdlogs the stack trace of the leaker.
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).
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.
Read traffic can move to replicas, if the data can tolerate replication lag, leaving the writer for transactions.
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).
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).
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.
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:
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.
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.
Illustrative.
| Tool | What it gives | How |
|---|---|---|
log_min_duration_statement (PostgreSQL) | Logs every statement slower than N ms | RDS parameter group, e.g. 500; ship logs to CloudWatch |
auto_explain | Logs the actual plan of slow statements | auto_explain.log_min_duration; use log_analyze carefully (overhead) |
| MySQL slow query log | long_query_time, slow_query_log=1 | RDS MySQL parameter group |
statement_timeout, lock_timeout | Kill runaway statements and long lock waits | Per role/database; protects the pool |
| CloudWatch RDS metrics | CPUUtilization, DatabaseConnections, FreeableMemory, ReadLatency/WriteLatency, DiskQueueDepth | Enhanced Monitoring for OS-level view |
| RDS Proxy / PgBouncer | Connection multiplexing for many small clients | When instances x pool exceeds max_connections |
- 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.
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.
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.
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, thenEXPLAIN (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,reWriteBatchedInsertsfor 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.
Cheat Sheet and Recap
| Symptom | Evidence | Fix | Measured here |
|---|---|---|---|
| 100+ statements per request | Hibernate statistics, trace spans, pg_stat_statements calls | Batch fetch / join fetch / projection | 51.8 -> 1.8 -> 0.84 ms |
| One statement slow, big scan | EXPLAIN: Seq Scan, Rows Removed by Filter | Index (check the plan changes) | 49.99 -> 0.409 ms (122x) |
| Deep pages slow | Latency grows with OFFSET | Keyset pagination | 45 ms -> 0.015 ms |
| Bulk write slow | Statements/s, WAL, round trips | Batching, bigger tx chunks, COPY | 49 -> 5.3 us/row |
| Connection is not available | pending > 0, active = max, timeout counter | Shorter transactions, right-sized pool, RDS Proxy | saturation at pool 8 of 400 q/s |
| Writer CPU high, reads dominate | AAS > vCPU, read-heavy statements | Cache, then replicas | not measured |
| Latency fine at p50, awful at p99 | Pool wait tail, lock waits, cold cache reads | Timeouts, fairness, short tx | p99 4-5 s vs p50 20 ms in pool lab |
- 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_statementssorted by total time finds N+1 and missing indexes. Those parts were not run here.
- 01 Measure First - the latency budget and Little's Law.
- 07 Spring, HTTP & Network - the request pool in front of this one.
- 09 Caching - skip the database.
- 11 Capacity & Failure - the connection budget and "DB 5x slower".
- 13 Scaling & Observability - traces that show the 650 ms.
- Spring Data JPA, Transactions.
- AWS RDS, Aurora, ElastiCache.