删除文本指定行:sed/awk/PowerShell/Python与编码避坑 手边一个两万行的 txt只想把第 3001 到 3050 行抽掉或者一个目录里躺着几百个文本文档每篇开头都挂着几行来源不明的说明需要统一清掉。这种需求在旁人看来就是删几行真上手才知道坑不少删完中文全变成问号行号数错一位把正文砍了或者换了个工具保存一遍文件体积直接翻倍。我在处理日志、小说素材 txt、系统导出的报表、仿真工具生成的覆盖率报告这些文本文档时几乎每一种删除指定行的方案都完整跑过一遍。这篇就把这件事从场景到命令到脚本拆开讲清楚什么情况下手工点几下就够什么情况下必须上命令行什么情况下只能写脚本流式处理行号基准、编码、换行符这三样东西为什么会联手坑人以及一份可以直接抄走的参数对照表和排查清单。不管你是完全没碰过命令行还是平时写脚本但总在编码上翻车都能从里面挑到能直接用的部分。1. 先给删行需求分个级别拿大炮打蚊子1.1 我实际遇到过的六类删行场景第一类是日志清理。程序跑完留下一份日志里面百分之八十是调试输出只想保留包含 ERROR 或 WARN 的行其余的整行干掉。这类需求的特征是按内容删不关心行号。第二类是小说和素材文本的整理。网上拿到一份 txt每章开头结尾都插着几行导流信息或者每隔几十行就冒出一句重复的推广语需要按关键词整行清掉顺便把连续三个以上的空行压成一个。这类文本动辄几十万字用编辑器打开还行但一旦要处理几百个文件就必须靠脚本。第三类是表格型导出的数据文件。系统导出的 txt 前面有五六行说明性文字最后一行是共导出 XXX 条记录中间才是正文。删掉头尾用行号定位最快但要小心导出行数和实际行数不一定一致。第四类是仿真和测试工具产生的报告。比如覆盖率报告一份完整的报告头部有个几十行的固定格式块把多份报告合并之后这些重复的头部会反复出现合并前或合并后都得清理掉。第五类是配置文件、公式文件导出成文本之后的清理。这类文件里注释行往往以特定符号开头删注释就是按行首模式匹配。第六类是把手写或旧格式的文本规整成干净数据需要删掉某一段连续区间或者只保留中间一段。这种区间删除是最容易数错行号的一类。1.2 用文件体量当尺子选方案心里就有底选工具的核心判断依据是文件体量和操作次数其次是操作系统和你对命令行的熟悉度。我自己的经验分档大致是这样文件体量操作次数推荐方案主要原因500 行以内一次性记事本 / 文本编辑器手工选中删除学习成本为零肉眼可核对500 行到 5 万行偶尔几次sed、awk、grep、PowerShell一条命令搞定不吃内存5 万行以上反复或按条件awk 或 Python 流式脚本逐行读逐行写内存占用恒定任意体量几百个文件批量脚本循环 临时文件替换手工不可能完成必须自动化结构规整的表格文本少量电子表格筛选删除谨慎直观但会改动编码和格式这里的判断逻辑很简单文本编辑器会把整个文件加载进内存并建立索引几十兆以内没问题几百兆就会卡到怀疑人生。而 sed、awk 这类流式工具是读一行、判断一行、写一行内存占用基本和文件大小无关所以在处理大文件时它们是唯一靠谱的选择。Python 如果用对了写法同样是流式的但用错了写法比如readlines()一次性读入就会退化成内存杀手。提示不要用文件体积来判断行数。一行可能长达几千字符一个 50 MB 的文件也可能只有几百行。想知道行数先跑wc -lLinux/macOS或者(Get-Content f.txt).CountPowerShell。2. 动手之前必须搞清楚的四个前置概念2.1 行号到底从几开始1 基和 0 基的错位陷阱这是删错行的头号原因没有之一。sed、awk、grep 以及绝大多数命令行工具行号都是从 1 开始数的也就是第 1 行就是文件的第一行。但 Python 的列表索引是从 0 开始的lines[0]才是第一行。而 PowerShell 里Get-Content返回的数组同样从索引 0 开始。于是同一句删掉第 3 行在不同环境下的写法完全不同环境删第 3 行的写法备注sedsed 3d file行号 1 基awkawk NR!3 fileNR 从 1 开始PowerShell$l Get-Content f; $l[0..1] $l[3..]索引 0 基Pythonlines.pop(2)索引 0 基记事本CtrlG 输入 3界面显示行号是 1 基我踩过最惨的一次是在 sed 里验证完删第 3 行没问题转头写成 Python 脚本忘了改索引结果把真正的第 3 行留下来了反而删掉了第 4 行而这两行恰好长得极像跑完几十个文件之后才发现。从那以后我的习惯是脚本里凡是涉及行号的地方都写成lineno - 1这种显式的换算绝不直接写数字。2.2 行尾符一个看不见的字符决定你的匹配能不能命中Windows 上的文本文件行尾是\r\nCRLFLinux 和 macOS 上是\nLF。这个差别平时看不见但在删行时会造成两种典型故障。第一种你在 Linux 上写sed /DEBUG$/d想删掉以 DEBUG 结尾的行结果一条都没删掉。原因是从 Windows 传过来的文件每行末尾是DEBUG\r$锚点前面还站着一个\r模式自然匹配不上。解决办法是把\r显式写进模式里或者先用dos2unix统一换行符。第二种Python 默认以文本模式打开文件会把\r\n翻译成\n写回去的时候又按当前系统的约定翻译回来。在 Windows 上跑 Python 脚本处理 Linux 传来的文件写回去就全变成 CRLF 了文件体积凭空变大后续用别的工具处理又出问题。要避免这种隐式翻译打开文件时加newline让它原样读写。注意如果只是行数对不上、内容看着没问题优先怀疑最后一行有没有换行符。有些工具统计行数时末尾没有换行符的那一行不计入wc -l和编辑器显示的行数就可能差 1。2.3 编码删行之前先确认文件是什么编码编码问题在删行场景里杀伤力最大因为它可能让你删对了行但把整个文件毁了。UTF-8 文件如果带 BOM字节顺序标记那么文件开头会多出三个字节EF BB BF。Python 用utf-8打开时第一行的开头会凭空多一个\ufeff字符。你想删掉第一行写了个模式匹配结果第一行死活匹配不上就是这个看不见的字符在捣乱。正确做法是读取时用utf-8-sig它会自动吃掉 BOM。另一头是写回时的编码。PowerShell 5.1 的Set-Content -Encoding utf8写出的是带 BOM 的 UTF-8而 PowerShell 7 的同一参数写出的是不带 BOM 的。如果你在旧版 PowerShell 上处理中文文件写完拿去给别的系统读开头就可能冒出乱码。要精确控制用 .NET 的[System.IO.File]::WriteAllLines($path, $lines, (New-Object System.Text.UTF8Encoding($false)))最后一个$false就是不写 BOM。GBK 文件的情况更麻烦。用 UTF-8 去读 GBK 文本通常会直接抛UnicodeDecodeError这时候别慌先试试gbk如果不行再试gb18030它是 GBK 的超集容错更好。最稳妥的做法是读的时候依次尝试utf-8-sig、utf-8、gb18030哪个成功用哪个并且把最终用的编码打印出来方便你确认。2.4 原地修改还是输出新文件这个决定关系到你的后悔权几乎所有的删行工具都支持两种模式原地修改直接覆盖原文件和输出到新文件。我的建议很直接——第一次处理某类文件时永远输出到新文件用diff或者fc对比差异确认删掉的正是你想删的那些行再把新文件覆盖回去。理由有二。一是行号定位本身容易出错输出到新文件相当于给自己留了后悔药。二是原地修改的实现方式有差异sed 的-i在 GNU 版本下是写临时文件再重命名这会改变文件的 inode 编号如果这个文件被硬链接引用或者在别的进程里被打开着行为可能和你预期的不一样。如果确实要用原地修改至少带上备份后缀sed -i.bak 3d file.txt它会保留一份file.txt.bak。这是成本最低的保险。3. 小文件的手工方案记事本和编辑器怎么删得又快又准3.1 记事本里删连续几行的正确手势很多人用记事本删行是靠鼠标拖选选多了选少了全凭眼力。其实键盘操作精确得多把光标放到要删的第一行行首按Home确保在行首位置然后按住Shift再按↓若干次每按一次选中一行选中的范围可以精确到行。选中之后CtrlX剪掉剩下的内容会自动接上不会留下空行。如果文件很大、需要跳到很远的位置用CtrlG打开转到行对话框输入目标行号回车光标直接落在那一行。这个功能在新版记事本里可用如果版本太老没有就换 Notepad 之类的编辑器。连续删除多段时先删靠后的段落会省事删前面的行会让后面所有行的行号都往前挪如果先记下一串行号再顺序删第二次就全错位了。这个从后往前删的原则在任何手工删行场景里都适用。3.2 查找替换删含关键词的行关键是处理遗留空行用记事本删除包含某个关键词的整行思路是把关键词加上它所在那一行的换行符一起替换成空。但记事本的查找框无法直接输入换行符只能复制一行真实文本粘贴进去操作起来很别扭。换 Notepad 就顺手多了。CtrlH打开替换对话框把查找模式从普通切到扩展或者正则表达式扩展模式查找关键词\r\n替换为空。注意这里的\r\n是字面转义扩展模式会把它们解释成回车换行。正则模式查找^.*关键词.*\r?\n替换为空。这个写法兼容 LF 和 CRLF 两种行尾更省心。如果你的目标只是删空行正则模式下查找^\s*\r?\n替换为空一次性清干净所有空白行包括只有空格和制表符的伪空行。这一步在整理小说 txt 的时候几乎必做因为很多转换工具会把段落标签变成一个空行转换完之后满屏的空白。3.3 电子表格删行能用但要知道它会破坏什么把 txt 用 Excel 打开按分隔符拆列然后用筛选把不要的行筛掉、整行删除再另存为文本制表符分隔。这条路对结构规整的表格型文本确实高效尤其是删掉某一列为空的那些行这种条件。但它的代价必须提前知道纯数字的长串会变成科学计数法比如订单号1234567890123456显示成1.23457E15。以 0 开头的编号前导零会被吃掉。保存出来的编码通常是 ANSI简体中文环境下即 GBK原来 UTF-8 的文件可能从此在别的系统里乱码。日期、身份证号之类的字段可能被自动识别并改写格式。所以我的做法是电子表格只用来看和定位不用来存。定位完行号之后回到命令行或脚本按行号删这才是安全的路径。4. Linux 和 macOS 上的三剑客sed、awk、grep4.1 sed 按行号删除六种常用写法一次讲透sed 是删除指定行最直接的工具语法结构就是地址 d 命令d 表示删除。下面这张表是我平时贴在便签上的目标命令说明删第 3 行sed 3d in.txt out.txt单行删除删第 3 到第 5 行sed 3,5d in.txt out.txt闭区间含首尾删第 3 行到末尾sed 3,$d in.txt out.txt$表示最后一行删第 1、4、7……行sed 1~3d in.txt out.txtGNU 扩展步长写法删第 3 行和第 7 行sed -e 3d -e 7d in.txt out.txt多个表达式串联只保留第 2 到第 5 行sed 2,5!d in.txt out.txt加!取反这里要解释一下删掉第 3 行之后第 4 行会不会变成新的第 3 行这个疑问。sed 是逐行读入、逐行判断的判断用的是原始行号所以sed 3d只会删掉原来的第 3 行第 4 行保持在第 4 位。这一点和手工在编辑器里顺序删除完全不同理解了这个机制就不会出现删一段行号区间结果只删了一半的事故。4.2 macOS 上的 sed -i 有个必须记住的语法差异Linux 上写sed -i 3d file.txt没问题但同样的命令拿到 macOS 上会报错因为 BSD 版的 sed 要求-i后面必须跟一个备份后缀参数哪怕你不想备份也要给个空串# macOS 正确写法不备份 sed -i 3d file.txt # macOS 带备份后缀 sed -i .bak 3d file.txt # Linux 写法 sed -i 3d file.txt sed -i.bak 3d file.txt我在公司用 Linux、在家用 Mac这个差异来回坑过好几次后来干脆在脚本里做判断if [[ $(uname) Darwin ]]; then SED_INPLACE(sed -i ) else SED_INPLACE(sed -i) fi ${SED_INPLACE[]} 3d file.txt4.3 awk条件写成表达式比 sed 灵活一个档次awk 的思路和 sed 不一样它是把要保留的行打印出来所以要删第 3 行就写成行号不等于 3 就打印awk NR!3 in.txt out.txt # 删第 3 行 awk NR10 || NR20 in.txt out.txt # 删第 10 到 20 行保留区间外的 awk NR10 NR20 {next} {print} in.txt out.txt # 显式跳过区间 awk NF in.txt out.txt # 删空行NF 为字段数非 0 就打印 awk !/DEBUG/ in.txt out.txt # 删含 DEBUG 的行 awk !seen[$0] in.txt out.txt # 去重保留首次出现的行NF删空行的写法值得单独说一下它和sed /^$/d的区别在于^$只匹配真正零长度的行而NF会把只有空格或制表符的行也一起删掉。整理从各种工具里导出的文本时后者往往才是你想要的效果。!seen[$0]这个去重写法也很实用。它的原理是用行内容当数组键第一次遇到时seen[$0]是 0未定义值当 0 用取反为真所以打印随后自增第二次遇到同一个键值变成 1取反为假就不打印了。删重复行、删重复的表头一行搞定。4.4 grep -v按内容删行的心智负担最低按内容删行grep 的-v选项是最容易理解的——-v就是反过来只输出不匹配的行grep -v DEBUG in.txt out.txt # 删掉含 DEBUG 的行 grep -v ^$ in.txt out.txt # 删掉空行 grep -v ^# in.txt out.txt # 删掉以 # 开头的注释行 grep -vxf patterns.txt in.txt out.txt # 按模式文件批量删除最后一条是精髓。-x表示整行匹配-f表示从文件里读模式合起来就是文件里列出了哪些完整行就把输入里的这些行删掉。这个写法特别适合处理那种有一份黑名单要从正文里把黑名单行全部剔除的场景比如删掉小说 txt 里那几条固定的推广语。但有个坑必须记住grep默认把模式当正则表达式处理。如果你的关键词里含有.、*、[、]、\这些字符会被当成正则元字符。比如想删掉3.5元这一行.会匹配任意字符可能误删305元。解决办法是加-F强制按固定字符串匹配grep -Fvx -f patterns.txt in.txt out.txt。4.5 批量处理一行循环加上零字节分隔文件名带空格也不怕批量删最常犯的错误是用for f in $(find ...)一旦文件名里有空格循环就会把一个文件拆成好几个参数。正确写法是用零字节分隔find . -name *.txt -print0 | while IFS read -r -d f; do sed -i.bak /^广告/d $f echo 已处理: $f done拆开看每个部分-print0让 find 用空字符分隔结果IFS清空字段分隔符防止 read 把行首行尾的空白吃掉-r禁止反斜杠转义-d 指定用空字符作分隔符。$f外面的双引号也是必须的否则带空格的文件名照样会被拆开。如果要删的是行号而不是内容批量场景下通常不适用因为不同文件的行结构不一样按行号批量删几乎必然误伤。真要按行号批量处理务必先在单个文件上验证再决定要不要推广。4.6 删完必须校验数三个数再收工我的习惯是每次删行之后跑三件事删之前的行数、删之后的行数、两者的差值。差值应该等于你预期删掉的行数如果不等说明模式匹配到了额外的行或者漏了行。echo 删除前: $(wc -l in.txt) 行 sed /DEBUG/d in.txt out.txt echo 删除后: $(wc -l out.txt) 行 diff (grep -c DEBUG in.txt) (echo 0) echo 已无 DEBUG 行更进一步是用diff in.txt out.txt直接看差异输出里那些开头的行就是被删掉的。删的行数少的时候肉眼扫一遍最保险。5. Windows 平台的方案PowerShell 与批处理5.1 PowerShell 按行号删除以及 ReadCount 的隐藏行为PowerShell 最直观的写法是把文件读成数组然后拼数组$lines Get-Content -Path .\a.txt -Encoding UTF8 # 删第 3 行索引 2 $new $lines[0..1] $lines[3..($lines.Count - 1)] $new | Set-Content -Path .\b.txt -Encoding UTF8这里有两个坑。第一如果文件只有一行$lines会被当成单个字符串而不是数组$lines.Count在旧版 PowerShell 里会返回字符串长度。稳妥的写法是加一个强转$lines (Get-Content ...)()保证结果一定是数组。第二Set-Content的编码参数在 5.1 和 7.x 上行为不同。5.1 的-Encoding UTF8写 BOM7.x 不写。要保持一致用 7.x 的-Encoding utf8NoBOM或者两个版本都兼容的 .NET 写法[System.IO.File]::WriteAllLines($PWD\b.txt, $new, (New-Object System.Text.UTF8Encoding($false)))管道里还有个ReadCount属性可以用它表示当前行在管道中的序号Get-Content .\a.txt | Where-Object { $_.ReadCount -ne 3 } | Set-Content .\b.txt -Encoding UTF8注意ReadCount不是$_的属性而是读取器对象的属性在某些版本的 PowerShell 里$_.ReadCount访问不到所以这个写法不如数组切片可靠。我一般只在按内容删的时候用管道Get-Content .\a.txt | Where-Object { $_ -notmatch DEBUG } | Set-Content .\b.txt -Encoding UTF8 Get-Content .\a.txt | Where-Object { $_.Trim() -ne } | Set-Content .\b.txt -Encoding UTF8第二条用Trim()去掉首尾空白再判断是否为空等价于 awk 的NF写法能一起清掉只有空格的行。5.2 千万别把输入和输出指向同一个文件这是 PowerShell 和重定向最容易翻车的地方# 危险可能把文件清空 Get-Content .\a.txt | Where-Object { $_ -notmatch X } | Set-Content .\a.txt管道是流式执行的Set-Content在开始写入时就把a.txt截断了而Get-Content还没读完结果读到一半发现文件是空的最终写进去一个残缺的文件。所以永远先写到临时文件再移动覆盖$tmp $env:TEMP\clean_$(Get-Random).txt Get-Content .\a.txt | Where-Object { $_ -notmatch X } | Set-Content $tmp -Encoding UTF8 Move-Item -Force $tmp .\a.txt同样的陷阱也存在于 shell 重定向里sed /X/d a.txt a.txt会把 a.txt 清空。shell 在执行命令之前就打开了输出文件并截断sed 读到的已经是空文件。这个错误的变体极多sort a.txt a.txt、grep -v X a.txt a.txt全都有同样的问题记住重定向不能指向输入文件这一条就够了。5.3 findstr 与批处理老旧环境下的兜底方案在没有 PowerShell 或者需要在 cmd 里快速处理时findstr可以顶上findstr /v DEBUG in.txt out.txt findstr /v /c:固定 短语 in.txt out.txt/v是反向匹配/c:表示把后面当成字面字符串而不是用空格分隔的多个模式。不加/c:的时候findstr /v A B会被理解成不含 A 或者不含 B逻辑和你想的很可能不一样。批量处理用 for 循环配合~n修饰符for %%f in (*.txt) do ( findstr /v /c:广告 %%f %%~nf.clean.txt )%%~nf取的是不含扩展名的文件名。这套写法在 cmd 里跑没问题但要注意 findstr 的正则能力很有限不支持\d、\s这类写法复杂匹配还是交给 PowerShell 或 Python。6. Python 脚本方案从最朴素的写法到能处理 GB 级文件6.1 最朴素的写法以及它身上的三个隐患with open(a.txt, encodingutf-8) as f: lines f.readlines() lines.pop(2) # 删第 3 行注意索引是 2 with open(a.txt, w, encodingutf-8) as f: f.writelines(lines)十行不到的代码就能删行但它藏着三个问题。一是readlines()把整个文件读进内存一个 500 MB 的文件在内存里可能膨胀到 1 GB 以上直接爆掉。二是没有处理 BOM第一行开头会带\ufeff。三是文本模式下\r\n被翻译成\n写回去的时候如果在 Windows 上运行又会翻译成\r\n行尾符可能被悄悄改写。这个写法只适合小文件、编码明确、且只在当前系统上用一次的场景。要可靠得往下面这个方向改。6.2 流式处理逐行读、逐行判断、逐行写流式处理的核心是不把文件装进内存用临时文件承接结果处理完再原子替换。下面是我现在常用的一个函数支持行号集合删除和区间删除import os import tempfile def delete_lines(src, dstNone, drop_linesNone, drop_rangesNone, encodingutf-8-sig, keep_backupTrue): src : 源文件路径 dst : 目标路径None 表示原地替换 drop_lines : 要删除的行号集合1 基如 {3, 7, 9} drop_ranges : 要删除的区间列表1 基闭区间如 [(10, 20), (35, 40)] encoding : 读取编码默认 utf-8-sig 可自动吃掉 BOM drop_lines set(drop_lines or []) drop_ranges drop_ranges or [] def should_drop(lineno): if lineno in drop_lines: return True for start, end in drop_ranges: if start lineno end: return True return False inplace dst is None target dst or src .tmp_clean removed 0 total 0 with open(src, r, encodingencoding, newline) as fin, \ open(target, w, encodingencoding, newline) as fout: for lineno, line in enumerate(fin, start1): total 1 if should_drop(lineno): removed 1 continue fout.write(line) if inplace: if keep_backup: os.replace(src, src .bak) os.replace(target, src) return {total: total, removed: removed, kept: total - removed, output: src if inplace else target}几个关键点值得展开说。enumerate(fin, start1)让行号从 1 开始和 sed 的语义保持一致避免 0 基 1 基来回换算出错。newline关闭了 Python 的换行符翻译读进来什么样就写成什么样\r\n不会被转成\n。encodingutf-8-sig在读的时候会自动剥离 BOM写的时候要注意——如果目标编码也是utf-8-sig它会重新写入 BOM。如果你不希望输出带 BOM读用utf-8-sig、写用utf-8。os.replace在同一个文件系统内是原子操作比先删除再重命名安全得多中途断电也不会留下半个文件。6.3 加上内容匹配和批量遍历一个脚本覆盖九成场景行号删除只是一个维度实际工作中更多是按内容删。把条件函数化就能把两种需求合成一个脚本import argparse import os import re def build_filter(patterns, invertFalse): regexes [re.compile(p) for p in patterns] def keep(line): hit any(r.search(line) for r in regexes) return (not hit) if not invert else hit return keep def clean_file(path, keep, encodingutf-8-sig, dry_runTrue): out_path path .cleaned removed 0 total 0 with open(path, r, encodingencoding, newline) as fin, \ open(out_path, w, encodingencoding, newline) as fout: for line in fin: total 1 if keep(line): fout.write(line) else: removed 1 if dry_run: os.remove(out_path) return total, removed def main(): ap argparse.ArgumentParser() ap.add_argument(root) ap.add_argument(--pattern, actionappend, default[]) ap.add_argument(--invert, actionstore_true) ap.add_argument(--apply, actionstore_true) ap.add_argument(--ext, default.txt) args ap.parse_args() keep build_filter(args.pattern, args.invert) grand_total grand_removed 0 for dirpath, _, filenames in os.walk(args.root): for name in filenames: if not name.lower().endswith(args.ext): continue full os.path.join(dirpath, name) total, removed clean_file(full, keep, dry_runnot args.apply) grand_total total grand_removed removed print(f{full}: 共 {total} 行删除 {removed} 行) print(f合计 {grand_total} 行删除 {grand_removed} 行) if __name__ __main__: main()这个脚本有两个我特意加上的设计。一是--dry_run默认开启也就是不传--apply的时候只统计不落盘跑一遍看看每个文件会删多少行心里有数了再真正执行。批量操作最怕的就是一把梭先空跑是最便宜的保险。二是逐文件打印统计结果而不是只给一个总数这样哪个文件的删除量异常比如某个本来该有两万行的文件只删了三行说明模式没匹配上一眼就能看出来。6.4 编码不确定时的自动回退策略别人的文件永远是编码迷宫。写一个读取器按优先级依次尝试def open_auto(path): for enc in (utf-8-sig, utf-8, gb18030, cp936): try: with open(path, r, encodingenc) as f: f.read() return enc except (UnicodeDecodeError, LookupError): continue raise ValueError(f无法识别编码: {path})先整个读一遍做试探确认哪个编码能解开再用这个编码正式处理。代价是多读一遍文件对于几百个文件来说完全可以接受换来的是不会因为编码问题毁掉数据。gb18030放在cp936前面是有讲究的前者覆盖面更广、容错更好能处理一些生僻字。7. 三个真实场景的完整复现7.1 场景一日志文件删除第 3001 到 3050 行并校验假设日志文件app.log有 12 万行中间第 3001 到 3050 行是一段无意义的心跳输出需要删掉。cp app.log app.log.bak echo 原始行数: $(wc -l app.log) sed 3001,3050d app.log.bak app.log echo 处理后行数: $(wc -l app.log) # 验证对比首尾 diff (sed -n 3000p app.log.bak) (sed -n 3000p app.log) # 应无输出 diff (sed -n 3051p app.log.bak) (sed -n 3000p app.log) # 应无输出验证的逻辑是删除前的第 3000 行和删除后的第 3000 行应该完全一样删除前的第 3051 行删完之后应该落到第 3000 行的后面。两条 diff 都没输出说明删除区间准确无误。这个边界验证法比单纯对比行数更有说服力因为行数相同但删错位置的情况也是存在的。7.2 场景二批量清掉目录下所有 txt 的空行和注释行find ./novels -name *.txt -print0 | while IFS read -r -d f; do before$(wc -l $f) sed -i.bak -e /^[[:space:]]*$/d -e /^#/d $f after$(wc -l $f) printf %s 删除 %d 行\n $f $((before - after)) done这里[[:space:]]*是关键它同时覆盖了空格、制表符、回车等空白字符只有真正零长度的空行和看起来空其实有空格的行都会被删掉。每条命令都生成.bak备份跑完之后挑几个文件打开看一眼确认没问题再批量删除备份文件。如果目录规模上千个文件sed -i.bak会在目录里留下同样数量的备份文件管理起来很烦。这时候改成用 Python 脚本加--apply参数输出统一到一个cleaned/子目录源头不动反而更清爽。7.3 场景三合并多份报告后删掉重复头部仿真工具输出的覆盖率报告 txt每份前面都有 46 行的固定头部。把二十份合并之后这 46 行会重复出现二十次需要只保留第一次出现的那些后面的全删。head_signature None out [] with open(merged.txt, encodingutf-8-sig, newline) as f: lines f.readlines() for i, line in enumerate(lines): if 0 i 46: out.append(line) continue # 后面对每一块判断是否和第 0 到 45 行完全一致 if i % 46 0 and lines[i:i46] lines[0:46]: continue out.append(line)实际场景中块大小不一定严格是 46 行更稳的做法是先扫描出头部结束的标志行比如 Report End Header 记录标志行的位置再按位置切块比对。用sed也可以做思路是用区间模式sed /^ Header Start /,/^ Header End /d但这样会把第一次出现的头部也删掉需要加一个只删第二次及以后的逻辑用 awk 的计数器实现更自然awk /^ Header Start $/ { count; if (count 1) skip1 } !skip { print } /^ Header End $/ { skip0 } merged.txt merged.clean.txt这段 awk 的三个规则要连起来读遇到头部开始标记就计数第二次及以后把skip打开没被跳过就打印遇到头部结束标记就把skip关掉。三个规则按顺序对每一行执行逻辑清晰且不需要把文件读进内存。7.4 场景四按内容删行时的成对删除有些文本里的垃圾不是单行而是一对标记之间的整块内容比如用!--广告开始--和!--广告结束--包起来的一整段。这种情况用区间删除最合适sed /!--广告开始--/,/!--广告结束--/d in.txt out.txtsed 的区间地址是从匹配第一个模式的行开始到匹配第二个模式的行结束含两端和单行匹配是两套语义。这里有个容易翻车的点如果文件里有多个区间sed 会正确地一对一对地处理但如果某个区间的结束标记缺失sed 会从开始标记一直删到文件末尾把后面所有内容都吃掉。所以用区间删除之前务必要确认开始标记和结束标记的数量相等echo 开始标记: $(grep -c 广告开始 in.txt) echo 结束标记: $(grep -c 广告结束 in.txt)两个数字不相等就先别动手把文件拿去修好再说。8. 常见问题与排查技巧实录8.1 问题速查表现象最可能的原因处理办法第一行怎么都匹配不上文件带 BOM行首多了不可见字符读取改用utf-8-sig或先剥离 BOM删完中文全变问号写回时用了错误的编码明确指定 UTF-8 写入PowerShell 用 .NET 写法控制 BOM模式匹配一条都不中行尾有\r$锚点前多一个字符模式里加\r?或先统一换行符行号总是差一位0 基和 1 基混用脚本里显式写lineno - 1换算文件体积翻倍换行符从 LF 变成了 CRLF打开文件时加newline或统一换行符sed -i报错macOS 的 BSD sed 语法不同改成sed -i 3d file输入输出同一文件后内容没了shell 重定向提前截断了文件永远输出到临时文件再移动覆盖中间大片内容被误删区间删除的结束标记缺失动手前统计首尾标记数量是否相等删完之后行数对不上预期末尾没有换行符的行没被统计用tail -c 1 file检查最后一个字节Excel 打开后数据变形自动类型转换和前导零丢失只用 Excel 定位不用它保存8.2 编码问题为什么最难查它不报错编码问题最折磨人的地方在于它经常不报错只是默默地改坏。用错误的编码写回文件程序不会抛异常文件也能正常打开只是里面的中文变成了??????或者一串问号加方框。等你发现的时候原始文件早就被覆盖了。我的防御措施有三层。第一层是永远先备份cp一份或者用带.bak的原地修改。第二层是输出到新文件再对比比较前后两个文件的大小和首尾若干行异常了立刻停下。第三层是在脚本里打印实际使用的编码让每一次运行都有迹可循enc open_auto(path) print(f[{path}] 使用编码 {enc})有了这三层即便真出了编码问题也能在损失一个文件的时候立刻发现而不是等到处理完一批才后知后觉。8.3 大文件处理的性能观察处理一个 800 MB 的日志文件时我对比过几种方案的耗时和内存占用结果大致是sed和awk都在十几秒量级内存占用几十兆Python 流式脚本稍慢一点二三十秒内存占用同样稳定而readlines()的朴素 Python 写法直接把机器拖到开始交换内存跑了十几分钟还没结束。结论很明确只要文件超过一百兆就不要用一次性读入的写法。判断标准也很简单看代码里有没有readlines()、f.read()、Get-Content不带流式管道这类调用。有的话大文件场景下就要改。另外输出到固态硬盘和机械硬盘的差异也很明显同样是 800 MB 的写入SSD 上几秒钟机械盘上可能要一分钟。如果脚本要处理几千个文件把输出目录放在 SSD 上能省下大量等待时间。8.4 几个我踩过的真实坑第一次用grep -v删小说里的推广行模式里有括号结果被当成正则分组报了个语法错误。后来加-F就解决了这个教训让我养成了凡是删内容先想想要不要加-F的习惯。有一次用 PowerShell 处理一份从别的系统导出的 CSV 风格 txt写完发现 Excel 打开全是乱码查了半天才发现是 5.1 版本-Encoding UTF8写了带 BOM 的结果而下游系统把 BOM 当成了正文的第一个字符。改成 .NET 的UTF8Encoding($false)之后一切正常。还有一次最惊险在 Linux 上写了个awk NR100 || NR200 data.txt data.txt本意是删掉 100 到 200 行。幸好当时手快按了 CtrlC因为重定向在命令执行前就把文件截断了如果跑完data.txt 会变成一个零字节文件。从那之后我的删除命令一律写成xxx data.clean.txt确认无误后再mv data.clean.txt data.txt。9. 我现在的几条固定习惯处理这类删除文本文档指定行的任务我现在的流程已经固定下来了几乎没有再出过事故。第一条先看再动。拿到文件先跑wc -l或者数一下行数用head -5和tail -5看一眼首尾用file命令确认编码猜测。这一步花不到十秒能避免九成以上的后续麻烦。第二条行号定位一律用 sed 或 awk 验证一遍再写脚本。命令行里改一下立刻能看结果比在脚本里改代码、跑、看输出快得多。第三条批量任务一律先空跑。不管脚本写得多有信心先在单文件上跑、再看十个文件的统计、最后才全量执行。三层递进成本极低收益极高。第四条把常用的删行操作封装成 shell 函数或者小脚本放进PATH里。比如我有个rmline的脚本接受rmline -f 文件 -l 3或rmline -f 文件 -p 关键词两种调用方式自动备份、自动校验行数变化、自动打印处理前后的统计。用了两年多比每次现敲命令省了不知道多少时间也彻底消灭了手滑写错参数的可能。第五条也是最重要的一条——不要在原文件上做破坏性操作除非你已经有一份可用的备份。这条听起来像废话但它是我那些事故里唯一一条如果严格遵守就能避免全部损失的原则。删行本身是个不可逆的操作工具再可靠也挡不住手滑留一份备份的成本是几个字节和几秒钟而没留备份的代价可能是几天的重做。后续如果你要处理的不只是行号删除还涉及按列筛选、字段拆分、格式转换那就不是单条命令能搞定的了值得单独写一个带参数解析的处理脚本。我这边的做法是把这些需求统一收进一个texttool脚本里用子命令区分texttool rmline、texttool rmblank、texttool dedup参数风格统一日志统一打印用久了就成了一套自己的小工具集。