1. 先从一次真实的文件比较说起
上个月帮朋友排查两个服务器上的软件包差异,一个跑着生产业务,一个准备做灾备切换,两边按理说应该是同一套环境,但总让人不放心。最简单的办法是把两边已安装的软件包列表导出来,然后对比。大家第一个想到的是diff,但diff看的是“逐行差异带上下文”,输出结果在纯粹“找出两边各自独有的东西”这种需求下显得特别啰嗦。
这时候comm就派上了用场。凡是老 Unix 兵,应该都见过这个命令,但说实话,现代 Linux 环境下知道diff、grep的人很多,真正把comm用熟的人反而不多。原因不外乎它有一个容易劝退的前提条件:输入文件必须按字典序排序。很多人第一次试的时候直接拿两个没排序的文件跑,输出结果完全没法看,于是用了一次就再也不碰它。
其实comm非常优雅,尤其是和sort、find、cut等命令组合使用后,专门解决“两个集合到底差在哪”这类问题,比diff更适合脚本处理,比grep -v -f更适合超大文件。这篇我就把这十几年前老命令的用法、原理、坑,以及在实际场景里怎么跟其他命令组合,一次性讲透。
2. comm 的基本玩法与参数拆解
2.1 输出三列格式究竟在表达什么
comm的名字来自common,意思是找两个文件里共有的行。它的标准输出是“三列”的排版:
- 第一列:只出现在第一个文件的行
- 第二列:只出现在第二个文件的行
- 第三列:两个文件都有的行
用文字描述总是绕,直接跑一遍最直观。先造两个小文件:
printf 'apple\nbanana\ngrape\norange\n' > left.txt printf 'banana\ngrape\nkiwi\nmelon\n' > right.txt然后执行:
comm left.txt right.txt输出如图(用字符表示 tab 分隔):
banana grape apple kiwi melon orange是不是一头雾水?这就是comm被吐槽最多的地方:它的“列”不是用空格对齐的,而是用制表符(tab)占位。第一列的行前面没有任何分隔符;第二列的行前面带有 1 个 tab;第三列的行前面带有 2 个 tab。
所以上面的输出实际意思是:
banana、grape是两边共有,输出在第三列;apple、orange只属于 left.txt,输出在第一列;kiwi、melon只属于 right.txt,输出在第二列。
但人眼直接看这个结果很吃力,因为它没有表头、没有对齐、tab 在不同终端下显示宽度不一致。这也是comm劝退新手的另一个原因。
想看清楚 tab 去哪了,可以用cat -A:
comm left.txt right.txt | cat -A^I就是 tab,三列区分得清清楚楚。
2.2 三个数字参数:-1、-2、-3 的控制逻辑
comm最核心的功能全在这三个参数上:
-1:不输出第一列(只在文件1里出现的行)-2:不输出第二列(只在文件2里出现的行)-3:不输出第三列(共有的行)
也就是说,-1对应“第一个文件独有”,-2对应“第二个文件独有”,-3对应“两边共有”。需要哪种结果就删掉哪一列。
我最常用的几个组合值得背下来:
# 只输出两边共有的行:屏蔽第一列和第二列 comm -12 left.txt right.txt # 只输出左边独有、右边没有的行:屏蔽第二列和第三列 comm -23 left.txt right.txt # 只输出右边独有、左边没有的行:屏蔽第一列和第三列 comm -13 left.txt right.txt注意,-12是-1 -2的简写形式,三个参数可以任意叠加,同时传-123就什么都不剩了,相当于把两边的行都屏蔽掉。这个特性我用过很多次,用来卡在管道里做“有副作用但无输出”的中间校验,比如只想要comm的退出逻辑而不需要它的输出文本。
2.3 第一次实战:找出两个列表的差集
回到开头那个服务器对比场景。假设已经从两台机器上导出了包列表,格式是每行一个软件包名(不含版本号),文件分别叫pkg-prod.txt和pkg-dr.txt。现在要看生产环境有、灾备环境没有的包,命令就一句话:
comm -23 <(sort -u pkg-prod.txt) <(sort -u pkg-dr.txt)这个写法用了进程替换<(...),两个文件的排序结果直接作为comm的输入,不用生成临时文件。如果测试下来结果为空,说明灾备没有缺少生产环境任意一个包;反过来查灾备多出来的包:
comm -13 <(sort -u pkg-prod.txt) <(sort -u pkg-dr.txt)这种能力用diff也能做,但diff的输出夹杂<、>、---这类标记,脚本里二次处理还得再解析。comm干净多了,输出的每一行都对应真实存在的行,直接喂给xargs或while read做后续操作都非常顺手。
3. 为什么必须先排序:原理层面的耐心解释
3.1 comm 的比较逻辑是线性逐行扫描
很多人只记住“要排序”,但没搞明白“为什么必须排序”。其实comm内部工作原理非常朴素,它同时对两个文件逐行读取,维护两个“当前行”指针,每次比较指针指向的两行。
伪代码大致是:
read line1 from file1 read line2 from file2 while both files have more lines: if line1 < line2: output line1 as "only in file1" read next line1 else if line1 > line2: output line2 as "only in file2" read next line2 else: output line1 as "common" read next line1 and line2这种算法的时间复杂度是 O(m + n),即两个文件的总行数相加,并且几乎不占额外内存。相比之下,grep -v -f的做法是把一个文件全部加载进内存构建模式集合,文件一大内存压力急剧上升;diff的 Myers 算法虽然也是线性复杂度,但它要维护编辑路径,输出中包含大量上下文标记,对机器解析不友好。
所以comm在“两个有序文本集合求差集”这个问题上,是目前命令行工具中最经济的选择。它不排序,只比较,把排序这个职责甩给了sort,自己保持轻薄小巧。
3.2 排序的坑:sort 和 comm 的排序规则必须一致
这里我要单独拎出来强调一个很多老手都翻过车的细节:sort -n(数值排序)和comm默认的字典序比较是不兼容的。
comm的内部行比较是逐字符按字典序进行的,也就是和sort默认行为一致。但如果你先执行了sort -n,比如对进程 PID 或者端口号排序,文件里出现10、9、100这样的顺序,comm会拿这些行做字典序比较,而不是按数值大小去对齐,结果就会产生一堆“看似不该出现”的独有行。
遇到数字开头的行,我的建议是非必要不用sort -n,直接用默认字典序排序后再交给comm。如果实在必须按数值排序,那就需要重新审视这个方案,是否应该改用join或awk来做关联逻辑。命令行工具各自有各自的长处,不要老想用一个命令硬拗所有场景。
另外,comm不像sort那样支持-k指定排序字段,它只能比较整行。如果只想按某个列作为比较键,就得先用awk '{print $N}'把对应列抽出来,或者用sort -k排好序后再cut出需要比的部分,避免整行内容不一致导致误判。
3.3 重复行:comm 不帮你去除,别指望它输出唯一值
再多说一句重复行的问题。comm只是机械地把输入文件各自的行拿来做比较,它不会去重。如果某个文件里同一行出现了两次,而另一个文件里只出现一次,结果可能出乎意料:
left.txt: A A B right.txt: A B执行comm -23 left.txt right.txt,输出会是:
A因为 left 里第一个A与 right 里的A配对了,第二个A没有配对对象,就被输出为“left 独有”。这就是为什么前面实战里我在sort后面必须要加-u的原因。sort -u和comm -23是天生一对,没有-u的排序结果交给comm,等于亲手埋雷。
4. 实际场景里的组合用法与效率观察
4.1 场景一:找两个软件源里版本不同的包名
我有一次要从两个内网软件仓库的索引文件里找出“哪些包的版本两边不一致”。列表格式是:
xxx-core 2.3.1 yyy-lib 1.0.2 zzz-agent 7.5.0两边行顺序不同、包含的包也有差异。如果直接用comm,会把这些行当成“整行不同”,把版本号差异的行当成独有项,这是符合逻辑的。但我想找的是“同名但版本不同的包”,怎么办?
思路是先按包名抽列,再求交集里的差异。步骤拆成两条:
# 先抽出包名,去重排序 cut -d' ' -f1 repo-old.txt | sort -u > old.names cut -d' ' -f1 repo-new.txt | sort -u > new.names # 找到两边都有的包名 comm -12 old.names new.names > both.names # 从原文件中筛出这些共同包名的版本进行对比 grep -F -f both.names repo-old.txt > old.entries grep -F -f both.names repo-new.txt > new.entries之后可以用paste、diff或者干脆写个小awk,把两个同名包的版本对齐展示。这套流程的价值在于:comm负责“求集合交集”这步脏活,后面的版本差异就可以交给更擅长的工具。
4.2 场景二:大文件下的性能表现
曾对比过两个百万行级别的白名单文件,一个约 38MB,一个约 41MB。用grep -v -F -f做差集,跑了差不多 40 秒,内存峰值接近 1GB;换成sort -u加comm -23的组合,整个过程只用了不到 5 秒,内存占用不到 200MB。
差距来源很直接:
grep -f的模式加载是内存密集型操作,模式文件一大,构建匹配串的开销很可观;comm是流式逐行比较,没有哈希表,没有模式编译,时间复杂度是令人安心的线性增长。
所以只要排序能接受、行内容不包含特殊换行符,comm在百万行级别的集合差运算里就是命令行首选。需要强调一下,comm对大文件的另一个好处是稳定性——它不会因为输入中的某一行特别长导致性能突然劣化,处理过程的时间预算基本可控。
4.3 场景三:配合 find 对比目录文件清单
再分享一个日常高频用法:对比两个目录下的文件列表。比如某个发布包在测试环境和生产环境的目录结构应该一致,但可能有遗漏上传的文件。
# 生成两个目录下所有文件相对路径列表 (cd /opt/test && find . -type f | sort -u) > test.files (cd /opt/prod && find . -type f | sort -u) > prod.files # 找出测试环境有、生产环境没有的文件 comm -23 test.files prod.files这个场景需要注意:find输出如果后面要配合xargs或tar,路径中有空格的文件名会被拆断。稳妥的做法是在生产端先确认是否有这类文件(用find ... -print0检查),如果有,建议直接用rsync -n这类工具代替。comm对比文本行,天然假设“每行一个文件名”,空格问题没法绕过。
4.4 场景四:把输出列做标记化处理
comm的三列输出不适合人读,但很适合机器二次处理。有一个小技巧,把三列变成带前缀的单列输出:
comm left.txt right.txt \ | sed -e 's/^\t\t/COMMON\t/' -e 's/^\t/RIGHT\t/' -e 's/^/LEFT\t/'执行后每行会带上标记前缀,归类起来一目了然。这里利用了 tab 转义的顺序,注意sed里最后的s/^/LEFT\t/不要写在前面,否则会把所有行都标记成LEFT,因为每行开头都可能匹配空串。实际经验是,前两条替换规则执行后,第一列的行模式变成了LEFT\t行内容,第二列的行变成了RIGHT\t行内容,第三列的行变成了COMMON\t行内容,后面再接awk '{print $1}'就能统计三类行各自的数量。
5. 容易踩的坑与排查方案
5.1 排序不一致导致错误差集
这个前面提过原理,这里是实际的排查体验。曾经有人找我帮忙定位一个数据差异脚本,他用sort -n排了端口号,再用comm对比两个端口列表。结果发现一个文件里有1000、900、90,另一个文件里有90、1000、900,排序后第一种是数值顺序90、900、1000,第二种是字典序1000、90、900,两边同一个值不在同一位置,comm当然找不出共同行。
排查思路很简单:先用od -c或head查看两边文件的开头几行,确定它们是字典序一致的状态;不规范的地方全部通过sort -u重新处理。还有一个检查手段是用join做个交叉验证,如果join的结果行数和comm -12的行数对不上,那大概率是排序规则出了问题。
5.2 换行符不统一:CRLF 惹的祸
从 Windows 环境生成的文件再传到 Linux 上,行尾经常是\r\n。在终端里看内容,每行末尾都会带一个^M,用cat -A能看到。comm把\r当成行内容的一部分,导致一个明明是nginx的行和一个nginx\r的行被判定为不同内容,完全匹配不上。
处理办法,一是用dos2unix转成 Linux 换行,二是用sed -i 's/\r$//'清理,三是如果不想动原文件,可以在管道里顺手处理:
comm -23 <(sed 's/\r$//' left.txt | sort -u) <(sed 's/\r$//' right.txt | sort -u)这个坑在自动化流水线里特别容易复发,因为文件源头可能来自异构系统。凡是comm结果明显不对劲,而文件肉眼看着内容都对得上,低概率怀疑排序问题,剩下的高概率就是换行符和不可见字符问题。
5.3 输出没反应,先检查文件是否为空
comm面对空文件不会报错,只是安静地输出,很容易让人以为比较结果为空。实际上如果一侧是全空,另一侧有内容,正确结果是整个非空文件的所有行都属于“独有”。如果用了comm -23 left.txt right.txt,而 left.txt 是空的,输出自然为空,但这可能不是真实结果,而是数据提取步骤没跑成功。
我在脚本里通常加一个文件行数前置检查:
[ -s left.txt ] || { echo "left.txt is empty"; exit 1; }避免空文件混进流程,导致后续决策基于一个假的“无差异”结论。这个问题容易忽略,但一旦发生,影响面比排序错误更可怕,因为空结果看起来太正常了。
5.4 性能定位:卡在 sort 而不是 comm
有时候整体执行很慢,第一反应是comm效率低,实际卡点往往在管道上游的sort。千万行级别的文件排序本来就是个重活,comm自己是线性扫描,几乎不会成为瓶颈。
想要确认到底卡在哪,可以用time分别测sort和comm,或者把sort的输出先落到临时文件,再手动跑comm,就能拆出各环节耗时。另一个经验是把LC_ALL=C设上再跑sort和comm,可以避免 locale 带来的字符比较性能损失,对大文件有明显提升。为什么有效?因为 C locale 按字节直接比较,不用处理 UTF-8 的多字节规则,省掉大量计算开销。
6. 和其他文本比较工具的横向选择
6.1 diff 与 comm:场景完全不一样
很多初学者分不清diff和comm,总觉得都在比较文件。我提供一个不太严格但很好用的理解方式:
diff输出的是“两个文件从文本 A 变成文本 B 需要做哪些修改”;comm输出的是“两个文件在集合意义下各自独有哪些、共有哪些”。
diff适合人工查看两个版本的代码或配置差异;comm适合脚本判断某个元素是否落入特定集合。比如你要判断某个用户是否在允许列表里,comm -12输出不为空就知道匹配了。如果用diff来做这个事,还得解析它的<>和---标记,多此一举。
comm还有一个实用优势是天然适合做“多集合归并判断”,比如三个文件分别求交集,语法上不需要额外标记。而diff的嵌套解析会非常痛苦。
6.2 grep -v -f 与 comm:正确率与性能的取舍
grep -v -F -f right.txt left.txt也可以找出“左边有右边没有”的行,且不要求排序。但问题在于:
grep -F固定字符串匹配时,一个文件里的每一行都作为模式,set 构建内存开销大;- 行内容里包含正则特殊字符时,用
-F能挡住一部分意外,但仍然可能因为模式行中的空字符串或超长字符串产生意外匹配; - 最关键的是,
grep -f没有集合语义,它做的是“模式匹配”,不是“行相等比较”。如果 right.txt 里有nginx,而 left.txt 里有nginx.conf,grep会认为后者“出现过”,但comm会正确地把它当作不同行。
所以结论是:判断两个集合是否完全相等,用comm;判断一段文本是否包含另一个文件的某些关键内容,用grep -f。两者目标不同,不能互相替代。
6.3 join 与 comm:同门师兄弟但侧重不同
join也是老牌排序文件处理命令,它按公共字段把两个文件的行拼接起来,类似于 SQL 里的 INNER JOIN。comm更接近集合运算,join更接近关系表连接。比如两个文件都有用户 ID 和用户名,你想把两边 ID 相同的行合并成一条记录,这是join的活儿;你想知道两边 ID 列表的并集、交集、差集,这是comm的活儿。
实际项目里经常三件套连用:先sort -u规范化,再comm做差集判断,最后join做字段关联。老 Unix 工具的哲学是单一职责、管道协作,组合起来的威力比任何单独一个都大。
6.4 与 awk 实现差集比较的取舍
近年写脚本的人越来越偏爱awk,用NR==FNR模式也能实现差集运算:
awk 'NR==FNR { seen[$0]=1 } NR>FNR && !seen[$0]' right.txt left.txt这个写法的好处是不要求排序、记忆体里建哈希,代码很短。但问题是一旦行数到几十万级,内存占用远高于comm;而且awk的哈希键在行内容过长时,哈希冲突和内存碎片会让速度下滑。comm的地板内存就是两个缓冲区加常量,光这一点就值得在大文件场景优先考虑。
不过awk的灵活性在于可以按字段比较、支持复杂条件,这是comm做不到的。日常小规模文件、字段语义复杂时我选awk;数据量大、行语义纯粹时我选comm。
7. 一点经验收尾
经常有人问我,怎么判断自己是不是真的掌握了一个命令。我的标准是:能不能在不看手册的情况下,准确说出它和三个相似命令的边界,并且知道哪个场景应该用哪一个。
comm给我最大的启发是:排序不是它的缺点,而是它的协议。它把复杂性和职责都推给了上游,自己只做一件简单的事,却在集合运算这个维度上无懈可击。那些觉得它不好用的人,往往是在拿其他工具的语义去强套它。
最后分享一个个人习惯:我所有涉及comm的脚本,第一行一定是set -euo pipefail,并且在执行前先对输入文件做wc -l日志输出。这能帮助判断是数据源的问题还是比较逻辑的问题。这个习惯已经替我挡掉了好几次空文件的“无差异”假象。
comm不像awk、sed那么“编程感”十足,也不像grep那么花哨,但它就是那种每个老系统管理员简历里一定会出现的命令——安静、可靠、招招到肉。