本文脑图
redis基本数据结构
本文脑图
前言
Redis它是一个基于C语言的开源非关系内存数据库,可用作数据库、缓存和信息中间件。客户必须一点一点地理解这样优秀的东西。
这是关于Redis详细说明了这五种数据结构的基本原理。
理论必须用于实践,所以最重要的是实战部分,即五种数据结构的应用场景。
话不多说,我们直接进入主题,很多人都知道Redis五种数据结构包括以下五种:
-
String
:字符串类型 -
List
:列表类型 -
Set
:无序集合类型 -
ZSet
:有序集合类型 -
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的中就会保存该值。
SDS
假如存储的就会使用SDS(simple dynamic string)
存储方式和encoding设置为raw;若是就会将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)两个字符串拼接,如果没有足够长的内存空间,;而会先根据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编码格式 * @param file * @return */ public static String encodeImg(MultipartFile file) { byte[] imgBytes = null; try { imgBytes = file.getBytes(); } catch (IOException e) { e.printStackTrace(); } BASE64Encoder encoder = new BASE64Encoder(); 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内部为了处理这种情况采用 。
Redis将所有的rehash的操作分成多步进行,直到都rehash完成,具体的实现与对象中的 rehashindex
属性相关, 。
当rehash操作开始时会将该值改成0,在渐进式rehash的过程 ,比如更新一个值先更新ht[0],然后再更新ht[1]。
而新增操作直接就新增到ht[1]表中,ht[0]不会新增任何的数据,这样保证 ,这样rehash操作完成。
上面就是字典的底层hashtable的实现原理,说完了hashtable的实现原理,我们再来看看Hash数据结构的两一种存储方式
ziplist
压缩列表 (ziplist)
是一组连续内存块组成的顺序的数据结构,压缩列表能够节省空间,压缩列表中使用多个节点来存储数据。
压缩列表是列表键和哈希键底层实现的原理之一, 压缩列表并不是以某种压缩算法进行压缩存储数据,而是它表示一组连续的内存空间的使用,节省空间 ,压缩列表的内存结构图如下:
压缩列表中每一个节点表示的含义如下所示:
-
zlbytes
:4个字节的大小,记录压缩列表占用内存的字节数。 -
zltail
:4个字节大小,记录表尾节点距离起始地址的偏移量,用于快速定位到尾节点的地址。 -
zllen
:2个字节的大小,记录压缩列表中的节点数。 -
entry
:表示列表中的每一个节点。 -
zlend
:表示压缩列表的特殊结束符号'0xFF'
。
再压缩列表中每一个entry节点又有三部分组成,包括 previous_entry_ength、encoding、content
。
-
previous_entry_ength
表示前一个节点entry的长度,可用于计算前一个节点的其实地址,因为他们的地址是连续的。 -
encoding:这里保存的是content的内容类型和长度。
-
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之前的版本是使用 ziplist
和 linkedlist
进行实现的。在3.2之后的版本就是引入了 quicklist
。
ziplist压缩列表上面已经讲过了,我们来看看linkedlist和quicklist的结构是怎么样的。
linkedlist是一个双向链表,他和普通的链表一样都是由指向前后节点的指针。插入、修改、更新的时间复杂度尾O(1),但是查询的时间复杂度确实O(n)。
linkedlist和quicklist的底层实现是采用链表进行实现,在c语言中并没有内置的链表这种数据结构,Redis实现了自己的链表结构。
Redis中链表的特性:
-
每一个节点都有指向前一个节点和后一个节点的指针。
-
头节点和尾节点的prev和next指针指向为null,所以链表是无环的。
-
链表有自己长度的信息,获取长度的时间复杂度为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<String, Object> 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;
}
}
/**
* 从消息队列中弹出消息 - <rpop:非阻塞式>
* @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<Object> 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(哈希表)前面已经详细了解过,下面我们来看看inset类型的存储结构。
inset也叫做整数集合,用于保存整数值的数据结构类型,它可以保存 int16_t
、 int32_t
或者 int64_t
的整数值。
在整数集合中,有三个属性值 encoding、length、contents[]
,分别表示编码方式、整数集合的长度、以及元素内容,length就是记录contents里面的大小。
在整数集合新增元素的时候,若是超出了原集合的长度大小,就会对集合进行升级,具体的升级过程如下:
-
首先扩展底层数组的大小,并且数组的类型为新元素的类型。
-
然后将原来的数组中的元素转为新元素的类型,并放到扩展后数组对应的位置。
-
整数集合升级后就不会再降级,编码会一直保持升级后的状态。
应用场景
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的底层实现是 ziplist
和 skiplist
实现的,ziplist上面已经详细讲过,这里来讲解skiplist的结构实现。
skiplist
也叫做 ,跳跃表是一种有序的数据结构,它通过每一个节点维持多个指向其它节点的指针,从而达到快速访问的目的。
skiplist由如下几个特点:
-
有很多层组成,由上到下节点数逐渐密集,最上层的节点最稀疏,跨度也最大。
-
每一层都是一个有序链表,只扫包含两个节点,头节点和尾节点。
-
每一层的每一个每一个节点都含有指向同一层下一个节点和下一层同一个位置节点的指针。
-
如果一个节点在某一层出现,那么该以下的所有链表同一个位置都会出现该节点。
具体实现的结构图如下所示:
在跳跃表的结构中有head和tail表示指向头节点和尾节点的指针,能后快速的实现定位。level表示层数,len表示跳跃表的长度,BW表示后退指针,在从尾向前遍历的时候使用。
BW下面还有两个值分别表示分值(score)和成员对象(各个节点保存的成员对象)。
跳跃表的实现中,除了最底层的一层保存的是原始链表的完整数据,上层的节点数会越来越少,并且跨度会越来越大。
跳跃表的上面层就相当于索引层,都是为了找到最后的数据而服务的,数据量越大,条表所体现的查询的效率就越高,和平衡树的查询效率相差无几。
应用场景
因为ZSet是有序的集合,因此ZSet在实现排序类型的业务是比较常见的,比如在首页推荐10个最热门的帖子,也就是阅读量由高到低,排行榜的实现等业务。
下面就选用获取排行榜前前10名的选手作为案例实现,实现的代码如下所示:
@Autowired
private RedisTemplate redisTemplate;
/**
* 获取前10排名
* @return
*/
public static List<levelVO > getZset(String key, long baseNum, LevelService levelService){
ZSetOperations<Serializable, Object> operations = redisTemplate.opsForZSet();
// 根据score分数值获取前10名的数据
Set<ZSetOperations.TypedTuple<Object>> set = operations.reverseRangeWithScores(key,0,9);
List<LevelVO> list= new ArrayList<LevelVO>();
int i=1;
for (ZSetOperations.TypedTuple<Object> 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中有配置参数 maxmemory
可以 。
在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提供了 ,其中默认的是 noeviction
,这6中淘汰策略如下:
-
noeviction
( ):若是内存的大小达到阀值的时候,所有申请内存的指令都会报错。 -
allkeys-lru
:所有key都是使用 进行淘汰。 -
volatile-lru
:所有 进行淘汰。 -
allkeys-random
:所有的key使用 的方式进行淘汰。 -
volatile-random
:所有 的方式进行淘汰。 -
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(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(Least Frequently Used)
即表示最近频繁被使用,也就是最近的时间段内,频繁被访问的key,它以最近的时间段的被访问次数的频率作为一种判断标准。
它的核心思想就是:根据key最近被访问的频率进行淘汰,比较少被访问的key优先淘汰,反之则优先保留。
LFU算法反映了一个key的热度情况,不会因为LRU算法的偶尔一次被访问被认为是热点数据。
在LFU算法中支持 volatile-lfu
策略和 allkeys-lfu
策略。
以上介绍了Redis的6种淘汰策略,这6种淘汰策略旨在告诉我们怎么做,但是什么时候做?这个还没说,下面我们就来详细的了解Redis什么时候执行淘汰策略。
删除过期键策略
在Redis种有三种删除的操作此策略,分别是:
-
定时删除:创建一个定时器,定时的执行对key的删除操作。
-
惰性删除:每次只有再访问key的时候,才会检查key的过期时间,若是已经过期了就执行删除。
-
定期删除:每隔一段时间,就会检查删除掉过期的key。
定时删除对于 ,定时清理出干净的空间,但是对于 ,程序需要维护一个定时器,这就会占用cpu资源。
惰性的删除对于 ,cpu不需要维护其它额外的操作,但是对于 ,因为要是有些key一直没有被访问到,就会一直占用着内存。
定期删除是上面两种方案的折中方案**,每隔一段时间删除过期的key,也就是根据具体的业务,合理的取一个时间定期的删除key**。
通过 来删除key,减 ,使删除操作合理化。
RDB和AOF 的淘汰处理
在Redis中持久化的方式有两种 RDB
和 AOF
,具体这两种详细的持久化介绍,可以参考这一篇文章[]。
在RDB中是以快照的形式获取内存中某一时间点的数据副本,在创建RDB文件的时候可以通过 save
和 bgsave
命令执行创建RDB文件。
这两个命令都不会把过期的key保存到RDB文件中,这样也能达到删除过期key的效果。
当在启动Redis载入RDB文件的时候, Master
不会把过期的key载入,而 Slave
会把过期的key载入。
在AOF模式下,Redis提供了Rewite的优化措施,执行的命令分别是 REWRITEAOF
和 BGREWRITEAOF
, 这两个命令都不会把过期的key写入到AOF文件中,也能删除过期key 。
Redis缓存三大问题
前言
日常的开发中,无不都是使用数据库来进行数据的存储,由于一般的系统任务中通常不会存在高并发的情况,所以这样看起来并没有什么问题。
一旦涉及大数据量的需求,如一些 的情景,或者 瞬间较大的时候,单一使用数据库来保存数据的系统会因为 , 速度问题有严重的性能弊端,详细的 请参考这一片[]。
在这一瞬间成千上万的请求到来,需要系统在 内完成成 次的 ,这个时候往往不是数据库能够承受的,极其容易造成数据库系统瘫痪,最终导致服务宕机的严重生产问题。
为了克服上述的问题,项目通常会引入 技术,这是一种 的 ,并且提供一定的 功能。
Redis
技术就是 NoSQL
技术中的一种。 Redis
缓存的使用,极大的提升了应用程序的性能和效率,特别是 方面。
但同时,它也带来了一些问题。其中,最要害的问题,就是数据的一致性问题,从严格意义上讲,这个问题无解。如果对 要求很高,那么就不能使用 。
另外的一些典型问题就是, 、 和 。本篇文章从实际代码操作,来提出解决这三个缓存问题的方案,毕竟Redis的缓存问题是实际面试中高频问点,理论和实操要兼得。
缓存穿透
缓存穿透是指查询一条数据库和缓存都没有的一条数据,就会一直查询数据库,对数据库的访问压力就会增大,缓存穿透的解决方案,有以下两种:
-
缓存空对象:代码维护较简单,但是效果不好。
-
布隆过滤器:代码维护复杂,效果很好。
缓存空对象
缓存空对象是指当一个请求过来缓存中和数据库中都不存在该请求的数据,第一次请求就会跳过缓存进行数据库的访问,并且访问数据库后返回为空,此时也将该空对象进行缓存。
若是再次进行访问该空对象的时候,就会直接 ,而不是再次 ,缓存空对象实现的原理图如下:
缓存空对象的实现代码如下:
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);
布隆过滤器
布隆过滤器是一种基于 的 ,主要用来判断某个元素是否在集合内,它具有 (时间效率), 的优点(空间效率),但是有一定的 和 的问题。它只能告诉你某个元素一定不在集合内或可能在集合内。
在计算机科学中有一种思想: 。一般两者是不可兼得,而布隆过滤器运行效率和空间大小都兼得,它是怎么做到的呢?
在布隆过滤器中引用了一个 的概念,即它可能会把不属于这个集合的元素认为可能属于这个集合,但是不会把属于这个集合的认为不属于这个集合,布隆过滤器的特点如下:
-
一个非常大 (数组里只有0和1)
-
若干个
-
空间效率和
-
不存在 (False Negative):某个元素在某个集合中,肯定能报出来。
-
可能存在 (False Positive):某个元素不在某个集合中,可能也被爆出来。
-
不提供删除方法,代码维护困难。
-
位数组初始化都为0,它不存元素的具体值,当元素经过哈希函数哈希后的值(也就是数组下标)对应的数组位置值改为1。
实际布隆过滤器存储数据和查询数据的原理图如下:
可能很多读者看完上面的特点和原理图,还是看不懂,别急下面通过图解一步一步的讲解布隆过滤器,总而言之一句简单的话概括就是布隆过滤器是一个 的 ,数组里面 。
初始化的布隆过滤器的结构图如下:
以上只是画了布隆过滤器的很小很小的一部分,实际布隆过滤器是非常大的数组(这里的大是指它的 ,并不是指它所占的 )。
那么一个数据是怎么存进布隆过滤器的呢?
当一个数据进行存入布隆过滤器的时候,会经过如干个哈希函数进行哈希(若是对哈希函数还不懂的请参考这一片[]),得到对应的哈希值作为数组的下标,然后将初始化的位数组对应的下标的值修改为1,结果图如下:
当再次进行存入第二个值的时候,修改后的结果的原理图如下:
所以每次存入一个数据,就会哈希函数的计算,计算的结果就会作为下标,在布隆过滤器中有多少个哈希函数就会计算出多少个下标,布隆过滤器插入的流程如下:
-
将要添加的元素给m个哈希函数
-
得到对应于位数组上的m个位置
-
将这m个位置设为1
那么为什么会有误判率呢?
假设在我们多次存入值后,在布隆过滤器中存在x、y、z这三个值,布隆过滤器的存储结构图如下所示:
当我们要查询的时候,比如查询a这个数,实际中a这个数是不存在布隆过滤器中的,经过2哥哈希函数计算后得到a的哈希值分别为2和13,结构原理图如下:
经过查询后,发现2和13位置所存储的值都为1,但是2和13的下标分别是x和z经过计算后的下标位置的修改,该布隆过滤器中实际不存在a,那么布隆过滤器就会误判改值可能存在,因为布隆过滤器不存 ,所以存在 。
那么具体布隆过布隆过滤的判断的准确率和一下 有关:
-
布隆过滤器大小:越大,误判率就越小,所以说布隆过滤器一般长度都是非常大的。
-
哈希函数的个数:哈希函数的个数越多,那么误判率就越小。
那么为什么不能删除元素呢?
原因很简单,因为删除元素后,将对应元素的下标设置为零,可能别的元素的下标也引用改下标,这样别的元素的判断就会收到影响,原理图如下:
当你删除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
,只需要在项目中引入一下依赖:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>27.0.1-jre</version>
</dependency>
实际项目中具体的操作代码如下:
public static void MyBloomFilterSysConfig {
@Autowired
OrderMapper orderMapper
// 1.创建布隆过滤器 第二个参数为预期数据量10000000,第三个参数为错误率0.00001
BloomFilter<CharSequence> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.forName("utf-8")),10000000, 0.00001);
// 2.获取所有的订单,并将订单的id放进布隆过滤器里面
List<Order> orderList = orderMapper.findAll()
for (Order order;orderList ) {
Long id = order.getId();
bloomFilter.put("" + id);
}
}
在实际项目中会启动一个 或者 ,来初始化布隆过滤器,将热点查询数据的id放进布隆过滤器里面,当用户再次请求的时候,使用布隆过滤器进行判断,改订单的id是否在布隆过滤器中存在,不存在直接返回null,具体操作代码:
// 判断订单id是否在布隆过滤器中存在
bloomFilter.mightContain("" + id)
布隆过滤器的缺点就是要维持容器中的数据,因为订单数据肯定是频繁变化的,实时的要更新布隆过滤器中的数据为最新。
缓存击穿
缓存击穿是指一个 key
非常热点,在不停的扛着大并发, 集中对这一个点进行访问,当这个key在失效的瞬间,持续的 就穿破缓存,直接请求数据库,瞬间对数据库的访问压力增大。
缓存击穿这里强调的是 ,造成缓存击穿的原因有以下两个:
-
该数据没有人查询过 ,第一次就大并发的访问。(冷门数据)
-
添加到了缓存,reids有设置数据失效的时间 ,这条数据刚好失效,大并发访问(热点数据)
对于缓存击穿的解决方案就是加锁,具体实现的原理图如下:
当用户出现 访问的时候,在查询缓存的时候和查询数据库的过程加锁,只能第一个进来的请求进行执行,当第一个请求把该数据放进缓存中,接下来的访问就会直接集中缓存,防止了 。
业界比价普遍的一种做法,即根据key获取value值为空时,锁上,从数据库中 load
数据后再释放锁。若其它线程获取锁失败,则等待一段时间后重试。这里要注意,分布式环境中要使用 , 的话用普通的锁( synchronized
、 Lock
)就够了。
下面以一个获取商品库存的案例进行代码的演示, 的锁实现具体实现的代码如下:
// 获取库存数量
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";
}
缓存雪崩
缓存雪崩 是指在某一个时间段,缓存集中过期失效。此刻无数的请求直接绕开缓存,直接请求数据库。
造成缓存雪崩的原因,有以下两种:
-
reids宕机
-
大部分数据失效
比如天猫双11,马上就要到双11零点,很快就会迎来一波抢购,这波商品在23点集中的放入了缓存,假设缓存一个小时,那么到了凌晨24点的时候,这批商品的缓存就都过期了。
而对这批商品的访问查询,都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰,对数据库造成压力,甚至压垮数据库。
缓存雪崩的原理图如下,当正常的情况下,key没有大量失效的用户访问原理图如下:
当某一时间点,key大量失效,造成的缓存雪崩的原理图如下:
对于缓存雪崩的解决方案有以下两种:
-
搭建高可用的集群,防止单机的redis宕机。
-
设置不同的过期时间,防止同意之间内大量的key失效。
“
针对业务系统,永远都是具体情况具体分析,没有最好,只有最合适。于缓存其它问题,缓存满了和数据丢失等问题,我们后面继续深入的学习。最后也提一下三个词LRU、RDB、AOF,通常我们采用LRU策略处理溢出,Redis的RDB和AOF持久化策略来保证一定情况下的数据安全。
redis持久化
本文脑图
Redis
是一个基于内存的非关系型的数据库,数据保存在内存中,但是内存中的数据也容易发生丢失。这里Redis就为我们提供了持久化的机制,分别是 RDB(Redis DataBase)
和 AOF(Append Only File)
。
Redis在以前的版本中是单线程的,而在6.0后对Redis的io模型做了优化, io Thread
为多线程的,但是 worker Thread
仍然是单线程。
在Redis启动的时候就会去加载持久化的文件,如果没有就直接启动,在启动后的某一时刻由继续持久化内存中产生的数据。
接下来我们就来详细了解Redis的两种持久化机制 RDB(Redis DataBase)
和 AOF(Append Only File)
。
RDB持久化机制
什么是RDB持久化呢?RDB持久化就是将当前进程的数据以生成快照的形式持久化到磁盘中。对于快照的理解,我们可以理解为将当前线程的数据以拍照的形式保存下来。
RDB持久化的时候会单独fork一个与当前进程一摸一样的子进程来进行持久化,因此RDB持久化有如下特点:
-
开机恢复数据快。
-
写入持久化文件快。
RDB的持久化也是Redis默认的持久化机制,它会把内存中的数据以快照的形式写入默认文件名为 dump.rdb
中保存。
在安装后的Redis中,Redis的配置都在 redis.conf
文件中,如下图所示, dbfilename
就是配置RDB的持久化文件名。
在这里插入图片描述
持久化触发时机
在RDB机制中触发内存中的数据进行持久化,有以下三种方式:
(1)save命令:
save命令不会fork子进程,通过阻塞当前Redis服务器,直到RDB完成为止,所以该命令在生产中一般不会使用。save命令执行原理图如下:
在这里插入图片描述
在redis.conf的配置中 dir
的配置就是RDB持久化后生成rdb二进制文件所在的位置,默认的位置是 ./
,表示当前位置,哪里启动redis,就会在哪里生成持久化文件,如下图所示:
在这里插入图片描述
下面我们进行一下实操,演示一下二进制文件生成的过程,在我本机的电脑虚拟机中,我所在的位置如下,该文件夹是新创建的redis的数据存储文件夹。
在这里插入图片描述
然后我们直接在该位置启动我们的Redis服务,启动的命令如下:
/root/redis-4.0.6/src/redis-server /root/redis-4.0.6/redis.conf
接着通过该命令: ps -aux | grep redis
,查看我们的redis服务是否正常启动,若是显示如下图所示,则表示Redis是正常启动的:
在这里插入图片描述
正常启动后,直接登陆Redis,可以通过以下命令登陆Redis,如下图所示:
在这里插入图片描述
因为当前中Redis是新安装的,数据都是为空,什么都没有,然后通过下图的命令随意向Redis中输入几条命令,最后执行 save
命令,在该文件夹下就会出现 dump.rdb
持久化的数据文件。
在这里插入图片描述
当然上面说到,在新安装的Redis中默认的RDB数据持久化位置为 ./
文件,一般我们会把它改成服务器自己的特定位置下,原理都是一样的,可以自己进行尝试,这里不再进行演示。
(2)bgsave命令:
bgsave
命令会在后台fork一个与Redis主线程一摸一样的子线程,由子线程负责内存中的数据持久化。
这样fork与主线程一样的子线程消耗了内存,但是不会阻塞主线程处理客户端请求,是以空间换时间的方式快照内存中的数据到到文件中。
bgsave
命令阻塞只会发生在fork子线程的时候,这段时间发生的非常短,可以忽略不计,如下图是 bgsave执行的流程图:
在这里插入图片描述
上面说到redis.conf中的 dir
配置是配置持久化文件生成的指定的目录, dbfilename
是配置生成的文件名,也可以通过命令行使用命令来动态的设置这两个配置,命令如下:
config set dir{newDir}
config set dbfilename{newFileName}
(3)自动化
除了上面在命令行使用save和bgsave命令触发持久化,也可以在 redis.conf
配置文件中,完成配置,如下图所示:
在这里插入图片描述
在新安装的redis中由默认的以上三个save配置, save 900 1
表示900秒内如果至少有1个key值变化,则进行持久化保存数据;
save 300 10
则表示300秒内如果至少有10个key值发生变化,则进行持久化, save 60 10000
以此类推。
通过以上的分析可以得出以下save和bgsave的对比区别:
-
save是 持久化数据,而bgsave是 持久化数据。
-
save
不会fork子进程,通过 持久化数据,会 处理客户端的请求,而bdsave
会fork
子进程持久化数据,同时还可以处理客户端请求,高效。 -
save ,而bgsave 。
RDB的优缺点
缺点:RDB持久化后的文件是紧凑的二进制文件,适合于备份、全量复制、大规模数据恢复的场景,对数据完整性和一致性要求不高,RDB会丢失最后一次快照的数据。
优点:开机的恢复数据快,写入持久化文件快。
AOF持久化机制
AOF持久化机制是以日志的形式记录Redis中的每一次的增删改操作,不会记录查询操作,以文本的形式记录,打开记录的日志文件就可以查看操作记录。
AOF是默认不开启的,若是像开启AOF,在如下图的配置修改即可:
在这里插入图片描述
只需要把 appendonly no
修改为 appendonly yes
即可开启,在AOF中通过 appendfilename
配置生成的文件名,该文件名默认为 appendonly.aof
,路径也是通过dir配置的,这个于RDB的一样,具体的配置信息如下图所示:
在这里插入图片描述
AOF触发机制
AOF带来的持久化更加安全可靠,默认提供 触发机制,如下所示:
-
no
:表示等操作系统等数据缓存同步到磁盘中(快、持久化没保证)。 -
always
:同步持久化,每次发生数据变更时,就会立即记录到磁盘中(慢,安全)。 -
everysec
:表示每秒同步一次(默认值,很快,但是会丢失一秒内的数据)。
AOF中每秒同步也是异步完成的, 的,由于该机制对日志文件的写入操作是采用 append
的形式。
因此在写入的过程即使宕机,也不会丢失已经存入日志文件的数据,数据的完整性是非常高的。