- Published on
- · Updated
Distributed Locking with Spring Boot and Redisson: Transactions, Leases, and Fencing
- Authors

- Name
- Maria
A distributed lock coordinates application instances that operate on the same logical resource. It can prevent two schedulers from starting the same job or two service instances from entering one critical section at the same time.
It does not make an operation exactly once. It does not replace a database constraint, an idempotency key, a transaction, or a recovery strategy. Redis failover, process pauses, network interruption, and incorrect Spring transaction boundaries can still produce duplicate or stale work.
This guide uses Spring Boot 4.1, Java 25, Redisson, Redis, and PostgreSQL.
TL;DR Keep durable invariants in the database. Acquire the Redis lock before opening the database transaction and release it after commit or rollback. Use watchdog and fixed leases deliberately, and use fencing tokens when a stale lock holder must be rejected.
Start with the Invariant
Before adding Redis, write down what must remain true.
One payment key may create at most one charge.
Inventory must not fall below zero.
Only one scheduler instance may create a daily report.
One order transition may be applied only once.
Then choose the mechanism nearest the authoritative state.
| Requirement | Preferred mechanism |
|---|---|
| Reject duplicate requests | Idempotency key and unique constraint |
| Prevent lost updates | Optimistic locking or conditional UPDATE |
| Serialize one database row | SELECT ... FOR UPDATE |
| Publish after DB commit | Transactional outbox |
| Elect one scheduled-job runner | Distributed lock |
| Limit global concurrency | Distributed semaphore |
| Reject stale workers | Fenced lock and token validation |
A distributed lock is normally a coordination layer around a durable invariant, not the invariant itself.
Why Local Locks Fail Across Replicas
Java synchronization protects only one JVM:
synchronized void process(UUID orderId) {
// Protected inside this process only.
}
With several application replicas, each replica owns a different monitor.
Redisson's RLock stores lock state in Redis or Valkey. Clients using the same lock name coordinate through that shared state:
lock:order:{orderId}
The lock is advisory. A client that ignores it can still modify the resource, so the database or external service must continue enforcing its rules.
Project Setup
Redisson's Spring Boot starter supports Spring Boot 4 and provides a RedissonClient bean.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.7</version>
<relativePath />
</parent>
<properties>
<java.version>25</java.version>
<redisson.version>4.6.1</redisson.version>
</properties>
<dependencies>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>${redisson.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
Basic Redis configuration:
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
connect-timeout: 2s
timeout: 3s
Use TLS, authentication, network isolation, and a suitable Redis high-availability topology in production.
Define Stable Lock Names
Every participant must derive the same key for the same resource.
public final class LockNames {
private LockNames() {}
public static String order(UUID orderId) {
return "lock:order:" + orderId;
}
public static String account(UUID accountId) {
return "lock:account:" + accountId;
}
}
Avoid mutable display names, locale-dependent formatting, secrets, personal data, and unbounded client input.
A Reusable Lock Executor
@Component
public class DistributedLockExecutor {
private final RedissonClient redisson;
public DistributedLockExecutor(RedissonClient redisson) {
this.redisson = redisson;
}
public <T> T execute(
String lockName,
Duration waitTime,
Supplier<T> action
) {
RLock lock = redisson.getLock(lockName);
boolean acquired = false;
try {
acquired = lock.tryLock(
waitTime.toMillis(),
TimeUnit.MILLISECONDS
);
if (!acquired) {
throw new LockUnavailableException(lockName);
}
return action.get();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new LockInterruptedException(
lockName,
exception
);
} finally {
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
This uses the overload without an explicit lease, so the Redisson watchdog renews the lock while the owner remains alive.
Only the owner thread may unlock an RLock. A different thread receives IllegalMonitorStateException.
Watchdog Versus Fixed Lease
These are different acquisition modes.
Watchdog-managed lock
boolean acquired = lock.tryLock(
2,
TimeUnit.SECONDS
);
Redisson renews the lock while the client remains alive. The default watchdog timeout is 30 seconds.
Fixed lease
boolean acquired = lock.tryLock(
2,
15,
TimeUnit.SECONDS
);
The first value is the acquisition wait. The second is the maximum lock lifetime.
A positive leaseTime expires automatically after the configured duration. Do not claim that the watchdog keeps extending this fixed lease.
Use a fixed lease only when:
- the critical section has a known upper bound;
- stale completion is harmless or fenced;
- exceeding the lease should permit another worker to proceed.
A lease that is too short can allow overlapping workers. A lease that is too long delays recovery after a crash.
The Spring Transaction Boundary Trap
This method contains a race:
@Transactional
public void processOrder(UUID orderId) {
RLock lock = redisson.getLock(
LockNames.order(orderId)
);
lock.lock();
try {
orderRepository.updateStatus(
orderId,
OrderStatus.PROCESSED
);
} finally {
lock.unlock();
}
}
Spring opens the transaction through a proxy before entering the method. The method releases the lock before it returns. The proxy normally commits after the return.
A second worker can therefore acquire the lock before the first database transaction commits.
Worker A acquires lock
Worker A changes entity
Worker A releases lock
Worker B acquires lock and reads old state
Worker A commits
Put Locking Outside the Transaction
Use one bean for lock ownership and another proxied bean for the transaction.
@Service
public class OrderProcessingCoordinator {
private final DistributedLockExecutor locks;
private final OrderTransactionalService transactions;
public OrderProcessingCoordinator(
DistributedLockExecutor locks,
OrderTransactionalService transactions
) {
this.locks = locks;
this.transactions = transactions;
}
public OrderResult process(UUID orderId) {
return locks.execute(
LockNames.order(orderId),
Duration.ofSeconds(2),
() -> transactions.process(orderId)
);
}
}
@Service
public class OrderTransactionalService {
private final OrderRepository orderRepository;
public OrderTransactionalService(
OrderRepository orderRepository
) {
this.orderRepository = orderRepository;
}
@Transactional
public OrderResult process(UUID orderId) {
Order order = orderRepository
.findById(orderId)
.orElseThrow();
if (order.isProcessed()) {
return OrderResult.alreadyProcessed(orderId);
}
order.markProcessed();
return OrderResult.processed(orderId);
}
}
The sequence is now:
Acquire Redis lock
Start proxied DB transaction
Commit or roll back
Return to coordinator
Release Redis lock
Do not place both methods in one class and call the transactional method through this; self-invocation bypasses Spring's transaction proxy.
Keep Database Constraints
For duplicate payment requests:
CREATE TABLE payment_attempts (
id UUID PRIMARY KEY,
idempotency_key VARCHAR(200) NOT NULL,
account_id UUID NOT NULL,
amount NUMERIC(19, 2) NOT NULL,
status VARCHAR(30) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (idempotency_key)
);
The Redis lock may reduce duplicate work. The unique constraint remains authoritative during lock loss, deployment mistakes, or a competing writer that does not use Redis.
A lock is not an idempotency mechanism.
Prefer Database Locks for Database Rows
When all writers update one PostgreSQL row, a row lock is often simpler.
public interface InventoryRepository
extends JpaRepository<Inventory, UUID> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select inventory
from Inventory inventory
where inventory.productId = :productId
""")
Optional<Inventory> findForUpdate(UUID productId);
}
@Transactional
public void reserve(UUID productId, int quantity) {
Inventory inventory = repository
.findForUpdate(productId)
.orElseThrow();
inventory.reserve(quantity);
}
The row lock and update share one transaction manager and one commit boundary.
Use Redis when the protected resource is broader than one row, is not owned by one database, or coordinates several application instances around an external activity.
Stale Lock Holders
Consider:
- Worker A acquires a lock.
- A pauses for a long time.
- Its lease expires or ownership is lost.
- Worker B acquires the lock and writes a newer result.
- A resumes and writes an older result.
Both workers used the locking API. The resource still received a stale write.
The stronger pattern is a fencing token.
Fencing with RFencedLock
RFencedLock lock = redisson.getFencedLock(
"lock:report:" + reportId
);
Long token = lock.tryLockAndGetToken(
2,
30,
TimeUnit.SECONDS
);
if (token == null) {
throw new LockUnavailableException(
"report:" + reportId
);
}
try {
reportStorage.write(
reportId,
payload,
token
);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
The token increases with each acquisition. The protected resource must store the highest accepted token and reject lower tokens. Obtaining a token without validating it adds no protection.
A PostgreSQL projection can enforce the rule:
CREATE TABLE generated_reports (
report_id UUID PRIMARY KEY,
content JSONB NOT NULL,
fencing_token BIGINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
INSERT INTO generated_reports (
report_id,
content,
fencing_token,
updated_at
)
VALUES (
:report_id,
CAST(:content AS jsonb),
:token,
NOW()
)
ON CONFLICT (report_id)
DO UPDATE SET
content = EXCLUDED.content,
fencing_token = EXCLUDED.fencing_token,
updated_at = NOW()
WHERE generated_reports.fencing_token
< EXCLUDED.fencing_token;
If token 41 resumes after token 42 was committed, its update is rejected.
Redis Failover Is Not Invisible
Redis replication is asynchronous. A simplistic lock can be lost when a primary fails before the lock reaches a promoted replica.
Current Redisson versions verify lock replication to connected replicas by default and fail an acquisition when synchronization cannot be confirmed within the configured timeout.
That reduces one failover window. It does not eliminate stale-client risks for long operations or make external systems part of the lock protocol. Use fencing when the resource can validate it.
Do not describe Sentinel or Cluster as proof that two clients can never believe they hold the same logical lock under every failure.
Multiple Locks
When one operation needs several locks, acquire them in one global order.
List<String> lockNames = Stream.of(
LockNames.account(fromAccountId),
LockNames.account(toAccountId)
)
.sorted()
.toList();
Without a consistent order:
Worker A holds account-1 and waits for account-2
Worker B holds account-2 and waits for account-1
For transfers between PostgreSQL rows, sorted database row locks are often safer than two Redis locks.
Scheduled Jobs
A lock can elect one scheduler replica:
@Component
public class DailyReportScheduler {
private final DistributedLockExecutor locks;
private final DailyReportService reports;
@Scheduled(cron = "0 0 2 * * *")
public void generate() {
try {
locks.execute(
"lock:job:daily-report",
Duration.ZERO,
() -> {
reports.generateFor(
LocalDate.now(ZoneOffset.UTC)
);
return null;
}
);
} catch (LockUnavailableException ignored) {
// Another instance owns this execution.
}
}
}
The job must still be idempotent. Persist a durable execution key:
CREATE TABLE job_executions (
job_name VARCHAR(150) NOT NULL,
execution_date DATE NOT NULL,
status VARCHAR(30) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (job_name, execution_date)
);
The lock chooses one runner. The execution row records completion.
Reactive and Asynchronous Boundaries
RLock ownership is associated with a thread identifier. Reactive pipelines and callbacks can continue on another thread.
Do not acquire synchronously on one thread and assume any later callback may unlock it.
Safer options:
- keep the critical section on one thread;
- use Redisson's reactive or async API with a consistent explicit thread ID;
- avoid holding locks across asynchronous boundaries;
- redesign around idempotent commands and durable state.
A lock held across an unbounded asynchronous workflow is a design warning.
Failure Policy
Decide what happens when Redis is unavailable.
Fail closed
Reject or delay the operation.
Suitable for:
- payment mutation;
- inventory changes;
- one-time transitions;
- jobs that must not overlap.
Fail open
Continue without the lock only when duplicate work is harmless and downstream controls remain authoritative.
Suitable examples:
- rebuilding a disposable cache;
- recomputing an idempotent report.
Do not catch every Redisson exception and silently run the protected operation anyway.
Observability
Track:
- acquisition attempts and success;
- acquisition timeout count;
- wait duration;
- hold duration;
- unlock failures;
- Redis command latency;
- connection failures;
- fencing-token rejection count;
- contention by lock category.
Do not use raw order or account IDs as metric labels; they create high-cardinality metrics.
Useful structured fields:
lock.category
resource.type
wait.duration
hold.duration
acquired
instance.id
trace.id
Testing
Test more than simultaneous method entry.
Required scenarios
- two application contexts contend for one lock;
- lock acquisition times out;
- interruption preserves the interrupt flag;
- transaction rollback occurs before unlock;
- duplicate idempotency keys are rejected;
- fixed lease expires during delayed work;
- watchdog-managed work exceeds 30 seconds;
- Redis restarts or fails over;
- stale fencing tokens are rejected;
- multiple lock keys use one deterministic order.
A useful transaction-order test records:
lock acquired
transaction started
business update
transaction committed
lock released
A test that only proves “two threads did not enter together” does not prove that the commit was protected.
Common Mistakes
“The lock makes the operation exactly once”
It does not. Use an idempotency key, unique constraint, or processed-message record.
“A fixed lease is renewed by the watchdog”
A supplied lease expires after its configured duration. Use an acquisition without a lease when watchdog renewal is intended.
“@Transactional on the locked method covers commit”
The transaction proxy can commit after the method releases the lock. Put lock ownership outside a separate transactional bean.
“Redis HA guarantees mutual exclusion under every failure”
HA improves availability. Fencing and durable invariants still matter.
“The database no longer needs constraints”
The database remains the final authority for durable state.
Decision Checklist
Before approving a Redis lock, answer:
- What invariant is protected?
- Why are a database constraint or row lock insufficient?
- What is the canonical lock key?
- What is the maximum wait?
- Is the lock watchdog-managed or fixed-lease?
- What happens if work exceeds the lease?
- Does the transaction commit before unlock?
- Is the operation idempotent?
- Can the resource validate a fencing token?
- Does failure cause fail-open or fail-closed behavior?
- How is contention monitored?
- Has failover been tested?
Conclusion
Redisson makes Redis-based locking convenient, but correctness still belongs to the application design.
A reliable implementation should:
- keep durable invariants in the database;
- use stable, fine-grained lock names;
- bound acquisition waits;
- choose watchdog and lease behavior deliberately;
- acquire the lock before starting the transaction;
- release it after commit or rollback;
- keep operations idempotent;
- use fencing tokens for stale-writer protection;
- define failure behavior;
- test process pauses, Redis failure, and transaction ordering.
A distributed lock succeeds when it reduces coordination races without becoming the only protection for the data.