Skip to content

Latest commit

 

History

History
599 lines (393 loc) · 34.9 KB

redis-questions-01.md

File metadata and controls

599 lines (393 loc) · 34.9 KB
title category tag head
Redis常见面试题总结(上)
数据库
Redis
meta
name content
keywords
Redis基础,Redis常见数据结构,Redis线程模型,Redis内存管理,Redis事务,Redis性能优化
meta
name content
description
一篇文章总结Redis常见的知识点和面试题,涵盖Redis基础、Redis常见数据结构、Redis线程模型、Redis内存管理、Redis事务、Redis性能优化等内容。

Redis 基础

什么是 Redis?

Redis 是一个基于 C 语言开发的开源数据库(BSD 许可),与传统数据库不同的是 Redis 的数据是存在内存中的(内存数据库),读写速度非常快,被广泛应用于缓存方向。并且,Redis 存储的是 KV 键值对数据。

为了满足不同的业务场景,Redis 内置了多种数据类型实现(比如 String、Hash、Sorted Set、Bitmap、HyperLogLog、GEO)。并且,Redis 还支持事务、持久化、Lua 脚本、多种开箱即用的集群方案(Redis Sentinel、Redis Cluster)。

Redis 没有外部依赖,Linux 和 OS X 是 Redis 开发和测试最多的两个操作系统,官方推荐生产环境使用 Linux 部署 Redis。

个人学习的话,你可以自己本机安装 Redis 或者通过 Redis 官网提供的在线 Redis 环境(少部分命令无法使用)来实际体验 Redis。

try-redis

全世界有非常多的网站使用到了 Redis ,techstacks.io 专门维护了一个使用 Redis 的热门站点列表 ,感兴趣的话可以看看。

Redis 为什么这么快?

Redis 内部做了非常多的性能优化,比较重要的有下面 3 点:

  1. Redis 基于内存,内存的访问速度是磁盘的上千倍;
  2. Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型,主要是单线程事件循环和 IO 多路复用(Redis 线程模式后面会详细介绍到);
  3. Redis 内置了多种优化过后的数据结构实现,性能非常高。

下面这张图片总结的挺不错的,分享一下,出自 Why is Redis so fast?

why-redis-so-fast

分布式缓存常见的技术选型方案有哪些?

分布式缓存的话,比较老牌同时也是使用的比较多的还是 MemcachedRedis。不过,现在基本没有看过还有项目使用 Memcached 来做缓存,都是直接用 Redis

Memcached 是分布式缓存最开始兴起的那会,比较常用的。后来,随着 Redis 的发展,大家慢慢都转而使用更加强大的 Redis 了。

另外,腾讯也开源了一款类似于 Redis 的分布式高性能 KV 存储数据库,基于知名的开源项目 RocksDB 作为存储引擎 ,100% 兼容 Redis 协议和 Redis4.0 所有数据模型,名为 Tendis

关于 Redis 和 Tendis 的对比,腾讯官方曾经发过一篇文章:Redis vs Tendis:冷热混合存储版架构揭秘 ,可以简单参考一下。

从这个项目的 GitHub 提交记录可以看出,Tendis 开源版几乎已经没有被维护更新了,加上其关注度并不高,使用的公司也比较少。因此,不建议你使用 Tendis 来实现分布式缓存。

说一下 Redis 和 Memcached 的区别和共同点

现在公司一般都是用 Redis 来实现缓存,而且 Redis 自身也越来越强大了!不过,了解 Redis 和 Memcached 的区别和共同点,有助于我们在做相应的技术选型的时候,能够做到有理有据!

共同点

  1. 都是基于内存的数据库,一般都用来当做缓存使用。
  2. 都有过期策略。
  3. 两者的性能都非常高。

区别

  1. Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型。
  2. Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 把数据全部存在内存之中。
  3. Redis 有灾难恢复机制。 因为可以把缓存中的数据持久化到磁盘上。
  4. Redis 在服务器内存使用完之后,可以将不用的数据放到磁盘上。但是,Memcached 在服务器内存使用完之后,就会直接报异常。
  5. Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 目前是原生支持 cluster 模式的。
  6. Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。 (Redis 6.0 针对网络数据的读写引入了多线程)
  7. Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。
  8. Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。

相信看了上面的对比之后,我们已经没有什么理由可以选择使用 Memcached 来作为自己项目的分布式缓存了。

为什么要用 Redis/为什么要用缓存?

下面我们主要从“高性能”和“高并发”这两点来回答这个问题。

1、高性能

假如用户第一次访问数据库中的某些数据的话,这个过程是比较慢,毕竟是从硬盘中读取的。但是,如果说,用户访问的数据属于高频数据并且不会经常改变的话,那么我们就可以很放心地将该用户访问的数据存在缓存中。

这样有什么好处呢? 那就是保证用户下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度相当快。

2、高并发

一般像 MySQL 这类的数据库的 QPS 大概都在 1w 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 10w+,甚至最高能达到 30w+(就单机 Redis 的情况,Redis 集群的话会更高)。

QPS(Query Per Second):服务器每秒可以执行的查询次数;

由此可见,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高了系统整体的并发。

常见的缓存读写策略有哪些?

关于常见的缓存读写策略的详细介绍,可以看我写的这篇文章:3 种常用的缓存读写策略详解

Redis 应用

Redis 除了做缓存,还能做什么?

  • 分布式锁:通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章:分布式锁详解
  • 限流:一般是通过 Redis + Lua 脚本的方式来实现限流。相关阅读:《我司用了 6 年的 Redis 分布式限流器,可以说是非常厉害了!》
  • 消息队列:Redis 自带的 list 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。它比较类似于 Kafka,有主题和消费组的概念,支持消息持久化以及 ACK 机制。
  • 复杂业务场景:通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景比如通过 bitmap 统计活跃用户、通过 sorted set 维护排行榜。
  • ......

如何基于 Redis 实现分布式锁?

关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章:分布式锁详解

Redis 可以做消息队列么?

实际项目中也没见谁使用 Redis 来做消息队列,对于这部分知识点大家了解就好了。

先说结论:可以是可以,但不建议使用 Redis 来做消息队列。和专业的消息队列相比,还是有很多欠缺的地方。

Redis 2.0 之前,如果想要使用 Redis 来做消息队列的话,只能通过 List 来实现。

通过 RPUSH/LPOP 或者 LPUSH/RPOP即可实现简易版消息队列:

# 生产者生产消息
> RPUSH myList msg1 msg2
(integer) 2
> RPUSH myList msg3
(integer) 3
# 消费者消费消息
> LPOP myList
"msg1"

不过,通过 RPUSH/LPOP 或者 LPUSH/RPOP这样的方式存在性能问题,我们需要不断轮询去调用 RPOPLPOP 来消费消息。当 List 为空时,大部分的轮询的请求都是无效请求,这种方式大量浪费了系统资源。

因此,Redis 还提供了 BLPOPBRPOP 这种阻塞式读取的命令(带 B-Bloking 的都是阻塞式),并且还支持一个超时参数。如果 List 为空,Redis 服务端不会立刻返回结果,它会等待 List 中有新数据后在返回或者是等待最多一个超时时间后返回空。如果将超时时间设置为 0 时,即可无限等待,直到弹出消息

# 超时时间为 10s
# 如果有数据立刻返回,否则最多等待10秒
> BRPOP myList 10
null

List 实现消息队列功能太简单,像消息确认机制等功能还需要我们自己实现,最要命的是没有广播机制,消息也只能被消费一次。

Redis 2.0 引入了发布订阅 (pub/sub) 功能,解决了 List 实现消息队列没有广播机制的问题。

Redis 发布订阅 (pub/sub) 功能

pub/sub 中引入了一个概念叫 channel(频道),发布订阅机制的实现就是基于这个 channel 来做的。

pub/sub 涉及发布者(Publisher)和订阅者(Subscriber,也叫消费者)两个角色:

  • 发布者通过 PUBLISH 投递消息给指定 channel。
  • 订阅者通过SUBSCRIBE订阅它关心的 channel。并且,订阅者可以订阅一个或者多个 channel。

我们这里启动 3 个 Redis 客户端来简单演示一下:

pub/sub 实现消息队列演示

pub/sub 既能单播又能广播,还支持 channel 的简单正则匹配。不过,消息丢失(客户端断开连接或者 Redis 宕机都会导致消息丢失)、消息堆积(发布者发布消息的时候不会管消费者的具体消费能力如何)等问题依然没有一个比较好的解决办法。

为此,Redis 5.0 新增加的一个数据结构 Stream 来做消息队列。Stream 支持:

  • 发布 / 订阅模式
  • 按照消费者组进行消费
  • 消息持久化( RDB 和 AOF)

Stream 使用起来相对要麻烦一些,这里就不演示了。而且,Stream 在实际使用中依然会有一些小问题不太好解决比如在 Redis 发生故障恢复后不能保证消息至少被消费一次。

综上,和专业的消息队列相比,使用 Redis 来实现消息队列还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。因此,我们通常建议不要使用 Redis 来做消息队列,你完全可以选择市面上比较成熟的一些消息队列比如 RocketMQ、Kafka。

相关阅读:Redis 消息队列发展历程 - 阿里开发者 - 2022

Redis 数据结构

关于 Redis 5 种基础数据结构和 3 种特殊数据结构的详细介绍请看下面这两篇文章:

Redis 常用的数据结构有哪些?

  • 5 种基础数据结构:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。
  • 3 种特殊数据结构:HyperLogLogs(基数统计)、Bitmap (位存储)、Geospatial (地理位置)。

String 的应用场景有哪些?

String 是 Redis 中最简单同时也是最常用的一个数据结构。String 是一种二进制安全的数据结构,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象。

String 的常见应用场景如下:

  • 常规数据(比如 session、token、序列化后的对象、图片的路径)的缓存;
  • 计数比如用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数;
  • 分布式锁(利用 SETNX key value 命令可以实现一个最简易的分布式锁);
  • ......

关于 String 的详细介绍请看这篇文章:Redis 5 种基本数据结构详解

String 还是 Hash 存储对象数据更好呢?

  • String 存储的是序列化后的对象数据,存放的是整个对象。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或者添加部分字段,节省网络流量。如果对象中某些字段需要经常变动或者经常需要单独查询对象中的个别字段信息,Hash 就非常适合。
  • String 存储相对来说更加节省内存,缓存相同数量的对象数据,String 消耗的内存约是 Hash 的一半。并且,存储具有多层嵌套的对象时也方便很多。如果系统对性能和资源消耗非常敏感的话,String 就非常适合。

在绝大部分情况,我们建议使用 String 来存储对象数据即可!

String 的底层实现是什么?

Redis 是基于 C 语言编写的,但 Redis 的 String 类型的底层实现并不是 C 语言中的字符串(即以空字符 \0 结尾的字符数组),而是自己编写了 SDS(Simple Dynamic String,简单动态字符串) 来作为底层实现。

SDS 最早是 Redis 作者为日常 C 语言开发而设计的 C 字符串,后来被应用到了 Redis 上,并经过了大量的修改完善以适合高性能操作。

Redis7.0 的 SDS 的部分源码如下(https://github.com/redis/redis/blob/7.0/src/sds.h):

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

通过源码可以看出,SDS 共有五种实现方式 SDS_TYPE_5(并未用到)、SDS_TYPE_8、SDS_TYPE_16、SDS_TYPE_32、SDS_TYPE_64,其中只有后四种实际用到。Redis 会根据初始化的长度决定使用哪种类型,从而减少内存的使用。

类型 字节
sdshdr5 < 1 <8
sdshdr8 1 8
sdshdr16 2 16
sdshdr32 4 32
sdshdr64 8 64

对于后四种实现都包含了下面这 4 个属性:

  • len:字符串的长度也就是已经使用的字节数
  • alloc:总共可用的字符空间大小,alloc-len 就是 SDS 剩余的空间大小
  • buf[]:实际存储字符串的数组
  • flags:低三位保存类型标志

SDS 相比于 C 语言中的字符串有如下提升:

  1. 可以避免缓冲区溢出:C 语言中的字符串被修改(比如拼接)时,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。SDS 被修改时,会先根据 len 属性检查空间大小是否满足要求,如果不满足,则先扩展至所需大小再进行修改操作。
  2. 获取字符串长度的复杂度较低:C 语言中的字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。SDS 的长度获取直接读取 len 属性即可,时间复杂度为 O(1)。
  3. 减少内存分配次数:为了避免修改(增加/减少)字符串时,每次都需要重新分配内存(C 语言的字符串是这样的),SDS 实现了空间预分配和惰性空间释放两种优化策略。当 SDS 需要增加字符串时,Redis 会为 SDS 分配好内存,并且根据特定的算法分配多余的内存,这样可以减少连续执行字符串增长操作所需的内存重分配次数。当 SDS 需要减少字符串时,这部分内存不会立即被回收,会被记录下来,等待后续使用(支持手动释放,有对应的 API)。
  4. 二进制安全:C 语言中的字符串以空字符 \0 作为字符串结束的标识,这存在一些问题,像一些二进制文件(比如图片、视频、音频)就可能包括空字符,C 字符串无法正确保存。SDS 使用 len 属性判断字符串是否结束,不存在这个问题。

🤐 多提一嘴,很多文章里 SDS 的定义是下面这样的:

struct sdshdr {
    unsigned int len;
    unsigned int free;
    char buf[];
};

这个也没错,Redis 3.2 之前就是这样定义的。后来,由于这种方式的定义存在问题,lenfree 的定义用了 4 个字节,造成了浪费。Redis 3.2 之后,Redis 改进了 SDS 的定义,将其划分为了现在的 5 种类型。

购物车信息用 String 还是 Hash 存储更好呢?

由于购物车中的商品频繁修改和变动,购物车信息建议使用 Hash 存储:

  • 用户 id 为 key
  • 商品 id 为 field,商品数量为 value

Hash维护简单的购物车信息

那用户购物车信息的维护具体应该怎么操作呢?

  • 用户添加商品就是往 Hash 里面增加新的 field 与 value;
  • 查询购物车信息就是遍历对应的 Hash;
  • 更改商品数量直接修改对应的 value 值(直接 set 或者做运算皆可);
  • 删除商品就是删除 Hash 中对应的 field;
  • 清空购物车直接删除对应的 key 即可。

这里只是以业务比较简单的购物车场景举例,实际电商场景下,field 只保存一个商品 id 是没办法满足需求的。

使用 Redis 实现一个排行榜怎么做?

Redis 中有一个叫做 sorted set 的数据结构经常被用在各种排行榜的场景,比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。

相关的一些 Redis 命令: ZRANGE (从小到大排序)、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。

《Java 面试指北》 的「技术面试题篇」就有一篇文章详细介绍如何使用 Sorted Set 来设计制作一个排行榜,感兴趣的小伙伴可以看看。

Set 的应用场景是什么?

Redis 中 Set 是一种无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet

Set 的常见应用场景如下:

  • 存放的数据不能重复的场景:网站 UV 统计(数据量巨大的场景还是 HyperLogLog更适合一些)、文章点赞、动态点赞等等。
  • 需要获取多个数据源交集、并集和差集的场景:共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集)、订阅号推荐(差集+交集) 等等。
  • 需要随机获取数据源中的元素的场景:抽奖系统、随机点名等等。

使用 Set 实现抽奖系统怎么做?

如果想要使用 Set 实现一个简单的抽奖系统的话,直接使用下面这几个命令就可以了:

  • SADD key member1 member2 ...:向指定集合添加一个或多个元素。
  • SPOP key count:随机移除并获取指定集合中一个或多个元素,适合不允许重复中奖的场景。
  • SRANDMEMBER key count : 随机获取指定集合中指定数量的元素,适合允许重复中奖的场景。

使用 Bitmap 统计活跃用户怎么做?

Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省储存空间。

你可以将 Bitmap 看作是一个存储二进制数字(0 和 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。

img

如果想要使用 Bitmap 统计活跃用户的话,可以使用日期(精确到天)作为 key,然后用户 ID 为 offset,如果当日活跃过就设置为 1。

初始化数据:

> SETBIT 20210308 1 1
(integer) 0
> SETBIT 20210308 2 1
(integer) 0
> SETBIT 20210309 1 1
(integer) 0

统计 20210308~20210309 总活跃用户数:

> BITOP and desk1 20210308 20210309
(integer) 1
> BITCOUNT desk1
(integer) 1

统计 20210308~20210309 在线活跃用户数:

> BITOP or desk2 20210308 20210309
(integer) 1
> BITCOUNT desk2
(integer) 2

使用 HyperLogLog 统计页面 UV 怎么做?

使用 HyperLogLog 统计页面 UV 主要需要用到下面这两个命令:

  • PFADD key element1 element2 ...:添加一个或多个元素到 HyperLogLog 中。
  • PFCOUNT key1 key2:获取一个或者多个 HyperLogLog 的唯一计数。

1、将访问指定页面的每个用户 ID 添加到 HyperLogLog 中。

PFADD PAGE_1:UV USER1 USER2 ...... USERn

2、统计指定页面的 UV。

PFCOUNT PAGE_1:UV

Redis 持久化机制(重要)

Redis 持久化机制(RDB 持久化、AOF 持久化、RDB 和 AOF 的混合持久化) 相关的问题比较多,也比较重要,于是我单独抽了一篇文章来总结 Redis 持久化机制相关的知识点和问题:Redis 持久化机制详解

Redis 线程模型(重要)

对于读写命令来说,Redis 一直是单线程模型。不过,在 Redis 4.0 版本之后引入了多线程来执行一些大键值对的异步删除操作, Redis 6.0 版本之后引入了多线程来处理网络请求(提高网络 IO 读写性能)。

Redis 单线程模型了解吗?

Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型 (Netty 的线程模型也基于 Reactor 模式,Reactor 模式不愧是高性能 IO 的基石),这套事件处理模型对应的是 Redis 中的文件事件处理器(file event handler)。由于文件事件处理器(file event handler)是单线程方式运行的,所以我们一般都说 Redis 是单线程模型。

《Redis 设计与实现》有一段话是如是介绍文件事件处理器的,我觉得写得挺不错。

Redis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler)。

  • 文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
  • 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关 闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。

既然是单线程,那怎么监听大量的客户端连接呢?

Redis 通过 IO 多路复用程序 来监听来自客户端的大量连接(或者说是监听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。

这样的好处非常明显:I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗(和 NIO 中的 Selector 组件很像)。

文件事件处理器(file event handler)主要是包含 4 个部分:

  • 多个 socket(客户端连接)
  • IO 多路复用程序(支持多个客户端连接的关键)
  • 文件事件分派器(将 socket 关联到相应的事件处理器)
  • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)

文件事件处理器(file event handler)

相关阅读:Redis 事件机制详解

Redis6.0 之前为什么不使用多线程?

虽然说 Redis 是单线程模型,但是,实际上,Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。

不过,Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主线程之外的其他线程来“异步处理”。

为此,Redis 4.0 之后新增了UNLINK(可以看作是 DEL 的异步版本)、FLUSHALL ASYNC(清空所有数据库的所有 key,不仅仅是当前 SELECT 的数据库)、FLUSHDB ASYNC(清空当前 SELECT 数据库中的所有 key)等异步命令。

redis4.0 more thread

大体上来说,Redis 6.0 之前主要还是单线程处理。

那 Redis6.0 之前为什么不使用多线程? 我觉得主要原因有 3 点:

  • 单线程编程容易并且更容易维护;
  • Redis 的性能瓶颈不在 CPU ,主要在内存和网络;
  • 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。

相关阅读:为什么 Redis 选择单线程模型?

Redis6.0 之后为何引入了多线程?

Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。

虽然,Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了,执行命令仍然是单线程顺序执行。因此,你也不需要担心线程安全问题。

Redis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要设置 IO 线程数 > 1,需要修改 redis 配置文件 redis.conf

io-threads 4 #设置1的话只会开启主线程,官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程

另外:

  • io-threads 的个数一旦设置,不能通过 config 动态设置。
  • 当设置 ssl 后,io-threads 将不工作。

开启多线程后,默认只会使用多线程进行 IO 写入 writes,即发送数据给客户端,如果需要开启多线程 IO 读取 reads,同样需要修改 redis 配置文件 redis.conf :

io-threads-do-reads yes

但是官网描述开启多线程读并不能有太大提升,因此一般情况下并不建议开启

相关阅读:

Redis 后台线程了解吗?

我们虽然经常说 Redis 是单线程模型(主要逻辑是单线程完成的),但实际还有一些后台线程用于执行一些比较耗时的操作:

  • 通过 bio_close_file 后台线程来释放 AOF / RDB 等过程中产生的临时文件资源。
  • 通过 bio_aof_fsync 后台线程调用 fsync 函数将系统内核缓冲区还未同步到到磁盘的数据强制刷到磁盘( AOF 文件)。
  • 通过 bio_lazy_free后台线程释放大对象(已删除)占用的内存空间.

bio.h 文件中有定义(Redis 6.0 版本,源码地址:https://github.com/redis/redis/blob/6.0/src/bio.h):

#ifndef __BIO_H
#define __BIO_H

/* Exported API */
void bioInit(void);
void bioCreateBackgroundJob(int type, void *arg1, void *arg2, void *arg3);
unsigned long long bioPendingJobsOfType(int type);
unsigned long long bioWaitStepOfType(int type);
time_t bioOlderJobOfType(int type);
void bioKillThreads(void);

/* Background job opcodes */
#define BIO_CLOSE_FILE    0 /* Deferred close(2) syscall. */
#define BIO_AOF_FSYNC     1 /* Deferred AOF fsync. */
#define BIO_LAZY_FREE     2 /* Deferred objects freeing. */
#define BIO_NUM_OPS       3

#endif

关于 Redis 后台线程的详细介绍可以查看 Redis 6.0 后台线程有哪些? 这篇就文章。

Redis 内存管理

Redis 给缓存数据设置过期时间有啥用?

一般情况下,我们设置保存的缓存数据的时候都会设置一个过期时间。为什么呢?

因为内存是有限的,如果缓存中的所有数据都是一直保存的话,分分钟直接 Out of memory。

Redis 自带了给缓存数据设置过期时间的功能,比如:

127.0.0.1:6379> expire key 60 # 数据在 60s 后过期
(integer) 1
127.0.0.1:6379> setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire)
OK
127.0.0.1:6379> ttl key # 查看数据还有多久过期
(integer) 56

注意:Redis 中除了字符串类型有自己独有设置过期时间的命令 setex 外,其他方法都需要依靠 expire 命令来设置过期时间 。另外, persist 命令可以移除一个键的过期时间。

过期时间除了有助于缓解内存的消耗,还有什么其他用么?

很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在 1 分钟内有效,用户登录的 token 可能只在 1 天内有效。

如果使用传统的数据库来处理的话,一般都是自己判断过期,这样更麻烦并且性能要差很多。

Redis 是如何判断数据是否过期的呢?

Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。

redis过期字典

过期字典是存储在 redisDb 这个结构里的:

typedef struct redisDb {
    ...

    dict *dict;     //数据库键空间,保存着数据库中所有键值对
    dict *expires   // 过期字典,保存着键的过期时间
    ...
} redisDb;

过期的数据的删除策略了解么?

如果假设你设置了一批 key 只能存活 1 分钟,那么 1 分钟后,Redis 是怎么对这批 key 进行删除的呢?

常用的过期数据的删除策略就两个(重要!自己造缓存轮子的时候需要格外考虑的东西):

  1. 惰性删除:只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
  2. 定期删除:每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。

定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 定期删除+惰性/懒汉式删除

但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就 Out of memory 了。

怎么解决这个问题呢?答案就是:Redis 内存淘汰机制。

Redis 内存淘汰机制了解么?

相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?

Redis 提供 6 种数据淘汰策略:

  1. volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰。
  2. volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰。
  3. volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰。
  4. allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)。
  5. allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰。
  6. no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!

4.0 版本后增加以下两种:

  1. volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰。
  2. allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key。

参考