KafkaStreams 计算图节点设计:ProcessorNode、SourceNode、SinkNode

ProcessorNode

在 Kafka Streams 中,一个完整的流处理逻辑被定义为一个处理器拓扑(Processor Topology) ,这个拓扑是一个由 流处理器(Stream Processor)节点和 流(Stream) 边组成的有向无环图(DAG)。ProcessorNode 正是这个图中"节点"在运行时的具体实现和封装。

核心职责与组件

ProcessorNode 的主要职责是包装和管理用户定义的处理逻辑(ProcessorFixedKeyProcessor),处理其生命周期,并将其连接到流处理拓扑中的其他部分

我们来看一下它的关键成员变量:

java 复制代码
// ... existing code ...
    private final List<ProcessorNode<KOut, VOut, ?, ?>> children;
    private final Map<String, ProcessorNode<KOut, VOut, ?, ?>> childByName;

    private final Processor<KIn, VIn, KOut, VOut> processor;
    private final FixedKeyProcessor<KIn, VIn, VOut> fixedKeyProcessor;
    private final String name;

    public final Set<String> stateStores;
    private ProcessingExceptionHandler processingExceptionHandler;

    private InternalProcessorContext<KOut, VOut> internalProcessorContext;
// ... existing code ...
    private Sensor droppedRecordsSensor;
// ... existing code ...
  • processor / fixedKeyProcessor : 这两个字段存储了用户提供的实际处理逻辑。一个 ProcessorNode 实例要么包含一个 Processor,要么包含一个 FixedKeyProcessorProcessor 是通用的处理器接口,而 FixedKeyProcessor 是一个优化版本,用于处理 key 不变的记录。
  • name: 每个节点的唯一标识符。在构建拓扑时,通过这个名字来连接不同的节点。
  • children / childByName : 这两个字段定义了拓扑结构。children 列表存储了所有下游节点的引用。当一个节点处理完一条记录后,它可以通过 internalProcessorContext.forward() 方法将结果发送给它的子节点。这构成了数据在拓扑中流动的路径。
  • stateStores : 这是一个 Set<String>,包含了与此节点关联的所有状态存储(State Store)的名称。这表明该处理器是有状态的,它可以在处理过程中读写这些状态存储。
  • internalProcessorContext : 这是非常关键的上下文对象。它为 Processor 提供了与 Kafka Streams 框架交互的能力,例如:
    • 访问记录的元数据(topic, partition, offset, timestamp, headers)。
    • 通过 forward() 方法将处理结果发送到下游节点。
    • 访问和操作与之关联的状态存储。
    • 调度周期性执行的任务(Punctuation)。
  • processingExceptionHandler : 这是一个异常处理器,用于捕获和处理在 process() 方法中发生的异常。用户可以自定义实现,决定是让任务失败(FAIL)还是跳过当前记录并继续(CONTINUE)。
  • droppedRecordsSensor : 这是一个度量(Metrics)传感器。当 processingExceptionHandler 决定 CONTINUE 时,当前记录会被丢弃,这个传感器就会记录下来,方便监控。

生命周期管理

ProcessorNode 精确地管理了其内部 Processor 的生命周期,主要通过 init()close() 方法。

  • 构造函数 : ProcessorNode 提供了多个构造函数,用于创建不同类型的节点。例如,可以只提供一个名字(通常用于 Source 或 Sink 节点),或者提供一个 Processor 实例和它所关联的状态存储。

  • init() 方法:

    java 复制代码
    // ... existing code ...
    public void init(final InternalProcessorContext<KOut, VOut> context) {
        if (!closed)
            throw new IllegalStateException("The processor is not closed");
    
        try {
            threadId = Thread.currentThread().getName();
            internalProcessorContext = context;
            droppedRecordsSensor = TaskMetrics.droppedRecordsSensor(threadId,
                internalProcessorContext.taskId().toString(),
                internalProcessorContext.metrics());
    
            if (processor != null) {
                processor.init(context);
            }
            if (fixedKeyProcessor != null) {
                @SuppressWarnings("unchecked") final FixedKeyProcessorContext<KIn, VOut> fixedKeyProcessorContext =
                    (FixedKeyProcessorContext<KIn, VOut>) context;
                fixedKeyProcessor.init(fixedKeyProcessorContext);
            }
        } catch (final Exception e) {
            throw new StreamsException(String.format("failed to initialize processor %s", name), e);
        }
    
        // revived tasks could re-initialize the topology,
        // in which case we should reset the flag
        closed = false;
    }
    
    public void init(final InternalProcessorContext<KOut, VOut> context, final ProcessingExceptionHandler processingExceptionHandler) {
        init(context);
        this.processingExceptionHandler = processingExceptionHandler;
    }
    // ... existing code ...

    在 Task 初始化时,StreamTask 会调用 ProcessorNodeinit() 方法。这个方法会:

    1. 保存 InternalProcessorContext 的引用。
    2. 初始化度量传感器 droppedRecordsSensor
    3. 调用用户 Processorinit() 方法,将上下文对象传递进去。这是用户进行初始化操作(如获取状态存储实例)的地方。
    4. 将节点状态设置为 open (closed = false)。
  • close() 方法:

    java 复制代码
    // ... existing code ...
    public void close() {
        throwIfClosed();
    
        try {
            if (processor != null) {
                processor.close();
            }
            if (fixedKeyProcessor != null) {
                fixedKeyProcessor.close();
            }
            internalProcessorContext.metrics().removeAllNodeLevelSensors(
                threadId,
                internalProcessorContext.taskId().toString(),
                name
            );
        } catch (final Exception e) {
            throw new StreamsException(String.format("failed to close processor %s", name), e);
        }
    
        closed = true;
    }
    // ... existing code ...

    当 Task 关闭时,close() 方法被调用。它会:

    1. 调用用户 Processorclose() 方法,让用户可以释放资源。
    2. 移除与此节点相关的度量传感器。
    3. 将节点状态设置为 closed = true

核心处理逻辑与异常处理

process() 方法是数据处理的入口点,也是 ProcessorNode 中最复杂的部分,尤其是其异常处理机制。

java 复制代码
// ... existing code ...
    public void process(final Record<KIn, VIn> record) {
        throwIfClosed();

        try {
            if (processor != null) {
                processor.process(record);
            } else if (fixedKeyProcessor != null) {
// ... existing code ...
            } else {
                throw new IllegalStateException(
                    "neither the processor nor the fixed key processor were set."
                );
            }
        } catch (final ClassCastException e) {
// ... existing code ...
                e);
        } catch (final FailedProcessingException | TaskCorruptedException | TaskMigratedException e) {
            // Rethrow exceptions that should not be handled here
            throw e;
        } catch (final Exception processingException) {
// ... existing code ...
            final ProcessingExceptionHandler.ProcessingHandlerResponse response;
            try {
                response = Objects.requireNonNull(
                    processingExceptionHandler.handle(errorHandlerContext, record, processingException),
                    "Invalid ProductionExceptionHandler response."
                );
            } catch (final Exception fatalUserException) {
// ... existing code ...
                throw new FailedProcessingException(
                    "Fatal user code error in processing error callback",
                    internalProcessorContext.currentNode().name(),
                    fatalUserException
                );
            }

            if (response == ProcessingExceptionHandler.ProcessingHandlerResponse.FAIL) {
// ... existing code ...
                throw new FailedProcessingException(internalProcessorContext.currentNode().name(), processingException);
            } else {
                droppedRecordsSensor.record();
            }
        }
    }
// ... existing code ...
  1. 调用用户逻辑 : try 块的核心是调用 processor.process(record)fixedKeyProcessor.process(...)
  2. 特定异常处理 :
    • ClassCastException: 这是一个非常常见的错误,通常是由于 Serde(序列化/反序列化器)配置不正确导致的。Kafka Streams 在这里捕获它,并抛出一个包含详细诊断信息的 StreamsException,提示用户检查 Serde 配置,极大地帮助了问题定位。
    • FailedProcessingException, TaskCorruptedException, TaskMigratedException: 这些是 Kafka Streams 内部定义的、不应由用户异常处理器处理的严重异常。它们会被直接重新抛出,通常会导致任务失败或迁移。
  3. 通用异常处理 :
    • catch (final Exception processingException): 这个 catch 块捕获所有其他在处理过程中发生的异常。
    • 它会调用 processingExceptionHandler.handle() 方法。
    • 如果 handle() 返回 FAIL,它会包装原始异常并抛出 FailedProcessingException,导致任务失败。
    • 如果 handle() 返回 CONTINUE,它会记录一条被丢弃的记录(droppedRecordsSensor.record()),然后处理流程继续,就像这条记录从未出现过一样。
    • 异常处理器自身的异常 : 如果在执行 processingExceptionHandler.handle() 时又发生了异常,这被认为是致命的用户代码错误,会直接抛出 FailedProcessingException,防止无限循环或未定义行为。

拓扑结构

ProcessorNode 通过 addChild 方法构建起拓扑图。

java 复制代码
// ... existing code ...
    public void addChild(final ProcessorNode<KOut, VOut, ?, ?> child) {
        children.add(child);
        childByName.put(child.name, child);
    }
// ... existing code ...

InternalTopologyBuilder 在构建拓扑时,会解析用户的代码(无论是 DSL 还是 Processor API),创建 ProcessorNode 实例,并调用 addChild 将它们连接起来。例如,KStream.map(...).to(...) 这样的链式调用,就会创建一个 map 对应的 ProcessorNode 和一个 to 对应的 SinkNode(也是一种 ProcessorNode),并将后者作为前者的 child。

forward

ProcessorNode 是 Kafka Streams 框架层面的一个组件。可以把它看作是一个执行容器或管理器。它的核心职责是:

  • 管理生命周期 :调用用户自定义处理器(Processor)的 init()close() 方法。
  • 调用处理逻辑 :在收到记录时,调用用户处理器的 process() 方法。
  • 异常处理 :捕获在用户 process() 方法中抛出的异常,并根据配置的 ProcessingExceptionHandler 来决定是失败任务还是跳过记录。

ProcessorNode.process() 只是一个委托者(delegator) ,它将实际的数据处理工作委托给了它所包装的 processor 对象。它本身并不关心数据处理完之后要去哪里。

那么,forward() 是在哪里被调用的呢?答案是:在用户自己实现的 Processor 内部,通过 ProcessorContext 对象来调用。

  1. 获取上下文 :当 Kafka Streams 初始化一个 Processor 时,会通过 init() 方法传入一个 ProcessorContext 实例。用户代码需要保存这个上下文的引用。
  2. 执行转发 :在 process() 方法中,用户根据自己的业务逻辑,决定是否要将处理结果发送到下游节点。如果需要,就调用 context.forward()

让我们看一个测试代码中的典型例子:

StreamTaskTest.java

java 复制代码
// ... existing code ...
@Override
            public void process(final Record<Integer, Integer> record) {
                if (record.key() % 2 == 0) {
                    // 在用户的处理逻辑中,通过 context 对象调用 forward
                    context.forward(record);
                }
            }
// ... existing code ...

在这个例子里,process 方法的逻辑是:只有当记录的 key 是偶数时,才将这条记录转发到下游。

当用户调用 context.forward() 时,ProcessorContext 的实现(例如 ProcessorContextImpl)会:

  1. 获取当前的 ProcessorNode
  2. 遍历当前节点的所有子节点(children)。
  3. 对每一个子节点,调用其 process() 方法,从而将数据传递下去。

这个过程在 ProcessorContextImpl.java 中有所体现,它会获取 currentNode(),然后将记录传递给它的下游节点。

我们可以将这个流程总结为:

  1. StreamTask 将一条记录交给拓扑中的一个 ProcessorNode
  2. ProcessorNode.process() 被调用,它立即调用其内部的用户 Processor 实例的 process() 方法。
  3. 用户的 Processor.process() 方法执行业务逻辑。
  4. 在业务逻辑中,用户通过 ProcessorContext.forward() 来将结果(可能是原始记录,也可能是新的记录)发送出去。
  5. ProcessorContext 的实现接收到 forward 调用,找到当前节点的下游 ProcessorNode,并调用它们的 process() 方法,从而启动下一轮处理。

这种设计将框架的管理逻辑 (在 ProcessorNode 中)和用户的业务逻辑 (在 Processor 实现中)清晰地分离开来,使得框架更加健壮,用户的代码也更加专注和易于测试。

总结

ProcessorNode 是 Kafka Streams 运行时的一个核心概念。它不仅仅是一个简单的包装器,更是一个功能完备的执行单元,承担了以下关键角色:

  • 封装与隔离: 将用户的处理逻辑与框架的内部实现隔离开。
  • 生命周期管理 : 负责用户 Processorinit()close() 调用,确保资源正确初始化和释放。
  • 上下文提供者 : 通过 ProcessorContext 为用户代码提供与框架交互的桥梁。
  • 数据流转枢纽: 作为拓扑图中的节点,连接上游和下游,是数据流动的载体。
  • 健壮性保障 : 提供了强大而灵活的异常处理机制,允许用户自定义错误处理策略,并对常见的配置错误(如 ClassCastException)提供了清晰的诊断信息。
  • 可观测性: 通过集成 Metrics 传感器,提供了对节点处理情况(如丢弃记录数)的监控能力。

理解 ProcessorNode 的工作原理,对于深入掌握 Kafka Streams 的内部机制、进行性能调优和问题排查都至关重要。

SinkNode

SinkNodeProcessorNode 的一个特殊子类,它代表了流处理拓扑中的终点。它的唯一职责就是将上游节点处理过的记录发送(写回)到一个或多个 Kafka Topic。

public class SinkNode<KIn, VIn> extends ProcessorNode<KIn, VIn, Void, Void> 就可以看出它的特殊性:它的输出键值类型被固定为 Void, Void,这意味着它不会再有下游节点,数据流到此为止。

下面是 SinkNode 的几个关键的、与 ProcessorNode 不同的处理:

拓扑结构的终结者:禁止添加子节点

SinkNode 是拓扑图的叶子节点,数据流的终点。因此,它在逻辑上不能有任何子节点。为了在代码层面强制执行这个规则,SinkNode 重写了 addChild 方法,直接抛出异常。

java 复制代码
// ... existing code ...
    /**
     * @throws UnsupportedOperationException if this method adds a child to a sink node
     */
    @Override
    public void addChild(final ProcessorNode<Void, Void, ?, ?> child) {
        throw new UnsupportedOperationException("sink node does not allow addChild");
    }
// ... existing code ...

这确保了在构建拓扑时,任何尝试将另一个处理器连接到 SinkNode 下游的操作都会立即失败,保证了拓扑的正确性。

初始化:准备序列化器

SinkNode 的核心任务是将数据写回 Kafka,这个过程需要将内存中的对象(Key 和 Value)序列化成字节数组。因此,在 init 方法中,它除了调用父类的 init 逻辑外,还专门初始化了用于输出的 keySerializervalSerializer

java 复制代码
// ... existing code ...
    @Override
    public void init(final InternalProcessorContext<Void, Void> context) {
        super.init(context);
        this.context = context;
        try {
            keySerializer = prepareKeySerializer(keySerializer, context);
        } catch (ConfigException | StreamsException e) {
            throw new StreamsException(String.format("Failed to initialize key serdes for sink node %s", name()), e, context.taskId());
        }

        try {
            valSerializer = prepareValueSerializer(valSerializer, context);
        } catch (final ConfigException | StreamsException e) {
            throw new StreamsException(String.format("Failed to initialize value serdes for sink node %s", name()), e, context.taskId());
        }
    }
// ... existing code ...

它会检查用户是否为 to() 操作提供了特定的 Serde。如果没有,它会尝试从 StreamsConfig 中获取默认的 Serde 配置。如果两者都没有,初始化就会失败并抛出异常,提示用户进行配置。

核心处理逻辑:发送记录

这是 SinkNodeProcessorNode 最本质的区别。通用的 ProcessorNodeprocess 方法是委托给用户自定义的 Processor。而 SinkNode 重写了 process 方法,其逻辑是固定的:将接收到的记录发送到 Kafka

java 复制代码
// ... existing code ...
    @Override
    public void process(final Record<KIn, VIn> record) {
        // 1. 获取 RecordCollector,这是实际负责发送消息的组件
        final RecordCollector collector = ((RecordCollector.Supplier) context).recordCollector();

        final KIn key = record.key();
        final VIn value = record.value();
        final long timestamp = record.timestamp();

        // ... (创建用于提取 topic 的上下文)

        // 2. 动态确定目标 Topic
        // 使用 TopicNameExtractor 从记录的 key/value 中提取出要发送到的 topic 名称
        final String topic = topicExtractor.extract(key, value, contextForExtraction);

        // 3. 发送记录
        // 调用 collector.send() 方法,将记录发送出去。
        // 这里会用到在 init 阶段准备好的序列化器和可选的分区器。
        collector.send(
            topic,
            key,
            value,
            record.headers(),
            timestamp,
            keySerializer,
            valSerializer,
            name(),
            context,
            partitioner);
    }
// ... existing code ...

这个过程可以分解为三步:

  1. 获取 RecordCollector:从上下文中获取记录收集器,它是 Kafka Streams 内部负责缓冲和发送消息到 Kafka Producer 的组件。
  2. 确定目标 Topic :使用 TopicNameExtractor 来决定这条记录应该被发送到哪个 Topic。这允许了动态路由,即根据记录内容将其发送到不同的 Topic。如果使用 to("my-topic"),这里就是一个静态的 Topic 名称提取器。
  3. 发送记录 :调用 collector.send(),传入 Topic、键、值、时间戳、序列化器以及可选的分区器(partitioner),完成最终的发送操作。

总结

SinkNode 通过继承 ProcessorNode 复用了节点的基本框架(如名称、上下文管理),但通过重写关键方法,实现了其高度专门化的功能:

  • 结构上 ,它是拓扑的终点,addChild 的重写保证了这一点。
  • 功能上 ,它的 process 方法不再是通用的处理逻辑,而是固定的"发送到 Kafka"逻辑。
  • 配置上 ,它特别关注输出所需的序列化器Topic 提取器分区器

可以说,SinkNode 是对 ProcessorNode 的一个具体化和特例化,完美地诠释了"流处理终点"这一角色。

SourceNode

SourceNode 是流处理拓扑的起点 。它的核心职责是从 Kafka Topic 中消费原始的、字节形式的记录,将其反序列化为 Java 对象,提取时间戳,然后将这条记录注入到拓扑中,供下游节点处理。

public class SourceNode<KIn, VIn> extends ProcessorNode<KIn, VIn, KIn, VIn> 可以看出,它继承自 ProcessorNode,并且其输入类型 (KIn, VIn) 和输出类型 (KIn, VIn) 是相同的。这意味着它接收什么类型的记录,就向下游转发什么类型的记录,本身不对记录的键值进行转换。

下面是 SourceNode 的几个关键设计差异点:

核心职责:反序列化与时间戳提取

SinkNode 负责序列化相反,SourceNode 的核心任务是反序列化 。它持有 Deserializer 的引用,并在 StreamTask 从 Kafka Consumer 拉取到原始的 ConsumerRecord<byte[], byte[]> 后,由 RecordDeserializer 调用 SourceNode 的反序列化方法。

java 复制代码
// ... existing code ...
    private Deserializer<KIn> keyDeserializer;
    private Deserializer<VIn> valDeserializer;
    private final TimestampExtractor timestampExtractor;
// ... existing code ...
    KIn deserializeKey(final String topic, final Headers headers, final byte[] data) {
        return keyDeserializer.deserialize(topic, headers, data);
    }

    VIn deserializeValue(final String topic, final Headers headers, final byte[] data) {
        return valDeserializer.deserialize(topic, headers, data);
    }
// ... existing code ...
  • keyDeserializer / valDeserializer: 这两个字段是反序列化器,负责将字节数组转换为用户指定的键值类型对象。
  • timestampExtractor : 这是一个非常重要的组件。Kafka Streams 中的时间是流处理的核心概念之一(用于窗口、连接等操作)。TimestampExtractor 负责从消费到的记录中提取出一个时间戳,作为这条记录在流处理中的"事件时间"。它可以从记录的元数据(默认)、消息体内容等地方提取。

初始化:准备反序列化器

SinkNode 类似,SourceNodeinit 方法中也需要准备好它的核心组件------反序列化器。

java 复制代码
// ... existing code ...
    @Override
    public void init(final InternalProcessorContext<KIn, VIn> context) {
// ... existing code ...
        super.init(context);
        this.context = context;

        try {
            keyDeserializer = prepareKeyDeserializer(keyDeserializer, context);
        } catch (final ConfigException | StreamsException e) {
            throw new StreamsException(String.format("Failed to initialize key serdes for source node %s", name()), e, context.taskId());
        }

        try {
            valDeserializer = prepareValueDeserializer(valDeserializer, context);
        } catch (final ConfigException | StreamsException e) {
            throw new StreamsException(String.format("Failed to initialize value serdes for source node %s", name()), e, context.taskId());
        }
    }
// ... existing code ...

这个过程会检查用户是否通过 Consumed.with(...) 提供了特定的 Serde(其中包含了 Deserializer),如果没有,则会从 StreamsConfig 中获取默认配置。如果都找不到,就会抛出异常。

核心处理逻辑:注入拓扑

SourceNodeprocess 方法非常简洁明了。它的任务不是"处理"数据,而是将已经反序列化好的记录 "注入" 到拓扑中。

java 复制代码
// ... existing code ...
    @Override
    public void process(final Record<KIn, VIn> record) {
        context.forward(record);
        processAtSourceSensor.record(1.0d, context.currentSystemTimeMs());
    }
// ... existing code ...
  1. context.forward(record) : 这是关键步骤。它接收一个已经完全成型(反序列化完毕、时间戳已提取)的 Record 对象,然后通过 ProcessorContext 将其转发给所有的下游子节点。这就是数据流开始在拓扑中传递的第一步。
  2. processAtSourceSensor.record(...): 记录一条度量指标,表示源节点处理了一条记录。

拓扑结构的起点

SinkNode 是终点相反,SourceNode 是拓扑的起点。它没有上游节点(predecessors),但可以有多个下游节点(children)。当用户编写 builder.stream("topic").map(...) 时,map 操作对应的 ProcessorNode 就会成为这个 SourceNode 的子节点。

总结

SourceNodeSinkNodeProcessorNode 的两个对称的、专门化的子类,它们共同构成了 Kafka Streams 拓扑与外部 Kafka Topic 交互的桥梁。

SourceNode 的设计差异可以总结为:

  • 角色 : 拓扑的数据入口
  • 核心任务 : 反序列化 字节记录为 Java 对象,并提取事件时间戳
  • init 逻辑 : 准备 DeserializerTimestampExtractor
  • process 逻辑 : 调用 context.forward() 将记录注入到拓扑中,启动数据流。
  • 结构: 拓扑图的根节点之一,没有父节点。

通过 SourceNodeProcessorNodeSinkNode 这三种节点的组合,Kafka Streams 能够构建出任意复杂的、有向无环的流处理拓扑图。

相关推荐
索迪迈科技3 小时前
java后端工程师进修ing(研一版‖day42)
java·开发语言·学习·算法
半桔3 小时前
【Linux手册】消息队列从原理到模式:底层逻辑、接口实战与责任链模式的设计艺术
java·linux·运维·服务器
名誉寒冰3 小时前
LeetCode 24 两两交换链表中的节点( 迭代与递归)
算法·leetcode·链表
小欣加油3 小时前
leetcode LCR 170.交易逆序对的总数
数据结构·c++·算法·leetcode·职场和发展·排序算法
Chris.Yuan7703 小时前
Java代理模式详解
java·开发语言·代理模式
kyle~3 小时前
排序---希尔排序(Shell Sort)
数据结构·算法·排序算法
Mr.朱鹏3 小时前
ShardingJDBC实战指南
java·jvm·数据库·spring·分库分表·shardingjdbc·shardingshere
学习OK呀3 小时前
从 java8 升级 java17 的调整
java·后端
咔咔一顿操作3 小时前
MySQL 事务管理与锁机制:解决并发场景下的数据一致性问题
java·数据库·mysql