Redis的概述
Redis和关系型数据库
Redis
- Redis是一种使用 C 语言编写的、开源的(BSD许可)、基于键值对的非关系型(NoSQL)高性能数据库。
- Redis的数据都存储于内存中,因此它的读写性能惊人,读写速度可达10万/秒,远超关系型数据库。
Redis将内存中的数据以快照或日志的形式保存到硬盘上,以保证数据的安全性,防止服务器关闭时丢失数据
读写性能优异, Redis能读的速度是110000次/s,写的速度是81000次/s。
- 支持数据持久化,支持AOF和RDB两种持久化方式。
- 支持事务,Redis的所有操作都是原子性的,同时Redis还支持对几个操作合并后的原子性执行。
- 数据结构丰富,除了支持string类型的value外还支持hash、set、zset、list等数据结构。
- 支持主从复制,主机会自动将数据同步到从机,可以进行读写分离。
缺点
- 数据库容量受到物理内存的限制,不能用作海量数据的高性能读写,因此Redis适合的场景主要局限在较小数据量的高性能操作和运算上。
- Redis 不具备自动容错和恢复功能,主机从机的宕机都会导致前端部分读写请求失败,需要等待机器重启或者手动切换前端的IP才能恢复。
- 主机宕机,宕机前有部分数据未能及时同步到从机,切换IP后还会引入数据不一致的问题,降低了系统的可用性。
- Redis 较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂。为避免这一问题,运维人员在系统上线时必须确保有足够的空间,这对资源造成了很大的浪费。
Redis的应用
Redis典型的应用场景包括缓存、点赞、排行榜、热门贴、计数器、社交网络、消息队列等,访问频繁、访问量大、只有一个值建立关系型数据库的表麻烦等情况。
- Redis最常用来做缓存,是实现分布式缓存的首先中间件。Redis 的数据是存在内存中的,所以读写速度快,因此 Redis 被广泛应用于缓存方向,每秒可以处理超过 10万次读写操作,是已知性能最快的Key-Value DB;
- Redis可以作为数据库,实现诸如点赞、关注、排行等对性能要求极高的互联网需求;
- Redis可以作为计算工具,能用很小的代价,统计诸如PV/UV、用户在线天数等数据;
- Redis可以实现分布式锁,经常用来做分布式锁;
- Redis可以作为消息队列使用;
- Redis支持事务 、持久化、LUA脚本、LRU驱动事件、多种集群方案。
为什么要用缓存 / Redis ?
主要从“高性能”和“高并发”这两点来看待这个问题。
高性能:
假如用户第一次访问数据库中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据存在数缓存中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度相当快。如果数据库中的对应数据改变的之后,同步改变缓存中相应的数据即可。
高并发:
直接操作缓存能够承受的请求是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。
为什么要用 Redis 而不用 map/guava 做缓存?
缓存分为本地缓存和分布式缓存。
- 以 Java 为例,使用自带的 map 或者 guava 实现的是本地缓存,最主要的特点是轻量以及快速,生命周期随着 JVM 的销毁而结束,并且在多实例的情况下,每个实例都需要各自保存一份缓存,缓存不具有一致性。
- 使用 Redis 或 Memcached 之类的称为分布式缓存,在多实例的情况下,各实例共用一份缓存数据,缓存具有一致性。缺点是需要保持 redis 或 memcached服务的高可用,整个程序架构上较为复杂。
Redis是单线程的,为什么还能这么快?
Redis的速度非常的快,单机的Redis就可以支撑每秒10几万的并发,性能是Mysql的几十倍。速度快的原因主要有几点:
- 完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。Redis的数据存在内存中,类似于 HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1)。Redis的大部分操作是在内存上完成的,这是它实现高性能的一个重要原因;
- 数据结构简单,对数据操作也简单,Redis 中的数据结构是专门进行设计的。C语言实现,优化过的数据结构,基于几种基础的数据结构,redis做了大量的优化,性能极高。
- 采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗。对服务端程序来说,线程切换和锁通常是性能杀手,而单线程避免了线程切换和竞争所产生的消耗;
- 使用非阻塞的 I/O多路复用模型。Redis采用了IO多路复用机制,使其在网络IO操作中能并发处理大量的客户端请求,实现高吞吐率;
- 使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis 直接自己构建了 VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求;
那为什么Redis6.0之后又改用多线程呢?
redis使用多线程并非是完全摒弃单线程,redis还是使用单线程模型来处理客户端的请求,只是使用多线程来处理数据的读写和协议解析,执行命令还是使用单线程。
这样做的目的是因为redis的性能瓶颈在于网络IO而非CPU,使用多线程能提升IO读写的效率,从而整体提高redis的性能。
Redis在持久化时fork出一个子进程,这时已经有两个进程了,怎么能说是单线程呢?
Redis是单线程的,主要是指Redis的网络IO和键值对读写是由一个线程来完成的。而Redis的其他功能,如持久化、异步删除、集群数据同步等,则是依赖其他线程来执行的。所以,说Redis是单线程的只是一种习惯的说法,事实上它的底层不是单线程的。
Redis的数据结构
Redis有哪些数据类型
Redis是一种基于Key-Value键值对的NoSQL数据库,可以存储键和值之间的映射。key只能是String字符串类型,value支持多种数据结构,常说的Redis的数据结构一般是指value支持的数据结构,包括:
- 五种核心的数据类型:字符串(String)、哈希(Hash)、列表(List)、无序集合(Set)、有序集合(SortedSet);
- 高级数据结构:HyperLogLog、Bitmap、Geo类型,但这些类型都是基于上述核心数据类型实现的;
- Redis5.0新增加了Streams数据类型,它是一个功能强大的、支持多播的、可持久化的消息队列。
字符串:redis没有直接使用C语言传统的字符串表示,而是自己实现的叫做简单动态字符串SDS的抽象类型。C语言的字符串不记录自身的长度信息,而SDS则保存了长度信息,这样将获取字符串长度的时间由O(N)降低到了O(1),同时可以避免缓冲区溢出和减少修改字符串长度时所需的内存重分配次数。
链表linkedlist:redis链表是一个双向无环链表结构,很多发布订阅、慢查询、监视器功能都是使用到了链表来实现,每个链表的节点由一个listNode结构来表示,每个节点都有指向前置节点和后置节点的指针,同时表头节点的前置和后置节点都指向NULL。
字典hashtable:用于保存键值对的抽象数据结构。redis使用hash表作为底层实现,每个字典带有两个hash表,供平时使用和rehash时使用,hash表使用链地址法来解决键冲突,被分配到同一个索引位置的多个键值对会形成一个单向链表,在对hash表进行扩容或者缩容的时候,为了服务的可用性,rehash的过程不是一次性完成的,而是渐进式的。
跳跃表skiplist:跳跃表是有序集合的底层实现之一,redis中在实现有序集合键和集群节点的内部结构中都是用到了跳跃表。redis跳跃表由zskiplist和zskiplistNode组成,zskiplist用于保存跳跃表信息(表头、表尾节点、长度等),zskiplistNode用于表示表跳跃节点,每个跳跃表的层高都是1-32的随机数,在同一个跳跃表中,多个节点可以包含相同的分值,但是每个节点的成员对象必须是唯一的,节点按照分值大小排序,如果分值相同,则按照成员对象的大小排序。
整数集合intset:用于保存整数值的集合抽象数据结构,不会出现重复元素,底层实现为数组。
压缩列表ziplist:压缩列表是为节约内存而开发的顺序性数据结构,他可以包含多个节点,每个节点可以保存一个字节数组或者整数值。基于这些基础的数据结构,redis封装了自己的对象系统,包含字符串对象string、列表对象list、哈希对象hash、集合对象set、有序集合对象zset,每种对象都用到了至少一种基础的数据结构。
redis通过encoding属性设置对象的编码形式来提升灵活性和效率,基于不同的场景redis会自动做出优化。不同对象的编码如下:
字符串对象string:int整数、embstr编码的简单动态字符串、raw简单动态字符串
列表对象list:ziplist、linkedlist
哈希对象hash:ziplist、hashtable
集合对象set:intset、hashtable
有序集合对象zset:ziplist、skiplist
Redis的应用场景补充
总结一
- 计数器:可以对 String 进行自增自减运算,从而实现计数器功能。Redis 这种内存型数据库的读写性能非常高,很适合存储频繁读写的计数量。
- 缓存:将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率。
- 会话缓存:可以使用 Redis 来统一存储多台应用服务器的会话信息。当应用服务器不再存储用户的会话信息,也就不再具有状态,一个用户可以请求任意一个应用服务器,从而更容易实现高可用性以及可伸缩性。
- 全页缓存(FPC):除基本的会话token之外,Redis还提供很简便的FPC平台。以Magento为例,Magento提供一个插件来使用Redis作为全页缓存后端。此外,对WordPress的用户来说,Pantheon有一个非常好的插件 wp-redis,这个插件能帮助你以最快速度加载你曾浏览过的页面。
- 查找表:例如 DNS 记录就很适合使用 Redis 进行存储。查找表和缓存类似,也是利用了 Redis 快速的查找特性。但是查找表的内容不能失效,而缓存的内容可以失效,因为缓存不作为可靠的数据来源。
- 消息队列(发布/订阅功能):List 是一个双向链表,可以通过 lpush 和 rpop 写入和读取消息。不过最好使用 Kafka、RabbitMQ 等消息中间件。
- 分布式锁实现:在分布式场景下,无法使用单机环境下的锁来对多个节点上的进程进行同步。可以使用 Redis 自带的 SETNX 命令实现分布式锁,除此之外,还可以使用官方提供的 RedLock 分布式锁实现。
- Set 可以实现交集、并集等操作,从而实现共同好友等功能。
- ZSet 可以实现有序性操作,从而实现排行榜等功能。
总结二
Redis相比其他缓存,有一个非常大的优势,就是支持多种数据类型。
数据类型说明string字符串,最简单的k-v存储hashhash格式,value为field和value,适合ID-Detail这样的场景。list简单的list,顺序列表,支持首位或者末尾插入数据set无序list,查找速度快,适合交集、并集、差集处理sorted set有序的set
其实,通过上面的数据类型的特性,基本就能想到合适的应用场景了。
- string——适合最简单的k-v存储,类似于memcached的存储结构,短信验证码,配置信息等,就用这种类型来存储。
- hash——一般key为ID或者唯一标示,value对应的就是详情了。如商品详情,个人信息详情,新闻详情等。
- list——因为list是有序的,比较适合存储一些有序且数据相对固定的数据。如省市区表、字典表等。因为list是有序的,适合根据写入的时间来排序,如:最新的*,消息队列等。
- set——可以简单的理解为ID-List的模式,如微博中一个人有哪些好友,set最牛的地方在于,可以对两个set提供交集、并集、差集操作。例如:查找两个人共同的好友等。
- Sorted Set——是set的增强版本,增加了一个score参数,自动会根据score的值进行排序。比较适合类似于top 10等不根据插入的时间来排序的数据。
如上所述,虽然Redis不像关系数据库那么复杂的数据结构,但是,也能适合很多场景,比一般的缓存数据结构要多。了解每种数据结构适合的业务场景,不仅有利于提升开发效率,也能有效利用Redis的性能。
Redis中List结构的相关操作
列表是线性有序的数据结构,它内部的元素是可以重复的,并且一个列表最多能存储2^32-1个元素。列表包含如下的常用命令:
- lpush/rpush:从列表的左侧/右侧添加数据;
- lrange:指定索引范围,并返回这个范围内的数据;
- lindex:返回指定索引处的数据;
- lpop/rpop:从列表的左侧/右侧弹出一个数据;
- blpop/brpop:从列表的左侧/右侧弹出一个数据,若列表为空则进入阻塞状态。
hash类型底层的数据结构
编码方案
哈希对象有两种编码方案,当同时满足以下条件时,哈希对象采用ziplist编码,否则采用hashtable编码:
- 哈希对象保存的键值对数量小于512个;
- 哈希对象保存的所有键值对中的键和值,其字符串长度都小于64字节。
其中,ziplist编码采用压缩列表作为底层实现,而hashtable编码采用字典作为底层实现。
压缩列表 ziplist
压缩列表(ziplist),是Redis为了节约内存而设计的一种线性数据结构,它是由一系列具有特殊编码的连续内存块构成的。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或一个整数值。
压缩列表的结构如下图所示:
该结构当中的字段含义如下表所示:
属性 | 类型 | 长度 | 说明 |
---|---|---|---|
zlbytes | uint32_t | 4字节 | 压缩列表占用的内存字节数; |
zltail | uint32_t | 4字节 | 压缩列表表尾节点距离列表起始地址的偏移量(单位字节); |
zllen | uint16_t | 2字节 | 压缩列表包含的节点数量,等于UINT16_MAX时,需遍历列表计算真实数量; |
entryX | 列表节点 | 不固定 | 压缩列表包含的节点,节点的长度由节点所保存的内容决定; |
zlend | uint8_t | 1字节 | 压缩列表的结尾标识,是一个固定值0xFF; |
其中,压缩列表的节点由以下字段构成:
previous_entry_length(pel)属性以字节为单位,记录当前节点的前一节点的长度,其自身占据1字节或5字节:
- 如果前一节点的长度小于254字节,则pel属性的长度为1字节,前一节点的长度就保存在这一个字节内;
- 如果前一节点的长度达到254字节,则pel属性的长度为5字节,其中第一个字节被设置为0xFE,之后的四个字节用来保存前一节点的长度;
基于 pel 属性,程序便可以通过指针运算,根据当前节点的起始地址计算出前一节点的起始地址,从而实现从表尾向表头的遍历操作。
content属性负责保存节点的值(字节数组或整数),其类型和长度由encoding属性决定,它们的关系如下:
encoding | 长度 | content |
---|---|---|
00 xxxxxx | 1字节 | 最大长度为26 -1的字节数组; |
01 xxxxxx bbbbbbbb | 2字节 | 最大长度为214-1的字节数组; |
10 __ bbbbbbbb … … … | 5字节 | 最大长度为232-1的字节数组; |
11 000000 | 1字节 | int16_t类型的整数; |
11 010000 | 1字节 | int32_t类型的整数; |
11 100000 | 1字节 | int64_t类型的整数; |
11 110000 | 1字节 | 24位有符号整数; |
11 111110 | 1字节 | 8位有符号整数; |
11 11xxxx | 1字节 | 没有content属性,xxxx直接存[0,12]范围的整数值; |
字典 dict
字典(dict)又称为散列表,是一种用来存储键值对的数据结构。C语言没有内置这种数据结构,所以Redis构建了自己的字典实现。
Redis字典的实现主要涉及三个结构体:字典、哈希表、哈希表节点。其中,每个哈希表节点保存一个键值对,每个哈希表由多个哈希表节点构成,而字典则是对哈希表的进一步封装。这三个结构体的关系如下图所示:
其中,dict代表字典,dictht代表哈希表,dictEntry代表哈希表节点。可以看出,dictEntry是一个数组,这很好理解,因为一个哈希表里要包含多个哈希表节点。而dict里包含2个dictht,多出的哈希表用于REHASH。当哈希表保存的键值对数量过多或过少时,需要对哈希表的大小进行扩展或收缩操作,在Redis中,扩展和收缩哈希表是通过REHASH实现的,执行REHASH的大致步骤如下:
- 为字典的ht[1]哈希表分配内存空间:如果执行的是扩展操作,则ht[1]的大小为第1个大于等于ht[0].used*2的2n。如果执行的是收缩操作,则ht[1]的大小为第1个大于等于ht[0].used的2n。
- 将存储在ht[0]中的数据迁移到ht[1]上:重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。
- 将字典的ht[1]哈希表晋升为默认哈希表:迁移完成后,清空ht[0],再交换ht[0]和ht[1]的值,为下一次REHASH做准备。
当满足以下任何一个条件时,程序会自动开始对哈希表执行扩展操作:
- 服务器目前没有执行bgsave或bgrewriteof命令,并且哈希表的负载因子大于等于1;
- 服务器目前正在执行bgsave或bgrewriteof命令,并且哈希表的负载因子大于等于5。
为了避免REHASH对服务器性能造成影响,REHASH操作不是一次性地完成的,而是分多次、渐进式地完成的。渐进式REHASH的详细过程如下:
- 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表;
- 在字典中的索引计数器rehashidx设置为0,表示REHASH操作正式开始;
- 在REHASH期间,每次对字典执行添加、删除、修改、查找操作时,程序除了执行指定的操作外,还会顺带将ht[0]中位于rehashidx上的所有键值对迁移到ht[1]中,再将rehashidx的值加1;
- 随着字典不断被访问,最终在某个时刻,ht[0]上的所有键值对都被迁移到ht[1]上,此时程序将rehashidx属性值设置为-1,标识REHASH操作完成。
REHSH期间,字典同时持有两个哈希表,此时的访问将按照如下原则处理:
- 新添加的键值对,一律被保存到ht[1]中;
- 删除、修改、查找等其他操作,会在两个哈希表上进行,即程序先尝试去ht[0]中访问要操作的数据,若不存在则到ht[1]中访问,再对访问到的数据做相应的处理。
zset类型底层的数据结构
有序集合对象有2种编码方案,当同时满足以下条件时,集合对象采用ziplist编码,否则采用skiplist编码:
- 有序集合保存的元素数量不超过128个;
- 有序集合保存的所有元素的成员长度都小于64字节。
其中,ziplist编码的有序集合采用压缩列表作为底层实现,skiplist编码的有序集合采用zset结构作为底层实现。
其中,zset是一个复合结构,它的内部采用字典和跳跃表来实现,其源码如下。其中,dict字典保存了从成员到分支的映射关系,zskiplist跳跃表则按分值由小到大保存了所有的集合元素。这样,当按照成员来访问有序集合时可以直接从dict中取值,当按照分值的范围访问有序集合时可以直接从zsl中取值,采用了空间换时间的策略以提高访问效率。
typedef struct zset {
dict *dict; // 字典,保存了从成员到分值的映射关系;
zskiplist *zsl; // 跳跃表,按分值由小到大保存所有集合元素;
} zset;
综上,zset对象的底层数据结构包括:压缩列表、字典、跳跃表。
压缩列表:和之前在hash中所述一样。
字典:和之前在hash中所述一样。
跳跃表 zskiplist
跳跃表的查找复杂度为平均O(logN),最坏O(N),效率堪比红黑树,却远比红黑树实现简单。跳跃表是在链表的基础上,通过增加索引来提高查找效率的。
有序链表插入、删除的复杂度为O(1),而查找的复杂度为O(N)。例:若要查找值为60的元素,需要从第1个元素依次向后比较,共需比较6次才行,如下图:
跳跃表是从有序链表中选取部分节点,组成一个新链表,并以此作为原始链表的一级索引。再从一级索引中选取部分节点,组成一个新链表,并以此作为原始链表的二级索引。以此类推,可以有多级索引,如下图:
跳跃表在查找时,优先从高层开始查找,若next节点值大于目标值,或next指针指向NULL,则从当前节点下降一层继续向后查找,这样便可以提高查找的效率了。
跳跃表的实现主要涉及2个结构体:zskiplist、zskiplistNode,它们的关系如下图所示:
其中,蓝色的表格代表zskiplist,红色的表格代表zskiplistNode。zskiplist有指向头尾节点的指针,以及列表的长度,列表中最高的层级。zskiplistNode的头节点是空的,它不存储任何真实的数据,它拥有最高的层级,但这个层级不记录在zskiplist之内。
set和zset的区别
- set:
- 集合中的元素是无序、不可重复的,一个集合最多能存储2^32-1个元素;
- 集合除了支持对元素的增删改查之外,还支持对多个集合取交集、并集、差集。
- zset:
持久化策略
持久化就是把内存的数据写到磁盘中去,防止服务宕机或崩溃了内存数据丢失。
Redis持久化机制
Redis支持RDB持久化、AOF持久化、RDB-AOF混合持久化这三种持久化方式。
RDB 快照(速度快但不能实时)
Redis DataBase,RDB 是 Redis 默认的持久化方式,可以在指定的时间间隔内将内存的数据以快照的形式持久化保存到硬盘中(生成数据集的时间点快照 point-in-time snapshot)。RDB 会对应创建一个经过压缩的二进制文件,文件以 .rdb 结尾,即 RDB 文件,内部存储了各个数据库的键值对数据等信息。
RDB持久化可以手动执行也可以根据配置定期执行,它的作用是将某个时间点上的数据库状态保存到RDB文件中,RDB文件是一个压缩的二进制文件,通过它可以还原某个时刻数据库的状态。由于RDB文件是保存在硬盘上的,所以即使Redis崩溃或者退出,只要RDB文件存在,就可以用它来恢复还原数据库的状态。RDB持久化的触发方式有两种,具体如下:
- 手动触发:通过 SAVE 或 BGSAVE 命令触发 RDB 持久化操作,生成RDB文件;
- SAVE命令执行期间,会阻塞Redis进程(Redis服务器将阻塞),直到RDB文件生成完毕,在进程阻塞期间,Redis不能处理任何命令请求,这显然是不合适的。
- BGSAVE命令是异步版本的SAVE命令,它会fork出一个子进程(Redis服务器进程的子进程),然后由子进程去负责生成RDB文件,父进程还可以继续处理命令请求,不会阻塞进程。BGSAVE命令在创建子进程时会存在短暂的阻塞,之后服务器便可以继续处理其他客户端的请求。总之,BGSAVE命令是针对SAVE阻塞问题做的优化,Redis内部所有涉及RDB的操作都采用BGSAVE的方式,而SAVE命令已经废弃。
- 自动触发:通过配置选项,让服务器在满足指定条件时自动执行BGSAVE命令。注:通过配置文件中的save参数来定义快照的周期。
RDB持久化优点:RDB生成紧凑压缩的二进制文件,体积小,使用该文件恢复数据的速度非常快;
- 只有一个文件 dump.rdb,方便持久化。
- 容灾性好,一个文件可以保存到安全的磁盘。
- 性能最大化,fork 子进程来完成写操作,让主进程继续处理命令,所以是 IO 最大化。使用单独子进程来进行持久化,主进程不会进行任何 IO 操作,保证了 redis 的高性能
- 相对于数据集大时,比 AOF 的启动效率更高。
RDB持久化缺点:BGSAVE每次运行都要执行fork操作创建子进程,属于重量级操作,不宜频繁执行,所以RDB持久化没办法做到实时的持久化。
数据安全性低。RDB 是间隔一段时间进行持久化,如果持久化之间 redis 发生故障,会发生数据丢失。所以这种方式更适合数据要求不严谨的时候。
AOF 机制(实时,安全但空间大)
Append-only file,AOF 解决了数据持久化的实时性,是目前Redis持久化的主流方式。AOF 持久化方式是指将所有的命令行记录以 Redis 命令请求协议的格式完全持久化存储保存为 AOF 文件。AOF 将 Redis 服务器所执行的每次写命令记录到单独的日志文件中,当重启Redis时,会重新执行持久化的日志文件即AOF文件中的命令来恢复数据。AOF是通过保存Redis服务器所执行的写命令来记录数据库状态的。
AOF通过追加、写入、同步三个步骤来实现持久化机制。当AOF持久化处于激活状态,服务器执行完写命令之后,写命令将会被追加append到aof_buf缓冲区的末尾
- 在服务器每结束一个事件循环之前,将会调用flushAppendOnlyFile函数决定是否要将aof_buf的内容保存到AOF文件中,可以通过配置appendfsync来决定。
如果不设置,默认选项将会是everysec,因为always来说虽然最安全(只会丢失一次事件循环的写命令),但是性能较差,而everysec模式只不过会可能丢失1秒钟的数据,而no模式的效率和everysec相仿,但是会丢失上次同步AOF文件之后的所有写命令数据。always ##aof_buf内容写入并同步到AOF文件
everysec ##将aof_buf中内容写入到AOF文件,如果上次同步AOF文件时间距离现在超过1秒,则再次对AOF文件进行同步
no ##将aof_buf内容写入AOF文件,但是并不对AOF文件进行同步,同步时间由操作系统决定
AOF的工作流程包括:命令写入(append)、文件同步(sync)、文件重写(rewrite)、重启加载(load),如下图:
AOF默认不开启,需要修改配置项来启用它:appendonly yes
# 启用AOF appendfilename “appendonly.aof” # 设置文件名
AOF以文本协议格式写入命令,如:*3\r\n$3\r\nset\r\n$5\r\nhello\r\n$5\r\nworld\r\n
文本协议格式具有如下的优点:
- 文本协议具有很好的兼容性;
- 直接采用文本协议格式,可以避免二次处理的开销;
- 文本协议具有可读性,方便直接修改和处理。
AOF持久化的文件同步机制:
为了提高程序的写入性能,现代操作系统会把针对硬盘的多次写操作优化为一次写操作。
- 当程序调用write对文件写入时,系统不会直接把书记写入硬盘,而是先将数据写入内存的缓冲区中;
- 当达到特定的时间周期或缓冲区写满时,系统才会执行flush操作,将缓冲区中的数据冲洗至硬盘中;
这种优化机制虽然提高了性能,但也给程序的写入操作带来了不确定性。
- 对于AOF这样的持久化功能来说,冲洗机制将直接影响AOF持久化的安全性;
- 为了消除上述机制的不确定性,Redis向用户提供了appendfsync选项,来控制系统冲洗AOF的频率;
- Linux的glibc提供了fsync函数,可以将指定文件强制从缓冲区刷到硬盘,上述选项正是基于此函数。
appendfsync选项的取值和含义如下:
AOF优点:与RDB持久化可能丢失大量的数据相比,AOF持久化的安全性要高很多。通过使用everysec选项,用户可以将数据丢失的时间窗口限制在1秒之内。
- 数据安全,AOF 持久化可以配置 appendfsync 属性,有 always,每进行一次命令操作就记录到 AOF 文件中一次。
- 通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。
- AOF 机制的 rewrite 模式。AOF 文件没被 rewrite 之前(文件过大时会对命令进行合并重写),可以删除其中的某些命令(比如误操作的 flushall)
AOF缺点:AOF文件存储的是协议文本,它的体积要比二进制格式的”.rdb”文件大很多。AOF需要通过执行AOF文件中的命令来恢复数据库,其恢复速度比RDB慢很多。AOF在进行重写时也需要创建子进程,在数据库体积较大时将占用大量资源,会导致服务器的短暂阻塞。
- AOF 文件比 RDB 文件大,且恢复速度慢。
- 数据集大的时候,比 RDB 启动效率低。
RDB和APF比较:当两种方式同时开启时,数据恢复Redis会优先选择AOF恢复。
- AOF文件比RDB更新频率高,优先使用AOF还原数据;
- AOF比RDB更安全也更大;
- RDB性能比AOF好;
- 如果两个都配了优先加载AOF;
RDB-AOF混合持久化
Redis从4.0开始引入RDB-AOF混合持久化模式,这种模式是基于AOF持久化构建而来的。用户可以通过配置文件中的“aof-use-rdb-preamble yes”配置项开启AOF混合持久化。Redis服务器在执行AOF重写操作时,会按照如下原则处理数据:
- 像执行BGSAVE命令一样,根据数据库当前的状态生成相应的RDB数据,并将其写入AOF文件中;
- 对于重写之后执行的Redis命令,则以协议文本的方式追加到AOF文件的末尾,即RDB数据之后。
通过使用RDB-AOF混合持久化,用户可以同时获得RDB持久化和AOF持久化的优点,服务器既可以通过AOF文件包含的RDB数据来实现快速的数据恢复操作,又可以通过AOF文件包含的AOF数据来将丢失数据的时间窗口限制在1s之内。
如何选择合适的持久化方式
- 一般来说, 如果想达到足以媲美PostgreSQL的数据安全性,你应该同时使用两种持久化功能。在这种情况下,当 Redis 重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整。
- 如果你非常关心你的数据, 但仍然可以承受数分钟以内的数据丢失,那么你可以只使用RDB持久化。
- 有很多用户都只使用AOF持久化,但并不推荐这种方式,因为定时生成RDB快照(snapshot)非常便于进行数据库备份, 并且 RDB 恢复数据集的速度也要比AOF恢复的速度要快,除此之外,使用RDB还可以避免AOF程序的bug。
如果你只希望你的数据在服务器运行的时候存在,你也可以不使用任何持久化方式。
Redis持久化数据和缓存怎么做扩容?
如果Redis被当做缓存使用,使用一致性哈希实现动态扩容缩容。
- 如果Redis被当做一个持久化存储使用,必须使用固定的keys-to-nodes映射关系,节点的数量一旦确定不能变化。否则的话(即Redis节点需要动态变化的情况),必须使用可以在运行时进行数据再平衡的一套系统,而当前只有Redis集群可以做到这样。
Redis为什么存的快,内存断电数据怎么恢复?
Redis存的快是因为它的数据都存放在内存里,并且为了保证数据的安全性,Redis还提供了三种数据的持久化机制,即RDB持久化、AOF持久化、RDB-AOF混合持久化。若服务器断电,那么我们可以利用持久化文件,对数据进行恢复。理论上来说,AOF/RDB-AOF持久化可以将丢失数据的窗口控制在1S之内。缓存策略
Redis的过期键的删除策略 / Redis的过期策略有哪些?
Redis是key-value数据库,我们可以设置Redis中缓存的key的过期时间。Redis的过期策略就是指当Redis中缓存的key过期了,Redis如何处理或如何删除。
过期策略通常有以下三种:
定时过期:每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间到达时,立即执行对键的删除操作。主动删除。
惰性过期:只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
当查询key的时候,Redis才检查它的过期时间,如果已经达到过期时间,则立刻删除这个key。显然,有一个缺点就是如果这些过期的key没有被访问,那么就一直无法被删除,而且一直占用内存。被动删除。
定期过期:每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。expires字典会保存所有设置了过期时间的key的过期时间数据,其中,key是指向键空间中的某个键的指针,value是该键的毫秒精度的UNIX时间戳表示的过期时间。键空间是指该Redis集群中保存的所有键。
Redis每隔一段时间对数据库做一次过期检查,删除里面的过期key。主动删除。 由于不可能对所有key去做轮询来删除,所以Redis会每次随机取一些key去做检查和删除。
Redis会将设置了过期时间的key放到一个独立的字典中,并对该字典进行每秒10次的过期扫描, 过期扫描不会遍历字典中所有的key,而是采用了一种简单的贪心策略。该策略的删除逻辑如下:
- 从过期字典中随机选择20个key;
- 删除这20个key中已过期的key;
- 如果已过期key的比例超过25%,则重复步骤1
采用对内存和CPU时间折中的方法,每隔一段时间执行一次删除过期键操作,并通过限制操作执行的时长和频率来减少对CPU时间的影响。难点在于,选择一个好的策略来设置删除操作的时长和执行频率。
Redis key的过期时间和永久有效分别怎么设置?
通过 expire和 persist 命令来设置 key 的过期时间。
通过expire来设置 key 的过期时间,那么对过期的数据怎么处理呢?
除了缓存服务器自带的缓存失效策略之外(Redis默认的有6中策略可供选择),我们还可以根据具体的业务需求进行自定义的缓存淘汰,常见的策略有两种:
- 定时去清理过期的缓存;
- 当有用户请求过来时,再判断这个请求所用到的缓存是否过期,过期的话就去底层系统得到新数据并更新缓存。
两者各有优劣,第一种的缺点是维护大量缓存的key是比较麻烦的,第二种的缺点就是每次用户请求过来都要判断缓存失效,逻辑相对比较复杂!具体用哪种方案,大家可以根据自己的应用场景来权衡。
定期+惰性都没有删除过期的key怎么办?
假设redis每次定期随机查询key的时候没有删掉,这些key也没有做查询的话,就会导致这些key一直保存在redis里面无法被删除,这时候就会走到redis的内存淘汰机制。
volatile-lru:从已设置过期时间的key中,移出最近最少使用的key进行淘汰volatile-ttl:从已设置过期时间的key中,移出将要过期的keyvolatile-random:从已设置过期时间的key中随机选择key淘汰allkeys-lru:从key中选择最近最少使用的进行淘汰allkeys-random:从key中随机选择key进行淘汰noeviction:当内存达到阈值的时候,新写入操作报错
如何设计Redis的过期时间?
- 热点数据不设置过期时间,使其达到“物理”上的永不过期,可以避免缓存击穿问题;
在设置过期时间时,可以附加一个随机数,避免大量的key同时过期,导致缓存雪崩。
Redis回收使用的是什么算法 / Redis内存淘汰机制
Redis回收:LRU算法
Redis内存淘汰机制:LRU,map+双链表 ```java public class LRUCache { class DLinkedNode {int key;
int value;
DLinkedNode prev;
DLinkedNode next;
public DLinkedNode() {}
public DLinkedNode(int _key, int _value) {key = _key; value = _value;}
}
private Map
cache = new HashMap (); private int size; private int capacity; private DLinkedNode head, tail; public LRUCache(int capacity) {
this.size = 0;
this.capacity = capacity;
// 使用伪头部和伪尾部节点
head = new DLinkedNode();
tail = new DLinkedNode();
head.next = tail;
tail.prev = head;
}
public int get(int key) {
DLinkedNode node = cache.get(key);
if (node == null) {
return -1;
}
// 如果 key 存在,先通过哈希表定位,再移到头部
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
DLinkedNode node = cache.get(key);
if (node == null) {
// 如果 key 不存在,创建一个新的节点
DLinkedNode newNode = new DLinkedNode(key, value);
// 添加进哈希表
cache.put(key, newNode);
// 添加至双向链表的头部
addToHead(newNode);
++size;
if (size > capacity) {
// 如果超出容量,删除双向链表的尾部节点
DLinkedNode tail = removeTail();
// 删除哈希表中对应的项
cache.remove(tail.key);
--size;
}
}
else {
// 如果 key 存在,先通过哈希表定位,再修改 value,并移到头部
node.value = value;
moveToHead(node);
}
}
private void addToHead(DLinkedNode node) {//连接到头的后面
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
private void removeNode(DLinkedNode node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void moveToHead(DLinkedNode node) {
removeNode(node);//去掉该节点
addToHead(node);//将该节点加到头上
}
private DLinkedNode removeTail() {
DLinkedNode res = tail.prev;
removeNode(res);
return res;
} }
<a name="jPdrX"></a>
## Redis的缓存淘汰策略
当写入数据将导致超出maxmemory限制时,Redis会采用maxmemory-policy所指定的策略进行数据淘汰,该策略一共包含如下8种选项:
| **策略** | **描述** | **版本** |
| --- | --- | --- |
| noeviction | 直接返回错误; | |
| volatile-ttl | 从设置了过期时间的键中,选择过期时间最小的键,进行淘汰; | |
| volatile-random | 从设置了过期时间的键中,随机选择键,进行淘汰; | |
| volatile-lru | 从设置了过期时间的键中,使用LRU算法选择键,进行淘汰; | |
| volatile-lfu | 从设置了过期时间的键中,使用LFU算法选择键,进行淘汰; | 4.0 |
| allleys-random | 从所有的键中,随机选择键,进行淘汰; | |
| allkeys-lru | 从所有的键中,使用LRU算法选择键,进行淘汰; | |
| allkeys-lfu | 从所有的键中,使用LFU算法选择键,进行淘汰; | 4.0 |
其中,volatile前缀代表从设置了过期时间的键中淘汰数据,allkeys前缀代表从所有的键中淘汰数据。关于后缀,ttl代表选择过期时间最小的键,random代表随机选择键,需要我们额外关注的是lru和lfu后缀,它们分别代表采用lru算法和lfu算法来淘汰数据。
**LRU**(Least Recently Used)算法:是按照最近最少使用原则来筛选数据,即最不常用的数据会被筛选出来。
- 标准LRU:把所有的数据组成一个链表,表头和表尾分别表示MRU和LRU端,即最常使用端和最少使用端。刚被访问的数据会被移动到MRU端,而新增的数据也是刚被访问的数据,也会被移动到MRU端。当链表的空间被占满时,它会删除LRU端的数据。
- 近似LRU:Redis会记录每个数据的最近一次访问的时间戳(LRU)。Redis执行写入操作时,若发现内存超出maxmemory,就会执行一次近似LRU淘汰算法。近似LRU会随机采样N个key,然后淘汰掉最旧的key,若淘汰后内存依然超出限制,则继续采样淘汰。可以通过maxmemory_samples配置项,设置近似LRU每次采样的数据个数,该配置项的默认值为5。
LRU算法的不足之处在于,若一个key很少被访问,只是刚刚偶尔被访问了一次,则它就被认为是热点数据,短时间内不会被淘汰。
**LFU**(Least Frequently Used)算法:是Redis4新增的淘汰策略,正式用于解决上述LRU算法的问题,它根据key的最近访问频率进行淘汰。LFU在LRU的基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用LFU策略淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出内存。如果两个数据的访问次数相同,LFU再比较这两个数据的访问时间,把访问时间更早的数据淘汰出内存。
<a name="KefLI"></a>
# 缓存异常
<a name="HkJxi"></a>
## 缓存穿透
查询缓存中不存在的数据,导致所有的请求都落到数据库上,就像没有缓存一样,造成数据库短时间内承受大量请求而崩掉。<br />**解决方案**
1. 接口层增加校验:如用户鉴权校验,id做基础校验,id<=0的直接拦截;
1. 缓存空对象:数据库也未命中后,将空值存入缓存中,客户端再次访问数据时,缓存会直接返回空值;
> 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用),这样可以防止攻击用户反复用同一个id暴力攻击。
3. 采用布隆过滤器:将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力;
> 加一层布隆过滤器。
> 布隆过滤器的原理是在存入数据的时候,会通过散列函数将它映射为一个位数组中的K个点,同时把它们置为1。这样当用户再次来查询A,而A在布隆过滤器值为0,直接返回,就不会产生击穿请求打到DB了。
> 显然,使用布隆过滤器之后会有一个问题就是误判,因为它本身是一个数组,可能会有多个值落到同一个位置,那么理论上来说只要数组长度够长,误判的概率就会越低,这种问题就根据实际情况来就好。
**附加**<br />对于空间的利用到达了一种极致,那就是Bitmap和布隆过滤器(Bloom Filter)。<br />**Bitmap**: 典型的就是哈希表<br />缺点是,Bitmap对于每个元素只能记录1bit信息,如果还想完成额外的功能,恐怕只能靠牺牲更多的空间、时间来完成了。<br />**布隆过滤器**:推荐,详见后面的讲解。<br />就是引入了k(k>1)k(k>1)个相互独立的哈希函数,保证在给定的空间、误判率下,完成元素判重的过程。<br />它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。<br />Bloom-Filter算法的核心思想就是利用多个不同的Hash函数来解决“冲突”。<br />Hash存在一个冲突(碰撞)的问题,用同一个Hash得到的两个URL的值有可能相同。为了减少冲突,我们可以多引入几个Hash,如果通过其中的一个Hash值我们得出某元素不在集合中,那么该元素肯定不在集合中。只有在所有的Hash函数告诉我们该元素在集合中时,才能确定该元素存在于集合中。这便是Bloom-Filter的基本思想。<br />Bloom-Filter一般用于在大数据量的集合中判定某元素是否存在。
<a name="eQsv8"></a>
## 缓存雪崩
缓存同一时间大面积的失效,所以后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。
> 当某一时刻发生大规模的缓存失效,比如缓存服务宕机、缓存中有大量的缓存数据同时都过期失效、Redis节点发生故障等情况,缓存层无法继续提供服务,会有大量的请求进来直达DB上,造成数据库宕机,这样可能导致整个系统的崩溃,称为雪崩。
**解决方案**
1. 避免数据同时过期:缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
> 针对不同key设置不同的过期时间,避免同时过期限流。
> 如果Redis宕机,可以限流,避免同时刻大量请求打崩DB二级缓存,同热key的方案。
2. 一般并发量不是特别多的时候,使用最多的解决方案是加锁排队。
2. 给每一个缓存数据增加相应的缓存标记,记录缓存的是否失效,如果缓存标记失效,则更新数据缓存。
2. 启用降级和熔断措施:在发生雪崩时,若应用访问的不是核心数据,则直接返回预定义信息/空值/错误信息。或者在发生雪崩时,对于访问缓存接口的请求,客户端并不会把请求发给Redis,而是直接返回空值。
2. 构建高可用的Redis服务:采用哨兵或集群模式,部署多个Redis实例,个别节点宕机,依然可以保持服务的整体可用。
<a name="DqjiD"></a>
## 缓存击穿
缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。和缓存雪崩不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
> 单个key并发访问过高,过期时导致所有请求直接打到DB上(一份热点数据,它的访问量非常大,在其缓存失效的瞬间,大量请求直达存储层,导致服务崩溃)。这个和热key的问题比较类似,区别在于过期导致请求全部打到DB上。
**解决方案**
1. 设置热点数据永远不过期:热点数据不设置过期时间,所以不会出现上述问题,这是“物理”上的永不过期。或者为每个数据设置逻辑过期时间,当发现该数据逻辑过期时,使用单独的线程重建缓存。
1. 加互斥锁:对数据的访问加互斥锁,当一个线程访问该数据时,其他线程只能等待。这个线程访问过后,缓存中的数据将被重建,届时其他线程就可以直接从缓存中取值。
> 加锁更新,比如请求查询A,发现缓存中没有,对A这个key加锁,同时去数据库查询数据,写入缓存,再返回给用户,这样后面的请求就可以从缓存中拿到数据了。将过期时间组合写在value中,通过异步的方式不断的刷新过期时间,防止此类现象。
<a name="iH9qQ"></a>
## 缓存预热
**缓存预热**就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!<br />**解决方案**
1. 直接写个缓存刷新页面,上线时手工操作一下;
1. 数据量不大,可以在项目启动的时候自动进行加载;
1. 定时刷新缓存;
<a name="pipot"></a>
## 缓存降级
当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。<br />**缓存降级**的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。<br />在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅;从而梳理出哪些必须誓死保护,哪些可降级;比如可以参考日志级别设置预案:
1. 一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
1. 警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
1. 错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
1. 严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。
服务降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。
<a name="dLxLr"></a>
## 热点数据和冷数据
热点数据,缓存才有价值<br />对于冷数据而言,大部分数据可能还没有再次访问到就已经被挤出内存,不仅占用内存,而且价值不大。频繁修改的数据,看情况考虑使用缓存<br />对于热点数据,比如我们的某IM产品,生日祝福模块,当天的寿星列表,缓存以后可能读取数十万次。再举个例子,某导航产品,我们将导航信息,缓存以后可能读取数百万次。<br />数据更新前至少读取两次,缓存才有意义。这个是最基本的策略,如果缓存还没有起作用就失效了,那就没有太大价值了。<br />那存不存在,修改频率很高,但是又不得不考虑缓存的场景呢?有!比如,这个读取接口对数据库的压力很大,但是又是热点数据,这个时候就需要考虑通过缓存手段,减少数据库的压力,比如我们的某助手产品的,点赞数,收藏数,分享数等是非常典型的热点数据,但是又不断变化,此时就需要将数据同步保存到Redis缓存,减少数据库压力。
<a name="PTNjy"></a>
## 缓存热点key
缓存中的一个Key(比如一个促销商品),在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。<br />**解决方案**<br />对缓存查询加锁,如果KEY不存在,就加锁,然后查DB入缓存,然后解锁;其他进程如果发现有锁就等待,然后等解锁后返回数据或者进入DB查询
<a name="UycxI"></a>
## 知道什么是热key吗?热key问题怎么解决?
所谓热key问题就是,突然有几十万的请求去访问redis上的某个特定key,那么这样会造成流量过于集中,达到物理网卡上限,从而导致这台redis的服务器宕机**引发雪崩**。<br />**针对热key的解决方案:**
- 提前把热key打散到不同的服务器,降低压力
- 加入二级缓存,提前加载热key数据到内存中,如果redis宕机,走内存查询
<a name="ryXzJ"></a>
## 布隆过滤器
布隆过滤器可以用很低的代价,估算出数据是否真实存在。例如:给用户推荐新闻时,要去掉重复的新闻,就可以利用布隆过滤器,判断该新闻是否已经推荐过。<br />布隆过滤器的**核心**包括两部分:
1. 一个大型的位数组;
1. 若干个不一样的哈希函数,每个哈希函数都能将哈希值算的比较均匀。
布隆过滤器的**工作原理**:
1. 添加key时,每个哈希函数都利用这个key计算出一个哈希值,再根据哈希值计算一个位置,并将位数组中这个位置的值设置为1。
1. 询问key时,每个哈希函数都利用这个key计算出一个哈希值,再根据哈希值计算一个位置。然后对比这些哈希函数在位数组中对应位置的数值:
- 如果这几个位置中,有一个位置的值是0,就说明这个布隆过滤器中,一定不存在这个key。
- 如果这几个位置中,所有位置的值都是1,就说明这个布隆过滤器中,极有可能存在这个key。之所以不是百分之百确定,是因为也可能是其他的key运算导致该位置为1。
**什么是布隆过滤器**<br />本质上布隆过滤器是一种数据结构,比较巧妙的概率型数据结构(probabilistic data structure),特点是高效地插入和查询,可以用来告诉你 **“某样东西一定不存在或者可能存在”**。<br />相比于传统的 List、Set、Map 等数据结构,它更高效、占用空间更少,但是缺点是其返回的结果是概率性的,而不是确切的。
**实现原理**<br />**(1)HashMap 的问题**<br />讲述布隆过滤器的原理之前,我们先思考一下,通常你判断某个元素是否存在用的是什么?应该蛮多人回答 HashMap 吧,确实可以将值映射到 HashMap 的 Key,然后可以在 O(1) 的时间复杂度内返回结果,效率奇高。但是 HashMap 的实现也有缺点,例如存储容量占比高,考虑到负载因子的存在,通常空间是不能被用满的,而一旦你的值很多例如上亿的时候,那 HashMap 占据的内存大小就变得很可观了。<br />还比如说你的数据集存储在远程服务器上,本地服务接受输入,而数据集非常大不可能一次性读进内存构建 HashMap 的时候,也会存在问题。<br />**(2)布隆过滤器数据结构**<br />布隆过滤器是一个 bit 向量或者说 bit 数组,长这样:<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/26064559/1649234463945-5e553fa1-330e-46cd-a8ac-f5087ca50441.png#clientId=u3dae9511-085e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=qZapY&name=image.png&originHeight=272&originWidth=1079&originalType=url&ratio=1&rotation=0&showTitle=false&size=66391&status=done&style=none&taskId=ucb512a95-8b50-427e-b74d-4373c2334e3&title=)<br />如果我们要映射一个值到布隆过滤器中,我们需要使用**多个不同的哈希函数**生成**多个哈希值,**并对每个生成的哈希值指向的 bit 位置 1,例如针对值 “baidu” 和三个不同的哈希函数分别生成了哈希值 1、4、7,则上图转变为:<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/26064559/1649234464046-0b2f0cf0-082c-4065-8cda-b442941daec1.png#clientId=u3dae9511-085e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=BbOES&name=image.png&originHeight=506&originWidth=1047&originalType=url&ratio=1&rotation=0&showTitle=false&size=135085&status=done&style=none&taskId=u6ce353d0-a5f0-4240-9d3c-fcf2006bf30&title=)<br />Ok,我们现在再存一个值 “tencent”,如果哈希函数返回 3、4、8 的话,图继续变为:<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/26064559/1649234464131-d574c256-90e2-4426-b1de-64f04c25e7c5.png#clientId=u3dae9511-085e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=yf1px&name=image.png&originHeight=545&originWidth=1056&originalType=url&ratio=1&rotation=0&showTitle=false&size=137917&status=done&style=none&taskId=uc1d0e971-1795-4ae2-a8a8-e022e8ac4ab&title=)<br />值得注意的是,4 这个 bit 位由于两个值的哈希函数都返回了这个 bit 位,因此它被覆盖了。现在我们如果想查询 “dianping” 这个值是否存在,哈希函数返回了 1、5、8三个值,结果我们发现 5 这个 bit 位上的值为 0,**说明没有任何一个值映射到这个 bit 位上**,因此我们可以很确定地说 “dianping” 这个值不存在。而当我们需要查询 “baidu” 这个值是否存在的话,那么哈希函数必然会返回 1、4、7,然后我们检查发现这三个 bit 位上的值均为 1,那么我们可以说 “baidu” **存在了么?答案是不可以,只能是 “baidu” 这个值可能存在。**<br />这是为什么呢?答案很简单,因为随着增加的值越来越多,被置为 1 的 bit 位也会越来越多,这样某个值 “taobao” 即使没有被存储过,但是万一哈希函数返回的三个 bit 位都被其他值置位了 1 ,那么程序还是会判断 “taobao” 这个值存在。
**支持删除么**<br />**传统的布隆过滤器并不支持删除操作**。但是名为 Counting Bloom filter 的变种可以用来测试元素计数个数是否绝对小于某个阈值,它支持元素删除。
**如何选择哈希函数个数和布隆过滤器长度**<br />很显然,过小的布隆过滤器很快所有的 bit 位均为 1,那么查询任何值都会返回“可能存在”,起不到过滤的目的了。布隆过滤器的长度会直接影响误报率,布隆过滤器越长其误报率越小。<br />另外,哈希函数的个数也需要权衡,个数越多则布隆过滤器 bit 位置位 1 的速度越快,且布隆过滤器的效率越低;但是如果太少的话,那我们的误报率会变高。<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/26064559/1649234464056-9e1b82da-3523-42ce-8909-ec0f992f07b0.png#clientId=u3dae9511-085e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=xpJcO&name=image.png&originHeight=456&originWidth=800&originalType=url&ratio=1&rotation=0&showTitle=false&size=121163&status=done&style=none&taskId=u0958ed8c-e5d1-451d-879e-cdb7615c782&title=)k 为哈希函数个数,m 为布隆过滤器长度,n 为插入的元素个数,p 为误报率<br />如何选择适合业务的 k 和 m 值呢,这里直接贴一个公式:<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/26064559/1649234463874-63102862-cb0d-4db8-b039-a4d15132dfd4.png#clientId=u3dae9511-085e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=jePlG&name=image.png&originHeight=280&originWidth=349&originalType=url&ratio=1&rotation=0&showTitle=false&size=46391&status=done&style=none&taskId=ub7b6f366-d0e6-434b-8e27-1e954169bb4&title=)<br />如何推导这个公式这里只是提一句,因为对于使用来说并没有太大的意义,你让一个高中生来推会推得很快。k 次哈希函数某一 bit 位未被置为 1 的概率为:<br />![](https://cdn.nlark.com/yuque/0/2022/svg/26064559/1649234464567-ed465180-bd46-4aa5-bf28-a4c1a95ed478.svg#clientId=u3dae9511-085e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=Sw154&originHeight=47&originWidth=88&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=u4a3e1bce-e515-400f-8e8b-7632a57aaea&title=)<br />插入n个元素后依旧为 0 的概率和为 1 的概率分别是:<br />![](https://cdn.nlark.com/yuque/0/2022/svg/26064559/1649234465109-a94ccda9-15e0-465c-9299-26b6044c7f10.svg#clientId=u3dae9511-085e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=AsO4P&originHeight=60&originWidth=111&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=ue1e3495a-b7ab-47cc-804f-f45b5c41899&title=)![](https://cdn.nlark.com/yuque/0/2022/svg/26064559/1649234465171-880ae412-8560-4cb0-a8a4-f94f7a407f67.svg#clientId=u3dae9511-085e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=ZZQ0y&originHeight=60&originWidth=147&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=u0dac0fde-5fc8-4824-a59a-80bea72ce5f&title=)<br />标明某个元素是否在集合中所需的 k 个位置都按照如上的方法设置为 1,但是该方法可能会使算法错误的认为某一原本不在集合中的元素却被检测为在该集合中(False Positives),该概率由以下公式确定<br />![](https://cdn.nlark.com/yuque/0/2022/svg/26064559/1649234465132-5938d04c-27f1-4654-9535-c3c3dd970b65.svg#clientId=u3dae9511-085e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=UqVtK&originHeight=72&originWidth=341&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=u498ff40d-4370-4884-956a-dbc8929ee27&title=)
**最佳实践**<br />常见的适用常见有,利用布隆过滤器减少磁盘 IO 或者网络请求,因为一旦一个值必定不存在的话,我们可以不用进行后续昂贵的查询请求。<br />另外,既然你使用布隆过滤器来加速查找和判断是否存在,那么性能很低的哈希函数不是个好选择,推荐 MurmurHash、Fnv 这些。
**大Value拆分**<br />Redis 因其支持 setbit 和 getbit 操作,且纯内存性能高等特点,因此天然就可以作为布隆过滤器来使用。但是布隆过滤器的不当使用极易产生大 Value,增加 Redis 阻塞风险,因此生成环境中建议对体积庞大的布隆过滤器进行拆分。<br />拆分的形式方法多种多样,但是本质是不要将 Hash(Key) 之后的请求分散在多个节点的多个小 bitmap 上,而是应该拆分成多个小 bitmap 之后,对一个 Key 的所有哈希函数都落在这一个小 bitmap 上。
<a name="uUrtl"></a>
# 集群
<a name="B15Ai"></a>
## 如何实现Redis的高可用?
要想实现高可用,一台机器肯定是不够的,而Redis要保证高可用,有2个可选方案。
<a name="R7E6i"></a>
### 主从架构
主从模式是最简单的实现高可用的方案,核心就是主从同步。主从同步的原理如下:
1. slave(从动装置)发送sync命令(数据同步写入磁盘:sync)到master
1. master(主动装置)收到sync之后,执行bgsave,生成RDB全量文件(持久化)
1. master把slave的写命令记录到缓存
1. bgsave执行完毕之后,发送RDB文件到slave,slave执行
1. master发送缓存中的写命令到slave,slave执行
![image.png](https://cdn.nlark.com/yuque/0/2022/png/26064559/1649236248931-67e4b0d0-2735-44e5-ba0e-bad5558c6690.png#clientId=uf08dd082-5fb4-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=u25b3a849&name=image.png&originHeight=538&originWidth=842&originalType=url&ratio=1&rotation=0&showTitle=false&size=88365&status=done&style=none&taskId=u2aed0095-60c5-4c89-bf97-e4392497802&title=)<br />这里我写的这个命令是sync,但是在redis2.8版本之后已经使用psync来替代sync了,原因是sync命令非常消耗系统资源,而psync的效率更高。
<a name="dlQxk"></a>
### 哨兵
基于主从方案的缺点还是很明显的,假设master宕机,那么就不能写入数据,那么slave也就失去了作用,整个架构就不可用了,除非你手动切换,主要原因就是因为没有自动故障转移机制。而哨兵(sentinel)的功能比单纯的主从架构全面的多了,它具备自动故障转移、集群监控、消息通知等功能。<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/26064559/1649236249243-c6bed20a-5768-41f0-ab8d-6cc6413aa64d.png#clientId=uf08dd082-5fb4-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=u627c2243&name=image.png&originHeight=680&originWidth=886&originalType=url&ratio=1&rotation=0&showTitle=false&size=170888&status=done&style=none&taskId=u919365b5-333f-42fa-874b-05ec20df919&title=)<br />哨兵可以同时监视多个主从服务器,并且在被监视的master下线时,自动将某个slave提升为master,然后由新的master继续接收命令。整个过程如下:
1. 初始化sentinel,将普通的redis代码替换成sentinel专用代码
1. 初始化masters字典和服务器信息,服务器信息主要保存ip:port,并记录实例的地址和ID
1. 创建和master的两个连接,命令连接和订阅连接,并且订阅sentinel:hello频道
1. 每隔10秒向master发送info命令,获取master和它下面所有slave的当前信息
1. 当发现master有新的slave之后,sentinel和新的slave同样建立两个连接,同时每个10秒发送info命令,更新master信息
1. sentinel每隔1秒向所有服务器发送ping命令,如果某台服务器在配置的响应时间内连续返回无效回复,将会被标记为下线状态
1. 选举出领头sentinel,领头sentinel需要半数以上的sentinel同意
1. 领头sentinel从已下线的的master所有slave中挑选一个,将其转换为master
1. 让所有的slave改为从新的master复制数据
1. 将原来的master设置为新的master的从服务器,当原来master重新回复连接时,就变成了新master的从服务器
sentinel会每隔1秒向所有实例(包括主从服务器和其他sentinel)发送ping命令,并且根据回复判断是否已经下线,这种方式叫做主观下线。当判断为主观下线时,就会向其他监视的sentinel询问,如果超过半数的投票认为已经是下线状态,则会标记为客观下线状态,同时触发故障转移。
依靠哨兵可以实现redis的高可用,如果还想在支持高并发同时容纳海量的数据,那就需要Redis集群。
<a name="XMC1j"></a>
## 多台Redis抗高并发访问该怎么设计 / 如果并发量超过30万,怎么设计Redis架构?
Redis Cluster 集群是Redis的分布式解决方案,在3.0版本正式推出,有效地解决了Redis分布式方面的需求。当遇到单机内存、并发、流量等瓶颈时,可以采用Cluster架构方案达到负载均衡的目的。
<a name="rVg19"></a>
## Redis哨兵
Redis Sentinel 哨兵是一个分布式架构,它包含若干个哨兵节点和数据节点。每个哨兵节点会对数据节点和其余的哨兵节点进行监控,当发现节点不可达时,会对节点做下线标识。如果被标识的是主节点,它就会与其他的哨兵节点进行协商,当多数哨兵节点都认为主节点不可达时,它们便会选举出一个哨兵节点来完成自动故障转移的工作,同时还会将这个变化实时地通知给应用方。整个过程是自动的,不需要人工介入,有效地解决了Redis的高可用问题!<br />一组哨兵可以监控一个主节点,也可以同时监控多个主节点,两种情况的拓扑结构如下图:<br />![](https://cdn.nlark.com/yuque/0/2022/png/26064559/1649232761898-eecf9bbc-5d88-4a84-8637-6bc5d5bb00de.png#clientId=ud44c8a2f-317e-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=u10bce99c&originHeight=557&originWidth=1932&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=u9190682d-02b5-4076-accf-a1ad4baa39d&title=)<br />哨兵节点包含如下的特征:
1. 哨兵节点会定期监控数据节点,其他哨兵节点是否可达;
1. 哨兵节点会将故障转移的结果通知给应用方;
1. 哨兵节点可以将从节点晋升为主节点,并维护后续正确的主从关系;
1. 哨兵模式下,客户端连接的是哨兵节点集合,从中获取主节点信息;
1. 节点的故障判断是由多个哨兵节点共同完成的,可有效地防止误判;
1. 哨兵节点集合是由多个哨兵节点组成的,即使个别哨兵节点不可用,整个集合依然是健壮的;
1. 哨兵节点也是独立的Redis节点,是特殊的Redis节点,它们不存储数据,只支持部分命令。
<a name="qewKc"></a>
## Redis集群的原理
Redis集群是Redis提供的分布式数据存储方案,集群通过数据分片sharding来进行数据的共享,同时提供复制和故障转移的功能。
**节点**<br />一个redis集群由多个节点node组成,而多个node之间通过cluster meet命令来进行连接,节点的握手过程:
1. 节点A收到客户端的cluster meet命令
1. A根据收到的**IP地址和端口号**,向B发送一条meet消息
1. 节点B收到meet消息返回pong
1. A知道B收到了meet消息,返回一条ping消息,握手成功
1. 最后,节点A将会通过**gossip协议**把节点B的信息传播给集群中的其他节点,其他节点也将和B进行握手
![image.png](https://cdn.nlark.com/yuque/0/2022/png/26064559/1649234084226-c1513774-d274-4214-9048-790540dc5dcf.png#clientId=ud44c8a2f-317e-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=137&id=u2994840c&name=image.png&originHeight=274&originWidth=1114&originalType=url&ratio=1&rotation=0&showTitle=false&size=74687&status=done&style=none&taskId=u4348f35f-72bd-4b97-90d5-cc6a2274334&title=&width=556)
**槽slot**<br />redis通过集群**分片**的形式来保存数据,整个集群数据库被分为16384个slot,集群中的每个节点可以处理0-16384个slot,当数据库16384个slot都有节点在处理时,集群处于上线状态,反之只要有一个slot没有得到处理都会处理下线状态。通过cluster addslots命令可以将slot指派给对应节点处理。<br />slot是一个位数组,数组的长度是16384/8=2048,而数组的每一位用1表示被节点处理,0表示不处理,如图所示的话表示A节点处理0-7的slot。<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/26064559/1649234084208-6820e2d7-fcd1-487d-99c7-c3cb30744982.png#clientId=ud44c8a2f-317e-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=126&id=u94ad58f0&name=image.png&originHeight=202&originWidth=1002&originalType=url&ratio=1&rotation=0&showTitle=false&size=42157&status=done&style=none&taskId=u3b0ed694-0baf-4679-8bab-6a5621177a9&title=&width=625)<br />**当客户端向节点发送命令,如果刚好找到slot属于当前节点,那么节点就执行命令,反之,则会返回一个MOVED命令到客户端指引客户端转向正确的节点。(MOVED过程是自动的)**<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/26064559/1649234084252-479aeada-0958-42b3-a523-630949feb5fe.png#clientId=ud44c8a2f-317e-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=265&id=ua989290b&name=image.png&originHeight=582&originWidth=1538&originalType=url&ratio=1&rotation=0&showTitle=false&size=162320&status=done&style=none&taskId=u2f374395-a413-4e12-a3ae-9df8f4e00d9&title=&width=701)<br />如果增加或者移出节点,对于slot的重新分配也是非常方便的,redis提供了工具帮助实现slot的迁移,整个过程是完全在线的,不需要停止服务。
**故障转移**<br />如果节点A向节点B发送ping消息,节点B没有在规定的时间内响应pong,那么节点A会标记节点B为pfail**疑似下线状态**,同时把B的状态通过消息的形式发送给其他节点,如果**超过半数以上的节点都标记B为pfail状态,B就会被标记为fail下线状态**,此时将会发生故障转移,优先从复制数据较多的从节点选择一个成为主节点,并且接管下线节点的slot,整个过程和哨兵非常类似,都是**基于Raft协议做选举**。
<a name="Ks9dC"></a>
## Redis集群的实现方案 / 分区方案 / 分片机制
Redis Cluster 集群采用虚拟槽分区来实现数据分片,它把所有的键根据哈希函数映射到0-16383整数槽内,计算公式为slot=CRC16(key)&16383,每一个节点负责维护一部分槽以及槽所映射的键值数据。虚拟槽分区具有如下特点:
1. 解耦数据和节点之间的关系,简化了节点扩容和收缩的难度;
1. 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据;
1. 支持节点、槽、键之间的映射查询,用于数据路由,在线伸缩等场景。
Redis集群中数据的分片逻辑如下图:<br />![](https://cdn.nlark.com/yuque/0/2022/png/26064559/1649232761915-846d0cb6-ee74-47af-b956-24f1f484fbe0.png#clientId=ud44c8a2f-317e-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=391&id=u1b9593f1&originHeight=453&originWidth=698&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=ud72575b5-9b13-41b6-a2a2-ab1879a49e0&title=&width=602)
<a name="EcN7d"></a>
## Redis集群的应用和优劣势
**优势:**<br />Redis Cluster是Redis的分布式解决方案,在3.0版本正式推出,有效地解决了Redis分布式方面的需求。当遇到单机内存、并发、流量等瓶颈时,可以采用Cluster架构方案达到负载均衡的目的。<br />**劣势:Redis集群的功能限制**<br />Redis集群方案在扩展了Redis处理能力的同时,也带来了一些使用上的限制:
1. key批量操作支持有限。如mset、mget,目前只支持具有相同slot值的key执行批量操作。对于映射为不同slot值的key由于执行mset、mget等操作可能存在于多个节点上所以不被支持。
1. key事务操作支持有限。同理只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能。
1. key作为数据分区的最小粒度,因此不能将一个大的键值对象(如hash、list等)映射到不同的节点。
1. 不支持多数据库空间。单机下的Redis可以支持16个数据库,集群模式下只能使用一个数据库空间,即DB0。
1. 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。
<a name="hH3D2"></a>
## Redis集群的通信方案
在分布式存储中需要提供维护节点元数据信息的机制,所谓元数据是指:节点负责哪些数据,是否出现故障等状态信息。常见的元数据维护方式分为:集中式和P2P方式。<br />Redis集群采用P2P的Gossip(流言)协议,Gossip协议的工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息,这种方式类似流言传播。通信的大致过程如下:
1. 集群中每个节点都会单独开辟一个TCP通道,用于节点之间彼此通信,通信端口号在基础端口号上加10000;
1. 每个节点再固定周期内通过特定规则选择几个节点发送ping消息;
1. 接收ping消息的节点用pong消息作为响应。
其中,Gossip协议的主要职责就是信息交换,而信息交换的载体就是节点彼此发送的Gossip消息,Gossip消息分为:meet消息、ping消息、pong消息、fail消息等。
- meet消息:用于通知新节点加入,消息发送者通知接受者加入到当前集群。meet消息通信正常完成后,接收节点会加入到集群中并进行周期性的ping、pong消息交换。
- ping消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送ping消息,用于检测节点是否在线和交换彼此状态信息。ping消息封装了自身节点和一部分其他节点的状态数据。
- pong消息:当接收到meet、ping消息时,作为响应消息回复给发送方确认消息正常通信。pong消息内封装了自身状态数据,节点也可以向集群内广播自身的pong消息来通知整个集群对自身状态进行更新。
- fail消息:当节点判定集群内另一个节点下线时,会向集群内广播一个fail消息,其他节点接收到fail消息之后把对应节点更新为下线状态。
虽然Gossip协议的信息交换机制具有天然的分布式特性,但它是有成本的。因为Redis集群内部需要频繁地进行节点信息交换,而ping/pong消息会携带当前节点和部分其他节点的状态数据,势必会加重带宽和计算的负担。所以,Redis集群的Gossip协议需要兼顾信息交换的实时性和成本的开销。
- 集群里的每个节点默认每隔一秒钟就会从已知节点列表中随机选出五个节点,然后对这五个节点中最长时间没有发送过PING消息的节点发送PING消息,以此来检测被选中的节点是否在线。
- 如果节点A最后一次收到节点B发送的PONG消息的时间,距离当前时间已经超过了节点A的超时选项设置时长的一半(cluster-node-timeout/2),那么节点A也会向节点B发送PING消息,这可以防止节点A因为长时间没有随机选中节点B作为PING消息的发送对象而导致对节点B的信息更新滞后。
- 每个消息主要的数据占用:slots槽数组(2KB)和整个集群1/10的状态数据(10个节点状态数据约1KB)。
<a name="iYY93"></a>
# 分布式问题
<a name="yNJ1R"></a>
## 何时需要分布式锁?
在分布式的环境下,当多个server并发修改同一个资源时,为了避免竞争就需要使用分布式锁。那为什么不能使用Java自带的锁呢?因为Java中的锁是面向多线程设计的,它只局限于当前的JRE环境。而多个server实际上是多进程,是不同的JRE环境,所以Java自带的锁机制在这个场景下是无效的。
<a name="nqfhM"></a>
## 如何利用Redis实现一个分布式锁?
采用Redis实现分布式锁,就是在Redis里存一份代表锁的数据,通常用字符串即可。
**一、采用改进后的setnx命令(即set...nx...命令)实现分布式锁的思路,以及优化的过程如下:**
**加锁:**
1. 第一版:`setnx key value` 这种方式的缺点是容易产生死锁,因为客户端有可能忘记解锁,或者解锁失败。
1. 第二版:`setnx key value expire key seconds` 给锁增加了过期时间,避免出现死锁。但这两个命令不是原子的,第二步可能会失败,依然无法避免死锁问题。
1. 第三版:`set key value nx ex seconds` 通过“set...nx...”命令,将加锁、过期命令编排到一起,它们是原子操作了,可以避免死锁。
**解锁:**<br />解锁就是删除代表锁的那份数据。`del key`
**问题:**<br />看起来已经很完美了,但实际上还有隐患,如下图。进程A在任务没有执行完毕时,锁已经到期被释放了。等进程A的任务执行结束后,它依然会尝试释放锁,因为它的代码逻辑就是任务结束后释放锁。但是,它的锁早已自动释放过了,它此时释放的可能是其他线程的锁。<br />![](https://cdn.nlark.com/yuque/0/2022/png/26064559/1649233344752-30f7f432-9834-4033-9ec5-83dd733672db.png#clientId=ud44c8a2f-317e-4&crop=0&crop=0&crop=1&crop=1&height=227&id=wb4pi&originHeight=672&originWidth=1655&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=uad8cc3e3-d03c-4f86-ad28-fc412827809&title=&width=560)<br />想要解决这个问题,我们需要解决两件事情:
1. 在加锁时就要给锁设置一个标识,进程要记住这个标识。当进程解锁的时候,要进行判断,是自己持有的锁才能释放,否则不能释放。可以为key赋一个随机值,来充当进程的标识。
1. 解锁时要先判断再释放,这两步需要保证原子性,否则第二步失败的话,就会出现死锁。而获取和删除命令不是原子的,这就需要采用Lua脚本,通过Lua脚本将两个命令编排在一起,而整个Lua脚本的执行是原子的。
按照以上思路,优化后的命令如下:
```java
# 加锁
set key random-value nx ex seconds
# 解锁
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1]) else
return 0 end
二、基于RedLock算法的分布式锁
上述分布式锁的实现方案,是建立在单个主节点之上的。它的潜在问题如下图所示,如果进程A在主节点上加锁成功,然后这个主节点宕机了,则从节点将会晋升为主节点。若此时进程B在新的主节点上加锁成果,之后原主节点重启,成为了从节点,系统中将同时出现两把锁,这是违背锁的唯一性原则的。
总之,就是在单个主节点的架构上实现分布式锁,是无法保证高可用的。若要保证分布式锁的高可用,则可以采用多个节点的实现方案。这种方案有很多,而Redis的官方给出的建议是采用RedLock算法的实现方案。该算法基于多个Redis节点,它的基本逻辑如下:
- 这些节点相互独立,不存在主从复制或者集群协调机制;
- 加锁:以相同的KEY向N个实例加锁,只要超过一半节点成功,则认定加锁成功;
- 解锁:向所有的实例发送DEL命令,进行解锁;
RedLock算法的示意图如下,我们可以自己实现该算法,也可以直接使用Redisson框架。
分区
事务
Redis事务机制
redis通过MULTI、EXEC、WATCH等命令来实现事务机制,事务执行过程将一系列多个命令按照顺序一次性执行,并且在执行期间,事务不会被中断,也不会去执行客户端的其他请求,直到所有命令执行完毕。事务的执行过程如下:
- 服务端收到客户端请求,事务以MULTI开始
- 如果客户端正处于事务状态,则会把事务放入队列同时返回给客户端QUEUED,反之则直接执行这个命令
- 当收到客户端EXEC命令时,WATCH命令监视整个事务中的key是否有被修改,如果有则返回空回复到客户端表示失败,否则redis会遍历整个事务队列,执行队列中保存的所有命令,最后返回结果给客户端
WATCH的机制本身是一个CAS的机制,被监视的key会被保存到一个链表中,如果某个key被修改,那么REDIS_DIRTY_CAS标志将会被打开,这时服务器会拒绝执行事务。
安装Redis
下载Redis:Redis-x64-3.2.100.msi;Redis官网使用手册;配到环境变量中:D:\DeveloperTools\Java\redis。
Redis的常用命令
Redis入门
Redis中的key如果是两个单词组成的,之间用冒号”:”连接;Mysql中用下划线连接;Java中用驼峰式。
list:命令用l、r开头;Set:命令用s开头;Hash:命令用h开头;Z
set有序集合:命令用z开头
// 全局命令
redis-cli //登录客户端
select n //选择数据库(内置16个数据库,索引为0~15)
flushdb //清空当前数据库所有的数据
keys * //查看当前数据库中所有的key //redis中的key由两个单词拼成时,使用:连接。
keys test* //查看当前数据库中所有以test开头的key
type test:user //查看某个key的值的类型用type key
exists test:user //查看当前数据库是否存在key用exists key [key ...],1表示存在,0表示不存在
del test:user //删key用del key [key ...]
expire test:students 10 //10s后删除这个key。设置某个key的过期时间expire key seconds
// 字符串string:存set key value [EX seconds] [PX milliseconds] [NX|XX] 查get key
set test:count 1
get test:count
incr test:count // 加1
decr test:count // 减1
// 哈希hash:存hset key field value 查hget key field
hset test:user id 1
hset test:user username zhangsan
hget test:user id
// 列表list(有序):从左右两边都可以存和取数据。从左侧存数据lpush key value [value ...]
lpush test:ids 101 102 103 // 依次从左进,103 102 101,其中103的索引是0
llen test:ids // 查看长度llen key
lindex test:ids 2 // 查看index为2的值 lindex key index
lrange test:ids 0 2 // 查看index从0到2的值 lrange key start stop
rpop test:ids // 从右侧弹出一个值rpop key
//无序集合set(值不能重复):存sadd key member [member ...]
sadd test:teachers aaa bbb ccc ddd eee
scard test:teachers // 查看集合中的元素数量scard key
spop test:teachers // 从集合中随机弹出count个元素,默认一个spop key [count]。应用:抽奖,人的编号存在集合中
smembers test:teachers // 查看集合中有哪些元素smembers key
// 有序集合zset:给每一个存进去的元素提供一个权重,小的排前面,相当于有索引
zadd test:students 10 aaa 20 bbb 30 ccc 40 ddd 50 eee// 存zadd key [NX|XX] [CH] [INCR] score member [score member ...]
zcard test:students // 查看集合中的元素数量zcard key
zscore test:students ccc // 查看某一个元素的分数zscore key member
zrank test:students ccc //返回某一个元素的排名,排名从0开始zrank key member
zrange test:students 0 2 //取某个范围内的元素zrange key start stop [WITHSCORES]
Redis中的watch命令
很多时候,要确保事务中的数据没有被其他客户端修改才执行该事务。Redis提供了watch命令来解决这类问题,这是一种乐观锁的机制。客户端通过watch命令,要求服务器对一个或多个key进行监视,如果在客户端执行事务之前,这些key发生了变化,则服务器将拒绝执行客户端提交的事务,并向它返回一个空值。
Redis中sexnx命令的返回值是什么,如何使用该命令实现分布式锁?
setnx命令返回整数值,当返回1时表示设置值成功,当返回0时表示设置值失败(key已存在)。
一般我们不建议直接使用setnx命令来实现分布式锁,因为为了避免出现死锁,我们要给锁设置一个自动过期时间。而setnx命令和设置过期时间的命令不是原子的,可能加锁成功而设置过期时间失败,依然存在死锁的隐患。对于这种情况,Redis改进了set命令,给它增加了nx选项,启用该选项时set命令的效果就会setnx一样了。
采用Redis实现分布式锁,就是在Redis里存一份代表锁的数据,通常用字符串即可。采用改进后的setnx命令(即set…nx…命令)实现分布式锁的思路,以及优化的过程如下:详见分布式问题。