非关系型数据库学习笔记-Redis

1. NOSQL的特点

1.1 分类

键值对/半结构化文档(json)/图数据库

1.2 优点

  • 内存级数据库,读写效率高
  • 支持高并发(关系型数据库有磁盘IO瓶颈)
  • 支持海量数据(关系型数据库单表过大导致SQL查询性能低下)
  • 键值对/半结构化文档(json)/图数据库……的形式,数据无耦合,扩展性强

1.3 缺点

  • 不支持sql
  • 不支持事务
  • 无数据完整性约束
  • 功能不如关系型数据库完善(例如缺少多表关联查询)

2. Redis的5种基础数据类型 / 对象类型

2.0 redis 对象系统

redis基于其底层基础数据结构(参考第3节)建立了一个对象系统。作为键值对数据库,redis中的每一个键值对都包含一个键对象和一个值对象。对象的结构体redisObject如下所示:

typedef struct redisObject {
    unsigned type:4;        // 类型
    unsigned encoding:4;    // 编码
    int refcount;           // 引用计数
    // ...
    void *ptr;              // 指向底层实现数据结构的指针
    // ...
    unsigned lru:22;        // 最后一次被命令程序访问的时间
} robj;

其中的type类型属性一共有五种类型,分别是字符串、列表、字典、集合和有序集。对于一个键对象而言,其类型永远是字符串;对于值对象,可以通过TYPE命令查看其类型。
encoding编码属性记录了本对象采用的底层数据结构类型,每种redis对象至少都有两种编码,会根据数据的类型和规模自动切换,提高了效率。
refcount引用计数可以用于内存回收机制,当一个对象被创建或被引用时,计数器增加1;不再被引用时则计数器减1,当引用计数变为0时内存释放。设计思想上类似于C++的智能指针,但由于redisObject不存在嵌套使用,故不会出现C++智能指针中的循环引用问题。
此外,redis出于节省内存的考虑,支持对象的共享机制,即不同的键对象可以指向同一个redisObject作为值对象,此机制也依赖于引用计数。例如,redis在初始化时,会直接创建0~9999的整数字符串对象,以备将来通过对象共享机制使用。
lru记录一个对象最后一次被命令程序访问的时间,如果服务器设置了相应的内存回收机制,则可以根据此成员变量找到空转时间较高的键,并将他们释放。

2.1 字符串 String

二进制安全的字符串,可以用来存数字,数组乃至图像。

2.1.1 底层实现

int或简单动态字符串(SDS)。
若字符串保存的是可以用long类型表示的整数值,那么字符串对象的编码encoding会被置为REDIS_ENCODING_INTptr会由void * 类型改为long类型,并直接将这个整数值保存在ptr中。(注意:浮点数不能用这种方式,需要当做字符串存)
保存字符串时,虽然底层数据结构都采用SDS,但是仍然分为两种不同的编码。当字符串长度小于39字节时,编码置为REDIS_ENCODING_EMBSTR,此时会在内存上用一块连续的区域存放redis对象结构体redisObject和SDS结构体sdshdr,此方法可以减少内存分配与释放的次数,且可以充分利用缓存机制,一次性加载对象和数据;当字符串长度大于39字节时,编码置为REDIS_ENCODING_RAW,此时redis对象结构体和SDS结构体会分别创建。
需要注意的是,redis对于embstr编码没有写任何的修改程序,所以需要修改时会自动将其转换为raw编码。

2.1.2 使用场景

  • 由于redis的单线程模型性质,可以存数字用做计数,例如粉丝数等。

2.2 哈希表 hash

键值对哈希表,其值本身也是键值对结构,即整体结构是:键-字段-值(key-field-value),一个key下可以存储多2^31条field-value字段/值。

2.2.1 底层实现

有压缩列表(ziplist)和字典(dict)两种实现。
默认的空白哈希表采用ziplist,当某个键或值的长度达到阈值(默认64字节),或者ziplist的节点个数达到阈值(默认512个),结构会转换为dict。
当采用ziplist实现,插入新的键值对时,总是在压缩列表的尾部先推入一个保存了键(field)的压缩列表节点,再推入一个保存值(value)的压缩列表节点。当采用dict实现,字典中的每个键(field)和值(value)都是SDS。

2.2.2 使用场景

  • 存结构化对象数据,即“成员变量-值”的键值对。例如购物车场景,key是用户id,field是商品id,value是商品数量

2.3 列表 list

元素列表,可以在左右两端进行插入和弹出操作;可以通过索引下标获取某个元素(例如,LINDEX mylist 0获取mylist正向第一个元素)或某个范围内的元素列表

2.3.1 底层实现

有压缩列表(ziplist)和双端链表(list)两种实现。
默认的空白列表采用ziplist,当尝试新增的string大小达到阈值(默认64字节),或ziplist节点数量达到阈值(默认512个)时,底层数据结构转换为list。在使用list作为底层实现时,其每一个链表节点的数据对象都是SDS。

2.3.2 使用场景

  • 做时间线、消息队列:左进右出。
  • 做数据分页:比如评论区分页,lrange命令,获取某个范围内评论

2.4 集合 set

自动去重的无序集合,支持交集并集差集等集合运算。
由于无序性,不能通过索引下标获取元素。

2.4.1 底层实现

有整数集合(intset)和字典(dict)两种实现。
由插入集合的第一个元素决定其初始实现类型,若此元素可以被表示为Long long(即是一个整数),则为intset;否则是dict。对于一个intset类型的set,当集合元素数量达到阈值(默认512个),或者尝试插入一个不能被表示为long long的数据时,转换为dict。在使用dict作为底层实现时,其每一个键的数据对象都是SDS,值都是NULL。

2.4.2 使用场景

  • 标签功能
  • 利用其去重特性和集合运算特性,存放好友列表,实现共同好友功能。

2.5 有序集 zset

有序、自动去重的集合,每个集合元素需要保存一个分值(score)作为排序的依据,按照score升序排列。
形式上有一点类似于hash,key-field-value对应有序集合的key-member-score

2.5.1 底层原理

有压缩列表(ziplist)和跳表+字典(skiplist + dict)两种实现。
由插入有序集的第一个元素决定其初始实现类型,当member长度小于阈值(默认128个),则设置为ziplist,否则为skiplist。对于一个ziplist类型的zset,当节点数量达到阈值,或者尝试新增一个member大小达到阈值(64字节)的数据时,结构转换为skiplist。
当采用ziplist实现,插入新的集合元素时,总是在排序的位置先推入一个保存成员(member)的压缩列表节点,再推入一个保存分值(score)的压缩列表节点,两个节点在内存上连续;
当采用skiplist+dict实现,skiplist每个节点的object属性保存成员,score属性保存分值,按分值升序排序;dict则作为所有成员到分值的映射,每个字典成员的field保存成员,value保存分值。通过字典,可以使查找给定成员的分值的复杂度变为O(1)。skip和dict通过指针的形式共享成员和分值的内存。
在zset中,成员总是SDS,score总是double类型的浮点数。

2.5.2 使用场景

  • 存放直播间用户列表,使用score排序做排行榜。

3. Redis的内部数据结构

整体来看,分为4种普通内部数据结构(SDS, list, dict, skiplist)和2种内存映射数据结构(intset, ziplist)。在数据规模较小时用后两者节省内存。

3.1 简单动态字符串 Simple Dynamic String, SDS

动态长度的字符串,其结构体维护了已使用的空间大小和已分配但未使用的空间大小;数组类型是char,可以把这些空间大小认为是字符长度。

struct sdshdr {
    int len;
    int free;
    char buf[];
}

与C字符串类似,字节数据buf中的字符串会以空字符'\0'结尾,此空字符不会计入已使用空间大小len,也不会被计入free

3.1.1 SDS相较于C字符串的优势

  1. 由于维护了长度len,相较于C字符串,SDS可以在O(1)时间复杂度下计算strlen;
  2. 修改字符串时能够预先检查已分配空间是否充足并进行额外空间分配,从而解决缓冲区溢出的问题。
  3. 修改字符串长度时需要的内存分配次数更少。C字符串每次修改长度时都需要重新调整内存以适应新的长度,而SDS实现了空间预分配和惰性空间回收机制来降低内存分配的次数,从而提高性能。空间预分配指在对SDS进行扩容时,会多分配一部分空间作为余量;惰性空间回收机是指在缩短SDS时,不立刻回收空间,而是将空出来的部分作为未使用空间计入free中。
  4. 二进制安全(包括SDS的所有API在内),可以用来存数字,数组乃至图像。而C字符串只能存放某种编码下的文本,且会出现以第一个空字符作为字符串结尾的问题。

3.1.2 SDS在Redis内部功能的应用

  • redis中所有键值对的键
  • 做缓存,AOF缓冲区、客户端的输入缓冲区

3.2 链表 list

redis的链表是双端、双向、无环链表,内部数据可以重复,可以通过索引下标获取元素。
节点结构体如下:

typedef struct listNode {
    struct listNode *prev;              // 前驱指针
    struct listNode *next;              // 后继指针
    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;

头结点包含指向链表头尾节点的指针,头尾的操作都是O(1)复杂度;保存了当前链表长度,故有着O(1)的长度计算复杂度;由于是链表,有着O(n)的查找复杂度。
由于值类型是void指针,且可以自定义复制、释放和比对函数,故redis的链表是多态的,一个链表可以保存各种不同类型的值。

3.2.1 链表在redis内部功能的应用

  • 作为列表的底层结构之一
  • 保存输入的命令
  • 实现发布与订阅功能

3.3 字典 dict

用哈希表实现,字典结构体dict包含两个哈希表指针,0号是主要使用的哈希表,1号只有在rehash时才启用,定义如下:

typedef struct dict {
    dictType *type;     // 保存了一组操作特定类型键值对的函数,例如哈希算法
    void *privdata;     // 保存传递给上述操作函数的可选参数
    dictht ht[2];       // 哈希表(2 个)
    int rehashidx;      // rehash索引,值为-1表示rehash未进行
} dict;

哈希表表头dictht保存了当前哈希表存放的节点个数和哈希表的大小(哈希桶大小),用于计算装载因子。结构体定义如下:

typedef struct dictht {
    dictEntry **table;          // 哈希表数组
    unsigned long size;         // 哈希表大小(桶个数)
    unsigned long sizemask;     // 哈希表大小掩码,用于计算索引,总是等于size - 1
    unsigned long used;         // 已有节点数量
} dictht;

redis哈希表内部采用拉链法解决冲突,其哈希表节点dictEntry结构体中保存了下一个节点的指针。为了速度考虑,新的节点总是被添加到链表头,从而得到O(1)复杂度。

typedef struct dictEntry{
    void *key;                  // 键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;                        // 值
    struct dictEntry *next;     // 拉链法的下一节点
} dictEntry;

3.3.1 rehash条件

负载因子即保存节点数量/哈希桶数量。当开启了自然rehash时,负载因子 >= 1即会触发自然rehash;无论是否设置,当保负载因子达到阈值(默认5)时强制触发。
当redis使用子进程进行后台持久化(执行BGSAVE或者BGREWRITEAOF命令)时,自然rehash会暂时关闭。
当负载因子小于0.1时,触发哈希表收缩。

3.3.2 渐进式rehash

rehash的过程即为将当前的1号哈希表分配一个更大的空间,然后将0号表的内容逐步拷贝到1号表,完成后清理0号表,再互换两个哈希表指针。
拷贝过程是渐进式的,当rehash开始后,将rehashindex设置为0,此后每执行一次添加/查找/删除/更新时,都会将0号表上rehashindex索引上的所有内容拷贝到1号表,最后将rehashindex递增。rehash结束后,将rehashindex重新置0.
此外,redis服务器还有一个常规任务执行rehash,在固定的毫秒数内尽可能地拷贝数据。
需要注意的是,rehash的过程中,所有的查找、删除操作都要访问0、1两个表,即0号表没有找到数据则去1号表里查找;而添加操作则直接插入1号表。

3.3.3 字典在redis内部功能的应用

  • redis作为KV数据库,其基础的KV功能就是用字典实现的,由一个被称为键空间(key space)的字典存放着所有的键对象和值对象,键空间的键即redis数据库的键,是字符串对象;键空间的值即数据库的值,是五种基础数据类型(对象)中的任一种。
  • 作为哈希的底层结构之一

3.4 跳表 skiplist

跳表是一种设置了多级分层索引的链表,从上到下记录的节点越来越多,且下一层永远包含上一层的节点,最下层是包含所有节点的原始链表。
查找时从最上层开始,定位左右区间的两个节点,然后进入下一层,如此迭代。查找的平均时间复杂度是O(logN),最坏复杂度O(N),性能与AVL、红黑树类似,且具有实现简单的优势。
跳表节点结构体如下:

typedef struct zskiplistNode {
    struct zskiplistNode *backward;         // 后退指针
    double score;                           // zset排序依据的分值
    robj *obj;                              // 成员对象
    struct zskiplistLevel {                 // 层
        struct zskiplistNode *forward;      // 前进指针
        unsigned int span;                  // 跨度
    } level[];
} zskiplistNode;

每个“层”包含一个指向下一个节点的指针和跨度,跨度记录了到下一个节点的距离,层数越高跨度越大。此外,在查找一个节点中累加经过的所有节点的跨度就可以得到节点在跳表中的排位。
每次构造新的跳表节点时,会由幂次定律(power law,越大的数出现概率越小)生成一个1~32间的值作为新节点的层数。redis中幂次参数ZSKIPLIST_P 设置为0.25,这样上一层的节点数大约是其下一层的1/4,从概率的角度来说,32层的跳表已经能够容纳2^64个节点,已经能满足大部分场景。
对象指针都是指向SDS,对于跳表节点来说,每个节点指向的成员对象必须是唯一的,而分值可以相同。分值相同时,顺序由成员对象的字典序决定。

跳表表头保存了头尾指针、节点数量和最大层数等信息,结构体如下:

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;    // 头尾指针
    unsigned long length;                   // 节点数量
    int level;                              // 最大层数
}

3.4.1 跳表在redis内部功能的应用

  • 作为有序集的zset的底层结构之一
  • 在集群节点中用作内部数据结构

3.4.2 跳表比起红黑树等平衡二叉树的优势

  • 在查询性能类似的前提下,实现简单
  • 范围查询性能更高,AVL必须中序遍历,跳表定位到符合范围的第一个节点后可以直接沿着最下层顺序访问
  • 插入/删除性能更高,AVL插入删除后必须调节子树,而跳表只需要调节前后节点

3.5 整数集合 intset

底层实现是一个数组,内部元素不重复有序,由于其有序性,插入复杂度是O(N),查找复杂度O(logN)。结构体如下:

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

根据encoding的取值,intset的编码可以设置为int8_t、int16_t和int32_t三种,但是contents还是声明为int8_t,取出数据需要根据实际的编码确定位宽。
当插入新元素的位宽大于当前位宽时(例如当前是int8,新插入了一个int16),数组会进行升级,即内存重分配,并对现有元素进行位宽拓展和拷贝,完成升级后再进行新数据的插入。升级机制使得intset更灵活,也更能节省内存。此外,intset不支持降级操作。

3.6 压缩列表 ziplist

是由连续内存块组成的顺序型数据结构,这种存储方式可以尽可能利用空间,在一块内存上紧凑地存放不同长度的数据。
一个压缩列表可以包含多个节点(Entry),每个Entry可以保存一个整数或是长度有限的字节数组,节点由如下几个部分组成:

  1. previous_entry_length 记录前一个节点的长度,如果前一个节点小于254字节,则本项长度为1字节,足以存放长度;否则本项长度为5字节,且第一个字节会被设置为0xFE作为标志,剩下4个字节用来保存实际长度
  2. encoding 记录本节点保存内容的编码和长度。
  3. content 保存节点的数据值,可以是一个整数或者字节数组。

由于每个节点记录了自己的长度和前一个节点的长度,故可以方便地访问上一节点,支持从后向前的遍历。

3.6.1 连锁更新问题

由于previous_entry_length根据前驱节点长度来变换长度的性质,插入或删除节点时可能导致连锁更新问题。比如在一串大小为250-253字节的数据前插入一个大于等于254字节的数据,会导致这一串数据都要进行previous_entry_length的拓展;或者在一串大小为250-253字节的数据前删除了一个数据,而新的前一节点大小大于等于254字节,同样也会导致连锁更新。若要进行N个节点的连锁更新,每次空间分配的最坏复杂度为O(N),连锁更新的复杂度是O(N^2)。
实际上,连锁更新出现的概率并不是很高,在ziplist中插入与删除节点的平均时间复杂度还是O(N),最坏O(N^2)。

4. Redis相较于其他工具的优势

4.1 对比MySQL

作为内存数据库,redis避免了极其耗时的磁盘IO,天生高性能、高并发

4.2 对比C++ map

各个高级语言所支持的键值对数据结构只能用来做本地缓存,在多实例的场景下,各个节点存在缓存一致性问题;而redis支持分布式缓存,各个实例可以共有一份缓存数据,具有一致性。

4.3 对比memcached

  1. memcached没有持久化功能,断电数据丢失,redis有持久化,可以将数据保存到磁盘。
  2. memcached只有string类型,redis支持更丰富的数据类型。
  3. memcached没有原生的分布式支持,若要实现必须在客户端使用一致性哈希等分布式算法;而redis有原生的主从复制模式,可以较容易地实现读写分离。
  4. memcached的value最大1MB;redis可以512MB
  5. memcached是多线程、非阻塞IO;redis是单线程,通过非阻塞IO多路复用处理多个客户端请求(最后这条主要作为区别,不一定是优势)对于数据量较小的情况,redis的单线程模式可以避免线程切换的开销,性能更高。

5. Reactor模式与Redis为什么快?

5.1 Reactor模式

一种常见的高性能服务器开发模式,见于redis、netty等软件。
reactor模式是一种事件驱动机制,主要由两个部分组成:Reactor和Handler,Handler以非阻塞方式处理与IO有关的实际工作;而Reactor负责监听和分发事件,以IO多路复用机制监听多个socket,响应IO事件,并把IO分发给不同Handler进行处理。

5.1.1 单reactor单线程模式

整个进程都是单线程。若reactor收到一个新的连接建立IO事件,则创建一个handler对象处理该事件;若reactor收到的IO事件不是连接建立,则使用此前已创建的对应handler进行处理。
这种结构优点是简单,没有多线程等问题;缺点是handler处理期间整个进程无法响应其他事件,性能低;且若handler崩溃或者死循环则问题更严重。
使用本模式的例子有:redis主线程(6.0之前)

单reactor单线程

5.1.2 单reactor多线程模式

reactor仍然是单线程,但是handler并不包含处理业务的代码,只是在调用read读取数据后,交给线程池worker进行业务处理,工作线程将响应结果返还给handler,handler再调用send将响应结果交给client。
这种结构可以充分利用多核cpu的多线程能力,但是reactor仍旧是单线程,单一线程负担了所有事件的监听和响应,在高并发场景下容易成为性能瓶颈。
使用本模式的例子有:redis主线程(开启6.0引入的多线程IO后,但不标准,详见5.2、6.4节)

单reactor多线程

5.1.3 主从reactor多线程模式

这种结构设置了两个Reactor线程,mainReactor线程只负责处理建立新连接的IO事件,而后将完成建立的连接交给SubReactor线程,SubReactor负责监听这些已建立的连接,将这些连接上的IO事件分发给Handler进行处理。Handler依然将业务处理交给线程池。
这种结构可以解决单reactor模式由一个线程处理连接事件和读写事件的缺点,将耗时的IO读写事件分给SubReactor线程,从而提高服务器的响应性能
使用本模式的例子有:NGINX,netty

主从reator模式

5.2 redis为什么快?

  1. 绝大多数都是内存操作,且使用了高效的数据结构;
  2. 主线程采用单线程模型,没有线程切换的开销;
  3. 采用了Reactor模式处理IO事件,即非阻塞IO+IO多路复用。在6.0版本以前,redis是单reactor单线程模式;6.0版本以后引入了多线程IO,在该模式下工作则是一个不标准的单reactor多线程模式,其不标准的地方在于其"handler"只以多线程的方式进行IO、读取客户端的命令并解析,而命令的执行工作还是由主线程执行。

redis的单线程reactor模型:

redis的单线程模型

redis 6.0开启多线程IO后的不标准多线程模型(详见6.4):

redis的多线程模型

参考

https://jishuin.proginn.com/p/763bfbd58a63
https://strikefreedom.top/archives/multiple-threaded-network-model-in-redis

6. Redis的线程模型

6.1 Redis的单线程指的是什么?

从整个系统的角度来看,即使是远古版本的redis也是多线程的,其持久化、集群同步等功能都由独立于主线程外的另外线程执行;4.0版本后redis还加入了惰性删除(Lazy Free)机制(在3.x版本中,如果删除一个值对象巨大的键——例如包含了数十万条field-value的哈希键——则del操作的执行往往会导致主线程卡顿,而惰性删除机制通过异步执行耗时过长的删除命令解决了这个问题),这也是一个基于多线程的功能。
一般来说,讨论redis的线程模型都是关注其主线程提供的服务,包括网络IO事件处理和命令的执行。只考虑主线程时,在6.0版本之前redis是真正意义上的单线程模型,其网络IO和命令执行都由同一个主线程完成;6.0版本后,redis引入了全新的多线程IO机制,支持多个IO的并行化(但是默认关闭),而命令执行等其他主线程功能则依旧保持单一线程。

6.2 Redis为什么采用单线程模型

  • 多线程有线程切换成本;
  • 多线程模型必然涉及到并发读写,必须引入锁等线程同步机制,底层数据结构必须支持线程安全,这都增加了系统复杂度且导致性能损耗;
  • 单线程实现简单,可维护性高
  • 数据库的大多数请求都是IO密集型而非CPU密集型。具体到redis上来说,作为内存数据库,redis执行数据操作的速度相当快,性能瓶颈并不在CPU处理速度上,而是在网络IO上,故没有充分的必要性将整个系统做成多线程。

6.3 Redis 6.0引入多线程IO的原因

随着互联网技术发展,QPS要求越来越高,Redis的单线程网络IO瓶颈越来越明显。常见解决方案是对数据进行分区并采用分布式架构的多个redis服务器,或者数据分区+单机运行多个redis实例。但是这样的方案显然存在缺陷,比如分区后热点数据的读写依然是问题,比如数据倾斜(某些节点/实例获得的数据量远大于其他,导致需要重新分配)。

6.4 Redis 6.0开启多线程IO后的执行逻辑

6.0引入的并行IO机制若开启(且设置的线程数大于1),则会按照如下逻辑运行:
主线程负责接收客户端建立连接的请求,且在每轮循环最开始时,通过多路复用获取可读的socket,放入全局等待队列;
队列满后,主线程将等待队列中的socket通过Round Robin(RR,即轮询的naive负载均衡算法)算法分配给一组IO线程,IO线程与被分配的socket绑定;
IO线程并行处理socket的读取、命令解析任务,此期间主线程阻塞等待;
主线程按照队列顺序,单线程顺序执行解析出来的命令;
IO线程并行处理socket的写回任务,此期间主线程阻塞等待;
主线程清空全局等待队列,解除IO线程和socket的绑定,等待客户端请求,回到循环最开始;

执行逻辑

参考

https://juejin.cn/post/6989109527886954527
https://blog.csdn.net/weixin_43767015/article/details/120450983
https://blog.csdn.net/m0_55389447/article/details/122963912
https://blog.csdn.net/adminpd/article/details/122934938

7. Redis的数据过期策略

7.1 键空间与过期字典

redis数据库结构体redisDb包含两个字典类型成员:

typedef struct redisDb {
    // ...
    dict *dict;         // 键空间
    dict *expires;      // 过期字典
    // ...
} redisDb;

字典dict被称为键空间(keyspace),存放着redis数据库中所有的键对象和值对象,键空间的键即redis数据库的键,是字符串对象;键空间的值即数据库的值,是五种基础数据类型(对象)中的任一种。
字典expires被称为过期字典,存放着设置了过期时间的键和它们的过期信息,过期字典的键是键空间中的键,值是一个long long类型的整数,以毫秒精度记录该键的过期时间戳。需要注意的是,存放在过期字典中的键的redisObject中指向底层数据结构的指针ptr会与键空间中的对应键共用(也就是共用底层数据结构SDS),用以节省内存。

可以通过EXPIRE/PEXPIRE命令设置键的生存时间,或是通过EXPIREAT/PEXPIREAT命令设置键的过期时间戳,此时对应的过期信息键值对会被记录到过期字典expires中。可以通过PERSIST命令移除键的过期时间,此时对应的过期信息键值对会被从expires中移除。

7.2 过期键的删除策略

redis主要采用定期删除和惰性删除两种方式。
定期删除:每隔一定时间(默认100ms),随机抽样过期字典中的20个键,将其中达到过期时间的键进行删除;若过期的超过了25%,则再抽样20个,循环操作。
惰性删除:访问数据时检查过期时间,若已过期,删除数据并返回null。

7.3 内存淘汰机制

上述两个过期键删除机制也不一定能保证过期数据的内存全都释放,当出现内存不足时需要用到内存淘汰机制,不过默认设置下内存淘汰是关闭的,不会淘汰任何数据,只会在剩余内存无法执行新的写入操作时报错。
redis支持的内存淘汰范围分为volatile(只淘汰过期字典中的键)和allkeys(可以淘汰所有键)。在volatile策略下,淘汰对象的选择算法分为LRU、LFU(least frequently used,使用频率最低)、随机和挑选即将过期的键;在allkeys策略下,淘汰对象的选择算法分为LRU、LFU和随机。

TODO: 7.4 AOF、RDB和复制功能对过期键的处理

8. 缓存三大问题

问题 情形 解决方案
缓存穿透 访问不存在的key,导致大量请求穿透到数据库 缓存空对象,设置较短的过期时间;布隆过滤器(将数据哈希到bitmap中的k个点,如果不都是1则认为不存在。有会误判的缺点)
缓存击穿 热点数据的缓存到期失效 热点数据缓存不过期;分布式互斥锁,缓存失效时只有拿到锁才可以访问数据库
缓存雪崩 大量缓存集中失效 过期时间随机浮动;多级缓存;熔断、限流保护数据库,流量过大时返回“系统拥挤”

9. Redis持久化

redis作为内存数据库,需要有持久化机制将数据写入硬盘,用来防止异常掉电或者重启后的数据丢失。Redis有RDB持久化和AOF持久化两种方案,这两种方案的共性是:都存在fork出子进程的方案,子进程将数据写入到临时文件,完成写入后,用临时文件替换上一次的快照文件。

9.1 RDB持久化

9.1.1 关于RDB持久化

Redis DataBase持久化,又称快照持久化。RDB持久化是将内存中的数据部分写入磁盘上的二进制快照文件,这一性质使得快照文件除了用于数据恢复以外,还可以用于创建有相同数据的Redis副本。RDB文件支持压缩,以LZF算法压缩所有RAW编码的字符串对象,在增大持久化与数据恢复所需时间的前提下减少文件大小。
由于RDB持久化一次性保存所有数据的性质,其快照文件更新频率通常低于AOF持久化,故当Redis启动服务进行数据库状态还原时,只要AOF持久化功能未关闭,则优先使用AOF持久化文件。

9.1.2 RDB持久化的触发

RDB持久化可以通过手动或者自动触发。
手动触发包括输入save(在主线程的单线程执行命令阶段进行RDB持久化,会导致阻塞其他客户端的请求,一般不推荐)、bgsave(fork出子进程,并行执行持久化);
自动触发需要输入带参的save命令,save m n表示在m秒内数据库存在n次修改时,自动触发bgsave。在默认情况下,redis内部包含一组save m n的配置。

9.1.3 RDB持久化的优缺点

优点:

  • RDB快照文件是二进制方式储存,大小紧凑、恢复速度快。

缺点:

  • BGSAVE对时间、内存的消耗都较大,无法做到实时持久化;
  • 若执行RDB持久化时崩溃,会丢失上一次成功RDB以后的数据。

9.2 AOF持久化

9.2.1 关于AOF持久化

AOF(append only file),是目前的主流持久化方式,在AOF文件中保存redis写命令来记录状态,需要恢复数据时则重新顺序执行AOF文件中记录的命令。默认是关闭的,修改redis.conf文件开启。相比起RDB,AOF文件是文本格式而非二进制格式;且由于大单次写入成本低,AOF持久化的实时性更好。

9.2.1 AOF持久化的工作流程

在执行完一个写命令后,redis会将该命令追加(append)写入AOF缓冲区中;每次redis主进程的事件循环结束前,依据设置的持久化策略判断是否将AOF缓冲区中的内容写入(write)和同步(fsync)到磁盘上的AOF文件里。持久化策略有三个类型,对应服务器配置appendfsync

行为
always 总是将缓冲区中的内容写入AOF文件并fsync
everysec 总是将缓冲区中的内容写入AOF文件,如果距离上次fsync时间超过一秒则执行fsync
no 总是将缓冲区中的内容写入AOF文件,不主动调用fsync,完全交由操作系统自行决定。

这三种方式与MySQL redo log file落盘的设置非常类似。

9.2.2 AOF文件的载入

由于redis的命令只能在客户端上下文中执行,所以在使用AOF持久化进行数据恢复时,redis会创建一个没有网络连接的伪客户端(fake client),逐条分析出AOF文件的写命令并交由该伪客户端执行。

9.2.3 AOF重写

AOF文件的大小会随着运行持续变大,不仅占用空间,还会使得AOF持久化的数据恢复时间无意义地增大。redis因此设计了AOF重写机制,当AOF文件的大小超过所设定的重写阈值时对AOF文件进行重写。实际上AOF重写的过程并不是分析和化简现有AOF文件,而是直接读取当前数据库中的键值对,对每条数据记录一条set命令(其他对象也是类似,例如对列表对象则直接使用RPUSH)。
进行重写时,父进程会fork一个带有父进程的数据副本的子进程,由子进程完成重写过程,在此期间父进程仍然可以处理其他命令。在子进程进行重写期间,父进程除了将新的AOF日志写入AOF缓冲区,还要写入一个独立的AOF重写缓冲区。当子进程完成重写后会以信号方式通知父进程,父进程在处理该信号的函数(会导致父进程阻塞)中会将AOF重写缓冲区的内容写入到新的AOF文件中,最后将新AOF文件重命名,完成对旧AOF文件的覆盖。

10. 缓存和数据库一致性问题

10.1 经典的cache-aside方案

  • 读:读数据时,先读缓存,若缓存未命中则读数据库,成功后将获得的数据放入缓存
  • 写:更新数据时,先更新数据库,成功后再删除缓存。

cache-aside

10.2 为什么cache-aside方案要在写的时候删除缓存?

实际上,也可以选择在写数据时先更新缓存而后再更新数据库,或是先更新数据库而后再更新缓存,但是这种方案在并发场景下存在显著的线程安全问题。例如,假设采用先更新数据后更新缓存的方式,此时有两个线程修改同一数据,由于网络等原因,实际线程执行顺序是这样的:

步骤 执行
1 线程1写库
2 线程2写库
3 线程2写缓存
4 线程1写缓存

这样,缓存与数据库就不一致了。为了解决此问题,只能引入加锁机制,这会使得系统变得复杂,且运行效率降低。因此,在写数据时直接删除缓存是稳妥的方案。通常情况下,删除缓存带来的副作用仅限于一次cache miss,更新成本不算高。

10.3 cache-aside方案的问题

在一些特殊情况下,删除缓存的策略依然会存在缓存一致性的问题,这里对进行分类讨论。
标准的cache-aside策略在更新数据时是先更新数据库,成功后再删除缓存;而倘若不采用标准方案,而是先删除缓存再更新数据库,那么当同时有读线程和写线程访问同一数据时,就会存在这样的问题:

步骤 执行
1 写线程删除缓存
2 读线程访问缓存,cache miss
3 读线程访问数据库获得旧值
4 读线程将旧值写入缓存
5 写线程将新值写入数据库

即使是采用标准的cache-aside策略,也会存在另一种问题:

步骤 执行
1 缓存恰好失效
2 读线程访问缓存,cache miss
3 读线程访问数据库获得旧值
4 写线程将新值写入数据库
5 写线程删除缓存(实际上缓存里也没有这个值)
6 读线程将旧值写入缓存

相对于先删缓存再更新数据库方案存在的脏数据问题,标准cache-aside策略存在的问题发生概率较低,只会在缓存过期时恰好有线程读写数据时发生。此外,如果删除缓存失败,也会导致缓存不一致。

10.4 延迟双删策略

为了解决上述问题,延迟双删策略被提出了。以标准cache-aside策略为例,引入延迟双删机制后,写数据的流程变为:
更新数据时,先更新数据库,成功后再删除缓存。经过1秒休眠后,再次删除缓存。(具体的休眠时间应当评估业务实际耗时进行调整)
从性能角度和避免缓存删除失败的角度来说,第二次删除应当设计成异步删除,并引入消息队列,当捕获到删除失败的异常时,再次向消息队列中加入一个删除任务。
这样一来,可以确保在读请求结束后,写请求可以删除读请求造成的缓存脏数据。

参考

https://zhuanlan.zhihu.com/p/147028497
https://note.dolyw.com/cache/00-DataBaseConsistency.html

11. redis实现分布式锁

SETNX(SET if Not eXsist)系列命令,获得锁。
https://segmentfault.com/a/1190000038988087

12. redis高可用实现

redis实现高可用主要有三种方式:主从复制、哨兵模式、redis集群

12.1 Redis主从复制

与MySQL自带的主从复制功能类似,redis提供了复制(replicate)功能,支持一主多从的复制模式。通过SLAVEOF命令可以开启主从复制功能。

12.2 哨兵模式

哨兵(sentinel)模式是redis的高可用性解决方案,由一个或多个哨兵实例组成的哨兵系统可以监视任意多个主服务器和他们下属的从服务器,当监视的主服务器下线并超过设定的时限后,哨兵系统会执行故障转移操作,选择其下属的某个从服务器作为新的主服务器来处理命令请求,并向其余从服务器发送新的复制指令,组成新的主从结构。
哨兵本质上只是运行在特殊模式下的redis服务器,其主事件循环的运行逻辑与正常模式下的redis不同。哨兵会与主从服务器都建立命令连接和订阅连接,通过命令连接发送信息,通过订阅连接接受信息。哨兵系统内的各个哨兵实例之间也会建立命令连接,但是不创建订阅连接。
哨兵实例每秒会向所有创建了命令链接的实例发送PING命令,根据回复判断实例是否在线;当发现某个主服务器回复超时后,哨兵认为该实例“主观下线”,此时需要向监视该主服务器的其他哨兵实例询问,确认其下线状态,收到足够多的下线判断后才认定该主服务器为“客观下线”状态。
确认主服务器客观下线后,监视该主服务器的各哨兵实例会选举出一个首领哨兵来执行故障转移操作,这个选举步骤实现了raft算法中的领头选举算法。

12.3 redis集群

哨兵模式归根结底仍然是主从复制模式的高可用性改良。在高并发场景下,主从复制模式相比起单机数据库在读性能方面得到了显著提高,但是系统的写性能依然受到主服务器性能上限的限制,存储能力也同理。为了扩展多机系统的写性能和存储性能,需要引入真正的分布式方案。
Redis集群通过分片(sharding)的方式进行数据共享,将集群的整个数据库分为16384个槽(slot),数据库的每个键都属于某一个槽,集群中的每个redis节点被指派(assign)处理某个或某些槽。当所有的槽都有负责处理的节点时,此redis集群进入上线状态(CLUSTER INFO命令返回的结果中,cluster_state项为ok)。通过键值的CRC-16校验和,再与16384进行按位与运算可以获得键值属于的槽的编号。
Redis集群也提供复制和故障转移功能,可以将某些节点设置为某一节点的从节点,主节点用于处理槽,从节点复制主节点的数据库,并在主节点下线时代替处理命令请求。

13.

知识共享许可协议
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇