redis
基础知识
redis的存储结构
string(字符串)
应用场景
计数: 使用Redis 作为计数的基础工具,它可以实现快速计数、查询缓存的功能,同时数据可以异步落地到其他数据源
共享Session: 使用Redis将用户的Session进行集中管理,避免在访问分布式服务时Session不存在导致重新登录
限速: 短信接口不被频繁访问,例如一分钟不能超过5次
命令
1 | APPEND key value |
hash(哈希)
应用场景
缓存等
命令
1 | HDEL key field [field ...] |
list(列表)
列表( list)类型是用来存储多个有序的字符串,a、b、c、c、b四个元素从左到右组成了一个有序的列表,列表中的每个字符串称为元素(element),一个列表最多可以存储(2^32-1)个元素(4294967295)。
应用场景
每个用户有属于自己的文章列表,需要分页展示文章列表。
消息队列,Redis的lpush+rpop命令组合即可实现阻塞队列。
命令
1 | BLPOP key [key ...] timeout |
set(集合)
应用场景
共同好友等
命令
1 | SADD key member [member ...] |
zsetsorted set(有序集合)
有序集合给每个元素设置一个分数(score)作为排序的依据。提供了获取指定分数和元素范围查询、计算成员排名等功能,合理的利用有序集合,能帮助我们在实际开发中解决很多问题。
应用场景
排行榜等
命令
1 | ZADD key score member [score member ...] |
Redis的持久化机制
Redis提供两种持久化机制 RDB 和 AOF 机制:
RDB(Redis DataBase)持久化方式:是指用数据集快照的方式半持久化模式记录 Redis 数据库的所有键值对,在某个时间点将数据写入一个临时文件,持久化结束后,用这个临时文件替换上次持久化的文件,达到数据恢复。
优点:
- 只有一个文件 dump.rdb,方便持久化。
- 容灾性好,一个文件可以保存到安全的磁盘。
- 性能最大化,fork 子进程来完成写操作,让主进程继续处理命令,所以是 IO 最大化。使用单独子进程来进行持久化,主进程不会进行任何 IO 操作,保证了 Redis的高性能。
- 相对于数据集大时,比 AOF 的启动效率更高。
缺点:
- 数据安全性低。RDB 是间隔一段时间进行持久化,如果持久化之间 Redis 发生故障,会发生数据丢失。所以这种方式更适合数据要求不严谨的时候。
AOF(Append-only file)持久化方式:是指所有的命令行记录以 Redis 命令请求协议的格式完全持久化存储保存为 aof 文件。
优点:
- 数据安全,aof 持久化可以配置 appendfsync 属性,默认值everysec。有 always,每进行一次命令操作就记录到 aof 文件中一次。
- 通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。
- AOF 机制的 rewrite 模式。AOF 文件没被 rewrite 之前(文件过大时会对命令进行合并重写),可以删除其中的某些命令(比如误操作的 flushall)
缺点:
- AOF 文件比 RDB 文件大,且恢复速度慢。
- 数据集大的时候,比 RDB 启动效率低。
常用参数
1 | ################################ SNAPSHOTTING ################################ |
Redis事务原理
Redis通过MULTI
、EXEC
、WATCH
等命令来实现事务功能。事务提供了一种将多个命令请求打包,然后一次性、按顺序的执行多个命令的机制,并且在事务执行期间,服务器不会中断事务而改去执行其他客户端的命令请求,它会将事务中的所有命令都执行完毕,然后才会处理其他客户端的命令请求。
核心命令
- multi:表示事务的开始
- exec:表示事务的执行
- discrad:表示事务的丢弃
- watch:一个乐观锁,在exec执行前,如果监视的任意数量的数据。如果在exec执行时,被监视的数据发生变化(被其他客户端修改),则服务器将拒绝执行事务,并且向客户端返回代表事务执行失败的空回复
事务的ACID性质
原子性
原子性就是服务器执行事务中的操作,要么全部失败,要么全部成功。
1 | 127.0.0.1:6379> multi |
从上面的例子(类型错误-运行时错误)可以看出,incr books
执行失败后,事务继续执行,没有受到影响;
1 | 127.0.0.1:6379> multi |
从上面的例子(语法错误-编译器错误)可以看出,如果事务期间,执行命令报错后,所有命令全部失败;所以,编译器错误会导致整个事务失败,运行是错误只会跳过错误命令继续执行;
Redis事务和其他关系型数据库最大的区别是:Redis不支持事务回滚机制,即使某个命令出错,整个事务也会继续执行下去(一个失败,接下来全部失败,而且之前的设置也失败),直到事务结束
一致性
- 入队错误(语法错误):出现了命令不存在,或者命令的格式不正确等,那么redis将拒绝执行这个事务
- 执行错误:类型错误,跳过当前错误命令
隔离性
- 所有的指令在exec之前不执行,而是缓存在服务器中的一个事务队列中
- 服务器一旦收到exec指令,才开始执行整个事务队列
- 执行完毕后一次性返回所有指令的运行结果。
因为redis使用单线程的方式来执行事物,并且服务器保证在执行事务期间不会对事务进行中断,它们不用担心自己在执行队列的识别被其他指令打扰,因此,Redis的事务总是以串行的方式运行的,事务具有隔离性
持久性
数据可以通过RDB、AOF模式,把数据或则命令保存到磁盘中进行持久化;
事务实现原理
该事务首先以一个MULTI命令开始,接着将多个命令放入事务中,最后有Exec命令将这个事务提交给服务器执行
事务开始
multi
命令的执行标志事务的开始
1 | 127.0.0.1:6379> multi |
- multi可以将执行该命令的客户端从非事务状态切换到事务状态,这一切都是在客服端状态的
flags
属性中
打开REDIS_MULTI
标志完成的
命令入队
当一个客户端处于非事务状态时,这个客户端发送的命令会立刻被服务器执行
当一个客户端处于事务状态时:
- 如果客户端发送的命令是exec、discard、watch、multi,服务器会立刻执行命令
- 如果客户端发送的命令不是exec、discard、watch、multi,服务器会将命令放入事务队列中,然后向客户端返回queued回复
执行事务
当一个处于事务状态的客户端向服务端发送exec命令时,这个exec命令将立即被服务器执行。
服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端
番外:事务队列
每个Redis客户端都有自己的事务状态,这个事务状态保存在客户端状态的mstate属性里面
1 | typedef struct redisClient{ |
事务状态包含一个事务队列,以及一个已入队命令的计数器(也就是事务队列的长度)
1 | typedef struct multiState{ |
事务队列是一个multiCmd类型的数组,数组中每个multiCmd结果都保存了一个已入队命令的相关信息,包括指向命令的实现函数的指针、命令的参数,以及参数的数量
1 | typedef truct multiCmd{ |
事务队列以先进先出FIFO的方式入队的命令,先入队的命令会被放到数组的前面,后入对的命令会被放到数组的后面
watch实现机制
使用watch命令监视数据库键
每个redis数据库都保存着一个watched_keys字典,
这个字典的键是某个被WATCH
命令监视的数据库键,而字典的值则是一个链表,链表中记录了所有监视相应数据库键的客户端。
1 | tyedef struct redisDb{ |
通过执行watch
命令,客户端可以在watched_keys
字典中与被监视的键进行关联。
如图: 从watched_key字典可以看出:
- 客户端c1和c2正在监视键“name’”
- 客户端c3正在监视键“age”
- 客户端c2和c4正在监视键”address”
如图:当前客户端c10086执行WATCH "name" "age"
之后,字典更新
监视机制的触发
所有对数据库进行修改的命令,在执行之后都会调用multi,c/touchWatchKey
函数对watched_keys
字典进行检查,查看是否有客户端正在监视刚刚被命令修改过的数据库键,如果有的话,那么touchWatchKey
函数会将监视被修改键的客户端的REDIS_DIRTY_CAS
标记打开,表示该客户端的事务安全性已经被破坏
判断事务是否安全
当服务器收到一个客户端发来的EXEC
命令时,服务器会根据这个客户端是否打开了REDIS_DIRTTY_CAS
标识来决定是否执行事务
布隆过滤器
布隆过滤器(Bloom Filter)是 Redis 4.0 版本提供的新功能。布隆过滤器是一种占用空间很小的数据结构,它由一个很长的二进制向量和一组Hash映射函数组成,它用于检索一个元素是否在一个集合中,空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。
相比于 Set 集合的去重功能而言,布隆过滤器在空间上能节省 90% 以上,但是它的不足之处是去重率大约在 99% 左右,也就是说有 1% 左右的误判率,这种误差是由布隆过滤器的自身结构决定的。
引用场景
布隆过滤器是 Redis 的高级功能,虽然这种结构的去重率并不完全精确,但和其他结构一样都有特定的应用场景,比如当处理海量数据时,就可以使用布隆过滤器实现去重。
下面举两个简单的例子:
1) 示例:
百度爬虫系统每天会面临海量的 URL 数据,我们希望它每次只爬取最新的页面,而对于没有更新过的页面则不爬取,因策爬虫系统必须对已经抓取过的 URL 去重,否则会严重影响执行效率。但是如果使用一个 set(集合)去装载这些 URL 地址,那么将造成资源空间的严重浪费。
2) 示例:
垃圾邮件过滤功能也采用了布隆过滤器。虽然在过滤的过程中,布隆过滤器会存在一定的误判,但比较于牺牲宝贵的性能和空间来说,这一点误判是微不足道的。
工作原理
布隆过滤器(Bloom Filter)是一个高空间利用率的概率性数据结构,由二进制向量(即位数组)和一系列随机映射函数(即哈希函数)两部分组成。
布隆过滤器使用exists()
来判断某个元素是否存在于自身结构中。当布隆过滤器判定某个值存在时,其实这个值只是有可能存在;当它说某个值不存在时,那这个值肯定不存在,这个误判概率大约在 1% 左右。
假设我们有个集合A,A中有n个元素。利用k个哈希散列函数(一个key计算多个哈希值),将A中的每个元素映射到一个长度为a位的数组B中的不同位置上,这些位置上的二进制数均设置为1。如果待检查的元素,经过这k个哈希散列函数的映射后,发现其k个位置上的二进制数全部为1,这个元素很可能属于集合A,反之,一定不属于集合A。
来看个简单例子吧,假设集合A有3个元素,分别为{d1,d2,d3}。有1个哈希函数,为Hash1。现在将A的每个元素映射到长度为16位数组B。
我们现在把d1映射过来,假设Hash1(d1)= 2,我们就把数组B中,下标为2的格子改成1,如下:
我们现在把d2也映射过来,假设Hash1(d2)= 5,我们把数组B中,下标为5的格子也改成1,如下:
接着我们把d3也映射过来,假设Hash1(d3)也等于 2,它也是把下标为2的格子标1:
因此,我们要确认一个元素dn是否在集合A里,我们只要算出Hash1(dn)得到的索引下标,只要是0,那就表示这个元素不在集合A,如果索引下标是1呢?那该元素可能是A中的某一个元素。因为你看,d1和d3得到的下标值,都可能是1,还可能是其他别的数映射的,布隆过滤器是存在这个缺点的:会存在hash碰撞导致的假阳性,判断存在误差。
如何减少这种误差呢?
- 搞多几个哈希函数映射,降低哈希碰撞的概率
- 同时增加B数组的bit长度,可以增大hash函数生成的数据的范围,也可以降低哈希碰撞的概率
我们又增加一个Hash2哈希映射函数,假设Hash2(d1)=6,Hash2(d3)=8,它俩不就不冲突了嘛,如下:
即使存在误差,我们可以发现,布隆过滤器并没有存放完整的数据,它只是运用一系列哈希映射函数计算出位置,然后填充二进制向量。如果数量很大的话,布隆过滤器通过极少的错误率,换取了存储空间的极大节省,还是挺划算的。
目前布隆过滤器已经有相应实现的开源类库啦,如Google的Guava类库,Twitter的 Algebird 类库,信手拈来即可,或者基于Redis自带的Bitmaps自行实现设计也是可以的。
面试题
1、什么是 Redis?
Redis 是完全开源免费的,遵守 BSD 协议,是一个高性能的 key-value 数据库。
Redis 与其他 key - value 缓存产品相比有以下三个特点:
- Redis 支持数据的持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用。
- Redis 不仅仅支持简单的 key-value 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。
- Redis 支持数据的备份,即 master-slave 模式的数据备份。
Redis 优势:
- 性能极高:Redis 能读的速度是 110000 次/s,写的速度是 81000 次/s。
- 丰富的数据类型:Redis 支持二进制案例的 Strings,Lists,Hashes,Sets 及 Ordered Sets 数据类型操作。
- 原子:Redis 的所有操作都是原子性的,意思就是要么成功执行要么失败完全不执行。单个操作是原子性的。多个操作也支持事务,即原子性,通过
MULTI
和EXEC
指令包起来。 - 丰富的特性:Redis 还支持 publish/subscribe,通知,key 过期等等特性。
Redis 与其他 key-value 存储有什么不同?
Redis 有着更为复杂的数据结构并且提供对他们的原子性操作,这是一个不同于其他数据库的进化路径。Redis 的数据类型都是基于基本数据结构的同时对程序员透明,无需进行额外的抽象。
Redis 运行在内存中但是可以持久化到磁盘,所以在对不同数据集进行高速读写时需要权衡内存,因为数据量不能大于硬件内存。在内存数据库方面的另一个优点是,相比在磁盘上相同的复杂的数据结构,在内存中操作起来非常简单,这样 Redis 可以做很多内部复杂性很强的事情。同时,在磁盘格式方面他们是紧凑的以追加的方式产生的,因为他们并不需要进行随机访问。
2、Redis是单线程还是多线程?
Redis5及之前是单线程
只有一个worker队列,所有读写操作都在这个队列进行,好处是不会有线程安全问题,但是读写这些系统调用在Redis执行占用了大部分CPU时间。
Redis6以后是单线程+多线程
- 一个worker线程+多个IO子线程,其他就是在IO就绪后使用多线程提升读写解析数据的效率,而在操作内存数据的时候还是单线程;
- 同时不会有线程安全问题,因为Redis针对内存操作的时候,还是在公共的worker队列实现的;
- 保留worker主线程是因为单线程机制使得Redis内存实现复杂度降低,而且可以保证线程安全;
3、字符串类型的最大容量是多少?
512M
4、Redis 常见性能问题和解决方案
- Master 最好不要写内存快照,如果 Master 写内存快照,save 命令调度 rdbSave函数,会阻塞主线程的工作,当快照比较大时对性能影响是非常大的,会间断性暂停服务。
- 如果数据比较重要,某个 Slave 开启 AOF 备份数据,策略设置为每秒同步一。
- 为了主从复制的速度和连接的稳定性,Master 和 Slave 最好在同一个局域网。
- 尽量避免在压力很大的主库上增加从。
- 主从复制不要用图状结构,用单向链表结构更为稳定,即:Master <- Slave1<- Slave2 <- Slave3……这样的结构方便解决单点故障问题,实现 Slave 对 Master 的替换。如果 Master 挂了,可以立刻启用 Slave1 做 Master,其他不变
5、Redis 过期键的删除策略
- 定时删除:在设置键的过期时间的同时,创建一个定时器 timer。让定时器在键的过期时间来临时,立即执行对键的删除操作。
- 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。通过expireIfNeeded函数,对每个key进行过滤判断;
- 定期删除:每隔一段时间(100ms)程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。
默认策略:【惰性删除+定期删除】
定期删除:
1、Redis服务器初始化时,读取hz的值默认10;
2、美妙执行hz次serverCron()->databaseCron()->activeExpireCycle();
3、activeExpireCycle()对每一个expires[*]逐一进行检查,每次执行250ms/hz;
4、检查时,随机挑选W个key检查,W = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP = 20;4.1、如果key超时,则删除key 4.2、删除key的数量>W*25%,循环该过程 4.3、删除key的数量<=W*25%,检查下一个expires[*]
5、参数current_db用于记录activeExpireCycle()进入的哪个expires[*];
6、执行时间到期,下次执行时从current_db继续向下执行;
参数配置:
1 | # Redis calls an internal function to perform many background tasks, like |
- hz:CPU每1秒执行10次后台定时任务
6、Redis 的回收策略(淘汰策略)
查看官方文档,结果如下,默认使用noeviction策略;
1 | volatile-lru -> remove the key with an expire set using an LRU algorithm |
- volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
- volatile-lfu:从已设置过期时间的数据集(server.db[i].expires)中挑选最佳使用次数最少的数据淘汰
- volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
- allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰
- allkeys-lfu:从数据集(server.db[i].dict)中挑选最佳使用次数最少的数据淘汰
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
- no-enviction(驱逐):禁止驱逐数据,新数据进入会引发OOM
使用策略规则:
- 如果数据呈现幂律分布,也就是一部分数据访问频率高,一部分数据访问频率低,则使用 allkeys-lru
- 如果数据呈现平等分布,也就是所有的数据访问频率都相同,则使用 allkeys-random
参数配置:
- maxmemory:最大可使用内存。占用物理内存的比例,默认0代表不限制。
- maxmemory-samples:每次选取的带删除数据的个数。每次选取数据并不是全库扫描,而是随机选取一部分。
- maxmemory-policy:逐出策略。当内存100%使用之后,对挑选出来的数据删除的策略。
7、为什么 Redis 需要把所有数据放到内存中?
Redis 为了达到最快的读写速度将数据都读到内存中,并通过异步的方式将数据写入磁盘。所以 Redis 具有快速和数据持久化的特征。如果不将数据放在内存中,磁盘 I/O 速度为严重影响 Redis 的性能。在内存越来越便宜的今天,Redis 将会越来越受欢迎。如果设置了最大使用的内存,则数据已有记录数达到内存限值后不能继续插入新值。
8、Redis 的同步机制
Redis 可以使用主从同步,从从同步。
全量同步
Slave第一次同步时,向master发送PSYNC命令,格式
psync {runId} {offset}
runId 是master的运行ID,offset是slave的复制偏移量
第一次请求的格式为:psync ? -1主节点返回 fullresync {runid} {offset}回复,表示主节点要求与从节点进行数据的完整全量复制
主节点做一次 bgsave,并同时将后续修改操作记录到内存 buffer,待完成后将 rdb 文件全量同步到复制节点
复制节点接收数据后丢弃所有旧数据,然后将 rdb 镜像加载到内存
加载完成后,再通知主节点将期间修改的操作记录同步到复制节点进行重放就完成了同步过程。
增量同步
- Slave向master发送PSYNC命令,格式
psync {runId} {offset}
- master收到后,发送offset之后的命令操作
9、Pipeline 有什么好处,为什么要用 Pipeline?
Pipeline指的是管道技术,指的是客户端允许将多个请求依次发给服务器,过程中而不需要等待请求的回复,在最后再一并读取结果即可。
可以将多次 IO 往返的时间缩减为一次,前提是 Pipeline 执行的指令之间没有因果相关性。使用 redis-benchmark 进行压测的时候可以发现影响 Redis 的 QPS 峰值的一个重要因素是 Pipeline 批次指令的数目。
注意:命令是一条一条执行的,所以是非原子的。
每次都只能在一个Redis节点上运行。
建议每次少量数据,否则会影响网络性能。
10、Redis集群方案
主从复制模式
主从复制优点
- 支持主从复制,主机会自动将数据同步到从机,可以进行读写分离;
- 为了分载 Master 的读操作压力,Slave 服务器可以为客户端提供只读操作的服务,写服务仍然必须由Master来完成;
- Slave 同样可以接受其它 Slaves 的连接和同步请求,这样可以有效的分载 Master 的同步压力;
- Master Server 是以非阻塞的方式为 Slaves 提供服务。所以在 Master-Slave 同步期间,客户端仍然可以提交查询或修改请求;
- Slave Server 同样是以非阻塞的方式完成数据同步。在同步期间,如果有客户端提交查询请求,Redis则返回同步之前的数据;
主从复制缺点
- Redis不具备自动容错和恢复功能,主机从机的宕机都会导致前端部分读写请求失败,需要等待机器重启或者手动切换前端的IP才能恢复(也就是要人工介入);
- 主机宕机,宕机前有部分数据未能及时同步到从机,切换IP后还会引入数据不一致的问题,降低了系统的可用性;
- 如果多个 Slave 断线了,需要重启的时候,尽量不要在同一时间段进行重启。因为只要 Slave 启动,就会发送sync 请求和主机全量同步,当多个 Slave 重启的时候,可能会导致 Master IO 剧增从而宕机。
- Redis 较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂;
配置方式
修改Slave节点配置文件
1 | 配置master节点 ip 端口号 |
Sentinel(哨兵)模式
哨兵模式是一种特殊的模式,首先 Redis 提供了哨兵的命令,哨兵是一个独立的进程,作为进程,它会独立运行。其原理是哨兵通过发送命令,等待Redis服务器响应,从而监控运行的多个 Redis 实例。
哨兵模式的作用
- 通过发送命令,让 Redis 服务器返回监控其运行状态,包括主服务器和从服务器;
- 当哨兵监测到 master 宕机,会自动将 slave 切换成 master ,然后通过发布订阅模式通知其他的从服务器,修改配置文件,让它们切换主机;
故障切换的过程
- 每个Sentinel(哨兵)进程以每秒钟一次的频率向整个集群中的Master主服务器,Slave从服务器以及其他Sentinel(哨兵)进程发送一个 PING 命令。
- 如果一个实例(instance)距离最后一次有效回复 PING 命令的时间超过 down-after-milliseconds(默认30秒) 选项所指定的值, 则这个实例会被 Sentinel(哨兵)进程标记为主观下线(SDOWN)
- 如果一个Master主服务器被标记为主观下线(SDOWN),则正在监视这个Master主服务器的所有 Sentinel(哨兵)进程要以每秒一次的频率确认Master主服务器的确进入了主观下线状态
- 当有足够数量的 Sentinel(哨兵)进程(大于等于配置文件指定的值)在指定的时间范围内确认Master主服务器进入了主观下线状态(SDOWN), 则Master主服务器会被标记为客观下线(ODOWN)
- 在一般情况下, 每个 Sentinel(哨兵)进程会以每 10 秒一次的频率向集群中的所有Master主服务器、Slave从服务器发送 INFO 命令。
- 当Master主服务器被 Sentinel(哨兵)进程标记为客观下线(ODOWN)时,Sentinel(哨兵)进程向下线的 Master主服务器的所有 Slave从服务器发送 INFO 命令的频率会从 10 秒一次改为每秒一次。
- 若没有足够数量的 Sentinel(哨兵)进程同意 Master主服务器下线, Master主服务器的客观下线状态就会被移除。若 Master主服务器重新向 Sentinel(哨兵)进程发送 PING 命令返回有效回复,Master主服务器的主观下线状态就会被移除。
Redis 哨兵机制中的 quorum 是什么?
Redis 哨兵机制中的 quorum 是指在进行主节点切换时,需要多少个哨兵节点同意切换操作才能进行切换。quorum 的大小可以通过配置文件进行设置。
配置方式
修改所有master、slave节点的sentinel配置文件
1 | 配置master节点ip 端口号 选举节点数 |
优先启动redis-server,然后启动redis-sentinel(先启动主节点,再启动slave)
Cluster模式
Cluster 即 集群模式,类似MySQL,Redis 集群也是一种分布式数据库方案,集群通过分片(sharding)模式来对数据进行管理,并具备分片间数据复制、故障转移和流量调度的能力。
Redis集群的做法是 将数据划分为 16384(2的14次方)个哈希槽(slots),如果你有多个实例节点,那么每个实例节点将管理其中一部分的槽位,槽位的信息会存储在各自所归属的节点中。以下图为例,该集群有3个 Redis 节点,每个节点负责集群中的一部分数据,数据量可以不均匀。比如性能好的实例节点可以多分担一些压力。
计算公式:CRC16(key)&16383
为什么需要Cluster模式
单机的吞吐无法承受持续扩增的流量的时候,最好的办法是从横向(scale out) 和 纵向(scale up)两方面进行扩展。
- 纵向扩展(scale up):将单个实例的硬件资源做提升,比如 CPU核数量、内存容量、SSD容量。
- 横向扩展(scale out):横向扩增 Redis 实例数,这样每个节点只负责一部分数据就可以,分担一下压力,典型的分治思维。
Cluster集群节点的通讯
一个Redis集群由多个节点组成,各个节点之间是怎么通信的呢?通过Gossip协议!
Redis Cluster集群通过Gossip协议进行通信,节点之前不断交换信息,交换的信息内容包括节点出现故障、新节点加入、主从节点变更信息、slot信息等等。常用的Gossip消息分为4种,分别是:ping、pong、meet、fail。
- meet消息:通知新节点加入。消息发送者通知接收者加入到当前集群,meet消息通信正常完成后,接收节点会加入到集群中并进行周期性的ping、pong消息交换。
- ping消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送ping消息,用于检测节点是否在线和交换彼此状态信息。
- pong消息:当接收到ping、meet消息时,作为响应消息回复给发送方确认消息正常通信。pong消息内部封装了自身状态数据。节点也可以向集群内广播自身的pong消息来通知整个集群对自身状态进行更新。
- fail消息:当节点判定集群内另一个节点下线时,会向集群内广播一个fail消息,其他节点接收到fail消息之后把对应节点更新为下线状态。
特别的,每个节点是通过集群总线(cluster bus) 与其他的节点进行通信的。通讯时,使用特殊的端口号,即对外服务端口号加10000。例如如果某个node的端口号是6379,那么它与其它nodes通信的端口号是 16379。nodes 之间的通信采用特殊的二进制协议。
故障转移
Redis集群实现了高可用,当集群内节点出现故障时,通过故障转移,以保证集群正常对外提供服务。
redis集群通过ping/pong消息,实现故障发现。这个环境包括主观下线和客观下线。
主观下线: 某个节点认为另一个节点不可用,即下线状态,这个状态并不是最终的故障判定,只能代表一个节点的意见,可能存在误判情况。
客观下线: 指标记一个节点真正的下线,集群内多个节点都认为该节点不可用,从而达成共识的结果。如果是持有槽的主节点故障,需要为该节点进行故障转移。
- 假如节点A标记节点B为主观下线,一段时间后,节点A通过消息把节点B的状态发到其它节点,当节点C接受到消息并解析出消息体时,如果发现节点B的pfail状态时,会触发客观下线流程;
- 当下线为主节点时,此时Redis Cluster集群为统计持有槽的主节点投票,看投票数是否达到一半,当下线报告统计数大于一半时,被标记为客观下线状态。
故障恢复:故障发现后,如果下线节点的是主节点,则需要在它的从节点中选一个替换它,以保证集群的高可用。流程如下:
- 资格检查:检查从节点是否具备替换故障主节点的条件。
- 准备选举时间:资格检查通过后,更新触发故障选举时间。
- 发起选举:到了故障选举时间,进行选举。
- 选举投票:只有持有槽的主节点才有票,从节点收集到足够的选票(大于一半),触发替换主节点操作
节点增加和删除
采用 Cluster 的集群方案,当节点增加和删除时,集群又是如何工作来保证服务的高可用?
下图展现一个 5 个节点构成的集群,每个节点平均大约负责 3276 个槽,以及通过计算公式映射到对应节点的对应槽的过程。
- 增加节点
当增加一个节点 Node-6 时,只需要把其他节点的某些哈希槽挪到新的节点
就可以了。 - 移除节点
移除一个节点 Node-5 时,只需要把该节点上的哈希槽挪到其他的节点上
就可以了。
在增加和删除节点,redis 的其他节点都不需要停机。
数据迁移
那么如何将槽的数据挪到其他的结点呢?
为了实现节点之间的数据迁移,节点之间必须相互连接。数据迁移分为两部分:
槽的迁移
现在要将 Master A 节点中的编号为 1,2,3 的槽迁移到 Master B 中在迁移的中间状态下,槽 1,2,3 在 MasterA 节点的状态为
MIGRATING(迁移)
,在 MasterB 节点的状态为IMPORTING(入口)
。
IMPORTING(入口)
状态是被迁移的槽在目标节点中出现的一种状态,准备迁移从A到B的时候,被迁移的槽的状态首先变为IMPORTING(入口)
注意:此时并不刷新 node 的映射关系
键空间的迁移
在满足了槽迁移的条件下,通过相关命令将 slot1, slot2, slot3 中的键空间从 A 迁移到B。迁移过程大概如下:
Master A 节点执行
DUMP
命令,序列化要迁移的 key,并将数据发送给 Master BMaster B 节点接受到要迁移的序列化的 key 之后执行
RESTORE
命令反序列化为 key, 并保存Master A 节点执行
DEL
命令删除掉已迁移的 key迁移完成之后,刷新 node 的映射关系
需要注意的是: MIGRATE(迁移) 并不是原子的,如果在 MIGRATE 出现错误的情况可能会导致下面问题:
- 键空间在两个节点都存在;
- 键空间只存在第一个节点;
深挖细节
- 为什么不用一致性哈希,而用槽哈希分区,原因是什么?
Redis 使用的是 crc16 的简单算法,Redis 的作者认为crc16(key) mod 16384
的效果已经不错了,虽然可能没有一致性哈希灵活,但实现比较简单,节点的增加和删除都比较方便 - 节点增加和删除的过程中,数据会不会丢失?
节点在数据迁移的时候数据会有备份,不会丢失
配置方式
修改所有j集群节点配置文件
1 | 开启集群 |
然后使用命令开启集群
1 | ./redis-cli --cluster create 127.0.0.1:8000 127.0.0.1:8001 127.0.0.1:8002 127.0.0.1:8003 127.0.0.1:8004 127.0.0.1:8005 --cluster-replicas 1 |
11、Redis为什么这么快?
1、基于内存存储
省去磁盘I/O的消耗
2、高效的数据结构
字符串:
字符串类型的内部编码有三种:
- int:存储 8 个字节的长整型(long,2^63-1)。
- embstr:代表 embstr 格式的 SDS(Simple Dynamic String 简单动态字符串),存储小于 44 个字节的字符串,只分配一次内存空间(因为 Redis Object 和 SDS 是连续的)。
- raw:存储大于 44 个字节的字符串(3.2 版本之前是 39 个字节),需要分配两次内存空间(分别为 Redis Object 和 SDS 分配空间)。
embstr
结构:
embstr分配的是连续的一块内存,包含redisObjec
和sds
,redisObject占用了16个字节;
SDS结构(3.2 版本):
- free:空闲值
- len:长度
- buf:实际保存内容
优点:
- 字符串长度处理:可以直接获取字符串长度,时间复杂度O(1);
- 空间预分配:额外分配未使用的空间,防止频繁修改造成的内存分配消耗;
- 惰性空间释放:SDS缩短是,不会收回多余的内存空间,而是free记录多余空间,预防后面扩展;
- 二进制安全:可以存储二进制文件
SDS结构(3.2 以后版本):
在 3.2 以后的版本中,SDS 又有多种结构(sds.h文件中):sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64,用于存储不同的长度的字符串,分别代表 2^5=32byte,2^8=256byte,2^16=65536byte=64KB,2^32byte=4GB。
1 | /* Note: sdshdr5 is never used, we just access the flags byte directly. |
redisObject(16)+sds(uint8_t (1个字节)* 2 + char(1个字节)+1(\0结束符))=20个字节。embstr最小分配内存是64字节,所以64-20=44字节可以存储字符串。
embstr
和row
差异
- 内存释放是embstr只需要释放一次,而raw需要释放两次
- emstr查找的更快
- raw会分别两次创建redisObject结构与sdshdr结构,内存不一定是连续的
字典
Redis 作为 K-V 型内存数据库,所有的键值就是用字典来存储。字典就是哈希表,比如HashMap,通过key就可以直接获取到对应的value。而哈希表的特性,在O(1)时间复杂度就可以获得对应的值。
跳跃表
- 跳跃表是Redis特有的数据结构,就是在链表的基础上,增加多级索引提升查找效率。
- 跳跃表支持平均 O(logN),最坏 O(N)复杂度的节点查找,还可以通过顺序性操作批量处理节点。
- 跳跃表实现由zskiplist和zskiplistNode两个结构组成,其中zskiplist用于保存跳跃表信息(如表头节点、表尾节点、长度),而zskiplistNode则用于表示跳跃表节点。
3、合理的数据编码
Redis 支持多种数据数据类型,每种基本类型,可能对多种数据结构。什么时候,使用什么样数据结构,使用什么样编码,是redis设计者总结优化的结果。
- String:如果存储数字的话,是用int类型的编码;如果存储非数字,小于等于39字节的字符串,是embstr;大于39个字节,则是raw编码。
- List:如果列表的元素个数小于512个,列表每个元素的值都小于64字节(默认),使用ziplist编码,否则使用linkedlist编码
- Hash:哈希类型元素个数小于512个,所有值小于64字节的话,使用ziplist编码,否则使用hashtable编码。
- Set:如果集合中的元素都是整数且元素个数小于512个,使用intset编码,否则使用hashtable编码。
- Zset:当有序集合的元素个数小于128个,每个元素的值小于64字节时,使用ziplist编码,否则使用skiplist(跳跃表)编码
4、合理的线程模型
I/O 多路复用
多路I/O复用技术可以让单个线程高效的处理多个连接请求,而Redis使用用epoll作为I/O多路复用技术的实现。并且,Redis自身的事件处理模型将epoll中的连接、读写、关闭都转换为事件,不在网络I/O上浪费过多的时间。
什么是I/O多路复用?
I/O :网络 I/O
多路 :多个网络连接
复用:复用同一个线程。
IO多路复用其实就是一种同步IO模型,它实现了一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;而没有文件句柄就绪时,就会阻塞应用程序,交出cpu。
单线程模型
- Redis是单线程模型的,而单线程避免了CPU不必要的上下文切换和竞争锁的消耗。也正因为是单线程,如果某个命令执行过长(如hgetall命令),会造成阻塞。Redis是面向快速执行场景的数据库。,所以要慎用如smembers和lrange、hgetall等命令。
- Redis 6.0 引入了多线程提速,它的执行命令操作内存的仍然是个单线程。
5、虚拟内存机制
Redis直接自己构建了VM机制 ,不会像一般的系统会调用系统函数处理,会浪费一定的时间去移动和请求。
Redis的虚拟内存机制是啥呢?
虚拟内存机制就是暂时把不经常访问的数据(冷数据)从内存交换到磁盘中,从而腾出宝贵的内存空间用于其它需要访问的数据(热数据)。通过VM功能可以实现冷热数据分离,使热数据仍在内存中、冷数据保存到磁盘。这样就可以避免因为内存不足而造成访问速度下降的问题。
12、缓存击穿、缓存穿透、缓存雪崩
缓存穿透问题
先来看一个常见的缓存使用方式:读请求来了,先查下缓存,缓存有值命中,就直接返回;缓存没命中,就去查数据库,然后把数据库的值更新到缓存,再返回。
读取缓存
缓存穿透:指查询一个一定不存在的数据,由于缓存是不命中时需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,进而给数据库带来压力。
通俗点说,读请求访问时,缓存和数据库都没有某个值,这样就会导致每次对这个值的查询请求都会穿透到数据库,这就是缓存穿透。
缓存穿透一般都是这几种情况产生的:
- 业务不合理的设计,比如大多数用户都没开守护,但是你的每个请求都去缓存,查询某个userid查询有没有守护。
- 业务/运维/开发失误的操作,比如缓存和数据库的数据都被误删除了。
- 黑客非法请求攻击,比如黑客故意捏造大量非法请求,以读取不存在的业务数据。
如何避免缓存穿透呢? 一般有三种方法。
- 1.如果是非法请求,我们在API入口,对参数进行校验,过滤非法值。
- 2.如果查询数据库为空,我们可以给缓存设置个空值,或者默认值。但是如有有写请求进来的话,需要更新缓存哈,以保证缓存一致性,同时,最后给缓存设置适当的过期时间。(业务上比较常用,简单有效)
- 3.使用布隆过滤器快速判断数据是否存在。即一个查询请求过来时,先通过布隆过滤器判断值是否存在,存在才继续往下查。
布隆过滤器原理:它由初始值为0的位图数组和N个哈希函数组成。一个对一个key进行N个hash算法获取N个值,在比特数组中将这N个值散列后设定为1,然后查的时候如果特定的这几个位置都为1,那么布隆过滤器判断该key存在。
缓存雪奔问题
缓存雪奔: 指缓存中数据大批量到过期时间,而查询数据量巨大,请求都直接访问数据库,引起数据库压力过大甚至down机。
- 缓存雪奔一般是由于大量数据同时过期造成的,对于这个原因,可通过均匀设置过期时间解决,即让过期时间相对离散一点。如采用一个较大固定值+一个较小的随机值,5小时+0到1800秒酱紫。
- Redis 故障宕机也可能引起缓存雪奔。这就需要构造Redis高可用集群啦。
缓存击穿问题
缓存击穿: 指热点key在某个时间点过期的时候,而恰好在这个时间点对这个Key有大量的并发请求过来,从而大量的请求打到db。
缓存击穿看着有点像,其实它两区别是,缓存雪奔是指数据库压力过大甚至down机,缓存击穿只是大量并发请求到了DB数据库层面。可以认为击穿是缓存雪奔的一个子集吧。有些文章认为它俩区别,是区别在于击穿针对某一热点key缓存,雪奔则是很多key。
解决方案就有两种:
- 1、使用互斥锁方案。缓存失效时,不是立即去加载db数据,而是先使用某些带成功返回的原子操作命令,如(Redis的setnx)去操作,成功的时候,再去加载db数据库数据和设置缓存。否则就去重试获取缓存。
- 2、“永不过期”,是指没有设置过期时间,但是热点数据快要过期时,异步线程去更新和设置过期时间。
13、什么是热Key问题,如何解决热key问题
什么是热Key呢?在Redis中,我们把访问频率高的key,称为热点key。
如果某一热点key的请求到服务器主机时,由于请求量特别大,可能会导致主机资源不足,甚至宕机,从而影响正常的服务。
而热点Key是怎么产生的呢?主要原因有两个:
- 用户消费的数据远大于生产的数据,如秒杀、热点新闻等读多写少的场景。
- 请求分片集中,超过单Redi服务器的性能,比如固定名称key,Hash落入同一台服务器,瞬间访问量极大,超过机器瓶颈,产生热点Key问题。
那么在日常开发中,如何识别到热点key呢?
- 凭经验判断哪些是热Key;
- 客户端统计上报;
- 服务代理层上报
如何解决热key问题?
- Redis集群扩容:增加分片副本,均衡读流量;
- 将热key分散到不同的服务器中;
- 使用二级缓存,即JVM本地缓存,减少Redis的读请求。
14、Redis分布式锁
分布式锁,是控制分布式系统不同进程共同访问共享资源的一种锁的实现。秒杀下单、抢红包等等业务场景,都需要用到分布式锁,我们项目中经常使用Redis作为分布式锁。
1、命令setnx + expire分开写
1 | // 加锁 |
如果执行完setnx加锁,正要执行expire设置过期时间时,进程crash掉或者要重启维护了,那这个锁就“长生不老”了,别的线程永远获取不到锁啦,所以分布式锁不能这么实现。
2、setnx + value值是过期时间
1 | public boolean setnx(String key, long expireTime) { |
这种方式也是有问题的:
- 过期时间是客户端自己生成的,分布式环境下,每个客户端的时间必须同步。
- 没有保存持有者的唯一标识,可能被别的客户端释放/解锁。
- 锁过期的时候,并发多个客户端同时请求过来,都执行了jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖。
3、set的扩展命令(set ex px nx)(注意可能存在的问题)
1 | if(jedis.set(key, lock_value, "NX", "EX", 100s) == 1){ //加锁 |
这个方案可能存在这样的问题:
- 锁过期释放了,业务还没执行完。
- 锁被别的线程误删。
4、set ex px nx + 校验唯一随机值,再删除
1 | if(jedis.set(key, uni_request_id, "NX", "EX", 100s) == 1){ //加锁 |
在这里,判断当前线程加的锁和释放锁是不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。
一般也是用lua脚本代替。lua脚本如下:
1 | if redis.call('get',KEYS[1]) == ARGV[1] |
这种方式比较不错了,一般情况下,已经可以使用这种实现方式。但是存在锁过期释放了,业务还没执行完的问题(实际上,估算个业务处理的时间,一般没啥问题了)。
15、使用过Redisson嘛?说说它的原理
分布式锁可能存在锁过期释放,业务没执行完的问题。有些小伙伴认为,稍微把锁过期时间设置长一些就可以啦。其实我们设想一下,是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。
当前开源框架Redisson就解决了这个分布式锁问题。我们一起来看下Redisson底层原理是怎样的吧:
只要线程一加锁成功,就会启动一个watch dog
看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了锁过期释放,业务没执行完问题。
16、什么是Redlock算法
Redis一般都是集群部署的,假设数据在主从同步过程,主节点挂了,Redis分布式锁可能会有哪些问题呢?一起来看些这个流程图:
如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。
为了解决这个问题,Redis作者 antirez提出一种高级的分布式锁算法:Redlock。Redlock核心思想是这样的:
搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。
我们假设当前有5个Redis master节点,在5台服务器上面运行这些Redis实例。
RedLock的实现步骤:如下
1.获取当前时间,以毫秒为单位。
2.按顺序向5个master节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间。(假设锁自动失效时间为10秒,则超时时间一般在5-50毫秒之间,我们就假设超时时间是50ms吧)。如果超时,跳过该master节点,尽快去尝试下一个master节点。
3.客户端使用当前时间减去开始获取锁时间(即步骤1记录的时间),得到获取锁使用的时间。当且仅当超过一半(N/2+1,这里是5/2+1=3个节点)的Redis master节点都获得锁,并且使用的时间小于锁失效时间时,锁才算获取成功。(如上图,10s> 30ms+40ms+50ms+4m0s+50ms)
4.如果取到了锁,key的真正有效时间就变啦,需要减去获取锁所使用的时间。
5.如果获取锁失败(没有在至少N/2+1个master实例取到锁,有或者获取锁时间已经超过了有效时间),客户端要在所有的master节点上解锁(即便有些master节点根本就没有加锁成功,也需要解锁,以防止有些漏网之鱼)。
简化下步骤就是:
- 按顺序向5个master节点请求加锁
- 根据设置的超时时间来判断,是不是要跳过该master节点。
- 如果大于等于三个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。
- 如果获取锁失败,解锁!
17、MySQL与Redis 如何保证双写一致性
1、缓存延时双删
- 先删除缓存
- 再更新数据库
- 休眠一会(比如1秒),再次删除缓存。
这个休眠一会,一般多久呢?都是1秒?
这个休眠时间 = 读业务逻辑数据的耗时 + 几百毫秒。为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据。
这种方案还算可以,只有休眠那一会(比如就那1秒),可能有脏数据,一般业务也会接受的。但是如果第二次删除缓存失败呢?缓存和数据库的数据还是可能不一致,对吧?给Key设置一个自然的expire过期时间,让它自动过期怎样?那业务要接受过期时间内,数据的不一致咯?还是有其他更佳方案呢?
2、删除缓存重试机制
因为延时双删可能会存在第二步的删除缓存失败,导致的数据不一致问题。可以使用这个方案优化:删除失败就多删除几次呀,保证删除缓存成功就可以了呀~ 所以可以引入删除缓存重试机制
删除缓存重试流程
- 写请求更新数据库
- 缓存因为某些原因,删除失败
- 把删除失败的key放到消息队列
- 消费消息队列的消息,获取要删除的key
- 重试删除缓存操作
3、读取biglog异步删除缓存
重试删除缓存机制还可以吧,就是会造成好多业务代码入侵。其实,还可以这样优化:通过数据库的binlog来异步淘汰key。
以mysql为例吧
- 可以使用阿里的canal将binlog日志采集发送到MQ队列里面
- 然后通过ACK机制确认处理这条更新消息,删除缓存,保证数据缓存一致性