ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis单线程为何能跑11万QPS?深度解析核心机制与配置

Redis单线程为何能跑11万QPS?深度解析核心机制与配置

简介:这份PPT面向希望系统掌握Redis的开发者与运维人员,以技术分享的形式梳理内存数据结构存储的核心知识,帮助读者建立从原理认知到配置实操的完整脉络。压缩包内仅含1个pptx文件,体积约1.59MB,以图文幻灯片承载讲解内容,便于直接演示或按章节自学。目前已有162人学习,说明内容具备一定的参考价值。幻灯片围绕简介与使用两大板块展开:先说明Redis的高读写性能、原子性、string/hash/set/sorted set/list等丰富数据结构及主从复制能力,并客观指出在线扩容复杂、宕机影响部分读写等局限;随后深入redis.conf常用配置项,如端口、超时、守护进程与数据库数量,再对比RDB快照与AOF日志两种持久化策略的配置命令、优缺点与适用场景,最后整理key与String等常用命令的用法。整体结构清晰,适合用作团队内部分享底稿或入门复习提纲。

1. 单线程的 Redis 凭什么跑到 11 万 QPS

很多人第一次看到 Redis 的性能数字会犯嘀咕:读 110000 次/s、写 81000 次/s,而它偏偏是单线程处理命令的。多核时代,一个单线程程序把一堆多线程服务按在地上摩擦,这事本身就值得刨根问底。答案不在“C 语言写得快”这种糊弄人的说法里,而在于它把“慢”的环节全部绕开了:没有磁盘 IO 挡在命令路径上、没有锁竞争、没有线程上下文切换,命令执行退化成一次内存里的数据结构操作加一次事件回调。搞懂这条主线,后面所有的配置项、持久化策略、主从复制才有落脚点——它们本质上都是在回答同一个问题:当数据只存在内存里,怎么在“快”和“不丢”之间做交易。这份分享材料把 Redis 的配置、持久化、五大数据结构命令和事务、发布订阅串成了一条线,正好顺着这条线把机制拆开看。

2. 事件循环与单线程模型:为什么快,快在哪

2.1 一条命令从 socket 到内存的完整路径

要理解 Redis 的性能,得先看它怎么处理一个连接。常见的实现思路是 Reactor 模式:主线程用一个事件循环(event loop)同时监听大量 socket,哪个连接可读就处理哪个,读完解析成命令,直接在内存里的字典上执行,再把结果写回输出缓冲区。整个过程没有为每个连接开线程,也没有把请求丢进线程池排队。

这条路径上有几个关键设计。第一,网络 IO 用的是非阻塞 socket 加多路复用(select/epoll/kqueue 这类机制),单线程就能盯住成千上万个连接,不会因为某个连接没数据就把整个进程卡住。第二,命令执行阶段是纯内存操作,一次 GET 就是哈希表查找,一次 LPUSH 就是链表头插,耗时在纳秒到微秒级,根本不需要并行。第三,协议解析和回复序列化都是流式的,边读边解析,不攒完整包再处理。

// 事件循环的骨架(简化示意,非源码逐行) while (!stop) { // 1. 等待事件就绪,超时时间由最近的时间事件决定 numevents = aeApiPoll(eventLoop, tvp); for (j = 0; j < numevents; j++) { aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd]; // 2. 可读事件:读 socket -> 解析协议 -> 执行命令 -> 写回复 if (fe->mask & AE_READABLE) fe->rfileProc(eventLoop, fd, fe->clientData, mask); // 3. 可写事件:把输出缓冲区里的回复刷给客户端 if (fe->mask & AE_WRITABLE) fe->wfileProc(eventLoop, fd, fe->clientData, mask); } // 4. 处理时间事件,比如 serverCron 里的过期键清理、持久化触发 processTimeEvents(eventLoop); }

这段骨架说明了单线程模型的核心:所有客户端共享同一个循环,命令一个接一个串行执行。串行带来的直接后果是“天然原子”——没有两个命令会同时改同一个 key,所以单条命令不需要加锁。这也是材料里“所有操作都是原子性”这句话的真正来源,它不是靠锁实现的,而是靠“根本不存在并发执行命令这回事”。

2.2 单线程的边界:哪些活被挪出了主线程

单线程能扛住高并发,前提是主线程上不能有慢操作。所以 Redis 把几类耗时活儿挪了出去。持久化的 RDB 快照靠 fork 子进程完成,主线程只负责 fork 那一瞬间;AOF 的 fsync 可以配置成后台线程或由主线程按策略触发;后来的版本里,删除大 key 的释放内存操作也可以放到后台线程。主线程始终只做“读事件、执行命令、写回复”这三件事。

这里有个容易被忽略的细节:fork 本身在主线程上是阻塞的。数据量越大,页表越大,fork 耗时越长,这段时间主线程完全停摆。材料里 RDB 的缺点写着“当数据量大时,fork 过程非常耗时,导致 redis 无法在毫秒级别内响应客户端请求”,说的就是这件事。所以单线程模型的性能上限,很大程度上取决于你有没有在主线程上塞进不该塞的东西——一个 KEYS * 扫全库、一次大 key 的 DEL、一次超大集合的 SMEMBERS,都能让整个实例卡住。

提示:判断一个操作会不会拖垮实例,标准很简单——它的时间复杂度是不是跟数据量成正比,且在主线程执行。O(N) 且 N 很大的命令,在生产环境基本等于定时炸弹。

2.3 为什么不用多线程执行命令

一个自然的疑问是:既然多核闲着,为什么不让多个线程并行执行命令?原因在于一旦并行,共享数据结构的读写就必须加锁,锁的粒度、死锁、缓存行伪共享这些问题会立刻把实现复杂度拉满,而收益并不明显——瓶颈往往在网络 IO 和内存带宽,不在 CPU 计算。用单线程换来的是实现简单、行为可预测、调试容易。这也是一个典型的设计取舍:牺牲多核并行度,换取确定性和低延迟。理解这一点,后面看主从复制、事务、发布订阅时,很多“为什么这么设计”就顺了。

3. 五大数据结构的底层编码:命令背后的数据结构选择

3.1 string、hash、list 的编码切换

材料里列了一大串命令,SET/GET、HSET/HGET、LPUSH/LRANGE,看起来只是 API 差异,实际上每种类型背后都有不止一种底层编码,Redis 会根据数据规模自动切换。这是它“数据结构丰富”之外更值得关注的一层:同一类型,小数据量和大数据量走的是完全不同的实现。

string 类型底层是 SDS(简单动态字符串),相比 C 字符串多了长度字段和预分配空间,取长度 O(1),追加时不必每次重新分配。数字型的值还会被编码成整数直接存,省掉字符串开销。hash 类型在小数据量时用压缩列表(ziplist,后来是 listpack),元素少且每个元素短的时候,一整块连续内存存完,省指针、省内存、缓存友好;超过阈值就转成哈希表。list 同理,小的时候用压缩列表,大了转成双向链表(后来的 quicklist 是两者的折中,把链表节点做成压缩列表块)。

# 观察编码切换:先看小 hash 的编码 redis-cli object encoding user:1 # 元素少且短 -> ziplist / listpack redis-cli hset user:1 name tom age 20 redis-cli object encoding user:1 # 塞入一个超长 value,触发编码转换 redis-cli hset user:1 bio "$(python3 -c 'print("x"*200)')" redis-cli object encoding user:1 # -> hashtable

object encoding返回的就是当前 key 实际使用的底层编码。这个命令在生产排查里非常有用:如果你发现一个本该很小的 hash 变成了 hashtable,说明它曾经被塞过大 value,内存占用会比预期高不少。编码转换是单向的,转过去就不会自动转回来,这也是内存“莫名其妙涨上去”的常见原因之一。

3.2 set 与 sorted set:集合运算和排序的代价

set 类型在元素全是整数且数量不多时用整数集合(intset),有序存储、二分查找,内存极省;一旦加入非整数元素或超过阈值,转成哈希表。材料里 SDIFF、SINTER、SUNION 这些集合运算命令,复杂度跟参与运算的集合大小相关,两个十万级的集合做交集,主线程要实打实算一遍,这就是前面说的“O(N) 命令要小心”的具体例子。

sorted set 更特殊,它要同时支持“按成员查分数”和“按分数范围取成员”,所以底层是哈希表加跳表(skiplist)的组合。哈希表负责 O(1) 查分数,跳表负责 O(log N) 的范围查询和排名。跳表相比平衡树的好处是实现简单、范围遍历天然有序、插入删除不用旋转。ZRANGEBYSCORE、ZRANK 这类命令能高效工作,全靠这层结构。理解了这个组合,你就明白为什么 sorted set 既能当排行榜又能当延时队列的索引——前者靠分数排序,后者靠分数存时间戳做范围扫描。

3.3 过期键与内存回收:TTL 背后的惰性删除

材料里 EXPIRE、TTL、PERSIST 这组命令看着简单,背后是两套删除策略的配合。Redis 不会为每个设置了过期时间的 key 起一个定时器,那样开销太大。实际做法是惰性删除加定期删除:访问一个 key 时先检查是否过期,过期就删并返回空;同时后台周期性随机抽样一批带过期时间的 key,删掉其中过期的。抽样而不是全扫,是为了控制单次耗时。

这个设计带来一个必须知道的后果:过期 key 不一定在到期那一刻就被释放内存,可能要等到被访问或者被抽样到。所以内存监控里看到“明明过期了内存还没降”,不一定是 bug。另外,主从架构下过期删除由主节点驱动,从节点收到主节点同步的 DEL 才删,自己不会主动删过期 key,这是为了保证主从数据一致。这些细节材料没展开,但它们是理解“为什么 TTL 行为和直觉有偏差”的关键。

4. 持久化:RDB 与 AOF 的取舍与实现

4.1 RDB 快照:fork 与写时复制

RDB 的思路是把某一时刻的全量数据写成一个二进制文件。材料里给了核心配置:save 60 1000表示 60 秒内 1000 次更改就触发,dbfilename dump.rdb指定文件名,rdbcompress yes控制压缩,stop-writes-on-bgsave-error no决定保存失败时是否停止写入。

真正值得讲的是它怎么在不阻塞主线程的情况下拿到一致快照。答案是 fork 加写时复制(copy-on-write)。fork 出子进程后,父子进程共享同一份物理内存页,子进程遍历内存写文件;如果主线程此时修改了某个页,操作系统会把这个页复制一份给主线程改,子进程看到的仍是 fork 那一刻的旧页。这样既不用停主线程,又能保证快照是 fork 瞬间的一致视图。

# 手动触发一次后台快照,观察 fork 耗时和内存变化 redis-cli bgsave redis-cli info stats | grep latest_fork_usec # 最近一次 fork 耗时(微秒) redis-cli info memory | grep used_memory # 快照期间内存可能上涨(COW 复制页)

latest_fork_usec是排查卡顿的重要指标。如果这个值到了几十毫秒甚至上百毫秒,说明实例数据量大、页表大,每次 bgsave 都会造成可感知的延迟毛刺。stop-writes-on-bgsave-error这个配置的含义也要说清:设成 yes 时,如果上次 bgsave 失败,主节点会拒绝所有写命令,逼你正视持久化故障;设成 no 则继续接受写入,但你要自己承担数据丢失风险。生产上这个值的选择,本质是在“可用性”和“数据安全”之间表态。

4.2 AOF:追加日志、fsync 策略与重写

AOF 走的是另一条路:把每条写命令追加到文件末尾,重启时重放。材料里的核心配置是appendonly yes、appendfsync everysec/always/no、auto-aof-rewrite-percentage 100、auto-aof-rewrite-min-size 64m。

appendfsync三档的差别必须掰开说。always每条命令都 fsync 到磁盘,最安全但最慢,磁盘 IO 直接成为瓶颈;everysec每秒 fsync 一次,最多丢一秒数据,是绝大多数场景的默认选择;no交给操作系统决定何时刷盘,性能最好但丢失窗口不可控。材料里写“redis 默认 1 秒写入一次 aof,那么最多丢失 1 秒的数据”,对应的就是everysec。

AOF 文件会越来越大,因为同一个 key 被改了一百次就记了一百条命令。重写(rewrite)机制解决这个问题:fork 子进程,根据当前内存里的数据生成一份“最小命令集”的新 AOF,比如一百次 INCR 最终只写一条 SET。重写期间主线程的新写入会同时追加到旧 AOF 和一个重写缓冲区,子进程完成后把缓冲区补进去,保证不丢命令。auto-aof-rewrite-percentage 100的意思是当前文件比上次重写后大了一倍就触发,auto-aof-rewrite-min-size 64m是兜底下限,文件太小就不折腾。

# 查看 AOF 状态与重写进度 redis-cli info persistence | grep -E "aof_enabled|aof_rewrite_in_progress|aof_last_bgrewrite_status|aof_current_size|aof_base_size" # 手动触发重写 redis-cli bgrewriteaof

aof_current_size和aof_base_size的比值就是判断要不要重写的依据。如果这个比值长期偏高但没触发重写,先检查auto-aof-rewrite-min-size是不是把门槛设太高了。

4.3 两种策略怎么选:一张取舍表

维度RDBAOF
数据丢失窗口两次快照之间,可能几分钟everysec 下约 1 秒
文件体积小,二进制压缩大,命令文本
恢复速度快,直接加载慢,逐条重放
对主线程影响fork 耗时,COW 内存上涨fsync 策略影响写延迟,重写时 fork
适合场景定期备份、灾难恢复对数据安全要求高的主存储

常见的落地做法是两者同时开:RDB 用于备份和快速恢复,AOF 用于把丢失窗口压到秒级。材料里把两者的优缺点并列摆出来,其实已经暗示了这个结论——它们不是二选一,而是互补。真正要决策的是appendfsync选哪档、save规则设多密,这两个参数直接决定了你在延迟和数据安全上的位置。

5. 主从复制、事务与发布订阅:一致性的边界在哪

5.1 主从复制:异步同步带来的读写分离代价

材料提到“主机会自动将数据同步到从机,可以进行读写分离”,也提到“主机从机的宕机都会导致前端部分读写请求失败”。这两句话合起来,点出了主从复制的核心事实:它是异步的。

主节点执行完写命令后,把命令传播给从节点,从节点重放。主节点不会等从节点确认再回复客户端,所以从节点上的数据天然落后于主节点,这个延迟就是“复制延迟”。读写分离把读请求打到从节点,就得接受“刚写进去的数据可能读不到”,也就是常说的读写不一致。对一致性要求高的读(比如刚下单查订单),要么强制走主节点,要么在业务层做补偿。

复制的过程大致分三步:从节点连上主节点后先做全量同步(主节点 fork 生成 RDB 发给从节点,期间的写命令缓存在缓冲区,发完 RDB 再补发缓冲区),之后进入命令传播阶段做增量同步。网络断开重连时,如果主节点的复制积压缓冲区还留着断点之后的数据,就做部分重同步,否则又得全量来一遍。全量同步的 fork 和传输开销,是主从架构下另一个需要盯的点。

5.2 事务:MULTI/EXEC 到底保证了什么

材料里的事务命令是 MULTI、EXEC、DISCARD、WATCH、UNWATCH。这里最容易踩的坑是把 Redis 事务和关系库事务划等号。Redis 事务的语义是:MULTI 之后的命令进队列,EXEC 时按顺序一次性执行,执行期间不会被其他客户端的命令插进来。它保证的是“这一批命令连续执行不被打断”,但不保证“要么全成功要么全回滚”。

如果队列里某条命令在运行时出错(比如对字符串执行 LPUSH),其他命令照样执行,Redis 不会回滚。只有命令入队阶段就语法错误的情况,EXEC 才会整体拒绝。这个差异源于它的实现:命令是逐条执行的,没有 undo log,回滚的成本太高。所以材料里“原子性”这个词要理解成“不被穿插”,而不是“可回滚”。

WATCH 提供的是乐观锁:监视某个 key,EXEC 时如果发现它被别的客户端改过,就放弃执行。这是实现 CAS 类逻辑的手段,比如“读值、计算、写回”这种需要防止并发覆盖的场景。但要注意 WATCH 的粒度是 key,监视太多 key 会放大冲突概率,重试逻辑得自己写。

# 乐观锁示例:只有 balance 没被别人改过才扣款 redis-cli watch balance redis-cli get balance # 假设读到 100 redis-cli multi redis-cli decrby balance 30 redis-cli exec # 若期间 balance 被改,返回 nil,需重试

5.3 发布订阅:没有持久化的消息通道

SUBSCRIBE/PUBLISH 这组命令实现了一个轻量的消息广播。发布者往频道发消息,所有订阅了该频道的客户端都能收到。材料里还列了 PUBSUB CHANNELS 查活跃频道、PUBSUB NUMSUB 查订阅者数量。

它的关键限制在于“发完就忘”:消息不落盘、不重放,订阅者断线期间的消息全部丢失;也没有确认机制,发布者不知道谁收到了。所以它适合做实时通知、配置变更广播这类“丢了也无所谓”的场景,不适合当可靠消息队列用。另外,订阅模式的客户端会进入一个特殊状态,只能收订阅消息,不能执行普通命令(后来的版本有所放宽),这也是使用时要留意的行为差异。理解了这个边界,就不会拿它去扛订单、支付这类不能丢消息的链路。

6. 配置与运维的避坑清单:那些文档不会主动告诉你的

6.1 常见问题与排查

现象:实例周期性卡顿,监控上看到延迟毛刺。原因:大概率是 bgsave 或 AOF 重写触发的 fork 阻塞了主线程,数据量越大越明显。解决:用info stats看latest_fork_usec,用info persistence看重写频率;把save规则调稀、避开业务高峰做备份,或者把实例拆小,别让单实例数据量无限膨胀。

现象:内存持续上涨,删了 key 也不降。原因:可能是过期 key 还没被抽样删除,也可能是编码转换后内存回不去(比如 hash 从 ziplist 转成 hashtable),还可能是客户端输出缓冲区堆积。解决:用info memory看used_memory和mem_fragmentation_ratio,用object encoding抽查大 key 的编码,用client list看输出缓冲区大小。

现象:主从数据对不上,从节点读到旧值。原因:异步复制固有的延迟,或者从节点曾经断连做过全量同步。解决:关键读走主节点;监控复制偏移量(info replication里的master_repl_offset和从节点的slave_repl_offset),差值持续偏大就要查网络和主节点负载。

现象:执行 KEYS * 之后整个服务像挂了。原因:KEYS 是 O(N) 全库扫描,在主线程执行,数据量大时直接堵死事件循环。解决:生产禁用 KEYS,改用 SCAN 游标分批遍历;在配置里用rename-command把危险命令改名或禁用。

现象:AOF 文件暴涨,磁盘告警。原因:写操作频繁但重写没触发,或者auto-aof-rewrite-min-size设得太大。解决:检查aof_current_size与aof_base_size的比值,适当调低重写门槛,必要时手动bgrewriteaof。

6.2 配置项背后的默认值逻辑

材料里那张配置表,每一项的默认值都不是随手定的。port 6379是约定俗成的端口;timeout 0表示不主动断开空闲连接,因为服务端很难判断客户端是真空闲还是只是暂时没请求;daemonize yes让进程后台化,配合logfile把日志落到文件;databases 16给了 16 个逻辑库,但集群模式下只有 0 号库可用,这个限制在单机切集群时经常翻车。tcp-backlog 511是 listen 队列长度,高并发短连接场景下如果出现连接被拒,可以适当调大。这些参数单独看是配置项,连起来看就是一套“默认保守、按需放开”的运维哲学。

7. 设计取舍的思考:快与不丢之间的那条线

把这份材料从头到尾捋一遍,会发现 Redis 的每一个设计决策都落在同一条轴上:数据在内存里,所以快;内存会丢,所以要在持久化和复制上做补偿;补偿有代价,所以要让你自己选补偿到什么程度。RDB 和 AOF 的选择、appendfsync的三档、主从的异步复制、事务的不回滚,全都是这条轴上的刻度,没有绝对的对错,只有你愿意为“不丢”付多少延迟和复杂度。

我一般会这么理解:Redis 不是数据库,是一个“带持久化选项的缓存和数据结构服务器”。把它当数据库用,就得接受它在事务回滚、强一致、在线扩容上的先天不足;把它当缓存用,就别在它身上压不能丢的数据。材料里提到的“较难支持在线扩容”也是同一个逻辑——单机内存有上限,横向扩展要靠集群分片,而分片会带来跨槽操作、事务范围、批量命令的限制,这些成本在选型时就得算进去。

验证一个 Redis 部署是否健康,我习惯看三个数:latest_fork_usec看持久化对延迟的冲击,master_repl_offset与从节点偏移的差值看复制健康度,used_memory与maxmemory的比值看是否逼近淘汰阈值。这三个数稳,配置基本就没跑偏。至于具体参数怎么调,没有放之四海皆准的答案,得拿你自己的读写比例、能容忍的丢失窗口、磁盘 IO 能力去试。

血泪经验是:别等到线上卡顿了才去翻info命令的输出。从那以后我每次上线新实例,都强制先把info all的基线打一份存着,出问题时才有对照。这份分享材料把命令和配置列得很全,正好可以当自查清单用,配合object encoding、info persistence这些命令边看边验证,比死记参数有用得多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进