恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Redis网络建连全链路解析:从listen到client对象落地的关键细节
首页
资讯中心
/
Redis网络建连全链路解析:从listen到client对象落地的关键细节
Redis网络建连全链路解析:从listen到client对象落地的关键细节
发布时间:2026/10/11 6:37:13
很多人在排查 Redis 连接问题时跳来跳去就那几个命令netstat看端口、redis-cli client list看连接、info clients看数量。但真正到了线上连接异常、握手超时、连接数被打满这类场景如果不懂 Redis 从监听端口到 client 对象落地的完整链路排查起来会非常被动。这篇文章我把自己啃源码和排障过程中整理出来的网络建连逻辑完整梳理一遍从listen开始一直到读事件注册、身份校验、连接回收全部走一遍。1. 建连链条的起点从 initServer 到监听队列的建立1.1 端口监听前的准备anetTcpServer 与 socket 属性Redis 的网络部分在anet.c里封装了绝大部分底层 socket 操作而在启动时initServer会调用listenToPort最终走到anetTcpServer。这里面的动作很多人想当然认为只是socketbindlisten三步其实 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再listenlisten的backlog参数来自server.tcp_backlog。这里有个大家容易混淆的地方backlog不是限定最大连接数而是内核中已完成三次握手但尚未被accept取走的连接队列长度。当进程来不及accept时新连接会在这个队列里排队。1.2 backlog 参数Redis 的 tcp-backlog 在哪里生效Redis 的tcp-backlog默认值是 5117.x 版本看配置文件是这样的tcp-backlog 511这个值会直接传给listen(fd, backlog)。而在 Linux 内核中实际生效的队列长度还要受/proc/sys/net/core/somaxconn限制如果somaxconn比 Redis 配置的tcp-backlog小内核会静默取较小值。所以很多场景下你以为自己设置了 512 甚至 1024 的 backlog实际生效的可能只有 128。注意修改tcp-backlog后如果线上并发连接建立速率非常高比如瞬时大量客户端重连可以同步检查somaxconnecho 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 cant 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 参数设置顺序很重要connNonBlock把客户端 fd 也设为非阻塞模式。Redis 不管读写都走事件循环任何阻塞的操作都会把主线程卡死这一步是必须的。connEnableTcpNoDelay关闭 Nagle 算法。如果这个不设置小数据包可能会在发送前被内核攒着等确认导致交互型命令的延迟明显上升。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里处理EOFvoid 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返回 0EOF这时候会标记这个 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 1createClient并不会立刻注册读事件处理器而是把这个 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表示永不超时。回收逻辑在clientsCronHandleTimeoutstatic 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是 65535Redis 的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 瓶颈。