Redis的数据结构和使用场景

Redis(全称:Remote Dictionary Server 远程字典服务)是一个开源的使用ANSIC语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。

redis是一个key-value存储系统。和Memcached类似,它支持存储的value类型相对更多,包括string(字符串)、list(链表)、set(集合)、zset(sorted set --有序集合)和hash(哈希类型)。这些数据类型都支持push/pop、add/remove及取交集并集和差集及更丰富的操作,而且这些操作都是原子性的。在此基础上,redis支持各种不同方式的排序。与memcached一样,为了保证效率,数据都是缓存在内存中。区别的是redis会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,并且在此基础上实现了master-slave(主从)同步。

一、为什么要使用redis

因为传统的关系型数据库如Mysql已经不能适用所有的场景了,比如秒杀的库存扣减,APP首页的访问流量高峰等等,都很容易把数据库打崩,所以引入了缓存中间件,目前市面上比较常用的缓存中间件有Redis和Memcached,不过中和考虑了他们的优缺点,最后选择了Redis。

在日常的Java Web开发中,无不都是使用数据库来进行数据的存储,由于一般的系统任务中通常不会存在高并发的情况,所以这样看起来并没有什么问题,可是一旦涉及大数据量的需求,比如一些商品抢购的情景,或者是主页访问量瞬间较大的时候,单一使用数据库来保存数据的系统会因为面向磁盘,磁盘读/写速度比较慢的问题而存在严重的性能弊端,一瞬间成千上万的请求到来,需要系统在极短的时间内完成成千上万次的读/写操作,这个时候往往不是数据库能够承受的,极其容易造成数据库系统瘫痪,最终导致服务宕机的严重生产问题。

为了克服上述的问题,Java Web项目通常会引入NoSQL技术,这是一种基于内存的数据库,并且提供一定的持久化功能。Redis和MongoDB是当前使用最广泛的NoSQL,而就Redis技术而言,它的性能十分优越,可以支持每秒十几万次的读/写操作,其性能远超数据库,并且还支持集群、分布式、主从同步等配置,原则上可以无限扩展,让更多的数据存储在内存中,更让人欣慰的是它还支持一定的事务能力,这保证了高并发的场景下数据的安全和一致性。

1、Redis是单线程为什么这么快?

为什么Redis是单线程的?

1、官方答案

因为Redis是基于内存的操作,CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章地采用单线程的方案了。

关于redis的性能,官方网站也有,普通笔记本轻松处理每秒几十万的请求。

2、详细原因

决定 Redis 请求效率的因素主要是三个方面,分别是cpu、内存、网络。

1. 首先,Redis的瓶颈不在CPU,它直接从内存中获取数据就通过网络进行传输,中间不需要任何计算,对于内存操作来说,速度非常快。所以对于Redis这种高频小操作场景,单线程的效率反而更高,它的性能更多取决于内存访问速度和网络IO,而不是CPU的多核能力。因此,单线程的设计就显得非常合理了,多线程反而会带来额外的复杂性,通过同步的方式来保证线程安全性、上下文切换等问题会造成额外的性能开销。

2. 其次,从内存层面来说,Redis 本身就是一个内存数据库,内存的 IO 速度本身就很快,所以内存的瓶颈只是受限于内存大小。

3. 再次,在网络层面,Redis 采用多路复用的设计,提升了并发处理的连接数,不过这个阶段, Server 端的所有 IO 操作都是由同一个主线程处理的,这个时候 IO 的瓶颈就会影响到 Redis 端的整体处理性能。 所以从 Redis6.0 开始,在多路复用基础上增加了多线程的处理,来优化 IO 处理的能力。不过,具体的数据操作仍然是由单线程来处理的,所以 Redis 对于数据的处理依然是单线程。

4. 最后,Redis 本身的数据结构也做了很多的优化,比如压缩表、跳跃表等方式降低了时间复杂度,进一步提升操作内存的性能,同时还提供了不同时间复杂度的数据类型,使得开发人员能够有更多合适的选择。

总结:Redis使用单线程处理数据的主要原因有三点:

1)Redis的瓶颈不在CPU

Redis的瓶颈不在CPU,它的性能更多取决于内存访问速度和网络IO,而不是CPU的多核能力。因此,单线程的设计就显得非常合理了。

2)不需要各种锁的性能消耗

在单线程的情况下,就不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗。

3)避免多线程上下文切换导致的CPU消耗

采用单线程,避免了不必要的多线程上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU。

Redis在高并发的场景下速度依然很快的原因:

1.redis是基于内存的,内存的读写速度非常快;

2.redis是单线程的,省去了很多上下文切换线程的时间;

3.redis使用多路复用技术,可以处理并发的连接。多路复用IO内部实现采用epoll,采用了epoll+自己实现的简单的事件框架。epoll中的读、写、关闭、连接都转化成了事件,然后利用epoll的多路复用特性,绝不在io上浪费一点时间。

2、Redis为什么能支持高并发?

Redis 之所以能支持高并发(通常能达到每秒数万甚至数十万次操作),是其架构设计和实现细节上多方面优化的结果,核心原因如下:

1、纯内存操作:‌

关键点:‌ Redis 将主要数据存储在‌内存(RAM)‌中。

优势:‌ 内存的访问速度(纳秒级)远高于磁盘(毫秒级)。几乎所有的数据读写操作都在内存中进行,避免了磁盘 I/O 这个传统数据库的最大瓶颈,使得单个操作的响应时间极短。

高并发基础:‌ 快速的单次操作为处理大量并发请求提供了最根本的基础。

2、单线程事件循环模型(核心工作线程):‌

关键点:‌ Redis 使用一个‌单线程‌来处理所有的‌网络 I/O 请求和核心数据操作‌(读写内存数据结构)。

优势:‌

  • 避免锁竞争:‌ 单线程操作内存数据结构,完全‌避免了多线程环境下复杂的锁竞争和上下文切换开销‌。锁竞争和上下文切换在多线程高并发时会消耗大量 CPU 资源并显著增加延迟。
  • 简单高效:‌ 模型设计简单,没有线程同步的复杂性,代码执行路径清晰高效。
  • 顺序性:‌ 所有命令被‌串行化‌执行,保证了操作的原子性(单个命令本身是原子的),简化了并发控制逻辑。

误解澄清:‌ 很多人认为单线程不能利用多核 CPU。但 Redis 的高性能恰恰证明了在‌内存操作是瓶颈‌的场景下,避免锁竞争带来的收益远大于利用多核。Redis 6.0 引入了多线程 I/O处理客户端请求,但核心命令执行仍然是单线程。

3、高效的非阻塞 I/O 多路复用:‌

关键点:‌ Redis 使用基于 epoll (Linux)、kqueue (BSD/macOS) 或 select 的 ‌I/O 多路复用‌技术。

优势:‌

  • 单线程处理大量连接:‌ 单个线程可以同时监听和管理‌成千上万的网络连接‌。
  • 非阻塞:‌ 当没有事件(可读/可写)发生时,线程不会阻塞在某个连接上,而是去处理其他就绪的连接。只有当连接有数据到达或可以发送数据时,线程才会被唤醒处理。
  • 最大化 CPU 利用率:‌ 避免了为每个连接创建线程的开销和线程阻塞/唤醒的代价,使得单个 CPU 核心能够以极高的效率处理海量并发连接。

4、优化的数据结构:‌

关键点:‌ Redis 提供了丰富的数据结构(String, Hash, List, Set, Sorted Set, Bitmap, HyperLogLog, Streams 等),并且这些数据结构在内存中的实现都经过了‌极致的优化‌。

优势:‌

  • 时间复杂度低:‌ 大部分常用操作(如哈希表查找 HGET, 列表头尾操作 LPUSH/RPOP, 集合交集 SINTER)的时间复杂度都是 ‌O(1)‌ 或 ‌O(log N)‌,执行速度非常快。
  • 内存效率:‌ 针对不同场景和使用模式,Redis 内部使用多种编码方式(如 ziplist, intset, quicklist, skiplist, dict)来‌节省内存‌并‌提高访问速度‌。更少的内存占用意味着更多的数据可以留在快速的内存中,同时减少内存分配/回收的开销。

5、其他优化与机制:‌

Pipeline:‌ 客户端可以将多个命令打包成一个批次发送给 Redis,服务器按顺序执行后一次性返回所有结果。这‌显著减少了网络往返次数(RTT)‌ 和 socket I/O 开销,是提升吞吐量的重要手段。

Lua 脚本:‌ 将多个操作封装在一个原子性的 Lua 脚本中执行,减少了多次网络通信的开销,保证了复杂操作的原子性。

高效的协议:‌ Redis 使用简单文本协议(RESP),易于解析且节省带宽。

合理的内存管理:‌ 通过配置合理的 maxmemory 策略(LRU, LFU, TTL, Random)和内存回收机制,避免内存无限增长导致性能下降或 OOM。

主从复制/集群:‌ 虽然单个 Redis 实例性能已经很高,但通过主从复制(读写分离)和 Redis Cluster(分片)可以‌水平扩展‌读写能力和存储容量,进一步支撑更高的并发量和更大的数据集。

总结:‌

Redis 的高并发能力是‌内存速度优势‌、‌避免锁竞争的单线程核心‌、‌高效处理海量连接的多路复用 I/O‌ 以及‌精心优化的数据结构和协议‌共同作用的结果。它以牺牲部分强持久性和复杂查询功能为代价(主要依赖内存),换取了极致的读写速度和极高的并发吞吐量,使其成为缓存、会话存储、排行榜、消息队列(Streams)等需要快速响应的场景的理想选择。

简单记忆:内存快 + 单线程无锁 + 多路复用 + 数据结构优 = 高并发。


 

二、redis的数据结构

Redis是一个高性能的key-value数据库,key是字符串类型的,value支持string(字符串)、list(双向链表)、hash(哈希)、set(集合)、zset(sorted set --有序集合)五种数据结构。

1、string

字符串类型可以是简单的字符串、复杂的字符串(xml、json)、数字(整数、浮点数)、二进制(图片、音频、视频), 但最大不能超过512M。

常用命令:(参考:Redis系列教材 (三)- 常见命令

SET key value 设置key=value

GET key 获得键key对应的值

GETRANGE key start end 得到字符串的子字符串存放在一个键

GETSET key value 设置键的字符串值,并返回旧值

GETBIT key offset 返回存储在键位值的字符串值的偏移

MGET key1 [key2..] 得到所有的给定键的值

SETBIT key offset value 给偏移量offset位置上设置value,value只能取值0和1

SETEX key seconds value 键到期时设置值

SETNX key value 设置键的值,只有当该键不存在

SETRANGE key offset value 覆盖字符串的一部分从指定键的偏移

STRLEN key 得到存储在键的值的长度

MSET key value [key value...] 设置多个键和多个值

MSETNX key value [key value...] 设置多个键多个值,只有在当没有按键的存在时

PSETEX key milliseconds value 设置键的毫秒值和到期时间

INCR key 增加键的整数值一次

INCRBY key increment 由给定的数量递增键的整数值

INCRBYFLOAT key increment 由给定的数量递增键的浮点值

DECR key 递减键一次的整数值

DECRBY key decrement 由给定数目递减键的整数值

APPEND key value 追加值到一个键

DEL key 如果存在删除键

DUMP key 返回存储在指定键的值的序列化版本

EXISTS key 此命令检查该键是否存在

EXPIRE key seconds 指定键的过期时间

EXPIREAT key timestamp 指定的键过期时间。在这里,时间是在Unix时间戳格式

PEXPIRE key milliseconds 设置键以毫秒为单位到期

PEXPIREAT key milliseconds-timestamp 设置键在Unix时间戳指定为毫秒到期

KEYS pattern 查找与指定模式匹配的所有键

MOVE key db 移动键到另一个数据库

PERSIST key 移除过期的键

PTTL key 以毫秒为单位获取剩余时间的到期键。

TTL key 获取键到期的剩余时间。

RANDOMKEY 从Redis返回随机键

RENAME key newkey 更改键的名称

RENAMENX key newkey 重命名键,如果新的键不存在

TYPE key 返回存储在键的数据类型的值。

使用场景:用作计数器(如微博数,关注的其他用户数量,粉丝数等),可以使用bitmap(位图)统计用户某段时间登录次数等。

SETBIT key offset value 给偏移量offset位置上设置value,value只能取值0和1,offset类似于数组下标,取值从0开始,从左到右依次递增。比如setbit key 2 1表示将下标为2的位置设置为1,即0 0 1 0 0 0 0 0。

2、list

redis的list类型其实就是每个元素都是String类型的双向链表。我们可以从链表的头部和尾部添加或者删除元素。这样的List既可以作为栈,也可以作为队列使用。

常用命令:(参考:Redis系列教材 (三)- 常见命令

BLPOP key1 [key2 ] timeout 取出并获取列表中的第一个元素,或阻塞,直到有可用

BRPOP key1 [key2 ] timeout 取出并获取列表中的最后一个元素,或阻塞,直到有可用

BRPOPLPUSH source destination timeout 从列表中弹出一个值,它推到另一个列表并返回它;或阻塞,直到有可用

LINDEX key index 从一个列表其索引获取对应的元素

LINSERT key BEFORE|AFTER pivot value 在列表中的其他元素之后或之前插入一个元素

LLEN key 获取列表的长度

LPOP key 获取并取出列表中的第一个元素

LPUSH key value1 [value2] 在前面加上一个或多个值的列表

LPUSHX key value 在前面加上一个值列表,仅当列表中存在

LRANGE key start stop 从一个列表获取各种元素

LREM key count value 从列表中删除元素

LSET key index value 在列表中的索引设置一个元素的值

LTRIM key start stop 修剪列表到指定的范围内

RPOP key 取出并获取列表中的最后一个元素

RPOPLPUSH source destination 删除最后一个元素的列表,将其附加到另一个列表并返回它

RPUSH key value1 [value2] 添加一个或多个值到列表

RPUSHX key value 添加一个值列表,仅当列表中存在

使用场景:如好友队列、粉丝队列、消息队列、最新消息排行等。另外可以通过lrange命令,就是从某个元素开始读取多少个元素,可以基于list实现分页查询,这是很棒的一个功能,基于redis实现简单的高性能分页,可以做类似微博那种下拉不断分页的东西(一页一页的往下走),性能高。

3、hash

Hash是一个String类型的field和value之间的映射表,即redis的hash数据类型key(hash表名称)对应的value实际的内部存储结构为一个HashMap,因此Hash特别适合存储对象。相当于把一个对象的每个属性存储为String类型,将整个对象存储在hash类型中会占用更少内存。

当前HashMap的实现有两种方式:当HashMap的成员比较少时Redis为了节省内存会采用类似一维数组的方式来紧凑存储,而不会采用真正的HashMap结构,这时对应的value的redisObject的encoding为zipmap,当成员数量增大时会自动转成真正的Hashmap,此时encoding为ht.

常用命令:

HDEL key field[field...] 删除对象的一个或几个属性域,不存在的属性将被忽略

HEXISTS key field 查看对象是否存在该属性域

HGET key field 获取对象中该field属性域的值

HGETALL key 获取对象的所有属性域和值

HINCRBY key field value 将该对象中指定域的值增加给定的value,原子自增操作,只能是integer的属性值可以使用

HINCRBYFLOAT key field increment 将该对象中指定域的值增加给定的浮点数

HKEYS key 获取对象的所有属性字段

HVALS key 获取对象的所有属性值

HLEN key 获取对象的所有属性字段的总数

HMGET key field[field...] 获取对象的一个或多个指定字段的值

HSET key field value 设置对象指定字段的值

HMSET key field value [field value ...] 同时设置对象中一个或多个字段的值

HSETNX key field value 只在对象不存在指定的字段时才设置字段的值

HSTRLEN key field 返回对象指定field的value的字符串长度,如果该对象或者field不存在,返回0.

HSCAN key cursor [MATCH pattern] [COUNT count] 类似SCAN命令

应用场景:用一个对象来存储用户信息,商品信息,订单信息等等。

4、set

Redis集合(Set类型)是一个无序的String类型数据的集合,类似List的一个列表,与List不同的是Set不能有重复的数据。实际上,Set的内部是用HashMap实现的,Set只用了HashMap的key列来存储对象。

常用命令:(参考:Redis系列教材 (三)- 常见命令

SADD key member [member ...] 添加一个或者多个元素到集合(set)里

SCARD key 获取集合里面的元素数量

SDIFF key [key ...] 从一个集合中获取另一个集合中不存在的元素,取差集

SDIFFSTORE destination key [key ...] 获得队列不存在的元素,并存储在一个关键的结果集

SINTER key [key ...] 获得两个集合的交集

SINTERSTORE destination key [key ...] 获得两个集合的交集,并存储在一个集合中

SISMEMBER key member 确定一个给定的值是一个集合的成员

SMEMBERS key 获取集合里面的所有key

SMOVE source destination member 移动集合里面的一个key到另一个集合

SPOP key [count] 获取并删除一个集合里面的元素

SRANDMEMBER key [count] 从集合里面随机获取一个元素

SREM key member [member ...] 从集合里删除一个或多个元素,不存在的元素会被忽略

SUNION key [key ...] 合并多个set元素,取并集

SUNIONSTORE destination key [key ...] 合并多个set元素,并将结果存入新的set里面

SSCAN key cursor [MATCH pattern] [COUNT count] 迭代set里面的元素

使用场景:集合有取交集、并集、差集等操作,因此可以求共同好友、共同兴趣、分类标签等。SRANDMEMBER或SPOP命令可以从集合里随机获取一个元素,因此可以用于抽奖活动。

5、sorted set

有序集合和集合有着必然的联系,他保留了集合不能有重复成员的特性,但不同的是,有序集合中的元素是可以排序的,但是它和列表使用索引下标作为排序依据不同的是,它给每个元素设置一个分数,作为排序的依据。(有序集合中的元素不可以重复,但是score可以重复,就和一个班里的同学学号不能重复,但考试成绩可以相同)。

常用命令:

ZADD key score1 member1 [score2 member2] 添加一个或多个成员到有序集合,或者如果它已经存在更新其分数

ZCARD key 得到的有序集合成员的数量

ZCOUNT key min max 计算一个有序集合成员与给定值范围内的分数

ZINCRBY key increment member 在有序集合增加成员的分数

ZINTERSTORE destination numkeys key [key ...] 多重交叉排序集合,并存储生成一个新的键有序集合。

ZLEXCOUNT key min max 计算一个给定的字典范围之间的有序集合成员的数量

ZRANGE key start stop [WITHSCORES] 由索引返回一个成员范围的有序集合(从低到高)

ZRANGEBYLEX key min max [LIMIT offset count]返回一个成员范围的有序集合(由字典范围)

ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT] 返回有序集key中,所有 score 值介于 min 和 max 之间(包括等于 min 或 max )的成员,有序集成员按 score 值递增(从小到大)次序排列

ZRANK key member 确定成员的索引中有序集合

ZREM key member [member ...] 从有序集合中删除一个或多个成员,不存在的成员将被忽略

ZREMRANGEBYLEX key min max 删除所有成员在给定的字典范围之间的有序集合

ZREMRANGEBYRANK key start stop 在给定的索引之内删除所有成员的有序集合

ZREMRANGEBYSCORE key min max 在给定的分数之内删除所有成员的有序集合

ZREVRANGE key start stop [WITHSCORES] 返回一个成员范围的有序集合,通过索引,以分数排序,从高分到低分

ZREVRANGEBYSCORE key max min [WITHSCORES] 返回一个成员范围的有序集合,以socre排序从高到低

ZREVRANK key member 确定一个有序集合成员的索引,以分数排序,从高分到低分

ZSCORE key member 获取给定成员相关联的分数在一个有序集合

ZUNIONSTORE destination numkeys key [key ...] 添加多个集排序,所得排序集合存储在一个新的键

ZSCAN key cursor [MATCH pattern] [COUNT count] 增量迭代排序元素集和相关的分数

使用场景:排行榜:有序集合经典使用场景。例如视频网站需要对用户上传的视频做排行榜,榜单维护可能是多方面:按照时间、按照播放量、按照获得的赞数等。在直播系统中,实时排行信息包含直播间在线用户列表,各种礼物排行榜,弹幕消息(可以理解为按消息维度的消息排行榜)等信息,适合使用Redis中的Sorted Set结构进行存储。

  • 排行榜应用‌:利用ZSet的分数排序特性,可实现新闻点击排行榜、商品销量排名等场景,通过ZREVRANGE获取指定范围的高分元素。
  • ‌时间序列数据处理‌:结合分数与时间戳,可实现七日搜索热点统计、用户行为时间窗口分析等。
  • ‌唯一性约束场景‌:当需要同时保证元素唯一性和有序性时(如去重后的排序列表),ZSet是高效选择。

三、有序集合ZSet的底层原理

zset中的每个元素包含数据本身和一个对应的分数(score)。ZSet 是有序的、自动去重的集合数据类型,ZSet 数据结构底层实现为压缩表ziplist或跳表skiplist+哈希表dict,zset的数据本身不允许重复,但是score允许重复。

当元素数量小于128且每个元素长度小于64字节时,ZSet底层使用紧凑的连续存储结构ziplist以减少内存开销,否则使用skiplist。

ZSet的编码可以是 ziplist 或者 skiplist,符合条件时可发生编码转换。

1ziplist(压缩列表)实现原理

ziplist是Redis为节省内存设计的一种紧凑型数据结构,采用连续内存存储方式,主要应用于元素数量较少且元素较小的场景。

使用ziplist的条件:

  • 有序集合保存的元素数量小于128个
  • 有序集合保存的所有元素的长度小于64字节

这两个数值可以通过redis.conf的zset-max-ziplist-entries 和 zset-max-ziplist-value选项进行修改。

zset-max-ziplist-entries 128 // 元素个数超过128 ,将用skiplist编码

zset-max-ziplist-value 64 // 单个元素大小超过 64 byte, 将用 skiplist编码

数据少时,并且每个元素要么是小整数要么是长度较小的字符串时使用ziplist。

ziplist占用连续内存,每项元素都是(数据+score)的方式连续存储,按照score从小到大排序。ziplist为了节省内存,每个元素占用的空间可以不同,对于大的数据(long),就多用一些字节来存储,而对于小的数据(short),就少用一些字节来存储。因此查找的时候需要按顺序遍历。ziplist省内存但是查找效率低。

ziplist的基本结构主要包括以下几个字段:

‌zlbytes‌:4字节,记录整个ziplist占用的内存字节数

‌zltail‌:4字节,记录最后一个entry的偏移量,便于反向遍历

‌zllen‌:2字节,记录entry节点数量(当超过65535时需遍历获取)

‌entry‌:不定长,存储实际数据

‌zlend‌:1字节固定值0xFF,标识ziplist结束

每个节点‌entry‌由三部分组成:prevlength、encoding、data。

previous_entry_length:前一个entry的长度(1或5字节)

encoding:内容编码(1/2/5字节)

content:实际数据

向ZSet中添加元素的命令是:ZADD key score1 member1 [score2 member2] ,添加一个或多个成员到有序集合,或者如果它已经存在更新其分数。

ziplist作为zset的底层存储结构时候,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个节点保存元素的分值,按照分值升序排列,当分值相同时,节点按照成员对象的大小进行排序

2、skiplist(跳表)实现原理

当ZSet不满足使用ziplist的条件时,ZSet底层使用skiplist(跳表)+dict(哈希表)来实现。

typedef struct zset{
     zskiplist *zsl;       // 跳跃表
     dict *dict;         // 字典
} zset;
  • 跳跃表按分值排序成员,用于支持平均复杂度为 O(log N) 的按分值定位成员操作以及范围操作。跳跃表中的节点按照分值大小升序排序,当分值相同时,节点按照成员对象的大小进行排序。
  • 字典的key为成员,value为分值,用于支持 O(1) 复杂度的按成员查找分值操作。

zset 结构中的 zsl 跳跃表按分值从小到大保存了所有集合元素, 每个跳跃表节点skiplistNode都保存了一个集合元素:跳跃表节点的 object 属性保存了元素的成员,而跳跃表节点的 score 属性则保存了元素的分值,这个分值在跳表中是按照从小到大的顺序排列的。

zskipList在Redis中的运用场景只有一个,那就是作为有序列表zset的底层实现。跳跃表可以保证增、删、查等操作时的时间复杂度为O(logN),这个性能可以与平衡树相媲美,但实现方式上却更加简单,唯一美中不足的就是跳表占用的空间比较大,其实就是一种空间换时间的思想。

/*
 * 跳跃表
 */
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;    // 表头节点和表尾节 
    unsigned long length;  				// 表中节点的数量
    int level;    						// 表中层数最大的节点的层数
} zskiplist;
  • header:指向跳表的头节点,通过这个指针可以直接找到表头,时间复杂度为O(1);
  • tail:指向跳表的尾节点,通过这个指针可以直接找到表尾,时间复杂度为o(1);
  • length:记录跳表的长度,表示整个跳表中有多少个元素,不包括头节点;
  • level:记录当前跳表内,所有节点中层数最大的level。

skiplist跳跃列表中每个节点zskiplistNode的数据格式如下所示,每个节点有保存数据的robj指针,分值score字段,后退指针backward便于回溯,zskiplistLevel的数组保存跳跃列表每层的指针。

/*
 * 跳跃表节点
 */
typedef struct zskiplistNode {
    robj *obj;					      		// 成员对象
    double score;      					// 分值
    struct zskiplistNode *backward;  		    // 后退指针
    struct zskiplistLevel {           		// 层
        struct zskiplistNode *forward;       // 前进指针   
        unsigned int span;        			// 跨度
    } level[];
} zskiplistNode;

其实有序集合ZSet单独使用字典或跳跃表其中一种数据结构都可以实现,但是这里使用两种数据结构组合起来,原因是:

  1. 假如单独使用字典,虽然能以 O(1) 的时间复杂度根据成员查找对应的分值,但是因为字典是以无序的方式来保存集合元素,所以每次进行范围操作的时候都要进行排序;
  2. 假如单独使用跳跃表来实现,虽然能执行范围操作,但是查找分值的操作由O(1)的复杂度变为了O(logN)。

因此Redis使用了两种数据结构来共同实现有序集合。

// 创建zset 数据结构: 字典 + 跳表
robj *createZsetObject(void) {
    zset *zs = zmalloc(sizeof(*zs));
    robj *o;
    // dict用来查询数据到分数的对应关系, 如 zscore 就可以直接根据 元素拿到分值 
    zs->dict = dictCreate(&zsetDictType,NULL);
    
    // skiplist用来根据分数查询数据(可能是范围查找)
    zs->zsl = zslCreate();
    // 设置对象类型 
    o = createObject(OBJ_ZSET,zs);
     // 设置编码类型 
    o->encoding = OBJ_ENCODING_SKIPLIST;
    return o;
}

跳表是基于一条有序双向链表构造的,通过构建索引提高查找效率,用空间换时间,查找方式是从最上面的链表层层往下查找,最后在最底层的链表找到对应的节点。

如下图所示,查找score=40的元素,步骤如下:

  • 从head开始遍历,指针指向L2层的score=10的节点,由于10<40,指针指向score=50的节点,由于50>40,所以从下一层的score=10的节点继续查找。
  • 从L1层的score=10的节点开始,指针指向score=30的节点,由于30<40,指针指向score=50的节点,由于50>40,所以从下一层的score=30的节点继续查找。
  • 依次最终跳到第一层(L0层),第一层是一个双向链表,从L0层的score=30的节点开始向后查找,最终查找到score=40的元素进行返回。

3zset存取数据的过程

以zadd的操作为例进行分析,整个过程如下:

  1. 解析参数得到每个元素及其对应的分值。
  2. 查找key对应的zset是否存在,若不存在则创建。
  3. 如果存储格式是ziplist,在执行添加的过程中需要区分元素存在和不存在两种情况,若存在则直接更新其分数并调整位置以保持有序性;若不存在则直接添加。并且需要考虑元素的长度是否超出限制或实际已有的元素个数是否超过最大限制进而决定是否转为skiplist对象。
  4. 如果存储格式是skiplist,通过dict检查member是否存在,若不存在,则在skiplist中插入新节点,在dict中添加映射;若存在,则更新skiplist节点位置和dict中的score。

查找插入位置的大致过程

Redis的有序集合(ZSet)使用zadd命令添加数据时,会根据当前存储格式(ziplist或skiplist)采用不同的算法确定元素插入位置,以保持集合的有序性。

1ziplist格式的插入位置查找

当ZSet使用ziplist存储时,插入位置的查找过程如下:

  1. 顺序遍历比较‌:从ziplist头部开始逐个比较元素的score值,直到找到第一个score大于等于新元素score的节点。
  2. ‌score相同处理‌:若score相同,则按member的字典序比较,找到合适位置。
  3. ‌内存调整‌:确定位置后,需要移动后续元素腾出空间,插入新元素。
  4. ‌复杂度分析‌:查找过程时间复杂度为O(N),插入过程由于需要移动元素,也是O(N)。

2skiplist格式的插入位置查找

当ZSet使用skiplist存储时,插入位置的查找过程如下:

  1. ‌多层索引搜索‌:从最高层(level)开始,向右比较score值:
    1. 若下一节点score小于目标score,继续向右。
    2. 若下一节点score大于等于目标score,下降一层。
  2. ‌记录搜索路径‌:查找过程中记录每层最后访问的节点(用于后续插入)。
  3. ‌随机层数生成‌:为新节点随机确定层数(1-32层),遵循幂次定律。
  4. ‌复杂度分析‌:查找和插入的时间复杂度均为O(logN)。

ZSet的增删改查大致过程

1、ziplist格式的操作实现

增加元素(zadd)

  1. 检查ziplist是否需要转换(元素数≥128或member长度≥64字节)。
  2. 顺序查找插入位置。
  3. 重新分配内存并移动后续元素。
  4. 插入新元素(包含score和member)。
  5. 更新ziplist头部信息(zlbytes, zltail, zllen)。

删除元素(zrem)

  1. 顺序查找目标元素。
  2. 移除元素并合并前后空间。
  3. 调整后续元素的previous_entry_length。
  4. 更新ziplist头部信息(zlbytes, zltail, zllen)。

修改元素(zincrby)

  1. 查找目标元素。
  2. 删除旧元素。
  3. 插入新score的元素。
  4. 若导致存储格式转换,则转为skiplist。

查询操作

  1. ZRANGE:顺序遍历获取范围内的元素。
  2. ZSCORE:根据member从dict中快速获取对应的score。
  3. ZRANK:顺序统计小于指定member的元素数量。

2、skiplist格式的操作实现

增加元素(zadd)

  1. 通过dict检查member是否存在。
  2. 若存在则更新score并调整位置。
  3. 若不存在,随机生成节点层数。
  4. 沿搜索路径在各层插入新节点。
  5. 更新span值。
  6. 在dict中添加member->score映射。

删除元素(zrem)

  1. 通过dict快速定位member。
  2. 查找节点并记录搜索路径。
  3. 在各层链表中移除节点。
  4. 调整相关节点的span值。
  5. 从dict中删除映射。

修改元素(zincrby)

  1. 通过dict找到member和旧score。
  2. 删除旧节点。
  3. 插入新score的节点。
  4. 更新dict中的score值。

查询操作

  1. ZRANGE:从起点开始沿底层链表遍历。
  2. ZSCORE:通过dict根据member快速得到score,时间复杂度是(O(1))。
  3. ZRANK:从顶层开始查找,累加span值确定排名。
  4. ZRANGEBYSCORE:利用跳表特性快速定位范围起点。

zadd添加元素底层源码如下所示:

void zaddGenericCommand(redisClient *c, int incr) {

    static char *nanerr = "resulting score is not a number (NaN)";

    robj *key = c->argv[1];
    robj *ele;
    robj *zobj;
    robj *curobj;
    double score = 0, *scores = NULL, curscore = 0.0;
    int j, elements = (c->argc-2)/2;
    int added = 0, updated = 0;

    // 输入的 score - member 参数必须是成对出现的
    if (c->argc % 2) {
        addReply(c,shared.syntaxerr);
        return;
    }

    // 取出所有输入的 score 分值
    scores = zmalloc(sizeof(double)*elements);
    for (j = 0; j < elements; j++) {
        if (getDoubleFromObjectOrReply(c,c->argv[2+j*2],&scores[j],NULL)
            != REDIS_OK) goto cleanup;
    }

    // 取出有序集合对象
    zobj = lookupKeyWrite(c->db,key);
    if (zobj == NULL) {
        // 有序集合不存在,创建新有序集合
        if (server.zset_max_ziplist_entries == 0 ||
            server.zset_max_ziplist_value < sdslen(c->argv[3]->ptr))
        {
            zobj = createZsetObject();
        } else {
            zobj = createZsetZiplistObject();
        }
        // 关联对象到数据库
        dbAdd(c->db,key,zobj);
    } else {
        // 对象存在,检查类型
        if (zobj->type != REDIS_ZSET) {
            addReply(c,shared.wrongtypeerr);
            goto cleanup;
        }
    }

    // 处理所有元素
    for (j = 0; j < elements; j++) {
        score = scores[j];

        // 有序集合为 ziplist 编码
        if (zobj->encoding == REDIS_ENCODING_ZIPLIST) {
            unsigned char *eptr;

            // 查找成员
            ele = c->argv[3+j*2];
            if ((eptr = zzlFind(zobj->ptr,ele,&curscore)) != NULL) {

                // 成员已存在

                // ZINCRYBY 命令时使用
                if (incr) {
                    score += curscore;
                    if (isnan(score)) {
                        addReplyError(c,nanerr);
                        goto cleanup;
                    }
                }

                // 执行 ZINCRYBY 命令时,
                // 或者用户通过 ZADD 修改成员的分值时执行
                if (score != curscore) {
                    // 删除已有元素
                    zobj->ptr = zzlDelete(zobj->ptr,eptr);
                    // 重新插入元素
                    zobj->ptr = zzlInsert(zobj->ptr,ele,score);
                    // 计数器
                    server.dirty++;
                    updated++;
                }
            } else {
                // 元素不存在,直接添加
                zobj->ptr = zzlInsert(zobj->ptr,ele,score);

                // 查看元素的数量,
                // 看是否需要将 ZIPLIST 编码转换为有序集合
                if (zzlLength(zobj->ptr) > server.zset_max_ziplist_entries)
                    zsetConvert(zobj,REDIS_ENCODING_SKIPLIST);

                // 查看新添加元素的长度
                // 看是否需要将 ZIPLIST 编码转换为有序集合
                if (sdslen(ele->ptr) > server.zset_max_ziplist_value)
                    zsetConvert(zobj,REDIS_ENCODING_SKIPLIST);

                server.dirty++;
                added++;
            }

        // 有序集合为 SKIPLIST 编码
        } else if (zobj->encoding == REDIS_ENCODING_SKIPLIST) {
            zset *zs = zobj->ptr;
            zskiplistNode *znode;
            dictEntry *de;

            // 编码对象
            ele = c->argv[3+j*2] = tryObjectEncoding(c->argv[3+j*2]);

            // 查看成员是否存在
            de = dictFind(zs->dict,ele);
            if (de != NULL) {

                // 成员存在

                // 取出成员
                curobj = dictGetKey(de);
                // 取出分值
                curscore = *(double*)dictGetVal(de);

                // ZINCRYBY 时执行
                if (incr) {
                    score += curscore;
                    if (isnan(score)) {
                        addReplyError(c,nanerr);

                        goto cleanup;
                    }
                }

                // 执行 ZINCRYBY 命令时,
                // 或者用户通过 ZADD 修改成员的分值时执行
                if (score != curscore) {
                    // 删除原有元素
                    redisAssertWithInfo(c,curobj,zslDelete(zs->zsl,curscore,curobj));

                    // 重新插入元素
                    znode = zslInsert(zs->zsl,score,curobj);
                    incrRefCount(curobj); /* Re-inserted in skiplist. */

                    // 更新字典的分值指针
                    dictGetVal(de) = &znode->score; /* Update score ptr. */

                    server.dirty++;
                    updated++;
                }
            } else {

                // 元素不存在,直接添加到跳跃表
                znode = zslInsert(zs->zsl,score,ele);
                incrRefCount(ele); /* Inserted in skiplist. */

                // 将元素关联到字典
                redisAssertWithInfo(c,NULL,dictAdd(zs->dict,ele,&znode->score) == DICT_OK);
                incrRefCount(ele); /* Added to dictionary. */

                server.dirty++;
                added++;
            }
        } else {
            redisPanic("Unknown sorted set encoding");
        }
    }

    if (incr) /* ZINCRBY */
        addReplyDouble(c,score);
    else /* ZADD */
        addReplyLongLong(c,added);

cleanup:
    zfree(scores);
    if (added || updated) {
        signalModifiedKey(c->db,key);
        notifyKeyspaceEvent(REDIS_NOTIFY_ZSET,
            incr ? "zincr" : "zadd", key, c->db->id);
    }
}

4zset底层为什么用跳表而非B+

Redis 选择跳表而非 B+ 树的原因,需从内存效率、实现复杂度、读写性能‌三个核心维度对比两者的特性。

1. 内存效率优先

Redis 是‌内存数据库‌,对内存使用效率要求极高。

  • ‌跳表‌:节点仅需存储少量指针(每层链表的指针),内存占用极低;
  • ‌B+ 树‌:节点需存储‌多个键值对‌(叶子节点存储数据)和多层指针(非叶子节点存储索引),内存占用远高于跳表。

对于 Redis 的 ZSET(有序集合),‌数据量较小时‌,跳表的内存优势尤为明显(节点结构更紧凑,无需存储大量键值对)。

2. 实现与维护的简单性

Redis 的设计哲学是「‌简单高效‌」,跳表的实现逻辑更贴合这一原则:

  • ‌跳表‌:无需复杂的树平衡算法(如红黑树的旋转、B+ 树的分裂/合并)。仅需通过「随机生成节点层数」+「简单指针修改」即可完成‌插入、删除‌操作,代码维护成本低。
  • ‌B+ 树‌:节点分裂/合并、指针调整逻辑复杂;且在‌并发场景‌下,需额外设计复杂的锁机制保证多线程安全,维护成本高。

3. 读写性能的平衡

跳表在「插入/删除」和「范围查询」的性能表现,更适配 Redis 的高并发场景:

‌插入/删除操作‌:

  • 跳表:仅需修改‌局部指针‌(无需全局结构调整),平均时间复杂度为 O(logn);
  • B+ 树:插入/删除可能触发‌节点分裂/合并‌,导致额外计算和内存开销(如分配新节点、调整指针链),高并发下性能可能下降。

‌范围查询‌:

  • 跳表:需遍历链表,但‌多层索引可加速起点定位‌(通过高层索引快速跳到目标范围);
  • B+ 树:通过「叶子节点指针顺序访问」效率更高,但 Redis 的 ZSET 需结合‌哈希表快速查找元素‌,跳表的「多层链表 + 索引」组合结构已足够高效(兼顾插入/删除与范围查询)。

综上,跳表在‌内存占用、实现复杂度、读写性能平衡‌上,更契合 Redis 作为内存数据库的特性需求,因此被选为 ZSET 的底层数据结构。

特性

跳表(SkipList)

B+树

‌实现复杂度‌

简单(仅维护多层指针)

复杂(需处理节点分裂合并)

‌插入/删除效率‌

O(logN),只需调整局部指针

O(logN),但需平衡操作,代价更高

‌范围查询‌

高效(定位后顺序遍历)

高效(叶子节点链表)

‌内存利用率‌

较高(动态节点大小)

较低(固定节点大小可能浪费空间)

‌磁盘友好性‌

差(节点大小不固定)

优(节点对齐磁盘页)

‌适用场景‌

内存数据库、高频更新

磁盘数据库、读密集型

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值