redis
前言
Redis是基于c语言编写的开源非关系型内存数据库,可以用作数据库、缓存、消息中间件。
Redis(全称:Remote Dictionary Server,即远程字典服务器)是一个开源的高性能键值数据库和缓存系统。Redis 的数据结构支持字符串、哈希表、列表、集合和有序集合等类型。同时,Redis 还提供了丰富的操作指令,例如 GET/SET、INCR/DECR、HGET/HSET、LPUSH/RPUSH、SADD/SMEMBERS、ZADD/ZRANGE 等。除此之外,Redis 还支持事务、过期时间、发布/订阅等特性,能够方便地实现各种高效的数据存储和读取方案。
redis基本数据结构
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语言开发的。但是Redis中的字符串和c语言中的字符串类型却是有明显的区别。
String类型的数据结构存储方式有三种int、raw、embstr。那么这三种存储方式有什么区别呢?
int
Redis中规定假如存储的是整数型值,比如set num 123这样的类型,就会使用 int的存储方式进行存储,在redisObject的ptr属性中就会保存该值。
SDS(embstr->raw)
假如存储的字符串是一个字符串值并且长度大于44个字节就会使用SDS(simple dynamic string)方式进行存储,并且encoding设置为raw;若是字符串长度小于等于44个字节就会将encoding改为embstr来保存字符串。
(3.2之前是39字节)
SDS称为简单动态字符串,对于SDS中的定义在Redis的源码中有的三个属性int len、int free、char buf[]。
len保存了字符串的长度,free表示buf数组中未使用的字节数量,buf数组则是保存字符串的每一个字符元素。
因此当你在Redsi中存储一个字符串Hello时,根据Redis的源代码的描述可以画出SDS的形式的redisObject结构图如下图所示:
SDS还提供空间预分配和惰性空间释放两种策略。在为字符串分配空间时,分配的空间比实际要多,这样就能减少连续的执行字符串增长带来内存重新分配的次数。
当字符串被缩短的时候,SDS也不会立即回收不适用的空间,而是通过free属性将不使用的空间记录下来,等后面使用的时候再释放。
具体的空间预分配原则是:当修改字符串后的长度len小于1MB,就会预分配和len一样长度的空间,即len=free;若是len大于1MB,free分配的空间大小就为1MB。
SDS是二进制安全的,除了可以储存字符串以外还可以储存二进制文件(如图片、音频,视频等文件的二进制数据);而c语言中的字符串是以空字符串作为结束符,一些图片中含有结束符,因此不是二进制安全的。
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的形式进行存储。
在redis.conf的默认配置为:
当hash对象同时满足以下两个条件的时候,使用ziplist编码否则使用hashtable:
a、所有的键值对的键和值的字符串长度都不超过64byte(一个英文字母一个字节);
b、哈希对象保存的键值对数量不超过512个。
字典类型的底层就是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操作完成。
ziplist
压缩列表(ziplist)是一组连续内存块组成的顺序的数据结构,压缩列表能够节省空间,压缩列表中使用多个节点来存储数据。
压缩列表是列表键和哈希键底层实现的原理之一,压缩列表并不是以某种压缩算法进行压缩存储数据,而是它表示一组连续的内存空间的使用,节省空间,压缩列表的内存结构图如下:
压缩列表中每一个节点表示的含义如下所示:
zlbytes:4个字节的大小,记录压缩列表占用内存的字节数。zltail:4个字节大小,记录表尾节点距离起始地址的偏移量,用于快速定位到尾节点的地址。zllen:2个字节的大小,记录压缩列表中的节点数。entry:表示列表中的每一个节点。zlend:表示压缩列表的特殊结束符号'0xFF'。
在压缩列表中每一个entry节点又有三部分组成,包括previous_entry_length、encoding、content。
previous_entry_length表示前一个节点entry的长度,可用于计算前一个节点的真实地址,因为他们的地址是连续的。encoding:这里保存的是content的内容类型和长度。
content:content保存的是每一个节点的内容。
hash的应用场景
存储用户数据(ht)
第一个场景比如我们要储存用户信息,一般使用用户的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));
}
}UUID
算法的核心思想是结合机器的网卡、当地时间、一个随记数来生成UUID。
- 优点:本地生成,生成简单,性能好,没有高可用风险
- 缺点:长度过长,存储冗余,且无序不可读,查询效率低
Redis生成方案
利用redis的incr原子性操作自增,一般算法为: 年份 + 当天距当年第多少天 + 天数 + 小时 + redis自增
优点:
- 有序递增,可读性强
缺点:
- 占用带宽,每次要向redis进行请求
- 存在单点问题,如果集群部署,复杂而如果单单只为了生成ID,得不偿失
算法可以调整为 就一个 redis自增,不需要什么年份,多少天等。
List类型
Redis中的列表在3.2之前的版本是使用ziplist和linkedlist进行实现的。在3.2之后的版本就是引入了quicklist替代了ziplist+linkedlist,不需要再进行编码转换。
在redis.conf的默认配置为:
列表类型 (List) 是⼀个使用线性结构存储的结构,它的元素插入会按照先后顺序存储到链表结构中。 列表类型的底层数据结构可以是压缩列表(ZipList)或者链表(LinkedList)
同时满足下述两个条件使用ziplist,否则使用链表
1.当列表对象的所有字符串元素长度都不超过64字节
2.保存的元素数量不超过512个
ziplist压缩列表上面已经讲过了,我们来看看linkedlist和quicklist的结构是怎么样的。
linkedlist是一个双向链表,他和普通的链表一样都是由指向前后节点的指针。插入、修改、更新的时间复杂度尾O(1),但是查询的时间复杂度确实O(n)。
linkedlist和quicklist的底层实现是采用链表进行实现,在c语言中并没有内置的链表这种数据结构,Redis实现了自己的链表结构。
Redis中链表的特性:
- 每一个节点都有指向前一个节点和后一个节点的指针。
- 头节点和尾节点的prev和next指针指向为null,所以链表是无环的。
- 链表有自己长度的信息,获取长度的时间复杂度为O(1)。
应用:消息队列
Set集合
Redis中列表和集合都可以用来存储字符串,但是Set是不可重复的集合,而List列表可以存储相同的字符串,Set集合是无序的这个和后面讲的ZSet有序集合相对。
Set的底层实现是ht和intset。
同时满足以下两个条件使用intset,否则使用ht:
1.所有的元素均为整数;
2.元素个数不超过512。
ht(哈希表)前面已经详细了解过,下面我们来看看inset类型的存储结构。
inset也叫做整数集合,用于保存整数值的数据结构类型,它可以保存int16_t、int32_t 或者int64_t 的整数值。
在整数集合中,有三个属性值encoding、length、contents[],分别表示编码方式、整数集合的长度、以及元素内容,length就是记录contents里面的大小。
在整数集合新增元素的时候,若是超出了原集合的长度大小,就会对集合进行升级,具体的升级过程如下:
首先扩展底层数组的大小,并且数组的类型为新元素的类型。
然后将原来的数组中的元素转为新元素的类型,并放到扩展后数组对应的位置。
整数集合升级后就不会再降级,编码会一直保持升级后的状态。
SRANDMEMBER key [count] 返回集合中一个或多个随机数
SINTER key1 [key2] 返回给定所有集合的交集
应用场景
Set集合的应用场景可以用来去重、抽奖、共同好友等业务类型。
ZSet集合
ZSet是有序集合,从上面的图中可以看到ZSet的底层实现是ziplist和skiplist实现的,ziplist上面已经详细讲过,这里来讲解skiplist的结构实现。
在redis.conf的默认配置为:
同时满足以下两个条件使用ziplist,否则使用skiplist:
1.有序集合保存的元素数量不超过128个
2.有序集合保存的所有元素的长度不超过64字节
skiplist也叫做跳跃表,跳跃表是一种有序的数据结构,它通过每一个节点维持多个指向其它节点的指针,从而达到快速访问的目的。
skiplist由如下几个特点:
有很多层组成,由上到下节点数逐渐密集,最上层的节点最稀疏,跨度也最大。
每一层都是一个有序链表,只扫包含两个节点,头节点和尾节点。
每一层的每一个每一个节点都含有指向同一层下一个节点和下一层同一个位置节点的指针。
如果一个节点在某一层出现,那么该以下的所有链表同一个位置都会出现该节点。
具体实现的结构图如下所示:
在跳跃表的结构中有head和tail表示指向头节点和尾节点的指针,能后快速的实现定位。level表示层数,len表示跳跃表的长度,BW表示后退指针,在从尾向前遍历的时候使用。
BW下面还有两个值分别表示分值(score)和成员对象(各个节点保存的成员对象)。
跳跃表的实现中,除了最底层的一层保存的是原始链表的完整数据,上层的节点数会越来越少,并且跨度会越来越大。
跳跃表的上面层就相当于索引层,都是为了找到最后的数据而服务的,数据量越大,跳表所体现的查询的效率就越高,和平衡树的查询效率相差无几。
| ZREVRANGE key start stop [WITHSCORES] 返回有序集中指定区间内的成员,通过索引,分数从高到低 | 按照PV降序获取页面 ZREVRANGE pv_zset 0 -1 |
应用场景
因为ZSet是有序的集合(每个元素都带有score(权重),以此来对元素进行排序),因此ZSet在实现排序类型的业务是比较常见的,比如在首页推荐10个最热门的帖子,也就是阅读量由高到低,排行榜的实现等业务。
Redis内存管理
假如你的Redis内存满了怎么办?在Redis中有配置参数maxmemory可以设置Redis内存的大小。
倘若实际的存储中超出了Redis的配置参数的大小时,Redis中有淘汰策略,把需要淘汰的key给淘汰掉,整理出干净的一块内存给新的key值使用。
接下来我们就详细的聊一聊Redis中的淘汰策略,并且深入的理解每个淘汰策略的原理和应用的场景。
淘汰策略
Redis提供了8种的淘汰策略,其中默认的是noeviction,这6中淘汰策略如下:
noeviction(默认策略):若是内存的大小达到阀值的时候,所有申请内存的指令都会报错。allkeys-lru:所有key都是使用LRU算法进行淘汰。volatile-lru:所有设置了过期时间的key使用LRU算法进行淘汰。- volatile-lfu:在设置了过期时间的键空间中,将访问频率最少的键值对淘汰
- allkeys-lfu:在所有主键空间中,将访问频率最少的键值对淘汰
allkeys-random:所有的key使用随机淘汰的方式进行淘汰。volatile-random:所有设置了过期时间的key使用随机淘汰的方式进行淘汰。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
在介绍8种的淘汰策略方式的时候,说到了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算法有如下弊端:
1.假如一个key值在以前都没有被访问到,然而最近一次被访问到了,那么就会认为它是热点数据,不会被淘汰;
2.有些数据以前经常被访问到,只是最近的时间内没有被访问到,这样就导致这些数据很可能被淘汰掉,这样一来就会出现误判而淘汰热点数据。
于是在Redis 4.0的时候除了LRU算法,新加了一种LFU算法,那么什么是LFU算法呢?
LFU算法
LFU(Least Frequently Used)即表示最近频繁被使用,也就是最近的时间段内,频繁被访问的key,它以最近的时间段的被访问次数的频率作为一种判断标准。
它的核心思想就是:根据key最近被访问的频率进行淘汰,比较少被访问的key优先淘汰,反之则优先保留。
LFU算法反映了一个key的热度情况,不会因为LRU算法的偶尔一次被访问被认为是热点数据。
在LFU算法中支持volatile-lfu策略和allkeys-lfu策略。
以上介绍了Redis的8种淘汰策略,这8种淘汰策略旨在告诉我们怎么做,但是什么时候做?这个还没说,下面我们就来详细的了解Redis什么时候执行淘汰策略。
删除过期键策略
在Redis中有三种删除过期键策略,分别是:
定时删除:创建一个定时器,定时的执行对key的删除操作。
惰性删除:每次只有再访问key的时候,才会检查key的过期时间,若是已经过期了就执行删除。
定期删除:每隔一段时间,就会检查删除掉过期的key。
定时删除对于内存来说是友好的,定时清理出干净的空间,但是对于cpu来说并不是友好的,程序需要维护一个定时器,这就会占用cpu资源。
惰性的删除对于cpu来说是友好的,cpu不需要维护其它额外的操作,但是对于内存来说是不友好的,因为要是有些key一直没有被访问到,就会一直占用着内存。
定期删除是上面两种方案的折中方案**,每隔一段时间删除过期的key,也就是根据具体的业务,合理的取一个时间定期的删除key**。
通过最合理控制删除的时间间隔来删除key,减少对cpu的资源的占用消耗,使删除操作合理化。
RDB和AOF 的淘汰处理
在Redis中持久化的方式有两种RDB和AOF。
在RDB中是以快照的形式获取内存中某一时间点的数据副本,在创建RDB文件的时候可以通过save和bgsave命令执行创建RDB文件。这两个命令都不会把过期的key保存到RDB文件中,这样也能达到删除过期key的效果。
当在启动Redis载入RDB文件的时候,Master不会把过期的key载入,而Slave会把过期的key载入。
在AOF模式下,Redis提供了Rewrite的优化措施,执行的命令分别是REWRITEAOF和BGREWRITEAOF,这两个命令都不会把过期的key写入到AOF文件中,也能删除过期key。
Redis缓存三大问题
日常的开发中,无不都是使用数据库来进行数据的存储,由于一般的系统任务中通常不会存在高并发的情况,所以这样看起来并没有什么问题。
一旦涉及大数据量的需求,如一些商品抢购的情景,或者主页访问量瞬间较大的时候,单一使用数据库来保存数据的系统会因为面向磁盘,磁盘读/写速度问题有严重的性能弊端,详细的磁盘读写原理请参考这一片[]。
在这一瞬间成千上万的请求到来,需要系统在极短的时间内完成成千上万次的读/写操作,这个时候往往不是数据库能够承受的,极其容易造成数据库系统瘫痪,最终导致服务宕机的严重生产问题。
为了克服上述的问题,项目通常会引入NoSQL技术,这是一种基于内存的数据库,并且提供一定的持久化功能。
Redis技术就是NoSQL技术中的一种。Redis缓存的使用,极大的提升了应用程序的性能和效率,特别是数据查询方面。
但同时,它也带来了一些问题。其中,最要害的问题,就是数据的一致性问题,从严格意义上讲,这个问题无解。如果对数据的一致性要求很高,那么就不能使用缓存。
另外的一些典型问题就是,缓存击穿、缓存穿透和缓存雪崩。
缓存击穿
缓存击穿是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,瞬间对数据库的访问压力增大。
缓存击穿这里强调的是并发,造成缓存击穿的原因有以下两个:
该数据没有人查询过 ,第一次就大并发的访问。(冷门数据)
添加到了缓存,reids有设置数据失效的时间 ,这条数据刚好失效,大并发访问(热点数据)
对于缓存击穿的解决方案就是加锁,具体实现的原理图如下:
当用户出现大并发访问的时候,在查询缓存的时候和查询数据库的过程加锁,只能第一个进来的请求进行执行,当第一个请求把该数据放进缓存中,接下来的访问就会直接击中缓存,防止了缓存击穿。
业界比价普遍的一种做法,即根据key获取value值为空时,锁上,从数据库中load数据后再释放锁。若其它线程获取锁失败,则等待一段时间后重试。这里要注意,分布式环境中要使用分布式锁,单机的话用普通的锁(synchronized、Lock)就够了。
利用redis本身实现:
//分布式的锁实现具体实现的代码如下:
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";
}缓存穿透
缓存穿透是指查询一条数据库和缓存都没有的一条数据,就会一直查询数据库,对数据库的访问压力就会增大,缓存穿透的解决方案,有以下两种,推荐使用布隆过滤器:
缓存空对象:代码维护较简单,但是效果不好。
布隆过滤器:代码维护复杂,效果很好。
缓存空对象
缓存空对象是指当一个请求过来缓存中和数据库中都不存在该请求的数据,第一次请求就会跳过缓存进行数据库的访问,并且访问数据库后返回为空,此时也将该空对象进行缓存。
若是再次进行访问该空对象的时候,就会直接击中缓存,而不是再次数据库:
//缓存空对象的实现代码如下:
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());
//缓存空对象的实现代码很简单,但是缓存空对象会带来比较大的问题,就是缓存中会存在很多空对象,占用内存的空间,浪费资源,一个解决的办法就是设置空对象的较短的过期时间:
// 再缓存的时候,添加多一个该空对象的过期时间60秒
redisCache.put(Integer.toString(id), new NullValueResultDO(),60);
}
return user;
}
}
}布隆过滤器
布隆过滤器是一种基于概率的数据结构,主要用来判断某个元素是否在集合内,它具有运行速度快(时间效率),占用内存小的优点(空间效率),但是有一定的误识别率和删除困难的问题。它只能告诉你某个元素一定不在集合内或可能在集合内。
在计算机科学中有一种思想:空间换时间,时间换空间。一般两者是不可兼得,而布隆过滤器运行效率和空间大小都兼得,它是怎么做到的呢?
在布隆过滤器中引用了一个误判率的概念,即它可能会把不属于这个集合的元素认为可能属于这个集合,但是不会把属于这个集合的认为不属于这个集合,布隆过滤器的特点如下:
一个非常大的二进制位数组 (数组里只有0和1)
若干个哈希函数
空间效率和查询效率高
不存在漏报(False Negative):某个元素在某个集合中,肯定能报出来。
可能存在误报(False Positive):某个元素不在某个集合中,可能也被爆出来。
不提供删除方法,代码维护困难。
位数组初始化都为0,它不存元素的具体值,当元素经过哈希函数哈希后的值(也就是数组下标)对应的数组位置值改为1。
实际布隆过滤器存储数据和查询数据的原理图如下:
可能很多读者看完上面的特点和原理图,还是看不懂,别急下面通过图解一步一步的讲解布隆过滤器,总而言之一句简单的话概括就是布隆过滤器是一个很大二进制的位数组,数组里面只存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元素的下标受到影响,导致数据的判断不准确.
布隆过滤器的缺点就是要维持容器中的数据,实时的要更新布隆过滤器中的数据为最新。
<!--引入谷歌guava依赖-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.0.1-jre</version>
</dependency>//创建一个测试类,存入100w个数据到布隆过滤器,同时用10w个不存在的数据测试误判率。
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.math.BigDecimal;
@SpringBootTest
class RetailUserApplicationTests {
@Test
void contextLoads() {
this.BloomTest();
}
public void BloomTest() {
// 开始时间
long startTime = System.currentTimeMillis();
// 初始化误判个数
BigDecimal count = new BigDecimal("0");
// 相当于一个常量
BigDecimal one = new BigDecimal("1");
// 测试的10W个数据 也是常量 用于计算误判率
BigDecimal testCount = new BigDecimal("100000");
// 百分比换算,还是常量
BigDecimal mult = new BigDecimal("100");
// 第一个参数为数据类型,第二个数组长度,第三个误判率
BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 1000000L, 0.01);
// 插入100w个数据
for (int i = 1; i <= 1000000; i++) {
bloomFilter.put(i);
}
// 测试10W个不存在的数据
for (int i = 2000000; i <= 2100000; i++) {
boolean mightContain = bloomFilter.mightContain(i);
if (mightContain) {
count = count.add(one);
}
}
System.out.println("总耗时" + (System.currentTimeMillis() - startTime) + "MS");
System.out.println("误判个数:" + count);
System.out.println("误判率:" + (count.divide(testCount)).multiply(mult) + "%");
}
}但是,误判率并不是设置的越小越好。设置的越小,进行的哈希次数就越多。要取一个适当的值来确定误差值。就和hashmap的负载因子是0.75一样, 为1哈希冲突太大,为0.5冲突是少了,但是空间利用率下降了。
缓存雪崩
缓存雪崩 是指在某一个时间段,缓存集中过期失效。此刻无数的请求直接绕开缓存,直接请求数据库。
造成缓存雪崩的原因,有以下两种:
reids宕机
大部分数据失效
比如天猫双11,马上就要到双11零点,很快就会迎来一波抢购,这波商品在23点集中的放入了缓存,假设缓存一个小时,那么到了凌晨24点的时候,这批商品的缓存就都过期了。
而对这批商品的访问查询,都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰,对数据库造成压力,甚至压垮数据库。
缓存雪崩的原理图如下,
当正常的情况下,key没有大量失效的用户访问原理图如下:
当某一时间点,key大量失效,造成的缓存雪崩的原理图如下:
我们可以在事故前中后三个方面来思考解决方案
- 事故前:redis高可用方案,主从+哨兵,集群方案,避免全盘崩溃
- 事故中:较少数据库的压力,本地Ehcache缓存+限流及降级,避免超过数据库承受压力
- 事故后:做redis持久化,一旦Redis重启,可从磁盘中快速恢复数据
针对大部分的数据失效,可采用过期时间随机,不要集中过期。
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完成为止,所以该命令在生产中一般不会使用。
在redis.conf的配置中dir的配置就是RDB持久化后生成rdb二进制文件所在的位置,默认的位置是./,表示当前位置,哪里启动redis,就会在哪里生成持久化文件,如下图所示:
(2)bgsave命令:
bgsave命令会在后台fork一个与Redis主线程一摸一样的子线程,由子线程负责内存中的数据持久化。
这样fork与主线程一样的子线程消耗了内存,但是不会阻塞主线程处理客户端请求,是以空间换时间的方式快照内存中的数据到到文件中。
bgsave命令阻塞只会发生在fork子线程的时候,这段时间发生的非常短,可以忽略不计,如下图是 bgsave执行的流程图:
dbfilename是配置生成的文件名,也可以通过命令行使用命令来动态的设置这两个配置,命令如下:
config set dir
config set dbfilename
(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的形式。
因此在写入的过程即使宕机,也不会丢失已经存入日志文件的数据,数据的完整性是非常高的。
在新安装的Redis的配置文件中,AOF的配置如下所示:
AOF重写机制
但是,在写入所有的操作到日志文件中时,就会出现日志文件很多重复的操作,甚至是无效的操作,导致日志文件越来越大。
所谓的无效的的操作,举个例子,比如某一时刻对一个k++,然后后面的某一时刻k--,这样k的值是保持不变的,那么这两次的操作就是无效的。
如果像这样的无效操作很多,记录的文件臃肿,就浪费了资源空间,所以在Redis中出现了rewrite机制。
redis提供了bgrewriteaof命令。将内存中的数据以命令的方式保存到临时文件中,同时会fork出一条新进程来将文件重写。
重写AOF的日志文件不是读取旧的日志文件瘦身,而是将内存中的数据用命令的方式重写一个AOF文件,重新保存替换原来旧的日志文件,因此内存中的数据才是最新的。
重写操作也会fork一个子进程来处理重写操作,重写以内存中的数据作为重写的源,避免了操作的冗余性,保证了数据的最新。
在Redis以append的形式将修改的数据写入老的磁盘中 ,同时Redis也会创建一个新的文件用于记录此期间有哪些命令被执行。
当AOF的日志文件增长到一定大小的时候Redis就能够bgrewriteaof对日志文件进行重写瘦身。当AOF配置文件大于改配置项时自动开启重写(这里指超过原大小的100%)。
该配置可以通过如下的配置项进行配置:
AOF的优缺点
优点: AOF更好保证数据不会被丢失,最多只丢失一秒内的数据,通过fork一个子进程处理持久化操作,保证了主进程不会阻塞io操作,能高效的处理客户端的请求。
另外重写操作保证了数据的有效性,即使日志文件过大也会进行重写。
AOF的日志文件的记录可读性非常的高,即使某一时刻有人执行flushall清空了所有数据,只需要拿到aof的日志文件,然后把最后一条的flushall给删除掉,就可以恢复数据。
缺点: 对于相同数量的数据集而言,AOF文件通常要大于RDB文件。RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快。AOF在运行效率上往往会慢于RDB。
混合持久化
在redis4.0后混合持久化(RDB+AOF)对重写的优化,4.0版本的混合持久化默认是关闭的,可以通过以下的配置开启混合持久化:
混合持久化也是通过bgrewriteaof来完成的,不同的是当开启混合持久化时,fork出的子进程先将共享内存的数据以RDB方式写入aof文件中,然后再将重写缓冲区的增量命令以AOF方式写入文件中。
写入完成后通知主进程统计信息,并将新的含有RDB格式和AOF格式的AOF文件替换旧的AOF文件。简单的说:新的AOF文件前半段是以RDB格式的全量数据后半段是AOF格式的增量数据。
优点: 混合持久化结合RDB持久化和AOF持久化的优点,由于绝大部分的格式是RDB格式,加载速度快,增量数据以AOF方式保存,数据更少的丢失。
RDB和AOF优势和劣势
rdb适合大规模的数据恢复,由于rdb时异快照的形式持久化数据,恢复的数据快,在一定的时间备份一次,而aof的保证数据更加完整,损失的数据只在秒内。
具体哪种更适合生产,在官方的建议中两种持久化机制同时开启,如果两种机制同时开启,优先使用aof持久化机制。
redis事务
Redis事务是一组命令的集合,将多个命令进行打包,然后这些命令会被顺序的添加到队列中,并且按顺序的执行这些命令。
Redis事务的应用场景
在分布式系统和高并发场景下,事务处理具有重要意义。Redis事务可以确保数据的一致性,避免并发操作导致的数据不一致问题。以下是一些Redis事务的应用场景:
- 批量操作:Redis 事务可以将多个命令打包成一个单元来执行,可以减少与 Redis 服务器的通信次数,从而提高性能。
- 数据库迁移:在迁移数据时,需要保证数据一致性。通过Redis事务,可以确保数据在迁移过程中不会出现不一致的情况。
- 分布式锁:在分布式系统中,为了保证数据的一致性,需要实现分布式锁。通过Redis事务,可以在同一个事务中执行锁定、解锁等操作,确保锁的原子性。
基本操作
这个与Redis的特点:快速、高效有着密切的关联,因为一些列回滚操作、像事务隔离级别那这样加锁、解锁,是非常消耗性能的。所以,Redis中执行事务的流程只需要简单的下面三个步骤:
开始事务(MULTI)
命令入队
执行事务(EXEC)、撤销事务(DISCARD )
在Redis中事务的实现主要是通过如下的命令实现的:
| 命令 | 功能描述 |
|---|---|
| MULTI | 事务开始的命令,执行该命令后,后面执行的对Redis数据类型的操作命令都会顺序的放进队列中,等待执行EXEC命令后队列中的命令才会被执行 |
| DISCARD | 放弃执行队列中的命令,你可以理解为Mysql的回滚操作,并且将当前的状态从事务状态改为非事务状态。 |
| EXEC | 执行该命令后表示顺序执行队列中的命令,执行完后并将结果显示在客户端,将当前状态从事务状态改为非事务状态。若是执行该命令之前有key被执行WATCH命令并且又被其它客户端修改,那么就会放弃执行队列中的所有命令,在客户端显示报错信息,若是没有修改就会执行队列中的所有命令。 |
| WATCH key | 表示指定监视某个key,该命令只能在MULTI命令之前执行,如果监视的key被其他客户端修改,EXEC****将会放弃执行队列中的所有命令 |
| UNWATCH | 取消监视之前通过****WATCH 命令监视的****key,通过执行EXEC 、DISCARD 两个命令之前监视的key也会被取消监视 |
错误处理
在事务执行过程中,可能会遇到命令执行失败的情况。对于错误的处理,Redis采用的策略是:即使某个命令执行失败,事务中的其他命令仍然会继续执行。然而,整个事务的返回结果会包含错误信息,以便客户端了解事务执行过程中发生的错误。
Redis事务的注意事项与局限性
虽然Redis事务具有一定的功能,但在使用过程中需要注意以下事项:
- 无回滚机制
与传统关系型数据库不同,Redis事务不支持回滚(Rollback)。当事务中的某个命令执行失败时,Redis不会回滚已执行的命令。因此,在使用Redis事务时,需要确保事务中的每个命令都能正确执行,以避免数据不一致的问题。
- 事务内的命令不支持条件判断
Redis事务不支持在事务内进行条件判断。这意味着,事务中的所有命令都会被执行,无论前面的命令是否执行成功。这可能导致数据的不一致性。想要解决这个问题,可以使用Lua脚本来实现条件判断。
- 性能影响
由于Redis使用单线程模型来执行事务,因此,在事务执行期间,服务器无法处理其他客户端的请求。这可能对Redis的性能产生影响。为了降低事务对性能的影响,建议将事务中的命令数量控制在一个合理的范围内。
使用Lua脚本优化Redis事务
在某些场景下,Redis事务可能无法满足应用的需求,例如需要在事务中进行条件判断或循环。在这种情况下,可以使用Redis的Lua脚本功能来优化事务。Lua脚本可以在Redis服务器端原子性地执行一系列命令,并支持条件判断和循环,从而提供更强大的事务处理能力。
- Lua脚本与Redis事务的比较
与Redis事务相比,Lua脚本具有以下优势:
- 更强大的逻辑处理能力:Lua脚本支持条件判断、循环等复杂逻辑,而Redis事务只能顺序执行命令。
- 更好的性能:由于Lua脚本在服务器端执行,避免了多次往返通信带来的延迟,因此性能通常优于Redis事务。
- 更高的可维护性:将业务逻辑封装在Lua脚本中,可以提高代码的可读性和可维护性。
然而,使用Lua脚本也有一些局限性:
- 学习成本:使用Lua脚本需要学习Lua语言及其在Redis中的使用方法。
- 脚本管理:当业务逻辑变得复杂时,需要对多个Lua脚本进行维护和管理。
- 脚本执行的限制:为了避免长时间执行的脚本阻塞Redis服务器,Redis对Lua脚本执行时间有一定的限制。如果脚本执行时间过长,可能会被强制终止。
- redis 使用lua脚本的语法
redis 127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"其中 script: 参数是一段 Lua 5.1 脚本程序。脚本不必(也不应该)定义为一个 Lua 函数。 numkeys: 用于指定键名参数的个数。 key [key …]: 从 EVAL 的第三个参数开始算起,表示在脚本中所用到的那些 Redis 键(key),这些键名参数可以在 Lua 中通过全局变量 KEYS 数组,用 1 为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)。 arg [arg …]: 附加参数,在 Lua 中通过全局变量 ARGV 数组访问,访问的形式和 KEYS 变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)。
在Lua中,可以通过内置的函数redis.call()(某个命令失败会整个中断)和redis.pcall()(某个命令失败,会继续执行)来执行redis命令。
redis.call()和redis.pcall()区别:前者过程中某个命令失败会整个事务中断,或者不会,继续执行。
redis-master:6379> eval "return redis.call('set','foo','bar')" 0
OK
redis-master:6379> eval "return redis.call('get',KEYS[1])" 1 foo
"bar"可以直接通过 redis-cli --eval执行写好的test.lua脚本:
执行命令: redis-cli -a 密码 --eval Lua 脚本路径 key [key ...] , arg [arg ...] 。 脚本路径后紧跟 key [key …] ,相比命令行模式,少了 numkeys 这个 key 的数量值。key [key …] 和 arg [arg …] 之间的英文逗号前后必须有空格,否则报错。
模拟cas命令,cas.lua
local old=ARGV[1]
local new=ARGV[2]
local expire=ARGV[3]
local current=redis.call('get',KEYS[1])
if current then
if current~=ARGV[1] then
return -1;
end;
redis.call('setex',KEYS[1],ARGV[2],ARGV[3])
return 1;
end;
return 0;redis-cli -h redis-master -a 123456 --eval cas.lua cas , old 60 newJava开发中的使用
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.3.1.RELEASE</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.8.1</version>
</dependency>import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.data.redis.serializer.GenericToStringSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
@Component
public class RedisUtil {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 执行 lua 脚本
* @author hengyumo
* @since 2021-06-05
*
* @param luaScript lua 脚本
* @param returnType 返回的结构类型
* @param keys KEYS
* @param argv ARGV
* @param <T> 泛型
*
* @return 执行的结果
*/
public <T> T executeLuaScript(String luaScript, Class<T> returnType, String[] keys, Object... argv) {
Object execute = redisTemplate.execute(RedisScript.of(luaScript, returnType),
new StringRedisSerializer(),
new GenericToStringSerializer<>(returnType),
Arrays.asList(keys),
(Object[]) argv);
return (T) execute;
}
// 以下命令删除xxx*格式的所有key值
private final static String LUA_SCRIPT_CLEAR_WITH_KEY_PRE =
"local redisKeys = redis.call('keys',KEYS[1]..'*');" +
"for i,k in pairs(redisKeys) do redis.call('del',k);end;" +
"return redisKeys;";
/**
* @author hengyumo
* @since 2021-06-05
*
* 删除以key为前缀的所有键值
* @param keyPre 前缀
* @return 返回删除掉的所有key
*/
public List<String> deleteKeysWithPre(String keyPre) {
@SuppressWarnings("unchecked")
List<Object> result = executeLuaScript(LUA_SCRIPT_CLEAR_WITH_KEY_PRE, List.class, new String[] {keyPre});
return result.stream().map(x -> {
if (x instanceof List) {
@SuppressWarnings("unchecked")
List<String> list = (List<String>) x;
if (list.size() > 0) {
return list.get(0);
}
}
return null;
}).filter(Objects::nonNull).collect(Collectors.toList());
}
}
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import javax.annotation.Resource;
import java.util.List;
@SpringBootTest
@RunWith(SpringRunner.class)
public class RedisUtilTest {
@Resource
private RedisUtil redisUtil;
@Test
@SuppressWarnings("unchecked")
public void executeLuaScript() {
String script_get = "redis.call('get',KEYS[1])";
List<Object> list = redisUtil.executeLuaScript(script_get,
List.class, new String[] {"cas"});
list.forEach(x -> {
if(x==null){
System.out.println("key的值为nil不存在");
}else{
System.out.println(x.toString());
}
});
String script_cas="local old=ARGV[1]\n" +
"local new=ARGV[2]\n" +
"local expire=ARGV[3]\n" +
"local current=redis.call('get',KEYS[1])\n" +
"if current then\n" +
"\tif current~=ARGV[1] then\n" +
"\t\treturn -1;\n" +
"\tend;\n" +
"\tredis.call('setex',KEYS[1],60,ARGV[2])\n" +
"\treturn 1;\n" +
"end;\n" +
"return 0;\n";
list = redisUtil.executeLuaScript(script_cas,
List.class, new String[] {"cas"},"old","new");
System.out.println("不存在时,返回:");
list.forEach(System.out::println);
String script_set = "redis.call('set',KEYS[1],ARGV[1]);return redis.call('get',KEYS[1]);";
list = redisUtil.executeLuaScript(script_set,
List.class, new String[] {"cas"},"old");
System.out.println("初始化key的值为old");
list = redisUtil.executeLuaScript(script_cas,
List.class, new String[] {"cas"},"old","new");
System.out.println("新旧一致时,返回:");
list.forEach(System.out::println);
list = redisUtil.executeLuaScript(script_cas,
List.class, new String[] {"cas"},"old","new");
System.out.println("新旧不一致时,返回:");
list.forEach(System.out::println);
}
@Test
public void deleteKeysWithPre() {
List<String> list = redisUtil.deleteKeysWithPre("ca");
list.forEach(System.out::println);
}
}注意脚本可能存在卡住情况,可以限制卡住的最长时间。
Redis的部署模式
Redis作为缓存的高效中间件,在我们日常的开发中被频繁的使用,今天就来说一说Redis的四种模式,分别是单机版、主从复制、哨兵、以及集群模式。
主从模式
原理
主从的原理还算是比较简单的,一主多从,主数据库(master)可以读也可以写(read/write),从数据库仅读(only read)。
但是,主从模式一般实现读写分离,主数据库仅写(only write),减轻主数据库的压力,下面一张图搞懂主从模式的原理
一般在读写分离通过客户端实现,可以由主节点提供写服务,由从节点提供读服务(即写Redis数据时应用连接主节点,读Redis数据时应用连接从节点)。
服务端也可以实现,先决条件HAProxy,或者其他任何能够自定义TCP健康检查策略的负载均衡服务都行(比如Nginx,F5等等),本文将以HAProxy为例。
实现思路
在Redis服务前面搭建L4负载均衡服务,做下面两件事:
- 把写请求定向到当前的Master节点上
- 把读请求在当前的Slave节点间负载均衡
这就要求负载均衡服务不仅要知道当前哪些Redis节点是可用的,还要知道这些Redis节点的身份。 通过自定义TCP健康检查策略可以很容易实现:
哨兵模式
原理
哨兵模式是主从的升级版,因为主从的出现故障后,不会自动恢复,需要人为干预,这就很蛋疼啊。
在主从的基础上,实现哨兵模式就是为了监控主从的运行状况,对主从的健壮进行监控,就好像哨兵一样,只要有异常就发出警告,对异常状况进行处理。
所以,总的概括来说,哨兵模式有以下的优点(功能点):
监控:监控master和slave是否正常运行,以及哨兵之间也会相互监控
自动故障恢复:当master出现故障的时候,会自动选举一个slave作为master顶上去。(过半选举机制)
优点
哨兵模式是主从模式的升级版,所以在系统层面提高了系统的可用性和性能、稳定性。当master宕机的时候,能够自动进行故障恢复,需不要人为的干预。
哨兵于哨兵之间、哨兵与master之间能够进行及时的监控,心跳检测,及时发现系统的问题,这都是弥补了主从的缺点。
缺点
哨兵一主多从的模式同样也会遇到写的瓶颈,已经存储瓶颈,若是master宕机了,故障恢复的时间比较长,写的业务就会受到影响。
增加了哨兵也增加了系统的复杂度,需要同时维护哨兵模式。
Cluster模式
最后,Cluster是真正的集群模式了,哨兵解决和主从不能自动故障恢复的问题,但是同时也存在难以扩容以及单机存储、读写能力受限的问题。
集群模式实现了Redis数据的分布式存储,实现数据的分片,每个redis节点存储不同的内容,并且解决了在线的节点收缩(下线)和扩容(上线)问题。
集群模式真正意义上实现了系统的高可用和高性能,但是集群同时进一步使系统变得越来越复杂,接下来我们来详细的了解集群的运作原理。
数据分区原理
集群的原理图还是很好理解的,在Redis集群中采用的使虚拟槽分区算法,会把redis集群分成16384 个槽(0 -16383)。
比如:下图所示三个master,会把0 -16383范围的槽可能分成三部分(0-5000)、(5001-11000)、(11001-16383)分别数据三个缓存节点的槽范围。
当客户端请求过来,会首先通过对key进行CRC16 校验并对 16384 取模(CRC16(key)%16383)计算出key所在的槽,然后再到对应的槽上进行取数据或者存数据,这样就实现了数据的访问更新。
之所以进行分槽存储,是将一整堆的数据进行分片,防止单台的redis数据量过大,影响性能的问题。
生产中的问题
1.big key
1.1 big key的简介
big keys指的是key对应的value值比较大, 判断标准并不是唯一。在实际业务开发中,对 big keys 的判断是需要根据具体的使用场景做不同的判断。比如操作某个 key 导致请求响应时间变慢,那么这个 key 就可以判定成 big keys。
1.2 big key的危害
在系统中如果存在 big keys,会导致请求数据响应变慢、请求超时或者系统不稳定。
响应变慢、超时阻塞
Redis 是单线程工作的,同一时间只能处理一个请求,操作 big keys 时比较耗时,请求响应也变慢。其他请求也处于阻塞状态,导致请求超时。除了查询 big keys 比较耗时,删除 big keys 也会导致一样的问题。
网络拥塞
请求单个 big keys 产生的网络流量比较大,假设一个 big keys 为 1MB,客户端每秒访问量是 1000,那么每秒产生 1000MB 的流量,普通的千兆网卡承受不了这么大的流量。而且一般会在单机部署多个Redis实例,一个 big keys 可能也会影响其他实例。
内存分布不均
Redis 集群模式中,key根据不同的hash嘈分配到不同的节点上,当大部分的 big keys 分布在同一个节点,导致内存倾斜在同一个节点上,内存分布不均。在水平扩容时,需要以最大容量的节为准,浪费内存。
1.3 big key的分析
big key的分析可分为在线分析和离线分析。
在线分析
使用命令 --bigkeys
--bigkeys是 redis 自带的命令,对整个 Key 进行扫描,统计 string,list,set,zset,hash 这几个常见数据类型中每种类型里的最大的 key。string 类型统计的是 value 的字节数;另外 4 种复杂结构的类型统计的是元素个数,不能直观的看出 value 占用字节数,所以 --bigkeys 对分析 string 类型的大 key 是有用的,而复杂结构的类型还需要一些第三方工具。
注:元素个数少,不一定 value 不大;元素个数多,也不一定 value 就大
redis-cli -h 127.0.0.1 -p 6379 -a "password" --bigkeys--bigkeys是以 scan 延迟计算的方式扫描所有 key,因此执行过程中不会阻塞 redis,但实例存在大量的 keys 时,命令执行的时间会很长,这种情况建议在 slave 上扫描。–-bigkeys其实就是找出类型中最大的 key,最大的 key 不一定是大 key,最大的 key 都不超过 10kb 的话,说明不存在大 key。但某种类型如果存在较多的大key (>10kb),只会统计 top1 的那个 key,如果要统计所有大于 10kb 的 key,需要用第三方工具扫描 rdb 持久化文件。
离线分析
使用 Rdbtools 工具包
Rdbtools 是 python写的 一个第三方开源工具,用来解析 Redis 快照文件。除了解析 rdb 文件,还提供了统计单个 key 大小的工具。
1、安装
git clone https://github.com/sripathikrishnan/redis-rdb-tools cd redis-rdb-tools sudo && python setup.py install2、使用
从
dump.rdb快照文件统计, 将所有 > 10kb 的 key 输出到一个 csv 文件rdb dump.rdb -c memory --bytes 10240 -f live_redis.csv使用rdb_bigkeys工具
(不支持redis 3.2之后的rdb版本,需要重新编译,需要趟坑)
线上遇到redis CPU高与网卡带宽跑满的情况, 很明显的bigkey问题, 但使用一个开源的以python编写的redis RDB分析工具来分析big key, 分析150MB的RDB文件花了一个小时, 这太慢了, 这是一个使用go重新写了个分析RDB文件来找出big key的工具rdb_bigkeys速度很快, 同样分析150MB的RDB文件, 只要1分2秒。
#使用很简单,全部就下面提到的5个参数: ./rdb_bigkeys --bytes 1024 --file bigkeys_6379.csv --sep 0 --sorted --threads 4 dump6379.rdb #上述命令分析dump6379.rdb文件中大于1024bytes的KEY, 由大到小排好序, 以CSV格式把结果输出到bigkeys_6379.csv的文件中安装过程以及遇到的问题解决
yum install go -y export GOBIN=$GOPATH/bin export PATH=$PATH:$GOBIN mkdir /home/gocode/ export GOPATH=/home/gocode/ cd $GOPATH git clone https://github.com/weiyanwei412/rdb_bigkeys.git cd rdb_bigkeys go install go mod init rdb_bigkeys go mod tidy #这里会遇到dial tcp 142.251.42.241:443: connect: connection refused go env -w GOPROXY=https://goproxy.cn,direct go get go build为了能支持高版本的rdb,暂时把版本检查给注释了
使用RDR工具
(不支持redis 3.2之后的rdb版本,需要重新编译,一样需要趟坑)
go clean --modcache go mod init rdr-linux go mod tidy go mod vendor #添加完依赖包之后,可以到vendor下check是否版本检查注释掉了 go buildRDR 是解析 redis rdbfile 工具。与redis-rdb-tools相比,RDR 是由golang 实现的,速度更快。
- 分析 Redis 内存中那个 Key 值占用的内存最多
- 分析出 Redis 内存中那一类开头的 Key 占用最多,有利于内存优化
- Redis Key 值以 Dashboard 展示,这样更直观
网页查看
./rdr-linux show -p 8080 dump.rdb网页查看如下:
查看redis keys情况(没有排序,不是top10的意思,从头抽了)
./rdr-linux keys dump.rdb | head -n 10redis-rdb-tools用于分析所有key及占用空间;rdr能够分析出所有key但是没法计算key占用空间,不过额外提供图形化界面。
rdr和rdb_bigkeys分析耗时都很快474MB都只有不到1min就处理完了,区别在于:rdb_bigkeys只是给出排完序的key的大小清单,若要看占比,还需要另外统计:rdr给出了不同前缀key的个数,所占用内存大小以及top100的key。
1.4 big key的解决
异步删除 big keys
找到 big keys 之后,首先需要删除对应的big keys,但是使用 del 命令删除 big keys 是比较耗时的。Redis4.0 后可以使用 unlink 删除,和 del 命令相比,unlink 是非阻塞的异步删除。
非字符串的 big keys,使用 hscan、sscan、zscan 方式渐进式删除,同时要注意防止big keys 过期时间自动删除问题(例如一个 200 万的 zset 设置1小时过期,会触发del操作,造成阻塞)。
big key 拆分
字符串类型的数据是减少字符串的长度,将一个字符串拆成几个小的字符串。非字符串的是减少元素数量。这些都是讲一个 key 拆成多个 key,比如:
- 字符串类型的数据,根据数据的属性拆分。比如商品信息,根据的类别拆分 key。
- 非字符串类型的数据,根据数据的属性拆分,可以按照日期拆分,比如每天登录人的集合,按照日期拆分,key20220101、key20220102.
如果 big keys 无法避免,那获取数据尽量不要把所有的数据都取出来,就使用分段的方式取出数据。删除的方式也类似,分段删除数据。