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脚本的主要内容,如果未能解决你的问题,请参考以下文章