技术笔记 ·
抓包握手完美,客户端却报连接超时
一个看起来铁定是跨境网络故障的问题,根因在服务器上,而且是两层叠加的。我在网络层排查了一上午,中途还两次以为已经修好。
早上打开 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.21s | 0.19s |
| 10 并发 | 2.87 – 6.09s | 0.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,然后宣布「关掉日志就好了」。每次都有看似充分的证据。真正的判据是在故障的实际触发场景下复现不出来,而不是「我随手测了几下是好的」。