首页
基于 Redis 的分布式锁实践

分布式锁的实现,目前常用的方案有以下三类:

  1. 数据库乐观锁;

  2. 基于分布式缓存实现的锁服务,典型代表有 Redis 和基于 Redis 的 RedLock;

  3. 基于分布式一致性算法实现的锁服务,典型代表有 ZooKeeper、Chubby 和 ETCD。

分布式锁原理回顾

分布式环境下,多台机器上多个进程对同一个共享资源(数据、文件等)进行操作,如果不做互斥,就有可能出现“余额扣成负数”,或者“商品超卖”的情况。为了解决这个问题,需要分布式锁服务。首先,来看一下分布式锁应该具备哪些条件。

为了确保锁服务可用,通常,分布式锁需同时满足以下四个约束条件。

  1. **互斥性:**在任意时刻,对于同一个锁,只有一个客户端能持有,从而保证一个共享资源同一时间只能被一个客户端操作;

  2. **安全性:**即不会形成死锁,当一个客户端在持有锁的期间崩溃而没有主动解锁的情况下,其持有的锁也能够被正确释放,并保证后续其它客户端能加锁;

  3. **可用性:**当提供锁服务的节点发生宕机等不可恢复性故障时,“热备” 节点能够接替故障的节点继续提供服务,并保证自身持有的数据与故障节点一致。

就 Redis 而言,当提供锁服务的 Redis Master 节点发生宕机等不可恢复性故障时,Slave 节点能够升主并继续提供服务,支持客户端加锁和解锁;对基于分布式一致性算法实现的锁服务(如 ETCD)而言,当 Leader 节点宕机时,Follow 节点能够选举出新的 Leader 继续提供锁服务;

  1. **对称性:**对于任意一个锁,其加锁和解锁必须是同一个客户端,即客户端 A 不能把客户端 B 加的锁给解了。

基于 Redis 实现分布式锁

基于 Redis 实现锁服务的思路比较简单。我们把锁数据存储在分布式环境中的一个节点,所有需要获取锁的调用方(客户端),都需访问该节点,如果锁数据(Key-Value 键值对)已经存在,则说明已经有其它客户端持有该锁,可等待其释放(Key-Value 被主动删除或者因过期而被动删除)再尝试获取锁;如果锁数据不存在,则写入锁数据(Key-Value),其中 Value 需要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的,以便释放锁的时候进行校验;锁服务使用完毕之后,需要主动释放锁,即删除存储在 Redis 中的 Key-Value 键值对。其架构如下:

image.png

对于 Redis 的实现,最重要的一点就是加锁和解锁的原子性

1. 加锁流程

**向 Redis 节点发送命令,请求锁。**代码如下:

SET lock_name my_random_value NX PX 30000

下面解释下各参数的意义。

  • lock_name,即锁名称,这个名称应是公开的,在分布式环境中,对于某一确定的公共资源,所有争用方(客户端)都应该知道对应锁的名字。对于 Redis 而言,lock_name 就是 Key-Value 中的 Key,具有唯一性。

  • my_random_value 是由客户端生成的一个随机字符串,它要保证在足够长的一段时间内,且在所有客户端的所有获取锁的请求中都是唯一的,用于唯一标识锁的持有者。

  • NX 表示只有当 lock_name(key) 不存在的时候才能 SET 成功,从而保证只有一个客户端能获得锁,而其它客户端在锁被释放之前都无法获得锁。

  • PX 30000 表示这个锁节点有一个 30 秒的自动过期时间(目的是为了防止持有锁的客户端故障后,无法主动释放锁而导致死锁,因此要求锁的持有者必须在过期时间之内执行完相关操作并释放锁)。

2. 解锁流程

如果步骤 1 的命令返回成功,则代表获取锁成功,否则获取锁失败。

对于一个拥有锁的客户端,释放锁流程如下。

  1. 向 Redis 节点发送命令,获取锁对应的 Value,代码如下:
GET lock_name
  1. 如果查询回来的 Value 和客户端自身的 my_random_value 一致,则可确认自己是锁的持有者,可以发起解锁操作,即主动删除对应的 Key,发送命令
DEL lock_name

通过 Redis-cli 执行上述命令,显示如下:

100.X.X.X:6379> set lock_name my_random_value NX PX 30000
OK
100.X.X.X:6379> get lock_name
"my_random_value"
100.X.X.X:6379> del lock_name
(integer) 1
100.X.X.X:6379> get lock_name
(nil)

Q:Redis 实现分布式锁有哪些问题?

加锁的原子性问题

如果使用 SETNX 命令,这个命令不能设置过期时间,需要配合 EXPIRE 命令来使用。

因为是用到了两个命令,这时候两个命令的组合使用是不能保障原子性的,在一些并发比较大的时候,会出现问题。

我们可以采用 set key value px milliseconds nx 或者 SETNX+Lua来实现。

set key value px milliseconds nx

因为这个命令同时能够设置键值和过期时间,同时 Redis 中的单命令都是原子性的,所以加锁的时候使用这个命令即可

func lock(key, value string, expire time.Duration, ctx context.Context) (isGetLock bool, err error) {
	res, err := rdb.Do(ctx, "set", key, value, "px", expire.Milliseconds(), "nx").Result()
	if err != nil {
		return false, err
	}
	if res == "OK" {
		return true, nil
	}
	return false, nil
}

lock("testlock", "123", 3000*time.Millisecond, context.Background())

SETNX+Lua

Lua 脚本可以保证组合命令的原子性。

func tryLockScript() string {
	script := `
		local key = KEYS[1]

		local value = ARGV[1]
		local expireTime = ARGV[2]
		local isSuccess = redis.call('SETNX', key, value)

		if isSuccess == 1 then
			redis.call('EXPIRE', key, expireTime)
			return "OK"
		end

		return "unLock"    `
	return script
}

func (r *Redis) TryLock(ctx context.Context, key, value string, expire time.Duration) (isGetLock bool, err error) {
	// 使用 Lua + SETNX
	res, err := r.Eval(ctx, tryLockScript(), []string{key}, value, expire.Seconds()).Result()
	if err != nil {
		return false, err
	}
	if res == "OK" {
		return true, nil
	}
	return false, nil
}

预防死锁问题

我们看下面这个典型死锁场景。

一个客户端获取锁成功,但是在释放锁之前崩溃了,此时该客户端实际上已经失去了对公共资源的操作权,但却没有办法请求解锁(删除 Key-Value 键值对),那么,它就会一直持有这个锁,而其它客户端永远无法获得锁。

我们的解决方案是:在加锁时为锁设置过期时间,当过期时间到达,Redis 会自动删除对应的 Key-Value,从而避免死锁。需要注意的是,这个过期时间需要结合具体业务综合评估设置,以保证锁的持有者能够在过期时间之内执行完相关操作并释放锁。

设置锁自动过期时间以预防死锁存在的隐患

为了避免死锁,可利用 Redis 为锁数据(Key-Value)设置自动过期时间,虽然可以解决死锁的问题,但却存在隐患。

  • 我们看下面这个典型场景。

    1. 客户端 A 获取锁成功;

    2. 客户端 A 在某个操作上阻塞了很长时间(对于 Java 而言,如发生 Full-GC);

    3. 过期时间到,锁自动释放;

    4. 客户端 B 获取到了对应同一个资源的锁;

    5. 客户端 A 从阻塞中恢复过来,认为自己依旧持有锁,继续操作同一个资源,导致互斥性失效。

这时我们可采取的解决方案见下。

  1. 存在隐患的方案。第 5 步中,客户端 A 恢复后,可以比较下目前已经持有锁的时间,如果发现已经过期,则放弃对共享资源的操作即可避免互斥性失效的问题。但是,客户端 A 所在节点的时间和 Redis 节点的时间很可能不一致(比如客户端与 Redis 节点不在同一台服务器,而不同服务器时间通常不完全同步),因此,严格来讲,任何依赖两个节点时间比较结果的互斥性算法,都存在隐患。目前网上很多资料都采用了这种方案,鉴于其隐患,不推荐。

  2. 可取的方案。既然比较时间不可取,那么,还可以比较 my_random_value,即客户端 A 恢复后,在操作共享资源前应比较目前自身所持有锁的 my_random_value 与 Redis 中存储的 my_random_value 是否一致,如果不相同,说明已经不再持有锁,则放弃对共享资源的操作以避免互斥性失效的问题。

解锁操作的原子性

为了保证每次解锁操作都能正确进行,需要引入全局唯一变量 my_random_value。具体而言,解锁需要两步,先查询(GET)锁对应的 Value,与自己加锁时设置的 my_random_value 进行对比,如果相同,则可确认这把锁是自己加的,然后再发起解锁(DEL)。需要注意的是,GET 和 DEL 是两个操作,非原子性,那么解锁本身也会存在破坏互斥性的可能。

  • 下面是典型场景。

    1. 客户端 A 获取锁成功;

    2. 客户端 A 访问共享资源;

    3. 客户端 A 为了释放锁,先执行 GET 操作获取锁对应的随机字符串的值;

    4. 客户端 A 判断随机字符串的值,与预期的值相等;

    5. 客户端 A 由于某个原因阻塞了很长时间;

    6. 过期时间到了,锁自动释放了;

    7. 客户端 B 获取到了对应同一个资源的锁;

    8. 客户端 A 从阻塞中恢复过来,执行 DEL 操纵,释放掉了客户端 B 持有的锁。

下面给出解决方案。

如何保障解锁操作的原子性呢?在实践中,我总结出两种方案。

1. 使用 Redis 事务功能

使用 Watch 命令监控锁对应的 Key,释放锁则采用事务功能(Multi 命令),如果持有的锁已经因过期而释放(或者过期释放后又被其它客户端持有),则 Key 对应的 Value 将改变,释放锁的事务将不会被执行,从而避免错误的释放锁,示例代码如下:

func unlock(key, value string, block time.Duration, ctx context.Context) (isReleased bool, err error) {
	// 监控锁对应的 Key,如果其它的客户端对这个 Key 进行了更改,那么本次事务会被取消。
	err = rdb.Watch(ctx, func(tx *redis.Tx) error {
		// 释放锁之前,校验是否持有锁
		val, err := tx.Get(ctx, key).Result()
		if err != nil && err != redis.Nil {
			return err
		}
		if val == value {
			// 模拟客户端阻塞n秒,锁超时,自动清除
			time.Sleep(block * time.Second)
			// 客户端恢复,继续释放锁
			_, err := tx.TxPipelined(ctx, func(pipeliner redis.Pipeliner) error {
				// 删除锁
				pipeliner.Del(ctx, key)
				return nil
			})
			if err != nil {
				return err
			}
		}
		return nil
	}, key)

	if err != nil && err != redis.Nil {
		return false, err
	}
	return true, nil
}

2. 使用 Lua 脚本

Redis 支持 Lua 脚本并保证其原子性,使用 Lua 脚本实现锁校验与释放,并使用 Redis 的 eval 函数执行 Lua 脚本,代码如下:

package main

import (
	"context"
	"fmt"
	"time"

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

func main() {
	// 连接 Redis
	ctx := context.Background()
	rdb := redis.NewClient(&redis.Options{
		Addr: "localhost:6379",
	})
	defer rdb.Close()

	// 自旋等待获取锁
	for {
		// 申请锁,只有当“lock_name”不存在时才能申请成功,返回“OK",锁的过期时间设置为 5s
		setCmd := rdb.SetNX(ctx, "lock_name", "my_random_value", 5*time.Second)
		if setCmd.Err() == nil && setCmd.Val() {
			break
		}
	}

	// 成功获取锁,则操作公共资源,自定义流程
	// to do something...

	// 模拟客户端阻塞10s,锁超时,自动清除
	time.Sleep(10 * time.Second)

	// Lua 脚本,用于校验并释放锁
	script := `
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
`
	// 执行 Lua 脚本,校验并释放锁
	result, err := rdb.Eval(ctx, script, []string{"lock_name"}, "my_random_value").Result()
	if err != nil {
		fmt.Println("Failed to release lock:", err)
	} else {
		fmt.Println("Lock released:", result)
	}
}

锁的续租问题

Redis 中分布式锁还有一个问题就是锁的续租问题,当锁的过期时间到了,但是业务的执行时间还没有完成,这时候就需要对锁进行续租了。

续租的流程:

  1. 当客户端加锁成功后,可以启动一个定时的任务,每隔一段时间,检查业务是否完成,未完成,增加 key 的过期时间;

  2. 这里判断业务是否完成的依据是:

  • 这个 key 是否存在,如果 key 不存在了,就表示业务已经执行完成了,也就不需要进行续租操作了;

  • 同时需要校验下 value 值,如果 value 对应的值和之前写入的值不同了,说明当前锁已经被别的线程获取了;

Redis 节点故障后,主备切换的数据一致性

考虑 Redis 节点宕机,如果长时间无法恢复,则导致锁服务长时间不可用。为了保证锁服务的可用性,通常的方案是给这个 Redis 节点挂一个 Slave(多个也可以),当 Master 节点不可用的时候,系统自动切到 Slave 上。但是由于 Redis 的主从复制(Replication)是异步的,这可能导致在宕机切换过程中丧失锁的安全性。

  • 我们看下典型场景。

    1. 客户端 A 从 Master 获取了锁;

    2. Master 宕机了,存储锁的 Key 还没有来得及同步到 Slave 上;

    3. Slave 升级为 Master;

    4. 客户端 B 从新的 Master 获取到了对应同一个资源的锁;

    5. 客户端 A 和客户端 B 同时持有了同一个资源的锁,锁的安全性被打破。

解决方案有两个。

  • 方案 1,设想下,如果要避免上述情况,可以采用一个比较“土”的方法,即自认为持有锁的客户端在对敏感公共资源进行写操作前,先进行校验,确认自己是否确实持有锁,校验的方式前面已经介绍过——通过比较自己的 my_random_value 和 Redis 服务端中实际存储的 my_random_value

    显然,这里仍存在一个问题。如果校验完毕后,Master 数据尚未同步到 Slave 的情况下 Master 宕机,该如何是好?诚然,我们可以为 Redis 服务端设置较短的主从复置周期,以尽量避免上述情况出现,但是,隐患还是客观存在的。

  • 方案 2,针对该问题场景,Redis 的作者 Antirez 提出了 RedLock,其原理基于分布式一致性算法的核心理念:多数派思想。下面对 RedLock 做简要介绍。

RedLock

为了避免 Redis 实例故障而导致的锁无法工作的问题,Redis 的开发者 Antirez 提出了分布式锁算法 Redlock。

Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。

这样一来,即使有单个 Redis 实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。

我们来具体看下 Redlock 算法的执行步骤。Redlock 算法的实现需要有 N 个独立的 Redis 实例。接下来,我们可以分成 3 步来完成加锁操作。

第一步是,客户端获取当前时间。

第二步是,客户端按顺序依次向 N 个 Redis 实例执行加锁操作。

这里的加锁操作和在单实例上执行的加锁操作一样,使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。当然,如果某个 Redis 实例发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给加锁操作设置一个超时时间。

如果客户端在和一个 Redis 实例请求加锁时,一直到超时都没有成功,那么此时,客户端会和下一个 Redis 实例继续请求加锁。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。

第三步是,一旦客户端完成了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时。

客户端只有在满足下面的这两个条件时,才能认为是加锁成功。

  • 条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;

  • 条件二:客户端获取锁的总耗时没有超过锁的有效时间。

在满足了这两个条件后,我们需要重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。

当然,如果客户端在和所有实例执行完加锁操作后,没能同时满足这两个条件,那么,客户端向所有 Redis 节点发起释放锁的操作。

在 Redlock 算法中,释放锁的操作和在单实例上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。这样一来,只要 N 个 Redis 实例中的半数以上实例能正常工作,就能保证分布式锁的正常工作了。

所以,在实际的业务应用中,如果你想要提升分布式锁的可靠性,就可以通过 Redlock 算法来实现。

总结

  1. 在分布式的场景下,使用分布式锁是我们经常遇到的一种场景;

  2. 使用 Redis 实现锁是个不错的选择,Redis 的单命令的执行是原子性的,同时借助于 Lua 也可以很容易的实现组合命令的原子性;

  3. 针对分布式场景下主从切换,数据同步不及时的情况,redis 中引入了 redLock 来处理分布式锁;

  4. 根据 martin 的描述,redLock 是繁重的,且存在安全性,不过我们可以根据自己的业务场景做出判断;

  5. 需要注意的是在设置分布式锁的时候需要设置 value 的唯一性,并且每次主动删除锁的时候需要匹配下 value 的正确性,避免误删除其他线程的锁;