【Redis】原理篇 3w字详解

前言

Redis博客导航

Redis入门篇

Redis实战篇

Redis高级篇

宝剑锋从磨砺出,梅花香自苦寒来

希望该博客对你有所帮助

文章目录

Redis数据结构

动态字符串

我们都知道Redis中保存的Key是字符串,value往往是字符串或者字符串的集合。可见字符串是Redis中最常用的一种数据结构。

不过Redis没有直接使用C语言中的字符串,因为C语言字符串存在很多问题:

  1. 获取字符串长度要遍历计算,C 字符串以 \0 作为结束标记,想要知道长度必须循环遍历直到遇见\0,时间复杂度 O (n),效率极低
  2. 不是二进制安全,无法存储图片、序列化二进制数据,C 字符串遇到 \0 就判定字符串结束,如果你的数据中间包含\0,后面内容直接丢失。
  3. 不支持动态扩容,C 字符串拼接前必须手动计算长度、手动申请内存;频繁追加数据会反复malloc/free,产生大量内存碎片、损耗 CPU。

Redis构建了一种新的字符串结构,称为简单动态字符串(Simple Dynamic String),简称SDS。例如,我们执行命令:

那么Redis将在底层创建两个SDS,其中一个是包含 "name" 的SDS,另一个是包含"虎哥"的SDS。

Redis是C语言实现的,其中SDS是一个结构体,源码如下:

  • 5 种 SDS 头部分类

字符串长度 256 → uint8_t 存不了,直接升级 sdshdr16

  1. SDS_TYPE_5(sdshdr5)

极短字符串,只存 flags,无单独 len/alloc,最大长度 31;Redis 内部很少对外使用。

  1. SDS_TYPE_8(sdshdr8,图中结构)

len/alloc 都是 uint8_t(1 字节),最大支持 255 字节字符串;

业务中绝大多数短 key、短 value 都会用这个结构,占用最小。

  1. SDS_TYPE_16(sdshdr16)

len/alloc 是 uint16_t(2 字节),最大 65535 字节。

  1. SDS_TYPE_32(sdshdr32)

len/alloc uint32_t(4 字节),最大 4GB。

  1. SDS_TYPE_64(sdshdr64)

len/alloc uint64_t(8 字节),超大字符串专用。

例如,一个包含字符串 "name" 的sds结构如下:

SDS之所以叫做动态字符串,是因为它具备动态扩容的能力,例如一个内容为"hi"的SDS:

假如我们要给SDS追加一段字符串",Amy",这里首先会申请新内存空间:

如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1;

如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1。称为内存预分配。

申请内存的操作很消耗资源,所以要多申请内存空间,以减少内存分配次数。

intset

Redis 的 Set 有两种底层实现:

  1. Dict(哈希表):元素是字符串、小数、大数混合,通用实现;
  2. IntSet:只有当集合全部都是整数、且数值很小时,Redis 自动用这个压缩数组存储,内存占用远小于哈希表,专门优化纯数字集合。

IntSet 基于整数数组来实现,并且具备长度可变、有序等特征。结构如下:

其中的encoding包含三种模式,表示存储的整数大小不同:

INTSET_ENC_INT16 每个数字占 2字节 范围:-32768 ~ 32767

INTSET_ENC_INT32 每个数字占 4字节 范围:-2147483648 ~ 2147483647

INTSET_ENC_INT64 每个数字占 8字节 超大整数

现在,数组中每个数字:{5,10,20} 都在 int16_t 的范围内,因此采用的编码方式是INTSET_ENC_INT16,每部分占用的字节大小为:

encoding:4字节

length:4字节

contents:2字节 * 3 = 6字节

我们向该其中添加一个数字:50000,集合变为:{5,10,20,50000},50000超出了 int16_t 的范围,intset会自动升级编码方式到合适的大小。

以当前案例来说流程如下:

  • 升级编码为INTSET_ENC_INT32,每个整数占4字节,并按照新的编码方式及元素个数扩容数组
  • 倒序依次将数组中的元素拷贝到扩容后的正确位置
    • Redis 为了节省内存,不会新开一块独立内存,直接在原内存空间原地扩容,旧数据和新大空间重叠在同一块内存。
    • 正序拷贝:把 0~1 的 num1 复制到 0~3,但原来 2~3 字节存的 num2,被新 num1 的后半段覆盖冲掉了;
  • 如果加入新数字需要扩容,那新数字如果是负数就在第一位,正数就在最后一位,不需要二分查找。
  • 如果不需要扩容,IntSet 要求数组永远有序
    • 判断如果比最大值大,就插入队尾;如果比最小值小,插入队首。
    • 如果都不是,通过二分查找找到插入下标,把新数字插入正确位置
  • 最后,将inset的encoding属性改为INTSET_ENC_INT32,将length属性改为4

源码如下:

小总结:

Intset可以看做是特殊的整数数组,具备一些特点:

  • Redis会确保Intset中的元素唯一、有序
  • 具备类型自动升级机制,可以节省内存空间
  • 底层采用二分查找方式来查询

Dict

Dict 是 Redis 最核心底层结构:

  • 所有 Redis 的 KV 主数据库、Hash 数据类型、Set(非 intset 时)、ZSet 底层都靠它实现;
  • 作用:而键与值的映射关系正是通过Dict来实现的,通过 key 快速映射 value,实现 O (1) 增删改查。

Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)

  1. DictHashTable 哈希表

作用:存储哈希节点,通过哈希值计算下标,实现 O (1) 定位节点。

哈希表的核心成员:

entry\[\]:数组,数组每一格存放 DictEntry* 指针;

size:数组长度,一定是 2 的幂(2、4、8、16、32...);

sizemask:掩码 = size - 1,用来计算数组下标;

used:当前实际存储的键值对总数量。

Dict 里会存两张哈希表 ht 0、ht 1,平时只用 ht 0;扩容缩容时渐进式迁移到 ht 1(rehash)。

  1. DictEntry 哈希节点(最小单元)

每一个键值对就是一个节点,next是链表指针,当两个 key 算出同一个数组下标,就用next把节点挂在链表尾部。

  1. Dict 字典(顶层结构体)

管理两张哈希表、rehash 状态、哈希函数等,对外提供增删改查接口。

当我们向 Dict 添加键值对时,Redis首先根据key计算出hash值(h),然后利用 h & sizemask 与运算来计算元素的数组索引。

位与 & 规则:对应二进制位同时为 1,结果才是 1,否则为 0。

  • 为什么不用 h % size,非要用 h & sizemask?
    • 仅当 size 是 2 的幂时,h & (size-1) 等价于 h % size。
    • 运算速度更快:CPU 二进制位运算 & 效率远高于取模 %

假如我们存储k1=v1,假设k1的哈希值h =1,则1&3 =1,因此k1=v1要存储到数组角标1位置。

加入又来一个 k2,k2计算出的数组角标也是1,把新来的元素加入到链表的队首。如果加到队尾,因为是单向链表就要遍历到最后一个元素才能添加新元素。

Dict的扩容

Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。

  • 负载因子:LoadFactor = used/size
    • used:哈希表里实际存放的键值对总数
    • size:哈希表数组长度(一定是 2 的幂:4、8、16、32...)

负载因子含义:数组平均每个下标链表上挂多少个节点。

  • 为什么要控制负载因子?
    • 哈希表靠数组下标快速定位,冲突元素靠链表遍历查找。
    • 负载因子越大,链表越长,查询时遍历链表次数变多,O (1) 退化成 O (n),性能暴跌。所以 Redis 会自动扩容,增大数组size,分散节点、缩短链表。

Dict在每次新增 键值对 时都会检查负载因子 ,满足以下两种情况时会触发哈希表扩容:

  1. 哈希表的 LoadFactor >= 1,并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程;
  2. 哈希表的 LoadFactor > 5 ,强制扩容,无视后台持久化任务。
  • 缩容 rehash:只有删除数据后,负载因子 LoadFactor < 0.1 才会触发。

新容量 = 大于等于当前 ht0.used 的最小 2 的整数次幂

硬性下限:新容量不能小于 4,防止数组太小频繁扩容缩容震荡。

Dict的rehash

哈希表扩容 / 缩容后,数组长度 size 变了,对应的 sizemask = size-1 也会变。同一个 key,新旧 sizemask 不同,算出的数组下标完全不一样。所以所有旧表 ht 0 里的键值对,必须重新算下标,搬到新表 ht 1,这个整体迁移流程就叫 rehash。

过程是这样的:

  • 计算新hash表的realSize,值取决于当前要做的是扩容还是收缩
    • 如果是扩容,则新size为第一个 大于等于 dict.ht0.used + 1 的 2^n
      • 旧表 used=5,used+1=6,大于等于 6 最小的 2 次幂是 8,新 size=8。
    • 如果是收缩,则新size为第一个 大于等于 dict.ht0.used 的2^n (不得小于4)
      • 限制最小 4 是为了避免数组过小,频繁反复缩容扩容。
  • 按照新的realSize申请内存空间,创建dictht,并赋值给 dict.ht1
    • 正常业务下 ht 1 是空的,只有 rehash 时才启用。
  • 设置dict.rehashidx = 0,标示开始 rehash
    • rehashidx 记录当前迁移到 ht 0 的第几个数组下标。
    • 等于 0 代表:从下标 0 开始,逐步迁移;等于 - 1 代表没有在做 rehash。
  • Dict采用渐进式rehash,每次对Dict进行 增删改查 操作时,都会检查 rehashidx 是否大于 -1,如果是,则执行rehash,迁移 ht 0 当前 rehashidx 下标整条链表所有节点
  • 每个 entry 重新计算哈希下标,存入 ht 1
  • 清空 ht 0rehashidx,rehashidx += 1,下一次操作迁移下一个下标。
  • 当rehashidx走完ht0.size全部下标,说明 ht 0 所有数据迁移完毕;释放旧ht0数组的内存;
  • 把ht1整体赋值给ht0;重置ht1为空哈希表,留作下一次 rehash 备用;
  • 最后将 rehashidx 赋值为 -1,代表rehash结束
  • rehash 期间读写规则(保证 ht 0 只减不增)
    • 新增 key:直接插入 ht 1,绝不写入 ht 0
    • 效果:ht 0 的数据只会被迁走、不会新增,最终一定会清空。
    • 查询 / 修改 / 删除 key:先查 ht 0,找不到再查 ht 1,两边都操作。

整个过程可以描述成:

  • 新增第五个元素,size 扩容
  • size 从4扩容到8
  • ht 0 所有数据迁移到 ht1
  • 把ht1整体赋值给ht0
  • 重置ht1为空哈希表

小总结:

Dict的结构:

  • 类似java的HashTable,底层是数组加链表来解决哈希冲突
  • Dict包含两个哈希表,ht0平常用,ht1用来rehash

Dict的伸缩:

  • 当LoadFactor大于5或者LoadFactor大于1并且没有子进程任务时,Dict扩容
  • 当LoadFactor小于0.1时,Dict收缩
  • 扩容大小为第一个大于等于used + 1的2^n
  • 收缩大小为第一个大于等于used 的2^n
  • Dict采用渐进式rehash,每次访问Dict时执行一次rehash
  • rehash时ht0只减不增,新增操作只在ht1执行,其它操作在两个哈希表

ZipList

ZipList 是一块连续内存实现的紧凑型双端链表,专为节省内存设计,没有普通链表的指针开销;

  • 内存全部连续,CPU 缓存友好、无内存碎片;
  • 支持头部、尾部快速压入 / 弹出,理论时间复杂度 O (1);
  • 适用场景:Hash、List、ZSet 元素数量少、数据很短时,Redis 自动用它存储。

整体内存固定格式(从头到尾):

zlbytes(4Byte) + zltail(4B) + zllen(2B) + 若干变长entry节点 + zlend(1B)

属性 类型 长度 用途
zlbytes uint32_t 4 字节 记录整个压缩列表占用的内存字节数
zltail uint32_t 4 字节 记录最后一个 entry 距离列表起始地址的字节偏移量。 作用:不用遍历全部节点,直接定位尾部元素,保证尾部 pop/push O (1),实现双端操作。
zllen uint16_t 2 字节 记录了压缩列表包含的节点数量。 最大值为UINT16_MAX (65534),如果超过这个值,此处会写死为65535,此时该字段失效,必须从头到尾完整遍历所有 entry 才能算出真实数量。
entry 列表节点 不定 节点的字节数不固定,由节点保存的内容决定
zlend uint8_t 1 字节 固定值 0xFF(255),专门标记压缩列表内存结束。

补充:理论 O (1) 只是定位节点的复杂度;

插入 / 删除会触发整块内存 realloc 搬迁所有元素,实际时间复杂度 O (n),元素多性能暴跌,因此 ZipList 只适合少量数据。

ZipListEntry

普通双向链表节点需要存 prev、next 两个 8 字节指针,合计 16 字节内存开销;

ZipList 为了压缩内存,不存指针,改用 前一个节点长度(字节数) 反向定位上一个节点,只记录数字长度,内存开销远小于指针。

例如:已知当前节点地址 A,previous_entry_length=300(5 字节存储)

上一个节点地址 = A - 300,无需遍历整条列表,实现反向遍历。

  • previous_entry_length:前一个节点的长度,占1个或5个字节。
    • 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
    • 如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为标识头0xfe,后四个字节才是存真实长度数据
  • encoding:编码属性,记录content的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节
    • 字符串编码(最高两位不是 11):记录字符串字节长度。1、2、5 字节三种规格,适配短 / 中 / 长字符串;
    • 整数编码(最高两位固定 11):记录整数字节长度。
  • contents:负责保存节点的数据,可以是字符串或整数
    • 如果 encoding 标记为字符串:这里存完整字符数组;
    • 如果 encoding 标记为压缩整数:此字段为空,数字直接存在 encoding 内部。

ZipList中所有存储长度的数值均采用小端字节序:低位字节存在低内存地址(靠前),高位字节存在高内存地址(靠后)。

例如:数值0x1234,高位0x12(一个字节),低位0x34(一个字节),采用小端字节序后实际存储值为:0x3412

适用范围:previous_entry_length 的 4 字节长长度、encoding 里多字节长度、zlbytes/zltail/zllen 全部使用小端序存储,是 Redis ZipList 统一解析标准。

Encoding编码

ZipListEntry中的encoding编码分为字符串和整数两种:

  1. 字符串:如果encoding是以"00"、"01"或者"10"开头,则证明content是字符串
编码 编码长度 字符串大小
00pppppp
01pppppp qqqqqqqq
10000000 qqqqqqqq

pppppp 存储占用字节数

例如,我们要保存字符串:"ab"和 "bc"

  • "ab"
  • 存储"ab"和"bc"的整个ZipList 表示,均用16 进制表示,且采用小端字节序
  1. 整数:如果encoding是以"11"开始,则证明content是整数,且encoding固定只占用1个字节
编码 编码长度 整数类型(contents 占用字节)
11000000 1 int16_t 短整数(2 bytes)
11010000 1 int32_t 整数(4 bytes)
11100000 1 int64_t 长整数(8 bytes)
11110000 1 24位有符整数(3 bytes)
11111110 1 8位有符整数(1 bytes)
1111xxxx 1 0字节,无 contents

1111xxxx 为压缩小整数:直接在xxxx位置保存数值,范围从00011101(113),减1后结果为实际值(0~12)

例如,一个ZipList中包含两个整数值:"2" 和 "5"

  • 表示 "2"
  • 表示 "2" 和 "5"
  • "2" 和 "5" 整个ZipList,无 contents

ZipList的连锁更新问题

ZipList的每个Entry都包含previous_entry_length来记录上一个节点的大小,长度是1个或5个字节:

如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值。

如果前一节点的长度大于等于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据。

  • 举例

节点 A (252B) → 节点 B (251B) → 节点 C (253B) → 节点 D (250B)

B 的previous_entry_length=252(1 字节)

C 的previous_entry_length=251(1 字节)

D 的previous_entry_length=253(1 字节)

  1. 在最前面插入一个新节点 X,X 的长度是 300B(≥254)。
  2. 原来的第一个节点 A,它的previous_entry_length原本存 252(1 字节),现在前节点变成 X、长度 300≥254:
    • A 的 previous_entry_length 必须从 1 字节扩容成 5 字节;
    • A 整个节点整体变长了 5 - 1 = 4 字节。
  3. A 变长 4 字节 → 它后面的节点 B 的前节点(A)总长度变大,超过 254:
    • B 的 previous_entry_length 也要从 1 字节扩为 5 字节,B 整体又增加 4 字节;
  4. B 变长 4 字节 → 下一个节点 C 的前节点长度超标,同样扩容 4 字节;
  5. 以此类推,后面所有 N 个节点全部要依次扩容、重写内存。
  • 反向场景
  1. 一串节点,前面某个大节点(长度 300B,5 字节 prevlen)被删除,它后面第一个节点的前节点长度变回 < 254:
  2. 该节点previous_entry_length从 5 字节缩为 1 字节,整体缩短 4 字节;
  3. 后序每一个节点的前节点长度同步变小,全部需要修改自身 prevlen,连续更新。

这种一次插入 / 删除,引发后续一串节点全部修改自身previous_entry_length、连续空间重分配的现象,就是连锁更新(Cascade Update)。

ZipList这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新(Cascade Update)。新增、删除都可能导致连锁更新的发生。

小总结

ZipList特性:

  • 压缩列表的可以看做一种连续内存空间的"双向链表"
  • 列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低
  • 如果列表数据过多,导致链表过长,可能影响查询性能
  • 增或删较大数据时有可能发生连续更新问题

QuickList

问题1:ZipList虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低。怎么办?

答:找到一大块连续内存很难,为了缓解这个问题,我们必须限制ZipList的长度和entry大小。

问题2:但是我们要存储大量数据,超出了ZipList最佳的上限该怎么办?

答:我们可以创建多个ZipList来分片存储数据。

问题3:一堆分散的 ZipList,怎么串联、统一管理、支持头尾快速读写?

答:Redis在3.2版本引入了新的数据结构QuickList,它是一个双端链表,只不过链表中的每个节点都是一个ZipList。

问题4:ZipList 为了压缩内存才不存指针,为什么还要加上 QuickList,QuickList 存双向指针了啊,岂不是背道而驰?

答:外层双向链表 只给每一块 ZipList配一对 prev/next 指针,不是给每一条数据配指针

为了避免QuickList中的每个ZipList中entry过多,Redis提供了一个配置项:list-max-ziplist-size来限制。

如果值为正,则代表ZipList的允许的entry个数的最大值。

如果值为负,则代表ZipList的最大内存大小,分5种情况:

  • -1:每个ZipList的内存占用不能超过4kb
  • -2:每个ZipList的内存占用不能超过8kb
  • -3:每个ZipList的内存占用不能超过16kb
  • -4:每个ZipList的内存占用不能超过32kb
  • -5:每个ZipList的内存占用不能超过64kb

其默认值为 -2:

除了控制ZipList的大小,QuickList 还可以对节点的ZipList做压缩。通过配置项 list-compress-depth 来控制。

因为链表绝大多数操作只操作头部、尾部(LPUSH、RPUSH、LPOP、RPOP),中间的节点很少读写。所以首尾是不压缩的。这个参数是控制首尾不压缩的节点个数:

0:特殊值,代表不压缩

1:链表头部 1 个节点、尾部 1 个节点保持原始不压缩,中间节点压缩

2:链表头部 2 个、尾部 2 个不压缩;其余中间节点压缩。

以此类推

  • 举例

链表节点顺序:N1 N2 N3 N4 N5 N6 N7 N8

首尾各 2 个免压缩

不压缩:N1、N2、N7、N8

压缩:N3、N4、N5、N6

  • 默认值

以下是QuickList的和QuickListNode的结构源码:

描述当前的这个结构:

中间两个节点为压缩后的形式

总结:

QuickList的特点:

  • 是一个节点为ZipList的双端链表
  • 节点采用ZipList,解决了传统链表的内存占用问题
  • 控制了ZipList大小,解决连续内存空间申请效率问题
  • 中间节点可以压缩,进一步节省了内存

SkipList

  • 普通有序单向链表:1 → 3 → 5 → 7 → 9

只有一层,查找元素必须从头逐个遍历,查询复杂度 O (n)。

  • SkipList(跳表)首先是链表,但与传统链表相比有几点差异:
  1. 元素按照升序排列。支持范围查询(ZRANGE/ZREVRANGE),Redis ZSet 依靠它实现区间遍历。
  2. 每个节点携带多层向前指针,每层指针跨度不一样
  3. QuickList 适合做首尾查询,SkipList 适合做中间查询

查找元素全程不用遍历全部节点,平均复杂度 O (log n)。

  • 结构表示为

小总结:

SkipList的特点:

  • 跳跃表是一个双向链表,每个节点都包含score和ele值
  • 节点按照score值排序,score值一样则按照ele字典排序
  • 每个节点都可以包含多层指针,层数是1到32之间的随机数
  • 不同层指针到下一个节点的跨度不同,层级越高,跨度越大
  • 增删改查效率与红黑树基本一致,实现却更简单

RedisObject

Redis中的任意数据类型的键和值都会被封装为一个RedisObject,也叫做Redis对象,源码如下:

redis 对象头要占去 16 个字节,存10个字符串就要占160个字节;

但如果把这10个字符串用集合去存储,对象头只占16个字节。

  • 什么是redisObject

从Redis的使用者的角度来看,⼀个Redis节点包含多个database(非cluster模式下默认是16个,cluster模式下只能是1个),而一个database维护了从key space到object space的映射关系。这个映射关系的key是string类型,而value可以是多种数据类型,比如:string, list, hash、set、sorted set等。

而从Redis内部实现的角度来看,database内的这个映射关系是用⼀个dict来维护的。dict的key固定用⼀种数据结构来表达就够了,这就是动态字符串sds。而value则比较复杂,为了在同⼀个dict内能够存储不同类型的value,这就需要⼀个通用的数据结构,这个通用的数据结构就是 robj,全名是 redisObject。

  • robj 的核心作用
  1. 统一封装:Dict 只存 robj*,不用区分底层是 ZipList / Dict / SkipList / IntSet 等;
  2. 记录类型 type:标记对外数据类型(String/List/Hash/Set/ZSet),对应 TYPE key 命令;
  3. 记录编码 encoding:标记底层真实存储结构(raw/embstr/ziplist/quicklist/intset/skiplist...),对应 OBJECT ENCODING key;
  4. 引用计数 refcount:实现对象共享、内存自动回收,0 引用时释放底层结构;
  5. lru 访问时间:用于内存淘汰策略(LRU/LFU)。
  • Redis的编码方式

Redis中会根据存储的数据类型不同,选择不同的编码方式,共包含11种不同类型:

编号 编码方式 说明
0 OBJ_ENCODING_RAW raw编码动态字符串
1 OBJ_ENCODING_INT long类型的整数的字符串
2 OBJ_ENCODING_HT hash表(字典dict)
3 OBJ_ENCODING_ZIPMAP 已废弃
4 OBJ_ENCODING_LINKEDLIST 双端链表
5 OBJ_ENCODING_ZIPLIST 压缩列表
6 OBJ_ENCODING_INTSET 整数集合
7 OBJ_ENCODING_SKIPLIST 跳表
8 OBJ_ENCODING_EMBSTR embstr的动态字符串
9 OBJ_ENCODING_QUICKLIST 快速列表
10 OBJ_ENCODING_STREAM Stream流

Redis中会根据存储的数据类型不同,选择不同的编码方式。每种数据类型的使用的编码方式如下:

数据类型 编码方式
OBJ_STRING int、embstr、raw
OBJ_LIST LinkedList和ZipList(3.2以前)、QuickList(3.2以后)
OBJ_SET intset、HT
OBJ_ZSET ZipList、HT、SkipList
OBJ_HASH ZipList、HT

示例 1:String 类型

  • type = OBJ_STRING
  • 编码可选:
    • OBJ_ENCODING_INT:数字字符串,直接存 long 整数,省内存;
    • OBJ_ENCODING_EMBSTR:短字符串,robj 和 SDS 连续内存;
    • OBJ_ENCODING_RAW:长字符串,标准 SDS。

示例 2:List 类型

  • type = OBJ_LIST
  • 编码只有:OBJ_ENCODING_QUICKLIST(3.2 后废弃 linkedlist、ziplist 单独使用)

示例 3:ZSet 有序集合

  • type = OBJ_ZSET
  • 编码:OBJ_ENCODING_ZIPLIST(少量数据) / OBJ_ENCODING_SKIPLIST(大数据)

五种数据结构

String

String是Redis中最常见的数据存储类型:

  • OBJ_ENCODING_RAW
    • 触发条件

满足任意一条:

  1. 字符串长度 > 44 字节;
  2. EMBSTR 字符串执行修改操作(append、setbit 等);
  3. INT 编码执行字符串操作,转成字符串后。
    • 特点
      • 基于简单动态字符串(SDS)实现,SDS 支持动态扩容,支持任意字符串修改。
      • 存储上限为512mb。
      • robj 结构体、SDS 字符串是两块分开的内存,两次 malloc。
  • OBJ_ENCODING_EMBSTR
    • 如果存储的SDS长度小于44字节。
    • 此时object head与SDS是一段连续空间。申请内存时只需要调用一次malloc 内存分配,效率更高。
    • EMBSTR 是只读编码,不支持修改操作:一旦执行 APPEND 追加、修改字符串,Redis 会重新分配两块独立内存,自动转成 RAW 编码。
  • OBJ_ENCODING_INT
    • 数值在 LONG_MIN ~ LONG_MAX 范围
    • 把数字值直接存在 ptr 字段(刚好8字节),不再需要SDS了。
    • INCR / DECR / INCRBY / DECRBY 自增自减:直接操作 ptr 里的 long 数字,无需字符串转换,运算速度极快。
    • 但如果执行 APPEND / SETBIT / GETRANGE / STRLEN 等字符串操作:不能直接操作 long 二进制,必须先把数字转成 SDS 字符串,编码会自动转为 RAW/EMBSTR。

底层实现方式:动态字符串sds 或者 longString的内部存储结构⼀般是sds(Simple Dynamic String,可以动态扩展内存),但是如果⼀个String类型的value的值是数字,那么Redis内部会把它转成long类型来存储,从而减少内存的使用。

sds 源码:

编码 底层结构 触发条件 内存特点 支持修改?
INT long 数字,无 SDS 字符串是合法 64 位整数 开销最小,无堆字符串 仅数字运算;字符串操作会转码
EMBSTR robj+SDS 连续内存 字符串≤44 字节、非纯数字 一次 malloc,效率高 只读,修改自动转 RAW
RAW robj、SDS 两块独立内存 长度 > 44 / EMBSTR 修改 / INT 转字符串 两次内存分配,支持动态扩容 完全支持增删改

确切地说,String在Redis中是用⼀个robj来表示的。

用来表示String的robj可能编码成3种内部表示:OBJ_ENCODING_RAW,OBJ_ENCODING_EMBSTR,OBJ_ENCODING_INT。

其中前两种编码使用的是sds来存储,最后⼀种OBJ_ENCODING_INT编码直接把string存成了long型。在对string进行incr, decr等操作的时候,如果它内部是OBJ_ENCODING_INT编码,那么可以直接行加减操作;

如果它内部是OBJ_ENCODING_RAW或OBJ_ENCODING_EMBSTR编码,那么Redis会先试图把sds存储的字符串转成long型,如果能转成功,再进行加减操作。对⼀个内部表示成long型的string执行 append, setbit, getrange 这些命令,针对的仍然是string的值(即十进制表示的字符串),而不是针对内部表示的long型进行操作。

比如字符串"32",如果按照字符数组来解释,它包含两个字符,它们的ASCII码分别是0x33和0x32。当我们执行命令 setbit key 7 0 的时候,相当于把字符0x33变成了0x32,这样字符串的值就变成了"22"。而如果将字符串"32"按照内部的64位long型来解释,那么它是0x0000000000000020,在这个基础上执行setbit位操作,结果就完全不对了。因此,在这些命令的实现中,会把long型先转成字符串再进行相应的操作。

List

Redis的List类型可以从首、尾操作列表中的元素:

哪一个数据结构能满足上述特征?

  • LinkedList :普通链表,可以从双端访问,内存占用较高,内存碎片较多
  • ZipList :压缩列表,可以从双端访问,内存占用低,存储上限低
  • QuickList:LinkedList + ZipList,可以从双端访问,内存占用较低,包含多个ZipList,存储上限高

Redis的List结构类似一个双端链表,可以从首、尾操作列表中的元素:

在3.2版本之前,Redis采用ZipList和LinkedList来实现List,当元素数量小于512 并且 元素大小小于64字节时采用ZipList编码,有一个条件不满足则采用LinkedList编码。

  • 旧方案痛点
  1. 一旦数据量稍大就全部变成纯双向链表,指针内存开销爆炸;
  2. 纯 ZipList 大数据量连锁更新性能极差,两者只能二选一,没有折中方案。

在3.2版本之后,Redis统一采用 QuickList 来实现List:

Set结构

Set是Redis中的单列集合,满足下列特点:

  • 不保证有序性
  • 保证元素唯一
  • 求交集、并集、差集

查询元素的频率太频繁了,所以对查询元素的效率要求很高。

能满足唯一 + 快速查找 的天然结构就是哈希表 Dict

但当存储的所有数据都是整数,并且元素数量不超过set-max-intset-entries时,Set会采用IntSet编码,以节省内存,因此 Set 存在两种编码自动切换:

  1. OBJ_ENCODING_INTSET 整数集合(小量纯整数)
  2. OBJ_ENCODING_HT 哈希字典(海量数据 / 含非整数)

Dict 本是键值对结构,Set 只需要存单个唯一元素,因此复用 Dict 做特殊改造:

  • 集合元素作为 Dict 的 key;
  • 所有 value 统一存 NULL(占位,无实际数据)。
编码 存储结构 适用场景 查询复杂度 内存开销
IntSet 连续有序整数数组 全整数、数量≤512 O (logn) 二分查找 极低
HT(Dict) 哈希字典,key 存元素,value=null 含字符串 / 数据量大 平均 O (1) 较高(哈希表指针、冲突链表)

结构如下:

ZSET

ZSet也就是SortedSet,其中每一个元素都需要指定一个score值和member值:

  • 可以根据score值排序后
  • member必须唯一
  • 可以根据member查询分数

因此,zset底层数据结构必须满足:键值存储、键必须唯一、可排序 这几个需求。

因此 ZSet 设计两套底层编码,自动切换:

  1. OBJ_ENCODING_ZIPLIST 压缩列表:少量短元素,节约内存;
  2. OBJ_ENCODING_SKIPLIST 跳表 + 字典双结构:大数据量,兼顾排序与快速查分。
  • SkipList:可以排序,并且可以同时存储score和ele值(member)
  • HT(Dict):可以键值存储 ,并且可以根据key找value,且保证键唯一

当元素数量不多时,HT和SkipList的优势不明显,而且更耗内存。因此zset还会采用ZipList结构来节省内存,不过需要同时满足两个条件:

  • 元素数量小于zset_max_ziplist_entries,默认值128
  • 每个元素都小于zset_max_ziplist_value字节,默认值64

ziplist本身没有排序功能,而且没有键值对的概念,因此需要通过编码实现:

  • 成对存储:连续两个 entry 为一组,先存 member,紧跟存对应 score;
    • 一组结构:memberscore memberscore ...
  • 整体按 score 升序排布:score 越小,越靠近 ZipList 头部;score 越大越靠尾部;
  • 查询 member 的逻辑:只能从头到尾顺序遍历 O (n),找到匹配 member 后取下一个 entry 作为 score。

编码 底层结构 查询 member 效率 范围排序 内存占用 适用场景
ZipList 连续内存成对存储 member+score O (n) 顺序遍历 O (n) 遍历 极低 元素少、每个 member 很短
SkipList+Dict 跳表有序存储 + Dict 哈希映射 O (1) 哈希直查 O(logn) 较高(双结构冗余) 元素多、长字符串、频繁区间查询

Hash

Hash结构与Redis中的Zset非常类似:

  • 都是键值存储
  • 都需求根据键获取值
  • 键必须唯一

区别如下:

  • zset的键是member,值是score;hash的键和值都是任意值
  • zset要根据score排序;hash则无需排序

底层实现方式:压缩列表ziplist 或者 字典dict

当Hash中数据项比较少的情况下,Hash底层才用压缩列表ziplist进行存储数据,随着数据的增加,底层的ziplist就可能会转成dict,具体配置如下:

  • hash-max-ziplist-entries 512
  • hash-max-ziplist-value 64

当满足上面两个条件其中之⼀的时候,Redis就使用dict字典来实现hash。

Redis的hash之所以这样设计,是因为当ziplist变得很大的时候,它有如下几个缺点:

  • 每次插入或修改引发的realloc操作会有更大的概率造成内存拷贝,从而降低性能。
  • ⼀旦发生内存拷贝,内存拷贝的成本也相应增加,因为要拷贝更大的⼀块数据。
  • 当ziplist数据项过多的时候,在它上面查找指定的数据项就会性能变得很低,因为ziplist上的查找需要进行遍历。

总之,ziplist本来就设计为各个数据项挨在⼀起组成连续的内存空间,这种结构并不擅长做修改操作。⼀旦数据发生改动,就会引发内存realloc,可能导致内存拷贝。

hash结构如下:

zset集合如下:

因此,Hash底层采用的编码与Zset也基本一致,只需要把排序有关的SkipList去掉即可:

Hash结构默认采用ZipList编码,用以节省内存。 ZipList中相邻的两个entry 分别保存field和value

当数据量较大时,Hash结构会转为HT编码,也就是Dict。

Redis网络模型

用户空间和内核态空间

服务器大多都采用Linux系统,这里我们以Linux为例来讲解:

ubuntu和Centos 都是Linux的发行版,发行版可以看成对linux包了一层壳,任何Linux发行版,其系统内核都是Linux。我们的应用都需要通过Linux内核与硬件交互。

用户的应用,比如redis,mysql等其实是没有办法去访问我们操作系统的硬件的,所以我们可以通过发行版的这个壳子去访问内核,再通过内核去访问计算机硬件。

计算机硬件包括,如cpu,内存,网卡等等,内核(通过寻址空间)可以操作硬件的,但是内核需要不同设备的驱动,有了这些驱动之后,内核就可以去对计算机硬件去进行 内存管理,文件系统的管理,进程的管理等等。

应用会消耗资源,如果不加任何限制,用户去操作随意的去操作我们的资源,就有可能导致一些冲突,甚至有可能导致我们的系统出现无法运行的问题,因此Linux 做了两层隔离手段:内存隔离、权限隔离。

  1. 内存隔离

进程的寻址空间划分成两部分:内核空间、用户空间

什么是寻址空间呢?

程序不能直接访问物理内存,操作系统给每个进程分配独立虚拟地址空间,通过页表映射到真实物理内存。

虚拟地址是一串无符号整数,程序访问虚拟内存只需虚拟地址,由 MMU 硬件自动翻译为物理地址。

比如一个32位的操作系统,它的带宽就是32,它的虚拟地址就是2的32次方,也就是说它寻址范围就是 0~232,这就是寻址空间。232 个字节 = 4GB,这个4GB,会有3个GB分给用户空间,会有1GB给内核系统。

  • 用户空间(0 ~ 0xBFFFFFFF,共 3GB):每个进程私有,存放应用代码、堆、栈、动态库;进程之间隔离,互不干扰。
  • 内核空间(0xC0000000 ~ 0xFFFFFFFF,共 1GB):所有进程共享,存放内核代码、驱动、硬件寄存器、内核缓冲区;只有内核能读写。
  1. 权限隔离

在linux中,它们权限分成两个等级,0和3。

用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问。Redis、Java、MySQL 等业务程序全部运行在 Ring3。

内核空间可以执行特权命令(Ring0),调用一切系统资源。

所以一般情况下,用户的操作是运行在用户空间,而内核运行的数据是在内核空间的。而有的情况下,一个应用程序需要去调用一些特权资源,去调用一些内核空间的操作,所以此时CPU需要在用户态和内核态之间进行切换。

  • 隔离之后,程序要读写磁盘 / 网络怎么办?
  1. 用户程序需要操作硬件(读磁盘、发网络请求);
  2. 主动调用 Linux 提供的系统调用 API接口;
  3. CPU 从 Ring3 切换到 Ring0,内核接管操作;
  4. 内核凭借最高权限操作硬件,拿到结果;
  5. 切回用户态,把结果还给应用。

比如:

Linux 为平衡磁盘低速与 CPU 高速,提高IO效率,在内核、用户层各设缓冲区:

  • 写数据进磁盘时,用户写进用户缓冲区,内核才能把数据写进磁盘,那内核缓冲区想拿到数据,就要把用户缓冲区的数据拷贝到内核缓冲区,然后写入设备。
  • 从磁盘或网卡读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区。

针对这个操作:我们的用户在读数据时,会去向内核态申请,想要读取内核的数据,而内核数据要去等待驱动程序从硬件上读取数据,当从磁盘上加载到数据之后,内核会将数据写入到内核的缓冲区中,然后再将数据拷贝到用户态的buffer中,然后再返回给应用程序。

整体而言,速度慢的原因是 wait for data(等磁盘或网卡给数据) 和数据拷贝。

后面学的五种IO模型就是针对这两个点做优化的。

五种IO模型

阻塞IO

在《UNIX网络编程》一书中,总结归纳了5种IO模型:

  • 阻塞IO(Blocking IO)
  • 非阻塞IO(Nonblocking IO)
  • IO多路复用(IO Multiplexing)
  • 信号驱动IO(Signal Driven IO)
  • 异步IO(Asynchronous IO)

应用程序想要去读取数据,它是无法直接去读取磁盘数据的,需要先到内核里边去等待内核操作硬件拿到数据,这个过程就是1,是需要等待的。等到内核从磁盘上把数据加载出来之后,再把这个数据写给用户的缓存区,这个过程是2。

如果是阻塞IO,那么整个过程中,用户从发起读请求开始,一直到读取到数据,都是一个阻塞状态。

具体流程如下图:

用户去读取数据时,会去先发起 recvform 一个命令,去尝试从内核上加载数据,如果内核没有数据,那么用户就会等待,此时内核会去从硬件上读取数据,内核读取数据之后,会把数据拷贝到用户态,并且返回ok,整个过程,都是阻塞等待的,这就是阻塞IO。

总结如下:

顾名思义,阻塞IO就是两个阶段都必须阻塞等待:

阶段一:等硬件数据

  • 用户进程尝试读取数据(比如网卡数据)
  • 此时数据尚未到达,内核需要等待数据
  • 此时用户进程也处于阻塞状态

阶段二:等内存拷贝

  • 数据到达并拷贝到内核缓冲区,代表已就绪
  • 将内核数据拷贝到用户缓冲区
  • 拷贝过程中,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据

可以看到,阻塞IO模型中,用户进程在两个阶段都是阻塞状态。

非阻塞IO

顾名思义,非阻塞IO的 recvfrom 操作会立即返回结果而不是阻塞用户进程。

阶段一:

  • 用户进程尝试读取数据(比如网卡数据)
  • 此时数据尚未到达,内核需要等待数据
  • 返回异常给用户进程
  • 用户进程拿到error后,再次尝试读取
  • 循环往复,不断重复调用recvfrom轮询查询,直到内核缓冲区出现数据。
  • 环不停查询,CPU 一直满负荷跑空逻辑,耗费体力,这就是CPU 空转

阶段二:

  • 将内核数据拷贝到用户缓冲区
  • 拷贝过程中,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据

可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。

IO多路复用

无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理方案:

如果调用recvfrom时,恰好没有数据,阻塞IO会使CPU阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据。

而在单线程情况下,只能依次处理IO事件,如果正在处理的IO事件恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有IO事件都必须等待,性能自然会很差。

就比如服务员给顾客点餐,分两步

  • 顾客思考要吃什么(等待数据就绪)
  • 顾客想好了,开始点餐(读取数据)

要提高效率有几种办法?

  1. 增加更多服务员(多线程)
  2. 不排队,谁想好了吃什么(数据就绪了),服务员就给谁点餐(用户应用就去读取数据)
  • 用户进程如何知道内核中数据是否就绪呢?

这个问题的解决依赖于提出的

文件描述符(File Descriptor):简称FD,是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备、网络套接字(Socket)都对应一个数字FD。

进程把一堆需要监听的 Socket FD 打包传给内核,内核在内核空间持续监控所有 FD 的读写状态,并在某个FD可读、可写时通知用户进程,从而避免无效的等待,充分利用CPU资源。

阶段一:(阻塞)

  • 用户进程创建 FD 集合,放入所有要监听的 Socket
  • 用户进程调用 select(FD集合),指定要监听的FD集合
  • 监听FD对应的多个 socket
  • 任意一个或多个 socket 数据就绪则返回 readable
  • 此过程中用户进程阻塞

阶段二:(阻塞)

  • 用户进程找到就绪的socket
  • 依次调用 recvfrom 读取数据
  • 内核将数据拷贝到用户空间
  • 用户进程处理数据

当用户去读取数据的时候,不再去直接调用recvfrom了,而是调用select的函数,select函数会将需要监听的数据交给内核,由内核去检查这些数据是否就绪了。如果说这个数据就绪了,就会通知应用程序数据就绪,然后来读取数据,再从内核中把数据拷贝给用户态,完成数据处理,如果N多个FD一个都没处理完,此时就进行等待。

用IO复用模式,可以确保去读数据的时候,数据是一定存在的,它的效率比原来的阻塞IO和非阻塞IO性能都要高

IO多路复用是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。不过监听FD的方式、通知的方式又有多种实现,常见的有:

  • select
  • poll
  • epoll

其中select 和 epool 相当于是当被监听的数据准备好之后,它会把你监听的FD整个数据都发给你,你需要到整个FD中去找,哪些是处理好了的,需要通过遍历的方式,所以性能也并不是那么好。

而epoll,则相当于内核准备好了之后,会把准备好的数据,直接发给你,就省去了遍历的动作。

select方式

select是Linux最早的I/O多路复用方案:

简单说,就是我们把需要处理的数据封装成FD,然后在用户态时创建一个fd的集合(这个集合的大小是要监听的那个FD的最大值+1),这个集合的长度大小是有限制的(默认1024),同时在这个集合中,标明出来我们要控制哪些数据。

比如要监听的数据,是1,2,5 三个数据,此时会执行select函数,然后将整个FD拷贝到内核态,内核态会去遍历用户态传递过来的数据,如果发现数据都没有就绪 就休眠,直到有数据准备好时,就会被唤醒。唤醒之后,再次遍历一遍,看谁准备好了,然后再处理掉没有准备好的数据,最后再将这个FD集合写回到用户态中去,此时用户态就知道有人准备好了,但是对于用户态而言,并不知道谁处理好了,所以用户态也需要去进行遍历,然后找到对应准备好数据的节点,再去发起读请求。

  • fd_set(fd集合) 本质是一张位图(bit 数组),每一个 bit 对应一个文件描述符 fd
    • bit = 1:代表这个 fd 是你要监听的;
    • bit = 0:代表不监听。

举例:监听 fd=1、2、5,最大 fd=5,集合大小 = 6

  1. 程序每次 select 前都要把 1、2、5 塞进集合;
    1. 下标 0 1 2 3 4 5
    2. bit 0 1 1 0 0 1
  2. 传给内核,内核循环检查 1、2、3、4、5;
  3. 假设只有 fd=5 有数据,内核把 1、2 的标记清除,只保留 5;
    1. 下标 0 1 2 3 4 5
    2. bit 0 0 0 0 0 1
  4. 整张 6 位的集合拷贝回用户;
  5. 程序循环判断 1、2、3、4、5,才发现 fd=5 就绪;
  6. 下一轮循环,原来要监听的 1、2 标记被清成 0 了,所以要把 1、2、5 重新加入集合,重复上面全部流程。

我们会发现,这种模式下虽然比阻塞IO和非阻塞IO好,但是依然有些麻烦的事情, 比如说频繁的传递fd集合,频繁的去遍历FD等问题。

poll模式

poll 模式对 select 模式做了简单改进,但性能提升不明显,部分关键代码如下:

IO流程:

  • 创建 pollfd 数组,向其中添加关注的fd信息,数组大小自定义
  • 调用 poll 函数,将 pollfd 数组拷贝到内核空间,转链表存储,无上限
  • 内核遍历fd,判断是否就绪
  • 数据就绪或超时后,拷贝 pollfd 数组到用户空间,返回就绪fd数量 n,但不会给出具体是哪几个
  • 用户进程判断n是否大于0,大于0则遍历pollfd数组,找到就绪的fd

与select对比:

  • select模式中的fd_set大小固定为1024,而pollfd在内核中采用链表,理论上无上限
  • 不用每次调用前重置监听集合,events(用户监听事件)和revents(内核就绪标记)完全分离,内核只会修改 revents,不会改动 events
  • 监听FD越多,每次遍历消耗时间也越久,性能反而会下降
epoll函数

epoll 模式是对select和poll的改进,它提供了三个函数:

  • epoll_create

调用此函数创建eventpoll(poll实例),内核生成一个独立的 epoll 对象,内部维护两个核心容器:

  1. 红黑树 -> 记录的事要监听的FD
  2. 一个是链表 -> 一个链表,记录的是就绪的FD
  • epoll_ctl

将要监听的数据添加到红黑树上去,并且给每个fd设置一个监听函数 ep_poll_callback,这个函数会在fd数据就绪时触发,就是准备好了,现在就把fd把数据添加到 list_head链表中去。

  • epoll_wait
  1. 用户态提前创建空的 events 数组,用来接收就绪事件。
  2. 调用 epoll_wait 进入内核,内核先检查就绪链表 rdllist:
    • 链表非空:直接把链表里所有就绪 fd 事件拷贝到用户 events 数组,返回就绪数量 n;
    • 链表为空:进程阻塞休眠,让出 CPU,等待硬件中断唤醒;
  3. 用户拿到 events 数组,数组里只有就绪 fd,只需要遍历少量活跃连接,不用遍历全部监听 fd;
  4. 逐个调用 recvfrom 读取数据,完成业务处理。

小总结:

select模式存在的三个问题:

  • 能监听的FD最大不超过1024
  • 每次select都需要把所有要监听的FD都拷贝到内核空间
  • 每次都要遍历所有FD来判断就绪状态

poll模式的问题:

  • poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降

epoll模式中如何解决这些问题的?

  • 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且红黑树增删改查效率都非常高
  • 每个FD只需要执行一次 epoll_ctl 添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
  • 利用ep_poll_callback 机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降
问题点 select poll epoll
FD 数量上限 固定 1024 无硬限 无硬限
fd 拷贝方式 每次全量双向拷贝 每次全量双向拷贝 仅 epoll_ctl 拷贝一次
就绪检测方式 内核遍历全部 fd 内核遍历全部 fd 回调自动存入就绪链表
用户遍历范围 所有监听 fd 所有监听 fd 仅少量就绪 fd
复杂度 O(n) O(n) O (活跃连接数)
epoll中的ET和LT

当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。

但是事件通知的模式有两种:

  • LevelTriggered:简称LT,也叫做水平触发(默认)

只要内核缓冲区还有未读完的数据,每次调用 epoll_wait 都会持续通知你这个 fd 可读。

  • EdgeTriggered:简称ET,也叫做边沿触发

只有发生 新数据抵达 这一状态跳变,才通知一次。缓冲区剩有旧数据也不会通知。

举个栗子:客户端发 2kb 数据,服务端一次只 read 1kb

  1. 假设一个客户端socket对应的FD已经注册到了epoll实例中
  2. 客户端socket发送了2kb的数据
  3. 服务端调用 epoll_wait,得到通知说FD就绪
  4. 服务端从FD读取了1kb数据
  5. 再次调用epoll_wait,形成循环

如果我们采用LT模式,因为FD中仍有1kb数据,则第5步依然会返回结果,并且得到通知

如果我们采用ET模式,因为第4步已经消费了FD可读事件,第5步FD状态没有变化,因此epoll_wait不会返回,数据无法读取,客户端响应超时。

  • LT(默认)

优点:编程简单,不用一次性读完缓冲区;哪怕一次只读少量数据,下一轮 epoll_wait 还会提醒;

缺点:若代码处理缓慢、缓冲区一直有数据,会频繁唤醒 epoll_wait,产生大量重复事件(消耗性能)。

  • ET

优点:仅数据新来时通知一次,事件触发次数更少,减少用户态 / 内核态切换开销;高并发海量连接场景性能更好(Nginx 默认使用 ET)。

缺点:编码要求严格,必须循环读完缓冲区全部数据,否则数据滞留;逻辑出错极易造成数据丢失、连接卡死。

结论:

ET模式避免了LT模式可能出现的惊群现象.

ET模式最好结合非阻塞IO读取FD数据,相比LT会复杂一些。

基于epoll的服务器端流程

服务器启动以后,服务端会去调用 epoll_create,创建一个epoll实例,epoll实例中包含两个数据:

1、红黑树(为空):rb_root 用来去记录需要被监听的FD

2、链表(为空):list_head,用来存放已经就绪的FD

创建好了之后,会去调用 epoll_ctl 函数,此函数会将需要监听的数据添加到 rb_root 红黑树中,后续每一个新客户端连接 fd,都要再次调用 epoll_ctl 添加到红黑树。并且对红黑树的节点设置回调函数,当这些被监听的数据一旦准备完成,就会被调用,而调用的结果就是将红黑树的fd添加到list_head 链表中去。

调用epoll_wait函数,这个函数会去校验是否有数据准备完毕(因为数据一旦准备就绪,就会被回调函数添加到list_head中),在等待了一段时间后(可以进行配置),如果等够了超时时间,则返回没有数据。如果有,则进一步判断当前是什么事件,如果是建立连接时间,则调用 accept() 接受客户端socket,拿到建立连接的socket,然后建立起来连接,如果是其他事件,则把数据进行写出。

信号驱动IO

信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。

阶段一:

  • 用户进程调用 sigaction 注册 SIGIO 信号的回调处理函数;
  • 内核返回成功,开始监听FD
  • 用户进程不阻塞等待,可以执行其它业务
  • 当内核数据就绪后,回调用户进程的SIGIO处理函数

阶段二:

  • 收到SIGIO回调信号
  • 调用recvfrom,读取
  • 内核将数据拷贝到用户空间
  • 用户进程处理数据
  • 缺点

如果瞬间成千上百个 socket 同时就绪,内核会批量产生大量 SIGIO 信号,SIGIO处理函数不能及时处理,可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。

信号触发会发生用户态 / 内核态切换、保存上下文、打断当前运行代码:

海量并发场景下,信号频繁触发,大量上下文切换严重消耗 CPU,整体性能不如 epoll。

SIGIO 信号只通知 "有 fd 就绪",不告诉你是哪一个 fd;

  • 适用:连接数量少、IO 事件稀疏的简单程序;
  • 不适用:高并发服务(Nginx、Redis、后端网关全部不用);

主流高并发服务统一选用 epoll 多路复用,解决了信号丢失、频繁切换、无法区分就绪 fd 等全部问题。

异步IO

这种方式,不仅仅是用户态在试图读取数据后 不阻塞,而且当内核的数据准备完成后 也不会阻塞。

它会由内核将所有数据处理完成后,由内核将数据写入到用户态中,然后才算完成,所以性能极高,不会有任何阻塞,全部都由内核完成,可以看到,异步IO模型中,用户进程在两个阶段都是非阻塞状态。

Linux 原生 AIO 主要针对磁盘文件 IO,对 socket 网络支持不完善;

IO模型对比

IO 模型 阶段 1:等待硬件数据 阶段 2:内核→用户缓冲区拷贝 线程是否阻塞 核心缺点 适用场景
阻塞 IO 阻塞,线程休眠 阻塞等待拷贝 两阶段全阻塞 单线程只能处理 1 个连接,高并发需要大量线程,内存、上下文切换开销大 简单单机小程序、低并发工具
非阻塞 IO 不阻塞,无数据立刻返回,循环轮询 拷贝阶段阻塞 阶段 1 忙等 CPU 100%,阶段 2 阻塞 循环轮询 CPU 空转,资源浪费 几乎不单独使用,仅配合多路复用
IO 多路复用 (epoll) epoll_wait 阻塞等待事件 recvfrom 拷贝阻塞 等待事件时阻塞,读数据拷贝阻塞 拷贝阶段仍会短暂阻塞;select/poll 有遍历缺陷,epoll 已优化 网络高并发:Nginx/Redis/Java NIO 主流方案
信号驱动 IO 完全不阻塞,线程执行业务 收到信号后调用 recvfrom 拷贝阻塞 仅拷贝阶段阻塞 大量事件会信号队列溢出丢失事件,无法区分就绪 fd,频繁信号切换损耗性能 低并发、IO 事件稀疏小程序,生产环境极少使用
异步 AIO 完全不阻塞,提交请求直接返回 内核后台自动完成拷贝,无需用户线程参与 两阶段全程无阻塞 Linux 对网络 socket 支持差,仅磁盘文件完善;API 复杂 数据库、大文件磁盘读写,网络服务不用

Redis单线程

Redis到底是单线程还是多线程?

  • 如果只看命令执行(核心业务逻辑),答案是单线程
    • 所有GET/SET/HGET等读写内存的命令,全部由唯一主线程串行执行,不存在并发执行,天然无线程安全问题。
  • 如果看整个 Redis 进程,那么答案就是多线程
    • 包含主线程、BIO 后台异步线程、Redis6.0 新增 IO 多线程三类线程,分工完全隔离,互不干涉核心命令执行。

在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:

  • Redis v4.0:引入多线程异步处理一些耗时较旧的任务,例如异步删除命令unlink
  • Redis v6.0:在核心网络模型中引入 多线程,进一步提高对于多核CPU的利用率

因此,对于Redis的核心网络模型,在Redis 6.0之前确实都是单线程。是利用epoll(Linux系统)这样的IO多路复用技术在事件循环中不断处理客户端情况。

为什么Redis要选择单线程?

  • 抛开持久化不谈,Redis 是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。
  • 多线程会导致过多的上下文切换,带来不必要的开销
  • 引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣

Redis 通过IO多路复用来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现进行封装,提供了统一的高性能事件库API库AE:

单线程和多线程网络模型变更

Redis 主线程基于 epoll IO 多路复用,把网络事件拆分成 4 类事件处理器:

  1. 连接应答处理器(acceptHandler)

监听 listenfd,专门处理客户端 TCP 握手、新建连接;

  1. 客户端请求读处理器(readQueryFromClient)

连接就绪可读时,读取客户端 socket 二进制数据流;

  1. 命令执行处理器

解析缓冲区字节 → 转成 Redis 命令对象 → 单线程操作内存数据结构;

  1. 命令回复写处理器(sendReplyToClient)

命令执行完成,把返回数据写入 socket 缓冲区,回复客户端。

当我们的客户端想要去连接我们服务器,会去先到IO多路复用模型去进行排队,会有一个连接应答处理器,它会去接受读请求,然后又把读请求注册到具体模型中。此时这些建立起来的连接,如果是客户端请求处理器去进行执行命令时,它会去把数据读取出来,然后把数据放入到client中, client 去解析当前的命令转化为redis认识的命令,接下来就开始处理这些命令,从redis中的command中找到这些命令,然后就真正的去操作对应的数据了,当数据操作完成后,会去找到命令回复处理器,再由它将数据写出。

Redis6.0 之前:纯单线程网络模型

  1. 客户端发起 TCP 连接,内核完成三次握手,listenfd 产生可读事件,epoll_wait 唤醒主线程;
  2. 连接应答处理器执行 accept,生成新客户端 connfd,注册读事件到 epoll 红黑树;
  3. 客户端发送指令(set/get),connfd 可读触发 epoll 事件;
  4. 读请求处理器:主线程调用 read,把 socket 数据读到客户端缓冲区 client;
  5. client 缓冲区解析字节流,转化为 Redis 可识别命令对象;
  6. 主线程串行执行命令,操作 SDS/Hash/ZSet 等内存数据;
  7. 生成响应数据,注册写事件;
  8. 命令回复处理器:主线程调用 write,把结果写回 socket 发给客户端;
  9. 一轮处理完毕,主线程再次阻塞 epoll_wait,等待下一批网络事件。
  • 单线程模型特点

所有操作:连接建立、读网络、解析命令、执行内存操作、写回响应

全部由一条主线程串行执行,无其他线程参与网络读写。

瓶颈:高并发大量客户端收发大包数据时,read/write 系统调用占用主线程 CPU,拖慢命令执行。

Redis6.0+:IO 多线程网络模型

  1. 读流程(多线程并行读网络)

  2. epoll_wait 主线程捕获一批可读 connfd;

  3. 主线程将就绪 socket 分发到多个 IO 工作线程;

  4. IO 多线程并行执行读请求处理器,批量读取 socket 数据存入 client 缓冲区;

  5. 主线程阻塞等待所有 IO 线程读取完成,回收所有客户端缓冲区数据;

  6. 主线程单线程统一解析、串行执行所有 Redis 内存命令。

  7. 写流程(多线程并行回包)

  8. 主线程执行完所有命令,生成每条客户端的响应数据;

  9. 将待写 socket 分发至 IO 线程池;

  10. IO 多线程并行执行命令回复处理器,批量 write 把数据发回客户端;

  11. 主线程等待全部写操作完成,继续下一轮 epoll 事件循环。

  12. 不变的核心逻辑

连接应答处理器(accept 新建连接)依旧主线程单线程执行;

命令解析、内存数据操作、过期键、持久化相关逻辑,全程主线程串行,不存在多线程并发修改数据。

环节 Redis6 前 纯单线程 Redis6+ IO 多线程
建立 TCP 连接 主线程 accept 主线程 accept
socket 读客户端数据 主线程串行 read IO 线程池并行批量 read
命令解析、内存读写 主线程串行执行 主线程串行执行(无变化)
socket 写响应返回 主线程串行 write IO 线程池并行批量 write
线程安全 无线程竞争,无需锁 IO 线程只操作网络缓冲区,不碰全局数据,无锁竞争
多核 CPU 利用 只能跑满单个 CPU 核心 网络读写分散到多个核心,提升高并发吞吐

Redis通信协议

RESP协议

Redis是一个CS架构的软件,通信一般分两步(不包括pipeline和PubSub):

  1. 客户端(client)向服务端(server)发送一条命令
  2. 服务端解析并执行命令,返回响应结果给客户端

因此客户端发送命令的格式、服务端响应结果的格式必须有一个规范,这个规范就是通信协议

而在Redis中采用的是RESP(Redis Serialization Protocol)协议:

  • Redis 1.2版本引入了RESP协议
  • Redis 2.0版本中成为与Redis服务端通信的标准,称为RESP2
  • Redis 6.0版本中,从RESP2升级到了RESP3协议,增加了更多数据类型并且支持6.0的新特性--客户端缓存

但目前,默认使用的依然是RESP2协议,也是我们要学习的协议版本(以下简称RESP)。

在RESP中,通过首字节的字符来区分不同数据类型,常用的数据类型包括5种:

  1. 单行字符串:首字节是 '+' ,后面跟上单行字符串,以CRLF( "\r\n" )结尾。例如返回"OK": "+OK\r\n"
  2. 错误(Errors):首字节是 '-' ,与单行字符串格式一样,只是字符串是异常信息,例如:"-Error message\r\n"
  3. 数值:首字节是 ':' ,后面跟上数字格式的字符串,以CRLF结尾。例如:":10\r\n"
  4. 多行字符串:首字节是 '$' ,表示二进制安全的字符串,记录字符串长度,最大支持512MB:
    1. 如果大小为0,则代表空字符串:"$0\r\n\r\n"
    2. 如果大小为-1,则代表不存在:"$-1\r\n"
  1. 数组:首字节是 '*',记录数组元素个数,再跟上元素,元素数据类型不限:

基于Socket自定义Redis的客户端

Redis支持TCP通信,因此我们可以使用Socket来模拟客户端,与Redis服务端建立连接:

java 复制代码
public class Main {
    
    static Socket s;
    static PrintWriter writer;
    static BufferedReader reader;
    
    public static void main(String[] args) {
        try {
            // 1.建立连接
            String host = "192.168.150.101";
            int port = 6379;
            s = new Socket(host, port);
            // 2.获取输出流、输入流
            writer = new PrintWriter(new OutputStreamWriter(s.getOutputStream(), StandardCharsets.UTF_8));
            reader = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
            
            // 3.发出请求
            // 3.1.获取授权 auth 123321
            sendRequest("auth", "123321");
            Object obj = handleResponse();
            System.out.println("obj = " + obj);
            
            // 3.2.set name 虎哥
            sendRequest("set", "name", "虎哥");
            // 4.解析响应
            obj = handleResponse();
            System.out.println("obj = " + obj);
            
            // 3.2.set name 虎哥
            sendRequest("get", "name");
            // 4.解析响应
            obj = handleResponse();
            System.out.println("obj = " + obj);
            
            // 3.2.set name 虎哥
            sendRequest("mget", "name", "num", "msg");
            // 4.解析响应
            obj = handleResponse();
            System.out.println("obj = " + obj);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            // 5.释放连接
            try {
                if (reader != null) reader.close();
                if (writer != null) writer.close();
                if (s != null) s.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    
    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;
                }
                if (len == 0) {
                    return "";
                }
                // 再读数据,读len个字节。我们假设没有特殊字符,所以读一行(简化)
                return reader.readLine();
            case '*':
                return readBulkString();
            default:
                throw new RuntimeException("错误的数据格式!");
        }
    }
    
    private static Object readBulkString() throws IOException {
        // 获取数组大小
        int len = Integer.parseInt(reader.readLine());
        if (len <= 0) {
            return null;
        }
        // 定义集合,接收多个元素
        List<Object> list = new ArrayList<>(len);
        // 遍历,依次读取每个元素
        for (int i = 0; i < len; i++) {
            list.add(handleResponse());
        }
        return list;
    }

    // set name 虎哥
    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其内存大小不宜过大,会影响持久化或主从同步性能。我们可以通过修改配置文件来设置Redis的最大内存:

当内存使用达到上限时,就无法存储更多数据了,无法写入新 key。

为了解决这个问题,Redis提供了一些策略实现内存回收:内存过期策略

在学习Redis缓存的时候我们说过,可以通过expire命令给Redis的key设置TTL(存活时间):

可以发现,当key的TTL到期以后,再次访问name返回的是nil,说明这个key已经不存在了,对应的内存也得到释放。从而起到内存回收的目的。

Redis本身是一个典型的key-value内存存储数据库,因此所有的key、value都保存在之前学习过的Dict结构中。不过在其database结构体中,有两个Dict:一个用来记录key-value;另一个用来记录key-TTL。

每个数据库 redisDb 维护两张独立哈希字典:

  1. dict(主字典):存储 key -> RedisObject,记录了 key-value;
  2. expires(过期字典):存储 key -> 过期时间戳(ms),只记录设置了 TTL 的 key,记录 key-ttl。

如果没有设置过期时间,只保存在 dict;如果设置了,两个字典都保存这个key。

这里有两个问题需要我们思考:

  1. Redis是如何知道一个key是否过期呢?

利用两个Dict分别记录 key-value对 与 key-ttl对,读写 key 时,去 expires 字典查询该 key 对应的时间戳,和当前系统时间对比。

  1. 那是不是TTL到期就立即删除了呢?

惰性删除

惰性删除:顾名思义并不是在TTL到期后就立刻删除,而是在访问一个key的时候,检查该key的存活时间,发现已经过期才执行删除。

优点:CPU 开销极小,删除逻辑分摊到用户请求,无后台扫描损耗;

缺点:内存不友好。如果一批过期 key永久不再被访问,会一直占用内存,造成内存泄漏。

周期删除

周期删除:顾名思义是通过一个定时任务,周期性的抽样部分过期的key,然后执行删除。

抽样会随着任务推进,把所有的key都遍历一遍。

执行周期分成两种模式:SLOW/FAST 双模式

  1. SLOW模式规则
  • 触发时机:服务初始化 initServer() 注册全局定时任务 databasesCron
  • 频率:由 server.hz 控制,默认 hz=10 → 每秒执行 10 次,每 100ms 一轮;
  • 时间上限:单次清理耗时不超过一轮周期(如100ms)的 25%,默认最多 25ms;
  • 执行规则:
    • 循环遍历所有 DB,每个 DB 随机抽取 20 个带 TTL 的 key 判断是否过期;
    • 删除所有抽样出的过期 key,统计过期比例;
    • 若未超时(25ms) 且 抽样过期 key 占比 > 10%,继续抽样一轮,否则停止本轮清理。
  1. FAST模式规则
  • 触发时机:每轮 epoll 事件循环执行前,调用 beforeSleep()
  • 限制:两次 FAST 模式间隔不低于2ms,避免频繁扫描占用 CPU;
  • 时间上限:单次清理最多耗时1ms;
  • 执行规则:
    • 先判断:如果全局过期 key 比例<10%,直接跳过不执行;
    • 满足条件则遍历 DB,每库抽样 20 个 key,删除过期数据;
    • 未超时且过期比例 > 10%,重复抽样,否则结束。

小总结:

  • RedisKey的TTL记录方式
    • 在RedisDB中通过一个Dict记录每个Key的TTL时间
  • 过期key的删除策略
    • 惰性清理:每次查找key时判断是否过期,如果过期则删除
    • 定期清理:定期抽样部分key,判断是否过期,如果过期则删除。定期清理的两种模式:
    • SLOW模式执行频率默认为10,间隔100ms,每次不超过25ms
    • FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms

内存淘汰策略

内存淘汰:就是当Redis内存使用达到设置的上限时,主动挑选部分key删除以释放更多内存。

在处理每条客户端命令的入口函数 processCommand() 内部,会调用 freeMemoryIfNeeded() 检查内存:

  • 只读命令(GET、HGET):不会新增内存,不会触发淘汰;
  • 写命令(SET、LPUSH、HSET):会占用内存,执行前强制检查内存,超限则循环淘汰 Key,直到内存低于 maxmemory,才执行当前写入命令。

淘汰策略

Redis支持8种不同策略来选择要删除的key:

  • noeviction: 不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略。
  • volatile-ttl: 对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰
  • allkeys-random:对全体key ,随机进行淘汰。也就是直接从db->dict中随机挑选
  • volatile-random:对设置了TTL的key ,随机进行淘汰。也就是从db->expires中随机挑选。
  • allkeys-lru: 对全体key,基于LRU算法进行淘汰(生产最常用
  • volatile-lru: 对设置了TTL的key,基于LRU算法进行淘汰
  • allkeys-lfu: 对全体key,基于LFU算法进行淘汰(热点频繁变化场景
  • volatile-lfu: 对设置了TTL的key,基于LFU算法进行淘汰

比较容易混淆的有两个:

  • LRU(Least Recently Used),最少最近使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
  • LFU(Least Frequently Used),最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。

Redis的数据都会被封装为RedisObject结构:

LFU的访问次数之所以叫做 逻辑访问次数,不是真实访问次数:LFU 计数器不是每次访问直接 + 1。

因为记录访问次数只有 8bit,最大值固定 255。热点key可能有会被访问成千上万次,255次不够计数。

所以要通过运算:

  • 生成0~1之间的随机数R;
  • 计算 P = 1 / (当前计数器值 × lfu_log_factor + 1),(默认 lfu_log_factor=10);
  • 如果 R < P ,则计数器 + 1,最大值封顶 255;
  • 计数器值越来越大,则P越来越小,P>R的概率也越来越小;
  • 访问次数会随时间衰减,距离上一次访问时间每隔 lfu_decay_time (默认1)分钟,计数器 -1;
  • 长期无人访问的 Key,计数持续降低,内存淘汰时优先被删掉。

特性:计数器数值越小(低频 Key),P 越大,更容易 + 1;高频 Key 增长越来越慢,防止快速拉满 255 无法区分热度。

举个例子:

  • KeyA:每秒被访问 1 万次
  • KeyB:每秒被访问 100 次

不加概率增长:

  • KeyA 短时间内疯狂 + 1,几天就顶到 255;
  • KeyB 慢慢涨,最后也涨到 255;
  • 此时两个 key 计数器都是 255,淘汰时 Redis 只能认为:两个 key 热度完全一样。

最后用一副图来描述当前的这个流程:

  1. 入口分支:内存充足判断
  • 菱形判断:内存是否充足
    • 是 → 直接结束淘汰流程(E),执行用户写命令;
    • 否 → 进入下一层判断。
  • 第二层判断:策略是否为NO_EVICTION
    • 是:拒绝写入,返回内存溢出错误,流程结束;
    • 否:进入正式淘汰逻辑。
  1. 分支区分:AllKeys / Volatile 筛选范围

菱形判断:是否是 AllKeys 系列策略

  • 是(allkeys-lru/allkeys-lfu/allkeys-random):从全局主字典db->dict筛选所有 key;
  • 否(volatile-lru/volatile-lfu/volatile-random/volatile-ttl):仅从过期字典db->expires筛选带 TTL 的 key。
  1. 两大淘汰执行分支

分支 A:RANDOM 随机淘汰(allkeys-random /volatile-random)

  1. 遍历所有数据库,随机挑选 1 个符合范围的 key;
  2. 直接删除该 key,释放内存;
  3. 判断当前释放内存是否满足阈值:
    1. 满足 → 结束淘汰;
    2. 不满足 → 回到随机挑选逻辑,继续循环删除。

分支 B:LRU / LFU / TTL 近似采样淘汰(核心流程)

采用**eviction_pool 淘汰池**存储本轮最优待删除 key,核心是maxmemory-samples采样机制:

  1. 初始化空的eviction_pool淘汰池;
  2. 循环遍历每一个 DB:
    1. 随机取出maxmemory-samples个符合筛选范围的 key;
    2. 根据策略计算idleTime(数值越大,越优先删除):
      1. TTL 策略:idleTime = maxTTL - 当前剩余TTL,剩余过期时间越短,idleTime 越大;
      2. LRU 策略:idleTime = 当前时间 - key最后访问时间,闲置越久 idleTime 越大;
      3. LFU 策略:idleTime = 255 - key的LFU计数,访问频次越低 idleTime 越大;
    3. 校验当前 key 是否可存入淘汰池:按idleTime升序存入池子,池子只保留本轮采样中最适合删除的 key;
    4. 判断是否存在下一个 DB,存在则继续采样;
  3. 全部 DB 采样完成后,倒序从 eviction_pool 取出 idleTime 最大的 key(最优淘汰对象)
  4. 删除该 key,释放内存;
  5. 判断释放内存是否达标:
    1. 满足 → 淘汰流程结束;
    2. 不满足 → 回到 eviction_pool 初始化步骤,重新全库采样一轮。
  • eviction_pool 淘汰池作用

Redis 不全局排序所有 key(会阻塞主线程),采用**采样择优**方案:每次随机取一批 key,放入淘汰池对比冷热 / 过期时间,只删除这批里最差的 key,平衡精度与主线程性能。

  • idleTime 统一设计思想

三种非随机策略统一用idleTime作为淘汰权重:数值越大,越优先删除

表格

策略 idleTime 计算公式 淘汰逻辑
volatile-ttl maxTTL - 剩余存活时间 剩余寿命越短,值越大,优先删
allkeys/volatile-lru 当前时间 - 最后访问时间 闲置越久,值越大,优先删
allkeys/volatile-lfu 255 - LFU 计数器 访问频次越低,值越大,优先删
  • 循环终止条件

每删除一个 key 后都会校验释放内存总量,只有内存占用低于maxmemory限制时,才会停止淘汰,否则持续循环采样删除。

  • Volatile 与 AllKeys 数据源区分
    • AllKeys:数据源 db->dict(全量业务 key)
    • Volatile:数据源 db->entries(expires过期字典)(仅设置过 expire 的 key)
相关推荐
2601_965798471 小时前
Why Most WordPress Themes Break Elementor and How I Fixed It
数据库·人工智能·php
long31610 小时前
MySQL 学习练习(配套 01 入门资料)
数据库·学习·mysql
数据库小学妹11 小时前
数据库等保三级和四级有什么区别?从访问控制到备份恢复的完整对比
数据库·安全·数据库安全·三级等保·等保合规·四级等保
看浪的路人12 小时前
第3讲:手写第一个 MCP Server(Python SDK)
jvm·数据库·oracle
大眼、不聚光13 小时前
5.mysql--主从同步安装部署
数据库·mysql
Yan_chen66614 小时前
SQL-LABS_Less18-20实战攻略
数据库·sql·web安全·网络安全
oradh14 小时前
Oracle RAC OCR维护操作总结
数据库·oracle·ocr·rac ocr维护·ocr维护·vote disk
snow@li14 小时前
HikariCP:高性能数据库连接池全景深入梳理
java·数据库