当浏览器下载速度归零:服务端如何从 TCP ACK 中找到真相

2026年7月28日 0 条评论 44 次阅读 1 人点赞

我在 CDN 行业工作多年,平时接触最多的就是大文件分发、下载加速、链路质量和各种奇怪的网络问题。

我们公司有一款网盘产品,很多文件都支持通过浏览器直接下载。也就是说,用户点击网页上的下载按钮之后,后续下载过程基本就交给了浏览器自己的下载管理器。

这件事听起来很普通,但只要做过下载体验优化,就会知道这里面有一个很麻烦的问题:浏览器不是我们的客户端。

PC 客户端下载慢了,我们可以埋点,可以分片重试,可以上报错误码,也可以记录每一段下载耗时。浏览器下载慢了,尤其是下载进入浏览器下载管理器之后,页面侧能感知到的信息就非常有限。

于是经常会出现一种很尴尬的场景:

用户说:“我下载卡住了,速度变成 0 了。”

我们看服务端日志:“没有啊,服务端已经发了很多数据。”

用户继续说:“可它就是不动了。”

这时候问题就来了:到底是谁在说真话?

应用层日志不等于用户真的收到了

很多时候,服务端 access log 会给我们一种错觉。

比如 Nginx 日志里显示 body_bytes_sent 已经有 10MB,我们很容易下意识认为:这 10MB 已经发给用户了。

但从 TCP 的角度看,这个理解并不严谨。

应用程序把数据写入 socket,只能说明数据进入了内核的 TCP 发送缓冲区。至于这些数据有没有真正从网卡发出去,经过网络后有没有到达用户浏览器,客户端有没有成功确认,应用层日志并不能直接回答。

所以在浏览器下载失速这个问题上,HTTP 日志只能告诉我们“我尽力往下写了”,但不能告诉我们“用户真的收到了”。

要确认这件事,我们需要去看 TCP 自己的账本。

TCP 的账本:ACK 有没有往前走

TCP 的可靠性依赖 ACK。服务端发送数据后,客户端会通过 ACK 告诉服务端:“我已经连续收到了哪里。”

可以先看一个简化场景:服务端依次发送了 1、2、3 三个报文,客户端收到了 2 和 3,但 1 在路上丢了。

这时客户端内核协议栈即使已经拿到了后面的数据,也不能绕过缺失的 1,直接把 2 和 3 交给浏览器下载逻辑。TCP 要按序交付,对上层应用来说,这段数据仍然是不连续的,而数据包卡在内核长时间不递交给上层应用,在实际表现上也就成了 0 速。

这就是典型的 TCP 队头阻塞:前面的报文没有补齐,后面的报文只能先卡在内核协议栈里。同时,客户端也不能跳过 1 去确认 2 和 3,所以它只能继续回复 ACK 1。即使开启了 SACK,客户端也只是告诉服务端“2 和 3 我已经收到了”,但累计 ACK 仍然停在 1,含义是:我还在等你从 1 开始把缺失的数据补齐。

这个现象落到服务端的 TCP 状态上,就是 snd_nxtsnd_una 之间拉开了距离:

  • snd_una:还没有被确认的最早序列号,也就是发送窗口的左边界;
  • snd_nxt:下一次准备发送的数据序列号,也就是服务端已经发送到哪里。

两者相减,就是当前连接里已经发送、但还没有被客户端确认的在途数据量:

inflight = snd_nxt - snd_una

这里的 inflight 可以理解为“在途数据”:服务端已经发出去了,但还没有收到客户端累计 ACK 确认的数据。它不是应用层队列里的待发送数据,而是 TCP 发送路径上已经离开服务端视角、正在等待确认的那部分数据。

如果一条连接是健康的,服务端不断发送,客户端不断确认,snd_una 就会持续向前推进。反过来,如果 snd_una 长时间不动,而 snd_nxt 停在比它更靠后的位置,也就是 inflight > 0,就说明服务端确实有数据发出去了,但客户端迟迟没有把累计确认位置往前推。

这里要特别强调一个边界:单纯看到 ACK 不推进,不能直接判断为下载失速。

比如 HTTP Keepalive 连接在两次请求之间处于空闲状态,服务端没有新数据要发,ACK 自然也不会推进。这不是失速,只是连接暂时没活干。

真正有价值的信号是:inflight > 0,并且 ACK 在足够长的时间内没有推进。前者说明服务端有数据在路上,后者说明这些数据迟迟没有被累计确认。两个条件同时成立,才有资格进入疑似失速判断。

为什么选择在 tcp_ack 上观测

为了在服务端侧识别这种情况,我们把观测点放到了 Linux 内核的 tcp_ack 路径上。

具体实现上,可以使用 eBPF 去 hook 内核 TCP 相关函数,在不修改业务进程的情况下采集连接状态。

这样拿到的不是 Nginx 或应用程序眼里的“写了多少”,而是内核 TCP 栈眼里的“确认推进到了哪里”。

拿到连接的四元组之后,还可以把内核里的 TCP 状态和 Nginx access log 关联起来。这里尤其要注意 HTTP keepalive:一条 TCP 连接上可能承载多条 HTTP 请求,因此也可能对应多条业务日志。

这样一来,我们既能看到某个 HTTP 下载请求在应用层写了多少,也能看到承载它的 TCP 连接在内核层到底有没有继续被客户端确认。

tcp_ack 是内核处理 ACK 的关键路径。每当服务端收到客户端 ACK,TCP 栈都会在这里更新发送窗口、确认序列、RTT、重传相关状态等信息。

相比应用层日志,在这里看到的是 TCP 连接更真实的推进情况。我们可以知道:

  • ACK 有没有继续推进;
  • 当前是否还有 inflight 数据;
  • ACK 停滞持续了多久;
  • 这条连接发生了多少重传;
  • 当前 RTT 大概是多少;
  • 失速大概从什么时候开始;
  • 浏览器所在的操作系统内核有没有把通告窗口打到 0。

当一条连接长时间 ACK 没有推进,同时仍然存在 inflight 数据,就可以先把它标记为疑似失速连接。接下来要做的不是马上下结论,而是看现场留下了哪些痕迹。

失速现场怎么看

用户看到的是“速度变成 0”,服务端看到的则是一组 TCP 状态。它们像几张不同角度的照片,拼在一起才接近真相。

如果 ACK 长时间不推进,且 inflight > 0 持续存在,说明服务端确实有数据已经发出去了,但客户端的累计确认没有继续往前走。这是“下载卡住了”的核心证据。反过来,如果 inflight = 0,ACK 不推进可能只是 keepalive 空闲连接,没有必要把它算成失速。

如果同时出现较高重传率,说明这条链路上很可能发生了丢包。丢包不一定是用户本地问题,也可能发生在 CDN 节点到用户之间的任意一段路径上。对 CDN 来说,这时就要继续按地区、运营商、节点维度聚合,看它是单个用户的偶发现象,还是一片连接都在抖。

如果 RTT 明显变大,说明这条连接还没完全死掉,但反馈变慢了。它可能正在从“慢”滑向“卡住”:先是 RTT 上升,然后重传变多,最后 ACK 长时间停住。

还有一种信号更直接:客户端通告窗口为 0。

这表示浏览器所在机器的 TCP 接收缓冲区已经满了,客户端内核明确告诉服务端:“先别发了,我这边收不动。”这种情况更像是接收端问题,或者其他本地因素拖住了数据包的消费速度。服务端这时看到的不是网络上丢得厉害,而是对方主动把门关上了。

这比一句“用户说慢”有用得多。用户的反馈是结果,TCP 状态能告诉我们过程。

总结

浏览器下载失速难处理,是因为客户端不在我们手里。页面无法可靠感知下载过程,HTTP 日志又只能说明应用层写了多少,不能证明客户端确认了多少。

所以我们把视角下沉到 TCP 层:看 ACK 有没有推进,看 snd_unasnd_nxt 之间有没有持续存在的 inflight,看重传率、RTT 和通告窗口如何变化。

这件事本质上是在回答一个很朴素的问题:用户说下载卡住了,服务端能不能看见?

答案是:可以,但不能只看 HTTP 日志。要去看 TCP 有没有真的往前走,也要看对方是不是已经明说“我收不动了”。

兰陵美酒郁金香

大道至简 Simplicity is the ultimate form of sophistication.

文章评论(0)

你必须 登录 才能发表评论