Redis分布式锁的几个坑

6 阅读10分钟

在分布式系统中,多个节点对共享资源的并发访问是常见场景,而分布式锁是保证资源访问互斥性、维护数据一致性的核心机制。Redis 凭借其高性能、高可用的特性,成为实现分布式锁的主流选择。但在实际使用中,若忽略 Redis 的特性和分布式场景的复杂性,很容易踩入各种坑,导致锁失效、数据错乱等严重问题。

本文将详细拆解 Redis 分布式锁的 3 个核心坑点,并通过Go 语言实战代码,演示如何正确实现健壮的 Redis 分布式锁,同时深入讲解背后的原理和设计思路。

一、Redis 分布式锁的基本原理

在深入坑点之前,我们先明确 Redis 分布式锁的核心实现逻辑,这是后续避坑的基础。

Redis 分布式锁的实现主要依赖两个核心特性:

  1. SETNX 命令(SET if Not Exists) :仅当指定 key 不存在时,才会设置 key 的值,返回成功;若 key 已存在,直接返回失败。该命令保证了锁的互斥性,同一时间只有一个客户端能获取到锁。
  2. Key 的过期时间:为锁设置过期时间,防止锁的持有者因宕机、崩溃等异常情况无法主动释放锁,导致其他客户端永远无法获取锁(即死锁)。

在 Go 语言中,我们通常不直接使用SETNX命令(单独使用SETNX+EXPIRE存在原子性问题),而是使用 Redis 的SET命令的组合参数,实现「原子性设置锁 + 过期时间」,核心参数如下:

  • NX:等价于SETNX,仅当 key 不存在时设置
  • PX/EX:设置 key 的过期时间,PX单位为毫秒,EX单位为秒

Go 语言中(以redis-v6客户端为例),原子性获取锁的核心调用如下:

// 原子性设置锁:key不存在才设置,同时设置过期时间,返回是否成功
ok, err := redisClient.SetNX(ctx, lockKey, requestID, expireTime)
// 或更灵活的SET命令(支持更多参数)
status, err := redisClient.Do(ctx, "SET", lockKey, requestID, "NX", "PX", expireTime.Milliseconds())

二、坑 1:锁的持有时间超过过期时间(锁提前失效)

问题描述

这是 Redis 分布式锁最常见的坑点。假设我们给锁设置了 30 秒过期时间,但客户端的业务逻辑执行了 40 秒(比如复杂计算、远程调用超时),那么在第 30 秒时,锁会自动过期释放,而此时客户端的业务逻辑还在执行。

此时其他客户端就可以获取到同一把锁,多个客户端同时操作共享资源,破坏了锁的互斥性,可能导致数据错乱、重复执行等问题。

根本原因

  1. 业务逻辑执行时间不可控,无法精准预估锁的持有时间
  2. 锁的过期时间是「静态」的,一旦设置无法自动延长
  3. 客户端未实现锁的「续约机制」,无法在业务逻辑未执行完成时延长锁的过期时间

解决方案:实现锁的自动续约(看门狗机制)

核心思路:客户端获取锁成功后,启动一个后台协程(看门狗),定期检查锁是否仍被当前客户端持有,若持有且即将过期,就延长锁的过期时间,直到业务逻辑执行完成或客户端异常退出。

Go 实战代码实现

package redislock

import (
	"context"
	"errors"
	"fmt"
	"time"

	redisv6 "code.byted.org/kv/redis-v6"
	"github.com/google/uuid"
)

// RedisLock Redis分布式锁结构体
type RedisLock struct {
	redisClient  redisv6.Client  // Redis客户端
	lockKey      string          // 锁的Key
	requestID    string          // 唯一请求ID,用于标识锁的持有者
	expireTime   time.Duration   // 锁的过期时间
	renewalTime  time.Duration   // 锁的续约间隔时间
	renewalTicker *time.Ticker   // 续约定时器
	stopChan     chan struct{}   // 停止续约的信号通道
	ctx          context.Context // 上下文
	cancel       context.CancelFunc// 上下文取消函数
}

// NewRedisLock 创建Redis分布式锁实例
// lockKey:锁的唯一标识
// expireTime:锁的过期时间(建议30秒左右)
// renewalTime:续约间隔时间(建议为过期时间的1/3,如10秒)
func NewRedisLock(client redisv6.Client, lockKey string, expireTime, renewalTime time.Duration) *RedisLock {
	// 生成唯一请求ID(用于标识当前锁持有者,避免误删其他客户端的锁)
	requestID := fmt.Sprintf("lock-%s-%s", uuid.New().String(), time.Now().Format("20060102150405"))
	ctx, cancel := context.WithCancel(context.Background())
	return &RedisLock{
		redisClient:  client,
		lockKey:      lockKey,
		requestID:    requestID,
		expireTime:   expireTime,
		renewalTime:  renewalTime,
		stopChan:     make(chan struct{}, 1),
		ctx:          ctx,
		cancel:       cancel,
	}
}

// Acquire 尝试获取分布式锁,并启动自动续约
func (l *RedisLock) Acquire() (bool, error) {
	// 原子性获取锁:SET NX PX(key不存在时设置,同时设置毫秒级过期时间)
	ok, err := l.redisClient.SetNX(l.ctx, l.lockKey, l.requestID, l.expireTime)
	if err != nil {
		return false, fmt.Errorf("获取锁失败:%w", err)
	}
	if !ok {
		// 锁已被其他客户端持有
		return false, nil
	}

	// 获取锁成功,启动自动续约协程(看门狗)
	l.startRenewal()
	return true, nil
}

// startRenewal 启动锁的自动续约机制
func (l *RedisLock) startRenewal() {
	// 初始化定时器,每隔renewalTime执行一次续约
	l.renewalTicker = time.NewTicker(l.renewalTime)
	go func() {
		defer l.renewalTicker.Stop()
		for {
			select {
			case <-l.ctx.Done():
				// 上下文取消,停止续约
				return
			case <-l.stopChan:
				// 主动停止续约
				return
			case <-l.renewalTicker.C:
				// 执行续约操作
				renewed, err := l.renewLock()
				if err != nil {
					fmt.Printf("锁续约失败:%v\n", err)
					return
				}
				if !renewed {
					fmt.Printf("锁已失效或被其他客户端持有,停止续约\n")
					return
				}
				fmt.Printf("锁续约成功,过期时间延长至%v\n", l.expireTime)
			}
		}
	}()
}

// renewLock 延长锁的过期时间(原子操作)
func (l *RedisLock) renewLock() (bool, error) {
	// 使用Lua脚本保证续约的原子性:仅当锁的持有者是当前客户端时,才延长过期时间
	renewScript := `
		if redis.call('GET', KEYS[1]) == ARGV[1] then
			return redis.call('PEXPIRE', KEYS[1], ARGV[2])
		else
			return 0
		end
	`
	// 执行Lua脚本
	result, err := l.redisClient.Eval(
		l.ctx,
		renewScript,
		[]string{l.lockKey},          // KEYS[1]:锁的Key
		l.requestID,                  // ARGV[1]:当前客户端的请求ID
		l.expireTime.Milliseconds(),  // ARGV[2]:新的过期时间(毫秒)
	)
	if err != nil {
		return false, fmt.Errorf("执行续约Lua脚本失败:%w", err)
	}

	// 脚本返回1表示续约成功,0表示续约失败(锁已失效或被其他客户端持有)
	resultInt, ok := result.(int64)
	if !ok {
		return false, errors.New("续约脚本返回结果格式异常")
	}

	return resultInt == 1, nil
}

关键说明

  1. 续约间隔时间建议设置为锁过期时间的 1/3(如过期时间 30 秒,续约间隔 10 秒),避免因网络延迟导致续约不及时。
  2. 续约操作使用 Lua 脚本保证原子性,先校验锁的持有者是否为当前客户端,再延长过期时间,防止给其他客户端的锁续约。
  3. 通过Tickerchan实现优雅的续约启停,避免协程泄露。

三、坑 2:解锁失败导致死锁(解锁操作非原子性)

问题描述

解锁操作看似简单,只需删除锁的 Key 即可,但如果忽略分布式场景的并发特性,很容易导致解锁失败,进而引发死锁。

最常见的错误解锁逻辑如下(伪代码):

// 错误示例:两步操作非原子性
func (l *RedisLock) WrongRelease() error {
	// 1. 先获取锁的当前值,判断是否为当前客户端的requestID
	lockValue, err := l.redisClient.Get(l.ctx, l.lockKey)
	if err != nil {
		return err
	}
	if lockValue != l.requestID {
		return errors.New("锁非当前客户端持有")
	}
	// 2. 再删除锁的Key
	return l.redisClient.Del(l.ctx, l.lockKey)
}

上述代码存在严重的原子性问题:假设客户端执行完第一步判断(确认锁是自己的)后,锁恰好过期,此时其他客户端已经获取到了这把锁,而当前客户端继续执行第二步删除操作,就会导致误删其他客户端的锁,同时如果当前客户端删除失败(如网络异常),锁会一直存在,导致后续客户端无法获取锁,引发死锁。

根本原因

解锁操作的「校验持有者」和「删除 Key」是两个独立的 Redis 命令,无法保证原子性,中间可能被其他操作打断(如锁过期、网络延迟)。

解决方案:使用 Lua 脚本保证解锁操作的原子性

Lua 脚本可以在 Redis 服务器端原子性地执行多个命令,将「校验持有者」和「删除 Key」合并为一个原子操作,避免中间被打断的问题。

Go 实战代码实现

// 继续在RedisLock结构体中补充解锁方法
// Release 安全释放分布式锁(原子操作)
func (l *RedisLock) Release() error {
	// 停止自动续约
	l.stopChan <- struct{}{}
	l.cancel()

	// 解锁Lua脚本:先校验锁的持有者,再删除锁,保证原子性
	unlockScript := `
		if redis.call('GET', KEYS[1]) == ARGV[1] then
			return redis.call('DEL', KEYS[1])
		else
			return 0
		end
	`

	// 执行Lua脚本
	result, err := l.redisClient.Eval(
		l.ctx,
		unlockScript,
		[]string{l.lockKey},  // KEYS[1]:锁的Key
		l.requestID,          // ARGV[1]:当前客户端的请求ID
	)
	if err != nil {
		return fmt.Errorf("执行解锁Lua脚本失败:%w", err)
	}

	// 解析脚本返回结果
	resultInt, ok := result.(int64)
	if !ok {
		return errors.New("解锁脚本返回结果格式异常")
	}

	if resultInt == 0 {
		return errors.New("解锁失败:锁已失效或非当前客户端持有")
	}

	fmt.Printf("锁释放成功\n")
	return nil
}

关键说明

  1. 解锁前先停止自动续约,避免续约协程继续操作已释放的锁。
  2. Lua 脚本的逻辑清晰:仅当GET到的锁值与当前客户端的requestID一致时,才执行DEL命令删除锁,否则返回 0 表示解锁失败。
  3. 原子性操作保证了即使在高并发场景下,也不会出现误解锁或解锁失败的问题,从根本上避免死锁。

四、坑 3:锁的误删(未标识锁的持有者)

问题描述

如果所有客户端使用相同的锁值(如固定字符串"locked"),那么任何一个客户端都可以删除其他客户端持有的锁,这就是「锁的误删」问题。

举个例子:

  1. 客户端 A 获取锁成功,设置锁值为"locked",过期时间 30 秒。
  2. 客户端 A 的业务逻辑执行超时,锁在 30 秒后自动过期。
  3. 客户端 B 获取到同一把锁,设置锁值仍为"locked"
  4. 客户端 A 此时业务逻辑执行完成,执行解锁操作,删除了客户端 B 持有的锁。

最终导致客户端 B 的锁被误删,后续其他客户端可以继续获取锁,破坏了互斥性。

根本原因

未为每个客户端分配唯一的标识,无法区分锁的持有者,导致任何客户端都可以随意删除锁。

解决方案:为每个客户端生成唯一请求 ID

核心思路:

  1. 每个客户端在获取锁时,生成一个唯一的requestID(如 UUID + 时间戳),作为锁的值存入 Redis。
  2. 解锁时,仅当锁的值与当前客户端的requestID一致时,才执行解锁操作。
  3. 这个requestID就是锁的「持有者标识」,确保只有锁的持有者才能释放锁,从根本上避免误删。

Go 实战代码强化(已集成在前面的示例中)

我们在前面的代码中,已经实现了唯一requestID的生成和校验,这里重点强调几个关键细节:

  1. 唯一requestID的生成
// 采用UUID+时间戳的方式,保证全局唯一性
requestID := fmt.Sprintf("lock-%s-%s", uuid.New().String(), time.Now().Format("20060102150405"))

UUID 保证了不同客户端、不同时间的请求唯一性,时间戳便于问题排查和日志分析。

  1. 获取锁时存储requestID
// 原子性设置锁时,将requestID作为锁的值存入Redis
ok, err := l.redisClient.SetNX(l.ctx, l.lockKey, l.requestID, l.expireTime)
  1. 解锁时校验requestID
// 在Lua脚本中,通过ARGV[1]传入requestID,与锁的值进行比对
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end

额外补充:避免重复生成requestID

每个RedisLock实例对应一个唯一的requestID,在实例创建时生成,避免多次获取锁时生成不同的requestID,导致无法解锁自身持有的锁。