1. 从一个真实痛点说起:为什么家里的下载一开,游戏就卡成幻灯片
很多人都有过这种体验:明明宽带是500M甚至1000M,但只要家里有人在下载大文件或者看4K视频,自己打游戏就开始疯狂跳ping,视频会议也开始卡顿、声音断续。测速软件跑出来的数字依然漂亮,可实际体验就是一团糟。这个问题困扰了我很久,直到我把家里的软路由从简单的HTB队列换成了HFSC,才真正把这个问题按在地上摩擦。
HFSC,全称Hierarchical Fair Service Curve,翻译过来叫“分层公平服务曲线”。它是Linux流量控制(Traffic Control,简称TC)体系里的一种队列规则(qdisc),专门用来解决“既要保证实时业务的低延迟,又要充分利用剩余带宽”这个看似矛盾的需求。简单说,它能让你的游戏包、语音包永远优先通过,同时又不浪费一丁点带宽给下载和视频流。这篇文章我会把HFSC的原理、配置、调参、踩坑经验全部倒出来,适合有一定Linux网络基础、正在折腾软路由或者服务器QoS的读者。哪怕你只是刚听说TC,我也会用生活化的类比把核心概念讲清楚。
我写这篇东西的起因很简单:网上关于HFSC的中文资料要么太学术,满篇公式;要么太简略,抄来抄去就那几行命令,出了问题根本不知道怎么排查。我前后折腾了大概两个月,把家里和实验室的几台机器都刷了个遍,踩了无数坑,才总结出一套能稳定跑起来的方案。下面这些内容,都是我一行一行命令敲出来、一个包一个包抓出来验证过的。
2. HFSC到底解决了什么问题:从“排队”说起
2.1 网络拥塞的本质:路由器不是高速公路,而是收费站
要理解HFSC,得先理解网络设备怎么处理数据包。你可以把路由器的出口网卡想象成一个单车道收费站,数据包就是排队过站的汽车。当车流量小于收费站的通过能力时,所有车都能直接开过去,没有延迟。但当车流量超过通过能力时,就必须排队。排队本身不可怕,可怕的是排队的规则。
默认情况下,Linux的网卡队列是FIFO(先进先出),谁先到谁先走。这听起来很公平,但实际很糟糕:一个下载大文件的巨大数据包和一个游戏的小数据包同时到达,它们被同等对待。更糟的是,下载会产生大量数据包,把队列塞满,游戏包只能排在后面,延迟瞬间飙升。这就是所谓的“缓冲膨胀”(Bufferbloat)。
HTB(Hierarchical Token Bucket)是另一种常见的解决方案,它通过令牌桶来限制每类流量的速率。但HTB有个问题:它只关心“平均速率”,不关心“延迟”。你可以给游戏流量分配高优先级,但只要下载流量没有超过它的限制速率,HTB就不会主动压制它。结果就是,下载依然会把队列填满,游戏包的延迟还是下不来。
2.2 HFSC的核心思想:给每类流量画一条“服务曲线”
HFSC的突破点在于,它不只看速率,还看“延迟”。它为每个类别定义了一条“服务曲线”(Service Curve),这条曲线描述的是:在任意时间窗口内,这个类别至少能获得多少带宽。这条曲线由两个参数决定:速率(rate)和延迟(delay)。
举个生活化的例子。假设你是一个餐厅经理,餐厅有两个窗口:一个给普通顾客,一个给VIP。HTB的做法是:给VIP窗口预留2个服务员,普通窗口用剩下的。但如果普通顾客突然暴增,把普通窗口的队伍排到了门外,VIP顾客虽然不用排那么久,但整个餐厅的秩序还是乱的。HFSC的做法是:给VIP顾客一个承诺——“你来了之后,最多等30秒就能吃上饭”。为了兑现这个承诺,餐厅必须动态调整服务员的分配,哪怕普通窗口排长队,也要保证VIP窗口的承诺不被打破。
这就是HFSC的“实时”(realtime)曲线。它保证的是延迟上限,而不是平均速率。对于游戏、VoIP这类对延迟极度敏感的业务,这个特性简直是救命稻草。
2.3 为什么选HFSC而不是HTB或CAKE
你可能会问,现在不是有CAKE了吗?CAKE确实很优秀,它集成了流量整形、公平队列和主动队列管理,配置也简单。但CAKE的公平队列是“流”级别的,它不区分业务类型。也就是说,CAKE会把你的游戏流和下载流同等对待,只是保证每条流公平。如果你的游戏流和下载流数量差不多,CAKE确实能缓解问题。但如果你开了几十个下载连接,游戏流依然会被淹没。
HFSC的优势在于“分层”和“分类”。你可以精确地定义:游戏流量属于哪个类,享受什么服务曲线;下载流量属于哪个类,被限制在什么范围。这种精细控制是CAKE做不到的。当然,HFSC的配置复杂度也远高于CAKE,这就是取舍。
我个人的选择是:在核心出口用HFSC做精细分类,在内部接口用CAKE做简单整形。两者结合,效果最好。
3. 核心概念拆解:SC、RT、LS与分层结构
3.1 服务曲线(Service Curve)的两种模式
HFSC的每个类都可以定义两条服务曲线:实时曲线(Realtime,简称RT)和链路共享曲线(Link Sharing,简称LS)。这两条曲线可以单独使用,也可以同时使用。
实时曲线保证的是“延迟上限”。它的参数是m1、d、m2。m1是初始速率,d是延迟时间,m2是后续速率。这条曲线描述的是:在d时间之后,这个类至少能获得m2的速率。如果m1等于m2,那就退化成一条简单的速率保证。对于游戏流量,我通常设置m1等于m2,d设得很小,比如5ms,这样游戏包几乎不用排队。
链路共享曲线保证的是“带宽分配比例”。它的参数是m2,也就是这个类在链路空闲时能借用的最大速率。当链路拥塞时,所有类按照各自的m2比例分配带宽。这有点像HTB的ceil,但更灵活。
注意:实时曲线和链路共享曲线不能同时设置
m1和m2冲突的参数。如果同时设置,实时曲线优先。我建议对延迟敏感的类只设实时曲线,对吞吐量敏感的类只设链路共享曲线。
3.2 分层结构:像文件夹一样管理流量
HFSC最强大的地方在于“分层”。你可以创建一个根类,然后在下面创建子类,子类下面再创建子类。每个子类都可以有自己的服务曲线。这种树状结构让你可以非常清晰地组织流量。
比如,我可以这样设计:
- 根类(1:0):总带宽100Mbit
- 交互类(1:1):游戏、VoIP,实时曲线5Mbit,延迟5ms
- 流媒体类(1:2):视频会议、在线视频,链路共享30Mbit
- 下载类(1:3):HTTP下载、P2P,链路共享60Mbit
- 大文件下载(1:31):链路共享40Mbit
- 小文件下载(1:32):链路共享20Mbit
这种结构的好处是,当下载类整体带宽没用满时,流媒体类可以借用;当流媒体类也没用满时,交互类可以借用。但交互类的实时曲线始终被保证,不受其他类影响。
3.3 分类器(Classifier):怎么把包分到对应的类
定义了类之后,还需要告诉HFSC怎么把数据包分到这些类里。这就是分类器的工作。Linux TC支持多种分类器:u32、fw、flow、cgroup等。
我最常用的是fw分类器,配合iptables的MARK目标。因为iptables的匹配规则非常灵活,可以基于端口、IP、协议、甚至连接状态来打标记。比如:
# 给游戏包打标记1 iptables -t mangle -A POSTROUTING -p udp --dport 27015 -j MARK --set-mark 1 # 给视频会议包打标记2 iptables -t mangle -A POSTROUTING -p udp --dport 3478 -j MARK --set-mark 2 # 给下载包打标记3 iptables -t mangle -A POSTROUTING -p tcp --dport 443 -j MARK --set-mark 3然后在TC里用fw分类器把标记映射到类:
tc filter add dev eth0 parent 1: protocol ip handle 1 fw flowid 1:1 tc filter add dev eth0 parent 1: protocol ip handle 2 fw flowid 1:2 tc filter add dev eth0 parent 1: protocol ip handle 3 fw flowid 1:3这种方式的优点是逻辑清晰,iptables规则和TC规则分离,方便维护。缺点是iptables的mangle表会消耗一些CPU,但在家用场景下完全可以接受。
提示:如果你用的是nftables,可以用
meta mark set来替代iptables的MARK目标,性能更好,语法也更现代。
4. 手把手配置:从零搭建一个可用的HFSC规则集
4.1 环境准备与前置检查
在开始之前,你需要确认几件事:
- 你的内核编译了HFSC模块。大多数发行版默认都有,可以用
modprobe sch_hfsc测试。如果没有报错,就说明支持。 - 你的网卡支持多队列。如果是单队列网卡,HFSC依然能用,但效果可能打折扣。可以用
ethtool -l eth0查看队列数。 - 你有一台可以做路由的Linux机器,比如软路由、树莓派或者云服务器。
我建议先在虚拟机或者测试环境里跑一遍,确认没问题再上生产。因为TC规则一旦配错,可能导致网络中断,远程SSH会直接断掉。如果你是在远程服务器上操作,一定要先设置一个定时任务,比如at now + 5 minutes来清空规则,防止把自己关在门外。
# 安全措施:5分钟后自动清空TC规则 echo "tc qdisc del dev eth0 root" | at now + 5 minutes4.2 清空现有规则与创建根队列
第一步永远是清空现有规则,避免冲突:
tc qdisc del dev eth0 root 2>/dev/null然后创建HFSC根队列:
tc qdisc add dev eth0 root handle 1: hfsc default 3这里的default 3表示,如果没有匹配到任何分类器的包,默认送到类1:3。这个默认类很重要,它相当于“垃圾桶”,所有未分类的流量都会走这里。我通常把默认类设为下载类,因为大部分未分类流量都是普通上网流量。
4.3 定义根类与子类
接下来定义根类。根类通常不设限制,只是作为树的根:
tc class add dev eth0 parent 1: classid 1:1 hfsc sc rate 100mbit ul rate 100mbit等等,这里有个常见的误区。很多人以为根类要设成总带宽,其实不对。根类应该是一个“容器”,它的速率应该等于或略大于你的实际出口带宽。如果你的宽带是100Mbit,上行可能只有20Mbit,那么你应该按上行带宽来设置。因为QoS主要控制的是发送方向,也就是上行。
假设我的上行是20Mbit,那么根类应该这样设:
tc class add dev eth0 parent 1: classid 1:1 hfsc sc rate 20mbit ul rate 20mbit然后创建三个子类:
# 交互类:实时曲线,保证5ms延迟,速率2Mbit tc class add dev eth0 parent 1:1 classid 1:10 hfsc rt m1 2mbit d 5ms m2 2mbit ls rate 2mbit # 流媒体类:链路共享,保证8Mbit tc class add dev eth0 parent 1:1 classid 1:20 hfsc ls rate 8mbit # 下载类:链路共享,保证10Mbit tc class add dev eth0 parent 1:1 classid 1:30 hfsc ls rate 10mbit这里解释一下参数:
rt m1 2mbit d 5ms m2 2mbit:实时曲线,初始速率2Mbit,延迟5ms,后续速率2Mbit。这意味着交互类在任何5ms窗口内都能获得至少2Mbit的带宽,延迟极低。ls rate 8mbit:链路共享曲线,保证8Mbit。当链路空闲时,可以借用更多。ls rate 10mbit:下载类保证10Mbit。
注意,这三个类的保证速率加起来是2+8+10=20Mbit,正好等于总带宽。这是故意的,这样可以确保每个类都有保底带宽,不会互相饿死。
4.4 为每个类挂载叶子队列
类本身只是逻辑概念,真正排队的是叶子队列。HFSC的类下面需要挂一个叶子qdisc,通常是fq_codel或sfq。我强烈推荐fq_codel,因为它能有效对抗缓冲膨胀,而且几乎不需要调参。
tc qdisc add dev eth0 parent 1:10 handle 10: fq_codel tc qdisc add dev eth0 parent 1:20 handle 20: fq_codel tc qdisc add dev eth0 parent 1:30 handle 30: fq_codelfq_codel的全称是Fair Queueing with Controlled Delay,它会把每个流单独排队,并且主动丢弃那些排队时间过长的包。这样即使某个类内部有多个流,也不会互相影响。
4.5 配置分类器与iptables标记
现在到了最关键的一步:把数据包分到对应的类。我前面说了用fw分类器,这里给出完整的iptables规则。
假设我的内网网段是192.168.1.0/24,出口网卡是eth0。我要做的是:
- 游戏流量:UDP端口27000-27100,标记1
- 视频会议:UDP端口3478-3480,标记2
- 其他所有流量:标记3
# 清空mangle表 iptables -t mangle -F iptables -t mangle -X # 游戏流量 iptables -t mangle -A POSTROUTING -o eth0 -p udp --dport 27000:27100 -j MARK --set-mark 1 # 视频会议 iptables -t mangle -A POSTROUTING -o eth0 -p udp --dport 3478:3480 -j MARK --set-mark 2 # 其他所有流量 iptables -t mangle -A POSTROUTING -o eth0 -j MARK --set-mark 3然后在TC里添加过滤器:
tc filter add dev eth0 parent 1: protocol ip handle 1 fw flowid 1:10 tc filter add dev eth0 parent 1: protocol ip handle 2 fw flowid 1:20 tc filter add dev eth0 parent 1: protocol ip handle 3 fw flowid 1:30注意:iptables的MARK是在POSTROUTING链打的,而TC的过滤器是在出口方向生效的。这个顺序是正确的。如果你在PREROUTING链打标记,TC可能看不到,因为包还没经过路由决策。
4.6 验证配置是否生效
配置完成后,用以下命令检查:
tc qdisc show dev eth0 tc class show dev eth0 tc filter show dev eth0你应该能看到类似这样的输出:
qdisc hfsc 1: root refcnt 2 default 30 qdisc fq_codel 10: parent 1:10 qdisc fq_codel 20: parent 1:20 qdisc fq_codel 30: parent 1:30 class hfsc 1:1 parent 1: sc rate 20Mbit ul rate 20Mbit class hfsc 1:10 parent 1:1 rt m1 2Mbit d 5.0ms m2 2Mbit ls rate 2Mbit class hfsc 1:20 parent 1:1 ls rate 8Mbit class hfsc 1:30 parent 1:1 ls rate 10Mbit然后可以用ping和iperf3来测试。一边跑iperf3打满下载,一边ping网关,看延迟是否稳定。如果延迟从1ms飙升到100ms,说明配置有问题。如果延迟稳定在5ms以内,恭喜你,成功了。
5. 参数调优与实战经验:那些文档不会告诉你的细节
5.1 实时曲线的d值到底设多少合适
d值是实时曲线的延迟参数,它决定了这个类最多能容忍多少延迟。设得太小,比如1ms,会导致HFSC过于激进地调度,可能影响其他类;设得太大,比如50ms,又起不到低延迟的效果。
我的经验是:对于游戏流量,d设5ms到10ms比较合适。因为大多数游戏服务器的tickrate是64或128,对应15.6ms或7.8ms的间隔。5ms的延迟保证足够覆盖。对于VoIP,d可以设10ms到20ms,因为语音对延迟的容忍度稍高,但抖动更敏感。
实操心得:你可以先用
d 10ms跑一段时间,然后用mtr或者ping -f观察延迟分布。如果延迟经常超过10ms,说明d设小了,需要调大或者增加m1。
5.2 链路共享曲线的rate怎么分配
链路共享曲线的rate决定了拥塞时各类能分到多少带宽。分配原则是:保证关键业务的最低需求,剩余带宽按比例分给其他业务。
我通常的做法是:
- 先算出总上行带宽,比如20Mbit。
- 给交互类分配2Mbit,这是游戏和VoIP的最低需求。
- 给流媒体类分配8Mbit,这是视频会议的最低需求。
- 剩下的10Mbit给下载类。
这样分配的好处是,即使下载类跑满,流媒体和交互类依然有保底带宽。而且当流媒体类空闲时,下载类可以借用它的带宽,提高整体利用率。
5.3 分层结构的深度与复杂度权衡
HFSC支持多层嵌套,但层数越多,CPU开销越大,配置也越复杂。我建议最多三层:根类、业务类、子业务类。再深就不划算了。
比如,你可以在下载类下面再分“大文件下载”和“小文件下载”,但没必要再分“HTTP下载”和“FTP下载”。因为fq_codel已经在叶子队列层面做了流级别的公平调度,再细分意义不大。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 配置后网络完全不通 | 根类速率设错,或者默认类没设 | tc class show检查根类速率 | 确保根类速率等于实际带宽,默认类存在 |
| 游戏延迟依然很高 | 实时曲线参数不对,或者分类器没匹配到 | tc -s filter show查看匹配计数 | 调整d值,检查iptables标记 |
| 下载速度上不去 | 链路共享速率设太低,或者根类速率太小 | tc -s class show查看各队列统计 | 调大下载类的rate,检查根类速率 |
| CPU占用过高 | 分类器太复杂,或者包量太大 | top查看ksoftirqd占用 | 简化分类器,用fw替代u32 |
| 延迟忽高忽低 | fq_codel参数不合适 | tc -s qdisc show查看丢包统计 | 调整fq_codel的limit和target |
5.5 独家避坑技巧
技巧一:先用tc -s观察,再调参。很多人配完就不管了,其实tc -s class show dev eth0会显示每个类的包计数、字节数、丢包数。如果某个类的丢包数很高,说明它的速率设小了,需要调大。
技巧二:用iftop或nethogs确认流量分类是否正确。有时候iptables规则写错了,流量跑到了错误的类里,但TC不会报错。用iftop看实时流量,再对照tc -s的计数,就能发现。
技巧三:不要忽略上行和下行。HFSC只能控制发送方向,也就是上行。如果你要控制下行,需要在ISP的入口做整形,或者用IFB(Intermediate Functional Block)做重定向。IFB的配置比较复杂,我一般只在必要时才用。
技巧四:定期清理iptables规则。如果你经常改规则,iptables的mangle表会积累很多无用规则,影响性能。建议每次修改前先iptables -t mangle -F清空。
6. 进阶玩法:HFSC与CAKE、IFB的组合拳
6.1 用IFB控制下行流量
前面说了,HFSC只能控制上行。但很多时候,下行流量才是问题所在。比如你下载一个文件,下行流量把队列塞满,导致游戏延迟飙升。这时候就需要IFB。
IFB的工作原理是:把下行流量重定向到一个虚拟接口(ifb0),然后在ifb0上做整形,再把流量送回原接口。这样就能控制下行方向了。
配置步骤:
# 加载ifb模块 modprobe ifb numifbs=1 # 启用ifb0 ip link set dev ifb0 up # 把eth0的下行流量重定向到ifb0 tc qdisc add dev eth0 handle ffff: ingress tc filter add dev eth0 parent ffff: protocol ip u32 match u32 0 0 action mirred egress redirect dev ifb0 # 在ifb0上配置HFSC tc qdisc add dev ifb0 root handle 1: hfsc default 30 # ... 后续配置和eth0类似注意:IFB会增加CPU开销,因为每个包都要被重定向一次。在千兆以下的环境里问题不大,但万兆环境要谨慎。
6.2 HFSC与CAKE的混合部署
CAKE在流级别公平调度上做得很好,但缺乏业务分类能力。我的做法是:在出口用HFSC做业务分类,在每个类的叶子队列用CAKE替代fq_codel。
tc qdisc add dev eth0 parent 1:10 handle 10: cake bandwidth 2mbit tc qdisc add dev eth0 parent 1:20 handle 20: cake bandwidth 8mbit tc qdisc add dev eth0 parent 1:30 handle 30: cake bandwidth 10mbit这样既有HFSC的业务分类,又有CAKE的流级别公平和主动队列管理。实测下来,游戏延迟比纯fq_codel还要低1-2ms。
6.3 动态调整:用脚本根据时间切换规则
如果你有定时任务,比如晚上下载,白天办公,可以写一个脚本,根据时间切换HFSC规则。比如:
#!/bin/bash HOUR=$(date +%H) if [ $HOUR -ge 9 ] && [ $HOUR -le 18 ]; then # 工作时间:限制下载 tc class change dev eth0 parent 1:1 classid 1:30 hfsc ls rate 5mbit else # 非工作时间:放宽下载 tc class change dev eth0 parent 1:1 classid 1:30 hfsc ls rate 15mbit fi这个脚本可以放在crontab里,每小时执行一次。tc class change命令可以动态修改类的参数,不需要重建整个队列。
7. 我的个人体会与后续扩展方向
折腾HFSC这段时间,我最大的感受是:网络QoS不是一劳永逸的事情,而是一个持续调优的过程。不同的网络环境、不同的业务组合、甚至不同的时间段,最优参数都可能不一样。我一开始照搬网上的配置,结果游戏延迟反而更高了,后来才发现是d值设得太小,导致HFSC过于频繁地调度,增加了CPU开销和抖动。
另一个体会是,不要迷信“最优配置”。网上很多配置是针对特定场景的,比如千兆对称光纤、或者特定运营商的网络。你的环境不一样,参数就要调整。我建议先用保守的参数跑起来,然后用tc -s和ping观察,逐步微调。
后续我打算尝试几个方向:一是用eBPF替代iptables做分类,性能更好,规则也更灵活;二是把HFSC和SDN结合,实现集中式的QoS策略下发;三是研究HFSC在容器网络里的应用,比如Kubernetes的CNI插件怎么利用HFSC做Pod级别的带宽保证。
最后分享一个小技巧:如果你不确定某个类的参数是否合适,可以先用tc class change临时改一下,观察效果,确认没问题再写进启动脚本。这样比反复重启网络服务要快得多。