As applications scale horizontally, multiple instances often process the same jobs simultaneously. Without proper coordination, duplicate processing can occur, leading to inconsistent data, duplicate emails, multiple payment attempts, or conflicting updates.
Distributed locking solves this problem by ensuring that only one application instance can execute a critical section at a time. Redis is a popular choice for implementing distributed locks because of its speed, atomic operations, and broad adoption in cloud-native applications.
In this article, you'll learn how to implement distributed locking with Redis in .NET 11, explore common use cases, and understand how to validate lock behavior using a structured testing methodology.
Note: This article focuses on implementation patterns and testing methodology. It does not include fabricated benchmark results.
Why Distributed Locking Matters
Consider an application running on multiple servers.
Load Balancer
│
┌─────────┴─────────┐
▼ ▼
ASP.NET Core App 1 ASP.NET Core App 2
│ │
└─────────┬─────────┘
▼
Background Job
Without coordination, both instances may execute the same job simultaneously.
Possible consequences include:
Duplicate invoice generation
Multiple payment processing
Duplicate email notifications
Inventory inconsistencies
Corrupted shared resources
A distributed lock ensures only one instance performs the operation.
When Should You Use Distributed Locks?
Common scenarios include:
Avoid using distributed locks for ordinary CRUD operations where optimistic concurrency or database transactions are sufficient.
Why Redis?
Redis provides:
Atomic operations
Very low latency
Automatic expiration
High availability
Cross-platform support
The SET command with the NX (Only if Not Exists) and PX (Expiration) options enables atomic lock acquisition.
Create the Project
dotnet new webapi -n DistributedLockDemo
Install the Redis client.
dotnet add package StackExchange.Redis
Configure Redis
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect("localhost:6379"));
This registers a shared Redis connection for the application.
Acquire a Lock
Create a service.
using StackExchange.Redis;
public class DistributedLockService
{
private readonly IDatabase _database;
public DistributedLockService(
IConnectionMultiplexer redis)
{
_database = redis.GetDatabase();
}
public async Task<bool> AcquireAsync(
string key,
string value,
TimeSpan expiry)
{
return await _database.StringSetAsync(
key,
value,
expiry,
When.NotExists);
}
}
When.NotExists ensures the lock is acquired only if it does not already exist.
Release the Lock
A lock should only be released by its owner.
public async Task ReleaseAsync(
string key,
string value)
{
const string script = """
if redis.call('GET', KEYS[1]) == ARGV[1]
then
return redis.call('DEL', KEYS[1])
end
return 0
""";
await _database.ScriptEvaluateAsync(
script,
new RedisKey[] { key },
new RedisValue[] { value });
}
Comparing the stored value before deletion prevents one process from accidentally releasing another process's lock.
Generate a Unique Lock Identifier
Each lock owner should have a unique identifier.
var lockId = Guid.NewGuid().ToString();
Store this value when acquiring the lock and reuse it during release.
Using the Lock
var acquired = await lockService.AcquireAsync(
"jobs:daily-report",
lockId,
TimeSpan.FromMinutes(2));
if (!acquired)
{
return Results.Conflict(
"Job already running.");
}
try
{
await GenerateReportAsync();
}
finally
{
await lockService.ReleaseAsync(
"jobs:daily-report",
lockId);
}
Always release locks in a finally block.
Lock Expiration
Every distributed lock should have an expiration.
Without expiration:
Example:
TimeSpan.FromMinutes(5)
Choose an expiration slightly longer than the expected execution time.
Background Service Example
public class ReportWorker : BackgroundService
{
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
// Acquire distributed lock
// Execute scheduled work
// Release lock
}
}
Only one worker instance performs the scheduled task across all application instances.
End-to-End Workflow
A typical workflow is:
Application starts.
Background worker begins execution.
Worker attempts to acquire a Redis lock.
Redis grants the lock to one instance.
Winning instance executes the job.
Other instances skip execution.
Job completes.
Lock is released or expires automatically.
This prevents duplicate execution across distributed deployments.
Distributed Locking Alternatives
| Approach | Suitable For | Distributed |
|---|
| lock keyword | Single process | No |
| SemaphoreSlim | Async within one process | No |
| SQL row locking | Database workloads | Yes |
| Redis distributed lock | Multi-instance applications | Yes |
| Leader election | Cluster coordination | Yes |
The C# lock statement only synchronizes threads within a single process and cannot coordinate multiple application instances.
Testing Methodology
Distributed locking should be validated under concurrent execution rather than measured with synthetic benchmark numbers.
Test Environment
Keep these variables consistent:
Test Scenarios
Evaluate:
Single instance
Multiple application instances
Long-running jobs
Simultaneous lock requests
Redis restart during execution
Lock expiration
Application crash while holding a lock
Metrics to Observe
Collect:
Successful lock acquisitions
Failed acquisition attempts
Lock wait time
Job duplication count
Lock expiration frequency
Redis latency
Useful Tools
Useful tools include:
Validate correctness first, then evaluate throughput under realistic concurrency.
Best Practices
Use unique lock identifiers.
Always configure lock expiration.
Release locks in a finally block.
Keep lock duration as short as possible.
Design critical operations to be idempotent.
Monitor lock acquisition failures.
Handle Redis connection failures gracefully.
Log lock ownership for troubleshooting.
Common Mistakes
| Mistake | Impact |
|---|
| No lock expiration | Permanent deadlocks after crashes |
| Releasing a lock without ownership verification | Another process's lock may be removed |
| Holding locks during lengthy operations | Reduced throughput |
| Using distributed locks for every request | Unnecessary complexity |
| Ignoring Redis failures | Inconsistent execution |
| Assuming locks guarantee business correctness | Incomplete fault tolerance |
Troubleshooting
Lock Cannot Be Acquired
Verify:
Existing lock ownership
Lock expiration
Redis connectivity
Application timing
Duplicate Jobs Still Execute
Review:
Lock key consistency
Lock acquisition logic
Expiration duration
Multiple Redis instances
Locks Never Release
Check:
Exception handling
finally block execution
Application crashes
Expiration configuration
FAQs
Why can't I use the C# lock keyword?
The lock keyword only synchronizes threads inside a single application process. It cannot coordinate multiple servers or containers.
Why should locks expire?
Expiration prevents stale locks from blocking future work if an application crashes before releasing the lock.
Is Redis locking suitable for scheduled jobs?
Yes. It is commonly used to ensure only one application instance executes recurring background jobs.
Should distributed locking replace database transactions?
No. Distributed locks coordinate execution across instances, while database transactions ensure consistency within database operations.
Can Redis become a single point of failure?
It can if deployed as a single instance. Production environments commonly use Redis replication, Sentinel, or clustered deployments to improve availability.
Conclusion
Distributed locking is an essential technique for coordinating work across multiple ASP.NET Core application instances. By using Redis atomic operations, unique lock identifiers, and automatic expiration, you can prevent duplicate processing while maintaining scalability and reliability.
When combined with idempotent operations, proper monitoring, and realistic concurrency testing, Redis-based distributed locking provides a robust foundation for production-ready background processing and distributed workflows.