[Redis] Redis知识点总结

Redis相关基础知识

Posted by Penistrong on March 14, 2023

Redis

Redis(Remote dictionary server),即 远程字典服务,是一个基于C语言开发的内存型-可持久化-键值对-NoSQL(Not only SQL)-数据库

Redis广泛用于实际生产环境,读写速度非常快,适用于分布式缓存集群

Redis快在何处

why-is-redis-so-fast

上图从三个方面总结了Redis性能突出的原因:

  • RAM-based: 基于内存,访存的速度远高于磁盘IO的速度(120$ns$ PK 50$\mu s$)

  • IO多路复用+单线程事件循环: 基于Reactor模式设计了一套高效的事件处理模型,单线程读写事件循环配合IO多路复用

  • 高效数据结构: Redis内置的5大基础类型其底层采用的都是优化过后的数据结构,性能较高

为什么要采用Redis/为什么项目要使用缓存

高性能

数据库的表是保存在硬盘中的,而磁盘IO速度相比访存速度而言弗如远甚,项目里总会存在一些常用的热点数据,如果用户每次发起请求都需要从数据库中读出这些数据难免对性能有所损失。

如果这些热点数据不会经常改变,就可以考虑将它们放在缓存里(比如Redis这种缓存数据库),用户下一次访问这些热点数据时,可以直接从存放在内存中的缓存里取出,节省了MySQL消耗的数据库连接资源

高并发

假设CPU为相对性能较高的4核8G处理器,对于MySQL这样的传统关系型数据库而言,其QPS(Query Per Second)大概在$10^4$左右,但是使用Redis缓存时QPS可以达到$10^5 \sim 3 \times 10^5$,比前者高1个数量级

访存能够承受的内存数据库请求数量远高于直接访问关系型数据库请求数量,将数据库的部分数据备份到缓存中,这样用户的部分请求会落在访问缓存这一环节上,进而提高系统整体的并发性

缓存读写策略

缓存与数据库具有多种组合方式,根据缓存读写策略的不同,可以大致分为3种: 旁路缓存模式、读写穿透模式、异步写入模式

Cache Aside Pattern 旁路缓存

旁路缓存模式是应用场景很多的一种缓存读写模式,比较适合读请求较多的场景

旁路缓存模式下服务端负责同时维护db和cache,且数据以db中存储的为基准

Cache Aside Write

cache-aside-write

写策略:

  • 先更新db

  • 再直接删除cache

FAQ:

  1. Q:可以先删除cache再更新db吗? A: 可能会导致数据不一致的问题,比如请求1先写数据A,如果先删除A数据的cache,此时再有个请求2要读数据A只能去db中读取,但是db中的数据还没更新,造成请求2读取的数据不一致

  2. Q:可是先更新db再删除cache也存在时间差,不是也会导致数据不一致吗? A: 数据库写入速度较慢而缓存写入速度快很多,虽然也会出现数据不一致问题但是总体概率很小

Cache Aside Read

cache-aside-read

读策略:

  • 从cache中读取请求的数据,有则直接返回

  • cache中没有对应数据,从db中读取数据并返回

  • 服务端在响应请求的同时,将该数据放到cache里

缺点与解决办法

旁路缓存说白了就是 cache一旦未击中,就去db中取数据,再将该数据放到cache中

缺陷:

  1. 首次请求数据一定不在cache中

    解决方法:将常用的热点数据提前放入cache里

  2. 写操作较为频繁时会导致cache中的数据被频繁删除,影响缓存命中率,这也是旁路缓存适用于读请求较多场景的原因

    解决方法

    • 数据库与缓存需要数据强一致性场景: 更新db后不等下一个请求同时更新cache,需要使用锁(集群采用分布式锁)保证更新cache时不会出现线程安全问题

    • 数据库与缓存可以短暂不一致的场景: 更新db时立马更新cache

Write/Read Through Pattern 读写穿透

读写穿透模式下,服务端将cache作为主要数据存储,从cache中读写数据,而cache负责将更新数据写入数据库里

这样服务端在写数据时只要命中了cache就不需要自己直接操作数据库,减轻了服务端的压力,但是对于缓存服务器的性能要求较高,因为它除了要负责处理读写缓存的请求,还要消耗资源读写数据库

除了性能原因外,大部分项目也不会采取这种模式,因为技术选型时如果使用了分布式Redis集群作为缓存,而Redis没有提供与数据库互操作的功能,还需要再起一个服务模块负责沟通两者导致舍本逐末

Write Through

write-through

写穿策略:

  • 先查cache,cache未命中则服务端负责更新db

  • cache命中,先更新cache,然后cache负责同步更新db

Read Through

read-through

读穿策略:

  • 先查cache,cache命中后直接返回数据

  • cache未命中,cache负责读取db中数据,写入cache后再返回

读写穿透模式其实是对旁路缓存模式的进一步封装,将读数据时cache未命中情况下的db写操作交给cache自己处理,对客户端是透明的,也比较适合读请求较多的场景

Write Begind Pattern 异步写入

异步写入模式与读写穿透模式类似,只是更新数据时不会同步更新db和cache,异步写入模式优先更新cache,将对应的db更新操作积攒到一定程度后采用异步批量方式更新

异步写入模式的数据一致性更难保障,比如cache已经更新数据了,但是db更新还没开始cache服务就宕机导致后续异步更新操作不翼而飞

这种缓存读写策略的应用场景主要有: 消息队列将消息异步写入磁盘、MySQL的Innodb Buffer Pool机制等

适合写请求较多即数据经常变化而一致性要求没有很高的场景,比如资源浏览量、点赞量等数据统计型场景

Redis 5种基础数据结构

Redis官网详细介绍了Redis数据结构,提供了5种直接供用户使用的数据结构: String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)

其底层实现利用了Redis自己实现的存储数据结构:SDS(简单动态字符串)、LinkedList(双向列表)、Hash Table(哈希表)、SkipList(跳表)、Intset(整数集合)、ZipList(压缩列表)、QuickList(快速列表)

Data Structure Base Storage Data Structure
String SDS
List LinkedList / ZipList / QuickList
Set Hash Table / ZipList
Hash ZipList / Intset
Zset ZipList / SkipList

String 字符串

String

String是Redis最简单也最常用的数据结构,可以存储任何以字符串类型存在的数据,比如字符串、整数、浮点数、图片的base64编码串、序列化后的对象

String底层采用SDS实现,而不是C语言的原生字符串(以空字符\0结尾的char数组),Redis7.0-SDS类型声明如下:

/* 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共有5种实现方式,除了第一种sdshdr5实际并不包含长度信息,后四种sdshdr8sdshdr16sdshdr32sdshdr64对应uint8_t~uint64_t的实际长度信息。Redis会根据初始化的长度决定具体使用类型,减少内存占用

字段属性释义:

  • len: 字符串长度(已经使用的字节数)
  • alloc: 字符空间总大小,alloc - len即可计算SDS剩余可分配空间的大小
  • buf[]: 实际存储字符串的字符数组
  • flags: 单字节标记位,目前只用到了低3位,高5位暂时保留未使用(以后可以扩展)

SDS相比C的原生字符串有以下优点:

  1. 避免缓冲区溢出: 原生字符串一旦没有被分配足够的内存空间,修改时会导致缓冲区溢出,而SDS可以根据lenalloc属性检查可用空间,不满足则可扩展大小再进行操作

  2. 获取字符串长度很快: 原生字符串如果不记录长度字段的情况下需要遍历计数的$O(N)$时间,而SDS直接读取len即可,消耗$O(1)$

  3. 二进制安全: 原生字符串的定界符为\0,部分二进制文件(比如音视频)可能包含了定界符\0,导致原生字符串无法正确保存此类数据,而SDS直接使用len属性判断是否结束,buf[]数组直接存储字节形式的二进制数

常用命令如下

> SET key value # 添加键值对
OK
> GET key # 获取指定键的值
"value"
> EXISTS key # 判断是否存在指定键
(integer) 1
> SETNX key valie # 指定键不存在时才可设置键值
(integer) 0
> STRLEN key # 计算指定键存储的字符串类型值的长度
(integer) 5
> DEL key # 删除指定键(通用命令)
(integer) 1
> GET key
(nil)
> MSET k1 v1 k2 v2 # 批量添加键值对
OK
> MGET k1 k2 # 批量获取
1) "v1"
2) "v2"
> INCR k1 # 指定键存储的数字类型值+1
(error) ERR value is not an integer or out of range
> SET k1 2
OK
> DECR k1 # 指定键存储的数字类型值-1
(integer) 1
> EXPIRE k2 60 # 设置过期时间(通用命令),否则永不过期
(integer) 1

内部实现

List 列表

List

Redis中的List为双向链表,两端都可以进行操作

常用命令如下:

> RPUSH list e1 # 列表尾部(右端)入队
(integer) 1
> LPUSH list e2 # 列表头部(左端)入队
(integer) 2
> LSET list 1 e3 # 更改指定列表指定索引的值
OK
> LRANGE list 0 1 # 获取列表索引[start, end]之间的元素 
1) "e2"
2) "e3"
> LLEN list # 获取列表里的元素个数
(integer) 2
> LPOP list # 左端出队并返回
"e2"
> RPOP list # 右端出队并返回
"e3"

内部实现

Hash 散列

Hash

Redis中的Hash为一个String类型的键值对映射表,内部与JDK7HashMap类似采取数组+链表的形式构建哈希表(拉链法处理冲突),适用于存储对象并直接修改对象的某些字段属性

常用命令如下:

> HSET hash id 123456 # 设置指定字段值
(integer) 1
> HSETNX hash id 000001 # 指定字段不存在时才能设置其值
(integer) 0
> HMSET hash name clw gender male age 24 # 批量设置
OK
> HGET hash age # 获取指定字段值
"24"
> HGETALL hash # 获取所有键值对
1) "id"
2) "123456"
3) "name"
4) "clw"
5) "gender"
6) "male"
7) "age"
8) "24"
> HEXISTS hash gpa # 判断是否存在字段
(integer) 0
> HDEL hash gender age # 批量删除字段
(integer) 2
> HLEN hash # 计算字段总数
(integer) 2
> HINCRBY hash id -1 # 对指定的数字类型字段进行简单加减运算
(integer) 123455

内部实现

Set 集合

Set

Redis的Set是无须集合,元素没有先后顺序但保证唯一,类似Java的HashSet,适用于存储无重复数据的列表,并可通过哈希算法直接判断某个元素是否在Set内部,还提供了数学意义上集合的常用操作比如交并差等

常用命令如下:

> SADD set v1 v2 # 向指定集合添加多个元素
(integer) 2
> SMEMBERS set # 返回集合中所有元素
1) "v2"
2) "v1"
> SCARD set # 计算集合中的元素个数
(integer) 2
> SISMEMBER set v3 # 判断集合中是否存在某些元素
(integer) 0
> SADD set2 v2 v3
(integer) 2
> SINTER set set2 # 计算集合的交集
1) "v2"
> SINTERSTORE set3 set set2 # 计算交集并存储到第一个参数指定的集合中
(integer) 1
> SUNION set set2 # 计算集合的并集
1) "v2"
2) "v3"
3) "v1"
> SUNIONSTORE set4 set set2 # 计算并集并存储到第一个参数指定的集合中
(integer) 3
> SDIFF set set2 # 计算集合的差集,即所有在第一个集合中而不在后续其他集合中的元素
1) "v1"
> SPOP set4 2 # 随机移除并获取指定个数的元素
1) "v3"
2) "v2"
> SRANDMEMBER set2 1 # 随机获取指定个数的元素(不移除)
1) "v3"

内部实现

Zset 有序集合

Zset

Sorted Set即Zset,在Set的基础上增加了一个权重参数score,使得集合中元素将按照score即你想那个排列,并可根据score范围获取元素列表

常用命令如下:

> ZADD zset 1.0 v1 2.0 v2 # 添加多个元素,每个元素前给定其score
(integer) 2
> ZCARD zset # 获取指定有序集合的元素总数
(integer) 2
> ZSCORE zset v2 # 获取指定元素的score值
"2"
> ZRANGE zset 0 1 # 获取指定有序集合里索引范围内的值,score升序排列
1) "v1"
2) "v2"
> ZREVRANGE zset 0 1 # 获取指定有序集合里索引范围内的值,score降序排列 
1) "v2"
2) "v1"
> ZREVRANK zset v2 # 获取指定有序集合里指定元素的排名,score降序排列
(integer) 0
> ZREVRANK zset v1
(integer) 1
> ZRANGE zset 0 1 WITHSCORES # 使用WITHSCORES关键字同时返回元素的score值
1) "v1"
2) "1"
3) "v2"
4) "2"
> ZADD zset2 3 v2 3 v3 4 v4
(integer) 2
> ZDIFF 2 zset zset2 # 求差集
1) "v1"
> ZRANGE zset2 0 2 WITHSCORES
1) "v2"
2) "3"
3) "v3"
4) "3"
5) "v4"
6) "4"
> ZREM zset2 v4 # 移除元素
(integer) 1
> ZUNIONSTORE zset_union_res 2 zset zset2 # 求并集,注意还会合并相同元素的score值
(integer) 3
> ZRANGE zset_union_res 0 2 WITHSCORES
1) "v1"
2) "1"
3) "v3"
4) "3"
5) "v2"
6) "5"

内部实现

Redis 7.0之前,Zset底层数据结构由 压缩列表ziplist 跳表skiplist实现:

  • key对应的有序集合中元素小于128每个元素占据空间小于64字节时,Redis会使用ziplist作为Zset的底层数据结构

  • 不满足上述条件时,就会使用skiplist作为Zset的底层数据结构

Redis 7.0开始,ziplist被废弃,转而使用listpack

底层数据结构详解

双向链表 list

压缩列表 ziplist

Redis 7.0前使用压缩列表这种内存紧凑的数据结构,占用一块连续内存空间,利用了CPU的缓存策略,有效节省内存开销,在Redis的ListHashZset类型中,当它们包含的元素数量较少且不大时都使用压缩列表作为其底层数据结构

ziplist是由连续内存块组成的顺序型数据结构,通过表头3个字段和表尾1个字段进行定界

表头3个字段依次为:

  • zlbytes: 记录ziplist占用内存的字节数

  • zltail: 记录ziplist的尾部节点距离ziplist内存起始地址的偏移量,单位为字节

  • zllen: 记录ziplist包含的节点总数

表尾的字段为:

  • zlend: 标记ziplist的结束点,该定界符固定值为一个字节的0xFF

由于zltail字段的存在,可以用O(1)的时间定位到尾部元素,但是对于非首尾节点,就需要O(N)的顺序查找时间复杂度,所以ziplist不适合保存过多元素,当元素过多时就会转为其他数据结构

ziplist中每个节点entry也是一个数据结构,包含3部分:

  • prevlen: 记录前1个节点的长度,该字段存在的目的是为了实现倒序遍历,该字段实际占用的内存空间大小跟前一个节点的长度值有关:

    1. 如果前1个节点长度小于254字节,那么prevlen长度为 1B

    2. 如果前1个节点长度大于等于254字节, 那么prevlen长度为 5B

  • encoding: 记录当前节点实际数据的类型和长度,其中类型只有2种: 字符串 or 整数

    1. 如果当前节点数据是整数,则encoding只使用 1B 空间进行编码,其中会标识整型类型,比如int16/32/64,Redis还额外增加了24位/8位整数

    2. 如果当前节点数据是字符串,根据字符串实际长度,encoding会使用 1B / 2B / 5B 的空间进行编码,前两个bit分别对应000110,后续的bit用于标识字符串的实际长度

  • data: 记录当前节点的实际数据

往ziplist中插入数据时,就会根据数据类型及其大小分配实际空间,保证各个节点之间内存分配紧凑,节省内存空间

缺点:

  • 元素过多时,O(N)的查询效率有点过低

  • 新增或者修改元素时,需要对压缩列表占据的连续内存空间进行重新分配,甚至引发连锁更新问题

    即压缩列表新增或修改某个元素时,如果空间不够就需要重新分配,而由于prevlen设计时存在不同的长度(1B 或者 5B),如果在某个prevlen为1B的节点前面插入一个大于等于254B的节点,就会导致当前节点要将prevlen扩展到5B,最差情况下如果当前节点长度在250~253B之间,扩展后导致后面的节点全部被连锁更新,极大影响性能

针对压缩列表的不足,Redis 3.2引入快表quicklist,Redis 5.0引入listpack,Redis 7.0之后将所有用到压缩列表的Redis对象全部替换成listpack实现

快表 quicklist

Redis 3.0前List对象的底层数据结构采用双向链表或者压缩列表实现,自Redis 3.2开始,改为单一的quicklist实现

快表整体上看就是一个双向链表,但其中每个链表节点又是一个压缩列表。quicklist的目标是为了解决压缩列表的缺点,它通过控制每个链表节点中压缩列表的大小或者元素个数,规避连锁更新导致的问题

typedef struct quicklist
{
  quicklistNode *head;  // 双向链表表头
  quicklistNode *tail;  // 双向链表表尾
  unsigned long count;  // 所有节点(压缩列表)中元素总个数
  unsigned long len;    // 节点个数
  ...
} quicklist;

typedef struct quicklistNode
{
    struct quicklistNode *prev; // 前一个节点指针
    struct quicklistNode *next; // 后一个节点指针
    unsigned char *zl;          // 当前节点对应的压缩列表
    unsigned int sz;            // 压缩列表的大小size,单位字节
    unsigned int count : 16;    // 压缩列表中的元素个数
    ...
} quicklistNode;

向quicklist中插入元素时,首先是检查插入位置对应的压缩列表能否容纳该元素,若能则直接保存到对应quicklistNode中的压缩列表里,如果不能再新建一个quicklistNode节点

这里的”能”与”不能”是指quicklist会控制节点中的压缩列表大小上限或者元素个数上限,规避全表连锁更新的风险,但是每一个快表节点内的压缩列表还是会出现局部的连锁更新问题

紧凑列表 listpack

为了彻底解决连锁更新问题,替代ziplist,自Redis 7.0起将所有使用ziplist作为底层数据结构的对象全部更改为紧凑列表listpack实现

listpack表头具有2个字段,表尾1个字段,每个listpack节点内部包含3部分

表头:

  1. tot-bytes: listpack总长度,单位字节数,长度4B

  2. num-elements: listpack节点数量,长度2B

节点(element):

  1. encoding-type: 当前节点数据的编码类型,仍是整数和字符串两种

  2. element-data: 实际数据

  3. element-tot-len: encoding + data 的总长度,不包括len字段本身

表尾:

listpack-end-type: listpack结尾定界符,1B的0xFF

由于listpack的节点中不再记录前一个节点的长度,向listpack中插入节点不会导致后续节点的长度字段的变化,避免了连锁更新问题

listpack仍然支持倒序遍历,通过解码每个节点中最后一个字段element-tot-len,计算当前节点占用的大小,向前移动同样的偏移量即可查找到上一个节点

整数集合 intset

Set对象的底层数据结构之一为整数集合intset,当Set对象只包含整数值元素且元素数量小于512(可根据set-maxintset-entries配置),就会采用整数集合,否则采用哈希表dictht

typedef struct intset
{
    uint32_t encoding;      // 编码方式
    uint32_t length;        // 集合包含的元素数量
    int8_t contents[];      // 保存实际元素的数组
} intset;

intset的encoding属性有3种,contents数组中存储的也是对应整型的元素:

  • INTSET_ENC_INT16

  • INTSET_ENC_INT64

整数集合的类型升级:

将一个新元素加入到整数集合时,如果新元素类型长于整数集合当前存储元素的类型时(比如int32_t元素存入int16_t的整数集合中),需要对原contents数组按照新类型int32_t进行类型扩展

即,每个以int16_t类型存储的元素从2字节扩展到4字节的int32_t

哈希表 dictht

Redis线程模型

过期删除机制

内存淘汰机制

持久化机制

Redis作为内存型数据库的一种,它与Memcached最重要的一点区别就在于Redis支持持久化,通过将内存中的数据写入到硬盘,可以支持数据重用和数据备份。Redis支持两种不同的持久化操作,一种是快照,另一种是只追加文件

RDB持久化

RDB 即 RedisDB,通过创建快照(Snapshot)获取内存中的数据在某个时间点的副本.rdb二进制文件,快照可以复制到其他服务器上进而创建具有相同数据的服务器副本(比如Redis主从结构的从节点),还可以在重启Redis服务器时自动读取快照恢复数据

RDB持久化方式是Redis默认采用的方式,在redis.conf中有以下默认配置

save 900 1          # 900秒内,如果至少有1个key发生变化,Redis就会触发bgsave命令创建快照

save 300 10         # 300秒内,如果至少有10个key发生变化,Redis就会触发bgsave命令创建快照

save 60 10000       # 60秒内,如果至少有10000个key发生变化,Redis就会触发bgsave命令创建快照

Redis提供了两个命令生成.rdb快照文件:

  • SAVE: 同步保存操作,会阻塞Redis主进程

  • BGSAVE: 异步保存操作(background save),fork出一个子进程,子进程保存快照,不会阻塞Redis主进程,默认选项

AOF持久化

AOF 即 Append-Only File,只追加文件,与快照持久化方式相比AOF持久化的实时性更好

Redis默认情况下没有开启AOF持久化,可以在redis.conf中设置appendonly参数

appendonly yes

开启AOF持久化后,Redis每执行一条更改数据的命令,就会将该命令写入到内存缓存server.aof_buf中,然后再根据配置文件中的appendfsync参数来决定何时写入到硬盘里的AOF文件(其保存位置与.rdb文件相同,默认文件名appendonly.aof)

appendfsync always    # 每次有数据修改发生后都会fsync到硬盘中, 会造成Redis性能的严重损失
appendfsync everysec  # 每秒钟同步一次,显式地同步到硬盘(自Redis 2.4后其速度与快照不相上下)
appendfsync no        # 让操作系统决定何时进行同步(通常Linux会每30s同步一次)

推荐使用appendfsync everysec选项,让Redis每秒同步一次AOF文件,即使服务器宕机导致内存缓存server.aof_buf丢失,Redis也只会丢失1秒之内的数据更新

AOF日志原理

像MySQL这样的关系型数据库,通常都是在执行命令前记录日志方便故障恢复(比如binlogredologundolog),但是Redis的AOF持久化机制是在执行完命令后再记录日志

这样做的优点和缺点都很明显:

  • 优点:

    • 避免额外的检查开销,记录AOF日志时不会进行语法检查

    • 不会阻塞当前命令的执行

  • 缺点:

    • 如果命令执行完毕而Redis还没来得及写入AOF时服务器突然宕机,导致对应数据修改的丢失

    • 虽然不阻塞当前命令,但可能会阻塞后续其他命令,因为写AOF日志时不像RDB的BGSAVE那样,是在Redis主进程中执行

AOF重写机制

以下AOF重写机制基于Redis < 7.0版本,>= 7.0后有些微变化

显然,由于AOF是日志文件,通常会比快照.rdb大得多,当硬盘里的.aof文件过大时,Redis会在后台”重写”AOF并产生一个新的AOF文件,它的体积会更小

“重写”打引号的原因是,AOF重写不需要对现存的AOF文件进行任何操作。Redis通过执行bgrewriteaof命令时fork一个子进程创建新的AOF文件,同时Redis主进程还会维护一个AOF重写缓冲区,在重写AOF文件期间将Redis执行的写命令记录在重写缓冲区中,待新AOF文件创建完成后就会将重写缓冲区里的内容追加到新AOF文件末尾,最后替换掉旧的AOF文件即可完成整个重写过程

RDB与AOF优劣

两者各有千秋

RDB相较AOF的优点:

  • RDB文件存储的是经过压缩的二进制数据,文件很小,适合数据备份、容灾恢复。而AOF因为记录的是写命令日志,文件体积较大,且需要重写

  • 使用RDB文件恢复数据时,只需要直接解析并还原,速度很快。而使用AOF文件恢复时,需要依次执行其中记录的每个写命令,速度相对较慢,显然RDB更适合恢复大数据集

AOF相较RDB的优点:

  • RDB文件是快照持久化的形式,没办法实时(比如秒级)持久化数据,且使用BGSAVE命令虽然是fork了1个子进程后台处理,但是仍然会消宿主机的CPU与内存资源,如果Redis存储的数据非常多,生成.rdb文件时非常影响服务器性能。而AOF可以通过开启appendfsync everysec,每秒执行一次fsync持久化到磁盘中,且只是在.aof文件后追加日志,消耗资源很少

  • RDB文件存在版本不一致问题,不同Redis版本会使用不同版本的RDB文件,导致老版本Redis存储的数据无法迁移到新版本

  • AOF由于记录的是写命令日志,可以轻松分析AOF文件,并通过操作AOF文件解决一些数据恢复问题。比如当错误执行FLUSHALL命令清空了所有数据时,只要.aof文件还没有被重写,打开AOF文件并删除最近的FLUSHALL记录,重启Redis会自动读取AOF文件恢复到之前的状态

RDB+AOF混合持久化

综合考虑RDB和AOF的优点,自Redis4.0起支持开启RDB+AOF混合持久化(默认关闭,在redis.conf中加入配置项aof-use-rdb-preamble开启)

在开启了混合持久化的情况下,AOF重写时会直接将RDB的内容写在AOF文件头部,后面再追加RDB快照所在时间点后的写命令日志,这样AOF重写时速度很快,且Redis利用.aof文件恢复大数据集时能够快速加载

Redis文档-持久化管理中提到:自Redis 2.4版本后,Redis会避免同时触发快照和AOF重写,这样做是为了防止磁盘I/O资源的严重消耗,即使用户在Redis执行快照的过程中显式地使用BGREWRITEAOF命令,服务器只会将AOF重写命令延迟到快照完成后再进行(虽然会立马返回一个OK状态码)

缓存问题与Redis的应对方式

旁路缓存模式下,常见3种缓存问题,Redis通常都是在旁路缓存模式下工作,因此它也有会一些解决方案

缓存穿透

假设外界的客户端突然发起了大量包含非法key(既不在缓存中也不在数据库中)的请求,服务端首先会去Redis那取数据,但这些数据显然是不在cache中的,那么只能向数据库发起大量请求,给数据库造成了极大压力,这个过程就像cache完全不存在一样,所以称为缓存穿透

对于这种情况,其实服务端可以通过参数校验方式过滤大量非法请求,但这一般都是应用后台的基础操作,如果攻击者知晓了请求参数的合法格式而伪造了大量合法却不存在的参数,仍然存在缓存穿透的风险

一般有两种解决方案:

  1. 缓存无效key

    一种治标方案,如果缓存与数据库都查不到对应key,直接构造一个具有较短过期时间的数据存到Redis中,这种方式可以解决请求参数变化不频繁的情况,但是碰到恶意构造大量随机key的攻击者,这种解决方式就会导致Redis中缓存了大量无效key

  2. 布隆过滤器(Bloom Filter)

    布隆过滤器可以快速判断某个元素是否在海量元素之中,Bloom Filter的原理是使用哈希函数对元素值进行计算,由于多个哈希函数的存在可以得到多种哈希值,然后根据哈希值在位数组中把对应位置的值置1即可,显然就是个超大型哈希表

    但是,既然是哈希表就肯定存在哈希冲突问题,哈希冲突可以通过设计散列性能优秀的哈希函数或者扩大哈希表大小等方法缓解,但还是会出现误判情况

缓存击穿

缓存击穿听着很像缓存穿透,但实质不同,客户端发起请求的key确实存在于数据库中而不存在于缓存中,如果短时间内发起了大量对于热点数据的请求,由于cache中没有这些数据就好像cache被击穿了一般,压力来到了数据库头上

以下给出3种解决办法:

  1. 给缓存中的热点数据设置较长的过期时间甚至设置为永不过期

    • 针对场景提前预热,比如抢购优惠券的高并发场景,可以提前将相关数据存入缓存中,并且将其过期时间至少设置到该场景结束之后

    • 访问缓存数据后,主动对缓存数据进行续期,延续热点数据的身份

  2. 使用分布式锁,限制落到数据库头上的请求数量,把压力分摊到服务端解决

    利用分布式锁串行化各个对缓存数据的请求,当某个线程获取到分布式锁后,再对缓存执行查询,如果发现缓存失效时,则去查询数据库并写回到缓存中。这样能够保证数据库的稳定性,但是牺牲了部分性能

  3. 设置多级缓存,对于访问量较高的热点数据多级存储,分级设定过期时间,保证热点数据不会同时在多级缓存中失效,尽可能地降低数据库的压力

缓存雪崩

缓存雪崩与穿透和击穿的区别在于,发起请求时,至少缓存中确实是存在这些请求需要的数据的

雪崩的意思是,如果缓存里的热点数据在较短的时间内大面积失效或者缓存所在的宿主机直接宕机,那么大量的合法请求会像雪崩一样直接涌向数据库,进而使数据库也面临瘫痪的风险

对于缓存突发性失效情况

  1. 对于部分数据,设置不同的失效时间让它们能够阶段性失效,比如随机设置key的失效时间
  2. 设置二级缓存,一级缓存里突然失效,还可以去二级缓存拿,仿佛缓存的缓存
  3. 设置缓存里的数据永不失效,但是就要人工删除无效冷数据,缺乏实用性

对于Redis服务不可用情况:

  1. 采用Redis集群
  2. 服务端进行限流、降级和熔断等操作,避免同时处理大量请求

Redis高可用集群

主从复制

主从复制机制是Redis集群高可用的基础,主节点读写操作均可进行,而从节点通常设置为只读并接收主节点同步过来的写操作命令,完成数据同步

即所有的数据修改只在主节点上进行,然后将最新的数据复制给从节点,使得主从节点数据一致(由于是异步复制,无法实现强一致性)

一个节点若想成为主服务器的从节点,执行replicaof(Redis 5.0之前为slaveof)命令即可建立主从关系:

replicaof <masterip> <masterport>

哨兵

哨兵机制是Redis集群高科用的保障,由于Redis集群通常为一主多从架构,如果主节点宕机,从节点无法自动升级为主节点,引入哨兵后由Sentinel自动完成故障发现和转移,重新选举主节点,以实现高可用性