本文脑图

前言
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语言开发的。但是Redis中的字符串和c语言中的字符串类型却是有明显的区别。
String类型的数据结构存储方式有三种int、raw、embstr。那么这三种存储方式有什么区别呢?
int
Redis中规定假如存储的是整数型值 ,比如set num 123这样的类型,就会使用 int的存储方式进行存储,在redisObject的ptr属性中就会保存该值。
范围:0 ~ 10000 全局共享
命令:set 键 数字 / incr / decr 都遵守该规则
编码变化
- 数字在区间内:
int编码 + 共享对象 - 超 10000:还是
int编码,但不再共享

SDS
假如存储的字符串是一个字符串值并且长度大于44个字节 就会使用SDS(simple dynamic string)方式进行存储,并且encoding设置为raw;若是字符串长度小于等于44个字节就会将encoding改为embstr来保存字符串。
embstr是一次malloc内存分配的,给的是redisobject+sds的连续在一起的整块内存,大小是固定死的,没有多余的空间,且不能修改,若是append就会转换成raw。
raw是两次malloc,分成两次申请内存,redisobject和sds分开的,sds通过len、free可以进行扩容。策略是修改后的字符大小<1MB翻倍扩容,节省扩容次数。修改后的字符大小>=1MB,每次分配的内存是1MB。
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得编码形式,具体得实现代码如下:
java
/**
* 将图片内容处理成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中,实现得代码如下所示:
java
/**
* 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类型得数据结构得应用也还有常规计数:统计微博数、统计粉丝数等。
java
// 注入Redis客户端
StringRedisTemplate redisTemplate;
/**
* 发布微博,微博数量+1
*/
public void addWeiboCount(Long userId){
String key = "user:weibo:count:" + userId;
// 原子自增
redisTemplate.opsForValue().increment(key, 1);
}
/**
* 删除微博,微博数量-1
*/
public void subWeiboCount(Long userId){
String key = "user:weibo:count:" + userId;
redisTemplate.opsForValue().increment(key, -1);
}
/**
* 关注用户,对方粉丝数+1
*/
public void followUser(Long targetUserId){
String key = "user:fans:count:" + targetUserId;
redisTemplate.opsForValue().increment(key, 1);
}
/**
* 取消关注,对方粉丝数-1
*/
public void unFollowUser(Long targetUserId){
String key = "user:fans:count:" + targetUserId;
redisTemplate.opsForValue().increment(key, -1);
}
/**
* 获取用户微博总数
*/
public Long getWeiboCount(Long userId){
String key = "user:weibo:count:" + userId;
String num = redisTemplate.opsForValue().get(key);
return num == null ? 0 : Long.parseLong(num);
}
/**
* 获取用户粉丝总数
*/
public Long getFansCount(Long userId){
String key = "user:fans:count:" + userId;
String num = redisTemplate.opsForValue().get(key);
return num == null ? 0 : Long.parseLong(num);
}
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表进行一个扩展或者收缩。
扩容->负载因子:used/size>=1时触发扩容,但是在有持久化操作(bgsave进行rdb的时候)的时候会暂停,除非负载因子>5
缩容->负载因子:used/size<0.1触发缩容
这里就会和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[1]不动),当要进行扩展或者收缩时,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],为下一次的扩展和收缩做准备。
举个具体例子,一看就懂
假设当前 ht[0].used = 3(就像你图里的情况):
- 扩容时
目标是找 ≥ 3×2 = 6 的第一个 2 的幂
- 2 的幂序列:2,4,8,16...
- 比 6 大的第一个 2 的幂是 8
- 所以
ht[1].size = 8
- 收缩时(比如 used 从 10 降到 3)
目标是找 ≥ 3 的第一个 2 的幂
- 比 3 大的第一个 2 的幂是 4
- 所以
ht[1].size = 4
为什么要这么设计?
-
扩容 ×2 的原因:
- 保证负载因子(used/size)降到 0.5 以下,大幅减少哈希冲突
- 同时新容量是 2 的幂,方便用
& sizemask快速计算下标
-
收缩不直接缩到 used 的原因:
- 直接缩到 used,size 可能小于 used,会立刻触发扩容
- 用 "大于等于 used 的第一个 2 的幂",既能节省内存,又避免频繁缩容 - 扩容的抖动
-
只要
size是 2 的幂 →size-1二进制全是低位 1 -
hash & 全1掩码= 快速截取hash低位 = 合法下标 -
一旦 size 不是 2 的幂 → 掩码参差不齐,位运算无法均匀算出 0~size 之间所有下标,这个高效算法就不能用了
所以 Redis、HashMap 都强制规定:哈希表容量必须是 2 的幂。
渐进式rehash
假如在rehash的过程中数据量非常大,Redis不是一次性把全部数据rehash成功,这样会导致Redis对外服务停止,Redis内部为了处理这种情况采用渐进式的rehash。
Redis将所有的rehash的操作分成多步进行,直到都rehash完成,具体的实现与对象中的rehashindex属性相关,若是rehashindex 表示为-1表示没有rehash操作。
当rehash操作开始时会将该值改成0,后面就依次增加直到rehashindex到数组末尾,在渐进式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 字节)
含义 :记录整个压缩列表占用的内存字节数。
作用:重分配内存或复制时,无需遍历即可知道需操作的内存大小。
对应命令场景:
HSET:向一个采用 ziplist 编码的哈希对象添加新字段时,如果当前 ziplist 空间不足,Redis 会根据zlbytes判断现有大小,并重新分配一个更大的 ziplist。RPUSH/LPUSH:向列表(Redis 6.x 及之前版本,且元素数量小于list-max-ziplist-entries)添加元素时,若空间不足也会触发 ziplist 扩容。ZADD:向采用 ziplist 编码的有序集合添加新成员时同样如此。
bash
# 哈希场景:设置少量短字段,底层用 ziplist
HSET user:100 name "Alice" age "30"
# 当 name 和 age 值很短且字段数小于 hash-max-ziplist-entries 时,压缩列表的 zlbytes 会记录总字节数
2、zltail(4 字节)
含义 :表尾节点距离压缩列表起始地址的偏移量(字节数)。
作用:快速定位到尾节点,避免遍历整个列表(因为节点长度可变,无法通过 zllen * 平均长度直接计算)。
对应命令场景:
RPOP/RPOPLPUSH:从列表右侧弹出元素时,需要立即找到最后一个 entry。Redis 通过zltail直接跳转到尾部,时间复杂度 O(1)。LRANGE负数索引 :例如LRANGE list -1 -1返回最后一个元素,同样依赖zltail快速定位。HGETALL:遍历哈希所有字段时,虽然通常是正向遍历,但内部在计算迭代器起点时也会用到偏移量。
bash
# 列表场景:快速弹出尾部元素
RPUSH mylist "a" "b" "c" # 底层 ziplist,zltail 指向 'c' 的地址
RPOP mylist # 通过 zltail 直接拿到 'c',O(1)
3、zllen(2 字节)
含义 :记录压缩列表中的节点数量。
作用:快速获取元素个数,避免遍历计数。
对应命令场景:
HLEN:获取哈希中字段数量时,直接返回zllen的值。LLEN:获取列表长度时,同样使用zllen。ZCARD:获取有序集合的成员数量时,也会读取zllen。
注意 :如果节点数超过 65535(2字节最大值),zllen 会设为最大值,此时需遍历才能获取精确数量,但 Redis 会特殊处理。
bash
# 哈希场景:获取字段个数
HSET user:101 name "Bob" age "25"
HLEN user:101 # 返回 2,底层读取 ziplist 的 zllen 字段
4、entry(节点)
含义 :列表中的每一个实际数据节点。
内部结构 :previous_entry_length + encoding + content。
4.1 previous_entry_length
作用:存储前一个 entry 的长度,用于从后向前遍历(通过当前地址减去该长度得到前一个 entry 的起始地址)。
对应命令场景:
RPOP/BRPOP:删除尾部元素后,需要找到新的尾部元素。新尾部元素的previous_entry_length能快速定位到上一个节点,实现反向遍历。ZREVRANGE(有序集合按分数从大到小遍历):内部是从尾部向前遍历,依赖previous_entry_length。HSCAN反向迭代(虽然很少用反向,但在某些内部实现中会用到)。
bash
# 有序集合场景:反向遍历成员
ZADD scores 100 "Alice" 95 "Bob"
ZREVRANGE scores 0 -1 # 返回 "Alice" "Bob",底层从尾向前移动,靠 previous_entry_length
4.2 encoding
作用 :保存 content 的数据类型(如整数、字符串)和长度。
对应命令场景:
HINCRBY:对哈希中整数类型的字段进行增减。Redis 先读取encoding判断 content 是否为整型编码,若是则直接修改数值。LINDEX:按索引获取列表元素时,需要根据编码解析 content。ZINCRBY:对有序集合成员分数增加时,会检查 encoding 来确定当前分数存储格式。
bash
# 哈希场景:对整数值做增减
HSET counter page_view 100 # content 内部是整型编码
HINCRBY counter page_view 1 # encoding 标识为整数,直接加1
4.3 content
作用:实际存储的数据内容(字符串或整数)。
对应命令场景:
HGET:根据字段名查找对应的值,定位到 entry 后直接读取 content 返回。LINDEX:根据索引取出列表某个位置的元素,最终将 content 返回给客户端。ZSCORE:获取有序集合成员的分数,从对应 entry 的 content 中读取。
bash
# 列表场景:通过索引取元素
RPUSH fruits "apple" "banana" "cherry"
LINDEX fruits 1 # 返回 "banana",内容来自对应 entry 的 content
5、 zlend(1 字节)
含义 :压缩列表的结束标志,固定值 0xFF。
作用:标志列表边界,防止遍历越界。
对应命令场景:
- 所有遍历压缩列表的操作(如
HGETALL、LRANGE、ZRANGE)在读取到zlend时停止。 - 插入、删除、查找操作都会检查该标记以确定列表终点。
bash
# 遍历哈希所有字段
HMSET user:102 name "Tom" city "NewYork"
HGETALL user:102 # 底层从第一个 entry 开始,依次读取直到遇到 0xFF
总结图:ziplist 结构与命令映射
ascii
+----------+--------+-------+--------+--------+-----+--------+-------+
| zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend |
+----------+--------+-------+--------+--------+-----+--------+-------+
↑ ↑ ↑ ↑ ↑
│ │ │ │ │
│ │ │ │ └─ 遍历终止标志(所有读取命令)
│ │ │ └─ 实际数据(HGET, LINDEX, ZSCORE)
│ │ └─ 快速返回元素个数(HLEN, LLEN, ZCARD)
│ └─ 快速尾部操作(RPOP, ZREVRANGE, LRANGE -1 -1)
└─ 内存重分配(HSET, RPUSH, ZADD 需要扩容时)
说到这里相信大家已经都hash这种数据结构已经非常了解,若是第一次接触Redis五种基本数据结构的底层实现的话,建议多看几遍,下面来说一说hash的应用场景。
listpack
在redis 7.0后推出了listpack替换ziplist,那么listpack 解决了 ziplist 的什么核心问题?
连锁更新(Cascading Update)问题
在 ziplist 中,每个 entry 开头有一个 previous_entry_length 字段,记录前一个 entry 的长度 (1 字节或 5 字节)。
当插入或删除一个 entry 时,如果它导致下一个 entry 的 previous_entry_length 需要从 1 字节变为 5 字节(或相反),这个变化会像多米诺骨牌一样传播下去,引发 O(N²) 的内存重分配和复制,严重影响性能。
ascii
ziplist 连锁更新示例:
[entry A: len=253] [entry B: pre_len=1] [entry C: pre_len=1] ...
↓ 在 A 后面插入一个 254 字节的 X
[entry A: len=253] [entry X: len=254] [entry B: pre_len 需从1→5] [entry C: pre_len 需从1→5] ...
↑ 连锁更新:B 变长后,C 的 pre_len 也要变,以此类推
listpack 如何解决?
核心思路 :移除 previous_entry_length,让每个 entry 只存储自身的信息,不再依赖前一个 entry 的长度。
listpack 的 entry 结构
ascii
+-------------------+--------------------+----------------+
| encoding+content | backlen (自身总长) | (变长编码)
+-------------------+--------------------+----------------+
- encoding+content:与 ziplist 类似,存储数据类型和实际数据。
- backlen :存储在 entry 的末尾 ,以变长编码方式记录当前 entry 的总字节长度(包括 encoding+content 和 backlen 自身)。
反向遍历的实现:从后向前遍历时,读取当前 entry 末尾的 backlen,即可知道当前 entry 的起始地址,从而直接跳到前一个 entry 的末尾 。
因为不再需要前一个 entry 的长度信息,所以每个 entry 完全独立。
插入/删除操作不再引发连锁更新
- 插入一个新 entry 时,只需计算自身长度,填入自己的
backlen,并更新 listpack 头部(总字节数、元素个数)。 - 后续 entry 的
backlen不需要任何修改,因为它们没有存储前一个 entry 的长度。 - 删除操作同理,不会引发传播。
性能对比
| 操作 | ziplist | listpack |
|---|---|---|
| 插入/删除(最坏情况) | O(N²) | O(N)(仅需移动后续数据,无需更新每个 entry 的元数据) |
| 内存效率 | 极高 | 极高(与 ziplist 相近) |
附加改进:listpack 的其他优化
- 更紧凑的编码 :
backlen采用变长编码,小长度只占 1 字节,大长度最多 5 字节。 - 简化结束符 :使用
0xFF作为全局结束标志,与 ziplist 类似。 - 元素计数:头部也用变长编码存储元素个数,不再受 2 字节限制(但实践中仍限制数量以保证性能)。
总结
| 问题 | ziplist | listpack 解决方案 |
|---|---|---|
| 连锁更新 | 存在(因 previous_entry_length 依赖前驱长度) |
消除(用 backlen 自描述,每个 entry 独立) |
| 反向遍历 | 通过 previous_entry_length 向前跳 |
通过尾部的 backlen 向前跳 |
| 插入/删除复杂度 | 最坏 O(N²) | O(N)(无元数据传播) |
Redis 版本:listpack 从 Redis 7.0 开始全面替代 ziplist,作为哈希、列表、有序集合在小数据量场景下的默认编码。
应用场景
哈希表相对于String类型存储信息更加直观,操作更加方便,经常会用来做用户数据的管理,存储用户的信息。
string的大key问题就需要拆分成hash的多个field,每次取一个field的速度也会变快。或者是压缩后存入string,取出时解压缩(浪费cpu)。
hash也可以用作高并发场景下使用Redis生成唯一的id。下面我们就以这两种场景用作案例编码实现。
存储用户数据
第一个场景比如我们要储存用户信息,一般使用用户的ID作为key值,保持唯一性,用户的其他信息(地址、年龄、生日、电话号码等)作为value值存储。
若是传统的实现就是将用户的信息封装成为一个对象,通过序列化存储数据,当需要获取用户信息的时候,就会通过反序列化得到用户信息。

但是这样必然会造成序列化和反序列化的性能的开销,并且若是只修改其中的一个属性值,就需要把整个对象序列化出来,操作的动作太大,造成不必要的性能开销。
若是使用Redis的hash来存储用户数据,就会将原来的value值又看成了一个k v形式的存储容器,这样就不会带来序列化的性能开销的问题。

分布式生成唯一ID
第二个场景就是生成分布式的唯一ID,这个场景下就是把redis封装成了一个工具类进行实现,实现的代码如下:
这个目的就是为了解决在分库分表中单库id的唯一性在多库中就不唯一的,其次是redis的IO多路复用生成这个id效率高
javascript
// 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实现了自己的链表结构。
quicklist = 用双向链表管理多个内存紧凑的 ziplist 块 ,用链表换取两端操作的高效,用 ziplist 换取内存效率,并通过对每个 ziplist 的大小限制来规避连锁更新的性能陷阱(若单纯的ziplist,当某个 entry 长度变化导致 previous_entry_length 字段从 1 字节变成 5 字节时,可能引发后续节点依次更新,最坏 O(N²)),这个是一个listNode中的ziplist大小都是有限的,即使更新也只是影响该listNode。

Redis中链表的特性:
- 每一个节点都有指向前一个节点和后一个节点的指针。
- 头节点和尾节点的prev和next指针指向为null,所以链表是无环的。
- 链表有自己长度的信息,获取长度的时间复杂度为O(1)。
Redis中List的实现比较简单,下面我们就来看看它的应用场景。
应用场景
Redis中的列表可以实现阻塞队列,结合lpush和brpop命令就可以实现。生产者使用lupsh从列表的左侧插入元素,消费者使用brpop命令从队列的右侧获取元素进行消费。
(1)首先配置redis的配置,为了方便我就直接放在application.yml配置文件中,实际中可以把redis的配置文件放在一个redis.properties文件单独放置,具体配置如下:
yml
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注解,表明他是一个配置类。
java
@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,自从学了面向对象后,就喜欢把一些通用的东西拆成工具类,好像一个一个零件,需要的时候,就把它组装起来。
java
@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消息队列工具类的创建,在后面的代码中就可以直接使用。
redis相对于rocketmq等消息中间件来说的话,没有ack确认机制,没有广播机制,没有按照主题进行订阅的机制。
Set集合
Redis中列表和集合都可以用来存储字符串,但是Set是不可重复的集合,而List列表可以存储相同的字符串,Set集合是无序的这个和后面讲的ZSet有序集合相对。
**set-max-intset-entries 配置(默认 512)**整数个数只要超过512转hashtable,非整数的直接转hashtable。
Set的底层实现是hashtable和intset,hashtable(哈希表)前面已经详细了解过,下面我们来看看inset类型的存储结构。
inset也叫做整数集合,用于保存整数值的数据结构类型,它可以保存int16_t、int32_t 或者int64_t 的整数值(这几个是c语言的有符号数据类型,存储的数据范围不同)。
在整数集合中,有三个属性值encoding、length、contents[],分别表示编码方式、整数集合的长度、以及元素内容,length就是记录contents里面的大小。
在整数集合新增元素的时候,若是超出了原集合的长度大小,就会对集合进行升级,具体的升级过程如下:
- 首先扩展底层数组的大小,并且数组的类型为新元素的类型。
- 然后将原来的数组中的元素转为新元素的类型,并放到扩展后数组对应的位置。
- 整数集合升级后就不会再降级,编码会一直保持升级后的状态。
应用场景
Set集合的应用场景可以用来去重、抽奖、共同好友等业务类型。接下来模拟一个添加好友的案例实现:
java
import redis.clients.jedis.Jedis;
import java.util.Set;
public class FriendDemo {
private Jedis jedis;
public FriendDemo() {
this.jedis = new Jedis("localhost", 6379);
}
// 添加好友关系(双向)
public void addFriend(String userId, String friendId) {
String userKey = "user:friends:" + userId;
String friendKey = "user:friends:" + friendId;
// 将对方加入自己的好友集合
jedis.sadd(userKey, friendId);
// 将自己加入对方的好友集合(双向)
jedis.sadd(friendKey, userId);
}
// 获取共同好友
public Set<String> getCommonFriends(String userIdA, String userIdB) {
String keyA = "user:friends:" + userIdA;
String keyB = "user:friends:" + userIdB;
// SINTER 计算两个集合的交集
return jedis.sinter(keyA, keyB);
}
// 示例用法
public static void main(String[] args) {
FriendDemo demo = new FriendDemo();
// 添加好友
demo.addFriend("user1", "user2");
demo.addFriend("user1", "user3");
demo.addFriend("user2", "user3");
demo.addFriend("user2", "user4");
// 查看 user1 和 user2 的共同好友
Set<String> common = demo.getCommonFriends("user1", "user2");
System.out.println("共同好友:" + common); // 预期输出 [user3]
}
}
抽奖:
java
import redis.clients.jedis.Jedis;
import java.util.Set;
public class LotteryDemo {
private Jedis jedis;
private static final String LOTTERY_KEY = "lottery:pool";
public LotteryDemo() {
jedis = new Jedis("localhost", 6379);
}
// 用户参与抽奖(加入奖池)
public void join(String userId) {
jedis.sadd(LOTTERY_KEY, userId);
}
// 抽取一名幸运用户(移除并返回)
public String drawOne() {
return jedis.spop(LOTTERY_KEY);
}
// 抽取多名幸运用户(例如三等奖10名)
public Set<String> drawMultiple(int count) {
return jedis.spop(LOTTERY_KEY, count);
}
// 仅预览中奖用户(不删除)
public Set<String> preview(int count) {
return jedis.srandmember(LOTTERY_KEY, count);
}
// 查看剩余参与人数
public long remainingCount() {
return jedis.scard(LOTTERY_KEY);
}
public static void main(String[] args) {
LotteryDemo lottery = new LotteryDemo();
// 用户参与
lottery.join("user1");
lottery.join("user2");
lottery.join("user3");
lottery.join("user4");
lottery.join("user5");
// 抽取一等奖1名
String winner = lottery.drawOne();
System.out.println("一等奖:" + winner);
// 抽取二等奖2名
Set<String> winners = lottery.drawMultiple(2);
System.out.println("二等奖:" + winners);
// 剩余人数
System.out.println("剩余参与人数:" + lottery.remainingCount());
}
}
ZSet集合
ZSet是有序集合,从上面的图中可以看到ZSet的底层实现是ziplist和skiplist实现的,ziplist上面已经详细讲过,这里来讲解skiplist的结构实现。
skiplist也叫做跳跃表,跳跃表是一种有序的数据结构,它通过每一个节点维持多个指向其它节点的指针,从而达到快速访问的目的。
skiplist由如下几个特点:
1、首先最小面的一层是包含了所有节点,score升序排序
2、接下来对每个元素在插入时独立运行 zslRandomLevel 函数:从第1层开始,以 1/4 的概率决定是否升到下一层,重复直到失败,最终层高就是所有元素能到达的最高层
3、上层元素:由这些"晋升成功"的元素构成,每个元素出现在 1 到它的层高之间的所有层中
4、通过分数寻找元素时,从最高层开始找,如图中所讲
5、len用来zcard查询长度;tail和backward用来倒叙排序(zreverange)

- 有很多层组成,由上到下节点数逐渐密集,最上层的节点最稀疏,跨度也最大。
- 每一层都是一个有序链表,只扫包含两个节点,头节点和尾节点。
- 每一层的每一个每一个节点都含有指向同一层下一个节点和下一层同一个位置节点的指针。
- 如果一个节点在某一层出现,那么该以下的所有链表同一个位置都会出现该节点。
跳跃表的实现中,除了最底层的一层保存的是原始链表的完整数据,上层的节点数会越来越少,并且跨度会越来越大。
跳跃表的上面层就相当于索引层,都是为了找到最后的数据而服务的,数据量越大,条表所体现的查询的效率就越高,和平衡树的查询效率相差无几。
应用场景
因为ZSet是有序的集合,因此ZSet在实现排序类型的业务是比较常见的,比如在首页推荐10个最热门的帖子,也就是阅读量由高到低,排行榜的实现等业务。
下面就选用获取排行榜前前10名的选手作为案例实现,实现的代码如下所示:
java
import redis.clients.jedis.Jedis;
import java.util.Set;
import java.util.List;
import java.util.ArrayList;
public class GameRankingService {
private Jedis jedis;
private static final String RANKING_KEY = "game:ranking";
public GameRankingService(Jedis jedis) {
this.jedis = jedis;
}
/**
* 玩家完成一局,增加积分
* @param playerId 玩家ID
* @param scoreToAdd 增加的分数
*/
public void addScore(String playerId, double scoreToAdd) {
// ZINCRBY:原子性地增加分数,如果不存在则创建
jedis.zincrby(RANKING_KEY, scoreToAdd, playerId);
// 可选:记录积分变更日志,用于后续审计
}
/**
* 获取积分前 N 名的玩家
* @param n 要获取的个数
* @return 玩家ID列表,按分数从高到低
*/
public List<String> getTopN(int n) {
// ZREVRANGE:按分数从高到低返回成员
Set<String> topPlayers = jedis.zrevrange(RANKING_KEY, 0, n - 1);
return new ArrayList<>(topPlayers);
}
/**
* 获取某玩家的排名(从1开始,分数最高为第1名)
* @param playerId 玩家ID
* @return 排名,如果不存在返回 -1
*/
public long getRank(String playerId) {
Long rank = jedis.zrevrank(RANKING_KEY, playerId);
return rank == null ? -1 : rank + 1;
}
/**
* 获取某玩家的当前积分
* @param playerId 玩家ID
* @return 积分,如果不存在返回 0.0
*/
public double getScore(String playerId) {
Double score = jedis.zscore(RANKING_KEY, playerId);
return score == null ? 0.0 : score;
}
/**
* 分页获取排行榜(用于前端滚动加载)
* @param page 页码(从0开始)
* @param pageSize 每页大小
* @return 玩家ID列表
*/
public List<String> getRankingByPage(int page, int pageSize) {
int start = page * pageSize;
int end = start + pageSize - 1;
Set<String> players = jedis.zrevrange(RANKING_KEY, start, end);
return new ArrayList<>(players);
}
}
// 使用示例
public class Main {
public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);
GameRankingService service = new GameRankingService(jedis);
// 玩家A赢了+20分
service.addScore("player_A", 20);
// 玩家B赢了+30分
service.addScore("player_B", 30);
// 玩家A又赢了+15分(总35分)
service.addScore("player_A", 15);
// 查看Top 3
System.out.println("Top3: " + service.getTopN(3));
// 查看玩家A排名
System.out.println("玩家A排名: " + service.getRank("player_A"));
// 查看玩家A当前积分
System.out.println("玩家A积分: " + service.getScore("player_A"));
}
}
到这里我们已经精通Redis的五种基本数据类型了,又可以去和面试官扯皮了,扯不过就跑路吧,或者这篇文章多看几遍,相信对你总是有好处的。