When @Transactional Goes Wrong: Misusing Transaction Propagation Leads to Deadlocks

A real‑world case shows that using @Transactional with the default REQUIRED propagation caused a long‑running transaction, frequent deadlocks, data inconsistency and higher timeout rates, and the article walks through the root cause, investigation steps, and practical fixes such as REQUIRES_NEW, self‑proxy calls, and async eventual consistency.

Coder Trainee
Coder Trainee
Coder Trainee
When @Transactional Goes Wrong: Misusing Transaction Propagation Leads to Deadlocks

Incident Symptoms

Three alerts were triggered:

Frequent database deadlocks.

Data inconsistency – some orders were marked as completed while inventory was not deducted.

Interface timeout rate rose by 5% .

Business flow: create order → deduct inventory → add points .

@Service
public class OrderService {
    @Autowired
    private StockService stockService;
    @Autowired
    private PointService pointService;

    @Transactional // default propagation REQUIRED
    public void createOrder(OrderRequest request) {
        Order order = orderRepository.save(request); // 1. save order
        stockService.deductStock(order.getProductId(), order.getQuantity()); // 2. deduct inventory
        pointService.addPoints(order.getUserId(), order.getAmount()); // 3. add points
    }
}

@Service
public class StockService {
    @Transactional // default propagation REQUIRED
    public void deductStock(Long productId, Integer quantity) {
        // deduct inventory ...
    }
}

@Service
public class PointService {
    @Transactional // default propagation REQUIRED
    public void addPoints(Long userId, Integer points) {
        // add points ...
    }
}

Investigation Process

Step 1 – Examine the code

All three methods are annotated with @Transactional and rely on the default REQUIRED propagation.

Step 2 – Trace the execution flow

createOrder() opens outer transaction
    ├── save order
    ├── stockService.deductStock()  // joins outer transaction
    └── pointService.addPoints()   // joins outer transaction

Key question: does each inner method start a new transaction or join the outer one?

Step 3 – Locate the problem

Because REQUIRED joins an existing transaction, the three operations run inside a single transaction. This creates two main issues:

Long transaction duration : both deductStock and addPoints acquire row locks. The locks are held until createOrder finishes, enlarging the lock window.

Deadlock risk : under high concurrency, transactions wait on each other's locks, e.g.

Tx A: update order table → wait for inventory lock
Tx B: update inventory table → wait for order lock
→ deadlock

Exception‑handling pitfall : if an inner method swallows its exception or the outer method catches it without re‑throwing, the transaction may commit unintentionally. This is unrelated to propagation but often observed together.

Self‑invocation trap : a @Transactional method called from another method in the same class uses the this reference, bypassing the Spring proxy, so the annotation is ignored.

// ❌ internal call – transaction ineffective
@Service
public class OrderService {
    public void methodA() { methodB(); }
    @Transactional
    public void methodB() { /* never runs in a transaction */ }
}

// ✅ fix – inject self‑proxy
@Service
public class OrderService {
    @Autowired private OrderService self;
    public void methodA() { self.methodB(); }
    @Transactional
    public void methodB() { /* transactional */ }
}

// ✅ fix – use AopContext
@Service
public class OrderService {
    public void methodA() { ((OrderService) AopContext.currentProxy()).methodB(); }
    @Transactional
    public void methodB() { /* transactional */ }
}

Step 4 – Why deadlock occurs?

The outer transaction holds locks on order , inventory , and points tables simultaneously. When two transactions acquire these locks in opposite order, a circular wait forms, leading to a deadlock.

Solutions

Solution 1 – Split into independent transactions

@Service
public class OrderService {
    @Autowired private StockService stockService;
    @Autowired private PointService pointService;

    @Transactional // outer transaction for order only
    public void createOrder(OrderRequest request) {
        Order order = orderRepository.save(request);
        stockService.deductStock(order.getProductId(), order.getQuantity()); // REQUIRES_NEW
        pointService.addPoints(order.getUserId(), order.getAmount());      // REQUIRES_NEW
    }
}

@Service
public class StockService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void deductStock(Long productId, Integer quantity) {
        // deduct inventory ...
    }
}

@Service
public class PointService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void addPoints(Long userId, Integer points) {
        // add points ...
    }
}

Execution flow:

createOrder() opens Transaction A (order)
    ├── save order
    ├── deductStock() opens Transaction B → commit B (release inventory lock)
    └── addPoints() opens Transaction C → commit C (release points lock)
    └── commit Transaction A (release order lock)

Solution 2 – Asynchronous processing with eventual consistency

@Service
public class OrderService {
    @Transactional
    public void createOrder(OrderRequest request) {
        Order order = orderRepository.save(request);
        applicationEventPublisher.publishEvent(new OrderCreatedEvent(order));
    }
}

@Component
public class OrderEventListener {
    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleOrderCreated(OrderCreatedEvent event) {
        stockService.deductStock(event.getOrder().getProductId(), event.getOrder().getQuantity());
        pointService.addPoints(event.getOrder().getUserId(), event.getOrder().getAmount());
    }
}

Transaction Propagation Quick Reference

REQUIRED (default) – join existing transaction or create a new one. Suitable for most scenarios.

REQUIRES_NEW – always start a new transaction, suspending the current one. Used for independent operations that must commit immediately.

NESTED – creates a savepoint‑based nested transaction. Allows partial rollback within a larger transaction.

SUPPORTS – join if a transaction exists; otherwise execute non‑transactionally. Typical for read‑only operations.

NOT_SUPPORTED – execute without a transaction, suspending any existing one. Used when a method must not run inside a transaction.

MANDATORY – requires an existing transaction; throws if none is present.

NEVER – must not run within a transaction; throws if a transaction is active.

Debugging Tips

// Check whether a transaction is active
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
String txName = TransactionSynchronizationManager.getCurrentTransactionName();
log.info("Transaction status: active={}, name={}", isActive, txName);

MySQL commands for deadlock analysis:

SHOW ENGINE INNODB STATUS;               -- view deadlock logs
SELECT * FROM information_schema.innodb_trx;        -- current transactions
SELECT * FROM information_schema.innodb_lock_waits;  -- lock wait information
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

BackendJavaDeadlockSpring@TransactionalTransaction Propagation
Coder Trainee
Written by

Coder Trainee

Experienced in Java and Python, we share and learn together. For submissions or collaborations, DM us.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.