未记录事实核验 · 状态:维护中

记录仅说明核验时间与维护状态,不保证适用于所有环境;使用前请留意正文中的版本、适用条件与风险提示。

warning 适用边界与维护说明

本文适合已有 Java 与 Kafka Streams 基础的读者学习时间语义、窗口、状态存储、连接、容错与监控,并开展风控聚合及查询方案练习,不是可直接部署的完整应用。当前 Micrometer 示例的 Gauge 捕获单次采集值,重复注册不能保证持续更新,且标签未保留线程、任务等区分维度,不宜直接用作生产监控。全文尚未统一客户端适用版本,窗口 API、旧 Transformer、metadataForKey 及异常处理器接口需按选定版本确认兼容性;DLQ 的额外 Producer 投递和分布式 HTTP 查询服务需自行实现。生产参数、告警阈值及备用状态恢复时间仅供结合业务、负载和状态规模评估,不构成时延或可靠性保证。本次仅作全文结构审阅,未进行外部事实核验、编译或运行验证。 已取消无法确定目标的可点击引用「../Flink/03.5-Flink执行引擎深度解析」,保留原提及;Link escapes vault: ../Flink/03.5-Flink执行引擎深度解析

Kafka Streams深入 - 掌握高级流处理技术

abstract 本节概览

  • 学习目标:掌握 Kafka Streams 的时间语义、状态管理、容错与监控
  • 前置知识:Kafka Streams 基础
  • 预计用时:50分钟
  • 后续学习:Schema Registry

高级架构师老陈收到一个棘手需求:实时风控系统需要在毫秒级内完成用户画像聚合、异常检测和决策输出,同时要处理网络延迟导致的乱序事件。"这已经不是简单的流处理了,"老陈深吸一口气,"需要搞清楚时间语义、水位线、状态管理和错误处理——这才是流处理的真正难点。"


流处理时间语义

三种时间概念

理解时间语义是流处理的基础,不同的时间定义会导致完全不同的计算结果。

时间类型 定义 特点 适用场景
Event Time(事件时间) 事件在数据源实际发生的时间 最准确,但可能乱序、延迟到达 业务分析、精确统计
Ingestion Time(摄入时间) 事件进入 Kafka Broker 的时间,可由 LogAppendTime 表示 更接近摄入时刻,但不保证事件时间有序或跨分区全局有序 近实时场景
Processing Time(处理时间) 事件被 Streams 处理时的系统时间 最简单,但跨实例不一致 监控、低精度统计

类比说明: 一封信在北京寄出(Event Time),在路上走了3天(网络延迟),今天到你手上(Processing Time),信封上的邮戳时间(Ingestion Time)。如果你要统计"本周北京寄出的信件数量",必须用 Event Time,而不是你拿到信的时间。

Timestamp Extractor 配置

Streams 通过 Timestamp Extractor 决定每条记录的时间戳。

// 内置 Timestamp Extractor
// 1. FailOnInvalidTimestamp(默认):使用 Kafka 消息时间戳,时间戳无效则抛异常
// 2. LogAndSkipOnInvalidTimestamp:时间戳无效时跳过该消息
// 3. UsePartitionTimeOnInvalidTimestamp:时间戳无效时使用分区最新时间
// 4. WallclockTimestampExtractor:始终用处理时间(不推荐用于精确统计)

// 自定义 Timestamp Extractor:从消息内容提取事件时间
public class EventTimeExtractor implements TimestampExtractor {

    private final ObjectMapper mapper = new ObjectMapper();

    @Override
    public long extract(ConsumerRecord<Object, Object> record, long partitionTime) {
        if (record.value() instanceof String) {
            try {
                JsonNode node = mapper.readTree((String) record.value());
                if (node.has("event_time")) {
                    return node.get("event_time").asLong();
                }
            } catch (Exception e) {
                // 解析失败,回退到分区时间
            }
        }
        // 回退到 Kafka 消息时间戳
        return record.timestamp() >= 0 ? record.timestamp() : partitionTime;
    }
}

// 在 Streams 配置中指定
props.put(StreamsConfig.DEFAULT_TIMESTAMP_EXTRACTOR_CLASS_CONFIG,
          EventTimeExtractor.class.getName());

// 也可以在 stream() 调用时单独指定(覆盖全局配置)
KStream<String, String> stream = builder.stream(
    "events",
    Consumed.with(Serdes.String(), Serdes.String())
            .withTimestampExtractor(new EventTimeExtractor())
);

水位线(Watermark)与 Grace Period

Kafka Streams 没有像 Flink 那样显式的水位线概念,而是通过 Grace Period 来处理迟到数据。

Grace Period 的含义:以 stream-time 为依据,在窗口结束时间加宽限期所界定的关闭条件之前接纳迟到记录;它不是按系统时钟在窗口结束后固定等待 N 时间。

// 10分钟滚动窗口,接受最多2分钟的迟到数据
TimeWindows window = TimeWindows
    .ofSizeAndGrace(Duration.ofMinutes(10), Duration.ofMinutes(2));

KTable<Windowed<String>, Long> counts = stream
    .groupByKey()
    .windowedBy(window)
    .count(Materialized.as("windowed-count-store"));

Grace Period 的权衡: 更大的宽限期可接纳更多迟到记录,但不保证数据完整或结果更准确,也不会单独强制延迟每次结果更新;若等待窗口关闭后再输出最终结果,等待时间才受宽限期影响。状态保留配置需覆盖窗口大小与宽限期。可将实时大屏 0-10 秒、精确统计 1-5 分钟作为配置假设,再按实际迟到分布、输出需求和存储预算评估,而非视为通用生产标准。


窗口操作深入

三种窗口对比

滚动窗口(Tumbling Window):size=10min
|--10min--|--10min--|--10min--|
  窗口1     窗口2     窗口3

跳跃窗口(Hopping Window):size=10min, advance=5min
|----10min----|
     |----10min----|
          |----10min----|
 窗口1      窗口2      窗口3(每条记录属于多个窗口)

会话窗口(Session Window):gap=5min
|--活动--|   |--活动--|
  会话1   5m静默  会话2(窗口大小动态,由活动决定)

窗口结果更新触发机制

KTable 的窗口结果不是在窗口关闭时才输出,而是每收到一条新记录就更新一次(除非启用了 Cache)。

// 关闭 Cache:每条记录立即触发下游更新(低延迟但高吞吐压力)
Materialized<String, Long, WindowStore<Bytes, byte[]>> materialized =
    Materialized.<String, Long, WindowStore<Bytes, byte[]>>as("no-cache-store")
        .withCachingDisabled();

// 启用 Cache(默认):攒批后更新(高吞吐但有延迟)
// 通过 cache.max.bytes.buffering 控制缓存大小
// 通过 commit.interval.ms 控制最长延迟

状态存储高级用法

自定义状态存储

// 注册自定义 KeyValue 状态存储
StoreBuilder<KeyValueStore<String, UserProfile>> storeBuilder =
    Stores.keyValueStoreBuilder(
        Stores.persistentKeyValueStore("user-profile-store"),
        Serdes.String(),
        new UserProfileSerde()
    )
    .withLoggingEnabled(
        // Changelog Topic 的额外配置
        Map.of("cleanup.policy", "compact",
               "min.cleanable.dirty.ratio", "0.1")
    )
    .withCachingEnabled();  // 启用读写缓存

builder.addStateStore(storeBuilder);

在 Processor 中使用状态存储:

public class UserEnrichmentTransformer
        implements Transformer<String, ClickEvent, KeyValue<String, EnrichedEvent>> {

    private ProcessorContext context;
    private KeyValueStore<String, UserProfile> profileStore;

    @Override
    public void init(ProcessorContext context) {
        this.context = context;
        // 获取注册的状态存储
        this.profileStore = context.getStateStore("user-profile-store");

        // 注册定时任务(每30秒清理过期状态)
        context.schedule(Duration.ofSeconds(30),
                         PunctuationType.WALL_CLOCK_TIME,
                         this::cleanupExpiredProfiles);
    }

    @Override
    public KeyValue<String, EnrichedEvent> transform(String key, ClickEvent event) {
        UserProfile profile = profileStore.get(event.getUserId());
        if (profile == null) {
            profile = UserProfile.anonymous();
        }
        return KeyValue.pair(key, new EnrichedEvent(event, profile));
    }

    private void cleanupExpiredProfiles(long timestamp) {
        long expiryMs = Duration.ofHours(24).toMillis();
        try (KeyValueIterator<String, UserProfile> it = profileStore.all()) {
            while (it.hasNext()) {
                KeyValue<String, UserProfile> entry = it.next();
                if (timestamp - entry.value.getLastSeenMs() > expiryMs) {
                    profileStore.delete(entry.key);
                }
            }
        }
    }

    @Override
    public void close() { }
}

// 在 Topology 中使用(指定关联的状态存储名称)
stream.transform(
    () -> new UserEnrichmentTransformer(),
    "user-profile-store"   // 必须与 addStateStore 中的名称一致
).to("enriched-events");

交互式查询(Interactive Queries)

允许外部服务直接查询 Streams 应用的本地状态存储,实现"实时查询流处理结果"。

KafkaStreams streams = new KafkaStreams(topology, props);
streams.start();

// 等待应用进入 RUNNING 状态
while (streams.state() != KafkaStreams.State.RUNNING) {
    Thread.sleep(100);
}

// 查询 KeyValue 存储
ReadOnlyKeyValueStore<String, Long> countStore =
    streams.store(StoreQueryParameters.fromNameAndType(
        "wordcount-store",
        QueryableStoreTypes.keyValueStore()
    ));

// 点查
Long count = countStore.get("kafka");
System.out.println("kafka 出现次数: " + count);

// 范围查询
try (KeyValueIterator<String, Long> range = countStore.range("a", "k")) {
    range.forEachRemaining(kv ->
        System.out.println(kv.key + ": " + kv.value));
}

// 查询 Window 存储
ReadOnlyWindowStore<String, Long> windowStore =
    streams.store(StoreQueryParameters.fromNameAndType(
        "windowed-count-store",
        QueryableStoreTypes.windowStore()
    ));

// 查询某个 Key 在特定时间范围内的窗口结果
Instant from = Instant.now().minus(Duration.ofHours(1));
Instant to = Instant.now();
try (WindowStoreIterator<Long> it = windowStore.fetch("user-123", from, to)) {
    while (it.hasNext()) {
        KeyValue<Long, Long> kv = it.next();
        System.out.println("窗口开始时间=" + kv.key + ", 计数=" + kv.value);
    }
}

note 分布式查询注意事项

在分布式部署中(多个 Streams 实例),每个实例只保存部分分区的状态。如果查询的 Key 不在当前实例,需要通过 streams.metadataForKey() 找到持有该 Key 的实例,再通过 HTTP 转发查询。


流连接深入

Co-partition 要求详解

KStream-KStream Join 和 KStream-KTable Join 都要求参与连接的 Topic 分区数相同,且使用相同的分区策略(默认按 Key 哈希)。

为什么需要 Co-partition?
Streams 将同一分区号的 Task 调度到同一线程处理。
如果 orders 的 partition-0 和 payments 的 partition-0 由同一个 Task 处理,
那么相同 Key 的记录就会在同一个 Task 中相遇,Join 才有意义。

如果参与连接的输入不满足共分区要求,不能假定 Kafka Streams 会自动修复源 Topic 的分区数。
应通过满足连接要求的拓扑重分区或重新组织输入,使两侧分区数及按连接键的分区策略一致;内部重分区会增加延迟和存储开销。

检查和修复分区数不一致:

# 检查 Topic 分区数
kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic orders
kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic payments

# 增加分区数的命令示例(只能增加,不能减少);扩分区不会重分布历史记录,不能单独保证连接输入重新满足共分区要求
kafka-topics.sh --bootstrap-server localhost:9092 \
  --alter --topic payments --partitions 6

Outer Join 与 null 值处理

// KStream-KStream Outer Join:两边都可能为 null
KStream<String, String> orderPaymentJoined = orders.outerJoin(
    payments,
    (orderValue, paymentValue) -> {
        // orderValue 或 paymentValue 可能为 null
        String order = orderValue != null ? orderValue : "NO_ORDER";
        String payment = paymentValue != null ? paymentValue : "NO_PAYMENT";
        return order + " | " + payment;
    },
    JoinWindows.ofTimeDifferenceAndGrace(
        Duration.ofMinutes(5),  // 时间窗口
        Duration.ofSeconds(30)  // 宽限期
    ),
    StreamJoined.with(Serdes.String(), Serdes.String(), Serdes.String())
);

错误处理

反序列化异常处理器

// 实现 DeserializationExceptionHandler:处理反序列化失败的消息
public class DeadLetterDeserializationHandler
        implements DeserializationExceptionHandler {

    @Override
    public DeserializationHandlerResponse handle(
            ProcessorContext context,
            ConsumerRecord<byte[], byte[]> record,
            Exception exception) {

        // 记录错误信息
        log.error("反序列化失败: topic={}, partition={}, offset={}, error={}",
                  record.topic(), record.partition(), record.offset(),
                  exception.getMessage());

        // 可以在这里将原始字节写入 Dead Letter Queue
        // (需要通过额外的 Producer 实现)

        // CONTINUE:跳过此消息,继续处理
        // FAIL:停止处理并报告失败;是否重启取决于外部进程管理或另行实现的恢复策略
        return DeserializationHandlerResponse.CONTINUE;
    }

    @Override
    public void configure(Map<String, ?> configs) { }
}

// 在配置中指定
props.put(StreamsConfig.DEFAULT_DESERIALIZATION_EXCEPTION_HANDLER_CLASS_CONFIG,
          DeadLetterDeserializationHandler.class.getName());

生产异常处理器

// 实现 ProductionExceptionHandler:处理消息写入 Kafka 失败的情况
public class RetryProductionExceptionHandler
        implements ProductionExceptionHandler {

    @Override
    public ProductionExceptionHandlerResponse handle(
            ProducerRecord<byte[], byte[]> record,
            Exception exception) {

        if (exception instanceof RecordTooLargeException) {
            log.error("消息过大,跳过: topic={}, size={}",
                      record.topic(), record.value().length);
            return ProductionExceptionHandlerResponse.CONTINUE;
        }

        // 其他交由该处理器处理的错误返回 FAIL;此响应本身不保证自动重启
        log.error("写入 Kafka 失败,停止应用: {}", exception.getMessage());
        return ProductionExceptionHandlerResponse.FAIL;
    }

    @Override
    public void configure(Map<String, ?> configs) { }
}

props.put(StreamsConfig.DEFAULT_PRODUCTION_EXCEPTION_HANDLER_CLASS_CONFIG,
          RetryProductionExceptionHandler.class.getName());

未捕获异常处理器(线程级)

// Streams 2.8+ 推荐使用 StreamsUncaughtExceptionHandler
streams.setUncaughtExceptionHandler(exception -> {
    log.error("Streams 线程遇到未捕获异常: {}", exception.getMessage(), exception);

    if (exception instanceof OutOfMemoryError) {
        // OOM:整个应用需要重启
        return StreamsUncaughtExceptionHandler.StreamThreadExceptionResponse
               .SHUTDOWN_APPLICATION;
    }

    // 其他情况:只替换当前线程,不影响整个应用
    return StreamsUncaughtExceptionHandler.StreamThreadExceptionResponse
           .REPLACE_THREAD;
});

扩展与再均衡

线程数与任务分配

Streams 应用的并行模型:
- 一个 JVM 实例 = 多个 Stream Threads(由 num.stream.threads 控制)
- 一个 Stream Thread = 多个 Tasks
- 一个 Task = 处理输入 Topic 的一个分区

单个子拓扑的活动 Task 数通常由该子拓扑输入分区数的最大值决定;应用若包含多个子拓扑,应分别计算 Task 数,不能用全部输入 Topic 分区数的最大值概括整个应用的并行度。
// 配置线程数(建议与 CPU 核数相同或略少)
props.put(StreamsConfig.NUM_STREAM_THREADS_CONFIG, 4);

// 运行时动态调整线程数(无需重启)
streams.addStreamThread();   // 增加一个线程
streams.removeStreamThread(); // 减少一个线程

Standby Replica:减少故障转移延迟

// 配置备用副本
props.put(StreamsConfig.NUM_STANDBY_REPLICAS_CONFIG, 1);

工作机制:

没有 Standby Replica 时的故障转移:
实例A(持有 Task-0 状态)宕机
→ 实例B 接管 Task-0
→ 实例B 从 Changelog Topic 重放所有状态(可能需要几分钟)
→ 恢复正常处理

有 Standby Replica 时的故障转移:
实例A(持有 Task-0 主状态)同时,实例B 保持 Task-0 的备用副本(持续同步)
→ 实例A 宕机
→ 假设实例B 的备用状态接近最新,仅积压几秒的 Changelog
→ 在积压较小且再均衡、资源等条件允许时,追赶并接管可达到秒级;不作为通用恢复保证

每个 Standby Replica 会带来额外的存储和网络开销(追 Changelog),并可能减少故障后的状态恢复工作;分钟级恢复缩短到秒级可作为特定负载下的目标假设,实际恢复时间取决于备用状态积压、状态规模、再均衡及可用资源。


Kafka Streams vs KSQL(ksqlDB)

维度 Kafka Streams ksqlDB(KSQL)
使用方式 Java 代码(编程式) SQL 语句(声明式)
灵活性 极高(可实现任意逻辑) 受限于 SQL 语法
上手难度 需要 Java 开发能力 SQL 即可
部署方式 嵌入业务应用 独立的 ksqlDB 服务
调试能力 标准 Java 工具链 KSQL CLI / REST API
状态管理 完全控制 自动管理
适用场景 复杂业务逻辑、需要精确控制 快速原型、数据探索、简单 ETL

选型建议:

  • 如果团队有 Java 能力且逻辑复杂(自定义状态、特殊 Join 语义、与业务代码集成)→ Kafka Streams
  • 如果需要快速上线、逻辑相对简单、团队偏 SQL 背景 → ksqlDB
  • 两者底层机制相同(都基于 Kafka),可以共存于同一数据平台

监控指标

JMX 关键指标

Kafka Streams 通过 JMX 暴露指标,命名空间为 kafka.streams。

应用级指标(kafka.streams:type=stream-metrics,client-id=...):

指标名 含义 告警阈值参考
state 应用状态(按相应版本的状态编码解释;使用枚举序号时,2=RUNNING、1=REBALANCING) 对持续异常状态告警,正常再均衡需区别处理

线程级指标(kafka.streams:type=stream-thread-metrics,thread-id=...):

指标名 含义 告警阈值参考
process-rate 每秒处理的记录数 持续为0时告警
process-latency-avg 平均处理延迟(ms) >1000ms 告警
commit-rate 每秒 Commit 次数 过低说明处理堵塞
poll-rate 每秒 Poll 次数
task-created-total 创建的 Task 总数 频繁变化说明再均衡

Task 级指标(kafka.streams:type=stream-task-metrics,task-id=...):

指标名 含义
process-rate Task 的处理速率
record-latency-avg 从消息生产到被处理的平均延迟
dropped-records-total 被丢弃的记录总数(反序列化失败等)

消费者 Lag 指标(最重要的延迟指标):

# 通过 kafka-consumer-groups.sh 查看 Streams 应用的 Lag
# Streams 的消费者组 ID 就是 application.id
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --describe --group wordcount-v1

# 输出示例:
# GROUP          TOPIC                PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
# wordcount-v1   text-input           0          12345           12400           55
# wordcount-v1   text-input           1          8901            8901            0

使用 Micrometer 暴露指标到 Prometheus

// 添加依赖:kafka-streams-micrometer(第三方库)或自定义收集

// 通过 KafkaStreams.metrics() 获取所有指标
Map<MetricName, ? extends Metric> metrics = streams.metrics();

// 定期采集并上报
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
    metrics.forEach((name, metric) -> {
        Object value = metric.metricValue();
        if (value instanceof Double) {
            // 上报到 Prometheus / Grafana
            Gauge.builder(normalizeMetricName(name), () -> (Double) value)
                 .tag("group", name.group())
                 .tag("client-id", name.tags().getOrDefault("client-id", ""))
                 .register(meterRegistry);
        }
    });
}, 0, 15, TimeUnit.SECONDS);

生产环境部署要点

推荐配置(高吞吐 + 高可靠)

Properties props = new Properties();

// 基础配置
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "risk-control-streams");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG,
          "kafka-01:9092,kafka-02:9092,kafka-03:9092");

// 精确一次语义(EXACTLY_ONCE_V2 需 Kafka Streams 客户端 3.0+,并要求 Broker 2.5+;具体性能应按负载评估)
props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG,
          StreamsConfig.EXACTLY_ONCE_V2);

// 线程和副本
props.put(StreamsConfig.NUM_STREAM_THREADS_CONFIG, 4);
props.put(StreamsConfig.NUM_STANDBY_REPLICAS_CONFIG, 1);
props.put(StreamsConfig.REPLICATION_FACTOR_CONFIG, 3);

// 状态存储
props.put(StreamsConfig.STATE_DIR_CONFIG, "/data/kafka-streams");

// 性能调优
props.put(StreamsConfig.COMMIT_INTERVAL_MS_CONFIG, 1000);     // 1秒提交一次
props.put(StreamsConfig.CACHE_MAX_BYTES_BUFFERING_CONFIG,
          50 * 1024 * 1024);  // 50MB 缓存

// 覆盖消费者参数(用于优化 Sink Task)
props.put(StreamsConfig.consumerPrefix(ConsumerConfig.MAX_POLL_RECORDS_CONFIG), 1000);
props.put(StreamsConfig.consumerPrefix(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG), 30000);

// 覆盖生产者参数
props.put(StreamsConfig.producerPrefix(ProducerConfig.COMPRESSION_TYPE_CONFIG), "lz4");
props.put(StreamsConfig.producerPrefix(ProducerConfig.BATCH_SIZE_CONFIG), 65536);

// 指标采集级别
props.put(StreamsConfig.METRICS_RECORDING_LEVEL_CONFIG, "INFO");

优雅关闭

KafkaStreams streams = new KafkaStreams(topology, props);
streams.start();

// 注册 JVM 关闭钩子
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    log.info("收到关闭信号,开始优雅关闭...");
    // 等待最多30秒完成正在处理的记录和 Offset 提交
    streams.close(Duration.ofSeconds(30));
    log.info("Streams 应用已关闭");
}));

掌握检查

  • [ ] 能解释 Event Time、Processing Time 的区别,并说明何时应选哪种
  • [ ] 能实现自定义 Timestamp Extractor 从消息 payload 中提取事件时间
  • [ ] 能解释 Grace Period 与乱序处理的关系
  • [ ] 能用交互式查询(Interactive Queries)实现实时状态查询接口
  • [ ] 能配置 DeserializationExceptionHandler 实现 Dead Letter Queue 模式
  • [ ] 能解释 Standby Replica 的工作机制及其对故障恢复时间的影响
  • [ ] 能在 JMX 或 kafka-consumer-groups.sh 中找到 Streams 应用的关键指标

练习题

  1. 你的风控 Streams 应用使用事件时间聚合,但发现有些手机 App 在弱网环境下会延迟上报数据(最长延迟30分钟)。如何配置才能既准确捕获这些事件,又不让状态存储无限增长?
  2. 生产中 Streams 应用突然出现 process-rate 接近0的情况,但应用还在运行,有哪些可能的原因?排查步骤是什么?
  3. 你需要实现一个功能:实时查询任意用户的"最近1小时内的消费总额",并通过 HTTP API 对外提供查询服务。基于交互式查询,写出关键实现思路。
  4. 将 Kafka Streams 方案与 ksqlDB 方案对比:实现"用户每分钟下单超过5次则触发异常告警"这个需求,分别需要写什么代码/SQL?各有什么优劣?

学习连接

前置知识: Kafka Streams 基础 下一步学习: Schema Registry - 数据模式管理 相关概念:

  • Flink 时间语义对比
  • 流处理架构设计
  • 分布式系统监控

tip 生产环境核心建议

部署生产级 Streams 应用的三个关键决策:

  1. 时间语义:默认用 Event Time,只有在没有可靠事件时间戳时才用 Processing Time
  2. 容错配置:按业务一致性要求选择处理保证;需要精确一次时可考虑 EXACTLY_ONCE_V2,需要缩短有状态任务恢复时间时可配置至少1个 Standby Replica,但二者不是所有生产应用的统一底线
  3. 监控优先:consumer lag 是最重要的健康指标,持续 lag 增长意味着处理跟不上消费