使用StackExchange.Redis实现C#分布式锁的两种方式
分布式锁在集群架构中扮演着关键角色,主要用于控制并发访问。以下是一些主要应用场景:
- 在秒杀、抢购等高并发场景下,多个用户可能同时下单同一商品,导致库存超卖。
- 支付、转账等金融操作需要保证同一账户的资金变动是串行执行的。
- 分布式环境下,多个节点可能同时触发同一任务(如定时报表生成)。
- 用户因网络延迟重复提交表单,可能导致数据重复插入。
自定义分布式锁实现
获取锁
以下代码示例展示了如何对特定订单进行扣款处理,防止用户重复提交导致不同节点处理同一订单:
public static async Task<(bool Success, string LockId)> AcquireLock(string cacheKey, int timeoutSeconds = 5)
{
var lockKey = BuildLockKey(cacheKey);
var lockId = Guid.NewGuid().ToString();
var timeoutMilliseconds = timeoutSeconds * 1000;
var expiresIn = TimeSpan.FromMilliseconds(timeoutMilliseconds);
bool acquired = await _redisDb.StringSetAsync(lockKey, lockId, expiresIn, When.NotExists);
return (acquired, acquired ? lockId : string.Empty);
}
private static string BuildLockKey(string cacheKey)
{
return $"MyApp:locker:{cacheKey}";
}
上述代码在请求时将订单号作为Redis键的一部分存储,并生成一个唯一的锁标识。只有在Redis中不存在该键时才能成功设置,即成功获取锁。
释放锁
释放锁时需要确保加锁和释放锁是同一个来源:
public static async Task<bool> ReleaseLock(string cacheKey, string lockId)
{
var lockKey = BuildLockKey(cacheKey);
var script = @"local expected = @lockId
local current = redis.call('get', @lockKey)
if current == expected then
redis.call('del', @lockKey)
return 1
else
return 0
end";
var parameters = new { lockKey, lockId };
var prepared = LuaScript.Prepare(script);
var result = (int)await _redisDb.ScriptEvaluateAsync(prepared, parameters);
return result == 1;
}
使用Lua脚本的原因:
- 如果锁过期或被释放,直接删除可能导致其他节点的锁被误删。
- 脚本在Redis单线程执行,确保操作原子性。
自动续期
对于耗时任务,需要实现自动续期:
public async static Task Renew(IDatabase db, string key, string lockId, int milliseconds)
{
if (!Locker renewLocker = AutoRenewHandler.Instance.GetOrAdd(key, () => new Locker()))
{
return;
}
var script = @"local current = redis.call('get', @key)
if current == @lockId then
redis.call('pexpire', @key, @milliseconds)
return 1
else
return 0
end";
var parameters = new { key, lockId, milliseconds };
var prepared = LuaScript.Prepare(script);
var result = await db.ScriptEvaluateAsync(prepared, parameters, CommandFlags.None);
if ((int)result == 0)
{
AutoRenewHandler.Instance.Remove(key);
}
}
自动续期处理器:
public class AutoRenewHandler
{
private static readonly ConcurrentDictionary<string, Locker> _handlers = new();
public static Locker GetOrAdd(string key, Func<Locker> factory)
{
return _handlers.AddOrUpdate(key, factory, (k, _) => factory());
}
public static bool Remove(string key)
{
return _handlers.TryRemove(key, out _);
}
}
public class Locker : IDisposable
{
private readonly CancellationTokenSource _token = new();
private Task _task;
public void Start()
{
_task = Task.Run(RenewTask, _token.Token);
}
public void Dispose()
{
_token.Cancel();
_task.Wait();
}
private async Task RenewTask()
{
while (!_token.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromMilliseconds(5000));
await Renew(_redisDb, _key, _lockId, _timeout);
}
}
}
完整的带自动续期的锁获取代码:
public static async Task<(bool Acquired, string LockId)> AcquireLock(string cacheKey, int timeoutSeconds = 5, bool autoRenew = false)
{
var lockKey = BuildLockKey(cacheKey);
var lockId = Guid.NewGuid().ToString();
var expiresIn = TimeSpan.FromSeconds(timeoutSeconds);
var acquired = await _redisDb.StringSetAsync(lockKey, lockId, expiresIn, When.NotExists);
if (acquired && autoRenew)
{
var handler = new AutoRenewHandler();
handler.Start(lockKey, lockId, timeoutSeconds);
}
return (acquired, lockId);
}
Redis的过期精度约为1秒,续期应选择TTL的一半间隔以确保在过期检查前完成续期。
使用StackExchange.Redis的内置锁
获取锁
string lockKey = "order:12345:lock";
string lockId = Guid.NewGuid().ToString();
TimeSpan expiry = TimeSpan.FromSeconds(30);
bool acquired = _redisDb.LockTake(lockKey, lockId, expiry);
释放锁
bool released = _redisDb.LockRelease(lockKey, lockId);
特点
- 使用Redis的原生
LOCK命令,提供原子操作保证。 - 内置自动过期机制,避免死锁。
- 使用OPTIMISTIC锁模式,提高性能。