简介
Redis 是一个开源的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件。 它支持多种类型的数据结构,如 字符串(strings), 散列(hashes), 列表(lists), 集合(sets), 有序集合(sorted sets) 与范围查询, bitmaps, hyperloglogs 和 地理空间(geospatial) 索引半径查询等。 Redis 内置了 复制(replication),LUA脚本(Lua scripting), LRU驱动事件(LRU eviction),事务(transactions) 和不同级别的 磁盘持久化(persistence), 并通过 Redis哨兵(Sentinel)和自动 分区(Cluster)提供高可用性(high availability)。
为什么使用缓存服务器
缓存是高并发场景下提高热点数据访问性能的一个有效手段,在开发项目时会经常使用到。
缓存的类型分为:本地缓存、分布式缓存和多级缓存。
本地缓存 就是在进程的内存中进行缓存,比如我们的 JVM 堆中,本地缓存是内存访问,没有远程交互开销,性能最好,但是受限于单机容量,一般缓存较小且无法扩展。
分布式缓存一般都具有良好的水平扩展能力,对较大数据量的场景也能应付自如。缺点就是需要进行远程请求,性能不如本地缓存。
为了平衡这种情况,实际业务中一般采用多级缓存,本地缓存只保存访问频率最高的部分热点数据,其他的热点数据放在分布式缓存中。
Redis & Memcached
- 数据结构:Memcached只支持简单的key/value数据结构,不像Redis可以支持丰富的数据类型。
- 持久化:Memcached无法进行持久化,数据不能备份,只能用于缓存使用,且重启后数据全部丢失。
- 多线程:Redis 使用单线程反而避免了多线程的频繁上下文切换问题,预防了多线程可能产生的竞争问题。
Memcache 存在着支持并发性不好、可运维性欠佳、原子性操作不够、在误操作时产生数据不一致等问题。
由于 Redis 只使用单核,而 Memcached 可以使用多核,所以 Redis 在存储小数据时比 Memcached 性能更高。而在 100k 以上的数据中,Memcached 性能要高于 Redis,虽然 Redis 最近也在存储大数据的性能上进行优化,但是比起 Memcached,还是稍有逊色。 - 分布式:Redis原生支持集群模式,Memcached没有原生的集群模式。
Redis 支持通过Replication进行数据复制,通过master-slave机制,可以实时进行数据的同步复制,支持多级复制和增量复制,master-slave机制是Redis进行HA的重要手段。
线程模型
Redis 内部使用文件事件处理器 file event handler
,这个文件事件处理器是单线程的,所以 Redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 Socket,根据 Socket 上的事件来选择对应的事件处理器进行处理。
文件事件处理器的结构包含 4 个部分:
- 多个 Socket
- IO 多路复用程序
- 文件事件分派器
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
多个 Socket 可能会并发产生不同的操作,每个操作对应不同的文件事件,但是 IO 多路复用程序会监听多个 Socket,会将 Socket 产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。
为什么Redis单线程也能效率这么高?
- 完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。它的数据存在内存中,类似于HashMap,HashMap 的优势就是查找和操作的时间复杂度都是O(1);
- 采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;
- 使用多路I/O复用模型,非阻塞IO;
- Redis 直接自己构建了 VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求;
数据结构
基本:String、Hash、List、Set、SortedSet
进阶:HyperLogLog、Geo、Pub/Sub、bitmaps
高级:BloomFilter
基本
String:存储简单的对象序列化字符串,应用场景:缓存热点数据、计数器、会话token
Hash:保存无嵌套的对象属性
List:列表,应用场景:简单消息队列、分页
Set:无序集合,自动去重,应用场景:共同好友
Sorted Set:有序集合,自动去重,应用场景:排行榜、微博热搜
进阶
Geo:可以用来保存地理位置,并作位置距离计算或者根据半径计算位置等。 应用场景:附近的人
Pub/Sub:订阅/发布,应用场景:简单消息队列
HyperLogLog:用来做基数统计的算法 ,比如数据集 {1, 3, 5, 7, 5, 7, 8}, 那么这个数据集的基数集为 {1, 3, 5 ,7, 8}, 基数(不重复元素)为5。 基数统计就是在误差可接受的范围内,快速计算基数。 应用场景:日活跃用户
Bitmap:支持按bit位来存储信息,等同于byte数组,计数效率高,应用场景:日活跃用户、布隆过滤器
ps:HyperLogLog只需要用12K内存就可以统计2^64个不同元素的基数
bitmaps存储一亿用户需要12.5M内存
高级
BloomFilter
布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。 存在误判,可能要查到的元素并没有在容器中,但是hash之后得到的k个位置上值都是1。如果bloom filter中存储的是黑名单,那么可以通过建立一个白名单来存储可能会误判的元素。删除困难。一个放入容器的元素映射到bit数组的k个位置上是1,删除的时候不能简单的直接置为0,可能会影响其他元素的判断。
布隆过滤器的原理是,当一个元素被加入集合时,通过K个散列函数将这个元素映射成一个位数组中的K个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。
Bloom Filter跟单哈希函数Bit-Map不同之处在于:Bloom Filter使用了k个哈希函数,每个字符串跟k个bit对应。从而降低了冲突的概率。
应用场景:允许一定误差的大数据去重(举个栗子:黑名单、推荐和浏览历史去重)
底层数据结构
SDS
Redis 用SDS(Simple Dynamic String)来保存字符串,SDS还被用作缓冲区(buffer)AOF模块中的AOF缓冲区。
struct sdshdr { // buf 中已占用空间的长度 int len; // buf 中剩余可用空间的长度 int free; // 数据空间 char buf[]; }; 复制代码
使用SDS的好处:
- 便于获取字符串长度
- 杜绝缓冲区溢出
- 减少修改字符串时带来的内存重分配次数
链表
List的底层实现之一就是链表
typedef struct listNode{ struct listNode *prev; struct listNode * next; void * value; } 复制代码
- 双端:链表节点带有prev 和next 指针,获取某个节点的前置节点和后置节点的时间复杂度都是O(N)
- 无环:表头节点的 prev 指针和表尾节点的next 都指向NULL,对立案表的访问时以NULL为截止
- 表头和表尾:因为链表带有head指针和tail 指针,程序获取链表头结点和尾节点的时间复杂度为O(1)
- 长度计数器:链表中存有记录链表长度的属性 len
整数集合
整数集合是集合(set)的底层实现之一,当一个集合中只包含整数,且这个集合中的元素数量不多时,redis就会使用整数集合intset作为集合的底层实现。
typedef struct intset{ //编码方式 uint32_t enconding; // 集合包含的元素数量 uint32_t length; //保存元素的数组 int8_t contents[]; } 复制代码
整数集合的底层实现为数组,这个数组以有序,无重复的范式保存集合元素,在有需要时,程序会根据新添加的元素类型改变这个数组的类型。
字典
字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对的抽象数据结构。类似HashMap,当发生哈希冲突时,采用头插法向单向链表表头插入元素。
rehash的时候将ht[0]数据重新分配到ht[1]中,将ht[0]释放,将ht[1]设置成ht[0],最后为ht[1]分配一个空白哈希表。
然而在实际开发过程中,rehash 操作并不是一次性、集中式完成的,而是分多次、渐进式地完成的。
渐进式rehash 的详细步骤:
1、为ht[1] 分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
2、在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash 开始
3、在rehash 进行期间,每次对字典执行CRUD操作时,程序除了执行指定的操作以外,还会将ht[0]中的数据rehash 到ht[1]表中,并且将rehashidx加1
4、当ht[0]中所有数据转移到ht[1]中时,将rehashidx 设置成-1,表示rehash 结束
采用渐进式rehash 的好处在于它采取分而治之的方式,避免了集中式rehash 带来的庞大计算量。
跳表
跳表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表是一种随机化的数据,跳跃表以有序的方式在层次化的链表中保存元素,效率和平衡树媲美 ——查找、删除、添加等操作都可以在对数期望时间下完成,并且比起平衡树来说,跳表的实现要简单直观得多。
Redis 只在两个地方用到了跳表,一个是实现有序集合键,另外一个是在集群节点中用作内部数据结构。
跳表数据结构其实相当于给原始链表加上多级索引
ps: 跳表是通过随机函数来维护“平衡性”。当我们往跳表中插入数据的时候,我们可以通过一个随机函数,来决定这个结点插入到哪几级索引层中,比如随机函数生成了值K,那我们就将这个结点添加到第一级到第K级这个K级索引中。
持久化
RDB做镜像全量持久化,AOF做增量持久化。因为RDB会耗费较长时间,不够实时,在停机的时候会导致大量丢失数据,所以需要AOF来配合使用。
RDB
RDB对Redis的性能影响非常小,是因为在同步数据的时候他只是fork了一个子进程去做持久化的,fork是指redis通过创建子进程来进行RDB操作,cow指的是copy on write,子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写脏的页面数据会逐渐和子进程分离开来。
RDB在数据恢复时比AOF快,因为数据文件小,每条记录只保存了一次,AOF一条记录可能保存多次操作记录。RDB文件的存储格式和Redis数据在内存中的编码格式是一致的,不需要再进行数据编码工作,所以在CPU消耗上要远小于AOF日志的加载。
缺点:快照截屏间隔可能较久,如果采用RDB进行持久化,服务挂掉可能造成更多数据的丢失;在生成快照时如果文件很大可能导致客户端卡顿
SAVE命令由服务器进程直接执行保存操作,会阻塞服务器。BGSAVE命令由子进程执行保存操作,不会阻塞服务器。
服务器状态中会保存所有用save选项设置的保存条件,当任意一个保存条件被满足时,服务器会自动执行BGSAVE命令。
AOF
根据默认配置,RDB 五分钟一次生成快照,但是 AOF 是一秒一次去通过一个后台的线程fsync
操作,那最多丢这一秒的数据。
AOF在对日志文件进行操作的时候是以 append-only
的方式去写的,他只是追加的方式写数据,自然就少了很多磁盘寻址的开销了,写入性能惊人,文件也不容易破损。
AOF的日志是通过一个叫 非常可读 的方式记录的,这样的特性就适合做 灾难性数据误删除 的紧急恢复了,比如公司的实习生通过 flushall 清空了所有的数据,只要这个时候后台重写还没发生,你马上拷贝一份 AOF 日志文件,把最后一条 flushall 命令删了就完事了。
缺点:一样的数据,AOF 文件比 RDB 还要大;AOF开启后,Redis支持写的QPS会比RDB支持写的要低,因为每秒异步刷新一次日志
AOF文件通过保存所有修改数据库的写命令请求来记录服务器的数据库状态;命令请求会先保存到AOF缓冲区里面,之后再定期写入并同步到AOF文件。
AOF重写 首先从数据库中读取键现在的值,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令 ,产生一个新的AOF文件,新AOF文件和原AOF保存的数据库状态一样,但体积更小;
在执行BGREWRITEAOF命令时,Redis会维护一个AOF重写缓冲区,该缓冲区会在子进程创建新的AOF文件期间,记录服务器执行的所有写命令。当子进程完成创建新AOF文件之后,服务器会将重写缓冲区中的所有内容追加到新的AOF文件的末尾。最后,服务器用新的AOF文件替换旧的AOF文件,以此来完成AOF文件重写操作。
配置项
no-appendfsync-on-rewrite
是否在后台写时阻塞,默认值no(表示阻塞写操作)。no表示新的主进程的set操作会被阻塞掉,而yes表示新的主进程的set不会被阻塞,待整个后台写完成之后再将这部分set操作同步到aof文件中。但这可能会存在数据丢失的风险(机率很小),如果对性能有要求,可以设置为yes,仅在后台写时会异步处理命令。
auto-aof-rewrite-percentage
AOF文件的体积比上一次重写之后的增长比例,假设用户对Redis设置了配置选项auto-aof-rewrite-percentage 100,那么当AOF文件的体积比上一次重写之后的文件大小大了至少一倍(100%)的时候,Redis将执行BGREWRITEAOF命令。
auto-aof-rewrite-min-size
触发AOF文件重写的最小的文件大小,即最开始AOF文件必须要达到这个文件大小时才触发重写,后面的每次重写就不会根据这个变量了(根据上一次重写完成之后的大小) 。
内存淘汰
过期策略
Redis键的过期策略,是有定期删除+惰性删除两种。
定期好理解,默认100ms就 随机 抽一些设置了过期时间的key,去检查是否过期,过期了就删了。
惰性删除,查询时再判断是否过期,过期就删除键不返回值。
内存淘汰机制
当新增数据发现内存达到限制时,Redis触发内存淘汰机制。
配置项
maxmemory
配置Redis存储数据时指定限制的内存大小,比如100m。当缓存消耗的内存超过这个数值时, 将触发数据淘汰。该数据配置为0时,表示缓存的数据量没有限制, 即LRU功能不生效。64位的系统默认值为0,32位的系统默认内存限制为3GB。
maxmemory_policy
触发数据淘汰后的淘汰策略
- no-eviction:当内存限制达到并且客户端尝试执行会让更多内存被使用的命令返回错误(大部分的写入指令,但DEL和几个例外)
- allkeys-lru: 尝试回收最少使用的键(LRU),使得新添加的数据有空间存放。
- volatile-lru: 尝试回收最少使用的键(LRU),但仅限于在过期集合的键,使得新添加的数据有空间存放。
- allkeys-lfu: 尝试回收最近最不常用的键(LFU),使得新添加的数据有空间存放。
- volatile-lfu: 尝试回收最近最不常用的键(LFU),但仅限于在过期集合的键,使得新添加的数据有空间存放。
- allkeys-random: 回收随机的键使得新添加的数据有空间存放。
- volatile-random: 回收随机的键使得新添加的数据有空间存放,但仅限于在过期集合的键。
- volatile-ttl: 回收在过期集合的键,并且优先回收存活时间(TTL)较短的键,使得新添加的数据有空间存放。
maxmemory_samples
随机采样的精度,也就是随即取出key的数目。该数值配置越大, 越接近于真实的LRU算法,但是数值越大,相应消耗也变高,对性能有一定影响,样本值默认为5。
近似LRU算法
真实LRU算法需要一个双向链表来记录数据的最近被访问顺序,比较耗费内存。
Redis 通过对少量键进行取样,然后回收其中的最久未被访问的键。通过调整每次回收时的采样数量maxmemory-samples,可以实现调整算法的精度。
Redis 的键空间是放在一个哈希表中的,要从所有的键中选出一个最久未被访问的键,需要另外一个数据结构存储这些源信息,这显然不划算。最初,Redis只是随机的选3个key,然后从中淘汰,后来算法改进到N个key的策略,默认是5个。
Redis 3.0之后又改善了算法的性能,会提供一个待淘汰候选key的pool,里面默认有16个key,按照空闲时间排好序。更新时从Redis键空间随机选择N个key,分别计算它们的空闲时间 idle,key只会在pool不满或者空闲时间大于pool里最小的时,才会进入pool,然后从pool中选择空闲时间最大的key淘汰掉。
Redis为什么不使用真实的LRU实现是因为这需要太多的内存。不过近似的LRU算法(approximated LRU)对于应用而言应该是等价的。
- 浅灰色带是已经被回收的对象。
- 灰色带是没有被回收的对象。
- 绿色带是被添加的对象。
- 在LRU实现的理论中,我们希望的是,在旧键中的第一半将会过期。Redis的LRU算法则是概率的过期旧的键。
分布式
Redis支持三种分布式部署的方式:主从复制、哨兵模式、集群模式
主从复制
工作方式
Redis可以使用主从同步,从从同步。第一次同步时,主节点做一次bgsave,并同时将后续修改操作记录到内存buffer,待完成后将RDB文件全量同步到复制节点,复制节点接受完成后将RDB镜像加载到内存。加载完成后,再通知主节点将期间修改的操作记录同步到复制节点进行重放就完成了同步过程。后续的增量数据通过AOF日志同步即可,有点类似数据库的binlog。
优点
可以进行读写分离,分担了主服务器读操作的压力
缺点
Redis不具备自动容错和恢复功能,主机从机的宕机都会导致前端部分读写请求失败,较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂。
哨兵模式
当主服务器中断服务后,可以将一个从服务器升级为主服务器,以便继续提供服务,但是这个过程需要人工手动来操作。 为此,Redis 2.8中提供了哨兵工具来实现自动化的系统监控和故障恢复功能。
哨兵的作用就是监控Redis系统的运行状况。它的功能包括以下两个。
(1)监控主服务器和从服务器是否正常运行。 (2)主服务器出现故障时自动将从服务器转换为主服务器。
工作方式
Sentinel是Redis的高可用性(HA)解决方案,由一个或多个Sentinel实例组成的Sentinel系统可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进行下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。
Redis提供的sentinel(哨兵)机制,通过sentinel模式启动redis后,自动监控master/slave的运行状态,基本原理是:心跳机制+投票裁决,每个sentinel只有一次选举的机会,当主库出现故障,哨兵会投票从库中选出一个承担主库的任务,剩下的还是从库
优点
- 可以进行读写分离,分担了主服务器读操作的压力
- 主从可以自动切换,可用性更高
缺点
- Redis较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂
集群模式
redis集群是一个由多个主从节点群组成的分布式服务器群,它具有复制、高可用和分片特性。Redis集群不需要sentinel哨兵也能完成节点移除和故障转移的功能。需要将每个节点设置成集群模式,这种集群模式没有中心节点,可水平扩展,据官方文档称可以线性扩展到上万个节点(官方推荐不超过1000个节点)。redis集群的性能和高可用性均优于之前版本的哨兵模式,且集群配置非常简单。
集群模式有以下几个特点:
- 由多个Redis服务器组成的分布式网络服务集群;
- 集群之中有多个Master主节点,每一个主节点都可读可写;
- 节点之间会互相通信,两两相连;
- Redis集群无中心节点。
优点
- 在哨兵模式中,仍然只有一个Master节点。当并发写请求较大时,哨兵模式并不能缓解写压力。 我们知道只有主节点才具有写能力,那如果在一个集群中,能够配置多个主节点,缓解写压力,redis-cluster集群模式能达到此类要求。
- 在Redis-Cluster集群中,可以给每一个主节点添加从节点,主节点和从节点直接遵循主从模型的特性。
当用户需要处理更多读请求的时候,添加从节点可以扩展系统的读性能。
故障转移
Redis集群的主节点内置了类似Redis Sentinel的节点故障检测和自动故障转移功能,当集群中的某个主节点下线时,集群中的其他在线主节点会注意到这一点,并对已下线的主节点进行故障转移。 集群进行故障转移的方法和Redis Sentinel进行故障转移的方法基本一样,不同的是,在集群里面,故障转移是由集群中其他在线的主节点负责进行的,所以集群不必另外使用Redis Sentinel。
集群分片策略
常见的集群分片算法有:一般哈希算法、一致性哈希算法以及Hash Slot算法,Redis采用的是Hash Slot
一般哈希算法
计算方式:hash(key)%N
缺点:如果增加一个redis,映射公式变成了 hash(key)%(N+1)
如果一个redis宕机了,映射公式变成了 hash(key)%(N-1)
在以上两种情况下,几乎所有的缓存都失效了。
一致性哈希算法
先构造出一个长度为2^32整数环,根据节点名称的hash值(分布在[0,2^32-1])放到这个环上。现在要存放资源,根据资源的Key的Hash值(也是分布在[0,2^32-1]),在环上顺时针的找到离它最近的一个节点,就建立了资源和节点的映射关系。
优点:一个节点宕机时,上面的数据转移到顺时针的下一个节点中,新增一个节点时,也只需要将部分数据迁移到这个节点中,对其他节点的影响很小
删除一个节点
新增节点
缺点:由于数据在环上分布不均,可能存在某个节点存储的数据比较多,那么当他宕机的时候,会导致大量数据涌入下一个节点中,把另一个节点打挂了,然后所有节点都挂了
改进:引进了虚拟节点的概念,想象在这个环上有很多“虚拟节点”,数据的存储是沿着环的顺时针方向找一个虚拟节点,每个虚拟节点都会关联到一个真实节点
Hash Slot算法
Redis采用的是Hash Slot分片算法,用来计算key存储位置的。集群将整个数据库分为16384个槽位slot,所有key-value数据都存储在这些slot中的某一个上。一个slot槽位可以存放多个数据,key的槽位计算公式为:slot_number=CRC16(key)%16384,其中CRC16为16位的循环冗余校验和函数。
客户端可能会挑选任意一个redis实例去发送命令,每个redis实例接收到命令,都会计算key对应的hash slot,如果在本地就在本地处理,否则返回moved给客户端,让客户端进行重定向到对应的节点执行命令
注意事项
过期时间的设置
如果大量的key过期时间设置的过于集中,到过期的那个时间点,Redis可能会出现短暂的卡顿现象。严重的话会出现缓存雪崩,我们一般需要在时间上加一个随机值,使得过期时间分散一些。
电商首页经常会使用定时任务刷新缓存,可能大量的数据失效时间都十分集中,如果失效时间一样,又刚好在失效的时间点大量用户涌入,就有可能造成缓存雪崩。
缓存雪崩:大量缓存的key同时失效,同时大批量的请求落到了数据库上,数据库扛不住挂了。(处理方法:失效时间加上一个随机值,避免同时失效)
缓存穿透:用户不断访问缓存和数据库都没有的数据。(处理方式:请求参数校验,将查询不到数据的key放到缓存中,value设为null)
缓存击穿:对于热点数据,如果一直有高并发的请求,刚好缓存失效,这时大量的请求就会落在数据库上(设置热点期间不过期)
keys缺陷
Redis的单线程的。keys指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。 使用scan指令可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用keys指令长 。
疑问补充
Redis字典渐进式扩容
虽然redis实现了在读写操作时,辅助服务器进行渐进式rehash操作,但是如果服务器比较空闲,redis数据库将很长时间内都一直使用两个哈希表。所以在redis周期函数中,如果发现有字典正在进行渐进式rehash操作,则会花费1毫秒的时间,帮助一起进行渐进式rehash操作
持久化
Redis持久化的数据库文件即是RDB文件,如果开启了AOF,数据则持久化在AOF文件中
为什么哈希槽是16384个
1、正常的心跳包携带节点的完整配置,可以用幂等方式替换旧节点以更新旧配置。 这意味着它们包含原始形式的节点的插槽配置,它使用带有16k插槽只需要2k空间,但使用65k插槽时将使用高达8k的空间。 2、同时,由于其他设计权衡,Redis Cluster不太可能扩展到超过1000个主节点。因此,16k处于正确的范围内,以确保每个主站有足够的插槽,最多1000个主节点,但足够小的数字可以轻松地将插槽配置传播为原始位图。