Redis 为什么快?把 "存内存" 这句话拆开看
Redis 为什么快?把 "存内存" 这句话拆开看
聊 Redis 的性能,绕不开那句被说烂的话:"快是因为数据放内存里。" 这话不算错,但太偷懒了。同样把数据放内存的数据库不少,跑到每秒十万级 QPS 的没几个 —— 单节点 Redis 在一台普通服务器上,读能到 10 万 +,写能到 8 万 +,比 MySQL 快一到两个数量级。光靠内存解释不了这个差距,真正的答案藏在它的架构选择里。
这篇文章想按自己的理解把这件事拆开:Redis 把什么做成了单线程,为什么敢这么做,以及单线程凭什么扛得住高并发。
先把 "单线程" 这个词说清楚#
网上大量文章一上来就说 "Redis 是单线程的",这个说法其实不准。单线程的只是命令执行这一段:所有键值对的读写逻辑,由一个主线程串行完成。除此之外,Redis 进程里一直有别的线程在干活 ——RDB 快照和 AOF 重写靠 fork 子进程,AOF 刷盘、过期键清理、大 key 的惰性删除(4.0 引入)走后台线程,6.0 之后连网络读取和协议解析都拆给了 IO 线程。
把耗时操作从主线程里摘出去之后,主线程只剩一件事:串行地执行命令。这带来一个很舒服的性质 —— 所有写操作发生在同一个线程里,天然不存在并发修改的问题,也就不需要锁。命令跑完,结果直接写回客户端,中间没有任何同步开销。
为什么敢不用多线程#
直觉上,多核时代用单线程是浪费。但 Redis 作者的想法很直接:内存操作快到 CPU 根本吃不饱,瓶颈几乎永远是内存大小和网络带宽,加核心解决不了任何问题。
有个数据可以感受一下:配合 pipeline,单线程 Redis 在普通 Linux 机器上每秒能跑约 100 万个请求,而前提是业务主要用 O (1) 或 O (logN) 复杂度的命令,这些操作消耗的 CPU 少得可怜。所以实际生产里经常出现的尴尬场景是:给服务器加了几个核,Redis 的性能数字一动不动。
反过来算多线程的账,就不太划算了。一次线程上下文切换大约 1 到 10 微秒,看着不起眼,可如果每秒切 10 万次,就有零点几秒纯粹耗在 "换人" 上。共享数据要加锁,而 Amdahl 定律摆在那里:���怕只有 5% 的代码路径需要加锁,100 个核心的理论加速比上限也只有 20 倍。还有缓存问题,每个核心有自己独立的 L1/L2 缓存,线程在核间跳来跳去,缓存就得反复失效;单线程钉在一个核上,命中率反而最高。
Redis 做的是内存操作,CPU 密集程度本来就不高。在这种场景里,多线程的全部开销都变成了纯浪费,单线程串行反而把 CPU 用到了极致。这不是保守,是算过账之后的选择。
一个线程怎么接住一万个连接#
单线程模型的软肋也很明显:传统阻塞 IO 的写法里,accept () 阻塞等连接,read () 阻塞等数据,一个线程只能伺候一个 socket。一万个客户端连进来,难道开一万个线程?
Redis 的答案是 IO 多路复用。思路是把 "盯着连接" 这件事交给操作系统:内核同时监视成千上万个 socket,哪个连接的数据到了,就通知线程去处理哪个。线程不需要在任何一个连接上空等,它处理的永远是 "已经就绪" 的事件。一万个连接就这样变成了一个事件队列,一个线程足够。
Linux 提供过三代多路复用实现,差距很大:
表格
| 特性 | select | poll | epoll |
|---|---|---|---|
| 最大连接数 | 1024(默认) | 无限制 | 无限制 |
| 事件检测复杂度 | O(n) | O(n) | O(1) |
| 工作方式 | 每次遍历全部连接 | 每次遍历全部连接 | 红黑树管理,只返回就绪连接 |
| 数据拷贝 | 每次用户态到内核态 | 每次用户态到内核态 | 内存映射,免拷贝 |
Redis 在 Linux 上用 epoll。它在内核里用红黑树登记所有待监听的连接,另配一张就绪链表:某个 socket 有数据到达,内核通过回调把它挂进就绪链表,epoll_wait 返回的永远只是就绪的那一小撮。连接总数再大,每次要处理的也只有活跃连接。这就是 "单线程 + 上万并发连接" 能同时成立的底层原因。
容易被忽略的一层:数据结构#
内存和架构之外,还有一层经常被低估的优化。Redis 给每种数据类型都挑了(或者自己造了)合适的数据结构:
- SDS 是自己实现的字符串,头部直接存长度,取长度是 O (1),扩容时预分配空间、缩短时不急着释放,减少内存重分配的次数;它用长度而不是
\0判断结尾,所以存什么二进制数据都行。 - 有序集合的底层是跳表,在有序链表上叠几层索引,查找从 O (n) 降到 O (logN)。相比红黑树,跳表实现简单得多,插入删除不用旋转,做范围查询也更顺手。
- 元素少的列表、哈希会先用压缩列表存,一整块连续内存,一个指针都不用,等元素数量或单个元素大小超了阈值再换成常规结构。
- 哈希表扩容用的是渐进式 rehash:保留新旧两张表,把数据迁移分摊到之后每次操作里慢慢做。要是一次性搬完,主线程会被一个大的 rehash 卡住,这个设计就是为了让这种卡顿不存在。
这套 "按场景选结构" 的组合拳,保证了绝大多数命令稳定在 O (1) 或 O (logN),也是前面那个每秒百万请求的底气。
快归快,别乱用#
单线程有个绕不开的推论:主线程一旦被卡住,所有请求都得排队。所以生产环境里有几件事最好别做。
执行 KEYS * 这种 O (n) 命令是最常见的翻车姿势,一行下去整个实例的请求全堵住。存超大 key 同样危险,读写它的开销本身就是耗时操作。主从全量同步时要 fork 子进程,数据量大的话,光 fork 这一下就能把主线程卡出一大片超时。
顺带说一下 6.0 的多线�� IO。它只把网络读写和协议解析改成了多线程,命令执行照样串行。动机是万兆网卡普及后,单线程处理网络 IO 只能用上 20% 到 30% 的 CPU,网络成了新瓶颈,官方测试显示开启后吞吐能提升 2 到 3 倍。这个改动本身就说明了 Redis 的设计态度:哪里是瓶颈就优化哪里,但串行执行命令这条线一直没动。
我的理解#
把上面的内容收一下:内存操作是物理基础,随机读比机械硬盘快约十万倍;单线程是算过账的架构选择,换来零锁竞争、零上下文切换;epoll 解决了并发连接问题;精心设计的数据结构把每条命令的复杂度压到最低;而使用侧要做的,是别用阻塞命令、别存大 key,别亲手把这套优势浪费掉。
Redis 让我觉得有意思的地方在于,它证明了 "简单" 本身可以是一种性能策略。少做事,把做的事情做到没有多余开销,比堆线程堆机器更有效。
评论