假设订单服务部署了三个实例,每个实例每分钟都会扫描并关闭超时订单。如果只在进程内使用 lockMonitorSemaphoreSlim,它们最多阻止同一个进程中的线程并发,无法让另外两个实例看到这把锁。三个实例仍可能同时选中同一批订单,并重复发送事件、释放库存或执行退款。

分布式锁把协调状态放到所有实例都能访问的系统中,但这只是起点。真正困难的不是“把锁写进 Redis”,而是当进程暂停、网络分区、节点故障或租约过期时,怎样判断谁仍然有权修改业务资源。

🎯 本地锁为什么不够

单进程中的锁依赖一块共享内存。进入临界区前,线程检查同一个锁对象;进程退出后,操作系统会回收这块状态。

多实例部署没有这块天然共享内存:

调度器
  ├─ 实例 A ── 本地锁 A ── 扫描超时订单
  ├─ 实例 B ── 本地锁 B ── 扫描超时订单
  └─ 实例 C ── 本地锁 C ── 扫描超时订单

三把本地锁互不认识,因此每个实例都能成功进入自己的临界区。要跨进程协调,必须引入数据库、Redis、ZooKeeper、etcd 或具备类似一致性能力的共享系统。

但在引入新组件之前,应先检查业务结果能否自然收敛。例如关闭订单可以使用带旧状态条件的更新:

UPDATE orders
SET status = 'closed',
    closed_at = @closedAt
WHERE order_id = @orderId
  AND status = 'pending';

只有一条更新能把状态从 pending 改成 closed,其他实例得到零行受影响。这个数据库条件更新往往比“先获取分布式锁,再无条件更新”更接近真正需要保护的业务事实。

🧭 分布式锁真正要解决什么

讨论实现之前,需要先明确希望得到哪些性质:

性质需要回答的问题
互斥正常情况下,同一资源是否只允许一个持有者进入临界区?
安全释放一个实例能否只释放自己仍然持有的锁?
活性持有者崩溃后,其他实例最终能否继续工作?
容错部分节点或网络故障时,系统会阻塞、降级还是产生多个持有者?

这里的“正常情况下”不是逃避正确性,而是要求把系统假设写清楚。单个 Redis 主节点、带异步副本的 Redis 故障切换、多个独立 Redis 节点,以及基于共识协议的协调服务,能够提供的保证并不相同。

锁所有权与租约

锁所有权表示“当前实例被协调系统认定为持有者”。为了避免持有者崩溃后锁永久残留,工程实现通常会给锁设置有效期。这种有时间上限的所有权称为租约

租约同时带来活性和新的风险:协调系统在租约到期后可以把锁交给别人,但它无法暂停或终止旧进程。锁已经过期与旧持有者已经停止,是两件不同的事。

正确性锁与效率锁

还应区分锁失败后造成的后果:

  • 效率锁用于减少重复计算、缓存回源或重复扫描。偶尔有两个实例同时执行,通常只是多消耗资源。
  • 正确性锁保护扣款、库存、文件写入或不可重复的外部操作。两个实例同时执行可能造成数据错误或资金损失。

效率锁可以接受更弱的故障保证;正确性锁则不能只依赖“多数时候只有一个持有者”,还需要资源侧的条件更新、唯一约束、幂等或 Fencing Token。

🧱 从错误实现逐步演进

错误一:先 SETNX,再设置过期时间

最直观的 Redis 实现是先创建键,再设置 TTL:

SETNX lock:order-timeout instance-a
PEXPIRE lock:order-timeout 30000

两个命令之间存在崩溃窗口。如果 SETNX 成功后进程退出,PEXPIRE 没有执行,这把锁就可能永久存在,其他实例也无法继续处理任务。

获取与设置过期时间必须是一个原子命令:

SET lock:order-timeout 7F4D...A921 NX PX 30000
  • NX 表示键不存在时才写入。
  • PX 30000 表示租约为 30000 毫秒。
  • 键的值是本次获取生成的唯一 token,而不是固定的实例名。

实例名无法区分同一个实例的两次锁获取。唯一 token 则把锁所有权绑定到“一次获取行为”,即使租约过期后同一个实例又获得了新锁,两次所有权也不会混淆。

错误二:释放时直接删除键

下面的顺序会让实例 A 删除实例 B 的锁:

实例 A 获取锁,随后执行超时
  ├─ A 的租约过期
实例 B 获取同一个键
  └─ A 执行 DEL,误删 B 的锁

因此释放锁必须先比较 token,并且“比较与删除”必须原子执行。Redis 8.4 以前通常使用 Lua 脚本;这一写法也能清楚表达协议:

-- Release only when this caller still owns the lock.
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
end
return 0

Redis 8.4 及以上版本还可以使用带条件比较的 DELEX ... IFEQ。无论使用原生命令、Lua 还是客户端封装,关键约束都相同:只有 token 仍然匹配时才能删除锁

错误三:无条件续期

续期也必须验证 token。否则已经失去锁的旧实例可能把新持有者的租约延长,导致所有权状态更加混乱:

-- Extend only when this caller still owns the lock.
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0

续期返回 0 表示键已经过期、被删除或属于其他 token。此时调用方不能继续假设自己持有锁,也不应继续启动新的副作用。

⏳ 租约过期后,旧持有者仍可能运行

假设锁的租约是 30 秒,业务通常只执行 5 秒。下面任一情况仍可能让任务超过租约:

  • 运行时发生较长的 GC 或线程调度暂停。
  • 容器或虚拟机被挂起后再恢复。
  • Redis 网络连接中断,但业务数据库仍然可访问。
  • 下游接口超时、重试或长时间阻塞。
  • 进程负载突增,续期线程没有及时获得执行机会。

此时可能出现两个逻辑持有者:

实例 A 获取锁,租约 30 秒
  ├─ 执行任务时发生长时间暂停
  ├─ 30 秒后锁过期
实例 B 获取同一个锁并完成写入
  └─ 实例 A 恢复,继续提交旧结果

Redis 中可能始终只有一个锁键,但受保护的数据库、对象存储或外部接口仍然收到了两个实例的请求。仅检查“Redis 现在还有没有我的 token”也不够,因为检查通过后到业务提交前仍然存在新的时间窗口。

这种协调系统已经把锁交给新持有者、旧持有者却仍然运行的状态,可以理解为锁层面的脑裂。

Watchdog 能解决什么

Watchdog 会在任务运行期间周期性续期。例如租约为 30 秒时,每 10 秒检查 token 并延长租约。它适合处理任务耗时不固定、但进程和网络总体健康的情况。

Watchdog 不能提供以下保证:

  • 无法在进程长时间暂停时按时续期。
  • 无法让已经发出的数据库或 HTTP 请求自动撤销。
  • 无法证明续期失败后的旧任务立即停止。
  • 无法消除协调系统与业务资源之间的双写窗口。

因此,续期提高的是任务正常完成的概率,不是资源侧拒绝旧持有者的证明。

🛡️ Fencing Token:让资源拒绝旧请求

Fencing Token 是协调系统在每次成功授予锁时生成的单调递增编号。编号越大,代表所有权越新。受保护资源记录自己见过的最大编号,并拒绝更旧的请求。

实例 A 获得 token 41,随后暂停
实例 B 获得 token 42,资源接受 42
实例 A 恢复并提交 token 41
资源发现 41 < 42,拒绝旧请求

资源侧可以使用条件更新实现校验:

UPDATE scheduled_jobs
SET last_fencing_token = @token,
    completed_at = @completedAt
WHERE job_name = @jobName
  AND last_fencing_token < @token;

示例假设 last_fencing_token 是非空列,并以 0 作为初始值。更新零行表示这个请求已经过期,或者已经被更新的持有者覆盖。检查 token 和写入业务结果必须位于资源自身能够保证原子的操作中;如果先查询最大 token,再单独执行无条件更新,仍然会重新打开竞争窗口。

Redis 锁中用于标识所有权的随机 token 与 Fencing Token 不是同一种东西:

token作用是否有顺序
随机 ownership token防止释放或续期别人的锁
Fencing Token让资源判断请求的新旧是,必须单调递增

不能因为 Redis 提供 INCR 就直接断言它生成的编号一定构成安全的 fencing 协议。编号生成、锁授予和故障切换必须共享能够证明顺序的正确性边界;异步复制切换可能丢失尚未复制的计数更新。

Fencing Token 也有适用前提:受保护资源必须能够接收、保存并比较 token。某些第三方接口不支持条件写入,此时需要通过本地事务、幂等键、代理层或业务状态机建立等价的拒绝机制。

💻 Redis + .NET 最小实现

下面使用 StackExchange.Redis 展示单个 Redis 实例上的最小租约协议。它用于说明 token、TTL、续期和释放之间的关系,不是对所有故障模型都安全的通用锁库。

应用应长期复用 ConnectionMultiplexer,再通过 GetDatabase() 获取轻量的 IDatabase 对象。不要为了每次获取锁创建一条新连接。

using System.Security.Cryptography;
using StackExchange.Redis;

public sealed class RedisLease : IAsyncDisposable
{
    private const string RenewScript = """
        if redis.call("GET", KEYS[1]) == ARGV[1] then
          return redis.call("PEXPIRE", KEYS[1], ARGV[2])
        end
        return 0
        """;

    private const string ReleaseScript = """
        if redis.call("GET", KEYS[1]) == ARGV[1] then
          return redis.call("DEL", KEYS[1])
        end
        return 0
        """;

    private readonly IDatabase _database;
    private readonly RedisKey _key;
    private readonly RedisValue _token;
    private readonly TimeSpan _leaseTime;
    private int _disposed;

    private RedisLease(
        IDatabase database,
        RedisKey key,
        RedisValue token,
        TimeSpan leaseTime)
    {
        _database = database;
        _key = key;
        _token = token;
        _leaseTime = leaseTime;
    }

    public static async Task<RedisLease?> TryAcquireAsync(
        IDatabase database,
        RedisKey key,
        TimeSpan leaseTime)
    {
        ArgumentNullException.ThrowIfNull(database);

        if (leaseTime <= TimeSpan.Zero)
        {
            throw new ArgumentOutOfRangeException(nameof(leaseTime));
        }

        var token = Convert.ToHexString(
            RandomNumberGenerator.GetBytes(16));

        var acquired = await database.StringSetAsync(
            key,
            token,
            expiry: leaseTime,
            when: When.NotExists);

        return acquired
            ? new RedisLease(database, key, token, leaseTime)
            : null;
    }

    public async Task<bool> RenewAsync()
    {
        ObjectDisposedException.ThrowIf(
            Volatile.Read(ref _disposed) != 0,
            this);

        var result = await _database.ScriptEvaluateAsync(
            RenewScript,
            new RedisKey[] { _key },
            new RedisValue[]
            {
                _token,
                (long)_leaseTime.TotalMilliseconds,
            });

        return (long)result == 1;
    }

    public async ValueTask DisposeAsync()
    {
        if (Interlocked.Exchange(ref _disposed, 1) != 0)
        {
            return;
        }

        await _database.ScriptEvaluateAsync(
            ReleaseScript,
            new RedisKey[] { _key },
            new RedisValue[] { _token });
    }
}

这个类型只负责三件事:

  1. 使用 StringSetAsync(..., when: When.NotExists) 原子获取带 TTL 的键。
  2. 使用 Lua 比较 ownership token 后续期。
  3. DisposeAsync 中比较 token 后释放。

如果释放脚本返回 0,说明租约已经丢失、过期或被更新的持有者替代。脚本仍然保证当前实例不会删除别人的锁。释放命令自身也可能因网络故障失败,因此 TTL 仍是恢复活性的最后保障,而不是可选配置。

StackExchange.Redis 还提供 LockTakeAsyncLockExtendAsyncLockReleaseAsync。在 Redis 8.4 及以上版本中,客户端可以利用 SET ... IFEQDELEX ... IFEQ 等条件命令完成比较更新或比较删除。使用高层 API 不会改变本文讨论的故障边界:租约丢失后,旧业务代码仍可能继续运行。

为长任务增加续期循环

下面的调用方式使用 30 秒租约,并每 10 秒尝试续期。这两个时间只是便于阅读的示例值;生产值应基于任务耗时分布、Redis 往返延迟、运行时暂停时间和故障检测目标确定。

public static async Task RunOrderTimeoutJobAsync(
    IDatabase database,
    CancellationToken stoppingToken)
{
    await using var lease = await RedisLease.TryAcquireAsync(
        database,
        "lock:order-timeout",
        TimeSpan.FromSeconds(30));

    if (lease is null)
    {
        return;
    }

    using var ownershipCts =
        CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);

    var renewalTask = RenewUntilLostAsync(
        lease,
        TimeSpan.FromSeconds(10),
        ownershipCts);

    try
    {
        await CloseExpiredOrdersAsync(ownershipCts.Token);
    }
    finally
    {
        await ownershipCts.CancelAsync();
        await renewalTask;
    }
}

private static async Task RenewUntilLostAsync(
    RedisLease lease,
    TimeSpan interval,
    CancellationTokenSource ownershipCts)
{
    using var timer = new PeriodicTimer(interval);

    try
    {
        while (await timer.WaitForNextTickAsync(ownershipCts.Token))
        {
            if (!await lease.RenewAsync())
            {
                await ownershipCts.CancelAsync();
                return;
            }
        }
    }
    catch (OperationCanceledException)
        when (ownershipCts.IsCancellationRequested)
    {
        // 正常停止或锁所有权已经丢失。
    }
}

续期失败后,示例通过取消令牌通知业务逻辑停止。但取消令牌是协作式机制:如果数据库命令已经发出、第三方接口不接受取消,或者业务代码忽略了取消,副作用仍可能发生。正确性要求较高时,最终写入仍应使用幂等、条件更新或 Fencing Token。

StackExchange.Redis 的这些异步命令没有接收 CancellationToken 的重载。不要用“调用方已经取消”推断 Redis 命令一定没有执行;超时或连接中断时,还要考虑结果未知以及稍后重试产生的重复操作。

锁键、租约与重试策略

锁的作用域应直接体现在键名中,例如 lock:order-timeout 表示全局任务锁,lock:order:{orderId} 表示单个订单锁。作用域过大会制造无谓竞争,作用域过小则无法保护真正共享的资源。

获取失败通常表示其他实例正在工作,不一定是异常。定时任务可以直接跳过本轮;交互请求可以使用有上限、带随机抖动的退避,但等待时间必须受请求期限约束。无限自旋只会把锁竞争放大成 Redis 和线程池压力。

⚖️ 数据库、Redis、ZooKeeper 与 etcd 怎么选

分布式锁没有脱离业务场景的“最佳实现”。选型时先回答四个问题:

  1. 锁偶尔失效会只造成重复计算,还是会破坏业务正确性?
  2. 最终被保护的资源是否支持条件写入、唯一约束或 Fencing Token?
  3. 系统能否接受协调服务短暂不可用时停止工作?
  4. 团队是否具备运行和排查该协调系统的能力?
方案适合场景主要优点关键边界
数据库约束、条件更新或数据库锁正确性最终落在同一数据库与业务事务接近,可以直接保护最终数据长任务容易占用连接或事务;热点行会形成竞争
Redis 租约锁缓存重建、定时扫描、允许幂等重做的短任务延迟低、部署常见、实现成本较低租约过期和故障切换可能留下旧持有者,资源侧仍需幂等或 fencing
ZooKeeper需要会话、临时节点、排队锁或领导者选举协调语义成熟,可按顺序节点组织等待者客户端和运维复杂度更高,会话失效后旧业务请求仍需被拒绝
etcd已使用云原生基础设施,需要租约、事务和线性一致 KV提供租约、比较事务和全局 revision不适合存放大体量业务数据;业务资源仍需识别旧持有者

数据库:优先保护最终事实

如果多个实例最终都要修改同一个数据库,优先考虑数据库原生能力:

  • 唯一约束防止同一个业务对象被重复创建。
  • UPDATE ... WHERE status = 'pending' 保护状态机迁移。
  • SELECT ... FOR UPDATE 保护短事务内的行级读改写。
  • advisory lock 可以协调没有自然业务行的短操作,但必须理解它与连接、会话或事务的绑定关系。

数据库方案的优势是协调条件和业务写入可以处于同一个事务边界。它的代价也很直接:长时间持有事务会增加锁等待、连接占用、死锁概率和 MVCC 清理压力,因此不要把分钟级外部调用包在数据库事务中。

Redis:适合短租约和可收敛的工作

单个 Redis 实例上的 SET NX PX 协议简单、延迟低,适合以下情况:

  • 锁主要用于效率,偶尔重复执行可以接受。
  • 业务操作本身具备幂等或条件更新。
  • 租约远大于正常执行耗时,并且有续期与超时监控。
  • Redis 不可用时,业务可以停止或降级,而不是绕过锁继续写入。

如果在 Redis 主库上获取锁后,锁键还没有复制到副本,主库便发生故障,异步提升的副本可能不知道这把锁。另一个实例随后可以在新主库获取同名锁,原持有者也可能仍在工作。Redis 主从复制、Sentinel 和 Cluster 提高的是可用性与故障恢复能力,并不会自动把锁协议变成线性一致协调服务。相关故障切换边界可以结合 Redis 主从复制Redis SentinelRedis Cluster 一起理解。

Redlock 应该怎样理解

Redlock 使用多个彼此独立的 Redis 主节点。客户端需要在限定时间内获得多数节点上的锁,并从租约中扣除获取过程耗时和时钟漂移余量。它试图在部分 Redis 节点故障时同时维持互斥和活性,而不是把一个主从集群简单看成多个投票节点。

围绕 Redlock 的争议集中在故障模型:进程可能长时间暂停、网络消息可能延迟、墙上时钟可能跳变,而受保护资源未必能够识别已经过期的持有者。Redis 官方文档给出了算法成立所依赖的时间和节点独立性假设;Martin Kleppmann 的分析强调正确性场景仍需要 Fencing Token;Redis 作者的回应则讨论了算法目标、时钟假设以及 token 可用于条件写入的方式。

工程判断不应停留在“Redlock 安全”或“Redlock 不安全”这两个标签上:

  • 如果只是避免缓存重复重建,单 Redis 租约通常已经够用,Redlock 可能增加不必要的复杂度。
  • 如果重复执行会破坏正确性,应先设计资源侧幂等、条件写入或 Fencing Token,再评估协调服务。
  • 如果系统无法满足独立节点、延迟上界和时钟假设,就不能引用算法结论作为保证。
  • 如果资源完全无法拒绝旧请求,应把这种限制明确写入风险评审,而不是用更多 Redis 节点掩盖它。

ZooKeeper:临时顺序节点组织等待者

ZooKeeper 的锁配方通常在固定路径下创建 ephemeral sequential 节点。序号最小的客户端获得锁,其他客户端只监听排在自己前面的那个节点,而不是所有客户端同时监听最小节点。前驱节点删除后,下一个等待者被唤醒,可以减少惊群。

临时节点会在会话失效后消失,这解决了崩溃客户端永久占锁的问题。但客户端可能在长时间暂停后才得知会话已经失效,它此前发出的外部请求也不会自动撤销。正确性要求高时,仍应把顺序号或等价版本交给受保护资源做 fencing。

etcd:租约、事务与全局 revision

etcd 提供租约、比较事务以及集群范围内单调递增的 revision。事务是原子的 If/Then/Else,可以按 key 的版本、创建 revision、修改 revision 或值进行比较。官方并发库在这些原语上实现 session 和 mutex。

全局 revision 可以作为请求排序的基础,但 token 的生成必须与锁授予处于能够证明顺序的同一一致性边界。不能先在一个弱一致系统中获取锁,再从另一个无关计数器拿编号,然后假设两者天然组成 fencing 协议。

etcd 的优势是协调语义比普通缓存更直接,代价是需要维护共识集群,并接受失去多数派时宁可停止授予新锁。对于正确性锁,“暂时不可用”通常比“同时出现两个持有者”更容易恢复。

🚫 哪些场景不该使用分布式锁

分布式锁会引入新的共享依赖、超时参数和故障模式。下面这些问题通常有更直接的解法:

目标优先方案原因
防止重复创建记录数据库唯一约束由最终存储原子裁决,不依赖先获取锁
控制订单状态迁移带旧状态条件的 UPDATE状态检查与写入在同一个语句中完成
防止消息重复生效幂等键、Inbox 或业务唯一键消息传输本来就可能重复,锁不能替代消费幂等
把任务分给多个实例队列竞争消费、稳定分片或调度平台直接表达任务归属与重试,而不是让所有实例争一把锁
避免缓存击穿singleflight、本地请求合并或短效率锁重复计算通常不值得引入强一致协调
只允许一个调度器工作领导者选举或带故障接管的调度系统生命周期和成员变化比通用锁更明确

还要避免把锁当成事务。服务 A 获取锁后依次写数据库、发布消息和调用第三方接口,这三个动作仍然不会自动成为原子操作。数据库与消息系统之间的可靠双写应使用 Outbox 等机制;跨服务长事务则应通过幂等、状态机、补偿或 Saga 明确处理部分成功。

先让操作幂等,再考虑减少并发

分布式锁和幂等解决的问题不同:

  • 锁尝试减少同一时刻进入临界区的执行者。
  • 幂等保证同一个业务动作重复执行时,结果不会重复生效。

网络超时会让调用方不知道请求是否成功,消息系统也通常至少投递一次。即使锁协议从未失效,重试仍可能发生。因此,正确性路径不能只做锁而不做幂等。

🔭 监控与故障演练

锁问题很少表现为一个清晰的“锁服务故障”告警。更常见的症状是任务偶尔重复、延迟突然升高、续期失败或业务存储拒绝旧 token。监控应同时覆盖协调层和业务结果。

信号它回答的问题异常时优先检查
获取成功率与等待时间锁是否长期被占用或竞争过高锁粒度、任务堆积、持有者是否卡死
当前持有时长租约是否接近过期业务尾延迟、GC、下游超时
续期成功率与延迟持有者能否稳定维持租约Redis 延迟、网络、线程池饥饿
获取失败后的重试次数客户端是否形成自旋或重试风暴退避、随机抖动、请求期限
任务执行时间分布TTL 和续期间隔是否覆盖真实耗时P95/P99、长尾任务、外部依赖
stale token 拒绝次数是否真的出现过期持有者进程暂停、网络分区、租约设置
Redis 或协调集群状态锁后端是否发生切换或失去多数派复制延迟、选主、节点和时钟

日志至少应携带锁键、ownership token 的安全摘要、Fencing Token、获取耗时、租约长度、续期结果和业务对象标识。不要把完整随机 token 当成普通业务字段到处输出;它虽然通常不是凭证,但过度传播会增加误操作和日志噪声。

用故障证据验证设计

演练操作预期证据
持有者进程终止获取锁后直接结束进程TTL 到期后其他实例能够获取;没有永久锁
网络隔离阻断持有者到 Redis 的连接,保留其到数据库的连接续期失败;新持有者出现;旧请求被幂等或 fencing 拒绝
暂停超过租约暂停进程或注入长时间停顿锁转移后旧实例恢复;资源拒绝旧 token
Redis 故障切换在可控环境切换主库或领导者记录切换窗口内的获取结果;确认是否出现双持有者
下游长时间阻塞让数据库或 HTTP 调用超过 TTLWatchdog 行为可观察;任务取消和结果未知路径符合预期
重复投递让同一个任务被两个执行者接收即使锁竞争或重试,业务结果也只生效一次

演练通过标准不能只是“服务最后恢复了”。应保存时间线、锁 token、Fencing Token、续期日志、协调服务状态和最终业务数据,证明预期的保护机制确实被触发。

✅ 上线检查表

  • 已明确这是效率锁还是正确性锁,以及锁失效后的业务损失。
  • 锁键包含稳定命名空间和正确资源粒度,不会误伤无关任务。
  • 获取锁使用原子条件写入,并同时设置有限租约。
  • 每次获取生成新的唯一 ownership token。
  • 释放和续期都比较 token,并在一个原子操作中完成。
  • TTL 来自任务耗时分布和故障目标,而不是随意设置的常量。
  • Watchdog 有明确间隔、停止条件和续期失败处理。
  • 获取失败使用跳过或有上限的退避,不存在无限自旋。
  • Redis 超时、网络中断和取消后的结果未知路径已经处理。
  • 正确性操作使用唯一约束、条件更新、幂等或 Fencing Token。
  • Fencing Token 单调递增,并由最终资源原子比较和保存。
  • 协调服务故障时默认停止或降级,不会静默绕过锁继续执行。
  • 已验证 Redis 故障切换、进程暂停和网络分区的实际行为。
  • 监控覆盖锁竞争、持有时间、续期失败和 stale token 拒绝。
  • 运行手册说明如何确认锁持有者,以及何时允许人工删除锁键。

人工删除锁键必须非常谨慎。即使 Redis 中的键看起来“卡住了”,旧持有者也可能仍在运行。操作前应先隔离或停止旧实例,核对 token、TTL 和业务资源状态,再决定是否介入。

结论

分布式锁的基础实现并不复杂:条件创建一个带 TTL 的键,使用唯一 token 标识本次所有权,并在释放和续期时原子比较 token。复杂之处在于租约到期后,协调系统可以授予新锁,却无法保证旧持有者已经停止。

因此,Watchdog、Redlock、ZooKeeper 或 etcd 都不能替代业务资源自身的正确性设计。效率场景可以接受较弱的租约锁;正确性场景应让数据库唯一约束、条件更新、幂等或 Fencing Token 成为最终裁决者。

选型时先保护最终业务事实,再选择足以满足可用性、延迟和运维要求的协调系统。这样即使锁在最坏的时间点失效,系统也能把重复执行收敛为可识别、可拒绝或可恢复的结果。

参考资料