Redis是一个用C语言编写的,开源的高性能非关系型的键值对数据库。Redis可以存储键和五种不同类型的值之间的映射,所以读写速度非常快。因此Redis被广泛的应用在缓存方向,每秒可以处理超过10万次读写操作。此外,Redis也经常用来做分布式锁,并且其支持事务、持久化、LUA脚本、LRU驱动事件等。
优点:
缺点:
为什么要用Redis?
Redis为什么这么快?
Redis的值主要是五种:String、List、Set、Hash、ZSet
数据类型 | 存储类型 | 操作 | 应用场景 |
---|---|---|---|
String | 字符串、整数或者浮点数 | 对整个字符串或者字符串的其中一部分进行操作;对整数和浮点数执行自增或者自减操作 | 做简单的键值对缓存,例如常用的信息、字符串、图片或者视频等存入;计数器;分布式session共享 |
List | 列表 | 从两端压入或者弹出元素;对单个或者多个元素进行增删改;只保留一个范围内的元素 | 存储一些列表类型,例如粉丝列表、文章评论列表之类的数据 |
Set | 无序集合 | 增删改单个元素;检查一个元素是否存在于集合中;计算交集、并集、差集;从集合里面获取元素 | 可以获取两个人的共同关注;点赞收藏;给用户添加标签 |
Hash | 包含键值对的无序散列表 | 添加、获取、移除单个键值对;获取所有键值对;检查某个键是否存在 | 适合频繁修改的缓存 |
ZSet | 有序集合 | 添加、获取、删除元素;根据分值范围或者成员来获取元素;计算一个键的排名 | 排行榜,例如发布排行榜,关注数排行榜,更新时间等 |
其中String是用C语言实现的,Redis自动封装了一个类库SDS用来操作:
因此获取字符串长度时间复杂度为O1,buf[]中采用了C语言的\0结尾,可以使用C语言的标准字符函数。一个字符串类型的值最大能存储512M。
分配原则:字符串长度小于1MB时,分配空间大小为其二倍;大于1MB时为其多分配1MB。
特点:每次都多分配一些空间,这样就降低了分配次数,提高了追加速度;二进制安全;查询长度速度快、追加效率很高。
Hash 也可以用来存储用户信息,和 String 不同的是 Hash 可以对用户信息的每个字段单独存储,String 则需要序列化用户的所有字段后存储。并且 String 需要以整个字符串的形式获取用户,而 hash可以只获取部分数据,从而节约网络流量。不过 hash 内存占用要大于 String,这是 hash 的缺点。
Hash底层存储可以用ziplist(压缩链表)也可以使用hashMap,当hash对象同时满足下面两个条件时用ziplist:
其中Hash的数据结构类似与Java中的map,一旦冲突就直接拉链,没有红黑树。其底层的结构是:一个hash对象代表了一个dict实例,而一个dict中包含了两个dictht组成的哈希表数组和一个指向dicType的指针。定义两个dictht的作用主要是为了扩容的过程中,能够保证读取数据的一致性。每个dictht中都存在一个dicEntry变量,其是存放数据的主要容器(bucket桶)。每个dictEntry中除了包括key和value的键值对以外,还包括指向下一个dictEntry对象的指针。
该hashtable在扩容的时候是使用渐进式rehash策略:在扩容的时候rehash策略会保留新旧两个hashtable结构(前面说的hash内部包含了两个hashtable,其中h[1]的容量是h[0]的二倍),查询的时候也会同时查询两个hashtable。Redis会将旧的hashtable中的内容一点一点的迁移到新的hashtable中,当迁移完成的时候,就会用新的取代之前的。当hashtable移除了最后一个元素的时候,旧的数据结构就会被删除。
其中数据搬迁的操作放在 hash 的后续指令中,也就是来自客户端对 hash 的指令操作。一旦客户端后续没有指令操作这个hash。Redis就会使用定时任务对数据主动搬迁。正常情况下,当 hashtable 中元素的个数等于数组的长度时,就会开始扩容,扩容的新数组是原数组大小的 2 倍。如果 Redis 正在做 bgsave(持久化) 时,可能不会去扩容,因为要减少内存页的过多分离(Copy On Write)。但是如果 hashtable已经非常满了(一直拉链),元素的个数达到了数组长度的 5 倍时,Redis 会强制扩容。在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到 ht[1] 里面, 而 ht[0] 则不再进行任何添加操作: 这一措施保证了 ht[0] 包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表。
小结扩容时机:
当哈希表的负载因子小于0.1时, 程序自动开始对哈希表执行收缩操作。
Redis3.2之前,List使用压缩列表和双向链表作为底层实现,当满足下列条件时使用ziplist,否则使用双向链表linkedlist:
3.2之后,引入了quicklist
quicklist是ziplist和linkedlist二者的结合,既有ziplist组成的双向链表,每个节点用ziplist保存数据。
Set底层的存储结构是intset和hashtable两种数据结构存储。其中hashtable中的key就是set中的值,value统一为null。
其中使用intset必须满足两个条件,否则使用hashtable:
intset内部是一个数组(int8_t coentents[]数组),而且存储数据的时候是有序的,因为在查找数据的时候是通过二分查找来实现的。
set存储过程:
其中,ZSet的实现是基于跳跃表skiplist实现的。
zset编码有ziplist和skiplist两种,底层对应压缩链表和跳表实现。当满足保存的元素小于128个且所有元素大小都小于64字节,使用ziplist,否则使用skiplist。跳跃表的空间复杂度是ON
为什么要有压缩列表?
ziplist的内存布局:
关键的是entry:
ziplist是紧凑存储,没有冗余空间,所以不适合存储大型字符串,存储的元素也不宜过多。
Redis是内存数据库,为了保证效率所有的操作都是在内存中完成。数据都是缓存在内存中,如果重启或者关闭系统,之前缓存在内存的数据都会丢失且无法找回。
Redis是一个支持持久化的内存数据库,通过持久化机制把内存中的数据同步到硬盘文件来保证数据持久化。当Redis重启后通过把硬盘文件重新加载到内存,以达到恢复数据的目的。
实现方式:单独fork一个子进程,将当前父进程的数据库数据复制到子进程的内存中,然后由子进程写入到临时文件中,持久化的过程就结束了,再用这个临时文件来替换上一次的快照文件,然后子进程退出,内存释放。
**RDB持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘。**它也是Redis默认的持久化方式,这种方式是就是将内存中数据以快照的方式写入到二进制文件中,默认的文件名为dump.rdb。对于RDB来说,提供了三种机制:save、bgsave、自动化。
save:该命令是一个手动触发的机制,会阻塞当前Redis服务器,执行save命令期间,Redis不能处理其他命令,直到RDB过程完成为止。执行完成时如果存在老的RDB文件,就让新的替代掉旧的。
bgsave:执行该命令时,Redis会在后台异步进行快照操作,快照同时还可以响应客户端请求。具体操作是Redis进程fork一个子进程,RDB持久化过程由子进程负责,完成后自动结束。阻塞只发生在fork阶段,一般时间很短。基本上Redis内部所有的RDB操作都是采用bgsave命令。
自动化:由我们的配置文件来完成,在redis.conf配置文件中。
优势:
劣势:
**Redis会将每一个收到的写命令都通过Write函数追加到文件最后,类似于MySQL的binlog。**当Redis重启时会通过重新执行文件中保存的写命令来在内存中重建整个数据库的内容。ps:AOF默认不开启,当两种方式同时开启,Redis默认选择AOF。
AOF的方式带来了一个问题:持久化文件会越来越大。为了压缩AOF的持久化文件,redis提供了bg rewrite aof命令来将内存中的数据以命令的方式保存到临时文件中,同时会fork出一条新进程来将文件重写。重写AOF文件的操作并没有读取旧的AOF文件,而是将整个内存中的数据库内容用命令方式重写了一个新的AOF文件,类似快照。
AOF的三种触发机制
AOF优点:
AOF缺点:
对于同一份数据来说,AOF日志文件通常比RDB数据快照文件更大。
AOF开启后,支持写QPS会比RDB支持的写QPS低,因为一般AOF会配置成每秒fsync一次日志文件。
主redis中的数据和从redis上的数据保持实时同步,当主redis写入数据时通过主从复制机制会复制到两个(多个)从redis服务上。
如果设置主从同步,则从服务器在连接的时候会发送SYNC命令(不管是第一次连接还是再次连接)。然后主服务器开始后台存储,并且开始缓存新连接进来的修改数据的命令。当后台存储完成后,主服务器把数据文件发送到从服务器,从服务器将其保存在磁盘上,然后加载到内存中。然后主服务器把刚才缓存的命令进行执行。
Redis全量同步一般发生在从服务器初始化阶段,这时从服务器需要将主服务器上的所有数据都复制一份:
全量同步的问题:
Redis增量同步是指从服务器初始化后开始正常工作时,主服务器发生的写操作同步到从服务器的过程。增量同步的过程主要是主服务器每执行一个写命令就会给从服务器发送相同的写命令,从服务器接收并执行收到的写命令。
**主从刚刚连接的时候,进行全量同步;全量同步结束后进行增量同步。如果有需要,从服务器在任何时候都可以发起全量同步。**redis的策略是首先进性增量同步,如果不成功则要求从服务器进行全量同步。
redis2.8以前不支持,部分同步是指即使主从连接中途断掉,从机重启的时候也不需要进行全量同步。部分同步的实现依赖于主服务器内存中维护了一个同步日志,并且给每个从服务器维护了一份同步标识,每个从服务器在跟主服务器进行同步时都会携带自己的同步标识和上次同步的最后位置。当主从连接断掉之后,从服务器隔断时间(默认1s)主动尝试和主服务器进行连接,如果从服务器携带的偏移量标识还在主服务器上的同步备份日志中,那么就从从服务器发送的偏移量开始继续上次的同步操作。如果主服务器的同步日志中没有(可能由于主从之间断掉的时间比较长或者在断掉的短暂时间内主服务器接收到大量的写操作),则必须进行一次全量更新。
Redis 的主从复制模式下,一旦主节点由于故障不能提供服务,需要手动将从节点晋升为主节点,同时还要通知客户端更新主节点地址,这种故障处理方式从一定程度上是无法接受的。
Redis2.8后提供了Redis Sentinel哨兵机制来解决这个问题。Sentinel(哨兵)是Redis的高可用性解决方案:由一个或多个Sentinel 实例 组成的Sentinel 系统可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器。
redis内部使用文件事件处理器file event handler,这个文件事件处理器是单线程的,所以redis才叫做单线程的模型,它采用IO多路复用机制同时监听多个socket,根据socket上的事件来选择对应的时间处理器进行处理。Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。
Redis是泡在单线程中的,所有的操作都是按照顺序线性执行的,由于读写操作等待用户的输入或者输出都是阻塞的,所以IO操作在一般情况下往往不能直接返回,这会导致某一文件的IO阻塞而导致整个进无法对其他客户提供服务。IO多路复用就是为了解决这个问题而出现的。
Redis的IO模型主要是基于epoll实现的,不过它也提供了 select和kqueue的实现,默认采用epoll。epoll对比其他的IO多路复用,有如下优点:
epoll和select/poll的区别:
select/poll的缺点:
设置键的时候都会给一个过期时间,redis对过期时间的key删除策略是定期删除+惰性删除
定期删除+惰性删除是有问题的:定期删除会遗留许多过期的key,然后你也没有请求这些key,那么会导致redis内存越来越高,此时将采取内存淘汰机制。
在redis.conf中有一行配置:maxmemory-policy xxx。该配置就是配置内存淘汰的策略,当内存不足以容纳新写入数据时,这些策略就派上用场了:
Redis3.0后对lru进行了一些优化:新算法会维护一个候选池(大小为16),池中的数据根据访问时间进行了排序,第一次随机选取的key都会放入池中,随后每次随机选取的key只有在访问时间小于池中最小的时间才会放入池中,直到侯选池被放满,当放满后,如果有新的key需要放入,则将池中最后访问的时间最大(最近被访问)的移除。当需要淘汰的时候,则直接从池中取最近访问时间最小(最久没有被访问)的key淘汰。
LRU和LFU最大的区别就是,如果某个key很久没有被访问到,一旦被访问一次,LRU就认为它是热点数据不会被淘汰,而有些key则是将来很有可能被访问到却被淘汰了。LFU就没有这个问题。
Redis是有事务的,它的事务就是一系列指令的结合:
关系型数据库ACID中,对于原子性的定义是:一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在某个环节,事务在执行过程中发生错误,会被恢复到事务开始前的状态(回滚)。
然而Redis的事务,定义如下:
不难看出,官方对于Redis的原子性是站在执行与否的角度考虑的,严格来说Redis的事务是非原子性的,因为在命令顺序执行错误的时候,Redis是不会停止回滚的。
在事务运行期间虽然Redis命令可能会执行失败,但是Redis依然会执行事务内剩余的命令而不会执行回滚操作。官方的解释如下:
只有当被调用的Redis命令有语法错误时,这条命令才会执行失败(在将这个命令放入事务队列期间,Redis能够发现此类问题),或者对某个键执行不符合其数据类型的操作:实际上,这就意味着只有程序错误才会导致Redis命令执行失败,这种错误很有可能在程序开发期间发现,一般很少在生产环境发现。支持事务回滚能力会导致设计复杂,这与Redis的初衷相违背,Redis的设计目标是功能简化及确保更快的运行速度。
这三个框架都是在Java中对Redis操作的封装
是Redis的Java实现客户端,其API提供了比较全面的Redis命令支持,支持基本的数据类型比如String、Hash、List、Set、ZSet(Sorted Set)
优点:比较全面的提供了Redis的操作特性,相比于其他的Redis封装框架更加原生。
编程模型:使用阻塞的IO,方法调用同步,程序流需要等到socket处理完IO才能执行,不支持异步操作。Jedis客户端的实例不是线程安全的,所以需要连接池来使用Jedis
高级的Redis客户端,用于线程安全同步,异步和响应使用,支持集群,管道和编码器等。
优点:适合分布式缓存框架。
编程模型:基于Netty框架的事件驱动的通信层,使用非阻塞IO,其方法调用是异步的。Lettuce的API是线程安全的,所以可以操作单个Lettuce连接来完成各种操作。
实现了分布式和可扩展的Java数据结构。Redisson不仅仅提供了一系列的分布式Java常用对象,基本可以与Java的基本数据结构通用,还提供了许多分布式服务。
优点:可以让使用者对Redis的关注分离,让使用者能够将精力更集中的放在处理业务逻辑上,提供了很多分布式操作服务,例如分布式锁,分布式集合,可以通过Redis支持延迟队列。
编程模型:基于Netty框架的事件驱动的通信层,使用非阻塞IO,其方法调用是异步的。Lettuce的API是线程安全的,所以可以操作单个Lettuce连接来完成各种操作。
缓存雪崩:由于原有缓存失效,新缓存未到期间(例如:我们设置缓存时采用了相同的过期时间,在同一时刻出现大面积的缓存过期),而导致后面原本应该访问缓存的请求都会落到持久层数据库上,造成持久层数据库短时间内承受大量请求而崩掉。
缓存穿透:用户查询一个数据库中不存在的某一key,查询Redis缓存发现并没有,即缓存没有命中,接着向持久层数据库查询,发现也没有,于是本次查询失败。当用户很多的时候,缓存都没有命中,于是请求都去了持久层数据库。这会给持久层数据库造成很大的压力,出现缓存穿透。
缓存击穿:某个key非常的热点,在不停的扛着大并发对这个点集中访问,这个key过期失效的瞬间,持续的大并发请求就会击穿缓存,直接请求持久层数据库。
解决方案:
设置热点key永不过期
在访问key之前,采用一个SETNX(set if not exists)来设置另一个短期key来锁住当前key的访问,访问结束再删除之
加互斥锁
static Lock reenLock = new ReentrantLock();
public List<String> getData() throws InterruptedException {
List<String> result = new ArrayList<String>();
// 从缓存读取数据
result = getDataFromCache();
if (result.isEmpty()) {
if (reenLock.tryLock()) {
try {
System.out.println("我拿到锁了,从DB获取数据库后写入缓存");
// 从数据库查询数据
result = getDataFromDB();
// 将查询到的数据写入缓存
setDataToCache(result);
} finally {
reenLock.unlock();// 释放锁
}
} else {
result = getDataFromCache();// 先查一下缓存
if (result.isEmpty()) {
System.out.println("我没拿到锁,缓存也没数据,先小憩一下");
Thread.sleep(100);// 小憩一会儿
return getData();// 重试
}
}
}
return result;
}
缓存预热:系统上线后,提前将相关的缓存数据直接加载到缓存系统。以避免用户请求的时候,先查询数据库,再将数据缓存的问题。
缓存更新:除了缓存服务器自带的缓存失效策略之外(Redis默认6种)我们还可以根据具体的业务需求进行自定义的缓存淘汰:
缓存降级:**缓存降级是指缓存失效或缓存服务器挂掉的情况下,不去访问数据库,直接返回默认数据或访问服务的内存数据。**当访问量剧增、服务出现问题(响应过慢或者不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使这样会对服务有损。核心就是将一些对核心服务影响不大的缓存进行丢弃。
降级的最终目的是保证核心服务可用,即使是有损的,而有些服务是无法降级的,例如加入购物车、结算等。
在进行降级之前要对系统进行梳理,看看系统能否可以丢车保帅,从而梳理出哪些必须誓死保护,哪些可以降级。
一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。
引入问题:如果面试官问你,有 100 亿 url 存在一个黑名单中,每条 url 平均 64 字节。问这个黑名单要怎么存?若此时随便输入一个 url,如何判断该 url 是否在这个黑名单中?
对于第一个问题,如果把黑名单看成是一个集合,将其存入hashMap中,需要640G的空间,所以我们需要使用布隆过滤器来解决,布隆过滤器只需要23GB。
布隆过滤器是一个很长的二进制矢量和一系列随机映射函数。其可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难,理论上来说添加到集合中的元素越多,误报的可能性就越大。
假设位数组的长度为m,每个元素值为0或1,有k个哈希函数参与运算。
当我们输入一个url的时候,该url会经过k个哈希函数处理,得到k个哈希值(v1,v2…vk),之后得到这些哈希值对应在数组的下标位置,并将这些下标的元素都置为1。
当我们要判断一个url是否在黑名单中,我们输入一个url并进行k个哈希函数处理,这会得到多个下标位置,如果这些下标的元素值都为1,说明该url在黑名单里面,只要存在一个0,说明该url不在布隆过滤器中。
如果布隆过滤器说某个元素存在,小概率会误判。但布隆过滤器说某个元素不存在,那么它必不存在。
布隆过滤器的缺点:
使用keys pre* 就可以了。但是这个命令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完服务才能恢复。我们可以选择scan命令,scan可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,我们在客户端做一次去重即可。
keys *:查询所有的键,时间复杂度ON
dbsize:查询键总数,直接获取redis内置键的总数变量,时间复杂度O1
exists key:查询某一个键是否存在,存在返回1,不存在返回0
del key:返回结果为成功删除键的个数,时间复杂度ON
expire key seconds:设置过期时间
ttl key:查看键剩余过期时间,单位是秒。>0为剩余过期时间;-1没设置过期时间;2键不存在
type key:返回键的类型
rename key newkey:重命名
set key value [ex] [px] [nx|xx]:ex秒级过期时间,px毫秒级过期时间,nx键必须不存在才能设置成功,用于添加,xx键必须存在才能设置成功,用于更新。
get key:获取值,O1,不存在返回nil
mset k v k v:批量设置值,ON
mget key:批量获取值
hset key filed value:hash,设置值
hget key field:获取值
hdel key field:删除一个或者多个field,返回结果为成功删除field的个数
hlen key:计算field的个数
hexists key field:判断field是否存在
hkeys key:获取所有的field
hvals key:获取所有value
hgetall key:获取所有的field value
l/rpush key value:从左/右边插入元素
linset key before/after pivot value:从列表中找到等于pivot的元素,在其前或者后插入一个新的元素value
lrange key start end:从左到右0到N-1,从右到左-1到-N,end包含自身
lindex key index:获取指定下标的元素
llen key:获取列表长度
l/rpop key:从列表左/右侧弹出元素
lrem key count value:删除count个元素,>0从左到右删除count个,<0反之,=0删除所有
lset key index newValue:修改指定索引下标的元素
blpop key timeout:阻塞操作,在timeout后返回,为0则一直阻塞
sadd key element:添加元素,返回结果为添加成功的元素个数
srem key element:删除元素,返回结果为删除成功的元素个数
scard key:计算元素个数,使用内部变量,O1
sismember key element:判断元素是否在集合中,是1否0
srandmember key count,随机从集合返回默认个元素