为什么要做这个总结
如今Redis已经像MySQL一样,成为了一名后端开发攻城狮必会的技术。面试中更是被频频问到,甚至占据了较大的比重。但是很多小伙伴并没有归纳过这些知识点,该怎么回答?或者看似说出了答案,却没有答到要点,又或者稍微深一点的知识就发现自己根本没有看过,怎么还会问这个?发挥很平凡,那你在面试官心中也就成了那厚厚一摞简历中一个平凡的候选人。
没关系,下面我会由浅入深在给出题目的同时,也会给出答案。由于知识点较多,并且会涉及到一些复杂的知识点,Redis部分我可能会连载用一个专题来写。
一、什么是Redis,应用场景是什么
Redis 是 C 语言开发的一个开源的高性能键值对(key-value)的内存数据库, 它是一种 NoSQL(not-only sql,泛指非关系型数据库)的数据库。
1.性能优秀,数据在内存中,读写速度非常快,支持并发 10W QPS。
2.支持数据持久化。可以将内存中数据保存在磁盘中,重启时加载。
3.可以做主从复制,哨兵,高可用。
场景:可以用作数据库、缓存、消息中间件等。具体表现为缓存、共享Session、消息队列、分布式锁。
二、Redis为什么这么快?
1.纯内存操作
2.单进程单线程操作,是线程安全,避免了频繁的上下文切换
3.数据结构简单,合理高效
4.采用了非阻塞I/O多路复用机制,非阻塞IO
多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。
这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。 采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),且 Redis 在内存中操作数据的速度非常快,也就是说内存内的操作不会成为影响Redis性能的瓶颈,主要由以上几点造就了 Redis 具有很高的吞吐量。
三、Redis有哪些数据类型
Redis 有五种数据类型。
1.String (字符串)是 Redis 最基本的类型,一个 Key 对应一个 Value。key是字符串类型,Value 不仅是 String,也可以是数字。其他几种数据结构都是在字符串类型基础上构建的。 String 类型是二进制安全的,意思是 Redis 的 String 类型可以包含任何数据,比如 jpg 图片或者序列化的对象。String 类型的值最大能存储 512M。 常用在缓存、计数、共享Session、限速等。
2.Hash(哈希)是一个键值(key-value)的集合。Redis 的 Hash 是一个 String 的 Key 和 Value 的映射表,Hash 特别适合存储对象。 哈希可以用来存放用户信息,比如实现购物车。
3.List (列表)用来存储多个有序的字符串,按照插入顺序排序。可以添加一个元素到列表的头部或者尾部。 常用命令:lpush、rpush、lpop、rpop、lrange(获取列表片段)等。 应用场景:List 应用场景非常多,也是 Redis 最重要的数据结构之一,比如 Twitter 的关注列表,粉丝列表都可以用 List 结构来实现。 数据结构:List 就是链表,可以用来当消息队列用。Redis 提供了 List 的 Push 和 Pop 操作,还提供了操作某一段的 API,可以直接查询或者删除某一段的元素。 实现方式:Redis List 的是实现是一个双向链表,既可以支持反向查找和遍历,更方便操作,不过带来了额外的内存开销。
4.Set (集合)是 String 类型的无序集合。集合是通过 hashtable 实现的。Set 中的元素是没有顺序的,而且是没有重复的。 应用场景:Redis Set 对外提供的功能和 List 一样是一个列表,特殊之处在于 Set 是自动去重的,而且 Set 提供了判断某个成员是否在一个 Set 集合中。利用 Set 的交集、并集、差集等操作,可以计算共同喜好,全部的喜好,自己独有的喜好等功能。
5.Zset (有序集合)和 Set 一样是 String 类型元素的集合,且不允许重复的元素,Sorted Set 多了一个权重参数 Score,集合中的元素能够按 Score 进行排列。。常用命令:zadd、zrange、zrem、zcard 等。 使用场景:Sorted Set 可以通过用户额外提供一个优先级(score)的参数来为成员排序,并且是插入有序的,即自动排序。 当你需要一个有序的并且不重复的集合列表,那么可以选择 Sorted Set 结构。 和 Set 相比,Sorted Set关联了一个 Double 类型权重的参数 Score,使得集合中的元素能够按照 Score 进行有序排列,Redis 正是通过分数来为集合中的成员进行从小到大的排序。 实现方式:Redis Sorted Set 的内部使用 HashMap 和跳跃表(skipList)来保证数据的存储和有序,HashMap 里放的是成员到 Score 的映射。 而跳跃表里存放的是所有的成员,排序依据是 HashMap 里存的 Score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单。
(zset和跳跃表作为一个单独知识点来讲)
四、Redis 的数据过期策略
Redis 中数据过期策略采用定期删除+惰性删除策略
定期删除策略:Redis 启用一个定时器定时监视所有的 key,判断key是否过期,过期的话就删除。这种策略可以保证过期的 key 最终都会被删除,但是也存在严重的缺点:每次都遍历内存中所有的数据,非常消耗 CPU 资源,并且当 key 已过期,但是定时器还处于未唤起状态,这段时间内 key 仍然可以用。
惰性删除策略:在获取 key 时,先判断 key 是否过期,如果过期则删除。这种方式存在一个缺点:如果这个 key 一直未被使用,那么它一直在内存中,其实它已经过期了,会浪费大量的空间。
这两种策略天然的互补,结合起来之后,定时删除策略就发生了一些改变,不在是每次扫描全部的 key 了,而是随机抽取一部分 key 进行检查,这样就降低了对 CPU 资源的损耗,惰性删除策略互补了为检查到的key,基本上满足了所有要求。但是有时候就是那么的巧,既没有被定时器抽取到,又没有被使用,这些数据又如何从内存中消失?没关系,还有内存淘汰机制,当内存不够用时,内存淘汰机制就会上场。淘汰策略分为:
1.当内存不足以容纳新写入数据时,新写入操作会报错。(Redis 默认策略)
2.当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 Key。(LRU推荐使用)
3.当内存不足以容纳新写入数据时,在键空间中,随机移除某个 Key。
4.当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 Key。这种情况一般是把 Redis 既当缓存,又做持久化存储的时候才用。
5.当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 Key。
6.当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 Key 优先移除。
五、Redis的LRU具体实现:
传统的LRU是使用栈的形式,每次都将最新使用的移入栈顶,但是用栈的形式会导致执行select *的时候大量非热点数据占领头部数据,所以需要改进。
Redis每次按key获取一个值的时候,都会更新value中的lru字段为当前秒级别的时间戳。Redis初始的实现算法很简单,随机从dict中取出五个key,淘汰一个lru字段值最小的。在3.0的时候,又改进了一版算法,首先第一次随机选取的key都会放入一个pool中(pool的大小为16),pool中的key是按lru大小顺序排列的。接下来每次随机选取的keylru值必须小于pool中最小的lru才会继续放入,直到将pool放满。放满之后,每次如果有新的key需要放入,需要将pool中lru最大的一个key取出。淘汰的时候,直接从pool中选取一个lru最小的值然后将其淘汰。
缓存淘汰算法:LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。
说到锁,那就一定会涉及加锁和解锁两个过程。先说加锁的实现。
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:
1.第一个为key,我们使用key来当锁,因为key是唯一的。
2.第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
3.第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
4.第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
5.第五个为time,与第四个参数相呼应,代表key的过期时间。 总的来说,执行上面的set()方法就只会导致两种结果:1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不做任何操作。
心细的童鞋就会发现了,我们的加锁代码满足我们可靠性里描述的三个条件。首先,set()加入了NX参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。其次,由于我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。最后,因为我们将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。由于我们只考虑Redis单机部署的场景,所以容错性我们暂不考虑。
释放锁:
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
由于解锁需要分为两步,第一步先获取锁,为什么不能直接删除呢?因为需要保证这是本线程加的锁,确认value相同,防止本锁过期,误删其他线程set的锁。第二步才是删除锁。
由于这是两步操作,就成为了不是线程安全对原子操作。为了保证对键操作对原子性,REdis官方推荐采用lua脚本对形式。直接在redis内部先进行get,再进行del。
时间有限内容太多,下篇继续讲解Redis。比如缓存雪崩、击穿、缓存穿透的问题,还有Redis与Mysql双写一致性方案、持久化、主从复制等问题。
请继续关注。