在分布式系统中,多个节点对共享资源的并发访问是常见场景,而分布式锁是保证资源访问互斥性、维护数据一致性的核心机制。Redis 凭借其高性能、高可用的特性,成为实现分布式锁的主流选择。但在实际使用中,若忽略 Redis 的特性和分布式场景的复杂性,很容易踩入各种坑,导致锁失效、数据错乱等严重问题。
本文将详细拆解 Redis 分布式锁的 3 个核心坑点,并通过Go 语言实战代码,演示如何正确实现健壮的 Redis 分布式锁,同时深入讲解背后的原理和设计思路。
一、Redis 分布式锁的基本原理
在深入坑点之前,我们先明确 Redis 分布式锁的核心实现逻辑,这是后续避坑的基础。
Redis 分布式锁的实现主要依赖两个核心特性:
SETNX命令(SET if Not Exists) :仅当指定 key 不存在时,才会设置 key 的值,返回成功;若 key 已存在,直接返回失败。该命令保证了锁的互斥性,同一时间只有一个客户端能获取到锁。- 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 秒时,锁会自动过期释放,而此时客户端的业务逻辑还在执行。
此时其他客户端就可以获取到同一把锁,多个客户端同时操作共享资源,破坏了锁的互斥性,可能导致数据错乱、重复执行等问题。
根本原因
- 业务逻辑执行时间不可控,无法精准预估锁的持有时间
- 锁的过期时间是「静态」的,一旦设置无法自动延长
- 客户端未实现锁的「续约机制」,无法在业务逻辑未执行完成时延长锁的过期时间
解决方案:实现锁的自动续约(看门狗机制)
核心思路:客户端获取锁成功后,启动一个后台协程(看门狗),定期检查锁是否仍被当前客户端持有,若持有且即将过期,就延长锁的过期时间,直到业务逻辑执行完成或客户端异常退出。
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/3(如过期时间 30 秒,续约间隔 10 秒),避免因网络延迟导致续约不及时。
- 续约操作使用 Lua 脚本保证原子性,先校验锁的持有者是否为当前客户端,再延长过期时间,防止给其他客户端的锁续约。
- 通过
Ticker和chan实现优雅的续约启停,避免协程泄露。
三、坑 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
}
关键说明
- 解锁前先停止自动续约,避免续约协程继续操作已释放的锁。
- Lua 脚本的逻辑清晰:仅当
GET到的锁值与当前客户端的requestID一致时,才执行DEL命令删除锁,否则返回 0 表示解锁失败。 - 原子性操作保证了即使在高并发场景下,也不会出现误解锁或解锁失败的问题,从根本上避免死锁。
四、坑 3:锁的误删(未标识锁的持有者)
问题描述
如果所有客户端使用相同的锁值(如固定字符串"locked"),那么任何一个客户端都可以删除其他客户端持有的锁,这就是「锁的误删」问题。
举个例子:
- 客户端 A 获取锁成功,设置锁值为
"locked",过期时间 30 秒。 - 客户端 A 的业务逻辑执行超时,锁在 30 秒后自动过期。
- 客户端 B 获取到同一把锁,设置锁值仍为
"locked"。 - 客户端 A 此时业务逻辑执行完成,执行解锁操作,删除了客户端 B 持有的锁。
最终导致客户端 B 的锁被误删,后续其他客户端可以继续获取锁,破坏了互斥性。
根本原因
未为每个客户端分配唯一的标识,无法区分锁的持有者,导致任何客户端都可以随意删除锁。
解决方案:为每个客户端生成唯一请求 ID
核心思路:
- 每个客户端在获取锁时,生成一个唯一的
requestID(如 UUID + 时间戳),作为锁的值存入 Redis。 - 解锁时,仅当锁的值与当前客户端的
requestID一致时,才执行解锁操作。 - 这个
requestID就是锁的「持有者标识」,确保只有锁的持有者才能释放锁,从根本上避免误删。
Go 实战代码强化(已集成在前面的示例中)
我们在前面的代码中,已经实现了唯一requestID的生成和校验,这里重点强调几个关键细节:
- 唯一
requestID的生成:
// 采用UUID+时间戳的方式,保证全局唯一性
requestID := fmt.Sprintf("lock-%s-%s", uuid.New().String(), time.Now().Format("20060102150405"))
UUID 保证了不同客户端、不同时间的请求唯一性,时间戳便于问题排查和日志分析。
- 获取锁时存储
requestID:
// 原子性设置锁时,将requestID作为锁的值存入Redis
ok, err := l.redisClient.SetNX(l.ctx, l.lockKey, l.requestID, l.expireTime)
- 解锁时校验
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,导致无法解锁自身持有的锁。