Skip to content
概述
Redis 的单机 QPS 通常可达 10 万以上。这一性能并非来自某一种优化,而是内存存储、单线程事件循环、专用数据结构以及简单协议等多项正交设计叠加的结果。以下从存储组织、关键数据结构、命令模型与持久化机制展开,说明这些因素如何影响性能。
基本概念
内存优先
Redis 将所有数据保存在主存中,读写无需磁盘寻道或经过文件系统页缓存,单次操作延迟在微秒级。内存容量直接决定了数据规模的上限;持久化通过异步机制弥补易失性,而非依赖内存本身保证持久。
单线程执行模型
核心命令处理始终在单个线程中运行。
操作本身是内存级,CPU 极少成为瓶颈,因此单线程足够。
单线程避免了多线程在同一数据结构上的锁竞争——在微秒级操作中,锁的摊销成本可能超过 20%,同时还会破坏 CPU 缓存局部性。ziplist 等紧凑编码在多线程下也将需要复杂的同步。
真正的瓶颈是网络 I/O 和内存带宽,而不是 CPU 核数。
Redis 6.0 引入的 I/O 线程仅用于网络读写的并行化,命令执行仍保持串行。多实例扩展通过分片(Cluster)和主从复制实现。
事件循环与协议
Redis 使用 Reactor 模式,底层封装了 epoll、kqueue 或 select。所有客户端连接在同一个事件循环中处理,并通过 beforeSleep / afterSleep 等钩子执行过期键清理、渐进式 rehash 等维护任务。
通信协议 RESP 是文本协议,以长度前缀分隔数据。解析状态机简单,避免了二进制协议的复杂拆包逻辑,并原生支持流水线(pipeline)以减少网络往返次数。
工作原理
Redis 的核心是“内存数据结构服务器”。每种数据类型都配有专用的存储格式,以便在常见场景中走最直接的代码路径。
SDS(Simple Dynamic String)
C 语言原生字符串存在 strlen O(n)、追加可能频繁 realloc、无法安全存储二进制等不足。SDS 的设计:
- 独立维护
len和alloc字段,strlen降为 O(1)。 - 增长时采用阈值预分配:新长度小于 1 MB 则分配两倍空间;大于等于 1 MB 则每次额外分配 1 MB。多次追加的摊还复杂度为 O(n),同时避免过大字符串造成的内存浪费。
- 缩短字符串时不立即释放内存,供后续扩展复用。
- 使用
len判断结尾,而非\0,因此可以存放任意二进制。
c
struct sdshdr {
uint32_t len; // 已用长度
uint32_t alloc; // 已分配长度(不含头)
unsigned char flags;
char buf[];
};ziplist(紧凑列表)
当 list、hash 或 zset 中元素数量少且长度短时,Redis 使用一块连续内存存储元数据和元素,每个元素记录前一个元素的长度及自身数据,通过变长编码压缩小整数。优势在于内存效率极高,代价是中间插入/删除需要移动后续元素(memmove)。超出阈值时自动升级为标准结构(链表、跳表、哈希表)。
跳表(skiplist)
有序集合的底层实现之一。与平衡树相比:
- 实现简单,核心插入逻辑约 50 行,平衡树通常需要 200 行以上。
- 范围查询天然高效,按序移动到下一个节点为 O(1)。
- 通过随机层数(每层 25% 概率上升)达到概率上的 O(log n),无需旋转操作。
ZRANGE、ZRANGEBYSCORE等范围操作是常见需求,跳表在这一场景下比平衡树更自然。
c
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;字典与渐进式 rehash
字典使用双哈希表实现渐进式 rehash:当负载因子触发扩容时,不一次性迁移所有键,而是将 rehashidx 设为非 -1,之后每次增删改查操作顺带将一个 bucket 从旧表迁至新表。这避免了单次大 rehash 导致的延迟尖刺。代价是 rehash 期间查询需要同时检索两张表。
c
typedef struct dict {
dictType *type;
dictEntry **ht_table[2];
unsigned long ht_used[2];
long rehashidx;
int16_t pauserehash;
signed char ht_size_exp[2];
} dict;intset
纯整数集合的专用编码。所有整数按统一宽度(int16 / int32 / int64)存储在连续内存中,使用二分查找。仅在插入更大范围的整数时触发编码升级。类型特化使得存储体积较通用结构小一个数量级。
设计权衡
下表概括了 Redis 在几个主要维度上的选择、收益与代价。
| 设计选择 | 收益 | 代价 |
|---|---|---|
| 纯内存存储 | 微秒级延迟 | 容量受限于 RAM,持久化成本高 |
| 单线程执行 | 无锁、实现简单 | 无法用多核加速单实例 |
| 定制数据结构 | 每种操作的最优路径 | 每种结构需要独立实现和维护 |
| 简单协议 | 解析快、调试方便 | 表达能力有限 |
| fork 持久化 | 不阻塞主线程 | COW 内存开销、fork 停顿 |
| 主从异步复制 | 高写入吞吐 | 故障切换可能丢数据 |
基本用法
以下示例通过 redis-cli 交互完成,同时给出返回值以及对应的行为说明。
基本读写
bash
127.0.0.1:6379> SET greeting "hello"
OK
127.0.0.1:6379> GET greeting
"hello"
127.0.0.1:6379> STRLEN greeting
(integer) 5STRLEN 直接读取 SDS 的 len 字段,时间复杂度 O(1),无需遍历整个字符串。
计数器
bash
127.0.0.1:6379> INCR page_view
(integer) 1
127.0.0.1:6379> INCRBY page_view 10
(integer) 11
127.0.0.1:6379> EXPIRE page_view 3600
(integer) 1INCR 和 INCRBY 均为原子操作。Redis 对字符串类型的值会进行内部编码优化:当值可表示为整数时,会采用 int 编码,使得递增操作直接在整数上进行,无需字符串转换。EXPIRE 设置过期时间,防止计数器无限积累。
批量发送(Pipeline)
单次网络往返耗时(RTT)约 0.1–1 ms,而命令执行本身在微秒级。通过 pipeline 一次发送多条命令并一次性读取回复,可将多次网络往返合并为一次:
bash
(printf "PING\r\nPING\r\nPING\r\n"; sleep 1) | nc localhost 6379
+PONG
+PONG
+PONG此时吞吐上限由命令执行耗时决定,而非 RTT。
数据持久化
持久化机制需要在不阻塞主线程的前提下完成写入。Redis 提供两种主要方式以及一种混合方式。
RDB 快照
主进程通过 fork() 创建子进程,子进程遍历内存生成 .rdb 文件,主进程继续服务请求。写时复制(COW)导致被修改的页面会被复制,额外内存占用与写入量正相关。fork 调用本身会阻塞主线程(复制页表),大内存实例可能出现毫秒到秒级的停顿。RDB 适合定期备份,但无法做到零丢失。
AOF 日志
每条写命令以追加方式写入日志文件,提供三种刷盘策略:
always:每次命令刷盘,最安全但吞吐最低。everysec:每秒刷盘一次(默认),最多丢失最近 1 秒的写入。no:依赖操作系统刷盘,性能最高但最不可控。
AOF 重写同样通过 fork 子进程完成,生成当前数据集的最小命令集,避免日志无限增长。
混合持久化(4.0 起)
重写时以 RDB 格式保存数据快照,后续追加 AOF 增量。恢复时先加载 RDB 再重放 AOF,兼顾速度和安全性。
应用
缓存穿透防护
对于不存在的数据,若每次都查询后端,大量这种请求可能压垮数据库。可以借助布隆过滤器(通过 BF.ADD 等模块命令或客户端实现)提前判断。若未使用布隆过滤器,也可以将不存在的数据标记为空值(如设置 EXPIRE 短暂过期),避免请求直接到达后端。
分布式锁
bash
127.0.0.1:6379> SET lock:order:1001 abc123 NX EX 30
OK
# 再次尝试,锁已被持有
127.0.0.1:6379> SET lock:order:1001 another NX EX 30
(nil)NX 表示仅当键不存在时设置,EX 设置过期时间,两者组合实现原子加锁。释放锁时建议使用 Lua 脚本校验持有者标识,防止误删:脚本判断值是否与锁标识一致,一致则执行 DEL。
滑动窗口限流
以 60 秒窗口、每个用户最多 N 次请求为例:
bash
127.0.0.1:6379> ZREMRANGEBYSCORE rate:user:42 0 1696000000000
(integer) 2
127.0.0.1:6379> ZADD rate:user:42 1696000001000 req1
(integer) 1
127.0.0.1:6379> ZADD rate:user:42 1696000002000 req2
(integer) 1
127.0.0.1:6379> ZCARD rate:user:42
(integer) 2ZREMRANGEBYSCORE 清理超出窗口的记录,ZADD 添加新请求(score 为时间戳),ZCARD 统计当前窗口内请求数。有序集合按 score 排序,天然适合滑动窗口的区间删除与计数。
简单消息队列
bash
127.0.0.1:6379> LPUSH tasks "job1"
(integer) 1
127.0.0.1:6379> LPUSH tasks "job2"
(integer) 2
127.0.0.1:6379> BRPOP tasks 5
1) "tasks"
2) "job1"BRPOP 在队列为空时会阻塞至多 5 秒,返回元素及队列名。这种模式不提供消息确认和消费者组;若需要重试、消费者组等功能,应使用 Stream(5.0 引入)。
排行榜
bash
127.0.0.1:6379> ZADD leaderboard 100 playerA 95 playerB 110 playerC
(integer) 3
127.0.0.1:6379> ZREVRANGE leaderboard 0 -1 WITHSCORES
1) "playerC"
2) "110"
3) "playerA"
4) "100"
5) "playerB"
6) "95"
127.0.0.1:6379> ZRANK leaderboard playerA
(integer) 1ZRANK 返回排名(从 0 开始),ZREVRANGE 按分数降序返回成员。所有有序集合命令的时间复杂度均在对数级别,由跳表保证。
注意点
大键操作
删除包含大量元素的键会阻塞主线程:
bash
127.0.0.1:6379> DEL large_list
(integer) 1 # 可能阻塞数百毫秒替代方案:
UNLINK(4.0 起):异步删除,后台线程回收内存。- 分批删除:应用层使用
LTRIM或ZREMRANGEBYRANK逐步缩小集合。
遍历全量键
KEYS * 会阻塞整个实例。全局键空间遍历应使用 SCAN 游标迭代,每次只返回部分键:
bash
127.0.0.1:6379> SCAN 0 COUNT 100
1) "2048"
2) 1) "key:12"
2) "key:35"
...SCAN 逐步返回游标,可以在每个迭代周期内释放 CPU。
慢查询
以下操作可能在单线程中导致延迟尖刺:
SORT作用于大集合FLUSHDB/FLUSHALL(4.0 起提供ASYNC选项)- 复杂 Lua 脚本
Redis 不提供抢占式调度,一个长任务会阻塞后续所有请求。排查时可使用 SLOWLOG 命令查看执行时间过长的命令。
主从复制延迟
主从异步复制可能导致故障切换时丢失部分写入。WAIT 命令可用于等待指定数量的从库确认,但会增加延迟。
参考链接
源码入口
| 模块 | 文件 |
|---|---|
| 事件循环 | ae.c, ae_epoll.c, ae_kqueue.c |
| 网络层 | networking.c (6.0+) |
| SDS | src/sds.h, src/sds.c |
| 字典 | src/dict.h, src/dict.c |
| 跳表 | src/t_zset.c (zslInsert, zslCreate 等) |
| 持久化 | src/rdb.c, src/aof.c |
| 数据类型命令 | src/t_string.c, src/t_list.c, src/t_hash.c, src/t_set.c, src/t_zset.c |
扩展阅读
- Redis 官方文档:命令参考及模块说明
- Redis 源码仓库:
redis/redis(GitHub) - Redis 6.0 发布说明:多线程 I/O 的设计讨论
