Sitemap

The Deadlock My Database Couldn’t See

12 min readOct 30, 2026

--

How a Spring Boot transaction locked my own rows against me, and how to avoid it

Press enter or click to view image in full size

The day my UI froze

Earlier this year, a feature I was building froze the UI. There was no crash and no obvious bug in the logic, just a request that never came back. The cause was a deadlock at the database level, and I had created it myself with one misplaced Spring transaction.

The feature was simple: update a set of rows, then fetch those same rows and send them back to the screen. Both steps ran in one request, under @Transactional, with the default isolation level.

The update had locked the rows, and the transaction holding those locks was still open. The fetch was waiting for those locks. But the transaction would only commit after the fetch returned. Each side was waiting for the other, forever.

Thankfully, it happened in a non-production environment. The fix was to update the rows in their own transaction, let it commit, and only then fetch them.

This post covers what I learned:

  • Why a row you just updated can block you
  • Why the database never reported it as a deadlock
  • Why the default isolation level doesn’t matter here
  • The other common ways Spring Boot apps deadlock, and a checklist to avoid them

Locks live until commit, not until the statement ends

When a transaction updates a row, the database puts an exclusive lock on it. That lock stays until the transaction commits or rolls back, not when the UPDATE statement finishes. This is true at every isolation level.

While the lock is held:

  • Your own transaction can read and change the row freely.
  • Any other transaction that wants to update, delete or lock that row (for example with SELECT ... FOR UPDATE) has to wait.

In Spring, @Transactional decides where that window starts and ends. With the default propagation, REQUIRED, the transaction begins at the outermost @Transactional method and commits when that method returns. Every repository call in between shares the same transaction and the same database connection.

That gives us the rule this whole post rests on: if a lock you took is blocking you, part of your code is on a second connection. To the database, that’s a separate session, even if your code has only one transaction.

What actually went wrong

Here is a simplified example of the pattern. In my code there was just one transactional method: update the rows, then fetch them for the UI.

@Service
@RequiredArgsConstructor
public class OrderApprovalService {
private final OrderRepository orderRepository;
private final OrderReader orderReader;
@Transactional
public List<Order> approve(List<Long> orderIds) {
// UPDATE orders SET status = 'APPROVED' WHERE id IN (...)
// Row locks are taken here and held until approve() returns
orderRepository.updateStatus(orderIds, OrderStatus.APPROVED);
// Fetch the same rows to send back to the UI.
// Looks like part of the same transaction...
return orderReader.findForDisplay(orderIds);
}
}

Nothing in this method asks for a second transaction. Yet the fetch hung, waiting on the locks the update had just taken.

This is the part that took me longest to understand: a database transaction never waits for its own locks. If the update and the fetch had really gone through the same connection, the fetch would have returned at once, with the new status.

A lock you hold can only block you when part of your code reaches the database on a second connection. To the database, that connection is a separate session waiting in line, even though your code has one @Transactional.

In Spring, that second connection can slip in without you ever writing a second transaction:

  • A method or repository on the call path annotated @Transactional(propagation = Propagation.REQUIRES_NEW)
  • The fetch running on another thread (@Async, CompletableFuture.supplyAsync(), a parallel stream). Spring binds a transaction to a thread, so another thread means another connection.
  • A second DataSource or transaction manager, or a JdbcTemplate that isn't wired to the one your @Transactional uses
  • An EntityManager created by hand with entityManagerFactory.createEntityManager()
  • A call to another service (REST or messaging) that reads the same rows from the same database

Step by step:

  1. approve() begins the transaction on connection 1 and updates the rows. Connection 1 now holds their locks.
  2. The fetch reaches the database on connection 2.
  3. Connection 2 asks for the same rows and waits for connection 1’s locks.
  4. Connection 1 can’t commit until the fetch returns, and the fetch can’t return until connection 1 commits.
Press enter or click to view image in full size

One @Transactional method. The update holds the locks on connection 1, and the fetch waits for them on connection 2.

Why the database never said “deadlock”

Databases detect deadlocks by looking for a cycle of sessions waiting on each other’s locks. Here the database sees only half of the cycle: connection 2 waiting for a lock, and connection 1 sitting idle inside an open transaction. The other half, “approve() is waiting for the fetch", lives inside your Java thread, where the database can't see it.

Press enter or click to view image in full size

Half of the cycle lives in your JVM, so the database’s deadlock detector never finds a cycle.

So there is no deadlock error. The request hangs until a lock timeout fires, if one is set at all:

Press enter or click to view image in full size

That’s why it looked like a frozen UI instead of an error in the logs.

Why the default isolation level didn’t save me

@Transactional uses Isolation.DEFAULT unless you say otherwise. That means "use whatever the database's default is":

Isolation levels control what your reads see. They don’t change how long write locks last: at every level, a row you update stays locked until you commit. Raising or lowering the isolation level would not have fixed my bug.

Where databases do differ is whether a plain SELECT waits for that lock:

  • PostgreSQL, Oracle and MySQL (InnoDB) use multi-version concurrency control (MVCC). A plain SELECT from another connection doesn't wait. It reads the last committed version of the row, so you silently get the old, pre-update data.
  • SQL Server, under its default READ COMMITTED (with READ_COMMITTED_SNAPSHOT off), makes a plain SELECT wait for the writer.
  • Locking reads wait on every database: SELECT ... FOR UPDATE, JPA's @Lock(PESSIMISTIC_WRITE) or @Lock(PESSIMISTIC_READ), and any UPDATE or DELETE.

So reading rows you just changed through a second connection is always a bug. Depending on your database and your query, you either hang or read stale data.

The fix: commit first, then read

I gave the update its own transaction, let it commit, and only then read the rows. Once the update commits, its locks are released, and the read has nothing to wait for.

@Service
@RequiredArgsConstructor
public class OrderApprovalService {
private final OrderCommandService orderCommandService;
private final OrderQueryService orderQueryService;
// No @Transactional here: this method only runs the two steps in order
public List<Order> approve(List<Long> orderIds) {
orderCommandService.markApproved(orderIds);
// transaction 1: UPDATE, then COMMIT
return orderQueryService.findForDisplay(orderIds);
// transaction 2: reads committed rows
}
}
@Service
@RequiredArgsConstructor
public class OrderCommandService {
private final OrderRepository orderRepository;
@Transactional
public void markApproved(List<Long> orderIds) {
orderRepository.updateStatus(orderIds, OrderStatus.APPROVED);
}
}
@Service
@RequiredArgsConstructor
public class OrderQueryService {
private final OrderRepository orderRepository;
@Transactional(readOnly = true)
public List<Order> findForDisplay(List<Long> orderIds) {
return orderRepository.findAllById(orderIds);
}
}
Press enter or click to view image in full size

Commit the update first, then fetch. The locks are gone before anyone asks for the rows.

This only works if nothing above approve() has already opened a transaction. If a caller is @Transactional, markApproved() joins that transaction and nothing commits until the caller returns.

The other valid fix: one transaction, start to finish

If the update and the read must succeed or fail together, keep them in a single transaction instead. Make sure the fetch really runs on the same connection by removing whatever moves it off: REQUIRES_NEW, another thread or another DataSource. A transaction can always read its own uncommitted changes, so nothing waits.

Get Siddarth Ambannavar’s stories in your inbox

Join Medium for free to get updates from this writer.

One catch with JPA: a @Modifying query writes straight to the database and skips the persistence context. Entities already loaded in that transaction can then be stale. Ask Spring Data to flush before and clear after the update:

@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("update Order o set o.status = :status where o.id in :ids")
int updateStatus(@Param("ids") List<Long> ids, @Param("status") OrderStatus status);

What you must not do is split the work across two connections that are open at the same time.

Watch out for self-invocation

Spring applies @Transactional through a proxy. If approve() calls this.markApproved() in the same class, the call never passes through the proxy, and no separate transaction starts. Your fix quietly does nothing.

Put the transactional method in a different bean, as above, or set the boundaries explicitly with TransactionTemplate:

public List<Order> approve(List<Long> orderIds) {
transactionTemplate.executeWithoutResult(status ->
orderRepository.updateStatus(orderIds, OrderStatus.APPROVED));
// commits here
return transactionTemplate.execute(status ->
orderRepository.findAllById(orderIds));
}

Five more ways Spring Boot apps deadlock

My bug was a self-deadlock. These are the other patterns that show up most often in Spring Boot services.

1. Locking the same rows in a different order

This is the textbook deadlock. Two requests transfer money between the same two accounts, in opposite directions. Each one locks its “from” account first, then reaches for the other.

Press enter or click to view image in full size

The classic deadlock, and its fix: take locks in the same order everywhere.

This time the database does see the full cycle. It picks one transaction as the victim and rolls it back. PostgreSQL reports deadlock detected, and MySQL reports Deadlock found when trying to get lock; try restarting transaction. Spring translates both into a PessimisticLockingFailureException or one of its subclasses, such as CannotAcquireLockException.

The fix is to always take locks in the same order, for example by primary key:

@Transactional
public void transfer(long fromId, long toId, BigDecimal amount) {
// Lock the lower id first, whichever way the money moves
Account first = accountRepository.findByIdForUpdate(Math.min(fromId, toId));
Account second = accountRepository.findByIdForUpdate(Math.max(fromId, toId));
Account from = first.getId() == fromId ? first : second;
Account to = (from == first) ? second : first;
from.withdraw(amount);
to.deposit(amount);
}

For batch updates, sort the ids before you update them.

2. Hibernate runs your SQL in its own order

save() doesn't run SQL right away. Hibernate collects changes and writes them at flush time, grouped by type: inserts first, then updates, then deletes. The order of calls in your Java code is not necessarily the order in which rows get locked.

One setting makes Hibernate update rows in a consistent order across requests. Its documentation notes that it leads to fewer deadlocks in highly concurrent systems:

spring.jpa.properties.hibernate.order_updates=true

It sorts updates by entity type and primary key. When a specific order matters in one place, flush explicitly with saveAndFlush() or entityManager.flush().

3. Transactions that stay open too long

A @Transactional method that calls a payment API, sends an email or uploads a file holds its row locks for the whole call. The longer locks are held, the more likely another request runs into them.

Do slow work outside the transaction, or after it commits. Publish an event inside the transaction, and handle it once the commit has happened:

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderApproved(OrderApprovedEvent event) {
emailClient.sendApprovalMail(event.orderId());
}

4. Missing indexes on the rows you update

In MySQL (InnoDB), an UPDATE or DELETE locks every index record it scans, not just the rows that match. Under the default REPEATABLE READ, it also locks the gaps between them. Without an index on the WHERE column, a statement scans, and locks, far more rows than you expect, so two "unrelated" updates collide.

Index the columns your UPDATE and DELETE statements filter by, including foreign keys.

5. REQUIRES_NEW and an exhausted connection pool

REQUIRES_NEW needs a second connection while the first one is still held. HikariCP, Spring Boot's default pool, holds 10 connections by default. If 10 requests each hold one connection and all ask for a second, none of them can continue.

No row is locked, but it is still a deadlock. After connectionTimeout (30 seconds by default), the requests fail with Connection is not available, request timed out after 30000ms. HikariCP's sizing advice for this case is a pool of at least threads × (connections per thread − 1) + 1.

How to avoid deadlocks: a checklist

1. Never touch rows you just changed from a second connection. No REQUIRES_NEW, @Async, extra threads or service calls on those rows while your transaction is open. Stay in one transaction, or commit first.

2. Keep transactions short. No HTTP calls, file uploads or waiting on a user inside @Transactional.

3. Lock rows in a consistent order. Sort ids before bulk updates, and turn on hibernate.order_updates.

4. Turn hangs into errors with timeouts. A request that fails after 5 seconds is far easier to debug than one that hangs forever.

// Roll back if the whole transaction takes longer than 5 seconds
@Transactional(timeout = 5)
public List<Order> approve(List<Long> orderIds) { ... }
# PostgreSQL: fail any statement that waits more than 5 s for a lock
spring.datasource.hikari.connection-init-sql=SET lock_timeout = '5s'
# MySQL equivalent (the default is 50 s)
# spring.datasource.hikari.connection-init-sql=SET SESSION innodb_lock_wait_timeout = 5

PostgreSQL’s idle_in_transaction_session_timeout is a useful safety net for self-deadlocks in particular. It ends any session that sits idle inside an open transaction, which is exactly what your updating connection looks like from the database's side.

5. Retry deadlock victims, around the whole transaction. A deadlock rolls back the entire transaction, so retrying one statement inside it doesn’t help. With Spring Retry (@EnableRetry), the retry advice runs outside @Transactional by default, so each attempt gets a fresh transaction:

@Retryable(retryFor = PessimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2))
@Transactional
public void transfer(long fromId, long toId, BigDecimal amount) { ... }

Only retry work that is safe to run twice. Spring Framework 7 also ships a built-in @Retryable (org.springframework.resilience.annotation); whichever you use, make sure each attempt starts a new transaction.

6. Prefer optimistic locking when conflicts are rare. With a @Version field, nothing stays locked between reading a row and writing it back. A conflicting change shows up as an ObjectOptimisticLockingFailureException that you can retry.

@Entity
public class Order {
@Id
private Long id;
@Version
private Long version;
// ...
}

7. Index the columns your writes filter on, including foreign keys, so UPDATE and DELETE statements lock only the rows they need.

8. Size the connection pool for nested transactions, or keep REQUIRES_NEW off busy code paths.

How to spot it when it happens

1. Log transaction boundaries. Turn on debug logging for your transaction manager:

logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG

A line like Suspending current transaction, creating new transaction with name [...] right before the hang points straight at a REQUIRES_NEW.

2. Take a thread dump of the stuck service with jstack <pid>, or the Actuator threaddump endpoint. The stuck request thread is waiting inside the JDBC driver, a few frames below your repository call.

3. Ask the database who is blocking whom. On PostgreSQL:

SELECT pid,
state,
pg_blocking_pids(pid) AS blocked_by,
now() - xact_start AS tx_age,
left(query, 80) AS last_query
FROM pg_stat_activity
WHERE datname = current_database()
AND state <> 'idle';

The self-deadlock signature: the blocking session’s state is idle in transaction, its last query is your UPDATE, and both sessions come from the same application.

On MySQL 8:

SELECT waiting_pid, waiting_query, blocking_pid, blocking_query
FROM sys.innodb_lock_waits;

A NULL in blocking_query means the blocker is idle inside an open transaction. For deadlocks the database did detect, SHOW ENGINE INNODB STATUS prints the most recent one under LATEST DETECTED DEADLOCK, and PostgreSQL writes the details of each one to its server log.

Takeaways

  • A row lock lasts until commit, at every isolation level.
  • If your own lock is blocking you, a second connection is involved, even with one transaction in your code. Find it: REQUIRES_NEW, another thread, another DataSource or another service.
  • The database can’t detect a deadlock that runs through your Java code. It just hangs, so set lock timeouts.
  • Commit first, then read, or keep it all in one transaction. Never split one unit of work across two connections that overlap.
  • For classic deadlocks: lock in a consistent order, keep transactions short, and retry the victim.

I was lucky to learn this in a non-production environment. If you’ve run into a different kind of Spring transaction deadlock, I’d like to hear about it in the comments.

--

--

Siddarth Ambannavar
Siddarth Ambannavar

Written by Siddarth Ambannavar

Full stack developer at Fintech firm