共计 11424 个字符,预计需要花费 29 分钟才能阅读完成。
今天就跟大家聊聊有关 Redis 中都有哪些数据结构,可能很多人都不太了解,为了让大家更加了解,丸趣 TV 小编给大家总结了以下内容,希望大家根据这篇文章可以有所收获。
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
假如存储的「字符串是一个字符串值并且长度大于 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 编码格式 * @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 内部为了处理这种情况采用「渐进式的 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)是一组连续内存块组成的顺序的数据结构,压缩列表能够节省空间,压缩列表中使用多个节点来存储数据。
压缩列表是列表键和哈希键底层实现的原理之一,「压缩列表并不是以某种压缩算法进行压缩存储数据,而是它表示一组连续的内存空间的使用,节省空间」,压缩列表的内存结构图如下:
压缩列表中每一个节点表示的含义如下所示:
鸿蒙官方战略合作共建——HarmonyOS 技术社区
zlbytes:4 个字节的大小,记录压缩列表占用内存的字节数。
zltail:4 个字节大小,记录表尾节点距离起始地址的偏移量,用于快速定位到尾节点的地址。
zllen:2 个字节的大小,记录压缩列表中的节点数。
entry:表示列表中的每一个节点。
zlend:表示压缩列表的特殊结束符号 0xFF。
再压缩列表中每一个 entry 节点又有三部分组成,包括 previous_entry_ength、encoding、content。
鸿蒙官方战略合作共建——HarmonyOS 技术社区
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 中链表的特性:
鸿蒙官方战略合作共建——HarmonyOS 技术社区
每一个节点都有指向前一个节点和后一个节点的指针。
头节点和尾节点的 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; } } /** * 从消息队列中弹出消息 * @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 和 intset」,ht(哈希表)前面已经详细了解过,下面我们来看看 inset 类型的存储结构。
inset 也叫做整数集合,用于保存整数值的数据结构类型,它可以保存 int16_t、int32_t 或者 int64_t 的整数值。
在整数集合中,有三个属性值 encoding、length、contents[],分别表示编码方式、整数集合的长度、以及元素内容,length 就是记录 contents 里面的大小。
在整数集合新增元素的时候,若是超出了原集合的长度大小,就会对集合进行升级,具体的升级过程如下:
鸿蒙官方战略合作共建——HarmonyOS 技术社区
首先扩展底层数组的大小,并且数组的类型为新元素的类型。
然后将原来的数组中的元素转为新元素的类型,并放到扩展后数组对应的位置。
整数集合升级后就不会再降级,编码会一直保持升级后的状态。
应用场景
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 有如下几个特点:
鸿蒙官方战略合作共建——HarmonyOS 技术社区
有很多层组成,由上到下节点数逐渐密集,最上层的节点最稀疏,跨度也最大。
每一层都是一个有序链表,至少包含两个节点,头节点和尾节点。
每一层的每一个每一个节点都含有指向同一层下一个节点和下一层同一个位置节点的指针。
如果一个节点在某一层出现,那么该以下的所有链表同一个位置都会出现该节点。
具体实现的结构图如下所示:
在跳跃表的结构中有 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; }
看完上述内容,你们对 Redis 中都有哪些数据结构有进一步的了解吗?如果还想了解更多知识或者相关内容,请关注丸趣 TV 行业资讯频道,感谢大家的支持。