一、对于缓存的介绍
1.缓存中常见淘汰策略
如果一个缓存中不使用剔除算法,会导致内存占用越来越大,且无法回收。下面是几个常见的剔除算法:
- FIFO(first in first out)
先进先出策略,最先进入缓存的数据会被优先被清除掉。
- LFU(less frequently used)
最少使用策略,使用次数较少的元素被优先被清除掉
- LRU(least recently used)
最近使用策略,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素释放空间。
2.缓存简单分类
本地缓存 :指的是在应用中的缓存组件,其最大的优点是应用和cache是在同一个进程内部,请求缓存非常快速,没有过多的网络开销等,在单应用不需要集群支持或者集群情况下各节点无需互相通知的场景下使用本地缓存较合适;同时,它的缺点也是因为缓存跟应用程序耦合,多个应用程序无法直接的共享缓存,各应用或集群的各节点都需要维护自己的单独缓存,对内存是一种浪费。
分布式缓存 :指的是与应用分离的缓存组件或服务,其最大的优点是自身就是一个独立的应用,与本地应用隔离,多个应用可直接的共享缓存。缺点是:依赖网络,同时如果缓存服务崩溃可能会影响所有依赖节点。
3.缓存的具体分类
- 本地缓存
Java集合类
我们可以用Java中提供的集合类自己实现一个简单的缓存:
Guava Cache
Guava是 Google 提供的一个非常好用的 Java 工具包。Guava Cache 是 Guava 中的一个本地缓存实现,基于LRU算法实现。
Caffeine
Caffeine是一个基于 Java8 开发的提供了近乎最佳命中率的高性能的缓存库。
在本地缓存方面SpringBoot2.0抛弃了GuavaCache,选择了Caffeine。
Ehcache
Ehcache是纯Java开源缓存框架,配置简单、结构清晰、功能强大,是一个非常轻量级的缓存实现,我们常用的Hibernate里面就集成了相关缓存功能。在早期开发的时候也用过这个,现在不知道是否还在使用。
- 分布式缓存
Memcached
Redis
4.Memcache 与 Redis 的区别
- 数据结构:Redis不仅仅支持String,int型的数据类型,同时还提供list,set,hash等数据类型的存储;
- 存储数据安全—memcache挂掉后,数据没了;redis可以持久化;
- 应用场景不一样:Redis出来作为NoSQL数据库使用外,还能用做消息队列、数据堆栈和数据缓存等;Memcached适合于缓存SQL语句、数据集、用户临时性数据、延迟查询数据和session等。
5. 缓存的更新策略
缓存的更新策略包含:
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
策略一:先更新数据库,再更新缓存
- 这种策略会导致线程安全问题
例如:线程 1 更新了数据库,线程 2 也更新数据库, 这时候由于某种原因,线程 2 首先更新了缓存,线程 1 后续更新。 这样就导致了脏数据的问题。 因为目前数据库中存储的线程 2 更新后的数据,而缓存存储的是线程 1 更新的老数据。
- 更新缓存的复杂度相对较高
数据写入数据库之后,一般存入缓存的数据都要经过一系列的加工计算,然后写入缓存。 这时候更新缓存相比较于直接删除缓存要比较复杂。
策略二:先删除缓存,再更新数据库
- 这种策略可能导致数据不一致的问题。
线程 1 写数据删除缓存;这时候有线程 2 查询该缓存,发现不存在,则去访问数据库,得到旧值放入缓存;线程 1 更新数据库。这时候就出现了数据不一致的问题。 如果缓存没有过期时间,这个脏数据一直存在。
解决方案:在写数据库成功之后, 再次淘汰缓存一次。
策略三:先更新数据库,再删除缓存
可能会造成比较短暂的数据不一致。在更新完成数据库, 还没有删除缓存的时刻,如果有缓存数据访问, 就会造成数据不一致的情形。 但这种如果数据同步机制比较科学,一般都会比较快, 不一致的影响比较小。
6.缓存命中率
缓存命中: 可以同缓存中获取到需要的数据
缓存不命中: 缓存中无法获取所需数据,需要再次查询数据库或者其他数据存储载体。
缓存命中率 = 缓存中获取数据次数 / 获取数据总次数
通常来说,缓存命中率越高,缓存的收益越高,应用的性能也就越好。
提高缓存命中率通常的手段有:
缓存预加载
增加缓存存储量
调整缓存存储数据类型
提升缓存更新频次
二、Redis 概念理解
1.什么是 Redis?
简单来说 Redis就是⼀个使⽤ C 语⾔开发的非关系型键值对内存数据库,所以它的读写速度⾮常快,被⼴泛应⽤于缓存⽅向。另外,Redis 除了做分布式缓存之外,Redis 也经常⽤来做分布式锁,甚⾄是消息队列。
可以存储键和五种不同类型的值之间的映射。
键的类型只能为字符串,值支持五种数据类型:字符串、列表、集合、散列表、有序集合。
Redis 支持持久化,主从分离,集群等多种特性。
2.Redis事务
Redis事务的概念:
Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。
总结说:redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。
Redis事务没有隔离级别的概念:
批量操作在发送 EXEC 命令前被放入队列缓存,并不会被实际执行,也就不存在事务内的查询要看到事务里的更新,事务外查询不能看到。
Redis保证编译期的原子性:
Redis中事务不保证执行期间的原子性,因为没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。(但是编译阶段会检查命令的语法正确性,如有错误整个事务都不会被执行)
Redis事务的三个阶段:
- 开始事务
- 命令入队
- 执行事务
Redis事务相关命令:
watch key1 key2 … : 监视一或多个key,如果在事务执行之前,被监视的key被其他命令改动,则事务被打断 ( 类似乐观锁 )
multi : 标记一个事务块的开始( queued )
exec : 执行所有事务块的命令 ( 一旦执行exec后,之前加的监控锁都会被取消掉 )
discard : 取消事务,放弃事务块中的所有命令
unwatch : 取消watch对所有key的监控
3.Redis流水线
由于Redis命令时间执行非常短,影响时间开销的主要是网络时间,所以我们可以把一组命令打包,然后一次发送过去。这样的话,时间开销就变为:
1 次 pipeline(n条命令) = 1 次网络时间 + n 次命令时间

pipeline 的好处
- 省略由于单线程导致的命令排队时间,一次命令的消耗时间=一次网络时间 + 命令执行时间
- 比起命令执行时间,网络时间很可能成为系统的瓶颈
- pipeline的作用是将一批命令进行打包,然后发送给服务器,服务器执行完按顺序打包返回。
- 通过pipeline,一次pipeline(n条命令)=一次网络时间 + n次命令时间 | 命令 | N个命令操作 | 1次pipeline(n个命令) | | :—-: | :—-: | :—-: | | 时间 | n次网络+n次命令 | 1次网络+n次命令 | | 数据量 | 1条命令 | n条命令 |
pipeline VS M 操作(mget、mset)
之前我们讲过 M 操作,也是类似 pipeline,将多个命令一次执行,一次发送出去,节省网络时间。对比如下:
- M操作在Redis队列中是一个原子操作,pipeline不是原子操作
- pipeline与M操作都会将数据顺序的传送顺序地返回(redis 单线程)
- M 操作一个命令对应多个键值对,而Pipeline是多条命令
4.Redis发布、订阅消息
Redis提供了发布订阅功能,可以用于消息的传输,Redis的发布订阅机制包括三个部分,发布者,订阅者和Channel。
发布者和订阅者都是Redis客户端,Channel则为Redis服务器端,发布者将消息发送到某个的频道,订阅了这个频道的订阅者就能接收到这条消息。Redis的这种发布订阅机制与基于主题的发布订阅类似,Channel相当于主题。
具体指令如下:
- 发布消息:publish channel message
- 订阅消息:subscribe channel [……]
- 退订消息:punsubscribe
5.Redis bigkey
什么是 bigkey? 有什么影响?
bigkey 是指存储 value 占用内存空间非常大的 key。例如一个 String 类型的 value 占用了 100MB 空间。
bigkey 的影响主要体现在:
造成内存空间不平衡: 如果 bigkey 存储量比较大,同一个 key 在同一个节点或者服务器中存储,造成一定的影响
超时阻塞: 由于占用空间比较大,那么操作起来效率肯定比较低,也就表示出现阻塞可能性增加
网络阻塞: 获取 bigkey 的时候,自然传输的数据量比较大,导致宽带的压力。
怎么发现 bigkey?
redis -cli-bigkeys 命令可以统计 bigkey 的分布。
生产环境下执行 debug object key 查看 serializedlength属性,表示 key 对应的 value 序列化之后的字节数。
6. Redis 常用的业务场景
适用场景:
数据(热点)高并发的读写
海量数据的读写
对扩展性要求高的数据
业务场景:
热点数据缓存: 由于 Redis 访问速度块、支持的数据类型比较丰富,所以 Redis 很适合用来存储热点数据
限时业务实现: expire 命令设置 key 的生存时间,到时间后自动删除 key。收集验证码、优惠活动等业务场景。
计数器实现: incrby 命令可以实现原子性的递增,所以可以运用于高并发的秒杀活动、分布式序列号的生成。比如限制一个手机号发多少条短信、一个接口一分钟限制多少请求、一个接口一天限制调用多少次等等。
排行榜实现: 借助 SortedSet 进行热点数据的排序。例如:下单量最多的用户排行榜,最热门的帖子(回复最多)等。
布式锁实现: 利用 Redis 的 setnx 命令进行。后面会有详细的实现介绍。
队列机制实现: Redis 有 list push 和 list pop 这样的命令,所以能够很方便的执行队列操作。
7.Redis 支持的 Java 客户端
官方推荐的有三种:Jedis、Redisson 和 lettuce。
Jedis:
轻量,简洁,便于集成和改造
支持连接池
支持 pipelining、事务、LUA Scripting、Redis Sentinel、Redis Cluster
不支持读写分离,需要自己实现
Redisson:
基于 Netty 实现,采用非阻塞 IO,性能高
支持异步请求
支持连接池
支持 pipelining、LUA Scripting、Redis Sentinel、Redis Cluster
不支持事务
支持读写分离,支持读负载均衡,在主从复制和 Redis Cluster 架构下都可以使用
内建 Tomcat Session Manager,为 Tomcat 6/7/8 提供了会话共享功能
可以与 Spring Session 集成,实现基于 Redis 的会话共享
文档较丰富,有中文文档
Jedis 的基本使用方法
Jedis jedis = new Jedis("127.0.0.1", 6379,500,500);jedis.set("hello", "world”);String value = jedis.get("hello”);
上面代码就是一个简单的 Redis 数据存储操作。
初始化 Jedis 需要四个参数:
Jedis(final String host, final int port, final int connectionTimeout, final int soTimeout)
Host:Redis 节点的服务 IP
Port:Redis 服务的端口
connectionTimeout:客户端连接超时时间
soTimeout:客户端读写超时时间
6. 分布式锁
分布式锁是控制分布式系统之间同步访问共享资源的一种方式。 在单机或者单进程环境下,多线程并发的情况下,使用锁来保证一个代码块在同一时间内只能由一个线程执行。比如 Java 的 Synchronized 关键字和 Reentrantlock 类。 多进程或者分布式集群环境下,如何保证不同节点的线程同步执行呢? 这就是分布式锁。
分布式锁实现
1. Memcached 分布式锁
Memcached 提供了原子性操作命令 add,key 不存在才能 add 成功,线程获取到锁。key 已存在的情况下,则 add 失败,获取锁也失败。
2. Redis 分布式锁
Redis 的 setnx 命令为原子性操作命令。只有在 key 不存在的情况下,才能 set 成功。和 Memcached 的 add 方法比较类似。
3. ZooKeeper 分布式锁
利用 ZooKeeper 的顺序临时节点,来实现分布式锁和等待队列。
4. Chubby 实现分布式锁
Chubby 底层利用了 Paxos 一致性算法,实现粗粒度分布式锁服务。
分布式锁实现要保证几个基本点
互斥性:任意时刻,只有一个资源能够获取到锁。
容灾性:在未成功释放锁的的情况下,一定时限内能够恢复锁的正常功能。
统一性:加锁和解锁保证同一资源来进行操作。
Redis 实现分布式锁
简单方案:
最简单的方法是使用 setnx 命令。释放锁的最简单方式是执行 del 指令。
问题:
锁超时:如果一个得到锁的线程在执行任务的过程中挂掉,来不及显式地释放锁,这块资源将会永远被锁住(死锁),别的线程再也别想进来。
优化方案:
setnx 没办法设置超时时间,如果利用 expire 来设置超时时间,那么这两步操作不是原子性操作。
利用 set 指令增加了可选参数方式来替代 setnx。set 指令可以设置超时时间。
三、Redis 八种数据结构
1、简单动态字符串(SDS)
SDS 定义:
struct sdshdr{//记录buf数组中已使用字节的数量//等于 SDS 保存字符串的长度int len;//记录 buf 数组中未使用字节的数量int free;//字节数组,用于保存字符串char buf[];}
用SDS保存字符串 “Redis”具体图示如下:

SDS相比C语言字符串的优势:
①、常数复杂度获取字符串长度
②、杜绝缓冲区溢出
③、减少修改字符串的内存重新分配次数
④、二进制安全
⑤、兼容部分 C 字符串函数
2、链表(list)
链表节点
typedef struct listNode{//前置节点struct listNode *prev;//后置节点struct listNode *next;//节点的值void *value;}listNode
链表:
typedef struct list{//表头节点listNode *head;//表尾节点listNode *tail;//链表所包含的节点数量unsigned long len;//节点值复制函数void (* free) (void *ptr);//节点值释放函数void (* free) (void *ptr);//节点值对比函数int (*match) (void *ptr, void *key);}list;

Redis链表特性:
①、双端:链表具有前置节点和后置节点的引用,获取这两个节点时间复杂度都为O(1)。
②、无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问都是以 NULL 结束。
③、带链表长度计数器:通过 len 属性获取链表长度的时间复杂度为 O(1)。
**④、多态:链表节点使用 void 指针来保存节点值,可以保存各种不同类型的值。
3、字典(dict)
字典又称为符号表或者关联数组、或映射(map),是一种用于保存键值对的抽象数据结构。字典中的每一个键 key 都是唯一的,通过 key 可以对值来进行查找或修改。
哈希表结构定义:
typedef struct dictht{//哈希表数组dictEntry **table;//哈希表大小unsigned long size;//哈希表大小掩码,用于计算索引值//总是等于 size-1unsigned long sizemask;//该哈希表已有节点的数量unsigned long used;}dictht
哈希表是由数组 table 组成,table 中每个元素都是指向 dict.h/dictEntry 结构,dictEntry 结构定义如下:
typedef struct dictEntry{//键: key 用来保存键void *key;//值:值可以是一个指针,也可以是uint64_t整数,也可以是int64_t整数union{void *val;uint64_tu64;int64_ts64;}v;//指向下一个哈希表节点,形成链表,解决哈希冲突struct dictEntry *next;}dictEntry

4、跳跃表(skiplist)
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其它节点的指针,从而达到快速访问节点的目的。具有如下性质:
1、由很多层结构组成;
2、每一层都是一个有序的链表,排列顺序为由高层到底层,都至少包含两个链表节点,分别是前面的head节点和后面的nil节点;
3、最底层的链表包含了所有的元素;
4、如果一个元素出现在某一层的链表中,那么在该层之下的链表也全都会出现(上一层的元素是当前层的元素的子集);
5、链表中的每个节点都包含两个指针,一个指向同一层的下一个链表节点,另一个指向下一层的同一个链表节点;

5、整数集合(intset)
整数集合(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)
压缩列表(ziplist)是Redis为了节省内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
压缩列表的原理:压缩列表并不是对数据利用某种算法进行压缩,而是将数据按照一定规则编码在一块连续的内存区域,目的是节省内存。

7、快速列表(quicklist)
这是一种, 以ziplist为结点的, 双端链表结构. 宏观上, quicklist是一个链表, 微观上, 链表中的每个结点都是一个ziplist.
8.压缩字典(zipmap)
dict作为字典结构, 优点很多, 扩展性强悍, 支持平滑扩容等等, 但对于字典中的键值均为二进制数据, 且长度都很小时, dict的中的一坨指针会浪费不少内存, 因此Redis又实现了一个轻量级的字典, 即为zipmap.
zipmap适合使用的场合是:
键值对量不大, 单个键, 单个值长度小
总结
大多数情况下,Redis使用简单字符串SDS作为字符串的表示,相对于C语言字符串,SDS具有常数复杂度获取字符串长度,杜绝了缓存区的溢出,减少了修改字符串长度时所需的内存重分配次数,以及二进制安全能存储各种类型的文件,并且还兼容部分C函数。
通过为链表设置不同类型的特定函数,Redis链表可以保存各种不同类型的值,除了用作列表键,还在发布与订阅、慢查询、监视器等方面发挥作用(后面会介绍)。
Redis的字典底层使用哈希表实现,每个字典通常有两个哈希表,一个平时使用,另一个用于rehash时使用,使用链地址法解决哈希冲突。
跳跃表通常是有序集合的底层实现之一,表中的节点按照分值大小进行排序。
整数集合是集合键的底层实现之一,底层由数组构成,升级特性能尽可能的节省内存。
压缩列表是Redis为节省内存而开发的顺序型数据结构,通常作为列表键和哈希键的底层实现之一。
四、Redis 五种数据类型
1、对象的类型与编码
Redis使用前面说的五大数据类型来表示键和值,每次在Redis数据库中创建一个键值对时,至少会创建两个对象,一个是键对象,一个是值对象,而Redis中的每个对象都是由 redisObject 结构来表示:
typedef struct redisObject{//类型:记录了对象属于五大数据类型中的哪一类unsigned type:4;//编码:决定对象底层的数据结构unsigned encoding:4;//指向底层数据结构的指针void *ptr;//引用计数int refcount;//记录最后一次被程序访问的时间unsigned lru:22;}robj
注意:在Redis中,键总是一个字符串对象,而值可以是字符串、列表、集合等对象,所以我们通常说的键为字符串键,表示的是这个键对应的值为字符串对象,我们说一个键为集合键时,表示的是这个键对应的值为集合对象。
2、字符串对象
字符串是Redis最基本的数据类型,不仅所有key都是字符串类型,其它几种数据类型构成的元素也是字符串。注意字符串的大小不能超过512M。
①、编码
字符串对象的编码可以是int,raw或者embstr。
1、int 编码:保存的是可以用 long 类型表示的整数值。
2、raw 编码:保存长度大于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)。
3、embstr 编码:保存长度小于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)。
由上可以看出,int 编码是用来保存整数值,raw编码是用来保存长字符串,而embstr是用来保存短字符串。其实 embstr 编码是专门用来保存短字符串的一种优化编码,raw 和 embstr 的区别:


embstr与raw都使用redisObject和sds保存数据,区别在于,embstr的使用只分配一次内存空间(因此redisObject和sds是连续的),而raw需要分配两次内存空间(分别为redisObject和sds分配空间)。因此与raw相比,embstr的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。而embstr的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,因此redis中的embstr实现为只读。
ps:Redis中对于浮点数类型也是作为字符串保存的,在需要的时候再将其转换成浮点数类型。
②、编码的转换
当 int 编码保存的值不再是整数,或大小超过了long的范围时,自动转化为raw。
对于 embstr 编码,由于 Redis 没有对其编写任何的修改程序(embstr 是只读的),在对embstr对象进行修改时,都会先转化为raw再进行修改,因此,只要是修改embstr对象,修改后的对象一定是raw的,无论是否达到了44个字节。
3、列表对象
list 列表,它是简单的字符串列表,按照插入顺序排序,你可以添加一个元素到列表的头部(左边)或者尾部(右边),它的底层实际上是个链表结构。
①、编码
列表对象的编码可以是 ziplist(压缩列表) 和 linkedlist(双端链表)。 关于链表和压缩列表的特性可以看我前面。
比如我们执行以下命令,创建一个 key = ‘numbers’,value = ‘1 three 5’ 的三个值的列表。
rpush numbers 1 ``"three"` `5
ziplist 编码表示如下:

linkedlist表示如下:

②、编码转换
当同时满足下面两个条件时,使用ziplist(压缩列表)编码:
1、列表保存元素个数小于512个
2、每个元素长度小于64字节
不能满足这两个条件的时候使用 linkedlist 编码。
上面两个条件可以在redis.conf 配置文件中的 list-max-ziplist-value选项和 list-max-ziplist-entries 选项进行配置。
4、哈希对象
哈希对象的键是一个字符串类型,值是一个键值对集合。
①、编码
哈希对象的编码可以是 ziplist 或者 hashtable。
当使用ziplist,也就是压缩列表作为底层实现时,新增的键值对是保存到压缩列表的表尾。比如执行以下命令:
hset profile name ``"Tom"``hset profile age 25``hset profile career ``"Programmer"
如果使用ziplist,profile 存储如下:

当使用 hashtable 编码时,上面命令存储如下:

hashtable 编码的哈希表对象底层使用字典数据结构,哈希对象中的每个键值对都使用一个字典键值对。
在前面介绍压缩列表时,我们介绍过压缩列表是Redis为了节省内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,相对于字典数据结构,压缩列表用于元素个数少、元素长度小的场景。其优势在于集中存储,节省空间。
②、编码转换
和上面列表对象使用 ziplist 编码一样,当同时满足下面两个条件时,使用ziplist(压缩列表)编码:
1、列表保存元素个数小于512个
2、每个元素长度小于64字节
不能满足这两个条件的时候使用 hashtable 编码。第一个条件可以通过配置文件中的 set-max-intset-entries 进行修改。
5、集合对象
集合对象 set 是 string 类型(整数也会转换成string类型进行存储)的无序集合。
①、编码
集合对象的编码可以是 intset 或者 hashtable。
intset 编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合中。
hashtable 编码的集合对象使用 字典作为底层实现,字典的每个键都是一个字符串对象,这里的每个字符串对象就是一个集合中的元素,而字典的值则全部设置为 null。这里可以类比Java集合中HashSet 集合的实现,HashSet 集合是由 HashMap 来实现的,集合中的元素就是 HashMap 的key,而 HashMap 的值都设为 null。
SADD numbers 1 3 5

SADD Dfruits ``"apple"` `"banana"` `"cherry"

②、编码转换
当集合同时满足以下两个条件时,使用 intset 编码:
1、集合对象中所有元素都是整数
2、集合对象所有元素数量不超过512
不能满足这两个条件的就使用 hashtable 编码。第二个条件可以通过配置文件的 set-max-intset-entries 进行配置。
6、有序集合对象
和上面的集合对象相比,有序集合对象是有序的。与列表使用索引下标作为排序依据不同,有序集合为每个元素设置一个分数(score)作为排序依据。
①、编码
有序集合的编码可以是 ziplist 或者 skiplist。
ziplist 编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个节点保存元素的分值。并且压缩列表内的集合元素按分值从小到大的顺序进行排列,小的放置在靠近表头的位置,大的放置在靠近表尾的位置。
ZADD price 8.5 apple 5.0 banana 6.0 cherry


skiplist 编码的有序集合对象使用 zet 结构作为底层实现,一个 zset 结构同时包含一个字典和一个跳跃表:
typedef` `struct` `zset{`` ``//跳跃表`` ``zskiplist *zsl;`` ``//字典`` ``dict *dice;``} zset;
字典的键保存元素的值,字典的值则保存元素的分值;跳跃表节点的 object 属性保存元素的成员,跳跃表节点的 score 属性保存元素的分值。
这两种数据结构会通过指针来共享相同元素的成员和分值,所以不会产生重复成员和分值,造成内存的浪费。
说明:其实有序集合单独使用字典或跳跃表其中一种数据结构都可以实现,但是这里使用两种数据结构组合起来,原因是假如我们单独使用 字典,虽然能以 O(1) 的时间复杂度查找成员的分值,但是因为字典是以无序的方式来保存集合元素,所以每次进行范围操作的时候都要进行排序;假如我们单独使用跳跃表来实现,虽然能执行范围操作,但是查找操作有 O(1)的复杂度变为了O(logN)。因此Redis使用了两种数据结构来共同实现有序集合。
②、编码转换
当有序集合对象同时满足以下两个条件时,对象使用 ziplist 编码:
1、保存的元素数量小于128;
2、保存的所有元素长度都小于64字节。
不能满足上面两个条件的使用 skiplist 编码。以上两个条件也可以通过Redis配置文件zset-max-ziplist-entries 选项和 zset-max-ziplist-value 进行修改。
7、五大数据类型的应用场景
对于string 数据类型,因为string 类型是二进制安全的,可以用来存放图片,视频等内容,另外由于Redis的高性能读写功能,而string类型的value也可以是数字,可以用作计数器(INCR,DECR),比如分布式环境中统计系统的在线人数,秒杀等。
对于 hash 数据类型,value 存放的是键值对,比如可以做单点登录存放用户信息。
对于 list 数据类型,可以实现简单的消息队列,另外可以利用lrange命令,做基于redis的分页功能
对于 set 数据类型,由于底层是字典实现的,查找元素特别快,另外set 数据类型不允许重复,利用这两个特性我们可以进行全局去重,比如在用户注册模块,判断用户名是否注册;另外就是利用交集、并集、差集等操作,可以计算共同喜好,全部的喜好,自己独有的喜好等功能。
对于 zset 数据类型,有序的集合,可以做范围查找,排行榜应用,取 TOP N 操作等。
8、内存回收和内存共享
16. 设置键的生存时间和过期时间有哪些命令?
EXPIRE 以秒为单位,设置键的生存时间
PEXPIRE 以毫秒为单位,设置键的生存时间
EXPIREAT 以秒为单位,设置键的过期 UNIX 时间戳
PEXPIREAT 以毫秒为单位,设置键的过期 UNIX 时间戳
五、Redis内存回收
Reids 所有的数据都是存储在内存中的,在某些情况下需要对占用的内存空间进行回收。内存回收主要分为两类,一类是 key 过期,一类是内存使用达到上限(max_memory)触发内存淘汰。
1.过期策略
要实现 key 过期,我们有几种思路。
定时过期(主动淘汰)
每个设置过期时间的 key 都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的 CPU 资源去处理过期的
数据,从而影响缓存的响应时间和吞吐量。
惰性过期(被动淘汰)
只有当访问一个 key 时,才会判断该 key 是否已过期,过期则清除。该策略可以最大化地节省 CPU 资源,却对内存非常不友好。极端情况可能出现大量的过期 key 没有再
次被访问,从而不会被清除,占用大量内存。
定期过期

每隔一定的时间,会扫描一定数量的数据库的 expires 字典中一定数量的 key,并清除其中已过期的 key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不
同情况下使得 CPU 和内存资源达到最优的平衡效果。
Redis 中同时使用了惰性过期和定期过期两种过期策略。
问题:如果都不过期,Redis 内存满了怎么办?
2. 淘汰策略
Redis 的内存淘汰策略,是指当内存使用达到最大内存极限时,需要使用淘汰算法来决定清理掉哪些数据,以保证新数据的存入。
1 最大内存设置
redis.conf 参数配置:
# maxmemory <bytes>
如果不设置 maxmemory 或者设置为 0,64 位系统不限制内存,32 位系统最多使用 3GB 内存。
动态修改:
redis> config set maxmemory 2GB
到达最大内存以后怎么办?
2 淘汰策略
不同于之前的版本,redis5.0为我们提供了八个不同的内存置换策略。很早之前提供了6种。

(1)volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰。
(2)volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰。
(3)volatile-random:从已设置过期时间的数据集中任意选择数据淘汰。
(4)volatile-lfu:从已设置过期时间的数据集挑选使用频率最低的数据淘汰。
(5)allkeys-lru:从数据集中挑选最近最少使用的数据淘汰。
(6)allkeys-lfu:从数据集中挑选使用频率最低的数据淘汰。
(7)allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
(8) no-enviction(驱逐):禁止驱逐数据,这也是默认策略。意思是当内存不足以容纳新入数据时,新写入操作就会报错,请求可以继续进行,线上任务也不能持续进行,采用no-enviction策略可以保证数据不被丢失。
这八种大体上可以分为4中,lru、lfu、random、ttl。
如果没有符合前提条件的 key 被淘汰,那么 volatile-lru、volatile-random 、volatile-ttl 相当于 noeviction(不做内存回收)。
动态修改淘汰策略:
redis> config set maxmemory-policy volatile-lru
建议使用 volatile-lru,在保证正常服务的情况下,优先删除最近最少使用的 key。
总结
Redis 对于内存的回收有两种方式,一种是过期 Key 的回收,另一种是超过 Redis 的最大内存后的内存释放。
对于第一种情况,Redis 会在:
每一次访问的时候判断 Key 的过期时间是否到达,如果到达,就删除 Key。
Redis 启动时会创建一个定时事件,会定期清理部分过期的 Key,默认是每秒执行十次检查,每次过期 Key 清理的时间不超过 CPU 时间的 25%。
即若 hz=1,则一次清理时间最大为 250ms,若 hz=10,则一次清理时间最大为 25ms。
对于第二种情况,Redis 会在每次处理 Redis 命令的时候判断当前 Redis 是否达到了内存的最大限制,如果达到限制,则使用对应的算法去处理需要删除的 Key。
六、Redis持久化
一、RDB快照(snapshotting)
1、RDB 简介
RDB是Redis用来进行持久化的一种方式,是把当前内存中的数据集快照写入磁盘。恢复时是将快照文件直接读到内存里。
2、触发方式
RDB 有两种触发方式,分别是自动触发和手动触发。
①、自动触发
在 redis.conf 配置文件中的 SNAPSHOTTING 下
save:这里是用来配置触发 Redis的 RDB 持久化条件,也就是什么时候将内存中的数据保存到硬盘。比如“save m n”。表示m秒内数据集存在n次修改时,自动触发bgsave(这个命令下面会介绍,手动触发RDB持久化的命令)
默认如下配置:
save 900 1:表示900 秒内如果至少有 1 个 key 的值变化,则保存save 300 10:表示300 秒内如果至少有 10 个 key 的值变化,则保存save 60 10000:表示60 秒内如果至少有 10000 个 key 的值变化,则保存
当然如果你只是用Redis的缓存功能,不需要持久化,那么你可以注释掉所有的 save 行来停用保存功能。可以直接一个空字符串来实现停用:save “”。
②、手动触发
手动触发Redis进行RDB持久化的命令有两种:
1、save
该命令会阻塞当前Redis服务器,执行save命令期间,Redis不能处理其他命令,直到RDB过程完成为止。
显然该命令对于内存比较大的实例会造成长时间阻塞,这是致命的缺陷,为了解决此问题,Redis提供了第二种方式。
2、bgsave
执行该命令时,Redis会在后台异步进行快照操作,快照同时还可以响应客户端请求。具体操作是Redis进程执行fork操作创建子进程,RDB持久化过程由子进程负责,完成后自动结束。阻塞只发生在fork阶段,一般时间很短。
基本上 Redis 内部所有的RDB操作都是采用 bgsave 命令。
ps:执行执行 flushall 命令,也会产生dump.rdb文件,但里面是空的.
3、恢复数据
将备份文件 (dump.rdb) 移动到 redis 安装目录并启动服务即可,redis就会自动加载文件数据至内存了。Redis 服务器在载入 RDB 文件期间,会一直处于阻塞状态,直到载入工作完成为止。
4、停止 RDB 持久化
有些情况下,我们只想利用Redis的缓存功能,并不像使用 Redis 的持久化功能,那么这时候我们最好停掉 RDB 持久化。可以通过上面讲的在配置文件 redis.conf 中,可以注释掉所有的 save 行来停用保存功能或者直接一个空字符串来实现停用:save “”
也可以通过命令:
redis-cli config set save ``" "
5、RDB 的优势和劣势
①、优势
1.RDB是一个非常紧凑(compact)的文件,它保存了redis 在某个时间点上的数据集。这种文件非常适合用于进行备份和灾难恢复。
2.生成RDB文件的时候,redis主进程会fork()一个子进程来处理所有保存工作,主进程不需要进行任何磁盘IO操作。
3.RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快。
②、劣势
1、RDB方式数据没办法做到实时持久化/秒级持久化。因为bgsave每次运行都要执行fork操作创建子进程,属于重量级操作。
2、RDB文件使用特定二进制格式保存,Redis版本演进过程中有多个格式的RDB版本,存在老版本Redis服务无法兼容新版RDB格式的问题(版本不兼容)
3、在一定间隔时间做一次备份,所以如果redis意外down掉的话,就会丢失最后一次快照后的所有修改(数据有丢失)
二、ADF持久化
1、AOF简介
Redis的持久化方式之一RDB是通过保存数据库中的键值对来记录数据库的状态。而另一种持久化方式 AOF 则是通过保存Redis服务器所执行的写命令来记录数据库状态。
比如对于如下命令:

RDB 持久化方式就是将 str1,str2,str3 这三个键值对保存到 RDB文件中,而 AOF 持久化则是将执行的 set,sadd,lpush 三个命令保存到 AOF 文件中。
2、开启 AOF
将 redis.conf 的 appendonly 配置改为 yes 即可。
AOF 保存文件的位置和 RDB 保存文件的位置一样,都是通过 redis.conf 配置文件的 dir 配置:

可以通过 config get dir 命令获取保存的路径。
4、AOF 文件恢复
重启 Redis 之后就会进行 AOF 文件的载入。
异常修复命令:redis-check-aof —fix 进行修复
5、 AOF 重写
由于AOF持久化是Redis不断将写命令记录到 AOF 文件中,随着Redis不断的进行,AOF 的文件会越来越大,文件越大,占用服务器内存越大以及 AOF 恢复要求时间越长。为了解决这个问题,Redis新增了重写机制,当AOF文件的大小超过所设定的阈值时,Redis就会启动AOF文件的内容压缩,只保留可以恢复数据的最小指令集。可以使用命令 bgrewriteaof 来重新。
比如对于如下命令:

如果不进行 AOF 文件重写,那么 AOF 文件将保存四条 SADD 命令,如果使用AOF 重写,那么AOF 文件中将只会保留下面一条命令:
sadd animals ``"dog"` `"tiger"` `"panda"` `"lion"` `"cat"
也就是说 AOF 文件重写并不是对原文件进行重新整理,而是直接读取服务器现有的键值对,然后用一条命令去代替之前记录这个键值对的多条命令,生成一个新的文件后去替换原来的 AOF 文件。
AOF 文件重写触发机制:通过 redis.conf 配置文件中的 auto-aof-rewrite-percentage:默认值为100,以及auto-aof-rewrite-min-size:64mb 配置,也就是说默认Redis会记录上次重写时的AOF大小,默认配置是当AOF文件大小是上次rewrite后大小的一倍且文件大于64M时触发。
这里再提一下,我们知道 Redis 是单线程工作,如果 重写 AOF 需要比较长的时间,那么在重写 AOF 期间,Redis将长时间无法处理其他的命令,这显然是不能忍受的。Redis为了克服这个问题,解决办法是将 AOF 重写程序放到子程序中进行,这样有两个好处:
①、子进程进行 AOF 重写期间,服务器进程(父进程)可以继续处理其他命令。
②、子进程带有父进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的安全性。
使用子进程解决了上面的问题,但是新问题也产生了:因为子进程在进行 AOF 重写期间,服务器进程依然在处理其它命令,这新的命令有可能也对数据库进行了修改操作,使得当前数据库状态和重写后的 AOF 文件状态不一致。
为了解决这个数据状态不一致的问题,Redis 服务器设置了一个 AOF 重写缓冲区,这个缓冲区是在创建子进程后开始使用,当Redis服务器执行一个写命令之后,就会将这个写命令也发送到 AOF 重写缓冲区。当子进程完成 AOF 重写之后,就会给父进程发送一个信号,父进程接收此信号后,就会调用函数将 AOF 重写缓冲区的内容都写到新的 AOF 文件中。
这样将 AOF 重写对服务器造成的影响降到了最低。
6、AOF的优缺点
优点:
①、AOF 持久化的方法提供了多种的同步频率,即使使用默认的同步频率每秒同步一次,Redis 最多也就丢失 1 秒的数据而已。
②、AOF 文件使用 Redis 命令追加的形式来构造,因此,即使 Redis 只能向 AOF 文件写入命令的片断,使用 redis-check-aof 工具也很容易修正 AOF 文件。
③、AOF 文件的格式可读性较强,这也为使用者提供了更灵活的处理方式。例如,如果我们不小心错用了 FLUSHALL 命令,在重写还没进行时,我们可以手工将最后的 FLUSHALL 命令去掉,然后再使用 AOF 来恢复数据。
缺点:
①、对于具有相同数据的的 Redis,AOF 文件通常会比 RDF 文件体积更大。
②、虽然 AOF 提供了多种同步的频率,默认情况下,每秒同步一次的频率也具有较高的性能。但在 Redis 的负载较高时,RDB 比 AOF 具好更好的性能保证。
③、RDB 使用快照的形式来持久化整个 Redis 数据,而 AOF 只是将每次执行的命令追加到 AOF 文件中,因此从理论上说,RDB 比 AOF 方式更健壮。
7、RDB-AOF混合持久化
这里补充一个知识点,在Redis4.0之后,既上一篇文章介绍的RDB和这篇文章介绍的AOF两种持久化方式,又新增了RDB-AOF混合持久化方式。
这种方式结合了RDB和AOF的优点,既能快速加载又能避免丢失过多的数据。
具体配置为:
aof-use-rdb-preamble
设置为yes表示开启,设置为no表示禁用。
当开启混合持久化时,主进程先fork出子进程将现有内存副本全量以RDB方式写入aof文件中,然后将缓冲区中的增量命令以AOF方式写入aof文件中,写入完成后通知主进程更新相关信息,并将新的含有 RDB和AOF两种格式的aof文件替换旧的aof文件。
简单来说:混合持久化方式产生的文件一部分是RDB格式,一部分是AOF格式。
这种方式优点我们很好理解,缺点就是不能兼容Redis4.0之前版本的备份文件了。
七、主从复制

1、修改配置文件
首先将redis.conf 配置文件复制三份,通过修改端口分别模拟三台Redis服务器。

然后我们分别对这三个redis.conf 文件进行修改。
①、修改 daemonize yes

表示指定Redis以守护进程的方式启动(后台启动)
②、配置PID文件路径 pidfile

表示当redis作为守护进程运行的时候,它会把 pid 默认写到 /var/redis/run/redis_6379.pid 文件里面
③、配置端口 port

④、配置log 文件名字

⑤、配置rdb文件名

依次将 6380redis.conf 、6381redis.conf 配置一次,则配置完毕。
接下来我们分别启动这三个服务。

通过命令查看Redis是否启动:

接下来通过如下命令分别进入到这三个Redis客户端:
redis-cli -p 6379``redis-cli -p 6380``redis-cli -p 6381
注意:如果修改端口了,启动redis-cli 命令,比如加上 -p 端口,否则连接不上.如果密码也修改了,则还得添加密码 -a 密码指令来连接.
2、设置主从关系
①、通过 info replication 命令查看节点角色


我们发现这三个节点都是扮演的 Master 角色。那么如何将 6380 和 6381 节点变为 Slave 角色呢?
②、选择6380端口和6381端口,执行命令:SLAVEOF 127.0.0.1 6379

我们再看 6379 节点信息:

这里通过命令来设置主从关系,一旦服务重启,那么角色关系将不复存在。想要永久的保存这种关系,可以通过配置redis.conf 文件来配置。
slaveof 127.0.0.1 6379
3、测试主从关系
①、增量复制
主节点执行 set k1 v1 命令,从节点 get k1 能获取吗?



由上图可知是可以获取的。
②、全量复制
通过执行 SLAVEOF 127.0.0.1 6379,如果主节点 6379 以前还存在一些 key,那么执行命令之后,从节点会将以前的信息也都复制过来吗?
答案也是肯定的,这里我就不贴测试结果了。
③、主从读写分离
主节点能够执行写命令,从节点能够执行写命令吗?

这里的原因是在配置文件 6381redis.conf 中对于 slave-read-only 的配置

如果我们将其修改为 no 之后,执行写命令是可以的。

但是从节点写命令的数据从节点或者主节点都不能获取的。
④、主节点宕机
主节点 Maste 挂掉,两个从节点角色会发生变化吗?


上图可知主节点 Master 挂掉之后,从节点角色还是不会改变的。
⑤、主节点宕机后恢复
主节点Master挂掉之后,马上启动主机Maste,主节点扮演的角色还是 Master 吗?

也就是说主节点挂掉之后重启,又恢复了主节点的角色。
4、哨兵模式
通过前面的配置,主节点Master 只有一个,一旦主节点挂掉之后,从节点没法担起主节点的任务,那么整个系统也无法运行。如果主节点挂掉之后,从节点能够自动变成主节点,那么问题就解决了,于是哨兵模式诞生了。
哨兵模式就是不时地监控redis是否按照预期良好地运行(至少是保证主节点是存在的),若一台主机出现问题时,哨兵会自动将该主机下的某一个从机设置为新的主机,并让其他从机和新主机建立主从关系。

哨兵模式搭建步骤:
①、在配置文件目录下新建 sentinel.conf 文件,名字绝不能错,然后配置相应内容

sentinel monitor 被监控机器的名字(自己起名字) ip地址 端口号 得票数

分别配置被监控的名字,ip地址,端口号,以及得票数。上面的得票数为1表示表示主机挂掉后salve投票看让谁接替成为主机,得票数大于1便成为主机
②、启动哨兵
redis-sentinel /etc/redis/sentinel.conf
启动界面:

接下来,我们干掉主机 6379,然后看从节点有啥变化。

干掉主节点之后,我们查看后台打印日志,发现 6381投票变为主节点了。

这时候我们查看从节点 6381的节点信息:

6381 节点自动变为主节点了。
PS:哨兵模式也存在单点故障问题,如果哨兵机器挂了,那么就无法进行监控了,解决办法是哨兵也建立集群,Redis哨兵模式是支持集群的。
5、主从复制原理
Redis的复制功能分为同步(sync)和命令传播(command propagate)两个操作。
①、旧版同步
当从节点发出 SLAVEOF 命令,要求从服务器复制主服务器时,从服务器通过向主服务器发送 SYNC 命令来完成。该命令执行步骤:
1、从服务器向主服务器发送 SYNC 命令
2、收到 SYNC 命令的主服务器执行 BGSAVE 命令,在后台生成一个 RDB 文件,并使用一个缓冲区记录从开始执行的所有写命令
3、当主服务器的 BGSAVE 命令执行完毕时,主服务器会将 BGSAVE 命令生成的 RDB 文件发送给从服务器,从服务器接收此 RDB 文件,并将服务器状态更新为RDB文件记录的状态。
4、主服务器将缓冲区的所有写命令也发送给从服务器,从服务器执行相应命令。
②、命令传播
当同步操作完成之后,主服务器会进行相应的修改命令,这时候从服务器和主服务器状态就会不一致。
为了让主服务器和从服务器保持状态一致,主服务器需要对从服务器执行命令传播操作,主服务器会将自己的写命令发送给从服务器执行。从服务器执行相应的命令之后,主从服务器状态继续保持一致。
总结:通过同步操作以及命令传播功能,能够很好的保证了主从一致的特性。
但是我们考虑一个问题,如果从服务器在同步主服务器期间,突然断开了连接,而这时候主服务器进行了一些写操作,这时候从服务器恢复连接,如果我们在进行同步,那么就必须将主服务器从新生成一个RDB文件,然后给从服务器加载,这样虽然能够保证一致性,但是其实断开连接之前主从服务器状态是保持一致的,不一致的是从服务器断开连接,而主服务器执行了一些写命令,那么从服务器恢复连接后能不能只要断开连接的哪些写命令,而不是整个RDB快照呢?
同步操作其实是一个非常耗时的操作,主服务器需要先通过 BGSAVE 命令来生成一个 RDB 文件,然后需要将该文件发送给从服务器,从服务器接收该文件之后,接着加载该文件,并且加载期间,从服务器是无法处理其他命令的。
为了解决这个问题,Redis从2.8版本之后,使用了新的同步命令 PSYNC 来代替 SYNC 命令。该命令的部分重同步功能用于处理断线后重复制的效率问题。也就是说当从服务器在断线后重新连接主服务器时,主服务器只将断开连接后执行的写命令发送给从服务器,从服务器只需要接收并执行这些写命令即可保持主从一致。
6、主从复制的缺点
主从复制虽然解决了主节点的单点故障问题,但是由于所有的写操作都是在 Master 节点上操作,然后同步到 Slave 节点,那么同步就会有一定的延时,当系统很繁忙的时候,延时问题就会更加严重,而且会随着从节点slave的增多而愈加严重。
八、Redis集群
1、为什么需要集群?
①、并发量
通常来说,单台Redis能够执行10万/秒的命令,这个并发基本上能够满足我们所有需求了,但有时候比如做离线计算,为了更快的得出结果,有时候我们希望超过这个并发,那这个时候单机就不满足我们需求了,就需要集群了.
②、数据量
通常来说,单台服务器的内存大概在16G-256G之间,前面我们说Redis数据量都是存在内存中的,那如果实际业务要保存在Redis的数据量超过了单台机器的内存,这个时候最简单的方法是增加服务器内存,但是单台服务器内存不可能无限制的增加,纵向扩展不了了,便想到如何进行横向扩展.这时候我们就会想将这些业务数据分散存储在多台Redis服务器中,但是要保证多台Redis服务器能够无障碍的进行内存数据沟通,这也就是Redis集群.
2、数据分区方式
对于集群来说,如何将原来单台机器上的数据拆分,然后尽量均匀的分布到多台机器上,这是我们创建集群首先要考虑的一个问题,通常来说,有如下两种数据分区方式.
①、顺序分布
比如我们有100W条数据,有3台服务器,我们可以将100W/3的结果分别存储到三台服务器上,如下所示:

特点:键值业务相关;数据分散,但是容易造成访问倾斜;支持顺序访问;支持批量操作
②、哈希分布
同样是100W条数据,有3台服务器,通过自定义一个哈希函数,比如节点取余的方法,余数为0的存在第一台服务器,余数为1的存在第二台服务器,余数为2的存储在第三台服务器.如下所示:

特点:数据分散度高;键值分布与业务无关;不支持顺序访问;支持批量操作。
3、一致性哈希分布
问题:对于上面介绍的哈希分布,大家可以想一下,如果向集群中增加节点,或者集群中有节点宕机,这个时候应该怎么处理?
①、增加节点

如上图所示,总共10个数据通过节点取余hash(key)%/3 的方式分布到3个节点,这时候由于访问量变大,要进行扩容,由 3 个节点变为 4 个节点。
我们发现,如图所示,数据除了标红的1 2 没有进行迁移,别的数据都要进行变动,达到了80%,如果这时候并发很高,80%的数据都要从下层节点(比如数据库)获取,会给下层节点造成很大的访问压力,这是不能接受的。
即使我们进行翻倍扩容,从3个节点增加到6个节点,其数据迁移也在50%左右。
②、删除节点

上图其实不管是哪一个节点宕机,其数据迁移量都会超过50%。基本上也是我们所不能接受的。
那么如何使得集群中新增节点或者删除节点时,数据迁移量最少?——一致性哈希算法诞生。
PS:关于一致性哈希算法,我会另外写一篇博客进行详细介绍,这里只是大概介绍一下。

假设有一个哈希环,从0到2的32次方,均匀的分成三份,中间存放三个节点,沿着顺时针旋转,从Node1到Node2之间的数据,存放在Node2节点上;从Node2到Node3之间的数据,存放在Node3节点上,依次类推。
假设Node1节点宕机,那么原来Node3到Node1之间的数据这时候改为存放到Node2节点上,Node2到Node3之间数据保持不变,原来Node1到Node2之间的数据还是存放在Node2上,也就是只影响三分之一的数据,节点越多,影响数据越少。

同理,假设增加一个节点,影响的数据甚至更少。

当然,实际业务中并不是你节点均匀分布,访问就会很平均,这时候容易造成访问倾斜的问题,这里就会引出虚拟节点的定义。我这里就不做详解了。
4、Redis Cluster虚拟槽分区
Redis集群数据分布没有使用一致性哈希分布,而是使用虚拟槽分区概念。
Redis内部内置了序号 0-16383 个槽位,每个槽位可以用来存储一个数据集合,将这些槽位按顺序分配到集群中的各个节点。每次新的数据到来,会通过哈希函数 CRC16(key) 算出将要存储的槽位下标,然后通过该下标找到前面分配的Redis节点,最后将数据存储到该节点中。
具体情况如下图:(以集群有3个节点为例)

至于为什么Redis不使用一致性哈希分布,而是虚拟槽分区。因为虚拟槽分区虽然没有一致性哈希那么灵活,但是CRC16(key)%16384 已经分布很均匀了,并且对于后面节点增删操作起来也很方便。
5、原生搭建 Redis Cluster
集群以三主三从的模式来搭建。
①、服务器列表

②、配置各个节点参数
#配置端口port ${port}#以守护进程模式启动daemonize yes#pid的存放文件pidfile /var/run/redis_${port}.pid#日志文件名logfile "redis_${port}.log"#存放备份文件以及日志等文件的目录dir "/opt/redis/data"#rdb备份文件名dbfilename "dump_${port}.rdb"#开启集群功能cluster-enabled yes#集群配置文件,节点自动维护cluster-config-file nodes-${port}.conf#集群能够运行不需要集群中所有节点都是成功的cluster-require-full-coverage no
配置完成后,通过 redis-server redis.conf 命令启动这六个节点。
启动之后,进程后面会有 cluster 的字样:

③、建立各个节点通信
这里有 6 个节点,我们只需要拉通 1 个节点和另外 5 个节点之间通信,那么每两个节点就能够通信了。命令如下:
redis-cli -h -p ${port1} -a ${password} cluster meet ${ip2} ${port2}
这里的 -a 参数表示该Redis节点有密码,如果没有可以不用加此参数。
实例中的 6 个节点,分别进行如下命令:
redis-cli -p 6379 -a 123 cluster meet 192.168.14.101 6382redis-cli -p 6379 -a 123 cluster meet 192.168.14.102 6380redis-cli -p 6379 -a 123 cluster meet 192.168.14.102 6383redis-cli -p 6379 -a 123 cluster meet 192.168.14.103 6381redis-cli -p 6379 -a 123 cluster meet 192.168.14.103 6384
执行完毕后,可以查看节点通信信息:
redis-cli -p 6379 -a 123 cluster nodes
结果如下:

或者执行如下命令:
redis-cli -p 6379 -a 123 cluster info
结果如下:

④、分配槽位
由于我们是三主三从的架构,所以只需要对主服务器分配槽位即可。三个节点,分配序号为 0-16383 ,总共16384 个槽位。
Node1:0~5460Node2:5461~10922Node3:10923~16383
分配槽位的命令如下:
redis-cli -p ${port} -a ${password} cluster addslots {${startSlot}..${endSlot}}
比如,对于Node1主节点,我们执行命令如下:
redis-cli -p 6379 -a 123 cluster addslots {0..5462}
另外两个节点对于上面的命令更改一下槽位数,然后查看集群信息:

查看Node1节点信息:

⑤、主从配置
命令如下:
redis-cli -p ${port} -a {password} cluster replicate ${nodeId}
前面的${port} 表示从节点的端口,这里的nodeId表示主节点的nodeId,如下:

如果弄反了,会报如下错误:
(error) ERR To set a master the node must be empty and without assigned slots.
执行三条命令完毕后,查看节点信息:

这时候,集群状态是成功了。
⑥、测试
经过如上几步操作,集群搭建成功,我们通过如下命令进入客户端:
redis-cli -c -p ${port} -a {password}
注意:必须要加 -c 参数,否则进行键值对操作时会报如下错误:

正确进入后,可以正确存值和取值。

6、脚本搭建Redis Cluster
上面原生命令安装Redis Cluster 走下来其实挺费劲的,在实际生产环境中,如果集群数量比较大,操作还是容易出错的。
不过Redis官方提供了一个安装集群的脚本,在Redis安装目录的src目录下——redis-trib.rb,使用该脚本可以快速搭建Redis Cluster集群。
注意:redis版本在5之前的集群运行该脚本需要安装ruby环境,而redis5.0之后已经将redis-trib.rb 脚本的功能全部集成到redis-cli之中了,所以如果当前版本是Redis5,那么可以不用安装ruby环境。
下面我分别介绍这两种方法。
①、Redis5之前使用redis-trib.rb脚本搭建
redis-trib.rb脚本使用ruby语言编写,所以想要运行次脚本,我们必须安装Ruby环境。安装命令如下:
yum -y install centos-release-scl-rhyum -y install rh-ruby23scl enable rh-ruby23 bashgem install redis
安装完成后,我们可以使用 ruby -v 查看版本信息。

Ruby环境安装完成后。运行如下命令:
redis-trib.rb create --replicas 1 192.168.14.101:6379 192.168.14.102:6380 192.168.14.103:6381 192.168.14.101:6382 192.168.14.102:6383 192.168.14.103:6384
关于这个命令的解释下面会一起介绍。
②、Redis5版本集群搭建
前面我们就说过,redis5.0之后已经将redis-trib.rb 脚本的功能全部集成到redis-cli中了,所以我们直接使用如下命令即可:
redis-cli -a ${password} --cluster create 192.168.14.101:6379 192.168.14.102:6380 192.168.14.103:6381 192.168.14.101:6382 192.168.14.102:6383 192.168.14.103:6384 --cluster-replicas 1
①、${password} 表示连接Redis的密码,通常整个集群我们要么不设置密码,要么设置成一样的。
②、后面的六个ip:port,按照顺序,前面三个是主节点,后面三个是从节点,顺序不能错。
③、最后数字 1 表示一个主节点只有一个从节点。和前面的配置相对应。
7、集群扩容
这里新增两个端口分别是 6390、6391的节点。其中6391节点是6390节点的从节点。
①、配置新增节点文件
比如,我们将6379节点的配置文件redis.conf 拷贝两份,然后将里面的配置文件里面的字符串 6379 分别替换成 6390 和 6391。
:%s/6379/6390/g,:%s/6379/6391/g
替换完成之后,分别启动这两个节点。
这时候这两个节点都不在集群当中,是两个孤儿节点。
②、将新增主节点加入到集群中
命令如下:
redis-cli -p existing_port -a ${password} --cluster add-node new_host:new_port existing_host:existing_port
我这里是将新增的主节点 6390 添加到原来的集群中。
redis-cli -p 6379 -a 123 --cluster add-node 192.168.14.101:6390 192.168.14.101:6379
添加完毕后,这时候查看集群状态

6390节点已经存在集群中了,但是还没有分配槽位。
③、为新增主节点分配槽位
分配命令如下:
redis-cli -p existing_port -a ${password} --cluster reshard existing_host:existing_port
后面的existing_host:existing_port表示原来集群中的任意一个节点,这个命令表示将源节点的一部分槽位分配个新增的节点。
在分配过程中,会出现如下几个提示:
#后面的2000表示分配2000个槽位给新增节点How many slots do you want to move (from 1 to 16384)? 2000#表示接受节点的NodeId,填新增节点6390的What is the receiving node ID? 64a0779c7baef78c8fd0f2bb6e73f29375e00133d#这里填槽的来源,要么填all,表示所有master节点都拿出一部分槽位分配给新增节点;#要么填某个原有NodeId,表示这个节点拿出一部分槽位给新增节点Please enter all the source node IDs.Type 'all' to use all the nodes as source nodes for the hash slots.Type 'done' once you entered all the source nodes IDs.Source node #1: all
分配成功后,我们查看节点信息:

我们发现已经给该节点分配了槽位。
④、将新增的从节点添加到集群中
redis-cli -p 6379 -a 123 --cluster add-node 192.168.14.101:6391 192.168.14.101:6379
⑤、建立新增节点的主从关系
命令如下:
redis-cli -p ${port} -a {password} cluster replicate ${nodeId}
前面的${port} 表示从节点的端口,这里的nodeId表示主节点的nodeId。
⑥、测试
查看节点信息,发现4主4从。

在6379节点新增一个字符串 (k4,v4),然后到6390节点查看:

自此,大功告成。
8、集群收缩
这里我们将上一步添加的主从节点6390和6391从集群中移除。
①、迁移待移除节点的槽位
移除之前的节点信息:

redis-cli -p existing_port -a {Redis登录密码} --cluster reshard --cluster-from {待移除的NodeId} --cluster-to {接受移除节点的NodeId} --cluster-slots {移除的槽位个数} existing_host:existing_port
比如,我这里要移除主节点 6390 的所有槽位,给6379节点。
redis-cli -p 6379 -a 123 --cluster reshard --cluster-from 4a0779c7baef78c8fd0f2bb6e73f29375e00133d --cluster-to 001a22b1edae6ea1699b753d193871824723f375 --cluster-slots 2000 192.168.14.101:6379
移除完后,查看节点信息,发现6390已经没有槽位了。

②、移除待删除主从节点
注意:要首先移除从节点,然后再移除主节点,因为如果你先移除主节点,会触发集群的故障转移。
所以,我们应该先移除 6391 从节点,然后在移除 6390 主节点。移除命令如下:
redis-cli -p existing_port -a {Redis登录密码} --cluster del-node host:port {待删除的NodeId}
删除 6391 从节点:
redis-cli -p 6379 -a 123 --cluster del-node 192.168.14.101:6379 3622ec34956b624358722e6f4a2b762574d35bf0
删除 6390 主节点:
redis-cli -p 6379 -a 123 --cluster del-node 192.168.14.101:6379 4a0779c7baef78c8fd0f2bb6e73f29375e00133d

九、 缓存穿透、缓存击穿、缓存雪崩
1、缓存穿透
一、概念
缓存穿透:缓存和数据库中都没有的数据,可用户还是源源不断的发起请求,导致每次请求都会到数据库,从而压垮数据库。
如下图红色的流程:

比如客户查询一个根本不存在的东西,首先从Redis中查不到,然后会去数据库中查询,数据库中也查询不到,那么就不会将数据放入到缓存中,后面如果还有类似源源不断的请求,最后都会压到数据库来处理,从而给数据库造成巨大的压力。
二、解决办法
①、业务层校验
用户发过来的请求,根据请求参数进行校验,对于明显错误的参数,直接拦截返回。
比如,请求参数为主键自增id,那么对于请求小于0的id参数,明显不符合,可以直接返回错误请求。
②、不存在数据设置短过期时间
对于某个查询为空的数据,可以将这个空结果进行Redis缓存,但是设置很短的过期时间,比如30s,可以根据实际业务设定。注意一定不要影响正常业务。
③、布隆过滤器
关于布隆过滤器,后面会详细介绍。布隆过滤器是一种数据结构,利用极小的内存,可以判断大量的数据“一定不存在或者可能存在”。
对于缓存穿透,我们可以将查询的数据条件都哈希到一个足够大的布隆过滤器中,用户发送的请求会先被布隆过滤器拦截,一定不存在的数据就直接拦截返回了,从而避免下一步对数据库的压力。
2、缓存击穿
一、概念
缓存击穿:Redis中一个热点key在失效的同时,大量的请求过来,从而会全部到达数据库,压垮数据库。

这里要注意的是这是某一个热点key过期失效,和后面介绍缓存雪崩是有区别的。比如淘宝双十一,对于某个特价热门的商品信息,缓存在Redis中,刚好0点,这个商品信息在Redis中过期查不到了,这时候大量的用户又同时正好访问这个商品,就会造成大量的请求同时到达数据库。
二、解决办法
①、设置热点数据永不过期
对于某个需要频繁获取的信息,缓存在Redis中,并设置其永不过期。当然这种方式比较粗暴,对于某些业务场景是不适合的。
②、定时更新
比如这个热点数据的过期时间是1h,那么每到59minutes时,通过定时任务去更新这个热点key,并重新设置其过期时间。
③、互斥锁
这是解决缓存击穿比较常用的方法。
互斥锁简单来说就是在Redis中根据key获得的value值为空时,先锁上,然后从数据库加载,加载完毕,释放锁。若其他线程也在请求该key时,发现获取锁失败,则睡眠一段时间(比如100ms)后重试。
3、缓存雪崩
一、概念
缓存雪崩:Redis中缓存的数据大面积同时失效,或者Redis宕机,从而会导致大量请求直接到数据库,压垮数据库。

对于一个业务系统,如果Redis宕机或大面积的key同时过期,会导致大量请求同时打到数据库,这是灾难性的问题。
二、解决办法
①、设置有效期均匀分布
避免缓存设置相近的有效期,我们可以在设置有效期时增加随机值;
或者统一规划有效期,使得过期时间均匀分布。
②、数据预热
对于即将来临的大量请求,我们可以提前走一遍系统,将数据提前缓存在Redis中,并设置不同的过期时间。
③、保证Redis服务高可用
前面我们介绍过Redis的哨兵模式和集群模式,为防止Redis集群单节点故障,可以通过这两种模式实现高可用。
十、布隆过滤器
1、布隆过滤器使用场景
比如有如下几个需求:
①、原本有10亿个号码,现在又来了10万个号码,要快速准确判断这10万个号码是否在10亿个号码库中?
解决办法一:将10亿个号码存入数据库中,进行数据库查询,准确性有了,但是速度会比较慢。
解决办法二:将10亿号码放入内存中,比如Redis缓存中,这里我们算一下占用内存大小:10亿*8字节=8GB,通过内存查询,准确性和速度都有了,但是大约8gb的内存空间,挺浪费内存空间的。
②、接触过爬虫的,应该有这么一个需求,需要爬虫的网站千千万万,对于一个新的网站url,我们如何判断这个url我们是否已经爬过了?
解决办法还是上面的两种,很显然,都不太好。
③、同理还有垃圾邮箱的过滤。
那么对于类似这种,大数据量集合,如何准确快速的判断某个数据是否在大数据量集合中,并且不占用内存,布隆过滤器应运而生了。
2、布隆过滤器简介
带着上面的几个疑问,我们来看看到底什么是布隆过滤器。
布隆过滤器:一种数据结构,是由一串很长的二进制向量组成,可以将其看成一个二进制数组。既然是二进制,那么里面存放的不是0,就是1,但是初始默认值都是0。
如下所示:

①、添加数据
介绍概念的时候,我们说可以将布隆过滤器看成一个容器,那么如何向布隆过滤器中添加一个数据呢?
如下图所示:当要向布隆过滤器中添加一个元素key时,我们通过多个hash函数,算出一个值,然后将这个值所在的方格置为1。
比如,下图hash1(key)=1,那么在第2个格子将0变为1(数组是从0开始计数的),hash2(key)=7,那么将第8个格子置位1,依次类推。

②、判断数据是否存在?
知道了如何向布隆过滤器中添加一个数据,那么新来一个数据,我们如何判断其是否存在于这个布隆过滤器中呢?
很简单,我们只需要将这个新的数据通过上面自定义的几个哈希函数,分别算出各个值,然后看其对应的地方是否都是1,如果存在一个不是1的情况,那么我们可以说,该新数据一定不存在于这个布隆过滤器中。
反过来说,如果通过哈希函数算出来的值,对应的地方都是1,那么我们能够肯定的得出:这个数据一定存在于这个布隆过滤器中吗?
答案是否定的,因为多个不同的数据通过hash函数算出来的结果是会有重复的,所以会存在某个位置是别的数据通过hash函数置为的1。
我们可以得到一个结论:布隆过滤器可以判断某个数据一定不存在,但是无法判断一定存在。
③、布隆过滤器优缺点
优点:优点很明显,二进制组成的数组,占用内存极少,并且插入和查询速度都足够快。
缺点:随着数据的增加,误判率会增加;还有无法判断数据一定存在;另外还有一个重要缺点,无法删除数据。
3、Redis实现布隆过滤器
①、bitmaps
我们知道计算机是以二进制位作为底层存储的基础单位,一个字节等于8位。
比如“big”字符串是由三个字符组成的,这三个字符对应的ASCII码分为是98、105、103,对应的二进制存储如下:

在Redis中,Bitmaps 提供了一套命令用来操作类似上面字符串中的每一个位。
一、设置值
setbit key offset value

我们知道”b”的二进制表示为0110 0010,我们将第7位(从0开始)设置为1,那0110 0011 表示的就是字符“c”,所以最后的字符 “big”变成了“cig”。
二、获取值
gitbit key offset

三、获取位图指定范围值为1的个数
bitcount key [start end]
如果不指定,那就是获取全部值为1的个数。
注意:start和end指定的是字节的个数,而不是位数组下标。

②、Redisson
Redis 实现布隆过滤器的底层就是通过 bitmap 这种数据结构,至于如何实现,这里就不重复造轮子了,介绍业界比较好用的一个客户端工具——Redisson。
Redisson 是用于在 Java 程序中操作 Redis 的库,利用Redisson 我们可以在程序中轻松地使用 Redis。
下面我们就通过 Redisson 来构造布隆过滤器。
1 package com.ys.rediscluster.bloomfilter.redisson;23 import org.redisson.Redisson;4 import org.redisson.api.RBloomFilter;5 import org.redisson.api.RedissonClient;6 import org.redisson.config.Config;78 public class RedissonBloomFilter {910 public static void main(String[] args) {11 Config config = new Config();12 config.useSingleServer().setAddress("redis://192.168.14.104:6379");13 config.useSingleServer().setPassword("123");14 //构造Redisson15 RedissonClient redisson = Redisson.create(config);1617 RBloomFilter<String> bloomFilter = redisson.getBloomFilter("phoneList");18 //初始化布隆过滤器:预计元素为100000000L,误差率为3%19 bloomFilter.tryInit(100000000L,0.03);20 //将号码10086插入到布隆过滤器中21 bloomFilter.add("10086");2223 //判断下面号码是否在布隆过滤器中24 System.out.println(bloomFilter.contains("123456"));//false25 System.out.println(bloomFilter.contains("10086"));//true26 }27 }
这是单节点的Redis实现方式,如果数据量比较大,期望的误差率又很低,那单节点所提供的内存是无法满足的,这时候可以使用分布式布隆过滤器,同样也可以用 Redisson 来实现,这里我就不做代码演示了,大家有兴趣可以试试。
4、guava 工具
最后提一下不用Redis如何来实现布隆过滤器。
guava 工具包相信大家都用过,这是谷歌公司提供的,里面也提供了布隆过滤器的实现。
1 package com.ys.rediscluster.bloomfilter;23 import com.google.common.base.Charsets;4 import com.google.common.hash.BloomFilter;5 import com.google.common.hash.Funnel;6 import com.google.common.hash.Funnels;78 public class GuavaBloomFilter {9 public static void main(String[] args) {10 BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8),100000,0.01);1112 bloomFilter.put("10086");1314 System.out.println(bloomFilter.mightContain("123456"));15 System.out.println(bloomFilter.mightContain("10086"));16 }17 }

