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 来进行一些复杂的原子操作就再合适不过了,setnx 与 setex 命令的局限性也被 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 命令 setnx 与 setex 设置 Key 和过期时间
    • 第二种方式:直接使用 Redis 命令 set resource-key resource-value nx ex max-lock-time 原子性地设置 Key 和过期时间

思考

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

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

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

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

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

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 脚本的 key 和 arg 参数即可,详细教程请点击 这里。