Redis知识总结

Redis知识总结

Redis简介

Redis是一个用C语言编写的,开源的高性能非关系型的键值对数据库。Redis可以存储键和五种不同类型的值之间的映射,所以读写速度非常快。因此Redis被广泛的应用在缓存方向,每秒可以处理超过10万次读写操作。此外,Redis也经常用来做分布式锁,并且其支持事务、持久化、LUA脚本、LRU驱动事件等。

  • 优点:

    • 读写性能优异,Redis的读速度是110000次/s,写的速度是81000次/s。
    • 支持数据持久化,支持AOF和RDB两种持久化方式。
    • 支持事务,Redis的所有操作都是原子性的,同时还支持对几个操作合并后的原子性执行。
    • 数据结构丰富:String、hash、set、zset、list等数据结构。
    • 支持主从复制,主机会将数据同步到主机,可以进行读写分离。
  • 缺点:

    • 数据库容量容易受到物理内存的限制,不能用作海量数据的高性能读写,因此Redis适合在较小数据量的高性能操作和运算上。
    • 不具备自动容错和恢复功能,主机从机的宕机都会导致前端部分读写请求失败,需要等待机器重启或者手动切换前端的IP才能恢复。
    • 主机宕机时,宕机前有部分数据未能及时同步到从机,切换IP后还会引入数据不一致的问题,降低了系统的可用性。
  • 为什么要用Redis?

    • 高性能:假如用户第一次访问数据库中的某些数据,这个过程会比较慢,因为是从硬盘上读取的。如果将该用户访问的数据存在缓存中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度非常的快。如果数据库中对应的数据改变了,同步改变缓存中相应的数据即可。
    • 高并发:直接操作缓存能够承受的请求是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。
  • Redis为什么这么快?

    • 完全运行在内存,绝大部分请求是纯粹的内存操作,非常快速。
    • 数据结构简单,对数据操作也简单。
    • 处理业务时采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗CPU,不必考虑各种锁的问题。
    • 采用了多路复用IO阻塞机制。

Redis数据类型详解

Redis的值主要是五种:String、List、Set、Hash、ZSet

Redis知识总结_第1张图片
数据类型 存储类型 操作 应用场景
String 字符串、整数或者浮点数 对整个字符串或者字符串的其中一部分进行操作;对整数和浮点数执行自增或者自减操作 做简单的键值对缓存,例如常用的信息、字符串、图片或者视频等存入;计数器;分布式session共享
List 列表 从两端压入或者弹出元素;对单个或者多个元素进行增删改;只保留一个范围内的元素 存储一些列表类型,例如粉丝列表、文章评论列表之类的数据
Set 无序集合 增删改单个元素;检查一个元素是否存在于集合中;计算交集、并集、差集;从集合里面获取元素 可以获取两个人的共同关注;点赞收藏;给用户添加标签
Hash 包含键值对的无序散列表 添加、获取、移除单个键值对;获取所有键值对;检查某个键是否存在 适合频繁修改的缓存
ZSet 有序集合 添加、获取、删除元素;根据分值范围或者成员来获取元素;计算一个键的排名 排行榜,例如发布排行榜,关注数排行榜,更新时间等

String

其中String是用C语言实现的,Redis自动封装了一个类库SDS用来操作:

  • len,buf中已经占有的字符串长度
  • free,buf中未使用的缓冲区长度
  • buf[],实际保存字符串的地方

因此获取字符串长度时间复杂度为O1,buf[]中采用了C语言的\0结尾,可以使用C语言的标准字符函数。一个字符串类型的值最大能存储512M。

分配原则:字符串长度小于1MB时,分配空间大小为其二倍;大于1MB时为其多分配1MB

特点:每次都多分配一些空间,这样就降低了分配次数,提高了追加速度;二进制安全;查询长度速度快、追加效率很高。

Hash

Hash 也可以用来存储用户信息,和 String 不同的是 Hash 可以对用户信息的每个字段单独存储,String 则需要序列化用户的所有字段后存储。并且 String 需要以整个字符串的形式获取用户,而 hash可以只获取部分数据,从而节约网络流量。不过 hash 内存占用要大于 String,这是 hash 的缺点。

Hash底层存储可以用ziplist(压缩链表)也可以使用hashMap,当hash对象同时满足下面两个条件时用ziplist:

  • hash对象保存的所有键值对的键和值的字符串长度都小于64字节。
  • hash对象保存的键值对数量小于512个。

其中Hash的数据结构类似与Java中的map,一旦冲突就直接拉链,没有红黑树。其底层的结构是:一个hash对象代表了一个dict实例,而一个dict中包含了两个dictht组成的哈希表数组和一个指向dicType的指针。定义两个dictht的作用主要是为了扩容的过程中,能够保证读取数据的一致性。每个dictht中都存在一个dicEntry变量,其是存放数据的主要容器(bucket桶)。每个dictEntry中除了包括key和value的键值对以外,还包括指向下一个dictEntry对象的指针。

该hashtable在扩容的时候是使用渐进式rehash策略:在扩容的时候rehash策略会保留新旧两个hashtable结构(前面说的hash内部包含了两个hashtable,其中h[1]的容量是h[0]的二倍),查询的时候也会同时查询两个hashtable。Redis会将旧的hashtable中的内容一点一点的迁移到新的hashtable中,当迁移完成的时候,就会用新的取代之前的。当hashtable移除了最后一个元素的时候,旧的数据结构就会被删除。

其中数据搬迁的操作放在 hash 的后续指令中,也就是来自客户端对 hash 的指令操作。一旦客户端后续没有指令操作这个hash。Redis就会使用定时任务对数据主动搬迁。正常情况下,当 hashtable 中元素的个数等于数组的长度时,就会开始扩容,扩容的新数组是原数组大小的 2 倍。如果 Redis 正在做 bgsave(持久化) 时,可能不会去扩容,因为要减少内存页的过多分离(Copy On Write)。但是如果 hashtable已经非常满了(一直拉链),元素的个数达到了数组长度的 5 倍时,Redis 会强制扩容。在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到 ht[1] 里面, 而 ht[0] 则不再进行任何添加操作: 这一措施保证了 ht[0] 包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表。

小结扩容时机:

  • 哈希表中保存的key数量超过了哈希表的大小。(可以看出size既是哈希表大小,同时也是扩容阈值)。即服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于 1
  • 当前没有子进程在执行AOF文件重写或者生成RDB文件;或者保存的节点数与哈希表大小的比例超过了安全阈值(默认值为5)即服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于 5

当哈希表的负载因子小于0.1时, 程序自动开始对哈希表执行收缩操作。

List

Redis3.2之前,List使用压缩列表和双向链表作为底层实现,当满足下列条件时使用ziplist,否则使用双向链表linkedlist:

  • 往列表里新添加一个字符串值,且这个字符串的长度小于64
  • 包含的结点小于512时

3.2之后,引入了quicklist

  • 双向链表的内存开销比较大,每个节点除了要保存数据还要额外保存两个指针。并且双向链表的各个结点都是单独的内存块,地址不连续,结点多了容易产生内存碎片。
  • ziplist存储在一段连续的内存上,存储效率较高,但是不利于修改操作,插入和删除操作需要频繁申请和释放内存。

quicklist是ziplist和linkedlist二者的结合,既有ziplist组成的双向链表,每个节点用ziplist保存数据。

Set

Set底层的存储结构是intset和hashtable两种数据结构存储。其中hashtable中的key就是set中的值,value统一为null。

其中使用intset必须满足两个条件,否则使用hashtable:

  • 集合对象保存的所有元素都是整数值
  • 集合对象保存的元素数量不超过512个

intset内部是一个数组(int8_t coentents[]数组),而且存储数据的时候是有序的,因为在查找数据的时候是通过二分查找来实现的。

set存储过程:

  • 检查set是否存在,不存在则创建一个set集合
  • 根据传入的set集合一个一个添加,添加的时候需要进行内存压缩
  • 在添加的时候,setTypeAdd会在set添加的过程中判断是否进行编码转换。
    • 如果本来就是hashtable编码,走正常的hashtable元素添加即可。如果原来是inset就得判断了。
    • 如果能够转成int对象,则使用intset保存。
    • 如果使用inset保存的时候,长度超过512就转为hashtable编码。
    • 其他情况同一用hashtable保存。

ZSet

其中,ZSet的实现是基于跳跃表skiplist实现的。

  • 跳跃表是一种随机化的数据结构,一个多层的有序链表,一种基于概率统计的插入算法。给一个有序链表,我们想要查询某值就得挨个遍历,那么我们可以每相邻两个结点增加一个指针,让指针指向下下个结点,以此类推。
  • 为什么不使用红黑树?hash?
    • 在做范围查找的时候,平衡树的操作要比跳跃表复杂。平衡树在查询到最小值的时候,还需要采用中序遍历去查询最大值。
    • 平衡树的删除和插入,需要对子树进行相应的调整,操作复杂,而跳表只需要修改相邻的结点即可。
    • 在做查询的时候,跳表和平衡树都是OlogN的时间复杂度。跳表的查询是自顶向下的。
    • hash只适合单值查询,不适合范围查询,且存储很有可能是无序的。用平衡树显得多此一举
  • 插入结点过程?
    • 新节点和各个索引结点逐一比较,确定原链表的插入位置,时间复杂度为OlogN。
    • 把索引插入到原链表,时间复杂度为O1。
    • 利用抛硬币的方式,决定新节点是否提升到上一级索引。结果为正则提升并且继续抛硬币,否则停止,时间复杂度OlogN
  • 删除结点过程?
    • 自上而下,查找第一次出现结点的索引,并逐层找到每一层对应的结点(每层索引都是由上层索引晋升的),时间复杂度是OlogN。
    • 删除每一层查找到的结点,如果该层只剩下一个结点,删除整个层,时间复杂度OlogN。

在这里插入图片描述

zset编码有ziplist和skiplist两种,底层对应压缩链表和跳表实现。当满足保存的元素小于128个且所有元素大小都小于64字节,使用ziplist,否则使用skiplist。跳跃表的空间复杂度是ON

ps:ziplist压缩列表

为什么要有压缩列表?

  • 普通的双向链表,会有两个指针,在存储数据很小的情况下,我们存储的实际数据的大小可能还没有指针占用的内存大,是不是有点得不偿失?而且Redis是基于内存的,而且是常驻内存的,内存是弥足珍贵的,所以Redis的开发者们肯定要使出浑身解数优化占用内存,于是,ziplist出现了。
  • 链表在内存中,一般是不连续的,遍历相对比较慢,而ziplist可以很好的解决这个问题。

ziplist的内存布局:

  • zlbytes:32bit无符号整数,表示ziplist占用的字节总数(包括本身占用的4个字节)
  • zltail:32bit无符号整数记录最后一个entry的偏移量,方便快速定位到最后一个entry
  • zllen:16bit无符号整数,记录entry的个数
  • entry:存储的若干个元素,可以为字节数或者整数
  • alend:ziplist的最后一个字节,是一个结束的标记为,固定值为255

关键的是entry:

  • prevlen:前一个元素的字节长度,便于快速的找到前一个元素的首地址,例如当前元素的首地址是x,那么(x-prevlen)就是前一个元素的首地址。
  • encoding:当前元素的编码。
  • entry-data:实际存储的数据。

ziplist是紧凑存储,没有冗余空间,所以不适合存储大型字符串,存储的元素也不宜过多。

Redis持久化机制

为什么要持久化?

Redis是内存数据库,为了保证效率所有的操作都是在内存中完成。数据都是缓存在内存中,如果重启或者关闭系统,之前缓存在内存的数据都会丢失且无法找回。

Redis是一个支持持久化的内存数据库,通过持久化机制把内存中的数据同步到硬盘文件来保证数据持久化。当Redis重启后通过把硬盘文件重新加载到内存,以达到恢复数据的目的。

实现方式:单独fork一个子进程,将当前父进程的数据库数据复制到子进程的内存中,然后由子进程写入到临时文件中,持久化的过程就结束了,再用这个临时文件来替换上一次的快照文件,然后子进程退出,内存释放。

  • 客户端向服务端发送写操作(数据在客户端中)
  • 服务端接收到写请求的数据(数据在服务端的内存中)
  • 服务端调用write这个系统调用,将数据往磁盘上写(数据在系统内存的缓冲区中)
  • 操作系统将缓冲区的数据转移到磁盘控制器上(数据在磁盘缓存中)
  • 磁盘控制器将数据写到磁盘的物理介质中(数据真正落到磁盘上)

RDB

**RDB持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘。**它也是Redis默认的持久化方式,这种方式是就是将内存中数据以快照的方式写入到二进制文件中,默认的文件名为dump.rdb。对于RDB来说,提供了三种机制:save、bgsave、自动化。

  • save:该命令是一个手动触发的机制,会阻塞当前Redis服务器,执行save命令期间,Redis不能处理其他命令,直到RDB过程完成为止。执行完成时如果存在老的RDB文件,就让新的替代掉旧的。

  • bgsave:执行该命令时,Redis会在后台异步进行快照操作,快照同时还可以响应客户端请求。具体操作是Redis进程fork一个子进程,RDB持久化过程由子进程负责,完成后自动结束。阻塞只发生在fork阶段,一般时间很短。基本上Redis内部所有的RDB操作都是采用bgsave命令。

    • 执行bgsave命令Redis父进程判断当前是否存在正在执行的子进程,如果RDB/AOF子进程存在,bgsave命令直接返回。
    • 父进程执行fork操作创建子进程,fork操作过程中父进程会阻塞。(通过info stats命令查看latest_fork_usec选项可以获取最近一个fork操作的耗时,单位为微秒)
    • 父进程fork完成后,bgsave命令返回"Background saving started"信息,并不再阻塞父进程,可以继续响应其他命令。
    • 子进程创建RDB文件,根据父进程内存生成临时快照文件,完成后对原有文件进行原子替换。(执行lastsave命令可以获取最后一次生成RDB的时间,对应info统计的rdb_last_save_time选项)。
    • 进程发送信号给父进程表示完成,父进程更新统计信息。
  • 自动化:由我们的配置文件来完成,在redis.conf配置文件中。

    • save:用来触发Redis的RDB持久化条件,也就是什么时候将内存中的数据保存到磁盘。例如 save m n,意思是m秒内数据集存在n次修改时,自动触发bgsave。
    • stop-writes-on-bgsave-error:默认值为yes,当启用了RDB且最后一次后台保存数据失败,Redis是否停止接收数据,
    • rdbcompression:默认值为yes,对于存储到磁盘中的快照,可以设置是否进行压缩存储。
    • rdbchecksum:默认yes,在存储快照后,我们还可以让redis使用CRC64算法来进行数据校验,但是这样做会增加大约10%的性能消耗,如果希望获取到最大的性能提升,可以关闭此功能。
    • dbfilename:设置快照的文件名,默认是dump.rdb。
    • dir:设置快照文件的存放路径。

    Redis知识总结_第2张图片

  • 优势:

    • RDB文件紧凑,全量备份,非常适合于进行备份和灾难恢复。
    • 使用bgsave,生成RDB文件的时候,redis主进程会fork()一个子进程来处理所有保存工作,主进程不需要进行任何磁盘IO操作。
    • RDB在恢复大数据集的速度比AOF的恢复速度要快。
  • 劣势:

    • 由于进行快照持久化的时候,bgsave是开启一个子进程专门负责的,这样如果父进程修改了内存子进程不会得知,所以在快照持久化期间修改的数据不会被保存,可能会导致数据丢失。
    • RDB要经常fork子进程来保存数据集到硬盘上,当数据集比较大的时候,fork是非常耗费时间的,可能会导致Redis在一些毫秒级别内不能响应客户端的请求。

AOF

**Redis会将每一个收到的写命令都通过Write函数追加到文件最后,类似于MySQL的binlog。**当Redis重启时会通过重新执行文件中保存的写命令来在内存中重建整个数据库的内容。ps:AOF默认不开启,当两种方式同时开启,Redis默认选择AOF。

  • 命令写入、文件同步、文件重写、重启加载
    • 所有写入命令会追加到aof_buf(缓冲区)中。
    • AOF缓冲区根据对应的策略向硬盘中的AOF文件做同步操作。
    • 随着AOF文件越来越大,需要定期的对AOF进行重写,以达到压缩的目的。
    • 当Redis服务器重启时,可以加载AOF文件进行数据恢复。
Redis知识总结_第3张图片

AOF的方式带来了一个问题:持久化文件会越来越大。为了压缩AOF的持久化文件,redis提供了bg rewrite aof命令来将内存中的数据以命令的方式保存到临时文件中,同时会fork出一条新进程来将文件重写。重写AOF文件的操作并没有读取旧的AOF文件,而是将整个内存中的数据库内容用命令方式重写了一个新的AOF文件,类似快照。

AOF的三种触发机制

  • 每修改同步always:同步持久化,每次发生数据变更会被立刻记录到磁盘,性能较差但数据完整性比较好。
  • 每秒同步everysec:异步操作,每秒记录,如果一秒内宕机就会有数据丢失。
  • 不同步no:从不同步

Redis知识总结_第4张图片

AOF优点:

  • AOF能够更好的保护数据不丢失,一般AOF采用everysec会每隔1秒通过一个后台线程执行一次fsync操作,最多丢失1秒的数据。
  • AOF日志文件是一个只进行追加的日志文件,没有任何的磁盘寻址开销,写入性能非常高,文件不容易破损。
  • AOF日志文件即使过大,出现后台重写操作,也不会影响客户端的读写。
  • AOF日志文件通过非常可读的方式进行记录,这个特性非常适合做灾难性的误删除的紧急恢复。如果不小心用flushall命令清空了所有数据,只要这时候后台rewrite还没有发生,那么就可以立刻拷贝AOF文件删除flushall命令,再将AOF文件放回去。

AOF缺点:

  • 对于同一份数据来说,AOF日志文件通常比RDB数据快照文件更大。

  • AOF开启后,支持写QPS会比RDB支持的写QPS低,因为一般AOF会配置成每秒fsync一次日志文件。

Redis的主从同步(复制)

主redis中的数据和从redis上的数据保持实时同步,当主redis写入数据时通过主从复制机制会复制到两个(多个)从redis服务上。

如果设置主从同步,则从服务器在连接的时候会发送SYNC命令(不管是第一次连接还是再次连接)。然后主服务器开始后台存储,并且开始缓存新连接进来的修改数据的命令。当后台存储完成后,主服务器把数据文件发送到从服务器,从服务器将其保存在磁盘上,然后加载到内存中。然后主服务器把刚才缓存的命令进行执行。

全量同步

Redis全量同步一般发生在从服务器初始化阶段,这时从服务器需要将主服务器上的所有数据都复制一份

  • 从服务器连接主服务器,发送SYNC命令;
  • 主服务器接收到SYNC命令后,开始执行BGSAVE命令生成RDB文件并使用缓冲区记录此后执行的所有写命令
  • 主服务器BGSAVE执行完后,向所有从服务器发送快照文件;
  • 从服务器收到快照文件后丢弃所有旧数据,载入收到的快照;
  • 主服务器快照发送完毕后开始向从服务器发送缓冲区中的写命令;
  • 从服务器完成对快照的载入,开始接收命令请求,并执行来自主服务器缓冲区的写命令。

全量同步的问题:

  • 如果多个从服务器断线了,需要重启,只要从服务器启动就会发送sync请求和主机进行全量同步,当多个从机出现的时候,可能会导致主服务器IO剧增(进行BGSAVE)而宕机。

增量同步

Redis增量同步是指从服务器初始化后开始正常工作时,主服务器发生的写操作同步到从服务器的过程。增量同步的过程主要是主服务器每执行一个写命令就会给从服务器发送相同的写命令,从服务器接收并执行收到的写命令。

主从同步策略

**主从刚刚连接的时候,进行全量同步;全量同步结束后进行增量同步。如果有需要,从服务器在任何时候都可以发起全量同步。**redis的策略是首先进性增量同步,如果不成功则要求从服务器进行全量同步。

部分同步

redis2.8以前不支持,部分同步是指即使主从连接中途断掉,从机重启的时候也不需要进行全量同步。部分同步的实现依赖于主服务器内存中维护了一个同步日志,并且给每个从服务器维护了一份同步标识,每个从服务器在跟主服务器进行同步时都会携带自己的同步标识和上次同步的最后位置。当主从连接断掉之后,从服务器隔断时间(默认1s)主动尝试和主服务器进行连接,如果从服务器携带的偏移量标识还在主服务器上的同步备份日志中,那么就从从服务器发送的偏移量开始继续上次的同步操作。如果主服务器的同步日志中没有(可能由于主从之间断掉的时间比较长或者在断掉的短暂时间内主服务器接收到大量的写操作),则必须进行一次全量更新。

与MySQL的差距

  • redis是主机将操作写在rdb文件里,然后从机拿到这个文件直接覆盖自己的数据,是从头开始复制,复制整个主服务器的数据;MySQL则是从接入点开始复制,当确认主从关系的时候才开始复制,复制的是确认关系之后的数据。
  • redis是主从关系搭建起来后,主机的写操作直接同步给从机;而MySQL则是主服务器写在二进制日志中,然后从机去读取这个日志并写入relay log中,读在从机中执行,MySQL一般是读写分离,主机做写操作,从机做读操作。

TODO:Redis哨兵模式

Redis 的主从复制模式下,一旦主节点由于故障不能提供服务,需要手动将从节点晋升为主节点,同时还要通知客户端更新主节点地址,这种故障处理方式从一定程度上是无法接受的。

  • 从Redis宕机:比较简单,可以通过上述的部分同步来恢复。
  • 主Redis宕机:比较复杂,首先从数据库中执行SLAVEOF NO ONE命令,断开主从关系并且提升为主库继续服务。然后将主库重新启动后,执行SLAVEOF命令,将其设置为其他库的从库,这时候数据就能更新回来。

Redis2.8后提供了Redis Sentinel哨兵机制来解决这个问题。Sentinel(哨兵)是Redis的高可用性解决方案:由一个或多个Sentinel 实例 组成的Sentinel 系统可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器。

Redis线程模型

redis内部使用文件事件处理器file event handler,这个文件事件处理器是单线程的,所以redis才叫做单线程的模型,它采用IO多路复用机制同时监听多个socket,根据socket上的事件来选择对应的时间处理器进行处理。Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。

为什么单线程效率也这么高?

  • 纯内存操作
  • 核心是基于非阻塞的IO多路复用机制
  • 单线程避免了多线程的频繁上下文切换的问题

Redis IO多路复用技术

Redis是泡在单线程中的,所有的操作都是按照顺序线性执行的,由于读写操作等待用户的输入或者输出都是阻塞的,所以IO操作在一般情况下往往不能直接返回,这会导致某一文件的IO阻塞而导致整个进无法对其他客户提供服务。IO多路复用就是为了解决这个问题而出现的。

Redis的IO模型主要是基于epoll实现的,不过它也提供了 select和kqueue的实现,默认采用epoll。epoll对比其他的IO多路复用,有如下优点:

  • epoll没有最大并发连接的限制,上限是最大可以打开文件的数目,这个数目和系统内存有关。
  • 效率很高,epoll只关注一个活跃的连接,而跟连接总数无关,因此在实际的网络环境中,epoll的效率就会远远高于select和poll。
  • epoll使用了共享内存,省略了内存拷贝。

epoll和select/poll的区别:

  • select、poll、epoll都是IO多路复用的机制,IO多路复用就是通过一种机制,可以监视多个描述符,一旦某个描述符就绪,能够通知程序进行相应的操作。
  • select的本质是采用32个整数的32位,即32x32=1024来标识,fd(与打开的文件建立联系)的值为1-1024。当fd的值超过1024的限制时,就必须修改FD_SETSIZE的大小。这时候就可以标识32*MAX值范围的fd。
  • poll与select不同,通过一个pollfd数组向内核传递需要关注的事件,故没有描述符个数的限制,pollfd中的events字段和revents分别用于标示关注的事件和发生的事件,故pollfd数组只需要被初始化一次。
  • epoll还是poll的一种优化,返回后不需要对所有的fd进行遍历,在内核中维持了fd的列表。select和poll是将这个内核列表维持在用户态,然后传递到内核中。与poll/select不同,epoll不再是一个单独的系统调用,而是由epoll_create/epoll_ctl/epoll_wait三个系统调用组成,后面将会看到这样做的好处。epoll在2.6以后的内核才支持。

select/poll的缺点:

  • 每次调用select/poll,都需要吧fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
  • 每次调用select/poll都需要再内核遍历传递来的所有fd,这个开销在fd很多时也很大。
  • 针对select支持的文件描述符数量太小了,默认1024。
  • select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件。
  • 相比select,poll使用链表保存文件描述符,因此没有了监视文件数量的限制,但是其他缺点依然存在。

Redis的过期策略和内存淘汰机制

设置键的时候都会给一个过期时间,redis对过期时间的key删除策略是定期删除+惰性删除

  • 定期删除:**redis默认每隔100ms就随机抽取一些设置了过期时间的key,检查其是否过期,如果过期就删除。**定期删除的问题就是很可能会导致许多key到了时间并没有被删除掉,所以就得靠惰性删除。
  • 惰性删除:在获取某个key的时候,redis会检查一下,如果设置了过期时间,判断是否过期,过期了就将其删除。

定期删除+惰性删除是有问题的:定期删除会遗留许多过期的key,然后你也没有请求这些key,那么会导致redis内存越来越高,此时将采取内存淘汰机制。

在redis.conf中有一行配置:maxmemory-policy xxx。该配置就是配置内存淘汰的策略,当内存不足以容纳新写入数据时,这些策略就派上用场了:

  • volatile-lru:从已设置过期时间的键空间中移除最近最少使用的key。
  • volatile-ttl:从已设置过期时间的键空间中挑选将要过期的key淘汰。(一般使用)
  • volatile-random:从已设置过期时间的键空间中随机选择key淘汰。
  • allkeys-lru:从键空间中移除最近最少使用的key。
  • allkeys-random:从键空间中随机选择key淘汰。
  • noenviction:新写入数据时直接报错。
  • allkeys-lfu:redis4.0后出现,从所有的key中选择最少使用频率的key淘汰。
  • volatile-lfu:redis4.0后出现,从设置过期的key中挑选最少使用频率的key淘汰。

Redis3.0后对lru进行了一些优化:新算法会维护一个候选池(大小为16),池中的数据根据访问时间进行了排序,第一次随机选取的key都会放入池中,随后每次随机选取的key只有在访问时间小于池中最小的时间才会放入池中,直到侯选池被放满,当放满后,如果有新的key需要放入,则将池中最后访问的时间最大(最近被访问)的移除。当需要淘汰的时候,则直接从池中取最近访问时间最小(最久没有被访问)的key淘汰。

LRU和LFU最大的区别就是,如果某个key很久没有被访问到,一旦被访问一次,LRU就认为它是热点数据不会被淘汰,而有些key则是将来很有可能被访问到却被淘汰了。LFU就没有这个问题。

Redis缓存和数据库一致性问题:不更新而是删除

先更新数据库,再更新缓存

  • 该方案主要就是缓存脏数据的问题:
    • 1.线程A更新了数据库;
    • 2.线程B更新了数据库并更新缓存;
    • 3.线程A更新缓存。
  • 理论上出现A更新缓存应该更早,但由于一些原因B比A更早更新,这就导致了缓存了脏数据。如果对数据库写操作比较多,采用这种方案就会导致有些数据还没有读到,缓存就被频繁更新。

先删除缓存,再更新数据库

  • 同时有一个请求A进行更新操作,另一个请求B进行查询操作,会导致缓存脏数据
    • 1.请求A删除缓存进行写操作;
    • 2.请求B去查询缓存发现不存在,于是去数据库中查,读到旧数据;
    • 3.请求A将新值写入数据库并更新缓存;
    • 4.请求B查出来的更新缓存操作,导致更新脏数据。
  • 解决方案:延时双删策略
    • 先删除缓存,然后再进行写数据库操作,然后另当前线程sleep1秒,再进行删除缓存。
    • 这样可以将1秒内所造成的缓存脏数据再次删除(这个时间具体由项目而定)
    • 当然也可以将第二次删除作为异步的,自己起一个线程进行异步删除。

推荐使用先更新数据库,再删除缓存

  • 同时有一个请求A进行更新操作,另一个请求B进行查询操作,该方案依然会会导致两个问题:
    • 查询脏数据:
      • 请求A更新数据库;
      • 请求B查询缓存,得到脏数据;
      • 请求A删除缓存;
    • 缓存脏数据(概率较小,因为数据库的读操作是比写操作快的多的):
      • 1.请求B查询数据库,得到旧值;
      • 2.请求A更新值写入数据库并删除缓存;
      • 3.请求B将查询到的旧值写入到缓存,导致缓存脏数据。
  • 解决方案:延时双删除。

Redis的事务

Redis是有事务的,它的事务就是一系列指令的结合:

  • 开启事务:使用MULTI开启一个事务。
  • 命令入队列:每次操作的命令都会加入到一个队列中,但命令此时不会真正被执行。
  • 提交事务:使用EXEC命令提交事务,开始顺序执行队列中的命令。

原子性问题

关系型数据库ACID中,对于原子性的定义是:一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在某个环节,事务在执行过程中发生错误,会被恢复到事务开始前的状态(回滚)

然而Redis的事务,定义如下:

  • 事务是一个单独的隔离操作:事务中所有命令都会序列化、按顺序执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
  • 事务是一个原子操作:事务中的命令,要么全部不执行,要么全部被执行。EXEC命令负责触发并执行事务中的所有命令:如果客户端在使用MULTI开启了一个事务之后,却因为断线而没有成功执行EXEC,那么事务中的所有命令都不会被执行;如果客户端成功在开启事务之后执行EXEC,那么事务中的所有命令都会被执行。

不难看出,官方对于Redis的原子性是站在执行与否的角度考虑的,严格来说Redis的事务是非原子性的,因为在命令顺序执行错误的时候,Redis是不会停止回滚的。

为什么不支持回滚?

在事务运行期间虽然Redis命令可能会执行失败,但是Redis依然会执行事务内剩余的命令而不会执行回滚操作。官方的解释如下:

只有当被调用的Redis命令有语法错误时,这条命令才会执行失败(在将这个命令放入事务队列期间,Redis能够发现此类问题),或者对某个键执行不符合其数据类型的操作:实际上,这就意味着只有程序错误才会导致Redis命令执行失败,这种错误很有可能在程序开发期间发现,一般很少在生产环境发现。支持事务回滚能力会导致设计复杂,这与Redis的初衷相违背,Redis的设计目标是功能简化及确保更快的运行速度。

事务相关命令?

  1. WATCH:可以为Redis事务提供 check-and-set (CAS)行为。被WATCH的键会被监视,并会发觉这些键是否被改动过了。如果有至少一个被监视的键在 EXEC 执行之前被修改了, 那么整个事务都会被取消, EXEC 返回nil-reply来表示事务已经失败。
  2. MULTI:用于开启一个事务,它总是返回OK。MULTI执行之后,客户端可以继续向服务器发送任意多条命令, 这些命令不会立即被执行,而是被放到一个队列中,当 EXEC命令被调用时, 所有队列中的命令才会被执行。
  3. UNWATCH:取消 WATCH 命令对所有 key 的监视,一般用于DISCARD和EXEC命令之前。如果在执行 WATCH 命令之后, EXEC 命令或 DISCARD 命令先被执行了的话,那么就不需要再执行 UNWATCH 了。因为 EXEC 命令会执行事务,因此 WATCH 命令的效果已经产生了;而 DISCARD 命令在取消事务的同时也会取消所有对 key 的监视,因此这两个命令执行之后,就没有必要执行 UNWATCH 了。
  4. DISCARD:当执行 DISCARD 命令时, 事务会被放弃, 事务队列会被清空,并且客户端会从事务状态中退出。
  5. EXEC:负责触发并执行事务中的所有命令:如果客户端成功开启事务后执行EXEC,那么事务中的所有命令都会被执行。如果客户端在使用MULTI开启了事务后,却因为断线而没有成功执行EXEC,那么事务中的所有命令都不会被执行。需要特别注意的是:即使事务中有某条/某些命令执行失败了,事务队列中的其他命令仍然会继续执行,Redis不会停止执行事务中的命令,而不会像我们通常使用的关系型数据库一样进行回滚。

Redis的三个框架

这三个框架都是在Java中对Redis操作的封装

Jedis

是Redis的Java实现客户端,其API提供了比较全面的Redis命令支持,支持基本的数据类型比如String、Hash、List、Set、ZSet(Sorted Set)

优点:比较全面的提供了Redis的操作特性,相比于其他的Redis封装框架更加原生。

编程模型:使用阻塞的IO,方法调用同步,程序流需要等到socket处理完IO才能执行,不支持异步操作。Jedis客户端的实例不是线程安全的,所以需要连接池来使用Jedis

Lettuce

高级的Redis客户端,用于线程安全同步,异步和响应使用,支持集群,管道和编码器等。

优点:适合分布式缓存框架。

编程模型:基于Netty框架的事件驱动的通信层,使用非阻塞IO其方法调用是异步的。Lettuce的API是线程安全的,所以可以操作单个Lettuce连接来完成各种操作。

Redisson

实现了分布式和可扩展的Java数据结构。Redisson不仅仅提供了一系列的分布式Java常用对象,基本可以与Java的基本数据结构通用,还提供了许多分布式服务。

优点:可以让使用者对Redis的关注分离,让使用者能够将精力更集中的放在处理业务逻辑上,提供了很多分布式操作服务,例如分布式锁,分布式集合,可以通过Redis支持延迟队列。

编程模型:基于Netty框架的事件驱动的通信层,使用非阻塞IO其方法调用是异步的。Lettuce的API是线程安全的,所以可以操作单个Lettuce连接来完成各种操作。

缓存雪崩、缓存穿透、缓存击穿

  • 缓存雪崩:由于原有缓存失效,新缓存未到期间(例如:我们设置缓存时采用了相同的过期时间,在同一时刻出现大面积的缓存过期),而导致后面原本应该访问缓存的请求都会落到持久层数据库上,造成持久层数据库短时间内承受大量请求而崩掉。

    • 解决方案:
      • 缓存数据的过期时间设置随机,防止同一时间大量数据过期的现象发生。
      • 一般并发量不是特别高的时候,考虑加锁或者队列的方式来保证不会有大量的线程对数据库一次性的读写,从而避免失效时的大量的并发请求落到底层存储系统上。
      • 给每个缓存数据增加相应的缓存标记来记录缓存是否已经失效,如果缓存标记失效则更新数据缓存。
  • 缓存穿透:用户查询一个数据库中不存在的某一key,查询Redis缓存发现并没有,即缓存没有命中,接着向持久层数据库查询,发现也没有,于是本次查询失败。当用户很多的时候,缓存都没有命中,于是请求都去了持久层数据库。这会给持久层数据库造成很大的压力,出现缓存穿透。

    • 注意与雪崩的区别:雪崩强调的是许多缓存失效,而穿透主要是指过度请求某一个不存在的数据。
    • 解决方案:
      • 接口层增加校验,例如id <= 0,对不合法的查询直接拦截。
      • 从缓存中读取不到数据,在数据库也没有找到,这样可以将key-value对写为key-null,缓存有效时间可以设置较短,例如30秒(设置时间太长可能会导致正常情况无法查询)。这样可以防止攻击用户反复采用同一个id进行攻击。
      • 采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。
  • 缓存击穿:某个key非常的热点,在不停的扛着大并发对这个点集中访问,这个key过期失效的瞬间,持续的大并发请求就会击穿缓存,直接请求持久层数据库。

    • 解决方案:

      • 设置热点key永不过期

      • 在访问key之前,采用一个SETNX(set if not exists)来设置另一个短期key来锁住当前key的访问,访问结束再删除之

      • 加互斥锁

        static Lock reenLock = new ReentrantLock();
        public List<String> getData() throws InterruptedException {
            List<String> result = new ArrayList<String>();
            // 从缓存读取数据
            result = getDataFromCache();
            if (result.isEmpty()) {
                if (reenLock.tryLock()) {
                    try {
                        System.out.println("我拿到锁了,从DB获取数据库后写入缓存");
                        // 从数据库查询数据
                        result = getDataFromDB();
                        // 将查询到的数据写入缓存
                        setDataToCache(result);
                    } finally {
                        reenLock.unlock();// 释放锁
                    }
                } else {
                    result = getDataFromCache();// 先查一下缓存
                    if (result.isEmpty()) {
                        System.out.println("我没拿到锁,缓存也没数据,先小憩一下");
                        Thread.sleep(100);// 小憩一会儿
                        return getData();// 重试
                    }
                }
            }
            return result;
        }
        

缓存预热、缓存更新、缓存降级

  • 缓存预热:系统上线后,提前将相关的缓存数据直接加载到缓存系统。以避免用户请求的时候,先查询数据库,再将数据缓存的问题。

    • 写一个缓存刷新页面,上线时候手动操作
    • 数据量不大的时候,在项目启动的时候自动进行加载
    • 定时刷新缓存
  • 缓存更新:除了缓存服务器自带的缓存失效策略之外(Redis默认6种)我们还可以根据具体的业务需求进行自定义的缓存淘汰:

    • 定期清理过期的缓存。缺点是维护大量缓存的key比较麻烦
    • 当有用户请求过来时,判断该请求所用到的缓存是否过期,过期就去数据库中得到新数据并更新缓存。缺点是每次用户请求过来都要判断缓存失效,逻辑相对比较复杂。
  • 缓存降级:**缓存降级是指缓存失效或缓存服务器挂掉的情况下,不去访问数据库,直接返回默认数据或访问服务的内存数据。**当访问量剧增、服务出现问题(响应过慢或者不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使这样会对服务有损。核心就是将一些对核心服务影响不大的缓存进行丢弃。

    • 降级的最终目的是保证核心服务可用,即使是有损的,而有些服务是无法降级的,例如加入购物车、结算等。

    • 在进行降级之前要对系统进行梳理,看看系统能否可以丢车保帅,从而梳理出哪些必须誓死保护,哪些可以降级。

      • 一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;

      • 警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;

      • 错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;

      • 严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。

布隆过滤器原理

引入问题:如果面试官问你,有 100 亿 url 存在一个黑名单中,每条 url 平均 64 字节。问这个黑名单要怎么存?若此时随便输入一个 url,如何判断该 url 是否在这个黑名单中?

对于第一个问题,如果把黑名单看成是一个集合,将其存入hashMap中,需要640G的空间,所以我们需要使用布隆过滤器来解决,布隆过滤器只需要23GB。

什么是布隆过滤器

布隆过滤器是一个很长的二进制矢量和一系列随机映射函数。其可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难,理论上来说添加到集合中的元素越多,误报的可能性就越大。

  • 很长的二进制矢量:一个长度很长的bit(位)类型数组(1Byte = 8bit,1KB = 1024Byte)。该位数组中每个元素都只占用1bit,并且每个元素只能是0或者1,这样申请一个100w元素的位数组只占用10000000 / 8 = 125000Byte / 1024 = 122kb的空间。
  • 随机映射函数就是常说的hash函数。

布隆过滤器的解决过程

  • 假设位数组的长度为m,每个元素值为0或1,有k个哈希函数参与运算。

  • 当我们输入一个url的时候,该url会经过k个哈希函数处理,得到k个哈希值(v1,v2…vk),之后得到这些哈希值对应在数组的下标位置,并将这些下标的元素都置为1。

  • 当我们要判断一个url是否在黑名单中,我们输入一个url并进行k个哈希函数处理,这会得到多个下标位置,如果这些下标的元素值都为1,说明该url在黑名单里面,只要存在一个0,说明该url不在布隆过滤器中。

  • 如果布隆过滤器说某个元素存在,小概率会误判。但布隆过滤器说某个元素不存在,那么它必不存在。

  • 布隆过滤器的缺点:

    • 由于hash算法的问题,可能会导致小概率误判问题。
    • 布隆过滤器无法删除元素,如果说该元素原本被删除了,但是却不能被布隆过滤器删除造成误判。

假如Redis里面有1亿个key,其中有10w个key是以某个固定的已知的前缀开头的,如果将它们全部找出来?

使用keys pre* 就可以了。但是这个命令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完服务才能恢复。我们可以选择scan命令,scan可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,我们在客户端做一次去重即可。

Redis常用命令

常规

keys *:查询所有的键,时间复杂度ON

dbsize:查询键总数,直接获取redis内置键的总数变量,时间复杂度O1

exists key:查询某一个键是否存在,存在返回1,不存在返回0

del key:返回结果为成功删除键的个数,时间复杂度ON

expire key seconds:设置过期时间

ttl key:查看键剩余过期时间,单位是秒。>0为剩余过期时间;-1没设置过期时间;2键不存在

type key:返回键的类型

rename key newkey:重命名

set key value [ex] [px] [nx|xx]:ex秒级过期时间,px毫秒级过期时间,nx键必须不存在才能设置成功,用于添加,xx键必须存在才能设置成功,用于更新。

get key:获取值,O1,不存在返回nil

mset k v k v:批量设置值,ON

mget key:批量获取值

Hash

hset key filed value:hash,设置值

hget key field:获取值

hdel key field:删除一个或者多个field,返回结果为成功删除field的个数

hlen key:计算field的个数

hexists key field:判断field是否存在

hkeys key:获取所有的field

hvals key:获取所有value

hgetall key:获取所有的field value

List

l/rpush key value:从左/右边插入元素

linset key before/after pivot value:从列表中找到等于pivot的元素,在其前或者后插入一个新的元素value

lrange key start end:从左到右0到N-1,从右到左-1到-N,end包含自身

lindex key index:获取指定下标的元素

llen key:获取列表长度

l/rpop key:从列表左/右侧弹出元素

lrem key count value:删除count个元素,>0从左到右删除count个,<0反之,=0删除所有

lset key index newValue:修改指定索引下标的元素

blpop key timeout:阻塞操作,在timeout后返回,为0则一直阻塞

Set

sadd key element:添加元素,返回结果为添加成功的元素个数

srem key element:删除元素,返回结果为删除成功的元素个数

scard key:计算元素个数,使用内部变量,O1

sismember key element:判断元素是否在集合中,是1否0

srandmember key count,随机从集合返回默认个元素

你可能感兴趣的:(redis,数据库,缓存)