Redis  

Implementing Distributed Locking in .NET with Redis

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:

  1. Application starts.

  2. Background worker begins execution.

  3. Worker attempts to acquire a Redis lock.

  4. Redis grants the lock to one instance.

  5. Winning instance executes the job.

  6. Other instances skip execution.

  7. Job completes.

  8. Lock is released or expires automatically.

This prevents duplicate execution across distributed deployments.

Distributed Locking Alternatives

ApproachSuitable ForDistributed
lock keywordSingle processNo
SemaphoreSlimAsync within one processNo
SQL row lockingDatabase workloadsYes
Redis distributed lockMulti-instance applicationsYes
Leader electionCluster coordinationYes

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:

  • .NET SDK version

  • Redis version

  • Number of application instances

  • Network latency

  • Hardware configuration

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:

  • Redis Insight

  • redis-cli

  • Docker Compose

  • k6

  • BenchmarkDotNet (application components)

  • dotnet-counters

  • Application Insights

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

MistakeImpact
No lock expirationPermanent deadlocks after crashes
Releasing a lock without ownership verificationAnother process's lock may be removed
Holding locks during lengthy operationsReduced throughput
Using distributed locks for every requestUnnecessary complexity
Ignoring Redis failuresInconsistent execution
Assuming locks guarantee business correctnessIncomplete 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.