文件上传成功却线上不生效排查实战:scp、rsync 与 tar 管道的假成功陷阱

为什么“上传成功”经常只是假象

做个人网站这些年,我踩过最冤的一类坑,不是代码写错了,而是文件传上去了,但站点用的是另一份。你在本地改了半天 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 root

nginx -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.css

scp 的成功有两层含义:网络层握手正常、远端写文件返回成功。它完全不检查目标目录里原本有什么、是否有多个同名文件、站点读的是不是这个路径。最常见的翻车是路径少写一层:本来要传到 /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 就会认为“远端更新,不用传”,然后安安静静跳过。什么时候会出现这种状态?

  • 你之前上传过一次,之后在服务器上 touchsed -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 -50

3. 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 对一下就心里有数了。差值不为零,先别怪缓存。

上传成功但页面没变:按这个顺序排查

下面是我固定用的排查顺序,从“最便宜的动作”到“最贵的动作”,避免一上来就重启服务:

  1. 直接读服务器上的文件,而不是看页面。 在服务器上 tail -n 20 /www/wwwroot/example.com/static/style.css,确认它是不是你刚上传的内容。如果服务器上就是新内容 → 问题一定在缓存或生效链路,继续往下;如果服务器上还是旧内容 → 问题在上传环节,回去看路径和 rsync 跳过日志。
  2. 确认站点读的是这份文件。 nginx -T | grep root + readlink -f 组合拳,排除“传对内容、传错位置”。
  3. 看文件的属主和权限。 传上去是 root:root 600,而 PHP-FPM 以 www-data 跑,读不了静态资源会 403、读不了 PHP 会 500。一条命令看清:ls -lstat -c '%U:%G %a %n'
  4. 确认没有第二个同名文件在抢。 find / -name 'style.css' -path '*example.com*',这一步能一次性暴露“两套路径”问题。
  5. 查缓存中间层。 浏览器强刷(Ctrl+Shift+R)、CDN 刷新、Nginx 的 proxy_cache、OPcache 的 opcache.revalidate_freq。这四层任何一层都能让你看到旧内容。
  6. 查软链接是否真的切过去了。 用 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-ModifiedETag 能帮你区分“文件换了”和“缓存还没过期”:

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 步就能定位到底是在上传环节还是缓存环节。对个人站长来说,这类可验证的小习惯,比多装几个监控工具更能省时间。

Last modification:September 21st, 2026 at 09:24 pm

Leave a Comment