Skip to content

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:

error.log
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:

Transaction Timeline
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 release
Result: Service A waits for B, Service B waits for COMMIT -> timeout

This 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:

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: true

This 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:

TransactionLoggingAspect.java
@Aspect
@Component
@Slf4j
public 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:

find_blocked_transactions.sql
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_state
FROM 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.pid
WHERE 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:

Query Result
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:

mysql_innodb_status.sql
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:

application.yml
management:
endpoints:
web:
exposure:
include: health,metrics,datasource
endpoint:
health:
show-details: always

Custom Transaction Metrics

I created custom metrics to track transaction performance:

TransactionMetricsAspect.java
@Component
public 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:

  1. Transaction A starts in Service A
  2. Service A inserts a row (holds exclusive row lock)
  3. Service A calls Service B (still within same transaction)
  4. Service B tries to read the same table (waits for lock)
  5. 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:

OrderService.java
@Service
public 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
}
}
@Service
public 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:

Before - Long Transaction
@Transactional
public 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!
}
}
After - Short Transactions
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 transaction
public void notifyExternal(Item item) {
externalApi.call(item);
}

Deadlock from Inconsistent Lock Order

Problem: Concurrent updates acquiring locks in different orders.

Solution: Consistent lock ordering:

SafeBatchProcessor.java
@Service
public 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:

BatchConfig.java
@Configuration
public 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

GoodPractice.java
// BAD: Transaction spans too much
@Transactional
public 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 scope
public void processOrder(Order order) {
Order saved = saveOrder(order);
// Transaction committed
// External calls outside transaction
externalServiceClient.callAsync(saved);
emailService.sendAsync(saved);
statsService.updateAsync(saved);
}
@Transactional
public Order saveOrder(Order order) {
return orderRepository.save(order);
}

2. Use Optimistic Locking for Concurrent Updates

Product.java
@Entity
public class Product {
@Id
private Long id;
private String name;
private Integer quantity;
@Version // Optimistic locking
private Long version;
}
// Usage - automatic version check on save
@Transactional
public 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

ProductRepository.java
@Service
public class ProductService {
@Transactional(readOnly = true)
public List&lt;Product&gt; findAllProducts() {
return productRepository.findAll();
}
@Transactional(readOnly = true)
public Optional&lt;Product&gt; 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

TransactionalService.java
@Service
public 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:

application.yml
spring:
transaction:
default-timeout: 30 # seconds

5. Monitor Lock Wait Time

For PostgreSQL, I created a scheduled check:

LockMonitor.java
@Component
@Slf4j
public class LockMonitor {
@Scheduled(fixedRate = 60000) // Every minute
public void checkLongWaitLocks() {
List&lt;LockWait&gt; 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

ToolPurposeWhen to Use
Spring Transaction DEBUG logsSee transaction boundariesInitial debugging
pg_locks / pg_stat_activityPostgreSQL lock inspectionProduction issues
SHOW ENGINE INNODB STATUSMySQL deadlock detectionMySQL environments
Spring Boot ActuatorOngoing monitoringProduction deployments
Custom metrics (Micrometer)Transaction performanceTrend analysis
jstack / thread dumpsThread state during hangJVM-level debugging

Debugging Checklist

When I encounter transaction locking issues now, I follow this sequence:

  1. Enable DEBUG logging - See transaction boundaries immediately
  2. Check database locks - Run the appropriate query for your database
  3. Analyze lock timeline - Who holds what lock, who’s waiting
  4. Review transaction propagation - Are nested calls causing issues?
  5. Check for external calls - Network I/O inside transactions is a red flag
  6. Verify lock ordering - Consistent ordering prevents deadlocks
  7. 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