Redis 分布式锁
一、为什么需要分布式锁
单机应用里,sync.Mutex 就能保护临界区。但在微服务集群中,多个进程------甚至跨机器------同时操作共享资源(减库存、抢优惠券、定时任务去重),单机锁就无能为力了。这时候需要一个所有节点都能访问的"协调器"来做锁仲裁,Redis 就是最常见的实现载体。
核心要求只有三条:
| 要求 | 说明 |
|---|---|
| 互斥性 | 同一时刻只有一个人能拿到锁 |
| 不死锁 | 即使持有者崩溃,锁最终必须释放(靠 TTL) |
| 解铃还须系铃人 | 释放锁时校验身份,不能删除别人拿到的锁 |
二、一步一步构造可靠的分布式锁
第一版:只用 SETNX(❌ 有缺陷)
go
// ❌ 危险写法:SETNX 和 EXPIRE 是两条命令,中间可能崩溃导致死锁
ok, _ := rdb.SetNX(ctx, "lock:order", "1", 0).Result()
if ok {
rdb.Expire(ctx, "lock:order", 30*time.Second) // 万一这里挂了,锁永远不会释放!
defer rdb.Del(ctx, "lock:order") // 还可能误删别人的锁
// 业务逻辑...
}
问题一:SETNX 和 EXPIRE 不是原子的,中间崩溃就死锁了。
问题二:直接 DEL 不验证身份,如果锁过期被其他人抢占,DEL 会把别人的锁删掉。
第二版:SET + NX + EX 原子命令(✅ 解决了原子性)
Redis 2.6.12 之后,SET 命令可以一次带上 NX 和 EX/PX:
go
// ✅ 单条命令原子地完成"不存在就创建 + 设置过期"
ok, _ := rdb.SetNX(ctx, "lock:order", token, 30*time.Second).Result()
但释放时还是不能直接 DEL------需要验证这把锁是不是自己拿到的。
第三版:唯一令牌 + Lua 原子释放(✅ 生产可用)
关键思路:加锁时给锁存一个唯一 token(比如 UUID),释放时先比较令牌是否匹配,匹配才删除。比较 + 删除这步必须用 Lua 脚本,因为在 Redis 中 Lua 脚本是原子执行的。
go
// 加锁:给这把锁贴上一个独一无二的"标签"
func AcquireLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (string, error) {
token := randomToken() // 每次加锁生成一个新 token
ok, err := rdb.SetNX(ctx, key, token, ttl).Result()
if err != nil {
return "", fmt.Errorf("acquire lock: %w", err)
}
if !ok {
return "", ErrLockHeld
}
return token, nil
}
// 释放锁:Lua 脚本保证"检查 + 删除"是原子的
var releaseScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`)
func ReleaseLock(ctx context.Context, rdb *redis.Client, key, token string) error {
n, err := releaseScript.Run(ctx, rdb, []string{key}, token).Int()
if err != nil {
return fmt.Errorf("release lock: %w", err)
}
if n == 0 {
return ErrLockExpired // token 不匹配,说明锁已过期或被其他人抢占
}
return nil
}
为什么必须用 Lua? 如果用 Go 代码先 GET 再 DEL,这两步之间有网络延迟,锁可能恰好过期、被其他人抢走,就会误删。Lua 在 Redis 单线程里一气呵成。
第四版:带重试的加锁
拿不到锁时别立刻放弃,加一个自旋重试:
go
func AcquireLockWithRetry(ctx context.Context, rdb *redis.Client, key string, ttl, retryInterval time.Duration, maxRetries int) (string, error) {
for i := 0; i <= maxRetries; i++ {
token, err := AcquireLock(ctx, rdb, key, ttl)
if err == ErrLockHeld {
if i == maxRetries {
return "", ErrLockHeld
}
select {
case <-time.After(retryInterval):
continue
case <-ctx.Done():
return "", ctx.Err()
}
}
return token, err
}
return "", ErrLockHeld
}
简而言之:等一等、再试试------但不等太久,用 ctx 控制超时。
三、进阶:锁过期比任务快怎么办
假设 TTL=30秒,但业务跑了 35 秒。锁早就过期被释放,第二个节点拿到锁进场------两个节点同时跑,分布式锁名存实亡。
方案A:看门狗(Watchdog)自动续期
加锁后启动一个后台 goroutine,每隔 TTL / 3 时间用 EXPIRE 续命。任务完成时关闭看门狗。
核心机制:不要让锁在你还在干活的时候就过期了。
go
func AcquireLockWithWatchDog(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (string, context.CancelFunc, error) {
token, err := AcquireLock(ctx, rdb, key, ttl)
if err != nil {
return "", nil, err
}
// 启动看门狗
renewCtx, cancel := context.WithCancel(context.Background())
go func() {
ticker := time.NewTicker(ttl / 3)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// 续期需验证 token 是否还匹配
renewScript.Run(ctx, rdb, []string{key}, token, int64(ttl.Seconds()))
case <-renewCtx.Done():
return
}
}
}()
return token, cancel, nil
}
虽然看门狗把窗口缩小了很多,但它不能从根本上消除问题------如果进程整个 GC 暂停了呢?这时候需要更硬核的手段。
方案B:Fencing Token(栅栏令牌)
原理:每次加锁成功时,返回一个单调递增的序号。写共享资源时带上这个序号,资源层拒绝小于当前已处理序号的请求。即使旧持有者"睡醒",它的请求也被截胡了。
可以用 Redis 的 INCR 原子增长来生成序号。
实用建议:日常业务用看门狗就够了。只有在对数据一致性极其敏感的金融场景,才上栅栏令牌。
四、Redlock:多节点 Redis 锁
单节点 Redis 有 SPOF(单点故障)风险------如果主节点宕机且异步复制还没同步到从节点,锁数据就丢了。
Redlock 算法(Redis 作者 Antirez 提出)的思路:同时在 N 个独立 Redis 节点上获取锁(N 为奇数,通常 N≥5),只有拿到过半数的成功才算加锁成功。
go
// Redlock 的核心逻辑:在多数节点上都拿到锁,才算成功
func (r *RedLock) Lock(ctx context.Context, key string, ttl time.Duration) (string, error) {
token := randomToken()
deadline := time.Now().Add(ttl / 2) // 加锁总耗时不能超过 TTL 的一半
success := 0
for _, client := range r.clients {
ok, _ := client.SetNX(ctx, key, token, ttl).Result()
if ok {
success++
}
if time.Now().After(deadline) {
break
}
}
// 过半成功 → 加锁成功;否则回滚所有节点
if success < len(r.clients)/2+1 {
r.Unlock(ctx, key, token)
return "", ErrLockHeld
}
return token, nil
}
实际上,开源的 go-redsync/redsync 库已经封装好了 Redlock,直接拿来用即可。
五、本章要点回顾
| 要点 | 一句话总结 |
|---|---|
| SETNX 不等于分布式锁 | SETNX 只是"不存在才设值",还需要 TTL、token、原子释放 |
| Lua 释放是刚需 | 比较 + 删除必须原子化,否则有竞态 |
| 看门狗是标配 | 业务跑得比 TTL 长时,需要后台续期 |
| 栅栏令牌是最强保障 | 即使锁失效,数据层也能拒绝过期请求 |
| Redlock 对抗节点故障 | 多节点过半确认,牺牲一点性能换一致性 |
一句箴言:分布式锁的核心不是"锁",而是"谁的锁"。token 校验是整个设计的灵魂。