大日志处理(附php实现代码)

昨天群里有人问怎么处理超大规模的nginx访问日志(50G),有人回答直接用awk,有的人回答用hadoop,那么到底有谁真的尝试过了呢?

对于小文件,awk自然是十分方便的,一条命令就能得到访问top N的ip,可是很明显内存不够,故这个方法行不通。

对于hadoop,肯定是十分合适的,但是也不是每个公司都会配置hadoop的,既然没有hadoop,我们可不可以自己来实现一个简易版的mapreduce呢?本人昨天还真的试了试。

其实整个过程并不复杂,用fgets扫描整个日志,这个过程是可以开多线程处理的,也可以先切割这个日志文件,然后分到多台电脑上去处理,完了进行一次或多次合并。每扫一行就可以得到一个ip,如果日志不大,自然是可以直接在内存里构建hash表来存储的,但是如果日志大,ip多的话就不行了,我们把ip分为4段,每段当作文件夹名新建4个文件夹,最终的文件是以ip命名的,里面存的就是ip的访问次数,每扫描一次我们去判断一次这个文件是否存在,如果存在就取出这个文件里的文件内容,+1,再覆盖进去,如果不存在,则创建之,内容为1.

这样扫描完了后,得到了一批文件,每个文件存的ip对应的访问数量。现在需要把他们读取出来并排序,然后取出top N就行了,对于取top N,可以采取一个N节点的环形双链表来获得。

当然上述的步骤并非一成不变,可是事实变通,局部可以采取内存硬盘映射而不是纯硬盘处理来加快处理速度,比如最后的排序,我在中间加了一层文件映射文件。我是全部采用php处理的,可以处理,但是过程非常非常慢。

第一步是扫描并归类,我没有分4层,而是把ip转换成一个无符号整数了,然后划了1000个文件夹来存储,文件名就是这个ip转成的整数,代码如下:

<?php

$file = fopen("access.log","r");
function aHash($key) {
    $base = \_\_DIR\_\_;
    $dir = $base.'/storage';
    !is_dir($dir) && mkdir($dir);
    $dir .= '/'.$key%1000;

    !is_dir($dir) && mkdir($dir);
    return $dir.'/'.$key;
}



$arr = array();
while(! feof($file)) {

      preg_match('|\[\\d\]{1,3}(\\.\[\\d\]{1,3}){3}|', fgets($file),$tmp);
      if(empty($tmp\[0\])) continue;
      $key = (int)sprintf("%u", ip2long($tmp\[0\]));
      if(is_file(aHash($key))) {
          $n = file\_get\_contents(aHash($key));
          file\_put\_contents(aHash($key), $n+1);
      } else {
          file\_put\_contents(aHash($key), 1);
      }

  }

fclose($file);

然后我中间多加了一步,把所有文件的地址归到一个文件夹里了

function traverse($path = '.') {
                 $current_dir = opendir($path);
                 while(($file = readdir($current_dir)) !== false) {
                     $sub\_dir = $path . DIRECTORY\_SEPARATOR . $file;
                     if($file == '.' || $file == '..') {
                         continue;
                     } else if(is\_dir($sub\_dir)) {    //如果是目录,进行递归

                         traverse($sub_dir);
                     } else {
                          file\_put\_contents(\_\_DIR\_\_.'/storage/dir\_list',$path.'/'.$file."\\n",FILE\_APPEND);

                     }
                 }
             }

//此时生成了一批文件,每个文件文件名就是ip(整数形式),然后遍历每个文件夹,把数据又收归一起 traverse(\_\_DIR\_\_.'/storage');

接下来要做的,就是根据这个dir_list文件里的地址,分别把每个ip和对应的访问次数找出来,分到一个新的文件里,其实这一步是不必要的,可以直接进行分堆排序,但是我还是多分了这一步,

$file = fopen(\_\_DIR\_\_.'/storage/dir_list',"r");
while(! feof($file)) {
    $path = trim(fgets($file));
    if(!is_file($path)) {
        //echo $path,"-\\n";
        continue;
    }
    $iplong = pathinfo($path,PATHINFO_BASENAME);
    $n = file\_get\_contents($path);
    file\_put\_contents(\_\_DIR\_\_.'/storage/result',$iplong.'_'.$n."\\n",FILE_APPEND);
}
fclose($file);

到现在,ip对应的访问次数就在一个文件夹里了,我们对他们进行排序,然后取出top N就行了,但是既然是取top N,就没必要排序了,我用php模拟的双向循环链表,节点数就是N。代码如下:

function show($head) {
    $curr = $head;
    $arr = array();
    do {
        $arr\[\] =  $curr->n;
        $curr = $curr->next;
    } while($curr->sign != $head->sign);
    return implode('-', $arr);
    //echo "\\n";
}
$head = new stdClass();
$head->ip = 0;
$head->n = 0;
$head->next = null;
$head->pre = null;
$head->sign = uniqid();
$last = $head;
for($i = 1;$i <= 9;$i++) {
    $new = new stdClass();
    $new->ip = 0;
    $new->n = 0;
    $new->next =  $head;
    $new->pre = $last;
    $new->sign = uniqid();
    $last->next = $new;
    $head->pre = $new;
    $last = $new;

}
//show($head);
//
//

//
//$curr = $head;



$file = fopen(\_\_DIR\_\_.'/storage/result',"r");
while(! feof($file)) {
    $str = fgets($file);
    if(empty($str)) {
        continue;
    }
    $arr  = explode('_', $str);
    $iplong = (int)$arr\[0\];
    $num = (int)$arr\[1\];


    //如果比尾节点都小,肯定舍去咯
    if($num <= $head->pre->n) {
        echo '-\[舍去'.$num.'\]'.show($head)."\\n";
        continue;
    }

    //如果比头节点大,则将头指针指向尾指针,然后把尾指针当成头指针,
    if($num >= $head->n) {
        $head =  $head->pre;
        $head->n = $num;
        $head->ip = $iplong;
        echo 'O\[轮转'.$num.'\]'.show($head)."\\n";
        continue;
    }
    //剩下的必然是在中间咯,这个情况好复杂啊......按道理讲,应该是插入,然后舍去尾巴,但是php申请内存malloc也是有消耗的,这样就不必了吧。我们先把尾巴拿出来,然后插入指定的位置。
    $tmp = $head->pre;
    $tmp->n = $num;
    $tmp->ip = $iplong;

    $head->pre = $tmp->pre;
    $tmp->pre->next =   $head;
    echo  '=状态\['.$num.'\]'.show($head)."\\n";


    $curr =  $head->next;
    $status = 1;
    do {

        if($num >= $curr->n) {
            $tmp->pre = $curr->pre;
            $tmp->next = $curr;


            $curr->pre->next = $tmp;
            $curr->pre = $tmp;
            echo '+插入\['.$num.'\]'.show($head)."\\n";
            $status = 0;


        }
        $curr = $curr->next;
    } while($status && $curr->sign != $head->sign);







   //如果循环完了,临时的那个节点还没归位,则需要把它放到尾部
   if($status === 1) {
       echo  '$归尾\[前\]'.show($head)."\\n";

    echo  '$归尾\['.$num.'\]'.show($head)."\\n";
   }

}
fclose($file);
    $curr = $head;
    $arr = array();
    do {
        echo long2ip($curr->ip).'---------'. $curr->n."\\n";
        $curr = $curr->next;
    } while($curr->sign != $head->sign);

我中间加了几个处理过程的说明,主要是为了调试,实际可以去掉。理论上,这个是可以处理很大的文件的,但是我刚才试了一个4G的日志文件,真尼玛慢啊,主要是第一步,太慢了,实际处理,这个应该先切割,然后开多进程处理甚至多pc处理。题外话,如果要获取某个指定的ip的访问次数咋办呢?那么我们就不能分堆了,我们得分梯次了。我们依次建立几个文件,100,1000,10000,1000000...,然后根据访问次数归类到不同的梯次文件里,然后我们根据一个ip对应的点击次数,只要统计他前几个梯次,以及自身梯次里比他大的就行了,如果数量实际比较多,梯次可以精确到个位的。

以上只是纯文件处理,如果实际内存比较大的话,有部分操作可以在内存操作,这样速度会大大加快。