When an application runs on multiple servers, two instances may attempt to process the same job at the same time. This can happen in scheduled tasks, background workers, inventory updates, report generation, and other operations that should not run concurrently.
A local lock such as C#'s lock statement protects code only within a single process. It cannot coordinate separate application instances running on different servers.
Redis can help coordinate these instances through distributed locks. However, acquiring a lock is only part of the problem. If a process crashes, a network request times out, or a lock expires while the original operation is still running, another instance may acquire the same lock. Incorrect cleanup can also cause one process to release a lock owned by another.
This article explains how to implement Redis-based distributed locks, how to prevent stuck locks, and when a distributed lock is not enough to protect your data.
What Is a Redis Distributed Lock?
A distributed lock is a coordination mechanism that allows one client at a time to acquire permission to perform a particular operation across multiple processes or machines.
For example, imagine an application with three background workers that all check whether a monthly report needs to be generated. Without coordination, all three workers may start generating the same report.
A distributed lock can ensure that only one worker acquires the lock for that report at a time.
Redis supports the basic operations needed for a simple lock:
Create a key only if it does not already exist.
Assign an expiration time to the key.
Associate the key with a unique ownership token.
Delete the key only when the caller still owns it.
The ownership token is essential. A process must not release a lock merely because the lock key exists. It must verify that the key still contains its own token.
Acquire a Lock with SET NX PX
Redis provides the SET command with the NX and PX options.
A lock acquisition can look like this:
SET lock:monthly-report unique-token-123 NX PX 30000
If the key does not exist, Redis creates it and returns a successful result. If another client already holds the lock, the command does not acquire it.
The expiration is important because a process might crash before releasing the lock. Without an expiration, the key could remain indefinitely and prevent other workers from proceeding.
However, an expiration also introduces a tradeoff. If the operation takes longer than the lock's lifetime, the key may expire while the original worker is still running. Another worker could then acquire the same lock.
The expiration must therefore be selected based on the expected duration of the operation, and the application must account for operations that run longer than expected.
Implement a Redis Lock in C#
The following example uses the StackExchange.Redis library.
Install the package:
dotnet add package StackExchange.Redis
The example below implements basic acquisition and safe release. It uses a unique token for each acquisition attempt and stores the token as the value of the Redis key.
using StackExchange.Redis;
public sealed class RedisLock
{
private readonly IDatabase _database;
public RedisLock(IDatabase database)
{
_database = database;
}
public async Task<string?> TryAcquireAsync(
string lockKey,
TimeSpan expiration)
{
var token = Guid.NewGuid().ToString("N");
var acquired = await _database.StringSetAsync(
lockKey,
token,
expiration,
When.NotExists);
return acquired ? token : null;
}
}
The StringSetAsync operation uses Redis's conditional set behavior. The lock is created only when the key does not already exist, and the expiration is set as part of the same operation.
Combining acquisition and expiration in one Redis command avoids a dangerous gap between creating the lock and assigning its expiry.
If the method returns a token, the caller acquired the lock. If it returns null, another client currently holds the key, or the acquisition otherwise did not succeed.
The token should be treated as an ownership credential. Do not reuse a fixed token for every request, because doing so makes it impossible to distinguish one lock acquisition from another.
Release the Lock Only If You Still Own It
A common implementation mistake is deleting the lock key directly:
await database.KeyDeleteAsync("lock:monthly-report");
This is unsafe because the lock might have expired and been acquired by another process before the original process reaches the delete statement.
Consider this sequence:
Worker A acquires a lock with token A.
Worker A runs longer than the lock expiration.
Redis expires the lock key.
Worker B acquires the same lock with token B.
Worker A finishes and deletes the lock key.
Worker A has now deleted Worker B's lock.
To prevent this, compare the stored token with the caller's token and delete the key only when they match. The comparison and deletion must be atomic.
Redis Lua scripting can perform both operations as one atomic command:
using StackExchange.Redis;
public sealed class RedisLock
{
private readonly IDatabase _database;
private const string ReleaseScript = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
""";
public RedisLock(IDatabase database)
{
_database = database;
}
public async Task<string?> TryAcquireAsync(
string lockKey,
TimeSpan expiration)
{
var token = Guid.NewGuid().ToString("N");
var acquired = await _database.StringSetAsync(
lockKey,
token,
expiration,
When.NotExists);
return acquired ? token : null;
}
public async Task<bool> ReleaseAsync(
string lockKey,
string token)
{
var result = await _database.ScriptEvaluateAsync(
ReleaseScript,
new RedisKey[] { lockKey },
new RedisValue[] { token });
return (long)result == 1;
}
}
The script checks the stored token and deletes the key only when it matches the caller's token. Because Redis executes the script atomically, another command cannot replace the key between the comparison and deletion.
A return value of true means the lock was released by this call. A return value of false means the key was absent or the token no longer matched.
This implementation prevents an expired lock owner from deleting a lock acquired by another worker. It does not, by itself, prevent the original worker from continuing its protected operation after the lock expires.
Use try/finally to Release the Lock
Once a worker acquires a lock, it should attempt to release it when the protected operation finishes, including when the operation throws an exception.
Use try/finally to ensure that release is attempted:
public async Task GenerateMonthlyReportAsync(
RedisLock redisLock,
CancellationToken cancellationToken)
{
const string lockKey = "lock:monthly-report";
var token = await redisLock.TryAcquireAsync(
lockKey,
TimeSpan.FromSeconds(30));
if (token is null)
{
return;
}
try
{
await GenerateReportCoreAsync(cancellationToken);
}
finally
{
await redisLock.ReleaseAsync(lockKey, token);
}
}
The finally block runs when the protected operation completes or throws, allowing the application to release the lock if it still owns it.
There is one important limitation: the finally block cannot run if the process is forcibly terminated or the machine loses power. This is why the lock also needs an expiration.
In production code, decide how to handle a failure during release. If Redis is unavailable, the application may be unable to remove the key immediately. The expiration provides eventual recovery, provided Redis remains available and the key has a valid TTL.
Do not replace release failures with unconditional key deletion. Doing so would reintroduce the ownership problem the token was designed to prevent.
Choose a Suitable Lock Expiration
The expiration controls how long a lock remains in Redis without renewal.
A very short expiration can allow another worker to acquire the lock while the original operation is still running. A very long expiration can delay recovery after a crash.
For example, a report normally takes five seconds to generate. A 30-second lock expiration may provide reasonable headroom for ordinary variation, but the right value depends on the workload and its worst-case execution time.
Measure the operation rather than choosing an expiration arbitrarily. Account for database delays, network latency, garbage collection pauses, and other factors that may extend execution time.
Even a generous expiration cannot guarantee that the lock will remain valid indefinitely. If an operation can run for an unpredictable duration, consider a renewal strategy or a different coordination mechanism.
Renew a Lock for Long-Running Operations
For operations that may take longer than the initial expiration, a lock can be renewed periodically. Renewal must verify ownership before extending the TTL.
The following Lua script renews the expiration only when the stored token matches:
private const string RenewScript = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
else
return 0
end
""";
It can be called through StackExchange.Redis:
public async Task<bool> RenewAsync(
string lockKey,
string token,
TimeSpan expiration)
{
var milliseconds = checked(
(long)expiration.TotalMilliseconds);
var result = await _database.ScriptEvaluateAsync(
RenewScript,
new RedisKey[] { lockKey },
new RedisValue[]
{
token,
milliseconds
});
return (long)result == 1;
}
This method extends the TTL only if the caller still owns the lock. It cannot restore ownership after the key has expired or been replaced.
A production renewal loop must run well before the expiration deadline, handle Redis errors, and stop the protected operation if it can no longer establish that the lock is valid.
Renewal is not a substitute for ownership checks. A delayed worker must not assume that it still holds the lock simply because it previously acquired it.
Understand the Stale-Owner Problem
A Redis lock with an expiration can prevent two workers from acquiring the same key simultaneously, but it cannot always guarantee that only one worker is still executing the protected operation.
For example:
Worker A acquires a lock.
Worker A pauses for a long time because of a process stall or network problem.
The lock expires.
Worker B acquires the lock and starts the operation.
Worker A resumes and continues its work.
Both workers may now be performing the operation, even though only one owns the current Redis lock.
This is known as a stale-owner problem. It is particularly important for operations that must never be applied twice or concurrently, such as financial updates, inventory changes, or irreversible external actions.
A lock token protects lock release, but it does not automatically stop a stale worker from modifying the underlying resource.
For critical operations, consider a fencing-token design. A fencing token is a monotonically increasing number issued for each new ownership period. The protected resource must reject writes from owners with older tokens.
The token must be generated by a system that guarantees the required ordering, and the resource being protected must enforce it. A random GUID is useful for lock ownership comparison, but it is not a fencing token.
For database operations, a transaction, a conditional update, a unique constraint, or an optimistic concurrency check may be a stronger way to protect the business invariant. For payment processing and other external operations, use idempotency mechanisms where appropriate.
Handle Contention Without Hammering Redis
When a worker cannot acquire a lock, it should not necessarily retry immediately in a tight loop.
Repeated attempts can generate unnecessary Redis traffic and cause multiple workers to compete continuously for the same lock.
A simple approach is to return control to the scheduler when the lock is unavailable. If the job must wait, use a bounded retry policy with a delay and cancellation support.
For example:
public async Task<string?> AcquireWithRetryAsync(
RedisLock redisLock,
string lockKey,
TimeSpan expiration,
CancellationToken cancellationToken)
{
const int maxAttempts = 5;
for (var attempt = 0; attempt < maxAttempts; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
var token = await redisLock.TryAcquireAsync(
lockKey,
expiration);
if (token is not null)
{
return token;
}
var delayMilliseconds =
Random.Shared.Next(100, 301) * (attempt + 1);
await Task.Delay(
delayMilliseconds,
cancellationToken);
}
return null;
}
This example makes a limited number of attempts and uses a randomized delay to reduce synchronized retries. The exact retry policy should reflect the job's deadline and importance.
If the lock cannot be acquired, the caller should have a defined response: skip the scheduled run, reschedule it, return a conflict, or report that another worker is processing the job. Do not silently wait forever.
Common Mistakes to Avoid
Several mistakes can make Redis distributed locks unreliable:
Creating a lock without an expiration, allowing a crash to leave it stuck indefinitely.
Deleting a lock without verifying its ownership token.
Using the same token for every lock acquisition.
Setting an expiration shorter than the operation's normal execution time.
Assuming renewal is successful without checking its result.
Continuing critical work after losing the lock.
Retrying acquisition continuously without a delay or attempt limit.
Treating a Redis lock as a replacement for database transactions or constraints.
Assuming a lock guarantees exactly-once execution of an external side effect.
Ignoring Redis failover and availability behavior when the application requires strong coordination guarantees.
The implementation should be tested against the failure scenarios it is expected to handle, not only the successful acquire-and-release path.
When to Use a Redis Lock
A Redis lock is useful when multiple application instances need temporary coordination, such as preventing duplicate scheduled jobs, coordinating cache regeneration, or limiting concurrent work on a shared task.
It may not be the best choice when the database can enforce the required invariant directly. For example, a unique constraint or conditional database update can often prevent duplicate writes more reliably than a separate Redis lock.
If correctness depends on strict mutual exclusion even during process pauses, network partitions, or Redis failover, evaluate a mature distributed-locking implementation and the guarantees provided by the coordination system. A simple Redis key should not be treated as a universal guarantee that only one process can ever execute the protected code.
Conclusion
Redis distributed locks can help coordinate work across application instances, but their correctness depends on more than setting a key.
Use an atomic SET with NX and an expiration, generate a unique token for every acquisition, and release the lock only when the stored token matches. Use try/finally for normal cleanup and expiration for recovery after process failure.
For long-running operations, consider safe renewal and what should happen if ownership is lost. For critical business operations, protect the underlying data with transactions, concurrency checks, fencing tokens, or idempotency mechanisms as appropriate.
A distributed lock is most effective when it is one part of a broader design that accounts for failures, retries, and the actual guarantees required by the application.
Jasen FiciPosted Oct 23, 2026, 1:02 PM
We picked this up for DotNetNews. Here's the issue: https://dotnetnews.beehiiv.com/p/new-post-a3e2