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:
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:
com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transactionThe weird part was that the error only happened when I called one service method from another. Let me show you the problematic code:
@Servicepublic 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! }}@Servicepublic 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:
processOrderstarts a transaction (let’s call it Transaction A)orderRepository.save(order)inserts a row and acquires a row lock- The transaction is NOT committed yet - it’s still active
processPaymentis called within the same transaction (due to defaultREQUIREDpropagation)processPaymenttries to query the same table- It waits for the lock held by Transaction A
- Transaction A is waiting for
processPaymentto complete - 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
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:
@Servicepublic 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:
- It creates a new transaction (Transaction B)
- Transaction A’s locks are still held, but Transaction B can read committed data
- Transaction B completes and commits
- 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:
@Servicepublic 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:
@Servicepublic 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)); }}@Componentpublic 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:
@Componentpublic 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:
-- Session levelSET lock_timeout = '10s';
-- Or in application.ymlspring: jpa: properties: javax.persistence.lock.timeout: 10000MySQL:
SET innodb_lock_wait_timeout = 10;You can also use NOWAIT or SKIP LOCKED for read queries:
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:
@Entitypublic 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:
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_timeoutis 0 (infinite wait) - Row-level locks:
FOR UPDATE,FOR SHARE,FOR NO KEY UPDATE - Deadlock detection is automatic
- Use
pg_locksview to monitor locks:
SELECT * FROM pg_locks WHERE NOT granted;MySQL
- Default
innodb_lock_wait_timeoutis 50 seconds - Gap locks in
REPEATABLE READisolation can cause unexpected locks - Consider using
READ COMMITTEDisolation level:
spring: jpa: properties: hibernate.connection.isolation: 2 # READ COMMITTEDBest Practices to Avoid Lock Timeouts
- Keep transactions short - Don’t do heavy processing within a transaction
- Don’t call external services within transactions - External API calls can take unpredictable time
- Use appropriate isolation levels -
READ COMMITTEDis often sufficient - Consider event-driven architecture - Decouple operations that don’t need to be atomic
- Monitor long-running transactions - Set up alerts for transactions exceeding thresholds
@Transactional(timeout = 10) // 10 seconds timeoutpublic 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:
- Immediate fix: Use
REQUIRES_NEWpropagation for operations that need independent transactions - Better approach: Restructure code to avoid calling other services within write transactions
- 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:
- 👨💻 Spring Framework Transaction Documentation
- 👨💻 PostgreSQL Row Locks Documentation
- 👨💻 MySQL InnoDB Locking
- 👨💻 Martin Fowler: Patterns of Enterprise Application Architecture
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments