分布式锁解决的核心问题是:当多个服务实例或进程同时运行并访问同一份资源时,如何保证同一时刻只有一个执行单元能进入临界区。单机环境中常用的 lock、Monitor 都依赖当前进程内的线程同步原语,一旦应用部署到多台服务器或容器中,这些机制就完全失效。C# 开发者需要借助独立于应用进程的外部存储来协调锁状态,其中 Redis 和关系型数据库是两种最主流的实现载体。

一、分布式锁的基本要求
无论采用哪种存储实现,一个合格的分布式锁都应该满足几个硬性条件。第一是互斥性,即任意时刻只有一个客户端能持有锁。第二是避免死锁,如果持有锁的进程崩溃,锁必须能自动过期或通过其他方式释放,不能永久阻塞其他请求。第三是锁标识,每个客户端在加锁时应写入唯一令牌,释放时校验令牌,防止把别的客户端持有的锁误删。第四是容错性,存储节点不能成为单点故障,否则锁服务不可用会拖垮业务。
除了这些基础要求,实际生产环境还会关注可重入、自动续期、高可用等问题。可重入锁允许同一个客户端在已经持有锁的情况下再次获取同一把锁,但这在分布式环境中需要额外记录持有者信息和重入次数。自动续期则用于处理业务执行时间超过锁过期时间的场景,避免锁提前失效导致并发进入。C# 中并没有内置的分布式锁原语,通常需要组合 Redis 命令或数据库事务来实现上述能力。
二、基于Redis的C#分布式锁实现
Redis 之所以适合做分布式锁,是因为它的单线程命令执行模型能够保证单个命令的原子性。早期方案使用 SETNX 设置锁,再通过 EXPIRE 单独设置过期时间,但这两个操作不是原子的,如果在两步之间进程崩溃,就会留下一个没有过期时间的锁。从 Redis 2.6.12 开始,官方推荐使用 SET key value NX PX milliseconds 一条命令完成加锁和设置过期时间。C# 可以通过 StackExchange.Redis 的 StringSet 方法配合 When.NotExists 参数实现。
下面的代码封装了一个简单的 Redis 锁,加锁时写入一个唯一的 token,过期时间由业务指定。
using StackExchange.Redis;
public class RedisLock
{
private readonly ConnectionMultiplexer _redis;
private readonly IDatabase _db;
public RedisLock(string connectionString)
{
_redis = ConnectionMultiplexer.Connect(connectionString);
_db = _redis.GetDatabase();
}
public bool TryAcquire(string key, string token, TimeSpan expiry)
{
return _db.StringSet(key, token, expiry, When.NotExists);
}
public void Release(string key, string token)
{
const string script = @"
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end";
_db.ScriptEvaluate(script, new RedisKey[] { key }, new RedisValue[] { token });
}
}
释放锁时不能直接执行 DEL,否则某个客户端业务超时后锁已经过期,它再删除可能会把其他客户端刚获取的锁删掉。正确做法是使用 Lua 脚本,先读取键的值并与自己的 token 比较,一致才删除。上面的 ScriptEvaluate 就是通过 Redis 的 Lua 脚本保证比较和删除的原子性。
在实际使用中,如果业务逻辑执行时间不确定,还需要考虑锁续期。可以启动一个后台定时器,在锁过期时间的前三分之一周期内刷新过期时间,但刷新时同样要校验 token 是否仍属于自己。续期失败通常意味着锁已经丢失,此时应该中断业务或进行回滚。另一个常见问题是 Redis 主从切换导致锁丢失:如果锁写到主节点,主节点宕机后还没同步到从节点,从节点升级后可能会让新的客户端获得同一把锁。对于一致性要求极高的场景,需要引入 RedLock 或使用带持久化策略的 Redis 集群。
三、基于关系数据库的C#分布式锁实现
数据库实现分布式锁的核心思想是利用数据库自身的事务和约束机制。比较简单的做法是创建一张锁表,锁名称作为主键或唯一键。加锁时向表中插入一条记录,只有插入成功的客户端才获得锁;释放锁时删除该记录。由于主键唯一约束能够阻止重复插入,因此保证互斥性。这种方案的优势是几乎不需要额外组件,已有 MySQL 或 SQL Server 的系统可以直接落地。
CREATE TABLE distributed_lock (
lock_name VARCHAR(128) PRIMARY KEY,
owner_token VARCHAR(64) NOT NULL,
expire_time DATETIME NOT NULL
);
加锁逻辑可以先用 INSERT 尝试插入,如果唯一约束冲突则说明锁已被占用。但是插入的锁记录如果没有过期时间,进程崩溃后记录会一直存在,造成死锁。解决方式是在表中增加 expire_time 字段,释放锁时先删除已过期的记录。更稳妥的办法是为锁记录增加定时清理任务,或者在代码中插入前先尝试删除过期数据。不过数据库锁的性能通常低于 Redis,因为每次加锁和释放都会产生磁盘 I/O 和事务日志。
SQL Server 还提供了更轻量的应用程序锁 sp_getapplock,它把锁状态维护在数据库会话或事务级别,不需要创建物理表。C# 中可以通过 SqlCommand 调用该存储过程。
using System.Data.SqlClient;
public class SqlServerLock
{
public static bool TryAcquire(string connectionString, string resource)
{
using var conn = new SqlConnection(connectionString);
conn.Open();
using var cmd = new SqlCommand("sp_getapplock", conn)
{
CommandType = System.Data.CommandType.StoredProcedure
};
cmd.Parameters.AddWithValue("@Resource", resource);
cmd.Parameters.AddWithValue("@LockMode", "Exclusive");
cmd.Parameters.AddWithValue("@LockOwner", "Session");
cmd.Parameters.AddWithValue("@LockTimeout", 5000);
var result = cmd.ExecuteScalar();
return Convert.ToInt32(result) == 0;
}
}
上面的代码以会话为锁持有者,调用 sp_getapplock 后如果返回 0 表示成功获得锁。释放锁时可调用 sp_releaseapplock,参数与加锁时保持一致。使用数据库锁的优点是与业务事务容易结合,例如在同一会话中既获取资源锁又执行数据更新,可以保证资源访问和数据操作的一致性。缺点是锁的超时粒度受数据库参数控制,难以像 Redis 一样做到毫秒级精细控制,并且高并发下数据库连接数容易成为瓶颈。
四、Redis与数据库方案对比和选型建议
从性能角度看,Redis 基于内存操作,单次加锁延迟通常在亚毫秒级,适合高并发、低延迟的互联网业务。数据库方案需要经过 SQL 解析、事务日志和磁盘写入,单次操作耗时通常在几毫秒到几十毫秒,适合并发量不高但要求强一致或已有数据库资产的企业应用。下面的表格列出了几个关键维度的对比。
| 对比维度 | Redis 分布式锁 | 数据库分布式锁 |
|---|---|---|
| 性能与延迟 | 高,内存级读写 | 较低,受磁盘和事务影响 |
| 一致性 | 主从切换可能丢锁 | 依赖事务,通常更强 |
| 实现复杂度 | 中等,需处理续期和误删 | 低,表或存储过程即可 |
| 运维成本 | 需要维护 Redis 集群 | 复用现有数据库 |
| 适用场景 | 高并发、超时敏感 | 并发可控、强一致优先 |
如果项目已经部署 Redis 并且没有严格的主从一致性要求,优先选择 Redis 锁,配合唯一令牌和 Lua 释放脚本能覆盖大部分业务场景。如果业务操作本身与数据库事务强相关,例如订单状态流转、库存扣减,使用数据库锁或直接依赖数据库行锁往往更自然,可以减少分布式协调的复杂度。
五、C#实现分布式锁的常见坑与优化建议
第一个坑是锁误删。无论是 Redis 还是数据库,释放锁时必须校验持有者身份。Redis 方案中应使用 Lua 脚本比较 token 后再删除;数据库方案中应按 owner_token 删除记录,而不是按锁名称无条件删除。
第二个坑是锁过期与业务超时。如果锁的过期时间设置得过短,而业务执行被 GC、网络抖动或慢查询拖住,锁可能在业务完成前失效,导致其他实例进入临界区。解决方案是将过期时间设置得尽量宽松,并为长时间任务实现续期线程。Redis 的 Redisson 等客户端已经提供了看门狗机制,C# 生态中虽然缺少完全等价的成熟组件,但可以通过定时刷新 token 自行实现。
第三个坑是错误使用锁粒度。锁的粒度应该尽量小,只锁定真正需要互斥的资源,例如对某个订单号加锁而不是对整个订单表加锁。锁粒度越小,并发吞吐越高,阻塞时间越短。C# 中可以将资源 ID 拼接为锁键,比如 lock:order:123456,避免所有请求争抢同一个全局锁。
最后还要关注分布式锁的监控和告警。统计锁的等待时间、持有时间、失败次数等指标,可以帮助发现锁竞争热点和异常长时间的临界区。如果频繁出现锁等待超时,应该考虑拆分粒度、优化业务逻辑或调整锁方案。