为什么一台服务器也需要"限速"
很多个人站长有一个误解:带宽控制是大公司才需要的功能。其实恰恰相反,单机部署的小站更容易被一个失控的进程拖垮整台服务器。
举几个真实场景。你给客户开了一个静态资源目录,对方用迅雷多线程拉一个 2GB 的备份包,瞬间把出口带宽占满,正常用户的页面请求全部排队超时;你的服务器上跑着一个 Node 定时任务,某天代码里多加了一个不该有的循环,开始疯狂往外发请求,几十兆的出口流量几分钟就跑光;又或者你挂了多个网站,其中一个站被采集程序盯上,单 IP 每秒两百次请求,Nginx 的 worker 连接数被吃满,其他站跟着一起 502。
这些事情用 Nginx 的 limit_rate、limit_conn 只能解决一小部分,因为限流发生在应用层,而带宽是操作系统的资源。要真正控制出口,得用内核自带的流量控制子系统,也就是我们常说的 tc(traffic control)。
这篇文章不打算抄一遍 tc 的手册。手册上能查到的参数我不重复,我要讲的是:为什么 tc 的写法看起来那么怪、你这台机器上到底是哪个网卡在出流量、以及怎么用一条队列规则把"某个端口偷偷跑满带宽"这件事按住,同时在出问题的时候怎么干净地撤销。全文都在一台真实的 4 核 8G、带宽 100Mbps 的 Debian 12 服务器上验证过。
先搞清楚流量从哪里出去
tc 最容易出错的第一步,不是写规则,而是挂错了网卡。很多人一上来就 tc qdisc add dev eth0,结果发现限速完全没生效,因为这台机器的出口流量根本不走 eth0。
现代云服务器普遍把公网 IP 通过 NAT 绑在一块虚拟网卡上,网卡名字可能是 eth0、ens3、enp1s0,也可能是 ens5 这种预测性命名。你还可能装了 Docker,多出一块 docker0 网桥。挂错网卡的规则不会报错,只会静默地什么都不做——这是 tc 最让人抓狂的地方。
正确的判断顺序是这样。先看默认路由走哪块网卡:
ip route show default
# default via 172.31.0.1 dev ens5 proto dhcp src 172.31.0.10 metric 100这里 dev ens5 就是出口网卡。再确认一下这块网卡的当前队列规则:
tc -s qdisc show dev ens5
# qdisc pfifo_fast 0: root refcnt 2 bands 3 priomap ...
# Sent 182743928 bytes 231045 pkt (dropped 0, overlimits 0 requeues 0)默认情况下每块网卡挂的都是 pfifo_fast——一个最简单的三优先级先进先出队列,它不做任何限速,只负责排队转发。你在上面看到的 Sent 是累计发包量,dropped 是被丢弃的包数。一个健康的网卡,dropped 应该长期保持 0 或极低的数字;如果这个数字在飞快上涨,说明队列已经满了,系统在丢包,用户的体感就是"网站在卡"。
顺带说一个判断出口带宽上限是否被跑满的土办法:连续采样两次发送字节数,算差值。
tc -s qdisc show dev ens5 | grep Sent
sleep 5
tc -s qdisc show dev ens5 | grep Sent
# 两次 Sent 字节数相减 ÷ 5 × 8 ÷ 1000000 = 实际出口 Mbps如果算出来接近你买的带宽上限(比如买的 100Mbps,算出来 96),那说明出口已经是满的,这时候限速才有意义。如果算出来只有 20Mbps 却还是慢,问题就不在带宽上,限速帮不了你——但这是后话,这篇文章先解决"跑满"这一类问题。
tc 的语法为什么这么反直觉
要理解 tc,得先接受一个前提:它不是给人随手敲的,而是 Linux 内核网络栈内部数据结构的直接映射。你看这几层名词:
- qdisc(队列规则):挂在网卡上的排队策略,负责决定包什么时候发出去。默认是 pfifo_fast,可以换成 tbf、htb、fq_codel 等。
- class(分类):qdisc 内部的子队列。htb 这种"分层令牌桶"可以在一个 qdisc 里分出多个 class,每个 class 分到不同的带宽额度。
- filter(过滤器):决定一个包该进哪个 class。最常用的是按 IP、按端口、按 mark 值分类。
关键的一点是:tc 只能做出方向(egress)限速,因为只有发包的时候你才有机会排队等待。入方向(ingress)的包是从网卡收进来的,你控制不了对端什么时候发。所以想限某个 IP 的下载速度,本质上限的是"你这台服务器发给它的包",这在大多数场景下没问题——你要保护的就是自己的出口带宽。
这解释了一个新手常见的困惑:为什么我写了限速规则,本机 curl 自己下载还是飞快?因为本机内部通信走的是 lo 网卡,你的规则挂在 ens5 上,根本不经过它。
实战:把某个端口偷偷跑的带宽按回去
假设场景是这样:服务器上跑着一个备份服务,监听 8081 端口做文件下载。平时没人用,但某天有人通过它拉大文件,出口被占满。我要做的限制是——给 8081 端口的流量单独划 10Mbps 的上限,其余流量不受影响。
v0.1 的第一步是建立 HTB 树。HTTP 的根 qdisc 需要先替换掉默认的 pfifo_fast:
tc qdisc add dev ens5 root handle 1: htb default 30逐字解释一下。handle 里的 1: 是给这个 qdisc 分配的编号,主编号 1、次编号 0(写 1: 就等于 1:0)。default 30 的意思是:所有没有命中任何过滤器的流量,都进入 class 1:30。这个 default 极其重要——如果你忘了写它,而那些没被分类的包找不到归属,内核会直接丢弃或者用最末的 class 兜底。最稳妥的做法是永远在末尾留一个"万能 class",把所有杂物收进去。
接着定义一个根 class,容量就是整条链路的带宽:
tc class add dev ens5 parent 1: classid 1:1 htb rate 100mbit ceil 100mbit然后再从 1:1 下面分出两个子 class。一个是"备份流量",10Mbps;另一个是"其他所有流量",90Mbps:
tc class add dev ens5 parent 1:1 classid 1:10 htb rate 10mbit ceil 10mbit
tc class add dev ens5 parent 1:1 classid 1:30 htb rate 90mbit ceil 100mbitrate 是保证带宽,ceil 是上限带宽。当链路空闲时,class 可以超出 rate 一路吃到 ceil;当整条链路被抢满时,每个 class 至少能拿到自己的 rate。这就是 HTB(Hierarchical Token Bucket,分层令牌桶)的核心价值——它不是简单的硬性切割,而是一个"有余量就借用、没余量就保底"的分配器。
我故意让 1:30 的 rate 是 90mbit 而不是 100,是为了给控制流量留一点余量。这个小动作在真实环境里很有意义:限制类如果吃了满额的 100Mbps,你的 SSH 会话、监控采集、DNS 查询这些"小但关键"的流量会被压在后面排队,看起来就像服务器卡住了。留 10% 的余量是运维上的一条经验法则。
最后加上过滤器,把 8081 端口发出去的包扔进 1:10:
tc filter add dev ens5 protocol ip parent 1:0 prio 1 \
u32 match ip sport 8081 0xffff flowid 1:10u32 是内核里最通用的分类器,功能强但语法晦涩。match ip sport 8081 0xffff 的意思是"匹配源端口等于 8081",后面的 0xffff 是掩码,表示 16 位全部精确匹配——如果写成 0xff00 就变成了匹配"8081 所在的那个 /8 端口段",这是新手最容易踩的坑:漏写或者写错掩码,规则会匹配到一大片无关端口。
配好以后立刻验证:
tc -s class show dev ens5 classid 1:10
# class htb 1:10 parent 1:1 leaf 10: prio 0
# rate 10Mbit ceil 10Mbit burst 1600b cburst 1600b
# Sent 0 bytes 0 pkt (dropped 0, overlimits 0 requeues 0)这一条只做限制不实际测试,等于没做。正确的验收方式是开一个真实的下载,看 Sent 增长速度和 overlimits:
# 在另一台机器上
curl -o /dev/null http://服务器IP:8081/bigfile.tar.gz
# 回到服务器观察
watch -n 1 'tc -s class show dev ens5 classid 1:10 | tail -2'如果 overlimits 开始增长,说明限速正在生效——这是令牌桶用光了、包被延后发送的计数。overlimits 增长是"限速起效"的标志,而 dropped 增长是"限速设得太死,开始丢包"的标志。两者的区别很关键:overlimits 只是让包慢一点发出去(表现为下载速度被压住),dropped 是直接丢包,那会引发 TCP 重传,用户体验反而更差。一个健康的限速规则应该是 overlimits 涨、dropped 不涨。
如果你看到 dropped 也在涨,通常是因为 burst 值太小。burst 是"令牌桶一次能攒多少令牌",相当于允许的瞬时突发量。默认值(一般是 1600 字节左右)对现代网络来说太小了,一个 1500 字节的 MTU 加各种头部就超了。生产环境的经验值是:burst 和 cburst 都设成"rate 的 1/10 到 1/8 对应的字节数"。10Mbps 大约对应 1.2MB/s,设成 15k 到 20k 比较稳:
tc class change dev ens5 parent 1:1 classid 1:10 htb \
rate 10mbit ceil 10mbit burst 20k cburst 20k注意这里用 change 而不是 add——class 已经存在,再加会报 "File exists"。tc change 可以原地改参数,不需要拆掉整棵树重建,这在线上排障时非常重要。
一个真实的排障故事:为什么限速没生效
去年底我帮一个做资源站的朋友处理过一件事,可以直接说明 tc 的坑有多隐蔽。他的情况是:服务器带宽 50Mbps,白天经常整站打不开,但负载(load average)只有 0.3,CPU 和内存都很闲。也就是典型的"机器不累,但就是慢"。
第一步我先看了网卡出流量,用前面那个差值法,算出来稳定在 48-49Mbps,几乎是满的——出口确实被跑满了。接着用 ss -tn state established 按连接排序,找出发送队列积压最多的连接:
ss -tn state established '( sport = :443 )' | awk 'NR==1 || $3 > 0' | sort -k3 -n -r | head发现有三四个连接,每个都在大量发送,对端 IP 是同一个网段。再去看 Nginx 的 access log,按 URL 聚合统计,找到罪魁祸首:一个 /download/ 目录下的资源,被同一批 IP 用 16 个并发连接拉取。
这里出现了一个有意思的判断分叉。他的第一反应是"用 Nginx 的 limit_rate 限速就行了",我建议不要。原因有两个:一是 limit_rate 是应用层的,Nginx 必须以极慢的速度把文件读进内存再吐给客户端,连接会长时间占着 worker,反而更容易把 worker 连接数吃满;二是这个下载目录之外还有其他正常流量也需要保护,而我们没法预知下一个失控的会是哪个端口。
于是我选择了 tc 的方案,而且不是针对某个端口,而是针对那个下载目录所在的整个后端端口——因为那几个 IP 无论请求哪个文件,出口都走同一个后端服务。这样规则就与具体 URL 解耦了,以后再有人拉别的文件也一并受到约束。
规则按前面讲的 HTB 树建好,给后端端口划了 15Mbps 上限。上线后观察了 15 分钟,数据对比是这样的:
- 改造前:出口长期 48-49Mbps 满载,整站平均响应时间 2.8 秒,高峰时段
dropped持续增长。 - 改造后:出口稳定在 15-18Mbps,下载方速度被压到约 1.7MB/s(对方能接受,只是没法再用多线程把带宽吃干),整站平均响应时间回落到 340 毫秒,
dropped停止增长。
最关键的一条经验是:他一开始抱怨"限速把下载也限慢了,用户会不满"。但真实数据是,那些被限速的用户本来就在无成本地消耗全部带宽,让他们慢一点换来整站的可用性,是绝对划算的买卖。运维决策一定要算总账,不能只看单个用户的主观感受。
这个案例里还有一个值得记住的细节:我在规则生效后的第五分钟,用 tc -s qdisc show dev ens5 检查了根 qdisc 的 dropped。如果根队列开始丢包,说明 HTB 分配的总带宽超过了物理上限,那就要把根的 rate 调低。限速规则不是设置完就完事,前十分钟的观察期决定了它是不是真的健康。
限速做完,怎么干净地撤销
线上做任何操作都要想好回滚。取消 tc 规则很简单,但要注意删的顺序:删掉根 qdisc,它下面的所有 class 和 filter 会一起消失,不用逐个删。
tc qdisc del dev ens5 root执行完之后立刻确认网卡回到了默认状态:
tc qdisc show dev ens5
# qdisc pfifo_fast 0: root refcnt 2 bands 3 priomap ...看到 pfifo_fast 就说明撤销干净了。如果这里报 "RTNETLINK answers: No such file or directory",那不是错误,只是说明本来就没有自定义规则,可以忽略。
还有一个特别隐蔽的坑:tc 规则在服务器重启后会全部消失,因为它是直接写进内核的运行时配置,不落盘。很多人调好了规则,重启一次就发现限速不见了,还以为是哪里配错了。解决方案是把整段命令写成一个脚本,用 systemd 的 oneshot service 在开机时执行:
[Unit]
Description=Apply traffic shaping rules
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/tc-shape.sh
[Install]
WantedBy=multi-user.target脚本里要注意幂等性——开机时如果规则已经存在,直接 add 会失败。标准的写法是在脚本开头先尝试删除再重建,并且允许删除失败:
tc qdisc del dev ens5 root 2>/dev/null || true
tc qdisc add dev ens5 root handle 1: htb default 30
tc class add dev ens5 parent 1: classid 1:1 htb rate 100mbit ceil 100mbit
tc class add dev ens5 parent 1:1 classid 1:10 htb rate 10mbit ceil 10mbit \
burst 20k cburst 20k
tc class add dev ens5 parent 1:1 classid 1:30 htb rate 90mbit ceil 100mbit
tc filter add dev ens5 protocol ip parent 1:0 prio 1 \
u32 match ip sport 8081 0xffff flowid 1:10把这套东西和监控接起来才算完整。最简单有效的一条:每分钟采集一次网卡出口速率和 dropped 计数,出口速率连续 5 分钟超过限速目标值的 1.5 倍就告警,dropped 有任何非零增长也告警。这样即使某天有人改了规则或者网卡名字变了(云服务器重装后常见),你也能第一时间知道限速失效了,而不是等用户来投诉整站变慢。
常见问题
问:我同时用了 Docker,需要给 docker0 也加限速吗?
视情况而定。容器之间的通信走 docker0 网桥,如果你要限制的是"容器 A 往容器 B 灌数据",那得在 docker0 上做,因为容器互访的流量不经过物理网卡。但如果你要限制的是容器访问外网,流量最终会从物理网卡出去,规则挂在物理网卡上就够。判断方法很简单:在别处做一次大流量测试,然后 tc -s qdisc show 同时看两块网卡的 Sent 增长,谁在涨就限制谁。
问:限速能不能针对单个 IP?
可以,把 u32 过滤器换成 match ip dst 1.2.3.4/32 就行,注意这里是 dst 不是 src,因为限的是"你发给对方的包"。但如果要针对一批动态变化的 IP(比如按请求频率自动拉黑),手工维护过滤器不现实,那种场景更适合在 Nginx 或 WAF 层面按 IP 做限流,或者用 ipset 配合 iptables 打 mark,再让 tc 按 mark 分类——这是更进阶的组合拳,本文不展开。
问:overlimits 一直在涨,是不是限速设得太严了?
不是。前面强调过,overlimits 是"包被延后发送"的计数,只要限速在起作用它就会涨,这是正常的。真正需要担心的是 dropped——那个才是丢包。如果 dropped 增长伴随用户投诉下载中断、连接重置,先加大 burst,再考虑适当放宽 rate。次序不能反,很多时候只是 burst 太小导致的假性拥塞。
总结
把这篇的要点收拢成几条可以直接用的判断。第一,限速前先确认出口网卡和当前出口速率,ip route show default 看网卡,两次 tc -s qdisc 差值算速率,这两个动作能排除掉一半"限速没生效"的伪问题。第二,HTB 三层结构(qdisc → class → filter)里,default 兜底 class 和 10% 的余量是两条不能省的经验法则,省了迟早会出问题。第三,验收标准是 overlimits 涨、dropped 不涨,不是"看起来速度下来了"。第四,tc 规则重启即丢,必须用 systemd oneshot 脚本固化,并且写幂等。第五,所有操作都要能在三十秒内用 tc qdisc del dev X root 完整回滚。
限速这件事的心理门槛比技术门槛高。很多站长担心"限速了会不会影响正常用户",但在单机、带宽有限的场景下,不做任何限制才是对正常用户最大的不公平——因为你把选择权交给了最先开始抢带宽的那个人。控制住出口,是让小站在有限资源下保持稳定的最直接手段。