☰
Redis网络建连全链路解析:从listen到client对象落地的关键细节
2026/10/11 6:37:00 网站建设 项目流程

很多人在排查 Redis 连接问题时,跳来跳去就那几个命令:netstat看端口、redis-cli client list看连接、info clients看数量。但真正到了线上连接异常、握手超时、连接数被打满这类场景,如果不懂 Redis 从监听端口到 client 对象落地的完整链路,排查起来会非常被动。这篇文章我把自己啃源码和排障过程中整理出来的网络建连逻辑完整梳理一遍,从listen开始一直到读事件注册、身份校验、连接回收,全部走一遍。

1. 建连链条的起点:从 initServer 到监听队列的建立

1.1 端口监听前的准备:anetTcpServer 与 socket 属性

Redis 的网络部分在anet.c里封装了绝大部分底层 socket 操作,而在启动时,initServer会调用listenToPort,最终走到anetTcpServer。这里面的动作很多人想当然认为只是socket+bind+listen三步,其实 Redis 在创建监听套接字时做了几个容易被忽略的细节。

int anetTcpServer(char *err, int port, char *bindaddr, int af, int backlog) { int s = anetCreateSocket(err, af); if (anetSetReuseAddr(err, s) == ANET_ERR) { anetClose(err, s); return ANET_ERR; } if (anetListen(err, s, sa, sa_len, backlog) == ANET_ERR) { anetClose(err, s); return ANET_ERR; } return s; }

第一步anetCreateSocket会创建一个非阻塞的 socket。这一步很关键,因为 Redis 整个事件循环是基于多路复用(epoll/kqueue/select)的,监听 fd 本身不能是阻塞模式,否则在高并发下accept调用会被卡死。随后设置了SO_REUSEADDR,这样 Redis 重启时可以立刻复用处于TIME_WAIT状态的本地端口,不会出现"端口被占用"的假象。

anetListen内部先bind再listen,listen的backlog参数来自server.tcp_backlog。这里有个大家容易混淆的地方:backlog不是限定最大连接数,而是内核中已完成三次握手但尚未被accept取走的连接队列长度。当进程来不及accept时,新连接会在这个队列里排队。

1.2 backlog 参数:Redis 的 tcp-backlog 在哪里生效

Redis 的tcp-backlog默认值是 511(7.x 版本),看配置文件是这样的:

tcp-backlog 511

这个值会直接传给listen(fd, backlog)。而在 Linux 内核中,实际生效的队列长度还要受/proc/sys/net/core/somaxconn限制,如果somaxconn比 Redis 配置的tcp-backlog小,内核会静默取较小值。所以很多场景下,你以为自己设置了 512 甚至 1024 的 backlog,实际生效的可能只有 128。

注意:修改tcp-backlog后,如果线上并发连接建立速率非常高(比如瞬时大量客户端重连),可以同步检查somaxconn,echo 1024 > /proc/sys/net/core/somaxconn是临时生效,持久化要写到/etc/sysctl.conf。

判断 backlog 是否被打满,最直接的现象就是客户端connect超时或握手异常缓慢,但 Redis 日志里没有任何明显报错。因为此时连接还停留在内核队列里,Redis 进程根本没有感知到。

1.3 监听 fd 进入事件循环:aeCreateFileEvent 注册 accept 回调

initServer在拿到监听 fd 后,会为每个监听 fd 注册读事件回调:

if (createSocketAcceptHandler(&server, aeLoop, server.ipfd[j], acceptTcpHandler) != C_OK) { serverPanic("Unrecoverable error creating Redis server listening socket"); }

createSocketAcceptHandler其实是封装了aeCreateFileEvent,注册的是AE_READABLE事件,回调函数是acceptTcpHandler。这里注意一点:一个监听 socket 的可读事件,语义就是"有一个新的连接已完成三次握手,可以 accept",这是理解整个建连流程的前提。

注册完监听事件之后,Redis 的主循环aeMain就开始跑aeProcessEvents。每轮事件循环中,内核会告诉 Redis 哪些 fd 可读,其中就包括监听 fd。一旦监听 fd 可读,acceptTcpHandler就被触发。

2. accept 之后的一连串动作:从内核 fd 到 redis client 对象

2.1 acceptTcpHandler 的批量接收逻辑

acceptTcpHandler在 Redis 7.x 中长这样:

void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask) { int cport, cfd, max = MAX_ACCEPTS_PER_CALL; char cip[NET_IP_STR_LEN]; UNUSED(el); UNUSED(mask); UNUSED(privdata); while (max--) { cfd = anetTcpAccept(server.neterr, fd, cip, sizeof(cip), &cport); if (cfd == ANET_ERR) { if (errno != EAGAIN) serverLog(LL_WARNING, "Accepting client connection: %s", server.neterr); return; } acceptCommonHandler(connCreateAcceptedSocket(cfd), 0, cip); } }

有几个关键点值得展开。

  • MAX_ACCEPTS_PER_CALL默认是 1000。也就是说,一次事件循环最多处理 1000 个新连接,这样可以避免某个时刻连接洪水导致事件循环被 accept 占满,命令处理得不到调度。
  • anetTcpAccept内部就调用了accept系统调用并把返回的客户端 fd 设置为非阻塞,接着取出对端 IP 和端口。
  • 当accept返回EAGAIN,说明当前没有更多已完成握手的连接了,此时直接返回,不再循环。

这个"循环 accept 直到 EAGAIN"的模式,在 Redis 源码里被称为 level-triggered 模式下的标准写法。由于 epoll 默认是水平触发,只要队列里还有待 accept 的连接,监听 fd 就会一直保持可读,所以必须在一次回调里尽量把队列清空,否则会出现忙轮询。

static int anetTcpAccept(char *err, int s, char *ip, size_t ip_len, int *port) { int fd; struct sockaddr_storage sa; socklen_t salen = sizeof(sa); fd = anetGenericAccept(err, s, (struct sockaddr*)&sa, &salen); if (fd == ANET_ERR) return ANET_ERR; if (sa.ss_family == AF_INET) { struct sockaddr_in *s = (struct sockaddr_in *)&sa; inet_ntop(AF_INET, (void*)&(s->sin_addr), ip, ip_len); *port = ntohs(s->sin_port); } else { struct sockaddr_in6 *s = (struct sockaddr_in6 *)&sa; inet_ntop(AF_INET6, (void*)&(s->sin6_addr), ip, ip_len); *port = ntohs(s->sin6_port); } return fd; }

这里提取的 IP 和端口,会作为 client 对象的addr字段来源,也是CLIENT LIST里看到的addr。这些信息在后续的 ACL 校验、client kill、连接统计中都会用到。

2.2 maxclients 的检查时机,而不是先创建后检查

我在很多文章里见过一种错误说法:Redis 会先创建 client 对象,再检查连接数是否超过maxclients,超过就关闭。实际上看acceptCommonHandler的源码,检查发生在 createClient 之前。

static void acceptCommonHandler(connection *conn, int flags, char *ip) { // ... if (listLength(server.clients) + getClusterConnectionsCount() >= server.maxclients) { char *err = "-ERR max number of clients reached\r\n"; if (server.io_threads_num == 1) { sds client = catClientInfoString(sdsempty(), c); ... } connWrite(conn, err, sdslen(err)); // ... connClose(conn); return; } client *c = createClient(conn); // ... }

注意两点:

  • 判断条件是listLength(server.clients) + getClusterConnectionsCount() >= server.maxclients,这里集群模式的内部连接也会占用配额。
  • 超过上限时,Redis 直接向客户端写入一行错误响应-ERR max number of clients reached,然后立刻关闭连接。所以如果你的应用错误日志里频繁出现这句话,说明客户端连接数已经触顶。

maxclients默认值是 10000,但实际还会受进程能打开的文件描述符数量限制。Redis 启动时会检查ulimit -n,如果系统限制低于配置值,会打印警告并自动调整:

# You requested maxclients of 10000 requiring at least 10032 max file descriptors. # Server can't set maximum open files to 10032 because of OS error: Operation not permitted.

这类启动日志值得关注,它意味着你的maxclients实际上没有完全生效。

2.3 createClient 初始化了哪些关键字段

createClient是整个建连过程最核心的函数,真正把一个内核 fd 包装成 Redis 内部的client结构体。以下只挑和网络建连强相关的逻辑。

client *createClient(connection *conn) { client *c = zmalloc(sizeof(client)); if (conn) { connNonBlock(conn); connEnableTcpNoDelay(conn); connKeepAlive(conn, server.tcpkeepalive); } /* 初始化 client 结构体字段 */ c->conn = conn; c->name = NULL; c->querybuf = sdsempty(); // ... c->id = clients_pool ? ... : ++server.next_client_id; c->created_at = server.mstime; c->last_interaction = server.mstime; // ... /* 如果是真实连接,注册读事件 */ if (conn) { connSetReadHandler(conn, readQueryFromClient); if (server.io_threads_num > 1 && ...) { listAddNodeTail(server.clients_pending_read, c); } else { connSetReadHandler(conn, readQueryFromClient); } } listAddNodeTail(server.clients, c); return c; }

这里涉及网络建连的三个 socket 参数设置,顺序很重要:

  1. connNonBlock:把客户端 fd 也设为非阻塞模式。Redis 不管读写都走事件循环,任何阻塞的操作都会把主线程卡死,这一步是必须的。
  2. connEnableTcpNoDelay:关闭 Nagle 算法。如果这个不设置,小数据包可能会在发送前被内核攒着等确认,导致交互型命令的延迟明显上升。
  3. connKeepAlive:设置 TCP Keep-Alive 探活,空闲时间来自tcp-keepalive配置(默认 300 秒)。这个参数对于清理死连接很重要,后面我会在断开连接部分展开。

client结构体里有两个时间戳:created_at和last_interaction。前者记录连接建立时刻,后者记录最后一次有命令交互的时刻。CLIENT LIST里的age和idle字段就来自这两个值,而 idle 值是timeout回收逻辑的判断依据。

2.4 读事件注册之后:readQueryFromClient 的生命

读事件回调注册的是readQueryFromClient。事件循环中只要客户端 fd 可读,就会执行这个函数,把内核接收缓冲区的数据读进c->querybuf,然后走解析和执行流程。

这里有个和建连直接相关的细节:readQueryFromClient里处理EOF:

void readQueryFromClient(connection *conn) { client *c = connGetPrivateData(conn); // ... nread = connRead(c->conn, c->querybuf + c->qb_pos, readlen); if (nread == -1) { if (connGetState(conn) == CONN_STATE_ERR && connLastError(conn) != EAGAIN) { connSetReadHandler(conn, NULL); freeClientAsync(c); } } else if (nread == 0) { connSetReadHandler(conn, NULL); freeClientAsync(c); } // ... }

当客户端正常断开或发送 RST 时,read返回 0(EOF),这时候会标记这个 client 需要异步释放。这里埋了一个伏笔:freeClientAsync不是立刻释放,而是放进一个待释放链表里,原因在第 5 节详细说。

3. 连接刚建立时,内核态到用户态的边界处理

3.1 非阻塞模式下 epoll 的配合

有人会问:accept返回的 fd 是阻塞的,为什么 Redis 还要再设置一次非阻塞?因为 Linux 的accept返回的 fd 默认继承监听 fd 的阻塞模式属性,但某些平台或某些特殊调用方式下行为不完全一致,所以 Redis 宁可显式再设置一遍。这属于典型的"防御式编程"。

非阻塞 socket 配合 epoll 后,TCP 收发的流程就变成了:

内核协议栈收包 -> 数据进入 socket 接收缓冲区 -> epoll 报告可读 -> read() 取走数据

如果客户端发了大量数据而 Redis 主线程在忙于执行耗时命令,数据会一直在内核缓冲区排队,不会丢包,但会导致应用层观察到延迟。这解释了 Redis 为什么不适合用大量阻塞型命令或超大 key 操作,不是因为线程不安全,而是因为单线程的延时会放大到所有连接上。

3.2 TCP_NODELAY 的取舍

Redis 默认开启TCP_NODELAY,禁用 Nagle 算法。Nagle 算法的目的是减少网络中微小数据包的数量,它要求一个连接上最多只能有一个未确认的小段,后续小数据必须等前一段确认后才能发送。问题在于,Redis 这种一问一答式的协议交互非常吃延迟,比如频繁的GET/SET,每条命令可能只有几十字节,如果 Nagle 生效,每个响应都会因为等待 ACK 而多出高达 40ms 的延迟(RTT 较高时甚至更糟),这是完全不能接受的。

所以这个参数对 Redis 来说是"默认必定开启",不需要你额外配置,但如果你自己基于 Redis 协议写客户端或者做中间层代理,就要记得在客户端侧也设置TCP_NODELAY,否则服务端响应再快,客户端也不一定会立刻发请求。

3.3 多线程 IO 下的延迟读注册

从 Redis 6.0 开始引入 IO 多线程,主要就是优化网络读写这部分。连接建立后,如果server.io_threads_num > 1,createClient并不会立刻注册读事件处理器,而是把这个 client 加到server.clients_pending_read链表中,由 IO 线程去批量读取和解析输入数据。

这个设计的原因是:当主线程逐个处理大量连接的读事件时,系统调用次数太多,上下文切换开销很大;IO 多线程模型把read和协议解析分摊到多个线程,主线程只负责执行命令和写回结果。

注意:IO 线程数默认是 1,即完全单线程模式。如果你要开启,需要把io-threads设置为 4 或 8 之类的值,并重启 Redis。不建议在 CPU 核数少的小机器上盲目开启,IO 线程也吃 CPU,开多了反而性能下降。

4. 建连之后的身份与资源关卡:AUTH、timeout、maxclients 的相互作用

4.1 AUTH 校验挂在命令执行链路哪里

连接建立成功并不代表可以直接发命令,如果 Redis 配置了requirepass,那么所有命令在真正执行前都要经过一道authRequired检查。看processCommand的关键路径:

int processCommand(client *c) { // ... if (!c->authenticated) { if (c->argv[0]->ptr[0] == 'A' && (command = lookupCommand(c->argv[0]->ptr)) != NULL) { // AUTH, HELLO, QUIT, RESET 这些命令放行 } else { rejectCommand(c, "-NOAUTH Authentication required."); return C_OK; } } // ... }

也就是说,没有认证的连接,除了AUTH、HELLO、QUIT、RESET这几个命令,其他一律返回NOAUTH错误。这里有个很多人踩过的坑:Redis 的AUTH校验是针对连接的,不是针对用户或者会话的。如果客户端 AUTH 之后连接断开重连,就必须重新认证,连接池里如果没做好重连后的重新认证,就会不断出现NOAUTH错误。

另外,ACL 模式下每个用户有独立的权限配置,AUTH username password之后,client 结构体里的user指针就指向对应的 ACL 用户,后续命令的权限判断都基于这个 user。

4.2 timeout 如何"静默"回收空闲连接

timeout配置项表明如果 client 在指定秒数内没有任何命令交互,就会被 Redis 主动断开。默认是 0,表示永不超时。

回收逻辑在clientsCronHandleTimeout:

static int clientsCronHandleTimeout(client *c, mstime_t now_ms) { // ... if (server.maxidletime && (server.maxidletime > 0) && (now_ms - c->last_interaction > server.maxidletime * 1000)) { serverLog(LL_VERBOSE, "Closing idle client"); freeClientAsync(c); return 1; } }

注意,这里用的是last_interaction,也就是最后一次命令交互的时间。一个连接即使 TCP 层面还活着,如果一直不发命令,超过timeout后也会被清理。

这里最关键的经验是:不要把 Redis 的 timeout 当成保活机制。如果你只是把 timeout 设大,但客户端和服务端之间有防火墙/NAT 中间设备,且这些设备对长期空闲连接有老化回收策略,那么 Redis 的 keepalive 和 timeout 必须配合配置。tcp-keepalive控制的是 TCP 层探活包间隔,timeout控制的是应用层空闲判断,两者作用域不同,线上如果要维持长连接,必须都检查一遍。

4.3 连接数耗尽时,是谁拒绝了新连接

前面提到maxclients检查发生在acceptCommonHandler里。这里再补充一个细节:当连接数达到上限时,Redis 不仅会拒绝新连接,还会打印连接被拒绝的错误信息,这个错误是直接写入 socket 然后关闭,不是通过正常的命令响应通道。所以客户端侧看到的是一个read返回 0,或者缓冲区中带着一段-ERR max number of clients reached文本。

这里有一个容易被忽略的细节:maxclients的上限不仅是配置,还受内核参数fs.file-max和ulimit -n影响。举个实际例子,我遇到过一台机器ulimit -n是 65535,Redis 的maxclients配置了 60000,看起来没问题,但 Redis 本身还要用 fd 处理监听 socket、主从连接、日志文件等,实际可用连接数会低于 60000。真到连接打满时,大家看到的现象是ERR max number of clients reached,但根因很可能是系统 fd 耗尽。

maxclients相关参数汇总:

配置/参数作用范围默认值说明
maxclientsRedis 应用层10000最大连接数,包含普通客户端和集群内部连接
tcp-backlog内核层511listen 队列长度,受somaxconn限制
ulimit -n系统层1024(常见)进程最大 fd 数,Redis 启动会检查
tcp-keepaliveTCP 层300s探活包发送间隔
timeoutRedis 应用层0空闲连接回收时间

5. 断开连接的隐藏细节:EOF、freeClientAsync 与真实排障

5.1 读事件返回 0 的语义与内存安全

当客户端连接正常关闭,Redis 的read会返回 0。但因为整个 Redis 主线程是单线程事件循环,如果在一个事件回调里直接 free 掉当前正在处理的 client 结构体,会产生两类问题:

  • 当前调用栈上还在引用这个client,函数返回到事件循环后,可能再次访问已释放的内存,造成崩溃。
  • 这个连接可能还有数据在c->reply里没写完,直接关闭会导致数据丢失。

所以freeClientAsync的设计是:先把 client 加到一个clients_to_close链表,标记为CLIENT_CLOSE_ASAP,事件循环在安全时机统一执行真正的释放。

void freeClientAsync(client *c) { if (c->flags & CLIENT_CLOSE_ASAP) return; c->flags |= CLIENT_CLOSE_ASAP; listAddNodeTail(server.clients_to_close, c); }

释放的触发点有两个:一个是beforeSleep中,一个是主循环里每次事件处理之后。所以在事件循环压力较大的时候,连接的回收可能不是即时的,但你不太会感知到,因为对于已经断开的连接来说,Redis 只是晚一点清理内存而已。

5.2 延迟释放临时对象,为什么还要立刻清理内核缓冲区

除了 client 对象的延迟释放,Redis 还有一种"快速清理"路径。比如在readQueryFromClient里遇到nread == -1 && errno != EAGAIN,表示读出错(例如 ECONNRESET),这时 Redis 会先把connSetReadHandler(conn, NULL)把读事件摘掉,再freeClientAsync(c)。摘掉读事件是为了避免事件循环继续对这个 fd 产生兴趣,而异步释放是为了避开当前调用栈的引用问题。

这个"先摘事件、再异步释放"的顺序,是网络编程里很经典的内存安全套路。自己写高并发网络服务时也应该照这个结构来:一个连接出错时,第一步永远是防止事件循环再次分发给它,第二步才是清理资源。

5.3 从建连逻辑反推两个高频线上问题的根因

结合前面所有内容,我实际遇到的三个比较典型的案例,可以从建连逻辑给出根因解释。

第一个案例:某服务每过一段时间就报ERR max number of clients reached,但通过CLIENT LIST看到实际连接数并不高。

这个很经典。问题不在 Redis 应用层,而在 TCP 层。大量客户端连接处于CLOSE_WAIT或TIME_WAIT状态,进程没有正确关闭 fd,导致内核连接还没释放,Redis 又无法感知到这些连接已经半死不活。CLIENT LIST会显示这些连接是空闲的,但maxclients已经把它们算进去了。解决方式:查ss -state all看连接状态分布,处理客户端侧的 fd 泄漏,同时调小tcp-keepalive让 Redis 更快探测到死连接。

第二个案例:服务刚启动时明明没多少人连接,但客户端 connect 总是超时,Redis 日志无任何异常。

这种基本就锁死在tcp-backlog和somaxconn的配合上了。瞬时连接建立速率大于 backlog 队列处理速度,新连接的三次握手虽然完成,但没有地方排队,客户端表现为 connect 超时或重试。调大这两个参数后通常立竿见影。

第三个案例:客户端连接建立成功,但发送第一条命令后偶尔收到NOAUTH。

绝大多数情况下是连接池在连接被服务端超时回收后复用了旧连接。解决方案是客户端连接池开启空闲连接检查,确保每次复用前先验证连接状态,或者设置 Redis 的timeout大于连接池的空闲回收时间。

6. 从建连逻辑看监控指标配置

最后从一个运维视角补充一些和建连直接相关的监控点。很多人监控 Redis 只看connected_clients,但这个指标对提前预警不够敏感,因为它只反映瞬时连接数。实际建连问题上,更值得关注的是下面这几个增量统计指标:

指标来源说明
total_connections_receivedINFO STATS累计接受的连接数,趋势上升过快说明有连接泄漏
rejected_connectionsINFO STATS被maxclients拒绝的连接数,大于 0 就要告警
instantaneous_ops_per_secINFO STATS建连风暴通常伴随着命令数陡然变化
connected_clientsINFO CLIENTS和连接配额配合看占比

如果total_connections_received在短时间内大幅上涨,但connected_clients平稳,那说明大量连接建立后又被快速关闭,典型模式是客户端连接池配置不当或健康检查过频,这种反复建连的抖动会放大 TCP 握手开销,也会让内核的连接表出现大量 TIME_WAIT。

我自己的经验是,把rejected_connections和total_connections_received做成同一张图,再叠加connected_clients,基本能一眼分辨出是哪类问题:connected_clients触顶且rejected上涨,是配额问题;total_connections_received飙升但connected_clients很低,是连接抖动;connected_clients很高但rejected为 0,说明还有隐性 fd 瓶颈。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询