使用 Unix 工具的批处理
我们从一个简单的例子开始。假设您有一台 Web 服务器,每次处理请求时都会在日志文件中附加一行。例如,使用 nginx 默认访问日志格式,日志的一行可能如下所示:
216.58.210.78 - - [27/Feb/2015:17:55:11 +0000] "GET /css/typography.css HTTP/1.1"
200 3377 "http://martin.kleppmann.com/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5)
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 Safari/537.36"
(实际上这只是一行,分成多行只是为了便于阅读。)这一行中有很多信息。为了解释它,你需要了解日志格式的定义,如下所示:
$remote_addr - $remote_user [$time_local] "$request"
$status $body_bytes_sent "$http_referer" "$http_user_agent"
日志的这一行表明在 2015 年 2 月 27 日 17:55:11 UTC,服务器从客户端 IP 地址 216.58.210.78
接收到对文件 /css/typography.css
的请求。用户没有被认证,所以 $remote_user
被设置为连字符( -
)。响应状态是 200(即请求成功),响应的大小是 3377 字节。网页浏览器是 Chrome 40,URL http://martin.kleppmann.com/
的页面中的引用导致该文件被加载。
分析简单日志
很多工具可以从这些日志文件生成关于网站流量的漂亮的报告,但为了练手,让我们使用基本的 Unix 功能创建自己的工具。 例如,假设你想在你的网站上找到五个最受欢迎的网页。 则可以在 Unix shell 中这样做:i
i. 有些人认为 cat
这里并没有必要,因为输入文件可以直接作为 awk 的参数。 但这种写法让线性管道更为显眼。 ↩
cat /var/log/nginx/access.log | #1
awk '{print $7}' | #2
sort | #3
uniq -c | #4
sort -r -n | #5
head -n 5 #6
- 读取日志文件
- 将每一行按空格分割成不同的字段,每行只输出第七个字段,恰好是请求的 URL。在我们的例子中是
/css/typography.css
。 - 按字母顺序排列请求的 URL 列表。如果某个 URL 被请求过 n 次,那么排序后,文件将包含连续重复出现 n 次的该 URL。
uniq
命令通过检查两个相邻的行是否相同来过滤掉输入中的重复行。-c
则表示还要输出一个计数器:对于每个不同的 URL,它会报告输入中出现该 URL 的次数。- 第二种排序按每行起始处的数字(
-n
)排序,这是 URL 的请求次数。然后逆序(-r
)返回结果,大的数字在前。 - 最后,只输出前五行(
-n 5
),并丢弃其余的。该系列命令的输出如下所示:
4189 /favicon.ico
3631 /2013/05/24/improving-security-of-ssh-private-keys.html
2124 /2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html
1369 /
915 /css/typography.css
如果你不熟悉 Unix 工具,上面的命令行可能看起来有点吃力,但是它非常强大。它能在几秒钟内处理几 GB 的日志文件,并且您可以根据需要轻松修改命令。例如,如果要从报告中省略 CSS 文件,可以将 awk 参数更改为 '$7 !~ /\.css$/ {print $7}'
,如果想统计最多的客户端 IP 地址,可以把 awk 参数改为 '{print $1}'
等等。
我们不会在这里详细探索 Unix 工具,但是它非常值得学习。令人惊讶的是,使用 awk,sed,grep,sort,uniq 和 xargs 的组合,可以在几分钟内完成许多数据分析,并且它们的性能相当的好【8】。
命令链与自定义程序
除了 Unix 命令链,你还可以写一个简单的程序来做同样的事情。例如在 Ruby 中,它可能看起来像这样:
counts = Hash.new(0) # 1
File.open('/var/log/nginx/access.log') do |file|
file.each do |line|
url = line.split[6] # 2
counts[url] += 1 # 3
end
end
top5 = counts.map{|url, count| [count, url] }.sort.reverse[0...5] # 4
top5.each{|count, url| puts "#{count} #{url}" } # 5
counts
是一个存储计数器的哈希表,保存了每个 URL 被浏览的次数,默认为 0。- 逐行读取日志,抽取每行第七个被空格分隔的字段为 URL(这里的数组索引是 6,因为 Ruby 的数组索引从 0 开始计数)
- 将日志当前行中 URL 对应的计数器值加一。
- 按计数器值(降序)对哈希表内容进行排序,并取前五位。
- 打印出前五个条目。
这个程序并不像 Unix 管道那样简洁,但是它的可读性很强,喜欢哪一种属于口味的问题。但两者除了表面上的差异之外,执行流程也有很大差异,如果你在大文件上运行此分析,则会变得明显。
排序 VS 内存中的聚合
Ruby 脚本在内存中保存了一个 URL 的哈希表,将每个 URL 映射到它出现的次数。 Unix 管道没有这样的哈希表,而是依赖于对 URL 列表的排序,在这个 URL 列表中,同一个 URL 的只是简单地重复出现。
哪种方法更好?这取决于你有多少个不同的 URL。对于大多数中小型网站,你可能可以为所有不同网址提供一个计数器(假设我们使用 1GB 内存)。在此例中,作业的 工作集(working set) (作业需要随机访问的内存大小)仅取决于不同 URL 的数量:如果日志中只有单个 URL,重复出现一百万次,则散列表所需的空间表就只有一个 URL 加上一个计数器的大小。当工作集足够小时,内存散列表表现良好,甚至在性能较差的笔记本电脑上也可以正常工作。
另一方面,如果作业的工作集大于可用内存,则排序方法的优点是可以高效地使用磁盘。这与我们在 SSTables 和 LSM 树 中讨论过的原理是一样的:数据块可以在内存中排序并作为段文件写入磁盘,然后多个排序好的段可以合并为一个更大的排序文件。 归并排序具有在磁盘上运行良好的顺序访问模式。 (请记住,针对顺序 I/O 进行优化是 第 3 章 中反复出现的主题,相同的模式在此重现)
GNU Coreutils(Linux)中的 sort
程序通过溢出至磁盘的方式来自动应对大于内存的数据集,并能同时使用多个 CPU 核进行并行排序【9】。这意味着我们之前看到的简单的 Unix 命令链很容易扩展到大数据集,且不会耗尽内存。瓶颈可能是从磁盘读取输入文件的速度。
Unix 哲学
我们可以非常容易地使用前一个例子中的一系列命令来分析日志文件,这并非巧合:事实上,这实际上是 Unix 的关键设计思想之一,且它今天仍然令人讶异地关联。让我们更深入地研究一下,以便从 Unix 中借鉴一些想法【10】。
Unix 管道的发明者道格·麦克罗伊(Doug McIlroy)在 1964 年首先描述了这种情况【11】: 当我们需要将消息从一个程序传递另一个程序时,我们需要一种类似水管法兰的拼接程序的方式【a】 ,I/O 应该也按照这种方式进行 。水管的类比仍然在生效,通过管道连接程序的想法成为了现在被称为 Unix 哲学 的一部分 —— 这一组设计原则在 Unix 用户与开发者之间流行起来,该哲学在 1978 年表述如下【12,13】:
- 让每个程序都做好一件事。要做一件新的工作,写一个新程序,而不是通过添加 功能 让老程序复杂化。
- 期待每个程序的输出成为另一个程序的输入。不要将无关信息混入输出。避免使用严格的列数据或二进制输入格式。不要坚持交互式输入。
- 设计和构建软件,甚至是操作系统,要尽早尝试,最好在几周内完成。不要犹豫,扔掉笨拙的部分,重建它们。
- 优先使用工具来减轻编程任务,即使必须曲线救国编写工具,且在用完后很可能要扔掉大部分。
这种方法 —— 自动化,快速原型设计,增量式迭代,对实验友好,将大型项目分解成可管理的块 —— 听起来非常像今天的敏捷开发和 DevOps 运动。奇怪的是,四十年来变化不大。
sort
工具是一个很好的例子。可以说它比大多数编程语言标准库中的实现(即使有很大好处,也不会溢出到磁盘或使用多线程)要更好。然而,单独使用 sort
几乎没什么用。它只能与其他 Unix 工具(如 uniq
)结合使用。
像 bash
这样的 Unix shell 可以让我们轻松地将这些小程序组合成令人讶异的强大数据处理任务。尽管这些程序中有很多是由不同人群编写的,但它们可以灵活地结合在一起。 Unix 如何实现这种可组合性?
统一的接口
如果你希望一个程序的输出成为另一个程序的输入,那意味着这些程序必须使用相同的数据格式 —— 换句话说,一个兼容的接口。如果你希望能够将任何程序的输出连接到任何程序的输入,那意味着所有程序必须使用相同的 I/O 接口。
在 Unix 中,这种接口是一个 文件(file) (更准确地说,是一个文件描述符)。一个文件只是一串有序的字节序列。因为这是一个非常简单的接口,所以可以使用相同的接口来表示许多不同的东西:文件系统上的真实文件,到另一个进程(Unix 套接字,stdin,stdout)的通信通道,设备驱动程序(比如 /dev/audio
或 /dev/lp0
),表示 TCP 连接的套接字等等。很容易将这些设计视为理所当然的,但实际上能让这些差异巨大的东西共享一个统一的接口是非常厉害的,这使得它们可以很容易地连接在一起ii 。
ii. 统一接口的另一个例子是 URL 和 HTTP,这是 Web 的基石。 一个 URL 标识一个网站上的一个特定的东西(资源),你可以链接到任何其他网站的任何网址。 具有网络浏览器的用户因此可以通过跟随链接在网站之间无缝跳转,即使服务器可能由完全不相关的组织维护。 这个原则现在似乎非常明显,但它却是网络取能取得今天成就的关键。 之前的系统并不是那么统一:例如,在公告板系统(BBS)时代,每个系统都有自己的电话号码和波特率配置。 从一个 BBS 到另一个 BBS 的引用必须以电话号码和调制解调器设置的形式;用户将不得不挂断,拨打其他 BBS,然后手动找到他们正在寻找的信息。 这是不可能的直接链接到另一个 BBS 内的一些内容。 ↩
按照惯例,许多(但不是全部)Unix 程序将这个字节序列视为 ASCII 文本。我们的日志分析示例使用了这个事实: awk
, sort
, uniq
和 head
都将它们的输入文件视为由 \n
(换行符,ASCII 0x0A
)字符分隔的记录列表。 \n
的选择是任意的 —— 可以说,ASCII 记录分隔符 0x1E
本来就是一个更好的选择,因为它是为了这个目的而设计的【14】,但是无论如何,所有这些程序都使用相同的记录分隔符允许它们互操作。
每条记录(即一行输入)的解析则更加模糊。 Unix 工具通常通过空白或制表符将行分割成字段,但也使用 CSV(逗号分隔),管道分隔和其他编码。即使像 xargs
这样一个相当简单的工具也有六个命令行选项,用于指定如何解析输入。
ASCII 文本的统一接口大多数时候都能工作,但它不是很优雅:我们的日志分析示例使用 {print $7}
来提取网址,这样可读性不是很好。在理想的世界中可能是 {print $request_url}
或类似的东西。我们稍后会回顾这个想法。
尽管几十年后还不够完美,但统一的 Unix 接口仍然是非常出色的设计。没有多少软件能像 Unix 工具一样交互组合的这么好:你不能通过自定义分析工具轻松地将电子邮件帐户的内容和在线购物历史记录以管道传送至电子表格中,并将结果发布到社交网络或维基。今天,像 Unix 工具一样流畅地运行程序是一种例外,而不是规范。
即使是具有 相同数据模型 的数据库,将数据从一种导出再导入另一种也并不容易。缺乏整合导致了数据的 巴尔干化译注 i 。
译注 i. 巴尔干化(Balkanization) 是一个常带有贬义的地缘政治学术语,其定义为:一个国家或政区分裂成多个互相敌对的国家或政区的过程。 ↩
逻辑与布线相分离
Unix 工具的另一个特点是使用标准输入( stdin
)和标准输出( stdout
)。如果你运行一个程序,而不指定任何其他的东西,标准输入来自键盘,标准输出指向屏幕。但是,你也可以从文件输入和/或将输出重定向到文件。管道允许你将一个进程的标准输出附加到另一个进程的标准输入(有个小内存缓冲区,而不需要将整个中间数据流写入磁盘)。
程序仍然可以直接读取和写入文件,但如果程序不担心特定的文件路径,只使用标准输入和标准输出,则 Unix 方法效果最好。这允许 shell 用户以任何他们想要的方式连接输入和输出;该程序不知道或不关心输入来自哪里以及输出到哪里。 (人们可以说这是一种 松耦合(loose coupling) , 晚期绑定(late binding) 【15】或 控制反转(inversion of control) 【16】)。将输入/输出布线与程序逻辑分开,可以将小工具组合成更大的系统。
你甚至可以编写自己的程序,并将它们与操作系统提供的工具组合在一起。你的程序只需要从标准输入读取输入,并将输出写入标准输出,它就可以加入数据处理的管道中。在日志分析示例中,你可以编写一个将 Usage-Agent 字符串转换为更灵敏的浏览器标识符,或者将 IP 地址转换为国家代码的工具,并将其插入管道。 sort
程序并不关心它是否与操作系统的另一部分或者你写的程序通信。
但是,使用 stdin
和 stdout
能做的事情是有限的。需要多个输入或输出的程序是可能的,但非常棘手。你没法将程序的输出管道连接至网络连接中【17,18】iii 。如果程序直接打开文件进行读取和写入,或者将另一个程序作为子进程启动,或者打开网络连接,那么 I/O 的布线就取决于程序本身了。它仍然可以被配置(例如通过命令行选项),但在 Shell 中对输入和输出进行布线的灵活性就少了。
iii. 除了使用一个单独的工具,如netcat
或curl
。 Unix 开始试图将所有东西都表示为文件,但是 BSD 套接字 API 偏离了这个惯例【17】。研究用操作系统 Plan 9 和 Inferno 在使用文件方面更加一致:它们将 TCP 连接表示为/net/tcp
中的文件【18】。 ↩
透明度和实验
使 Unix 工具如此成功的部分原因是,它们使查看正在发生的事情变得非常容易:
- Unix 命令的输入文件通常被视为不可变的。这意味着你可以随意运行命令,尝试各种命令行选项,而不会损坏输入文件。
- 你可以在任何时候结束管道,将管道输出到
less
,然后查看它是否具有预期的形式。这种检查能力对调试非常有用。 - 你可以将一个流水线阶段的输出写入文件,并将该文件用作下一阶段的输入。这使你可以重新启动后面的阶段,而无需重新运行整个管道。
因此,与关系数据库的查询优化器相比,即使 Unix 工具非常简单,但仍然非常有用,特别是对于实验而言。
然而,Unix 工具的最大局限在于它们只能在一台机器上运行 —— 而 Hadoop 这样的工具即应运而生。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论