前言
本文主要讲述如何使用Redis实现分布式锁的几种方案,希望能帮到你!
一、单实例Redis
使用Redis实现分布式锁,首先想到的就是使用单实例的Redis,因为单实例的Redis是唯一的,并不会有数据不一致性的问题,但是单实例由于只有一个节点,所以存在“单点”的问题,如果这个Redis宕机了就无法继续提供服务了。
单实例Redis实现分布式锁依靠的是SET方法:
# SET方法的语法如下:
# val后面可以接两个可选参数:
# 第一个参数用于控制超时时间,由EX和PX两种计算方式
# 第二个参数用于插入的模式,NX表示仅当key不存在时设置值;XX表示仅当key存在时设置值
set key value [expiration EX seconds|PX milliseconds] [NX|XX]
# 举个例子
# 设置 distribute_key 为 test,超时时间100秒,仅当distribute_key不存在时插入
> set distribute_key test EX 100 NX
# 如果distribute_key存在时不会成功
> set distribute_key test EX 100 NX
(nil)
这里有两个注意事项,一是要给这个key设置过期时间;二是这个key的值必须是一个随机值,或者是唯一值,即不同客户端所对应的值是不同的。
设置过期时间的原因是,如果客户端A获取锁lock_key成功,但却不幸宕机,或者连接断开,这个锁就无法得到释放,其他客户端也无法获取(一直阻塞)。
设置随机值的原因主要是避免A客户端获取的锁被B客户端释放,从而加锁失败,场景如下:
- 客户端 A 获取锁
lock_key成功 - 客户端 A 在某个操作上阻塞了很长时间(超过
TIMEOUT),过期时间到了,锁自动释放了 - 客户端 B 获取到了对应同一个资源的锁(超时
lock_key/random_value被删除) - 客户端 A 从阻塞中恢复过来,释放掉了客户端 B 持有的锁
- 后续流程中,客户端 B 在访问共享资源的时候,就没有锁为它提供保护了,分布式锁逻辑失效
依据上面的思想,我自己也简单地造了一个轮子,完整例子可以参见我的Github:
type RSession struct {
client *redis.Client
timeout time.Duration
}
type RMutex struct {
s *RSession
key string
val string
}
func NewSession(client *redis.Client, timeout time.Duration) *RSession {
return &RSession{client: client, timeout: timeout}
}
func NewRMutex(s *RSession, key string) *RMutex {
val := uuid.New().String()
return &RMutex{s: s, key: key, val: val}
}
// 获取分布式锁
func (m *RMutex) Lock(ctx context.Context) error {
if m.key == "" {
return errors.New("key empty")
}
// NX: 当key不存在时设置值
args := redis.SetArgs{Mode: "NX", TTL: m.s.timeout}
cmd := m.s.client.SetArgs(ctx, m.key, m.val, args)
if err := cmd.Err(); err != nil {
return err
}
return nil
}
// 释放分布式锁
func (m *RMutex) UnLock(ctx context.Context) error {
if m.key == "" {
return errors.New("key empty")
}
cmd := m.s.client.Get(ctx, m.key)
// 释放锁时对应的值要相等,避免释放错误
if cmd.Val() != m.val {
return errors.New(fmt.Sprintf("val not equal: %s %s", cmd.String(), m.val))
}
m.s.client.Del(ctx, m.key)
return nil
}
二、主从Redis
前面提到,单实例Redis存在单点问题,那使用主从Redis是否可以解决呢?主从Redis虽然可以解决单点问题,但如果使用其实现分布式锁,则锁的安全性不一定能得到保障(所以主从Redis不OK)。
原因是Redis的**主从复制是异步的,**意思是假如我的Master节点宕机了,在由新的Slave接替Master节点的过程,有可能导致分布式锁逻辑失效。
可以考虑下面的场景:
- 客户端 A 从 Master 获取了锁
lock_key - 在 Master 将锁
lock_key信息备份到 Slave 节点之前, Master 主节点宕机,即存储锁的lock_key还没有来得及同步到 Slave 上 - 此时,Slave 节点升级为 Master 节点
- 客户端 B 从新的 Master 节点获得锁
lock_key - 客户端 B 从新的 Master 节点获取到了对应同一个资源的锁
lock_key - 于是,客户端 A 和客户端 B 同时持有了同一个资源的锁
lock_key,分布式锁逻辑失效
三、Redlock算法
既然单例和主从都存在问题,那有没有一种更稳定的算法呢,当然有,这就是Redlock算法!
Redlock算法基于 N 个完全独立的 Redis 节点,且N一般为奇数个。完全独立指的是不能使用Slave节点,比如都是Master节点。
它的算法思想是依次向这N个Redis节点获取锁,如果最终获取成功的节点数超过半数(quorum ≥ N/2 + 1),即认为分布式锁获取成功,否则认为获取失败,并释放锁。
另外,这里还有一个注意点就是分布式锁的有效时长要加上“获取分布式锁耗费的时间”,不然就会导致锁提前失效。
3.1 Redlock的应用
有很多语言都实现了Redlock算法,可以在官方文档看到,这里主要讲解Golang的实现。
Go语言对应的库是redsync,使用起来也非常简单。
package main
import (
goredislib "github.com/go-redis/redis/v8"
"github.com/go-redsync/redsync/v4"
"github.com/go-redsync/redsync/v4/redis/goredis/v8"
)
func main() {
// 创建连接池,跟单机的连接池一致
client := goredislib.NewClient(&goredislib.Options{
Addr: "localhost:6379",
})
pool := goredis.NewPool(client) // or, pool := redigo.NewPool(...)
// 传入连接池,创建 redisync 实例,可以通过这个实例获取分布式锁
rs := redsync.New(pool)
// 获取一个分布式锁
mutexname := "my-global-mutex"
mutex := rs.NewMutex(mutexname)
// 加锁
if err := mutex.Lock(); err != nil {
panic(err)
}
// 业务逻辑
// Do your work that requires the lock.
// 释放锁
if ok, err := mutex.Unlock(); !ok || err != nil {
panic("unlock failed")
}
}
这里需要注意下,如果业务逻辑执行的时间超过分布式锁的超时时间(默认8秒),此时Unlock的ok为false,即释放出错
// Unlock unlocks m and returns the status of unlock.
func (m *Mutex) UnlockContext(ctx context.Context) (bool, error) {
n, err := m.actOnPoolsAsync(func(pool redis.Pool) (bool, error) {
return m.release(ctx, pool, m.value)
})
// 超时后 n = 0,m.quorm = 1
if n < m.quorum {
return false, err
}
return true, nil
}
如果有多个Master节点,则创建多个pool即可,代码如下:
var addrs = flag.String("addrs", "", "comma separated list of addrs")
func init() {
flag.Parse()
}
func main() {
var poolList []redis.Pool
var addrList []string
if len(*addrs) == 0 {
addrList = []string{"localhost:6379"}
} else {
addrList = strings.Split(*addrs, ",")
}
fmt.Println("addrList", addrList)
for _, addr := range addrList {
client := goredislib.NewClient(
&goredislib.Options{Addr: addr},
)
pool := goredis.NewPool(client)
poolList = append(poolList, pool)
}
// 每个pool就是一个redis实例
rs := redsync.New(poolList...)
mutexName := "my-global-mutex"
...
运行:
./redis_lock --addrs=localhost:6381,localhost:6382,localhost:6383
addrList [localhost:6381 localhost:6382 localhost:6383]
切记传入的地址不能是slave节点的,不然获取锁的时候会报错redsync: failed to acquire lock
3.2 Redlock的问题
1、成本过高
Redlock的实现需要N个独立的节点(master节点),一般情况下我们不可能因为分布式锁去专门部署这N个节点,成本耗费太大。
2、无法保证100%能拿到锁,有可能两个客户端都拿到,而且Redlock对机器的时钟依赖性太强,机器时钟不同步也会有问题,详情可以参考这两篇文章:
redis分布式锁的安全性探讨(二):分布式锁Redlock