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 核心知识图谱

Redis 数据结构底层实现

单机开启多个 Redis 实例

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

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

Python 操作 redis 接口函数

Python 操作 Redis 数据库的函数

Redis 双主备份实现

php-redis 各种函数中文手册

统计Redis中各种数据的大小

Redis 常用命令

Redis系列(2)—— 概述

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

Redis系列(4)—— 高级功能

Redis系列(5)—— 排序与订阅

Redis系列(6)—— Lua

Redis实例(8)—— 事务

Redis实例(10)—— 持久化

Redis实例(11)—— 虚拟内存

Redis实例(12)—— 管线

Redis实例(13)—— 服务器管理

Redis实例(14)—— 内存优化

Redis 核心知识图谱

单机开启多个 Redis 实例

Redis,MemCached,MongoDB概述