09-Redis 进阶原理篇:单线程、多线程、过期、LRU-LFU、Fork 与 Lua

Redis 的单线程被讲了太多年,讲法基本固定成一句「内存加单线程加 IO 多路复用」。这句话没错,但它解释不了一串更具体的问题。既然只有一个线程执行命令,它凭什么挂住几万个连接,为什么 6.0 又要加多线程,加了多线程之后慢命令为什么照样卡死整个实例,过期的 key 到底是谁在什么时候删掉的,内存满了 Redis 凭什么决定淘汰哪一个,fork 之后那几十 GB 内存到底复制没复制,Lua 脚本的原子性从哪来,Pipeline 和事务和 Lua 又该怎么选。

这篇把这几个问题挨个拆开。顺序有承接。先讲单线程的能力和它的边界,这个边界直接解释了 6.0 为什么要动网络 IO;然后看单线程里怎么回收过期 key,这是内存回收的第一条路径;过期解决不了内存满的问题,于是接上 LRU 和 LFU,看内存满的时候淘汰谁;淘汰和持久化都要碰内存,顺着往下讲 fork 和写时复制,也就是内存是怎么被「复制」的;再回到单线程,讲 Lua 怎么在同一个线程里保证一段逻辑不被打断;最后把 Pipeline、事务、Lua 摆在一起,因为这三样解决的都是「一次发很多命令」,但代价完全不同。

版本口径以 Redis 7.x 为准。本机没有装 redis-server 和 redis-cli,Docker 守护进程也没跑起来,所以下面不会出现任何终端回显。需要看返回形态的地方用引用框列关键字段,涉及耗时、内存、页数的地方只给量级和推导过程。文中命令和脚本都可以直接拿去跑。

Redis 单线程为什么还能支持高并发

先界定一下「单线程」指的是什么。它指的是命令执行那条路径,以及围绕它的网络事件处理。Redis 进程里从来不止一个线程,后台一直有 bio 线程在干 UNLINK 释放内存、AOF 刷盘这类活,6.0 之后又多了一组 IO 线程。这些后面都会讲到。这一章先把单线程这一侧讲透,看它凭什么扛住高并发。

Reactor,把 IO 就绪和业务处理拆开

Reactor 是一种事件驱动的服务端设计模式,技术底座是三样东西凑在一起,IO 多路复用、非阻塞 IO、事件循环。核心思路是把「等数据到」和「处理数据」分成两件事,一个线程只负责阻塞在多路复用函数上等事件,事件来了分发给对应的处理器。等待这件事,从每个连接各自的线程里被拿出来了。

Reactor 常见有三种形态,单 Reactor 单线程、单 Reactor 多线程、主从 Reactor 多线程。Netty 用的是后两种的组合,Redis 用的是第一种,一路走到今天。

单 Reactor 单线程具体长什么样。那个唯一的线程里跑一个事件循环,循环里管三类事情。新连接进来时,监听 fd 上产生可读事件,回调 acceptTcpHandler 把连接 accept 出来,fd 注册进事件循环,挂上读事件处理器 readQueryFromClient。连接上有数据可读时,readQueryFromClient 把数据读进查询缓冲区,解析出 RESP 命令,交给命令执行逻辑。命令执行完,回复塞进客户端输出缓冲区,等可写事件就绪,或者在下一轮 beforeSleep 里由 sendReplyToClient 写出去。

这里的关键是解耦。线程不会阻塞在 read 或者 write 上,它只阻塞在多路复用函数上,一次调用同时盯住几万个 fd。哪个 fd 有数据它才去读,哪个连接能写它才去写。所以在任意一个时刻,Redis 都只在处理一件事,但它不需要为「等待」这件事付线程的成本。

对比一下一个连接一个线程的阻塞式模型,就明白这个解耦省掉了什么。那种模型下每个连接一个线程,线程阻塞在 read 上,数据来了再处理。连接数一上去,线程数线性增长,每个线程要一份独立的栈空间,还要作为一个调度实体被内核管理,上下文切换时要保存恢复寄存器、切换地址空间映射。更麻烦的是,绝大多数线程的时间都花在等数据上,等于用一份昂贵的系统资源换一次等待。C10K 讲的就是这件事,一万个并发连接用一万个线程去扛,系统光调度就喘不上气。

Reactor 的答案是把这个「等」集中起来。

一个线程用一次 epoll_wait 代替一万次阻塞 read,剩下的按事件分发。并发连接数由多路复用承担,业务处理仍然串行,这两件事被拆开,互不干扰。Redis 的高并发能力就建立在这一步上。

epoll 这一层,只需要回扣

多路复用本身的演进在同系列的性能优化文章里展开过,这里只回扣一句。select 和 poll 每次返回都要 O(N) 遍历整个集合,epoll 把「监听哪些 fd」和「哪些 fd 就绪了」拆开,epoll_create 建实例,epoll_ctl 往内核的红黑树里增删改 fd,epoll_wait 只取就绪链表上的事件,监听一万个连接、十个活跃时,成本跟那十个有关,跟一万无关。

Redis 在 Linux 上用的就是 epoll,实现在 ae_epoll.c。它用的是 LT,水平触发,epoll_ctl 时没有设 EPOLLET。LT 的语义是只要 fd 上还有没读完的数据,下次 epoll_wait 还会告诉你,这正好匹配 Redis 每轮事件循环把可读 fd 处理一遍的节奏,也避开了漏读数据导致连接卡住这类难查的 bug。这些细节性能优化那篇讲过了,这里不重复,接着看事件循环本身是怎么组织的。

事件循环的骨架和它的顺序

事件循环实现在 ae.c,核心结构体是 aeEventLoop,里面每个字段都对应一个机制。events 是注册的事件数组,下标就是 fd,容量是 setsize,会随着最大连接数调整。fired 是本轮就绪的事件数组,aeApiPoll 返回后,就绪事件被填进来,循环再逐个分发。timeEventHead 是时间事件链表的头,serverCron 就挂在这条链上。beforesleep 和 aftersleep 是两个函数指针,分别在进入阻塞前和醒来后调用。还有一个 stop 标志,用来让循环退出。

aeMain 是主循环,逻辑简单到一行,只要 stop 是 0 就反复调 aeProcessEvents,每次带上 AE_ALL_EVENTS、AE_CALL_BEFORE_SLEEP、AE_CALL_AFTER_SLEEP 三个标志。

c 复制代码
/* ae.c 主循环的骨架,去掉退出处理 */
void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        /* 要求处理所有类型事件,并在阻塞前后各调一次钩子 */
        aeProcessEvents(eventLoop,
            AE_ALL_EVENTS | AE_CALL_BEFORE_SLEEP | AE_CALL_AFTER_SLEEP);
    }
}

真正的顺序在 aeProcessEvents 里。它先看有没有事情可做,既没有文件事件也没有时间事件就直接返回。然后找最近的一个时间事件,用它的到期时间算出 aeApiPoll 最多能睡多久,这样 serverCron 不会因为阻塞而迟到。如果标志里带了 AE_DONT_WAIT,超时会被强制设成 0,也就是不阻塞、只取一遍就绪事件。带 AE_CALL_BEFORE_SLEEP 时先调 beforesleep,然后才是真正阻塞的 aeApiPoll,醒来后带 AE_CALL_AFTER_SLEEP 调 aftersleep。

关键在后面的顺序,就绪的文件事件先处理,处理完再处理到期的时间事件。这个顺序不是随手定的。文件事件代表有客户端在等着,优先处理能降低延迟;时间事件是内部维护任务,晚一点点可以接受。AE_DONT_WAIT 用在哪里也说明问题,脚本执行期间需要处理一部分事件、TLS 还有没读完的数据,这些场景都要求「别睡,赶紧转一圈」,于是用这个标志强制不阻塞。

beforeSleep 里到底干了什么

beforeSleep 是这一章最值得细看的地方,因为它把「不依赖新事件、但必须做」的事情集中在一个点上,而它跑在即将阻塞等待之前,那段时间进程本来也闲着。

它做的事情大致是这些。处理阻塞客户端的超时,把超时的阻塞请求按规则结束掉。用 IO 线程处理待读客户端,也就是 6.0 之后的多线程读路径。跑一次快速过期循环 activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST),前提是当前节点是主库。处理等 ACK 的 WAIT 客户端。处理 unblocked_clients 队列里那些刚从阻塞状态恢复的客户端。把 AOF 缓冲区刷到磁盘,appendfsync everysec 的触发点之一就在这条路径上。把这一轮攒下来的回复真正写到 socket 上,也就是 handleClientsWithPendingWrites。清理异步关闭队列里待释放的客户端。如果 ready_keys 非空,还要处理那些因为 key 就绪而被唤醒的阻塞客户端,比如一个 BLPOP 等的 list 终于被 LPUSH 填上了,handleClientsBlockedOnKeys 就负责把它们放出去。

为什么放在这里,理由很实在。阻塞等待期间进程什么都不干,正好把不依赖新事件的活干完,等下一次 aeApiPoll 醒来,手里就是一个干净状态。

serverCron 与 hz

时间事件里最重要的是 serverCron。它负责过期 key 的清理、统计数据的更新、客户端超时检查、内存淘汰的相关检查、字典 rehash 的推进等等。频率由 hz 控制,默认是 10,也就是每 100 毫秒跑一次。它跑在主线程上,花的每一毫秒都算在主线程头上,这一点是后面讲过期策略和淘汰抖动的地基。

7.0 的配置文件里 dynamic-hz 默认是 yes。它的意思是 hz 作为基线,连接数变多的时候,实际使用的频率会按基线的倍数临时提高,空闲实例保持低频省 CPU,繁忙实例响应更及时。hz 本身的可调范围是 1 到 500,官方注释里建议不要超过 100,只有极低延迟要求的环境才往上调。调大之后定时任务更准时,代价是更多的系统调用和更碎的 CPU 占用。

前面提到 aeApiPoll 的超时来自最近的时间事件,这一条把两边连起来了。时间事件表里下一个任务的到期时间决定了这一轮最多睡多久,所以哪怕实例完全空闲,serverCron 也会按时醒过来,这也是一个没有流量的 Redis 仍然持续消耗一点 CPU 的原因。

单线程的边界

讲了这么多机制,得回到那个原始问题,命令执行为什么必须单线程。

原子性是第一层理由。只有一个线程在改数据,任何一条命令在执行过程中都不会被别的请求打断,INCR、LPUSH、SETNX 天然原子,Lua 脚本和 MULTI 事务的原子性也建立在这个前提上。数据结构不用考虑并发是第二层。字典不需要读写锁,跳表不需要做无锁改造,redisObject 的引用计数除了在 bio 线程那条路径上要小心,其余时候都不用担心竞态。缓存友好是第三层,单线程顺序访问数据,CPU 缓存命中率高,多线程改相邻内存地址会让缓存行反复失效,也就是伪共享。

代价同样清楚。

一个慢命令阻塞全部,事件循环只有一条线,KEYS * 在一个几百万 key 的实例上跑几秒,这几秒里所有客户端都在排队。另一面是机器再多核也用不上,三十二核的机器上 Redis 只用一个核执行命令,剩下的核最多被 IO 线程和 bio 线程分掉一点。应对方式不是改 Redis,是横向拆,一台机器起多个实例,或者用 Cluster 分片。

那「高并发」是怎么来的。得把并发连接数和并发处理拆开看。并发连接数由 IO 多路复用承担,几万个连接挂在 epoll 上,成本跟活跃数量有关,跟总数关系不大。命令执行是串行的,同一时刻只有一条命令在跑,这一点从 6.0 到现在没有变过。所以单实例的 QPS 上限约等于 1 除以单次命令的耗时,一次命令 10 微秒量级,对应的就是十万量级,跟机器有多少核无关。

这里要停下来问一句。如果命令执行是串行队列,而队列本身又不是瓶颈,那瓶颈去哪了?顺着这个问题往下走,就到了 6.0 引入多线程的地方。

Redis 6.0 为什么引入多线程

先分清哪部分还是单线程

讲 6.0 之前必须先把这件事钉死,否则后面每一句都会被误读。6.0 之后,命令执行仍然是单线程 ,而且 7.0、7.2 到今天的版本都没有变过。执行命令、执行 Lua 脚本、处理过期删除、执行内存淘汰、跑 serverCron,全都还在那个叫 main thread 的主线程上。

变化的只有一处,网络 IO 的读写和 RESP 协议解析。6.0 之前,从 socket 读字节、把字节解析成命令、把回复序列化、写回 socket,这些事全在主线程做。6.0 之后,这部分可以分给一组 IO 线程并行做。所以你听到「Redis 6.0 支持多线程了」,它指的是网络 IO 多线程,不是命令执行多线程。这个区分是这一章的骨架,先记住它,后面所有细节都是在这条线上展开的。

瓶颈是怎么转移的

为什么要在 6.0 动网络 IO,得看那条串行队列的两头发生了什么。

机器这一侧在变。网卡从 1Gb 到 10Gb,再到 25Gb,单位时间里要搬的字节数涨了一个量级。命令那一侧基本没变,GET、SET 这类命令本身执行很快,取一次字典、改一个字段,微秒量级。夹在中间的是 IO,一次请求要走一遍 read 系统调用把字节读进来、解析 RESP 协议、执行、序列化回复、write 系统调用写出去。当命令执行只占整条链路的一小部分时,剩下的时间都在搬字节和进内核。

再叠上多核这个背景就更明显。一台三十二核的机器,命令执行只用一核,网络 IO 也挤在这一核上,其余三十一核在 Redis 这条路径上帮不上忙。于是瓶颈从「命令执行」转移到了「网络 IO 与协议解析」。6.0 引入多线程,动的是后半段,因为前半段的串行是设计上要保的东西。

这里要克制一下,不给精确数字。不同命令、不同 value 大小、不同网卡,IO 与执行的时间占比差别很大,任何「IO 占百分之几十」的说法都只在特定场景成立。能确定的是方向,value 越大、QPS 越高,IO 占比越高,多线程的收益越明显。

IO Thread 怎么工作

两个配置项决定这件事。io-threads 默认是 1,也就是关闭多线程,用主线程干所有 IO。官方注释里建议这个值不超过 8,还建议留至少一个空闲核,理由是超过 8 个线程收益就不明显了。io-threads-do-reads 默认是 no,控制读和协议解析要不要也走多线程。两个参数都不能用 CONFIG SET 在运行时改,改了要重启,而且开了 SSL 的情况下这套机制不生效。

工作流程是标准的三段式,读并行、执行串行、写并行。

读这一段,事件循环收就绪的 fd,主线程把这些客户端按轮转的方式分配给各个 IO 线程,编号 0 的那个是主线程自己,也会分到一份任务。IO 线程做的事只有一件,读 socket、把数据放进查询缓冲区、解析出第一条完整命令,然后停手。它不会去执行命令,也不碰任何数据结构。分配完成后主线程忙轮询等所有 IO 线程的计数归零,确认这一批请求都读完了。

执行这一段完全回到单线程。主线程按顺序遍历刚才那批客户端,把解析好的命令一条条执行掉,回复写进各自的输出缓冲区。这一段就是原来的串行队列,一条命令执行期间,别的命令还是在等。

写这一段又并行起来。待写出的客户端同样按轮转分给 IO 线程和主线程,IO 线程调用 writeToClient 把缓冲区里的字节写进 socket。写完主线程再扫一遍队列,谁还有残留数据没写完,就给它注册一个可写事件处理器 sendReplyToClient,等下次 socket 可写再补。
IO 线程 2 IO 线程 1 主线程 客户端 IO 线程 2 IO 线程 1 主线程 客户端 #mermaid-svg-DyNY0EaHHh8rNnaZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DyNY0EaHHh8rNnaZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DyNY0EaHHh8rNnaZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DyNY0EaHHh8rNnaZ .error-icon{fill:#552222;}#mermaid-svg-DyNY0EaHHh8rNnaZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DyNY0EaHHh8rNnaZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DyNY0EaHHh8rNnaZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DyNY0EaHHh8rNnaZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DyNY0EaHHh8rNnaZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DyNY0EaHHh8rNnaZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DyNY0EaHHh8rNnaZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DyNY0EaHHh8rNnaZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DyNY0EaHHh8rNnaZ .marker.cross{stroke:#333333;}#mermaid-svg-DyNY0EaHHh8rNnaZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DyNY0EaHHh8rNnaZ p{margin:0;}#mermaid-svg-DyNY0EaHHh8rNnaZ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-DyNY0EaHHh8rNnaZ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-DyNY0EaHHh8rNnaZ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-DyNY0EaHHh8rNnaZ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-DyNY0EaHHh8rNnaZ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-DyNY0EaHHh8rNnaZ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-DyNY0EaHHh8rNnaZ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-DyNY0EaHHh8rNnaZ .sequenceNumber{fill:white;}#mermaid-svg-DyNY0EaHHh8rNnaZ #sequencenumber{fill:#333;}#mermaid-svg-DyNY0EaHHh8rNnaZ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-DyNY0EaHHh8rNnaZ .messageText{fill:#333;stroke:none;}#mermaid-svg-DyNY0EaHHh8rNnaZ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-DyNY0EaHHh8rNnaZ .labelText,#mermaid-svg-DyNY0EaHHh8rNnaZ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-DyNY0EaHHh8rNnaZ .loopText,#mermaid-svg-DyNY0EaHHh8rNnaZ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-DyNY0EaHHh8rNnaZ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-DyNY0EaHHh8rNnaZ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-DyNY0EaHHh8rNnaZ .noteText,#mermaid-svg-DyNY0EaHHh8rNnaZ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-DyNY0EaHHh8rNnaZ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-DyNY0EaHHh8rNnaZ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-DyNY0EaHHh8rNnaZ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-DyNY0EaHHh8rNnaZ .actorPopupMenu{position:absolute;}#mermaid-svg-DyNY0EaHHh8rNnaZ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-DyNY0EaHHh8rNnaZ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-DyNY0EaHHh8rNnaZ .actor-man circle,#mermaid-svg-DyNY0EaHHh8rNnaZ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-DyNY0EaHHh8rNnaZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 多个连接的数据到达aeApiPoll 取出就绪 fd轮转分配一批 fd轮转分配另一批 fd读 socket 并解析出命令读 socket 并解析出命令主线程串行执行全部命令轮转分配一批待写客户端轮转分配另一批待写客户端writeToClient 写回 socketwriteToClient 写回 socket残留未写完的注册可写事件

这套协作是刻意做成无锁的。主线程和 IO 线程之间只共享三个东西,io_threads_pending 这个原子计数器、io_threads_op 表示当前是读还是写、每个 IO 线程自己的本地任务队列。计数器本身是原子的不用锁;任务队列和操作类型靠「交错访问」避免竞争,主线程先把任务全部塞进各个队列、再设好操作类型、然后才唤醒 IO 线程,而 IO 线程在醒来之前不碰自己的队列,醒来之后主线程只访问自己那份队列。IO 线程空闲时先忙轮询一百万次等任务,还没等到才退化成加锁休眠。

还有个容易被忽略的细节,任务量不足时会自动退化。待写出的客户端数量不到 IO 线程数的两倍,Redis 就直接让主线程同步写完,不走多线程路径,因为这时候唤醒和同步的开销比并行省下的时间还多。

收益与代价

收益的方向很清楚。value 大、并发高、命令本身很短的时候,IO 和协议解析占比高,把这一段并行出去,吞吐能有明显提升。官方在 6.0 发布时给过吞吐提升的说法,但具体倍数和场景强相关,别人的压测数字搬到自己机器上经常对不上,这一点得自己压。

代价也要说清。复杂度上去了,无锁协作、忙轮询、退化逻辑,都是额外的代码路径,出问题时排查难度比纯单线程高。io-threads 设太大反而更慢,因为线程多了同步和调度的开销也跟着涨,收益会被吃掉。更重要的是,不是所有场景都有收益。小 value、低并发、命令本身就不短的场景,IO 占比本来就低,开多线程可能只带来噪声。调这个参数只有一条路,压测,从 2 到 4 到 6 逐个试,看吞吐和 P99 一起动。

还有一件事要明确,多线程不改变命令的原子性。命令执行还在单线程,单条命令执行期间不会被打断,Lua 脚本整体执行期间不会被打断,MULTI 事务的语义也没变。IO 线程只搬字节,不碰数据,所以它不会让两个命令并发修改同一个 key。这一点很关键,很多人一听多线程就担心数据结构要不要加锁,答案是不要。

7.0 之后的演进

7.0 之后,IO 线程负责的范围在慢慢扩大。除了读写 socket,一部分客户端输出缓冲的处理、连接关闭这类收尾动作,也有版本把它挪到了 IO 线程或者改成异步完成,shutdown 路径也做过改进。这些边界在版本之间还在动,具体哪一版做了什么,我也不敢说得太死,看源码或者发布说明更靠谱。

能确定的是一条主线,多线程的边界在逐步扩大,但命令执行始终单线程 。这也解释了为什么慢命令的问题一直没被多线程解决。一个 KEYS * 卡住的是命令执行那一段,IO 线程再快也只能在旁边等着。慢命令要靠规范、SLOWLOG 和 LATENCY 去治,这是性能优化那篇的活儿。

Redis 过期 Key 删除策略

命令执行单线程,那过期的 key 是谁删的。这个问题看起来小,实际上是内存回收的第一条路径,而且它的行为直接决定线上内存曲线的形状。

为什么不立即删除

最直觉的方案是,key 一到期就删掉。这个方案做不到,原因有两层。

一层是找不到它。Redis 的过期时间存在每个 db 的 expires 字典里,key 和到期时间戳对应。要知道哪些 key 到期了,得遍历这个字典,几百万 key 就是几百万次比较,这个成本不可能挂在每次读写上。另一层是就算找到了也不划算。真正的删除动作要释放对象、从字典里摘掉、维护各种元数据,一次读写命令为了删一个过期 key 多背这些开销,得不偿失。

所以 Redis 选了组合方案,惰性删除加定期删除。惰性负责「被访问到的过期 key 一定不返回」,定期负责「没人访问的过期 key 慢慢清掉」。这两条路径分工明确,合起来才是一个完整的策略。

惰性删除

惰性删除的入口是 expireIfNeeded,它被调用的位置在命令执行路径上,读 key 之前、写 key 之前都会走一遍。发现 key 已经过期就删掉,然后命令看到的是「key 不存在」。

删完之后还有一步,传播。主库删掉一个过期 key,要保证从库和 AOF 也知道这件事,所以会把删除动作传播下去。小 key 用 DEL,大 key 可能走 UNLINK,具体取决于 lazyfree-lazy-expire 的配置。这一步不做的话,从库和主库的数据就分叉了。

惰性删除的优点是成本低,只有被访问的 key 才做判断,没被访问的 key 一次判断都不做。缺点也很直白,没人访问的过期 key 会一直占着内存。一个业务已经下线、再也没人读的 key,设了 TTL 也没用,它不会自己消失,只能等定期删除捞到它。

所以还需要第二条路径。

顺带说一个 7.0 之后好用的细节。TTL 本身可以带条件修改,EXPIRE key seconds NX 只在 key 还没有 TTL 时设置,XX 只在已有 TTL 时设置,GT 和 LT 分别表示只在新的到期时间更晚或者更早时才生效。这几个选项让「续期」这类逻辑不用先读 TTL 再写回去,省掉了一次读判断写,也省掉了它带来的竞态。想直接读到期时间用 EXPIRETIME 和 PEXPIRETIME,前者返回秒,后者返回毫秒,比 TTL 少一层换算。这些命令都是 7.0 加的,老版本上没有。

定期删除 activeExpireCycle

定期删除的实现在 activeExpireCycle 里,触发点有两个,一个是 serverCron,一个是 beforeSleep。两个触发点对应两种模式,这个设计是理解它的关键。

ACTIVE_EXPIRE_CYCLE_SLOW 模式在 serverCron 里跑,也就是默认每 100 毫秒一次。它的时间上限由 hz 推导出来,源码里的时间占比常量是 25%,也就是说一个周期里最多拿出四分之一的时间做过期清理,按默认 hz 10 算就是 25 毫秒的量级。配置注释里的说法是,默认 effort 会尽量让内存里残留的过期 key 不超过 10%,同时避免引入额外延迟。

ACTIVE_EXPIRE_CYCLE_FAST 模式在 beforeSleep 里跑,跑得更频繁,但条件更苛刻、时间更短。它只在没有慢客户端等干扰条件时触发,而且时间上限是一个很短的常量(ACTIVE_EXPIRE_CYCLE_FAST_DURATION,微秒量级),两次 fast 之间还有最小间隔,避免把事件循环的每一轮都拖慢。它存在的意义是让过期清理在请求间隙里插空做,而不是全压在 serverCron 那一下。

每一轮具体怎么删,是采样加判断的循环。从每个 db 的 expires 字典里随机采样 ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP 个 key,这个常量是 20。检查这 20 个里有多少已经过期,如果过期比例超过 ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE,也就是 10%,说明这个 db 里过期 key 还很多,继续再采一轮;低于这个比例就认为清理得差不多了,退出这一轮。整个过程还有一个总时间上限,超了立刻停。

三重控制是这套机制的核心。采样数量控制了单轮的粒度,过期比例阈值控制了循环什么时候停,时间上限控制了最坏情况下的阻塞时长。所以哪怕一个实例里堆了几百万个过期 key,activeExpireCycle 也不会一次把它们删完,它是一轮一轮、一小片一小片地推进,把成本摊到很多个事件循环里。

7.0 把这件事做得更可调。它引入了 active-expire-effort 配置,默认是 1,可以调到 10。调大的意思是更激进地清理,用更多 CPU 换更少的内存残留和更长的清理周期。配置文件里的原话大意是,默认 effort 会尽量让已过期的 key 残留不超过 10%,调到最大值则是拿 CPU 和延迟换内存。另外 7.0 对 activeExpireCycle 内部的循环控制和 effort 的换算做过调整,细节我建议直接看 7.0 的源码,这里只讲方向,更可调、更精细。

从库的过期处理

从库的过期行为跟主库不一样,这一点跟主从复制的一致性有关。

从库不主动删过期 key。它必须等主库发来 DEL 或者 UNLINK 才删,否则主库那边 key 还在,从库先删了,两边数据就不一致了。所以从库的内存里可能存在大量逻辑上已经过期的 key,它们占着内存,等着主库的通知。

但读的时候不能返回这些 key。从 3.2 开始,从库读到逻辑上已过期的 key 会当成不存在,返回空。这个处理保证了客户端不会读到本该过期的数据,同时又不动数据本身。所以从库上的「过期」分了两层,逻辑上过期和物理上删除是分开的。

还有一条相关的设置。从 5.0 起,从库默认忽略自己的 maxmemory 设置,也就是 replica-ignore-maxmemory yes。淘汰由主库决定,主库淘汰一个 key 就把 DEL 同步给从库。这样主从的内存内容保持一致,代价是从库实际占用的内存可能比配置的上限高,得单独盯着。

lazyfree 与过期

过期的删除动作本身也可能很贵。一个大 key 到期,比如一个几百万字段的哈希,释放它的内存要遍历并释放所有对象,这些活都在主线程做,卡住的时间跟 key 大小成正比。

lazyfree-lazy-expire 就是为这个场景准备的。它设成 yes 之后,过期删除走异步路径,主线程把 key 从字典里摘掉就返回,真正的内存释放交给后台 bio 线程。同一组配置里还有 lazyfree-lazy-eviction 管淘汰释放,lazyfree-lazy-server-del 管命令副作用导致的删除,replica-lazy-flush 管从库全量同步前的清库。

这几个默认都是 no,理由是异步释放会让内存回收的时机变得不可预测,压测时看到的 used_memory 曲线会滞后。但如果你的实例里确实有大 key,把它们打开是值得的,代价是内存峰值可能高一点。

观测与调参

过期清理的状态可以从 INFO stats 里看。expired_keys 是累计删除的过期 key 数量,看增长速率比看总量有用。expired_stale_perc 是采样里过期 key 的比例,这个值长期偏高说明清理跟不上写入。expired_time_cap_reached_count 是「本轮清理因为到达时间上限而提前退出」的次数,它开始涨,就说明过期压力已经超过当前 effort 能处理的量了。

LATENCY 里还有一个 expire-cycle 事件,能把单次过期循环的耗时暴露出来。慢日志看不到它,因为它不是某条命令慢,但客户端确实被它挡过。

调参的旋钮有三个。hz 调大,serverCron 跑得更勤,定期删除更及时,代价是更多 CPU 和更碎的占用。active-expire-effort 调大,每轮清理更激进,同样吃 CPU。maxmemory 和过期速率的关系也要看,内存越紧,过期 key 占着的空间越致命,因为淘汰会提前触发。

一个实践推论

把上面的机制合起来推一步,就能得到一条很实用的结论。大量 key 在同一时刻过期,是线上最容易踩的坑之一。

设想一批缓存统一在凌晨写入,统一设 24 小时过期,第二天凌晨它们会同时到期。activeExpireCycle 的采样里过期比例一下冲到很高,于是它反复触发、反复循环,CPU 出现抖动;expired_time_cap_reached_count 开始涨;同时业务侧这些 key 全部 miss,请求一起回源,对数据库形成一次集中冲击。这跟缓存雪崩是同一个问题的两个侧面。

解法很简单,给 TTL 加随机抖动。基础 TTL 3600 秒,就加上 0 到 300 秒的随机值,把到期时间摊开到一个窗口里。这一行代码能省掉后面很多排查。

Redis LRU 与 LFU 原理

过期管的是「key 该不该消失」,淘汰管的是「内存满了删谁」。这两个问题看起来像,实际上独立,一个 key 可以没过期但被淘汰,也可以过期了但还没被删。这一章讲淘汰的依据是怎么算出来的。

为什么不做真正的 LRU

教科书上的 LRU 是一条双向链表,每次访问把节点挪到头部,淘汰时取尾部的节点。这个方案在 Redis 里不成立。

链表本身要占内存,每个 key 多两个指针,几百万 key 就是几十 MB 的额外开销。更贵的是维护动作,每次读写都要把节点从链表中间摘下来再挂到头部,这是 O(1) 但常数不小的操作,而它发生在每一条命令的路径上。Redis 命令执行是单线程的,这个成本会直接变成 QPS 的下降。

所以 Redis 用的是近似算法。每个对象里有一个 24 位的 lru 字段,访问时把当前时间写进去,淘汰时随机采样若干个 key,挑里面最久没被访问的那个。用一点点精度换掉了整条链表的开销。

Approximate LRU 的做法

lru 字段是 24 位,在 LRU 模式下存的是一个秒级的时钟值。时钟的精度由 LRU_CLOCK_RESOLUTION 决定,值是 1000 毫秒,也就是一秒。所以 Redis 记录的「最后一次访问时间」只有秒级精度,同一秒内的多次访问在它眼里是一样的。24 位装秒数,大概 194 天会回绕一圈,回绕本身不影响正确性,因为比较用的是相对差值。

淘汰时的做法是采样。maxmemory-samples 决定一次采样抽多少个 key,默认是 5。抽出来之后算每个 key 的空闲时间,也就是当前时钟减去它 lru 里存的值,挑空闲最长的那个淘汰。这个动作挂在写命令的路径上,内存到上限时,每次需要分配内存的写命令前都会先腾出空间。

Sampling 的效果

采样数直接决定近似程度。官方配置注释给了三条经验,默认的 5 已经够用,10 非常接近真正的 LRU 但更耗 CPU,3 更快但不准。这个取舍很直白,采样越多越准,也越慢。

为什么 5 个就够用,直觉是这样。假设大多数 key 都是冷的,只有少数几个是热点,那随机抽 5 个,大概率抽到的都是冷的,挑出来的那个也就够冷了。它不保证找到全局最老的那个,但它避开了「恰好淘汰掉一个热点」这种最坏情况。相比之下纯随机淘汰命中热点的概率等于热点在 key 总量里的占比,热点越稀有越容易误伤。

3.0 之后还加了一个淘汰候选池。每次采样到的较老 key 会被放进一个池子里,池子容量是 16,淘汰时从池子里挑最老的那个,而不是只看这一轮采样的结果。这样多轮采样的信息会累积下来,效果比单轮采样明显更好,接近真 LRU 所需的采样数也降下来了。

7.0 还加了一个 maxmemory-eviction-tenacity 参数,默认 10,用来控制淘汰处理的「韧性」。写流量特别大的时候可以调高,让淘汰更果断;反过来调低能减少延迟抖动,代价是淘汰效率下降。这个参数是 7.0 之后才有的,老版本上没有。

LFU 的计数器和衰减

LFU 是 4.0 引入的。它复用了同一个 24 位字段,但拆成两半用,高 16 位存最后一次衰减的时间(分钟级),低 8 位存访问计数器。

计数器不是每访问一次加一。它的增长是概率性的,LFULogIncr 算出的概率 p 等于 1 除以「baseval 乘以 lfu-log-factor 再加 1」,其中 baseval 是当前计数器减去 LFU_INIT_VAL。每访问一次,以这个概率决定加不加一。计数器越大,baseval 越大,p 越小,加一就越难。所以计数器的增长是对数的,前期涨得快,后期几乎不动,上限是 255。

lfu-log-factor 默认是 10,调大就让增长更慢,更看重长期热度。这个设计的意图是防止一个曾经被访问几百万次的 key 把计数器顶到 255,让后来者永远追不上。

计数器还会衰减。lfu-decay-time 默认是 1,单位是分钟,表示每过一分钟没有访问,计数就减一。这个衰减是惰性计算的,只有读这个对象的时候才算一次,算法是拿当前时间和存的衰减时间算出经过了多少个周期,然后一次性减掉。这样既省掉了后台遍历的开销,又保证读到的计数是衰减后的值。

新 key 的初始计数是 LFU_INIT_VAL,值是 5,不是 0。这个设计很关键。如果新 key 从 0 开始,它会在下一轮淘汰里立刻被赶出去,哪怕它马上就要变成热点。给一个初始值,相当于给新 key 一点观察期。

OBJECT FREQ 与 OBJECT IDLETIME

查看这两个指标用两条命令。

redis 复制代码
# LFU 策略下看访问频率计数器
OBJECT FREQ user:1001:profile

# LRU 策略下看空闲了多少秒
OBJECT IDLETIME user:1001:profile

它们互斥,取决于当前的 maxmemory-policy。策略是 LFU 系列时,OBJECT IDLETIME 会报错,因为那个字段被拆开存计数器了;策略不是 LFU 时,OBJECT FREQ 会报错,因为没有记录频率。这两个字段共用同一块空间,一次只能有一种语义。

LRU 和 LFU 的适用差别

LRU 看的是「最近有没有被访问」,适合近期访问最重要的场景,比如刚上线的活动页、不断刷新的话题列表。LFU 看的是「被访问了多少次」,适合热点长期稳定的场景,比如一个常年被读的商品详情、一份不常改的配置。

LRU 有一个很典型的弱点,偶发的批量扫描会污染它 。你跑一次 --bigkeys 或者自己写的全库扫描脚本,或者做一次全量预热,这些操作会把大量冷 key 的 lru 字段刷成当前时间。扫描完之后,真正热点的空闲时间反而比那些被扫过的冷 key 长,淘汰的时候热点就先走了。这个坑不太好复现,因为它的表现是「扫描之后一段时间命中率下降」,很容易被归因到别处。

LFU 对这个问题鲁棒得多。一次扫描只会让每个 key 的计数器以概率加一,冷 key 本来就低,加一次也还是低;热点的计数器已经很高,扫描不会让它变低。所以如果你的实例上有周期性全库扫描,LFU 更稳。

但 LFU 也有自己的坑,而且这个坑更隐蔽。写命令同样会刷新访问信息 。ZADD、HSET 这类写操作在查找 key 的时候会走更新逻辑,把 lru 字段刷新成当前时间,LFU 模式下计数器也会被加。所以一个只写不读的 key,在 LFU 眼里也是「有用」的。而 SET 更彻底,它会替换掉整个对象,新对象的计数器从 LFU_INIT_VAL 开始,等于清零重来。如果你用 SET 做覆盖写,那个 key 的 LFU 历史就没了。

还有一条限制,计数器只有 8 位,上限 255。当一个实例里很多 key 的计数器都到了 255,LFU 就区分不出它们了,这时候它退化成随机淘汰。热点特别集中的场景要留意这一点。

配置建议

maxmemory-samples 设 5 到 10 是常见选择。小于 5 省 CPU 但精度差,大于 10 收益递减,CPU 成本反而明显。

LFU 的两个参数按业务调。热点变化慢、希望长期热度占主导,就把 lfu-log-factor 调大,让计数器涨得更慢。热点切换快、希望老热点尽快掉下去,就把 lfu-decay-time 调小,让衰减更快。

拿不准的时候先用 LRU。它的行为更好预测,出问题也更容易解释。等确认了访问分布确实长期稳定,再换 LFU。内存淘汰的八种策略在性能优化那篇里逐条过过,这里只补充了 LRU 和 LFU 内部的算法细节。

再往回接一句过期那章的内容。volatile-* 系列的淘汰只在设了 TTL 的 key 里采样,如果实例里没有 TTL 的 key 占满了内存,采样池就是空的,淘汰退化成不淘汰,写命令直接报错。所以用 volatile-* 的前提是缓存 key 都设了 TTL,而且这个前提要在监控里确认,不然它会在某个不起眼的时刻失效。

Redis Fork 和 Copy-On-Write 原理

淘汰是内存满了删数据,持久化是另一条路,把数据写出去。RDB 快照和 AOF 重写都要 fork 一个子进程,而 fork 这件事在几十 GB 的实例上是有代价的。这一章讲清楚代价具体是什么。

fork 复制的是页表

先纠正一个常见误解。fork() 不会把父进程的内存复制一份。它复制的是页表,也就是虚拟地址到物理地址的映射关系。复制完之后,子进程和父进程指向同一批物理页,这些页的页表项被标记成只读。

fork() 的调用方式也很特别,它调用一次返回两次。父进程里返回子进程的 pid,子进程里返回 0,两边的代码从同一个点继续往下跑,靠返回值区分身份。Redis 就是靠这个来让子进程去写 RDB 文件、父进程继续处理请求。

成本有两块。一块是复制页表本身的时间和内存。Linux 的页大小是 4KB,每个页表项大约 8 字节,一个 16GB 的实例大约是 400 万个页,对应页表大约 32MB 量级。这个数字要按内存规模线性放大,32GB 实例就是 64MB 量级。跟数据本身比起来它很小,但它是一次性分配,而且在 fork 那一刻完成。

另一块是阻塞。fork 期间父进程被挂住,什么命令都执行不了。阻塞时长跟页表大小、内存带宽、是否开着透明大页都有关系,十几 GB 的实例上落在几十毫秒到上百毫秒的量级。这个数字不用记死,但要知道它存在,而且要监控。INFO stats 里的 latest_fork_usec 就是最近一次 fork 的耗时,单位微秒。

Page Table 为什么便宜

页表的作用是让进程用虚拟地址访问内存,每次访问由硬件做一次地址翻译。因为是稀疏的地址空间,页表被设计成多级结构,用不到的地址段不占表项。

理解页表为什么便宜,看比例就够了。16GB 的数据需要 32MB 的页表,比例大概是五百分之一。fork 复制的是这五百分之一,而不是全部数据。真正共享的物理内存一页都没动,只是把映射复制了一份,然后把所有共享页标成只读。

这就是 fork 成为写时复制典型应用的原因。大多数场景下,子进程只是读一遍数据,父进程的修改量有限,所以「先共享、改的时候再复制」的策略让 fork 的实际开销远小于「复制整个数据集」。如果没有写时复制,一个 16GB 的实例每做一次快照就要真复制 16GB,这显然不可能。

COW 的过程

写时复制发生在有人要写共享页的时候。任一方对只读的共享页发起写操作,CPU 触发缺页中断,内核接管,分配一个新的物理页,把原页内容复制过去,让写入方指向新页。另一方仍然指向旧页,看到的是 fork 那一刻的内容。
子进程 RDB 内核 父进程 Redis 子进程 RDB 内核 父进程 Redis #mermaid-svg-2CcEeiOSVXoRVk51{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2CcEeiOSVXoRVk51 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2CcEeiOSVXoRVk51 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2CcEeiOSVXoRVk51 .error-icon{fill:#552222;}#mermaid-svg-2CcEeiOSVXoRVk51 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2CcEeiOSVXoRVk51 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2CcEeiOSVXoRVk51 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2CcEeiOSVXoRVk51 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2CcEeiOSVXoRVk51 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2CcEeiOSVXoRVk51 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2CcEeiOSVXoRVk51 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2CcEeiOSVXoRVk51 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2CcEeiOSVXoRVk51 .marker.cross{stroke:#333333;}#mermaid-svg-2CcEeiOSVXoRVk51 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2CcEeiOSVXoRVk51 p{margin:0;}#mermaid-svg-2CcEeiOSVXoRVk51 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2CcEeiOSVXoRVk51 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-2CcEeiOSVXoRVk51 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-2CcEeiOSVXoRVk51 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-2CcEeiOSVXoRVk51 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-2CcEeiOSVXoRVk51 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-2CcEeiOSVXoRVk51 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-2CcEeiOSVXoRVk51 .sequenceNumber{fill:white;}#mermaid-svg-2CcEeiOSVXoRVk51 #sequencenumber{fill:#333;}#mermaid-svg-2CcEeiOSVXoRVk51 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-2CcEeiOSVXoRVk51 .messageText{fill:#333;stroke:none;}#mermaid-svg-2CcEeiOSVXoRVk51 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2CcEeiOSVXoRVk51 .labelText,#mermaid-svg-2CcEeiOSVXoRVk51 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-2CcEeiOSVXoRVk51 .loopText,#mermaid-svg-2CcEeiOSVXoRVk51 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-2CcEeiOSVXoRVk51 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-2CcEeiOSVXoRVk51 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-2CcEeiOSVXoRVk51 .noteText,#mermaid-svg-2CcEeiOSVXoRVk51 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-2CcEeiOSVXoRVk51 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2CcEeiOSVXoRVk51 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2CcEeiOSVXoRVk51 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2CcEeiOSVXoRVk51 .actorPopupMenu{position:absolute;}#mermaid-svg-2CcEeiOSVXoRVk51 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-2CcEeiOSVXoRVk51 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2CcEeiOSVXoRVk51 .actor-man circle,#mermaid-svg-2CcEeiOSVXoRVk51 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-2CcEeiOSVXoRVk51 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 父进程要写某个共享页 调用 fork复制页表,物理页保持共享且只读返回子进程 pid,父进程继续处理命令返回 0,子进程开始遍历写只读页,触发缺页中断分配新页并复制内容父进程改指向新页子进程读同一页仍返回 fork 时刻的旧页

这套机制带来一个很重要的保证。子进程遍历时看到的,是 fork 那一刻的逻辑快照,跟父进程之后的修改无关。所以 RDB 文件里不会出现「一半旧数据一半新数据」的中间态,AOF 重写生成的也是同一时刻的一致视图。这个保证是 fork 方案最核心的价值。

代价是内存放大。父进程写得越多,被复制的页越多,最坏情况下所有页都被写过,额外内存接近原数据量。观测指标是 INFO persistence 里的 rdb_last_cow_size 和 aof_last_cow_size,分别是最近一次 RDB 和 AOF 重写期间因为写时复制而额外分配的字节数。这两个数一涨,就说明 fork 期间的写入很猛。

还有个细节值得知道。复制的粒度是页,4KB。改一个字节也可能复制一整页。所以修改分散在大批不同 key 上的写入,比集中修改少数几个 key 更容易放大内存,因为前者触发的页更多。

大内存 Redis 的 Fork 风险

把上面几条合起来,大内存实例的 fork 风险就清楚了。

fork 本身阻塞主线程,页表越大阻塞越久。十几 GB 的实例上这个阻塞是几十毫秒量级,已经足够让客户端的 P99 难看。几十 GB 的实例上更久,如果还开着透明大页,情况会更糟。

写时复制带来的内存放大可能导致 OOM。最坏情况接近翻倍,而 Linux 的 OOM killer 可不管你在干什么,它挑内存占用最大的进程下手,通常就是 Redis。所以 maxmemory 不能设成物理内存本身,一般建议不超过物理内存的 50% 到 60%,留出给页表、写时复制、客户端缓冲区、AOF 缓冲区以及操作系统缓存的余量。

透明大页是这里最容易被忽略的一条。THP 会把多个 4KB 小页合并成一个 2MB 的大页,平时能减少页表项、提升 TLB 命中率,但在写时复制的场景下,它把复制粒度从 4KB 放大到了 2MB。改一个字节要复制 2MB,这个放大倍数足以让一次 BGSAVE 变成内存事故。所以 Redis 的部署规范里都要求关闭 THP。

vm.overcommit_memory 也要设成 1。这个内核参数的默认值会让内核对内存分配做保守估计,fork 的时候如果它认为内存可能不够,就直接让 fork 失败。Redis 拿不到子进程,快照和重写都做不了。设成 1 表示总是允许超额分配,让 fork 能成功,真实的内存压力交给写时复制去扛。

最后一条是高频 fork 的叠加。RDB 快照和 AOF 重写如果同时触发,就是两个子进程同时在跑,两份写时复制开销叠加,内存放大和 fork 阻塞都翻倍。所以要错开它们的触发条件,别让它们在同一个时间点撞上。

这五条串起来就是一次典型的线上事故链。maxmemory 贴着物理内存设,THP 没关,写入量在某个高峰涨起来,正好撞上一次 BGSAVE,父进程大量写触发写时复制,额外内存一路涨到物理内存上限,OOM killer 出手把 Redis 干掉。复盘的时候大家通常盯着「为什么内存涨这么快」,而根因在三步之前就埋下了。防这件事不需要多高的技巧,就是别让这几个条件同时成立。

缓解手段

按见效程度排一下。控制单实例内存规模是根本手段,把几十 GB 的实例拆成几个十 GB 的,页表小了、fork 快了、写时复制放大的绝对量也小了。这是运维层面最容易执行也最有效的一条。

持久化策略上,如果只需要一种,就只留 AOF,关掉 RDB 的 save 规则,减少一次 fork。如果两个都要,就把 save 和 auto-aof-rewrite-percentage、auto-aof-rewrite-min-size 调一调,让它们别撞在一起。重写频率太高是很多实例 fork 频繁的真正原因,重写阈值设得太敏感,稍微一写就触发。

系统层面两件事必须做,关闭 THP,把 vm.overcommit_memory 设成 1。这两条在容器环境里也要确认,宿主机设了不等于容器里生效。

监控上要盯四个数,latest_fork_usec、rdb_last_cow_size、aof_last_cow_size,还有 LATENCY 里的 fork 事件。前三个能告诉你单次 fork 有多贵,第四个能告诉你它有没有真的把请求挡住。

还有一件事容易漏,fork 不只在持久化时发生。主从全量同步时,主库要 fork 出子进程生成 RDB,从库收到之后清库再加载,两边都会卡一下。所以「关掉 RDB 就没有 fork」这个说法不成立,只要有全量同步,fork 就一定会来。评估 fork 风险的时候要把它算进去。

Redis Lua 脚本原理与实践

前面几章一直在说单线程,说命令执行是一条串行队列。那么问题来了,如果我想让一串操作不被别人插进来,怎么办。答案之一是 Lua 脚本。这一章讲它凭什么原子,以及写脚本要守哪些规矩。

EVAL 与 EVALSHA

EVAL 的签名是 EVAL script numkeys key... arg...,script 是脚本文本,numkeys 说明后面有几个 key,剩下的都是参数。key 和参数的区分不是形式主义,Cluster 需要知道脚本要碰哪些 key 才能做路由和校验。

每次都传脚本文本,带宽就浪费在重复的代码上。所以 Redis 提供了 SCRIPT LOAD,它把脚本存进脚本缓存并返回一个 sha1 摘要,之后用 EVALSHA 加上这个摘要就能执行,省掉了脚本文本本身。脚本缓存不持久化,实例重启之后就空了,所以客户端通常的做法是先用 EVALSHA 试,收到 NOSCRIPT 错误再退回 EVAL,顺便把脚本重新加载一遍。

7.0 引入了 Functions,用 FUNCTION LOAD 把一个库加载进去,库里的函数用 FCALL 调用。跟 EVAL 比,Functions 常驻在服务端,不占网络带宽,可以命名、可以按库管理、支持 FUNCTION LIST 和 FUNCTION DUMP,更像正经的代码而不是一次性脚本。代价是多了一层版本管理,函数库更新要管好各节点的同步。EVAL 并没有被废弃,短小的临时脚本用它更省事。

原子性

Lua 脚本在 Redis 里是整体原子执行的。执行期间 Redis 不处理其它命令,所以脚本里的一串操作要么全部发生,要么全不发生,中间不会被别人的命令插进来。这一点让它特别适合「读判断写」的复合操作,比如读一个值、根据值决定要不要写、然后写。

但要澄清一个误解,它不是事务,没有回滚 。如果脚本执行到一半,某条 redis.call 报错了,脚本会中断并返回错误,但之前已经执行的命令不会撤销。数据就停在那个中间状态。所以脚本的正确性要靠自己保证,不能指望出错时能回滚。

写脚本要守的规矩

脚本要短。

执行时间全部算在主线程头上,一段几十毫秒的脚本,就是全实例停顿几十毫秒。这条没有例外。

不要写耗时循环,不要写 while true。一个死循环会让整个实例失去响应,而且救不回来,后面会讲为什么。

KEYS 和 ARGV 必须正确使用,脚本里不能硬编码 key 名。一方面 Cluster 要靠 KEYS 知道路由到哪个节点,硬编码的 key 客户端看不到,路由就错了;另一方面脚本缓存的 sha1 只跟脚本文本有关,硬编码 key 会让同一个脚本在不同业务上跑出不同的 key,复用性和可维护性都完蛋。

Cluster 下脚本操作的所有 key 必须在同一个 slot。判断依据是 key 的哈希,所以要跨 key 就得用 hash tag,把共同部分放进花括号里,比如 {user1001}:profile 和 {user1001}:orders,花括号里的内容相同,它们就落在同一个 slot。不同 slot 会直接返回 CROSSSLOT 错误。部署前可以用 CLUSTER KEYSLOT 把脚本要碰的每个 key 算一遍,确认它们真的同 slot,别等上线才撞上这个错误。

随机性和副作用要小心。早期的 Redis 是复制脚本本身到从库,如果脚本里有 TIME、RANDOMKEY、SRANDMEMBER 这类不确定的命令,主库和从库各自执行会得到不同结果,数据就不一致了。从 5.0 起默认改成按效果复制,也就是把脚本产生的写命令打包成 MULTI 传播下去,7.0 之后整脚本复制这个模式已经移除。这个历史要知道,因为它解释了为什么老文档里反复强调「脚本必须确定性」。今天写脚本仍然建议把时间戳之类的值从 ARGV 传进来,不依赖服务端时间。顺带说一句,从库和 AOF 看到的既然是这批写命令而不是脚本文本,那么影响复制体积的就是写命令的数量,跟脚本有多长没关系。

redis.call 和 redis.pcall 的差别要分清。前者遇到错误会中断脚本并把错误返回给客户端,后者会把错误捕获成一个 Lua 表继续往下执行,让脚本自己决定怎么处理。

常用 API 有几个值得记住。redis.error_reply(msg) 返回一个错误回复,redis.status_reply(msg) 返回一个状态回复,redis.sha1hex(str) 算摘要,redis.log 打日志,redis.setresp(2) 或 redis.setresp(3) 切换脚本里看到的协议版本,7.0 起支持 RESP3。

Lua 限流

固定窗口是最简单的一种。一个 key 记当前窗口的计数,第一次写的时候设过期时间。

lua 复制代码
-- KEYS[1] 计数 key,比如 rate:api:user:1001
-- ARGV[1] 窗口长度,单位秒
-- ARGV[2] 窗口内允许的最大次数
-- 返回剩余可用次数,超过限制返回 -1
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])

local current = redis.call('INCR', key)
if current == 1 then
    -- 第一次计数,顺手设过期,保证窗口结束能自动清理
    redis.call('EXPIRE', key, window)
end
if current > limit then
    return -1
end
return limit - current

固定窗口的问题是边界效应,窗口末尾和下一个窗口开头连着放行,实际速率会翻倍。令牌桶没有这个问题。

lua 复制代码
-- KEYS[1] 令牌桶 key,用哈希存 tokens 和 ts 两个字段
-- ARGV[1] 桶容量
-- ARGV[2] 每秒补充的令牌数
-- ARGV[3] 当前时间戳,秒,带小数
-- ARGV[4] 本次请求要消耗的令牌数
-- 返回 1 表示放行,0 表示拒绝
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local need = tonumber(ARGV[4])

-- 取出当前令牌数和上次补充时间
local bucket = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(bucket[1])
local ts = tonumber(bucket[2])

if tokens == nil then
    -- 桶还不存在,按满桶初始化
    tokens = capacity
    ts = now
end

-- 按经过的时间补充令牌,上限不超过桶容量
local delta = math.max(0, now - ts)
tokens = math.min(capacity, tokens + delta * rate)

local allowed = 0
if tokens >= need then
    -- 令牌够,扣减并放行
    tokens = tokens - need
    allowed = 1
end

-- 写回状态,并给一个比桶自然清空时间稍长的过期时间
redis.call('HSET', key, 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', key, math.ceil(capacity / rate) + 1)
return allowed

这里有个刻意的设计,时间戳从 ARGV 传进来,而不是在脚本里调 redis.call('TIME')。原因就是前面说的确定性,客户端传时间让脚本的行为完全由输入决定,主从复制和调试都更省心。代价是客户端时钟不同步时会出现偏差,分布式环境下这本身也是个要处理的问题。

Lua 分布式锁

加锁其实不需要 Lua,一条命令就够,SET 带 NX 和 PX 本身是原子的。

lua 复制代码
-- KEYS[1] 锁 key
-- ARGV[1] 锁的唯一标识,建议用客户端 id 加随机串
-- ARGV[2] 锁的过期时间,毫秒
-- 返回 1 表示加锁成功,0 表示被别人占着
local ok = redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2])
if ok then
    return 1
end
return 0

释放锁才需要 Lua,因为要做两步,先看锁是不是自己的,是才删。这两步如果不原子,就可能在判断通过之后、删除之前锁过期并被别人拿到,然后你删掉了别人的锁。

lua 复制代码
-- KEYS[1] 锁 key
-- ARGV[1] 加锁时写入的唯一标识
-- 返回 1 表示释放成功,0 表示锁不属于自己
local value = redis.call('GET', KEYS[1])
if value == ARGV[1] then
    return redis.call('DEL', KEYS[1])
end
return 0

锁的续期、看门狗、RedLock 的争议这些,分布式应用那篇单独展开过,这里不重复。要提醒的是,锁的过期时间一定要设,而且要比业务最长执行时间留够余量,锁没设过期或者过期时间太短,都是线上事故的常见来源。

Lua 的性能问题

脚本执行时间全部计入主线程,这是所有性能问题的根。一段脚本跑 100 毫秒,整个实例就停 100 毫秒,期间所有请求排队。

如果脚本跑太久,其他客户端会开始收到 BUSY 错误。控制这个时机的是 busy-reply-threshold,默认 5000 毫秒。这个名字在 7.0 的配置文件里是正式名,lua-time-limit 是它的旧名,现在作为别名保留,两个配的是同一件事。要注意它的语义,它只决定什么时候开始回 BUSY,不会中断脚本。脚本该跑完还是跑完,Redis 只是告诉别的客户端「我忙着呢」。

想终止一个跑飞的脚本,SCRIPT KILL 是唯一手段,但它有个硬边界。只有还没执行过任何写命令的脚本才能被杀掉 。原因是如果脚本已经写了数据,杀掉它会让主从和 AOF 出现不一致,Redis 宁可继续卡着也不愿意破坏一致性。已经写过的脚本,只剩两个选择,等它自己跑完,或者 SHUTDOWN NOSAVE 直接关掉实例,牺牲未持久化的数据。

观察脚本耗时用 SLOWLOG。脚本执行会被记成一条慢日志,参数里能看到脚本的 sha1 和长度。LATENCY 里也可能出现相关事件。调优的方向通常不是优化脚本本身,而是把大脚本拆小,或者把能异步的活挪出脚本。

调试

redis-cli --eval 可以直接跑脚本文件,逗号前面的当 key,逗号后面的当参数,格式是 redis-cli --eval script.lua key1 key2 , arg1 arg2。

交互式调试用 redis-cli --ldb --eval,它启动一个服务端辅助的调试器,能设断点、单步、打印变量。功能挺全,但它是通过让服务端暂停执行来实现的,而且调试会话本身会占用实例,生产环境不要用 。本地或者测试环境调试脚本,用它比自己加 redis.log 反复跑要快得多。

Redis Pipeline、Transaction 和 Lua 的区别

七章走到这里,最后要回答一个很实际的问题。一次要发很多命令出去,到底用哪种方式。Pipeline、MULTI 事务、Lua 脚本,三样都能做到「一次网络往返发多条」,但它们的语义差别很大,选错了会踩坑。

Pipeline 省的是 RTT

Pipeline 是最简单的一种。客户端把多条命令一次性写进连接,不等回复;服务端按顺序执行,把回复依次写回;客户端一次性读回来。它省掉的是每条命令之间的一次网络往返。

省掉的是往返,不是保证。

它不保证原子性。服务端按顺序处理这批命令,但中间完全可能插入别的客户端的命令。你的 100 条命令不是一段不可分割的逻辑。

它也不保证全部成功。某一条命令报错,后面的照常执行,错误混在回复列表里一起返回。客户端要自己遍历回复判断哪些失败了。

还有一点常被误解,Pipeline 减少的是网络等待,不是服务端的执行时间。那 1000 条命令在服务端还是一条一条按顺序执行,总耗时该多少还是多少。Pipeline 只是让你不用在每条命令之间等网络,它把「网络等待」这一项从总时间里抠掉了。所以如果服务端本身已经跑满,Pipeline 一点忙都帮不上。

用法上有几条要注意。一次 pipeline 的条数不能无限大,服务端要把请求攒在查询缓冲区里,回复攒在输出缓冲区里,条数太多会顶到 client-query-buffer-limit 和 client-output-buffer-limit。7.0 还加了 maxmemory-clients,给所有客户端缓冲区设总量上限,超了直接断连接。所以几千条一批比较稳,几十万条一次性发出去是在给自己找事。

Pipeline 最适合的场景是批量读写和数据迁移,就是那种「我要发一万条 SET,不在乎中间有没有别人插队」的活。

MULTI 与 EXEC

事务的用法是 MULTI 开启,后面的命令进队列(返回 QUEUED),EXEC 一次性执行,DISCARD 放弃。

它有一个很反直觉的性质,Redis 事务没有回滚 。这句话要拆成两种情况说。执行期出错,比如对一个 String 用了 LPUSH,那条命令报错,其它命令照常执行,事务不会整体撤销。入队期出错,比如命令名拼错了或者参数个数不对,Redis 会标记事务状态出错,EXEC 时直接返回 EXECABORT,整个事务一条都不执行。

所以「Redis 事务是 ACID 事务」这个说法是错的。它保证的是「这批命令连续执行、中间不插入别的客户端」,仅此而已,没有原子回滚,也没有隔离级别的概念。

WATCH 是给事务加乐观锁用的。你在 MULTI 之前 WATCH 一个 key,如果这个 key 在 EXEC 之前被别人改过,EXEC 就返回 nil,一条都不执行。WATCH 在 EXEC 或 DISCARD 之后自动取消,也可以手动 UNWATCH。这个机制适合「读出来算一下再写回去,如果期间被人改了就算了重来」的模式。

Cluster 下还有一条限制。事务里的命令最终要在某个节点上执行,如果它们的 key 分散在不同 slot,客户端没办法把它们发到同一个节点,只能在客户端侧报错。所以事务和 Lua 在 Cluster 下都有「同 slot」这个前提,Pipeline 没有这个限制,因为 Pipeline 里的命令可以分别路由到不同节点。

Lua 的定位

Lua 一次网络往返,整体原子,而且能在脚本里做条件判断,这是它跟事务最大的区别。事务没法根据读到的值决定后面执行什么,Lua 可以。

代价前面都讲过,脚本阻塞主线程、调试麻烦、Cluster 下所有 key 必须同 slot。它是一把很锋利的刀,用在短小的读判断写上很合适,用在大批量数据搬运上就是自残。

三者对比

方式 网络往返 原子性 能否条件判断 失败处理 Cluster 限制 适用场景
逐条命令 N 次 单条原子 可以,但在客户端 自己判断 按 key 路由 低频、逻辑简单
Pipeline 1 次 不保证 客户端算好再发 遍历回复 按 key 路由 批量读写、迁移
MULTI 加 EXEC 1 次 连续执行,无回滚 不能 执行期错误不中断 所有 key 同 slot 乐观锁、成组写入
Lua 1 次 整体原子 可以 自己处理,无回滚 所有 key 同 slot 读判断写、限流、锁

网络 RTT 的量化

把 RTT 这一项算清楚,选型的直觉就出来了。

假设同机房 RTT 是 0.2 毫秒,要发 1000 条命令。逐条同步发,每条要等一次往返,1000 次就是 200 毫秒,折算下来一秒最多 5000 条,这个数跟 Redis 自己的处理能力没关系,是网络给的。改成 Pipeline 一次发 1000 条,往返次数从 1000 降到 1,网络那部分的时间从 200 毫秒降到 0.2 毫秒,剩下的时间是带宽时间和服务端执行 1000 条命令的时间。Lua 也是一次往返,多出来的成本是脚本自身的解析和执行。

所以 Pipeline 和 Lua 在 RTT 这一项上是同一档,两者都在「一次往返」这个量级。它们真正的差别不在网络,在原子性和能不能做条件判断。

一个反直觉的点

Pipeline 在单机单连接下收益最大。如果你已经用了连接池,几十个连接并发地发命令,每条命令的 RTT 被并发掩盖掉了,Pipeline 的收益就被摊薄了。极端情况下,一个用了 50 个连接的客户端再加 Pipeline,提升可能很有限,因为 RTT 本来就不是瓶颈了。

但摊薄不等于无效。Pipeline 减少的是往返次数这个物理量,连接再多也改变不了这一点,只是它从「主要瓶颈」退成了「次要优化」。反过来,如果你的服务是单连接串行调用,Pipeline 的收益是数量级的。

选型建议

批量读写、数据迁移,用 Pipeline。不需要原子性,只要减少往返。

需要「读判断写」的复合逻辑,用 Lua。事务做不到条件判断,Pipeline 做不到原子。

需要乐观锁,用 WATCH 加 MULTI。这是事务唯一的不可替代之处,也是它最合适的用法。

跨 key 的原子逻辑而且比较复杂,用 Lua,前提是这些 key 在同一个 slot,不然只能靠 hash tag 或者重新设计 key。
#mermaid-svg-fP4pz2AiSlO1FFyF{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fP4pz2AiSlO1FFyF .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fP4pz2AiSlO1FFyF .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fP4pz2AiSlO1FFyF .error-icon{fill:#552222;}#mermaid-svg-fP4pz2AiSlO1FFyF .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fP4pz2AiSlO1FFyF .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fP4pz2AiSlO1FFyF .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fP4pz2AiSlO1FFyF .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fP4pz2AiSlO1FFyF .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fP4pz2AiSlO1FFyF .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fP4pz2AiSlO1FFyF .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fP4pz2AiSlO1FFyF .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fP4pz2AiSlO1FFyF .marker.cross{stroke:#333333;}#mermaid-svg-fP4pz2AiSlO1FFyF svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fP4pz2AiSlO1FFyF p{margin:0;}#mermaid-svg-fP4pz2AiSlO1FFyF .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fP4pz2AiSlO1FFyF .cluster-label text{fill:#333;}#mermaid-svg-fP4pz2AiSlO1FFyF .cluster-label span{color:#333;}#mermaid-svg-fP4pz2AiSlO1FFyF .cluster-label span p{background-color:transparent;}#mermaid-svg-fP4pz2AiSlO1FFyF .label text,#mermaid-svg-fP4pz2AiSlO1FFyF span{fill:#333;color:#333;}#mermaid-svg-fP4pz2AiSlO1FFyF .node rect,#mermaid-svg-fP4pz2AiSlO1FFyF .node circle,#mermaid-svg-fP4pz2AiSlO1FFyF .node ellipse,#mermaid-svg-fP4pz2AiSlO1FFyF .node polygon,#mermaid-svg-fP4pz2AiSlO1FFyF .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fP4pz2AiSlO1FFyF .rough-node .label text,#mermaid-svg-fP4pz2AiSlO1FFyF .node .label text,#mermaid-svg-fP4pz2AiSlO1FFyF .image-shape .label,#mermaid-svg-fP4pz2AiSlO1FFyF .icon-shape .label{text-anchor:middle;}#mermaid-svg-fP4pz2AiSlO1FFyF .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fP4pz2AiSlO1FFyF .rough-node .label,#mermaid-svg-fP4pz2AiSlO1FFyF .node .label,#mermaid-svg-fP4pz2AiSlO1FFyF .image-shape .label,#mermaid-svg-fP4pz2AiSlO1FFyF .icon-shape .label{text-align:center;}#mermaid-svg-fP4pz2AiSlO1FFyF .node.clickable{cursor:pointer;}#mermaid-svg-fP4pz2AiSlO1FFyF .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fP4pz2AiSlO1FFyF .arrowheadPath{fill:#333333;}#mermaid-svg-fP4pz2AiSlO1FFyF .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fP4pz2AiSlO1FFyF .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fP4pz2AiSlO1FFyF .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fP4pz2AiSlO1FFyF .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fP4pz2AiSlO1FFyF .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fP4pz2AiSlO1FFyF .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fP4pz2AiSlO1FFyF .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fP4pz2AiSlO1FFyF .cluster text{fill:#333;}#mermaid-svg-fP4pz2AiSlO1FFyF .cluster span{color:#333;}#mermaid-svg-fP4pz2AiSlO1FFyF div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fP4pz2AiSlO1FFyF .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fP4pz2AiSlO1FFyF rect.text{fill:none;stroke-width:0;}#mermaid-svg-fP4pz2AiSlO1FFyF .icon-shape,#mermaid-svg-fP4pz2AiSlO1FFyF .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fP4pz2AiSlO1FFyF .icon-shape p,#mermaid-svg-fP4pz2AiSlO1FFyF .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fP4pz2AiSlO1FFyF .icon-shape .label rect,#mermaid-svg-fP4pz2AiSlO1FFyF .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fP4pz2AiSlO1FFyF .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fP4pz2AiSlO1FFyF .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fP4pz2AiSlO1FFyF :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 不需要
需要
需要
不需要
需要
不需要
要一次发多条命令
需要原子性吗
用 Pipeline
需要读判断写吗
用 Lua,注意同 slot
需要乐观锁吗
WATCH 加 MULTI
MULTI 加 EXEC

回到开头那条线。单线程决定了命令执行是一条串行队列,它的能力来自 IO 多路复用,它的边界也来自这条队列;6.0 的多线程动的是队列外面那层搬字节的活,队列本身没变;过期删除和内存淘汰是这条队列自己做的内存管理,一个按时间,一个按访问热度;fork 和写时复制是队列之外多了一个只读的兄弟进程,代价是页表和内存放大;Lua 是往这条队列里塞一段不可打断的逻辑,原子性来自队列本身;Pipeline、事务、Lua 则是三种往队列里塞东西的方式,分别用不同的代价换不同的保证。这七个问题看下来,Redis 的「简单」不是因为它做的事少,是因为它把复杂的东西都收敛到了一条队列上。

相关推荐
Omics Pro1 小时前
计算虚拟扰动:网络毒理+虚拟敲除
数据库·人工智能·算法·机器学习·自然语言处理
白帽攻防录9 小时前
SRC 挖洞:Roundcube 预认证 SQL 注入深度复盘,CVE-2026-48842 preg_replace 转义绕过怎么打穿邮件系统
网络·数据库·sql·网络安全·sql注入
꯭自꯭闭꯭10 小时前
达梦SQL优化相关
linux·运维·数据库·sql
对空六课10 小时前
支持注意力分析的热力图工具有哪些?
前端·数据库·数据分析
zl_dfq10 小时前
Redis 之 【高可用架构】(从主从复制、哨兵到集群)
redis
南京码讯光电技术有限公司10 小时前
How to Design an Antenna System for an Industrial WiFi Module
数据库·人工智能
lusklusklusk12 小时前
Oracle数据库基础之2_体系结构
数据库·oracle
可乐ea12 小时前
从第一性原理构建 AI Agent:提示词、工具、技能与记忆全解剖
数据库·人工智能·工具调用·ai智能体·提示词工程·agent开发·智能体记忆
东方护航数据恢复(深圳)12 小时前
医疗案例:HIS/PACS 数据库页损坏修复,医院不停诊完成恢复【东方护航数据恢复深圳店】
数据库·数据恢复·医疗·二次开盘