1. 从一次面试追问说起
“Redis 为什么这么快?”这是我被问过无数次的问题,也是很多开发者简历里写的第一行技能。但有意思的是,大多数人给出的答案只有三个字:因为快。
然后呢?内存存储?单线程?IO多路复用?说得都对,但都不完整。
前阵子有个同事去面资深后端岗,被面试官连续追问了四层:Redis 快在哪一层?为什么单线程还能快?同样是内存数据库,为什么 Memcached 没有 Redis 这么猛?为什么 Redis 6.0 又引入了多线程?他回来跟我说,这才发现自己对 Redis 的理解一直停留在“背面试题”的层面。
实际上,“Redis 为什么这么快”是一个极好的技术解剖题。它牵扯到操作系统调度、网络模型、数据结构设计、编译器优化、持久化策略取舍等多个维度。把这一个问题吃透,你对高并发系统的理解会比单纯背十道面试题都深。
这篇文章我就用一线实战的视角,把 Redis 高性能背后的核心机制一层层拆开讲清楚,尽量说人话,不堆概念。
2. 内存存储:快的地基,但不全是功劳
2.1 “内存快”其实是个伪命题
很多人把 Redis 快的第一原因归结为“数据在内存里”。这个说法对,但只对了一半。
内存确实比磁盘快,现代 SSD 的顺序读带宽可以跑到 7GB/s 级别,而 DDR4 内存条轻松突破 40GB/s,延迟上内存纳秒级,SSD 微秒级,机械硬盘毫秒级。但问题在于——同样是内存数据库,为什么 Memcached 被 Redis 按在地上摩擦?为什么有人用 Java HashMap 加锁做缓存,性能却远不如 Redis?
答案在于:**单靠内存存储只能保证“数据读取不落盘”,但网络协议解析、命令分发、数据拷贝、并发控制才是真正的开销大头。**Redis 的快,本质上是“内存存储 + 高效网络模型 + 高效数据结构 + 极致系统优化”的组合拳。内存是地基,但房子是后面那几层盖起来的。
2.2 数据在内存和在磁盘,经历了什么差别
一张 4 人聚餐的账单,放在你脑子里回想(内存)和翻手机相册找账单截图(磁盘),速度差距是数量级的。但还有一个隐性成本:如果数据在磁盘上,每次查询不仅要等磁盘 IO,还要处理页缓存命中率、文件系统锁、磁盘碎片等问题。Redis 把数据整个放在内存后,读操作没有任何外部 IO 等待,这是它能达到单实例十万级 QPS 的前提。
但别忽略一个重要事实:Redis 的写入性能同样强悍。它写入时也只在内存里做数据结构操作,然后通过系统调用write()把命令追加到 AOF 缓冲区或者直接交给内核缓冲区,根本不直接写磁盘文件(持久化策略下篇细说)。也就是说,读写都躲开了磁盘这条慢速路径。
3. 网络模型:IO 多路复用才是真核心
3.1 从“一个连接一个线程”说起
传统的 BIO 模型下,每个客户端连接都要占用一个线程,线程阻塞在read()上等数据。如果一台机器开 1000 个连接,就要 1000 个线程,光上下文切换就能把 CPU 拖垮。且线程切换一次大概要 5~10 微秒,1000 个线程每秒切换几百次,CPU 都在做无用功,业务逻辑根本没跑多少。
Redis 用的是IO 多路复用。一句话解释:一个线程同时盯着成千上万个连接,哪个连接有数据来了,我就处理哪个,没数据的连接绝不占用 CPU。
这个思路有点像餐厅服务员——不是每个顾客配一个专职服务员(那得雇多少人),而是服务员巡视全场,谁举手(有事件到达)就服务谁,没举手的就不搭理。高并发场景下这是最高效的模型。
3.2 select、poll、epoll 到底选谁
IO 多路复用的底层实现有select、poll、epoll(Linux)和kqueue(macOS/BSD)。Redis 在 Linux 上用的是epoll,为什么偏偏选它?
一是select有 FD_SETSIZE 默认 1024 的限制,且每次调用都要把所有 fd 从用户态拷贝到内核态,句柄多时 O(n) 遍历就是灾难。二是poll解决了 1024 限制但仍有全量拷贝问题。三是epoll的三个关键特性:事件就绪通知机制、O(1) 复杂度、mmap 减少拷贝。
简单说,epoll在内核中维护了一个事件表,应用程序只需要把“我关心的 fd 列表”注册一次,之后内核主动告诉你“哪些 fd 可读可写了”,不需要每次全量扫描。复杂度从“等所有连接都问一遍”(O(n))变成“谁有事我直接找谁”(O(1))。整个 Redis 主线程就阻塞在epoll_wait()上,有事件就处理事件,没事件就睡觉,CPU 占用极低。
3.3 Redis 自己的 ae 事件模型
Redis 没有直接裸用epoll,而是封装了自己的事件库ae(Atomic Event)。它抽象了aeFileEvent(文件事件,处理客户端连接和命令)和aeTimeEvent(时间事件,处理过期键清理、cron 任务)。这也是为什么 Redis 能在一个线程里既服务网络请求,又能做定期任务——它们都挂在同一个事件循环里,靠优先级和调度策略分配执行窗口。
如果只是文件事件,那么高负载下所有命令都是串行执行的。时间事件的频率很低(默认server.hz=10,即每秒执行 10 次 cron),所以不会喧宾夺主。
4. 单线程:被误解最深的“慢”词
4.1 为什么单线程反而快
先给结论:Redis 的快,恰恰是单线程带来的,而不是单线程限制了它。
这里有个核心逻辑链路:
- 单线程意味着没有锁竞争,访问共享数据结构不需要加锁、解锁
- 没有锁竞争意味着没有等待,线程不会因为等锁而阻塞
- 没有等待意味着调度开销极小,不需要频繁上下文切换
- 性能模型是确定性的,不会出现“某次请求因为等高并发锁而突然变慢”的毛刺
用生活场景比喻:一条单行道的公路,所有车都按顺序排好,没有红绿灯,没有交叉路口,虽然窄但流速极快。多线程则是多车道加绿灯加路口,看起来宽,真正跑起来反而因为等灯、变道互相制肘。
Redis 的核心瓶颈从来都不是 CPU 算力,而是内存和网络带宽。即使单线程,在一个普通的 8 核虚机上,Redis 单实例也能跑到 10 万+ QPS(取决于数据结构和命令复杂度)。如果计算逻辑都简单(大多是内存操作,纳秒级),多线程分拆反而会引入锁、上下文切换、CPU 缓存失效等问题,得不偿失。
4.2 单线程下的性能红线
单线程也意味着一个命令不好好设计,会阻塞所有人。最典型的反面教材是KEYS *——在几百万 key 上执行它,主线程要扫描完整个 keyspace 才能返回,期间所有读写全被卡死。这也是为什么生产环境禁用KEYS,用SCAN代替。
再比如SMEMBERS一个大 set 的所有成员、HGETALL一个大 hash 的所有字段,如果集合巨大,都会造成明显的阻塞。所以建议是:线上大集合操作一律分批取,采用SSCAN、HSCAN、ZSCAN替代全量获取。
还有一个经典问题:FLUSHDB和FLUSHALL。如果数据量大,这条命令执行时会一次性释放所有 key 的内存,这不仅是 CPU 密集,还可能触发内存碎片整理,耗时不可控。Redis 4.0 后新增了FLUSHDB ASYNC和FLUSHALL ASYNC,把释放内存的动作丢给后台线程,主线程立即返回。
我当时在一个几千万 key 的实例上执行FLUSHALL,卡了 3 秒多,线上告警直接拉满。后来改成FLUSHALL ASYNC,效果立竿见影。这就是单线程模型下的红线:任何时候都不要在主线程做重操作。
4.3 多线程:6.0 引入的“局部多线程”
既然单线程这么好,为什么 Redis 6.0 又引入了多线程?注意,Redis 6.0 的多线程只作用于网络 IO 的读写,命令执行仍然是单线程。
为什么要这样?因为单线程模型下,瓶颈开始出现在网络协议解析和 socket 读写上——尤其在高并发短连接场景,accept、read、write、close这些系统调用占了主线程大量时间,反而把真正该做的命令执行挤掉了。
引入多线程 IO 之后,多个线程可以同时处理连接的读写,而命令执行阶段还是单线程串行,这就保持了“执行无锁”的核心优势,又分摊了网络 IO 的压力。这个设计非常精妙,本质就是:把 CPU 密集的协议解析和系统调用并行化,把共享状态计算串行化。
具体配置是通过io-threads参数开启,默认是关闭的。只有网络读写有压力(比如大量小请求)时建议开启,而且线程数不宜超过机器核心数的一半,因为 IO 线程也会涉及上下文切换成本。
5. 数据结构:为性能定制的“内功”
5.1 简单动态字符串(SDS)比 C 字符串聪明在哪
Redis 没有直接用 C 语言的char*字符串表示 key 和 value,而是自己实现了一个结构体叫 SDS(Simple Dynamic String)。这个数据结构里有三个核心成员:len(长度)、alloc(分配容量)、buf[](字节数组)。
这个设计的聪明之处在于:
- 获取长度是 O(1)。C 字符串要遍历到
\0才知道长度,SDS 直接读len字段 - 杜绝缓冲区溢出。拼接字符串前检查
alloc - len是否足够,不够就扩容,扩容策略是有余量的(小于 1MB 翻倍,大于 1MB 每次加 1MB),减少内存重分配次数 - 二进制安全。C 字符串以
\0结尾,没法存二进制数据(比如\x00会被截断),SDS 用len字段判断结束位置,所以 Redis 能存任何二进制内容,比如序列化后的 Java 对象、Protobuf 字节流,甚至图片
这个细节直接回答了热词里“redis序列化”的疑问:为什么序列化后的对象放进 Redis 不会乱码,就是因为 SDS 二进制安全,写入什么读出来就是什么,不关心里面是否有\0。
5.2 跳表 + 哈希表 + 压缩列表:各有所长的组合
Redis 的 hash、set、zset 内部都不是单一数据结构,而是根据数据规模动态升级的。
拿ZSET举例,数据量小(zset-max-ziplist-entries默认 128 且元素长度不超过 64 字节)时用ziplist(压缩列表),一种连续内存的紧凑结构,省内存且局部性好。当数据量超过阈值后,升级为skiplist(跳表)+ 哈希表的组合。
为什么用跳表而不是红黑树或 B+ 树?因为跳表实现简单、无锁性好、范围查询天然友好。ZSET 的核心操作是ZRANGE(范围查询)、ZSCORE(按成员查分数)、ZADD(插入)。跳表支持 O(logN) 的查找、插入、删除,同时按顺序遍历时就是链表遍历,效率极高。而且跳表每一层的索引节点占内存不大,以空间换时间,是 Redis 作者权衡后的选择。
再看 hash。小数据量用 ziplist,数据量变多或者单个 value 变大后转为hashtable。哈希表是典型 O(1) 操作,但最大的坑是扩容时的 rehash——如果一次 rehash 全部完成,会阻塞主线程。Redis 用了渐进式 rehash:扩容时不是一次性搬完,而是每次增删改查顺便搬一个 bucket,把 rehash 的耗时打散到各次请求中,避免了卡顿。这就是为什么你的 Redis 实例在 key 数量暴涨时依然能保持稳定延迟。
5.3 整数集合与内存紧凑的底层思路
SET如果全是整数且数量不大,底层用的是intset,一个有序整数数组,查找用二分 O(logN),关键是内存极紧凑——每个元素只占 4 字节或 8 字节,没有链表指针开销。数据量大了才转为 hashtable。
Redis 在这方面的核心思路一句话总结:能用紧凑内存的绝不用指针链式结构,能用二分查找的绝不用哈希。因为紧凑内存意味着更高的缓存命中率。CPU 读取数据是线性预取的,连续内存比散乱指针更能命中 CPU 缓存 L1/L2,而这个层面的性能差异往往是数量级的。很多人在分析 Redis 性能时忽视了“CPU 缓存友好性”,其实这才是“快”的底层物理机制之一。
6. 持久化策略:快与稳的平衡术
6.1 RDB:定期快照,写时复制
RDB 是 Redis 默认的持久化方式,它把某个时间点的全量数据拍成二进制快照存盘。触发生成 RDB 的方式有 save 同步和 bgsave 异步。
bgsave的实现很巧妙:主进程fork()一个子进程,子进程负责把数据写进临时 RDB 文件,主进程继续服务请求。这里用到了操作系统的写时复制(Copy-On-Write)机制——fork 出来的子进程与父进程共享内存页,只要父进程不改内存,就不需要复制页;一旦某个 key 被修改,对应的内存页才会复制一份,保证子进程看到的是 fork 瞬间的“冻结”数据。
这个机制也带来一个隐藏坑:如果 fork 后写操作很密集,父进程会大量复制内存页,瞬时内存占用飙升。我遇到过一台 16G 的 Redis 服务器,因为频繁写操作 + bgsave 并发,瞬时内存涨到 20G+,直接把机器 OOM。后来调整策略:在低峰期触发 bgsave,且留足内存余量。
6.2 AOF:追加日志,刷盘策略最影响延迟
AOF 记录的是每一条写命令的日志。它有三个刷盘级别:
| 配置项 | 行为 | 性能影响 | 安全性 |
|---|---|---|---|
| appendfsync always | 每条命令都 fsync 到磁盘 | 最慢,QPS 骤降 | 最安全,最多丢一条命令 |
| appendfsync everysec | 每秒批量 fsync 一次 | 性能均衡 | 最多丢 1 秒数据 |
| appendfsync no | 交给操作系统决定何时落盘 | 最快 | 可能丢较多数据 |
关键点在于fsync这个系统调用——它强制把内核缓冲区数据刷到磁盘物理介质。SSD 上单次 fsync 大约 0.5~2ms,机械硬盘上可能 5~10ms。这个操作极其昂贵,所以生产环境大部分选择everysec。
Redis 4.0 引入了混合持久化:RDB 作为快照主体,AOF 只记录快照之后的增量命令。这样重启恢复时加载 RDB 快,再回放少量 AOF 日志,兼顾了恢复速度和数据安全。想用的话配置aof-use-rdb-preamble yes,默认打开的。
6.3 持久化开销被“卸到后台”
持久化最影响性能的是磁盘 IO,Redis 通过两种方式把影响降到最低:
一是 RDB 用子进程生成文件,主线程只付出 fork 的系统调用成本(通常毫秒级)和写时复制的内存页复制成本,磁盘写的动作全部在子进程,不阻塞主线程。
二是 AOF rewrite(AOF 重写,压缩日志文件大小)也采用类似机制,fork 子进程产出新的 AOF 文件,主线程同时把增量命令写到 rewrite buffer,等子进程完了再合并 append。这个过程同样不需要主线程参与磁盘大 IO。
Redis 的设计哲学很清晰:**能交给子进程做的绝不占主线程,能异步做的绝不同步等结果。**这是它保持“快”的底层纪律。
7. 系统级优化:处处是细节
7.1 零拷贝与协议解析
Redis 在返回数据给客户端时,尽量使用writev()或sendfile()这类零拷贝系统调用,减少用户态和内核态之间的数据拷贝次数。每次命令应答的数据经过的网络栈路径越短,延迟越低。
另外 Redis 的RESP 协议非常紧凑——以\r\n分隔,用前缀字符代表类型(+字符串、-错误、:整数、$长度、*数组),解析极其简单。对比 HTTP 协议那套繁琐的头部分析,RESP 的解析成本几乎可以忽略不计。这个微观层面的高效累积起来就是巨大的宏观性能差异。
7.2 连接复用与 Pipeline
Redis 支持长连接,避免了 TCP 三次握手和四次挥手的开销。如果应用层频繁建立新连接,单次握手消耗就有 0.5~1ms,在高并发下这是不可忽视的浪费。
另一个杀手锏是Pipeline(流水线)。它把多条命令在一次网络往返中发给服务器,一次 RTT 执行多条命令。比如需要批量写入 1 万个 key,普通循环每条命令一把 RTT,那要 1 万次网络往返;用 Pipeline 一次提交,只需几次往返就能完成。我压测过,带 Pipeline 和不带的吞吐差距能到十倍。
7.3 内存分配器与过期策略
Redis 默认使用jemalloc内存分配器,它比 glibc 的 malloc 更适合大量小对象的分配释放场景,能显著减少内存碎片,提升分配效率。这在 Redis 存储大量小 key 时感受特别明显。
过期键删除也有心思:惰性删除 + 定期删除结合。惰性删除是访问 key 时才检查是否过期,不访问就不管;定期删除是每秒钟采样部分 key 删除过期项。这个组合避免了定时全量扫描带来的 CPU 峰值,也不会把过期 key 一直留在内存里。
还有一个冷知识:如果过期 key 比例太高,定期删除可能忙不过来,内存会被临时占用。所以线上要监控expired_keys指标,如果持续快速增长,要考虑调高hz或者迁移部分 key。
8. 冷静聊聊 Redis 的“不快”时刻
8.1 大 Key、热 Key、慢查询
Redis 快,但前提是“数据分布合理”。有几个场景会让 Redis 迅速变慢:
- 大 Key:一个 hash 里有几百万字段,
HGETALL一次拿回上百 MB 数据,主线程直接阻塞几秒。后续所有请求全部排队等它执行完,这也是最常见的 Redis 雪崩诱因之一。排查用redis-cli --bigkeys,它会扫描大 key 并给出统计。 - 热 Key:某个 key 被超高并发打爆,单线程上一个 key 的访问占满了 CPU。解决方案要么加本地缓存挡一层,要么把 key 加上随机后缀拆分成多个 key 分摊压力。
- 慢查询:Redis 有
SLOWLOG命令能记录吃时间的命令。建议把slowlog-log-slower-than设为 10000 微秒(10ms),监控超过这个阈值的命令。
8.2 fork 阻塞、内存碎片、集群分片后的全局操作
上面提到的 fork 内存翻倍问题不用再赘述。内存碎片可以通过INFO memory看出来,mem_fragmentation_ratio大于 1.5 就需要注意,可以考虑重启让 jemalloc 重新整理(前提是可接受停机),或者配合activedefrag开启自动碎片整理。
集群模式下还有一个容易踩的坑:跨 slot 的多 key 操作不再支持。比如MGET、MSET、DEL多个 key,如果这些 key 不在同一个 slot,集群会直接报错。生产环境设计 key 命名时要提前考虑 slot 分布,用 hash tag 强制把相关 key 放到同一个 slot。
8.3 我见过最典型的性能翻车案例
去年我们一个业务上线了秒杀活动,活动开始的前五分钟,Redis 的 CPU 冲到 90%,延迟从 1ms 涨到 800ms。排查后发现:前端在秒杀时频繁调用EXISTS去检测用户是否已下单,这个 key 是热点,所有请求全部打在同一个分片上。
我们的修复方案是:把“是否已秒杀”这个状态从 Redis 抽出来,放到各业务节点本地缓存(存 10 秒过期 + 数据库兜底),Redis 只需要在真正的库存扣减时才介入。调整后 Redis 延迟立刻回落。这个案例告诉我们:Redis 再快,也经不住无脑把所有流量打到热点 key 上,设计缓存结构时要有“分流”意识。
9. 工具选择与日常性能观测
热词里反复出现“redis可视化工具”“redis desktop manager”,我顺便补充一下实际选型建议。轻量连接调试用redis-cli足够,但如果要频繁看 key 分布、内存、慢日志,可视化工具确实效率高。
我日常用的组合:
- Another Redis Desktop Manager:免费开源,跨平台,支持 key 的树形展示、直接查看 TTL、支持命令行面板,个人用够了。下载时注意从官方 GitHub release 获取,避免第三方打包捆绑。
- RedisInsight:Redis 官方出的可视化工具,支持内存分析、慢日志、命令分析、集群拓扑,专业排查首选,缺点是界面偏重。
- Redis 官方自带的
redis-cli --stat:实时滚动性能面板,显示 ops/sec、hit/miss 比例、内存,适合压测时快速观察。
监控层面,线上至少要盯四个指标:instantaneous_ops_per_sec(QPS)、used_memory(内存)、latency(延迟)、rejected_connections(拒绝连接数)。如果延迟突然从 0.5ms 跳到 20ms,优先检查慢日志和大 key,其次看 fork 时间和内存碎片。
10. 一个保留实验:亲手验证 Redis 的快
与其听我说一堆原理,不如自己动手跑一轮压测。我常用的方式:
# 启动一个 Redis(默认端口 6379) redis-server --daemonize yes # 使用 redis-benchmark 自带工具压测 redis-benchmark -h 127.0.0.1 -p 6379 -c 50 -n 100000 -t set,get -q-c 50表示 50 个并发连接,-n 100000表示总共发 10 万条请求。实测在普通虚拟机上,SET 和 GET 的吞吐通常在 8 万~12 万 QPS 之间,延迟平均 0.4~0.8ms。
然后试一下对比实验:把-P 16加上(Pipeline 16 条一批),SET的 QPS 可能直接冲到 40 万+。这个实验能让你直观理解“网络往返开销”占总成本的比重有多大。
最后再用DEBUG JMAP这类命令观察内存分布,但注意生产环境别乱用。
11. 关于“快”的最终体会
Redis 为什么快,我的理解是:它不是靠某一项黑科技,而是把每一层的开销都抠到了极致——内存随机访问替代磁盘、epoll 替代阻塞网络、单线程消除锁竞争、紧凑数据结构降低内存与 CPU 缓存开销、fork 与异步衔接持久化、Pipeline 压缩网络往返。
如果非要把这些浓缩成一句对所有人的建议,我会说:Redis 高性能的背后不是魔法,是处处替 CPU 和 IO 着想的设计纪律。
而且这套设计对做后端系统的启示很大:同样一条业务请求,出身于高效模型还是低效模型,性能可能差两个数量级。平时写代码多想想“我的数据放在哪里、怎么被访问、网络怎么绕、锁怎么避免”,比背一打框架 API 实在得多。
从 Redis 4.0 的异步删除,到 6.0 的 IO 多线程,再到 7.0 的 auto-aof-rewrite 优化,它的每次演进都在解决“如何在不牺牲一致性的前提下,把系统压榨得更快”这个问题。这套思路,值得每个搞技术的同学学一遍。