Redis 分布式锁与原子操作:SET NX EX、Redlock 与 Lua 脚本

Redis 分布式锁实现:单实例 SET NX EX、Redlock 算法、Lua 脚本原子性、看门狗续期与生产实践

在分布式系统中,多个进程或服务节点需要协调对共享资源的访问。Java 中常用的 synchronized 关键字和 ReentrantLock 只能在单个 JVM 内生效,无法跨越进程边界。Redis 分布式锁正是解决跨进程、跨机器互斥问题的经典方案。本文将深入剖析 Redis 分布式锁的完整技术体系,从基础的单实例实现到 Redlock 多节点算法,再到 Lua 脚本原子操作、看门狗续期机制以及 Go 语言的完整工程实现,帮助你系统掌握该技术在生产环境中的应用。

1. 为什么需要分布式锁

1.1 单机锁的局限性

在单体应用中,线程同步可以通过以下方式实现:

import "sync"

var mu sync.Mutex

func processResource() {
    mu.Lock()
    defer mu.Unlock()
    // 访问共享资源
}

这种方式的依赖前提是所有竞争者都在同一个进程中。但在微服务架构下,订单服务、库存服务、支付服务部署在不同机器上,它们可能需要同时操作数据库中的同一行记录或者调用某个外部 API。单机锁无法感知其他进程的存在,竞争条件(Race Condition)问题随之而来。

1.2 分布式锁需要满足的条件

一个可靠的分布式锁应该满足四个核心条件:

条件说明
互斥性任意时刻,只有一个客户端持有锁
防死锁即使持有锁的客户端崩溃,锁也能被自动释放
可重入同一个客户端可以多次获取同一锁而不导致死锁
一致性主节点宕机时,锁状态不应丢失或出现"双主持有"

Redis 作为分布式锁的载体具有天然优势:

  1. 单线程模型:Redis 的命令执行是单线程的,天然具备原子性保证。
  2. 高性能:加锁和解锁操作是简单的内存写入,延迟通常在微秒级别。
  3. 过期机制:支持为 Key 设置生存时间(TTL),天然满足防死锁条件。
  4. 广泛部署:几乎每个后端项目都依赖 Redis,无需额外引入组件。

2. 单实例锁:SET NX EX

2.1 原子命令的正确用法

Redis 分布式锁的最基础实现依赖一条原子命令:

SET resource_lock my_random_value NX EX 30

这条命令四个部分的含义如下:

  • resource_lock:锁的名称,对应被保护的资源标识。
  • my_random_value:一个全局唯一的值,推荐使用 UUID 或随机字符串。
  • NX(Not eXists):仅当 Key 不存在时才设置成功,实现互斥。
  • EX 30:设置 Key 在 30 秒后过期,防止死锁。

重要:早期实现中有些方案使用 SETNX + EXPIRE 两条命令。这在两条命令之间如果服务崩溃,锁将永远不会释放。必须始终使用 SET ... NX EX 单条原子命令。

2.2 为什么锁的值必须是唯一的

释放锁时,客户端需要验证持有锁的身份。如果进程 A 持有锁,但由于 GC 停顿或网络延迟导致锁过期,此时进程 B 成功获取了锁。如果进程 A 随后执行释放操作时不验证身份,就会错误地释放进程 B 的锁,这就是"误删"问题。

正确的释放逻辑应该是这样的流程:

1. 获取 Key 对应的 Value
2. 比较 Value 是否与当前客户端设置的值相等
3. 仅当相等时才执行 DEL 删除

第 2 步和第 3 步之间虽然极短,但在高并发场景下仍存在竞争窗口。后面会介绍用 Lua 脚本将这三个步骤原子化。

2.3 锁的过期时间如何设定

过期时间的设定需要权衡两个矛盾因素:

  • 时间过短:如果业务逻辑执行时间超过锁的 TTL,锁会在业务完成前过期,其他客户端趁机获取锁,造成并发问题。
  • 时间过长:如果客户端崩溃,锁的存活时间会很长,影响系统可用性。

推荐做法:

  1. 为锁设置合理的基础 TTL(如 30 秒),确保正常业务能在到期前完成。
  2. 使用"看门狗"机制在业务执行期间动态续期。
  3. 在业务代码中加入超时保护(Context + Deadline),确保即使锁未释放,业务也不会无限等待。

3. 看门狗续期策略

3.1 为什么要续期

在微服务中,一个请求可能涉及远程调用、数据库事务、消息队列发消息等耗时操作,执行时间很难精确预估。固定 TTL 面临两难:设长了影响可用性,设短了导致锁提前释放。看门狗(Watchdog)模式通过后台线程定期为锁续期,解决了这个矛盾。

3.2 看门狗的核心逻辑

// WatchDog 自动为锁续期
type WatchDog struct {
    client     *redis.Client
    key        string
    value      string
    ttl        time.Duration
    stopCh     chan struct{}
    wg         sync.WaitGroup
}

// Start 启动看门狗,每 ttl/3 时间续期一次
func (w *WatchDog) Start() {
    w.wg.Add(1)
    interval := w.ttl / 3
    ticker := time.NewTicker(interval)
    go func() {
        defer w.wg.Done()
        for {
            select {
            case <-ticker.C:
                // 使用 PEXPIRE 续期
                ok, err := w.client.Expire(context.Background(), w.key, w.ttl).Result()
                if err != nil || !ok {
                    // 续期失败:可能锁已被释放或 Redis 故障
                    return
                }
            case <-w.stopCh:
                ticker.Stop()
                return
            }
        }
    }()
}

// Stop 停止看门狗
func (w *WatchDog) Stop() {
    close(w.stopCh)
    w.wg.Wait()
}

续期间隔选择为 TTL 的三分之一是一个经验值,例如 TTL 为 30 秒,则每 10 秒续期一次。这样即使一次续期请求丢失或延迟,后续仍有足够的重试窗口,避免锁在两次续期之间过期。

3.3 防止"无限续期"

一个常见坑是:如果业务逻辑本身陷入死循环或严重阻塞,看门狗将无限制地为锁续期,导致其他客户端永远无法获取锁。解决方案是:

  1. 设置总续期次数上限(如最多续期 10 次)。
  2. 业务代码必须通过 Context 设置最大执行时间。
  3. 监控锁的持有时间,超过阈值时告警。
const maxRenewals = 10

func (w *WatchDog) StartWithLimit() {
    var renewalCount int
    interval := w.ttl / 3
    ticker := time.NewTicker(interval)
    // ...
    for {
        select {
        case <-ticker.C:
            renewalCount++
            if renewalCount > maxRenewals {
                // 超过续期上限,放弃续期,让锁自然过期
                return
            }
            w.client.Expire(context.Background(), w.key, w.ttl)
        // ...
        }
    }
}

4. Redlock 算法:多实例加锁

4.1 单实例 Redis 的隐患

单实例 Redis 作为主从架构运行,主节点负责读写,从节点仅做数据备份。在以下场景中,单实例锁不可靠:

  1. 客户端 A 在主节点加锁成功。
  2. 主节点尚未将锁数据同步到从节点就宕机了。
  3. 从节点被提升为新主节点。
  4. 客户端 B 在新主节点上加锁成功。
  5. 此时 A 和 B 同时认为持有锁,互斥性被破坏。

4.2 Redlock 算法原理

Redlock 由 Redis 作者 Salvatore Sanfilippo 提出,核心思想是:客户端向多个独立的 Redis 实例申请加锁,当成功获取大多数实例(超过半数)的锁,且总耗时小于锁的 TTL 时,才认为加锁成功。

Redlock 的完整步骤:

  1. 获取当前 Unix 时间戳(毫秒精度)。
  2. 依次向 N 个独立的 Redis 实例发送 SET resource_lock my_random_value NX PX ttl 命令(每次请求都设置合理的连接和命令超时)。
  3. 统计成功获取锁的实例数量。
  4. 计算从步骤 1 到当前的总耗时。
  5. 如果成功获取锁的实例数 >= N/2 + 1,且总耗时小于锁的有效期,则加锁成功。
  6. 如果加锁失败,向所有实例发送释放锁的脚本(无论之前是否成功在该实例加锁)。
  7. 如果加锁成功,锁的实际有效时间 = 初始 TTL 减去步骤 4 的耗时。
// Redlock 的简单示意
type Redlock struct {
    clients []*redis.Client
    quorum  int
}

type LockResult struct {
    Success bool
    Value   string
    Validity time.Duration
}

func (r *Redlock) Lock(resource string, ttl time.Duration) (*LockResult, error) {
    value := generateUniqueValue()
    startTime := time.Now()
    acquired := 0

    for _, client := range r.clients {
        ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
        ok, err := client.SetNX(ctx, resource, value, ttl).Result()
        cancel()

        if err == nil && ok {
            acquired++
        }
    }

    elapsed := time.Since(startTime)
    validity := ttl - elapsed - clockDriftFactor

    if acquired >= r.quorum && validity > 0 {
        return &LockResult{
            Success:  true,
            Value:    value,
            Validity: validity,
        }, nil
    }

    // 加锁失败,释放所有已获取的锁
    for _, client := range r.clients {
        releaseLock(client, resource, value)
    }

    return &LockResult{Success: false}, nil
}

4.3 时钟漂移问题

Redlock 算法的一个争议点是时钟漂移。如果不同 Redis 节点之间系统时钟存在偏差,可能出现以下场景:

  • 节点 A 的时钟比节点 B 快。客户端在 A 上加的锁实际过期时间更早,但客户端按自己最晚收到的成功响应计算有效期。
  • 另一个客户端 B 在当前锁"逻辑上"尚未过期但在某个节点的物理时钟上已过期时获取了锁。

应对策略:

  1. ntp 同步:各节点启用 ntp 时间同步,将漂移控制在毫秒级。
  2. 减去漂移缓冲:在计算 validity 时预留一个 clock drift 余量(如 TTL 的 1-2%)。
  3. 使用单调时钟:如果 Redis 版本支持,优先使用单调时钟(monotonic clock)而非墙上时间(wall clock)。
const clockDriftFactor = 10 * time.Millisecond // 预留 10ms 漂移缓冲

validity := ttl - elapsed - clockDriftFactor

4.4 Redlock 的争论

分布式系统专家 Martin Kleppmann 曾在文章中质疑 Redlock 的正确性,认为在异步网络模型下无法保证安全性。主要论点包括:

  1. 如果请求 Redis 时发生 GC 停顿或网络分区,锁可能已经过期但客户端并不知情。
  2. 时钟同步不是绝对的,不可靠时钟基础上的协议存在安全隐患。

在日常生产实践中,Redlock 的正确性高度依赖以下前提:

  • Redis 实例独立运行,不使用主从复制(加锁期间的数据持久化)。
  • 各实例时钟同步良好。
  • 客户端设置合理的请求超时,超时即视为失败。
  • 业务对锁的短暂失效有容错能力(即锁并非绝对刚性约束)。

对于绝大多数业务场景,单实例 Redis 配合 AOF 持久化(appendfsync always)加上哨兵或集群的高可用方案已足够可靠。

5. Lua 脚本原子操作

5.1 为什么需要原子释放

释放锁时需要执行"先检查再删除"的操作:

GET resource_lock
# 比较值是否等于 my_random_value
DEL resource_lock

这是两条独立的 Redis 命令,中间存在极短的竞态窗口。在高并发下可能导致另一个客户端刚获取的锁被误删。

Redis 的 Lua 脚本执行是原子的——脚本在执行期间不会被其他命令打断。将释放逻辑封装在 Lua 脚本中即可消除竞态条件。

5.2 标准释放脚本

-- unlock.lua
-- KEYS[1] = 锁的 key
-- ARGV[1] = 加锁时设置的 value(客户端唯一标识)

local lock_value = redis.call("get", KEYS[1])

if lock_value == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

在 Go 中调用:

const unlockScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
`

func releaseLock(client *redis.Client, key, value string) error {
    res, err := client.Eval(context.Background(), unlockScript, []string{key}, value).Result()
    if err != nil {
        return err
    }
    if res.(int64) == 1 {
        // 释放成功
        return nil
    }
    // 可能锁已不存在,或已被其他客户端持有
    return fmt.Errorf("lock not held by this client or already expired")
}

5.3 用 Lua 实现可重入锁

可重入锁记录当前持有者以及重入次数。加锁和解锁都需通过 Lua 脚本保证原子性。

-- reentrant_lock.lua (加锁部分)
local key = KEYS[1]
local value = ARGV[1]
local ttl = tonumber(ARGV[2])

local current = redis.call("get", key)

if current == false then
    -- 锁不存在,创建锁,设置重入次数为 1
    redis.call("set", key, value..":1", "EX", ttl)
    return 1
end

local stored_value = string.match(current, "^(.+):%d+$")
local stored_count = tonumber(string.match(current, ":(%d+)$"))

if stored_value == value then
    -- 同一个客户端重入,次数加 1
    redis.call("set", key, value..":"..(stored_count + 1), "EX", ttl)
    return stored_count + 1
end

-- 锁被其他客户端持有
return 0

解锁时对应递减重入次数,仅当次数归零时才真正删除 Key。

5.4 Redis 事务的局限

有些开发者会问:为什么不用 WATCH/MULTI/EXEC 事务?问题在于 Redis 事务不支持回滚条件判断WATCH 可以在 Key 被修改时取消事务,但无法做到"如果值等于 X 则删除"这样的条件逻辑。Lua 脚本才是真正支持复杂原子操作的方案。

# 以下事务无法实现条件删除
MULTI
GET resource_lock     # 事务中 GET 的结果在下一条命令中不可用
DEL resource_lock     # 无条件执行
EXEC

6. Go + Redis 分布式锁完整实现

下面是一个生产级别的分布式锁实现,包含加锁、解锁、看门狗续期、可重入等功能。

package redislock

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "fmt"
    "sync"
    "time"

    "github.com/redis/go-redis/v9"
)

var (
    unlockScript = `
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
    `
    // 用于可重入锁,此处省略可重入实现以节省篇幅
)

type DistributedLock struct {
    client      *redis.Client
    key         string
    value       string
    ttl         time.Duration
    watchDog    *WatchDog
    mu          sync.Mutex
    isLocked    bool
}

type WatchDog struct {
    client      *redis.Client
    key         string
    value       string
    ttl         time.Duration
    stopCh      chan struct{}
    wg          sync.WaitGroup
}

func NewDistributedLock(client *redis.Client, key string, ttl time.Duration) *DistributedLock {
    return &DistributedLock{
        client: client,
        key:    key,
        ttl:    ttl,
    }
}

// generateUniqueValue 生成 16 字节随机值,hex 编码后 32 字符
func generateUniqueValue() string {
    b := make([]byte, 16)
    if _, err := rand.Read(b); err != nil {
        panic(err)
    }
    return hex.EncodeToString(b)
}

// Lock 尝试获取分布式锁,使用默认上下文超时
func (dl *DistributedLock) Lock() error {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    return dl.LockWithContext(ctx)
}

// LockWithContext 支持自定义上下文的加锁
func (dl *DistributedLock) LockWithContext(ctx context.Context) error {
    dl.mu.Lock()
    defer dl.mu.Unlock()

    if dl.isLocked {
        return fmt.Errorf("lock already held")
    }

    value := generateUniqueValue()

    for {
        ok, err := dl.client.SetNX(ctx, dl.key, value, dl.ttl).Result()
        if err != nil {
            return fmt.Errorf("redis error: %w", err)
        }

        if ok {
            // 加锁成功,记录状态并启动看门狗
            dl.value = value
            dl.isLocked = true
            dl.watchDog = &WatchDog{
                client: dl.client,
                key:    dl.key,
                value:  value,
                ttl:    dl.ttl,
                stopCh: make(chan struct{}),
            }
            dl.watchDog.Start()
            return nil
        }

        // 自旋等待策略:推荐使用 Exponential Backoff
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(100 * time.Millisecond):
            // 继续尝试
        }
    }
}

// Unlock 释放分布式锁
func (dl *DistributedLock) Unlock() error {
    dl.mu.Lock()
    defer dl.mu.Unlock()

    if !dl.isLocked {
        return fmt.Errorf("lock not held")
    }

    // 先停止看门狗
    if dl.watchDog != nil {
        dl.watchDog.Stop()
        dl.watchDog = nil
    }

    // 执行原子释放
    res, err := dl.client.Eval(context.Background(), unlockScript, []string{dl.key}, dl.value).Result()
    if err != nil {
        dl.isLocked = false
        return fmt.Errorf("release lock failed: %w", err)
    }

    dl.isLocked = false

    if res.(int64) == 0 {
        return fmt.Errorf("lock was not held by this client or already expired")
    }

    return nil
}

// IsLocked 返回当前锁状态
func (dl *DistributedLock) IsLocked() bool {
    dl.mu.Lock()
    defer dl.mu.Unlock()
    return dl.isLocked
}

func (w *WatchDog) Start() {
    w.wg.Add(1)
    interval := w.ttl / 3
    if interval < time.Second {
        interval = time.Second
    }

    go func() {
        defer w.wg.Done()
        ticker := time.NewTicker(interval)
        defer ticker.Stop()

        for {
            select {
            case <-ticker.C:
                // 续期前先校验锁是否仍属于自己
                val, err := w.client.Get(context.Background(), w.key).Result()
                if err != nil || val != w.value {
                    // 锁已丢失或已被其他客户端持有
                    return
                }

                ok, err := w.client.Expire(context.Background(), w.key, w.ttl).Result()
                if err != nil || !ok {
                    return
                }
            case <-w.stopCh:
                return
            }
        }
    }()
}

func (w *WatchDog) Stop() {
    close(w.stopCh)
    w.wg.Wait()
}

6.1 使用示例

package main

import (
    "context"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
    "redislock" // 替换为实际包路径
)

func main() {
    rdb := redis.NewClient(&redis.Options{
        Addr:     "localhost:6379",
        Password: "",
        DB:       0,
    })

    lock := redislock.NewDistributedLock(rdb, "order:123:stock", 30*time.Second)

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    if err := lock.LockWithContext(ctx); err != nil {
        fmt.Println("获取锁失败:", err)
        return
    }

    defer func() {
        if err := lock.Unlock(); err != nil {
            fmt.Println("释放锁失败:", err)
        }
    }()

    // 执行业务逻辑
    fmt.Println("获取锁成功,执行业务逻辑...")
    time.Sleep(5 * time.Second) // 模拟耗时操作
    fmt.Println("业务逻辑执行完毕")
}

7. 生产最佳实践

7.1 key 命名规范

锁的 Key 命名应该清晰、唯一,建议采用以下模式:

lock:{resource_type}:{resource_id}

例如:

  • lock:order:1000001 表示订单 1000001 的锁
  • lock:user:8888:daily_bonus 表示用户 8888 的每日签到锁

避免使用简单的 Key 名如 mylock,在大系统中极易发生冲突。

7.2 value 的选择

value 的作用是标识锁的持有者,需要满足:

  1. 全局唯一:避免不同客户端生成相同 value。
  2. 不可伪造:防止恶意客户端伪造 value 释放他人锁。
  3. 可追踪:包含足够信息以便排查问题。

推荐的数据包含:客户端标识(IP/主机名)+ 随机值 + 时间戳 + 请求 TraceID。

func generateLockValue(nodeID, traceID string) string {
    b := make([]byte, 8)
    rand.Read(b)
    randomPart := hex.EncodeToString(b)
    ts := time.Now().UnixMilli()
    return fmt.Sprintf("%s:%s:%d:%s", nodeID, traceID, ts, randomPart)
}

7.3 设置合理的 TTL

场景建议 TTL说明
电商秒杀扣减库存5-10 秒操作极快,过期时间应尽可能短
数据库事务30-60 秒涉及多表操作,预留一定时间
定时任务调度120-300 秒可能涉及复杂的数据处理
分布式计算分片600 秒以上计算任务通常耗时较长,配合看门狗使用

同时务必将业务代码的执行时间限制在锁 TTL 的 1/3 以内。例如锁 TTL 为 30 秒,业务代码应使用 Context 限制在 10 秒内完成。

7.4 重试与退避策略

获取锁失败时不要立即无限重试,这会给 Redis 造成压力。使用退避策略:

func acquireWithBackoff(ctx context.Context, lock *DistributedLock) error {
    maxRetries := 5
    baseDelay := 50 * time.Millisecond
    maxDelay := 2 * time.Second

    for i := 0; i < maxRetries; i++ {
        err := lock.LockWithContext(ctx)
        if err == nil {
            return nil
        }

        // 指数退避 + 随机抖动
        delay := baseDelay * time.Duration(1<<i) // 2^n
        if delay > maxDelay {
            delay = maxDelay
        }
        jitter := time.Duration(rand.Int63n(int64(delay) / 2))
        time.Sleep(delay + jitter)
    }

    return fmt.Errorf("failed to acquire lock after %d retries", maxRetries)
}

7.5 避免单点故障

  • 高可用部署:Redis 使用主从 + Sentinel 哨兵模式或 Redis Cluster,确保单节点宕机不影响服务。
  • 持久化策略:打开 AOF(appendfsync everysec),在性能和数据可靠性之间取得平衡。但如果要求严格不丢锁,应使用 appendfsync always
  • 连接池配置:合理配置连接池大小、超时(ReadTimeout/WriteTimeout/DialTimeout),避免因连接问题导致锁误判。
  • 监控告警:对锁的持有时间、获取失败率、获取延迟等指标进行监控,通过 Prometheus + Grafana 可视化并在异常时触发告警。

7.6 测试你的锁实现

任何分布式锁实现都必须经过严格测试。以下是必须覆盖的测试场景:

// 1. 基础互斥性测试
func TestMutualExclusion(t *testing.T) {
    // 两个 goroutine 同时竞争,只有一个能获取锁
}

// 2. 锁过期释放测试
func TestLockExpiry(t *testing.T) {
    // 故意不释放锁,验证 TTL 到期后其他客户端能否获取
}

// 3. 误删防护测试
func TestNoCrossDelete(t *testing.T) {
    // 客户端 A 持有锁,客户端 B 尝试释放 A 的锁,应失败
}

// 4. 看门狗续期测试
func TestWatchdogRenewal(t *testing.T) {
    // 模拟业务执行时间超过 TTL,验证锁是否被正确续期
}

// 5. 高并发竞争测试
func TestHighContention(t *testing.T) {
    // 100 个 goroutine 同时竞争 1 个锁,统计获取成功数和性能
}

总结

Redis 分布式锁是后端开发中不可或缺的工具。本文从为什么需要分布式锁出发,逐层深入讲解了单实例锁的正确用法、看门狗续期策略、Redlock 多实例算法、Lua 脚本的原子操作保证,并给出了完整的 Go 语言工程实现和 7 条生产实践建议。

回顾核心要点:

  1. 单实例锁使用 SET key value NX EX ttl 一条命令避免竞态,释放时用 Lua 脚本验证持有者身份。
  2. 看门狗通过后台定期续期解决业务执行时间不确定的问题,但要设置续期上限防止无限持有。
  3. Redlock通过多实例多数派加锁提升分布式环境下的可靠性,但需要注意时钟同步和漂移问题。
  4. Lua 脚本是 Redis 中实现原子条件的唯一正确方式,WATCH/MULTI/EXEC 不能满足条件删除的需求。
  5. 生产环境务必做好 key 命名规范、value 唯一性、TTL 合理设置、重试退避、高可用部署、监控告警和边界测试。

掌握这些知识后,你可以根据业务对一致性的要求,在单实例锁(简单场景)和 Redlock(高可用场景)之间做出合理选择,以最小化系统复杂度同时保障可靠性。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. 缓存架构演进之路:从单机 Redis 到亿级分布式多级缓存体系
  2. Redis 7.x 重大新特性与架构升级深度解析
  3. Redis 消息队列深度对比:Pub/Sub、Streams 与 Kafka/RabbitMQ 选型指南