Implementing Distributed Locking with Java Spring Boot and Redis for Data Integrity
A production guide to distributed locking with Spring Boot and Redis (Redisson) — preventing race conditions, ensuring idempotency, and handling failure gracefully in high-concurrency systems.
Introduction
In a single-JVM application, synchronized or a ReentrantLock is enough. Run two replicas behind a load balancer and those locks protect nothing — each JVM has its own. Distributed locking coordinates mutual exclusion across processes, machines and regions.
The most common implementation: Redis + Redisson.
What distributed locking solves
- Race conditions — two replicas processing the same message simultaneously.
- Duplicate processing — a retried webhook charging the customer twice.
- Resource contention — only one node should run the nightly export.
- Idempotency — guarantee an operation runs at most once per key.
A concrete race condition
@PostMapping("/orders/{id}/refund")
public void refund(@PathVariable String id) {
Order o = orders.findById(id);
if (o.getStatus() == REFUNDED) return;
paymentGateway.refund(o); // ← if two requests get here simultaneously
o.setStatus(REFUNDED); // the customer is refunded twice
orders.save(o);
}
The "check then act" between line 3 and line 4 is the race.
Step 1 — Add Redisson
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.32.0</version>
</dependency>
spring:
redis:
host: redis
port: 6379
redisson:
single-server-config:
address: redis://redis:6379
Step 2 — Lock the critical section
```java
@Service
public class RefundService {
private final RedissonClient redisson;
private final OrderRepository orders;
private final PaymentGateway gateway;public RefundService(RedissonClient r, OrderRepository o, PaymentGateway g) { this.redisson = r; this.orders = o; this.gateway = g; }
public void refund(String orderId) { RLock lock = redisson.getLock("order:" + orderId + ":refund"); boolean acquired = false; try { // wait up to 2s, hold for at most 10s acquired = lock.tryLock(2, 10, TimeUnit.SECONDS); if (!acquired) throw new ConcurrentOperationException("refund already in progress");
Order o = orders.findById(orderId).orElseThrow(); if (o.getStatus() == OrderStatus.REFUNDED) return; gateway.refund(o); o.setStatus(OrderStatus.REFUNDED); orders.save(o); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } finally { if (acquired && lock.isHeldByCurrentThread()) lock.unlock(); } } } ```
Three things are non-negotiable:
1. Bounded wait time — never call lock() without a timeout, or you'll exhaust threads.
2. Lease time — Redis auto-expires the lock if the JVM crashes mid-section.
3. Release in finally + check ownership — never release a lock you don't own.
Step 3 — How Redis distributed locks actually work
Redisson's lock is a Lua script around SET key value NX PX <ttl>:
SET order:42:refund <uuid> NX PX 10000
- NX — only set if not exists (atomic acquisition).
- PX 10000 — auto-expire after 10s (dead-holder safety).
- value = client UUID — release script checks ownership before deleting.
The watchdog thread renews the TTL while the JVM is alive, so a healthy holder never loses the lock.
Step 4 — Sequence diagram
client A ──► refund(42) ──► SET NX (OK) ──► gateway.refund ──► UNLOCK
client B ──► refund(42) ──► SET NX (FAIL, wait)
└─► retry ──► SET NX (FAIL, status=REFUNDED) ──► return
Step 5 — Idempotency keys (a better default for HTTP)
For external-facing endpoints, prefer idempotency keys stored as a Redis SETNX with TTL:
String key = "idemp:" + request.getHeader("Idempotency-Key");
Boolean first = redis.opsForValue().setIfAbsent(key, "in-progress", Duration.ofHours(24));
if (Boolean.FALSE.equals(first)) return cachedResponse(key);
Stripe and most payment APIs work this way. Combine with a distributed lock when the *processing* of the key needs mutual exclusion.
Step 6 — Docker Compose for local Redis
services:
redis:
image: redis:7
ports: ["6379:6379"]
command: ["redis-server","--appendonly","yes"]
app:
build: .
environment: [SPRING_REDIS_HOST=redis]
depends_on: [redis]
Real-world examples
- Payments — preventing duplicate captures or refunds.
- Banking transfers — locking both accounts in a deterministic order to avoid deadlock.
- Inventory — decrementing stock for a hot product during a flash sale.
- Scheduled jobs — only one replica should run the 02:00 export.
- Cache regeneration — single-flight rebuilds of an expensive cache key.
Production best practices
- Always set a lease time. A lock without TTL is a future outage.
- Keep critical sections short. Network calls inside a lock multiply contention; pre-compute, then lock only the mutation.
- Use logical, stable keys —
order:<id>:refund, not random UUIDs. - Monitor lock wait/hold times. Surface them as Micrometer metrics.
- Use Redisson's
RedLockacross multiple independent Redis nodes only if you need correctness against full-node failure — it's stricter and slower than the single-node lock.
Common pitfalls and anti-patterns
- Holding a lock across HTTP calls to a slow external API — one slow call can block every replica.
- Using
SETNXwithout TTL — JVM crash leaves a permanent lock. - Locking with
@Transactionalthat commits *after* unlock — another node sees stale data. Acquire → load → mutate → commit → unlock, in that order. - Assuming Redis fail-over preserves locks — async replication can lose the lock during a failover window. Combine with idempotency for safety.
Related tutorials
Architecture
Distributed Locking with Redis
TL;DR
Key takeaways
- Understand the core concepts behind Implementing Distributed Locking with Java Spring Boot and Redis for Data Integrity in a production context.
- Apply the patterns to real Microservices systems, not just toy examples.
- Recognize the trade-offs, failure modes, and operational concerns before adopting them.
- Get a clear path to the next step — related tutorials, tools, and reference architectures.
Avoid these
Common mistakes
1. Copy-pasting code without understanding the trade-offs
It's tempting to ship a snippet from a blog post into production, but Microservices patterns only work when the failure modes are understood. Always reason about timeouts, retries, and consistency.
2. Skipping observability from day one
Structured logs, metrics, and traces are not optional. Wire them in before you ship — debugging Microservices systems without them is painful and expensive.
3. Optimizing too early
Premature caching, sharding, or microservice extraction adds operational cost. Validate the bottleneck with real measurements first.
4. Ignoring security defaults
Secrets in env files, open management ports, missing RBAC — these are the most common production incidents. Treat security as part of the definition of done.
Ship it safely
Production best practices
Apply these before promoting Implementing Distributed Locking with Java Spring Boot and Redis for Data Integrity to a real production environment.
Scalability
Design Microservices services to scale horizontally. Keep request handlers stateless, push session and cache state to external stores (Redis, the database), and benchmark p95/p99 latency under realistic load before tuning.
Monitoring & Observability
Emit metrics (RED/USE), structured JSON logs, and distributed traces from day one. Wire dashboards and alerts to SLOs you actually care about — error rate, latency, saturation — not vanity metrics.
Logging
Log with correlation IDs, never log secrets or PII, and centralize logs (ELK, Loki, CloudWatch). Use levels deliberately: INFO for state changes, WARN for recoverable issues, ERROR for incidents.
Security
Apply least-privilege IAM, rotate secrets through a vault, validate every input, and patch dependencies on a schedule. For HTTP services, enable TLS everywhere and set sensible security headers.
Testing
Layer unit, integration, and contract tests. Run them in CI on every PR, and add smoke tests post-deploy. For Microservices systems, also run chaos and load tests before a major release.
Reliability & Rollouts
Ship with health checks, readiness probes, graceful shutdown, and a rollback strategy. Prefer canary or blue/green deploys over big-bang releases.
Questions
Frequently asked questions
Is this tutorial up to date?
Yes. This tutorial was last reviewed and updated on May 24, 2026. We revisit popular Microservices tutorials regularly to keep them aligned with current best practices.
What level is this tutorial aimed at?
It is written for working developers with some backend experience. Beginners can still follow along, and senior engineers will find production-grade patterns and trade-off discussions.
Do I need to follow every step in order?
The walkthrough is sequential because each step depends on the previous one. If you only need a specific concept, the table of contents at the top of the article lets you jump straight to that section.
Where can I find the source code?
Code samples are inlined in the tutorial. When a companion repository is published it will be linked at the top of this page.
Go deeper
Further reading
More From the Channel
Follow the full tutorial series on YouTube
The MasterLabSystems channel publishes in-depth, project-based tutorials on Java, Spring Boot, microservices, Docker, Kubernetes, AWS and DevOps — the same topics covered on this site, with full code walkthroughs.
Stay in the Loop
Get the next tutorial in your inbox
next tutorial →
Modular Monolith Architecture in Spring Boot — The Right Way to Scale a Monolith
Related tutorials
Spring Boot Microservices Architecture Explained Step by Step
A complete, beginner-friendly walkthrough of microservices architecture using Spring Boot — services, gateway, discovery, config and observability.
How to Build a Spring Cloud Config Server
Step-by-step guide to building a centralized configuration server with Spring Cloud Config, Git-backed properties and dynamic refresh.
Service Discovery with Eureka in Spring Boot
How service discovery works, why you need it, and how to set up Netflix Eureka with Spring Cloud step by step.
