连接之外:吞吐、扇出与背压
前五篇回答的都是“能挂多少条连接”。推送网关挂上了几十万个 App,真正的工作才开始:订单状态一变,消息要推给关心它的每一个人。这一篇讨论连接数之外的容量:消息量、扇出、读得慢的客户端,以及网关前后那些同样有上限的设备。读完后,你应该能估算一次推送真正产生的网络流量,知道读得慢的客户端会把数据堆在哪里,以及压测报告要记录哪些东西才能用于规划。
连接数、新建速率和吞吐是三件事
先把三个经常被混在一起的指标分开:
| 指标 | 主要花在哪里 | 常见瓶颈 |
|---|---|---|
| 同时保持的连接数 | 连接状态、fd、内存、心跳 | fd 额度、Socket 内存、连接跟踪表 |
| 每秒新建连接数 | 握手、对象分配、TLS 运算 | CPU、accept 队列、证书运算 |
| 每秒处理的消息量 | 解析、业务逻辑、序列化、下游调用 | CPU、线程池、数据库、网卡 |
一台能挂百万空闲连接的机器,未必扛得住每秒一万次新建(第 04 篇的重连风暴),也未必推得动每秒十万条消息。容量规划要对三个指标分别建模,分别压测。
一条消息推给多少人:扇出
推送网关的入口流量看起来很小,每秒几百条订单状态变化。但每条变化要推给订单相关的所有人:下单用户的几台设备、商家后台、骑手 App。一条进来、多条出去,这叫扇出。出站流量要乘上扇出:
text
出站字节/秒 ≈ 每秒事件数 × 每条事件的接收人数 × 每份消息字节数例如每秒 100 条订单事件,每条 500 字节,平均推给 20 个接收者:
text
100 × 20 × 500 B = 1,000,000 B/s ≈ 8 Mbit/s这只是消息正文。每份消息还要加上 TLS 记录头、TCP/IP 头和以太网帧头;小消息场景下,每秒的包数和系统调用次数往往比带宽先成为瓶颈。如果已经按“每个接收者每秒收几条”统计,就不要再乘一次扇出。
扇出最大的风险是广播:一条运营通知推给全部 50 万在线用户,入口只是一条消息,出口是 50 万次写入。这时序列化要尽量只做一次、多次复用,写出要分批进行,否则一条广播就能占满几秒的 CPU 和网卡。
读得慢的客户端会把数据堆在哪里
推出去的消息,不是写进 Socket 就完事了。手机进了电梯、App 被系统冻结,客户端不读,数据就会堆积。下面的实验让客户端一直不读,服务端一直写,看能写多少、堆在哪里。保存为 sendq.py:
python
import socket, subprocess
srv = socket.create_server(("127.0.0.1", 9000))
client = socket.create_connection(("127.0.0.1", 9000)) # 这个客户端从不读
conn, _ = srv.accept()
conn.setblocking(False)
sent = 0
try:
while True:
sent += conn.send(b"x" * 1024)
except BlockingIOError:
print(f"服务端写了 {sent // 1024} KiB 后 send() 返回 EAGAIN")
print(subprocess.run(["ss", "-tn", "( sport = :9000 or dport = :9000 )"],
capture_output=True, text=True).stdout)bash
docker run --rm -i capacity-lab python - < sendq.py实际输出:
text
服务端写了 2573 KiB 后 send() 返回 EAGAIN
State Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB 0 2581504 127.0.0.1:9000 127.0.0.1:41226
ESTAB 91136 0 127.0.0.1:41226 127.0.0.1:9000两行 ESTAB 分别是服务端和客户端看到的同一条连接。数据先填满客户端的接收队列(Recv-Q 约 89 KiB),对方的接收窗口变成 0 以后,剩下的都留在服务端的发送队列里(Send-Q 约 2.5 MiB)。服务端的发送缓冲满了,send() 才返回 EAGAIN;如果是阻塞模式,send() 会一直卡住。
内核缓冲满了,这是 TCP 告诉应用“对方读不动了”的信号。问题在于应用怎么回应。很多程序会把发不出去的消息先放进自己的内存队列,等可写了再发。如果这个队列没有上限,读得慢的客户端就会把网关的内存一点点吃掉。第 03 篇的实验里,一千条这样的连接就占了 500 多 MiB。
算一笔账:某个客户端每秒收到 20 条消息、只能处理 5 条,每条 1 KiB,净积压就是每秒 15 KiB。一个 1 MiB 上限的队列约 68 秒填满,算上协议头和对象开销会更快。提高服务器的连接数上限、加内存,都改变不了这条连接的收支差。
所以每条连接的队列必须有上限,并且事先定好满了怎么办:
- 丢弃过期的旧状态。 订单“当前状态”只关心最新值,旧的可以直接丢。
- 按对象合并。 同一订单的多次更新,只保留最后一条。
- 暂停上游生产, 或降低这个客户端的推送频率和精度。
- 断开长期追不上的连接, 让客户端重连后从断点补齐。
怎么选取决于数据能不能丢。订单状态可以合并成最新值;支付流水不能静默丢弃,要写进持久化日志,用消费游标记录每个客户端读到哪里,追不上的客户端断开后从日志恢复。先确定数据语义,再决定队列满了的行为。
网关前后的其他上限
服务器本身的五道关卡都放开了,端到端的容量还受链路上其他设备约束。客户端到推送网关,中间可能经过:
text
客户端的端口池 → 运营商 NAT → 防火墙连接跟踪 → 云负载均衡 → 推送网关 → 上游服务每一环都有自己的并发连接数、每秒新建数和空闲超时:防火墙的连接跟踪表(nf_conntrack_max)满了会直接丢新连接;NAT 和负载均衡的空闲超时决定了心跳间隔的上限;云负载均衡按规格限制并发和新建速率。只在服务器上用回环地址压测,验证不到这些限制。
网关调用上游时,连接池是另一个容易出问题的地方。网关接了一万个客户端,不需要对上游也开一万条连接,通常复用一小组上游连接。HTTP/1.1 的一条连接同一时间只能处理一个请求,HTTP/2 能在一条连接上并发多个 Stream。连接池太小,请求排队等连接;太大,又可能压垮上游,或者像第 01 篇那样耗尽本机端口。要同时观察:活跃和空闲的上游连接数、等待连接池的请求数、建连和首字节耗时、重试次数。
压测报告要记录什么
容量最终要靠压测确认。一份能用于规划的压测报告,至少记录这些条件,换一个条件,结论就可能不成立:
| 类别 | 要记录的内容 |
|---|---|
| 环境 | CPU、内存、网卡、内核版本、容器限制 |
| 连接 | 总连接数、每秒新建数、是否 TLS、空闲连接占比 |
| 消息 | 大小、频率、方向、扇出、是否压缩 |
| 网络 | 往返时延、带宽、丢包、抖动 |
| 应用 | 工作线程或事件循环数、队列上限、下游依赖 |
| 结果 | P50/P95/P99 延迟、错误率、资源曲线、最先出现的失败点 |
负载要逐步增加,用尾延迟和错误率作为停止条件,并给生产环境留出余量。“成功建立了 100 万条连接”这样的结论,没有消息量、新建速率和失败点,无法用来规划。
压测过程中,用下面的命令观察资源变化。它们来自 iproute2 和 sysstat,PID 换成服务的进程号:
bash
ss -s
sar -n DEV,TCP,ETCP 1
pidstat -t -p PID 1回到最初的问题
“一台服务器能连多少客户端”,现在可以这样回答:服务端的端口几乎不会用完;默认配置下,文件描述符会在一千条左右先卡住;放开以后,上限取决于每条连接实测占多少内存、有多少连接会积压,以及心跳和新建连接需要多少 CPU;accept 队列和链路上的设备还会在特定时刻挡住新连接。而连接数之外,消息量、扇出和慢客户端决定了这些连接能不能真正把消息送到。
遇到具体故障时,按从客户端到服务端的顺序逐层收集证据,方法见 网络排障方法与案例。想回到系列开头按症状选读,见连接容量:一台服务器能连多少客户端。
