Redis设计与实现---学习使用和深入理解Redis

Redis(缓存数据库)

redis入门

基础知识(跳过)

​ nosql:not only sql;1.高性能 2.易于扩展(数据间没有关系) 3.数据类型多样,不需要事先设计

​ NoSQL数据库类型:

​ 1.KV键值对:redis

​ 2.文档类型(bson和json):MongoDB

​ 3.列存储(分布式文件系统):HBase

​ 4.图关系型数据库(推荐系统):Neo4j

​ 快速入门:

​ Redis:远程字典服务 :Redis(Remote Dictionary Server ),即远程字典服务,是一个开的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API;redis使用C语言实现;

image-20210718104232519
安装和基础
Linux安装redis

​ 1.下载和解压:http://www.redis.cn/download.html;(下载tar.gz包,移动到**/usr/local/src/**对应目录,执行tar -zvxf redis-XXXXXX.tar.gz安装(在此之前安装gcc环境,见下面问题描述),make,最后make install确认;

​ make的时候可能遇到问题:https://blog.csdn.net/aaaPostcard/article/details/112468265

​ 2.redis默认安装在**/usr/local/bin/下,复制/usr/local/src/redis-XXX的redis.conf到一个新建在/usr/local/bin/**的文件夹(这里使用myconfig)下(主要时多环境配置),设置该配置文件为后台运行vim /user/local/bin/myconfig/redis.conf;设置里面的deamosize为yes;【里面还可以配置端口号等等】

image-20210718133810434

​ 3.运行: 回到/usr/local/bin, 启动redis-server -myconfig/redis.config【按自己的配置文件运行】

​ 4.测试连接和关闭: 运行redis-cli客户端;输入ping;关闭则直接输入shutdown

5.压力测试:运行redis-benchmark

​ 可以测试命令https://www.runoob.com/redis/redis-benchmarks.html

Redis基础知识

​ 1.默认有16个数据库;可以在客户端通过:select 号码(0-15);

​ 2.DBSIZE:当前数据库大小

3.Redis操作线程是单线程的,但是其将所有数据放在内存中,速度十分快,制约其速度不是CPU,没必要使用多线程;

redis数据库结构

image-20210718141442073

redis数据库使用C编写,整个数据库均使用key-value(数据结构)方式进行保存,其结构可以大致按如下:

  • redisServer(包含一个redisDB数组指针
    • redisDB(包含一个dict数组指针
      • dict(包含5大数据结构的key–string
        • value—redisObject记录了数据类型编码方式
          • 5个基本的对象(字符串、列表、集合、有序集合、hash表)对应的存储形势
            • Stringsds(带长度的字符串)、int(当是整数的时候,非整数通过字符串保存,不过也可以运算)
              • sds包括:raw(redisObject和SDS不相邻)和embstr(redisObject和SDS直接相邻)
            • list:在redis3.2后使用快表(之前使用压缩表或者双向链表)
              • quicklist:就是压缩表(快表的数据项)和双向链表(快表的节点)结合
            • set:intset(当元素均为整数且少于2^9次方个(即512个)时)或者hashtable
              • intset:专门用来保持int的集合,hashtable:字典
            • zset:有序集合,使用跳跃表或者压缩表
              • skiplist:跳跃表
              • ziplist:压缩表
            • hash压缩表或者字典实现
              • **ziplist:**压缩表
              • **hashtable:**字典
image-20211120223052717 image-20211120223140411

​ redis使用的是key-value键值对包括数据库同样如此;redis默认有16个数据库,也就是说有16个redisdb再redisServer数据结构中;上面俩图基本展示了redis的数据库组织方式;下面是redis数据库的数据结构:

redisdb

typedef struct redisDb {
    //数据库键空间,保存着数据库中的所有键值对
    dict *dict; 
    //所有过期时间字典
    dict *expires; 
    dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys; /* Blocked keys that received a PUSH */
    //所有监视key
    dict *watched_keys; 
    int id; /* Database ID */
    long long avg_ttl; /* Average TTL, just for stats */
    unsigned long expires_cursor; /* Cursor of the active expire cycle. */
    list *defrag_later; /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;

redisServer

struct redisServer {
    // ...
    // 一个数组,保存着服务器中的所有数据库
    redisDb *db;
    // 服务器的数据库数量
    int dbnum;
    //该字段struct内就是一对 int类型数据,保存save的俩个int设置;
    //记录了保存条件的数组,即RDB持久化中设置的BGSAVE触发条件
    struct saveparam *saveparams;
    //下面俩个字段共同实现持久化操作,dirty作为计数器计算上次持久化以来总共进行了多少次增上改操作,lastsave则是记录时间
    //上一次执行保存的时间,和下面字段共同记录持久化,lastsave属性是一个UNIX时间戳,记录了服务器上一次成功执行SAVE命令或者BGSAVE命令的时间
    time_t lastsave;
    //修改计数器;dirty计数器记录距离上一次成功执行SAVE命令或者BGSAVE命令之后,服务器对数据库状态(服务器中的所有数据库)进行了多少次修改(包括写入、删除、更新等操作)。
    long long dirty;
    
    //AOP缓冲区;sds数据结构是字符串,参考附录;由于都是保存命令自然就是字符串;
    struct sds aof_buf;
    // ...
};

数据类型

redis中数据的存储方式
对象
对象的结构体
typedef struct redisObject {
    // 类型 4bits
    unsigned type:4;
    // 编码方式 4bits
    unsigned encoding:4;
    // LRU 时间(相对于 server.lruclock) 24bits
    unsigned lru:22;
    // 引用计数 Redis里面的数据可以通过引用计数进行共享 32bits
    int refcount;
    // 指向对象的值 64-bit
    void *ptr;
} robj;// 16bytes
  • type:为下文将介绍到的5种数据类型
/*
 * 对象类型
 */
#define REDIS_STRING 0  // 字符串 string
#define REDIS_LIST 1    // 列表   list
#define REDIS_SET 2     // 集合   set
#define REDIS_ZSET 3    // 有序集 Zset
#define REDIS_HASH 4    // 哈希表 hash
  • encoding:编码方式深刻影响redis性能
/*
 * 对象编码
 */
#define REDIS_ENCODING_RAW 0            // 编码为字符串
#define REDIS_ENCODING_INT 1            // 编码为整数
#define REDIS_ENCODING_HT 2             // 编码为哈希表
#define REDIS_ENCODING_ZIPMAP 3         // 编码为 zipmap
#define REDIS_ENCODING_LINKEDLIST 4     // 编码为双端链表
#define REDIS_ENCODING_ZIPLIST 5        // 编码为压缩列表
#define REDIS_ENCODING_INTSET 6         // 编码为整数集合
#define REDIS_ENCODING_SKIPLIST 7       // 编码为跳跃表
数据类型编码对照

下图的list不再使用双端链表或压缩表,而是使用快表;

img
  • lru:记录最新一次操作的时间,供伪lru算法模范lru算法且能达到性能使用;
  • refcount:引用对象的数量,每个对象都有个引用计数,当引用计数为零时,对象就会被销毁,内存被回收
  • ptr:ptr 指针将指向对象内容 (body) 的具体存储位置
SDS
  • 专门用来存储字符串的数据结构
  • 是一个带记录字符串长度的数据结构,可以避免C语言的插入的时候溢出;
  • 根据SDS和RedisObject的是否相连可以分为俩种:raw和embstr
字典(hashtable)
image-20211111212522973
基础

字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构。

  • 字典由俩个dictht组成、操作函数及其参数、rehash记录组成;(换句话说字典就是一个可以不影响使用下解决hash表扩容的hash表)
  • dictht就是一个使用链地址法解决冲突的hash表;
  • 在redis中字典十分重要,redis数据库是基于字典实现的,在redis中字典使用hash表保存
    • dictEntry就是值节点

dictht

typedef struct dictht {
    // 哈希表数组,保存dictEntry数组,dictEntry数组存放真正的数据并使用链地址法解决hash冲突
    dictEntry **table;
    // 哈希表大小,记录table数组的大小
    unsigned long size;
    // 哈希表大小掩码,用于计算索引值
    // 总是等于 size - 1
    unsigned long sizemask;
    // 该哈希表已有节点的数量,即hash表中已有节点的数量(保存链地址中的节点)
    unsigned long used;
} dictht;

dictEntry结构如下:

typedef struct dictEntry {
    // 键,可以是指针或uint64_t整数、int64_t整数
    void *key;
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

下图为字典的示意图

image-20211111211500498

dict

typedef struct dict {
    // 类型特定函数,保存用于操作特定类型键值对的函数
    dictType *type;
    // 私有数据,特定类型函数的参数
    void *privdata;
    // 哈希表,dictht[0]:保存字典数据,一般字典只会用到dictht[0];但是需要rehash的时候就会将数据逐步转到扩展hash即:dictht[1]
    dictht ht[2];
    // rehash 索引,rehash是用于扩展哈希表
    // 用于记录当前rehash时赋值到哪一步,当处于非rehash 进行时,值为 -1;
    int rehashidx;
} dict;

type属性和privdata属性是针对不同类型的键值对,而创建多态字典而设置的;

type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。而privdata属性则保存了需要传给那些类型特定函数的可选参数。

初始时完整的字典如下:

image-20211111212522973

dictType

typedef struct dictType {
    // 计算哈希值的函数,字典使用hash保存自然需要计算哈希值
    unsigned int (*hashFunction)(const void *key);
    // 复制键的函数
    void *(*keyDup)(void *privdata, const void *key);
    // 复制值的函数
    void *(*valDup)(void *privdata, const void *obj);
    // 对比键的函数
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    // 销毁键的函数
    void (*keyDestructor)(void *privdata, void *key);
    // 销毁值的函数
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

hash表最重要是维持负载因子在合理范围;下面是ConcurrentHashmap实现rehash的基本要点,在设计思路上俩者基本没有重大区别

  • 我们在ConcurrentHashmap的学过其hash是通过两倍扩展保证每次扩容均不需要重新hash(其2倍扩展使得其插入位置只能是原桶位置或者扩展处的原桶位置;
    • redis也是使用两倍扩容机制(这里的两倍不是桶的数量,而是hash表元素的数量,大于等于每次扩容到桶的元素数量的两倍的2^n),所以这里就需要重新hash,这导致了redis的扩容期间的访问会比较复杂:(可能需要两次hash,分别对应h[0]和h[1])
      • 首先就是添加元素:需要进行两步:首先在redis的h[0]检查是否有该hashKey的元素,有着替换,没有这在h[1]添加新元素
      • 然后就是查找元素:先在h[0]查找,没找到后到h[1]操作;
      • 如果长期没有增删改查(空闲),会有一个定时任务进行rehash;
  • ConcurrentHashmap并不是一个线程完成所有扩容操作而是每次有线程进行增删改查的时候协助扩容;
    • redis尽管是单线程的,但是线程每次增删改查操作也会辅助实现扩容的一部分,换句话说俩者都是渐进式扩容
  • ConcurrentHashmap同样有一个辅助的hash表只有在扩容的时候有用(h[0]即常规的hash表、h[1]即扩容的时候已经复制的hash表),实现渐进hash的基础;
    • 前面说过redis同样借助一个辅助hash表实现渐进式扩容
  • redis和ConcurrentHashmap的区别主要在于redis可以缩容、需要重新hash,redis不存在链表树化的说法等
rehash

为了避免哈希表的负载因子过大或者过小,所以当字典中的负载因子超过合理范围时需要触发rehash

条件

  • 要扩展哈希表:哈希表的负载因子大于1且没有执行BGSAVE或BGREWRITEAOF命令;或者哈希表的负载因子大于5且正在执行BGSAVE命令或者BGREWRITEAOF命令
  • 需要收缩哈希表:哈希表负载因子小于0.1

方案

  • h[1]容量**:hash在进行扩展的时候为第一个大于等于**ht[0].used*2的2n
  • hash进行收缩的容量为第一个大于等于ht[0].used的2n**

要点1.为了保证服务的质量hash使用的是渐进性rehash,这一点在redis很多地方都有体现;2.redis的字典最终只会在h[0]中,也就是说rehash完成后,将h[0]的内存释放之后就会将h[1]的值赋给h[0]并重新申请h[1]3.渐进rehash的过程中同时存在h[1]和h[0]所有对于h[0]的删改查操作,会顺带把rehashidx值对应的hash中的所有数据复制到h[1]中,最后rehashidx+14.开始rehash的时候rehashidx为0完成rehash后就会将rehashidx重置为-1;5.为了确保h[1]只增不减不允许对于h[1]插入

渐进式rehash的过程

image-20211111222655095

键值对的插入

键值对插入在非rehash状态只会插入h[0]、rehash状态只会插入h[1];

总结:

·字典被广泛用于实现Redis的各种功能,其中包括数据库和哈希键。

·Redis中的字典使用哈希表作为底层实现,每个字典带有两个哈希表,一个平时使用,另一个仅在进行rehash时使用。

·当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。

·哈希表使用链地址法来解决键冲突,被分配到同一个索引上的多个键值对会连接成一个单向链表。

·在对哈希表进行扩展或者收缩操作时,程序需要将现有哈希表包含的所有键值对rehash到新哈希表里面,并且这个rehash过程并不是一次性地完成的,而是渐进式地完成的。

api

image-20211111223300511

跳跃表

​ Redis就会使用跳跃表来作为有序集合键的底层实现

​ 跳跃表是一种为了提升链表查找速率的一种数据结构其实现思想基于索引;跳跃表为了实现快速查找,其数据是有序的,每个节点可以指向多个“下一节点”,形成跳跃;在大部分情况下,跳跃表的查找效率可以和平衡树相媲美O(log(n))时间复杂度,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。下面是redis的跳跃表示意图:

image-20211112144236338

  • 跳跃表核心就是(二分)跳跃(这时查询效率为log(n))即每一层的上一层索引数目是下一层的1/2,下图为非常完美的二分跳跃(但是我们不可能每次删除或者插入的时候就调节各层索引维持二分跳跃)
    • image-20220102152156671
  • 通过概率论,抛硬币决定是否需要增加在本层添加索引(或者增加索引层);(理论上原始链表是必然会插入的、L1索引插入的概率是1/2,那么L2的就是1/2*1/2,……),这样概率是就是一个接近满足二分的跳跃表(查询的时间复杂度接近log(n);)
  • 跳跃表相较于平衡二叉树显然更加容易实现而且对于并发支持也更好,但是其稳定性不如平衡二叉树;

redis中跳跃表数据结构:

zskiplistNode

数据表节点

typedef struct zskiplistNode {
    // 层,上图的中LX;一般而言层数越多,可跳跃跨度和密度越大,越接近有序数组的查询速度
    //层是很特别的,并不是每个节点的层都是一样的,redis使用幂次定律(power law,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”。
   //每个层都有一个指向表尾方向的前进指针(level[i].forward属性),用于从表头向表尾方向访问节点。这句话前进节点并不是一定是level[0],但是每个节点必然存在一个这样的指针
    //
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度
        unsigned int span;
    } level[];
    // 后退指针,便于查询
    struct zskiplistNode *backward;
    // 分值,用来成员对象在确定跳跃表位置,节点按分值从小到大来排序
    // 分值相同的跳跃表按hash值由小到大排序
    double score;
    // 成员对象,保存的数据;它指向一个字符串对象,而字符串对象则保存着一个SDS值
    robj *obj;
} zskiplistNode;

1)迭代程序首先访问跳跃表的第一个节点(表头),然后从第四层的前进指针移动到表中的第二个节点。

2)在第二个节点时,程序沿着第二层的前进指针移动到表中的第三个节点。

3)在第三个节点时,程序同样沿着第二层的前进指针移动到表中的第四个节点。

4)当程序再次沿着第四个节点的前进指针移动时,它碰到一个NULL,程序知道这时已经到达了跳跃表的表尾,于是结束这次遍历。

跨度

  • 层的跨度(level[i].span属性)用于记录两个节点之间的距离:

  • 两个节点之间的跨度越大,它们相距得就越远。

  • 指向NULL的所有前进指针的跨度都为0,因为它们没有连向任何节点。

初看上去,很容易以为跨度和遍历操作有关,但实际上并不是这样,遍历操作只使用前进指针就可以完成了,跨度实际上是用来计算排位(rank)的:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。

zskiplist

头节点

typedef struct zskiplist {
    // 表头节点和表尾节点
    structz skiplistNode *header, *tail;
    // 表中节点的数量,表的表头节点不计入其中
    unsigned long length;
    // 表中层数最大的节点的层数(表头除外)
    int level;
} zskiplist;

API

image-20211118202834633

双向链表

​ redis的链表是一个双向链表,同时表头记录了基本数据;下面是redis双向链表数据结构:但是由于双向链表有一个严重的问题就是双指针会消耗内存空间如果数据仅仅是int8的话将极大的浪费内存,所以redis使用了快表代替双向链表,但是快表同样是基于双向链表和后面的压缩表;

list

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;

listNode

typedef struct listNode {
    // 前置节点
    struct listNode * prev;
    // 后置节点
    struct listNode * next;
    // 节点的值
    void * value;
}listNode;

image-20211112202606222

Redis的链表实现的特性可以总结如下:

  • 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。

  • 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。

  • 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。

  • 获取长度时间复杂度为O(1);

intset

​ 整数集合(intset)是集合键的底层实现之一,其会按有小到大存放数据,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。

typedef struct intset {
    // 编码方式,记录context数据保存的数据的位数是int16、int32、int64中哪一个
    uint32_t encoding;
    // 集合包含的元素数量
    uint32_t length;
    // 保存元素的数组
    int8_t contents[];
} intset;

​ contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项

​ encoding编码方式,为了提升内存的利用率redis尽可能的节省内存,所以对于不同大小的数组数据尽可能选小的编码方式;

image-20211113092515365

升级:所谓的升级就是当要插入一个超过本编码方式的整数的时候,redis对于所有的数据进行升级为更高(更大)编码方式的数据;例如,若原来使用的是int16编码,插入32768大于int16(32767)所以要将int16升级为int32;升级的过程:

​ 1.首先申请原来数组length+1长度的新intX空间;(扩容);

​ 2.将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变;

​ 3.最后将引起升级的数据插入合适的位置;我们知道引起升级的数据要么是小于全部数据(负数)要么大于全部数据(正数);

特点

  • redis不支持降级。一旦对数组进行了升级,编码就会一直保持升级后的状态。
  • 整数集合的升级策略有两个好处,一个是提升整数集合的灵活性,另一个是尽可能地节约内存
压缩列表

​ 当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构一个压缩列表可以包含任意多个节点(entry)每个节点可以保存一个字节数组或者一个整数值

struct zlist {
    //记录压缩表总的大小,查询分配内存或者zlend位置时使用
    uint32_t zlbytes;
    //记录表尾节点据离起使位置相差多少节点,通过这个可以直接找到表尾节点
    uint32_t zltail;
    //记录压缩表包含的节点数量,超过uint16时将需要遍历整个压缩表才能确定
    uint16_t zllen;
    //列表节点
    entry* entryX;
    //用于标记压缩节点的末端
    uint8_t zlend;
}
struct entry {
    //以字节为单位,记录了压缩列表中前一个节点的长度。previous_entry_length属性的长度可以是1字节或者5字节:
    uint8_t previous_entry_length;
    //记录了节点的content属性所保存数据的类型以及长度
    encoding;
    //节点值可以是一个字节数组或者整数
    content;
}

image-20211119161730208

连锁更新

​ 前面说过,每个节点的previous_entry_length属性都记录了前一个节点的长度:

·如果前一节点的长度小于254字节,那么previous_entry_length属性需要用1字节长的空间来保存这个长度值。

·如果前一节点的长度大于等于254字节,那么previous_entry_length属性需要用5字节长的空间来保存这个长度值。

现在,考虑这样一种情况:在一个压缩列表中,有多个连续的、长度介于250字节到253字节之间的节点e1至eN,如图7-11所示。

img

图7-11 包含节点el至eN的压缩列表

因为e1至eN的所有节点的长度都小于254字节,所以记录这些节点的长度只需要1字节长的previous_entry_length属性,换句话说,e1至eN的所有节点的previous_entry_length属性都是1字节长的

这时,**如果我们将一个长度大于等于254字节的新节点new设置为压缩列表的表头节点,那么new将成为e1的前置节点,**如图7-12所示。

img

因为e1的previous_entry_length属性仅长1字节,它没办法保存新节点new的长度,所以程序将对压缩列表执行空间重分配操作并将e1节点的previous_entry_length属性从原来的1字节长扩展为5字节长。

现在,麻烦的事情来了,e1原本的长度介于250字节至253字节之间,在为previous_entry_length属性新增四个字节的空间之后,e1的长度就变成了介于254字节至257字节之间,而这种长度使用1字节长的previous_entry_length属性是没办法保存的。因此,为了让e2的previous_entry_length属性可以记录下e1的长度,程序需要再次对压缩列表执行空间重分配操作,并将e2节点的previous_entry_length属性从原来的1字节长扩展为5字节长。

正如扩展e1引发了对e2的扩展一样,扩展e2也会引发对e3的扩展,而扩展e3又会引发对e4的扩展……为了让每个节点的previous_entry_length属性都符合压缩列表对节点的要求,程序需要不断地对压缩列表执行空间重分配操作,直到eN为止。,即发生大规模连锁更新

除了添加新节点可能会引发连锁更新之外,删除节点也可能会引发连锁更新

因为连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N^2)。

要注意的是,尽管连锁更新的复杂度较高,但它真正造成性能问题的几率是很低的

  • 首先,压缩列表里要恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见;

  • 其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响:比如说,对三五个节点进行连锁更新是绝对不会影响性能的;

因为以上原因,ziplistPush等命令的平均复杂度仅为O(N),在实际中,我们可以放心地使用这些函数,而不必担心连锁更新会影响压缩列表的性能。

快表

quicklist:考虑到链表的附加空间相对太高,prev 和 next 指针就要占去 16 个字节 (64bit 系统的指针是 8 个字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。redis3.2后续版本对列表数据结构进行了改造,使用 quicklist 代替了 ziplist 和 linkedlist.

quickList 是 zipList 和 linkedList 的混合体,它将 linkedList 按段切分,每一段使用 zipList 来紧凑存储,多个 zipList 之间使用双向指针串接起来。https://www.cnblogs.com/hunternet/p/12624691.html

img

api

image-20211120213501380

quicklist

初始节点

typedef struct quicklist {
    //quicklistNode头指针
    quicklistNode *head;
    //quicklistNode尾节点
    quicklistNode *tail;
    // 所有ziplist数据项的个数总和。
    unsigned long count;        /* total count of all entries in all ziplists */
    //quicklist节点的个数。
    unsigned long len;          /* number of quicklistNodes */
    //ziplist大小设置,存放list-max-ziplist-size参数的值。
    int fill : QL_FILL_BITS;              /* fill factor for individual nodes */
    //节点压缩深度设置,存放list-compress-depth参数的值。
    unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
    //书签数量
    unsigned int bookmark_count: QL_BM_BITS;
    //	typedef struct quicklistBookmark {
    		//书签指向的快排列表的节点
    //		quicklistNode *node; 
    		//书签名字
    //		char *name; 
	//	} quicklistBookmark;
    //
    quicklistBookmark bookmarks[];
} quicklist;

quicklistNode

链表节点

typedef struct quicklistNode {
    //上一个node节点
    struct quicklistNode *prev; 
    //下一个node
    struct quicklistNode *next; 
    //保存的数据 压缩前ziplist 压缩后压缩的数据
    unsigned char *zl;
    //表示zl指向的ziplist的总大小;如果ziplist被压缩了,那么这个sz的值仍然是压缩前的ziplist大小。
    unsigned int sz;             /* ziplist size in bytes */
    //表示ziplist里面包含的数据项个数。这个字段只有16bit。
    unsigned int count : 16;     /* count of items in ziplist */
    //表示ziplist是否压缩了(以及用了哪个压缩算法)。
    //目前只有两种取值:2表示被压缩了(而且用的是LZF压缩算法),1表示没有压缩。
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    //是一个预留字段。
    //本来设计是用来表明一个quicklist节点下面是直接存数据,还是使用ziplist存数据,或者用其它的结构来存数据(用作一个数据容器,所以叫container)。但是,在目前的实现中,这个值是一个固定的值2,表示使用ziplist作为数据容器。
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    //当我们使用类似lindex这样的命令查看了某一项本来压缩的数据时,需要把数据暂时解压,这时就设置recompress=1做一个标记,等有机会再把数据重新压缩。
    unsigned int recompress : 1; /* was this node previous compressed? */
    //redis自动化程序,对于我们没有作用
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    //其它扩展字段。目前Redis的实现里也没用上。
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

如果到这里,不使用压缩列表(即将压缩列表换直接保存数据)显然就是普通的双向链表;回顾压缩链表,如果就是用压缩列表的话对于push、insert,find等操作就要重新malloc和更新数据平均时间复杂度为O(n)所以需要尽可能控制n大小使其能接受O(n)时间复杂度;

需要说明的是压缩压缩链表会使得压缩表不能被读出,需要先解压在读取;

//quicklistLZF结构表示一个被压缩过的ziplist。
typedef struct quicklistLZF {
    //表示压缩后的ziplist大小。
    unsigned int sz; /* LZF size in bytes*/
    //是个柔性数组(flexible array member),存放压缩后的ziplist字节数组。
    char compressed[];
} quicklistLZF;
5大基本数据类型
1.String
基础

最常用的redis数据类型,Redis 的字符串是动态字符串,是可以修改的字符串,内部结构实现上类似于 Java 的
ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配,当字符串长度小于 1M 时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间。需要注意的是字符串最大长度为 512M

sds

​ string底层是字符串是SDS【Simple Dynamic String】,它的结构是一个带长度信息字节数组。使用sds是为了保证 1.api安全【避免缓冲区溢出】;

2.获取字符串长度时间复杂度为O(1),而不用全表扫描【redis会频繁获取字符串长度,因为如果要拼接、追加字符串等都要先获取字符串长度避免缓冲区溢出】;

3.减少malloc和字符串赋值的次数

4.允许保存二进制文件,我们知道c语言字符串数组是以’\0’结尾的,尽管redis在是现实时为了使用c类库也是以’\0’结束,但是redis的sds边界却不是’\0’而是记录的字符串长度;

//sds类型:https://blog.csdn.net/weixin_35390390/article/details/84824242
//3个字节
struct SDS<T> {
	T capacity; // 数组容量,uint8_t-uint64_t
	T len; // 数组长度,uint8_t-uint64_t
	byte flags; // 标记位,标志为5种sds哪一种
	char[] content; // 数组内容
}
编码方式:embstr和raw

普通字符串redis使用embstr或者raw保存可以自增的字符串redis可以使用int或者long保存,但是标记都是int,浮点型数据也是作为普通字符串保存;Redis 会根据当前值的类型和长度决定使用内部编码实现,换句话说只要可以自增的就用int标记,否则根据长度选择embstr或者raw标记;

对于embstr和raw有显著区别:

image-20211111160500419

embstr :是专门用于保存短字符串(44个字符以内)的一种优化编码方式; embstr 编码则通过调用一次内存分配函数来分配一块连续的内存空间,空间中包含 redisObject 和 sdshdr(动态字符串)两个结构,两者在同一个内存块中。因为Redis没有为embstr编码的字符串对象编写任何相应的修改程序(只有int编码的字符串对象和raw编码的字符串对象有这些程序),所以embstr编码的字符串对象实际上是只读的对embstr编码的字符串对象执行任何修改命令时,会先将对象的编码从embstr转换成raw,然后再执行修改命令。所以embstr编码的字符串对象在执行修改命令之后,总会变成一个raw编码的字符串对象

raw:保存长字符串(44个字符以上),调用两次内存分配函数来分别创建 redisObject 和 sdshdr 结构(动态字符串结构);

redis认为申请长度大于64字节为长字符串,redis的redisObject、sds、'\0’占用了16+3+1个字节,剩下最多保存44字节,所以要使用embstr 注意字节数;

除了raw和embstr ,如果想使用int,则只需要是纯数字即可;

api

image-20211111205540670

image-20211111205624611
常用命令:
1.定义:set name value
		mset name value name value#可以批量创建
2.获取长度:strlen key
3.末尾增加:append key value#若key不存在,则为新建key-value
4.截取:GETRANGE name from end#【由0开始,包括end】
5.加某一值、减某一直:INCRBY name number【int类型】; DECR name number【int类型】
					INCRBYFLOAT name number【包括浮点型】 
6.替换某一位置的值:SETRANGE name number value
7.定义时设置过期时间:setex key seconds value#设置
				  setnx key seconds vlaue#如果不存在才有效,如果存在不生效,返回0;
8.保存对象:mset name:{id}:属性 属性值 name:{id}:属性 属性值
9.getset:组合
2.hash

哈希对象的编码可以是ziplist或者hashtable。

编码选择

当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码:【可通过hash-max-ziplist-value和hash-max-ziplist-entries配置,下面是未配置情况】

  • 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节

  • 哈希对象保存的键值对数量小于512个;

不能满足这两个条件的哈希对象需要使用hashtable编码。注意升级为hashtable后不会降级为ziplist(毕竟不可能每次删除key value就检查一次是否可以降级吧)

常用命令:

​ 相当于java的map集合【hkey (String)-- key(Object) --value(Object)】(如果key、value都是String则相当于上面的String的集合)

1.定义:hset hasname key value [key value……]
		hmset hashname key value [key value……]#弃用
2.获取:hget hashname key
		hgetall hashname#获取所有key value
		hkeys hashname#获取所有key
		hvals hasname#获取所有value
3.删除:hdel hashname value
4.获取长度:hlen hashname
5.判断是否存在指定字段:HEXISTS hashname key
6.加某一值、减某一直:hincrby hasname key number
					hdecrby hasname key number
3.list

【本质就是链表,常用来模拟栈、队列阻塞队列】;

Redis 的列表结构常用来做异步队列使用。将需要延后处理的任务结构体序列化成字符串塞进 Redis 的列表,另一个线程从这个列表中轮询数据进行处理。

编码格式
  • 为了提高内存利用率和提高响应速度,不再使用压缩表或者链表作为底层数据结构,而是使用快表;
  • image-20220102163136746
image-20210718152400767 image-20210718145447286
常用命令
#########默认从头部放,头部出(栈)
1.定义:LPUSH listname value
		RPUSH listname value
2.获取:LRANGE listname from end #【end为到-1则为末尾】
3.放到列表尾部:LPUSH listname value righr
4.移除:LPOP listname#移除第一个元素
		RPOP listname#移除最后一个元素
5.通过下标获取值:LINDEX number
6.获取列表长度:LLEN listname
7.移除指定value:lrem listname number value#number为移除的数量 
8.截取元素:LTRIM listname from end#只保留这一部分元素
9.将列表最后元素移动到另外一个列表:rpoplpush fromListname toListname
10.根据元素下表更新值:lset listname number value#只有该列表存在、且该下表也存在有效
11.在某一值前、后插入:linsert listname BEFORE|AFTER value 
4.set

集合对象的编码可以是intset或者hashtable。

编码转换

当集合对象可以同时满足以下两个条件时,对象使用intset编码:

  • 集合对象保存的所有元素都是整数值;

  • 集合对象保存的元素数量不超过512个。

不能满足这两个条件的集合对象需要使用hashtable编码(不能退级为intset)。

常见命令

和java的set的value类似无序、不重复(String的集合)

1.定义:sadd setname value……
2.取值:smembers setname
3.判断值是否存在:sismember setname value
4.查看set中元素个数:scard setname
5.移除指定元素:srem setname value
6.随机获取一个元素:srandmember setname
7.随机删除一个:spop setname
8.将一个指定的值移动到另外的set中:smove fromSetname toSetname value
9.集合间并交叉:叉:SDIFF setname1 setname2
			并:SUNION setname1 setname2
			交:SINTER setname1 setname2
5.Zset(sorted sets,有序集合)
编码格式
  • 使用压缩表或者跳跃表作为编码格式;

  • 当有序集合对象可以同时满足以下两个条件时,对象使用ziplist编码:

    • 有序集合保存的元素数量小于128个;
    • 有序集合保存的所有元素成员的长度都小于64字节;

    不能满足以上两个条件的有序集合对象将使用skiplist编码。

常用命令
  • zadd:添加
  • zrange及zrangebyscore:输出指定范围元素
  • zrem:删除
  • zrank:获取元素索引
  • zRevXXXXXX:倒叙
1.定义:zadd setname key score(排名依据)
		zadd setname key value key score
2.获取:
3.排序获取:
	zrangebyscore setname -inf +inf with scores #通过score排序输出所有,并且带上score的值
	zRevRangeByScore key +inf -inf#通过倒叙输出所有,不带score
	zrangebyscore setname -inf 100#通过score排序输出-00到100,并且带上score的值
4.移除指定元素:zrem setname key
5.查看set中元素个数:zcard setname
6.判断区间的元素的个数:zcount setname numberMin numberMax 
3大特殊数据类型
1.geospatial地理位置(基于Zset)
使用

image-20210718191740273

1.GEOADD:添加经纬度和城市的对应
	GEOADD:china:city 维度 经度 名称
2.GEODIST:给定俩点距离
	GEODIST:china:city  名称1 名称2
3.GEOHASH:返回给定点的字符串,减少经度损失
	
4.GEOPOS:获取城市的维度、经度
	GEOPOP china:city 名称
5.GEORADIUS:通过给定维度 经度 半径查询周围点(这里是城市)信息
	GEORADIUS china:city 维度 经度 半径 withdis count 3#可以通过count 限制数量; withdis显示距离
6.GEORADIUSBYMEMBER:位于指定位置查询周围点的信息
	GEORADIUSBYMEMBER china:city 名称 半径
	7.可以通过Zset的操作操作这个(例如删除)
实现

其实现是通过二分划分,然后再根据二进制合并出字符串;

1.将纬度(-90,90)分成两个区间,(-90,0)和(0,90),如果目标纬度落在左边区间则记为0,否则记为1;再将目标纬度所在的那个区间在通过二分法分成两个相等的区间,如果目标纬度落在左边区间则记为0,否则记为1,以此类推。
2.将上面的两个二进制按照“偶数位放经度,奇数位放纬度”的原则,从0位开始数起,合并成一个二进制。
3.二进制转换成十进制,按照从左往右,每5位划分成1个组,如果最后一组如果不足5位就用0补齐到5位。
4.十进制转base32字符串
    hash的字符串长1-12位,对应精度的级别1-12级。字符串越长,位置越精确。

下面是hash长度和精度对照关系

image-20220102171627371

​ 由于是通过不断二分所以最后的结果将是一个正方形的区域认为是相同区域,如果只计算这个区域可能不能得到最近的结果,所以一般会计算除了本区域外的其他八个相邻的方块

2.Hyperloglog【运行容错的基数统计】
  • 基数**:即集合中不重复的元素;**
  • Hyperloglog常用来做基数统计(误差率0.81%);
  • redis基于HLL算法实现的HyperLogLog结构。 redis中统计数组大小设置为,hash函数生成64位bit数组,其中14位用来找到统计数组的位置,剩下50位用来记录第一个1出现的位置,最大位置为50,需要 位记录。
image-20210718194938510
1.定义:PFadd hyperloglogname value value
2.统计基数:pfcount hyperloglogname
3.合并:pfmerge hyperloglogname0  hyperloglogname1 hyperloglogname1
3.Bitmap
image-20210718195834389

1.位存储

2.保存只有俩个状态的数据结构【典型的是操作系统的文件系统的位示图】

1.定义:setbit bitmapname offsetname 0/1#offsetname表示位名字
2.获取某一个位的状态:getbit bitmapname offsetname
3.统计1的个数:bitcount bitmapname from end
命令

所有命令:http://www.redis.cn/commands.html

常用命令:

1.清空当前数据库:FLUSHDB
2.清空所有数据库:FLUSHALL
3.查看所有key:keys *
4.切换数据库:select (数据库号码)
5.查看当前数据库大小:DBSIZE
6.判断key是否存在:EXISTS name【key名字】
7.移除key:move name
8.设置key过期时间(4种方式):EXPIRE key seconds【秒】
								PEXPIRE【毫秒】、EXPIREAT【设置timestamp过期时间,单位m】、PEXPIREAT【同上,单位毫秒】
	移除过期时间 PERSIST key
	检查过期时间剩余: TTL key 同理可以PTTL获取毫秒级
9.set设置key-value:set name value
10.获取value:get name
11.查看key数据类型:type name
12.查看key过期时间:ttl name
13.模糊查找:   
14.查看某一key的底层编码形式:OBJECT ENCODING name
15.查看某一键空闲时间 object idletime
16.AOF重写:BGREWRITEAOF

*.常用公共命令
DEL命令、EXPIRE命令、RENAME命令、TYPE命令、OBJECT命令
*.常用命令
·SET、GET、APPEND、STRLEN等命令只能对字符串键执行;
·HDEL、HSET、HGET、HLEN等命令只能对哈希键执行;
·RPUSH、LPOP、LINSERT、LLEN等命令只能对列表键执行;
·SADD、SPOP、SINTER、SCARD等命令只能对集合键执行;
·ZADD、ZCARD、ZRANK、ZSCORE等命令只能对有序集合键执行;

模糊查找:https://blog.csdn.net/zhaipengfei1231/article/details/80819454

三个通配符:*、?、[];其中*表示统配任意多个字符,?表示统配一个字符、[]:表示只能统配括号内的字符;

定时任务:https://cloud.tencent.com/developer/article/1536852

非分布式:Quartz

分布式:Elastic-Job、xxl-job

redis发布订阅

(简答的消息队列)

image-20210719143515468

简单应用:实时信息、在线聊天室、订阅和发布

订阅:psubscribe psubscribesName1 p2
发布:publish p2 ok

Lua脚本

  • lua是一个简易的C语言编写的、一个小巧的脚本语言。
  • Redis通过Lua脚本支持复杂(组合)redis语句的原子操作。
  • 可以复用**。客户端发送的脚本会永久存在redis中**,这样其他客户端可以复用这一脚本,而不需要使用代码完成相同的逻辑。

Lua基础语法

https://www.runoob.com/lua/lua-tutorial.html

基础语法
  • Lua是动态语言,不需要显示表示变量类型;Lua具有八种数据类型:nilbooleannumberstringuserdata【可以认为是结构体】function【函数】threadtable【hash表、数组等】
    • image-20220321154256647
  • 变量赋值
    • 普通变量赋值
      • 支持同时多个赋值,例如 a,b,c = 1,2,这样 a\b\c分别赋值为(1,2,nil【nil自动填充】)
      • 支持赋值的时候运算
      • 支持其他交换,例如 x,y = y,x,就会直接交换x、y
    • table赋值table = {};
      • 【然后就像普通变量一样操作table[“x”],table[1],table[“yyy”] = “aaa”,111,222
      • 另外 table.x 等价于table[“x”]
      • 支持多维数组,即**table={} table[i]={}**
      • 在 Lua 索引值是以 1 为起始,redis也这么操作
  • 运算
    • 算数运算:多了乘幂运算整除运算
      • image-20220321155453481
    • 关系运算符:注意不等于是 ~=
      • image-20220321155553439
    • 逻辑运算符: and or not
    • 其他运算符
      • image-20220321155723310
--注释
--[[
多行注释
--]]

--输出
print("aaa")

--变量
	--局部变量
do
	local b = b;
end
	--全局变量
a = 'aaa'
	--使变量a失效,释放内存
a = nil
b = {};
b[1] = 11
b[2] = 12

--运算
if(a==nil AND b[1]==11) then
    ---xxxx
elseif(b[1]==b[2] OR a==nil) then
    ---xxxxx
    --xxxxx
else
    -----
end
流程控制
  • 循环:支持goto和break

    • for支持泛型 for 循环通过一个迭代器函数来遍历所有值,类似 java 中的 foreach 语句。

      • days = {"Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"}  
        for i,v in ipairs(days) do  
            print(v) 
        end 
        
    • for同样使用三元操作

      • --exp1:初始值;exp2:小于等于exp2;exp3:每次递增exp3(不指定的话默认为1
        for i=exp1,exp2,exp3 do  
            ---- 
        end  
        
    • while循环同样和java类似

      • condition = false
        while (condition) do
            break
        end
        
  • goto语句

    • condition = false
      while (condition) do
          goto aaaa
      end
      
      ::aaaa:: print("goto来这里")
      
  • 函数

    • function

    • --获取1到x的和(x小于等于1直接返回)
      function fff(x)
          local i = 0;
          if(x>1) then
              i = x+fff(x-1)
          else
              return x
          end
          return i;
      end
      --允许可变参数数量;下面返回m+可变参数数量个数
      function fff(m,...)
      	local a = {...}
          return m+#a
      end
      --使用
      a = fff(2)
      b = f(3,2,1,1,1,1) --返回3+5,即8
      

Redis的Lua支持

  • EVAL使用 Lua 解释器执行脚本EVAL script numkeys key [key …] arg [arg …]

  • SCRIPT LOAD :将脚本 script 添加到脚本缓存中,但并不立即执行这个脚本。 返回sha1码

  • EVALSHA:**根据给定的 sha1 校验码,执行缓存在服务器中的脚本。 ** 和SCRIPT LOAD结合使用;

  • SCRIPT EXISTS:校验指定的脚本是否已经被保存在缓存当中。

  • SCRIPT FLUSH:用于清除所有 Lua 脚本缓存。

  • SCRIPT KILL:用于杀死当前正在运行的 Lua 脚本,当且仅当这个脚本没有执行过任何写操作时,这个命令才生效。

  • image-20220321204207320
  • call和pCall: redis.pcall() 出错时并不引发(raise)错误,而是返回一个带 err 域的 Lua 表(table),用于表示错误;call则脚本会停止执行,并返回一个脚本错误

EVAL命令
EVAL script numkeys key [key …] arg [arg …]
--eval 标示开启eval命令
--script lua语句块
--numkeys key的数量
--key 0 numkeys个key ;一般用作参数
--arg 其他参数

--例子
eval "if(redis.call('zCard',KEYS[1]) ~=0) then           												return redis.call('zRangeByScore',KEYS[1],0,100,'WITHSCORES')								else return nil end" 1 key

(键空间)事件通知

  • 在Redis2.8后允许开启监听,监听KEY的事件(增删改查、过期等);
    • 使用redis.conf中的notify-keyspace-events或者使用CONFIG SET命令来开启通知。
  • 键空间通知允许客户端订阅发布/订阅频道,以便以某种方式接收影响Redis数据集的事件。

使用

--监听0号数据库、mykey所有删除事件
PUBLISH __keyspace@0__:mykey del
--监听0号数据库mykey的del事件
PUBLISH __keyevent@0__:del mykey

事务

1.没有原子性;【单条命令有原子性,表现在事务中,一条命令运行时有问题【其能入队】尽管其不能执行,但不影响其他命令;该事务一条命令编译时出错【其不能入队】队伍中所有命令不执行;】

​ 2.没有隔离级别;

​ 3.redis事务是按:顺序、一次、排他的执行;

​ 执行过程:1.开启事务(multi) 2.命令入队(写命令) 3.执行事务(Exec)

​ 4.放弃事务:【会自动放弃队列中所有事务】:DISCARD;

image-20210718201146236

​ 1.异常:

​ 编译异常:不能入队

​ 运行异常:能入队,不能正常执行

image-20210718202056439

​ https://www.jianshu.com/p/47fd7f86c848

1.悲观锁:

​ 悲观锁使用的是占位的思想;事务开启前占位、结束后释放,为了避免意外中断导致锁不释放设置超时时间,并发冲突事务需要获取到占位才能继续执行;

​ SET lock_key key NX PX 5000;

​ nx:只有没有该key才有效

​ px:时间单位

​ 5000:5000px

2.乐观锁:

​ 1.watch,如果watch变量事务开启到结束期间,值改变,事务不允许提交;

​ 1.加上锁;

​ 2.开启事务;

​ 3.执行事务前,检查锁;

​ 4.unwatch释放锁;

image-20210718202427495

Jedis

​ 作用:java连接redis开发工具,和jdbc连接数据库一样;

​ 1.下载jedis依赖

        <dependency>
            <groupId>com.github.antelopeframework</groupId>
            <artifactId>antelope-jedis</artifactId>
            <version>1.1.5</version>
        </dependency>

​ 2.new jedis(主机号,端口号);

​ 3.利用new的jedis对象执行上面的命令(事务)

@SpringBootTest
public class TestRedis {
    @Test
    public void test(){
        Jedis jedis = new Jedis("127.0.0.1",6379);//打开连接
        Transaction transaction =  jedis.multi();//开启事务
        transaction.set("name","wsp");//输入命令,并入队
        transaction.sadd("names","11");
        try {
            transaction.exec();//提交事务
        } catch (Exception e){
            transaction.discard();//异常则回滚
        } finally {
            transaction.close();//关闭事务
            jedis.close();//关闭连接
        }
    }
}

spring-boot整合redis

image-20210719165138330

​ 1.spring-boot利用spring-data连接所有数据库源

​ 简单使用:

​ 1.导入依赖

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

​ 2.使用配置文件配置属性

#例如配置主机
spring
 redis
  host:xxxxx

​ 3.调用RedisTemplate进行操作(测试例子)

@SpringBootTest
public class TestSpringBootAtRedis {
    @Qualifier("redisTemplate")
    @Autowired
    private RedisTemplate template;


    @Test
    public void test() {
        System.out.println(template.opsForSet().add("name", "k1"));
        template.opsForSet().pop("name");
    }
}
/**
1.操作字符串
	opsForValue().add()
2.操作集合....
	opsForXXX().
3.常用操作直接开始:
*/
image-20210719111936601
spring-boot整合redis入门

​ 1.保存对象需要序列化:默认为jdk序列化方式;

​ 自己设置序列化方式替换原有RedisTemplate

@SuppressWarnings("all")//屏蔽警告
@Configuration
public class RedisConfig {
    /**
     * 自定义redisTemplate
     * @param factory redis工厂
     * @return redisTemplate
     */
    @Bean
    public RedisTemplate<String,Object> getRedisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String,Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        //json解析容易对象
        Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.DEFAULT);
        objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.WRAPPER_ARRAY);
        serializer.setObjectMapper(objectMapper);

        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        // value值的序列化采用fastJsonRedisSerializer
        template.setValueSerializer(serializer);
        template.setHashValueSerializer(serializer);
        // key的序列化采用StringRedisSerializer
        template.setKeySerializer(stringRedisSerializer);
        template.setHashKeySerializer(stringRedisSerializer);
        template.afterPropertiesSet();
        return template;
    }
    @Bean
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
        StringRedisTemplate template = new StringRedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }
}

​ 2.封装工具类减少代码量

1.发展理论

redis理论

平稳运行基础

介绍

主要包括C语言的命令状态检查、对象管理(内存释放)、内存共享(共享对象):

  • 命令检查见下文
    • 由于c并不能抛出异常,一旦出错将中断运行,所以redis需要进行类型检查
  • 对象管理
    • redis对象包括:创建、活跃、死亡、释放
    • redis的对象通过引用计数确定对象是否存活
    • 通过专门的回收线程回收死亡的对象(并释放内存)
  • 共享内存
    • redis为了提高内存利用率,对于0-99999(可设置)的int可以共享内存;
    • 对象和字符串将不能共享(主要考虑俩者确定是否相等时间复杂度比较高)

redis命令检查

redis的命令可以分为公共命令和特定命令公共命令是所有redis对象都可以执行的、可以用来操作任何redis对象,另一种命令只能对特定类型的键执行;由于c并不能抛出异常,一旦出错将中断运行,所以redis需要进行类型检查

类型检查的实现

执行一个类型特定的命令之前,Redis会先检查输入键的类型是否正确,然后再决定是否执行给定的命令。类型特定命令所进行的类型检查是通过redisObject结构的type属性来实现的

·在执行一个类型特定命令之前,服务器会先检查输入数据库键的值对象是否为执行命令所需的类型,如果是的话,服务器就对键执行指定的命令;

·否则,服务器将拒绝执行命令,并向客户端返回一个类型错误。

对于LLEN命令来说:

图8-18 LLEN命令执行时的类型检查过程

其他类型特定命令的类型检查过程也和这里展示的LLEN命令的类型检查过程类似。

多态命令的实现

Redis除了会根据值对象的类型来判断键是否能够执行指定命令之外,还会根据值对象的编码方式,选择正确的命令实现代码来执行命令

列表对象有ziplist和linkedlist两种编码可用,其中前者使用压缩列表API来实现列表命令,而后者则使用双端链表API来实现列表命令,如果我们对一个键执行LLEN命令,那么服务器除了要确保执行命令的是列表键之外,还需要根据键的值对象所使用的编码来选择正确的LLEN命令实现

·如果列表对象的编码为ziplist,那么说明列表对象的实现为压缩列表,程序将使用ziplistLen函数来返回列表的长度;

·如果列表对象的编码为linkedlist,那么说明列表对象的实现为双端链表,程序将使用listLength函数来返回双端链表的长度;

借用面向对象方面的术语来说,我们可以认为LLEN命令是多态(polymorphism)的,只要执行LLEN命令的是列表键,那么无论值对象使用的是ziplist编码还是linkedlist编码,命令都可以正常执行。

实际上,我们可以将DEL、EXPIRE、TYPE等命令也称为多态命令,因为无论输入的键是什么类型,这些命令都可以正确地执行。

DEL、EXPIRE等命令和LLEN等命令的区别在于,前者是基于类型的多态——一个命令可以同时用于处理多种不同类型的键,而后者是基于编码的多态——一个命令可以同时用于处理多种不同编码。

image-20211118220633352

其实简单来说就是会根据数据类型和编码方式的类型选择正确的函数执行

内存释放

​ C语言显然不能自动垃圾回收,所以需要手动释放内存;redis定义了垃圾回收机制,为此redis的保存的一系列对象会经历四(三)个生命周期,创建—活跃—死亡—释放(该生命周期是指redis对象死亡后,redis并不会立刻释放内存,而是等待回收线程回收),redis对象有引用标记记录引用数量,一旦没有引用将进入死亡状态等待回收;

对象的引用计数信息会随着对象的使用状态而不断变化:

  • 在创建一个新对象时,引用计数的值会被初始化为1;

  • 当对象被一个新程序使用时,它的引用计数值会被增一;

  • 当对象不再被一个程序使用时,它的引用计数值会被减一;

  • 当对象的引用计数值变为0时,对象所占用的内存等待释放。

共享对象

redis开启时会自动创建[0-9999]作为共享对象共享对象引用标记refcount为INT_MAX(即2^31 - 1),后面有新的引用指向该对象或者该引用释放都不会改变共享对象标记;尽管共享对象很节约内存但是对于字符串等复杂对象redis并不共享因为字符串检查可共享就需要O(N)的时间复杂度,为了共享,每次插入数据都进行检查显然不合适的如果共享对象是包含了多个值(或者对象的)对象,比如列表对象或者哈希对象,那么验证操作的复杂度将会是O(N^2)。;当然对于[0-9999]检查只需要O(1)说的复杂度当然可以接受

redis核心

线程模型

img

配置文件优化

​ redis.conf是什么:redis启动的配置文件

​ redis.conf可以配置什么:

1.单位

image-20210719133748633

2.包含配置文件(配置文件导入、模块配置,下图的下一个)

image-20210719134557941

3.网络配置(主机、端口、TCP设置、集群)

image-20210719134741303

4.通用配置(进程为前台还是守护进程 日志文件 数据库大小 )

image-20210719134827664

5.快照(RDB配置持久化规则)

image-20210719134941130

6.主从复制策略

image-20210719135035418

7.安全(数据库密码)

8.客户端设置和内存满了之后处理方式

9.AOF【appendonly】持久化配置(默认不开启AOF持久化)

redis的持久化

​ redis的持久化有RDB和AOF俩种方式;下面是俩种方法的基本介绍:

  • RDB(Redis DataBase)
    • RDB使用二进制文件保存Redis所有的数据,通过覆盖的方式进行数据保存
    • RDB异步方式是使用fork(子进程)+CopyOnlyRead(内存快照)的方式进行保存
      • fork的子进程和主进程共享内存数据,但是内存数据该内存数据只能读不能写(所以主进程如果需要写则需要重新对该内存页copy然后再写
      • CopyOnlyRead(内存快照)是通过将数据页设置为只读
    • RDB阻塞方式(即手动SAVE命令)显然就更加简单,直接通过主进程完成Redis数据读取和保存,此时将阻塞所有操作;
    • 由于每次需要对整个内存数据库进行保存而且使用的是内存快照方式所以(其可能丢失数据将比较多):
      • 自动保存的save命令没起作用期间;(还未达到SAVE要求,则期间所有的数据均丢失)
      • 在进行内存快照期间(即fork的子进程在保存数据期间),如果出现意外,新的数据将丢失;(将SAVE设置太小,例如SAVE 10000 1也只是徒劳)
    • RDB最大的优点就是占用空间小、恢复速度快;
  • AOF(Append Only File)
    • AOF保存的是写命令,或者说所有会改变数据的命令;
      • 所以写命令需要执行和保存到AOF缓存
    • AOF维护一个缓存保存还未提交到AOF文件缓冲写命令;
    • AOF对于所有写命令均追加到文件后面,所以文件将可能非常的庞大;(这时就会通过rewrite重写AOF文件,方式和RDB类似)

RDB

我们知道redis数据库的数据使用内存进行操作,如果想要redis数据库服务程序中断后恢复的话就需要磁盘进行持久化,在redis中磁盘持久化;RDB持久化是redis默认的持久化方式,我们可以使用SAVE和BGSAVE命令手动使其持久化,其中SAVE命令是在主进程中阻塞执行的,BGSAVE是在子进程中执行的,不会阻塞主进程(即redis还能提供对外服务);RDB文件的载入工作是在服务器启动时自动执行的,另外,因为AOF文件的更新频率通常比RDB文件的更新频率高,所以:

  • 如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态。

  • 只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态

自动RDB

那么这里就有一个很重要的信息点了除了手动持久化自然就有自动持久化,自动持久化不可能使用SAVE进行持久化,毕竟不可能自己阻塞自己提供服务吧),BGSAVE自动持久化需要配置持久化条件,而且持久化不能相互并发,所以有以下触发rdb持久化的条件:

  • 手动执行RDB持久化命令,且此时数据库中没有正在执行持久化命令;之所以这么设置是因为同类持久化会有资源竞争,不同类持久化并发执行需要大量IO操作影响redis业务处理
    • BGREWRITEAOF命令正在执行,那么客户端发送的BGSAVE命令会被服务器拒绝
    • BGSAVE命令正在执行,那么客户端发送的BGREWRITEAOF命令会被延迟到BGSAVE命令执行完毕之后执行
    • 禁止SAVE命令和BGSAVE命令同时执行
  • 满足自动执行BGSAVE条件却不存在上面说的冲突

自动BGSAVE条件的设置:通过设置redis.conf文件然后使用SAVE配置,SAVE xxx秒内 执行xxx次擦操作 就执行RDB持久化,参考上面的redisServer数据库数据结构,其将SAVE设置的条件保存在其中;如下:

#redis.conf

SAVE time number
SAVE time number
……

上文介绍redisServer已经介绍过实现RDB持久化的三个关键字段:1.条件保存结构体、2.上次持久化时间记录,3.计数器;显然执行增删改操作的时候会顺便在计数器上加一;【到目前为止,我们貌似在增上改查操作附带执行了很多操作了:1.(增改)数据结构是否需要编码转换:如hash数据结构的压缩表是否变为hashtable;2.(增)inset的升级;3.hashtable的重新hash;4.过期检查;5.压缩表的连锁更新】;除此之外,为了及时发现满足持久化条件,Redis的服务器周期性操作函数serverCron默认每隔100毫秒就会执行一次,该函数用于对正在运行的服务器进行维护,它的其中一项工作就是检查save选项所设置的保存条件是否已经满足,如果满足的话,就执行BGSAVE命令。【serverCron介绍见附录】

RDB优势和不足

①、优势

(1)由于rdb文件都是二进制文件RDB文件紧凑,全量备份,非常适合用于进行备份和灾难恢复

(2)生成RDB文件的时候,redis主进程会fork()一个子进程来处理所有保存工作,主进程不需要进行任何磁盘IO操作。

(3)RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快。

②、劣势

(1)RDB快照是一次全量备份。换句话说每次RDB都是一次新的文件覆盖,而且保存的是数据不是命令这里又想起mysql的binlog、undolog、redolog

(2)当进行快照持久化时,会开启一个子进程专门负责快照持久化,子进程会拥有父进程的内存数据,父进程修改内存子进程不会反应出来,所以在快照持久化期间修改的数据不会被保存,可能丢失数据

BGSAVE实现原理

bgsave原理是fork() + copyOnWrite;

fork

​ linux操作系统的一个api,fork()用于创建一个子进程。fork()出来的进程共享其父类的内存数据仅仅是共享fork()出子进程的那一刻的内存数据,后期主进程修改数据对子进程不可见,同理,子进程修改的数据对主进程也不可见。是不是很想mysql的读快照,再看copyonwrite

copyonwrite

主进程fork()子进程之后,内核把主进程中所有的内存页的权限都设为read-only越看越像】,然后子进程的地址空间指向主进程。这也就是共享了主进程的内存,当其中某个进程写内存时(这里肯定是主进程写,因为子进程只负责rdb文件持久化工作,不参与客户端的请求),CPU硬件检测到内存页是read-only的于是触发页异常中断(page-fault),陷入内核的一个中断例程。中断例程中,内核就会把触发的异常的页复制一份(这里仅仅复制异常页,也就是所修改的那个数据页,而不是内存中的全部数据),于是主子进程各自持有独立的一份。如下:

image-20211121112845717

这里参考mysql的mvcc快照读应该很容易就理解了;

【redis DataBase】持久化【即保存一个rdb文件】

执行流程
image-20210719140909258 image-20210719140827884

​ 1.触发规则 (数据保存到dump.rdb文件)

​ 1.触发save设置的规则

​ 2.使用flushall刷新

​ 3.正常关闭redis数据库

1.优点:每次启动redis时,自动执行dump.rdb恢复redis;
	   适合大规模数据
	   	
2.缺点:由于设置的save规则写入rdb文件最小更新间隔为秒,所以数据完整性不是很高,宕机这一秒的数据可能丢失
	fork进程会占用一定空间

AOF

append only file:AOF持久化是通过保存Redis服务器所执行的写命令来记录数据库状态的;在aop文件中数据是以命令的形式保存的,aop文件记录了恢复数据库的一系列命令(及其参数),

实现持久化的原理

​ AOF持久化功能的实现可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤

1.命令追加

​ redisServer有专门缓存命令的缓存,当AOF持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾

2.文件写入和同步

Redis的服务器进程就是一个事件循环(loop),这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复,而时间事件则负责执行像serverCron函数这样需要定时运行的函数。因为服务器在处理文件事件时可能会执行写命令,使得一些内容被追加到aof_buf缓冲区里面,所以在服务器每次结束一个事件循环之前,它都会调用flushAppendOnlyFile函数,考虑是否需要将aof_buf缓冲区中的内容写入和保存到AOF文件里面flushAppendOnlyFile函数的行为由服务器配置的appendfsync选项的值来决定;如果用户没有主动为appendfsync选项设置值,那么appendfsync选项的默认值为everysec

image-20211121152619530

现代操作系统中,当用户调用write函数,将一些数据写入到文件的时候,操作系统通常会将写入数据暂时保存在一个内存缓冲区里面,等到缓冲区的空间被填满、或者超过了指定的时限之后,才真正地将缓冲区中的数据写入到磁盘里面。这种做法虽然提高了效率,但也为写入数据带来了安全问题,因为如果计算机发生停机,那么保存在内存缓冲区里面的写入数据将会丢失。

为此,系统提供了fsync和fdatasync两个同步函数,它们可以强制让操作系统立即将缓冲区中的数据写入到硬盘里面,从而确保写入数据的安全性。

always:最慢,最安全的,因为即使出现故障停机,AOF持久化也只会丢失一个事件循环中所产生的命令数据,

everysec:足够快,并且就算出现故障停机,数据库也只丢失一秒钟的命令数据。

no:最快,从平摊操作的角度来看,no模式和everysec模式的效率类似,当出现故障停机时,使用no模式的服务器将丢失上次同步AOF文件之后的所有写命令数据。

恢复

因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态。

Redis读取AOF文件并还原数据库状态的详细步骤如下

​ 1)创建一个不带网络连接的伪客户端(fake client):因为Redis的命令只能在客户端上下文中执行,而载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令的效果和带网络连接的客户端执行命令的效果完全一样。

​ 2)从AOF文件中分析并读取出一条写命令。

​ 3)使用伪客户端执行被读出的写命令。

​ 4)一直执行步骤2和步骤3,直到AOF文件中的所有写命令都被处理完毕为止。

AOF重写

​ 因为AOF持久化是通过保存被执行的写命令来记录数据库状态的,所以随着服务器运行时间的流逝,AOF文件中的内容会越来越多,文件的体积也会越来越大,如果不加以控制的话,体积过大的AOF文件很可能对Redis服务器、甚至整个宿主计算机造成影响,并且AOF文件的体积越大,使用AOF文件来进行数据还原所需的时间就越多。为了解决AOF文件体积膨胀的问题,Redis提供了AOF文件重写(rewrite)功能这个功能是通过读取服务器当前的数据库状态来实现的,程序无须对现有AOF文件进行任何读入、分析或者写入操作AOF重写程序放到子进程里执行这样做可以同时达到两个目的:

1)子进程进行AOF重写期间,服务器进程(父进程)可以继续处理命令请求

2)子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的安全性

为了解决这种数据不一致问题,Redis服务器设置了一个AOF重写缓冲区,执行BGREWRITEAOF命令时,Redis服务器会维护一个AOF重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当Redis服务器执行完一个写命令之后,它会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区

这也就是说,在子进程执行AOF重写期间,服务器进程需要执行以下三个工作:

1)执行客户端发来的命令。

2)将执行后的写命令追加到AOF缓冲区。

3)将执行后的写命令追加到AOF重写缓冲区。

子进程完成AOF重写工作之后,它会向父进程发送一个信号,父进程在接到该信号之后,会调用一个信号处理函数,并执行以下工作:

1)将AOF重写缓冲区中的所有内容写入到新AOF文件中,这时新AOF文件所保存的数据库状态将和服务器当前的数据库状态一致。

2)对新的AOF文件进行改名,原子地(atomic)覆盖现有的AOF文件,完成新旧两个AOF文件的替换。

这个信号处理函数执行完毕之后,父进程就可以继续像往常一样接受命令请求了。

在整个AOF后台重写过程中,只有信号处理函数执行时会对服务器进程(父进程)造成阻塞,在其他时候,AOF后台重写都不会阻塞父进程,这将AOF重写对服务器性能造成的影响降到了最低。

image-20210719142242164

​ 将所有修改操作追加保存到appendonly.aof文件(可以理解为关系数据库的日志)中

​ 规则:可以设置每秒一次,或者每次修改则写入一次;

1.优点:如果设置为每一秒修改一次,文件将几乎没有丢失;
	   可以通过修复工具修复损坏的文件;
	   
2.缺点:保存了所有修改操作,数据量将非常大,修复速度也慢
	     	   

redis的缓存击穿、穿透和雪崩

​ 主要是缓存没有改数据,请求向关系型数据库发起请求

​ 概念:缓存击穿:大量请求同时访问一个没有的数据,在缓存中根本不可能找到,就向例如mysql中查找;

​ 缓存穿透:一个热点数据不在缓存中,或者在缓存中刚过期,这时大量请求同时请求改数据,导致缓存失效,请求均向类似mysql数据库中查找

​ 缓存雪崩:大量缓存同时到期失效,或者缓存服务器宕机,导致大量请求突然转向请求类似mysql服务器

image-20210719164408535

​ 解决思路:缓存击穿:1.加一层:该层为一个过滤器,过滤所有无效请求;(布隆过滤器)2.保存无效数据,所有无效请求的数据同样保存进缓存

​ 缓存穿透:1.热点数据不失活;2.对访问mysql等非缓存的请求进行排队,每次只执行一个(少量)请求,其他请求等待,并同时将热点数据放回缓存

​ 缓存雪崩:1.多备机;2.数据预热,缓存数据存活时间按算法设置,避免大量缓存同时到期;

高可用

主从复制

redis可以生成一个树状结构的主从数据库模式:

  • redis的主从复制模式是一写多读,如果主节点宕机,将丢失数据
  • 如果只有一级目录显然就是单机模式;
  • 如果为二级目录则为单master多slave;
  • 多级目录可以形成master维护其子slave,各个子slave维护各自slave
image-20220315121302733
复制算法

redis在2.8后进行了很大的改进,增加了对于增量复制的支持,通过Psync代替sync命令对sync命令(只能全量复制进行了优化)

全量复制和增量复制

  • 全量复制:一般适用在slave首次连接时,它连接的那个redis数据库将负责将RDB文件发送给该数据库,该数据库通过RED文件对数据库进行初始化;
  • 增量复制(部分复制):如果数据库断连后,再次重连成功,(大多数时候)显然没必要进行全量复制,只需要进行增量复制;
    • 增量复制是通过其所连接的数据库对其发出写命令实现的,所以必须知道当前,该数据库在哪一步开始需要进行恢复
  • psync复制的实现
    • psync {runId} {offset};
    • 主从数据库的复制偏移量(replication offset)
      • 复制偏移量:主从服务器各自维护自己的复制偏移量,主节点每发出N字节数据给其所有的子节点就会让复制偏移量+N;同理子节点器每收到N字节数据就会将自己的复制偏移量+N;
      • 当主从节点的复制偏移量一致时两数据库处于一致性状态;
    • 主服务器的复制积压
      • 积压缓冲区是一个固定大小(默认为1M)的队列(FIFO),每次主节点向从节点发送数据是即向从节点发送,同时也向积压缓冲区发送;
      • 积压缓冲区的所有字节的数据主节点都会给其加上一个偏移量
        • image-20220315150025889
      • 如果从节点断开成功重连在该主节点上,那么将检查其位偏移量+1是否在积压缓冲区,如果在将通过积压缓冲区进行同步,否则将进行全量复制;
    • 服务器的运行ID
      • 运行ID在服务器启动时自动生成,由40个随机的十六进制字符组成,在从节点首次连接到主节点进行全量复制时的时候,主节点会将自己的运行时ID发给从节点,子节点保存该ID;
      • 断开重连后,如果从节点保存的主节点运行ID和当前连接的主节点的运行ID并不相同,那么说明从服务器断线之前复制的主服务器并不是当前连接的这个主节点,主节点将对从节点执行完整重同步操作否则将可以检查是否可以执行部分复制;
全量复制实现
image-20220315122338368
  • 从节点连接到首次主节点上,发送Psync?-1命令,主节点根据其命令为Psync?-1确定需要进行全量复制或者(psync {xx} {xx} 其中主节点id不是当前节点id或者偏移量过大超出积压缓冲区记录的范围)
  • 主节点当前节点运行ID、 FULLRESYNC标志 、和位偏移量,并执行bgsave
  • 子节点保存主节点运行ID、确定位偏移量;
  • 主节点进行BGsave(创建子进程、进行快照保存RDB文件),保存完毕后将RDB文件发送到子节点(期间新的数据将被保存在客户端缓冲区中,但是可能会出现主节点复制客户端缓冲区溢出)
    • 默认配置为 client-output-buffer-limit slave 256MB 64MB 60,如果60s内缓冲区消耗持续大于64MB或者直接超过256MB时,主节点将直接关闭复制客户端连接,造成全量同步失败。
  • 从节点把接收的 RDB 文件保存在本地并直接作为从节点的数据文件,接收完 RDB 后从节点打印相关日志;
  • 从节点接收完主节点传送来的全部数据后会清空自身旧数据(RDB替换及数据更新)
  • 从节点接收并载入这个 RDB 文件,将自己的数据库状态更新至主服务器执行 BGSAVE 命令时的数据库状态。
  • 主节点将客户端缓冲区的新的写数据和从节点进行同步;
部分复制实现
image-20220315152447978
  • 部分复制是只有:从节点重连在上次断连的主节点上、且积压缓冲区缓存的数据的偏移量在从节点需要同步的数据范围时才会生效;
  • 从节点重连,发送psync {runID} {合适的偏移量}到主节点
  • 主节点接到 psync 命令后首先核对参数 runId 是否与自身一致,如果一致,说明之前复制的是当前主节点;之后根据参数 offset 在自身复制积压缓冲区查找,如果偏移量之后的数据存在缓冲区中,则向从节点发送 +CONTINUE 响应,表示可以进行部分复制。
  • 主节点根据偏移量把复制积压缓冲区里的数据发送给从节点,主从复制进入同步状态。
主从同步
节点的状态
  • 主从连接建立(SLAVEOF
    • 通过向从节点发送SLAVEOF命令,我们可以让一个从节点去复制一个主服务器(一般,只需要在从节点配置主节点的ip和端口号即可。)
      • 主节点客户端向从节点发送SLAVEOF ip 端口;从节点保存该IP和端口,返回OK;
    • 从节点根据保存的IP 和端口和某一个主节点建立TCP连接;
    • 主节点将为该套接字创建相应的客户端状态,并将从节点看作客户端来对待,这时从节点将同时具有服务器(server)和客户端(client)两个身份:从节点可以向主主节点发送命令请求,而主节点则会向从节点返回命令回复;
    • image-20220315154527554
  • 连接校验
    • 从节点发送PING命令检查建立的连接是否可用
      • PING命令有三种可能的结果:
        • 没有响应,主节点或者从节点所在网络繁忙导致超时,从节点将断连然后重新连接
        • 主节点响应错误,从节点得知主节点暂时不可用,断连然后重新连接
        • 主节点返回PONG,从节点得知可进行同步;
        • image-20220315154808352
  • 从节点校验(密码校验)
    • image-20220315154948699
  • 同步
    • 从服务器将向主服务器发送PSYNC命令,执行同步操作,并将自己的数据库更新至主服务器数据库当前所处的状态。在同步操作执行之前,只有从服务器是主服务器的客户端,但是在执行同步操作之后,主服务器也会成为从服务器的客户端
  • 命令传播
    • 完成了同步之后,主从服务器就会进入命令传播阶段,这时主服务器只要一直将自己执行的写命令发送给从服务器,而从服务器只要一直接收并执行主服务器发来的写命令,就可以保证主从服务器一直保持一致了(最终一致性)。
心跳检查
  • 在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:REPLCONF ACK ,以对主节点进行检查

    • 检测主从服务器的网络连接状态。

      • 如果主节点超过1秒没有收到主节点心跳,就知道该从节点连接出现了故障;从节点同理
    • **辅助实现min-slaves选项,**可以减少数据不一致

      • Redis的min-slaves-to-write(最少有效从节点数量)和min-slaves-max-lag(最小有效从节点延迟)两个选项可以防止主服务器在不安全的情况下执行写命令

        • min-slaves-to-write 3

        • min-slaves-max-lag 10

          那么在从服务器的数量少于3个,或者三个从服务器的延迟(lag)值都大于或等于10秒时,主服务器将拒绝执行写命令

    • 检测命令丢失。

      • 主服务器传播给从服务器的写命令在半路丢失,那么当从服务器向主服务器发送REPLCONF ACK命令时,主服务器将发觉从服务器当前的复制偏移量少于自己的复制偏移量,然后主服务器就会根据从服务器提交的复制偏移量,在复制积压缓冲区里面找到从服务器缺少的数据,并将这些数据重新发送给从服务器。

哨兵模式

  • 主从模式需要手动选择主节点,显然存在严重弊端,哨兵则可以通过哨兵监控多个节点,自主实现主节点的切换
  • 哨兵模式是对于主从模式的主节点切换的优化,数据节点仍然是主从复制模式,增加了哨兵集群进行监控数据节点;
  • 由于哨兵模式自主切换节点,前面我们知道主从复制模式不保证主从节点数据一致性,因此可能会出现数据未同步(这对于分布式锁是不可接受的)
img
哨兵
启动

Sentinel:是Redis的一个特殊Redis数据库,有自身专用的代码,专门负责监控各个节点的状态,Redis数据库以哨兵模式启动:redis-sentinel ./sentinel.conf

  • 初始化服务器。
  • 将普通Redis服务器使用的代码替换成Sentinel专用代码。
  • 初始化Sentinel状态。
  • 根据给定的配置文件,初始化Sentinel的监视主服务器列表。
  • 创建连向主服务器的网络连接。
  • image-20220315162018173
状态
  • 每个哨兵和所有的主从节点都会有两个连接:命令连接和订阅连接

    • 命令连接:专门用于向master发送命令,并接收命令回复
    • 订阅连接:专门订阅master服务的 sentinel:hello频道
    • Sentinel需要与多个实例创建多个网络连接,所以Sentinel使用的是异步连接
    • 当Sentinel发现主服务器有新的从服务器出现时,Sentinel除了会为这个新的从服务器创建相应的实例结构之外,Sentinel还会创建连接到从服务器的命令连接和订阅连接。
  • Sentinel默认会以每十秒一次的频率,通过命令连接向被监视的主从服务器发送INFO命令,并通过分析INFO命令的回复来获取主服务器的当前信息。

    • 根据INFO命令的回复,Sentinel会提取出以下信息:

    • 主节点

      一方面是关于主服务器本身的信息,包括run_id域记录的服务器运行ID,以及role域记录的服务器角色;

      ·另一方面是关于主服务器属下所有从服务器的信息,每个从服务器都由一个"slave"字符串开头的行记录,每行的ip=域记录了从服务器的IP地址,而port=域则记录了从服务器的端口号。根据这些IP地址和端口号,Sentinel无须用户提供从服务器的地址信息,就可以自动发现从服务器。

    • 从节点

      ·从服务器的运行ID run_id。

      ·从服务器的角色role。

      ·主服务器的IP地址master_host,以及主服务器的端口号master_port。

      ·主从服务器的连接状态master_link_status。

      ·从服务器的优先级slave_priority。

      ·从服务器的复制偏移量slave_repl_offset

Raft算法
  • redis通过Raft算法对哨兵的leader进行选举,然后通过这个leader负责进行从节点转换为主节点以及日志的同步;
  • redis哨兵监控的leader是不是哨兵集群,是数据节点的leader(表现在一主多从就是master节点);
  • Raft将节点分为:leader、follower、candidate(候选者);每个节点可以是多个状态(同时是候选者和follower)
  • 算法流程分为:
    • leader失效,开启新纪元
      • 每有一个节点认为leader下线,其在本节点进入主观下线(知道其恢复或者确定为客观下线)
      • 超过半数认为其主观下线,其变为客观下线
    • leader选举
      • 候选者(所有发现leader下线的follower自动成为candidate)投自己,并向其他节点发送投自己的请求
      • follower投最先收到的请求的候选者,拒绝后面的leader;
    • 选举成功:leader向所有节点发送信息告知本节点为leader
    • 选举失败:开启新纪元继续选举,直到成功选举新leader
    • leader复制同步日志,在其后的整个过程,leader将一直是leader
redis实现
主观下线
  • 每个Sentinel节点,每隔1秒会对数据节点发送ping命令做心跳检测,
  • 当这些节点超过down-after-milliseconds没有进行有效回复时,Sentinel节点会对该节点做失败判定,这个行为叫做主观下线。
    • 有效回复:PONG、-LOADING、-MASTERDOWN三种回复的其中一种
客观下线
  • 当大多数Sentinel节点,都认为master节点宕机了,那么这个判定就是客观的,叫做客观下线。
  • 分布式协调中的quorum判定了,大多数就是过半数,比如哨兵数量是3,那么大多数就是3/2+1=2个,哨兵数量是5,大多数就是5/2+1=3个。
故障迁移

故障迁移分为两步:选举哨兵leader重新选择master进行故障迁移

选举哨兵leader
规则
  • 选举成功条件:被选择的数量是大多数(即超过一半);
  • 每个哨兵节点都会选择推荐选择自己为leader,并发出;
  • 所有节点接受收到的第一个选举推荐并返回,拒绝后面接收到的选举推荐
  • 当一个节点接受到超过半数选择他是则其为leader
  • 如果在给定时限内,没有一个Sentinel被选举为领头Sentinel,那么各个Sentinel将在一段时间之后再次进行选举
  • 为了避免过去的选举对当前选举影响,每次选举无论是否成功都会将Sentinel的配置纪元(configuration epoch)的值都会自增一次

所以最好是单数哨兵节点;

leader的作用
  • 过滤待故障迁移下非良好的子节点
  • Sentinel将根据从服务器的优先级,对列表中剩余的从服务器进行排序,并选出其中优先级最高的从服务器
  • 如果有多个优先级一致的,选出其中偏移量最大的从服务器
  • 如果还未选出,选出其中运行ID最小的从服务器。
  • Leader Sentinel节点,会从新的master节点那里得到一个configuration epoch(纪元),本质是个version版本号,每次主从切换的version号都必须是唯一的。其他的哨兵都是根据vetsion来更新自己的master配置。
过程
  • image-20220315165327359
故障迁移
规则
  • 已下线的Master主机下面挑选一个良好Slave的将其转换为主服务器。

    • 由哨兵leader负责选择子节点作为新的master
  • 让其余所有Slave服务器的主服务器设置为新的Master服务器。

    • 哨兵leader会让之前下线Master的Slave发送SLAVEOF命令,让它们复制新的Master。
  • 监控已下线的Master服务器;重新上线后新的Master服务器的Slave

    • 当已下线的Master重新上线后,领头Sentinel会向此服务器发送SLAVEOF命令,将当前服务器变成新的Master的Slave。

集群(cluster)模式

  • 哨兵(主从)模式存在俩个问题:
    • 只有一个master节点复制全部写操作,如果其节点断连,将导致未同步但是已经提交成功的数据在其他节点没有这对于分布式锁等问题非常严重;
    • 另外就是海量数据下,对master服务器要求很高
image-20220315172218036
基本概念
  • 节点:一个节点就是一个运行在集群模式下的Redis服务器Redis服务器在启动时会根据cluster-enabled配置选项是否为yes来决定是否开启服务器的集群模式;节点和单机数据库的区别主要在于其三个特殊的数据结构
    • clusterNode、clusterLink,clusterState
  • 槽位:
    • 集群的整个数据库被分为16384个槽(slot)数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。
    • 但所有操作均有节点处理时将处于上线状态,反之则处于下线状态;
  • 分片:
数据结构

深入理解redis

过期的实现

​ redis支持过期处理,有以下四(五)种设置过期时间的方式

过期时间的设置和保存

setex、expire、pexpire、expireat、pexpireat;显然setex就是set和expire的组合,实现就是自动执行set和expire;对于另外四个有:

  • EXPIRE命令用于将键key的生存时间设置为ttl

  • PEXPIRE命令用于将键key的生存时间设置为ttl毫秒

  • EXPIREAT命令用于将键key的过期时间设置为timestamp所指定的数时间戳。

  • PEXPIREAT命令用于将键key的过期时间设置为timestamp所指定的毫秒数时间戳。

redis最终会转化为一种实现方式:image-20211120230004598

换句话说就是对于expire每次操作之后都会重新设置ddl(刷新pexpireat);回顾前文redis的redisdb有过期字典字段,专门保存设置过期字典的key和longlong类型的时间戳;redisDb结构的expires字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典(显然也是一个dict类型,在dictEntry的key指向真实的key,v保存longlong值):

  • 过期字典的键是一个指针,这个指针指向键空间中的某个键对象(也即是某个数据库键)。【这也是为什么redis数据库只能在key设置过期时间并不能在hash的field之类设置的原因】;

  • 过期字典的值是一个long long类型的整数,这个整数保存了键所指向的数据库键的过期时间——一个毫秒精度的UNIX时间戳。

过期键的判定

通过过期字典,程序可以用以下步骤检查一个给定键是否过期:

1)检查给定键是否存在于过期字典:如果存在,那么取得键的过期时间。

2)检查当前UNIX时间戳是否大于键的过期时间:如果是的话,那么键已经过期;否则的话,键未过期。

删除策略

1.定时删除:在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作。显然这种删除策略问题很大,尽管可以最大化利用内存,但是需要设置一个定时器,1.如果可以使用多线程加休眠实现但是会频繁切换线程2.如果通过设置时间事件每个一小段时间检查以下时间时间set中是否需要执行,时间复杂度为O(n),同样不可取

2.惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。这种方式问题也很明显如果一个键已经过期,而这个键又仍然保留在数据库中,那么只要这个过期键不被删除,它所占用的内存就不会释放。

3.定期删除每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。定期删除最大的问题是定多少、删多少,删除哪一个数据库;

在这三种策略中,第一种和第三种为主动删除策略,而第二种则为被动删除策略。Redis服务器实际使用的是惰性删除和定期删除两种策略

过期策略的实现

惰性删除的实现:

img

定期删除策略的实现:
定期策略是每隔一段时间执行一次删除过期键的操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU 时间的影响,同时也减少了内存浪费

Redis 默认会每秒进行 10 次(redis.conf 中通过 hz 配置)过期扫描,扫描并不是遍历过期字典中的所有键,而是采用了如下方法

  • 过期字典中随机取出 20 个键

  • 删除这 20 个键中过期的键

  • 如果过期键的比例超过 25% ,重复步骤 1 和 2

为了保证扫描不会出现循环过度,导致线程卡死现象,还增加了扫描时间的上限,默认是 25 毫秒(即默认在慢模式下,如果是快模式,扫描上限是 1 毫秒);所以redis的定期删除策略函数思路如下:

activeExpireCycle函数的工作模式可以总结如下:

  • 函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键。

  • 全局变量current_db会记录当前activeExpireCycle函数检查的进度,并在下一次activeExpireCycle函数调用时,接着上一次的进度进行处理。比如说,如果当前activeExpireCycle函数在遍历10号数据库时返回了,那么下次activeExpireCycle函数执行时,将从11号数据库开始查找并删除过期键。

  • 随着activeExpireCycle函数的不断执行,服务器中的所有数据库都会被检查一遍,这时函数将current_db变量重置为0,然后再次开始新一轮的检查工作。

  • 如果数据库中没有一个键带有过期时间,那么跳过这个数据库

事件

Redis服务器是一个事件驱动程序,服务器需要处理以下两类事件:

  • 文件事件(file event):Redis服务器通过套接字与客户端(或者其他Redis服务器)进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端(或者其他服务器)的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作。

  • 时间事件(time event):Redis服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象。

文件事件

基础

文件事件专门处理网络IO,所以根据网络编程的特点,可以分为:

  • CONNECT:连接事件
  • READ 、WRITE:读写事件
  • CLOSE:关闭事件
image-20220316154134488

根据前面的Redis线程模型:

img

实现
  • redis使用IO多路复用的线程模型(单reactor单线程模型
  • 有三个handler:acceptread和业务处理write(send)
    • 连接事件:accept
    • 命令请求处理器:read进行IO读取命令、以及业务逻辑处理(由于是在内存操作所以业务逻辑非常快)
    • 命令恢复处理器:write进行IO写回,返回命令的执行结果

时间事件

  • redis的时间事件通过一个链表(list)保存,所以每次需要遍历整个list才能知道哪些时间到期需要执行
    • 节点的数据为:
      • 事件id;
      • 到期时间;目前版本的Redis只使用周期性事件,而没有使用定时事件。
      • ·timeProc:时间事件处理器,一个函数。当时间事件到达时,服务器就会调用相应的处理器来处理事件。
  • 在Redis3.0版本,正常模式下的 Redis 只带有 serverCron 一个时间事件(所以链表退化成节点), 而在 benchmark 模式下, Redis 也只使用两个时间事件。
  • serverCron函数
    • 更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况等。
    • 清理数据库中的过期键值对。(在过期实现中介绍过
    • 关闭和清理连接失效的客户端。
    • 尝试进行AOF或RDB持久化操作。
    • 如果服务器是主服务器,那么对从服务器进行定期同步。
    • 如果处于集群模式,对集群进行定期同步和连接测试。

总结和问题

问题

mysql和redis双写问题

  • 在分布式缓存策略介绍过,根据对于不一致性容忍程度,有旁路缓存策略读写穿透策略异步写穿透

分布式锁问题

  • 分布式锁难点
    • 分布式系统中怎么保证锁的可重入、阻塞等待(特别是有超时时间的阻塞等待)
    • 怎么避免客户端线程意外,导致锁不释放
    • 怎么避免客户端业务还在进行(但是由于某些原因导致其执行时间过长),导致锁过期(超时)
      • 出现并发安全问题:相当于同时有俩个线程获取到写锁
      • 该业务处理完毕后,会到redis释放锁,导致释放了另一个线程持有的锁,就会出现连锁错误
    • 在集群模式下,怎么保证锁的获取不会因为节点和集群的断连而不一致
可重入和阻塞等待
  • 等待:可以通过原子获取锁+自旋实现,问题是消耗CPU资源
  • 阻塞等待:通过JUC实现阻塞等待和超时等待(Redisson使用的是信号量机制Semaphore
  • 可重入:如果是单机可重入,仅仅只需要知道当前线程是否获取到锁即可,可以在分布式锁中记录线程id;或者本地保存一个锁和重入线程的对应关系即可;
超时等待
  • 无论是通过lua还是借用redis的组合语句 set no exists + expire机制,都是通过利用redis的超时机制;

  • --setnx expire
    SET key value[EX seconds][PX milliseconds][NX|XX]
    --lua
    if(redis.call('SETNX',KEYS[1],ARGV[1])~=0) THEN 
        redis.call('EXPIRE',KEYS[1],ARGV[2])
        return true
    end
    return false;
    
  • 对于可能出现业务超时导致释放,一般使用watch dog机制避免;

    • watch dog可以通过一个守护线程实现,也可以通过定时任务实现
    • image-20220322101516298
集群模式

附录

sds数据结构

typedef char *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[];
};

malloc的实现原理:http://blog.codinglabs.org/articles/a-malloc-tutorial.html

redis的内存分配器:

redis的内存分配器可以是 libcjemalloc或者tcmalloc,默认使用jemalloc;

jemaloloc:在减小内存碎片方面做的相对比较好。jemalloc在64位系统中,将内存空间划分为小、大、巨大三个范围每个范围内又划分了许多小的内存块单位;当Redis存储数据时,会选择大小最合适的内存块进行存储;内存分配器jemalloc /tcmalloc 等分配内存大小的单位都是 2、4、8、16、32、64 等等

serverCorn

https://blog.csdn.net/mytt_10566/article/details/98476617

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

舔猫

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值