# 使用lua脚本的好处

  1. 减少网络开销:本来5次网络请求的操作,可以用一个请求完成
  2. 原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入
  3. 复用:客户端发送的脚本会永久存储在Redis中,意味着其他客户端可以复用这一脚本而不需要使用代码完成同样的逻辑。

# 准备条件

  • redis在服务器端内置lua解释器(版本2.6以上)
  • redis-cli提供了EVALEVALSHA命令执行Lua脚本

# redis内置lua执行命令

# EVAL命令语法

EVAL script numkeys key [key …] arg [arg …]

  • EVAL —- lua程序的运行环境上下文
  • script —- lua脚本
  • numkeys —- 参数的个数(key的个数)
  • key —- redis键,访问下标从1开始,例如:KEYS[1]
  • arg —- redis键的附加参数

# EVALSHA 命令语法

EVALSHA SHA1 numkeys key [key …] arg [arg …]

EVALSHA命令允许通过脚本的SHA1来执行(节省带宽)

Redis在执行EVAL/SCRIPT LOAD后会计算脚本SHA1缓存, EVALSHA根据SHA1取出缓存脚本执行

# Redis中管理Lua脚本

  • script load script 将Lua脚本加载到Redis内存中(如果redis重启则会丢失)
  • script exists sh1 [sha1 …] 判断sha1脚本是否在内存中
  • script flush 清空Redis内存中所有的Lua脚本
  • script kill 杀死正在执行的Lua脚本。(如果此时Lua脚本正在执行写操作,那么script kill将不会生效)

Redis提供了一个lua-time-limit参数,默认5秒,它是Lua脚本的超时时间,如果Lua脚本超时,其他执行正常命令的客户端会收到“Busy Redis is busy running a script”错误,但是不会停止脚本运行,此时可以使用script kill 杀死正在执行的Lua脚本。

# lua函数

主要有两个函数来执行redis命令

  • redis.call() –- 出错时返回具体错误信息,并且终止脚本执行
  • redis.pcall() –- 出错时返回lua table的包装错误,但不引发错误

使用流程如下:

  1. 编写脚本
  2. 脚本提交到REDIS并获取SHA
  3. 使用SHA调用redis脚本

# redis运行lua脚本

# EVAL 直接运行脚本

EVAL "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second

# EVALSHA使用

需要SCRIPT LOADEVALSHA配合使用

  1. SCRIPT LOAD加载到内存,返回SHA签名
  2. EVALSHA使用已经存在的签名

这样只用加载一次,便可重复使用已经加载的签名脚本,可以多次使用,避免长脚本输入

> SCRIPT LOAD "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}"
"a42059b356c875f0717db19a51f6aaca9ae659ea"
EVALSHA "a42059b356c875f0717db19a51f6aaca9ae659ea" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"
1
2
3
4
5
6
7

# 在redis下使用脚本文件执行

在路径下创建脚本文件,这里直接在redis/bin下创建,方便使用,其他目录可以使用全路径

set.lua

--[[ set.lua, redis的set命令使用 
redis: set key val
--]]
local key = KEYS[1]
local val = ARGV[1]

return redis.call('set', key, val)
1
2
3
4
5
6
7

设置k-v值,运行命令

redis-cli --eval ./set.lua foo , bar

注意redis-cli --eval set.lua foo , bar,foo和bar之间的逗号左右都有空格分隔,否则会当做一个字符串

通过redis-cli查看值

127.0.0.1:6379> get foo
"bar"
1
2

get.lua

--[[ get.lua, redis的get命令使用 
redis: get key
--]]

local key = KEYS[1]
local val = redis.call("GET", key);

return val;
1
2
3
4
5
6
7
8

获取k值,运行命令

redis-cli --eval ./get.lua foo

通常做法是

  1. 脚本文件保存到一个路径下或者数据库中,比如:/mnt/redis/lua/set.lua
  2. SCRIPT LOAD 加载脚本文件内容,返回SHA签名保存到应对的值K-V值,(set,SHA)
  3. 获取对应脚本名称的SHA签名,如果存在则执行,否则进行第二步操作

# 实际开发应用实战

# 访问次数限制

需求场景:限制用户在一段时间内只能访问某一资源限定次数

ratelimiting.lua

local times = redis.call('incr',KEYS[1])

if times == 1 then
    redis.call('expire',KEYS[1], ARGV[1])
end

if times > tonumber(ARGV[2]) then
    return 0
end
return 1
1
2
3
4
5
6
7
8
9
10

运行脚本(限定10秒内最多访问3次):

redis-cli --eval ratelimiting.lua rate.limitingl:127.0.0.1 , 10 3
(integer) 1
redis-cli --eval ratelimiting.lua rate.limitingl:127.0.0.1 , 10 3
(integer) 1
redis-cli --eval ratelimiting.lua rate.limitingl:127.0.0.1 , 10 3
(integer) 1
redis-cli --eval ratelimiting.lua rate.limitingl:127.0.0.1 , 10 3
(integer) 0
1
2
3
4
5
6
7
8
  • rate.limitingl:127.0.0.1是前缀+ip组成的KEY,用KEYS[1]获取,
  • ","后面的10和3是参数,在脚本中能够使用ARGV[1]和ARGV[2]获得

命令的作用是将访问频率限制为每10秒最多3次,所以在终端中不断的运行此命令会发现当访问频率在10秒内小于或等于3次时返回1,否则返回0。

# lua实现redis分布式锁

分布式锁需要考虑的问题

  1. 失效时间;如果没有失效时间,当解锁失败时,就会导致锁记录一直在tair中,其他线程无法再获得到锁。
  2. 非阻塞:分布式锁应该是非阻塞的,无论成功还是失败都直接返回
  3. 非重入:一个线程获得锁之后,在释放锁之前,无法再次获得该锁

# 实现思路

setnx:如果key不存在则添加值并返回1,如果已经存在key则返回0

加锁

使用业务setnx(key,业务流水号)当加锁成功返回1时设置过期时间,避免业务异常没有解锁时防止死锁

重入锁

当同一业务再次申请锁时,如果随机值相同 则认为是重试,则直接设置过期时长;如果随机值不同则直接返回0,获取锁失败

解锁

业务完成直接del(key)完成

以上方案是很多客户端实现的方式,建立和释放锁,并保证绝对的安全,是这个锁的设计比较棘手的地方。有两个潜在的陷阱:

  1. 应用程序通过网络和redis交互,这意味着从应用程序发出命令到redis结果返回之间会有延迟。这段时间内,redis可能正在运行其他的命令,而redis内数据的状态可能不是你的程序所期待的。如果保证程序中获取锁的线程和其他线程不发生冲突?
  2. 如果程序在获取锁后突然crash,而无法释放它?这个锁会一直存在而导致程序进入“死锁”

对于第一个问题,除了pile批量一次执行,目前只有lua脚本是在同一个线程中一次执行完的。 第二个问题,如果在获取锁之后,设置expire之前发生了异常,那么这个key-v永远都不会过期,即便是lua脚本也是一样会发生这样的情况(通常是设置过期时间这个参数设置的不是数字类型,虽然这种情况不太可能发生),但仍然比客户端多条命令执行来的更加简短

lock.lua

-- Set a lock
--  如果获取锁成功,则返回 1
local key     = KEYS[1]
local content = ARGV[1]
local ttl     = tonumber(ARGV[2])
local lockSet = redis.call('setnx', key, content)
if lockSet == 1 then
  redis.call('PEXPIRE', key, ttl)
else
  -- 如果value相同,则认为是同一个线程的请求,则认为重入锁
  local value = redis.call('get', key)
  if(value == content) then
    lockSet = 1;
    redis.call('PEXPIRE', key, ttl)
  end
end
return lockSet
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

unlock.lua

-- unlock key
local key     = KEYS[1]
local content = ARGV[1]
local value = redis.call('get', key)
if value == content then
  return redis.call('del', key)
else
    return 0
end
1
2
3
4
5
6
7
8
9

测试加锁和解锁

redis-cli  --eval lock.lua lo3 , 2 60000
redis-cli  --eval unlock.lua lo3 , 2
1
2

# 参考

  • https://github.com/mike-marcacci/node-redlock