为什么需要分布式锁
在分布式系统中,多实例并发访问共享资源(比如执行定时任务、写同一条记录、生成唯一序号等)时必须保证互斥,否则会出现竞态、重复处理或数据不一致。分布式锁提供跨进程/跨主机的互斥机制,确保在同一时间只有一个节点对关键资源执行操作。
常见实现方案概览
基于 Redis 的 Key+TTL(Redlock):通过在多个独立 Redis 实例上加锁并使用超时/租约保证,适合高性能场景。
基于数据库(例如 SQL Server 的 sp_getapplock / PostgreSQL advisory locks):利用数据库内建的锁机制实现可靠互斥,适合已有数据库且不想引入新依赖的场景。
使用成熟的 .NET 分布式锁库(如 DistributedLock、RedLock.net 等):用库封装复杂细节,生产中更省心。
设计要点(无论哪种实现都应注意)
锁的可重入性与租约(lease):多数分布式锁使用租约(TTL)来避免死锁,租约应略大于预期处理时间,或支持续租。
超时与失败回退(fallback):获取锁时设置合理等待超时,获取失败可选择重试或返回错误。
释放锁的安全性:释放锁时必须确保只有持有者能够释放(通常通过锁值/令牌校验),避免误释放。
时钟漂移与分布式一致性:分布式系统中时钟不一致会影响判断,Redlock 等算法有相关考虑;若使用单实例 Redis 要意识到其局限性。
幂等与补偿:即使锁出现失效或重入,业务应尽量保持幂等或提供补偿机制。
方案一:使用 DistributedLock(开源 .NET 库)——极简上手
DistributedLock 是常用的 .NET 库,封装了多种后端(SQL Server、Redis 等),提供 AcquireAsync() 风格的 API,使用起来像本地 lock。示例:
// 安装 NuGet: DistributedLock (或 DistributedLock.SqlServer / DistributedLock.Redis)
using DistributedLock; // depending on package
// 假设使用 SQL Server 后端
var connectionString = "Server=...;Database=...;User Id=...;Password=...;";
await using var provider = new SqlDistributedSynchronizationProvider(connectionString);
var resource = "my-app:process-order:order-123";
var timeout = TimeSpan.FromSeconds(10);
await using (var handle = await provider.AcquireAsync(resource, timeout))
{
if (!handle.IsAcquired)
{
// 获取失败:可重试或返回
return;
}
// 在此处安全地处理共享资源
ProcessOrder("order-123");
}
优点:API 简洁,支持 reader-writer 等多种锁类型;对常见后端提供封装,减少自己实现细节的风险。
方案二:Redis + Redlock(高性能选项)
核心思想:在多个独立 Redis 实例上以相同 key 设置带 TTL 的锁,只有在多数实例上成功设置时视为加锁成功,从而降低单点故障影响。使用成熟实现(如 RedLock.net)能正确处理续租、时钟漂移与冲突。示例(使用 RedLock.net):
// 安装 NuGet: RedLock.net
using RedLockNet;
using RedLockNet.SERedis;
using StackExchange.Redis;
// 创建 Redis 连接和 RedLockFactory
var multiplexers = new[] {
ConnectionMultiplexer.Connect("redis1:6379"),
ConnectionMultiplexer.Connect("redis2:6379"),
ConnectionMultiplexer.Connect("redis3:6379")
};
var redlockFactory = RedLockFactory.Create(multiplexers.Select(m => m.GetDatabase()).ToList());
string resource = "locks:process-order:order-123";
TimeSpan expiry = TimeSpan.FromSeconds(30);
using (var redLock = redlockFactory.CreateLock(resource, expiry))
{
if (redLock.IsAcquired)
{
// 拿到锁,执行操作
ProcessOrder("order-123");
}
else
{
// 锁获取失败,处理失败逻辑
}
}
注意:Redlock 的实现与使用有一定争议(需要理解设计假设),但对于多数需要高吞吐、低延迟锁的场景是常见选择。
方案三:利用 SQL Server 的应用级锁(简单可靠)
如果你已经依赖 SQL Server,使用 sp_getapplock / sp_releaseapplock 是非常实用且可靠的方案,优点是事务边界内容易控制,不需要额外组件。示例(使用 Dapper 调用):
using (var conn = new SqlConnection(connString))
{
await conn.OpenAsync();
// 获取应用级锁(分布式互斥)
var result = await conn.ExecuteScalarAsync<int>(
"EXEC @returnCode = sp_getapplock @Resource = @resource, @LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = @timeout",
new { resource = "process-order:order-123", timeout = 10000, returnCode = 0 });
// returnCode >= 0 表示获取成功
if (result >= 0)
{
try
{
ProcessOrder("order-123");
}
finally
{
await conn.ExecuteAsync("EXEC sp_releaseapplock @Resource = @resource, @LockOwner = 'Session'", new { resource = "process-order:order-123" });
}
}
else
{
// 获取失败
}
}
优点:事务级别可靠、实现简单;缺点:性能可能不如 Redis,且依赖数据库可用性。
实现细节与示例策略(超时、续租与唯一标识)
1. 使用唯一锁值(token)
在写入锁 key 时写入随机 token(GUID),释放锁时只允许持有该 token 的客户端删除 key,避免误释放(常见 Lua 脚本校验与删除)。
-- Lua: 释放锁的原子操作
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
2. 续租机制
如果业务处理时间可能超过租约,设计续租机制(在持有锁时定期延长 TTL),并在续租失败时安全中止或降级处理。
3. 等待与重试策略
避免无限阻塞:把获取锁的等待时间和重试间隔参数化,使用指数回退或抖动避免羊群效应。
4. 幂等设计
确保被保护的业务操作是幂等的(或能被补偿),以应对锁意外失效或重复执行的情况。
常见坑与误区
- 单实例 Redis + 简单 SET NX 可能导致不可恢复的竞争:单实例失效会导致不可用或错判。考虑使用 Redlock 或多副本策略。
- 把分布式锁当成事务替代:锁解决并发,不等于事务的原子性、隔离性;复杂业务仍需数据库事务保证一致性。
- 忽视释放安全:不校验 token 就删除 key,会导致非持有者误释放。
- 租约设置不合理:租约太短会导致持有者在处理未完成时锁被释放;太长则降低容错能力。
如何选择适合你的方案
- 需要超高性能、短租约场景(缓存、计数器):优先考虑 Redis + Redlock(但理解其假设)。
- 已有稳定数据库、希望简单可靠:使用数据库内建锁(sp_getapplock、advisory locks)。
- 想快速接入且不想自行实现细节:使用成熟库(DistributedLock、RedLock.net)可显著降低实现错误的概率。
结论与行动建议
- 先评估场景需求:并发量、延迟、依赖组件(是否已有 Redis/数据库)、容错目标。
- 优先使用成熟库:避免自行重写分布式锁算法带来的坑。
- 做好超时、续租与释放安全:锁值校验、续租失败处理与幂等化是关键。
- 测试故障场景:模拟网络分区、服务重启与时钟漂移,验证锁在异常情况下的表现。