Redis 官网:

https://redis.io (英文)

https://www.runoob.com/redis/redis-tutorial.html (中文)

 

Lua 官网:

http://www.lua.org (英文)

https://www.runoob.com/lua/lua-tutorial.html (中文)

 

一、引言

Redis是高性能的key-value数据库,在很大程度克服了memcached这类key/value存储的不足,在部分场景下,是对关系数据库的良好补充。

得益于超高性能和丰富的数据结构,Redis已成为当前架构设计中的首选key-value存储系统。

Redis自2.6.0加入了Lua脚本相关的命令,EVAL、EVALSHA、SCRIPT EXISTS、SCRIPT FLUSH、SCRIPT KILL、SCRIPT LOAD,自3.2.0加入了Lua脚本的调试功能和命令。

Lua脚本可以运行在任何平台上,也可以嵌入到大多数语言中,来扩展其功能。

Lua脚本是用C语言写的,体积很小,运行速度很快。

使用Redis Lua脚本功能,用户可以向服务器发送Lua脚本来执行自定义动作,获取脚本的相应数据。Redis服务器会单线程原子性执行Lua脚本,保证Lua脚本在执行过程中不会被任意其他请求打断。

虽然Redis官网上提供了200多个命令,但做程序设计时还是避免不了为了实现一小步业务逻辑而多次调用Redis的情况

以compare and set场景为例:

如果使用Redis原生命令,需要从Redis中获取这个key,然后提取其中的值进行比对:

1)如果相等就不做处理;

2)如果不相等或者key不存在则将key设置成目标值。

仅仅一个单点的compare and set操作就需要与Redis通讯两次。此外,这种分散操作无法利用Redis的原子特性,占用多次网络IO。

今天我们就来探讨一下如何优雅地应对上述场景。

 

二、Redis 与 Lua

在介绍Lua之前,我们需要先对这个语言有个初步了解。

Lua 是一个小巧的脚本语言,几乎可以运行在所有操作系统和平台上。我们一般不会用Lua处理特别复杂的事务,因此只需了解一些lua的基本语法即可。

Redis问世之后,其开发者也意识到了开篇提到的问题,因此Redis从2.6版本开始支持Lua脚本。请见米扑博客:Redis系列(6)—— Lua

新版本的Redis还支持Lua Script debug,感兴趣的小伙伴可以去官网的Documentation中找到对应介绍和QuickStart。

有了Lua脚本之后,使用Redis程序时便能够在以下方面实现显著提升:

1)减少网络开销:本来N次网络请求的操作,可以用一个请求完成。原先N次请求的逻辑放在Redis服务器上完成,减少了网络往返时延;

2)原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。这是一个重要特性,一定要拿小本本记好。至于为什么是一个原子操作,我们以后再分析;

3)复用:客户端发送的脚本会永久存储在Redis中。这样其他客户端就可以复用这一脚本,而不需要使用代码完成同样的逻辑。

所以现在流传一句话:要想学好Redis,必会Lua Script

 

三、通过Lua脚本实现compare and set

接下来我们就实现一个简单的compare and set,并通过这个例子感受一下Lua脚本给Redis使用带来的全新体验。

首先看一下如何让Redis执行Lua脚本。

3.1 Redis的EVAL

格式:

Redis 127.0.0.1:6379> EVAL script  numkeys key [key ...] arg [arg ...]

示例:

Redis 127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2  key1 key2 first second

Redis 127.0.0.1:6379> eval "return redis.call('set',KEYS[1],'bar')" 1  foo

命令说明:

1)script: 参数是一段 Lua 5.1 脚本程序,脚本不必(也不应该)定义为一个Lua函数

2)numkeys: 用于指定键名参数的个数

3)key [key ...]: 从 EVAL 的第三个参数开始算起,表示在脚本中所用到的Redis键(key)。在Lua中,这些键名参数可以通过全局变量 KEYS 数组,用1为基址的形式访问( KEYS[1] ,KEYS[2],依次类推)

4)arg [arg ...]: 附加参数,在Lua中通过全局变量ARGV数组访问,访问的形式和KEYS变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)

这里借用一下官网的例子 https://redis.io/commands/eval

127.0.0.1:6379[10]> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"

上述脚本直接返回了入参。

1)eval 为Redis关键字;

2)第一个引号中的内容就是Lua脚本,即 "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}"

3)2为参数个数;

4)key1和key2是KEYS[1]、KEYS[2]的入参;

5)first和second是ARGV[1],ARGV[2]的入参。

大家可以简单地将KEYS[1],KEYS[2],ARGV[1],ARGV[2]理解为占位符。

例如:

127.0.0.1:6379[10]> eval "return redis.call('set',KEYS[1],'bar')" 1 foo
OK
127.0.0.1:6379[10]> get foo
"bar"

127.0.0.1:6379[10]> eval "return 10" 0
(integer) 10

127.0.0.1:6379[10]> eval "return {1,2,{3,'Hello World!'}}" 0
1) (integer) 1
2) (integer) 2
3) 1) (integer) 3
   2) "Hello World!"

 

3.2 执行脚本文件和缓存脚本

如果只能在命令行中写脚本执行,遇到复杂的脚本程序岂不是会抓狂?

下面我们来看一下,如何让Redis执行Lua脚本文件,同时也验证一下lua脚本的复用特性(以后我们再也不需要定期批量删除某些符合特定规则的key了)。

Redis 127.0.0.1:6379> SCRIPT LOAD  script
Redis 127.0.0.1:6379> EVALSHA sha1  numkeys key [key ...] arg [arg ...]

Redis提供了一个SCRIPTLOAD命令,命令后面的script即为Lua脚本

命令将脚本script添加到脚本缓存中,但并不立即执行这个脚本。执行命令后,Redis会返回一个SHA1串,第二个EVALSHA命令即可执行。

需要注意的是,脚本可以在缓存中保留无限长的时间,直到执行完SCRIPT FLUSH。我们来看一下效果。

127.0.0.1:6379[10]> script load "return 'I love mimvp.com'"
"14de891d6d2c3931752008a18d764dd63b4ba4a4"
127.0.0.1:6379[10]> evalsha "14de891d6d2c3931752008a18d764dd63b4ba4a4" 0
"I love mimvp.com"

 

Redis还支持直接执行Lua脚本文件。

首先,编写并存储一个Lua脚本

cd ~/script/
vim test.lua

添加 lua 脚本内容:

local name = KEYS[1]
return "hello "..name.."!"

 

然后,调用 Redis-cli –eval 命令,执行lua脚本:

1)redis 无密码

$ redis-cli --eval test.lua mimvp.com 
"hello mimvp.com!"

2)redis 有密码(端口号 6380,密码 123456)

$ redis-cli -h 127.0.0.1 -p 6380 -a '123456' --eval test.lua mimvp.com
"hello mimvp.com!"

说明:Redis-cli –eval命令语法,基本与原eval语法相同。

 

3.3 使用Lua脚本实现compare and set

compareand set的实现逻辑是这样的:

首先,获取Redis中指定key的value

然后,与给定值进行比较:如果相等,则将key设定为目标值并返回一个标识符;如果不相等,则不作任何操作并返回一个标识符。

cd ~/script/
vim compareAndSet.lua

添加 lua 脚本内容:

if redis.call('get', KEYS[1]) == ARGV[1]  then
     redis.call('set', KEYS[1], ARGV[2]);
     return 1
else
     return 0 end

 

下面我们来测试一下这个脚本。

首先向Redis的指定key compareAndSet:key写入一个值value

127.0.0.1:6379> get "compareAndSet:key"
(nil)
127.0.0.1:6379> set "compareAndSet:key" value
OK
127.0.0.1:6379> get "compareAndSet:key"
"value"

在Redis中执行lua脚本

$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval compareAndSet.lua compareAndSet:key , value new_value
(integer) 1
$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval compareAndSet.lua compareAndSet:key , value new_value
(integer) 0
$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval compareAndSet.lua compareAndSet:key , value new_value
(integer) 0

可以看到第一次执行返回1,说明修改成功了,if判断符合条件;再使用原参数执行时返回0,说明没有做任何修改,if判断不符合条件。

我们再查询一下compareAndSet:key这个key

127.0.0.1:6379> get compareAndSet:key
"value"

// 此处执行 lua 脚本, 然后再获取key的值

127.0.0.1:6379> get compareAndSet:key
"new_value"
127.0.0.1:6379> get compareAndSet:key
"new_value"
127.0.0.1:6379> get compareAndSet:key
"new_value"

可以看到compareAndSet:key这个key已经被修改为new_value

 

再举例:Redis + lua 实现屏蔽频繁访问的IP地址

实现一个访问频率控制,某个IP地址在短时间内频繁访问页面,需要记录并检测出来,就可以通过Lua脚本高效的实现

在redis客户端机器上,新建一个文件ratelimit.lua

cd ~/script/
vim ratelimit.lua

添加 lua 脚本内容:

-- limit rate of ip access 
-- Author: blog.mimvp.com
-- Date  : 2019.08.08

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

接着,在redis客户端机器上,运行 lua 脚本:

$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval  ratelimit.lua rate.limit:127.0.0.1 , 10 3

说明:

--eval 参数是告诉redis-cli读取并运行后面的Lua脚本,ratelimit.lua是脚本的位置(相对位置,如上为当前目录下的 ratelimit.lua 脚本文件),后面跟着是传给Lua脚本的参数。

其中,","前的rate.limiting:127.0.0.1是要操作的键,可以在脚本中用KEYS[1]获取,","后面的10和3是参数,在脚本中能够使用ARGV[1]和ARGV[2]获得。

注:"," 两边的空格不能省略,否则会出错

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

测试运行如下:

$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval ratelimit.lua rate.limit:127.0.0.1 , 10 3
(integer) 1
$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval ratelimit.lua rate.limit:127.0.0.1 , 10 3
(integer) 1
$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval ratelimit.lua rate.limit:127.0.0.1 , 10 3
(integer) 1

$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval ratelimit.lua rate.limit:127.0.0.1 , 10 3
(integer) 0
$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval ratelimit.lua rate.limit:127.0.0.1 , 10 3
(integer) 0
$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval ratelimit.lua rate.limit:127.0.0.1 , 10 3
(integer) 0
$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval ratelimit.lua rate.limit:127.0.0.1 , 10 3
(integer) 0

$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval ratelimit.lua rate.limit:127.0.0.1 , 10 3
(integer) 1
$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval ratelimit.lua rate.limit:127.0.0.1 , 10 3
(integer) 1
$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval ratelimit.lua rate.limit:127.0.0.1 , 10 3
(integer) 1

$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval ratelimit.lua rate.limit:127.0.0.1 , 10 3
(integer) 0
$ redis-cli -h 127.0.0.1 -p 6379 -a '123456' --eval ratelimit.lua rate.limit:127.0.0.1 , 10 3
(integer) 0

 

 Lua 应用场景

Lua脚本现在用在很多游戏上,主要是Lua脚本做到可以嵌入到其他程序中运行,游戏升级的时候,可以直接升级脚本,而不用重新安装游戏。

比如游戏的很多关卡,只需要增加lua脚本,在游戏中嵌入Lua解释器,游戏团队线上更新Lua脚本,然后游戏自动下载最新的游戏关卡。

例如之前很多的游戏《愤怒的小鸟》就是用Lua语言实现的关卡。

 

 

四、总结

我们通过lua脚本实现了一个简单的compareAndSet操作。

下面我们通过这个例子来验证一下开篇提到的特性。

1)减少网络开销:不使用脚本的情况下,我们实现一个compareAndSet至少需要与Redis交互两次,而现在只需要执行一次操作即可完成,使用脚本,减少了网络往返时延;

2)原子操作:得益于Redis的设计,Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。因此在编写脚本的过程中无需担心出现竞态条件,无需使用事务,感兴趣的可以百度或等待以后后续文章更新;

3)复用:可以将一系列操作封装成一个Lua脚本,存储在文件或Redis上,下次使用时直接调用即可,其他客户端可以复用这一脚本而不需要使用代码完成同样的逻辑。

 

使用Lua脚本需注意的问题:

1)单线程执行。所有Lua命令都在同一个Lua解释器中执行,当一个脚本执行时,其他脚本或Redis命令都不能执行。如果脚本执行慢,会比较麻烦。

2)写纯函数脚本

3)Redis集群模式要求单个Lua脚本操作的Key必须在同一节点上,但是Cluster会将数据自动分布到不同的节点(虚拟的16384个slot)。阿里云集群版官网也有说明:在Redis集群版实例中,事务、脚本等命令要求的key必须在同一slot中,否则会返回错误信息:command keys must in same slot。

读到这里,希望你已经对Redis+Lua有了一定的了解,并能使用脚本完成一些简单的复合操作。后续还会继续更新一些基于Lua脚本+java程序实现的分布式数据结构,如延迟队列、可重入锁等,感兴趣的小伙伴可以持续关注。

 

本文转自Redis进阶应用:Redis+Lua脚本实现复合操作

 

 

参考推荐

Redis系列(6)—— Lua

Redis 核心知识图谱

Redis 数据结构底层实现

单机开启多个 Redis 实例

Redis 主从集群配置高可用技术方案

BloomFilter + Redis 大数据去重策略的实现

Python 操作 redis 接口函数

Python 操作 Redis 数据库的函数

Redis 双主备份实现

php-redis 各种函数中文手册

统计Redis中各种数据的大小

Redis 常用命令

Redis系列(2)—— 概述

Redis系列(3)—— 数据结构

Redis,MemCached,MongoDB概述