同一个错误,为什么你在电脑上永远复现不了
去年有个读者给我发消息,说他的网站"在手机上打不开,电脑上完全正常"。他自己试了七八次手机都失败,让我帮他看看。我打开他的站,秒开。这种事在运维圈里有个半开玩笑的说法:最难修的 bug,是修不了的 bug。它只在特定设备、特定网络、特定时刻出现,而你的测试环境永远是"健康"的那一种。
这篇文章讲的就是怎么打破这个僵局。核心技巧只有一个:让服务器"假装"自己就是那台出问题的设备。做法是伪装请求头,让同样的 URL 返回不同的内容 —— 如果你能精确复现差异,问题就解决了一半。
为什么同一个 URL 会返回不同结果
很多人以为"同一个链接,所有人看到的东西都一样"。这在现代网站里几乎从不成立。至少有六个层面会让同一个 URL 输出完全不同:
- User-Agent 判断:有些主题或插件会对手机和电脑返回不同模板,甚至对搜索引擎爬虫返回不同内容
- Accept-Encoding:服务器可能给支持 Brotli 的浏览器返回 br 压缩,给其他的返回 gzip 或不压缩
- Cookie / 登录态:登录用户看到缓存绕过,游客看到缓存版本
- IP 归属地:CDN 按访客所在地区分发不同节点,某些节点配置不一致
- Referer:防盗链规则会让"从站外点进来"的请求返回 403
- HTTP 版本:HTTP/2、HTTP/3 与 HTTP/1.1 走的代码路径不同
"手机打不开、电脑正常"这个现象,最可能落在第一到第五条上。所以我们要做的,就是拿着 curl 逐一模拟这些条件,看哪个条件触发了故障。
第一步:先在裸条件下拿到基准
永远从最简单的请求开始。这个请求不带任何伪装,代表"服务器最原始的响应":
curl -sS -o /dev/null -w '状态:%{http_code} 大小:%{size_download} 耗时:%{time_total}s\n' \
"https://www.example.com/?_=$(date +%s)"把状态码、返回字节数、耗时记下来。这三个数字是你后面所有对比的锚点。特别注意返回大小 —— 如果伪装成手机后返回的字节数骤减,说明走了另一套模板或触发了错误页,这本身就是极强的线索。
第二步:伪装成手机浏览器
这是解决本类问题的核心命令。用 -A 指定 User-Agent,模拟 iPhone 上的 Safari:
UA_MOBILE='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'
curl -sS -o /tmp/mobile.html -w '状态:%{http_code} 大小:%{size_download} 耗时:%{time_total}s\n' \
-A "$UA_MOBILE" \
-H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' \
"https://www.example.com/?_=$(date +%s)"
# 看看返回的到底是什么
grep -o '[^<]* ' /tmp/mobile.html
head -c 500 /tmp/mobile.html如果这一步就出错了(403、500、或者返回一个奇怪的页面),那问题就锁定了:你在处理移动端 UA 的逻辑里有 bug。常见原因有三种:
- 移动端检测代码写得不对,把正常 UA 误判成了爬虫并拒绝
- 移动端模板文件缺失或路径写错,导致 500
- 缓存插件按 UA 生成缓存,但移动端缓存从未被正确生成,一直返回空
顺带说一句,我强烈建议把常用 UA 存成变量或写进脚本,而不是每次手打 —— 手打极易出错,一个括号位置错了,模拟的就不是你以为的那个浏览器了。
第三步:伪装成搜索引擎爬虫(做 SEO 必须会)
排查收录问题的时候,这一招是刚需。你要确认"百度看到的页面"和"人看到的页面"是不是同一个:
UA_BAIDU='Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)'
UA_GOOGLE='Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
curl -sS -o /dev/null -w '百度爬虫: 状态%{http_code} 大小%{size_download}\n' \
-A "$UA_BAIDU" "https://www.example.com/?_=$(date +%s)"
curl -sS -o /dev/null -w '谷歌爬虫: 状态%{http_code} 大小%{size_download}\n' \
-A "$UA_GOOGLE" "https://www.example.com/?_=$(date +%s)"我见过最典型的事故是:站长装了一个"防采集"插件,规则写得太宽,把 Baiduspider 也一起拦了。结果就是文章天天发,收录一动不动,站长还以为是内容质量问题,白白优化了一个月。用上面两条命令,一分钟就能排除这种可能。
顺便提醒:有些站会对爬虫返回"伪装内容"(cloking),这在搜索引擎的规则里是明确违规的,一旦被发现会被降权甚至除名。我们用它来排查,绝不用它来作弊。
第四步:模拟 CDN 边缘节点的行为
如果直连源站正常、走 CDN 异常,用 --resolve 精确控制请求落到哪个 IP,就能判断是不是某个节点的问题:
# 强制请求打到源站 IP
curl -sS -o /dev/null -w '直连源站: 状态%{http_code} 耗时%{time_total}s\n' \
--resolve "www.example.com:443:1.2.3.4" \
"https://www.example.com/?_=$(date +%s)"
# 强制请求打到某个具体的 CDN 节点
curl -sS -o /dev/null -w '指定CDN节点: 状态%{http_code} 耗时%{time_total}s\n' \
--resolve "www.example.com:443:5.6.7.8" \
"https://www.example.com/?_=$(date +%s)"这个技巧在多节点 CDN 上特别好用。曾经有个站长反馈"部分用户图片加载不出来",我们用这个方法轮询了几个节点 IP,发现其中一个节点上缓存的图片是损坏的旧版本。清除该节点缓存后立刻恢复。—— 如果没有 --resolve,你只能反复清整站缓存,既慢又可能误伤。
第五步:让 curl 显示完整的响应头
很多答案就藏在响应头里,而默认 curl 不显示它们。用 -I 发 HEAD 请求,或 -D 把响应头存到文件:
# 只看响应头
curl -sSI -A "$UA_MOBILE" "https://www.example.com/?_=$(date +%s)"
# 头 + 体分开保存,方便逐项核对
curl -sS -D /tmp/headers.txt -o /tmp/body.html \
-A "$UA_MOBILE" "https://www.example.com/?_=$(date +%s)"
cat /tmp/headers.txt重点看这几个头:
Content-Type:如果移动端返回的是text/plain而不是text/html,浏览器会直接显示源码 —— 表现就是"手机打开全是乱码"Content-Encoding:如果声明了 br 但内容其实是明文,浏览器解码失败就白屏Vary:如果按 UA 返回不同内容却没有Vary: User-Agent,CDN 会把手机版缓存喂给电脑用户,或者反过来 —— 这是"时好时坏"类故障的头号元凶X-Cache/CF-Cache-Status:直接告诉你这次是命中缓存还是回源了Location:如果是 301/302,看看跳到了哪里,有没有死循环
把整套流程写成一个排查脚本
手动敲这些命令效率太低,我把它固化成了一个脚本,输入域名就自动跑一轮全条件对比:
#!/bin/bash
# ua-probe.sh —— 多条件请求对比排查
URL="$1"
TS=$(date +%s)
UA_PC='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'
UA_MOBILE='Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1'
UA_BOT='Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)'
probe() {
local label="$1"; shift
local out
out=$(curl -sS -o /dev/null -w '%{http_code} %{size_download} %{time_total}' \
"$@" "${URL}?_=${TS}${RANDOM}")
printf '%-12s 状态码:%s 大小:%s字节 耗时:%ss\n' "$label" $out
}
echo "===== 多条件请求对比 $(date '+%F %T') ====="
probe "默认" ""
probe "电脑UA" -A "$UA_PC"
probe "手机UA" -A "$UA_MOBILE"
probe "百度爬虫" -A "$UA_BOT"
probe "无Referer" -e ";auto"
probe "HTTP/1.1" --http1.1 -A "$UA_PC"
echo "===== 对比完成,某项明显异常即为突破口 ====="用法很简单:./ua-probe.sh https://www.example.com/。正常情况下六行的状态码应该一致、大小接近;只要有一行明显不同,你就找到了突破口,再针对那个条件单独深挖即可。
给它配上定时监控,让故障自己浮出来
排查解决的是"已经发生"的问题。更高级的做法是:让脚本定期跑,异常时主动告警。这样你就能在用户投诉之前知道:"手机端 UA 从今天凌晨 3 点开始返回 500 了"。
#!/bin/bash
# ua-watch.sh —— 移动端可用性巡检,异常则告警
URL="https://www.example.com/"
UA_MOBILE='Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1'
CODE=$(curl -sS -o /dev/null -w '%{http_code}' -A "$UA_MOBILE" "${URL}?_=$(date +%s)")
if [ "$CODE" != "200" ]; then
MSG="[告警] $(date '+%F %T') 移动端访问异常,状态码=${CODE},URL=${URL}"
echo "$MSG"
# 这里换成你的告警方式(邮件 / Server酱 / 钉钉机器人)
# curl -s -d "text=${MSG}" "https://你的告警接口" > /dev/null
fi放进 crontab 每十分钟跑一次:
*/10 * * * * /root/ua-watch.sh >> /var/log/ua-watch.log 2>&1这个脚本只有十几行,却数次帮我提前发现了模板更新导致的移动端白屏。它的价值不在于技术含量,而在于把"偶然发现"变成了"必然发现"。
三条踩坑经验
这套方法我用了很多次,也翻过车,下面三条是拿教训换来的:
一、别忘了带上缓存绕行参数
对比测试时必须给 URL 加一个唯一参数(我常用 ?_=时间戳)。否则 CDN 会把第一次的结果缓存下来,后面所有请求读的都是同一份缓存 —— 你测出来的"全部正常",可能只是缓存内容的正常,真实源站早已出错。这个坑我踩过不止一次。
二、注意 Vary 头缺失导致的缓存污染
如果你的站按 UA 返回不同内容,务必要让 CDN 知道这件事。Nginx 上可以用:
add_header Vary "User-Agent, Accept-Encoding" always;没有这个头,CDN 会随机把手机版缓存发给电脑用户,现象就是"同一个页面刷新几次内容不一样",极其难查。加上之后,CDN 会按 UA 分别缓存,问题消失。
三、不要用"我试了没问题"结案
排障最忌讳的就是用自己的单一环境下结论。你用的是 Chrome,不代表客户的 Safari 没问题;你在南方,不代表北方的线路没问题。正确的结案标准是:能用命令复现出客户的故障,并且修复后能用同样的命令验证故障消失。两条都做到,才算真的修好了。
小结
"手机上打不开"这类问题之所以让人绝望,是因为它把人和故障隔离在了两个环境里。而 curl 的 User-Agent 伪装,本质上是一把能让你"借来别人的眼睛"的钥匙 —— 你可以是 iPhone、可以是百度爬虫、可以是任何一个 CDN 节点。一旦你能稳定复现,问题就从"玄学"变成了"待修的代码"。这也是我这些年最深的体会:运维里的大多数绝望,都不是因为问题太难,而是因为你还没有找到那把复现它的钥匙。