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.
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 transactionKey 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
→ deadlockException‑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 informationSigned-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Coder Trainee
Experienced in Java and Python, we share and learn together. For submissions or collaborations, DM us.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
