「Flink实时数据分析系列」8.与外部系统的读写交互(下)

来源 | 「Stream Processing with Apache Flink」

作者 | Fabian Hueske and Vasiliki Kalavri

翻译 | 吴邪 大数据4年从业经验,目前就职于广州一家互联网公司,负责大数据基础平台自研、离线计算&实时计算研究

校对 | gongyouliu

编辑 | auroral-L

全文共9435字,预计阅读时间60分钟。

目录

三、实现自定义数据源函数

       1.可重置的数据源函数

       2.数据源函数、时间戳及水位线

四、实现自定义接收端函数 
              1.幂等性接收端连接器 
              2.事务性接收端连接器 
       五、异步访问外部系统

❤总结

在「与外部系统的读写交互(上)」中,我们讲解了「应用的一致性保障」 、「内置连接器」这两部分,在本篇文章中我们将讲解余下部分。

三、实现自定义数据源函数 

DataStream API提供了两个接口来实现源连接器和相应的RichFunction抽象类:

  • SourceFunction和RichSourceFunction可用于定义非并行的源连接器,使用单个任务运行的源。

  • ParallelSourceFunction和RichParallelSourceFunction可用于定义与多个并行任务实例一起运行的源连接器。

除了非并行和并行的区别之外,这两个接口是相同的。RichSourceFunction和RichParallelSourceFunction的子类可以覆盖open()和close()方法,并访问Runtime Context,其中提供并行任务实例的数量和当前实例的索引等。

SourceFunction 和 ParallelSourceFunction定义了两个方法:

  • void run(SourceContext<T> ctx)

  • void cancel()

run()方法执行读取或接收记录并将其摄入到Flink应用程序中,是实际的工作方法,根据接收数据的系统,数据可以推送或拉取。run()方法由Flink调用一次,并在指定的源线程中运行,通常在一个无限循环(无界流)中读取或接收数据并发出记录。可以在某个时间点显式地取消任务,或者在有界流的情况下,当输入被完全消费完也会终止任务。

当Flink调用cancel()方法时,应用程序会被取消和关闭。为了优雅的关闭,在单独的线程中运行的run()方法应该在调用cancel()方法后立即终止。示例8-10显示了一个简单的源函数,其计数范围从0到Long.MaxValue。

class CountSource extends SourceFunction[Long] {
   var isRunning: Boolean = true
   override def run(ctx: SourceFunction.SourceContext[Long]) = {
       var cnt: Long = -1
       while (isRunning && cnt < Long.MaxValue) {
           cnt += 1
           ctx.collect(cnt)
      }
  }
   override def cancel() = isRunning = false
}

1.可重置的数据源函数 

在本章的前面,我们解释了Flink只能为使用源连接器的应用程序提供精确一致性保证,这些应用程序可以重放输出数据。如果提供数据的外部系统暴露API来检索和重置读取offset,则源函数可以重放其输出数据。这类系统的示例提供文件流offset的文件系统将文件流移动到特定位置,或者使用Apache Kafka的seek方法,后者为主题的每个分区提供offset,可以设置分区的读取位置。相反的是从socket读取数据的源连接器,它会立即丢弃已传输的数据。

支持输出回放的源函数需要与Flink的检查点机制集成,并且必须在生成检查点时必须持久化当前的读取位置。当应用程序从保存点启动或从故障中恢复时,读取offset将从最新的检查点或保存点检索。如果应用程序在没有现有状态的情况下启动,则读取offset必须设置为默认值。复位源函数需要实现CheckpointedFunction接口,存储读取offset和所有相关的元数据信息,如文件路径或分区ID,在operator list state或operator union list state中,取决于在重新扩展应用的程序中offsets应该如何分配到并行的task实例上。有关operator list state和union list state的分配的详细信息,请查阅“缩放有状态算子(Scaling Stateful Operators)”章节。

此外,还有非常重要的一点是确保在单独的线程中运行的SourceFunction.run()方法不会提前读取offset并在采取检查点时发出数据;换句话说,当调用CheckpointedFunction.snapshotState()方法时,这是通过控制run()中的代码来实现的,run()将读取位置往前移,并在一个块中发出记录,该块同步于锁对象上,该对象是从SourceContext.getCheckpointLock()方法获得的。例8-11使例8-10的CountSource重置。

class ResettableCountSource extends SourceFunction[Long] withCheckpointedFunction {
   var isRunning: Boolean = true
   var cnt: Long = _
   var offsetState: ListState[Long] = _
   override def run(ctx: SourceFunction.SourceContext[Long]) = {
       while (isRunning && cnt < Long.MaxValue) {
           // synchronize data emission and checkpoints
           ctx.getCheckpointLock.synchronized {
               cnt += 1
               ctx.collect(cnt)
          }
      }
  }
   override def cancel() = isRunning = false
   override def snapshotState(snapshotCtx:FunctionSnapshotContext): Unit = {
       // remove previous cnt
       offsetState.clear()
       // add current cnt
       offsetState.add(cnt)
  }
   override def initializeState(initCtx: FunctionInitializationContext): Unit = {
       val desc = new ListStateDescriptor[Long]("offset",classOf[Long])
       offsetState =initCtx.getOperatorStateStore.getListState(desc)
       // initialize cnt variable
       val it = offsetState.get()
       cnt = if (null == it || !it.iterator().hasNext) {
           -1L
      } else {
           it.iterator().next()
      }
  }
}

2.数据源函数、时间戳及水位线 

源函数的另一个重要方面是时间戳和水位线。正如在“事件时间处理”和“分配时间戳和生成水位线”中指出的那样,DataStream API提供了两个选项来分配时间戳和生成水位线。时间戳和水位线可以由指定的TimestampAssigner分配和生成(详细信息请查阅“分配时间戳和生成水位线”),或者由源函数分配和生成。

源函数分配时间戳并通过其SourceContext对象发出水位线。SourceContext提供了以下方法:

  • def collectWithTimestamp(T record, longtimestamp): Unit

  • def emitWatermark(Watermark watermark):Unit

collectWithTimestamp()输出具有相关时间戳的记录,emitWatermark()输出提供的水位线。

如果源函数的一个并行实例消费了多个流分区的记录,如Kafka主题的分区,那么除了不需要另外的算子外,在源函数中分配时间戳和生成水位线也是非常有用的。通常,外部系统(如Kafka)只保证流分区中的消息顺序。给定一个并行度为2的源函数算子,从Kafka主题的6个分区读取数据,源函数的每个并行实例将从3个Kafka主题分区读取记录。结果是源函数的每个实例多路复用三个流分区的记录来输出它们。多路复用分区数据很可能会在事件时间戳方面增加更多的无序性,这样一来下游时间戳分配器可能会产生比预期更多的延迟记录。

为了避免这种现象,源函数可以独立地为每个流分区生成水位线,并且始终将其分区的最低水位线作为水位线,这样一来就不会输出不必要的延迟记录。

源函数必须处理的另一个问题是实例变得空闲并且不再输出数据的实例,通常有出现问题,因为它可能使得程序的水位线不再向前推移,从而导致应用程序陷入停滞状态。由于水位线应该是数据驱动的,所以如果水位线生成器(集成在源函数或时间戳分配程序中)没有接收到输入记录,将不会发出新的水位线。如果你查看一下Flink是如何传播和更新水位线的(请查阅“水位线传播和事件时间”),你就会发现,一旦应用程序涉及到一个shuffle操作(keyBy()、rebalance()等),那么一个不提前使用水位线的算子就可以使应用程序所有的水位线停滞不前。

Flink提供了一种机制,通过将源函数标记为临时空闲状态来避免这种情况。当处于空闲状态时,Flink的水位线传播机制将忽略空闲流分区。一旦数据源再次开始发出记录,它就会被自动设置为活跃状态。源函数可以通过调用SourceContext.markAsTemporarilyIdle()方法来决定何时将自己标记为空闲状态。

四、实现自定义接收端函数 

在Flink的DataStream API中,任何算子或函数都可以将数据发送到外部系统或其他应用程序,数据流最终不必流到接收器算子中。例如,你可以实现一个FlatMapFunction,通过HTTP POST调用而不是通过其收集器来输出每个传入的记录。尽管如此,DataStream API提供了一个专用的SinkFunction接口和一个相应的RichSinkFunction抽象类。SinkFunction接口提供了一个单一的方法:

void invoke(IN value, Context ctx)

SinkFunction的上下文对象提供了对当前处理时间、当前水位线以及记录的时间戳的访问。

例8-12显示了一个简单的SinkFunction将传感器读数写入socket。注意,在启动程序之前,你需要启动一个监听socket的进程。否则,由于会出现socket连接异常,程序会因ConnectException异常而失败。在Linux上运行命令nc -l localhost 9191来监听localhost:9191。

val readings: DataStream[SensorReading] = ???
// write the sensor readings to a socket
readings.addSink(new SimpleSocketSink("localhost", 9191))
// set parallelism to 1 because only one thread can write to a socket
.setParallelism(1)
// -----
class SimpleSocketSink(val host: String, val port: Int)
extends RichSinkFunction[SensorReading] {
   var socket: Socket = _
   var writer: PrintStream = _
   override def open(config: Configuration): Unit = {
       // open socket and writer
       socket = new Socket(InetAddress.getByName(host), port)
       writer = new PrintStream(socket.getOutputStream)
  }
   override def invoke(
       value: SensorReading,
       ctx: SinkFunction.Context[_]): Unit = {
       // write sensor reading to socket
       writer.println(value.toString)
       writer.flush()
  }
   override def close(): Unit = {
       // close writer and socket
       writer.close()
       socket.close()
  }
}

如上所述,应用程序的端到端精确一致性保证取决于其sink连接器的属性。为了实现端到端的精确一次语义,应用程序需要幂等性或事务性接收端连接器。例8-12中的SinkFunction既不执行幂等写功能,也不提供事务性写支持。由于socket仅提供追加的特性,因此无法执行幂等写操作。此外套接字没有内置的事务支持,所以只能使用Flink的通用WAL sink完成事务写。在接下来的部分中,你将了解如何实现幂等性或事务性接收端连接器。

1.幂等性接收端连接器 

对于许多应用程序,SinkFunction接口足以实现幂等接收器连接器。需要满足以下两点:

  1. 结果数据具有一个确定(复合)key,可以对该key执行幂等更新。对于计算每个传感器和每分钟的平均温度的应用程序,确定key可以是传感器的ID和每分钟的时间戳。确定key对于确保在恢复的情况下正确地覆盖所有写入是很重要的。

  2. 外部系统支持每个key的更新,比如关系数据库系统或键值存储。

 

示例8-13说明了如何实现和使用向JDBC数据库写入的幂等SinkFunction,在本例中是嵌入式Apache Derby数据库。

val readings: DataStream[SensorReading] = ???
// write the sensor readings to a Derby table
readings.addSink(new DerbyUpsertSink)
// -----
class DerbyUpsertSink extends RichSinkFunction[SensorReading] {
var conn: Connection = _
var insertStmt: PreparedStatement = _
var updateStmt: PreparedStatement = _
override def open(parameters: Configuration): Unit = {
   // connect to embedded in-memory Derby
   conn = DriverManager.getConnection("jdbc:derby:memory:flinkExample",newProperties())
   // prepare insert and update statements
   insertStmt = conn.prepareStatement("INSERT INTO Temperatures (sensor, temp) VALUES   (?, ?)")
   updateStmt = conn.prepareStatement("UPDATE Temperatures SET temp = ? WHERE sensor = ?")
}
override def invoke(r: SensorReading, context: Context[_]):Unit = {
   // set parameters for update statement and execute it
   updateStmt.setDouble(1, r.temperature)
   updateStmt.setString(2, r.id)
   updateStmt.execute()
   // execute insert statement if update statement did not update any row
   if (updateStmt.getUpdateCount == 0) {
       // set parameters for insert statement
       insertStmt.setString(1, r.id)
       insertStmt.setDouble(2, r.temperature)
       // execute insert statement
       insertStmt.execute()
  }
}
override def close(): Unit = {
   insertStmt.close()
   updateStmt.close()
   conn.close()
}
}

由于Apache Derby不提供内置的UPSERT语句,因此示例接收器首先尝试更新行,如果没有具有给定键的行,则插入新行。当未启用WAL时,Cassandra sink连接器遵循相同的方法。

2.事务性接收端连接器 

当幂等接收器连接器不适合时,无论是应用程序输出的特性,或者所需接收器系统的属性,还是更严格的一致性要求,事务性接收器连接器都可以作为另一种选择方案。如前所述,事务性接收端连接器需要与Flink的检查点机制集成,因为它们只能在检查点成功完成时将数据提交到外部系统。

为了简化事务性接收器的实现,Flink的DataStream API提供了两个模板,可以扩展它们来实现自定义接收算子。两个模板都实现了CheckpointListener接口来接收来自JobManager关于完成的检查点的通知(有关接口的详细信息,请查阅“接收关于完成的检查点的通知”):

  • GenericWriteAheadSink模板收集每个检查点的所有输出记录,并将它们存储在sink任务的算子状态。在失败的情况下,状态被检查并恢复。当任务收到检查点完成通知时,它将完成的检查点的记录写入外部系统。带有WAL- enabled的Cassandra sink连接器实现了这个接口。

  • TwoPhaseCommitSinkFunction模板利用了外部接收器系统的事务特性,对于每个检查点,它启动一个新事务,并在当前事务的上下文中将所有记录写入接收器。接收器在接收到相应检查点的完成通知时提交事务。

在下面,我们描述了接口及其一致性保证。

GENERICWRITEAHEADSINK

GenericWriteAheadSink通过改进一致性属性简化了sink算子的实现。该算子与Flink的检查点机制集成,目的是将每条记录精确一次地写入外部系统。但是,你应该知道存在这样的失败场景,即预写日志接收器输出的记录不止一次。因此,一个GenericWriteAheadSink无法提供精确一次保证,只能提供至少一次的保证。我们将在本节后面更详细地讨论这些场景。

GenericWriteAheadSink的工作方式是将所有接收到的记录附加到由检查点分割的预写日志中。每次sink算子接收到一个检查点barrier时,它就会启动一个新分片,并将所有后续记录附加到新分片中。存储WAL并作为算子状态进行检查。由于日志将被恢复,因此在失败的情况下不会丢失任何记录。

当GenericWriteAheadSink接收到关于完成的检查点的通知时,它会输出存储与已完成检查点的segment中的所有记录。根据sink算子的具体实现,可以将记录写入任何类型的存储或消息系统,当所有记录都成功输出后,必须在内部提交相应的检查点。

检查点分两个步骤提交。首先,接收器持续存储已提交的检查点信息,然后从WAL中删除记录。将提交信息存储在Flink的应用程序状态中,因为它不是持久性的,并且在出现故障时将被重置。相反,GenericWriteAheadSink依赖于一个名为CheckpointCommitter的可插入组件来存储和查找关于外部持久存储中提交的检查点的信息。例如,Cassandra sink连接器默认使用一个向Cassandra写入的CheckpointCommitter。

由于GenericWriteAheadSink的内置逻辑,实现一个利用WAL的sink并不困难。扩展GenericWriteAheadSink的算子需要提供三个构造函数参数:

  • 一个CheckpointCommitter,见前面章节介绍。

  • 一个TypeSerializer用于序列化输入记录。

  • 传递给CheckpointCommitter的作业ID,以标识跨应用程序重新启动的提交信息

此外,write-ahead运算符需要实现一个单一的方法:

boolean sendValues(Iterable<IN> values, long chkpntId, long timestamp)

GenericWriteAheadSink调用sendValues()方法将完成的检查点的记录写入外部存储系统。该方法接收一个Iterable(包括检查点的所有记录)、一个检查点的ID和一个生成检查点的时间戳。如果所有写操作都成功,则该方法必须返回true;如果写操作失败,则返回false。

示例8-14展示了一个写到标准输出的WriteAhead sink实现。它使用FileCheckpointCommitter,我们在这里不讨论它。你可以在包含该书示例的代码仓库中查找它的实现。

注意
GenericWriteAheadSink不实现SinkFunction接口。因此,不能使用DataStream.addSink()添加扩展GenericWriteAheadSink的sink,而是使用DataStream.transform()方法附加它。
val readings: DataStream[SensorReading] = ???
// write the sensor readings to the standard out via a writeahead log
readings.transform("WriteAheadSink", new SocketWriteAheadSink)
// -----
class StdOutWriteAheadSink extends GenericWriteAheadSink[SensorReading](
// CheckpointCommitter that commits checkpoints to the local filesystem
new FileCheckpointCommitter(System.getProperty("java.io.tmpdir")),
// Serializer for records
createTypeInformation[SensorReading].createSerializer(new ExecutionConfig),
// Random JobID used by the CheckpointCommitter
UUID.randomUUID.toString) {
override def sendValues(
   readings: Iterable[SensorReading],
   checkpointId: Long,
   timestamp: Long): Boolean = {
   for (r <- readings.asScala) {
  // write record to standard out
  println(r)
  }
  true
  }
}

示例代码库包含一个应用程序,该应用程序在发生故障时定期进行故障恢复,以演示StdOutWriteAheadSink和一个常规的DataStream.print() sink的行为。

如前所述,GenericWriteAheadSink不能提供精确一次保证。有两种故障情况会导致记录被输出不止一次:

  • 当任务运行sendValues()方法时,程序失败。如果外部sink系统不能原子地写入多个记录,要么全部写入,要么全部失败,而部分记录可能被写入。由于检查点尚未提交,所以在恢复期间sink将再次写入所有记录。

  • 所有记录都正确写入,sendValues()方法返回true;但是,在调用CheckpointCommitter或CheckpointCommitter未能提交检查点之前,程序会失败。在恢复期间,所有尚未提交的检查点记录将被重新写入。

TWOPHASECOMMITSINKFUNCTION

Flink提供了TwoPhaseCommitSinkFunction接口,以简化sink函数的实现,这些sink函数提供端到端的精确一次保证。但是,2PC sink函数是否提供这种保证取决于实现细节。我们从一个问题开始讨论这个接口:“2PC协议是不是代价太大了?”

通常,2PC是确保分布式系统一致性的方法,代价相对来说比较大。但是,在Flink上下文中,协议对于每个检查点只运行一次。此外,TwoPhaseCommitSinkFunction协议利用了Flink的常规检查点机制,因此增加的开销很小。TwoPhaseCommitSinkFunction的工作原理与WAL sink非常相似,但它不会收集Flink应用状态下的记录;相反,它将它们以开放事务的形式写入外部接收器系统。

TwoPhaseCommitSinkFunction实现以下协议。在sink任务发出第一个记录之前,它在外部sink系统上启动一个事务。所有随后收到的记录都是在事务的上下文中写入的。当JobManager启动一个检查点并在应用程序的源中注入barriers时,2PC协议的投票阶段就开始了。当算子接收到barrier时,它会保持其状态,并在完成之后向JobManager发送确认消息。当sink任务接收到barrier时,它将持久化该状态,准备提交当前事务,并在JobManager上确认检查点。JobManager的确认消息类似于2PC协议的提交投票。sink任务还不能提交事务,因为不能保证作业的所有任务都将完成其检查点。sink任务还为在下一个检查点barrier之前到达的所有记录启动一个新事务。

当JobManager从所有任务实例接收到成功的检查点通知时,它会向所有的任务发送检查点完成的通知,此通知对应于2PC协议的提交命令。当接收器任务接收到通知时,它将提交以前检查点的所有打开的事务。sink任务一旦确认其检查点,就必须能够提交相应的事务,即使在出现故障的情况下也是如此。如果无法提交事务,则接收器将丢失数据。当所有sink任务提交它们的事务时,2PC协议的迭代就算成功了。

我们来总结一下外部sink系统的要求:

  • 外部sink系统必须提供事务支持,或者sink必须能够模拟外部系统上的事务。因此,sink应该能够向sink系统写入数据,但是写入的数据在提交之前不能对外公开。

  • 在检查点间隔期间,事务必须开启并接受写操作。

  • 事务必须等到接收到检查点完成通知时,再提交。在恢复周期的情况下,可能需要一些时间。如果sink系统关闭事务,未提交的数据将丢失。

  • 处理一旦失败,sink必须能够恢复事务。一些sink系统提供一个事务ID可用于提交或中止一个开启的事务。

  • 提交一个事务必须是一个幂等操作,sink或外部系统应该能够做到:一个事务已经提交或重复提交,没有影响。

通过一个具体的例子,可以更容易地理解sink系统的协议和需求。例8-15显示了一个TwoPhaseCommitSinkFunction,它只向文件系统写一次(精确一次)。实际上,这是前面讨论的BucketingFileSink的简化版本。

class TransactionalFileSink(val targetPath: String, valtempPath: String)
extends TwoPhaseCommitSinkFunction[(String, Double),String, Void](
createTypeInformation[String].createSerializer(new ExecutionConfig),
createTypeInformation[Void].createSerializer(new ExecutionConfig)) {
var transactionWriter: BufferedWriter = _
// Creates a temporary file for a transaction into which the records are written.
override def beginTransaction(): String = {
   // path of transaction file is built from current time and task index
   val timeNow =LocalDateTime.now(ZoneId.of("UTC")).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
   val taskIdx = this.getRuntimeContext.getIndexOfThisSubtask
   val transactionFile = s"$timeNow-$taskIdx"
   // create transaction file and writer
   val tFilePath = Paths.get(s"$tempPath/$transactionFile")
   Files.createFile(tFilePath)
   this.transactionWriter = Files.newBufferedWriter(tFilePath)
   println(s"Creating Transaction File: $tFilePath")
   // name of transaction file is returned to later identify the transaction
   transactionFile
}
/** Write record into the current transaction file. */
override def invoke(
   transaction: String,
   value: (String, Double),
   context: Context[_]): Unit = {
   transactionWriter.write(value.toString)
   transactionWriter.write('\n')
}
/** Flush and close the current transaction file. */
override def preCommit(transaction: String): Unit = {
   transactionWriter.flush()
   transactionWriter.close()
}
/** Commit a transaction by moving the precommitted transaction file
* to the target directory.
*/
override def commit(transaction: String): Unit = {
   val tFilePath = Paths.get(s"$tempPath/$transaction")
   // check if the file exists to ensure that the commit is idempotent
   if (Files.exists(tFilePath)) {
       val cFilePath = Paths.get(s"$targetPath/$transaction")
       Files.move(tFilePath, cFilePath)
  }
}
/** Aborts a transaction by deleting the transaction file. */
override def abort(transaction: String): Unit = {
val tFilePath = Paths.get(s"$tempPath/$transaction")
   if (Files.exists(tFilePath)) {
  Files.delete(tFilePath)
  }
}
}

TwoPhaseCommitSinkFunction[IN, TXN, CONTEXT]有三个类型参数:

  • IN指定输入记录的类型。在例8-15中,这是一个带有String和Double的Tuple2。

  • TXN定义了一个事务标识符,可用于在失败后识别和恢复事务。在例8-15中,这是一个包含事务文件名称的字符串。

  • CONTEXT定义了一个可选的自定义上下文。例8-15中的TransactionalFileSink不需要上下文,因此将类型设置为Void。

TwoPhaseCommitSinkFunction的构造函数需要两个TypeSerializer,一个用于TXN类型,另一个用于CONTEXT类型。

最后,TwoPhaseCommitSinkFunction定义了五个需要实现的功能:

  • beginTransaction(): TXN启动一个新的事务并返回事务标识符。例8-15中的TransactionalFileSink创建一个新的事务文件,并将其名称作为标识符返回。

  • invoke(txn: TXN, value: IN, context: Context[_]): Unit  将一个值写入当前事务。示例8-15中的sink将该值作为字符串追加到事务文件。

  • preCommit(txn: TXN): Unit 预提交一个事务。预提交事务可能不会收到进一步的写操作。例8-15中的实现刷新并关闭事务文件。

  • commit(txn: TXN): Unit 提交一个事务。此操作必须是幂等的,如果此方法被调用两次,不能将记录写入输出系统两次。在例8-15中,我们检查事务文件是否仍然存在,并将其移动到目标目录(如果存在的话)。

  • abort(txn: TXN): Unit 中止一个事务。对于一个事务,此方法也可能被调用两次。例8-15中的TransactionalFileSink检查事务文件是否仍然存在,如果仍然存在,则删除它。

正如你所看到的,该接口的实现并不太复杂。然而,实现的复杂性和一致性保证取决于sink系统的特性和功能。例如,Flink的Kafka构造器实现了TwoPhaseCommitSinkFunction接口。如前所述,如果由于超时而回滚事务,连接器可能会丢失数据。因此,即使它实现了TwoPhaseCommitSinkFunction接口,也不能提供精确一次保证。

五、异步访问外部系统 

除了提取或发送数据流之外,另一个需要与外部存储系统交互的常见用例是通过在远程数据库中查找信息来丰富数据流。最为众所周知的例子就是雅虎流处理基准测试,它基于一系列广告点击,产生丰富的信息,存储在键值存储中。

对于这些用例,最直接的方法是实现一个MapFunction,它查询每条处理记录的数据存储,等待查询返回结果,丰富记录,并输出结果。虽然这种方法很容易实现,但它存在一个缺陷:对外部数据存储的请求都会增加明显的延迟(请求/响应涉及两条网络消息),而MapFunction会花费大部分时间等待查询结果。

Apache Flink提供AsyncFunction来减少远程I/O调用的延迟。AsyncFunction同时发送多个查询并异步处理它们的结果。可以将其配置为保留记录的顺序(请求返回的顺序可能与发送它们的顺序不同),或者按照查询结果的顺序返回结果,以进一步减少延迟。该函数还与Flink的检查点机制适当地集成在一起,检查当前等待响应的输入记录,并在恢复的情况下重复查询。此外,AsyncFunction可以基于事件时间进行正确处理,即使结果是无序的,水位线也不会被记录覆盖。

为了利用AsyncFunction,外部系统应该提供一个支持异步调用的客户端,大多数系统都是如此。如果系统只提供同步客户端,则可以创建线程来发送请求并处理它们。AsyncFunction的接口如下图所示:

trait AsyncFunction[IN, OUT] extends Function {
   def asyncInvoke(input: IN, resultFuture:ResultFuture[OUT]): Unit
}

函数的类型参数定义其输入和输出类型。为每个使用两个参数的输入记录调用asyncInvoke()方法。第一个参数是输入记录,第二个参数是回调对象,返回函数或异常结果。在示例8-16中,我们展示了如何在DataStream上应用AsyncFunction。

val readings: DataStream[SensorReading] = ???
val sensorLocations: DataStream[(String, String)] =AsyncDataStream.orderedWait(
   readings,
   new DerbyAsyncFunction,
   5,
   TimeUnit.SECONDS, // timeout requests after 5 seconds
   100) // at most 100 concurrent requests

应用AsyncFunction的异步算子通过AsyncDataStream对象配置,该对象提供了两个静态方法:orderedWait() 和 unorderedWait()。这两个方法是重载方法的,使用不同的参数组合。orderedWait()应用一个异步算子,它按照输入记录的顺序发出结果,而unorderWait()算子只确保水位线和检查点barrier保持对齐。其他参数指定记录的异步调用何时超时,以及启动多少并发请求。示例8-17显示了DerbyAsyncFunction,它通过JDBC接口查询嵌入式Derby数据库。

class DerbyAsyncFunction extends AsyncFunction[SensorReading, (String, String)] {
// caching execution context used to handle the query threads
private lazy val cachingPoolExecCtx =ExecutionContext.fromExecutor(Executors.newCachedThreadPool())
// direct execution context to forward result future to callback object
private lazy val directExecCtx =
ExecutionContext.fromExecutor(org.apache.flink.runtime.concurrent.Executors.directExecutor())
/**
* Executes JDBC query in a thread and handles the resulting Future
* with an asynchronous callback.
*/
override def asyncInvoke(
   reading: SensorReading,
   resultFuture: ResultFuture[(String, String)]): Unit = {
val sensor = reading.id
// get room from Derby table as Future
val room: Future[String] = Future {
// Creating a new connection and statement for each record.
// Note: This is NOT best practice!
// Connections and prepared statements should be cached.
val conn = DriverManager.getConnection("jdbc:derby:memory:flinkExample",newProperties())
val query = conn.createStatement()
// submit query and wait for result; this is a synchronous call
val result = query.executeQuery(s"SELECT room FROM SensorLocations WHERE sensor ='$sensor'")
// get room if there is one
val room = if (result.next()) {
result.getString(1)
} else {
"UNKNOWN ROOM"
}
// close resultset, statement, and connection
result.close()
query.close()
conn.close()
// return room
room
}(cachingPoolExecCtx)
// apply result handling callback on the room future
room.onComplete {
   case Success(r) => resultFuture.complete(Seq((sensor,r)))
   case Failure(e) => resultFuture.completeExceptionally(e)
}(directExecCtx)
}
}

示例8-17中的DerbyAsyncFunction的asyncInvoke()方法将阻塞的JDBC查询在Future中,该查询是通过CachedThreadPool执行的。为了示例的简洁,我们为每个记录创建一个新的JDBC连接,当然这是非常低效的,实际应用中应该避免。Future[String]保存JDBC查询的结果。

最后,我们对Future应用onComplete()回调方法,并将结果(或可能的异常)传递给ResultFuture处理程序。与JDBC查询Future相反,onComplete()回调方法由DirectExecutor处理,因为将结果传递给ResultFuture是一个轻量级操作,不需要专门的线程。注意,所有操作都是以非阻塞方式完成的。

需要指出的是,AsyncFunction实例顺序调用其每个输入记录的,函数实例不是以多线程方式调用的。因此,asyncInvoke()方法应该通过启动异步请求并将结果转发给ResultFuture的回调方法来处理结果并快速返回,必须避免的常见反模式包括:

  • 发送一个阻塞asyncInvoke()方法的请求。

  • 发送异步请求,但在asyncInvoke()方法中等待请求完成。

 

❤总结

在本章中,你了解了Flink DataStream应用程序如何与外部系统进行读写交互,包括数据的读取和写入,以及应用程序实现不同端到端一致性保证的条件。不仅如此,我们介绍了Flink最常用的内置源和sink连接器,同时也代表着不同类型的存储系统,如消息队列、文件系统和键值存储。

随后,我们向你展示了如何实现自定义源和sink连接器,包括WAL和2PC接收端连接器,并提供了详细的例子。最后,你了解了Flink的AsyncFunction,它可以通过异步执行和处理请求来显著提高与外部系统交互的性能。

1、精确一次状态一致性是端到端精确一次一致性的要求,但并不相同。

2、我们在“通用写提前槽”中详细讨论了WAL槽的一致性保证。

3、有关时间戳分配人接口的详细信息,请参阅第6章。

4、输入格式是该链接用于在数据集API中定义数据源的接口。

5、与SQL插入语句相比,CQL插入语句的行为类似于升级查询——它们使用相同的主键覆盖现有的行。

6、在第5章中讨论了丰富的函数。

7、通常使用富接收函数接口,因为接收函数通常需要在富函数中设置与外部系统的连接。打开()方法。有关富功能界面的详情,请参见第5章。

8、如果确认消息丢失,则任务可能需要提交多个事务。

9、详见“阿帕奇卡夫卡水槽连接器”。

10、API提供了一个具有各自静态方法的异步数据流类。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

数据与智能

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值