一、Redis的作用

  1. Redis最常用来做缓存,是实现分布式缓存的首先中间件;
  2. Redis可以作为数据库,实现诸如点赞、关注、排行等对性能要求极高的互联网需求;
  3. edis可以作为计算工具,能用很小的代价,统计诸如PV/UV、用户在线天数等数据;
  4. Redis还有很多其他的使用场景,例如:可以实现分布式锁,可以作为消息队列使用。

二、Redis和传统关系型数据库的区别

  • Redis是一种基于键值对的NoSQL数据库,而键值对的值是由多种数据结构和算法组成的。Redis的数据都存储于内存中,因此它的速度惊人,读写性能可达10万/秒,远超关系型数据库。
  • 关系型数据库是基于二维数据表来存储数据的,它的数据格式更为严谨,并支持关系查询。关系型数据库的数据存储于磁盘上,可以存放海量的数据,但性能远不如Redis。

三、Redis有哪些数据类型

1. Redis支持5种核心的数据类型,分别是字符串、哈希、列表、集合、有序集合;

下面详细解说这个五个数据类型

  • String
    它是Redis最基础的数据类型,几乎处处都有字符串
    图片说明
    String可以存储二进制数据,那么可不可用它来存图片和视频呢?这就要根据你存的东西要占多大的空间了,因为String最大只有512M,如果都拿来存视频,显然不合理,是一种空间浪费。
  • list
    图片说明
    所以Redis不止可以用来作为缓存,也可以用来当做消息队列,5.2版本以后的streams更为专业。
  • hash
    图片说明
  • set
    图片说明
    set这里支持求交集、并集、差集的操作非常有用,在实际应用中经常用在找共同好友,例如微博、豆瓣。在这里就体现了Redis不仅仅只是作为缓存,他更是一个数据库。
  • zset
    图片说明
    这里zset是基于set实现的,功能与set相近,他的有序是给每个元素提供一个分数依据实现的。
  • 总结一下,String适合做缓存,set适合做共同关注,zset(利用权值)和list(有序插入)适合做有序排列。String最大513M,其他统称集合最大能存2^32-1个元素。*

再来说说他们的底层实现
每一个数据类型底层都有多种实现,每一个实现我们称之为编码,每一种编码对应一种实现方案。为什么需要多种实现方案,因为Redis是基于内存实现的,不像硬盘,他内存不大,资源是稀缺。所以在设计的时候要尽可能的节约资源,同时考虑性能。但是在很多情况下,时间和空间并不能两全其美,所以有时候我们需要牺牲一点空间来换取时间,又有时候我们需要牺牲一点时间来换取空间。所以这些实现方案就对应了不同的场景。(当数据量小的时候,尽可能提高性能;在数据量大的时候尽可能压缩空间)

  • String
    图片说明
    SDS是动态结构
  • list
    图片说明
    数量较少,长度较短的时候用ziplist;数量较多,长度较长的时候用linkedlist。quicklist是两者结合。
    list结构的相关操作:
    1)lpush/rpush:从列表的左侧/右侧添加数据;
    2)lpop/rpop:从列表的左侧/右侧弹出一个数据;
    3)blpop/brpop:从列表的左侧/右侧弹出一个数据,若列表为空则进入阻塞状态。
    4)lrange:指定索引范围,并返回这个范围内的数据;
    5)lindex:返回指定索引处的数据。
  • hash
    图片说明
    还是一样数量较少,长度较短时用ziplist。不符合条件时用hashtable,他的底层实现是字典。
  • set
    图片说明
  • zset
    图片说明
    因为zset要有序,所以它就不能用hashtable,要用字典+跳表。那么既然要有序的话,为什么不用红黑树呢?因为在速度上跳表和红黑树几乎是一样的,而在实现上,跳表要比红黑树简单许多,所以这里用跳表而不用红黑树。(跳表的实现逻辑是建多级索引)实际上skiplist有两个数据类型,一个是字典,一个是跳表,当你要有序取值时,那就走跳表;当你要用key去取值时,那就走字典。这里内存空间虽然耗了点,但是性能好(主要解决性能问题)。

2. Redis还提供了Bitmap、HyperLogLog、Geo类型,但这些类型都是基于上述核心数据类型实现的;

3. Redis在5.0新增加了Streams数据类型,它是一个功能强大的、支持多播的、可持久化的消息队列。

四、Redis是单线程,速度之快的原因

  • 对服务端程序来说,线程切换和锁通常是性能杀手,而单线程避免了线程切换和竞争所产生的消耗;
  • Redis的大部分操作是在内存上完成的,这是它实现高性能的一个重要原因;
  • Redis采用了IO多路复用机制,使其在网络IO操作中能并发处理大量的客户端请求,实现高吞吐率。

下面讲一下IO多路复用机制:
图片说明
服务器处理与客户端的相应是基于套接字socket实现的,Redis通过一个单线程来监测socket,但不是每个状态都监测,像在客户端与服务器连接时、关闭时,客服端像服务器写入数据或者读取数据时,Redis就通过IO多路复用程序去监测,因为他是单线程,那么如何去处理如此多的相应,这里就需要一个有序队列了,IO多路复用程序会将这些相应放在一个套接字队列里,再交由文件事件分派器去处理,文件分派器会根据套接字相应的类型选择适合的事件处理器去处理,这里共有三种事件处理器分别是:命令请求处理器、命令回复处理器和连接应答处理器。
IO多路复用程序的实现其实依靠的是操作系统底层多路复用机制,不同的操作系统会有不同的底层实现,Redis会根据实际情况选择合适的多路复用程序,例如select、epoll、evport和kqueue,并且这些机制,Redis都是支持的。

五、Redis在持久化时fork出一个子进程,这时已经有两个进程了,怎么能说是单线程呢?

Redis是单线程的,主要是指Redis的网络IO和键值对读写是由一个线程来完成的。而Redis的其他功能,如持久化、异步删除、集群数据同步等,则是依赖其他线程来执行的。所以,说Redis是单线程的只是一种习惯的说法,事实上它的底层不是单线程的。

六、set和zset的区别

set

  • 集合中的元素是无序、不可重复的,一个集合最多能存储232-1个元素;
  • 集合除了支持对元素的增删改查之外,还支持对多个集合取交集、并集、差集。

zset

  • 有序集合保留了集合元素不能重复的特点;
  • 有序集合会给每个元素设置一个分数,并以此作为排序的依据;
  • 有序集合不能包含相同的元素,但是不同元素的分数可以相同。

七、Redis中的watch命令

watch其实是一种乐观锁机制。很多时候,要确保事物没有被其他客户端修改才能执行该事物,Redis提供watch命令来解决这个问题,客户端可以使用watch命令要求服务器端对一个或者多个key进行监视,如果在客户端执行事物之前这些key值发生了变化,那么就拒绝执行客户端提交的事物,并向它返回一个空值。

八、如何设计Redis的过期时间

  • 热点数据不设置过期时间,使其达到“物理”上的永不过期,可以避免缓存击穿问题;
  • 在设置过期时间时,可以附加一个随机数,避免大量的key同时过期,导致缓存雪崩。

九、setnx

  • setnx命令返回整数值,当返回1时表示设置值成果,当返回0时表示设置值失败(key已存在)。
    一般我们不建议直接使用setnx命令来实现分布式锁,因为为了避免出现死锁,我们要给锁设置一个自动过期时间。而setnx命令和设置过期时间的命令不是原子的,可能加锁成果而设置过期时间失败,依然存在死锁的隐患。对于这种情况,Redis改进了set命令,给它增加了nx选项,启用该选项时set命令的效果就会setnx一样了。
    采用Redis实现分布式锁,就是在Redis里存一份代表锁的数据,通常用字符串即可。采用改进后的setnx命令(即set...nx...命令)实现分布式锁的思路,以及优化的过程如下:

加锁:

第一版,这种方式的缺点是容易产生死锁,因为客户端有可能忘记解锁,或者解锁失败。

setnx key value

第二版,给锁增加了过期时间,避免出现死锁。但这两个命令不是原子的,第二步可能会失败,依然无法避免死锁问题。

setnx key value
expire key seconds

第三版,通过“set...nx...”命令,将加锁、过期命令编排到一起,它们是原子操作了,可以避免死锁。

set key value nx ex seconds 

解锁:
解锁就是删除代表锁的那份数据。

del key

问题:

看起来已经很完美了,但实际上还有隐患,如下图。进程A在任务没有执行完毕时,锁已经到期被释放了。等进程A的任务执行结束后,它依然会尝试释放锁,因为它的代码逻辑就是任务结束后释放锁。但是,它的锁早已自动释放过了,它此时释放的可能是其他线程的锁。
图片说明
想要解决这个问题,我们需要解决两件事情:

  • 在加锁时就要给锁设置一个标识,进程要记住这个标识。当进程解锁的时候,要进行判断,是自己持有的锁才能释放,否则不能释放。可以为key赋一个随机值,来充当进程的标识。
  • 解锁时要先判断、再释放,这两步需要保证原子性,否则第二步失败的话,就会出现死锁。而获取和删除命令不是原子的,这就需要采用Lua脚本,通过Lua脚本将两个命令编排在一起,而整个Lua脚本的执行是原子的。
    按照以上思路,优化后的命令如下:
# 加锁
set key random-value nx ex seconds 

# 解锁
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

十、Redis的持久化机制

Redis支持RDB持久化、AOF持久化、RDB-AOF混合持久化这三种持久化方式,如图:
图片说明

RDB(Redis DataBase)方式:是Redis默认采用的持久化方式

图片说明
如上图所示,RDB持久化是通过BGSAVE命令来触发的,那么执行BGSAVE命令,其实就是让父进程去fork一个子进程。在没有子进程的时候父进程去fork一个子进程,如果有子进程则返回给BGSAVE已经存在一个子进程了,没必要在fork一个子进程去持久化。这里要注意一个问题,当父进程在执行fork操作时,会有一个阻塞,这时候客户端无论发来什么请求,父进程都无法响应,但是还好这个时间非常短暂,还是能够接受的。当父进程已经fork完一个子进程后,父进程接触阻塞,去响应其他命令;而子进程去执行持久化操作,当然父进程和子进程的操作是可以并发的,同时进行的。
下面仔细说下子进程的持久化操作,所谓持久化就是读取内存中的数据将其存入磁盘中。这里的方式是子进程读取快照生成RDB文件(经过压缩的二进制文件,文件以".rdb"结尾),所谓快照就相当于给那一刻内存的数据拍了张照,这里的数据不能被修改,存的是那一时刻的数据。这里又有一个问题,如果子进程和父进程同时对那一块内存进行操作怎么办,不就会阻塞吗?要解决这个问题就要依赖操作系统的一个底层实现:CopyOnWrite写时复制机制。
页是内存的最小单位,我们可以把快照想成一个个页,我们称之为page,如果父进程和子进程同时读取红色page,这是允许的;但是如果子进程在读取红色page的时候,父进程要对其进行修改呢?根据CopyOnWrite机制,每当父进程要修改page时,就会生成当前这个page的副本,父进程在这个副本上进行修改,那么这时候父进程你改你的,子进程我读我的,谁也不妨碍谁。
最后,当子进程完成读取生成RDB文件,会通知父进程替换旧文件。
总结RDB过程
图片说明

  • 若父进程存在正在执行的子进程,直接返回
  • fork操作执行过程中,父进程进入阻塞;
  • fork操作结束后,父进程继续相应其他命令;
  • 子进程创建“.rdb”文件,存储父进程内存汇总的数据;
  • 父进程得到通知,以新文件替换旧的“.rdb”文件。
    图片说明
  • RDB持久化优缺点如下:*
  • 优点:RDB生成紧凑压缩的二进制文件,体积小,使用该文件恢复数据的速度非常快;
  • 缺点:BGSAVE每次运行都要执行fork操作创建子进程,属于重量级操作,不宜频繁执行,所以RDB持久化没办法做到实时的持久化。

    AOF(Append Only File)方式:是目前Redis持久化的主流方式

    图片说明
    我们以这张图来讲解AOF持久化,我们先看左边白底的过程。不同于RDB方式存的是二进制文件,AOF存的是命令;其次,RDB执行的BGSAVE命令容易引起阻塞,影响性能,所以不适合实时执行,比较适合一段时间来一次备份,比如说每5个几小时备份一次,所以它就就不具备这样的实时性。而AOF方式是具有实时性的。当执行增删改操作时,这些命令首先会被写入一个缓冲区aof_buf中,然后再同步到AOF文件中,那么同步是多久同步一次呢?每5秒刷一次盘,还是等操作系统判断缓冲区满了在刷盘?
  • 为了消除上述机制的不确定性,Redis向用户提供了appendfsync选项,来控制系统冲洗AOF的频率;
    图片说明
    为了避免丢失数据并且提供较好的实时性,这里采取每1秒刷盘一次,将缓冲区的数据写入AOF文件,1秒可能对于我们来说很快,但对于系统来说并不快。最后当每次重启服务器时,就会加载AOF文件。
    这里有个问题就是,这个AOF文件会有很多冗余,举个例子:
    set name 1
    set name 2
    set name 3
    这里其实只要记录最后一条命令就可以,没必要记录前面两条。所以为了解决冗余问题,就引入了AOF重写机制。
    我们看右边黄底的过程,这个过程类似BGSAVE命令,AOF重写通过bgrewriteaof命令触发,如果AOF正在重写则返回,如果正在执行BGSAVE命令则推迟重写。然后父进程fork一个子进程,当然fork的时候会引起阻塞,fork后父进程继续相应其他命令。这时候父进程和子进程共享一份内存,就是快照子进程将快照数据写入新的AOF文件中,但是这里有个问题:在子进程写入数据的同时,父进程修改了缓冲区里的数据,那么这时候新的AOF替换旧的AOF文件就会有数据缺失,所以为了解决这个问题,父进程会创建一个重写缓冲区rewrite_buf在里面修改数据,同时将数据同步到新的AOF文件中,最后子进程通知父进程,替换旧的AOF文件(新的AOF文件就是压缩版的文件)。
    也许有的人会问,那如果在执行5,6,7,8的时候断电了怎么办,答案是没关系,因为AOF重写过程并不是必需的,你可以执行也可以不执行,只不过你不执行的话AOF文件就会很大,显得很冗余,但是因为实时写入,他里面的命令是全的,完整的,服务器同样可以根据这个冗余的AOF文件完成持久化操作。
  • 简单总结一下
    AOF以独立日志的方式,记录了每次写入命令,重启时再重新执行AOF文件中的命令来恢复数据。AOF的工作流程包括:命令写入(append)、文件同步(sync)、文件重写(rewrite)、重启加载(load),如下图:
    图片说明
  • AOF默认不开启,需要修改配置项来启用它:
    appendonly yes                           # 启用AOF
    appendfilename "appendonly.aof"      # 设置文件名
  • AOF以文本协议格式写入命令,如:
    *3\r\n$3\r\nset\r\n$5\r\nhello\r\n$5\r\nworld\r\n
  • 文本协议格式具有如下的优点:
  • 文本协议具有很好的兼容性;
  • 直接采用文本协议格式,可以避免二次处理的开销;
  • 文本协议具有可读性,方便直接修改和处理。

AOF持久化的优缺点如下:

  • 优点:与RDB持久化可能丢失大量的数据相比,AOF持久化的安全性要高很多。通过使用everysec选项,用户可以将数据丢失的时间窗口限制在1秒之内。
  • 缺点:AOF文件存储的是协议文本,它的体积要比二进制格式的”.rdb”文件大很多。AOF需要通过执行AOF文件中的命令来恢复数据库,其恢复速度比RDB慢很多。AOF在进行重写时也需要创建子进程,在数据库体积较大时将占用大量资源,会导致服务器的短暂阻塞。

RDB-AOF混合持久化:

Redis从4.0开始引入RDB-AOF混合持久化模式,这种模式是基于AOF持久化构建而来的。用户可以通过配置文件中的“aof-use-rdb-preamble yes”配置项开启AOF混合持久化。Redis服务器在执行AOF重写操作时,会按照如下原则处理数据:

  • 像执行BGSAVE命令一样,根据数据库当前的状态生成相应的RDB数据,并将其写入AOF文件中;
  • 对于重写之后执行的Redis命令,则以协议文本的方式追加到AOF文件的末尾,即RDB数据之后。
  • 通过使用RDB-AOF混合持久化,用户可以同时获得RDB持久化和AOF持久化的优点,服务器既可以通过AOF文件包含的RDB数据来实现快速的数据恢复操作,又可以通过AOF文件包含的AOF数据来将丢失数据的时间窗口限制在1s之内。*