When building scalable .NET applications that run across multiple instances, you'll often need to coordinate work to ensure that certain operations are performed by only one instance at a time. This is where distributed locking comes into play.
What is Distributed Locking?
Distributed locking is a mechanism that allows you to coordinate access to shared resources across multiple application instances. Unlike traditional thread-level locking (using lock statements or Mutex), distributed locks work across different processes and machines.
Common Use Cases
Here are some scenarios where distributed locking is essential:
- Scheduled Jobs: Ensuring only one instance processes a scheduled task
- Resource Processing: Preventing multiple instances from processing the same queue item
- Configuration Updates: Coordinating configuration changes across instances
- Database Migrations: Ensuring migrations run only once across deployments
Implementation with Redis
Let's implement a distributed lock using Redis and the StackExchange.Redis library:
csharp1public class RedisDistributedLock : IDistributedLock 2{ 3 private readonly IDatabase _database; 4 private readonly string _lockKey; 5 private readonly string _lockValue; 6 private readonly TimeSpan _expiry; 7 8 public RedisDistributedLock( 9 IDatabase database, 10 string lockKey, 11 TimeSpan expiry) 12 { 13 _database = database; 14 _lockKey = lockKey; 15 _lockValue = Guid.NewGuid().ToString(); 16 _expiry = expiry; 17 } 18 19 public async Task<bool> TryAcquireAsync() 20 { 21 return await _database.StringSetAsync( 22 _lockKey, 23 _lockValue, 24 _expiry, 25 When.NotExists); 26 } 27 28 public async Task ReleaseAsync() 29 { 30 const string script = @" 31 if redis.call('get', KEYS[1]) == ARGV[1] then 32 return redis.call('del', KEYS[1]) 33 else 34 return 0 35 end"; 36 37 await _database.ScriptEvaluateAsync( 38 script, 39 new RedisKey[] { _lockKey }, 40 new RedisValue[] { _lockValue }); 41 } 42}
Usage Example
Here's how to use the distributed lock in a background service:
csharp1public class OrderProcessingService : BackgroundService 2{ 3 private readonly IDistributedLockFactory _lockFactory; 4 private readonly IOrderProcessor _orderProcessor; 5 6 protected override async Task ExecuteAsync(CancellationToken stoppingToken) 7 { 8 while (!stoppingToken.IsCancellationRequested) 9 { 10 using var distributedLock = _lockFactory.CreateLock( 11 "order-processing-lock", 12 TimeSpan.FromMinutes(5)); 13 14 if (await distributedLock.TryAcquireAsync()) 15 { 16 try 17 { 18 await _orderProcessor.ProcessPendingOrdersAsync(); 19 } 20 finally 21 { 22 await distributedLock.ReleaseAsync(); 23 } 24 } 25 26 await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); 27 } 28 } 29}
Best Practices
- Always Set Expiration: Locks should have a reasonable expiration time to prevent deadlocks
- Use Unique Values: Each lock acquisition should use a unique identifier
- Proper Cleanup: Always release locks in a
finallyblock or using statement - Handle Failures: Plan for scenarios where lock acquisition fails
- Monitor Performance: Track lock contention and acquisition times
Alternative Solutions
While Redis is popular for distributed locking, consider these alternatives:
- Database-based locks: Using database transactions and row-level locking
- Cloud services: Azure Service Bus, AWS SQS with message visibility timeouts
- Consul: HashiCorp Consul's session-based locking
- ZooKeeper: Apache ZooKeeper for coordination services
Conclusion
Distributed locking is a crucial pattern for building reliable multi-instance .NET applications. By implementing proper distributed locking mechanisms, you can ensure data consistency and prevent race conditions across your distributed system.
Remember to always consider the trade-offs between performance, complexity, and reliability when choosing your distributed locking strategy.