引言
在微服务架构日益流行的今天,分布式系统已经成为互联网应用的主流部署方式。随着系统被拆分成多个独立服务,一个关键挑战随之出现:如何在不同服务实例之间协调对共享资源的访问?分布式锁正是解决这一问题的核心技术。
本文将系统性地介绍分布式锁的理论基础,分析各种实现方案的优劣,并重点演示如何使用Go语言实现一个基于Redis的、可用于生产环境的分布式锁。
一、分布式锁理论基础
1.1 什么是分布式锁?
分布式锁是一种在分布式系统中协调多个进程/服务对共享资源进行互斥访问的机制。它与单机环境中的线程锁(如Mutex)有相似的目标,但面临更多挑战:网络延迟、节点故障、时钟不同步等。
1.2 分布式锁的核心特性
一个可靠的分布式锁必须满足以下四个基本要求:
- 互斥性:这是最基本的要求。在任意时刻,只有一个客户端能够持有锁。
- 安全性:锁只能由持有它的客户端释放,不能被其他客户端意外释放。这通常通过为每个锁设置一个唯一值(UUID)来保证。
- 避免死锁:锁必须有超时时间(租约),以保证即使客户端崩溃或发生网络分区,锁最终也能被释放,避免系统死锁。
- 容错性:只要大部分分布式锁组件节点正常运行,客户端就能获取和释放锁。但这引出了更复杂的分布式共识问题。
1.3 分布式锁的实现方案对比
常见的分布式锁实现方案包括:
- 基于数据库:使用唯一约束或乐观锁实现,简单但性能较差
- 基于ZooKeeper:通过临时顺序节点实现,强一致性但重量级
- 基于Redis:性能高,实现相对简单,是互联网公司的热门选择
- 基于Etcd:通过租约机制实现,强一致性,适合Kubernetes环境
- 基于Consul:通过Session机制实现,服务发现集成度高
方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
---|---|---|---|---|
数据库 | 弱 | 低 | 低 | 简单场景,低并发 |
ZooKeeper | 强 | 中 | 高 | 金融、政务等强一致性要求场景 |
Redis | 弱 | 高 | 中 | 互联网应用,高并发场景 |
Etcd | 强 | 中 | 中 | Kubernetes环境,云原生应用 |
Consul | 强 | 中 | 中 | 服务网格,多数据中心 |
本文将重点介绍基于Redis的实现方案。
二、基于Redis的单实例分布式锁实现
我们先从最简单的单Redis实例开始,这是理解所有问题的基础。我们将使用 github.com/go-redis/redis/v8
这个主流客户端。、
2.1 SET NX PX 命令实现分布式锁核心原理
Redis 命令:SET lock_key unique_value NX PX milliseconds
保证了锁的互斥性、安全释放和避免死锁问题
NX
:只有当lock_key
不存在时,才会设置它的值。如果设置成功,返回1
;如果 key 已存在(意味着锁已被其他客户端持有),则返回0
。这保证了"互斥性
"。PX
:同时设置过期时间(毫秒),避免了客户端崩溃后锁永远无法释放,解决了"死锁"问题。unique_value
: 使用UUID,这是实现安全释放的关键。释放锁时,需要检查这个值是否匹配。
2.2 代码实现
获取锁-SET NX PX
获取锁的核心是原子性地执行一个命令:如果键不存在则设置它,并同时设置过期时间。
go
package lock
import (
"context"
"github.com/go-redis/redis/v8"
"github.com/google/uuid"
"time"
)
// RedisLock Redis锁结构
type RedisLock struct {
client *redis.Client
key string
value string // 通常使用UUID,用于安全释放
expiration time.Duration
}
// NewRedisLock 创建一个锁实例
func NewRedisLock(client *redis.Client, key string, expiration time.Duration) *RedisLock {
return &RedisLock{
client: client,
key: key,
value: uuid.New().String(),
expiration: expiration,
}
}
func (lock *RedisLock) Acquire(ctx context.Context) (bool, error) {
result, err := lock.client.SetNX(ctx, lock.key, lock.value, lock.expiration).Result()
if err != nil {
return false, err
}
return result, nil
}
释放锁 - 使用Lua脚本
释放锁不是简单的 DEL
命令。必须先检查当前锁的值是否与加锁时设置的值相同,只有相同才能删除。这是一个"检查再删除"的操作,必须保证原子性,否则会有误删其他客户端锁的风险。
go
// Release 释放锁
func (lock *RedisLock) Release(ctx context.Context) (bool, error) {
// 1. Lua脚本:如果Redis中存储的值等于传入的 value,则执行删除操作
cadScript := `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`
script := redis.NewScript(cadScript)
// 2. 执行Lua脚本,传入锁的 key 和唯一的 value
result, err := script.Run(ctx, lock.client, []string{lock.key}, lock.value).Int64()
if err != nil {
return false, err
}
// 如果返回结果 > 0,代表删除成功(释放了锁)
return result > 0, nil
}
加锁解锁使用示例
go
package examples
import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
rLock "redis-distributed-lock/lock"
"sync"
"time"
)
var wg sync.WaitGroup
func TestBaseLock() {
ctx := context.Background()
lockKey := "my_service_name_" + "lock"
expiration := 10 * time.Second
// lock.Acquire(ctx)
count := 0
// 50 个携程对 count 进行 +1 操作 100 次
for i := 0; i < 50; i++ {
client := redis.NewClient(&redis.Options{
Addr: "192.168.36.109:6379",
})
// 1. 创建锁实例
lock := rLock.NewRedisLock(client, lockKey, expiration)
fmt.Printf("启动携程:%d\n", i+1)
time.Sleep(100)
wg.Add(1)
goroutineId := i
go func() {
defer wg.Done()
j := 0
for j < 100 {
// 2.1 尝试获取锁
success, err := lock.Acquire(ctx)
if err != nil {
// 2.2 获取锁出错,抛出异常
panic(err)
}
if !success {
// 2.3 没有获取到锁,重新尝试获取锁
continue
}
count++
time.Sleep(1 * time.Millisecond)
j++
// 3. 释放锁
release, err := lock.Release(ctx)
if err != nil {
panic(err)
}
if !release {
fmt.Printf("协程 %d 解锁失败\n", goroutineId)
}
if count%100 == 0 {
fmt.Printf("count = %d", count)
}
}
//fmt.Printf("count = %d", count)
}()
}
wg.Wait()
fmt.Printf("count = %d", count)
}
三、自动续期分布式锁
3.1 问题分析与解决方案
3.1.1 问题分析
以上基于单实例Redis场景实现的分布式锁,为了防止客户端崩溃锁无法释放,加了一个过期时间,在实际生产中,客户端并未崩溃,因为某种原因业务执行的时间过长超过了锁过期的时间,锁自动释放,这可能导致其他客户端获取到锁并操作共享资源,此时原来客户端仍然继续执行业务操作,数据一致性被破坏。
3.1.2 解决方案
为了防止持有锁的客户端崩溃导致死锁必须设置一个过期时间,但是由于设置过期时间太短或业务执行时间太长导致锁过期,需要引入自动续期
的功能("看门狗"机制),即在加锁成功后,开启一个定时任务,自动刷新Redis加锁的key的超时时间
3.2 代码实现
自动续期的加锁及测试
自动续期的加锁代码:
go
package nlock
import (
"context"
"github.com/go-redis/redis/v8"
"github.com/google/uuid"
"time"
)
// RedisLock 支持续期的分布式锁
type RedisLock struct {
client *redis.Client
key string
value string
expiration time.Duration
cancelRenewal context.CancelFunc // 用于停止续期的协程
}
// NewRedisLock 创建一个可续期锁实例
func NewRedisLock(client *redis.Client, key string, expiration time.Duration) *RedisLock {
return &RedisLock{
client: client,
key: key,
value: uuid.New().String(),
expiration: expiration,
}
}
// LockAndRenewal 获取锁并启动协程自动续期
func (lock *RedisLock) LockAndRenewal(ctx context.Context) (bool, error) {
// 1. 尝试获取锁
result, err := lock.client.SetNX(ctx, lock.key, lock.value, lock.expiration).Result()
if err != nil || !result {
// 2. 获取锁失败
return result, err
}
// 3. 成功获取锁,启动协程进行自动续期
renewalCtx, cancelFunc := context.WithCancel(ctx)
lock.cancelRenewal = cancelFunc
go lock.renewal(renewalCtx)
return true, nil
}
// renewal 自动续期
func (lock *RedisLock) renewal(ctx context.Context) {
ticker := time.NewTicker(lock.expiration / 3) // 在过期时间 1/3 时续期
defer ticker.Stop()
for {
select {
case <-ctx.Done():
// 主上下文已结束,停止续期
return
case <-ticker.C:
// 续期 lua 脚本:只有锁的持有者才能续期
// PEXPIRE 重新设置为固定毫秒数
renewalScript := `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end
`
script := redis.NewScript(renewalScript)
// 每次续期都将其重置为原过期时间 lock.expiration
err := script.Run(ctx, lock.client, []string{lock.key}, lock.value, lock.expiration.Milliseconds()).Err()
if err != nil {
// 续期失败,记录日志并推出
return
}
// fmt.Printf("续期成功\n")
}
}
}
测试代码:
go
func TestLockRenewal() {
ctx := context.Background()
lockKey := "my_service_name_" + "lock"
expiration := 6 * time.Second
client := redis.NewClient(&redis.Options{
Addr: "192.168.36.109:6379",
})
// 1. 创建锁实例
lock := nlock.NewRedisLock(client, lockKey, expiration)
success, err := lock.LockAndRenewal(ctx)
if err != nil {
panic(err)
}
if !success {
fmt.Printf("加锁失败\n")
}
// 新建一个客户端尝试去获取锁
client1 := redis.NewClient(&redis.Options{
Addr: "192.168.36.109:6379",
})
ctx2 := context.Background()
rLock := rLock.NewRedisLock(client1, lockKey, expiration)
fmt.Printf("新建一个客户端尝试获取锁")
for {
success, err := rLock.Acquire(ctx2)
if err != nil {
panic(err)
} else if !success {
fmt.Printf("新客户端获取锁失败\n")
} else {
fmt.Printf("获取锁成功\n")
}
}
}
运行程序可以看到控制台一直输出新客户端获取锁失败
,意味着持有可续期锁的客户端一直没有释放锁且没有过期。
解锁并停止续期
go
// Unlock 自动续期锁的解锁并停止续期
func (lock *RedisLock) Unlock(ctx context.Context) (bool, error) {
// 1. 先停止续期
if lock.cancelRenewal != nil {
lock.cancelRenewal()
}
// 2. 释放锁,使用 lua 脚本保证原子性
cadScript := `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`
script := redis.NewScript(cadScript)
result, err := script.Run(ctx, lock.client, []string{lock.key}, lock.value).Int64()
if err != nil {
return false, nil
}
return result > 0, nil
}
测试代码:
go
func TestUnlock() {
ctx := context.Background()
lockKey := "my_service_name_" + "lock"
expiration := 2 * time.Second
client := redis.NewClient(&redis.Options{
Addr: "192.168.36.109:6379",
})
// 1. 创建锁实例
lock := nlock.NewRedisLock(client, lockKey, expiration)
success, err := lock.LockAndRenewal(ctx)
if err != nil {
panic(err)
}
if !success {
fmt.Printf("加锁失败\n")
}
fmt.Printf("加锁成功时间为%s\n", time.Now().Format("2006-01-02 15:04:05.000"))
go func() {
// 10秒后释放锁
time.Sleep(time.Second * 10)
release, err := lock.Unlock(ctx)
if err != nil {
panic(err)
}
if !release {
fmt.Printf("释放锁失败\n")
}
fmt.Printf("释放锁时间为%s\n", time.Now().Format("2006-01-02 15:04:05.000"))
}()
// 新建一个客户端尝试去获取锁
client1 := redis.NewClient(&redis.Options{
Addr: "192.168.36.109:6379",
})
ctx2 := context.Background()
lock2 := rLock.NewRedisLock(client1, lockKey, expiration)
fmt.Printf("新建一个客户端尝试获取锁\n")
for {
success, err := lock2.Acquire(ctx2)
if err != nil {
panic(err)
} else if !success {
fmt.Printf("新客户端获取锁失败\n")
} else {
fmt.Printf("新客户端获取锁成功,时间为:%s\n", time.Now().Format("2006-01-02 15:04:05.000"))
break
}
time.Sleep(time.Millisecond * 500)
defer lock2.Release(ctx2)
}
}
可续期锁客户端释放锁大约2秒后,新建的客户端立即拿到锁
3.3 续期频率与时长探讨
续期频率与时长
是一个非常关键的设计决策,直接影响分布式锁的可靠性 、性能 和安全性。续期策略需要在安全性和资源消耗之间找到平衡。
3.3.1 核心原则
续期的核心目标是:确保在业务操作完成前,锁不会意外过期 ,同时避免不必要的网络开销和Redis负载。
3.3.2 续期频率(多久续期一次)
推荐策略:过期时间的 1/3 到 1/2
例如:如果锁的过期时间设置为 30 秒,那么每 10-15 秒 续期一次。
理由分析:
-
预留足够的重试时间:
- 如果第一次续期请求失败,你还有时间进行 1-2 次重试,然后才会触发锁过期。
续期间隔 < (锁过期时间 / 2)
可以确保至少有一次重试机会。
-
平衡网络开销:
- 过于频繁(如每秒一次):会产生大量不必要的 Redis 请求,增加网络和服务器负担。
- 过于稀疏:风险窗口太大,如果一次续期失败,可能来不及重试锁就过期了。
-
容错性考虑:
- 假设锁过期时间为
T
,续期间隔为T/3
。 - 即使连续 2 次 续期请求失败(例如 due to network issues),你仍然有
T - 2*(T/3) = T/3
的时间窗口来让网络恢复并进行成功续期,然后锁才过期。
- 假设锁过期时间为
计算公式 :
续期间隔 = 锁过期时间 * (1/3 到 1/2)
3.3.3 续期时长(每次续期多久)
推荐策略:续期到相同的初始过期时长
例如:初始过期时间为 30 秒,每次续期都将其重新设置为 30 秒。
理由分析:
-
一致性:
- 保持锁的存活时间(TTL)恒定,使得系统行为可预测。无论续期多少次,锁的最大占用时间都是固定的(从最后一次成功续期开始计算)。
-
避免无限延长:
- 如果每次续期都增加时间(例如
当前TTL + 30秒
),一个异常或恶意的客户端可能会通过不断续期来永久持有锁,即使它的业务逻辑已经卡死或失败。固定时长确保了锁最终一定会被释放。
- 如果每次续期都增加时间(例如
-
简化逻辑:
- 逻辑清晰简单:"只要我还活着并在工作,我就需要再持有锁 30 秒"。不需要计算复杂的叠加时间。
四、多节点Redis分布式锁实现:Redlock算法
问题:单点Redis实例有单点故障风险。如果主节点宕机,即使有从节点,在主从切换的瞬间,也可能导致锁的互斥性失效。
解决方案:Redis分布式锁算法 (Redlock) 。
4.1 核心思想
客户端向Redis集群中的大多数(N/2 + 1) 个独立Master节点申请锁。只有当从大多数节点都获取到锁,并且总耗时小于锁的有效期时,才算获取成功。
4.2 Redlock算法步骤
- 获取当前时间(毫秒)
- 按顺序向N个Redis节点请求获取锁,使用相同的key和随机值
- 计算获取锁的总耗时(当前时间减去步骤1的时间)
- 只有当大多数节点(N/2+1)获取成功,且总耗时小于锁有效期,才算成功
- 如果获取失败,向所有节点发送释放锁请求
4.3 Redlock的争议与适用场景
争议焦点
Martin Kleppmann指出Redlock在以下场景可能存在问题:
- 时钟跳跃:系统时钟不同步可能导致锁过期时间计算错误
- GC暂停:长时间的垃圾回收可能导致客户端认为锁仍有效但实际上已过期
- 网络延迟:网络分区期间的延迟可能导致多个客户端同时持有锁
Antirez的回应
- Redlock不依赖绝对时间,而是依赖相对时间流逝
- 对于GC暂停问题,可以通过合理的超时设置和监控来缓解
- 建议在使用Redlock时配合fencing token机制增强安全性
适用场景建议
- 适合对
可用性要求高于一致性
的场景 - 不建议用于对一致性要求极高的金融交易场景
- 可以结合业务层的幂等性设计来降低风险
五、生产环境最佳实践
5.1 监控与告警
- 监控锁获取成功率、平均等待时间、续期失败率等关键指标
- 设置锁竞争激烈时的告警机制
- 记录锁的持有时间和等待时间分布
5.2 性能优化
- 根据业务特点调整锁粒度(细粒度锁 vs 粗粒度锁)
- 设置合理的超时时间,避免过长或过短
- 使用连接池减少Redis连接开销
5.3 故障处理
- 实现降级策略,当分布式锁不可用时使用本地锁或直接排队
- 设计重试机制,使用指数退避算法避免雪崩
- 添加熔断器模式,防止Redis故障时大量请求堆积
5.4 测试策略
- 单元测试:测试锁的基本功能
- 集成测试:测试与Redis的交互
- 混沌测试:模拟网络分区、Redis故障等异常情况
六、总结
分布式锁是微服务架构中的重要组件,正确实现和使用分布式锁对保证数据一致性至关重要。本文从理论基础到Go语言实践,详细介绍了基于Redis的分布式锁实现方案。
关键要点回顾
- 分布式锁必须满足互斥性、安全性、无死锁和容错性四个基本要求
- Redis单实例方案适合大多数场景,配合续期机制可以解决长任务问题
- Redlock算法提供了多节点方案,但需要了解其局限性和适用场景
- 生产环境需要完善的监控、告警和故障处理机制