ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Windows文本文件合并实战:命令、脚本与避坑指南

Windows文本文件合并实战:命令、脚本与避坑指南

说实话,在 Windows 上搬数据的人,迟早都会撞上"合并文本文件"这个需求。十几份日志要揉成一个,几十个 CSV 片段要拼成完整表格,或者一堆配置文件想合并后一次性丢给程序处理——手动打开每个文件复制粘贴,小文件还好,几百个文件能把你逼疯,而且极易漏行、错行、多空行。

Windows 下合并文本文件,远不只是"把内容堆在一起"那么简单:文件顺序对不对、结尾有没有换行、编码会不会乱、几十 GB 的大文件能不能扛得住——这些才是真正决定你干活效率的地方。这篇文章不聊花哨的工具,只讲我在实际项目中用过的几种方案、踩过的坑,以及可以直接抄走的命令和脚本。

1. 动手之前,先把合并需求拆成四项

很多人在网上搜"合并文本文件",拿到一条命令就跑,结果合并出来的文件要么乱码,要么顺序不对,要么干脆丢了数据。我的经验是:先别急着写命令,把下面四个问题想清楚,后面才顺手。

1.1 文件顺序:比你想象的更重要

合并日志文件时,顺序基本等于时间线。日志字段里往往有时间戳,但文件名的排列顺序不一定等于时间顺序。

比如一批文件名是access_1.log、access_10.log、access_2.log,如果直接按字符串排序合并,结果是 1、10、2,而不是 1、2、10。这种数字补零不规范的文件名,在 cmd 通配符展开时尤其容易踩坑。所以第一步就是确认:合并后的内容到底应按什么顺序?是文件名、修改时间,还是某个清单文件里写好的顺序?

如果你只是想把几个文件按你写的先后顺序拼起来,命令很简单。但如果要按修改时间排,或者按文件名里的日期字段排,就得用 PowerShell 做排序了。

1.2 内容之间要不要分隔符

第二个问题是:合并完,你是否还能分清哪段内容来自哪个文件?

如果只是拼日志,通常不需要分隔,直接首尾相连就行。但如果是合并配置片段、SQL 脚本或者报告片段,最好在每段之间插入一行注释或分隔线,否则以后想按来源切回去,就只能靠眼睛找。

分隔符的选择也有讲究。纯文本场景下,我习惯用类似下面这种:

===== 文件名 =====

如果你的合并结果还要再给程序解析,这种分隔线反而可能变成脏数据。那么不要加任何分隔,甚至要保证行尾严格一致。需求不同,方案完全不同。

1.3 编码和换行:最容易被忽视的坑

这一块是重灾区。Windows 下的文本文件常见编码有 ANSI(GBK)、UTF-8 带 BOM、UTF-8 无 BOM、UTF-16 LE/BE。如果你直接拿type或copy去拼,结果是:内容虽然拼到一起了,但打开时中文乱码,或者文件开头多了一个看不见的 BOM 头。

为什么会这样?因为type和copy这类原生命令,本质上是按字节把文件搬过去,它不会帮你做编码转换。两个文件如果一个是 GBK、一个是 UTF-8,拼出来的文件里就同时存在两套编码规则,任何打开方式都只能正确显示其中一部分。

换行符也一样。Linux 下来的文件是\n(LF),Windows 记事本现在能识别了,但很多旧程序只认\r\n(CRLF)。如果合并时文件末尾没有换行符,后一个文件的内容会直接粘在前一个文件的最后一行的末尾,形成一整行怪物。

1.4 文件大小决定你用哪条路

最后要想清楚体积。几个 KB 的小文件,用什么都行;几个 GB 的文本,就要考虑内存和速度了。

一次把几个 GB 读进内存再写出来,很容易把内存吃满,机器直接卡死。这种场景下应该用流式处理:边读边写,或者干脆用copy /b这种底层复制,让操作系统帮你搬字节,速度反而最快。下表是我常用的选型思路:

场景推荐方案不推荐方案
几个小文件,无编码要求copy /b或type手动画框复制
大量小文件,需要加分隔PowerShell 循环读取cmd 原生直接拼
文件名顺序有讲究PowerShellSort-Objectfor /r通配符
多个 GB 的大文件copy /b或 .NET 流式读写Get-Content一次性赋值
不同编码混合PowerShell 显式转码后合并type直接重定向

把需求拆到这一步,下面选命令就有依据了。

2. Windows 原生命令合并:copy 与 type

原生命令的好处是干净、快、不需要额外环境。Windows 从 XP 到现在的 11,这套东西基本没变过,脚本写出来拿到别的机器也能跑。

2.1 copy /b:最原始也最可靠

copy是 DOS 时代留下来的命令,很多人以为它只能复制文件,其实它可以用来拼接文件。最基础的写法是这样的:

copy /b part1.txt + part2.txt + part3.txt merged.txt

这个命令会把三个文件按顺序首尾相接,写进merged.txt。/b参数是二进制模式,千万不要去掉。

不加/b时,copy会把文件当作文本模式处理,遇到Ctrl+Z(十六进制0x1A)就认为是文件结束,后面的内容全部丢掉。老系统里文本文件经常以0x1A作为结束标记,但现代文件里完全可能出现这个字节,比如压缩包、带特殊符号的文本。加了/b,就是纯字节复制,不解释、不截断,最保险。

然后要注意一个经典问题:别让输出文件出现在源文件的通配符范围内。比如你执行:

copy /b *.txt merged.txt

如果merged.txt本身也在当前目录里,而且扩展名也是.txt,那么源文件列表里就会包含它自己,结果要么报"无法复制自身"的错误,要么文件大小翻倍。我的习惯是:输出文件放到另一个目录,或者用完全不同的扩展名,比如merged.all、merged.log,不让它被通配符匹配到。

copy /b还有一个隐藏优势:非常快。因为它不解析内容,不关心里面是几行,直接把数据块搬过去,所以处理几个 GB 的文件也只需要十几秒,内存占用几乎为零。前提是编码一致、不需要加分隔符。

2.2 type 重定向:适合纯文本小批量

type本来是显示文件内容的命令,配合重定向符号就能把内容写进另一个文件:

type a.txt b.txt > merged.txt

也可以直接合并当前目录下所有 txt:

type *.txt > merged.txt

这个命令比copy /b直观,但有几个限制。

第一,type对二进制内容不友好。如果文件里有非常规的字节,它有可能在显示和重定向时做处理,结果就不再是原始字节了。所以它只适合确认是纯文本的场景。

第二,重定向输出会受系统代码页影响。在中文 Windows 上,cmd 默认代码页是 936(GBK),type遇到 UTF-8 无 BOM 的文件时,字节本身不会变,但如果你把它重定向到文件后再用记事本打开,记事本会按 GBK 去猜,就会出现中文乱码。这不是type把文件弄坏了,而是查看端编码不匹配。真正的解决方案是统一用 UTF-8 编码,并且明确告诉打开文件的程序。

第三,type合并同样不会自动补换行。如果前一个文件最后一行没有换行符,后一个文件的内容会直接跟在后面。下面这段批处理可以解决这个问题。

2.3 批处理脚本实现“自动补换行”

如果一定要在 cmd 里解决"粘行"问题,可以用一个批处理脚本。思路是:每合并完一个文件,手动追加一个换行。

@echo off setlocal EnableDelayedExpansion set "out=C:\logs\merged.txt" type nul > "%out%" for %%f in (*.txt) do ( if /i not "%%f"=="merged.txt" ( copy /b "%out%" + "%%f" "%out%" >nul <nul set /p "= " >> "%out%" ) ) echo 合并完成: %out%

这里用<nul set /p "= "是为了输出一个换行。它的原理是让set /p从空设备读取输入,不等待用户按键,只输出提示文本,而提示文本后面带着回车换行。注意括号里的if是为了防止把输出文件自己也合并进去,输出文件merged.txt被跳过。

这个脚本有个代价:每次循环都执行一次copy /b,文件越多越慢,因为都在重复读输出文件。所以它只适合几十个小文件,大文件千万别这么玩。

3. PowerShell 合并:把控制权拿回来

如果你需要控制编码、加分隔符、按文件名排序、处理不同编码,cmd 原生命令就不够用了。这时候用 PowerShell,几乎能做所有事。

3.1 一行命令的常规合并

最基础的 PowerShell 合并命令:

Get-Content -Path a.txt, b.txt, c.txt -Encoding UTF8 | Out-File -FilePath merged.txt -Encoding UTF8

这条命令会把三个文件的内容按行读取出来,然后统一输出。注意两点:

一是Get-Content默认按行读取,所以输出到目标文件时,每个文件行尾都会补齐换行符。这反而避免了"粘行"问题。

二是-Encoding UTF8要显式写。Windows PowerShell 5.1 里的Out-File -Encoding UTF8默认写带 BOM 的 UTF-8,PowerShell 7 则默认写无 BOM 的 UTF-8。如果你要给别人处理,先确认对方能接受 BOM。一般认为,跨平台场景下无 BOM 更通用,但在 Windows 老程序里,带 BOM 的 UTF-8 反而更不容易乱码。

不过这条命令有个隐患:如果某个文件里有一行特别长,比如几兆的 JSON 单行,Get-Content会把这整行读进内存,内存压力突然变大。对这种文件,用后面介绍的流式方案更安全。

3.2 合并时加文件名和分隔标记

如果你希望合并后的文件还能看出内容来源,可以这么写:

$dest = 'C:\logs\merged.log' $files = Get-ChildItem -Path C:\logs -Filter *.log | Sort-Object Name foreach ($f in $files) { "`n===== $($f.Name) =====" | Out-File -FilePath $dest -Append -Encoding UTF8 Get-Content -Path $f.FullName -Encoding UTF8 | Out-File -FilePath $dest -Append -Encoding UTF8 }

这段脚本先按文件名排序,然后对每个文件先写一行分隔标记,再写入内容。Out-File -Append是追加模式,不会覆盖之前的内容。运行前记得把$dest文件删掉,否则重复运行会越攒越多。

这里有一个细节:因为Get-Content是按行输出,如果最后一个文件末尾没有换行,输出时也会自动补上,不会粘行。如果你不需要这种自动补换行,想保留完全原始的内容,就得用-Raw或流式读取。

3.3 用 -Raw 高速处理一堆小文件

当你有几百个几十 KB 的小文件,又不想在循环里反复打开文件时,-Raw是更好的选择:

$files = Get-ChildItem -Path C:\data -Filter *.txt | Sort-Object Name $content = ($files | ForEach-Object { [System.IO.File]::ReadAllText($_.FullName, [System.Text.Encoding]::UTF8) }) -join "`r`n" [System.IO.File]::WriteAllText('C:\data\merged.txt', $content, [System.Text.Encoding]::UTF8)

这里用 .NET 的ReadAllText一个文件读成一个大字符串,最后用-join把所有内容用 CRLF 连接起来。好处是快,文件少的场景几乎是瞬间完成;坏处是会把所有内容一次性装进内存,所以只适合总大小不大、你可以接受全部放内存的场景。

如果不确定文件编码是不是 UTF-8,就不要用固定编码读。可以先用[System.IO.StreamReader]自动检测 BOM,或者直接读取字节再判断。我在实际项目里更倾向于先约定:所有输入文件全部转换为 UTF-8,再来合并。

3.4 用流式读取扛住超大文件

处理几个 GB 的文本时,上面所有"读进来再写出去"的方案都不合适。要么内存炸,要么速度慢到没法忍。这种情况我用 .NET 的流式处理,思路是逐块复制,不把整个文件加载进内存:

$dest = 'C:\logs\merged.txt' $out = New-Object System.IO.StreamWriter($dest, $false, [System.Text.Encoding]::UTF8) Get-ChildItem -Path C:\logs -Filter *.log | Sort-Object Name | ForEach-Object { $reader = New-Object System.IO.StreamReader($_.FullName, [System.Text.Encoding]::UTF8, $true) $reader.BaseStream.CopyTo($out.BaseStream) $out.BaseStream.Flush() $reader.Dispose() } $out.Dispose()

这段脚本里,StreamReader按块读取,CopyTo直接把字节流写到输出流,内存占用始终保持在很低水平。脚本执行过程中,你可以开着任务管理器观察,内存基本是一条平线。

要注意的是:BaseStream.CopyTo是字节级的复制,不会在文件之间插入换行。所以如果希望文件之间自动换行,需要自己手动写分隔符,比如:

$out.WriteLine()

在每次CopyTo之后调用一次。不加也可以,但内容会首尾相连。

4. 批量目录与递归场景:从手动到自动化

合并文件的需求一旦扩大成"把某个目录下面所有日志合并成一个",就需要考虑递归、排序、去重、防重复运行这些问题了。

4.1 用 for /r 递归合并

cmd 里最直接的递归合并方式:

for /r "C:\data" %f in (*.txt) do type "%f" >> C:\data\combined.txt

在批处理文件里,变量要改成%%f:

for /r "C:\data" %%f in (*.txt) do type "%%f" >> C:\data\combined.txt

这个命令会把C:\data下所有子目录里的 txt 文件全部追加到combined.txt。有两点要提醒:

一是输出文件绝对不能放在C:\data目录下且扩展名为.txt,否则循环到后面会把已经生成的合并文件也读进去,造成内容重复甚至把自己越读越大。

二是for /r的遍历顺序不保证按文件名排序,更不保证按修改时间排序。在多数机器上它是按目录枚举顺序来的,但你没法依赖它。如果顺序对你有意义,请用 PowerShell。

4.2 用 PowerShell 递归合并并保留来源信息

这个示例我经常用来整理项目日志,把分散在几十个目录里的日志合并成一份,同时保留来源路径:

$dest = 'C:\logs\archive\all.log' if (Test-Path $dest) { Remove-Item $dest } Get-ChildItem -Path 'C:\logs' -Filter *.log -Recurse | Sort-Object FullName | ForEach-Object { "`n===== $($_.FullName) =====" | Out-File -FilePath $dest -Append -Encoding UTF8 Get-Content -Path $_.FullName -Encoding UTF8 | Out-File -FilePath $dest -Append -Encoding UTF8 }

先删除旧输出文件,是为了避免重复运行导致内容叠加。Sort-Object FullName能保证合并顺序稳定,而且路径相同的文件一定排在同一个子目录区域内。

这段脚本的缺点是Out-File -Append在循环里会被反复调用,性能一般。如果你对速度敏感,可以改成用StringBuilder先攒一批再写,或者用 StreamWriter 一次性写。但为了代码简单,多数场景下这点性能损失无所谓。

4.3 按自定义清单合并跨目录文件

有些场景下,你并不想合并某个目录下的所有文件,而是只想合并指定的一批文件,顺序还要按清单来。我处理过的一个场景是:从多个数据源目录里挑出特定文件,按业务日期排好序再合并。

这个用批处理不好做,PowerShell 却很自然:

$fileList = Get-Content -Path 'C:\tasks\filelist.txt' $dest = 'C:\data\merged.csv' if (Test-Path $dest) { Remove-Item $dest } foreach ($line in $fileList) { if ($line -match '^\s*$' -or $line.StartsWith('#')) { continue } $resolved = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($line) if (Test-Path $resolved) { Get-Content -Path $resolved -Encoding UTF8 | Out-File -FilePath $dest -Append -Encoding UTF8 } else { Write-Warning "文件不存在: $line" } }

清单文件里每一行写一个文件的完整路径,支持#注释和空行。这样做的好处是:合并逻辑和文件列表分离,调顺序时不用改脚本,改清单就行。

4.4 把合并任务放进计划任务

当合并变成日常操作,每天都要跑一次时,我建议把脚本做成独立.ps1文件,然后用任务计划程序定时执行。

一个适合计划任务的脚本模板:

param( [string]$Source = 'C:\logs', [string]$Dest = 'C:\logs\merged_daily.log', [string]$Filter = '*.log' ) if (Test-Path $Dest) { Remove-Item $Dest } Get-ChildItem -Path $Source -Filter $Filter -Recurse | Sort-Object FullName | ForEach-Object { Get-Content -Path $_.FullName -Encoding UTF8 | Out-File -FilePath $Dest -Append -Encoding UTF8 }

在任务计划程序里创建任务,操作里填入:

powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\scripts\merge-daily.ps1"

-ExecutionPolicy Bypass是为了避免脚本被系统执行策略拦下来,适合自己控制的机器。注意把工作目录、日志路径都写成绝对路径,计划任务的环境变量和手动执行时不一样。

5. 常见问题排查与避坑实录

这一节是我在真实项目里踩过、也帮别人排过的坑。很多问题看着莫名其妙,其实原因就那么几个。

5.1 合并后中文乱码,十有八九是编码问题

症状:合并出来的文件,用记事本打开后部分中文变成锟斤拷或者一堆问号。

原因分两种:要么源文件之间编码不一致,要么输出文件的编码和查看工具不匹配。

排查和解决思路:

# 查看一个文件是否带 BOM,以及大致编码 $bytes = [System.IO.File]::ReadAllBytes('C:\logs\a.txt') $hex = ($bytes | Select-Object -First 4 | ForEach-Object { $_.ToString('X2') }) -join ' ' $hex

如果开头是EF BB BF,说明是 UTF-8 带 BOM;如果是FF FE,说明是 UTF-16 LE;如果是FE FF,是 UTF-16 BE;什么 BOM 都没有,就只能用内容判断了。

解决方法是先把所有源文件统一成同一种编码,最好统一成 UTF-8,然后再合并。比如:

Get-ChildItem -Path 'C:\logs' -Filter *.log | ForEach-Object { $content = [System.IO.File]::ReadAllText($_.FullName, [System.Text.Encoding]::Default) [System.IO.File]::WriteAllText($_.FullName, $content, [System.Text.Encoding]::UTF8) }

注意Encoding::Default在 Windows PowerShell 5.1 里其实是系统当前的 ANSI 代码页,在中文系统里就是 GBK。这个方案只适合确定源文件都是 GBK 的情况。

5.2 内容粘成一整行怎么办

症状:合并后上一文件的结尾和下一文件的开头在同一行,看起来像...结束 开始...。

原因就是源文件末尾没有换行符,而合并命令又没有自动补。copy /b和type都这样。

解决方式很简单,在合并前先给文件尾部补换行。PowerShell 可以在循环里做:

Get-ChildItem -Path 'C:\data' -Filter *.txt | ForEach-Object { $content = [System.IO.File]::ReadAllText($_.FullName) if (-not $content.EndsWith("`n")) { [System.IO.File]::AppendAllText($_.FullName, "`r`n") } }

如果不想改源文件,也可以在合并后统一替换,把不该粘的地方重新拆开——但这很难判断哪里是故意的、哪里是粘了。所以我建议直接改源文件,代价最小。

5.3 输出文件把自己也“吃”进去了

症状:合并后的文件里出现大量重复内容,而且文件越来越大,甚至报错 "The process cannot access the file because it is being used by another process."

原因:输出文件放在源目录里,扩展名又和源文件一样,结果通配符*.txt把它也匹配进去了。合并过程中,它一边被读、一边被写,数据翻倍增长。

解决:把输出文件放到不同目录,或者用不同扩展名,并且在脚本里显式跳过输出文件。

$files = Get-ChildItem -Path 'C:\data' -Filter *.txt | Where-Object { $_.Name -ne 'merged.txt' }

这个判断不贵,但能避免大量诡异问题。

5.4 排查实例:一个看似“卡死”的合并任务

有次帮同事排一个问题:他用 PowerShell 合并一个 5 GB 的日志目录,跑了十分钟还没结束,任务管理里 PowerShell 内存占用涨到 3 GB,电脑卡到几乎没法操作。

我一看他的脚本,里面写着:

$content = Get-Content -Path $files $content | Out-File ...

问题就出在$content =,它把Get-Content返回的所有行一次性装进了内存,5 GB 文件逐行变成对象后,内存占用甚至超过原始文件大小。

改成流式管道之后:

Get-ChildItem -Path 'C:\logs' -Filter *.log | ForEach-Object { Get-Content -Path $_.FullName | Out-File -FilePath 'C:\logs\merged.log' -Append -Encoding UTF8 }

几分钟就跑完,内存一直稳定在几百兆。记住一个原则:只要你不把一个结果赋值给变量,PowerShell 管道默认就是流式的;一旦赋值,就是整批载入。

5.5 问题速查表

症状常见原因解决方案
中文乱码源文件编码不一致或查看端编码不匹配先统一转成 UTF-8 再合并
内容粘行源文件末尾无换行合并前补换行,或用 PowerShell 逐行输出
输出文件重复增长输出文件匹配了源通配符调整输出文件名/目录,脚本里跳过
合并顺序不对通配符展开顺序和你预期不一致用 PowerShellSort-Object Name或按清单
内存占用过高Get-Content结果赋值给变量改用管道流式处理或CopyTo
文件被占用无法写入源文件正被程序打开关闭占用程序,或先复制到临时目录再处理
UTF-16 文件合并后没法看原生命令按字节拼,BOM或编码冲突用 PowerShell 显式指定-Encoding Unicode

6. 合并不是终点:三个能直接用的进阶玩法

到这里,基础合并已经讲透了。如果你经常处理特定类型的文本,下面这三个玩法可以直接拿去改。

6.1 合并 CSV 时只保留第一个表头

合并多个 CSV 文件时,最烦人的是每个文件都带表头,合并后中间会夹杂重复表头。用 PowerShell 跳过多余的头部:

$dest = 'C:\data\all.csv' $first = $true Get-ChildItem -Path 'C:\data' -Filter *.csv | Sort-Object Name | ForEach-Object { if ($first) { Get-Content -Path $_.FullName -Encoding UTF8 | Out-File -FilePath $dest -Encoding UTF8 $first = $false } else { $lines = Get-Content -Path $_.FullName -Encoding UTF8 $lines | Select-Object -Skip 1 | Out-File -FilePath $dest -Append -Encoding UTF8 } }

这段脚本只保留第一个文件的表头,后面的文件从第二行开始追加。需要注意,如果某个 CSV 文件本身就是空文件,只有表头没有数据,跳过表头后就什么都不写。

6.2 合并前自动备份并校验

合并这种操作属于"覆盖性强"的操作,一旦输出文件路径写错,可能把原文件覆盖掉。我的习惯是:合并前先自动备份,合并后校验文件行数或大小。

$dest = 'C:\data\merged.txt' if (Test-Path $dest) { Copy-Item $dest "$dest.bak_$(Get-Date -Format 'yyyyMMdd_HHmmss')" } # 合并之后再对比行数 $srcLines = (Get-ChildItem -Path 'C:\data' -Filter *.txt | Get-Content | Measure-Object -Line).Lines $destLines = (Get-Content $dest | Measure-Object -Line).Lines if ($srcLines -ne $destLines) { Write-Warning "合并后行数不一致,请检查源文件是否被改动" }

这个校验思路不算严格,因为有些源文件最后一行没有换行,行数统计会有偏差,但至少能发现明显的数据丢失问题。

6.3 按日期生成每日合并文件

如果你每天都要把日志合并成一份,并且希望文件名带日期,可以在脚本里动态生成目标路径:

$today = Get-Date -Format 'yyyyMMdd' $dest = "C:\logs\merged_$today.log"

配合计划任务,每天早上自动跑一次,文件名里带上日期,这样既不会覆盖历史合并结果,也方便后续按天查日志。

我个人在实际操作中的一个体会是:合并文本文件的本质不是命令技巧,而是对数据格式的掌控。你越是能把文件顺序、编码、换行、内存这四件事想清楚,越不会被各种诡异问题卡住。很多人遇到乱码第一反应是找转码工具,其实只要源头统一,后面一路畅通。最后再分享一个小习惯:合并完成后,别急着删除源文件,先打开合并结果翻一下开头、中间、结尾各一段,确认没有粘行、没有乱码,再清理不迟。这比任何校验命令都直观,也最保险。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进