【程序设计】11.1 [大数据] 大数据和游标
11.1 大数据和游标
我们这一章来讲如何处理大数据。
所谓大数据,在这里是指内存中装不下的数据,一般会在文件形式存放在硬盘上,或者存在数据库中(也是硬盘里),本书不涉及数据库,只讲述文件中的大数据。
Excel 文件一般不会太大(它有最大行数的限制),大文件通常是前面说过的 txt 或 csv 格式。按现代计算机的内存算,大概要数千万行甚至上亿行(记录数量)的数据才能叫大,文件大小也会有几个 G 或几十个 G 或更多。
其实我们在日常桌面工作中很少碰到这么大的数据量,所以前面讲过的运算方法在大多数场景都是够用的。不过,作为完整的图书,我们仍然介绍一下大数据的处理方法。
大数据和我们前面处理的数据有什么不同吗?前面学过的方法不能用了吗?
前面章节讲过的处理方法,都是在内存中的数据。而用到的文件数据,也是读入到内存中之后再来处理的。
因为 CPU 只能直接处理内存中的数据,不能直接处理硬盘以及其它非内存的数据。
那能不能这么办:把硬盘上的数据都编个地址,要用的时候临时从硬盘中读进内存,用完了扔掉,要改变就再写回硬盘,这样看起来就和直接操作一大片内存一样了。
理论上是可以的,确实也有某些场景下的程序是这么做的(Windows 有个系统缓存就在干这件事)。但结构化数据运算很难这么做。
那是因为内存和硬盘有什么本质的不同吗?
是的,用 IT 界的术语来讲,内存可以随机小量访问,也就是随便给一片内存的地址就能够取出这个地址下的数据。比如我们在使用序列时,可以随意用个序号访问任何成员。
而硬盘不可以。
硬盘更适合连续批量访问,它只能整块的读写,每次至少一个最小单位(具体大小和操作系统相关,现在计算机大都是 4096 字节,字节是用来衡量数据占用空间大小的单位,不懂没关系,不影响理解),只读取一个整数(只有 4 或 8 字节,如果压缩了会更小)也必须把整块都读进来。如果大量读取各种地址的小量数据(访问很多处,每处很小数据量,这其实是很常见的情况),实质上被读出的数据量却非常大,导致性能很差。古老一些的机械硬盘也无法支持高频度的随机访问(即使读整块),性能下降极为严重。
因为这些麻烦事,对于结构化数据运算的场景,也就没有人把硬盘模拟成内存了。而且,更麻烦的是,操作系统也没有为结构化数据做什么专门的工作,对硬盘上的数据也就是读写文件,而操作文件是个非常复杂的事情,这又会进一步降低性能。
那怎么办?
我们不能再用以前的办法直接去访问硬盘上的结构化数据,通常使用游标技术来完成运算。
打开一个数据文件,游标就像一个指针先指向这个文件的第一条记录,然后可以读取若干条到内存中处理,读取过程中游标指针也会向后移动,在内存处理完之后丢掉,再从游标当前的位置再读取下一批来处理,…,如果反复直到把所有数据都读入处理过一遍。
因为使用游标每次都会读入一批数据,通常会占用多个硬盘的块,这样实际上就是批量连续的访问,硬盘的困难就被避开了。
不过,由于 txt,csv 等大多数数据文件的一些结构特殊性,游标通常只能向后移动,而不能向前移动(向前移动的成本很高),即使向后也不能跳跃,只能一条一条遍历。也就是说,用游标访问数据已经失去了随机性。
所以前面讲过的运算方法很多都不能用了,我们要为游标专门设计一套函数。
下面我们就来讲游标相关的函数。
为了做实验,我们先来生成一个大文件,一个简化的订单表。
A |
B |
|
1 |
[East,West,North,South,Center] |
|
2 |
for 0,999 |
=10000.new(A2*10000+~:id,A1(rand(5)+1):area,date(now())-1000+A2:dt,1+rand(10000):amount,1+rand(100):quantity) |
3 |
=B2.select(rand()>=0.01) |
|
4 |
>file("data.txt").export@at(B3) |
export 函数将把序表写入文件,@a 选项表示追加写入,这样文件会被追加 1000 次,每次写入不到 1 万条记录(在 B3 会随机舍弃一部分),总共不到 1000 万条记录。@t 表示文件第一行把字段名作为标题写入,和 xlsimport 中的 @t 是一样的意思。
id 字段可以充当主键,但文件数据中不能真地建立出主键。dt 是日期,每次生成的数据都是同一天的,并且随循环变量 A2 变大,这样能保证日期是有序的(后面会利用到),并且要把 now() 的时间信息丢弃掉。amount 和 quantity 则随意生成即可。
注意执行之前要把原有的 data.txt 删除,否则会在当前文件后继续追加一批数据。另外,再强调一下,实际使用时最好加上文件的绝对路径,不然可能因为当前路径的不确定而找不到这个文件写到哪里去了。
我们先来数一下这个文件中总共有多少条记录。
A |
B |
C |
|
1 |
=0 |
=file("data.txt").cursor@t() |
|
2 |
for |
=B1.fetch(10000) |
|
3 |
if B2==null |
break |
|
4 |
>A1+=B2.len() |
cursor 函数将打开文件创建一个游标(也是个复杂对象),@t 表示文件的第一行是标题,游标的 fetch 函数将读出指定数量的记录构成的序表(以标题为字段名),并向后移动,下次再执行 fetch 就是下一批记录,如果读的过程中碰到文件结束,那能读多少就算多少,所以不一定总能读出期望数量的记录。到了文件尾再也读不出来时,就会返回 null。
了解上面的知识,这段代码就很容易看懂了。做个死循环,每次读 1 万条记录,读完了就跳出循环,过程中把每次读到的记录数加起来就是结果了,存在 A1 中。
在这个基础上再继续,我们来计算一下平均订单金额:
A |
B |
C |
|
1 |
=0 |
=file("data.txt").cursor@t() |
=0 |
2 |
for |
=B1.fetch(10000) |
|
3 |
if B2==null |
break |
|
4 |
>A1+=B2.len() |
||
5 |
>C1+=B2.sum(amount) |
||
6 |
=C1/A1 |
可以在同一个循环中把合计也算出来。
计算合计,其实只要读出 amount 字段,而数个数更是随便读一个字段就够了,没必要把所有字段都读出来,能少读一点速度也会快一点(从前面讨论可以知道,从硬盘上读取的数据量几乎是一样的,但少读几个字段会让文件读取的处理简单些):
A |
B |
C |
|
1 |
=0 |
=file("data.txt").cursor@t(amount) |
=0 |
2 |
for |
=B1.fetch(10000) |
|
3 |
if B2==null |
break |
|
4 |
>A1+=B2.len() |
||
5 |
>C1+=B2.sum(amount) |
||
6 |
=C1/A1 |
可以在 cursor 函数的参数中写上要读出的字段,这样 fetch 出来的序表就会只有少量几个字段,用不着的字段不必读入,这是外存计算和内存计算的一个重要差别,内存计算时我们从来不关注这个问题。
这种死循环的结构是个固定模式的,每次都写有点麻烦,而且还可能发生忘了写或写错 B3 的情况就真地变成死循环了。SPL 其实支持直接针对游标做循环:
A |
B |
C |
|
1 |
=0 |
=file("data.txt").cursor@t(amount) |
=0 |
2 |
for B1,10000 |
>A1+=A2.len() |
|
3 |
>C1+=A2.sum(amount) |
||
4 |
=C1/A1 |
for 语句可以针对游标进行循环,后面跟着整数就是每次循环读出来的记录数,而循环变量就是读入的序表,读不出数据时循环会自然停止,这样就不用再写 fetch 和结束条件了,代码更简单了。
用这种循环的方式,把游标中的数据(其实是用来创建游标的文件中的数据,我们后面会经常使用这个说法)依次读取一遍做运算的过程,称为游标的遍历。再回顾之前讲的规则,游标遍历只能从前向后,而且只能遍历一次,游标中所有数据都读取过一次之后,游标遍历就结束了,这个游标也就没用了, 不能再读出数据了。要再从头读出数据,得重新创建一个新的游标。