当**大量缓存数据同时失效(过期)**或者 Redis 故障, 就会有大量请求直接访问数据库, 导致数据库宕机, 从而产生的连锁反应, 导致系统瘫痪
访问的数据不在 Redis 中, 那么先给该请求一个互斥锁, 保证同一时间只有一个请求来构建缓存, 等待请求将数据缓存进 Redis 之后就开始释放锁
如果此时又有新的请求来, 要么就等待互斥锁释放, 要么就直接返回默认空值
应该给锁添加一个超时时间, 防止出现故障导致锁无法释放, 形成阻塞
设置一个主 key 和 副 key, 主 key 保存有过期时间, 副 key 则没有过期时间, 相当于主 key 的一个副本, 两者之间的区别只有 key 不同, 缓存 value 值是一样的
当主 key 的数据拿不到时, 就访问 副 key 的值, 这时返回的数据可能是旧数据, 也就是说, 场景允许出现旧数据
当请求从后台拿到数据之后, 应该再次给 主 key 和 副 key 都更新缓存
不设置过期时间缓存也会因为内存限制而被淘汰, 而这个空档期数据就查询不到了
两种方式解决
后台线程不仅负责定时更新缓存, 也负责频繁的监测缓存是否有效
这种方法就需要设计较为合理的监测间隔时间, 不至于影响用户体验也不会经常出现空值
业务线程发现缓存被淘汰之后, 通过消息队列发送消息通知后台线程更新缓存, 后台线程再判断是否需要更新
这种方式相对来说比较好, 不会太影响用户体验
暂停业务对缓存的访问, 直接返回错误, 不用再继续访问数据库, 但是这样业务就无法正常运行
所以可以在此基础上使用请求限流机制, 只将少部分请求发送到数据库处理, 其他请求入口处就拒绝服务
AOF 日志记录的是命令
RDB 文件内容是二进制数据
Redis 每执行一条写命令后, 就会将该命令存入 AOF 日志(硬盘)中, 即(Append Only File), 只会记录写操作, 不会记录读操作
默认不会开启, 需要在 redis.conf
配置文件中修改
appendonly yes // 表示是否开启 AOF 持久化(默认 no)
appendfilename "appendonly.aof" // AOF 持久化文件的名称
*3
表示当前命令有三个部分, 每部分都是以$+数字
开头, 后面紧跟命令, 键, 值
数字
代表当前这部分的字节数, 如$3 set
表示 set 命令字符串的长度
避免额外的检查开销, 如果先存入 AOF 日志, 命令发生了语法错误, 那么使用日志恢复时, 会出现错误
但如果先执行命令, 就可以保证存入日志的命令都是正确的, 就避免了检查日志的操作
不会阻塞当前写操作的进行, 只有写操作成功后, 才会存入日志
但是会带来一些风险
Redis 在执行命令之后, 没来得及将命令写入硬盘, 服务器宕机了, 数据会有丢失的风险
可能会阻塞下一个写命令
也就是说, 风险的产生与命令写入硬盘的时机有关
Redis 写入 AOF 日志的过程
Redis 提供的三种写回策略, 控制的主要是 内核缓冲区 中的数据什么时候写入硬盘
在 redis.conf
文件中的appendsync
项有三个参数可填
Always
, 每次执行完命令后, 就将 AOF 日志的数据写回硬盘Everysec
, 每次写命令执行完后, 先写入 内核缓冲区, 每隔一秒将内容写回硬盘No
, Redis 不控制写回, 每次执行完命令放入 内核缓冲区之后, 由操作系统决定什么时候写入硬盘Always, 高可靠, 但是不可避免的影响主进程的性能
No, 高性能, 但是操作系统写入硬盘的时机是不可预知的, 可靠性较低
Everysec, 折中的一种办法
内核发起的写操作, 其实就是调用 fsync()
函数, 将内核缓冲区的数据写入到硬盘
Always 每次写入 AOF 文件后, 就执行 fsync() 函数
Everysec 创建一个异步任务来执行 fsync() 函数
No 不会执行 fsync() 函数
是由后台子进程 bgrewriteaof 来完成的
子进程是通过主进程 在产生 子进程时会将 页表 复制一份给子进程, 也就是说, 子进程和父进程的虚拟空间不同, 但是其对应的物理地址是同一个
导致父进程阻塞的方法情况
主进程修改了已经存在 key-value,就会发生写时复制,注意这里只会复制主进程修改的物理内存数据,没修改物理内存还是与子进程共享的
当子进程重写完成之后, 会像主进程发送一条信号, 主进程接收到后会调用一个函数, 主要处理
两个命令, save
和 bgsave
save
在主线程生成 RDB 文件, 会阻塞主线程bgsave
创建一个子进程生成 RDB 文件, 不会阻塞主线程RDB 文件是在服务器启动时自动执行的, Redis 并没有直接命令
Redis 会通过配置文件的方式 默认执行 RDB 快照
RDB 快照是 全量快照, 也就是会将内存中所有数据都存入硬盘中, 对性能影响较大
bgsave
时, Redis 主线程仍然可以处理操作命令, 用到的就是 AOF 中的 写时复制技术混合持久化过程发生在 AOF 重写过程中
混合持久化之后的 AOF 文件 前半部分是 RDB 内容, 后半部分是 AOF 内容
这样即可以避免 RDB 快照过程中不会缺少主线程修改的数据(因为记录在了 AOF 内容中) 也保证了加载的时候速度较快(RDB)
当开启了混合持久化时,在 AOF 重写日志时,
fork
出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。
对多台 Redis 服务器组成的主从关系, 进行信息备份的方式, 以保证服务器之间的数据一致性
主服务器可以进行读写操作, 并且在发生读写操作之后将数据同步给从服务器, 从服务器一般只进行读操作, 并且接受来自主服务器的同步来的写操作.
主从复制一般分为三种方式
bgsave
子线程生成的 RDB 文件并给从服务器使用时, 主服务器执行写命令产生的新数据存放在 replication buffer
缓冲区里replication buffer
缓冲区中的数据 都发送后, 从服务器执行,至此 第一次同步工作就完成了repl_backlog_buffer
,是一个环形缓冲区,用于主从服务器断连后,从中找到差异的数据;先更新数据库再更新缓存 或 先更新缓存再更新数据库, 都会发生并发问题, 导致缓存中的数据与数据库中的数据不一致
可以采用 Cache Aside 策略 (旁路缓存策略),
不更新缓存, 而是删除缓存中的数据. 之后读数据时, 如果发现缓存中没有了数据, 再从数据库中读取数据, 更新到缓存中
这时, 针对 写策略, 有两种情况
先删除缓存, 再更新数据库
在 读 + 写 并发时, 还是会容易出现并发问题
先更新数据库, 再删除缓存
在 读 + 写 并发时, 也会出现并发问题,
但是 出现的概率非常低 , 因为 写回缓存的时间 比 写回数据库的时间 要快很多, 因此很难出现这种情况
还可以给缓存中数据 加上过期时间, 保证过期之间之后数据也能保持一致
所以我们通常选择, 第二种, 先更新数据库, 再删除缓存
但是这有引发出另一个问题, 就是 更新数据库 和 删除缓存 这两个操作是不能保证同时都成功的, 如果缓存删除失败, 那么也会读取到错误的数据
有两种解决办法, 两种方法都采用异步方式操作缓存
概念: Redis 中的 String, List, Hash, Set, Zset 是Redis 中的数据类型(Redis 对象), 数据结构指的是实现的底层结构
\0
, 也就是说不能保存图片, 音频视频等二进制文件len
记录字符串长度, 即 获取字符串长度的时间复杂度为 O(1)alloc
记录已经分配的空间长度, 也就是说可以程序可以通过 api , 直接 查看alloc - len
即字符串剩余长度从而判断拼接字符串时是否会发生缓冲区溢出 ,并自动进行扩容从而达到所需大小flags
表示不同类型的 SDS, 一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64, 区别是所占用的字节数不同, 即根据情景选择合适的类型可以避免空间浪费buf[]
保存实际的数据Redis 在编程上还使用了专门的编译优化来节省内存空间,即在 flags 的变量如 sdshdr5 等结构体的 struct 中声明了 __attribute__ ((packed))
,它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐。从而解决了 C 语言中 char* 所存在的几个缺陷
空间预分配: 对 SDS 进行修改和空间扩充时, 除了分配已经使用的空间之外, 还会分配未使用的空间
SDS 修改后,len 长度小于 1M,那么将会额外分配与 len 相同长度的未使用空间。如果修改后长度大于 1M,那么将分配1M的使用空间
惰性空间释放: SDS 缩短时, 不会回收多余的内存空间, 而是使用 free 字段将多余的空间记录下来.
如果后续有变更操作, 直接使用 free 中记录的空间, 减少了内存的分配
Redis 中的链表 listNode
有前置节点和后置节点两个链表节点, 也就是双向链表
Redis 还使用 list
对 listNode
进行封装
提供了链表头尾节点 head , tail 以及链表节点数量 len, 还有可以自定义实现的 dup(复制) , free(释放) , match(比较) 函数
typedef struct list {
//链表头节点
listNode *head;
//链表尾节点
listNode *tail;
//节点值复制函数
void *(*dup)(void *ptr);
//节点值释放函数
void (*free)(void *ptr);
//节点值比较函数
int (*match)(void *ptr, void *key);
//链表节点数量
unsigned long len;
} list;
typedef struct listNode {
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
} listNode;
优点:
缺点:
zlbytes
: 记录整个压缩列表占用的内存字节数zltail
: 记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;zllen
: 记录压缩列表包含的节点数量zlend
: 标记压缩列表的结束点,固定值 0xFF(十进制255)压缩列表节点包含
prevlen
: 记录前一个节点的长度encoding
: 记录当前实际节点的长度 以及 类型data
: 记录实际数据节点会根据 数据是字符串还是整数, 以及数据大小使用不同 空间大小的 prevlen
和 encoding
来标识, 从而节约内存
对于prevlen
:
前一字节长度 < 254 : 使用 1 字节
前一字节长度 >= 254 : 使用 5 字节
对于 encoding
:
当前节点是整数: 使用 1 字节
当前节点是字符串: 根据字符串长度, 使用 1 / 2 / 5 字节空间进行编码
对于 Hash 对象, 使用 压缩列表 的情况
由配置文件中的 hash-max-ziplist-value
和 hash-max-ziplist-entries
选项定义上限值
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
unsigned long sizemask;
//该哈希表已有的节点数量
unsigned long used;
} dictht;
typedef struct dictEntry {
//键值对中的键
void *key;
//键值对中的值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
//指向下一个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
哈希表是一个数组(dictEntry **table
),数组的每个元素是一个指向「哈希表节点(dictEntry
)」的指针
dictEntry
里不仅有 key 和 value ,还有 next 指向下一个节点来解决哈希冲突, 这就是链式哈希(被分配到同一个哈希桶上的多个节点可以用这个单项链表连接起来)
dictEntry
中的 value 是一个 union
联合体, 也就是说 value 的值可以根据实际填充的值来实现, 从而节省内存空间, 比如说存放的是整数值就可以直接存入 value , 而不需要再用一个指针指向实际的值
联合体: 所有成员占用同一段内存,修改一个成员会影响其余所有成员. 联合体占用的内存等于最长的成员占用的内存
dictht
定义了两个, 就是为了 进行 rehash下图是 rehash 的大致过程
但这也有一个问题, 就是在哈希表1 数据过多时, 迁移的时候就会占用很长时间, 导致 redis 阻塞较长时间, 而渐进式 rehash 可以解决这个问题
过程:
另外, 在 rehash 过程中, 新增一个 key-value 时, 会直接保存到 哈希表 2 中, 这样就保证了 哈希表1 的 key-value 只会减少, 不会增加
负载因子 = 哈希表以保存节点数量 / 哈希表大小
是 set 对象的底层实现之一, 当一个 set 对象只包含整数值元素时, 并且元素个数不超过512时, 就会使用整数集合作为底层实现
不满足任意一条时就使用哈希表来实现
typedef struct intset {
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;
content 的类型跟 encoding 相关, 如 encoding 为 INTSET_ENC_INT16, 那么 content 的类型就是 int16_t, 其他如 INTSET_ENC_INT32, INTSET_ENC_INT64 也同理
整数集合升级的好处?
节省内存资源, 如果让一个数组同时保存 16位, 32 位, 64 位的数据, 那么最简单做法就是大小扩充为 64 位, 但是如果使用升级的策略, 在 32 位 和 64 位数据进入数组之前, 数据的空间占用就会小很多, 也就节省了内存
不支持降级操作
假如说支持降级操作, 就会出现两个问题:
什么时候降级?
假如说删除了一个 int64_t 的数据, 那么如果需要降级, 就需要遍历一遍整个数组, 保证内部没有 int64_t 类型的数据才能降级, 这个时间是O(n), 而且就算我们此时遍历完之后进行了降级, 我们也无法确定之后会不会继续插入 64 位的数据, 如果插入了, 那么 redis 又要对 intset 进行升级, 这样来回的升降级会极大的影响系统性能, 所以降级不合适
降级要何种级别的数据类型?
这个也是同理, 要确定降低到什么级别的数据类型, 是 int32_t 还是 int16_t 我们必须要对数据进行一次遍历, 之后当前最大元素之后才能进行降级, 时间开销同样大
所以, redis 就只负责给 intset 升级, 而忽略了降级操作
// zset结构
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
// 跳表节点
typedef struct zskiplistNode {
//Zset 对象的元素值
sds ele;
//元素权重值
double score;
//后向指针
struct zskiplistNode *backward;
//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
保存了 Zset 对象的 元素值(sds ele) 和 权重值(double score)
后向指针, 指向前一个节点, 方便从跳表尾结点开始访问节点, 方便倒序查找
跳表是一个带有层级关系的链表,而且每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的zskiplistLevel 结构体类型的 level 数组
level 中的每一个元素就代表跳表的每一层, level[0] 就是第 1 层.
同时还定义了指向下一个跳表节点的指针 forward, 还有跨度, 跨度主要是记录两个节点之间的距离(跨度实际上是为了计算这个节点在跳表中的排位 排位就是从头节点到该节点的查询路径上经过的所有层跨度的和)
// 跳表
typedef struct zskiplist {
struct zskiplistNode *header, *tail;// 头尾节点, O(1) 查询头尾结点
unsigned long length; // 跳表长度, O(1) 获取跳表节点数量
int level; // 跳表最大层数, O(1) 获取跳表层高
} zskiplist;
从最高层开始, 遍历每一层的跳表节点, 使用 SDS 类型的元素值 和 元素的权重来判断, 有两个规则
如果都不满足, 直接进入下一层查找
跳表相邻两层节点数量会影响跳表查询性能
跳表相邻两层节点数量最理想的比例是 2 : 1 , 查找复杂度可以降低到 O(logN), 如下图
如果在 删除节点 和 添加节点 时, 来调整跳表节点以维持比例的话, 会带来额外的开销
Redis 采用一种巧妙的方法是, 跳表创建节点时, 随机生成每个节点的层数
跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数
这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64 (源码中规定的)
typedef struct quicklist {
//quicklist的链表头
quicklistNode *head;
//quicklist的链表尾
quicklistNode *tail;
//所有压缩列表中的总元素个数
unsigned long count;
//quicklistNodes的个数
unsigned long len;
...
} quicklist;
typedef struct quicklistNode {
//前一个quicklistNode
struct quicklistNode *prev; //前一个quicklistNode
//下一个quicklistNode
struct quicklistNode *next; //后一个quicklistNode
//quicklistNode指向的压缩列表
unsigned char *zl;
//压缩列表的的字节大小
unsigned int sz;
//压缩列表的元素个数
unsigned int count : 16; //ziplist中的元素个数
....
} quicklistNode;
quicklistNode 与双向链表节点的区别是, 保存的数据结构内容是 压缩列表
quicklist, quicklistNode 和 ziplist
之间的关系如下图
listpack 保存了 压缩列表 很多有点, 比如使用连续的一段内存空间紧凑的保存数据, 不同节点采用不同编码来节省内存空间
具体结构如下图
listpack 没有记录上一节点的长度了, 只记录当前节点的长度, 所以当我们向 listpack 中添加新元素时, 不会影响其他节点的长度变化, 从而避免了连锁更新问题