八股文—REDIS
Redis简介
Redis是用C语言编写的,开源的高性能非关系型数据库(Nosql),是一个高性能的key–value数据库;
Redis可以存储键和五种不同类型的值之间的映射。键的类型只能为字符串,值得类型可以为字符串,List列表,Set集合,Sorted set有序集合,Hash散列表。
与传统数据库不同的是Redis的数据库是存储与内存中的,所以读写速度非常快,所以Redis被广泛应用与缓存方向,每秒可以处理超过10w次读写操作,是已执性能最快的Key–value DB;
此外,Redis也经常用来做分布式锁。而且Redis还支持事务,持久化,LUA脚本,LRU驱动事件,多种集群方案。
RDB 和 AOF
- RDB:RDB持久化可以在指定时间间隔内生成数据集的时间点快照。在默认情况下, Redis 将内存数据库快照保存在名字为 dump.rdb 的二进制文件中。
优点:RDB持久化方式对于大规模数据的备份非常高效,因为它只需要在一定时间间隔内将Redis当前的数据生成一个快照,就可以将这个快照存档到磁盘中。而且,快照文件通常比AOF文件更小,这意味着它们的恢复速度会更快。 - AOF:每当Redis执行了一条修改数据的指令时,它就会将这条指令以命令的形式写入到一个AOF文件中,当Redis需要恢复数据时,会执行AOF文件中所有的指令。
优点:AOF持久化方式会记录所有的修改操作,并且在Redis各项命令执行完毕后才会写入AOF文件,并没有延时。由于这个原因,即使在发生意外宕机时,最后一条日志条目也不会丢失。这种持久化方式足以极大程度上减少数据丢失的风险。
缺点:相比RDB方式,AOF方式虽然在进行数据备份时更加实时和精确,但是也因为历史执行过的所有指令都必须保存,会导致文件变得更加庞大和臃肿。因此,AOF恢复速度较RDB慢,且文件需要定期进行重写,否则文件大小会无限制增长。
REDIS为什么这么快
基于内存实现
内存由CPU控制,也就是CPU内部集成的内存控制器,享受与CPU通信的最优带宽。Redis 将数据存储在内存中,读写操作不会受到磁盘的 IO 速度限制,所以Redis的读写速度会非常的快。
使用I/O多路复用模型
多路指的是多个 socket 连接,复用指的是复用一个线程。多路复用主要有三种技术:select,poll,epoll。epoll 是最新的也是目前最好的多路复用技术。
- select:其时间复杂度为O(n),因为需要轮询所有连接,以确定哪些连接有数据可读或可写。其缺点包括单个进程可监视的连接数量有限(通常为1024个),以及每次调用时都需要将连接集合从用户态拷贝到内核态。
- poll:其与select非常相似,但使用不同的数据结构来存放连接集合。poll没有连接数量的限制,但同样存在每次调用都需要将连接集合从用户态拷贝到内核态的问题。
- epoll:其时间复杂度为O(1),因为它只通知用户哪些连接发生了事件,而不是轮询所有连接。epoll没有连接数量的限制,并且在内存使用上更为高效,因为它在创建时只需复制一次连接集合。此外,epoll使用回调机制,减少了系统调用和上下文切换的开销。但是,epoll在并发处理方面可能不如select和poll高效,特别是在连接数量非常多且活跃连接较少的情况下。
采用单线程模型
Redis 的单线程指的是 Redis 的网络 IO 以及键值对指令读写是由一个线程来执行的。 对于 Redis 的持久化、集群数据同步、异步删除等都是其他线程执行。
- 不会因为线程创建导致的性能消耗
- 避免上下文切换引起的 CPU 消耗,没有多线程切换的开销
- 避免了线程之间的竞争问题,比如添加锁、释放锁、死锁等,不需要考虑各种锁问题
- 代码更清晰,处理逻辑简单
高效的数据结构
HashTable
Redis哈希表结构如下,很像JAVA的HashMap结构
哈希冲突:当写入 Redis 的数据越来越多,哈希冲突不可避免,会出现不同的key计算出一样的哈希值的情况。
链式哈希:Redis采用链式哈希的方式解决冲突,即同一个桶里面的元素使用链表保存。
- rehash的执行过程
给哈希表 2 分配更大的空间
将 hash 表 1 的数据重新映射拷贝到 hash 表 2 中
释放 hash 表 1 的空间 - 扩容时 rehash:当以下条件中的任意一个被满足时, 程序会自动开始对哈希表执行扩容 rehash 操作
服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 1;
服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 5。 - 收缩时rehash:当哈希表的负载因子小于 0.1 时,程序自动开始对哈希表执行收缩操作
渐进式rehash
:因为 Redis 是单线程,当数据量过大时,会造成严重阻塞,所以采用的是渐进式 rehash。所谓渐进式rehash,就是每处理一个请求时,从哈希表 1 中依次将索引位置上的所有 entry 拷贝到哈希表 2 中
,将rehash 分散到多次请求过程中,避免耗时阻塞。
SDS 简单动态字符串
SDS是String的底层实现结构。
- 低时间复杂度
SDS的len保存了已使用空间的长度,获取字符串长度的时间复杂度为O(1) - 空间预分配
SDS被修改后,会被分配所需要的必须空间以及额外的未使用空间。
分配规则:如果SDS被修改后,len=10,重新分配后,buf的实际长度会变为10(已使用空间)+10(额外空间)+1(空字符)=21。 - 惰性空间释放
在执行完一个字符串缩短的操作后,Redis并不会马上回收free的空间,是为了预防后续拼接的操作,这样可以减少重新分配空间带来的消耗,但是再次操作之后还是没用到free的空间,Redis就会收回free的空间,防止内存的浪费。
struct sdshdr {
int len; // buf中已使用空间的长度
int free; // buf中剩余可用空间的长度
char buf[]; // 存储的实际内容
};
ZipList 压缩列表
ZipList是List 、hash、sorted Set三种数据类型底层实现之一。
查找第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,时间复杂度是O(1)。而查找其他元素时,只能逐个查找,此时的时间复杂度是O(N)。
ZipList在表头有三个字段:zlbytes、zltail和zllen,分别表示列表占用字节数、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。
缺点:插入和删除操作需要频繁的申请和释放内存,同时会发生内存拷贝,数据量大时内存拷贝开销较大。
LinkedList 双向链表
结构如下,就是普通的双向链表
typedef struct list {
listNode *head; // 头结点
listNode *tail; // 尾结点
void *(*dup)(void *ptr); // 节点复制函数
void (*free)(void *ptr); // 节点释放函数
int (*match)(void *ptr, void *key); // 节点对比函数
unsigned long len; // 链表所包含的节点数量
} list;
QuickList
在Redis 3.2版本中,新增了QuickList数据结构,用于替代ZipList和LinkedList(这个组合其实又有点像hashmap了)
官网对QuickList的解释为"A doubly linked list of ziplists",意为由ZipList组成的LinkedList,结合了ZipList和LinkedList的优点。
SkipList 跳表
SkipList是Sorted Set的底层实现结构之一。SkipList的特性
- SkipList是一种有序数据结构,它通过在每个节点中存储着多个指向其他节点的指针,从而达到快速访问节点的目的。
- SkipList支持平均O(logN)、最坏O(N)时间复杂度的节点查找,还可以通过顺序性操作来批量处理节点。
- SkipList在LinkedList的基础上,增加了多层级索引,通过索引位置的几个跳转,实现数据的快速定位。
合理的数据编码
- String
常用命令 :set/get/decr/incr/mget等;
应用场景 :String是最常用的一种数据类型,普通的key/value存储都可以归为此类;
实现方式:String在redis内部存储默认就是一个字符串,被redisObject所引用,当遇到incr、decr等操作时会转成数值型进行计算,此时redisObject的encoding字段为int。 - List
常用命令 :lpush/rpush/lpop/rpop/lrange等;
应用场景 :Redis list的应用场景 非常多,也是Redis最重要的数据结构之一,比如twitter的关注列表,粉丝列表等都可以用Redis的list结构来实现;
实现方式:Redis list的实现为一个双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销,Redis内部的很多实现,包括发送缓冲队列等也都是用的这个数据结构。 - Set
常用命令 :sadd/spop/smembers/sunion等;
应用场景 :Redis set对外提供的功能与list类似是一个列表的功能,特殊之处在于set是可以自动排重的,当你需要存储一个列表数据,又不希望出现重复数据时,set是一个很好的选择,并且set提供了判断某个成员是否在一个set集合内的重要接口,这个也是list所不能提供的;
实现方式:set 的内部实现是一个 value永远为null的HashMap,实际就是通过计算hash的方式来快速排重的,这也是set能提供判断一个成员是否在集合内的原因。 - Sorted Set
常用命令 :zadd/zrange/zrem/zcard等;
应用场景 :Redis sorted set的使用场景与set类似,区别是set不是自动有序的,而sorted set可以通过用户额外提供一个优先级(score)的参数来为成员排序,并且是插入有序的,即自动排序。当你需要一个有序的并且不重复的集合列表,那么可以选择sorted set数据结构,比如twitter 的public timeline可以以发表时间作为score来存储,这样获取时就是自动按时间排好序的。
实现方式:Redis sorted set的内部使用HashMap和跳跃表(SkipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单。 - Hash
常用命令 :hget/hset/hgetall等
应用场景 :我们要存储一个用户信息对象数据,其中包括用户ID、用户姓名、年龄和生日,通过用户ID我们希望获取该用户的姓名或者年龄或者生日;
实现方式:Redis的Hash实际是内部存储的Value为一个HashMap,并提供了直接存取这个Map成员的接口。如图所示,Key是用户ID, value是一个Map。这个Map的key是成员的属性名,value是属性值。这样对数据的修改和存取都可以直接通过其内部Map的Key(Redis里称内部Map的key为field),也就是通过 key(用户ID) + field(属性标签) 就可以操作对应属性数据。当前HashMap的实现有两种方式:当HashMap的成员比较少时Redis为了节省内存会采用类似一维数组的方式来紧凑存储,而不会采用真正的HashMap结构,这时对应的value的redisObject的encoding为zipmap,当成员数量增大时会自动转成真正的HashMap,此时redisObject的encoding字段为int。
Redis过期键和删除策略
我们都知道,Redis是key-value数据库,我们可以设置Redis中缓存的key的过 期时间。Redis的过期策略就是指当Redis中缓存的key过期了,Redis如何处理。
过期策略通常有以下三种
- 定时过期:每个过期时间的Key都需要一个定时器,到过期期间就会立即清楚。该策略可以立即清楚过期的key,对内存很友好;但是会占用大量CPU去处理过期的数据,从而影响缓存的响应时间和吞吐量。
- 惰性过期:只有当访问一个key时,才会去判断这个Key是否已经过期,过期则清除;该策略可以最大化的节省CPU资源,但对内存十分不友好,极端情况可能出现大量的 过期key没有再次被访问,从而不会被清除,占用大量内存
- 定期过期:每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数 量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定 时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达 到最优的平衡效果。
Redis内存淘汰策略
全局的键空间选择性移
- no-eviction:当内存不足以容纳新写入数据时,新写入操作会报错。
- allkeys-lru:(这个是最常用的)当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用 的key。
- allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key。
设置过期时间的键空间选择性移除
- volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中, 移除最近最少使用的key。
- volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空 间中,随机移除某个key。
- volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除将要过期的key,ttl的值越大优先被移除。
Redis的内存淘汰策略的选取并不会影响过期的key的处理。内存淘汰策略用于 处理内存不足时的需要申请额外空间的数据;过期策略用于处理过期的缓存数据。
缓存穿透
访问透过redis直接经过mysql,通常是一个不存在的key,在数据库查询为null。每次请求落在数据库、并且高并发。数据库扛不住会挂掉。
- 可以将查到的null设成该key的缓存对象。
- 当然,也可以根据明显错误的key在逻辑层就就行验证。
- 同时,你也可以分析用户行为,是否为故意请求或者爬虫、攻击者。针对用户访问做限制。
- 其他等等,比如采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层 存储系统的查询压力
缓存雪崩
同一时间缓存大面积的失效,后面的请求都会落到数据库 上,造成数据库短时间内承受大量请求而崩掉
- 通常的解决方案是将key的过期时间后面加上一个随机数,让key均匀的失效。
- 考虑用队列或者锁让程序执行在压力范围之内,当然这种方案可能会影响并发量。
- 热点数据可以考虑不失效
缓存击穿
是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,好像蛮力击穿一样。
击穿你可以理解为正面刚击穿,这种通常为大量并发对一个key进行大规模的读写操作。这个key在缓存失效期间大量请求数据库,对数据库造成太大压力使得数据库崩掉。就比如在秒杀场景下10000块钱的mac和100块的mac这个100块的那个订单肯定会被抢到爆,不断的请求(当然具体秒杀有自己处理方式这里只是举个例子)。所以缓存击穿就是针对某个常用key大量请求导致数据库崩溃。
解决方法
- 可以使用互斥锁避免大量请求同时落到db。
- 可以将缓存设置永不过期(适合部分情况)
如何保证缓存与数据库双写时的数据一致性
缓存双删
用户写数据》删除缓存》写数据库》延迟一段时间(如500ms后)再删除
延迟删除时利用消息队列去保证一定删除成功