How to Debug Spring Boot Transaction Locking and Deadlock Issues
My Spring Boot application started hanging randomly. No errors in logs, no exceptions - just frozen threads. Sometimes it would throw a LockTimeoutException after 30 seconds. This is the nightmare of transaction locking issues.
The Problem: Application Hangs with Lock Timeout
I noticed the issue first in production. Our order processing service would occasionally freeze, and after checking the logs, I found this:
org.springframework.dao.CannotAcquireLockException:could not acquire lock on row in relation "orders"The symptoms were inconsistent - sometimes it worked fine, sometimes it hung for exactly 30 seconds before timing out. Classic signs of a database lock conflict.
Understanding What Happened
After extensive debugging, I discovered the root cause. Our code had this pattern:
Service A: TX START -> INSERT row -> ROW LOCK acquired (not committed)Service A: calls Service B (waiting for response)Service B: SELECT from same table -> wait for row lock releaseResult: Service A waits for B, Service B waits for COMMIT -> timeoutThis was a classic deadlock scenario, but not the traditional circular wait - instead, it was a self-imposed lock through nested service calls.
Step 1: Enable Transaction Debug Logging
First, I needed visibility into what Spring and Hibernate were doing with transactions. I added this to application.yml:
logging: level: org.springframework.transaction: DEBUG org.springframework.orm.jpa: DEBUG org.hibernate.SQL: DEBUG org.hibernate.type.descriptor.sql: TRACE
spring: jpa: properties: hibernate: generate_statistics: true format_sql: trueThis gave me visibility into:
- Transaction boundaries (when they start/commit)
- SQL statements being executed
- Lock acquisition attempts
Creating a Transaction Logging Aspect
For more detailed transaction tracking, I created a custom aspect:
@Aspect@Component@Slf4jpublic class TransactionLoggingAspect {
@Around("@annotation(org.springframework.transaction.annotation.Transactional)") public Object logTransaction(ProceedingJoinPoint joinPoint) throws Throwable { String methodName = joinPoint.getSignature().getName(); String txName = TransactionSynchronizationManager.getCurrentTransactionName(); boolean isNewTx = TransactionSynchronizationManager.isActualTransactionActive();
log.debug("TX [{}] Starting: {} (isNew={})", txName, methodName, isNewTx);
try { Object result = joinPoint.proceed(); log.debug("TX [{}] Completed: {}", txName, methodName); return result; } catch (Exception e) { log.error("TX [{}] Failed: {} - {}", txName, methodName, e.getMessage()); throw e; } }}This showed me exactly when transactions started and ended, and which methods were participating.
Step 2: Database Lock Monitoring
The application logs showed transactions, but I needed to see what locks were actually held at the database level.
PostgreSQL Lock Detection
For PostgreSQL, I used pg_locks and pg_stat_activity views:
SELECT blocked_locks.pid AS blocked_pid, blocked_activity.usename AS blocked_user, blocking_locks.pid AS blocking_pid, blocking_activity.usename AS blocking_user, blocked_activity.query AS blocked_statement, blocking_activity.query AS current_statement_in_blocking_process, blocked_activity.state AS blocked_stateFROM pg_catalog.pg_locks blocked_locks JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid JOIN pg_catalog.pg_locks blocking_locks ON blocking_locks.locktype = blocked_locks.locktype AND blocking_locks.DATABASE IS NOT DISTINCT FROM blocked_locks.DATABASE AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple AND blocking_locks.pid != blocked_locks.pid JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pidWHERE NOT blocked_locks.GRANTED;This query revealed which transactions were blocked and which were holding the locks. I ran this during a hang and found:
blocked_pid | blocking_pid | blocked_statement | current_statement_in_blocking_process------------+--------------+----------------------------+--------------------------------------24689 | 24512 | SELECT * FROM orders WHERE | INSERT INTO orders (...)This confirmed my theory - a SELECT was waiting for an INSERT lock to be released.
MySQL Deadlock Analysis
For MySQL/MariaDB, I used:
SHOW ENGINE INNODB STATUS;The LATEST DETECTED DEADLOCK section in the output shows the exact deadlock cycle with involved queries and locks.
Step 3: Spring Boot Actuator for Ongoing Monitoring
For production monitoring, I set up Spring Boot Actuator:
management: endpoints: web: exposure: include: health,metrics,datasource endpoint: health: show-details: alwaysCustom Transaction Metrics
I created custom metrics to track transaction performance:
@Componentpublic class TransactionMetricsAspect { private final MeterRegistry meterRegistry;
public TransactionMetricsAspect(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; }
@Around("@annotation(transactional)") public Object measureTransaction(ProceedingJoinPoint joinPoint, Transactional transactional) throws Throwable { Timer.Sample sample = Timer.start(meterRegistry); try { Object result = joinPoint.proceed(); sample.stop(meterRegistry.timer("transaction.success", "method", joinPoint.getSignature().getName(), "readOnly", String.valueOf(transactional.readOnly()))); return result; } catch (Exception e) { sample.stop(meterRegistry.timer("transaction.failure", "method", joinPoint.getSignature().getName(), "exception", e.getClass().getSimpleName())); throw e; } }}This gave me time-series data on transaction performance, making it easier to spot degradation.
Step 4: Analyzing the Root Cause
With logging and monitoring in place, I traced the execution flow:
- Transaction A starts in Service A
- Service A inserts a row (holds exclusive row lock)
- Service A calls Service B (still within same transaction)
- Service B tries to read the same table (waits for lock)
- Deadlock: A waits for B’s response, B waits for A’s lock
The Fix: Separate Transaction Boundaries
The solution was to ensure Service B runs in a separate transaction:
@Servicepublic class OrderService {
private final NotificationService notificationService;
@Transactional public void processOrder(Order order) { orderRepository.save(order); // Transaction committed here }
// Called after transaction commits public void processOrderWithNotification(Order order) { processOrder(order); notificationService.sendNotification(order); // Runs outside transaction }}
@Servicepublic class NotificationService {
@Transactional(propagation = Propagation.REQUIRES_NEW) public void sendNotification(Order order) { // New transaction, doesn't block on previous locks notificationRepository.save(createNotification(order)); }}Common Transaction Issues I Found
Lock Timeout from Long-Running Transactions
Problem: Transactions holding locks for too long.
Solution: Reduce transaction scope:
@Transactionalpublic void processLargeBatch(List<Item> items) { // This holds locks for entire batch for (Item item : items) { itemRepository.save(item); externalApi.call(item); // External call inside transaction! }}public void processLargeBatch(List<Item> items) { for (Item item : items) { processSingleItem(item); // Each in separate transaction }}
@Transactional(timeout = 30)public void processSingleItem(Item item) { itemRepository.save(item);}
// External calls outside transactionpublic void notifyExternal(Item item) { externalApi.call(item);}Deadlock from Inconsistent Lock Order
Problem: Concurrent updates acquiring locks in different orders.
Solution: Consistent lock ordering:
@Servicepublic class SafeBatchProcessor {
@Transactional public void processBatch(List<Item> items) { // Sort by ID to ensure consistent lock order items.sort(Comparator.comparing(Item::getId));
for (Item item : items) { processItem(item); } }
@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 30) public void processWithRetry(Item item) { int attempts = 0; while (attempts < 3) { try { itemRepository.save(item); return; } catch (CannotAcquireLockException e) { attempts++; if (attempts >= 3) throw e; sleep(100 * attempts); } } }
private void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException ignored) {} }}Lock Escalation
Problem: Too many row locks causing database to escalate to table lock.
Solution: Batch processing with proper sizing:
@Configurationpublic class BatchConfig {
@Bean public BatchConfigurer batchConfigurer(DataSource dataSource) { return new DefaultBatchConfigurer(dataSource) { @Override protected void initialize() { // Configure batch size to avoid lock escalation // PostgreSQL: ~1000 rows before potential escalation // MySQL: Depends on innodb_buffer_pool_size } }; }}Best Practices I Now Follow
1. Keep Transactions Short
// BAD: Transaction spans too much@Transactionalpublic void processOrder(Order order) { saveOrder(order); callExternalService(order); // Long network call sendEmail(order); // Another external call updateStats(order); // Unnecessary in same transaction}
// GOOD: Minimal transaction scopepublic void processOrder(Order order) { Order saved = saveOrder(order); // Transaction committed
// External calls outside transaction externalServiceClient.callAsync(saved); emailService.sendAsync(saved); statsService.updateAsync(saved);}
@Transactionalpublic Order saveOrder(Order order) { return orderRepository.save(order);}2. Use Optimistic Locking for Concurrent Updates
@Entitypublic class Product { @Id private Long id;
private String name; private Integer quantity;
@Version // Optimistic locking private Long version;}
// Usage - automatic version check on save@Transactionalpublic void updateQuantity(Long productId, int delta) { Product product = productRepository.findById(productId); product.setQuantity(product.getQuantity() + delta); // If version changed, throws OptimisticLockException productRepository.save(product);}3. Use Read-Only Transactions for Queries
@Servicepublic class ProductService {
@Transactional(readOnly = true) public List<Product> findAllProducts() { return productRepository.findAll(); }
@Transactional(readOnly = true) public Optional<Product> findById(Long id) { return productRepository.findById(id); }}Read-only transactions don’t acquire write locks and can use database-level optimizations.
4. Set Explicit Timeouts
@Servicepublic class PaymentService {
@Transactional(timeout = 10) // 10 seconds public void processPayment(Payment payment) { // Must complete within 10 seconds paymentRepository.save(payment); accountRepository.debit(payment.getAccountId(), payment.getAmount()); }}Or globally:
spring: transaction: default-timeout: 30 # seconds5. Monitor Lock Wait Time
For PostgreSQL, I created a scheduled check:
@Component@Slf4jpublic class LockMonitor {
@Scheduled(fixedRate = 60000) // Every minute public void checkLongWaitLocks() { List<LockWait> longWaits = lockRepository.findLongWaits(Duration.ofSeconds(5)); if (!longWaits.isEmpty()) { log.warn("Detected {} locks waiting > 5 seconds", longWaits.size()); longWaits.forEach(lw -> log.warn("Lock wait: {} blocked by {} for {}s", lw.getBlockedPid(), lw.getBlockingPid(), lw.getWaitDuration())); } }}Tools in My Debugging Arsenal
| Tool | Purpose | When to Use |
|---|---|---|
| Spring Transaction DEBUG logs | See transaction boundaries | Initial debugging |
pg_locks / pg_stat_activity | PostgreSQL lock inspection | Production issues |
SHOW ENGINE INNODB STATUS | MySQL deadlock detection | MySQL environments |
| Spring Boot Actuator | Ongoing monitoring | Production deployments |
| Custom metrics (Micrometer) | Transaction performance | Trend analysis |
jstack / thread dumps | Thread state during hang | JVM-level debugging |
Debugging Checklist
When I encounter transaction locking issues now, I follow this sequence:
- Enable DEBUG logging - See transaction boundaries immediately
- Check database locks - Run the appropriate query for your database
- Analyze lock timeline - Who holds what lock, who’s waiting
- Review transaction propagation - Are nested calls causing issues?
- Check for external calls - Network I/O inside transactions is a red flag
- Verify lock ordering - Consistent ordering prevents deadlocks
- Review isolation level - Higher levels = more locking
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