Appearance
跨境链路慢与不稳定的诊断
一台带宽充足的境外代理服务器,多人使用时仍可能出现“网页偶尔慢、长请求和大请求体上传超时”。这类问题不能靠加带宽解决,也不能凭感觉换协议。本页把一次完整排查整理成可复用的方法:先用证据排除服务器,再把问题收敛到客户端到服务器这一段,最后确认协议形态是否放大了丢包,并用固定探针验证修复效果。
触发场景
- 服务器出口带宽标称足够,单账号限速也不低(例如每人 16 Mbit/s),实际体验却忽快忽慢,而不是持续慢。
- 浏览器大多数时候正常,但通过代理访问中转 API 的客户端经常卡住;网关日志里出现大量
408。 - 服务器负载、内存、进程状态都正常,重启服务只能短暂缓解。
“忽快忽慢”是判断的第一个线索:持续慢通常是容量或限速问题,波动慢更像链路丢包或路径抖动。
先排除服务器
下面每一项都应当用命令取得数字,再做结论。
| 检查项 | 看什么 | 本次结论 |
|---|---|---|
| 负载与连接 | uptime、ss -s | 负载接近 0,活跃连接不到 20 条 |
| 出口速度 | 在服务器上直接下载公网大文件、测到目标的首包时间 | 单线程约 8 MB/s,首包正常 |
| 限速队列 | tc -s qdisc 中各队列的 dropped | 每账号限速队列无丢包,限速不是瓶颈 |
| TCP 重传 | nstat 的 TcpRetransSegs / TcpOutSegs | 累计约 2.3%,30 秒抽样约 0.5% |
| 多网卡路由 | ip rule、ip route get <目标> from <本机地址> | 回包的源地址与出网网卡不一致,见 Hysteria2 落地 |
累计重传率只能说明“历史上出现过丢包”,不能证明此刻的瓶颈。抽样时要和业务高峰对齐,并且同时记录出站段数,用比例而不是绝对次数下结论。
把问题收敛到客户端到服务器
排除服务器后,问题只可能在三个位置:客户端到服务器的公网路径、协议形态本身、客户端自己的配置。证据来自两处。
连接层数据。 在服务器上用 ss -tin 查看面向客户端的连接,可以读到每条连接的 rtt、retrans、cwnd。本次样本的 RTT 在 80 到 130 ms 之间,抖动(rtt 后的偏差值)达到 25 到 50 ms,重传集中出现在面向客户端的连接上。ICMP ping 的丢包率可能被中间设备限速,不能单独当作丢包证据。
网关耗时日志。 如果入口 Nginx 记录了 request_time、upstream_header_time、status、content_length 和 request_length,就能把“网关慢”和“请求没传完”区分开:
- 成功请求的
upstream_header_time与上游模型的响应时间一致,说明后端不慢。 408集中在少数来源,约占这些来源请求的三成;失败请求的content_length在 30 到 50 万字节,而request_length只有 16 KB 左右,也就是约一个 HTTP/2 DATA 帧的大小,之后再没有数据到达。408的含义是“客户端没有在时限内发完请求体”,不是网关处理慢。
这两组数据合起来指向同一个结论:大请求体上传在很早的阶段就被卡住,之后一直等不到后续数据。
为什么 TCP 套 TCP 在丢包链路上更差
常见的 Shadowsocks 加 v2ray-plugin 的 WebSocket 方案,数据要依次经过 Shadowsocks 加密、WebSocket 封装、TLS 和底层 TCP。三层都跑在同一条 TCP 连接上:
- 底层任何一个包丢失,整条隧道里的所有请求一起等待重传,这是队头阻塞。
- 内层应用的 TCP 也有自己的重传和拥塞控制。外层重传期间,内层看到的是延迟突增,容易误判为拥塞而降速;降速后恢复又需要多个往返。
- 大请求体上传需要连续多个往返才能发完,更容易撞上丢包;浏览器的小请求短而多,受影响反而小。
这解释了“浏览器多数正常、大上传经常超时”的差异,也解释了为什么越测越慢:每次重传都在压低发送窗口。QUIC 基于 UDP,丢包只影响对应的流;Hysteria2 还可以按声明的速率固定发送,不会被丢包持续拉低,所以在同一条路径上表现更稳。方案细节见 Hysteria2 落地与订阅编排。
诊断流程
四个判断点依次回答:服务器资源是否正常、服务器到目标的出口是否正常、客户端到服务器的链路是否异常、失败是否集中在大请求体上传。前两个为“否”时,在服务器侧处理;第三个为“否”时,回到按层排查(规则、DNS、节点参数);第四个为“是”时,结论才是隧道协议在丢包链路上的队头阻塞。
用请求体上传做探针
排查和验收必须使用同一个可重复的探针,否则无法比较修复前后。大请求体上传最贴近真实故障:
bash
# 生成约 500 KB 的请求体
head -c 500000 /dev/urandom | base64 | head -c 500000 > body.bin
# 向中转服务发起 12 次 POST;没有带密钥时返回 401 也算“请求体已传完”
for i in $(seq 1 12); do
curl -s -m 25 -X POST -H 'Content-Type: application/json' \
--data-binary @body.bin -o /dev/null \
-w "%{http_code} %{time_total}\n" \
https://relay.example.com/responses
done判定标准是“25 秒内是否收到服务端的任意响应码”,统计完成次数和耗时分布。本次结果:
| 通道 | 完成情况 | 单次耗时 |
|---|---|---|
| 直连(TCP) | 12 次只有 4 次完成,其余超时 | 完成的从 1.5 秒到 15 秒不等 |
| Hysteria2 隧道 | 连续 40 余次全部完成 | 约 1 到 2.5 秒 |
比较时要使用同一时段、多轮测试,并关注完成率和分位数,不要只看单次峰值速度。
测试时容易误判的陷阱
这次排查中,下面几个问题让早期的若干轮测试结果完全无效,写在这里是为了下次先排除。
| 现象 | 原因 | 处理 |
|---|---|---|
| 经代理的测试瞬间失败或全部通过 | 环境变量 no_proxy=* 让 curl 忽略 -x 参数,请求根本没进代理 | 测试时加 --noproxy '',或先 unset no_proxy |
| 对任何外网 IP 的 TCP 握手都只要约 1 ms | 本机或局域网有透明代理接管了连接 | 先确认流量真实路径,再判断延迟 |
代理参数没有传给 curl | zsh 不会按空格拆分未加引号的变量 | 使用数组,或 ${=VAR} 展开 |
| 内核刚启动时请求全部失败 | 配置含 GEOIP 规则时,首次启动需要下载地理库,耗时可达数十秒 | 等待监听端口就绪后再测 |
| 测试中途连接断开 | 另一个会话同时修改并重启了服务端 | 测试前确认没有并发变更 |
| 结论来回摇摆 | 只测一次、只看峰值 | 固定探针,多轮取完成率与分位数 |
总结与适用边界
- 这套方法针对“服务器正常、客户端到服务器的路径有丢包或抖动”的场景。如果前两个判断点为“否”,先按表处理服务器问题。
- 结论不是“换一种协议”,而是三件事一起做:用 QUIC 通道承载主要流量,保留 TCP 通道作为故障切换兜底,用固定探针验证效果。
- 探针的数字只代表当次网络环境。换运营商、换时段或换地区后需要重新测,不能直接外推。
