你可以把 Redis 想象成一个超级智能的快递柜:
它比普通柜子快得多,因为所有东西都存在内存里,不用去硬盘翻。
它有 5 种不同设计的 “格子”,每种格子适合放不同的东西,这就是我们要讲的 5 种高频数据类型。
下面我们从 0 开始,用最通俗的话讲清楚,同时深入到 Redis 源码,让你既能听懂,又能搞懂底层。
一、String:最普通的 “可伸缩便利贴”
1. 通俗类比
String 就是快递柜里最普通的格子,一个格子放一样东西 —— 可以是一张纸条、一个数字,甚至是一张小照片。
你可以把它想象成可伸缩的便利贴:
普通的纸条写满了就不能加字了,要换一张新的。
这个便利贴不一样,它左上角写了 “我已经写了多少字”、“还剩多少空地方”,加字的时候如果有空地方直接写,没有的话自动换个更大的便利贴,还会多留一半空地方,下次加字就不用再换了。
2. 基本用法(代码样例)
# 1. 存东西:SET 格子名 内容
SET user:1:name "张三"
SET user:1:age 25
# 2. 取东西:GET 格子名
GET user:1:name # 拿到 "张三"
# 3. 给数字加1(原子操作,不会错)
INCR user:1:age # 年龄变成 26
INCRBY user:1:age 5 # 一次性加5,变成31
# 4. 批量存/取,减少跑腿
MSET user:2:name "李四" user:2:age 28
MGET user:1:name user:2:name # 一次拿到两个人的名字3. 底层原理:SDS 源码解析
Redis 没有用 C 语言自带的字符串(char*),因为那个太笨了:
要知道字符串长度,得从头到尾数一遍,慢。
加字的时候很容易溢出,把旁边的东西覆盖了。
每次加字都要重新买新的便利贴,浪费时间。
所以 Redis 自己做了个 SDS(简单动态字符串),源码在 src/sds.h:
// SDS 本质是一个 char*,但它的"左上角信息"藏在内容的前面
typedef char *sds;
// 5种不同大小的便利贴,根据内容长度选
// 1. 小便利贴:内容<256字节,用1字节记长度
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; // 已经写了多少字
uint8_t alloc; // 这个便利贴总共多大
unsigned char flags; // 标记这是哪种便利贴
char buf[]; // 实际写内容的地方
};
// 2. 中便利贴:内容<65536字节,用2字节记长度
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len;
uint16_t alloc;
unsigned char flags;
char buf[];
};
// 还有更大的 sdshdr32、sdshdr64,用来存更长的内容核心设计 1:空间预分配(为什么加字这么快?)
你要给便利贴加字的时候,Redis 不是只加刚好够的地方,而是多留一倍的空:
// 源码:src/sds.c
sds sdsMakeRoomFor(sds s, size_t addlen) {
// ... 省略无关代码
newlen = len + addlen; // 我需要的新长度
// 空间预分配策略:
if (newlen < 1024*1024)
newlen *= 2; // 小于1MB的,多留一倍空
else
newlen += 1024*1024; // 大于1MB的,最多留1MB空
// ... 重新分配内存
}比如你原来写了 10 个字,加 5 个字,Redis 直接给你买个能写 30 个字的便利贴,下次再加 10 个字,就不用再换了,省了好多时间。
核心设计 2:惰性释放(为什么删字这么快?)
你把便利贴后面的字划掉了,Redis 不会立刻把剩下的纸扔了,而是留着下次用:
// 源码:src/sds.c
sds sdstrim(sds s, const char *cset) {
// ... 找到新的长度
s[len] = '\0';
sdssetlen(s, len); // 只改了"已经写了多少字",便利贴大小不变
return s;
}比如你原来的便利贴能写 30 个字,删到只剩 10 个,纸还是那个纸,下次写的时候直接用剩下的 20 个空地方,不用再买新的。
4. 最佳实践
二、Hash:带小抽屉的 “收纳盒”
1. 通俗类比
Hash 就是一个大的收纳盒,里面有很多带名字的小抽屉 —— 比如你要存一个用户的信息,大盒子叫 user:1,里面有 name 抽屉放 “张三”,age 抽屉放 25,email 抽屉放邮箱。
你不用把整个盒子都拿出来改,想改年龄直接拉开 age 抽屉改就行,特别方便。
2. 基本用法(代码样例)
# 1. 给小抽屉放东西:HSET 大盒子名 抽屉名 内容
HSET user:1 name "张三" age 25 email "zhangsan@example.com"
# 2. 拿单个抽屉的东西:HGET
HGET user:1 name # 拿到 "张三"
# 3. 拿所有抽屉的东西:HGETALL
HGETALL user:1 # 拿到所有抽屉的名字和内容
# 4. 给抽屉里的数字加1:HINCRBY
HINCRBY user:1 age 1 # 年龄变成263. 底层原理:Ziplist + Hashtable 源码解析
Hash 有两种存储方式,Redis 会自动切换:
小盒子的时候,用压缩列表(Ziplist),把所有抽屉挤在一张小纸条上,省地方。
盒子大了,用哈希表(Hashtable),做个目录,找哪个抽屉直接看目录,不用翻纸条。
小盒子的省空间神器:Ziplist
当抽屉很少(<512 个),每个抽屉的内容也很小(<64 字节),Redis 就用 Ziplist,源码在 src/ziplist.h: 它把所有抽屉的内容都挤在一张连续的小纸条上,没有多余的指针,特别省内存:
// 纸条的结构:
// [总大小] [最后一个抽屉的位置] [抽屉数量] [抽屉1] [抽屉2] ... [结束标记]
// 每个抽屉的结构:
// [前一个抽屉的大小] [编码] [内容]比如存 name:张三、age:25,这张纸条就把它们紧凑地排在一起,比单独存两个 String 省一半内存。
大盒子的快速查找:Hashtable + 渐进式 Rehash
当抽屉多了,纸条翻起来慢了,Redis 就转成 Hashtable,也就是我们常说的字典,源码在 src/dict.h:
// 字典结构
typedef struct dict {
dictht ht[2]; // 两个目录!一个旧的,一个新的
long rehashidx; // 搬家进度,-1就是没在搬家
// ... 其他
} dict;
// 哈希表结构
typedef struct dictht {
dictEntry **table; // 目录数组
unsigned long size; // 目录大小
unsigned long used; // 已经放了多少抽屉
} dictht;你有没有遇到过目录满了,要做个更大的目录? 普通的做法是:把所有抽屉都搬完,再用新目录,这时候你要等好久,不能取快递。 Redis 不一样,它用渐进式 Rehash,源码在 src/dict.c:
// 每次你操作一个抽屉,我就搬一个旧目录的抽屉到新目录
int dictRehash(dict *d, int n) {
while (n-- && 旧目录还有东西) {
// 搬一个抽屉
// ...
d->rehashidx++; // 搬家进度+1
}
// 搬完了,旧目录扔了,新目录变成旧的
}比如你有 1000 个抽屉,我不是一下子搬完,而是你每次来存 / 取一个抽屉,我就顺便搬一个,这样你完全感觉不到卡顿,不知不觉就搬完了。
4. 最佳实践
三、List:分组排队的 “队伍”
1. 通俗类比
List 就是一个排队的队伍,你可以在队伍的头加人,也可以在尾巴加人,也可以把队头的人拉走,也可以把队尾的人拉走。
比如做消息队列,生产者在队尾加任务,消费者在队头拿任务,特别合适。
2. 基本用法(代码样例)
# 1. 加人:左边加(队头)用 LPUSH,右边加(队尾)用 RPUSH
LPUSH queue:task "task1" # 队头加任务1
RPUSH queue:task "task2" # 队尾加任务2,现在队伍是 [task1, task2]
# 2. 拉人:左边拉用 LPOP,右边拉用 RPOP
LPOP queue:task # 拉走队头的task1
RPOP queue:task # 拉走队尾的task2
# 3. 阻塞拉人:队里没人的时候,我等你10秒,有了就拿
BRPOP queue:task 103. 底层原理:Quicklist 源码解析
Redis 3.2 之前,List 要么用 linkedlist(每个人单独站着,占地方),要么用 ziplist(所有人挤成一排,动一下要挪所有人)。 后来 Redis 做了个 Quicklist,把两者结合起来:把 10 个人分成一组,组内挤成一排,组之间用绳子连起来,源码在 src/quicklist.h:
// 快速列表结构
typedef struct quicklist {
quicklistNode *head; // 第一组
quicklistNode *tail; // 最后一组
unsigned long count; // 总共有多少人
int fill : 16; // 每组最多多大(默认8KB)
unsigned int compress : 16; // 压缩深度,中间的组没人动,我就打包起来省地方
} quicklist;
// 组的结构
typedef struct quicklistNode {
quicklistNode *prev; // 前一组
quicklistNode *next; // 后一组
unsigned char *zl; // 组内的ziplist,把组里的人挤在一起
unsigned int count : 16; // 组里有多少人
unsigned int encoding : 2; // 有没有打包压缩
} quicklistNode;比如你要在队头加人,我就看第一组有没有空,有空直接加进去,没空就新建一个组,插在最前面,又快又省地方。
而且中间的组好久没人动了,Redis 会用 LZF 算法把它打包压缩,比如原来 8KB 的组,压缩完变成 2KB,又省了好多内存。
4. 最佳实践
四、Set:不重复的 “球袋”
1. 通俗类比
Set 就是一个装不同颜色球的袋子,没有顺序,而且每个颜色的球只有一个—— 你放进去一个红球,再放一个红球,袋子里还是只有一个红球。
而且它还能做集合运算:比如你有两个袋子,一个是 A 文章的标签,一个是 B 文章的标签,我能一下子找出两个文章都有的标签(交集),或者所有标签(并集)。
2. 基本用法(代码样例)
# 1. 放球:SADD,重复的自动忽略
SADD tag:1 "Java" "Redis" "面试"
SADD tag:1 "Java" # 再放Java,没用,袋子里还是只有一个
# 2. 看有没有这个球:SISMEMBER
SISMEMBER tag:1 "Redis" # 有,返回1
SISMEMBER tag:1 "MySQL" # 没有,返回0
# 3. 集合运算
SADD tag:2 "Java" "MySQL"
SINTER tag:1 tag:2 # 交集:两个标签都有的,返回["Java"]
SUNION tag:1 tag:2 # 并集:所有标签,返回["Java","Redis","面试","MySQL"]3. 底层原理:Intset + Hashtable 源码解析
Set 也有两种存储方式:
都是数字球,而且数量少(<512 个),用整数集合(Intset),把数字按顺序排好,写在小纸条上,省地方。
有非数字球,或者数量多了,用 Hashtable,做个目录,找有没有这个球直接看目录。
数字球的省空间神器:Intset
当所有球都是数字,Redis 就用 Intset,源码在 src/intset.h:
typedef struct intset {
uint32_t encoding; // 编码:用16位整数,还是32位,还是64位
uint32_t length; // 有多少个球
int8_t contents[]; // 按从小到大排好的数字
} intset;比如你原来的数字都是小的,比如 1、2、3,我用 16 位整数存,每个占 2 字节,省地方。 后来你加了个很大的数字,比如 100000,超过 16 位了,Redis 就自动编码升级:
// 源码:src/intset.c
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
// 1. 换个大的编码,比如从16位换成32位
// 2. 重新买个更大的纸条
// 3. 把原来的数字都抄到新纸条上,每个都转成新的编码
// 4. 把新数字加进去
}比如原来的 1、2、3,我都转成 32 位的,再把 100000 加进去,这样就都能存下了,而且升级之后就不能降级了,因为没必要,反正已经有大空间了。
4. 最佳实践
五、ZSet:带分数的 “排行榜”
1. 通俗类比
ZSet 是 Redis 最厉害的类型,它是 Set 和 List 的结合体:
像 Set 一样:元素不重复,每个玩家只能有一个。
像 List 一样:元素有序,但不是按插入顺序,而是按分数排序 —— 比如游戏排行榜,分数越高,排名越靠前。
你可以把它想象成游戏的排行榜,每个玩家是一个元素,分数是他的得分,你可以快速找前 10 名,也可以快速找某个玩家排第几。
2. 基本用法(代码样例)
# 1. 加玩家:ZADD 排行榜名 分数 玩家名
ZADD rank:game 100 "张三" 200 "李四" 150 "王五"
# 2. 看玩家排第几:ZRANK是升序(分数低的排前面),ZREVRANK是降序(分数高的排前面)
ZRANK rank:game "张三" # 张三分数最低,排第0(也就是第1名,从0开始数)
ZREVRANK rank:game "李四" # 李四分数最高,排第0
# 3. 拿前N名:ZREVRANGE 从高到低拿,WITHSCORES 把分数也拿出来
ZREVRANGE rank:game 0 1 WITHSCORES # 前2名:["李四",200,"王五",150]
# 4. 给玩家加分数:ZINCRBY
ZINCRBY rank:game 50 "张三" # 张三加50分,变成1503. 底层原理:Ziplist + 跳表 + 字典 源码解析
ZSet 也有两种存储方式:
玩家少的时候,用 Ziplist,把玩家和分数交替挤在小纸条上,省地方。
玩家多了,用跳表(SkipList)+ 字典(Dict),两个表一起用,一个管快速找分数,一个管快速找排名。
双重结构:为什么又快又省?
ZSet 同时维护了两个表,源码在 src/t_zset.c:
typedef struct zset {
dict *dict; // 字典:玩家名 -> 分数,O(1) 找你多少分
zskiplist *zsl; // 跳表:按分数排序,O(log n) 找排名、找前N名
} zset;你要查
ZSCORE,也就是 “张三多少分”,直接用字典,一秒查到。你要查
ZRANK,也就是 “张三排第几”,或者ZRANGE找前 10 名,用跳表,一秒查到。而且两个表的玩家名是同一个,不重复存,省内存。
跳表:为什么比平衡树还好用?
跳表就是给排行榜做了个多层目录,就像字典的目录一样:
普通的链表,你要找张三,要从第一个人开始一个个翻,慢。
跳表,给每个人加了几层目录,有的人只有 1 层,有的人有 2 层,有的人有 3 层,找的时候先从最高层的目录开始跳,不用一个个翻。
源码在 src/server.h:
// 跳表节点
typedef struct zskiplistNode {
sds ele; // 玩家名
double score; // 分数
struct zskiplistNode *backward; // 后退指针,从后往前翻
struct zskiplistLevel {
struct zskiplistNode *forward; // 前进指针,下一个节点
unsigned long span; // 跨度,两个节点之间隔了多少人,用来算排名
} level[]; // 层级数组,每个节点的层级是随机的
} zskiplistNode;那层级是怎么来的?随机的!
// 源码:src/t_zset.c
int zslRandomLevel(void) {
int level = 1;
// 每次有1/2的概率加一层,1/4的概率加两层,1/8的概率加三层...
while ((random() & 0xFFFF) < (0.25 * 0xFFFF))
level += 1;
return level < 32 ? level : 32; // 最多32层
}比如大部分节点只有 1 层,少数有 2 层,更少的有 3 层,这样平均下来,找东西的时间复杂度是 O (log n),和平衡树一样,但是插入删除比平衡树简单多了,不用旋转,所以 Redis 用跳表不用平衡树。
4. 最佳实践
六、总结:数据类型选型指南
拓展:Redis 所有数据类型的核心设计思想
内存优先:能用压缩的就压缩,能用小结构就用小结构,尽可能省内存。
性能优先:
渐进式操作:比如渐进式 Rehash,不阻塞主线程。
预分配:提前留空,减少内存分配次数。
按需切换:小数据用省内存的结构,大数据用快的结构,自动切换,不用你管。