抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

Redis之Lua

简介

   Lua 是一个小巧的脚本语言。是巴西里约热内卢天主教大学(Pontifical Catholic University of Rio de Janeiro)里的一个研究小组,由Roberto Ierusalimschy、Waldemar Celes 和 Luiz Henrique de Figueiredo所组成并于1993年开发。 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。Lua由标准C编写而成,几乎在所有操作系统和平台上都可以编译,运行。Lua并没有提供强大的库,这是由它的定位决定的。所以Lua不适合作为开发独立应用程序的语言。Lua 有一个同时进行的JIT项目,提供在特定平台上的即时编译功能。

​ Lua脚本可以很容易的被C/C++ 代码调用,也可以反过来调用C/C++的函数,这使得Lua在应用程序中可以被广泛应用。不仅仅作为扩展脚本,也可以作为普通的配置文件,代替XML,ini等文件格式,并且更容易理解和维护。 Lua由标准C编写而成,代码简洁优美,几乎在所有操作系统和平台上都可以编译,运行。一个完整的Lua解释器不过200k,在目前所有脚本引擎中,Lua的速度是最快的。这一切都决定了Lua是作为嵌入式脚本的最佳选择。

使用Lua脚本的好处

  • 减少网络开销:可以将多个请求通过脚本的形式一次发送,减少网络时延和请求次数。
  • 原子性的操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。因此在编写脚本的过程中无需担心会出现竞态条件,无需使用事务。  
  • 代码复用:客户端发送的脚步会永久存在redis中,这样,其他客户端可以复用这一脚本来完成相同的逻辑。  
  • 速度快:见 与其它语言的性能比较, 还有一个 JIT编译器可以显著地提高多数任务的性能; 对于那些仍然对性能不满意的人, 可以把关键部分使用C实现, 然后与其集成, 这样还可以享受其它方面的好处。**     
  • 可以移植:只要是有ANSI C 编译器的平台都可以编译,你可以看到它可以在几乎所有的平台上运行:从 Windows 到Linux,同样Mac平台也没问题, 再到移动平台、游戏主机,甚至浏览器也可以完美使用 (翻译成JavaScript)。   
  • 源码小巧:20000行C代码,可以编译进182K的可执行文件,加载快,运行快。

对于发生在 EXEC执行之前的错误,客户端以前的做法是检查命令入队所得的返回值:如果命令入队时返回 QUEUED ,那么入队成功;否则,就是入队失败。如果有命令在入队时失败,那么大部分客户端都会停止并取消这个事务。

不过,从 Redis 2.6.5 开始,服务器会对命令入队失败的情况进行记录,并在客户端调用 EXEC 命令时,拒绝执行并自动放弃这个事务。

在 Redis 2.6.5 以前, Redis 只执行事务中那些入队成功的命令,而忽略那些入队失败的命令。 而新的处理方式则使得在流水线(pipeline)中包含事务变得简单,因为发送事务和读取事务的回复都只需要和服务器进行一次通讯。

至于那些在 EXEC命令执行之后所产生的错误, 并没有对它们进行特别处理: 即使事务中有某个/某些命令在执行时产生了错误, 事务中的其他命令仍然会继续执行。

经过测试lua中发生异常处理方式和redis 事务一致,可以说这两个东西是一样的,但是lua支持缓存,可以复用脚本,这个是原来的事务所没有的

Lua脚本与事务

​ 从定义上来说, Redis 中的脚本本身就是一种事务, 所以任何在事务里可以完成的事, 在脚本里面也能完成。 并且一般来说, 使用脚本要来得更简单,并且速度更快

使用事务时可能会遇上以下两种错误:

  • 事务在执行 EXEC之前,入队的命令可能会出错。比如说,命令可能会产生语法错误(参数数量错误,参数名错误,等等),或者其他更严重的错误,比如内存不足(如果服务器使用 maxmemory 设置了最大内存限制的话)。

  • 命令可能在 EXEC调用之后失败。举个例子,事务中的命令可能处理了错误类型的键,比如将列表命令用在了字符串键上面,诸如此类。

执行LUA脚本

EVAL命令

语法
1
eval script numkeys key [key ...] arg [arg ...]

通过key和arg这两类参数向脚本传递数据,它们的值在脚本中分别使用KEYS和ARGV两个表类型的全局变量访问。

script: 是lua脚本

numkeys:表示有几个key,分别是KEYS[1],KEYS[2]…,如果有值,从第numkeys+1个开始就是参数值,ARGV[1],ARGV[2]…

注意: EVAL命令依据参数numkeys来将其后面的所有参数分别存入脚本中KEYS和ARGV两个table类型的全局变量。当脚本不需要任何参数时,也不能省略这个参数(设为0)

例子

Redis中可以使用EVAL命令执行相应的Lua脚本

1
2
> EVAL 'local val="Hello Jackey" return val' 0
"Hello Jackey"

​ 你可以像这样在交互模式下执行Lua脚本,这样更方便处理错误。只是这样还不够,有时候,我们需要给Lua脚本传入一些参数。细心的同学一定注意到了,脚本的后面还有一个数字0,它的意思的不传入参数。

那怎么传参数呢?

1
2
> EVAL 'local val=KEYS[1] return val.." "..ARGV[1]' 1 Hello Redis
"Hello Redis"

其实也很简单,传入的参数都是kv形式的,这个数字代表传入参数的key的数量,再后面就是n个key和n个value。在脚本中,可以理解为从KEYS数组和ARGV数组中获取对应的值,下标是从1开始的。

上面例子中的两个点是Lua脚本中字符串连接的操作符

现在我们已经知道怎么在Redis中执行Lua脚本了,可是这样的脚本和Redis没有关系啊,怎么才能操作Redis中的数据呢?

1
2
3
4
> set name lucy
OK
> EVAL 'local val = ARGV[1].." "..redis.call("get",KEYS[1]) return val' 1 name hello
"hello lucy"

使用redis.call或redis.pcall(以后会提到)就可以操作redis了。

需要注意的是,如果返回下面的错误,说明要获取的key不存在

1
2
> EVAL 'local val=ARGV[1].." "..redis.call("get",KEYS[1]) return val' 1 me Hello
(error) ERR Error running script (call to f_eb11f8ddeeee07cc88d1f3bd103069284b83c5d8): @user_script:1: user_script:1: attempt to concatenate a boolean value

我们可以使用上面这种方法执行一些简单的Lua脚本,如果要执行更加复杂的Lua脚本,用EVAL命令就会显得臃肿且凌乱。所以Redis又提供了一种方法。

redis-cli –eval 命令

语法
1
redis-cli --eval path/to/redis.lua KEYS[1] KEYS[2] , ARGV[1] ARGV[2] ...
  • –eval,告诉redis-cli读取并运行后面的lua脚本         
  • path/to/redis.lua,是lua脚本的位置          
  • KEYS[1] KEYS[2],是要操作的键,可以指定多个,在lua脚本中通过KEYS[1], KEYS[2]获取             
  • ARGV[1] ARGV[2],参数,在lua脚本中通过ARGV[1], ARGV[2]获取。

注意: KEYS和ARGV中间的 ‘,’ 两边的空格,不能省略。

支持的标准库
库名 说明
Base 提供一些基础函数
String 提供用于字符串操作的函数
Table 提供用于表操作的函数
Math 提供数学计算函数
Debug 提供用于调试的函数
返回数据类型

​ Redis命令的返回值有5种类型,redis.call函数会将这5种类型的回复转换成对应的Lua的数据类型,具体的对应规则如下(空结果比较特殊,其对应Lua的false)

redis返回值类型和Lua数据类型转换规则
redis返回值类型 Lua数据类型
整数回复 数字类型
字符串回复 字符串类型
多行字符串回复 table类型(数组形式)
状态回复 table类型(只有一个ok字段存储状态信息)
错误回复 table类型(只有一个err字段存储错误信息)
Lua数据类型和redis返回值类型转换规则
Lua数据类型 redis返回值类型
数字类型 整数回复(Lua的数字类型会被自动转换成整数)
字符串类型 字符串回复
table类型(数组形式) 多行字符串回复
table类型(只有一个ok字段存储状态信息) 状态回复
table类型(只有一个err字段存储错误信息) 错误回复
例子

我们可以先写一个Lua文件,然后使用redis-cli命令来执行。

1
2
3
4
local name=redis.call("get", KEYS[1])
local greet=ARGV[1]
local result=greet.." "..name
return result
1
2
> redis-cli --eval hello.lua my_name,Hello
"Hello Jackey"

这样,我们就可以先写一个.lua文件,然后再使用redis-cli命令来执行了,看起来也不会很凌乱,使用这种方式传入参数时,不需要指定key的数量,而是用逗号分隔key和argv。

EVALSHA命令

简介

​ 在脚本比较长的情况下,如果每次调用脚本都需要将整个脚本传给Redis会占用较多的带宽。为了解决这个问题,Redis提供了EVALSHA命令,允许开发者通过脚本内容的SHA1摘要来执行脚本,该命令的用法和EVAL一样,只不过是将脚本内容替换成脚本内容的SHA1摘要。

​ Redis在执行EVAL命令时会计算脚本的SHA1摘要并记录在脚本缓存中,执行EVALSHA命令时Redis会根据提供的摘要从脚本缓存中查找对应的脚本内容,如果找到了则执行脚本,否则会返回错误:”NOSCRIPT No matching script. Please use EVAL.”

使用步骤
上传脚本文件

先计算脚本的SHA1摘要,并使用EVALSHA命令执行脚本。

1
2
$ redis-cli SCRIPT LOAD "$(cat hello.lua)"
"463ff2ca9e78e36cd66ee9d37ee0dcd59100bf46"

会得到一串十六进制的数字,这是这个脚本的唯一标识。拿到这个数字后,表示我们已经将脚本上传到服务器了,接下来就可以使用这个标识来执行脚本了。

运行脚本

获得返回值,如果返回“NOSCRIPT”错误则使用EVAL命令重新执行脚本。

1
2
> EVALSHA 463ff2ca9e78e36cd66ee9d37ee0dcd59100bf46 1 my_name Hello
"Hello Jackeyzhe"

虽然这一流程略显麻烦,但值得庆幸的是很多编程语言的Redis客户端都会代替开发者完成这一流程。执行EVAL命令时,先尝试执行EVALSHA命令,如果失败了才会执行EVAL命令。

SCRIPTLOAD "lua-script"  将脚本加入缓存,但不执行, 返回:脚本的SHA1摘要            

SCRIPT EXISTS lua-script-sha1   判断脚本是否已被缓存

SCRIPT FLUSH

​ 清空脚本缓存,redis将脚本的SHA1摘要加入到脚本缓存后会永久保留,不会删除,但可以手动使用SCRIPT FLUSH命令情况脚本缓存。

1
2
3
4
127.0.0.1:6379> script flush
OK
127.0.0.1:6379> SCRIPT FLUSH
OK

终止脚本

​ Redis中Lua脚本到默认执行时长是5秒,一般情况下脚本的执行时间都是毫秒级的,如果执行超时,脚本也不会停止,而是记录错误日志。

script kill命令
1
2
3
4
5
127.0.0.1:6379> script kill
(error) NOTBUSY No scripts in execution right now.
127.0.0.1:6379> SCRIPT KILL
(error) NOTBUSY No scripts in execution right now.

不过不建议手动终止脚本

lua-time-limit 5000

​ 为了防止某个脚本执行时间过长导致Redis无法提供服务(比如陷入死循环),Redis提供了lua-time-limit参数限制脚本的最长运行时间,默认为5秒钟。当脚本运行时间超过这一限制后,Redis将开始接受其他命令但不会执行(以确保脚本的原子性,因为此时脚本并没有被终止),而是会返回“BUSY”错误。

spring-data-redis操作lua

上面讲的是如何在redis控制台调用lua脚本,现在我们来讲下怎么在java里面调用
在java里面调用redis一般使用jedis,对于调用lua脚本来讲,spring-data-redis包做的封装使用起来更加方便,底层也是基于jiedis,所以我们这边直接讲spring-data-redis中的redisTemplate如何来调用lua

导入依赖

1
2
3
4
5
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>1.8.1.RELEASE</version>
</dependency>

编写代码

然后我们使用StringRedisTemplate这个类来操作

1
2
3
4
5
6
7
8
9
@Resource
private StringRedisTemplate stringRedisTemplate;

public <T> T runLua(String fileClasspath, Class<T> returnType, List<String> keys, Object ... values){
DefaultRedisScript<T> redisScript =new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(fileClasspath)));
redisScript.setResultType(returnType);
return stringRedisTemplate.execute(redisScript,keys,values);
}

这个框架把lua脚本封装成RedisScript对象,并且可以将lua脚本执行的结果自动转换为配置的java类型,然后只要直接调用execute方法即可
并且这个execute逻辑中封装了evalsha的优化,源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected <T> T eval(RedisConnection connection, RedisScript<T> script, ReturnType returnType, int numKeys,
byte[][] keysAndArgs, RedisSerializer<T> resultSerializer) {

Object result;
try {
result = connection.evalSha(script.getSha1(), returnType, numKeys, keysAndArgs);
} catch (Exception e) {

if (!exceptionContainsNoScriptError(e)) {
throw e instanceof RuntimeException ? (RuntimeException) e : new RedisSystemException(e.getMessage(), e);
}

result = connection.eval(scriptBytes(script), returnType, numKeys, keysAndArgs);
}

if (script.getResultType() == null) {
return null;
}

return deserializeResult(resultSerializer, result);
}

因为sha1的算法是通用的,所以在java客户端可以提前算出SHA1校验和,然后用evalsha来执行脚本,如果SHA1对应的脚本,那么还是用eval来执行,eval执行一次后,下次都可以直接调用evalsha了,减少网络开销

Lua脚本调试

​ 我们写完一个lua脚本,lua和redis的数据类型是不一致的,存在一个转换,并且如果遇到复杂逻辑的lua脚本,如果不能debug,只在自己脑子里面走这个逻辑,是不科学的,如果redis lua也提供了debug功能,要在redis客户端执行
​ 在运行lua的eval,加上-ldb即可开启debug功能,debug只支持eval命令

1
./redis-cli --ldb --eval /tmp/script.lua mykey somekey , arg1 arg2

然后提供了一些调试命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
lua debugger> help
Redis Lua debugger help:
[h]elp Show this help.
[s]tep Run current line and stop again.
[n]ext Alias for step.
[c]continue Run till next breakpoint.
[l]list List source code around current line.
[l]list [line] List source code around [line].
line = 0 means: current position.
[l]list [line] [ctx] In this form [ctx] specifies how many lines
to show before/after [line].
[w]hole List all source code. Alias for 'list 1 1000000'.
[p]rint Show all the local variables.
[p]rint <var> Show the value of the specified variable.
Can also show global vars KEYS and ARGV.
[b]reak Show all breakpoints.
[b]reak <line> Add a breakpoint to the specified line.
[b]reak -<line> Remove breakpoint from the specified line.
[b]reak 0 Remove all breakpoints.
[t]race Show a backtrace.
[e]eval <code> Execute some Lua code (in a different callframe).
[r]edis <cmd> Execute a Redis command.
[m]axlen [len] Trim logged Redis replies and Lua var dumps to len.
Specifying zero as <len> means unlimited.
[a]abort Stop the execution of the script. In sync
mode dataset changes will be retained.

Debugger functions you can call from Lua scripts:
redis.debug() Produce logs in the debugger console.
redis.breakpoint() Stop execution as if there was a breakpoint in the
next line of code.

用redis.debug() 可以打日志
用redis.breakpoint()在lua脚本里打断点
s和n都是跳到下行代码
c是跳到下个断点
list可以展示当前这条代码前后的代码

写个简单的lua脚本来测试下

1
2
3
4
5
6
7
8
9
10
local value1 = ARGV[1]
local value2 = ARGV[2]
redis.debug(value1)
redis.debug(value2)
if(value1>value2)
then
return "a"
else
return "b"
end

实战

redis生成全局id

在我们项目中使用redis生成全局id,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Autowired
private RedisTemplate<String,Long> redisTemplate;


public String nextID(){
String key = Prefix+simpleDateFormatThreadLocal.get().format(new Date());
Long existedID = redisTemplate.opsForValue().get(key);
if(existedID!=null){
redisTemplate.opsForValue().set(key,existedID+1);
return key+String.format("%04d",existedID+1);
}else{
redisTemplate.opsForValue().set(key,1L);
return key+"0001";
}
}

这段代码是存在问题的,在并发的情况下,get方法可以访问到相同的key,就会出现id重复的问题,测试代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
System.out.println("current:"+idGenerator.currentID());
Integer threadSize =5;
final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
Runnable runnable = new Runnable() {
@Override
public void run() {
for(int i =0 ;i<100;i++){
System.out.println(Thread.currentThread().getName()+":"+idGenerator.nextID());
}
countDownLatch.countDown();
}
};
for(int i =0;i<threadSize;i++){
new Thread(runnable,"Thread"+i).start();
}
countDownLatch.await();
System.out.println("current:"+idGenerator.currentID());

当然这边我们也可以使用乐观锁或者分布式锁来实现,但是锁自旋的逻辑还是有潜在危险的
如果用lua来实现,把这个阻塞动作放在redis服务器,那我们的代码就会很健壮了
新建一个lua脚本

1
2
3
4
5
6
7
8
9
10
local key = KEYS[1]
local id = redis.call('get',key)
if(id == false)
then
redis.call('set',key,1)
return key.."0001"
else
redis.call('set',key,id+1)
return key..string.format('%04d',id + 1)
end

对应调用java代码如下

1
2
3
4
5
6
7
8
public String nextIDLua(){
String key = Prefix+simpleDateFormatThreadLocal.get().format(new Date());
DefaultRedisScript<String> redisScript =new DefaultRedisScript<>();
redisScript.setLocation(new ClassPathResource("lua/genID.lua"));
redisScript.setResultType(String.class);
//System.out.println(redisScript.getSha1());
return redisTemplate.execute(redisScript,(RedisSerializer<?>) redisTemplate.getKeySerializer(),(RedisSerializer<String>)redisTemplate.getKeySerializer(),Lists.newArrayList(key));
}

把上面那个测试方法修改一下,进行测试
可以发现,第一份代码在多线程并发下是存在id重复问题的。
第二份代码避免了这个问题

实现一个对IP的限流

Lua脚本

​ LUA脚本如下,第一次使用incr对KEY(某个IP作为KEY)加一,如果是第一次访问,使用expire设置一个超时时间,这个超时时间作为Value第一个参数传入,如果现在递增的数目大于输入的第二个Value参数,返回失败标记,否则成功。redis的超时时间到了,这个Key消失,又可以访问啦。

1
2
3
4
5
6
7
8
9
local num = redis.call('incr', KEYS[1])
if tonumber(num) == 1 then
redis.call('expire', KEYS[1], ARGV[1])
return 1
elseif tonumber(num) > tonumber(ARGV[2]) then
return 0
else
return 1
end
Jedis调用代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Limiter {
public static void main(String[] args) {
JedisPool jedisPool = JedisPoolUtils.getInstance();
Jedis jedis = jedisPool.getResource();
try {
String lua = "local num = redis.call('incr', KEYS[1])\n" +
"if tonumber(num) == 1 then\n" +
"\tredis.call('expire', KEYS[1], ARGV[1])\n" +
"\treturn 1\n" +
"elseif tonumber(num) > tonumber(ARGV[2]) then\n" +
"\treturn 0\n" +
"else \n" +
"\treturn 1\n" +
"end\n";
Object result = jedis.evalsha(jedis.scriptLoad(lua), Arrays.asList("localhost"), Arrays.asList("10", "2"));
System.out.println(result);
}catch (Exception e){
e.printStackTrace();
}finally {
if(jedis != null){
try {
jedis.close();
}catch (Exception e){
e.printStackTrace();
}
}
}
}
}

评论