Go 接入 Redis 分布式锁实战:SETNX、Lua 脚本与看门狗续期完整教程


引言

库存扣减、订单防重、定时任务防多实例并发执行——这些场景在多实例部署下,单机的 sync.Mutex 完全失效,必须借助外部存储实现分布式锁。Redis 因单线程执行命令的天然优势,成为最常用的分布式锁载体。

但分布式锁看似简单,坑却极多:SETNX 和 EXPIRE 分两步执行会死锁、直接 DEL 会误删别人的锁、业务没执行完锁就过期……本文用 Go 的 go-redis/v9 从零实现一个生产可用的 Redis 分布式锁,把每个坑和对应的正确姿势讲清楚,最后附完整可运行代码。

一、为什么本地锁不够

假设你有一个扣减库存的接口,用 sync.Mutex 保护:

var mu sync.Mutex

func DeductStock() {
    mu.Lock()
    defer mu.Unlock()
    // 扣库存...
}

单实例部署没问题。但线上通常是多副本部署(K8s 多个 Pod),每个进程有自己的 mu,三个实例就是三把互不相干的锁——并发请求打到不同实例时,库存照样超卖。第 10 期 《互斥锁(Mutex)和读写锁(RWMutex)的区别》 讲的锁只能保护单个进程内的并发,跨进程就必须用大家都"看得见"的外部锁。

二、常见错误姿势盘点

错误姿势 后果 正确做法
只用 SETNX,不加过期时间 客户端宕机 → 死锁 加锁同时设 TTL
SETNX 成功后再 EXPIRE 两条命令间宕机 → 死锁 SET key val NX PX ttl 原子完成
释放锁时直接 DEL 误删别人的锁(自己的锁已过期时) 先比对唯一 token,用 Lua 脚本原子删
锁过期时间设太短 业务没跑完锁失效,出现并发 看门狗续期
拿不到锁直接返回错误 瞬时竞争下体验差 有限次自旋重试 + 超时控制

三、正确姿势一:原子加锁 SET NX PX

加锁必须满足两个原子性:「不存在才设置」和「设置过期时间」。Redis 的 SET key value NX PX milliseconds 一条命令同时做到:

// go-redis/v9 中 SetNX 直接支持过期时间参数,底层就是 SET NX PX
ok, err := rdb.SetNX(ctx, "order:deduct:sku_1001", token, 10*time.Second).Result()
if err != nil {
    return err
}
if ok {
    // 拿到锁了
}

其中 token 必须是全局唯一的随机值(如 UUID),它是后续"只能释放自己的锁"的凭证。

四、正确姿势二:Lua 脚本原子解锁

为什么不能先 GET 比对再 DEL?因为这两条命令之间锁可能刚好过期、被别的客户端拿到,你还是会误删。把比对和删除放进 Lua 脚本,由 Redis 单线程原子执行:

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

五、正确姿势三:自旋重试与超时

秒杀场景下锁竞争激烈,拿不到锁立刻返回错误对用户不友好。常规做法是有限次自旋重试,用 context.Context 控制总等待时间,避免无限阻塞:

for {
    ok, err := rdb.SetNX(ctx, key, token, expiration).Result()
    if err != nil {
        return false, err
    }
    if ok {
        return true, nil
    }
    select {
    case <-ctx.Done(): // 总超时,放弃
        return false, nil
    case <-time.After(200 * time.Millisecond): // 隔 200ms 再抢
    }
}

六、正确姿势四:看门狗续期

10 秒的锁,业务因为 GC 停顿或下游接口变慢跑了 15 秒怎么办?锁在第 10 秒被 Redis 自动释放,另一个实例立刻拿到锁,两个进程同时操作资源——这是分布式锁最隐蔽的事故。

解决方案是看门狗(Watchdog):拿到锁后启动一个后台 goroutine,每隔「过期时间的 1/3」检查一次,如果锁还属于自己就用 PEXPIRE 续期;续期失败(比如锁已丢失)就停止续期,让业务侧尽快收尾退出:

renewScript := redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("pexpire", KEYS[1], ARGV[2])
else
    return 0
end`)

func (l *RedisLock) startWatchdog(ctx context.Context) {
    interval := l.expiration / 3
    go func() {
        ticker := time.NewTicker(interval)
        defer ticker.Stop()
        for {
            select {
            case <-ctx.Done():
                return
            case <-ticker.C:
                res, err := renewScript.Run(ctx, l.rdb,
                    []string{l.key}, l.token, l.expiration.Milliseconds()).Result()
                if err != nil || res == int64(0) {
                    return // 锁已不属于自己,停止续期
                }
            }
        }
    }()
}

七、完整可运行代码

依赖安装:go get github.com/redis/go-redis/v9(建议 Go 1.21+)。本地需有 Redis,docker run -d -p 6379:6379 redis 即可。

package main

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

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

var (
    ErrNotHeld = errors.New("redis: lock not held by this token")

    unlockScript = redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end`)

    renewScript = redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("pexpire", KEYS[1], ARGV[2])
else
    return 0
end`)
)

type RedisLock struct {
    rdb        *redis.Client
    key        string
    token      string
    expiration time.Duration
    cancel     context.CancelFunc
}

func NewRedisLock(rdb *redis.Client, key string, expiration time.Duration) *RedisLock {
    b := make([]byte, 16)
    _, _ = rand.Read(b)
    return &RedisLock{
        rdb: rdb, key: key, token: hex.EncodeToString(b), expiration: expiration,
    }
}

// Lock 自旋重试加锁;ctx 超时仍未拿到锁则返回 false
func (l *RedisLock) Lock(ctx context.Context) (bool, error) {
    for {
        ok, err := l.rdb.SetNX(ctx, l.key, l.token, l.expiration).Result()
        if err != nil {
            return false, err
        }
        if ok {
            watchdogCtx, cancel := context.WithCancel(context.Background())
            l.cancel = cancel
            l.startWatchdog(watchdogCtx)
            return true, nil
        }
        select {
        case <-ctx.Done():
            return false, nil
        case <-time.After(200 * time.Millisecond):
        }
    }
}

func (l *RedisLock) startWatchdog(ctx context.Context) {
    interval := l.expiration / 3
    go func() {
        ticker := time.NewTicker(interval)
        defer ticker.Stop()
        for {
            select {
            case <-ctx.Done():
                return
            case <-ticker.C:
                res, err := renewScript.Run(ctx, l.rdb,
                    []string{l.key}, l.token, l.expiration.Milliseconds()).Result()
                if err != nil || res == int64(0) {
                    return
                }
            }
        }
    }()
}

// Unlock Lua 脚本原子释放,并停止看门狗
func (l *RedisLock) Unlock(ctx context.Context) error {
    if l.cancel != nil {
        l.cancel()
    }
    res, err := unlockScript.Run(ctx, l.rdb, []string{l.key}, l.token).Result()
    if err != nil {
        return err
    }
    if n, ok := res.(int64); !ok || n != 1 {
        return ErrNotHeld
    }
    return nil
}

func main() {
    rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
    defer rdb.Close()

    ctx := context.Background()
    lock := NewRedisLock(rdb, "order:deduct:sku_1001", 10*time.Second)

    ok, err := lock.Lock(ctx)
    if err != nil {
        panic(err)
    }
    if !ok {
        fmt.Println("未抢到锁,请稍后重试")
        return
    }
    defer lock.Unlock(context.Background()) // 业务 panic 也能释放

    fmt.Println("抢到锁,开始处理业务...")
    time.Sleep(3 * time.Second)
    fmt.Println("业务完成,锁已释放")
}

运行效果:先启动一个实例拿到锁,第二个实例会在自旋重试后打印「未抢到锁」;把 Sleep 改成 25 秒(超过 10 秒过期时间),可以看到看门狗每约 3.3 秒续期一次,业务全程持有锁不被打断。

八、再进一步:Redlock 与 redsync

本文实现的是单节点 Redis 分布式锁,已经覆盖绝大多数业务场景。如果 Redis 本身要做主从/集群部署,故障转移瞬间可能丢失锁,这时需要 Redlock 算法:向多个独立的主节点依次加锁,过半成功才算拿到。Go 社区有成熟实现 github.com/go-redsync/redsync,生产环境可以直接引入,不必重复造轮子。

另外提醒:锁能不用就不用。能用数据库唯一约束(如订单号唯一索引)解决的防重问题,优先用约束——更简单,也没有死锁和续期的烦恼。锁永远是兜底手段。遇到锁相关的诡异阻塞,可以参考 《死锁排查指南:Go工程师必须掌握的调试艺术与避坑实践》 的排查思路。

相关阅读

🛒 站长推荐(京东联盟)

以上链接含京东联盟推广代码;通过链接购买本站可能获得少量佣金,不影响你的购买价格。

wx

关注公众号

©2017-2026 鲁ICP备17023316号-1 Powered by Hugo · 隐私政策