Redis 学习笔记(三):分布式锁

写在前面

本文是 Redis 学习笔记系列的第三篇,深入分布式锁的实现原理、常见问题和 .NET 实战封装。前置知识:缓存实战(第二篇)。


一、为什么需要分布式锁

1.1 单机锁的问题

1
2
3
4
5
6
7
单机环境:
  lock (obj) { ... }     — Monitor
  SemaphoreSlim          — 信号量
  Mutex                  — 互斥体

这些锁只在当前进程内有效。
部署多个实例后,各进程的锁互不影响,无法保证互斥。

1.2 分布式场景

1
2
3
4
5
6
7
8
场景:多个订单服务实例,防止同一笔订单被重复处理。

实例 A:处理订单 12345
实例 B:也收到订单 12345 的请求

如果用单机锁,A 和 B 各自加锁成功 → 重复处理

需要一把跨进程、跨机器的锁 → 分布式锁

1.3 分布式锁的要求

1
2
3
4
5
1. 互斥性    — 任意时刻只有一个客户端持有锁
2. 可重入    — 同一线程/进程可以重复获取同一把锁
3. 不会死锁  — 持有锁的客户端宕机后锁能自动释放
4. 高性能    — 加锁/解锁要快
5. 高可用    — 锁服务不能成为单点故障

二、Redis 实现分布式锁

2.1 基本原理

1
2
3
4
5
6
7
加锁:SET lock_key unique_value NX EX 30
  NX  — key 不存在才设置(保证互斥)
  EX  — 过期时间(防止死锁)
  unique_value — 客户端唯一标识(防止误删别人的锁)

解锁:Lua 脚本(原子操作)
  先判断 value 是否匹配 → 匹配才删除

2.2 为什么解锁要用 Lua 脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
不用 Lua 脚本的问题:

1. 客户端 A 执行 GET lock_key → 返回 "A"
2. 客户端 A 判断 value == "A" → 是我的锁
3. 【此时锁恰好过期,Redis 自动删除】
4. 客户端 B 执行 SET lock_key "B" NX → 成功
5. 客户端 A 执行 DEL lock_key → 删了 B 的锁!

Lua 脚本保证"判断 + 删除"是原子操作:

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

2.3 基础实现

1
2
3
4
5
# 加锁
SET lock:order:12345 "client-uuid-abc" NX EX 30

# 解锁(Lua 脚本)
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 lock:order:12345 "client-uuid-abc"

三、锁续期

3.1 问题:业务没处理完锁就过期了

1
2
3
4
5
6
7
设置锁过期时间 30 秒,但业务处理需要 40 秒:

1. 客户端 A 加锁(30 秒)
2. A 开始处理业务(预计 40 秒)
3. 30 秒后锁自动过期
4. 客户端 B 加锁成功
5. A 处理完业务,释放了 B 的锁 → 严重问题!

3.2 解决:看门狗机制

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
看门狗(Watchdog):后台线程定期延长锁的过期时间。

工作方式:
  - 加锁成功后,启动看门狗线程
  - 每隔 lock_time / 3 检查一次
  - 如果锁还持有(value 匹配),重置过期时间
  - 如果业务处理完成,看门狗停止续期

示例:
  锁过期时间 30 秒
  看门狗每 10 秒续期一次
  业务处理 40 秒:
    0s    加锁(30s 过期)
    10s   续期(30s 过期)
    20s   续期(30s 过期)
    30s   续期(30s 过期)
    40s   业务完成,主动释放锁,看门狗停止

四、可重入锁

4.1 问题:同一线程多次加锁

1
2
3
方法 A 加锁 → 调用方法 B → 方法 B 也加同一把锁

如果锁不可重入,方法 B 等待方法 A 释放 → 死锁

4.2 实现:Hash + 计数器

1
2
3
4
5
6
7
8
9
# 加锁(可重入)
# key = lock name, field = client_id, value = 重入次数
HSET lock:order:12345 "client-uuid-abc" 1
# 重入时计数 +1
HINCRBY lock:order:12345 "client-uuid-abc" 1

# 解锁时计数 -1
HINCRBY lock:order:12345 "client-uuid-abc" -1
# 计数归零时删除 key

五、.NET 实战封装

5.1 基础分布式锁

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
public interface IDistributedLock : IDisposable
{
    Task<bool> TryAcquireAsync(TimeSpan timeout);
    Task ReleaseAsync();
}

public class RedisDistributedLock : IDistributedLock
{
    private readonly IDatabase _db;
    private readonly string _lockKey;
    private readonly string _lockValue;
    private readonly TimeSpan _expiry;
    private bool _disposed;

    // Lua 解锁脚本(只删自己的锁)
    private static readonly LuaScript UnlockScript = LuaScript.Prepare(
        @"if redis.call('get', @key) == @value then
            return redis.call('del', @key)
          else
            return 0
          end");

    public RedisDistributedLock(
        IDatabase db, string lockKey, TimeSpan expiry)
    {
        _db = db;
        _lockKey = lockKey;
        _lockValue = Guid.NewGuid().ToString();  // 唯一标识
        _expiry = expiry;
    }

    public async Task<bool> TryAcquireAsync(TimeSpan timeout)
    {
        var deadline = DateTime.UtcNow + timeout;
        var retryDelay = TimeSpan.FromMilliseconds(50);

        while (DateTime.UtcNow < deadline)
        {
            var acquired = await _db.StringSetAsync(
                _lockKey, _lockValue, _expiry, When.NotExists);

            if (acquired)
                return true;

            await Task.Delay(retryDelay);
        }

        return false;  // 超时未获取到锁
    }

    public async Task ReleaseAsync()
    {
        await _db.ScriptEvaluateAsync(UnlockScript,
            new { key = (RedisKey)_lockKey, value = _lockValue });
    }

    public void Dispose()
    {
        if (!_disposed)
        {
            ReleaseAsync().GetAwaiter().GetResult();
            _disposed = true;
        }
    }
}

5.2 使用方式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class OrderService
{
    private readonly IConnectionMultiplexer _redis;

    public OrderService(IConnectionMultiplexer redis) => _redis = redis;

    public async Task ProcessOrderAsync(string orderId)
    {
        var db = _redis.GetDatabase();
        var lockKey = $"lock:order:{orderId}";

        await using var @lock = new RedisDistributedLock(
            db, lockKey, TimeSpan.FromSeconds(30));

        // 尝试获取锁,最多等 5 秒
        var acquired = await @lock.TryAcquireAsync(TimeSpan.FromSeconds(5));

        if (!acquired)
        {
            throw new InvalidOperationException($"获取订单 {orderId} 的锁失败");
        }

        // 获取锁成功,处理业务
        await DoProcessOrder(orderId);

        // 离开 using 块时自动释放锁
    }
}

5.3 带看门狗的分布式锁

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
public class RedisDistributedLockWithWatchdog : IDistributedLock
{
    private readonly IDatabase _db;
    private readonly string _lockKey;
    private readonly string _lockValue;
    private readonly TimeSpan _expiry;
    private readonly TimeSpan _renewInterval;
    private CancellationTokenSource? _cts;
    private bool _disposed;

    private static readonly LuaScript UnlockScript = LuaScript.Prepare(
        @"if redis.call('get', @key) == @value then
            return redis.call('del', @key)
          else
            return 0
          end");

    private static readonly LuaScript RenewScript = LuaScript.Prepare(
        @"if redis.call('get', @key) == @value then
            return redis.call('expire', @key, @ttl)
          else
            return 0
          end");

    public RedisDistributedLockWithWatchdog(
        IDatabase db, string lockKey, TimeSpan expiry)
    {
        _db = db;
        _lockKey = lockKey;
        _lockValue = Guid.NewGuid().ToString();
        _expiry = expiry;
        _renewInterval = TimeSpan.FromTicks(expiry.Ticks / 3);
    }

    public async Task<bool> TryAcquireAsync(TimeSpan timeout)
    {
        var deadline = DateTime.UtcNow + timeout;
        var retryDelay = TimeSpan.FromMilliseconds(50);

        while (DateTime.UtcNow < deadline)
        {
            var acquired = await _db.StringSetAsync(
                _lockKey, _lockValue, _expiry, When.NotExists);

            if (acquired)
            {
                StartWatchdog();
                return true;
            }

            await Task.Delay(retryDelay);
        }

        return false;
    }

    private void StartWatchdog()
    {
        _cts = new CancellationTokenSource();
        _ = Task.Run(async () =>
        {
            while (!_cts.Token.IsCancellationRequested)
            {
                await Task.Delay(_renewInterval, _cts.Token);

                try
                {
                    await _db.ScriptEvaluateAsync(RenewScript,
                        new
                        {
                            key = (RedisKey)_lockKey,
                            value = _lockValue,
                            ttl = (int)_expiry.TotalSeconds
                        });
                }
                catch (OperationCanceledException) { break; }
            }
        }, _cts.Token);
    }

    public async Task ReleaseAsync()
    {
        _cts?.Cancel();

        await _db.ScriptEvaluateAsync(UnlockScript,
            new { key = (RedisKey)_lockKey, value = _lockValue });
    }

    public void Dispose()
    {
        if (!_disposed)
        {
            ReleaseAsync().GetAwaiter().GetResult();
            _cts?.Dispose();
            _disposed = true;
        }
    }
}

六、RedLock 算法

6.1 单节点锁的问题

1
2
3
4
5
6
7
Redis 主从复制场景:
  1. 客户端 A 在 Master 加锁成功
  2. Master 还没同步到 Slave 就宕机了
  3. Slave 被提升为 Master(锁数据丢失)
  4. 客户端 B 加锁成功 → 两把锁同时存在!

RedLock:在多个独立的 Redis 实例上加锁,多数成功才算成功。

6.2 RedLock 流程

1
2
3
4
5
6
7
假设 5 个独立的 Redis 节点:

1. 记录开始时间
2. 依次向 5 个节点加锁(SET NX EX,很快)
3. 计算加锁耗时
4. 如果在 N/2+1(即 3)个节点加锁成功,且耗时 < 锁有效期 → 加锁成功
5. 失败则向所有节点释放锁

6.3 .NET 使用 RedLock.net

1
2
dotnet add package RedLock.net
dotnet add package RedLock.net.SERedis
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 注册 RedLockFactory
builder.Services.AddSingleton<RedLockFactory>(sp =>
{
    var connection = sp.GetRequiredService<IConnectionMultiplexer>();
    return RedLockFactory.Create(connection);
});

// 使用
public class OrderService
{
    private readonly RedLockFactory _lockFactory;

    public OrderService(RedLockFactory lockFactory) => _lockFactory = lockFactory;

    public async Task ProcessOrderAsync(string orderId)
    {
        var resource = $"lock:order:{orderId}";
        var expiry = TimeSpan.FromSeconds(30);
        var wait = TimeSpan.FromSeconds(5);
        var retry = TimeSpan.FromMilliseconds(200);

        await using var redLock = await _lockFactory.CreateLockAsync(
            resource, expiry, wait, retry);

        if (redLock.IsAcquired)
        {
            await DoProcessOrder(orderId);
        }
        else
        {
            throw new InvalidOperationException("获取锁失败");
        }
    }
}

七、分布式锁注意事项

7.1 锁过期时间设置

1
2
3
4
5
6
7
太短:业务没处理完锁就过期了 → 失去互斥保护
太长:客户端宕机后锁很久才释放 → 其他客户端等待太久

建议:
  - 根据业务最大执行时间设置(通常是秒级)
  - 加上看门狗续期机制
  - 一般设置 10-30 秒

7.2 锁粒度

1
2
3
4
5
6
7
粒度太粗:锁整个业务 → 并发度低
粒度太细:锁太多 key → 管理复杂、性能开销大

建议:锁到具体业务实体
  lock:order:12345          — 锁一个订单
  lock:inventory:SKU001     — 锁一个 SKU 的库存
  lock:user:phone:13800138000 — 锁一个手机号的发送频率

7.3 锁的释放

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
一定要在 finally 中释放锁:

try
{
    var acquired = await @lock.TryAcquireAsync(timeout);
    if (acquired)
    {
        await DoWork();
    }
}
finally
{
    await @lock.ReleaseAsync();  // 无论如何都释放
}

或者用 using/await using 自动释放

八、小结

本文学习了分布式锁:

  • 为什么需要分布式锁
  • Redis 分布式锁原理(SET NX EX + Lua 解锁)
  • 锁续期(看门狗机制)
  • 可重入锁
  • .NET 实战封装(基础锁、带看门狗的锁)
  • RedLock 算法和 RedLock.net
  • 锁的注意事项(过期时间、粒度、释放)

下一篇将学习持久化、高可用和运维。