TCP数据传输这个话题,日常开发里最容易被忽略,可真出问题的时候又最让人头疼。前阵子某支付对接系统就是栽在这上面:回调入口一到整点就连接尖峰,后端机器数并不算少,但connect timeout报警一条接一条,应用代码翻来覆去查不出名堂。后来我们往链路中间加了一层Nginx,用它的stream模块做TCP转发,把入向连接先收拢、再分发,问题才算从根上缓解。这篇东西想把Nginx处理TCP数据流的经验完整梳理一遍——它到底解决了什么问题、配置骨架怎么搭、调优参数背后是什么网络原理、排查链路怎么走,以及哪些场景其实不该用它。无论你是后端开发、运维还是在做网关中间件选型,这篇文章应该都能给你一些可直接复用的判断依据。
1. 为什么会有“TCP数据要交给Nginx”这种需求
1.1 连接压力与“调度出口”问题:从一次回调风暴说起
先说回那个回调系统的场景。应用本身逻辑不重,QPS也不算夸张,但客户端那边有很多批量任务,一到整点集中触发,短时间内在TCP层产生大量新建连接。新建连接这件事比很多人想象中更消耗资源——三次握手要占用内核协议栈的sock结构,要分配接收/发送缓冲区,还要经过accept队列进入应用。后端服务每秒钟能accept的连接数是有上限的,一旦瞬时建连速率超过处理能力,连接就开始在SYN队列里排队,队列满了之后,新连接直接被内核丢弃,客户端表现就是connect timeout。
这种情况最直接的解法并不是无限加后端机器,而是在前面放一个专门的“连接调度层”。这个调度层不需要理解业务协议,只需要把TCP数据流完整地接到自己身上,然后按策略转发给后端的某一台机器。它把“海量瞬时连接”和“后端处理能力”之间的压力差消化掉了。Nginx的stream模块干的正是这件事。它工作在四层,只关心TCP流的建立、维持和转发,不关心流的内部承载的是什么协议。
我当时第一次想到Nginx,是因为团队对Nginx已经非常熟:配置文件结构、reload机制、错误日志格式,大家都顺手。引入一个新的LVS或者硬件负载均衡器,学习成本和运维改造成本反而更高。这个判断后来验证是对的——TCP转发配置比HTTP配置还简单,真正花时间的反而是参数调优和问题定位。
1.2 Nginx在四层协议栈里的生态位
要理解Nginx做TCP转发的定位,先把它和HTTP反向代理区分开。常规的Nginx配置里,server块写在http上下文内,处理的是HTTP请求:解析header、匹配location、转发后还要管响应状态码。而stream模块是另一个独立的上下文,它不解析协议内容,只做四层透传。
打个比方:HTTP反代像一个前台接待员,要问清楚你来干嘛、找谁、什么诉求;TCP转发更像一个火车站调度台,只负责把每趟列车引导到正确的轨道上,列车上装的是什么货完全不打开看。正因为它不拆包,所以对协议完全无感知——MySQL连接、Redis命令、自定义TCP长连接、游戏同步消息,全都一视同仁地转发。
这也带来了一个关键优势:几乎任何基于TCP的业务都能在不需要改代码的情况下被接入到Nginx后面。后端服务不需要关心客户端来自哪里,因为Nginx替它把连接都接住了。客户端也不需要关心后端拓扑,因为对客户端来说,它只看到Nginx的IP和端口。
和同类工具对比,LVS性能确实更高,但配置偏底层,通常要配合keepalived做VIP漂移,操作门槛高;HAProxy在四层转发和健康检查上也很成熟,但它和Nginx的生态不完全一样。如果团队已经用Nginx管理着大量HTTP流量,再用stream模块统一管TCP流量,运维只需要维护一套配置风格和一套发布流程,这是很现实的效率收益。
1.3 实际用得最多的几类场景
根据我见过和做过的项目,Nginx TCP转发最常见的场景有这么几类:
- 数据库只读路由:多个只读库节点,前面统一挂一个Nginx四层入口,应用只连这个入口,由Nginx按权重把连接分散到不同只读节点。
- Redis/MQ集群前端:在客户端与Redis/MQ节点之间加转发层,方便后续节点扩容时客户端零感知。
- 游戏/IM长连接网关:长连接非常考验空闲连接管理,Nginx可以用proxy_timeout控制空闲超时,把死连接清掉,避免后端口数被耗尽。
- 自定义TCP协议接入:设备上报、采集网关这类场景,协议往往是自己定义的,业务侧希望先统一收流再做分发,Nginx四层透传是最省事的接入方式。
这些场景都有一个共同点:连接生命周期往往比较长,或者建连频率很高,应用真正需要的不是一个更强的单机,而是一个能把连接打散、收敛的统一入口。
2. Nginx做TCP转发时的核心架构与配置骨架
2.1 stream上下文与http上下文的关系
Nginx从1.9.0开始把stream模块变成官方模块,之后的版本里,编译时通过--with-stream启用。官方发行的很多二进制包也默认带上了这个模块。你可以用nginx -V看一下configure参数里有没有--with-stream,这一步很多人会忽略,结果装了Nginx发现stream配置不生效。
stream和http是平级的两套上下文,在同一个nginx.conf里可以同时存在。也就是说,一台Nginx可以既处理80端口的HTTP流量,又在3307端口转发MySQL连接,两者互不干扰。配置结构大致如下:
events { worker_connections 10240; } stream { upstream tcp_backend { server 10.10.0.11:3306; server 10.10.0.12:3306; } server { listen 3307; proxy_pass tcp_backend; } }这一段就是最精简的TCP转发配置。stream块里定义了一个上游组tcp_backend,包含两个后端地址;然后server块监听3307端口,所有到达这个端口的TCP连接都被转发给tcp_backend组。Nginx作为中转,会把客户端连接与后端连接之间的数据双向搬运。
2.2 配置骨架之外容易被忽略的“会话保持”问题
如果业务对会话有要求——同一个客户端IP的请求必须始终落到同一台后端机器上——那么需要在upstream里加一条hash指令。常见写法是:
upstream tcp_backend { hash $remote_addr consistent; server 10.10.0.11:3306; server 10.10.0.12:3306; }consistent表示使用一致性哈希,这样当后端节点增减时,只有少部分客户端会被重新映射到新节点,大部分会话不受影响。这一点和HTTP层的ip_hash思路类似,但stream上下文的指令名不是ip_hash,而是hash。
我见过有人在这里踩坑:业务是短连接还好,一旦是长连接且后端保存了连接级状态,结果负载均衡把同一个客户端的连接分发到不同后端,导致后端各自维护了半截会话,数据错乱。排查到最后才意识到是上行转发策略的问题。
2.3 多端口多后端的组织方式
实际业务不可能只转发一个端口。数据库、Redis、MQ、设备接入可能各有各的端口,每个端口的后端节点也不一样。如果把所有server块堆在一个nginx.conf里,文件会越来越难维护。我的习惯是把stream配置拆开,按业务方向建立单独的配置文件,然后用include引入:
stream { include /etc/nginx/stream.d/*.conf; }每个*.conf文件里管理一组上游和对应的server监听端口。比如mysql-ro.conf里放只读库转发,redis-cache.conf里放缓存集群转发。好处很明显:改动某个业务的转发只动对应文件,nginx -t检查也只关注这个片段,减少误操作影响面。
多端口场景下还需要注意监听端口本身的规划。尽量选择不常见的高位端口作为Nginx入口,避免与后端真实端口冲突,也方便防火墙规则按端口精确放行。比如MySQL真身跑在3306,Nginx入口用3307,后端收到连接后看到的源IP是Nginx内网IP,而不是真实客户端IP,这个问题下一节讲proxy_protocol时会展开。
3. 调优参数背后真实的网络原理
3.1 proxy_timeout的真实含义:空闲,不是最大工作时长
stream模块里最容易被误解的参数是proxy_timeout。很多人以为它控制的是“一个连接最多转发多久”,超时就掐断,其实它控制的是空闲超时:如果Nginx与上游之间在这段时间内没有任何数据传输,连接就会被断开。
默认值是10分钟。这意味着一个连接建立后,即使业务啥也不干挂在那里,10分钟后也会被Nginx主动关掉。对于像数据库连接池这种长期保持空闲连接的场景,这个默认值可能过短,容易导致连接被Nginx频繁回收,应用侧报“Connection closed unexpectedly”。而对于设备上报这类短交互场景,10分钟又太长了,死连接会堆在那里占用文件描述符。
所以配置时要结合业务形态来判断。我的经验是:交互频繁的服务,可以放宽到600s甚至1200s;低频上报、长轮询类服务,设置60s更合适,配合client端的自动重连机制,能有效清理掉死连接。另外要区分proxy_timeout和proxy_connect_timeout——后者控制的是Nginx与上游建立TCP连接的超时,一般默认60s,如果后端网络异常,这个值可以收紧到3~5s,让故障快速暴露。
3.2 keepalive连接池:解决上游频繁建连
在HTTP模块里,upstream keepalive是常见的连接复用指令。stream模块里也有对应的能力,但用法有差别。stream里需要在proxy_pass那个server块中,配合proxy_socket_keepalive来理解。
在TCP转发场景下,Nginx与上游后端之间可以建立一个连接池,每次有客户端连接进来时,如果能复用池里已有的上游连接,就不需要再经历一次TCP握手。配置通常是:
upstream tcp_backend { server 10.10.0.11:3306; keepalive 32; } server { listen 3307; proxy_pass tcp_backend; proxy_socket_keepalive on; }keepalive 32表示每个worker进程保留最多32个空闲上游连接。连接池的好处是减少握手开销和TIME_WAIT连接数量。建连变快了,整体延迟就降下来了,后端机器的连接波动也会小很多。连接池的大小不需要设置得特别夸张,一般8~64之间比较合适,设太大反而占内存。
3.3 socket层面的几个隐藏参数:reuseport与TCP_NODELAY
TCP转发场景里,socket参数直接影响连接建立和转发效率。这里说几个平时不容易注意但实际影响很大的点。
第一个是reuseport。Nginx默认由多个worker进程共同accept同一个监听端口。在Linux内核不支持SO_REUSEPORT的旧版本里,多个worker之间通过锁竞争来决定谁能accept,压力大的时候锁竞争本身会成为瓶颈。开启listen 3307 reuseport;之后,内核会为每个worker独立维护一个接收队列,连接进来时由内核直接分发,避免了应用层的锁竞争。对高连接数场景,这个参数的提升是实打实的。
第二个是tcp_nodelay。在stream上下文配置tcp_nodelay on;,相当于对转发socket设置TCP_NODELAY,禁用Nagle算法。Nagle算法会把小包攒在一起再发送,虽然提高了网络利用率,但增加了交互延迟。做TCP转发时,尤其是数据库这类交互型协议,每次连接都希望数据尽快到达对端,开启NODELAY能减少延迟抖动。没有特殊理由,这个参数建议直接设成on。
第三个是proxy_socket_keepalive。它控制Nginx与上游连接是否开启TCP keepalive探测包。开启后,上游如果长时间没数据,Nginx会定时发探测包确认链路是否还活着。防止网络设备静默断开连接后,Nginx这边还傻傻地保留着已死掉的连接。
4. 多节点编排:负载均衡与健康检查
4.1 三种主流转发策略的选择逻辑
stream模块里转发策略主要由upstream块决定,最常用的三种是round-robin(默认轮询)、least_conn(最少连接)、hash(哈希取模/一致性哈希)。选择不是拍脑袋,得看业务特性。
如果后端是无状态的短连接服务,轮询最公平,每个节点拿到的连接数接近均等。如果连接生命周期差异很大——比如有的客户端连上来挂几分钟,有的连几秒就断——轮询就可能出现某一台机器因为积累了多个长连接而负载偏高,这时候least_conn更合适,每次都把新连接交给当前活跃连接数最少的节点。如果业务有会话保持要求,粘滞性连接必须落在同一节点,那就用hash。
我常用的策略是“least_conn + hash备用”:默认保持会话用一致性哈希;如果后端处理能力真的出现倾斜,先排查是不是某个节点异常,而不是盲改策略。转发策略本质是对后端能力的预判,后端机器配置不一致时,还可以用weight倾斜权重,比如大规格节点weight设为2,让它的连接数约为小规格节点的两倍。
| 策略 | 适用场景 | 注意点 |
|---|---|---|
| round-robin | 短连接、后端规格一致 | 长连接场景可能分配不均 |
| least_conn | 连接时长差异大 | 需要关注后端处理耗时不均衡 |
| hash | 会话保持、缓存亲和 | 节点增删时映射可能漂移 |
4.2 passive健康检查与max_fails的真实语义
stream模块默认不主动探测上游健康状态,它用的是被动健康检查:只有当请求转发失败时,才把节点标记为不可用。控制这个机制的关键参数是max_fails和fail_timeout。
upstream tcp_backend { server 10.10.0.11:3306 max_fails=2 fail_timeout=10s; server 10.10.0.12:3306 max_fails=2 fail_timeout=10s; }含义是:在fail_timeout(10秒)窗口内,如果转发失败次数达到max_fails(2次),节点就被标记为不可用,并且在接下来的fail_timeout时间内不再接收新连接。两个参数的默认值分别是1和10s。有些生产环境把这个窗口调成3~5次,避免后端偶发GC或网络抖动就立刻被摘除,但也不能调太大,否则故障节点会在很长时间里持续接收流量。
这里有一个很关键的区别:HTTP反向代理可以通过后端返回的5xx状态码来判定失败,但TCP转发没有状态码概念,判定标准是“连接是否成功建立”以及“建立后是否立刻被重置”。所以TCP健康检查的结果更粗糙,它只能感知“连不上/连上就断”,感知不了后端内部处理是否缓慢。如果需要更精细的健康状态,还是要依靠后端业务自身的存活接口,或者外部监控系统配合摘流。
4.3 reload与连接迁移:增删节点时的操作细节
Nginx的reload是热加载,不会断开已有的TCP连接。worker进程在reload时,正在处理的旧连接会继续由旧worker完成,直到连接自然关闭。这带来一个要注意的点:如果你用reload把某个后端从upstream里去掉了,已经建立的连接不会立刻断开,而是会继续转发到旧地址。对于数据库这类不允许连接飘移的协议,这可能导致连接短暂指向一个“已下线”的节点。
我处理节点下线时一般分两步:先把节点从DNS/配置中摘除,reload等新连接全部切走;再观察其活跃连接数,下降到接近0后,再真正停掉该节点服务。通过Nginx来“优雅下线”是它作为流量调度层很好用的能力。
同样,新增节点也建议先加到upstream里,观察一段时间的错误率和延迟,确认稳定后再把权重调高。一次性把大流量引入新节点,一旦新节点有问题,整个集群都会跟着抖。
5. TCP转发不起作用的排查链路
5.1 先分清症状属于哪一类
TCP转发出问题时,客户端表现千奇百怪,但归纳起来就那么几类。先判断属于哪一类,能帮你少走一半弯路。
| 症状 | 常见可能原因 |
|---|---|
| connect timeout | Nginx监听端口没起、防火墙丢弃、后端没监听 |
| connection reset | 后端主动拒绝、Nginx与被转发端协议不匹配 |
| 转发成功但偶发失败 | max_fails生效摘节点、后端连接数打满 |
| 客户端能连上但无数据 | proxy_timeout过短误杀长连接、缓冲区参数问题 |
有一类非常隐蔽的“connection reset”值得单独说:Nginx转发时,如果proxy_pass指向的后端解析出来的地址,客户端根本无权访问——比如后端端口只绑定了内网IP,而Nginx因为某种原因走了另一个出口IP——那么连接建立后就会被后端RST。这种问题从Nginx日志上看往往很正常,必须靠网络层数据才能发现。
5.2 从客户端到后端的完整排查顺序
我处理TCP转发问题的排查顺序基本固定:从最外层到最里层,每层只做两件事——确认监听、确认连通性。
第一步,在客户端机器上确认到Nginx入口的连通性:
ss -nltp | grep 3307 telnet 192.168.1.10 3307 nc -vz 192.168.1.10 3307能看到端口已监听,且telnet能通,说明客户端到Nginx这一层链路正常。如果telnet不通,要么Nginx没监听,要么中间防火墙拦截。注意检查Nginx监听的IP是否绑定正确——有时候配置里写listen 127.0.0.1:3307,客户端走内网IP当然连不上。
第二步,在Nginx本机确认到后端的连通性:
ss -nltp | grep 3306 curl -v telnet://10.10.0.11:3306在Nginx所在机器上直接telnet后端端口。如果Nginx能连后端但客户端通过Nginx转发连不上,问题大概率出在Nginx配置本身——proxy_pass写错端口、upstream名称拼错、或者include的配置文件没有生效。
第三步,抓包确认数据是否真的到达了Nginx和后端:
tcpdump -i eth0 port 3307 -nn抓包能看到SYN包是否到达、是否有RST包返回。防火墙拦截通常表现为“无任何响应”,后端拒绝通常表现为“收到SYN后返回RST”。这两个现象的区别能帮你迅速锁定是策略阻断还是服务拒绝。
5.3 一次“转发不通”的模拟定位过程
抽象地讲步骤不如看一次模拟排查。假设这样一个环境:客户端访问192.168.1.10:3307,Nginx配置了转发到后端10.10.0.11:3306,但客户端始终报connect timeout。
先在客户端上nc -vz 192.168.1.10 3307,如果超时,说明SYN包没到Nginx或被丢。登录Nginx确认监听:
ss -nltp | grep 3307发现端口确实在听,于是看Nginx的错误日志:
tail -f /var/log/nginx/error.log日志里没有accept错误,此时用tcpdump确认SYN是否到达网卡:
tcpdump -i eth0 tcp port 3307 -nn如果SYN到达网卡但服务没响应,可能是防火墙把入站包丢了,检查iptables相应链。如果SYN正常响应,问题可能在转发路径上——再抓后端端口:如果Nginx发起向后端的SYN后没有收到响应,说明后端节点不可达;这时去查Nginx到后端的路由,可能发现后端网络隔离策略,导致Nginx内网IP无法访问后端的3306端口。
整个链路排查下来,往往发现不是某一个环节大问题,而是好几个小问题叠加。比如后端只监听在127.0.0.1上没有监听内网IP,Nginxproxy_pass写的是域名但resolver没配好,解析失败导致连接无法建立。所以我的建议是:抓包永远比看日志来得快,tcpdump的输出是网络层的“实锤”。
6. 什么场景该用Nginx做TCP,什么场景不该用
6.1 适合的场景:协议简单、连接生命周期长、需要快速接入
Nginx做TCP转发最大的价值是“快速、统一、低成本接入”。你不需要改后端代码,不需要换框架,只需要配一段stream块,流量就能被收拢和调度。对以下几种情况它是第一选择:
- 后端已经有成熟的连接池和重连机制,需要前端统一入口时。
- 业务对HTTP协议无感,纯粹是自定义TCP或数据库长连接。
- 需要在不停服的情况下完成后端节点的增删和权重调整。
- 团队已经深度使用Nginx,不想再引入一套新组件。
在这些场景下,Nginx的stream模块做得很好:配置简单、reload安全、参数透明。它和我们熟悉的Nginx运维体系完全打通,排错路径清晰。
6.2 不适合的场景:超高并发小包、协议感知、强可观测性
Nginx不是万能的。做TCP转发时,它有几个明显的“够不着”的地方。
如果业务是超高并发小包转发——比如行情推送、高频交易这类每秒钟几十万次连接、每次只传几十字节的场景——Nginx这种用户态进程做中转的架构,性能和吞吐都打不过内核态的DPDK方案或者纯内核转发的LVS。用户态转发意味着每个包都要经过两次协议栈进出,多了系统调用和上下文切换的开销。这种场景老老实实用LVS或者DPDK方案更合适。
如果业务需要感知协议内容并做精细路由——比如根据MySQL请求里的库名转发到不同集群,或者根据Redis key的前缀做分片——四层转发完全做不到。这种场景得用专门的数据库中间件(比如标准MySQL proxy层)或业务侧路由,Nginx在四层“不拆包”的设计反而成了硬边界。
如果业务强依赖健康检查和详细指标,Nginx的被动健康检查可能不够用。四层层面的健康检查只能识别“能不能建立连接”,没法感知后端响应时间变长、CPU飙高这类性能劣化。这时候需要外部监控系统定期探测业务层健康接口,发现问题后通过Nginx配置摘除节点。
6.3 我对比过几个方案后得出的结论
我在实际项目里对比过LVS、HAProxy和Nginx stream三种方案。LVS确实吞吐高,但VIP漂移、后端配置这些操作比较重,适合大流量核心入口;HAProxy的连接数统计和健康检查做得比Nginx细,但对HTTP生态的接管能力不如Nginx强;Nginx stream胜在“全家桶”效果——HTTP、TCP、静态资源、统一配置一套体系全管了。
结论是:如果你在选主入口,优先LVS或者其他性能更强的四层方案;如果你在给业务模块前面加转发层,优先Nginx stream。前者是“数据中心级”的调度,后者是“业务级”的接入。两者不是竞争关系,而是分工关系。
7. 我在生产环境沉淀下的注意事项
7.1 配置管理上的四个习惯
TCP转发配置看起来简单,但生产环境里炸过好几次之后,我给自己定了几条规矩。
第一,任何变更先nginx -t再nginx -s reload,这条看似基础,但当配置文件多了之后,经常会引入重复的server块或端口冲突,语法检查未必查得出来,所以还要仔细看reload时的日志输出。
第二,upstream节点变更要灰度。先在权重上做调整,比如新节点weight=1,观察几分钟再调大。生产环境的故障很少是配置写错,更多是流量瞬间压垮刚上线的节点。
第三,给每个转发server块写注释,说明这个端口是给谁用的、后端是什么、为什么timeout设成这个值。TCP配置相比HTTP配置更“安静”,没有请求日志可查,没有upstream响应码可看,唯一能依赖的就是维护者脑子里的上下文。写清楚注释,等于把上下文固化到文件里。
第四,保留上一版本的配置快照。reload之后如果业务异常,能快速比对当前版本和上一个版本的差异。你可能会说配置文件在git里有记录,但生产机器上的“最近一次可用版本”备份,能省去很多临时比对的时间。
7.2 可观测性缺口与补全方式
Nginx的stub_status模块只统计HTTP上下文,stream模块的转发连接数、活跃连接数、上游连接失败次数这些指标,默认没有对应的页面输出。如果只依赖Nginx自带监控,TCP转发基本是黑盒状态。
我的做法是在Nginx所在机器上采集两类数据:一是ss -s看到的系统级连接数统计,二是Nginx日志里的连接关闭事件。stream模块同样可以配置access_log,记录连接建立和关闭的时间、字节数、转发耗时。把日志收集到监控系统里,通过连接数变化曲线和转发耗时分布,就能发现很多单点问题。比如某后端节点转发耗时突然从1ms变成50ms,虽然连接还通着,但业务感知已经劣化了——这种情况必须靠日志指标才能发现。
另外,重要端口建议配上拨测脚本:每5秒用nc尝试连接一次,超时标记失败。这类外部探测能发现Nginx进程存活但监听失效的“假活”状态,比只盯进程守护更靠谱。
7.3 最后一个实操小技巧
最后分享一个我在切换TCP转发时常用的技巧:在防火墙规则或后端节点调整后,不要急着切生产流量。先用nc -v在后端端口试连一下,确认链路通,再手动发一个测试连接验证整个转发链路。如果你用的是MySQL,可以直接用命令行客户端连Nginx入口,执行一条select 1,整条链路就验证完了。这个方法简单粗暴,但能挡住绝大多数“以为配好了但其实没通”的失误。
TCP数据传输与Nginx的组合,本质上解决的是“连接接入”和“连接调度”问题。它不像HTTP反向代理那样有那么多“请求/响应”语义可看,但正因为它足够底层,反而能承接几乎所有TCP协议。把stream模块的配置骨架、参数网络原理、排查顺序和场景边界都吃透之后,你会发现自己对系统的掌控力又上了一个台阶——至少,再遇到连接风暴,你知道先往哪看,而不是一头扎进应用代码里翻半天。