上一篇在讲路径时,我们把“位置”说清楚了:绝对路径和相对路径怎么数、/etc、/var、/usr 各自装什么。那篇解决的是“指得到”的问题;这篇开始动手——创建、复制、移动、删除。
mkdir、cp、mv、rm 这四个命令本身很短,但它们在服务器上出的事故往往不是因为敲错命令,而是因为对三件事没预期:目标已经存在时会怎样、源和目标在两个文件系统上时会怎样、参数里带了通配符时会怎样。这篇就把这三种情况逐个拆开。
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 dirB(dirB 已存在)会把 dirA 整个塞进 dirB,而不是替换 dirB。
想让 mv 把目标当作一个普通名字而不是“放进这个目录”,用 -T:
1mv -T dirA dirB此时如果 dirB 是非空目录,rename() 会以 ENOTEMPTY 失败;如果 dirB 是空目录,则会被替换掉。另外记一下:移动目录不需要 -r,mv 自己会处理,需要 -r 的是 cp。
rm:删的是名字,不是内容
rm 默认只删文件,遇到目录直接报错。删目录要 -r 或 -R(递归),只想删空目录可以用 -d。
它做的事情接近 unlink:把文件名从目录里摘掉。只有当没有任何名字再指向那份数据时,磁盘空间才算被回收。所以 rm 不等于安全擦除,内容在一段时间内仍可能被恢复。同理,删掉一个还有硬链接指向的文件,内容依然在。
几个选项的含义值得分清楚:
-i:每一个文件都问一次。-I:文件超过三个、或者带-r递归时,只问一次。比-i克制,但足以挡住大多数误操作。-f:两层含义——忽略不存在的文件,并且永不提示(包括对写保护的只读文件)。所以rm -rf是“递归、不问、找不到也算成功”。
rm 默认带着 --preserve-root 保护,会拒绝 rm -rf /。文件名以 - 开头时(比如 -foo),用 rm -- -foo 或 rm ./-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 次。最危险的组合是通配符、变量和递归删除混在一起。不确定展开结果时,先把同一条模式交给 echo 或 ls -d 看一遍,再把它交给 rm。
收起来
拿到一个文件操作,按这个顺序想三件事:目标存不存在——mkdir 会因此报错,cp 和 mv 会覆盖(除非写了 -n、-u),rm 只看名字不看内容;两边是不是同一个文件系统——同则 mv 只是改个目录条目,跨则它退化成复制加删除;参数里有没有通配符——有的话,真正决定命令行为的是 shell 把它展开成了什么。