看完这篇Redis,可能只是入了个门

看完这篇Redis,可能只是入了个门!

redis基础

redis的应用场景

  1. 缓存

    (会话缓存,最常用)

    • 对于一些前端数据的缓存,当有大量数据库的增删改查操作的时候,为了避免每次都去sql里请求数据,可以把一些数据存储到redis中去,这样是直接从内存中将获取数据,速度会增快很多

    • web端用户,用于登录缓存session数据,登录的一些数据信息存到session中,缓存到redis中

  2. 队列

    (消息队列,比如支付,此外应用的场景还有例如活动排行榜或计数、发布或订阅消息、商品列表或评论列表等)

    • redis中提供了list接口,这个list接口提供了lpush和rpop,这两个方法具有原子性,可以插在队列元素和弹出队列元素
  3. 数据存储
    • redis是非关系型数据库,可以把redis直接用于数据存储,提供了增删改查等操作,因为redis有良好的硬盘持久化机制,reids数据就可以定期持久化到硬盘中,保证了redis数据的完整性和安全性
  4. redis锁实现防刷机制
    • redis锁可以处理好并发问题,redis数据类型中有一个set类型,set类型存储数据的时候是无序的,而且每个值都不一样,不能重复,这样就可以快速的查找元素中的某个值是否存在,精确的进行增删改查操作
  5. 排行榜
    • 很多网站都有排行榜应用的,如京东的月度销量榜单、商品按时间的上新排行榜等。Redis提供的有序集合数据类构能实现各种复杂的排行榜应用。
  6. 分布式会话
    • 集群模式下,在应用不多的情况下一般使用容器自带的session复制功能就能满足,当应用增多相对复杂的系统中,一般都会搭建以Redis等内存数据库为中心的session服务,session不再由容器管理,而是由session服务及内存数据库管理。
  7. 分布式锁
    • 在很多互联网公司中都使用了分布式技术,分布式技术带来的技术挑战是对同一个资源的并发访问,如全局ID、减库存、秒杀等场景,并发量不大的场景可以使用数据库的悲观锁、乐观锁来实现,但在并发量高的场合中,利用数据库锁来控制资源的并发访问是不太理想的,大大影响了数据库的性能。可以利用Redis的setnx功能来编写分布式的锁,如果设置返回1说明获取锁成功,否则获取锁失败,实际应用中要考虑的细节要更多。

redis数据的基本类型

  1. String(字符串类型)

    string存储的元素类型可以是string/int/float,int类型可以进行增加和减少操作。有的人喜欢对象或者List转换为JSONString进行存储,拿出来再反序列化。

    set string koala
    #设置一个key为string,value为koala的键值对
    get string
    #得到value
    set string1 2
    
    incr string2 
    #将原本为string2:2的值增加1
    decrby string2 2
    #将原本为string2的值设置为2后自减1,即最后的值为1
    
  2. list(字符串列表)

    list类型是一个有序的列表,有序表示的是从左到右火哦从右到左,而且数据内容是可以重复的。List 是有序列表,这个还是可以玩儿出很多花样的。

    比如可以通过 List 存储一些列表型的数据结构,类似粉丝列表、文章的评论列表之类的东西。

    比如可以通过 lrange 命令,读取某个闭区间内的元素,可以基于 List 实现分页查询,这个是很棒的一个功能,基于 Redis 实现简单的高性能分页,可以做类似微博那种下拉不断分页的东西,性能高,就一页一页走。

    比如可以搞个简单的消息队列,从 List 头怼进去,从 List 屁股那里弄出来。

    List本身就是我们在开发过程中比较常用的数据结构了,热点数据更不用说了。

    • 消息队列:Redis的链表结构,可以轻松实现阻塞队列,可以使用左进右出的命令组成来完成队列的设计。比如:数据的生产者可以通过Lpush命令从左边插入数据,多个数据消费者,可以使用BRpop命令阻塞的“抢”列表尾部的数据。
    • 文章列表或者数据分页展示的应用。

    比如,我们常用的博客网站的文章列表,当用户量越来越多时,而且每一个用户都有自己的文章列表,而且当文章多时,都需要分页展示,这时可以考虑使用Redis的列表,列表不但有序同时还支持按照范围内获取元素,可以完美解决分页查询功能。大大提高查询效率。

    rpop 用于移除并返回列表的最后一个元素
    
    lpush list1 12
    
    lpush list1 13
    #往list1集合中添加元素
    
    rpop list1
    #移除最后一个元素并返回值
    
    lrange list1 0 -1
    #查看元素从第一个到最后一个
    
  3. set(字符串集合)

    set类型中提供了无序的方式来存储多个不同的元素,set类型中每个元素的值都不一样,用户可以快速对元素中的值添加删除,检查某些值是否存在,重复的元素是无法继续插入集合的。

    Set 是无序集合,会自动去重的那种。

    直接基于 Set 将系统里需要去重的数据扔进去,自动就给去重了,如果你需要对一些数据进行快速的全局去重,你当然也可以基于 JVM 内存里的 HashSet 进行去重,但是如果你的某个系统部署在多台机器上呢?得基于Redis进行全局的 Set 去重。

    可以基于 Set 玩儿交集、并集、差集的操作,比如交集吧,我们可以把两个人的好友列表整一个交集,看看俩人的共同好友是谁?对吧。

    反正这些场景比较多,因为对比很快,操作也简单,两个查询一个Set搞定。

    sadd set1 12
    #往集合无需集合里添加一个元素
    
    sadd set1 12
    #添加失败,因为集合里面的值不能重复
    
    scrad set1
    #查看set1里面的元素有多少个
    
    sismeber set1 13
    #查看13是否在set1这个集合中
    
    srem set1 13
    #从集合中删除13
    
  4. hash(哈希)

    哈希也叫做散列类型,存储的时候存的是键值对。查询条数的时候只要是键的值不一样,就是不同的条数,尽管值是相同的

    hset hash1 key1 12
    #在哈希列表hash1中设置key为key1,value为12的键值对
    hset hash1 key2 13
    
    hget hash1 key1
    #得到哈希列表hash1中key为key1的值
    
    hmget hash1 key1 key2
    #得到多个key的值
    
    hlen hash1
    #查询hash1的条数
    
  5. sort set(有序字符串集合即ZSet)

    Redis有序集合zset与普通集合set非常相似,是一个没有重复元素的字符串集合。不同之处是有序集合的没有成员都关联了一个评分(score) ,这个评分(score)被用来按照从最低分到最高分的方式排序集合中的成员。集合的成员是唯一的,但是评分可以是重复了 。

    sort set中的值是全局唯一的,一个值设置了之后再次设置就不会增加,只会覆盖修改

    如果有两条分数相同,排名应该怎么查看?

    如果两个分值形同,会根据值两个元素变量名的字典排序先后

    Sorted set 是排序的 Set,去重但可以排序,写进去的时候给一个分数,自动根据分数排序。

    有序集合的使用场景与集合类似,但是set集合不是自动有序的,而Sorted set可以利用分数进行成员间的排序,而且是插入时就排序好。所以当你需要一个有序且不重复的集合列表时,就可以选择Sorted set数据结构作为选择方案。

    • 排行榜:有序集合经典使用场景。例如视频网站需要对用户上传的视频做排行榜,榜单维护可能是多方面:按照时间、按照播放量、按照获得的赞数等。
    • Sorted Sets来做带权重的队列,比如普通消息的score为1,重要消息的score为2,然后工作线程可以选择按score的倒序来获取工作任务。让重要的任务优先执行。

    微博热搜榜,就是有个后面的热度值,前面就是名称

    zadd zset1 10.1 val1
    zadd zset1 11.1 val2
    zadd zset1 12.1 val3
    
    zcard zset1
    #统计一下当前key下值的个数
    
    zrange zset1 0 2 withscores
    #查看0到2的所有值和分数,按照排名列出
    

redis底层数据结构

图片

String底层数据结构(SDS)

redis字符串如果保存的对象是整数类型,那么就用int存储;如果保存的是字符串类型,就用sds(简单动态字符串)来表示,sds通过记录长度和预分配空间可以高效计算长度进行append操作。下面是sds的结构体

typedef char *sds;
struct sdshdr{
    //buf已占用的长度
    int len;
    //buf剩余可用的长度
    int free;
    //实际保存字符串数据的地方
    char buf[];
}
SDS的优势
  1. sds通过len属性可以将获取字符长度的复杂度减小到O(1)
  2. 防止buf存储内容溢出的问题,每次增加字符串的长度的时候先检查free属性是否能存下要增加的字符串长度,如果不够则先对buf数组扩容,然后在将内容存入buf数组中。
  3. 空间预分配&空间惰性释放,sds会预分配一部分空闲的空间,当字符串内容添加时不需要做空间申请的工作,当字符串从buf数组中移除是,空闲出来的空间不会立马被内存回收,防止新增字符串的内容写入时空间不够而临时申请空间。
list底层数据结构ziplist&linkedlist

在redis 3.2之前list底层采用了ziplist和linkedList实现,在3.2之后,list底层采用了quicklist

在redis3.2之前,因为双向链表占用的内存比压缩链表要大很多,所以在创建新的列表list时,会优先考虑使用(ziplist)压缩链表,在达到条件(当某个元素长度超过64字节或者是list中的元素数量超过512)后再转换为(linkedlist)双向链表。

ziplist的实现原理

img

ziplist是由一块连续的存储空间组成的,ziplist没有前后指针。

  • zlbytes:表示当前list的存储元素的总长度
  • zllen:表示当前list存储的元素的个数
  • zltail:表示当前list的头结点的地址,通过zltail就是可以实现list的遍历
  • zlend:表示当前list的结束标识
  • entry:表示储存实际数据的节点,每个entry代表一个元素
  • previous_entry_length:表示当前节点元素的长度,通过长度可以计算出下一个元素的位置
  • encoding:表示元素的编码格式
  • content:表示实际存储的元素内容
ziplist的优缺点
  • 优点:内存地址连续,省去了每个元素的头尾节点指针占用的内存
  • 缺点:对于删除和插入操作来说需要进行维护,比如在list中间插入删除一个元素时,在插入和删除位置后面的元素可能都需要发生相应的移动操作。
linkedlist的实现原理

img

linkedlist是有一块不连续内存通过指针连接起来的双向链表

  • head:表示list的头结点,通过其可以找到list的头结点
  • tail:表示list的尾结点,通过其可以找到list的尾结点
  • len:表示list存储的元素个数
  • dup:表示用于复制元素的函数
  • free:表示用于释放元素的函数
  • match:表示用于对比元素的函数
quicklist底层实现原理

img

在redis3.2版本之后,redis集合采用了quicklist作为list的底层实现,quicklist就是结合了ziplist和linkedlist的优点设计出来的

  • 每一个listnode存储一个指向ziplist的指针,ziplist用来真正存储元素数据
  • ziplist中存储的元素总大小超过8KB(默认大小,通过list-max-ziplist-size参数可以进行配置)的时候,就会重新创建出来一个listnode和ziplist,然后将其通过指针关联起来。
set的底层实现原理

set集合采用整数集合和字典的两种方式实现的,当满足如下两个条件的时候,采用整数集合实现,一旦有一个条件不满足是则采用字典来实现。

  • set集合中的所有元素都是整数
  • set集合中的元素个数不大于512

整数集合实现原理图

字段实现原理图

Zset的底层实现原理

zset底层同样采用了两种方法来进行实现,分别是ziplist和skiplist。当满足以下两个条件是,此才有ziplist实现,反之是采用skiplist实现

  • zset中保存的元素个数小于128(可以通过zset-max-ziplist-entries配置来修改)
  • zset中保存的所有元素长度小于64byte(可以通过修改zset-max-ziplist-values来进行修改)
ziplist的实现原理

img

对于ziplist的结构,和list中的ziplist的底层实现有些相似,不同的是zset存储是以键值对的方式依次排列,键存储的是实际的value,值存储的是value对应的分值。

skiplist的实现原理

img

skiplist分为两个部分,dict部分是有字典实现的,zset部分使用跳表实现,dict和跳表都存储的数据,实际上dict和跳跃表最终都使用指针指向了同一份数据,即数据是被两部分共享的。

hash的底层实现原理

hash底层采用了ziplist和hashtable两种实现方式,hash结构同时满足如下两个条件时底层采用ziplist实现,一旦有一个条件不满足就会转换为hashtable进行存储。

  • hash中存储的所有元素的key和value的长度都小于64byte(通过修改hash-max-ziplist-value配置调节大小)
  • hash中存储的元素个数小于512,(通过修改hash-max-ziplist-entries配置调节大小)

redis中的key-value是通过dictEntry对象进行包装的,而哈希表就是将dictEntry对象又进行了再一次包装得到的,这就是哈希表对象dictht:

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

上面结构定义中的table是一个数组,其每个元素都是一个dictEntry对象。

字典,又称符号表(symbol table),关联数组(associative array)或者映射(map),字典内部嵌套了哈希表dictht对象,下面即时一个字典ht的定义:

typedef struct dict{
    dictType *type;//字典类型的一些特定函数
    void *privdata;//私有数据,type中的特定函数可能需要用到
    dictht ht[2];//哈希表(这里有两个hash表)
    long rehashidx;//rehash索引,不在rehash时,值为-1
    unsigned long iterators;//正在使用的迭代器数量
}dict;

其中dictType内部定义了一些常用函数,其数据结构定义如下:

typedef struct dictType{
    unit64_t(*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对象的时候可以得到如下结构

img

redis Hash扩容的过程

dict中定义了一个数组ht[2],ht[2]中定义了两个哈希表:ht[0]和ht[1]。而redis在默认情况下只会使用ht[0],并不会使用ht[1],也不会为ht[1]初始化分配空间。

但设置一个hash对象时,具体会落在哈希数组中的哪一个下标,是通过计算哈希值来确定的,如果发生哈希碰撞(即计算出来的哈希值是一样的情况),那么同一个下标就会有多个dictEntry,从而形成一个链表,不过需要注意的是最后插入元素的总是落在链表的最前面,即发生hash冲突的时候,总是往链表的头部节点放。

当读取数据的时候遇到一个节点有多个数据就需要遍历链表,故链表越长,性能越差,为了保证哈希表的性能,需要满足以下两个条件中的一个是,对哈希表进行rehash操作(重新散列):

  • 负载因子大于等于1且dicr_can_resize为1时
  • 负载因子大于等于安全阈值(dict_force_resize_ratio = 5)时

负载因子 = 哈希表已经使用的节点数/哈希表的大小(即h[0].used/h[0].size)

rehash步骤

扩展哈希和收缩哈希都是通过rehash来完成的,这其中就涉及到了空间的分配和释放,主要经历以下五步:

  1. 为字典dict的ht[1]哈希表分配空间,其大小取决于当前哈希表已保存节点数(即:ht[0].used):
    • 如果是扩展操作则ht[1]的大小为2的n次方中第一个大于等于ht[0].used2属性的值(比如used=3,此时ht[0].used * 2=6,故2的3次方为8就是第一个大于used2的值)
    • 如果是收缩操作则ht[1]大小为2的n次方中第一个大于等于ht[0].used的值
  2. 将字典中的属性rehashidx的值设置为0,表示正在rehash操作
  3. 将ht[0]中所有的键值对依次重新计算哈希值,并放到ht[1]数组对应位置,每完成一个键值对的rehash之后rehashidx的值就需要自增1.
  4. 当ht[0]中所有的键值对都迁移到ht[1]之后,释放ht[0],并将ht[1]修改为ht[0],然后在创建一个新的ht[1]数组,为下一次rehash做准备。
  5. 将字典中的属性rehashidx设置为-1,表示此次rehash操作结束,等待下一次rehash

在渐进式rehash的过程中因为可能会有新的键值对存进来,此时redis的做法是新添加的键值对统一放入ht[1]中,这样就确保了ht[0]键值对的数量只会减少。当有查询操作的时候,会先在hr[0]中查询,查找不到在到ht[1]中查找。

redis多数据库

一个redis实例可以包含多个数据库,客户端可以指定链接哪个数据库(与mysql链接数据库相似)。一个redis实例最多可以提供16个数据库,下表是从0到15,默认连接的是0数据库

select 1
#选择1号数据库

keys *
#查看所有的key

move list1 0
#将0号数据库中的list1移动到0号数据库中

select 0

type list1
#查看数据类型

redis事务的概念

  • 事务的基本命令
    1. multi标记一个事务的开始
    2. exec执行所有事务块内的命令
    3. discard取消事务,放弃执行事务内的所有命令
  • 事务的特性
    1. 事务中的命令都是串行执行的
    2. 事务执行期间redis不会对其他客户端提供任何服务,从而保证事务中的命令呢能够原子化执行
    3. 单个redis命令的执行是原子性的,但是redis没有在事务上增加任何维持原子性的机制,所以redis事务的执行并不是原子性的。事务可以理解为一个打包的批量执行脚本,但是批量指令并非原子化的操作,中间某条指令的失败不会导致前面已做的指令回滚,也会造成后续的指令不做
MULTI
#开启事务

SET book-name "Mastering C++ in 21 days"
SADD tag "C++" "Programming" "Mastering Series"
SMEMBERS tag
EXEC
#提交事务

redis数据持久化

RDB数据持久化方式
  • 优势

    1. redis数据库只会包含一个文件存储在硬盘中,对于文件备份辉简单很多
    2. 对于灾难修复,RDB是更好的选择,因为一个文件可以直接拷贝带走,拷贝回来
    3. 性能最大化,redis持久化的时候只分出一些子进程,之后这些子进程会完成持久化工作,避免了服务器进程进行io操作。数据集很大的时候启动效率会更高
  • 劣势

    1. 最大限度的避免数据丢失,RDB做的不是特别好,系统如果在定时持久化之前出现一些档期的情况,还没来得及往硬盘上写,数据就已经丢失了
    2. 因为RDB是通过开启子进程的方式进行持久化操作的,因此当数据集比较大的时候,这个过程可能导致服务器停止一定事件,几十毫秒甚至1秒
  • 配置

    Linux目录中/usr/local/redis/redis-conf目录中找到这样的几行代码

    save 500 1
    save 300 10
    save 60 10000
    

    第一行代码表示:900秒,也就是15分钟至少有一个key发生变化就会持久化一次

    第二行代码表示:300秒,至少有10个key发生变化就会往硬盘中持久化一次

    第三行代码表示:60秒,至少有10000个key发生变化就会往硬盘中持久化一次

    dbfilename dump.rdb
    

    配置中继续往下看,看到这样一行代码,这个dump是数据库的名字

    dir ./
    配置保存路径的位置,这里指的是当前目录下上面的名字,就是持久化的数据库
    
AOF数据持久化方式
  • 优势

    1. 可以带来更加高的数据安全性,这种数据持久化方式有三种同步策略,每秒同步,每修改同步(每一次发生数据的变化都会立即被记录到硬盘中,效率最低但是最安全)。
    2. 日志的写入操作是采用append追加的模式,在写入过程中即使出现服务器档期问题,也不会破坏日志文件中已经写入的内容。
    3. 如果日志过大,redist可以自动启动重写机制,redis会不断的将修改的数据写道老的磁盘中,同时redis会创建一个新的文件来记录期间产生了哪些命令被执行了。
    4. AOF包括一个格式非常清晰易于理解的日志文件,用于记录所有的修改操作。通过这个文件就可以完成数据的重建。
  • 劣势

    1. 对于相同的数据集文件,AOF要比RDB要大
    2. 根据同步的策略不同,AOF在运行效率上往往低于RDB,AOF每修改就同步到硬盘上效率没有RDB高。
  • 配置

    linux目录中/usr/local/redis/redis-conf目录中找到这样的几行代码

    qppendonly no
    #the name of the append only file (defaultL:"appendonly.aof")
    
    appendfilename "appendonly.aof"
    

    如果使用AOF的持久化方式,需要把appendonly后面的属性变为yes

    appendonly.aof是用来记录所有修改操作的文件,这个文件还可以用来进行数据的恢复等,例如一条删除数据操作成功后,我们在appendonly.aof文件中把命令去掉,重新运行redis,之前的数据又恢复了

    #appendfSync always
    appendsync everysec
    #appendsync no
    

    这段代码是关于同步策略的一个设置,第一条是每修改就会同步持久化,第二条是每秒同步就会持久化一次,第三条是不会持久化同步

RedisCLuster

Redis Cluster是一种服务器Sharding技术(分片和路由都是在服务端实现),采用多主多从,每一个分区都是由一个Redis主机和多个从机组成,片区和片区之间是相互平行的。Redis Cluster集群采用了P2P的模式,完全去中心化。

img

如上图,官方推荐,集群部署至少要 3 台以上的master节点,最好使用 3 主 3 从六个节点的模式。Redis Cluster集群具有如下几个特点:

  • 集群完全去中心化,采用多主多从;所有的redis节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽。

  • 客户端与 Redis 节点直连,不需要中间代理层。客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可。

  • 每一个分区都是由一个Redis主机和多个从机组成,分片和分片之间是相互平行的。

  • 每一个master节点负责维护一部分槽,以及槽所映射的键值数据;集群中每个节点都有全量的槽信息,通过槽每个node都知道具体数据存储到哪个node上。

配置集群节点

cluster-enabled yes # 配置当前节点为集群节点

cluster-config-file nodes-${port}.conf # 配置cluster节点自身配置文件

cluster-node-timeout 15000 # 集群中各个节点相互通讯(ping)时,允许"失联"的最大毫秒数,如果超过这个时间没有得到响应,会认为该节点故障,若节点是主节点,则会进行故障转移

cluster-require-full-coverage yes # 若配置为 yes,则当集群中有节点不可用时,整个集群都不能提供服务,通常设置为 no

为什么会设计成 16384个槽

​ Redis Cluster 是Redis的集群实现,内置数据自动分片机制,集群内部将所有的key映射到16384个Slot中,集群中的每个Redis Instance负责其中的一部分的Slot的读写。集群客户端连接集群中任一Redis Instance即可发送命令,当Redis Instance收到自己不负责的Slot的请求时,会将负责请求Key所在Slot的Redis Instance地址返回给客户端,客户端收到后自动将原请求重新发往这个地址,对外部透明。一个Key到底属于哪个Slot由crc16(key) % 16384 决定。

1.如果槽位为65536,发送心跳信息的消息头达8k,发送的心跳包过于庞大。

在消息头中,最占空间的是 myslots[CLUSTER_SLOTS/8]。当槽位为65536时,这块的大小是: 65536÷8=8kb因为每秒钟,redis节点需要发送一定数量的ping消息作为心跳包,如果槽位为65536,这个ping消息的消息头太大了,浪费带宽。

2.redis的集群主节点数量基本不可能超过1000个。

如上所述,集群节点越多,心跳包的消息体内携带的数据越多。如果节点过1000个,也会导致网络拥堵。因此redis作者,不建议redis cluster节点数量超过1000个。那么,对于节点数在1000以内的redis cluster集群,16384个槽位够用了。没有必要拓展到65536个。

3.槽位越小,节点少的情况下,压缩率高。

Redis主节点的配置信息中,它所负责的哈希槽是通过一张bitmap的形式来保存的,在传输过程中,会对bitmap进行压缩,但是如果bitmap的填充率slots / N很高的话(N表示节点数),bitmap的压缩率就很低。如果节点数很少,而哈希槽数量很多的话,bitmap的压缩率就很低。而16384÷8=2kb,怎么样,神奇不!

Redis哨兵模式(sentinel)

​ Sentinel是用于监控redis集群中master状态的工具,是redis的高可用性解决方案,sentinel哨兵模式已经被集成在redis 2.4之后的版本中,sentinel系统可以监听一个或者多个redis master服务,以及这些master服务的所有从服务当某个master服务下线时,自动将master下的某个从服务升级为master服务代替已下线的master服务继续处理请求。

​ sentinel可以让redis实现主从复制,当一个集群中的master失效之后,由上面我们可以知道sentinel可以选举出一个新的master自动接替master的工作,集群中的其他redis服务器自动指向新的master同步数据,一般建议sentinel采用奇数台,防止某一台sentinel无法连接到master导致误切换。其结构如下

img

深入剖析Redis Sentinel

Redis 的哨兵模式虽然已经可以实现高可用,读写分离 ,但是存在几个方面的不足:

  • 哨兵模式下每台 Redis 服务器都存储相同的数据,很浪费内存空间;数据量太大,主从同步时严重影响了master性能。

  • 哨兵模式是中心化的集群实现方案,每个从机和主机的耦合度很高,master宕机到salve选举master恢复期间服务不可用。

  • 哨兵模式始终只有一个Redis主机来接收和处理写请求,写操作还是受单机瓶颈影响,没有实现真正的分布式架构。

Redis集群选举过程

gossip协议

gossip协议:各节点之间会保持通讯,当某一个节点挂掉或者新增的时候,与它相邻的节点就会感知到,这时候此节点就是失去链接或者创建链接

  • ping:每个节点会频繁的给其他节点发送ping,其中包含自己的状态还有自己维护的集群元数据,互相通过ping交换元数据
  • pong:返回ping和meet,包括自己的状态和其他信息,也可以用于信息广播和更新
  • fail:某个节点判断另一个节点fail之后,就发送fail给其它节点,通知其它节点,指定的节点宕机了
  • meet:某个节点发送meet给新加入的节点,让新节点加入集群中,然后新节点就会开始与其它节点通信,不需要发送形成网络的所需的所有cluster meet命令
redis集群选举原理

当slave发现自己的master不可用时,便尝试进行failover,以便成为新的master,由于挂掉的master可能会有多个slave,从而会存在多个slave竞争成为master节点的过程,其过程如下:

  1. slave发现自己的master不可用
  2. salve将记录集群的currentEpoch(选举周期)加1,并广播FAILOVER_AUTH_REQUEST信息进行选举
  3. 其他节点收到FAILOVER_AUTH_REQUEST信息后,只有其他的master可以进行响应,master收到信息返回FAILOVER_AUTH_ACK信息,对于同一个Epoch,只能响应一次ack
  4. slave收集master返回的ack消息
  5. slave判断收到的ack消息个数是否大于半数的master个数,若是,则成为新的master
  6. 广播pong消息通知其它集群节点,自己已经成为新的master

注意:从节点并不是在主节点一进入FAIL状态就马上尝试发起选举,而是有一定延迟,一定延迟确保我们等待FAIL状态在集群中传播,slave如果立即尝试选举,其它master或许尚未意识到FAIL状态,可能会拒绝投票。

redis各种集群方案的优缺点

主从模式:

  • 优点:主从结构具有读写分离,提高效率,数据备份,提供多个副本等优点
  • 缺点:最大的不足是主从模式不具备自动容错和恢复功能,主节点故障,集群则无法进行工作,可用性比较低,从节点升级为主节点需要人工干预

哨兵模式:

  • 优点:哨兵模式是基于主从模式的,解决主从模式中master故障不可以自动切换故障的问题
  • 缺点:是一种中心化的集群实现方案,始终只有一个redis主机来接收和处理写请求,写操作受单机瓶颈影响;集群里所有节点保存的是全量数据,浪费内存空间,没有真正实现分布式存储,数据量过大时,主从同步严重影响master性能;Redis主机宕机后,哨兵模式正在投票选举的情况之外,因为投票选举结束之前,谁也不知道主机和从机是谁,此时Redis也会开启保护机制,禁止写操作,直到选举出新的redis主机

redis 有序集合怎么实现的

zset-max-ziplist-entries 128
zset-max-ziplist-value 64

在时间允许的情况下会采取节约内存的方案,如果时间不允许了再使用占用更多内存的方案。

在理解这两个参数前,我们先简单了解下 ziplist 这种数据结构,顾名思义:压缩列表,怎么个压缩法,简单来说就是对于大的数据会用比较多点字节来存储,对于小的数据就用比较少的字节来存储

typedef struct zset {
 dict *dict;
 zskiplist *zsl;
} zset;

zset 结构体里有两个元素,一个是 dict,用来维护 数据 到 分数 的关系,一个是 zskiplist,用来维护 分数所在链表 的关系。zskiplist 结构体中有四个元素:分别是指向 头 和 尾 的指针,整个链表的长度,以及最大的 跳表层数。

跳表

https://zhuanlan.zhihu.com/p/54869087

什么是跳表

跳表实际上是对有序链表的改造,使我们可以使对链表进行二分查找。

假如对链表进行改造,先对链表中没两个节点建立第一级索引,再对第一级索引每两个节点建立二级索引,如下图所示:img

对于上图中的带二级索引的链表中,我们查询元素16,先从二级索引查询1->7->13,发现16大于13,然后通过13的down指针找到第一级索引的17,发现16小于17,再通过13的down指针找到链表中的16,只需要遍历10个节点,是不是效率上有所提升呢,由于数据量较小,遍历10个节点到遍历6个节点你可能觉得没有提升多少性能,请看下图:img

从图中我们可以看出,原来没有索引的时候,查找62需要遍历62个节点,现在只需要遍历11个节点,速度比之前提高了很多,所以链表的长度n比较大时,比如1000、10000的时候,在构建索引之后,查找效率的提升就会非常明显。这种带多级索引的链表就是跳表,有点像数据库中的索引

跳表有多快

​ 单链表的查找一个元素的时间复杂度为O(n),那么跳表的时间复杂度是多少?假如链表中有n个元素,我们每两个节点建立一个索引,那么第一级索引的节点个数就是n/2,第二级就是n/8,依此类推,也就是说第k级索引的节点个数是第k-1级索引的节点个数的1/2,那么第k级索引的节点个数为n除以2的k次方,即n/(2^k)。

​ 假设索引有h级,最高级的索引有2个节点,通过上面的公式,我们可以得到n/(2^h) = 2,得到h = log2n-1,包含原始链表这一层的话,跳表的高度就是log2n,假设每层需要访问m个节点,那么总的时间复杂度就是O(m*log2n)。而且每层需要访问的m个节点,m的最大值不超过3。img

因此跳表的时间复杂度为O(3log2n) = O(log2n)

为什么Redis使用跳表而不使用红黑树
  1. 红黑树在查找区间的效率没有跳表高,其他操作时间复杂度一致
  2. 相比红黑树,跳表的实现还是比较简单的,简单就意味着不容易出错
  3. 跳表更加灵活,通过改变索引构建策略,有效平衡效率和内存消耗

实现跳表的过程

Redis 缓存穿透

缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,我们数据库的 id 都是1开始自增上去的,如发起为id值为 -1 的数据或 id 为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大,严重会击垮数据库。

缓存穿透我会在接口层增加校验,比如用户鉴权校验,参数做校验,不合法的参数直接代码Return,比如:id 做基础校验,id <=0的直接拦截等。

Redis还有一个高级用法**布隆过滤器(Bloom Filter)**这个也能很好的防止缓存穿透的发生,他的原理也很简单就是利用高效的数据结构和算法快速判断出你这个Key是否在数据库中存在,不存在你return就好了,存在你就去查了DB刷新KV再return。

Redis缓存击穿

缓存击穿是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个完好无损的桶上砸开一个洞

缓存击穿的话,设置热点数据永远不过期。或者加上互斥锁就能搞定了

Redis缓存雪崩

缓存雪崩是因为大面积的缓存失效,打崩了DB,在批量往Redis存数据的时候,把每个Key的失效时间都加个随机值就好了,这样可以保证数据不会在同一时间大面积失效,我相信,Redis这点流量还是顶得住的。

setRedis(Key,value,time + Math.random() * 10000);

如果Redis是集群部署,将热点数据均匀分布在不同的Redis库中也能避免全部失效的问题,不过本渣我在生产环境中操作集群的时候,单个服务都是对应的单个Redis分片,是为了方便数据的管理,但是也同样有了可能会失效这样的弊端,失效时间随机是个好策略。

或者设置热点数据永远不过期,有更新操作就更新缓存就好了(比如运维更新了首页商品,那你刷下缓存就完事了,不要设置过期时间),电商首页的数据也可以用这个操作,保险。

布隆过滤器

Bloom Filter概念

布隆过滤器(Bloom Filter)是1970年由一个叫布隆的小伙子提出的,他实际上是一个很长的二进制向量和一系列随机映射函数,布隆过滤器可以用于检索一个元素是否在一个集合中,它的优点是空间效率和查询时间远远超过一般的算法,缺点是有一定的误识别率和删除困难。

Bloom Filter原理

原理就是一个对一个key进行k个hash算法获取k个值,在比特数组中将这k个值散列后设定为1,然后查的时候如果特定的这几个位置都为1,那么布隆过滤器判断该key存在。

布隆过滤器可能会误判,如果它说不存在那肯定不存在,如果它说存在,那数据有可能实际不存在;

Redis的bitmap只支持2^32大小,对应到内存也就是512MB,误判率万分之一,可以放下2亿左右的数据,性能高,空间占用率及小,省去了大量无效的数据库连接。

redis 分布式锁

我们在系统中修改已有数据时,需要先读取,然后进行修改保存,此时很容易遇到并发问题。由于修改和保存不是原子操作,在并发场景下,部分对数据的操作可能会丢失。在单服务器系统我们常用本地锁来避免并发带来的问题,然而,当服务采用集群方式部署时,本地锁无法在多个服务器之间生效,这时候保证数据的一致性就需要分布式锁来实现。

实现

Redis锁主要是利用Redis的setex命令。

  • 加锁命令:SETNX key value,当键不存在是,对键进行设置操作并返回成功,否则返回失败,key是锁的唯一标识,一般按业务名来决定命名
  • 解锁命令:DEL key ,通过删除键值对释放锁,以便其他线程可以通过SETNX命令来获取锁
  • 锁超时:EXPIRE key timeout,设置key的超时时间,以保证即使锁没有被显式释放,锁也可以在一定时间后自动释放,避免资源被永远锁住。

加锁解锁伪代码如下:

ifsetnx(key,1)==1{
    expire(k,30);
    try{
        //业务逻辑
    }finally{
        del(key);
    }
}

上面锁实现的方式存在一些问题:

setnx和expire非原子性

如果setnx成功,在设置锁超时时间后,服务器挂掉,重启或者网络问题等,导致expire命令没有执行,锁没有设置超时时间变成死锁

有很多开源代码来解决这个问题,比如使用 lua 脚本。示例:

if (redis.call('setnx', KEYS[1], ARGV[1]) < 1)
then return 0;
end;
redis.call('expire', KEYS[1], tonumber(ARGV[2]));
return 1;

// 使用实例
EVAL "if (redis.call('setnx',KEYS[1],ARGV[1]) < 1) then return 0; end; redis.call('expire',KEYS[1],tonumber(ARGV[2])); return 1;" 1 key value 100

锁误解除

如果线程A成功获得了锁,并且设置了过期时间30秒,但A执行时间超过了30秒,锁过期自动释放,此时线程B获得了锁,随后A执行完成,线程A使用DEL命令来释放锁,但此时B加的锁还没有执行完成,线程A实际释放的是线程B加的锁

通过在 value 中设置当前线程加锁的标识,在删除之前验证 key 对应的 value 判断锁是否是当前线程持有。可生成一个 UUID 标识当前线程,使用 lua 脚本做验证标识和解锁操作。

// 加锁
String uuid = UUID.randomUUID().toString().replaceAll("-","");
SET key uuid NX EX 30
// 解锁
if (redis.call('get', KEYS[1]) == ARGV[1])
 then return redis.call('del', KEYS[1])
else return 0
end

超时解锁导致并发

A、B两个线程发生并发显然是不被允许的,一般有两种方式解决该问题:

  • 将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成
  • 为获取锁的线程增加守护线程,为将要过期但为释放的锁增加有效时间

不可重入

当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的,如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败,Redis可通过对锁进行重入计数,加锁是加1,解锁时减1,当计数归0时释放锁。

无法等待锁释放

上述命令执行都是立即返回的,如果客户端可以等待锁释放就无法使用。

  • 可以通过客户端轮询的方式解决该问题,当未获取到锁时,等待一段时间重新获取锁,直到成功获取锁或等待超时。这种方式比较消耗服务器资源,当并发量比较大时,会影响服务器的效率。
  • 另一种方式是使用 Redis 的发布订阅功能,当获取锁失败时,订阅锁释放消息,获取锁成功后释放时,发送锁释放消息。

redis 线程模型

概述

Redis作为一个内存服务器,他需要处理很多来自外部网络请求,它使用I/O多路复用机制同时监听多个文件描述符的可读和可写状态,一旦收到网络请求就会在内存中快速处理,由于绝大多数的操作都是纯内存的,所以处理的速度会非常快。

在Redis4.0之后的版本,情况有了一些变动,新版Redis服务器在执行一些命令时就会使用[主处理线程]之外的其他线程,例如UNLINK、FLUSHALL ASYNC、FLUSHDB ASYNC等非阻塞的删除操作。

单线程模型

Redis从一开始就选择使用单线程模型处理来自客户端的绝大多数请求,这种考虑其实是多方面的,其中几个重要的原因如下:

  1. 使用单线程模型能够带来更好的可维护性,方便开发和调试
  2. 使用Redis单线程模型也能并发的处理客户端的请求(使用I/O多路复用机制并发处理来自客户端的多个连接,在 I/O 多路复用模型中,最重要的函数调用就是 select 以及类似函数,该方法的能够同时监控多个文件描述符(也就是客户端的连接)的可读可写情况,当其中的某些文件描述符可读或者可写时,select 方法就会返回可读以及可写的文件描述符个数。使用 I/O 多路复用技术能够极大地减少系统的开销,系统不再需要额外创建和维护进程和线程来监听来自客户端的大量连接,减少了服务器的开发成本和维护成本。)
  3. redis服务中运行的绝大多是操作的性能瓶颈都不是CPU,一个普通的 Linux 服务器上启动 Redis 服务,它也能在 1s 的时间内处理 1,000,000 个用户请求。如果这种吞吐量不能满足我们的需求,更推荐的做法是使用分片的方式将不同的请求交给不同的 Redis 服务器来处理,而不是在同一个 Redis 服务中引入大量的多线程操作。

简单总结一下,Redis 并不是 CPU 密集型的服务,如果不开启 AOF 备份,所有 Redis 的操作都会在内存中完成不会涉及任何的 I/O 操作,这些数据的读写由于只发生在内存中,所以处理速度是非常快的;整个服务的瓶颈在于网络传输带来的延迟和等待客户端的数据传输,也就是网络 I/O,所以使用多线程模型处理全部的外部请求可能不是一个好的方案。

多线程模型

我们可以在Redis中使用DEL命令来删除一个键对应的值,如果待删除的键值对占用了较小的内存空间,那么哪怕是同步地删除这些键值对也不会消耗太多的时间,但是对于redis中的一些超大键值对,几十MB或者更大的数据就不能再几毫秒内处理完,Redis可能会需要在释放内存空间上消耗较多的时间,这些操作就会阻塞待处理任务,影响redis的服务处理

Redis 集群扩容

Redis集群加入新节点主要分为如下几步:(1) 准备新节点 (2) 加入集群 (3) 迁移slot到新节点。即首先启动一个集群模式下的Redis节点,然后通过与任意一个集群中的节点握手使得新的节点加入集群,最后再向新的节点分配它负责的slot以及向其迁移slot对应的数据。由于Redis采用Gossip协议,所以可以让新节点与任意一个现有集群节点握手,一段时间后整个集群都会知道新节点的加入。

Redis Pipelining

Redis客户端与服务器之间使用TCP进行通信,并且很早就支持管道技术了,在某些高并发的场景下,网络开销成了Redis速度的瓶颈,所以需要使用管道技术来实现突破。

在介绍管道之前,先来想一下单条命令的执行步骤:

  1. 客户端把命令发送到服务器,然后阻塞服务端,等待着从socket读取服务器的返回结果
  2. 服务器处理命令并将结果返回给客户端

按照这样描述的话,每个命令的执行时间=客户端发送时间+服务器处理和返回的时间+一个网络来回的时间,其中网络来回的时间是不固定的,所以为了解决这种问题,Redis在很早之前就支持了pipeline技术,也就是说客户端可以一次发送多条命令,不用逐条等待命令的返回值,而是到最后一起读取返回结果,这样只需要一次网络开销,速度会得到明显的提升。

Redis的交互流程

管道不只是用来解决网络延迟的一种方法,它实际上是会提升Redis服务器每秒操作的总数,在解释原因之前,需要更加深入的了解redis命令处理的流程。img

一个完整的交互流程如下:

  1. 客户端进程调用write()把消息写入到操作系统内核为Socket分配的send buffer中
  2. 操作系统会把send buffer中的内容写入网卡,网卡在通过网关把内容发送到服务器端的网卡
  3. 服务器网卡会把接收到的消息写入操作系统为Socket分配的recv buffer
  4. 服务器进程调用read()读取消息进行处理
  5. 处理完成后调用write()把结果写入到服务器的send buffer
  6. 服务器操作系统再将send buffer中的内容写入网卡,然后发送客户端
  7. 客户端操作系统将网卡内容读到recv buffer中
  8. 客户端进程调用read()从recv buffer中读取消息并返回

现在我们可以把命令的执行时间细分为:

命令的执行时间=客户端调用write并写入网卡的时间+一次网络开销的时间+服务器网卡调用read时间+服务器处理数据时间+服务器调用write并写入网卡时间+客户端读取网卡并调用read时间。

这其中除了网络的开销,花费最长的就是进行系统调用read()和write()的时间了,使用管道时,多个命令只会进行一次read()andwrite()系统调用,因此管道会提高了Redis服务器处理命令的速度。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值