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;- type: 是哪种Redis对象。比如:String、List、Set、Hash、Sorted Set、Stream 等…
- encoding: 表示用哪种底层编码,用
OBJECT ENCODING[key]可以看到对应的编码方式 - lru: 记录对象访问信息,用于内存淘汰
- refcount: 引用计数,用来描述有多少个指针,指向该对象
- ptr: 内容指针,指向实际内容
String#
String 是Redis中最基本的数据对象,最大为512MB,我们可以通过配置项 proto-max-bulk-len 来修
改它,一般来说是不用主动修改的。
适用场景#
- 一般可以用来存字节数据、文本数据、序列化后的对象数据
- 例如:缓存场景,Value存Json字符串等信息,计数场景
- 因为 Redis 处理命令是单线程,所以执行命令的过程是原子的。因此 String 数据类型适合计数场景,比如计算访问次数、点赞、转发、库存数量等等。
常用操作#
写操作#
SET key value:设置一个key 的值为 value
SETNX key value <只有key不存在才能操作>只有key不存在才能操作>
下面有一些拓展的参数:
- ex second : 设置键的过期时间为多少秒
- px millisecond : 设置键的过期时间为多少毫秒
- NX<只在键不存在时>只在键不存在时>,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value,基本是替代.了下面的SETNX操作
- XX : 只在键已经存在时,才对键进行设置操作
删除操作#
语法: DEL key [key2 …]
功能<删除对象>删除对象>,返回值为删除成功了几行
读操作#
- 单个读
- 语法:GET key
- 功能<返回>返回> key 的value 没有就返回null
- 多个读
- 语法:MGET key [key2 …]
- 功能<返回对应>返回对应> key 的value 没有就返回null
操作演示#
#---------- SET 演示 -------------# 设置key测试服务器:db0> set name leonsongOK#key为空设置,这里name已经有了,所以命令没用测试服务器:db0> setnx name leonsong0#设置age 过期时间5s测试服务器:db0> set age 18 ex 5OK#直接获取是有的测试服务器:db0> get age18#等5s在获取没有输出了测试服务器:db0> get age#key 存在的时候设置,这里发现设置失败了测试服务器:db0> set age 18 xx#name 是存在的,这时候设置就存在了测试服务器:db0> set name leonsong xxOK测试服务器:db0> set age 20OK
#---------- GET 演示 -------------测试服务器:db0> get nameleonsong测试服务器:db0> mget name age1) "leonsong"2) "20"
#---------- DEL 演示 -------------# 删除操作测试服务器:db0> del name age2底层实现#
三种编码方式#
String有三种编码方式,如下图所示:

- Int :当存储的值为整数,且值的大小可以用 long 类型表示时,Redis 使用 int 编码。在 int 编码中,String 对象的实际值会被存储在一个 long 类型的整数中。这种编码方式的优点是存储空间小,且无需进行额外的解码操作。( 只有整数才会使用int,如果是浮点数, Redis内部其实先将浮点数转化为字符串值,然后再保存)
- embstr 编码:当字符串长度小于等于 44 字节时,使用 embstr 编码(一种优化的 SDS 编码方式)。如图所示:redisObiect 和 SDS 是连续的内存

- raw 编码:当字符串长度大于 44 字节时,使用 raw 编码(标准的 SDS 结构)。如图所示:RAW编码下 redisObject 和 SDS 的内存是分开的

EMBSTR 和 RAW 的区别#
- 内存分配次数:
embstr:在创建字符串对象时,只分配一次连续内存,同时包含redisObject和sdshdr结构。raw:需要两次内存分配,分别分配redisObject和sdshdr
- 适用场景
embstr:用于短字符串(长度 ≤ 44 字节),是只读优化的编码。raw:用于长字符串(长度 > 44 字节),支持修改操作。
任何写操作之后 EMBSTR 都会变成 RAW,理念是发生过修改的字符审通常会认为是易变的。
SDS 简单的动态字符串(simple dynamic string)#
char字符数组的缺陷#
- 获取长度需遍历:必须从头到尾扫描直到遇到
\0,时间复杂度 O(n)。 - 不能存储二进制数据:中间若出现
\0会被误认为字符串结束,导致截断。 - 缓冲区溢出风险:操作(如
strcat、strcpy)不检查目标空间大小,容易越界写入。
SDS结构介绍#
struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; // 已使用字节数(字符串长度) uint8_t alloc; // 总分配字节数(不包括头部和结尾的 \0) unsigned char flags; // 保存不同类型的sds(如 sdshdr8、sdshdr16 等) char buf[]; // 实际字符数组(柔性数组)};- len :返回长度时间复杂度O(1)
- alloc :len小于1M的情况下,alloc=2倍*len,即预留len大小的空间en大于1M的情况下,alloc是1M+len,即预留1M大小的空间简单来说,预留空间为min(len,1M)
- buf[] <保存的是实际的数据>保存的是实际的数据>,字符串和二进制都可以保存

- flags :保存不同类型的sds,总共有五种:sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64
- 这几种的区别就在于后面的数字,5 、 8 、 16、 32、 64表示的是字符数组的长度和分配空间的大小不能超过2^5、 2^8、 2^16、 2^32、 2^64
SDS的优势#
- 长度 O(1) 获取
- 二进制安全,有个专门的 len 成员变量来记录长度,所以可存储包含 “\0” 的数据
- 安全追加(自动扩容),不会发生缓冲区溢出的情况
- 节省内存空间,设计的不同的sds结构体,为了灵活保存不同大小的字符串,节省内存空间
List#
- List 是一组连接起来的字符串集合
- List 最大元素个数是2^64 - 1
适用场景#
- 存储一批任务数据
- 存储一批消息
常用操作#
添加元素#
LPUSH key value [value ...]:从左边插入一个或多个值。RPUSH key value [value ...]:从右边插入一个或多个值。
弹出元素#
LPOP key:移除并返回左边第一个元素。RPOP key:移除并返回右边第一个元素。BLPOP key [key ...] timeout:阻塞式左弹出(常用于消息队列)。BRPOP key [key ...] timeout:阻塞式右弹出。
查询元素#
LRANGE key start stop:获取列表指定范围的元素(如LRANGE list 0 -1获取全部)。LINDEX key index:获取指定索引的元素。LLEN key:获取列表长度。
修改#
LSET key index value:设置指定索引的值。LTRIM key start stop:保留指定范围内的元素,其余删除(常用于保留最新 N 条)。
这些命令支持高效地在列表两端操作,时间复杂度大多为 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](连续排列)现在要在 A 和 B 之间插入 X:必须:
- 为新元素
X腾出空间; - 把
B和C向后移动(即内存拷贝); - 写入
X。


- zlbytes:记录整个压缩列表占用内存字节数,这个数字包含zlbytes本身占据的字;
- zltail:记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;
- zllen:记录压缩列表包含的节点数量 图中有N个;
- zlend:标记压缩列表的结束点,固定值 0xFF(十进制255)
- entry1~entryN: 压缩列表数据节点
- prevlen:记录了「前一个节点」的数据长度;
- encoding:记录了当前节点实际数据的类型以及长度;
- data:记录了当前节点的实际数据
LINKEDLIST#
(Redis3.2 之前)现在已经被 quick list 取代

- 结构:标准的双向链表,每个节点(
listNode)存储一个 List 元素。 - 优点:插入或者修改不用内存拷贝,效率高
- **缺点:**内存开销大,每个节点都有前后指针
QUICKLIST#
如果节点非常多的情况,LINKEDLIST链表的节点就很多,会占用不少的内存。这种情况有没有办法优化呢? Redis 3.2版本就引入了QUICKLIST。QUICKLIST其实就是ZIPLIST和LINKEDLIST的结合体。

typedef struct quicklist { quicklistNode *head; quicklistNode *tail; unsigned long count; // 总元素数 unsigned long len; // 节点数量 ...} quicklist;LINKEDLIST之前是单节点存放一个数据,QUICKLIST是单节点存放一个ZIPLIST,也就是多数据
- 对于QUICKLIST来说数据较少的时候,只有一个quickListNode节点,此时就相当于ZIPLIST
- 数据多的时候同时应用了ZIPLIST 和 QUICKLIST的优势
为什么会发生连锁更新?#
问题根源:prevlen 字段#
ziplist 的每个 entry 都包含一个 prevlen 字段,用于记录前一个 entry 的长度,以便从后往前遍历。
- 如果前一个 entry 长度 < 254 字节 →
prevlen占 1 字节 - 如果前一个 entry 长度 ≥ 254 字节 →
prevlen占 5 字节(1 字节标记 + 4 字节实际长度)
连锁更新如何发生?#
假设有一串 entry,每个都刚好 253 字节。现在,修改 entry1,使其变成 254 字节:
- entry1 变长 → entry2 的
prevlen必须从 1 字节 → 5 字节; - entry2 因为
prevlen变长了 4 字节 → 自身总长度增加 4 字节; - → entry3 的
prevlen原本记录的是 entry2 的旧长度(253+1=254),现在 entry2 实际变长了 → entry3 的prevlen也要从 1 字节 → 5 字节; - → entry3 变长 → entry4 的
prevlen也要更新…… - 一直传递到末尾!
LISTPACK优化#
导致连锁更新的原因:压缩列表中的元素需要保存上一个节点的的长度 prevlen ,所以会出现连锁更新,listpack直接移除了这个字段,只记录自己节点的长度
- LISTPACK 结构:

- encoding:定义该元素的编码类型,会对不同长度的整数和字符串进行编码;
- data:实际存放的数据;
- len:encoding+data的总长度;
这里的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]
MATCH 和 COUNT 是关键字,必须显式写出,不能省略
key:Set 的键名cursor:游标(首次传0,后续用返回的新游标)MATCH pattern:可选,模糊匹配元素(如user:*)COUNT count:可选,建议每次返回的元素数量(默认 10,非精确值)
SINTER#
用法:SINTER key [key …]
功能:返回第一个集合中存在,并且其他集合中也存在的元素
SUNION#
用法:SUNION key [key …]
功能:返回集合并集
SDIFF#
用法:SDIFF key [key …]
功能:返回第一个集合有,其他集合没有的元素
实机演示#
#---------- SADD SREM SISMEMBER SCARD 演示 -------------测试服务器:db0> sadd name leonsong1 leonsong2 leonsong33测试服务器:db0> srem name leonsong11测试服务器:db0> sismember name leonsong10测试服务器:db0> sismember name leonsong21#返回数量测试服务器:db0> scard name2#返回所有测试服务器:db0> smembers name1) "leonsong2"2) "leonsong3"
#---------- SINTER SUNION SDIFF SSCAN 演示 -------------测试服务器:db0> sadd name 小明 小华 小黄 小兰4测试服务器:db0> sadd boy 小明 小华 小天 小地4测试服务器:db0> sinter name boy1) "小华"2) "小明"测试服务器:db0> sunion name boy1) "小兰"2) "小黄"3) "小明"4) "小天"5) "小华"6) "小地"
测试服务器:db0> sdiff name boy1) "小黄"2) "小兰"测试服务器:db0> SSCAN name 01) "0"2) 1) "小兰"2) "小黄"3) "小华"4) "小明"
测试服务器:db0> sadd name 大宋 大小 大的 大人4测试服务器:db0> sscan name 0 match *小* count 31) "6"2) 1) "小黄"2) "小兰"测试服务器:db0> sscan name 0 match *小1) "0"2) 1) "大小"底层编码#
-
intset : 元素都是整数,且元素数量不超过512个就使用INTSET编码,
- 内存紧凑:使用连续数组存储,按值排序;
- 查找/插入/删除:通过二分查找,时间复杂度 O(log N);
-
hashtable :不满足INTSET 编码的集群使用的编码都是HASHTABLE 编码(后面有讲解hashtable的章节)
- 键值对结构:Set 的每个元素作为 key,value 为
NULL(只用 key 存储成员); - O(1) 平均时间复杂度:查找、插入、删除;
- 拉链法解决哈希冲突
- 动态扩容/缩容:负载因子过高时 渐进式rehash
- 键值对结构:Set 的每个元素作为 key,value 为
# 查看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本身就用于小集合,没必要说再切分成几段来返回。
实机演示#
#---------- HSET HSETNX 演示 -------------测试服务器:db4> hset user name leonsong age 18 sex man3测试服务器:db4> hsetnx user look good1测试服务器:db4> hsetnx user look bed0
#---------- HGET HGETALL HLEN 演示 -------------测试服务器:db4> hgetall user1) "name"2) "leonsong"3) "age"4) "18"5) "sex"6) "man"7) "look"8) "good"测试服务器:db4> hget user nameleonsong测试服务器:db4> hlen user底层编码#
-
ziplist: 压缩列表
- 数据量小的时候紧凑排列,数据量大的时候就要用HASHTABLE
- key 和 value 成对紧挨着存,靠顺序位置和逐个比对来定位,不是靠指针或哈希。这是用时间换空间的设计。

-
hashtable:与set中的hashtable不同的是,set中 field指向的value都是null,hash的的field都有值
HASHTABLE#
HASHTABLE 结构#
typedef struct dictht { //哈希表数组 dictEntry **table; //哈希表大小 unsigned long size; //哈希表大小掩码,用于计算索引值 unsigned long sizemask; //该哈希表已有的节点数量 unsigned long used;} dictht;- table:指向hash存储
- size:hash表的大小
- sizemask:总是等于size - 1,长度掩码。计算Index的值,index = hash & sizemask
- used:表示已经使用的节点数量
dictEntry 结构里不仅包含指向键和值的指针,还包含了指向下一个哈希表节点的指针,hashtable使用链式hash来解决哈希冲突,使用的是头插法(头插法不用遍历到尾部,直接插入,效率会比尾插法高)

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

Rehash 过程如下:
1. 触发条件
- 扩容:负载因子 > 1(或 < 0.1 且启用了缩容)
- Redis 创建
ht[1](新表,大小为ht[0]的 2 倍或 1/2)
2. 开始 rehash
- 设置
rehashidx = 0表示rehash正式开始(从旧数据的第 0(rehashidx ) 个桶开始迁移)
3. 后续每次操作(GET/SET/HDEL 等)都做一点迁移
- 在执行命令前,先将
ht[0]中rehashidx指向的桶全部迁移到ht[1] - 然后
rehashidx++ - 同时执行:
- 新增操作:直接写入
ht[1] - 查询/删除:先查
ht[0],再查ht[1](因为数据可能在任一表)
- 新增操作:直接写入
4. 完成 rehash
- 当
ht[0]所有桶都迁移完 → 释放ht[0],将ht[1]赋给ht[0],重置rehashidx = -1
扩容时机#
负载因子 k 的大小为:k = used / size(ht[0])
- 负载因子大于等于1,说明桶已经被占满了,每次插入新的元素的时候,都是在链表上叠加的,越来越多的数据无法在O(1)时间复杂度被找到,还需要遍历链表。如果服务器没有执行 BGSAVE或者BGREWRITEAOF命令,就会发生扩容
- 负载因子大于5,即使在执行BGSAVE或者BGREWRITEAOF命令也要扩容,因为此时hashtable已经不堪重负了
缩容#
负载因子小于0.1就会缩容,新表大小为原表used的2次方幂,比如原表 used=200,那缩容就将 size 变为 256
跳表#
跳表的本质是链表,普通的链表结构如下:

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

场景:查找分数为45的元素
- 通过二级索引查到35,跳到35,
- 从35通过二级索引查到65,发现太大,不跳到65
- 从35的一级索引找到45
Redis的跳表分数可以重复,并且还是双向的链表结构
Redis 跳表#

//from Redis 7.0.8//* ZSETs use a specialized version of Skipliststypedef struct zskiplistNode { sds ele; double score; struct zskiplistNode *backward; struct zskiplistlevel { struct zskiplistNode *forward; unsigned long span; } level[];} zskiplistNode;- ele:sds结构,存放数据
- score:浮点型的分数
- backward:指向前一个节点,支持表尾向表头遍历ZREVRANGE命令
- level[] : 一个数组,说明有多少层
- forward:指向当前层的下一个节点
- span:距离下一个节点的步数
优化后的跳表平均时间复杂度为O(logN),最坏的情况是O(N)
ZSET#
ZSET(Sorted Set,有序集合) 是一个元素唯一、按分值(score)排序的数据结构,常用于排行榜、带权重的任务队列等场景。
常用操作#
写操作#
ZADD#
用法:
ZADD key [NX|XX] [GT|LT] [CH] [INCR] score member [score member …]
功能:向zset添加数据,如果key存在,则更新对应的数据
- 拓展内容
- XX:只更新已经存在的元素,不添加新元素
- NX:只添加新元素,不更新已有元素
- LT:新元素会添加;对于已存在元素,当新
score值小于当前score值时才会更新 - GT:新元素会添加;对于已存在元素,当新
score值大于当前score值时才会更新
ZREM#
用法:ZREM key member [member]
功能:删除key中的元素
ZCARD#
用法:ZCARD key
功能:查看key中成员数
ZRANGE#
用法:ZRANGE key min max[WITHSCORES]
功能:查询从start到stop范围的ZSet数据,WITHSCORES选填,不写输出里就只有key,没有score值
- 命令默认按照索引作为返回元素的范围,
min和max是索引范围,0为第一个元素,1为第二个元素,以此类推;在这些元素中按照score由低到高排序后返回。由于Redis默认就是由低到高排序,所以该命令就是返回第min到第max的元素 - 范围首尾都是闭区间,即[min,max];
- 索引值也可以是负数,-1表示最后一个元素,-2表示倒数第二个元素,以此类推;
- 给定索引超出列表范围不会报错:
- 如果min大于列表最大索引或者大于max,返回空集合
- 如果max大于列表最大索引,Redis则令 max=最大索引
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的分数
底层编码#
- ZIPLIST:
- 列表中的字符串对象的长度都小于64字符
- 列表中的对象少于128个
- SKIPLIST+HT:SKIPLIST+HASHTABLE搭配使用,HASHTABLE存储menber值对应的score值,例如命令zscor key member 就能使用HASHTABLE快速得到score值;SKIPLIST是有序的排列,所以能够快速的按照score值进行查找,插入和删除这些操作,然后两个搭配起来使用,先通过HASHTABLE得到score值,然后在SKIPLIST中直接查找score区间的元素,所以实现了更快速的查找
过期对象的处理#
如何设置过期时间#
- SET key value EX seconds: 设置多少秒之后过期
- SET key value PX milliseconds: 设置多少毫秒之后过期
- TTL key: 查看还有多久过期
更通用的过期命令是EXPIRE,它可以对所有数据对象设置过期时间,EXPIRE也分秒和毫秒:
- EXPIRE key seconds<设置一个key的过期时间>设置一个key的过期时间>,单位秒
- PEXPIRE key milliseconds<设置一个key的过期时间>设置一个key的过期时间>,单位毫秒
删除策略#
-
定时删除:在创建过期时间的同时,同时创建一个定时器,让那个定时器在键过期的时候执行对键的删除操作,对CPU不是很友好
- 定时删除并没有看起来那么简单,如果系统重启,对应的定时器随重启消失,那么重启后定时器需要重新构建
-
惰性删除:取出key的时候进行过期检查,但是可能造成很多的key没有被删除,占用内存,但是对CPU很友好
-
定期删除:每隔一段时间抽出一部分key执行删除过期的操作
- 定时删除频率 1s 10次:取决于Redis周期任务执行频率,周期任务中包含删除过期的key的任务,可以用INFO 查看频率,hz:10表示1s十次触发周期任务
Terminal window 测试服务器:db0> info# Server...hz:10...- 每次检查20个key,如果这20个中有大于25%的key都过期了,那么再抽出20个来检查。重复下去。为了防止循环过度,每次定期删除循环的时间上限默认是25ms
Redis如何存储数据#
Redis 的数据库结构:
typedef struct redisDb { dict *dict; // 主字典:key → value dict *expires; // 过期字典:key → 过期时间戳(毫秒) // ...} redisDb;dict:存储所有 key-value 数据;expires:只有设置了过期时间的 key 才会出现在expires中,value 是 以毫秒为单位的 Unix 时间戳(long long)。- 为了节省内存,
expires字典中的 key 复用dict中的 key 对象指针(不是拷贝);所以**DEL命令会同步、原子地同时删除主字典(dict)和过期字典(expires)中的 key**
Redis单线程#
Redis为何选择单线程?#
- redis本身单线程也是十分快的
- redis的数据结构是做过很大的优化的,单线程情况下效率很高,如果多线程的话要考虑线程安全的问题,会比较复杂
- 多线程会带来很多额外的成本
- 上下文切换成本,多线程调度需要切换上下文,这个操作需要先存储当前线程的本地数据,程序指针,然后再载入另一个线程
- 多线程同步机制的开销
- 线程占用内存
单线程为什么这么快#
- redis是基于内存存储,没有磁盘的开销
- redis优化了很多的数据结构,例如跳表、压缩列表
- redis采用了 I/O多路复用机制,使其在网络IO中能处理大量的客户端的请求,实现高吞吐
I/O多路复用#
单线程下,客户端发送一个请求服务端的处理过程如下:
- 客户端请求到来的时候,通过accept进行连接
- 调用recv从套接字中读取请求内容
- 解析请求,拿到参数
- 处理请求
- 通过send把数据发送到客户端

套接字默认阻塞模式,要么是没法连接阻塞在了accept,要么是没有接收到客户端的消息,阻塞在了recv。
单线程模式下,如果发生阻塞就会导致整个服务都被卡住,所以redis 设置了套接字非阻塞模式,在非阻塞模式下,虽然不会出现阻塞,但是也需要回过头来看看那些操作是否准备就绪,各个操作系统实现了一种叫做I/O多路复用的机制
I/O多路复用的机制
有I/O操作触发,就会产生通知,收到通知,再去处理通知对应的事件。redis针对I/O多路复用做了一层封装,叫做Reactor模型:本质就是监听各种时间,当事件发生,将事件分发到不同的处理器中,这样就不会阻塞在某一个操作,注意这里是并发,不是并行。

Redis 6.0引用多线程(了解)#
多线程默认关闭,可以在redis.conf里面修改
随着互联网发展、业务体量可能达到原来想象不到的问题,此时Redis处理流程中的I/O操作,就成了瓶颈。
为了尽可能保持Redis代码的极致简洁、也为了兼容之前的版本,Redis选择了仅在I/0操作引入多线程,具体来说就是读请求包,和发返回包的时候使用了多线程
优化之后,在高并发场景,性能提高了一倍左右。即使只在关键的小范围引入了多线程,但是依然引入了更多的复杂度,可想而知,要是全部处理流程都变为多线程,那将真的是翻天地覆的改变。
Redis内存淘汰机制#
我们知道,redis是基于内存存储的,那redis的内存有没有限制呢?答案是肯定的,我们在redis的配置文件中可以看到,有一个 maxmemory配置是默认注释掉的,在32位操作系统中,redis限制最大内存默认是3G,64位操作系统不做限制。我们可以主动配置maxmemory,maxmemory支持各单位:
- maxmemory1024(默认字节)
- maxmemory 1024KB
- maxmemory 1024MB
- maxmemory 1204GB
// 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种:
- volatile-lru ->在设置过期时间的keys中,使用LRU算法进行淘汰
- allkeys-lru -> 在所有的keys中,使用LRU算法进行淘汰
- volatile-lfu -> 在设置过期时间的keys中,使用LFU算法进行淘汰
- allkeys-lfu -> 在所有的keys中,使用LFU算法进行淘汰
- volatile-random -> 在设置过期时间的keys中,使用随机算法进行淘汰
- allkeys-random -> 在所有的keys中,使用随机算法进行淘汰
- volatile-ttl -> 在设置过期时间的keys中,选择最接近过期时间的keys进行删除
- noeviction -> 不移除任何键,在写操作时返回错误
淘汰算法的选择和淘汰时机#
淘汰算法的选择
- 如果我们的数据十分重要,不能被删除的时候,我们就选择不移除任何keys
- 如果是缓存业务的话,常用的就是LRU和LFU算法
淘汰的时机
- 如果我们设置了maxmemory,在内存达到maxmemory 后,每次写操作都会触发 freeMemoryIfNeeded释放内存,释放的策略按照我们的配置来释放
淘汰算法#
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算法就是使用了随机采样的方式来淘汰元素,内存不足的时候就执行一次。具体步骤如下:
- 随机采样n个key,默认是 5 个
- 根据时间戳来淘汰最旧的key(也就是 lru字段最小的那个key)
- 淘汰后内存依旧不足继续随机采样淘汰
淘汰池优化#
通过近似LRU的介绍我们也能看出来一些问题:随机采样的数据并不是真正的全局最旧key,数据量越大的情况下,这种采样就会越不准确,那该如何解决呢?
redis维护了一个大小为16的淘汰池,池中的数据根据访问的时间进行排序,第一次过程如下:
-
第一次随即选举的 5 个key,淘汰掉最旧的那个,其他四个放入淘汰池,然后根据访问时间排序
-
淘汰后内存不足继续随机采样淘汰
- 如果淘汰池没有满,淘汰后剩下的 4 个key不管时间大小,直接放入淘汰池中
- 如果淘汰池满了,随机选举的5个key,与淘汰池中的key进行比较。把满足大于池子中的最小空闲时间的key放入池中,然后淘汰掉池中最大空闲时间的key。

内存淘汰算法 - LFU#
前面我们讲过 LRU 是根据key的使用时间来进行淘汰的,这里我们要讲
LFU(Least Frequently Used)根据key的使用频率来进行淘汰
LRU字段复用#
我们知道LRU是通过redisObject中的lru字段来记录访问时间的,这个字段的长度固定为26位,LFU通过复用这个字段,把lru的前16位和后8 位拆分,前16位存储访问时间,这个时间是以分钟为单位,后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) { //如果使用的是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;- 计算次数衰减:根据这次和上次的访问间隔来计算应该减少的次数,如果间隔比较大说明访问的频率低,那么这个次数就应该衰减
- 一定概率增加访问的次数:我们知道 LFU_INIT_VAL 默认是5,
- 如果小于5,肯定会增加这个次数
- 如果大于5 小于 255(因为2进制的8个1 是255) 会一定概率加1,原来的次数越大增加的概率就越低除了原来的次数影响之外,还有一个
lfu-log-factor参数可以设置这个增加的概率,如果参数为0,那么每次必加1,很快就能到最大值
- 更新lru字段的值
Redis持久化#
什么是持久化#
redis是基于内存存储的,如果系统重启或者崩溃,那么数据就会丢失,如果业务场景需要崩溃后数据还在,就需要持久化,即:数据保存到永久可以保存的存储设备中
持久化的方式#
Redis提供了两种持久化的方式:
- RDB:记录redis某个时刻的全部数据,这种方式就是快照的方式,直接保存二进制数据到磁盘,后续通过加载RDB来恢复数据

- AOF:记录redis执行了什么命令,重启之后通过重放命令来恢复数据。AOF的本质是记录操作的日志

RDB 和 AOF 如何选择#
根据业务不同选择不同的方式:
-
如果只是用作缓存,并且数据并不是一个海量访问,那么就不用开启持久化
-
如果对数据很重视,可以同时开启RDB和AOF,Redis的RDB是默认开启的
-
如果可以接受丢几分钟级别的数据,可以选择只开RDB,为什么说会丢几分钟?因为RDB做全量快照需要几分钟做一次,如果1s一次,那么上次的全量快照还没有做完,又来了一个,会导致主线程阻塞

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 1save 300 100save 60 10000- 在指定时间内,如果发生了指定数量以上的写操作(如 SET、HSET、DEL 等),就触发一次 RDB 快照保存
- 这三条命令,只要满足一个就会触发 bgsave
- 这三条命令是默认开启的
RDB 文件存在哪儿#
下面的两个参数决定了RDB文件的位置
# The filename where to dump the DBdbfilename dump.rdb
dir ./什么时候持久化?#
-
执行save命令:这个命令会占用主线程,如果save时间过长,会阻塞主线程
-
执行bgsave:这个是后台save,会创建一个子线程,不会阻塞主线程
-
达到配置文件的要求
-
程序正常关闭的时候执行一个save,会阻塞主线程
RDB做了什么#
整体上分如下几个步骤:
- 后台创建一个子线程,专门做RDB持久化
- 子线程写数据到RDB文件
- 写完之后,新的RDB文件替换旧的RDB文件
如果RDB进行全量快照的时候,主线程修改数据怎么办呢?
执行快照的时候修改数据(写时复制COW)#
bgsava 过程中,Redis 依然可以继续处理操作命令的,用到的就是*写时复制技术*。
- fork() 子进程时,父进程和子进程共享同一物理内存页;
- 只要不修改数据,就不复制内存;
- 当主进程要修改某内存页时,操作系统才复制该页(COW),子进程继续读旧页;
- → 子进程始终看到 fork 时刻的内存快照,主进程可继续处理请求。
极端情况:如果所有的共享内存都被修改,则此时的内存占用是原先的 2 倍
AOF详解#
如何开启AOF#
我们知道redis 默认是开启RDB的,但是如何开启AOF呢?
//配置文件开启AOF 设置为 yes即可打开appendonly no
# The name of the append only file (default: "appendonly.aof")
appendfilename "appendonly.aof"AOF 三种写回策略#
- redis执行完写操作之后,把命令追加到一个**
server.aof_buf** 缓冲区 - 通过write系统调用,把缓冲区数据写入AOF文件,但是没有写入硬盘而是拷贝到内核缓冲区,等待内核把数据写入磁盘。
- 内核缓冲区的数据什么时候写入磁盘由内核决定
redis提供了三种刷盘策略:
- Always:每次写操作执行完之后,都把AOF日志数据写回硬盘
- Everysec:执行完写之后,每隔一秒写回磁盘
- No : 由操作系统决定啥时候写回磁盘。
Redis推荐方案二,每秒刷一次盘,这种方式下速度足够快,同时崩溃时损失的数据只有1s,这在大多数场景都是可以接受的。
AOF重写#
避免AOF文件过大,所以提供了重写机制。重写的时候是读取最新键值对的写入命令,然后只记录最新的一条。实现了压缩。不复用原来的AOF文件是为了防止修改的过程宕机,导致文件被污染无法恢复使用。使用一个新的即使出现问题删了就行,影响不大。
- AOF重写是在后台子进程完成的
- 重写的时候不会阻塞主进程
AOF恢复数据很慢,因为Redis是单线程执行的,AOF是顺序执行日志中的命令,如果文件过大就会导致过程很慢。
重写条件:同时满足以下两个条件重写
//相比上次重写,数据增长了100 %auto-aof-rewrite-percentage 100//超过大小auto-aof-rewrite-min-size 64mbRDB和AOF混合持久化#
怎么开启?
//redis 5.0 之后默认打开aof-use-rdb-preamble yes混合持久化发生在AOF重写期间
- AOF重写的时候,将redis当前状态的全量快照 bgsave保存到 AOF文件前半段
- 在bgsave过程中,主线程会出现一些操作命令,这些操作命令被记录在重写缓冲区中,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF文件后半段
这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快。

场景应用#
缓存异常场景#
缓存穿透#
- 定义:查询根本不存在的数据(如恶意攻击或参数错误),导致请求每次都绕过缓存,直接打到数据库。
- 特点:
- key 不在缓存,也不在数据库;
- 每次请求都穿透到 DB;
- 可能被恶意利用(如遍历 ID 攻击)。
解决方案:#
- 布隆过滤器(Bloom Filter):快速判断 key 是否可能存在于 DB;
- 缓存空值(null):对查不到的数据也缓存一个空结果(带短 TTL,如 1~5 分钟);
- 参数校验:提前拦截非法请求。
布隆过滤器#
用于 快速判断“一个元素是否可能存在于集合中”。
大致工作原理:
- 初始化:
- 创建一个 位数组(bit array),长度为
m,初始全为0; - 选择
k个不同的哈希函数
- 创建一个 位数组(bit array),长度为
- 添加元素(如
"apple"):- 用
k个哈希函数计算出k个位置 - 将位数组中这
k个位置设为1。
- 用
- 查询元素(如
"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 → 可能存在(但可能误判)⚠️缓存击穿#
- 定义:某个热点 key 过期的瞬间,大量并发请求同时发现缓存失效,全部打到数据库。
- 特点:
- key 原本存在且很热;
- 刚好在过期那一刻高并发访问;
- 影响单个 key,但压力集中。
- 解决方案:
- 互斥锁(Mutex Lock):只让一个线程去 DB 加载,其他等待;
- 逻辑过期(Logical Expire):不设物理过期时间,后台异步更新;
- 永不过期 + 主动刷新:适用于可预测的热点数据。
缓存雪崩#
- 定义:大量 key 同时过期,或缓存服务整体宕机,导致大量请求瞬间压向数据库。
- 特点:
- 大面积缓存失效(非单个 key);
- 可能由“相同过期时间”或“Redis 宕机”引发;
- 数据库可能被打垮。
- 解决方案:
- 过期时间加随机值:如
TTL = 基础时间 + random(0, 300); - 高可用架构:Redis 集群、主从、哨兵;
- 熔断/限流:保护数据库;
- 多级缓存:本地缓存(如 Caffeine)兜底。
- 过期时间加随机值:如
缓存一致性怎么保证#
什么是缓存一致性
Redis作为MySQL的缓存,如果 MySQL 更新了,Redis的数据该怎么保持最终一致呢?
过期时间兜底#
只更新MySQL,Redis等缓存过期失效自动让他同步,由过期时间兜底。这种实现方式较为简单,但是不一致会比较明显。如果请求比较频繁并且过期时间设的比较长,那么就会产生很多脏数据。
优点:开发成本低,易于实现
缺点:完全依赖过期时间,时间短容易造成缓存经常失效,太长又会产生很多不一致的数据,不适合对一致性要求较高的场景
直接删除key#
在更新MySQL的时候同时删除key,一般不用更新,因为更新会导致时序性的问题,例如:
假设MySQL中有个数 age = 18,接下来有两个服务器要对age进行修改:
- A 服务器 将 age 的值加 1,变成 19。同时更新 redis 缓存数据
- B 服务器 将 age 的值设置为 30。 同时更新Redis 缓存数据
最终mysql 中 age = 30
但是由于redis更新存在时序性的问题,AB 服务器的命令一定谁先执行。这就有可能发生:
- Redis 先执行了 B 服务器发出的修改请求,age 设置为 30
- 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主节点,基本保证它们不会同时宕掉,获取锁和释放锁的过程中,客户端会执行以下操作:
- 向 5 个 Redis 申请加锁;
- 只要超过一半,也就是 3 个 Redis 返回成功,那么就是获取到了锁。如果超过一半失败。需要向每个Redis发送解锁命令;
- 由于向5个Redis发送请求,会有一定时耗。所以锁剩余持有时间,需要减去请求时间。这个可以作为判断依据,如果剩余时间已经为0,那么也是获取锁失败;
- 使用完成之后,向5个Redis发送解锁请求。
Redis 生产订阅模式#
PUB/SUB#
当订阅者订阅某个频道,如果生产者将消息发送到这个频道,订阅者就能收到该消息,这种模式支持多个消费者订阅相同的频道,互不干扰。
- Subscribe channel
- publish channel message
Redis在秒杀中的作用#
秒杀系统存在如下几个问题:
- 高并发:秒杀的时候如何让服务器能抗住
- 超卖少卖:都会导致商家损失
- 黄牛脚本:侵犯用户的权益的同时,脚本大量的请求也会增大服务器压力
高并发解决#
我们可以把库中的商品数量加载到redis,然后在redis中进行扣减,扣减成功的,再通过消息队列传递到MySQL,做到真正的订单完成
如果请求超过 6w/s 我们就要考虑使用多个redis进行分流
超卖少卖的解决#
超卖#
卖操作的核心总共有两步:
- 查看库存是否足够
- 从库存中减少售卖的数量
这样有个问题就是,第一步判断的时候库存是够的,但是实际调用的时候,库存可能已经没有了,这就导致了超卖。因为这两个操作并不是原子性的,我们要想保证这个操作是原子性的,所以使用redis+lua脚本来实现
少卖#
库存减少,但是用户订单没生成这就会导致少卖
什么情况会这样呢:
- 减少库存操作超时,但实际是成功的,因为超时并不会进入生成订单流程;
- 在Redis操作成功,但是向Kafka发送消息失败,这种情况也会白白消耗Redis中的库存。
- 解决办法:
- 如果redis投递kafka消息失败,那么就渐进式重试或者固定重试
- 也可以把投递失败的消息记录在磁盘,慢慢重试,投递成功后就从磁盘删除
黄牛#
为了打击黄牛,最常见的方式是限购,一个用户最多只能抢到N份
为了性能,我们还是将限制逻辑加入到Redis中。我们的Lua脚本中原来的逻辑是:第一步查询库存;第二步扣减库存;需要优化为:第一步查询库存;第二步查询用户已购买个数;第三步扣减库存;第四步记录用户购买数。
或者我们可以加验证码限制
限流器#
限流就是流量限制,可以用来管控请求的速度。
为什么需要管控请求速度呢?来看以下一个场景:
如果一个网站每秒能处理的请求就几千量级。这时候网站突然涌进来一波流量,这波流量以每秒10w的频率访问业务,然后直接打到数据库,那么数据库可能直接挂掉。所以不管是什么后台服务,每秒能处理的请求个数总有极限。如果超过这个极限,轻则让服务变得缓慢,重则直接干跨业务。因此我们需要使用一些手段限制请求的发送频率。
计数器算法#
非常简单的一个算法,具体的实现就是记录某个时间段的请求数,如果超过了就拒绝,在下一个时间段的时候把这个请求数重置,重新计算。
缺点很明显:虽然t1 ~ t2、t2 ~ t3这两个时间段能保证流量在可控范围,但是如果出现请求突刺,也就是t2时间段前后流量激增,这就超过了流量限制。解决的办法就是滑动窗口算法。

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

漏桶算法#
漏桶滴水的速度恒定(服务器处理请求的速度)桶的大小固定(限流)
缺点:我们无法精确判断网络带宽或处理能力。
一般来说都只能设置一个相对比较小的流量目标,比如800/s。如果产生了一波突发流量,经过了漏桶算法后,依然会以恒定的目标码率慢慢地发送。就算我们的服务有足够的实力,也不能让它快速处理了。
有两种方式解决:
- 动态调节水滴速度(就像输液瓶一样)<通过对后端进行不断试探>通过对后端进行不断试探>,尽可能始
终维持在性能处理的极致,这种方式的弊端在于带来了更多多的复杂性和耦合
性,属于特定优化了。
- 用另一种桶,令牌桶。
令牌桶#
在实际生产中,令牌桶因为其均匀性及突发流量容忍性,更受受青睐。腾讯云团队,阿里线上管控体系,Shopee金融团队都使用了令牌桶来做限流。
令牌桶算法:我们有一个桶,桶里均匀生成令牌,请求来的时候需要先拿到令牌,然后再做后续的处理。桶中的令牌满了就会不再生成了。例如桶中最多1000个令牌,系统每1ms生成一个,1s就可以塞满桶,这样1s就可以处理1000个请求。

多机部署#
哨兵模式#
哨兵说白了就是redis用来监测redis 服务的一个程序,本质上也是一个redis进程
当哨兵集群选举出哨兵Leader后,由哨兵Leader从Redis从节点点中选择一个Redis节点作为主节点,选举的策略:
- 先过滤故障的节点
- 选择优先级最大的从节点为主节点,如果不存在则继续(slave-priority)
- 选择复制偏移量最大的节点作为主节点,如果都一样就继续。
- 选择uuid最小的那个节点为主节点。redis每次启动都会生成一个随机uuid

前面说过了哨兵也会选举出Leader,那么这个leader 是怎么选出来的呢?
每一个哨兵节点都可以成为Leader,当哨兵节点确认Redis集群的主节点下线后,会请求其他哨兵节点要求将自己选举为Leader。
- 被请求的哨兵节点如果没有同意过其他哨兵节点的选举请求,则同意该请求,也就是选举票数+1。否则不同意
- 如果一个哨兵节点获得的选举票数超过节点数的一半,且大于quorum配置的值,则该哨兵节点选举为Leader。否则重新进行选举。
主从数据同步#
Redis 主从节点的数据同步分为 全量同步(Full Resynchronization) 和 增量同步(Partial Resynchronization) 两种方式。由 PSYNC 命令(Redis 2.8+)统一管理
全量同步(首次同步或断连太久)#
同步流程:#
- 从节点发送
PSYNC ? -1- 表示“我不知道主节点 runid(身份id) 和 offset(复制偏移量),请求全量同步”。
- 主节点响应
FULLRESYNC <runid> <offset>- 返回自己的
runid和当前复制偏移量offset。
- 返回自己的
- 主节点执行
BGSAVE,生成 RDB 快照- 同时开启 复制缓冲区(replication buffer),缓存 RDB 生成期间的新写命令。
- 主节点将 RDB 文件发送给从节点
- 从节点清空自身数据,加载 RDB。
- 主节点发送复制缓冲区中的增量命令
- 保证从节点状态与主节点完全一致。
- 后续进入增量同步阶段。
⚠️ 全量同步会 阻塞主节点网络 I/O(但不阻塞命令处理),且消耗大量带宽和内存。
增量同步(断连后快速恢复)#
触发条件:#
- 从节点短暂断开,重新连接;
- 主节点的 复制积压缓冲区(replication backlog) 中仍包含从节点所需的命令。
关键机制:复制偏移量(replication offset) + 复制积压缓冲区
- 主从各自维护一个 复制偏移量(offset),表示已同步的命令字节数;
- 主节点维护一个 固定大小的环形缓冲区(replication backlog),默认 1MB,记录最近的写命令;
- 从节点重连时发送
PSYNC <master_runid> <offset>; - 主节点检查:
runid是否匹配;offset是否仍在 backlog 范围内;- 若是 → 发送
CONTINUE,并补发缺失命令; - 若否 → 降级为全量同步。
📦 核心组件说明:
| 组件 | 作用 |
|---|---|
| runid | 主节点唯一 ID,用于识别是否同一主节点 |
| replication offset | 主从各自记录的同步进度(字节偏移) |
| replication backlog | 主节点的环形缓冲区,保存最近写命令(用于增量同步) |
| replication buffer | 全量同步期间临时缓存新命令(每个从节点独立) |
💡
repl-backlog-size可配置 backlog 大小(如 100MB),避免频繁全量同步。🎯 Redis 通过 PSYNC + 复制积压缓冲区,在保证一致性的同时,大幅减少全量同步次数,提升主从复制效率。
Redis集群(Cluster)#
Redis 集群是 Redis 官方提供的分布式解决方案
哨兵模式基于主从模式,实现了读写分离,并且还可以自动切换,但是每个节点存的数据都一样,浪费内存,不好在线扩容
哈希槽#
Redis使用 **16384 个哈希槽(hash slots)**将数据进行分片。每个 key 通过 CRC16(key) % 16384 决定归属哪个槽,这个槽可以理解为只是个分片的依据,并不是真正的存储。每个节点负责一部分槽,key算出来在哪个槽,就应该去跟负责这个槽的节点来交互。
Redis中hash槽的结构
// from redis 5.0.5#define CLUSTER_SLOTS 16384typedef struct clusterNode {
unsigned char slots[CLUSTER_SLOTS/8];
int numslots; /* Number of slots handled by this node */} clusterNode;slots[]数组是一个 位图(bitmap),长度为 16384 / 8 = 2048 字节,他的每一位(bit)对应一个哈希槽(slot)

这种位图的方式,使得每个节点可以在O(1)时间复杂度下查询负责的槽。
集群模式下如何访问key#
当客户端请求访问一个键时,Redis 会计算该键的哈希槽编号,并将请求转发到负责该哈希槽的节点。如果客户端直接连接到错误的节点,该节点会返回 MOVED 响应,MOVED 重定向响应会将哈希槽所在的新的实例IP和端口号port返回,告知客户端应将请求重定向到正确的节点。具体流程如下:
-
计算 key 的
slot -
检查
slot是不是由本节点负责 -
slot不归本节点负责,返回MOVED重定向 -
如果是本节点负责,且 key 就在
slot中,返回 key对应结果 -
如果 key 不在
slot中,检查本节点对slot的状态- 如果本节点
slot处于迁移MIGRATING[ˈmaɪɡreɪtɪŋ]状态,返回 ASK - 若本节点
slot处于导入 IMPORTING 状态。说明当前节点即将接管此slot,但是要判断访问数据的时候有没有“通行证ASKING”。- 如果客户端在访问key之前使用了
ASKING,则返回 key - 如果没有
ASKING则返回MOVED重定向
- 如果客户端在访问key之前使用了
- 如果本节点
Gossip 协议#
gossip [ˈɡɒsɪp] 闲话、流言蜚语
Redis Cluster 是去中心化的,没有中心节点,所有节点地位平等。它依靠 Gossip 协议同步gge各节点的信息。
每个节点周期性的从节点列表中选择 K 个节点,然后将自己节点存储的信息传播出去,直到所有节点信息都是一致的
gossip协议包含很多消息类型:
- meet消息:通知新的节点加入。
- ping消息:节点每秒会向集群中发送ping消息。消息中有已知两个节点的地址、槽、状态等信息
- pong消息:当收到ping meet 消息是,响应方回复发送方的 消息,响应的消息同样带已知两个节点的信息
- fail消息:当节点判断有个节点下线之后,会向集群广播fail消息,其他节点收到消息后,把对应节点状态改为下线状态
集群中主节点宕机且有从节点的情况#
Redis Cluster 使用 Gossip 协议 + 多数派投票 实现去中心化故障检测
主观下线(PFAIL):
-
当节点 A 发现另一个主节点 B 没有响应(ping超时)
-
A 将 B 标记为 PFAIL (“我觉得它可能挂了”)
-
A 后续通过 gossip协议告诉其他节点,“同志们B可能挂了”
客观下线(FAIL):
- 其他节点收到 A 发送的信息后,回去检查 B 的状态
- 当超过半数的主节点都觉得B是 PFAIL
- 某个节点会广播 FAIL 消息
- 所有节点把 B 的状态标记为 FAIL (“B永远离开了我们”)
故障转移:
- B 节点的从节点 C 会发起竞选,竞选成功后向其他主节点发送信息,并请求投票
- 其他节点收到请求后,觉得时机合适(未迁移数据)就投赞成票
- 当超过半数节点投票通过,该从节点 C 赢得选举
- 节点 C 将自己的角色改为 master,接管原来挂掉的 B 节点的所有 slot
- 全服广播,宣告自己新身份
什么是一致性哈希#
分布式系统中用于 数据分片和负载均衡 的算法,核心目标是:在节点增减时,尽量减少数据的迁移量,同时保证负载相对均衡。
一致性哈希被广泛用在:分布式缓存、分布式存储、负载均衡器中
传统哈希的问题#
如果我们有 N 台服务器,key的位置计算是通过 hash(key) % N。这样其实会导致一个问题。假设我们把节点从三个扩容到四个的时候,几乎所有的数据都要重新映射:
- 原来 3 台机器:
index = hash(key) % 3 - 新增 1 台变成 4 台:
index = hash(key) % 4
几乎有 75% 以上的 key都要重新映射。这在高可用系统中是无法接受的。
一致性哈希的实现原理#
一致性哈希通过将哈希空间组织成一个环,即映射 服务器节点,也映射 数据 key
也就是说,环里面有两个映射:服务器节点 和 数据key。如下图:

图中Data映射到环中位置,顺时针方向找到第一个服务器节点,上图中Data就顺时针找到第一个服务器节点(Server C)
节点变动影响小原理#
当我们新增节点的时候,只会影响新增节点和他下一个节点的数据。还是通过图解来理解。

- 原本节点C中的数据是 B~C 之间映射的所有数据。
- 当我们新增 D 节点之后。B ~ D 之间的数据分到了 D 节点。C 节点的数据 变成了 D ~ C
- 删除也是同理。
数据倾斜#
问题描述:物理节点少 → 数据分布不均(有的节点负载高,有的低),如下图所示:

解决办法:每个物理节点对应多个虚拟节点
-
虚拟节点均匀分布在哈希环上;
-
数据先映射到虚拟节点,再指向物理节点。

为什么 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 哈希槽能精准控制迁移的数量,一致性哈希做不到