你可以把 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. 最佳实践

场景

非最佳实践

最佳实践

为什么?

点赞计数器

先 GET 拿到数字,加 1,再 SET 回去

直接用 INCR

你拿出来加 1 再放回去,两个人同时做的话,就会少加一次,INCR 是原子的,不会错

存大对象

用 String 存 100MB 的 JSON

拆成 Hash 存每个字段

大对象会让 Redis 频繁换便利贴,还会阻塞其他操作

缓存

不设置过期时间

存的时候加 EX 3600(1 小时过期)

不然东西越来越多,把内存占满了


二、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  # 年龄变成26

3. 底层原理: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. 最佳实践

场景

非最佳实践

最佳实践

为什么?

存用户信息

用 String 存整个 JSON:SET user:1 '{"name":"张三","age":25}'

用 Hash 存每个字段

你要改年龄的话,String 要把整个 JSON 拿出来解析,改完再存回去,Hash 直接改 age 抽屉就行

购物车

用多个 String 存每个商品:SET cart:1:goods:1 2

用 Hash 存:HSET cart:1 goods:1 2

你要拿整个购物车,Hash 一次就能拿完,不用一个个查


三、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 10

3. 底层原理: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. 最佳实践

场景

非最佳实践

最佳实践

为什么?

消息队列

用数据库表存消息,每秒轮询查有没有新消息

用 List:LPUSH 生产,BRPOP 消费

BRPOP 没人的时候就等着,不占 CPU,比轮询快多了

分页

把所有数据都拿出来,在代码里分页

LRANGE key start end 直接拿分页数据

减少网络传输,Redis 内部处理更快


四、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. 最佳实践

场景

非最佳实践

最佳实践

为什么?

去重

用 List 存,然后遍历去重

直接用 Set 存

Set 自动去重,加的时候就帮你去了,不用你自己遍历

共同关注

把两个用户的关注列表都拿出来,在代码里找交集

SINTER 直接算

Redis 内部算的比你快多了,还不用把所有数据都传过来

抽奖

用 List 存用户,然后随机取

用 Set 存,SPOP 随机弹

Set 保证用户不重复,SPOP 是原子的,不会抽重复的人


五、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分,变成150

3. 底层原理: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. 最佳实践

场景

非最佳实践

最佳实践

为什么?

游戏排行榜

用数据库 ORDER BY 查询,每次都要扫全表

用 ZSet:ZADD 存分数,ZREVRANGE 取排名

数据库是磁盘操作,慢,ZSet 是内存操作,O (log n) 速度,每秒能查上万次

延迟队列

用定时任务每秒轮询数据库,找到期的任务

用 ZSet:分数存执行时间戳,ZRANGEBYSCORE 取到期的

避免无效轮询,性能高太多

热点文章

用 List 存文章 ID,每次加阅读量都要重新排序

用 ZSet:分数存阅读量,ZINCRBY 加阅读量

ZSet 自动按分数排序,不用你手动维护


六、总结:数据类型选型指南

数据类型

核心特点

典型场景

String

最简单,可伸缩,原子操作

缓存、计数器、分布式锁

Hash

键值对集合,适合存对象

用户信息、购物车、配置项

List

有序,两端操作,分组存储

消息队列、栈、分页列表

Set

无序,不重复,集合运算

去重、共同关注、抽奖

ZSet

有序,不重复,带分数,范围查询

排行榜、延迟队列、热点排行


拓展:Redis 所有数据类型的核心设计思想

  1. 内存优先:能用压缩的就压缩,能用小结构就用小结构,尽可能省内存。

  2. 性能优先

    1. 渐进式操作:比如渐进式 Rehash,不阻塞主线程。

    2. 预分配:提前留空,减少内存分配次数。

  3. 按需切换:小数据用省内存的结构,大数据用快的结构,自动切换,不用你管。

两块二每分钟