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、epollLinux和kqueuemacOS/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而是封装了自己的事件库aeAtomic Event。它抽象了aeFileEvent文件事件处理客户端连接和命令和aeTimeEvent时间事件处理过期键清理、cron 任务。这也是为什么 Redis 能在一个线程里既服务网络请求又能做定期任务——它们都挂在同一个事件循环里靠优先级和调度策略分配执行窗口。如果只是文件事件那么高负载下所有命令都是串行执行的。时间事件的频率很低默认server.hz10即每秒执行 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而是自己实现了一个结构体叫 SDSSimple 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 持久化开销被“卸到后台”持久化最影响性能的是磁盘 IORedis 通过两种方式把影响降到最低一是 RDB 用子进程生成文件主线程只付出 fork 的系统调用成本通常毫秒级和写时复制的内存页复制成本磁盘写的动作全部在子进程不阻塞主线程。二是 AOF rewriteAOF 重写压缩日志文件大小也采用类似机制fork 子进程产出新的 AOF 文件主线程同时把增量命令写到 rewrite buffer等子进程完了再合并 append。这个过程同样不需要主线程参与磁盘大 IO。Redis 的设计哲学很清晰**能交给子进程做的绝不占主线程能异步做的绝不同步等结果。**这是它保持“快”的底层纪律。7. 系统级优化处处是细节7.1 零拷贝与协议解析Redis 在返回数据给客户端时尽量使用writev()或sendfile()这类零拷贝系统调用减少用户态和内核态之间的数据拷贝次数。每次命令应答的数据经过的网络栈路径越短延迟越低。另外 Redis 的RESP 协议非常紧凑——以\r\n分隔用前缀字符代表类型字符串、-错误、:整数、$长度、*数组解析极其简单。对比 HTTP 协议那套繁琐的头部分析RESP 的解析成本几乎可以忽略不计。这个微观层面的高效累积起来就是巨大的宏观性能差异。7.2 连接复用与 PipelineRedis 支持长连接避免了 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 获取避免第三方打包捆绑。RedisInsightRedis 官方出的可视化工具支持内存分析、慢日志、命令分析、集群拓扑专业排查首选缺点是界面偏重。Redis 官方自带的redis-cli --stat实时滚动性能面板显示 ops/sec、hit/miss 比例、内存适合压测时快速观察。监控层面线上至少要盯四个指标instantaneous_ops_per_secQPS、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 优化它的每次演进都在解决“如何在不牺牲一致性的前提下把系统压榨得更快”这个问题。这套思路值得每个搞技术的同学学一遍。
阅读完成 · 觉得有帮助?