【已迁移到Github不再维护】【缓存篇】Redis大全

数据结构

  redis 相比 memcached 来说,拥有更多的数据结构,能支持更丰富的数据操作。如果需要缓存能够支持更复杂的结构和操作, redis 会是不错的选择。

  redis 其实都是key,value的结构,但是value支持以下几种数据类型:string、hash、list、set、sorted set

  字符串表示:

  众所周知,Redis是用C语言来实现的,在C语言中,String这个类型,其实就是一个char数组,比如char data[]="xxx\0",但是,客户端往Redis发送set命令,是可以发任意的字符串的,是没有校验的,所以假如我们发了一个字符串xx\0xx,那么\0后面的xx是不会读的,只会读前面的xx(C语言中用"\0"表示字符串结束,如果字符串本身就有"\0"字符,字符串就会被截断)。

  所以Redis自实现了一个string叫sds,sds中记录了一个len和一个char buf[],len用来记录buf的长度,比如char buf[] = "xx\0xx",那么len就是5,sds中还有一个比较重要的属性就是free,表示还剩余多少。

  编码

  • String:存储数字的话,采用int类型的编码,如果是非数字的话,采用 raw 编码;
  • Hash:哈希表
    • 如果hash不够,会有rehash过程,这个过程不是一次性拓容的,而是惰性拓容的。实现堕性拓容的方式是加一个字段表示是否被rehash
  • List:字符串长度及元素个数小于一定范围使用 ziplist 编码,任意条件不满足,则转化为 quickList 编码;
    • 有zipList和quickList两种实现
    • zipList是压缩的列表,其实是内存连续的,用协议把变长的元素组织起来
    • quickList是zipList和linkedList的结合。
  • Set:保存元素为整数及元素个数小于一定范围使用 intset 编码,任意条件不满足,则使用 hashtable 编码;
  • Zset:zset 对象中保存的元素个数小于及成员长度小于一定值使用 ziplist 编码,任意条件不满足,则使用 skiplist 编码。
    • 用跳表实现的数据结构
    • 每一个元素(简称 setElement )都会存在一个数组的一个桶中,hash即数组的随机访问+选择随机位置的函数
    • 跳表其实是一个List,List里面的node元素存放着level数组及setElement,level数组代表该node有多少跳,level数组的元素在同一个级别上,会相互链接形成双向链表。
    • 跳表的一个优点是节点有序,适合内存存储。实现简单。

  各个数据类型的场景、实现原理。。。待续

过期策略

  redis 过期策略是:定期删除+惰性删除+内存淘汰机制。

  定期删除(定期抽样删除):指的是 redis 默认是每隔 100ms 就随机抽取一些设置了过期时间的 key,检查其是否过期,如果过期就删除。假设 redis 里放了 10w 个 key,都设置了过期时间,你每隔几百毫秒,就检查 10w 个 key,那 redis 基本上就死了,cpu 负载会很高的,消耗在你的检查过期 key 上了。注意,这里可不是每隔 100ms 就遍历所有的设置过期时间的 key,那样就是一场性能上的灾难。实际上 redis 是每隔 100ms 随机抽取一些 key 来检查和删除的。

  但是问题是,定期删除可能会导致很多过期 key 到了时间并没有被删除掉,那咋整呢?所以就是惰性删除了。

  惰性删除:这就是说,在你获取某个 key 的时候,redis 会检查一下 ,这个 key 如果设置了过期时间那么是否过期了?如果过期了此时就会删除,不会给你返回任何东西。

获取 key 的时候,如果此时 key 已经过期,就删除,不会返回任何东西。

  但是实际上这还是有问题的,如果定期删除漏掉了很多过期 key,然后你也没及时去查,也就没走惰性删除,此时会怎么样?如果大量过期 key 堆积在内存里,导致 redis 内存块耗尽了,咋整?

  内存淘汰机制:redis 内存淘汰机制有以下几个:

  • noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错,这个一般没人用吧,实在是太恶心了。
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)。
  • allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key,这个一般没人用吧,为啥要随机,肯定是把最近最少使用的 key 给干掉啊。
  • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key(这个一般不太合适)。
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。

持久化策略

  RDB:RDB持久化是把当前进程数据生成快照保存到硬盘的过程,触发RDB持久化过程分为手动触发和自动触发。

  优点

  • RDB是一个紧凑压缩的二进制文件,代表Redis在某一个时间点上的数据快照,非常适合用于备份,全量复制等场景。
  • Redis加载RDB恢复数据远远快于AOF方式
  • RDB对redis对外提供读写服务的时候,影像非常小,因为redis 主进程只需要fork一个子进程出来,让子进程对磁盘io来进行rdb持久化
    • fork因为用到了虚拟页和页帧做了copy on write的操作,所以效率非常高

  缺点

  • RDB方式数据没办法做到实时持久化/秒级持久化。因为bgsave每次运行都要执行fork操作创建子进程,属于重量级操作,频繁执行成本过高。
  • RDB文件使用特定二进制格式保存,Redis版本演进过程中有多个格式的RDB,存在老版本Redis服务无法兼容新版RDB格式的问题。 针对RDB不适合实时持久化的问题,Redis提供了AOF持久化方式来解决
  • 如果redis要故障时要尽可能少的丢失数据,RDB没有AOF好,例如1:00进行的快照,在1:10又要进行快照的时候宕机了,这个时候就会丢失10分钟的数据

   AOF:(append only file)持久化:以独立日志的方式记录每次写命令,重启时再重新执行AOF文件中命令达到恢复数据的目的。AOF的主要作用是解决了数据持久化的实时性,目前已经是Redis持久化的主流方式

  AOF日志存储的是Redis服务器的顺序指令序列,AOF日志只记录对内存进行修改的指令记录.通过重放指令,可以恢复Redis实例的内存数据结构.

  Redis在收到命令后会先执行,再记录日志,这不同与levelDB,hbase等存储引擎,他们都是先存储日志再做逻辑处理.(因为redis在命令未被执行前是不会检查语言是否正确的,如果写记录日志,那可能会把有语法错误的写操作写入AOF日志中)

  fsync:

  AOF 日志是以文件的形式存在的,当程序对 AOF 日志文件进行写操作时,实际上是将内容写到了内核为文件描述符分配的一个内存缓存中,然后内核会异步将脏数据刷回到磁盘的

  fsync(int fd)函数可以将指定文件的内容强制从内核缓存刷到磁盘 策略:

  • 每隔 1s 左右执行一次 fsync 操作,周期 1s 是可以配置的。

  • 一个是永不 fsync——让操作系统来决定何时同步磁盘,很不安全。

  • 来一个指令就 fsync 一次——非常慢。

  优点

  • AOF可以更好的保护数据不丢失,一般AOF会以每隔1秒,通过后台的一个线程去执行一次fsync操作,如果redis进程挂掉,最多丢失1秒的数据。
  • AOF以appen-only的模式写入,所以没有任何磁盘寻址的开销,写入性能非常高。
  • AOF日志文件的命令通过非常可读的方式进行记录,这个非常适合做灾难性的误删除紧急恢复,如果某人不小心用flushall命令清空了所有数据,只要这个时候还没有执行rewrite,那么就可以将日志文件中的flushall删除,进行恢复。
  缺点
  • 对于同一份文件AOF文件比RDB数据快照要大。
  • AOF开启后支持写的QPS会比RDB支持的写的QPS低,因为AOF一般会配置成每秒fsync操作,每秒的fsync操作还是很高的
  • 数据恢复比较慢,不适合做冷备。

  混合持久化将 rdb 文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是自持久化开始到持久化结束的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小。

  • 不要仅仅使用RDB这样会丢失很多数据。
  • 也不要仅仅使用AOF,因为这一会有两个问题,第一通过AOF做冷备没有RDB做冷备恢复的速度快;第二RDB每次简单粗暴生成数据快照,更加健壮。
  • 综合AOF和RDB两种持久化方式,用AOF来保证数据不丢失,作为恢复数据的第一选择;用RDB来做不同程度的冷备,在AOF文件都丢失或损坏不可用的时候,可以使用RDB进行快速的数据恢复。

  AOF的缓冲大小设置

  Redis会把对自己状态产生修改的指令记录在本地内存buffer中,然后异步将buffer中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一样的状态,一边向主节点反馈自己同步到哪里了(偏移量).

  因为内存的buffer是有限的,所以redis主节点不能将所有的指令都记录在buffer中,redis的内存复制buffer是一个定长的环形数组,如果数组内容满了,就会从头开始覆盖前面的内容,所以缓存buffer的大小,直接决定了AOF主从同步阻塞的时间大小,不管仅用AOF还是AOF和RDB一起用,都有可能失败异常。

主从架构:

  主从模式:

  保障Redis缓存系统的高可用和高并发的原理,主要包括两个内容:主从架构模式,哨兵集群

  单机的 Redis,能够承载的 QPS 大概就在上万到几万不等。对于缓存来说,一般都是用来支撑读高并发的。因此架构做成主从(master-slave)架构,一主多从,主负责写,并且将数据复制到其它的 slave 节点,从节点负责读。所有的读请求全部走从节点。这样也可以很轻松实现水平扩容,支撑读高并发。

  主从架构图

  核心机制

  • Redis 采用异步方式复制数据到 slave 节点,不过 Redis2.8 开始,slave node 会周期性地确认自己每次复制的数据量;
  • 一个 master node 是可以配置多个 slave node 的; slave node 也可以连接其他的 slave node; slave node 做复制的时候,不会 block master node 的正常工作;
  • slave node 在做复制的时候,也不会 block 对自己的查询操作,它会用旧的数据集来提供服务;但是复制完成的时候,需要删除旧数据集,加载新数据集,这个时候就会暂停对外服务了;
  • slave node 主要用来进行横向扩容,做读写分离,扩容的 slave node 可以提高读的吞吐量。

  全量复制

  当启动一个 slave node 的时候,它会发送一个 PSYNC 命令给 master node。  如果这是 slave node 初次连接到 master node,那么会触发一次 full resynchronization 全量复制。

  • Master视觉:此时 master 会启动一个后台线程,开始生成一份 RDB 快照文件,同时还会将从客户端 client 新收到的所有写命令缓存在内存中。 RDB 文件生成完毕后, master 会将这个 RDB 发送给 slave,slave 会先写入本地磁盘,然后再从本地磁盘加载到内存中,接着 master 会将内存中缓存的写命令发送到 slave,slave 也会同步这些数据。slave node 如果跟 master node 有网络故障,断开了连接,会自动重连,连接之后 master node 仅会复制给 slave 部分缺少的数据。
    • master 执行 bgsave ,在本地生成一份 rdb 快照文件。
    • master node 将 rdb 快照文件发送给 slave node,如果 rdb 复制时间超过 60 秒(repl-timeout),那么 slave node 就会认为复制失败,可以适当调大这个参数(对于千兆网卡的机器,一般每秒传输 100MB,6G 文件,很可能超过 60s)
    • master node 在生成 rdb 时,会将所有新的写命令缓存在内存中,在 slave node 保存了 rdb 之后,再将新的写命令复制给 slave node。
    • 如果在复制期间,因为内存缓冲区是一个环数组,所以持续消耗超过 64MB,或者一次性超过 256MB,那么停止复制,复制失败。
    • master 每次接收到写命令之后,先在内部写入数据,然后异步发送给 slave node。
  • Slave视觉:slave node 内部有个定时任务,每秒检查是否有新的 master node 要连接和复制,如果发现,就跟 master node 建立 socket 网络连接。然后 slave node 发送 ping 命令给 master node。如果 master 设置了 requirepass,那么 slave node 必须发送 masterauth 的口令过去进行认证。master node 第一次执行全量复制,将所有数据发给 slave node。而在后续,master node 持续将写命令,异步复制给 slave node。
    • slave node 接收到 rdb 之后,清空自己的旧数据,然后重新加载 rdb 到自己的内存中,同时基于旧的数据版本对外提供服务。
    • 如果 slave node 开启了 AOF,那么会立即执行 BGREWRITEAOF,重写 AOF。

  断点续传

  master node 会在内存中维护一个 backlog,master 和 slave 都会保存一个 replica offset 还有一个 master run id,offset 就是保存在 backlog 中的。如果 master 和 slave 网络连接断掉了,slave 会让 master 从上次 replica offset 开始继续复制,如果没有找到对应的 offset,那么就会执行一次 resynchronization 。

  其他

  • RunID:如果根据 host+ip 定位 master node,是不靠谱的,如果 master node 重启或者数据出现了变化,那么 slave node 应该根据不同的 run id 区分。
  • 过期同步:slave 不会过期 key,只会等待 master 过期 key。如果 master 过期了一个 key,或者通过 LRU 淘汰了一个 key,那么会模拟一条 del 命令发送给 slave。
  • 心跳保活:主从节点互相都会发送 heartbeat 信息,master 默认每隔 10 秒发送一次 heartbeat,slave node 每隔 1 秒发送一个 heartbeat。

哨兵模式:

  哨兵介绍

  sentinel,中文名是哨兵。哨兵是 Redis 集群架构中非常重要的一个组件,主要有以下功能:

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

  哨兵用于实现 Redis 集群的高可用,本身也是分布式的,作为一个哨兵集群去运行,互相协同工作。  

  • 故障转移时,判断一个 master node 是否宕机了,需要大部分的哨兵都同意才行,涉及到了分布式选举的问题。
  • 即使部分哨兵节点挂掉了,哨兵集群还是能正常工作的,因为如果一个作为高可用机制重要组成部分的故障转移系统本身是单点的,那就很坑爹了。

  核心知识

  • 监控参数:quorum,这个数字代表如果有quorum个哨兵认为master宕机了,那么就客观的认为宕机了。majority代表执行故障转移必须要存活的sentinel数量
    • 每次一个哨兵要做主备切换,首先需要 quorum 数量的哨兵认为 odown,然后选举出一个哨兵来做切换,这个哨兵还需要得到 majority 哨兵的授权,才能正式执行切换。
    • 如果 quorum < majority,比如 5 个哨兵,majority 就是 3,quorum 设置为 2,那么就 3 个哨兵授权就可以执行切换。  但是如果 quorum >= majority,那么必须 quorum 数量的哨兵都授权,比如 5 个哨兵,quorum 是 5,那么必须 5 个哨兵都同意授权,才能执行切换。
  • 转移策略:哨兵至少需要 3 个实例,来保证自己的健壮性。三个主要是因为脑裂问题、选举决策问题。用一个majority值来做故障转移决定
    • 如果3个实例,majority=2,那么就是说,必须有2个哨兵一致认为某个master宕机才会进行故障转移
    • 指向故障转移的时候,需要在majority中选举一个哨兵出来执行。
  • 丢失无保障:哨兵 + Redis 主从的部署架构,是不保证数据零丢失的,只能保证 Redis 集群的高可用性。
  • sdown和odown
    • sdown 是主观宕机,就一个哨兵如果自己觉得一个 master 宕机了,那么就是主观宕机
    • odown 是客观宕机,如果 quorum 数量的哨兵都觉得一个 master 宕机了,那么就是客观宕机
    • sdown 达成的条件很简单,如果一个哨兵 ping 一个 master,超过了 is-master-down-after-milliseconds 指定的毫秒数之后,就主观认为 master 宕机了;如果一个哨兵在指定时间内,收到了 quorum 数量的其它哨兵也认为那个 master 是 sdown 的,那么就认为是 odown 了。

  一般的哨兵模式如下所示:M代表master,R代表slave,S代表sentinel哨兵

       +----+
       | M1 |
       | S1 |
       +----+
          |
+----+    |    +----+
| R2 |----+----| R3 |
| S2 |         | S3 |
+----+         +----+

  配置 quorum=2 ,如果 M1 所在机器宕机了,那么三个哨兵还剩下 2 个,S2 和 S3 可以一致认为 master 宕机了,然后选举出一个来执行故障转移,同时 3 个哨兵的 majority 是 2,所以还剩下的 2 个哨兵运行着,就可以允许执行故障转移。

  通讯机制

  哨兵互相之间的发现,是通过 Redis 的 pub/sub 系统实现的,每个哨兵都会往 __sentinel__:hello 这个 channel 里发送一个消息,这时候所有其他哨兵都可以消费到这个消息,并感知到其他的哨兵的存在。  

  每隔两秒钟,每个哨兵都会往自己监控的某个 master+slaves 对应的 __sentinel__:hello channel 里发送一个消息,内容是自己的 host、ip 和 runid 还有对这个 master 的监控配置。  

  每个哨兵也会去监听自己监控的每个 master+slaves 对应的 __sentinel__:hello channel,然后去感知到同样在监听这个 master+slaves 的其他哨兵的存在。  每个哨兵还会跟其他哨兵交换对 master 的监控配置,互相进行监控配置的同步。

  选举算法:  

  如果一个 master 被认为 odown 了,而且 majority 数量的哨兵都允许主备切换,那么某个哨兵就会执行主备切换操作,此时首先要选举一个 slave 来,会考虑 slave 的一些信息:

  • 跟 master 断开连接的时长
  • slave 优先级
  • 复制 offset
  • run id

  如果一个 slave 跟 master 断开连接的时间已经超过了 down-after-milliseconds 的 10 倍,外加 master 宕机的时长,那么 slave 就被认为不适合选举为 master。

  (down-after-milliseconds * 10) + milliseconds_since_master_is_in_SDOWN_state

  接下来会对 slave 进行排序:

  • 按照 slave 优先级进行排序,slave priority 越低,优先级就越高。
  • 如果 slave priority 相同,那么看 replica offset,哪个 slave 复制了越多的数据,offset 越靠后,优先级就越高。
  • 如果上面两个条件都相同,那么选择一个 run id 比较小的那个 slave。
  • 如果转换哨兵宕机了,其他哨兵会发生超时然后继续选一个操作。

  数据丢失

  • 情况1:主机宕机:因为主从同步是异步的,所以无法保证数据不丢失。
  • 情况2:脑裂:因为脑裂导致写入数据不相同,然后恢复后只有一个Master,会导致另一个master被覆盖。
min-slaves-to-write 2
min-slaves-max-lag 10
  • 解决方案:如何避免呢?可以设置master和n个slave通讯的复制超时时间
    • 上面代表,如果2个slave都超过了10s,那么master停止接受数据,尽量减少损失。

Cluster模式:

  在 redis3.x 版本中,便能支持 cluster 模式,而 memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据。Redis cluster 功能强大,直接集成了 replication 和 sentinel 的功能

  如果你的数据量很少,主要是承载高并发高性能的场景,比如你的缓存一般就几个 G,单机就足够了,可以使用 replication,一个 master 多个 slaves,要几个 slave 跟你要求的读吞吐量有关,然后自己搭建一个 sentinel 集群去保证 redis 主从架构的高可用性。

  redis cluster,主要是针对海量数据+高并发+高可用的场景。redis cluster 支撑 N 个 redis master node,每个master node存放部分数据,而每个 master node 都可以挂载多个 slave node。这样整个 redis 就可以横向扩容了。如果你要支撑更大数据量的缓存,那就横向扩容更多的 master 节点,每个 master 节点就能存放更多的数据了。

  • 注意master node之间数据分布是通过一致性hash算法计算的
  • 集群元数据的维护是分布式的,通过gossip协议通信。
    • 集中式维护元数据单节点压力大
    • 分布式维护元数据压力小但会有延时
    • 每个节点都有一个专门用于节点间通信的端口,就是自己提供服务的端口号+10000,比如 7001,那么用于节点间通信的就是 17001 端口

  Cluster Node节点:

 

struct clusterNode{
  // 节点创建时间
  mstime ctime;

  // 节点的名字,由40个十六进制字符组成
  // 1a2b3b3b12b123b123b213b4bab123b123c123e1f23
  char name[REDIS_CLUSTER_NAMELEN];

  // 节点标识
  // 使用各种不同的标识值记录节点的角色(比如主节点或者从节点)
  // 以及节点目前所处的状态(比如在线或者下线)
  int flags;

  // 节点当前的配置纪元.用于实现故障转移
  uint64_t configEpoch;

  // 当前节点管理的槽数组
  unsigned char slots[CLUSTER_SLOTS/8]; 

  // 记录当前节点的从节点数量
  int numslaves;

  // 记录当前节点的从节点的名单
  struct clusterNode **slaves;

  // 记录这个节点正在复制的主节点
  clusterNode *slaveof;

  // 节点的IP地址
  char ip[REDIS_IP_STR_LEN];

  // 节点的端口号
  int port;

  // 保存连接节点所需的有关信息
  clusterLink *link;
/
  // 一个链表,记录了所有其他节点对该节点的下线报告
  list *fail_reports;
}

  一致性Hash

  一致性 hash 算法将整个 hash 值空间组织成一个虚拟的圆环,整个空间按顺时针方向组织,下一步将各个 master 节点(使用服务器的 ip 或主机名)进行 hash。这样就能确定每个节点在其哈希环上的位置。  

  来了一个 key,首先计算 hash 值,并确定此数据在环上的位置,从此位置沿环顺时针“行走”,遇到的第一个 master 节点就是 key 所在位置。  

  在一致性哈希算法中,如果一个节点挂了,受影响的数据仅仅是此节点到环空间前一个节点(沿着逆时针方向行走遇到的第一个节点)之间的数据,其它不受影响。增加一个节点也同理。 但是,一致性哈希算法在节点太少时,容易因为节点分布不均匀而造成缓存热点的问题。为了解决这种热点问题,一致性 hash 算法引入了虚拟节点机制,即对每一个节点计算多个 hash,每个计算结果位置都放置一个虚拟节点。这样就实现了数据的均匀分布,负载均衡。

  hash slot算法

  Redis cluster 有固定的 16384 个 hash slot,对每个 key 计算 CRC16 值,然后对 16384 取模,可以获取 key 对应的 hash slot。  Redis cluster 中每个 master 都会持有部分 slot,比如有 3 个 master,那么可能每个 master 持有 5000 多个 hash slot。hash slot 让 node 的增加和移除很简单,增加一个 master,就将其他 master 的 hash slot 移动部分过去,减少一个 master,就将它的 hash slot 移动到其他 master 上去。

  gossip协议

  Gossip协议是基于六度分隔理论(Six Degrees of Separation)哲学的体现,简单的来说,一个人通过6个中间人可以认识世界任何人。

  1. 种子节点周期性的散播消息 【假定把周期限定为 1 秒】。
  2. 被感染节点随机选择N个邻接节点散播消息【假定fan-out(扇出)设置为6,每次最多往6个节点散播】。
  3. 节点只接收消息不反馈结果。
  4. 每次散播消息都选择尚未发送过的节点进行散播。
  5. 收到消息的节点不再往发送节点散播:A -> B,那么B进行散播的时候,不再发给 A。

  Goosip 协议的信息传播和扩散通常需要由种子节点发起。整个传播过程可能需要一定的时间,由于不能保证某个时刻所有节点都收到消息,但是理论上最终所有节点都会收到消息,因此它是一个最终一致性协议。  

  Gossip协议是一个多主协议,所有写操作可以由不同节点发起,并且同步给其他副本。Gossip内组成的网络节点都是对等节点,是非结构化网络。

gossip 协议包含多种消息,包含 ping , pong , meet , fail 等等。

  • meet:某个节点发送 meet 给新加入的节点,让新节点加入集群中,然后新节点就会开始与其它节点进行通信。
  • ping:每个节点都会频繁给其它节点发送 ping,其中包含自己的状态还有自己维护的集群元数据,互相通过 ping 交换元数据。
  • pong:返回 ping 和 meet,包含自己的状态和其它信息,也用于信息广播和更新。
  • fail:某个节点判断另一个节点 fail 之后,就发送 fail 给其它节点,通知其它节点说,某个节点宕机啦。

  slave节点选举

  • 每个从节点,都根据自己对 master 复制数据的 offset,来设置一个选举时间,offset 越大(复制数据越多)的从节点,选举时间越靠前,优先进行选举。
  • 所有的 master node 开始 slave 选举投票,给要进行选举的 slave 进行投票,如果大部分 master node (N/2 + 1) 都投票给了某个从节点,那么选举通过,那个从节点可以切换成 master。  
  • 从节点执行主备切换,从节点切换为主节点。

性能对比

  由于 redis 只使用单核,而 memcached 可以使用多核,所以平均每一个核上 redis 在存储小数据时比 memcached 性能更高。而在 100k 以上的数据中,memcached 性能要高于 redis。虽然 redis 最近也在存储大数据的性能上进行优化,但是比起 memcached,还是稍有逊色。

线程模型

  redis 内部使用文件事件处理器 file event handler,这个文件事件处理器是单线程的,所以 redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 socket,将产生事件的 socket 压入内存队列中,事件分派器根据 socket 上的事件类型来选择对应的事件处理器进行处理。

  文件事件处理器的结构包含 4 个部分,本质是类React模型及状态机和调度模型的结合:

  • 多个 socket,socket不同状态有不同的事件,如果用NIO的话就支持面向channel编程
  • IO 多路复用程序,多路复用这里特指对多个IO,复用同一个线程通道去处理,多路复用程序效率高可能和0拷贝技术相关,但多路复用也是NIO的最大特点,也可以实现伪NIO或者AIO,
  • 文件事件分派器,多路复用之后的分派点,所有事件和实例状态都会分派给不同的处理器
  • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)

  多个 socket 每个都可能同时有各自的操作,每个操作对应不同的文件事件,但是 IO 多路复用程序会监听多个 socket,会将产生事件的 socket 放入队列中排队,事件分派器每次从队列中取出一个 socket,根据 socket 的事件类型交给对应的事件处理器进行处理。 

  首先,redis 服务端进程初始化的时候,会将 server socket 的 AE_READABLE 事件与连接应答处理器关联。这里先分清server socket是专门建立链接的一个特殊的socket;

  客户端 socket01 向 redis 进程的 server socket 请求建立连接,此时 server socket 会产生一个 AE_READABLE 事件,IO 多路复用程序监听到 server socket 产生的事件后,将该 socket 压入队列中。文件事件分派器从队列中获取 socket,交给连接应答处理器。连接应答处理器会创建一个能与客户端通信的 socket01,并将该 socket01 的 AE_READABLE 事件与命令请求处理器关联。

  假设此时客户端发送了一个 set key value 请求,此时 redis 中的 socket01 会产生 AE_READABLE 事件,IO 多路复用程序将 socket01 压入队列,此时事件分派器从队列中获取到 socket01 产生的 AE_READABLE 事件,由于前面 socket01 的 AE_READABLE 事件已经与命令请求处理器关联,因此事件分派器将事件交给命令请求处理器来处理。命令请求处理器读取 socket01 的 key value 并在自己内存中完成 key value 的设置。操作完成后,它会将 socket01 的 AE_WRITABLE 事件与命令回复处理器关联。

  如果此时客户端准备好接收返回结果了,那么 redis 中的 socket01 会产生一个 AE_WRITABLE 事件,同样压入队列中,事件分派器找到相关联的命令回复处理器,由命令回复处理器对 socket01 输入本次操作的一个结果,比如 ok,之后解除 socket01 的 AE_WRITABLE 事件与命令回复处理器的关联。

这样便完成了一次通信。

REACTOR:模型

  基本组件:reactor组件、accepted组件、handler组件;

  • reactor组件用作监听事件、分派事件
  • accepted组件用作建立连接
  • handler组件用作处理业务逻辑

  基本事件:连接事件、读事件、写事件;

  类型

  • 单Reactor单线程:建立连接(Acceptor),监听accept、read、write事件(Reactor),处理事件(Handler)都用一个线程
  • 单Reactor多线程:建立连接(Acceptor),监听accept、read、write事件(Reactor)在一个线程;处理事件(Handler)都用多个线程
  • 多Reactor多线程:建立连接(Acceptor)多线程;监听accept、read、write事件(Reactor)在多个线程;处理事件(Handler)都用多个线程

  REDIS:在6.0之后,把IO的读写做成了多线程,其他还是单线程。

  Nginx:多进程模型,master进程不处理IO,每个work进程单独一个单线程Reactor

  Netty:默认,多reactor多线程模型,一个主reactor建立连接,从reactor复杂IO读写

  Kakfa:也是多reactor,但是因为业务handler和磁盘交互,所以单独用的worker线程池处理

Redis线程模型

  文件事件处理器:Redis 基于 Reactor 模式开发了自己的网络事件处理器 - 文件事件处理器(file event handler,后文简称为 FEH),而该处理器又是单线程的,所以redis设计为单线程模型

  • 为了适配不同操作系统,redis自己封装一套事件库。
  • 对应于NIO的ServerSocket和Socket,Redis的模型也拆分了不同的处理过程
  • 做事件分派,主要是接受socket的事件,把事件分派给以下的处理器:命令请求处理器、命令回复处理器、连接应答处理器;

  IO多路复用:采用I/O多路复用同时监听多个socket,根据socket当前执行的事件来为 socket 选择对应的事件处理器。 当被监听的socket准备好执行accept、read、write、close等操作时,和操作对应的文件事件就会产生,这时FEH就会调用socket之前关联好的事件处理器来处理对应事件。

  • 在网络数据的读取和写入上,利用了系统的 epoll/select/kqueue 等多路复用技术做非阻塞单线程的操作。
  • 什么是多路复用,就是有某一条通道(路线)是不区分通道内的内容的,该通道只面向传输。
  • 各自不同的类型通讯包都封装为同一种类型的包在通道上传输。通道段不区分通讯包,由接收端分派处理。
  • 多路复用可以复用一个通道,而在CPU上的多路复用通道可以用单线程处理,这样可以接耦过程,关注分离,从而可以分离调度。

  多线程模型:注意! Redis 6.0 之后的版本抛弃了单线程模型这一设计,原本使用单线程运行的 Redis 也开始选择性地使用多线程模型。  前面还在强调 Redis 单线程模型的高效性,现在为什么又要引入多线程?这其实说明 Redis 在有些方面,单线程已经不具有优势了。

  • 因为读写网络的 Read/Write 系统调用在 Redis 执行期间占用了大部分 CPU 时间,如果把网络读写做成多线程的方式对性能会有很大提升。  
  • Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程。
  • 之所以这么设计是不想 Redis 因为多线程而变得复杂,需要去控制 key、lua、事务、LPUSH/LPOP 等等的并发问题。

  总结:Redis 选择使用单线程模型处理客户端的请求主要还是因为 CPU 不是 Redis 服务器的瓶颈,所以使用多线程模型带来的性能提升并不能抵消它带来的开发成本和维护成本,系统的性能瓶颈也主要在网络 I/O 操作上;而 Redis 引入多线程操作也是出于性能上的考虑,对于一些大键值对的删除操作,通过多线程非阻塞地释放内存空间(释放操作不会阻塞网络IO读写,因为网络IO读写与释放的命令执行不是同一个线程)也能减少对 Redis 主线程阻塞的时间,提高执行的效率。

缓存双写

  1、 Cache Aside Pattern(旁路缓存),是最经典的缓存+数据库读写模式。

  • 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
  • 更新的时候,先更新数据库,然后再删除缓存。
  • 为什么是删除缓存,而不是更新缓存呢?  
    • 缓存的值是一个结构,hash、list等更新数据需要遍历;  
    • 懒加载,使用的时候才更新缓存,也可以采用异步的方式填充缓存。    
  • 存在问题:高并发脏读的三种情况:
    • 先更新数据库,再更新缓存;update与commit之间,更新缓存,commit失败,则DB与缓存数据不一致。
    • 先更新缓存,再更新数据库;如果有两个事务同时进行着,那么顺序不一致性可能会导致数据被覆盖而导致的数据库缓存不一致。
    • 先删除缓存,再更新数据库 ;update与commit之间,有新的读,缓存空,读DB数据到缓存,数据是旧的数据;commit后DB为新的数据;  则DB与缓存数据不一致。
    • 先更新数据库,再删除缓存(推荐);该方法适用性高,但也是有问题的。但配合延时双删出事概率会很低
    • /**
      *
      *延时双删除,第一个删除可以放在不同位置
      *
      **/
      public void write(String key,Object data){
          redis.delKey(key);
          db.updateData(data);
          Thread.sleep(1000);
          redis.delKey(key);
      }
    • 绝对的方案:串行化,但效率极低,所以要根据业务场景选择以上方案;
  • 2、Read/Write Through Pattern
    • 应用程序只操作缓存,缓存操作数据库;  Read-Through(穿透读模式/直读模式):应用程序读缓存,缓存没有,由缓存回源到数据库,并写入缓存;  Write-Through(穿透写模式/直写模式):应用程序写缓存,缓存写数据库。该种模式需要提供数据库的handler,开发较为复杂。    
  • 3、Write Behind Caching Pattern
    • 应用程序只更新缓存;  缓存通过异步的方式将数据批量或合并后更新到DB中,不能时时同步,甚至会丢数据。

双写一致性

  方案:最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern。

  • 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
  • 更新的时候,先更新数据库,然后再删除缓存,或者先更新缓存再更新数据库,他们都各有优点和缺点。

  问题:为什么是删除缓存,而不是更新缓存?

  1、更新线程不做读取的事情,在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值。

  2、懒加载策略,解决多次更新不使用问题。

  问题:先删缓存,中间插入一个查询,又缓存了旧的值,最后更新数据库,问题就出现了,缓存和数据库长时间不一致,怎么办?

  只要用缓存,就可能会涉及到缓存与数据库双存储双写,双写会有数据一致性的问题;

  一致性问题原因

  • 分布式系统,不存在事务,redis是独立的,数据库也是。
  • 高并发读一般是多线程,不存在事务意味着线程不安全。
  • 各自时机的系统宕机问题。

  方案:串行化读写:串行化读写就是读和写都在一个库中操作,不做读写分离,串行化可以保证不会出现读写不一致情况,但效率会大大降低,通常涉及事务和锁表行等。所以如果业务不要求完全一致性,通常不要采取这样的方案。

  这是一个同步问题,如果用锁解决就太过麻烦了,比较好的是利用MQ异步化这两个请求,同时保证他们的执行顺序,就可以保证缓存和数据库必然一致,考虑具体业务的时候,还可以做更多的优化,比喻允许非强一致性,可以发了获取缓存请求后,就去数据库取一个旧值来用一会。

  解决方案

  • 延迟双删,核心策略是允许短时间不一致,利用冲突线程完毕后再启动一次刷新操作。
  • 更新队列,把可能造成不一致的操作全部串行化操作。
  • 监听binlog做缓存更新,保证按照顺序更新,但必须要row模式。
  • 如果是并发产生的数据顺序错乱造成缓存数据不正确,除了延迟删外还可以用CAS乐观锁+版本解决。但双删一了百了。

缓存雪崩

  问题1:对于系统 A,假设每天高峰期每秒 5000 个请求,本来缓存在高峰期可以扛住每秒 4000 个请求,但是缓存机器意外发生了全盘宕机。缓存挂了,此时 1 秒 5000 个请求全部落数据库,数据库必然扛不住,它会报一下警,然后就挂了。此时,如果没有采用什么特别的方案来处理这个故障,DBA 很着急,重启数据库,但是数据库立马又被新的流量给打死了。这就是缓存雪崩。

  

  

  方案:缓存雪崩的事前事中事后的解决方案如下。

  • 事前:redis 高可用,主从+哨兵,redis cluster,避免全盘崩溃。
  • 事中:本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死(注意ehcache是java的实现,其他程序请另行参考)。
  • 事后:redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。

  问题2:大量缓存同一个时间失效。

  方案:

  • 过期时间加随机数,保证不在一起宕机
  • 做限流,保证宕机后也能有能力恢复,被限流的流量走降级处理
  • 加强DB和redis的健壮性,DB做分布分库,REDIS做集群

缓存穿透

  问题:对于系统A,假设一秒 5000 个请求,结果其中 4000 个请求是黑客发出的恶意攻击。

  黑客发出的那 4000 个攻击,缓存中查不到,每次你去数据库里查,也查不到。

  举个栗子。数据库 id 是从 1 开始的,结果黑客发过来的请求 id 全部都是负数。这样的话,缓存中不会有,请求每次都“视缓存于无物”,直接查询数据库。这种恶意攻击场景的缓存穿透就会直接把数据库给打死。

  方案:解决方式很简单,每次系统 A 从数据库中只要没查到,就写一个空值到缓存里去,比如 set -999 UNKNOWN。然后设置一个过期时间,这样的话,下次有相同的 key 来访问的时候,在缓存失效之前,都可以直接从缓存中取数据。

  方案:布隆过滤器(Bloom Filte),这个就是一个bitmap和hash结合的算法,可以保证存在的数据一定会被判别为存在;所以把所有的key放到过滤器中,查询的时候如果发现不存在,那一定是不存在的key,如果发现是存在的key,也有可能该key不存在,再配合方案1,这样就不可能会被缓存穿透。

缓存击穿

  问题:缓存击穿,就是说某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,当这个 key 在失效的瞬间,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。

  方案:简单粗暴,可以将热点数据设置为永远不过期;

  方案:其实击穿也是一个同步问题,同步问题的解决方案可以使用 redis or zookeeper 实现互斥锁,等待第一个请求构建完缓存之后,再释放锁,进而其它请求才能通过该 key 访问数据。

分布式锁:

  Redis 分布式锁,官方叫做 RedLock 算法,是 Redis 官方支持的分布式锁算法。这个分布式锁有 3 个重要的考量点:

  • 互斥(只能有一个客户端获取锁)
  • 不能死锁
  • 容错(只要大部分 Redis 节点创建了这把锁就可以)

  Redis 最普通的分布式锁,最普通的实现方式,redis2.8之后redis支持nx和ex操作是同一原子操作,所以不用考虑原子性问题。就是在 Redis 里使用 SET key value [EX seconds] [PX milliseconds] NX 创建一个 key,这样就算加锁。其中:

  • NX:表示只有 key 不存在的时候才会设置成功,如果此时 redis 中存在这个 key,那么设置失败,返回 nil。
  • EX seconds:设置 key 的过期时间,精确到秒级。意思是 seconds 秒后锁自动释放,别人创建的时候如果发现已经有了就不能加锁了。
  • PX milliseconds:同样是设置 key 的过期时间,精确到毫秒级。

  比如执行以下命令:

  SET resource_name my_random_value PX 30000 NX


  误删除:A线程加了锁,B可能会删除,所以可以在value设置客户端标识,释放锁就是删除 key ,但是一般可以用 lua 脚本删除,判断 value 一样才删除:-- 删除锁的时候,找到 key 对应的 value,跟自己传过去的 value 做比较,如果是一样的才删除。

-- 删除锁的时候,找到 key 对应的 value,跟自己传过去的 value 做比较,如果是一样的才删除。
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

  为啥要用 random_value 随机值呢?因为如果某个客户端获取到了锁,但是阻塞了很长时间才执行完,比如说超过了 30s,此时可能已经自动释放锁了,此时可能别的客户端已经获取到了这个锁,要是你这个时候直接删除 key 的话会有问题,所以得用随机值加上面的 lua 脚本来释放锁。

  但是这样是肯定不行的。因为如果是普通的 Redis 单实例,那就是单点故障。或者是 Redis 普通主从,那 Redis 主从异步复制,如果主节点挂了(key 就没有了),key 还没同步到从节点,此时从节点切换为主节点,别人就可以 set key,从而拿到锁。

  RedLock 算法:
  这个场景是假设有一个 Redis cluster,有 5 个 Redis master 实例。然后执行如下步骤获取一把锁:

  1. 获取当前时间戳,单位是毫秒;
  2. 跟上面类似,轮流尝试在每个 master 节点上创建锁,超时时间较短,一般就几十毫秒(客户端为了获取锁而使用的超时时间比自动释放锁的总时间要小。例如,如果自动释放时间是 10 秒,那么超时时间可能在 5~50 毫秒范围内);
  3. 尝试在大多数节点上建立一个锁,比如 5 个节点就要求是 3 个节点 n / 2 + 1 ;
  4. 客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了;
  5. 要是锁建立失败了,那么就依次之前建立过的锁删除;
  6. 只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁。

  Redis 官方给出了以上两种基于 Redis 实现分布式锁的方法,详细说明可以查看:https://redis.io/topics/distlock 。

  ZooKeeper锁

  • redis 分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。
  • zk 分布式锁,获取不到锁,注册个监听器即可,不需要不断主动尝试获取锁,性能开销较小。

  另外一点就是,如果是 Redis 获取锁的那个客户端 出现 bug 挂了,那么只能等待超时时间之后才能释放锁;而 zk 的话,因为创建的是临时 znode,只要客户端挂了,znode 就没了,此时就自动释放锁。

 

posted @ 2019-04-07 16:09  饭小胖  阅读(653)  评论(0编辑  收藏  举报