Redis为什么快
- Redis是基于内存,内存本身要比常规磁盘访问快。
- 数据结构简单。
- 采用单线程,省去了上下文切换,不存在竞争,不用考虑各种锁问题。
- 基于IO多路由复用机制机制,同时用来监听多个socket连接,处理高并发网络请求。
Redis如何使用IO多路由复用
Redis 使用一个事件驱动的架构来处理所有客户端连接,基于 epoll实现了自己的事件分发器
- 事件注册:当客户端连接到 Redis,服务器会把这个 socket 注册到
epoll,监听其读写事件。 - 事件监听:Redis 的主线程进入事件循环,不断调用
epoll_wait等函数阻塞等待事件发生。 - 事件触发:某个 socket 有可读/可写事件,
epoll通知 Redis。 - 事件处理:Redis 根据事件类型(读、写)调用相应的回调函数,例如读取客户端命令、写入响应等。
IO 多路复用是一种通过一个线程同时监听多个文件描述符(socket 连接)是否可读/可写的机制。它本质上是事 件驱动:当某个描述符上有事件发生(如有数据可读),通知主程序处理。
epoll(Linux 特有):Redis 默认使用这个IO多路复用技术,效率高,支持事件通知机制,不会随着连接数增多而性能下降。
Redis的单线程
为什么使用单线程?
官方答案是:因为CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章地采用单线程的方案了。
我们使用单线程的方式是无法发挥多核CPU 性能,不过我们可以通过在单机开多个Redis 实例来解决这个问题。
Redis 6.0的多线程
客户端发送请求 --> 多线程读取请求(读 I/O) --> 主线程执行命令 --> 多线程发送响应(写 I/O)
-
为什么要引入多线程
Redis的瓶颈主要是在网络I/O模块带来的CPU耗时,多线程用来处理网络I/O这部分,充分利用CPU资源
-
如何开启多线程
默认时关闭,可以在conf文件中配置开启
io-threads 4 # 启用 4 个 I/O 线程(不包括主线程) io-threads-do-reads yes # 表示开启读阶段的多线程处理(默认仅写是多线程)
Redis数据库设计
Redis使用HashTable管理数据
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2]; // 两个哈希表,ht[0] 主表,ht[1] 为扩容时新表
long rehashidx; // 当前rehash的索引,如果为-1表示没有在rehash
int iterators;
} dict;
typedef struct dictht {
dictEntry **table; // 哈希表数组
unsigned long size; // 哈希表大小
unsigned long sizemask;
unsigned long used; // 当前拥有的键值对总数
} dictht;
通过 hash(key) % hashtable.size的方式,获取当前key在HashTable的位置。
HashTable实际存储的是当前数据指针。
指针指向一个列表,列表中每一个结构为 Entry(k, v, next)。当遇到hash冲突时,Redis通过头插法,将元素添加到这个列表当中。
扩容
-
size——桶(bucket)的数量,用于内部数组空间大小; -
used——实际存储的键值对数量,是dictEntry总和;
当 (DICT_RESIZE_ENABLE && dict.used >= dict.size) || (!DICT_RESIZE_FORBID && dict.used / dict.size > 安全阈值) 时,
就会触发扩容。扩容会直接创建一个原来长度2倍的HashTable
扩容时采用渐进式rehash
- 每次访问HashTable时,从前向后,迁移HashTable有值的第一个
- 后台轮询迁移,每次默认迁移100个
当扩容时,get操作会先去旧HashTable访问,如果没有,则去访问新的HashTable;set操作会直接讲数据添加到新的HashTable。
问题:如果 rehash 还没完成,ht[1] 已满了怎么办?
- 假设 rehash 正在进行中,
ht[1]已经存在。 - 如果
ht[1]由于大量新写入(插入),导致负载因子过高,也到达扩容阈值。 - 扩容限制:在rehash完成前,不会触发二次扩容(见
_dictExpandIfNeeded中的dictIsRehashing判断)。
Redis key
Redis的key类型均为String。
在set时,Redis的key可以为任意类型,在Redis service都会将其转为String类型
redisObject
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;
- type:表示 Redis 对象类型,由调用的API决定。
- encoding:根据value决定不同的存储类型,当数据发生变化时,Redis会将其变为合适的编码结构。
- lru: 存储与 Redis 的 缓存淘汰策略 (LRU 或 LFU) 相关的信息。
- LRU:存储的是一个 相对时间戳,淘汰时,Redis 比较所有对象的
lru值,淘汰距离当前lru_clock最远的 (即最久未被访问的) 对象。 - LFU:
- 低 8 位 (Least Significant Bits - LSB): 存储一个 访问频率计数器。这是一个基于概率的计数器,值越大表示访问越频繁。它不是精确计数,并且会随着时间衰减。
- 高 16 位 (Most Significant Bits - MSB): 存储一个 最近一次访问的时间戳 (单位是分钟,相对于某个起点)。这个时间戳用于辅助频率计数器的衰减。如果一个对象很久没被访问,即使它曾经访问频繁,其频率计数也会逐渐降低。
- LRU:存储的是一个 相对时间戳,淘汰时,Redis 比较所有对象的
- refcount:引用计数,Redis 自动内存回收 (垃圾收集) 的基础。它确保不再被使用的对象能够被及时释放。
- *ptr:指向实际数据的指针
- 当value小于等于20时,会尝试将其转为long,若转化成功,则将value直接存放到这个位置。
- 为什么是long?因为64位操作系统中,long和指针所用内存都为8byte,可以减少内存IO。
- 为什么小于等于20?因为64位操作系统中,最大整数位19位,加上符号位为20位。
- 当value小于等于20时,会尝试将其转为long,若转化成功,则将value直接存放到这个位置。
embstr 优秀的内存利用
在 Redis 中,当我们存储的值为字符串类型(string)时,其编码方式依赖于字符串的长度:当长度小于等于 44 字节时,采用 embstr 编码;超过 44 字节,则使用 raw 编码。
这是基于对 CPU 缓存机制的优化考虑。CPU 读取内存时,通常以缓存行为单位进行操作,最小读取单位为 64 字节。而一个 redisObject 结构本身仅占用 16 字节,剩余的 48 字节在传统实现中将被浪费。为提高内存利用率,Redis 设计了 embstr 编码策略,巧妙地利用这 48 字节空间。
这 48 字节空间用于存放字符串的实际内容。在 embstr 编码中,Redis 使用 sdshdr8 结构来保存字符串信息,其中 sdshdr8 本身占用 4 字节,剩余的 44 字节则可用于存放字符串数据。
基本数据类型 - String
为什么不使用c语言当中的String?
- C字符串因为是以
\0空字符作为字符串结束标志,所以只能用来保存文本数据,而不能保存图片、音频、视频这样的二进制数据。 - 而SDS是通过
len属性的值来判断字符串结束,所以SDS是二进制安全的,可以用来保存任意格式的二进制数据。
SDS内部结构
Redis自定义了一种String类型:SDS「simple dynamic string」
-
内存布局
在64位系统下,字段len和字段free各占4个字节,紧接着存放字符串。
+-----------------+ | len 4b | +-----------------+ | free 4b | +-----------------+ | buf content... | | '\0' terminator | +-----------------+ -
结构
// 3.2之前的结构 struct sdshdr { // buf数组中已使用字节数量,即SDS所保存字符串的长度 unsigned int len; // buf数组中未使用的字节数量 unsigned int free; // 字节数组,真正保存字符串的数据空间 char buf[]; };free属性:减少修改字符串时带来的内存重新分配
多种SDS类型(Redis 3.2+)
SDS的总体结构包括头部head和存储用户数据的buf,其中用户数据后总跟着一个\0。为了节省内存,Redis 引入不同大小的 header 类型:sdshdr5/8/16/32/64
详情如:
-
sdshdr5仅 1 char flags描述长度,低3位为类型,高5位存长度(≤31),不存 free,适合极短字符串-
内存布局
+-------------------------------+ | flags (1B: 3位类型+5位len) | +-------------------------------+ | buf content... | | '\0' terminator | +-------------------------------+ -
结构
struct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; /* 3 lsb of type, and 5 msb of string length */ char buf[]; }; -
-
sdshdr8、sdshdr16、sdshdr32和sdshdr64的结构相同,只不过flags的高5位将不会填充数据。以
sdshdr16为例-
内存布局
+-----------------+ | len: uint16 2B | | alloc: uint16 2B| | flags: char 1B | +-----------------+ | buf content... | | '\0' terminator | +-----------------+ -
结构
struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* 已经使用的长度 */ uint8_t alloc; /* 总长度 */ unsigned char flags; /* 低3位存储类型,高5位预留 */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; /* used */ uint16_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len; /* used */ uint32_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; /* used */ uint64_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; -
字节对齐
sdshdr使用了__attribute__ ((__packed__))做修饰,修饰后按1字节对齐,注意buf是个char类型的柔性数组,地址连续,始终在flags之后。
基本数据类型 - List
在 Redis 3.2 之前,list 的底层实现是 linkedlist 或 ziplist,优先使用 ziplist,当元素数量「默认 512」或者单个元素大小「默认 64 字节」超过阈值,转为 linkedlist。
linkedlist:插入/删除快,但内存开销大(每个节点要存指针,64 位系统上一个节点元数据就几十字节)。ziplist:连续内存存储,节省空间,但过大时会导致一次性 realloc(内存拷贝),插入/删除性能差。
自Redis 3.2起,List的底层核心是quicklist,它结合了ziplist和传统双向双向链表的优势
quicklist
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; /* total count of all entries in all ziplists */
unsigned long len; /* number of quicklistNodes */
int fill : QL_FILL_BITS; /* fill factor for individual nodes */
unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
head/tail: 指向双向链表首尾节点的指针。count: 整个 List 包含的元素总数 (所有 ziplist entry 之和),LLEN命令直接返回此值,复杂度O(1)。len:quicklistNode节点的数量。fill: 控制单个 ziplist 的容量上限。值来自配置list-max-ziplist-size:compress: 控制压缩深度。值来自配置list-compress-depth:0: 不压缩 (默认)。n: 压缩列表 头尾各n个节点 以外的所有节点。因为头尾节点访问频繁,压缩它们收益低且增加 CPU 开销。
bookmarks: 用于大型列表遍历的辅助结构 (较少使用)。
quicklistNode
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl;
unsigned int sz; /* ziplist size in bytes */
unsigned int count : 16; /* count of items in ziplist */
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
unsigned int recompress : 1; /* was this node previous compressed? */
unsigned int attempted_compress : 1; /* node can't compress; too small */
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
prev/next: 构成双向链表。zl: 核心数据指针:- 如果
encoding = RAW (1): 指向一个ziplist。 - 如果
encoding = LZF (2): 指向一个quicklistLZF结构 (内含压缩后的数据)。
- 如果
sz: 当前节点zl指向的数据结构占用的总字节数 (无论压缩与否)。count: 该节点内ziplist包含的entry个数 (O(1)获取节点内元素数)。encoding: 标识该节点的数据是原始ziplist还是压缩后的LZF数据。container: 固定为ZIPLIST (2),表示zl指向的是 ziplist (或其压缩形式)。预留了NONE类型供未来扩展。recompress: 如果节点是压缩的 (LZF),当需要访问其内容时,Redis 会临时解压它。设置此标志表示该节点是临时解压的,后续需要重新压缩。attempted_compress: 表示该节点曾尝试压缩但太小不值得压缩。
ziplist
ziplist是quicklistNode当中真正存储元素的结构
其内存结构如下
<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
<uint32_t zlbytes>是一个无符号整数,用于保存 ziplist 占用的字节数,包括 zlbytes 字段本身的 4个字节。需要存储此值以便能够在不先遍历整个结构的情况下调整其大小。<uint32_t zltail>是列表中最后一个条目的偏移量。这使得在列表的远端执行弹出操作时无需进行完整遍历。<uint16_t zllen>表示条目数量。当条目数量超过 2^16 - 2 时,此值将被设置为 2^16 - 1,此时我们需要遍历整个列表才能知道它包含多少项。<uint8_t zlend>是一个特殊的条目,表示 ziplist 的结束。它被编码为一个值为 255 的单字节。没有其他正常条目以值为 255 的字节开头。
ziplist entries内存结构如下
<prevlen from 1 to 5 bytes> <encoding> <entry-data>
prevlen: 存储 前一个 entry 的总字节长度。这是一个变长字段:- 如果前一个 entry 长度
< 254字节: 用1字节存储。 - 如果前一个 entry 长度
>= 254字节: 用5字节存储 (首字节固定 254 (FE) + 实际长度 4 字节)。
- 如果前一个 entry 长度
encoding: 编码 当前 entry 的数据类型 (整数或字符串) 和长度。- 如果 entry 是字符串,编码的第一个字节的前 2 位表示用于存储字符串长度的编码类型,然后是字符串的实际长度。
- 如果 entry 是整数,编码的第一个字节的前 2 位都设为 1。接下来的 2 位用于指定在这个头部之后将存储哪种类型的整数。
|00pppppp| - 1 字节
字符串值,长度小于或等于 63 字节(6 位)。"pppppp" 表示无符号 6 位长度。
|01pppppp|qqqqqqqq| - 2 字节
字符串值,长度小于或等于 16383 字节(14 位)。14 位数字采用大端序存储。
|10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| - 5 字节
字符串值,长度大于或等于 16384 字节。仅后 4 字节表示长度,最大为 2^32-1。首字节低 6 位未使用,置 0。32 位数字采用大端序存储。
|11000000| - 3 字节
整数,编码为 int16\_t(2 字节)。
|11010000| - 5 字节
整数,编码为 int32\_t(4 字节)。
|11100000| - 9 字节
整数,编码为 int64\_t(8 字节)。
|11110000| - 4 字节
整数,编码为 24 位有符号整数(3 字节)。
|11111110| - 2 字节
整数,编码为 8 位有符号整数(1 字节)。
|1111xxxx|(xxxx 为 0001 到 1101)
立即数 4 位无符号整数,范围 0 到 12。实际编码值为 1 到 13,减 1 得到真实值。
|11111111|
ziplist 结束标志。
listpack
listpack 是 Redis 从 5.x 引入、在 6.0/7.0 广泛替换 ziplist 的一种紧凑线性容器格式,用于在一段连续内存中高效存放一串元素(字符串或整数),支持正反向遍历。
相比 ziplist,listpack 的核心改进是消除了级联更新的最坏情况
级联更新:假设一个
ziplist中有多个连续的、长度刚好在 250-253 字节之间的条目,它们的prevlen都是 1 字节。此时,如果在这个序列的头部插入一个长度大于等于 254 的新条目,那么紧跟着它的下一个条目的prevlen就需要从 1 字节扩展为 5 字节。这个扩展操作本身可能导致该条目自身的总长度也超过 254 字节,进而引发再下一个条目的prevlen也需要扩展……这种连锁反应可能会一直传播下去,导致一次插入操作的平均复杂度从 O(1) 恶化到 O(N)。在最坏情况下,这可能成为一个性能隐患。
其内存结构如下
<lpbytes> <lplen> <entry> <entry> ... <entry> <lpeof>
-
<uint32_t lpbytes>:listpack占用的总字节数(含头与结尾)。 -
<uint16_t lplen>:元素个数。超过可表示范围时饱和为最大值,需要遍历计算真实长度。 -
<uint8_t lpeof>:结尾标记,固定单字节 0xFF。
与 ziplist 不同点:
-
没有
zltail(最后一个元素偏移)。listpack通过每个entry尾部的backlen反向跳转,配合末尾 0xFF 可 O(1) 找到最后一个 entry。 -
不存
prevlen(前一元素长度),而是把“本 entry 总长度”的变长值放在“本 entry 的尾部”,避免 ziplist 的级联更新。
listpack entries内存结构如下
<encoding header> <payload> <backlen>
-
encoding header(1~5 字节):描述当前 entry 是字符串还是整数、以及长度/位宽。- 对于小整数,可能直接用 1 字节的 encoding 同时表示类型和数值。
- 对于短字符串,会用 encoding 的前几位表示类型是字符串,后几位表示字符串的长度。
- 对于大整数或长字符串,encoding 本身可能占用更多字节,并指示需要从后续的多少个字节中读取实际的数据长度。
-
payload:元素实际数据字节(字符串的字节序列,或整数的定长字节)。 -
backlen(1~5 字节):变长整型,表示“当前 entry 的总字节长度”(含header+payload+backlen自身)。-
编码为 7-bit 组的变长数。每个字节低 7 位是数据位,高 1 位为续位标志。
-
最后一个字节的最高位为 0;其左边(更高有效)的字节最高位为 1。这样可以从“后往前”解码,单步得到整条 entry 的起始位置。
-
基本数据类型 - Set
Set是一个 无序且唯一的字符串集合,支持 O(1) 操作(插入、删除、判断成员)。
主要依赖两种底层数据结构:intset(整数集合)和 hashtable(哈希表)。
intset
typedef struct intset {
uint32_t encoding; // 编码方式:INTSET_ENC_INT16, INT32, INT64
uint32_t length; // 元素个数
int8_t contents[]; // 柔性数组,按encoding存储实际整数
} intset;
- 所有整数有序(升序)存储在
contents[]数组中。 encoding决定每个元素的字节大小(2, 4, 或 8 字节)。例如INTSET_ENC_INT16时,contents相当于int16_t[]。
对于小数据集(通常≤512个元素),有序数组的内存效率更好
hashtable
一旦元素类型非整数或数量超过阈值(由 set-max-intset-entries 控制),自动切换为 hashtable,键为 set 元素,值为 NULL,支持全类型字符串
基本数据类型 - Hash
底层采用两种编码方式:ziplist(压缩列表) 和 hashtable(哈希表)。
Redis 根据配置阈值自动切换编码(redis.conf):
// 默认配置
hash-max-ziplist-entries 512 // ziplist 最大元素数量
hash-max-ziplist-value 64 // 单个 field/value 最大字节数
- ziplist → hashtable 触发条件(源码
t_hash.c):- Field-Value 对数量 >
hash-max-ziplist-entries - 任意 field 或 value 长度 >
hash-max-ziplist-value
- Field-Value 对数量 >
基本数据类型 - zset
底层采用:skiplist(跳表) + hashtable(哈希表)+ ziplist/listpack。
小规模使用ziplist,大规模使用hashtable进行 O(1)查找,skiplist维护有序。
zset 中的每个元素(member)都是唯一的(就像 set),但每个元素都关联一个浮点数类型的值,称为 score(分数)。
元素在 zset 内部按照 score 的值从小到大进行排序。
多个不同的元素 (member) 可以拥有相同的 score。当 score 相同时,这些元素会按照它们的 member 字符串在字典序 (lexicographical order) 中的顺序进行排列(默认是升序,a 在 b 前面)。
Redis 为了优化内存使用,对小型的
zset采用了不同的内部编码ziplist,当满足以下任意条件时,切换到skiplist
zset中的 元素数量 >zset-max-ziplist-entries(默认 128)zset中任意一个member的长度 >zset-max-ziplist-value(默认 64 字节)
skiplist
/* 跳跃列表节点 */
typedef struct zskiplistNode {
sds ele; // 存储的元素(字符串对象)
double score; // 元素对应的分数(用于排序)
struct zskiplistNode *backward; // 后向指针(用于从后向前遍历)
struct zskiplistLevel {
struct zskiplistNode *forward; // 同层的下一个节点
unsigned long span; // 到下一个节点的跨度(用于计算排名)
} level[]; // 可变长度数组,实际大小由层数决定
} zskiplistNode;
/* 跳跃列表结构 */
typedef struct zskiplist {
struct zskiplistNode *header, *tail; // 头尾节点
unsigned long length; // 节点总数
int level; // 当前最大层级
} zskiplist;
- 每个节点
zskiplistNode用可变长度数组level[]存储多个层级的“前向指针”和“跨度”(span 用于快速计算元素排名)。 - 跳表本身
zskiplist记录当前最高层数level(最大为 32)以及节点总数与头尾指针。
跳跃表的查找、插入和删除操作
-
查找操作
从最高层索引的头节点开始,
- 如果当前节点的下一个节点的值小于要查找的值,则向右移动;
- 如果当前节点的下一个节点的值大于要查找的值,则向下移动,
- 直到找到目标值或者确定目标值不存在。
-
插入操作: 首先进行查找操作,找到插入位置。然后随机生成一个层数,根据这个层数在每一层插入新节点。
-
删除操作: 首先进行查找操作,找到要删除的节点。然后在每一层删除这个节点。
跳表的平衡性依赖于“随机层数”,由 zslRandomLevel() 决定
#define ZSKIPLIST_MAXLEVEL 32
#define ZSKIPLIST_P 0.25
int zslRandomLevel(void) {
int level = 1;
while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
-
初始层数为 1,每次以概率
P=0.25递增一层,直至达到最大层数 32(足够支持 2^32元素)。 -
这种几何分布保证平均插入、查找、删除复杂度均为 O(log N)。