前言

Redis 当中提供了许多重要的高级特性,比如发布与订阅,Lua 脚本等。Redis 当中也提供了自增的原子命令,但是假如我们需要同时执行好几个命令的同时又想让这些命令保持原子性,该怎么办呢?这时候就可以使用本文介绍的 Lua 脚本来实现。

发布与订阅

发布与订阅功能理论上来说可以直接通过一个双端链表就可以实现了,然而这种通过普通的双端链表来实现的发布与订阅功能有两个局限性:

  • 如果生产者生产消息的速度远大于消费者消费消息的速度,那么链表中未消费的消息会大量堆积,导致占用大量的内存。
  • 基于链表实现的消息队列,不支持一对多的消息分发。

因为普通双端链表来实现发布与订阅功能有这两个局限性,故而 Redis 当中并没有直接通过双端列表来实现。在 Redis 中的发布与订阅可以分为两种类型:基于频道基于模式

基于频道的实现

基于频道的实现方式主要通过以下三个命令:

  • subscribe channel-1 channel-2:订阅一个或者多个频道。
  • unsubscribe channel-1:取消频道的订阅(命令操作界面上无法退订)。
  • publish channel-1 message:向频道 channel-1 发送消息 message

打开一个客户端 一,输入订阅命令 subscribe music movie,表示当前客户端订阅了 musicmovie 两个频道的消息:

然后再打开一个客户端二,执行以下发布消息命令:

publish movie myCountry //发布消息 myCountry 到 movie 频道
publish music love //发布消息 love 到 music 频道
publish tv myHome //发布消息 myHome 到 tv 频道

前面两个频道发布之后返回 1 就表示当前有 1 个客户端订阅了该频道,消息已经发送到这个客户端。

这时候我们再回到之前的客户端一,就会发现客户端一收到了消息 myCountrylove 两条消息,而 myHome 这条消息是属于频道 tv,客户端一并没有订阅,故而不会收到:

同时,还有以下 2 个命令可以查看当前客户端订阅的频道信息:

  • punsub channels [channel_name] :查看当前服务器被订阅的频道。不带参数则返回所有频道,后面的参数可以使用通配符 ? 或者 *
  • pubsub numsub channel_name [channel_name]:查看指定频道的订阅数(可同时查看多个)。

实现原理分析

客户端与其订阅的频道信息被保存在 redisServer 对象中的 pubsub_channels 属性中。

struct redisServer {
dict *pubsub_channels;//保存了客户端及其订阅的频道信息
//... 省略其他信息
};

pubsub_channels 属性是一个字典,其 key 值保存的就是频道名,value 是一个链表,链表中保存的就是每个客户端的 id,下图就是基于频道订阅的存储结构示意图:

  • 订阅

    订阅的时候首先会检查字典内是否存在这个频道:如果不存在,则需要为当前频道创建一个字典,同时创建一个链表作为 value,并将当前客户端 id 放入链表;如果存在,则直接将当前客户端 id 放入链表末尾即可。
  • 取消订阅

    取消订阅的时候需要将客户端 id 从对应的链表中移除,如果移除之后链表为空,则需要同时将该频道从字典内删除。
  • 发送消息

    发送消息时首先会去 pubsub_channels 字典内寻找键,如果发现有可以匹配上的键,则会找到对应的链表,进行遍历发送消息。

基于模式的实现

基于模式的发布与订阅实现方式主要通过以下三个命令:

  • psubscribe pattern-1 pattern-2:订阅一个或者多个模式,模式可以通过通配符 ?* 来表示。
  • punsubscribe pattern-1 pattern-1:取消模式的订阅(基于命令操作,界面上无法退订)
  • publish channel-1 message :向频道 channel-1 发送消息 message。这里和上面基于频道命令是一样的。

打开一个客户端 一,输入订阅命令 psubscribe m*,表示当前客户端订阅了所有以 m 开头的频道:

然后再打开一个客户端二,执行一下发布消息命令:

publish movie myCountry //发布消息 myCountry 到 movie 频道
publish music love //发布消息 love 到 music 频道
publish tv myHome //发布消息 myHome 到 tv 频道

前面两个频道发布之后返回 1 就表示当前有 1 个客户端订阅了该频道(上面基于频道订阅的客户端关闭之后会自动取消订阅),消息已经发送到这个客户端。

这时候我们再回到之前的客户端一,就会发现客户端一收到了 myCountrylove 两条消息,因为这两个频道都是以 m 开头的,而 myHome 这条消息是属于频道 tv,并不是以 m 开头,客户端一并没有订阅,故而不会收到:

同样的,基于模式的订阅也提供了一个查询命令:

  • pubsub numpat:查询当前服务器被订阅模式的数量。

实现原理分析

客户端与其订阅的模式信息被保存在 redisServer 对象中的 pubsub_patterns 属性中。

struct redisServer {
list pubsub_patterns;//保存了客户端及其订阅的模式信息
//...省略其他信息
};

pubsub_patterns 属性是一个列表,其列表内结构(源码 serer.h 内)定义如下:

typedef struct pubsubPattern {
client *client;//订阅模式的客户端
robj *pattern;//被订阅的模式
} pubsubPattern;

  • 订阅

    新建一个 pubsubPattern 数据结构加入到链表 pubsub_patterns 的结尾。
  • 取消订阅

    从链表中将当前取消订阅的客户端 pubsubPattern 从链表 pubsub_patterns 中移除
  • 发送消息

    此时需要遍历整个链表来寻找能匹配的模式。之所以基于模式场景使用链表是因为模式支持通配符,所以没有办法直接用字典实现。

PS:当基于频道和基于模式两种订阅同时都存在时,Redis 会先去寻找频道字典,再去遍历模式链表进行消息发送。

Lua 脚本

Redis2.6 版本开始支持 Lua 脚本,为了支持 Lua 脚本,Redis 在服务器中嵌入了 Lua 环境。

使用 Lua 脚本最大的好处是 Redis 会将整个脚本作为一个整体执行,不会被其他请求打断,可以保持原子性且减少了网络开销。

Lua 脚本的调用

Lua 脚本的执行语法如下:

eval lua-script numkeys key [key ...] arg [arg ...]
  • eval:执行 Lua 脚本的命令。
  • lua-script:Lua 脚本内容。
  • numkeys:表示的是 Lua 脚本中需要用到多少个 key,如果没用到则写 0
  • key [key ...]:将 key 作为参数按顺序传递到 Lua 脚本,numkeys0 时则可省略。
  • arg:Lua 脚本中用到的参数,如果没有可省略。

接下来我们执行一个不带任何参数的简单 Lua 脚本命令:

eval "return 'Hello Redis'" 0

Lua 脚本中执行 Redis 命令

Lua 脚本中执行 Redis 命令时需要使用以下语法:

redis.call(command, key [key ...] argv [argv…])
  • command:Redis 中的命令,如 setget 等。
  • key:操作 Redis 中的 key 值,相当于我们调用方法时的形参。
  • param:代表参数,相当于我们调用方法时的实参。

假如我们想执行一个命令 set name lonely_wolf,那么利用 Lua 脚本则应该这么执行:

eval "return redis.call('set',KEYS[1],ARGV[1])" 1 name lonely_wolf

需要注意的是:KEYSARGV 必须要大写,参数的下标从 1 开始。上面命令中 1 表示当前需要传递 1key

Lua 脚本摘要

有时候如果我们执行的一个 Lua 脚本很长的话,那么直接这么调用 Lua 脚本的话非常不方便,所以 Redis 当中提供了一个命令 script load 来手动给每一个 Lua 脚本生成摘要,这里之所以要说手动的原因是即使我们不使用这个命令,每次调用完 Lua 脚本的时候,Redis 也会为每个 Lua 脚本生成一个摘要

其他相关命令:

  • script exists 摘要:判断一个摘要是否存在。0 表示不存在,1 表示存在。
  • script flush:清除所有 Lua 脚本缓存。

接下来我们来验证一下,依次执行以下命令:

script load "return redis.call('set',KEYS[1],ARGV[1])"  //给当前 Lua脚本生成摘要,这时候会返回一个摘要
evalsha "c686f316aaf1eb01d5a4de1b0b63cd233010e63d" 1 address china //相当于执行命令 set address china
get address //获取 adress,确认上面的脚本是否执行成功
script exists "c686f316aaf1eb01d5a4de1b0b63cd233010e63d" //判断当前摘要的 Lua脚本是否存在
script flush //清除所有 Lua脚本缓存
script exists "c686f316aaf1eb01d5a4de1b0b63cd233010e63d" //清除之后这里就不存在了

执行之后得到如下效果:

Lua 脚本文件

当我们的 Lua 脚本很长时,直接在命令窗口中写脚本是不直观的,也很难发现语法问题,所以 Redis 当中也支持我们直接把先把脚本写入文件中,然后直接调用文件。

比如我们新建一个 test.lua 脚本:

redis.call('set',KEYS[1],ARGV[1])
return redis.call('get',KEYS[1])

将文件上传到指定目录之后,执行如下命令:

redis-cli --eval test.lua 1 age , 18 //注意 key 和 arg 参数之间要以逗号隔开,且逗号两边的空格不能省略

这时候就可以正常返回 18

脚本异常

我们知道,Redis 的指令是单线程执行的,而现在使用了 Lua 脚本,我们就可以通过 Lua 脚本来实现一些业务逻辑,那么如果 Lua 脚本执行超时或者陷入了死循环,这个时候其他的指令就会被阻塞,导致 Redis 无法正常使用。这个时候应该如何处理呢?

脚本超时

为了解决 Lua 脚本超时的问题,Redis 提供了一个超时时间的参数 lua-time-limit 来控制 Lua 脚本执行的超时时间,单位是毫秒,默认是 5000 (即 5 秒),到达超时时间之后 Lua 会自动中断脚本。

脚本陷入死循环

假如脚本陷入了死循环,这时候超时时间就不起作用了,我们来模拟一下:

首先打开客户端一,执行一个死循环的 lua 脚本:

eval 'while(true) do end' 0

然后打开另一个客户端二,任意执行一个命令:

get name

这时候会返回 busy,表示当前无法执行这个命令:

提示 busy 之后,同时 Redis 也给出了解决方案,我们只能只用 script kill 或者 shutdown nosave 命令,这两个命令又是做什么用的呢?

  • script kill:当脚本陷入死循环之后,执行这个命令可以强制 Lua 脚本中断执行。这个脚本的局限性就是当前陷入死循环的 Lua 脚本必须没有成功执行过命令。
  • shutdown nosave:强制退出 Lua 脚本,可以解决 script kill 命令的局限性。

接下来让我们在客户端二执行命令 script kill,然后再去看看陷入死循环的客户端一的效果:

可以看到,客户端一的 Lua 脚本已经退出了,根据后面的提示可以知道就是因为执行了 script kill 命令而导致了 Lua 脚本的中断。

现在我们重新用客户端一执行下面这个 Lua 脚本,这个脚本和上面的脚本区别就是这里执行成功了一个 Redis 命令之后才开始死循环:

eval "redis.call('set','age','28') while true do end" 0

这时候再去客户端二执行 script kill 命令,发现无法中止 Lua 脚本了:

这里不允许直接中断 Lua 脚本是因为在死循环前已经有 Redis 命令被成功执行了,如果直接中断,那么就会造成数据不一致问题。

在这种场景下,只能通过执行 shutdown nosave 命令来强行中断 Lua 脚本,这里因为加了 nosave 之后不会触发 Redis 的持久化,所以当重启 Redis 服务之后,可以保证数据的一致性,下图就是执行 shutdown nosave 命令之后客户端一的效果图:

为什么可以执行 script kill 命令

Redis 当中执行命令是单线程的,那么为什么 Lua 脚本陷入死循环之后其他客户端还可以执行 script kill 命令呢?

这是因为 Lua 脚本引擎提供了钩子(hook)函数,它允许在内部虚拟机执行指令时运行钩子代码,所以 Redis 正是利用了这一原理,在执行 Lua 脚本之前设置了一个钩子,也就是说 script kill 命令是通过钩子(hook)函数来执行的。

总结

本文主要介绍了 Redis 中的发布订阅功能和 Lua 脚本的使用,使用 Lua 脚本可以让多个命令原子执行,减少网络开销,但是同时也要注意 Lua 脚本引发的死循环问题。

要想用活Redis,Lua脚本是绕不过去的坎的更多相关文章

  1. Redis Lua脚本调试

    从版本3.2开始,Redis包含一个完整的Lua调试器,可以用来使编写复杂Redis脚本的任务更加简单. 由于Redis 3.2仍处于测试阶段,请unstable从Github 下载Redis 的分支 ...

  2. Redis Lua脚本完全入门

    1. 前言 Redis是高性能的KV内存数据库,除了做缓存中间件的基本作用外还有很多用途,比如胖哥以前分享的Redis GEO地理位置信息计算.Redis提供了丰富的命令来供我们使用以实现一些计算.R ...

  3. Redis Lua 脚本使用

    本文转载自Redis Lua 脚本使用 Lua 简介 Lua语言提供了如下几种数据类型:booleans(布尔).numbers(数值).strings(字符串).tables(表格). 下面是一些 ...

  4. Redis进阶应用:Redis+Lua脚本实现复合操作

    一.引言 Redis是高性能的key-value数据库,在很大程度克服了memcached这类key/value存储的不足,在部分场景下,是对关系数据库的良好补充.得益于超高性能和丰富的数据结构,Re ...

  5. Redis Lua脚本原理

    2.6版本之后支持嵌入Lua脚本,客户端使用Lua脚本,直接在服务器端原子的执行多条命令 Lua脚本执行过程 创建并修改Lua环境 1 创建基础Lua环境 2 载入函数库 3 创建全局表格Lua 4 ...

  6. 在Spring中使用Redis Lua脚本批量删除缓存

    背景 之前分享了一篇利用lua脚本批量删除redis的key的文章.现在项目中我打算使用spring的缓存,而Spring缓存以前我是用ehcache来做实现的.没发现什么问题..这次我换成redis ...

  7. 【连载】redis库存操作,分布式锁的四种实现方式[四]--基于Redis lua脚本机制实现分布式锁

    一.redis lua介绍 Redis 提供了非常丰富的指令集,但是用户依然不满足,希望可以自定义扩充若干指令来完成一些特定领域的问题.Redis 为这样的用户场景提供了 lua 脚本支持,用户可以向 ...

  8. redis --- lua 脚本实现原子操作

    如题, 楼主的想法很简单, lua 脚本本身支持原子性, 所以把命令写进一个脚本就行, 当然后续还会优化才能放到生产上,例如缓存脚本 ,redis 本身会缓存执行过的脚本 ,这样速度更快, 再优化, ...

  9. redis lua脚本学习

    语法格式(常见) a = 5 -- 全局变量 local b = 5 -- 局部变量 Eval的使用 EVAL script numkeys key [key ...] arg [arg ...] 首 ...

随机推荐

  1. 【TNS】TNS-00515 TNS-12560 TNS-12545解决方案

    今天同事的plsql连接不上数据库,我用他的本地tnsping是不通的,于是上服务器上查看下,结果发现监听没起来,不知道怎么就断了 再次尝试重启 lsnrctl start 发现直接报错: NSLSN ...

  2. Docker安装配置及华为云镜像加速

    Docker华为云镜像加速 软件介绍 支持的操作系统 docker安装 docker镜像加速(华为云实现) 1.登录华为云网站,注册华为云账户 2.登录华为云账户,点击网页右上角的控制台 3.点击左上 ...

  3. linux DRM GPU scheduler 笔记

    内核文档:   Overview   The GPU scheduler provides entities which allow userspace to push jobs into softw ...

  4. Py-时间,随机,os,sys,jsonpickle序列化,shelve,xml模块

    内置模块 1.时间模块 第一:time.time()是时间戳 时间戳默认是 从1970年到现在过的秒数,是一个很长的数值它可以做时间的计算以及显示 第二:localtime() 获取当前的时间,按元组 ...

  5. JavaWeb三大框架基础架构——CRUD的基础功能搭建

    @ 目录 介绍 注意 applicationContext.xml mybatis-config.xml web.xml 结束语 介绍 项目前端采用了bootstrap,后端是ssm三大框架 注意 这 ...

  6. 改变JavaScript中函数的内部this指向!

    改变JavaScript中函数的内部this指向! 第一种方法 call call 可以 调用函数 + 改变函数内的this指向! var obj = { name: 'lvhang' } funct ...

  7. monitor a local unix domain socket like tcpdump

    Can I monitor a local unix domain socket like tcpdump? - Super User https://superuser.com/questions/ ...

  8. secure hashes message digests 安全哈希 消息摘要

    hashlib --- 安全哈希与消息摘要 - Python 3.8.3 文档 https://docs.python.org/zh-cn/3.8/library/hashlib.html hashl ...

  9. 【LinuxShell】命令行常用快捷键

    Ctrl + A :光标跳到一行命令的开头.一般来说,Home 键有相同的效果: Ctrl + E :光标跳到一行命令的结尾.一般来说,End 键有相同的效果:. Ctrl + U :删除所有在光标左 ...

  10. 【LinuxShell】echo用法详解

    使用echo打印信息到终端 1 [Shell]echo "yz test" 2 yz test 3 [Shell]echo yz test 4 yz test 5 [Shell]e ...