一、一个 Redis 命令的完整旅程
当你在终端敲下 SET name "Tom",这条命令经历了怎样的旅途?
Redis 单线程处理流水线:一次只处理一个命令,没有锁竞争
📊 生活类比
Redis = 单窗口银行柜台:只有一个柜员(单线程),一次只办一笔业务
epoll = 叫号机:柜员不需要挨个问"你要办业务吗?",而是同时监控 10000 个取号器,谁叫了就处理谁
// ae.c — Redis 事件循环的核心
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) { // ← 死循环永不退出
aeProcessEvents(eventLoop, AE_ALL_EVENTS); // ← epoll_wait + 处理
}
}
// ae_epoll.c — 封装 epoll 系统调用
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
// 调用 epoll_wait,阻塞等待就绪事件
int retval = epoll_wait(state->epfd, state->events, ...);
for (j = 0; j < numevents; j++) {
// 将就绪的 fd 搬到 fired 数组
eventLoop->fired[j] = state->events[j];
}
return numevents;
}
// networking.c — 读取客户端命令
void readQueryFromClient(int fd, void *privdata) {
nread = read(fd, c->querybuf+qblen, readlen); // ← 读 TCP 数据
processInputBuffer(c); // ← 解析 & 执行
}
// server.c — 命令查找与执行
void processCommand(client *c) {
c->cmd = lookupCommand(c->argv[0]->ptr); // ← 查命令表
call(c, CMD_CALL_FULL); // ← 执行命令
}
💡 关键理解
Redis 6.0 的 I/O 读写使用了多线程(I/O 多线程),但命令执行仍然是单线程。核心逻辑不变:一次只执行一条命令,保证原子性。
二、单线程为什么这么快?三大法宝
单线程没有锁竞争,加上三大法宝,Redis 才能做到 10 万+ QPS
传统阻塞 I/O vs epoll:一个连接一个线程 vs 一个线程管理所有连接
三大法宝详解
法宝一:I/O 多路复用(epoll)
Linux epoll 机制允许一个线程同时监控海量文件描述符。只有当某个 fd 有数据可读时,epoll_wait() 才返回,不会浪费 CPU 在空等待上。
法宝二:纯内存操作
Redis 数据全部在内存中,读写延迟在纳秒级别。对比 MySQL 的磁盘随机读(毫秒级),差了10 万倍。
法宝三:高效数据结构
SDS(O(1) 获取长度)、ziplist(紧凑连续内存)、skiplist(O(logN) 范围查询),每种结构都为特定场景做了极致优化。
// ae_epoll.c — epoll 系统调用封装
static int aeApiCreate(aeEventLoop *eventLoop) {
state->epfd = epoll_create(1024); // ← 创建 epoll 实例
return AE_OK;
}
static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
struct epoll_event ee;
ee.events = mask; ee.data.fd = fd;
epoll_ctl(state->epfd, EPOLL_CTL_ADD, fd, &ee); // ← 注册 fd
return AE_OK;
}
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
numevents = epoll_wait(state->epfd, state->events, // ← 等待就绪
eventLoop->setsize, timeout);
for (j = 0; j < numevents; j++) {
// 只返回有数据的 fd,不遍历全部
eventLoop->fired[j].fd = state->events[j].data.fd;
}
return numevents;
}
📊 核心机制
传统 BIO = 1 个线程阻塞等待 1 个连接,连接没数据就傻等,其他连接没人管
epoll = 1 个线程同时监控 100 个连接,谁的 fd 有数据(就绪)就处理谁,不浪费一秒
| 对比项 | Redis | MySQL (InnoDB) |
| 存储介质 | 纯内存 | 磁盘 + Buffer Pool |
| 单次读写延迟 | ~100ns | ~10ms (磁盘随机读) |
| 线程模型 | 单线程(6.0前) / I/O多线程+执行单线程 | 多线程 + 锁竞争 |
| 典型 QPS | 10万+ | 数千 ~ 1万 |
| 数据结构 | SDS / skiplist / ziplist | B+ Tree |
| 适用场景 | 缓存 / 计数器 / 排行榜 | 持久化存储 / 复杂查询 |
三、五种数据结构内部长什么样
Redis 对外暴露 5 种数据类型,内部实现却比想象中复杂得多
String → SDS(Simple Dynamic String)
Redis 没有直接用 C 语言的字符串,而是自己实现了 SDS。核心优势:O(1) 获取长度、二进制安全、杜绝缓冲区溢出。
SDS 结构:len(5) + free(3) + "Hello" + 预分配 + \0,代码映射:sds s = sdsempty() → sds_alloc()
Hash → ziplist / hashtable
元素少时用 ziplist(连续内存,省空间),元素多时自动转为 hashtable(数组 + 链表,O(1) 查找)。
Hash 底层:元素 ≤ 512 且 value ≤ 64B 用 ziplist,否则用 hashtable
List → quicklist(ziplist 组成的链表)
Redis 3.2 后 List 底层统一使用 quicklist:一个双向链表,每个节点是一个 ziplist。兼顾了链表的灵活和 ziplist 的省空间。
quicklist = 双向链表 + ziplist,每个节点可压缩(LZF),代码映射:quicklistCreate() → quicklistPush()
Set → intset / hashtable
当集合元素全是整数且数量 ≤ 512 时,使用 intset(有序整数数组),否则使用 hashtable。
Set 底层:全整数小集合用 intset(有序数组),否则用 hashtable
ZSet → ziplist / skiplist + dict
有序集合是最复杂的数据结构:skiplist 负责 O(logN) 范围查询,dict 负责 O(1) 查 score。元素少时仍用 ziplist。
ZSet = skiplist(范围查询 O(logN)) + dict(按 key 查 score O(1)),代码映射:zaddCommand() → zslInsert()
// t_zset.c — 跳表插入核心逻辑
zskiplistNode* zslInsert(zskiplist *zsl, double score, sds ele) {
zskiplistNode *update[ZSKIPLIST_MAXLEVEL];
unsigned int rank[ZSKIPLIST_MAXLEVEL];
int i, level;
// 从最高层开始,逐层查找插入位置
x = zsl->header;
for (i = zsl->level-1; i >= 0; i--) {
while (x->level[i].forward && // ← 逐层向右找
x->level[i].forward->score < score) {
rank[i] += x->level[i].span;
x = x->level[i].forward;
}
update[i] = x; // ← 记录每层的前驱节点
}
// 随机决定新节点的层数
level = zslRandomLevel(); // ← 概率 1/2^level
if (level > zsl->level) {
for (i = zsl->level; i < level; i++) {
update[i] = zsl->header;
}
zsl->level = level;
}
// 创建节点并插入各层链表
x = zslCreateNode(level, score, ele);
for (i = 0; i < level; i++) {
x->level[i].forward = update[i]->level[i].forward;
update[i]->level[i].forward = x;
}
return x;
}
| 数据类型 | 底层实现 | 生活类比 |
| String | SDS | 带长度标签的便签贴(一眼知道多长,不用数) |
| Hash | ziplist / hashtable | 两层表格(少用紧凑排列,多用哈希桶) |
| List | quicklist | 手风琴(每个折页是 ziplist,拉开是链表) |
| Set | intset / hashtable | 抽奖箱(整数用排序数组,混合用哈希桶) |
| ZSet | ziplist / skiplist+dict | 排行榜(skiplist) + 花名册(dict) |
四、持久化——RDB 与 AOF,数据怎么存盘
Redis 在内存中跑,断电就没了。RDB 和 AOF 两种机制保证数据不丢。
RDB 流程:bgsaveCommand() → fork() → COW → rdbSave() → dump.rdb
AOF 流程:propagate() → feedAppendOnlyFile() → aof_fsync() → appendonly.aof
| fsync 策略 | 含义 | 数据安全 | 性能 | 推荐场景 |
| always | 每条命令都 fsync | 最多丢 1 条 | 最低 | 金融级安全 |
| everysec | 每秒 fsync 一次 | 最多丢 1 秒数据 | 较高 | 生产环境推荐 |
| no | 交给 OS 决定 | 可能丢数秒 | 最高 | 可容忍少量丢失 |
AOF 重写:fork 子进程 → 遍历所有 key 生成最小命令集 → 原子替换旧文件
// aof.c — AOF 重写核心逻辑(简化)
int rewriteAppendOnlyFile(char *filename) {
// 遍历所有数据库
for (j = 0; j < server.dbnum; j++) {
while((de = dictNext(di)) != NULL) { // ← 遍历每个 key
sds keystr = dictGetKey(de);
robj key, *o = dictGetVal(de);
switch(o->type) {
case OBJ_STRING:
rewriteStringObject(&aof, &key, o);
break;
case OBJ_LIST:
rewriteListObject(&aof, &key, o);
break;
case OBJ_SET:
rewriteSetObject(&aof, &key, o);
break;
case OBJ_ZSET:
rewriteSortedSetObject(&aof, &key, o);
break;
case OBJ_HASH:
rewriteHashObject(&aof, &key, o);
break;
}
// 每条命令用一条 SET/HSET/ZADD... 表达当前状态
}
}
return AE_OK;
}
// server.c — 触发重写
void bgrewriteaofCommand(client *c) {
if (fork() == 0) { // ← 子进程执行重写
rewriteAppendOnlyFile(tmpfile);
exitFromChild(0);
} else {
// 父进程继续处理请求
server.aof_rewrite_scheduled = 1;
}
}
📊 生活类比
RDB = 拍全家福:定期把所有人叫到一起拍一张照片,恢复时看照片还原
AOF = 记账本:每花一笔钱就记一笔,恢复时从头到尾重新算一遍
AOF 重写 = 重新抄账本:把 100 页的流水账浓缩成 1 页的"当前余额"
COW = 你在看书(原始),我复印一份改复印件:fork 后共享内存页,只有修改时才复制
PRO
Redis 4.0+ 支持 混合持久化:AOF 重写时先写 RDB 格式(快),再追加增量 AOF(全),兼顾速度和安全性。
五、主从复制——数据怎么从主到从
一主多从,读写分离。数据如何同步?断了又怎么恢复?
全量同步:PSYNC → BGSAVE → 传输 RDB → 发送积压缓冲区 → 增量追赶
增量同步:从库带 offset 重连 → 主库在 backlog 中找到差异 → 只发增量数据
Sentinel 故障转移:SDOWN → ODOWN → 选举领头 → 故障转移,代码映射:sentinelTimer() → sentinelStartFailover()
// replication.c — PSYNC 命令处理(简化)
void syncCommand(client *c) {
if (!strcasecmp(c->argv[0]->ptr, "psync")) {
if (masterTryPartialResynchronization(c) == C_OK) {
// ← offset 在 backlog 范围内,增量同步
return;
}
// 增量失败,退化为全量同步
}
// 全量同步
startBgsaveForReplication(); // ← fork 子进程生成 RDB
}
int masterTryPartialResynchronization(client *c) {
long long psync_offset = strtoll(c->argv[3]->ptr, NULL, 10);
// 检查 offset 是否在 repl_backlog 范围内
if (psync_offset >= server.repl_backlog_off &&
psync_offset < server.repl_backlog_off + server.repl_backlog_histlen) {
// 发送增量数据
addReply(c, "+CONTINUE");
replicationSendPartialSync(c, psync_offset);
return C_OK;
}
return C_ERR; // 超出范围,需要全量
}
// sentinel.c — 哨兵定时器
void sentinelTimer(void) {
sentinelCheckSubjectivelyDown(); // ← 检查主观下线
sentinelCheckObjectivelyDown(); // ← 检查客观下线
sentinelStartFailoverIfNeeded(); // ← 触发故障转移
}
📊 生活类比
主从复制 = 总部改了 Excel,分店还没同步
全量同步 = 重新复印整本:把总部的整份 Excel 重新复印给分店
增量同步 = 只复印改的那几页:从 offset 开始,只传新增的修改
repl_backlog = 变更记录本:记录了最近的修改,分店断线重连时从这里补
| 模式 | 机制 | 数据安全 | 适用场景 |
| 全量同步 | 主库 BGSAVE → 传输 RDB | 完整但慢 | 首次连接 / backlog 不足 |
| 增量同步 | 从 backlog 传差异数据 | 快速补齐 | 短时间断线重连 |
| 哨兵 | 自动检测 + 故障转移 | 自动恢复 | 高可用(主挂了自动选新主) |
| 集群 | 16384 槽分片 + 多主 | 水平扩展 | 大数据量 / 高并发 |
六、面试速答
高频 Redis 原理面试题,每题 30 秒内答完
Q1:Redis 为什么用单线程还这么快?
三大法宝:① I/O 多路复用(epoll)一个线程监控万级连接,不空等;② 纯内存操作纳秒级读写,没有磁盘寻道;③ 高效数据结构SDS/ziplist/skiplist 为场景极致优化。单线程还有额外好处:无锁竞争、无上下文切换、无死锁。
Q2:Redis 6.0 的多线程是怎么回事?
6.0 引入的是 I/O 多线程(读写网络数据),命令执行仍然是单线程。所以不影响原子性。默认关闭,需配置 io-threads-do-reads yes 开启。本质是把网络 I/O 的瓶颈解决了,执行逻辑不变。
Q3:RDB 和 AOF 怎么选?
RDB:文件小、恢复快,但可能丢几分钟数据。AOF:数据更安全(everysec 最多丢 1 秒),但文件大、恢复慢。推荐:混合持久化(Redis 4.0+),AOF 重写时先写 RDB 格式再追加增量,兼顾两者优点。
Q4:跳表是什么?为什么 ZSet 用跳表不用红黑树?
跳表 = 多层索引的有序链表,查找 O(logN)。比红黑树好在:① 实现简单,代码好维护;② 范围查询方便(找到起点后沿最底层链表遍历);③ 内存灵活(层数随机);④ 并发友好(Redis 虽单线程但设计上更优)。
Q5:主从复制断线后怎么恢复?
从库重连时发送 PSYNC ? offset,主库检查 offset 是否在 repl_backlog(环形缓冲区)范围内:在范围内 → 增量同步,只传差异数据;超出范围 → 全量同步,重新生成 RDB 传输。所以 backlog 大小很重要,建议调大。
Q6:SDS 和 C 字符串有什么区别?
4 个核心区别:① O(1) 获取长度(SDS 有 len 字段,C 要遍历);② 杜绝缓冲区溢出(append 前检查 free 空间);③ 二进制安全(不怕 \0,用 len 判断结尾);④ 空间预分配(减少重分配次数,free 预留空间)。