初识Apache Seatunnel

1,472 阅读8分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 4 天,点击查看活动详情


数据集成工具的介绍

Datax

数据集成是数据平台必不可少的一个功能,最开始接触数据集成的时候使用的集成工具是DataX。基于其设计理念,我们可以很灵活的进行异构数据源同步,并且可以通过插件的形式扩展自定义的Reader和Writer。

开源版的Datax是单机版的,在启动后可以启动多个线程去并发执行task。其一个task由一个reader和一个writer组成,之间是基于内存的channel,可以看做一个生产消费的模型。

一般我们在开发Datax插件的过程,除了完成基本的连接数据源读取写入功能之外,还需要考虑如何对reader进行切分,因为只有切分出多个reader,最终才会产生多个task,才能有机会提高其并行度。如mysqlReader插件中可以对表设置splitK,可以将读取一个表的Select语句按范围拆分成多个Select,最终可以产生多个task多线程执行。

关于DataX这里就不过多介绍了,网上的资料也很丰富。

基于Spark自研的ETL平台

后续在另一家公司工作,发现其数据集成不是基于DataX来实现的,而是基于Spark自研的一套。开始对这一套体系了解不多的情况下,还在想为什么不直接用DataX呢。但是在后续进行开发的过程中也渐渐体会到了其优势。

当时我们是基于Spark和SparkStreaming开发了一套可视化的ETL平台。借助Spark自身集成的数据源以及对其进行自定义的外部数据源的开发支持了多种数据源的读取和写入。

整体的思路就是基于spark.read读取数据源,获取到DataFrame。有了DataFrame之后就可以通过DataFrame的Api或者转成SQL进行各种清洗转换的操作,最近在写入到目标数据源。

并且我们将其和springboot进行了整合,即使是离线的spark任务变成了一个长服务,可以不停的处理页面提交的各种算子。

更详细的介绍就不多说了毕竟不是开源项目。

Apache Seatunnel

刚刚发现这个项目的时候,会发现基于Spark引擎的模式和之前公司的思路基本一致。但是Seatunnel的功能更加的丰富,在比较新的版本中除了Spark之外同时还支持Flink、和其自研的Seatunnel Engine三种引擎。其官方的架构图如下:

通过官方的架构图,可以看到Source、Transform、Sink默认运行在Seatunnel Engine上,可以通过Translation将其进行运行到Spark Engine或者Flink Engine上。并且Source、Transform、Sink都是一套代码。我们更多的是需要关注如何使用这些Source、Transform、Sink和如何对其扩展。

下面我们就通过一个入门dmeo来看看如何使用吧。

Kafka集成数据到Console

Seatunnel版本基于2.3.0

这里我们在本地运行,首先需要找到官方自身的example,我这里选择基于spark的seatunnel-spark-connector-v2-example模块。

1-启动kafka

首先启动Kafka,并且打开其命令行的生产者和消费者,用于发布消息和查看。

创建topic
./kafka-topics.sh --create --topic test_seatunnel --replication-factor 1 --partitions 2 --zookeeper localhost:2181

./kafka-topics.sh --list  --zookeeper localhost:2181

./kafka-topics.sh --zookeeper  localhost:2181 --describe --topic test_seatunnel

生产和消费
./kafka-console-consumer.sh --topic test_seatunnel --from-beginning --bootstrap-server localhost:9092
./kafka-console-producer.sh --broker-list localhost:9092 --topic test_seatunnel

2-新增Seatunnel配置文件

spark-kafka-console.conf

env {
  # You can set spark configuration here
  # see available properties defined by spark: https://spark.apache.org/docs/latest/configuration.html#available-properties
  #job.mode = BATCH
  job.mode = "STREAMING"
  job.name = "SeaTunnel"
  spark.executor.instances = 1
  spark.executor.cores = 1
  spark.executor.memory = "1g"
  spark.master = local
}

source {
  Kafka {
    parallelism = 2 
    result_table_name = "kafka_name"
    schema = {
      fields {
        name = "string"
        age = "int"
      }
    }
    format = text
    field_delimiter = "#"
    topic = "test02"
    bootstrap.servers = "localhost:9092"
   }
}


sink {
  Console {
    parallelism = 1
  }
}

在配置文件的source中配置kafka相关的信息,地址,topic,格式,这里是text,并且配置了如何解析,已经解析后的schema是什么。

3-运行main函数

public static void main(String[] args) throws FileNotFoundException, URISyntaxException, CommandException {
	String configurePath = args.length > 0 ?  args[0] : "/examples/spark-kafka-console.conf";
	ExampleUtils.builder(configurePath);
}

这里启动后,因为我们的配置job.mode = "STREAMING"所以,程序会一直启动并接收kafka的消息。我们可以直接通过命令行发送消息,通过这里看日志的打印。

4-基本原理

基于spark engine对上面的配置进行执行的时候,最终会走到SparkExecution中

@Override
public void execute() throws TaskExecuteException {
	List<Dataset<Row>> datasets = new ArrayList<>();
	datasets = sourcePluginExecuteProcessor.execute(datasets);
	datasets = transformPluginExecuteProcessor.execute(datasets);
	sinkPluginExecuteProcessor.execute(datasets);

	log.info("Spark Execution started");
}

可以看到source、transform、sink都是通过Dataset来建立联系。具体到source的实现:

@Override
public List<Dataset<Row>> execute(List<Dataset<Row>> upstreamDataStreams) {
	List<Dataset<Row>> sources = new ArrayList<>();
	for (int i = 0; i < plugins.size(); i++) {
		SeaTunnelSource<?, ?, ?> source = plugins.get(i);
		Config pluginConfig = pluginConfigs.get(i);
		int parallelism;
		if (pluginConfig.hasPath(SourceCommonOptions.PARALLELISM.key())) {
			parallelism = pluginConfig.getInt(SourceCommonOptions.PARALLELISM.key());
		} else {
			parallelism = sparkEnvironment.getSparkConf().getInt(EnvCommonOptions.PARALLELISM.key(), EnvCommonOptions.PARALLELISM.defaultValue());
		}
		Dataset<Row> dataset = sparkEnvironment.getSparkSession()
			.read()
			.format(SeaTunnelSource.class.getSimpleName())
			.option(SourceCommonOptions.PARALLELISM.key(), parallelism)
			.option(Constants.SOURCE_SERIALIZATION, SerializationUtils.objectToString(source))
			.schema((StructType) TypeConverterUtils.convert(source.getProducedType())).load();
		sources.add(dataset);
		registerInputTempView(pluginConfigs.get(i), dataset);
	}
	return sources;
}

可以发现其是基于spark外部数据源来接入各个connector中的SeaTunnelSource。我们配置的kafka对应了KafkaSource。并且dataset也会注册为一张临时表。

具体spark外部数据源相关的源码大家可以先去查看translation模块的实现。

大体的逻辑就是每个分区,都会创建一个PartitionReader,里面维护了Parallelism和subtaskId。spark会根据配置的并行度来产生对应数量的task,source这里对应PartitionReader,每个Reader会创建其自己的SplitEnumerator和EnumeratorContext。SplitEnumerator中维护了需要进行分片的信息。然后通过SplitEnumerator中的assignSplit方法来分配读取的任务。

以Kafka为例,SplitEnumerator会维护其所有的分区信息。假设topic有两个分区。如果我们设置了只有一个parallelism,就只会产生一个PartitionReader,那么两个partition都会由一个PartitionReader来消费。如果设置parallelism是2,那么spark会产生两个task对应两个PartitionReader,根据每个PartitionReader的subtaskId为其分配自己应该消费的分区数据。

具体源码参考KafkaSourceSplitEnumerator

private synchronized void assignSplit() {
	Map<Integer, List<KafkaSourceSplit>> readySplit = new HashMap<>(Common.COLLECTION_SIZE);
	for (int taskID = 0; taskID < context.currentParallelism(); taskID++) {
                //readySplit维护所有taskId所对应的kafka分区信息集合                
		readySplit.computeIfAbsent(taskID, id -> new ArrayList<>());
	}

	pendingSplit.entrySet().forEach(s -> {
		if (!assignedSplit.containsKey(s.getKey())) {
                        //分配其应该属于哪个 taskID                        
			readySplit.get(getSplitOwner(s.getKey(), context.currentParallelism()))
				.add(s.getValue());
		}
	});

        //真实调用reader进行分片的逻辑。每个reader只获取和自已一样的taskID对应的分片信息
	readySplit.forEach(context::assignSplit);

	assignedSplit.putAll(pendingSplit);
	pendingSplit.clear();
}

@Override
public void assignSplit(int subtaskId, List<SplitT> splits) {
	if (this.subtaskId == subtaskId) {
		parallelSource.addSplits(splits);
	}
}

对于transform和sink,相关的实现,大家可以自己了解一下。

5-Spark local模式运行过程中的小插曲

Demo中运行的本地代码是基于spark.master = local模式来运行的。

在启动后通过命令行发送消息:

./kafka-console-producer.sh --broker-list localhost:9092 --topic test_seatunnel
>15 
>16

控制台sink已经可以打印

Seeking to offset 7 for partition test_seatunnel-0
2023-02-07 15:23:42,014 INFO  org.apache.kafka.clients.consumer.KafkaConsumer - [Consumer clientId=seatunnel-consumer-494495802, groupId=SeaTunnel-Consumer-Group] Subscribed to partition(s): test_seatunnel-0
2023-02-07 15:23:42,014 INFO  org.apache.kafka.clients.consumer.KafkaConsumer - [Consumer clientId=seatunnel-consumer-494495802, groupId=SeaTunnel-Consumer-Group] Seeking to offset 8 for partition test_seatunnel-0
subtaskIndex=0: row=1 : 15,

但是仔细一看,只输出了一条数据。

在前面创建topic的时候指定了--partitions 2两个分区,并且对source 的配置指定了parallelism = 2 ,意味着一个分区需要一个task来进行消费。而这里通过所有日志只看到订阅了一个分区 Subscribed to partition(s): test_seatunnel-0。

但是日志中spark本身已经提交了两个task,DAGScheduler - Submitting 2 missing tasks。

那么说明应该是某个资源配置不足导致另一个task一直没有分配运行。

通过追踪Spark源码可以发现在TaskSchedulerImpl的resourceOfferSingleTaskSet中,在遍历处理taskSet中的task前会先进行判断 availableCpus(i) >= CPUS_PER_TASK,然后会进行availableCpus(i) -= CPUS_PER_TASK。通过debug发现,在提交完第一个task后,这里已经不满足条件了。

private def resourceOfferSingleTaskSet(
  taskSet: TaskSetManager,
  maxLocality: TaskLocality,
  shuffledOffers: Seq[WorkerOffer],
  availableCpus: Array[Int],
  tasks: IndexedSeq[ArrayBuffer[TaskDescription]],
  addressesWithDescs: ArrayBuffer[(String, TaskDescription)]) : Boolean = {
var launchedTask = false
// nodes and executors that are blacklisted for the entire application have already been
// filtered out by this point
for (i <- 0 until shuffledOffers.size) {
  val execId = shuffledOffers(i).executorId
  val host = shuffledOffers(i).host
  if (availableCpus(i) >= CPUS_PER_TASK) {
	try {
	  for (task <- taskSet.resourceOffer(execId, host, maxLocality)) {
		tasks(i) += task
		val tid = task.taskId
		taskIdToTaskSetManager.put(tid, taskSet)
		taskIdToExecutorId(tid) = execId
		executorIdToRunningTaskIds(execId).add(tid)
		availableCpus(i) -= CPUS_PER_TASK
		assert(availableCpus(i) >= 0)
		// Only update hosts for a barrier task.
		if (taskSet.isBarrier) {
		  // The executor address is expected to be non empty.
		  addressesWithDescs += (shuffledOffers(i).address.get -> task)
		}
		launchedTask = true
	  }
	} catch {
	  case e: TaskNotSerializableException =>
		logError(s"Resource offer failed, task set ${taskSet.name} was not serializable")
		// Do not offer resources for this task, but don't throw an error to allow other
		// task sets to be submitted.
		return launchedTask
	}
  }
}
return launchedTask
}

那么就来看看availableCpus是在哪里配置的吧。availableCpus其实就是提交任务时候对executor配置的cores。我们Demo中因为是local模式是通过--master参数控制的,就会在创建SchedulerBackend的时候默认为1。

case "local" =>
	val scheduler = new TaskSchedulerImpl(sc, MAX_LOCAL_TASK_FAILURES, isLocal = true)
	val backend = new LocalSchedulerBackend(sc.getConf, scheduler, 1)
	scheduler.initialize(backend)
	(backend, scheduler)

而我们前面在conf中env的配置中指定了spark.master = local,就会走到上面的代码。

到此发现一个小的配置有误就会导致运行结果不符合预期的情况。修改一下配置为local[2],查看日志,发现已经可以正常消费两个partition了。

env {
  # You can set spark configuration here
  # see available properties defined by spark: https://spark.apache.org/docs/latest/configuration.html#available-properties
  #job.mode = BATCH
  job.mode = "STREAMING"
  job.name = "SeaTunnel"
  spark.executor.instances = 1
  spark.executor.cores = 1
  spark.executor.memory = "1g"
  spark.master = "local[2]"
}

source {
  Kafka {
    parallelism = 2
    result_table_name = "kafka_name"
    schema = {
      fields {
        name = "string"
        age = "int"
      }
    }
    format = text
    field_delimiter = "#"
    topic = "test_seatunnel"
    bootstrap.servers = "localhost:9092"
   }
}


sink {
  Console {
    parallelism = 1
  }
}

总结

上文简单介绍了自己使用过的数据集成的工具,并且对Seatunnel进行了初步的了解,发现提供了丰富的connector,在使用上也非常方便。

在上面的Seatunnel的使用中我们选择的引擎是Spark Engine,大家也可以选择自己属性的引擎进行测试。