Redis 分布式锁中 Lua 脚本的使用

Lua 简介

从 Redis 2.6.0 版本开始,通过内置的 Lua 解释器,可以使用 EVAL 命令对 Lua 脚本进行求值。Redis 使用单个 Lua 解释器去运行所有脚本,并且 Redis 也保证脚本会以原子性 (atomic) 的方式执行。当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。这和使用 MULTI / EXEC 包围的事务很类似。在其他别的客户端看来,脚本的效果 (effect) 要么是不可见的 (not visible),要么就是已完成的 (already completed)。在 Lua 脚本中,可以使用 redis.call () 函数来执行 Redis 命令。

Lua 在 Reids 中的使用方式

​Redis 中内嵌了 Lua 脚本的解释器,并提供了执行 Lua 脚本的入口 eval 命令,格式为 eval script numkeys key [key ...] arg [arg...]。其中 eval 为命令,script 为执行的命令脚本,numkeys 为脚本中共涉及到的 key 的数量,后续接收若干个 key 的输入和若干个 arg 的输入。​在 Lua 脚本中使用 KEYS[index], 和 ARGV[index] 来获取实际输入的参数,这有点类似于 SQL 的占位符。另外一层原因由于 Redis 集群的固有模式导致 EVAL 命令在集群中涉及多个 KEY 的操作时,要求所有的 KEY 都在同一个 Hash Solt 上。在集群环境中调用 EVAL 命令,Redis 会对脚本先做一个的校验。KEYS[1] KEYS[2] 是要操作的键,可以指定多个,在 Lua 脚本中可以通过 KEYS[1]KEYS[2] 获取 Key 的值。特别注意,这些键要在 Redis 中存在,不然就获取不到对应的值。ARGV[1] ARGV[2] 参数在 Lua 脚本中可以通过 ARGV[1]ARGV[2] 获取值。

Lua 脚本示例

下述的 Lua 脚本可以保证 Redis 删除 Key 这一操作的原子性。该脚本会先判断 Key 是否存在和 Key 的值是否匹配,若满足条件,则会删除对应的 Key。

1
2
3
4
5
6
if redis.call("get", KEYS[1]) == ARGV[1]
then
return redis.call("del", KEYS[1])
else
return 0
end

Redis 执行 Lua 脚本的保证

Redis 可以保证对一个 Lua 脚本执行的完整性,也就是说一个 Lua 脚本的执行结果只会有成功和失败,且保证在 Redis Server 端同时只会有一个 Lua 脚本在运行,这样就意味着 Lua 脚本中的操作是一个完整的原子操作,不会伴随中间状态和资源竞争,同时也意味着在 Lua 脚本中不适合进行一些耗时较长的操作。由于有以上的保证,使用 Redis 来进行一些复杂的原子操作就再合适不过了,setnxsetex 命令的局限性也被 Redis Lua 进行了弥补。Redis 对嵌入的 Lua 做了若干的限制,可以保证脚本不对 Redis 造成破坏。不提供访问系统状态的库,禁止使用 loadfile 函数,禁止带有随机性质的命令或者带有副作用的命令,对随机读命令的结果进行排序,替换 math 原有的 random 方法,不允许定义函数,不允许声明全局变量等。

Lua 脚本调用 Redis 命令

在 Lua 脚本中,可以使用 redis.call() 函数调用 Redis 的命令,示例代码如下。redis.call() 函数的返回值就是 Redis 命令的执行结果。Redis 命令的返回值有 5 种类型,redis.call() 函数会将这 5 种类型的返回值转换成对应的 Lua 数据类型。

1
2
3
4
5
-- Set Key
redis.call('set', 'foo', 'bar')

-- Get Key
local value = redis.call('get', 'foo')

Redis 分布式锁实现

  • 分布锁一般需要满足两个条件,一个是加拥有过期时间的锁,一个是高性能解锁
  • 解锁:需要采用 Lua 脚本,必须保证解锁操作的原子性
  • 加锁:可以采用两种方式,但都必须保证加锁操作的原子性
    • 第一种方式:在 Lua 脚本中,分别使用 Redis 命令 setnxsetex 设置 Key 和过期时间
    • 第二种方式:直接使用 Redis 命令 set resource-key resource-value nx ex max-lock-time 原子性地设置 Key 和过期时间

思考

为什么不能直接使用 Redis 命令 setnxsetex 命令实现加锁操作,而是必须借助 Lua 脚本呢?

当按照上面的流程图直接使用 Redis 命令 setnxsetex 实现加锁操作时,如果在 setnxsetex 这两个命令执行中间,万一发生网络抖动或者 Reids 服务器宕机了,那么 Key 将没有设置过期时间,也就是 Key 会永远存在;当后续解锁操作执行失败时,会导致其他请求永远获取不到锁。

由于 setnxsetex 命令是分步执行的,那么可以想办法将两步合成一步,将加锁操作放在同一个原子中执行即可:

  • 第一种方案:使用 Lua 脚本,它可以保证 setnxsetex 命令执行的原子性
  • 第二种方案:Redis 从 2.6 版本之后支持 setnxsetex 连用,也就是可以直接使用 set resource-key resource-value nx ex max-lock-time 命令实现原子性地加锁

基于 Lua 脚本,使用 setnxsetex 命令进行加锁的代码

1
2
3
4
5
6
7
8
9
10
11
12
local lockKey = KEYS[1]
local lockTime = KEYS[2]
local lockValue = KEYS[3]

local result_1 = redis.call('SETNX', lockKey, lockValue)
if result_1 == 1
then
local result_2= redis.call('SETEX', lockKey, lockTime, lockValue)
return result_2
else
return 'faild'
end

基于 Lua 脚本解锁的代码

1
2
3
4
5
6
if redis.call("get", KEYS[1]) == ARGV[1]
then
return redis.call("del", KEYS[1])
else
return 0
end

提示

在 Spring 项目中,可以直接使用 StringRedisTemplate 实例对象调用 Lua 脚本,只需传入 Lua 脚本的 keyarg 参数即可,详细教程请点击 这里