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:
Scheduled background jobs
Inventory updates
Payment processing
Cache rebuilding
Report generation
File processing
Database migration coordination
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:
Application crashes may leave locks permanently held.
Future executions may never proceed.
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.
Join the conversation! Your thoughts help the community grow.