你的 sync.Mutex 无法挺过 kill -9。它也无法挺过 OOM、部署滚动更新或节点重启。进程退出的瞬间,锁就消失了。如果这个锁正在保护一个定时任务、数据迁移或主节点选举,那么你现在会有两个进程都坚信自己是唯一在运行的那个。

这不是你的互斥锁有 bug。这是一个范畴错误。进程本地的锁无法保护集群范围的资源。

解决方案是分布式租约:一种存储在外部系统中的锁,带有存活时间(Time-To-Live)、唯一的持有者令牌以及安全释放机制。Redis、PostgreSQL、etcd 和 ZooKeeper 都以不同的权衡实现了相同的思路。模式本身很简单。边界情况则不然。

什么是分布式租约?

租约是外部存储做出的一种承诺:在有限的时间内,只有一个客户端持有某个命名锁。与存在于堆内存中的互斥锁不同,租约存在于 Redis 或 PostgreSQL 中。它能跨越进程重启而持续存在,因为存储本身具有持久性。

基本操作包括:

  • Acquire:以原子方式写入一个带 TTL 的 key,但仅在它尚不存在时。
  • Renew:在你仍持有锁时延长 TTL。
  • Release:删除该 key,但仅当它仍然包含你的唯一令牌时。

唯一令牌是关键所在。没有它,一个延迟的客户端可能会释放一个在崩溃后已被他人重新获取的锁。

为什么 TTL 是必须的,以及为什么它是危险的

如果一个客户端获取了锁然后宕机,锁最终必须自行释放。实现这一点且无需人工干预的唯一方式就是设置过期时间。Redis 原生支持这一点,通过 SET key value NX EX 30。PostgreSQL 的 advisory lock 与会话绑定,当 TCP 连接关闭时自动释放,这很优雅但可移植性较差。

TTL 引入了一种新的故障模式:如果你的工作耗时超过 TTL,锁会在你仍在运行时过期。另一个客户端获取了锁并开始执行同样的工作。你现在有两个并发进程在修改同一份数据。

天真的修复方法是设置一个很长的 TTL。这确实有效,直到某个客户端在获取锁后立即宕机。剩余的 TTL 就变成了一个强制的停机窗口,在此期间没有任何人能够接管。

正确的修复方法是心跳机制。持有者启动一个后台 goroutine,每隔几秒续订一次租约。如果持有者崩溃,心跳停止,TTL 过期,新的所有者可以在数秒内获取锁。如果持有者只是运行缓慢,只要进程还在,心跳就会让租约保持有效。

一个可用的 Go 语言 Redis 租约实现

下面是一个完整的实现,涵盖获取、续订和安全释放。它在释放时使用 Lua 脚本,以确保仅当令牌匹配时删除才会成功。

package lease

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "fmt"
    "sync"
    "time"

    "github.com/redis/go-redis/v9"
)

type Lease struct {
    client     *redis.Client
    key        string
    token      string
    ttl        time.Duration
    renewEvery time.Duration
    stopRenew  chan struct{}
    once       sync.Once
}

func generateToken() string {
    b := make([]byte, 16)
    rand.Read(b)
    return hex.EncodeToString(b)
}

func Acquire(ctx context.Context, client *redis.Client, key string, ttl time.Duration) (*Lease, error) {
    token := generateToken()
    ok, err := client.SetNX(ctx, key, token, ttl).Result()
    if err != nil {
        return nil, err
    }
    if !ok {
        return nil, fmt.Errorf("lock already held")
    }

    l := &Lease{
        client:     client,
        key:        key,
        token:      token,
        ttl:        ttl,
        renewEvery: ttl / 3,
        stopRenew:  make(chan struct{}),
    }
    go l.renew()
    return l, nil
}

func (l *Lease) renew() {
    ticker := time.NewTicker(l.renewEvery)
    defer ticker.Stop()
    for {
        select {
        case <-ticker.C:
            ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
            script := `
                if redis.call("get", KEYS[1]) == ARGV[1] then
                    return redis.call("pexpire", KEYS[1], ARGV[2])
                else
                    return 0
                end
            `
            l.client.Eval(ctx, script, []string{l.key}, l.token, l.ttl.Milliseconds()).Result()
            cancel()
        case <-l.stopRenew:
            return
        }
    }
}

func (l *Lease) Release(ctx context.Context) error {
    l.once.Do(func() { close(l.stopRenew) })
    script := `
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
    `
    res, err := l.client.Eval(ctx, script, []string{l.key}, l.token).Int64()
    if err != nil {
        return err
    }
    if res == 0 {
        return fmt.Errorf("lock was lost or stolen")
    }
    return nil
}

使用方式:

lease, err := lease.Acquire(ctx, redisClient, "job:invoice-generation", 10*time.Second)
if err != nil {
    // Another instance is running the job.
    return
}
defer lease.Release(ctx)

// Do the work. If this takes 30 seconds, the heartbeat keeps the lease alive.
generateInvoices()

Lua 脚本是必需的,因为 Redis 不支持将比较并删除作为单一的原生命令。没有它们,释放操作可能与重新获取发生竞争,从而删除他人的锁。

它在哪里会失效:fencing token 问题

即使有了完美的 TTL 和续订机制,仍然存在一种微妙的竞态条件,Martin Kleppmann 在他对 Redlock 的批判中指出了这一点。想象一下:

  1. 客户端 A 获取了租约。
  2. 客户端 A 暂停了 45 秒(GC stop-the-world、虚拟机挂起、CPU 节流)。
  3. 租约过期。
  4. 客户端 B 获取了租约。
  5. 客户端 A 恢复并写入数据库。
  6. 客户端 B 写入数据库。

两个客户端都认为自己持有锁。两者都修改了共享状态。

TTL 可以防止永久死锁,但它无法防御延迟的进程。修复方法是 fencing token:一个单调递增的数字或 UUID,锁持有者在每次写入时附带它。存储层会拒绝携带过期令牌的写入。

在实践中,这意味着你的数据库表需要一个 lock_version 列,或者你的 blob 存储需要条件写入。大多数应用会跳过这一步,因为这需要修改数据层,而不仅仅是锁层。这是一个合理的权衡,但它确实是一个权衡。你应该知道自己正在做这个取舍。

值得考虑的替代方案

PostgreSQL advisory locks 是会话范围的。当 TCP 连接关闭时,锁会自动释放。没有 TTL 管理,也不存在时钟偏移问题。缺点是它们与单个数据库连接绑定,因此在连接池或多区域架构中表现不佳。

etcd leases 正是为这个问题而设计的。它们支持 TTL、自动撤销,以及租约死亡时的 watch 通知。如果你已经在运行 Kubernetes,你就已经有了 etcd。API 比 Redis 更冗长,但语义更清晰。

ZooKeeper ephemeral sequential nodes 是经典的解决方案。它们在 CAP 定理下属于 CP(一致且分区容忍),这完全消除了时钟偏移问题。但它们也比 Redis 更慢、运维负担更重。

我们没有尝试的做法

我们没有在关系型数据库之上实现自定义的共识协议。每个团队最终都会尝试这种做法:一个 locks 表,使用 INSERT ... ON CONFLICT,以及一个由 cron 任务定期清理的 last_heartbeat 列。它在正常路径下工作。但在高竞争下会崩溃,因为 MVCC 数据库会串行化冲突的写入,而你的锁获取操作会变成整个系统的瓶颈。要为工作选择合适的工具。

如何选择 TTL

太短:心跳变得过于频繁,一次缓慢的 GC 暂停就可能丢失租约。

太长:崩溃的持有者会在整个 TTL 期间阻塞故障转移。

一个不错的起点是 TTL 10 秒,心跳间隔 3 秒。根据你观察到的 GC 暂停和网络延迟在此基础上调优。测量你的 p99 心跳延迟。如果它是 500 毫秒,你的 TTL 必须至少高出一个数量级。

试试看

如果你现在正用 sync.Mutex 来保护一个后台任务,请把它换成由 Redis 或 PostgreSQL 支撑的租约锁。从上面的实现开始。添加 lease_acquiredlease_lostheartbeat_latency 等指标。第一次你在一个长时间运行的任务期间部署,并看到第二个实例礼貌地等待而不是发生冲突时,你就会知道这个范畴错误已经被修正了。