为什么“上传成功”经常只是假象
做个人网站这些年,我踩过最冤的一类坑,不是代码写错了,而是文件传上去了,但站点用的是另一份。你在本地改了半天 CSS,部署脚本也回显“上传成功”,刷新浏览器却一点变化都没有;过两天你手动去服务器上看,发现那个文件确实已经不是旧内容了——于是你开始怀疑人生:到底是缓存没刷,还是我改的不是这个文件?
这类问题在个人站长圈子里极其常见,因为它同时牵扯到三件事:上传工具的行为、部署目标路径是否正确、以及生效链路里有没有中间层在帮你“兜着旧版本”。本文围绕一个非常具体的场景展开:用 SSH/SCP、rsync、tar 管道这三种常见方式把文件推到网站目录时,怎么确认“推过去的那份”就是“站点实际读的那份”,以及出问题时按什么顺序排查。
先搞清楚:站点到底读哪个路径
绝大多数“传了不生效”,根因是第一环就错了——你推的目录不是站点读的目录。Nginx 里给 PHP 站点的典型配置是这样的:
server {
listen 80;
server_name example.com;
root /www/wwwroot/example.com; # ← 站点真实根目录
index index.php index.html;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.1-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}先用一条命令把这个路径问出来,别靠记忆猜:
nginx -T 2>/dev/null | grep -E "server_name|root"
# 或只看某个站点
nginx -T 2>/dev/null | awk '/server_name example.com/,/}/' | grep rootnginx -T 会把 include 进来的配置一起打印出来,比你翻 sites-enabled 里某个文件靠谱得多。关键点:很多面板环境(宝塔、aaPanel)会有两套类似路径,比如 /www/wwwroot/example.com 与 /home/wwwroot/example.com,或者根目录外面还套一层 public。一旦搞错,你的文件会被完整、成功地传到磁盘上——只不过那个位置永远没人读。
顺带一个容易忽略的差异:如果站点开了软链接(部署到 releases 目录再切 current),root 指向的是软链接本身。这时你用 readlink -f 看到的真实路径和 Nginx 记的路径不一致是正常的,别急着去改配置:
readlink -f /www/wwwroot/example.com
ls -l /www/wwwroot/ | grep example.com三种上传方式的“成功”到底意味着什么
下面按我实际用过的顺序说,每一种都标注了它的“成功”能证明什么、不能证明什么。
1. scp / sftp:只证明字节到了磁盘
scp -P 22 ./style.css root@1.2.3.4:/www/wwwroot/example.com/static/style.cssscp 的成功有两层含义:网络层握手正常、远端写文件返回成功。它完全不检查目标目录里原本有什么、是否有多个同名文件、站点读的是不是这个路径。最常见的翻车是路径少写一层:本来要传到 /www/wwwroot/example.com/static/,结果传到了 /www/wwwroot/example.com/,站点读的是前者,页面自然没变化。
还有一个隐蔽问题:scp 不会自动创建中间目录(老版本),有些工具会静默失败或者报个不太明显的错。上传后立刻用校验和对齐,是兜底最省事的做法:
# 本地
md5sum ./style.css
# 远端(注意用站点真实路径)
ssh -p 22 root@1.2.3.4 "md5sum /www/wwwroot/example.com/static/style.css"2. rsync:多了一个“哪些文件被跳过”的视角
rsync 是我现在的主力,原因不是它更快,而是它会把“跳过/传输了什么”打印出来,这是 scp 给不了的可观测性:
rsync -avz --delete --itemize-changes \
--exclude='.git' --exclude='cache/' \
./ root@1.2.3.4:/www/wwwroot/example.com/--itemize-changes(简写 -i)是关键:每个文件的输出前缀告诉你它做了什么。>f+++++++++ 是新建,>f.st...... 表示内容更新,什么都没有、只列出文件名——那是被判定为已同步并跳过的。
这里有个极其经典的坑:rsync 判断“要不要传”默认用的是大小 + 修改时间(mtime),不是内容哈希。所以只要远端文件的 mtime 比你本地的新,rsync 就会认为“远端更新,不用传”,然后安安静静跳过。什么时候会出现这种状态?
- 你之前上传过一次,之后在服务器上
touch或sed -i改过这个文件; - 用 tar 解包过来的文件保留了打包时的 mtime,恰好比本地新;
- 某些网盘/编辑器同步工具回写时把 mtime 改成了当前时间。
遇到“rsync 说成功但内容没变”,先看 itemize 输出里这个文件是 >f 还是被跳过;确认要强推时用 --checksum(按内容哈希判断)或者直接 --ignore-times:
rsync -avz --checksum --itemize-changes ./style.css \
root@1.2.3.4:/www/wwwroot/example.com/static/style.css另外提醒一句:--delete 在没有 --dry-run 验证过之前不要第一次就跑。我习惯固定加一步:
rsync -avz --delete --dry-run ./ root@1.2.3.4:/www/wwwroot/example.com/ | head -503. tar 管道 / 解压覆盖:最容易掉进“层级错位”
没有 rsync 的机器上,很多人用这种一行流:
tar czf - -C ./dist . | ssh root@1.2.3.4 "tar xzf - -C /www/wwwroot/example.com/"它能用,而且速度不错,但要小心两点。第一是 ./ 打点的位置:-C ./dist . 表示“把 dist 里的内容作为包根”,解到目标目录就是 目标/xxx;如果你写成 tar czf - ./dist,解出来会变成 目标/dist/xxx,凭空多一层。第二是解包不删除多余文件:你删掉的旧文件还留在服务器上,如果它是被 include 的旧模板或者旧图片,就会出现“明明部署完了,页面上还是老图”。
一个可验证的组合写法,用管道两端的校验和互相确认:
# 远端先算好包内文件清单(不落盘)
tar czf - -C ./dist . | ssh root@1.2.3.4 \
"tar xzvf - -C /www/wwwroot/example.com/ 2>&1 | wc -l"tar xzvf 会把每个解出的文件名打到 stderr,行数就是文件数;和你本地 find ./dist -type f | wc -l 对一下就心里有数了。差值不为零,先别怪缓存。
上传成功但页面没变:按这个顺序排查
下面是我固定用的排查顺序,从“最便宜的动作”到“最贵的动作”,避免一上来就重启服务:
- 直接读服务器上的文件,而不是看页面。 在服务器上
tail -n 20 /www/wwwroot/example.com/static/style.css,确认它是不是你刚上传的内容。如果服务器上就是新内容 → 问题一定在缓存或生效链路,继续往下;如果服务器上还是旧内容 → 问题在上传环节,回去看路径和 rsync 跳过日志。 - 确认站点读的是这份文件。
nginx -T | grep root+readlink -f组合拳,排除“传对内容、传错位置”。 - 看文件的属主和权限。 传上去是
root:root 600,而 PHP-FPM 以www-data跑,读不了静态资源会 403、读不了 PHP 会 500。一条命令看清:ls -l加stat -c '%U:%G %a %n'。 - 确认没有第二个同名文件在抢。
find / -name 'style.css' -path '*example.com*',这一步能一次性暴露“两套路径”问题。 - 查缓存中间层。 浏览器强刷(Ctrl+Shift+R)、CDN 刷新、Nginx 的
proxy_cache、OPcache 的opcache.revalidate_freq。这四层任何一层都能让你看到旧内容。 - 查软链接是否真的切过去了。 用 releases 目录部署时,忘了切
current是高频事故,ls -l current一眼看穿。
把“假成功”变成可验证的真成功
与其每次出问题再排查,不如在上传那一刻就拿到证据。我现在固定做三件事。
第一,上传后立刻做远端内容指纹比对,而不是只看退出码:
LOCAL=$(md5sum ./dist/index.php | awk '{print $1}')
REMOTE=$(ssh -p 22 root@1.2.3.4 "md5sum /www/wwwroot/example.com/index.php" | awk '{print $1}')
[ "$LOCAL" = "$REMOTE" ] && echo "同步一致" || echo "不一致,别急着刷缓存"第二,用 HTTP 头确认页面真的是远端生成的新内容。Last-Modified 与 ETag 能帮你区分“文件换了”和“缓存还没过期”:
curl -sI "https://example.com/static/style.css?_=$(date +%s)" | grep -Ei 'last-modified|etag|cache-control|age'第三,给部署脚本加一个自检出口。 我现在的脚本最后一定会打印三行:本地哈希、远端哈希、HTTP 状态码。三者都对不上就 exit 1,让部署这件事有明确的成败判定,而不是靠“看起来成功了”。
还有一个很多人忽略的细节:编辑器与 FTP 客户端的“保存时上传”经常是异步的,你在终端里手动 scp 一份、编辑器后台又自动覆盖一份,最后的产物是哪个版本完全取决于时间差。改代码和传文件最好只走一条路径,避免自己跟自己打架。同理,如果你同时用 git pull 和 rsync 两种方式部署同一个目录,也会出现“刚提交的代码被 rsync 的旧快照覆盖回去”这种诡异现象,排查时先确认机器上到底有几个部署入口。
这类问题的共同点是“工具说成功了”,而不是“结果是对的”——所以验证的对象必须是站点实际读到的字节,而不是工具的返回码。另外值得养成的一个习惯是:每次部署完,把那一次的 commit 号或构建版本写进一个静态文件(比如 /version.txt),然后直接 curl https://example.com/version.txt 看一眼。页面缓存再厚,也挡不住你去取这个带时间戳的小文件;线上跑的是哪一版,从此一目了然。
把这套流程固化下来之后,我那类“传了不生效”的排障时间从半小时压到了两三分钟,而且大部分情况下第 1 步就能定位到底是在上传环节还是缓存环节。对个人站长来说,这类可验证的小习惯,比多装几个监控工具更能省时间。