Redis中使用Lua脚本

Posted 南依说

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Redis中使用Lua脚本相关的知识,希望对你有一定的参考价值。

如果感兴趣,请查看我的简书:https://www.jianshu.com/u/c4206331771c

Lua脚本

Redis的单一指令都是原子的,可以有效保证执行结果要么成功要么失败;当用户要执行多条数据时,一方面每条指令都需要建立链接,并执行,消耗网络开销,另外一方面也无法保证多条指令都能正确执行。当这几条指令具有业务性时,往往会产生业务上的BUG。比如当用户下订单时,有一个业务要求,用户必须在10分钟内完成支付,否则就认为订单失效,业务需要异步重置订单状态。这个业务的实现方法如下。

  • 用户下订单,将订单号写入Redis,并设置过期实现为10分钟

// key规则为order:orderId,值无所谓,这里将订单ID写入值,不考虑采用字符类型的合理性
public void setOrderInvalid(String orderId) {
String key = "order:".concat(orderId);
boolean ret = redisUtils.set(key, orderId);
if(ret) {
// 设置key过期时间为10分钟
redisUtils.expire(key, 600);
}
}
  • 设置监听key过期通知,需要配置Redis以及Java端实现监听服务

// 这里也不考虑启用Redis key过期监听的性能损耗
public class MyKeyExpirationEventMessageListener extends KeyExpirationEventMessageListener {
//@Autowired
OrderService orderService;

public MyKeyExpirationEventMessageListener(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
}

@Override
public void onMessage(Message message, byte[] pattern) {
String key = new String(message.getBody());
if (key.startsWith(CacheName.PAYMENT_ORDER_EXPIRE.concat("order:"))) {
// 获取订单ID
String orderId = CacheNameUtils.parseKey(key, "order:");
orderService.orderOverTime(orderId);
}
}

正常情况,该流程处理订单状态是有效的。如果在第一步中,设置订单ID有效期为10分钟,这一步发生了异常(比如网络闪断等),而第一步设置订单进入Redis已经成功,此时该订单永久有效。这将导致当用户没有支付的情况下,该订单永远在订单池中,没有办法调整状态,产生业务BUG。

为了解决这个问题,可以采用Redis的事务功能,事务可以保证一个事务的指令都被执行,但是也有可能外部修改键值,导致WATCH失败,整体流程复杂。

如果采用Redis执行Lua脚本的方式实现多条指令,可以解决以上问题。Lua脚本整体上在Redis中是原子的,并且在脚本执行期间,其他指令无法插入。并且Lua脚本编写简单,可以将一部分业务规则放入其中。Lua脚本来Redis的业务操作,其具有以下好处:

  • 一次链接,降低网络开销。如上述业务中,有两条指令,采用Lua脚本,可降低为一次链接,一个请求

  • 原子操作,Lua脚本的最重要一点。可以解决诸多业务要求所有指令都必须被成功执行的需求,Lua脚本在执行过程中,其他指令无法插入其中,不存在竞态及数据被修改的情况,同时实现的效果同事务一致

  • Redis支持脚本缓存,可以进一步提高性能,如果有一个较大脚本,缓存在Redis中,最少可以减少网络传输的开销

  • 执行速度快, 底层很多组件采用C开发,比如sha、json转换器等,执行速度较快

Redis执行Lua脚本

在Lua脚本中调用Redis的常用方法主要有

-- 1. 调用Redis指令,当执行出错时,该方法会直接返回错误,并退出
redis.call(redisCommand, key, argv...)
-- 如以下使用方式
redis.call('SET', 'a', 'hello redis lua script')
local val = redis.call('GET', 'a')

-- 2. 调用Redis指令,当执行出错时,记录错误信息,并继续执行
redis.pcall(redisCommand, key, argv...)

-- 3. 记录日志,写入到Redis配置的日志文件中
redis.log(logLevel, message)
-- 配置的NOTICE级别,写入日志
redis.log(redis.LOG_NOTICE, "The Calc Result:" .. ret)

-- 4. 对输入的字符串进行sha1编码
redis.sha1hex(arvg)
-- 在redis控制台执行
eval "return redis.sha1hex(ARGV[1])" 0 'hello world'
-- "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed"

Lua写入Redis日志时,该方法调用比较简单,只是需要设置日志级别,并且只有当设置的日志级别大于等于Redis配置的日志级别时,redis.log方法才能正常记录日志。

Redis的日志级别共有四种,由低到高排序,分别如下

  • redis.LOG_DEBUG 适用于开发、测试阶段,会打印更多的DEBUG信息

  • redis.LOG_VERBOS 比DEBUG打印的信息少一些,仍然会有不少无用信息,可以用于开发测试阶段

  • redis.LOG_NOTICE 一般适用于生产模式,Redis默认的就是该级别配置

  • redis.LOG_WARNING 只记录警告信息

日志方法的第二个参数,只能是一个字符串,因此在执行过程中可能需要进行类型转换

Lua与Redis类型转换

Lua在Redis的使用过程中,是一个嵌入的脚本,调用过程是Redis调用Lua,Lua脚本执行,执行完成后,再将结果返回给Redis,Redis再将结果返回给客户端。因此这个过程中会出现Redis执行结果类型到Lua数据类型的转换,完成后,从Lua类型再转为Redis类型的转换。转换规则参考下表

调用redis.call后结果转为Lua数据类型,即Redis to Lua类型转换对应表

Redis返回的数据类型 Lua数据类型
integer(整数回复) number(数字类型)
bulk replay(字符串) string(字符串类型)
多行字符串 table(数组形式)
status(状态回复) table(只有一个ok字段的数组)
error(错误回复) table(只有一个err字段的数组)

当Lua脚本执行完成,将结果返回给Redis客户端时,需要转为Redis数据类型,即Lua to Redis类型转换对应表

Lua数据类型 Redis返回数据类型
number(数字类型) integer(整数回复)
string(字符串类型) bulk replay(字符串)
table(数组形式) 多行字符串
table(只有一个ok字段的数组) status(状态回复)
table(只有一个err字段的数组) error(错误回复)

调用Lua指令

EVAL script numkeys key [key...] arg [arg...]

EVALSHA sha1 numkeys key [key...] arg [arg...]

SCRPIT EXISTS sha1 [sha1...]

SCRIPT FLUSH

SCRIPT KILL

SCRIPT LOAD script
  • EVAL指令

Redis通过EVAL指令来解释并执行Lua脚本。主要是通过numkeys、key或arg向Lua脚本传递数据。

numkeys:key的数量,可以为0不传递键

script:Lua脚本,不能是函数

key列表:从第三个参数起,与numkeys数量相同的参数为要操作的Redis键。访问方式KEYS[1]、KEYS[2],1为起始索引

arg列表:指定numkeys后的参数,全部都是参数,访问ARGV[1]、ARGV[2],1为起始索引

# 使用Lua脚本将参数转为数组返回
EVAL "return {ARGV[1],ARGV[2]}" 0 hello world
# 1) "hello"
# 2) "world"

# 使用Lua脚本设置一个字符串缓存
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 myluastr 'hello lua set'
# OK
# 获取值,GET不需要值,因此只传递了KEY
EVAL "return redis.call('GET', KEYS[1])" 1 myluastr
# "hello lua set"

# 设置myluastr有效时间
EVAL "return redis.call('EXPIRE', KEYS[1], ARGV[1])" 1 myluastr 20
# (integer) 1
TTL myluastr
# (integer) 15

# 使用LPUSH
EVAL "return redis.call('LPUSH',KEYS[1], ARGV[1], ARGV[2], ARGV[3])" 1 mylualist 1 2 3
# (integer) 3
  • EVALSHA/SCRIPT LOAD指令

EVALSHA指令根据一段Lua脚本的SHA1值进行脚本调用。当一段脚本比较大时,每次调用都使用脚本直接调用,需要较大的网络传输量,为了解决这个问题,Redis提供了EVALSHA指令,可根据脚本的SHA1值进行调用。当一个脚本在Redis中执行时,会进行缓存,并计算SHA1,当再次执行该脚本时,只需要使用该SHA1值使用EVALSHA指令进行调用即可。如果指定的SHA1值在Redis中没有对应的缓存脚本时,将会报错。

该指令的使用方式同EVAL指令除了将脚本替换为SHA1外,其他参数方式基本一致。

SCRIPT LOAD指令用于将一个脚本加载到Redis混存,并返回脚本的SHA1值。在将脚本加入到缓存时,并不会执行脚本。EVAL指令在执行过程中,也会将脚本加入到缓存,但是会立即执行指令。根据SCRIPT LOAD返回的脚本SHA值,可以使用EVALSHA指令进行相应的调用。

# 创建一个设置字符串的脚本
SCRIPT LOAD "return redis.call('set', KEYS[1], ARGV[1])"
# "55b22c0d0cedf3866879ce7c854970626dcef0c3"
# 使用EVALSHA调用
EVALSHA "55b22c0d0cedf3866879ce7c854970626dcef0c3" 1 a b
# OK
# 创建获取字符串指令
SCRIPT LOAD "return redis.call('get', KEYS[1])"
# "4e6d8fc8bb01276962cce5371fa795a7763657ae"
EVALSHA "4e6d8fc8bb01276962cce5371fa795a7763657ae" 1 a
# "b"
  • SCRPIT EXISTS指令

SCRIPT EXISTS指令用于检查指定SHA1值对应的脚本是否在Redis缓存中,返回值是一个列表,其值只包含0和1。1表示指定的SHA1对应的脚本已在Reids,0为不存在。如果调用SHA1对应的脚本不存在时,将报NOSCRIPT错误,因此调用前使用SCRIPT EXISTS检查脚本是否存在,可以避免错误的发生。

# SHA不存在
EVALSHA "FF11111111111111111111111111111111" 1 a
# (error) NOSCRIPT No matching script. Please use EVAL.

# 检查
SCRIPT EXISTS "FF11111111111111111111111111111111"
# 1) (integer) 0

# 返回列表的顺序,与检查时的SHA顺序一一对应
SCRIPT EXISTS "4e6d8fc8bb01276962cce5371fa795a7763657ae" "F111111111111111"
# 1) (integer) 1
# 2) (integer) 0
  • SCRIPT FLUSH指令

Redis在使用EVAL或者SCRIPT LOAD指令将一个脚本对应的SHA1缓存后,将不会主动删除缓存的脚本。如果要将不需要的脚本清除(如一段脚本有业务BUG,修改后继续使用,此时旧的脚本缓存已无意义),可以使用SCRIPT FLUSH指令清除,该指令会将所有的缓存脚本清除。当客户端再次采用EVAL等指令时,只缓存有有用的脚本。

# 清除所有脚本
SCRIPT FLUSH
# OK
# 检查上一节存在的脚本
SCRIPT EXISTS "4e6d8fc8bb01276962cce5371fa795a7763657ae"
# 1) (integer) 0
  • SCRIPT KILL指令

Lua脚本在Redis中运行时具有原子性,一段脚本要么全部执行,要么全部不被执行;并且在脚本执行过程中,其他指令将不能插入。正是具有这样的特性,Lua脚本更适合进行一些业务性的开发,但是如果一段脚本运行时间过程,也会导致Redis无法对其他客户端提供服务。

Redis可以限制Lua脚本的运行最大时间,在redis.conf中使用lua-time-limit进行限制,其默认值为5000(毫秒)。如果不改变该配置,那么如果一个脚本运行时,在5秒内,其他客户端发送的指令不被Redis所接收,当超过5秒,该Lua脚本还未执行完成,此时Redis可以接收其他客户端指令,但是由于上一个原子性的脚本未执行完成,此时即使接收了其他指令,也会立即返回BUSY错误提示。并且提示:只能使用SCRIPT KILL or SHUTDOWN NOSAVE两个指令解除当前这种状态。

根据Redis的描述,一旦出现Lua脚本先入死循环,除了使用SCRIPT KILL或者SHUTDOWN NOSAVE外,都无法再使得Redis恢复正常对客户端的工作,这是由于Lua脚本运行的原子性决定的。并且,对于脚本中如果有更新或设置操作时,使用SCRIPT KILL指令业务解决此问题,因为之前可能已经使用了SET指令写入了一个数据,Lua脚本要求全部执行,而后半部分由于死循环,无法成功,因此也会导致脚本无法被终止。当发生这种情况时,只能靠终止Redis服务,危害性更大。因此在使用Lua脚本时,需要格外小心,务必保证业务脚本稳定无错。

现在有一个操作,用户购买了一件商品,要进行扣款操作(不考虑设计问题,只演示Lua脚本指令功能),要求用户的账户金额需要超过当前支付的金额才能扣款,否则等待用户充值(此处为演示SCRIPT KILL使用循环等待),扣款成功后,返回用户账号余额。

local userAccount = tonumber(redis.call('GET', KEYS[1]))
local dedu = false

local deduNum = tonumber(ARGV[1])

while not dedu do
if userAccount >= deduNum then
redis.call('DECRBY', KEYS[1], deduNum)
dedu = true
else
userAccount = tonumber(redis.call('GET', KEYS[1]))
end
end

return redis.call('GET', KEYS[1])

现在假定用户账号account:a具有存款100元,现在购买了意见商品,价值58元,调用该脚本,成功扣款,并返回余额。

# 客户端1
# 这里将上述脚本存储在了文件中,因此直接使用redis-cli --eval指令进行调用,该指令的格式参加下面章节的介绍
redis-cli -p 6300 -a UUUUU --eval /home/lixl/lua/sk.lua account:a , 58
# "42"
# 扣款成功,余额还有42元
# 假定再购买一件商品,价值50元,在当前客户端继续调用脚本,参数修改为50,在另外一个客户端获取该用户余额
# 此时客户端1被阻塞
redis-cli -p 6300 -a UUUUU --eval /home/lixl/lua/sk.lua account:a , 50
# 客户端2采用SCRIPT KILL后,客户端1结束,即脚本结束执行,被该指令杀死
# (error) ERR Error running script (call to f_04203e36167f501f195f5f05f9b6db16a3fc3578): @user_script:11: Script killed by user with SCRIPT KILL...



# 客户端2
GET account:a
# 此时未到5秒,Redis不处理该指令,被阻塞
# 5秒后,由于客户端1脚本先入死循环,因此客户端2返回了BUSY错误信息,此时该Redis不在支持正常的指令服务
# (error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
# (2.58s)

# 此时提示只能使用SCRIPT KILL 或者SHUTDOWN NOSAVE操作终止这种状态,继续使用其他指令,业务成功
# 采用SCRIPT KILL解除此问题,注意客户端1此后的变化
SCRIPT KILL
# OK
# 此后无论在哪个客户端,Redis恢复服务

--eval指令

redis-cli [-h ....] [-p ...] [-a passwrod] --eval luaScriptPath key , arg [arg ...]

端口默认可以省略,HOST如果在本机可以忽略,如果未配置密码,可以忽略-a选项

--eval 之后跟随Lua脚本路径,后面紧跟着key信息,key与参数之间使用,分割,因此逗号前都是Key,可以多个;逗号后都是参数,也可以是多个;注意逗号前后必须有空格

还有一种情况,是无法使用SCRIPT KILL解决的,必须重启Redis服务,只要在一个脚本中有更新或添加操作时,由于必须保证脚本的原子性,但是更新和添加操作已写入了数据,因此该脚本无法被终止。对上述示例进行些许调整:当用户支付订单时,同时创建了用户购物列表,新增加了参数商品ID。如下

local userAccount = tonumber(redis.call('GET', KEYS[1]))
local dedu = false

local deduNum = tonumber(ARGV[1])
-- 写入商品购买信息
redis.call('LPUSH', KEYS[2], ARGV[2])
while not dedu do
if userAccount >= deduNum then
redis.call('DECRBY', KEYS[1], deduNum)
dedu = true
else
userAccount = tonumber(redis.call('GET', KEYS[1]))
end
end

return redis.call('GET', KEYS[1])

在调用时,仍然采用了上述最后一个购买50的请求,两个客户端分别演示

# 客户端1
redis-cli -p 6300 -a UUUUU --eval /home/lixl/lua/sk.lua account:a product:a , 50 1
# 此时脚本死循环,陷入等待
# Error: Server closed the connection

# 客户端2
GET account:a
# 同上述示例一致,一开始阻塞5秒后,返回BUSY错误,客户端1仍先入等待
# (error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
# (2.37s)
# 尝试使用SCRIPT KILL指令,此时无法终止脚本执行,提示必须使用SHUTDOWN NOSAVE指令
# (error) UNKILLABLE Sorry the script already executed write commands against the dataset. You can either wait the script termination or kill the server in a hard way using the SHUTDOWN NOSAVE command.
SHUTDOWN NOSAVE
# 此时Redis被停止,客户端1返回错误

最后一种情况比较危险,因为需要停服,如果是生产环境,则可能会造成严重的服务宕机事件。因此在编写Lua脚本时需要格外小心,并进行合理的测试。

使用Lua脚本提高业务服务性能

在上面的章节,介绍了Lua脚本的优势,除了原子性外,最重要的优点就是一次链接可以将一段业务全部封装到脚本中,减少打开链接以及网络传输的消耗,提高性能。在之前的介绍的过程中,使用Lua都是非常简单的操作,如写入一个字符串数据,读取一个字符串数据,业务体现这方面的优势,这里做一个实际的例子,用于说明这方面的使用情况。

需求读取用户收藏列表,按阅读和收藏时间顺序排序

  • 用户收藏图书,将此收藏的ID加入到收藏有序列表,值为时间戳,可以根据时间排序

  • 每次用户阅读收藏图书时,更新收藏列表,阅读的图书前置

  • 同时将用户收藏的信息写入收藏详情,主要包括,收藏ID、图书ID、用户标识等

  • 获取用户收藏列表时(不分页),一方面根据列表顺序组装数据(收藏详情),另一封面需要从另外的图书缓存中获取书名、封面信息;从用户缓存中获取用户头像、用户名信息,组成完整数据返回。

按照原有的Java方面的做法(API返回用户收藏列表),其步骤如下

  • 建立Redis连接(1),读取列表

  • 循环列表,每个ID,建立连接(n),读取收藏详情

  • 每个收藏详情根据图书ID读取书名和封面,需要建立连接(n)读取图书详情

  • 建立连接(1),读取当前用户的头像及姓名

上述一共需要建立2n+2次连接,n为收藏图书的数量,虽然都在局域网,但是连接次数过多,也会有一定的消耗。

这里描述一下各个缓存对象的信息以及key

收藏列表,有序集合,存储收藏ID,Key= collectList:用户标识

收藏详情,Hash,key=collects, item=收藏ID,数据格式:{id:1, userCode: "", bookId:""}

图书详情,Hash,key=books,item=图书ID,数据格式:{bookId:"", tilte="", cover: ""}

用户详情,Hash,key=users,item=用户唯一标识,数据格式:{userCode:"", name: "", avatar: ""}

入库的数据:

图书 {title: "庆余年", bookId: "b0001", cover: "http://localhost/b0001.jpg"}

用户 {name: "迟鑫月", userCode: "chixinyue", avatar: "http://localhost/chixinyue.jpg"}

收藏 {id: 1, bookId: "b0001", userCode: "chixinyue"}

列表 只有一个元素:1

如果将此需求采用Lua脚本实现,其具体的代码如下

-- 调用形式
-- EVALSHA "sha1" 4 收藏列表KEY 用户数据key 图书数据key 收藏详情key 用户唯一标识
-- 组织参数
local collectListKey = KEYS[1]
local usersKey = KEYS[2]
local bookKey = KEYS[3]
local collectKey = KEYS[4]

local userCode = ARGV[1]

-- 全部列表
local list = redis.call('ZRANGE', collectListKey .. ':' .. userCode, 0, -1)

local result = {}

for _, v in pairs(list) do
-- 收藏信息
local collectBook = cjson.decode(redis.call('HGET', collectKey, v))
-- 用户数据
local user = cjson.decode(redis.call('HGET', usersKey, userCode))
collectBook.user = user
-- 图书数据
local book = cjson.decode(redis.call('HGET', bookKey, collectBook.bookId))
collectBook.book = book
-- 清除不需要的数据
collectBook.bookId = nil
collectBook.userCode = nil
-- redis列表类型中,只能存储字符串
result[#result + 1] = cjson.encode(collectBook)
end

-- 返回列表
return result

以上Lua脚本以文件形式存储在/home/lixl/lua/book.lua中,使用SCRIPT LOAD指令加入到缓存

redis-cli -p 6300 -a UUUU SCRIPT LOAD "$(cat /home/lixl/lua/book.lua)"
# "b2858ec7b866034fb2aeb8f4d614a18970f70b4e"
# 返回的数据即为该脚本的SHA1,后续使用该值进行调用

新起客户端,当要查询用户迟鑫月的收藏列表时,Java代码,可以直接调用该脚本,完成整个列表数据的组织及返回数据的封装

EVALSHA "b2858ec7b866034fb2aeb8f4d614a18970f70b4e" 4 collectList users books collects chixinyue
# 1) {"id":1,"user":{"avatar":"http://localhost/chixinyue.jpg","userCode":"chixinyue","name":"迟鑫月"},"book":{"bookId":"b0001","cover":"http://localhost/b0001.jpg","title":"庆余年"}}

上述脚本中,返回了一个列表,列表中是一个字符串,该字符串是一个JSON格式的字符串,可以再次组装成一个JSON数组返回给客户端。通过这种方式,可以有效的提高性能,并且Lua脚本比较简单,同时执行性能高,对于这种非核心的数据组织业务,可以采用这种方式实现。

以上是关于Redis中使用Lua脚本的主要内容,如果未能解决你的问题,请参考以下文章

Redis使用LUA脚本

Redis使用LUA脚本

Redis+Lua实现限流

PHP 中Lua嵌入redis

Redis与Lua脚本

lua脚本~ Redis调用