简介: NoSQL 泛指非关系型数据库,随着 web2.0 互联网的诞生,传统的关系型数据库很难对付 web2.0 大数据时代!尤其是超大规模的高并发的社区,暴露出来很多难以克服的问题,NoSQL 在当今大数据环境下发展的十分迅速,Redis 是发展最快的。
一 什么是 NoSQL?
Nosql = not only sql(不仅仅是 SQL)
关系型数据库:列 + 行,同一个表下数据的结构是一样的。
非关系型数据库:数据存储没有固定的格式,并且可以进行横向扩展。
NoSQL 泛指非关系型数据库,随着 web2.0 互联网的诞生,传统的关系型数据库很难对付 web2.0 大数据时代!尤其是超大规模的高并发的社区,暴露出来很多难以克服的问题,NoSQL 在当今大数据环境下发展的十分迅速,Redis 是发展最快的。
传统 RDBMS 和 NoSQL
1 | RDBMS |
二 redis 是什么?
Redis = Remote Dictionary Server,即远程字典服务。
是一个开源的使用 ANSI C 语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value 数据库,并提供多种语言的 API。
与 memcached 一样,为了保证效率,数据都是缓存在内存中。区别的是 redis 会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,并且在此基础上实现了 master-slave (主从) 同步。
三 redis 五大基本类型
Redis 是一个开源,内存存储的数据结构服务器,可用作数据库,高速缓存和消息队列代理。它支持字符串、哈希表、列表、集合、有序集合,位图,hyperloglogs 等数据类型。内置复制、Lua 脚本、LRU 收回、事务以及不同级别磁盘持久化功能,同时通过 Redis Sentinel 提供高可用,通过 Redis Cluster 提供自动分区。
由于 redis 类型大家很熟悉,且网上命令使用介绍很多,下面重点介绍五大基本类型的底层数据结构与应用场景,以便当开发时,可以熟练使用 redis。
1 String(字符串)
1 | 1.String类型是redis的最基础的数据结构,也是最经常使用到的类型。 |
应用场景
1、缓存功能:String 字符串是最常用的数据类型,不仅仅是 redis,各个语言都是最基本类型,因此,利用 redis 作为缓存,配合其它数据库作为存储层,利用 redis 支持高并发的特点,可以大大加快系统的读写速度、以及降低后端数据库的压力。
2、计数器:许多系统都会使用 redis 作为系统的实时计数器,可以快速实现计数和查询的功能。而且最终的数据结果可以按照特定的时间落地到数据库或者其它存储介质当中进行永久保存。
3、统计多单位的数量:eg,uid:gongming count:0 根据不同的 uid 更新 count 数量。
4、共享用户 session:用户重新刷新一次界面,可能需要访问一下数据进行重新登录,或者访问页面缓存 cookie,这两种方式做有一定弊端,1)每次都重新登录效率低下 2)cookie 保存在客户端,有安全隐患。这时可以利用 redis 将用户的 session 集中管理,在这种模式只需要保证 redis 的高可用,每次用户 session 的更新和获取都可以快速完成。大大提高效率。
2 List(列表)
1 | 1.list类型是用来存储多个有序的字符串的,列表当中的每一个字符看做一个元素 |
应用场景
1、消息队列:reids 的链表结构,可以轻松实现阻塞队列,可以使用左进右出的命令组成来完成队列的设计。比如:数据的生产者可以通过 Lpush 命令从左边插入数据,多个数据消费者,可以使用 BRpop 命令阻塞的 “抢” 列表尾部的数据。
2、文章列表或者数据分页展示的应用。比如,我们常用的博客网站的文章列表,当用户量越来越多时,而且每一个用户都有自己的文章列表,而且当文章多时,都需要分页展示,这时可以考虑使用 redis 的列表,列表不但有序同时还支持按照范围内获取元素,可以完美解决分页查询功能。大大提高查询效率。
3 Set(集合)
1 | 1.redis集合(set)类型和list列表类型类似,都可以用来存储多个字符串元素的集合。 |
应用场景
1、标签:比如我们博客网站常常使用到的兴趣标签,把一个个有着相同爱好,关注类似内容的用户利用一个标签把他们进行归并。
2、共同好友功能,共同喜好,或者可以引申到二度好友之类的扩展应用。
3、统计网站的独立 IP。利用 set 集合当中元素不唯一性,可以快速实时统计访问网站的独立 IP。
数据结构
set 的底层结构相对复杂写,使用了 intset 和 hashtable 两种数据结构存储,intset 可以理解为数组。
4 sorted set(有序集合)
redis 有序集合也是集合类型的一部分,所以它保留了集合中元素不能重复的特性,但是不同的是,有序集合给每个元素多设置了一个分数。
1 | redis有序集合也是集合类型的一部分,所以它保留了集合中元素不能重复的特性,但是不同的是, |
应用场景
1、 排行榜:有序集合经典使用场景。例如视频网站需要对用户上传的视频做排行榜,榜单维护可能是多方面:按照时间、按照播放量、按照获得的赞数等。
2、用 Sorted Sets 来做带权重的队列,比如普通消息的 score 为 1,重要消息的 score 为 2,然后工作线程可以选择按 score 的倒序来获取工作任务。让重要的任务优先执行。
5 hash(哈希)
1 | Redis hash数据结构 是一个键值对(key-value)集合,它是一个 string 类型的 field 和 value 的映射表, |
应用场景
1、由于 hash 数据类型的 key-value 的特性,用来存储关系型数据库中表记录,是 redis 中哈希类型最常用的场景。一条记录作为一个 key-value,把每列属性值对应成 field-value 存储在哈希表当中,然后通过 key 值来区分表当中的主键。
2、经常被用来存储用户相关信息。优化用户信息的获取,不需要重复从数据库当中读取,提高系统性能。
四 五大基本类型底层数据存储结构
在学习基本类型底层数据存储结构前,首先看下 redis 整体的存储结构。
redis 内部整体的存储结构是一个大的 hashmap,内部是数组实现的 hash,key 冲突通过挂链表去实现,每个 dictEntry 为一个 key/value 对象,value 为定义的 redisObject。
结构图如下:
dictEntry 是存储 key->value 的地方,再让我们看一下 dictEntry 结构体
1 | /* |
1 redisObject
我们接着再往下看 redisObject 究竟是什么结构的
1 | /* |
*ptr 指向具体的数据结构的地址;type 表示该对象的类型,即 String,List,Hash,Set,Zset 中的一个,但为了提高存储效率与程序执行效率,每种对象的底层数据结构实现都可能不止一种,encoding 表示对象底层所使用的编码。
redis 对象底层的八种数据结构
1 | REDIS_ENCODING_INT(long 类型的整数) |
好了,通过 redisObject 就可以具体指向 redis 数据类型了,总结一下每种数据类型都使用了哪些数据结构,如下图所示
前期准备知识已准备完毕,下面分每种基本类型来讲。
2 String 数据结构
String 类型的转换顺序
- 当保存的值为整数且值的大小不超过 long 的范围,使用整数存储
- 当字符串长度不超过 44 字节时,使用 EMBSTR 编码
它只分配一次内存空间,redisObject 和 sds 是连续的内存,查询效率会快很多,也正是因为 redisObject 和 sds 是连续在一起,伴随了一些缺点:当字符串增加的时候,它长度会增加,这个时候又需要重新分配内存,导致的结果就是整个 redisObject 和 sds 都需要重新分配空间,这样是会影响性能的,所以 redis 用 embstr 实现一次分配而后,只允许读,如果修改数据,那么它就会转成 raw 编码,不再用 embstr 编码了。
- 大于 44 字符时,使用 raw 编码
SDS
embstr 和 raw 都为 sds 编码,看一下 sds 的结构体
1 | /* 针对不同长度整形做了相应的数据结构 |
由于 redis 底层使用 c 语言实现,可能会有疑问为什么不用 c 语言的字符串呢,而是用 sds 结构体。
\1) 低复杂度获取字符串长度:由于 len 存在,可以直接查出字符串长度,复杂度 O (1);如果用 c 语言字符串,查询字符串长度需要遍历整个字符串,复杂度为 O (n);
\2) 避免缓冲区溢出:进行两个字符串拼接 c 语言可使用 strcat 函数,但如果没有足够的内存空间。就会造成缓冲区溢出;而用 sds 在进行合并时会先用 len 检查内存空间是否满足需求,如果不满足,进行空间扩展,不会造成缓冲区溢出;
3)减少修改字符串的内存重新分配次数:c 语言字符串不记录字符串长度,如果要修改字符串要重新分配内存,如果不进行重新分配会造成内存缓冲区泄露;
redis sds 实现了空间预分配和惰性空间释放两种策略
空间预分配:
1)如果 sds 修改后,sds 长度(len 的值)将于 1mb,那么会分配与 len 相同大小的未使用空间,此时 len 与 free 值相同。例如,修改之后字符串长度为 100 字节,那么会给分配 100 字节的未使用空间。最终 sds 空间实际为 100 + 100 + 1 (保存空字符’\0’);
2)如果大于等于 1mb,每次给分配 1mb 未使用空间
惰性空间释放:对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 free 属性将这些字节的数量记录下来,等待后续使用(sds 也提供 api,我们可以手动触发字符串缩短);
4)二进制安全:因为 C 字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此 C 字符串无法正确存取;而所有 SDS 的 API 都是以处理二进制的方式来处理 buf 里面的元素,并且 SDS 不是以空字符串来判断是否结束,而是以 len 属性表示的长度来判断字符串是否结束;
5)遵从每个字符串都是以空字符串结尾的惯例,这样可以重用 C 语言库 <string.h> 中的一部分函数。
学习完 sds,我们回到上面讲到的,为什么小于 44 字节用 embstr 编码呢?
再看一下 rejectObject 和 sds 定义的结构(短字符串的 embstr 用最小的 sdshdr8)
1 | typedef struct redisObject { |
redisObject 占用空间
4 + 4 + 24 + 32 + 64 = 128bits = 16 字节
sdshdr8 占用空间
1(uint8_t) + 1(uint8_t)+ 1 (unsigned char)+ 1(buf [] 中结尾的’\0’字符)= 4 字节
初始最小分配为 64 字节,所以只分配一次空间的 embstr 最大为 64 - 16- 4 = 44 字节
3 List 存储结构
Redis3.2 之前的底层实现方式:压缩列表 ziplist 或者 双向循环链表 linkedlist
当 list 存储的数据量比较少且同时满足下面两个条件时,list 就使用 ziplist 存储数据:
- list 中保存的每个元素的长度小于 64 字节;
- 列表中数据个数少于 512 个
- Redis3.2 及之后的底层实现方式:quicklist
quicklist 是一个双向链表,而且是一个基于 ziplist 的双向链表,quicklist 的每个节点都是一个 ziplist,结合了双向链表和 ziplist 的优点。
ziplist
ziplist 是一种压缩链表,它的好处是更能节省内存空间,因为它所存储的内容都是在连续的内存区域当中的。当列表对象元素不大,每个元素也不大的时候,就采用 ziplist 存储。但当数据量过大时就 ziplist 就不是那么好用了。因为为了保证他存储内容在内存中的连续性,插入的复杂度是 O (N),即每次插入都会重新进行 realloc。如下图所示,redisObject 对象结构中 ptr 所指向的就是一个 ziplist。整个 ziplist 只需要 malloc 一次,它们在内存中是一块连续的区域。
ziplist 结构如下:
1、zlbytes: 用于记录整个压缩列表占用的内存字节数
2、zltail:记录要列表尾节点距离压缩列表的起始地址有多少字节
3、zllen:记录了压缩列表包含的节点数量。
4、entryX:要说列表包含的各个节点
5、zlend:用于标记压缩列表的末端
为什么数据量大时不用 ziplist?
因为 ziplist 是一段连续的内存,插入的时间复杂化度为 O (n),而且每当插入新的元素需要 realloc 做内存扩展;而且如果超出 ziplist 内存大小,还会做重新分配的内存空间,并将内容复制到新的地址。如果数量大的话,重新分配内存和拷贝内存会消耗大量时间。所以不适合大型字符串,也不适合存储量多的元素。
快速列表(quickList)
快速列表是 ziplist 和 linkedlist 的混合体,是将 linkedlist 按段切分,每一段用 ziplist 来紧凑存储,多个 ziplist 之间使用双向指针链接。
为什么不直接使用 linkedlist?
linkedlist 的附加空间相对太高,prev 和 next 指针就要占去 16 个字节,而且每一个结点都是单独分配,会加剧内存的碎片化,影响内存管理效率。
quicklist 结构
1 | typedef struct quicklist { |
结构图如下:
ziplist 的长度
quicklist 内部默认单个 ziplist 长度为 8k 字节,超出了这个字节数,就会新起一个 ziplist。关于长度可以使用 list-max-ziplist-size 决定。
压缩深度
我们上面说到了 quicklist 下是用多个 ziplist 组成的,同时为了进一步节约空间,Redis 还会对 ziplist 进行压缩存储,使用 LZF 算法压缩,可以选择压缩深度。quicklist 默认的压缩深度是 0,也就是不压缩。压缩的实际深度由配置参数 list-compress-depth 决定。为了支持快速 push/pop 操作,quicklist 的首尾两个 ziplist 不压缩,此时深度就是 1。如果深度为 2,就表示 quicklist 的首尾第一个 ziplist 以及首尾第二个 ziplist 都不压缩。
4 Hash 类型
当 Hash 中数据项比较少的情况下,Hash 底层才用压缩列表 ziplist 进行存储数据,随着数据的增加,底层的 ziplist 就可能会转成 dict,具体配置如下
1 | hash-max-ziplist-entries 512 |
在 List 中已经介绍了 ziplist,下面来介绍下 dict。
看下数据结构
1 | typedef struct dict { |
我们可以看到每个 dict 中都有两个 hashtable
结构图如下:
虽然 dict 结构有两个 hashtable,但是通常情况下只有一个 hashtable 是有值的。但是在 dict 扩容缩容的时候,需要分配新的 hashtable,然后进行渐近式搬迁,这时候两个 hashtable 存储的旧的 hashtable 和新的 hashtable。搬迁结束后,旧 hashtable 删除,新的取而代之。
下面让我们学习下 rehash 全貌。
5 渐进式 rehash
所谓渐进式 rehash 是指我们的大字典的扩容是比较消耗时间的,需要重新申请新的数组,然后将旧字典所有链表的元素重新挂接到新的数组下面,是一个 O (n) 的操作。但是因为我们的 redis 是单线程的,无法承受这样的耗时过程,所以采用了渐进式 rehash 小步搬迁,虽然慢一点,但是可以搬迁完毕。
扩容条件
我们的扩容一般会在 Hash 表中的元素个数等于第一维数组的长度的时候,就会开始扩容。扩容的大小是原数组的两倍。不过在 redis 在做 bgsave(RDB 持久化操作的过程),为了减少内存页的过多分离(Copy On Write),redis 不会去扩容。但是如果 hash 表的元素个数已经到达了第一维数组长度的 5 倍的时候,就会强制扩容,不管你是否在持久化。
不扩容主要是为了尽可能减少内存页过多分离,系统需要更多的开销去回收内存。
缩容条件
当我们的 hash 表元素逐渐删除的越来越少的时候。redis 于是就会对 hash 表进行缩容来减少第一维数组长度的空间占用。缩容的条件是元素个数低于数组长度的 10%,并且缩容不考虑是否在做 redis 持久化。
不用考虑 bgsave 主要是因为我们的缩容的内存都是已经使用过的,缩容的时候可以直接置空,而且由于申请的内存比较小,同时会释放掉一些已经使用的内存,不会增大系统的压力。
rehash 步骤
1、为 ht [1] 分配空间,让字典同时持有 ht [0] 和 ht [1] 两个哈希表;
2、定时维持一个索引计数器变量 rehashidx,并将它的值设置为 0,表示 rehash 开始;
3、在 rehash 进行期间,每次对字典执行 CRUD 操作时,程序除了执行指定的操作以外,还会将 ht [0] 中的数据 rehash 到 ht [1] 表中,并且将 rehashidx 加一;
4、当 ht [0] 中所有数据转移到 ht [1] 中时,将 rehashidx 设置成 - 1,表示 rehash 结束;
(采用渐进式 rehash 的好处在于它采取分而治之的方式,避免了集中式 rehash 带来的庞大计算量。特别的在进行 rehash 时只能对 h [0] 元素减少的操作,如查询和删除;而查询是在两个哈希表中查找的,而插入只能在 ht [1] 中进行,ht [1] 也可以查询和删除。)
5、将 ht [0] 释放,然后将 ht [1] 设置成 ht [0],最后为 ht [1] 分配一个空白哈希表。
过程如下图:
6 set 数据结构
Redis 的集合相当于 Java 中的 HashSet,它内部的键值对是无序、唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。集合 Set 类型底层编码包括 hashtable 和 inset。
当存储的数据同时满足下面这样两个条件的时候,Redis 就采用整数集合 intset 来实现 set 这种数据类型:
- 存储的数据都是整数
- 存储的数据元素个数小于 512 个
当不能同时满足这两个条件的时候,Redis 就使用 dict 来存储集合中的数据
hashtable 在上面介绍过了,我们就只介绍 inset。
inset 结构体
1 | typedef struct intset { |
inset 的查询
intset 是一个有序集合,查找元素的复杂度为 O (logN)(采用二分法),但插入时不一定为 O (logN),因为有可能涉及到升级操作。比如当集合里全是 int16_t 型的整数,这时要插入一个 int32_t,那么为了维持集合中数据类型的一致,那么所有的数据都会被转换成 int32_t 类型,涉及到内存的重新分配,这时插入的复杂度就为 O (N) 了。是 intset 不支持降级操作。
inset 是有序不要和我们 zset 搞混,zset 是设置一个 score 来进行排序,而 inset 这里只是单纯的对整数进行升序而已
7 Zset 数据结构
Zset 有序集合和 set 集合有着必然的联系,他保留了集合不能有重复成员的特性,但不同的是,有序集合中的元素是可以排序的,但是它和列表的使用索引下标作为排序依据不同的是,它给每个元素设置一个分数,作为排序的依据。
zet 的底层编码有两种数据结构,一个 ziplist,一个是 skiplist。
Zset 也使用了 ziplist 做了排序,所以下面讲一下 ziplist 如何做排序。
ziplist 做排序
每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score)。
存储结构图如下一目了然:
skiplist 跳表
结构体如下,skiplist 是与 dict 结合来使用的,这个结构比较复杂。
1 | /* |
跳表是什么?
我们先看下链表
如果想查找到 node5 需要从 node1 查到 node5,查询耗时
但如果在 node 上加上索引:
这样通过索引就能直接从 node1 查找到 node5
redis 跳跃表
让我们再看下 redis 的跳表结构(图太复杂,直接从网上找了张图说明)
- header: 指向跳跃表的表头节点,通过这个指针程序定位表头节点的时间复杂度就为 O (1);
- tail: 指向跳跃表的表尾节点,通过这个指针程序定位表尾节点的时间复杂度就为 O (1);
- level: 记录目前跳跃表内,层数最大的那个节点的层数 (表头节点的层数不计算在内),通过这个属性可以再 O (1) 的时间复杂度内获取层高最好的节点的层数;
- length: 记录跳跃表的长度,也即是,跳跃表目前包含节点的数量 (表头节点不计算在内),通过这个属性,程序可以再 O (1) 的时间复杂度内返回跳跃表的长度。
结构右方的是四个 zskiplistNode 结构,该结构包含以下属性
- 层 (level):
节点中用 L1、L2、L3 等字样标记节点的各个层,L1 代表第一层,L2 代表第二层,以此类推。
每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离 (跨度越大、距离越远)。在上图中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。
每次创建一个新跳跃表节点的时候,程序都根据幂次定律 (powerlaw, 越大的数出现的概率越小) 随机生成一个介于 1 和 32 之间的值作为 level 数组的大小,这个大小就是层的 “高度”。
- 后退 (backward) 指针:
节点中用 BW 字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。与前进指针所不同的是每个节点只有一个后退指针,因此每次只能后退一个节点。
- 分值 (score):
各个节点中的 1.0、2.0 和 3.0 是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。
- 成员对象 (oj):
各个节点中的 o1、o2 和 o3 是节点所保存的成员对象。在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面 (靠近表头的方向), 而成员对象较大的节点则会排在后面 (靠近表尾的方向)。
五 三大特殊数据类型
1 geospatial(地理位置)
1 | 1.geospatial将指定的地理空间位置(纬度、经度、名称)添加到指定的key中。 |
应用场景
- 查看附近的人
- 微信位置共享
- 地图上直线距离的展示
2 Hyperloglog(基数)
什么是基数? 不重复的元素
1 | hyperloglog 是用来做基数统计的,其优点是:输入的提及无论多么大,hyperloglog使用的空间总是固定的12KB ,利用12KB,它可以计算2^64个不同元素的基数!非常节省空间!但缺点是估算的值,可能存在误差 |
应用场景
网页统计 UV (浏览用户数量,同一天同一个 ip 多次访问算一次访问,目的是计数,而不是保存用户)
传统的方式,set 保存用户的 id,可以统计 set 中元素数量作为标准判断。
但如果这种方式保存大量用户 id,会占用大量内存,我们的目的是为了计数,而不是去保存 id。
3 Bitmaps(位存储)
1 | Redis提供的Bitmaps这个“数据结构”可以实现对位的操作。Bitmaps本身不是一种数据结构,实际上就是字符串,但是它可以对字符串的位进行操作。 |
应用场景
两种状态的统计都可以使用 bitmaps,例如:统计用户活跃与非活跃数量、登录与非登录、上班打卡等等。
六 Redis 事务
事务本质:一组命令的集合
1 数据库事务与 redis 事务
数据库的事务
数据库事务通过 ACID(原子性、一致性、隔离性、持久性)来保证。
数据库中除查询操作以外,插入 (Insert)、删除 (Delete) 和更新 (Update) 这三种操作都会对数据造成影响,因为事务处理能够保证一系列操作可以完全地执行或者完全不执行,因此在一个事务被提交以后,该事务中的任何一条 SQL 语句在被执行的时候,都会生成一条撤销日志 (Undo Log)。
redis 事务
redis 事务提供了一种 “将多个命令打包, 然后一次性、按顺序地执行” 的机制, 并且事务在执行的期间不会主动中断 —— 服务器在执行完事务中的所有命令之后, 才会继续处理其他客户端的其他命令。
Redis 中一个事务从开始到执行会经历开始事务(muiti)、命令入队和执行事务 (exec) 三个阶段,事务中的命令在加入时都没有被执行,直到提交时才会开始执行 (Exec) 一次性完成。
一组命令中存在两种错误不同处理方式
1. 代码语法错误(编译时异常)所有命令都不执行
2. 代码逻辑错误(运行时错误),其他命令可以正常执行 (该点不保证事务的原子性)
为什么 redis 不支持回滚来保证原子性
这种做法的优点:
- Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面:这也就是说,从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。
- 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。
鉴于没有任何机制能避免程序员自己造成的错误, 并且这类错误通常不会在生产环境中出现, 所以 Redis 选择了更简单、更快速的无回滚方式来处理事务。
事务监控
悲观锁:认为什么时候都会出现问题,无论做什么操作都会加锁。
乐观锁:认为什么时候都不会出现问题,所以不会上锁!更新数据的时候去判断一下,在此期间是否有人修改过这个数据。
使用 cas 实现乐观锁
redis 使用 watch key 监控指定数据,相当于加乐观锁
watch 保证事务只能在所有被监视键都没有被修改的前提下执行, 如果这个前提不能满足的话,事务就不会被执行。
watch 执行流程
七 Redis 持久化
Redis 是一种内存型数据库,一旦服务器进程退出,数据库的数据就会丢失,为了解决这个问题 Redis 供了两种持久化的方案,将内存中的数据保存到磁盘中,避免数据的丢失
两种持久化方式:快照(RDB 文件)和追加式文件(AOF 文件),下面分别为大家介绍两种方式的原理。
- RDB 持久化方式会在一个特定的间隔保存那个时间点的数据快照。
- AOF 持久化方式则会记录每一个服务器收到的写操作。在服务启动时,这些记录的操作会逐条执行从而重建出原来的数据。写操作命令记录的格式跟 Redis 协议一致,以追加的方式进行保存。
- Redis 的持久化是可以禁用的,就是说你可以让数据的生命周期只存在于服务器的运行时间里。
两种方式的持久化是可以同时存在的,但是当 Redis 重启时,AOF 文件会被优先用于重建数据。
1 RDB 持久化
RDB 持久化产生的文件是一个经过压缩的二进制文件,这个文件可以被保存到硬盘中,可以通过这个文件还原数据库的状态,它可以手动执行,也可以在 redis.conf 配置文件中配置,定时执行。
工作原理
在进行 RDB 时,redis 的主进程不会做 io 操作,会 fork 一个子进程来完成该操作:
- Redis 调用 forks。同时拥有父进程和子进程。
- 子进程将数据集写入到一个临时 RDB 文件中。
- 当子进程完成对新 RDB 文件的写入时,Redis 用新 RDB 文件替换原来的 RDB 文件,并删除旧的 RDB 文件。
这种工作方式使得 Redis 可以从写时复制(copy-on-write)机制中获益 (因为是使用子进程进行写操作,而父进程依然可以接收来自客户端的请求)
触发机制
在 Redis 中 RDB 持久化的触发分为两种:自己手动触发与自动触发。
主动触发
- save 命令是同步的命令,会占用主进程,会造成阻塞,阻塞所有客户端的请求
- bgsave
bgsave 是异步进行,进行持久化的时候,redis 还可以将继续响应客户端请求
bgsave 和 save 对比
自动触发
1、save 自动触发配置,见下面配置,满足 m 秒内修改 n 次 key,触发 rdb
1 | # 时间策略 save m n m秒内修改n次key,触发rdb |
2、从节点全量复制时,主节点发送 rdb 文件给从节点完成复制操作,主节点会触发 bgsave 命令;
3、执行 flushall 命令,会触发 rdb
4、退出 redis,且没有开启 aof 时
优点:
1、RDB 的内容为二进制的数据,占用内存更小,更紧凑,更适合做为备份文件;
2、RDB 对灾难恢复非常有用,它是一个紧凑的文件,可以更快的传输到远程服务器进行 Redis 服务恢复;
3、RDB 可以更大程度的提高 Redis 的运行速度,因为每次持久化时 Redis 主进程都会 fork () 一个子进程,进行数据持久化到磁盘,Redis 主进程并不会执行磁盘 I/O 等操作;
4、与 AOF 格式的文件相比,RDB 文件可以更快的重启。
缺点:
1、因为 RDB 只能保存某个时间间隔的数据,如果中途 Redis 服务被意外终止了,则会丢失一段时间内的 Redis 数据。
2、RDB 需要经常 fork () 才能使用子进程将其持久化在磁盘上。如果数据集很大,fork () 可能很耗时,并且如果数据集很大且 CPU 性能不佳,则可能导致 Redis 停止为客户端服务几毫秒甚至一秒钟。
2 AOF(Append Only File)
以日志的形式来记录每个写的操作,将 Redis 执行过的所有指令记录下来(读操作不记录),只许追加文件但不可以改写文件,redis 启动之初会读取该文件重新构建数据,换言之,redis 重启的话就根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。
AOF 配置项
1 | # 默认不开启aof 而是使用rdb的方式 |
AOF 的整个流程大体来看可以分为两步,一步是命令的实时写入(如果是 appendfsync everysec 配置,会有 1s 损耗),第二步是对 aof 文件的重写。
AOF 重写机制
随着 Redis 的运行,AOF 的日志会越来越长,如果实例宕机重启,那么重放整个 AOF 将会变得十分耗时,而在日志记录中,又有很多无意义的记录,比如我现在将一个数据 incr 一千次,那么就不需要去记录这 1000 次修改,只需要记录最后的值即可。所以就需要进行 AOF 重写。
Redis 提供了 bgrewriteaof 指令用于对 AOF 日志进行重写,该指令运行时会开辟一个子进程对内存进行遍历,然后将其转换为一系列的 Redis 的操作指令,再序列化到一个日志文件中。完成后再替换原有的 AOF 文件,至此完成。
同样的也可以在 redis.config 中对重写机制的触发进行配置:
通过将 no-appendfsync-on-rewrite 设置为 yes,开启重写机制;auto-aof-rewrite-percentage 100 意为比上次从写后文件大小增长了 100% 再次触发重写;
auto-aof-rewrite-min-size 64mb 意为当文件至少要达到 64mb 才会触发制动重写。
触发方式
- 手动触发:bgrewriteaof
- 自动触发 就是根据配置规则来触发,当然自动触发的整体时间还跟 Redis 的定时任务频率有关系。
优点
1、数据安全,aof 持久化可以配置 appendfsync 属性,有 always,每进行一次 命令操作就记录到 aof 文件中一次。
2、通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。
3、AOF 机制的 rewrite 模式。AOF 文件没被 rewrite 之前(文件过大时会对命令 进行合并重写),可以删除其中的某些命令(比如误操作的 flushall))
缺点
1、AOF 文件比 RDB 文件大,且恢复速度慢。
2、数据集大的时候,比 rdb 启动效率低。
3 rdb 与 aof 对比
八 发布与订阅
redis 发布与订阅是一种消息通信的模式:发送者(pub)发送消息,订阅者(sub)接收消息。
redis 通过 PUBLISH 和 SUBSCRIBE 等命令实现了订阅与发布模式,这个功能提供两种信息机制,分别是订阅 / 发布到频道、订阅 / 发布到模式的客户端。
1 频道(channel)
订阅
发布
完整流程
发布者发布消息
发布者向频道 channel:1 发布消息 hi
1 | 127.0.0.1:6379> publish channel:1 hi |
订阅者订阅消息
1 | 127.0.0.1:6379> subscribe channel:1 |
执行 subscribe 后客户端会进入订阅状态,仅可以使 subscribe、unsubscribe、psubscribe 和 punsubscribe 这四个属于 "发布 / 订阅" 之外的命令
订阅频道后的客户端可能会收到三种消息类型
- subscribe。表示订阅成功的反馈信息。第二个值是订阅成功的频道名称,第三个是当前客户端订阅的频道数量。
- message。表示接收到的消息,第二个值表示产生消息的频道名称,第三个值是消息的内容。
- unsubscribe。表示成功取消订阅某个频道。第二个值是对应的频道名称,第三个值是当前客户端订阅的频道数量,当此值为 0 时客户端会退出订阅状态,之后就可以执行其他非 "发布 / 订阅" 模式的命令了。
数据结构
基于频道的发布订阅模式是通过字典数据类型实现的
1 | struct redisServer { |
其中,字典的键为正在被订阅的频道, 而字典的值则是一个链表, 链表中保存了所有订阅这个频道的客户端。
订阅
当使用 subscribe 订阅时,在字典中找到频道 key(如没有则创建),并将订阅的 client 关联在链表后面。
当 client 10 执行 subscribe channel1 channel2 channel3 时,会将 client 10 分别加到 channel1 channel2 channel3 关联的链表尾部。
发布
发布时,根据 key,找到字典汇总 key 的地址,然后将 msg 发送到关联的链表每一台机器。
退订
遍历关联的链表,将指定的地址删除即可。
2 模式(pattern)
pattern 使用了通配符的方式来订阅
通配符中?表示 1 个占位符,* 表示任意个占位符 (包括 0),?* 表示 1 个以上占位符。
所以当使用 publish 命令发送信息到某个频道时, 不仅所有订阅该频道的客户端会收到信息, 如果有某个 / 某些模式和这个频道匹配的话, 那么所有订阅这个 / 这些频道的客户端也同样会收到信息。
订阅发布完整流程
发布者发布消息
1 | 127.0.0.1:6379> publish b m1 |
订阅者订阅消息
1 | 127.0.0.1:6379> psubscribe b* |
数据结构
pattern 属性是一个链表,链表中保存着所有和模式相关的信息。
1 | struct redisServer { |
数据结构图如下:
订阅
当有信的订阅时,会将订阅的客户端和模式信息添加到链表后面。
发布
当发布者发布消息时,首先会发送到对应的频道上,在遍历模式列表,根据 key 匹配模式,匹配成功将消息发给对应的订阅者。
完成的发布伪代码如下
1 | def PUBLISH(channel, message): |
退订
使用 punsubscribe,可以将订阅者退订,将改客户端移除出链表。
九 主从复制
什么是主从复制
1 | 主从复制,是指将一台Redis服务器的数据,复制到其他的Redis服务器。 |
主从复制的作用主要包括
- 数据冗余:主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
- 故障恢复:当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复;实际上是一种服务的冗余。
- 负载均衡:在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务(即写 Redis 数据时应用连接主节点,读 Redis 数据时应用连接从节点),分担服务器负载;尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高 Redis 服务器的并发量。
- 高可用基石:除了上述作用以外,主从复制还是哨兵和集群能够实施的基础,因此说主从复制是 Redis 高可用的基础。
主从库采用的是读写分离的方式
1 原理
分为全量复制与增量复制
全量复制:发生在第一次复制时
增量复制:只会把主从库网络断连期间主库收到的命令,同步给从库
2 全量复制的三个阶段
第一阶段是主从库间建立连接、协商同步的过程。
主要是为全量复制做准备。从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。
具体来说,从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync 命令包含了主库的 runID 和复制进度 offset 两个参数。runID,是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设为 “?”。offset,此时设为 -1,表示第一次复制。主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。这里有个地方需要注意,FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。
第二阶段,主库将所有数据同步给从库。
从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。
具体来说,主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。
第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。
具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。
十 哨兵机制
哨兵的核心功能是主节点的自动故障转移
下图是一个典型的哨兵集群监控的逻辑图
Redis Sentinel 包含了若个 Sentinel 节点,这样做也带来了两个好处:
- 对于节点的故障判断是由多个 Sentinel 节点共同完成,这样可以有效地防止误判
- 即使个别 Sentinel 节点不可用,整个 Sentinel 集群依然是可用的。
哨兵实现了一下功能
- 监控:每个 Sentinel 节点会对数据节点(Redis master/slave 节点)和其余 Sentinel 节点进行监控
- 通知:Sentinel 节点会将故障转移的结果通知给应用方
- 故障转移:实现 slave 晋升为 master,并维护后续正确的主从关系
- 配置中心:在 Redis Sentinel 模式中,客户端在初始化的时候连接的是 Sentinel 节点集合,从中获取主节点信息
其中,监控和自动故障转移功能,使得哨兵可以及时发现主节点故障并完成转移;而配置中心和通知功能,则需要在与客户端的交互中才能体现。
1 原理
监控
Sentinel 节点需要监控 master、slave 以及其它 Sentinel 节点的状态。这一过程是通过 Redis 的 pub/sub 系统实现的。Redis Sentinel 一共有三个定时监控任务,完成对各个节点发现和监控:
- 监控主从拓扑信息:每隔 10 秒,每个 Sentinel 节点,会向 master 和 slave 发送 INFO 命令获取最新的拓扑结构
- Sentinel 节点信息交换:每隔 2 秒,每个 Sentinel 节点,会向 Redis 数据节点的__sentinel__:hello 频道上,发送自身的信息,以及对主节点的判断信息。这样,Sentinel 节点之间就可以交换信息
- 节点状态监控:每隔 1 秒,每个 Sentinel 节点,会向 master、slave、其余 Sentinel 节点发送 PING 命令做心跳检测,来确认这些节点当前是否可达
主观 / 客观下线
主观下线
每个 Sentinel 节点,每隔 1 秒会对数据节点发送 ping 命令做心跳检测,当这些节点超过 down-after-milliseconds 没有进行有效回复时,Sentinel 节点会对该节点做失败判定,这个行为叫做主观下线。
客观下线
客观下线,是指当大多数 Sentinel 节点,都认为 master 节点宕机了,那么这个判定就是客观的,叫做客观下线。
那么这个大多数是指多少呢?这其实就是分布式协调中的 quorum 判定了,大多数就是过半数,比如哨兵数量是 5,那么大多数就是 5/2+1=3 个,哨兵数量是 10 大多数就是 10/2+1=6 个。
注:Sentinel 节点的数量至少为 3 个,否则不满足 quorum 判定条件。
哨兵选举
如果发生了客观下线,那么哨兵节点会选举出一个 Leader 来进行实际的故障转移工作。Redis 使用了 Raft 算法来实现哨兵领导者选举,大致思路如下:
- 每个 Sentinel 节点都有资格成为领导者,当它主观认为某个数据节点宕机后,会向其他 Sentinel 节点发送 sentinel is-master-down-by-addr 命令,要求自己成为领导者;
- 收到命令的 Sentinel 节点,如果没有同意过其他 Sentinel 节点的 sentinelis-master-down-by-addr 命令,将同意该请求,否则拒绝(每个 Sentinel 节点只有 1 票);
- 如果该 Sentinel 节点发现自己的票数已经大于等于 MAX (quorum, num (sentinels)/2+1),那么它将成为领导者;
- 如果此过程没有选举出领导者,将进入下一次选举。
故障转移
选举出的 Leader Sentinel 节点将负责故障转移,也就是进行 master/slave 节点的主从切换。故障转移,首先要从 slave 节点中筛选出一个作为新的 master,主要考虑以下 slave 信息:
- 跟 master 断开连接的时长:如果一个 slave 跟 master 的断开连接时长已经超过了 down-after-milliseconds 的 10 倍,外加 master 宕机的时长,那么该 slave 就被认为不适合选举为 master;
- slave 的优先级配置:slave priority 参数值越小,优先级就越高;
- 复制 offset:当优先级相同时,哪个 slave 复制了越多的数据(offset 越靠后),优先级越高;
- run id:如果 offset 和优先级都相同,则哪个 slave 的 run id 越小,优先级越高。
接着,筛选完 slave 后, 会对它执行 slaveof no one 命令,让其成为主节点。
最后,Sentinel 领导者节点会向剩余的 slave 节点发送命令,让它们成为新的 master 节点的从节点,复制规则与 parallel-syncs 参数有关。
Sentinel 节点集合会将原来的 master 节点更新为 slave 节点,并保持着对其关注,当其恢复后命令它去复制新的主节点。
注:Leader Sentinel 节点,会从新的 master 节点那里得到一个 configuration epoch,本质是个 version 版本号,每次主从切换的 version 号都必须是唯一的。其他的哨兵都是根据 version 来更新自己的 master 配置。
十一 缓存穿透、击穿、雪崩
1 缓存穿透
- 问题来源
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求。由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能 DB 就挂掉了,要是有人利用不存在的 key 频繁攻击我们的应用,这就是漏洞。
如发起为 id 为 “-1” 的数据或 id 为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
- 解决方案
- 接口层增加校验,如用户鉴权校验,id 做基础校验,id<=0 的直接拦截;
- 从缓存取不到的数据,在数据库中也没有取到,这时也可以将 key-value 对写为 key-null,缓存有效时间可以设置短点,如 30 秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个 id 暴力攻击。
- 布隆过滤器。类似于一个 hash set,用于快速判某个元素是否存在于集合中,其典型的应用场景就是快速判断一个 key 是否存在于某容器,不存在就直接返回。布隆过滤器的关键就在于 hash 算法和容器大小。
2 缓存击穿
- 问题来源
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
- 解决方案
1、设置热点数据永远不过期。
2、接口限流与熔断,降级。重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些服务不可用时候,进行熔断,失败快速返回机制。
3、加互斥锁
3 缓存雪崩
- 问题来源
缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至 down 机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
- 解决方案
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
- 如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中。
- 设置热点数据永远不过期
文章最后
每一项技术深挖都是一个庞大的体系,学海无涯,共勉。
来自:阿里技术