深入版:不只讲「有哪几步」,而是讲清每一步到底在协商什么、失败了会怎样、你该怎么亲手验证。这一篇的落点是:以后线上出问题,你能用三条curl 命令定位到哪一层,而不是靠猜。
你服务器 124.222.108.196 上的 memberhandbook.cn,有三个天天见、说不清归属的现象:
①主站是 React 单页应用,84 个 URL给爬虫只返回 3120 字节空壳 HTML(正文全靠 JS 执行后才有)
② www.memberhandbook.cn 会 301 跳到非www(规范域名)
③ nginx 里有 try_files $uri $uri/ /index.html 这条兜底规则,/tool/ 上还多一条 try_files $uri.html $uri $uri/ /index.html
速查版讲的是「这三条现象分别归谁管」。这一篇往下钻一层:DNS 为什么改完要等、握手到底握什么、nginx 凭什么把请求交给 Node、返回 200 怎么可能是错的。
先说清楚一件事:TCP 握手不传业务数据。它只解决一个问题——确认「我在」,顺便约定「我们以后用哪套规则说话」。
行不通,两次会留下一个致命漏洞:历史连接的垃圾包会被当成新连接。
举个真实场景:你 3 分钟前打开过网站,连接断了,路上留了一个包卡在网络里。3 分钟后你重新打开,这个迟到的旧包正好 arrive在新连接建立的那一刻。
| 握手次数 | 发生什么 | 后果 |
|---|---|---|
| 两次(假设可行) | 服务器发出 SYN+ACK 就认为连接建好了,开始等数据 | 那个迟到的旧包被当成「客户端的第一批数据」收下→ 服务器把一个根本不存在的请求当成真的,浪费资源,甚至可能返回错误响应给一个没发请求的人 |
| 三次 | 服务器要等到客户端的第三次确认才认为连接真建立 | 迟到的旧包因为序号对不上,直接被丢弃,不会污染新连接 |
TCP 连接建好了,现在是一条明文的管道——路上任何人抓包都看得见你请求的 URL 和 Cookie。TLS 的任务就是在这条管道里装一把只有你和服务器有钥匙的锁。
上面这套流程有个致命漏洞:如果我伪造一个服务器,把自己的公钥发给你,你加密后我用自己的私钥解开——你完全没察觉,数据全在我手里。
证书就是来解决这个漏洞的。证书里写着「这把公钥确实属于 memberhandbook.cn 这个域名」,并且由一个你信任的机构(CA,证书颁发机构)签了名。浏览器内置了一堆公认 CA 的公钥,只要签名对得上,就说明这把钥匙没被掉包。
用openssl 自己签的证书,签名人是你自己,不是任何浏览器信任的 CA。浏览器会想:「任何人都能给自己签一张证书,我凭什么信?」——这就是「不受信任的证书 / NET::ERR_CERT_AUTHORITY_INVALID」。
| 场景 | 浏览器表现 | 本质 |
|---|---|---|
| Let's Encrypt / 宝塔签发的证书 | 小绿锁,正常访问 | 签名人 CA 在浏览器信任列表里 |
| 自签证书 | 红色警告页,要你手动点「继续」 | 签名人不在信任列表,等于「无法验证是谁」 |
| 证书过期 | 警告页 | 有效期过了,身份声明失效 |
| 域名不匹配 | 警告页 | 证书写的是 A 域名,你访问的是 B 域名 |
/www/server/panel/vhost/cert/<子站>/,不在 certbot 的仓库里。所以你跑 certbot renew 管不到它→ 证书过期 → nginx 仍引用旧证 → HTTPS 握手直接失败(https 000),现象是「页面完全打不开」,不是「页面变慢」。修法是重新签发后 手动 cp 到宝塔的 cert 路径,nginx 才认。HTTP 的朴素之处在于:它就是一大段纯文本,没有二进制协议那么难读。请求和响应都是「一堆行 + 一个空行 + 正文」。
GET /fde/kb/14-request-deep.html HTTP/1.1 Host: memberhandbook.cn User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Accept: text/html,application/xhtml+xml Accept-Encoding: gzip, deflate, br Connection: keep-alive Cache-Control: max-age=0 <空行> (GET 请求没有正文,这个空行之后什么都没有)
第 1 行是请求行,三段:方法 + 路径 + 协议版本。GET 是「我要东西」,POST 是「我给你东西」。
空行之后是请求头,每行一个 名字: 值。请求头是「补充信息」:我要什么格式、有没有身份、有没有缓存。
HTTP/1.1 200 OK Server: nginx Content-Type: text/html; charset=utf-8 Content-Length: 18432 Cache-Control: no-cache Last-Modified: Thu, 02 Oct 2026 03:20:11 GMT ETag: "66b1f0a2-4810" <空行> <!doctype html> <html lang="zh-CN"> <head><title>深入 · 一次请求的完整旅程 ...</title></head> ... </html>
第 1 行是状态行:版本 + 状态码 + 一句人类可读的原因短语(OK/Not Found/Moved Permanently)。程序只看状态码,那句短语是给人看的。
| 头 | 大白话 | 真实作用 / 你的项目里 |
|---|---|---|
| Host | 我要访问的是哪个站点 | 同一台 IP 上挂了主站和一堆子站,全靠这个头区分。你自己在服务器上用 curl http://127.0.0.1/不带 Host 会落到默认站点 → 误判成404「文件不存在」,这是多 vhost 环境的经典坑,必须写 -H 'Host: lumenx.memberhandbook.cn' |
| User-Agent | 我是什么客户端 | 爬虫识别完全靠它。你做反向验证时默认 curl UA 不改,会拿到和真实浏览器不同的内容,误判「站点不给爬虫」。你主站预渲染那次的验证就是:curl 带 DeepSeekBot UA 应该同样 200,证明问题在 JS 渲染不在 robots |
| Authorization | 我的身份凭证 | 你 delimao-system 前端存了 JWT,发请求时带上:authorization: Bearer <token>。Bearer 的意思就是「持有者即可通行」,不校验是不是本人签发。这条是 httpBatchLink 的 headers() 里加的 |
| Content-Type | 我发/我回的东西是什么格式 | 请求里 application/json(tRPC 传的就是 JSON),响应里 text/html。最常见的 405 就是它对不上:一个只收 POST 的 API 被当成静态文件 GET 返回 |
| Cache-Control | 这东西能不能缓存 | HTML 默认别缓存,静态资源(js/css/图)可以长缓存。它是「改了没生效」误判的常见来源——文件真改了,但浏览器/代理拿着旧缓存 |
curl -A "Mozilla/5.0...",原因就在这张表里。| 类别 | 含义 | 典型码 | 你该怎么做 |
|---|---|---|---|
| 1xx | 还在协商,没完 | 100 Continue、101 升级 | 一般看不到,工具会自动处理 |
| 2xx | 成了 | 200 | 拿到就够?不够,还得看内容对不对(见下方红线) |
| 3xx | 换个地方 | 301 /302 / 304 / 307 | 决定「永久搬家」还是「临时借用」,选错会留后遗症 |
| 4xx | 你(客户端)错了 | 400/ 401 / 403/ 404 / 405 / 422 | 别重试,先查请求本身:路径、身份、方法 |
| 5xx | 我(服务器)错了 | 500 / 502 / 503 / 504 | 可以重试,但要查后端日志。502 = nginx 找到了后端但后端没回话;504 = 后端回话太慢 |
语义是「这个地址从此不再用了,以后都去新地址」。浏览器会把它记下来、缓存下来——下次你直接敲旧域名,浏览器可能根本不请求网络,直接跳新地址。这叫缓存型重定向。
语义是「这次先你去那边,下一次我可能还给你别的答案」。浏览器不缓存它,每次都老实发请求问一次。
搜索引擎把 301 当成「旧站把这页的权重全部移交到新页」,会把旧 URL 排出去换成新 URL。302 不会转移权重。所以换域名、合并页面必须用 301,用 302 等于告诉搜索引擎「我这儿只是临时有事」。
你主站的真实问题:www.memberhandbook.cn 和 memberhandbook.cn 都能打开同一批页面。这在 SEO 里叫「重复内容 + 权重分散」——搜索引擎不知道该把排名给谁,两边都排不上。
server {
listen 80;
server_name www.memberhandbook.cn;
# 规范域名:永久搬家,浏览器和搜索引擎都会记住
return 301 https://memberhandbook.cn$request_uri;
}
# 反过来,非 www 那边所有请求都 301 过去 → 整个站只有一个「官方地址」
return 301 https://$host$request_uri;,certbot 的 HTTP-01 验证会被自己的 301 拦死——它要访问 /.well-known/acme-challenge/,请求被301 甩到死 https,证书永远签不出来。修法是先给 location ^~ /.well-known/acme-challenge/ 开个口子放行,再把剩下的 301 出去。nginx 是门卫。一个请求进来,它按固定的优先级决定:读磁盘上的文件?还是转给 Node 进程?还是给404?
请求进来
│
├─ ① 精确匹配location = /某个路径
│ └─ 优先级最高,一旦命中就结束,别的规则再看
│ (你给 /video/polish_r2v_prompt 单独开的就是这个)
│
├─ ② 前缀匹配 location ^~ /前缀/
│ ├─ 命中且带 ^~ → 立刻生效,不再往下匹配正则
│ │ ⚠️ 好处:不会被后面的正则location 抢走
│ │ ⚠️ 代价:同前缀的后端 API 也会被它吃掉
│ │ (你踩过:POST /video/polish_prompt 被改写成 /files/video/polish_prompt → 静态目录只收 GET → 405)
│ └─ 命中但不带 ^~ → 记下这个候选,继续试正则 location
│
├─ ③ 正则匹配 location ~ /某个正则/
│ └─ 命中就用它。nginx 按配置里出现的先后顺序试,
│ 第一个匹配的就赢(不是最长匹配)
│
├─ ④ 普通前缀 location /前缀/(不带 ^~ 不带 ~)
│ └─ 前面正则都没赢,才用这个(最长前缀优先)
│
└─ ⑤ 都没有 → 走 server 层的兜底
│
├─ try_files $uri $uri/ /index.html
│ ① $uri 当文件找 ──有就返回
│ ② $uri/ 当目录找,找里面index.html ──有就返回
│ ③ /index.html ──都没有就返回它(SPA 兜底)
│
├─ 都没有 → 404
└─ 你也可以在最后写 return 200 /index.html 强制兜底
location /fde/ {
alias /www/wwwroot/memberhandbook.cn/fde/;
# 关键:静态文件必须能被爬虫直接读到完整 HTML
try_files $uri $uri/ =404;
# ⚠️ 这里刻意不写 /index.html 兜底
#因为 /fde/ 是真静态站,兜底会把「写错的路径」也返回 200,
# 让 404 变成永远发现不了的假象
}
为什么这里不兜底:SPA 主站需要兜底(因为磁盘上真的没有 /bt/ 这类文件),但纯静态站不需要。写错 URL 就该给 404,这是给搜索引擎的诚实信号。
location / {
proxy_pass http://127.0.0.1:17177;
# 反代五件套,少一个都会出诡异问题
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s; # AI 生成要等很久,超时必须放大
}
| 这五行在干什么 | 不写会怎样 |
|---|---|
Host $host | Node 收到的是 127.0.0.1:17177,拼出来的绝对地址全错,回调链接、重定向全指向本地 |
X-Real-IP | 日志里所有请求 IP 都变成 127.0.0.1,你看不出谁在调,排查问题直接瞎 |
X-Forwarded-For | 丢失真实来源链,多层代理时无法定位 |
X-Forwarded-Proto | 后端不知道原请求是 https,拼出来的 URL 降级成 http,前端接口调用可能被浏览器拦(混合内容) |
proxy_read_timeout | 默认 60 秒。你那边 AI 润色/视频生成要几分钟,60 秒一到 nginx 就把连接掐了,前端看到的是 504 而不是模型错误 |
/uploads/、/storyboard/、/video/、/audio/、/video_inputs/)转给后端的 /files/。它用 ^~,优先级高于给后端 API 用的正则 location,顺序无关。所以新增后端 API 一律别用这几个前缀命名。修法是加 location = /精确路径,优先级最高。DNS 就是电话簿:把域名翻译成 IP。你只需要认识一种记录——A。
| 记录类型 | 作用 | 你什么时候用 |
|---|---|---|
| A | 域名 → IPv4 地址 | memberhandbook.cn → 124.222.108.196。你配站 99% 就是在配这一条 |
| CNAME | 域名 → 另一个域名(别名) | 子域指向 CDN 或另一个主机名时用 |
| AAAA | 域名 → IPv6 地址 | 有 IPv6 才配 |
| CNAME/NS/MX/TXT | 别名 / 权威服务器 / 邮件 / 各种验证 | 站长验证的 TXT 记录走这一类 |
curl 显式指定新 IP 并带上域名的 Host 头,这样能绕过 DNS 直接测到目标机器,用来区分「DNS 还没生效」还是「服务器本身有问题」。# 绕过 DNS 直测某个 IP(区分是 DNS 问题还是服务器问题)
curl -I --resolve memberhandbook.cn:443:124.222.108.196 \
https://memberhandbook.cn/fde/
# ↑ --resolve 意思是「这次别查 DNS,直接用这个 IP,但域名的 Host 头照旧」
线上出问题后,人工点开看不算验证。你看到的浏览器页面经过了缓存、重定向、JS 渲染多层加工。验证要程序化:看状态码 + 看字节数 + 看内容采样。
curl -I https://memberhandbook.cn/fde/ # 输出举例: # HTTP/2 200 # server: nginx # content-type: text/html; charset=utf-8 # last-modified: Thu, 02 Oct 2026 03:20:11 GMT # -I 只取响应头,不下载正文,快
curl -A "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)\
AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" \
-I https://memberhandbook.cn/tool/abandoned
# ⚠️ 不加 -A 就是 curl 默认 UA,会拿到和真实浏览器不同的结果
curl -s -o /dev/null -w "%{http_code}:%{size_download}\n" https://memberhandbook.cn/tool/abandoned
# 期望输出: 200:18432 (正常,有真实内容)
# 如果输出: 200:3120 ←这就是你主站那个空壳!字节数一下就暴露了
# 连类型一起看:
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" https://memberhandbook.cn/tool/abandoned
# 期望: 200 text/html
# 同一 URL 换三种 UA 各测一次,把字节数并排比
for UA in "curl/8.0" "Mozilla/5.0 (compatible; DeepSeekBot/1.0)" "Mozilla/5.0 (compatible; Bytespider)"; do
N=$(curl -s -A "$UA" -o /dev/null -w "%{http_code}:%{size_download}" https://memberhandbook.cn/tool/abandoned)
echo "$UA -> $N"
done
# 判读:
# 三个字节数一样 → HTTP 层没拦爬虫,问题在页面是 JS 空壳
# 爬虫 UA 字节数明显小 → 服务器在按 UA 做区别返回,要去查 vhost
# 爬虫 UA 状态码非 200 → 才是真的被拦
# 看完整的跳转链(-L 跟随,-v 看每次请求的头) curl -IL http://www.memberhandbook.cn/fde/ # 期望: 301 → https://memberhandbook.cn/fde/ → 200 # 如果看到 302,或者干脆 200(www 也直接出内容)→ 规范域名没做对 # 服务器本机直测必须带 Host,否则落到默认站点误判 404 curl -H 'Host: lumenx.memberhandbook.cn' -I http://127.0.0.1/projects/
200 只说明「服务器愿意给你一个响应」,不代表那个响应里有你要的东西。你 LumenX 整站挂着 Basic Auth,页面返回 200,但浏览器给你弹的是登录框——爬虫拿到的是登录页,不是正文。所以必须加一条:同时验证字节数和内容采样。
# 内容采样:确认拿到的确实是正文,不是登录页/错误页 curl -s https://memberhandbook.cn/tool/abandoned | grep -o '<title>[^<]*</title>' # 如果输出是站点名(而不是这个工具页自己的标题)→ 就是空壳,需要预渲染
临时跳转用了 301,浏览器把跳转永久缓存了,你想改回来发现改不动——因为客户端已经不请求你了。规范域名、域名迁移用 301;登录后跳回、灰度切换、A/B 分流用 302 或 307。
拿 curl 默认 UA 去测,测的是「curl 看到什么」,不是「爬虫看到什么」。结论会错,方向会错,排查会白跑一轮。验证爬虫行为时,UA 必须换成对应爬虫的真名;验证用户行为时,用真实浏览器 UA。
你改完 HTML 刷新,页面还是老的。可能是浏览器缓存、CDN 缓存、或 Cache-Control 设成了长缓存。排查第一步永远是绕缓存验证:curl 直连(curl 不读浏览器缓存),确认服务器上新内容确实存在;如果 curl 能看到新的、浏览器看不到,那就是缓存问题,不是部署没成功。
Host / User-Agent / Authorization / Content-Type / Cache-Control 各自管什么= > ^~ > 正则 > 普通前缀的优先级^~ 抢API 会导致405,并会用 location = 修curl -s -o /dev/null -w "%{http_code}:%{size_download}" 一眼看出 SPA 空壳我按六步答,每步给一个可验证的证据,不空谈:
1. DNS——查 memberhandbook.cn 对应哪个 IP,拿到 124.222.108.196。这一步是查电话簿,还没开始传数据。改 DNS 后要等,是各级 DNS 都有缓存。
2. TCP 三次握手——建立连接、确认双方在线并约定传输规则。必须三次,因为两次的话服务器无法区分「对面是真想连,还是网络里迟到的历史包」。
3. TLS 握手——协商出只有双方知道的会话密钥。证书的作用是证明「这把钥匙的持有者真是这个域名」,因为不加证书的话任何人都能伪造一把公钥骗走你的数据。自签证书浏览器报警,就是因为签名人不在信任列表里。
4. HTTP 请求响应——请求行 + 请求头 + 空行 + 正文,响应状态行 + 响应头 + 正文。关键头是 Host(多站点靠它区分)、User-Agent(爬虫识别就靠它)、Authorization(我项目里 JWT Bearer 就走这个头)、Content-Type、Cache-Control。
5. nginx 决策——门卫。按 = 精确 → ^~ 前缀 → 正则 → 普通前缀的优先级选location,然后走 try_files 逐个尝试:文件 → 目录 → 兜底 index.html。我主站的 /tool/ 多一条 $uri.html,因为预渲染页存成了带 .html 的静态文件,URL 还是干净的。
6. 渲染——静态 HTML 直接显示;React SPA 则是下载空壳 + 一个大 JS,执行后才把内容填进 <div id="root">。
最后落到我们项目的真实对比上收尾:主站 84 个 URL 给爬虫只返回 3120 字节空壳,因为它是 JS 渲染;而 /fde/ 这些静态页 nginx 直接递出完整文字,不依赖 JS。所以收录差异不在 SEO 技巧,在架构——一个是图纸,一个是成品。验证也不靠人工点开,用 curl -s -o /dev/null -w "%{http_code}:%{size_download}" 取状态码和字节数,再换不同 User-Agent 对比,能程序化定位到问题在哪一层。
curl -I 看头、curl -A 换 UA、curl -IL 看跳转链、--resolve 绕过 DNS——每一条都对应一个具体的排查场景。