2021-07-06

Redis详解

Redis概念介绍

Redis是一个基于内存的key-value存储系统,可以用来做数据库,缓存,消息中间件
Redis是单进程单线程模型,采用队列模式,将并发访问变为串行访问,但是因为它是基于内存的,而且采用I/O多路复用等技术,因此单线程的Redis性能依然非常高
Redis支持的数据结构比较丰富 比较常用的基础数据类型有5种,String,List,Hash,Set,SortedSet
Redis数据结构可以从两个角度看
从用户的使用角度看,Redis暴露给用户使用的数据类型可以分为String,List,Hash,Set,SortedSet。
从Redis底层实现来看,可以分为SDS,dict,ziplist,skiplist,intset,quicklist

一.SDS

Redis底层是c语言实现的,底层数据结构也是c语言,c语言自带字符串的数据结构但是Redis没有直接使用,而是自己构建了一种名为简单动态字符串(Simple Dynamic String,SDS)的抽象类型,并将SDS作为Redis默认的字符串表示
为什么Redis要自己实现字符串?因为Redis作为数据库时会有非常多的字符串修改和查询操作,而c语言的在这方面的性能比较一般

SDS定义

struct sdshdr {
    // 记录 buf 数组中已使用的字节的数量
    int len;
    // 记录 buf 数组中未使用的字节的数量
    int free;
    // 字节数组, 用于保存字符串
    char buf[];
}

这个数据结构能够保证获取字符串的长度操作从O(n)降低到了O(1),C语言里通过遍历来获取字符串长度,而SDS只需要读取字符串的len属性

杜绝缓冲区溢出

这个数据结构也为空间预分配,惰性空间事放,二进制安全带来了可能

空间预分配

在c语言中,修改字符串的时候需要重新分配内存,内存分配时一个比较复杂的算法,需要涉及到系统调用,所以通常时一个比较耗时的操作
在一般情况下,修改字符串是不太会出现的,因此每次修改就执行一次内存分配的性能损耗是可以接受的,但是Redis作为数据库时,字符串的修改是极其频繁的,所以Redis使用SDS实现字符串空间预分配功能来提高字符串修改操作的性能,其实是典型的空间换时间的操作
在SDS中,buf数组的长度不一定就是字符串长度加一,数组里可以包含未使用的子接,而这些字节由free属性记录,当SDS进行扩展的时候,Redis不仅会为SDS分配修改所必须的空间,还会分配额外的未使用空间,类似于Java里HashMap的扩容操作,并不是需要多少就扩容多少,而是根据某个公式来扩容,扩容的空间往往大于当前所需要的空间大小

SDS空间分配规则如下:
如果对SDS进行修改之后,SDS的长度小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS的len属性的值将和free属性的值相同
如果对SDS进行修改之后,SDS的长度大于1MB,那么程序会分配1MB的未使用空间

惰性空间释放

惰性空间释放用于优化SDS缩短操作,当SDS进行缩短操作时,Redis并不会使用内存重新分配算法来释放内存,而是修改字符串的len和free值,这些空闲的字节可以留待后面使用

二进制安全

c语言的字符串中的字符是符合ASCII编码的,这种编码的特点是:遇到空字符就认为是这个字符串的结尾,这就限制了c语言的字符串只能用来保存文本数据,而不能保存图片,视频,压缩文件这样的二进制数据
但是使用SDS就完全可以,因为SDS不是依靠空字符串来判断字符串的结尾的
应用

分布式锁

基于单redis节点实现的分布式锁

首先使用setnx来抢锁,然后设置expire命令来设置锁的过期时间以避免锁在某些情况下不被释放,但是有可能会产生系统在setnx之后重启或者抛异常,就会导致没有执行expire命令使锁永远不会被释放,所以一般都是直接把setnx和expire合成一条命令来执行

SET ${target_key} ${target_value}NX PX ${expire_time}

其中NX保证仅当${target_key}不存在时才set值(获取锁),PX保证设置过期时间
通过delete释放锁,但是释放锁之前最好先做个判断

// 伪代码
// 每个客户端都有自己的specific_value
// 在使用SETNX的时候机已经把这个value set进去了
// delete key的时候先对比下value,再决定要不要删除

```java
if getValue(lock_key) == specific_value:
    delete(lock_key)
else
    return

另外Redis要求释放锁的操作必须使用lua脚本来实现,这样才能保证原子性

List
Redis也是自己实现链表数据结构

链表定义


typedef struct listNode {
    // 前置节点
    struct listNode *prev;
    // 后置节点
    struct listNode *next;
    // 具体数值,这里是一个void的指针,也就是可以放任何内容
    // 这东西相当于一个泛型啊
    void *value;
} listNode;
typedef struct list {
    // 头节点
    listNode *head;
    // 尾节点
    listNode *tail;
    // 链表所包含的节点数量
    unsigned long len;
    // 节点值复制函数
    void *(*dup)(void *ptr);
    // 节点值释放函数
    void (*free)(void *ptr);
    // 节点值对比函数
    int (*match)(void *ptr, void *key);
} list;

从上面的数据结构中可以看出,Redis的List实现具有如下特性:
双端链表,获取前置or后置节点的时间复杂度O(1)
无环,首节点前置节点和尾节点后置节点都是null
list结构带有头节点和尾节点,获取头节点和尾节点的时间复杂度是O(1)
带有长度,获取链表长度的时间复杂度都是O(1)
链表节点的void*指针使得节点可以为不同属性的值

应用

可以用来做任务队列,利用lpush、rpush命令实现入队,lpop,rpop实现出队列
可以左进右出或者右进左出来实现先进先出队列
也可以左进左出或者右进右出来实现先进后出队列
当然可以用brpop、blpop实现阻塞的访问。

Set

Set就是直接用Hash实现的,添加,删除,查找的复杂度都是O(1)

Hash
Redis里的Hash就是一个哈希表,类似于Java里的HashMap,但是Redis里的Hash只能存储key_value 的String类型的数据,每个Hash可以存储2^32-1个键值对
从使用者的角度看,Redis暴露给外部的数据结构是Hashes,但是在Redis的内部实现中,Hash是使用dict数据结构来实现的,dict称为字典,其底层实现是哈希表

Hash的定义
typedef struct dictht {
// entry数组
dictEntry **table;
// 总大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 已有节点的数量
unsigned long used;
} dictht;
typedef struct dictEntry {
void *key;
// value
union {
void *val;
unit64_t u64;
int64_t s64;
}
// 指向下一个Entry的指针
struct dictEntry *next;
} dictEntry;

它的实现和HashMap的实现基本一致,Redis利用哈希表实现了字段dict,实际上这个字典就是对哈希表的一个封装

字典dict的定义

typedef struct dict {
// type和private属性是针对不同类型的键值对,为创建多态字典而设置的
dictType *type;
void *private;
// 哈希表数组,这是一个数组,每个元素都一个dictht哈希表
// 一般情况下只使用ht[0]的哈希表
// h[1]哈希表只会在ReHash的时候使用
dictht ht[2];
// rehash 索引
int trehashindex;
}

渐进式ReHash

Redis的Hash和Java里的HashMap不同的地方在于 Redis实现了渐进式的ReHash,因为服务端的Redis里的Hash可能会存很多的键值对,如果一次对这些键值对进行ReHash,庞大的计算量会导致Redis在一段时间内停止服务,所以Redis采用渐进式的ReHash策略,分多次,渐进式的把数据ReHash到新的Entry数组里
渐进式ReHash是一个典型的分而治之,以空间换时间的操作
首先给ht[1]分配空间,这样字典就同时拥有了ht[0]和ht[1]两个哈希表
在字典中维持一个索引计数器变量rehashindex,并将它的值设置为0,表示rehash工作正式开始
在rehash期间,每次对字典执行添加,删除,查找,更新操作时,程序除了执行指定的操作之外,还会顺带将ht[0]哈希表在rehashindex索引上的所有键值对rehash到ht[1],当rehash工作完成之后,rehashindex属性的值加一
随着字段操作的不断执行,最终会在某个时间点上,h[0]所有的键值对都会被rehash至ht[1],这时候把rehashindex设置为-1,表示rehash完成

SortedSet

Redis里的SortedSet是个复合结构,由一个hash和一个skiplist组成,其中hash用来保存value和score的对应关系,skiplist用于给score排序
SkipList
因为Redis的应用场景,SortedSet会有很多插入,删除,更新的操作,为了保证这些操作的高效,只能使用链表来实现,链表可以保证插入,删除,更新操作的时间复杂度都在O(1)
但是即使是排好序的链表,查找某个特定值的操作的时间复杂度也是O(N),因为只能通过遍历整个链表来查找,链表的特性使得二分法查找在这个场景下毫无用武之地
但是跳跃表结合了链表与二分法查找,它维护了一个多层级得链表结构,每一层都可以看成是一条链表,跳表有如下特点:
一个跳表有几个层(level)组成
调表得第0层包含所有的元素,且节点值是有序的
每一层都是一个有序的链表,层级越高越稀疏,这样在高层次中能跳过许多不符合条件的数据
如果元素x出现在第i层,则所有比i小的层都包含x
每个节点包含key及其对应的value和一个指向该节点第n层的下个节点的指针数组 ,x->next[level]表示第level层的x的下一个节点

skiplist

// 跳跃表的节点数据结构
typedef struct zskiplistNode {
    struct zskiplistNode *backward;
    // 分值
    double score;
    // 成员对象
    robj *obj;
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度
        unsigned int span;
    } level[];
} zskiplistNode;
// 跳跃表结构
typedef struct zskiplist {
    // 头结点和尾节点
    struct zskiplistNode *header, *tail;
    // 节点的数量
    unsigned long length;
    // 层数
    int level;
} zskiplist;

Redis速度为什么这么快

  1. Redis的操作完全基于内存,类似与HashMap,查找效率是O(1)
  2. 数据结构足够简单,对数据的操作也很简单,而且数据结构也是经过专门设计的,比如String实现了预空间分配和惰性空间释放,比如SortedSet使用的跳表
  3. Redis的单进程单线程模型避免了不必要的上下文系切换以及线程竞争,也不需要考虑各种加锁解锁的操作,极大的节省了性能开销
  4. Redis使用了I/O多路复用,实现了非阻塞I/O

Redis单线程模型

Redis的核心是文件事件处理器(File Event Handler),它的File Event Handler由四部分组成:套接字、I/O多路复用程序、文件事件分派器以及事件处理器。 具体的运行过程是:采用非阻塞的I/O多路复用模型,监听多个socket,同步、有序的将产生事件的socket放到一个FIFO内存队列当中,然后文件时间分派器也总是以同步、有序的方式将事件分派给事件处理器进行处理。 因为这整个过程都是单线程的,或者说Redis的文件事件处理器就是单线程的,所以Redis被称为单线程模型。

Redis持久化

持久化:是最简单的高可用方法,主要作用是数据的备份
主从复制:是Redis高可用的基础,哨兵和集群是在主从复制的基础上实现的,主从复制实现了数据的多机备份,可以用于负载均衡以及简单的故障恢复,其缺点是故障恢复无法自动化,写操作无法负载均衡,存储能力受到单机 的限制
哨兵:在复制的基础上实现了自动化的故障恢复,缺点是写操作无法负载均衡,存储能力受到单机的限制
集群:解决了写操作无法负载均衡以及存储能力受到单机限制的问题,实现了较为完善的高可用

Redis提供两种方式的持久化

RDB:将Redis在内存中的数据记录定时dump到磁盘的文件上
AOF:将Redis的写,删除数据的操作日志记录到磁盘文件上

RDB

RDB持久化的方式是通过快照完成的,Redis将内存中的所有数据以二进制的方式dump到磁盘的文件上,一般来说可以配置间隔多少时间就dump一次来用于备份
save和bgsave可以手动触发RDB持久化,区别在于:
save命令使用Redis的进程进行RDB持久化,会直接阻塞Redis,这段时间不会响应任何用户发来的请求
bgsave执行时,Redis会fork出一个子进程用于执行RDB持久化,fork的操作时阻塞的但是比较短暂,后面RDB持久化的操作由子进程完整,不会阻塞处理用户请求的主进程
另外在FLUSHALL,SHUTDOWN,主从辅助的时候Redis会自动触发RDB持久化
当Redis意外崩溃或者关闭再次启动时,此时AOP持久化未开启时(默认未开启),将使用RDB快照文件恢复数据,但是我们知道RDB一般是隔一段时间备份一次的,所以可能会丢掉一部分数据

AOF

AOF可以将Rdis执行的每一条写命令追加到磁盘文件中,Redis启动时优先选从AOF文件中恢复数据,由于每一次的写操作,Redis都会记录到文件中,所以开启AOF持久化会对性能由一定的影响
可以用bgrewriteaof手动触发AOF,也可以使用配置自动触发
AOF文件重写过程中与RDB快照bgsave工作过程有点相似,都是用过fork子进程,由子进程完成响应的操作,同样在fork子进程的简短的时间内,Redis是阻塞的

RDB和AOF的比较

RDB优势:
可以全部备份到一个文件中
启动时如果数据集很大,从RDB文件中恢复更快
RDB缺点:RDB是定时持久化,要是Redis挂了会丢失数据
数据集过大的时候,RDB持久化fork出来的子进程会抢夺CPU资源,导致主进程受到比较大的影响

AOF优势:
与RDB持久化相比,AOF持久化数据丢失更少,内存消耗更少(RDB方式执行bgsave会有内存拷贝)
AOF缺点:对于相同数量的数据集,AOF文件通常要大于RDB文件,AOF运行效率上往往会慢于RDB
Redis4.0版本可以开启混合持久化,同时启用RDB和AOF

Redis缓存

服务端的缓存应用有一些经典问题,在设计或者使用缓存的时候,这些问题都是需要确认并且解决的,不然很容易造成影响范围巨大的故障
缓存穿透 缓存击穿 缓存雪崩 缓存失效机制
缓存并发更新

缓存与数据库数据一致性的问题

缓存穿透,缓存击穿,缓存雪崩,缓存失效机制

缓存穿透

缓存穿透是指某条数据在缓存和数据库中都没有,然后会导致这条数据的每一次请求都直接落到数据库上,请求量大的情况可能会造成数据库崩溃,一般来说我们可以使用给不存在的key设定一个特定的value来避免缓存穿透,高阶一点的解决方案有布隆过滤器

缓存击穿

缓存击穿是指某条数据访问的非常频繁,在这个key失效的瞬间,会有大量的请求直接落到数据库,导致数据库崩溃,根据不同的场景,缓存击穿有以下几种解决方案
如果缓存数据不更新,可以选择合适的缓存失效机制来解决,比如把热点数据设置为永不过时
如果缓存数据更新不频繁,并且刷新缓存的耗时较短的情况下,可以采用Redis,Zookeeper,实现分布式互斥锁,或者本地实现互斥锁用于保证只有少量请求能请求到数据库并且重新构建缓存数据,其余的线程在锁释放后才能访问到更新后的缓存数据
如果缓存数据更新频繁或者刷新缓存耗时较长,可以利用定时线程在缓存失效之前主动构建缓存然后更新,或者也可以延后缓存的过期时间,以保证所有的请求都能访问到缓存数据

缓存雪崩

指大面积的缓存数据在某个时间点一起失效,会有大量的请求落到数据库,导致数据库崩溃,一般来说我们可以写入缓存数据库给失效时间加一个随机的值来保证不会出现大面积的缓存失效的情况,或者也可以选择合适的缓存失效策略,比如LRU,某些情况下也能避免出现大面积缓存失效的情况
保证缓存集群的高可用,乐意使用Redis Sentinel,Redis Cluster
保证本地内存+redis多级缓存,开启限流/降级策略
开启Redis持久化,缓存挂了之后重启可以从磁盘快速恢复数据
Redis内存优化策略

LRU

Least Recently Used 最近最少使用 赋予每一个缓存一个访问字段 用来记录一个页面自上次被访问以来所经历的时间t 根据t的最大值进行删除
Redis默认的内存优化策略
Redis采用定期删除+惰性删除策略

定期删除

redis每隔100ms检查是否有过期的key(检查采用随机的方式进行)
当数据很多的时候会出现超时却没有删除的数据

惰性删除

当用户获取key的时候,首先检查数据是否已经过了超过时间 已经超时就删除数据
出现同上相同问题

可以采取内存优化手段主动删除

上一篇:allure报告加标签进行优化


下一篇:socket聊天的业务逻辑