Golang Redis 分布式锁

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")               // 还可能误删别人的锁
    // 业务逻辑...
}

问题一:SETNXEXPIRE 不是原子的,中间崩溃就死锁了。

问题二:直接 DEL 不验证身份,如果锁过期被其他人抢占,DEL 会把别人的锁删掉。

第二版:SET + NX + EX 原子命令(✅ 解决了原子性)

Redis 2.6.12 之后,SET 命令可以一次带上 NXEX/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 代码先 GETDEL,这两步之间有网络延迟,锁可能恰好过期、被其他人抢走,就会误删。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 校验是整个设计的灵魂。

相关推荐
ZGG0032 小时前
底座 01:Agent 的 token 成本怎么砍——Redis 缓存实战
数据库·redis·缓存
陈皮波比茶2 小时前
RabbitMQ 消息队列
分布式·rabbitmq
李可以量化2 小时前
Redis Client 从入门到精通(一)下:redis-py 核心数据结构操作与连接管理
数据结构·redis·python·bootstrap·量化交易·qmt
圣殿骑士-Khtangc3 小时前
Go-Channel底层结构深度解析与select多路复用机制
golang
圣殿骑士-Khtangc3 小时前
Go-net-http标准库深度使用从路由到反向代理
golang
运维开发笔记3 小时前
6.1 Go 切片学习笔记
golang
x863 小时前
Go 1.27 正式发布:泛型方法、encoding/json/v2、后量子签名 ML-DSA 与 SIMD
开发语言·golang
敖行客 Allthinker3 小时前
分布式远程研发团队,借助云 IDE 统一开发环境
ide·分布式