【Redis】Redis中使用Lua脚本

本文详细解析了Lua在Redis中的应用,介绍了eval和evalsha命令的用法,脚本的原子性特性,以及如何在Lua中执行Redis命令和数据类型转换。还涵盖了EVALSHA的优化原理、脚本缓存、安全性及lua-time-limit限制。

Lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放,其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

Lua具体语法参考:https://www.runoob.com/lua/lua-tutorial.html

脚本的原子性

Redis使用单个Lua解释器去运行所有脚本,并且Redis也保证脚本会以原子性(atomic)的方式执行:当某个脚本正在运行的时候,不会有其他脚本或 Redis命令被执行。这和使用MULTI/EXEC包围的事务很类似。

在其他别的客户端看来,脚本的效果要么是不可见的,要么就是已完成的。

另一方面,这也意味着,执行一个运行缓慢的脚本并不是一个好主意。写一个跑得很快很顺溜的脚本并不难,因为脚本的运行开销非常少,但是当你不得不使用一些跑得比较慢的脚本时,请小心,因为当这些蜗牛脚本在慢吞吞地运行的时候,其他客户端会因为服务器正忙而无法执行命令。

eval命令的使用

eval和evalsha命令是从Redis2.6.0版本开始引入的,使用内置的Lua解释器,可以对Lua脚本进行求值。

eval命令的说明:

> help eval

  EVAL script numkeys key [key ...] arg [arg ...]
  summary: Execute a Lua script server side
  since: 2.6.0
  group: scripting

参数说明:

  • script:一段Lua脚本程序,这段Lua脚本不需要也不应该定义函数,它运行在Redis服务器中。
  • numkeys:键名参数的个数。
  • key[]: 键名参数,表示在脚本中所用到的那些Redis键(key),这些键名参数可以在Lua中通过全局变量KEYS数组,用1为基址的形式访问(KEYS[1]、KEYS[2],以此类推)。
  • arg[]:不是键名参数的附加参数,可以在Lua中通过全局变量ARGV数组访问,访问的形式和KEYS变量类似(ARGV[1]、ARGV[2],诸如此类)。

举例说明:

> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 a b c d
1) "a"
2) "b"
3) "c"
4) "d"

返回结果是Redis multi bulk replies的Lua数组,这是一个Redis的返回类型,其他客户端库(如JAVA客户端)可能会将他们转换成数组类型。

Lua中执行redis命令

在Lua中,可以通过内置的函数redis.call()和redis.pcall()来执行redis命令。

redis.call()和redis.pcall()两个函数的参数可以是任意的Redis命令:

> eval "return redis.call('set','foo','bar')" 0
OK

需要注意的是,上面这段脚本的确实现了将键foo的值设为bar的目的,但是,它违反了EVAL命令的语义,因为脚本里使用的所有键都应该由KEYS数组来传递,就像这样:

> eval "return redis.call('set',KEYS[1],'bar')" 1 foo
OK

要求使用正确的形式来传递键(key)是有原因的,因为不仅仅是EVAL这个命令,所有的Redis命令,在执行之前都会被分析,借此来确定命令会对哪些键进行操作。

因此,对于EVAL命令来说,必须使用正确的形式来传递键,才能确保分析工作正确地执行。除此之外,使用正确的形式来传递键还有很多其他好处,它的一个特别重要的用途就是确保Redis集群可以将你的请求发送到正确的集群节点。

redis.call()与redis.pcall()很类似,他们唯一的区别是当redis命令执行结果返回错误时,redis.call()将返回给调用者一个错误,而redis.pcall()会将捕获的错误以Lua表的形式返回。

下面的例子演示了redis.call()与redis.pcall()的区别:

> eval "return redis.call('set1',KEYS[1],'bar')" 1 foo
(error) ERR Error running script (call to f_d968406ee98123006fa91fd2ee764d4f7f859dd7): @user_script:1: @user_script: 1: Unknown Redis command called from Lua script

> eval "return redis.pcall('set1',KEYS[1],'bar')" 1 foo
(error) @user_script: 1: Unknown Redis command called from Lua script

> eval "return type(redis.call('set1',KEYS[1],'bar'))" 1 foo
(error) ERR Error running script (call to f_c62b83c8313fd8f2557865e37d2bb5133f1789af): @user_script:1: @user_script: 1: Unknown Redis command called from Lua script

> eval "return type(redis.pcall('set1',KEYS[1],'bar'))" 1 foo
"table"

Lua数据类型和Redis数据类型之间转换

当Lua通过call()或pcall()函数执行Redis命令的时候,命令的返回值会被转换成Lua数据结构。

同样地,当Lua脚本在Redis内置的解释器里运行时,Lua脚本的返回值也会被转换成Redis协议(protocol),然后由EVAL将值返回给客户端。

数据类型之间的转换遵循这样一个设计原则:如果将一个Redis值转换成Lua值,之后再将转换所得的Lua值转换回Redis值,那么这个转换所得的Redis 值应该和最初时的Redis值一样。

换句话说,Lua类型和Redis类型之间存在着一一对应的转换关系。

RedisLua
Redis integer replyLua number
Redis bulk replyLua string
Redis multi bulk replyLua table (may have other Redis data types nested)
Redis status replyLua table with a single ok field containing the status
Redis error replyLua table with a single err field containing the error
Redis Nil bulk reply and Nil multi bulk replyLua false boolean type

从Lua转换到Redis有一条额外的规则,这条规则没有和它对应的从Redis转换到Lua的规则:

  • Lua boolean true -> Redis integer reply with value of 1. / Lua 布尔值 true 转换成 Redis 整数回复中的 1

Lua中整数和浮点数之间没有什么区别。因此,我们始终将Lua的数字转换成整数的回复,这样将舍去小数部分。如果你想从Lua返回一个浮点数,你应该将它作为一个字符串,比如ZSCORE命令。

以下是几个类型转换的例子:

> eval "return 10" 0
(integer) 10

> eval "return {1,2,{3,'Hello World!'}}" 0
1) (integer) 1
2) (integer) 2
3) 1) (integer) 3
   2) "Hello World!"

> eval "return redis.call('get','foo')" 0
"bar"

最后一个例子展示如果是Lua直接命令调用它是如何可以从redis.call()或redis.pcall()接收到准确的返回值。

下面的例子我们可以看到浮点数和nil将怎么样处理:

> eval "return {1,2,3.3333,'foo',nil,'bar'}" 0
1) (integer) 1
2) (integer) 2
3) (integer) 3
4) "foo"

正如你看到的3.333被转换成了3,并且nil后面的字符串bar没有被返回回来。

可以使用tostring()函数将数字转字符串:

> eval "return tostring(3.3333)" 0
"3.3333"

有两个辅助函数从Lua返回Redis的类型:

  • redis.error_reply(error_string):returns an error reply. This function simply returns the single field table with the err field set to the specified string for you.
  • redis.status_reply(status_string):returns a status reply. This function simply returns the single field table with the ok field set to the specified string for you.

使用redis.error_reply()函数与直接返回一个table效果一样:

> eval "return {err='My Error'}" 0
(error) My Error

> eval "return redis.error_reply('My Error')" 0
(error) My Error

EVALSHA

EVAL命令要求你在每次执行脚本的时候都发送一次脚本主体(script body)。Redis有一个内部的缓存机制,因此它不会每次都重新编译脚本,不过在很多场合,付出无谓的带宽来传送脚本主体并不是最佳选择。

为了减少带宽的消耗,Redis实现了EVALSHA命令,它的作用和EVAL一样,都用于对脚本求值,但它接受的第一个参数不是脚本,而是脚本的SHA1校验和(sum)。

如果服务器还记得给定的SHA1校验和所指定的脚本,那么执行这个脚本,如果服务器不记得给定的SHA1校验和所指定的脚本,那么它返回一个特殊的错误,提醒用户使用EVAL代替EVALSHA。

以下是示例:

> set foo bar
OK

> eval "return redis.call('get','foo')" 0
"bar"

> evalsha 6b1bf486c81ceb7edf3c093f4c48582e38c0e791 0
"bar"

> evalsha ffffffffffffffffffffffffffffffffffffffff 0
(error) NOSCRIPT No matching script. Please use EVAL.

客户端库的底层实现可以一直乐观地使用EVALSHA来代替EVAL,并期望着要使用的脚本已经保存在服务器上了,只有当NOSCRIPT错误发生时,才使用 EVAL命令重新发送脚本,这样就可以最大限度地节省带宽。

这也说明了执行EVAL命令时,使用正确的格式来传递键名参数和附加参数的重要性:因为如果将参数硬写在脚本中,那么每次当参数改变的时候,都要重新发送脚本,即使脚本的主体并没有改变,相反,通过使用正确的格式来传递键名参数和附加参数,就可以在脚本主体不变的情况下,直接使用EVALSHA 命令对脚本进行复用,免去了无谓的带宽消耗。

脚本缓存

Redis保证所有被运行过的脚本都会被永久保存在脚本缓存当中,这意味着,当EVAL命令在一个Redis实例上成功执行某个脚本之后,随后针对这个脚本的所有EVALSHA命令都会成功执行。

刷新脚本缓存的唯一办法是显式地调用SCRIPT FLUSH命令,这个命令会清空运行过的所有脚本的缓存。通常只有在云计算环境中,Redis实例被改作其他客户或者别的应用程序的实例时,才会执行这个命令。

缓存可以长时间储存而不产生内存问题的原因是,它们的体积非常小,而且数量也非常少,即使脚本在概念上类似于实现一个新命令,即使在一个大规模的程序里有成百上千的脚本,即使这些脚本会经常修改,即便如此,储存这些脚本的内存仍然是微不足道的。

事实上,用户会发现Redis不移除缓存中的脚本实际上是一个好主意。比如说,对于一个和Redis保持持久化链接(persistent connection)的程序来说,它可以确信,执行过一次的脚本会一直保留在内存当中,因此它可以在流水线中使用EVALSHA命令而不必担心因为找不到所需的脚本而产生错误。

Redis提供了以下几个SCRIPT命令,用于对脚本子系统(scripting subsystem)进行控制:

  • SCRIPT FLUSH:清除所有脚本缓存
  • SCRIPT EXISTS:根据给定的脚本校验和,检查指定的脚本是否存在于脚本缓存
  • SCRIPT LOAD:将一个脚本装入脚本缓存,但并不立即运行它
  • SCRIPT KILL:杀死当前正在运行的脚本

可用库

Redis Lua解释器可用加载以下Lua库:

  • base lib.
  • table lib.
  • string lib.
  • math lib.
  • debug lib.
  • struct lib.
  • cjson lib.
  • cmsgpack lib.
  • bitop lib.
  • redis.sha1hex function.

每一个Redis实例都拥有以上的所有类库,以确保您使用脚本的环境都是一样的。

struct,CJSON和cmsgpack都是外部库,所有其他库都是标准Lua库。

CJSON库为Lua提供极快的JSON处理:

> eval 'return cjson.encode({["foo"]= "bar"})' 0
"{\"foo\":\"bar\"}"

> eval 'return cjson.decode(ARGV[1])["foo"]' 0 "{\"foo\":\"bar\"}"
"bar"

> eval "local table = {} table['foo']='bar' table['hello']='world' return cjson.encode(table)" 0
"{\"hello\":\"world\",\"foo\":\"bar\"}"

沙箱(sandbox)和最大执行时间

脚本应该仅仅用于传递参数和对Redis数据进行处理,它不应该尝试去访问外部系统(比如文件系统),或者执行任何系统调用。

除此之外,脚本还有一个最大执行时间限制,它的默认值是5秒钟,一般正常运作的脚本通常可以在几分之几毫秒之内完成,花不了那么多时间,这个限制主要是为了防止因编程错误而造成的无限循环而设置的。

最大执行时间的长短由lua-time-limit选项来控制(以毫秒为单位),可以通过编辑redis.conf文件或者使用CONFIG GET和CONFIG SET命令来修改它。

当一个脚本达到最大执行时间的时候,它并不会自动被Redis结束,因为Redis必须保证脚本执行的原子性,而中途停止脚本的运行意味着可能会留下未处理完的数据在数据集(data set)里面。

因此,当脚本运行的时间超过最大执行时间后,以下动作会被执行:

  • Redis记录一个脚本正在超时运行
  • Redis开始重新接受其他客户端的命令请求,但是只有SCRIPT KILL和SHUTDOWN NOSAVE两个命令会被处理,对于其他命令请求,Redis服务器只是简单地返回BUSY错误。
  • 可以使用SCRIPT KILL命令将一个仅执行只读命令的脚本杀死,因为只读命令并不修改数据,因此杀死这个脚本并不破坏数据的完整性
  • 如果脚本已经执行过写命令,那么唯一允许执行的操作就是SHUTDOWN NOSAVE,它通过停止服务器来阻止当前数据集写入磁盘

流水线(pipeline)上下文(context)中的EVALSHA

在流水线请求的上下文中使用EVALSHA命令时,要特别小心,因为在流水线中,必须保证命令的执行顺序。

一旦在流水线中因为EVALSHA命令而发生NOSCRIPT错误,那么这个流水线就再也没有办法重新执行了,否则的话,命令的执行顺序就会被打乱。

为了防止出现以上所说的问题,客户端库实现应该实施以下的其中一项措施:

  • 总是在流水线中使用EVAL命令
  • 检查流水线中要用到的所有命令,找到其中的EVAL命令,并使用SCRIPT EXISTS命令检查要用到的脚本是不是全都已经保存在缓存里面了。如果所需的全部脚本都可以在缓存里找到,那么就可以放心地将所有EVAL命令改成EVALSHA命令,否则的话,就要在流水线的顶端(top)将缺少的脚本用SCRIPT LOAD 命令加上去。
Redis使用 Lua 脚本可以**实现原子性操作**,避免多命令竞态条件,同时减少网络开销(只需一次通信)。以下是详细使用方法及示例: --- ## **一、Lua 脚本基础语法** ### **1. 脚本执行命令** ```bash # 直接执行脚本 EVAL "return 'Hello, Redis!'" 0 # 使用参数 EVAL "return ARGV[1]" 0 "Hello" ``` - **`EVAL`**:执行脚本的核心命令。 - **参数说明**: - 第一个参数:Lua 脚本内容。 - 第二个参数:`KEYS` 的数量(即使不使用也必须声明)。 - 后续参数:`ARGV` 数组传递的额外参数。 --- ### **2. 键(KEYS)与参数(ARGV)** - **`KEYS`**:用于标识 Redis 中的键(保证原子性操作的资源)。 - **`ARGV`**:其他非键参数。 ```bash EVAL "return {KEYS[1], ARGV[1]}" 1 "user:1001" "name" ``` **输出**: ```json ["user:1001", "name"] ``` --- ## **二、实际应用场景与示例** ### **1. 原子性计数器递增** **需求**:实现一个带过期时间的计数器,避免竞态条件。 ```lua -- KEYS[1]: 计数器键 -- ARGV[1]: 过期时间(秒) -- ARGV[2]: 递增步长 local current = redis.call('GET', KEYS[1]) if not current then redis.call('SET', KEYS[1], 0, 'EX', ARGV[1]) end return redis.call('INCRBY', KEYS[1], ARGV[2]) ``` **执行命令**: ```bash EVAL "local current=redis.call('GET',KEYS[1]);if not current then redis.call('SET',KEYS[1],0,'EX',ARGV[1]) end;return redis.call('INCRBY',KEYS[1],ARGV[2])" 1 "counter:page_view" 60 1 ``` --- ### **2. 分布式锁实现** **需求**:获取锁时设置过期时间,避免死锁。 ```lua -- KEYS[1]: 锁键 -- ARGV[1]: 锁值(唯一标识) -- ARGV[2]: 过期时间(毫秒) if redis.call('SETNX', KEYS[1], ARGV[1]) == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) return 1 -- 获取锁成功 else return 0 -- 获取锁失败 end ``` **执行命令**: ```bash EVAL "if redis.call('SETNX',KEYS[1],ARGV[1])==1 then redis.call('PEXPIRE',KEYS[1],ARGV[2]);return 1 else return 0 end" 1 "lock:order" "uuid-1234" 5000 ``` --- ### **3. 限流器(滑动窗口)** **需求**:限制用户每分钟最多 10 次操作。 ```lua -- KEYS[1]: 限流键(如 "rate_limit:user123") -- ARGV[1]: 当前时间戳(毫秒) -- ARGV[2]: 窗口大小(毫秒,如 60000) -- ARGV[3]: 最大请求数(如 10) local key = KEYS[1] local now = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local limit = tonumber(ARGV[3]) -- 清除过期请求 redis.call('ZREMRANGEBYSCORE', key, 0, now - window) -- 获取当前请求数 local count = redis.call('ZCARD', key) if count < limit then -- 允许请求,记录时间戳 redis.call('ZADD', key, now, now) redis.call('PEXPIRE', key, window) return 1 -- 允许 else return 0 -- 拒绝 end ``` **执行命令**: ```bash EVAL "local key=KEYS[1];local now=tonumber(ARGV[1]);local window=tonumber(ARGV[2]);local limit=tonumber(ARGV[3]);redis.call('ZREMRANGEBYSCORE',key,0,now-window);local count=redis.call('ZCARD',key);if count<limit then redis.call('ZADD',key,now,now);redis.call('PEXPIRE',key,window);return 1 else return 0 end" 1 "rate_limit:user123" 1630000000000 60000 10 ``` --- ## **三、性能优化技巧** ### **1. 使用 `SCRIPT LOAD` + `EVALSHA`** - **减少网络传输**:先加载脚本生成 SHA1 摘要,后续通过摘要调用。 ```bash # 加载脚本 SCRIPT LOAD "return redis.call('GET', KEYS[1])" # 返回: "abc123sha1hash..." # 通过 SHA1 执行 EVALSHA "abc123sha1hash..." 1 "user:1001" ``` ### **2. 避免长脚本** - Redis 是单线程模型,长脚本会阻塞其他命令。 - 复杂逻辑拆分为多个短脚本。 ### **3. 错误处理** - Lua 脚本中调用 Redis 命令出错时会抛出异常: ```lua local ok, err = pcall(redis.call, 'GET', 'nonexistent_key') if not ok then return "Error: " .. err end ``` --- ## **四、Java 集成示例(Spring Data Redis)** ```java import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; // 定义 Lua 脚本 String luaScript = "return redis.call('GET', KEYS[1])"; // 创建脚本对象 DefaultRedisScript<String> script = new DefaultRedisScript<>(); script.setScriptText(luaScript); script.setResultType(String.class); // 执行脚本 String result = redisTemplate.execute( script, Collections.singletonList("user:1001"), // KEYS "optional_arg" // ARGV ); ``` --- ## **五、注意事项** 1. **原子性**:Lua 脚本Redis 中单线程执行,天然原子性。 2. **调试**:脚本中可用 `redis.log(redis.LOG_NOTICE, "Debug message")` 输出日志。 3. **资源限制**: - 脚本默认最长执行时间:5秒(可通过 `lua-time-limit` 配置)。 - 超出限制后,Redis 会记录警告但**不会终止脚本**(需用 `SCRIPT KILL` 手动干预)。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

morris131

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值