程序员的 Redis 面试金典

学好一门技术最有价值的体现就是“面试”,对于大部分人来说 “面试”是涨薪的主要途径之一,因此我们需要认真的准备面试,因为它直接决定着你今后几年内的薪资水平,所以在面试这件事上花费再多的时间和精力都是值得的。

你会发现有时候一个知识点回答的好坏能决定你的月薪是涨 500 还是涨 5000,我相信大部分人都想成为后者,但所有人都这样想,所以你应该出类拔萃,所以你应该学习更多的技能,所以你应该好好的准备面试,而本专栏将会很好的帮助到你。

专栏亮点

  • 不用死记硬背面试题,本专栏采用 理论 + 实践的方式,让你在懂得原理和代码实践的基础上“顺便”记住了相关的面试题;
  • 内容全面,本专栏的特点是从一个知识点入手,把该知识点相关的所有知识点和面试题进行全面的解析
  • 深入而又详细的讲解了 Redis 中最热门的面试问题;
  • 内容最新,本专栏是基于最新版本 Redis 6.x 开发的课程;
  • 针对面试群体,本专栏的内容主要的目标是针对面试群体和准备面试的群体,提供了考题分析、知识扩展等版块。

内容介绍

image

内容目录

  1. Redis 属於单线程还是多线程?不同的版本有什么区别?
  2. Redis 如何实现限流功能?
  3. Redis 有哪些数据类型?
  4. 如何实现查询附近的人?
  5. Redis 内存用完会怎样?
  6. Redis 如何处理已经过期的数据?
  7. Redis 如何实现分布式锁?
  8. 如何保证 Redis 消息队列中的数据不丢失?
  9. 使用 Redis 如何实现延迟消息队列?
  10. 如何在海量数据中查询一个值是否存在?
  11. 常用的 Redis 优化手段有哪些?
  12. 如何设计不宕机的 Redis 高可用服务?
  13. Redis 面试题补充与汇总

你将收获什么?

  1. Redis 线程模型的相关知识点;
  2. Redis 数据类型的相关知识:String、Hash、List、Set、ZSet、GEO、HyperLogLog、Stream;
  3. Redis 限流算法:漏桶算法、令牌算法介绍和具体的代码实现;
  4. Redis 内存淘汰策略和算法;
  5. Redis 键值过期淘汰策略;
  6. 分布式锁的相关知识点和具体代码实现;
  7. Redis 消息队列实现的 4 种方法还有延迟消息队列的具体实现;
  8. Redis 常见的 10+ 种优化手段;
  9. Redis 主从、哨兵、集群相关知识。

作者介绍

王磊 GitChat 畅销作者、2019 年腾讯云最佳年度作者,十余年编程从业经验,曾就职 360,有着丰富的系统设计、开发和调优的经验,在不断探索和学习的过程中,积累了宝贵的经验,希望以技术传播为使命,帮助更多的人在技术的世界里持续精进。

本专栏是我结合自己近十年使用 Redis 的经验,曾依靠 Redis 为多个大厂,如腾讯游戏、360 游戏、迅雷游戏、多玩、17173、游久等知名公司,提供了数据支持的经验开发了这门专栏。

适宜人群

  1. 准备跳槽的后端工程师(初、中、高级)
  2. 即将毕业的在校学生
  3. 自学的准备转行的“准程序员”

购买须知

  • 本专栏为图文内容,共计 13 篇。每周更新 2 篇,预计 7 月中旬更新完毕。
  • 付费用户可享受文章永久阅读权限。
  • 本专栏为虚拟产品,一经付费概不退款,敬请谅解。
  • 本专栏可在 GitChat 服务号、App 及网页端 gitbook.cn 上购买,一端购买,多端阅读。

订阅福利

  • 订购本专栏可获得专属海报(在 GitChat 服务号领取),分享专属海报每成功邀请一位好友购买,即可获得 25% 的返现奖励,多邀多得,上不封顶,立即提现。

  • 提现流程:在 GitChat 服务号中点击「我-我的邀请-提现」。

  • ①点击这里跳转至》第 3 篇《翻阅至文末获得入群口令。

  • ②购买本专栏后,服务号会自动弹出入群二维码和暗号。如果你没有收到那就先关注微信服务号「GitChat」,或者加我们的小助手「GitChatty6」咨询。

课程内容

Redis 属於单线程还是多线程?不同的版本有什么区别?

Redis 是普及率最高的技术之一,同时也是面试中必问的一个技术模块,所以从今天开始我们将从最热门的 Redis 面试题入手,更加深入的学习和了解一下 Redis。

我们本文的面试题是 Redis 属於单线程还是多线程?

典型回答

本文的问题在不同的 Redis 版本下答案是不同的,在 Redis 4.0 之前,Redis 是单线程运行的,但单线程并不意味着性能低,类似单线程的程序还有 Nginx 和 NodeJs 他们都是单线程程序,但是效率并不低。Redis 的 FAQ(Frequently Asked Questions,常见问题)也回到过这个问题,具体内容如下:

Redis is single threaded. How can I exploit multiple CPU / cores?

It's not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.

However, to maximize CPU usage you can start multiple instances of Redis in the same box and treat them as different servers. At some point a single box may not be enough anyway, so if you want to use multiple CPUs you can start thinking of some way to shard earlier.

You can find more information about using multiple Redis instances in the Partitioning page.

However with Redis 4.0 we started to make Redis more threaded. For now this is limited to deleting objects in the background, and to blocking commands implemented via Redis modules. For future releases, the plan is to make Redis more and more threaded.

详情请见:https://redis.io/topics/faq

他的大体意思是说 Redis 是基于内存操作的,因此他的瓶颈可能是机器的内存或者网络带宽而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了,况且使用多线程比较麻烦。但是在 Redis 4.0 中开始支持多线程了,例如后台删除等功能。 

简单来说 Redis 之所以在 4.0 之前一直采用单线程的模式是因为以下三个原因:

  • 使用单线程模型是 Redis 的开发和维护更简单,因为单线程模型方便开发和调试;
  • 即使使用单线程模型也并发的处理多客户端的请求,主要使用的是多路复用(详见本文下半部分);
  • 对于 Redis 系统来说,主要的性能瓶颈是内存或者网络带宽而并非 CPU。

Redis 在 4.0 中引入了惰性删除(也可以叫异步删除),意思就是说我们可以使用异步的方式对 Redis 中的数据进行删除操作了,例如 unlink key / flushdb async / flushall async 等命令,他们的执行示例如下:

> unlink key # 后台删除某个 key> OK # 执行成功> flushall async # 清空所有数据> OK # 执行成功

这样处理的好处是不会导致 Redis 主线程卡顿,会把这些删除操作交给后台线程来执行。

小贴士:通常情况下使用 del 指令可以很快的删除数据,而当被删除的 key 是一个非常大的对象时,例如时包含了成千上万个元素的 hash 集合时,那么 del 指令就会造成 Redis 主线程卡顿,因此使用惰性删除可以有效的避免 Redis 卡顿的问题。

考点分析

关于 Redis 线程模型的问题(单线程或多线程)几乎是 Redis 必问的问题之一,但能回答好的人却寥寥无几,大部分的人只能回到上来 Redis 是单线程的以及说出来单线程的众多好处,但对于 Redis 4.0 和 Redis 6.0 中,尤其是 Redis 6.0 中的多线程能回答上来的人少之又少,和这个知识点相关的面试题还有以下这些。

  • Redis 主线程既然是单线程的,为什么还这么快?
  • 介绍一下 Redis 中的多路复用?
  • 介绍一下 Redis 6.0 中的多线程?

知识扩展

1.Redis 为什么这么快?

我们知道 Redis 4.0 之前是单线程的,那既然是单线程为什么还能这么快?

Redis 速度比较快的原因有以下几点:

  • 基于内存操作:Redis 的所有数据都存在内存中,因此所有的运算都是内存级别的,所以他的性能比较高;
  • 数据结构简单:Redis 的数据结构比较简单,是为 Redis 专门设计的,而这些简单的数据结构的查找和操作的时间复杂度都是 O(1),因此性能比较高;
  • 多路复用和非阻塞 I/O:Redis 使用 I/O 多路复用功能来监听多个 socket 连接客户端,这样就可以使用一个线程连接来处理多个请求,减少线程切换带来的开销,同时也避免了 I/O 阻塞操作,从而大大提高了 Redis 的性能;
  • 避免上下文切换:因为是单线程模型,因此就避免了不必要的上下文切换和多线程竞争,这就省去了多线程切换带来的时间和性能上的消耗,而且单线程不会导致死锁问题的发生。

官方使用基准测试的结果是,单线程的 Redis 吞吐量可以达到 10W/每秒,如下图所示:image.png

2.I/O 多路复用

套接字的读写方法默认情况下是阻塞的,例如当调用读取操作 read 方法时,缓冲区没有任何数据,那么这个线程就会阻塞卡在这里,直到缓冲区有数据或者是连接被关闭时,read 方法才可以返回,线程才可以继续处理其他业务。

但这样显然降低了程序的整体执行效率,而 Redis 使用的就是非阻塞的 I/O,这就意味着 I/O 的读写流程不再是阻塞的,读写方法都是瞬间完成并返回的,也就是他会采用能读多少读多少能写多少写多少的策略来执行 I/O 操作,这显然更符合我们对性能的追求。

但这种非阻塞的 I/O 依然存在一个问题,那就是当我们执行读取数据操作时,有可能只读取了一部分数据,同样写入数据也是这种情况,当缓存区满了之后我们的数据还没写完,剩余的数据何时写何时读就成了一个问题。

而 I/O 多路复用就是解决上面的这个问题的,使用 I/O 多路复用最简单的实现方式就是使用 select 函数,此函数为操作系统提供给用户程序的 API 接口,是用于监控多个文件描述符的可读和可写情况的,这样就可以监控到文件描述符的读写事件了,当监控到相应的事件之后就可以通知线程处理相应的业务了,这样就保证了 Redis 读写功能的正常执行了。

I/O 多路复用执行流程如下图所示:image.png

小贴士:现在的操作系统已经很少使用 select 函数了,改为调用 epoll(linux)和 kqueue(MacOS)等函数了,因为 select 函数在文件描述符特别多时性能非常的差。

3.Redis 6.0 多线程

Redis 单线程的优点很明显,不但降低了 Redis 内部的实现复杂性,也让所有操作都可以在无锁的情况下进行操作,并且不存在死锁和线程切换带来的性能和时间上的消耗,但缺点也很明显,单线程的机制导致 Redis 的 QPS(Query Per Second,每秒查询率)很难得到有效的提高。

Redis 4.0 版本中虽然引入了多线程,但此版本中的多线程只能用于大数据量的异步删除,然而对于非删除操作的意义并不是很大。

如果我们使用多线程就可以分摊 Redis 同步读写 I/O 的压力,以及充分的利用多核 CPU 的资源,并且可以有效的提升 Redis 的 QPS。在 Redis 中虽然使用了 I/O 多路复用,并且是基于非阻塞 I/O 进行操作的,但 I/O 的读和写本身是堵塞的,比如当 socket 中有数据时,Redis 会通过调用先将数据从内核态空间拷贝到用户态空间,再交给 Redis 调用,而这个拷贝的过程就是阻塞的,当数据量越大时拷贝所需要的时间就越多,而这些操作都是基於单线程完成的。

因此在 Redis 6.0 中新增了多线程的功能来提高 I/O 的读写性能,他的主要实现思路是将主线程的 IO 读写任务拆分给一组独立的线程去执行,这样就可以使多个 socket 的读写可以并行化了,但 Redis 的命令依旧是由主线程串行执行的。

需要注意的是 Redis 6.0 默认是禁用多线程的,可以通过修改 Redis 的配置文件 redis.conf 中的 io-threads-do-reads 等于 true 来开启多线程,完整配置为 io-threads-do-reads true,除此之外我们还需要设置线程的数量才能正确的开启多线程的功能,同样是修改 Redis 的配置,例如设置 io-threads 4 表示开启 4 个线程。

小贴士:关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。

关于 Redis 的性能,Redis 作者 antirez 在 RedisConf 2019 分享时曾提到,Redis 6 引入的多线程 I/O 特性对性能提升至少是一倍以上。国内也有人在阿里云使用 4 个线程的 Redis 版本和单线程的 Redis 进行比较测试,发现测试的结果和 antirez 给出的结论基本吻合,性能基本可以提高一倍。

总结

本文我们介绍了 Redis 在 4.0 之前单线程依然很快的原因:基于内存操作、数据结构简单、多路复用和非阻塞 I/O、避免了不必要的线程上下文切换,在 Redis 4.0 中已经添加了多线程的支持,主要体现在大数据的异步删除功能上,例如 unlink key、flushdb async、flushall async 等,Redis 6.0 新增了多线程 I/O 的读写并发能力,用于更好的提高 Redis 的性能。

Redis 有哪些数据类型?

Redis 的数据类型可谓是 Redis 的精华所在,同样的数据类型,例如字符串存储不同的值对应的实际存储结构也是不同,当你存储的 int 值是实际的存储结构也是 int,如果是短字符串(小于 44 字节)实际存储的结构为 embstr,长字符串对应的实际存储结构是 raw,这样设计的目的是为了更好的节约内存。

我们本文的面试题是 Redis 有哪些数据类型?

典型回答

Redis 最常用的数据类型有 5 种:String(字符串类型)、Hash(字典类型)、List(列表类型)、Set(集合类型)、ZSet(有序集合类型)。

1.字符串类型

字符串类型(Simple Dynamic Strings 简称 SDS),译为:简单动态字符串,它是以键值对 key-value 的形式进行存储的,根据 key 来存储和获取 value 值,它的使用相对来说比较简单,但在实际项目中应用非常广泛。

字符串的使用如下:

127.0.0.1:6379> set k1 v1 # 添加数据 OK127.0.0.1:6379> get k1 # 查询数据"v1"127.0.0.1:6379> strlen k1 # 查询字符串的长度(integer) 5

我们也可以在存储字符串时设置键值的过期时间,如下代码所示:

127.0.0.1:6379> set k1 v1 ex 1000 # 设置 k1 1000s 后过期(删除)OK

我们还可以使用 SDS 来存储 int 类型的值,并且可以使用 incr 指令和 decr 指令来操作存储的值 +1 或者 -1,具体实现代码如下:

127.0.0.1:6379> get k1 # 查询 k1=3"3"127.0.0.1:6379> incr k1 # 执行 +1 操作(integer) 4127.0.0.1:6379> get k1 # 查询 k1=4"4"127.0.0.1:6379> decr k1 # 执行 -1 操作(integer) 3127.0.0.1:6379> get k1 # 查询 k1=3"3"

字符串的常见使用场景:

  • 存放用户(登录)信息;
  • 存放文章详情和列表信息;
  • 存放和累计网页的统计信息(存储 int 值)。

……

2.字典类型

字典类型 (Hash) 又被成为散列类型或者是哈希表类型,它是将一个键值 (key) 和一个特殊的“哈希表”关联起来,这个“哈希表”表包含两列数据:字段和值。例如我们使用字典类型来存储一篇文章的详情信息,存储结构如下图所示:哈希表存储结构.png同理我们也可以使用字典类型来存储用户信息,并且使用字典类型来存储此类信息就无需手动序列化和反序列化数据了,所以使用起来更加的方便和高效。

字典类型的使用如下:

127.0.0.1:6379> hset myhash key1 value1 # 添加数据(integer) 1127.0.0.1:6379> hget myhash key1 # 查询数据"value1"

字典类型的数据结构,如下图所示:

Redis-HashType-02.png

通常情况下字典类型会使用数组的方式来存储相关的数据,但发生哈希冲突时才会使用链表的结构来存储数据。

3.列表类型

列表类型 (List) 是一个使用链表结构存储的有序结构,它的元素插入会按照先后顺序存储到链表结构中,因此它的元素操作 (插入和删除) 时间复杂度为 O(1),所以相对来说速度还是比较快的,但它的查询时间复杂度为 O(n),因此查询可能会比较慢。

列表类型的使用如下:

127.0.0.1:6379> lpush list 1 2 3 # 添加数据(integer) 3127.0.0.1:6379> lpop list # 获取并删除列表的第一个元素1

列表的典型使用场景有以下两个:

  • 消息队列:列表类型可以使用 rpush 实现先进先出的功能,同时又可以使用 lpop 轻松的弹出(查询并删除)第一个元素,所以列表类型可以用来实现消息队列;
  • 文章列表:对于博客站点来说,当用户和文章都越来越多时,为了加快程序的响应速度,我们可以把用户自己的文章存入到 List 中,因为 List 是有序的结构,所以这样又可以完美的实现分页功能,从而加速了程序的响应速度。

4.集合类型

集合类型 (Set) 是一个无序并唯一的键值集合。

集合类型的使用如下:

127.0.0.1:6379> sadd myset v1 v2 v3 # 添加数据(integer) 3127.0.0.1:6379> smembers myset # 查询集合中的所有数据1) "v1"2) "v3"3) "v2"

集合类型的经典使用场景如下:

  • 微博关注我的人和我关注的人都适合用集合存储,可以保证人员不会重复;
  • 中奖人信息也适合用集合类型存储,这样可以保证一个人不会重复中奖。

集合类型(Set)和列表类型(List)的区别如下:

  • 列表可以存储重复元素,集合只能存储非重复元素;
  • 列表是按照元素的先后顺序存储元素的,而集合则是无序方式存储元素的。

5.有序集合类型

有序集合类型 (Sorted Set) 相比于集合类型多了一个排序属性 score(分值),对于有序集合 ZSet 来说,每个存储元素相当于有两个值组成的,一个是有序结合的元素值,一个是排序值。有序集合的存储元素值也是不能重复的,但分值是可以重复的。

当我们把学生的成绩存储在有序集合中时,它的存储结构如下图所示:

学生存储值.png

有序集合类型的使用如下:

127.0.0.1:6379> zadd zset1 3 golang 4 sql 1 redis # 添加数据(integer) 3127.0.0.1:6379> zrange zset 0 -1 # 查询所有数据1) "redis"2) "mysql"3) "java"

有序集合的经典使用场景如下:

  • 学生成绩排名;
  • 粉丝列表,根据关注的先后时间排序。

考点分析

关于 Redis 数据类型的这个问题,对于大多数人既熟悉又陌生,熟悉的是每天都在使用 Redis 存取数据,陌生的是对于 Redis 的数据类型知之甚少,因为对于普通的开发工作使用字符串类型就可以搞定了。但是善用 Redis 的数据类型可以到达意想不到的效果,不但可以提高程序的运行速度又可以减少业务代码,可谓一举两得。

例如我们经常会把用户的登录信息存储在 Redis 中,但通常的做法是先将用户登录实体类转为 JSON 字符串存储在 Redis 中,然后读取时先查询数据再反序列化为 User 对象,这个过程看似没什么问题,但我们可以有更优的解决方案来处理此问题,比如我们可以使用 Hash 存储用户的信息,这样就无需序列化的过程了,并且读取之后无需反序列化,直接使用 Map 来接收就可以了,这样既提高了程序的运行速度有省去了序列化和反序列化的业务代码。

与此知识点相关的面试题还有以下几个:

  • 有序列表的实际存储结构是什么?
  • 除了五种基本的数据类型之外,还有什么数据类型?

知识扩展

有序列表的内部实现

有序集合是由 ziplist (压缩列表) 或 skiplist (跳跃表) 组成的。

ziplist 介绍

当数据比较少时,有序集合使用的是 ziplist 存储的,如下代码所示:

127.0.0.1:6379> zadd myzset 1 db 2 redis 3 mysql(integer) 3127.0.0.1:6379> object encoding myzset"ziplist"

从结果可以看出,有序集合把 myset 键值对存储在 ziplist 结构中了。有序集合使用 ziplist 格式存储必须满足以下两个条件:

  • 有序集合保存的元素个数要小于 128 个;
  • 有序集合保存的所有元素成员的长度都必须小于 64 字节。

如果不能满足以上两个条件中的任意一个,有序集合将会使用 skiplist 结构进行存储。接下来我们来测试以下,当有序集合中某个元素长度大于 64 字节时会发生什么情况?代码如下:

127.0.0.1:6379> zadd zmaxleng 1.0 redis(integer) 1127.0.0.1:6379> object encoding zmaxleng"ziplist"127.0.0.1:6379> zadd zmaxleng 2.0 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(integer) 1127.0.0.1:6379> object encoding zmaxleng"skiplist"

通过以上代码可以看出,当有序集合保存的所有元素成员的长度大于 64 字节时,有序集合就会从 ziplist 转换成为 skiplist。

小贴士:可以通过配置文件中的 zset-max-ziplist-entries(默认 128)和 zset-max-ziplist-value(默认 64)来设置有序集合使用 ziplist 存储的临界值。

skiplist 介绍

skiplist 数据编码底层是使用 zset 结构实现的,而 zset 结构中包含了一个字典和一个跳跃表,源码如下:

typedef struct zset {    dict *dict;    zskiplist *zsl;} zset;

跳跃表的结构如下图所示:有序集合-跳跃表.png

根据以上图片展示,当我们在跳跃表中查询值 32 时,执行流程如下:

  • 从最上层开始找,1 比 32 小,在当前层移动到下一个节点进行比较;
  • 7 比 32 小,当前层移动下一个节点比较,由于下一个节点指向 Null,所以以 7 为目标,移动到下一层继续向后比较;
  • 18 小于 32,继续向后移动查找,对比 77 大于 32,以 18 为目标,移动到下一层继续向后比较;
  • 对比 32 等于 32,值被顺利找到。

从上面的流程可以看出,跳跃表会想从最上层开始找起,依次向后查找,如果本层的节点大于要找的值,或者本层的节点为 Null 时,以上一个节点为目标,往下移一层继续向后查找并循环此流程,直到找到该节点并返回,如果对比到最后一个元素仍未找到,则返回 Null。

高级数据类型

除了有 5 大基本数据类型外,还有 GEO(地理位置类型)、HyperLogLog(统计类型)、Stream(流类型)。

GEO(地理位置类型)是 Redis 3.2 版本中新增的数据类型,用于存储和查询地理位置的,使用它我们可以实现查询附近的人或查询附近的商家等功能(这部分的内容会在后面的章节单独讲解)。

Stream(流类型)是 Redis 5.0 版本中新增的数据类型,因为使用 Stream 可以实现消息消费确认的功能,使用“xack key group-key ID”命令,所以此类型的出现给 Redis 更好的实现消息队列提供了很大的帮助。

HyperLogLog(统计类型)是本文介绍的重点,HyperLogLog (下文简称为 HLL) 是 Redis 2.8.9 版本添加的数据结构,它用于高性能的基数 (去重) 统计功能,它的缺点就是存在极低的误差率。

HLL 具有以下几个特点:

  • 能够使用极少的内存来统计巨量的数据,它只需要 12K 空间就能统计 2^64 的数据;
  • 统计存在一定的误差,误差率整体较低,标准误差为 0.81%;
  • 误差可以被设置辅助计算因子进行降低。

HLL 的命令只有 3 个,但都非常的实用,下面分别来看。

1.添加元素

127.0.0.1:6379> pfadd key "redis"(integer) 1127.0.0.1:6379> pfadd key "java" "sql"(integer) 1

相关语法: pfadd key element [element ...]此命令支持添加一个或多个元素至 HLL 结构中。

2.统计不重复的元素

127.0.0.1:6379> pfadd key "redis"(integer) 1127.0.0.1:6379> pfadd key "sql"(integer) 1127.0.0.1:6379> pfadd key "redis"(integer) 0127.0.0.1:6379> pfcount key(integer) 2

从 pfcount 的结果可以看出,在 HLL 结构中键值为 key 的元素, 有 2 个不重复的值:redis 和 sql,可以看出结果还是挺准的。相关语法: pfcount key [key ...]
此命令支持统计一个或多个 HLL 结构。

3.合并一个或多个 HLL 至新结构

新增 k 和 k2 合并至新结构 k3 中,代码如下:

127.0.0.1:6379> pfadd k "java" "sql"(integer) 1127.0.0.1:6379> pfadd k2 "redis" "sql"(integer) 1127.0.0.1:6379> pfmerge k3 k k2OK127.0.0.1:6379> pfcount k3(integer) 3

相关语法:pfmerge destkey sourcekey [sourcekey ...]**pfmerge 使用场景:当我们需要合并两个或多个同类页面的访问数据时,我们可以使用 pfmerge 来操作。

总结

本文我们介绍了 Redis 的 5 大基础数据类型的概念以及简单的使用:String(字符串类型)、Hash(字典类型)、List(列表类型)、Set(集合类型)、ZSet(有序集合类型),还深入的介绍了 ZSet 的底层数据存储结构:ziplist (压缩列表) 或 skiplist (跳跃表)。除此之外我们还介绍了 Redis 中的提前 3 个高级的数据类型:GEO(地理位置类型)用于实现查询附近的人、HyperLogLog(统计类型)用于高效的实现数据的去重统计(存在一定的误差)、Stream(流类型)主要应用于消息队列的实现。

使用 Redis 如何实现延迟队列?

延迟消息队列在我们的日常工作中经常会被用到,比如支付系统中超过 30 分钟未支付的订单,将会被取消,这样就可以保证此商品库存可以释放给其他人购买,还有外卖系统如果商家超过 5 分钟未接单的订单,将会被自动取消,以此来保证用户可以更及时的吃到自己点的外卖,等等诸如此类的业务场景都需要使用到延迟消息队列,又因为它在业务中比较常见,因此这个知识点在面试中也会经常被问到。

我们本文的面试题是,使用 Redis 如何实现延迟消息队列?

典型回答

延迟消息队列的常见实现方式是通过 ZSet 的存储于查询来实现,它的核心思想是在程序中开启一个一直循环的延迟任务的检测器,用于检测和调用延迟任务的执行,如下图所示:image.pngZSet 实现延迟任务的方式有两种,第一种是利用 zrangebyscore 查询符合条件的所有待处理任务,循环执行队列任务;第二种实现方式是每次查询最早的一条消息,判断这条信息的执行时间是否小于等于此刻的时间,如果是则执行此任务,否则继续循环检测。

方式一:zrangebyscore 查询所有任务此实现方式是一次性查询出所有的延迟任务,然后再进行执行,实现代码如下:

import redis.clients.jedis.Jedis;import utils.JedisUtils;import java.time.Instant;import java.util.Set;/** * 延迟队列 */public class DelayQueueExample {    // zset key    private static final String _KEY = "myDelayQueue";    public static void main(String[] args) throws InterruptedException {        Jedis jedis = JedisUtils.getJedis();        // 延迟 30s 执行(30s 后的时间)        long delayTime = Instant.now().plusSeconds(30).getEpochSecond();        jedis.zadd(_KEY, delayTime, "order_1");        // 继续添加测试数据        jedis.zadd(_KEY, Instant.now().plusSeconds(2).getEpochSecond(), "order_2");        jedis.zadd(_KEY, Instant.now().plusSeconds(2).getEpochSecond(), "order_3");        jedis.zadd(_KEY, Instant.now().plusSeconds(7).getEpochSecond(), "order_4");        jedis.zadd(_KEY, Instant.now().plusSeconds(10).getEpochSecond(), "order_5");        // 开启延迟队列        doDelayQueue(jedis);    }    /**     * 延迟队列消费     * @param jedis Redis 客户端     */    public static void doDelayQueue(Jedis jedis) throws InterruptedException {        while (true) {            // 当前时间            Instant nowInstant = Instant.now();            long lastSecond = nowInstant.plusSeconds(-1).getEpochSecond(); // 上一秒时间            long nowSecond = nowInstant.getEpochSecond();            // 查询当前时间的所有任务            Set<String> data = jedis.zrangeByScore(_KEY, lastSecond, nowSecond);            for (String item : data) {                // 消费任务                System.out.println("消费:" + item);            }            // 删除已经执行的任务            jedis.zremrangeByScore(_KEY, lastSecond, nowSecond);            Thread.sleep(1000); // 每秒轮询一次        }    }}

以上程序执行结果如下:

消费:order2消费:order3 消费:order4消费:order5 消费:order_1

方式二:判断最早的任务此实现方式是每次查询最早的一条任务,再与当前时间进行判断,如果任务执行时间大于当前时间则表示应该立即执行延迟任务,实现代码如下:

import redis.clients.jedis.Jedis;import utils.JedisUtils;import java.time.Instant;import java.util.Set;/** * 延迟队列 */public class DelayQueueExample {    // zset key    private static final String _KEY = "myDelayQueue";    public static void main(String[] args) throws InterruptedException {        Jedis jedis = JedisUtils.getJedis();        // 延迟 30s 执行(30s 后的时间)        long delayTime = Instant.now().plusSeconds(30).getEpochSecond();        jedis.zadd(_KEY, delayTime, "order_1");        // 继续添加测试数据        jedis.zadd(_KEY, Instant.now().plusSeconds(2).getEpochSecond(), "order_2");        jedis.zadd(_KEY, Instant.now().plusSeconds(2).getEpochSecond(), "order_3");        jedis.zadd(_KEY, Instant.now().plusSeconds(7).getEpochSecond(), "order_4");        jedis.zadd(_KEY, Instant.now().plusSeconds(10).getEpochSecond(), "order_5");        // 开启延迟队列        doDelayQueue2(jedis);    }    /**     * 延迟队列消费(方式 2)     * @param jedis Redis 客户端     */    public static void doDelayQueue2(Jedis jedis) throws InterruptedException {        while (true) {            // 当前时间            long nowSecond = Instant.now().getEpochSecond();            // 每次查询一条消息,判断此消息的执行时间            Set<String> data = jedis.zrange(_KEY, 0, 0);            if (data.size() == 1) {                String firstValue = data.iterator().next();                // 消息执行时间                Double score = jedis.zscore(_KEY, firstValue);                if (nowSecond >= score) {                    // 消费消息(业务功能处理)                    System.out.println("消费消息:" + firstValue);                    // 删除已经执行的任务                    jedis.zrem(_KEY, firstValue);                }            }            Thread.sleep(100); // 执行间隔        }    }}

以上程序执行结果和实现方式一相同,结果如下:

消费:order2消费:order3 消费:order4消费:order5 消费:order_1

其中,执行间隔代码 Thread.sleep(100) 可根据实际的业务情况删减或配置。

考点分析

延迟消息队列的实现方法有很多种,不同的公司可能使用的技术也是不同的,我上面是从 Redis 的角度出发来实现了延迟消息队列,但一般面试官不会就此罢休,会借着这个问题来问关于更多的延迟消息队列的实现方法,因此除了 Redis 实现延迟消息队列的方式,我们还需要具备一些其他的常见的延迟队列的实现方法。

和此知识点相关的面试题还有以下这些:

  • 使用 Java 语言如何实现一个延迟消息队列?
  • 你还知道哪些实现延迟消息队列的方法?

知识扩展

Java 中的延迟消息队列

我们可以使用 Java 语言中自带的 DelayQueue 数据类型来实现一个延迟消息队列,实现代码如下:

public class DelayTest {    public static void main(String[] args) throws InterruptedException {        DelayQueue delayQueue = new DelayQueue();        delayQueue.put(new DelayElement(1000));        delayQueue.put(new DelayElement(3000));        delayQueue.put(new DelayElement(5000));        System.out.println("开始时间:" +  DateFormat.getDateTimeInstance().format(new Date()));        while (!delayQueue.isEmpty()){            System.out.println(delayQueue.take());        }        System.out.println("结束时间:" +  DateFormat.getDateTimeInstance().format(new Date()));    }    static class DelayElement implements Delayed {        // 延迟截止时间(单面:毫秒)        long delayTime = System.currentTimeMillis();        public DelayElement(long delayTime) {            this.delayTime = (this.delayTime + delayTime);        }        @Override        // 获取剩余时间        public long getDelay(TimeUnit unit) {            return unit.convert(delayTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);        }        @Override        // 队列里元素的排序依据        public int compareTo(Delayed o) {            if (this.getDelay(TimeUnit.MILLISECONDS) > o.getDelay(TimeUnit.MILLISECONDS)) {                return 1;            } else if (this.getDelay(TimeUnit.MILLISECONDS) < o.getDelay(TimeUnit.MILLISECONDS)) {                return -1;            } else {                return 0;            }        }        @Override        public String toString() {            return DateFormat.getDateTimeInstance().format(new Date(delayTime));        }    }}

以上程序执行的结果如下:

开始时间:2019-6-13 20:40:38 2019-6-13 20:40:39 2019-6-13 20:40:41 2019-6-13 20:40:43 结束时间:2019-6-13 20:40:43

此实现方式的优点是开发比较方便,可以直接在代码中使用,实现代码也比较简单,但它缺点是数据保存在内存中,因此可能存在数据丢失的风险,最大的问题是它无法支持分布式系统。

使用 MQ 实现延迟消息队列

我们使用主流的 MQ 中间件也可以方便的实现延迟消息队列的功能,比如 RabbitMQ,我们可以通过它的 rabbitmq-delayed-message-exchange 插件来实现延迟队列。

首先我们需要配置并开启 rabbitmq-delayed-message-exchange 插件,然后再通过以下代码来实现延迟消息队列。

配置消息队列:

import com.example.rabbitmq.mq.DirectConfig;import org.springframework.amqp.core.*;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import java.util.HashMap;import java.util.Map;@Configurationpublic class DelayedConfig {    final static String QUEUE_NAME = "delayed.goods.order";    final static String EXCHANGE_NAME = "delayedec";    @Bean    public Queue queue() {        return new Queue(DelayedConfig.QUEUE_NAME);    }    // 配置默认的交换机    @Bean    CustomExchange customExchange() {        Map<String, Object> args = new HashMap<>();        args.put("x-delayed-type", "direct");        //参数二为类型:必须是 x-delayed-message        return new CustomExchange(DelayedConfig.EXCHANGE_NAME, "x-delayed-message", true, false, args);    }    // 绑定队列到交换器    @Bean    Binding binding(Queue queue, CustomExchange exchange) {        return BindingBuilder.bind(queue).to(exchange).with(DelayedConfig.QUEUE_NAME).noargs();    }}

发送者实现代码如下:

import org.springframework.amqp.AmqpException;import org.springframework.amqp.core.AmqpTemplate;import org.springframework.amqp.core.Message;import org.springframework.amqp.core.MessagePostProcessor;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Component;import java.text.SimpleDateFormat;import java.util.Date;@Componentpublic class DelayedSender {    @Autowired    private AmqpTemplate rabbitTemplate;    public void send(String msg) {        SimpleDateFormat sf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");        System.out.println("发送时间:" + sf.format(new Date()));        rabbitTemplate.convertAndSend(DelayedConfig.EXCHANGE_NAME, DelayedConfig.QUEUE_NAME, msg, new MessagePostProcessor() {            @Override            public Message postProcessMessage(Message message) throws AmqpException {                message.getMessageProperties().setHeader("x-delay", 3000);                return message;            }        });    }}

从上述代码我们可以看出,我们配置 3s 之后再进行任务执行。

消费者实现代码如下:

import org.springframework.amqp.rabbit.annotation.RabbitHandler;import org.springframework.amqp.rabbit.annotation.RabbitListener;import org.springframework.stereotype.Component;import java.text.SimpleDateFormat;import java.util.Date;@Component@RabbitListener(queues = "delayed.goods.order")public class DelayedReceiver {    @RabbitHandler    public void process(String msg) {        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");        System.out.println("接收时间:" + sdf.format(new Date()));        System.out.println("消息内容:" + msg);    }}

测试代码如下:

import com.example.rabbitmq.RabbitmqApplication;import com.example.rabbitmq.mq.delayed.DelayedSender;import org.junit.Test;import org.junit.runner.RunWith;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.test.context.junit4.SpringRunner;import java.text.SimpleDateFormat;import java.util.Date;@RunWith(SpringRunner.class)@SpringBootTestpublic class DelayedTest {    @Autowired    private DelayedSender sender;    @Test    public void Test() throws InterruptedException {        SimpleDateFormat sf = new SimpleDateFormat("yyyy-MM-dd");        sender.send("Hi Admin.");        Thread.sleep(5 * 1000); //等待接收程序执行之后,再退出测试    }}

以上程序的执行结果为:

发送时间:2020-06-11 20:47:51 接收时间:2018-06-11 20:47:54 消息内容:Hi Admin.

从上述结果中可以看出,当消息进入延迟队列 3s 之后才被正常消费,执行结果符合我的预期,RabbitMQ 成功的实现了延迟消息队列。

总结

本文我们讲了延迟消息队列的两种使用场景:支付系统中的超过 30 分钟未支付的订单,将会被自动取消,以此来保证此商品的库存可以正常释放给其他人购买,还有外卖系统如果商家超过 5 分钟未接单的订单,将会被自动取消,以此来保证用户可以更及时的吃到自己点的外卖。并且我们讲了延迟队列的 4 种实现方式,使用 ZSet 的 2 种实现方式,以及 Java 语言中的 DelayQueue 的实现方式,还有 RabbitMQ 的插件 rabbitmq-delayed-message-exchange 的实现方式。

如何实现查询附近的人?
Redis 如何实现限流功能?
Redis 如何处理已经过期的数据?
Redis 内存用完会怎样?
Redis 如何实现分布式锁?
如何保证 Redis 消息队列中的数据不丢失?
如何在海量数据中查询一个值是否存在?
常用的 Redis 优化手段有哪些?
如何设计不宕机的 Redis 高可用服务?
Redis 面试题补充与汇总

阅读全文: http://gitbook.cn/gitchat/column/5ee1d22c4a99494972797132

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章