返回 Notes

技术笔记 ·

抓包握手完美,客户端却报连接超时

一个看起来铁定是跨境网络故障的问题,根因在服务器上,而且是两层叠加的。我在网络层排查了一上午,中途还两次以为已经修好。

早上打开 Vercel,site 项目在报 500。错误信息干脆利落:

[Error [ConnectTimeoutError]: Connect Timeout Error
 (attempted address: api.site.chuchuang.work:443, timeout: 10000ms)]
 code: 'UND_ERR_CONNECT_TIMEOUT'

connect timeout。后端在阿里云杭州,前端在 Vercel,跨境调用连不上——看起来再清楚不过的网络问题。

于是我花了一上午排查网络。方向从头到尾都是错的,而且真正的根因有两层,我在中途两次以为自己已经修好了。

被逐一排除的假说

假说怎么死的
宝塔 / fail2ban 封禁抓包显示每个 SYN 都在 60 微秒内收到应答,零拦截
阿里云安全组443 对 0.0.0.0/0 开放,拒绝规则全是欧洲段、建于一年前
跨境链路拥塞同一时刻另一条路径访问同一后端只要 0.6s
区域选错从 hkg1 切到 hnd1,再切回 iad1,故障一模一样
PMTU 黑洞抓包里大包一次都没重传
DNS 分线路解析日本、香港、通用子网全部解析到同一个 IP

中间还一度怀疑到 Vercel 的 Data Cache 上——因为失败的那条路径用了 next: { tags },成功的那条是原生 fetch。逻辑自洽、证据也对得上,可惜同样是错的。

卡住我的那个悖论

在服务器上抓包,TCP 三次握手完美无缺

10:31:37.750622  客户端 > 服务器.443: Flags [S]
10:31:37.750695  服务器.443 > 客户端: Flags [S.]   ← 73 微秒就回了

零重传、零丢弃、100% 应答。可客户端那边,同一批请求全部卡满 10 秒然后报连接超时。握手明明成功了,客户端凭什么说连不上?

答案是我一直默认错了一件事:

TCP 握手由内核完成,TLS 握手由 nginx worker 完成。 而 undici 的 connectTimeout 覆盖的是整个连接建立过程,包括 TLS

当 worker 全卡在等 PHP-FPM 时,内核照常回 SYN-ACK(抓包完美),但没有 worker 能接手 TLS 握手,连接就那么晾着,10 秒后客户端放弃。"服务器握手正常"和"客户端连接超时"因此可以同时成立。

这个认知偏差,是我在网络层绕远路的起点。

第一层:WP_DEBUG 把磁盘写爆了

真正让方向掉头的不是什么高级工具,是一个对照组:同一台服务器上的另一个站点完全正常,只有这个站在失败。

同机、同 IP、同一套 nginx——如果是网络问题,不可能只挑一个站点下手。改测后端本身,立刻见分晓:

api.site:    超时(12s)   9.64s   0.81s   1.19s   6.33s
api.launch:   7.68s      6.08s   3.50s   4.47s   4.43s

两个站点都慢得离谱(正常应在 500ms 内)。所谓"网络故障",其实是服务器根本忙不过来。

元凶是主题依赖 tonik/gin 的 8 个 ArrayAccess 方法在 PHP 8.1+ 下每次请求都触发 deprecation notice,而生产环境的 WP_DEBUG_LOG 开着——每个请求几十次磁盘写入

关掉之后:api.site 从 3–10 秒降到 0.21 秒,另一个站点没改任何配置也跟着快了 10 倍(被邻居的日志 I/O 拖累)。

我当时宣布修好了。这是第一次高兴得太早。

第二层:PHP-FPM 的冷启动风暴

几十分钟后,部署时又爆出同样的 UND_ERR_CONNECT_TIMEOUT

这次我学乖了,直接压并发:

单个请求:           0.21s
10 个并发:  2.87  2.87  2.88  3.00  3.38  3.40  4.21  6.09  6.09s

**单请求飞快,一上并发就崩。**再高一点就能越过 10 秒线——而部署会让缓存全部失效、页面集中重渲染,并发轻松到二三十。

看 PHP-FPM 配置,max_children = 30 一点不小,我原以为的"进程数不够"当场作废。真正的问题在上一行:

运行模式:按需模式(ondemand)

ondemand 不预启动常驻进程,每个请求都要现场 fork,fork 完还得完整初始化一遍 WordPress;空闲进程 10 秒后还会被回收。于是:

  • 低流量时进程全被杀光,下一个请求得冷启动
  • 突发并发时触发 fork 风暴,十几个进程同时跑 WP 初始化,在 2 个核上互相抢 CPU

这也解释了这个故障最折磨人的特征——间歇性。我平时单发一条 curl 去测,总是碰上热进程,看起来一切正常。

修复与最终数据

服务器是 2核2G,还跑着多个 WP 实例。所以正确的方向不是调大,反而是调小

运行模式:      按需模式 → 动态模式     ← 关键
max_children:  30 → 10                ← 调小

30 × 实际内存 会直接把 2G 机器推进 swap,而 2 个核同时也就能跑 2 个进程,开 30 个只是让它们互相抢。排队比争抢更高效。

场景修复前修复后
单请求0.21s0.19s
10 并发2.87 – 6.09s0.36 – 1.02s
25 并发(10 个就已 6s)0.25 – 0.81s
故障路径404 / 11s,53 次连续失败200 / 0.9 – 1.6s

距离 10 秒超时线有一个数量级的余量。两层都改掉,问题才真正消失。

记下来的几件事

  • **「warning 无害」不等于「记录 warning 无害」。**判断那条 deprecation 时我只看了语义,没算副作用。每请求几十次磁盘写,在有流量的机器上就是 I/O 炸弹。
  • **单请求快不等于并发扛得住。**我用单发 curl 测了一上午,全是绿的。压到 10 并发,问题一秒现形。测性能必须测并发。
  • 参数不是越大越好。max_children=30 在 2核2G 上是危险配置,调到 10 反而快了 6 倍。小机器要「少而热」,不要「多而冷」。
  • **症状层不等于根因层。**应用层饱和会精准伪装成网络故障,抓包、DNS、路由查下来全是干净的。
  • **对照组比什么工具都管用。**三小时的抓包分析,抵不上「另一个站点是好的」这一句。
  • 别急着宣布胜利。这次我错了三轮:先怀疑封禁,再怀疑 PMTU,然后宣布「关掉日志就好了」。每次都有看似充分的证据。真正的判据是在故障的实际触发场景下复现不出来,而不是「我随手测了几下是好的」。