一、Redis简介

Redis(全称为Remote Dictionary Server)是一个开源的高性能键值对存储系统,具有快速、灵活和可扩展的特性。它是一个基于内存的数据结构存储系统,可以用作数据库、缓存和消息代理。

Redis 的一些主要特点和用途:

  • 高性能:Redis 数据存储在内存中,因此能够提供极快的读写操作。它采用单线程模型和异步 I/O,避免了多线程的竞争和阻塞,从而达到了非常高的性能。
  • 数据结构多样:Redis 支持多种数据结构,包括字符串(String)、哈希(Hash)、列表(List)、集合(Set)和有序集合(Sorted Set)。这些数据结构提供了丰富的操作命令,使得开发者可以方便地处理各种数据需求。
  • 持久化支持:Redis 提供了两种持久化方式,即快照(Snapshotting)和日志追加(Append-only file,AOF)。快照方式将 Redis 内存数据以二进制格式写入磁盘,而 AOF 则通过追加记录 Redis 的操作命令来实现持久化。
  • 发布/订阅:Redis 支持发布/订阅模式,可以用作消息代理。发布者将消息发送到指定的频道,订阅者则可以接收和处理这些消息。这种模式在构建实时通信、事件驱动系统和消息队列等场景中非常有用。
  • 分布式缓存:Redis可以通过主从复制和分片来实现数据的分布式存储和高可用性。主从复制可以将数据复制到多个从节点,实现读写分离和数据备份。而分片则可以将数据分布在多个Redis节点上,实现横向扩展和负载均衡。
  • 事务支持:Redis 支持事务,开发者可以将多个操作组合成一个原子性的操作序列,保证这些操作要么全部执行成功,要么全部不执行。
  • 功能丰富:Redis不仅仅是一个简单的缓存,它还提供了许多其他功能,如事务支持、Lua脚本执行、定时任务、原子操作等。这使得开发者可以在Redis中实现更复杂的应用逻辑。

Redis 是一个功能丰富的存储系统,适用于多种场景,包括缓存、会话存储、排行榜、实时分析等。它有广泛的应用,并且拥有活跃的社区支持。

二、Redis五种数据结构

Redis 可以存储键和常用的五种不同类型的值之间的映射。键的类型只能为字符串,值支持五种数据类型:字符串、列表、集合、散列表、有序集合。

2.1 字符串(Strings)

2.1.1 基础介绍

Redis 里的字符串是动态字符串,会根据实际情况动态调整。类似于 Go 里面的切片-slice,如果长度不够则自动扩容。

至于如何扩容,方法大致如下:当 length 小于 1M 的时候,扩容规则将目前的字符串翻倍;如果 length 大于 1M 的话,则每次只会扩容 1M,直到达到 512M。

image-20210428221208246

新增、修改操作

set key value

查询操作

get key

2.1.2 字符串底层数据结构

String是用SDS(简单动态字符串)来实现的。String类型一共有三种存储方式。

  • 长度小于等于44字符时采用embstr
  • 长度大于44字符时采用raw
  • 当值为整数时采用int

2.1.3 SDS结构

image-20201229142931940

SDS由上图所示的三部分组成

  • buf:字节数组,保存实际数据。为了表⽰字节数组的结束,Redis会⾃动在数组最后加⼀个“\0”,这就会额外占⽤1个字节的开销。
  • len:占4个字节,表⽰buf的已⽤⻓度。
  • alloc:也占个4字节,表⽰buf的实际分配⻓度,⼀般⼤于len。

2.1.4 embstr、raw、int三者区别

  • 当保存的是Long类型整数时,RedisObject中的指针就直接赋值为整数数据了,这样就不⽤额外的 指针再指向整数了,节省了指针的空间开销。
  • 当保存的是字符串数据,并且字符串⼩于等于44字节时,RedisObject中的元数据、指针和SDS 是⼀块连续的内存区域,这样就可以避免内存碎⽚。这种布局⽅式也被称为embstr编码⽅式。
  • 当字符串⼤于44字节时,SDS的数据量就开始变多了,Redis就不再把SDS和RedisObject布局在⼀起 了,⽽是会给SDS分配独⽴的空间,并⽤指针指向SDS结构。这种布局⽅式被称为raw编码模式。 image-20201229144810910

2.2 列表(lists)

2.2.1 基础介绍

Redis 里的 List 是一个链表,由于链表本身插入和删除比较块,但是查询的效率比较低,所以常常被用做异步队列。

Redis 里的 List 设计非常牛,当数据量比较小的时候,数据结构是压缩链表,而当数据量比较多的时候就成为了快速链表。

**可运用的场景:**在业务中异步队列使用 rpush/lpush 操作队列,使用 lpop 和 rpop 出队列,具体结构如下图所示:

image-20210428221256636

list在头部或尾部添加一个元素的操作,时间复杂度是常数级别的。

LPUSH命令可向list的左边(头部)添加一个新元素,而RPUSH命令可向list的右边(尾部)添加一个新元素。

lpush key value1 value2

LRANGE命令可从list中取出一定范围的元素:

 lrange mylist 0 3

lpoprpop可以在左边或右边删除list中元素并同时返回删除的值。

rpop key

2.2.2 list底层数据结构

  • ziplist和双向链表
  • 当列表长度超过一定值或者列表中某个值大小超过一定值则转为双向列表

2.2.3 ziplist

image-20201229161348434 ziplist表头有三个字段zlbytes、zltail和zllen,分别表⽰列表⻓度、列表尾的偏移量,以及列表中的entry个数。压缩列表尾还有⼀个zlend,表⽰列表结束。

2.2.4 双向链表

每个节点包含头前一个节点指针和后一个节点指针

2.3 集合(sets)

2.3.1 基础介绍

Redis 中的 set 是一个无序 Map,由于 Go 中没有 set 结构,所以这里只能类比 Java 中的 HashSet 概念。

Redis 的 set 底层也是一个 Map 结构,不同于 Java 的是:value 是一个 NULL。由于 set 的特性,它可以用于去重逻辑,这一点在 Java 中也经常使用。

可运用场景:活动抽奖去重。

Redis Set 是 String 的无序排列。SADD 指令把新的元素添加到 set 中。对 set 也可做一些其他的操作,比如测试一个给定的元素是否存在,对不同 set 取交集,并集或差,等等。

sadd key 1 2 3

spop可以删除并返回删除的元素。

spop game:1:deck

2.3.2 set底层数据结构

  • hash表及整数数组
  • 当存储数据均为整数且长度小于一定值时使用整数数组,否则使用hashtable

2.4 字典(hashes)

2.4.1 基础介绍

Redis 中的字典类型大家不陌生,也许其他语言都有这种结构(python,Java,Go), hash 的扩容 rehash 过程和 Go 里面的设计颇有类似,也就是维护了两个 hash 结构。

如果需要扩容的时候,就把新的数据写入新字典中,然后后端起一个线程来逐步迁移,总体上来说就是采用了空间换时间的思想。

**可运用场景:**记录业务中的不同用户/不同商品/不同场景的信息:如某个用户的名称,或者用户的历史行为。

image-20210428221355465

hashes就是已从存储键值对的结构

hset命令用于新增或修改,hget用于获取

hset key field value
hget key field

2.4.2 hash底层数据结构

  • ziplist(压缩列表)及hash表
  • 当hash集合中元素个数超过一定值或者列表中某个值大小超过一定值则转为hash表

2.5 有序集合(sorted sets)

2.5.1 基础介绍

Redis 中的 zset 是一个比较特殊的数据结构(跳跃列表),也就是我们了解到的跳表,底层由于 set 的特性保证了 value 唯一,同时也给了 value 一个得分,所谓的有序其实就是根据这个得分来排序。

至于跳跃表如何插入,其实内部采用了一个随机策略:L0:100%-L2:50%-L3:25%-….Ln:(n-1)value/2%。

**可运用场景:**榜单,总榜,热榜。

image-20210428221452627

有序集合和集合一样也是 string 类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。有序集合的成员是唯一的,但分数(score)却可以重复。

ZADD key score value

通过分数返回有序集合指定区间内的成员

ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT]

2.5.2 有序集合地层数据结构

  • 跳表及hash表

2.5.3 跳表

跳表在链表的基础上,增加了多级索引,通过索引位置的⼏个跳转,实现数据的快速定位 image-20201231090607355

跳表每层都是一个链表,上层的链表元素存在指向下一层链表的相同元素,查找时从上向下查找,上层无法查到,但却能确定一个查找区间,再从下一层的这个区间内查找,时间复杂度O(logN)

2.6 quicklist

quicklist是Redis底层最重要的数据结构之一,它是Redis对外提供的6种基本数据结构中List的底层实现,在Redis 3.2版本中引入。在引入quicklist之前,Redis采用压缩链表(ziplist)以及双向链表(adlist)作为List的底层实现。当元素个数比较少并且元素长度比较小时,Redis采用ziplist作为其底层存储;当任意一个条件不满足时,Redis采用adlist作为底层存储结构。这么做的主要原因是,当元素长度较小时,采用ziplist可以有效节省存储空间,但ziplist的存储空间是连续的,当元素个数比较多时,修改元素时,必须重新分配存储空间,这无疑会影响Redis的执行效率,故而采用一般的双向链表。

2.7 Redis事务

2.7.1 事务命令

Redis事务功能是通过MULTI、EXEC、DISCARD和WATCH 四个原语实现的

Redis会将一个事务中的所有命令序列化,然后按顺序执行。

redis 不支持回滚,“Redis 在事务失败时不进行回滚,而是继续执行余下的命令”, 所以 Redis 的内部可以保持简单且快速。

如果在一个事务中的命令出现错误,那么所有的命令都不会执行;

如果在一个事务中出现运行错误,那么正确的命令会被执行。

WATCH :命令是一个乐观锁,可以为 Redis 事务提供 check-and-set (CAS)行为。 可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行,监控一直持续到EXEC命令。

MULTI:命令用于开启一个事务,它总是返回OK。 MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当EXEC命令被调用时,所有队列中的命令才会被执行。

EXEC:执行所有事务块内的命令。返回事务块内所有命令的返回值,按命令执行的先后顺序排列。 当操作被打断时,返回空值 nil 。

DISCARD:通过调用DISCARD,客户端可以清空事务队列,并放弃执行事务, 并且客户端会从事务状态中退出。

UNWATCH:命令可以取消watch对所有key的监控。

2.7.2 事务管理(ACID)概述

原子性(Atomicity)

原子性是指事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不发生。

一致性(Consistency)

事务前后数据的完整性必须保持一致。

隔离性(Isolation)

多个事务并发执行时,一个事务的执行不应影响其他事务的执行

持久性(Durability)

持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来即使数据库发生故障也不应该对其有任何影响

Redis的事务总是具有ACID中的一致性和隔离性

Redis中,单条命令是原子性执行的,但事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。

三 Redis内存模型

为了实现从键到值的快速访问,Redis使⽤了⼀个哈希表来保存所有键值对,类似java中的HashMap。

⼀个哈希表,其实就是⼀个数组,数组的每个元素称为⼀个哈希桶。⼀个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。

image-20210111140351965

哈希表中并不直接存储我们的数据,而是存储的dictEntry元素。dictEntry结构如下图,dictEntry存储了三个指针地址,分别指向key、value以及hash冲突时产生的下一个dictEntry。

image-20210111142210778

上图中我们看到了RedisObject,我们的key和value的指针就存储在RedisObject中,RedisObject中包含8Bit的元数据信息和指向实际值的指针。元数据信息包括*

  • 存储的数据类型(字符串、列表、哈希、集合、有序集合)
  • 存储的数据的数据结构(int、ebmstr、raw、字典、链表、压缩列表、整数集合、跳表)
  • 引用计数
  • 最后一次访问时间。

image-20210111142700397

四 redis持久化机制

4.1 AOF 日志

AOF日志是一种“写后”日志,也就是先执行命令,后打印日志,而日志中记录的内容就是我们操作redis的每一个命令。后写日志的好处的好处就是不会记录错误的命令,而且不会阻塞当前线程。

4.1.1 AOF写回策略

redis提供了三种写回策略。

  • Always,同步写回:每个写命令执⾏完,⽴⻢同步地将⽇志写回磁盘;
  • Everysec,每秒写回:每个写命令执⾏完,只是先把⽇志写到AOF⽂件的内存缓冲区,每隔⼀秒把缓冲 区中的内容写⼊磁盘;
  • No,操作系统控制的写回:每个写命令执⾏完,只是先把⽇志写到AOF⽂件的内存缓冲区,由操作系统 决定何时将缓冲区内容写回磁盘。

image-20210111150755102

4.1.2 AOF重写

随着redis运行时间的越来越长,aof日志也将越来越大,所以数据回复时使用一个特别大的aof日志文件修复那效率可想而知,可能半个小时都无法恢复完成。AOF重写就是为了解决这个问题,AOF重写就是根据当前redis的现状写成一个新的AOF日志文件来替换老的。

reidis提供了两个配置控制AOF重写的时机

auto-aof-rewrite-percentage 100 #AOF文件大小较上次重写超过100%时进行重写
auto-aof-rewrite-min-size 64mb #AOF文件大小超过64m时重写

4.1.3 AOF重写过程

AOF重写过程是由后台进程bgrewriteaof来完成的,目的就是为了避免阻塞主线程,导致数据库性能下降。

AOF重写过程如下:

  1. 由redis主进程fork出bgrewriteaof子进程,bgrewriteaof子进程和主进程指向相同的内存地址,fork过程redis是阻塞的。
  2. bgrewriteaof子进程开始对当前内存进行读取写aof文件。
  3. 在重写aof文件过程中主进程依然可以对客户端请求进行响应,如果客户端操作的是已经存在的键值对,则redis会把此键值对拷贝到新的内存空间进行处理,如果操作的是一个大key的话就会造成redis阻塞。对于重写过程的请求依然会写入原来aof日志文件,这些请求同时还会写入另一个aof重写缓冲区,等aof重写完成时将这些操作写入重写的aof文件并替换原文件。

uTools_1610354603807

4.2 RDB 快照

所谓RDB快照,就是指内存中的数据在某⼀个时刻的状态记录。和AOF相⽐,RDB记录的是某⼀时刻的数据,并不是操作,所以,在做数据恢复时,我们可以直接把RDB⽂件读⼊内存,很快地完成恢复。

4.2.1 快照配置

在redis.conf中可以增加多条配置 save <seconds> <changes>,只要有其中一个满足条件就会进行RDB快照。

save 900 1 #900秒内有一个key被修改
save 300 10 #300秒内有10个key被修改
save 60 10000 #60秒内有10000个key被修改

4.2.2 快照过程

Redis写rdb是由子进程bgsave完成的。

  1. 由主进程fork出子进程bgsave,子进程与主进程指向相同的内存地址,fork过程redis是阻塞的。
  2. bgsave子进程读取内存内容写RDB文件。
  3. 在bgsave子进程写RDB文件过程中主进程依然可以处理客户端请求,如果客户端操作的是已经存在的键值对,则redis会把此键值对拷贝到新的内存空间进行处理,如果操作的是一个大key的话就会造成redis阻塞。

image-20210111170442589

4.3 混合持久化

Redis 4.0中提出了⼀个 混合使⽤AOF⽇志和内存快照的⽅法。混合持久化同样也是通过bgrewriteaof完成的,不同的是当开启混合持久化时,fork出的子进程先将共享的内存副本全量的以RDB方式写入aof文件,然后在将重写缓冲区的增量命令以AOF方式写入到文件,写入完成后通知主进程更新统计信息,并将新的含RDB格式和AOF格式的AOF文件替换旧的的AOF文件。简单的说:新的AOF文件前半段是RDB格式的全量数据后半段是AOF格式的增量数据

4.4 过期键的删除策略

惰性过期:只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。

定期过期:每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。

4.5 内存淘汰策略

Redis的内存淘汰策略是指在Redis的用于缓存的内存不足时,怎么处理需要新写入且需要申请额外空间的数据。

全局的键空间选择性移除

  • noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。

  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key。(这个是最常用的)

  • allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key。

设置过期时间的键空间选择性移除

  • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key。
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key。
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key优先移除。

总结

Redis的内存淘汰策略的选取并不会影响过期的key的处理。内存淘汰策略用于处理内存不足时的需要申请额外空间的数据;过期策略用于处理过期的缓存数据。

五 Redis部署的三种模式

5.1 主从模式

主从模式主要用于读写分离,主节点用来写入,从节点用来读取。

Redis 的同步方式有:主从同步、从从同步(由于全部都由 master 同步的话,会损耗性能,所以部分的 slave 会通过 slave 之间进行同步)。

image-20210428221924335

同步过程如下:

  • 建立连接,然后从库告诉主库:“我要同步啦,你给我准备好”,然后主库跟从库说:“收到”。
  • 从库拿到数据后,要把数据保存到库里。这个时候就会在本地完成数据的加载,会用到 RDB 。
  • 主库把新来的数据 AOF 同步给从库。

5.1.1 搭建

例如,现在有实例1(ip:172.16.19.3)和实例2(ip:172.16.19.5),我们在实例2上执⾏以下这个命令后,实例2就变成了实例1的从库,并从实例1上复制数据:

replicaof 172.16.19.3 6379

或者在实例2的配置文件中增加配置

masterauth "bCZaaicN5LNt6Qm748lW0B0Hx3Xc" #如果redis有密码则主从密码要一致
replicaof 172.16.19.3 6379

5.1.2 数据同步

​ 在从节点第一次连接到主节点时,主节点会进行一次全量同步,在Redis 2.8.18版本之前,全量同步采用的是rdb文件同步,也就是主节点生成rdb文件,然后传输给从节点,从节点读取rdb文件来恢复数据。在Redis 2.8.18版本之后Redis支持了无盘复制,无盘复制是指主节点通过套接字将快照内容发送到从节点,从节点将收到的内容保存到磁盘,在一次性加载。在同步期间主节点不中断服务,在同步期间redis主节点接收的新的指令存储在本地内存buffer中,然后异步的将buffer中的指令同步到从节点,而从节点一边执行同步的指令一把反馈自己读到了哪里(偏移量)。

​ 内存buffer是一个环形数组,所以如果同步的太慢,新来的指令就会覆盖旧的尚未同步的指令,这时就需要rdb文件同步了。所以要通过repl-backlog-size配置合适的buffer内存。

5.2 哨兵模式

​ 主从模式能共实现读写分离,但是却不能进行故障转移。而哨兵模式可以实现主从库自动切换。这里哨兵主要解决的问题就是:当 master 挂了的情况下,如果在短时间内重新选举出一个新的 master 。

Sentinel 集群是一个由 3-5 个(可以更多)节点组成的,用来监听整个 Redis 的集群,如果发现 master 不可用的时候,会关闭和断开全部的与 master 相连的旧链接。

这个时候 Sentinel 会完成选举和故障转移,新的请求则会转到新到 master 中。

5.2.1 哨兵机制的基本流程

​ 哨兵其实就是⼀个运⾏在特殊模式下的Redis进程。

image-20210510121959836

主从库实例运⾏的同时,它也在运⾏。哨兵主要负责的就是三个任务:监控、选主(选择主库)和通知。

  • 监控(Monitoring): Sentinel 会不断地检查主服务器和从服务器是否运作正常。

  • 选主: 当一个主服务器不能正常工作时, Sentinel 会开始一次自动故障迁移操作, 它会将失效主服务器的其中一个从服务器升级为新的主服务器。

  • 通知: 选主后哨兵会通知其他从服务器改为复制新的主服务器; 当客户端试图连接失效的主服务器时, 哨兵也会向客户端返回新主服务器的地址, 使得集群可以使用新主服务器代替失效服务器。image-20210114093558419

5.2.2 监控基本流程

​ 哨兵进程会通过心跳机制检测它⾃⼰和主、从库的⽹络连接情况,⽤来判断实例的状态。如果哨兵发现主库或从库响应超时了,那么,哨兵就会先把它标记为“主观下线”。如果检测的是从库,那么,哨兵简单地把它标记为“主观下线”就⾏了,因为从库的下线影响⼀般不太⼤,集群的对外服务不会间断。但是,如果检测的是主库,那么,哨兵还不能简单地把它标记为“主观下线”,开启主从切换。因为很有可能存在这么⼀个情况:那就是哨兵误判了,其实主库并没有故障,只是集群⽹络压⼒较⼤、⽹络拥塞,或者是主库本⾝压⼒较⼤。可是,⼀旦启动了主从切换,后续的选主和通知操作都会带来额外的计算和通信开销。 ​ 为了避免这些不必要的开销,所以一般使用哨兵集群,多个哨兵一起判断。在判断主库是否下线时,只有⼤多数的哨兵实例都判断主库已经“主观下线”了,主库才会被标记为“客观下线”。同时,这会进⼀步触发哨兵开始主从切换流程。 image-20210114094532408

简单来说,“客观下线”的标准就是,当有N个哨兵实例时,最好要有N/2+1个实例判断主库为“主观下线”,才能最终判定主库为“客观下线”。这样⼀来,就可以减少误判的概率,也能避免误判带来的⽆谓的主从库切换。(当然,有多少个实例做出“主观下线”的判断才可以,可以由Redis管理员通过哨兵配置文件的sentinel monitor <master-name> <ip> <redis-port> <quorum>⾃⾏设定,其中quorum就是主观下线的哨兵数量)。

5.2.3 选主的基本流程

​ 选主时先要对从节点进行筛选,选择当前在线的且不经常掉线的从节点参与选举。选举过程分为三个过程,分别为:从库优先级从库复制进度以及从库ID号。只要在某⼀轮中,有从库得分最⾼,那么它就是主库了,选主过程到此结束。如果没有出现得分最⾼的从库,那么就继续进⾏下⼀轮。

第⼀轮:优先级最⾼的从库得分高

用户可以通过slave-priority配置项,给不同的从库设置不同优先级。⽐如,你有两个从库,它们的内存⼤ ⼩不⼀样,你可以⼿动给内存⼤的实例设置⼀个⾼优先级。在选主时,哨兵会给优先级⾼的从库打⾼分,如 果有⼀个从库优先级最⾼,那么它就是新主库了。如果从库的优先级都⼀样,那么哨兵开始第⼆轮打分。

第⼆轮:和旧主库同步程度最接近的从库得分⾼

这个规则的依据是,如果选择和旧主库同步最接近的那个从库作为主库,那么,这个新主库上就有最新的数 据。主从库同步时有个命令传播的过程。在这个过程中,主库会⽤master_repl_offset记 录当前的最新写操作在repl_backlog_buffer中的位置,⽽从库会⽤slave_repl_offset这个值记录当前的复制进度。如果在所有从库中,有从库的slave_repl_offset最接近master_repl_offset,那么它的得分就最⾼,可以作为新主库。

第三轮:ID号⼩的从库得分⾼

每个实例都会有⼀个ID,这个ID就类似于这⾥的从库的编号。⽬前,Redis在选主库时,有⼀个默认的规 定: 在优先级和复制进度都相同的情况下,ID号最⼩的从库得分最⾼,会被选为新主库

5.2.4 选择执行主从切换的哨兵

只要是确定主库客观下线了的哨兵都可以向其他哨兵发送命名表名自己想进行主从切换(成为leader),收到命令的哨兵进行投票,只要赞成票大于哨兵配置⽂件中的quorum值,就可以成为leader了。

image-20210510122032641

5.3 集群模式

在redis数据量非常大的时候,redis在重写AOF和RDB快照fork子线程的时候就会造成阻塞,所以这时候我们就要把这些数据分布在多个redis实例中进行存储。从Redis 3.0版本后官方提供了Redis Cluster⽅案实现集群部署。

Redis Cluster⽅案使用哈希槽来处理数据和实例之间的映射关系。在Redis Cluster⽅案中,⼀个集群共有16384个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的key,被映射到⼀个哈希槽中。

具体的映射过程分为两⼤步:⾸先根据键值对的key,按照CRC16算法计算⼀个16 bit的值;然后,再⽤这个16bit值对16384取模,得到0~16383范围内的模数,每个模数代表⼀个相应编号的哈希槽。在集群创建好后,16384个哈希槽会平均分配到每个redis实例,也可以使⽤cluster addslots命令⼿动分配哈希槽。只有16384个哈希槽全部分配了集群才可用。

整个集群对应的槽是由 16384 大小的二进制数组组成,集群中每个主节点分配一部分槽,每条写命令落到二进制数组中的某个位置,该位置被分配给了哪个节点,则对应的命令就由该节点去执行。

槽指派对应的二进制数组如下图所示:

image-20210428222006550

从上图可以看到:节点 1 只负责 执行 0 - 4999 的槽位,而节点 2 负责执行 5000 - 9999,节点 3 执行 9999- 16383 。

当进行写的时候:

set key value

命令通过 CRC16(key) & 16383 = 6789(假设结果),由于节点 2 负责 5000~9999 的槽位,则该命令的结果 6789 最终由节点 2 执行。

当然如果在节点 2 执行一条命令时,假设通过 CRC 计算后得到的值为 567,则其应该由节点 1 执行,此时命令会进行转向操作,将要执行的命令流转到节点 1 上去执行。

image-20210428222028020

**集群节点同步:**集群中每个主节点都会定时发送信息到其他主节点进行同步。

如果其他主节点在规定时间内响应了发送消息的主节点,则发送消息的主节点认为响应了消息的主节点正常,反之则认为响应消息的主节点疑似下线,则发送消息的主节点在其节点上将其标记“疑似下线”。

当集群中超过一半以上的节点认为某个主节点被标记为“疑似下线”,则其中某个主节点将疑似下线节点标记为下线状态,并向集群广播一条下线消息。

当下线节点对应的从节点接收到该消息时,则从从节点中选举出一个节点作为主节点继续对外提供服务。

5.3.1 发现故障节点

  1. 集群内的节点会向其他节点发送PING命令,检查是否在线
  2. 如果未能在规定时间内做出PONG响应,则会把对应的节点标记为疑似下线
  3. 集群中一半以上负责处理槽的主节点都将主节点X标记为疑似下线的话,那么这个主节点X就会被认为是已下线
  4. 向集群广播主节点X已下线,大家收到消息后都会把自己维护的结构体里的主节点X标记为已下线

5.3.2 从节点选举

  1. 当从节点发现自己复制的主节点已下线了,会向集群里面广播一条消息,要求所有有投票权的节点给自己投票(所有负责处理槽的主节点都有投票权)
  2. 主节点会向第一个给他发选举消息的从节点回复支持
  3. 当支持数量超过N/2+1的情况下,该从节点当选新的主节点

5.3.3 故障的迁移

  1. 新当选的从节点执行 SLAVEOF no one,修改成主节点
  2. 新的主节点会撤销所有已下线的老的主节点的槽指派,指派给自己
  3. 新的主节点向集群发送命令,通知其他节点自己已经变成主节点了,负责哪些槽指派
  4. 新的主节点开始处理自己负责的槽的命令

5.4 通信协议

Redis 采用了 Gossip 协议作为通信协议。Gossip 是一种传播消息的方式,可以类比为瘟疫或者流感的传播方式,使用 Gossip 协议的有:Redis Cluster、Consul、Apache Cassandra 等。

Gossip 协议类似病毒扩散的方式,将信息传播到其他的节点,这种协议效率很高,只需要广播到附近节点,然后被广播的节点继续做同样的操作即可。

当然这种协议也有一个弊端就是:会存在浪费,哪怕一个节点之前被通知到了,下次被广播后仍然会重复转发。

六 Redis线程模型

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

文件事件处理器的结构包含 4 个部分:

  • 多个 socket
  • IO 多路复用程序
  • 文件事件分派器
  • 事件处理器(包括:连接应答处理器、命令请求处理器、命令回复处理器)

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

七 Redis的应用场景

7.1 计数器

可以对 String 进行自增自减运算,从而实现计数器功能。Redis 这种内存型数据库的读写性能非常高,很适合存储频繁读写的计数量。

7.2 缓存

将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率。

7.3 会话缓存

可以使用 Redis 来统一存储多台应用服务器的会话信息。当应用服务器不再存储用户的会话信息,也就不再具有状态,一个用户可以请求任意一个应用服务器,从而更容易实现高可用性以及可伸缩性。

7.4 全页缓存(FPC)

除基本的会话token之外,Redis还提供很简便的FPC平台。以Magento为例,Magento提供一个插件来使用Redis作为全页缓存后端。此外,对WordPress的用户来说,Pantheon有一个非常好的插件 wp-redis,这个插件能帮助你以最快速度加载你曾浏览过的页面。

7.5 查找表

例如 DNS 记录就很适合使用 Redis 进行存储。查找表和缓存类似,也是利用了 Redis 快速的查找特性。但是查找表的内容不能失效,而缓存的内容可以失效,因为缓存不作为可靠的数据来源。

7.6 消息队列(发布/订阅功能)

List 是一个双向链表,可以通过 lpush 和 rpop 写入和读取消息。不过最好使用 Kafka、RabbitMQ 等消息中间件。

7.7 分布式锁实现

在分布式场景下,无法使用单机环境下的锁来对多个节点上的进程进行同步。可以使用 Redis 自带的 SETNX 命令实现分布式锁,除此之外,还可以使用官方提供的 RedLock 分布式锁实现。

现在市面上主流的实现分布锁的技术有 ZK 和 Redis;下文为大家简单介绍一下 Redis 如何实现分布式锁。

命令如下:

setnx lock:mutex ture #加锁
del  lock:mutex #删除锁

**实现分布式锁的核心就是:**请求的时候 Set 这个 key,如果其他请求设置失败的时候,即拿不到锁。

但是存在一个问题:如果业务 panic 或者忘记调用 del 的话,就会产生死锁,这个时候大家很容易能想到:我们可以 expire 一个过期时间,这样就可以保证请求不会一直独占锁且无法释放锁的逻辑了。

**但是假设业务存在这样一种情况:**A 请求在获取锁后处理逻辑,由于逻辑过长,这个时候锁到期释放了,A 这个时候刚刚处理完成,而 B 又去改了这个数据,这就存在一个锁失效的问题。

解决这种问题参考 CAS 的方式,对锁设置一个随机数,可以理解为版本号,如果释放的时候版本号不一致,则表示数字已经在释放那一刻改掉了。

7.8 隆过滤器

对于空间的利用到达了一种极致,那就是Bitmap和布隆过滤器(Bloom Filter)。

Bitmap: 典型的就是哈希表

缺点是,Bitmap对于每个元素只能记录1bit信息,如果还想完成额外的功能,恐怕只能靠牺牲更多的空间、时间来完成了。

Redis 在 4.0 以后支持布隆过滤(准确的来说是支持了布隆过滤器的插件),给 Redis 提供了强大的去重功能。

在业务中,我们可能需要查询数据库判断历史数据是否存在,如果数据库的并发能力有限,这个时候我们可以采用 Redis 的 set 做去重。

如果缓存的数据过大,这个时候就需要遍历所有缓存数据,另外如果我们的历史数据缓存写不下了,终究要去查询数据库,这个时候就可以使用布隆过滤器。

当然布隆过滤器精确度不是 100% 准确(如果对数据准确度要求很高的话,这里不建议使用),因为对于存在的数据也许这个值不一定存在,当然如果不存在,那肯定 100% 不存在了。

命令使用:

bf.add #添加元素
bf.exists #判断元素是否存在
bf.madd #批量添加
bf.mexists #批量判断是否存在

原理如下图:

image-20210428221543082

布隆过滤的组成可以当作一个位数组和几个计算结果比较均匀的 hash 函数,每次添加 key 的时候,会把 key 通过多次 hash 来计算所得到的位置,如果当前位置不是 0 则表示存在。

可以看到,这样的计算存在一定误差,这也正是它的不准确性问题的由来。

7.9 缓存预热

缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!

解决方案

直接写个缓存刷新页面,上线时手工操作一下;

数据量不大,可以在项目启动的时候自动进行加载;

定时刷新缓存;

7.10 热点数据

将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率。

先从缓存中获取,没有再到DB获取,并保存到缓存中;大量失效数据可以走队列,避免大量db查询;查到的再走缓存;

redis读写分离;

发现热点:

人为预测

LFU机制发现热点数据。只要把redis内存淘汰机制设置为allkeys-lfu或者volatile-lfu方式,再执行./redis-cli –hotkeys

7.11 实现延时队列

使用sortedset,使用时间戳做score, 消息内容作为key,调用zadd来生产消息,消费者使用zrangbyscore获取n秒之前的数据做轮询处理。

八 Redis相关问题

8.1 缓存雪崩

缓存雪崩是指缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。

解决方案

缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。

一般并发量不是特别多的时候,使用最多的解决方案是加锁排队。

给每一个缓存数据增加相应的缓存标记,记录缓存的是否失效,如果缓存标记失效,则更新数据缓存。

8.2 缓存穿透

缓存穿透是指缓存和数据库中都没有的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。

解决方案

接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;

从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击

采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力

8.3 如何保证缓存与数据库双写时的数据一致性?

你只要用缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题,那么你如何解决一致性问题?

一般来说,就是如果你的系统不是严格要求缓存+数据库必须一致性的话,缓存可以稍微的跟数据库偶尔有不一致的情况,最好不要做这个方案,读请求和写请求串行化,串到一个内存队列里去,这样就可以保证一定不会出现不一致的情况

串行化之后,就会导致系统的吞吐量会大幅度的降低,用比正常情况下多几倍的机器去支撑线上的一个请求。

还有一种方式就是可能会暂时产生不一致的情况,但是发生的几率特别小,就是先更新数据库,然后再删除缓存。

8.4 假如Redis里面有1亿个key,其中有10w个key是以某个固定的已知的前缀开头的,如果将它们全部找出来?

使用keys指令可以扫出指定模式的key列表。

对方接着追问:如果这个redis正在给线上的业务提供服务,那使用keys指令会有什么问题?

这个时候你要回答redis关键的一个特性:redis的单线程的。keys指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用scan指令,scan指令可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用keys指令长。

8.5 使用Redis做过异步队列吗,是如何实现的

使用list类型保存数据信息,rpush生产消息,lpop消费消息,当lpop没有消息时,可以sleep一段时间,然后再检查有没有信息,如果不想sleep的话,可以使用blpop, 在没有信息的时候,会一直阻塞,直到信息的到来。redis可以通过pub/sub主题订阅模式实现一个生产者,多个消费者,当然也存在一定的缺点,当消费者下线时,生产的消息会丢失。

8.6 redis在线扩容

1、 添加节点

./src/redis-cli –cluster add-node 127.0.0.1:7000 xxx.xxx.xxx.xxx:7000

2、 分配hash槽

开始重新分片,输入1-16384 需要重新分配多少个哈希槽,输入哈希槽的目标节点id

被移动的哈希槽的原节点 如果原来的所有节点是源节点 直接输入all 也可以指定源节点,依次输入源节点后输入 done 结束

redis-cli –cluster reshard 127.0.0.1:7000

3、添加从节点

./src/redis-cli –cluster add-node 127.0.0.1:7001 xxx.xxx.xxx.xxx:7000 –cluster-slave –cluster-master-id 48c711cf275d4e2e849c5b5e094ddafe694d8534

8.6 Redis 为什么变慢了

业务场景中,不知道大家是否碰到过 Redis 变慢的情况:

  • 执行 SET、DEL 命令耗时也很久
  • 偶现卡顿,之后又恢复正常了
  • 在某个时间点,突然开始变慢了

image-20210428222100261

**原因分析:**查看慢查询,由于笔者本身机器没有慢查询,所以这里看到是空(实在尴尬,这里没有可用的例子~~)

  • 由于 Redis 在 IO 操作和对键值对的操作是单线程的,所以直接在客户端 Redis-cli 上执行的 Redis 命令有可能会导致操作延迟变大。
  • 使用复杂的命令会让 Redis的处理变慢,以及CPU过高,例如 SORT、SUNION、ZUNIONSTORE 聚合类命令(时间负责度O(N) )。
  • 查询的数据量过大,使得更多时间花费在数据协议的组装和网络传输过程中。
  • 大 key 查询,比如对于一个很大的 hash、zset 等,这样的对象对 Redis 的集群数据迁移带来了很大的问题,因为在集群环境下,如果某个 key 太大,会导致数据迁移卡顿。
  • 另外在内存分配上,如果一个 key 太大,那么当它需要扩容时,会一次性申请更大的一块内存,这也会导致卡顿。如果这个大 key 被删除,内存会一次性回收,卡顿现象会再一次产生。
  • 集中过期,变慢的时间统一,所以业务中的 Key 过期时间尽量在统一的一个时间点加上一个随机数时间。
  • 内存使用达到上限,当内存达到内存上限的时候,就不许淘汰一些数据,这个时候也可能导致 Redis 查询效率低。
  • 碎片整理,Redis 在 4.0 版本后会自动整理碎片(由于内存回收过程中存在大量的碎片空间,不整理会导致 Redis 的空间少量浪费),而在整理碎片的过程中会消耗 CPU 的资源,从而影响了请求得到性能。
  • 网络带宽,Redis 集群和业务混部,或者并发量过大以及每次返回的数据也很大,网卡带宽跑满的情况容易导致网络阻塞。
  • AOF 的频率过高,由于 AOF 需要将全部的写命令同步,如果同步的间隔比较短,也会影响到 Redis 的性能。
  • Redis 提供了 flushdb 和 flushall 指令,用来清空数据库,这也是导致 Redis 缓慢的操作。

8.7 Redis 安全

默认会监听 6379 端口,最好在 Redis 的配置文件中指定监听的 IP 地址,更进一步还可以增加 Redis 的 ACL 访问控制,对客户指定群组,并限限制用户对数据的读写权限。

访问 Redis 尽量走公司代理,由于 Redis 本身不支持 SSL 的链接,所以走公司代理可以保证安全。客户端登陆 Redis 必须设置 Auth 秘密登陆。

九 总结

Redis相比其他缓存,有一个非常大的优势,就是支持多种数据类型。

数据类型说明string字符串,最简单的k-v存储hashhash格式,value为field和value,适合ID-Detail这样的场景。list简单的list,顺序列表,支持首位或者末尾插入数据set无序list,查找速度快,适合交集、并集、差集处理sorted set有序的set

其实,通过上面的数据类型的特性,基本就能想到合适的应用场景了。

string——适合最简单的k-v存储,类似于memcached的存储结构,短信验证码,配置信息等,就用这种类型来存储。

hash——一般key为ID或者唯一标示,value对应的就是详情了。如商品详情,个人信息详情,新闻详情等。

list——因为list是有序的,比较适合存储一些有序且数据相对固定的数据。如省市区表、字典表等。因为list是有序的,适合根据写入的时间来排序,如:最新的***,消息队列等。

set——可以简单的理解为ID-List的模式,如微博中一个人有哪些好友,set最牛的地方在于,可以对两个set提供交集、并集、差集操作。例如:查找两个人共同的好友等。

Sorted Set——是set的增强版本,增加了一个score参数,自动会根据score的值进行排序。比较适合类似于top 10等不根据插入的时间来排序的数据。

如上所述,虽然Redis不像关系数据库那么复杂的数据结构,但是,也能适合很多场景,比一般的缓存数据结构要多。了解每种数据结构适合的业务场景,不仅有利于提升开发效率,也能有效利用Redis的性能。

十 其他

redis中文网:https://redis.com.cn/

redis官网:https://redis.io/