Redis实现分布式锁的几种姿势

654 阅读6分钟

前言

本文主要讲述如何使用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对机器的时钟依赖性太强,机器时钟不同步也会有问题,详情可以参考这两篇文章:

分布式锁 - RedLock到底有哪些缺陷?

redis分布式锁的安全性探讨(二):分布式锁Redlock

参考

基于Redis的分布式锁实现

redLock详解与不足