Redis初级

目标

  1. 线程模型
  2. 数据结构
  3. lua
  4. 虚拟内存
  5. 持久化
  6. 事务机制
  7. 内存淘汰
  8. 缓存穿透、击穿、雪崩
  9. 分布式锁
  10. redission(watchdog/lock/tryLock/jedis)
  11. key过期

线程模型

参考文章:

https://javaguide.cn/database/redis/redis-questions-01.html#redis-线程模型-重要

https://www.xiaolincoding.com/redis/base/redis_interview.html#redis-线程模型

数据结构

字符串

SDS

Redis 构建了简单动态字符串(SDS)的数据类型,作为 Redis 的默认字符串表示,包含字符串的键值对在底层都是由 SDS 实现

1
2
3
4
5
6
7
8
9
10
struct sdshdr {
// 记录buf数组中已使用字节的数量,等于 SDS 所保存字符串的长度
int len;

// 记录buf数组中未使用字节的数量
int free;

// 【字节】数组,用于保存字符串(不是字符数组)
char buf[];
};

SDS 遵循 C 字符串以空字符结尾的惯例, 保存空字符的 1 字节不计算在 len 属性,SDS 会自动为空字符分配额外的 1 字节空间和添加空字符到字符串末尾,所以空字符对于 SDS 的使用者来说是完全透明的


对比

常数复杂度获取字符串长度:

  • C 字符串不记录自身的长度,获取时需要遍历整个字符串,遇到空字符串为止,时间复杂度为 O(N)
  • SDS 获取字符串长度的时间复杂度为 O(1),设置和更新 SDS 长度由函数底层自动完成

杜绝缓冲区溢出:

  • C 字符串调用 strcat 函数拼接字符串时,如果字符串内存不够容纳目标字符串,就会造成缓冲区溢出(Buffer Overflow)

    s1 和 s2 是内存中相邻的字符串,执行 strcat(s1, " Cluster")(有空格):

  • SDS 空间分配策略:当对 SDS 进行修改时,首先检查 SDS 的空间是否满足修改所需的要求, 如果不满足会自动将 SDS 的空间扩展至执行修改所需的大小,然后执行实际的修改操作, 避免了缓冲区溢出的问题

二进制安全:

  • C 字符串中的字符必须符合某种编码(比如 ASCII)方式,除了字符串末尾以外其他位置不能包含空字符,否则会被误认为是字符串的结尾,所以只能保存文本数据
  • SDS 的 API 都是二进制安全的,使用字节数组 buf 保存一系列的二进制数据,使用 len 属性来判断数据的结尾,所以可以保存图片、视频、压缩文件等二进制数据

兼容 C 字符串的函数:SDS 会在为 buf 数组分配空间时多分配一个字节来保存空字符,所以可以重用一部分 C 字符串函数库的函数


内存

C 字符串每次增长或者缩短都会进行一次内存重分配,拼接操作通过重分配扩展底层数组空间,截断操作通过重分配释放不使用的内存空间,防止出现内存泄露

SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联,在 SDS 中 buf 数组的长度不一定就是字符数量加一, 数组里面可以包含未使用的字节,字节的数量由 free 属性记录

内存重分配涉及复杂的算法,需要执行系统调用,是一个比较耗时的操作,SDS 的两种优化策略:

  • 空间预分配:当 SDS 的 API 进行修改并且需要进行空间扩展时,程序不仅会为 SDS 分配修改所必需的空间, 还会为 SDS 分配额外的未使用空间

    • 对 SDS 修改之后,SDS 的长度(len 属性)小于 1MB,程序分配和 len 属性同样大小的未使用空间,此时 len 和 free 相等

      s 为 Redis,执行 sdscat(s, " Cluster") 后,len 变为 13 字节,所以也分配了 13 字节的 free 空间,总长度变为 27 字节(额外的一字节保存空字符,13 + 13 + 1 = 27)

    • 对 SDS 修改之后,SDS 的长度大于等于 1MB,程序会分配 1MB 的未使用空间

    在扩展 SDS 空间前,API 会先检查 free 空间是否足够,如果足够就无需执行内存重分配,所以通过预分配策略,SDS 将连续增长 N 次字符串所需内存的重分配次数从必定 N 次降低为最多 N 次

  • 惰性空间释放:当 SDS 的 API 需要缩短字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free 属性将这些字节的数量记录起来,并等待将来使用

    SDS 提供了相应的 API 来真正释放 SDS 的未使用空间,所以不用担心空间惰性释放策略造成的内存浪费问题


链表

链表提供了高效的节点重排能力,C 语言并没有内置这种数据结构,所以 Redis 构建了链表数据类型

链表节点:

1
2
3
4
5
6
7
8
9
10
typedef struct listNode {
// 前置节点
struct listNode *prev;

// 后置节点
struct listNode *next;

// 节点的值
void *value
} listNode;

多个 listNode 通过 prev 和 next 指针组成双端链表

list 链表结构:提供了表头指针 head 、表尾指针 tail 以及链表长度计数器 len

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;

// 链表所包含的节点数量
unsigned long len;

// 节点值复制函数,用于复制链表节点所保存的值
void *(*dup) (void *ptr);
// 节点值释放函数,用于释放链表节点所保存的值
void (*free) (void *ptr);
// 节点值对比函数,用于对比链表节点所保存的值和另一个输入值是否相等
int (*match) (void *ptr, void *key);
} list;

Redis 链表的特性:

  • 双端:链表节点带有 prev 和 next 指针,获取某个节点的前置节点和后置节点的时间复杂度都是 O(1)
  • 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问以 NULL 为终点
  • 带表头指针和表尾指针: 通过 list 结构的 head 指针和 tail 指针,获取链表的表头节点和表尾节点的时间复杂度为 O(1)
  • 带链表长度计数器:使用 len 属性来对 list 持有的链表节点进行计数,获取链表中节点数量的时间复杂度为 O(1)
  • 多态:链表节点使用 void * 指针来保存节点值, 并且可以通过 dup、free 、match 三个属性为节点值设置类型特定函数,所以链表可以保存各种不同类型的值

字典

哈希表

Redis 字典使用的哈希表结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct dictht {
// 哈希表数组,数组中每个元素指向 dictEntry 结构
dictEntry **table;

// 哈希表大小,数组的长度
unsigned long size;

// 哈希表大小掩码,用于计算索引值,总是等于 【size-1】
unsigned long sizemask;

// 该哈希表已有节点的数量
unsigned long used;
} dictht;

哈希表节点结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct dictEntry {
// 键
void *key;

// 值,可以是一个指针,或者整数
union {
void *val; // 指针
uint64_t u64;
int64_t s64;
}

// 指向下个哈希表节点,形成链表,用来解决冲突问题
struct dictEntry *next;
} dictEntry;


字典结构

字典,又称为符号表、关联数组、映射(Map),用于保存键值对的数据结构,字典中的每个键都是独一无二的。底层采用哈希表实现,一个哈希表包含多个哈希表节点,每个节点保存一个键值对

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct dict {
// 类型特定函数
dictType *type;

// 私有数据
void *privdata;

// 哈希表,数组中的每个项都是一个dictht哈希表,
// 一般情况下字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用
dictht ht[2];

// rehash 索引,当 rehash 不在进行时,值为 -1
int rehashidx;
} dict;

type 属性和 privdata 属性是针对不同类型的键值对, 为创建多态字典而设置的:

  • type 属性是指向 dictType 结构的指针, 每个 dictType 结构保存了一簇用于操作特定类型键值对的函数, Redis 会为用途不同的字典设置不同的类型特定函数
  • privdata 属性保存了需要传给那些类型特定函数的可选参数


哈希冲突

Redis 使用 MurmurHash 算法来计算键的哈希值,这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快

将一个新的键值对添加到字典里,需要先根据键 key 计算出哈希值,然后进行取模运算(取余):

1
index = hash & dict->ht[x].sizemask

当有两个或以上数量的键被分配到了哈希表数组的同一个索引上时,就称这些键发生了哈希冲突(collision)

Redis 的哈希表使用链地址法(separate chaining)来解决键哈希冲突, 每个哈希表节点都有一个 next 指针,多个节点通过 next 指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题

dictEntry 节点组成的链表没有指向链表表尾的指针,为了速度考虑,程序总是将新节点添加到链表的表头位置(头插法),时间复杂度为 O(1)


负载因子

负载因子的计算方式:哈希表中的节点数量 / 哈希表的大小(长度

1
load_factor = ht[0].used / ht[0].size

为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时 ,程序会自动对哈希表的大小进行相应的扩展或者收缩

哈希表执行扩容的条件:

  • 服务器没有执行 BGSAVE 或者 BGREWRITEAOF 命令,哈希表的负载因子大于等于 1

  • 服务器正在执行 BGSAVE 或者 BGREWRITEAOF 命令,哈希表的负载因子大于等于 5

    原因:执行该命令的过程中,Redis 需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on­-write)技术来优化子进程的使用效率,通过提高执行扩展操作的负载因子,尽可能地避免在子进程存在期间进行哈希表扩展操作,可以避免不必要的内存写入操作,最大限度地节约内存

哈希表执行收缩的条件:负载因子小于 0.1(自动执行,servreCron 中检测),缩小为字典中数据个数的 50% 左右


重新散列

扩展和收缩哈希表的操作通过 rehash(重新散列)来完成,步骤如下:

  • 为字典的 ht[1] 哈希表分配空间,空间大小的分配情况:
    • 如果执行的是扩展操作,ht[1] 的大小为第一个大于等于 $ht[0].used * 2$ 的 $2^n$
    • 如果执行的是收缩操作,ht[1] 的大小为第一个大于等于 $ht[0].used$ 的 $2^n$
  • 将保存在 ht[0] 中所有的键值对重新计算哈希值和索引值,迁移到 ht[1] 上
  • 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后(ht[0]变为空表), 释放 ht[0],将 ht[1] 设置为 ht[0],并在 ht[1] 创建一个新的空白哈希表,为下一次 rehash 做准备

如果哈希表里保存的键值对数量很少,rehash 就可以在瞬间完成,但是如果哈希表里数据很多,那么要一次性将这些键值对全部 rehash 到 ht[1] 需要大量计算,可能会导致服务器在一段时间内停止服务

Redis 对 rehash 做了优化,使 rehash 的动作并不是一次性、集中式的完成,而是分多次,渐进式的完成,又叫渐进式 rehash

  • 为 ht[1] 分配空间,此时字典同时持有 ht[0] 和 ht[1] 两个哈希表
  • 在字典中维护了一个索引计数器变量 rehashidx,并将变量的值设为 0,表示 rehash 正式开始
  • 在 rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1],rehash 完成之后将 rehashidx 属性的值增一
  • 随着字典操作的不断执行,最终在某个时间点上 ht[0] 的所有键值对都被 rehash 至 ht[1],这时程序将 rehashidx 属性的值设为 -1,表示 rehash 操作已完成

渐进式 rehash 采用分而治之的方式,将 rehash 键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式 rehash 带来的庞大计算量

渐进式 rehash 期间的哈希表操作:

  • 字典的查找、删除、更新操作会在两个哈希表上进行,比如查找一个键会先在 ht[0] 上查找,查找不到就去 ht[1] 继续查找
  • 字典的添加操作会直接在 ht[1] 上添加,不在 ht[0] 上进行任何添加

跳跃表

底层结构

跳跃表(skiplist)是一种有序(默认升序)的数据结构,在链表的基础上增加了多级索引以提升查找的效率,索引是占内存的,所以是一个空间换时间的方案,跳表平均 O(logN)、最坏 O(N) 复杂度的节点查找,效率与平衡树相当但是实现更简单

原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,因此当节点本身比较大或者元素数量比较多的时候,其优势可以被放大,而缺点(占内存)则可以忽略

Redis 只在两个地方应用了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构

1
2
3
4
5
6
7
8
9
10
typedef struct zskiplist {
// 表头节点和表尾节点,O(1) 的时间复杂度定位头尾节点
struct skiplistNode *head, *tail;

// 表的长度,也就是表内的节点数量 (表头节点不计算在内)
unsigned long length;

// 表中层数最大的节点的层数 (表头节点的层高不计算在内)
int level
} zskiplist;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct zskiplistNode {
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];

// 后退指针
struct zskiplistNode *backward;

// 分值
double score;

// 成员对象
robj *obj;
} zskiplistNode;


属性分析

层:level 数组包含多个元素,每个元素包含指向其他节点的指针。根据幕次定律(power law,越大的数出现的概率越小)随机生成一个介于 1 和 32 之间的值(Redis5 之后最大为 64)作为 level 数组的大小,这个大小就是层的高度,节点的第一层是 level[0] = L1

前进指针:forward 用于从表头到表尾方向正序(升序)遍历节点,遇到 NULL 停止遍历

跨度:span 用于记录两个节点之间的距离,用来计算排位(rank)

  • 两个节点之间的跨度越大相距的就越远,指向 NULL 的所有前进指针的跨度都为 0

  • 在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,结果就是目标节点在跳跃表中的排位,按照上图所示:

    查找分值为 3.0 的节点,沿途经历的层:查找的过程只经过了一个层,并且层的跨度为 3,所以目标节点在跳跃表中的排位为 3

    查找分值为 2.0 的节点,沿途经历的层:经过了两个跨度为 1 的节点,因此可以计算出目标节点在跳跃表中的排位为 2

后退指针:backward 用于从表尾到表头方向逆序(降序)遍历节点

分值:score 属性一个 double 类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序

成员对象:obj 属性是一个指针,指向一个 SDS 字符串对象。同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值可以是相同的,分值相同的节点将按照成员对象在字典序中的大小来进行排序(从小到大)

个人笔记:JUC → 并发包 → ConcurrentSkipListMap 详解跳跃表


整数集合

底层结构

整数集合(intset)是用于保存整数值的集合数据结构,是 Redis 集合键的底层实现之一

1
2
3
4
5
6
7
8
9
10
typedef struct intset {
// 编码方式
uint32_t encoding;

// 集合包含的元素数量,也就是 contents 数组的长度
uint32_t length;

// 保存元素的数组
int8_t contents[];
} intset;

encoding 取值为三种:INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64

整数集合的每个元素都是 contents 数组的一个数组项(item),在数组中按值的大小从小到大有序排列,并且数组中不包含任何重复项。虽然 contents 属性声明为 int8_t 类型,但实际上数组并不保存任何 int8_t 类型的值, 真正类型取决于 encoding 属性

说明:底层存储结构是数组,所以为了保证有序性和不重复性,每次添加一个元素的时间复杂度是 O(N)


升级降级

整数集合添加的新元素的类型比集合现有所有元素的类型都要长时,需要先进行升级(upgrade),升级流程:

  • 根据新元素的类型长度以及集合元素的数量(包括新元素在内),扩展整数集合底层数组的空间大小

  • 将底层数组现有的所有元素都转换成与新元素相同的类型,并将转换后的元素放入正确的位置,放置过程保证数组的有序性

    图示 32 * 4 = 128 位,首先将 3 放入索引 2(64 位 - 95 位),然后将 2 放置索引 1,将 1 放置在索引 0,从后向前依次放置在对应的区间,最后放置 65535 元素到索引 3(96 位- 127 位),修改 length 属性为 4

  • 将新元素添加到底层数组里

每次向整数集合添加新元素都可能会引起升级,而每次升级都需要对底层数组中的所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为 O(N)

引发升级的新元素的长度总是比整数集合现有所有元素的长度都大,所以这个新元素的值要么就大于所有现有元素,要么就小于所有现有元素,升级之后新元素的摆放位置:

  • 在新元素小于所有现有元素的情况下,新元素会被放置在底层数组的最开头(索引 0)
  • 在新元素大于所有现有元素的情况下,新元素会被放置在底层数组的最末尾(索引 length-1)

整数集合升级策略的优点:

  • 提升整数集合的灵活性:C 语言是静态类型语言,为了避免类型错误通常不会将两种不同类型的值放在同一个数据结构里面,整数集合可以自动升级底层数组来适应新元素,所以可以随意的添加整数

  • 节约内存:要让数组可以同时保存 int16、int32、int64 三种类型的值,可以直接使用 int64_t 类型的数组作为整数集合的底层实现,但是会造成内存浪费,整数集合可以确保升级操作只会在有需要的时候进行,尽量节省内存

整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态


压缩列表

底层结构

压缩列表(ziplist)是 Redis 为了节约内存而开发的,是列表键和哈希键的底层实现之一。是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构,一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值

  • zlbytes:uint32_t 类型 4 字节,记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重分或者计算 zlend 的位置时使用
  • zltail:uint32_t 类型 4 字节,记录压缩列表表尾节点距离起始地址有多少字节,通过这个偏移量程序无须遍历整个压缩列表就可以确定表尾节点的地址
  • zllen:uint16_t 类型 2 字节,记录了压缩列表包含的节点数量,当该属性的值小于 UINT16_MAX (65535) 时,该值就是压缩列表中节点的数量;当这个值等于 UINT16_MAX 时节点的真实数量需要遍历整个压缩列表才能计算得出
  • entryX:列表节点,压缩列表中的各个节点,节点的长度由节点保存的内容决定
  • zlend:uint8_t 类型 1 字节,是一个特殊值 0xFF (255),用于标记压缩列表的末端

列表 zlbytes 属性的值为 0x50 (十进制 80),表示压缩列表的总长为 80 字节,列表 zltail 属性的值为 0x3c (十进制 60),假设表的起始地址为 p,计算得出表尾节点 entry3 的地址 p + 60


列表节点

列表节点 entry 的数据结构:

previous_entry_length:以字节为单位记录了压缩列表中前一个节点的长度,程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址,完成从表尾向表头遍历操作

  • 如果前一节点的长度小于 254 字节,该属性的长度为 1 字节,前一节点的长度就保存在这一个字节里
  • 如果前一节点的长度大于等于 254 字节,该属性的长度为 5 字节,其中第一字节会被设置为 0xFE(十进制 254),之后的四个字节则用于保存前一节点的长度

encoding:记录了节点的 content 属性所保存的数据类型和长度

  • 长度为 1 字节、2 字节或者 5 字节,值的最高位为 00、01 或者 10 的是字节数组编码,数组的长度由编码除去最高两位之后的其他位记录,下划线 _ 表示留空,而 bx 等变量则代表实际的二进制数据

  • 长度为 1 字节,值的最高位为 11 的是整数编码,整数值的类型和长度由编码除去最高两位之后的其他位记录

content:每个压缩列表节点可以保存一个字节数组或者一个整数值

  • 字节数组可以是以下三种长度的其中一种:

    • 长度小于等于 $63 (2^6-1)$ 字节的字节数组

    • 长度小于等于 $16383(2^{14}-1)$ 字节的字节数组

    • 长度小于等于 $4294967295(2^{32}-1)$ 字节的字节数组

  • 整数值则可以是以下六种长度的其中一种:

    • 4 位长,介于 0 至 12 之间的无符号整数

    • 1 字节长的有符号整数

    • 3 字节长的有符号整数

    • int16_t 类型整数

    • int32_t 类型整数

    • int64_t 类型整数


连锁更新

Redis 将在特殊情况下产生的连续多次空间扩展操作称之为连锁更新(cascade update)

假设在一个压缩列表中,有多个连续的、长度介于 250 到 253 字节之间的节点 e1 至 eN。将一个长度大于等于 254 字节的新节点 new 设置为压缩列表的头节点,new 就成为 e1 的前置节点。e1 的 previous_entry_length 属性仅为 1 字节,无法保存新节点 new 的长度,所以要对压缩列表执行空间重分配操作,并将 e1 节点的 previous_entry_length 属性从 1 字节长扩展为 5 字节长。由于 e1 原本的长度介于 250 至 253 字节之间,所以扩展后 e1 的长度就变成了 254 至 257 字节之间,导致 e2 的 previous_entry_length 属性无法保存 e1 的长度,程序需要不断地对压缩列表执行空间重分配操作,直到 eN 为止

删除节点也可能会引发连锁更新,big.length >= 254,small.length < 254,删除 small 节点

连锁更新在最坏情况下需要对压缩列表执行 N 次空间重分配,每次重分配的最坏复杂度为 O(N),所以连锁更新的最坏复杂度为 O(N^2)

说明:尽管连锁更新的复杂度较高,但出现的记录是非常低的,即使出现只要被更新的节点数量不多,就不会对性能造成影响


Lua脚本

Lua是一种轻量级的、高效的、可扩展的脚本语言,常用于游戏开发、系统管理、Web应用等场景。

基本使用

数据类型

Lua支持多种数据类型,包括数字(整数和浮点数)、字符串、布尔值、table(类似于数组和哈希表)、函数等。

1
2
3
4
5
6
7
8
9
10
11
12
-- 定义变量
local x = 10
local y = "Hello, World!"
local z = true

-- 多个变量可以在一行定义
local a, b = 1, 2

-- 打印变量
print(x)
print(y)
print(z)

控制结构

Lua提供常见的控制结构,如ifforwhilerepeat-until等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- if 语句
if x > 5 then
print("x is greater than 5")
end

-- for 循环
for i = 1, 10 do
print(i)
end

-- while 循环
local count = 1
while count <= 5 do
print(count)
count = count + 1
end

-- repeat-until 循环
local num = 1
repeat
print(num)
num = num + 1
until num > 5

函数

在Lua中,函数是第一类公民,可以作为参数传递,也可以从其他函数返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 定义函数
function greet(name)
print("Hello, " .. name .. "!")
end

-- 调用函数
greet("Alice")

-- 返回函数
function createGreeting(name)
return function()
print("Hi, " .. name .. "!")
end
end

local greeting = createGreeting("Bob")
greeting()

Redis中使用

为什么使用Lua脚本?

  1. 减少网络延迟:通过将多个Redis命令封装在一个Lua脚本中,可以减少网络传输次数,提高响应速度。
  2. 原子性:Lua脚本在Redis服务器内部以原子方式执行,这意味着脚本内的所有操作要么全部成功,要么全部失败,这有助于避免竞态条件。
  3. 灵活性:Lua脚本提供了比Redis原生命令更丰富的编程能力,可以实现复杂的业务逻辑。

编写脚本

首先,需要编写Lua脚本。脚本通常接受一些键名和参数,使用KEYSARGV数组来引用它们。例如,以下脚本将两个数值相加并存储结果:

1
2
3
4
-- 将两个数值相加并存储结果
local sum = tonumber(ARGV[1]) + tonumber(ARGV[2])
redis.call('SET', KEYS[1], sum)
return sum

执行脚本

使用EVAL命令执行脚本

一旦脚本编写完成,你可以使用EVAL命令将其发送给Redis服务器执行。EVAL命令接收三个参数:

  • 脚本的文本
  • 脚本要访问的键的数量
  • 脚本要使用的键和参数
1
2
3
4
5
6
EVAL "local sum = tonumber(ARGV[1]) + tonumber(ARGV[2]); redis.call('SET', KEYS[1], sum); return sum;" 1 myKey 10 20

脚本的文本:"local sum = ...... return sum;"
键的数量:1
要使用的键:myKey
使用的参数:10 20

使用EVALSHA命令执行脚本

为了提高性能,Redis允许你通过计算脚本的SHA1校验和来缓存脚本,之后你可以使用EVALSHA命令通过校验和来执行脚本,而无需每次都发送脚本文本。这可以减少网络带宽的使用。

例如,如果脚本的SHA1校验和是abcdef1234567890,则可以使用:

1
EVALSHA "abcdef1234567890" 1 myKey 10 20

脚本调试

Redis从2.8.0版本开始支持脚本调试。你可以使用DEBUG SCRIPT命令来查看脚本是否被正确加载,以及脚本的详细信息。

注意事项

  • Lua脚本在Redis服务器中是单线程执行的,这意味着在执行脚本期间,Redis不能同时处理其他命令。
  • 应确保脚本的执行时间尽可能短,以避免阻塞Redis服务器。

SpringBoot中使用

  1. 创建一个.lua文件,例如scripts.lua,并在其中编写你的Lua脚本。例如,一个简单的脚本可能看起来像这样:

  2. 在你的Spring Boot应用中,注入StringRedisTemplateReactiveStringRedisTemplate,然后使用execute方法执行Lua脚本。这里以StringRedisTemplate为例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Service;

    @Service
    public class RedisService {

    private final StringRedisTemplate stringRedisTemplate;

    @Autowired
    public RedisService(StringRedisTemplate stringRedisTemplate) {
    this.stringRedisTemplate = stringRedisTemplate;
    }

    public long setIfAbsent(String key, String value) {
    DefaultRedisScript<Long> script = new DefaultRedisScript<>();
    script.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts.lua")));
    script.setResultType(Long.class);

    return stringRedisTemplate.execute(script, Collections.singletonList(key), value);
    }
    }

    在上述代码中,setIfAbsent方法使用了DefaultRedisScript来执行Lua脚本。ClassPathResource用于从类路径加载Lua脚本文件。

虚拟内存

Redis的虚拟内存(VM)机制是一种允许Redis将不经常访问的数据从内存移动到磁盘的技术,以此来节省服务器的RAM资源。

在Redis从2.4版本开始废弃虚拟内存功能,并且在更高版本中不推荐使用。

要在Redis中启用虚拟内存,你需要在配置文件redis.conf中设置以下选项:

  • vm-enabled yes:启用虚拟内存功能。
  • vm-max-memory <bytes>:设置Redis可以使用的最大虚拟字节数。当数据集的大小超过这个值时,Redis会开始将不常访问的数据移到磁盘上。
  • vm-page-size <bytes>:设定虚拟内存页面的大小,这影响到数据的分页方式。
  • vm-pages <number>:指定可以使用的虚拟内存页面数量。
  • vm-swap-file <path>:指定虚拟内存交换文件的路径和文件名。
  • vm-max-cache-size <bytes>:设置在内存中缓存的已分页数据的最大大小,以加速数据的读取。
  • vm-min-cache-ttl <seconds>:设置在内存中缓存的已分页数据的最小生存时间。

持久化

概述

持久化:利用永久性存储介质将数据进行保存,在特定的时间将保存的数据进行恢复的工作机制称为持久化

作用:持久化用于防止数据的意外丢失,确保数据安全性,因为 Redis 是内存级,所以需要持久化到磁盘

计算机中的数据全部都是二进制,保存一组数据有两种方式

RDB:将当前数据状态进行保存,快照形式,存储数据结果,存储格式简单

AOF:将数据的操作过程进行保存,日志形式,存储操作过程,存储格式复杂


RDB

文件创建

RDB 持久化功能所生成的 RDB文件 是一个经过压缩的紧凑二进制文件,通过该文件可以还原生成 RDB 文件时的数据库状态,有两个 Redis 命令可以生成 RDB 文件,一个是 SAVE,另一个是 BGSAVE

SAVE

SAVE 指令:手动执行一次保存操作,该指令的执行会阻塞当前 Redis 服务器,客户端发送的所有命令请求都会被拒绝,直到当前 RDB 过程完成为止,有可能会造成长时间阻塞,线上环境不建议使用

工作原理:Redis 是个单线程的工作模式,会创建一个任务队列,所有的命令都会进到这个队列排队执行。当某个指令在执行的时候,队列后面的指令都要等待,所以这种执行方式会非常耗时

配置 redis.conf:

1
2
3
4
dir path				#设置存储.rdb文件的路径,通常设置成存储空间较大的目录中,目录名称data
dbfilename "x.rdb" #设置本地数据库文件名,默认值为dump.rdb,通常设置为dump-端口号.rdb
rdbcompression yes|no #设置存储至本地数据库时是否压缩数据,默认yes,设置为no节省CPU运行时间
rdbchecksum yes|no #设置读写文件过程是否进行RDB格式校验,默认yes

BGSAVE

BGSAVE:bg 是 background,代表后台执行,命令的完成需要两个进程,进程之间不相互影响,所以持久化期间 Redis 正常工作

工作原理:

流程:客户端发出 BGSAVE 指令,Redis 服务器使用 fork 函数创建一个子进程,然后响应后台已经开始执行的信息给客户端。子进程会去执行持久化的操作,持久化过程是先将数据写入到一个临时文件中,持久化操作结束再用这个临时文件替换上次持久化的文件

1
2
3
4
5
6
7
8
9
10
11
12
13
# 创建子进程
pid = fork()
if pid == 0:
# 子进程负责创建 RDB 文件
rdbSave()
# 完成之后向父进程发送信号
signal_parent()
elif pid > 0:
# 父进程继续处理命令请求,并通过轮询等待子进程的信号
handle_request_and_wait_signal()
else:
# 处理出错恃况
handle_fork_error()

配置 redis.conf

1
2
3
4
5
stop-writes-on-bgsave-error yes|no	#后台存储过程中如果出现错误,是否停止保存操作,默认yes
dbfilename filename
dir path
rdbcompression yes|no
rdbchecksum yes|no

注意:BGSAVE 命令是针对 SAVE 阻塞问题做的优化,Redis 内部所有涉及到 RDB 操作都采用 BGSAVE 的方式,SAVE 命令放弃使用

在 BGSAVE 命令执行期间,服务器处理 SAVE、BGSAVE、BGREWRITEAOF 三个命令的方式会和平时有所不同

  • SAVE 命令会被服务器拒绝,服务器禁止 SAVE 和 BGSAVE 命令同时执行是为了避免父进程(服务器进程)和子进程同时执行两个 rdbSave 调用,产生竞争条件
  • BGSAVE 命令也会被服务器拒绝,也会产生竞争条件
  • BGREWRITEAOF 和 BGSAVE 两个命令不能同时执行
    • 如果 BGSAVE 命令正在执行,那么 BGREWRITEAOF 命令会被延迟到 BGSAVE 命令执行完毕之后执行
    • 如果 BGREWRITEAOF 命令正在执行,那么 BGSAVE 命令会被服务器拒绝

特殊指令

RDB 特殊启动形式的指令(客户端输入)

  • 服务器运行过程中重启

    1
    debug reload
  • 关闭服务器时指定保存数据

    1
    shutdown save

    默认情况下执行 shutdown 命令时,自动执行 bgsave(如果没有开启 AOF 持久化功能)

  • 全量复制:主从复制部分详解


文件载入

RDB 文件的载入工作是在服务器启动时自动执行,期间 Redis 会一直处于阻塞状态,直到载入完成

Redis 并没有专门用于载入 RDB 文件的命令,只要服务器在启动时检测到 RDB 文件存在,就会自动载入 RDB 文件

1
[7379] 30 Aug 21:07:01.289 * DB loaded from disk: 0.018 seconds  # 服务器在成功载入 RDB 文件之后打印

AOF 文件的更新频率通常比 RDB 文件的更新频率高:

  • 如果服务器开启了 AOF 持久化功能,那么会优先使用 AOF 文件来还原数据库状态
  • 只有在 AOF 持久化功能处于关闭状态时,服务器才会使用 RDB 文件来还原数据库状态

自动保存

配置文件

Redis 支持通过配置服务器的 save 选项,让服务器每隔一段时间自动执行一次 BGSAVE 命令

配置 redis.conf:

1
save second changes #设置自动持久化条件,满足限定时间范围内key的变化数量就进行持久化(bgsave)
  • second:监控时间范围
  • changes:监控 key 的变化量

默认三个条件:

1
2
3
save 900 1		# 900s内1个key发生变化就进行持久化
save 300 10
save 60 10000

判定 key 变化的依据:

  • 对数据产生了影响,不包括查询
  • 不进行数据比对,比如 name 键存在,重新 set name seazean 也算一次变化

save 配置要根据实际业务情况进行设置,频度过高或过低都会出现性能问题,结果可能是灾难性的


自动原理

服务器状态相关的属性:

1
2
3
4
5
6
7
8
9
10
struct redisServer {
// 记录了保存条件的数组
struct saveparam *saveparams;

// 修改计数器
long long dirty;

// 上一次执行保存的时间
time_t lastsave;
};
  • Redis 服务器启动时,可以通过指定配置文件或者传入启动参数的方式设置 save 选项, 如果没有自定义就设置为三个默认值(上节提及),设置服务器状态 redisServe.saveparams 属性,该数组每一项为一个 saveparam 结构,代表 save 的选项设置

    1
    2
    3
    4
    5
    6
    struct saveparam {
    // 秒数
    time_t seconds
    // 修改数
    int changes;
    };
  • dirty 计数器记录距离上一次成功执行 SAVE 或者 BGSAVE 命令之后,服务器中的所有数据库进行了多少次修改(包括写入、删除、更新等操作),当服务器成功执行一个修改指令,该命令修改了多少次数据库, dirty 的值就增加多少

  • lastsave 属性是一个 UNIX 时间戳,记录了服务器上一次成功执行 SAVE 或者 BGSAVE 命令的时间

Redis 的服务器周期性操作函数 serverCron 默认每隔 100 毫秒就会执行一次,该函数用于对正在运行的服务器进行维护

serverCron 函数的其中一项工作是检查 save 选项所设置的保存条件是否满足,会遍历 saveparams 数组中的所有保存条件,只要有任意一个条件被满足服务器就会执行 BGSAVE 命令


文件结构

RDB 的存储结构:图示全大写单词标示常量,用全小写单词标示变量和数据

  • REDIS:长度为 5 字节,保存着 REDIS 五个字符,是 RDB 文件的开头,在载入文件时可以快速检查所载入的文件是否 RDB 文件
  • db_version:长度为 4 字节,是一个用字符串表示的整数,记录 RDB 的版本号
  • database:包含着零个或任意多个数据库,以及各个数据库中的键值对数据
  • EOF:长度为 1 字节的常量,标志着 RDB 文件正文内容的结束,当读入遇到这个值时,代表所有数据库的键值对都已经载入完毕
  • check_sum:长度为 8 字节的无符号整数,保存着一个校验和,该值是通过 REDIS、db_version、databases、EOF 四个部分的内容进行计算得出。服务器在载入 RDB 文件时,会将载入数据所计算出的校验和与 check_sum 所记录的校验和进行对比,来检查 RDB 文件是否有出错或者损坏

Redis 本身带有 RDB 文件检查工具 redis-check-dump


AOF

基本概述

AOF(append only file)持久化:以独立日志的方式记录每次写命令(不记录读)来记录数据库状态,增量保存只许追加文件但不可以改写文件,与 RDB 相比可以理解为由记录数据改为记录数据的变化

AOF 主要作用是解决了数据持久化的实时性,目前已经是 Redis 持久化的主流方式

AOF 写数据过程:


持久实现

AOF 持久化功能的实现可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤

命令追加

启动 AOF 的基本配置:

1
2
3
appendonly yes|no				#开启AOF持久化功能,默认no,即不开启状态
appendfilename filename #AOF持久化文件名,默认appendonly.aof,建议设置appendonly-端口号.aof
dir #AOF持久化文件保存路径,与RDB持久化文件路径保持一致即可

当 AOF 持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的 aof_buf 缓冲区的末尾

1
2
3
4
struct redisServer {
// AOF 缓冲区
sds aof_buf;
};

文件写入

服务器在处理文件事件时可能会执行写命令,追加一些内容到 aof_buf 缓冲区里,所以服务器每次结束一个事件循环之前,就会执行 flushAppendOnlyFile 函数,判断是否需要将 aof_buf 缓冲区中的内容写入和保存到 AOF 文件

flushAppendOnlyFile 函数的行为由服务器配置的 appendfsync 选项的值来决定

1
appendfsync always|everysec|no	#AOF写数据策略:默认为everysec
  • always:每次写入操作都将 aof_buf 缓冲区中的所有内容写入并同步到 AOF 文件

    特点:安全性最高,数据零误差,但是性能较低,不建议使用

  • everysec:先将 aof_buf 缓冲区中的内容写入到 AOF 文件,判断上次同步 AOF 文件的时间距离现在超过一秒钟,再次对 AOF 文件进行同步,这个同步操作是由一个(子)线程专门负责执行的

    特点:在系统突然宕机的情况下丢失 1 秒内的数据,准确性较高,性能较高,建议使用,也是默认配置

  • no:将 aof_buf 缓冲区中的内容写入到 AOF 文件,但并不对 AOF 文件进行同步,何时同步由操作系统来决定

    特点:整体不可控,服务器宕机会丢失上次同步 AOF 后的所有写指令


文件同步

在现代操作系统中,当用户调用 write 函数将数据写入文件时,操作系统通常会将写入数据暂时保存在一个内存缓冲区空间,等到缓冲区写满或者到达特定时间周期,才真正地将缓冲区中的数据写入到磁盘里面(刷脏)

  • 优点:提高文件的写入效率
  • 缺点:为写入数据带来了安全问题,如果计算机发生停机,那么保存在内存缓冲区里面的写入数据将会丢失

系统提供了 fsync 和 fdatasync 两个同步函数做强制硬盘同步,可以让操作系统立即将缓冲区中的数据写入到硬盘里面,函数会阻塞到写入硬盘完成后返回,保证了数据持久化

异常恢复:AOF 文件损坏,通过 redis-check-aof–fix appendonly.aof 进行恢复,重启 Redis,然后重新加载


文件载入

AOF 文件里包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍 AOF 文件里的命令,就还原服务器关闭之前的数据库状态,服务器在启动时,还原数据库状态打印的日志:

1
[8321] 05 Sep 11:58:50.449 * DB loaded from append only file: 0.000 seconds 

AOF 文件里面除了用于指定数据库的 SELECT 命令是服务器自动添加的,其他都是通过客户端发送的命令

1
2
3
* 2\r\n$6\r\nSELECT\r\n$1\r\n0\r\n	# 服务器自动添加
* 3\r\n$3\r\nSET\r\n$3\r\nmsg\r\n$5\r\nhello\r\n
* 5\r\n$4\r\nSADD\r\n$6\r\nfruits\r\n$5\r\napple\r\n$6\r\nbanana\r\n$6\r\ncherry\r\n

Redis 读取 AOF 文件并还原数据库状态的步骤:

  • 创建一个不带网络连接的伪客户端(fake client)执行命令,因为 Redis 的命令只能在客户端上下文中执行, 而载入 AOF 文件时所使用的命令来源于本地 AOF 文件而不是网络连接
  • 从 AOF 文件分析并读取一条写命令
  • 使用伪客户端执行被读出的写命令,然后重复上述步骤

重写实现

重写策略

随着命令不断写入 AOF,文件会越来越大,很可能对 Redis 服务器甚至整个宿主计算机造成影响,为了解决这个问题 Redis 引入了 AOF 重写机制压缩文件体积

AOF 重写:读取服务器当前的数据库状态,生成新 AOF 文件来替换旧 AOF 文件,不会对现有的 AOF 文件进行任何读取、分析或者写入操作,而是直接原子替换。新 AOF 文件不会包含任何浪费空间的冗余命令,所以体积通常会比旧 AOF 文件小得多

AOF 重写规则:

  • 进程内具有时效性的数据,并且数据已超时将不再写入文件

  • 对同一数据的多条写命令合并为一条命令,因为会读取当前的状态,所以直接将当前状态转换为一条命令即可。为防止数据量过大造成客户端缓冲区溢出,对 list、set、hash、zset 等集合类型,单条指令最多写入 64 个元素

    如 lpushlist1 a、lpush list1 b、lpush list1 c 可以转化为:lpush list1 a b c

  • 非写入类的无效指令将被忽略,只保留最终数据的写入命令,但是 select 指令虽然不更改数据,但是更改了数据的存储位置,此类命令同样需要记录

AOF 重写作用:

  • 降低磁盘占用量,提高磁盘利用率
  • 提高持久化效率,降低持久化写时间,提高 IO 性能
  • 降低数据恢复的用时,提高数据恢复效率

重写原理

AOF 重写程序 aof_rewrite 函数可以创建一个新 AOF 文件, 但是该函数会进行大量的写入操作,调用这个函数的线程将被长时间阻塞,所以 Redis 将 AOF 重写程序放到 fork 的子进程里执行,不会阻塞父进程,重写命令:

1
bgrewriteaof
  • 子进程进行 AOF 重写期间,服务器进程(父进程)可以继续处理命令请求

  • 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下, 保证数据的安全性

子进程在进行 AOF 重写期间,服务器进程还需要继续处理命令请求,而新命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的 AOF 文件所保存的数据库状态不一致,所以 Redis 设置了 AOF 重写缓冲区

工作流程:

  • Redis 服务器执行完一个写命令,会同时将该命令追加到 AOF 缓冲区和 AOF 重写缓冲区(从创建子进程后才开始写入)
  • 当子进程完成 AOF 重写工作之后,会向父进程发送一个信号,父进程在接到该信号之后, 会调用一个信号处理函数,该函数执行时会对服务器进程(父进程)造成阻塞(影响很小),主要工作:
    • 将 AOF 重写缓冲区中的所有内容写入到新 AOF 文件中, 这时新 AOF 文件所保存的状态将和服务器当前的数据库状态一致
    • 对新的 AOF 文件进行改名,原子地(atomic)覆盖现有的 AOF 文件,完成新旧两个 AOF 文件的替换

自动重写

触发时机:Redis 会记录上次重写时的 AOF 大小,默认配置是当 AOF 文件大小是上次重写后大小的一倍且文件大于 64M 时触发

1
2
auto-aof-rewrite-min-size size		#设置重写的基准值,最小文件 64MB,达到这个值开始重写
auto-aof-rewrite-percentage percent #触发AOF文件执行重写的增长率,当前AOF文件大小超过上一次重写的AOF文件大小的百分之多少才会重写,比如文件达到 100% 时开始重写就是两倍时触发

自动重写触发比对参数( 运行指令 info Persistence 获取具体信息 ):

1
2
aof_current_size					#AOF文件当前尺寸大小(单位:字节)
aof_base_size #AOF文件上次启动和重写时的尺寸大小(单位:字节)

自动重写触发条件公式:

  • aof_current_size > auto-aof-rewrite-min-size
  • (aof_current_size - aof_base_size) / aof_base_size >= auto-aof-rewrite-percentage

对比

RDB 的特点

  • RDB 优点:

    • RDB 是一个紧凑压缩的二进制文件,存储效率较高,但存储数据量较大时,存储效率较低
    • RDB 内部存储的是 Redis 在某个时间点的数据快照,非常适合用于数据备份,全量复制、灾难恢复
    • RDB 恢复数据的速度要比 AOF 快很多,因为是快照,直接恢复
  • RDB 缺点:

    • BGSAVE 指令每次运行要执行 fork 操作创建子进程,会牺牲一些性能
    • RDB 方式无论是执行指令还是利用配置,无法做到实时持久化,具有丢失数据的可能性,最后一次持久化后的数据可能丢失
    • Redis 的众多版本中未进行 RDB 文件格式的版本统一,可能出现各版本之间数据格式无法兼容

AOF 特点:

  • AOF 的优点:数据持久化有较好的实时性,通过 AOF 重写可以降低文件的体积
  • AOF 的缺点:文件较大时恢复较慢

AOF 和 RDB 同时开启,系统默认取 AOF 的数据(数据不会存在丢失)

应用场景:

  • 对数据非常敏感,建议使用默认的 AOF 持久化方案,AOF 持久化策略使用 everysecond,每秒钟 fsync 一次,该策略 Redis 仍可以保持很好的处理性能

    注意:AOF 文件存储体积较大,恢复速度较慢,因为要执行每条指令

  • 数据呈现阶段有效性,建议使用 RDB 持久化方案,可以做到阶段内无丢失,且恢复速度较快

    注意:利用 RDB 实现紧凑的数据持久化,存储数据量较大时,存储效率较低

综合对比:

  • RDB 与 AOF 的选择实际上是在做一种权衡,每种都有利有弊
  • 灾难恢复选用 RDB
  • 如不能承受数分钟以内的数据丢失,对业务数据非常敏感,选用 AOF;如能承受数分钟以内的数据丢失,且追求大数据集的恢复速度,选用 RDB
  • 双保险策略,同时开启 RDB 和 AOF,重启后 Redis 优先使用 AOF 来恢复数据,降低丢失数据的量
  • 不建议单独用 AOF,因为可能会出现 Bug,如果只是做纯内存缓存,可以都不用

fork

介绍

fork() 函数创建一个子进程,子进程与父进程几乎是完全相同的进程,系统先给子进程分配资源,然后把父进程的所有数据都复制到子进程中,只有少数值与父进程的值不同,相当于克隆了一个进程

在完成对其调用之后,会产生 2 个进程,且每个进程都会从 fork() 的返回处开始执行,这两个进程将执行相同的程序段,但是拥有各自不同的堆段,栈段,数据段,每个子进程都可修改各自的数据段,堆段,和栈段

1
2
3
#include<unistd.h>
pid_t fork(void);
// 父进程返回子进程的pid,子进程返回0,错误返回负值,根据返回值的不同进行对应的逻辑处理

fork 调用一次,却能够返回两次,可能有三种不同的返回值:

  • 在父进程中,fork 返回新创建子进程的进程 ID
  • 在子进程中,fork 返回 0
  • 如果出现错误,fork 返回一个负值,错误原因:
    • 当前的进程数已经达到了系统规定的上限,这时 errno 的值被设置为 EAGAIN
    • 系统内存不足,这时 errno 的值被设置为 ENOMEM

fpid 的值在父子进程中不同:进程形成了链表,父进程的 fpid 指向子进程的进程 id,因为子进程没有子进程,所以其 fpid 为0

创建新进程成功后,系统中出现两个基本完全相同的进程,这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的调度策略

每个进程都有一个独特(互不相同)的进程标识符 process ID,可以通过 getpid() 函数获得;还有一个记录父进程 pid 的变量,可以通过 getppid() 函数获得变量的值


使用

基本使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <unistd.h>  
#include <stdio.h>
int main ()
{
pid_t fpid; // fpid表示fork函数返回的值
int count = 0;
fpid = fork();
if (fpid < 0)
printf("error in fork!");
else if (fpid == 0) {
printf("i am the child process, my process id is %d/n", getpid());
count++;
}
else {
printf("i am the parent process, my process id is %d/n", getpid());
count++;
}
printf("count: %d/n",count);// 1
return 0;
}
/* 输出内容:
i am the child process, my process id is 5574
count: 1
i am the parent process, my process id is 5573
count: 1
*/

进阶使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <unistd.h>  
#include <stdio.h>
int main(void)
{
int i = 0;
// ppid 指当前进程的父进程pid
// pid 指当前进程的pid,
// fpid 指fork返回给当前进程的值,在这可以表示子进程
for(i = 0; i < 2; i++){
pid_t fpid = fork();
if(fpid == 0)
printf("%d child %4d %4d %4d/n",i, getppid(), getpid(), fpid);
else
printf("%d parent %4d %4d %4d/n",i, getppid(), getpid(),fpid);
}
return 0;
}
/*输出内容:
i 父id id 子id
0 parent 2043 3224 3225
0 child 3224 3225 0
1 parent 2043 3224 3226
1 parent 3224 3225 3227
1 child 1 3227 0
1 child 1 3226 0
*/

在 p3224 和 p3225 执行完第二个循环后,main 函数退出,进程死亡。所以 p3226,p3227 就没有父进程了,成为孤儿进程,所以 p3226 和 p3227 的父进程就被置为 ID 为 1的 init 进程(笔记 Tool → Linux → 进程管理详解)

参考文章:https://blog.csdn.net/love_gaohz/article/details/41727415


内存

fork() 调用之后父子进程的内存关系

早期 Linux 的 fork() 实现时,就是全部复制,这种方法效率太低,而且造成了很大的内存浪费,现在 Linux 实现采用了两种方法:

  • 父子进程的代码段是相同的,所以代码段是没必要复制的,只需内核将代码段标记为只读,父子进程就共享此代码段。fork() 之后在进程创建代码段时,子进程的进程级页表项都指向和父进程相同的物理页帧

  • 对于父进程的数据段,堆段,栈段中的各页,由于父子进程相互独立,采用写时复制 COW 的技术,来提高内存以及内核的利用率

    在 fork 之后两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,两者的虚拟空间不同,但其对应的物理空间是同一个,当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间。如果两者的代码完全相同,代码段继续共享父进程的物理空间;而如果两者执行的代码不同,子进程的代码段也会分配单独的物理空间。

    fork 之后内核会将子进程放在队列的前面,让子进程先执行,以免父进程执行导致写时复制,而后子进程再执行,因无意义的复制而造成效率的下降

补充知识:

vfork(虚拟内存 fork virtual memory fork):调用 vfork() 父进程被挂起,子进程使用父进程的地址空间。不采用写时复制,如果子进程修改父地址空间的任何页面,这些修改过的页面对于恢复的父进程是可见的

参考文章:https://blog.csdn.net/Shreck66/article/details/47039937


事务机制

基本操作

Redis 事务的主要作用就是串联多个命令防止别的命令插队

  • 开启事务

    1
    multi	#设定事务的开启位置,此指令执行后,后续的所有指令均加入到事务中
  • 执行事务

    1
    exec	#设定事务的结束位置,同时执行事务,与multi成对出现,成对使用

    加入事务的命令暂时进入到任务队列中,并没有立即执行,只有执行 exec 命令才开始执行

  • 取消事务

    1
    discard	#终止当前事务的定义,发生在multi之后,exec之前

    一般用于事务执行过程中输入了错误的指令,直接取消这次事务,类似于回滚

Redis 事务的三大特性:

  • Redis 事务是一个单独的隔离操作,将一系列预定义命令包装成一个整体(一个队列),当执行时按照添加顺序依次执行,中间不会被打断或者干扰
  • Redis 事务没有隔离级别的概念,队列中的命令在事务没有提交之前都不会实际被执行
  • Redis 单条命令式保存原子性的,但是事务不保证原子性,事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚

工作流程

事务机制整体工作流程:

image-20240707155933038

几种常见错误:

  • 定义事务的过程中,命令格式输入错误,出现语法错误造成,整体事务中所有命令均不会执行,包括那些语法正确的命令

  • 定义事务的过程中,命令执行出现错误,例如对字符串进行 incr 操作,能够正确运行的命令会执行,运行错误的命令不会被执行

  • 已经执行完毕的命令对应的数据不会自动回滚,需要程序员在代码中实现回滚,应该尽可能避免:

    事务操作之前记录数据的状态

    • 单数据:string
    • 多数据:hash、list、set、zset

    设置指令恢复所有的被修改的项

    • 单数据:直接 set(注意周边属性,例如时效)
    • 多数据:修改对应值或整体克隆复制

监控锁

对 key 添加监视锁,是一种乐观锁,在执行 exec 前如果其他客户端的操作导致 key 发生了变化,执行结果为 nil

  • 添加监控锁

    1
    watch key1 [key2……]	#可以监控一个或者多个key
  • 取消对所有 key 的监视

    1
    unwatch

应用:基于状态控制的批量任务执行,防止其他线程对变量的修改


内存淘汰

逐出算法

数据淘汰策略:当新数据进入 Redis 时,在执行每一个命令前,会调用 freeMemoryIfNeeded() 检测内存是否充足。如果内存不满足新加入数据的最低存储要求,Redis 要临时删除一些数据为当前指令清理存储空间,清理数据的策略称为逐出算法

逐出数据的过程不是 100% 能够清理出足够的可使用的内存空间,如果不成功则反复执行,当对所有数据尝试完毕,如不能达到内存清理的要求,出现 Redis 内存打满异常

1
(error) OOM command not allowed when used memory >'maxmemory'

策略配置

Redis 如果不设置最大内存大小或者设置最大内存大小为 0,在 64 位操作系统下不限制内存大小,在 32 位操作系统默认为 3GB 内存,一般推荐设置 Redis 内存为最大物理内存的四分之三

内存配置方式:

  • 通过修改文件配置(永久生效):修改配置文件 maxmemory 字段,单位为字节

  • 通过命令修改(重启失效):

    • config set maxmemory 104857600:设置 Redis 最大占用内存为 100MB

    • config get maxmemory:获取 Redis 最大占用内存

    • info :可以查看 Redis 内存使用情况,used_memory_human 字段表示实际已经占用的内存,maxmemory 表示最大占用内存

影响数据淘汰的相关配置如下,配置 conf 文件:

  • 每次选取待删除数据的个数,采用随机获取数据的方式作为待检测删除数据,防止全库扫描,导致严重的性能消耗,降低读写性能

    1
    maxmemory-samples count
  • 达到最大内存后的,对被挑选出来的数据进行删除的策略

    1
    maxmemory-policy policy

    数据删除的策略 policy:3 类 8 种

    第一类:检测易失数据(可能会过期的数据集 server.db[i].expires):

    1
    2
    3
    4
    volatile-lru	# 对设置了过期时间的 key 选择最近最久未使用使用的数据淘汰
    volatile-lfu # 对设置了过期时间的 key 选择最近使用次数最少的数据淘汰
    volatile-ttl # 对设置了过期时间的 key 选择将要过期的数据淘汰
    volatile-random # 对设置了过期时间的 key 选择任意数据淘汰

    第二类:检测全库数据(所有数据集 server.db[i].dict ):

    1
    2
    3
    allkeys-lru		# 对所有 key 选择最近最少使用的数据淘汰
    allkeLyRs-lfu # 对所有 key 选择最近使用次数最少的数据淘汰
    allkeys-random # 对所有 key 选择任意数据淘汰,相当于随机

    第三类:放弃数据驱逐

    1
    no-enviction	#禁止驱逐数据(redis4.0中默认策略),会引发OOM(Out Of Memory)

数据淘汰策略配置依据:使用 INFO 命令输出监控信息,查询缓存 hit 和 miss 的次数,根据需求调优 Redis 配置


缓存方案

缓存模式

旁路缓存

缓存本质:弥补 CPU 的高算力和 IO 的慢读写之间巨大的鸿沟

旁路缓存模式 Cache Aside Pattern 是平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景

Cache Aside Pattern 中服务端需要同时维系 DB 和 cache,并且是以 DB 的结果为准

  • 写操作:先更新 DB,然后直接删除 cache
  • 读操作:从 cache 中读取数据,读取到就直接返回;读取不到就从 DB 中读取数据返回,并放到 cache

时序导致的不一致问题:

  • 在写数据的过程中,不能先删除 cache 再更新 DB,因为会造成缓存的不一致。比如请求 1 先写数据 A,请求 2 随后读数据 A,当请求 1 删除 cache 后,请求 2 直接读取了 DB,此时请求 1 还没写入 DB(延迟双删)

  • 在写数据的过程中,先更新 DB 再删除 cache 也会出现问题,但是概率很小,因为缓存的写入速度非常快

旁路缓存的缺点:

  • 首次请求数据一定不在 cache 的问题,一般采用缓存预热的方法,将热点数据可以提前放入 cache 中
  • 写操作比较频繁的话导致 cache 中的数据会被频繁被删除,影响缓存命中率

读写穿透

读写穿透模式 Read/Write Through Pattern:服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中,cache 负责将此数据同步写入 DB,从而减轻了应用程序的职责

  • 写操作:先查 cache,cache 中不存在,直接更新 DB;cache 中存在则先更新 cache,然后 cache 服务更新 DB(同步更新 cache 和 DB)

  • 读操作:从 cache 中读取数据,读取到就直接返回 ;读取不到先从 DB 加载,写入到 cache 后返回响应

    Read-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,对客户端是透明的

Read-Through Pattern 也存在首次不命中的问题,采用缓存预热解决


异步缓存

异步缓存写入 Write Behind Pattern 由 cache 服务来负责 cache 和 DB 的读写,对比读写穿透不同的是 Write Behind Caching 是只更新缓存,不直接更新 DB,改为异步批量的方式来更新 DB,可以减小写的成本

缺点:这种模式对数据一致性没有高要求,可能出现 cache 还没异步更新 DB,服务就挂掉了

应用:

  • DB 的写性能非常高,适合一些数据经常变化又对数据一致性要求不高的场景,比如浏览量、点赞量

  • MySQL 的 InnoDB Buffer Pool 机制用到了这种策略


缓存一致

使用缓存代表不需要强一致性,只需要最终一致性

缓存不一致的方法:

  • 数据库和缓存数据强一致场景:
    • 更新 DB 时同样更新 cache,加一个锁来保证更新 cache 时不存在线程安全问题,这样可以增加命中率
    • 延迟双删:先淘汰缓存再写数据库,休眠 1 秒再次淘汰缓存,可以将 1 秒内造成的缓存脏数据再次删除
    • CDC 同步:通过 canal 订阅 MySQL binlog 的变更上报给 Kafka,系统监听 Kafka 消息触发缓存失效
  • 可以短暂允许数据库和缓存数据不一致场景:更新 DB 的时候同样更新 cache,但是给缓存加一个比较短的过期时间,这样就可以保证即使数据不一致影响也比较小

参考文章:http://cccboke.com/archives/2020-09-30-21-29-56


企业方案

缓存预热

场景:宕机,服务器启动后迅速宕机

问题排查:

  1. 请求数量较高,大量的请求过来之后都需要去从缓存中获取数据,但是缓存中又没有,此时从数据库中查找数据然后将数据再存入缓存,造成了短期内对 redis 的高强度操作从而导致问题

  2. 主从之间数据吞吐量较大,数据同步操作频度较高

解决方案:

  • 前置准备工作:

    1. 日常例行统计数据访问记录,统计访问频度较高的热点数据

    2. 利用 LRU 数据删除策略,构建数据留存队列例如:storm 与 kafka 配合

  • 准备工作:

    1. 将统计结果中的数据分类,根据级别,redis 优先加载级别较高的热点数据

    2. 利用分布式多服务器同时进行数据读取,提速数据加载过程

    3. 热点数据主从同时预热

  • 实施:

    1. 使用脚本程序固定触发数据预热过程

    2. 如果条件允许,使用了 CDN(内容分发网络),效果会更好

总的来说:缓存预热就是系统启动前,提前将相关的缓存数据直接加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据!


缓存雪崩

场景:数据库服务器崩溃,一连串的问题会随之而来

问题排查:在一个较短的时间内,缓存中较多的 key 集中过期,此周期内请求访问过期的数据 Redis 未命中,Redis 向数据库获取数据,数据库同时收到大量的请求无法及时处理。

解决方案:

  1. 加锁,慎用
  2. 设置热点数据永远不过期,如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中
  3. 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生
  4. 构建多级缓存架构,Nginx 缓存 + Redis 缓存 + ehcache 缓存
  5. 灾难预警机制,监控 Redis 服务器性能指标,CPU 使用率、内存容量、平均响应时间、线程数
  6. 限流、降级:短时间范围内牺牲一些客户体验,限制一部分请求访问,降低应用服务器压力,待业务低速运转后再逐步放开访问

总的来说:缓存雪崩就是瞬间过期数据量太大,导致对数据库服务器造成压力。如能够有效避免过期时间集中,可以有效解决雪崩现象的出现(约 40%),配合其他策略一起使用,并监控服务器的运行数据,根据运行记录做快速调整。


缓存击穿

场景:系统平稳运行过程中,数据库连接量瞬间激增,Redis 服务器无大量 key 过期,Redis 内存平稳无波动,Redis 服务器 CPU 正常,但是数据库崩溃

问题排查:

  1. Redis 中某个 key 过期,该 key 访问量巨大

  2. 多个数据请求从服务器直接压到 Redis 后,均未命中

  3. Redis 在短时间内发起了大量对数据库中同一数据的访问

简而言之两点:单个 key 高热数据,key 过期

解决方案:

  1. 预先设定:以电商为例,每个商家根据店铺等级,指定若干款主打商品,在购物节期间,加大此类信息 key 的过期时长 注意:购物节不仅仅指当天,以及后续若干天,访问峰值呈现逐渐降低的趋势

  2. 现场调整:监控访问量,对自然流量激增的数据延长过期时间或设置为永久性 key

  3. 后台刷新数据:启动定时任务,高峰期来临之前,刷新数据有效期,确保不丢失

  4. 二级缓存:设置不同的失效时间,保障不会被同时淘汰就行

  5. 加锁:分布式锁,防止被击穿,但是要注意也是性能瓶颈,慎重

总的来说:缓存击穿就是单个高热数据过期的瞬间,数据访问量较大,未命中 Redis 后,发起了大量对同一数据的数据库访问,导致对数据库服务器造成压力。应对策略应该在业务数据分析与预防方面进行,配合运行监控测试与即时调整策略,毕竟单个 key 的过期监控难度较高,配合雪崩处理策略即可


缓存穿透

场景:系统平稳运行过程中,应用服务器流量随时间增量较大,Redis 服务器命中率随时间逐步降低,Redis 内存平稳,内存无压力,Redis 服务器 CPU 占用激增,数据库服务器压力激增,数据库崩溃

问题排查:

  1. Redis 中大面积出现未命中

  2. 出现非正常 URL 访问

问题分析:

  • 访问了不存在的数据,跳过了 Redis 缓存,数据库页查询不到对应数据
  • Redis 获取到 null 数据未进行持久化,直接返回
  • 出现黑客攻击服务器

解决方案:

  1. 缓存 null:对查询结果为 null 的数据进行缓存,设定短时限,例如 30-60 秒,最高 5 分钟

  2. 白名单策略:提前预热各种分类数据 id 对应的 bitmaps,id 作为 bitmaps 的 offset,相当于设置了数据白名单。当加载正常数据时放行,加载异常数据时直接拦截(效率偏低),也可以使用布隆过滤器(有关布隆过滤器的命中问题对当前状况可以忽略)

  3. 实时监控:实时监控 Redis 命中率(业务正常范围时,通常会有一个波动值)与 null 数据的占比

    • 非活动时段波动:通常检测 3-5 倍,超过 5 倍纳入重点排查对象
    • 活动时段波动:通常检测10-50 倍,超过 50 倍纳入重点排查对象

    根据倍数不同,启动不同的排查流程。然后使用黑名单进行防控

  4. key 加密:临时启动防灾业务 key,对 key 进行业务层传输加密服务,设定校验程序,过来的 key 校验;例如每天随机分配 60 个加密串,挑选 2 到 3 个,混淆到页面数据 id 中,发现访问 key 不满足规则,驳回数据访问

总的来说:缓存击穿是指访问了不存在的数据,跳过了合法数据的 Redis 数据缓存阶段,每次访问数据库,导致对数据库服务器造成压力。通常此类数据的出现量是一个较低的值,当出现此类情况以毒攻毒,并及时报警。无论是黑名单还是白名单,都是对整体系统的压力,警报解除后尽快移除

参考视频:https://www.bilibili.com/video/BV15y4y1r7X3


性能指标

Redis 中的监控指标如下:

  • 性能指标:Performance

    响应请求的平均时间:

    1
    latency

    平均每秒处理请求总数:

    1
    instantaneous_ops_per_sec

    缓存查询命中率(通过查询总次数与查询得到非nil数据总次数计算而来):

    1
    hit_rate(calculated)
  • 内存指标:Memory

    当前内存使用量:

    1
    used_memory

    内存碎片率(关系到是否进行碎片整理):

    1
    mem_fragmentation_ratio

    为避免内存溢出删除的key的总数量:

    1
    evicted_keys

    基于阻塞操作(BLPOP等)影响的客户端数量:

    1
    blocked_clients
  • 基本活动指标:Basic_activity

    当前客户端连接总数:

    1
    connected_clients

    当前连接 slave 总数:

    1
    connected_slaves

    最后一次主从信息交换距现在的秒:

    1
    master_last_io_seconds_ago

    key 的总数:

    1
    keyspace
  • 持久性指标:Persistence

    当前服务器其最后一次 RDB 持久化的时间:

    1
    rdb_last_save_time

    当前服务器最后一次 RDB 持久化后数据变化总量:

    1
    rdb_changes_since_last_save
  • 错误指标:Error

    被拒绝连接的客户端总数(基于达到最大连接值的因素):

    1
    rejected_connections

    key未命中的总次数:

    1
    keyspace_misses

    主从断开的秒数:

    1
    master_link_down_since_seconds

要对 Redis 的相关指标进行监控,我们可以采用一些用具:

  • CloudInsight Redis
  • Prometheus
  • Redis-stat
  • Redis-faina
  • RedisLive
  • zabbix

命令工具:

  • benchmark

    测试当前服务器的并发性能:

    1
    redis-benchmark [-h ] [-p ] [-c ] [-n <requests]> [-k ]

    范例:100 个连接,5000 次请求对应的性能

    1
    redis-benchmark -c 100 -n 5000
  • redis-cli

    monitor:启动服务器调试信息

    1
    monitor

    slowlog:慢日志

    1
    slowlog [operator]    #获取慢查询日志
    • get :获取慢查询日志信息
    • len :获取慢查询日志条目数
    • reset :重置慢查询日志

    相关配置:

    1
    2
    slowlog-log-slower-than 1000 #设置慢查询的时间下线,单位:微妙
    slowlog-max-len 100 #设置慢查询命令对应的日志显示长度,单位:命令数

分布式锁

参考文章:https://www.pdai.tech/md/arch/arch-z-lock.html#基于redis如何实现分布式锁-有什么缺陷

基本操作

由于分布式系统多线程并发分布在不同机器上,这将使单机部署情况下的并发控制锁策略失效,需要分布式锁

Redis 分布式锁的基本使用,悲观锁

  • 使用 setnx 设置一个公共锁

    1
    setnx lock-key value	# value任意数,返回为1设置成功,返回为0设置失败
    • 对于返回设置成功的,拥有控制权,进行下一步的具体业务操作
    • 对于返回设置失败的,不具有控制权,排队或等待

    NX:只在键不存在时,才对键进行设置操作,SET key value NX 效果等同于 SETNX key value

    XX :只在键已经存在时,才对键进行设置操作

    EX:设置键 key 的过期时间,单位时秒

    PX:设置键 key 的过期时间,单位时毫秒

    说明:由于 SET 命令加上选项已经可以完全取代 SETNX、SETEX、PSETEX 的功能,Redis 不推荐使用这几个命令

  • 操作完毕通过 del 操作释放锁

    1
    del lock-key 
  • 使用 expire 为锁 key 添加存活(持有)时间,过期自动删除(放弃)锁

    1
    2
    expire lock-key second 
    pexpire lock-key milliseconds

    通过 expire 设置过期时间缺乏原子性,如果在 setnx 和 expire 之间出现异常,锁也无法释放

  • 在 set 时指定过期时间

    1
    SET key value [EX seconds | PX milliseconds] NX

应用:解决抢购时出现超卖现象


防误删

setnx 获取锁时,设置一个指定的唯一值(uuid),释放前获取这个值,判断是否自己的锁,防止出现线程之间误删了其他线程的锁

1
2
// 加锁, unique_value作为客户端唯一性的标识
SET lock_key unique_value NX PX 10000

unique_value 是客户端的唯一标识,可以用一个随机生成的字符串来表示,PX 10000 则表示 lock_key 会在 10s 后过期,以免客户端在这期间发生异常而无法释放锁


redission(watchdog/lock/tryLock/jedis)

参考文章:https://javaguide.cn/distributed-system/distributed-lock-implementations.html#基于-redis-实现分布式锁


key过期(过期删除)

删除策略

删除策略就是针对已过期数据的处理策略,已过期的数据不一定被立即删除,在不同的场景下使用不同的删除方式会有不同效果,在内存占用与 CPU 占用之间寻找一种平衡,顾此失彼都会造成整体 Redis 性能的下降,甚至引发服务器宕机或内存泄露

针对过期数据有三种删除策略:

  • 定时删除
  • 惰性删除
  • 定期删除

Redis 采用惰性删除定期删除策略的结合使用


定时删除

在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间到达时,立即执行对键的删除操作

  • 优点:节约内存,到时就删除,快速释放掉不必要的内存占用
  • 缺点:对 CPU 不友好,无论 CPU 此时负载多高均占用 CPU,会影响 Redis 服务器响应时间和指令吞吐量
  • 总结:用处理器性能换取存储空间(拿时间换空间)

创建一个定时器需要用到 Redis 服务器中的时间事件,而时间事件的实现方式是无序链表,查找一个事件的时间复杂度为 O(N),并不能高效地处理大量时间事件,所以采用这种方式并不现实


惰性删除

数据到达过期时间不做处理,等下次访问到该数据时执行 expireIfNeeded() 判断:

  • 如果输入键已经过期,那么 expireIfNeeded 函数将输入键从数据库中删除,接着访问就会返回空
  • 如果输入键未过期,那么 expireIfNeeded 函数不做动作

所有的 Redis 读写命令在执行前都会调用 expireIfNeeded 函数进行检查,该函数就像一个过滤器,在命令真正执行之前过滤掉过期键

惰性删除的特点:

  • 优点:节约 CPU 性能,删除的目标仅限于当前处理的键,不会在删除其他无关的过期键上花费任何 CPU 时间
  • 缺点:内存压力很大,出现长期占用内存的数据,如果过期键永远不被访问,这种情况相当于内存泄漏
  • 总结:用存储空间换取处理器性能(拿空间换时间)

定期删除

定期删除策略是每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响

  • 如果删除操作执行得太频繁,或者执行时间太长,就会退化成定时删除策略,将 CPU 时间过多地消耗在删除过期键上
  • 如果删除操作执行得太少,或者执行时间太短,定期删除策略又会和惰性删除策略一样,出现浪费内存的情况

所以采用定期删除策略的话,服务器必须根据情况合理地设置删除操作的执行时长和执行频率

定期删除是周期性轮询 Redis 库中的时效性数据,从过期字典中随机抽取一部分键检查,利用过期数据占比的方式控制删除频度

  • Redis 启动服务器初始化时,读取配置 server.hz 的值,默认为 10,执行指令 info server 可以查看,每秒钟执行 server.hz 次 serverCron() → activeExpireCycle()

  • activeExpireCycle() 对某个数据库中的每个 expires 进行检测,工作模式:

    • 轮询每个数据库,从数据库中取出一定数量的随机键进行检查,并删除其中的过期键

    • 全局变量 current_db 用于记录 activeExpireCycle() 的检查进度(哪一个数据库),下一次调用时接着该进度处理

    • 随着函数的不断执行,服务器中的所有数据库都会被检查一遍,这时将 current_db 重置为 0,然后再次开始新一轮的检查

定期删除特点:

  • CPU 性能占用设置有峰值,检测频度可自定义设置
  • 内存压力不是很大,长期占用内存的冷数据会被持续清理
  • 周期性抽查存储空间(随机抽查,重点抽查)