一次搜索优化之旅

背景

有编辑反映全站检索的速度很慢。检查了一下,发现是由于需要检索的内容存在于两个表的title和content字段,两个表有共同的主键ID,查询的时候是left join两个表,再使用like语句去匹配内容。一旦数据比较多就会耗时严重。 直接把sql语句复制出来,在navicat中跑一下,两个表都是2w多的记录,跑下来大概需要10多秒的时间。

第一次优化

从切分出发,如果一次全表left join导致数据太多,那么就切分,使用3000作为步长,每次最多只查询3000条。

  • 获取本次查询最小和最大ID,根据时间判断
  • 根据最小最大ID,按步长切分成多个URL
  • 前端点击“搜索”按钮,同时请求这些URL,然后使用promise.all,在所有请求完毕之后,将结果存在一个数组里面
  • 前端根据ID进行排序,做好分页显示

这样部署了之后发现,根本没效果,请求还是一个一个的发送,完全没有显示并发的功效。 一番查找,原因是:

开启session_start以后,这个session会一直开起,并且被一个用户使用。其他用户开启session的话要等待第一个session用户关闭以后才可以开启session,这样就造成了session阻塞。由于PHP的Session信息是写入文件的,1个客户端占有1个session文件。因此,当 session_start被调用的时候,该文件是被锁住的,而且是以读写模式锁住的(因为程序中可能要修改session的值),这样,第2次调用 session_start的时候就被阻塞了。

解决办法很简单,加上:

session_write_close()
在session_start之后调用

效果很明显,之前十多秒的搜索,目前只需要2-3秒就可以了。一切很完美,收工。 但是,换了一个数据量更多的站点就傻眼了,这个站点的表有40多万数据,请求时间居然需要50多秒了。这是为啥呢?

第二次优化

经过一番查找,原因出在浏览器端:

浏览器对请求数有限制。主流浏览器对同一域名的并发请求数限制大部分都是6。

也就是说,针对40万的数据,我切分了140个请求,虽然我同时将这些请求发出去了,但是浏览器却还是每6个请求发送一次,等这批完成了,再发送下一批请求。这样就导致要20多次才能完成发送,每次的请求需要完成需要近10秒,总共需要50多秒才能搜索完成。这种比之前连表查询速度还慢。

我们没办法去修改浏览器的限制,那问题还是回道从前了,需要减少发送请求的次数,但是还是要切分请求,那出路还得是在后端。将并发的工作放到后端,因为后端是没有请求数限制的,修改之前的步骤:

  • 获取本次查询最小和最大ID,根据时间判断
  • 发送请求至proxy请求
  • proxy请求根据ID,切分成多个URL
  • 使用curl_multi_init、curl_multi_exec等并发请求,并将返回数据进行合并
  • 前端根据返回数据分页显示出来

这样下来既可以避免前端并发限制,也可以提高数据库使用效率。 经过测试,之前50多秒的请求,目前稳定在10秒左右,如果不是全表like,指定时间段就会更快。

代码

前端的promise.all,虽然最终没有用上,但是还是比较优雅的

async function fetchAll(urls) {
	const fetchPromises = urls.map(url => fetch(url));
	const responses = await Promise.all(fetchPromises);
	const dataPromises = responses.map(response => response.json());
	return Promise.all(dataPromises);
}

fetchAll(urls).then(data => {
	let datas = [];
	if(data){
		//合并数组
		for(let i = 0; i < data.length; i++){
			for(let j = 0; j < data[i].length; j++){
				datas.push(data[i][j]);
			}
		}
		//排序
		if(datas.length){
			datas.sort(function(x, y){
			return y.id - x.id;
		});
		searchResult = datas;
		const totalCounts = datas.length;
		const pageSize = 25;
		$('#pages').hide();
		//前端分页,组件使用的是 https://jqpaginator.keenwon.com/
		$('#pages2').jqPaginator({
			totalCounts: totalCounts,
			pageSize: pageSize,
			visiblePages: 10,
			currentPage: 1,
			first: '<li class="first"><a href="javascript:void(0);">首页</a></li>',
			prev: '<li class="prev"><a href="javascript:void(0);">上一页</a></li>',
			next: '<li class="next"><a href="javascript:void(0);">下一页</a></li>',
			last: '<li class="last"><a href="javascript:void(0);">末页</a></li>',
			page: '<li class="page"><a href="javascript:void(0);">{{page}} / {{totalPages}}</a></li>',
			onPageChange: function (num, type) {
				buildResult(num);
				$('#pages2').append('<li class="page"><a href="javascript:void(0);">共'+searchResult.length+'条</a></li>');
			}
		});
		$('#submit').removeAttr('disabled');
	}else{
		$('#pages').hide();
		$('#searchResult').html('');
		$('#submit').removeAttr('disabled');
	}
}).catch(error => {
	console.error('Error fetching data:', error);
});

后端并发请求

$mh = curl_multi_init();
$curlArray = [];
foreach ($urls as $i => $url) {
	$ch = curl_init();
	curl_setopt($ch, CURLOPT_URL, $url);
	curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
	curl_multi_add_handle($mh, $ch);
	$curlArray[$i] = $ch;
}

$running = null;
do {
	curl_multi_exec($mh, $running);
	curl_multi_select($mh);
} while ($running > 0);

$responses = [];

foreach ($curlArray as $i => $ch) {
	$responses[$i] = curl_multi_getcontent($ch);
	curl_multi_remove_handle($mh, $ch);
	curl_close($ch);
}

curl_multi_close($mh);
$data = array();
foreach ($responses as $i => $response) {
	if($response != null){
		$r = json_decode($response, true);
		if(count($r)){
			for($i = 0; $i < count($r); $i++){
				$data[] = $r[$i];
			}
		}
	}
}