
干了十年后端遇到最多的不是“Redis 挂了”而是“Redis 连不上”“连接数飙了”“延迟突然高起来”。这些问题看着是运维活根子其实全在网络建连这条链路上。我一直觉得Redis 源码里最值得精读的就是networking.c和事件循环ae.c把网络建连逻辑吃透线上很多疑难杂症不需要猜看一眼日志和参数就能定位。今天就把这块拆开揉碎了讲面向的是想深入理解 Redis 原理、准备面试、或者被线上连接问题折磨过的朋友。我不会给你贴一大堆源码然后让人自己看而是沿着一条真实的连接请求路径走一遍从客户端 TCP 握手开始到 Redis 接受连接到注册读事件再到第一条命令被解析执行。每一步我都会讲清楚“这段代码为什么要这么写”“这个参数慢了会有什么后果”全是这些年踩坑和读源码攒下的内容。1. 网络层整体设计一台单线程服务如何扛住十万连接1.1 Redis 高性能的底层基座不是“单线程”而是多路复用很多讲 Redis 性能的文章开口就是“Redis 是单线程的所以快”。这句话对但容易误导人。单线程本身不产生性能真正让 Redis 在单线程下扛住高并发的是它的 I/O 多路复用机制——在 Linux 上就是 epoll在 macOS 上是 kqueue在 Windows 上则用 select 模拟虽然官方不推荐生产环境用 Windows 版。Redis 所有网络操作围绕一个核心结构aeEventLoop。你可以把它理解成一个“事件总调度台”它维护着两类事件文件事件file event套接字可读、可写。时间事件time event定时任务比如serverCron。主循环aeMain()做的事情极其简单准备好文件事件和时间事件调用aeProcessEvents()进入阻塞等待。epoll 一旦发现有 fd 可读就调用对应的回调函数。这个回调函数是在创建事件时注册好的——监听套接字注册的是acceptTcpHandler客户端连接注册的是readQueryFromClient。搞清楚这个模型你就能理解为什么 Redis 能支撑“几万个连接依然毫秒级响应”因为大部分连接是空闲的epoll 不需要轮询内核直接把“有数据到达”的 fd 告诉 RedisRedis 只需要处理“真正有数据”的连接。1.2 三种监听 SocketTCP、TLS、Unix Socket 各管什么Redis 的监听套接字不是只有 TCP 一种。initServer阶段会做三件事调用listenToPort创建 TCP 监听套接字默认端口 6379。如果配置了 TLStls-port创建 TLS 监听套接字。如果配置了unixsocket创建 Unix Socket 监听套接字。三种套接字对应的 accept 回调也不同acceptTcpHandler、acceptTLSPHandler、acceptUnixHandler。从代码设计上看Redis 在 6.0 之后引入了connection抽象层conn.c把 TCP、TLS、Unix Socket 统一成一套接口。到了 Redis 7.0这套抽象还支持了多线程。如果你只读老版本源码会看到到处都是fd直接操作新版本里则被conn*函数包了一层。关于 Unix Socket 多说一句很多人不知道当 Redis 和客户端在同一台机器上时用 Unix Socket 比 TCP 回环地址性能更好。因为它省掉了 TCP 协议栈的封装和拆封直接走内核的 socket 文件读写。我在压测场景里见过的差距大约在 10% 到 15%。所以如果延迟敏感、又允许本机访问建议把unixsocket配置加上。2. 一次 TCP 连接从握手到注册的全链路拆解2.1 accept 的入口和惊群问题的真相客户端发起 TCP 连接时内核完成三次握手后连接会进入监听套接字的 accept 队列。Redis 的aeMain在 epoll_wait 中醒来发现监听 fd 可读调用acceptTcpHandler。这个函数的逻辑线性得很调用anetTcpAccept底层是accept()拿到已连接套接字 cfd。拿到 cfd 后acceptCommonHandler(cfd, 0, NULL)统一处理比如进行连接数限制检查、创建 client 对象。如果accept()返回EAGAIN说明暂时没有新连接直接返回。如果返回EMFILE文件描述符用尽会打印日志并短暂延迟防止 CPU 空转。这里很多人问惊群问题多个进程/线程同时 epoll_wait 在同一个监听 fd 上当新连接到来时内核会唤醒多个等待者造成无谓竞争。Redis 默认是单线程事件循环只有主线程在 accept所以不存在惊群。但 Redis 7.0 引入了多线程事件循环io-threads开启后严格说可能引入这个风险。Redis 官方的做法是在 Linux 上设置SO_REUSEPORT让内核把新连接分发到多个监听套接字上实现负载均衡。不过这个只有较新版本支持生产环境默认保持单线程事件循环没什么问题。2.2 createClient新连接的“户口”登记acceptCommonHandler里有一段关键逻辑if (server.maxclients listLength(server.clients) server.maxclients) { // 发送错误信息并关闭连接 rejectCommandFormat(c, -ERR max clients reached\r\n); }这里的判断是listLength(server.clients)也就是当前已登记的所有客户端数量。需要注意的是主从复制节点、Monitor、Pub/Sub 订阅者都算在server.clients里所以maxclients是全局的不只是普通业务连接。通过检查后createClient会做一系列初始化分配client结构体内存初始化输入缓冲区querybuf、输出缓冲区buf和回复链表reply。把新连接设置成非阻塞模式anetNonBlock(cfd)。根据连接的端口号判断是否为本地回环地址如果是则跳过protected-mode的 ACL 限制源码里通过ok值标记。调用connSetReadHandler/aeCreateFileEvent把 cfd 的可读事件注册到事件循环上回调是readQueryFromClient。这里有个容易忽略的细节建立连接这个动作本身不读数据。客户端第一个字节到达时epoll 才会触发readQueryFromClient。也就是说Redis 对空闲连接几乎是零成本的不占用 CPU只占一个 fd。这也是它能保持几十万空闲连接的底气。2.3 文件描述符耗尽最容易被忽略的崩溃点每个 TCP 连接对应一个 fd。Linux 默认进程级 fd 限制是 1024生产环境一般会调高到 65535 或更高。Redis 启动时会在initServer阶段尝试通过setrlimit提高自身 fd 限制但受系统硬限制约束。如果 fd 耗尽会发生什么accept()返回EMFILE。这时候 Redis 不会崩溃但会打印日志Accepting client connection: error: Too many open files然后临时把监听 fd 从事件循环中移除延迟一段时间再重新加入。这个机制是为了避免“accept 失败 → 立刻重试 → 立刻失败”的忙循环。但是代价是在这段退避窗口内新连接一个都进不来。线上如果看到这种日志第一反应该是前面有连接没释放而不是急着加maxclients。判断连接释放问题有个很实用的命令ss -tan | grep 6379 | wc -l如果连接数逼近ulimit -n再配合redis-cli client list看 connected clients 的分布基本能定位是谁在占 fd。3. 连接建立之后数据如何从 socket 变成命令3.1 第一个可读事件readQueryFromClient 是如何被触发的当客户端发来数据epoll 触发 cfd 可读Redis 调用readQueryFromClient。这个函数是网络读的入口逻辑核心是通过connRead从 socket 读数据到client-querybuf。调用processInputBuffer解析并处理输入缓冲区里的命令。如果客户端设置了CLIENT_READ_ONLY之类的限制或者连接已关闭则不继续读。注意Redis 6.0 之后如果开了 IO 线程io-threads配置读 socket 这一步可以由多个 IO 线程并行完成但命令的解析和执行仍然在主线程。为什么要这么设计因为 Redis 核心数据结构的操作必须串行否则就要加锁加锁带来的性能损失比多线程收益还大。IO 线程只做“把数据从内核拷贝到用户态”这种天然可以并行的事情。我实际测试过在大量小命令场景下开启 2-4 个 IO 线程能提升 20%-40% 的吞吐但如果是大 value 或慢命令IO 线程收益有限瓶颈转移到了命令执行本身。3.2 输入缓冲区 querybuf 的设计与内存风险querybuf是每个 client 独立的输入缓冲区类型是sds。它的增长策略是动态扩容但有一个致命问题容量只会涨不会自动降。举个例子一个客户端发送了一个 100MB 的批量命令比如set big_key 100MB 数据querybuf 会膨胀到 100MB 以上。命令执行完后sds并不会把内存归还给操作系统而是继续占用。如果这种客户端多了Redis 的内存会被查询缓冲区悄悄吃掉一大块used_memory飙升但你的 key 其实没占多少。针对这个问题的解决办法使用client-query-buffer-limit配置限制单个客户端 querybuf 的最大值。默认是 1GB这个值在生产环境往往偏大建议根据实际业务的最大命令体积来设比如 64MB 或 256MB。定期执行redis-cli memory doctor或监控memory stats里的client-query-buffers字段。对客户端做规范约束大批量写入拆分成小块或者用 pipeline 但控制单批大小。另外processInputBuffer在解析协议时会先做一次简单的协议合法性检查。如果消息头不符合 RESP 协议比如第一个字符不是*Redis 会直接记日志并关闭该连接。线上看到大量这种报错通常是客户端用了错误的序列化工具或代理层协议没配对。3.3 输出缓冲区的不同命运普通客户端和 Pub/Sub 的差异有输入就有输出。Redis 的回复先写入client-buf固定长度 16KB 的临时缓冲区写不下的部分追加到回复链表reply中然后注册写事件等 fd 可写时再 flush。这里最容易踩坑的是 Pub/Sub 订阅者。普通 GET/SET 的回复量小缓冲区很快刷走但 Pub/Sub 模式下如果订阅者消费速度跟不上生产速度回复链表会不断堆积。Redis 有一个保护机制client-output-buffer-limit pubsub默认是32mb 8mb 60表示硬限制 32MB、软限制 8MB 且持续 60 秒。超过限制后 Redis 会强制断开这个订阅者防止内存被单个慢消费者拖垮。同样需要注意的还有复制客户端client-output-buffer-limit slave保护主从复制的同步数据。所以如果你的 Redis 内存涨得莫名其妙除了查 bigkey还要查一下是不是有慢消费者堵住了输出缓冲区。3.4 命令执行主链路的最后一公里数据从 querybuf 解析出来processInputBuffer会按\r\n分割出多个命令参数形成argv和argc然后调用processCommand。这之后的事情命令查找、执行、ACL 校验、慢日志记录就是另一大块内容了但网络建连的使命到这里其实已经完成——从 TCP 数据流到命令入参中间经历了 accept、读事件注册、数据拷贝、协议解析四步。如果你要画一张网络请求生命周期图核心节点就是客户端 connect内核三次握手epoll_wait 唤醒accept 创建 fdcreateClient 注册可读事件数据到达触发 readQueryFromClientquerybuf 解析协议processCommand 执行命令回复写入输出缓冲区注册写事件写事件触发数据返回客户端整个链路的每一步都是异步的没有一步是“等客户端准备好”的。这也是 Redis 网络层设计的精髓全异步 事件驱动 非阻塞 IO。4. 影响建连的关键参数与调优实践4.1 参数速查表哪些参数直接决定你能连上、连得稳参数名默认值作用调优建议port6379监听端口内网尽量用非默认端口避免被扫描bind127.0.0.1 -::1监听地址多网卡环境必须显式指定protected-modeyes保护模式限制外部 IP 访问有密码时才能安全关闭tcp-backlog511accept 队列长度高并发下需要调大受系统 somaxconn 限制timeout0空闲连接关闭时间默认不关容易积累空闲连接tcp-keepalive300TCP 保活探测间隔配合 timeout 使用避免死连接占用 fdmaxclients10000最大客户端数量受 fd 限制需配合 ulimitclient-query-buffer-limit1gb单客户端输入缓冲区上限建议调小防止内存被大命令打爆4.2 tcp-backlog 怎么调为什么不是越大越好tcp-backlog是监听套接字的 accept 队列长度。客户端 connect 时如果队列已满内核会直接丢弃 SYN 包或让 connect 超时。默认值 511 对于一般业务够用但短连接高并发场景比如频繁创建连接的服务会出现 connect 偶尔失败的情况。调大 backlog 需要同时调整内核参数# 查看当前值 sysctl net.core.somaxconn net.core.netdev_max_backlog # 临时调大 sysctl -w net.core.somaxconn4096 # 永久生效写入 /etc/sysctl.conf net.core.somaxconn 4096 net.core.netdev_max_backlog 8192但 backlog 不是越大越好。accept 队列过长会导致三次握手的连接在队列里等着 Redis 主线程来处理如果 accept 速度跟不上队首连接的延迟会变大。另一个副作用是内存占用——每个等待 accept 的连接都会占用内核内存。我的经验是如果 QPS 在一万以内511 完全够用如果连接创建非常频繁比如每次请求新建连接把 backlog 调到 1024 或 2048同时把net.core.somaxconn同步调上去否则配置不生效。4.3 bind 和 protected-mode连不上时的判断优先级这是我线上排查“Redis 连不上”时最常用的排查顺序先看进程是否在监听ss -lnp | grep 6379。看bind配置是否包含客户端所在的网段。如果你 bind 了127.0.0.1外部 IP 当然连不上。看protected-mode。默认 yes 时即使 bind 了0.0.0.0外部 IP 没有密码也无法访问。看requirepass或 ACL 配置。Redis 6.0 之后推荐用 ACL而不是简单的 requirepass。很多新手一看到外部连不上第一反应是protected-mode no这在本机测试没问题但在生产环境等于裸奔。正确的做法是设置强密码或 ACL 白名单保留protected-mode yes。如果内网环境确实需要多台机器访问最好用防火墙或安全组控制来源 IP而不是直接关保护模式。4.4 文件描述符与 maxclients 的关系Redis 的maxclients默认 10000这个数字看起来很大但每增加一个连接就消耗一个 fd。fd 限制来自操作系统层ulimit -n # 查看当前 shell 的 fd 限制Redis 启动时会尝试把 fd 软限制提升到硬限制。但如果系统硬限制本身就小Redis 只能在自己的能力范围内工作。实际教训是调整了maxclients 65535却发现连接数卡在 1024先查ulimit -Hn很可能硬限制没放开。另外Redis 连接数统计里包含主从连接、集群总线连接、Pub/Sub 连接所以线上监控如果你看到 connected clients 逼近 maxclients不要只查业务连接还要用redis-cli client list看看每个连接的age、idle、flags字段。flagsN表示普通客户端flagsS表示副本flagsM主节点flagsPPub/Sub。5. 线上故障排查实录那些年我们踩过的连接坑5.1 现象一客户端报 “Connection refused”但端口明明在听这种矛盾现象我见过很多次。端口在听、进程活着为什么 refused定位步骤ss -tnlp | grep 6379确认监听地址。如果有多个网卡bind可能只绑了某一个 IP客户端访问的是另一个 IP被拒绝是必然的。查看防火墙和安全组iptables -L -n阿里云、腾讯云还要看安全组规则。用redis-cli -h 目标IP -p 6379 ping从客户端机器测试排除网络链路问题。看系统日志dmesg | tail如果出现nf_conntrack table full之类是连接跟踪表满了需要调net.netfilter.nf_conntrack_max。别忘了protected-mode yes时即便端口和监听都没有问题外部无密码访问也会被拒绝而且拒绝信息里有明确的-DENIED Redis is running in protected mode提示看清楚日志就能省掉很多排查时间。5.2 现象二连接数缓慢增长内存也在涨找不到大 key这个案例印象很深。某次线上 Redis 内存逐步爬升到接近 maxmemory用redis-cli --bigkeys扫了半天没有发现特别大的 key。后来才意识到是 querybuf 和输出缓冲区在吃内存。redis-cli info memory里有两项mem_clients_normal mem_clients_obey正常的业务连接 client 缓冲不会占用太多但如果某个客户端脚本用长连接批量发送超大命令querybuf 会持有一大块内存。推荐处理方式# 查看所有客户端的输入输出缓冲区占用 redis-cli client list | awk {print $2, $10, $11}重点看qbuf输入缓冲和obl、oll输出缓冲链表。如果某个连接qbuf高达几十 MB基本可以确定是它把内存吃掉了。用CLIENT KILL ID id可以清理掉这些异常连接释放内存。更彻底的预防开启client-query-buffer-limit并监控 output buffer 指标。5.3 现象三连接风暴导致 Redis 延迟瞬间飙升某个业务上线后短连接突然暴增客户端反馈“Redis 超时”。读源码之前我一直以为是 Redis 撑不住那么大的量后来发现瓶颈在 accept 本身。当 Redis 事件循环一直在处理 accept 事件时readQueryFromClient和命令执行的调度会被挤压。对 TCP 来说accept 是一个快速操作但如果连接数从几百瞬间涨到几万这个“快速操作”也要花费可观的 CPU 时间。排查方法redis-cli info stats看total_connections_received和instantaneous_ops_per_sec。pidstat -p redis_pid 1看用户态 CPU 占用。如果 CPU 高且集中在主线程连接风暴是大概率原因。解决思路有两个方向一是从客户端侧改成连接池控制连接数峰值二是从服务端侧调大maxclients和tcp-backlog让连接不被拒绝但这不是治本。治本永远是业务侧不要频繁创建和销毁连接复用长连接才是正道。5.4 一个小技巧用 strace 直接看 Redis 的 accept 行为读源码归读源码线上排查最快的方式是看系统调用。strace 在不影响服务的情况下抓取一小段时间strace -f -e traceaccept4,epoll_wait,read,write -p redis_pid -o /tmp/redis_trace.log等几秒后 CtrlC看 accept 调用的频率。正常长连接业务下accept 调用很少如果每隔几毫秒就有一个 accept说明客户端在不断重建连接这本身就是需要优化的信号。6. 阅读源码的门道从哪几个文件看起6.1 主干路径文件清单想系统看 Redis 网络建连逻辑不用从server.c一行行啃按下面的路径读效率最高文件核心内容重点函数src/ae.c事件循环aeMain、aeProcessEvents、aeCreateFileEventsrc/anet.c网络基础封装anetTcpServer、anetTcpAccept、anetNonBlock、anetSetTcpNoDelaysrc/networking.c客户端管理、协议处理acceptTcpHandler、acceptCommonHandler、createClient、readQueryFromClient、processInputBuffersrc/conn.c连接抽象层connCreateSocket、connSocketAcceptsrc/server.c启动与初始化initServer、listenToPort我第一次完整读的时候是按“客户端连接到来”这条主线走的listenToPort→aeCreateFileEvent注册监听 →acceptTcpHandler→acceptCommonHandler→createClient→readQueryFromClient→processInputBuffer→processCommand。你会发现整个流程链路清晰每个函数的职责非常单一没有多余分支。6.2 带着问题读源码的方法源码不是背出来的是靠问题带出来的。我自己的习惯是每遇到一个线上问题先不看 blog 不看手册去源码里搜对应日志或参数反向追代码路径。举个例子如果你想知道“为什么 Redis 连接空闲不会被自动关闭”带着这个疑问搜timeout全局搜server.maxidletime找到clientsCronHandleTimeout。发现timeout 0时该函数直接返回。再往下看clientsCron知道这是每秒执行一次的时间事件。这一套走下来不仅知道了答案还看到了 Redis 的定时任务体系是怎么和网络层协作的。下次再遇到“为什么空闲连接不释放”你就能直接说清楚不是内核不回收是 Redis 默认没开启主动回收。工具方面我常用 gdbgdb -p redis_pid break acceptTcpHandler但生产环境一般不建议随便挂 gdb调试线上服务会暂停进程。更安全的做法是用日志和client list再配合本地复现。本地可以用redis-server --port 7777起一个最小实例再用 Python 或 nc 模拟各种畸形连接观察行为。6.3 学完网络层之后下一个值得攻克的模块网络建连只是 Redis 源码之旅的入口理解这条链路之后接下来值得读的几个模块是按依赖顺序递进的processCommand之后的命令分发与执行链server.c、server.h。数据库读写db.c、expire.c的过期逻辑。持久化rdb.c、aof.c特别是 fork 和写时复制。多机协作replication.c、cluster.c。为什么先把网络层放最前面因为所有上层功能都要经过这条链路它是 Redis 的“气管”。把这个搞明白再去理解主从同步、Cluster 迁移、客户端重连机制都会轻松很多。我个人读源码还有个习惯每理解一个机制就把它压缩成一张流程卡。建连这条流程卡我到现在还在用排查问题的时候对照着看效率很高。Redis 的代码最可爱的地方是命名清晰、注释到位对一个有后端基础的人来说读懂网络建连这一章大概只需要一个安静的下午加一杯咖啡。