I once spent a weekend debugging an e‑commerce inventory system that kept overselling the same pair of shoes. The logs showed two API requests arriving at the exact millisecond. Both read “stock = 1”, both validated, both decremented. The final stock was -1. The customer was furious, the database was inconsistent, and I was staring at a problem that a simple synchronized block couldn’t solve because the two requests were handled by two different Kubernetes pods. That night I learned what every microservice developer eventually faces: distributed locking isn’t a luxury, it’s a necessity.
We’re going to build a production‑ready distributed locking system using Spring Boot and Redisson, which implements the RedLock algorithm. No theory without practice – you’ll see real code that handles inventory, scheduled jobs, and idempotent payments. By the end you’ll know exactly how to prevent race conditions across any number of service instances.
Let me show you what I mean by starting with the simplest possible scenario. Imagine you have two instances of a payment service, both processing a webhook for the same order. Both check if the payment has already been processed, both see “not yet processed”, both apply the charge. The customer gets billed twice. How do you guarantee that only one instance ever processes a given order? The answer is a distributed lock.
The core problem: crossing JVM boundaries
In a single Java process you could use synchronized or ReentrantLock. But a lock on one pod cannot block code running on another pod. You need a shared, external coordinator that both pods can see. Redis is a natural choice because it’s fast, simple, and already used as a cache or session store in most microservice architectures.
Redisson wraps Redis with robust Java locks that can be reentrant, fair, or even read/write. It also implements the RedLock algorithm, which uses multiple independent Redis nodes to stay available even if some nodes fail.
Setting up Redisson with Spring Boot
First, add the Redisson Spring Boot Starter to your pom.xml:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.27.0</version>
</dependency>
Then configure the client. I like to keep the Redisson configuration in a separate YAML file:
singleServerConfig:
address: "redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}"
connectionPoolSize: 20
connectTimeout: 3000
timeout: 3000
codec: !<org.redisson.codec.JsonJacksonCodec> {}
And load it in a configuration bean:
@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient() throws IOException {
Config config = Config.fromYAML(
new ClassPathResource("redisson.yaml").getInputStream()
);
return Redisson.create(config);
}
Now you have a RedissonClient that you can inject anywhere.
Acquiring your first distributed lock
Let’s protect the inventory reservation method that got me into trouble. I’ll use a lock with a key that uniquely identifies the resource (e.g., stock:itemId).
@Autowired
private RedissonClient redisson;
public void reserveItem(String itemId, int quantity) {
RLock lock = redisson.getLock("stock:" + itemId);
try {
// Wait up to 5 seconds, hold lock for 30 seconds maximum
if (lock.tryLock(5, 30, TimeUnit.SECONDS)) {
int currentStock = inventoryRepository.getStock(itemId);
if (currentStock >= quantity) {
inventoryRepository.updateStock(itemId, currentStock - quantity);
}
} else {
throw new ServiceException("Could not acquire lock, try again later");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
Notice the tryLock with a wait time and a lease time. The wait time says “I’m willing to block for up to 5 seconds trying to get the lock.” The lease time says “give up the lock automatically after 30 seconds even if I crash.” The latter prevents a deadlock if the service dies while holding the lock.
But what if the operation takes longer than 30 seconds? You don’t want the lock to expire while your database transaction is still running. Redisson has a built‑in watchdog that automatically extends the lease time while the lock‑holding thread is alive. You enable it by omitting the lease time (or passing -1):
lock.lock(5, TimeUnit.SECONDS); // no lease time = watchdog enabled
By default the watchdog refreshes every 10 seconds (one third of the default 30‑second lease). If the thread dies, the lock eventually expires. This is much safer than guessing a static TTL.
Reentrant locks: the same thread can lock again
A reentrant lock allows the same thread to acquire the same lock multiple times without blocking itself. This is important when a service method calls another method that also tries to acquire the same lock.
RLock lock = redisson.getLock("user:123");
public void outerMethod() {
lock.lock(10, TimeUnit.SECONDS);
try {
innerMethod(); // this also tries to lock the same key
} finally {
lock.unlock();
}
}
public void innerMethod() {
lock.lock(10, TimeUnit.SECONDS);
try {
// do work
} finally {
lock.unlock();
}
}
Without reentrancy, innerMethod would deadlock because it tries to acquire a lock already held by the same thread. Redisson’s RLock is reentrant by default, so this works flawlessly.
Fair locks: respecting order
Sometimes you want requests to be processed in the order they arrived. A fair lock gives the lock to the oldest waiting thread. Redisson supports this with RedissonClient.getFairLock(key). Use it when your application logic expects strict FIFO ordering – for example, a ticket booking system where the first to queue should get the seat.
RLock fairLock = redisson.getFairLock("booking:seat123");
fairLock.lock(5, 10, TimeUnit.SECONDS);
Fair locks add a performance penalty because each lock request touches Redis more often, but they prevent starvation in contention‑heavy scenarios.
ReadWrite locks for shared resources
If you have a resource that is read frequently but written rarely, you can improve concurrency with a read/write lock. Multiple readers can hold the read lock simultaneously, but a write lock is exclusive.
RReadWriteLock rwLock = redisson.getReadWriteLock("config:global");
RLock readLock = rwLock.readLock();
RLock writeLock = rwLock.writeLock();
// Reading (many instances can do this concurrently)
readLock.lock(10, TimeUnit.SECONDS);
try {
String config = configRepository.getGlobalConfig();
// use config
} finally {
readLock.unlock();
}
// Writing (only one instance at a time)
writeLock.lock(10, TimeUnit.SECONDS);
try {
configRepository.updateGlobalConfig(newValue);
} finally {
writeLock.unlock();
}
This pattern is excellent for session configuration or feature flags that are cached in memory.
Handling lock exceptions and failure scenarios
No lock system is perfect. Redis nodes can go down, network partitions can happen. You need to decide how your application behaves when a lock cannot be acquired.
One common pattern is to fail fast: if you can’t acquire the lock within a short timeout, throw an exception and let the client retry. Another is to degrade gracefully – for example, fall back to a local lock if the distributed lock is unavailable (though this increases risk of race conditions).
I always set a reasonable wait timeout (e.g., 2 seconds) and log a warning when a lock is not acquired. This gives me visibility into contention.
For critical workflows like payment processing, I also add a database‑level idempotency key as a second layer of protection. The lock prevents concurrent execution; the idempotency key prevents duplicate state changes if the lock system fails.
Testing distributed locks with Testcontainers
You can’t test distributed locking with an embedded Redis. I use Testcontainers to spin up a real Redis instance in tests.
@Testcontainers
class InventoryServiceTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7.2")
.withExposedPorts(6379);
@DynamicPropertySource
static void configureRedis(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired
private InventoryService inventoryService;
@Test
void testConcurrentReservations() throws Exception {
// Use a CountDownLatch to simulate simultaneous requests
int threadCount = 10;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger(0);
// Assume stock is 5
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
inventoryService.reserveItem("item1", 1);
successCount.incrementAndGet();
} catch (Exception e) {
// lock not acquired – expected
} finally {
latch.countDown();
}
});
}
latch.await(10, TimeUnit.SECONDS);
assertThat(successCount.get()).isEqualTo(5);
assertThat(inventoryService.getStock("item1")).isZero();
}
}
This test verifies that exactly five reservations succeed and the stock ends up at zero, no matter how many threads race.
Comparison with other distributed locking approaches
Redisson is my go‑to, but you might encounter alternatives:
- ShedLock focuses on scheduled tasks. It uses a simple database table or Redis entry to prevent duplicate job execution. It lacks reentrancy and read/write locks.
- ZooKeeper provides sequential and ephemeral nodes for locks. It’s more consistent (CP) than Redis’s eventual consistency, but it adds operational complexity and latency.
- Database‑based locks (e.g.,
SELECT ... FOR UPDATEor advisory locks in PostgreSQL) work well inside a single database transaction, but they don’t scale across microservices that use different databases.
For most microservice use cases, Redisson strikes the right balance between simplicity, performance, and feature set.
Real‑world use cases I’ve implemented
- Idempotent payment webhooks: Each payment has a unique
idempotencyKey. The webhook handler first tries to acquire a lock on that key. If the lock is acquired, it processes the webhook and updates the database. If the lock is not acquired, it knows another instance is already handling that webhook and simply returns “accepted”. - Scheduled job coordination: A job that runs every five minutes and sends summary emails to users. Without locking, all four replicas would send four identical emails. The job acquires a lock on
job:email-summaryand only the instance holding the lock runs the job. - Distributed rate limiter: Using Redisson’s
RRateLimiterto allow only N requests per second across all service instances, protecting a downstream API from overload. - Distributed semaphore for finite resources: For example, limiting concurrent database writes to a table that has a row‑level lock contention issue.
Each of these patterns follows the same core: acquire a lock on a well‑defined key, do the work, release the lock.
Watchdog pitfalls and manual extension
The watchdog is great, but it can mask slow operations. I’ve seen developers rely on it to cover database queries that take minutes. That’s dangerous. If your operation consistently takes longer than the default lease time, set a proper lease time based on your worst‑case execution time. The watchdog should be a safety net, not a design crutch.
If you need manual lease extension, you can call lock.lock(leaseTime, timeUnit) repeatedly, but that’s rarely necessary.
Final thoughts on distributed locking
Distributed locking is not a silver bullet. It adds latency, consumes Redis resources, and can become a single point of failure if not configured correctly (use multiple Redis nodes for RedLock). Always measure the cost. For many operations, an idempotent service that can safely retry is simpler and more robust than adding explicit locks.
But when you absolutely need mutual exclusion across replicas – for inventory, payments, or leader election – distributed locking with Redisson and Spring Boot is a proven, reliable solution. Start with the simple tryLock, enable the watchdog, test with Testcontainers, and monitor your lock acquisition times.
If this walkthrough helped you understand how to protect your microservices from race conditions, I’d appreciate it if you like this article, share it with a colleague who’s still debugging weekend oversells, and comment with your own story of a race condition that kept you up at night. I read every comment, and your feedback helps me write better content for the community.
Now go clean up that inventory logic.
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva