写在前面

这篇文章是我学习 Redis 的完整笔记,从 Redis 的诞生故事讲起,覆盖核心数据结构、性能原理、持久化机制、内存管理、常见应用场景、高可用架构、以及进阶数据结构。希望能帮到同样在入门 Redis 的朋友。

一、Redis 的诞生背景

1.1 其实它最初是一个人的项目

Redis 最初不是一个团队的项目,而是一个意大利程序员 Salvatore Sanfilippo(网名 antirez)在 2009 年独自创造的。后来 Pieter Noordhuis 加入成为核心维护者,所以早期的核心团队是两个人。再后来 VMware、Pivotal、Redis Labs(现在叫 Redis Inc.)相继赞助 antirez 全职开发,项目才壮大起来。

1.2 antirez 当时遇到了什么问题?

这是理解 Redis 设计哲学的关键。antirez 当时在做一个实时 Web 日志分析产品,叫 LLOOGG.com。这个产品要做的事情大概是:

  • 网站访客一波一波涌进来

  • 每个访问都要记录下来

  • 实时展示"最近 N 条访问记录"、"访客来源排行"等

他一开始用的是 MySQL,结果遇到了一堵墙:MySQL 在高频写入 + 实时排行榜这种场景下,性能完全顶不住。

具体痛点有这么几个:

  • 写入太慢:每个访客都要 INSERT,磁盘 IO 成瓶颈

  • 排行榜难做:"Top N 访客"这种需求,SQL 要 ORDER BY + LIMIT,数据量一大就崩

  • 列表操作笨重:"保留最近 1000 条"这种"有序列表"语义,关系型数据库表达起来非常别扭

  • 内存利用率低:真正的热点数据其实很少,但 MySQL 的缓存机制不够直接

1.3 他的核心洞察

antirez 想明白了一件事:

这些数据本质上不是"表格",而是"数据结构"——列表、集合、计数器、排行榜。既然内存这么便宜,为什么不直接做一个"网络化的、内存里的数据结构服务器"?

于是 Redis 的英文全称就是这个意思:REmote DIctionary Server —— 远程字典服务器。

它最早只支持很简单的功能:在内存里存 key-value,通过网络访问。但 antirez 很快加上了 List、Set、Sorted Set、Hash、计数器等数据结构。这些数据结构不是后来加的花哨功能,而是 Redis 诞生的初衷本身

所以请记住这句话:

Redis 不等于缓存。Redis = 网络化的内存数据结构服务器,缓存只是它最常见的一种用法。

二、核心数据结构

Redis 像一本字典,"词条名"(Key)永远是字符串,但"词条解释"(Value)可以是各种各样的数据结构。最常用的有 5 种基础类型 + 3 种进阶类型(进阶类型在最后一章展开):

  • String — 一个格子放一个值,典型场景:缓存、计数器、分布式锁

  • List — 一个有顺序的队列,典型场景:消息队列、最新 N 条记录

  • Hash — 一个小字典(对象),典型场景:存用户信息这种结构化数据

  • Set — 无序、不重复的集合,典型场景:标签、去重、共同好友

  • ZSet (Sorted Set) — 带分数的集合,会自动排序,典型场景:排行榜、延时队列

2.1 String(字符串)—— 最简单也最常用

String 能存任何二进制数据,只要不超过 512MB。包括普通字符串、数字(Redis 会智能识别成整数)、JSON、甚至图片和序列化后的对象。

SET    user:1001:name  "张三"        # 存
GET    user:1001:name                # 取  → "张三"
INCR   page:home:views               # 自增1(原子操作!)
INCRBY page:home:views  10           # 自增10
EXPIRE user:1001:name  3600          # 1小时后过期
bash

为什么 INCR 这么重要? 它是原子的——哪怕 1 万个请求同时来,计数也不会错。这就是为什么 Redis 做计数器特别合适(点赞数、阅读量、限流计数)。

典型场景:缓存、计数器(文章阅读量、点赞数)、分布式锁、限流。

2.2 List(列表)—— 一个双向队列

把它想象成一个两头都能进出的管道,两端操作都是 O(1),非常快。

LPUSH  msg_queue  "msg1"      # 从左边塞
RPUSH  msg_queue  "msg2"      # 从右边塞
LPOP   msg_queue              # 从左边取
LRANGE msg_queue  0  -1       # 取所有元素
LLEN   msg_queue              # 队列长度
BRPOP  msg_queue  10          # 阻塞式取,最多等10秒(消息队列神器)
bash

典型场景:

  • 简单消息队列:生产者 LPUSH,消费者 BRPOP

  • 最新 N 条记录:还记得 antirez 当年的需求吗?就是这个!LPUSH 加 LTRIM 配合,只保留最新 1000 条

2.3 Hash(哈希)—— 字典里的小字典

如果说 Redis 整体是一本大字典,那 Hash 就是每个词条解释里又嵌了一个小字典:

key: "user:1001"
  ├── name:  "张三"
  ├── age:   "18"
  └── city:  "北京"
TEXT

它和"用 String 存 JSON"有什么区别? 这是个特别值得讲的对比:

  • String 存 JSON:改 age 字段需要取出整个 JSON → 反序列化 → 改 → 序列化 → 写回

  • Hash:一条命令搞定:HSET user:1001 age 19

所以存对象优先用 Hash,特别是字段经常单独修改的场景。

HSET    user:1001  name "张三" age 18    # 设多个字段
HGET    user:1001  name                  # 取单个 → "张三"
HGETALL user:1001                        # 取全部
HINCRBY user:1001  age  1                # 给某字段+1
HDEL    user:1001  city                  # 删某字段
bash

典型场景:存用户/商品对象、购物车(key=cart:userId,field=商品ID,value=数量)。

2.4 Set(集合)—— 无序、不重复的袋子

关键特性:不重复(自动去重)、无序支持集合运算(交集、并集、差集,这是它的杀手锏)。

SADD       user:1001:tags  "篮球"  "音乐"     # 加
SMEMBERS   user:1001:tags                     # 看所有元素
SISMEMBER  user:1001:tags  "篮球"             # 是否包含 → 1或0

# 集合运算(核心能力!)
SINTER     user:1001:tags  user:1002:tags    # 交集 → 共同兴趣
SUNION     user:1001:tags  user:1002:tags    # 并集
SDIFF      user:1001:tags  user:1002:tags    # 差集
bash

典型场景:标签系统、共同好友/共同兴趣(SINTER 一行搞定)、去重(UV 统计、抽奖去重)、黑白名单。

2.5 ZSet(Sorted Set,有序集合)—— Redis 的"排行榜利器"

ZSet = Set + 每个元素带一个分数(score),元素按分数自动排序

ZADD    leaderboard  99  "Alice"           # 加/更新
ZADD    leaderboard  87  "Bob"

ZREVRANGE    leaderboard  0  9   WITHSCORES    # 降序前10名 ← 排行榜!
ZRANK        leaderboard  "Bob"                # Bob排第几(升序)
ZSCORE       leaderboard  "Alice"              # 查Alice的分数
ZINCRBY      leaderboard  1  "Alice"           # 给Alice加1分
bash

典型场景:排行榜(游戏积分、热搜榜、销量榜)、延时队列(score 用时间戳)、带权重的去重排序。

2.6 怎么选?决策思路

  1. 就一个值? → String

  2. 要按顺序进出,或要"最新 N 条"? → List

  3. 是个对象,有多个字段? → Hash

  4. 要去重,或要算交集并集? → Set

  5. 要排序、要排行榜? → ZSet

2.7 思考题:微博热搜榜 Top 50

如果让你设计一个"微博热搜榜 Top 50",你会用哪种数据结构?如果还要支持"某个话题被搜了多少次"实时累加呢?

第一反应可能是:排行榜用 ZSet,累加用 INCR。方向对了,但这里有个新手最容易踩的坑

INCR 是 String 类型的命令,它不能作用在 ZSet 上。 如果你把热搜数存成独立的 String 计数器,它们之间没有关系、没法排序。你想取 Top 50,就得把所有 key 拿出来在应用层排序——完全失去了 ZSet 的意义。

正确答案是 ZINCRBY——ZSet 版的 INCR:

# 用户每搜一次"王者荣耀",就执行:
ZINCRBY  hotsearch:board  1  "王者荣耀"

# 取 Top 50(按分数从高到低):
ZREVRANGE  hotsearch:board  0  49  WITHSCORES

# 查某个话题的搜索次数:
ZSCORE  hotsearch:board  "王者荣耀"     # → 12450
bash

ZINCRBY 把"计数 + 排序"两个操作原子化了,一条命令同时完成。这就是 Redis 命令设计的精妙之处。

2.8 命令命名规律

Redis 的命令按数据类型分组,同样是"自增"语义,不同类型有对应的版本:

  • String:INCR / INCRBY、SET、GET

  • HashHINCRBY、HSET、HGET

  • ZSetZINCRBY、ZADD、ZSCORE

看出规律了吗?前缀就是类型的首字母(H = Hash,Z = ZSet)。知道这个规律,以后猜命令都能猜个八九不离十。

三、Redis 为什么这么快

先抛一个数字:Redis 在普通服务器上,单实例 QPS 可以轻松达到 10 万+,一次操作的延迟通常在微秒级。对比 MySQL 单机 QPS 大概几千,Redis 比 MySQL 快了一到两个数量级。

为什么?答案是三条腿支撑

  1. 纯内存操作(避免磁盘)

  2. 单线程模型(避免锁竞争)

  3. IO 多路复用(一个线程扛住高并发)

3.1 纯内存操作

数据存在哪里,决定了访问速度的物理上限:

  • 内存(RAM):约 100 纳秒

  • SSD 磁盘:约 100 微秒,比内存慢 1000 倍

  • 机械硬盘:约 10 毫秒,比内存慢 10 万倍

MySQL 的数据主要在磁盘上,Redis 的数据全部在内存里。光这一条就足以拉开数量级的差距。

但内存也有代价:(内存比磁盘贵 100 倍)、(一台机器通常几十到几百 G)、断电就没(需要持久化兜底)。所以 Redis 通常只存"热数据",全量数据还是放 MySQL。

3.2 单线程模型 —— 最反直觉的一条

我们平时被教育要多线程并发,结果 Redis 告诉你:核心命令处理用的是单线程,一个线程串行处理所有客户端的请求。

这不是退步吗?怎么反而快?

关键洞察:多线程不是免费午餐。 多线程有上下文切换开销、锁竞争、缓存失效、编程复杂度等隐藏成本。而 Redis 的特点是每个操作本身极快(内存操作,纳秒级),瓶颈不在 CPU,而在内存和网络。当瓶颈不是 CPU 时,多线程不仅没好处,反而会因为锁竞争和上下文切换变慢。

打个比方:想象一个奶茶店,多线程模式是 10 个店员抢一台制作机天天吵架;Redis 单线程模式是 1 个超快的店员,做一杯只要 1 秒,100 个客人排队他一个人全搞定。当"做一杯"本身就足够快时,单线程反而效率最高,因为没有任何协调成本。

单线程带来的附赠福利:Redis 的每条命令都是原子的——因为根本没有"并发",全是串行执行。不是 Redis 加了锁,而是它根本就是一个一个执行的。这就是为什么 Redis 适合做分布式锁、限流计数器这些强一致性场景。

重要澄清: Redis 6.0 之后引入了多线程 IO,但只有"读网络数据"和"写网络数据"用了多线程,真正执行命令的还是单线程。所以"Redis 是单线程"这个说法,在命令执行这个核心环节至今仍然成立。

3.3 IO 多路复用(epoll)—— 单线程怎么扛住高并发

一个单线程,怎么扛得住成千上万个客户端连接?答案是操作系统提供的事件通知机制——epoll。

核心思想:让一个线程同时"盯着"成千上万个连接,谁有动静就处理谁。

打个比方:传统方式是一个保安站在每扇门前等客人,1000 扇门要 1000 个保安;epoll 是一个保安在监控室,1000 扇门的画面全在屏幕上,哪扇门有人按铃就处理哪扇。

整个流程是这样的:

  1. Redis 启动,创建一个 epoll 实例,把所有客户端连接注册进去

  2. Redis 调用 epoll_wait():有人发数据了吗?没有我就等着

  3. 操作系统返回:"连接 A、连接 C 有数据可读了"(注意:返回的是一批)

  4. Redis 拿到"就绪列表",依次处理:读数据 → 解析 → 执行 → 返回结果

  5. 回到步骤 2,继续 epoll_wait()

3.4 深入理解:多个连接同时有数据进来怎么办?

假设连接 1 先进来但数据没发完,连接 2 的数据完整到了,连接 3 也只到了一半。会怎样?

这恰恰是 epoll 最擅长的场景:

  • epoll 不会把"半包"数据交给你——Redis 内部有读缓冲区,数据没到齐就先攒着,不处理

  • epoll 返回的是"谁准备好了",不是"谁先来的"——先来不一定先处理,先准备好的先处理

  • 不存在"选择推哪个进去"的决策——epoll 直接告诉你"这些都好了",Redis 按列表顺序一个一个处理

如果 10000 个客户端同时发请求,epoll_wait 可能返回"5000 个连接可读了",Redis 一个一个串行处理。因为每个只要微秒级,5000 个也只要几十毫秒。

3.5 Redis 6.0 前后的对比

6.0 之前(纯单线程): 网络 IO 也是同一个单线程干的。一个线程包办接收数据、解析命令、执行命令、返回结果。不会出错(串行),但当客户端特别多时,网络读写会占用大量时间。

6.0 之后(多线程 IO): 多个 IO 线程并行读数据、解析协议,然后主线程依次执行命令(仍是单线程串行),执行完后再由 IO 线程并行写回结果。为什么执行还是单线程?因为如果执行也多线程就必须加锁,锁的开销(微秒级)会直接翻倍延迟。

3.6 三条腿缺一不可

  • 没有内存 → 慢成普通数据库

  • 没有单线程 → 锁竞争抵消速度优势

  • 没有 IO 多路复用 → 单线程扛不住高并发

3.7 反过来理解 Redis 的"边界"

理解了它为什么快,也就理解了它不适合什么:

  • 不适合存大 value:单线程意味着一条慢命令会卡住所有人。比如存了 1GB 的 String,GET 的时候所有其他请求全部排队等待。经验值:单个 value 控制在 10KB 以内。

  • 不适合复杂计算:KEYS *(扫全库)、SMEMBERS 一个百万级 Set,都会让 Redis 长时间卡住。

  • 不适合存全量数据:内存贵且小,Redis 通常只存热点数据。

  • 适合:小 value、高频访问、对延迟敏感的场景——缓存、计数器、排行榜、分布式锁、会话存储、消息队列。

四、持久化 —— 解决"断电就丢"的问题

核心矛盾:快(内存)和不丢(持久化)怎么同时做到?Redis 给出了两套方案,可以单独用也可以组合用。

4.1 RDB(Redis Database)—— 快照

在某个时间点,把整个内存数据序列化成一个二进制文件(dump.rdb),写到磁盘上。

顺便说一下 dump.rdb 这个文件名的含义:dump 在计算机领域是个经典术语,意思是"把内存中的数据倾倒/转储到外部存储"。类似的用法还有 memory dump(内存转储)、heap dump(堆转储)、mysqldump 等。.rdb 则是 Redis DataBase 的缩写。它是一个紧凑的二进制文件,用文本编辑器打开会看到乱码。

触发方式:

# 手动触发
SAVE            # 阻塞式,Redis 停止服务直到保存完。生产别用
BGSAVE          # 后台保存(fork子进程),不阻塞主线程

# 配置自动触发(redis.conf)
save 900 1      # 900秒内有至少1次写操作 → 触发
save 300 10     # 300秒内有至少10次写操作 → 触发
save 60 10000   # 60秒内有至少10000次写操作 → 触发
bash

BGSAVE 怎么做到不阻塞的? 精妙的 fork + Copy-On-Write(写时复制)

  1. 主进程 fork() 一个子进程,子进程获得父进程内存的"副本"(操作系统级别的虚拟内存映射,极快)

  2. 子进程负责把数据写到 dump.rdb,主进程继续处理请求

  3. 如果主进程在这期间修改了某个 key → 操作系统才会真正复制那一页内存(Copy-On-Write)

  4. 子进程写完 → 通知主进程 → 替换旧 rdb 文件 → 完成

RDB 的优缺点:

  • 优点:文件紧凑,恢复速度快;对性能影响小;适合做备份和灾备

  • 缺点:会丢数据——两次快照之间的数据断电就没了(默认配置下最多可能丢几分钟);fork 大内存时有瞬间卡顿

4.2 AOF(Append Only File)—— 日志追加

把每条写命令按顺序追加到一个文件里(appendonly.aof)。恢复数据时,把 AOF 文件里的命令从头到尾重放一遍,就像"回放录像"。

写入时机(最关键的配置):

appendfsync always     # 每条命令都刷盘。最安全,但最慢
appendfsync everysec   # 每秒刷一次盘。折中方案 ← 默认推荐
appendfsync no         # 交给操作系统决定何时刷。最快,但可能丢几十秒
bash
  • always:不丢数据,但慢

  • everysec:最多丢 1 秒,性能几乎无损。生产环境最常用

  • no:最快,可能丢 30 秒

AOF 的问题:文件越来越大。 比如你对同一个 key INCR 了 10000 次,AOF 记了 10000 条命令,但最终数据就一个值。

解决方案:AOF 重写(BGREWRITEAOF)。 Redis 定期触发重写——用当前内存数据生成一份最精简的 AOF 文件。10000 行变 1 行。重写也是 fork 子进程完成,不阻塞主线程。

AOF 的优缺点:

  • 优点:数据安全性高;可读性好(打开就能看到命令);重写机制控制文件大小

  • 缺点:文件比 RDB 大;恢复速度比 RDB 慢

4.3 RDB vs AOF 对比

  • 数据安全:RDB 可能丢几分钟,AOF 最多丢 1 秒

  • 文件大小:RDB 小(二进制压缩),AOF 大(文本命令)

  • 恢复速度:RDB 快(直接加载),AOF 慢(重放命令)

  • 适合场景:RDB 适合备份灾备;AOF 适合对数据安全要求高的场景

4.4 生产怎么用?两个都开!

# redis.conf
save 900 1          # RDB 还是开着(用于备份)
appendonly yes       # AOF 开
appendfsync everysec # 每秒刷盘
bash

恢复数据时:有 AOF 优先用 AOF(数据更完整),没有 AOF 才用 RDB。

Redis 4.0 之后的混合持久化(推荐方案):

aof-use-rdb-preamble yes   # 开启混合模式
bash

AOF 文件变成:[RDB 格式的全量快照] + [快照之后的增量 AOF 命令]。这样既有 RDB 的快速恢复,又有 AOF 的数据完整。

一句话总结:RDB 像定期拍照,丢了就丢了;AOF 像实时录像,几乎不丢。生产上两个都开,4.0 以后用混合模式。

五、过期策略与内存淘汰

Redis 数据在内存里,内存有限。两个现实问题:设了过期时间的 key 到期了怎么删?内存满了新数据写不进去怎么办?

5.1 过期 key 怎么删?

Redis 用两种策略配合

策略 1:惰性删除(Lazy Deletion)

不主动删。等下次访问这个 key 时,发现过期了才删。优点是对 CPU 友好;缺点是如果一个 key 过期了但永远没人访问它,它就永远占着内存。

策略 2:定期删除(Periodic Deletion)

Redis 每秒跑 10 次"清理任务",每次随机抽一批设了过期时间的 key,过期的就删。具体逻辑:从设了过期时间的 key 里随机抽 20 个 → 删除其中已过期的 → 如果被删的比例大于 25%,说明过期 key 很多,再来一轮 → 直到删除比例低于 25%,或时间到了。

为什么不全量扫描? 因为 Redis 是单线程的!如果你有 1000 万个设了过期时间的 key,全量扫描可能耗时几百毫秒甚至几秒,这期间所有客户端请求全部排队等着,Redis 完全卡死。所以 Redis 选择了"少量多次"的策略——用"概率"换"性能",把工作打碎摊薄。每次只看 20 个 key,耗时微秒级,对正常请求几乎无感。再配合惰性删除兜底,用极低的 CPU 代价达到"差不多干净"的效果。

5.2 内存满了怎么办?—— 内存淘汰策略

通过配置 maxmemory-policy 来决定。Redis 提供了 8 种淘汰策略:

  • allkeys-lru(All Keys - Least Recently Used):从所有 key 中淘汰最近最少使用的。最常用,做缓存首选。

  • volatile-lru(Volatile - Least Recently Used):只从设了过期时间的 key 中淘汰最近最少使用的。想保护永久 key 时用。

  • allkeys-lfu(All Keys - Least Frequently Used):从所有 key 中淘汰使用频率最低的(4.0+)。比 LRU 更精准。

  • volatile-lfu(Volatile - Least Frequently Used):只从过期 key 中淘汰频率最低的。

  • allkeys-random(All Keys - Random):随机淘汰。数据访问没规律时用。

  • volatile-random(Volatile - Random):从过期 key 中随机淘汰。

  • volatile-ttl(Volatile - Time To Live):淘汰剩余存活时间最短的 key。

  • noeviction(No Eviction,不驱逐):不淘汰,内存满了直接报错。不允许丢数据时用。

补充几个关键词:volatile(易失的)在 Redis 语境里特指"设了过期时间的 key";LRU 按最后一次访问时间排,最久没碰的先删;LFU 按访问总频率排,总共用得最少的先删——LFU 更聪明,一个 key 可能刚刚偶然被访问了一次(LRU 会保护它),但它历史上几乎没人用(LFU 照删不误)。

你只需要记住:做缓存 → allkeys-lru 或 allkeys-lfu不能丢数据 → noeviction

六、常见使用场景

6.1 缓存(最经典)

问题:数据库扛不住高并发读。方案:数据先查 Redis,没有再查数据库,查完写回 Redis。

要注意的三个经典问题:

  • 缓存穿透:查一个数据库里也没有的 key,每次都打到数据库。解决:布隆过滤器 / 缓存空值。

  • 缓存击穿:某个热点 key 过期瞬间,大量请求打到数据库。解决:互斥锁 / 热点 key 永不过期。

  • 缓存雪崩:大量 key 同时过期,数据库瞬间被压垮。解决:过期时间加随机值打散。

6.2 分布式锁

多个服务实例要互斥地操作同一个资源:

# 加锁(原子操作)
SET lock:order:123 "uuid-xxx" NX EX 30
# NX = 不存在才能设(互斥)
# EX 30 = 30秒后自动过期(防死锁)

# 解锁(用 Lua 脚本保证原子性)
# if redis.call("GET", key) == uuid then redis.call("DEL", key) end
bash

6.3 计数器 / 限流

# 文章阅读量
INCR article:1001:views

# 简单限流:1秒内最多10次请求
INCR  rate:user:1001
EXPIRE rate:user:1001 1
# 如果 INCR 返回值 > 10 → 拒绝
bash

6.4 排行榜

ZSet + ZINCRBY,前面思考题已经详细讲过。

6.5 会话存储(Session)

# 用户登录后,把 session 存 Redis
HSET session:token-xxx  userId 1001  role admin  loginTime 1715200000
EXPIRE session:token-xxx  3600   # 1小时过期
bash

好处:多台应用服务器共享 session,无状态部署。

6.6 消息队列(轻量级)

# 生产者
LPUSH  task_queue  '{"taskId":1,"type":"email"}'

# 消费者(阻塞式等待)
BRPOP  task_queue  0    # 0=一直等到有消息
bash

注意:重度消息队列场景还是用 Kafka / RocketMQ,Redis 做轻量级的够用。

七、高可用架构

单台 Redis 有两个风险:挂了怎么办(可用性)?一台内存不够怎么办(容量)?Redis 提供了三层方案。

7.1 主从复制(Master-Slave)

Master 负责写,Slave 复制 Master 的数据,读可以分散到 Slave(读写分离)。

问题:Master 挂了得手动切换,不够自动化。

7.2 哨兵(Sentinel)—— 自动故障转移

一组哨兵进程负责:

  1. 监控:不停 ping Master,看它活没活

  2. 发现故障:多数哨兵认为 Master 挂了 → 确认下线

  3. 自动切换:从 Slave 中选一个提升为新 Master

  4. 通知:告诉客户端新 Master 地址

适合场景:数据量不大(单机能放下),但需要高可用。

7.3 Redis Cluster —— 分片集群

当一台机器的内存放不下所有数据时:把 16384 个 slot(槽位)分配给多个节点,每个 key 通过 hash 算出属于哪个 slot,就去那个节点读写。

  • 横向扩容:加节点,迁移 slot,容量线性增长

  • 自动故障转移:每个 Master 有自己的 Slave,挂了自动切换

  • 无中心:节点之间互相通信(Gossip 协议),没有单点

适合场景:数据量大、并发高,需要分布式。

7.4 三者关系

主从复制 → 数据冗余(基础);加上哨兵 → 自动故障切换(高可用);Cluster → 分片 + 高可用(大规模)。

八、进阶数据结构

快速过一遍,知道它们存在、什么时候用就行。

8.1 Bitmap —— 位图

本质是一个很长的 0/1 数组,用 String 底层实现。

# 用户1001在第5天签到
SETBIT sign:1001:202501  4  1     # 第5天(下标4)设为1

# 查看第5天是否签到
GETBIT sign:1001:202501  4        # → 1

# 这个月签到了几天
BITCOUNT sign:1001:202501         # → 统计1的个数
bash

杀手锏:一个用户一年的签到数据只需要 365 bit = 46 字节。一亿用户一年也才 4.6 GB。

适合:签到、活跃天数、布隆过滤器。

8.2 HyperLogLog —— 基数估算

问题:统计网站的 UV(独立访客数)。如果用 Set 存所有访客 ID,一亿用户要几个 G 内存。HyperLogLog 用固定 12KB 内存就能估算出基数,误差仅 0.81%。

PFADD  page:home:uv  "user1" "user2" "user3"
PFADD  page:home:uv  "user1"    # 重复的不算

PFCOUNT page:home:uv             # → 3(估算值)
bash

适合:亿级 UV 统计、去重计数,不需要精确值的场景。

8.3 Stream —— 消息流

Redis 5.0 引入,专门做消息队列。比 List 强在:支持消费者组(多个消费者分工消费)、支持消息确认(ACK 机制,消息不丢)、支持消息回溯(可以重新消费历史消息)。

# 生产消息
XADD  mystream  *  name "Tom" action "login"

# 消费者组消费
XREADGROUP GROUP g1 consumer1 COUNT 1 BLOCK 0 STREAMS mystream >
bash

适合:需要可靠消息传递但不想引入 Kafka 的轻量场景。

九、完整知识图谱

Redis 知识体系

├── 背景:antirez 为解决实时分析瓶颈而生(Remote Dictionary Server)

├── 核心数据结构
│   ├── String → 缓存、计数器(INCR)
│   ├── List   → 队列、最新N条(LPUSH + LTRIM)
│   ├── Hash   → 对象存储(HSET / HGET)
│   ├── Set    → 去重、交并差集(SINTER)
│   └── ZSet   → 排行榜(ZINCRBY!不是 INCR)

├── 为什么快
│   ├── 内存操作(比磁盘快1000~100000倍)
│   ├── 单线程(无锁、原子、6.0后IO多线程但执行仍单线程)
│   └── IO多路复用(epoll,事件驱动而非轮询)

├── 持久化
│   ├── RDB(快照,恢复快,可能丢几分钟)
│   ├── AOF(日志追加,最多丢1秒,恢复慢)
│   └── 混合模式(4.0+推荐,RDB头 + AOF尾)

├── 过期 + 淘汰
│   ├── 惰性删除 + 定期删除(随机抽样,不全量扫)
│   └── 淘汰策略(allkeys-lru / allkeys-lfu 最常用)

├── 使用场景
│   ├── 缓存(穿透 / 击穿 / 雪崩三大问题)
│   ├── 分布式锁(SET NX EX + Lua 解锁)
│   ├── 计数器 / 限流(INCR + EXPIRE)
│   ├── 排行榜(ZSet)
│   ├── 会话存储(Hash + EXPIRE)
│   └── 轻量消息队列(List BRPOP / Stream)

├── 高可用架构
│   ├── 主从复制 → 数据冗余
│   ├── 哨兵 → 自动故障切换
│   └── Cluster → 分片 + 高可用(16384 slot)

└── 进阶结构
    ├── Bitmap → 签到、活跃统计(46字节/用户/年)
    ├── HyperLogLog → 亿级UV估算(固定12KB)
    └── Stream → 可靠消息队列(消费者组 + ACK)
TEXT

日常开发和面试,这套体系完全够用了。更深的东西(源码、Lua 脚本、Redis Module)属于进阶/专家级,后续有需要再深入。