一、为什么需要分布式锁

想象一下这样的场景:你的电商系统正在搞秒杀活动,同一件商品在同一时刻可能有成百上千的用户点击购买。如果不加控制,很可能会导致超卖问题——库存显示还有10件,结果卖出去20件。这就是典型的并发问题,而分布式锁就是解决这类问题的利器。

在单机环境下,我们可以用简单的lock关键字或者Mutex来解决。但在分布式系统中,不同的请求可能被分发到不同的服务器上,这时候就需要一个所有服务器都能访问的中间件来协调锁的状态。

二、基于Redis的实现方案

Redis是最常用的分布式锁实现方案之一,主要因为它性能高、使用简单。我们来看一个完整的实现示例:

// 技术栈:.NET Core + StackExchange.Redis

public class RedisDistributedLock
{
    private readonly IDatabase _redisDb;
    
    public RedisDistributedLock(IConnectionMultiplexer connection)
    {
        _redisDb = connection.GetDatabase();
    }
    
    public async Task<bool> AcquireLockAsync(string lockKey, string lockValue, TimeSpan expiry)
    {
        // 使用SETNX命令尝试获取锁
        return await _redisDb.StringSetAsync(
            lockKey, 
            lockValue, 
            expiry, 
            When.NotExists, 
            CommandFlags.None);
    }
    
    public async Task ReleaseLockAsync(string lockKey, string lockValue)
    {
        // 使用Lua脚本确保只有锁的持有者才能释放锁
        var script = @"if redis.call('get', KEYS[1]) == ARGV[1] then 
                        return redis.call('del', KEYS[1]) 
                      else 
                        return 0 
                      end";
        
        await _redisDb.ScriptEvaluateAsync(
            script, 
            new RedisKey[] { lockKey }, 
            new RedisValue[] { lockValue });
    }
}

这个实现有几个关键点需要注意:

  1. 使用SETNX命令保证原子性操作
  2. 给锁设置过期时间,防止死锁
  3. 释放锁时使用Lua脚本保证操作的原子性
  4. 每个锁都有唯一标识,防止误删其他客户端的锁

优点:

  • 性能极高,Redis本身单机可以支持10万+的QPS
  • 实现相对简单
  • 社区支持好,资料丰富

缺点:

  • Redis主从切换时可能导致锁丢失(虽然概率低)
  • 需要处理锁续期问题(长时间任务)

三、基于数据库的实现方案

如果你的系统已经使用了关系型数据库,又不想引入额外的中间件,可以考虑基于数据库的实现。下面是使用SQL Server的实现:

// 技术栈:.NET Core + Dapper

public class SqlServerDistributedLock
{
    private readonly string _connectionString;
    
    public SqlServerDistributedLock(string connectionString)
    {
        _connectionString = connectionString;
    }
    
    public async Task<bool> AcquireLockAsync(string lockName, int timeoutSeconds)
    {
        using var connection = new SqlConnection(_connectionString);
        
        // 使用存储过程实现锁获取
        var parameters = new DynamicParameters();
        parameters.Add("@Resource", lockName);
        parameters.Add("@LockTimeout", timeoutSeconds);
        parameters.Add("@Result", dbType: DbType.Int32, direction: ParameterDirection.Output);
        
        await connection.ExecuteAsync(
            "sp_getapplock", 
            parameters, 
            commandType: CommandType.StoredProcedure);
        
        return parameters.Get<int>("@Result") >= 0;
    }
    
    public async Task ReleaseLockAsync(string lockName)
    {
        using var connection = new SqlConnection(_connectionString);
        
        var parameters = new DynamicParameters();
        parameters.Add("@Resource", lockName);
        parameters.Add("@Result", dbType: DbType.Int32, direction: ParameterDirection.Output);
        
        await connection.ExecuteAsync(
            "sp_releaseapplock", 
            parameters, 
            commandType: CommandType.StoredProcedure);
    }
}

这里利用了SQL Server内置的应用程序锁机制,关键点包括:

  1. 使用系统存储过程sp_getapplock和sp_releaseapplock
  2. 支持锁超时设置
  3. 数据库事务会自动释放关联的锁

优点:

  • 无需额外基础设施
  • 与数据库事务天然集成
  • 可靠性高

缺点:

  • 性能较差,不适合高并发场景
  • 数据库连接池可能成为瓶颈
  • 不同数据库实现差异大

四、基于ZooKeeper的实现方案

对于需要高可靠性的场景,ZooKeeper是个不错的选择。虽然使用起来比Redis复杂,但提供了更强的保证:

// 技术栈:.NET Core + ZooKeeperNetEx

public class ZooKeeperDistributedLock
{
    private readonly ZooKeeper _zk;
    private string _lockPath;
    
    public ZooKeeperDistributedLock(string connectionString)
    {
        _zk = new ZooKeeper(
            connectionString, 
            TimeSpan.FromSeconds(10), 
            new NullWatcher());
    }
    
    public async Task<bool> AcquireLockAsync(string lockName, TimeSpan timeout)
    {
        var lockNode = $"/locks/{lockName}";
        var ourPath = await _zk.CreateAsync(
            $"{lockNode}/lock-", 
            Array.Empty<byte>(),
            ZooKeeperNetEx.Ids.OPEN_ACL_UNSAFE,
            CreateMode.EphemeralSequential);
        
        // 获取所有子节点并排序
        var children = await _zk.GetChildrenAsync(lockNode, false);
        var sortedChildren = children.OrderBy(c => c).ToList();
        
        // 检查我们是否是最小的节点
        var ourIndex = sortedChildren.IndexOf(ourPath.Split('/').Last());
        if (ourIndex == 0)
        {
            _lockPath = ourPath;
            return true;
        }
        
        // 如果不是,监听前一个节点
        var watch = new ManualResetEvent(false);
        var watcher = new NodeDeletedWatcher(watch);
        await _zk.ExistsAsync($"{lockNode}/{sortedChildren[ourIndex - 1]}", watcher);
        
        // 等待超时或前一个节点被删除
        return watch.WaitOne(timeout);
    }
    
    public async Task ReleaseLockAsync()
    {
        if (_lockPath != null)
        {
            await _zk.DeleteAsync(_lockPath, -1);
            _lockPath = null;
        }
    }
}

ZooKeeper实现的关键特性:

  1. 使用临时顺序节点实现锁
  2. 通过监听机制实现锁等待
  3. 会话断开时自动清理临时节点

优点:

  • 可靠性最高,不会出现Redis那样的锁丢失问题
  • 提供公平锁实现
  • 原生支持锁重入

缺点:

  • 部署和维护成本高
  • 性能比Redis差
  • 使用复杂度高

五、方案对比与选型建议

现在我们来横向对比这三种方案:

  1. 性能方面:
  • Redis > ZooKeeper > 数据库
  • 如果QPS超过1万,基本只能选Redis
  1. 可靠性方面:
  • ZooKeeper > 数据库 > Redis
  • 金融级场景建议ZooKeeper
  1. 实现复杂度:
  • Redis < 数据库 < ZooKeeper
  • 快速开发首选Redis
  1. 适用场景:
  • Redis:秒杀、缓存击穿防护
  • 数据库:低频但需要强一致性的业务
  • ZooKeeper:分布式协调、配置中心

六、实际应用中的注意事项

无论选择哪种方案,都需要注意以下几点:

  1. 锁的过期时间要设置合理:
  • 太短会导致任务未完成锁就释放
  • 太长会导致死锁时恢复慢
  1. 一定要实现锁的续期机制:
// Redis锁续期示例
public async Task RenewLockAsync(string lockKey, string lockValue, TimeSpan expiry)
{
    if (await _redisDb.StringGetAsync(lockKey) == lockValue)
    {
        await _redisDb.KeyExpireAsync(lockKey, expiry);
    }
}
  1. 考虑锁的可重入性:
  • 同一个线程多次获取同一个锁应该成功
  • 可以通过ThreadLocal存储锁计数实现
  1. 监控和报警:
  • 记录锁等待时间
  • 锁获取失败率超过阈值要报警

七、总结

分布式锁是分布式系统中的重要基础设施,没有放之四海而皆准的完美方案。Redis适合大多数高性能场景,数据库方案适合低频强一致性需求,ZooKeeper则适用于对可靠性要求极高的场景。

在实际应用中,除了考虑性能、可靠性外,还要结合团队的技术栈和运维能力。有时候,简单的方案反而是最好的选择。希望这篇文章能帮助你在项目中做出合理的技术选型。