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工程师必须掌握的调试艺术与避坑实践》 的排查思路。
相关阅读
- Go语言实战:JWT鉴权全流程开发指南 —— 网关侧鉴权与本篇的资源侧互斥是微服务安全的两个互补面
- 「Go语言面试题」10 - 互斥锁(Mutex)和读写锁(RWMutex)有什么区别? —— 进程内锁与分布式锁的边界
- 「Go语言面试题」27 - Go 内存模型与 happens-before —— 理解同步原语为什么能保证可见性
- 《Go 程序设计语言》(Go 语言圣经,机械工业出版社) —— Go 官方团队出品,公认入门第一书
- 《Go 语言实战》(异步图书,人民邮电出版社) —— 实战导向,面试前巩固工程手感
- 《Go 语言编程之旅:一起用 Go 做项目》(博文视点,电子工业出版社) —— 项目驱动入门,从零写出完整应用
- Keychron K1Max 矮轴机械键盘(Mac/Win 双系统,QMK/VIA 开源改键) —— 轻薄矮轴 + 开源固件,程序员桌面常客
- 罗技 MX Master 3S 无线鼠标(Flow 跨屏,静音电磁滚轮) —— 人体工学设计,写代码一整天手不酸
以上链接含京东联盟推广代码;通过链接购买本站可能获得少量佣金,不影响你的购买价格。