How to read this guide
your app is a concurrent program — the DB just hides it wellMost database bugs never show up on your laptop. You click through the app alone, one request at a time, and
everything is correct. Production runs the same code hundreds of times in parallel, holds connections for too
long, retries, crashes halfway, and reads from replicas. This guide is about the mechanisms behind that: connections and
pools, transactions, isolation levels, Spring's @Transactional and propagation, locking, JPA's hidden
queries, and what to check in every application that talks to a database.
Examples use Java, Spring and JPA/Hibernate on PostgreSQL, with MySQL InnoDB notes where it behaves differently. The ideas carry over to any stack. Facts were checked against the PostgreSQL 18, Spring Framework 7.0, Hibernate ORM 6.6, HikariCP and MySQL 8.4 documentation in September 2026.
The guide has levels. Chapters and blocks carry a chip — L1 essentials, L2 practitioner, L3 expert, L4 operations. Choose your depth with the switch in the bottom-left corner; deeper material hides until you want it. The map below jumps anywhere and ticks off what you've read.
A colleague writes a money transfer: read both balances, check that the sender has enough, subtract, add, save.
The method is annotated with @Transactional. "It's in a transaction, so it's safe," they say.
Is it? Keep your answer — the last chapter comes back to it.
Essentials
Connections, transactions, and what goes wrong when two of them run at the same time.
Connections and the pool
the scarcest thing in your app is not CPUEvery query travels over a connection: a network session with the database server. Opening one costs a TCP handshake, authentication and a server-side session, so applications keep a connection pool. In Spring Boot that's HikariCP by default. A request borrows a connection, runs its statements, and returns it.
The pool is small on purpose. HikariCP's maximumPoolSize defaults to 10. When all ten
are borrowed, the next getConnection() call blocks for up to connectionTimeout — default
30 seconds — and then throws. So the question is never "how fast is my query" alone. It is "how long does each request
hold a connection".
connectionTimeout; time is compressed.Notice what broke: not the database. The database was idle while requests queued. The pool ran dry because connections were held while the code waited on something else.
connections = (core_count × 2) + effective_spindle_count, and tells you to
load-test around it. Fix the hold time first.autoCommit default is true as well): each statement is its own transaction and
commits immediately. A transaction spanning several statements only exists when something — you or Spring — turns
auto-commit off and later commits.🔬 Go deeper: the Hikari settings worth knowing
| Setting | Default | What it does / when to touch it |
|---|---|---|
maximumPoolSize | 10 | Max connections, idle and in use. Size from load tests, not guesses. |
minimumIdle | same as maximumPoolSize | Hikari recommends leaving it unset: a fixed-size pool. |
connectionTimeout | 30000 ms | How long getConnection() waits before throwing. Lower it so overload fails fast. |
maxLifetime | 1800000 ms (30 min) | Retire connections; keep it several seconds shorter than any database or infrastructure connection time limit. |
idleTimeout | 600000 ms (10 min) | Only applies when minimumIdle < maximumPoolSize. |
leakDetectionThreshold | 0 (off) | Logs a warning when a connection is out of the pool longer than this. Min 2000 ms. |
autoCommit | true | Default auto-commit of connections handed out. |
- Connections are pooled and few (Hikari default: 10). Throughput depends on hold time.
- A full pool makes callers wait up to
connectionTimeout(30 s default), then fail. - Without an explicit transaction, each statement auto-commits on its own.
Transactions: all or nothing
the crash always happens between line 1 and line 2A transaction groups statements into one unit: BEGIN, several statements,
COMMIT — or ROLLBACK, which undoes all of them. The classic promise is ACID:
| Letter | Promise | What it means for your code |
|---|---|---|
| Atomicity | All statements take effect, or none | A crash mid-way leaves no half-done transfer. |
| Consistency | Constraints hold after every commit | Only rules the DB knows (PK, FK, CHECK, UNIQUE). Your business rules are your job. |
| Isolation | Concurrent transactions don't see each other's mess | …to a degree set by the isolation level. Chapters 3–4. |
| Durability | Committed means it survives a crash | Once COMMIT returns, the data is safe. |
In PostgreSQL, once any statement in a transaction fails, the transaction is aborted: every further command is
rejected until you end the block. ROLLBACK (or ROLLBACK TO a savepoint) is the only way back.
Catching the SQL exception in Java and "continuing" inside the same transaction does not work.
Not everything rolls back. In PostgreSQL, sequence changes (and so serial/identity counters) are
immediately visible to other transactions and are not rolled back when the transaction aborts. Gaps in IDs are
normal; never use them as "row count" or business numbering.
- A transaction = several statements that commit or roll back together.
- Only database constraints are guaranteed by C in ACID; put important rules into constraints.
- After an error, a PostgreSQL transaction is dead until rollback.
The anomaly zoo
five ways two correct transactions make a wrong resultAtomicity handles crashes. Isolation handles neighbours. When two transactions interleave, each one can be correct alone and the combination still wrong. The SQL standard names three phenomena — dirty read, non-repeatable read, phantom read — and PostgreSQL's documentation adds a fourth, the serialization anomaly. Two more are what bites applications in practice: the lost update and write skew (a kind of serialization anomaly).
| Anomaly | What happens | Typical bug |
|---|---|---|
| Dirty read | You read data another transaction hasn't committed | Acting on a change that later rolls back |
| Non-repeatable read | Reading the same row twice gives different values | Report totals that don't add up |
| Phantom read | Re-running a query returns a different set of rows | "Count, then insert if under limit" races |
| Lost update | Two read-modify-write cycles; the second overwrites the first | Stock, counters, balances edited in Java |
| Write skew | Two transactions read an overlapping set, each updates a different row, the invariant breaks | "At least one doctor on call", double booking |
Your code does Product p = repo.findById(id); p.setStock(p.getStock() - 1); repo.save(p); inside
@Transactional. Which anomaly can hit it under PostgreSQL's default settings?
Answer
The lost update. Two requests both read stock 5, both write 4. Read Committed
doesn't prevent it, because each UPDATE writes a value computed in Java from a stale read. Chapter 7 shows
four fixes.
- Standard phenomena: dirty read, non-repeatable read, phantom read, plus serialization anomaly.
- In application code, the dangerous ones are lost update and write skew.
- Any "read in Java, decide, write back" pattern is suspect.
Isolation levels: how much you see of your neighbours
same names, different databases, different rulesThe SQL standard defines four isolation levels by which phenomena they must prevent. Databases may give you more than the standard requires, and they do — differently. That's why "Repeatable Read" in PostgreSQL and in MySQL are not the same thing.
| Level | Dirty read | Non-repeatable | Phantom | Serialization anomaly |
|---|
| Level | PostgreSQL | MySQL InnoDB |
|---|---|---|
| Default | Read Committed | Repeatable Read |
| Read Uncommitted | Behaves like Read Committed | Nonlocking SELECTs, “a possible earlier version of a row might be used”: dirty reads |
| Read Committed | Each statement sees a fresh snapshot | Each consistent read sets its own fresh snapshot; no gap locks except FK and duplicate-key checks |
| Repeatable Read | One snapshot per transaction (Snapshot Isolation); no phantoms; update conflicts fail with a serialization error | Plain reads use the snapshot of the first read; locking reads and range UPDATE/DELETE use gap or next-key locks |
| Serializable | Serializable Snapshot Isolation: detects anomalies, aborts one transaction | Like Repeatable Read, but plain SELECTs become locking reads when auto-commit is off |
// Spring: per transaction
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void closeMonth() { … }
-- SQL: first statement inside the transaction
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
Higher isolation is not free safety. In PostgreSQL, Repeatable Read and Serializable transactions can fail with
SQLSTATE 40001 (serialization_failure), and your application must retry them. Turning
on Serializable without a retry loop just converts silent data bugs into loud errors.
- Defaults: PostgreSQL Read Committed, MySQL InnoDB Repeatable Read.
- PostgreSQL has three real levels: Read Uncommitted = Read Committed.
- Stronger levels in PostgreSQL = retries on SQLSTATE
40001.
Practitioner
Spring's transaction machinery, propagation, locking strategies and the queries JPA runs behind your back.
@Transactional is a proxy, not magic
it only works when the call goes through the front doorSpring doesn't rewrite your method. By default (proxy mode) it wraps the bean in a proxy. When
another bean calls your method, the call hits the proxy first: it begins a transaction (for JDBC: takes a connection and
switches auto-commit off), calls your code, then commits — or rolls back if an exception escapes. That design explains almost every
@Transactional surprise.
orders.place()BEGIN … COMMITplace()@Transactionalsave()REQUIRED, default rollback rules).| Trap | Why | Fix |
|---|---|---|
| Self-invocation | "only external method calls coming in through the proxy are intercepted" | Move the method to another bean, or put @Transactional on the caller |
| Checked exception | By default only RuntimeException and Error roll back | rollbackFor = Exception.class, or since Spring 6.2 @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) |
| Exception caught inside | The proxy only sees exceptions that escape the method | Rethrow, or call TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() |
Called from @PostConstruct | The proxy must be fully initialized first | Run the work after startup (e.g. an application-ready event listener) or from another bean |
| Interface-based proxy, non-public method | Must be public and declared in the interface | Since 6.0, class-based proxies also make protected and package-visible methods transactional |
@Transactional mean?REQUIRED, isolation DEFAULT (whatever the database
default is), read-write, timeout of the underlying transaction system (or none), and rollback on
RuntimeException/Error only.@Transactional go on the repository or the service?🔬 Go deeper: AspectJ mode
With @EnableTransactionManagement(mode = AdviceMode.ASPECTJ) Spring weaves the transaction aspect into
the class byte code (compile-time or load-time weaving, spring-aspects.jar), so self-invocation is
transactional too. Most teams avoid the build complexity and simply split beans.
- The transaction starts in the proxy. Calls that bypass the proxy (
this.x()) get none. - Checked exceptions commit by default. Decide your rollback rules explicitly.
- An exception you catch and swallow never reaches the proxy.
Propagation: what happens when transactional methods call each other
logical vs physical transactionsWhen a @Transactional method calls another one (through a proxy), the inner method's
propagation decides: join the running transaction, start a new one, use a savepoint, or run without one.
Spring's docs distinguish the physical transaction (the real database transaction on one connection) from
logical transaction scopes (one per annotated method).
| Propagation | Transaction exists | No transaction | Use it when |
|---|---|---|---|
REQUIRED (default) | joins it | creates one | almost always |
REQUIRES_NEW | suspends it, creates a new one (new connection) | creates one | audit/log rows that must survive the caller's rollback |
NESTED | savepoint inside it | like REQUIRED | "try this part, undo only it on failure" (JDBC / DataSourceTransactionManager) |
SUPPORTS | joins it | runs without | read helpers that don't care |
MANDATORY | joins it | throws | methods that must never be the unit of work |
NOT_SUPPORTED | suspends it, runs without | runs without | slow non-DB work called from a transaction |
NEVER | throws | runs without | asserting "no transaction here" |
DataSourceTransactionManager-style JDBC transactions and default rollback rules. With
JPA/JTA, NESTED support depends on the transaction manager.UnexpectedRollbackException. With REQUIRED, inner and outer share one physical transaction. If
inner throws, its scope marks the transaction rollback-only. Outer catches the exception and tries to commit —
Spring rolls back instead and throws UnexpectedRollbackException so the caller "can never be misled to
assume that a commit was performed when it really was not".
REQUIRES_NEW needs a second connection while the outer one stays bound. Spring's docs warn this can exhaust the pool and deadlock when many threads hold an outer transaction and all wait for an inner connection. Their rule: don't use it unless the pool exceeds the number of concurrent threads by at least 1. Chapter 14 animates it.
A participating (REQUIRED) scope silently ignores its own isolation, timeout and read-only settings and
inherits the outer ones. Set validateExistingTransaction = true on the transaction manager to reject isolation
and read-only mismatches instead. REQUIRES_NEW scopes can declare their own settings.
REQUIRED: one physical transaction; an inner failure dooms the outer one.REQUIRES_NEW: independent commit, extra connection, locks released at inner commit.NESTED: savepoint, partial rollback, same connection; JDBC only out of the box.
Four ways to sell the last ticket once
atomic, optimistic, pessimistic — and naiveBack to the lost update. Alice and Bob both try to buy the last ticket. Read Committed alone lets both succeed. Here are the standard fixes, from simplest to most specific.
| Strategy | How | Use it when |
|---|---|---|
| Atomic UPDATE | UPDATE … SET stock = stock - 1 WHERE id = ? AND stock > 0, check the row count | The rule fits in one statement. Cheapest and safest. |
Optimistic (@Version) | Hibernate adds WHERE version = ?; 0 rows → an optimistic-lock exception (Hibernate: StaleObjectStateException / OptimisticEntityLockException) | Conflicts are rare; edits span user think-time (forms) |
Pessimistic (FOR UPDATE) | Lock the row when reading; others wait | Conflicts are frequent and retries are expensive |
FOR UPDATE SKIP LOCKED | Skip rows someone else has locked | Job queues: many workers, each takes a different row |
FOR UPDATE NOWAIT | Error immediately instead of waiting | Interactive actions that should fail fast |
| Unique constraint | Let the database reject the duplicate | "Only one X per Y" rules — race-proof at any isolation |
@Entity
class Ticket {
@Id Long id;
int stock;
@Version long version; // optimistic locking
}
interface TicketRepo extends JpaRepository<Ticket, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE) // SELECT … FOR UPDATE
@Query("select t from Ticket t where t.id = :id")
Ticket lockById(Long id);
@Modifying
@Query("update Ticket t set t.stock = t.stock - 1 where t.id = :id and t.stock > 0")
int takeOne(Long id); // 1 = sold, 0 = sold out
}
SKIP LOCKED gives "an inconsistent view of the data" by design. Perfect for a queue, wrong for a report
or a balance check.
- Prefer pushing the rule into one SQL statement or a constraint.
@Versionturns lost updates into exceptions you must handle (retry or tell the user).FOR UPDATEserializes access to a row; keep those transactions short.
JPA and the queries you didn't write
persistence context, dirty checking, N+1, lazy loadingInside a transaction, Hibernate keeps a persistence context (the first-level cache): every entity you load is tracked. Change a field of a managed entity and, as Hibernate's guide puts it, "there is no need to call a particular method to make your modifications persistent" — dirty checking writes it at flush time.
Flushes happen in AUTO mode: before commit, before a JPQL/HQL query that overlaps the queued changes,
and — with the JPA EntityManager — before every native SQL query. So a SELECT in your code can trigger
an UPDATE first.
| Setting / tool | Default | Advice |
|---|---|---|
@ManyToOne, @OneToOne fetch | EAGER (JPA) | Hibernate's guide: "it's better if all associations are marked as LAZY" |
Collection associations (@OneToMany, @ManyToMany) | LAZY (JPA) | Keep lazy; fetch per use case |
JOIN FETCH / entity graph | — | Load exactly what this use case needs, in one query |
@BatchSize, hibernate.default_batch_fetch_size | off | Turns N lazy loads into N / size queries |
| DTO projection | — | Read-only screens: no tracking, no dirty checking |
spring.jpa.open-in-view | on in Spring Boot | Set to false; fetch what the view needs inside the service |
Open Session in View. Spring Boot registers OpenEntityManagerInViewInterceptor by default, so lazy
loading "works" while rendering the response — each lazy access runs another query, outside your service transaction,
after the business logic finished. Turn it off with spring.jpa.open-in-view=false and the hidden N+1s
become visible LazyInitializationExceptions ("an attempt to access unfetched data outside the context of
an open stateful Session").
- Managed entities are saved by dirty checking — no
save()needed, and no way to "just change it locally". - N+1: one query for the list, one per row for an association. Fix with
JOIN FETCH, entity graphs or batch fetching. - Make to-one associations LAZY; disable Open Session in View.
Round trips: batching, paging and read-only
latency × count beats any indexEvery statement is a network round trip. Inserting 1,000 rows one by one costs 1,000 of them. JDBC
batching sends many statements at once; in Hibernate you enable it with hibernate.jdbc.batch_size
(its guide suggests a value between 10 and 50).
With GenerationType.IDENTITY, "Hibernate will not be able to batch INSERT statements" — it must run each
insert immediately to learn the generated ID. Use a sequence for entities you insert in bulk. And in long loops, call
flush() and clear() regularly so the persistence context doesn't grow without bound.
Paging: OFFSET vs keyset
PostgreSQL's docs: "The rows skipped by an OFFSET clause still have to be computed inside the server; therefore a large OFFSET might be inefficient." Page 5,000 of an infinite scroll reads and throws away 100,000 rows. Keyset pagination remembers where the last page ended:
-- OFFSET: cost grows with the page number
SELECT * FROM event ORDER BY id LIMIT 20 OFFSET 100000;
-- keyset: an index seek, same cost on every page
SELECT * FROM event WHERE id > :lastSeenId ORDER BY id LIMIT 20;
@Transactional(readOnly = true) actually do?REQUIRED scope inherits the outer
transaction's flag.- Batch writes (
hibernate.jdbc.batch_size); IDENTITY IDs disable insert batching. - Large OFFSETs are slow; use keyset pagination for deep pages.
readOnly = trueis an optimization hint, not a guard.
Expert
Snapshots, serializability, deadlocks, side effects outside the database, and the ways a healthy app starves itself.
MVCC: every reader gets its own photo
readers don't block writers, writers don't block readersPostgreSQL (and InnoDB) use multiversion concurrency control. An UPDATE doesn't
overwrite a row in place; it creates a new version. Each statement reads from a snapshot — the set of
transactions committed at a certain moment — and picks the version that snapshot can see. So plain reads never wait for
writers. The only lock that blocks a plain SELECT is ACCESS EXCLUSIVE.
The difference between Read Committed and Repeatable Read is just when the photo is taken: at the start of each statement, or at the first statement of the transaction.
account A:Read Committed has a subtle twist for writes. An UPDATE that finds a row being changed by another
transaction waits; if that one commits, the UPDATE re-checks its WHERE clause against the
new version and proceeds. That's why the atomic stock - 1 … AND stock > 0 works. It's also why
PostgreSQL's docs call Read Committed "unsuitable for commands that involve complex search conditions": one statement
can see a mix of new rows it updates and old rows it doesn't.
In Repeatable Read, the same situation fails with ERROR: could not serialize access due to concurrent update:
a Repeatable Read transaction "cannot modify or lock rows changed by other transactions after [it] began".
Old versions can only be cleaned up once no open transaction might still need them. PostgreSQL's docs note that a transaction left open idle "prevents vacuuming away recently-dead tuples that may be visible only to this transaction" — one forgotten session can bloat busy tables.
- MVCC keeps row versions; reads use a snapshot and don't block writes.
- Read Committed: new snapshot per statement. Repeatable Read: one snapshot per transaction.
- Long-open transactions hold back cleanup.
Serializable, write skew and the retry loop
correctness you pay for with retriesWrite skew slips through Snapshot Isolation because the two transactions write different rows.
PostgreSQL's Serializable level uses Serializable Snapshot Isolation: it tracks what each transaction read
(predicate locks, shown as SIReadLock in pg_locks) flags read/write dependencies among concurrent
Serializable transactions, and aborts one when a combination of them could lead to a serialization anomaly. These predicate locks never block and cannot cause deadlocks.
40001.The retry loop — outside the transaction
PostgreSQL's advice: retry the complete transaction, "including all logic that decides which SQL to
issue and/or which values to use". It also suggests retrying deadlocks (40P01) and, in some cases, unique
violations (23505). In Spring, that means the retry wraps the proxy, never the other way round:
// caller bean — NOT @Transactional
public void bookWithRetry(Cmd cmd) {
for (int attempt = 1; ; attempt++) {
try {
bookingService.book(cmd); // @Transactional(isolation = SERIALIZABLE): a fresh transaction each time
return;
} catch (ConcurrencyFailureException e) { // parent of Spring's lock / deadlock / serialization failures
if (attempt == 3) throw e;
sleep(backoffWithJitter(attempt));
}
}
}
Data read inside a Serializable transaction is not trustworthy until the transaction commits. Don't send an
email or call an API from inside it based on what you read — it might be rolled back a moment later. (A
READ ONLY DEFERRABLE serializable transaction waits for a safe snapshot and can trust its reads
immediately.)
SELECT … FOR UPDATE on all
on-call doctors), or turn the invariant into a constraint, or materialize the conflict — a single row both
transactions must update. Serializable is the general solution; the others are targeted.READ ONLY
when possible, keeping them short, controlling connection count, and not leaving sessions idle in transaction.- Write skew needs Serializable, explicit locks, or a constraint.
- SQLSTATE
40001= retry the whole unit of work, from outside the transactional method. - Never act on reads of an uncommitted serializable transaction.
Deadlocks: two polite people in a doorway
you don't need explicit locks to get oneEvery UPDATE locks the rows it changes until commit. If T1 locks A then wants B, while T2 locks
B then wants A, each waits for the other forever. PostgreSQL waits deadlock_timeout (default 1 s)
before running its relatively expensive deadlock check, then aborts one of the transactions with SQLSTATE
40P01.
| Defense | How |
|---|---|
| Consistent lock order | PostgreSQL docs: "the best defense … acquire locks on multiple objects in a consistent order". Sort IDs before updating. |
| Take the strongest lock first | Don't read-lock then upgrade; lock for update up front. |
| Short transactions | Fewer locks held, for less time. |
| Retry | Deadlocks will still happen; treat 40P01 like 40001. |
lock_timeout | Bounds how long each lock wait may last (not only deadlocks). |
🔬 Go deeper: MySQL gap locks add another way to deadlock
InnoDB at Repeatable Read locks ranges, not just rows: for locking reads, UPDATE and
DELETE with a non-unique or range condition, it "locks the index range scanned, using gap locks or next-key
locks to block insertions by other sessions into the gaps". Two transactions inserting into the same gap after locking
it can deadlock without ever touching the same row. InnoDB detects deadlocks automatically and rolls one back; its docs
say applications must be ready to retry.
- Deadlock = cycle of waits; PostgreSQL checks after
deadlock_timeout(1 s) and kills one side. - Prevent with a consistent lock order; handle the rest with retries.
The database commits. Kafka and email don't care.
dual writes, events after commit, the outboxA transaction only covers the database. The moment your method also sends a message, an email or an HTTP request, you have a dual write: two systems that can't commit together. Whatever order you pick, some crash point leaves them disagreeing.
| Design | Failure | Use it when |
|---|---|---|
| Send inside the transaction | Message sent, then rollback → event for an order that doesn't exist | Never for important events |
@TransactionalEventListener (default phase AFTER_COMMIT) | Crash after commit, before send → event lost | Best-effort side effects: cache eviction, notifications you can miss |
| Transactional outbox | Relay may publish twice → consumers must be idempotent | Events other services depend on |
@Transactional
public void placeOrder(Order o) {
orders.save(o);
outbox.save(new OutboxEvent("OrderPlaced", o.getId(), toJson(o))); // same transaction
}
// a separate relay (poller or CDC) reads the outbox table, publishes, marks rows as sent
Idempotency is not optional. microservices.io: "The Message relay might publish a message more than once. As a result, a message consumer must be idempotent, perhaps by tracking the IDs of the messages that it has already processed." Same for HTTP retries: give each request an idempotency key and store it with a unique constraint.
- DB + anything else = dual write. There is no order that is safe for all crash points.
- Outbox: write the event in the same transaction; publish it asynchronously.
- At-least-once delivery ⇒ idempotent consumers.
Long transactions and pool starvation
the app that deadlocks itself without the database noticingA transaction holds its connection — and its row locks — from BEGIN to COMMIT.
Everything slow inside it multiplies: an HTTP call, a file upload, a user prompt, a loop that calls another service per row.
PostgreSQL's docs call holding transactions open while waiting for user input "a bad idea".
REQUIRES_NEW, the pool must
exceed the number of concurrent threads by at least 1.Timeouts: your safety net
| Setting | Default | Stops |
|---|---|---|
statement_timeout | 0 (off) | Any single statement running too long |
lock_timeout | 0 (off) | Waiting too long for one lock |
idle_in_transaction_session_timeout | 0 (off) | Sessions idle inside an open transaction (terminates the session) |
transaction_timeout | 0 (off) | Whole transactions longer than the limit (PostgreSQL 17+) |
@Transactional(timeout = 5) | underlying default | Spring-side limit, in seconds |
Hikari connectionTimeout | 30 s | Waiting for a pool connection |
PostgreSQL's docs say setting statement_timeout, lock_timeout or
transaction_timeout in postgresql.conf is not recommended "because it would affect all sessions".
Set them per role, per session or per transaction instead.
- Do remote calls, file I/O and heavy computation before the transaction.
- Open a short transaction: re-read what you need, validate, write.
- Trigger side effects after commit (outbox or after-commit listener).
- For long batch jobs, commit in chunks — and make each chunk restartable.
- Transaction time = connection time = lock time. Keep it short.
- REQUIRES_NEW under load can deadlock the pool.
- Set timeouts at every layer; the defaults are "wait forever" on the PostgreSQL side.
Read replicas: yesterday's data, served fast
read-your-writes is not freeReplicas (PostgreSQL hot standby) take read load off the primary, but "the data on the standby takes some time to arrive from the primary server", so the same query on both "might therefore return differing results". The docs call standby data eventually consistent.
| Scenario | Problem | Fix |
|---|---|---|
| User saves, page reloads from replica | Their change "disappears" | Read from the primary for a short time after a user writes (read-your-writes) |
| Check on replica, write on primary | The check is based on stale data | Validate and write in one transaction on the primary |
Routing by readOnly = true | A read-only method called inside a write transaction joins it, on the primary | Decide routing at the outermost transaction |
| Long queries on the replica | Can conflict with replayed changes and be cancelled, or delay replay | Tune the standby delay settings for the replica's purpose |
- Replicas lag. Never validate business rules on a replica.
- Decide per use case whether stale reads are acceptable.
Operations
Changing the schema under traffic, seeing what the database is doing, and fixing it at 3 a.m.
Schema migrations under traffic
the 5 ms ALTER that took the site downMost ALTER TABLE forms take an ACCESS EXCLUSIVE lock — the one lock that blocks even
plain SELECTs. The statement itself may be instant. The danger is getting the lock: it must wait for
every open transaction that touched the table, and while it waits, it blocks others.
src/backend/storage/lmgr/README; the docs of
pg_blocking_pids call this a "soft block").| Change | Risky way | Safer way |
|---|---|---|
| Any DDL | Run and hope | SET lock_timeout = '2s' in the migration; retry on failure |
| New index | CREATE INDEX blocks writes for the whole build | CREATE INDEX CONCURRENTLY — not allowed inside a transaction block; check for an INVALID index if it fails |
| New column with default | Volatile default (e.g. clock_timestamp()) rewrites the table | Non-volatile default: stored in metadata, "very fast even on large tables" |
| New FK / CHECK | Validates all rows under a strong lock | ADD CONSTRAINT … NOT VALID, then VALIDATE CONSTRAINT separately |
| Rename / drop a column | Old app version still running breaks | Expand → migrate → contract across several deploys |
- Expand: add the new column/table; code writes both old and new.
- Migrate: backfill in small batches (short transactions!).
- Switch: code reads from the new structure.
- Contract: once no running version uses the old one, drop it.
- A waiting
ACCESS EXCLUSIVErequest blocks newcomers too. Always setlock_timeoutfor DDL. - Index concurrently; add constraints
NOT VALIDthen validate. - Every migration must work with both the old and the new app version running.
Seeing what the database is doing
pg_stat_activity is your best friendpg_stat_activity shows one row per server process: its state, current query and when the
transaction started. The state to hunt for is idle in transaction: "the backend is in a transaction, but
is not currently executing a query" — typically your app holding a connection while doing something else.
| pid | state | xact age | wait | query |
|---|
pid, state, xact_start,
wait_event_type, query; pg_blocking_pids(pid) returns the blockers.-- open transactions, oldest first
SELECT pid, state, now() - xact_start AS xact_age, wait_event_type, left(query, 60)
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;
-- who blocks whom
SELECT pid, pg_blocking_pids(pid) AS blocked_by, left(query, 60)
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
| Signal | Where | Means |
|---|---|---|
| Pool active = max, threads pending > 0 | Hikari pool metrics | Pool exhaustion: look at hold times |
| "Connection leak detection triggered for …" | Hikari log with leakDetectionThreshold | A connection out of the pool too long — stack trace shows who took it |
Many idle in transaction | pg_stat_activity | Work done inside open transactions |
| Slow statements | log_min_duration_statement, pg_stat_statements | Queries to index or rewrite |
| Lock waits | log_lock_waits, pg_locks, pg_blocking_pids() | Contention or long transactions |
| Statement count per request | Hibernate statistics / SQL logging in tests | N+1 regressions — assert the count in tests |
- Watch transaction age and
idle in transaction, not just query time. - Turn on pool metrics and leak detection in every environment.
Troubleshooting: symptom → cause → fix
start from what you see| Symptom | Likely cause | Fix |
|---|---|---|
| "Connection is not available, request timed out after 30000ms" | Pool exhausted: slow work inside transactions, leaks, REQUIRES_NEW under load | Shorten transactions, leak detection, size pool > threads with REQUIRES_NEW |
UnexpectedRollbackException | Inner REQUIRED method threw, outer caught it | Let it propagate, or use REQUIRES_NEW/NESTED deliberately |
| Data saved although an exception was thrown | Checked exception, swallowed exception, or self-invocation (no transaction) | Rollback rules; rethrow; call through the proxy |
LazyInitializationException | Lazy association accessed after the transaction | JOIN FETCH / entity graph / DTO in the service |
| Page makes hundreds of queries | N+1, often EAGER to-one or Open Session in View | LAZY + fetch per use case; open-in-view=false |
Optimistic-lock exception (StaleObjectStateException, OptimisticEntityLockException) | Concurrent edit of an entity with @Version | Retry or show "changed by someone else" |
SQLSTATE 40001 | Serialization failure (RR/Serializable) | Retry the whole transaction from outside |
SQLSTATE 40P01 "deadlock detected" | Inconsistent lock order | Sort before locking; retry |
| "current transaction is aborted, commands ignored until end of transaction block" | An earlier statement failed; code carried on | Roll back (or use a savepoint) after any SQL error |
| Sold twice / counter off by a few | Lost update via read-modify-write | Atomic UPDATE, @Version or FOR UPDATE |
| Duplicate rows despite "check then insert" | Race between the check and the insert | Unique constraint + handle 23505 |
| Table bloat, slow queries over days | Long or idle-in-transaction sessions holding back VACUUM | idle_in_transaction_session_timeout, fix the app |
| Whole app freezes during a deploy | DDL waiting for ACCESS EXCLUSIVE, queue behind it | lock_timeout in migrations |
| User doesn't see their own change | Read from a lagging replica | Read-your-writes routing |
The checklist for every app that uses a database
go through it at design review and before go-livelocalStorage on this device only; nothing is sent anywhere.Common mistakes and interview traps
answer first, then open the cardEach card is a question interviewers like because the "obvious" answer is wrong. Say your answer out loud before you open it.
"@Transactional on a private method works, right?"
Answer
In proxy mode, calls from inside the same object never pass through the proxy, so a private helper called internally gets no transaction. Class-based proxies can intercept protected and package-visible methods (since Spring 6.0), but never private ones.
"Does a checked exception roll back?"
Answer
Not by default. Only RuntimeException and Error. Use rollbackFor or the 6.2 global rollbackOn = ALL_EXCEPTIONS.
"What is PostgreSQL's default isolation level? MySQL's?"
Answer
PostgreSQL: Read Committed. MySQL InnoDB: Repeatable Read. Same code, different anomalies.
"Does Repeatable Read prevent phantoms?"
Answer
The standard allows phantoms at Repeatable Read. PostgreSQL's implementation doesn't allow them. InnoDB's plain reads use a snapshot; its locking reads use next-key locks. "It depends on the database" is the right start.
"Serializable means transactions run one at a time?"
Answer
No — they run concurrently; the result must equal some serial order. PostgreSQL enforces that by aborting transactions (40001), so you need retries.
"REQUIRES_NEW inside REQUIRED: what if the outer rolls back?"
Answer
The inner transaction already committed and stays committed. And it needed a second connection while the outer held the first.
"Inner method threw, I caught it — why UnexpectedRollbackException?"
Answer
With REQUIRED both share one physical transaction; the inner scope marked it rollback-only. The commit turns into a rollback and Spring tells you.
"Optimistic or pessimistic locking?"
Answer
Optimistic when conflicts are rare or span user think-time; pessimistic when conflicts are frequent and short. Often the best answer is neither: a single atomic UPDATE or a constraint.
"Why is my read-only method still flushing / writing?"
Answer
readOnly is a hint, and a joining REQUIRED scope inherits the outer transaction's characteristics anyway.
"What's N+1 and how do you detect it?"
Answer
One query for a list plus one per row for an association. Detect with SQL logging/statistics in tests (assert query counts); fix with JOIN FETCH, entity graphs, batch fetching or DTOs.
"Why not just increase the pool size?"
Answer
More connections than the DB can run in parallel means more contention, not more throughput. Reduce hold time first; size from load tests.
"Can you deadlock without SELECT FOR UPDATE?"
Answer
Yes. Plain UPDATEs take row locks; two transactions updating the same rows in opposite order deadlock.
"Send the Kafka event inside or after the transaction?"
Answer
Inside risks ghost events, after risks lost ones. Transactional outbox + idempotent consumers.
"Is a sequence-generated ID gap a bug?"
Answer
No. PostgreSQL sequence changes are not rolled back, so aborted transactions leave gaps.
"Is SELECT count(*) then INSERT safe in a transaction?"
Answer
Not at Read Committed or Snapshot Isolation — that's a phantom / write skew. Use a unique constraint, a lock on a parent row, or Serializable.
"Why does my SELECT trigger an UPDATE in the SQL log?"
Answer
Hibernate's AUTO flush: before a query that overlaps pending changes, dirty entities are flushed first.
Exam + cheat sheet
check what stuckCheat sheet
| Parameter / command | Default / what it does | Use when |
|---|---|---|
| @Transactional | REQUIRED, DEFAULT isolation, read-write, rollback on RuntimeException/Error | On the service method that is the unit of work |
| rollbackFor = Exception.class | Also roll back on checked exceptions | Your methods throw checked exceptions |
| Propagation.REQUIRES_NEW | Suspend outer, new transaction + connection | Audit rows that must survive; pool > threads |
| Propagation.NESTED | Savepoint in the outer transaction | Partial rollback with JDBC transactions |
| Isolation.REPEATABLE_READ | PG: snapshot per transaction, 40001 on conflicts | Consistent multi-query reads (reports) |
| Isolation.SERIALIZABLE | PG: SSI, aborts anomalies with 40001 | Complex invariants; with a retry loop |
| @Version | Optimistic lock; exception on conflicting update | Rare conflicts, long edits |
| SELECT … FOR UPDATE [NOWAIT | SKIP LOCKED] | Row lock until commit | Hot rows; job queues (SKIP LOCKED) |
| hibernate.jdbc.batch_size | Batch statements (guide: 10–50); IDENTITY disables insert batching | Bulk writes |
| spring.jpa.open-in-view=false | Boot default registers the OSIV interceptor | Always, in services with clear boundaries |
| maximumPoolSize | 10 | Size from load tests |
| connectionTimeout | 30000 ms | Lower to fail fast |
| leakDetectionThreshold | 0 (off); min 2000 ms | Find connections held too long |
| statement_timeout / lock_timeout | 0 (off) | Per role/session; lock_timeout in every migration |
| idle_in_transaction_session_timeout | 0 (off) | Kill sessions forgotten in a transaction |
| deadlock_timeout | 1 s | Wait before the deadlock check |
| CREATE INDEX CONCURRENTLY | No write lock; not in a transaction block | Indexes on live tables |
| SQLSTATE 40001 / 40P01 / 23505 | serialization failure / deadlock / unique violation | Retry candidates |
Back to the transfer from the start: read both balances, check, subtract, add — in @Transactional. Is it
safe? Not by itself. Under PostgreSQL's default Read Committed, two concurrent transfers from the same account can
both pass the balance check (a lost update / write skew). It's also only transactional if called through the proxy, and
a checked exception would commit half of it. Fix it with an atomic
UPDATE … SET balance = balance - :x WHERE id = :from AND balance >= :x (check the row count), or lock
both rows FOR UPDATE in id order, or run it Serializable with a retry loop — and keep the transaction short.
Next step: take the checklist to your own service and count the ✗s.