news 2026/10/19 12:00:42

Rsync同步Shell脚本详解:从参数到排障的生产实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Rsync同步Shell脚本详解:从参数到排障的生产实践

做数据同步这事,说简单也简单,说麻烦也麻烦。简单的时候,A 目录拷到 B 目录,一条 cp 命令就完事;麻烦的时候,几十 GB 数据、上百万个小文件、源目录还在被业务频繁读写,这时候靠全量拷贝基本是拿命在扛。rsync 这个工具我用了很多年,它靠增量算法解决了“每次只传变化部分”的问题,把同步从体力活变成了技术活。

不过 rsync 也不是拿来就能顺手用好的。它的参数非常多,同名参数还分短写和长写,组合起来之后一个命令能拉老长。真到生产环境,我一个人手动敲还好,换同事操作,换一台机器,很容易因为漏参数、漏尾部斜杠、漏排除规则,给出一个看似成功实则跑偏的结果。所以我在项目里越来越倾向于把 rsync 参数固化成 shell 脚本,源目录、目标目录、排除规则、日志、锁都提前写清楚,剩下的事情交给脚本和定时任务去执行。

这篇文章就把我一直在用的 rsync 同步 shell 脚本拆开讲。除了脚本本身的代码,我会把每个参数为什么这么选、锁和日志怎么设计、挂到定时任务之后要注意什么,以及这几年踩过的真实坑都整理出来。不管是运维、后端还是刚接触服务器数据管理的新人,只要把脚本逻辑理解透,改改路径和参数就能直接拿去用。

1. 先别急着复制脚本:同步脚本的设计思路

1.1 rsync 到底适合解决什么问题

rsync 全名是 remote sync,核心能力就四个字:增量同步。第一次同步的时候,它会把源目录的数据完整传到目标目录;之后再次运行,它会根据文件大小、修改时间、校验信息判断哪些文件变了,只传输变化部分。这个机制和全量拷贝相比,省下来的不仅是带宽,还有磁盘写入和等待时间。

很多人只把它当备份工具,其实它的适用面远比想象中宽。常见场景包括:服务器配置目录的定时备份、静态站点发布到多台机器、日志文件归档、代码构建产物同步、数据库冷备份之后把备份文件拉到异地。这些场景有一个共同特点:数据总量大,但每次变化量小,用增量同步能把成本压得很低。

还有一点经常被忽略:rsync 的同步结果是可预期的。只要两端环境正常,跑出来的文件结构、权限、时间戳都能和源端保持一致,不会像某些同步工具那样“传完了但细节丢了”。这一点在工作流里非常重要,因为自动化脚本最怕的就是结果不确定性,越是可预期的工具,越适合放进无人值守的流程里。

1.2 为什么非要封装成 shell 脚本,而不是直接敲命令

rsync 单条命令本身不复杂,但真实使用场景一介入,事情就变味了。看一条我在生产环境用过的完整命令:

rsync -avz --delete -e "ssh -p 2222" \ --exclude "*.log" --exclude ".git/" \ /data/project/ user@host:/opt/data/project/

这条命令已经算规整了。可它很容易出问题:*.log的匹配范围到底是哪一级目录?.git/到底有没有被正确排除?目标目录的父目录如果不存在,rsync 能不能自动建?远程端口写错了,会不会要等半天超时?这些细节靠人脑去记,早晚会出漏子。

所以我把 rsync 使用经验封装成脚本,至少解决三件事。

第一,参数固化。所有关键选项都写在脚本里,每次执行的都是同一套规则,不会出现今天带了--delete明天忘了带的情况。第二,日志统一。哪一次同步成功、哪一次失败、退出码是多少,全都有记录,事后排查不需要翻 Shell 历史。第三,执行保护。脚本里加上锁机制,防止两次同步任务叠在一起跑,避免资源争抢和数据错乱。

还有一点容易被忽视:脚本本身就是同步策略的“知识沉淀”。今天加一个排除规则,明天调一个带宽限制,后天补一个错误通知,过两个月回头看,注释和配置里能清楚还原当初为什么这么做。裸敲命令永远留不下这种积累。

2. 脚本核心细节拆解:参数、锁和日志背后的理由

2.1 基础参数:-a、-v、-z 到底该不该加

先看最基础的-a参数。它是 archive 模式的简写,展开之后相当于-rlptgoD,分别代表递归、保留软链接、保留权限、保留修改时间、保留属主、保留属组、保留设备文件。对绝大多数备份和同步场景来说,-a是最稳的底座,不加它,同步过去的文件很可能权限错乱、时间戳丢失。

但-a也有一个容易被忽略的副作用:它包含-o和-g,也就是会尝试保留属主和属组。如果你是用普通用户执行同步,而源目录里有些文件属于另一个用户,rsync 会尝试执行 chown 然后失败,这会在日志里留下大量警告。生产环境里的常规做法是:要么用 root 跑同步,要么根据实际情况去掉-o,或者用--chown把属主显式转换一遍。

-v是 verbose,输出过程信息。在脚本里我一般都会加,因为日志里需要看到具体传了哪些文件。如果你担心日志太吵,可以用-i替代,它只输出变更项的简要标记,适合看“到底哪些文件变了”。-z是压缩传输,这个参数要看场景:本机同步或内网同步,加了反而白白消耗 CPU,因为本地磁盘拷贝根本不存在带宽瓶颈;跨公网同步时,-z能明显减少传输量,但也会提高 CPU 使用率。建议先不加,等确认带宽确实紧张再加。

2.2 --delete 和排除规则:这是全脚本最需要敬畏的地方

很多 rsync 教程会把--delete当成默认参数写进命令,我不太赞同。--delete的含义是让目标目录和源目录完全对齐,目标端多出来的文件会被删掉。如果源目录写错、尾部斜杠漏掉、或者源路径指向了一个空目录,目标目录可能瞬间被清理掉大量数据。

我用--delete之前会强制自己回答一个问题:目标目录里那些多出来的文件,是真的要删吗?如果是纯备份,目标端多几个历史文件不致命;如果是镜像同步、发布上线,--delete才能保证目标端和源端完全一致。这个判断必须由人来做,不能随手加上。

排除规则同样有讲究。生产环境常用的是排除日志和临时文件:

--exclude="*.tmp" --exclude="*.log" --exclude=".git/" --exclude="cache/"

注意 rsync 的 exclude 规则是按第一个匹配结果生效的。如果你同时用了--include和--exclude,顺序会影响最终结果。比如你想排除所有.log,但保留important.log,就必须先写--include="important.log"再写--exclude="*.log"。这个顺序问题很隐蔽,我第一次用的时候就被坑过一次。

2.3 健壮性设计:返回值、锁、日志一个都不能少

给同步脚本做健壮性设计,核心在于三个词:返回值、锁、日志。

rsync 执行完毕之后会返回退出码,0 表示成功,23 表示部分文件传输失败,24 表示源文件在同步过程中消失了,255 通常代表 SSH 连接失败。脚本里不能只判断“非 0 就是失败”,因为 24 这种退出码在业务频繁读写的目录里很常见,属于可以容忍的噪声。我一般会把退出码完整记录到日志里,再决定是立刻告警,还是等下一次同步自动补上。

锁机制也非常关键。如果脚本被定时任务触发,而上一轮同步还没结束,下一轮任务又启动了,两个 rsync 进程可能会同时读写同一批文件,轻则重复传数据,重则产生文件锁冲突。最常见的锁方案是用flock,它比用 mkdir 那种“锁目录”的方式更可靠,因为进程退出后锁会自动释放,不会出现“锁残留”。

日志设计上,我坚持一条原则:成功和失败都要写。只记录失败不记录成功,时间长了你会分不清“没跑”和“跑成功了但没记录”;只记录成功不记录失败,那日志就变成了自欺欺人。日志里至少要包含时间、源目录、目标目录、退出码这四个要素。

3. 从零到一:搭一个生产可用的 rsync 同步 shell 脚本

3.1 本地目录同步:一个可以直接改路径用的完整脚本

先给一个最基础的版本,适合把本机某个目录增量同步到另一个目录。完整脚本如下:

#!/usr/bin/env bash # file: rsync_backup.sh # desc: 本地增量同步脚本,适合目录备份和镜像 set -Eeuo pipefail readonly SRC="${1:-/data/workspace/incoming/}" readonly DST="${2:-/data/backup/incoming/}" readonly LOG_FILE="/var/log/rsync_backup.log" readonly LOCK_FILE="/tmp/rsync_backup.lock" EXCLUDE_ARGS=( "--exclude=*.tmp" "--exclude=*.log" "--exclude=.cache/" ) log_info() { echo "$(date '+%F %T') [INFO] $*" >> "${LOG_FILE}" } log_error() { echo "$(date '+%F %T') [ERROR] $*" >> "${LOG_FILE}" } # 并发保护:拿不到锁就直接退出,避免脚本叠加执行 exec 9>"${LOCK_FILE}" if ! flock -n 9; then log_error "上一次同步还没结束,跳过本次执行。" exit 0 fi log_info "开始同步:${SRC} -> ${DST}" if rsync -a "${EXCLUDE_ARGS[@]}" "${SRC}" "${DST}" >>"${LOG_FILE}" 2>&1; then log_info "同步成功:${SRC} -> ${DST}" else rc=$? log_error "同步失败,退出码 ${rc}:${SRC} -> ${DST}" exit "${rc}" fi

这段脚本有几个点值得展开说。

set -Eeuo pipefail里面,-e是出错即退出,-u是变量未定义就报错,-o pipefail是让管道命令的失败能被正确捕获。这三个组合起来,可以让脚本在异常初发阶段就停下来,而不是带着错误继续跑。不过在 rsync 这种“返回值需要单独处理”的场景里,-e不能直接套在没有 if 包裹的命令上,所以我用if rsync ...; then ... else ...的结构来捕获退出码,这样既不会因为非零退出码直接中断,又能拿到具体的返回状态。

exec 9>"${LOCK_FILE}"是把文件描述符 9 和锁文件绑定,然后flock -n 9尝试获取独占锁。-n表示非阻塞,拿不到锁直接返回失败。这个方案比mkdir锁更省心,因为进程结束或者被 kill 时,文件描述符会自动关闭,锁也随之释放。

readonly SRC="${1:-...}"表示脚本可以接受命令行参数,第一个参数是源目录,第二个是目标目录。不传参时使用默认值。这样设计是为了让脚本既能手动执行,也能被其他工具调用。不过要提醒一句:如果同一套脚本要服务多个不同任务,锁文件最好带上任务标识,否则不同任务之间会互相锁住。

3.2 升级到远程同步:SSH 密钥和连接超时是重点

本地同步毕竟是少数场景,更多时候我们是把数据从一台机器推到另一台。远程同步最常用的方式是走 SSH,而不是 rsync daemon。原因很简单:SSH 不用单独起服务、不用配置模块、天然支持密钥认证,安全性也更高。只有在“多台客户端同时往一台服务器集中备份”的场景下,我才会考虑 rsync daemon。

远程同步的第一步是配好免密登录。我建议用专门的密钥而不是服务器主密钥:

ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_rsa_backup ssh-copy-id -i ~/.ssh/id_rsa_backup.pub backup_user@remote_host

生成密钥时用-N ""表示空密码,否则定时任务执行时会卡在输密码环节。配合~/.ssh/config配置别名,可以极大缩短 rsync 的参数长度:

Host backup-server HostName 10.0.0.10 Port 22 User backup_user IdentityFile ~/.ssh/id_rsa_backup

配置好之后,远程同步脚本可以写成这样:

#!/usr/bin/env bash # file: rsync_remote.sh set -Eeuo pipefail readonly REMOTE="backup-server" readonly LOCAL_PATH="/data/app/upload/" readonly REMOTE_PATH="/backup/app/upload/" readonly LOG_FILE="/var/log/rsync_remote.log" # 确保远端目标目录存在 ssh -o BatchMode=yes "${REMOTE}" "mkdir -p ${REMOTE_PATH}" if rsync -a --partial \ -e "ssh -o BatchMode=yes -o ConnectTimeout=10" \ "${LOCAL_PATH}" "${REMOTE}:${REMOTE_PATH}" >>"${LOG_FILE}" 2>&1; then echo "$(date '+%F %T') [INFO] 远程同步成功" >> "${LOG_FILE}" else rc=$? echo "$(date '+%F %T') [ERROR] 远程同步失败,退出码 ${rc}" >> "${LOG_FILE}" exit "${rc}" fi

这里有两个关键细节。-o BatchMode=yes会让 SSH 在无法免密登录时直接失败,而不是卡住等待输入密码,这对定时任务极其重要。-o ConnectTimeout=10限制了连接超时时间,避免目标机器网络不通时脚本挂起十分钟以上。--partial允许大文件传输中途失败后保留已传部分,下次同步可以继续,不用从头传。

--partial这个参数我最开始没加,后来同步一个 40 多 GB 的数据库文件,网络抖动断了两次,每次都要重传,气得我直接把参数写死在脚本模板里。现在不管是大文件场景还是普通文件场景,我都会带上它,理由是:断点续传的成本太低了,不值得为了省几个字节的临时文件去冒风险。

3.3 挂定时任务:cron 和 PATH 的坑一起说

脚本写完,下一步就是让它自动跑。最常用的方式就是 crontab:

# 每天凌晨 2 点执行 0 2 * * * /usr/local/sbin/rsync_remote.sh >> /dev/null 2>&1

这里要特别提醒一个很隐蔽的坑:cron 执行时的环境变量和你手动登录 Shell 是不一样的。它默认的PATH非常精简,很多系统路径都不在里面。如果你的脚本内部调用了ssh、rsync、flock这些命令,而它们没有被安装在/usr/bin这种默认路径下,cron 执行时可能会报command not found。

解决办法有两种:一是在脚本开头显式定义 PATH:

export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

二是在 crontab 里配置 PATH:

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 0 2 * * * /usr/local/sbin/rsync_remote.sh >> /dev/null 2>&1

我一般两个都做。脚本内部设置 PATH,是为了让脚本在手动执行和定时执行时行为一致;crontab 里配置 PATH,是为了让其他环境变量也能正确生效。

另外,定时任务的输出重定向不要直接丢给/dev/null,至少在调试阶段这样做会把所有错误信息都吞掉。我通常先让脚本把原始输出追加到一个调试日志文件,等确认稳定运行一周之后再改成静默执行,只保留脚本内部的日志。

4. 高发事故与排障实录:把坑提前填上

4.1 rsync 退出码 23 和 24:别把正常的抖动当成故障

先看一张我最常用的退出码速查表:

退出码含义常见原因处理建议
0同步成功无无需处理
23部分文件传输失败权限不足、磁盘满、部分文件读取失败查看日志,定位具体失败文件
24源文件在同步过程中消失日志切割、临时文件被清理、业务正在移动文件可接受,二次同步会补上
255SSH 连接失败网络不通、密钥错误、远端无法执行 rsync检查连接和认证配置

我最开始写脚本时,把“非 0 即失败”当作原则,结果每天都在收告警。后来发现,那些告警里有一半都是退出码 24,场景基本是业务在同步过程中滚动日志,rsync 还没读到某个文件,文件就被 logrotate 改名或者删掉了。对这种情况,正确做法是把它当作“需要关注但不至于告警”的信号,在脚本里单独处理:

if [[ "${rc}" -eq 24 ]]; then log_error "退出码 24:源目录有文件在同步过程中被移动/删除。" log_error "首次出现可继续观察,频繁出现需检查源目录是否持续有大量文件变动。" exit 0 fi

如果业务侧非常在意源目录的稳定性,你还可以加上--ignore-missing-args,让 rsync 在源文件消失时不把这个情况当作致命错误。但我不建议无脑加,因为它会把一些真正需要关注的写入问题也一并忽略掉。

4.2 --delete 的“手滑现场”和防御方法

这是我最想强调的坑,因为一旦出事,往往是事故级别。

先解释一个最容易弄混的知识点:rsync 目标路径尾部有没有斜杠,决定了目录层级。以/data/project/为例:

# 把 /data/project/ 目录里的内容同步到 /backup/project/ 目录里 rsync -a --delete /data/project/ /backup/project/ # 这个写法很可能把 /data/project 作为一层目录,放进了 /backup/project 下面 rsync -a --delete /data/project /backup/project/

如果第二种写法配合--delete,目标目录里原有的内容很容易被清空,最后得到一个结构错乱的目录。更危险的情况是手滑把源路径写成了其他目录,再配合--delete,结果可想而知。

我用两个手段来防御这类事故。

第一,在脚本里给--delete加开关。默认不启用删除,只有显式设置环境变量时才加:

DELETE_FLAG=() if [[ "${ENABLE_DELETE:-0}" == "1" ]]; then DELETE_FLAG+=(--delete) fi rsync -a "${DELETE_FLAG[@]}" "${SRC}" "${DST}"

这样即使源路径写错了,最坏的结果是同步出多余文件,而不是把目标端清空。

第二,上线之前先跑 dry-run。-n是空跑,不实际传输,配合-i可以看到会产生哪些变更:

rsync -an --delete -i /data/project/ /backup/project/

如果输出里出现一堆*deleting,你就知道--delete到底要删什么,心里有数再决定是否真正执行。这一步花不了几秒钟,但能避免绝大多数“低级手误”。

4.3 属主、权限、ACL:同步结果和你看得见的不一致

用-a同步之后,最常遇到的疑问是:文件内容明明一模一样,但目标端权限和源端不一样,或者属主显示成执行同步的用户。原因在于-a里的-o和-g只在当前用户有权限做 chown 时才生效。普通用户同步别人拥有的文件时,chown 会被拒绝,rsync 只能退而求其次,保留它自己能设置的权限。

解决方式要看场景。如果源和目标都是你控制范围内的机器,最省心的方式就是用 root 执行同步。如果不想给同步脚本太高的权限,也可以去掉-o,让文件属主统一归到执行用户,在脚本注释里写清楚这个决定。

还有一个很容易漏的地方:ACL 权限。普通 Linux 目录如果设置了 setfacl,-a并不会保留这些扩展属性,需要额外加-X参数。rsync 3.x 版本里,-X会尝试保留 extended attributes,也就是 xattr 和 ACL。如果你的同步目标是给另一个团队使用的文件目录,这个参数建议加上。

4.4 大量小文件同步慢:先想清楚瓶颈在哪

几十万个几 KB 的小文件,是 rsync 最不擅长处理的场景。因为每个文件都需要建立索引、比较元数据、创建临时文件、重命名,这些操作单看都不慢,但乘以几十万之后就很可观了。

针对这种场景,我的经验是分情况处理。

如果源目录里只有一小部分文件经常变化,可以先用find生成变更清单,再用--files-from让 rsync 只同步这些文件。这种方式适合“文件多但变化集中”的场景,但缺点是不好处理删除操作。如果目标是让两端完全一致,还是得老老实实全量比较。

如果文件虽然多,但单文件体积大,那么--inplace值得考虑。默认情况下,rsync 是先写临时文件再重命名,避免目标端出现半个写坏的文件;但对超大文件来说,临时文件占用的磁盘空间和重命名的耗时都是负担。--inplace会直接改目标文件,省去临时文件,代价是传输过程中如果中断,目标文件可能不完整。我只在对磁盘空间比较敏感、且目标端有完整备份兜底时用它。

带宽占用也是大文件场景里必须要管的。同步任务最好避开业务高峰期,或者在脚本里加带宽限制:

--bwlimit=10240

--bwlimit的单位是 KiB/s,10240就代表最大约 10MB/s。这个参数在跨公网同步时尤其重要,否则一个晚上跑完,业务侧网络可能被拖出明显卡顿。

5. 后期收敛:让脚本从一个工具变成一套体系

5.1 用配置文件把脚本和具体任务解耦

脚本用久了,最怕的是“每个环境一个版本”。有的机器上改了端口,有的机器上改了排除规则,最终散落一地,谁也说不清哪份是最新。

我有一个习惯:把脚本里容易变的参数抽到外部配置文件里。脚本只负责逻辑,配置负责描述“这一次同步要做什么”。比如维护一个/etc/rsync/sync_common.conf:

# sync_common.conf SOURCE_DIR="/data/app/" DEST_DIR="backup-server:/backup/app/" EXCLUDE_ARGS=( "--exclude=*.tmp" "--exclude=*.log" "--exclude=cache/" ) EXTRA_ARGS=( "--partial" "-e" "ssh -o BatchMode=yes -o ConnectTimeout=10" )

脚本里用source引入配置:

source /etc/rsync/sync_common.conf rsync -a "${EXCLUDE_ARGS[@]}" "${EXTRA_ARGS[@]}" \ "${SOURCE_DIR}" "${DEST_DIR}"

这样每新增一个同步任务,只需要复制一份配置并修改路径,不用改动脚本主体。长期维护下来,脚本保持稳定,差异都集中在配置里,排查问题也快得多。

5.2 多一步验证,比加十行告警更有效

脚本跑得再顺,也不能保证同步结果一定对。备份圈里有一句话我很认同:没有做过恢复演练的备份,只能算“存了一份数据”,不能叫备份。

所以我每次写完同步脚本,都会先做一次恢复验证。本机同步的话,直接对比文件差异:

diff -rq "${SRC}" "${DST}" | head -50

远端同步的话,先在远端空目录里恢复一次关键目录,试着重启服务或者加载配置,确认数据不只是“传过去了”,而是真的能用。这个验证不用每次同步都做,但重大变更之后必须来一次。

5.3 告警通知别让日志睡大觉

日志写得再全,如果没人看也是白写。脚本进入无人值守模式后,我会给失败场景加一个告警通道,用最简单的 Webhook 通知:

if [[ "${rc}" -ne 0 ]]; then curl -fsS -m 10 -X POST \ -H "Content-Type: application/json" \ -d "{\"subject\":\"rsync backup failed\",\"message\":\"exit code ${rc}\"}" \ "${WEBHOOK_URL}" || true fi

关键是尾部加|| true。告警通知只是附属能力,不能因为告警接口暂时不可用,反而让同步脚本本身失败。这是我在实际运营里踩过的小坑:有一阵子 Webhook 地址配错了,结果同步成功之后脚本反而报错退出,非常尴尬。

最后再给一个容易踩的细节:如果你所在环境的flock不可用,退回用mkdir做锁也不是不行,但一定要在trap里把锁目录删掉,否则进程异常退出后,锁目录会一直留在那,后续所有同步任务都会被挡住。锁文件本身用起来倒是不用清空,只要文件描述符正常关闭,下次执行就能继续拿锁。这个细节看着小,真遇到的时候最容易让人抓狂。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 11:58:31

做服务机器人三年:可靠与温度,比追求智能更重要

开头:三年下来,我反而没那么迷恋“智能”了 做服务机器人这个领域,满打满算已经三年。从最初看到一台设备能自主导航、能听懂几句人话就兴奋得不行,到如今每天面对无数个“为什么用户不买账”的瞬间,我对“智能”这两个…

作者头像 李华
网站建设 2026/10/19 11:55:52

STM32单片机 定时器TIM

分三个部分学习 第一部分,学习定时器定时基本功能,也就是定一个时间,然后让定时器每隔一个时间产生一个中断,来实现每隔一个固定时间执行一段程序的目的。比如要做个时钟、秒表、或者一些程序算法的时候,都需要定时中断的这个功能。 第二部分,学习定时器输出比较功能。…

作者头像 李华
网站建设 2026/10/19 11:47:09

REA:一条命令把整条逆向工程链路交给 AI Agent

REA:一条命令把整条逆向工程链路交给 AI Agent 【免费下载链接】rea Reverse engineer anything with agents, from app behavior down to native binaries. 项目地址: https://gitcode.com/GitHub_Trending/rea2/rea REA 是一条逆向工程链路的统一 MCP 服务…

作者头像 李华
网站建设 2026/10/19 11:44:40

固态变压器上电流程详解:从冷态到带电的五道关

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/19 11:38:48

毫米波雷达与视频跌倒检测:隐私、抗干扰与选型实战对比

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华