Imagine your application runs on three servers. All three servers receive a request to generate the same monthly report. If every server starts the job, your application may waste resources or produce duplicate results.
A lock can help ensure that only one server performs the job at a time.
When an application runs on a single server, an in-memory lock may be enough. But when the application runs across multiple servers, each server has its own memory. A lock created on one server does not automatically stop the others.
This is where Redis distributed locks can help.
Redis allows multiple application instances to coordinate through a shared service. However, distributed locks come with their own problems. A server can crash while holding a lock, a network request can time out, or a lock can expire while the original job is still running.
If the lock is not handled carefully, your application may run the same job more than once or prevent other servers from doing useful work.
In this article, you will learn how Redis distributed locks work, how to acquire and release them safely, and what to do when a lock expires unexpectedly.
What Is a Redis Distributed Lock?
A distributed lock is a shared mechanism that helps multiple application instances coordinate access to a resource or operation.
For example, suppose an application runs a scheduled job that processes pending orders. The application has three instances, and each instance checks for work every minute.
Without coordination, all three instances might process the same orders at the same time.
A Redis lock can help one instance claim responsibility for the job while the others wait or skip that execution.
The basic process is:
An application instance tries to create a lock in Redis.
Redis allows the lock to be created if it does not already exist.
The instance performs the protected operation.
The instance releases the lock when the operation finishes.
If the instance crashes, the lock expires after its configured lifetime.
The expiration time is important. Without it, a crashed application could leave a lock behind indefinitely.
However, expiration alone does not solve every problem. If a job takes longer than the lock's lifetime, another instance may acquire the lock while the first instance is still working.
Why Redis Locks Can Become a Problem
A Redis lock may look simple, but distributed systems introduce situations that do not happen with an ordinary in-memory lock.
Consider these examples:
An application acquires a lock and then crashes before releasing it.
A job takes longer than expected, and the lock expires while the job is still running.
A network delay causes the application to believe that acquiring a lock failed, even though Redis may have processed the request.
An old lock holder tries to release a lock that now belongs to another application instance.
Each situation needs a clear solution.
A safe implementation should use a unique value for every lock acquisition, set an expiration time, release the lock only when the value matches, and handle expiration during long-running work.
Step 1: Acquire a Lock with an Expiration Time
Redis supports an atomic operation that creates a key only when it does not already exist and assigns it an expiration time.
Using StackExchange.Redis, you can acquire a basic lock like this:
using StackExchange.Redis;
public sealed class RedisLock
{
private readonly IDatabase _database;
public RedisLock(IConnectionMultiplexer redis)
{
_database = redis.GetDatabase();
}
public async Task<string?> TryAcquireAsync(
string lockKey,
TimeSpan expiry)
{
string lockValue = Guid.NewGuid().ToString("N");
bool acquired = await _database.StringSetAsync(
lockKey,
lockValue,
expiry,
When.NotExists);
return acquired ? lockValue : null;
}
}
The important part is the StringSetAsync call.
lockKey identifies the resource being protected.
lockValue identifies this particular lock acquisition.
expiry limits how long the lock remains in Redis.
When.NotExists tells Redis to create the key only if it does not already exist.
Redis performs the creation and expiration as one operation. This avoids a race condition that could occur if you created a key first and assigned its expiration in a separate command.
If the method returns a value, the application acquired the lock. If it returns null, another holder already has the lock.
A unique value is essential because the application must be able to distinguish its own lock from a lock acquired later by another instance.
Step 2: Release the Lock Only If You Own It
One of the most important rules for Redis locks is that an application must not delete a lock simply because it has finished its work.
Consider this sequence:
Server A acquires a lock with a 10-second expiration.
Server A takes longer than expected to finish.
The lock expires.
Server B acquires a new lock using the same key.
Server A finishes and deletes the key.
If Server A deletes the key without checking its value, it can accidentally remove Server B's lock.
To avoid this, compare the stored value with the unique value created during acquisition. Delete the key only if they match.
Redis supports this comparison and deletion through a Lua script, which runs atomically on the Redis server.
private const string ReleaseLockScript = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
""";
public async Task<bool> ReleaseAsync(
string lockKey,
string lockValue)
{
var result = await _database.ScriptEvaluateAsync(
ReleaseLockScript,
new RedisKey[] { lockKey },
new RedisValue[] { lockValue });
return (long)result == 1;
}
This code checks the lock value and deletes the key only when the values match.
If another instance owns the lock, the script leaves it untouched.
A successful release returns true. If the key is missing or its value has changed, the method returns false.
That result is useful for logging and debugging, but it should not automatically be treated as a reason to delete the key again.
Step 3: Use the Lock Around the Protected Work
Now you can combine acquisition and release in a simple workflow.
public async Task ProcessReportAsync(
CancellationToken cancellationToken)
{
const string lockKey = "locks:monthly-report";
string? lockValue = await TryAcquireAsync(
lockKey,
TimeSpan.FromSeconds(30));
if (lockValue is null)
{
// Another instance is already processing the report.
return;
}
try
{
await GenerateMonthlyReportAsync(
cancellationToken);
}
finally
{
await ReleaseAsync(lockKey, lockValue);
}
}
The finally block attempts to release the lock whether the job completes successfully or throws an exception.
The 30-second expiration is only an example. Choose an expiration based on the expected job duration and the consequences of the lock expiring early.
There is also an important limitation: the finally block may not run if the process crashes or loses power. That is why the expiration time is still necessary.
Most importantly, a Redis lock does not guarantee that the protected operation can never run twice. The lock may expire while the original operation continues. Critical operations need additional safeguards.
What Happens When a Job Takes Longer Than the Lock Expiration?
Suppose a job normally takes 20 seconds, so you set the lock expiration to 30 seconds.
One day, the database slows down and the job takes 45 seconds. After 30 seconds, Redis expires the lock. Another server can now acquire it and begin the same job.
The original server may still be running.
This is one of the most common mistakes when implementing distributed locks.
You have several options, depending on the operation.
Choose a suitable expiration time
For short and predictable operations, a reasonable expiration time may be enough.
However, making the expiration extremely long creates a different problem: if the application crashes, other instances may have to wait much longer before the lock becomes available.
Choose a practical timeout based on actual execution times and failure recovery requirements.
Renew the lock when needed
For longer jobs, you may need to extend the lock's expiration while the current instance is still working.
A renewal operation should extend the expiration only if the stored lock value still matches the current holder's unique value. Use an atomic Redis script to check the value and update the expiration together.
Do not blindly extend a key by name. If the original lock has expired and another instance has acquired it, an old holder must not extend the new holder's lock.
Renewal also requires careful handling of network failures and missed renewals. If the application can no longer confirm ownership, it should stop performing protected work when it is safe to do so.
Use a fencing token for critical operations
For important operations, a stronger approach is to use a fencing token.
A fencing token is a monotonically increasing number assigned to each new lock holder. The protected resource checks the token and rejects operations from older holders.
For example, Server A might receive token 41, while Server B later receives token 42. If Server A continues working after its lock expires, the database or other protected resource can reject its stale operations.
A fencing token works only when the protected resource actually validates it. A random lock value alone is not a fencing token, and simply adding a number to Redis does not automatically protect your database from stale writes.
For payments, financial records, or other critical operations, combine locking with appropriate database constraints, idempotency, or fencing where supported.
Handle Redis Failures Carefully
Your application should also decide what to do when Redis is unavailable.
Suppose a scheduled job cannot acquire a lock because the Redis request times out. The application may not know whether Redis created the lock before the timeout occurred.
Do not assume that a timeout means the lock was definitely not acquired.
For operations where duplicate execution is dangerous, a safer response may be to stop and retry later rather than continue without a confirmed lock.
For less critical operations, skipping one execution may be acceptable. The right choice depends on what the job does.
Also consider what happens if the Redis connection drops while the job is running. Losing the connection does not prove that the lock has expired, nor does it prove that the application still owns it.
For critical work, use a design that remains safe even when lock ownership cannot be confirmed.
Common Mistakes to Avoid
Mistake | Why it is risky | Better approach |
|---|
Creating a lock without an expiration | A crash can leave the lock stuck | Set an expiration during acquisition |
Deleting a lock by key alone | An old holder may delete another holder's lock | Compare the unique value before deleting |
Setting the expiration too short | Two instances may run the same job | Choose a suitable lifetime and consider renewal |
Assuming a lock prevents all duplicates | A lock can expire while work continues | Use idempotency or fencing for critical operations |
Ignoring Redis timeouts | The lock acquisition result may be uncertain | Handle ambiguous outcomes carefully |
Renewing a lock without checking ownership | An old holder may extend someone else's lock | Renew only when the stored value matches |
Treating the lock as a complete transaction | Redis and a database are separate systems | Protect database changes with suitable database guarantees |
Best Practices for Production Applications
Keep these points in mind when using Redis distributed locks:
Use a unique value for each acquisition. This lets the application identify its own lock.
Set an expiration atomically. Avoid separate commands for creating the lock and assigning its lifetime.
Release locks safely. Compare the stored value before deleting the key.
Keep lock ownership separate from job success. A failed job may still need cleanup, and a successful job may finish after its lock expires.
Plan for long-running jobs. Use carefully designed renewal or another coordination method when appropriate.
Handle uncertain failures. Do not assume a timeout means a lock was not created.
Make critical work safe to repeat. Use idempotency, unique constraints, or fencing where the protected resource supports it.
Monitor lock behavior. Track acquisition failures, expiration events, renewal failures, and job durations.
Conclusion
Redis distributed locks help multiple application instances coordinate work through a shared service. They are useful for scheduled jobs, background processing, and operations that should not normally run at the same time.
The important part is handling failure cases correctly. Always use an expiration, assign a unique value to each lock acquisition, and release the lock only when that value still matches. For long-running jobs, plan for expiration and renewal rather than assuming the lock will remain valid.
For critical operations, do not rely on a Redis lock alone to prevent duplicate work. Combine it with idempotency, database constraints, or fencing where appropriate.
A well-designed lock can reduce conflicts, but a reliable application must also remain safe when the lock expires, Redis becomes unavailable, or an old process continues running.
Join the conversation! Your thoughts help the community grow.