主题
第 1 章 Redis 是什么 & 为什么这么快
学习目标:能用一句话向产品经理解释「Redis 是什么、为什么需要它」;能向面试官完整解释「Redis 为什么这么快」;能用
redis-cli完成第一次 Hello World,并能用nc手动构造 RESP 协议报文。
1.1 缓存的本质:为什么我们需要 Redis
1.1.1 一个生活类比:图书馆借书
想象你在图书馆查资料:
你要查一本书:《三体》
方案 A(每次都跑书库) 方案 B(先看桌上有没有)
┌─────────────────────────┐ ┌─────────────────────────┐
│ 你 ── 去 5 楼书库 ──── │ │ 你 ── 看桌上 ───────── │
│ └ 找书架 ─────── │ │ └ 命中!直接读 │
│ └ 取书 ─────── │ │ │
│ └ 回座位 ──── │ │ 耗时:3 秒 │
│ └ 读书 │ └─────────────────────────┘
│ │
│ 耗时:5 分钟 │ 没找到?再去书库拿,
└─────────────────────────┘ 顺便放一本到桌上。- 5 楼书库 = 数据库(MySQL,磁盘 IO 慢)
- 桌子 = 缓存(Redis,内存读写快)
- 常看的书放桌上 = 把热点数据缓存起来
第一次找书会慢,但第二次、第三次……成千上万次访问都瞬间完成——这就是缓存的价值。
1.1.2 速度的鸿沟:内存 vs 磁盘
存储介质的速度差距比你想象的大得多:
存储介质速度对比(访问 1 KB 数据需要的时间):
CPU 寄存器 ▏< 1 ns
L1 Cache ▏~ 1 ns
L2 Cache ▎~ 4 ns
L3 Cache ▍~ 10 ns
内存 (DRAM) ▌~ 100 ns ← Redis 工作在这里
SSD ████ ~ 100,000 ns (10 万 ns = 0.1 ms)
机械硬盘 ████████████████ ~ 10,000,000 ns (10 ms)
网络往返(同机房) ██████████ ~ 500,000 ns
把单位拉平到「秒」级别(×10^7 倍)做类比:
内存读一次 = 1 秒
SSD 读一次 = 17 分钟
机械硬盘读一次 = 28 小时结论:内存比磁盘快 5 个数量级(10 万倍)。把热点数据放内存里,性能立刻飞起。
1.1.3 Redis 的定位
Redis(REmote DIctionary Server,远程字典服务)是一个基于内存的 KV 数据库。
┌────────────────────────┐
│ 你的 Web 应用 │
└────────┬──────────┬────┘
│ │
优先查 ▼ ▼ 兜底查
┌──────────┐ ┌──────────┐
│ Redis │ │ MySQL │
│ (内存KV) │ │ (磁盘RDB)│
└──────────┘ └──────────┘
快:~ 0.1 ms 慢:~ 10 ms
小:贵的内存 大:便宜的磁盘
易丢:断电没 持久:永久保存| 维度 | Redis | MySQL |
|---|---|---|
| 存储介质 | 内存(可选持久化到磁盘) | 磁盘(带内存 Buffer Pool) |
| 数据模型 | KV(值可以是 String/List/Hash/Set/ZSet 等) | 关系表(行 + 列) |
| 查询语言 | 200+ 个内置命令 | SQL |
| 典型 QPS | 10 万 ~ 100 万 | 几千 ~ 几万 |
| 典型场景 | 缓存、计数器、排行榜、分布式锁、消息队列 | 持久化业务数据(用户、订单、商品) |
一句话总结:Redis 不是要替代 MySQL,而是站在 MySQL 前面挡掉绝大部分读请求,让你的系统能扛住高并发。
1.2 Redis 为什么这么快
这是面试 100% 会问的问题。标准答案是三大原因 + 一个加分项。
┌────────────────────────────────────────────────────────┐
│ Redis 高性能的「四大金刚」 │
│ │
│ ① 数据在内存 (硬件层:消除磁盘 IO) │
│ ② 单线程处理命令 (软件层:无锁、无上下文切换) │
│ ③ IO 多路复用 (网络层:一个线程管 N 个连接)│
│ ④ 高效的数据结构 (算法层:SDS、跳表、压缩列表)│
└────────────────────────────────────────────────────────┘1.2.1 原因一:数据在内存
这点最直观,前面已经讲过:内存比磁盘快 10 万倍。
但要注意一个常见误区:
❌ "Redis 因为是 C 写的所以快"
✅ "Redis 快的核心是数据在内存,C 语言只是锦上添花"
1.2.2 原因二:单线程模型
很多人第一次听到「Redis 是单线程」都会震惊:单线程怎么可能快?
单的是什么?
Redis 进程内部其实有多个线程:
┌─────────────────────────────────────────────────┐
│ Redis 进程 │
│ │
│ ┌──────────────────────────────┐ │
│ │ 主线程(单线程,处理客户端请求)│ ← "单线程" │
│ │ ─ 接收命令 │ 指的是它 │
│ │ ─ 解析命令 │ │
│ │ ─ 执行命令(操作数据) │ │
│ │ ─ 返回结果 │ │
│ └──────────────────────────────┘ │
│ │
│ ┌────────────┐ ┌────────────┐ ┌──────────┐ │
│ │ BIO 线程 │ │ AOF 刷盘 │ │ 持久化 │ │
│ │ (关闭fd) │ │ 线程 │ │ 子进程 │ │
│ └────────────┘ └────────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────┘「单线程」专指处理客户端命令的主线程。其他后台任务(关闭文件描述符、AOF 刷盘、生成 RDB 快照)都是另开线程或子进程。
单线程为什么不慢?
对比图:多线程 vs 单线程处理 3 个请求
多线程(假设 2 个工作线程):
时间 →
T1: ████ 命令A ████ 命令B
T2: ▓▓▓▓ 命令C
↑ ↑
加锁 上下文切换
竞争 开销
单线程(Redis):
时间 →
Main: ████ A ████ B ████ C
↑ ↑ ↑
一个接一个,无锁、无切换单线程的好处:
- ✅ 无锁:所有数据操作天然串行,不需要 mutex/spinlock
- ✅ 无上下文切换:CPU 缓存命中率高
- ✅ 代码简单:不用考虑并发 Bug
单线程的前提:每条命令必须执行得足够快(< 1 微秒级),否则后面的请求都被堵住——这就是为什么 Redis 强调「禁用 KEYS * 等慢命令」。
Redis 6.0 的「多线程」改了什么?
Redis 6.0 之前(纯单线程):
主线程 ─ 读 socket ─ 解析 ─ 执行 ─ 写 socket
└─────────────────────────┘
全部都是主线程做
Redis 6.0+ (IO 多线程):
IO 线程 ─ 读 socket ─ 解析 ┐
├─→ 主线程:执行命令(仍然单线程!)
IO 线程 ─ 读 socket ─ 解析 ┘
↓
IO 线程 ←── 写 socket ←──────── 主线程⚠️ 关键:Redis 6.0 的多线程只用在网络 IO 阶段(读写 socket、协议解析),命令执行依然是单线程。所以「Redis 单线程」这个说法到现在依然成立。
1.2.3 原因三:IO 多路复用
这是最容易被误解的概念。我们用一个生活类比来理解。
生活类比:餐厅服务员
场景:餐厅有 100 桌客人,要点单。
方案 A(一桌一个服务员)= 多线程
100 桌 → 雇 100 个服务员
问题:成本爆炸,服务员之间还可能撞车
方案 B(一个服务员守一桌,按顺序问)= 阻塞 IO
服务员 → 走到 1 号桌:「您点好了吗?」
客人:「还在看……」(服务员傻等 5 分钟)
问题:1 号桌没好,2~100 号桌全部饿死
方案 C(服务员在中央,谁举手他过去)= IO 多路复用 ✅
服务员 → 站在中央,盯着所有桌
3 号桌客人举手 → 过去点单
7 号桌客人举手 → 过去点单
...
关键:客人「举手」这个动作 = 操作系统的 epoll
服务员一直问操作系统:「哪些 socket 有数据可读?」
操作系统直接告诉他:「3、7、25 号有」Linux 三种多路复用机制对比
┌─────────┬──────────────┬──────────────┬──────────────────┐
│ 机制 │ select │ poll │ epoll │
├─────────┼──────────────┼──────────────┼──────────────────┤
│ 上限 │ 1024(FD_SETSIZE)│ 无 │ 无 │
│ 复杂度 │ O(N) 轮询 │ O(N) 轮询 │ O(1) 事件回调 │
│ 数据结构 │ 位图 │ 数组 │ 红黑树 + 就绪链表 │
│ 拷贝 │ 每次拷贝全量FD│ 每次拷贝全量FD│ 注册一次,事件回调│
│ Redis │ 兜底 │ 备选 │ Linux 默认 ✅ │
└─────────┴──────────────┴──────────────┴──────────────────┘epoll 工作方式(伪代码):
c
// 1. 创建 epoll 实例
int epfd = epoll_create(1024);
// 2. 注册感兴趣的 socket
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &event);
// 3. 事件循环:阻塞等待事件
while (1) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
// 处理已就绪的 socket(不会被某一个 socket 卡住)
handle_event(events[i]);
}
}Redis 的事件循环
┌────────────────────────────────┐
│ Redis 主线程的事件循环 │
└─────────────┬──────────────────┘
│
┌──────────▼───────────┐
│ epoll_wait() │ ← 阻塞等待
│ "有事件来吗?" │
└──────────┬───────────┘
│ 有事件
┌─────────────┼─────────────┐
│ │ │
┌───────▼─────┐ ┌────▼─────┐ ┌────▼──────┐
│ 文件事件 │ │ 文件事件 │ │ 时间事件 │
│ (新连接到达) │ │ (有数据可读)│ │ (定时任务) │
└───────┬─────┘ └────┬─────┘ └────┬──────┘
│ │ │
▼ ▼ ▼
accept read+exec serverCron
注册新fd +write (过期清理等)
│ │ │
└────────────┼────────────┘
│
▼
回到 epoll_wait1.2.4 原因四:高效的数据结构
Redis 为每种类型都准备了「内存友好 + 时间复杂度低」的数据结构。后面 第 2 章 会详细讲。先剧透一下:
| 数据类型 | 底层编码 | 关键设计 |
|---|---|---|
| String | SDS | 动态扩容、O(1) 取长度 |
| List | quicklist | 链表 + ziplist,平衡内存与速度 |
| Hash | listpack / hashtable | 小数据用紧凑列表,大数据用哈希表 |
| Set | intset / hashtable | 全是整数时用有序数组节省内存 |
| ZSet | 跳表 + hashtable | 跳表 O(log N) 范围查询 + 哈希 O(1) 查值 |
1.3 RESP 协议:和 Redis 对话的「黑话」
Redis 客户端和服务端之间用 RESP(REdis Serialization Protocol) 通信。这是一个人类可读的文本协议——你完全可以用手敲。
1.3.1 协议格式
RESP 用第一个字节区分类型:
第一字节 │ 类型 │ 例子
────────┼──────────────────┼────────────────────
+ │ Simple String │ +OK\r\n
- │ Error │ -ERR unknown command\r\n
: │ Integer │ :1000\r\n
$ │ Bulk String │ $5\r\nhello\r\n
* │ Array │ *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n所有行都用
\r\n(CRLF)结尾。
1.3.2 一次 SET name Alice 的完整对话
客户端发送(按 RESP 编码 ["SET", "name", "Alice"]):
*3\r\n ← 数组,有 3 个元素
$3\r\n ← 第 1 个元素是长度 3 的字符串
SET\r\n ← 内容
$4\r\n ← 第 2 个元素是长度 4 的字符串
name\r\n ← 内容
$5\r\n ← 第 3 个元素是长度 5 的字符串
Alice\r\n ← 内容
服务端返回:
+OK\r\n ← 简单字符串 "OK"1.3.3 用 nc 手敲 RESP(练习题)
bash
# 安装 nc(已装可跳过)
# Ubuntu: apt install netcat
# CentOS: yum install nc
# 连接到 Redis(注意:要按 Ctrl+V Ctrl+M 输入 \r,或用 printf)
printf '*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$5\r\nAlice\r\n' | nc 127.0.0.1 6379
# 输出:+OK
printf '*2\r\n$3\r\nGET\r\n$4\r\nname\r\n' | nc 127.0.0.1 6379
# 输出:$5
# Alice🎉 看,你已经手动「扮演」了一回 Redis 客户端!
1.4 实操:第一次 Hello Redis
1.4.1 用 redis-cli 命令行
bash
# 进入交互式命令行
$ redis-cli
127.0.0.1:6379> PING
PONG
127.0.0.1:6379> SET greeting "Hello, Redis!"
OK
127.0.0.1:6379> GET greeting
"Hello, Redis!"
127.0.0.1:6379> TYPE greeting
string
127.0.0.1:6379> OBJECT ENCODING greeting
"embstr"
127.0.0.1:6379> DEL greeting
(integer) 1
127.0.0.1:6379> EXIT1.4.2 用 Python 客户端
完整可运行的代码见配套文件 01_intro/code/hello_redis.py。核心片段:
python
import redis
r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True)
r.set('greeting', 'Hello, Redis!')
print(r.get('greeting')) # → Hello, Redis!
r.set('counter', 0)
r.incr('counter')
r.incr('counter')
print(r.get('counter')) # → 21.4.3 浏览器可视化演示
打开配套文件 01_intro/demo.html,可以点击按钮一步步看:
- 阻塞 IO vs IO 多路复用的对比动画
- epoll 事件循环的完整流程
- RESP 协议报文逐字节解析
1.5 本章小结
┌─────────────────────────────────────────────────────┐
│ 本章核心要点 │
├─────────────────────────────────────────────────────┤
│ │
│ ① Redis = 内存 KV 数据库 = MySQL 前面的「快桌子」 │
│ │
│ ② Redis 快的「四大金刚」: │
│ • 内存 │
│ • 单线程(无锁、无切换) │
│ • IO 多路复用(epoll) │
│ • 高效数据结构 │
│ │
│ ③ 「单线程」专指处理命令的主线程; │
│ 6.0+ 的多线程只优化网络 IO,命令执行依然单线程 │
│ │
│ ④ RESP 协议人类可读,掌握它能更好地理解性能与排障 │
│ │
└─────────────────────────────────────────────────────┘1.6 面试高频题
Q1:Redis 为什么这么快?
考察点:对 Redis 整体架构的理解深度。
标准答案(按重要性排序):
- 内存存储:避免磁盘 IO,是性能的根本保证。
- 单线程模型:避免锁竞争和上下文切换开销,CPU 缓存命中率高。
- IO 多路复用:基于 epoll 实现「一个线程管 N 个连接」,单机轻松支撑数万连接。
- 高效数据结构:SDS、跳表、压缩列表、哈希表等,每种类型都为「内存 + 时间复杂度」做了优化。
- 简洁的协议(RESP):解析开销小。
加分项:提一句 Redis 6.0 引入了多线程 IO,进一步利用多核 CPU 的网络处理能力,但命令执行仍然是单线程。
易错点:不要只说「因为是 C 语言写的」——这只是次要因素。
Q2:Redis 是单线程的,那为什么还能这么快?
考察点:单线程模型的本质。
标准答案:
- Redis 的瓶颈通常是网络 IO 和内存带宽,而不是 CPU;
- 单线程避免了多线程的锁竞争、上下文切换、CPU 缓存失效等开销;
- Redis 操作的绝大部分命令都是 O(1) 或 O(log N),单条命令执行极快(微秒级),单线程足够;
- 通过 IO 多路复用(epoll),单线程也能并发处理数万个客户端连接。
加分项:提一下「单线程的代价」——必须避免慢命令(如 KEYS *、大 Key 操作),否则会阻塞所有其他请求。
Q3:Redis 6.0 的多线程是真的多线程吗?解决了什么问题?
考察点:对 Redis 演进的了解。
标准答案:
- 6.0 引入的多线程只用于网络 IO 阶段(读 socket、协议解析、写 socket);
- 命令执行依然是单线程,所以不存在并发安全问题,原有的单线程优势保留;
- 解决的问题:在万兆网卡 + 高 QPS 场景下,单线程处理网络 IO 成为瓶颈,多线程 IO 能更好利用多核 CPU;
- 默认是关闭的,需要在
redis.conf中配置io-threads-do-reads yes和io-threads 4。
易错点:不要说「Redis 6.0 之后变成多线程数据库了」——这是误解。
Q4:什么是 IO 多路复用?select / poll / epoll 有什么区别?
考察点:网络编程基础。
标准答案:
IO 多路复用:用一个线程同时监听多个 socket 的状态,哪个 socket 就绪就处理哪个。本质是把「轮询是否有数据」的工作下放给操作系统内核,应用层只在有事件时被唤醒。
| 维度 | select | poll | epoll |
|---|---|---|---|
| 文件描述符上限 | 1024 | 无 | 无 |
| 时间复杂度 | O(N) | O(N) | O(1) |
| 数据结构 | 位图 | 链表 | 红黑树 + 就绪链表 |
| 内核到用户态拷贝 | 全量 | 全量 | 仅就绪事件 |
| 触发方式 | 水平触发 | 水平触发 | 水平触发 + 边缘触发 |
Redis 的选择:
- Linux 优先用
epoll - macOS / BSD 用
kqueue - Solaris 用
evport - 兜底用
select(封装在ae.c,对应ae_epoll.c/ae_kqueue.c等)
Q5:Redis 是 KV 数据库,那是不是只能存字符串?
考察点:对 Redis 数据类型的认知(钓鱼题)。
标准答案:
不是。Redis 的「Key 是字符串,但 Value 可以是 5 种基础类型 + 3 种特殊类型」:
- 基础类型:String、List、Hash、Set、ZSet(Sorted Set)
- 特殊类型:Bitmap、HyperLogLog、Geo
- 新增类型:Stream(5.0+)、Bloom Filter(模块)
每种类型都有针对性的命令和优化的底层编码(这是后面章节的内容)。
加分项:能举一个场景——例如「ZSet 用于排行榜」「Bitmap 用于签到」「HyperLogLog 用于 UV 统计」。
Q6:Redis 和 Memcached 怎么选?
考察点:技术选型能力。
标准答案:
| 维度 | Redis | Memcached |
|---|---|---|
| 数据结构 | 5+ 种丰富类型 | 仅字符串 |
| 持久化 | 支持 RDB/AOF | 不支持 |
| 集群 | 原生 Cluster | 客户端分片 |
| 多线程 | 6.0+ IO 多线程 | 原生多线程 |
| 内存效率 | 一般 | 更高(slab 分配) |
| 场景 | 缓存 + 数据结构服务 | 纯字符串缓存 |
结论:99% 的新项目直接选 Redis;只有「极简纯字符串缓存 + 极致内存效率」的场景才考虑 Memcached。
📌 下一章预告:第 2 章我们会深入 Redis 的五大基础数据类型(String / List / Hash / Set / ZSet),看看它们底层用了什么数据结构、为什么这么设计,以及在生产环境中的最佳实践。
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗
💻 示例代码
python
"""
第 1 章配套实战代码 —— Hello Redis
演示内容:
1. 基本连接与 PING 探活
2. String 类型的基础操作(SET/GET/INCR)
3. 用 socket 手动发送 RESP 协议报文,模拟一个最小客户端
4. 简单的性能基准测试,直观感受 Redis 的速度
运行前提:
1) 本机已有可用的 Redis 服务(127.0.0.1:6379)
2) pip install redis
运行方式:
python hello_redis.py
"""
import socket
import time
import redis
HOST = "127.0.0.1"
PORT = 6379
# ----------------------------------------------------------------------
# Demo 1:使用 redis-py 客户端
# ----------------------------------------------------------------------
def demo_basic_client() -> None:
print("\n" + "=" * 60)
print("Demo 1: 使用 redis-py 客户端的基本操作")
print("=" * 60)
# decode_responses=True 让返回值自动从 bytes 解码为 str
r = redis.Redis(host=HOST, port=PORT, decode_responses=True)
# 探活
print(f"PING -> {r.ping()}") # True
# String 类型
r.set("greeting", "Hello, Redis!")
print(f"GET greeting -> {r.get('greeting')}")
# 计数器(INCR 是原子操作)
r.set("counter", 0)
r.incr("counter")
r.incr("counter")
r.incr("counter")
print(f"counter after 3 INCR -> {r.get('counter')}")
# 查看类型与底层编码(验证文档中说的 embstr)
print(f"TYPE greeting -> {r.type('greeting')}")
print(f"OBJECT ENCODING greeting -> {r.object('encoding', 'greeting')}")
# 设置过期时间(10 秒后自动消失)
r.set("temp_key", "will expire", ex=10)
print(f"TTL temp_key -> {r.ttl('temp_key')} 秒")
# 清理
r.delete("greeting", "counter", "temp_key")
# ----------------------------------------------------------------------
# Demo 2:手动构造 RESP 协议报文
# ----------------------------------------------------------------------
def encode_resp(*args: str) -> bytes:
"""把命令编码成 RESP 协议字节流。
示例:encode_resp("SET", "name", "Alice")
-> b'*3\\r\\n$3\\r\\nSET\\r\\n$4\\r\\nname\\r\\n$5\\r\\nAlice\\r\\n'
"""
parts: list[bytes] = [f"*{len(args)}\r\n".encode()]
for a in args:
ab = a.encode()
parts.append(f"${len(ab)}\r\n".encode())
parts.append(ab + b"\r\n")
return b"".join(parts)
def demo_raw_resp() -> None:
print("\n" + "=" * 60)
print("Demo 2: 用 socket 手敲 RESP 协议(模拟最小客户端)")
print("=" * 60)
# 建立 TCP 连接
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((HOST, PORT))
# 发送 SET name Alice
request = encode_resp("SET", "name", "Alice")
print("发送字节流:")
print(f" {request!r}")
sock.sendall(request)
response = sock.recv(1024)
print(f"收到回复:{response!r} # +OK\\r\\n 表示成功")
# 发送 GET name
request = encode_resp("GET", "name")
print("\n发送字节流:")
print(f" {request!r}")
sock.sendall(request)
response = sock.recv(1024)
print(f"收到回复:{response!r} # $5\\r\\nAlice\\r\\n")
# 清理 + 关闭
sock.sendall(encode_resp("DEL", "name"))
sock.recv(1024)
sock.close()
# ----------------------------------------------------------------------
# Demo 3:感受一下 Redis 的速度
# ----------------------------------------------------------------------
def demo_benchmark(n: int = 10_000) -> None:
print("\n" + "=" * 60)
print(f"Demo 3: 简单基准测试({n} 次 SET + {n} 次 GET)")
print("=" * 60)
r = redis.Redis(host=HOST, port=PORT, decode_responses=True)
# 1) 单条命令循环
start = time.perf_counter()
for i in range(n):
r.set(f"k:{i}", i)
set_cost = time.perf_counter() - start
start = time.perf_counter()
for i in range(n):
r.get(f"k:{i}")
get_cost = time.perf_counter() - start
print(f"单条命令 {n} 次 SET 耗时 {set_cost * 1000:.2f} ms"
f" → QPS ≈ {n / set_cost:,.0f}")
print(f"单条命令 {n} 次 GET 耗时 {get_cost * 1000:.2f} ms"
f" → QPS ≈ {n / get_cost:,.0f}")
# 2) Pipeline 批处理(提前剧透:第 7 章会详讲)
start = time.perf_counter()
pipe = r.pipeline()
for i in range(n):
pipe.set(f"k:{i}", i)
pipe.execute()
pipe_cost = time.perf_counter() - start
print(f"Pipeline {n} 次 SET 耗时 {pipe_cost * 1000:.2f} ms"
f" → QPS ≈ {n / pipe_cost:,.0f}"
f" (提速 {set_cost / pipe_cost:.1f}x)")
# 清理
keys = [f"k:{i}" for i in range(n)]
r.delete(*keys)
# ----------------------------------------------------------------------
if __name__ == "__main__":
try:
demo_basic_client()
demo_raw_resp()
demo_benchmark()
print("\n✅ 所有 Demo 执行完毕。"
"\n 下一步:打开 ../demo.html 在浏览器中可视化探索 IO 多路复用!")
except redis.ConnectionError as e:
print(f"❌ 连接 Redis 失败:{e}")
print(f" 请确认 {HOST}:{PORT} 上有可用的 Redis 服务。")