1. Redis数据结构及其使用场景
Redis中主要有5种数据结构:字符串String、列表list、字典hash、集合set和有序集合zset,并且每种数据类型都提供了最少两种的内部编码。
首先Redis内部使用一个redisObject表示所有的key和value,其信息如图所示。type表示一个value对象是什么数据类型,encoding表示不同数据类型在redis的内部编码。
1. 字符串String
字符串是Redis中最基本的数据结构,主要用来存储key-value型的键值对。Redis中字符串对象的编码可以是int、raw和embstr。
int编码:保存long型的64位有符号整数embstr编码:保存长度小于44字节的字符串raw编码:保存长度大于44字节的字符串。
Redis使用SDS简单动态字符串结构体来存储字符串。
struct sdshdr{
int len; //记录buf中已使用字节的数量等于SDS所保存的字符串的长度
int free; //记录buf中未使用字节的长度
char []buf; //字节数据,保存字符串
}
SDS相比C语言字符串有以下好处:
- 可以实现
O(1)复杂度的获取字符串长度,而C语言需要通过时间复杂度为O(n)的遍历来获取 - 杜绝缓冲区溢出。当对字符串进行拼接时,SDS会检测
buf的空间,如果空间不足会先扩容 - 通过空间预分配和惰性回收减少内存重新分配次数。
- 当
len小于1mb时,每次扩容后buf数组长度都为len*2+1,此时len和free相等,一个字节用于保存字符串结尾的空字符;当len大于1mb时,每次会额外分配1mb的free空间 - 当SDS进行缩容时,不会立即进行内存重分配回收缩短后空出来的字节,而是用free记录这些字节留待将来使用。
- 当
- 二进制安全。SDS通过len属性判断字符串是否结束而不是通过结尾的空字符,所以可以存储任何数据。
- 兼容部分C字符串函数。
常用命令:GET key、SET key value、EXISTS key、DEL key、SETNX
使用场景:常规key-value缓存、计数等。
2. 列表list
Redis中的列表相当于Java中的LinkedList,其插入删除非常快但随机访问较慢。Redis在3.2之前使用ziplist或linkedlist作为底层结构,而在3.2后改用quicklist作为底层实现。
Redis列表使用两种数据结构作为底层实现:压缩列表ziplist和双向链表linkedlist。因为双向链表占用内存较多,当满足列表节点数量小于512并且单个节点元素大小小于64字节时会优先使用ziplist。
ziplist是为了节省内存而开发的,是一块连续的地址空间。一个ziplist可以包含多个节点(Entry),每个结点的长度不同,通过记录当前entry和上一个entry的长度来推算下一个元素的位置。
linkedlist是标准的双向链表,Node节点包含prev和next指针,Redis记录了链表的head和tail指针,所以在表头和表尾进行插入删除的时间复杂度都为O(1)。
linkedlist在链表的两端操作很快,但它的内存开销较大,因为每个节点还要额外保存两个指针,并且地址不连续容易产生内存碎片;ziplist存储在一块连续内存上,存储效率很高但不利于修改操作,插入和删除需要频繁地申请和释放内存。
所以Redis在3.2后引入了quicklist作为list的底层实现。quicklist可以看成是ziplist和linkedlist的结合,是一个ziplist组成的双向链表,每个节点使用ziplist来保存数据。
常用命令:lpush、lpop、rpush、rpop、lrange、lindex
使用场景:队列、栈、关注列表、好友列表
3. 哈希hash
Redis中的哈希表采用字典作为底层实现,相当于java中的HashTable,都是通过数组+链表解决哈希冲突。
dict结构包含两个hashtable,通常情况下只有一个是有值的,另一个是在渐进式rehash时使用。
当往字典中添加键值对时,会根据键计算出哈希值和索引值,再根据索引值将哈希表节点添加到桶数组上。当发生哈希冲突时利用拉链法解决冲突。
渐进式rehash
当hash表中的元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组长度是原来的两倍;当元素个数小于数组长度的10%时,会开始缩容。
Redis下hash结构的扩容和缩容的rehash不是一次性、集中完成的,而是分多次、渐进式进行的,这样在大数据的情况下才不会对服务器正常服务造成影响。
- 字典结构
dict中定义了一个成员rehashidx,等于-1时表示不进行rehash,开始进行rehash时初始化为0。 - 在rehash期间,每次对字典进行增删改查都会将
rehashidx指向的元素进行从ht[0]表rehash到ht[1]表,并且将rehashidx+1。 rehash完成后,将rehashidx置为-1。
常用命令:HSET、HGET
使用场景:存储对象
4. 集合set
Redis下的集合set使用整数集合intset或者hashtable作为底层实现。
当set对象保存的元素都是整数值且元素数量不超过512时,采用intset作为底层实现。
intset是一个整数组成的有序集合,可以通过二分查找快速判断一个元素是否属于集合。intset是一块连续的空间,并且对大整数和小整数采用了不同的编码。
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))
encoding:数据编码,表示intset中每个数据元素用几个字节存储。INTSET_ENC_INT16表示用2个字节存储,INTSET_ENC_INT32表示用4个字节进行存储,INTSET_ENC_INT64表示用8个字节进行存储。
intset根据添加的每个元素的大小对编码进行升级,并且升级不可逆。
Redis下集合采用hashtable作为底层实现时,与Java下的HashSet类似,都是用的哈希表,key为set的值,value为null。
常用命令:SADD、SMEMBERS、SISMEMBER、SPOP、交集sinter、并集sunion、差集sdiff
使用场景:集合的交、并、差、去重等。
5. 有序集合zset
Redis下的zset一方面保证了value的唯一性,又为每个value赋予一个score权重用于排序,底层使用ziplist或者skiplist实现,当元素数量小于128并且元素大小小于64字节时使用ziplist。
当ziplist作为底层结构时,每个集合元素使用两个紧挨在一起的压缩节点来保存,第一个节点保存成员,第二个节点保存权重。
当使用skiplist作为底层结构时,使用了一个zskiplist对象和一个dict对象。使用zskiplist按序保存元素及分值,用dict保存元素key与分值score的映射关系。
zskiplist包括指向头尾的指针和最大层数level。
/*
* 跳跃表
*/
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
每个节点都有保存数据的robj指针、分值score、后退指针backward用于回溯:
/*
* 跳跃表节点
*/
typedef struct zskiplistNode {
// 成员对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
跳跃表的查找过程就是按层次不断向下细化查找范围直到找到数据,与平衡树类似,但实现比平衡树简单,增删查的平均复杂度都为O(log(n)),所以Redis采用了skiplist作为底层实现。
常用命令:ZADD、ZRANGE、ZREVRANGE、ZCARD、ZRANGEBYSCORE、ZREM等
使用场景:各类排行榜等。
2. Redis事务
Redis通过MULTI、DISCARD、EXEC和WATCH四个命令来实现事务功能。
MULTI用来显示开启一个事务,并保证事务在执行期间不会中断,即服务器处理完事务中所有命令后才会继续处理其他客户端的命令。MULTI开启后,客户端进入事务状态,接下来的所有命令都会放进一个事务队列中,当服务器接收到EXEC命令后,服务器以FIFO的方式执行任务队列中的命令。
DISCARD命令用于取消一个事务,其会清空客户端的任务队列,并将客户端从事务状态转化为非事务状态。
当发生命令错误时,在EXEC调用之前发生错误会拒绝执行事务,返回错误信息并清空任务队列;在EXEC调用之后发生了错误,即使命令失败也不会回滚,队列中其他命令也会被处理。
WATCH命令用于在事务开始前监视任意数量的键,当调用EXEC命令执行事务时,如果任意一个被监视的键被其他客户端修改,那么事务不会执行,直接返回失败。Redis中维护一个watched_keys字典,字典的键就是被监视的键,字典的值是一个链表,链表中保存了所有监视这个键的客户端。通过检查字典中是否存在这个键就可以判断这个键是否被监视;通过获取某个键的链表,就可以获取监视这个键的所有客户端。
Redis在事务命令执行失败时不会回滚,而是会继续执行剩余命令。
3. Redis与Memcached的区别
redis有更丰富的数据类型,支持更复杂的应用场景。redis支持数据持久化,可以把数据持久化到硬盘中,重启后可以进行数据恢复,而Memcached只能把数据存在内存中Redis原生支持集群模式Memcached是多线程的非阻塞IO复用模型;Redis是单线程的多路IO复用模型。
4. Redis的线程模型
Redis内部使用文件事件处理器event file handler,这个文件事件处理器是单线程的,所以Redis才叫单线程模型。它采用IO多路复用机制同时监听多个Socket,将产生事件的Socket压入队列中,事件分派器根据Socket上的事件类型选择对应的事件处理器进行处理。
文件事件处理器的结构分为4个部分:
- 多个
Socket - IO多路复用程序
- 文件事件分派器
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
多个Socket可能产生不同的操作,每个操作对应不同的文件事件,但IO多路复用程序会监听多个Socket,会见产生事件的Socket放入队列进行排队,事件分派器每次从队列中取出一个Socket,根据Socket的事件类型交给对应的事件处理器进行处理。
5. Redis单线程模型效率也很高?&&Redis为什么这么快?
- 纯内存操作
- 核心是基于非阻塞的IO多路复用模型
- C语言实现,速度较快
- 单线程反而避免了频繁地上下文切换,预防了多线程可能的竞争问题
6. Redis为什么引入多线程?
Redis6.0后引入了多线程模型,这是因为读写网络的Read/Write系统调用在Redis执行期间占用了大部分CPU时间,所以将网络读写部分引入多线程可以提高性能。
Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程。
7. Redis设置过期时间&&删除过期键的策略
Redis在set key的时候,可以设置expire time过期时间,Redis通过定期删除+惰性删除清除过期的键。
-
定期删除
Redis默认每隔100ms随机抽取一些设置了过期时间的key,检查其是否过期,如果过期就删除;这里是随机抽取的,主要是防止Redis存了很多key的情况下,每隔100ms遍历一次会给CPU很大负载 -
惰性删除
定期删除可能会导致过期的
key没有被删除,惰性删除就是指过期的key停留在内存中,只有去查一下才会删除。
8. 内存淘汰机制
如果定期删除漏过了很多key,也没通过惰性删除,就会造成大量过期key滞留在内存中,导致内存耗尽,这是就需要内存淘汰策略。
Redis提供了6中内存淘汰策略:
- **volatile-lru:**从已设置过期时间的数据集中挑选最近最少使用的数据淘汰
- **volatile-ttl:**从已设置过期时间的数据集中挑选将要过期的数据淘汰
- **volatile-random:**从已设置过期时间的数据集中任意选择数据淘汰
- **allkeys-lru:**在键空间中,选择最近最少使用的数据淘汰
- **allkeys-random:**在键空间中,任意选择数据淘汰
- no-eviction:内存不足时,拒绝写入新数据
9. Redis持久化
Redis支持将数据持久化到硬盘上,异常重启后可以快速恢复,或者将数据备份到远程位置。
Redis支持两种持久化机制,数据快照RDB和只追加文件AOF。
-
快照持久化
RDBRDB是指在指定的时间间隔内将数据集快照写入磁盘,恢复时直接将快照文件读到内存中。Redis会单独fork一个子进程来进行持久化,会先将数据文件写入到一个临时文件中,待持久化结束后,再将临时文件替换上次持久化好的文件。整个过程中,主进程不进行IO操作,确保了极高的性能。save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。 save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。 save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发BGSAVE命令创建快照。RDB在大规模数据恢复情景下效率较高,但数据完整性较差,
Redis会丢失最后一次快照后的所有数据。
-
只追加文件
AOF
AOF是以日志的形式记录每次写操作,将所有写指令追加到aof文件的尾部,恢复时根据文件的内容从头到尾执行一遍就可以完成数据恢复。
Redis配置文件中有三种不同的持久化方式:
appendfsync always #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
appendfsync everysec #每秒钟同步一次,显示地将多个写命令同步到硬盘
appendfsync no #让操作系统决定何时进行同步
一般都会选择每秒同步一次,可以兼顾数据完整性与效率。
相同大小的数据集aof文件要远大于rdb文件,恢复速度也慢于快照持久化RDB,但AOF的数据实时性更好,文件的内容也更容易被读懂。

AOF重写
Redis采用只追加文件的方式,会导致aof文件越来越大,当文件大小超过设定的阈值时,就会启动AOF文件压缩,只保留恢复数据的最小指令集。
AOF文件过大时,会fork一个子进程来将文件重写(也是先写临时文件再rename),数据集中每条记录对应一条set语句,与快照类似,读取数据库中内容用命令的方式重写aof文件。
AOF重写过程中会维护一个AOF重写缓冲区,记录重写时所有的写命令,当子进程完成新aof文件的创建后,会将缓存区中内容追加到新aof文件的末尾,保证数据一致性。
10. 缓存雪崩&&缓存穿透&&缓存击穿
缓存雪崩
缓存雪崩指的是缓存同一时间大面积的失效,导致所有请求都落到数据库上,导致数据库宕机。
- 事前:保证
Redis高可用,主从+哨兵,避免全部崩溃 - 事中:本地缓存+hystrix限流&降级,避免MySQL被打死
- 事后:Redis持久化,快速恢复数据

系统发送请求后,先查本地缓存,没查到再查Redis。如果都没查到,再查数据库,将结果写入本地缓存和Redis。同时通过限流组件设置每秒最多能到达数据库的请求数,未通过的请求走降级,返回系统繁忙。
一个重要的原则就是MySQL绝对不能死。
缓存穿透
缓存穿透主要是防止故意伪造不存在的数据去请求,所以缓存中一定没有,绕过缓存直接查询数据库。
防止缓存穿透,最基本的就是做好参数校验,不合法的参数直接返回。除此还有两种方式缓存无效key和布隆过滤器:
-
缓存无效
key如果缓存和数据库都查不到某个
key时,就将key写到Redis中去并设置过期时间。这种方式只能解决请求的
key变化不频繁的情况,如果恶意伪造不同的key,会导致Redis中缓存大量无效key。不能从根本上解决。 -
布隆过滤器
通过布隆过滤器可以非常方便地判断一个数据是否存在于海量数据中,因为布隆过滤器有个很重要的性质:
布隆过滤器说某个元素存在,小概率会误判;但布隆过滤器说某个元素不存在,则一定不存在。
布隆过滤器可以看成是由位数组和哈希函数组成的数据结构,其占用空间少且效率高。缺点是有一定的错误识别率和删除难度。
向布隆过滤器中添加元素时,会先对元素进行哈希计算得到哈希值,再根据哈希值将位数组中对应的下标值置1;
判断一个元素是否存在于布隆过滤器中时,会对给定元素进行相同的哈希运算,得到值后会去判断位数组中对应的下标值是否都为1.

把所有存在的值都放在布隆过滤器中,当请求过来时,会先判断是否存在于布隆过滤器中,如果不在,直接返回,存在的话才会走下面的流程。
缓存击穿
缓存击穿指的是某个热点key在访问频繁地情况下失效,大量的请求击穿了缓存,直接请求数据库。
- 如果缓存的数据基本不会更新,就把热点
key设置为永不过期 - 如果热点数据更新不频繁,就采用分布式锁或者本地互斥锁保证仅少量请求能到达数据库并重新构建缓存,其他请求能访问到新缓存
- 如果更新频繁,则在过期前主动重新构建缓存或延后过期时间。
11. Redis主从同步
Redis主从架构是一主多从,主负责写,并且将数据复制到其他的slave节点,从节点负责读,所有的读请求全部走从节点。
当启动一个slave node时,会发送一个PSYNC命令给master node。
如果这是slave node初次连接到master node会触发一次全量复制full resynchronization,此时master节点会启动一个后台进程生成一份RDB快照文件,同时还会将所有收到的写命令缓存在内存中。RDB文件生成后,master会发送给slave,slave会先写入磁盘,再从磁盘加载到内存中,接着master将缓存的写命令发送给slave,slave会进行同步。如果发生了网络故障,会自动重连并支持断点续传。
master节点的key过期或者通过淘汰策略淘汰了一个key后,会模拟一条del命令发送给从节点。
主从节点会互相发送heartbeat信息,master 默认每隔 10 秒发送一次 heartbeat,slave node 每隔 1 秒发送一个 heartbeat。
12. 如何保证缓存与数据库双写一致性
读的时候先读缓存,再读数据库,并将结果写入缓存;
更新的时候,先删除缓存再更新数据库。这样主要是防止先更新数据库时缓存删除失败,导致数据库中是新数据,缓存中是旧数据,造成数据不一致。