我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [Redis 从 0 到 1(十三):分布式锁被误删、锁不住?Go 无 Lua 实现 3 级防御](#Redis 从 0 到 1(十三):分布式锁被误删、锁不住?Go 无 Lua 实现 3 级防御)
-
- 一、两个经典事故
- [二、为什么不用 Lua(收藏点 0:本系列立场)](#二、为什么不用 Lua(收藏点 0:本系列立场))
- [三、3 级防御的 Go 实现(收藏点 1)](#三、3 级防御的 Go 实现(收藏点 1))
- [四、3 级防御对照表(收藏点 2)](#四、3 级防御对照表(收藏点 2))
- [五、还要知道的 3 个真相](#五、还要知道的 3 个真相)
- 六、命令清单与排查步骤
- 七、总结
Redis 从 0 到 1(十三):分布式锁被误删、锁不住?Go 无 Lua 实现 3 级防御
上一篇《Redis 从 0 到 1(十二):先删缓存还是先更新库?4 种方案的 Go 并发实测》我们压测了缓存一致性的 4 种方案,链接:https://blog.csdn.net/2501_92769340/article/details/167399698 。
本篇处理"多实例抢同一个资源"的问题:分布式锁。而且按本系列铁律------不用 Lua,全部用 Go 实现。
一、两个经典事故
事故 1:锁被误删。 服务 A 拿到锁,任务执行超过了锁的 TTL,锁自动过期;这时服务 B 拿到了新锁。A 执行完,随手 DEL------删的是 B 的锁。接下来 C 又进来,互斥性彻底瓦解。
事故 2:锁不住。 面试官最爱追问的场景:SETNX 成功 → 客户端还没执行 SET PX,客户端宕机 → 锁永远不过期,全系统死锁。所以"加锁"和"过期时间"必须是一条原子命令。
这两件事定义了分布式锁的及格线:加锁原子、解锁要验明正身、临界区可能超时要能续期。下面 3 级防御逐级加固。
二、为什么不用 Lua(收藏点 0:本系列立场)
网上 90% 的教程解锁用 if get(key)==token then del 的 Lua 脚本。本系列刻意不用 Lua:
| 维度 | Lua 脚本 | SET NX PX + WATCH/MULTI |
|---|---|---|
| 原子性 | 脚本整体原子 | 加锁是单命令天然原子;解锁用 WATCH 乐观锁,冲突时重试 |
| 可观测性 | 出错只能看整段脚本 | 每步命令都在慢查询/监控里可见 |
| 依赖 | 服务端脚本能力 | 只有基础命令 |
| 冲突处理 | 无 | WATCH 冲突 → 重试,天然排队 |
解锁的关键:WATCH key 监视锁 → GET 比对 token → 是本人的才 DEL,全放 MULTI/EXEC 里。期间 key 被任何人动过,EXEC 都会失败(返回空),重试即可。go-redis 里对应 TxPipelined(自动带 WATCH)。
三、3 级防御的 Go 实现(收藏点 1)
go
// redisx/lock.go
package redisx
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
var ErrLockNotAcquired = errors.New("未抢到锁")
type Lock struct {
rdb *redis.Client
key string
token string
ttl time.Duration
stop chan struct{}
}
// Acquire 第 1 级:SET NX PX ------ 加锁和过期时间一条命令,原子
func Acquire(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (*Lock, error) {
token := newToken()
ok, err := rdb.SetNX(ctx, "lock:"+key, token, ttl).Result()
if err != nil {
return nil, fmt.Errorf("加锁失败: %w", err)
}
if !ok {
return nil, ErrLockNotAcquired
}
l := &Lock{rdb: rdb, key: key, token: token, ttl: ttl, stop: make(chan struct{})}
l.startWatchdog() // 第 3 级:看门狗续期
return l, nil
}
// Release 第 2 级:WATCH token + MULTI DEL ------ 只删自己的锁
func (l *Lock) Release(ctx context.Context) error {
l.stopWatchdog()
key := "lock:" + l.key
// TxPipelined = WATCH + MULTI/EXEC;key 被改动则事务失败,重试 3 次
for i := 0; i < 3; i++ {
_, err := l.rdb.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
cur, err := pipe.Get(ctx, key).Result()
if errors.Is(err, redis.Nil) {
return errors.New("锁已不存在")
}
if err != nil {
return err
}
if cur != l.token {
return errors.New("锁已被他人持有,拒绝删除") // 第 1 级防御误删事故
}
pipe.Del(ctx, key)
return nil
})
if err == nil {
return nil
}
if i < 2 {
time.Sleep(50 * time.Millisecond)
}
}
return errors.New("释放锁失败(事务冲突重试耗尽)")
}
// startWatchdog 第 3 级:TTL 的 1/3 时续期一次;任务异常结束则停止续期,锁自然过期
func (l *Lock) startWatchdog() {
go func() {
t := time.NewTicker(l.ttl / 3)
defer t.Stop()
for {
select {
case <-l.stop:
return
case <-t.C:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
// 续期也走 WATCH:只有自己还持有才续
l.rdb.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
cur, err := pipe.Get(ctx, "lock:"+l.key).Result()
if err == nil && cur == l.token {
pipe.Expire(ctx, "lock:"+l.key, l.ttl)
}
return nil
})
cancel()
}
}
}()
}
func (l *Lock) stopWatchdog() {
close(l.stop)
}
func newToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
业务侧用法(可复用模板):
go
func ProcessOrder(ctx context.Context, rdb *redis.Client, orderID string) error {
lock, err := redisx.Acquire(ctx, rdb, "order:"+orderID, 10*time.Second)
if errors.Is(err, redisx.ErrLockNotAcquired) {
return nil // 别的实例在处理,跳过即可
}
if err != nil {
return err
}
defer lock.Release(context.Background()) // 释放失败不能吞掉,至少打日志
// ... 临界区:扣库存、发券等
return nil
}
四、3 级防御对照表(收藏点 2)
| 级别 | 防御的事故 | 机制 |
|---|---|---|
| 1. SET NX PX | 加锁后宕机导致死锁 | 加锁 + 过期一条原子命令 |
| 2. token + WATCH/MULTI | 误删他人锁 | 解锁前验明 token,事务保证"校验+删除"之间锁不被换走 |
| 3. 看门狗续期 | 临界区超过 TTL 被误判过期 | TTL/3 定时续期,只续自己持有的锁 |
五、还要知道的 3 个真相
- 锁的 TTL 要大于临界区最坏耗时(含 GC 暂停、网络抖动),看门狗是第二道保险不是偷懒理由;
- 主从切换会丢锁:加锁瞬间主库还没把 SET 同步给从库就挂了,从库升主,锁没了。强一致场景需要 Redlock(多实例多数派)或干脆换 etcd/ZooKeeper------本系列坚持简单优先,普通业务单实例 + 短 TTL + 看门狗足够;
- 锁是性能洼地:能用无锁设计(数据库唯一约束、队列串行化)就别上锁。
六、命令清单与排查步骤
| 命令 | 作用 |
|---|---|
SET lock:k token NX PX 10000 |
原子加锁 |
WATCH lock:k + GET + MULTI + DEL + EXEC |
安全解锁(go-redis TxPipelined) |
TTL lock:k |
看剩余时间,判断有没有看门狗 |
OBJECT ENCODING lock:k |
排查锁 key 是否被塞成大 value |
排查"锁不住/锁不释放":
TTL看锁是否长期存活(看门狗没停 / 没 Release);- 应用日志查 Release 是否报"拒绝删除"(token 不一致 = 有实例没走封装自己 DEL);
- 压测:100 goroutine 抢锁计数器,验证最终计数恰好 100(互斥)且耗时正常。
七、总结
- 及格线:SET NX PX 原子加锁;防误删:token + WATCH/MULTI 解锁;防超时:看门狗续期;
- 不用 Lua 也能做到同样的原子性,换来的是每步可观测、冲突可重试;
- TTL 要按最坏耗时设,主从切换丢锁靠架构评估兜底;
- 锁是最后手段,先想无锁方案。
下一篇预告:锁管的是"谁能做",下一篇管"能做多少"------下一篇《接口被刷爆?3 种限流算法的 Go+Redis 实现》,我们实现固定窗口、滑动窗口、令牌桶,并把突刺问题压测出来。