1. 追加内容之前,先搞懂重定向的本质
在 Linux 下混了这么多年,我发现很多人对“往文件里追加内容”这件事的理解,都停留在“用 >> 就行”的层面。但真要上手写脚本、做日志、改配置的时候,各种问题就冒出来了:为什么追加完内容跑到文件开头了?为什么追加不了但也没报错?为什么加了 sudo 还是 permission denied?
这些问题,追根溯源还是没搞懂 Shell 重定向的本质。我在实际带人的时候常说一句话:>> 只是表象,文件描述符和重定向机制才是内功。内功不扎实,招式再多也会翻车。
1.1 为什么 > 和 >> 看起来一样,结果却天差地别
先看一个最简单的对比:
bash复制echo "hello" > test.txt
echo "world" >> test.txt
第一条命令用 > 把 hello 写入 test.txt,注意,这一步如果 test.txt 之前有内容,会被直接清空重写。第二条命令用 >> 把 world 追加到末尾,原有内容不受影响。
很多新手记不住这个区别,我通常给一个生活化的类比:> 是“新建文档并输入”,你每按一次回车,之前写的东西就没了,因为整个文档被覆盖了;>> 是“打开文档拉到末尾继续写”,之前的记录全都保留。
但这个类比只是帮助你记忆,真正的底层机制是:
>等价于在系统调用层面使用O_WRONLY | O_CREAT | O_TRUNC打开文件。O_TRUNC是关键,它会把文件长度截断为 0。>>等价于使用O_WRONLY | O_CREAT | O_APPEND打开文件。O_APPEND意味着每次写入前,内核都会把文件偏移量定位到末尾,而且是原子操作。
这里有个很多人忽略的细节:O_APPEND 的“定位到末尾”是内核保证的原子操作,不是 Shell 先调用 lseek 再调用 write 那么简单。这就解释了为什么多进程并发追加日志时,>> 通常不会互相覆盖(后面我会专门展开讨论这个问题)。
1.2 文件描述符才是理解输出的关键
在 Bash 中,每个进程默认有 3 个标准的文件描述符:
| 文件描述符 | 名称 | 默认指向 | 常见用途 |
|---|---|---|---|
| 0 | stdin | 键盘输入 | 读取输入 |
| 1 | stdout | 终端屏幕 | 正常输出 |
| 2 | stderr | 终端屏幕 | 错误输出 |
当我们执行 echo "msg" >> file,本质上是:
- Shell 以追加模式打开 file,得到一个新的文件描述符(比如 3)。
- Shell 临时把 fd 1(stdout)重定向到 fd 3。
- echo 进程运行时,它向 stdout 写入的内容,实际上都进了 file。
- 命令执行完毕,Shell 恢复 fd 1 的指向。
理解了这一点,你就能看明白很多“奇怪”的写法:
bash复制# 把正常输出和错误输出都追加到同一个文件
command >> log.txt 2>&1
# 把错误输出追加到 err.log,正常输出追加到 out.log
command >> out.log 2>> err.log
# 只丢弃正常输出,保留错误输出追加到日志
command 2>> err.log > /dev/null
2>&1 这个写法很多人背下来了,但不懂为什么必须写在后面。它表示:把 fd 2 重定向到 fd 1 当前指向的位置。如果写成 2>&1 >> log.txt,那么 fd 2 指向的是旧的 stdout(终端),而不是 log.txt,顺序错一个,行为就完全变了。实际工作中我见过不少因为这个顺序问题导致日志丢失的脚本,排查到最后就是这一行的事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最常用的几种追加写法与适用场景
搞懂原理之后,接下来看看实际操作中我们会用到的几种追加方式。每种方式都有它适合的场景,选对了能省不少事。
2.1 单行追加:echo 和 printf 的取舍
最基础的需求是追加一行文本,大家第一个想到的肯定是 echo:
bash复制echo "2026-05-01 10:00:00 INFO 服务启动成功" >> app.log
echo 的好处是简单直观,默认会在末尾补一个换行符,保证每次追加都独占一行。不过有两个小坑需要注意:
第一个坑是转义字符。在默认情况下(不开启 -e 参数),echo 不会解释 \n、\t 这类转义序列。如果写成:
bash复制echo "第一行\n第二行" >> file.txt
你得到的是一行字面量 第一行\n第二行,而不是两行。想要换行效果,要么拆成两个 echo,要么用 echo -e:
bash复制echo -e "第一行\n第二行" >> file.txt
但 echo -e 的可移植性并不是很好,有些 Unix 系统(比如默认的 sh)对 -e 的处理不一致。所以我在写脚本时更推荐 printf:
bash复制printf "2026-05-01 %s\n" "10:00:00 INFO 服务启动成功" >> app.log
printf 的优点在于格式完全可控,而且不会像 echo 那样在不同 Shell 之间行为有差异。它的第一个参数是格式字符串,%s 会被后续参数替换,\n 一定会被解释为换行。对于需要变量插入的场景,printf 明显更稳妥。
注意:如果追加的内容刚好以
-n或-e开头(比如要记录-n这个参数本身),echo可能会把它误认为是参数。用echo -- "-n"或者直接用printf "%s\n" "-n"可以规避。
2.2 多行追加:cat heredoc 的三种姿势
需要一次性往文件里追加多行内容时,echo 一行行写就很痛苦了。这时候用 heredoc 是标准做法:
bash复制cat >> ~/.bashrc << 'EOF'
# 自定义别名
alias ll='ls -alF'
alias update='sudo apt update && sudo apt upgrade'
export PATH="$PATH:$HOME/bin"
EOF
这个命令的执行逻辑是:cat 读取标准输入,重定向符号是 >>,所以读到的内容会被追加到 ~/.bashrc 的末尾。<< 'EOF' 表示从这里开始到单独一行的 EOF 为止,都是标准输入的内容。
这里有几个细节值得展开。
第一,EOF 加不加引号,效果完全不同。我见过很多脚本因此翻车:
bash复制# 不带引号:变量会被展开替换
cat >> config.txt << EOF
home=$HOME
user=$USER
EOF
# 带引号:内容原样写入,不做任何替换
cat >> config.txt << 'EOF'
home=$HOME
user=$USER
EOF
不带引号的版本,写入文件的是 /home/yourname 和 yourname;带引号的版本,写入的就是字面量 $HOME 和 $USER。如果你写配置文件、模板文件时希望保留变量名,等程序运行时再解析,那必须给 EOF 加引号。这个坑在写 systemd service 模板、Nginx 配置片段时尤其常见。
第二,结束标记必须顶格写。heredoc 的结束符前面不能有空格,不能有 Tab。你缩进多了它不认,会一直读下去直到 EOF,甚至把后面的命令都吞了。实际输出时如果脚本里用了缩进,看起来就会很崩。想解决这个问题,可以用 <<-(在 bash 中支持),它允许结束标记前有 Tab:
bash复制if [ "$DEBUG" = "1" ]; then
cat >> debug.txt <<- 'EOF'
line 1
line 2
EOF
fi
注意,<<- 只处理 Tab,不处理空格,而且最终写入的内容也会把前导 Tab 去掉。这个技巧适合在 if 语句里写 heredoc 时保持代码整洁,但如果你写入的内容本身需要保留前导空格,就别用它。
第三,heredoc 只是输入来源,真正干活的是前面的命令。除了 cat,任何能读取标准输入的命令都可以配合 heredoc 使用。比如:
bash复制tee -a multiple_targets.txt << 'EOF'
这行内容会同时追加到多个文件
EOF
这里用到了 tee,我下一节专门讲。
2.3 tee 追加:既要写文件又要看输出
有时候我希望追加内容到文件,同时希望追加的内容也能在终端上显示出来,方便确认写对了没有。这时候 tee 就派上用场了。
bash复制echo "backup completed at $(date)" | tee -a backup.log
tee 默认会覆盖文件,-a 参数让它改为追加模式。它既把输入写到文件,又原样输出到 stdout,相当于一个“分流器”。
这个命令在需要同时给多个文件追加相同内容时特别好用:
bash复制echo "sync completed" | tee -a /var/log/sync.log /home/user/sync_history.log
有点意思的是管道和权限的配合:tee 前面可以加 sudo,实现“普通用户执行命令、root 身份写日志”的效果。因为管道右侧的命令归右侧的用户权限管,单独给 tee 加 sudo 即可:
bash复制df -h | sudo tee -a /var/log/disk_usage.log
另外 tee 还有一个常见用途,是在脚本里给用户提示的同时记录操作痕迹:
bash复制echo "正在重启 Nginx..." | tee -a /tmp/deploy.log
3. 实战案例:日志记录、配置写入与数据累积
原理和工具都过了一遍,接下来进入实战环节。我挑三个最常见的场景展开讲,每个都是实际运维和开发中几乎天天碰到的问题。
3.1 给脚本加日志函数的完整示例
脚本跑着跑着挂了,但你不知道它跑到哪一步挂的——这个问题我相信每个人都遇到过。解决办法是给脚本加一个简单的日志函数,核心就靠 >> 追加:
bash复制#!/bin/bash
LOG_FILE="/var/log/my_script.log"
# 确保日志文件存在,不存在则创建
touch "$LOG_FILE"
log_info() {
echo "$(date '+%Y-%m-%d %H:%M:%S') [INFO] $*" >> "$LOG_FILE"
}
log_error() {
echo "$(date '+%Y-%m-%d %H:%M:%S') [ERROR] $*" >> "$LOG_FILE" 2>&1
}
log_info "脚本开始执行"
if mkdir -p /tmp/test_dir; then
log_info "目录创建成功: /tmp/test_dir"
else
log_error "目录创建失败"
exit 1
fi
log_info "脚本执行结束"
这个函数有几个设计细节:
$*会把所有参数以空格拼接成一个字符串,方便你传带空格的整条日志消息。date命令给每条日志加上时间戳,后续排查时能知道事件发生的时间顺序。touch确保文件存在,避免第一次写入时因文件不存在导致行为不一致。其实>>在文件不存在时会自动创建,但用touch显式处理更稳妥,尤其是在脚本早期就要判断文件权限的场景。- 日志输出统一走
>>,不会因为脚本中途有人设置set -o noclobber之类的选项就出错。
如果你希望脚本的错误输出也一起进日志,可以在调用脚本时做重定向:
bash复制./my_script.sh >> /var/log/my_script.log 2>&1
这样脚本里凡是没有显式重定向的输出(包括 echo 到 stderr、命令的报错信息),全部会追加到日志里。这也是“整段脚本输出全部走日志”最常见的实现方式。
3.2 批量写配置文件的注意事项
第二个场景是程序初始化时生成配置文件。这类需求的核心问题是:文件可能已存在、已有部分配置,我们需要在保留原有内容的前提下追加新配置。
假设我们要往 Nginx 的 conf.d 目录下追加一个站点配置片段:
bash复制cat >> /etc/nginx/conf.d/example.com.conf << 'EOF'
server {
listen 80;
server_name example.com;
root /var/www/example;
}
EOF
实际操作中有几个点必须考虑:
第一,要确认目标文件是否已存在且非空。如果文件不存在,> 和 >> 效果一样(都是创建新文件);如果文件存在且非空,>> 是追加,这是你想要的,但你最好先看一眼文件末尾有没有换行符。如果文件最后一行没有换行,你追加的内容会和原有内容挤在同一行,整个配置文件直接报废。稳妥做法是先 echo "" >> conf 补一个空行:
bash复制test -s /etc/nginx/conf.d/example.com.conf && echo "" >> /etc/nginx/conf.d/example.com.conf
cat >> /etc/nginx/conf.d/example.com.conf << 'EOF'
...
EOF
第二,写完配置最好做一次语法检查。比如 Nginx 有 nginx -t,系统 crontab 有 crontab -l 可以校验。这是经验之谈,我见过太多次配置文件写错导致服务起不来,尤其是多行 heredoc 里少了分号、括号不匹配这种肉眼难查的问题。
第三,批量操作时注意文件权限变化。如果用 sudo 追加,生成的文件属主是 root;如果后续程序以普通用户运行,可能没有读权限。可以在追加完成后统一调整一次权限:
bash复制sudo bash -c 'cat >> /etc/nginx/conf.d/example.conf << '\''EOF'\''
...
EOF
'
sudo chown root:root /etc/nginx/conf.d/example.conf
sudo chmod 644 /etc/nginx/conf.d/example.conf
这里我用了一个技巧:sudo bash -c '...' 把整段追加逻辑放到 root Shell 里执行,避免单独对重定向操作做 sudo 的权限细节问题。如果你是第一次写这种嵌套引号的命令,很容易搞错,建议先在本地测试。
3.3 处理已有文件的错误与陷阱
写脚本时,我们经常需要判断“目标文件是否已经包含某段内容”,避免重复追加。这个需求用 grep 就够了:
bash复制if ! grep -qF "alias ll='ls -alF'" ~/.bashrc; then
echo "alias ll='ls -alF'" >> ~/.bashrc
fi
-q 是静默模式,不输出匹配内容;-F 把模式当作固定字符串而不是正则表达式。这能避免 [、. 等字符被当成正则元字符解释。如果你不加 -F,像 alias ll='ls -alF' 这种带括号和点号的字符串,很可能匹配不到或误匹配。
另一个常见问题是文件末尾缺少换行导致追加的内容粘连。可以用一个辅助函数处理:
bash复制append_with_newline() {
local file="$1"
shift
# 如果文件非空且最后一行没有换行符,先补一个
if [ -s "$file" ] && [ -n "$(tail -c 1 "$file")" ]; then
echo "" >> "$file"
fi
echo "$*" >> "$file"
}
tail -c 1 读取文件的最后一个字节,如果它不是空(也就是说不是换行符),就补一个空行。这个技巧在写 Python、JSON 这类对行尾敏感的文件时特别有用。
4. 追加操作的常见坑与排查技巧实录
最后这块是我的“血泪史”。追加内容看起来简单,但实际环境里坑特别多,我把高频问题整理成速查表,再逐个展开讲。
4.1 permission denied、No such file or directory 的排查思路
追加时最常见的两个报错:
bash复制# 场景一:没有写权限
$ echo "test" >> /etc/some_file
bash: /etc/some_file: Permission denied
# 场景二:目录不存在
$ echo "test" >> /nonexistent_dir/file.txt
bash: /nonexistent_dir/file.txt: No such file or directory
第一种情况,解决思路是确认当前用户对文件及其所在目录的权限。注意,对文件有写权限还不够,你还需要对所在目录有写权限。因为当你要创建新文件时,实际上是需要在目录里写入目录项。哪怕文件已存在,如果目录没有写权限,>> 依然有可能失败(取决于文件本身的权限)。如果必须写,可以用 sudo 提权,但要想清楚是不是真的需要 root 才能写。
还有一种信息量很大的场景:命令没报错,但文件里没有内容。这通常是因为实际写入的是另一个文件,比如当前目录跟你以为的目录不一致:
bash复制cd /tmp
echo "hello" >> log.txt
# 结果怎么写到了 /tmp/log.txt,而不是你开始以为的 /root/log.txt?
排查时先用 pwd 确认当前目录,用 ls -l 看目标文件真实路径,别靠记忆猜。
4.2 noclobber、只读文件与符号链接的坑
Bash 有一个变量叫 noclobber,一旦开启,> 就无法覆盖已有文件(会报错),但 >> 不受影响:
bash复制set -o noclobber
echo "a" > existing.txt # 报错:cannot overwrite existing file
echo "b" >> existing.txt # 正常追加
这个特性在某些强化过的环境或别人的脚本里可能会遇到。如果你发现自己的脚本突然在某些服务器上报错,先检查对方是否设置了 noclobber。解决办法是显式取消,或者用 >| 强制覆盖:
bash复制echo "a" >| existing.txt
再说只读文件。文件有 w 权限是写入的前提,如果你用 ls -l 看到写入的目标文件没有写权限,即使是 root 用户,在某些安全设置下也会被拒绝。实际环境里更常见的是目标文件没问题,但目标路径的一个中间目录没有执行权限,导致无法进入该目录,于是报错。
符号链接也容易把人搞晕。如果你向一个符号链接追加内容,实际写入的是链接指向的目标文件。这本来没什么,但如果你在脚本里对同一个路径先 >> 后判断,可能会因为链接的指向变化导致数据写到别的地方。排查时用 readlink -f 看真实路径。
提示:
2>>和>> file 2>&1都能把错误输出追加到文件,但2>>只重定向 stderr,stdout 还是在终端。如果希望全部进文件,必须写>> file 2>&1,顺序别反。
4.3 并发追加会不会丢数据?内核的 O_APPEND 说了算
最后一个高频问题是:多个进程同时用 >> 往同一个文件追加,会不会互相覆盖?
答案是:在 Linux 上,用 >> 追加时不会互相覆盖。原因就是我在第一节提到的 O_APPEND 标志。当文件以 O_APPEND 打开时,每次 write 之前内核都会把当前偏移量自动设为文件末尾,并且这个“定位 + 写入”是原子操作。无论多少个进程并发追加,每次写入都是从当前末尾开始。
但这里有一个非常重要的前提:每次写入的数据量不能太大。对于普通文件,write 会一次性写入用户缓冲区的内容,多进程并发追加时,单次 write 是原子的,但如果你在用户态分两次写同一逻辑记录(比如先 write 一行时间戳,再 write 一行数据),那中间别的进程可能插入内容。另外如果你用的是 NFS 等网络文件系统,O_APPEND 的原子性不一定有保证。
实际工作中,我给并发日志场景的建议是:
- 每条日志尽量用一次
echo或printf写完整,不要拆成多次追加。 - 如果日志量大且并发高,用专业的日志库(如 syslog、logrotate + rsyslog)代替直接
>>,它们的写入策略经过专门设计。 - 如果多条记录必须在文件里保持连续,考虑加文件锁
flock。
4.4 追加路径中带空格的特殊处理
路径中带空格在 Linux 桌面环境下太常见了。比如:
bash复制echo "test" >> /home/user/My Documents/test.txt
这条命令会执行失败,因为 Shell 会把 /home/user/My 和 Documents/test.txt 当成两个参数。处理办法是给整个路径加引号:
bash复制echo "test" >> "/home/user/My Documents/test.txt"
变量场景也一样,必须加引号,否则遇到带空格的变量值就会拆开:
bash复制TARGET="/home/user/My Documents/test.txt"
echo "test" >> "$TARGET"
不加引号的 $TARGET 会被 Shell 做分词,轻则写到错误位置,重则把路径拆成两条命令导致不可预期行为。这个习惯我从开始就强调,宁可多打一对引号,也别省。
5. 最后再分享两个实用小技巧
写配置、写日志的时候,有些小技巧能让效率提升不少。
5.1 临时文件 + mv 的原子替换,比持续追加更安全
前文一直在讲追加,但追加有一个弱点:文件是持续增长的,写入过程中断电、进程崩溃,文件末尾可能留下半行数据。某些对数据完整性要求高的配置场景(比如用户配置文件、应用状态文件),更推荐的做法是:写到临时文件,校验通过后整体替换。
bash复制cat > /tmp/app.conf.new << 'EOF'
key1=value1
key2=value2
EOF
# 校验内容
if grep -q "key2=value2" /tmp/app.conf.new; then
mv /tmp/app.conf.new /etc/app.conf
fi
mv 在同一文件系统内是原子操作,不会出现“文件写到一半被读到”的情况。这在写服务配置、K8s ConfigMap 生成脚本、用户首选项文件时尤其有用。追加适合日志、历史记录这类“只增不改”的数据,而“必须完整、不能半截”的数据用临时文件 + mv 更可靠。
5.2 追加内容到文件时,注意 Bash 的路径扩展
如果你往文件里追加的内容中包含 *、? 或者 [abc] 这类字符,而没有加引号,Bash 可能会做路径扩展(glob),导致写入的内容出乎意料:
bash复制# 当前目录有 file1.txt file2.txt 时
echo "*" >> list.txt # 写入的是 "*"(在双引号里,安全)
echo * >> list.txt # 写入的是 file1.txt file2.txt,不是星号
所以,希望原样写入特殊字符时,务必用单引号或双引号包起来。单引号里所有字符都字面化,双引号里 $、反引号还是会被解析,写死了就直接单引号。
这些技巧看着零碎,但都是我在真实项目里踩过的坑。追加内容到文件是最基础的 Shell 操作之一,可一旦牵扯到权限、并发、特殊字符、文件锁,里面能挖的细节远比想象中多。把这篇文章里的场景挨个在实验环境里过一遍,我想你以后写脚本会少走很多弯路。
