任务发放应该是一个全局的总机,它主要和用户打交道,用来接收用户的请求,然后将用户的请求转换成机器能执行的任务。用户就不需要关心任务是如何发放的,只要将想要抓的网站或内容分配给总机即可。这里些问题需要考虑是:1.如何保证任务能有序的执行。2.保证断电了任务不丢失,来电了任务还能继续在原来的断点处执行。3.当用户如果发了几百万的任务,保证所有主机不崩溃。基于前面的3点,我想到如下解决方案:1.任务有序执行,那给任务做个序号标记。2.任务不丢失,那将任务写到硬盘。3.保证所有任务不会出现峰值执行情况,那可以让抓取机器反馈给主机,说执行完了就在取总机那里拿就好了。考虑到如同的主机分布式的抓数据,肯定是要用消息队列解决任务的方法,这让我自然想到了之前用过rabbitmq,但rabbitmq要保证消息不丢,通常采用的主从备份架构,但由于master-slave架构的缺点能保存的消息量取决于单台主机的容量,结合本次大量图片的下载存储,那不妨用kafka来解决这样的问题。
考虑到我们这个爬虫任务多,针对kafka的一个partition 只能同时被一个consumer消费,同时要保证可靠性,即断电不丢任务,我建立了一个13个partition ,副本数为3的topic;
(图4 topic情况)
因为我将用我那集群的13台机器去抓数据,所有我设置partition为13。然后剩下的就是如何将用户的输入转换成任务了。看会代码。下面是总机的发送任务的方法,它是将坐标转成图像的tile号,我们是要抓取百度地图上的卫星遥感图像,因此涉及到坐标的运算。然后就是记录操作;最后将任务发送到队列中。
/**
* 根据坐标发送位置消息
*/
private def sendPositionsTask = {
//将坐标生成抓取任务的任务号范围,即经度纬度范围
val (titleX, titleY) = PositionUtil.wgs84ToTile(firstLng, firstLat, level)
val (titleX2, titleY2) = PositionUtil.wgs84ToTile(secondLng, secondLat, level)
val satelliteMap = new SatelliteMap(
source,
s"${source}卫星",
source,
s"${source}卫星",
MD5Util.getErrorMD5(source),
MD5Util.getErrorRowkey(source),
Array(new Acquistion(
ack_time,
Array(LevelArea(level,
Array(new Area(new Loc(firstLng, firstLat), new Loc(secondLng, secondLat))))))))
MongoCRUD.updateData(mongo_collection_name, satelliteMap) //将任务的操作记录至mongo,防止重复生成区域坐标任务
AvroProducer.sendMessage(AvroProducer.getPicAvroBeanProducer, source, style, ack_time, level, titleY, titleY2, titleX, titleX2, lngNums)//将任务发送至队列中
}
看下发送任务的代码,下面是将消息封装成ProducerRecord,通过producer发送至topic的队列上。
def sendMessage[K,V](topic:String,producer: Producer[K,V],key:K,msg:V):Unit={
val producerRec:ProducerRecord[K, V] = new ProducerRecord(topic,key,msg)
producer.send(producerRec,new Callback {
override def onCompletion(metadata: RecordMetadata, exception: Exception): Unit = {
// println(s"${metadata.topic()}${metadata.partition()}")
}
})
}
这里需要注意的是因为我们的消息需要在网络中传输,因此kafka需要msg是实现了序列化的对象。这里用avro的工具对对象进行了序列化。
如我的msg对象是这样的:
@SuppressWarnings("all")
@org.apache.avro.specific.AvroGenerated
public class WnsysMapPicBean extends org.apache.avro.specific.SpecificRecordBase implements org.apache.avro.specific.SpecificRecord{
public static final org.apache.avro.Schema SCHEMA$ = new org.apache.avro.Schema.Parser().parse("{\"type\":\"record\",\"name\":\"WnsysMapPicBean\",\"namespace\":\"kafka\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"},{\"name\":\"mapType\",\"type\":\"string\"},{\"name\":\"level\",\"type\":\"int\"},{\"name\":\"latNum\",\"type\":\"int\"},{\"name\":\"lngNum\",\"type\":\"int\"},{\"name\":\"style\",\"type\":\"string\"},{\"name\":\"MD5\",\"type\":\"string\"},{\"name\":\"time\",\"type\":\"string\"},{\"name\":\"pic\",\"type\":\"bytes\"},{\"name\":\"productTablePrefix\",\"type\":\"string\"},{\"name\":\"rowkey\",\"type\":\"string\"},{\"name\":\"position\",\"type\":{\"type\":\"array\",\"items\":{\"type\":\"record\",\"name\":\"Loc\",\"fields\":[{\"name\":\"lat\",\"type\":\"double\"},{\"name\":\"lng\",\"type\":\"double\"}]}}}]}");
然后看下发送者的producer中的配置:
def getHashProducer[T]():Producer[String,T]={
val properties=new Properties
properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, classOf[StringSerializer].getName)
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,classOf[AvroSerializer[T]].getName)
properties.put(ProducerConfig.ACKS_CONFIG, "-1")
// properties.put("producer.type","async")
properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,PropertyParseUtils.getValue("kafka.broker_list").toString)
properties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG,classOf[HashPartition].getName)
new KafkaProducer[String,T](properties)
}
在配置中需要注意数据发送的确认保证,数据序列化和数据在partition 的负载均衡,不可能说我把所有的数据放到一个分区上,其他分区没数据吧。因此在原理对象的基础上封装了一层AvroSerializer[T]
class AvroSerializer[T <: SpecificRecordBase] extends Serializer[T]{
override def configure(configs: util.Map[String, _], isKey: Boolean) = {}
/**
* 序列化成字节数组
* @param topic
* @param data
* @return
*/
override def serialize(topic: String, data: T):Array[Byte] = {
if (data == null){
println(s"producer is null")
return null;
}
val writer=new SpecificDatumWriter[T](data.getSchema)
val outputStream=new ByteArrayOutputStream();
val encoder=EncoderFactory.get().directBinaryEncoder(outputStream,null)
writer.write(data,encoder)
outputStream.toByteArray
}
override def close() = {}
}
然后自己实现一个hashPartition类,使得任务负载均衡那个到每个partititon上,很简单,如下:
public class HashPartition implements Partitioner {
private static int count=0;
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
List partitions=cluster.partitionsForTopic(topic);
int numberP=partitions.size();
if(keyBytes!=null) {
//hash partition方式
/* int hashCode=0;
if (key instanceof Integer || key instanceof Long)
hashCode=Integer.parseInt(key.toString());
else hashCode=key.hashCode();
hashCode=hashCode & 0x7fffffff;
return hashCode%numberP;*/
return count++%numberP;
}else return 0;
}
public void close() {
}
public void configure(Map configs) {
}
}
该类继承了Partitioner,改写里面的partition。我这里偷懒了,直接用一个计数器对partition求余(如果任务太多count会为负数,建议用hash partition 方式)
好了,分布式爬虫系统的任务发送已经完成了。其中,注意点就是要注意任务需要avro序列化处理,ack_config=-1,即保证所有队列发送成才算成功,保证消息可靠的投递;最后做好消息的负载均衡化;partition合适的个数也很重要,太多了会导致zookeeper的负担,太少了又可能导致抓取的任务机器过少,任务不能很好的并行化。下面章节我们看如何接收消息,如何处理任务。