Skip to content

Why Does Spring Boot Transaction Deadlock When Calling Another Service?

Problem

When I run my Spring Boot application that processes orders, I get this deadlock error:

2026-03-14 08:15:45,123 WARN [main] o.h.e.j.s.SqlExceptionHelper - SQL Error: 1213, SQLState: 40001
2026-03-14 08:15:45,456 ERROR [main] o.h.e.j.s.SqlExceptionHelper - Deadlock found when trying to get lock; try restarting transaction
2026-03-14 08:15:46,789 ERROR [main] o.s.t.i.TransactionInterceptor - Application exception overridden by rollback exception
org.springframework.dao.DeadlockLoserDataAccessException: Deadlock found when trying to get lock; try restarting transaction

The application hangs for a while and then throws a deadlock exception.

Environment

  • Spring Boot 3.2.0
  • Java 21
  • MySQL 8.0
  • Spring Data JPA

What happened?

I was building an order processing system where OrderService calls ValidationService to validate orders. Here’s my original code:

src/main/java/com/example/OrderService.java
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ValidationService validationService;
public OrderService(OrderRepository orderRepository, ValidationService validationService) {
this.orderRepository = orderRepository;
this.validationService = validationService;
}
@Transactional
public void processOrder(Order order) {
orderRepository.save(order); // Insert row - lock acquired
validationService.validateOrder(order); // Calls another service
order.setStatus("VALIDATED");
}
}
src/main/java/com/example/ValidationService.java
@Service
public class ValidationService {
private final OrderRepository orderRepository;
public ValidationService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void validateOrder(Order order) {
Order savedOrder = orderRepository.findById(order.getId()).orElse(null);
// Validation logic...
}
}

I can explain the key parts:

  • @Transactional on OrderService.processOrder() starts Transaction 1
  • orderRepository.save(order) inserts a row and acquires a row lock
  • validationService.validateOrder(order) calls another service
  • @Transactional(propagation = Propagation.REQUIRES_NEW) on ValidationService starts a NEW transaction

The problem is that the new transaction cannot see the uncommitted row from Transaction 1, and it waits for the lock to be released. But Transaction 1 is waiting for Transaction 2 to complete. This creates a deadlock.

How to solve it?

I tried several approaches to fix this issue.

Approach 1: Use Default Propagation (REQUIRED)

The simplest fix is to remove the REQUIRES_NEW propagation:

src/main/java/com/example/ValidationService.java
@Service
public class ValidationService {
private final OrderRepository orderRepository;
public ValidationService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional // Default: REQUIRED - joins existing transaction
public void validateOrder(Order order) {
Order savedOrder = orderRepository.findById(order.getId()).orElse(null);
// Validation logic...
}
}

Now test again:

Terminal window
mvn spring-boot:run
2026-03-14 08:25:15,123 INFO [main] o.s.b.SpringApplication - Started Application in 1.456 seconds
2026-03-14 08:25:16,456 INFO [main] c.e.OrderService - Order processed successfully

You can see that I succeeded to process the order without deadlock. The REQUIRED propagation (default) means the method joins the existing transaction instead of creating a new one.

Approach 2: Restructure Service Calls

If you really need a new transaction (for example, to commit validation separately), restructure the code:

src/main/java/com/example/OrderService.java
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ValidationService validationService;
public OrderService(OrderRepository orderRepository, ValidationService validationService) {
this.orderRepository = orderRepository;
this.validationService = validationService;
}
public void processOrder(Order order) {
Long orderId = createOrder(order); // Transaction 1 commits here
validationService.validateOrder(orderId); // Transaction 2 sees committed data
}
@Transactional
public Long createOrder(Order order) {
orderRepository.save(order);
return order.getId();
}
}
src/main/java/com/example/ValidationService.java
@Service
public class ValidationService {
private final OrderRepository orderRepository;
public ValidationService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void validateOrder(Long orderId) {
Order savedOrder = orderRepository.findById(orderId).orElse(null);
// Validation logic...
}
}

This approach commits Transaction 1 before starting Transaction 2, so the new transaction can see the committed data.

Approach 3: Use @TransactionalEventListener

For post-commit actions, use the event listener pattern:

src/main/java/com/example/OrderCreatedEvent.java
public class OrderCreatedEvent {
private final Long orderId;
public OrderCreatedEvent(Long orderId) {
this.orderId = orderId;
}
public Long getOrderId() {
return orderId;
}
}
src/main/java/com/example/OrderService.java
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
public OrderService(OrderRepository orderRepository, ApplicationEventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.eventPublisher = eventPublisher;
}
@Transactional
public void processOrder(Order order) {
orderRepository.save(order);
eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
order.setStatus("CREATED");
}
}
src/main/java/com/example/OrderEventListener.java
@Component
public class OrderEventListener {
private final ValidationService validationService;
public OrderEventListener(ValidationService validationService) {
this.validationService = validationService;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
// Executes after transaction commits
validationService.validateOrder(event.getOrderId());
}
}

The reason

I think the key reason for the deadlock is the transaction propagation behavior:

Deadlock Timeline
Transaction 1 (OrderService) Transaction 2 (ValidationService)
| |
| START TRANSACTION |
| |
| INSERT row (acquires row lock) |
| |
| CALL ValidationService ------------->|
| | START NEW TRANSACTION
| |
| | SELECT row (waits for lock)
| |
| (waiting for T2 to complete) | (waiting for T1 to commit)
| |
v v
DEADLOCK DETECTED

The root causes are:

  1. REQUIRES_NEW creates a suspended transaction: When ValidationService uses REQUIRES_NEW, Spring suspends Transaction 1 and starts Transaction 2.

  2. Database locks are not released until commit: Transaction 1 holds a row lock on the inserted order. This lock is not released until Transaction 1 commits or rolls back.

  3. Transaction 2 cannot see uncommitted data: By default, database transactions use READ COMMITTED isolation level. Transaction 2 cannot see uncommitted changes from Transaction 1.

  4. Classic deadlock pattern: Transaction 1 waits for Transaction 2 to complete. Transaction 2 waits for Transaction 1’s lock. Neither can proceed.

Understanding Transaction Propagation

Spring provides several propagation options:

Propagation Options
public enum Propagation {
REQUIRED(0), // Join existing or create new (default)
SUPPORTS(1), // Join existing if exists, else non-transactional
MANDATORY(2), // Must join existing transaction
REQUIRES_NEW(3), // Suspend existing, create new
NOT_SUPPORTED(4), // Suspend existing, run non-transactional
NEVER(5), // Must run non-transactional
NESTED(6); // Nested transaction if supported
}

The default REQUIRED is the safest choice in most cases. Use REQUIRES_NEW only when you explicitly need a separate transaction boundary.

Summary

In this post, I showed why Spring Boot transactions deadlock when calling another service with REQUIRES_NEW propagation. The key points are:

  1. REQUIRES_NEW suspends the current transaction and creates a new one
  2. Database locks are held until commit, so suspended transactions keep their locks
  3. The new transaction cannot see uncommitted data from the suspended transaction
  4. Use REQUIRED (default) to join the existing transaction, or restructure your service calls

To prevent deadlocks:

  • Use default REQUIRED propagation unless you have a specific reason for REQUIRES_NEW
  • Keep transactions as short as possible
  • Consider using @TransactionalEventListener for post-commit actions
  • Test your transaction boundaries with concurrent load

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