动手读过Redis源码的人,多半是被一些"表象问题"勾过去的:为什么单线程还能扛住十万级QPS?为什么某条命令会把整个实例卡住?为什么明明连接数不高,输出缓冲却撑爆了内存?这些问题翻文档翻不出答案,但顺着源码走一遍命令的完整生命周期,基本都能找到落脚点。这篇文章就把Redis的命令处理机制从头到尾拆开看,从网线那头的一条SET命令开始,一路跟到事件循环、协议解析、命令表查找、执行器调用再到结果写回,把每一段关键路径上的源码和设计意图都过一遍。适合对Redis有一定使用经验、想往深走一层的后端开发者。
1. 从一条SET命令的出生,看清全链路地图
1.1 网络上传过来的不是"SET",而是一串字节
先抛一个很多人忽视的事实:当你在命令行敲下SET foo bar回车,真正经过TCP连接送出去的并不是人眼看到的六个字符那么简单,而是这么一串东西:
*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n这是Redis的RESP协议在传输层的真实样貌。*3表示后面有三个参数,$3表示接下来这个参数的长度是3字节,后面跟着参数内容。之所以聊命令处理机制要先说这个,是因为整个服务端解析流程的起点就是这串字节。
很多人在源码里找"命令是怎么识别的",找半天找到processCommand函数觉得这就是入口,其实这已经是后半程了。真正的前半段发生在更底层的网络层:数据从内核的socket缓冲区被读出来,放进Redis自己管理的内存缓冲区,然后一层层剥出参数。这串字节没被解析成argc和argv之前,服务端眼里它只是一堆等待处理的二进制流。
1.2 服务端到客户端的完整路径速览
我把这条命令从进门到出门经过的主要函数列出来,后面每个章节再逐个展开:
| 阶段 | 关键函数 | 作用 |
|---|---|---|
| 事件等待 | aeMain/aeProcessEvents | 通过epoll等待可读事件 |
| 读入缓冲 | readQueryFromClient | 从socket读数据到querybuf |
| 协议解析 | processInputBuffer/processMultibulkBuffer | 把字节流拆成参数数组 |
| 命令查找 | processCommand/lookupCommand | 根据argv[0]查命令表 |
| 安全检查 | processCommand内部的层层校验 | 认证、ACL、OOM、集群重定向等 |
| 真正执行 | call→setCommand | 调用命令的具体实现 |
| 回复准备 | addReply/prepareClientToWrite | 把结果写入输出缓冲区 |
| 写回客户端 | handleClientsWithPendingWrites/writeToClient | 把缓冲数据通过socket发出 |
这条链路里最精巧的部分不在某个单一函数,而在各层级之间的解耦。网络层不关心你传的是SET还是GET,协议层不关心你要操作哪个key,命令表不关心你的参数值是什么,执行器也不关心结果怎么返回给客户端。每层只做好一件事,然后通过精心设计的结构体把上下文传给下一层。
理解了这条链路的整体形状,后面看每个环节都会有种"果然如此"的感觉——因为每个函数的存在都是有明确理由的,不是代码堆砌。
2. 事件驱动的心脏:aeEventLoop如何决定"现在该做什么"
2.1 单线程的优势恰恰是"没有锁"
聊Redis命令处理就绕不开事件循环。Redis不是每来一个连接就开一个线程,而是用一个进程里的一个主线程循环处理所有事件。这个事件循环的实现在ae.c里,核心结构叫aeEventLoop。
先看服务端启动时的调用关系。main函数在完成initServer之后,执行了这么一行:
aeMain(server.el);aeMain本身就是一个死循环,只有收到关闭信号或发生致命错误才会退出:
void aeMain(aeEventLoop *eventLoop) { eventLoop->stop = 0; while (!eventLoop->stop) { aeProcessEvents(eventLoop, AE_ALL_EVENTS); } }很多人把aeProcessEvents理解成"处理所有事件",这个理解没错,但会忽略一个关键点:它不只是处理已经发生的事件,还要主动等待事件。aeProcessEvents内部会调用aeApiPoll,而aeApiPoll在各个平台上有不同实现,Linux上就是对epoll的封装,会阻塞在那里等待内核通知。它返回后,说明至少有一个fd上有事件发生了,这时才进入事件的处理阶段。
为什么单线程反而是优势?因为整个命令执行过程中,Redis内部所有数据结构——哈希表、链表、跳表、字符串对象——都处在单线程的访问模型下,不需要任何锁。多线程引入的锁竞争、上下文切换、缓存失效成本,在Redis这种以内存操作为主的场景里,往往比并行执行带来的收益更显著。这也是为什么Redis 6.0引入多线程I/O时,特意把线程限制在网络读写层面,命令执行仍然是主线程串行完成的。
2.2 读事件与写事件的分工逻辑
aeEventLoop里维护着两种关键事件:文件事件(file event)和时间事件(time event)。文件事件对应的是网络连接的可读可写状态,时间事件则对应周期性任务,比如serverCron。
文件事件有一个很重要的设计:同一时刻,一个fd上的读事件和写事件是分开处理的。aeProcessEvents会先通过aeApiPoll拿到一批就绪的文件事件,然后逐个调用注册的回调函数。对于监听socket上的可读事件,回调是acceptTcpHandler,用来接受新连接;对于客户端socket上的可读事件,回调是readQueryFromClient,也就是下一章的主角。
这里有个值得注意的细节:Redis通常只关心读事件,不主动注册写事件。为什么?因为写事件和读事件不一样——读事件是有数据了才触发,写事件是缓冲区空闲就触发。如果一个客户端连上来但不怎么发数据,你却一直注册着它的写事件,那么每次事件循环都会因为"这个fd可写"而被唤醒,白白消耗CPU。Redis的做法是:先尝试直接写,写不完(返回EAGAIN)才注册写事件,等socket可写了再继续写。这个"被动注册写事件"的思路,在后面的输出缓冲章节还会再见到。
2.3 时间事件里的见缝插针
serverCron是Redis的时间事件,默认每100ms执行一次。它管的事情特别杂:过期key的抽样删除、重新计算内存峰值、更新统计信息、检查持久化持久化状态、处理客户端超时等等。这些任务不需要精确到毫秒级,100ms的粒度非常合适。
时间事件和文件事件在aeProcessEvents里的关系是:先找出最近要执行的时间事件,算出离它还有多少毫秒,然后把这个时间作为aeApiPoll的超时时间。如果这段时间内有网络事件来了,就优先处理网络事件,处理好之后再看时间事件到没到点;如果没有网络事件,等到超时就把时间事件先执行了。
这套调度逻辑非常优雅,它保证了:网络请求再密集,时间事件也总有机会执行;网络请求很稀疏的时候,时间事件也不会被饿死。理解了这个模型,再看那些"为什么Redis每秒会做一次后台操作""为什么毫秒级定时不选这个循环"之类的问题,就都很清楚了。
3. 读请求现场:readQueryFromClient把数据放到了哪里
3.1 从socket到querybuf的搬运工
当客户端连接上有数据可读时,事件循环会触发readQueryFromClient。这个函数名字相当直白,它的任务就是从连接里把数据读出来,放进客户的输入缓冲区。
在Redis 7.x里,连接层抽象成了connection结构,底层可以是TCP套接字、TLS套接字或者Unix socket。readQueryFromClient的前几行长这样:
void readQueryFromClient(connection *conn) { client *c = connGetPrivateData(conn); ... readlen = PROTO_IOBUF_LEN; /* 默认16KB */ ... nread = connRead(c->conn, c->querybuf + c->querybuf_cur_pos, readlen); }这里的c->querybuf就是客户端的输入缓冲区,类型是SDS字符串,而不是普通的char*数组。用SDS的好处是长度信息现成、二进制安全——别忘了RESP协议里可能出现\r\n之外的任意字节,靠\0判断结尾是会出事的。
每次读操作的目标长度是16KB(PROTO_IOBUF_LEN)。为什么是16KB?这是典型的性能折中:太小会导致read系统调用次数变多、上下文切换开销变大;太大则单次read占用时间过长,影响事件循环的响应速度。16KB覆盖了绝大多数Redis命令的请求体,实测下来吞吐和延迟都很均衡。
3.2 输入缓冲区的扩容与上限
读到数据之后,readQueryFromClient会做两件事:一是可能对输入缓冲进行合理扩容,二是调用processInputBuffer进入解析阶段。扩容走的是SDS的常规路径,如果当前剩余空间不够16KB,就自动扩展;但Redis也不是让输入缓冲无限膨胀,它会检查客户端的输入缓冲是否超过硬性限制。
这个限制在哪里?server.client_max_querybuf_len,默认是1GB。为什么需要这个保护?设想一个场景:某个客户端连接因为网络问题或者客户端自身bug,一直发数据但服务端处理不过来了,querybuf就会不断堆积。如果没有上限,内存会直接被拖垮。超过限制后,Redis会记录日志,然后断开这个客户端,并加一条说明,让运维知道"哪个客户端把连接搞坏了"。源码里的日志大概长这样:
Closing client that reached max query buffer length尽管1GB这个默认值看着很大,但在多租户共享实例或者存在异常客户端的场景里,它确实是一道保命防线。笔者在实际运维中曾见过因为慢客户端导致内存暴涨的case,当时就是从querybuf长度异常这个指标发现了问题。
3.3 多线程I/O在这里如何介入
如果你配置了io-threads,readQueryFromClient里还有一个隐藏分流逻辑。Redis 6.0之后,多线程I/O可以在读阶段介入:主线程读了一部分连接的数据之后,可以把解析工作也分配给I/O线程吗?不是的——解析仍然是主线程干的活,I/O线程只做数据的connRead读取。
在readQueryFromClient里,主线程判断如果当前连接属于I/O线程的处理范围,就把这次读取记账到待读列表,由后台线程并发读取到各自的querybuf里;主线程则先处理其他不在I/O线程范围内的连接,等到读任务全部完成后再统一进入解析阶段。
这里要澄清一个常见误解:Redis的多线程I/O不是"多线程处理命令",而是"多线程搬运数据"。真正执行命令的call过程,依然只有一个线程。所以,如果你的Redis瓶颈在网络读写本身,开io-threads可能有效;如果瓶颈在命令实现的CPU计算上,开多少I/O线程都白搭。这也是为什么官方文档反复强调,4核机器上默认关闭多线程I/O,因为收益不明显还会增加复杂度。
4. 协议解析:processInputBuffer与RESP的逐字节博弈
4.1 两条分支:inline命令与multibulk命令
processInputBuffer是命令解析的主控函数。它在一个while循环里持续处理querybuf中已有的数据,直到缓冲区里已经没有一条完整的命令为止。
每次进入循环,Redis会先判断当前客户端使用哪种协议格式。判断依据非常朴素:看querybuf的第一个字节是不是*。如果是,按multibulk格式解析;如果不是,按inline格式解析。在Redis 7.x里,这个状态缓存在c->reqtype里,避免每条命令都重新判断。
inline格式是什么?Redis支持一种极简协议,客户端直接发SET foo bar\r\n,用空格分隔参数。这种格式在telnet裸连Redis的时候特别有用——你不需要知道RESP协议细节,敲命令回车就能执行。但官方明确不推荐生产环境用inline格式,因为它无法覆盖二进制安全的场景——如果某个key或value里恰好有空格或换行,inline格式根本没法表达。
生产环境默认走的都是multibulk分支,也就是RESP协议的标准格式。在processInputBuffer的循环里,每次调用processMultibulkBuffer,如果能解析出一条完整命令,就会设置好c->argc和c->argv,然后交给processCommandAndResetClient去执行,执行完再回来继续解析下一条。这就是管道(pipelining)能够生效的底层原因——一次事件触发读入大量命令,while循环把同批命令一条条全部执行完,性能自然比一问一答高得多。
4.2 processMultibulkBuffer中间态:multibulklen与bulklen
processMultibulkBuffer是整个协议解析里最值得细看的函数,因为它维护着两个比较难懂的状态量:c->multibulklen和c->bulklen。
multibulklen表示当前命令还剩多少个参数没读;bulklen表示当前这个参数还剩多少字节没读。
为什么需要这两个状态?因为TCP是流式协议,一次read拿到的数据量完全不确定。可能一条命令分三个包到,也可能一个包里塞了十条命令。processMultibulkBuffer必须做到:这次被调进来时,如果上一条命令没读完,能从上次的位置继续读,而不是从头重来。
实际过程分两个阶段。第一阶段,当c->multibulklen == 0时,说明要开始读一条新命令了,先解析*<count>\r\n里这个count,把它赋给multibulklen,同时给c->argv分配好数组长度。第二阶段,进入while (c->multibulklen)循环,逐个解析参数。每个参数先解析$<len>\r\n得到长度,然后等待len + 2字节的内容(参数本体加结尾的\r\n)。bulklen初始为-1,表示还没读到$的长度;读到长度后变成具体数值,并开始从querybuf拷贝字节。
这个"分阶段推进"的写法,本质是在流式数据上做增量解析。碰到querybuf里数据不够的情况,函数直接返回C_ERR,但已经解析的中间状态都保存在client结构体里,等下一批数据到达时继续。如果不这么设计,就得每次把没读完的"半条命令"缓存下来拼接完整再解析,性能和代码复杂度都会差很多。
4.3 协议异常处理:什么情况会被直接断开
processMultibulkBuffer里有一类很防御性的代码,专门应对畸形协议。比如,*后面的数字不是合法整数、参数个数超过PROTO_MAX_MULTIBULK_LEN(1024*1024,也就是百万级别)、$后面的长度是负数但又不是-1等。这些情况下,协议基本可以判断为不可信。
碰到协议错误时,Redis不会尝试"猜"客户端意图,而是调用setProtocolError,给客户端返回一个-ERR Protocol error开头的错误消息,然后把querybuf清空、重置解析状态。如果错误特别严重,协议格式错到连错误回复都可能写不出去,就直接关闭连接。
这里有个安全层面的考虑:如果Redis对这种畸形协议过于宽容,恶意客户端可以通过制造大量协议错误来耗尽CPU或者占满输出缓冲。所以协议解析的逻辑是"宁可断开,不可纵容"。
5. 命令查找:从字符串命令名到redisCommand结构体
5.1 命令表里到底存了什么
命令解析完成后,c->argv[0]就是一个SDS字符串,比如"set"。下一步要在命令表里把它查找成真正的命令结构体redisCommand。
Redis的命令表定义在server.c中,名叫redisCommandTable,它是一个超大的静态数组。数组里的每一项在启动时都会被插入到server.commands这个字典结构里,key是命令名的小写形式,value是redisCommand*。
看一个命令定义的样子会有更直观的感受,以SET为例:
{"set", setCommand, -3, "write use-memory @string", 0, NULL, 0, CMD_DENYOOM|CMD_WRITE, ...}这里的字段包括:命令名set、处理函数setCommand、参数个数规范-3、命令类别描述、ACL分类、键提取规范、命令标志位。arity是-3的含义是"至少3个参数",负数表示不小于绝对值;正数则要求参数个数必须精确等于这个值。SET最少需要三个参数:命令名、key、value,所以是-3;像GET就是2,命令名加key,固定两个。
CMD_WRITE和CMD_DENYOOM是命令标志位里比较核心的两个。CMD_WRITE代表这是一个写命令,在从库上执行时会被拒绝或做特殊处理;CMD_DENYOOM代表当Redis内存超过maxmemory限制时,这条命令会被直接拒绝,并返回OOM错误。这套标志位设计得很精巧——它把"这个命令有什么性质""这个命令在不同环境下能不能执行"这类问题,从命令实现中抽离了出来,变成了命令表的元数据。新增一个命令时,开发者只需要声明标志位,而不需要在执行路径里到处写特殊判断。
5.2 lookupCommand与误写命令名
lookupCommand的实现非常直接:查字典。Redis把命令名统一转成小写再作为字典key,所以SET、Set、set找到的都是同一个命令。命令表里的原始名称用大写,但字典查询一律走小写,这也解释了为什么Redis命令大小写不敏感。
可能有人会问:为什么不直接用strcasecmp线性遍历所有命令?Redis的命令数量只有两百多个,线性遍历其实也能接受。但关键在于,lookupCommand不只被普通请求调用,在COMMAND、COMMAND INFO、ACL权限校验等场景都会被频繁调用,用哈希表查找能把时间复杂度降到O(1),在高QPS场景下这是实打实的收益。
5.3 processCommand执行前的层层关卡
找到命令结构体之后,命令并没有立刻执行。processCommand里有一长串前置校验,像安检口一样把不合理请求挡下来。我简单梳理一下顺序和逻辑:
先做基本检查。查不到命令返回未知命令错误;参数个数不符合arity要求返回参数错误。然后是认证和ACL检查,如果开启了requirepass且客户端还没认证,只放行AUTH等少数命令;ACL则细粒度到命令级别和key级别,Redis 7.x在ACL阶段甚至可以解析出命令访问了哪些key。
再往下是集群模式下的槽位检查。如果Redis以cluster模式运行,命令涉及的key不在当前节点的槽位中,就会返回MOVED或ASK重定向错误,客户端需要根据错误里携带的地址重新请求正确的节点。这一步在单机模式下是跳过的。
接着是内存相关检查。如果配置了maxmemory且当前内存超限,对于带CMD_DENYOOM标志的命令,在尝试内存淘汰之后仍不满足条件,则直接返回OOM错误。注意这里有个很容易被误解的点:触发内存淘汰发生在processCommand里,而不等命令真正执行。因为等命令执行完再淘汰,可能已经晚了——写命令可能已经把内存推到非常危险的境地。
之后还有持久化状态检查:如果Redis正在从RDB等持久化文件中加载数据,除了INFO等少数命令,其他命令一律拒绝,这是为了保证加载过程中数据视图的一致性。此外还有从库只读检查、磁盘错误检查等等。
这一连串检查的顺序是有讲究的。越廉价的检查越靠前,成本高的检查越靠后。比如查命令表是哈希查找,成本低放最前;ACL检查涉及用户和key的匹配,相对复杂;集群重定向可能需要计算CRC16,之前已经提前算好就还好。这样设计,能让绝大多数不合法请求在最早期就被拒绝,而不会白白耗费昂贵的解析和内存操作。
6. call()大戏:命令真正执行的那一瞬
6.1 执行前后的快照:dirty、监控与慢查询
所有前置检查通过后,processCommand会调用call(c, CMD_CALL_FULL)。call函数才是命令真正执行的"舞台"。
call的第一步动作很有意思:先拍一张状态快照。它记录当前server.dirty的值到dirty_before。server.dirty是Redis全局的一个计数器,记录"从上次持久化以来,键空间被修改了多少次"。命令执行后,用新的dirty减去dirty_before,就能知道这条命令到底改了多少个键。这个数值会被AOF追加、主从复制和INFO stats里的dirty指标用到。
call还会检查是不是有MONITOR客户端在监听。如果有,这条命令的参数会被格式化并推送给所有monitor连接。这也解释了为什么MONITOR会影响性能——每条命令执行前都要额外做一次格式化发送,在高QPS下开销不小。
另外,call中还有一个看似不起眼但很重要的计数:
server.stat_numcommands++;简单一句话,但被很多人忽视。Redis每秒能处理多少命令,就是靠这个计数来统计的。你在INFO stats里看到的total_commands_processed,就是从这里累加出来的。做性能压测时,观察这个计数的增长速率,比看客户端测出的延迟更接近服务端真实处理能力。
6.2 proc函数被调用时的上下文
在执行命令之前,call会用redisCommand里的proc函数指针做一次调用。以SET为例,proc就是setCommand。在Redis 7.0之前,命令函数返回int,成功返回C_OK,失败返回C_ERR,由调用方统一处理;从Redis 7.0开始,命令函数的签名变成了void,不再有返回值,执行结果一律通过addReply系列函数输出。
这个改动看似只是签名变化,实际上是架构思路的转变:以前有一部分命令会在返回C_ERR后,由call补充错误响应,导致错误响应格式不统一;改成void后,命令自己负责把所有要写给客户端的响应通过addReply输出,要么是正确数据,要么是错误信息。这样call就不需要关心命令执行的具体结果了,只负责调度。
在执行命令期间,c->cmd和c->argv都已经准备就绪,命令实现可以直接从argv里取参数。这里还要注意一点:call对命令执行前后的客户端状态做了检查,比如执行前c->flags里可能带有CLIENT_PENDING_WRITE之类的标志,执行过程中命令产生的输出会挂到客户端的输出缓冲上,而不是直接写socket。
6.3 命令执行后的善后工作
命令执行完,call还要做几件收尾的事。第一件是慢查询日志:记录命令执行前的时间点,执行后计算耗时差,如果超过slowlog-log-slower-than配置的阈值,就把这条命令的相关信息(命令名、参数、耗时、客户端地址)存入慢查询队列。这里有个小坑:慢查询日志记录的是命令执行本身的时间,不包含等待socket可读、排队的时间,所以某些场景下客户端感知到的延迟可能远大于慢查询日志中的值。
第二件是针对写命令的脏数据统计。就像前面说的,计算server.dirty - dirty_before,如果大于0,说明这条命令修改了数据,那么在主从复制的上下文里,这个命令就需要被写入复制积压缓冲区(replication backlog),供从节点增量同步;同时如果开启了AOF,这只命令也要被追加到AOF缓冲区,等待后续刷盘。
第三件是信号传播:call会在命令真正执行前设置server.in_exec标志,这能防止在命令执行过程中,因为某些副作用(比如键过期事件)再次调用call导致重入问题。这种重入场景在Redis里有明确的禁入设计,避免出现递归执行命令的混乱状态。
从这里能看出,call的名字虽然朴素,但它实际上承担了命令监控、统计、持久化联动、复制联动这样一个"兜底汇聚点"的角色。理解了call的位置,就理解了为什么Redis能优雅地把性能监控和持久化功能塞进同一条命令执行路径,而不用侵入各个命令的实现。
7. 回复之路:addReply到writeToClient的最后一公里
7.1 两级输出缓冲:快速路径buf与兜底路径reply
命令执行完后,客户端对象c的输出缓冲里已经积累了一堆要发给客户端的数据。写到socket之前,这些数据先要经过Redis自己管理的输出缓冲区。
Redis针对每个客户端维护了两级输出结构。一级是c->buf,一个固定大小的字节数组,默认16KB(PROTO_REPLY_CHUNK_BYTES);另一级是c->reply,一个链表,每个节点是一块clientReplyBlock内存。addReply的逻辑很聪明:能塞进c->buf就塞进去,塞不下的部分挂到c->reply链表上。
为什么设计成两级而不是直接用一个动态扩张的缓冲区?因为大多数命令的回复都很小,OK、PONG、一个整数、一个短字符串,16KB完全装得下。用固定数组可以避免小回复时做内存分配——堆上分配和释放的成本虽然不高,但在每秒百万级别的回复量下就非常可观了。只有回复特别大,比如MGET返回几百个长字符串、LRANGE返回一个超大列表,才会走链表路径。
这里有一个面试中经常问到的点:addReply末尾会调用prepareClientToWrite,它的作用不是真的去写数据,而是"登记"这个客户端需要被写回。具体来说,分几种情况:如果回复能完整塞进c->buf,那就直接标记一下准备在事件循环退出前的beforeSleep阶段统一写;如果回复很大需要走c->reply链表,同样也要登记到待写列表里。prepareClientToWrite是写回机制的统一入口,它决定了这个客户端是放到server.clients_pending_write链表里,还是因为某些原因(比如客户端已被标记为要关闭)干脆不写。
7.2 事件循环退场前的大扫除
这里需要解释一下clients_pending_write的作用。Redis早期版本是立刻注册写事件,写到不可写为止;后来发现这样对事件循环的唤醒次数太多了。优化后的模式是:所有产生了回复的客户端,先加入待写列表,在beforeSleep里统一处理。
beforeSleep是aeMain每次进入aeApiPoll阻塞等待之前都要调用的钩子函数。它在Redis源码中的位置很特殊——介于"处理完一批事件"和"继续等待下一批事件"之间。beforeSleep会调用handleClientsWithPendingWrites,这个函数遍历clients_pending_write列表,对每个客户端尝试把输出缓冲里的数据写出去。
这种"攒一批、写一批"的模式极大地减少了系统调用次数。设想一个场景:客户端一次pipeline发送1000条命令,服务端分几次read全部读入并逐条执行,每执行一条都会产生回复。如果没有批量写机制,就要注册1000次写事件、可能触发1000次系统调用;有了beforeSleep批量写之后,1000条回复在同一个写回调里尽量一次性写出,效率完全不是一个量级。
7.3 writeToClient的背压与截断保护
writeToClient是真正通过socket发送数据的函数。它优先处理c->buf里的数据,写完再遍历c->reply链表,把每个节点里的数据尽量写出去。写的过程中如果socket的发送缓冲区满了,write会返回EAGAIN,Redis会记录"当前写不完的位置",然后真正注册一个写事件,等下次socket可写时从断点续传。
这里还有一个非常重要的保护机制:client-output-buffer-limit。它分三档——普通客户端、从节点客户端、发布订阅客户端。如果某个客户端的输出缓冲数据量超过配置限制,比如普通客户端限制为normal 0 0 0表示不限制,但如果设置了比如normal 256mb 64mb 60,当输出缓冲超过256MB或者持续60秒超过64MB,Redis会直接断开这个客户端。
为什么这个保护很重要?设想一个消费缓慢的客户端,它的socket发送缓冲区一直满,服务端writeToClient一直写不完,回复就源源不断堆叠在服务端的c->reply链表里。如果客户端持续不消费,这些堆积会占满内存,最终拖垮整个Redis实例。这就是典型的"慢客户端拖垮服务端"场景。理解了输出缓冲的机制,再去看CLIENT LIST里omem(output memory)字段,就会明白为什么这个字段能用来定位慢客户端问题了。
写回完成后,handleClientsWithPendingWrites会检查是否全部写完:如果还有数据没写完,说明socket处于拥塞状态,这时才注册可写事件;如果都写完了,就无需注册,让客户端继续安静地等待下一条命令。这就是我之前提到"被动注册写事件"的落地实现,也是Redis在事件调度上节省CPU的关键一手。
我在追这段源码时最感慨的一点是,Redis并没有用多高的技术,每个环节都是朴素的工程手段——批量写、被动唤醒、分级缓冲、背压保护。但这些手段组合在一起,就构成了一套极端情况下依然能稳定工作的系统。尤其当你在线上看到omem暴涨、实例延迟抖动时,脑子里如果能映射到这个链条,定位问题的速度会比瞎猜快很多。源码探究的真正价值也就在于此:不是为了读懂一段代码,而是给自己建立一张"出问题时去哪一查"的地图。