跳到主要内容

Redis

Redis 提供了 9 种数据类型。

  • STRING 字符串
    • GET
    • SET
    • DEL
  • LIST 列表 (有序,可重复)
    • LPUSH 将元素推入表头
    • RPUSH 将元素推入表尾
    • LPOP
    • RPOP
    • LINDEX
    • LRANGE
  • SET 集合 (无顺序,不重复)
    • SADD 添加元素
    • SREM 删除元素
    • SMEMBERS 获取所有元素
    • SISMEMBER 检查元素是否存在
    • SINTER 交集
    • SUNION 并集
    • SDIFF 差集
  • HASH 散列 (包含键值对的无序散列表)
    • HSET
    • HGETALL
    • HDEL
    • HGET
  • ZSET 有序集合 (存储键值对)
    • ZADD
    • ZREM
    • ZRANGE
    • ZRANGEBYSCORE
  • BitMap
  • HyperLogLog
  • GER
  • Stream

文章投票系统

评分:文章发布时间 + 票数 * 常量

  • 时间:Unix 时间
  • 常量:一天的秒数 86400/200 = 432

使用 HASH 来存储文章的信息 name: HASH-ARTICLE key-> article:10086 value-> title, link, poster, time, votes

使用 ZSET 存储文章发布时间 name: ZSET-TIME key -> article:10086 value -> unix time

使用 ZSET 存储文章评分 name: ZSET-SCORE key -> article:10086 value -> score

使用 SET 存储每个文章的投票用户 name: voted:10086 vaule -> user:996,user:12580 ...

当用户给文章 ID 为 10086 投票时,向 voted:10086 集合添加用户的 ID,同时修改 ZSET-SCORE 中文章 ID 为 10086 的评分。

投票的逻辑

  1. 使用 ZSCORE 命令来检查 ZSET-TIME 中,文章的发布时间是否超过 7 天,如果超过则不允许投票。
  2. 使用 SADD 命令,将用户ID添加到 voted:10086 集合中。
  3. 使用 ZINCRBY 命令,修改 ZSET-SCORE 中文章 ID 为 10086 的评分。
  4. 使用 HINCRBY 命令,修改 HASH-ARTICLE 中文章 ID 为 10086 的票数。

发布文章的逻辑

  1. 使用 INCR 命令,获取文章的 ID。
  2. 使用 SADD 命令,将 poster 的ID 添加到 voted:10087 集合中。
  3. 使用 EXPIRE 命令,设置 voted:10087 集合的过期时间。
  4. 使用 HMSET 命令,将文章的信息存储到 HASH-ARTICLE 中。
  5. 使用 ZADD 命令,将文章的发布时间存储到 ZSET-TIME 中。
  6. 使用 ZADD 命令,将文章的评分存储到 ZSET-SCORE 中。

取出文章

  1. 使用 ZREVRANGE 命令,从 ZSET-SCORE 取出评分最高的文章 ID。
  2. 使用 HGETALL 命令,从 HASH-ARTICLE 取出文章的信息。

文章分组

  1. 文章属于哪个分组
  2. 取出某个分组的所有文章

Redis 面试题目

1、为什么要使用 Redis

  • 高性能
    • 线程模型
    • 数据结构
    • 持久化
    • 网络框架
  • 高可靠
    • 主从复制
    • 哨兵机制
  • 高拓展
    • 数据分片
    • 负载均衡

2、Redis 为什么使用单线程模型

单线程模型的优点

  1. 简单性,多线程编程需要处理诸如数据同步、线程间通信等复杂问题,而单线程模型可以避免这些复杂性。
  2. CPU 不是瓶颈Redis 的瓶颈通常在于网络 I/O,而不是 CPU。因此使用多线程并不能提高 Redis 的性能。
  3. 高效的事件模型Redis 使用了高效的事件模型非阻塞 I/O,可以处理大量并发连接,这使得单线程模型能够充分利用 CPU 资源。
  4. 避免了锁的开销。在多线程环境中,为了保证数据的一致性,需要使用锁来同步数据,这会带来额外的开销。

为什么 CPU 不是 Redis 的瓶颈呢?

因为 Redis 是一个基于内存的数据结构存储系统。它的操作大多数是增、查、删,都是在内存中完成的,速度非常快,通常不会成为瓶颈。

反而, I/O 通常是 Redis 的瓶颈。当 Redis 服务器需要处理大量并发连接,或者需要频繁地读写大量数据时,网络带宽和延迟可能会成为限制 Redis 性能的主要因素。

为什么 Redis 会是单线的呢?

  • 性能上考虑,单线程避免了上下文频繁切换问题,效率高。
  • 内部结构上设计原理进行考虑,Redis 是基于 Reactor 模式开发了自己的网络事件处理器(file event handler)。这个文件事件处理器是单线程的。

3、Linux 网络 I/O 模型

Linux 中,一切皆文件。

对文件读写,返回 file descriptor (fd,文件描述符)
socket 读写,返回 socketfd (socket 描述符)

什么是 recvfrom?

recvfrom 是一个系统调用,用于从一个套接字接受数据,并将数据存储在缓冲区中。

Unix 提供了 5 种 I/O 模型

  1. 阻塞 I/O 模型

所有文件操作都是阻塞的。

在进程空间中调用 recvfrom ,其系统调用直到数据包到达且被复制到应用进程的缓冲区或者发生错误时才返回,在此期间一直会等待,进程在调用 recvfrom 到返回的整段事件都是阻塞的。

  1. 非阻塞 I/O 模型

recvfrom 从应用层到内核的时候,如果该缓冲区没有数据的话,就直接返回一个 EWOULDBLOCK 错误,一般对非阻塞I/O模型进行沦陷检查这个状态,看内核是不是有数据到来。

  1. I/O 复用模型

Linux 提供 select/poll, 进程通过将一个或多个 fd 传递给 selectpoll 系统调用,阻塞在 select 操作上,这样 select/poll 可以帮我们侦测多个 fd 是否处于就绪状态。

select/poll 是顺序扫描 fd 是否就绪,而且支持的 fd 数量有限,因此它的使用收到了一些制约。Linux 还提供了一个 epoll 系统调用,epoll 使用基于事件驱动方式代替顺序扫描,因此性能更高。

当有 fd 就绪时,立即回调函数 callback

  1. 信号驱动 I/O 模型

首先开启套接口信号驱动 I/O 功能,并通过系统调用 sigaction 执行一个信号处理函数(此系统调用立即返回,进程继续工作,它是非阻塞的)。当数据准备就绪时,就为该进程生成一个 SIGIO 信号,通过信号回调通知应用程序调用 recvfrom 来读取数据,并通知主循环函数处理数据。

  1. 异步 I/O 模型

告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核复制到用户自己的缓冲区)通知我们。

这种模型与信号驱动模型的主要区别是:信号驱动 I/O 由内核通知我们何时可以开始一个 I/O 操作;异步 I/O 模型由内核通知我们一个 I/O 操作何时已完成。

4、I/O 多路复用技术

在 I/O 编程过程中,如果需要同时处理多个客户端接入请求时,可以利用多线程或者I/O多路复用技术进行处理。

I/O 多路复用技术通过把多个 I/O 的阻塞复用到同一个 select 的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端的请求。

I/O 多路复用技术的优点

系统开销小,系统不需要创建新的额外进程或线程,不需要维护这些进程和线程,降低了系统的维护工作量,节省了系统资源。

使用场景

  • 服务器需要同时处理多个处于监听状态或者多个连接状态的套接字
  • 服务器需要同时处理多种网络协议的套接字

select、pselect、poll、epoll

epoll

epoll 使用一种称为事件驱动的方式来处理多个文件描述符

  1. 支持一个进程打开的 socket 描述符不受限制(仅受限于操作系统的做大文件句柄数,通过 cat /proc/sys/fs/file-max 查看最大句柄数)。
  2. I/O 效率不会随着 fd 数目的增加而线性下降。(selectpoll 都是线性轮询扫描 fd 集合,因此效率会随着 fd 数目增加而线性下降。epoll 是根据每个 fd 上面的 callback 函数实现的,只有活跃的 socket 才会主动调用 callback 函数,其他 idle 状态的 socket 则不会。)
  3. 使用 mmap 加速内核与用户空间的消息传递。
  4. epollAPI 更加简单。

5、Redis 客户端与服务端进行连接并发生命令的过程

  1. 客户端向服务端发送建立 socket 连接的请求,那么监听套接字将产生 AE_READABLE 事件,触发连接应答处理器执行。
  2. 处理器会对客户端的连接请求进行应答,然后创建客户端套接字,以及客户端状态,并将客户端套接字的 AE_READABLE 事件与命令请求处理器关联。
  3. 客户端建立连接后,向服务器发生命令,那么客户端套接字将产生 AE_READABLE 事件,触发命令请求处理器执行,处理器读取客户端命令,然后传递给相关程序去执行。
  4. 执行命令获得相应的命令回复,为了将命令回复传递给客户端,服务器将客户端套接字的 AE_WRITEABLE 事件与命令回复处理器关联。当客户端试图读取命令回复时,客户端套接字产生 AE_WRITEABLE 事件,触发命令回复处理器将命令回复全部写入套接字中。

redis单线程模型的大致工作流程及原理

6、Reactor 模式

在基于 epoll 的编程中,和传统的函数调用思路不同的是,我们并不能主动调用某个 API 来处理。 因为无法知道我们想要处理的事件啥时候发生。所以只好提前把想要处理的事件的处理函数注册到一个事 件分发器上去。当事件发生的时候,由这个事件分发器调用回调函数进行处理。这类基于实现注册事件分 发器的开发模式也叫 Reactor 模型。

7、epoll IO 多路复用技术

图解 | 深入揭秘 epoll 是如何实现 IO 多路复用的!

复用指的是对进程的复用

int main(){
listen(lfd, ...);
// accept 创建新 socket
cfd1 = accept(...);
cfd2 = accept(...);
// 创建一个 epoll 对象
efd = epoll_create(...);
// 向 epoll 对象中添加要管理的连接
epoll_ctl(efd, EPOLL_CTL_ADD, cfd1, ...);
epoll_ctl(efd, EPOLL_CTL_ADD, cfd2, ...);
// 等待其管理的连接上的 IO 事件
epoll_wait(efd, ...)
}

1、accept 创建新的 socket

  1. 初始化 struct socket 对象
  2. 为新 socket 对象申请 file
  3. 接收连接
  4. 添加新文件到当前进程的打开文件列表中

2、epoll_create 实现

在用户进程调用 epoll_create 时,内核会创建一个 struct eventpoll 的内核对象。

3、epoll_ctl 添加 socket

8、Redis 的过期删除策略

Redis 中的过期键如何删除

Redis 过期删除策略和内存淘汰策略有什么区别?

首先,Redis 键的过期时间存放在 redisDb 对象的 kvstore *expires; 属性中。 expires 的键就是数据库的键(redisObject 对象),值是该键的过期时间(long long 类型)。

Redis 的键过期之后,有三种删除策略

  1. 定时删除:设置键的过期时间的时候,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作。(内存友好,CPU不友好。)
  2. 惰性删除:放任键的过期时间不管,在每次获取键的时候,都判断一下,键是否过期,如果过期就删除该键。没过期就返回该键的值。(CPU友好,内存不友好)
  3. 定期删除:每隔一段时间程序就对数据库进行一次检查,删除里面的键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。

Redis 是通过搭配使用惰性删除和定期删除两种策略来实现键的过期删除。

// TODO 惰性删除的代码实现

// TODO 定期删除的代码实现

9、AOF、RDB 和复制功能对过期键的处理

Redis 提供了两种数据持久化的方式。 RDB(类似快照的方式) 和 AOF(日志追加的方式)。

RDB

RDB 快照持久化:通过快照的方式,在指定的时间间隔内将内存中的数据集快照写入磁盘。 RDBRedis 默认的持久化方式。

RDB 文件

RDB 文件是一个经过压缩的二进制文件,通过该文件可以还原生成 RDB 文件时的数据库状态。(序列化与反序列化)。

设置快照的文件名和所在文件夹

# 设置快照的文件名
dbfilename dump.rdb
# 设置快照文件的文件夹
dir ./

触发快照的时机

  • 执行 savebgsave 命令。
  • 配置文件设置 save <seconds> <changes> 规则,自动间隔性执行 bgsave 命令。 save 900 1 900 秒内 1 个键的值发生变化时就自动触发 bgsave 命令。
  • 主从复制时,从库全量复制同步主库数据,主库会执行 bgsave
  • 执行 flushall 命令清空服务器数据。
  • 执行 shutdown 命令关闭 Redis 时,会执行 save 命令。

save 和 bhsave 命令的区别

save 会阻塞 Redis 服务器进程。 bgsavefork 一个子进程,这个子进程负责创建 RDB 文件,不会阻塞 Redis 服务器进程。

save 命令执行时,客户端所有的命令请求都会被拒绝。

可以通过 save 命令设置多个持久化策略(用户可以根据实际情况来平衡数据安全性和性能。)。

根据 RDB 文件恢复数据

已经过期的键不会被报错到新创建的 RDB 文件中

根据 RDB 文件恢复数据时,主库和从库的操作有些不一样。
主服务器,在加载 RDB 文件时,会判断键是否已过期,未过期的键才会被加载到数据库。
从服务器,在加载 RDB 文件时,无论过期与否,都会加载到数据库中。

RDB 文件的载入工作是在服务器启动时自动执行的。只要 Redis 服务器在启动时检测到 RDB 文件存在,它就会自动载入 RDB 文件。

  • 如果服务器开启了 AOF 持久化功能,那么服务器会优先使用 AOF 文件来还原数据库状态。
  • 只有 AOF 关闭,才会使用 RDB 文件来还原数据库状态。

AOF 持久化(Append Only File)

AOF 持久化会把被执行的写命令写到 AOF 文件的末尾,记录数据的变化。默认情况下,Redis 时没有开启 AOF 持久化的,开启后,每执行一条更改 Redis 数据的命令,都会把该命令追加到 AOF 文件中,这会降低 Redis 的性能。

AOF 的步骤:命令追加 append、文件写入 write 、文件同步 sync

命令追加

每执行一个写命令,都会把该命令以协议格式先追加到 aof_buf 缓冲区的末尾。

文件写入和文件同步

何时将 aof_buf 缓冲区的内容写入 AOF 文件中,Redis 有多种策略。

  • appendfsync always:每个写命令时同步
  • appendfsync everysec:每秒同步一次,该操作由一个线程专门负责。
  • appendfsync no:由操作系统决定何时写入。

AOF文件重写

随着时间的推移,AOF 文件会越来越大,过大的 AOF 文件可能会对 Redis 服务器造成影响。

AOF 重写的目的就是减小 AOF 文件的体积。AOF 文件重写并不需要对现有的 AOF 文件进行任何读取、分析和写入操作,而是通过读取服务器当前的数据库状态实现的

# 手动触发 aof 文件重写
bgrewriteaof
# 表示当 AOF 文件的体积大于 64MB,且 AOF 文件的体积比上一次重写后的体积
# 大了一倍(100%)时会执行 bgrewriteaof 命令
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

AOF模式

  • 主库删除一个键后,会向从库发送一个 DEL 命令,通知从库删除这个键。
  • 从库在执行读命令时,即使键过期了也不会删除该键。
  • 从库只有接到主库发送的 DEL 命令之后,才会删除过期键。

为什么从库不主动删除过期键呢?

为了保证主从数据的一致性

10、Redis 中的伪客户端的作用

  1. 载入 AOF 文件并还原数据库状态。
  2. 执行 Lua 脚本中包含的 Redis 命令。

伪客户端的 fd 属性的值为 -1

处理 AOF 文件命令的伪客户端和执行 Lua 命令的伪客户端不是同一个对象

  • 处理 Lua 脚本的伪客户端在 Redis 启动的时候创建,在 Redis 关闭的时候关闭。
  • 处理 AOF 文件命令的伪客户端在载入工作开始时创建,载入工作完毕之后关闭。(AOF 文件重写、AOF 文件加载,这两种情况下会创建伪客户端)。

11、Lua 脚本的常见用途

  1. 原子操作:Redis 单个命令已是原子操作,但若需要执行一些列命令且保持为一个原子单元就需要 Lua 脚本,Lua 脚本执行期间 Redis 会阻塞任何其他命令的执行。
  2. 减少网络开销:执行一系列的命令,通常情况下需要为每个命令发送一个请求。如果使用 Lua 脚本可以在一个请求中执行所有的命令,大大减少了网络开销。
  3. 复杂逻辑实现:有些复杂的操作需要多个命令才能完成。

注意:由于执行 Lua 脚本时会阻塞其他命令,如果脚本执行时间过长,可能会导致 Redis 响应变慢,需要权衡。

Redis 的数据结构

Redis 提供的数据类型

String 字符串

  • SDS 存储字符串
  • int 整数

Hash 哈希 理解为键值对

  • ziplist 压缩列表
  • hashtable 哈希表

List 列表 有序字符串列表

  • ziplist 压缩列表
  • linked list 双向链表

Set 集合 不重复元素的字符串集合

  • intset 整数集合
  • hashtable 哈希表

Zset 有序集合 sorted set

  • ziplist 压缩列表
  • skiplist 跳跃表

BitMap 位存储

Bitmap 是字符串 stirng 的一种特殊应用。

每个字节都被视为连续的 8 位,可以单独的设置或查询每一位的值。

  • SDS

Stream

用于处理实时消息队列的场景。

流是一种类似于日志的数据结构,允许多个生产者向流中添加消息,同时允许多个消费者从流中读取消息。

流中的每条消息都是一个带有唯一 id 的键值对集合。

  • ListPack 列表包 (Redis 特别设计的紧凑、高效的二进制格式)
  • 基于 Radix Tree 的索引

GEO 地理位置

存储地理位置信息并进行查询的功能。

  • zset 有序集合
  • Geoghash 算法

HyperLogLog 基数统计

Redis HyperLogLog(基数统计)是一种基于概率统计的数据结构, 用于估计大型数据集合的基数(不重复元素的数量),以及对多个集合 进行并、交运算等。HyperLogLog的优点是可以使用极少的内存空间, 同时可以保证较高的准确性。

简单动态字符串 SDS

Simple Dynamic String

redisDb

server.h/redisDb

redis 读写键空间时的维护操作

  • 读写键后,服务器会根据读的结果来更新服务器的键空间命中次数 hit 或不命中次数 miss。
info stats

  • 读取一个键后,会更新键的 LRU ,这个值可以用于计算键的空闲时间. (server.h/redisObject lru:LRU_BITS)
OBJECT idletime book

  • 在读一个键的时候,如果发现该键已过期,那么服务器会先删除这个过期键,然后才执行余下的其他操作。 惰性删除

  • 对被 watch 命令监视的键修改,会将这个键标记为脏 dirty ,从而让事务程序注意到这个键已经被修改过。

  • 服务器每修改一个键,都会对脏 dirty 键计数器的值加一,这个计数器会触发服务器的持久化以及复制操作。

  • 数据库通知功能

redisObject

Redis 对象。

键的生存时间

设置键的生存时间的方法

expire.c/expireGenericCommand

过期字典

  • 过期字典的键是一个指针,这个指针指向键空间的某个键对象(也即是某个数据库键)键空间和过期字典的键都指向同一个对象。
  • 过期字典的值是一个 long long 类型的整数(64位) ,存储数据库键的过期时间, timestamp。

移除键的过期时间的方法

expire.c/persistCommand

db.c/removeExpire

计算并返回键的剩余生存时间

expire.c/ttlGenericCommand

Redis 命令

切换数据库

SELECT 2

注意:Redis 没有命令可以获取到当前所在的数据库是哪个,因此在执行 Redis 命令特别是 FLUSHDB 时,最好执行 SELECT 命令,显式的切换到指定的数据库,然后再执行别的命令。

设置键的生存时间和过期时间

set msg "hello"

# 设置 msg 的生存时间为 56 秒
expire msg 56

# 设置 msg 的生存时间为 12345 毫秒
pexpire msg 12345

# 设置 msg 的生存时间为 56 秒
expire msg 56

# 设置 msg 的过期时间设置为 timestamp 在指定的秒数时间
expireat msg <timestamp>

# 设置 msg 的过期时间设置为 timestamp 在指定的毫秒数时间
pexpireat msg <timestamp>

移除过期时间

persist book

计算并返回剩余生存时间

ttl msg   // 返回秒数
pttl msg // 返回毫秒数
# RDb
save
bgsave

# AOF
bgrewriteaof

redis 的缓存策略

Read/Write Through 模式(读穿/写穿)

读数据

先去缓存中找,没有命中就去数据库(redis)中查找,得到结果之后先把数据写到缓存中,再返回。

写数据

先把数据更新到数据库中(redis),再去更新缓存中的数据。

问题(可能会产生脏数据)

在高并发的时候,可能会产生脏数据。例如,在一个读数据的时候,缓存没有命中,就回去数据库中读取数据,并把数据更新到缓存中。在把读数据的进程把数据更新到缓存之前,写数据进程先一步更新了数据和缓存。这就会导致读数据进程的旧数据把写数据的进程产生的新数据给覆盖了。

Cache Aside 模式(旁路缓存)

处理读请求与 Read/Write Through 模式一样。

区别是 Cache Aside 模式更新数据的时候,不会更新缓存,而是删除缓存。

问题(可能会造成缓存穿透引起雪崩)

1、缓存雪崩

什么是缓存雪崩

某一时刻出现大规模的缓存失效,这个时候就会有大量的请求直接打在数据库上,导致数据库宕机,DBA 重启数据库之后,会有新的请求把数据库打死。

什么时候会出现大规模的 key 失效?
  • redis 宕机

    搭建 redis 集群,提高 redis 的容灾性。

  • 采用相同的过期时间

    在原有的失效时间加上一个随机值。

发生了缓存雪崩之后的兜底措施

  1. 使用熔断机制。流量超过阈值时,返回“系统拥挤”的提示。

  2. 提高数据库的容灾能力,使用分表分库,读写分离的策略。

2、缓存击穿

什么是缓存击穿?

大并发请求某一特定的 key(热点 key ),突然这个 key 失效了,那么这些并发会打在数据库上,导致数据库压力剧增。

解决方案
  • 热点 key 不设置过期时间。

  • 使用互斥锁。只有拿到锁才可以查询数据库,降低同一时间打在数据库上的请求,防止数据库宕机。 缺点是会导致系统的性能变差。

3、缓存穿透

什么是缓存穿透?

高并发请求一个 redis 中不存在的 key 值。

解决方案
  1. 把无效 key 存进 redis 中,即 key = null(不推荐)

  2. 布隆过滤器。先通过布隆过滤器过滤一下不存在的 key 值。