Skip to content

How to Fix Row Lock Timeout in Spring Boot Transactions

Problem

When I was working on a Spring Boot application, I encountered a strange issue. The application would hang for about 50 seconds and then throw this error:

Error Log
org.postgresql.util.PSQLException: ERROR: lock not available
at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2553)
at org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2285)
...

Or on MySQL:

Error Log
com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction

The weird part was that the error only happened when I called one service method from another. Let me show you the problematic code:

OrderService.java
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private PaymentService paymentService;
@Transactional
public void processOrder(Order order) {
orderRepository.save(order); // Insert a row
paymentService.processPayment(order); // This hangs!
}
}
PaymentService.java
@Service
public class PaymentService {
@Autowired
private PaymentRepository paymentRepository;
@Transactional
public void processPayment(Order order) {
// Try to query the order that was just inserted
Payment payment = paymentRepository.findByOrderId(order.getId());
// ... process payment
}
}

When processOrder called processPayment, the application would hang and eventually timeout. Why?

Environment

  • Spring Boot 3.2.x
  • Java 21
  • PostgreSQL 15 (or MySQL 8.0)
  • Spring Data JPA

What happened?

I spent hours debugging this. At first, I thought it was a deadlock issue. But then I realized the real problem: transaction boundaries and row locks.

Here’s what was happening:

  1. processOrder starts a transaction (let’s call it Transaction A)
  2. orderRepository.save(order) inserts a row and acquires a row lock
  3. The transaction is NOT committed yet - it’s still active
  4. processPayment is called within the same transaction (due to default REQUIRED propagation)
  5. processPayment tries to query the same table
  6. It waits for the lock held by Transaction A
  7. Transaction A is waiting for processPayment to complete
  8. Circular wait = timeout

The key insight is that Spring’s default @Transactional propagation is REQUIRED, which means it joins an existing transaction if one exists. So both methods run in the same transaction, and the locks from the first method are still held when the second method executes.

Understanding Row Locks

Before diving into solutions, let me explain what row locks are:

  • When locks are acquired: INSERT, UPDATE, DELETE operations acquire row-level locks
  • When locks are released: On COMMIT or ROLLBACK
  • The trap: If you call another service method within a transaction, the parent transaction’s locks are still held
Lock Timeline
Transaction A starts
|
+-- INSERT order (lock acquired on order row)
|
+-- Call processPayment()
| |
| +-- SELECT order (waits for lock... timeout!)
|
+-- Transaction A never commits (stuck)

Solution 1: Use REQUIRES_NEW Propagation

The quickest fix is to use REQUIRES_NEW propagation for the inner method. This creates a new, independent transaction:

PaymentService.java
@Service
public class PaymentService {
@Autowired
private PaymentRepository paymentRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processPayment(Order order) {
// New transaction - no lock conflict
Payment payment = paymentRepository.findByOrderId(order.getId());
// ... process payment
}
}

Now when processPayment is called:

  1. It creates a new transaction (Transaction B)
  2. Transaction A’s locks are still held, but Transaction B can read committed data
  3. Transaction B completes and commits
  4. Transaction A continues and eventually commits

Solution 2: Restructure Transaction Boundaries

A better approach is to restructure your code so that you don’t call other services within a write transaction:

OrderService.java
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private PaymentService paymentService;
public void processOrder(Order order) {
// Step 1: Save order in its own transaction
saveOrder(order);
// Step 2: Process payment after order is committed
paymentService.processPayment(order);
}
@Transactional
public void saveOrder(Order order) {
orderRepository.save(order);
}
}

This way, the order is committed before payment processing starts, releasing all locks.

Solution 3: Event-Driven Approach

For a cleaner architecture, use Spring events to decouple the operations:

OrderService.java
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private ApplicationEventPublisher eventPublisher;
@Transactional
public void processOrder(Order order) {
orderRepository.save(order);
// Publish event - will be processed after commit
eventPublisher.publishEvent(new OrderCreatedEvent(order));
}
}
PaymentHandler.java
@Component
public class PaymentHandler {
@Autowired
private PaymentService paymentService;
@EventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleOrderCreated(OrderCreatedEvent event) {
// Runs after OrderService transaction commits
paymentService.processPayment(event.getOrder());
}
}

For even better control, use @TransactionalEventListener:

PaymentHandler.java
@Component
public class PaymentHandler {
@Autowired
private PaymentService paymentService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleOrderCreated(OrderCreatedEvent event) {
// Guaranteed to run after the source transaction commits
paymentService.processPayment(event.getOrder());
}
}

Solution 4: Database-Level Tuning

You can also configure lock timeouts at the database level, though this is more of a band-aid than a fix:

PostgreSQL:

Set lock timeout
-- Session level
SET lock_timeout = '10s';
-- Or in application.yml
spring:
jpa:
properties:
javax.persistence.lock.timeout: 10000

MySQL:

Set lock wait timeout
SET innodb_lock_wait_timeout = 10;

You can also use NOWAIT or SKIP LOCKED for read queries:

PostgreSQL NOWAIT example
SELECT * FROM orders WHERE id = 1 FOR UPDATE NOWAIT;

This throws an error immediately if the row is locked, rather than waiting.

Solution 5: Optimistic Locking

For scenarios where conflicts are rare, consider optimistic locking instead of pessimistic locking:

Product.java
@Entity
public class Product {
@Id
private Long id;
@Version
private Long version; // Optimistic lock version
private String name;
private BigDecimal price;
}

With @Version, JPA automatically checks the version before committing. If another transaction modified the row, you get an OptimisticLockException:

Handling optimistic lock exception
try {
productRepository.save(product);
} catch (OptimisticLockException e) {
// Handle conflict - retry or notify user
}

Optimistic locking doesn’t hold database locks during the transaction, only checking at commit time.

Database-Specific Considerations

PostgreSQL

  • Default lock_timeout is 0 (infinite wait)
  • Row-level locks: FOR UPDATE, FOR SHARE, FOR NO KEY UPDATE
  • Deadlock detection is automatic
  • Use pg_locks view to monitor locks:
Check current locks
SELECT * FROM pg_locks WHERE NOT granted;

MySQL

  • Default innodb_lock_wait_timeout is 50 seconds
  • Gap locks in REPEATABLE READ isolation can cause unexpected locks
  • Consider using READ COMMITTED isolation level:
application.yml
spring:
jpa:
properties:
hibernate.connection.isolation: 2 # READ COMMITTED

Best Practices to Avoid Lock Timeouts

  1. Keep transactions short - Don’t do heavy processing within a transaction
  2. Don’t call external services within transactions - External API calls can take unpredictable time
  3. Use appropriate isolation levels - READ COMMITTED is often sufficient
  4. Consider event-driven architecture - Decouple operations that don’t need to be atomic
  5. Monitor long-running transactions - Set up alerts for transactions exceeding thresholds
Transaction timeout configuration
@Transactional(timeout = 10) // 10 seconds timeout
public void longRunningOperation() {
// Will rollback if takes longer than 10 seconds
}

Summary

Row lock timeouts in Spring Boot are fundamentally a transaction design issue, not a framework problem. The key is understanding that locks persist until transaction commit, and nested service calls within the same transaction will block.

Here’s my recommended approach:

  1. Immediate fix: Use REQUIRES_NEW propagation for operations that need independent transactions
  2. Better approach: Restructure code to avoid calling other services within write transactions
  3. Best practice: Keep transactions short, use events for post-commit operations, and choose appropriate isolation levels

Remember: the default behavior where Spring joins existing transactions is usually correct. Only use REQUIRES_NEW when you have a specific reason for transaction isolation, as it can lead to data inconsistency if not carefully managed.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments