你的 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 的批判中指出了这一点。想象一下:
- 客户端 A 获取了租约。
- 客户端 A 暂停了 45 秒(GC stop-the-world、虚拟机挂起、CPU 节流)。
- 租约过期。
- 客户端 B 获取了租约。
- 客户端 A 恢复并写入数据库。
- 客户端 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_acquired、lease_lost 和 heartbeat_latency 等指标。第一次你在一个长时间运行的任务期间部署,并看到第二个实例礼貌地等待而不是发生冲突时,你就会知道这个范畴错误已经被修正了。