备份任务一跑起来,SSH 就开始一卡一卡;带宽监控显示还不到上限,谁也说不清卡在哪。这类问题八成不是带宽不够,而是包在队列里排队。你去查监控,出口带宽曲线离标称上限还有一截,CPU 也闲着,磁盘 IO 也没打满,唯一不对劲的就是那几条交互式会话——敲一个字符要等小半秒才回显,scp 一个文件的速度倒是很正常。越看越像玄学。
说白了,这里有两个被长期当成一回事、其实是完全独立的变量:一个是单位时间能搬多少数据(吞吐/带宽),另一个是一个包从进来到出去要等多久(延迟)。你买的带宽决定的是前者,你感受到的卡顿来自后者。把这两个变量拆开,后面所有现象都解释得通了。
这篇文章只谈队列调度、整形位置和缓冲膨胀这三件事,不展开成一份 TCP 参数调优清单——BBR、拥塞窗口、MTU/MSS 那些话题各有各的坑,混在一起讲反而什么都讲不清。
数据在链路上传输,本质上是一个"到达—排队—被服务—离开"的过程。任何一个网络设备上,包从某个接口进来、从另一个接口出去,出口那边都有一个发送队列。包到达的速度超过接口能把它发出去的速度时,多出来的部分就只能在队列里等着。这个等待时间,就是排队延迟。
排队延迟的大小由两个东西决定:队列里已经堆了多少,以及这个队列被清空的速度有多快。堆得越多、清得越慢,排在队尾的包等待的时间就越长。就这么简单,没有别的因素。
一条链路上速率最低的那一环叫瓶颈。瓶颈可能在你服务器的网卡上,可能在机房的接入交换机上,可能在运营商的某个汇聚设备上,也可能在云平台的宿主机侧。不管在哪,只要上游送得比瓶颈发得快,队列就在瓶颈前面堆起来。
这里有个反直觉的点值得单独拎出来说:排队延迟只发生在瓶颈处,而瓶颈处的带宽利用率反而是整条链路最高的。所以你会看到一个很矛盾的现象——出口带宽确实跑得很满(在瓶颈那一跳),但整体的平均利用率看起来又不高,因为瓶颈之后的所有环节都在"饿着"。监控采样的是平均值,瓶颈那一跳的瞬时排队被平均掉了。
按常理想,缓冲区大一点不是好事吗?包来了先存着,不至于丢。这正是过去几十年网络设备的设计思路:内存越来越便宜,厂商就把缓冲区越做越大,"不丢包"成了一个卖点。结果就是缓冲膨胀(bufferbloat)——缓冲区大到一定程度之后,它不再是一个缓冲,而是变成一个延迟制造机。
为什么?因为丢包本来是网络告诉发送端"我扛不住了"的唯一显式信号。TCP 靠丢包(或者后来的拥塞标记)来判断链路拥塞,然后降速。你把缓冲区做大,包就不丢了,全部排进队列慢慢发;发送端收不到任何拥塞信号,继续加大发送量;队列继续变长;延迟从几毫秒一路涨到几十、几百毫秒。整个系统进入一个"吞吐看着正常、延迟彻底失控"的状态,而且非常稳定地卡在这个状态里出不来。
交互类流量对这件事最敏感。SSH 敲一个键、数据库发一个查询、HTTP 请求一个接口,这些操作的包很小,但要求往返快。当它们和大文件传输挤在同一个先进先出队列里时,一个小包前面可能排着几千个备份流量包。它只能老实排队。你的吞吐指标一点没坏,手感却烂透了。
传统的先进先出队列(Linux 上老一点的默认是 pfifo_fast)不区分流量来自哪个连接。一个跑满带宽的备份任务可以占掉队列里绝大部分位置,剩下的交互式连接只能分到零头,而且排队时长完全由别人决定。这不是"带宽不够",这是队列被不公平地占用。
所以你会发现一个很有意思的现象:把备份任务单独停掉,SSH 立刻就顺了;但只要它一启动,哪怕限速到链路的一半,卡顿依旧。原因就在队列上——只要队列是那条又深又不分流的 FIFO,限速只改变了队列被填满的速度,没改变"小包要排在长队后面"这个事实。
很多人对"限速"的理解停留在"让网变慢",所以一听到"解决卡顿的办法是限速",第一反应是抗拒。这里必须把两个动作拆开:整形(shaping)和丢弃(policing),它们对延迟和重传的影响完全不同。
整形的做法是:超额的流量不扔,而是先放进队列里缓存,按设定的速率一点点排出去。发送端感受到的是"对端收得慢了",于是自然降速。整个过程的代价是延迟——但这是可控的、有界的延迟,因为队列由你自己管理。
丢弃的做法是:超过设定速率的包直接丢掉,不做缓存。发送端感受到的是"包丢了",于是靠超时重传或快速重传来恢复。这个过程的代价是重传——丢掉的包要重发,重发的包又可能再超速率再被丢。对 TCP 来说, policing 触发的往往是整窗口的丢包,容易引发一连串重传和窗口塌陷,吞吐曲线会变得很难看。
两者的关键差别在于:整形把拥塞信号的产生点掌握在自己手里,丢弃把拥塞信号甩给了对端去猜。要压延迟,你要的是前者。
这就要说到那句最反直觉的结论了:把整形点设在略低于实际链路速率的位置,反而能让交互延迟降下来。
逻辑是这样的。假设你的实际可用出口是某个速率,你不做任何整形,那么真正的瓶颈在链路的更下游——可能是运营商设备、可能是云平台的宿主机侧,那里的队列你碰不到、看不见、调不动,而且通常又深又不分流。你在自己的机器上有没有队列都无所谓,因为队列不在这儿堆。
现在你在自己的出口上挂一个整形器,速率设成略低于实际可用速率。这时候瓶颈被人为地搬到了你的网卡上——包开始在你自己的队列里排队了。这一步听着像自找麻烦,但它带来了一个巨大的好处:队列现在归你管了。你可以给它配按流公平的调度,可以给它配主动队列管理,可以限制它的最大长度。下游那个又深又笨的队列从此不再被填满,下游设备的排队延迟被压到接近零。
换来的是:你自己这个队列的延迟。但因为你用的是现代队列管理算法(下一节讲),这个延迟是被压在毫秒量级的,而不是几百毫秒。这笔买卖非常划算——用一点点吞吐,换掉了最坏情况下的那几百毫秒。
这是实操里最容易踩的坑。你把整形速率设成等于标称带宽,队列就永远不会被填满,队列管理算法永远观察不到拥塞,也就永远不会被触发。结果是你既没压住延迟,又白白多了一层处理开销。
只有设成略低于实际可用速率,队列里才会稳定地存那么一点点积压,队列管理算法才有东西可管、有信号可反馈。"略低"这个"略"字是全文的技术核心——低太多,吞吐损失明显;低太少,队列形同虚设,等于没做。具体低多少,取决于你的链路实际能跑多少、抖动有多大,只能靠自己测,抄别人的数字没意义。
把队列搬到自己机器上只是第一步,接下来要选一个会管队列的算法。这一层有三个名字绕不开:CoDel、fq_codel、CAKE。
早期主动队列管理(比如 RED)的思路是"队列超过某个长度就开始随机丢包",问题是要调的参数太多,而且队列长度这个指标和延迟的关系并不稳定——链路速率一变,同样的长度对应的延迟就变了。
CoDel(RFC 8289)换了个观测对象:它不看队列里有多少个包,而看每个包在队列里待了多久。这个指标叫停留时间(sojourn time),它直接就是排队延迟本身,跟链路速率无关。CoDel 的逻辑大致是:如果在一个设定的间隔(interval)内,包的停留时间持续超过设定的目标(target),就认为队列处于"持续积压"状态,开始按一定节奏丢包或标记,直到停留时间降回目标以下。
Linux 上 fq_codel 常见的默认量级是 target 5 毫秒、interval 100 毫秒。这两个值的含义要理解清楚:target 不是"保证延迟低于 5 毫秒",而是"队列管理开始介入的门槛";interval 是"积压要持续多久才算真积压"。短暂突发不会被误伤,只有持续堆积才会被处理——这正是它比"按长度丢包"聪明的地方。
CoDel 解决了"队列太长"的问题,但没解决"队列被一条大流占满"的问题。一条跑满带宽的备份连接,在纯 CoDel 看来,它的包停留时间也很长,也会被丢;但它丢完之后还是占着队列的大头,SSH 那条小流依旧排在后面。
fq_codel(RFC 8290)在 CoDel 前面加了一层按流公平排队:它把不同连接(流)分到不同的子队列里,轮流服务,让每条流都有机会被发出去。一条大流再凶,也只能占它该占的那一份;新来的短流(比如你刚敲的那个 SSH 字符)会被识别为"稀疏流"优先调度,几乎不用排队。
这一层才是"备份一跑 SSH 就卡"这个问题真正的解药。CoDel 管的是队列总长度,按流公平管的是队列归谁用,两个问题都要解决,缺一个都不完整。
CAKE(Common Applications Kept Enhanced)的思路更进一步:它把整形、按流公平、优先级区分、NAT 感知整合在同一个 qdisc 里。对运维来说最直接的好处是——你不需要再手动拼"HTB 做整形 + fq_codel 做队列"这种组合,直接给 CAKE 一个带宽值,它自己把该做的都做了。
NAT 感知这一项在网关场景里很实用:做了地址转换之后,内网侧看到的流和公网侧的流是两回事,CAKE 能在转换之后仍然正确识别流,公平调度才不会失效。如果你的场景是"直接设定一个带宽值就想完事",CAKE 通常是最省心的选择。
HTB(Hierarchical Token Bucket)是分层令牌桶,它的强项是分级限速和带宽分配——比如给整体出口设一个总速率,下面再分成"在线业务保证多少、备份最多用多少、其他默认多少"这样的层级结构。配置工具是 tc。
HTB 本身只负责"按速率放行",不负责"队列不要太长"。所以常见的组合是 HTB 在上层做分配,每个叶子类别下面再挂 fq_codel 或 CAKE 做队列管理。如果你只有一条出口、只想压延迟,其实不需要 HTB 这一层,直接上 CAKE 就够了;需要把带宽明确分给几类业务时,HTB 才有出场必要。
这一点要留足余地:Linux 的默认 qdisc 因发行版和内核版本而异。早期多数发行版默认是 pfifo_fast,多数现代发行版已经默认 fq_codel。但"多数"不等于"你的机器"。动手之前先确认你自己的环境默认是什么,别拿文档上的结论直接往下推。
| 队列或整形方式 | 解决的是哪一类问题 | 需要你设定的关键参数 | 代价与副作用 | 什么情况下不适合 |
|---|---|---|---|---|
默认先进先出队列(pfifo / pfifo_fast) |
几乎不解决任何延迟问题,只提供"来不及就堆着/堆不下就丢"的默认行为 | 队列长度上限 | 不分流、不主动管理,长队列直接表现为高延迟;大流会挤占小流 | 任何有交互式流量与大流量混跑的出口都不适合留着它 |
| 单纯速率丢弃(policing / ingress police) | 把流量硬性限制在某个速率以内,常用于接收方向或对方不配合的场景 | 速率上限、突发缓冲大小、超限动作 | 靠丢包发信号,易触发 TCP 重传与窗口塌陷;吞吐曲线抖动明显,对延迟几乎无改善 | 不适合作为改善交互延迟的手段;对丢包敏感的业务慎用 |
HTB 分层令牌桶(tc 配置) |
分级限速与带宽分配:给不同业务类别划定速率上限与下限 | 总速率、各类别的保证速率与上限速率、层级结构 | 配置复杂度高,层级设计不合理反而制造新瓶颈;本身不管队列长度 | 只需要压延迟、不需要分类分配时属于过度设计;需与队列管理算法配合使用 |
| fq_codel(RFC 8290) | 把队列停留时间压在目标附近,同时按流公平调度,防大流饿死小流 | 目标停留时间(target)、观察间隔(interval)、流数量上限 | 需要配合一个略低于实际速率的整形点才生效;单独挂在高速接口上等于没配 | 整形点不在本机(如云平台宿主机侧限速)时,本机配置收益有限 |
| CAKE | 把整形、按流公平、优先级区分与 NAT 感知整合在一起,直接给定带宽值即可 | 带宽值(关键:略低于实际可用速率)、是否启用 NAT 感知、优先级模式 | 带宽值设错(等于或高于实际)即失效;整合度高意味着细粒度定制空间小 | 需要精细自定义层级策略、或要与其他 qdisc 深度组合的场景 |
前面那套"把瓶颈搬到自己机器上"的逻辑,有一个前提条件:你自己的网卡确实是这条链路上速率最低的一环。在物理机上这个条件容易满足,在云主机上未必。
云主机看到的"网卡"通常是一个虚拟设备,真正的限速可能发生在宿主机侧,也可能在接入交换机侧。你在客户机内部看到的带宽数值,是平台告诉你的标称值,不是你能直接控制的那条链路的速率。
后果很直接:如果你在客户机里把整形速率设成等于标称带宽,那么从客户机自己的视角看,队列永远不会被填满——包都以标称速率发出去了,队列管理算法没有任何拥塞可观测。真正的排队发生在宿主机侧或者交换机侧那个你碰不到的队列里,那里的缓冲区又深又不分流,延迟照旧。
这就是为什么很多人在云主机上配完 fq_codel 之后感觉"没什么用"——不是算法不行,是整形点的位置和速率没设对。要让队列管理生效,必须把整形值设在略低于实际可用速率的位置,让队列在你的客户机内部就形成那一点点可控积压,把拥塞的处理权真正抢到自己手里。
另一个高频坑是内网与公网的限速位置不一致。同一台云主机,内网通信可能走一条路径、限速点在某处;公网出口又走另一条路径、限速点在另一处。你在公网出口网卡上做的整形,对内网流量完全不起作用;反过来,你在内网接口上验证出来的队列行为,也不能直接套用到公网方向。
典型症状是这样的:你在测试环境里用内网对打验证了队列参数,一切正常;上了生产,公网出口的延迟还是飘。原因往往是两侧链路的实际可用速率不同、限速点不同,你那个"略低于实际速率"的值在一边成立、在另一边不成立。整形参数必须按方向分别确认,不能一套配置打天下。
整形本质上是一个出口方向的动作——你只能控制包什么时候被发出去,很难控制包什么时候进来。入口方向的流量要管,通常有两种思路:一种是在入口做 policing(直接丢),代价就是前面说的重传问题;另一种更常见也更有效的做法是——去管对端方向上的出口。比如你想限制某台机器下载的速度,实际上是在上游那个发送端的出口做整形更合理。
在网关或边界设备上,入口限速往往不得不做,这时候要清楚自己拿到的是什么: policing 给你的是速率上限,不是延迟改善。别指望它能解决交互卡顿。
还有一个观测层面的陷阱值得单独提。现代网卡普遍开启了 TSO(分段卸载)和 GRO(合并卸载)这类卸载功能:发送时由网卡把大块数据切成包,接收时由网卡把多个包合并成大块再交给协议栈。好处是省 CPU,坏处是队列以更大的粒度在排队。
这带来两个后果。第一,排队延迟被放大:一个大的聚合单元占住发送口的时间更长,排在它后面的小包要等更久。第二,观测容易误判:你在协议栈层面看到的"包数"和链路上实际传输的帧数不是一回事,队列统计里的计数含义也会跟着变。你在排查延迟问题时,如果发现队列统计和你的直觉对不上,先确认一下卸载功能的开关状态和聚合粒度,再下结论。
经验上的建议是:排查阶段可以考虑临时关掉相关卸载来排除干扰,确认问题之后再决定是关是留。不要在生产上一边开着大聚合一边按"每个包多少字节"去推算队列时长,那个数字会错得很离谱。
前面讲了原理,这一节讲怎么验。注意下面只写方法方向和要盯的字段,不给出任何具体数字结果——不同环境的数字没有可比性,你自己的数字只能你自己跑出来。
排队延迟只在队列被填满时才出现。空闲时 ping 一下看到的是传播延迟,一点问题都暴露不出来。正确做法是:一边打流,一边持续 ping。打流的流量要能真正把瓶颈填满——备份任务、大文件传输、或者专门的流量生成工具都行。
你要看的是延迟的变化量,不是绝对值。打流前多少、打流后多少、停止打流后多久回落。如果打流期间延迟涨了一大截而带宽显示没到上限,排队延迟的可能性就非常高了。
ping 的间隔也要留意。间隔太长会漏掉抖动,太短本身又会给链路增加负担。取一个能反映抖动又不至于干扰观测的间隔,同一台机器上前后对比时用同一个间隔。
光知道"延迟涨了"不够,还要知道涨在哪一跳。mtr 的价值在于把逐跳的延迟和丢包一起列出来,你可以看到延迟是从哪一跳开始跳上去的,以及它在后续跳是否保持。
判读的要点是:如果某一跳之后的所有跳延迟都同步抬高,那么问题多半出在这一跳的出口队列上;如果只有中间某一跳异常而后续跳正常,那更可能是那一跳设备本身对探测包的响应策略问题,不代表真实转发延迟,别被它误导。
配完队列管理之后,要确认它真的生效了,而不是静静地挂在那里什么都不做。用 tc -s qdisc 看 qdisc 级别的统计,用 tc -s class 看类别级别的统计。要盯的字段包括:
通过的包数和字节数——确认流量确实走了你配置的这个队列,而不是被别的规则提前分流了;丢弃数(dropped)——确认队列管理是否在按预期丢/标记,如果一直是零,很可能是整形速率设高了、队列根本没被填满;积压(backlog)——看队列里此刻堆着多少,正常情况下应该维持在很小的量级;以及队列管理算法自己的状态计数字段,它能告诉你算法是否进入过"持续积压"的处理状态。
这三个观测要同时在打流的状态下做。不打流看队列统计,和空转时 ping 一样,什么都看不出来。
整形不是免费的。把限速点设在略低于实际链路速率的位置,意味着你的峰值吞吐会下降——那部分被你主动"让出来"的带宽,是换延迟所付的价钱。
值不值,取决于你的业务是什么。如果这条出口上同时跑着在线业务和批量任务,那么用几个百分点的峰值吞吐换掉最坏情况下几百毫秒的延迟,几乎总是划算的——在线业务的体验提升是实打实的,批量任务本来也不差那点峰值,它慢一点没关系。
反过来,纯大流量搬运类的任务,整形的收益很有限。一条专门用来做离线数据同步、异地备份、镜像分发的链路,上面没有交互式流量,延迟高低对业务结果几乎没有影响,这时候去做整形就是在纯损失吞吐。这类链路应该把注意力放在别的地方——链路本身的稳定性、并发连接数的规划、传输任务的调度窗口,而不是队列管理。
还有一类不适合的情况是:瓶颈不在你能碰到的任何地方。如果你确认了限速点在运营商侧或平台侧、你在自己的机器上无论怎么配都抢不到队列的控制权,那么在客户机内部做精细队列管理的意义就不大了。这时候更实际的动作是控制发送端的行为——减少并发大流的数量、给批量任务加自己的速率上限、把备份窗口挪到业务低峰——用业务层的手段去规避,而不是指望队列算法救场。
两件事都发生了,但要看清哪个是目的、哪个是代价。调低整形速率的直接作用是把瓶颈从你控制不了的下游设备搬到你自己机器上。下游那个又深又不分流的队列不再被填满,它贡献的几百毫秒排队延迟消失了。你自己这边多出来的队列延迟,因为用了 fq_codel/CAKE 这类算法被压在很低的量级。所以净效果是延迟大幅下降,代价是峰值吞吐低了一点。如果你只是"限速"而没有配队列管理算法,那确实只是把网变慢了,什么也没改善。
如果你的场景是"我知道这条链路大概能跑多少,想直接设个值把延迟压住",CAKE 更省心——它把整形、按流公平、优先级区分和 NAT 感知都打包好了,不用你自己拼组合。如果你的队列管理是挂在某个已有的分级结构下面(比如 HTB 的某个叶子类别),或者你只想加一层队列管理、整形由别的机制负责,那 fq_codel 更合适。两者底层思路同源,差别主要在"是否需要自己搭整形这一层"。
有用,但有个硬前提:整形值必须设在略低于实际可用速率的位置,不能设成等于标称带宽。云主机的限速常常发生在宿主机侧或交换机侧,你在客户机里设成等于标称值的话,队列永远填不满,队列管理算法永远不会被触发,等于白配。还有一层更隐蔽的坑:内网和公网的限速位置可能不一致,两侧要分别确认实际速率,别用一套参数覆盖两个方向。
可以,而且这是很常见的做法。思路是用 HTB 之类的分层机制给不同类别的业务划定速率上限,备份类放在一个被限制了上限的类别里,在线业务放在另一个类别。但要注意两点:一是分类规则要能准确匹配到流量(按端口、按地址、按标记),匹配错了等于没配;二是每个类别下面仍然要挂队列管理,光限速不分流,类别内部的排队问题依然存在。
不一定是配错,很可能是正常代价。整形点设在略低于实际速率的位置,峰值吞吐本来就会降。按流公平生效之后,原来一条流独吞带宽的局面被打破了,单条大流的速度下降是预期内的。你要判断的是:这个下降幅度是否在你让出的那部分带宽范围内。如果掉得远超预期,再去查整形值是不是设得太保守、分类规则是不是把不该限的流量也圈进去了。
整形本质是出口方向的动作,你控制不了包什么时候进来。入口想限速通常只能做 policing,代价是靠丢包发信号,会引发重传,对延迟没有改善作用。更实际的做法是去管发送端的出口——想限制某台机器的下载速度,在上游那个发送端做整形效果更好。在网关或边界设备上确实必须做入口限速时,要清楚自己拿到的是速率上限,别指望它解决卡顿。
先用 tc -s qdisc 看当前接口上挂着什么,默认 qdisc 因发行版和内核版本而异,早期多为 pfifo_fast,多数现代发行版默认 fq_codel,但一定要在你自己的机器上确认,别照抄文档。至于 target 和 interval,默认值对绝大多数场景已经够用,先别动。真要调,前提是你能说清楚"为什么默认值不适合我",并且有办法对比调整前后的延迟变化——没有对照观测就改参数,只会把问题搞得更难查。
需要说明的是,文中未引用任何实测、压测或跑分数据,具体参数需以自有业务压测为准。本文引用的机制性事实来自公开标准与通行实现:CoDel 见 RFC 8289,fq_codel 见 RFC 8290,两者在 Linux 上的默认参数量级(target 5 毫秒、interval 100 毫秒)为常见实现取值,具体以你所运行内核的实际默认值为准;CAKE、HTB 与 tc 的行为描述基于其公开设计目标。
需要你自己在自有环境里验证的部分包括:你的默认 qdisc 究竟是什么、你的链路实际可用速率是多少、限速点位于哪一层(客户机、宿主机还是交换机)、内网与公网方向的限速位置是否一致、以及"略低于实际速率"这个"略"在你的环境里到底取多少。这几项都无法从文档推断,只能观测得出。
本文也不涉及拥塞控制算法选型、窗口调优、MTU/MSS 调整等相邻话题——它们和队列管理有关系,但属于另一层的问题,混在一起处理容易互相掩盖。
队列管理这件事的效果上限,很大程度上取决于你能不能拿到一个稳定、可预期的链路速率——如果实际可用速率本身波动很大,那么"略低于实际速率"这个值就很难定,整形点会不停地在生效和失效之间摇摆。这也是为什么在做队列调优之前,先要把带宽形态和端口形态确定下来。
一万网络深耕 IDC 19 年(成立于 2007 年),BGP 多线结合 CN2 GIA 回国线路,带宽与端口形态可按业务峰值选择,7×24 中文工单平均 5 分钟响应。
具体到这类场景,能帮上忙的地方在于:带宽与端口形态可以按业务峰值来选,避免"标称很高、实际波动"带来的整形点漂移;当你的在线业务和批量任务共用一条出口时,可以配合确认限速点在哪个层次、以及公网与内网方向的速率形态是否一致,这两项信息直接决定你本地的队列配置能不能生效。至于具体的队列参数取值,仍然要以你自己的观测结果为准。
Copyright © 2013-2020 idc10000.net. All Rights Reserved. 一万网络 科技有限公司 版权所有 深圳市科技有限公司 粤ICP备07026347号
本网站的域名注册业务代理北京新网数码信息技术有限公司的产品