Skip to content

Redis 核心知识体系

· 85 min

Redis对象#

Redis Object#

Redis存储的方式是Key-Value的方式,其中key是String类型的字符串,Value支持丰富的对象种类

Object 的构成如下:

// from Redis 5.0.5
#define LRU BITS 24
typedef struct redisObject {
unsigned type :4;
unsigned encoding:4;
//ps:Lru字段表示的时间戳越小,就代表这个key空闲的时间越大 就越应该被淘汰
unsigned lru:LRU BITS;
int refcount;
void *ptr;
] robj;

String#

String 是Redis中最基本的数据对象,最大为512MB,我们可以通过配置项 proto-max-bulk-len 来修

改它,一般来说是不用主动修改的。

适用场景#

常用操作#

写操作#

SET key value:设置一个key 的值为 value

SETNX key value <只有key不存在才能操作>

下面有一些拓展的参数:

删除操作#

语法: DEL key [key2 …]

功能<删除对象>,返回值为删除成功了几行

读操作#

操作演示#

Terminal window
#---------- SET 演示 -------------
# 设置key
测试服务器:db0> set name leonsong
OK
#key为空设置,这里name已经有了,所以命令没用
测试服务器:db0> setnx name leonsong
0
#设置age 过期时间5s
测试服务器:db0> set age 18 ex 5
OK
#直接获取是有的
测试服务器:db0> get age
18
#等5s在获取没有输出了
测试服务器:db0> get age
#key 存在的时候设置,这里发现设置失败了
测试服务器:db0> set age 18 xx
#name 是存在的,这时候设置就存在了
测试服务器:db0> set name leonsong xx
OK
测试服务器:db0> set age 20
OK
#---------- GET 演示 -------------
测试服务器:db0> get name
leonsong
测试服务器:db0> mget name age
1) "leonsong"
2) "20"
#---------- DEL 演示 -------------
# 删除操作
测试服务器:db0> del name age
2

底层实现#

三种编码方式#

String有三种编码方式,如下图所示:

img

img

img

EMBSTR 和 RAW 的区别#
  1. 内存分配次数:
    • embstr:在创建字符串对象时,只分配一次连续内存,同时包含 redisObjectsdshdr 结构。
    • raw:需要两次内存分配,分别分配 redisObjectsdshdr
  2. 适用场景
    • embstr:用于短字符串(长度 ≤ 44 字节),是只读优化的编码。
    • raw:用于长字符串(长度 > 44 字节),支持修改操作。

任何写操作之后 EMBSTR 都会变成 RAW,理念是发生过修改的字符审通常会认为是易变的。

SDS 简单的动态字符串(simple dynamic string)#
char字符数组的缺陷#
  1. 获取长度需遍历:必须从头到尾扫描直到遇到 \0,时间复杂度 O(n)。
  2. 不能存储二进制数据:中间若出现 \0 会被误认为字符串结束,导致截断。
  3. 缓冲区溢出风险:操作(如 strcatstrcpy)不检查目标空间大小,容易越界写入。
SDS结构介绍#
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; // 已使用字节数(字符串长度)
uint8_t alloc; // 总分配字节数(不包括头部和结尾的 \0)
unsigned char flags; // 保存不同类型的sds(如 sdshdr8、sdshdr16 等)
char buf[]; // 实际字符数组(柔性数组)
};

img

SDS的优势#
  1. 长度 O(1) 获取
  2. 二进制安全,有个专门的 len 成员变量来记录长度,所以可存储包含 “\0” 的数据
  3. 安全追加(自动扩容),不会发生缓冲区溢出的情况
  4. 节省内存空间,设计的不同的sds结构体,为了灵活保存不同大小的字符串,节省内存空间

List#

适用场景#

常用操作#

添加元素#
弹出元素#
查询元素#
修改#

这些命令支持高效地在列表两端操作,时间复杂度大多为 O(1)(LRANGE 为 O(N))。

底层实现#

List 对象有两种编码方式:ZIPLIST 、LINKEDLIST;

ZIPLIST#

ziplist 是一块连续的内存块,数据的获取靠的是计算数据位置得到的。所以ziplist 不支持随机访问。只能从头开始逐个解析 entry获取,所以redis规定:所有元素长度都小于 64 字节,且列表元素数量 ≤ 512的时候才会用ziplist,否则会影响效率。(512可通过 list-max-ziplist-size 配置调整)

​ 由于ziplist是连续存储,这就导致插入和删除的时候会发生内存拷贝。假设 ziplist 当前存储:[A][B][C](连续排列)现在要在 AB 之间插入 X:必须:

  1. 为新元素 X 腾出空间
  2. BC 向后移动(即内存拷贝);
  3. 写入 X

image-20251210161556982

image-20251210172442473

LINKEDLIST#

Redis3.2 之前)现在已经被 quick list 取代

image-20251210162013027

QUICKLIST#

​ 如果节点非常多的情况,LINKEDLIST链表的节点就很多,会占用不少的内存。这种情况有没有办法优化呢? Redis 3.2版本就引入了QUICKLIST。QUICKLIST其实就是ZIPLIST和LINKEDLIST的结合体。

image-20251210171913676

typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; // 总元素数
unsigned long len; // 节点数量
...
} quicklist;

LINKEDLIST之前是单节点存放一个数据,QUICKLIST是单节点存放一个ZIPLIST,也就是多数据

为什么会发生连锁更新?#

问题根源:prevlen 字段#

ziplist 的每个 entry 都包含一个 prevlen 字段,用于记录前一个 entry 的长度,以便从后往前遍历。

连锁更新如何发生?#

假设有一串 entry,每个都刚好 253 字节。现在,修改 entry1,使其变成 254 字节

  1. entry1 变长 → entry2 的 prevlen 必须从 1 字节 → 5 字节
  2. entry2 因为 prevlen 变长了 4 字节 → 自身总长度增加 4 字节;
  3. → entry3 的 prevlen 原本记录的是 entry2 的旧长度(253+1=254),现在 entry2 实际变长了 → entry3 的 prevlen 也要从 1 字节 → 5 字节;
  4. → entry3 变长 → entry4 的 prevlen 也要更新……
  5. 一直传递到末尾!

LISTPACK优化#

导致连锁更新的原因:压缩列表中的元素需要保存上一个节点的的长度 prevlen ,所以会出现连锁更新,listpack直接移除了这个字段,只记录自己节点的长度

image-20251211092202877

这里的len就是解决连锁更新的关键

Set#

适用场景#

Redis的Set是一个不重复、无序的一个集合,适用于无序集合场景,O(1) 判断某元素是否存在。

常用操作#

写操作#
SADD#

语法:SADD key member [member …]

返回值:返回添加了几个元素

SREM#

用法:SREM key member [member …]

返回值:返回删除了多少个元素

读操作#
SISMEMBER#

用法:SISMEMBER key member

功能:查询元素是否存在

返回值:1(存在)/ 0(不存在)

SCARD#

用法:SCARD key

功能:返回key中的元素数量

SMEMBERS#

用法:SMEMBERS key

功能:查看key中所有的元素

SSCAN#

用法:SSCAN key cursor [MATCH pattern] [COUNT count]

MATCHCOUNT 是关键字,必须显式写出,不能省略

SINTER#

用法:SINTER key [key …]

功能:返回第一个集合中存在,并且其他集合中也存在的元素

SUNION#

用法:SUNION key [key …]

功能:返回集合并集

SDIFF#

用法:SDIFF key [key …]

功能:返回第一个集合有,其他集合没有的元素

实机演示#

Terminal window
#---------- SADD SREM SISMEMBER SCARD 演示 -------------
测试服务器:db0> sadd name leonsong1 leonsong2 leonsong3
3
测试服务器:db0> srem name leonsong1
1
测试服务器:db0> sismember name leonsong1
0
测试服务器:db0> sismember name leonsong2
1
#返回数量
测试服务器:db0> scard name
2
#返回所有
测试服务器:db0> smembers name
1) "leonsong2"
2) "leonsong3"
#---------- SINTER SUNION SDIFF SSCAN 演示 -------------
测试服务器:db0> sadd name 小明 小华 小黄 小兰
4
测试服务器:db0> sadd boy 小明 小华 小天 小地
4
测试服务器:db0> sinter name boy
1) "小华"
2) "小明"
测试服务器:db0> sunion name boy
1) "小兰"
2) "小黄"
3) "小明"
4) "小天"
5) "小华"
6) "小地"
测试服务器:db0> sdiff name boy
1) "小黄"
2) "小兰"
测试服务器:db0> SSCAN name 0
1) "0"
2) 1) "小兰"
2) "小黄"
3) "小华"
4) "小明"
测试服务器:db0> sadd name 大宋 大小 大的 大人
4
测试服务器:db0> sscan name 0 match ** count 3
1) "6"
2) 1) "小黄"
2) "小兰"
测试服务器:db0> sscan name 0 match *
1) "0"
2) 1) "大小"

底层编码#

Terminal window
# 查看niuniu集群的元素(这里都是整形)
本地redis:0>smembers niuniu
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"
#由于都是整形,所以编码模式是 intset
本地redis:0>object encoding niuniu
"intset"
# 插入字符串,破坏intset编码条件
本地redis:0>sadd niuniu abc
"1"
# 由于存在字符串,所以编码模式变成了hashtable
本地redis:0>object encoding niuniu
"hashtable"

Hash#

适用场景#

Redis Hash是一个field、value都为string的hash表,适用于0(1)时间字典查找某个field对应数据的场景,比如任务信息的配置,就可以任务类型为field,任务配置参数为value

常用操作#

写操作#
HSET & HMSET#

HMSET 在Redis 4.0.0后被弃用。在4.0.0之前,HSet只能设置单个键值对。同时设置多个时必须使用HMSET。而现在HSet也允许接受多个键值对作参数了。

用法:HSET key field value [field value ...]

功能:添加多个键值对到key中

HSETNX#

用法:HSETNX key field value

功能:如果key中 filed不存在,则为field设置value值,如果存在则保持原来的值

HDEL#

用法:HDEL key field [field ..]

功能:删除对象中指定的field

DEL#

用法:DEL key [key ...]

功能:删除对象

读操作#
HGETALL#

用法:HGETALL key

功能:查找全部数据

HGET#

用法:HGET key field

功能:获取对象中的field

HLEN#

用法:HLEN key

功能:获取key中元素个数

HSCAN#

用法:HSCAN key cursor [MATCH pattern] [COUNTcount]

功能:迭代查询从指定位置查询一定数量的数据,这里要注意,如果是小数据量下,处于ZIPLIST时,COUNT不管填多少,都是返回全部,因为ZIPLIST本身就用于小集合,没必要说再切分成几段来返回。

实机演示#

Terminal window
#---------- HSET HSETNX 演示 -------------
测试服务器:db4> hset user name leonsong age 18 sex man
3
测试服务器:db4> hsetnx user look good
1
测试服务器:db4> hsetnx user look bed
0
#---------- HGET HGETALL HLEN 演示 -------------
测试服务器:db4> hgetall user
1) "name"
2) "leonsong"
3) "age"
4) "18"
5) "sex"
6) "man"
7) "look"
8) "good"
测试服务器:db4> hget user name
leonsong
测试服务器:db4> hlen user

底层编码#

HASHTABLE#

HASHTABLE 结构#

typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
unsigned long sizemask;
//该哈希表已有的节点数量
unsigned long used;
} dictht;

dictEntry 结构里不仅包含指向键和值的指针,还包含了指向下一个哈希表节点的指针,hashtable使用链式hash来解决哈希冲突,使用的是头插法(头插法不用遍历到尾部,直接插入,效率会比尾插法高)

image-20251211103657319

Hash表渐进式扩容#

我们知道,链式hash如果hash冲突比较多的时候会严重影响效率,所以需要扩容。Redis hash扩容采用的是渐进式扩容的方法。这是为了解决 大字典(hashtable)扩容/缩容时一次性 rehash 导致主线程阻塞 的问题。

typedef struct dict {
dictht ht[2]; // ht[0] = 当前表,ht[1] = 新表(rehash 时用)
long rehashidx; // -1 = 未 rehash;≥0 = 正在 rehash,表示当前处理的桶索引
} dict;

为了实现渐进式扩容,redis在dictht 外面封装了一层 dict,里面有两个hashtable,当插入元素的时候,字典发现需要扩容,就会进行rehash

image-20251211103808969

Rehash 过程如下:

1. 触发条件

2. 开始 rehash

3. 后续每次操作(GET/SET/HDEL 等)都做一点迁移

4. 完成 rehash

扩容时机#

负载因子 k 的大小为:k = used / size(ht[0])

缩容#

负载因子小于0.1就会缩容,新表大小为原表used的2次方幂,比如原表 used=200,那缩容就将 size 变为 256

跳表#

跳表的本质是链表,普通的链表结构如下:

image-20251211110412339

这种结构虽然简单,但是查询的效率很低,时间复杂度O(N),为了提高查找的性能,跳表在链表的基础上加入了多级的索引,通过索引可以一次实现多个节点的跳跃,提高了性能

跳表的结构#

image-20251211110459385

场景:查找分数为45的元素

Redis的跳表分数可以重复,并且还是双向的链表结构

Redis 跳表#

image-20251211110524559

//from Redis 7.0.8
//* ZSETs use a specialized version of Skiplists
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistlevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;

优化后的跳表平均时间复杂度为O(logN),最坏的情况是O(N)

ZSET#

ZSET(Sorted Set,有序集合) 是一个元素唯一、按分值(score)排序的数据结构,常用于排行榜、带权重的任务队列等场景。

常用操作#

写操作#
ZADD#

用法:

ZADD key [NX|XX] [GT|LT] [CH] [INCR] score member [score member …]

功能:向zset添加数据,如果key存在,则更新对应的数据

ZREM#

用法:ZREM key member [member]

功能:删除key中的元素

ZCARD#

用法:ZCARD key

功能:查看key中成员数

ZRANGE#

用法:ZRANGE key min max[WITHSCORES]

功能:查询从start到stop范围的ZSet数据,WITHSCORES选填,不写输出里就只有key,没有score值

ZREVRANGE#

用法:ZREVRANGE key min max[WITHSCORES]

功能:reverse range,从大到小遍历,WITHSCORES选填,不写输出里就只有key,没有score值

ZCOUNT#

用法:ZCOUNT key min max

功能:计算min-max积分范围的成员个数

ZRANK#

用法:ZRANK key member

功能:查找key中的member的排名索引,从0 开始

ZSCORE#

用法:ZSCORE key member

功能:查找key中的member的分数

底层编码#

过期对象的处理#

如何设置过期时间#

更通用的过期命令是EXPIRE,它可以对所有数据对象设置过期时间,EXPIRE也分秒和毫秒:

  1. EXPIRE key seconds<设置一个key的过期时间>,单位秒
  2. PEXPIRE key milliseconds<设置一个key的过期时间>,单位毫秒

删除策略#

Redis如何存储数据#

Redis 的数据库结构:

typedef struct redisDb {
dict *dict; // 主字典:key → value
dict *expires; // 过期字典:key → 过期时间戳(毫秒)
// ...
} redisDb;

Redis单线程#

Redis为何选择单线程?#

  1. redis本身单线程也是十分快的
  2. redis的数据结构是做过很大的优化的,单线程情况下效率很高,如果多线程的话要考虑线程安全的问题,会比较复杂
  3. 多线程会带来很多额外的成本
    1. 上下文切换成本,多线程调度需要切换上下文,这个操作需要先存储当前线程的本地数据,程序指针,然后再载入另一个线程
    2. 多线程同步机制的开销
    3. 线程占用内存

单线程为什么这么快#

  1. redis是基于内存存储,没有磁盘的开销
  2. redis优化了很多的数据结构,例如跳表、压缩列表
  3. redis采用了 I/O多路复用机制,使其在网络IO中能处理大量的客户端的请求,实现高吞吐

I/O多路复用#

单线程下,客户端发送一个请求服务端的处理过程如下:

image-20251211171606204

套接字默认阻塞模式,要么是没法连接阻塞在了accept,要么是没有接收到客户端的消息,阻塞在了recv。

单线程模式下,如果发生阻塞就会导致整个服务都被卡住,所以redis 设置了套接字非阻塞模式,在非阻塞模式下,虽然不会出现阻塞,但是也需要回过头来看看那些操作是否准备就绪,各个操作系统实现了一种叫做I/O多路复用的机制

I/O多路复用的机制

有I/O操作触发,就会产生通知,收到通知,再去处理通知对应的事件。redis针对I/O多路复用做了一层封装,叫做Reactor模型:本质就是监听各种时间,当事件发生,将事件分发到不同的处理器中,这样就不会阻塞在某一个操作,注意这里是并发,不是并行。

image-20251211172111721

Redis 6.0引用多线程(了解)#

多线程默认关闭,可以在redis.conf里面修改

随着互联网发展、业务体量可能达到原来想象不到的问题,此时Redis处理流程中的I/O操作,就成了瓶颈。

为了尽可能保持Redis代码的极致简洁、也为了兼容之前的版本,Redis选择了仅在I/0操作引入多线程,具体来说就是读请求包,和发返回包的时候使用了多线程

优化之后,在高并发场景,性能提高了一倍左右。即使只在关键的小范围引入了多线程,但是依然引入了更多的复杂度,可想而知,要是全部处理流程都变为多线程,那将真的是翻天地覆的改变。

Redis内存淘汰机制#

我们知道,redis是基于内存存储的,那redis的内存有没有限制呢?答案是肯定的,我们在redis的配置文件中可以看到,有一个 maxmemory配置是默认注释掉的,在32位操作系统中,redis限制最大内存默认是3G,64位操作系统不做限制。我们可以主动配置maxmemory,maxmemory支持各单位:

// redis 6.2.14
# maxmemory <bytes>
# MAXMEMORY POLICY: how Redis will select what to remove when maxmemory
# is reached. You can select one from the following behaviors:
#
# volatile-lru -> Evict using approximated LRU, only keys with an expire set.
# allkeys-lru -> Evict any key using approximated LRU.
# volatile-lfu -> Evict using approximated LFU, only keys with an expire set.
# allkeys-lfu -> Evict any key using approximated LFU.
# volatile-random -> Remove a random key having an expire set.
# allkeys-random -> Remove a random key, any key.
# volatile-ttl -> Remove the key with the nearest expire time (minor TTL)
# noeviction -> Don't evict anything, just return an error on write operations.

内存满了之后会触发内存淘汰机制,我们从配置文件可以看出,淘汰机制总共有8种:

淘汰算法的选择和淘汰时机#

淘汰算法的选择

淘汰的时机

淘汰算法#

redisObject对象源码#

robj *createObject(int type, void *ptr) {
robj *o = zmalloc(sizeof(*o));
o->type = type;
o->encoding = OBJ_ENCODING RAW;
o->ptr = ptr;
o->refcount = 1;
/* Set the LRU to the current lruclock (minutes resolution
* alternatively the LFU counter. */
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
o->lru = (LFUGetTimeInMinutes<<8) | LFU_INIT_VAL;
} else {
o->lru = LRU_CLOCK();
}
return o;
}

Redis - 近似LRU#

Redis使用的是近似LRU算法,我们接下来看一下他们的区别

LRU 算法#

LRU:记录每个key的最近访问时间,维护一个访问时间的列表

我们前面学习过Redis 是如何存储数据的,如果redis维护一个key的访问时间的列表,那么就相当于维护了一个双向链表。当数据量特别大的时候,对于内存的消耗是巨大的,我们知道redis内存有多么宝贵,所以redis选择了采样的方式来做,也就是近似LRU 的算法

近似LRU算法#

我们在学习[redisObject](#Redis Object)的时候,里面有个字段 lru 存储的是key被访问的时钟,当key被访问的时候,redis会更新lru字段(redis为了保证单线程的性能,缓存了unix操作系统时钟,默认每100ms更新一次)

// from Redis 5.0.5#define LRU BITS 24
#define LRU BITS 24
typedef struct redisObject {
...
unsigned lru:LRU BITS; /* LRU time or
* LFU data_*/
//ps:Lru字段表示的时间戳越小,就代表这个key空闲的时间越大
// 就越应该被淘汰
...
] robj;

近似LRU算法就是使用了随机采样的方式来淘汰元素,内存不足的时候就执行一次。具体步骤如下:

淘汰池优化#

通过近似LRU的介绍我们也能看出来一些问题:随机采样的数据并不是真正的全局最旧key,数据量越大的情况下,这种采样就会越不准确,那该如何解决呢?

redis维护了一个大小为16的淘汰池,池中的数据根据访问的时间进行排序,第一次过程如下:

内存淘汰算法 - LFU#

前面我们讲过 LRU 是根据key的使用时间来进行淘汰的,这里我们要讲

LFU(Least Frequently Used)根据key的使用频率来进行淘汰

LRU字段复用#

我们知道LRU是通过redisObject中的lru字段来记录访问时间的,这个字段的长度固定为26位,LFU通过复用这个字段,把lru的前16位和后8 位拆分,前16位存储访问时间,这个时间是以分钟为单位,后8位存储访问次数。

image-20251211174422586

创建redisObject对象源码#
robj *createObject(int type, void *ptr) {
robj *o = zmalloc(sizeof(*o));
o->type = type;
o->encoding = OBJ_ENCODING RAW;
o->ptr = ptr;
o->refcount = 1;
/* Set the LRU to the current lruclock (minutes resolution
* alternatively the LFU counter. */
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
//如果使用的是LUF,那么lru字段的前16位用的是 LUF获取分钟为单位的时间
//后8 位是LFU初始化值 默认为 5
o->lru = (LFUGetTimeInMinutes<<8) | LFU_INIT_VAL;
} else {
// 如果使用的是LRU,那么lru字段的值就是时间戳
o->lru = LRU_CLOCK();
}
return o;
}
LFU数据更新(某个key被访问到)#
void updateLFU(robj *val) {
//计算次数衰减
unsigned long counter = LFUDecrAndReturn(val);
//一定概率增加访问的次数
counter = LFULogIncr(counter);
//更新lru字段
val->lru = (LFUGetTimeInMinutes()<<8) | counter;
  1. 计算次数衰减:根据这次和上次的访问间隔来计算应该减少的次数,如果间隔比较大说明访问的频率低,那么这个次数就应该衰减
  2. 一定概率增加访问的次数:我们知道 LFU_INIT_VAL 默认是5,
    1. 如果小于5,肯定会增加这个次数
    2. 如果大于5 小于 255(因为2进制的8个1 是255) 会一定概率加1,原来的次数越大增加的概率就越低除了原来的次数影响之外,还有一个 lfu-log-factor参数可以设置这个增加的概率,如果参数为0,那么每次必加1,很快就能到最大值
  3. 更新lru字段的值

Redis持久化#

什么是持久化#

redis是基于内存存储的,如果系统重启或者崩溃,那么数据就会丢失,如果业务场景需要崩溃后数据还在,就需要持久化,即:数据保存到永久可以保存的存储设备中

持久化的方式#

Redis提供了两种持久化的方式:

image-20251211174607043

image-20251211174641148

RDB 和 AOF 如何选择#

根据业务不同选择不同的方式:

image-20251211175049315

RDB详解#

如何开启RDB持久化?#

配置文件:

# Unless specified otherwise, by default Redis will save the DB:
# * After 3600 seconds (an hour) if at least 1 key changed
# * After 300 seconds (5 minutes) if at least 100 keys changed
# * After 60 seconds if at least 10000 keys changed
# save <seconds> <changes>
save 3600 1
save 300 100
save 60 10000

RDB 文件存在哪儿#

下面的两个参数决定了RDB文件的位置

Terminal window
# The filename where to dump the DB
dbfilename dump.rdb
dir ./

什么时候持久化?#

  1. 执行save命令:这个命令会占用主线程,如果save时间过长,会阻塞主线程

  2. 执行bgsave:这个是后台save,会创建一个子线程,不会阻塞主线程

  3. 达到配置文件的要求

  4. 程序正常关闭的时候执行一个save,会阻塞主线程

RDB做了什么#

整体上分如下几个步骤:

  1. 后台创建一个子线程,专门做RDB持久化
  2. 子线程写数据到RDB文件
  3. 写完之后,新的RDB文件替换旧的RDB文件

如果RDB进行全量快照的时候,主线程修改数据怎么办呢?

执行快照的时候修改数据(写时复制COW)#

bgsava 过程中,Redis 依然可以继续处理操作命令的,用到的就是*写时复制技术*。

  1. fork() 子进程时,父进程和子进程共享同一物理内存页
  2. 只要不修改数据,就不复制内存
  3. 当主进程要修改某内存页时,操作系统才复制该页(COW),子进程继续读旧页
  4. 子进程始终看到 fork 时刻的内存快照,主进程可继续处理请求。

极端情况:如果所有的共享内存都被修改,则此时的内存占用是原先的 2 倍

AOF详解#

如何开启AOF#

我们知道redis 默认是开启RDB的,但是如何开启AOF呢?

Terminal window
//配置文件开启AOF 设置为 yes即可打开
appendonly no
# The name of the append only file (default: "appendonly.aof")
appendfilename "appendonly.aof"

AOF 三种写回策略#

  1. redis执行完写操作之后,把命令追加到一个**server.aof_buf** 缓冲区
  2. 通过write系统调用,把缓冲区数据写入AOF文件,但是没有写入硬盘而是拷贝到内核缓冲区,等待内核把数据写入磁盘。
  3. 内核缓冲区的数据什么时候写入磁盘由内核决定

redis提供了三种刷盘策略:

  1. Always:每次写操作执行完之后,都把AOF日志数据写回硬盘
  2. Everysec:执行完写之后,每隔一秒写回磁盘
  3. No : 由操作系统决定啥时候写回磁盘。

Redis推荐方案二,每秒刷一次盘,这种方式下速度足够快,同时崩溃时损失的数据只有1s,这在大多数场景都是可以接受的。

AOF重写#

避免AOF文件过大,所以提供了重写机制。重写的时候是读取最新键值对的写入命令,然后只记录最新的一条。实现了压缩。不复用原来的AOF文件是为了防止修改的过程宕机,导致文件被污染无法恢复使用。使用一个新的即使出现问题删了就行,影响不大。

AOF恢复数据很慢,因为Redis是单线程执行的,AOF是顺序执行日志中的命令,如果文件过大就会导致过程很慢。

重写条件:同时满足以下两个条件重写

Terminal window
//相比上次重写,数据增长了100 %
auto-aof-rewrite-percentage 100
//超过大小
auto-aof-rewrite-min-size 64mb

RDB和AOF混合持久化#

怎么开启?

Terminal window
//redis 5.0 之后默认打开
aof-use-rdb-preamble yes

混合持久化发生在AOF重写期间

这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快。

image-20251211181645502

场景应用#

缓存异常场景#

缓存穿透#

解决方案:#
  1. 布隆过滤器(Bloom Filter):快速判断 key 是否可能存在于 DB;
  2. 缓存空值(null):对查不到的数据也缓存一个空结果(带短 TTL,如 1~5 分钟);
  3. 参数校验:提前拦截非法请求。

布隆过滤器#

用于 快速判断“一个元素是否可能存在于集合中”。

大致工作原理:

  1. 初始化
    • 创建一个 位数组(bit array),长度为 m,初始全为 0
    • 选择 k 个不同的哈希函数
  2. 添加元素(如 "apple"):
    • k 个哈希函数计算出 k 个位置
    • 将位数组中这 k 个位置设为 1
  3. 查询元素(如 "banana"):
    • 用同样的 k 个哈希函数计算位置;
    • 如果所有位置都是 1 → 返回 “可能存在”
    • 如果任意一个位置是 0 → 返回 “一定不存在”
添加 "apple":hash1=3, hash2=7, hash3=12 → bit[3]=1, bit[7]=1, bit[12]=1
查询 "banana":hash1=3, hash2=8, hash3=12 → bit[8]= 0 → 一定不存在 ✅
查询 "orange":hash1=3, hash2=7, hash3=12 → 全为1 → 可能存在(但可能误判)⚠️

缓存击穿#

缓存雪崩#

缓存一致性怎么保证#

什么是缓存一致性

Redis作为MySQL的缓存,如果 MySQL 更新了,Redis的数据该怎么保持最终一致呢?

过期时间兜底#

​ 只更新MySQL,Redis等缓存过期失效自动让他同步,由过期时间兜底。这种实现方式较为简单,但是不一致会比较明显。如果请求比较频繁并且过期时间设的比较长,那么就会产生很多脏数据。

优点:开发成本低,易于实现

缺点:完全依赖过期时间,时间短容易造成缓存经常失效,太长又会产生很多不一致的数据,不适合对一致性要求较高的场景

直接删除key#

​ 在更新MySQL的时候同时删除key,一般不用更新,因为更新会导致时序性的问题,例如:

假设MySQL中有个数 age = 18,接下来有两个服务器要对age进行修改:

  1. A 服务器 将 age 的值加 1,变成 19。同时更新 redis 缓存数据
  2. B 服务器 将 age 的值设置为 30。 同时更新Redis 缓存数据

最终mysql 中 age = 30

但是由于redis更新存在时序性的问题,AB 服务器的命令一定谁先执行。这就有可能发生:

  1. Redis 先执行了 B 服务器发出的修改请求,age 设置为 30
  2. Redis 后执行了 A 服务器发出的修改请求,age + 1 = 31

最终 Redis 中 age = 31

所以,一般MySQL修改数据后,Redis直接删除,下次访问的时候再缓存

优点:在方案 1 的基础上增加了删除逻辑,达成最终一致性的延迟小。一个是等过期删除更新数据,这个是主动删除更新

缺点:如果删除redis失败,就退化为 “过期时间兜底” 方案。同时 带来了 Redis 删除损耗

canal 组件订阅binlog#

使用开源的 canal 组件,该组件通过订阅MySQL的binlog日志,每次MySQL进行写操作数据的时候,组件会将数据更新到Redis中。完全和业务解耦。减少很多心智成本

缺点:引入 新的 canal 组件,维护是个麻烦的问题,并且如果 同步的服务挂了,会出现很多脏数据

分布式锁#

什么是分布式锁?#

分布式场景下的锁,比如多台机器的不同进程,竞争同一个资源,这就是分布式锁。当前 APS 系统为了实现同一时间只有一个人能操作排程页面,每次用户进入页面时都会尝试获取分布式锁,如果获取到就可以访问,如果没有获取到(有用户访问),那么就无法访问

分布式锁的特性#

分布式锁常用实现方式#

setnx key 命令加锁#

​ 我们知道setnx 命令是尝试添加 key,key不存在就可以添加,存在就不能添加。这不就是有没有人持有锁的意思吗,根据这个特性我们使用 setnx key value命令来尝试添加key (获取锁)(setnx lock 1)如果命令返回1说明获取到锁了,如果返回 0 说明锁被别服务占用了

**存在的问题:**如果持有锁的服务在释放锁之前挂掉了,那么锁就一直得不到释放,别人就一直无法访问。APS早期的分布式锁实现版本中,就是没有设置过期时间导致服务器挂掉之后,其他用户访问受限。解决办法就是给当前的锁加一个过期时间

带过期时间锁#

注意 setnx 这个命令,是没办法携带过期时间参数的。如果setnx再加expire命令,就没办法保证加锁的原子性。所以要用set命令,携带nx和px参数,才能保证加锁的原子性

Redis 有expire命令来设置key的过期时间,但是我们不能保证setnx 命令和expire命令的原子性,但是有一个命令具备原子性:

set key value nx ex second 这样即使服务挂了,锁也能过期后释放

存在的问题:当 服务 A在获取锁之后卡了,此时锁过期了。服务B 成功拿到锁。但是这时候服务A恢复后释放了锁,导致服务B的锁被释放了。解决办法就是给锁加一个身份,用来判断是不是自己的锁。也就是value值设置为“身份标识”

加上身份标识的锁#

解决释放别人锁的办法就是在释放锁的时候,检查这个锁是不是自己的锁,就可以把set key value nx ex second中的value设置为获取锁的线程的UUID

存在的问题:执行完毕,检查锁,再释放这一系列的操作并不是原子性的,可能获取锁的时候还是自己的,删除的时候就是别人的了

引入lua#

lua脚本配合Redis的单线程特性能很好的实现原子性。这样就能解决上面的问题了

Java实现#
public String getRedisLock() {
// 1、占分布式锁,去redis占坑,设置过期时间必须和加锁是同步的,保证原子性(避免死锁)
String uuid = UUID.randomUUID().toString();
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid,300,TimeUnit.SECONDS);
if (lock) {
System.out.println("获取分布式锁成功...");
try {
// 加锁成功...执行业务
return "finish!";
} finally {
// 执行 lua 脚本释放锁来保证释放锁是原子性的
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
stringRedisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid);
}
} else {
System.out.println("获取分布式锁失败...等待重试...");
// 加锁失败...重试机制,休眠一百毫秒
try { TimeUnit.MILLISECONDS.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }
// 自旋的方式
return getRedisLock();
}
}

如何保证可靠性?#

前面我们说的都是基于单机考虑的,如果Redis挂掉了,那么就获取不到锁了,那这个问题如何解决呢?

主从容灾#

​ 为redis配置主从节点,如果主节点挂了,就用从节点顶包,并且主从切换的时候redis有自己的哨兵模式,可以灵活切换,不用人工接入

​ 通过增加从节点的方式,虽然一定程度的解决了容灾的问题,但是还会存在一些问题:由于主从同步存在延迟,从节点可能会丢失一部分数据,导致分布式锁失效,如果我们对一致性要求比较高,那么我们可以采用多机部署的方式

多机部署#

RedLock(redis分布式锁的实现方式)大概的思路就是多个机器,通常是奇数个,达到一半以上同意加锁才算加锁成功。现在假设有5个Redis主节点,基本保证它们不会同时宕掉,获取锁和释放锁的过程中,客户端会执行以下操作:

  1. 向 5 个 Redis 申请加锁;
  2. 只要超过一半,也就是 3 个 Redis 返回成功,那么就是获取到了锁。如果超过一半失败。需要向每个Redis发送解锁命令;
  3. 由于向5个Redis发送请求,会有一定时耗。所以锁剩余持有时间,需要减去请求时间。这个可以作为判断依据,如果剩余时间已经为0,那么也是获取锁失败;
  4. 使用完成之后,向5个Redis发送解锁请求。

Redis 生产订阅模式#

PUB/SUB#

当订阅者订阅某个频道,如果生产者将消息发送到这个频道,订阅者就能收到该消息,这种模式支持多个消费者订阅相同的频道,互不干扰。

Redis在秒杀中的作用#

秒杀系统存在如下几个问题:

高并发解决#

我们可以把库中的商品数量加载到redis,然后在redis中进行扣减,扣减成功的,再通过消息队列传递到MySQL,做到真正的订单完成

如果请求超过 6w/s 我们就要考虑使用多个redis进行分流

超卖少卖的解决#

超卖#

卖操作的核心总共有两步:

这样有个问题就是,第一步判断的时候库存是够的,但是实际调用的时候,库存可能已经没有了,这就导致了超卖。因为这两个操作并不是原子性的,我们要想保证这个操作是原子性的,所以使用redis+lua脚本来实现

少卖#

库存减少,但是用户订单没生成这就会导致少卖

什么情况会这样呢:

  1. 减少库存操作超时,但实际是成功的,因为超时并不会进入生成订单流程;
  2. 在Redis操作成功,但是向Kafka发送消息失败,这种情况也会白白消耗Redis中的库存。

黄牛#

为了打击黄牛,最常见的方式是限购,一个用户最多只能抢到N份

为了性能,我们还是将限制逻辑加入到Redis中。我们的Lua脚本中原来的逻辑是:第一步查询库存;第二步扣减库存;需要优化为:第一步查询库存;第二步查询用户已购买个数;第三步扣减库存;第四步记录用户购买数。

或者我们可以加验证码限制

限流器#

限流就是流量限制,可以用来管控请求的速度。

为什么需要管控请求速度呢?来看以下一个场景:

如果一个网站每秒能处理的请求就几千量级。这时候网站突然涌进来一波流量,这波流量以每秒10w的频率访问业务,然后直接打到数据库,那么数据库可能直接挂掉。所以不管是什么后台服务,每秒能处理的请求个数总有极限。如果超过这个极限,轻则让服务变得缓慢,重则直接干跨业务。因此我们需要使用一些手段限制请求的发送频率。

计数器算法#

非常简单的一个算法,具体的实现就是记录某个时间段的请求数,如果超过了就拒绝,在下一个时间段的时候把这个请求数重置,重新计算。

缺点很明显:虽然t1 ~ t2、t2 ~ t3这两个时间段能保证流量在可控范围,但是如果出现请求突刺,也就是t2时间段前后流量激增,这就超过了流量限制。解决的办法就是滑动窗口算法。

image-20251212111742034

滑动窗口算法#

滑动窗口算法,实际是计数器算法的优化版本。

滑动窗口算法以当前物理时间为基点往前看,看基于当前时间的时间窗口,是否超过阈值,超过阈值就拒绝请求,问题就迎刃而解了。

image-20251212111812917

漏桶算法#

漏桶滴水的速度恒定(服务器处理请求的速度)桶的大小固定(限流)

缺点:我们无法精确判断网络带宽或处理能力。

一般来说都只能设置一个相对比较小的流量目标,比如800/s。如果产生了一波突发流量,经过了漏桶算法后,依然会以恒定的目标码率慢慢地发送。就算我们的服务有足够的实力,也不能让它快速处理了。

有两种方式解决:

  1. 动态调节水滴速度(就像输液瓶一样)<通过对后端进行不断试探>,尽可能始

终维持在性能处理的极致,这种方式的弊端在于带来了更多多的复杂性和耦合

性,属于特定优化了。

  1. 用另一种桶,令牌桶。

令牌桶#

在实际生产中,令牌桶因为其均匀性及突发流量容忍性,更受受青睐。腾讯云团队,阿里线上管控体系,Shopee金融团队都使用了令牌桶来做限流

令牌桶算法:我们有一个桶,桶里均匀生成令牌,请求来的时候需要先拿到令牌,然后再做后续的处理。桶中的令牌满了就会不再生成了。例如桶中最多1000个令牌,系统每1ms生成一个,1s就可以塞满桶,这样1s就可以处理1000个请求。

image-20251212111842433

多机部署#

哨兵模式#

哨兵说白了就是redis用来监测redis 服务的一个程序,本质上也是一个redis进程

当哨兵集群选举出哨兵Leader后,由哨兵Leader从Redis从节点点中选择一个Redis节点作为主节点,选举的策略:

  1. 先过滤故障的节点
  2. 选择优先级最大的从节点为主节点,如果不存在则继续(slave-priority)
  3. 选择复制偏移量最大的节点作为主节点,如果都一样就继续。
  4. 选择uuid最小的那个节点为主节点。redis每次启动都会生成一个随机uuid

image-20251212111923463

前面说过了哨兵也会选举出Leader,那么这个leader 是怎么选出来的呢?

每一个哨兵节点都可以成为Leader,当哨兵节点确认Redis集群的主节点下线后,会请求其他哨兵节点要求将自己选举为Leader。

主从数据同步#

Redis 主从节点的数据同步分为 全量同步(Full Resynchronization)增量同步(Partial Resynchronization) 两种方式。由 PSYNC 命令(Redis 2.8+)统一管理

全量同步(首次同步或断连太久)#

同步流程:#

  1. 从节点发送 PSYNC ? -1
    • 表示“我不知道主节点 runid(身份id) 和 offset(复制偏移量),请求全量同步”。
  2. 主节点响应 FULLRESYNC <runid> <offset>
    • 返回自己的 runid 和当前复制偏移量 offset
  3. 主节点执行 BGSAVE,生成 RDB 快照
    • 同时开启 复制缓冲区(replication buffer),缓存 RDB 生成期间的新写命令。
  4. 主节点将 RDB 文件发送给从节点
    • 从节点清空自身数据,加载 RDB。
  5. 主节点发送复制缓冲区中的增量命令
    • 保证从节点状态与主节点完全一致。
  6. 后续进入增量同步阶段

⚠️ 全量同步会 阻塞主节点网络 I/O(但不阻塞命令处理),且消耗大量带宽和内存。

增量同步(断连后快速恢复)#

触发条件:#

关键机制:复制偏移量(replication offset) + 复制积压缓冲区


📦 核心组件说明:

组件作用
runid主节点唯一 ID,用于识别是否同一主节点
replication offset主从各自记录的同步进度(字节偏移)
replication backlog主节点的环形缓冲区,保存最近写命令(用于增量同步)
replication buffer全量同步期间临时缓存新命令(每个从节点独立)

💡 repl-backlog-size 可配置 backlog 大小(如 100MB),避免频繁全量同步。

🎯 Redis 通过 PSYNC + 复制积压缓冲区,在保证一致性的同时,大幅减少全量同步次数,提升主从复制效率。

Redis集群(Cluster)#

Redis 集群是 Redis 官方提供的分布式解决方案

Redis Cluster 官方文档

哨兵模式基于主从模式,实现了读写分离,并且还可以自动切换,但是每个节点存的数据都一样,浪费内存,不好在线扩容

哈希槽#

​ Redis使用 **16384 个哈希槽(hash slots)**将数据进行分片。每个 key 通过 CRC16(key) % 16384 决定归属哪个槽,这个槽可以理解为只是个分片的依据,并不是真正的存储。每个节点负责一部分槽,key算出来在哪个槽,就应该去跟负责这个槽的节点来交互。

Redis中hash槽的结构

// from redis 5.0.5
#define CLUSTER_SLOTS 16384
typedef struct clusterNode {
unsigned char slots[CLUSTER_SLOTS/8];
int numslots;
/* Number of slots handled by this node */
} clusterNode;

image-20251217104135670

这种位图的方式,使得每个节点可以在O(1)时间复杂度下查询负责的槽。

集群模式下如何访问key#

​ 当客户端请求访问一个键时,Redis 会计算该键的哈希槽编号,并将请求转发到负责该哈希槽的节点。如果客户端直接连接到错误的节点,该节点会返回 MOVED 响应,MOVED 重定向响应会将哈希槽所在的新的实例IP和端口号port返回,告知客户端应将请求重定向到正确的节点。具体流程如下:

Gossip 协议#

gossip [ˈɡɒsɪp] 闲话、流言蜚语

​ Redis Cluster 是去中心化的,没有中心节点,所有节点地位平等。它依靠 Gossip 协议同步gge各节点的信息。

​ 每个节点周期性的从节点列表中选择 K 个节点,然后将自己节点存储的信息传播出去,直到所有节点信息都是一致的

gossip协议包含很多消息类型:

集群中主节点宕机且有从节点的情况#

Redis Cluster 使用 Gossip 协议 + 多数派投票 实现去中心化故障检测

主观下线(PFAIL):

客观下线(FAIL):

故障转移:

什么是一致性哈希#

分布式系统中用于 数据分片和负载均衡 的算法,核心目标是:在节点增减时,尽量减少数据的迁移量,同时保证负载相对均衡。

一致性哈希被广泛用在:分布式缓存、分布式存储、负载均衡器中

传统哈希的问题#

​ 如果我们有 N 台服务器,key的位置计算是通过 hash(key) % N。这样其实会导致一个问题。假设我们把节点从三个扩容到四个的时候,几乎所有的数据都要重新映射:

几乎有 75% 以上的 key都要重新映射。这在高可用系统中是无法接受的。

一致性哈希的实现原理#

一致性哈希通过将哈希空间组织成一个环,即映射 服务器节点,也映射 数据 key

也就是说,环里面有两个映射:服务器节点数据key。如下图:

image-20251216154327274

图中Data映射到环中位置,顺时针方向找到第一个服务器节点,上图中Data就顺时针找到第一个服务器节点(Server C)

节点变动影响小原理#

当我们新增节点的时候,只会影响新增节点和他下一个节点的数据。还是通过图解来理解。

image-20251216155502821

数据倾斜#

问题描述:物理节点少 → 数据分布不均(有的节点负载高,有的低),如下图所示:

image-20251216155916086

解决办法:每个物理节点对应多个虚拟节点

为什么 Redis 集群不用一致性哈希#

We wanted to support multi-key operations, and consistent hashing doesn’t allow that in a simple way. By using a fixed number of slots, we can ensure that keys with the same hash tag end up in the same node, enabling atomic operations across multiple keys.” —— Salvatore Sanfilippo (antirez)

翻译:

“我们希望支持多 key 操作,而一致性哈希无法简单实现这一点。通过使用固定数量的槽,我们可以确保带有相同 hash tag 的 key 落在同一个节点,从而支持跨多个 key 的原子操作。”

——Redis 作者

并且Redis 哈希槽能精准控制迁移的数量,一致性哈希做不到