redis脚本

1,256 阅读15分钟

EVAL

有没有发现这篇文章的样式最朴素,那是因为带html样式的话会超过字数限制~~~

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

eval 参数详解

  • 第一个参数 "return ..." 是要执行的lua脚本(脚本不要只是纯粹定义lua函数)
  • 第二个参数代表了 key的数量,就是脚本中所有调用命令需要操作的key
  • 后面紧跟的key放入KEYS数组中
  • 除了key剩下的会放到ARGV数组中

可以把脚本理解为一个对一系列key进行了一系列操作(读,根据参数来计算,写 ,等等)的方法,而操作中用到的key可以放KEYS数组传入进去(当然也可以写死在脚本中,但不建议写死),除key之外的其他参数可以放ARGV数组传入进去(当然也可以写死在脚本中,但也不建议写死)。那为什么建议用两个数组来隔离key和非key参数呢,直接用一个数组传入不就行了?

eval命令规范

redis在执行任何命令之前都要分析该命令将要对哪些key进行操作,所以key尽量以KEYS数组的形式传入进去以方便redis执行检查,下面这个例子key直接写死了foo是不符合规范的,虽然能执行成功

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

敲黑板!!! 上面脚本在单机环境没问题,但在分片集群下就可能会出问题了,不兼容! 分片场景下,正常的操作应该是先根据KEYS数组中的key判断由哪个槽节点来执行脚本,然后把脚本请求路由给对应的槽节点来执行。如果写死key在lua脚本中Redis就无法检查判断了~~~

所以尽量修改成下面这样:

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

好了,这下知道为什么要用两个数组了吧:你要是把非key参数也放到key数组中,redis会把它当成key来检查判断的

lua和redis数据类型的转换

lua数据类型

redis 转 lua

  • integer -> Lua number
  • bulk -> Lua string
  • multi bulk -> Lua table (可能嵌套其他数据类型)
  • status -> Lua table (单个键值对:ok:status)
  • error -> Lua table (单个键值对:err : error msg)
  • Nil bulkNil multi bulk -> Lua false (boolean类型)

lua 转 redis

  • Lua number -> integer (Lua只有一个数字类型:number ,不区分整型和浮点。如果是一个浮点转换成redis的类型,会丢掉小数部分,如果不想丢失精度应该在lua中先把数值转换成string再转到redis)
  • Lua string -> bulk
  • Lua table (数组) -> multi bulk (如果数组里面有 nil 会被截断)
  • Lua table (单个键值对:ok:xxx) -> status
  • Lua table (单个键值对:err:xxx)-> Redis error
  • Lua boolean false -> Redis Nil bulk

多出的对应关系(lua数据类型比redis要丰富)

  • Lua boolean true -> Redis integer reply with value of 1.

略,数据类型对应关系详见官方文档吧,不一一翻译了

辅助函数返回redis类型

有两个辅助函数可以从lua返回redis的类型

redis.error_reply(error_string) 返回一个error. 返回一个属性err的table redis.status_reply(status_string) 返回一个状态. 返回一个属性的table

直接返回table和使用辅助函数没区别所以下面两个是等效的:

return {err="My Error"}
return redis.error_reply("My Error")

没区别你官方写这废话干啥 ,搞不懂........

脚本的原子性

redis保证脚本原子性:执行脚本时,不会执行其他脚本或Redis命令。这意味着脚本执行时会阻塞其他的脚本或者命令,所以应当尽量保证脚本执行速度非常快,或者服务器空闲时执行慢脚本也可以

错误处理

redis.call()redis.pcall() 区别 主要区别在于执行命令报错时的返回结果

127.0.0.1:6379> eval "return redis.call('setx','foo','bar')" 0
(error) ERR Error running script  \
(call to f_2d0a227c55ebf9213eb296d81c3ba078f4894409): @user_script:1:  \
@user_script: 1: Unknown Redis command called from Lua script

redis.call()的错误会阻断lua运行

redis.pcall()会把错误封装成Lua table(table是lua的一种数据结构)返回error:

127.0.0.1:6379> eval "return redis.pcall('setx','foo','bar')" 0
(error) @user_script: 1: Unknown Redis command called from Lua script

就向上面所说一样call的错误会阻断lua运行,pcall会把错误封装成table返回:

> del foo
(integer) 1
> lpush foo a
(integer) 1
> eval "return redis.call('get','foo')" 0
(error) ERR Error running script (call to f_6b1bf486c81ceb7edf3c093f4c48582e38c0e791): ERR 

EVALSHA节省带宽

EVAL命令每次都要发送脚本,每次都发送相同的脚本也是很浪费带宽的,redis并不需要每次都重新编译脚本,他自身有内部缓存机制

另一方面,使用在redis.conf自定义的命令也可能会有以下问题:

  • 不同的redis实例对同一个command可能有不同的实现

  • 要确保所有redis实例都包含同一个给定的命令,部署过程是十分困难的, 尤其是在分布式环境中

  • 读应用程序代码时,应用程序所调用命令的完整语义非常难理解,因为命令是在redis服务端自定义的

为了避免这些问题,同时避免带宽损失,redis实现了EVALSHA,EVALSHAEVAL只是第一个参数不一样

EVALSHA行为如下:

  • 服务端如果有SHA1摘要对应的脚本,则执行脚本
  • 如果没有则返回错误告诉客户端以EVAL命令执行

比如:

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

字符串"return redis.call('get','foo')" 经过sha1运算得到6b1bf486c81ceb7edf3c093f4c48582e38c0e791 所以把上面的eval换成下面这样是等效的

> evalsha 6b1bf486c81ceb7edf3c093f4c48582e38c0e791 0
"bar"

若缓存中找不到对应摘要的脚本则报错:

> evalsha ffffffffffffffffffffffffffffffffffffffff 0
(error) `NOSCRIPT` No matching script. Please use [EVAL](/commands/eval).

所以可以每次先乐观的认为服务端已经执行过该脚本啦,直接发送evalsha + sha1(脚本),如果返回了NOSCRIPT错误再发送eval + 脚本

敲黑板!!!

EVAL命令规范又起到一个很好的作用:把变化的key作为KEYS数组,arg也可以作为ARGV数组,传入给脚本,这样脚本就是固定不变的了,不变的脚本sha1结果是一致的,可以很好的被redis缓存而不用重新编译脚本哟~~~

脚本缓存

脚本只要被一个实例执行过就会被缓存起来,一直不会过期,也就是说只要EVAL命令执行过,则后续对应脚本的EVALSHA命令必定会成功调用

脚本可以被长期缓存的原因是:设计良好的情况下,脚本一般不会占用过多内存,脚本在概念上类似于自定义的新命令,在一个复杂的系统中最后也就几百个,即便是脚本进行了多次修改(每次修改的都会被缓存),占用内存也可以忽略不计

SCRIPT FLUSH命令可以清空已执行过的所有脚本

当然重启之后脚本缓存会丢失(只是不过期并没有持久化到硬盘),从客户端的角度来讲只有两种方法能判断客户端的两个命令的空隙之间redis服务器是否发生了重启:

  • 客户端服务端的连接未关闭,(正常情况下连接是会一直保持的)
  • 客户端显式的发送INFO命令检查服务的runid是否相同来判断是否是同一进程

所以,在实际使用过程中,最好只假设在同一连接上下文中脚本缓存还存在,除非有人显式的调用SCRIPT FLUSH命令。

所以一般在同一个管道pipelining中,可以假设脚本缓存是一直存在的。

一般做法是在管道开始时先执行一次SCRIPT LOAD,管道后面的命令就可以直接使用EVALSHA,而不用去操心因脚本不存在而引起的错误了

脚本相关的命令

  • SCRIPT FLUSH

强制清空所有缓存过的脚本

  • SCRIPT EXISTS sha1 sha2 ... shaN

判断摘要对应的脚本是否存在 给定一个sha1数组,返回一个元素只有0和1的数组与摘要对应,1代表存在 0代表不存在

  • SCRIPT LOAD script

注册指定脚本并放入缓存,通常用来确保在pipelineMULTI/EXEC过程中EVALSHA命令不会失败

  • SCRIPT KILL

仅有的一个用来中断脚本执行的命令,只有超过了配置的脚本最大执行时间才可以中断,并且脚本还未修改过数据才可以中断,比如只发生了读操作。 已经写入操作的是不允许中断的,因为这样就不具备原子性了。那已经发生写入操作的,且后面死循环了或者执行非常慢一直阻塞redis服务器要怎么办啊?

脚本完全复制 vs 脚本效果复制

redis5.0默认使用的是脚本效果复制,不需要显示的指定

先说这两个的含义

在主从同步复制或者AOF持久化时,如果执行了脚本, 把脚本本身(lua代码)给复制的叫做脚本完全复制(whole scripts replication),把脚本执行期间产生的效果(就是产生的写命令)进行复制的叫做脚本效果复制(script effects replication)。 一个是复制脚本本身再重新执行一遍,一个是复制脚本产生的写命令,这两者还是有很大区别的:

  • 某些情况下脚本完全复制速度要比脚本效果复制快(带宽占用少)

比如 这样的一个lua脚本:

伪代码


//读取key的list,注意:这里的list是读取当前脚本运行环境的数据
List list = redis.call(get(key))


//把key读取出来的结果写入到key2
redis.call(set key2 list)

如果是脚本完全复制,则复制发送这两行lua代码就行了。带宽占用很少。

那如果是脚本效果复制呢? 发送的是一条写命令 set key2 list ,要把list数据本身都发送过去,假如list非常大呢,是不是要占用很多带宽资源?

  • 某些情况下脚本效果复制可以避免lua脚本中的重复计算

脚本完全复制,复制过去之后又要重新跑一次脚本,如果脚本计算逻辑非常复杂非常占用cpu资源的话,脚本完全复制就很浪费计算资源了。脚本效果复制则不会,只需要把计算结果也就是最终的写命令复制过去就行了。

  • 脚本完全复制需要依赖纯函数才能保证复制的出来结果是一样的。此话怎讲?

纯函数:若入参相同,函数的出参或产生的行为一定是相同的。难道有入参相同,出参或行为不同的函数?有的:比如函数中依赖了系统时间的,或者random的等等。

如果是采用脚本完全复制的方式,碰到一个依赖了random不是纯函数的脚本,会产生什么结果? 比如:主从复制时,在主节点执行脚本随机了一个结果A,在从节点时把脚本复制了过去又重新执行了一遍,又随机出来了一个结果B,结果A不等于结果B,这个时候主从一致性恐怕就无法保证了吧。(AOF同理,先持久化,等宕机恢复重新执行脚本时又随机了新的结果,导致宕机前后结果不一致)

如果非要使用脚本完全复制的方式呢,怎么解决上面这个问题?redis对脚本的执行做了一些保证:

  1. Lua does not export commands to access the system time or other external state.

  2. 脚本执行期间如果执行了随机命令(比如RANDOMKEY, SRANDMEMBER, TIME)之后又调用了修改数据的操作命令,则会直接阻断执行并抛出error。 也就是说调用这些随机命令也可以,但是后面只允许是只读的

  3. reids 4.0,可以返回元素随机顺序的命令(比如SMEMBERS)会产生不同的行为,但是在返回数据给lua脚本之前通过隐藏的辅助函数给结果排了序,lua中多次调用这些随机顺序命令时会一直得到同一个结果。仅仅是lua调用时才会发生隐式排序。redis5.0之后就没有此排序操作了,因为redis5.0之后不再需要将不确定性命令转换为确定性命令的方式来复制脚本

  4. redis的lua环境中替换一些lua随机函数,保证其确定性,math.randommath.randomseed被改成了只要种子一样,随机结果就一样。这个时候只需要把种子随机然后传给脚本就可以保障脚本行为的一致性了,一样有随机的效果。(修改之后的函数的一致性行为不受redis 32 64 及硬件系统的影响)

脚本效果复制

注意:redis5开始这脚本效果复制是默认的,不需要显式的启用

redis3.2开始可以选择脚本效果复制,脚本复制时,redis收集脚本执行的所有更改数据的命令。当脚本执行完成,收集到的脚本序列被MULTI / EXEC包括起来进行复制或者追加到AOF中。

脚本效果复制的用处:

  • 脚本计算很慢,但效果只产生了一点写命令,复制效果有很大收益(上面已经说过这点)
  • 启动效果复制时,上面章节所说的对不确定行为的函数控制会解除。可以随意用这些函数了
  • PRNG每次调用都随机,就是上面说的随机种子

redis.replicate_commands() 在脚本的写操作之前加入上面的代码就可以启动脚本效果复制了,如果调用这个方法时,启动成功会返回true,如果调用之前已经执行过了写操作则返回false然后按脚本完全复制模式进行复制

选择性的复制

redis.replicate_commands() 

redis.call('set','A','1')
redis.call('set','B','2')
redis.call('set','C','3')

假如有这样一个代码,只想复制A和C,不复制B怎么办?可以实现吗? 通过redis.set_repl()可以实现,改成下面这样

redis.replicate_commands()
redis.call('set','A','1')
redis.set_repl(redis.REPL_NONE)
redis.call('set','B','2')
redis.set_repl(redis.REPL_ALL)
redis.call('set','C','3')

set_repl的五个枚举值及对应含义

redis.set_repl(redis.REPL_ALL) -- 复制到AOF和副本
redis.set_repl(redis.REPL_AOF) -- 只复制到AOF
redis.set_repl(redis.REPL_REPLICA) -- 只复制到副本 (Redis >= 5)
redis.set_repl(redis.REPL_SLAVE) -- 用于向后兼容  效果同REPL_REPLICA
redis.set_repl(redis.REPL_NONE) -- AOF和副本都不复制

全局变量保护

redis不允许lua脚本设置全局变量,会直接报错。如果想记录某个值,应该把它记录到redis的keys中。

如果你是一个新手,为了避免污染全局变量,lua脚本中的每个变量最好都用local修饰下

脚本中的 SELECT

2.8.12之后脚本中的SELECT只会影响脚本本身,并不会影响调用脚本客户端的数据库选择

RESP3模式中使用lua脚本

从redis6开始 ,服务端支持两种不同的协议 RESP2是老的,RESP3是新的,客户端可以通过HELLO 3 来切换到新的协议3,脚本可以使用redis.setresp(3)切换到协议3。总的来讲协议3和2会有数据类型转换不同吧,具体详见官方文档不一一翻译了

redis的lua环境可用库

  • base lib.
  • table lib.
  • string lib.
  • math lib.
  • struct lib.
  • cjson lib.
  • cmsgpack lib.
  • bitop lib.
  • redis.sha1hex function.
  • redis.breakpoint and redis.debug function in the context of the Redis Lua debugger.

详略

脚本打log

redis.log(loglevel,message)

redis.log(redis.LOG_WARNING,"Something is wrong with this script.")

日志级别:

redis.LOG_DEBUG
redis.LOG_VERBOSE
redis.LOG_NOTICE
redis.LOG_WARNING

沙箱和脚本最大执行时间

脚本不应该访问外部系统,比如文件系统或者其他系统调用。脚本应该只操作redis的数据和参数。

脚本有一个最大执行时间(默认是5秒)lua-time-limit可以配置。

脚本执行时间一般都是毫秒级别的,5秒相对来说非常大,只是用来解决开发时的死循环的。

因为脚本要保证原子性,脚本超时时不会自动去中断脚本执行。所以当脚本执行超时后讲发生以下情况:

  • log记录脚本运行时间过长
  • 开始接受其他客户端命令,但不会执行只会返回BUSY错误。唯一能处理的两个命令是SCRIPT KILLSHUTDOWN NOSAVE
  • 如果脚本未执行过写命令,则SCRIPT KILL可以中断脚本执行,这不会破坏原子性
  • 若脚本执行过写命令,则只允许SHUTDOWN NOSAVE命令,执行不保存硬盘上的重启

通道中EVALSHA

管道中不允许命令失败,所以尽可能保证EVALSHA命令不会报错,以下方式二选一:

  • 全部使用EVAL命令

  • 先用命令SCRIPT EXISTS判断脚本是否存在,若存在则EVALSHA调用就行。若不存在则 SCRIPT LOAD + EVALSHA 或者 EVAL

lua debug

redis 3.2开始支持redis服务的远程debug ,详细见 redis lua debug