Deadlocks are frequent in SQL databases, as normalization and two-phase locking (2PL) require locking multiple tables or rows during DML operations. Numerous use cases share them, and transactions might access data in a different order, resulting in circular waits and deadlocks. While databases can detect them, they can’t prevent or resolve deadlocks, as the application determines what is run and in what order. The best the database can do is cancel one statement or transaction, and let the application rollback and retry, which can reduce performance as systems wait several seconds before getting notified. As Oracle Database makes clear in the error log: “It is a deadlock due to user error in the design of an application or from issuing incorrect ad-hoc SQL.” Most RDBMS use pessimistic locking to avoid conflicts, but deadlocks are unavoidable, and locking can increase latency. For instance, by default, DB2 waits 10 seconds for deadlock detection, SQL Server waits 5 seconds, Oracle waits 3 seconds, and YugabyteDB and PostgreSQL wait 1 second. These delays impact latency, but deadlock prevention remains the application’s responsibility. The only way to entirely prevent deadlocks is by avoiding locks altogether, as in MongoDB, which provides lock-free ACID transactions.
Avoiding Deadlocks in MongoDB
There are two principal strategies to avoid deadlocks and their performance penalties:
- Avoid normalization and use a single-statement update, which the database can transparently restart in the event of a conflict.
- When multi-statement transactions are necessary, use an optimistic concurrency control to detect conflicts immediately and raise errors without waiting.
MongoDB encourages the first approach by leveraging a rich document model that naturally aligns business transactions with a single database document. Any update to a single document is atomic. When the business logic spans multiple documents, MongoDB supports explicit multi-document transactions using optimistic concurrency control, which aborts conflicting transactions immediately, allowing your application to retry rather than waiting for timeouts. For an in-depth look, see my previous post: Lock-Free Wait-on-Conflict and Fail-on-Conflict in MongoDB
Adding retry logic in your application may sound complex, but you must always handle exceptions, and your development framework can make the retry declarative and straightforward. With Spring Retry, you can define retry policies using @Retryable annotation alongside the @Transactional annotation.
Handling Write Conflicts in MongoDB with Spring Data
The Account class represents a bank account document, identified by owner and holding balances for sub-accounts (e.g., checking and savings). I’ve declared it with Spring Data MongoDB annotations in src/main/java/com/example/demo/Account.java as:
package com.example.demo;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.HashMap;
import java.util.Map;
@Document(collection = "account")
public class Account {
@Id
public String id;
public String owner;
public Map<String, Double> balances = new HashMap<>();
public Account() {}
public Account(String owner, Map<String, Double> balances) {
this.owner = owner;
this.balances = balances;
}
@Override
public String toString() {
return String.format("%s: %s", owner, balances);
}
}
Here’s an example of documents:
[
{
_id: ObjectId('688c7b458a3d8a4f4fba3b5a'),
owner: 'alex',
balances: { checking: 100, savings: 50 }
},
{
_id: ObjectId('688c7b458a3d8a4f4fba3b5b'),
owner: 'kim',
balances: { checking: 30, savings: 60 }
}
]
This document model allows fund transfers between sub-accounts for the same user to be performed as atomic updates to a single document. No transaction is needed, and no deadlock is possible.
Spring Data MongoDB’s repository abstraction makes data access both simple and powerful. By extending MongoRepository in your repository, Spring Data provides ready-to-use CRUD operations and parses custom finder methods directly from their names, eliminating the need for manual query code. Here is my src/main/java/com/example/demo/AccountRepository.java:
package com.example.demo;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.Optional;
public interface AccountRepository extends MongoRepository<Account, String> {
Optional<Account> findByOwner(String owner);
}
I compile it with mvn compile and the following POM, which configures a Spring Boot 3.3.0 app with MongoDB support, Spring Retry, Java 17, and standard build plugin:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>spring-mongo-transfer-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<spring-boot.version>3.3.0</spring-boot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.10.1</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
Single-Document Operations: No Retry Loop Needed
With MongoDB, atomicity at the single-document level guarantees safe concurrent updates, even under high load. The database’s storage engine detects conflicts and transparently retries the operation.
Here’s an atomic transfer using Spring Data’s lower-level MongoTemplate that I declared in src/main/java/com/example/demo/AccountService.java:
package com.example.demo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Service;
import static org.springframework.data.mongodb.core.query.Criteria.where;
// added for future usage of retryable transactions
import org.springframework.transaction.annotation.Transactional;
import org.springframework.retry.annotation.Retryable;
import org.springframework.retry.annotation.Backoff;
import org.springframework.dao.TransientDataAccessResourceException;
import org.springframework.data.mongodb.UncategorizedMongoDbException;
@Service
public class AccountService {
private final MongoTemplate mongoTemplate;
AccountService(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
public void transferBetweenSubAccounts(String owner, String from, String to, double amount) {
Query query = new Query(where("owner").is(owner).and("balances." + from).gte(amount));
Update update = new Update()
.inc("balances." + from, -amount)
.inc("balances." + to, amount);
var res = mongoTemplate.updateFirst(query, update, Account.class);
if (res.getModifiedCount() == 0) {
throw new RuntimeException("Insufficient funds in " + from + " or account not found");
}
}
}
The query ensures sufficient balance before updating. The entire operation is handled in a single atomic update command in MongoDB. No transaction or application-level retry logic is needed here, and MongoDB guarantees ACID properties.
To test it, I can call it from my Spring Boot application’s entry point in src/main/java/com/example/demo/DeadlockDemoApplication.java:
package com.example.demo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.util.Map;
@SpringBootApplication
@EnableRetry
public class DeadlockDemoApplication implements CommandLineRunner {
@Autowired
private AccountService accountService;
@Autowired
private AccountRepository repo;
public static void main(String[] args) {
SpringApplication.run(DeadlockDemoApplication.class, args);
}
@Override
public void run(String... args) {
// Demo: clean up and initialize data
repo.deleteAll();
Account alex = new Account("alex", Map.of("checking", 100.0, "savings", 50.0));
Account kim = new Account("kim", Map.of("checking", 30.0, "savings", 60.0));
repo.save(alex);
repo.save(kim);
System.out.println("Before:");
repo.findAll().forEach(System.out::println);
System.out.println("Transferring 40 from alex.checking to alex.savings...");
accountService.transferBetweenSubAccounts("alex", "checking", "savings", 40);
repo.findAll().forEach(System.out::println);
}
}
I run it with mvn spring-boot:run and it shows the balances before and after:
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.3.0)
Before:
alex: {checking=100.0, savings=50.0}
kim: {checking=30.0, savings=60.0}
Transferring 40 from alex.checking to alex.savings...
alex: {checking=60.0, savings=90.0}
kim: {checking=30.0, savings=60.0}
For such operations, debug logs show a single update without transaction markers:
$ mvn spring-boot:run -Dspring-boot.run.arguments="--logging.level.org.mongodb.driver.protocol.command=DEBUG"
...
Single-document transfer (alex moves 40 from checking to savings):
2025-08-01T08:28:35.969Z DEBUG ... Command "update" started ... Command: {"update": "account", ... "owner": "alex", ...}
2025-08-01T08:28:35.982Z DEBUG ... Command "update" succeeded ... Command reply: {"n": 1, "nModified": 1, "ok": 1.0, ...}
2025-08-01T08:28:35.984Z DEBUG ... Command "find" started ... Command: {"find": "account", ...}
...
alex: {checking=60.0, savings=90.0}
kim: {checking=30.0, savings=60.0}
MongoDB is free for these operations because the concurrency control is at the document level. A concurrent transaction that starts to update a sub-account will detect the conflict and restart transparently. The application experiences only a few milliseconds of latency, with no errors.
Multi-Document Transactions: Handle Write Conflicts with Retries
For operations that require coordination across documents, such as transferring funds between different owners in our example, it is essential to use a MongoDB multi-document transaction. If two transactions conflict, MongoDB will abort one of them and report a WriteConflict error to the application, treating it as a transient error that can be retried. Here’s an example, adding a method in src/main/java/com/example/demo/AccountService.java:
@Autowired
private AccountRepository repo;
@Transactional
@Retryable(
value = {
org.springframework.dao.TransientDataAccessResourceException.class,
org.springframework.data.mongodb.UncategorizedMongoDbException.class
},
maxAttempts = 15,
backoff = @Backoff(delay = 20, multiplier = 2, random = true)
)
public void transferBetweenAccounts(String fromOwner, String toOwner, double amount) {
Account from = repo.findByOwner(fromOwner).orElseThrow();
Account to = repo.findByOwner(toOwner).orElseThrow();
double fromBal = from.balances.getOrDefault("checking", 0.0);
if (fromBal < amount) throw new RuntimeException("Insufficient funds for transfer");
from.balances.put("checking", fromBal - amount);
to.balances.put("checking", to.balances.getOrDefault("checking", 0.0) + amount);
repo.save(from);
repo.save(to);
}
@Transactional participates in MongoDB’s ACID transaction system (requires a configured MongoTransactionManager). @Retryable declares my retry policy, here with exponential backoff. I include both TransientDataAccessResourceException and UncategorizedMongoDbException in the value list until all Spring Data versions consistently map write conflicts to the transient type. I run it by adding the following in src/main/java/com/example/demo/DeadlockDemoApplication.java:
System.out.println("\nMulti-document transfer (40 from alex.checking to kim.checking, with retry):");
accountService.transferBetweenAccounts("alex", "kim", 40);
repo.findAll().forEach(System.out::println);
When run, it adds the following to the output:
Multi-document transfer (40 from alex.checking to kim.checking, with retry):
...
alex: {savings=90.0, checking=20.0}
kim: {savings=60.0, checking=70.0}
...
Multi-document transfer (40 from alex.checking to kim.checking, with retry):
2025-08-01T08:31:11.663Z DEBUG ... Command "find" started ... Command: {"find": "account", ... "owner": "alex", ... "startTransaction": true, ...}
2025-08-01T08:31:11.675Z DEBUG ... Command "find" started ... Command: {"find": "account", ... "owner": "kim", ...}
2025-08-01T08:31:11.689Z DEBUG ... Command "update" started ... Command: {"update": "account", ... "owner": "alex", ...}
2025-08-01T08:31:11.701Z DEBUG ... Command "update" started ... Command: {"update": "account", ... "owner": "kim", ...}
2025-08-01T08:31:11.717Z DEBUG ... Command "commitTransaction" started ... Command: {"commitTransaction": 1, ...}
alex: {checking=20.0, savings=90.0} kim: {checking=70.0, savings=60.0}
The presence of commitTransaction shows that an explicit transaction was used to run this.
If at the same time another instance of the application did update the same documents in a different order, the write conflict would be detected:
2025-08-01T11:00:57.555Z DEBUG ... Command "update" failed ... Command: {"update": "account", ...} com.mongodb.MongoCommandException: Command failed with error 112 (WriteConflict): 'Caused by :: Write conflict during plan execution and yielding is disabled. :: Please retry your operation or multi-document transaction.' ... "errorLabels": ["TransientTransactionError"], "code": 112, "codeName": "WriteConflict", ...
2025-08-01T11:00:57.564Z DEBUG ... Command "abortTransaction" started ... Command: {"abortTransaction": 1, ...}
2025-08-01T11:00:57.576Z DEBUG ... Command "abortTransaction" failed ... Command failed with error 251 (NoSuchTransaction): com.mongodb.MongoCommandException: Command failed with error 251 (NoSuchTransaction): 'Transaction with { txnNumber: 4 } has been aborted.'
Suppose the conflict stays, which is improbable when the transaction does not span user interaction, but it can be simulated with a manual transaction. In that case, the sequence of aborted updates is retried until the conflict is resolved, the maximum number of retries is reached, or the other transaction times out.
The settings for exponential backoff retries are dependent on your specific application and infrastructure. The primary objective is to minimize the number of retries while allowing concurrent transactions to complete without causing significant latency.
Troubleshooting
To build upon the previous code, I have addressed a few potential pitfalls.
Verifying Transactional and Retryable Support
It is essential to ensure that transactions are used, as a race condition could otherwise corrupt data. While developing this demo, I’ve added:
System.out.println("Is transaction active? " + org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive());
I’ve also printed the retry attempts:
if (RetrySynchronizationManager.getContext() != null
&& RetrySynchronizationManager.getContext().getRetryCount() > 0) {
System.out.printf("RETRY #%d at %s%n",
RetrySynchronizationManager.getContext().getRetryCount(),
java.time.LocalDateTime.now());
}
Enabling Transaction Support in Spring Data MongoDB
Initially, no transaction was started, and I had to enable the MongoDB transaction manager in the application context, adding this in src/main/java/com/example/demo/MongoConfig.java:
package com.example.demo;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.MongoDatabaseFactory;
import org.springframework.data.mongodb.MongoTransactionManager;
@Configuration
public class MongoConfig {
@Bean
MongoTransactionManager transactionManager(MongoDatabaseFactory dbFactory) {
return new MongoTransactionManager(dbFactory);
}
}
Then, my code displayed the following when I was in an explicit transaction:
Before:
alex: {checking=100.0, savings=50.0}
kim: {checking=30.0, savings=60.0}
Transferring 40 from alex.checking to alex.savings...
Is transaction active? false
alex: {checking=60.0, savings=90.0}
kim: {checking=30.0, savings=60.0}
Multi-document transfer (40 from alex.checking to kim.checking, with retry):
Is transaction active? true
alex: {checking=20.0, savings=90.0}
kim: {checking=70.0, savings=60.0}
Printing this info was good during testing, but I recommend adding such an assertion to raise an error if there’s no transaction after the first statement, because the business logic requires an ACID transaction:
import org.springframework.transaction.support.TransactionSynchronizationManager;
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException("No transaction active! ACID guarantee not present.");
}
This is a safeguard to avoid inconsistencies during a race condition, in case the transaction manager was not correctly configured.
Enabling Retry Support in Spring Data
The retryable annotation must be enabled. I added the following to src/main/java/com/example/demo/DeadlockDemoApplication.java:
import org.springframework.retry.annotation.EnableRetry;
@EnableRetry
Catching the Right Exception for the Retry Logic
When building this demo, I initially retried only on TransientDataAccessResourceException, but the traces show that Spring is throwing UncategorizedMongoDbException for this error, because Spring Data MongoDB has no mapping for TransientTransactionError. I used the following logging flags when troubleshooting:
mvn spring-boot:run -Dspring-boot.run.arguments="
--logging.level.org.springframework.data.mongodb.core.MongoTemplate=DEBUG
--logging.level.org.mongodb.driver.protocol.command=DEBUG \
--logging.level.org.springframework.data.mongodb.MongoTransactionManager=DEBUG
"
Exponential Backoff
It is vital to implement a waiting period to prevent overwhelming the database during contention on a hotspot. Failing to do so has led to the myth that transactions are inherently slow, as discussed in my previous post Transaction Performance: Retry with Backoff. An exponential backoff minimizes retries and increases latency only during high contention, allowing the hotspot to cool down instead of becoming overwhelmed by repeated attempts. An additional randomness, or jitter, prevents many clients from retrying at the exact same moments, which could otherwise result in recurring spikes of contention.
In Summary
MongoDB does not experience deadlocks due to its lock-free concurrency control, which is fully ACID compliant. The WiredTiger storage engine employs optimistic concurrency control (OCC). Still, MongoDB’s query layer presents it as wait-on-conflict for single-document updates, allowing these updates to be transparently retried by the query layer. When application-initiated explicit transactions conflict, the application must manage retries, as the database lacks context on other operations the application may have performed. With explicit transactions, MongoDB adopts a fail-on-conflict strategy to provide predictable latency. This differs from traditional RDBMS databases, which often lock and wait, but still require application-level logic to handle deadlocks.
Spring Data MongoDB offers a robust framework for implementing ACID transactions, streamlining retryable exception handling without added complexity. Key considerations include ensuring a transaction is initiated when needed, as the transactional annotation may be ignored if no transaction manager is set up. Additionally, ensure that the retry logic captures the correct exceptions, as not all transient errors are consistently mapped across all Spring Data MongoDB versions. Although MongoDB’s lock-free concurrency control proactively prevents deadlocks, incorporating retry logic remains essential when using transactions. This typically involves a loop that catches temporary errors and includes a brief wait before retrying, ensuring quick retries without overwhelming the database.