上一篇在讲路径时,我们把“位置”说清楚了:绝对路径和相对路径怎么数、/etc/var/usr 各自装什么。那篇解决的是“指得到”的问题;这篇开始动手——创建、复制、移动、删除。

mkdircpmvrm 这四个命令本身很短,但它们在服务器上出的事故往往不是因为敲错命令,而是因为对三件事没预期:目标已经存在时会怎样、源和目标在两个文件系统上时会怎样、参数里带了通配符时会怎样。这篇就把这三种情况逐个拆开。

mkdir:唯一一个“目标已存在”就停手的命令

1mkdir logs

目录已存在时,它报 File exists,什么都不做。这一点和后面三个命令正相反,也是它比较安全的原因。

常用的是带 -p 的形式:

1mkdir -p /srv/app/logs

-p 做两件事:中间缺的层一起建出来,并且每一层已经存在都不算错。所以 mkdir -p 可以反复执行而结果不变——写部署脚本时这一点很有用。

cp:目标已存在时,它是在原文件里改写

这是全篇最值得记住的一步。

cp a.txt b.txt,如果 b.txt 已经存在,cp 并不是“删掉 b.txt,再放一个新文件进去”。按 POSIX 的规定,它做的事情是:以“只写 + 截断”的方式打开 b.txt,把它的长度清成 0,然后写入 a.txt 的内容。

这个实现细节解释了几个很实际的现象:

  • b.txt 这个目录条目和它背后的文件没有变。所以如果别处有硬链接也指向 b.txt,那些链接会一起看到新内容。
  • b.txt 的权限和属主 不会 变成 a.txt 的。它们属于那个早已存在的文件,打开一个已有文件不会重设它的 mode。
  • 覆盖不是原子的。写到一半被中断,b.txt 就停在“旧内容已经没了、新内容只有一半”的状态。想要“先删掉再新建”的语义,用 --remove-destination,代价是中间有一瞬间目标不存在。
  • 如果 b.txt 是一个指向普通文件的符号链接,cp 默认跟着链接走,改写的是链接指向的那个文件,链接本身留着。

几种覆盖策略的差别:

写法目标已存在时
cp a b覆盖
cp -i a b每次覆盖前问你
cp -n a b跳过,不动它
cp -u a b只有当 a 比 b 新才覆盖

复制目录要加 -r。目标目录已存在时,cp -r src dst 的结果是 dst/src;如果 dst/src 也已经存在,两边会 合并:同名文件按上面的规则覆盖,而 dst/src 里多出来的文件会原样保留——cp 不会为了“和源一致”而删东西。这是很多人对 cp -r 的误解。

另外,不加 -p 时,复制出来的新文件用的是你的默认属主和 umask 权限,时间戳也是新的。

mv:同一个文件系统上只是改名,跨了就是复制加删除

mv 的机制和 cp 有本质区别,这一点决定了它跨分区时会有完全不同的代价。

在同一文件系统内,mv a b 实际调用的是 rename()。它只改目录里的条目,不搬任何数据:文件是 10 KB 还是 10 GB,都是瞬间完成,也不多占空间;而且是 原子 的,别人只可能看到“老名字还在”或“新名字已经可用”,看不到中间状态。

rename() 有一条硬限制:源和目标必须在同一个挂载的文件系统上,否则返回 EXDEV(跨设备链接)。这时 mv 会退化成 把源复制到目标,再把源删掉。按 POSIX 要求,它不保证这两个位置能原子切换,中途被打断可能留下不完整的副本;目标是一个全新的文件;源上的硬链接关系也不会跟着过去。

还有一个容易忽略的后果:跨文件系统时,属主、时间戳这些属性可能复制不过去。这种情况下 mv 会往标准错误打一条提示,但 退出状态仍然是成功——也就是说脚本里判断“上一条命令成功了吗”并不会因此失败,属性没跟上却没人拦你。

下图是这条判断路径:

绘制中

想提前知道会走哪条路,比较两个位置所在的文件系统就行:

1df /home/deploy/app /data

看输出的 Filesystem 或 Mounted on 两列是否相同。

另一个必须记住的坑是“目标是已存在的目录”。 mv 的规则是:如果最后一个参数是一个已经存在的目录,就把前面的每个源 放进去。于是

1mv app.conf /etc/nginx

是把文件放进 /etc/nginx/app.conf。如果你本来想给 app.conf 改个名字,结果它进了目录里,而且 /etc/nginx/app.conf 若已存在会直接覆盖。同样,mv dirA dirBdirB 已存在)会把 dirA 整个塞进 dirB,而不是替换 dirB

想让 mv 把目标当作一个普通名字而不是“放进这个目录”,用 -T

1mv -T dirA dirB

此时如果 dirB 是非空目录,rename() 会以 ENOTEMPTY 失败;如果 dirB 是空目录,则会被替换掉。另外记一下:移动目录不需要 -rmv 自己会处理,需要 -r 的是 cp。

rm:删的是名字,不是内容

rm 默认只删文件,遇到目录直接报错。删目录要 -r-R(递归),只想删空目录可以用 -d

它做的事情接近 unlink:把文件名从目录里摘掉。只有当没有任何名字再指向那份数据时,磁盘空间才算被回收。所以 rm 不等于安全擦除,内容在一段时间内仍可能被恢复。同理,删掉一个还有硬链接指向的文件,内容依然在。

几个选项的含义值得分清楚:

  • -i:每一个文件都问一次。
  • -I:文件超过三个、或者带 -r 递归时,只问一次。比 -i 克制,但足以挡住大多数误操作。
  • -f:两层含义——忽略不存在的文件,并且永不提示(包括对写保护的只读文件)。所以 rm -rf 是“递归、不问、找不到也算成功”。

rm 默认带着 --preserve-root 保护,会拒绝 rm -rf /。文件名以 - 开头时(比如 -foo),用 rm -- -foorm ./-foo

通配符:命令根本看不到那个 *

最后这条会反过来影响上面所有命令,所以放在这里。

rm *.log 里,rm 从来没见过 *.log 这几个字符。是你的 shell 把它展开成匹配到的文件名列表, 把这些名字交给 rm。理解通配符,本质上就是理解 shell 在命令运行之前做了什么:

  • * 匹配任意多个字符,但 不匹配 /,也 不匹配文件名开头的 .。所以 rm * 不会删掉 .bashrc 这类隐藏文件,得显式写 rm .*dotglob
  • ? 匹配一个字符,[...] 匹配集合里的一个字符。
  • 展开结果按字母序排列,所以 cp *.txt dir/ 的处理顺序是确定的。
  • 一个模式匹配不到任何东西时,bash 默认把模式原样留下。 于是在没有 .log 文件的目录里执行 rm *.log,实际请求是“删除一个名叫 *.log 的文件”,报 No such file or directory。用 shopt -s nullglob 会让匹配不到的模式变成空;用 shopt -s failglob 会让命令直接不执行并报错。注意 nullglob 的风险:ls *.zip 敲成 ls *.zpi 时会退化成 ls,把当前目录全部列出来。

因为它发生在命令执行之前,通配符展开的数量会直接改变命令的行为:rm *.log 展开出 300 个文件时,-I 只问你一次,-i 会问你 300 次。最危险的组合是通配符、变量和递归删除混在一起。不确定展开结果时,先把同一条模式交给 echols -d 看一遍,再把它交给 rm

收起来

拿到一个文件操作,按这个顺序想三件事:目标存不存在——mkdir 会因此报错,cp 和 mv 会覆盖(除非写了 -n-u),rm 只看名字不看内容;两边是不是同一个文件系统——同则 mv 只是改个目录条目,跨则它退化成复制加删除;参数里有没有通配符——有的话,真正决定命令行为的是 shell 把它展开成了什么。