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

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

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

其实整个过程并不复杂,用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对应的点击次数,只要统计他前几个梯次,以及自身梯次里比他大的就行了,如果数量实际比较多,梯次可以精确到个位的。

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