Redis 为什么这么快(10w+ QPS)
官方给出的单点 QPS 上限在 10w 以上,这个数字背后是四件事:
- 完全基于内存:绝大部分请求是纯粹的内存操作,执行效率高。
- 数据结构简单:对数据操作也简单,不会对存储的数据强制进行像关系数据库的那种关联。
- 避免锁竞争:核心业务处理使用单线程设计(Redis 6.0 之前全部是单线程),避免了锁竞争和上下文切换时间。
- 多路 I/O 复用模型:基于 epoll/kqueue 等机制,单线程高效处理多个客户端连接。
关于单线程的常见疑问:单线程是指核心业务处理是单线程的,可开其他线程用于持久化等操作,并不是整个 Redis 进程都是单线程。
Redis 6.0 引入的多线程 I/O
Redis 6.0 之前,“单线程”包含网络 I/O + 命令解析 + 命令执行。Redis 6.0 之后:
- I/O 读写多线程:网络数据的读写(read/write 系统调用)可以使用多个 I/O 线程并行处理。
- 命令执行仍然是单线程:解析命令、执行命令、内存回收依然是单线程模型。
- 默认未开启,需要在
redis.conf配置io-threads。
启用 I/O 多线程后,Redis 在超高并发场景下(单机 QPS 超过 10w 接近极限时)的吞吐能力有明显提升,但同时也会带来命令执行顺序的不确定性问题——这点需要权衡。
Redis 的数据类型
Redis 不是简单的 key-value 存储,它提供了 9 种常用数据类型,对应不同的业务场景。
string
Redis 没有使用 C 语言的 char*,而是定义了简单动态字符串(Simple Dynamic String,SDS)作为默认字符串表示:
struct sdshdr {
int len; // buf 中存储的字符串长度
int free; // buf 中空闲空间的长度
char buf[]; // 用于存储字符串
};
SDS 的好处:
- O(1) 获取长度(
strlen命令不需要遍历)。 - 避免缓冲区溢出:扩容前先检查 free。
- 减少内存重分配:惰性空间释放 + 空间预分配。
- 二进制安全:用 len 而不是
\0判断结束。
hash
特别适合存储对象,例如用户信息 user:{id} → 多个 field。
list
底层是双向链表 + 压缩列表(ziplist)的组合,能存储约 40 亿个成员。提供栈(LPUSH/LPOP)、队列(RPUSH/LPOP)的功能,可以实现最新消息排行榜等功能。
set
带去重的无序集合,添加成功返回 1,添加重复元素返回 0。
应用场景:在微博中,将一个用户所有的关注人存于 set 中,Redis 提供交集、并集、差集等操作,可以轻松获取到两个用户的共同关注、共同喜好等。
zset
同 set 类似,但每个元素关联一个 double 类型的分数。集合中元素唯一,但分数可以重复。
应用:排行榜(充值榜、主播房间人数榜)。
HyperLogLog
非精准统计,标准误差率为 0.81%。
典型场景:网站每个网页每天的 UV 数据。
误差参考:插入 10 万条数据,统计出来少近 300,误差率 0.3%。
存储开销:HyperLogLog 不是免费的,占据一定 12k 的存储空间,不适合统计单个用户相关的数据。如果用户上亿,可以算算这个空间成本有多惊人。
但是相比 set 存储方案,HyperLogLog 在百万级以上的去重场景里内存节省是非常可观的。Redis 内部做了优化:计数较小时用稀疏矩阵存储(占用很小),计数变大超过阈值时才一次性转变成稠密矩阵(占用 12k)。
Geo
支持存储地理位置信息,可用于”附近的人”、地图标注等场景。
bitmap
bitmap 不是新的数据结构,而是基于 string 的衍生类型。
典型场景:公司有 5000w 用户,可用 bitmap 做活跃用户统计(每天一个 key,第 N 位表示 ID=N 的用户是否活跃)。
Stream(Redis 5.0+)
Redis 5.0 引入的日志型数据结构,用于消息队列场景:
- 消息持久化:消息会持久化到 AOF/RDB,不会丢失。
- 消费者组:支持多消费者协同消费(类似 Kafka 的 consumer group)。
- 消息回溯:可以重新读取已消费的消息。
- 阻塞消费:
XREAD BLOCK支持阻塞等待。
和 pub/sub 的区别:
| 特性 | pub/sub | Stream |
|---|---|---|
| 持久化 | 不持久化 | 持久化 |
| 离线消息 | 不保留 | 保留 |
| 多消费者 | 不支持消费者组 | 支持消费者组 |
| 消息回溯 | 不支持 | 支持 |
键过期与内存淘汰
键过期与内存淘汰是 Redis 两个不同层级的清理机制:
- 键过期(TTL):对单个 key 设置的过期时间,到期后删除。
- 内存淘汰(maxmemory-policy):Redis 内存达到
maxmemory上限时触发的全局策略。
键过期命令
EXPIRE key seconds # 设置过期时间(秒)
PEXPIRE key milliseconds # 设置过期时间(毫秒)
TTL key # 查看剩余过期时间(秒)
PTTL key # 查看剩余过期时间(毫秒)
PERSIST key # 取消过期时间
删除策略
Redis 同时使用两种策略删除过期 key:
- 惰性删除:访问 key 时才检查是否过期。优点是 CPU 友好;缺点是过期 key 长期不被访问会一直占内存。
- 定期删除:每隔 100ms 随机抽取一批 key 检查过期。频率和比例可在
redis.conf调(hz、active-expire-effort)。
两种策略配合,保证不会让过期 key 永远占用内存,同时也不会在 key 过期瞬间消耗大量 CPU。
内存淘汰策略
当 Redis 内存达到 maxmemory 上限,且无法立即释放时(所有 key 都未过期),必须按策略淘汰部分 key:
# 配置上限
config set maxmemory 2gb
# 配置淘汰策略
config set maxmemory-policy <策略>
| 策略 | 含义 | 适用场景 |
|---|---|---|
noeviction | 不淘汰,写入报错 | 不允许丢数据的场景 |
allkeys-lru | 所有 key 中淘汰最近最少使用 | 通用缓存 |
allkeys-lfu | 所有 key 中淘汰最不经常使用(Redis 4.0+) | 热点变化慢的场景 |
allkeys-random | 所有 key 中随机淘汰 | 数据无差别场景 |
volatile-lru | 过期 key 中 LRU | 既有持久数据又有缓存 |
volatile-lfu | 过期 key 中 LFU | 同上 |
volatile-random | 过期 key 中随机淘汰 | 同上 |
volatile-ttl | 过期 key 中淘汰剩余 TTL 最小的 | 同上 |
选型建议:纯缓存用
allkeys-lru或allkeys-lfu;混合持久和缓存数据用volatile-*。
LRU 和 LFU 都是近似算法(用采样+链表模拟),不是严格实现。Redis 默认采样 5 个 key(maxmemory-samples),可调到 10 提升精度但会消耗更多 CPU。
bigkey 排查与治理
bigkey 的危害
- 阻塞:单个 key 操作(如 DEL、HGETALL)耗时过长,阻塞单线程的 Redis。
- 倾斜:Cluster 集群下大 key 所在节点压力剧增,破坏负载均衡。
- 网络:同步给从节点、客户端读取大 value 都会占用大量带宽。
- 内存:单个 key 占用过多内存,可能直接触发 maxmemory 淘汰。
排查方法
# 找出最大的 key(抽样统计,不是精确)
redis-cli --bigkeys
# 精确查看单个 key 占用内存
redis-cli memory usage <key>
# 找出占用内存最大的 N 个 key(需要 rdbtools 工具集)
rdb -c memory dump.rdb --bytes 10240 -f memory.csv
治理建议
- 拆分:大 list 拆成多个小 list(按业务键拆分),大 hash 拆成多级 hash。
- 压缩:大字符串考虑序列化压缩(如 msgpack、protobuf)。
- 分离存储:特别大的对象用对象存储(OSS)+ Redis 只存元数据。
redis 和 memcache 区别
| 维度 | Redis | Memcached |
|---|---|---|
| 数据类型 | 丰富(9 种) | 简单(string) |
| 持久化 | 支持(RDB/AOF) | 不支持 |
| 主从复制 | 支持 | 不支持 |
| 数据分片 | 支持(Cluster / Codis) | 客户端分片 |
| 内存管理 | 多种淘汰策略 | LRU |
| 线程模型 | 6.0 后部分多线程 | 多线程 |
Redis 相对 Memcached 的核心优势:数据类型丰富、可持久化、可主从、可分片存储,更适合作为系统的”事实主存储 + 缓存”统一层。