大数据时代NoSQL开始大行其道,其中常用于缓存的Redis可谓风头正盛,是大小公司技术架构中必不可少的一种中间件,也是职场技术同仁们必知必会的一种技术。本场Chat将从各个方面对Redis进行全面的讲解并分析常见问题。本场Chat将涉及如下内容

  1. Redis的基本概念及背景知识
  2. 五种常用数据对象及应用场景
  3. 数据对象的底层实现方式
  4. Jedis的使用
  5. Redis持久化策略
  6. Redis的事务机制
  7. 分布式锁的实现及改进策略
  8. Redis的删除策略
  9. Cluster集群模式
  10. 缓存穿透、缓存雪崩、缓存击穿

适合人群:不了解Redis的新手,对Redis的实现机制感兴趣的技术人员

本文的全部内容来自我个人在Redis学习过程中整理的博客,是该博客专栏的精华部分。在书写过程中过滤了流程性的上下文,例如部署环境、配置文件等,而致力于像读者讲述其中的核心部分,如果读者有意对过程性内容深入探究,可以移步MaoLinTian的Blog,在这篇索引目录里找到答案分布式技术相关专栏索引,需要注意的是,本文的内容学习来源来自于书籍《Redis的设计与实现》及《黑马程序员-Redis视频教程》,特作相关说明。

Redis的基本概念及背景

首先我们要知道什么是NoSQL,什么又是Redis,为什么需要用Redis,Redis有哪些使用场景?

什么是非关系型数据库

NoSQL = Not Only SQL,也就是非关系型数据库,既然有了SQL,为什么还需要NoSQL?这和时代背景有很大的关系,我们所处的时代可以划分为Web1.0和Web2.0时代:

  • Web1.0,是基于浏览器,用户通过浏览器获取内容信息。
  • Web2.0,是基于1.0,增加了用户与系统的交互,使用者既是网络内容的获取者,也是网络数据的制造者,例如:论坛、博客、微博等相关社交类型的平台。

我们当前身处Web2.0时代,面对很多问题:

  • High performance - 高并发读写,在Web2.0时代,需要依据用户个性化需要高并发读写,关系型数据库读还可以,写就很难做到了。例如论坛这样的站点, 网站的用户并发性非常高,往往达到每秒上万次读写请求,对于传统关系型数据库来说,硬盘I/O是一个很大的瓶颈
  • Huge Storage - 海量数据的高效率存储和访问,海量数据高效率存储和访问, 网站每天产生的数据量是巨大的,对于关系型数据库来说,在一张包含海量数据的表中查询,效率是非常低的,类似FaceBook这样的社交网站、社区。
  • High Scalability &High Availability - 高可拓展性和高可用性, 在基于Web的结构当中,数据库是最难进行横向扩展的,当一个应用系统的用户量和访问量与日俱增的时候,数据库却没有办法像Web server和App Server那样简单的通过添加更多的硬件和服务节点来扩展性能和负载能力。对于很多需要提供24小时不间断服务的网站来说,对数据库系统进行升级和扩展是非常痛苦的事情,往往需要停机维护和数据迁移。

这些问题主要是由于关系型数据库要求的:事务一致性、读写实时性、和复杂SQL的查询,这些都是导致关系型数据库性能差的原因,而这些场景和严格的要求在很多场景下不必要了,例如社交网络。NoSQL因为它的易扩展、大数据量高性能、灵活的数据模型和高可用在社区时代可以发挥很大的作用。

NoSQL的分类

NoSQL 依据存储的内容也分很多种,让我们从这些类别里来定位Redis吧!依据使用场景来划分类别,按照上面我们提到的三种优点,看看哪种能发挥极致:

  • 面向高性能并发读写的key-value数据库:key-value数据库的主要特点是具有极高的并发读写性能,Redis,Tokyo Cabinet,Flare就是这类的代表
  • 面向海量数据访问的面向文档数据库:这类数据库的特点是,可以在海量的数据中快速的查询数据,典型代表为MongoDB以及CouchDB
  • 面向可扩展性的分布式数据库:这类数据库想解决的问题就是传统数据库存在可扩展性上的缺陷,这类数据库可以适应数据量的增加以及数据结构的变化

也正是因为Redis极高的并发读写能力,所以被常用作缓存。

Redis的应用场景

Redis有着广泛的应用场景。典型应用是:内容缓存,主要用于处理大量数据的高访问负载,优点就是快速查询

  • 缓存,缓存现在几乎是所有中大型网站都在用的必杀技,合理的利用缓存不仅能够提升网站访问速度,还能大大降低数据库的压力。Redis提供了键过期功能,也提供了灵活的键淘汰策略,所以,现在Redis用在缓存的场合非常多。
  • 排行榜,很多网站都有排行榜应用的,如京东的月度销量榜单、商品按时间的上新排行榜等。Redis提供的有序集合数据类构能实现各种复杂的排行榜应用。
  • 计数器,什么是计数器,如电商网站商品的浏览量、视频网站视频的播放数等。为了保证数据实时效,每次浏览都需+1,并发量高时如果每次都请求数据库操作无疑是种挑战和压力。Redis提供的incr命令来实现计数器功能,内存操作,性能非常好,非常适用于这些计数场景。
  • 分布式会话,集群模式下,在应用不多的情况下一般使用容器自带的session复制功能就能满足,当应用增多相对复杂的系统中,一般都会搭建以Redis等内存数据库为中心的session服务,session不再由容器管理,而是由session服务及内存数据库管理。
  • 分布式锁,很多互联网公司中都使用了分布式技术,分布式技术带来的技术挑战是对同一个资源的并发访问,如全局ID、减库存、秒杀等场景,并发量不大的场景可以使用数据库的悲观锁、乐观锁来实现,但在并发量高的场合中,利用数据库锁来控制资源的并发访问是不太理想的,大大影响了数据库的性能。
  • 社交网络,点赞、踩、关注/被关注、共同好友等是社交网站的基本功能,社交网站的访问量通常来说比较大,而且传统的关系数据库类型不适合存储这种类型的数据,Redis提供的哈希、集合等数据结构能很方便的的实现这些功能。
  • 最新列表,Redis列表结构,LPUSH可以在列表头部插入一个内容ID作为关键字,LTRIM可用来限制列表的数量,这样列表永远为N个ID,无需查询最新的列表,直接根据ID去到对应的内容页即可。
  • 消息系统,消息队列是大型网站必用中间件,如ActiveMQ、RabbitMQ、Kafka等流行的消息队列中间件,主要用于业务解耦、流量削峰及异步处理实时性低的业务。Redis提供了发布/订阅及阻塞队列功能,能实现一个简单的消息队列系统。

这些场景都是当下大型电商网站和社交网站需要的。所以Reids火也不奇怪,其它的NoSQL数据库的应用场景都比较窄,类似文档存储和图片存储等,都在特定应用场景下使用

五种常用数据对象及应用场景

Redis是高性能键值对数据库,支持的键值数据类型:字符串类型 、哈希类型、列表类型 、集合类型、有序集合类型 , 这些类型的操作方式和结构需要详细了解。

字符串类型

字符串的操作命令有很多,常用的操作命令有如下几种,涉及到:设置及获取值,获取并修改值,自增值,自减值,追加字符串等操作:

  • set key value:设置指定 key 的值。
  • get key:获取指定 key 的值。
  • getset key value:将给定 key 的值设为 value ,并返回 key 的旧值(old value)。
  • incr key:将 key 中储存的数字值增一。
  • incr key increment:将 key 所储存的值加上给定的增量值(increment)
  • decr key :将 key 中储存的数字值减一。
  • decr key decrement:key 所储存的值减去给定的减量值(decrement) 。
  • append key value:如果 key 已经存在并且是一个字符串, APPEND 命令将指定的 value 追加到该 key 原来值(value)的末尾。
  • del key:删除该key。

需要注意的是,操作状态返回的0和1要和返回结果做区分,并且set操作其实是有则更新,无则新增。常用场景如下:

  • 场景一:利用数值操作特性Incr指令为分布式数据库主键自增,例如数据库做分库分表后仍然希望所有数据能保持主键单调递增。
  • 场景二:利用key的生命周期做投票系统,在投票的场景中,我们经常会有一天可投几次票这样的限制,那么这个就需要一个过期时间,例如每天最多可以投5张票,可以把用户id作为key,当value大于5时不允许继续投,24小时后key销毁,重新设置。
  • 场景三:利用数值操作特性Incr指令刷新热点数据,例如分别设置微博大V的粉丝数、点赞数为key,然后刷新数量。

以上就是字符串类型的介绍。String的场景利用了String的Incr指令、过期Key的特性

哈希类型

Hash是一个String类型的Field和Value的映射表,Hash特别适合用于存储对象。Redis 中每个 hash 可以存储 2^32 - 1 个键值对(40多亿),常用操作如下

  • hset key field value:将哈希表 key 中的字段 field 的值设为 value 。
  • hget key field:获取存储在哈希表中指定字段的值。
  • hmset key field1 value1 field2 value2 ...:同时将多个 field-value (域-值)对设置到哈希表 key 中。时间复杂度为O(n)
  • hmget key field1 field2...:获取所有给定字段的值。时间复杂度为O(n)
  • hgetall key:获取在哈希表中指定 key 的所有字段和值 。时间复杂度为O(n)
  • hdel key field1 field2... :删除一个或多个哈希表字段。返回值为0则表示删除的属性不存在
  • del key:删除该key,也就是删除该哈希。

还有一些自增及判断的命令:

  • hincrby key field increment:为哈希表 key 中的指定字段的整数值加上增量 increment 。
  • hlen key:获取哈希表中字段的数量
  • hvals key:同时将多个 field-value (域-值)对设置到哈希表 key 中。时间复杂度为O(n)
  • hexists key field:查看哈希表 key 中,指定的字段是否存在。时间复杂度为O(n)

由于Hash的这些特性,常有如下的应用场景:

  • 场景一:利用hash的对象存储特性设置用户的购物车,一个人的购物车可以看做一个对象,而商品可以当做field,数量可以当做value,然后对购物车进行各种操作。
  • 场景二 :利用hash作为商品秒杀计数对象完成商品秒杀系统,一个商品秒杀系统可以看作一个对象,而秒杀的商品可以当作field,数量可以当作value,设置value为该商品的余量,然后使用hincrby来进行秒杀业务,降低数量。

和String类型相比,Hash更适合数据的呈现,而不适合数据的更新,具体为什么,可以在下一小节其底层结构上一窥究竟。Hash的场景利用了Hash的结构特性存储对象信息

列表类型

List的核心特点是顺序性,其底层主要实现为一个双向链表,为什么说主要,因为本节提到的各种数据对象底层都有不止一种实现方式。简单常用的一些相关相关命令:

  • lpush key value1 [value2]:将一个或多个值插入到列表头部,从左侧添加,最后添加的在最左边
  • lrange key start stop:获取列表指定范围内的元素, 假如共有6个元素,[0,-2]表示从列表头到倒数第二个,[0,-1]和[0,5]效果一样。
  • rpush key value1 [value2]:将一个或多个值插入到列表尾部,从右侧添加,最后添加的在最右边
  • lpop key:移出并获取列表的第一个元素,弹出列表头
  • rpop key:移除列表的最后一个元素,返回值为移除的元素。
  • llen key:获取列表长度。
  • lpushx key value:将一个值插入到已存在的列表头部,仅队列存在时有效。当队列不存在时,不进行任何操作。
  • rpushx key value:将一个值插入到已存在的列表尾部,仅队列存在时有效。当队列不存在时,不进行任何操作。

当然还有些相对复杂的命令:

  • lrem key count value:移除count个为value的元素,如果count大于0,则从左向右数,如果count小于0,从右向左数,如果count等于0,删除全部为value的元素。
  • lset key index value:通过索引设置列表元素的值,列表头的索引是0.
  • linsert key before|after pivot value:在列表的元素前或者后插入元素

列表有如下的一些应用场景:

  • 场景一:利用blpop特性实现任务队列,轮询从任务队列里取数据【可以同时从多个队列获取】,如果取到数据就返回,如果没有数据就等待设置时间持续获取,直到数据过期
  • 场景二:利用list顺序特性实现朋友圈点赞,因为点赞等信息都是有顺序性的,而且修改的效率高,适合使用list来操作,点赞用rpush,取消点赞用lrem
  • 场景三:利用list顺序特性进行分布式日志顺序性展示,使用list顺序性实现多路数据汇总展示,利用其栈的特性实现最新的消息最先展示,即组合使用rpush和rpop

在List的场景下,主要利用了List的顺序性、队列和栈的双重特性

集合类型

Set 是String 类型的无序集合集合成员是唯一的,这就意味着集合中不能出现重复的数据。集合中最大的成员数为 2^32 - 1 (40多亿个成员),常用操作如下:

  • sadd key member1 [member2]:向集合添加一个或多个成员
  • srem key member1 [member2]:移除集合中一个或多个成员
  • smembers key:返回集合中的所有成员
  • sismember key member:判断 member 元素是否是集合 key 的成员
  • scard key:获取集合的成员数
  • srandmember key [count]:返回集合中一个或多个随机数
  • spop key:移除并返回集合中的一个随机元素

set集合之间的操作通过如下命令实现:

  • sdiff key1 [key2]:返回给定所有集合的差集,两个集合的第一个不同数字。
  • sdiffstore destination key1 [key2]:返回给定所有集合的差集并存储在 destination 中
  • sinter key1 [key2]:返回给定所有集合的交集
  • sinterstore destination key1 [key2]:返回给定所有集合的交集并存储在 destination 中
  • sunion key1 [key2]:返回所有给定集合的并集
  • sunionstore destination key1 [key2]:返回所有给定集合的并集存储在 destination 集合中
  • smove source destination member:将 member 元素从 source 集合移动到 destination 集合

set的主要特性是不重复性,我们看基于这样的特性,常用的有哪些场景呢?

  • 场景一:利用set特性随机获取不重复数据实现简单推荐系统,系统汇集好读者的所有爱好标签后,使用srandmember 指令实现随机推送三个用户喜欢的标签数据
  • 场景二:利用set交并差实现推荐系统池,例如获取中老年群体用户中共同的爱好,可以用sinter来实现,获取我在中国地质大学中的一度人脉、二度人脉等可以使用sunion来实现,获取我喜欢但是我妈不喜欢的电视节目,可以用sdiff来实现。
  • 场景三:利用set不重复特征获取所有业务系统权限,我们想要获取所有用户的所有权限,取出其中补充五的业务系统权限该怎么做?我们可以设置用户为一个set集合,他的权限为value,把所有权限放到set
  • 场景四:利用set不重复特征获取UV和IP数据,UV即网站被不同用户访问的次数,相同用户切换IP地址,UV不变,使用set存储用户cookie信息,统计UV量。IP即网站被不同IP的访问次数,相同IP访问,不同用户访问,IP不变。使用set存储IP信息,统计IP量。
  • 场景五:利用set不重复特征实现黑白名单,可以在黑名单中添加IP或设备或用户,通过不重复特性,设置唯一的黑名单

Set的场景主要利用了set的不重复特性、随机取值特性和并交差集的特性

有序集合类型

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

  • zadd key score1 member1 [score2 member2]:向有序集合添加一个或多个成员,或者更新已存在成员的分数
  • zscore key member:返回有序集中,成员的分数值
  • zcard key:获取有序集合的成员数
  • zrem key member [member ...]:移除有序集合中的一个或多个成员
  • zrange key start stop [withscores]:通过索引区间返回有序集合成指定区间内的成员,如果需要同时返回scores ,带上后边那段,默认分数从低到高
  • zrevrange key start stop [withscores]:返回索引区间集中指定区间内的成员,分数从高到底
  • zrangebyscore key min max [withscores] [limit]:通过分数区间返回有序集合指定区间内的成员,默认分数从低到高
  • zremrangebyrank key start stop:移除有序集合中给定的排名区间的所有成员
  • zremrangebyscore key min max:移除有序集合中给定的分数区间的所有成员
  • zincrby key increment member:有序集合中对指定成员的分数加上增量 increment
  • zcount key min max:计算在有序集合中指定区间分数的成员数

Zset最经典的应用场景就是进行排行榜设置了。当然除此之外还有些带权重的操作都类似:

  • 场景一:利用set不重复排序特征实现计数器组合排序排行榜功能,为所有参与排名的资源进行排序
  • 场景二:利用set不重复排序特征实现基于时效性任务提醒,队列中全部为vip,按照会员时间长短排序,短时间到期后提醒下一个快到期的任务。
  • 场景三:利用set不重复排序特征实现带权重任务队列,仅是任务队列可以通过队列,但是如果队列中的任务有优先级,则需要使用带权重的

ZSet的场景主要利用了set的不重复特性和分数排序特性

数据对象的底层实现方式

上一小节我们提到的五种数据类型其实就是Redis的数据对象,我们先来看看数据对象的类型:Redis的key都是string类型的,以上各类型说的其实都是value的类型,以下是对象的几个优点:

  • 通过这五种不同类型的对象,Redis可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令适配场景
  • 使用对象的另一个好处是,我们可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率,提升效率
  • Redis的对象系统还实现了基于引用计数技术的内存回收机制,当程序不再使用某个对象的时候,这个对象所占用的内存就会被自动释放,内存回收
  • Redis还通过引用计数技术实现了对象共享机制,这一机制可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存,节约内存
  • Redis的对象带有访问时间记录信息,该信息可以用于计算数据库键的空转时长,在服务器启用了maxmemory功能的情况下,空转时长较大的那些键可能会优先被服务器删除内存回收

每次当我们在Redis的数据库中新创建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)。Redis中的每个对象都由一个redisObject结构表示,该结构中和保存数据有关的三个属性分别是type属性、encoding属性和ptr属性

redisObject结构:typedef struct redisObject{//类型unsigned type:4;//编码unsigned encoding:4;//指向底层实现数据结构的指针void *ptr;…..
}
  • 对象的type属性记录了对象的类型,REDIS_STRING、REDIS_HASH、REDIS_LIST、REDIS_SET、REDIS_ZSET,对于Redis数据库保存的键值对来说,键总是一个字符串对象,而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种
  • 对象的encoding属性记录了对象所使用的编码,也即是说这个对象使用了什么数据结构作为对象的底层实现,接下来会详细介绍下使用的数据编码
  • 对象的ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定

也就是一个对象包含自身的数据结构属性,实际使用的编码类型以及数据对象对实际数据编码的指针

数据对象和数据结构

在Redis中会涉及很多数据结构,比如SDS,双向链表、字典、压缩列表、整数集合、跳跃表等。数据结构有如下几种:

结构常量 结构对应的底层数据结构
REDIS_ENCODING_INT long类型的整数
REDIS_ENCODING_EMBSTR embstr编码的简单动态字符串SDS
REDIS_ENCODING_RAW 简单动态字符串SDS
REDIS_ENCODING_HT 字典
REDIS_ENCODING_LINKEDLIST 双向链表
REDIS_ENCODING_ZIPLIST 压缩列表
REDIS_ENCODING_INTSET 整数集合
REDIS_ENCODING_SKIPLIST 跳跃表

每种类型的对象都至少使用了两种不同的编码,在内容长短发生变化的时候数据对象会自动切换适合的数据编码,且切换后不可逆

数据对象 数据编码 备注
String int long类型的整数
embstr sds实现 <=32 字节
raw sds实现 > 32字节
List ziplist 压缩列表实现
linkedlist 双端链表实现
Set intset 整数集合实现
hashtable 字典实现
Hash ziplist 压缩列表实现
hashtable 字典实现
Zset ziplist 压缩列表实现
skiplist 跳跃表+字典实现

Redis数据结构

在介绍数据对象与数据结构的转换关系之前,先来了解下Redis的几种数据结构:动态字符串、字典、链表、整数集合、压缩列表、跳跃表+字典

动态字符串SDS

SDS的数据结构如下,包含三部分属性,len、free以及buf数组,用来描述一个SDS的结构体:

struct sdshdr {unsigned int len;  //记录buf数组中已使用字节数量,也即SDS所保存字符串长度unsigned int free;  //记录buf数组中未使用字节数量char buf[];    //字节数组,用于保存字符串
};

动态字符串结构

示例如下图所示:

  • free属性的值为5,表示这个SDS分配了5字节的未使用空间
  • len属性的值为5,表示这个SDS保存了一个五字节长的字符串,需要注意的是长度不包含末尾的补0
  • buf属性是一个char类型的数组,数组的前五个字节分别保存了’R’、‘e’、‘d’、‘i’、‘s’五个字符,而最后一个字节则保存了空字符’\0’

保留了C语言补0的习惯是为了方便复用C语言的一些函数。

动态字符串优点

SDS的优势如下:

  • 常数复杂度获取字符串长度,因为SDS在len属性中记录了SDS本身的长度,所以获取一个SDS长度的复杂度仅为O(1)
  • 杜绝缓冲区溢出,SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小
  • 减少修改字符串时带来的内存重分配次数SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联 :在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由SDS的free属性记录
  • 空间预分配策略,如果对SDS进行修改之后,SDS的长度(也即是len属性的值)将小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS len属性的值将和free属性的值相同,如果对SDS进行修改之后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间。也就是说1M以下倍增,1M以上只增加1M,防止存储过大对象,通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数
  • 惰性空间释放策略,惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。SDS也提供了相应的API,在有需要时,我们可以真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费通过惰性空间释放策略,Redis可以减少执行字符串缩短操作所需的内存重分配次数
  • 二进制安全,SDS的API都是二进制安全的(binary-safe),所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样,例如,使用SDS来保存包含空字符格式的数据格式就没有任何问题,因为SDS使用len属性的值而不是空字符来判断字符串是否结束Redis不仅可以保存文本数据,还可以保存任意格式的二进制数据

总而言之,动态字符串用额外的free和len空间来弥补了很多C语言性能上的问题。

链表

链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度,但是由于C语言没有链表的数据结构,所以Redis的链表是自己定义的结构。

链表结构

链表单节点的数据结构如下,包含三部分属性prev、next以及value,用来描述一个链表单节点的结构体:

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

示例如下图所示:

同时Redis为了方便的操作链表,提供了一个list结构来持有链表,也就是我们的链表结构,如下所示

list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len,而dup、free和match成员则是用于实现多态链表所需的类型特定函数:

  • dup函数用于复制链表节点所保存的值
  • free函数用于释放链表节点所保存的值
  • match函数则用于对比链表节点所保存的值和另一个输入值是否相等

用代码来描述如下所示:

typedef struct list{//表头节点listNode *head;//表尾节点listNode *tail;//链表所包含的节点数量unsigned long len;//节点值复制函数void *(*dup)(void *ptr);//节点值释放函数void *(*free)(void *ptr);//节点值对比函数int (*match)(void *ptr,void *key);
}list;

链表结构优势

链表结构的优势其实在很多语言中都有体现,在Redis这里由于特殊的设计结构,又有些不一样的地方,总而言之如下:

  • 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)
  • 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点
  • 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)
  • 带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)
  • 多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值

正是由于这些优势,链表编码有广泛的用途:比如Redis列表数据对象、发布与订阅、慢查询、监视器等

字典

字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构,字典中的每个键都是独一无二的,程序可以在字典中根据键查找与之关联的值,或者通过键来更新值,又或者根据键来删除整个键值对

  • Redis数据库就是一个字典模型,key和value组成
  • 同时hash对象的底层实现之一也包括字典

总而言之,字典有较为广泛的用途,但是同链表一样,C语言没有字典这种数据结构,所以Redis自己实现了这种结构。

字典结构

字典是由哈希表加上一系列属性方法组成,而哈希表又是由哈希表节点加上一系列哈希表结构组成的:

哈希表节点

哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对,但是有链式结构:

typedef struct dictEntry {// 键void *key;// 值union {void *val;uint64_t u64;int64_t s64;} v;// 指向下个哈希表节点,形成链表struct dictEntry *next;} dictEntry;

各个属性部分如下:

  • key属性保存着键值对中的
  • v属性则保存着键值对中的,其中键值对的值可以是一个指针,或者是一个uint64_t整数,又或者是一个int64_t整数
  • next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一次,以此来解决键冲突(collision)的问题

也就是说在相同键上的多个哈希表节点存在链式关系,有链表实现。

哈希表

哈希表结构定义如下,包括哈希表数组,哈希表大小【已用+未用】的变量,哈希表大小的掩码值,哈希表已有节点数量

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

各个属性部分如下:

  • table属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对
  • size属性记录了哈希表的大小,也即是table数组的大小
  • used属性则记录了哈希表目前已有节点(键值对)的数量
  • sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面

其中table是我们这个结构的核心。

字典

字典又是由哈希表和一系列属性和函数组成的,为了满足Redis快而增加的一些空间占用属性:

typedef struct dict {// 类型特定函数dictType *type;// 私有数据void *privdata;// 哈希表dictht ht[2];// rehash 索引,当 rehash 不再进行时,值为 -1int rehashidx; /* rehashing not in progress if rehashidx == -1 */} dict;

各个属性含义如下:

  • type属性和privdata属性是针对不同类型的键值对,而创建多态字典而设置的:type属性是一个指向dictType结构的指针,每个dictType结构保存了一组用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同类型的特定函数。而privadata属性则保存了需要传给那些类型特定函数的可选参数
  • ht属性是一个包含了两个项的数组,数组中每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,而ht[1]哈希表只对ht[0]哈希表进行rehash时使用。
  • rehashidx属性,与rehash相关,它积累了rehash目前的进度,如果没有进行rehash,则它的值为-1

关于rehash算法在接下来的内容重点看,其中ht属性是较为核心的属性。

哈希算法

当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面

  • 使用字典设置的哈希函数,计算键 key 的哈希值hash = dict->type->hashFunction(key);
  • 使用哈希表的 sizemask 属性和哈希值,计算出索引值,index = hash & dict->ht[x].sizemask,根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1],

举个例子,假设hash计算结果为8,且掩码为3,则相与的结果为0,所以被放到ht[0]哈希表的字典索引0的位置

解决键冲突

当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突(collision)。Redis的哈希表使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题:

另外因为dictEntry节点组成的链表没有指向链表表尾的指针,为了考虑速度,程序总是将新节点添加到链表的表头位置(这样添加节点的时间复杂度为O(1))

rehash

随着操作的不断进行,哈希表保存的键值对会逐渐增多或减少,为了让**哈希表负载因子【哈希表已保存节点数量/哈希表的size,可以理解为used/size】**维持在一个合理范围之内,当哈希表保存的键值对太多或太少时,程序要对哈希表的大小进行相应的扩展或收缩。

Redis对字典的哈希表执行rehash的步骤如下:

  1. 为字典的ht[1]哈希表分配空间,这个空间大小取决于要执行的操作

    • 如果执行扩展操作,则ht[1]的大小为第一个大于等于等于ht[0].used*2的2^n
    • 如果执行收缩操作,则ht[1]的大小为第一个大于等于ht[0].used的2^n;当哈希表负载因子小于0.1时,程序自动开始对哈希表执行收缩操作
  2. 将保存在ht[0]中的所有键值对rehash到ht[1]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]的指定位置上。

  3. 当ht[0]包含的所有键值对都迁移到ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备

以上就是rehash的全流程。当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作

  • 1)服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1
  • 2)服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。

BGSAVE或BGREWRITEAOF操作是Redis的持久化操作,在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)【只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程】技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存

渐进式rehash

Redis中的rehash动作并不是一次性、集中式完成的,而是分多次、渐进式的完成的。这样做的目的是,如果服务器中包含很多键值对,要一次性的将这些键值对全部rehash到ht[1]的话,庞大的计算量可能导致服务器在一段时间内停止服务。渐进式Rehash操作

  1. 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。在字典中维持一个索引计数器变量rehashidx,并将它置为0,表示rehash工作开始。

  2. 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1]中,当rehash工作完成之后,程序将rehashidx属性的值+1

  3. 随着字典操作的不断进行,最终在某个时间点上,ht[0]的所有键值对都被rehash到ht[1]上,这时将rehashidx属性设为-1,表示rehash完成

    渐进式rehash的好处在于其采取分而治之的方式,将rehash键值对所需要的计算工作均摊到字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量:

  • 字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行。例如,要在字典里面查找一个键的话,程序会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找,诸如此类。
  • 在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表

这样又是一个以空间换取时间的案例。参考《Redis的设计与实现》

整数集合

整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现

整数集合结构

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

//每个intset结构表示一个整数集合
typedef struct intset{//编码方式uint32_t encoding;//集合中包含的元素数量uint32_t length;//保存元素的数组int8_t contents[];
} intset;

各个属性的含义如下:

  • contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项
  • length属性记录了整数集合包含的元素数量,也即是contents数组的长度
  • encoding属性记录了contents数组中元素的真实类型,虽然intset结构将contents属性声明为int8_t类型的数组,但实际上contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值,类型对应关系
    • 如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int16_t类型的数组,数组里的每个项都是一个int16_t类型的整数值(最小值为-32768,最大值为32767)
    • 如果encoding属性的值为INTSET_ENC_INT32,那么contents就是一个int32_t类型的数组,数组里的每个项都是一个int32_t类型的整数值(最小值为-2147483648,最大值为2147483647)
    • 如果encoding属性的值为INTSET_ENC_INT64,那么contents就是一个int64_t类型的数组,数组里的每个项都是一个int64_t类型的整数值(最小值为-9223372036854775808,最大值为9223372036854775807)

例如如下结构就描述了一个整数数组:

因为每个集合元素都是int64_t类型的整数值,所以contents数组的大小等于sizeof(int64_t)x 4=64x4=256位,根据整数集合的升级规则,当向一个底层为int16_t数组的整数集合添加一个int64_t类型的整数值时,整数集合已有的所有元素都会被转换成int64_t类型,所以contents数组保存的四个整数值都是int64_t类型的

整数集合升级

每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级,然后才能将新元素添加到整数集合里面。升级整数集合并添加新元素主要分三步来进行:

  1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间
  2. 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变
  3. 将新元素添加到底层数组里面

图解如下,图片来源

因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大,所以这个新元素的值要么就大于所有现有元素,要么就小于所有现有元素【正整数和负整数】

  • 在新元素小于所有现有元素的情况下,新元素会被放置在底层数组的最开头(索引0)
  • 在新元素大于所有现有元素的情况下,新元素会被放置在底层数组的最末尾(索引length-1)

这样升级就完成了,整数集合的升级策略有两个好处,一个是提升整数集合的灵活性,另一个是尽可能地节约内存

  • 提升灵活性:因为C语言是静态类型语言,为了避免类型错误,我们通常不会将两种不同类型的值放在同一个数据结构里面,因为整数集合可以通过自动升级底层数组来适应新元素,所以我们可以随意地将int16_t、int32_t或者int64_t类型的整数添加到集合中,而不必担心出现类型错误
  • 要让一个数组可以同时保存int16_t、int32_t、int64_t三种类型的值,最简单的做法就是直接使用int64_t类型的数组作为整数集合的底层实现。不过这样一来,即使添加到整数集合里面的都是int16_t类型或者int32_t类型的值,数组都需要使用int64_t类型的空间去保存它们,从而出现浪费内存的情况。而整数集合现在的做法既可以让集合能同时保存三种不同类型的值,又可以确保升级操作只会在有需要的时候进行,这可以尽量节省内存,也就是动态变化

有了这两个特性可以安全灵活又不担心内存大量使用的去玩儿了,整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态,即使我们把65535干掉了,其它元素都小。数组类型还是int32。

跳跃表

跳跃表是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。Redis使用跳跃表作为ZSET的底层实现之一。如果一个ZSET包含的元素数量比较多,又或者ZSET中元素的成员是比较长的字符串时, Redis就会使用跳跃表来作为有序集合健的底层实现。

  • 跳跃表在链表的基础上增加了多级索引以提升查找的效率,是一个空间换时间的方案,必然会带来一个问题——索引是占内存的。原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值值和几个指针,并不需要存储对象,因此当节点本身比较大或者元素数量比较多的时候,其优势必然会被放大,而缺点则可以忽略

跳跃表支持**平均O(logN)、最坏O(N)**复杂度的节点查找,还可以通过顺序性操作来批量处理节点,Redis的跳跃表由zskiplistNode和skiplist两个结构定义,其中 zskiplistNode结构用于表示跳跃表节点,而 zskiplist结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等

跳跃表结构

跳跃表由跳跃表节点加一些附加属性组合而成。

跳跃表节点

跳跃表节点的数据结构定义如下

/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {robj *obj;  /*成员对象*/double score;   /*分值*/struct zskiplistNode *backward; /*后退指针*/struct zskiplistLevel { /*层*/struct zskiplistNode *forward;  /*前进指针*/unsigned int span;  /*跨度*/} level[];
} zskiplistNode;

各个属性的含义如下:

  • 层(level):节点中用L1、L2、L3等字样标记节点的各个层,L1代表第一层,L2代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。一般来说,层的数量越多,访问其他节点的速度就越快,每次创建一个新跳跃表节点的时候,程序都根据**幂次定律(power law,越大的数出现的概率越小)**随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”

  • 后退(backward)指针:节点中用BW字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用因为每个节点只有一个后退指针,所以每次只能后退至前一个节点

  • 分值(score):各个节点中的1.0、2.0和3.0是节点所保存的分值。分值是一个double类型的浮点数,在跳跃表中,节点按各自所保存的分值从小到大排列有序的。节,跳跃表中的所有节点都按分值从小到大来排序

  • 成员对象(obj):各个节点中的o1、o2和o3是节点所保存的成员对象,节点的成员对象(obj属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值

那么跳跃表是如何迭代寻找分值对象呢?使用前进指针就能实现

  • 1)迭代程序首先访问跳跃表的第一个节点(表头),然后从第四层的前进指针移动到表中的第二个节点
  • 2)在第二个节点时,程序沿着第二层的前进指针移动到表中的第三个节点。
  • 3)在第三个节点时,程序同样沿着第二层的前进指针移动到表中的第四个节点。
  • 4)当程序再次沿着第四个节点的前进指针移动时,它碰到一个NULL,程序知道这时已经到达了跳跃表的表尾,于是结束这次遍历

那么如何计算目标节点在跳跃表中的排位呢?在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是,实际上就是节点的顺序值。

需要注意:在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)

跳跃表

跳跃表结构的构建代码如下:

typedef struct zskiplist {struct zskiplistNode *header, *tail;    //header指向跳跃表的表头节点,tail指向跳跃表的表尾节点unsigned long length;   //记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)int level;  //记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)
} zskiplist;

skiplist结构包含以下属性:

  • header,指向跳跃表的表头节点,通过这个指针程序定位表头节点的时间复杂度就为O(1)
  • tail,指向跳跃表的表尾节点,通过这个指针程序定位表尾节点的时间复杂度就为O(1)
  • level,记录目前跳跃表内,层数最大的那个节点的层数,通过这个属性可以在O(1)的时间复杂度内获取层高最好的节点的层数可以理解为深度,表头节点的层数不计算在内
  • length,记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内),通过这个属性,程序可以在O(1)的时间复杂度内返回跳跃表的长度可以理解为长度,表头节点的层高并不计算在内

核心部分就是header了,之后挂了一串跳跃表节点,可以看到Redis的数据结构,无论是链表还是哈希表,都是采用链表的方式实现的。而链表的插入和删除动作都是O(1),单纯指“插入”这个操作,而不包含找到插入的位置。链表插入只要修改元素的地址。而数组需要将后面所有元素都修改位置,如果连续空间不够还要查找空间,并将整个数组重新存储。所以对比“插入”操作,链表是O(1),数组是O(n)

压缩列表

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

压缩列表结构

压缩列表包括压缩列表节点和一系列的属性组合而成。

压缩列表节点

每个压缩列表节点可以保存一个字节数组或者一个整数值,其中,字节数组可以是以下三种长度的其中一种:

  • 长度小于等于63(2^6–1)字节的字节数组
  • 长度小于等于16383(2^14–1)字节的字节数组
  • 长度小于等于4294967295(2^32–1)字节的字节数组;

而整数值则可以是以下六种长度的其中一种:

  • 4位长,介于0至12之间的无符号整数;
  • 1字节长的有符号整数;
  • 3字节长的有符号整数;
  • int16_t类型整数;
  • int32_t类型整数;
  • int64_t类型整数

每个压缩列表节点都由previous_entry_length、encoding、content三个部分组成

  • 节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。长度可以是1字节或者5字节

    • 如果前一节点的长度小于254字节,那么previous_entry_length属性的长度为1字节:前一节点的长度就保存在这一个字节里面
    • 如果前一节点的长度大于等于254字节,那么previous_entry_length属性的长度为5字节:其中属性的第一字节会被设置为0xFE(十进制值254),而之后的四个字节则用于保存前一节点的长度
  • 节点的encoding属性记录了节点的content属性所保存数据的类型以及长度

    • 一字节、两字节或者五字节长,值的最高位为00、01或者10的是字节数组编码:这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录
    • 一字节长,值的最高位以11开头的是整数编码:这种编码表示节点的content属性保存着整数值,整数值的类型由编码除去最高两位之后的其他位记录
  • 节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定

压缩列表

压缩列表的数据结构如下:

各个属性说明如下:

  • zlbytes,大小为4字节,记录整个压缩列表占用的内存字节数
  • zltail,大小为4字节,记录压缩列表表尾节点距离压缩列表起始地址有多少字节。通过这个值,压缩列表无需偏移量就能快速确定表尾节点地址。
  • zllen,大小为2字节,当属性值小于65535时,记录了压缩列表包含的节点数量,当属性值等于65535时,节点数量需要遍历获取。
  • entryN:压缩列表的节点,节点长度由节点保存的内容决定。
  • zlend:,大小为1字节,特殊值0xFF(十进制255),用于标记压缩列表的末端

举个例子如下:

各个属性值解释如下:

  • 列表zlbytes属性的值为0xd2(十进制210),表示压缩列表的总长为210字节,O(1)
  • 列表zltail属性的值为0xb3(十进制179),这表示如果我们有一个指向压缩列表起始地址的指针p,那么只要用指针p加上偏移量179,就可以计算出表尾节点entry5的地址,O(1)
  • 列表zllen属性的值为0x5(十进制5),表示压缩列表包含五个节点,O(1)

压缩列表有些特性和需要注意的地方

压缩列表特性

压缩列表包含倒序回溯和连锁更新两个特性,需要注意。

倒序回溯

倒序回溯,因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址,压缩列表的从表尾向表头遍历操作就是使用这一原理实现的

连锁更新

在一个压缩列表中,有多个连续的、长度介于250字节到253字节之间的节点e1至eN,因为e1至eN的所有节点的长度都小于254字节,所以记录这些节点的长度只需要1字节长的previous_entry_length属性,换句话说,e1至eN的所有节点的previous_entry_length属性都是1字节长的。

  1. 如果我们将一个长度大于等于254字节的新节点new设置为压缩列表的表头节点,那么new将成为e1的前置节点
  2. 因为e1的previous_entry_length属性仅长1字节,它没办法保存新节点new的长度,所以程序将对压缩列表执行空间重分配操作,并将e1节点的previous_entry_length属性从原来的1字节长扩展为5字节长
  3. 而接下来引发了一系列的重新分配空间的内存重分配

展示如下:

当然删除一个小节点也会有同样的问题:如果e1至eN都是大小介于250字节至253字节的节点,big节点的长度大于等于254字节(需要5字节的previous_entry_length来保存),而small节点的长度小于254字节(只需要1字节的previous_entry_length来保存):


那么当我们将small节点从压缩列表中删除之后,为了让e1的previous_entry_length属性可以记录big节点的长度,程序将扩展e1的空间,并由此引发之后的连锁更新

因为连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N^2)

要注意的是,尽管连锁更新的复杂度较高,但它真正造成性能问题的几率是很低的

  • 首先,压缩列表里要恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见
  • 其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响:比如说,对三五个节点进行连锁更新是绝对不会影响性能的

因为以上原因,ziplistPush等命令的平均复杂度仅为O(N)

数据对象下数据结构转换

每种数据对象由至少两种数据编码实现,但某个key在同一时间一定是某一个数据编码,数据编码会随着数据对象存储数据的变化而发生不可逆的切换

String类型对象

由上表可知,String类型有三种展现形式:int、embstr的sds实现,raw的sds实现,在不同的场景下使用不同的展现形式

编码类型[int/embstr sds->raw sds]

String类型对象包含如下的几种转换场景:

  • 整数场景:如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面(将void*转换成long),并将字符串对象的编码设置为int。需要注意的是可以用long double类型表示的浮点数在Redis中也是作为字符串值来保存的

  • embstr的sds场景:如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于32字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值

  • raw的sds场景:如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为raw

    以上提到了两个编码方式embstr和raw,区别是什么呢?

  • 相同点:embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样,都使用redisObject结构和sdshdr结构来表示字符串对象

  • 不同点:raw编码会调用两次内存分配函数来分别创建redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject和sdshdr两个结构

既然创造了embstr,一定是有优势的:

  • 内存分配次数少:embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次
  • 内存释放快:释放embstr编码的字符串对象只需要调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次内存释放函数
  • 缓存利用率高:因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起raw编码的字符串对象能够更好地利用缓存带来的优势

编码转换

int编码的字符串对象和embstr编码的字符串对象在条件满足的情况下,会被转换为raw编码的字符串对象

  1. int编码的字符串对象,如果我们向对象执行了一些命令,使得这个对象保存的不再是整数值,而是一个字符串值,那么字符串对象的编码将从int变为raw
  2. embstr编码的字符串对象实际上是只读的。当我们对embstr编码的字符串对象执行任何修改命令时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令。因为这个原因,embstr编码的字符串对象在执行修改命令之后,总会变成一个raw编码的字符串对象

以下是一些常用操作的对应关系:

List类型对象

列表对象的编码可以是ziplist或者linkedlist

编码类型[ziplist->linkedlist]

举个例子,如果我们执行以下RPUSH命令,那么服务器将创建一个列表对象作为numbers键的值:

  • ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。
  • linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点(node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素

    linkedlist编码的列表对象在底层的双端链表结构中包含了多个字符串对象,字符串对象是Redis五种类型的对象中唯一一种会被其他四种类型对象嵌套的对象

编码转换

当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码,对于使用ziplist编码的列表对象来说,当使用ziplist编码所需的两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行,原本保存在压缩列表里的所有列表元素都会被转移并保存到双端链表里面,对象的编码也会从ziplist变为linkedlist

  • 列表对象保存的所有字符串元素的长度都小于64字节
  • 列表对象保存的元素数量小于512个

不能满足这两个条件的列表对象需要使用linkedlist编码,以上两个条件的上限值是可以通过配置文件修改的。以下是一些常用操作的对应关系:

Hash类型对象

哈希对象的编码可以是ziplist或者hashtable.

编码类型【ziplist->hashtable】

ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾:

  • 保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后
  • 先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向


指针指向的压缩列表表示如下:

hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:

  • 字典的每个键都是一个字符串对象,对象中保存了键值对的键
  • 字典的每个值都是一个字符串对象,对象中保存了键值对的值

hashtable实现方式如下:

编码转换

当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码,对于使用ziplist编码的列表对象来说,当使用ziplist编码所需的两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行,原本保存在压缩列表里的所有键值对都会被转移并保存到字典里面,对象的编码也会从ziplist变为hashtable

  • 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节
  • 哈希对象保存的键值对数量小于512个

这两个条件的上限值是可以修改的。以下是一些编码操作的常用命令:

Set类型对象

集合对象的编码可以是intset或者hashtable

编码类型【inset->hashtable】

intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面

hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为NULL

编码转换

当集合对象可以同时满足以下两个条件时,对象使用intset编码,对于使用intset编码的集合对象来说,当使用intset编码所需的两个条件的任意一个不能被满足时,就会执行对象的编码转换操作,原本保存在整数集合中的所有元素都会被转移并保存到字典里面,并且对象的编码也会从intset变为hashtable

  • 集合对象保存的所有元素都是整数值
  • 集合对象保存的元素数量不超过512个

不能满足这两个条件的集合对象需要使用hashtable编码,第二个条件的上限值是可以修改的,以下是一些常用命令:

ZSet类型对象

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

编码类型

ziplist编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score),压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向

指针指向的压缩列表表示如下:

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

  • zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score属性则保存了元素的分值。通过这个跳跃表,程序可以对有序集合进行范围型操作,比如ZRANK、ZRANGE等命令就是基于跳跃表API来实现的
  • zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。通过这个字典,程序可以用O(1)复杂度查找给定成员的分值,ZSCORE命令就是根据这一特性实现的

有序集合每个元素的成员都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数。值得一提的是,虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费额外的内存

其具体实现方式如下:

为什么同时使用两种方式实现

有序集合可以单独使用字典或者跳跃表的其中一种数据结构来实现,但无论单独使用字典还是跳跃表,在性能上对比起同时使用字典和跳跃表都会有所降低

  • 如果我们只使用字典来实现有序集合,那么虽然以O(1)复杂度查找成员的分值这一特性会被保留,但是,因为字典以无序的方式来保存集合元素,所以每次在执行范围型操作——比如ZRANK、ZRANGE等命令时,程序都需要对字典保存的所有元素进行排序,完成这种排序需要至少O(NlogN)时间复杂度,以及额外的O(N)内存空间(因为要创建一个数组来保存排序后的元素)
  • 如果我们只使用跳跃表来实现有序集合,那么跳跃表执行范围型操作的所有优点都会被保留,但因为没有了字典,所以根据成员查找分值这一操作的复杂度将从O(1)上升为O(logN)

字典用于快速查找分值,跳跃表用于执行范围操作

编码转换

当有序集合对象可以同时满足以下两个条件时,对象使用ziplist编码,对于使用ziplist编码的有序集合对象来说,当使用ziplist编码所需的两个条件中的任意一个不能被满足时,就会执行对象的编码转换操作,原本保存在压缩列表里的所有集合元素都会被转移并保存到zset结构里面,对象的编码也会从ziplist变为skiplist

  • 有序集合保存的元素数量小于128个
  • 有序集合保存的所有元素成员的长度都小于64字节

不能满足以上两个条件的有序集合对象将使用skiplist编码,以上两个条件的上限值是可以修改的,以下是一些命令相关操作:

数据对象的特性

作为缓存的常用中间件,Redis的数据对象具备如下的特性来支持它更好的服务于缓存。

类型检查与多态

Redis中用于操作键的命令基本上可以分为两种类型。一种是通用类的指令,一种是针对某个特定的数据对象类型服务的:

  • 通用类命令:DEL命令、EXPIRE命令、RENAME命令、TYPE命令、OBJECT命令等
  • 特定类型命令:SET、GET、APPEND、STRLEN等命令只能对字符串键执行;HDEL、HSET、HGET、HLEN等命令只能对哈希键执行;RPUSH、LPOP、LINSERT、LLEN等命令只能对列表键执行;SADD、SPOP、SINTER、SCARD等命令只能对集合键执行;ZADD、ZCARD、ZRANK、ZSCORE等命令只能对有序集合键执行

对于特定类型命令,如果操作的指令和类型对不上,则会报类型错误,这就是类型检查,为了确保只有指定类型的键可以执行某些特定的命令,在执行一个类型特定的命令之前,Redis会先检查输入键的类型是否正确,然后再决定是否执行给定的命令类型检查流程如下:

  1. 在执行一个类型特定命令之前,服务器会先检查输入数据库键的值对象是否为执行命令所需的类型,如果是的话,服务器就对键执行指定的命令
  2. 否则,服务器将拒绝执行命令,并向客户端返回一个类型错误

Redis除了会根据值对象的类型来判断键是否能够执行指定命令之外,还会根据值对象的编码方式,选择正确的命令实现代码来执行命令,如果对一个list键执行LLEN命令,那么服务器除了要确保执行命令的是列表键之外,还需要根据键的值对象所使用的编码来选择正确的LLEN命令实现,多态的实现流程如下:

  1. 如果列表对象的编码为ziplist,那么说明列表对象的实现为压缩列表,程序将使用ziplistLen函数来返回列表的长度
  2. 如果列表对象的编码为linkedlist,那么说明列表对象的实现为双端链表,程序将使用listLength函数来返回双端链表的长度

LLEN命令是多态(polymorphism)的,只要执行LLEN命令的是列表键,那么无论值对象使用的是ziplist编码还是linkedlist编码,命令都可以正常执行

内存回收

因为Redis是基于C语言的嘛,而C语言并不具备自动内存回收功能,所以Redis在自己的对象系统中构建了一个引用计数(reference counting)【和Java的引用计数机制是一样的】技术实现内存回收机制。通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。每个对象的引用计数信息由redisObject结构的refcount属性记录。

  • 在创建一个新对象时,引用计数的值会被初始化为1
  • 当对象被一个新程序使用时,它的引用计数值会被增一
  • 当对象不再被一个程序使用时,它的引用计数值会被减一
  • 当对象的引用计数值变为0时,对象所占用的内存会被释放

由于Redis是个内存级的数据库,所以可想而知其瓶颈就在内存上,内存回收策略很重要,而且Java其实也是基于C实现的。

对象共享

除了用于实现引用计数内存回收机制之外,对象的引用计数属性还带有对象共享的作用。假设键A创建了一个包含整数值100的字符串对象作为值对象,键B也要创建一个同样保存了整数值100的字符串对象作为值对象,此时B发现A已经创建了,则无需再创建而是直接指向A的值对象即可。

共享对象池

Redis会在初始化服务器时,创建一万个字符串对象,这些对象包含了从0到9999的所有整数值,当服务器需要用到值为0到9999的字符串对象时,服务器就会使用这些共享对象,而不是新创建。这一万个字符串对象也叫共享对象池。创建共享字符串对象的数量可以通过修改配置来调整。

  • 这些共享对象的引用计数开始都是1,被服务器引用,之后如果有键A或B之类的指向它,refcount就累加即可,但不会被释放,除非服务器宕机,重新初始化。
  • 这些共享对象不单单只有字符串键可以使用,那些在数据结构中嵌套了字符串对象的对象(linkedlist编码的列表对象、hashtable编码的哈希对象、hashtable编码的集合对象,以及zset编码的有序集合对象)都可以使用这些共享对象

共享对象池对于节约内存还是很重要的

为什么Redis不共享包含字符串的对象

当服务器考虑将一个共享对象设置为键的值对象时,程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同,只有在共享对象和目标对象完全相同的情况下,程序才会将共享对象用作键的值对象,而一个共享对象保存的值越复杂,验证共享对象和目标对象是否相同所需的复杂度就会越高,消耗的CPU时间也会越多

  • 如果共享对象是保存整数值的字符串对象,那么验证操作的复杂度为O(1)
  • 如果共享对象是保存字符串值的字符串对象,那么验证操作的复杂度为O(N)
  • 如果共享对象是包含了多个值(或者对象的)对象,比如列表对象或者哈希对象,那么验证操作的复杂度将会是O(N 2)

尽管共享更复杂的对象可以节约更多的内存,但受到CPU时间的限制,Redis只对包含整数值的字符串对象进行共享。所以是基于内存和CPU的平衡来考虑的吧

对象的空转时长

除了介绍过的type、encoding、ptr和refcount四个属性之外,redisObject结构包含的最后一个属性为lru属性,该属性记录了对象最后一次被命令程序访问的时间

  • OBJECT IDLETIME命令可以打印出给定键的空转时长,这一空转时长就是通过将当前时间减去键的值对象的lru时间计算得出的,所以数值越小越好**,越小说明键越热点**。
  • OBJECT IDLETIME命令的实现是特殊的,这个命令在访问键的值对象时,不会修改值对象的lru属性

如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存数超过了maxmemory选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存

Jedis的使用

Jedis实际上就是Java语言操作Redis数据的工具,其实我们之前在用JDBC操作Mysql的时候也是一样的,实际上Redis不也是一个非关系型的数据库嘛!

Jedis有如下的一些优点:轻量,简洁,便于集成和改造;支持连接池;支持pipelining、事务、LUA Scripting、Redis Sentinel、Redis Cluster,但是需要注意,它不支持读写分离,需要自己实现,还是我们之前看的按次计时服务案例:

综合分析如下实现步骤:

实现代码如下:

import redis.clients.jedis.Jedis;import redis.clients.jedis.exceptions.JedisException;//业务服务和管理
public class JedisService {private String id;private int num;public JedisService(String id,int num){this.id=id;this.num=num;}public void servie(){//连接RedisJedis jedis=new Jedis("127.0.0.1",6379);String value= jedis.get("compid"+id);try {if(value==null){jedis.setex("compid"+id,5,Long.MAX_VALUE-num +"");}else {Long val=jedis.incr("compid"+id);business(id,num-(Long.MAX_VALUE-val));}}catch (JedisException e){System.out.println("使用已达上限");return ;}finally {//关闭Redisjedis.close();}}public void  business(String id,Long val){System.out.println("用户"+id+"执行业务操作第"+val+"次");}
}
//线程管理服务
class MyThread  extends Thread{JedisService jedisService;MyThread(String id,int num){jedisService =new JedisService(id,num);}public void run(){while (true){jedisService.servie();try {Thread.sleep(300L);} catch (InterruptedException e) {e.printStackTrace();}}}}class main{public static void main(String[] args) {MyThread myThread=new MyThread("初级用户",10);MyThread myThread1=new MyThread("高级用户",30);myThread.start();myThread1.start();}}

Redis持久化策略

我们知道Redis时一个内存级的数据库,如果内存中的数据如果突然遭遇断电,将会丢失Redis保存的数据,那么为了保证数据不丢失,内存中的数据要持久化到硬盘里来利用永久性存储介质将数据进行保存,在特定的时间将保存的数据进行恢复的工作机制称为持久化。持久化的作用就是防止数据的意外丢失,确保数据安全性!也就是为什么我们每写会儿文档就要保存一次的原因

RDB持久化策略

RDB是数据快照的持久化策略,只存储数据结果,存储格式简单,关注点在数据。依据执行持久化时机分为如下三种策略

save即时执行策略

save即使生成策略持久化的命令为save,会阻塞输入的指令,save指令比较耗费服务器性能!

bgsave后台执行策略

使用指令bgsave会使用延迟执行save策略。

条件save后台执行策略

限定时间限定条件的save持久化:满足限定时间内key的变化数量达到指定数量则进行持久化。命令为:

save second changes

second代表指定时间,changes代表key的变化数量,如果设置为:

save 100  10

100秒内有10个key变化则进行持久化,那么如果我在100秒到期的时候只有9个key变化,则重置时间,重新从上一次持久化后的key的变化数全量统计变化值【实际上也就是9个】,也就是剩余100秒只需再等待一个key变化就能进行持久化了。

RDB三种执行策略对比

以下是三种执行RDB方式的对比:

方式 save bgsave 条件save
读写 同步 异步 满足条件异步
阻塞客户端指令
额外内存
启动新进程
推荐使用度

RDB策略的优缺点

优点

  • RDB存储效率高【能存更多】,RDB文件是紧凑的二进制文件,存储效率高,比较适合做冷备,灾备,全量复制的场景。RDB做会生成多个文件,每个文件都代表了某一个时刻的Redis完整的数据快照,RDB这种多个数据文件的方式,非常适合做冷备,因为大量的一个个的文件,可以每隔一定的时间,复制出来;可以将这种完整的数据文件发送到一些远程的云服务、分布式存储上进行安全的存储,以预定好的备份策略来定期备份Redis中的数据;

  • RDB恢复数据更快【能恢复更快】,直接基于RDB数据文件来重启和恢复Redis进程,更加快速:RDB就是一份数据文件,恢复的时候,直接加载到内存中即可;

  • **RDB对Redis的读写无影响,RDB对Redis【不影响Redis】对外提供的读写服务,影响非常小,可以让Redis保持高性能,因为Redis主进程只需要fork一个子进程,让子进程执行磁盘IO操作来进行RDB持久化即可;RDB每次写,都是直接写Redis内存,只是在一定的时候,才会将数据写入磁盘中

缺点

  • RDB无法做到实时持久化【可能会丢数据】,一般来说,RDB数据快照文件,都是每隔5分钟,或者更长时间生成一次,这个时候就得接受一旦Redis进程宕机,那么会丢失最近5分钟的数据;这个问题,也是RDB最大的缺点,就是不适合做第一优先的恢复方案,如果你依赖RDB做第一优先恢复方案,会导致数据丢失的比较多;
  • RDB在fork子进程时消耗内存【有一些内存损耗】,RDB每次在fork子进程来执行RDB快照数据文件生成的时候,都会牺牲一些内存。
  • RDB基于快照,每次读写都是全量数据,数据量大时性能较低
  • RDB如果设置的dump读写时间不合适,大数据量下会有IO频繁的风险

AOF持久化策略

AOF是数据快照的持久化策略,存储操作过程,存储格式复杂,关注点在数据的操作过程。我们依据RDB的缺点就能理解AOF的存在价值了,因为没有哪种策略是完美的,只有合适的:

  • RDB无法做到实时持久化【可能会丢数据】,一般来说,RDB数据快照文件,都是每隔5分钟,或者更长时间生成一次,这个时候就得接受一旦Redis进程宕机,那么会丢失最近5分钟的数据;这个问题,也是RDB最大的缺点,就是不适合做第一优先的恢复方案,如果你依赖RDB做第一优先恢复方案,会导致数据丢失的比较多;
  • RDB在fork子进程时消耗内存【有一些内存损耗】,RDB每次在fork子进程来执行RDB快照数据文件生成的时候,都会牺牲一些内存。
  • RDB基于快照,每次读写都是全量数据,数据量大时性能较低
  • RDB如果设置的dump读写时间不合适,大数据量下会有IO频繁的风险

基于以上问题,我们看下AOF的实现。

  • 只记录部分数据,不记录全量数据
  • 只记录操作过程,不记录操作数据
  • 对所有操作均记录,降低丢失数据的可能性。

AOF也有三种策略:

always写数据策略

Redis 在每个事件循环都要将 AOF 缓冲区中的所有内容写入到 AOF 文件,并且同步 AOF 文件,所以 always 的效率是 appendfsync 选项三个值当中最差的一个,但从安全性来说,也是最安全的。当发生故障停机时,AOF 持久化也只会丢失一个事件循环中所产生的命令数据。数据零误差,性能极低,不推荐使用

everysec写数据策略

Redis 在每个事件循环都要将 AOF 缓冲区中的所有内容写入到 AOF 文件中,并且每隔一秒就要在子线程中对 AOF 文件进行一次同步。从效率上看,该模式足够快。当发生故障停机时,只会丢失一秒钟的命令数据准确性较高,性能较高,推荐使用

no写数据策略

Redis 在每一个事件循环都要将 AOF 缓冲区中的所有内容写入到 AOF 文件。而 AOF 文件的同步由操作系统控制。这种模式下速度最快,但是同步的时间间隔较长,出现故障时可能会丢失较多数据

AOF重写机制

并不是所有的AOF数据都需要重写。

  • 进程里超时的数据不再重写,例如进程里已经过期的一些数据就不再重写了。
  • 忽略无效指令,重写时使用进程中的最终数据直接生成,这样AOF只保留最终数据生成命令。例如连续冗余的set。
  • 对同一数据的多条指令进行合并,例如3次incr num,可以调整为:set num 3

满足这些条件就会触发重写,以降低AOF文件的内存占用。AOF执行的重写原理:

我们最常用的是everysec开启重写这种模式,我们详细看下这种模式:

RDB与AOF对比

学习完了两种持久化机制后,我们来看下两种持久化机制的对比:

持久化方式 RDB AOF
占用存储空间 小(数据级压缩) 大(指令级重写)
存储速度
恢复速度
数据安全性 会丢失数据 依据策略而定,最多1秒
资源消耗
启动优先级

选择的时候可以依据如下策略,对数据敏感选择AOF【实时】,对数据不敏感选择RDB【阶段】

Redis的事务机制

其实和Mysql一样,Redis虽然作为一个非关系的K-V结构数据库,也是存在事务的,当然事务并不会有MySQL那么强。当多个客户端对同一个Key执行set操作的时候,客户端的get预期是会有偏差的,那么依赖于Redis的单线程特性,我们处理Redis的问题比Mysql的要简单一些。

Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令

事务的工作流程

事务有三个基本操作,创建事务队列multi;执行事务exec;取消事务discard

执行的流程如下,当一条指令到来的时候要判断它是普通指令还是事务指令,

  • 如果是普通指令,则判断当前是否存在事务队列,如果不存在直接执行,如果存在则入队
  • 如果是事务指令,那么分为三种,multi为开启事务队列,exec为执行事务队列中的指令,执行完成后销毁队列,descrad为不执行队列中的指令,直接销毁队列

执行的时候按照先进先出的队列模式进行执行,事务操作时出错的情况分为两种:指令书写错误语法性错误(例如让list实现自增),这种情况下处理机制是什么呢?

  • 指令书写错误,若在事务队列中存在命令书写错误(类似于java编译性错误),则执行EXEC命令时,所有命令都不会执行
  • 指令语法错误,若在事务队列中存在语法性错误(类似于java的1/0的运行时异常),则执行EXEC命令时,其他正确命令会被执行,错误命令抛出异常

通过上边两种异常的了解我们知道,当指令出现语法错误的时候,redis是不支持事务的回滚机制的。能做的只能通过持久化的备份去恢复,以及写代码的时候小心小心再小心。

锁的使用

想象一个场景,如果多个客户端都想对同一个Key进行操作,如果我们只使用事务去限制,不一定能达到效果。例如我想让一个num自增1,客户端1和客户端2都接到了这个任务**【商品补货】**:客户端1创建了一个事务,并且让num自增1

127.0.0.1:6379> flushdb
OK
127.0.0.1:6379> set age 50
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr age
QUEUED
127.0.0.1:6379> exec
1) (integer) 52
127.0.0.1:6379>

结果在客户端1事务队列执行的时候,客户端2对num进行了自增得到了51,此时客户端1再执行事务的时候发现结果变为了52,事务怎么不具备原子性了呢?这都是并发导致的问题

127.0.0.1:6379> incr age
(integer) 51
127.0.0.1:6379>

Watch锁

为了解决这个问题,我们可以使用锁来监控key的状态,只要监控的key变化了,那么事务就取消执行,防止执行过程中的非原子性。还是上一个例子:客户端1开启锁并开启事务

127.0.0.1:6379> flushdb
OK
127.0.0.1:6379> set age 50
OK
127.0.0.1:6379> watch age
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr age
QUEUED
127.0.0.1:6379> set name tml
QUEUED
127.0.0.1:6379> exec
(nil)
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379>

同时客户端2在事务执行的过程中incr了num:

127.0.0.1:6379> flushdb
OK
127.0.0.1:6379> incr age
(integer) 51
127.0.0.1:6379>

那么执行事务的时候就返回了nil,事务执行失败,并且这个操作相当于销毁队列,连name的值都取不到,还有一点需要注意,当watch事务的时候,即使事务队列没有watch的key,如果key发生变化也会销毁队列,redis不会识别队列的key:

127.0.0.1:6379> flushdb
OK
127.0.0.1:6379> set age 50
OK
127.0.0.1:6379> watch age
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name guochengyu
QUEUED
127.0.0.1:6379> exec
(nil)
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379>

例如这里,watch的是age,但是我事务里对name操作,当另一个客户端对age操作变化后,事务队列name的操作也是无效的。如果我们不想监控的话,可以使用unwatch命令,unwatch之后事务队列可以正常执行了

分布式锁

处理补货问题的时候,可以使用watch来监控添加数量,防止重复添加,但使用watch的时候只能监控到要改变的key是否改变了,在超卖问题下,不仅要监控key是否改变了还要求各个客户端不能进行操作。这里我们需要使用setnx这个分布式锁来操作:

setnx key value

有值返回设置失败【无控制权】,无值返回设置成功【有控制权】

127.0.0.1:6379> flushdb
OK
127.0.0.1:6379> setnx lock-num 1    //拿到锁
OK
127.0.0.1:6379> expire lock-num 10
(integer) 1
127.0.0.1:6379> set num 10
OK
127.0.0.1:6379> incr num
(integer) 11
127.0.0.1:6379> del lock-num    //删除锁
(integer) 1
127.0.0.1:6379> get num
"11"
127.0.0.1:6379>

为了防止客户端拿到锁后宕机,我们一般需要给该锁设置一个过期时间,防止发生死锁,关于分布式锁的实现和优化,详细见下一小节的介绍。

分布式锁的实现及改进策略

在分布式的场景下,我们需要考虑并发问题,避免多个线程同时对同一个资源执行操作,造成不可预期的后果,这个时候就需要给资源加分布式的锁,来保证资源的独占。

简易SetNx锁

对整个消息队列,要想控制线程对消息队列的单独处理,就需要给整个队列加一个分组锁,而分组锁可以用Redis的SetNx实现,语义是:设置key,如果存在则返回flase,如果不存在则设置成功并返回true,即设置和判断是一个原子操作

队列锁即对队列的控制权,我们可以设定一个约定好的常量值即可,使用Redis的特性SetNx操作,天然的原子操作:只在键 key 不存在的情况下, 将键 key 的值设置为 value 。若键 key 已经存在, 则 SETNX 命令不做任何动作,需要注意的是队列锁需要设置过期时间,如果线程意外关闭没有来得及释放队列锁,会导致死锁。

        /// <summary>/// 获取缓存队列的队列锁/// </summary>/// <returns></returns>public static bool GetQueueLock(){//申请成功标志const string queueLockkey = "QueueLock";const int tenantId = TenantIdInRedis;try{using (var redis = new RedisNativeProviderV2(KeySpaceInRedis, tenantId)){//redis中,如果返回true设置成功代表队列锁空闲,如果返回false设置失败表明队列锁正在被持有if (RedisSetNx(queueLockkey))  //如果为true,设置队列锁并设置该锁的过期时间{RedisExpire(queueLockkey, 600);//设置过期时间为10分钟return true;}}}catch (Exception ex){//进行查询异常操作Loggging.Error($"在redis 设置队列锁[{queueLockkey}]异常", ex);//抛出异常}return false;}/// <summary>/// 给缓存队列的队列解锁/// </summary>/// <returns></returns>public static bool QueueUnLock(){//申请成功标志const string queueLockkey = "QueueLock";const int tenantId = TenantIdInRedis;try{using (var redis = new RedisNativeProviderV2(KeySpaceInRedis, tenantId)){return RedisDeleteKey(queueLockkey);//redis中,如果返回true设置成功代表原来不存在这样的分组,如果返回false设置失败表明原来存在这样的分组}}catch (Exception ex){//进行查询异常操作Loggging.Error($"在redis 解除队列锁[{queueLockkey}]异常", ex);//抛出异常}return false;}

SetNx的执代码如下:

        /// <summary>/// 如果返回true设置成功代表原来不存在这样的锁,如果返回false设置失败表明原来存在这样的锁/// </summary>/// <param name="key"></param>/// <returns></returns>public static bool RedisSetNx(string key){const int tenantId = TenantIdInRedis;try{using (var redis = new RedisNativeProviderV2(KeySpaceInRedis, tenantId)){return redis.SetNx(key, StringToBytes(key));}}catch (Exception ex){//进行锁创建异常的操作Loggging.Error($"在redis setNx[{key}]:[{key}]异常", ex);}finally{//进行锁创建成功或失败的操作Loggging.Info($"在redis setNx[{key}]:[{key}]");}return false;}

单机状态下SetNx锁的改进策略

一个高可用的分布式锁应该满足如下几点要求:

  • 互斥性:任意时刻只能有一个客户端拥有锁,不能被多个客户端获取
  • 安全性:锁只能被持有该锁的客户端删除,不能被其它客户端删除
  • 防死锁:获取锁的客户端因为某些原因而宕机,而未能释放锁,其它客户端也就无法获取该锁,需要有机制来避免该类问题的发生
  • 高可用:当部分节点宕机,客户端仍能获取锁或者释放锁

之前的设计,通过setnx实际上只解决了互斥性的问题,其它的都不满足,那么基于问题如何再进行拓展设计呢?

解决SetNx的超时非原子操作问题【互斥性、防死锁】

可以看到简易SetNx锁中,setnx命令无法原子性的设置锁的自身过期时间,也就是说执行setnx命令时我们无法同时设置其过期时间,那么就会出现死锁,例如:客户端A刚执行完setnx,这时候客户端A挂掉了,没有完成给锁设置过期时间,此时就产生了死锁,所有的客户端再也无法获得该锁,这种情况一般采用Lua脚本来实现(因为Redis执行Lua脚本是原子性的),其实从 Redis 2.6.12 版本开始set命令完全可以替代setnx命令,我们看官网的set命令参数

SET key value [EX seconds] [PX milliseconds] [NX|XX]
  • EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。
  • PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。
  • NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。
  • XX :只在键已经存在时,才对键进行设置操作。

例如:SET key value NX PX 30000 这个命令的作用是在只有这个key不存在的时候才会设置这个key的值(NX选项的作用),超时时间设为30000毫秒(PX选项的作用)。那么我们用set命令带上EX或者PX、以及NX参数就满足了上面提到的互斥性(加锁)、死锁(自动过期)两个要求

解决非本线程对锁的删除问题【安全性】

如果客户端A拿到锁并设置了锁的过期时间为10S,但是由于某种原因客户端A执行时间超过了10S,此时锁自动过期,那么客户端B拿到了锁,然后客户端A此时正好执行完毕删除锁,但是此时删除的是客户端B加的锁,如何防止这种不安全的情况发生呢?有两种方案:

给锁自动续期,执行完任务再删除

解锁实际就是删除缓存key,简易SetNx锁调用后就立即执行删除,没有判断现在的锁还是不是自己的锁了,我们可以让获得锁的线程开启一个守护线程,用来给自己的锁“续期”。例如设置的超时时间为10秒,则有如下的判断方法:

  • 当过去了9S,客户端A还没执行完,守护线程会执行expire指令,把锁再“续期”10S,
  • 守护线程从第9S开始执行,每9秒执行一次。
  • 当客户端A执行完任务,会显式关掉守护线程。

如果客户端A忽然宕机,由于A线程和守护线程在同一个进程,守护线程也会停下。这把锁到了超时的时候,没人给它续期,也就自动释放了

设置锁的唯一标识【判断和删除原子操作】

可以在加锁的时候把set的value值设置成一个唯一标识,标识这个锁是谁加的锁,在删除锁的时候判断是不是自己加的那把锁,如果不是则不删除。例如加上自己的线程号作为唯一标识。当然这里会有一个新问题产生,判断是不是自己加的锁和释放锁是两个独立操作,不是原子性,所以我们需要使用Lua脚本执行判断和释放锁

if redis.call("get",KEYS[1]) == ARGV[1] thenreturn redis.call("del",KEYS[1])
elsereturn 0
end

集群状态下SetNx锁的改进策略

从纯集群的状态下去考虑可能遇到的问题,当然单机遇到的问题集群一定都会遇到,只是场景更为复合。在大型的应用中,一般Redis服务都是集群形式,主从模式下:

  1. master挂掉后,slave选举上来成为master时容易出现问题,redis在进行主从复制时是异步完成的,比如在clientA获取锁后,主redis崩溃了,数据还没有复制到从redis中,
  2. 从redis选举出一个升级为主redis,造成新的主redis没有clientA 设置的锁,这时clientB尝试获取锁,并且能够成功获取锁,导致互斥失效

这个时候就只能使用终极大招,Redis之父创建的Redlock算法了,有两个前置概念

  • TTL:Time To Live;只 redis key 的过期时间或有效生存时间
  • clock drift:时钟漂移;指两个电脑间时间流速基本相同的情况下,两个电脑(或两个进程间)时间的差值;如果电脑距离过远会造成时钟漂移值 过大

时钟漂移相对于TTL来说要小的多,我们采用RedLock算法来解决这个问题:在分布式版本的算法里我们假设我们有N个Redis Master节点,这些节点都是完全独立的。不使用任何复制或者其他隐含的分布式协调算法(如果采用的是Redis Cluster集群此方案可能不适用,因为Redis Cluster是按哈希槽 (hash slot)的方式来分配到不同节点上的,明显存在分布式协调算法)

RedLock算法

我们把N设成5,因此我们需要在不同的计算机或者虚拟机上运行5个master节点来保证他们大多数情况下都不会同时宕机。一个客户端需要做如下操作来获取锁:

  • 获取时间:5台机器同时获取当前系统时间(单位是毫秒)
  • 轮流请求锁:轮流用相同的key和随机值(客户端的唯一标识)在5个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。
  • 计算获取锁时间:客户端计算上一步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。
  • 锁持有时间:如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间【例如获取花了3秒,锁释放为10秒,则不超过,并且实际使用时间为7秒,实际应该再减去时钟漂移,但可忽略不计】。
  • 释放锁:如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。由于释放锁时会判断这个锁的value是不是自己设置的,如果是才删除,所以在释放锁时非常简单,只要向所有实例都发出释放锁的命令,不用考虑能否成功释放锁【也解决了判定是否是自己加的锁防止误删的问题】

以上就是整个RedLock的算法流程。

RedLock注意事项

有以下几个概念点需要厘清,关于RedLock的注意点:

  • RedLock可以看成是同步算法:因为 即使进程间(多个电脑间)没有同步时钟,但是每个进程时间流速大致相同;并且时钟漂移相对于TTL小,可以忽略,所以可以看成同步算法

  • RedLock失败重试机制:当client不能获取锁时,应该在随机时间后重试获取锁;并且最好在同一时刻并发的把set命令发送给所有redis实例;而且对于已经获取锁的client在完成任务后要及时释放锁,这是为了节省时间;

  • 各节点Redis需要有持久化机制:如果redis没有持久化功能,在clientA获取锁成功后,所有redis重启,clientB能够再次获取到锁,这样违法了锁的排他互斥性; 如果启动AOF永久化存储,重启redis后由于redis过期机制是按照unix时间戳走的,所以在重启后,然后会按照规定的时间过期,不影响业务;但是由于AOF同步到磁盘的方式默认是每秒1次,如果在一秒内断电,会导致数据丢失,立即重启会造成锁互斥性失效;但如果同步磁盘方式使用Always(每一个写命令都同步到硬盘)造成性能急剧下降;所以锁完全有效性和性能方面要有所取舍;

  • 锁的有效获取时间计算方式:先假设client获取所有实例,所有实例包含相同的key和过期时间(TTL) ,但每个实例set命令时间不同导致不能同时过期,第一个set命令之前是T1,最后一个set命令后为T2,则此client有效获取锁的最小时间为TTL-(T2-T1)-时钟漂移;

虽然说RedLock算法可以解决单点Redis分布式锁的高可用问题,但如果集群中有节点发生崩溃重启,还是会出现锁的安全性问题

RedLock的极致有效

假设一共有A, B, C, D, E,5个Redis节点,设想发生了如下的事件序列:

  1. 客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)
  2. 节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了
  3. 节点C重启后,客户端2锁住了C, D, E,获取锁成功

这样,客户端1和客户端2同时获得了锁(针对同一资源)。针对这样场景,解决方式也很简单, 断电后等待TTL后重启:即使断电情况也能有效保证锁完全有效性及性能高效:redis同步到磁盘方式保持默认每秒,在redis无论停掉后要等待TTL时间后再重启【这种情况下相当于客户端1放弃了自己的锁,客户端2设置后唯一,仍然互斥有效】(延迟重启) ,缺点是 TTL时间内服务相当于暂停状态

Redis的删除策略

什么是过期数据,当我们执行del删除redis数据以及expire过期的就属于过期数据,也就是我们现在或将来一定要删除的数据,为了防止CPU压力过大,采取不同的删除策略。

过期数据的删除策略

和ElasticSearch的定期段合并策略相同,Redis处理过期数据也有一套。时效性数据的内存结构如下,有个espires的hash结构来存储数据的内存地址和时间,到时删除

删除策略的目标是在内存占用和CPU性能之间寻找一种平衡,既不能让过期数据过多,又不能让CPU太忙。目前有三种处理策略:

定时删除

创建一个定时器,当key设置有过期时间,且过期时间到达时,由定时器任务立即执行对键的删除操作。

  • 优点:节约内存,到时就删除,快速释放掉不必要的内存占用
  • 缺点:CPU压力很大,无论CPU此时负载量多高,均占用CPU,会影响redis服务器响应时间和指令吞吐量

总而言之一句话:牺牲时间换取空间

惰性删除

定时删除可能会导致很多过期key到了时间并没有被删除掉。所以就有了惰性删除。过期key,依然停留在内存里,除非访问一下过期key,才会被redis给删除掉。这就是所谓的惰性删除。expireIfNeeded(),检查数据是否过期,执行get的时候调用

  • 优点:节约CPU性能,发现必须删除的时候才删除
  • 缺点:内存压力很大,出现长期占用内存的数据

总而言之一句话:牺牲空间换时间

定期删除

当然同AOF的几种策略相比,删除策略也不会走极端,于是就有了定期删除的策略:

这几个参数在配置中可以指定:

hz 10

周期性轮询redis库中的时效性数据,采用随机抽取的策略,利用过期数据占比的方式控制删除频度.

  • 优点: CPU性能占用设置有峰值,检测频度可自定义设置,内存压力不是很大,长期占用内存的冷数据会被持续清理

总而言之一句话:空间和时间具有平衡性

删除策略对比

三种删除策略及对比如下:

删除策略 特点 执行特点 总结
定时删除 节约内存,消耗CPU 不分时段执行,内存占用低,CPU损耗高 牺牲时间换空间
惰性删除 内存占用验证,CPU消耗低 延迟执行,内存占用高,CPU损耗低 牺牲空间换时间
定期删除 内存定期随机清理 每秒花费固定CPU资源维护内存,处理时间久的冷数据 定时抽查,重点抽查

一般会组合惰性删除定期删除进行使用。

Redis的逐出算法

当新数据进入redis时,如果内存不足怎么办?Redis使用内存存储数据,在执行每一个命令前,会调用freeMemoryIfNeeded()检测内存是否充足。如果内存不满足新加入数据的最低存储要求,redis要临时删除一些数据为当前指令清理存储空间。清理数据的策略称为逐出算法
首先需要注意几个参数,这几个参数决定了逐出算法的后续逻辑。

  • 最大可使用内存maxmemory:占用物理内存的比例,默认值为0,表示不限制。通常设置在50%以上
  • 每次选取待删除数据的个数maxmemory-samples: 选取数据时并不会全库扫描,导致严重的性能损耗,降低读写性能。因此采用随机获取数据的方式作为待检测删除数据
  • 删除策略maxmemory-policy :达到最大内存后,对被选出来的数据进行删除的策略

逐出算法有三大类:

检测易失性数据(可能会过期的数据集server.db[i].expires)

检测易失数据有四种算法:

  • volatile-lru --> 从已设置过期时间的数据集中挑选最近最少使用的数据淘汰【时间】
  • volatile-lfu–>从已设置过期时间的数据集中挑选最不经常使用的数据淘汰【次数】
  • volatile-ttl–>从已设置过期时间的数据集中挑选将要过期的数据淘汰
  • volatile-random -->从已设置过期时间的数据集中任意选择数据淘汰

检测全库数据(所有数据集server.db[i].dict)

检测易失数据有三种算法:

  • allkeys-lru --> 当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key(最常用)
  • allkeys-random–>从数据集中任意选择数据淘汰
  • allkeys-lfu–>当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的key

因为不需要关心数据是否过期,所以没有volatile-ttl

放弃数据驱逐

no-eviction–>禁止驱逐数据(redis4.0默认策略),也就是说当内存不足以容纳新写入数据时,新写入操作或报错,回引发OOM(Out of memory)

综合以上,我们推荐使用的策略是:maxmemory-policy volatile-lru

无法逐出的情况

逐出数据的过程不是100%能够清理出足够的可使用的内存空间,如果不成功则反复执行。当对所有数据尝试完毕后,如果不能达到内存清理的要求【均无过期数据,且占用内存已满】,将出现错误信息。

(err)OOM command not allowed when used memory > 'maxmemory'

Cluster集群模式

其实Redis的集群有三种模式,主从复制模式、哨兵模式以及Cluster集群模式。其中Cluster集群模式可以说是当前最成熟的解决方案。所以本节重点介绍这部分内容。

Cluster模式,它一定意义上也是基于主从复制模式的,只不过比主从复制模式更加强大,不仅做到了主从的读写分离包括读的负载均衡,还能进行很好的写的负载均衡

  • 高可扩展, 分散单台服务器的访问压力,实现负载均衡
  • 高可扩展,分散单台服务器的存储压力,实现可扩展性
  • 高可用, 降低单台服务器宕机带来的业务灾难

具备了以上特点的Cluster架构如下:

Cluster集群结构设计

数据写入时会依据CRC算key,计算结果再对16384个插槽取余,然后放置到指定主服务器。因为初始化机器的时候就会给每个主初始化一定数量的槽,数据放到哪个槽一定是有明确的路由地址的

如果直接访问一次命中就直接取,否则就由客户端直接去目标机器找,因为存在地址簿,所以最多两次命中:

槽是集群建立之初或集群加减机器时都会动态调整和变化的。每个机器分为若干个槽slot,加机器和减机器都可以通过动态调整槽来实现。

数据存取的方式如下:

[root@192 redis-6.0.8]# redis-cli -h 192.168.5.101
192.168.5.101:6379> set love guochengyu
(error) MOVED 16198 192.168.5.103:6379

可以看的出存储到了插槽16198,被路由到了master3上了,所以需要使用重定向的方式插入和获取,只需要直接在客户端命令后边加个-c即可:

[root@192 redis-6.0.8]#  redis-cli -h 192.168.5.101 -c
192.168.5.101:6379> set love guochenyubaobei
-> Redirected to slot [16198] located at 192.168.5.103:6379
OK
192.168.5.103:6379> get love
"guochenyubaobei"
192.168.5.103:6379>

集群的主从切换

主从切换分为两种,一种是主服务器下线,一种是从服务器下线。

  • 从服务器下线,一个 从节点下线,可以从主的日志中看到,10秒连接不上,就下线,下线后再上线主向从节点复制数据。其它主从会记录整个集群状态
  • 主服务器下线,master一段时间失联,被slave谋朝篡位,当它再回来的时候,只能屈居为一个小小的slave。一个 主节点下线,从节点尝试重连,连到10秒【10次】,认为主节点失败,自己申请成为主节点,主重新连接后成为了slave,已经被改朝换代了。其它主从会记录整个集群状态

以上就是主从切换的场景。

主从复制

在主从切换的过程中使用到了主从复制,数据同步。主从复制有非常重要的作用,和kafka及ES的集群架构本质相同,都是为了三高设计的:

  • 读写分离:master写,slave读,提高服务器读写负载能力
  • 负载均衡:基于主从结构,配合读写分离,由slave来分担master的负载,并根据需求的变化,弹性扩容slave的数量,通过多节点分担数据读取负载,大大提高Redis服务器并发量与数据吞吐量。
  • 故障恢复:当master出现问题时,由slave提供服务,实现快速的故障恢复
  • 数据冗余:实现数据热备份,是持久化之外的一种数据冗余方式
  • 高可用基石:基于主从复制,构建哨兵模式与集群,实现Redis的高可用方案

了解了主从复制的优势,我们来看看主从复制的实现流程,整体分为三个阶段:建立连接阶段【slave发起】、数据同步阶段【master发起】、命令传播阶段【master和slave互发指令】

建立连接阶段

建立slave到master的连接,使master能够识别slave,并保存slave的端口号。建立连接阶段主要有以下几个步骤:

数据同步阶段

数据同步阶段主要工作就是将master的数据全量的【包含历史数据快照以及同步过程中新进来的指令,也就是RDB和AOF混合模式】同步到slave机器,让他们之间保持数据状态一致,主要流程如下:

数据同步和命令传播阶段的详细实现工作流程如下,第一次slave连接时发送的是psync2 ? -1拿到runid和offset后即可进行增量的数据请求

对于Master而言需要注意以下几点数据同步时的场景需求,Master需要进行如下的选择和配置以达到数据同步的最佳状态:

  • 同步时机:如果master数量巨大,数据同步阶段应避开流量高峰期,避免造成master阻塞,影响业务正常执行
  • 复制缓冲区设置:复制缓冲区大小设置应合理,否则会导致指令溢出,这种情况下数据可能会丢失,同步到slave的也不全,丢失后必须重新进行RDB,然后指令再溢出,导致陷入死循环,大小可以依据业务场景设置,我这里设置为100m:repl-backlog-size 100mb,具体应该依据业务场景判断
  • 内存使用设置:master单机内存占用主机内存比例不应过大,建议使用50%-70%的内存,留下30%-50%内存用于执行bgsave命令创建和复制缓冲区

以上就是master部分需要设置的,当然slave也需要做一些设置和配置,Slave需要进行如下的选择和配置以达到数据同步的最佳状态:

  • 关闭对外写服务:为避免slave进行全量、部分复制时服务器阻塞,建议slave保持关闭服务状态:slave-server-stale-data no,设置后同步期间外部客户端均不能对slave进行任何操作
  • 同步时机:多个slave同时对master请求数据同步,master发送的RDB文件增加,造成带宽冲击,数据同步应错峰执行
  • 架构思考:slave过多时,调整拓扑结构为复合架构,多层级架构,这么做可以减少master压力,但需要注意顶层master与底层slave之间的数据同步可能不及时。

以上就是slave部分需要设置和注意的内容

命令传播阶段

数据同步完成后就开始周期性的进行指令的传播,进行反复同步,实际上就是AOF不停的从master发到slave,slave重写指令后执行指令,达到数据复制的目的。需要注意,其实在数据同步阶段的时候命令也在不停的传播,这个过程中是通过心跳检测机制来进行的。

缓存穿透、缓存雪崩、缓存击穿

关于企业级的一些Redis缓存解决方案,包括:缓存预热、缓存雪崩、缓存击穿、缓存穿透,以及一些简单的性能监测指标。依据政策流程以及可能会在什么分支上发生什么灾难,我构建了下列的流程图,正确请求是先从缓存拿数据,拿不到再从数据库里拿。

缓存预热、缓存雪崩、缓存击穿主要原因为频繁访问数据库更新缓存,造成数据库压力过大,而穿透则是因为数据库也没有数据,不停的进行穿透请求,造成Redis服务器和数据库压力均很大。心里有个数后可以详细的看看各种情况及处理手段。

缓存雪崩

缓存雪崩是指缓存中短期内有大批量的key集中过期,而查询数据量巨大,导致短期内应用服务器、数据库、Redis服务器和集群扛不住压力集中宕机。

现象和问题原因

为了便于理解,我将雪崩时的现象和背后的原因做了一个对应关系的展示:

解决方案

其实主要问题就是短时间内key的集中化过期,我们让这些key不集中过期其实就是解决这个问题的关键。

  • 调整删除策略,LRU和LFU的删除策略,按照命中次数去进行删除,保留高热数据key
  • 稀释数据有效期,对不同的业务数据进行统计和分类错峰过期,错峰使用随机时间实现,稀释key的集中到期数量
  • 超热数据使用永久的key
  • 定期维护,对即将过期的热点数据进行延时策略,延长过期时间
  • 加锁,缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。

加锁排队只是为了减轻数据库的压力,并没有提高系统吞吐量。假设在高并发下,缓存重建期间key是锁着的,这时过来1000个请求999个都在阻塞的。同样会导致用户等待超时,加锁排队有可能还要解决分布式锁的问题;线程还会被阻塞,用户体验很差!因此,在真正的高并发场景下很少使用!

缓存击穿

缓存击穿,是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。导致短期内数据库扛不住压力集中宕机

现象和问题原因

为了便于理解,我将击穿时的现象和背后的原因做了一个对应关系的展示:

解决方案

其实主要问题就是单个超热key的高并发访问,我们让超热key不过期其实就是解决这个问题的关键。

  • 预先设定超热Key,对此类Key设置长期Key并追加延时策略【后台开启定时任务刷新有效期】
  • 临时设置永久Key,访问业务量短期内激增的时候,临时设置为永久Key保证不过期
  • 加分布式锁,防止被击穿,其实就是降低对数据库的访问压力,但整体依然是阻塞的。

简单地来说,加分布式锁就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是在获取到锁后再进行操作。

缓存穿透

缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,数据库的压力骤增。 导致短期内数据库扛不住压力宕机

现象和问题原因

为了便于理解,我将穿透时的现象和背后的原因做了一个对应关系的展示:

解决方案

其实主要问题就是大量请求对数据库击穿,我们让请求到不了数据库就行了:

  • 缓存null,如果一个查询返回的数据为空(无论是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。问题是:空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间 ( 如果是攻击,问题更严重 ),比较有效的方法是针对这类数据设置一个较短的过期时间(最长不超过五分钟),让其自动剔除。还有就是缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为 5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象
  • 白名单策略,提前预热所有可能查询的参数以hash形式存储,在控制层先进行校验,不符合则丢弃。还有最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力
  • 实时监控,实时监控redis命中率中null的占比,平时监测一个数量,如果超过多少倍就认定有攻击,增加黑名单策略
  • key加密,防止黑客知道哪些key不存在,让key加密。

以上就是整个缓存穿透的一个解决方案

行文至此,已洋洋洒洒7万言,关于Redis的知识要点都已在此做了重点说明,看此一篇基本就能上手Redis了。希望与诸君在学习和进步的路上越走越远。

Redis看这一篇就够了相关推荐

  1. 《Redis篇》01.Redis看这一篇就够了

    陈老老老板 说明:在整体的复习一遍知识,边复习边总结,基础真的重要,需要注意的地方都标红了,还有资源的分享. 一起加油.本篇有对Redis知识的阐述,和使用IDEA操作Redis 视频链接:黑马Red ...

  2. 基础 | 零散的MySql基础记不住,看这一篇就够啦

    ❝ 这是小小本周的第二篇,本篇将会着重的讲解关于MySql基础的内容,MySql基础看这一篇就够啦. ❞ 送书反馈与继续送书 之情小微信公众号第一次送书,Java深度调试技术,书已经被中奖者麦洛签收, ...

  3. 面试被问到 ConcurrentHashMap答不出 ,看这一篇就够了!

    本文汇总了常考的 ConcurrentHashMap 面试题,面试 ConcurrentHashMap,看这一篇就够了!为帮助大家高效复习,专门用"★ "表示面试中出现的频率,&q ...

  4. api网关选型_如何轻松打造百亿流量API网关?看这一篇就够了(下)

    如何轻松打造百亿流量API网关?看这一篇就够了(上) 上篇整体描述了网关的背景,涉及职能.分类.定位环节,本篇进入本文的重点,将会具体谈下百亿级流量API网关的演进过程. 准备好瓜子花生小板凳开始积累 ...

  5. python装饰器功能是冒泡排序怎么做_传说中Python最难理解的点|看这完篇就够了(装饰器)...

    https://mp.weixin.qq.com/s/B6pEZLrayqzJfMtLqiAfpQ 1.什么是装饰器 网上有人是这么评价装饰器的,我觉得写的很有趣,比喻的很形象 每个人都有的内裤主要是 ...

  6. serviceloader java_【java编程】ServiceLoader使用看这一篇就够了

    转载:https://www.jianshu.com/p/7601ba434ff4 想必大家多多少少听过spi,具体的解释我就不多说了.但是它具体是怎么实现的呢?它的原理是什么呢?下面我就围绕这两个问 ...

  7. docker 删除所有镜像_关于 Docker 镜像的操作,看完这篇就够啦 !(下)| 文末福利...

    紧接着上篇<关于 Docker 镜像的操作,看完这篇就够啦 !(上)>,奉上下篇 !!! 镜像作为 Docker 三大核心概念中最重要的一个关键词,它有很多操作,是您想学习容器技术不得不掌 ...

  8. mysql ip比较大小_MySQL优化/面试,看这一篇就够了

    原文链接:http://www.zhenganwen.top/articles/2018/12/25/1565048860202.html 作者:Anwen~ 链接:https://www.nowco ...

  9. 【系统架构设计师】软考高级职称,一次通过,倾尽所有,看完这篇就够了,论软件架构设计的重要性、本篇论文“未通过考试”,供分析参考

    [系统架构设计师]软考高级职称,一次通过,倾尽所有,看完这篇就够了,学习方法和技巧这里全都有. 论软件架构设计的重要性.本篇论文未通过考试(不合格),供分析参考. 目录 摘要 正文 结尾 摘要 201 ...

最新文章

  1. supervisor 守护php,laravel队列之Supervisor守护进程(centos篇)
  2. 30条HTML代码编写指南 for入门者
  3. SQL Server 设置编辑所有行
  4. 全球及中国水产加工市场消费潜力分析与投资规模建议报告2022版
  5. powerdesigner导出到mysql数据库
  6. 输入 n 个整数,输出其中最小的 k 个
  7. G6 图可视化引擎——入门教程——元素及其配置
  8. [j2me]手机也可以玩播客(Podcast)! Geek开发说明[开源]
  9. 核心显卡是新一代的智能图形核心
  10. Java Integer类中的parseInt和valueOf的区别
  11. 基于asp. net sql快餐外卖设计网站成品
  12. 微信小程序获取Token 存储2小时
  13. Atitit gui控件定位解决方案
  14. 易筋SpringBoot 2.1 | 第十九篇:SpringBoot的常用注解
  15. UOS系统应用商店提示安装失败
  16. 单片机原理与应用技术
  17. 高尔顿钉板概率模型的实现
  18. 可视化工具netron的使用
  19. 亚马逊为什么能吞噬世界?贝索斯有这么一种独特的战略思维
  20. Svn服务器与客户端、AnkhSVN使用

热门文章

  1. 如何干净的卸载Oracle
  2. hadoop相关软件下载地址
  3. cocos2dx3.x的TableView使用
  4. SHA224和SHA256哈希算法原理及实现(附源码)
  5. python一个数的阶乘_python整数阶乘计算
  6. mysql查询结果升序_MySQL ORDER BY:对查询结果进行排序
  7. 一个剪切shader 液体水
  8. 阿里云短信业务SMS
  9. 百度算法发布历史列表
  10. 1072: 青蛙爬井 Java