Golang Redis 分布式锁

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