Redis提供了丰富的数据类型,常见的有五种:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
随着Redis版本的更新,后面又支持了四种数据类型:BitMap(2.2版新增)、HyperLogLog(2.8版新增)、GEO(3.2版新增)、Stream(5.0版新增)。
在Redis中有一个核心的对象叫做redisObject
,是用来表示所有的key和value的,用redisObject结构体来表示String、Hash、List、Set、ZSet
五种数据类型。
redisObject
的源代码在redis.h
中,使用c语言写的,表示redisObject的结构如下所示:
在redisObject中type表示属于哪种数据类型,encoding表示该数据的存储方式,也就是底层的实现的该数据类型的数据结构。那么encoding中的存储类型又分别表示什么意思呢?具体数据类型所表示的含义,如下图所示:
这张图只是让你找到每种中数据结构对应的储存类型有哪些,举一个简单的例子,在Redis中设置一个字符串key 234
,然后查看这个字符串的存储类型就会看到为int类型,非整数型的使用的是embstr储存类型,具体操作如下图所示:
127.0.0.1:6379> set key 234 OK 127.0.0.1:6379> object encoding key "int" 127.0.0.1:6379> set k2 3.200 OK 127.0.0.1:6379> object encoding k2 "embstr"
String是Redis最基本的数据类型,这是最简单的类型,就是普通的set和get,做简单的KV缓存。一个键最大能存储512M。
内部的实现是通过SDS(Simple Dynamic String)来存储的。SDS类似于Java中的ArrayList,可以通过预分配冗余空间的方式来减少内存的频繁分配。Redis是用c语言开发的。但是Redis中的字符串和c语言中的字符串类型却是有明显的区别。
String类型的底层的数据结构实现主要是int和SDS(简单动态字符串)。内部编码有三种int、raw、embstr
。
Redis中规定假如存储的是整数型值,比如set num 123
这样的类型,就会使用int的存储方式进行存储,在redisObject的「ptr属性」中就会保存该值。
存储的字符串对象是一个字符串值就会使用SDS
方式进行存储。
SDS称为简单动态字符串,对于SDS中的定义在Redis的源码中有的三个属性int len、int free、char buf[]
。len保存了字符串的长度,free表示buf数组中未使用的字节数量,buf数组则是保存字符串的每一个字符元素。
raw和embstr区别
embstr优缺点
缺点:
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 |
说到这里我相信很多人可以说已经精通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是一个键值对(key-value)集合,其中value的形式入:value=[{field1,value1},...{fieldN,valueN}]。Hash特别适合用于存储对象。
Hash与String对象的区别如下图所示:
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
(压缩列表)是一组连续内存块组成的顺序的数据结构,压缩列表能够节省空间,压缩列表中使用多个节点来存储数据。压缩列表是列表键和哈希键底层实现的原理之一,压缩列表并不是以某种压缩算法进行压缩存储数据,而是它表示一组连续的内存空间的使用,节省空间,压缩列表的内存结构图如下:
压缩列表中每一个节点表示的含义如下所示:
zlbytes
:4个字节的大小,记录压缩列表占用内存的字节数。zltail
:4个字节大小,记录表尾节点距离起始地址的偏移量,用于快速定位到尾节点的地址。zllen
:2个字节的大小,记录压缩列表中的节点数。entry
:表示列表中的每一个节点。zlend
:表示压缩列表的特殊结束符号'0xFF'
。再压缩列表中每一个entry节点又有三部分组成,包括previous_entry_ength、encoding、content
。
previous_entry_ength
表示前一个节点entry的长度,可用于计算前一个节点的其实地址,因为他们的地址是连续的。在Redis 7.0中,压缩列表数据结构已经废弃了,交由listpack数据结构来实现了。
哈希表相对于String类型存储信息更加直观,存储更加方便,经常会用来做用户数据的管理,存储用户的信息。hash也可以用作高并发场景下使用Redis生成唯一的id。下面我们就以这两种场景用作案例编码实现。
第一个场景比如我们要储存用户信息,一般使用用户的ID作为key值,保持唯一性,用户的其他信息(地址、年龄、生日、电话号码等)作为value值存储。
若是传统的实现就是将用户的信息封装成为一个对象,通过序列化存储数据,当需要获取用户信息的时候,就会通过反序列化得到用户信息。
但是这样必然会造成序列化和反序列化的性能的开销,并且若是只修改其中的一个属性值,就需要把整个对象序列化出来,操作的动作太大,造成不必要的性能开销。
若是使用Redis的hash来存储用户数据,就会将原来的value值又看成了一个kv形式的存储容器,这样就不会带来序列化的性能开销的问题。
第二个场景就是生成分布式的唯一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)); } }
以用户id为key,商品id为field,商品数量为value,恰好构成了购物车的3个要素。
List列表是简单的字符串列表,按照插入顺序排序,可以从头部或尾部向List列表添加元素。
列表的最大长度为2^32 - 1,也即每个列表支持超过40亿个元素。
Redis中的列表在3.2之前的版本是使用ziplist
和linkedlist
进行实现的。
如果列表的元素个数小于512个(默认值,可由list-max-ziplist-entries配置),列表每个元素的值都小于64字节(默认值,可由list-max-ziplist-value配置),Redis会使用压缩列表作为List类型的底层数据结构;
如果列表的元素不满足上面的条件,Redis会使用双向链表作为List类型的底层数据结构;
在3.2之后的版本就是引入了quicklist
。linkedlist是一个双向链表,普通的链表一样都是由指向前后节点的指针。插入、修改、更新的时间复杂度尾O(1),但是查询的时间复杂度确实O(n)。linkedlist和quicklist的底层实现是采用链表进行实现,在c语言中并没有内置的链表这种数据结构,Redis实现了自己的链表结构。
Redis中链表的特性:
Redis中列表和集合都可以用来存储字符串,但是Set是不可重复的集合,而List列表可以存储相同的字符串,Set集合是无序的这个和后面讲的ZSet有序集合相对。
Set类型和List类型的区别如下:
Set的底层实现是ht(哈希表)和intset(整数集合)。
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(Sorted set)是有序集合类型,相比于Set类型多了一个排序属性score(分值),对于有序集合ZSet来说,每个存储元素相当于有两个值组成的,一个是有序结合的元素值,一个是排序值。
有序集合保留了集合不能有重复成员的特性(分值可以重复),但不同的是,有序集合中的元素可以排序。
Zset的底层实现是ziplist
(压缩列表)和skiplist
(跳表)实现的。
skiplist
(跳跃表)是一种有序的数据结构,它通过每一个节点维持多个指向其它节点的指针,从而达到快速访问的目的。
skiplist有如下几个特点:
具体实现的结构图如下所示
在跳跃表的结构中有head和tail表示指向头节点和尾节点的指针,能快速的实现定位。level表示层数,len表示跳跃表的长度,BW表示后退指针,在从尾向前遍历的时候使用。BW下面还有两个值分别表示分值(score)和成员对象(各个节点保存的成员对象)。跳跃表的实现中,除了最底层的一层保存的是原始链表的完整数据,上层的节点数会越来越少,并且跨度会越来越大。跳跃表的上面层就相当于索引层,都是为了找到最后的数据而服务的,数据量越大,条表所体现的查询的效率就越高,和平衡树的查询效率相差无几。
zset的使用场景与set类似,但是set集合不是自动有序的,而Sorted set可以利用分数进行成员间的排序,而且是插入时就排序好。所以当你需要一个有序且不重复的集合列表时,就可以选择Sorted set数据结构作为选择方案。
*微博热搜榜,就是有个后面的热度值,前面就是名称。因为ZSet是有序的集合,因此ZSet在实现排序类型的业务是比较常见的,比如在首页推荐10个最热门的帖子,也就是阅读量由高到低,排行榜的实现等业务。下面就选用获取排行榜前前10名的选手作为案例实现,实现的代码如下所示:
/** * 获取前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对象的列表进行返回。
Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1的设置,表示某个元素的值或者状态,时间复杂度为O(1)。
由于bit是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用二值统计的场景。
Bitmap本身是用String类型作为底层数据结构实现的一种统计二值状态的数据类型。
String类型是会保存为二进制的字节数组,所以,Redis就把字节数组的每个bit位利用起来,用来表示一个元素的二值状态,你可以把Bitmap看作是一个bit数组。
Bitmap类型非常适合二值状态统计的场景,这里的二值状态就是指集合元素的取值就只有0和1两种,在记录海量数据时,Bitmap能够有效地节省内存空间。
在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态。
签到统计时,每个用户一天的签到用1个bit位就能表示,一个月(假设是31天)的签到情况用31个bit位就可以,而一年的签到也只需要用365个bit位,根本不用太复杂的集合类型。
Bitmap提供了GETBIT、SETBIT操作,通过一个偏移值offset对bit数组的offset位置的bit位进行读写操作,需要注意的是offset从0开始。
只需要一个key=login_status表示存储用户登陆状态集合数据,将用户ID作为offset,在线就设置为1,下线设置0。通过GETBIT判断对应的用户是否在线。50000万用户只需要6MB的空间。
把每天的日期作为Bitmap的key,userId作为offset,若是打卡则将offset位置的bit设置成1。
key对应的集合的每个bit位的数据则是一个用户在该日期的打卡记录。
一共有7个这样的Bitmap,如果我们能对这7个Bitmap的对应的bit位做『与』运算。同样的UserID offset都是一样的,当一个userID在7个Bitmap对应对应的offset位置的bit=1就说明该用户7天连续打卡。
结果保存到一个新Bitmap中,我们再通过BITCOUNT统计bit=1的个数便得到了连续打卡3天的用户总数了。
HyperLogLog是Redis 2.8.9版本新增的数据类型,是一种用于「统计基数」的数据集合类型,基数统计就是指统计一个集合中不重复的元素个数。但要注意,HyperLogLog是统计规则是基于概率完成的,不是非常准确,标准误算率是0.81%。
所以,简单来说HyperLogLog提供不精确的去重计数。
HyperLogLog的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的内存空间总是固定的、并且是很小的。
在Redis里面,每个HyperLogLog键只需要花费12KB内存,就可以计算接近2^64个不同元素的基数,和元素越多就越耗费内存的Set和Hash类型相比,HyperLogLog就非常节省空间。
这什么概念?举个例子给大家对比一下。
用Java语言来说,一般long类型占用8字节,而1字节有8位,即:1byte = 8bit,即long数据类型最大可以表示的数是:263-1。对应上面的264个数,假设此时有263-1这么多个数,从0~263-1,按照long以及1k=1024字节的规则来计算内存总数,就是:((2^63-1) * 8/1024)K,这是很庞大的一个数,存储空间远远超过12K,而HyperLogLog却可以用12K就能统计完。
百万级网页UV计数:HyperLogLog优势在于只需要花费12KB内存,就可以计算接近2^64个元素的基数,和元素越多就越耗费内存的Set和Hash类型相比,HyperLogLog就非常节省空间。
所以,非常适合统计百万级以上的网页UV的场景。
GEO是Redis 3.2版本新增的数据类型,主要用于存储地理位置信息,并对存储的信息进行操作。
在日常生活中,我们越来越依赖搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-Based Service,LBS)的应用。LBS应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO就非常适合应用在LBS服务的场景中。
GEO本身并没有设计新的底层数据结构,而是直接使用了SortedSet集合类型。
GEO类型使用GeoHash编码方法实现了经纬度到Sorted Set中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为Sorted Set元素的权重分数。
这样一来,我们就可以把经纬度保存到Sorted Set中,利用Sorted Set提供的“按权重进行有序范围查找”的特性,实现LBS服务中频繁使用的“搜索附近”的需求。
这里以滴滴叫车的场景为例,介绍下具体如何使用GEO命令:GEOADD和GEORADIUS这两个命令。
假设车辆ID是33,经纬度位置是(116.034579,39.030452),我们可以用一个GEO集合保存所有车辆的经纬度,集合key是cars:locations。
当用户想要寻找自己附近的网约车时,LBS应用就可以使用GEORADIUS命令。
例如,LBS应用执行下面的命令时,Redis会根据输入的用户的经纬度信息(116.054579,39.030452),查找以这个经纬度为中心的5公里内的车辆信息,并返回给LBS应用。
可以用来保存地理位置,并作位置距离计算或者根据半径计算位置等。有没有想过用Redis来实现附近的人?或者计算最优地图路径?这三个其实也可以算作一种数据结构,不知道还有多少朋友记得,我在梦开始的地方,Redis基础中提到过,你如果只知道五种基础类型那只能拿60分,如果你能讲出高级用法,那就觉得你有点东西。
Stream是Redis 5.0版本新增加的数据类型,Redis专门为消息队列设计的数据类型。
在Redis 5.0 Stream没出来之前,消息队列的实现方式都有着各自的缺陷,例如:
基于以上问题,Redis 5.0便推出了Stream类型也是此版本最重要的功能,用于完美地实现消息队列,它支持消息的持久化、支持自动生成全局唯一ID、支持ack确认消息的模式、支持消费组模式等,让消息队列更加的稳定和可靠。
生产者通过XADD命令插入一条消息,消费者通过XREAD命令从消息队列中读取消息时,可以指定一个消息ID,并从这个消息ID的下一条消息开始进行读取
Redis常见的五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)及Zset(sorted set:有序集合)。
这五种数据类型都由多种数据结构实现的,主要是出于时间和空间的考虑,当数据量小的时候使用更简单的数据结构,有利于节省内存,提高性能。
Redis五种数据类型的应用场景:
Redis后续版本又支持四种数据类型,它们的应用场景如下: