在分布式系统中,多个进程或服务节点需要协调对共享资源的访问。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 作为分布式锁的载体具有天然优势:
- 单线程模型:Redis 的命令执行是单线程的,天然具备原子性保证。
- 高性能:加锁和解锁操作是简单的内存写入,延迟通常在微秒级别。
- 过期机制:支持为 Key 设置生存时间(TTL),天然满足防死锁条件。
- 广泛部署:几乎每个后端项目都依赖 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,锁会在业务完成前过期,其他客户端趁机获取锁,造成并发问题。
- 时间过长:如果客户端崩溃,锁的存活时间会很长,影响系统可用性。
推荐做法:
- 为锁设置合理的基础 TTL(如 30 秒),确保正常业务能在到期前完成。
- 使用"看门狗"机制在业务执行期间动态续期。
- 在业务代码中加入超时保护(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 防止"无限续期"
一个常见坑是:如果业务逻辑本身陷入死循环或严重阻塞,看门狗将无限制地为锁续期,导致其他客户端永远无法获取锁。解决方案是:
- 设置总续期次数上限(如最多续期 10 次)。
- 业务代码必须通过 Context 设置最大执行时间。
- 监控锁的持有时间,超过阈值时告警。
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 作为主从架构运行,主节点负责读写,从节点仅做数据备份。在以下场景中,单实例锁不可靠:
- 客户端 A 在主节点加锁成功。
- 主节点尚未将锁数据同步到从节点就宕机了。
- 从节点被提升为新主节点。
- 客户端 B 在新主节点上加锁成功。
- 此时 A 和 B 同时认为持有锁,互斥性被破坏。
4.2 Redlock 算法原理
Redlock 由 Redis 作者 Salvatore Sanfilippo 提出,核心思想是:客户端向多个独立的 Redis 实例申请加锁,当成功获取大多数实例(超过半数)的锁,且总耗时小于锁的 TTL 时,才认为加锁成功。
Redlock 的完整步骤:
- 获取当前 Unix 时间戳(毫秒精度)。
- 依次向 N 个独立的 Redis 实例发送
SET resource_lock my_random_value NX PX ttl命令(每次请求都设置合理的连接和命令超时)。 - 统计成功获取锁的实例数量。
- 计算从步骤 1 到当前的总耗时。
- 如果成功获取锁的实例数
>= N/2 + 1,且总耗时小于锁的有效期,则加锁成功。 - 如果加锁失败,向所有实例发送释放锁的脚本(无论之前是否成功在该实例加锁)。
- 如果加锁成功,锁的实际有效时间 = 初始 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 在当前锁"逻辑上"尚未过期但在某个节点的物理时钟上已过期时获取了锁。
应对策略:
- ntp 同步:各节点启用 ntp 时间同步,将漂移控制在毫秒级。
- 减去漂移缓冲:在计算 validity 时预留一个 clock drift 余量(如 TTL 的 1-2%)。
- 使用单调时钟:如果 Redis 版本支持,优先使用单调时钟(monotonic clock)而非墙上时间(wall clock)。
const clockDriftFactor = 10 * time.Millisecond // 预留 10ms 漂移缓冲
validity := ttl - elapsed - clockDriftFactor
4.4 Redlock 的争论
分布式系统专家 Martin Kleppmann 曾在文章中质疑 Redlock 的正确性,认为在异步网络模型下无法保证安全性。主要论点包括:
- 如果请求 Redis 时发生 GC 停顿或网络分区,锁可能已经过期但客户端并不知情。
- 时钟同步不是绝对的,不可靠时钟基础上的协议存在安全隐患。
在日常生产实践中,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 的作用是标识锁的持有者,需要满足:
- 全局唯一:避免不同客户端生成相同 value。
- 不可伪造:防止恶意客户端伪造 value 释放他人锁。
- 可追踪:包含足够信息以便排查问题。
推荐的数据包含:客户端标识(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 条生产实践建议。
回顾核心要点:
- 单实例锁使用
SET key value NX EX ttl一条命令避免竞态,释放时用 Lua 脚本验证持有者身份。 - 看门狗通过后台定期续期解决业务执行时间不确定的问题,但要设置续期上限防止无限持有。
- Redlock通过多实例多数派加锁提升分布式环境下的可靠性,但需要注意时钟同步和漂移问题。
- Lua 脚本是 Redis 中实现原子条件的唯一正确方式,
WATCH/MULTI/EXEC不能满足条件删除的需求。 - 生产环境务必做好 key 命名规范、value 唯一性、TTL 合理设置、重试退避、高可用部署、监控告警和边界测试。
掌握这些知识后,你可以根据业务对一致性的要求,在单实例锁(简单场景)和 Redlock(高可用场景)之间做出合理选择,以最小化系统复杂度同时保障可靠性。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。