Day86-88(12-14)-Redis原理
黑马点评
Redis原理
为什么Redis性能好
简单来说,jemalloc是一种高性能的内存分配器(Memory Allocator),而它与Redis的关联所在:jemalloc是Redis在Linux系统下默认的内存管家,是Redis能够保持高性能和低内存碎片的“幕后功臣”。
以下是详细的拆解:
1. 什么是jemalloc?
在Smashing(如C语言)中,程序需要通过malloc申请内存,用后通过free释放。jemalloc就是这些标准接口的一种具体实现。它最初是为FreeBSD操作系统开发的,后来表现得非常出色,被广泛评价Facebook、Mozilla Firefox以及Redis等知名项目中。
其核心设计目标是:
减少内存碎片:需要让物理内存利用率更高。
高性能并发:在多核多线程环境下,减少申请内存时的锁竞争(虽然Redis核心是单线程,但这个特性对其他多线程应用极有帮助)。
2. jemalloc 与 Redis 的高性能关联
Redis 作为一个内存数据库,对内存的操作极为严格。如果使用系统自带的分配器(如 glibc 的 ptmalloc),在长时间运行后会遇到严重的性能瓶颈。jemalloc 带来的优势主要体现在三个方面:
① 极低的内存碎片率(碎片)
Redis最怕的是“内存碎片”:即虽然系统显示还有2GB空间,但都是细碎的小块,无法分配给一个大的Key。
jemalloc 的做法:会将内存划分为不同大小的“槽位”(Bin),比如 8 字节、16 字节、32 字节等。当 Redis 申请空间时,jemalloc 会给它最匹配的大小,从而减少浪费。
结果:Redis 的mem_fragmentation_ratio(碎片率指标)在 jemalloc 下通常能维持在 1.0~1.2 之间,非常健康。
② 内存分配与恢复的速度
Redis 是单线程模型,如果在内存分配时出现阻塞(比如因为锁争抢或复杂的恢复逻辑),整个 Redis 都会卡顿。
jemalloc 的做法:它通过预分配技术(Arena)和精巧的数据结构,使得malloc其free操作几乎是业界级的时间复杂度。
结果:即使在高并发的读写请求下,Redis也能保持亚毫秒级的延迟。
③ 更好的内存管理能力(Active Defrag)
这是Redis 5.0以后的一个“杀手锏”功能:主动内存碎片整理。
关联点:Redis 的主动碎片整理功能是深度绑定jemalloc 的。Redis 会询问 jemalloc:“这个键在物理内存里是不是占了个很尴尬的位置?”如果是,Redis 就重新申请一块连续内存并把数据搬过去。
结果:用户无需重启Redis就可以在运行过程中恢复被浪费的内存。
3.为什么不直接用系统自带的?
大多数 Linux 发行版自带的是glibc (ptmalloc) 。
对比:在处理大量小对象的间隙申请和释放时(这种恐慌是Redis的典型场景),glibc往往会产生较高的内存碎片,且在释放内存给操作系统时不够果断,导致Redis进程占用的物理内存(RSS)居高不下。
数据结构
动态字符串SDS
虽然你的代码(用户态)发起了申请,但真正掌握内存“生杀大权”的只有内核。
1. 为什么一定要切换?(保安与地皮)
你可以把 用户空间 想象成租客,把 内核空间 想象成房东。
• 安全隔离:为了防止某个恶意程序(租客)乱占地盘或者偷看别人的内存,Linux 禁止用户程序直接操作物理内存。
• 统一调度:只有内核知道现在的物理内存还剩多少,该分给谁。
所以,当你调用 malloc(用户态)时,它最终必须通过一个**系统调用(System Call)**去敲内核的门,请求它分配空间。

2. 内存申请的“两步走”策略
实际上,并不是每次 malloc 都会立刻切换到内核态,这里有一层优化:
第一步:用户态的小账本(标准库层)
当你调用 malloc(10k) 时,C 语言的标准库(glibc)会先检查它自己手里有没有之前申请好了还没用的内存。
• 如果有,直接分给你。这时不发生切换,速度极快。
• 如果没有,或者你要的内存太大,它就得进行第二步。
第二步:内核态的大动作(系统调用层)
如果标准库手里没钱了,它会通过以下两个系统调用之一向内核“要地”:
1. brk:用于申请较小的空间,把“堆”的边界往上推。
2. mmap:用于申请大块空间,在内存里找个空位单独划出来。
在这个时刻,CPU 会发生“上下文切换”,从用户态陷入内核态,由内核完成页表的映射。

3. 性能代价:为什么我们要减少切换?
态的切换是有开销的。CPU 需要:
1. 保存当前用户程序的寄存器状态。
2. 切换到内核栈。
3. 执行内核代码,查找空闲物理内存,更新页表。
4. 切回用户态,恢复现场。
这就是为什么 Redis 性能高的原因之一:它通过 jemalloc 等内存分配器,尽量在用户态缓存内存块,减少向内核伸手要钱的次数。

4. 联想你的 Redis 与 LeetCode
• Redis 内存碎片:就是因为内核分给 Redis 的大块地皮里,Redis 自己没规划好(用户态管理不当),导致很多小缝隙用不上。
• LeetCode 代码:你在 Java 里 new HashMap,JVM 实际上已经预先向 Linux 内核申请了一大片空间(堆)。你在代码里反复 put 和 remove,绝大多数时候只是 JVM 在它内部的“小账本”上划线,并不会频繁触发内核态切换。
| 类型 | 长度范围 | 头部大小 | 头部关键字段 |
|---|---|---|---|
sdshdr5 |
0 - 31 字节 | 1 字节 | 唯一flags(高5位存储长度,低3位存储类型) |
sdshdr8 |
32 - 255 字节 | 3 字节 | len(1B),alloc(1B),flags(1B) |
sdshdr16 |
256 - 64 KB | 5 字节 | len(2B),alloc(2B),flags(1B) |
sdshdr32 |
64 KB - 4 GB | 9 字节 | len(4B),alloc(4B),flags(1B) |
sdshdr64 |
4 GB | 17 字节 | len(8B)、alloc(8B)、flags(1B) |
linux系统分为用户空间和内核空间,需要从用户态切换为内核态才能申请内存
IntSet整数数组
虽然intset结构将contents属性声明为int8t类型的数组,但实际上contents数组并不保存任何int8t类型的值,contents数组的真正类型取决于encoding属性的值
encoding和length是uint32_t类型,就是32位无符号整数类型,32位那不就是4字节
encoding这个属性的定义其实是一串32位也就是uint32_t的编码
这32位里面可以表示他是INTSET_ENC_INT16还是INTSET_ENC_INT32还是INTSET_ENC_INT64
我们可以把 encoding 想象成一个**“档位指示灯”**。虽然这个灯只需要表示 3 种状态,但 Redis 专门为它开辟了一个 32 位(4 字节)的巨大灯框。
在 Redis 源码中,这 32 位并不是随机乱写的,而是使用了预定义的常量。虽然它是 uint32_t,但目前它只存储以下三个特定的数值:
INTSET_ENC_INT16:数值为 2(二进制:0000...0010)
INTSET_ENC_INT32:数值为 4(二进制:0000...0100)
INTSET_ENC_INT64:数值为 8(二进制:0000...1000)
极速寻址:就像我们之前说的,4 字节对齐让 encoding 和后面的 length 可以被 CPU 一次性加载。
代码简洁:在 C 语言里,直接操作 uint32_t 类型的变量比操作位(Bit)或者单个字节要快得多,因为这符合 CPU 的天然步长。
留有余地:如果未来需要支持 128 位整数(INTSET_ENC_INT128),只需要在这个 32 位的空间里写个 16 进去就行了,结构体完全不需要改动。
uint32_t 是一种跨平台的类型定义(在 C 语言的 stdint.h 头文件中):
• u: 代表 unsigned(无符号),意味着它不能表示负数,只能从 0 开始。
• int: 代表 integer(整数)。
• 32: 代表 32 个比特位 (Bits)。
• _t: 代表 type(类型后缀)。
计算关系:
所以,当你看到 uint32_t,它在内存里就铁定占 4 个字节。
content有三个,每个2字节,但是5、10和15其实小于65535,应该只需要2个字节(由encoding规定);但是为了方便通过指针寻址映射到物理内存的位置
1. 指针的本质:一个 8 字节的“门牌号”
在 64 位环境下,CPU 的寻址步长是 64 位。为了能指代内存中的任何一个位置,指针必须有足够的位数:
大小:64 bits÷8=8 字节。
类型:它在数值上就是一个 uint64_t(无符号 64 位整数)。
范围:它可以表示从 0 到 264 −1 个地址,理论上可以管理 16 EB 的内存空间(远超目前的物理内存限制)。
Dict字典
普通变量 (int a):宝藏本身(比如 100 块钱)。
• 一级指针 (int *p):一张纸条,上面写着宝藏在“1号房间”。
• **二级指针 (int pp):又一张纸条,上面写着“存着1号房间地址的纸条”在“2号房间”。
变量名 变量类型 内存地址 (虚拟) 存储的内容
a int 0x1000 100 (数据)
p int * 0x2000 0x1000 (a 的地址)
pp int ** 0x3000 0x2000 (p 的地址)
size必须是2的n次方
在 Redis 的源码中,哈希表(dict)初始化的最小尺寸被定义为 DICT_HT_INITIAL_SIZE,其值为 4。
为什么要选 4,而不是 1、2 或者更大的数字(比如 16)?这背后主要有三个维度的考量:
1. 性能与哈希冲突的权衡
哈希表的本质是“空间换时间”。
• 如果设为 1 或 2:容量太小,极其容易发生哈希冲突。一旦两个键(Key)映射到同一个位置,Redis 就必须使用链表法(Entry 连成链表)来解决。这会导致 的查找效率迅速退化为 。
• 如果设为 4:它提供了一个最基本的“分散度”。在存储极少量数据时,它既能保持极低的内存占用,又能大概率让这些数据分布在不同的槽位(Bucket)中,维持 的访问速度。
2. 内存分配的“性价比”
既然你研究过指针和 uint32_t,你一定记得内存对齐。
• 在 64 位系统下,一个 dictEntry *(一级指针)占用 8 字节。
• 初始化大小为 4 的哈希表数组,占用的空间是 字节。
• 32 字节 是现代内存分配器(如 jemalloc)非常喜欢的一个“阶梯尺寸”。分配 8 字节、16 字节或 32 字节的开销几乎是一样的。
• 如果你设为 1,分配器可能还是会给你分 16 或 32 字节的空间(由于内存碎片处理机制)。既然都要花这么多钱,不如直接申请 4 个槽位。
3. 指数级扩容的起点
Redis 的哈希表扩容规律是 2 的 n次幂()。
• 为什么是 2 的幂? 因为在计算索引时,可以使用位运算 (hash & mask) 代替昂贵的取模运算 %。
• 从 4 开始是一个非常稳健的起点。如果从 1 或 2 开始,在数据增长初期会频繁触发 Rehash(扩容重哈希)。频繁的扩容会导致 CPU 在短时间内不断申请内存、搬运指针,产生不必要的抖动。
dictExpand()在三个地方调用:初始化、扩容、收缩
在 Redis 的源码逻辑中,_dictExpand(或内部调用的 dictExpand)是哈希表动态调整大小的核心。它的主要任务是根据当前元素的数量,申请一块新的、大小为 2 n的内存空间,并准备开始搬运数据。
它主要出现在以下三个关键场景:
1. 显式初始化或手动触发
这是最基础的用法。当一个 dict(字典)刚被创建,或者为了性能预热需要提前申请空间时。
场景描述:当字典还是空的(size 为 0),或者调用者明确知道将要存入大量数据时。
具体动作:Redis 会调用 dictExpand 将哈希表从初始状态扩展到目标大小(比如你提到的最小值 4)。
例子:在某些模块(如集群迁移、冷启动加载 RDB 文件)中,为了避免在加载过程中频繁触发自动扩容,系统会预估总量并提前调用一次 dictExpand。
2. 自动扩容 (Key 数量达到阈值)
这是最常见的场景,也是 Redis 保证 O(1) 查找效率的核心。
场景描述:当哈希表中的元素个数(used)已经超过或接近数组长度(size)时,即 负载因子 (Load Factor) ≥1。
扩容策略:
如果当前没有执行 BGSAVE 或 BGREWRITEAOF(没有子进程),负载因子达到 1 就扩容。
如果正在执行子进程,为了利用 Linux 的 写时复制 (Copy On Write) 机制节省内存,Redis 会尽量不触发扩容,除非负载因子达到 5。
结果:调用 dictExpand 申请一个 不小于 used*2 的最小 2 n的新表(ht[1])。
3. 内存收缩 (缩容)
为了不浪费内存,当你的 Redis 删除了大量数据后,占用的空间需要还给系统。
场景描述:当哈希表里的元素变得非常稀疏时,即 负载因子 <0.1(填充率不到 10%)。
具体动作:Redis 的定时任务(Cron)或显式的缩容指令会检测到这个状态,调用 dictExpand。
结果:此时的 dictExpand 实际上是 “反向操作”。它会申请一个更小的空间(同样是 2 n),并将数据迁移过去,从而释放掉原本巨大的数组占用的内存。
渐进式的rehash
Rehash 的触发和搬运可以分为**“被动触发”和“主动触发”**两种模式。
1. 被动触发:操作数据时“顺便”搬运(你提到的点)
当你对某个具体的 Key 进行操作时,Redis 会发现当前正处于 Rehash 状态,于是它会“搭便车”执行搬运。
• 触发动作:执行 HSET、HGET、HDEL 等任何涉及该哈希表的命令。
• 搬运量:主线程每处理一个命令,就顺手把 ht[0] 中当前 rehashidx 索引下的**一个桶(Bucket)**搬到 ht[1]。
• 逻辑:这叫“分摊开销”。谁用这块内存,谁就贡献一点搬运的 CPU 时间。
2. 主动触发:没人访问时“定时”搬运(保险机制)
如果你的 Redis 里的数据现在没人访问(比如是深夜),难道 Rehash 就停在那里不动了吗? 不会。 为了防止 Rehash 过程拖得太久占用两份内存,Redis 还有一个后台保底机制。
• 触发者:serverCron(Redis 的时间事件处理函数)。
• 频率:默认每秒执行 10 次(每 100 ms 一次)。
• 动作:如果发现有字典正在 Rehash,主线程会专门拨出一段** CPU 毫秒数(通常是 1ms)**,全速进行搬运。
• 逻辑:这叫“主动推进”。即使没有用户请求,也要保证 Rehash 尽快完成。
3. 特殊情况:什么时候会“不得不”操作数据?
其实还有一个地方会触发 Rehash 的开始(注意是开始,不是搬运过程):
• 扩容检查:每当你执行 HSET 新增数据时,Redis 都会检查一下:
“现在的元素数量是不是已经超过桶的数量了?”
• 如果超过了,它会立刻执行 dictExpand(申请新内存,把 rehashidx 从 -1 改为 0)。
• 此时 Rehash 才正式开启,后续的查询命令才会开始执行前面提到的“搬运”动作。
所以就hset这些命令触发一次就迁移一个bucket,迁移bucket的序号从零开始,和当次命令操作的bucket的序号无关,而是通过一个rehashidx来维护
1. 为什么不搬运“当次操作”的桶?
如果 Redis 每次只搬运当前命令操作的那个桶,会面临两个大问题:
• 不确定性:有些桶可能被频繁访问,有些桶可能永远没人访问。如果不按顺序搬,那些“冷门”桶里的数据就永远搬不过去,旧表 ht[0] 也就永远无法释放。
• 效率低下:维护一个“哪些桶搬过、哪些没搬”的复杂位图,远比维护一个简单的自增数字 rehashidx 要浪费内存和 CPU。
2. rehashidx 的变迁过程
在内存中,它的变化逻辑是这样的:
• 准备阶段:rehashidx = -1(表示目前没有 Rehash)。
• 启动阶段:当负载因子过高,触发 dictExpand 后,rehashidx 被设为 0。
• 搬运阶段:
• 来了一个命令(不管是看哪个桶),Redis 看一眼 rehashidx 是 0。
• 把 ht[0] 的 0 号桶搬到 ht[1]。
• rehashidx++(变成 1)。
• 下一个命令来,搬 1 号桶,以此类推。
• 跳过空桶:如果 rehashidx 指向的桶是空的,Redis 不会傻等,它会继续往后找,直到发现一个有数据的桶并搬完它,或者达到一个预设的步长限制。
3. 这里的“原子性”保护
既然你在操作数据的同时,rehashidx 正在有条不紊地向后推进,Redis 是如何保证你拿得到数据的呢?
这也就是你之前理解的那个逻辑:
• 查询时:主线程会先去 ht[0] 找。如果 ht[0] 的那个桶已经被搬走了(即它的索引小于当前的 rehashidx),主线程立刻转身去 ht[1] 找。
• 这种设计极其精妙:它利用单线程的特性,保证了在任何一个时间点,数据要么在 ht[0],要么在 ht[1],绝不会出现在“虚空”中。
ZipList
Encoding编码
250+4=254>253➡️previous_entry_length从1变成5
QuickList
SkipList
在 Redis 的源码实现中,跳表(SkipList)的最大层数确实被硬编码为 32(即 #define ZSKIPLIST_MAXLEVEL 32)。
RedisObject
编码方式在redis数据结构中的应用
Redis数据结构
String
Raw
EmbStr
这里需要区分两个概念:
1. 纯 redisObject 结构体:它在 C 源码里固定占用 16 字节。
2. embstr 组合体:也就是你指的“整个对象”。在字符串长度为 44 时,这个组合体(Object + SDS)总共占用 64 字节。
Int
当你执行 OBJECT ENCODING mykey 时,Redis 的内部逻辑是这样的:
1. 在全局 dict 中找到名为 mykey 的索引项。
2. 该项包含两个指针:一个指向 Key,一个指向 Value。
3. Redis 忽略 Key,直接顺着指针找到 Value 对应的 redisObject。
4. 读取该 redisObject 里的 encoding 字段并返回。
Key 没有 embstr 和 raw 之分。
这个结论背后隐藏着 Redis 极其硬核的内存优化逻辑。为了弄清楚为什么,我们需要厘清“谁”在负责区分编码。
1. 编码(Encoding)是 redisObject 的特权
在 Redis 中,所谓的 embstr 或 raw,其实是记录在 redisObject 结构体里的 encoding 字段中的。
Value:为了支持多种数据类型(String, List, Hash...)和多种内存优化方案,Value 必须 包裹在 redisObject 里。
Key:Redis 追求的是极限的查询速度。Key 的唯一使命就是被 Hash,然后进行匹配。因此,为了省下那 16 字节 的 redisObject 开销,Redis 的 Key 是直接指向 SDS 的。
结论:既然 Key 连 redisObject 都没有,自然也就没有所谓的“对象编码”这一说了。它就是一整块连续的 SDS 内存。
2. 为什么 Key 不做 embstr 优化?
所谓的 embstr 优化,核心是将 redisObject 和 SDS 连在一起分配,从而减少一次内存分配请求。
对于 Value:因为它必须有 redisObject 头部,所以把头部和数据连在一起(embstr)是个好主意。
对于 Key:它本来就没有头部,它本身就是一块连续的 SDS 内存。从内存布局上看,一个短的 Key 在内存里已经是“最精简”的状态了,不需要再进行所谓的“嵌入式”包装。
3. Key 长度超过 44 字节会发生什么?
虽然没有 embstr 到 raw 的概念转换,但 Key 超过 44 字节依然有显著变化:
SDS 头部变重:
长度 ≤31 字节:可能使用 sdshdr5(1 字节头部)。
长度 32∼255 字节:使用 sdshdr8(3 字节头部)。
长度更长时:升级到 sdshdr16(5 字节头部)。
jemalloc 档位跳跃:
正如你之前算过的,如果 Key 是 44 字节,加上 SDS 头部等,可能刚好落在 64 字节的分配档位。
一旦超过这个临界点,jemalloc 会分配 80 字节、96 字节甚至更多,内存碎片的浪费会突然增大。
4. 总结:Key vs Value 的内存待遇
维度 Key (键) Value (值)
容器 直接指向 SDS 包裹在 redisObject 中
元数据开销 极低 (仅 SDS 头部 1~3 字节) 较高 (16 字节 Object 头 + SDS 头)
是否有 embstr 无 有 (长度 ≤44 字节时)
长度影响 仅影响 SDS 头部类型和 Hash 计算耗时 触发编码转换 (embstr → raw)
List
就是比quicklist加一层
Set
java中hashset底层是hashmap,用key存值,value为null
但是一旦插入的不是int了,需要从inset转换为dict(hash)
ZSet
dict通过hash快速定位查有没有,唯一,键值存储;skiplist通过score也就是span进行排列
显然内存消耗太大!!!!!!
创建为ziplist时候,底层没有zipset
但是如果后面元素数量太多也需要转换
Hash
网络模型(IO模型)
用户应用和内核都无法访问物理内存,而是给他们分配不同的虚拟内存空间映射到物理内存;应用和内存访问虚拟内存的时候需要一个虚拟的地址,这个地址是一个无符号的整数,从0开始,最大值取决于CPU总限和寄存器的带宽。32位的系统带宽一般是32,那么地址上限就是2的32次方。也就是说寻址范围是从0到2的32次方-1这个空间,每一个地址代表每一个存储单元,也就是一个字节,也就是2的32次方字节,也就是4GB。
IO访问就是涉及磁盘或者网络的访问,涉及系统资源,也就是内核态
阻塞IO-bio
非阻塞IO-nio
IO多路复用
基于IO多路复用技术的 Reactor 模型中,监听器是阻塞式因为需要调用select阻塞等待数据就绪,而执行器是非阻塞式的因为调用recvfrom时一定是有数据的。
IO多路复用-select
IO多路复用-poll
没划分哪种事件存放的不同数组;采用pollfd,events分组传递需要监听的事件类型,有多种pollfd事件类型;等到有就绪的再把就绪的类型传到revents
poll函数中自定义*fds,理论上无上限监听数量(和select的区别)
IO多路复用-epoll
把select的功能拆分开了分别执行
使得对每一个fd来讲epoll_ctl只需要添加一次,而select和poll每次请求都需要添加;poll理论数量没有上限(但是底层链表,受限于性能),但是epoll是红黑树随着fd数量的增加,性能下降没有那么大;返回用户空间拷贝的数量减少,只需要拷贝就绪数量。
| 操作系统 | 核心 I/O 模型 | 模型类型 | 核心逻辑 |
|---|---|---|---|
| Linux | epoll | Reactor (就绪通知) | 内核告诉你:“数据已经到了,快来读吧!” |
| macOS/BSD | kqueue | Reactor (就绪通知) | 内核告诉你:“Socket 有动向了,去处理吧!” |
| Windows | IOCP (I/O Completion Ports) | Proactor (完成通知) | 内核告诉你:“你要的数据我已经读好放内存里了,你直接拿去用!” |
① Linux 的 epoll:高效的“哨兵”
特点:epoll 解决了 select 遍历所有连接的低效问题。它在内核中维护一个红黑树和就绪链表。
逻辑:它是同步非阻塞的。当某个连接有数据进来,epoll 只是发个信号说“这儿有活干了”,具体的 read 或 write 动作还是得你的应用程序自己去调用系统函数完成。
② Windows 的 IOCP:真正的“异步”
特点:IOCP 是 Windows 网络编程的皇冠。
逻辑:它是异步 I/O。你先告诉内核:“帮我读 1024 字节到这个缓冲区”,然后你就可以去干别的事了。等 Windows 默默地把数据从网卡搬到你指定的内存后,再通过完成端口通知你。
优势:这种方式极大地减少了线程上下文切换,在 Windows 上性能极其强悍。
③ macOS 的 kqueue:全能的“通知器”
特点:kqueue 是从 FreeBSD 引入的,设计非常优雅。
区别:它不仅能处理网络 Socket,还能监控文件变化、信号、定时器等,是一个非常通用的事件通知机制。在处理网络 I/O 的思路上,它和 epoll 类似,都属于 Reactor 模式,但在 API 设计上比 epoll 更简洁强大。
3. 应用层的统一:Java 与 Netty
既然底层模型完全不同,为什么我们写代码时感觉不到?
这就是 Java NIO 和 Netty 等框架的功劳。
Java NIO:在 Linux 上底层调用 epoll,在 Windows 上底层调用 poll(或通过模拟实现类似效果),在 Mac 上调用 kqueue。
Netty:为了追求极致性能,Netty 甚至专门为 Linux 写了原生的 JNI 传输层(Native Transport),直接调用 epoll,绕过了 Java 标准 NIO 的一些限制。
4. 未来趋势:Linux 的 io_uring
值得一提的是,Linux 近几年引入了 io_uring。它改变了 epoll “只通知就绪”的逻辑,变得像 Windows 的 IOCP 一样,支持真正的异步提交和完成记录。这标志着 Linux 也在向更高效的异步模型进化。
总结建议:
如果是做 Linux 服务端 开发,重点研究 epoll。
如果是做 Windows 高性能客户端或服务器,必须掌握 IOCP。
如果是写跨平台应用,直接用 Netty 或 libuv(Node.js 底层库),它们已经帮你屏蔽了这些底层的痛苦。
lt因为fd被移除后又塞回去了,可能导致惊群现象,调用过多的event_wait去list_head里面处理,但是可能已经数据处理完了还有一些被唤醒的线程没事干。
在IO 多路复用的模型中,ssfd(监听设备)和普通fd(已连接设备)您可以配置着完全不同的角色:
1. ssfd(服务器套接字文件描述符):门卫
职责:它专门负责“监听”是否有新的客户端想要建立连接。
特性:在整个Web服务生命周期中,通常只有一个ssfd。
流程:当ssfd触发事件事件时,流程图走向accept()分支,去接收这个新客人的请求。
2.普通fd (Connected Socket File Descriptor):专属客服
职责:一旦accept()成功,系统会创建一个全新的fd(文件读写),专门用于和这个特定的客户端通信。
记录:这个新的fd会被epoll_ctl注册到那棵红黑树(rb_root)上。
后续:当同一个客户端第二次、第三次发来数据(比如发送一个 GET 请求),触发事件的就不再是ssfd,但是这个已经记录在案子的普通了fd。
流程:此时流程图走向读取请求数据->写出响应的分支。
1.发现新客人:ssfd触发
当一个新的客户端尝试连接服务器时,渲染器ssfd将实现“不可或缺”。
epoll_wait检测到这个事件,将其就绪队列list_head。
2.捕获连接:accept()退出
程序判断出这是ssfd的事件,于是走向右上角的分支。
调用accept()函数,系统会正式接受这个连接,并返回一个全新的普通fd(专门用于后续和这个客户端聊天)。
3. 登记册:epoll_ctl注册
得到这个新fd后,程序会立即调用epoll_ctl。
它的作用就是把这个新fd挂到那棵红黑树(rb_root)上。
关键动作:同时会为这个fd注册一个回调函数。以后这个客户端发消息过来时,内核就知道该把哪个fd塞进稳定链表里了。
信号驱动IO
真正的非阻塞
异步IO
阻塞非阻塞和同步异步是两个维度的概念,不能混为一谈
同步异步取决于数据从内核到用户空间拷贝到过程中相对于主线程是否异步
Redis网络模型(重点)
Redis6.0之前的网络模型(重点)
直到addreply方法执行完还是没有搞定发送给client,只是把要发送到的client加入一个队列中。
最终由before sleep方法迭代遍历接受传给写处理器然后发送出去,就是说这个beforesleep会把所有的队列中的客户端fd绑定一个写处理器
Redis6.0之后的网络模型(重点)
真正影响性能的是IO,也就是网络传输,磁盘读写;问题在于高并发下请求数据的读取慢可能阻塞(传入),命令处理涉及网络交互也可能阻塞(传出)。
(重点)主线程把多个不同的客户端通过轮询的形式发送给不同的线程,让他们并行解析请求当中的数据,解析为redis命令,解析完执行命令还是由主线程执行,也就是说命令还是逐个执行;命令回复器开启多线程从client客户端队列中并行发送
1.初始化阶段(aeCreateEventLoop)
在Redis启动时,aeEventLoop会被创建。
aeApiCreate:这一步会调用内核接口(如epoll_create)。
内核行为:此时内核会在内部创建一个Eventpoll对象,这个对象确实包含了你提到的两个核心结构:
红黑树 (RB-Tree):用于存储所有待监听的 Socket 文件(fd)。
就绪链表 (List_head):用于存储那些已经触发事件(有数据可执行/写)的fd。
2. 绑定监听(tcpAcceptHandler注册)
注册监听:Redis启动后,将ServerSocketfd注册到aeEventLoop中。
绑定回调:它会返回 fd 的AE_READABLE事件绑定到tcpAcceptHandler函数。
内核操作:通过epoll_ctl将ServerSocket放入红黑树中。
3.事件循环与beforeSleep
这是你提到的核心循环部分:
beforeSleep:在每一轮aeApiPoll(阻塞等待)之前,Redis 会执行beforeSleep。它不仅仅是注册迭代器,更重要的任务是:
处理一些被延迟的任务(例如将 AOF 写入磁盘)。
处理写事件(Output Buffer):如果之前有客户端的回复没发完成,会在此时尝试推入队列。
aeApiPoll:调用epoll_wait。此时线程会阻塞,直到内核将就绪的fd从红黑树移动到就绪链表中并返回给Redis。
4. 建立连接(Client接入)
当Client端发起连接请求时:
触发事件:aeApiPoll返回,发现ServerSocket区别。
执行回调:调用绑定之前的tcpAcceptHandler。
创建连接:accept接收新连接,生成ClientSocketfd。
注册 Client:Redis 为这个新的ClientSocket创建一个connection对象,并将其读取事件(绑定到readQueryFromClient)再次注册进aeEventLoop的红黑树中。
Redis通信协议
响应结果物种都有可能,输入的基本上是多行字符串或者数组
package com.heima;
import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
public class Main {
static Socket s;
static PrintWriter writer;
static BufferedReader reader;
public static void main(String[] args) {
//1.跟redis建立连接
String host = "192.168.100.128";
int port = 6379;
try {
s = new Socket(host, port);
//2.获取socket的输出流和输入流
writer = new PrintWriter(new OutputStreamWriter(s.getOutputStream(), StandardCharsets.UTF_8));
reader = new BufferedReader(new InputStreamReader(s.getInputStream(),StandardCharsets.UTF_8));
//3.1. 获取授权 auth 123321
sendRequest("auth","123321");
//3.2.发出请求 set name 虎哥
sendRequest("set","name","虎哥");
//3.3.
sendRequest("get","name");
//3.4.
sendRequest("mget","name","num","msg");
//4.解析响应
Object object = handleResponse();
System.out.println("obj+"+object);
//5.释放连接
} catch (IOException e) {
throw new RuntimeException(e);
}finally {
try {
if (reader != null)reader.close();
if (writer != null)writer.close();
if (s != null)s.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
private static Object handleResponse() throws IOException {
//读取首字节,判断类型标识
int prefix = reader.read();
//判断标识
switch (prefix){
case '+'://单行字符串直接读一行
return reader.readLine();
case '-'://异常也读一行
throw new RuntimeException(reader.readLine());
case ':'://数字
return Long.parseLong(reader.readLine());
case '$'://多行字符串
//多行字符串先读长度,再读数据
int len = Integer.parseInt(reader.readLine());
if (len == -1){
return null;
}else if (len == 0){
return "";
}
//再读len个字节才对,假如没有特殊字符,所以只读一行
return reader.readLine();
case '*'://数组
return readBulkString();
default:
throw new RuntimeException("错误的数据格式!");
}
}
private static Object readBulkString() throws IOException {
//1.获取数组大小
int len = Integer.parseInt(reader.readLine());
if (len <= 0){
return null;
}
//定义一个集合接受多个元素
List<Object> list = new ArrayList<>(len);
//2.遍历依次读取每个元素
for (int i = 0;i<len;i++){
list.add(handleResponse());
}
return list;
}
private static void sendRequest(String ... args) {
writer.println("*"+args.length);
for (String arg : args) {
writer.println("$"+arg.getBytes(StandardCharsets.UTF_8).length);//字节大小
writer.println(arg);
}
writer.flush();
}
}
Redis内存策略
Redis内存回收
Redis过期策略
*dict里面存redisobject对应的内存地址的指针
如果一个key没有ttl那只会存在于第一个dict,不会存在于第二个dict
惰性删除
周期删除
| 模式 | 触发场景 | 时间上限 | 比例要求 | 备注 |
|---|---|---|---|---|
| 慢速模式 | serverCron定期触发 |
25ms(默认) | > 25% 则循环 | 因为彻底,但怕时间长 |
| 快速模式 | 事件循环休闲触发 | 1毫秒 | > 25% 则循环 | 见缝插针,频率极高 |
redis淘汰策略
每次执行客户端的命令之前尝试内存淘汰
在RedisObject中标注了LRU和LFU

浙公网安备 33010602011771号