文章目录
Redis是基于内存的单进程单线程的Key-Value数据库,由C语言编写,官方提供的数据是可以达到100000+的QPS(每秒内查询次数)。
Redis完全基于内存,绝大部分请求都是内存操作。数据也存在内存,类似于HashMap。并支持多种数据结构,例如String,Hash,List,Set,Sort Set,BitMap,Hyperloglog和Geospatial等。Redis具有内置的复制,Lua脚本,LRU逐出,事务和不同级别的磁盘持久性,并通过 Redis Sentinel 和 Redis Cluster 自动分区提供了高可用性。
1. 线程模型
基于Reactor模型实现了自己的网络事件处理器,称为文件事件处理器(File Event Handler)。这个文件事件处理器是单线程的,所以Redis才叫做单线程的模型。
文件事件处理器的结构包含4个部分:
- 多个Socket;
- IO多路复用程序;
- 文件事件分派器;
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)。
文件事件处理器采用I/O多路复用机制同时监听多个Socket,根据Socket上的事件来选择对应的事件处理器进行处理。
多个Socket可能会并发产生不同的操作,每个操作对应不同的文件事件;I/O多路复用程序会监听多个Socket,将Socket产生的事件放入队列。事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。
Reactor模型
Reactor是将所有要处理的I/O事件注册到一个中心I/O多路复用器上,同时主线程/进程阻塞在多路复用器上;一旦有I/O事件到来或是准备就绪,多路复用器返回,并将事先注册的相应I/O事件分发到对应的处理器中
1.1 多线程
Redis 4 之后并不是单线程,除了主线程外,它也有后台线程在处理一些较为缓慢的操作,例如清理脏数据、无用连接的释放、大 key 的删除等等。而且 Redis 的瓶颈并不在CPU,而在内存和网络。
Redis 6 之前只有主线程串行处理读写事件。Redis 6 中主线程负责串行处理客户端指令,将网络IO读取和写入提交到队列中,由工作线程从队列中轮询获取执行,通过多线程任务可以分摊 Redis 同步 IO 读写负荷。
2. 数据类型
基础数据类型有字符串String、字典Hash、列表List、集合Set和有序集合SortedSet。还有HyperLogLog、Geo、Pub/Sub,Redis Module(BloomFilter,RedisSearch,Redis-ML)等数据类型。
2.1 String
字符串,普通的set和get,做简单的Key-Value缓存。虽然 Redis 是用 C 语言写的,但是 Redis 并没有使用 C 的字符串表示,而是自己构建了一种 简单动态字符串(Simple Dynamic String,SDS)。相比于 C 的原生字符串,Redis 的 SDS 不光可以保存文本数据还可以保存二进制数据,并且获取字符串长度复杂度为 O(1)(C 字符串为 O(N)),除此之外,Redis 的 SDS API 是安全的,不会造成缓冲区溢出。
应用场景
- 缓存:将Redis作为缓存,再配合其它数据库作为存储层。利用Redis支持高并发的特点,可以大大加快系统的读写速度,并降低后端数据库的压力。
- 计数器:将Redis作为系统的实时计数器,可以快速实现计数和查询的功能。最终的数据结果可以按照特定的时间落地到数据库等存储介质当中进行永久保存。
- 共享Session:利用Redis将Session集中管理,每次Session的更新和获取都可以快速完成,大大提高效率。
2.2 Hash
字典,类似于 JDK 8 前的 HashMap,内部实现为数组 + 链表。不过,Redis 的 Hash 做了更多优化。另外,Hash 是一个 String 类型的 Field 和 Value 的映射表,特别适合用于存储对象,后续操作的时候,可以直接仅仅修改这个对象中的某个字段的值。
应用场景
- 将结构化的数据给缓存在Redis里,比如一个对象(前提是这个对象没嵌套其他的对象),然后每次读写缓存的时候,可以就操作Hash里的某个字段。
2.3 List
有序列表。List 即 链表,特点是易于数据元素的插入和删除并且可以灵活调整链表长度,但是链表的随机访问困难。许多高级编程语言都内置了链表的实现,比如 Java 中的 LinkedList,但是 C 语言并没有实现链表,所以 Redis 实现了自己的链表数据结构。Redis 的 List 的实现为一个双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。
应用场景
- 列表型的数据:如粉丝列表。通过 lrange 命令读取某个闭区间内的元素,列表不但有序同时还支持按照范围内获取元素,实现基于Redis的高性能分页。
- 异步队列:使用右进左出的命令组成来完成队列的设计。数据的生产者可以通过 Rpush 命令从右边插入数据,多个数据消费者可以使用 BLpop 命令阻塞的“抢”列表头部的数据。
2.4 Set
自动去重的无序集合,类似于 Java 中的 HashSet 。Redis 中的 Set 类型是一种无序集合,集合中的元素没有先后顺序。当需要存储一个列表数据,又不希望出现重复数据时,Set 是一个很好的选择,并且 Set 提供了判断某个成员是否在一个 Set 集合内的重要接口,这个也是 List 所不能提供的。
应用场景
- 全局去重。
- 交集、并集、差集的操作。比如把两个人的好友列表整一个交集。
2.5 Sorted Set
zset,去重可排序的Set。写入时给一个分数score,自动根据分数排序,利用分数进行成员间的排序,并且插入时就排序好。像 Java 中 HashMap 和 TreeSet 的结合体。
应用场景:
- 排行榜:有序集合经典使用场景。例如微博热搜榜,后面的热度值为score。
- 带权重的队列:比如普通消息的score为1,重要消息的score为2,然后工作线程可以选择按score的倒序来获取工作任务,让重要的任务优先执行。
- 延迟队列:时间戳作为score,消息内容作为key,调用zadd来生产消息,消费者用命令 zrangebyscore 获取N秒之前的数据并轮询进行处理。
2.6 高级用法
Bitmap
位图是支持按bit位来存储信息,可以用来实现布隆过滤器(BloomFilter)。
HyperLogLog
基数统计。供不精确的去重计数功能,比较适合用来做大规模数据的去重统计,例如统计UV。
Geospatial
地理信息。可以用来保存地理位置,并作位置距离计算或者根据半径计算位置等。
Pub/Sub
发布订阅 。可以用作简单的消息队列。
Pipeline
管道。可以批量执行一组指令,一次性返回全部结果,可以减少频繁的请求应答。使用 redis-benchmark 进行压测的时候可以发现影响 Redis 的QPS峰值的一个重要因素是管道批次中指令的数量。
Redis是一种基于客户端-服务端模型以及请求/响应协议的TCP服务。这意味着通常一个请求是由客户端发送到服务端,然后客户端监听Socket返回。这时候客户端处于阻塞状态,等待服务端响应。服务端处理完命令后,才将结果返回给客户端。
Redis管道技术可以在服务端未响应时,客户端可以继续向服务端发送请求,并最终一次性读取所有服务端的响应,将多次IO往返的时间缩减为一次,前提是管道执行的指令之间没有因果相关性。
Lua
脚本。Redis支持提交Lua脚本来执行一系列的功能。
3. 事务
Redis通过MULTI、EXEC、WATCH等命令来实现事务功能。事务提供了一种将多个命令请求打包,然后一次性、按顺序地执行多个命令的机制。Redis只保证串行执行命令,并且能保证全部执行,但是执行命令失败时并不会回滚,而是会继续执行下去。
单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以 Redis 事务的执行并不是原子性的。在事务执行期间,服务器不会中断事务而去执行其他客户端的命令请求,它会将事务中的所有命令执行完毕后,才去处理其他客户端的命令请求。
过程
一个事务从开始到结束通常会经历三个阶段。
- 事务开始。MULTI命令的执行标志着事务的开始,将执行该命令的客户端从非事务状态切换成事务状态;
- 命令入队。客户端发送的是 EXEC、DISCARD、WATCH和MULTI 四个命令以外的其他命令,服务器会将这个命令放入一个事务队列里面,然后向客户端返回 QUEUE 回复。
- 事务执行。当一个处于事务状态的客户端向服务端发送 EXEC 命令时,这个 EXEC 命令会立即被执行。服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得结果全部返回给客户端。
4. 持久化
Redis是内存数据库,数据存在内存里面。如果不想办法将储存在内存中的数据库状态保存到磁盘里面,那么一旦服务器进程退出,服务器中的数据库状态也会消失不见。
Redis提供了两种持久化方式,都可以把Redis内存中的数据持久化到磁盘上:
- RDB(redis database):全量持久化,对Redis中的数据执行周期性的持久化。
- AOF(append only file):增量持久化,对每条写入命令作为日志,以仅追加的方式写入一个日志文件中,所以没有磁盘寻址的开销。
AOF文件的更新频率通常比RDB文件的更新频率高。如果服务器开启AOF,就会优先使用AOF还原;只有在AOF关闭时,才会使用RDB文件还原数据库状态。
4.1 RDB
RDB 通过保存数据库中的键值对来记录数据库状态。Redis 可以通过 RDB 来获得存储在内存里面的数据在某个时间点上的副本。Redis 创建 RDB 后,可以进行备份,也可以将它复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将 RDB 用于重启服务器的时候使用。
RDB 持久化有手动和自动触发。
手动
通过命令SAVE和BGSAVE来生成RDB文件。
- SAVE:阻塞服务器进程,直到RDB文件创建完毕,在服务器进程阻塞期间,服务器不能处理任何命令请求。
- BGSAVE:派生出一个子进程,然后由子进程负责创建RDB文件,父进程(服务器进程)继续处理命令请求。子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写脏的页面数据会逐渐和子进程分离开来。
子进程创建后,父子进程共享数据段。父进程继续提供读写服务,如果父进程对数据进行修改,就会使用操作系统的 COW (Copy On Write)机制来进行数据段页面的分离。
数据段是由很多操作系统的页面组合而成,当父进程对其中一个页面的数据进行修改时,会将被共享的页面复制一份分离出来,然后对这个复制的页面进行修改。这时子进程相应的页面是没有变化的,还是进程产生时那一瞬间的数据。子进程因为数据没有变化,接下来就可以非常安心的遍历数据了进行序列化写磁盘了。
自动
配置服务器的save选项,让服务器每隔一段时间自动执行一次BGSAVE。save选项可以设置多个保存条件,只要其中任意一个条件被满足,服务器就会执行BGSAVE。save m n
表示m秒内数据集存在n次修改。
例如:
save 900 1 服务器在900秒内,对数据库进行了至少1次修改
save 300 10 服务器在300秒内,对数据库进行了至少10次修改
save 60 10000 服务器在60秒内,对数据库进行了至少10000次修改
以上配置,只要满足其中一个,服务器就会执行BGSAVE。
优点
- 生成多个数据文件,每个数据文件分别都代表了某一时刻Redis里面的数据。
- 采用二进制压缩存储,数据恢复的速度比AOF快。
缺点
- 无法实现实时/秒级持久化。RDB是快照文件,默认五分钟甚至更久的时间才会生成一次。意味着这次同步到下次同步中间五分钟的数据都很可能全部丢失掉,而AOF则最多丢一秒的数据。
4.2 AOF
AOF通过保存服务器所执行的写命令来记录数据库状态,以仅追加的方式写入一个日志文件中,所以没有磁盘寻址的开销。
步骤
- 命令追加:服务器执行完写命令后,添加命令转成协议,追加到AOF缓冲区末尾;
- 文件写入与同步:根据 appendfsync 的值做同步操作,默认everysec。
appendfsync | 描述 | 效率 | 安全性 |
---|---|---|---|
always | 将AOF缓冲区中的所有内容写入并同步到AOF文件。 | 最低 | 最安全,只丢失一个时间循环中所产生的命令数据 |
everysec | 将AOF缓冲区中的所有内容写入到AOF文件,如果上次同步AOF文件的时间距离现在超过一秒,那么再次将AOF文件进行同步(由一个线程专门负责)。 | 足够快 | 只丢失一秒的命令数据 |
no | 将AOF缓冲区中的所有内容写入到AOF文件,但并不对AOF文件进行同步,由操作系统决定。 | 最快 | 将丢失上次同步AOF文件之后的所有写命令数据 |
文件同步:在现代操作系统中,当用户调用write函数,将一些数据写入到文件的时候,操作系统通常会将写入数据暂时保存在一个内存缓冲区里面,等到缓冲区的空间被填满,或者超过指定的时限之后,才真正的将缓冲区写入到磁盘中。
为了兼顾数据和写入性能,用户可以考虑 appendfsync everysec 选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能几乎没受到任何影响。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。
优点
- AOF在对日志文件写入操作是以追加模式,少了磁盘寻址的开销,写入性能高,文件也不容易破损。
- 有灵活的同步策略,支持每次修改同步、每秒同步和不同步。
- RDB默认五分钟一次生成快照,AOF默认是一秒一次去通过一个后台的线程 fsync 操作,那最多丢这一秒的数据。
缺点
- 相同规模的数据集,AOF文件大小要大于RDB,且AOF在运行效率上往往会慢于RDB。
4.2.1 AOF重写
AOF持久化是通过保存被执行的写命令来记录数据库状态的,所以随着服务器运行时间的流逝,AOF文件中的内容会越来越多,文件的体积也会越来越大,如果不加以控制的话,体积过大的AOF文件很可能对Redis服务器、甚至整个宿主计算机造成影响,并且AOF文件的体积越大,使用AOF文件来进行数据还原所需的时间就越多。
AOF重写是生成新AOF文件,并替换旧AOF文件的功能,并不需要对现有的AOF文件进行任何读取、分析或者写入操作。这个功能是通过读取服务器当前的数据库状态来实现的。它将原来可能保存一个键所需的多个命令减少为一条,来代替之前记录的这个键值对。如果超过常量值64,就会将一条命令转成多条。
AOF重写有手动和自动触发。
- 手动。使用命令 BGREWIRTEAOF。
- 自动。根据 auto-aof-rewrit-min-size 和 auto-aof-rewrite percentage 参数确定自动触发时机。auto-aof-rewrite-min-size 表示运行AOF重写时文件最小体积,默认为64MB。auto-aof-rewrite percentage 标识当前AOF文件空间和上一次重写后AOF文件空间的比值。
流程
- 创建子进程,执行AOF文件重写。(如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。)
- 父进程执行客户端发来的命令,追加写命令到AOF缓冲区和AOF重写缓冲区。
保证AOF缓冲区的内容会定期被写入和同步到AOF文件,对现有AOF文件的处理工作会如常进行。从创建子进程开始,服务器执行的所有写命令都会被记录到AOF重写缓冲区里面。 - 子进程完成AOF文件重写,向父进程发送信号。
- 父进程接收到子进程信号,将AOF重写缓冲区追加到新AOF文件的中。
- 对新AOF文件进行改名,原子地覆盖旧AOF文件。
5. 缓存
缓存是高并发场景下提高热点数据访问性能的一个有效手段。
5.1 缓存类型
缓存的类型分为:本地缓存、分布式缓存和多级缓存。
本地缓存
本地缓存就是在进程的内存中进行缓存,比如 JVM 的堆中,可以用 LRUMap 或 Ehcache 工具来实现。本地缓存是内存访问,没有远程交互开销,性能最好,但是受限于单机容量,一般缓存较小且无法扩展。
分布式缓存
分布式缓存可以很好得解决单机容量和缓存较小的问题。分布式缓存一般都具有良好的水平扩展能力,对较大数据量的场景也能应付自如。缺点就是需要进行远程请求,性能不如本地缓存。
多级缓存
为了平衡本地缓存和分布式缓存的问题,在实际业务中一般采用多级缓存。本地缓存只保存访问频率最高的部分热点数据,其他的热点数据放在分布式缓存中。
5.2 缓存问题
5.2.1 缓存雪崩
原因是Key大面积失效或缓存挂掉,之后所有的请求都直接到达数据库。
解决方法:
- 在批量往Redis存数据的时候,把每个Key的失效时间都加个随机值就好了,保证数据不会在同一时间大面积失效。
setRedis(Key,value,time + Math.random() * 10000);
- 设置热点数据永远不过期,有更新操作就更新缓存;或使用互斥锁。
- 集群部署下,将热点数据均匀分布在不同的 Redis 库中也能避免全部失效的问题。
- 限流,避免同时处理大量的请求。
5.2.2 缓存击穿
原因是某个Key非常热点,在不停的扛着大并发,当这个Key在失效的瞬间,持续的大并发就穿破缓存,请求直接到达数据库。
解决方法:
- 设置热点数据永远不过期。
- 如果不要求数据严格一致性的话,在后台开启一个异步线程,发现失效的Key直接重写缓存;如果要严格保证数据一致的话,则使用互斥锁更新,在Key失效的时候,让一个线程读取数据并构建缓存,其他线程等待,直到缓存构建完成后重新读取缓存。
5.2.3 缓存穿透
原因是用户不断请求缓存和数据库中都没有的数据。大部分数据库的id都是从1开始自增上去的,如果请求一个id为-1或特别大的数据时,缓存中是没有,那么就都要去数据库中去执行验证。这时的用户很可能是攻击者,攻击会导致数据库压力过大,严重会击垮数据库。
解决方法:
- 在接口层增加参数校验。
- 缓存无效 key。在缓存和数据库中都没有取到的数据,在缓存中将对应Key的Value赋值为null、位置错误或稍后重试等值,该缓存有效时间可以设置短点,如30秒,设置太长会导致正常情况也没法使用,这样可以防止攻击用户反复用同一个id暴力攻击。因为正常用户是不会在单秒内发起这么多次请求的,所以可以在网关层 Nginx 对单个 IP 每秒访问次数超出阈值的 IP 都拉黑。
- 使用布隆过滤器(Bloom Filter),特点是存在性检测,利用高效的数据结构和算法快速判断出你这个 Key 是否在数据库中存在,不存在就直接返回。如果存在,再去查询数据库,最后刷新Key-Value后返回。
5.3 一致性
最经典的缓存+数据库读写的模式,就是Cache Aside Pattern。
读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
更新的时候,先更新数据库。如果涉及很多其他的逻辑操作,应该删除缓存;如果只是更改缓存中的值,无其他逻辑操作,可以直接更新。
如果更新数据库成功,而删除缓存这一步失败的情况的话,有两个解决方案:
- 缓存失效时间变短(不推荐,治标不治本) :将缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。
- 增加缓存更新重试机制(常用): 如果缓存服务当前不可用导致缓存删除失败的话,就隔一段时间进行重试,重试次数可以自己定。如果多次重试还是失败的话,我们可以把当前更新失败的 Key 存入队列中,等缓存服务可用之后,再将缓存中对应的 Key 删除即可。
6. 内存淘汰
6.1 过期时间
一般情况下,保存的缓存数据的时候都会设置一个过期时间。因为内存是有限的,如果缓存中的所有数据都是一直保存的话,容易导致内存溢出。Redis 自带了给缓存数据设置过期时间的功能,除了字符串类型有自己独有设置过期时间的命令 setex 外,其他方法都需要依靠 expire 命令来设置过期时间 。另外, persist 命令可以移除一个键的过期时间。
Redis 通过一个叫做过期字典(可以看作是 Hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个Key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 Key 所指向的数据库键的过期时间(毫秒精度的UNIX时间戳)。
过期字典是存储在 redisDb 这个结构里的:
typedef struct redisDb {
...
dict *dict; //数据库键空间,保存着数据库中所有键值对
dict *expires // 过期字典,保存着键的过期时间
...
} redisDb;
6.2 失效机制
Redis 中的key可以设置过期时间,过期后Redis采用主动和被动结合的失效机制,定期主动删除和访问时触发惰性删除。
- 定期删除:默认100s就随机抽一些设置了过期时间的key,去检查是否过期,过期了就删了。
- 惰性删除:查询Key时,判断是否过期,过期就删掉返回空。
最大内存 maxmemory 可以设置内存使用限制为指定的字节数。当达到内存限制时,Redis将根据选择的淘汰机制尝试删除Key。如果Redis无法根据策略删除密钥,或者策略为设置为noeviction
,Redis将针对如SET,LPUSH等命令回复命令错误,而继续响应只读命令。
6.3 内存淘汰机制
内存淘汰机制 | 解释 |
---|---|
noeviction | 不要逐出任何东西,只需在写操作中返回错误。 |
allkeys-lru | 回收最近最少使用的键(LRU)。 |
volatile-lru | 回收最近最少使用的键(LRU),但仅限于在过期集合的键。 |
allkeys-lfu | 回收最少使用的键(LFU)。 |
volatile-lfu | 回收最少使用的键(LFU),但仅限于在过期集合的键。 |
allkeys-random | 回收随机的键。 |
volatile-random | 回收随机的键,但仅限于在过期集合的键。 |
volatile-ttl | 回收存活时间(TTL)较短的键,但仅限于在过期集合的键。 |
如果 MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,通过内存淘汰机制可以保证 Redis 中的数据都是热点数据。
7. 复制
在Redis中,可通过执行命令 SLAVEOF 或设置 slaveof 选项,让一个服务器去复制另一个服务器。被复制的称为主服务器,对主服务器进行复制的称为从服务器。
复制功能分为同步(sync)和命令传播(command propagate):
- 同步:用于将从服务器的数据库状态更新至主服务器当前所处的服务器状态。
- 命令传播:当主服务器的数据库状态被修改,导致主从服务器的数据库状态不一致时,让主从服务器的数据库重新回到一致状态。
主服务器通过向从服务器传播命令来更新从服务器的状态,保持主从服务器一致;而从服务器则通过向主服务器发送命令来进行心跳检测,以及命令丢失检测。
7.1 旧版
同步
- 从服务器连接主服务器,发送命令 SYNC;
- 主服务器接收到命令 SYNC 后,开始执行命令 BGSAVE,在后台生成RDB文件,并使用缓冲区记录从现在开始执行的所有写命令;
- 主服务器BGSAVE执行完后,将生成的RDB文件发送给从服务器,并在发送期间继续记录被执行的写命令;
- 从服务器收到快照文件后丢弃所有旧数据,载入收到的RDB文件;
- 主服务器在发送RDB文件完毕后,开始向从服务器发送缓冲区中的写命令;
- 从服务器完成对RDB文件的载入,并执行来自主服务器缓冲区的写命令。
命令传播
在同步操作执行完毕后,主从服务器的数据库将达到一致状态。但是当主服务器执行客户端发送的写命令时,主从数据库状态又不一致了。所以主服务器需要将写命令发送给从服务器去执行,让主从数据库状态再次回到一致。
缺陷
- 从服务器初次复制可以很好完成任务,但是断线后需要重新复制,效率很低。
7.2 新版
为了解决旧版复制功能来处理断线重复复制情况时的低效问题,Redis使用命令 PSYNC 来代替命令 SYNC 来执行复制时的同步操作。
命令 PSYNC 分为完整重同步(full resynchronization)和部分重同步(patial resynchronization):
- 完整重同步:处理初次同步的情况。和命令 SYNC 执行步骤一致,通过让主服务器创建并发送RDB文件,以及向从服务器发送保存在缓冲区里面的写命令来进行同步。
- 部分重同步:处理断线后重复制的情况。当从服务器在断线后重新连接主服务器,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前状态。部分重同步通过复制偏移量,复制积压缓冲区和服务器运行ID三个部分来实现。
复制偏移量
主从服务器都分别维护一个复制偏移量,主服务器每次向从服务器传播N个字节的数据,就会在自己的服务偏移量上加N;从服务器每次收到主服务器传播来的N个字节的数据,也会在自己的服务偏移量上加N。
复制积压缓冲区
主服务器维护的一个固定长度的先进先出队列,通过 repl-backlog-size 选项控制,默认1M。当入队元素的数量大于队列长度时,最先入队的元素会被弹出,而新元素会被放入队列。
当主服务器进行命令传播时,不仅将写命令发送给所有从服务器,还会将写命令入队到复制积压缓冲区中。主服务器的复制积压缓冲区里面会保存一部分最近传播的写命令,并且会为每个字节记录偏移量。
当从服务器重新连上主服务器,从服务器通过命令 PSYNC 将自己的复制偏移量发送给主服务器,主服务器会根据这个复制偏移量决定进行何种同步。如果偏移量之后的数据(即offset+1)仍然在复制积压缓冲区中,那么进行部分重同步;否则进行完整重同步。
服务器运行ID
无论主从,每个服务器都有自己的运行ID,在启动时自动生成。
当从服务器对主服务器进行初次复制,主服务器会将自己的运行ID发送给从服务器,从服务器会将这个运行ID保存起来。当从服务器断线并重新连上一个主服务器,从服务器将向当前连接的主服务器发送之前保存的运行ID。如果断线前后的主服务器运行ID相同,主服务器继续尝试执行部分重同步;相反,主服务器执行完成重同步。
8. 哨兵
哨兵(Sentinal)系统由一个或多个哨兵实例组成,通过Raft协议来保证自身的高可用。哨兵系统可以监控任意多个主服务器,以及这些主服务器属下的所有从服务器。在被监控的主服务器进入下线状态时,哨兵系统会选出主哨兵,负责执行故障转移操作,将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求,保证了Redis的高可用。
8.1 建立连接
哨兵在启动后会建立两条连接。
- 获取监控着此Redis系统的其他哨兵信息。
- 给主服务器发送命令info获取信息。
8.2 执行任务
当和主服务器完成链接建立后,该哨兵就会定时完成三件事情。
- 每10秒会向主从服务器发送命令info;
- 每2秒会向主从服务器发送自己的信息;
- 每1秒会向主从服务器以及其他同样在监控着此Redis系统的哨兵发送命令ping。
命令info可以让哨兵获取到当前服务器的信息,比如运行id,复制信息等等,并实现新节点的自动发现。从服务器的信息也是从命令info中获取的,获取后哨兵也会和从服务器建立两条和主服务器相同的链接,之后哨兵就会每10s向主从服务器发送命令info,当有新的从服务器加入时,就会从命令info中发现了,从而将这个新的从服务器加入自己的监控列表中。如果有新的哨兵加入到了监控中,其他哨兵也是从这个命令info中获取的。
8.3 故障转移
- 主观下线:哨兵会每秒向这些节点发送命令PING,如果一段时间没有收到回复后,那么这个哨兵就会认为该节点已经挂了。
- 客观下线:如果主服务器一段时间没有回复,哨兵就会向其他节点询问,看其他节点是否也认为该节点挂了,当票数达到了一定的次数,那么哨兵就认为该节点真的挂了。
哨兵选举
主服务器下线后,哨兵会选举出一个主哨兵处理此次的故障转移。
- 第一个发现该主服务器挂的哨兵,向每个哨兵发送命令,让对方选举自己成为主哨兵;
- 其他哨兵如果没有选举过其他人,就会将这一票投给它;
- 当发现有超过一半哨兵都投给自己,并且数量也超过了参数quoram,那么该哨兵就成了主哨兵;
- 如果多个哨兵同时参与这个选举,那么就会重复该过程,直到选出一个主哨兵。
选主策略
选出主哨兵后,开始执行故障转移,选择已下线的主服务器下的一个从服务器作为新主服务器。
- 从服务器的priority设置的越低
- 同等情况下,从服务器复制的数据越多
- 相同的条件下,运行id越小
8.4 主要功能
- 集群监控:负责监控 Redis 主从进程是否正常工作;
- 消息通知:如果某个 Redis 实例有故障,那么哨兵负责发送消息作为报警通知给管理员;
- 故障转移:如果主服务器挂掉了,会自动转移到从服务器上;
- 配置中心:如果故障转移发生了,通知客户端新的主服务器地址。
9. 集群
集群(Cluster)是Redis提供的分布式数据库方案,集群通过分片(sharding)来进行数据共享,并提供复制和故障转移功能。
分片
集群的整个数据库被分为16384个槽,对每个键使用CRC16算法计算出散列值,然后对16384取模,获取键对应的槽。数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。
复制
Redis集群中的节点分为主从服务器。主服务器用于处理槽,而从服务器则用于复制某个主服务器.
故障转移
当被复制的主服务器下线时,在从服务器中通过Raft协议选举新的主服务器,代替下线主服务器继续处理命令请求。
提高Redis的读写能力
提高写能力只需要横向扩容主服务器,提高读能力只需要横向扩容从服务器
10. 底层数据结构
10.1 简单动态字符串
Redis没有直接使用C语言传统的字符串标识,而是使用简单动态字符串(simple dynamic string),并将SDS用于默认字符串表示。SDS 还可以作为缓冲区:包括 AOF 模块中的AOF缓冲区以及客户端状态中的输入缓冲区。
struct sdshdr{
// 记录buf数组中已使用字节的数量,等于SDS保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
}
与C字符串区别
区别 | C字符串 | SDS |
---|---|---|
获取长度复杂度 | O(N) | O(1) |
缓冲区溢出 | API不安全,会导致 | API安全,不会导致 |
内存重分配 | 修改字符串长度N次,就进行N次内存重分配 | 修改字符串长度N次,最多进行N次 |
数据类型 | 文本 | 文本或二进制 |
<string.h> 库 | 使用所有函数 | 使用部分函数 |
常数复杂度获取字符串长度
C 语言,获取字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n);SDS有 len 属性,获取 SDS 字符串的长度只需要读取 len 属性。
杜绝缓冲区溢出
C 语言中使用 strcat 函数来进行两个字符串的拼接,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出;SDS 数据类型,在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后再进行修改操作。
减少修改字符串的内存重新分配次数
C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。
SDS由于属性len和free的存在,修改字符串实现了空间预分配和惰性空间释放两种策略:
- 空间预分配(优化字符串增长):SDS在进行修改,还会为SDS分配额外的未使用空间。当len小于1MB,那么分配和len同样大小的未使用空间;当len大于1MB,那么分配1MB未使用空间。通过空间预分配,Redis可以减少连续执行字符串增长操作所需的内存重分配。
- 惰性空间释放(优化字符串缩短):SDS要缩短保存的字符串时,不会立即使用内存重分配回收缩短出来的字节,而是使用free属性将这些字节数量记录下来,并等待将来使用。避免缩短字符串时所需的内存重分配操作,并为将来的增长提供优化。SDS也提供了相应的API,可以手动释放这些未使用的空间。
二进制安全
C字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此C字符串无法正确存取;SDS 以处理二进制的方式来处理 buf 里面的元素,并且 SDS 不是以空字符串来判断是否结束,而是以 len 属性表示的长度来判断字符串是否结束。
兼容部分 C 字符串函数
SDS 是二进制安全的,但是一样遵从每个字符串都是以空字符串结尾的惯例,这样可以重用 C 语言库<string.h> 中的一部分函数。
10.2 字典
Redis的字典使用哈希表作为底层实现,类似HashMap。
一个哈希表里面可以有多个哈希表节点,而每个哈希节点就保存了字典中的一个键值对。
10.3 跳跃表
跳跃表(SkipList)是基于有序链表的扩展,通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。支持平均O(logN),最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。Redis使用跳表而不用B+树,是因为跳表相比B+树实现简单,占用内存小。
跳跃表有很多层组成,每层都是链表,并且是有序的,都至少包含两个节点,分别是前面的head节点和后面的nil节点。每个节点保存这指向同层下一个节点和指向下一层同一个节点的指针,如果一个元素出现在某一层的链表中,那么在该层之下的链表也全都会出现(上一层的元素是当前层的元素的子集)。最底层的链表保存了所有的数据,通过分层优化搜索存储在最底层的数据,进行搜索和插入。
- 搜索:从最高层的链表节点开始,如果比当前节点要大和比当前层的下一个节点要小,那么则往下找。也就是和下一层的同节点的下一个节点进行比较,以此类推,一直找到最底层的最后一个节点,如果找到则返回,反之则返回空。
- 插入:通过随机层数,确认插入的层数,将新元素插入到从底层到指定层。Redis 跳跃表默认允许最大的层数是 32。
- 删除:在各个层中找到包含指定值的节点,然后将节点从链表中删除即可,如果删除以后只剩下头尾两个节点,则删除这一层。
10.4 整数集合
整数集合(intset)是集合的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合的底层实现。
struct inset{
// 编码方式
uint32_t encoding;
// 集合包含元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
}
intset可以保存int16_t、int32_t或int64_t的整数值,并且保证集合中不会出现重复元素(通过二分查找整数判断是否已存在)。
升级
当新元素要添加到整数集合,并且新元素的类型比整数集合现有所有元素的类型要长时,整数集合会先进行升级,然后才能将新元素添加到里面,所以添加元素的时间复杂度为O(N)。新元素小于所有现有元素时,新元素添加到最开头(索引0);相反,新元素添加到最末尾(索引length-1)。
升级策略可以提升整数集合的灵活性,并且可以尽可能的节约内存;但不支持降级,升级后会一直保持升级状态。
10.5 压缩列表
压缩列表(ZipList)是Redis为了节约内存而开发,是由一系列特殊编码的连续内存块组成的顺序型数据结构。
压缩列表类似数组。但是数组要求每个元素大小相同,如果存储不同长度的字符串,就需要用最大长度的字符串大小作为元素的大小,存储小字符串时会导致空间浪费。
压缩列表包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值,大小不固定。所以每个节点添加length属性表示长度,那么在遍历节点的之后就知道每个节点的长度,再计算出下一个节点在内存中的位置。
当一个列表只包含少量列表项或一个哈希只包含少量键值对,Redis就会使用压缩列表来做列表和哈希的底层实现。
11. 数据类型底层实现
判断对象类型
type key
查看值对象的编码
object encoding key
String
String的编码可以是int,embstr或raw。
- int 编码:保存的是可以用 long 类型表示的整数值。
- embstr 编码:保存长度小于44字节的字符串(Redis 3.2 版本之前是39字节,之后是44字节)。
- raw 编码:保存长度大于44字节的字符串(Redis 3.2 版本之前是39字节,之后是44字节)。
当 int 编码保存的值不再是整数,或大小超过了long的范围时,自动转化为raw。对于 embstr 编码,由于 Redis 没有对其编写任何的修改程序(embstr 是只读的),在对embstr对象进行修改时,都会先转化为raw再进行修改,因此,只要是修改embstr对象,修改后的对象一定是raw的,无论是否达到了44个字节。
List
List的编码可以是压缩列表,双端链表。
当同时满足下面两个条件时,使用压缩列表编码:
- 每个元素长度小于64字节(redis.conf 中的 list-max-ziplist-value 选项);
- 列表保存元素个数小于512个(redis.conf 中的 list-max-ziplist-entries 选项)。
不能满足这两个条件的时候使用双端链表编码。
Hash
Hash的编码可以是压缩列表,字典。
当同时满足下面两个条件时,使用压缩列表编码:
- 每个元素长度小于64字节;
- 列表保存元素个数小于512个(redis.conf 中的 set-max-intset-entries 选项)。
不能满足这两个条件的时候使用字典编码。
Set
Set的编码可以是整数集合,字典。
当同时满足下面两个条件时,使用整数集合编码:
- 集合对象中所有元素都是整数;
- 集合对象所有元素数量不超过512(redis.conf 中的 set-max-intset-entries 选项)。
不能满足这两个条件的就使用字典编码。
Sorted Set
Sorted Set的编码可以是压缩列表,跳跃表+字典。
当同时满足下面两个条件时,使用压缩列表编码:
- 元素长度都小于64字节(redis.conf 中的 zset-max-ziplist-value 选项);
- 元素数量小于128(redis.conf 中的 zset-max-ziplist-entries 选项)。
不能满足上面两个条件的使用跳跃表+字典编码。
12. 与Memcache区别
Memcache
- 使用多线程异步IO的方式,可以合理利用CPU多核的优势,性能非常优秀;
- 功能简单,使用内存存储数据;
- 对缓存的数据可以设置失效期,过期后的数据会被清除;
- 失效的策略采用延迟失效,就是当再次使用数据时检查是否失效;
- 当容量存满时,会对缓存中的数据进行剔除,剔除时除了会对过期key进行清理,还会按LRU策略对数据进行剔除。
Memcache限制
- key不能超过250个字节;
- value不能超过1M字节;
- key的最大失效时间是30天;
- 只支持K-V结构,不提供持久化和主从同步功能。
Redis支持复杂的数据结构
Redis相比Memcached来说,拥有更多的数据结构,能支持更丰富的数据操作。
Redis原生支持集群模式
在redis 3.x版本中,就能支持Cluster模式,而Memcached没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据。
性能对比
由于Redis只使用单核,而Memcached可以使用多核,所以平均每一个核上Redis在存储小数据时比Memcached性能更高。而在100k以上的数据中,Memcached性能要高于Redis,虽然Redis最近也在存储大数据的性能上进行优化,但是比起 Remcached,还是稍有逊色。
13. 生产策略
- 事前:Redis 高可用,主从+哨兵,Redis cluster,避免全盘崩溃;
- 事中:本地ehcache缓存 + Hystrix限流 + 降级,避免MySQL被打死;
- 事后:Redis 持久化 RDB+AOF,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。
这样数据库不会死,限流组件确保了每秒只有多少个请求能通过。只要数据库不死,那么3/5的请求都是可以被处理的。 只要有3/5的请求可以被处理,就意味着你的系统没死,对用户来说,可能就是点击几次刷不出来页面,但是多点几次,就可以刷出来一次。
14. 命令
keys
获取指定模式key列表。由于Redis单线程,如果Key很多,执行keys命令就会阻塞直到完成。
scan
无阻塞获取指定模式的key列表,会有一定的重复概率,需要在客户端做一次去重,整体所花费的时间会比直接用keys指令长。命令 scan 仅对返回的元素提供有限保证,因为递增迭代的集合可以在迭代过程中更改。
del
删除命令del是阻塞的。
unlink
异步删除一个key。
flushall/flushdb async
异步删除数据库所有key。
setnx key value
将key设置值为value,如果key不存在,这种情况下等同SET命令。 当key存在时,什么也不做。SETNX是”SET if Not eXists”的简写。
set key value [EX seconds] [PX milliseconds] [NX|XX]
EX seconds:设置失效时长,单位秒
PX milliseconds:设置失效时长,单位毫秒
NX:key不存在时设置value,成功返回OK,失败返回(nil)
XX:key存在时设置value,成功返回OK,失败返回(nil)
15. 用途
15.1 分布式锁
单实例下通过命令set key value [EX seconds] [PX milliseconds] [NX|XX]
实现,集群使用Redis实现分布式锁的规范算法Redlock。
流程
- 获取当前时间t1;
- 依次尝试在N个实例中,使用相同的key和唯一的value获取锁,并设置过期时间ex。客户端还需要设置获取该锁的超时时间,超过了这个时间,就去询问下一个实例,超时时间应比锁过期时间小。
例如锁过期时间为10秒,则超时时间应该在5-50毫秒之间。如果这个实例不行,那么换下一个实例设置,防止客户端死等。 - 获取当前时间t2,则获取锁的消耗时间为t2-t1。当从至少N/2+1个实例中都取到锁,并且使用的总耗时小于锁过期时间,锁才算获取成功。
- 如果获取锁成功,key的真正有效时间为锁过期时间减去获取锁的总耗时,即
ex-获取锁的总耗时-时钟漂移
。 - 如果获取锁失败,即没有从至少N/2+1个实例中都取到锁或者取锁时间超过了有效时间,在所有实例中解锁,即使没有加锁成功的实例。
失败时重试
当客户端无法获取锁时,应该在一个随机延迟后重试,防止多个多个客户端同时抢锁,导致脑裂,没人获取到锁。
优缺点
- 优点:性能好。
- 缺点:实现复杂,考虑因素多,需要部署5个节点才更可靠等。
15.2 限流
zset
zset中value保证唯一,score为时间戳,用命令zrangebyscore获取两个时间戳中间有多少请求,起到滑动窗口的效果,保证每N秒内至多M个请求。缺点就是zset数据结构会越来越大。
漏桶
水先进入漏桶,漏桶以一定速度出水。当水流入速度过大会直接溢出。漏桶算法可以很好地限制容量池的大小,从而防止流量暴增。
在Redis中使用限流模块redis-cell。
令牌桶
以一个恒定的速度往桶里放入令牌,当请求需要被处理时,先从桶里获取一个令牌。当桶里没有令牌可取时,则拒绝服务。
令牌桶算法通过发放令牌,根据令牌的频率做请求频率和容量限制等。
漏桶和令牌桶
区别
漏桶算法能够强行限制数据的传输速率。
令牌桶算法能够在限制数据的平均传输速率的同时还允许某种程度的突发传输。
在某些情况下,漏桶算法不能够有效地使用网络资源。因为漏桶的漏出速率是固定的,所以即使网络中没有发生拥塞,漏桶算法也不能使某一个单独的数据流达到端口速率。因此,漏桶算法对于存在突发特性的流量来说缺乏效率。而令牌桶算法则能够满足这些具有突发特性的流量。
使用场景
漏桶算法:恒定速率流出,不支持突发流量。在依赖服务没有做限流的场景下,可以用于防止打垮我们依赖服务,因为第三方服务的最大水位及其在最大水位可持续服务多长时间,对上层服务是未知的。
令牌桶算法:恒定速率流入,可以支持突发流量。通常突发流量最大值对于我们自己维护的服务是清晰可控的,为保证系统的最大可用性(尽可能处理更多的请求),同时防止自己的服务被打垮,优先使用令牌桶算法。
16. 调优
16.1 慢查询
Redis的慢查询日志是在命令执行前后计算每条命令的执行时间,当超过预设阈值,就将这条命令的相关信息(识别id、发生时间戳、命令耗时、执行命令和参数)记录下来。通过配置slowlog-log-slower-than
和slowlog-max-len
控制,可修改配置文件或通过命令config set
动态修改,再执行命令config rewrite
重写配置文件。
slowlog-log-slower-than
:表示预设阈值,默认值10000,单位是微秒(1秒=1000毫秒=1000000微秒)。当执行的命令超过了10000微秒,那么它将被记录在慢查询日志中。如果设置为0,那么会记录所有的命令;如果设置为小于0,那么不会记录命令。slowlog-max-len
:表示最多存储最大长度。Redis使用一个列表来存储慢查询日志,当慢查询日志列表已经处于最大长度时,会移除列表中最早插入的那条记录。
在高流量的场景下,将预设阈值设为1毫秒。适当增大慢查询列表长度,减缓被剔除的可能,Redis在记录慢查询时会对长命令做截断操作,并不会占用大量内存。慢查询记录在内存中,可以定期获取进行持久化。
参考:
Redis基础
Redis
Redis 6.0 新特性-多线程连环13问!
缓存雪崩、击穿、穿透
缓存穿透、击穿、雪崩
Redis哨兵、持久化、主从、手撕LRU
双写一致性、并发竞争、线程模型
Redis常见面试题
Redis的最全知识点
I/O多路复用技术(multiplexing)是什么?
缓存,究竟是淘汰,还是修改?
究竟先操作缓存,还是数据库?
Redis 的主从同步,及两种高可用方式
Redis 教程
Redis 中文官网 redis.conf
理解Redis 6 的多线程
说说 Redis 主从哨兵集群 ~
Redis 设计与实现
漫画:什么是跳跃表?
Redis(2)——跳跃表
Redis数据结构——压缩列表
Redis详解(四)------ redis的底层数据结构
Redis详解(五)------ redis的五大数据类型实现原理
Redis深度历险:核心原理和应用实践
Redis分布式锁
一文掌握 Redisson 分布式锁原理
Spring Boot 整合 redisson 来实现分布式锁
三种分布式锁优缺点及解决方案
限流算法–令牌桶和漏斗