Redis 从 0 到 1(十三):分布式锁被误删、锁不住?Go 无 Lua 实现 3 级防御

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • [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 个真相

  1. 锁的 TTL 要大于临界区最坏耗时(含 GC 暂停、网络抖动),看门狗是第二道保险不是偷懒理由;
  2. 主从切换会丢锁:加锁瞬间主库还没把 SET 同步给从库就挂了,从库升主,锁没了。强一致场景需要 Redlock(多实例多数派)或干脆换 etcd/ZooKeeper------本系列坚持简单优先,普通业务单实例 + 短 TTL + 看门狗足够;
  3. 锁是性能洼地:能用无锁设计(数据库唯一约束、队列串行化)就别上锁。

六、命令清单与排查步骤

命令 作用
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

排查"锁不住/锁不释放":

  1. TTL 看锁是否长期存活(看门狗没停 / 没 Release);
  2. 应用日志查 Release 是否报"拒绝删除"(token 不一致 = 有实例没走封装自己 DEL);
  3. 压测:100 goroutine 抢锁计数器,验证最终计数恰好 100(互斥)且耗时正常。

七、总结

  1. 及格线:SET NX PX 原子加锁;防误删:token + WATCH/MULTI 解锁;防超时:看门狗续期;
  2. 不用 Lua 也能做到同样的原子性,换来的是每步可观测、冲突可重试;
  3. TTL 要按最坏耗时设,主从切换丢锁靠架构评估兜底;
  4. 锁是最后手段,先想无锁方案。

下一篇预告:锁管的是"谁能做",下一篇管"能做多少"------下一篇《接口被刷爆?3 种限流算法的 Go+Redis 实现》,我们实现固定窗口、滑动窗口、令牌桶,并把突刺问题压测出来。

相关推荐
草莓熊Lotso1 小时前
【C++ 基于微服务的即时通讯系统】网关基建:cpp-httplib 与 WebSocketpp 从入门到实战
linux·开发语言·数据库·c++·redis·微服务·云原生
m0_6399069410 小时前
Redis入门|五种数据结构、使用场景、持久化RDB&AOF
数据结构·数据库·redis
IT规划师15 小时前
Redis 数据类型全解 - String 不只是 get set:90% 开发者忽略的 8 个高阶用法与内存陷阱(一)
redis·.net core
Flynn Liang17 小时前
内存/Redis/PGVector/Milvus/ES 怎么选?Spring AI VectorStore 抽象与向量库选型对照表
redis·elasticsearch·milvus·rag·springai·pgvector·向量库
hweiyu0018 小时前
Redis命令:SUNIONSTORE
redis·缓存
麻瓜code19 小时前
秒杀优化三连:Lua + 阻塞队列 + Redis Stream,响应从 3.4 秒到毫秒级
redis·junit·lua
hourinX1 天前
从 v1 到 v2,我重新拆了头像生成的整条链路—— gdenticon 重构日志(二)
go
没有气的汽水1 天前
一次生产 Redis 内存打满的排查、清理和复盘
redis