锐单电子商城 , 一站式电子元器件采购平台!
  • 电话:400-990-0325

4W字的Redis面试教程 再不会我可就锤你了

时间:2023-04-24 12:37:01 cp114差压变送器ht2088变送器cyb11w微压力变送器aux11变送器

本文脑图

redis基本数据结构

本文脑图

前言

Redis它是一个基于C语言的开源非关系内存数据库,可用作数据库、缓存和信息中间件。客户必须一点一点地理解这样优秀的东西。

这是关于Redis详细说明了这五种数据结构的基本原理。

理论必须用于实践,所以最重要的是实战部分,即五种数据结构的应用场景。

话不多说,我们直接进入主题,很多人都知道Redis五种数据结构包括以下五种:

  1. String:字符串类型

  2. List:列表类型

  3. Set:无序集合类型

  4. ZSet:有序集合类型

  5. Hash:哈希表类型

但作为一名优秀的程序员,它可能不仅仅停留在五种类型中crud在工作中,我们仍然需要深入了解这五种数据结构的基本原理。

Redis核心对象

在Redis中有一个核心的对象叫做redisObject ,用来表示一切key和value的,用redisObject表示结构体String、Hash、List、Set、ZSet五种数据类型。

redisObject的源代码在redis.h用C语言写的,感兴趣的可以自己查看。redisObject我在这里画了一张图,表示redisObject的结构如下所示:

闪盲人的五颜六色图

在redisObject中type表示属于哪种数据类型,encoding表示数据的存储模式,也就是说,数据类型的数据结构是在底层实现的。因此,本文具体介绍了这篇文章encoding对应部分。

那么encoding存储类型的含义是什么?具体数据类型的含义如下图所示:

图片截图来自《Redis第二版的设计与实现

也许看完这张照片后,我仍然感到困惑。不要惊慌,将详细介绍五个数据结构,这张图片只是让你找到每个数据结构对应的存储类型,可能有一个印象。

举个简单的例子,你在Redis设置字符串key 234,然后检查字符串的存储类型int非整数型使用非整数型embstr存储类型,具体操作如下图所示:

String类型

String是Redis最基本的数据类型也在上面的介绍中提到Redis是用c语言开发的。但是RedisC语言中的字符串和字符串有明显的区别。

String有三种数据结构存储方式int、raw、embstr。那么这三种存储方式有什么区别呢?

int

Redis如果存储是中规定的整数型值,比如set num 123这种类型将被使用 int存储方式存储在redisObject的ptr属性该值将保存在中间。

SDS

假如存储的字符串是一个字符串,长度大于32个字节就会使用SDS(simple dynamic string)存储方式和encoding设置为raw;若是字符串的长度小于或等于32个字节就会将encoding改为embstr来保存字符串。

SDS称为简单动态字符串,对于SDS中的定义在Redis源代码中有三个属性int len、int free、char buf[]

len保存字符串的长度,free表示buf数组中未使用的字节数,buf数组则是保存字符串的每一个字符元素。

因此当你在Redsi存储一个字符串Hello时,根据Redis可以画出源代码的描述SDS的形式的redisObject结构图如下图所示:

SDS与比C语言字符串

Redis使用SDS存储字符串的类型必须有自己的优点,SDS与C语言字符串相比,SDS对C语言字符串进行了自己的设计和优化,具体优点如下:

(1)c语言中的字符串不会记录自己的长度,所以每次都能得到字符串的长度,时间的复杂性就是O(n),而Redis获得字符串只需读取len时间复杂度可以变成O(1)。

(2)c语言两个字符串拼接,如果没有足够长的内存空间,缓冲区会溢出;而SDS会先根据len属性判断空间是否符合要求。如果空间不够,相应的空间应的空间。缓冲区不会溢出

(3)SDS还提供空间预分配惰性空间释放两种策略。在分配字符串的空间时,分配的空间比实际的要多,这样就可以了减少连续执行字符串增长导致内存重新分配的次数

当字符串缩短时,SDS不适用的空间不会立即回收,而是通过free记录未使用的空间,然后在以后使用时释放属性。

空间预分配的具体原则是:修改字符串后的长度len小于1MB,预分配和len空间长度相同,即len=free;若是len大于1MB,free分配的空间大小为1MB

(4)SDS二进制是安全的。除了存储字符串外,还可以存储二进制文件(如图片、音频、视频等文件的二进制数据);C语言中的字符串以空字符串为结束符,有些图片包含结束符,因此二进制不安全。

为了方便易懂,做了C语言字符串和SDS对比表如下:

c语言字符串 SDS
获取长度的时间复杂度为O(n) 获取长度的时间复杂度为O(1)
不是二进制安全的 二进制安全
字符串只能保存 还可以保存二进制数据
n次增长字符串必然会带来n次内存分配 n次增长字符串内存分配的次数<=n

String类型应用

说到这里,相信很多人已经精通了Redis的String类型,但精通纯理论,理论仍需应用实践,上述String可用于存储图片,现在以图片存储为例。

(1)首先要编码上传的图片,这里写了一个工具类,把图片处理成Base64必须实现以下代码:

/** *处理图片内容Base64编码格式 *@paramfile *@return */ publicstaticStringencodeImg(MultipartFilefile){ byte[]imgBytes=null; try{ imgBytes=file.getBytes(); }catch(IOExceptione){ e.printStackTrace(); } BASE64Encoderencoder=newBASE64Encoder(); &nbp;    return imgBytes==null?null:encoder.encode(imgBytes );
    }

(2)第二步就是把处理后的图片字符串格式存储进Redis中,实现得代码如下所示:

    /**
     * Redis存储图片
     * @param file
     * @return
     */
    public void uploadImageServiceImpl(MultipartFile image) {
        String imgId = UUID.randomUUID().toString();
        String imgStr= ImageUtils.encodeImg(image);
        redisUtils.set(imgId , imgStr);
        // 后续操作可以把imgId存进数据库对应的字段,如果需要从redis中取出,只要获取到这个字段后从redis中取出即可。
    }

这样就是实现了图片得二进制存储,当然String类型得数据结构得应用也还有常规计数:统计微博数、统计粉丝数等。

Hash类型

Hash对象的实现方式有两种分别是ziplist、hashtable,其中hashtable的存储方式key是String类型的,value也是以key value的形式进行存储。

字典类型的底层就是hashtable实现的,明白了字典的底层实现原理也就是明白了hashtable的实现原理,hashtable的实现原理可以于HashMap的是底层原理相类比。

字典

两者在新增时都会通过key计算出数组下标,不同的是计算法方式不同,HashMap中是以hash函数的方式,而hashtable中计算出hash值后,还要通过sizemask 属性和哈希值再次得到数组下标。

我们知道hash表最大的问题就是hash冲突,为了解决hash冲突,假如hashtable中不同的key通过计算得到同一个index,就会形成单向链表(链地址法),如下图所示:

rehash

在字典的底层实现中,value对象以每一个dictEntry的对象进行存储,当hash表中的存放的键值对不断的增加或者减少时,需要对hash表进行一个扩展或者收缩。

这里就会和HashMap一样也会就进行rehash操作,进行重新散列排布。从上图中可以看到有ht[0]ht[1]两个对象,先来看看对象中的属性是干嘛用的。

在hash表结构定义中有四个属性分别是dictEntry **table、unsigned long size、unsigned long sizemask、unsigned long used,分别表示的含义就是哈希表数组、hash表大小、用于计算索引值,总是等于size-1、hash表中已有的节点数

ht[0]是用来最开始存储数据的,当要进行扩展或者收缩时,ht[0]的大小就决定了ht[1]的大小,ht[0]中的所有的键值对就会重新散列到ht[1]中。

扩展操作:ht[1]扩展的大小是比当前 ht[0].used 值的二倍大的第一个 2 的整数幂;收缩操作:ht[0].used 的第一个大于等于的 2 的整数幂。

当ht[0]上的所有的键值对都rehash到ht[1]中,会重新计算所有的数组下标值,当数据迁移完后ht[0]就会被释放,然后将ht[1]改为ht[0],并新创建ht[1],为下一次的扩展和收缩做准备。

渐进式rehash

假如在rehash的过程中数据量非常大,Redis不是一次性把全部数据rehash成功,这样会导致Redis对外服务停止,Redis内部为了处理这种情况采用渐进式的rehash

Redis将所有的rehash的操作分成多步进行,直到都rehash完成,具体的实现与对象中的rehashindex属性相关,若是rehashindex 表示为-1表示没有rehash操作

当rehash操作开始时会将该值改成0,在渐进式rehash的过程更新、删除、查询会在ht[0]和ht[1]中都进行,比如更新一个值先更新ht[0],然后再更新ht[1]。

而新增操作直接就新增到ht[1]表中,ht[0]不会新增任何的数据,这样保证ht[0]只减不增,直到最后的某一个时刻变成空表,这样rehash操作完成。

上面就是字典的底层hashtable的实现原理,说完了hashtable的实现原理,我们再来看看Hash数据结构的两一种存储方式ziplist(压缩列表)

ziplist

压缩列表(ziplist)是一组连续内存块组成的顺序的数据结构,压缩列表能够节省空间,压缩列表中使用多个节点来存储数据。

压缩列表是列表键和哈希键底层实现的原理之一,压缩列表并不是以某种压缩算法进行压缩存储数据,而是它表示一组连续的内存空间的使用,节省空间,压缩列表的内存结构图如下:

压缩列表中每一个节点表示的含义如下所示:

  1. zlbytes:4个字节的大小,记录压缩列表占用内存的字节数。

  2. zltail:4个字节大小,记录表尾节点距离起始地址的偏移量,用于快速定位到尾节点的地址。

  3. zllen:2个字节的大小,记录压缩列表中的节点数。

  4. entry:表示列表中的每一个节点。

  5. zlend:表示压缩列表的特殊结束符号'0xFF'

再压缩列表中每一个entry节点又有三部分组成,包括previous_entry_ength、encoding、content

  1. previous_entry_ength表示前一个节点entry的长度,可用于计算前一个节点的其实地址,因为他们的地址是连续的。

  2. encoding:这里保存的是content的内容类型和长度。

  3. content:content保存的是每一个节点的内容。

说到这里相信大家已经都hash这种数据结构已经非常了解,若是第一次接触Redis五种基本数据结构的底层实现的话,建议多看几遍,下面来说一说hash的应用场景。

应用场景

哈希表相对于String类型存储信息更加直观,擦欧总更加方便,经常会用来做用户数据的管理,存储用户的信息。

hash也可以用作高并发场景下使用Redis生成唯一的id。下面我们就以这两种场景用作案例编码实现。

存储用户数据

第一个场景比如我们要储存用户信息,一般使用用户的ID作为key值,保持唯一性,用户的其他信息(地址、年龄、生日、电话号码等)作为value值存储。

若是传统的实现就是将用户的信息封装成为一个对象,通过序列化存储数据,当需要获取用户信息的时候,就会通过反序列化得到用户信息。

但是这样必然会造成序列化和反序列化的性能的开销,并且若是只修改其中的一个属性值,就需要把整个对象序列化出来,操作的动作太大,造成不必要的性能开销。

若是使用Redis的hash来存储用户数据,就会将原来的value值又看成了一个k v形式的存储容器,这样就不会带来序列化的性能开销的问题。

分布式生成唯一ID

第二个场景就是生成分布式的唯一ID,这个场景下就是把redis封装成了一个工具类进行实现,实现的代码如下:

    // offset表示的是id的递增梯度值
    public Long getId(String key,String hashKey,Long offset) throws BusinessException{
        try {
            if (null == offset) {
                offset=1L;
            }
            // 生成唯一id
            return redisUtil.increment(key, hashKey, offset);
        } catch (Exception e) {
            //若是出现异常就是用uuid来生成唯一的id值
            int randNo=UUID.randomUUID().toString().hashCode();
            if (randNo < 0) {
                randNo=-randNo;
            }
            return Long.valueOf(String.format("%16d", randNo));
        }
    }

List类型

Redis中的列表在3.2之前的版本是使用ziplistlinkedlist进行实现的。在3.2之后的版本就是引入了quicklist

ziplist压缩列表上面已经讲过了,我们来看看linkedlist和quicklist的结构是怎么样的。

linkedlist是一个双向链表,他和普通的链表一样都是由指向前后节点的指针。插入、修改、更新的时间复杂度尾O(1),但是查询的时间复杂度确实O(n)。

linkedlist和quicklist的底层实现是采用链表进行实现,在c语言中并没有内置的链表这种数据结构,Redis实现了自己的链表结构。

Redis中链表的特性:

  1. 每一个节点都有指向前一个节点和后一个节点的指针。

  2. 头节点和尾节点的prev和next指针指向为null,所以链表是无环的。

  3. 链表有自己长度的信息,获取长度的时间复杂度为O(1)。

Redis中List的实现比较简单,下面我们就来看看它的应用场景。

应用场景

Redis中的列表可以实现阻塞队列,结合lpush和brpop命令就可以实现。生产者使用lupsh从列表的左侧插入元素,消费者使用brpop命令从队列的右侧获取元素进行消费。

(1)首先配置redis的配置,为了方便我就直接放在application.yml配置文件中,实际中可以把redis的配置文件放在一个redis.properties文件单独放置,具体配置如下:

spring
 redis:
  host: 127.0.0.1
  port: 6379
  password: user
  timeout: 0
  database: 2
  pool:
   max-active: 100
   max-idle: 10
   min-idle: 0
   max-wait: 100000

(2)第二步创建redis的配置类,叫做RedisConfig,并标注上@Configuration注解,表明他是一个配置类。

@Configuration
public class RedisConfiguration {

@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Value("${spring.redis.password}")
private String password;
@Value("${spring.redis.pool.max-active}")
private int maxActive;
@Value("${spring.redis.pool.max-idle}")
private int maxIdle;
@Value("${spring.redis.pool.min-idle}")
private int minIdle;
@Value("${spring.redis.pool.max-wait}")
private int maxWait;
@Value("${spring.redis.database}")
private int database;
@Value("${spring.redis.timeout}")
private int timeout;

@Bean
public JedisPoolConfig getRedisConfiguration(){
 JedisPoolConfig jedisPoolConfig= new JedisPoolConfig();
 jedisPoolConfig.setMaxTotal(maxActive);
 jedisPoolConfig.setMaxIdle(maxIdle);
 jedisPoolConfig.setMinIdle(minIdle);
 jedisPoolConfig.setMaxWaitMillis(maxWait);
 return jedisPoolConfig;
}

@Bean
public JedisConnectionFactory getConnectionFactory() {
 JedisConnectionFactory factory = new JedisConnectionFactory();
 factory.setHostName(host);
 factory.setPort(port);
 factory.setPassword(password);
 factory.setDatabase(database);
 JedisPoolConfig jedisPoolConfig= getRedisConfiguration();
 factory.setPoolConfig(jedisPoolConfig);
 return factory;
}

@Bean
public RedisTemplate getRedisTemplate() {
 JedisConnectionFactory factory = getConnectionFactory();
 RedisTemplate redisTemplate = new StringRedisTemplate(factory);
 return redisTemplate;
}
}

(3)第三步就是创建Redis的工具类RedisUtil,自从学了面向对象后,就喜欢把一些通用的东西拆成工具类,好像一个一个零件,需要的时候,就把它组装起来。

@Component
public class RedisUtil {

@Autowired
private RedisTemplate redisTemplate;
/**
* 存消息到消息队列中
* @param key 键
* @param value 值
* @return
*/
public boolean lPushMessage(String key, Object value) {
 try {
   redisTemplate.opsForList().leftPush(key, value);
   return true;
 } catch (Exception e) {
   e.printStackTrace();
   return false;
 }
}

/**
* 从消息队列中弹出消息 - 
* @param key 键
* @return
*/
public Object rPopMessage(String key) {
 try {
   return redisTemplate.opsForList().rightPop(key);
 } catch (Exception e) {
   e.printStackTrace();
   return null;
 }
}

/**
* 查看消息
* @param key 键
* @param start 开始
* @param end 结束 0 到 -1代表所有值
* @return
*/
public List getMessage(String key, long start, long end) {
 try {
   return redisTemplate.opsForList().range(key, start, end);
 } catch (Exception e) {
   e.printStackTrace();
   return null;
 }
}
 
    

这样就完成了Redis消息队列工具类的创建,在后面的代码中就可以直接使用。

Set集合

Redis中列表和集合都可以用来存储字符串,但是Set是不可重复的集合,而List列表可以存储相同的字符串,Set集合是无序的这个和后面讲的ZSet有序集合相对。

Set的底层实现是ht和intset,ht(哈希表)前面已经详细了解过,下面我们来看看inset类型的存储结构。

inset也叫做整数集合,用于保存整数值的数据结构类型,它可以保存int16_tint32_t 或者int64_t 的整数值。

在整数集合中,有三个属性值encoding、length、contents[],分别表示编码方式、整数集合的长度、以及元素内容,length就是记录contents里面的大小。

在整数集合新增元素的时候,若是超出了原集合的长度大小,就会对集合进行升级,具体的升级过程如下:

  1. 首先扩展底层数组的大小,并且数组的类型为新元素的类型。

  2. 然后将原来的数组中的元素转为新元素的类型,并放到扩展后数组对应的位置。

  3. 整数集合升级后就不会再降级,编码会一直保持升级后的状态。

应用场景

Set集合的应用场景可以用来去重、抽奖、共同好友、二度好友等业务类型。接下来模拟一个添加好友的案例实现:

@RequestMapping(value = "/addFriend", method = RequestMethod.POST)
public Long addFriend(User user, String friend) {
    String currentKey = null;
    // 判断是否是当前用户的好友
    if (AppContext.getCurrentUser().getId().equals(user.getId)) {
        currentKey = user.getId.toString();
    }
    //若是返回0则表示不是该用户好友
    return currentKey==null?0l:setOperations.add(currentKey, friend);
}

假如两个用户A和B都是用上上面的这个接口添加了很多的自己的好友,那么有一个需求就是要实现获取A和B的共同好友,那么可以进行如下操作:

public Set intersectFriend(User userA, User userB) {
    return setOperations.intersect(userA.getId.toString(), userB.getId.toString());
}

举一反三,还可以实现A用户自己的好友,或者B用户自己的好友等,都可以进行实现。

ZSet集合

ZSet是有序集合,从上面的图中可以看到ZSet的底层实现是ziplistskiplist实现的,ziplist上面已经详细讲过,这里来讲解skiplist的结构实现。

skiplist也叫做跳跃表,跳跃表是一种有序的数据结构,它通过每一个节点维持多个指向其它节点的指针,从而达到快速访问的目的。

skiplist由如下几个特点:

  1. 有很多层组成,由上到下节点数逐渐密集,最上层的节点最稀疏,跨度也最大。

  2. 每一层都是一个有序链表,只扫包含两个节点,头节点和尾节点。

  3. 每一层的每一个每一个节点都含有指向同一层下一个节点和下一层同一个位置节点的指针。

  4. 如果一个节点在某一层出现,那么该以下的所有链表同一个位置都会出现该节点。

具体实现的结构图如下所示:

在跳跃表的结构中有head和tail表示指向头节点和尾节点的指针,能后快速的实现定位。level表示层数,len表示跳跃表的长度,BW表示后退指针,在从尾向前遍历的时候使用。

BW下面还有两个值分别表示分值(score)和成员对象(各个节点保存的成员对象)。

跳跃表的实现中,除了最底层的一层保存的是原始链表的完整数据,上层的节点数会越来越少,并且跨度会越来越大。

跳跃表的上面层就相当于索引层,都是为了找到最后的数据而服务的,数据量越大,条表所体现的查询的效率就越高,和平衡树的查询效率相差无几。

应用场景

因为ZSet是有序的集合,因此ZSet在实现排序类型的业务是比较常见的,比如在首页推荐10个最热门的帖子,也就是阅读量由高到低,排行榜的实现等业务。

下面就选用获取排行榜前前10名的选手作为案例实现,实现的代码如下所示:

@Autowired
private RedisTemplate redisTemplate;
 /**
  * 获取前10排名
  * @return
  */
    public static List getZset(String key, long baseNum, LevelService levelService){
        ZSetOperations operations = redisTemplate.opsForZSet();
        // 根据score分数值获取前10名的数据
        Set> set = operations.reverseRangeWithScores(key,0,9);
        List list= new ArrayList();
        int i=1;
        for (ZSetOperations.TypedTuple o:set){
            int uid = (int) o.getValue();
            LevelCache levelCache = levelService.getLevelCache(uid);
            LevelVO levelVO = levelCache.getLevelVO();
            long score = (o.getScore().longValue() - baseNum + levelVO .getCtime())/CommonUtil.multiplier;
            levelVO .setScore(score);
            levelVO .setRank(i);
            list.add( levelVO );
            i++;
        }
        return list;
    }
 
    

以上的代码实现大致逻辑就是根据score分数值获取前10名的数据,然后封装成lawyerVO对象的列表进行返回。

到这里我们已经精通Redis的五种基本数据类型了,又可以去和面试官扯皮了,扯不过就跑路吧,或者这篇文章多看几遍,相信对你总是有好处的。

Redis内存分配策略

概述

今天就带来了一个面试常问的一个问题:假如你的Redis内存满了怎么办? 长期的把Redis作为缓存使用,总有一天会存满的时候对吧。

这个面试题不慌呀,在Redis中有配置参数maxmemory可以设置Redis内存的大小

在Redis的配置文件redis.conf文件中,配置maxmemory的大小参数如下所示:

实际生产中肯定不是100mb的大小哈,不要给误导了,这里我只是让大家认识这个参数,一般小的公司都是设置为3G左右的大小。

除了在配置文件中配置生效外,还可以通过命令行参数的形式,进行配置,具体的配置命令行如下所示:

//获取maxmemory配置参数的大小
127.0.0.1:6379> config get maxmemory
//设置maxmemory参数为100mb
127.0.0.1:6379> config set maxmemory 100mb

倘若实际的存储中超出了Redis的配置参数的大小时,Redis中有淘汰策略,把需要淘汰的key给淘汰掉,整理出干净的一块内存给新的key值使用

接下来我们就详细的聊一聊Redis中的淘汰策略,并且深入的理解每个淘汰策略的原理和应用的场景。

淘汰策略

Redis提供了6种的淘汰策略,其中默认的是noeviction,这6中淘汰策略如下:

  1. noeviction(默认策略):若是内存的大小达到阀值的时候,所有申请内存的指令都会报错。

  2. allkeys-lru:所有key都是使用LRU算法进行淘汰。

  3. volatile-lru:所有设置了过期时间的key使用LRU算法进行淘汰。

  4. allkeys-random:所有的key使用随机淘汰的方式进行淘汰。

  5. volatile-random:所有设置了过期时间的key使用随机淘汰的方式进行淘汰。

  6. volatile-ttl:所有设置了过期时间的key根据过期时间进行淘汰,越早过期就越快被淘汰

假如在Redis中的数据有一部分是热点数据,而剩下的数据是冷门数据,或者我们不太清楚我们应用的缓存访问分布状况,这时可以使用allkeys-lru

假如所有的数据访问的频率大概一样,就可以使用allkeys-random的淘汰策略。

假如要配置具体的淘汰策略,可以在redis.conf配置文件中配置,具体配置如下所示:

这只需要把注释给打开就可以,并且配置指定的策略方式,另一种的配置方式就是命令的方式进行配置,具体的执行命令如下所示:

// 获取maxmemory-policy配置
127.0.0.1:6379> config get maxmemory-policy
// 设置maxmemory-policy配置为allkeys-lru
127.0.0.1:6379> config set maxmemory-policy allkeys-lru

在介绍6种的淘汰策略方式的时候,说到了LRU算法,那么什么是LRU算法呢?

LRU算法

LRU(Least Recently Used)即表示最近最少使用,也就是在最近的时间内最少被访问的key,算法根据数据的历史访问记录来进行淘汰数据。

它的核心的思想就是:假如一个key值在最近很少被使用到,那么在将来也很少会被访问

实际上Redis实现的LRU并不是真正的LRU算法,也就是名义上我们使用LRU算法淘汰键,但是实际上被淘汰的键并不一定是真正的最久没用的。

Redis使用的是近似的LRU算法,通过随机采集法淘汰key,每次都会随机选出5个key,然后淘汰里面最近最少使用的key

这里的5个key只是默认的个数,具体的个数也可以在配置文件中进行配置,在配置文件中的配置如下图所示:

当近似LRU算法取值越大的时候就会越接近真实的LRU算法,可以这样理解,因为取值越大那么获取的数据就越全,淘汰中的数据的就越接近最近最少使用的数据

那么为了实现根据时间实现LRU算法,Redis必须为每个key中额外的增加一个内存空间用于存储每个key的时间,大小是3字节。

在Redis 3.0中对近似的LRU算法做了一些优化,Redis中会维护大小是16的一个候选池的内存。

当第一次随机选取的采样数据,数据都会被放进候选池中,并且候选池中的数据会根据时间进行排序。

当第二次以后选取的数据,只有小于候选池内的最小时间的才会被放进候选池中。

当某一时刻候选池的数据满了,那么时间最大的key就会被挤出候选池。当执行淘汰时,直接从候选池中选取最近访问时间最小的key进行淘汰。

这样做的目的就是选取出最近似符合最近最少被访问的key值,能够正确的淘汰key值,因为随机选取的样本中的最小时间可能不是真正意义上的最小时间。

但是LRU算法有一个弊端:就是假如一个key值在以前都没有被访问到,然而最近一次被访问到了,那么就会认为它是热点数据,不会被淘汰。

然而有些数据以前经常被访问到,只是最近的时间内没有被访问到,这样就导致这些数据很可能被淘汰掉,这样一来就会出现误判而淘汰热点数据。

于是在Redis 4.0的时候除了LRU算法,新加了一种LFU算法,那么什么是LFU算法算法呢?

LFU算法

LFU(Least Frequently Used)即表示最近频繁被使用,也就是最近的时间段内,频繁被访问的key,它以最近的时间段的被访问次数的频率作为一种判断标准。

它的核心思想就是:根据key最近被访问的频率进行淘汰,比较少被访问的key优先淘汰,反之则优先保留。

LFU算法反映了一个key的热度情况,不会因为LRU算法的偶尔一次被访问被认为是热点数据。

在LFU算法中支持volatile-lfu策略和allkeys-lfu策略。

以上介绍了Redis的6种淘汰策略,这6种淘汰策略旨在告诉我们怎么做,但是什么时候做?这个还没说,下面我们就来详细的了解Redis什么时候执行淘汰策略。

删除过期键策略

在Redis种有三种删除的操作此策略,分别是:

  1. 定时删除:创建一个定时器,定时的执行对key的删除操作。

  2. 惰性删除:每次只有再访问key的时候,才会检查key的过期时间,若是已经过期了就执行删除。

  3. 定期删除:每隔一段时间,就会检查删除掉过期的key。

定时删除对于内存来说是友好的,定时清理出干净的空间,但是对于cpu来说并不是友好的,程序需要维护一个定时器,这就会占用cpu资源。

惰性的删除对于cpu来说是友好的,cpu不需要维护其它额外的操作,但是对于内存来说是不友好的,因为要是有些key一直没有被访问到,就会一直占用着内存。

定期删除是上面两种方案的折中方案**,每隔一段时间删除过期的key,也就是根据具体的业务,合理的取一个时间定期的删除key**。

通过最合理控制删除的时间间隔来删除key,减少对cpu的资源的占用消耗,使删除操作合理化。

RDB和AOF 的淘汰处理

在Redis中持久化的方式有两种RDBAOF,具体这两种详细的持久化介绍,可以参考这一篇文章[]。

在RDB中是以快照的形式获取内存中某一时间点的数据副本,在创建RDB文件的时候可以通过savebgsave命令执行创建RDB文件。

这两个命令都不会把过期的key保存到RDB文件中,这样也能达到删除过期key的效果。

当在启动Redis载入RDB文件的时候,Master不会把过期的key载入,而Slave会把过期的key载入。

在AOF模式下,Redis提供了Rewite的优化措施,执行的命令分别是REWRITEAOFBGREWRITEAOF这两个命令都不会把过期的key写入到AOF文件中,也能删除过期key

Redis缓存三大问题

前言

日常的开发中,无不都是使用数据库来进行数据的存储,由于一般的系统任务中通常不会存在高并发的情况,所以这样看起来并没有什么问题。

一旦涉及大数据量的需求,如一些商品抢购的情景,或者主页访问量瞬间较大的时候,单一使用数据库来保存数据的系统会因为面向磁盘磁盘读/写速度问题有严重的性能弊端,详细的磁盘读写原理请参考这一片[]。

在这一瞬间成千上万的请求到来,需要系统在极短的时间内完成成千上万次的读/写操作,这个时候往往不是数据库能够承受的,极其容易造成数据库系统瘫痪,最终导致服务宕机的严重生产问题。

为了克服上述的问题,项目通常会引入NoSQL技术,这是一种基于内存数据库,并且提供一定的持久化功能。

Redis技术就是NoSQL技术中的一种。Redis缓存的使用,极大的提升了应用程序的性能和效率,特别是数据查询方面。

但同时,它也带来了一些问题。其中,最要害的问题,就是数据的一致性问题,从严格意义上讲,这个问题无解。如果对数据的一致性要求很高,那么就不能使用缓存

另外的一些典型问题就是,缓存穿透缓存击穿缓存雪崩。本篇文章从实际代码操作,来提出解决这三个缓存问题的方案,毕竟Redis的缓存问题是实际面试中高频问点,理论和实操要兼得。

缓存穿透

缓存穿透是指查询一条数据库和缓存都没有的一条数据,就会一直查询数据库,对数据库的访问压力就会增大,缓存穿透的解决方案,有以下两种:

  1. 缓存空对象:代码维护较简单,但是效果不好。

  2. 布隆过滤器:代码维护复杂,效果很好。

缓存空对象

缓存空对象是指当一个请求过来缓存中和数据库中都不存在该请求的数据,第一次请求就会跳过缓存进行数据库的访问,并且访问数据库后返回为空,此时也将该空对象进行缓存。

若是再次进行访问该空对象的时候,就会直接击中缓存,而不是再次数据库,缓存空对象实现的原理图如下:

缓存空对象的实现代码如下:

public class UserServiceImpl {
     @Autowired
     UserDAO userDAO;
     @Autowired
     RedisCache redisCache;
 
     public User findUser(Integer id) {
          Object object = redisCache.get(Integer.toString(id));
          // 缓存中存在,直接返回
          if(object != null) {
               // 检验该对象是否为缓存空对象,是则直接返回null
               if(object instanceof NullValueResultDO) {
                    return null;
               }
               return (User)object;
          } else {  
               // 缓存中不存在,查询数据库
               User user = userDAO.getUser(id);
               // 存入缓存
               if(user != null) {
                    redisCache.put(Integer.toString(id),user);
               } else {
                    // 将空对象存进缓存
                    redisCache.put(Integer.toString(id), new NullValueResultDO());
               }
               return user;
          }
     }          
}

缓存空对象的实现代码很简单,但是缓存空对象会带来比较大的问题,就是缓存中会存在很多空对象,占用内存的空间,浪费资源,一个解决的办法就是设置空对象的较短的过期时间,代码如下:

// 再缓存的时候,添加多一个该空对象的过期时间60秒
redisCache.put(Integer.toString(id), new NullValueResultDO(),60);

布隆过滤器

布隆过滤器是一种基于概率数据结构,主要用来判断某个元素是否在集合内,它具有运行速度快(时间效率),占用内存小的优点(空间效率),但是有一定的误识别率删除困难的问题。它只能告诉你某个元素一定不在集合内或可能在集合内。

在计算机科学中有一种思想:空间换时间,时间换空间。一般两者是不可兼得,而布隆过滤器运行效率和空间大小都兼得,它是怎么做到的呢?

在布隆过滤器中引用了一个误判率的概念,即它可能会把不属于这个集合的元素认为可能属于这个集合,但是不会把属于这个集合的认为不属于这个集合,布隆过滤器的特点如下:

  1. 一个非常大的二进制位数组 (数组里只有0和1)

  2. 若干个哈希函数

  3. 空间效率查询效率高

  4. 不存在漏报(False Negative):某个元素在某个集合中,肯定能报出来。

  5. 可能存在误报(False Positive):某个元素不在某个集合中,可能也被爆出来。

  6. 不提供删除方法,代码维护困难。

  7. 位数组初始化都为0,它不存元素的具体值,当元素经过哈希函数哈希后的值(也就是数组下标)对应的数组位置值改为1。

实际布隆过滤器存储数据和查询数据的原理图如下:

可能很多读者看完上面的特点和原理图,还是看不懂,别急下面通过图解一步一步的讲解布隆过滤器,总而言之一句简单的话概括就是布隆过滤器是一个很大二进制位数组,数组里面只存0和1

初始化的布隆过滤器的结构图如下:

以上只是画了布隆过滤器的很小很小的一部分,实际布隆过滤器是非常大的数组(这里的大是指它的长度大,并不是指它所占的内存空间大)。

那么一个数据是怎么存进布隆过滤器的呢?

当一个数据进行存入布隆过滤器的时候,会经过如干个哈希函数进行哈希(若是对哈希函数还不懂的请参考这一片[]),得到对应的哈希值作为数组的下标,然后将初始化的位数组对应的下标的值修改为1,结果图如下:

当再次进行存入第二个值的时候,修改后的结果的原理图如下:

所以每次存入一个数据,就会哈希函数的计算,计算的结果就会作为下标,在布隆过滤器中有多少个哈希函数就会计算出多少个下标,布隆过滤器插入的流程如下:

  1. 将要添加的元素给m个哈希函数

  2. 得到对应于位数组上的m个位置

  3. 将这m个位置设为1

那么为什么会有误判率呢?

假设在我们多次存入值后,在布隆过滤器中存在x、y、z这三个值,布隆过滤器的存储结构图如下所示:

当我们要查询的时候,比如查询a这个数,实际中a这个数是不存在布隆过滤器中的,经过2哥哈希函数计算后得到a的哈希值分别为2和13,结构原理图如下:

经过查询后,发现2和13位置所存储的值都为1,但是2和13的下标分别是x和z经过计算后的下标位置的修改,该布隆过滤器中实际不存在a,那么布隆过滤器就会误判改值可能存在,因为布隆过滤器不存元素值,所以存在误判率

那么具体布隆过布隆过滤的判断的准确率和一下两个因素有关:

  1. 布隆过滤器大小:越大,误判率就越小,所以说布隆过滤器一般长度都是非常大的。

  2. 哈希函数的个数:哈希函数的个数越多,那么误判率就越小。

那么为什么不能删除元素呢?

原因很简单,因为删除元素后,将对应元素的下标设置为零,可能别的元素的下标也引用改下标,这样别的元素的判断就会收到影响,原理图如下:

当你删除z元素之后,将对应的下标10和13设置为0,这样导致x和y元素的下标受到影响,导致数据的判断不准确,所以直接不提供删除元素的api。

以上说的都是布隆过滤器的原理,只有理解了原理,在实际的运用才能如鱼得水,下面就来实操代码,手写一个简单的布隆过滤器。

对于要手写一个布隆过滤器,首先要明确布隆过滤器的核心:

  • 若干哈希函数

  • 存值得Api

  • 判断值得Api

实现得代码如下:

public class MyBloomFilter {
    // 布隆过滤器长度
    private static final int SIZE = 2 << 10;
    // 模拟实现不同的哈希函数
    private static final int[] num= new int[] {5, 19, 23, 31,47, 71};   
    // 初始化位数组
    private BitSet bits = new BitSet(SIZE);
    // 用于存储哈希函数
    private MyHash[] function = new MyHash[num.length];
    
    // 初始化哈希函数
    public MyBloomFilter() {
        for (int i = 0; i < num.length; i++) {
            function [i] = new MyHash(SIZE, num[i]);
        }
    }
   
    // 存值Api 
    public void add(String value) {
        // 对存入得值进行哈希计算
        for (MyHash f: function) {
            // 将为数组对应的哈希下标得位置得值改为1
            bits.set(f.hash(value), true);
        }
    }
   
    // 判断是否存在该值得Api 
    public boolean contains(String value) {
        if (value == null) {
            return false;
        }
        boolean result= true;
        for (MyHash f : func) {
            result= result&& bits.get(f.hash(value));
        }
        return result;
    }
}

哈希函数代码如下:

public static class MyHash {
        private int cap;
        private int seed;
        // 初始化数据
        public MyHash(int cap, int seed) {
            this.cap = cap;
            this.seed = seed;
        }
        // 哈希函数
        public int hash(String value) {
            int result = 0;
            int len = value.length();
            for (int i = 0; i < len; i++) {
                result = seed * result + value.charAt(i);
            }
            return (cap - 1) & result;
        }
    }

布隆过滤器测试代码如下:

    public static void test {
        String value = "4243212355312";
        MyBloomFilter filter = new MyBloomFilter();
        System.out.println(filter.contains(value));
        filter.add(value);
        System.out.println(filter.contains(value));
    }

以上就是手写了一个非常简单得布隆过滤器,但是实际项目中可能事由牛人或者大公司已经帮你写好的,如谷歌的Google Guava,只需要在项目中引入一下依赖:


    com.google.guava
    guava
    27.0.1-jre

实际项目中具体的操作代码如下:

public static void MyBloomFilterSysConfig {

     @Autowired
     OrderMapper orderMapper
     
    // 1.创建布隆过滤器  第二个参数为预期数据量10000000,第三个参数为错误率0.00001
    BloomFilter bloomFilter =  BloomFilter.create(Funnels.stringFunnel(Charset.forName("utf-8")),10000000, 0.00001);
    // 2.获取所有的订单,并将订单的id放进布隆过滤器里面
    List orderList = orderMapper.findAll()
    for (Order order;orderList ) {
        Long id = order.getId();
        bloomFilter.put("" + id);
    }
}

在实际项目中会启动一个系统任务或者定时任务,来初始化布隆过滤器,将热点查询数据的id放进布隆过滤器里面,当用户再次请求的时候,使用布隆过滤器进行判断,改订单的id是否在布隆过滤器中存在,不存在直接返回null,具体操作代码:

// 判断订单id是否在布隆过滤器中存在
bloomFilter.mightContain("" + id)

布隆过滤器的缺点就是要维持容器中的数据,因为订单数据肯定是频繁变化的,实时的要更新布隆过滤器中的数据为最新。

缓存击穿

缓存击穿是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,瞬间对数据库的访问压力增大。

缓存击穿这里强调的是并发,造成缓存击穿的原因有以下两个:

  1. 该数据没有人查询过 ,第一次就大并发的访问。(冷门数据)

  2. 添加到了缓存,reids有设置数据失效的时间 ,这条数据刚好失效,大并发访问(热点数据)

对于缓存击穿的解决方案就是加,具体实现的原理图如下:

当用户出现大并发访问的时候,在查询缓存的时候和查询数据库的过程加锁,只能第一个进来的请求进行执行,当第一个请求把该数据放进缓存中,接下来的访问就会直接集中缓存,防止了缓存击穿

业界比价普遍的一种做法,即根据key获取value值为空时,锁上,从数据库中load数据后再释放锁。若其它线程获取锁失败,则等待一段时间后重试。这里要注意,分布式环境中要使用分布式锁单机的话用普通的锁(synchronizedLock)就够了。

下面以一个获取商品库存的案例进行代码的演示,单机版的锁实现具体实现的代码如下:

// 获取库存数量
public String getProduceNum(String key) {
    try {
        synchronized (this) {   //加锁
            // 缓存中取数据,并存入缓存中
            int num= Integer.parseInt(redisTemplate.opsForValue().get(key));
            
            if (num> 0) {
                //没查一次库存-1
                redisTemplate.opsForValue().set(key, (num- 1) + "");
                System.out.println("剩余的库存为num:" + (num- 1));
            } else {
                System.out.println("库存为0");
            }
        }
    } catch (NumberFormatException e) {
        e.printStackTrace();
    } finally {
    }
    return "OK";
}

分布式的锁实现具体实现的代码如下:

public String getProduceNum(String key) {
    // 获取分布式锁
    RLock lock = redissonClient.getLock(key);
    try {
        // 获取库存数
        int num= Integer.parseInt(redisTemplate.opsForValue().get(key));  
        // 上锁           
        lock.lock();
        if (num> 0) {
            //减少库存,并存入缓存中
            redisTemplate.opsForValue().set(key, (num - 1) + "");
            System.out.println("剩余库存为num:" + (num- 1));
        } else {
            System.out.println("库存已经为0");
        }
    } catch (NumberFormatException e) {
        e.printStackTrace();
    } finally {
        //解锁
        lock.unlock();
    }
    return "OK";
}

缓存雪崩

缓存雪崩 是指在某一个时间段,缓存集中过期失效。此刻无数的请求直接绕开缓存

锐单商城拥有海量元器件数据手册IC替代型号,打造电子元器件IC百科大全!

相关文章