环境:

  • 操作系统: Ubuntu 20.04
  • Redis:7.0.0

我们知道Redis主要用途是数据存储,而可编程性(programmability)意味着用户可以在Redis服务器上运行自定义的脚本,实现自定义逻辑。用户脚本是在Redis的嵌入式沙箱脚本引擎中运行的,也就是Lua解释器。

使用脚本的优点如下:

  • 可编程性:实现自定义的逻辑;
  • 性能:脚本是一次性执行的,节省了与Redis服务器往返交互的开销,另外,对数据的读写都是在服务器端进行了,因此性能非常高效;
  • 原子性:脚本运行是原子的,避免了竞态条件(race condition);

注意:脚本虽然在服务器端运行,但是被视为客户端程序的一部分。脚本在服务器端只是被缓存起来,一旦丢失(比如清除脚本,或者重启服务器),客户端需要把脚本重新传到服务器端。从Redis 7.0起,Redis提供了一种被称为“函数”的服务器端的编程方式。

  • Redis 6.2及以下,可用 evalscript 命令来运行和管理脚本;
  • Redis 7.0及以上,可用 fcallfunction 命令来运行和管理脚本;

Lua脚本

eval / evalsha 命令

eval 命令的格式如下:

EVAL script numkeys [key [key ...]] [arg [arg ...]]
  • script:脚本内容
  • numkeys:参数中key的数量
  • key:Redis的key,在脚本中用 KEYS[n] 来引用,注意要大写, n 从1开始计数
  • arg:其它参数,在脚本中用 ARGV[n] 来引用,注意要大写, n 从1开始计数

比如,最简单的“hello world”脚本如下:

127.0.0.1:6379> eval "return 'hello world'" 0
"hello world"
127.0.0.1:6379>

本例中脚本内容为 return 'hello world' ,参数中key的数量为0。

再来看一个带参数的脚本:

127.0.0.1:6379> eval "return ARGV[1]" 0 "hello world"
"hello world"
127.0.0.1:6379>

注意:ARGV 必须用大写。

本例中,参数里key的数量为0,但是有一个其它参数,在脚本中用 ARGV[1] 来引用。

evalsha 命令的用法参见下面的 scirpt 部分。

redis.call() / redis.pcall() 方法

在Lua脚本中,可以通过 redis.call() (或者 redis.pcall() )来运行Redis命令。

redis.call() 格式如下:

redis.call(command [,arg...])

例如:

127.0.0.1:6379> eval "return redis.call('set', 'mykey1', 'hello')" 0
OK
127.0.0.1:6379> eval "return redis.call('get', 'mykey1')" 0
"hello"
127.0.0.1:6379>

该例中key和value的值都是硬编码,若要改为传参的方式,如下:

127.0.0.1:6379> eval "return redis.call('set', KEYS[1], ARGV[1])" 1 mykey1 world
OK
127.0.0.1:6379> eval "return redis.call('get', KEYS[1])" 1 mykey1
"world"
127.0.0.1:6379>

注意: KEYSARGV 是大小写敏感的,必须用大写。

redis.pcall()redis.call() 的功能基本相同,区别是提供了错误处理能力。

比如:

127.0.0.1:6379> eval "return redis.call('get', 'mykey1')" 0
"world"
127.0.0.1:6379>

如果不小心把 get 命令拼错了:

127.0.0.1:6379> eval "return redis.call('gett', 'mykey1')" 0
(error) ERR Unknown Redis command called from script script: 8426105f7bad9a131b19c9b8c374df63bc9015ef, on @user_script:1.
127.0.0.1:6379>

可见,返回了一个Redis系统错误消息。

如果换成 redis.pcall() ,就能做错误处理,比如:

127.0.0.1:6379> eval "local reply = redis.pcall('gett', 'mykey1'); if reply['err'] ~= nil then reply['err'] = 'Something is wrong' end; return reply" 0
(error) Something is wrong
127.0.0.1:6379>

这样,就可以返回自定义的消息。

redis.call() 则不能处理错误:

127.0.0.1:6379> eval "local reply = redis.call('gett', 'mykey1'); if reply['err'] ~= nil then reply['err'] = 'Something is wrong' end; return reply" 0
(error) ERR Unknown Redis command called from script script: a8ca7042298e6fa8155fcdb0e35cc99566489e91, on @user_script:1.
127.0.0.1:6379>

可见,仍然返回了Redis的系统错误消息。

script 命令

script load 命令把脚本缓存在服务器,并返回其SHA1摘要,以后就可以通过 evalsha 命令来运行脚本。

127.0.0.1:6379> script load "return 'hello ' .. ARGV[1]"
"3a0817935cd31956a48cbc3f113ca7aabf07171d"
127.0.0.1:6379>

接下来就可以通过

127.0.0.1:6379> evalsha 3a0817935cd31956a48cbc3f113ca7aabf07171d 0 world
"hello world"
127.0.0.1:6379>

注意:脚本只是被缓存到服务器端,一旦服务器重启,或者运行了 script flush 命令,脚本缓存就没了。

127.0.0.1:6379> script flush
OK
127.0.0.1:6379>

再次运行 evalsha 命令:

127.0.0.1:6379> evalsha 3a0817935cd31956a48cbc3f113ca7aabf07171d 0 world
(error) NOSCRIPT No matching script. Please use EVAL.
127.0.0.1:6379>

可见,缓存的脚本已经没了。

可以用 script exists 命令查看脚本缓存是否存在:

127.0.0.1:6379> script exists 3a0817935cd31956a48cbc3f113ca7aabf07171d
1) (integer) 0
127.0.0.1:6379>

Redis函数

尽管 script load 命令可以把脚本缓存到服务器端,但这是不可靠的,缓存的脚本随时都可能会被丢掉(比如服务器重启,或者 script flush 命令)。这就意味着客户端需要做维护脚本缓存的工作,并带来了一系列麻烦,比如:

  • 所有客户端都需要保存一份脚本,增加了脚本维护和升级的麻烦;
  • SHA1摘要是一串无意义的序列值;
  • 多个脚本之间无法交互;

Redis 7.0引入了“函数”的概念。函数是脚本的进化版。

函数提供了和脚本相同的核心功能,但是脚本是“附加”的,而函数是Redis的组成部分。Redis会通过持久化和复制来保证其可用性。而且客户端要使用函数,只依赖于API,而不是嵌入式的脚本逻辑。

Function并没有限定其实现语言。运行引擎理论上可以使用任何语言,不过显然Lua是首选。

function load 命令

用来创建函数。例如,下面的“hello world”函数:

127.0.0.1:6379> function load "#!lua name=mylib\nredis.register_function('myfunc1', function() return 'hello world' end)"
"mylib"
127.0.0.1:6379>

fcall 命令

用来调用函数:

127.0.0.1:6379> fcall myfunc1 0
"hello world"
127.0.0.1:6379>

function list 命令

用来显示函数信息:

127.0.0.1:6379> function list libraryname mylib
1) 1) "library_name"
   2) "mylib"
   3) "engine"
   4) "LUA"
   5) "functions"
   6) 1) 1) "name"
         2) "myfunc1"
         3) "description"
         4) (nil)
         5) "flags"
         6) (empty array)
127.0.0.1:6379>

function delete 命令

用来删除函数:

127.0.0.1:6379> function delete mylib
OK
127.0.0.1:6379>

创建函数的其它方式

也可以通过以下方式来创建函数。

创建 test4.lua 文件,内容如下:

#!lua name=mylib

local function my_hset(keys, args)
  local hash = keys[1]
  local time = redis.call('TIME')[1]
  return redis.call('HSET', hash, '_last_modified_', time, unpack(args))
end

local function my_hgetall(keys, args)
  redis.setresp(3)
  local hash = keys[1]
  local res = redis.call('HGETALL', hash)
  res['map']['_last_modified_'] = nil
  return res
end

local function my_hlastmodified(keys, args)
  local hash = keys[1]
  return redis.call('HGET', keys[1], '_last_modified_')
end

redis.register_function('my_hset', my_hset)
redis.register_function('my_hgetall', my_hgetall)
redis.register_function('my_hlastmodified', my_hlastmodified)

注:

  • my_hset 函数中,通过 redis.call('TIME')[1] 获取系统时间;
  • my_hgetall 函数中,通过 redis.setresp(3)redis.call 设置为返回 RESP3 应答。与默认的 RESP2 不同, RESP3 提供了dictionary应答,允许函数删除其中的某个字段(在Lua中是设置为nil),在本例中是删除了 _last_modified_ 字段;

运行下列命令来创建函数:

➜  ~ cat temp/test4.lua | redis-cli -x FUNCTION LOAD REPLACE 
"mylib"

调用 my_hset 函数:

127.0.0.1:6379> fcall my_hset 1 myhash myfield "some value" another_field "another value"
(integer) 3
127.0.0.1:6379>

通过Redis命令查看:

127.0.0.1:6379> hgetall myhash
1) "_last_modified_"
2) "1651933164"
3) "myfield"
4) "some value"
5) "another_field"
6) "another value"
127.0.0.1:6379>

可见,确实创建了 myhash

调用 my_hgetall 函数:

127.0.0.1:6379> fcall my_hgetall 1 myhash
1) "another_field"
2) "another value"
3) "myfield"
4) "some value"
127.0.0.1:6379>

可见,返回了 _last_modified_ 之外的其它信息。

调用 my_hlastmodified 函数:

127.0.0.1:6379> fcall my_hlastmodified 1 myhash
"1651933164"
127.0.0.1:6379>

可见,返回了 my_hlastmodified 信息。

最终 function list 如下:

127.0.0.1:6379> function list
1) 1) "library_name"
   2) "mylib"
   3) "engine"
   4) "LUA"
   5) "functions"
   6) 1) 1) "name"
         2) "my_hlastmodified"
         3) "description"
         4) (nil)
         5) "flags"
         6) (empty array)
      2) 1) "name"
         2) "my_hgetall"
         3) "description"
         4) (nil)
         5) "flags"
         6) (empty array)
      3) 1) "name"
         2) "my_hset"
         3) "description"
         4) (nil)
         5) "flags"
         6) (empty array)
127.0.0.1:6379>