Animated guide · databases from the application side

Your app vs. the database

Transactions, isolation levels, Spring propagation, locking, JPA traps, deadlocks, outbox and zero-downtime migrations — from "what is a transaction" to the questions that sink interviews. Java, Spring and PostgreSQL, with MySQL notes. Scroll — the diagrams start by themselves.

from BEGIN to production 🧠
scroll ↓

How to read this guide

your app is a concurrent program — the DB just hides it well

Most 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.

🧠 Brain power

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.

Part I

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 CPU

Every 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".

▶ A pool of 10 connections normal traffic, then make one step slow
Waiting for a connection:
served0
waiting0
timed out0
sim time0 s
Press ▶ to send requests.
Illustrative simulation: 3 requests arrive per simulated second; a normal request holds a connection for about 1 s, a "slow" one for 8 s (e.g. an HTTP call made while the transaction is open). The 30 s timeout is Hikari's default 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.

❓ There are no dumb questions
Q:
So I just raise the pool to 200?
A:
Usually not. Each connection is a server process in PostgreSQL, and throughput flattens once active connections exceed what the CPU and disks can serve. HikariCP's pool-sizing page quotes the PostgreSQL project's starting point, connections = (core_count × 2) + effective_spindle_count, and tells you to load-test around it. Fix the hold time first.
Q:
Is every statement in a transaction?
A:
Yes, but maybe not the one you think. JDBC connections start in auto-commit mode (HikariCP's 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
SettingDefaultWhat it does / when to touch it
maximumPoolSize10Max connections, idle and in use. Size from load tests, not guesses.
minimumIdlesame as maximumPoolSizeHikari recommends leaving it unset: a fixed-size pool.
connectionTimeout30000 msHow long getConnection() waits before throwing. Lower it so overload fails fast.
maxLifetime1800000 ms (30 min)Retire connections; keep it several seconds shorter than any database or infrastructure connection time limit.
idleTimeout600000 ms (10 min)Only applies when minimumIdle < maximumPoolSize.
leakDetectionThreshold0 (off)Logs a warning when a connection is out of the pool longer than this. Min 2000 ms.
autoCommittrueDefault auto-commit of connections handed out.
📌 Bullet points
  • 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 2

A transaction groups statements into one unit: BEGIN, several statements, COMMIT — or ROLLBACK, which undoes all of them. The classic promise is ACID:

LetterPromiseWhat it means for your code
AtomicityAll statements take effect, or noneA crash mid-way leaves no half-done transfer.
ConsistencyConstraints hold after every commitOnly rules the DB knows (PK, FK, CHECK, UNIQUE). Your business rules are your job.
IsolationConcurrent transactions don't see each other's mess…to a degree set by the isolation level. Chapters 3–4.
DurabilityCommitted means it survives a crashOnce COMMIT returns, the data is safe.
▶ Transfer 50 from A to B then crash between the two updates
|
Account A100
Account B0
Total100
Pick a mode and press a button.
Simplified: one session, no concurrency. The crash stands for anything between the statements — an exception, a killed pod, a lost network.
⚠️ Watch it!

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.

🤓 Deep dive

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.

📌 Bullet points
  • 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 result

Atomicity 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).

▶ Two transactions, one table pick an anomaly and watch the interleaving
Pick an anomaly.
Each scenario shows an interleaving that the weakest isolation which allows it would permit. Which levels prevent which anomaly is the next chapter. Values are illustrative.
AnomalyWhat happensTypical bug
Dirty readYou read data another transaction hasn't committedActing on a change that later rolls back
Non-repeatable readReading the same row twice gives different valuesReport totals that don't add up
Phantom readRe-running a query returns a different set of rows"Count, then insert if under limit" races
Lost updateTwo read-modify-write cycles; the second overwrites the firstStock, counters, balances edited in Java
Write skewTwo transactions read an overlapping set, each updates a different row, the invariant breaks"At least one doctor on call", double booking
✏️ Sharpen your pencil

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.

📌 Bullet points
  • 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 rules

The 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.

▶ Levels × phenomena switch the database, click a level
LevelDirty readNon-repeatablePhantomSerialization anomaly
Click a level.
Both views come from PostgreSQL 18 docs, Table 13.1: "SQL standard" shows what the standard permits, "PostgreSQL" marks where PostgreSQL is stricter. "Possible" means the standard allows it at that level, not that it happens on every run.
LevelPostgreSQLMySQL InnoDB
DefaultRead CommittedRepeatable Read
Read UncommittedBehaves like Read CommittedNonlocking SELECTs, “a possible earlier version of a row might be used”: dirty reads
Read CommittedEach statement sees a fresh snapshotEach consistent read sets its own fresh snapshot; no gap locks except FK and duplicate-key checks
Repeatable ReadOne snapshot per transaction (Snapshot Isolation); no phantoms; update conflicts fail with a serialization errorPlain reads use the snapshot of the first read; locking reads and range UPDATE/DELETE use gap or next-key locks
SerializableSerializable Snapshot Isolation: detects anomalies, aborts one transactionLike 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;
⚠️ Watch it!

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.

❓ There are no dumb questions
Q:
Does Read Committed stop the lost update from the last chapter?
A:
No. No standard phenomenon row is called "lost update" — and Read Committed allows it when you compute the new value in Java. In PostgreSQL, Repeatable Read does detect it (the second writer gets "could not serialize access due to concurrent update"). MySQL's Repeatable Read does not raise that error.
📌 Bullet points
  • 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.
Part II

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 door

Spring 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.

▶ Where does the transaction start? try each call path
Controller
orders.place()
Spring proxy
BEGIN … COMMIT
OrderService
place()
@Transactional
save()
Pick a call path.
Simplified: one bean, default settings (proxy mode, REQUIRED, default rollback rules).
TrapWhyFix
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 exceptionBy default only RuntimeException and Error roll backrollbackFor = Exception.class, or since Spring 6.2 @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS)
Exception caught insideThe proxy only sees exceptions that escape the methodRethrow, or call TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()
Called from @PostConstructThe proxy must be fully initialized firstRun the work after startup (e.g. an application-ready event listener) or from another bean
Interface-based proxy, non-public methodMust be public and declared in the interfaceSince 6.0, class-based proxies also make protected and package-visible methods transactional
❓ There are no dumb questions
Q:
What do the defaults of a bare @Transactional mean?
A:
Propagation REQUIRED, isolation DEFAULT (whatever the database default is), read-write, timeout of the underlying transaction system (or none), and rollback on RuntimeException/Error only.
Q:
Should @Transactional go on the repository or the service?
A:
On the service method that defines the unit of work — the set of changes that must commit together. Repositories then join that transaction.
🔬 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.

📌 Bullet points
  • 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 transactions

When 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).

PropagationTransaction existsNo transactionUse it when
REQUIRED (default)joins itcreates onealmost always
REQUIRES_NEWsuspends it, creates a new one (new connection)creates oneaudit/log rows that must survive the caller's rollback
NESTEDsavepoint inside itlike REQUIRED"try this part, undo only it on failure" (JDBC / DataSourceTransactionManager)
SUPPORTSjoins itruns withoutread helpers that don't care
MANDATORYjoins itthrowsmethods that must never be the unit of work
NOT_SUPPORTEDsuspends it, runs withoutruns withoutslow non-DB work called from a transaction
NEVERthrowsruns withoutasserting "no transaction here"
▶ Propagation simulator outer() is @Transactional and calls inner() on another bean
Pick a combination.
Assumes DataSourceTransactionManager-style JDBC transactions and default rollback rules. With JPA/JTA, NESTED support depends on the transaction manager.
⚠️ Watch it!

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".

⚠️ Watch it!

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.

🤓 Deep dive

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.

📌 Bullet points
  • 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 naive

Back 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.

▶ Last ticket, two buyers pick a strategy
Pick a strategy.
PostgreSQL, Read Committed. Timings are illustrative; in reality the order of the two transactions is random.
StrategyHowUse it when
Atomic UPDATEUPDATE … SET stock = stock - 1 WHERE id = ? AND stock > 0, check the row countThe 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 waitConflicts are frequent and retries are expensive
FOR UPDATE SKIP LOCKEDSkip rows someone else has lockedJob queues: many workers, each takes a different row
FOR UPDATE NOWAITError immediately instead of waitingInteractive actions that should fail fast
Unique constraintLet 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
}
⚠️ Watch it!

SKIP LOCKED gives "an inconsistent view of the data" by design. Perfect for a queue, wrong for a report or a balance check.

📌 Bullet points
  • Prefer pushing the rule into one SQL statement or a constraint.
  • @Version turns lost updates into exceptions you must handle (retry or tell the user).
  • FOR UPDATE serializes access to a row; keep those transactions short.

JPA and the queries you didn't write

persistence context, dirty checking, N+1, lazy loading

Inside 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.

▶ The N+1 problem list 20 orders and print each customer's name
SQL statements sent: 0
Pick a variant.
Illustrative: 20 orders with 20 different customers. With repeated customers, the persistence context avoids loading the same one twice, so N is the number of distinct customers.
Setting / toolDefaultAdvice
@ManyToOne, @OneToOne fetchEAGER (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_sizeoffTurns N lazy loads into N / size queries
DTO projection—Read-only screens: no tracking, no dirty checking
spring.jpa.open-in-viewon in Spring BootSet to false; fetch what the view needs inside the service
⚠️ Watch it!

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").

📌 Bullet points
  • 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 index

Every 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).

▶ Insert 1,000 entities change the batch size and ID strategy
…
Counts INSERT round trips only (sequence calls ignored). Illustrative latency: 1 ms per round trip.
⚠️ Watch it!

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;
❓ There are no dumb questions
Q:
What does @Transactional(readOnly = true) actually do?
A:
It is a hint that the transaction is "effectively read-only, allowing for corresponding optimizations at runtime". What gets optimized depends on the transaction manager, the ORM and the driver. Don't count on it to block writes, and note that a joining REQUIRED scope inherits the outer transaction's flag.
📌 Bullet points
  • Batch writes (hibernate.jdbc.batch_size); IDENTITY IDs disable insert batching.
  • Large OFFSETs are slow; use keyset pagination for deep pages.
  • readOnly = true is an optimization hint, not a guard.
Part III

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 readers

PostgreSQL (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.

▶ Row versions and snapshots same interleaving, two isolation levels
Versions of account A:
Press ▶.
Simplified: real versions carry transaction IDs, and PostgreSQL removes old versions later with VACUUM.
🤓 Deep dive

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".

⚠️ Watch it!

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.

📌 Bullet points
  • 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 retries

Write 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.

▶ Two doctors, one rule: someone must stay on call run under each level
Pick a level.
PostgreSQL. Which of the two transactions gets aborted is the database's choice; the exact error text varies, the SQLSTATE is always 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));
    }
  }
}
⚠️ Watch it!

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.)

❓ There are no dumb questions
Q:
Can't I fix write skew without Serializable?
A:
Yes: lock the rows the decision depends on (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.
Q:
Will Serializable make everything slow?
A:
It adds tracking and aborts. PostgreSQL recommends declaring transactions READ ONLY when possible, keeping them short, controlling connection count, and not leaving sessions idle in transaction.
📌 Bullet points
  • 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 one

Every 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.

▶ Transfer A→B and B→A at the same time then fix the lock order
Press ▶.
Solid arrow into a row = holds its lock; the curved arrows are "waits for". Timing compressed.
DefenseHow
Consistent lock orderPostgreSQL docs: "the best defense … acquire locks on multiple objects in a consistent order". Sort IDs before updating.
Take the strongest lock firstDon't read-lock then upgrade; lock for update up front.
Short transactionsFewer locks held, for less time.
RetryDeadlocks will still happen; treat 40P01 like 40001.
lock_timeoutBounds 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.

📌 Bullet points
  • 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 outbox

A 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.

▶ Place an order and publish OrderPlaced pick a design, then crash it
Database
Message broker
Consumer
Pick a design.
Each design is played with its worst crash point. Simplified: one message, one consumer.
DesignFailureUse it when
Send inside the transactionMessage sent, then rollback → event for an order that doesn't existNever for important events
@TransactionalEventListener (default phase AFTER_COMMIT)Crash after commit, before send → event lostBest-effort side effects: cache eviction, notifications you can miss
Transactional outboxRelay may publish twice → consumers must be idempotentEvents 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
⚠️ Watch it!

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.

📌 Bullet points
  • 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 noticing

A 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".

▶ 4 threads, REQUIRES_NEW inside each pool of 4 vs pool of 5
pool
threads
outer txinner txwaitingtimed out
Press ▶.
Illustrative sizes. Real pools are bigger, but the rule is Spring's: with REQUIRES_NEW, the pool must exceed the number of concurrent threads by at least 1.

Timeouts: your safety net

SettingDefaultStops
statement_timeout0 (off)Any single statement running too long
lock_timeout0 (off)Waiting too long for one lock
idle_in_transaction_session_timeout0 (off)Sessions idle inside an open transaction (terminates the session)
transaction_timeout0 (off)Whole transactions longer than the limit (PostgreSQL 17+)
@Transactional(timeout = 5)underlying defaultSpring-side limit, in seconds
Hikari connectionTimeout30 sWaiting 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.

🧰 Refactoring a slow transaction
  1. Do remote calls, file I/O and heavy computation before the transaction.
  2. Open a short transaction: re-read what you need, validate, write.
  3. Trigger side effects after commit (outbox or after-commit listener).
  4. For long batch jobs, commit in chunks — and make each chunk restartable.
📌 Bullet points
  • 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 free

Replicas (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.

ScenarioProblemFix
User saves, page reloads from replicaTheir change "disappears"Read from the primary for a short time after a user writes (read-your-writes)
Check on replica, write on primaryThe check is based on stale dataValidate and write in one transaction on the primary
Routing by readOnly = trueA read-only method called inside a write transaction joins it, on the primaryDecide routing at the outermost transaction
Long queries on the replicaCan conflict with replayed changes and be cancelled, or delay replayTune the standby delay settings for the replica's purpose
📌 Bullet points
  • Replicas lag. Never validate business rules on a replica.
  • Decide per use case whether stale reads are acceptable.
Part IV

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 down

Most 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.

▶ A 5 ms ALTER behind a long report with and without lock_timeout
report tx
ALTER TABLE
app queries
Press ▶.
Illustrative timeline. The queuing follows PostgreSQL's lock manager rules (a request is granted immediately only if it conflicts with no granted and no waiting request — src/backend/storage/lmgr/README; the docs of pg_blocking_pids call this a "soft block").
ChangeRisky waySafer way
Any DDLRun and hopeSET lock_timeout = '2s' in the migration; retry on failure
New indexCREATE INDEX blocks writes for the whole buildCREATE INDEX CONCURRENTLY — not allowed inside a transaction block; check for an INVALID index if it fails
New column with defaultVolatile default (e.g. clock_timestamp()) rewrites the tableNon-volatile default: stored in metadata, "very fast even on large tables"
New FK / CHECKValidates all rows under a strong lockADD CONSTRAINT … NOT VALID, then VALIDATE CONSTRAINT separately
Rename / drop a columnOld app version still running breaksExpand → migrate → contract across several deploys
🧰 Expand / contract
  1. Expand: add the new column/table; code writes both old and new.
  2. Migrate: backfill in small batches (short transactions!).
  3. Switch: code reads from the new structure.
  4. Contract: once no running version uses the old one, drop it.
📌 Bullet points
  • A waiting ACCESS EXCLUSIVE request blocks newcomers too. Always set lock_timeout for DDL.
  • Index concurrently; add constraints NOT VALID then 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 friend

pg_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.

▶ The site is slow. Find the culprit. a fake pg_stat_activity
pidstatexact agewaitquery
Press a button.
Invented data. Real columns: 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;
SignalWhereMeans
Pool active = max, threads pending > 0Hikari pool metricsPool exhaustion: look at hold times
"Connection leak detection triggered for …"Hikari log with leakDetectionThresholdA connection out of the pool too long — stack trace shows who took it
Many idle in transactionpg_stat_activityWork done inside open transactions
Slow statementslog_min_duration_statement, pg_stat_statementsQueries to index or rewrite
Lock waitslog_lock_waits, pg_locks, pg_blocking_pids()Contention or long transactions
Statement count per requestHibernate statistics / SQL logging in testsN+1 regressions — assert the count in tests
📌 Bullet points
  • 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
SymptomLikely causeFix
"Connection is not available, request timed out after 30000ms"Pool exhausted: slow work inside transactions, leaks, REQUIRES_NEW under loadShorten transactions, leak detection, size pool > threads with REQUIRES_NEW
UnexpectedRollbackExceptionInner REQUIRED method threw, outer caught itLet it propagate, or use REQUIRES_NEW/NESTED deliberately
Data saved although an exception was thrownChecked exception, swallowed exception, or self-invocation (no transaction)Rollback rules; rethrow; call through the proxy
LazyInitializationExceptionLazy association accessed after the transactionJOIN FETCH / entity graph / DTO in the service
Page makes hundreds of queriesN+1, often EAGER to-one or Open Session in ViewLAZY + fetch per use case; open-in-view=false
Optimistic-lock exception (StaleObjectStateException, OptimisticEntityLockException)Concurrent edit of an entity with @VersionRetry or show "changed by someone else"
SQLSTATE 40001Serialization failure (RR/Serializable)Retry the whole transaction from outside
SQLSTATE 40P01 "deadlock detected"Inconsistent lock orderSort before locking; retry
"current transaction is aborted, commands ignored until end of transaction block"An earlier statement failed; code carried onRoll back (or use a savepoint) after any SQL error
Sold twice / counter off by a fewLost update via read-modify-writeAtomic UPDATE, @Version or FOR UPDATE
Duplicate rows despite "check then insert"Race between the check and the insertUnique constraint + handle 23505
Table bloat, slow queries over daysLong or idle-in-transaction sessions holding back VACUUMidle_in_transaction_session_timeout, fix the app
Whole app freezes during a deployDDL waiting for ACCESS EXCLUSIVE, queue behind itlock_timeout in migrations
User doesn't see their own changeRead from a lagging replicaRead-your-writes routing

The checklist for every app that uses a database

go through it at design review and before go-live
▶ Review checklist ticks are saved in this browser
0 / 0
Stored in localStorage on this device only; nothing is sent anywhere.

Common mistakes and interview traps

answer first, then open the card

Each 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 stuck
Score: 0 / 0

Cheat sheet

Parameter / commandDefault / what it doesUse when
@TransactionalREQUIRED, DEFAULT isolation, read-write, rollback on RuntimeException/ErrorOn the service method that is the unit of work
rollbackFor = Exception.classAlso roll back on checked exceptionsYour methods throw checked exceptions
Propagation.REQUIRES_NEWSuspend outer, new transaction + connectionAudit rows that must survive; pool > threads
Propagation.NESTEDSavepoint in the outer transactionPartial rollback with JDBC transactions
Isolation.REPEATABLE_READPG: snapshot per transaction, 40001 on conflictsConsistent multi-query reads (reports)
Isolation.SERIALIZABLEPG: SSI, aborts anomalies with 40001Complex invariants; with a retry loop
@VersionOptimistic lock; exception on conflicting updateRare conflicts, long edits
SELECT … FOR UPDATE [NOWAIT | SKIP LOCKED]Row lock until commitHot rows; job queues (SKIP LOCKED)
hibernate.jdbc.batch_sizeBatch statements (guide: 10–50); IDENTITY disables insert batchingBulk writes
spring.jpa.open-in-view=falseBoot default registers the OSIV interceptorAlways, in services with clear boundaries
maximumPoolSize10Size from load tests
connectionTimeout30000 msLower to fail fast
leakDetectionThreshold0 (off); min 2000 msFind connections held too long
statement_timeout / lock_timeout0 (off)Per role/session; lock_timeout in every migration
idle_in_transaction_session_timeout0 (off)Kill sessions forgotten in a transaction
deadlock_timeout1 sWait before the deadlock check
CREATE INDEX CONCURRENTLYNo write lock; not in a transaction blockIndexes on live tables
SQLSTATE 40001 / 40P01 / 23505serialization failure / deadlock / unique violationRetry candidates
🎓 Congratulations

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.