深入理解Redis

Redis基础可参考另一篇博文:NoSQL数据库之Redis

为什么要用Redis

主要从“高性能”和“高并发”这两点来看待这个问题。

高性能:

假如用户第一次访问数据库中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据存在数缓存中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度相当快。如果数据库中的对应数据改变的之后,同步改变缓存中相应的数据即可。

图片名称

高并发:

直接操作缓存能够承受的请求是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。

Redis的线程模型

redis 和 memcached 的区别

总结四点区别:

  1. redis支持更丰富的数据类型(支持更复杂的应用场景):Redis不仅仅支持简单的k/v类型的数据,同时还提供list,set,zset,hash等数据结构的存储。memcache支持简单的数据类型,String。
  2. Redis支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而Memecache把数据全部存在内存之中。
  3. 集群模式:memcached没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 redis 目前是原生支持 cluster 模式的.
  4. Memcached是多线程,非阻塞IO复用的网络模型;Redis使用单线程的多路 IO 复用模型。

多路I/O复用(epoll)

这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程
1.网络IO都是通过Socket实现,Server在某一个端口持续监听,客户端通过Socket(IP+Port)与服务器建立连接(ServerSocket.accept),成功建立连接之后,就可以使用Socket中封装的InputStream和OutputStream进行IO交互了。针对每个客户端,Server都会创建一个新线程专门用于处理;
2.默认情况下,网络IO是阻塞模式,即服务器线程在数据到来之前处于阻塞状态,等到数据到达,会自动唤醒服务器线程,着手进行处理。阻塞模式下,一个线程只能处理一个流的IO事件;
3.为了提升服务器线程处理效率,有以下三种思路:

  • 非阻塞(忙轮询):采用死循环方式轮询每一个流,如果有IO事件就处理,这样可以使得一个线程可以处理多个流,但是效率不高,容易导致CPU空转;
  • select代理(无差别轮询):可以观察多个流的IO事件,如果所有流都没有IO事件,则将线程进入阻塞状态,如果有一个或多个发生了IO事件,则唤醒线程去处理。但是还是得遍历所有的流,才能找出哪些流需要处理。如果流个数为N,则时间复杂度为O(N);
  • epoll代理:select代理有一个缺点,线程在被唤醒后轮询所有的Stream,还是存在无效操作。 epoll会将哪个流发生了怎样的I/O事件通知处理线程,因此对这些流的操作都是有意义的,复杂度降低到了O(1)。

单线程模型

Redis客户端对服务端的每次调用都经历了发送命令,执行命令,返回结果三个过程。其中执行命令阶段,由于Redis是单线程来处理命令的,所以每一条到达服务端的命令不会立刻执行,所有的命令都会进入一个队列中,然后逐个被执行。并且多个客户端发送的命令的执行顺序是不确定的。但是可以确定的是不会有两条命令被同时执行,不会产生并发问题,这就是Redis的单线程基本模型。

文件事件处理器(阅读):

redis基于reactor模式开发了网络事件处理器,这个处理器叫做文件事件处理器,file event handler。这个文件事件处理器,是单线程的,redis才叫做单线程的模型,采用IO多路复用机制同时监听多个socket,根据socket上的事件来选择对应的事件处理器来处理这个事件。 如果被监听的socket准备好执行accept、read、write、close等操作的时候,跟操作对应的文件事件就会产生,这个时候文件事件处理器就会调用之前关联好的事件处理器来处理这个事件。文件事件处理器是单线程模式运行的,但是通过IO多路复用机制监听多个socket,可以实现高性能的网络通信模型,又可以跟内部其他单线程的模块进行对接,保证了redis内部的线程模型的简单性。文件事件处理器的结构包含4个部分:多个socket,IO多路复用程序,文件事件分派器,事件处理器(命令请求处理器、命令回复处理器、连接应答处理器等)。多个socket可能并发的产生不同的操作,每个操作对应不同的文件事件,但是IO多路复用程序会监听多个socket,但是会将socket放入一个队列中排队,每次从队列中取出一个socket给事件分派器,事件分派器把socket给对应的事件处理器。然后一个socket的事件处理完之后,IO多路复用程序才会将队列中的下一个socket给事件分派器。文件事件分派器会根据每个socket当前产生的事件,来选择对应的事件处理器来处理。

enter description here

单线程高效原因

  1. 绝大部分请求是纯粹的内存操作,非常快速。数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1);
  2. 采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作;
  3. 核心是基于非阻塞的IO多路复用机制

Redis的五种数据结构

各种数据结构及使用场景

1.String

常用命令: set,get,decr,incr,mget 等。

String数据结构是简单的key-value类型,value其实不仅可以是String,也可以是数字。
常规key-value缓存应用:常规计数:微博数,粉丝数等。

2.Hash

常用命令: hget,hset,hgetall 等。

hash 是一个 string 类型的 field 和 value 的映射表,hash 特别适合用于存储对象,可以直接仅仅修改这个对象中的某个字段的值。 比如可以用hash数据结构来存储用户信息,商品信息:

key=JavaUser293847
value={
  “id”: 1,
  “name”: “SnailClimb”,
  “age”: 22,
  “location”: “Wuhan, Hubei”
}

3.List

常用命令: lpush,rpush,lpop,rpop,lrange等

list就是链表,Redis list的应用场景非常多,也是Redis最重要的数据结构之一,比如微博的关注列表,粉丝列表,消息列表等功能都可以用Redis的list结构来实现。

Redis list的实现为一个双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。

另外可以通过lrange命令,就是从某个元素开始读取多少个元素,可以基于list实现分页查询,这个很棒的一个功能,基于redis实现简单的高性能分页,可以做类似微博那种下拉不断分页的东西(一页一页的往下走),性能高。

4.Set

常用命令:
sadd,spop,smembers,sunion 等

set 对外提供的功能与list类似是一个列表的功能,特殊之处在于 set 是可以自动排重的。

当你需要存储一个列表数据,又不希望出现重复数据时,set是一个很好的选择,并且set提供了判断某个成员是否在一个set集合内的重要接口,这个也是list所不能提供的。可以基于 set 轻易实现交集、并集、差集的操作。
比如:在微博应用中,可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程,具体命令如下:

sinterstore key1 key2 key3     将交集存在key1内

还可以保存用户已经浏览过的信息,避免重复浏览。

5.Sorted Set

常用命令: zadd,zrange,zrem,zcard等

和set相比,sorted set增加了一个权重参数score,使得集合中的元素能够按score进行有序排列。

举例: 在直播系统中,实时排行信息包含直播间在线用户列表,各种礼物排行榜,弹幕消息(可以理解为按消息维度的消息排行榜)等信息,适合使用Redis中的Sorted Set结构进行存储。

Redis的底层数据结构

此部分内容参考https://www.cnblogs.com/ysocean/p/9080942.html及《Redis设计与实现》

通过OBJECT ENCODING key命令可以显示某一数据类型的底层数据结构。

比如对于string数据类型,可以看到实现string数据类型的数据结构有embstr以及int

1.简单动态字符串

Redis是用C语言写的,但是对于Redis的字符串,却不是C语言中的字符串(即以空字符’\0’结尾的字符数组),它是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型,并将SDS作为Redis的默认字符串表示。关键在于len字段保存了SDS保存字符串的长度。

2.链表

Redis的链表为双端队列

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

3.字典

字典又称映射(map),是一种用于保存键值对的抽象数据结构。字典中的每一个键key都是唯一的,通过key可以对值来进行查找或修改。C语言中没有内置这种数据结构的实现,所以字典依然是Redis自己构建的。Redis 的字典使用哈希表作为底层实现,采用了链地址法解决冲突

图片名称

4.跳跃表

跳跃表的简单实现可参考我的另一篇博文:手写一个跳表

Redis中跳表的定义如下:

typedef struct zskiplistNode {
    // 层
    struct zskiplistLevel{
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度
        unsigned int span;
    }level[];

    // 后退指针
    struct zskiplistNode *backward;
    // 分值
    double score;
    // 成员对象
    robj *obj;
} zskiplistNode

5.整数集合

整数集合(intset)是Redis用于保存整数值的集合抽象数据类型,它可以保存类型为int16_t、int32_t 或者int64_t 的整数值,并且保证集合中不会出现重复元素。定义如下:

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

整数集合的每个元素都是 contents 数组的一个数据项,它们按照从小到大的顺序排列,并且不包含任何重复项。length属性记录了contents数组的大小。需要注意的是虽然contents数组声明为int8_t类型,但是实际上contents 数组并不保存任何int8_t类型的值,其真正类型有encoding来决定。

6.压缩列表

压缩列表(ziplist)是Redis为了节省内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。

压缩列表的原理:压缩列表并不是对数据利用某种算法进行压缩,而是将数据按照一定规则编码在一块连续的内存区域,目的是节省内存。

其中zlbytes、zltail、zllen是压缩列表头( ziplist header ),entry1到 entryN是列表的结点部分,zlen 是结尾标记。可以简单地把整个整体当成一个十六进制的数。

具体可参考:https://blog.csdn.net/WhereIsHeroFrom/article/details/84718315

Redis五大数据类型的实现原理

Redis使用前面说的五大数据类型来表示键和值,每次在Redis数据库中创建一个键值对时,至少会创建两个对象,一个是键对象,一个是值对象,而Redis中的每个对象都是由redisObject结构来表示:

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

对象的type属性记录了对象的类型,这个类型就是前面的五大数据类型;

对象的prt指针指向对象底层的数据结构,而数据结构由encoding属性来决定,而每种类型的对象都至少使用了两种不同的编码:

1.String

字符串是Redis最基本的数据类型,不仅所有key都是字符串类型,其它几种数据类型构成的元素也是字符串。注意字符串的长度不能超过512M。字符串对象的编码可以是int,raw或者embstr。

  • int 编码:保存的是可以用 long 类型表示的整数值;
  • raw 编码:保存长度大于44字节的字符串(redis3.2版本之前是39字节,之后是44字节);
  • embstr 编码:保存长度小于44字节的字符串(redis3.2版本之前是39字节,之后是44字节),是专门用来保存短字符串的一种优化编码

embstr与raw都使用redisObject和sds保存数据,区别如下:

2.List

列表对象的编码可以是 ziplist(压缩列表) 和 linkedlist(双端链表)

3.Hash

哈希对象的编码可以是ziplist或者hashtable。当使用ziplist,也就是压缩列表作为底层实现时,新增的键值对是保存到压缩列表的表尾。hashtable编码的哈希表对象底层使用字典数据结构,哈希对象中的每个键值对都使用一个字典键值对。

4.Set

集合对象的编码可以是 intset 或者 hashtable。intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合中。hashtable编码的集合对象使用 字典作为底层实现,字典的每个键都是一个字符串对象,这里的每个字符串对象就是一个集合中的元素,而字典的值则全部设置为null。

5.SortedSet

有序集合的编码可以是 ziplist 或者 skiplist

ziplist编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个节点保存元素的分值。并且压缩列表内的集合元素按分值从小到大的顺序进行排列,小的放置在靠近表头的位置,大的放置在靠近表尾的位置。

skiplist编码的有序集合对象使用zet结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表:

typedef struct zset{
    //跳跃表
    zskiplist *zsl;
    //字典
    dict *dice;
} zset;

字典的键保存元素的值,字典的值则保存元素的分值;跳跃表节点的object属性保存元素的成员,跳跃表节点的 score属性保存元素的分值。这两种数据结构会通过指针来共享相同元素的成员和分值,所以不会产生重复成员和分值,造成内存的浪费。

参考:https://www.cnblogs.com/ysocean/p/9102811.html

Redis的过期策略

过期删除

策略为:定期删除+惰性删除

  • 定期删除:redis默认是每隔100ms就随机抽取一些设置了过期时间的key,检查其是否过期,如果过期就删除。如果不是随机抽取,那么每隔100ms就遍历所有的设置过期时间的key的话,就会给CPU带来很大的负载。
  • 惰性删除 :定期删除可能会导致很多过期 key 到了时间并没有被删除掉。所以就有了惰性删除。假如一个过期的key,靠定期删除没有被删除掉,还停留在内存里,除非去查一下那个key,才会被redis给删除掉

但是仅仅通过设置过期时间还是有问题的。如果定期删除漏掉了很多过期 key,然后也没及时去查,也就没走惰性删除,那么就会有大量过期key堆积在内存里,导致redis内存块耗尽了。为了解决这个问题呢,引出了redis的内存淘汰机制。

内存淘汰机制

可以设置内存最大使用量,当内存使用量超出时,会施行数据淘汰策略。

Redis 具体有6种淘汰策略

策略描述
volatile-lru从已设置过期时间的数据集中挑选最近最少使用的数据淘汰
volatile-ttl从已设置过期时间的数据集中挑选将要过期的数据淘汰
volatile-random从已设置过期时间的数据集中任意选择数据淘汰
allkeys-lru从所有数据集中挑选最近最少使用的数据淘汰
allkeys-random从所有数据集中任意选择数据进行淘汰
noeviction(默认)禁止驱逐数据

作为内存数据库,出于对性能和内存消耗的考虑,Redis 的淘汰算法实际实现上并非针对所有key,而是抽样一小部分并且从中选出被淘汰的key。

使用 Redis 缓存数据时,为了提高缓存命中率,需要保证缓存数据都是热点数据。可以将内存最大使用量设置为热点数据占用的内存量,然后启用 allkeys-lru 淘汰策略,将最近最少使用的数据淘汰。

LRU的简易实现

利用LinkedHashMap实现,将最近访问的放在头,将最老访问的放在尾部。

public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    
    private final int CACHE_SIZE;
 
    // 这里就是传递进来最多能缓存多少数据
    public LRUCache(int cacheSize) {
        // 设置一个hashmap的初始大小,同时最后一个true指的是让linkedhashmap按照访问顺序来进行排序,最近访问的放在头,最老访问的就在尾
        super((int) Math.ceil(cacheSize / 0.75) + 1, 0.75f, true); 
        CACHE_SIZE = cacheSize;
    }
 
    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        // 这个意思就是说当map中的数据量大于指定的缓存个数的时候,就自动删除最老的数据
        return size() > CACHE_SIZE;
    }
}

Redis的高并发与高可用

读写分离

一个系统只靠MySQL很难实现高并发(QPS十万以上),需要Redis缓存的支持。单机Redis能够承受的QPS大概在几万左右,有很大的瓶颈。若想支撑10万+的高并发,需要采用读写分离的方式。

p.s. 一般来说,对缓存都是用来支撑读高并发的,写的请求是比较少的,可能写请求只有一秒钟几千。

如果QPS继续增加,添加Redis的slave即可(水平扩容)。

主从复制

redis replication(主从复制)的核心机制

  1. redis采用异步方式复制数据到slave节点;
  2. 一个master node是可以配置多个slave node的,slave node也可以连接其他的slave node;
  3. slave node做复制的时候,是不会block master node的正常工作的;
  4. slave node在做复制的时候,也不会block对自己的查询操作,它会用旧的数据集来提供服务; 但是复制完成的时候,需要删除旧数据集,加载新数据集,这个时候就会暂停对外服务了;
  5. slave node主要用来进行横向扩容,做读写分离,扩容的slave node可以提高读的吞吐量;
  6. master节点,必须要使用持久化机制。

Redis主从复制可以根据是否是全量分为全量同步和增量同步

Redis全量复制一般发生在Slave初始化阶段,这时Slave需要将Master上的所有数据都复制一份。具体步骤如下:

  • 从服务器连接主服务器,发送SYNC命令;
  • 主服务器接收到SYNC命名后,开始执行BGSAVE命令生成RDB文件并使用缓冲区记录此后执行的所有写命令;
  • 主服务器BGSAVE执行完后,向所有从服务器发送快照文件,并在发送期间继续记录被执行的写命令;
  • 从服务器收到快照文件后丢弃所有旧数据,载入收到的快照;
  • 主服务器快照发送完毕后开始向从服务器发送缓冲区中的写命令;
  • 从服务器完成对快照的载入,开始接收命令请求,并执行来自主服务器缓冲区的写命令。

完成上面几个步骤后就完成了从服务器数据初始化的所有操作,从服务器此时可以接收来自用户的读请求。

Redis增量复制是指Slave初始化后开始正常工作时主服务器发生的写操作同步到从服务器的过程。 增量复制的过程主要是主服务器每执行一个写命令就会向从服务器发送相同的写命令,从服务器接收并执行收到的写命令。

哨兵模式

master挂掉后,数据无法写入Redis,整个系统就不可用了。

Redis的高可用架构,叫作故障转移failover,也可以叫作主备切换。这个在master node故障时,自动检测,并且将某个slave node自动切换为master node的过程,称为主备切换,需要使用Redis中的哨兵模式。

sentinal,中文名是哨兵

哨兵是Redis集群架构中非常重要的一个组件,主要功能如下:

  • 集群监控:负责监控Redis master和slave进程是否正常工作;
  • 消息通知:如果某个Redis实例有故障,那么哨兵负责发送消息作为报警通知给管理员;
  • 故障转移:如果master node挂掉了,会自动转移到slave node上;
  • 配置中心:如果故障转移发生了,通知client客户端新的master地址。

哨兵本身也是分布式的,作为一个哨兵集群去运行,互相协同工作:

  • 故障转移时,判断一个master node是宕机了,需要大部分的哨兵都同意才行,涉及到了分布式选举的问题;
  • 即使部分哨兵节点挂掉了,哨兵集群还是能正常工作的,因为如果一个作为高可用机制重要组成部分的故障转移系统本身是单点的,那可用性太低;
  • 哨兵至少需要3个实例,来保证自己的健壮性。

经典的3节点哨兵集群

如果sentinel-1所在机器宕机了,那么三个哨兵还剩下2个,S2和S3可以一致认为master宕机,然后选举出一个来执行故障转移。

两种失败状态:

sdown是主观宕机,一个哨兵如果自己觉得一个master宕机了,那么就是主观宕机;sdown达成的条件很简单,如果一个哨兵ping一个master,超过了is-master-down-after-milliseconds指定的毫秒数之后,就主观认为master宕机。

odown是客观宕机,如果quorum数量的哨兵都觉得一个master宕机了,那么就是客观宕机;sdown到odown转换的条件很简单,如果一个哨兵在指定时间内,收到了quorum指定数量的其他哨兵也认为那个master是sdown了,那么就认为是odown了,客观认为master宕机。

quorum和majority:

每次一个哨兵要做主备切换,首先需要quorum数量的哨兵认为odown,然后选举出一个哨兵来做切换,这个哨兵还要得到majority哨兵的授权,才能正式执行切换。如果quorum < majority,比如5个哨兵,majority就是3,quorum设置为2,那么就3个哨兵授权就可以执行切换;但是如果quorum >= majority,那么必须quorum数量的哨兵都授权,比如5个哨兵,quorum是5,那么必须5个哨兵都同意授权,才能执行切换。

哨兵选举机制可参考:https://www.cnblogs.com/mseddl/p/11495405.html

数据丢失的问题

主备切换的过程,可能会导致数据丢失,主要分为以下两种情况。

异步复制导致的丢失

因为master -> slave的复制是异步的,所以可能有部分数据还没复制到slave,master就宕机了,此时这些部分数据就丢失了。

解决的办法为配置两个参数:

min-slaves-to-write 1
min-slaves-max-lag 10

要求至少有1个slave,数据复制和同步的延迟不能超过10秒,如果说一旦所有的slave,数据复制和同步的延迟都超过了10秒钟,那么这个时候,master就不会再接收任何请求了,上面两个配置可以减少异步复制和脑裂导致的数据丢失。

脑裂导致的丢失

脑裂,也就是说某个master所在机器突然脱离了正常的网络,跟其他slave机器不能连接,但是实际上master还运行着。此时哨兵可能就会认为master宕机了,然后开启选举,将其他slave切换成了master。这个时候,集群里就会有两个master,也就是所谓的脑裂。

此时虽然某个slave被切换成了master,但是可能client还没来得及切换到新的master,还继续写向旧master的数据可能也丢失了。因此旧master再次恢复的时候,会被作为一个slave挂到新的master上去,自己的数据会清空,重新从新的master复制数据。

Redis cluster集群模式

Redis cluster介绍

前面所述的哨兵模式,master能容纳多少数据量,那么整个系统也就能容纳多大的数据量。

几年前,如果想配置一个Redis集群,需要利用一些中间件来实现,比如codis、twemproxy等。近两年Redis也在不断发展,Redis原生的集群模式Redis cluster已经成为了主流。

Redis cluster支撑多个Redis master node,每个master node又可以挂载多个slave node,且支持读写分离及slave到master的自动切换过程。因此redis cluster = 多master + 读写分离 + 高可用,我们只要基于redis cluster去搭建redis集群即可,不需要手工去搭建replication复制 + 主从架构 + 读写分离 + 哨兵集群 + 高可用。

p.s. Redis cluster对读写分离支持不好,不适合读写分离,读写直接走Master,通过扩容提高并发量。

一致性哈希算法

普通的Hash算法,如果一个节点宕机,则几乎全部节点的数据都要重分布(因为要由对n取模变为对n-1取模)。

查找一个key首先计算其hash值(设计哈希函数 Hash(key),要求取值范围为 [0, 2^32) ),然后找到其落在圆环上的位置,接着就会顺时针旋转去寻找距离自己最近的节点,如果有一个节点宕机,那么它只会影响其中1/n的缓存数据(上一个节点到宕机节点之间的数据)。

但是这种方案的问题在于:节点数越少,越容易出现节点在哈希环上的分布不均匀,导致各节点映射的对象数量严重不均衡(数据倾斜)。当增加节点和减少节点时,这种数据倾斜会更加严重。

但实际部署的物理节点有限,我们可以用有限的物理节点,虚拟出足够多的虚拟节点(Virtual Node),最终达到数据在哈希环上均匀分布的效果。虚拟节点哈希值的计算方法调整为:对“节点的IP(或机器名)+虚拟节点的序号(1~N)”作哈希。

参考:https://blog.csdn.net/kefengwang/article/details/81628977

Hash slot

Redis 集群没有并使用传统的一致性哈希来分配数据,而是采用另外一种叫做哈希槽 (hash slot)的方式来分配的。redis cluster 默认分配了16384 个slot,当我们set一个key 时,会用CRC16算法来取模得到所属的slot,然后将这个key分到哈希槽区间的节点上,具体算法就是:CRC16(key) % 16384。每一个实节点负责维护一部分槽以及槽所映射的键值数据。

下图展现一个五个节点构成的集群,每个节点平均大约负责3276个槽,以及通过计算公式映射到对应节点的对应槽的过程:

hash slot让node的增加和移除很简单,增加一个master,就将其他master的hash slot移动部分过去,减少一个master,就将它的hash slot移动到其他master上去,且移动hash slot的成本是非常低的。

gossip协议通信

redis cluster节点间采取gossip协议进行通信,跟集中式不同,不是将集群元数据(节点信息,故障,等等)集中存储在某个节点上,而是互相之间不断通信,保持整个集群所有节点的数据是完整的。交换的信息包括:故障信息,节点的增加和移除,hash slot信息等。

图片名称

这也就解释了为什么Redis cluster设置了16384个槽:由于使用CRC16算法,该算法可以产生2^16-1=65535个值,为什么不用65535个槽呢?因为Redis的一个节点的心跳信息中需要携带该节点的所有配置信息,而16K大小的槽数量所需要耗费的内存为2K,但如果使用65K个槽,这部分空间将达到8K,心跳信息就会很庞大。

p.s. smart jedis的JedisCluster可以方便地操作redis cluster集群。

redis cluster vs replication + sentinal

如果数据量很少,主要是承载高并发高性能的场景,比如缓存一般就只有个G,单机足够了自己搭建一个sentinal集群,去保证redis主从架构的高可用性,就可以了;

redis cluster主要是针对海量数据+高并发+高可用的场景,海量数据,如果数据量很大,那么就用redis cluster。

最后,可以对比一下集群模式与哨兵模式:https://blog.csdn.net/angjunqiang/article/details/81190562

Redis的缓存雪崩和穿透

缓存雪崩

简介:缓存同一时间大面积的失效,所以后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉,进而系统崩溃,发生雪崩。

图片名称

解决办法:

  • 事前:尽量保证整个 Redis 集群的高可用性,发现机器宕机尽快补上,并选择合适的内存淘汰策略;
  • 事中:本地ehcache缓存 + hystrix限流&降级,避免MySQL崩掉;
  • 事后:利用 redis 持久化机制保存的数据尽快恢复缓存。

缓存穿透

简介:一般是黑客故意去请求缓存中不存在的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。

解决办法: 有很多种方法可以有效地解决缓存穿透问题,最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。另外也有一个更为简单粗暴的方法,如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。

缓存击穿

缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。

解决方案:https://blog.csdn.net/zl1zl2zl3/article/details/85054518

缓存与数据库双写的一致性

Cache Aside Pattern

  1. 读的时候,先读缓存,缓存没有的话,那么就读数据库,然后取出数据后放入缓存,同时返回响应;
  2. 更新的时候,先删除缓存,然后再更新数据库

question1:为什么是删除缓存,而不是更新缓存呢?

举个例子,一个缓存涉及的表的字段,在1分钟内就修改了100次,如果每次都更新数据,那么缓存就要更新100次, 但是这个缓存在1分钟内可能就被读取了1次,有大量的冷数据。实际上,如果你只是删除缓存的话,那么1分钟内,这个缓存不过就重新计算一次而已,开销大幅度降低。这是一种懒加载的思想。

question2:为什么先操作缓存,再操作数据库?

如果先修改数据库,再删除缓存,如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据出现不一致。所以必须先删除缓存中的数据。

高并发下的双写一致

数据发生了变更,先删除了缓存,然后要去修改数据库,还没来得及修改,一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中,随后数据变更的程序完成了数据库的修改,这就产生了高并发下的数据不一致。

解决方案:

更新数据的时候,根据数据的唯一标识,将操作路由之后,发送到一个JVM内部队列中。读取数据的时候,如果发现数据不在缓存中,那么将重新读取数据 + 更新缓存的操作,根据唯一标识路由之后,也发送到同一个JVM内部队列中。一个队列对应一个工作线程,每个工作线程串行拿到对应的操作,然后一条一条的执行。

这样的话,一个数据变更的操作,先删除缓存,然后再去更新数据库,但是还没完成更新。此时如果一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,然后同步等待缓存更新完成。这是一种串行化的解决方案。

这里有一个优化点,一个队列中,其实多个更新缓存请求串在一起是没意义的,因此可以做过滤,如果发现队列中已经有一个更新缓存的请求了,那么就不用再放个更新请求操作进去了,直接等待前面的更新操作请求完成即可。

可参考:https://www.sohu.com/a/328141954_673711

Redis的并发竞争问题

所谓 Redis 的并发竞争 Key 的问题也就是多个系统同时对一个 key 进行操作,但是最后执行的顺序和我们期望的顺序不同,这样也就导致了结果的不同。redis没有像db中的sql语句,update val = val + 10 where …,无法使用这种方式进行对数据的更新。假如有某个key = “price”,value值为10,现在想把value值进行+10操作。正常逻辑下,就是先把数据key为price的值读回来,加上10,再把值给设置回去。如果只有一个连接的情况下,这种方式没有问题,可以工作得很好,但如果有两个连接时,两个连接同时想对还price进行+10操作,就可能会出现不一致的问题了。

解决方案:zookeeper + 时间戳

利用zookeeper分布式锁,确保在同一时间,只能有一个系统实例在操作某个key,别的系统不允许读写。

在每次要写之前,先判断一下的当前这个value的时间戳是否比缓存里的value时间戳要更新,如果更新,那么可以写入,否则放弃写入。

p.s. 一个实际生产环境下的Redis集群部署

redis cluster模式,共10台机器,5台机器部署了Redis主实例,另外5台机器部署了Redis的从实例,每个主实例挂了一个从实例,5个节点对外提供读写服务,每个节点的读写高峰qps可能可以达到每秒5万,5台机器最多是25万读写请求/s。机器的配置为32G内存+8核CPU+1T磁盘,但是分配给redis进程的是10g内存(线上生产环境Redis的内存尽量不要超过10g,否则可能会有问题)。5台机器对外提供读写,一共有50g内存。因为每个主实例都挂了一个从实例,所以是高可用的,任何一个主实例宕机,都会自动故障迁移,Rredis从实例会自动变成主实例继续提供读写服务

商品数据,每条数据是10kb。100条数据是1Mb,10万条数据是1G。常驻内存的是200万条商品数据,占用内存是20g,仅仅不到总内存的50%。目前高峰期每秒就是3500左右的请求量。
  • 4
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值