Курс по Apache Flink 2.2 для Java-разработчика

Flink в проде

За 21 урок ты собираешь PayGuard, антифрод для потока платежей, и доводишь его от первого задания до Kubernetes, мониторинга и разбора аварий.

Что ты получишь

Курс рассчитан на разработчика, который уверенно пишет на Java и работал со Spark, но Flink ещё не трогал. Каждая тема сразу попадает в проект. К концу у тебя будет рабочая система с точной обработкой без дублей, правилами, которые меняются без перезапуска, запросами на SQL, сквозными тестами, дашбордом в Grafana и выкаткой через оператор в Kubernetes.

Теория

Как механизм устроен внутри, с анимациями и симуляторами, которые можно покрутить руками.

Исходники

Объяснения устройства Flink подтверждены кодом Flink со ссылкой на строки в теге release-2.2.1 и на раздел документации.

Практика

Команды, файлы и зависимости целиком. Код вставлен из снапшотов проекта, которые собирались и запускались.

Задание и ответ

Задача на проект и готовое решение для сверки. Ответ скрыт, пока ты сам его не откроешь.

Глоссарий

Отдельная вкладка в каждом уроке. Первое упоминание термина на вкладке подсвечено, подсказка открывается по наведению.

Настоящие цифры

Длительность чекпоинтов, дубли после падений и размеры состояния сняты с живого кластера на этой нагрузке.

Как проходить

  1. Читай вкладку «Теория» и решай квиз в её конце.
  2. Проходи «Практику» у себя в терминале. Каждый урок продолжает предыдущий.
  3. Сделай «Задание» сам, потом сверь с вкладкой «Ответ».
  4. Отставший или сломанный проект можно заменить папкой solutions/lesson-NN. Там полное состояние проекта после урока вместе с заданием.

Что поставить заранее

  • JDK 17. Flink 2.2 официально поддерживает Java 17, поддержка Java 21 пока экспериментальная.
  • Docker с Compose v2 и хотя бы 8 ГиБ памяти для контейнеров.
  • Утилиты curl и jq. Для урока 20 ещё kubectl, helm и minikube.

Gradle ставить не нужно, он приедет через wrapper вместе с проектом. На macOS, где JDK по умолчанию новее, перед сборкой выполни export JAVA_HOME=$(/usr/libexec/java_home -v 17).

Почему Flink 2.2.1, если вышел 2.3.0

Коннектор Kafka выходит отдельно от самого Flink, и последняя его версия на момент написания курса (ноябрь 2026) это 5.0.0-2.2, собранная под Flink 2.2. Под 2.3 коннектора ещё нет, поэтому весь стек закреплён на 2.2.1. В проде такая проверка совместимости идёт перед каждым обновлением, и в уроке 21 мы её разберём отдельно.

Урок 1 · Основы

Зачем Flink, если есть Spark, и как поднять рабочий стенд

После урока ты сможешь объяснить, в каких задачах Flink выигрывает у Spark, и запустишь первое задание, которое читает поток платежей из Kafka.

Задача, ради которой всё затевается

Мошенник получил данные карты и проверяет их серией мелких платежей. Если система заметит серию через пять минут, деньги уже ушли. Если через секунду, банк успевает отклонить следующий платёж. Весь курс мы строим такую систему и называем её PayGuard.

У этой задачи три свойства, которые определяют выбор инструмента. Данные не заканчиваются, поэтому нельзя дождаться конца файла и посчитать результат. Решение зависит от истории карты за последние минуты, поэтому система обязана помнить прошлое. Ошибка стоит денег в обе стороны, поэтому потерянный или задвоенный платёж недопустим даже при падении сервера.

Apache Flink создан под такие условия. Это движок обработки потоков с состоянием, то есть программа на нём получает события по одному и держит между ними данные, которые переживают падения.

Шаттл и траволатор

Представь аэропорт. До выхода на посадку можно добраться автобусом-шаттлом, который отправляется каждые пять минут и забирает всех, кто успел подойти. А можно встать на траволатор, и он повезёт тебя в ту же секунду, как ты на него шагнул.

Spark Structured Streaming в обычном режиме работает как шаттл. Он копит записи, запускает маленькую пакетную задачу (micro-batch) и выдаёт результат всей порции разом. Flink работает как траволатор. Его операторы запущены постоянно, и запись идёт через них сразу после появления в источнике.

Сдвинь ползунок и посмотри, как интервал шаттла влияет на задержку. Модель упрощённая, в ней нет времени на саму обработку, зато видна причина разницы.

Гонка двух моделей

Нажми кнопку, и 40 событий за 10 секунд модельного времени поедут по обеим дорожкам.

Из этой разницы не следует, что Flink всегда быстрее по записям в секунду. На больших объёмах пакетная обработка очень эффективна, и Spark силён именно там. У Flink задержку определяют логика задания и сетевые буферы, у micro-batch ещё и интервал запуска.

В исходниках

Посмотрим, что такое запущенная подзадача Flink. Метод processInput вызывается в цикле на протяжении всей её жизни. Он просит следующую запись у обработчика входа и смотрит на статус. Понятия «порция» или «граница пакета» здесь нет.

apache/flink@release-2.2.1 · StreamTask.java, строки 645-655
protected void processInput(MailboxDefaultAction.Controller controller) throws Exception {
    DataInputStatus status = inputProcessor.processInput();
    switch (status) {
        case MORE_AVAILABLE:
            if (taskIsAvailable()) {
                return;
            }
            break;
        case NOTHING_AVAILABLE:
            break;
        case END_OF_RECOVERY:

Статус MORE_AVAILABLE значит, что можно сразу брать следующую запись. Цикл завершится, только если задание остановят или закончатся данные.

Совсем без порций тоже не обходится, потому что поштучная отправка по сети была бы слишком дорогой. Записи складываются в сетевой буфер, и он уходит либо заполненным, либо по таймауту. Настройка execution.buffer-timeout.interval ограничивает это ожидание.

apache/flink@release-2.2.1 · ExecutionOptions.java, строки 107-122
public static final ConfigOption<Duration> BUFFER_TIMEOUT =
        ConfigOptions.key("execution.buffer-timeout.interval")
                .durationType()
                .defaultValue(Duration.ofMillis(100))
                .withDeprecatedKeys("execution.buffer-timeout")
                .withDescription(
                        Description.builder()
                                .text(
                                        "The maximum time frequency (milliseconds) for the flushing of the output buffers. By default "
                                                + "the output buffers flush frequently to provide low latency and to aid smooth developer "
                                                + "experience. Setting the parameter can result in three logical modes:")
                                .list(
                                        text(
                                                "A positive value triggers flushing periodically by that interval"),
                                        text(
                                                FLUSH_AFTER_EVERY_RECORD

Четыре вещи, которые Flink берёт на себя

Написать цикл чтения из Kafka можно и на обычной Java. Flink ценен тем, что решает трудные части за тебя, и курс построен вокруг них.

  • Время события. Платёж мог прийти с опозданием из-за сети, а считать его надо в той минуте, когда он случился. Об этом уроки 5 и 6.
  • Состояние. Счётчик платежей карты живёт внутри задания, может занимать терабайты и читается без обращения к внешней базе. Об этом уроки 7 и 19.
  • Восстановление без потерь. Flink регулярно снимает согласованный снимок всего задания и после падения продолжает с него. Об этом уроки 11 и 12.
  • Изменение на ходу. Задание можно остановить со снимком, обновить код, сменить параллелизм и продолжить с того же места. Об этом урок 13.

Когда Flink не нужен

Ночной отчёт по таблице в озере данных Spark посчитает проще и дешевле. Переход не окупится и тогда, когда задержка в минуту никому не мешает и команда уже знает Spark. Простое преобразование топика без состояния часто проще сделать на Kafka Connect или маленьком консьюмере. Flink оправдан там, где одновременно нужны низкая задержка, состояние и корректность при сбоях.

Что мы построим

PayGuard читает платежи из Kafka, чистит их и проверяет правилами, а алерты отправляет обратно в Kafka. Цепочка будет расти от урока к уроку. Сегодня работает только левая часть схемы, источник и печать в лог.

Генератор Kafkapayments.raw Задание Flink Очистка Правила Обогащение состояние по каждой карте, снимки на диск Kafkafraud.alerts PostgreSQLитоги, продавцы

Почему твой конфиг молчит

В папке проекта лежит твой Makefile с образом flink:latest и файлом flink-conf.yaml. Если выполнить make start, контейнеры поднимутся, в логе JobManager не будет ошибок, и при этом REST-интерфейс покажет "taskmanagers":0. Процесс TaskManager исполняет задания, и кластер без него не умеет ничего. Причин две. Первая в том, что flink:latest на момент проверки это Flink 2.3.0, а вторая в самом файле.

В исходниках

Flink 2.x читает настройки из файла с другим именем. Константа задаёт его жёстко.

apache/flink@release-2.2.1 · GlobalConfiguration.java, строки 43-43
public static final String FLINK_CONF_FILENAME = "config.yaml";

При загрузке берётся только этот файл, старое имя в коде больше не встречается.

apache/flink@release-2.2.1 · GlobalConfiguration.java, строки 137-147
// get Flink yaml configuration file
Configuration configuration;
File yamlConfigFile = new File(confDirFile, FLINK_CONF_FILENAME);
if (!yamlConfigFile.exists()) {
    throw new IllegalConfigurationException(
            "The Flink config file '"
                    + yamlConfigFile
                    + "' ("
                    + yamlConfigFile.getAbsolutePath()
                    + ") does not exist.");
} else {

В образе рядом лежит родной config.yaml, поэтому исключения не возникает. Твой файл просто никто не открывает. Адрес JobManager остаётся значением из образа, TaskManager подключается не туда, и ни один из процессов не считает это фатальной ошибкой.

Если перенести те же настройки в правильный файл, всплывут другие проблемы. Таблица показывает, что произойдёт с каждой строкой.

Строка в твоём файлеЧто с ней не такКак правильно во Flink 2.2
state.backend: filesystemКлюч устарел, и значения filesystem больше нетstate.backend.type: hashmap
state.checkpoints.dirКлюч переименованexecution.checkpointing.dir
state.savepoints.dirКлюч переименованexecution.checkpointing.savepoint-dir
taskmanager.number.ofTaskSlotsОпечатка, такого ключа нет, и слотов будет один вместо четырёхtaskmanager.numberOfTaskSlots
rootLogger.levelЭто настройка Log4j, ей место в log4j.propertiesУбрать из файла
Четыре настройки памяти разомОни противоречат друг другу, сеть получает 0 байтОставить только taskmanager.memory.process.size

Последняя строка проверена запуском. С твоими значениями памяти TaskManager падает при старте и пишет, что вычисленный размер сетевой памяти равен нулю.

лог TaskManager
org.apache.flink.configuration.IllegalConfigurationException: TaskManager memory configuration failed:
If Total Flink, Task Heap and (or) Managed Memory sizes are explicitly configured then the Network Memory size
is the rest of the Total Flink memory after subtracting ...
Caused by: Derived Network Memory size (0 bytes) is not in configured Network Memory range [64.000mb (67108864 bytes), ...]
⚠
Тег latest опасен для Flink

Коннекторы выходят отдельно от движка и собираются под конкретную версию. Образ с тегом latest однажды обновится до версии, под которую твоего коннектора ещё нет. В курсе версия закреплена везде, от образа до зависимостей Gradle.

Проверь себя

Какую из задач разумнее всего решать на Flink?

Коллега смонтировал рабочий flink-conf.yaml с 16 слотами в образ Flink 2.2. Задание запускается, но в интерфейсе один слот. Почему?

Что будет в конце

На твоей машине работают Kafka и кластер Flink из одного JobManager и одного TaskManager. Генератор пишет платежи в топик, а задание печатает их в лог. Это скелет, на котором будет расти весь проект.

Шаг 1. Создай проект

Создай папку payguard рядом со своим Makefile, а внутри два файла. Первый задаёт имя проекта. Gradle ставить не обязательно, wrapper можно скопировать из solutions/lesson-01, это папка gradle/ и файлы gradlew и gradlew.bat. Wrapper сам скачает Gradle 9.8.1 при первом запуске.

settings.gradle.kts
rootProject.name = "payguard"

Второй файл описывает сборку и зависимости.

build.gradle.kts
plugins {
    java
    id("com.gradleup.shadow") version "9.6.1"
}

group = "dev.study.payguard"
version = "1.0.0"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}

val flinkVersion = "2.2.1"
val kafkaConnectorVersion = "5.0.0-2.2"
val jacksonVersion = "2.21.0"

// Классы, которые уже лежат в образе Flink: нужны для компиляции и тестов, в jar не попадают.
val flinkProvided: Configuration by configurations.creating
configurations.compileOnly { extendsFrom(flinkProvided) }
configurations.testImplementation { extendsFrom(flinkProvided) }

dependencies {
    flinkProvided("org.apache.flink:flink-streaming-java:$flinkVersion")
    flinkProvided("org.apache.flink:flink-clients:$flinkVersion")
    flinkProvided("org.apache.flink:flink-connector-base:$flinkVersion")

    implementation("org.apache.flink:flink-connector-kafka:$kafkaConnectorVersion")
    implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")

    runtimeOnly("org.slf4j:slf4j-simple:2.0.17")

    testImplementation(platform("org.junit:junit-bom:6.1.3"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
    testImplementation("org.assertj:assertj-core:3.27.7")
}

tasks.test {
    useJUnitPlatform()
}

tasks.shadowJar {
    archiveClassifier = ""
    manifest { attributes["Main-Class"] = "dev.study.payguard.job.PayGuardJob" }
    mergeServiceFiles()
    filesMatching("META-INF/services/**") { duplicatesStrategy = DuplicatesStrategy.INCLUDE }
    dependencies { exclude(dependency("org.slf4j:.*:.*")) }
    exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
}

tasks.build {
    dependsOn(tasks.shadowJar)
}

tasks.register<JavaExec>("loadgen") {
    group = "application"
    description = "Запускает генератор платежей: ./gradlew loadgen --args='--rate 200'"
    classpath = sourceSets.main.get().runtimeClasspath
    mainClass = "dev.study.payguard.loadgen.LoadGenerator"
    systemProperty("org.slf4j.simpleLogger.defaultLogLevel", "warn")
}

Разберём места, где сборка Flink-задания отличается от обычного Java-приложения. Самое важное здесь разделение зависимостей.

КОД
// Классы, которые уже лежат в образе Flink: нужны для компиляции и тестов, в jar не попадают.
val flinkProvided: Configuration by configurations.creating
configurations.compileOnly { extendsFrom(flinkProvided) }
configurations.testImplementation { extendsFrom(flinkProvided) }

dependencies {
    flinkProvided("org.apache.flink:flink-streaming-java:$flinkVersion")
    flinkProvided("org.apache.flink:flink-clients:$flinkVersion")
    flinkProvided("org.apache.flink:flink-connector-base:$flinkVersion")

    implementation("org.apache.flink:flink-connector-kafka:$kafkaConnectorVersion")
    implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")

    runtimeOnly("org.slf4j:slf4j-simple:2.0.17")
ПРОСТЫМИ СЛОВАМИ

Создаём свою группу зависимостей flinkProvided. Классы Flink уже лежат в образе, и если положить их в jar второй раз, начнутся конфликты загрузчиков классов.

Эти классы нужны при компиляции и в тестах, поэтому compileOnly и testImplementation наследуют группу.

flink-streaming-java это DataStream API, flink-clients умеет запускать задание, flink-connector-base содержит общую основу коннекторов.

Коннектор Kafka в образе отсутствует, поэтому он идёт через implementation и попадает в jar. Его версия 5.0.0-2.2 читается как версия коннектора плюс версия Flink, под которую он собран.

Jackson нужен для JSON в генераторе и позже в задании.

Реализация логгера нужна только генератору на твоей машине. В кластере логгер уже есть.

Блок tasks.shadowJar собирает толстый jar, в котором лежит твой код и все зависимости из implementation. Строка с Main-Class нужна, чтобы flink run нашёл точку входа без лишних аргументов. Вызовы mergeServiceFiles и filesMatching склеивают файлы META-INF/services из разных библиотек. Без них коннектор теряет регистрацию своих компонентов. Исключение подписей *.SF нужно потому, что подпись чужого jar становится недействительной после пересборки.

Шаг 2. Опиши стенд в Docker Compose

Положи в корень проекта docker-compose.yml.

docker-compose.yml
name: payguard

x-flink-common: &flink-common
  image: flink:2.2.1-java17
  environment:
    FLINK_PROPERTIES: |
      jobmanager.rpc.address: jobmanager
      taskmanager.numberOfTaskSlots: 4
      parallelism.default: 2
      jobmanager.memory.process.size: 1600m
      taskmanager.memory.process.size: 2048m
  volumes:
    - ./build/libs:/jobs:ro
    - ./data:/flink-data

services:
  kafka:
    image: apache/kafka:4.3.2
    ports:
      - "9094:9094"
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_LISTENERS: INTERNAL://:9092,CONTROLLER://:9093,EXTERNAL://:9094
      KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:9092,EXTERNAL://localhost:9094
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT
      KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
    healthcheck:
      test: ["CMD-SHELL", "/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list"]
      interval: 5s
      timeout: 10s
      retries: 20

  kafka-init:
    image: apache/kafka:4.3.2
    depends_on:
      kafka:
        condition: service_healthy
    entrypoint: ["/bin/bash", "-c"]
    command:
      - |
        for topic in payments.raw payments.dlq fraud.alerts rules.config; do
          /opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 \
            --create --if-not-exists --topic "$$topic" --partitions 4 --replication-factor 1
        done

  jobmanager:
    <<: *flink-common
    command: jobmanager
    ports:
      - "8081:8081"

  taskmanager:
    <<: *flink-common
    command: taskmanager
    depends_on:
      - jobmanager

Настройки Flink переданы переменной FLINK_PROPERTIES. Скрипт запуска образа дописывает её содержимое в родной config.yaml, поэтому ключи здесь проверенные и файл монтировать не нужно. Папка build/libs смонтирована в контейнер как /jobs, так собранный jar виден JobManager. У Kafka два слушателя. Контейнеры ходят на kafka:9092, а твоя машина на localhost:9094. Служба kafka-init создаёт топики с четырьмя партициями и завершается.

Шаг 3. Модель и генератор платежей

Платёж описан неизменяемым record. Сумма хранится в минимальных единицах валюты в long, чтобы не ловить ошибки округления double.

src/main/java/dev/study/payguard/model/PaymentEvent.java
package dev.study.payguard.model;

/** Платёж по карте: неизменяемый факт, который случился в момент {@code eventTimeMs}. */
public record PaymentEvent(
        String paymentId,
        String cardId,
        String merchantId,
        long amountMinor,
        String currency,
        String country,
        long eventTimeMs) {
}

Генератор создаёт нагрузку, близкую к настоящей. Треть платежей идёт в одного продавца m-0000, часть приходит с опозданием, часть дублируется, часть битая. Эта грязь понадобится нам в следующих уроках.

src/main/java/dev/study/payguard/loadgen/PaymentFactory.java
package dev.study.payguard.loadgen;

import dev.study.payguard.model.PaymentEvent;
import java.util.List;
import java.util.Random;
import java.util.UUID;

/** Создаёт правдоподобные платежи. Random передаётся снаружи, чтобы тесты были повторяемыми. */
final class PaymentFactory {

    private static final int CARD_COUNT = 5_000;
    private static final int MERCHANT_COUNT = 50;
    private static final String HOT_MERCHANT = "m-0000";
    private static final double HOT_MERCHANT_SHARE = 0.3;
    private static final long MAX_LATENESS_MS = 30_000;
    private static final List<String> COUNTRIES = List.of("DE", "FR", "PL", "ES", "NL");

    private final Random random;
    private final double lateShare;

    PaymentFactory(Random random, double lateShare) {
        this.random = random;
        this.lateShare = lateShare;
    }

    PaymentEvent next(long nowMs) {
        return new PaymentEvent(
                new UUID(random.nextLong(), random.nextLong()).toString(),
                "c-%04d".formatted(random.nextInt(CARD_COUNT)),
                nextMerchant(),
                100 + random.nextInt(50_000),
                "EUR",
                COUNTRIES.get(random.nextInt(COUNTRIES.size())),
                nextEventTime(nowMs));
    }

    private String nextMerchant() {
        return random.nextDouble() < HOT_MERCHANT_SHARE
                ? HOT_MERCHANT
                : "m-%04d".formatted(1 + random.nextInt(MERCHANT_COUNT - 1));
    }

    private long nextEventTime(long nowMs) {
        return random.nextDouble() < lateShare
                ? nowMs - (long) (random.nextDouble() * MAX_LATENESS_MS)
                : nowMs;
    }
}
src/main/java/dev/study/payguard/loadgen/RawRecordSource.java
package dev.study.payguard.loadgen;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.study.payguard.model.PaymentEvent;
import java.util.Random;

/** Превращает платежи в строки JSON и подмешивает грязь: дубликаты и битые записи. */
final class RawRecordSource {

    static final String BAD_RECORD = "{\"paymentId\": broken";

    private final ObjectMapper mapper = new ObjectMapper();
    private final PaymentFactory factory;
    private final Random random;
    private final double duplicateShare;
    private final double badShare;
    private String previousRecord;

    RawRecordSource(PaymentFactory factory, Random random, double duplicateShare, double badShare) {
        this.factory = factory;
        this.random = random;
        this.duplicateShare = duplicateShare;
        this.badShare = badShare;
    }

    String next(long nowMs) {
        if (random.nextDouble() < badShare) {
            return BAD_RECORD;
        }
        if (previousRecord != null && random.nextDouble() < duplicateShare) {
            return previousRecord;
        }
        previousRecord = toJson(factory.next(nowMs));
        return previousRecord;
    }

    private String toJson(PaymentEvent event) {
        try {
            return mapper.writeValueAsString(event);
        } catch (JsonProcessingException e) {
            throw new IllegalStateException("Не удалось сериализовать платёж", e);
        }
    }
}
src/main/java/dev/study/payguard/loadgen/GeneratorConfig.java
package dev.study.payguard.loadgen;

import java.util.HashMap;
import java.util.Map;

/** Настройки генератора. Каждая доля задаётся числом от 0 до 1. */
record GeneratorConfig(
        String bootstrapServers,
        String topic,
        int ratePerSecond,
        double lateShare,
        double duplicateShare,
        double badShare) {

    static GeneratorConfig fromArgs(String[] args) {
        Map<String, String> options = parseOptions(args);
        return new GeneratorConfig(
                options.getOrDefault("bootstrap", "localhost:9094"),
                options.getOrDefault("topic", "payments.raw"),
                Integer.parseInt(options.getOrDefault("rate", "100")),
                share(options, "late", 0.05),
                share(options, "dup", 0.0),
                share(options, "bad", 0.0));
    }

    private static Map<String, String> parseOptions(String[] args) {
        if (args.length % 2 != 0) {
            throw new IllegalArgumentException("Ожидаются пары --имя значение");
        }
        Map<String, String> options = new HashMap<>();
        for (int i = 0; i < args.length; i += 2) {
            if (!args[i].startsWith("--")) {
                throw new IllegalArgumentException("Опция должна начинаться с --: " + args[i]);
            }
            options.put(args[i].substring(2), args[i + 1]);
        }
        return options;
    }

    private static double share(Map<String, String> options, String name, double defaultValue) {
        double value = options.containsKey(name) ? Double.parseDouble(options.get(name)) : defaultValue;
        if (value < 0 || value > 1) {
            throw new IllegalArgumentException("--" + name + " должно быть от 0 до 1, получено " + value);
        }
        return value;
    }
}
src/main/java/dev/study/payguard/loadgen/LoadGenerator.java
package dev.study.payguard.loadgen;

import java.time.Duration;
import java.util.Properties;
import java.util.Random;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringSerializer;

/** Пишет поток платежей в Kafka с заданной скоростью, пока процесс не остановят. */
public final class LoadGenerator {

    private static final Duration TICK = Duration.ofMillis(100);
    private static final long REPORT_EVERY_RECORDS = 1_000;

    public static void main(String[] args) throws InterruptedException {
        GeneratorConfig config = GeneratorConfig.fromArgs(args);
        Random random = new Random();
        RawRecordSource source = new RawRecordSource(
                new PaymentFactory(random, config.lateShare()), random,
                config.duplicateShare(), config.badShare());

        try (KafkaProducer<String, String> producer = new KafkaProducer<>(producerProperties(config))) {
            produceForever(producer, source, config);
        }
    }

    private static void produceForever(
            KafkaProducer<String, String> producer, RawRecordSource source, GeneratorConfig config)
            throws InterruptedException {
        int recordsPerTick = Math.max(1, config.ratePerSecond() / 10);
        long sent = 0;
        while (!Thread.currentThread().isInterrupted()) {
            for (int i = 0; i < recordsPerTick; i++) {
                producer.send(new ProducerRecord<>(config.topic(), source.next(System.currentTimeMillis())));
                if (++sent % REPORT_EVERY_RECORDS == 0) {
                    System.out.println("Отправлено записей: " + sent);
                }
            }
            Thread.sleep(TICK.toMillis());
        }
    }

    private static Properties producerProperties(GeneratorConfig config) {
        Properties properties = new Properties();
        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, config.bootstrapServers());
        properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        return properties;
    }

    private LoadGenerator() {
    }
}

Шаг 4. Первое задание

Создай src/main/java/dev/study/payguard/job/PayGuardJob.java.

src/main/java/dev/study/payguard/job/PayGuardJob.java
package dev.study.payguard.job;

import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.serialization.SimpleStringSchema;
import org.apache.flink.connector.kafka.source.KafkaSource;
import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;

/** Точка входа Flink-задания PayGuard. */
public final class PayGuardJob {

    private static final String BOOTSTRAP_SERVERS = "kafka:9092";
    private static final String RAW_TOPIC = "payments.raw";

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        KafkaSource<String> source = KafkaSource.<String>builder()
                .setBootstrapServers(BOOTSTRAP_SERVERS)
                .setTopics(RAW_TOPIC)
                .setGroupId("payguard")
                .setStartingOffsets(OffsetsInitializer.latest())
                .setValueOnlyDeserializer(new SimpleStringSchema())
                .build();

        env.fromSource(source, WatermarkStrategy.noWatermarks(), "payments-raw")
                .print()
                .name("stdout");

        env.execute("payguard");
    }

    private PayGuardJob() {
    }
}
КОД
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        KafkaSource<String> source = KafkaSource.<String>builder()
                .setBootstrapServers(BOOTSTRAP_SERVERS)
                .setTopics(RAW_TOPIC)
                .setGroupId("payguard")
                .setStartingOffsets(OffsetsInitializer.latest())
                .setValueOnlyDeserializer(new SimpleStringSchema())
                .build();

        env.fromSource(source, WatermarkStrategy.noWatermarks(), "payments-raw")
                .print()
                .name("stdout");

        env.execute("payguard");
ПРОСТЫМИ СЛОВАМИ

Получаем окружение. При запуске через flink run оно подключено к кластеру. Как и SparkSession, оно служит точкой входа, но ничего не исполняет сразу.

Описываем источник Kafka. setGroupId задаёт группу потребителей, latest велит читать только новые записи, а SimpleStringSchema превращает байты в строку.

fromSource добавляет источник в граф. Про noWatermarks поговорим в уроке 5, пока время события нам не нужно. Третий аргумент это имя в интерфейсе.

print добавляет приёмник, который пишет записи в стандартный вывод TaskManager. В терминале клиента этих строк не будет.

До этой строки был только план. env.execute отправляет его на кластер, похоже на действие start() в Spark.

Шаг 5. Собери и запусти

На macOS сначала укажи JDK 17, если по умолчанию у тебя другая версия. Порядок команд здесь не случайный. Если поднять Compose раньше сборки, Docker смонтирует пустую папку build/libs, и jar в контейнере не появится.

терминал
export JAVA_HOME=$(/usr/libexec/java_home -v 17)
./gradlew shadowJar
docker compose up -d --wait

Флаг --wait заставляет команду дождаться готовности контейнеров. Теперь спроси у кластера, как он себя чувствует. REST-интерфейс отвечает на порту 8081, там же открывается Web UI.

терминал
curl -s localhost:8081/overview | jq .
вывод
{
  "taskmanagers": 1,
  "slots-total": 4,
  "slots-available": 4,
  "jobs-running": 0,
  "jobs-finished": 0,
  "jobs-cancelled": 0,
  "jobs-failed": 0,
  "flink-version": "2.2.1",
  "flink-commit": "450c63e"
}

Поля taskmanagers и slots-total сразу показывают то, чего не было при твоём запуске через Makefile. Отправь задание и запусти генератор на пять платежей в секунду.

терминал
docker compose exec jobmanager flink run --detached /jobs/payguard-1.0.0.jar
./gradlew loadgen --args="--rate 5 --dup 0.02 --bad 0.01"

Флаг --detached отдаёт терминал сразу после отправки и оставляет задание работать. Вывод print живёт в логе TaskManager, смотри его из второго терминала.

терминал
docker compose logs -f taskmanager | grep -E '[0-9]+> '
вывод
1> {"paymentId":"3d7afcf2-701e-3e1b-be95-6415e9444aa4","cardId":"c-2982","merchantId":"m-0048","amountMinor":676,"currency":"EUR","country":"NL","eventTimeMs":1794084709909}
1> {"paymentId":"8b470b4d-c563-a975-d7fa-cd92300f2f0f","cardId":"c-0422","merchantId":"m-0000","amountMinor":17305,"currency":"EUR","country":"FR","eventTimeMs":1794084710014}

Число перед > это номер подзадачи плюс один. Открой http://localhost:8081, зайди в задание payguard и посмотри на его граф. В следующем уроке мы подробно разберём, что там нарисовано. Сервисы можно оставить запущенными или остановить командой docker compose down.

Задание. Почини свой старый конфиг

В корне проекта лежит flink-conf.yaml, с которым твой кластер получал ноль TaskManager. Перепиши его так, чтобы Flink 2.2.1 реально применил настройки. Сам исходный файл и Makefile оставь как есть, новый файл положи в conf/config.yaml внутри проекта.

  1. Создай файл с правильным именем и ключами Flink 2.x. Сохрани смысл настроек, это 4 слота, параллелизм 2, состояние в куче и каталоги чекпоинтов в /tmp.
  2. Подними JobManager и TaskManager обычным docker run в отдельной сети, смонтировав файл в /opt/flink/conf/config.yaml. Образ бери flink:2.2.1-java17.
  3. Докажи, что настройки применились, запросами к REST.

Готово, если /overview показывает 1 TaskManager и 4 слота, а /jobmanager/config содержит твои значения.

Подсказка. Родной config.yaml в образе задаёт не только адрес JobManager. Посмотри на него командой docker run --rm flink:2.2.1-java17 cat /opt/flink/conf/config.yaml и подумай, на каком адресе процессы слушают сеть, если ключей bind-host нет.

Ответ

Глоссарий урока

Apache Flink
Движок для обработки потоков данных с состоянием. Программа на нём работает непрерывно и переживает падения без потери результатов.
Micro-batch
Режим Spark Structured Streaming, при котором записи копятся и обрабатываются небольшими пакетными задачами.
Задание (job)
Программа Flink, отправленная на кластер. В потоковом режиме работает, пока её не остановят.
Оператор (operator)
Один шаг обработки в задании, например источник, фильтр или окно.
Подзадача (subtask)
Один параллельный экземпляр оператора. При параллелизме 4 у оператора четыре подзадачи.
JobManager
Процесс, который управляет заданиями, раздаёт работу и координирует снимки. Подробно в уроке 2.
TaskManager
Рабочий процесс, в котором выполняются подзадачи. Похож на экзекутор Spark.
Слот (task slot)
Единица ресурсов TaskManager, в которой выполняются подзадачи. Их число задаёт ключ taskmanager.numberOfTaskSlots.
Состояние (state)
Данные, которые оператор хранит между записями, например счётчик платежей карты.
Сетевой буфер (network buffer)
Участок памяти, куда складываются записи перед отправкой другому TaskManager. Уходит заполненным или по таймауту.
Apache Kafka
Распределённый журнал сообщений. Записи лежат в топиках и хранятся заданное время, поэтому читать их можно повторно.
Топик (topic)
Именованный журнал в Kafka, разбитый на партиции.
Партиция (partition)
Часть топика Kafka с собственным порядком записей. Единица параллельного чтения.
Коннектор (connector)
Библиотека, связывающая Flink с внешней системой. Выходит отдельно от Flink и собирается под его конкретную версию.
Толстый jar (fat jar)
Один файл с твоим кодом и всеми его зависимостями. Его собирает плагин Shadow для Gradle.
REST-интерфейс
HTTP-интерфейс JobManager на порту 8081. Через него работает Web UI, и им же удобно пользоваться из скриптов.
Web UI
Панель Flink в браузере. Показывает граф задания, метрики, чекпоинты и ошибки.
Docker Compose
Инструмент, который поднимает несколько контейнеров по описанию из одного YAML-файла.
Record
Вид неизменяемого класса в Java. Компилятор сам создаёт конструктор, геттеры, equals и hashCode.

Урок 2 · Основы

Как устроен кластер и что происходит между flink run и RUNNING

После урока ты проследишь путь задания от своего main до потока в TaskManager, посчитаешь нужное число слотов и найдёшь причину, если задание не стартует.

Авария, которую объясняет устройство кластера

Ночью PayGuard перезапустили с параллелизмом 8 на кластере с четырьмя слотами. В мониторинге статус задания RUNNING, алертов о падении нет, а платежи никто целиком не проверяет. Через несколько минут задание падает с NoResourceAvailableException. Другой частый случай выглядит так. Задание годами запускалось из CI, его перевели в режим Application, и оно упало на чтении локального файла в main.

Оба случая разбираются за минуту, если знать, какой процесс за что отвечает и в каком порядке они разговаривают. Этим мы и займёмся, причём пройдём весь путь по коду Flink 2.2.1.

Стройка на пять ролей

Представь строительную контору. Заказчик приносит проект в приёмную и может уехать домой. Приёмная регистрирует заказ и назначает на него отдельного прораба. Прораб считает, сколько рабочих мест ему нужно, и обращается к кадровику, который знает все бригады и их свободные места. Кадровик говорит бригаде, к кому идти, бригада сама приходит к прорабу, и тот выдаёт наряды.

Во Flink заказчика зовут клиентом, приёмную зовут Dispatcher, прораба JobMaster, кадровика ResourceManager, бригаду TaskManager, а рабочее место слотом. Dispatcher, ResourceManager и все JobMaster живут внутри одного процесса JobManager, это написано прямо в разделе про архитектуру.

Клиентflink run, твой main JobManager DispatcherREST, Web UI, приём заданий JobMasterодин на задание, граф, снимки, сбои ResourceManagerучёт слотов всего кластера TaskManager 1 слот слот свободен TaskManager 2 свободен свободен свободен

Со Spark роли похожи только частично, и таблица показывает, где сходство обманывает.

ВопросSparkFlink
Где выполняется mainВ Driver, на твоей машине или в кластере по режимуВ клиенте при режиме Session, в JobManager при режиме Application
Кто планирует работуDriverJobMaster, отдельный на каждое задание
Кто исполняетExecutorTaskManager
Единица исполненияTask, живёт одну стадию и завершаетсяПодзадача, поток работает всю жизнь задания
Единица ресурсовЯдра экзекутораСлот, он разделяет только управляемую память и не изолирует CPU
Если отправитель отключилсяDriver обязан жить до конца приложенияКлиент при режиме detached может уйти сразу после отправки

Путь задания шаг за шагом

Жми кнопку и смотри, кто с кем говорит, когда ты запускаешь flink run для нашего задания с параллелизмом 2.

К
Клиент
D
Dispatcher
J
JobMaster
R
ResourceManager
T
TaskManager
Нажми «Следующий шаг»

В исходниках

Начнём с клиента, и здесь есть сюрприз. Страница Deployment в документации до сих пор говорит, что клиент отправляет на кластер JobGraph. Посмотри, что передаётся в submitJob в коде версии 2.2.1. Команда flink run без флага --target выбирает как раз этого исполнителя.

apache/flink@release-2.2.1 · AbstractSessionClusterExecutor.java, строки 93-106
StreamGraph streamGraph = PipelineExecutorUtils.getStreamGraph(pipeline, configuration);

try (final ClusterDescriptor<ClusterID> clusterDescriptor =
        clusterClientFactory.createClusterDescriptor(configuration)) {
    final ClusterID clusterID = clusterClientFactory.getClusterId(configuration);
    checkState(clusterID != null);

    final ClusterClientProvider<ClusterID> clusterClientProvider =
            clusterDescriptor.retrieve(clusterID);
    ClusterClient<ClusterID> clusterClient = clusterClientProvider.getClusterClient();

    streamGraph.serializeUserDefinedInstances();
    return clusterClient
            .submitJob(streamGraph)
apache/flink@release-2.2.1 · DefaultCLI.java, строки 71-75
resultingConfiguration.set(DeploymentOptions.TARGET, RemoteExecutor.NAME);

DynamicPropertiesUtil.encodeDynamicProperties(commandLine, resultingConfiguration);

return resultingConfiguration;

Клиент сериализует пользовательские объекты и отправляет StreamGraph. Обработчик REST на стороне кластера вызывает gateway.submitJob, и дальше управление у Dispatcher. Вот что он делает с планом.

apache/flink@release-2.2.1 · Dispatcher.java, строки 645-664
private void persistAndRunJob(ExecutionPlan executionPlan) throws Exception {
    executionPlanWriter.putExecutionPlan(executionPlan);
    initJobClientExpiredTime(executionPlan);
    runJob(createJobMasterRunner(executionPlan), ExecutionType.SUBMISSION);
}

private JobManagerRunner createJobMasterRunner(ExecutionPlan executionPlan) throws Exception {
    Preconditions.checkState(!jobManagerRunnerRegistry.isRegistered(executionPlan.getJobID()));
    return jobManagerRunnerFactory.createJobManagerRunner(
            executionPlan,
            configuration,
            getRpcService(),
            highAvailabilityServices,
            heartbeatServices,
            jobManagerSharedServices,
            new DefaultJobManagerJobMetricGroupFactory(jobManagerMetricGroup),
            fatalErrorHandler,
            failureEnrichers,
            System.currentTimeMillis());
}

Сохранение плана идёт раньше запуска, поэтому при настроенной высокой доступности новый лидер найдёт его после сбоя. Отдельный запускатель создаётся на каждое задание, отсюда и правило «один JobMaster на задание».

Три графа одного задания

Один и тот же код проходит через три представления. На стройке это эскиз архитектора, рабочие чертежи и наряды на конкретных людей.

  • StreamGraph описывает логику. Каждый оператор твоей программы в нём отдельный узел.
  • JobGraph описывает, что исполняется как единое целое. Его вершины (JobVertex) это цепочки операторов, которые будут жить в одном потоке.
  • ExecutionGraph описывает параллельное исполнение. На каждую вершину приходится столько подзадач, сколько задано параллелизмом, и у каждой есть своё состояние и своя история попыток.
StreamGraph Source: payments-raw drop-blank Sink: stdout JobGraph Одна вершинаSource -> drop-blank -> Sinkпараллелизм 2 ExecutionGraph Подзадача 1 из 2свой поток Подзадача 2 из 2свой поток

В исходниках

Где JobGraph появляется при обычном запуске. Для потокового задания планировщик по умолчанию это DefaultScheduler, и его фабрика получает план от JobMaster. Смотри на ветку с StreamGraph.

apache/flink@release-2.2.1 · DefaultSchedulerFactory.java, строки 87-96
JobGraph jobGraph;

if (executionPlan instanceof JobGraph) {
    jobGraph = (JobGraph) executionPlan;
} else if (executionPlan instanceof StreamGraph) {
    jobGraph = ((StreamGraph) executionPlan).getJobGraph(userCodeLoader);
} else {
    throw new FlinkException(
            "Unsupported execution plan " + executionPlan.getClass().getCanonicalName());
}
apache/flink@release-2.2.1 · StreamGraph.java, строки 1166-1173
public JobGraph getJobGraph(ClassLoader userClassLoader) {
    return getJobGraph(userClassLoader, jobId);
}

/** Gets the assembled {@link JobGraph} with a specified {@link JobID}. */
public JobGraph getJobGraph(ClassLoader userClassLoader, @Nullable JobID jobID) {
    return StreamingJobGraphGenerator.createJobGraph(userClassLoader, this, jobID);
}

Значит, склейка цепочек выполняется уже в JobManager. Если на этом шаге что-то сломается, полный стек ищи в логе JobManager. Затем сборщик сортирует вершины и прикрепляет их к ExecutionGraph.

apache/flink@release-2.2.1 · DefaultExecutionGraphBuilder.java, строки 189-198
// topologically sort the job vertices and attach the graph to the existing one
List<JobVertex> sortedTopology = jobGraph.getVerticesSortedTopologicallyFromSources();
if (log.isDebugEnabled()) {
    log.debug(
            "Adding {} vertices from job graph {} ({}).",
            sortedTopology.size(),
            jobName,
            jobId);
}
executionGraph.attachJobGraph(sortedTopology, jobManagerJobMetricGroup);

Здесь видно, откуда берётся число подзадач. Каждая вершина создаёт массив размером с параллелизм и заполняет его в цикле.

apache/flink@release-2.2.1 · ExecutionJobVertex.java, строки 216-216
this.taskVertices = new ExecutionVertex[parallelismInfo.getParallelism()];
apache/flink@release-2.2.1 · ExecutionJobVertex.java, строки 236-245
// create all task vertices
for (int i = 0; i < this.parallelismInfo.getParallelism(); i++) {
    ExecutionVertex vertex =
            createExecutionVertex(
                    this,
                    i,
                    producedDataSets,
                    timeout,
                    createTimestamp,
                    executionHistorySizeLimit,

Цепочки операторов

Цепочка (operator chain) соединяет несколько операторов в один поток, и запись идёт между ними обычным вызовом метода, без сети и сериализации. Каменщик сразу штукатурит свою стену, и материалы никто не перевозит. Документация объясняет выгоду меньшими расходами на передачу между потоками и буферизацию. Подробно о том, что происходит на разрыве цепочки, поговорим в уроке 4.

В исходниках

Условия склейки собраны в одном методе. Цепочка должна быть включена, операторы должны быть в одной группе slot sharing, их стратегии должны разрешать склейку, а связь должна допускать передачу без перемешивания.

apache/flink@release-2.2.1 · StreamingJobGraphGenerator.java, строки 1733-1749
private static boolean isChainableInput(
        StreamEdge edge, StreamGraph streamGraph, boolean allowChainWithDefaultParallelism) {
    StreamNode upStreamVertex = streamGraph.getSourceVertex(edge);
    StreamNode downStreamVertex = streamGraph.getTargetVertex(edge);

    if (!(streamGraph.isChainingEnabled()
            && upStreamVertex.isSameSlotSharingGroup(downStreamVertex)
            && areOperatorsChainable(
                    upStreamVertex,
                    downStreamVertex,
                    streamGraph,
                    allowChainWithDefaultParallelism)
            && arePartitionerAndExchangeModeChainable(
                    edge.getPartitioner(), edge.getExchangeMode(), streamGraph.isDynamic()))) {

        return false;
    }

Подходит только связь ForwardPartitioner, поэтому keyBy цепочку разрывает. Запись может уйти в другую подзадачу, значит через вызов метода её не передать.

apache/flink@release-2.2.1 · StreamingJobGraphGenerator.java, строки 1762-1776
@VisibleForTesting
static boolean arePartitionerAndExchangeModeChainable(
        StreamPartitioner<?> partitioner,
        StreamExchangeMode exchangeMode,
        boolean isDynamicGraph) {
    if (partitioner instanceof ForwardForConsecutiveHashPartitioner) {
        checkState(isDynamicGraph);
        return true;
    } else if ((partitioner instanceof ForwardPartitioner)
            && exchangeMode != StreamExchangeMode.BATCH) {
        return true;
    } else {
        return false;
    }
}

Параллелизм тоже обязан совпасть. Если ты задашь фильтру параллелизм 4 при источнике с параллелизмом 2, в задании появится лишняя сетевая передача.

apache/flink@release-2.2.1 · StreamingJobGraphGenerator.java, строки 1833-1844
// When allowChainWithDefaultParallelism is true, any vertex with default parallelism can
// be chained, otherwise only vertices with the same parallelism can be chained.
if (allowChainWithDefaultParallelism) {
    isChainable &=
            (upStreamVertex.getParallelism() == downStreamVertex.getParallelism()
                    || upStreamVertex.getParallelism()
                            == ExecutionConfig.PARALLELISM_DEFAULT
                    || downStreamVertex.getParallelism()
                            == ExecutionConfig.PARALLELISM_DEFAULT);
} else {
    isChainable &= upStreamVertex.getParallelism() == downStreamVertex.getParallelism();
}

Слоты и slot sharing

Слот это единица планирования в TaskManager. Каждый слот получает равную долю управляемой памяти, а ядра между ними не делятся. Документация говорит об этом прямо, поэтому четыре слота на одном CPU дадут четыре потока, которые спорят за процессор.

По умолчанию подзадачи разных вершин одного задания могут жить в одном слоте. Этот механизм называется slot sharing. Правило простое. В один разделяемый слот попадает не больше одной подзадачи от каждой вершины. Отсюда простая арифметика, которая встречается в работе постоянно. Заданию с одной группой нужно столько слотов, сколько составляет наибольший параллелизм среди его вершин.

В исходниках

Правило «одна подзадача вершины на слот» записано в комментарии к стратегии, которая раскладывает подзадачи по разделяемым слотам.

apache/flink@release-2.2.1 · LocalInputPreferredSlotSharingStrategy.java, строки 93-101
* A JobVertex only belongs to one {@link SlotSharingGroup}. A SlotSharingGroup is
* corresponding to a set of {@link ExecutionSlotSharingGroup}s. We can maintain available
* ExecutionSlotSharingGroups for each JobVertex.
*
* <p>Once an ExecutionSlotSharingGroup is created, it becomes available for all JobVertices
* in the corresponding SlotSharingGroup in the beginning.
*
* <p>Once a SchedulingExecutionVertex is added to the ExecutionSlotSharingGroup, the group
* is no longer available for other SchedulingExecutionVertices with the same JobVertexID.

Группа перестаёт быть доступной для других подзадач той же вершины, как только одна из них туда попала. Из этого и следует формула про наибольший параллелизм. Сама формула есть и в разделе Task Slots and Resources.

Покрути планировщик. Поставь параллелизм 8 при четырёх слотах и посмотри на результат. Потом включи отдельные группы, и потребность в слотах вырастет втрое. Заодно цепочка разорвётся, потому что операторы из разных групп код склеивать запрещает. Порядок, в котором модель заполняет TaskManager, условный. Настоящий зависит от настройки taskmanager.load-balance.mode.

Планировщик слотов для PayGuard

RUNNING ещё ничего не гарантирует

Вернёмся к ночной аварии. Статус задания и состояние подзадач разные вещи, и разница видна в одном методе.

В исходниках

Планировщик сначала переводит задание в RUNNING и только потом запускает стратегию, которая просит слоты.

apache/flink@release-2.2.1 · DefaultScheduler.java, строки 243-249
protected void startSchedulingInternal() {
    log.info(
            "Starting scheduling with scheduling strategy [{}]",
            schedulingStrategy.getClass().getName());
    transitionToRunning();
    schedulingStrategy.startScheduling();
}

Подзадача считается работающей только после отчёта из потока на TaskManager. Сначала JobMaster отправляет описание подзадачи, TaskManager запускает поток, и уже поток проходит свои состояния.

apache/flink@release-2.2.1 · Execution.java, строки 653-662
taskDeploymentDescriptorFuture
        .thenComposeAsync(
                deploymentDescriptor ->
                        taskManagerGateway.submitTask(deploymentDescriptor, rpcTimeout),
                executor)
        .whenCompleteAsync(
                (ack, failure) ->
                        handleDeploymentCompletionAndCleanup(
                                maybeOffloadedTaskRestoreCleanupRef, ack, failure),
                jobMasterMainThreadExecutor);
apache/flink@release-2.2.1 · TaskExecutor.java, строки 875-886
try {
    taskAdded = taskSlotTable.addTask(task);
} catch (SlotNotFoundException | SlotNotActiveException e) {
    throw new TaskSubmissionException("Could not submit task.", e);
}

if (taskAdded) {
    task.startTaskThread();

    setupResultPartitionBookkeeping(
            tdd.getJobId(), tdd.getProducedPartitions(), task.getTerminationFuture());
    return CompletableFuture.completedFuture(Acknowledge.get());
apache/flink@release-2.2.1 · Task.java, строки 947-955
if (!transitionState(ExecutionState.INITIALIZING, ExecutionState.RUNNING)) {
    throw new CancelTaskException();
}

// notify everyone that we switched to running
taskManagerActions.updateTaskExecutionState(
        new TaskExecutionState(executionId, ExecutionState.RUNNING));

runWithSystemExitMonitoring(finalInvokable::invoke);

Смотри на статус вершин в /jobs/<id>, в этом ответе видно настоящее положение дел. Этому полю мы и посвятим скрипт в задании.

Когда слотов не хватило

Кластер standalone не умеет сам поднимать новые TaskManager, ResourceManager распределяет только то, что уже подключилось. На Kubernetes или YARN он может запросить новые процессы. Поэтому в нашем стенде нехватка слотов кончается ошибкой, и поведение зависит от возраста кластера.

В исходниках

Первые минуты после старта кластер терпит нехватку, потому что TaskManager могут ещё подключаться. Длительность задаёт resourcemanager.standalone.start-up-time, а без него берётся slot.request.timeout, то есть пять минут.

apache/flink@release-2.2.1 · StandaloneResourceManager.java, строки 110-121
private void startStartupPeriod() {
    setFailUnfulfillableRequest(false);

    final long startupPeriodMillis = startupPeriodTime.toMillis();

    if (startupPeriodMillis > 0) {
        scheduleRunAsync(
                () -> setFailUnfulfillableRequest(true),
                startupPeriodMillis,
                TimeUnit.MILLISECONDS);
    }
}
apache/flink@release-2.2.1 · JobManagerOptions.java, строки 409-413
public static final ConfigOption<Duration> SLOT_REQUEST_TIMEOUT =
        key("slot.request.timeout")
                .durationType()
                .defaultValue(Duration.ofMillis(5L * 60L * 1000L))
                .withDescription("The timeout for requesting a slot from Slot Pool.");

После этого периода менеджер слотов сообщает о нехватке заданию сразу.

apache/flink@release-2.2.1 · FineGrainedSlotManager.java, строки 699-708
if (sendNotEnoughResourceNotifications) {
    for (JobID jobId : unfulfillableJobs) {
        try (MdcUtils.MdcCloseable ignored =
                MdcUtils.withContext(MdcUtils.asContextData(jobId))) {
            LOG.warn("Could not fulfill resource requirements of job {}.", jobId);
            resourceEventListener.notEnoughResourceAvailable(
                    jobId, resourceTracker.getAcquiredResources(jobId));
        }
    }
}

В пуле слотов этого задания такое уведомление отменяет все потоковые запросы, ожидающие слот.

apache/flink@release-2.2.1 · DeclarativeSlotPoolBridge.java, строки 507-521
private void failPendingRequests(Collection<ResourceRequirement> acquiredResources) {
    // only fails streaming requests because batch jobs do not require all resources
    // requirements to be fullfilled at the same time
    Predicate<PendingRequest> predicate = request -> !request.isBatchRequest();
    if (pendingRequests.values().stream().anyMatch(predicate)) {
        log.warn(
                "Could not acquire the minimum required resources, failing slot requests. Acquired: {}. Current slot pool status: {}",
                acquiredResources,
                getSlotServiceStatus());
        cancelPendingRequests(
                predicate,
                NoResourceAvailableException.withoutStackTrace(
                        "Could not acquire the minimum required resources."));
    }
}

Есть и вторая проверка, в самом JobMaster. Если запрос не удаётся выполнить в течение таймаута, он отменяется с другим текстом. Поэтому при поиске в логах проверяй обе строки.

apache/flink@release-2.2.1 · PhysicalSlotRequestBulkCheckerImpl.java, строки 86-93
case TIMEOUT:
    Throwable cancellationCause =
            new NoResourceAvailableException(
                    "Slot request bulk is not fulfillable! Could not allocate the required slot within slot request timeout",
                    new TimeoutException(
                            "Timeout has occurred: " + timeout));
    bulk.cancel(cancellationCause);
    break;

Дальше решает стратегия перезапуска. Без чекпоинтов её значение по умолчанию это отсутствие перезапуска, с чекпоинтами это экспоненциальная задержка. В нашем задании чекпоинтов пока нет, поэтому оно перейдёт в FAILED. Этот сценарий мы в стенде не прогоняли, описание составлено по коду.

⚠
Частичный запуск тоже бывает

Планирование идёт по регионам, связанным передачей данных. У нашей цепочки нет связей между подзадачами, поэтому часть подзадач может получить слоты и успеть стартовать, пока остальные ждут. В этот момент задание читает только часть партиций Kafka, а смотреть в мониторинге надо на статус вершин.

Групповой чат запуска

Тот же запуск глазами участников, вместе с вопросом, что случится при нехватке слотов.

Режимы Session и Application

Сегодня Flink разворачивается двумя способами. Они различаются временем жизни кластера и тем, где выполняется main. Третий режим per-job удалён во Flink 2.0, и в старых статьях он всё ещё встречается.

SessionApplication
КластерУже запущен и живёт независимо от заданийСоздаётся на приложение и останавливается вместе с ним
Где выполняется mainВ клиентеВ JobManager
ИзоляцияЗадания делят TaskManager, упавший процесс задевает всехРесурсы только у этого приложения
Откуда берётся jarКлиент загружает его на кластерОн уже лежит на classpath кластера, в папке usrlib
Командаflink run --target remoteflink run --target kubernetes-application

В нашем стенде режим Session, потому что кластер поднимает Docker Compose заранее. Развёртывание в Kubernetes разберём в уроке 20. Учти одну тонкость стенда. Команда docker compose exec jobmanager flink run выполняется в контейнере JobManager, но клиент при этом отдельная JVM. Твой main работает в ней, и JobManager его кода не исполняет.

В исходниках

В режиме Application задание подаётся изнутри кластера. Исполнитель обращается к Dispatcher напрямую, минуя REST, а в остальном передаёт тот же StreamGraph.

apache/flink@release-2.2.1 · EmbeddedExecutor.java, строки 139-151
final StreamGraph streamGraph =
        PipelineExecutorUtils.getStreamGraph(pipeline, configuration);
final JobID actualJobId = streamGraph.getJobID();

this.submittedJobIds.add(actualJobId);
LOG.info("Job {} is submitted.", actualJobId);

if (LOG.isDebugEnabled()) {
    LOG.debug("Effective Configuration: {}", configuration);
}

final CompletableFuture<JobID> jobSubmissionFuture =
        submitJob(configuration, dispatcherGateway, streamGraph, timeout);

Проверь себя

В задании источник с параллелизмом 2, оператор с keyBy с параллелизмом 6 и приёмник с параллелизмом 2. Группа slot sharing одна. Сколько свободных слотов нужно кластеру?

После релиза задание показывает RUNNING, но лаг Kafka растёт, а обработанных записей нет. Что проверишь первым?

В main читается файл /etc/payguard/rules.yaml, он есть на сервере CI. После перехода с режима Session на Application задание падает с FileNotFoundException. Почему?

Что будет в конце

В задании появится оператор посередине, у всех операторов будут постоянные идентификаторы, скрипт отправки избавит от длинной команды, и ты увидишь цепочку в выводе CLI и REST. Новых зависимостей нет. Файл scripts/cluster-status.sh из снапшота относится к заданию этого урока, пока его создавать не нужно.

Шаг 1. Дай операторам имена и uid

Открой src/main/java/dev/study/payguard/job/PayGuardJob.java и внеси такие правки.

что изменилось в src/main/java/dev/study/payguard/job/PayGuardJob.java
@@ -24,9 +24,15 @@                 .build();
 
         env.fromSource(source, WatermarkStrategy.noWatermarks(), "payments-raw")
+                .uid("payments-raw")+                .filter(record -> !record.isBlank())+                .name("drop-blank")+                .uid("drop-blank")                 .print()
-                .name("stdout");+                .name("stdout")+                .uid("stdout"); 
+        System.out.println(env.getExecutionPlan());         env.execute("payguard");
     }
 
КОД
        env.fromSource(source, WatermarkStrategy.noWatermarks(), "payments-raw")
                .uid("payments-raw")
                .filter(record -> !record.isBlank())
                .name("drop-blank")
                .uid("drop-blank")
                .print()
                .name("stdout")
                .uid("stdout");

        System.out.println(env.getExecutionPlan());
        env.execute("payguard");
ПРОСТЫМИ СЛОВАМИ

Источник получает имя payments-raw третьим аргументом и такой же uid. Имя видит человек в интерфейсе. По uid Flink находит состояние оператора при восстановлении.

Фильтр выбрасывает пустые строки. Настоящей очисткой мы займёмся в уроке 3. Сейчас он нужен, чтобы посередине появился оператор и стала видна цепочка.

name и uid относятся к предыдущему вызову, поэтому стоят сразу за фильтром.

Приёмник print тоже получает имя и uid. Состояния у него нет, зато правило «у каждого оператора есть uid» проще проверять.

getExecutionPlan возвращает план в JSON, и мы печатаем его в стандартный вывод. Это выполняется в main, поэтому строка появится в терминале клиента.

execute отправляет задание, как и раньше.

В исходниках

Зачем ставить uid сейчас, если состояния ещё нет. Посмотри, как Flink выбирает идентификатор оператора. Без uid хеш строится из положения узла в графе, а с uid только из этой строки.

apache/flink@release-2.2.1 · StreamGraphHasherV2.java, строки 182-210
// Check for user-specified ID
String userSpecifiedHash = node.getTransformationUID();

if (userSpecifiedHash == null) {
    // Check that all input nodes have their hashes computed
    for (StreamEdge inEdge : node.getInEdges()) {
        // If the input node has not been visited yet, the current
        // node will be visited again at a later point when all input
        // nodes have been visited and their hashes set.
        if (!hashes.containsKey(inEdge.getSourceId())) {
            return false;
        }
    }

    Hasher hasher = hashFunction.newHasher();
    byte[] hash =
            generateDeterministicHash(node, hasher, hashes, isChainingEnabled, streamGraph);

    if (hashes.put(node.getId(), hash) != null) {
        // Sanity check
        throw new IllegalStateException(
                "Unexpected state. Tried to add node hash "
                        + "twice. This is probably a bug in the JobGraph generator.");
    }

    return true;
} else {
    Hasher hasher = hashFunction.newHasher();
    byte[] hash = generateUserSpecifiedHash(node.getTransformationUID(), hasher);

Автоматический хеш зависит от структуры программы, поэтому добавленный фильтр может поменять идентификаторы соседей, и состояние из savepoint им больше не подойдёт. Документация настойчиво советует задавать uid вручную. Восстановление из savepoint мы разберём в уроке 13, и к тому времени uid уже должны стоять во всех версиях задания.

И почему печать плана не ломает запуск. Метод вызывает getStreamGraph(false), то есть не очищает список преобразований, и последующий execute их видит.

apache/flink@release-2.2.1 · StreamExecutionEnvironment.java, строки 2096-2105
/**
 * Creates the plan with which the system will execute the program, and returns it as a String
 * using a JSON representation of the execution data flow graph. Note that this needs to be
 * called, before the plan is executed.
 *
 * @return The execution plan of the program, as a JSON String.
 */
public String getExecutionPlan() {
    return getStreamGraph(false).getStreamingPlanAsJSON();
}

Шаг 2. Скрипт отправки

Создай scripts/submit.sh.

scripts/submit.sh
#!/usr/bin/env bash
# Отправляет собранный jar в кластер в фоновом режиме. Параметры передаются заданию как есть.
set -euo pipefail
docker compose exec -T jobmanager flink run --detached /jobs/payguard-1.0.0.jar "$@"

Строка set -euo pipefail останавливает скрипт при первой ошибке и при обращении к неопределённой переменной. Флаг -T у docker compose exec отключает псевдотерминал, без него скрипт падал бы в CI, где терминала нет. Флаг --detached завершает клиента сразу после отправки. Конструкция "$@" передаёт аргументы скрипта после пути к jar, поэтому они попадают в main твоего задания. Флаги самого flink run, например -p, таким способом не передать, их пиши в отдельной команде.

терминал
chmod +x scripts/submit.sh

Команда chmod +x разрешает запускать файл как программу.

Шаг 3. Собери и отправь

Если стенд остался работать с урока 1, сначала выполни docker compose down, чтобы старое задание не занимало слоты. Порядок прежний, сначала сборка, потом Compose.

терминал
./gradlew shadowJar
docker compose up -d --wait
curl -s localhost:8081/overview | jq -c .
./scripts/submit.sh

В прогоне jar занял 24327017 байт. Ответ /overview подтверждает, что свободны все четыре слота. Поля те же, что в уроке 1.

вывод
{"taskmanagers":1,"slots-total":4,"slots-available":4,"jobs-running":0,"jobs-finished":0,"jobs-cancelled":0,"jobs-failed":0,"flink-version":"2.2.1","flink-commit":"450c63e"}

Теперь вывод самой отправки. Обрати внимание на его источник. Этот JSON напечатал твой System.out.println, то есть код из main выполнился в клиенте, и результат пришёл в твой терминал.

вывод
{
  "nodes" : [ {
    "id" : 1,
    "type" : "Source: payments-raw",
    "pact" : "Data Source",
    "contents" : "Source: payments-raw",
    "parallelism" : 2
  }, {
    "id" : 2,
    "type" : "drop-blank",
    "pact" : "Operator",
    "contents" : "drop-blank",
    "parallelism" : 2,
    "predecessors" : [ {
      "id" : 1,
      "ship_strategy" : "FORWARD",
      "side" : "second"
    } ]
  }, {
    "id" : 3,
    "type" : "Sink: stdout",
    "pact" : "Data Sink",
    "contents" : "Sink: stdout",
    "parallelism" : 2,
    "predecessors" : [ {
      "id" : 2,
      "ship_strategy" : "FORWARD",
      "side" : "second"
    } ]
  } ]
}
Job has been submitted with JobID 8fe3d0eda0de9f053bb73d644c2fd485

Это StreamGraph, три узла с нашими именами. Поле ship_strategy показывает способ передачи между операторами. Значение FORWARD здесь значит, что подзадача передаёт записи подзадаче с тем же номером, и именно оно разрешает склейку. Значение parallelism равно 2, потому что в Compose задано parallelism.default: 2. Последняя строка содержит идентификатор задания, он пригодится в следующих командах.

Шаг 4. Посмотри на то, что реально работает

Спроси у REST, какие вершины запущены. Подставь свой идентификатор. Фильтр jq оставляет только нужные поля, а флаг -c печатает результат в одну строку.

терминал
curl -s localhost:8081/jobs/8fe3d0eda0de9f053bb73d644c2fd485 \
  | jq -c '{name, state, vertices: [.vertices[] | {name, parallelism, status}]}'
вывод
{"name":"payguard","state":"RUNNING","vertices":[{"name":"Source: payments-raw -> drop-blank -> Sink: stdout","parallelism":2,"status":"RUNNING"}]}

Три узла превратились в одну вершину с именем через стрелки. Это JobGraph, и склейку ты видишь своими глазами. Две подзадачи заняли два слота, значит в /overview теперь должно быть 2 в поле slots-available. При параллелизме 4 в нашем стенде заняты все четыре слота, это проверено на запуске из урока 13.

i
Как сравнивать цепочку и план

Вывод getExecutionPlan показывает логику до склейки, поэтому там всегда отдельные операторы. Настоящие вершины видны в ответе REST и на графе в Web UI. Если в плане FORWARD, а вершины разные, ищи различия в параллелизме или группе slot sharing.

Шаг 5. CLI

Список заданий кластера выдаёт команда flink list.

терминал
docker compose exec -T jobmanager flink list
вывод
Waiting for response...
------------------ Running/Restarting Jobs -------------------
07.11.2026 20:56:54 : 8fe3d0eda0de9f053bb73d644c2fd485 : payguard (RUNNING)
--------------------------------------------------------------
No scheduled jobs.

В строке стоят время запуска, идентификатор, имя из env.execute и статус. Остановить задание без сохранения состояния можно так.

терминал
docker compose exec -T jobmanager flink cancel 8fe3d0eda0de9f053bb73d644c2fd485

Команда cancel переводит задание в отменённое состояние, и все его вычисления прекращаются. После этого curl -s localhost:8081/jobs | jq -c . покажет задание со статусом CANCELED, а все четыре слота снова будут свободны. Для остановки с сохранением состояния есть flink stop, его мы разберём в уроке 13. Запусти задание снова командой ./scripts/submit.sh, идентификатор будет новым.

Шаг 6. Web UI

Открой http://localhost:8081. На странице запущенных заданий нажми payguard и найди на графе блок с надписью Source: payments-raw -> drop-blank -> Sink: stdout. Это та же одна вершина, что в REST. Нажми на неё и открой список подзадач, там их две, и у каждой указан TaskManager. На странице Task Managers видно число всех и свободных слотов. На странице задания есть вкладки Exceptions и Checkpoints. В нашем прогоне список исключений был пуст и чекпоинтов ноль, мы их ещё не включали.

Чтобы записи шли, запусти генератор из урока 1. Он отправит в поток битые записи, и они пока пройдут через фильтр дальше в print, потому что пустыми не являются.

терминал
./gradlew loadgen --args="--rate 5 --dup 0.02 --bad 0.01"

Шаг 7. Проверь на себе нехватку слотов

Этот опыт покажет, как выглядит авария из начала урока. Флаг -p задаёт параллелизм заданию при отправке и стоит перед путём к jar.

терминал
docker compose exec -T jobmanager flink cancel <id текущего задания>
docker compose exec -T jobmanager flink run --detached -p 8 /jobs/payguard-1.0.0.jar

В этом случае подзадач восемь, а слотов четыре. Вывод такого запуска мы не снимали, поэтому смотри на него сам и сверяй с теорией. Статус задания должен стать RUNNING, затем в логе JobManager появится NoResourceAvailableException. Если кластер только что поднят, ждать придётся до пяти минут. Логи можно читать командой docker compose logs jobmanager. Для продолжения курса верни обычный запуск через ./scripts/submit.sh.

Задание. Скрипт состояния кластера

Когда заданий и TaskManager становится больше, смотреть в браузер неудобно. Напиши scripts/cluster-status.sh, который одной командой показывает то, что мы сегодня проверяли руками. Пользуйся только curl и jq, CLI Flink не нужен.

  1. Первая строка показывает число TaskManager, сколько слотов занято из общего числа и версию Flink.
  2. Для каждого задания со статусом RUNNING скрипт показывает его имя, статус и идентификатор.
  3. Под каждым заданием идут его вершины, у каждой параллелизм, статус и имя.
  4. Адрес кластера по умолчанию http://localhost:8081, переменная окружения FLINK_REST его заменяет.
  5. При ошибке в команде скрипт завершается, как и submit.sh.

Готово, если при работающем задании вывод показывает одну вершину с параллелизмом 2 и два занятых слота, а после flink cancel остаётся только первая строка с нулём занятых слотов.

Подсказки. Адреса нужны такие, /overview, /jobs и /jobs/<id>. Занятые слоты придётся вычислить из двух полей. В имени поля с дефисом jq требует запись в скобках. Для вставки значений в строку в jq есть интерполяция \(...), а флаг -r убирает кавычки вокруг результата. Посмотри ответ /jobs глазами, прежде чем писать фильтр, названия полей статуса в разных адресах отличаются.

Ответ

Глоссарий урока

Клиент (client)
Программа, которая готовит задание и отправляет его на кластер. Это JVM команды flink run или твоего Java-приложения. В исполнение задания не входит.
JobManager
Процесс, который управляет кластером. Содержит Dispatcher, ResourceManager и по одному JobMaster на задание.
Dispatcher
Компонент JobManager. Даёт REST-интерфейс для отправки заданий, запускает для каждого новый JobMaster и обслуживает Web UI.
ResourceManager
Компонент JobManager, который ведёт учёт слотов кластера и раздаёт их заданиям. На Kubernetes и YARN также запрашивает новые TaskManager.
JobMaster
Компонент JobManager, отвечающий за одно задание. Держит его ExecutionGraph, просит слоты, развёртывает подзадачи, реагирует на сбои.
TaskManager
Рабочий процесс JVM. Выполняет подзадачи в отдельных потоках и обменивается данными с другими TaskManager.
Слот (task slot)
Единица планирования ресурсов в TaskManager. Делит управляемую память поровну и не изолирует CPU. Число задаёт ключ taskmanager.numberOfTaskSlots.
Slot sharing
Размещение подзадач разных вершин одного задания в одном слоте. Включено по умолчанию. Задаётся через группу, по умолчанию она называется default.
Подзадача (subtask)
Один параллельный экземпляр вершины задания. Выполняется в своём потоке на TaskManager.
Параллелизм (parallelism)
Число подзадач вершины. Задаётся в коде, флагом -p или ключом parallelism.default.
StreamGraph
Логический граф программы, где каждый оператор отдельный узел. Создаётся при вызове env.execute. Клиент во Flink 2.2 отправляет на кластер именно его.
JobGraph и JobVertex
Граф, в котором операторы склеены в цепочки. Каждая цепочка это одна вершина (JobVertex). Строится из StreamGraph классом StreamingJobGraphGenerator.
ExecutionGraph
Параллельная версия JobGraph, которую держит JobMaster. На каждую вершину в нём столько элементов, сколько задано параллелизмом.
Цепочка операторов (operator chain)
Операторы, которые работают в одном потоке и передают записи вызовом метода, без сети. Разрывается при keyBy, разном параллелизме или разных группах slot sharing.
FORWARD
Способ передачи, при котором подзадача отправляет записи подзадаче следующего оператора с тем же номером. Без него операторы не склеиваются.
Режим Session
Кластер работает независимо от заданий и обслуживает несколько сразу. main выполняется в клиенте.
Режим Application
Кластер создаётся на одно приложение, а main выполняется в JobManager. Jar уже лежит на classpath кластера.
Режим per-job
Прежний режим развёртывания, удалённый во Flink 2.0. Вместо него используется Application.
Standalone
Способ запуска кластера без системы управления ресурсами. Процессы поднимаешь сам, как у нас в Docker Compose, и новых TaskManager кластер запросить не может.
uid
Постоянный идентификатор оператора, заданный вручную. По нему Flink находит состояние оператора при восстановлении из savepoint.
Savepoint
Снимок состояния задания, который ты создаёшь вручную, чтобы остановить задание и продолжить его с того же места. Разбирается в уроке 13.
Стратегия перезапуска (restart strategy)
Правило, по которому Flink решает, перезапускать ли задание после сбоя и с какой задержкой.
Чекпоинт (checkpoint)
Согласованный снимок состояния, который Flink снимает автоматически. Подробно в уроке 11.
NoResourceAvailableException
Исключение, которым кончается запрос слотов, если кластер не может их дать.
REST-интерфейс
HTTP-интерфейс Dispatcher на порту 8081. Через него работают Web UI, клиент flink и твои скрипты.
Web UI
Панель Flink в браузере. Показывает вершины, подзадачи, слоты и исключения.
jq
Консольная утилита для разбора и преобразования JSON.
Driver (Spark)
Процесс Spark, который выполняет main и планирует работу приложения.
Executor (Spark)
Рабочий процесс Spark, который выполняет задачи. Похож на TaskManager.
Kubernetes
Система управления контейнерами. Flink на ней может сам запрашивать новые TaskManager. Разбирается в уроке 20.
YARN
Менеджер ресурсов из экосистемы Hadoop, на котором тоже можно запускать Flink.
Apache Kafka
Распределённый журнал сообщений. Из него наше задание читает платежи.

Урок 3 · Основы

DataStream и грязные данные

После урока ты будешь уводить битые записи в отдельный топик Kafka, сохраняя работу задания, и заранее узнавать, какие твои типы Flink отдал бы в Kryo.

Одна битая запись против целого задания

Ночью партнёр выкатывает новую версию сервиса, и в топик платежей попадает строка с обрезанным JSON. Парсер в задании бросает исключение. Дальше всё зависит от настроек, и ни один из вариантов по умолчанию тебя не обрадует.

Без чекпоинтов (периодических снимков задания) задание переходит в состояние FAILED и перестаёт читать платежи, пока кто-то не запустит его заново. С чекпоинтами Flink перезапускает его и возвращается к смещениям из последнего снимка. Та же запись приезжает снова, и всё повторяется. Такую запись называют ядовитой (poison pill).

В исходниках

Проверим три звена этой цепочки. Контракт функции прямо говорит, что исключение роняет операцию.

apache/flink@release-2.2.1 · ProcessFunction.java, строки 62-70
 * @param value The input value.
 * @param ctx A {@link Context} that allows querying the timestamp of the element and getting a
 *     {@link TimerService} for registering timers and querying the time. The context is only
 *     valid during the invocation of this method, do not store it.
 * @param out The collector for returning result values.
 * @throws Exception This method may throw exceptions. Throwing an exception will cause the
 *     operation to fail and may trigger recovery.
 */
public abstract void processElement(I value, Context ctx, Collector<O> out) throws Exception;

Оператор вокруг твоей функции исключение не перехватывает. Никакого пропуска плохих записей на этом уровне нет.

apache/flink@release-2.2.1 · ProcessOperator.java, строки 60-66
@Override
public void processElement(StreamRecord<IN> element) throws Exception {
    collector.setTimestamp(element);
    context.element = element;
    userFunction.processElement(element.getValue(), context, collector);
    context.element = null;
}

Что будет после падения, решает стратегия перезапуска (restart strategy). Её значение по умолчанию зависит от чекпоинтов. У exponential-delay число попыток по умолчанию равно Integer.MAX_VALUE, в документации оно обозначено как бесконечное.

apache/flink@release-2.2.1 · RestartStrategyOptions.java, строки 151-156
.text(
        "If checkpointing is disabled, the default value is %s. "
                + "If checkpointing is enabled, the default value is %s, and the default values of %s related config options will be used.",
        code(NO_RESTART_STRATEGY.getMainValue()),
        code(EXPONENTIAL_DELAY.getMainValue()),
        code(EXPONENTIAL_DELAY.getMainValue()))
apache/flink@release-2.2.1 · RestartStrategyOptions.java, строки 301-306
@Documentation.OverrideDefault("infinite")
public static final ConfigOption<Integer> RESTART_STRATEGY_EXPONENTIAL_DELAY_ATTEMPTS =
        ConfigOptions.key("restart-strategy.exponential-delay.attempts-before-reset-backoff")
                .intType()
                .defaultValue(Integer.MAX_VALUE)
                .withDescription(

В нашем docker-compose.yml стратегия не задана. Чекпоинты появятся только в уроке 11. Значит, сегодня одна брошенная наружу ошибка остановила бы задание насовсем.

Линия розлива

Представь завод лимонада. Бутылки едут по ленте, автомат клеит этикетки. Бутылка со сколом может заклинить автомат, и тогда встанет вся линия. После ремонта на ленту приедет та же бутылка, потому что с неё и начали.

Поэтому перед автоматом ставят отбраковщик. Он сбрасывает плохую бутылку на боковой лоток и вешает бирку с причиной. Лента едет дальше, а контролёр смены разбирает лоток, когда дойдут руки.

В Flink лента называется основным потоком, лоток называется побочным выходом (side output), а ящик с бирками это топик для отбракованных записей, dead letter queue или DLQ. Выбери вариант и посмотри на судьбу задания. Модель упрощённая, в ней 16 записей и две из них битые.

Что делает одна битая запись

Нажми кнопку. Зелёные точки это нормальные платежи, красные битые.

Такой же цикл есть в реальных замерах курса. В уроке 15 задание с CDC после первого UPDATE уходит в цикл RESTARTING из-за одной и той же ошибки, и само оттуда не выходит.

DataStream это план

Прежде чем чинить, разберёмся, из чего строится задание. DataStream<T> внешне похож на Dataset<T> в Spark. Это неизменяемое описание потока с известным типом, и каждый вызов вроде map или process возвращает новый объект. Ни одна запись при этом никуда не едет.

В исходниках

Посмотри на метод, через который проходят преобразования с одним входом. Он создаёт узел плана OneInputTransformation, запоминает его в окружении и заворачивает в новый поток. Запуска операторов здесь нет.

apache/flink@release-2.2.1 · DataStream.java, строки 823-847
protected <R> SingleOutputStreamOperator<R> doTransform(
        String operatorName,
        TypeInformation<R> outTypeInfo,
        StreamOperatorFactory<R> operatorFactory) {

    // read the output type of the input Transform to coax out errors about MissingTypeInfo
    transformation.getOutputType();

    OneInputTransformation<T, R> resultTransform =
            new OneInputTransformation<>(
                    this.transformation,
                    operatorName,
                    operatorFactory,
                    outTypeInfo,
                    environment.getParallelism(),
                    false);

    @SuppressWarnings({"unchecked", "rawtypes"})
    SingleOutputStreamOperator<R> returnStream =
            new SingleOutputStreamOperator(environment, resultTransform);

    getExecutionEnvironment().addOperator(resultTransform);

    return returnStream;
}

Обрати внимание на аргумент outTypeInfo. Тип выхода Flink должен знать уже на этапе описания плана, к этому мы вернёмся ниже.

Основные преобразования, которые встретятся в курсе.

ВызовЧто делаетГде встретится
mapОдна запись на входе, одна на выходеПростые преобразования полей
filterОставляет запись или выбрасывает еёdrop-blank из урока 2
flatMapНоль или больше записей на одну входнуюРазбиение пачки на отдельные события
processКак flatMap, плюс контекст с временем и побочными выходами, на потоке с ключом ещё таймеры и состояниеparse-payment сегодня, урок 7
keyByРазбивает поток по ключу, отдельного оператора не создаётУрок 4
union, connectОбъединяют два потокаУроки 8 и 9
getSideOutputДаёт поток из побочного выхода оператораdlq-sink сегодня
sinkToДобавляет приёмникЗапись в Kafka сегодня

Ленивость тебе знакома по Spark, там план тоже начинает работать после start(). Разница в том, что происходит дальше. Spark в обычном режиме исполняет план заново на каждой порции. Flink один раз разворачивает его в операторы, и они живут, пока живёт задание.

Побочный выход

Разделить поток на хорошие и плохие записи можно двумя фильтрами, но тогда каждую запись придётся разбирать дважды, причём оба фильтра должны согласованно решать, что считать ошибкой. Побочный выход решает это за один проход. Функция один раз разбирает строку и отправляет результат либо в обычный Collector, либо в выход с меткой OutputTag. Типы двух выходов могут различаться. У нас основной поток содержит PaymentEvent, а побочный RejectedRecord.

Kafkapayments.raw Одна вершина, parallelism 2 Sourcepayments-raw processparse-payment Sink: stdout dlq-sink PaymentEvent RejectedRecord Kafkapayments.dlq

В исходниках

Метка обязана быть анонимным подклассом. Из-за стирания типов обычный new OutputTag<RejectedRecord>("x") не знает свой параметр. У анонимного подкласса он записан в метаданных класса. Без фигурных скобок конструктор бросит InvalidTypesException.

apache/flink@release-2.2.1 · OutputTag.java, строки 60-74
public OutputTag(String id) {
    Preconditions.checkNotNull(id, "OutputTag id cannot be null.");
    Preconditions.checkArgument(!id.isEmpty(), "OutputTag id must not be empty.");
    this.id = id;

    try {
        this.typeInfo = TypeExtractor.createTypeInfo(this, OutputTag.class, getClass(), 0);
    } catch (InvalidTypesException e) {
        throw new InvalidTypesException(
                "Could not determine TypeInformation for the OutputTag type. "
                        + "The most common reason is forgetting to make the OutputTag an anonymous inner class. "
                        + "It is also not possible to use generic type variables with OutputTags, such as 'Tuple2<A, B>'.",
                e);
    }
}

Вызов context.output передаёт запись тому же объекту output, что и обычный collect, только с меткой. Время записи сохраняется.

apache/flink@release-2.2.1 · ProcessOperator.java, строки 96-102
@Override
public <X> void output(OutputTag<X> outputTag, X value) {
    if (outputTag == null) {
        throw new IllegalArgumentException("OutputTag must not be null.");
    }
    output.collect(outputTag, new StreamRecord<>(value, element.getTimestamp()));
}

Дальше каждый следующий оператор в цепочке подключён через свой выход, и этот выход сравнивает метки. Чужую запись он просто игнорирует.

apache/flink@release-2.2.1 · CopyingChainingOutput.java, строки 53-62
@Override
public <X> void collect(OutputTag<X> outputTag, StreamRecord<X> record) {
    if (this.outputTag == null || !this.outputTag.equals(outputTag)) {
        // we are not responsible for emitting to the side-output specified by this
        // OutputTag.
        return;
    }

    pushToOperator(record);
}

На стороне плана getSideOutput создаёт отдельное преобразование и заодно проверяет, что под одним именем не просят два разных типа. Метки сравниваются только по строке с именем, поэтому имя в пределах оператора должно быть уникальным.

apache/flink@release-2.2.1 · SingleOutputStreamOperator.java, строки 416-435
public <X> SideOutputDataStream<X> getSideOutput(OutputTag<X> sideOutputTag) {
    sideOutputTag = clean(requireNonNull(sideOutputTag));

    // make a defensive copy
    sideOutputTag = new OutputTag<X>(sideOutputTag.getId(), sideOutputTag.getTypeInfo());

    TypeInformation<?> type = requestedSideOutputs.get(sideOutputTag);
    if (type != null && !type.equals(sideOutputTag.getTypeInfo())) {
        throw new UnsupportedOperationException(
                "A side output with a matching id was "
                        + "already requested with a different type. This is not allowed, side output "
                        + "ids need to be unique.");
    }

    requestedSideOutputs.put(sideOutputTag, sideOutputTag.getTypeInfo());

    SideOutputTransformation<X> sideOutputTransformation =
            new SideOutputTransformation<>(this.getTransformation(), sideOutputTag);
    return new SideOutputDataStream<>(this.getExecutionEnvironment(), sideOutputTransformation);
}

Это преобразование не порождает оператора. При переводе плана в граф оно становится виртуальным узлом, поэтому лишнего шага обработки побочный выход не добавляет.

apache/flink@release-2.2.1 · SideOutputTransformationTranslator.java, строки 69-76
final List<Integer> virtualResultIds = new ArrayList<>();
final Transformation<?> parentTransformation = parentTransformations.get(0);
for (int inputId : context.getStreamNodeIds(parentTransformation)) {
    final int virtualId = Transformation.getNewNodeId();
    streamGraph.addVirtualSideOutputNode(inputId, virtualId, transformation.getOutputTag());
    virtualResultIds.add(virtualId);
}
return virtualResultIds;

Писать в побочный выход можно из ProcessFunction и родственных функций, полный список есть в документации по ссылке выше. У MapFunction и FlatMapFunction контекста с методом output нет, поэтому парсер у нас написан на ProcessFunction.

В Spark встроенного побочного выхода нет. Обычно строку разбирают через from_json, который на битых данных даёт null, и затем фильтруют результат на два направления внутри foreachBatch. Во Flink разделение происходит внутри одного оператора и на каждой записи.

Что класть в DLQ и что с ним делать

DLQ стоит проектировать как данные, которые придётся разбирать. Мы кладём три поля. Исходный текст нужен, чтобы после исправления парсера отправить запись обратно в payments.raw. Код причины позволяет считать отказы по видам. Время отказа показывает, когда началась проблема.

Лог TaskManager эту задачу не решает, потому что из него неудобно переигрывать записи. Топик Kafka хранит их заданное время и читается любым потребителем. Цена в том, что теперь задание зависит от записи в этот топик. Если продюсер получит ошибку, она дойдёт до задачи и уронит её.

В исходниках

Посмотрим, что значит AT_LEAST_ONCE у приёмника Kafka. Метод flush дожидается отправки всего накопленного и затем проверяет ошибки продюсера.

apache/flink-connector-kafka@v5.0.0 · KafkaWriter.java, строки 180-188
@Override
public void flush(boolean endOfInput) throws IOException, InterruptedException {
    if (deliveryGuarantee != DeliveryGuarantee.NONE || endOfInput) {
        LOG.debug("final flush={}", endOfInput);
        currentProducer.flush();
    }

    checkAsyncException();
}
apache/flink-connector-kafka@v5.0.0 · KafkaWriter.java, строки 290-300
private void checkAsyncException() throws IOException {
    // reset this exception since we could close the writer later on
    Exception e = asyncProducerException;
    if (e != null) {

        asyncProducerException = null;
        numRecordsOutErrorsCounter.inc();
        throw new IOException(
                "One or more Kafka Producer send requests have encountered exception", e);
    }
}

Вызывает его оператор приёмника непосредственно перед барьером чекпоинта.

apache/flink@release-2.2.1 · SinkWriterOperator.java, строки 187-193
@Override
public void prepareSnapshotPreBarrier(long checkpointId) throws Exception {
    super.prepareSnapshotPreBarrier(checkpointId);
    if (!endOfInput) {
        sinkWriter.flush(false);
        emitCommittables(checkpointId);
    }

Отсюда следует важное ограничение. Гарантия держится на чекпоинтах, а их у нас пока нет. Записи в DLQ всё равно доходят, потому что продюсер отправляет свои пачки сам, но обещать отсутствие потерь при падении мы сможем с урока 11.

Зачем Flink знать твои типы

Тот же завод объясняет систему типов. Захваты автомата подогнаны под известную форму бутылки и работают быстро. Бутылку незнакомой формы приходится класть в универсальную коробку с пенопластом. Поместится почти всё, но коробка тяжелее, и без вскрытия не видно, что внутри.

Flink описывает каждый тип объектом TypeInformation, а тот создаёт TypeSerializer, который переводит объект в байты и умеет его копировать. Сериализатор работает при передаче по сети между TaskManager (урок 4) и при записи в состояние (урок 7). Он нужен и внутри одной цепочки операторов, где сети нет вовсе.

В исходниках

Между операторами цепочки запись передаётся вызовом метода, но перед этим копируется сериализатором. Так оператор не сможет испортить объект, который держит его сосед.

apache/flink@release-2.2.1 · CopyingChainingOutput.java, строки 69-75
@SuppressWarnings("unchecked")
StreamRecord<T> castRecord = (StreamRecord<T>) record;

numRecordsOut.inc();
numRecordsIn.inc();
StreamRecord<T> copy = castRecord.copy(serializer.copy(castRecord.getValue()));
recordProcessor.accept(copy);

Такая обёртка выбирается всегда, если не включено повторное использование объектов (pipeline.object-reuse, по умолчанию выключено).

apache/flink@release-2.2.1 · OperatorChain.java, строки 910-924
if (containingTask.getExecutionConfig().isObjectReuseEnabled()) {
    currentOperatorOutput =
            new ChainingOutput<>(
                    operator, recordsOutCounter, operator.getMetricGroup(), outputTag);
} else {
    TypeSerializer<IN> inSerializer =
            operatorConfig.getTypeSerializerIn1(userCodeClassloader);
    currentOperatorOutput =
            new CopyingChainingOutput<>(
                    operator,
                    inSerializer,
                    recordsOutCounter,
                    operator.getMetricGroup(),
                    outputTag);
}

Теперь о том, как Flink выбирает описание типа. Последние шаги TypeExtractor такие. Интерфейс сразу становится generic-типом, затем идёт попытка разобрать класс как POJO, а если она не удалась, снова generic.

apache/flink@release-2.2.1 · TypeExtractor.java, строки 2017-2039
if (Modifier.isInterface(clazz.getModifiers())) {
    // Interface has no members and is therefore not handled as POJO
    return new GenericTypeInfo<>(clazz);
}

try {
    Type t = parameterizedType != null ? parameterizedType : clazz;
    TypeInformation<OUT> pojoType =
            analyzePojo(t, new ArrayList<>(typeHierarchy), in1Type, in2Type);
    if (pojoType != null) {
        return pojoType;
    }
} catch (InvalidTypesException e) {
    if (LOG.isDebugEnabled()) {
        LOG.debug(
                "Unable to handle type " + clazz + " as POJO. Message: " + e.getMessage(),
                e);
    }
    // ignore and create generic type info
}

// return a generic type
return new GenericTypeInfo<>(clazz);

Первая проверка внутри разбора POJO касается видимости класса. Она срабатывает и для record, поэтому record без public тоже уедет в Kryo.

apache/flink@release-2.2.1 · TypeExtractor.java, строки 2127-2136
Class<OUT> clazz = typeToClass(type);
if (!Modifier.isPublic(clazz.getModifiers())) {
    LOG.info(
            "Class "
                    + clazz.getName()
                    + " is not public so it cannot be used as a POJO type "
                    + "and must be processed as GenericType. {}",
            GENERIC_TYPE_DOC_HINT);
    return new GenericTypeInfo<>(clazz);
}

Классу нужен конструктор без аргументов, а для record эта проверка пропущена. Объекты record Flink создаёт через канонический конструктор, об этом написано в описании PojoTypeInfo.

apache/flink@release-2.2.1 · TypeExtractor.java, строки 2221-2245
if (isRecord) {
    // no default constructor extraction needs to be applied for Java records
    return pojoType;
}

// Try retrieving the default constructor, if it does not have one
// we cannot use this because the serializer uses it.
Constructor<OUT> defaultConstructor = null;
try {
    defaultConstructor = clazz.getDeclaredConstructor();
} catch (NoSuchMethodException e) {
    if (clazz.isInterface() || Modifier.isAbstract(clazz.getModifiers())) {
        LOG.info(
                clazz
                        + " is abstract or an interface, having a concrete "
                        + "type can increase performance.");
    } else {
        LOG.info(
                clazz
                        + " is missing a default constructor so it cannot be used as a POJO type "
                        + "and must be processed as GenericType. {}",
                GENERIC_TYPE_DOC_HINT);
        return null;
    }
}
apache/flink@release-2.2.1 · PojoTypeInfo.java, строки 63-65
* Java Record classes can also be used as valid POJOs (even though they don't fulfill some of the
* above criteria). In this case Flink will use the record canonical constructor to create the
* objects.

Правила легко проверить на практике. Таблицу ниже мы получили вызовом TypeInformation.of на Flink 2.2.1 в обычном тесте, кластер для этого не нужен. Некоторые строки удивляют.

ТипЧто выбрал FlinkКомментарий
public record PaymentEventPojoTypeInfoНаш случай, все поля свои
record без publicGenericTypeInfoВесь объект уходит в Kryo
public record с полем UUIDPojoTypeInfo, поле GenericTypeKryo спрятан внутри хорошего на вид типа
Поле LocalDateGenericTypeПри этом Instant и BigDecimal получают свои сериализаторы
Поле List<String>NullableListВстроенная поддержка, нужен интерфейс и конкретный параметр
Поле ArrayList<String> или просто ListGenericTypeТе же данные, но условие не выполнено
Поле Optional<String>GenericTypeОтдельного сериализатора нет
Класс без конструктора без аргументовGenericTypeInfoПричина видна только в логе уровня INFO
Класс с приватным полем без сеттераGenericTypeInfoТам же

Kryo как ловушка

Kryo это универсальная библиотека сериализации, через которую Flink пропускает всё, что не распознал. Задание при этом работает, поэтому проблема долго остаётся незаметной. Документация называет такие типы чёрными ящиками и прямо говорит, что с POJO Flink работает эффективнее. Для состояния есть цена посерьёзнее. Схему типа, который хранится через Kryo, нельзя менять при восстановлении из savepoint, а для POJO такая возможность есть. В уроке 13 мы добавим поле в record и поднимем задание из старого savepoint, и это сработает.

Поэтому в задании стоит PipelineOptions.GENERIC_TYPES = false, то есть настройка pipeline.generic-types. Любой тип, который пошёл бы через Kryo, теперь вызывает ошибку.

В исходниках

Запрет проверяется в одном месте, когда у generic-типа просят сериализатор. Флаг читается из конфигурации, которую мы передаём окружению.

apache/flink@release-2.2.1 · GenericTypeInfo.java, строки 82-93
@Override
@PublicEvolving
public TypeSerializer<T> createSerializer(SerializerConfig config) {
    if (config.hasGenericTypesDisabled()) {
        throw new UnsupportedOperationException(
                "Generic types have been disabled in the ExecutionConfig and type "
                        + this.typeClass.getName()
                        + " is treated as a generic type.");
    }

    return new KryoSerializer<T>(this.typeClass, config);
}
apache/flink@release-2.2.1 · SerializerConfigImpl.java, строки 245-251
public boolean hasGenericTypesDisabled() {
    return !configuration.get(PipelineOptions.GENERIC_TYPES);
}

public void setGenericTypes(boolean genericTypes) {
    configuration.set(PipelineOptions.GENERIC_TYPES, genericTypes);
}

Для POJO сериализаторы создаются для каждого поля. Вот почему поле UUID внутри record тоже будет поймано, хотя сам record получил PojoTypeInfo.

apache/flink@release-2.2.1 · PojoTypeInfo.java, строки 350-360
public PojoSerializer<T> createPojoSerializer(SerializerConfig config) {
    TypeSerializer<?>[] fieldSerializers = new TypeSerializer<?>[fields.length];
    Field[] reflectiveFields = new Field[fields.length];

    for (int i = 0; i < fields.length; i++) {
        fieldSerializers[i] = fields[i].getTypeInformation().createSerializer(config);
        reflectiveFields[i] = fields[i].getField();
    }

    return new PojoSerializer<T>(getTypeClass(), fieldSerializers, reflectiveFields, config);
}

Теперь важно, когда это происходит. Сериализаторы создаются при сборке графа, и env.execute собирает граф перед отправкой. Мы проверили это отдельным тестом с полем UUID. Описание потока прошло без ошибок, а getStreamGraph бросил исключение из StreamGraph.addOperator. Значит, ошибка типа вылезет при запуске задания, а записи в этот момент ещё не пошли.

apache/flink@release-2.2.1 · StreamGraph.java, строки 1183-1187
private <T> TypeSerializer<T> createSerializer(TypeInformation<T> typeInfo) {
    return typeInfo != null && !(typeInfo instanceof MissingTypeInfo)
            ? typeInfo.createSerializer(executionConfig.getSerializerConfig())
            : null;
}
apache/flink@release-2.2.1 · StreamExecutionEnvironment.java, строки 1838-1846
public JobExecutionResult execute(String jobName) throws Exception {
    final List<Transformation<?>> originalTransformations = new ArrayList<>(transformations);
    StreamGraph streamGraph = getStreamGraph();
    if (jobName != null) {
        streamGraph.setJobName(jobName);
    }

    try {
        return execute(streamGraph);

В Spark роль TypeInformation играет Encoder. В Java-коде его передают явно, например Encoders.bean или Encoders.kryo, поэтому выбор Kryo виден в строке вызова. Во Flink тип определяется отражением, и переход на Kryo случается молча. Настройка GENERIC_TYPES=false делает этот переход видимым.

Где побочный выход не поможет

Наш try/catch защищает только код внутри processElement. До него запись уже прошла источник, а там своя обработка ошибок.

В исходниках

Источник превращает байты в строку через SimpleStringSchema. Сообщение Kafka с пустым значением (tombstone) приходит сюда как null, и конструктор String бросает NullPointerException. Мы проверили это отдельным вызовом метода.

apache/flink@release-2.2.1 · SimpleStringSchema.java, строки 74-79
// ------------------------------------------------------------------------

@Override
public String deserialize(byte[] message) {
    return new String(message, charset);
}

Обёртка коннектора передаёт значение как есть, а эмиттер оборачивает любую ошибку и не сдвигает смещение. Смещение обновляется только после успешного разбора.

apache/flink-connector-kafka@v5.0.0 · KafkaValueOnlyDeserializationSchemaWrapper.java, строки 48-52
@Override
public void deserialize(ConsumerRecord<byte[], byte[]> message, Collector<T> out)
        throws IOException {
    deserializationSchema.deserialize(message.value(), out);
}
apache/flink-connector-kafka@v5.0.0 · KafkaRecordEmitter.java, строки 50-57
try {
    sourceOutputWrapper.setSourceOutput(output);
    sourceOutputWrapper.setTimestamp(consumerRecord.timestamp());
    deserializationSchema.deserialize(consumerRecord, sourceOutputWrapper);
    splitState.setCurrentOffset(consumerRecord.offset() + 1);
} catch (Exception e) {
    throw new IOException("Failed to deserialize consumer record due to", e);
}

Способ обойти это есть в самом контракте схемы. Если deserialize вернул null, запись молча пропускается, поэтому своя схема с проверкой на null решит вопрос.

apache/flink@release-2.2.1 · DeserializationSchema.java, строки 81-86
default void deserialize(byte[] message, Collector<T> out) throws IOException {
    T deserialize = deserialize(message);
    if (deserialize != null) {
        out.collect(deserialize);
    }
}
⚠
Узкий catch и текст null

Парсер ловит только JsonProcessingException. Строка из четырёх букв null это корректный JSON, Jackson возвращает Java null без исключения, и следующая строка падает с NullPointerException. Мы проверили это на тестовом стенде функции. Как закрыть этот случай, разобрано на вкладке с ответом.

Проверь себя

Задание с чекпоинтами после релиза партнёра циклится в RESTARTING. В списке исключений одна и та же ошибка разбора JSON. Что исправит ситуацию?

Коллега добавил в public record PaymentEvent поле UUID traceId. В задании стоит GENERIC_TYPES=false. Что произойдёт?

Партнёр добавил в JSON платежа новое поле merchantName. ParsePaymentFunction использует ObjectMapper без настроек. Что ты увидишь?

Что будет в конце

Задание разбирает JSON в PaymentEvent, а непригодные записи отправляет в топик payments.dlq с причиной и временем. Настройки подключения приходят из аргументов командной строки. Функцию проверяют быстрые тесты без кластера. Бизнес-проверки платежа ты допишешь в задании, сегодня они будут пустой заглушкой.

Шаг 1. Зависимости

Открой build.gradle.kts. Появляются две группы зависимостей.

что изменилось в build.gradle.kts
@@ -29,6 +29,7 @@     flinkProvided("org.apache.flink:flink-streaming-java:$flinkVersion")
     flinkProvided("org.apache.flink:flink-clients:$flinkVersion")
     flinkProvided("org.apache.flink:flink-connector-base:$flinkVersion")
+    flinkProvided("org.apache.flink:flink-json:$flinkVersion") 
     implementation("org.apache.flink:flink-connector-kafka:$kafkaConnectorVersion")
     implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
@@ -39,6 +40,9 @@     testImplementation("org.junit.jupiter:junit-jupiter")
     testRuntimeOnly("org.junit.platform:junit-platform-launcher")
     testImplementation("org.assertj:assertj-core:3.27.7")
+    testImplementation("org.apache.flink:flink-test-utils:$flinkVersion")+    testImplementation("org.apache.flink:flink-streaming-java:$flinkVersion:tests")+    testImplementation("org.apache.flink:flink-runtime:$flinkVersion:tests") }
 
 tasks.test {

flink-json содержит JsonSerializationSchema, которой мы пишем в DLQ. Он идёт через flinkProvided, потому что файл flink-json лежит в папке lib/ дистрибутива Flink и в твой jar не нужен. Это видно по файлу сборки дистрибутива flink-dist/src/main/assemblies/bin.xml.

Строки с :tests подключают специальные артефакты с классами из тестового кода Flink. Классификатор tests означает отдельный jar рядом с основным. В них лежат OneInputStreamOperatorTestHarness и ProcessFunctionTestHarnesses, а flink-test-utils добавляет остальные утилиты, например PojoTestUtils.

Шаг 2. Запись для DLQ

Создай model/RejectedRecord.java. Это public record, поэтому Flink получит для него PojoTypeInfo, и запрет на generic-типы его не затронет.

src/main/java/dev/study/payguard/model/RejectedRecord.java
package dev.study.payguard.model;

/** Запись, которую не удалось принять: исходный текст и причина отказа для разбора в DLQ. */
public record RejectedRecord(String raw, String reason, long rejectedAtMs) {
}

Шаг 3. Функция разбора

Создай pipeline/ParsePaymentFunction.java.

src/main/java/dev/study/payguard/pipeline/ParsePaymentFunction.java
package dev.study.payguard.pipeline;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.study.payguard.model.PaymentEvent;
import dev.study.payguard.model.RejectedRecord;
import java.util.Optional;
import org.apache.flink.api.common.functions.OpenContext;
import org.apache.flink.streaming.api.functions.ProcessFunction;
import org.apache.flink.util.Collector;
import org.apache.flink.util.OutputTag;

/** Разбирает и проверяет платёж. Всё непригодное уходит в побочный выход, а не роняет задание. */
public class ParsePaymentFunction extends ProcessFunction<String, PaymentEvent> {

    public static final OutputTag<RejectedRecord> REJECTED = new OutputTag<>("rejected") {
    };

    private transient ObjectMapper mapper;

    @Override
    public void open(OpenContext context) {
        mapper = new ObjectMapper();
    }

    @Override
    public void processElement(String raw, Context context, Collector<PaymentEvent> out) {
        try {
            PaymentEvent payment = mapper.readValue(raw, PaymentEvent.class);
            Optional<String> reason = PaymentValidator.rejectionReason(payment);
            if (reason.isPresent()) {
                reject(context, raw, reason.get());
            } else {
                out.collect(payment);
            }
        } catch (JsonProcessingException e) {
            reject(context, raw, "bad-json");
        }
    }

    private static void reject(Context context, String raw, String reason) {
        context.output(REJECTED, new RejectedRecord(raw, reason, System.currentTimeMillis()));
    }
}
КОД
    public static final OutputTag<RejectedRecord> REJECTED = new OutputTag<>("rejected") {
    };

    private transient ObjectMapper mapper;

    @Override
    public void open(OpenContext context) {
        mapper = new ObjectMapper();
    }

    @Override
    public void processElement(String raw, Context context, Collector<PaymentEvent> out) {
        try {
            PaymentEvent payment = mapper.readValue(raw, PaymentEvent.class);
            Optional<String> reason = PaymentValidator.rejectionReason(payment);
            if (reason.isPresent()) {
                reject(context, raw, reason.get());
            } else {
                out.collect(payment);
            }
        } catch (JsonProcessingException e) {
            reject(context, raw, "bad-json");
        }
    }

    private static void reject(Context context, String raw, String reason) {
        context.output(REJECTED, new RejectedRecord(raw, reason, System.currentTimeMillis()));
    }
ПРОСТЫМИ СЛОВАМИ

Метка побочного выхода. Пустые фигурные скобки создают анонимный подкласс, только так Flink узнаёт тип RejectedRecord. Поле публичное и статическое, потому что по нему же задание забирает поток.

Сама функция сериализуется на клиенте и едет в TaskManager. Поле transient в этом не участвует. Метод open создаёт ObjectMapper уже в подзадаче, свой на каждую.

processElement вызывается на каждую запись. Jackson собирает record из JSON через канонический конструктор, затем валидатор решает, принимать ли платёж.

Нормальный платёж идёт в основной выход через out.collect, с причиной отказа уходит в побочный.

Ошибка разбора перехватывается и становится обычной записью с кодом bad-json. Запись продолжает жизнь как данные и больше не роняет задачу.

Вспомогательный метод собирает RejectedRecord и пишет его через context.output с меткой. Время здесь берётся с часов машины, это момент отказа.

В строке с PaymentValidator.rejectionReason используется класс, который ты напишешь в задании, поэтому его файл с решением сейчас создавать не нужно. Чтобы код компилировался, положи рядом заглушку. Это класс PaymentValidator в том же пакете с одним статическим методом rejectionReason(PaymentEvent), который возвращает Optional.empty(). С ней любой разобранный платёж считается годным.

Шаг 4. Конфигурация и приёмник

До сих пор адреса были зашиты в код. JobConfig читает их из аргументов, значит, один и тот же jar подойдёт для любого стенда.

src/main/java/dev/study/payguard/job/JobConfig.java
package dev.study.payguard.job;

import java.io.Serializable;
import org.apache.flink.util.ParameterTool;

/** Настройки задания из аргументов командной строки: один и тот же jar работает на любом стенде. */
public record JobConfig(String bootstrapServers, String rawTopic, String dlqTopic, String groupId)
        implements Serializable {

    public static JobConfig fromArgs(String[] args) {
        ParameterTool params = ParameterTool.fromArgs(args);
        return new JobConfig(
                params.get("bootstrap", "kafka:9092"),
                params.get("raw-topic", "payments.raw"),
                params.get("dlq-topic", "payments.dlq"),
                params.get("group-id", "payguard"));
    }
}

ParameterTool разбирает пары вида --dlq-topic имя. Значения по умолчанию подходят для нашего Compose, поэтому запуск без аргументов продолжит работать. Всё, что написано после имени jar в flink run, попадает в args.

Сборку приёмника вынесем в отдельный класс, она ещё пригодится для алертов.

src/main/java/dev/study/payguard/job/KafkaSinks.java
package dev.study.payguard.job;

import org.apache.flink.connector.base.DeliveryGuarantee;
import org.apache.flink.connector.kafka.sink.KafkaRecordSerializationSchema;
import org.apache.flink.connector.kafka.sink.KafkaSink;
import org.apache.flink.formats.json.JsonSerializationSchema;

/** Единая сборка приёмников Kafka, которые пишут объекты как JSON. */
final class KafkaSinks {

    static <T> KafkaSink<T> atLeastOnceJson(String bootstrapServers, String topic) {
        return KafkaSink.<T>builder()
                .setBootstrapServers(bootstrapServers)
                .setRecordSerializer(KafkaRecordSerializationSchema.<T>builder()
                        .setTopic(topic)
                        .setValueSerializationSchema(new JsonSerializationSchema<T>())
                        .build())
                .setDeliveryGuarantee(DeliveryGuarantee.AT_LEAST_ONCE)
                .build();
    }

    private KafkaSinks() {
    }
}

JsonSerializationSchema превращает объект в JSON, топик задан заранее, а setDeliveryGuarantee включает режим повторной отправки, разобранный в теории. Ключ записи мы не задаём, распределением по партициям занимается продюсер Kafka.

Шаг 5. Задание

Перепиши PayGuardJob. Фильтр drop-blank из урока 2 больше не нужен, потому что пустая строка теперь получит причину bad-json. Также мы убрали печать плана, ты уже видел его в уроке 2.

что изменилось в src/main/java/dev/study/payguard/job/PayGuardJob.java
@@ -1,39 +1,52 @@ package dev.study.payguard.job;
 
+import dev.study.payguard.model.PaymentEvent;+import dev.study.payguard.model.RejectedRecord;+import dev.study.payguard.pipeline.ParsePaymentFunction; import org.apache.flink.api.common.eventtime.WatermarkStrategy;
 import org.apache.flink.api.common.serialization.SimpleStringSchema;
+import org.apache.flink.configuration.Configuration;+import org.apache.flink.configuration.PipelineOptions; import org.apache.flink.connector.kafka.source.KafkaSource;
 import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
+import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
 
 /** Точка входа Flink-задания PayGuard. */
 public final class PayGuardJob {
 
-    private static final String BOOTSTRAP_SERVERS = "kafka:9092";-    private static final String RAW_TOPIC = "payments.raw";+    public static void main(String[] args) throws Exception {+        JobConfig config = JobConfig.fromArgs(args); 
-    public static void main(String[] args) throws Exception {-        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();+        Configuration flinkConfig = new Configuration();+        flinkConfig.set(PipelineOptions.GENERIC_TYPES, false);+        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(flinkConfig); 
-        KafkaSource<String> source = KafkaSource.<String>builder()-                .setBootstrapServers(BOOTSTRAP_SERVERS)-                .setTopics(RAW_TOPIC)-                .setGroupId("payguard")+        SingleOutputStreamOperator<PaymentEvent> payments = env+                .fromSource(rawSource(config), WatermarkStrategy.noWatermarks(), "payments-raw")+                .uid("payments-raw")+                .process(new ParsePaymentFunction())+                .name("parse-payment")+                .uid("parse-payment");++        payments.getSideOutput(ParsePaymentFunction.REJECTED)+                .sinkTo(KafkaSinks.<RejectedRecord>atLeastOnceJson(config.bootstrapServers(), config.dlqTopic()))+                .name("dlq-sink")+                .uid("dlq-sink");++        payments.print().name("stdout").uid("stdout");++        env.execute("payguard");+    }++    private static KafkaSource<String> rawSource(JobConfig config) {+        return KafkaSource.<String>builder()+                .setBootstrapServers(config.bootstrapServers())+                .setTopics(config.rawTopic())+                .setGroupId(config.groupId())                 .setStartingOffsets(OffsetsInitializer.latest())
                 .setValueOnlyDeserializer(new SimpleStringSchema())
                 .build();
--        env.fromSource(source, WatermarkStrategy.noWatermarks(), "payments-raw")-                .uid("payments-raw")-                .filter(record -> !record.isBlank())-                .name("drop-blank")-                .uid("drop-blank")-                .print()-                .name("stdout")-                .uid("stdout");--        System.out.println(env.getExecutionPlan());-        env.execute("payguard");     }
 
     private PayGuardJob() {
КОД
        JobConfig config = JobConfig.fromArgs(args);

        Configuration flinkConfig = new Configuration();
        flinkConfig.set(PipelineOptions.GENERIC_TYPES, false);
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(flinkConfig);

        SingleOutputStreamOperator<PaymentEvent> payments = env
                .fromSource(rawSource(config), WatermarkStrategy.noWatermarks(), "payments-raw")
                .uid("payments-raw")
                .process(new ParsePaymentFunction())
                .name("parse-payment")
                .uid("parse-payment");

        payments.getSideOutput(ParsePaymentFunction.REJECTED)
                .sinkTo(KafkaSinks.<RejectedRecord>atLeastOnceJson(config.bootstrapServers(), config.dlqTopic()))
                .name("dlq-sink")
                .uid("dlq-sink");

        payments.print().name("stdout").uid("stdout");

        env.execute("payguard");
ПРОСТЫМИ СЛОВАМИ

Читаем аргументы командной строки.

Запрещаем Kryo для всего задания. Настройка передаётся при создании окружения. Позже здесь появятся и другие параметры.

Источник тот же, а вместо фильтра стоит process. Результат нужно сохранить в переменную типа SingleOutputStreamOperator, потому что метод getSideOutput есть именно у него.

Из той же переменной забираем поток по метке и направляем его в Kafka. Явный параметр <RejectedRecord> нужен компилятору, чтобы вывести тип обобщённого метода.

Основной поток пока печатаем в лог, правила появятся позже. У всех операторов есть uid, зачем это нужно, обсуждали в уроке 2.

execute собирает граф, здесь и сработает проверка типов, и отправляет задание на кластер.

Шаг 6. Тест без кластера

Harness (тестовый стенд) это обёртка из тестового jar Flink, которая запускает один оператор прямо в JVM теста. Он отдаёт в функцию записи и показывает все её выходы, в том числе побочные.

src/test/java/dev/study/payguard/pipeline/ParsePaymentFunctionTest.java
package dev.study.payguard.pipeline;

import static org.assertj.core.api.Assertions.assertThat;

import dev.study.payguard.model.PaymentEvent;
import dev.study.payguard.model.RejectedRecord;
import java.util.List;
import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness;
import org.apache.flink.streaming.util.ProcessFunctionTestHarnesses;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class ParsePaymentFunctionTest {

    private static final String VALID = """
            {"paymentId":"p1","cardId":"c1","merchantId":"m1","amountMinor":1500,
             "currency":"EUR","country":"DE","eventTimeMs":1000}""";

    private OneInputStreamOperatorTestHarness<String, PaymentEvent> harness;

    @BeforeEach
    void setUp() throws Exception {
        harness = ProcessFunctionTestHarnesses.forProcessFunction(new ParsePaymentFunction());
    }

    @Test
    void validJsonGoesToMainOutput() throws Exception {
        harness.processElement(VALID, 0L);

        List<PaymentEvent> payments = harness.extractOutputValues();
        assertThat(payments).containsExactly(new PaymentEvent("p1", "c1", "m1", 1500, "EUR", "DE", 1000));
        assertThat(harness.getSideOutput(ParsePaymentFunction.REJECTED)).isNull();
    }

    @Test
    void brokenJsonGoesToSideOutputWithReason() throws Exception {
        harness.processElement("{broken", 0L);

        assertThat(harness.extractOutputValues()).isEmpty();
        StreamRecord<RejectedRecord> rejected = harness.getSideOutput(ParsePaymentFunction.REJECTED).poll();
        assertThat(rejected.getValue().raw()).isEqualTo("{broken");
        assertThat(rejected.getValue().reason()).isEqualTo("bad-json");
    }
}
КОД
    @BeforeEach
    void setUp() throws Exception {
        harness = ProcessFunctionTestHarnesses.forProcessFunction(new ParsePaymentFunction());
    }

    @Test
    void validJsonGoesToMainOutput() throws Exception {
        harness.processElement(VALID, 0L);

        List<PaymentEvent> payments = harness.extractOutputValues();
        assertThat(payments).containsExactly(new PaymentEvent("p1", "c1", "m1", 1500, "EUR", "DE", 1000));
        assertThat(harness.getSideOutput(ParsePaymentFunction.REJECTED)).isNull();
    }

    @Test
    void brokenJsonGoesToSideOutputWithReason() throws Exception {
        harness.processElement("{broken", 0L);

        assertThat(harness.extractOutputValues()).isEmpty();
        StreamRecord<RejectedRecord> rejected = harness.getSideOutput(ParsePaymentFunction.REJECTED).poll();
        assertThat(rejected.getValue().raw()).isEqualTo("{broken");
        assertThat(rejected.getValue().reason()).isEqualTo("bad-json");
    }
ПРОСТЫМИ СЛОВАМИ

forProcessFunction создаёт и открывает стенд, поэтому open вызывать вручную не надо. Новый стенд перед каждым тестом исключает влияние тестов друг на друга.

Подаём корректный JSON. Второй аргумент processElement это время записи, здесь оно ни на что не влияет.

Сравниваем основной выход с ожидаемым объектом, record сам сравнивается по полям. Если в побочный выход ничего не писали, стенд возвращает для него null.

Подаём сломанный JSON. Основной выход пуст, а из очереди побочного выхода достаём запись и проверяем исходный текст и причину.

терминал
./gradlew test

С заглушкой валидатора пройдут шесть тестов, два из этого файла и четыре из RawRecordSourceTest урока 1. Отчёт лежит в build/reports/tests/test/index.html.

Шаг 7. Запусти и проверь DLQ

Собери jar и подними стенд, порядок прежний. Если стенд уже работает, docker compose up ничего не сломает. Новый jar появится в смонтированной папке сам.

терминал
./gradlew shadowJar
docker compose up -d --wait
docker compose exec jobmanager flink list

Если от прошлого урока осталось задание, останови его через flink cancel <JobID>. Теперь отправь новое и запусти генератор с одним процентом битых записей. Флаг --bad задаёт эту долю. Источник читает с конца топика, поэтому задание запускай первым.

терминал
scripts/submit.sh
./gradlew loadgen --args="--rate 200 --bad 0.01"
вывод submit.sh
Job has been submitted with JobID 013a070d18cd8a75bfd8bd29657cd664

Из второго терминала прочитай начало топика. --from-beginning велит читать с начала, --max-messages останавливает чтение после трёх записей, 2>/dev/null прячет служебный итог.

терминал
docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 --topic payments.dlq --from-beginning --max-messages 3 2>/dev/null
вывод
{"raw":"{\"paymentId\": broken","reason":"bad-json","rejectedAtMs":1794085151733}
{"raw":"{\"paymentId\": broken","reason":"bad-json","rejectedAtMs":1794085158791}
{"raw":"{\"paymentId\": broken","reason":"bad-json","rejectedAtMs":1794085160285}

Поле raw хранит исходный текст записи, кавычки внутри него экранированы, потому что вся строка лежит внутри JSON. Такую битую запись подмешивает генератор, вот его константа.

src/main/java/dev/study/payguard/loadgen/RawRecordSource.java (строки 11-11)
    static final String BAD_RECORD = "{\"paymentId\": broken";

Посмотрим на задание глазами REST. Сохрани его идентификатор в переменную и запроси описание.

терминал
JOB=$(curl -s localhost:8081/jobs | jq -r '.jobs[] | select(.status == "RUNNING") | .id')
curl -s localhost:8081/jobs/$JOB | jq -c '{name, state, vertices: [.vertices[] | {name, parallelism, status}]}'
вывод
{"name":"payguard","state":"RUNNING","vertices":[{"name":"Source: payments-raw -> parse-payment -> (dlq-sink: Writer -> dlq-sink: Committer, Sink: stdout)","parallelism":2,"status":"RUNNING"}]}

Вершина всего одна. Стрелки показывают цепочку операторов, а скобки обозначают развилку на два направления, это и есть основной и побочный выходы. Приёмник dlq-sink раскрылся в два оператора. Writer отправляет записи, а Committer нужен режиму exactly-once, в режиме AT_LEAST_ONCE его метод prepareCommit в KafkaWriter возвращает пустой список. Подробнее об этом поговорим в уроке 12.

Теперь метрики вершины по подзадачам. Параметр get перечисляет нужные счётчики, а agg просит сложить их по подзадачам.

терминал
V=$(curl -s localhost:8081/jobs/$JOB | jq -r '.vertices[0].id')
curl -s "localhost:8081/jobs/$JOB/vertices/$V/subtasks/metrics?get=numRecordsIn,numRecordsOut,busyTimeMsPerSecond,backPressuredTimeMsPerSecond&agg=min,max,sum" | jq -c .
вывод
[{"id":"numRecordsIn","min":8573.0,"max":9207.0,"sum":17780.0},{"id":"numRecordsOut","min":84.0,"max":92.0,"sum":176.0},{"id":"busyTimeMsPerSecond","min":5.0,"max":6.0,"sum":11.0},{"id":"backPressuredTimeMsPerSecond","min":0.0,"max":0.0,"sum":0.0}]

Это числа с нашего стенда, у тебя они будут другими. На вход пришло 17780 записей, и они примерно поровну разошлись по двум подзадачам. Подзадачи заняты 5-6 мс в секунду, а обратного давления нет, запас огромный.

На выход вершины записано 176 записей, это около 1 %. На первый взгляд странно, ведь в stdout ушли почти все платежи. Причина в определении этого счётчика.

В исходниках

Оператор приёмника передаёт задаче свой счётчик. Комментарий объясняет смысл, это записи, отправленные во внешнюю систему.

apache/flink@release-2.2.1 · SinkWriterOperator.java, строки 145-149
// Metric "numRecordsOut" & "numBytesOut" is defined as the total number of records/bytes
// written to the external system in FLIP-33, reuse them for task to account for traffic
// with external system
this.metrics.getIOMetricGroup().reuseOutputMetricsForTask();
this.metrics.getIOMetricGroup().reuseBytesOutputMetricsForTask();

Счётчик увеличивает код самого приёмника. Приёмник Kafka делает это на каждой отправке. В коде PrintSinkOutputWriter ни одного счётчика нет, поэтому stdout в сумму не попадает, и 176 здесь означает записи в DLQ.

apache/flink-connector-kafka@v5.0.0 · KafkaWriter.java, строки 170-178
public void write(@Nullable IN element, Context context) throws IOException {
    checkAsyncException();
    final ProducerRecord<byte[], byte[]> record =
            recordSerializer.serialize(element, kafkaSinkContext, context.timestamp());
    if (record != null) {
        currentProducer.send(record, deliveryCallback);
        numRecordsOutCounter.inc();
    }
}

Осталось убедиться, что битые записи не причинили заданию вреда и что чекпоинтов действительно пока нет.

терминал
curl -s localhost:8081/jobs/$JOB/exceptions | jq -c '.exceptionHistory.entries'
curl -s localhost:8081/jobs/$JOB/checkpoints | jq -c '{counts, recent: .history}'
вывод
[]
{"counts":{"restored":0,"total":0,"in_progress":0,"completed":0,"failed":0},"recent":[]}

Список исключений пуст, хотя через функцию прошли сотни битых записей. Нули во второй строке напоминают об ограничении из теории про гарантию AT_LEAST_ONCE.

Что ещё попадёт в DLQ

Мы прогнали функцию через тестовый стенд с разными входами. Все эти строки дают основной выход пустым и причину bad-json, задание продолжает работу.

Что пришлоПочему отказ
Обрезанный JSONОшибка синтаксиса
Пустая строка или только пробелыНет данных для разбора
[1,2] или 123Корректный JSON другой формы
"amountMinor":"abc"Строку нельзя превратить в long
Правильный платёж с лишним полемВ Jackson по умолчанию включён FAIL_ON_UNKNOWN_PROPERTIES

Последняя строка важна для продакшена. Добавит партнёр новое поле, и задание продолжит работать, но все его платежи окажутся в DLQ. Поэтому на рост этого топика нужна сигнализация. Её мы настроим в уроке 17, а решение про лишние поля принимай осознанно.

Задание. Напиши PaymentValidator

Сейчас вместо проверок стоит заглушка, и платёж с отрицательной суммой спокойно идёт дальше. Замени её настоящим валидатором и напиши для него тест.

Класс dev.study.payguard.pipeline.PaymentValidator доступен только внутри пакета, экземпляры не создаются. Метод static Optional<String> rejectionReason(PaymentEvent payment) возвращает код причины или пустой Optional. Проверки идут в порядке из таблицы, срабатывает первая подходящая.

УсловиеКод причины
Пустой или отсутствующий paymentId, cardId или merchantIdmissing-id
Сумма не больше нуляnon-positive-amount
Валюта отсутствует или это не три заглавные латинские буквыbad-currency
Время события не больше нуляmissing-event-time

Тест PaymentValidatorTest лежит рядом с тестом функции. В нём должны быть проверка корректного платежа и по одному отказу на каждую причину.

Готово, если выполнены все условия.

  1. ./gradlew test проходит, и заглушки больше нет.
  2. Новый тест обходится без Flink и без тестового стенда, это обычный JUnit.
  3. После ./gradlew shadowJar и нового запуска задания payments.dlq продолжает получать только bad-json, потому что генератор создаёт корректные платежи.

Подсказка первая. Подумай, что сделает Jackson с record, если в JSON нет поля amountMinor типа long. Ответ объясняет формулировку некоторых условий.

Подсказка вторая. Подумай, почему валидатор удобнее сделать обычной статической функцией от PaymentEvent, и что от этого выигрывает тест.

Ответ

Глоссарий урока

DataStream
Основной API Flink для потоковых программ на Java. Объект описывает поток записей одного типа и план его обработки.
Преобразование (transformation)
Шаг в плане задания, например map или process. Из таких шагов Flink позже строит граф.
ProcessFunction
Самая гибкая пользовательская функция DataStream API. Получает запись и контекст, через контекст умеет писать в побочные выходы.
Побочный выход (side output)
Дополнительный выход оператора со своим типом. Запись попадает туда по метке OutputTag.
OutputTag
Типизированная именованная метка побочного выхода. Создаётся как анонимный подкласс, чтобы Flink узнал параметр типа.
DLQ (dead letter queue)
Топик или очередь для записей, которые не удалось обработать. Хранит их для разбора и повторной отправки.
Ядовитая запись (poison pill)
Запись, на которой обработка падает при каждой попытке и из-за которой задание после перезапуска снова возвращается к той же ошибке.
Стратегия перезапуска (restart strategy)
Правило, по которому Flink перезапускает упавшее задание. Без чекпоинтов по умолчанию равна none, с чекпоинтами exponential-delay.
Чекпоинт (checkpoint)
Периодический согласованный снимок задания, включая смещения Kafka. После сбоя задание продолжает с него. Подробно в уроке 11.
Savepoint
Снимок задания, который запускают вручную для обновлений и переноса. Подробно в уроке 13.
Смещение (offset)
Порядковый номер записи в партиции Kafka. По нему потребитель знает, откуда читать дальше.
TypeInformation
Описание типа данных во Flink. Определяется на клиенте при описании плана и создаёт сериализатор.
Сериализатор (TypeSerializer)
Объект, который переводит записи в байты и обратно и умеет копировать их.
POJO
Обычный Java-класс, который Flink разбирает по полям. Требования это публичный класс, конструктор без аргументов и доступные поля. Record также подходит, если он публичный.
Record
Вид неизменяемого класса в Java. Во Flink считается POJO и создаётся через канонический конструктор.
Generic-тип
Тип, который Flink не смог разобрать по полям. Описывается через GenericTypeInfo и сериализуется через Kryo.
Kryo
Универсальная библиотека сериализации для Java, запасной путь Flink для нераспознанных типов.
Encoder
Механизм Spark, который описывает перевод объектов Dataset во внутренний формат. Ближайший аналог TypeInformation.
Jackson
Популярная Java-библиотека для JSON. Главный класс ObjectMapper превращает текст в объекты и обратно.
Tombstone
Сообщение Kafka с ключом и пустым значением. В топиках с компактированием означает удаление ключа.
Harness (тестовый стенд)
Обёртка из тестовых артефактов Flink. Запускает один оператор в JVM теста и даёт доступ к его выходам.
Цепочка операторов (operator chain)
Несколько операторов, работающих в одном потоке и в одной подзадаче. Записи передаются между ними вызовом метода.
At least once
Гарантия доставки, при которой запись не теряется, но при сбое может прийти повторно. Для приёмника Kafka требует чекпоинтов.
Exactly once
Гарантия, при которой каждая запись влияет на результат один раз. Разбирается в уроке 12.
Обратное давление (backpressure)
Ситуация, когда следующий оператор не успевает и сдерживает предыдущий. Подробно в уроке 4.

Урок 4 · Основы

Партиционирование, цепочки и сетевой стек

После урока ты сможешь заранее посчитать, на какую подзадачу попадёт ключ, найти перекос по метрикам и объяснить, как медленный оператор тормозит всё задание через сеть.

Треть платежей у одного продавца

PayGuard должен держать по каждому продавцу накопительный итог. Для этого все платежи одного продавца обязаны приходить в одну и ту же параллельную копию оператора, иначе итог разъедется по нескольким местам. В Spark ту же задачу решает shuffle по ключу перед агрегацией.

В генераторе есть подвох, и он взят из жизни. У любого маркетплейса или платёжного агрегатора найдётся крупный продавец, через которого идёт заметная доля оборота. У нас это m-0000, на него приходится 30 % платежей.

src/main/java/dev/study/payguard/loadgen/PaymentFactory.java (строки 12-14)
    private static final int MERCHANT_COUNT = 50;
    private static final String HOT_MERCHANT = "m-0000";
    private static final double HOT_MERCHANT_SHARE = 0.3;

Если все платежи m-0000 обязаны попасть в одну подзадачу, эта подзадача получит минимум треть нагрузки, сколько бы их ни было. В уроке разберём, как Flink выбирает подзадачу для ключа, как записи физически едут по сети и почему одна перегруженная подзадача замедляет соседей.

Гардероб в театре

Представь гардероб на 128 крючков и несколько гардеробщиков. Номер крючка для пальто выбирают по фамилии зрителя по жёсткому правилу, поэтому одно и то же пальто всегда висит на одном крючке. Каждый гардеробщик отвечает за непрерывный ряд крючков. Двое делят ряд пополам, четверо берут по 32 крючка.

Если в зал пришёл ещё один гардеробщик, пальто никто не перевешивает. Меняются только границы рядов, и часть крючков переходит к новому человеку целиком, вместе с пальто. Число крючков при этом поменять нельзя, они прибиты к стене.

Во Flink крючки называются группами ключей (key group), гардеробщики это подзадачи оператора после keyBy, а пальто это состояние ключа. Если пришёл автобус туристов с одной фамилией, всё ляжет на один крючок, и разгребать очередь будет один гардеробщик. Это перекос ключей.

keyBy и группы ключей

Метод keyBy объявляет, что дальше поток разбит по ключу. Сам он ничего не считает, он ставит на ребро графа стратегию распределения (partitioner) KeyGroupStreamPartitioner. Для каждой записи она делает два шага.

  1. Номер группы ключей. keyGroup = murmurHash(key.hashCode()) % maxParallelism. Здесь key.hashCode() это обычный Java-хеш ключа, а murmurHash перемешивает его биты, чтобы похожие строки не легли в соседние группы. maxParallelism задаёт число групп, по умолчанию 128.
  2. Номер подзадачи. subtask = keyGroup * parallelism / maxParallelism в целых числах. Группы нарезаются непрерывными диапазонами, при параллелизме 4 подзадача 0 получает группы 0-31, подзадача 1 группы 32-63 и так далее.

Посчитаем вручную для нашего горячего продавца. Числа проверены вызовом KeyGroupRangeAssignment из flink-runtime 2.2.1 и отдельным расчётом на Python.

Шагm-0000m-0007
hashCode() строки-1131353760-1131353753
murmurHash от него10494407861500536744
Остаток от деления на 1281840
Подзадача при параллелизме 218 × 2 / 128 = 040 × 2 / 128 = 0
Подзадача при параллелизме 418 × 4 / 128 = 040 × 4 / 128 = 1
Подзадача при параллелизме 818 × 8 / 128 = 140 × 8 / 128 = 2

Хеши у двух строк почти одинаковые, отличаются на 7. После murmurHash они расходятся далеко, это и есть смысл второго хеширования. Обрати внимание, что номер группы от параллелизма не зависит. При смене параллелизма меняется только то, какая подзадача владеет группой 18.

В исходниках

Обе формулы живут в классе KeyGroupRangeAssignment. Сначала ключ превращается в номер группы, на вход идёт обычный hashCode().

apache/flink@release-2.2.1 · KeyGroupRangeAssignment.java, строки 63-77
public static int assignToKeyGroup(Object key, int maxParallelism) {
    Preconditions.checkNotNull(key, "Assigned key must not be null!");
    return computeKeyGroupForKeyHash(key.hashCode(), maxParallelism);
}

/**
 * Assigns the given key to a key-group index.
 *
 * @param keyHash the hash of the key to assign
 * @param maxParallelism the maximum supported parallelism, aka the number of key-groups.
 * @return the key-group to which the given key is assigned
 */
public static int computeKeyGroupForKeyHash(int keyHash, int maxParallelism) {
    return MathUtils.murmurHash(keyHash) % maxParallelism;
}

Потом группа превращается в номер подзадачи одной целочисленной формулой.

apache/flink@release-2.2.1 · KeyGroupRangeAssignment.java, строки 124-127
public static int computeOperatorIndexForKeyGroup(
        int maxParallelism, int parallelism, int keyGroupId) {
    return keyGroupId * parallelism / maxParallelism;
}

Сама стратегия на ребре берёт ключ из записи и вызывает эту функцию. Число каналов numberOfChannels равно параллелизму получателя.

apache/flink@release-2.2.1 · KeyGroupStreamPartitioner.java, строки 54-65
@Override
public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
    K key;
    try {
        key = keySelector.getKey(record.getInstance().getValue());
    } catch (Exception e) {
        throw new RuntimeException(
                "Could not extract key from " + record.getInstance().getValue(), e);
    }
    return KeyGroupRangeAssignment.assignKeyToParallelOperator(
            key, maxParallelism, numberOfChannels);
}

Отсюда следует требование к ключу. Его hashCode() должен давать одно и то же число в разных JVM. У String, Long и record из строк так и есть. Хеш по умолчанию из Object зависит от конкретного объекта, и одинаковые ключи на разных машинах уйдут в разные подзадачи. Документация прямо запрещает ключи без переопределённого hashCode() и массивы.

Максимальный параллелизм выбирают один раз

Группа ключей это единица, которой Flink переносит состояние при смене параллелизма. Отсюда два последствия. Параллелизм оператора с состоянием не может превысить maxParallelism, потому что подзадаче без единой группы нечего делать. И сменить maxParallelism после первого запуска без потери состояния нельзя, ведь от него зависит номер группы каждого ключа.

В исходниках

Если maxParallelism не задан, планировщик вычисляет его из параллелизма вершины.

apache/flink@release-2.2.1 · SchedulerBase.java, строки 322-329
public static int getDefaultMaxParallelism(JobVertex vertex) {
    return getDefaultMaxParallelism(vertex.getParallelism());
}

public static int getDefaultMaxParallelism(int parallelism) {
    return KeyGroupRangeAssignment.computeDefaultMaxParallelism(
            normalizeParallelism(parallelism));
}

Формула берёт полтора параллелизма, округляет вверх до степени двойки и держит результат в пределах от 128 до 32768.

apache/flink@release-2.2.1 · KeyGroupRangeAssignment.java, строки 137-147
public static int computeDefaultMaxParallelism(int operatorParallelism) {

    checkParallelismPreconditions(operatorParallelism);

    return Math.min(
            Math.max(
                    MathUtils.roundUpToPowerOfTwo(
                            operatorParallelism + (operatorParallelism / 2)),
                    DEFAULT_LOWER_BOUND_MAX_PARALLELISM),
            UPPER_BOUND_MAX_PARALLELISM);
}

Мы вызвали эту функцию для разных значений. До параллелизма 85 включительно получается 128, при 86 уже 256, при 200 получается 512. На странице Production Readiness написано, что 128 выдаётся для любого параллелизма до 128. Код с этим расходится, верь коду.

⚠
Задай maxParallelism до первого запуска в прод

Задание стартовало с параллелизмом 100 и без явной настройки получило 256 групп. Через год нагрузка выросла, и нужен параллелизм 300. Без потери состояния это невозможно. Выбери число с запасом на рост, например 1024 или 4096, и задай его через env.setMaxParallelism или ключ pipeline.max-parallelism. Слишком большое число тоже вредно, часть структур бэкенда состояния растёт вместе с числом групп.

В Spark Structured Streaming похожее ограничение устроено жёстче. Состояние там разбито хешем ключа на spark.sql.shuffle.partitions частей, и документация запрещает менять эту настройку после первого запуска запроса. Во Flink неизменно число групп, а параллелизм можно менять в его пределах.

Другие способы раздать записи

Ключ нужен не всегда. Если оператор работает с каждой записью отдельно, записи можно раздавать так, как удобнее сети. У каждого способа есть имя, которое Web UI пишет на ребре между вершинами.

Вызов в DataStreamИмя на ребреКому уходит записьКогда нужен
keyBy(...)HASHПодзадаче, которой принадлежит группа ключаСостояние и агрегаты по ключу
rebalance()REBALANCEПо кругу всем подзадачам получателяВыровнять перекос после источника
rescale()RESCALEПо кругу, но только своему подмножеству получателейРазветвить поток без связи каждого с каждым
forward()FORWARDПодзадаче с тем же номеромЦепочки, параллелизм обязан совпадать
broadcast()BROADCASTВсем подзадачам сразуМаленький справочник или правила, урок 8
shuffle(), global()SHUFFLE, GLOBALСлучайной подзадаче или всегда первойРедко, global сводит всё в одну подзадачу

Если ты ничего не указал, Flink выбирает сам. При одинаковом параллелизме соседей ставится FORWARD, при разном REBALANCE. Если явно попросить forward() между операторами с разным параллелизмом, задание упадёт ещё при построении графа.

В исходниках

Правило выбора по умолчанию и проверка для FORWARD находятся в одном методе StreamGraph.

apache/flink@release-2.2.1 · StreamGraph.java, строки 896-914
// If no partitioner was specified and the parallelism of upstream and downstream
// operator matches use forward partitioning, use rebalance otherwise.
if (partitioner == null
        && upstreamNode.getParallelism() == downstreamNode.getParallelism()) {
    partitioner =
            dynamic ? new ForwardForUnspecifiedPartitioner<>() : new ForwardPartitioner<>();
} else if (partitioner == null) {
    partitioner = new RebalancePartitioner<Object>();
}

if (partitioner instanceof ForwardPartitioner) {
    if (upstreamNode.getParallelism() != downstreamNode.getParallelism()) {
        if (partitioner instanceof ForwardForConsecutiveHashPartitioner) {
            partitioner =
                    ((ForwardForConsecutiveHashPartitioner<?>) partitioner)
                            .getHashPartitioner();
        } else {
            throw new UnsupportedOperationException(
                    "Forward partitioning does not allow "

REBALANCE начинает со случайного канала, чтобы разные отправители не били в одного получателя одновременно, и дальше идёт по кругу.

apache/flink@release-2.2.1 · RebalancePartitioner.java, строки 38-49
@Override
public void setup(int numberOfChannels) {
    super.setup(numberOfChannels);

    nextChannelToSendTo = ThreadLocalRandom.current().nextInt(numberOfChannels);
}

@Override
public int selectChannel(SerializationDelegate<StreamRecord<T>> record) {
    nextChannelToSendTo = (nextChannelToSendTo + 1) % numberOfChannels;
    return nextChannelToSendTo;
}

RESCALE устроен так же внутри одного отправителя, а подмножество получателей определяется при построении графа исполнения. Если получателей больше, отправитель с номером partitionNum кормит непрерывный кусок из numConsumers подзадач.

apache/flink@release-2.2.1 · VertexInputInfoComputationUtils.java, строки 133-143
} else {
    for (int partitionNum = 0; partitionNum < sourceCount; partitionNum++) {

        int start = (partitionNum * targetCount + sourceCount - 1) / sourceCount;
        int end = ((partitionNum + 1) * targetCount + sourceCount - 1) / sourceCount;
        int numConsumers = end - start;

        IndexRange partitionRange = new IndexRange(partitionNum, partitionNum);
        // Variable used in lambda expression should be final or effectively final
        final int finalPartitionNum = partitionNum;
        for (int i = start; i < end; i++) {

Имя на ребре в Web UI это результат toString() стратегии, поэтому там видны именно HASH, REBALANCE и FORWARD.

apache/flink@release-2.2.1 · StreamingJobGraphGenerator.java, строки 1612-1613
// set strategy name so that web interface can show it.
jobEdge.setShipStrategyName(partitioner.toString());

Посмотри, как выглядит раскладка на практике. Модель использует те же формулы, что Flink, поэтому номера подзадач для ключей совпадают с настоящими. Включи и выключи горячего продавца, поменяй параллелизм получателя с 2 на 8 и сравни HASH с REBALANCE.

Куда уходят платежи

Нажми кнопку, и платежи разойдутся по подзадачам.

При HASH и четырёх получателях одна подзадача получает больше других даже без горячего ключа. 50 ключей это мало, и на четыре подзадачи они легли как 8, 14, 11 и 17. Горячий продавец добавляет к этому треть всего потока в подзадачу 0. REBALANCE и RESCALE раскладывают ровно, но ключ при этом теряется, и считать по нему итог уже нельзя.

Где рвётся цепочка

Из урока 2 ты помнишь, что соседние операторы склеиваются в цепочку и работают в одном потоке. Склейка возможна только на ребре FORWARD. Любой из способов выше, кроме forward, разрывает цепочку, потому что запись может уйти в другую подзадачу, а значит в другой поток или на другую машину. Ещё цепочку рвут разный параллелизм, разные группы слотов, вызовы startNewChain() и disableChaining().

В PayGuard после этого урока две вершины. Первая заканчивается на to-merchant-total, вторая начинается с merchant-running-total, между ними ребро HASH.

Вершина 1, параллелизм 2 Вершина 2, параллелизм 2 подзадача 0подзадача 1 payments-raw → parse-payment → to-merchant-total (и dlq-sink) payments-raw → parse-payment → to-merchant-total (и dlq-sink) подзадача 0подзадача 1 merchant-running-total → stdout merchant-running-total → stdout HASH

В исходниках

Внутри цепочки запись передаётся следующему оператору обычным вызовом метода. Сериализации, буферов и сети здесь нет, счётчики увеличиваются и сразу вызывается обработчик.

apache/flink@release-2.2.1 · ChainingOutput.java, строки 101-114
protected <X> void pushToOperator(StreamRecord<X> record) {
    try {
        // we know that the given outputTag matches our OutputTag so the record
        // must be of the type that our operator expects.
        @SuppressWarnings("unchecked")
        StreamRecord<T> castRecord = (StreamRecord<T>) record;

        numRecordsOut.inc();
        numRecordsIn.inc();
        recordProcessor.accept(castRecord);
    } catch (Exception e) {
        throw new ExceptionInChainedOperatorException(e);
    }
}

На разрыве цепочки запись проходит весь путь, о котором ниже. Её сериализуют в байты, копят в сетевом буфере, передают и разбирают на другой стороне. Поэтому лишний rebalance() между простыми операторами стоит заметно дороже самого оператора.

Сетевой стек

Когда цепочка разорвана, у отправителя появляется результирующая партиция (ResultPartition). Она разбита на подчасти (subpartition), по одной на каждую подзадачу получателя. На стороне получателя её встречает входной шлюз (InputGate) с каналами (InputChannel), по одному на каждого отправителя. Если отправитель работает в том же TaskManager, канал локальный и читает подчасть напрямую. Если в другом, канал удалённый, и данные идут по TCP через библиотеку Netty.

Записи едут сетевыми буферами по 32 КиБ. Это размер по умолчанию ключа taskmanager.memory.segment-size. Буфер уходит, когда заполнится или когда истечёт execution.buffer-timeout.interval, это ты видел в уроке 1.

Главный вопрос в том, что делать, если получатель не успевает. Flink отвечает управлением потоком на кредитах (credit-based flow control). Отправитель шлёт буфер только тогда, когда получатель заранее сообщил, что ему есть куда этот буфер положить. Пройди по шагам.

O
Оператор отправителя
P
ResultPartition
N
Netty
C
InputChannel получателя
Нажми «Следующий шаг»

В исходниках

Начнём с получателя. Когда в канале освобождается буфер, растёт счётчик невыданного кредита. Сообщение отправителю уходит, если до этого счётчик был нулём, иначе сообщение уже в пути и заберёт свежий кредит с собой.

apache/flink@release-2.2.1 · RemoteInputChannel.java, строки 427-432
@Override
public void notifyBufferAvailable(int numAvailableBuffers) throws IOException {
    if (numAvailableBuffers > 0 && unannouncedCredit.getAndAdd(numAvailableBuffers) == 0) {
        notifyCreditAvailable();
    }
}

Само сообщение AddCredit забирает и обнуляет накопленный кредит в момент отправки.

apache/flink@release-2.2.1 · NettyPartitionRequestClient.java, строки 297-303
@Override
Object buildMessage() {
    int credits = inputChannel.getAndResetUnannouncedCredit();
    return credits > 0
            ? new NettyMessage.AddCredit(credits, inputChannel.getInputChannelId())
            : null;
}

Каждый пришедший буфер несёт backlog отправителя, и получатель сразу просит плавающие буферы под всю очередь плюс начальный кредит.

apache/flink@release-2.2.1 · RemoteInputChannel.java, строки 558-560
public void onSenderBacklog(int backlog) throws IOException {
    notifyBufferAvailable(bufferManager.requestFloatingBuffers(backlog + initialCredit));
}

На стороне отправителя каждый буфер с данными списывает один кредит. Отправить буфер без кредита нельзя, это считается ошибкой. События, например барьер чекпоинта, кредита не требуют.

apache/flink@release-2.2.1 · CreditBasedSequenceNumberingViewReader.java, строки 255-268
public BufferAndAvailability getNextBuffer() throws IOException {
    BufferAndBacklog next = subpartitionView.getNextBuffer();
    if (next != null) {
        if (next.buffer().isBuffer() && --numCreditsAvailable < 0) {
            throw new IllegalStateException("no credit available");
        }

        final Buffer.DataType nextDataType = getNextDataType(next);
        return new BufferAndAvailability(
                next.buffer(), nextDataType, next.buffersInBacklog(), next.getSequenceNumber());
    } else {
        return null;
    }
}

Сколько буферов у канала. В Flink 2.2.1 числа зашиты в код. 2 эксклюзивных буфера на канал, 8 плавающих на шлюз и не больше 10 буферов на одну подчасть у отправителя. Ключей buffers-per-channel и floating-buffers-per-gate в классах настроек этой версии нет, хотя страница о сетевой памяти их ещё упоминает.

apache/flink@release-2.2.1 · NettyShuffleEnvironmentConfiguration.java, строки 325-332
int buffersPerChannel = 2;
int extraBuffersPerGate = 8;

Optional<Integer> maxRequiredBuffersPerGate =
        configuration.getOptional(
                NettyShuffleEnvironmentOptions.NETWORK_READ_MAX_REQUIRED_BUFFERS_PER_GATE);

int maxBuffersPerChannel = 10;

На стенде курса один TaskManager, поэтому все каналы там локальные. Кредиты в них не участвуют, получатель читает подчасть напрямую, и сдерживание работает через пул буферов отправителя, о котором следующий раздел.

Откуда берётся backpressure

Обратное давление (backpressure) это состояние, при котором подзадача хочет отправить данные дальше и не может, потому что у неё нет свободного выходного буфера. Буферы не возвращаются, потому что получатель не выдаёт кредит. Кредит не выдаётся, потому что получатель не успевает разбирать вход. Так задержка одного медленного оператора поднимается вверх по графу до самого источника, и источник просто перестаёт читать Kafka быстрее, чем задание успевает обработать. Ни данные, ни память при этом не теряются, растёт только лаг.

У этого механизма есть следствие, которое касается перекоса. Пул буферов отправителя становится недоступным, как только хотя бы одна его подчасть набрала 10 буферов. Если подзадача с горячим ключом не успевает, её подчасть у каждого отправителя заполняется, и отправители встают целиком. Остальные получатели в это время простаивают, хотя работы для них хватает.

В исходниках

Пул отправителя считает буферы по подчастям. Подчасть, дошедшая до предела, делает весь пул недоступным.

apache/flink@release-2.2.1 · LocalBufferPool.java, строки 410-414
if (targetChannel != UNKNOWN_CHANNEL) {
    if (++subpartitionBuffersCount[targetChannel] == maxBuffersPerChannel) {
        unavailableSubpartitionsCount++;
    }
}
apache/flink@release-2.2.1 · LocalBufferPool.java, строки 513-518
@GuardedBy("availableMemorySegments")
private boolean shouldBeAvailable() {
    assert Thread.holdsLock(availableMemorySegments);

    return !availableMemorySegments.isEmpty() && unavailableSubpartitionsCount == 0;
}

Подзадача замечает это в главном цикле. Если выход недоступен, она приостанавливает обработку входа и начинает отсчёт времени backpressure. Если недоступен вход, считается время простоя.

apache/flink@release-2.2.1 · StreamTask.java, строки 674-682
TaskIOMetricGroup ioMetrics = getEnvironment().getMetricGroup().getIOMetricGroup();
PeriodTimer timer;
CompletableFuture<?> resumeFuture;
if (!recordWriter.isAvailable()) {
    timer = new GaugePeriodTimer(ioMetrics.getSoftBackPressuredTimePerSecond());
    resumeFuture = recordWriter.getAvailableFuture();
} else if (!inputProcessor.isAvailable()) {
    timer = new GaugePeriodTimer(ioMetrics.getIdleTimeMsPerSecond());
    resumeFuture = inputProcessor.getAvailableFuture();

Метрика backPressuredTimeMsPerSecond, которую показывает Web UI, складывается из этого мягкого ожидания и жёсткого, когда буфер пришлось ждать посреди записи.

apache/flink@release-2.2.1 · TaskIOMetricGroup.java, строки 240-243
public long getBackPressuredTimeMsPerSecond() {
    return getSoftBackPressuredTimePerSecond().getValue()
            + getHardBackPressuredTimePerSecond().getValue();
}

Документация советует читать три метрики вместе. busyTimeMsPerSecond, idleTimeMsPerSecond и backPressuredTimeMsPerSecond в сумме дают около 1000 мс. Виноват обычно первый оператор по ходу потока, у которого busy высокий, а backpressure нулевой.

i
Больше параллелизма горячему ключу не поможет

Все записи одного ключа обрабатывает одна подзадача. При параллелизме 8 она всё равно получит 30 % потока, только остальные станут ещё свободнее. Лечат перекос двухфазной агрегацией с солью, когда ключ временно дробят на части, считают частичные итоги и потом сливают. Это мы сделаем в уроке 18.

Сравнение с shuffle в Spark

В Spark shuffle разделяет задание на стадии. Задачи первой стадии сортируют результат по целевым партициям и пишут его в файлы на локальном диске, задачи следующей стадии потом читают нужные блоки. Файлы переживают падение задачи-получателя, и Spark может перезапустить только её.

Потоковый Flink передаёт данные конвейером. Запись уходит получателю сразу, как только заполнился буфер или истёк таймаут, на диск по дороге ничего не пишется. Задержка получается маленькой, зато обе стороны обязаны работать одновременно, а при падении восстановление идёт от чекпоинта. Медленного получателя Flink сдерживает кредитами и backpressure, в Spark ту же роль играет то, что следующая стадия просто стартует позже.

Проверь себя

Агрегат по merchantId на параллелизме 4. Одна подзадача занята 1000 мс в секунду, три других около 300. Команда предлагает поднять параллелизм до 8. Что произойдёт?

Web UI красит источник чёрным, backPressuredTimeMsPerSecond 950. Ниже два оператора, у второго busy 990 и backpressure 0. Что чинить первым?

Задание с состоянием год работает с параллелизмом 100, maxParallelism никто не задавал. Теперь нужно 300. Что будет при перезапуске из savepoint с -p 300?

Что будет в конце

Задание считает накопительный итог по каждому продавцу через keyBy и reduce. Ты увидишь в REST, что цепочка разорвалась на две вершины, и найдёшь в метриках перекос, который заранее предскажешь по формуле. Файлы задания KeyDistribution и его тест пока не создавай, они понадобятся на следующей вкладке.

Шаг 1. Модель итога

Создай src/main/java/dev/study/payguard/model/MerchantTotal.java. Это record с тремя полями и двумя методами. of превращает один платёж в итог из одной записи, plus складывает два итога одного продавца.

src/main/java/dev/study/payguard/model/MerchantTotal.java
package dev.study.payguard.model;

/** Накопительный итог по продавцу с момента запуска задания. */
public record MerchantTotal(String merchantId, long paymentCount, long amountMinor) {

    public static MerchantTotal of(PaymentEvent payment) {
        return new MerchantTotal(payment.merchantId(), 1, payment.amountMinor());
    }

    public MerchantTotal plus(MerchantTotal other) {
        return new MerchantTotal(merchantId, paymentCount + other.paymentCount, amountMinor + other.amountMinor);
    }
}

Итог неизменяемый, plus возвращает новый объект. Так безопаснее, потому что reduce хранит предыдущий итог в состоянии, и случайная правка старого объекта испортила бы его. Record из простых полей Flink сериализует своим сериализатором без Kryo, это ты проверял в уроке 3.

Шаг 2. keyBy и reduce в задании

В PayGuardJob печать сырых платежей заменяется на подсчёт итогов.

что изменилось в src/main/java/dev/study/payguard/job/PayGuardJob.java
@@ -1,5 +1,6 @@ package dev.study.payguard.job;
 
+import dev.study.payguard.model.MerchantTotal; import dev.study.payguard.model.PaymentEvent;
 import dev.study.payguard.model.RejectedRecord;
 import dev.study.payguard.pipeline.ParsePaymentFunction;
@@ -34,7 +35,16 @@                 .name("dlq-sink")
                 .uid("dlq-sink");
 
-        payments.print().name("stdout").uid("stdout");+        payments.map(MerchantTotal::of)+                .name("to-merchant-total")+                .uid("to-merchant-total")+                .keyBy(MerchantTotal::merchantId)+                .reduce(MerchantTotal::plus)+                .name("merchant-running-total")+                .uid("merchant-running-total")+                .print()+                .name("stdout")+                .uid("stdout"); 
         env.execute("payguard");
     }
КОД
        payments.map(MerchantTotal::of)
                .name("to-merchant-total")
                .uid("to-merchant-total")
                .keyBy(MerchantTotal::merchantId)
                .reduce(MerchantTotal::plus)
                .name("merchant-running-total")
                .uid("merchant-running-total")
                .print()
                .name("stdout")
                .uid("stdout");
ПРОСТЫМИ СЛОВАМИ

Каждый платёж превращаем в итог из одной записи. Это обычный map, он склеится в цепочку с разбором, потому что параллелизм тот же и ребро FORWARD.

Имя и uid даём сразу. Имя будет видно в Web UI, а uid понадобится состоянию в уроке 13.

keyBy ставит на ребро стратегию HASH по merchantId. Здесь цепочка рвётся, дальше записи пойдут по сети.

reduce складывает новый итог с предыдущим итогом того же продавца. Предыдущий хранится в состоянии с ключом.

У reduce тоже имя и uid. Это оператор с состоянием, для него uid обязателен.

Каждый обновлённый итог печатаем. print склеится с reduce во вторую вершину.

В исходниках

Посмотрим, что делает оператор за reduce. Предыдущее значение лежит в ValueState, это состояние с ключом, и оно своё у каждого продавца. Результат уходит дальше на каждой записи, поэтому в выводе будет поток постепенно растущих итогов.

apache/flink@release-2.2.1 · StreamGroupedReduceOperator.java, строки 56-69
@Override
public void processElement(StreamRecord<IN> element) throws Exception {
    IN value = element.getValue();
    IN currentValue = values.value();

    if (currentValue != null) {
        IN reduced = userFunction.reduce(currentValue, value);
        values.update(reduced);
        output.collect(element.replace(reduced));
    } else {
        values.update(value);
        output.collect(element.replace(value));
    }
}

Состояние разбито по группам ключей так же, как поток. Поэтому подзадача, которая получает платежи m-0000, держит и его итог, и сетевых обращений к чужому состоянию нет.

Шаг 3. Собери и запусти

терминал
./gradlew test shadowJar
docker compose up -d --wait
⚠
Сначала jar, потом стенд

Если поднять Compose до первой сборки, Docker смонтирует пустую папку build/libs, и jar в контейнере не появится. Мы на это нарвались при подготовке курса. Если стенд уже работает, новый jar появится в смонтированной папке сам.

Останови задание прошлого урока через flink cancel, если оно ещё работает. Затем отправь новое и запусти генератор. Флаг --rate задаёт число платежей в секунду, --bad долю битых записей.

терминал
scripts/submit.sh
./gradlew loadgen --args="--rate 200 --bad 0.01"
вывод submit.sh
Job has been submitted with JobID 472b3db906017a267aa9e67e17c1282c

Шаг 4. Две вершины вместо одной

Из второго терминала спроси у REST, из каких вершин состоит задание.

терминал
JOB=$(curl -s localhost:8081/jobs | jq -r '.jobs[] | select(.status == "RUNNING") | .id')
curl -s localhost:8081/jobs/$JOB | jq -c '{name, state, vertices: [.vertices[] | {name, parallelism, status}]}'
вывод
{"name":"payguard","state":"RUNNING","vertices":[{"name":"Source: payments-raw -> parse-payment -> (dlq-sink: Writer -> dlq-sink: Committer, to-merchant-total)","parallelism":2,"status":"RUNNING"},{"name":"merchant-running-total -> Sink: stdout","parallelism":2,"status":"RUNNING"}]}

В уроке 3 вершина была одна. Теперь развилка первой вершины кончается на to-merchant-total, а merchant-running-total открывает вторую. Разрыв ровно там, где стоит keyBy. print остался в цепочке с reduce, потому что между ними FORWARD.

Шаг 5. Метрики по подзадачам

Пройди по обеим вершинам и запроси метрики, сведённые по подзадачам. Параметр agg=min,max,sum возвращает минимум, максимум и сумму, а разница между минимумом и максимумом и есть перекос.

терминал
for V in $(curl -s localhost:8081/jobs/$JOB | jq -r '.vertices[].id'); do
  curl -s "localhost:8081/jobs/$JOB/vertices/$V/subtasks/metrics?get=numRecordsIn,numRecordsOut,busyTimeMsPerSecond,backPressuredTimeMsPerSecond&agg=min,max,sum" | jq -c .
done
вывод
[{"id":"numRecordsIn","min":7600.0,"max":10180.0,"sum":17780.0},{"id":"numRecordsOut","min":7600.0,"max":10180.0,"sum":17780.0},{"id":"busyTimeMsPerSecond","min":2.0,"max":3.0,"sum":5.0},{"id":"backPressuredTimeMsPerSecond","min":0.0,"max":0.0,"sum":0.0}]
[{"id":"numRecordsIn","min":7037.0,"max":10560.0,"sum":17597.0},{"id":"numRecordsOut","min":0.0,"max":0.0,"sum":0.0},{"id":"busyTimeMsPerSecond","min":5.0,"max":6.0,"sum":11.0},{"id":"backPressuredTimeMsPerSecond","min":0.0,"max":0.0,"sum":0.0}]

Первая строка относится к вершине с источником. Неравномерность 7600 против 10180 здесь появилась ещё в Kafka. Каждая подзадача источника читает две партиции из четырёх, и партиции заполнены неодинаково. До keyBy ключ на раскладку не влияет. Выход вершины теперь ненулевой, потому что записи уходят в сеть ко второй вершине. Это видно по счётчику numRecordsOut.

Вторая строка относится к вершине с reduce. Сюда попало 17597 записей, на 183 меньше, чем вышло из первой. Это около 1 %, примерно столько битых записей ушло в DLQ, плюс счётчики двух вершин сняты не в один момент. Выход нулевой, потому что последний оператор цепочки печатает в stdout и дальше ничего не отправляет. busy в обеих вершинах единицы миллисекунд, а backpressure ноль, так что при такой нагрузке перекос безвреден. При нагрузке, которую подзадача не тянет, он превратится в backpressure по механизму из теории.

Шаг 6. Предскажи перекос заранее

Перекос во второй вершине можно посчитать, не запуская кластер. По формуле из теории при параллелизме 2 и 128 группах из 50 продавцов в подзадачу 0 попадают 22, включая m-0000 с его группой 18, а в подзадачу 1 попадают 28.

Теперь доли. На m-0000 приходится 30 % потока, на каждого из 49 остальных 70 / 49 = 1,43 %. Подзадача 0 получает 30 % + 21 × 1,43 % = 60 %, подзадача 1 получает 28 × 1,43 % = 40 %. От 17597 записей это 10558 и 7039. В метриках стоят 10560 и 7037. Формула и генератор сошлись почти до записи.

Обрати внимание на неожиданность. Без горячего ключа перекос был бы обратным, у подзадачи 1 больше продавцов. Горячий продавец перевесил.

Шаг 7. Посмотри своими глазами

Открой http://localhost:8081, зайди в задание и посмотри на граф. Между двумя вершинами на ребре написано HASH. Нажми на вторую вершину, вкладка Metrics или Subtasks покажет те же числа numRecordsIn по каждой подзадаче.

Предсказание можно проверить и по логу. Префикс перед строкой print это номер подзадачи плюс один, а m-0000 по расчёту живёт в подзадаче 0.

терминал
docker compose logs taskmanager | grep -oE '[0-9]+> MerchantTotal\[merchantId=m-0000' | sort | uniq -c

Ты должен увидеть одну строку с префиксом 1> и большим счётчиком. Строк с 2> для этого продавца быть не может. Итоги выглядят как MerchantTotal[merchantId=..., paymentCount=..., amountMinor=...], это стандартный toString record, и paymentCount у одного продавца только растёт.

Задание. Калькулятор раскладки ключей

Перед тем как поднимать параллелизм в проде, полезно знать, как лягут ключи. Напиши утилиту, которая считает это без кластера теми же функциями, что использует Flink.

  1. Создай класс dev.study.payguard.job.KeyDistribution со статическим методом loadBySubtask. На вход набор ключей, функция веса ключа, параллелизм и maxParallelism. На выход нагрузка по номеру подзадачи.
  2. Добавь метод skew, который возвращает отношение самой загруженной подзадачи к средней. 1,0 значит идеально ровно.
  3. Напиши тест KeyDistributionTest для 50 продавцов m-0000…m-0049, параллелизма 4 и 128 групп. При равных весах перекос меньше 1,5. С очень тяжёлым m-0000 перекос больше 2. Один и тот же ключ при повторном расчёте попадает в ту же подзадачу.

Готово, если ./gradlew test проходит, а метод для параллелизма 2 и весов из генератора даёт 0,6 и 0,4 от потока, как в шаге 6 практики.

Подсказка. Повторять формулы руками не нужно, в flink-runtime есть публичный статический метод, который делает оба шага сразу. Зависимость уже доступна при компиляции и в тестах через группу flinkProvided из урока 1. Используй TreeMap, чтобы подзадачи шли по порядку.

Ответ

Глоссарий урока

keyBy
Вызов DataStream API, который разбивает поток по ключу. Все записи с одним ключом попадают в одну подзадачу следующего оператора.
Группа ключей (key group)
Единица разбиения ключей и состояния. Номер группы равен murmurHash от hashCode ключа по модулю maxParallelism. При смене параллелизма группы переходят между подзадачами целиком.
Максимальный параллелизм (maxParallelism)
Число групп ключей оператора и верхняя граница его параллелизма. Без явной настройки вычисляется из параллелизма, минимум 128. После первого запуска не меняется без потери состояния.
MurmurHash
Быстрая хеш-функция, которая перемешивает биты числа. Flink применяет её к hashCode ключа, чтобы похожие ключи расходились по разным группам.
Стратегия распределения (partitioner)
Правило на ребре графа, которое выбирает подзадачу-получателя для каждой записи. Имя стратегии Web UI пишет на ребре, например HASH или REBALANCE.
rebalance
Стратегия, которая раздаёт записи по кругу всем подзадачам получателя. Выравнивает нагрузку, но теряет связь с ключом.
rescale
Стратегия, которая раздаёт записи по кругу только своему подмножеству получателей. Связей меньше, чем у rebalance, и часть передач остаётся локальной.
forward
Стратегия, при которой подзадача отправляет записи подзадаче с тем же номером. Единственная, которая позволяет склеить операторы в цепочку.
broadcast
Стратегия, которая отправляет каждую запись всем подзадачам получателя.
Цепочка операторов (operator chain)
Несколько операторов, склеенных в одну задачу. Запись передаётся между ними вызовом метода, без сериализации и сети.
ResultPartition
Выходные данные одной подзадачи-отправителя. Разбита на подчасти, по одной на каждую подзадачу получателя.
Подчасть (subpartition)
Очередь буферов внутри ResultPartition, предназначенная одной подзадаче получателя.
InputGate
Входной шлюз подзадачи-получателя. Собирает данные со всех отправителей, у каждого отправителя свой канал.
InputChannel
Канал внутри InputGate, который читает одну подчасть одного отправителя. Локальный читает её в памяти того же TaskManager, удалённый получает буферы по сети.
Сетевой буфер (network buffer)
Участок памяти, по умолчанию 32 КиБ, в котором записи едут между подзадачами. Уходит заполненным или по таймауту.
Кредит (credit)
Число свободных буферов, о котором получатель сообщил отправителю. Отправитель шлёт буфер с данными, только если кредит больше нуля.
Эксклюзивный буфер (exclusive buffer)
Буфер, закреплённый за одним удалённым каналом. Во Flink 2.2.1 их 2 на канал.
Плавающий буфер (floating buffer)
Буфер из общего запаса входного шлюза, который канал берёт, когда у отправителя накопилась очередь. Во Flink 2.2.1 их 8 на шлюз.
Backlog
Число буферов, которые ждут отправки в подчасти. Передаётся вместе с каждым буфером, чтобы получатель заранее запросил место.
Обратное давление (backpressure)
Состояние подзадачи, которая не может отправить данные, потому что получатель не выдаёт кредит. Поднимается вверх по графу до источника и проявляется ростом лага.
Перекос ключей (data skew)
Неравная нагрузка подзадач после keyBy. Чаще всего возникает из-за горячего ключа, на который приходится большая доля записей.
Соль (salting)
Приём против перекоса. К горячему ключу дописывают случайный суффикс, считают частичные итоги, а потом сливают их во второй фазе.
busyTimeMsPerSecond и соседи
Метрики подзадачи. Показывают, сколько миллисекунд в секунду она работала, ждала вход и стояла из-за backpressure. В сумме около 1000.
Netty
Java-библиотека для сетевых приложений. Flink передаёт через неё буферы между TaskManager.
Shuffle
Перераспределение данных между параллельными задачами по ключу. В Spark пишет промежуточные файлы на диск между стадиями, в потоковом Flink идёт конвейером через сетевые буферы.
reduce
Агрегация на KeyedStream, которая складывает новую запись с предыдущим результатом того же ключа и выдаёт итог на каждой записи.

Урок 5 · Время и состояние

Время события и watermark

После урока ты сможешь выбрать допуск беспорядка по реальным данным, объяснить, почему время задания стоит на месте, и найти watermark в REST и Web UI.

Платёж из метро

Терминал в вагоне метро провёл оплату в 12:00:05 и отправил её только на станции, в 12:00:40. Правило PayGuard считает платежи карты за минуту. Если брать время прихода записи, платёж попадёт в чужую минуту, и счётчик карты окажется неверным.

Вторая беда опаснее. Задание упало ночью, утром его подняли, и оно за пять минут дочитывает из Kafka два часа накопленных платежей. Если считать по часам сервера, двухчасовой поток уместится в несколько минут, и правило «больше 10 платежей за минуту» сработает на тысячах честных карт. Чтобы повторный прогон дал тот же ответ, что и живой, считать надо по времени, записанному в самом платеже.

Два времени одного платежа

У каждой записи есть время события (event time), момент, когда платёж случился. В нашем PaymentEvent это поле eventTimeMs, его ставит терминал. Есть и время обработки (processing time), это показания часов машины, на которой оператор Flink видит запись.

Время обработки удобно, потому что ничего не нужно ждать. Результат при этом зависит от скорости сети, нагрузки и перезапусков, поэтому повторить его нельзя. Время события даёт воспроизводимый результат, но порождает вопрос, сколько ждать отставших. Платёж из метро пришёл на 35 секунд позже соседей, и система должна как-то решить, когда минута 12:00 закрыта.

Чеки в семейном бюджете

Представь, что ты ведёшь семейный бюджет по неделям. На каждом чеке напечатана дата покупки, это время события. Домашние отдают чеки, когда вспомнят, это время обработки. Сын носит чеки в кармане день-два, жена отдаёт сразу.

Закрыть неделю, получив первый чек понедельника, нельзя, ведь сын ещё не вывернул карманы. Ты вводишь правило. Если самый свежий чек, который ты видел, датирован средой 18:00, а чеки задерживаются не больше двух суток, то всё до понедельника 18:00 уже пришло. Эта отметка и есть watermark. Чек, датированный раньше отметки, который всё-таки пришёл, называется поздним.

Отметку ведёшь по каждому члену семьи отдельно, а неделю закрываешь по самому медленному. Если сын уехал в лагерь и чеков не приносит вовсе, неделя не закроется никогда. Тогда ты решаешь, что молчащего больше не ждёшь. Дальше по уроку каждое из этих правил найдётся в коде Flink.

Watermark как обещание

Watermark это служебный элемент потока с одним числом T. Он идёт по графу вместе с записями и сообщает операторам, что событий со временем T и раньше больше не ожидается. Получив его, оператор передвигает своё время события на T. Окна из урока 6 по этому сигналу закрываются и отдают результат.

Позднее событие это запись, чья метка времени не больше текущего watermark оператора. Watermark только обещание, и оно бывает ложным. В цепочке этого урока поздние платежи проходят дальше, потому что ни один оператор их не отбрасывает. Как с ними поступает окно, разберём в уроке 6.

forBoundedOutOfOrderness изнутри

Стратегию watermark описывает WatermarkStrategy. Она создаёт два объекта. TimestampAssigner достаёт метку времени из записи. Генератор watermark (WatermarkGenerator) смотрит на метки и решает, какое обещание можно дать. Самый частый генератор задаётся методом forBoundedOutOfOrderness(B), где B это допуск беспорядка, то есть насколько, по твоему предположению, запись может отстать от самой свежей.

В исходниках

Весь генератор помещается в два метода. На каждую запись он только запоминает наибольшую метку. Watermark выпускается в отдельном методе, который зовут периодически.

apache/flink@release-2.2.1 · BoundedOutOfOrdernessWatermarks.java, строки 56-70
    // start so that our lowest watermark would be Long.MIN_VALUE.
    this.maxTimestamp = Long.MIN_VALUE + outOfOrdernessMillis + 1;
}

// ------------------------------------------------------------------------

@Override
public void onEvent(T event, long eventTimestamp, WatermarkOutput output) {
    maxTimestamp = Math.max(maxTimestamp, eventTimestamp);
}

@Override
public void onPeriodicEmit(WatermarkOutput output) {
    output.emitWatermark(new Watermark(maxTimestamp - outOfOrdernessMillis - 1));
}

Формула watermark получается такой. Это наибольшая увиденная метка минус B и ещё минус одна миллисекунда. Вычитание единицы делает обещание строгим, ведь событие с меткой ровно max - B ещё допустимо. Начальное значение подобрано так, чтобы до первой записи watermark был равен Long.MIN_VALUE.

Как часто зовут onPeriodicEmit, задаёт настройка конвейера.

apache/flink@release-2.2.1 · PipelineOptions.java, строки 90-97
public static final ConfigOption<Duration> AUTO_WATERMARK_INTERVAL =
        key("pipeline.auto-watermark-interval")
                .durationType()
                .defaultValue(Duration.ofMillis(200))
                .withDescription(
                        "The interval of the automatic watermark emission. Watermarks are used throughout"
                                + " the streaming system to keep track of the progress of time. They are used, for example,"
                                + " for time based windowing.");

По умолчанию раз в 200 мс. Значит, watermark отстаёт от самого свежего события на B плюс до 200 мс, об этом же говорит комментарий в WatermarkStrategy.

Разберём на числах при B, равном 10 секундам. Приходят платежи с метками 12:00:05, 12:00:12 и 12:00:09. После второго максимум равен 12:00:12, и ближайший периодический выпуск даст watermark 12:00:01.999. Третий платёж старше максимума, но новее watermark, поэтому он не поздний. Если следом придёт платёж с меткой 12:00:01, он окажется поздним.

Выбор B это сделка. Большой допуск ловит почти все опоздания, зато каждое окно закрывается на B позже. Маленький даёт быстрый ответ и больше поздних событий. Наш генератор делает 5 % платежей опоздавшими на случайную величину до 30 секунд, а в проекте стоит B, равный 10 секундам. По расчёту около двух третей опоздавших отстанут сильнее допуска, это примерно 3 % потока. Настоящую долю покажет проба, которую ты напишешь в задании.

Поиграй с моделью. Три партиции Kafka читает один источник. Кружки стоят на оси времени события, красные оказались поздними. Голубая черта на дорожке показывает watermark партиции, пунктир показывает общий watermark источника, а серая зона слева от него считается закрытой.

Симулятор watermark

Нажми кнопку, и 30 секунд модельного времени пройдут за 10 настоящих.

Модель упрощена. Watermark здесь выпускается каждые 0,1 с модельного времени, а событие проверяется по watermark, выпущенному до его прихода. Попробуй три опыта. Поставь B больше наибольшего опоздания, и красных не останется. Включи молчание партиции без withIdleness и посмотри, где застрянет пунктир. Потом добавь withIdleness и посмотри, что станет с событиями вернувшейся партиции.

Почему разбор переехал в schema

Потребитель Kafka отдаёт записи пачками из разных партиций вперемешку. Внутри одной партиции порядок почти правильный, а смесь двух партиций, одна из которых отстала на минуту, выглядит как сплошной беспорядок. Flink умеет вести watermark отдельно для каждой партиции прямо в источнике. Для этого метка времени должна быть известна в момент, когда источник отдаёт запись.

В исходниках

Источник Kafka превращает байты в объект сразу после чтения. Обрати внимание на consumerRecord.timestamp(), это метка записи в Kafka, она уходит в выход вместе с объектом.

apache/flink-connector-kafka@v5.0.0 · KafkaRecordEmitter.java, строки 50-58
    try {
        sourceOutputWrapper.setSourceOutput(output);
        sourceOutputWrapper.setTimestamp(consumerRecord.timestamp());
        deserializationSchema.deserialize(consumerRecord, sourceOutputWrapper);
        splitState.setCurrentOffset(consumerRecord.offset() + 1);
    } catch (Exception e) {
        throw new IOException("Failed to deserialize consumer record due to", e);
    }
}

Выход, в который пишет эмиттер, создан для конкретного сплита. Сплит это кусок работы источника, у Kafka один сплит равен одной партиции. На каждую запись выход вызывает TimestampAssigner, а потом генератор watermark этого сплита.

apache/flink@release-2.2.1 · SourceOutputWithWatermarks.java, строки 104-111
@Override
public final void collect(T record, long timestamp) {
    try {
        final long assignedTimestamp = timestampAssigner.extractTimestamp(record, timestamp);

        // IMPORTANT: The event must be emitted before the watermark generator is called.
        recordsOutput.emitRecord(reusingRecord.replace(record, assignedTimestamp));
        watermarkGenerator.onEvent(record, assignedTimestamp, onEventWatermarkOutput);

Генератор создаётся отдельно на каждый сплит, поэтому у каждой партиции свой максимум.

apache/flink@release-2.2.1 · ProgressiveTimestampsAndWatermarks.java, строки 286-291
final WatermarkOutput onEventOutput = watermarkMultiplexer.getImmediateOutput(splitId);
final WatermarkOutput periodicOutput = watermarkMultiplexer.getDeferredOutput(splitId);

final WatermarkGenerator<T> watermarks =
        watermarksFactory.createWatermarkGenerator(
                watermarksContextProvider.create(inputActivityClock));

Отсюда следует решение проекта. TimestampAssigner получает объект, который вернула схема десериализации. Если схема отдаёт сырую строку, достать eventTimeMs можно только повторным разбором JSON на каждой записи. Поэтому разбор переехал из отдельного оператора в ParsedRecordSchema.

Можно было оставить разбор после источника и вызвать assignTimestampsAndWatermarks. В этом операторе один генератор на подзадачу, и партиции к нему приходят уже перемешанными.

apache/flink@release-2.2.1 · TimestampsAndWatermarksOperator.java, строки 95-111
timestampAssigner = watermarkStrategy.createTimestampAssigner(this::getMetricGroup);
watermarkGenerator =
        emitProgressiveWatermarks
                ? watermarkStrategy.createWatermarkGenerator(
                        new WatermarkGeneratorSupplier.Context() {
                            @Override
                            public MetricGroup getMetricGroup() {
                                return TimestampsAndWatermarksOperator.this
                                        .getMetricGroup();
                            }

                            @Override
                            public RelativeClock getInputActivityClock() {
                                return inputActivityClock;
                            }
                        })
                : new NoWatermarksGenerator<>();

Минимум по входам

Как только у источника несколько партиций, нужно правило слияния. Оно одно на весь Flink. Общий watermark равен минимуму по входам, потому что обещание «ничего старше T» верно, только если его дал каждый вход. Так работает источник по своим сплитам, и так работает любой оператор, получающий данные от нескольких подзадач, например после keyBy.

P0 · 12:00:20 P1 · 12:00:14 P2 · 12:00:18 P3 · 12:00:25 Источник, подзадача 0min(:20, :14) = 12:00:14 Источник, подзадача 1min(:18, :25) = 12:00:18 После keyBy, подзадача 0min(:14, :18) = 12:00:14 После keyBy, подзадача 1min(:14, :18) = 12:00:14

Одна отставшая партиция P1 задаёт время всего задания. Обе подзадачи после keyBy видят одинаковый watermark, потому что получают данные от одних и тех же подзадач источника. Это пригодится в практике, когда ты увидишь два одинаковых числа в REST.

В исходниках

Внутри источника watermark сплитов сливает CombinedWatermarkStatus. Берётся минимум по активным сплитам. Если активных не осталось, берётся максимум, и комментарий объясняет почему.

apache/flink@release-2.2.1 · CombinedWatermarkStatus.java, строки 71-99
long maximumOverAllOutputs = Long.MIN_VALUE;
long minimumOverAllActiveOutputs = Long.MAX_VALUE;

boolean allIdle = true;
for (PartialWatermark partialWatermark : partialWatermarks) {
    final long watermark = partialWatermark.getWatermark();
    maximumOverAllOutputs = Math.max(maximumOverAllOutputs, watermark);
    if (!partialWatermark.isIdle()) {
        minimumOverAllActiveOutputs = Math.min(minimumOverAllActiveOutputs, watermark);
        allIdle = false;
    }
}

this.idle = allIdle;

final long combinedWatermark;
if (allIdle) {
    // If all splits are idle, we should flush all watermarks, which effectively
    // means emitting the maximum watermark over all outputs.
    // Otherwise, there could be a race condition between splits when idleness is triggered.
    // E.g., split 1 of 2 emits 5 and goes into idle, split 2 of 2 emits 4 and goes into
    // idle. If split 2 is idle first, watermark 5 wins. If split 1 is idle first, watermark
    // 4 wins. But if both are idle, we should conclude on 5.
    combinedWatermark = maximumOverAllOutputs;
} else {
    // Active splits should determine the progression of the watermark. Therefore, the
    // minimum watermark across all active splits takes precedence over that of idle splits.
    combinedWatermark = minimumOverAllActiveOutputs;
}

Между подзадачами то же делает StatusWatermarkValve, клапан на входе каждой задачи. Он хранит последний watermark каждого канала и держит каналы в куче, упорядоченной по watermark.

apache/flink@release-2.2.1 · StatusWatermarkValve.java, строки 165-186
// ignore the input watermark if its subpartition, or all subpartitions are idle (i.e.
// overall the valve is idle).
if (lastOutputWatermarkStatus.isActive() && subpartitionStatus.watermarkStatus.isActive()) {
    long watermarkMillis = watermark.getTimestamp();

    // if the input watermark's value is less than the last received watermark for its
    // subpartition, ignore it also.
    if (watermarkMillis > subpartitionStatus.watermark) {
        subpartitionStatus.watermark = watermarkMillis;

        if (subpartitionStatus.isWatermarkAligned) {
            adjustAlignedSubpartitionStatuses(subpartitionStatus);
        } else if (watermarkMillis >= lastOutputWatermark) {
            // previously unaligned subpartitions are now aligned if its watermark has
            // caught up
            markWatermarkAligned(subpartitionStatus);
        }

        // now, attempt to find a new min watermark across all aligned subpartitions
        findAndOutputNewMinWatermarkAcrossAlignedSubpartitions(output);
    }
}

Наружу уходит вершина кучи, то есть минимум, и только если он вырос.

apache/flink@release-2.2.1 · StatusWatermarkValve.java, строки 273-285
private void findAndOutputNewMinWatermarkAcrossAlignedSubpartitions(DataOutput<?> output)
        throws Exception {
    boolean hasAlignedSubpartitions = !alignedSubpartitionStatuses.isEmpty();

    // we acknowledge and output the new overall watermark if it really is aggregated
    // from some remaining aligned subpartition, and is also larger than the last output
    // watermark
    if (hasAlignedSubpartitions
            && alignedSubpartitionStatuses.peek().watermark > lastOutputWatermark) {
        lastOutputWatermark = alignedSubpartitionStatuses.peek().watermark;
        output.emitWatermark(new Watermark(lastOutputWatermark));
    }
}

Из кода видны два свойства. Watermark канала, который пришёл меньше предыдущего, отбрасывается. Выходной watermark только растёт, поэтому время оператора никогда не идёт назад.

Тихая партиция и зависшее время

Минимум безжалостен к молчунам. В топике четыре партиции, генератор пишет в них без ключа, и сегодня все получают данные. В проде часто бывает иначе. Партицию читают, но в неё ночью никто не пишет, её watermark стоит, а вместе с ним стоит время всего задания. Окна не закрываются, состояние растёт, алертов нет, и при этом ни одной ошибки в логе.

Лекарство это withIdleness(timeout). Если по входу нет записей дольше таймаута, вход объявляется простаивающим (idle), и минимум считается без него.

В исходниках

Обёртка над любым генератором. Каждая запись сбрасывает таймер. При периодическом выпуске, если таймер истёк, вместо watermark выдаётся признак простоя.

apache/flink@release-2.2.1 · WatermarksWithIdleness.java, строки 66-83
@Override
public void onEvent(T event, long eventTimestamp, WatermarkOutput output) {
    watermarks.onEvent(event, eventTimestamp, output);
    idlenessTimer.activity();
    isIdleNow = false;
}

@Override
public void onPeriodicEmit(WatermarkOutput output) {
    if (idlenessTimer.checkIfIdle()) {
        if (!isIdleNow) {
            output.markIdle();
            isIdleNow = true;
        }
    } else {
        watermarks.onPeriodicEmit(output);
    }
}

Таймер считает по часам машины, время событий здесь ни при чём. Источник даёт каждому сплиту свои часы и ставит их на паузу при обратном давлении, об этом комментарий в ProgressiveTimestampsAndWatermarks, поэтому перегруженный источник не объявит партицию простаивающей. Когда в партицию снова приходят записи, она становится активной, но общий watermark уже ушёл вперёд и обратно не вернётся.

apache/flink@release-2.2.1 · CombinedWatermarkStatus.java, строки 101-106
if (combinedWatermark > this.combinedWatermark) {
    this.combinedWatermark = combinedWatermark;
    return true;
}

return false;
⚠
Idleness меняет поздние события на быстрые окна

Партиция, которая молчала из-за сбоя продюсера, потом выдаёт накопленное со старыми метками. Пока она считалась простаивающей, общее время ушло вперёд, и её платежи станут поздними. Таймаут выбирай больше обычных пауз в данных. В проекте стоит одна минута.

Сравнение со Spark

В Spark Structured Streaming watermark задаётся вызовом withWatermark("timestamp", "10 minutes"). Движок берёт наибольшее время события, увиденное запросом по всем партициям сразу, вычитает порог и применяет результат с начала следующего micro-batch. Если запрос объединяет несколько потоков, по умолчанию берётся минимум их watermark, это задаёт spark.sql.streaming.multipleWatermarkPolicy.

Во Flink watermark считается по каждой партиции отдельно, сливается минимумом на каждом шаге графа и выходит каждые 200 мс. Отставшая партиция во Flink сдерживает время. В Spark её данные рискуют оказаться за порогом, потому что максимум задают быстрые партиции. Простой во Flink объявляется явно через withIdleness.

i
Одно место для правил времени

Стратегия watermark часть контракта задания, как схема данных. В проекте она живёт в одном классе PaymentWatermarks. Когда допуск понадобится поменять по метрикам, правка будет в одной строке.

Проверь себя

Задание лежало два часа и теперь дочитывает накопленное в 20 раз быстрее обычного. Правило считает платежи карты за минуту. Что произойдёт?

Топик с 12 партициями. В одну пишут только платежи из маленькой страны, ночью их нет. Каждую ночь окна перестают закрываться, ошибок в логе нет. Что делать?

B равен 10 с. Проба из задания показывает 4 % поздних платежей, а 99-й перцентиль их отставания от watermark равен 20 000 мс. Бизнес согласен ждать результат дольше. Что поменять?

Что будет в конце

Источник Kafka сам разбирает JSON и ставит каждому платежу метку времени из eventTimeMs. Watermark считается по каждой партиции с допуском 10 секунд, а молчащая минуту партиция перестаёт его держать. Ты найдёшь watermark в REST и в Web UI и поймёшь, почему у источника его не видно. Пробу опозданий напишешь в задании, её файлы сейчас создавать не нужно.

Новых зависимостей нет, build.gradle.kts не меняется.

Шаг 1. Результат разбора

Схема десериализации обязана вернуть один объект на запись. Битая запись тоже должна дойти до DLQ, поэтому заводим обёртку, в которой заполнено ровно одно поле. Создай model/ParsedRecord.java.

src/main/java/dev/study/payguard/model/ParsedRecord.java
package dev.study.payguard.model;

/** Результат разбора одной записи Kafka: заполнено ровно одно поле. */
public record ParsedRecord(PaymentEvent payment, RejectedRecord rejected) {

    public static ParsedRecord accepted(PaymentEvent payment) {
        return new ParsedRecord(payment, null);
    }

    public static ParsedRecord rejected(RejectedRecord rejected) {
        return new ParsedRecord(null, rejected);
    }

    public boolean isAccepted() {
        return payment != null;
    }
}

Это public record из двух полей-record, поэтому Flink сериализует его без Kryo, и запрет generic-типов из урока 3 продолжает действовать.

Шаг 2. Разбор в источнике

Создай pipeline/ParsedRecordSchema.java. Класс PaymentValidator из урока 3 остаётся как был.

src/main/java/dev/study/payguard/pipeline/ParsedRecordSchema.java
package dev.study.payguard.pipeline;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.study.payguard.model.ParsedRecord;
import dev.study.payguard.model.PaymentEvent;
import dev.study.payguard.model.RejectedRecord;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import org.apache.flink.api.common.serialization.AbstractDeserializationSchema;

/**
 * Разбирает запись прямо в источнике Kafka. Метка времени становится известна до выхода из источника,
 * поэтому watermark считается отдельно для каждой партиции.
 */
public class ParsedRecordSchema extends AbstractDeserializationSchema<ParsedRecord> {

    private transient ObjectMapper mapper;

    @Override
    public void open(InitializationContext context) {
        mapper = new ObjectMapper();
    }

    @Override
    public ParsedRecord deserialize(byte[] message) {
        String raw = new String(message, StandardCharsets.UTF_8);
        try {
            PaymentEvent payment = mapper.readValue(raw, PaymentEvent.class);
            Optional<String> reason = PaymentValidator.rejectionReason(payment);
            return reason.map(r -> rejected(raw, r)).orElseGet(() -> ParsedRecord.accepted(payment));
        } catch (JsonProcessingException e) {
            return rejected(raw, "bad-json");
        }
    }

    private static ParsedRecord rejected(String raw, String reason) {
        return ParsedRecord.rejected(new RejectedRecord(raw, reason, System.currentTimeMillis()));
    }
}
КОД
public class ParsedRecordSchema extends AbstractDeserializationSchema<ParsedRecord> {

    private transient ObjectMapper mapper;

    @Override
    public void open(InitializationContext context) {
        mapper = new ObjectMapper();
    }

    @Override
    public ParsedRecord deserialize(byte[] message) {
        String raw = new String(message, StandardCharsets.UTF_8);
        try {
            PaymentEvent payment = mapper.readValue(raw, PaymentEvent.class);
            Optional<String> reason = PaymentValidator.rejectionReason(payment);
            return reason.map(r -> rejected(raw, r)).orElseGet(() -> ParsedRecord.accepted(payment));
        } catch (JsonProcessingException e) {
            return rejected(raw, "bad-json");
        }
    }

    private static ParsedRecord rejected(String raw, String reason) {
        return ParsedRecord.rejected(new RejectedRecord(raw, reason, System.currentTimeMillis()));
    }
ПРОСТЫМИ СЛОВАМИ

AbstractDeserializationSchema берёт на себя описание типа результата. Её конструктор выводит тип ParsedRecord из параметра класса через TypeExtractor, наследнику остаётся написать deserialize.

Схема, как и функция, сериализуется на клиенте и едет в TaskManager. ObjectMapper создаётся в open, уже внутри подзадачи.

Байты превращаются в строку, затем в PaymentEvent. Валидатор из урока 3 решает, годится ли платёж. Годный оборачивается в accepted, негодный в rejected с причиной.

Синтаксическая ошибка JSON перехватывается и становится записью с причиной bad-json. Исключение из deserialize уронило бы задачу, эмиттер Kafka заворачивает его в IOException.

Время отказа берётся с часов машины, это момент разбора, его видно в DLQ.

Старый ParsePaymentFunction и его тест удали, их работу теперь делают схема и следующий класс. Новый тест схемы проверяет те же три случая.

src/test/java/dev/study/payguard/pipeline/ParsedRecordSchemaTest.java
package dev.study.payguard.pipeline;

import static org.assertj.core.api.Assertions.assertThat;

import dev.study.payguard.model.ParsedRecord;
import dev.study.payguard.model.PaymentEvent;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class ParsedRecordSchemaTest {

    private final ParsedRecordSchema schema = new ParsedRecordSchema();

    @BeforeEach
    void setUp() {
        schema.open(null);
    }

    private ParsedRecord parse(String raw) {
        return schema.deserialize(raw.getBytes(StandardCharsets.UTF_8));
    }

    @Test
    void validRecordIsAccepted() {
        ParsedRecord record = parse("""
                {"paymentId":"p1","cardId":"c1","merchantId":"m1","amountMinor":1500,
                 "currency":"EUR","country":"DE","eventTimeMs":1000}""");
        assertThat(record.payment()).isEqualTo(new PaymentEvent("p1", "c1", "m1", 1500, "EUR", "DE", 1000));
    }

    @Test
    void brokenJsonIsRejectedWithReason() {
        ParsedRecord record = parse("{broken");
        assertThat(record.isAccepted()).isFalse();
        assertThat(record.rejected().reason()).isEqualTo("bad-json");
    }

    @Test
    void invalidPaymentIsRejectedWithValidatorReason() {
        ParsedRecord record = parse("""
                {"paymentId":"p1","cardId":"c1","merchantId":"m1","amountMinor":-5,
                 "currency":"EUR","country":"DE","eventTimeMs":1000}""");
        assertThat(record.rejected().reason()).isEqualTo("non-positive-amount");
    }
}

Схеме не нужен тестовый стенд, это обычный объект. В open передаётся null, потому что наш метод контекст не читает.

Шаг 3. Развилка после источника

Из источника выходит ParsedRecord. Разведём его на поток платежей и побочный выход отказов. Создай pipeline/SplitParsedRecords.java.

src/main/java/dev/study/payguard/pipeline/SplitParsedRecords.java
package dev.study.payguard.pipeline;

import dev.study.payguard.model.ParsedRecord;
import dev.study.payguard.model.PaymentEvent;
import dev.study.payguard.model.RejectedRecord;
import org.apache.flink.streaming.api.functions.ProcessFunction;
import org.apache.flink.util.Collector;
import org.apache.flink.util.OutputTag;

/** Разводит принятые платежи в основной выход, а отклонённые записи в побочный. */
public class SplitParsedRecords extends ProcessFunction<ParsedRecord, PaymentEvent> {

    public static final OutputTag<RejectedRecord> REJECTED = new OutputTag<>("rejected") {
    };

    @Override
    public void processElement(ParsedRecord record, Context context, Collector<PaymentEvent> out) {
        if (record.isAccepted()) {
            out.collect(record.payment());
        } else {
            context.output(REJECTED, record.rejected());
        }
    }
}

Метка REJECTED и способ работы с побочным выходом те же, что в уроке 3. Функция стала проще, потому что разбирать ей больше нечего.

Шаг 4. Стратегия watermark

Создай pipeline/PaymentWatermarks.java. Это единственное место, где задание решает, что такое время.

src/main/java/dev/study/payguard/pipeline/PaymentWatermarks.java
package dev.study.payguard.pipeline;

import dev.study.payguard.model.ParsedRecord;
import java.time.Duration;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;

/** Единственное место, где описано, как задание понимает время событий. */
public final class PaymentWatermarks {

    public static final Duration MAX_OUT_OF_ORDERNESS = Duration.ofSeconds(10);
    public static final Duration IDLE_AFTER = Duration.ofMinutes(1);

    public static WatermarkStrategy<ParsedRecord> strategy() {
        return WatermarkStrategy.<ParsedRecord>forBoundedOutOfOrderness(MAX_OUT_OF_ORDERNESS)
                .withTimestampAssigner((record, kafkaTimestamp) ->
                        record.isAccepted() ? record.payment().eventTimeMs() : kafkaTimestamp)
                .withIdleness(IDLE_AFTER);
    }

    private PaymentWatermarks() {
    }
}
КОД
    public static final Duration MAX_OUT_OF_ORDERNESS = Duration.ofSeconds(10);
    public static final Duration IDLE_AFTER = Duration.ofMinutes(1);

    public static WatermarkStrategy<ParsedRecord> strategy() {
        return WatermarkStrategy.<ParsedRecord>forBoundedOutOfOrderness(MAX_OUT_OF_ORDERNESS)
                .withTimestampAssigner((record, kafkaTimestamp) ->
                        record.isAccepted() ? record.payment().eventTimeMs() : kafkaTimestamp)
                .withIdleness(IDLE_AFTER);
    }
ПРОСТЫМИ СЛОВАМИ

Допуск беспорядка 10 секунд и таймаут простоя одна минута вынесены в публичные константы, чтобы их было видно с первого взгляда и можно было взять в тестах.

forBoundedOutOfOrderness создаёт генератор, который мы разобрали в теории. Явный параметр <ParsedRecord> нужен компилятору, иначе он не узнает тип записи в лямбде ниже.

TimestampAssigner получает запись и метку Kafka. Принятому платежу ставится его eventTimeMs. У отказа времени события нет, и он берёт метку Kafka. Генератор не задаёт её сам, поэтому её ставит продюсер в момент отправки, и она близка к текущему времени.

withIdleness оборачивает генератор проверкой простоя. Партиция без записей дольше минуты перестаёт держать общий watermark.

Отказы тоже двигают watermark, ведь генератор видит все записи сплита. Их метка Kafka почти совпадает с текущим временем, поэтому watermark от них не убегает вперёд.

Шаг 5. Задание

Открой PayGuardJob. Источник теперь отдаёт ParsedRecord, а вместо noWatermarks передаётся наша стратегия. Начало метода main выглядит так.

src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 29-39)
        SingleOutputStreamOperator<PaymentEvent> split = env
                .fromSource(paymentSource(config), PaymentWatermarks.strategy(), "payments-raw")
                .uid("payments-raw")
                .process(new SplitParsedRecords())
                .name("split-parsed")
                .uid("split-parsed");

        split.getSideOutput(SplitParsedRecords.REJECTED)
                .sinkTo(KafkaSinks.<RejectedRecord>atLeastOnceJson(config.bootstrapServers(), config.dlqTopic()))
                .name("dlq-sink")
                .uid("dlq-sink");

Метод сборки источника меняет тип и схему.

src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 60-68)
    private static KafkaSource<ParsedRecord> paymentSource(JobConfig config) {
        return KafkaSource.<ParsedRecord>builder()
                .setBootstrapServers(config.bootstrapServers())
                .setTopics(config.rawTopic())
                .setGroupId(config.groupId())
                .setStartingOffsets(OffsetsInitializer.latest())
                .setValueOnlyDeserializer(new ParsedRecordSchema())
                .build();
    }

Подсчёт сумм по продавцам из урока 4 остаётся прежним. До задания подключи его прямо к переменной split, то есть цепочка начинается с split.map(MerchantTotal::of). В задании между ними встанет проба.

Оператор переименован в split-parsed и получил новый uid. Состояния у прежнего оператора не было, и чекпоинтов в проекте пока нет, поэтому смена uid ничего не ломает. В уроке 13 мы увидим, когда это опасно.

Шаг 6. Собери и запусти

Тесты, сборка, стенд. Порядок прежний, иначе Compose смонтирует пустую build/libs.

терминал
./gradlew test shadowJar
docker compose up -d --wait
docker compose exec jobmanager flink list

Если от прошлого урока осталось задание, останови его через flink cancel <JobID>. Затем отправь новое и запусти генератор с процентом битых записей. Флаг --late по умолчанию равен 0,05, это доля опоздавших платежей.

терминал
scripts/submit.sh
./gradlew loadgen --args="--rate 100 --bad 0.01"
вывод submit.sh
Job has been submitted with JobID b891c20d5e8a11946fb87cf4f96fe80c

Битые записи должны по-прежнему попадать в DLQ, теперь их отсеивает схема. Прочитай начало топика той же командой, что в уроке 3.

терминал
docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 --topic payments.dlq --from-beginning --max-messages 3 2>/dev/null
вывод
{"raw":"{\"paymentId\": broken","reason":"bad-json","rejectedAtMs":1794085416337}
{"raw":"{\"paymentId\": broken","reason":"bad-json","rejectedAtMs":1794085417451}
{"raw":"{\"paymentId\": broken","reason":"bad-json","rejectedAtMs":1794085424125}

Шаг 7. Watermark в REST

У каждой вершины графа есть ресурс /watermarks. Пройдём по всем вершинам задания. Первая строка находит идентификатор работающего задания, цикл берёт идентификаторы вершин, а jq -c '[.[].value]' оставляет из ответа только значения по подзадачам.

терминал
JOB=$(curl -s localhost:8081/jobs | jq -r '.jobs[] | select(.status == "RUNNING") | .id')
for V in $(curl -s localhost:8081/jobs/$JOB | jq -r '.vertices[].id'); do
  curl -s localhost:8081/jobs/$JOB/vertices/$V/watermarks | jq -c '[.[].value]'
done
вывод
[]
["1794085494920","1794085494920"]

Первая строка относится к вершине с источником, вторая к вершине merchant-running-total. Значение это миллисекунды от начала эпохи Unix, 7 ноября 2026 года, 21:04:54.920 по UTC. Команда date -r 1794085494 на macOS переведёт его в твой часовой пояс. Обе подзадачи показывают одно число, как и на схеме из теории.

Пустой список у источника выглядит как поломка, хотя watermark там есть. Причина в том, что именно отдаёт этот ресурс.

В исходниках

Обработчик REST читает у каждой подзадачи одну метрику уровня задачи, currentInputWatermark.

apache/flink@release-2.2.1 · JobVertexWatermarksHandler.java, строки 86-98
AccessExecutionVertex[] taskVertices = jobVertex.getTaskVertices();
List<Metric> metrics = new ArrayList<>(taskVertices.length);

for (AccessExecutionVertex taskVertex : taskVertices) {
    String id =
            taskVertex.getParallelSubtaskIndex()
                    + "."
                    + MetricNames.IO_CURRENT_INPUT_WATERMARK;
    String watermarkValue = taskMetricStore.getMetric(id);
    if (watermarkValue != null) {
        metrics.add(new Metric(id, watermarkValue));
    }
}

Эту метрику на уровне задачи регистрирует задача с сетевым входом. Код ниже из OneInputStreamTask. В задаче источника такой регистрации нет, у неё нет входа из сети.

apache/flink@release-2.2.1 · OneInputStreamTask.java, строки 129-135
mainOperator
        .getMetricGroup()
        .gauge(MetricNames.IO_CURRENT_INPUT_WATERMARK, inputWatermarkGauge);
// wrap watermark gauge since registered metrics must be unique
getEnvironment()
        .getMetricGroup()
        .gauge(MetricNames.IO_CURRENT_INPUT_WATERMARK, inputWatermarkGauge::getValue);

Поэтому watermark источника видно только по вершине, которая его получила. У операторов внутри цепочки есть своя метрика с тем же именем на уровне оператора. Её регистрирует OperatorChain, в списке метрик подзадачи она начинается с имени оператора, например split-parsed.

Шаг 8. Watermark в Web UI

Открой http://localhost:8081 и задание payguard. На прямоугольнике вершины merchant-running-total есть строка Low Watermark. Щёлкни по вершине, в нижней панели открой вкладку Watermarks. Там значения по подзадачам и рядом дата в часовом поясе браузера.

В исходниках

Web UI берёт тот же ресурс REST и считает Low Watermark как минимум по подзадачам. Значение не больше Long.MIN_VALUE превращается в «нет watermark».

apache/flink@release-2.2.1 · metrics.service.ts, строки 147-167
public loadWatermarks(jobId: string, vertexId: string): Observable<Watermarks> {
  return this.httpClient
    .get<JobMetric[]>(`${this.configService.BASE_URL}/jobs/${jobId}/vertices/${vertexId}/watermarks`)
    .pipe(
      map(arr => {
        let minValue = NaN;
        let lowWatermark: number;
        const watermarks: MetricMap = {};
        arr.forEach(item => {
          const value = parseInt(item.value, 10);
          const subTaskIndex = item.id.replace('.currentInputWatermark', '');
          watermarks[subTaskIndex] = value;
          if (isNaN(minValue) || value < minValue) {
            minValue = value;
          }
        });
        if (!isNaN(minValue) && minValue > this.configService.LONG_MIN_VALUE) {
          lowWatermark = minValue;
        } else {
          lowWatermark = NaN;
        }

Отсюда два практических вывода. У вершины источника строки Low Watermark не будет, список пуст. Если вместо числа во вкладке написано No Watermark (Watermarks are only available if EventTime is used), вершина получила только начальное значение Long.MIN_VALUE. Так было в уроках 1-4 с noWatermarks, и так бывает, когда ни одна партиция ещё ничего не прислала.

Как читать число. Сравни watermark с текущим временем. Разница около 10 секунд значит, что всё в порядке, это наш допуск. Разница, которая растёт, значит, что одна партиция отстала или замолчала, а таймаут простоя ещё не истёк.

Задание. Проба опозданий

Допуск 10 секунд мы выбрали наугад. Напиши оператор, который измеряет опоздания на живом потоке, чтобы выбирать допуск по данным.

  1. Класс dev.study.payguard.pipeline.LatenessProbe принимает PaymentEvent и отдаёт его же без изменений, все события, включая поздние.
  2. Событие считается поздним, если его eventTimeMs не больше текущего watermark оператора.
  3. Метрика-счётчик lateEvents считает поздние события.
  4. Метрика-гистограмма behindWatermarkMs получает для каждого позднего события, на сколько миллисекунд оно старше watermark. Храни последние 10 000 значений.
  5. В задании проба стоит сразу после split-parsed, имя и uid у неё lateness-probe. Подсчёт сумм продавцов читает её выход.
  6. Тест на тестовом стенде проверяет, что позднее событие не потерялось.

Готово, если ./gradlew test зелёный, задание работает на стенде, а в метриках подзадач вершины с источником есть lateEvents и перцентили behindWatermarkMs.

Подсказки. Обычной ProcessFunction без ключа таймеры недоступны, но узнать текущий watermark через контекст можно. Готовая реализация гистограммы есть в пакете org.apache.flink.runtime.metrics, модуль flink-runtime приходит вместе с flink-streaming-java. Тестовый стенд умеет подавать не только записи, но и watermark. Проба окажется в одной цепочке с источником, ищи её метрики по имени оператора.

Ответ

Глоссарий урока

Время события (event time)
Момент, когда событие случилось, записанный в самой записи. У платежа это поле eventTimeMs. Результат по нему воспроизводим при любом повторном прогоне.
Время обработки (processing time)
Показания часов машины, на которой оператор видит запись. Ждать ничего не нужно, но результат зависит от скорости и перезапусков.
Watermark
Служебный элемент потока с меткой T. Обещает операторам, что событий с меткой T и раньше больше не будет, и двигает их время события.
Допуск беспорядка (out-of-orderness)
Насколько запись может отстать от самой свежей. В forBoundedOutOfOrderness watermark равен максимуму меток минус допуск минус 1 мс.
Позднее событие (late event)
Запись, чья метка времени не больше текущего watermark оператора. Watermark сам её не удаляет, решение принимает оператор, например окно.
Метка времени (timestamp)
Число миллисекунд, привязанное к записи во Flink. У записи Kafka есть своя метка, по умолчанию её ставит продюсер в момент отправки.
TimestampAssigner
Функция стратегии watermark, которая достаёт метку времени из записи. Получает запись и метку, пришедшую от источника.
Генератор watermark (WatermarkGenerator)
Объект, который видит метки записей и периодически выпускает watermark. Источник создаёт по генератору на каждый сплит.
Стратегия watermark (WatermarkStrategy)
Описание того, как задание понимает время. Создаёт TimestampAssigner и генератор watermark, передаётся в fromSource.
Простой (idleness)
Состояние входа, по которому дольше таймаута нет записей. Простаивающий вход не участвует в минимуме watermark. Включается через withIdleness.
Сплит (split)
Кусок работы источника, который читает одна подзадача. У источника Kafka один сплит равен одной партиции топика.
Схема десериализации (DeserializationSchema)
Класс, превращающий байты записи источника в объект Java. Работает внутри источника, до того как запись получит метку времени.
StatusWatermarkValve
Клапан на входе задачи. Помнит watermark каждого входного канала и пропускает дальше минимум, только когда он вырос.
Партиция (partition)
Часть топика Kafka со своим порядком записей. Единица параллельного чтения и, во Flink, единица подсчёта watermark в источнике.
Apache Kafka
Распределённый журнал сообщений. Записи лежат в топиках, разбитых на партиции, у каждой записи есть метка времени.
withWatermark в Spark
Способ задать watermark в Spark Structured Streaming. Берёт наибольшее время события по всему запросу минус порог и применяет его с начала следующего micro-batch.
Счётчик (Counter)
Метрика Flink, которая только растёт. В пробе считает поздние события.
Гистограмма (Histogram)
Метрика Flink, которая хранит распределение значений. В REST видна как набор чисел с суффиксами _min, _max, _p99 и другими.
Тестовый стенд (harness)
Обёртка из тестового jar Flink, которая запускает один оператор в JVM теста и позволяет подавать ему записи и watermark.
REST-интерфейс
HTTP-интерфейс JobManager на порту 8081. Ресурс /jobs/.../vertices/.../watermarks отдаёт watermark подзадач вершины.
Web UI
Панель Flink в браузере. Показывает Low Watermark на вершинах графа и вкладку Watermarks по подзадачам.
Окно (window)
Оператор, который собирает записи за отрезок времени и выдаёт результат, когда watermark проходит конец отрезка. Тема урока 6.

Урок 6 · Время и состояние

Окна во Flink изнутри

После урока ты выберешь вид окна под задачу, посчитаешь, сколько состояния оно займёт, и решишь, что делать с опоздавшими платежами, а PayGuard начнёт выдавать минутные итоги по продавцам.

Итог, у которого нет конца

В уроке 4 задание считало по каждому продавцу накопительный итог через keyBy и reduce. Этот итог растёт с момента запуска и на вопрос «сколько продавец m-0000 принял с 12:00 до 12:01» ответить не может. Антифроду и дашборду нужны итоги за ограниченные отрезки времени. Сколько платежей прошло у продавца за минуту. Сколько раз карта платила подряд без долгих пауз.

У накопительного итога есть и вторая беда. reduce держит по значению на каждый ключ всё время работы задания, и Flink не знает, когда это значение можно выбросить. Окно задаёт обе границы сразу. Оно говорит, когда результат готов к выдаче, и когда его состояние можно удалить.

Квитанция за свет

Представь управляющую компанию. Каждый жилец сдаёт показания счётчика, а бухгалтерия раз в месяц выставляет квитанцию. Месяц здесь это окно, жилец это ключ. Квитанцию за октябрь печатают, когда бухгалтерия решила, что показаний за октябрь больше не будет. Обычно это 3 ноября, к этому дню успевают даже те, кто сдаёт с небольшой задержкой. Такое решение во Flink принимает watermark, ты знаешь его по уроку 5.

Жилец принёс октябрьские показания 10 ноября. Квитанция уже ушла, но перерасчёт разрешён до 25 ноября, и жилец получит исправленную квитанцию. Во Flink это допуск опозданий allowedLateness и позднее срабатывание окна. Показания за октябрь, принесённые в декабре, в октябрь уже не войдут. Их складывают в отдельную папку для ручного разбора, во Flink это побочный выход.

Бухгалтерии незачем хранить каждое показание до конца месяца, ей достаточно текущей суммы по счётчику. Так работает AggregateFunction. А среднее потребление за последние три месяца, которое пересчитывают каждый месяц, это sliding окно. Каждое показание входит в три таких расчёта.

Четыре вида окон

Окно описывается в коде после keyBy вызовом window(...). Аргумент этого вызова называется assigner. Он получает время события записи и возвращает окна, в которые она попадает.

ВидКак назначаются окнаОкон на одну записьКогда выдаётся итогПример в PayGuard
TumblingОтрезки одной длины встык, без пересечений1Watermark прошёл конец отрезкаОборот продавца за каждую минуту
SlidingОтрезки длины size, новый начинается каждые slidesize / slideWatermark прошёл конец отрезкаПлатежи карты за 10 минут с обновлением каждую минуту
SessionКаждая запись открывает отрезок длиной в паузу, пересекающиеся отрезки сливаются1 после слиянияWatermark прошёл последний платёж плюс паузуСерия платежей карты без перерыва дольше 30 с
GlobalОдно окно на ключ на всё время1Только по твоему triggerСчётчик со своим условием выдачи

Попробуй виды окон на одной ленте. Двенадцать платежей одного ключа уже пришли вовремя, а ещё один, с временем 52 с, появится, когда watermark дойдёт до 75 с. Размер tumbling и sliding окна 30 с, шаг sliding окна 10 с, пауза сессии 10 с. Для простоты платежи здесь приходят строго по порядку, и watermark совпадает со временем последнего из них. Логика срабатываний повторяет код оконного оператора, который мы разберём ниже.

Лента платежей и окна

Обрати внимание на три вещи. В sliding окнах каждый платёж лежит трижды, и счётчик копий в подписи под лентой растёт втрое. Global окно не выдаёт ничего, сколько бы ни шёл watermark. Опоздавший платёж ведёт себя по-разному в зависимости от вида окна и допуска, и в sliding окнах он может попасть только в часть своих окон.

Как tumbling окно находит своё начало

Assigner ничего не ищет в состоянии и не хранит список окон. Начало окна он вычисляет арифметикой от времени записи.

В исходниках

Метод assignWindows у tumbling окна возвращает список из одного окна. Смотри на две детали. Начало берётся из getWindowStartWithOffset, а запись без времени события роняет задание с понятным текстом.

apache/flink@release-2.2.1 · TumblingEventTimeWindows.java, строки 69-86
public Collection<TimeWindow> assignWindows(
        Object element, long timestamp, WindowAssignerContext context) {
    if (timestamp > Long.MIN_VALUE) {
        if (staggerOffset == null) {
            staggerOffset =
                    windowStagger.getStaggerOffset(context.getCurrentProcessingTime(), size);
        }
        // Long.MIN_VALUE is currently assigned when no timestamp is present
        long start =
                TimeWindow.getWindowStartWithOffset(
                        timestamp, (globalOffset + staggerOffset) % size, size);
        return Collections.singletonList(new TimeWindow(start, start + size));
    } else {
        throw new RuntimeException(
                "Record has Long.MIN_VALUE timestamp (= no timestamp marker). "
                        + "Did you forget to call 'DataStream.assignTimestampsAndWatermarks(...)'?");
    }
}

Сама формула занимает три строки. Остаток от деления времени на размер окна отбрасывается, ветка для отрицательного остатка нужна временам до 1970 года.

apache/flink@release-2.2.1 · TimeWindow.java, строки 264-272
public static long getWindowStartWithOffset(long timestamp, long offset, long windowSize) {
    final long remainder = (timestamp - offset) % windowSize;
    // handle both positive and negative cases
    if (remainder < 0) {
        return timestamp - (remainder + windowSize);
    } else {
        return timestamp - remainder;
    }
}

Проверим на настоящем платеже со стенда. Время события 1794085549065, размер окна 60000 мс, смещение по умолчанию 0. Остаток 49065, значит начало окна 1794085500000, а конец 1794085560000. Ровно такие границы ты увидишь в топике merchant.stats на практике. Окна выровнены по началу эпохи, поэтому минутное окно всегда начинается в ноль секунд по UTC. Сдвинуть границы можно вторым аргументом of(size, offset), это нужно, например, для суточных окон по московскому времени.

Почему sliding окно дорого стоит

В исходниках

У sliding окна тот же метод строит список. Цикл идёт от последнего начала назад с шагом slide, пока окно ещё накрывает запись.

apache/flink@release-2.2.1 · SlidingEventTimeWindows.java, строки 77-91
public Collection<TimeWindow> assignWindows(
        Object element, long timestamp, WindowAssignerContext context) {
    if (timestamp > Long.MIN_VALUE) {
        List<TimeWindow> windows = new ArrayList<>((int) (size / slide));
        long lastStart = TimeWindow.getWindowStartWithOffset(timestamp, offset, slide);
        for (long start = lastStart; start > timestamp - size; start -= slide) {
            windows.add(new TimeWindow(start, start + size));
        }
        return windows;
    } else {
        throw new RuntimeException(
                "Record has Long.MIN_VALUE timestamp (= no timestamp marker). "
                        + "Did you forget to call 'DataStream.assignTimestampsAndWatermarks(...)'?");
    }
}

Ёмкость списка задана прямо как size / slide. Окно на 10 минут с шагом в минуту даёт 10 окон на каждую запись, с шагом в 10 секунд уже 60. Документация приводит крайний пример, сутки с шагом в секунду дают 86400 окон на запись.

Как сессии склеиваются

Сессионное окно заранее не знает своих границ. Поэтому каждая запись сначала получает собственное окно длиной в паузу, а потом оператор склеивает его с соседними.

В исходниках

Assigner сессий возвращает окно от времени записи до этого времени плюс пауза.

apache/flink@release-2.2.1 · EventTimeSessionWindows.java, строки 60-64
@Override
public Collection<TimeWindow> assignWindows(
        Object element, long timestamp, WindowAssignerContext context) {
    return Collections.singletonList(new TimeWindow(timestamp, timestamp + sessionTimeout));
}

Склеиваются окна, которые пересекаются или касаются друг друга. Результат накрывает оба окна.

apache/flink@release-2.2.1 · TimeWindow.java, строки 112-123
/**
 * Returns {@code true} if this window intersects the given window or if this window is just
 * after or before the given window.
 */
public boolean intersects(TimeWindow other) {
    return this.start <= other.end && this.end >= other.start;
}

/** Returns the minimal window covers both this window and the given window. */
public TimeWindow cover(TimeWindow other) {
    return new TimeWindow(Math.min(start, other.start), Math.max(end, other.end));
}

При склейке накопленные значения двух окон объединяются. Для состояния в куче это вызов merge твоей AggregateFunction. Метод выше, add, показывает, что состояние адресуется текущим namespace, то есть окном.

apache/flink@release-2.2.1 · HeapAggregatingState.java, строки 93-111
@Override
public void add(IN value) throws Exception {
    final N namespace = currentNamespace;

    if (value == null) {
        clear();
        return;
    }
    stateTable.transform(namespace, value, aggregateTransformation);
}

// ------------------------------------------------------------------------
//  state merging
// ------------------------------------------------------------------------

@Override
protected ACC mergeState(ACC a, ACC b) {
    return aggregateTransformation.aggFunction.merge(a, b);
}

Отсюда правило. Метод merge в AggregateFunction вызывается только при склейке окон. Tumbling окна его не трогают. Сессии без него дадут неверный результат. Конец сессии всегда равен времени последнего платежа плюс пауза.

Global окно устроено проще всех. Все записи ключа попадают в одно окно, а trigger по умолчанию у него никогда не срабатывает.

В исходниках

Фабрика create подставляет NeverTrigger, комментарий прямо предупреждает, что без своего trigger вычислений не будет.

apache/flink@release-2.2.1 · GlobalWindows.java, строки 65-72
/**
 * Creates a {@link WindowAssigner} that assigns all elements to the same {@link GlobalWindow}.
 * The window is only useful if you also specify a custom trigger. Otherwise, the window will
 * never be triggered and no computation will be performed.
 */
public static GlobalWindows create() {
    return new GlobalWindows(new NeverTrigger());
}

Конец global окна равен Long.MAX_VALUE, поэтому оно никогда не считается опоздавшим и само из состояния не удаляется. Global окно имеет смысл только вместе со своим trigger и логикой очистки.

Из чего собран оконный оператор

За вызовами window(...) и aggregate(...) стоит один оператор WindowOperator из четырёх частей.

Платёжключ и время Assignerв какие окна Состояние окнаключ + окно Trigger Evictorнеобязателен Функция окнаитог наружу таймеры на конец окна и на очистку
  • Assigner решает, в какие окна попадает запись. Мы его только что разобрали.
  • Trigger решает, когда окно готово отдать результат и нужно ли после этого очистить его содержимое. У каждого assigner есть trigger по умолчанию.
  • Evictor может выкинуть часть записей из окна до или после вызова функции. Он необязателен и дорого обходится, об этом ниже.
  • Функция окна считает результат. Это ReduceFunction, AggregateFunction или ProcessWindowFunction.

В исходниках

Вот путь одной записи через оператор для окон без склейки. Assigner вызывается в начале processElement, дальше цикл по окнам записи.

apache/flink@release-2.2.1 · WindowOperator.java, строки 405-432
for (W window : elementWindows) {

    // drop if the window is already late
    if (isWindowLate(window)) {
        continue;
    }
    isSkippedElement = false;

    windowState.setCurrentNamespace(window);
    windowState.add(element.getValue());

    triggerContext.key = key;
    triggerContext.window = window;

    TriggerResult triggerResult = triggerContext.onElement(element);

    if (triggerResult.isFire()) {
        ACC contents = windowState.get();
        if (contents != null) {
            emitWindowContents(window, contents);
        }
    }

    if (triggerResult.isPurge()) {
        windowState.clear();
    }
    registerCleanupTimer(window);
}

Для каждого окна выполняются одни и те же шаги. Опоздавшее окно пропускается. Состояние переключается на namespace этого окна, и запись добавляется туда. Trigger получает запись и отвечает FIRE, PURGE, оба сразу или ничего. На каждое окно регистрируется таймер очистки. Отсюда видно, как окно живёт в состоянии. Это обычный keyed state, где к ключу добавлен namespace окна, поэтому у одного продавца одновременно может жить несколько окон.

Когда окно срабатывает

В исходниках

Trigger по умолчанию для окон по времени события называется EventTimeTrigger. Посмотри, что он делает на каждую запись и на таймер.

apache/flink@release-2.2.1 · EventTimeTrigger.java, строки 36-52
@Override
public TriggerResult onElement(
        Object element, long timestamp, TimeWindow window, TriggerContext ctx)
        throws Exception {
    if (window.maxTimestamp() <= ctx.getCurrentWatermark()) {
        // if the watermark is already past the window fire immediately
        return TriggerResult.FIRE;
    } else {
        ctx.registerEventTimeTimer(window.maxTimestamp());
        return TriggerResult.CONTINUE;
    }
}

@Override
public TriggerResult onEventTime(long time, TimeWindow window, TriggerContext ctx) {
    return time == window.maxTimestamp() ? TriggerResult.FIRE : TriggerResult.CONTINUE;
}

Если watermark ещё не дошёл до окна, trigger ставит таймер на maxTimestamp окна. Таймер по времени события срабатывает, когда watermark его догоняет. Если watermark уже позади окна, trigger срабатывает сразу, на этой самой записи. Это и есть позднее срабатывание. maxTimestamp на единицу меньше конца, потому что конец окна в него не входит.

apache/flink@release-2.2.1 · TimeWindow.java, строки 75-86
/**
 * Gets the largest timestamp that still belongs to this window.
 *
 * <p>This timestamp is identical to {@code getEnd() - 1}.
 *
 * @return The largest timestamp that still belongs to this window.
 * @see #getEnd()
 */
@Override
public long maxTimestamp() {
    return end - 1;
}

Для нашего минутного окна, которое заканчивается в 1794085560000, таймер стоит на 1794085559999. Итог уйдёт, как только watermark станет не меньше этого числа. При задержке watermark в 10 с из урока 5 это случится, когда задание увидит платёж с временем около 12:01:10.

AggregateFunction или ProcessWindowFunction

ProcessWindowFunction получает все записи окна разом и вдобавок контекст с границами окна. AggregateFunction видит записи по одной, складывает их в аккумулятор и о границах окна ничего не знает. Разница в стоимости решается в момент построения оператора.

В исходниках

Вызов aggregate с двумя функциями строит состояние типа AggregatingStateDescriptor. В нём лежит один аккумулятор на окно.

apache/flink@release-2.2.1 · WindowOperatorBuilder.java, строки 325-352
public <ACC, V, R> OneInputStreamOperator<T, R> aggregate(
        AggregateFunction<T, ACC, V> aggregateFunction,
        ProcessWindowFunction<V, R, K, W> windowFunction,
        TypeInformation<ACC> accumulatorType) {

    Preconditions.checkNotNull(aggregateFunction, "AggregateFunction cannot be null");
    Preconditions.checkNotNull(windowFunction, "ProcessWindowFunction cannot be null");

    if (aggregateFunction instanceof RichFunction) {
        throw new UnsupportedOperationException(
                "This aggregate function cannot be a RichFunction.");
    }

    if (evictor != null) {
        return buildEvictingWindowOperator(
                new InternalAggregateProcessWindowFunction<>(
                        aggregateFunction, windowFunction));
    } else {
        AggregatingStateDescriptor<T, ACC, V> stateDesc =
                new AggregatingStateDescriptor<>(
                        WINDOW_STATE_NAME,
                        aggregateFunction,
                        accumulatorType.createSerializer(config.getSerializerConfig()));

        return buildWindowOperator(
                stateDesc, new InternalSingleValueProcessWindowFunction<>(windowFunction));
    }
}

Вызов process с одной ProcessWindowFunction строит ListStateDescriptor. Это список всех записей окна.

apache/flink@release-2.2.1 · WindowOperatorBuilder.java, строки 388-405
public <R> OneInputStreamOperator<T, R> process(ProcessWindowFunction<T, R, K, W> function) {
    Preconditions.checkNotNull(function, "ProcessWindowFunction cannot be null");
    return apply(new InternalIterableProcessWindowFunction<>(function));
}

private <R> OneInputStreamOperator<T, R> apply(
        InternalWindowFunction<Iterable<T>, R, K, W> function) {
    if (evictor != null) {
        return buildEvictingWindowOperator(function);
    } else {
        ListStateDescriptor<T> stateDesc =
                new ListStateDescriptor<>(
                        WINDOW_STATE_NAME,
                        inputType.createSerializer(config.getSerializerConfig()));

        return buildWindowOperator(stateDesc, function);
    }
}

Обрати внимание на ветку evictor != null в обоих методах. С evictor даже aggregate собирает оператор, который хранит все записи, потому что evictor должен их видеть. Предварительное сложение пропадает.

Посчитаем для PayGuard. Примерно 200 платежей в секунду, окно в минуту, значит около 12000 платежей в окне на всех продавцов. С ProcessWindowFunction все они лежат в состоянии до конца окна и ещё минуту допуска. С AggregateFunction в состоянии 50 аккумуляторов по два числа. Две функции можно соединить. Аккумулятор копится по мере прихода записей, а ProcessWindowFunction в конце получает один готовый результат и добавляет к нему границы окна. Так сделан наш MerchantStatsWindow.

Опоздавшие платежи

У watermark есть задержка, но часть платежей приходит ещё позже. По умолчанию допуск опозданий равен нулю, и такая запись молча отбрасывается. Метрика numLateRecordsDropped на операторе увеличится на единицу, и больше ты ничего не узнаешь. Два вызова меняют это поведение. allowedLateness держит окно в состоянии дольше, и опоздавшая запись вызывает пересчёт. sideOutputLateData отправляет записи, которые не принял уже никто, в побочный выход.

В исходниках

Окно считается опоздавшим, когда watermark дошёл до его времени очистки. Время очистки равно maxTimestamp плюс допуск.

apache/flink@release-2.2.1 · WindowOperator.java, строки 605-624
/**
 * Returns {@code true} if the watermark is after the end timestamp plus the allowed lateness of
 * the given window.
 */
protected boolean isWindowLate(W window) {
    return (windowAssigner.isEventTime()
            && (cleanupTime(window) <= internalTimerService.currentWatermark()));
}

/**
 * Decide if a record is currently late, based on current watermark and allowed lateness.
 *
 * @param element The element to check
 * @return The element for which should be considered when sideoutputs
 */
protected boolean isElementLate(StreamRecord<IN> element) {
    return (windowAssigner.isEventTime())
            && (element.getTimestamp() + allowedLateness
                    <= internalTimerService.currentWatermark());
}
apache/flink@release-2.2.1 · WindowOperator.java, строки 670-677
private long cleanupTime(W window) {
    if (windowAssigner.isEventTime()) {
        long cleanupTime = window.maxTimestamp() + allowedLateness;
        return cleanupTime >= window.maxTimestamp() ? cleanupTime : Long.MAX_VALUE;
    } else {
        return window.maxTimestamp();
    }
}

В конце processElement запись уходит в побочный выход, только если её не приняло ни одно окно. Без тега она просто считается в метрике.

apache/flink@release-2.2.1 · WindowOperator.java, строки 435-446
// side output input event if
// element not handled by any window
// late arriving tag has been set
// windowAssigner is event time and current timestamp + allowed lateness no less than
// element timestamp
if (isSkippedElement && isElementLate(element)) {
    if (lateDataOutputTag != null) {
        sideOutput(element);
    } else {
        this.numLateRecordsDropped.inc();
    }
}

Отсюда тонкость с sliding окнами, которую видно в визуализации. Опоздавший платёж может не попасть в старые окна и при этом попасть в одно ещё живое. Тогда в побочный выход он не уйдёт. Старые окна так и останутся без него.

⚠
Одно окно, несколько итогов

При допуске больше нуля каждое позднее срабатывание выдаёт итог окна заново, уже с опоздавшим платежом. В топике merchant.stats окажется несколько записей с одним продавцом и одним windowStartMs. Последняя из них верная. Потребитель должен перезаписывать итог по ключу из продавца и начала окна, то есть делать upsert. Простое сложение всех записей подряд даст завышенный оборот.

Сравнение со Spark

В Spark Structured Streaming окно задаётся функцией window(col, "1 minute") внутри groupBy, для скользящего окна передаётся третий аргумент с шагом, для сессий есть session_window. Порог опоздания там один, это задержка из withWatermark. Она же определяет, когда окно выдаёт итог в режиме append и когда его состояние выбрасывается. Записи позже этого порога Spark вправе отбросить, и побочного выхода для них нет.

Во Flink это три независимые настройки. Задержка watermark решает, когда окно сработает впервые. allowedLateness решает, сколько ещё окно ждёт опоздавших. Побочный выход ловит то, что не дождалось. Сверх этого во Flink можно заменить trigger и выдавать, например, промежуточный итог каждые 10 секунд, не дожидаясь конца окна.

Проверь себя

Аналитик хочет видеть число платежей карты за последние 10 минут с обновлением каждые 10 секунд. Коллега написал sliding окно с одной ProcessWindowFunction, которая считает размер списка. Память TaskManager тает. Что происходит?

В merchant.stats пришли две записи для m-0007 с одинаковым windowStartMs, в первой paymentCount 30, во второй 31. Перезапусков задания не было. Что делать потребителю?

В MerchantStatsWindow убрали allowedLateness, побочный выход оставили. Генератор опаздывает с частью платежей до 30 с, задержка watermark 10 с. Что окажется в payments.late?

Что будет в конце

Накопительный итог из урока 4 уходит. Вместо него задание считает минутные итоги по продавцам в tumbling окнах по времени события, с допуском опозданий в минуту, и пишет их в топик merchant.stats. Платежи, которые не принял уже никто, уходят в payments.late. Окно проверяет тест на MiniCluster. Файлы сессий карты из задания пока создавать не нужно.

Шаг 1. Модель итога

Создай src/main/java/dev/study/payguard/model/MerchantStats.java. Это то, что уйдёт в Kafka. Границы окна лежат прямо в записи, иначе потребитель не поймёт, к какой минуте относится итог. Старый MerchantTotal удали, он больше не нужен.

src/main/java/dev/study/payguard/model/MerchantStats.java
package dev.study.payguard.model;

/** Итоги продавца за одно окно времени. */
public record MerchantStats(String merchantId, long windowStartMs, long windowEndMs, long paymentCount, long amountMinor) {
}

Шаг 2. Функция сложения

Создай src/main/java/dev/study/payguard/pipeline/PaymentTotals.java. Класс открытый, потому что им же воспользуется окно сессий из задания.

src/main/java/dev/study/payguard/pipeline/PaymentTotals.java
package dev.study.payguard.pipeline;

import dev.study.payguard.model.PaymentEvent;
import org.apache.flink.api.common.functions.AggregateFunction;

/** Считает количество и сумму платежей. Хранит только два числа, сами события не копятся. */
public class PaymentTotals implements AggregateFunction<PaymentEvent, PaymentTotals.Accumulator, PaymentTotals.Accumulator> {

    /** Промежуточный итог окна. */
    public record Accumulator(long count, long amountMinor) {
    }

    @Override
    public Accumulator createAccumulator() {
        return new Accumulator(0, 0);
    }

    @Override
    public Accumulator add(PaymentEvent payment, Accumulator accumulator) {
        return new Accumulator(accumulator.count() + 1, accumulator.amountMinor() + payment.amountMinor());
    }

    @Override
    public Accumulator getResult(Accumulator accumulator) {
        return accumulator;
    }

    @Override
    public Accumulator merge(Accumulator left, Accumulator right) {
        return new Accumulator(left.count() + right.count(), left.amountMinor() + right.amountMinor());
    }
}
КОД
public class PaymentTotals implements AggregateFunction<PaymentEvent, PaymentTotals.Accumulator, PaymentTotals.Accumulator> {

    /** Промежуточный итог окна. */
    public record Accumulator(long count, long amountMinor) {
    }

    @Override
    public Accumulator createAccumulator() {
        return new Accumulator(0, 0);
    }

    @Override
    public Accumulator add(PaymentEvent payment, Accumulator accumulator) {
        return new Accumulator(accumulator.count() + 1, accumulator.amountMinor() + payment.amountMinor());
    }

    @Override
    public Accumulator getResult(Accumulator accumulator) {
        return accumulator;
    }

    @Override
    public Accumulator merge(Accumulator left, Accumulator right) {
        return new Accumulator(left.count() + right.count(), left.amountMinor() + right.amountMinor());
    }
ПРОСТЫМИ СЛОВАМИ

Три параметра типа означают вход, аккумулятор и результат. Вход это платёж, а аккумулятор и результат здесь совпадают.

Аккумулятор описан как record из двух чисел. Именно он лежит в состоянии окна вместо самих платежей.

createAccumulator вызывается для первой записи нового окна. Окно рождается лениво, вместе с первым платежом.

add вызывается на каждый платёж. Record неизменяемый, поэтому метод возвращает новый аккумулятор. Flink кладёт возвращённое значение обратно в состояние.

getResult вызывается при срабатывании окна и отдаёт аккумулятор как есть, превращать его во что-то другое здесь незачем.

merge складывает два аккумулятора. Сессионное окно вызывает его при каждой склейке. Tumbling окну он не нужен.

Шаг 3. Окно продавца

Создай src/main/java/dev/study/payguard/pipeline/MerchantStatsWindow.java. Все настройки окна собраны в одном классе, а задание только подключает его к потоку.

src/main/java/dev/study/payguard/pipeline/MerchantStatsWindow.java
package dev.study.payguard.pipeline;

import dev.study.payguard.model.MerchantStats;
import dev.study.payguard.model.PaymentEvent;
import java.time.Duration;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;
import org.apache.flink.util.OutputTag;

/** Минутные итоги по продавцам с допуском опозданий. */
public final class MerchantStatsWindow {

    public static final Duration WINDOW_SIZE = Duration.ofMinutes(1);
    public static final Duration ALLOWED_LATENESS = Duration.ofMinutes(1);
    public static final OutputTag<PaymentEvent> TOO_LATE = new OutputTag<>("too-late") {
    };

    public static SingleOutputStreamOperator<MerchantStats> attach(DataStream<PaymentEvent> payments) {
        return payments
                .keyBy(PaymentEvent::merchantId)
                .window(TumblingEventTimeWindows.of(WINDOW_SIZE))
                .allowedLateness(ALLOWED_LATENESS)
                .sideOutputLateData(TOO_LATE)
                .aggregate(new PaymentTotals(), new AddWindowBounds())
                .name("merchant-stats-1m")
                .uid("merchant-stats-1m");
    }

    private static final class AddWindowBounds
            extends ProcessWindowFunction<PaymentTotals.Accumulator, MerchantStats, String, TimeWindow> {

        @Override
        public void process(
                String merchantId,
                Context context,
                Iterable<PaymentTotals.Accumulator> totals,
                Collector<MerchantStats> out) {
            PaymentTotals.Accumulator total = totals.iterator().next();
            TimeWindow window = context.window();
            out.collect(new MerchantStats(merchantId, window.getStart(), window.getEnd(), total.count(), total.amountMinor()));
        }
    }

    private MerchantStatsWindow() {
    }
}
КОД
    public static final Duration WINDOW_SIZE = Duration.ofMinutes(1);
    public static final Duration ALLOWED_LATENESS = Duration.ofMinutes(1);
    public static final OutputTag<PaymentEvent> TOO_LATE = new OutputTag<>("too-late") {
    };

    public static SingleOutputStreamOperator<MerchantStats> attach(DataStream<PaymentEvent> payments) {
        return payments
                .keyBy(PaymentEvent::merchantId)
                .window(TumblingEventTimeWindows.of(WINDOW_SIZE))
                .allowedLateness(ALLOWED_LATENESS)
                .sideOutputLateData(TOO_LATE)
                .aggregate(new PaymentTotals(), new AddWindowBounds())
                .name("merchant-stats-1m")
                .uid("merchant-stats-1m");
    }

    private static final class AddWindowBounds
            extends ProcessWindowFunction<PaymentTotals.Accumulator, MerchantStats, String, TimeWindow> {

        @Override
        public void process(
                String merchantId,
                Context context,
                Iterable<PaymentTotals.Accumulator> totals,
                Collector<MerchantStats> out) {
            PaymentTotals.Accumulator total = totals.iterator().next();
            TimeWindow window = context.window();
            out.collect(new MerchantStats(merchantId, window.getStart(), window.getEnd(), total.count(), total.amountMinor()));
        }
ПРОСТЫМИ СЛОВАМИ

Размер окна и допуск опозданий вынесены в константы. Тест и задание ссылаются на них, и числа не разъедутся.

Тег побочного выхода объявлен анонимным подклассом, отсюда фигурные скобки. Так Flink узнаёт тип PaymentEvent несмотря на стирание дженериков в Java.

keyBy раскладывает платежи по продавцам. Окна бывают и без ключа, но тогда весь поток идёт через одну подзадачу.

TumblingEventTimeWindows.of задаёт минутные окна по времени события, выровненные по началу эпохи.

allowedLateness держит окно в состоянии ещё минуту после срабатывания. sideOutputLateData направляет в тег то, что опоздало и сверх этого.

aggregate с двумя аргументами складывает платежи через PaymentTotals, а в конце отдаёт единственный аккумулятор в AddWindowBounds.

Имя и uid оператора задаются сразу, их смысл ты знаешь по уроку 2.

AddWindowBounds наследует ProcessWindowFunction. Итератор totals всегда содержит ровно один элемент, это готовый аккумулятор.

context.window() даёт границы окна, их и добавляем к счётчикам. Без этой функции границ в результате не было бы.

Шаг 4. Настройки, топики и задание

В JobConfig появляются три топика. Поле для сессий карты пригодится в задании, заведи его сразу, чтобы не возвращаться к этому файлу.

что изменилось в src/main/java/dev/study/payguard/job/JobConfig.java
@@ -4,8 +4,14 @@ import org.apache.flink.util.ParameterTool;
 
 /** Настройки задания из аргументов командной строки: один и тот же jar работает на любом стенде. */
-public record JobConfig(String bootstrapServers, String rawTopic, String dlqTopic, String groupId)-        implements Serializable {+public record JobConfig(+        String bootstrapServers,+        String rawTopic,+        String dlqTopic,+        String lateTopic,+        String merchantStatsTopic,+        String cardSessionsTopic,+        String groupId) implements Serializable { 
     public static JobConfig fromArgs(String[] args) {
         ParameterTool params = ParameterTool.fromArgs(args);
@@ -13,6 +19,9 @@                 params.get("bootstrap", "kafka:9092"),
                 params.get("raw-topic", "payments.raw"),
                 params.get("dlq-topic", "payments.dlq"),
+                params.get("late-topic", "payments.late"),+                params.get("merchant-stats-topic", "merchant.stats"),+                params.get("card-sessions-topic", "card.sessions"),                 params.get("group-id", "payguard"));
     }
 }

Те же три топика добавь в цикл службы kafka-init в docker-compose.yml.

что изменилось в docker-compose.yml
@@ -45,7 +45,7 @@     entrypoint: ["/bin/bash", "-c"]
     command:
       - |
-        for topic in payments.raw payments.dlq fraud.alerts rules.config; do+        for topic in payments.raw payments.dlq payments.late merchant.stats card.sessions fraud.alerts rules.config; do           /opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 \
             --create --if-not-exists --topic "$$topic" --partitions 4 --replication-factor 1
         done

В PayGuardJob цепочку map, keyBy, reduce и print замени подключением окна и двух приёмников Kafka. Приёмник из урока 3 обобщённый, поэтому годится и для итогов, и для платежей. Импорты MerchantStats и MerchantStatsWindow добавь, а импорт MerchantTotal убери.

С этого урока main растёт с каждым новым оператором, поэтому разнеси граф по коротким методам. parsePayments возвращает разобранный поток вместе с DLQ, а каждое следующее ответвление подключается своим методом. Сам main остаётся оглавлением задания.

src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 25-37)
    public static void main(String[] args) throws Exception {
        JobConfig config = JobConfig.fromArgs(args);

        Configuration flinkConfig = new Configuration();
        flinkConfig.set(PipelineOptions.GENERIC_TYPES, false);
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(flinkConfig);

        DataStream<PaymentEvent> payments = parsePayments(env, config);
        attachMerchantStats(payments, config);
        attachCardSessions(payments, config);

        env.execute("payguard");
    }

Окно и оба его приёмника.

src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 60-68)
        SingleOutputStreamOperator<MerchantStats> merchantStats = MerchantStatsWindow.attach(payments);
        merchantStats
                .sinkTo(KafkaSinks.<MerchantStats>atLeastOnceJson(config.bootstrapServers(), config.merchantStatsTopic()))
                .name("merchant-stats-sink")
                .uid("merchant-stats-sink");
        merchantStats.getSideOutput(MerchantStatsWindow.TOO_LATE)
                .sinkTo(KafkaSinks.<PaymentEvent>atLeastOnceJson(config.bootstrapServers(), config.lateTopic()))
                .name("too-late-sink")
                .uid("too-late-sink");

Побочный выход берётся у результата окна через тот же тег. Тип потока известен из тега, поэтому приёмник получает PaymentEvent.

Шаг 5. Тест на MiniCluster

Harness из урока 3 проверяет один оператор, а у окна важна связка из keyBy, watermark, таймеров и состояния. Проще запустить кусок конвейера целиком. MiniCluster это настоящий Flink внутри JVM теста, зависимость flink-test-utils уже есть в build.gradle.kts. Сначала общий помощник для тестов, создай src/test/java/dev/study/payguard/testing/Payments.java.

src/test/java/dev/study/payguard/testing/Payments.java
package dev.study.payguard.testing;

import dev.study.payguard.model.PaymentEvent;
import java.util.List;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;

/** Заготовки платежей для тестов. */
public final class Payments {

    public static PaymentEvent payment(String id, String card, String merchant, long amount, long eventTimeMs) {
        return new PaymentEvent(id, card, merchant, amount, "EUR", "DE", eventTimeMs);
    }

    public static PaymentEvent paymentOnCard(String id, String card, long eventTimeMs) {
        return payment(id, card, "m1", 1_000, eventTimeMs);
    }

    /** Поток из готовых событий. Когда данные кончаются, watermark уходит в бесконечность и окна закрываются. */
    public static DataStream<PaymentEvent> streamOf(StreamExecutionEnvironment env, List<PaymentEvent> payments) {
        return env.fromData(payments).assignTimestampsAndWatermarks(
                WatermarkStrategy.<PaymentEvent>forMonotonousTimestamps()
                        .withTimestampAssigner((payment, recordTimestamp) -> payment.eventTimeMs()));
    }

    private Payments() {
    }
}

В streamOf время события берётся из поля платежа, а watermark следует за ним без задержки. Это стратегия forMonotonousTimestamps. Комментарий в коде обещает, что после конца данных watermark уходит в бесконечность и закрывает все окна. Проверим это в исходниках, ведь на этом держится весь тест.

В исходниках

Когда источник дочитал данные, задача источника вызывает этот метод. Комментарий говорит, что он отправляет MAX_WATERMARK, чтобы сработали все таймеры и сбросилось состояние окон.

apache/flink@release-2.2.1 · StreamTask.java, строки 737-747
/**
 * Emits the {@link org.apache.flink.streaming.api.watermark.Watermark#MAX_WATERMARK
 * MAX_WATERMARK} so that all registered timers are fired.
 *
 * <p>This is used by the source task when the job is {@code TERMINATED}. In the case, we want
 * all the timers registered throughout the pipeline to fire and the related state (e.g.
 * windows) to be flushed.
 *
 * <p>For tasks other than the source task, this method does nothing.
 */
protected void advanceToEndOfEventTime() throws Exception {}

Для задачи источника метод переопределён и действительно отправляет Watermark.MAX_WATERMARK, то есть Long.MAX_VALUE.

apache/flink@release-2.2.1 · SourceOperatorStreamTask.java, строки 215-219
@Override
protected void advanceToEndOfEventTime() {
    output.emitWatermark(Watermark.MAX_WATERMARK);
}

Поэтому в тесте последнее окно тоже выдаст итог, хотя платежей после него нет. На стенде с Kafka источник бесконечен, и последнее окно ждёт настоящего watermark.

Теперь сам тест, src/test/java/dev/study/payguard/pipeline/MerchantStatsWindowTest.java.

src/test/java/dev/study/payguard/pipeline/MerchantStatsWindowTest.java
package dev.study.payguard.pipeline;

import static dev.study.payguard.testing.Payments.payment;
import static dev.study.payguard.testing.Payments.streamOf;
import static org.assertj.core.api.Assertions.assertThat;

import dev.study.payguard.model.MerchantStats;
import dev.study.payguard.model.PaymentEvent;
import java.util.List;
import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.test.junit5.MiniClusterExtension;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

class MerchantStatsWindowTest {

    @RegisterExtension
    static final MiniClusterExtension CLUSTER = new MiniClusterExtension(
            new MiniClusterResourceConfiguration.Builder()
                    .setNumberTaskManagers(1)
                    .setNumberSlotsPerTaskManager(2)
                    .build());

    @Test
    void sumsPaymentsPerMerchantPerMinute() throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(2);
        List<PaymentEvent> payments = List.of(
                payment("p1", "c1", "m1", 100, 1_000),
                payment("p2", "c2", "m1", 200, 59_000),
                payment("p3", "c3", "m2", 300, 10_000),
                payment("p4", "c4", "m1", 400, 61_000));

        List<MerchantStats> stats = MerchantStatsWindow.attach(streamOf(env, payments)).executeAndCollect(10);

        assertThat(stats).containsExactlyInAnyOrder(
                new MerchantStats("m1", 0, 60_000, 2, 300),
                new MerchantStats("m2", 0, 60_000, 1, 300),
                new MerchantStats("m1", 60_000, 120_000, 1, 400));
    }
}
КОД
    @RegisterExtension
    static final MiniClusterExtension CLUSTER = new MiniClusterExtension(
            new MiniClusterResourceConfiguration.Builder()
                    .setNumberTaskManagers(1)
                    .setNumberSlotsPerTaskManager(2)
                    .build());

    @Test
    void sumsPaymentsPerMerchantPerMinute() throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(2);
        List<PaymentEvent> payments = List.of(
                payment("p1", "c1", "m1", 100, 1_000),
                payment("p2", "c2", "m1", 200, 59_000),
                payment("p3", "c3", "m2", 300, 10_000),
                payment("p4", "c4", "m1", 400, 61_000));

        List<MerchantStats> stats = MerchantStatsWindow.attach(streamOf(env, payments)).executeAndCollect(10);

        assertThat(stats).containsExactlyInAnyOrder(
                new MerchantStats("m1", 0, 60_000, 2, 300),
                new MerchantStats("m2", 0, 60_000, 1, 300),
                new MerchantStats("m1", 60_000, 120_000, 1, 400));
    }
ПРОСТЫМИ СЛОВАМИ

Расширение JUnit 5 поднимает один MiniCluster на весь класс, с одним TaskManager и двумя слотами. Поднимать кластер на каждый тест было бы долго.

getExecutionEnvironment внутри такого теста сам подключается к MiniCluster, расширение регистрирует для этого тестовое окружение. Параллелизм 2 нужен, чтобы продавцы разошлись по двум подзадачам, как на стенде.

Четыре платежа подобраны под границы. Платежи в 1 с и 59 с попадают в первую минуту m1, платёж в 61 с открывает вторую минуту, m2 живёт отдельно.

executeAndCollect(10) запускает задание и собирает до 10 результатов или до конца данных. Метод заранее создаёт список на столько элементов, сколько указано в лимите, поэтому лимит должен быть небольшим. С Integer.MAX_VALUE тест упадёт с OutOfMemoryError, мы на это наткнулись при подготовке урока 13.

Порядок итогов между подзадачами не определён, поэтому сравнение идёт без учёта порядка. Начало первого окна равно 0, это то же выравнивание по эпохе.

терминал
./gradlew test --tests 'dev.study.payguard.pipeline.MerchantStatsWindowTest'

Флаг --tests запускает только указанный класс. Тест должен пройти. Если вместо этого он падает с текстом Record has Long.MIN_VALUE timestamp, значит, поток пришёл в окно без времени события. Это исключение из assignWindows, которое мы видели в теории, и лечится оно стратегией watermark на источнике.

Шаг 6. Запусти на стенде

Порядок прежний, сначала сборка, потом стенд. Если поднять Compose раньше сборки, Docker смонтирует пустую build/libs. Команда службы kafka-init изменилась, поэтому Compose пересоздаст её и она создаст новые топики. Ключ --if-not-exists не даёт ей споткнуться о старые.

терминал
./gradlew shadowJar
docker compose up -d --wait
docker compose exec jobmanager flink list

Останови задание прошлого урока через flink cancel <JobID>. Его состояние с накопительными итогами новому заданию не нужно. Затем отправь новое и запусти генератор. По умолчанию генератор делает 5 % платежей опоздавшими.

терминал
scripts/submit.sh
./gradlew loadgen --args="--rate 200 --bad 0.01"

Подожди минуты полторы, чтобы watermark закрыл хотя бы одно окно, и прочитай начало топика итогов.

терминал
docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 --topic merchant.stats --from-beginning --max-messages 3 2>/dev/null
вывод
{"merchantId":"m-0000","windowStartMs":1794085500000,"windowEndMs":1794085560000,"paymentCount":632,"amountMinor":16098738}
{"merchantId":"m-0008","windowStartMs":1794085500000,"windowEndMs":1794085560000,"paymentCount":29,"amountMinor":850031}
{"merchantId":"m-0009","windowStartMs":1794085500000,"windowEndMs":1794085560000,"paymentCount":27,"amountMinor":650680}

Границы окна делятся на 60000 без остатка, это выравнивание из getWindowStartWithOffset. Окно неполное, генератор начал писать примерно за 11 секунд до его конца. Даже так у горячего продавца m-0000 632 платежа, а у обычных около 30. Треть потока идёт в одного продавца, так задано в генераторе.

Теперь посмотри на вершину окна через REST. Первая команда находит идентификатор задания, вторая вершину, имя которой начинается с имени оператора окна.

терминал
JOB=$(curl -s localhost:8081/jobs | jq -r '.jobs[] | select(.status == "RUNNING") | .id')
V=$(curl -s localhost:8081/jobs/$JOB | jq -r '.vertices[] | select(.name | startswith("merchant-stats-1m")) | .id')
curl -s "localhost:8081/jobs/$JOB/vertices/$V/subtasks/metrics?get=numRecordsIn,numRecordsOut,busyTimeMsPerSecond,backPressuredTimeMsPerSecond&agg=min,max,sum" | jq -c .
curl -s localhost:8081/jobs/$JOB/vertices/$V/watermarks | jq -c '[.[].value]'
вывод
[{"id":"numRecordsIn","min":7012.0,"max":10592.0,"sum":17604.0},{"id":"numRecordsOut","min":89.0,"max":93.0,"sum":182.0},{"id":"busyTimeMsPerSecond","min":2.0,"max":2.0,"sum":4.0},{"id":"backPressuredTimeMsPerSecond","min":0.0,"max":0.0,"sum":0.0}]
["1794085630855","1794085630855"]

Это числа с нашего стенда. На вход пришло 17604 платежа, и одна подзадача получила в полтора раза больше другой. Горячий продавец целиком живёт в одной подзадаче, перекос ты помнишь по уроку 4.

На выход ушло 182 записи. Как и в уроке 3, numRecordsOut вершины считает записи, отправленные приёмниками Kafka. Watermark равен 1794085630855, значит, сработали окна, которые кончаются в 1794085560000 и 1794085620000. Продавцов 50, так что основных итогов не больше 100. Остальные записи могли появиться только поздними срабатываниями, других путей выдачи у оператора нет. Опоздавшие платежи генератора приходили, когда их минута уже выдала итог, и каждый такой платёж выпускал её итог заново.

Прочитай payments.late той же командой, что и merchant.stats. На нашем стенде топик пуст, и это ожидаемо. Генератор опаздывает максимум на 30 с.

src/main/java/dev/study/payguard/loadgen/PaymentFactory.java (строки 15-15)
    private static final long MAX_LATENESS_MS = 30_000;

Платёж уходит в побочный выход, только когда watermark обогнал его время больше чем на допуск. Watermark отстаёт от самого свежего платежа на 10 с, допуск равен 60 с, поэтому отбросить можно только платёж, опоздавший больше чем на 70 с. Чтобы увидеть побочный выход в работе, временно поставь ALLOWED_LATENESS в ноль и повтори прогон. Потом верни минуту, следующие уроки рассчитывают на неё.

Задание. Сессии карты

Антифроду нужна картина поведения карты. Мошенник проверяет карту серией платежей подряд, а обычный покупатель платит редко. Собери платежи каждой карты в сессии. Сессия продолжается, пока паузы между платежами по времени события короче 30 секунд.

  1. Создай record model/CardSession с полями cardId, sessionStartMs, sessionEndMs, paymentCount, amountMinor.
  2. Создай pipeline/CardSessionWindow с публичной константой паузы и методом attach(DataStream<PaymentEvent>), по образцу MerchantStatsWindow. Оператору дай имя и uid card-sessions.
  3. В состоянии сессии не должны копиться сами платежи.
  4. Подключи окно в PayGuardJob и пиши результат в топик card.sessions приёмником с именем и uid card-sessions-sink.
  5. Напиши CardSessionWindowTest на MiniCluster. Два платежа одной карты с паузой меньше 30 с и третий после длинной паузы должны дать ровно две сессии.

Готово, если выполнены все условия.

  1. ./gradlew test проходит.
  2. В графе задания на стенде появилась третья вершина card-sessions.
  3. В card.sessions идут записи, и у сессии из одного платежа sessionEndMs больше sessionStartMs ровно на 30000.

Подсказка первая. Посмотри, какой из классов практики можно взять без изменений, и вспомни, какой его метод впервые начнёт вызываться.

Подсказка вторая. Прежде чем писать ожидания в тесте, посчитай границы сессий вручную. Каждый платёж открывает окно от своего времени до времени плюс пауза, а пересекающиеся окна сливаются.

Ответ

Глоссарий урока

Окно (window)
Ограниченный отрезок потока для одного ключа, по которому считается результат. Во Flink это часть keyed state с собственными таймерами.
Tumbling окно
Окна одной длины, которые идут встык и не пересекаются. Каждая запись попадает ровно в одно окно.
Sliding окно
Окна длины size, новое начинается каждые slide. Окна пересекаются, и запись попадает в size / slide окон.
Сессионное окно (session window)
Окно без заранее известных границ. Закрывается, когда между записями ключа проходит пауза дольше заданной.
Global окно
Одно окно на ключ на всё время работы. По умолчанию не срабатывает никогда, требует своего trigger.
Assigner (WindowAssigner)
Часть оконного оператора, которая по времени записи решает, в какие окна она попадает.
Trigger
Часть оконного оператора, которая решает, когда выдать результат окна и очистить ли его содержимое.
Evictor
Необязательная часть оконного оператора, которая убирает записи из окна до или после вызова функции. Отключает предварительное сложение.
AggregateFunction
Функция окна, которая складывает записи в аккумулятор по одной. В состоянии хранит только аккумулятор.
Аккумулятор (accumulator)
Промежуточный итог AggregateFunction, например счётчик и сумма. Хранится в состоянии вместо самих записей.
ProcessWindowFunction
Функция окна, которая получает все записи окна разом и контекст с его границами. Без AggregateFunction хранит все записи окна.
ReduceFunction
Функция, которая сворачивает две записи одного типа в одну. Как и AggregateFunction, считает по мере прихода записей.
allowedLateness
Сколько времени окно живёт в состоянии после срабатывания, принимая опоздавшие записи. По умолчанию ноль.
Позднее срабатывание (late firing)
Повторная выдача итога окна, вызванная опоздавшей записью в пределах allowedLateness. Содержит полный обновлённый итог.
Побочный выход (side output)
Дополнительный выход оператора, помеченный тегом OutputTag. У окна туда уходят записи, которые не приняло ни одно окно.
Watermark
Отметка в потоке, которая утверждает, что событий старше этого времени больше не ждём. Двигает время событий в операторах.
Время события (event time)
Момент, когда событие случилось у источника, например время платежа. Противопоставляется времени обработки на сервере.
Таймер (timer)
Отложенный вызов оператора для конкретного ключа и окна. Таймер по времени события срабатывает, когда его догоняет watermark.
Namespace состояния
Второй уровень адресации keyed state после ключа. Оконный оператор использует окно как namespace, поэтому у ключа может быть много окон сразу.
keyBy и keyed state
keyBy раскладывает поток по ключу, после чего каждый ключ получает своё состояние в одной подзадаче.
MiniCluster
Полноценный Flink внутри JVM теста. Позволяет проверить кусок конвейера с таймерами и состоянием без Docker.
Upsert
Запись по ключу, которая заменяет прежнее значение, если оно есть, и добавляет новое, если нет.
Apache Kafka
Распределённый журнал сообщений. Наше задание читает платежи из Kafka и пишет туда итоги окон.
Spark Structured Streaming
Потоковый движок Apache Spark. Окна задаёт функциями window и session_window, а порог опоздания через withWatermark.

Урок 7 · Время и состояние

Состояние с ключом, таймеры и TTL

После урока ты напишешь правило антифрода на KeyedProcessFunction, которое помнит историю каждой карты, само чистит старые данные таймерами и TTL и не раздувает состояние.

Карта, которую надо помнить

Мошенник проверяет украденную карту серией из двадцати мелких платежей за полминуты. Правило частоты должно сказать «стоп» на одиннадцатом. Для этого оператору нужно знать, сколько платежей эта карта сделала за последнюю минуту. Окна из урока 6 выдают итог в конце интервала, а здесь ответ нужен на каждом платеже.

Вторая беда в дубликатах. Продюсер Kafka после таймаута может отправить платёж повторно, и без защиты один платёж посчитается дважды во всех итогах. Чтобы выбросить повтор, надо помнить идентификаторы уже виденных платежей. За час при 200 платежах в секунду это 720 000 идентификаторов.

Первая мысль Java-разработчика здесь в том, чтобы положить счётчики в Redis. Тогда каждый платёж превращается в сетевой запрос, а после падения задания счётчики в Redis уже учли платежи, которые Flink прочитает из Kafka заново. Flink решает задачу иначе. Он хранит состояние рядом с оператором, раскладывает его по ключам и включает в снимок вместе с позицией чтения источника. Снимкам посвящён урок 11, а сегодня разберём, как это состояние устроено и как не дать ему расти бесконечно.

Стойка регистрации в гостинице

Представь стойку регистрации большой гостиницы. За ней картотека, в которой на каждого гостя заведена своя карточка. Когда гость подходит к стойке, администратор достаёт именно его карточку и видит только её. Записать что-то в чужую карточку, пока перед ним стоит этот гость, он не может.

Рядом висит доска побудок. Гость из 412 номера просит разбудить его в 7:00, администратор пишет на доске «412, 7:00». Если гость попросит о том же ещё раз, вторая запись не появится, на доске уже есть ровно такая. Когда часы показывают 7:00, администратор берёт карточку гостя из 412 номера и звонит ему.

Раз в сутки архивариус выбрасывает карточки гостей, которые не появлялись дольше года. Он смотрит на настенные часы и на дату последней записи в карточке, ему всё равно, о каких событиях в ней идёт речь.

Во Flink картотека это состояние с ключом (keyed state), гость это ключ, администратор это твоя функция, которая в каждый момент видит состояние только текущего ключа. Доска побудок это таймеры, а архивариус это TTL. Дальше разберём каждую часть по коду.

Четыре вида состояния

Состояние с ключом доступно только после keyBy. Функция получает его через дескриптор, в котором записаны имя состояния и тип значения. Имя должно быть уникальным внутри оператора, по нему Flink находит состояние при восстановлении из снимка.

ВидЧто хранит для одного ключаГде пригодится в PayGuard
ValueState<T>Одно значение, методы value() и update()Флаг «этот платёж уже был», время последнего алерта
MapState<K, V>Словарь, методы get, put, entries, keysСчётчики платежей по корзинам времени
ListState<T>Список, методы add и getПоследние N платежей карты, если нужны сами платежи
ReducingState<T>Одно значение, которое ReduceFunction сворачивает при каждом addСумма платежей карты за всё время

Есть и пятый вид, AggregatingState. Он устроен как ReducingState, но тип накопленного значения может отличаться от типа входа, как у AggregateFunction из урока 6. У каждого ключа может быть сколько угодно состояний разных видов, в правиле частоты их два.

Откуда состояние знает ключ

В коде функции нет ни одного упоминания карты. Ты пишешь seen.value(), и Flink сам возвращает значение для того платежа, который сейчас обрабатывается. Это работает потому, что перед каждым вызовом функции оператор запоминает текущий ключ.

В исходниках

Задача берёт обработчик записи у этой утилиты. Если у оператора есть ключ, обработчик сначала вызывает setKeyContextElement и только потом processElement.

apache/flink@release-2.2.1 · RecordProcessorUtils.java, строки 56-66
if (canOmitSetKeyContext) {
    return input::processElement;
} else if (input instanceof AsyncKeyOrderedProcessing
        && ((AsyncKeyOrderedProcessing) input).isAsyncKeyOrderedProcessingEnabled()) {
    return ((AsyncKeyOrderedProcessing) input).getRecordProcessor(1);
} else {
    return record -> {
        input.setKeyContextElement(record);
        input.processElement(record);
    };
}

Сам setKeyContextElement применяет к записи тот же селектор ключа, что ты передал в keyBy, и делает результат текущим ключом бэкенда состояния.

apache/flink@release-2.2.1 · AbstractStreamOperator.java, строки 575-585
private <T> void setKeyContextElement(StreamRecord<T> record, KeySelector<T, ?> selector)
        throws Exception {
    if (selector != null) {
        Object key = selector.getKey(record.getValue());
        setCurrentKey(key);
    }
}

public void setCurrentKey(Object key) {
    stateHandler.setCurrentKey(key);
}

Отсюда следует правило, о котором часто забывают. Ключ вычисляется заново в принимающем операторе, поэтому селектор обязан быть детерминированным. Если он вернёт для того же платежа другое значение, запись придёт в одну подзадачу, а искать состояние будет по чужому ключу.

Где лежат значения

Объект ValueState в твоей функции ничего не хранит, это окно в бэкенд состояния (state backend). Бэкенд выбирается настройкой state.backend.type, и если её нет, работает HashMapStateBackend. В нашем docker-compose.yml настройки нет, значит, всё состояние PayGuard сейчас живёт в куче JVM TaskManager.

HashMapStateBackend, куча JVM группа 17 группа 18 группа 19 (c-1764, VoidNamespace)→ HashMap<Long, Long> (c-4262, VoidNamespace) чтение без сериализации, размер ограничен кучей RocksDB, локальный диск [группа | c-1764 | namespace] байты значения [группа | c-4262 | namespace] байты значения каждое чтение сериализует ключ и разбирает байты

В исходниках

У HashMapStateBackend состояние лежит в StateTable. Комментарий к полю говорит, что внешний массив разбит по группам ключей. Группы ключей ты знаешь по уроку 4, это единица, которую Flink переносит между подзадачами при смене параллелизма.

apache/flink@release-2.2.1 · StateTable.java, строки 74-78
/**
 * Map for holding the actual state objects. The outer array represents the key-groups. All
 * array positions will be initialized with an empty state map.
 */

Метод value() обращается к таблице только по namespace. Ключ и его группу таблица берёт из контекста, то есть из того самого текущего ключа.

apache/flink@release-2.2.1 · HeapValueState.java, строки 70-90
@Override
public V value() {
    final V result = stateTable.get(currentNamespace);

    if (result == null) {
        return getDefaultValue();
    }

    return result;
}

@Override
public void update(V value) {

    if (value == null) {
        clear();
        return;
    }

    stateTable.put(currentNamespace, value);
}
apache/flink@release-2.2.1 · StateTable.java, строки 138-147
/**
 * Returns the state of the mapping for the composite of active key and given namespace.
 *
 * @param namespace the namespace. Not null.
 * @return the states of the mapping with the specified key/namespace composite key, or {@code
 *     null} if no mapping for the specified key is found.
 */
public S get(N namespace) {
    return get(keyContext.getCurrentKey(), keyContext.getCurrentKeyGroupIndex(), namespace);
}

У RocksDB то же самое сделано байтами. Текущий ключ, его группа и namespace сериализуются в ключ RocksDB, а значение каждый раз разбирается сериализатором. Поэтому чтение из RocksDB дороже, зато состояние может быть больше памяти.

apache/flink@release-2.2.1 · RocksDBValueState.java, строки 78-92
@Override
public V value() throws IOException {
    try {
        byte[] valueBytes =
                backend.db.get(columnFamily, serializeCurrentKeyWithGroupAndNamespace());

        if (valueBytes == null) {
            return getDefaultValue();
        }
        dataInputView.setBuffer(valueBytes);
        return valueSerializer.deserialize(dataInputView);
    } catch (RocksDBException e) {
        throw new IOException("Error while retrieving data from RocksDB.", e);
    }
}

Namespace в правилах этого урока всегда пустой, VoidNamespace. Окна из урока 6 кладут туда само окно, поэтому у одного ключа может быть несколько окон с отдельным состоянием.

Для сравнения бэкендов в документации есть простое правило. HashMapStateBackend быстрее, потому что работает с объектами в куче, но всё состояние должно поместиться в память кластера. RocksDB медленнее на каждом обращении и держит столько, сколько влезет на диск. Ещё одно следствие из кода. В куче лежат живые объекты, поэтому менять объект, полученный из value(), без update() опасно. На heap он изменится в состоянии, на RocksDB нет, и задание поведёт себя по-разному на разных бэкендах.

KeyedProcessFunction и таймеры

KeyedProcessFunction это самый низкий уровень DataStream API. У неё два метода. processElement вызывается на каждую запись, onTimer вызывается, когда срабатывает таймер. Оба видят состояние текущего ключа и могут выдавать результат через Collector.

Таймер регистрируется через context.timerService() на конкретный момент. Таймер по времени события срабатывает, когда watermark дошёл до его времени или прошёл дальше. Таймер по времени обработки срабатывает по настенным часам TaskManager. Правило частоты PayGuard использует время события, потому что минута должна считаться по времени платежа.

Ниже одна подзадача правила частоты. Каждый платёж добавляется в корзину своих 10 секунд и регистрирует таймер на конец корзины плюс минуту. Сделай серию по c-1, потом несколько раз нажми «Прошло 10 с» и заплати картой c-2.

Корзины и таймеры двух карт в одной подзадаче

Обрати внимание на две вещи. Двенадцать платежей серии создали один таймер, потому что все они попали в одну корзину. И таймеры карты c-1 срабатывают от платежа карты c-2. Watermark один на всю подзадачу, он не принадлежит ключу, и любая запись может продвинуть его и разбудить таймеры всех ключей.

В исходниках

Таймер запоминает текущий ключ в момент регистрации. Время, ключ и namespace вместе образуют таймер.

apache/flink@release-2.2.1 · InternalTimerServiceImpl.java, строки 248-252
@Override
public void registerEventTimeTimer(N namespace, long time) {
    eventTimeTimersQueue.add(
            new TimerHeapInternalTimer<>(time, (K) keyContext.getCurrentKey(), namespace));
}

Очередь таймеров в куче устроена как множество. Повторное добавление такого же таймера ничего не меняет, именно поэтому серия дала один таймер.

apache/flink@release-2.2.1 · HeapPriorityQueueSet.java, строки 121-124
public boolean add(@Nonnull T element) {
    return getDedupMapForElement(element).putIfAbsent(element, element) == null
            && super.add(element);
}

Когда приходит watermark, сервис достаёт таймеры по порядку, пока их время не больше watermark. Перед каждым вызовом он делает ключ таймера текущим, поэтому в onTimer состояние снова принадлежит нужной карте.

apache/flink@release-2.2.1 · InternalTimerServiceImpl.java, строки 328-347
public boolean tryAdvanceWatermark(
        long time, InternalTimeServiceManager.ShouldStopAdvancingFn shouldStopAdvancingFn)
        throws Exception {
    currentWatermark = time;
    InternalTimer<K, N> timer;
    boolean interrupted = false;
    while ((timer = eventTimeTimersQueue.peek()) != null
            && timer.getTimestamp() <= time
            && !cancellationContext.isCancelled()
            && !interrupted) {
        keyContext.setCurrentKey(timer.getKey());
        eventTimeTimersQueue.poll();
        triggerTarget.onEventTime(timer);
        taskIOMetricGroup.getNumFiredTimers().inc();
        // Check if we should stop advancing after at least one iteration to guarantee progress
        // and prevent a potential starvation.
        interrupted = shouldStopAdvancingFn.test();
    }
    return !interrupted;
}

Оператор, в который Flink оборачивает твою функцию, получает этот вызов и передаёт его в onTimer. Рядом видно, что processElement вызывается прямо в потоке задачи. Методы никогда не работают одновременно, и синхронизация состояния не нужна.

apache/flink@release-2.2.1 · KeyedProcessOperator.java, строки 67-96
public void onEventTime(InternalTimer<K, VoidNamespace> timer) throws Exception {
    collector.setAbsoluteTimestamp(timer.getTimestamp());
    invokeUserFunction(TimeDomain.EVENT_TIME, timer);
}

@Override
public void processWatermark(WatermarkEvent watermark) throws Exception {
    output.emitWatermark(watermark);
}

@Override
public void onProcessingTime(InternalTimer<K, VoidNamespace> timer) throws Exception {
    collector.eraseTimestamp();
    invokeUserFunction(TimeDomain.PROCESSING_TIME, timer);
}

@Override
public void processElement(StreamRecord<IN> element) throws Exception {
    collector.setTimestamp(element);
    context.element = element;
    userFunction.processElement(element.getValue(), context, collector);
    context.element = null;
}

private void invokeUserFunction(TimeDomain timeDomain, InternalTimer<K, VoidNamespace> timer)
        throws Exception {
    onTimerContext.timeDomain = timeDomain;
    onTimerContext.timer = timer;
    userFunction.onTimer(timer.getTimestamp(), onTimerContext, collector);
    onTimerContext.timeDomain = null;

Документация называет приём с корзиной «timer coalescing». Таймер один на ключ и момент, поэтому округление времени таймера уменьшает их число. Таймеры тоже входят в снимок, и миллион лишних таймеров удлиняет чекпоинт.

TTL, срок хранения

Таймер нужен, когда момент очистки зависит от логики, например от времени события. Если достаточно «забыть ключ через час после записи», проще включить TTL. Это настройка дескриптора, а Flink сам перестанет отдавать просроченное значение и со временем удалит его.

В исходниках

Первое, что видно в StateTtlConfig, это единственная временная шкала. TTL считается только по времени обработки.

apache/flink@release-2.2.1 · StateTtlConfig.java, строки 84-89
/** This option configures time scale to use for ttl. */
@PublicEvolving
public enum TtlTimeCharacteristic {
    /** Processing time. */
    ProcessingTime
}

Значения по умолчанию в построителе. Срок продлевается при создании и при записи, чтение его не продлевает. Просроченное значение не возвращается никогда, даже если физически ещё лежит в бэкенде. Фоновая очистка включена.

apache/flink@release-2.2.1 · StateTtlConfig.java, строки 153-165
public static Builder newBuilder(Duration ttl) {
    return new Builder(ttl);
}

/** Builder for the {@link StateTtlConfig}. */
@PublicEvolving
public static class Builder {

    private UpdateType updateType = OnCreateAndWrite;
    private StateVisibility stateVisibility = NeverReturnExpired;
    private TtlTimeCharacteristic ttlTimeCharacteristic = ProcessingTime;
    private Duration ttl;
    private boolean isCleanupInBackground = true;

При чтении обёртка TTL проверяет метку времени. Просроченное значение удаляется прямо здесь, и функция получает null.

apache/flink@release-2.2.1 · AbstractTtlDecorator.java, строки 92-110
public <SE extends Throwable, CE extends Throwable, CLE extends Throwable, V>
        TtlValue<V> getWrappedWithTtlCheckAndUpdate(
                SupplierWithException<TtlValue<V>, SE> getter,
                ThrowingConsumer<TtlValue<V>, CE> updater,
                ThrowingRunnable<CLE> stateClear)
                throws SE, CE, CLE {
    TtlValue<V> ttlValue = getter.get();
    if (ttlValue == null) {
        return null;
    } else if (expired(ttlValue)) {
        stateClear.run();
        if (!returnExpired) {
            return null;
        }
    } else if (updateTsOnRead) {
        updater.accept(rewrapWithNewTs(ttlValue));
    }
    return ttlValue;
}

Документация добавляет то, чего не видно в этих строках. Фоновая очистка в куче по умолчанию проверяет 5 записей при каждом обращении к состоянию, у RocksDB её делает фильтр компакции. Если к состоянию никто не обращается, просроченные значения так и лежат. Цена TTL в памяти это дополнительный объект с меткой времени на каждое значение в куче и 8 байт на значение в RocksDB.

⚠
TTL живёт по настенным часам

Если задание отстало на два часа и догоняет Kafka, платежи двухчасовой давности пролетают за минуты. Для TTL между ними прошли минуты, и дубликат будет пойман. В обратную сторону это тоже работает. Повтор, который пришёл через 61 минуту по часам TaskManager, пройдёт, даже если по времени события он рядом с оригиналом. Когда логика привязана к времени события, используй таймер.

Кардинальность ключей

Размер состояния с ключом равен числу ключей, умноженному на размер состояния одного ключа. Оба множителя растут незаметно.

Первый множитель задаёт поле в keyBy. Карт в генераторе 5000, и состояние правила частоты ограничено этим числом. Дедупликация разложена по paymentId, и каждый платёж там новый ключ. Без TTL состояние дедупликации растёт вечно, с TTL в час оно держится около «скорость умножить на час», то есть 720 000 ключей при 200 платежах в секунду. Прежде чем писать keyBy, спроси себя, сколько разных значений будет у поля через месяц.

Второй множитель зависит от того, что ты хранишь на ключ. Самое простое правило частоты держит в ListState все платежи карты за минуту и на каждом платеже пересчитывает список. Под атакой это сотни объектов на карту. PayGuard хранит корзины, то есть в MapState лежит «начало 10-секундного интервала → число платежей». Корзин на карту не больше семи, сколько бы платежей ни было.

У корзин есть цена, это точность. Правило берёт корзину целиком, если её конец позже начала минуты. Платёж, сделанный 65 секунд назад, может попасть в счёт, и окно фактически плавает от 60 до 70 секунд. Для антифрода это нормально. Если нужна точность до секунды, уменьши корзину, и таймеров станет больше.

В исходниках

Деталь, которая влияет на кардинальность в куче. MapState.remove у HeapMapState удаляет запись ключа целиком, когда словарь опустел.

apache/flink@release-2.2.1 · HeapMapState.java, строки 124-136
public void remove(UK userKey) {

    Map<UK, UV> userMap = stateTable.get(currentNamespace);
    if (userMap == null) {
        return;
    }

    userMap.remove(userKey);

    if (userMap.isEmpty()) {
        clear();
    }
}

А keys() отдаёт представление самого словаря. Удаление через его итератор убирает элемент, но пустой HashMap остаётся в таблице состояния.

apache/flink@release-2.2.1 · HeapMapState.java, строки 151-154
public Iterable<UK> keys() {
    Map<UK, UV> userMap = stateTable.get(currentNamespace);
    return userMap == null ? Collections.emptySet() : userMap.keySet();
}

Наш BucketedSum удаляет корзины именно итератором. Для 5000 карт пустые словари ничего не стоят. При миллионах ключей после очистки стоит проверить isEmpty() и вызвать clear().

Сравнение со Spark

В Spark Structured Streaming похожую задачу решают mapGroupsWithState и flatMapGroupsWithState. У каждой группы там один объект состояния GroupState и один тайм-аут, по времени обработки или по watermark. Функция вызывается раз в micro-batch со всеми новыми записями группы, и тайм-аут тоже обрабатывается внутри очередного micro-batch. В Spark 4.0 появился transformWithState с несколькими переменными состояния, таймерами и TTL, он ближе к модели Flink.

Во Flink у ключа сколько угодно именованных состояний и таймеров. Функция вызывается на каждую запись сразу, таймер срабатывает в момент, когда watermark прошёл его время. Цена этой свободы в том, что очистку ты проектируешь сам, и забытая очистка превращается в состояние, которое растёт месяцами.

Проверь себя

Коллега убрал TTL из DeduplicateFunction со словами «карт всего 5000, состояние маленькое». Что будет через месяц при 200 платежах в секунду?

По карте прошло 6 платежей на 2-й секунде и 5 платежей на 65-й. Корзина 10 с, окно минута, лимит 10. Будет ли алерт на последнем платеже?

Задание два часа стояло под backpressure и теперь догоняет Kafka. Повтор платежа лежит в топике через 70 минут после оригинала по времени события. TTL дедупликации 1 час. Что произойдёт?

Что будет в конце

Сразу после разбора платежей стоит дедупликация, и окна из урока 6 считают уже очищенный поток. Платежи, разложенные по карте, проверяет правило частоты, а его алерты уходят в топик fraud.alerts. Генератор умеет устраивать серии платежей по одной карте, чтобы правилу было что ловить. Обе функции проверены тестами на harness. Файлы правила суммы из задания пока создавать не нужно. Новых зависимостей нет, build.gradle.kts и docker-compose.yml не меняются, топик fraud.alerts служба kafka-init создаёт с урока 6.

Шаг 1. Модель алерта

Создай src/main/java/dev/study/payguard/model/FraudAlert.java. Идентификатор алерта собирается из правила и платежа. Если после сбоя Flink обработает тот же платёж повторно, алерт получит тот же alertId, и потребитель сможет отбросить повтор.

src/main/java/dev/study/payguard/model/FraudAlert.java
package dev.study.payguard.model;

/** Сигнал о подозрительной активности. Идентификатор детерминирован: повторная обработка даёт тот же алерт. */
public record FraudAlert(
        String alertId,
        String ruleId,
        String cardId,
        String merchantId,
        String paymentId,
        String detail,
        long eventTimeMs) {

    public static FraudAlert of(String ruleId, PaymentEvent trigger, String detail) {
        return new FraudAlert(
                ruleId + ":" + trigger.paymentId(),
                ruleId,
                trigger.cardId(),
                trigger.merchantId(),
                trigger.paymentId(),
                detail,
                trigger.eventTimeMs());
    }
}

Шаг 2. Дедупликация

Создай src/main/java/dev/study/payguard/pipeline/DeduplicateFunction.java. Функция разложена по paymentId, поэтому её состояние это один флаг на платёж.

src/main/java/dev/study/payguard/pipeline/DeduplicateFunction.java
package dev.study.payguard.pipeline;

import dev.study.payguard.model.PaymentEvent;
import java.io.IOException;
import java.time.Duration;
import org.apache.flink.api.common.functions.OpenContext;
import org.apache.flink.api.common.state.StateTtlConfig;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;

/** Пропускает только первый платёж с данным paymentId. Память о платеже живёт {@link #REMEMBER_FOR}. */
public class DeduplicateFunction extends KeyedProcessFunction<String, PaymentEvent, PaymentEvent> {

    public static final Duration REMEMBER_FOR = Duration.ofHours(1);

    private transient ValueState<Boolean> seen;

    @Override
    public void open(OpenContext context) {
        ValueStateDescriptor<Boolean> descriptor = new ValueStateDescriptor<>("seen", Boolean.class);
        descriptor.enableTimeToLive(StateTtlConfig.newBuilder(REMEMBER_FOR).build());
        seen = getRuntimeContext().getState(descriptor);
    }

    @Override
    public void processElement(PaymentEvent payment, Context context, Collector<PaymentEvent> out) throws IOException {
        if (seen.value() == null) {
            seen.update(true);
            out.collect(payment);
        }
    }
}
КОД
public class DeduplicateFunction extends KeyedProcessFunction<String, PaymentEvent, PaymentEvent> {

    public static final Duration REMEMBER_FOR = Duration.ofHours(1);

    private transient ValueState<Boolean> seen;

    @Override
    public void open(OpenContext context) {
        ValueStateDescriptor<Boolean> descriptor = new ValueStateDescriptor<>("seen", Boolean.class);
        descriptor.enableTimeToLive(StateTtlConfig.newBuilder(REMEMBER_FOR).build());
        seen = getRuntimeContext().getState(descriptor);
    }

    @Override
    public void processElement(PaymentEvent payment, Context context, Collector<PaymentEvent> out) throws IOException {
        if (seen.value() == null) {
            seen.update(true);
            out.collect(payment);
        }
    }
ПРОСТЫМИ СЛОВАМИ

Три параметра типа означают тип ключа, вход и выход. Ключ здесь строка, это paymentId.

Срок памяти вынесен в публичную константу. Тест сошлётся на неё и не разойдётся с кодом.

Поле состояния помечено transient. Сама функция сериализуется и едет на TaskManager, а объект состояния создаётся уже там.

open вызывается один раз на подзадачу перед первой записью. Дескриптор называет состояние seen и задаёт тип значения.

enableTimeToLive включает TTL с настройками по умолчанию. Срок отсчитывается от записи флага, чтения его не продлевают.

getState возвращает доступ к состоянию. Один и тот же объект служит всем ключам подзадачи.

Для нового платежа value() вернёт null. Ставим флаг и пропускаем платёж дальше.

Для повтора флаг уже стоит, и функция просто ничего не выдаёт.

Шаг 3. Корзины

Создай src/main/java/dev/study/payguard/pipeline/BucketedSum.java. Это обычный класс поверх MapState. Он пригодится двум правилам, поэтому логика корзин вынесена из функции. Видимость пакетная, снаружи пакета pipeline он никому не нужен.

src/main/java/dev/study/payguard/pipeline/BucketedSum.java
package dev.study.payguard.pipeline;

import java.time.Duration;
import java.util.Iterator;
import java.util.Map;
import org.apache.flink.api.common.state.MapState;

/** Сумма за скользящий интервал, разбитая на корзины: хранит несколько чисел вместо всех событий. */
final class BucketedSum {

    private final MapState<Long, Long> buckets;
    private final long bucketMs;

    BucketedSum(MapState<Long, Long> buckets, Duration bucketSize) {
        this.buckets = buckets;
        this.bucketMs = bucketSize.toMillis();
    }

    void add(long eventTimeMs, long value) throws Exception {
        long bucket = bucketStart(eventTimeMs);
        Long current = buckets.get(bucket);
        buckets.put(bucket, (current == null ? 0 : current) + value);
    }

    long sumSince(long fromMs) throws Exception {
        long sum = 0;
        for (Map.Entry<Long, Long> entry : buckets.entries()) {
            if (entry.getKey() + bucketMs > fromMs) {
                sum += entry.getValue();
            }
        }
        return sum;
    }

    void evictBefore(long fromMs) throws Exception {
        Iterator<Long> keys = buckets.keys().iterator();
        while (keys.hasNext()) {
            if (keys.next() + bucketMs <= fromMs) {
                keys.remove();
            }
        }
    }

    long bucketEnd(long eventTimeMs) {
        return bucketStart(eventTimeMs) + bucketMs;
    }

    private long bucketStart(long eventTimeMs) {
        return eventTimeMs - eventTimeMs % bucketMs;
    }
}
КОД
    void add(long eventTimeMs, long value) throws Exception {
        long bucket = bucketStart(eventTimeMs);
        Long current = buckets.get(bucket);
        buckets.put(bucket, (current == null ? 0 : current) + value);
    }

    long sumSince(long fromMs) throws Exception {
        long sum = 0;
        for (Map.Entry<Long, Long> entry : buckets.entries()) {
            if (entry.getKey() + bucketMs > fromMs) {
                sum += entry.getValue();
            }
        }
        return sum;
    }

    void evictBefore(long fromMs) throws Exception {
        Iterator<Long> keys = buckets.keys().iterator();
        while (keys.hasNext()) {
            if (keys.next() + bucketMs <= fromMs) {
                keys.remove();
            }
        }
    }

    long bucketEnd(long eventTimeMs) {
        return bucketStart(eventTimeMs) + bucketMs;
    }

    private long bucketStart(long eventTimeMs) {
        return eventTimeMs - eventTimeMs % bucketMs;
    }
ПРОСТЫМИ СЛОВАМИ

add находит начало корзины и прибавляет значение. Для правила частоты значение равно единице, для правила суммы это сумма платежа.

sumSince складывает корзины, которые кончаются позже заданного момента. Корзина, задевшая границу, берётся целиком, отсюда плавающая минута из теории.

evictBefore удаляет корзины, которые целиком лежат раньше заданного момента. Удаление идёт через итератор ключей.

bucketEnd нужен правилу, чтобы поставить таймер на конец корзины. Так все платежи одной корзины делят один таймер.

Начало корзины получается отбрасыванием остатка от деления. Корзины выровнены по началу эпохи, как окна в уроке 6.

Шаг 4. Правило частоты

Создай src/main/java/dev/study/payguard/pipeline/VelocityRule.java. Лимит приходит через конструктор, поэтому его можно менять параметром запуска без пересборки.

src/main/java/dev/study/payguard/pipeline/VelocityRule.java
package dev.study.payguard.pipeline;

import dev.study.payguard.model.FraudAlert;
import dev.study.payguard.model.PaymentEvent;
import java.time.Duration;
import org.apache.flink.api.common.functions.OpenContext;
import org.apache.flink.api.common.state.MapStateDescriptor;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;

/** Правило частоты: слишком много платежей по одной карте за скользящую минуту. Ключ потока это cardId. */
public class VelocityRule extends KeyedProcessFunction<String, PaymentEvent, FraudAlert> {

    public static final String RULE_ID = "velocity";
    private static final Duration WINDOW = Duration.ofMinutes(1);
    private static final Duration BUCKET = Duration.ofSeconds(10);

    private final int maxPayments;
    private transient BucketedSum counts;
    private transient ValueState<Long> lastAlertAtMs;

    public VelocityRule(int maxPayments) {
        this.maxPayments = maxPayments;
    }

    @Override
    public void open(OpenContext context) {
        counts = new BucketedSum(getRuntimeContext().getMapState(
                new MapStateDescriptor<>("payment-counts", Long.class, Long.class)), BUCKET);
        lastAlertAtMs = getRuntimeContext().getState(new ValueStateDescriptor<>("last-alert-at", Long.class));
    }

    @Override
    public void processElement(PaymentEvent payment, Context context, Collector<FraudAlert> out) throws Exception {
        long eventTime = payment.eventTimeMs();
        counts.add(eventTime, 1);
        context.timerService().registerEventTimeTimer(counts.bucketEnd(eventTime) + WINDOW.toMillis());

        long recentPayments = counts.sumSince(eventTime - WINDOW.toMillis());
        if (recentPayments > maxPayments && alertIsDue(eventTime)) {
            lastAlertAtMs.update(eventTime);
            out.collect(FraudAlert.of(RULE_ID, payment, recentPayments + " платежей за минуту"));
        }
    }

    @Override
    public void onTimer(long timestamp, OnTimerContext context, Collector<FraudAlert> out) throws Exception {
        counts.evictBefore(timestamp - WINDOW.toMillis());
    }

    private boolean alertIsDue(long eventTime) throws Exception {
        Long last = lastAlertAtMs.value();
        return last == null || eventTime - last >= WINDOW.toMillis();
    }
}
КОД
    @Override
    public void open(OpenContext context) {
        counts = new BucketedSum(getRuntimeContext().getMapState(
                new MapStateDescriptor<>("payment-counts", Long.class, Long.class)), BUCKET);
        lastAlertAtMs = getRuntimeContext().getState(new ValueStateDescriptor<>("last-alert-at", Long.class));
    }

    @Override
    public void processElement(PaymentEvent payment, Context context, Collector<FraudAlert> out) throws Exception {
        long eventTime = payment.eventTimeMs();
        counts.add(eventTime, 1);
        context.timerService().registerEventTimeTimer(counts.bucketEnd(eventTime) + WINDOW.toMillis());

        long recentPayments = counts.sumSince(eventTime - WINDOW.toMillis());
        if (recentPayments > maxPayments && alertIsDue(eventTime)) {
            lastAlertAtMs.update(eventTime);
            out.collect(FraudAlert.of(RULE_ID, payment, recentPayments + " платежей за минуту"));
        }
    }

    @Override
    public void onTimer(long timestamp, OnTimerContext context, Collector<FraudAlert> out) throws Exception {
        counts.evictBefore(timestamp - WINDOW.toMillis());
    }

    private boolean alertIsDue(long eventTime) throws Exception {
        Long last = lastAlertAtMs.value();
        return last == null || eventTime - last >= WINDOW.toMillis();
    }
ПРОСТЫМИ СЛОВАМИ

В open создаются два состояния. payment-counts хранит корзины, last-alert-at хранит время последнего алерта по карте.

На каждый платёж счётчик его корзины растёт на единицу.

Таймер ставится на конец корзины плюс минута. В этот момент корзина гарантированно выпала из окна любого будущего платежа, если он не опоздал сильнее watermark.

Правило считает платежи за минуту до текущего. Если их больше лимита и алерт давно не выдавался, выдаём алерт и запоминаем его время.

onTimer вызывается, когда watermark дошёл до времени таймера. Он удаляет корзины, которые кончились минуту назад и раньше.

alertIsDue не даёт выдать больше одного алерта в минуту на карту. Без него серия из 25 платежей дала бы 15 алертов подряд.

У last-alert-at нет ни TTL, ни таймера. Значение остаётся у каждой карты, по которой хоть раз был алерт. Карт в нашем генераторе 5000, и это сознательное упрощение. В проде с миллионами карт сюда стоит добавить TTL.

Шаг 5. Настройки и задание

В JobConfig появляются топик алертов и лимиты правил. Поле лимита суммы пригодится в задании, заведи его сразу, чтобы не возвращаться к файлу.

что изменилось в src/main/java/dev/study/payguard/job/JobConfig.java
@@ -11,6 +11,9 @@         String lateTopic,
         String merchantStatsTopic,
         String cardSessionsTopic,
+        String alertsTopic,+        int velocityMaxPayments,+        long amountSpikeLimitMinor,         String groupId) implements Serializable {
 
     public static JobConfig fromArgs(String[] args) {
@@ -22,6 +25,9 @@                 params.get("late-topic", "payments.late"),
                 params.get("merchant-stats-topic", "merchant.stats"),
                 params.get("card-sessions-topic", "card.sessions"),
+                params.get("alerts-topic", "fraud.alerts"),+                params.getInt("velocity-max-payments", 10),+                params.getLong("amount-spike-limit-minor", 2_000_000),                 params.get("group-id", "payguard"));
     }
 }

В PayGuardJob дедупликация встаёт в общую цепочку сразу после пробы опозданий. Метод parsePayments теперь возвращает очищенный поток, и окна продавцов и сессий карт получают его без изменений в своём коде.

src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 61-68)
        return split
                .process(new LatenessProbe())
                .name("lateness-probe")
                .uid("lateness-probe")
                .keyBy(PaymentEvent::paymentId)
                .process(new DeduplicateFunction())
                .name("deduplicate")
                .uid("deduplicate");

Перед правилами поток раскладывается по карте. Переменная byCard живёт в main, потому что к этому же разложению позже подключатся другие потребители. Правила собираются в методе fraudAlerts, приёмник алертов в sinkAlerts.

src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 41-42)
        KeyedStream<PaymentEvent, String> byCard = payments.keyBy(PaymentEvent::cardId);
        sinkAlerts(fraudAlerts(byCard, config), config);
src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 91-95)
    private static DataStream<FraudAlert> fraudAlerts(KeyedStream<PaymentEvent, String> byCard, JobConfig config) {
        DataStream<FraudAlert> velocityAlerts = byCard
                .process(new VelocityRule(config.velocityMaxPayments()))
                .name("rule-velocity")
                .uid("rule-velocity");

В снапшоте fraudAlerts возвращает union с правилом из задания, у тебя пока метод вернёт только velocityAlerts. Приёмник получает готовый поток алертов.

src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 106-108)
                .sinkTo(KafkaSinks.<FraudAlert>atLeastOnceJson(config.bootstrapServers(), config.alertsTopic()))
                .name("alerts-sink")
                .uid("alerts-sink");

Добавь импорты FraudAlert, DeduplicateFunction, VelocityRule и KeyedStream.

Шаг 6. Тесты на harness

Функции с состоянием и таймерами удобно проверять на harness из урока 3. Он даёт управлять watermark, временем TTL и смотреть на число таймеров. Создай src/test/java/dev/study/payguard/pipeline/DeduplicateFunctionTest.java.

src/test/java/dev/study/payguard/pipeline/DeduplicateFunctionTest.java
package dev.study.payguard.pipeline;

import static dev.study.payguard.testing.Payments.paymentOnCard;
import static org.assertj.core.api.Assertions.assertThat;

import dev.study.payguard.model.PaymentEvent;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.streaming.util.KeyedOneInputStreamOperatorTestHarness;
import org.apache.flink.streaming.util.ProcessFunctionTestHarnesses;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class DeduplicateFunctionTest {

    private KeyedOneInputStreamOperatorTestHarness<String, PaymentEvent, PaymentEvent> harness;

    @BeforeEach
    void setUp() throws Exception {
        harness = ProcessFunctionTestHarnesses.forKeyedProcessFunction(
                new DeduplicateFunction(), PaymentEvent::paymentId, Types.STRING);
    }

    @Test
    void repeatedPaymentIdPassesOnlyOnce() throws Exception {
        harness.processElement(paymentOnCard("p1", "c1", 1_000), 1_000);
        harness.processElement(paymentOnCard("p1", "c1", 1_000), 1_000);
        harness.processElement(paymentOnCard("p2", "c1", 2_000), 2_000);

        assertThat(harness.extractOutputValues()).extracting(PaymentEvent::paymentId).containsExactly("p1", "p2");
    }

    @Test
    void paymentIsForgottenAfterTtl() throws Exception {
        harness.setStateTtlProcessingTime(0);
        harness.processElement(paymentOnCard("p1", "c1", 1_000), 1_000);

        harness.setStateTtlProcessingTime(DeduplicateFunction.REMEMBER_FOR.toMillis() + 1);
        harness.processElement(paymentOnCard("p1", "c1", 1_000), 1_000);

        assertThat(harness.extractOutputValues()).hasSize(2);
    }
}

Второй тест двигает часы TTL вручную. setStateTtlProcessingTime подменяет время, по которому TTL проверяет срок, поэтому тест не ждёт час и не зависит от настоящих часов.

Теперь src/test/java/dev/study/payguard/pipeline/VelocityRuleTest.java.

src/test/java/dev/study/payguard/pipeline/VelocityRuleTest.java
package dev.study.payguard.pipeline;

import static dev.study.payguard.testing.Payments.paymentOnCard;
import static org.assertj.core.api.Assertions.assertThat;

import dev.study.payguard.model.FraudAlert;
import dev.study.payguard.model.PaymentEvent;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.streaming.api.watermark.Watermark;
import org.apache.flink.streaming.util.KeyedOneInputStreamOperatorTestHarness;
import org.apache.flink.streaming.util.ProcessFunctionTestHarnesses;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class VelocityRuleTest {

    private static final int MAX_PAYMENTS = 3;

    private KeyedOneInputStreamOperatorTestHarness<String, PaymentEvent, FraudAlert> harness;

    @BeforeEach
    void setUp() throws Exception {
        harness = ProcessFunctionTestHarnesses.forKeyedProcessFunction(
                new VelocityRule(MAX_PAYMENTS), PaymentEvent::cardId, Types.STRING);
    }

    private void pay(String id, String card, long eventTime) throws Exception {
        harness.processElement(paymentOnCard(id, card, eventTime), eventTime);
    }

    @Test
    void alertsWhenLimitExceededWithinMinute() throws Exception {
        pay("p1", "c1", 1_000);
        pay("p2", "c1", 2_000);
        pay("p3", "c1", 3_000);
        assertThat(harness.extractOutputValues()).isEmpty();

        pay("p4", "c1", 4_000);
        assertThat(harness.extractOutputValues())
                .singleElement()
                .satisfies(alert -> assertThat(alert.alertId()).isEqualTo("velocity:p4"));
    }

    @Test
    void doesNotRepeatAlertWithinSameMinute() throws Exception {
        for (int i = 1; i <= 6; i++) {
            pay("p" + i, "c1", i * 1_000L);
        }
        assertThat(harness.extractOutputValues()).hasSize(1);
    }

    @Test
    void cardsAreCountedSeparately() throws Exception {
        pay("p1", "c1", 1_000);
        pay("p2", "c1", 2_000);
        pay("p3", "c2", 3_000);
        pay("p4", "c2", 4_000);
        assertThat(harness.extractOutputValues()).isEmpty();
    }

    @Test
    void oldPaymentsLeaveStateWhenWatermarkPasses() throws Exception {
        pay("p1", "c1", 1_000);
        pay("p2", "c1", 2_000);
        pay("p3", "c1", 3_000);
        assertThat(harness.numEventTimeTimers()).isEqualTo(1);

        harness.processWatermark(new Watermark(200_000));
        assertThat(harness.numEventTimeTimers()).isZero();

        pay("p4", "c1", 201_000);
        assertThat(harness.extractOutputValues()).isEmpty();
    }
}
КОД
    @Test
    void oldPaymentsLeaveStateWhenWatermarkPasses() throws Exception {
        pay("p1", "c1", 1_000);
        pay("p2", "c1", 2_000);
        pay("p3", "c1", 3_000);
        assertThat(harness.numEventTimeTimers()).isEqualTo(1);

        harness.processWatermark(new Watermark(200_000));
        assertThat(harness.numEventTimeTimers()).isZero();

        pay("p4", "c1", 201_000);
        assertThat(harness.extractOutputValues()).isEmpty();
    }
ПРОСТЫМИ СЛОВАМИ

Три платежа приходят на 1, 2 и 3 секунде. Все они лежат в корзине от 0 до 10 секунд.

Таймер у них общий, на 70 секунде. Тест проверяет это через numEventTimeTimers, и значение 1 доказывает слияние таймеров.

Watermark на 200 секунде обгоняет таймер. Таймер срабатывает и исчезает, а с ним уходит корзина.

Четвёртый платёж через 200 секунд даёт в окне всего один платёж. Будь старая корзина жива, сумма была бы 4, и правило выдало бы алерт при лимите 3.

В исходниках

Можно ли верить harness? Фабрика оборачивает функцию в KeyedProcessOperator, тот же класс, что разбирали в теории.

apache/flink@release-2.2.1 · ProcessFunctionTestHarnesses.java, строки 75-92
public static <K, IN, OUT>
        KeyedOneInputStreamOperatorTestHarness<K, IN, OUT> forKeyedProcessFunction(
                final KeyedProcessFunction<K, IN, OUT> function,
                final KeySelector<IN, K> keySelector,
                final TypeInformation<K> keyType)
                throws Exception {

    KeyedOneInputStreamOperatorTestHarness<K, IN, OUT> testHarness =
            new KeyedOneInputStreamOperatorTestHarness<>(
                    new KeyedProcessOperator<>(Preconditions.checkNotNull(function)),
                    keySelector,
                    keyType,
                    1,
                    1,
                    0);
    testHarness.open();
    return testHarness;
}

В задании KeyedStream.process делает то же самое, если не включено асинхронное состояние.

apache/flink@release-2.2.1 · KeyedStream.java, строки 360-367
public <R> SingleOutputStreamOperator<R> process(
        KeyedProcessFunction<KEY, T, R> keyedProcessFunction, TypeInformation<R> outputType) {
    OneInputStreamOperator<T, R> operator =
            isEnableAsyncState()
                    ? new AsyncKeyedProcessOperator<>(clean(keyedProcessFunction))
                    : new KeyedProcessOperator<>(clean(keyedProcessFunction));
    return transform("KeyedProcess", outputType, operator);
}

Время TTL harness берёт из своего провайдера, его и двигает setStateTtlProcessingTime.

apache/flink@release-2.2.1 · AbstractStreamOperatorTestHarness.java, строки 802-808
public void setProcessingTime(long time) throws Exception {
    processingTimeService.setCurrentTime(time);
}

public void setStateTtlProcessingTime(long timeStamp) {
    ttlTimeProvider.setCurrentTimestamp(timeStamp);
}

Чего harness не проверит, так это сериализацию функции при отправке задания. Её ловит только настоящий запуск, в уроке 8 мы на это наткнёмся.

терминал
./gradlew test --tests 'dev.study.payguard.pipeline.DeduplicateFunctionTest' --tests 'dev.study.payguard.pipeline.VelocityRuleTest'

Флаг --tests можно повторять, тогда запустятся оба класса. Все шесть тестов должны пройти. Попробуй ради эксперимента убрать строку с enableTimeToLive. Тест paymentIsForgottenAfterTtl упадёт, на выходе останется один платёж.

Шаг 7. Генератор с сериями

Правилу частоты нужна атака, а случайные платежи по 5000 картам почти никогда не дают 11 платежей карты за минуту. Генератор учится делать серию из 25 платежей по одной карте подряд. Новая опция --burst задаёт долю, с которой серия начинается вместо обычного платежа.

что изменилось в src/main/java/dev/study/payguard/loadgen/GeneratorConfig.java
@@ -10,7 +10,8 @@         int ratePerSecond,
         double lateShare,
         double duplicateShare,
-        double badShare) {+        double badShare,+        double burstShare) { 
     static GeneratorConfig fromArgs(String[] args) {
         Map<String, String> options = parseOptions(args);
@@ -20,7 +21,8 @@                 Integer.parseInt(options.getOrDefault("rate", "100")),
                 share(options, "late", 0.05),
                 share(options, "dup", 0.0),
-                share(options, "bad", 0.0));+                share(options, "bad", 0.0),+                share(options, "burst", 0.0));     }
 
     private static Map<String, String> parseOptions(String[] args) {

В PaymentFactory создание платежа вынесено в paymentOn, и метод burst переиспользует его для одной карты. Время событий серии идёт с шагом в миллисекунду, а суммы мелкие, так и выглядит проверка украденной карты.

что изменилось в src/main/java/dev/study/payguard/loadgen/PaymentFactory.java
@@ -1,6 +1,7 @@ package dev.study.payguard.loadgen;
 
 import dev.study.payguard.model.PaymentEvent;
+import java.util.ArrayList; import java.util.List;
 import java.util.Random;
 import java.util.UUID;
@@ -24,14 +25,32 @@     }
 
     PaymentEvent next(long nowMs) {
+        return paymentOn(randomCard(), nextMerchant(), 100 + random.nextInt(50_000), nextEventTime(nowMs));+    }++    /** Серия платежей по одной карте подряд: так выглядит атака, которую ловит правило частоты. */+    List<PaymentEvent> burst(long nowMs, int size) {+        String card = randomCard();+        List<PaymentEvent> payments = new ArrayList<>();+        for (int i = 0; i < size; i++) {+            payments.add(paymentOn(card, nextMerchant(), 100 + random.nextInt(5_000), nowMs + i));+        }+        return payments;+    }++    private PaymentEvent paymentOn(String card, String merchant, long amountMinor, long eventTimeMs) {         return new PaymentEvent(
                 new UUID(random.nextLong(), random.nextLong()).toString(),
-                "c-%04d".formatted(random.nextInt(CARD_COUNT)),-                nextMerchant(),-                100 + random.nextInt(50_000),+                card,+                merchant,+                amountMinor,                 "EUR",
                 COUNTRIES.get(random.nextInt(COUNTRIES.size())),
-                nextEventTime(nowMs));+                eventTimeMs);+    }++    private String randomCard() {+        return "c-%04d".formatted(random.nextInt(CARD_COUNT));     }
 
     private String nextMerchant() {

RawRecordSource складывает серию в очередь и отдаёт по одному платежу за вызов, поэтому темп генератора не меняется.

что изменилось в src/main/java/dev/study/payguard/loadgen/RawRecordSource.java
@@ -3,35 +3,52 @@ import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import dev.study.payguard.model.PaymentEvent;
+import java.util.ArrayDeque;+import java.util.Deque; import java.util.Random;
 
-/** Превращает платежи в строки JSON и подмешивает грязь: дубликаты и битые записи. */+/** Превращает платежи в строки JSON и подмешивает грязь и атаки: дубликаты, битые записи, серии по карте. */ final class RawRecordSource {
 
     static final String BAD_RECORD = "{\"paymentId\": broken";
+    static final int BURST_SIZE = 25; 
     private final ObjectMapper mapper = new ObjectMapper();
+    private final Deque<PaymentEvent> pendingBurst = new ArrayDeque<>();     private final PaymentFactory factory;
     private final Random random;
     private final double duplicateShare;
     private final double badShare;
+    private final double burstShare;     private String previousRecord;
 
-    RawRecordSource(PaymentFactory factory, Random random, double duplicateShare, double badShare) {+    RawRecordSource(PaymentFactory factory, Random random, double duplicateShare, double badShare, double burstShare) {         this.factory = factory;
         this.random = random;
         this.duplicateShare = duplicateShare;
         this.badShare = badShare;
+        this.burstShare = burstShare;     }
 
     String next(long nowMs) {
+        if (!pendingBurst.isEmpty()) {+            return remember(pendingBurst.poll());+        }+        if (random.nextDouble() < burstShare) {+            pendingBurst.addAll(factory.burst(nowMs, BURST_SIZE));+            return remember(pendingBurst.poll());+        }         if (random.nextDouble() < badShare) {
             return BAD_RECORD;
         }
         if (previousRecord != null && random.nextDouble() < duplicateShare) {
             return previousRecord;
         }
-        previousRecord = toJson(factory.next(nowMs));+        return remember(factory.next(nowMs));+    }++    private String remember(PaymentEvent payment) {+        previousRecord = toJson(payment);         return previousRecord;
     }
 

Осталось передать долю серий в конструктор и поправить старый тест под новую сигнатуру.

что изменилось в src/main/java/dev/study/payguard/loadgen/LoadGenerator.java
@@ -19,7 +19,7 @@         Random random = new Random();
         RawRecordSource source = new RawRecordSource(
                 new PaymentFactory(random, config.lateShare()), random,
-                config.duplicateShare(), config.badShare());+                config.duplicateShare(), config.badShare(), config.burstShare()); 
         try (KafkaProducer<String, String> producer = new KafkaProducer<>(producerProperties(config))) {
             produceForever(producer, source, config);
что изменилось в src/test/java/dev/study/payguard/loadgen/RawRecordSourceTest.java
@@ -12,7 +12,7 @@ 
     private static RawRecordSource sourceWith(double duplicateShare, double badShare) {
         Random random = new Random(42);
-        return new RawRecordSource(new PaymentFactory(random, 0.0), random, duplicateShare, badShare);+        return new RawRecordSource(new PaymentFactory(random, 0.0), random, duplicateShare, badShare, 0.0);     }
 
     @Test

Шаг 8. Запусти на стенде

Порядок прежний, сначала сборка, потом стенд. Затем найди задание прошлого урока и останови его. Состояние его окон новому заданию не нужно. Снимков у нас пока нет, переносить всё равно нечего.

терминал
./gradlew shadowJar
docker compose up -d --wait
docker compose exec jobmanager flink list
docker compose exec jobmanager flink cancel <JobID>

Отправь новое задание и запусти генератор с дубликатами, битыми записями и сериями.

терминал
scripts/submit.sh
./gradlew loadgen --args="--rate 200 --dup 0.02 --bad 0.01 --burst 0.002"

Флаг --dup 0.02 повторяет предыдущую запись в 2 % случаев, --bad 0.01 подмешивает 1 % битого JSON, --burst 0.002 начинает серию с вероятностью 0,2 % на каждой записи. Через пару минут посмотри на метрики дедупликации и правила частоты. Команды те же, что в уроке 6, меняется только имя вершины.

терминал
JOB=$(curl -s localhost:8081/jobs | jq -r '.jobs[] | select(.status == "RUNNING") | .id')
for NAME in deduplicate rule-velocity; do
  V=$(curl -s localhost:8081/jobs/$JOB | jq -r --arg n "$NAME" '.vertices[] | select(.name == $n) | .id')
  curl -s "localhost:8081/jobs/$JOB/vertices/$V/subtasks/metrics?get=numRecordsIn,numRecordsOut,busyTimeMsPerSecond,backPressuredTimeMsPerSecond&agg=min,max,sum" | jq -c .
done
вывод
[{"id":"numRecordsIn","min":8725.0,"max":8865.0,"sum":17590.0},{"id":"numRecordsOut","min":8550.0,"max":8714.0,"sum":17264.0},{"id":"busyTimeMsPerSecond","min":2.0,"max":3.0,"sum":5.0},{"id":"backPressuredTimeMsPerSecond","min":0.0,"max":0.0,"sum":0.0}]
[{"id":"numRecordsIn","min":8537.0,"max":8719.0,"sum":17256.0},{"id":"numRecordsOut","min":19.0,"max":21.0,"sum":40.0},{"id":"busyTimeMsPerSecond","min":2.0,"max":2.0,"sum":4.0},{"id":"backPressuredTimeMsPerSecond","min":0.0,"max":0.0,"sum":0.0}]

Это числа с нашего стенда, у тебя они будут другими, они зависят от длительности прогона и долей генератора. Первая строка относится к дедупликации. На вход пришло 17590 платежей, дальше ушло 17264, выброшено 326, почти 2 %. Ровно столько повторов и заказано флагом --dup. Подзадачи нагружены поровну, у paymentId нет горячих значений.

Вторая строка относится к правилу частоты. Из 17256 платежей получилось 40 алертов. Метрика busyTimeMsPerSecond равна 2 мс в секунду, то есть правило с состоянием в куче почти ничего не стоит при такой нагрузке. Теперь прочитай сами алерты.

терминал
docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 --topic fraud.alerts --from-beginning --max-messages 3 2>/dev/null
вывод
{"alertId":"velocity:9f83941a-d1f6-aa34-2b55-488691ed5149","ruleId":"velocity","cardId":"c-1764","merchantId":"m-0000","paymentId":"9f83941a-d1f6-aa34-2b55-488691ed5149","detail":"11 платежей за минуту","eventTimeMs":1794085691016}
{"alertId":"velocity:681860b7-5e8c-bdb5-1366-c0f1f50a0c2a","ruleId":"velocity","cardId":"c-4262","merchantId":"m-0000","paymentId":"681860b7-5e8c-bdb5-1366-c0f1f50a0c2a","detail":"11 платежей за минуту","eventTimeMs":1794085692640}
{"alertId":"velocity:9ab9501b-b3b5-dd7a-0188-9dfc628b84f0","ruleId":"velocity","cardId":"c-1321","merchantId":"m-0047","paymentId":"9ab9501b-b3b5-dd7a-0188-9dfc628b84f0","detail":"11 платежей за минуту","eventTimeMs":1794085696992}

Во всех трёх алертах ровно 11 платежей. Так выглядит серия генератора. Правило сработало на одиннадцатом платеже серии, а оставшиеся 14 платежей той же карты промолчали благодаря last-alert-at. alertId собран из правила и платежа, поэтому повторная обработка того же платежа даст тот же идентификатор.

Последняя проверка касается сохранности состояния.

терминал
curl -s localhost:8081/jobs/$JOB/checkpoints | jq -c '{counts, recent: .history}'
вывод
{"counts":{"restored":0,"total":0,"in_progress":0,"completed":0,"failed":0},"recent":[]}

Ни одного чекпоинта, мы их ещё не включали. Если TaskManager сейчас упадёт, корзины и флаги дедупликации пропадут вместе с кучей. Это исправит урок 11.

Задание. Правило суммы

Мошенник может обойтись без частых платежей и потратить много за несколько крупных. Напиши второе правило, которое замечает, что по карте за 10 минут потрачено больше лимита.

  1. Создай pipeline/AmountSpikeRule на KeyedProcessFunction с идентификатором правила amount-spike. Окно 10 минут по времени события, лимит в минимальных единицах валюты приходит через конструктор.
  2. В состоянии не должны копиться сами платежи. Используй BucketedSum и подбери размер корзины так, чтобы корзин на карту было не больше одиннадцати.
  3. Старые корзины должны уходить из состояния по таймеру. На одну корзину не больше одного таймера.
  4. Не больше одного алерта на карту за 10 минут. В detail напиши, сколько потрачено.
  5. Подключи правило к тому же потоку byCard с именем и uid rule-amount-spike. Алерты обоих правил должны уходить в fraud.alerts через один приёмник alerts-sink. Лимит бери из amount-spike-limit-minor в JobConfig.
  6. Напиши AmountSpikeRuleTest на harness. Два платежа по 600 с лимитом 1000 дают алерт, если между ними 5 минут, и не дают, если больше 10 минут.

Готово, если выполнены все условия.

  1. ./gradlew test проходит.
  2. В графе задания на стенде есть вершина rule-amount-spike рядом с rule-velocity.
  3. С заниженным лимитом в fraud.alerts появляются записи с "ruleId":"amount-spike".

Подсказка первая. Сравни это правило с правилом частоты построчно. Отличий меньше, чем кажется.

Подсказка вторая. Подумай заранее, сколько алертов даст правило с лимитом по умолчанию на нашем генераторе. Посчитай, сколько в среднем платежей приходится на карту за 10 минут при 200 платежах в секунду и какая у них средняя сумма.

Ответ

Глоссарий урока

Состояние с ключом (keyed state)
Состояние оператора после keyBy, разложенное по ключам. Функция видит только значение текущего ключа, Flink переключает ключ перед каждой записью и каждым таймером.
ValueState
Вид состояния с одним значением на ключ. Методы value() и update().
MapState
Вид состояния со словарём на ключ. Удобен, когда у ключа много независимых частей, например корзины времени.
ListState
Вид состояния со списком на ключ. Подходит, когда нужны сами элементы, и опасен, когда список растёт без ограничения.
ReducingState и AggregatingState
Виды состояния с одним свёрнутым значением на ключ. Каждое add сразу объединяется с накопленным через ReduceFunction или AggregateFunction.
Дескриптор состояния (state descriptor)
Описание состояния с именем и типом значения. По имени Flink находит состояние при восстановлении, на дескрипторе включается TTL.
KeyedProcessFunction
Функция нижнего уровня DataStream API для потока после keyBy. Метод processElement вызывается на каждую запись, onTimer при срабатывании таймера.
Таймер (timer)
Отложенный вызов onTimer для ключа на заданный момент по времени события или обработки. На один ключ и момент существует не больше одного таймера, таймеры входят в снимок.
Слияние таймеров (timer coalescing)
Округление времени таймеров, чтобы много событий одного ключа делили один таймер. Уменьшает число таймеров и размер снимка.
TTL (time-to-live)
Срок жизни значения состояния. Задаётся через StateTtlConfig на дескрипторе и во Flink 2.2 считается только по времени обработки.
Время обработки (processing time)
Показания часов машины, на которой работает оператор. Противоположность времени события.
Watermark
Отметка в потоке, которая утверждает, что событий раньше этого времени больше не будет. Один на подзадачу, двигает таймеры всех её ключей.
Бэкенд состояния (state backend)
Хранилище, в котором живёт состояние операторов. Выбирается ключом state.backend.type.
HashMapStateBackend
Бэкенд по умолчанию. Хранит состояние Java-объектами в куче TaskManager, быстрый и ограниченный памятью.
RocksDB
Встраиваемое хранилище ключ-значение на диске. Во Flink служит бэкендом для состояния больше памяти, каждое обращение сериализует ключ и значение.
Группа ключей (key group)
Единица раскладки состояния по подзадачам. Их число равно максимальному параллелизму, при смене параллелизма группы переезжают целиком.
Namespace состояния
Второй уровень адресации состояния внутри ключа. В окнах это окно, в KeyedProcessFunction пустой VoidNamespace.
Кардинальность ключей
Число разных значений ключа, которое увидит оператор за время жизни состояния. Главный множитель размера состояния.
Корзина (bucket)
Отрезок времени фиксированной длины, по которому копится сумма. Вместо всех событий хранится одно число на отрезок.
Дедупликация
Удаление повторов одной и той же записи. В PayGuard делается по paymentId с памятью на час.
Test harness
Тестовая обвязка Flink, которая запускает один оператор без кластера и даёт управлять watermark, временем и состоянием. ProcessFunctionTestHarnesses создаёт её для функций.
mapGroupsWithState
Операция Spark Structured Streaming для произвольного состояния по группе. Один объект состояния и один тайм-аут на группу, вызов раз в micro-batch.
Apache Kafka
Распределённый журнал сообщений. Отсюда читаются платежи и сюда пишутся алерты.
Redis
Хранилище ключ-значение в памяти, к которому обращаются по сети. Частый выбор для внешних счётчиков в обычных сервисах.

Урок 8 · Время и состояние

Правила без перезапуска на broadcast state

После урока ты сможешь менять пороги антифрода сообщением в Kafka, не останавливая задание, и не дашь потоку настроек заморозить время событий.

Порог, который меняют чаще релизов

Лимит правила velocity сейчас задаётся параметром запуска, по умолчанию 10 платежей карты за минуту. Аналитик антифрода видит волну атак из одной страны и хочет заблокировать её платежи прямо сейчас. Через полчаса ложных срабатываний становится много, и лимит надо поднять.

Если каждая такая правка идёт через релиз, это savepoint, остановка, новая сборка и запуск. Проверка платежей в это время стоит, а дежурный инженер нужен даже ночью. Аналитик хочет менять пороги сам и сразу.

Выход в том, чтобы считать настройки правил ещё одним потоком данных. Аналитик пишет сообщение в топик rules.config, задание читает его и применяет к следующему платежу. Для этого во Flink есть broadcast state, состояние, которое доставляется во все параллельные подзадачи оператора.

Циркуляр для касс

В супермаркете десять касс. Утром офис рассылает циркуляр «алкоголь продавать только по паспорту». Копию получает каждая касса, кассир держит её у себя и сверяется с ней при каждом покупателе. Звонить соседней кассе ему не нужно, всё под рукой.

Править циркуляр кассир не может. Если один допишет в свою копию «кроме постоянных покупателей», кассы начнут работать по разным правилам, и никто этого не заметит. Менять текст имеет право только офис, и делает он это новым циркуляром, который снова получают все.

Во Flink офис это поток правил, кассы это подзадачи оператора, копия циркуляра это broadcast state, покупатели это платежи. Каждый платёж попадает в одну подзадачу, ту, что отвечает за его карту. Каждое правило попадает во все.

ПлатежиkeyBy(cardId) rules-config, подзадача 0 rules-config, подзадача 1 rules-dynamic, подзадача 0состояние карт c-1, c-3 и других своихкопия правил, ключ current rules-dynamic, подзадача 1состояние карт c-2, c-4 и других своихкопия правил, ключ current сплошные стрелки это keyBy, пунктир это BROADCAST

Как устроен broadcast state

Поток правил становится широковещательным после вызова broadcast(descriptor). Метод ставит разбиение BROADCAST, при котором каждая запись уходит во все подзадачи следующего оператора, и запоминает дескрипторы состояния. Дескриптор MapStateDescriptor задаёт имя и типы ключа и значения, так что broadcast state устроен как словарь.

Затем поток платежей с ключом соединяется с ним через connect, и в process передаётся KeyedBroadcastProcessFunction. У функции два главных метода. processElement вызывается для каждого платежа, processBroadcastElement для каждого правила.

В исходниках

Метод broadcast у DataStream ставит BroadcastPartitioner и заворачивает поток вместе с дескрипторами в BroadcastStream.

apache/flink@release-2.2.1 · DataStream.java, строки 338-344
@PublicEvolving
public BroadcastStream<T> broadcast(
        final MapStateDescriptor<?, ?>... broadcastStateDescriptors) {
    Preconditions.checkNotNull(broadcastStateDescriptors);
    final DataStream<T> broadcastStream = setConnectionType(new BroadcastPartitioner<>());
    return new BroadcastStream<>(environment, broadcastStream, broadcastStateDescriptors);
}

Внутри оператора CoBroadcastWithKeyedOperator два входа разведены по двум методам. Запись первого входа идёт в processElement вместе с контекстом rContext, запись второго в processBroadcastElement с контекстом rwContext.

apache/flink@release-2.2.1 · CoBroadcastWithKeyedOperator.java, строки 122-136
@Override
public void processElement1(StreamRecord<IN1> element) throws Exception {
    collector.setTimestamp(element);
    rContext.setElement(element);
    userFunction.processElement(element.getValue(), rContext, collector);
    rContext.setElement(null);
}

@Override
public void processElement2(StreamRecord<IN2> element) throws Exception {
    collector.setTimestamp(element);
    rwContext.setElement(element);
    userFunction.processBroadcastElement(element.getValue(), rwContext, collector);
    rwContext.setElement(null);
}

Имена контекстов означают read и read-write. Чем они отличаются, посмотрим в следующем разделе.

Почему писать можно только со стороны правил

Подзадачи Flink не переговариваются между собой. Каждая хранит свою копию broadcast state, и копии совпадают по одной причине. Все подзадачи получают одни и те же правила и делают с ними одно и то же. Поэтому код processBroadcastElement обязан быть детерминированным. Если он смотрит на часы машины, на случайное число или во внешнюю базу, копии разойдутся.

Платёж приходит только в одну подзадачу. Если бы processElement мог менять broadcast state, изменение попало бы в одну копию из нескольких. Поэтому сторона платежей читает правила, но писать в них не может. Зато ей доступны состояние с ключом и таймеры. У стороны правил таймеров нет, взамен она может пройтись по состоянию всех ключей своей подзадачи методом applyToKeyedState.

В исходниках

Описание KeyedBroadcastProcessFunction прямо распределяет права между двумя методами.

apache/flink@release-2.2.1 · KeyedBroadcastProcessFunction.java, строки 41-53
* <p>The user has to implement two methods:
*
* <ol>
*   <li>the {@link #processBroadcastElement(Object, Context, Collector)} which will be applied to
*       each element in the broadcast side
*   <li>and the {@link #processElement(Object, ReadOnlyContext, Collector)} which will be applied
*       to the non-broadcasted/keyed side.
* </ol>
*
* <p>The {@code processElementOnBroadcastSide()} takes as an argument (among others) a context that
* allows it to read/write to the broadcast state and also apply a transformation to all (local)
* keyed states, while the {@code processElement()} has read-only access to the broadcast state, but
* can read/write to the keyed state and register timers.

Теперь то, как сделано «только чтение». Контекст стороны платежей берёт состояние из той же карты states, что и контекст правил, и приводит его к интерфейсу ReadOnlyBroadcastState.

apache/flink@release-2.2.1 · CoBroadcastWithKeyedOperator.java, строки 294-309
@Override
public <K, V> ReadOnlyBroadcastState<K, V> getBroadcastState(
        MapStateDescriptor<K, V> stateDescriptor) {
    Preconditions.checkNotNull(stateDescriptor);

    stateDescriptor.initializeSerializerUnlessSet(config);
    ReadOnlyBroadcastState<K, V> state =
            (ReadOnlyBroadcastState<K, V>) states.get(stateDescriptor);
    if (state == null) {
        throw new IllegalArgumentException(
                "The requested state does not exist. "
                        + "Check for typos in your state descriptor, or specify the state descriptor "
                        + "in the datastream.broadcast(...) call if you forgot to register it.");
    }
    return state;
}

Объект один и тот же, ограничение задаёт только тип. В интерфейсе для чтения нет put и remove, а его описание предупреждает, что значения отдаются без копирования.

apache/flink@release-2.2.1 · ReadOnlyBroadcastState.java, строки 25-37
/**
 * A read-only view of the {@link BroadcastState}.
 *
 * <p>Although read-only, the user code should not modify the value returned by the {@link
 * #get(Object)} or the entries of the immutable iterator returned by the {@link
 * #immutableEntries()}, as this can lead to inconsistent states. The reason for this is that we do
 * not create extra copies of the elements for performance reasons.
 *
 * @param <K> The key type of the elements in the {@link ReadOnlyBroadcastState}.
 * @param <V> The value type of the elements in the {@link ReadOnlyBroadcastState}.
 */
@PublicEvolving
public interface ReadOnlyBroadcastState<K, V> extends State {

Отсюда практическое правило. Объект, который вернул get, менять нельзя, даже если у него есть сеттер. Изменение в одной подзадаче останется в её копии, и ошибки при этом не будет. У нас настройки хранит record RuleConfig, и код правил только читает его поля.

Порядок, снимки и память

Детерминированный код ещё не гарантирует одинаковых копий. Если правила читают две подзадачи источника, каждая подзадача оператора получает записи от обеих по двум разным каналам. Внутри канала порядок сохраняется. Как перемешаются два канала, у каждой подзадачи решается по-своему. Документация говорит прямо, что порядок правил в разных подзадачах может различаться и обновление не должно от него зависеть.

У нас обновление устроено по принципу «последнее побеждает», ключ current просто перезаписывается. Если аналитик отправит два сообщения подряд и они лягут в разные партиции rules.config, подзадачи могут закончить на разных версиях. На учебном стенде у топика четыре партиции, и мы отправляем одно правило, поэтому это не мешает. В проде делай топик правил с одной партицией или добавь в правило номер версии и принимай только более новую.

При чекпоинте свою копию сохраняет каждая подзадача, так что в снимке broadcast state лежит столько раз, каков параллелизм. Зато при восстановлении подзадачи не читают один и тот же файл. После восстановления или смены параллелизма каждая подзадача получает целую копию, и поэтому копии обязаны совпадать.

В исходниках

Описание интерфейса BroadcastState объясняет, как копии раздаются при восстановлении и почему расхождение копий даёт непредсказуемый результат.

apache/flink@release-2.2.1 · BroadcastState.java, строки 26-45
/**
 * A type of state that can be created to store the state of a {@code BroadcastStream}. This state
 * assumes that <b>the same elements are sent to all instances of an operator.</b>
 *
 * <p><b>CAUTION:</b> the user has to guarantee that all task instances store the same elements in
 * this type of state.
 *
 * <p>Each operator instance individually maintains and stores elements in the broadcast state. The
 * fact that the incoming stream is a broadcast one guarantees that all instances see all the
 * elements. Upon recovery or re-scaling, the same state is given to each of the instances. To avoid
 * hotspots, each task reads its previous partition, and if there are more tasks (scale up), then
 * the new instances read from the old instances in a round robin fashion. This is why each instance
 * has to guarantee that it stores the same elements as the rest. If not, upon recovery or rescaling
 * you may have unpredictable redistribution of the partitions, thus unpredictable results.
 *
 * @param <K> The key type of the elements in the {@link BroadcastState}.
 * @param <V> The value type of the elements in the {@link BroadcastState}.
 */
@PublicEvolving
public interface BroadcastState<K, V> extends ReadOnlyBroadcastState<K, V> {

Где лежат данные. Хранилище состояния операторов создаёт для broadcast state объект HeapBroadcastState, то есть словарь в куче JVM.

apache/flink@release-2.2.1 · DefaultOperatorStateBackend.java, строки 163-174
BackendWritableBroadcastState<K, V> broadcastState =
        (BackendWritableBroadcastState<K, V>) registeredBroadcastStates.get(name);

if (broadcastState == null) {
    broadcastState =
            new HeapBroadcastState<>(
                    new RegisteredBroadcastStateBackendMetaInfo<>(
                            name,
                            OperatorStateHandle.Mode.BROADCAST,
                            broadcastStateKeySerializer,
                            broadcastStateValueSerializer));
    registeredBroadcastStates.put(name, broadcastState);

Документация в разделе об ограничениях подтверждает, что broadcast state во время работы живёт в памяти и при RocksDB тоже, и что в чекпоинте он хранится в p экземплярах при параллелизме p.

i
Broadcast state для маленьких данных

Пороги и списки стран весят килобайты, и копия в каждой подзадаче ничего не стоит. Справочник продавцов на миллионы строк сюда класть нельзя. Он займёт кучу каждого TaskManager столько раз, сколько у оператора подзадач на этой машине, и столько же раз попадёт в чекпоинт. Для больших справочников есть Async I/O, о нём урок 9.

Путь одного правила

Пройди по шагам, что происходит с сообщением аналитика от консоли до проверки следующего платежа.

A
Аналитик, консольный продюсер
K
Kafka, rules.config
S
Source rules-config
0
rules-dynamic, подзадача 0
1
rules-dynamic, подзадача 1
Нажми «Следующий шаг»

Два входа и одно время

У оператора rules-dynamic два входа, и у каждого свой watermark. Правило слияния ты знаешь по уроку 5. Время оператора равно минимуму по входам, потому что обещание «старше T ничего не придёт» верно, только если его дал каждый вход. Для двух входов действует тот же минимум.

Теперь представь, что поток правил подключён с WatermarkStrategy.noWatermarks(). Выбор кажется естественным, ведь у настроек нет времени события. Такой вход ни разу не пришлёт watermark, его время навсегда остаётся минимально возможным, и минимум по двум входам тоже. Время оператора стоит на месте, таймеры по времени события не срабатывают. В DynamicRules это таймеры, которые выбрасывают старые корзины счётчика, поэтому состояние растёт без предела. Алерты при этом приходят, исключений в логе нет.

Выбери стратегию для входа правил и запусти пять минут времени событий. Слева видно watermark входов и оператора, справа число корзин одной карты в состоянии.

Два входа одного оператора

Выбери стратегию для входа правил и нажми кнопку.

Модель повторяет DynamicRules. Платёж приходит раз в 10 секунд, корзина равна 10 секундам, окно минуте, допуск беспорядка платежей 10 секундам, как в PaymentWatermarks.

В исходниках

Проследим watermark от сети до оператора. Выход сетевого входа знает свой номер и вызывает у оператора processWatermark1 или processWatermark2.

apache/flink@release-2.2.1 · StreamTwoInputProcessorFactory.java, строки 260-276
@Override
public void emitWatermark(Watermark watermark) throws Exception {
    inputWatermarkGauge.setCurrentWatermark(watermark.getTimestamp());
    if (inputIndex == 0) {
        if (watermarkBypass == null) {
            operator.processWatermark1(watermark);
        } else {
            watermarkBypass.processWatermark1(watermark);
        }
    } else {
        if (watermarkBypass == null) {
            operator.processWatermark2(watermark);
        } else {
            watermarkBypass.processWatermark2(watermark);
        }
    }
}

В AbstractStreamOperator оба метода сводятся к одному. Номер входа становится индексом в combinedWatermark, который в конструкторе оператора создан на два входа вызовом forInputsCount(2). Оператор получает новый watermark, только если общий вырос.

apache/flink@release-2.2.1 · AbstractStreamOperator.java, строки 689-701
private void processWatermark(Watermark mark, int index) throws Exception {
    if (combinedWatermark.updateWatermark(index, mark.getTimestamp())) {
        processWatermark(new Watermark(combinedWatermark.getCombinedWatermark()));
    }
}

public void processWatermark1(Watermark mark) throws Exception {
    processWatermark(mark, 0);
}

public void processWatermark2(Watermark mark) throws Exception {
    processWatermark(mark, 1);
}

Общий watermark считает CombinedWatermarkStatus, тот же класс, что сливает партиции в источнике. Его минимум по активным входам мы разбирали в уроке 5. Сейчас нас интересует начальное значение каждого входа.

apache/flink@release-2.2.1 · CombinedWatermarkStatus.java, строки 109-118
/** Per-output watermark state. */
static class PartialWatermark {
    private long watermark = Long.MIN_VALUE;
    private boolean idle = false;
    private final WatermarkOutputMultiplexer.WatermarkUpdateListener onWatermarkUpdate;

    public PartialWatermark(
            WatermarkOutputMultiplexer.WatermarkUpdateListener onWatermarkUpdate) {
        this.onWatermarkUpdate = onWatermarkUpdate;
    }

Вход, который ни разу не прислал watermark, держит Long.MIN_VALUE, и минимум выше него не поднимется. Именно такой вход даёт noWatermarks, его генератор ничего не выпускает.

apache/flink@release-2.2.1 · WatermarkStrategy.java, строки 243-249
/**
 * Creates a watermark strategy that generates no watermarks at all. This may be useful in
 * scenarios that do pure processing-time based stream processing.
 */
static <T> WatermarkStrategy<T> noWatermarks() {
    return (ctx) -> new NoWatermarksGenerator<>();
}

Из минимума вход выпадает, только когда объявлен простаивающим. Генератор NoWatermarksGenerator пуст в обоих методах, так что и признака простоя от него не будет.

Исправление в нашем проекте называется RuleWatermarks.neverHoldBack(). Его генератор периодически выпускает watermark Long.MAX_VALUE. Вход правил обещает, что старых данных от него не будет никогда, и минимум всегда равен времени платежей. Самим правилам это ничем не грозит, окон и таймеров на их стороне нет.

Можно было бы объявить вход простаивающим через withIdleness из урока 5. Но простаивающим вход считается только после таймаута, и всё это время оператор бы ждал. Генератору с Long.MAX_VALUE таймаут не нужен.

⚠
Время стоит, а ошибок нет

Застрявший watermark двухвходового оператора задание не роняет. Записи идут, счётчики входа растут, молчат только окна и таймеры по времени события. В такой ситуации первым делом смотри watermark каждой вершины в Web UI или через REST. Поломка там, где у вершины время стоит, а у вершин перед ней идёт.

Сравнение со Spark

В Spark похожую задачу решают broadcast variable. Её создаёт драйвер, на каждый экзекутор она доставляется один раз и дальше только читается. Чтобы поменять значение, создают новую переменную. В Structured Streaming это обычно делают внутри foreachBatch или перезапуском запроса.

Broadcast state во Flink живёт внутри задания. Новое значение приходит записью из потока, сохраняется в чекпоинте вместе с остальным состоянием и восстанавливается после падения. Цена этого в двух обязательствах. Код обновления должен быть детерминированным, и поток правил участвует во времени оператора.

Проверь себя

Аналитик отправил подряд два сообщения, лимит 50 и сразу за ним лимит 20. Через минуту часть карт проверяется по лимиту 50, часть по лимиту 20. В топике правил четыре партиции, у источника правил параллелизм 2. Что произошло?

После выкладки состояние вершины rules-dynamic растёт каждую минуту. Алерты приходят, исключений нет. В Web UI watermark у deduplicate растёт, у rules-dynamic стоит. Что проверить первым?

Коллега хочет считать срабатывания каждого правила. Он предлагает в processElement привести ReadOnlyBroadcastState к BroadcastState и увеличивать счётчик. Код компилируется и работает. Чем это плохо?

Что будет в конце

Правило velocity из урока 7 переедет в новую функцию DynamicRules, которая берёт порог из топика rules.config. Ты отправишь в топик новое правило консольным продюсером Kafka, и задание применит его без перезапуска. Проверку заблокированных стран ты напишешь в задании, поэтому в практике её код пропускаем. Файлы решения пока создавать не нужно.

Новых зависимостей нет, build.gradle.kts не меняется. Broadcast state и KeyedBroadcastProcessFunction входят в те модули Flink, что уже подключены.

Шаг 1. Настройки правил

Создай model/RuleConfig.java. Настройки описывает record с двумя полями, лимитом платежей карты в минуту и списком заблокированных стран.

src/main/java/dev/study/payguard/model/RuleConfig.java
package dev.study.payguard.model;

import java.io.Serializable;
import java.util.List;

/** Настройки правил, которые аналитик меняет на лету сообщением в топик rules.config. */
public record RuleConfig(int velocityMaxPayments, List<String> blockedCountries) implements Serializable {
}

Слова implements Serializable появились после ошибки. Первая версия была без них. Тесты прошли, jar собрался, а flink run упал ещё до старта задания.

вывод flink run
RuleConfig[velocityMaxPayments=10, blockedCountries=[]] is not serializable. The object probably contains or references non serializable fields.

Причина в поле defaults функции DynamicRules. Значения по умолчанию передаются в конструктор и хранятся в поле, а функция уезжает на TaskManager через сериализацию Java вместе со всем, на что ссылаются её поля. В самом broadcast state RuleConfig хранится иначе, через сериализатор Flink для POJO, и Serializable ему не нужен. Record Flink разбирает как POJO, это было в уроке 3.

В исходниках

Проверку запускает метод, который добавляет функцию в граф. Ещё на клиенте он пропускает её через clean окружения.

apache/flink@release-2.2.1 · BroadcastConnectedStream.java, строки 249-271
@Internal
private <KEY, OUT> SingleOutputStreamOperator<OUT> transform(
        final KeyedBroadcastProcessFunction<KEY, IN1, IN2, OUT> userFunction,
        final TypeInformation<OUT> outTypeInfo) {

    // read the output type of the input Transforms to coax out errors about MissingTypeInfo
    nonBroadcastStream.getType();
    broadcastStream.getType();

    KeyedStream<IN1, KEY> keyedInputStream = (KeyedStream<IN1, KEY>) nonBroadcastStream;

    final KeyedBroadcastStateTransformation<KEY, IN1, IN2, OUT> transformation =
            new KeyedBroadcastStateTransformation<>(
                    "Co-Process-Broadcast-Keyed",
                    nonBroadcastStream.getTransformation(),
                    broadcastStream.getTransformation(),
                    clean(userFunction),
                    broadcastStateDescriptors,
                    keyedInputStream.getKeyType(),
                    keyedInputStream.getKeySelector(),
                    outTypeInfo,
                    environment.getParallelism(),
                    false);

Метод clean зовёт ClosureCleaner с проверкой сериализуемости, и её текст совпадает с нашей ошибкой. Код проверки и причину, по которой test harness её обходит, мы разберём в уроке 16. Для практики хватит вывода. Забытый Serializable ловит отправка задания или тест, который строит граф, а тест функции на harness его пропускает.

В JobConfig добавь имя топика правил. Умолчание rules.config совпадает с топиком, который уже создаёт служба kafka-init.

что изменилось в src/main/java/dev/study/payguard/job/JobConfig.java
@@ -12,6 +12,7 @@         String merchantStatsTopic,
         String cardSessionsTopic,
         String alertsTopic,
+        String rulesTopic,         int velocityMaxPayments,
         long amountSpikeLimitMinor,
         String groupId) implements Serializable {
@@ -26,6 +27,7 @@                 params.get("merchant-stats-topic", "merchant.stats"),
                 params.get("card-sessions-topic", "card.sessions"),
                 params.get("alerts-topic", "fraud.alerts"),
+                params.get("rules-topic", "rules.config"),                 params.getInt("velocity-max-payments", 10),
                 params.getLong("amount-spike-limit-minor", 2_000_000),
                 params.get("group-id", "payguard"));

Шаг 2. Разбор, который не роняет задание

Правила пишет человек, и опечатка в JSON рано или поздно случится. Если схема бросит исключение, задание упадёт целиком. Сейчас чекпоинты выключены, и без них Flink по умолчанию задание не перезапускает. Когда в уроке 11 чекпоинты появятся, перезапуск включится сам, и задание будет раз за разом доходить до той же записи и падать снова. Создай pipeline/LenientJsonSchema.java. На битой записи она пишет предупреждение в лог и возвращает null.

src/main/java/dev/study/payguard/pipeline/LenientJsonSchema.java
package dev.study.payguard.pipeline;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import org.apache.flink.api.common.serialization.AbstractDeserializationSchema;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** Читает JSON и пропускает битые записи. Возврат null заставляет источник Kafka просто пропустить запись. */
public class LenientJsonSchema<T> extends AbstractDeserializationSchema<T> {

    private static final Logger LOG = LoggerFactory.getLogger(LenientJsonSchema.class);

    private final Class<T> type;
    private transient ObjectMapper mapper;

    public LenientJsonSchema(Class<T> type) {
        super(type);
        this.type = type;
    }

    @Override
    public void open(InitializationContext context) {
        mapper = new ObjectMapper();
    }

    @Override
    public T deserialize(byte[] message) {
        try {
            return mapper.readValue(message, type);
        } catch (IOException e) {
            LOG.warn("Пропускаю запись, которую не удалось разобрать как {}", type.getSimpleName(), e);
            return null;
        }
    }
}

ObjectMapper помечен transient и создаётся в open, один раз на подзадачу. В сериализованную схему он не попадает. Класс записи передаётся конструктору, поэтому схему можно взять для любого типа.

В исходниках

Почему хватает null. Источник Kafka с setValueOnlyDeserializer оборачивает схему и вызывает её вариант с Collector.

apache/flink-connector-kafka@v5.0.0 · KafkaValueOnlyDeserializationSchemaWrapper.java, строки 43-52
@Override
public void open(DeserializationSchema.InitializationContext context) throws Exception {
    deserializationSchema.open(context);
}

@Override
public void deserialize(ConsumerRecord<byte[], byte[]> message, Collector<T> out)
        throws IOException {
    deserializationSchema.deserialize(message.value(), out);
}

Этот вариант по умолчанию отдаёт объект дальше, только если deserialize вернул что-то отличное от null.

apache/flink@release-2.2.1 · DeserializationSchema.java, строки 80-86
@PublicEvolving
default void deserialize(byte[] message, Collector<T> out) throws IOException {
    T deserialize = deserialize(message);
    if (deserialize != null) {
        out.collect(deserialize);
    }
}

Пустой результат просто не попадает в поток, и задание продолжает работать с прежними правилами.

Теперь о том, что было бы без этой схемы. Стратегия перезапуска по умолчанию зависит от того, включены ли чекпоинты. Описание настройки restart-strategy.type говорит об этом прямо.

apache/flink@release-2.2.1 · RestartStrategyOptions.java, строки 150-157
                                "here"))))
.text(
        "If checkpointing is disabled, the default value is %s. "
                + "If checkpointing is enabled, the default value is %s, and the default values of %s related config options will be used.",
        code(NO_RESTART_STRATEGY.getMainValue()),
        code(EXPONENTIAL_DELAY.getMainValue()),
        code(EXPONENTIAL_DELAY.getMainValue()))
.build());

Шаг 3. Watermark для потока правил

Создай pipeline/RuleWatermarks.java. Это стратегия из теории, которая не даёт правилам держать время оператора.

КОД
    public static WatermarkStrategy<RuleConfig> neverHoldBack() {
        return WatermarkStrategy.forGenerator(context -> new WatermarkGenerator<>() {
            @Override
            public void onEvent(RuleConfig rule, long eventTimestamp, WatermarkOutput output) {
            }

            @Override
            public void onPeriodicEmit(WatermarkOutput output) {
                output.emitWatermark(new Watermark(Long.MAX_VALUE));
            }
        });
    }
ПРОСТЫМИ СЛОВАМИ

forGenerator собирает стратегию из фабрики генераторов. Источник сам создаст генераторы из этой фабрики.

onEvent пустой. Время правила нас не интересует, по записям watermark не двигаем.

onPeriodicEmit вызывается периодически, по умолчанию раз в 200 мс, это настройка pipeline.auto-watermark-interval. Каждый раз выпускаем Long.MAX_VALUE, обещание, что от этого входа старых данных не будет никогда.

Стратегия типизирована RuleConfig, поэтому подходит прямо в fromSource источника правил.

Шаг 4. Функция правил

Создай pipeline/DynamicRules.java. Она заменяет VelocityRule из урока 7, поэтому удали этот класс и его тест VelocityRuleTest. Подсчёт переезжает почти без изменений, меняется только то, откуда берётся порог.

В снапшоте файл уже содержит проверку стран из задания. Ниже он показан кусками, строки 22, 48 и 68-73 относятся к заданию, их пока не пиши. Начало файла до первой из них.

src/main/java/dev/study/payguard/pipeline/DynamicRules.java (строки 1-21)
package dev.study.payguard.pipeline;

import dev.study.payguard.model.FraudAlert;
import dev.study.payguard.model.PaymentEvent;
import dev.study.payguard.model.RuleConfig;
import java.time.Duration;
import java.util.List;
import org.apache.flink.api.common.functions.OpenContext;
import org.apache.flink.api.common.state.BroadcastState;
import org.apache.flink.api.common.state.MapStateDescriptor;
import org.apache.flink.api.common.state.ReadOnlyBroadcastState;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.streaming.api.functions.co.KeyedBroadcastProcessFunction;
import org.apache.flink.util.Collector;

/** Правила с настройками, которые меняются без перезапуска. Ключ платёжного потока это cardId. */
public class DynamicRules extends KeyedBroadcastProcessFunction<String, PaymentEvent, RuleConfig, FraudAlert> {

    public static final String VELOCITY_RULE = "velocity";

Дальше дескриптор, поля, open и начало processElement.

src/main/java/dev/study/payguard/pipeline/DynamicRules.java (строки 23-47)
    public static final MapStateDescriptor<String, RuleConfig> RULES =
            new MapStateDescriptor<>("rules", Types.STRING, Types.POJO(RuleConfig.class));

    private static final String CURRENT = "current";
    private static final Duration WINDOW = Duration.ofMinutes(1);
    private static final Duration BUCKET = Duration.ofSeconds(10);

    private final RuleConfig defaults;
    private transient BucketedSum counts;
    private transient ValueState<Long> lastVelocityAlertAtMs;

    public DynamicRules(RuleConfig defaults) {
        this.defaults = defaults;
    }

    @Override
    public void open(OpenContext context) {
        counts = new BucketedSum(getRuntimeContext().getMapState(
                new MapStateDescriptor<>("payment-counts", Long.class, Long.class)), BUCKET);
        lastVelocityAlertAtMs = getRuntimeContext().getState(new ValueStateDescriptor<>("last-alert-at", Long.class));
    }

    @Override
    public void processElement(PaymentEvent payment, ReadOnlyContext context, Collector<FraudAlert> out) throws Exception {
        RuleConfig rules = currentRules(context.getBroadcastState(RULES));

Дескриптор RULES объявлен публичной константой, потому что он нужен в трёх местах, в вызове broadcast, в функции и в тесте. Типы заданы явно через Types, ведь общие типы Kryo мы запретили в уроке 3. Если запросить состояние по дескриптору с другим именем, оператор бросит IllegalArgumentException с советом проверить опечатки.

После строки 47 идёт вызов правила частоты и два новых метода.

КОД
        checkVelocity(payment, rules, context, out);
    }

    @Override
    public void processBroadcastElement(RuleConfig update, Context context, Collector<FraudAlert> out) throws Exception {
        BroadcastState<String, RuleConfig> state = context.getBroadcastState(RULES);
        state.put(CURRENT, update);
    }

    @Override
    public void onTimer(long timestamp, OnTimerContext context, Collector<FraudAlert> out) throws Exception {
        counts.evictBefore(timestamp - WINDOW.toMillis());
    }

    private RuleConfig currentRules(ReadOnlyBroadcastState<String, RuleConfig> state) throws Exception {
        RuleConfig rules = state.get(CURRENT);
        return rules != null ? rules : defaults;
    }
ПРОСТЫМИ СЛОВАМИ

Платёж проверяется правилом частоты с порогом из текущих настроек. Настройки processElement получил строкой выше из broadcast state через контекст только для чтения.

processBroadcastElement получает новое правило и кладёт его под единственным ключом current. Контекст здесь другой, и getBroadcastState возвращает изменяемый BroadcastState. Код не смотрит ни на часы, ни во внешние системы, поэтому во всех подзадачах сделает одно и то же.

onTimer остался из урока 7. Таймер срабатывает по watermark оператора и выбрасывает корзины старше минуты. Именно он замолчит, если поток правил задержит время.

currentRules читает current. Пока не пришло ни одного правила, словарь пуст, и берутся значения по умолчанию из конструктора.

Остаток файла это правило частоты из урока 7. Изменилась одна строка, порог теперь берётся из rules.velocityMaxPayments().

src/main/java/dev/study/payguard/pipeline/DynamicRules.java (строки 74-92)
    private void checkVelocity(PaymentEvent payment, RuleConfig rules, ReadOnlyContext context, Collector<FraudAlert> out)
            throws Exception {
        long eventTime = payment.eventTimeMs();
        counts.add(eventTime, 1);
        context.timerService().registerEventTimeTimer(counts.bucketEnd(eventTime) + WINDOW.toMillis());

        long recent = counts.sumSince(eventTime - WINDOW.toMillis());
        if (recent > rules.velocityMaxPayments() && velocityAlertIsDue(eventTime)) {
            lastVelocityAlertAtMs.update(eventTime);
            out.collect(FraudAlert.of(VELOCITY_RULE, payment, recent + " платежей за минуту"));
        }
    }

    private boolean velocityAlertIsDue(long eventTime) throws Exception {
        Long last = lastVelocityAlertAtMs.value();
        return last == null || eventTime - last >= WINDOW.toMillis();
    }
}

Шаг 5. Тест на harness

Фабрика ProcessFunctionTestHarnesses.forKeyedBroadcastProcessFunction принимает функцию, селектор ключа, тип ключа и дескрипторы broadcast state. Правило подаётся методом processBroadcastElement, платёж обычным processElement. Третий тест в файле снапшота относится к заданию, поэтому возьми строки до него и закрой класс фигурной скобкой.

src/test/java/dev/study/payguard/pipeline/DynamicRulesTest.java (строки 1-52)
package dev.study.payguard.pipeline;

import static dev.study.payguard.testing.Payments.paymentOnCard;
import static org.assertj.core.api.Assertions.assertThat;

import dev.study.payguard.model.FraudAlert;
import dev.study.payguard.model.PaymentEvent;
import dev.study.payguard.model.RuleConfig;
import java.util.List;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.streaming.util.ProcessFunctionTestHarnesses;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.apache.flink.streaming.util.KeyedBroadcastOperatorTestHarness;

class DynamicRulesTest {

    private KeyedBroadcastOperatorTestHarness<String, PaymentEvent, RuleConfig, FraudAlert> harness;

    @BeforeEach
    void setUp() throws Exception {
        harness = ProcessFunctionTestHarnesses.forKeyedBroadcastProcessFunction(
                new DynamicRules(new RuleConfig(2, List.of())),
                PaymentEvent::cardId,
                Types.STRING,
                DynamicRules.RULES);
    }

    private void pay(String id, long time) throws Exception {
        harness.processElement(paymentOnCard(id, "c1", time), time);
    }

    private List<String> alertIds() {
        return harness.extractOutputValues().stream().map(FraudAlert::alertId).toList();
    }

    @Test
    void usesDefaultsUntilFirstUpdateArrives() throws Exception {
        pay("p1", 1_000);
        pay("p2", 2_000);
        pay("p3", 3_000);
        assertThat(alertIds()).containsExactly("velocity:p3");
    }

    @Test
    void updatedLimitAppliesWithoutRestart() throws Exception {
        harness.processBroadcastElement(new RuleConfig(100, List.of()), 0);
        pay("p1", 1_000);
        pay("p2", 2_000);
        pay("p3", 3_000);
        assertThat(alertIds()).isEmpty();
    }

Первый тест проверяет значения по умолчанию, лимит 2 и алерт на третьем платеже. Второй присылает лимит 100 и убеждается, что те же три платежа алерта не дают. Это и есть смена правил без перезапуска в миниатюре, функция та же, harness тот же.

терминал
./gradlew test

Помни, что забытый Serializable из шага 1 этот тест не заметит. Harness не отправляет функцию по сети.

Шаг 6. Подключи поток правил

В PayGuardJob появляется второй источник и оператор с двумя входами.

что изменилось в src/main/java/dev/study/payguard/job/PayGuardJob.java
@@ -6,19 +6,24 @@ import dev.study.payguard.model.ParsedRecord;
 import dev.study.payguard.model.PaymentEvent;
 import dev.study.payguard.model.RejectedRecord;
+import dev.study.payguard.model.RuleConfig; import dev.study.payguard.pipeline.AmountSpikeRule;
 import dev.study.payguard.pipeline.CardSessionWindow;
 import dev.study.payguard.pipeline.DeduplicateFunction;
+import dev.study.payguard.pipeline.DynamicRules; import dev.study.payguard.pipeline.LatenessProbe;
+import dev.study.payguard.pipeline.LenientJsonSchema; import dev.study.payguard.pipeline.MerchantStatsWindow;
 import dev.study.payguard.pipeline.ParsedRecordSchema;
 import dev.study.payguard.pipeline.PaymentWatermarks;
+import dev.study.payguard.pipeline.RuleWatermarks; import dev.study.payguard.pipeline.SplitParsedRecords;
-import dev.study.payguard.pipeline.VelocityRule;+import java.util.List; import org.apache.flink.configuration.Configuration;
 import org.apache.flink.configuration.PipelineOptions;
 import org.apache.flink.connector.kafka.source.KafkaSource;
 import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
+import org.apache.flink.streaming.api.datastream.BroadcastStream; import org.apache.flink.streaming.api.datastream.DataStream;
 import org.apache.flink.streaming.api.datastream.KeyedStream;
 import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
@@ -39,7 +44,7 @@         attachCardSessions(payments, config);
 
         KeyedStream<PaymentEvent, String> byCard = payments.keyBy(PaymentEvent::cardId);
-        sinkAlerts(fraudAlerts(byCard, config), config);+        sinkAlerts(fraudAlerts(env, byCard, config), config); 
         env.execute("payguard");
     }
@@ -88,17 +93,24 @@     }
 
     /** Все правила антифрода, сведённые в один поток алертов. */
-    private static DataStream<FraudAlert> fraudAlerts(KeyedStream<PaymentEvent, String> byCard, JobConfig config) {-        DataStream<FraudAlert> velocityAlerts = byCard-                .process(new VelocityRule(config.velocityMaxPayments()))-                .name("rule-velocity")-                .uid("rule-velocity");+    private static DataStream<FraudAlert> fraudAlerts(+            StreamExecutionEnvironment env, KeyedStream<PaymentEvent, String> byCard, JobConfig config) {+        BroadcastStream<RuleConfig> rules = env+                .fromSource(ruleSource(config), RuleWatermarks.neverHoldBack(), "rules-config")+                .uid("rules-config")+                .broadcast(DynamicRules.RULES);++        DataStream<FraudAlert> dynamicAlerts = byCard+                .connect(rules)+                .process(new DynamicRules(new RuleConfig(config.velocityMaxPayments(), List.of())))+                .name("rules-dynamic")+                .uid("rules-dynamic");         DataStream<FraudAlert> amountAlerts = byCard
                 .process(new AmountSpikeRule(config.amountSpikeLimitMinor()))
                 .name("rule-amount-spike")
                 .uid("rule-amount-spike");
 
-        return velocityAlerts.union(amountAlerts);+        return dynamicAlerts.union(amountAlerts);     }
 
     private static void sinkAlerts(DataStream<FraudAlert> allAlerts, JobConfig config) {
@@ -118,6 +130,16 @@                 .build();
     }
 
+    private static KafkaSource<RuleConfig> ruleSource(JobConfig config) {+        return KafkaSource.<RuleConfig>builder()+                .setBootstrapServers(config.bootstrapServers())+                .setTopics(config.rulesTopic())+                .setGroupId(config.groupId() + "-rules")+                .setStartingOffsets(OffsetsInitializer.earliest())+                .setValueOnlyDeserializer(new LenientJsonSchema<>(RuleConfig.class))+                .build();+    }+     private PayGuardJob() {
     }
 }

Источник правил читает свой топик со своей группой потребителей, к имени группы добавлен суффикс -rules. Значения разбирает LenientJsonSchema, время задаёт RuleWatermarks.neverHoldBack(). Вызов broadcast(DynamicRules.RULES) делает поток широковещательным, а byCard.connect(rules) соединяет его с платежами, разбитыми по карте. Значения по умолчанию берутся из параметров запуска, как раньше лимит VelocityRule.

Источник правил начинает с OffsetsInitializer.earliest(). Чекпоинтов у нас пока нет, и после каждого запуска broadcast state пуст. Чтение топика с начала восстанавливает настройки, а последнее сообщение перезаписывает предыдущие. Kafka хранит порядок только внутри партиции, поэтому с четырьмя партициями «последнее» при повторном чтении может оказаться не тем, что отправили последним. Это ещё один довод за одну партицию в проде.

Шаг 7. Собери и запусти

Порядок прежний, сначала сборка, потом стенд.

терминал
./gradlew shadowJar
docker compose up -d --wait
docker compose exec jobmanager flink list

Останови задание прошлого урока командой flink cancel <JobID>, затем отправь новое и запусти генератор. Без аргументов генератор пишет 100 платежей в секунду.

терминал
scripts/submit.sh
./gradlew loadgen
вывод submit.sh
Job has been submitted with JobID 524a5209d6e80a468c3861e3474d31cf

Посмотри на вершины задания.

терминал
JOB=$(curl -s localhost:8081/jobs | jq -r '.jobs[] | select(.status == "RUNNING") | .id')
curl -s localhost:8081/jobs/$JOB | jq -c '{name, state, vertices: [.vertices[] | {name, parallelism, status}]}'
вывод
{"name":"payguard","state":"RUNNING","vertices":[{"name":"Source: payments-raw -> split-parsed -> (dlq-sink: Writer -> dlq-sink: Committer, lateness-probe)","parallelism":2,"status":"RUNNING"},{"name":"Source: rules-config","parallelism":2,"status":"RUNNING"},{"name":"deduplicate","parallelism":2,"status":"RUNNING"},{"name":"merchant-stats-1m -> (merchant-stats-sink: Writer -> merchant-stats-sink: Committer, too-late-sink: Writer -> too-late-sink: Committer)","parallelism":2,"status":"RUNNING"},{"name":"card-sessions -> card-sessions-sink: Writer -> card-sessions-sink: Committer","parallelism":2,"status":"RUNNING"},{"name":"rules-dynamic","parallelism":2,"status":"RUNNING"},{"name":"rule-amount-spike","parallelism":2,"status":"RUNNING"},{"name":"alerts-sink: Writer -> alerts-sink: Committer","parallelism":2,"status":"RUNNING"}]}

Источник правил стал отдельной вершиной Source: rules-config. Вершина rules-dynamic тоже стоит особняком. К ней ведут ребро по ключу карты и ребро BROADCAST, а цепочка, как ты помнишь по уроку 4, рвётся на любом разбиении, кроме forward. Теперь спроси метрики и watermark вершины правил.

терминал
V=$(curl -s localhost:8081/jobs/$JOB | jq -r '.vertices[] | select(.name == "rules-dynamic") | .id')
curl -s "localhost:8081/jobs/$JOB/vertices/$V/subtasks/metrics?get=numRecordsIn,numRecordsOut,busyTimeMsPerSecond,backPressuredTimeMsPerSecond&agg=min,max,sum" | jq -c .
curl -s localhost:8081/jobs/$JOB/vertices/$V/watermarks | jq -c '[.[].value]'
вывод
[{"id":"numRecordsIn","min":8530.0,"max":8656.0,"sum":17186.0},{"id":"numRecordsOut","min":20.0,"max":22.0,"sum":42.0},{"id":"busyTimeMsPerSecond","min":3.0,"max":3.0,"sum":6.0},{"id":"backPressuredTimeMsPerSecond","min":0.0,"max":0.0,"sum":0.0}]
["1794085908663","1794085908663"]

Это числа с нашего стенда. В numRecordsIn 17186 записей, это платежи, правил в топике ещё не было. У вершины Source: rules-config в тот же момент было 0 записей. На выход ушло 42 алерта velocity. Watermark обеих подзадач равен 1794085908663, у deduplicate и окон число такое же. Вход правил время не держит, хотя по нему не пришло ни одной записи.

Шаг 8. Поменяй правило на лету

Отправь в топик правило, которое поднимает лимит частоты до 1000 и блокирует Польшу.

терминал
echo '{"velocityMaxPayments":1000,"blockedCountries":["PL"]}' | docker compose exec -T kafka \
  /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic rules.config

kafka-console-producer.sh отправляет каждую строку ввода отдельным сообщением. Флаг -T у docker compose exec не выделяет терминал в контейнере, так строка из echo доходит до продюсера как через обычный конвейер. Флаг --bootstrap-server задаёт адрес брокера изнутри контейнера.

Повтори команду с метриками из шага 7. В метриках вершины Source: rules-config появится одна запись, и задание при этом не перезапускалось. Чтобы увидеть смену порога, сравни numRecordsOut вершины rules-dynamic до и после. С лимитом 1000 новых алертов velocity быть не должно. Список стран твоя функция пока не читает. На нашем стенде в снапшоте уже была проверка стран из задания, и за 25 секунд при 100 платежах в секунду после этого сообщения пришло 539 алертов blocked-country. У тебя они появятся, когда сделаешь задание.

Теперь проверь, что опечатка не роняет задание.

терминал
echo 'not-json' | docker compose exec -T kafka \
  /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic rules.config
docker compose logs taskmanager | grep RuleConfig

В логе TaskManager появится предупреждение уровня WARN с текстом «Пропускаю запись, которую не удалось разобрать как RuleConfig» и стеком исключения Jackson. Задание остаётся в RUNNING и работает с прежним правилом.

Шаг 9. Как увидеть застрявшее время

Если хочешь проверить ловушку из теории своими глазами, замени в PayGuardJob вызов RuleWatermarks.neverHoldBack() на WatermarkStrategy.noWatermarks(), собери и перезапусти задание. Мы этот вариант на стенде не запускали, поэтому ниже то, что предсказывает код. Команда с watermarks из шага 7 вернёт для rules-dynamic число -9223372036854775808, это Long.MIN_VALUE, а у deduplicate время будет расти. Не забудь вернуть neverHoldBack.

В исходниках

У задачи с двумя входами на каждый вход свой измеритель watermark, а наружу показывается их минимум.

apache/flink@release-2.2.1 · AbstractTwoInputStreamTask.java, строки 49-55
public AbstractTwoInputStreamTask(Environment env) throws Exception {
    super(env);

    input1WatermarkGauge = new WatermarkGauge();
    input2WatermarkGauge = new WatermarkGauge();
    minInputWatermarkGauge = new MinWatermarkGauge(input1WatermarkGauge, input2WatermarkGauge);
}

Измеритель входа начинает с Long.MIN_VALUE и меняется, только когда по входу пришёл watermark. Минимум считается обычным min.

apache/flink@release-2.2.1 · WatermarkGauge.java, строки 24-36
public class WatermarkGauge implements Gauge<Long> {

    private volatile long currentWatermark = Long.MIN_VALUE;

    public void setCurrentWatermark(long watermark) {
        currentWatermark = watermark;
    }

    @Override
    public Long getValue() {
        return currentWatermark;
    }
}
apache/flink@release-2.2.1 · MinWatermarkGauge.java, строки 28-40
public class MinWatermarkGauge implements Gauge<Long> {
    private final WatermarkGauge[] watermarkGauges;

    public MinWatermarkGauge(WatermarkGauge... watermarkGauges) {
        checkArgument(watermarkGauges.length > 0);
        this.watermarkGauges = watermarkGauges;
    }

    @Override
    public Long getValue() {
        return Arrays.stream(watermarkGauges).mapToLong(WatermarkGauge::getValue).min().orElse(0);
    }
}

Минимум регистрируется метрикой задачи с именем currentInputWatermark.

apache/flink@release-2.2.1 · AbstractTwoInputStreamTask.java, строки 87-93
mainOperator
        .getMetricGroup()
        .gauge(MetricNames.IO_CURRENT_INPUT_WATERMARK, minInputWatermarkGauge);
// wrap watermark gauge since registered metrics must be unique
getEnvironment()
        .getMetricGroup()
        .gauge(MetricNames.IO_CURRENT_INPUT_WATERMARK, minInputWatermarkGauge::getValue);

Обработчик REST для /watermarks отдаёт именно эту метрику по каждой подзадаче вершины.

apache/flink@release-2.2.1 · JobVertexWatermarksHandler.java, строки 89-98
for (AccessExecutionVertex taskVertex : taskVertices) {
    String id =
            taskVertex.getParallelSubtaskIndex()
                    + "."
                    + MetricNames.IO_CURRENT_INPUT_WATERMARK;
    String watermarkValue = taskMetricStore.getMetric(id);
    if (watermarkValue != null) {
        metrics.add(new Metric(id, watermarkValue));
    }
}

Задание. Блокировка стран

В RuleConfig уже есть поле blockedCountries, но DynamicRules его не читает. Допиши правило, которое срабатывает на платежи из заблокированных стран.

  1. Если страна платежа есть в списке blockedCountries текущих настроек, функция выдаёт алерт. Идентификатор правила blocked-country, объяви его константой рядом с VELOCITY_RULE. В поле detail напиши, какая страна заблокирована.
  2. Алерт выдаётся на каждый такой платёж, без ограничения «раз в минуту», как у velocity.
  3. Сообщение без поля blockedCountries не должно ронять задание.
  4. Добавь в DynamicRulesTest тест. Пришли правило с одной страной и проверь, что первый же платёж из неё даёт ровно один алерт с идентификатором blocked-country:<paymentId>.

Готово, если ./gradlew test проходит, а на стенде после правила с ["PL"] в fraud.alerts появляются алерты blocked-country, примерно на каждый пятый платёж.

Подсказки. Заготовка paymentOnCard из testing/Payments создаёт платёж из страны DE. Подумай, что Jackson положит в поле record, если в JSON такого поля нет. Помни, что настройки в processElement только читаются, менять список нельзя.

Ответ

Глоссарий урока

Broadcast state
Состояние оператора, копия которого есть в каждой его подзадаче. Пишется только со стороны широковещательного потока, читается при обработке остальных записей. Устроено как словарь и живёт в куче JVM.
Широковещательный поток (BroadcastStream)
Поток, каждая запись которого доставляется во все подзадачи следующего оператора. Получается вызовом broadcast с дескрипторами состояния.
Разбиение BROADCAST
Способ раздачи записей, при котором каждая запись копируется во все каналы к следующему оператору. Его ставит BroadcastPartitioner.
KeyedBroadcastProcessFunction
Функция для потока с ключом, соединённого с широковещательным. processElement обрабатывает записи с ключом и читает broadcast state, processBroadcastElement обрабатывает правила и пишет в него.
Дескриптор состояния (MapStateDescriptor)
Описание состояния, имя и типы ключа и значения. По нему оператор находит состояние, поэтому один и тот же дескриптор передают в broadcast и в функцию.
Оператор с двумя входами (two-input operator)
Оператор, который читает два потока, например после connect. Его watermark равен минимуму watermark двух входов.
Watermark
Служебный элемент потока с меткой T. Обещает, что событий с меткой T и раньше больше не будет, и двигает время события операторов.
Long.MIN_VALUE и Long.MAX_VALUE
Наименьшее и наибольшее значения типа long в Java. Первое означает, что watermark ещё не было, второе обещает, что старых данных от входа не будет никогда.
Простой входа (idleness)
Признак входа, по которому дольше таймаута нет записей. Простаивающий вход не участвует в минимуме watermark. Включается через withIdleness.
Детерминированный код
Код, который при одинаковом входе всегда даёт одинаковый результат. Не читает часы, случайные числа и внешние системы.
Состояние с ключом (keyed state)
Состояние, привязанное к ключу записи, у нас к карте. Каждая подзадача хранит только свои ключи. Тема урока 7.
Таймер (timer)
Отложенный вызов onTimer для ключа. Таймер по времени события срабатывает, когда watermark оператора дошёл до его метки.
Чекпоинт (checkpoint)
Согласованный снимок состояния всего задания, из которого оно восстанавливается после падения. Подробно в уроке 11.
Сериализация Java (Serializable)
Встроенный в Java способ превратить объект в байты. Через неё функции задания уезжают с клиента на TaskManager, поэтому всё, на что ссылаются их поля, должно её поддерживать.
Схема десериализации (DeserializationSchema)
Класс, превращающий байты записи источника в объект Java. Если он вернул null, источник Kafka пропускает запись.
Jackson
Библиотека Java для JSON. ObjectMapper из неё читает JSON в объекты и умеет создавать record через его конструктор.
Консольные клиенты Kafka
Скрипты kafka-console-producer.sh и kafka-console-consumer.sh из поставки Kafka. Первый отправляет каждую строку ввода сообщением, второй печатает сообщения топика.
Партиция (partition)
Часть топика Kafka со своим порядком записей. Порядок между разными партициями Kafka не гарантирует.
Test harness
Обёртка из тестовых jar Flink, которая запускает один оператор в JVM теста. Записи, правила и watermark подаются вручную. Функцию по сети не отправляет.
Broadcast variable в Spark
Значение, которое драйвер Spark один раз рассылает на экзекуторы для чтения. Поменять его можно только созданием новой переменной.
Подзадача (subtask)
Один параллельный экземпляр оператора. При параллелизме 2 у rules-dynamic две подзадачи, и у каждой своя копия правил.
REST-интерфейс
HTTP-интерфейс JobManager на порту 8081. Ресурс /jobs/.../vertices/.../watermarks отдаёт метрику currentInputWatermark подзадач вершины.

Урок 9 · Время и состояние

Async I/O и соединение потоков

После урока ты сможешь обращаться из задания во внешнюю базу без потери пропускной способности и соединять два потока событий по времени, понимая, сколько это стоит в состоянии.

Алерт без контекста

Аналитик антифрода получает алерт «11 платежей за минуту» и первым делом спрашивает, что за продавец. Уровень риска продавца лежит в PostgreSQL, его ведёт другая команда. Без этой информации алерт приходится проверять руками, поэтому PayGuard должен дописывать риск в алерт сам. Такую операцию называют обогащением.

Самый очевидный способ это map, внутри которого JDBC-запрос. Подзадача Flink обрабатывает записи одну за другой в одном потоке. Пока запрос идёт в базу, поток стоит и ничего больше не делает. Если запрос занимает 5 мс, подзадача выдаст не больше 200 записей в секунду, даже когда процессор свободен на 99 %. Дальше очередь растёт в сетевых буферах, и backpressure доходит до источника.

Вторая задача урока тоже про связь с внешним миром. Банк-эмитент присылает подтверждения платежей отдельным потоком. Нужно соединить платёж с подтверждением, которое пришло в течение пяти минут, и понять, сколько данных Flink будет держать в состоянии, пока ждёт пару.

Прачечная самообслуживания

У тебя гора белья и стиральная машина, стирка идёт 40 минут. Если стоять у машины и ждать, за вечер получится три загрузки. Время уходит на ожидание, руки при этом свободны. В прачечной самообслуживания ты загружаешь сразу восемь машин и забираешь бельё из той, что закончила первой. Пропускная способность теперь равна числу машин, делённому на время стирки.

У прачечной есть правила, и у Flink они те же. Машин ограниченное число, и когда все заняты, новая корзина ждёт у входа. Это ёмкость очереди (capacity). Иногда машина зависает, и ты через час забираешь бельё мокрым, чтобы не ждать до утра. Это timeout. Если бельё надо раскладывать в том порядке, в каком загружал, чистое бельё из быстрых машин лежит и ждёт, пока достирается самая первая. Это режим ordered. Перед уходом на обед ты записываешь, какие корзины сейчас в машинах. Если отключат свет, по списку можно загрузить их заново. Так работает связь Async I/O с чекпоинтом.

Async I/O во Flink

Асинхронный оператор подключается вызовом AsyncDataStream.unorderedWait или orderedWait. Ему передают поток, функцию, timeout и capacity. Функция наследует RichAsyncFunction и реализует метод asyncInvoke. Метод получает запись и объект ResultFuture, запускает запрос и сразу возвращает управление. Когда ответ придёт, кто-то вызовет ResultFuture.complete, и только тогда результат уйдёт дальше по графу.

Документация отдельно предупреждает, что asyncInvoke вызывается последовательно в потоке подзадачи. Если внутри него ждать ответа, например вызвать join() у future, асинхронность пропадает и получается тот же синхронный map. У JDBC нет асинхронного клиента, поэтому в PayGuard запрос уходит в собственный пул потоков через CompletableFuture.supplyAsync. Документация называет такой путь допустимым, но менее эффективным, чем настоящий асинхронный клиент.

В исходниках

Ответ из базы приходит в чужом потоке, из пула AlertEnrichment. Посмотрим, что оператор делает с вызовом complete. Результат упаковывается в задачу для mailbox, то есть в очередь дел самой подзадачи, и обрабатывается её потоком. Поэтому выдавать записи дальше из потока пула безопасно, гонок с обработкой входа нет.

apache/flink@release-2.2.1 · AsyncWaitOperator.java, строки 647-670
private void processInMailbox(Collection<OUT> results) {
    // move further processing into the mailbox thread
    mailboxExecutor.execute(
            () -> processResults(results),
            "Result in AsyncWaitOperator of input %s",
            results);
}

private void processResults(Collection<OUT> results) {
    // Cancel the timer once we've completed the stream record buffer entry. This will
    // remove the registered
    // timer task
    if (timeoutTimer != null) {
        // canceling in mailbox thread avoids
        // https://issues.apache.org/jira/browse/FLINK-13635
        timeoutTimer.cancel(true);
    }

    // update the queue entry with the result
    resultFuture.complete(results);
    // now output all elements from the queue that have been completed (in the correct
    // order)
    outputCompletedElement();
}

Второй путь завершения это completeExceptionally. Он вызывает failExternally, и вся подзадача падает с перезапуском задания. Одна недоступная запись в справочнике обернётся рестартом, поэтому в PayGuard ошибка базы превращается в значение UNKNOWN и счётчик в метриках.

apache/flink@release-2.2.1 · AsyncWaitOperator.java, строки 672-691
@Override
public void completeExceptionally(Throwable error) {
    // already completed, so ignore exception
    if (!completed.compareAndSet(false, true)) {
        return;
    }

    // signal failure through task
    getContainingTask()
            .getEnvironment()
            .failExternally(
                    new Exception(
                            "Could not complete the stream element: " + inputRecord + '.',
                            error));

    // complete with empty result, so that we remove timer and move ahead processing (to
    // leave potentially
    // blocking section in #addToWorkQueue or #waitInFlightInputsFinished)
    processInMailbox(Collections.emptyList());
}

Capacity и пропускная способность

Оператор держит очередь записей, по которым запрос уже отправлен, а результат ещё не выдан. Её размер ограничен capacity. Отсюда простая оценка сверху. Пропускная способность асинхронного оператора не больше, чем параллелизм, умноженный на capacity и делённый на задержку запроса.

Разберём на числах. Пусть запрос идёт 5 мс, параллелизм 2, capacity 200. Формула даёт 2 × 200 / 0,005 = 80 000 записей в секунду. Синхронный map при тех же условиях дал бы 2 / 0,005 = 400. Только в PayGuard одновременно в базу идут не 200 запросов. У каждой подзадачи пул из 8 потоков и 8 соединений, поэтому настоящий потолок равен 2 × 8 / 0,005 = 3200 записей в секунду. В формулу подставляют наименьшее из ограничений, и capacity лишь одно из них.

Остальные места в очереди тоже работают. Они поглощают всплески, пока пул разбирает запросы, и держат результаты из кэша, которые готовы сразу. Есть и цена. Каждая запись в очереди занимает память и попадает в чекпоинт, поэтому capacity в десятки тысяч не ставят. Документация советует при нехватке скорее поднять параллелизм.

В исходниках

Что происходит, когда очередь полна. Метод addToWorkQueue пытается положить запись и, пока не получится, отдаёт управление mailbox. Подзадача продолжает выдавать готовые результаты, но новые записи со входа не берёт. Это и есть backpressure, который поднимается вверх по графу.

apache/flink@release-2.2.1 · AsyncWaitOperator.java, строки 346-355
private ResultFuture<OUT> addToWorkQueue(StreamElement streamElement)
        throws InterruptedException {

    Optional<ResultFuture<OUT>> queueEntry;
    while (!(queueEntry = queue.tryPut(streamElement)).isPresent()) {
        mailboxExecutor.yield();
    }

    return queueEntry.get();
}

Сама проверка места в упорядоченной очереди. Место освобождается, только когда запись покинула очередь, то есть её результат выдан дальше. Готовый, но не выданный результат продолжает занимать место.

apache/flink@release-2.2.1 · OrderedStreamElementQueue.java, строки 95-107
public Optional<ResultFuture<OUT>> tryPut(StreamElement streamElement) {
    if (queue.size() < capacity) {
        StreamElementQueueEntry<OUT> queueEntry = createEntry(streamElement);

        queue.add(queueEntry);

        LOG.debug(
                "Put element into ordered stream element queue. New filling degree "
                        + "({}/{}).",
                queue.size(),
                capacity);

        return Optional.of(queueEntry);

Ordered и unordered

Запросы возвращаются в случайном порядке. В режиме unordered результат уходит дальше сразу, как только готов. В режиме ordered он ждёт, пока выйдут все записи, пришедшие раньше. Один медленный запрос в голове очереди задерживает всех за ним, и они продолжают занимать места. Такую ситуацию называют head-of-line blocking.

Попробуй сам. Каждый восьмой запрос в модели в восемь раз медленнее остальных. Запусти в режиме unordered, потом включи ordered и запусти снова. Серия задержек в обоих прогонах одинаковая.

Очередь асинхронного оператора

Нажми кнопку, и модель прогонит 4 секунды работы одной подзадачи с бесконечной очередью записей на входе.

С watermark режим unordered ведёт себя сложнее. Записи между двумя watermark могут обгонять друг друга. Watermark не обгоняет записи до него, и записи после него не обгоняют его самого. При частых watermark unordered теряет часть своего преимущества. В PayGuard порядок алертов неважен, поэтому стоит unorderedWait.

В исходниках

Упорядоченная очередь считает готовой только голову. Пока первая запись не завершена, hasCompletedElements возвращает false, сколько бы записей за ней ни было готово.

apache/flink@release-2.2.1 · OrderedStreamElementQueue.java, строки 62-73
@Override
public boolean hasCompletedElements() {
    return !queue.isEmpty() && queue.peek().isDone();
}

@Override
public void emitCompletedElement(TimestampedCollector<OUT> output) {
    if (hasCompletedElements()) {
        final StreamElementQueueEntry<OUT> head = queue.poll();
        head.emitResult(output);
    }
}

Неупорядоченная очередь делит записи на сегменты, и границы сегментов проходят по watermark. Обгон возможен только внутри сегмента.

apache/flink@release-2.2.1 · UnorderedStreamElementQueue.java, строки 42-52
/**
 * Unordered implementation of the {@link StreamElementQueue}. The unordered stream element queue
 * provides asynchronous results as soon as they are completed. Additionally, it maintains the
 * watermark-stream record order.
 *
 * <p>Elements can be logically grouped into different segments separated by watermarks. A segment
 * needs to be completely emitted before entries from a following segment are emitted. Thus, no
 * stream record can be overtaken by a watermark and no watermark can overtake a stream record.
 * However, stream records falling in the same segment between two watermarks can overtake each
 * other (their emission order is not guaranteed).
 */

Timeout

Timeout защищает от запросов, которые не вернутся никогда. Его отсчёт начинается, когда запись встала в очередь оператора. Время в пуле потоков AlertEnrichment тоже входит в эти 2 секунды. Если пул перегружен, запись может истечь, даже не дойдя до базы. По умолчанию по истечении timeout функция завершает запись исключением, а исключение, как мы видели, перезапускает задание. Поэтому AlertEnrichment переопределяет метод timeout и выдаёт алерт с риском UNKNOWN.

В исходниках

Таймер регистрируется в processElement сразу после того, как запись получила место в очереди, и до вызова пользовательской функции.

apache/flink@release-2.2.1 · AsyncWaitOperator.java, строки 268-277
} else {
    final ResultHandler resultHandler = new ResultHandler(element, entry);

    // register a timeout for the entry if timeout is configured
    if (timeout > 0L) {
        resultHandler.registerTimeout(getProcessingTimeService(), timeout);
    }

    userFunction.asyncInvoke(element.getValue(), resultHandler);
}

Поведение по умолчанию задано в интерфейсе AsyncFunction.

apache/flink@release-2.2.1 · AsyncFunction.java, строки 89-99
/**
 * {@link AsyncFunction#asyncInvoke} timeout occurred. By default, the result future is
 * exceptionally completed with a timeout exception.
 *
 * @param input element coming from an upstream task
 * @param resultFuture to be completed with the result data
 */
default void timeout(IN input, ResultFuture<OUT> resultFuture) throws Exception {
    resultFuture.completeExceptionally(
            new TimeoutException("Async function call has timed out."));
}

Что попадает в чекпоинт

Запрос в полёте нельзя сохранить на диск, соединение с базой не сериализуется. Flink сохраняет то, что можно, то есть входные записи из очереди. Они лежат в состоянии оператора (operator state), которое не привязано к ключу. После восстановления оператор при открытии отправляет их в processElement заново, и запросы уходят повторно.

В исходниках

При снимке весь список записей очереди кладётся в списочное состояние оператора. Метод values возвращает входные элементы, результаты в снимок не попадают.

apache/flink@release-2.2.1 · AsyncWaitOperator.java, строки 291-311
public void snapshotState(StateSnapshotContext context) throws Exception {
    super.snapshotState(context);

    ListState<StreamElement> partitionableState =
            getOperatorStateBackend()
                    .getListState(
                            new ListStateDescriptor<>(STATE_NAME, inStreamElementSerializer));

    try {
        partitionableState.update(queue.values());
    } catch (Exception e) {
        partitionableState.clear();

        throw new Exception(
                "Could not add stream element queue entries to operator state "
                        + "backend of operator "
                        + getOperatorName()
                        + '.',
                e);
    }
}

При открытии оператора восстановленные элементы снова проходят через обработку, как будто только что пришли со входа.

apache/flink@release-2.2.1 · AsyncWaitOperator.java, строки 225-241
if (recoveredStreamElements != null) {
    for (StreamElement element : recoveredStreamElements.get()) {
        if (element.isRecord()) {
            processElement(element.<IN>asRecord());
        } else if (element.isWatermark()) {
            processWatermark(element.asWatermark());
        } else if (element.isLatencyMarker()) {
            processLatencyMarker(element.asLatencyMarker());
        } else {
            throw new IllegalStateException(
                    "Unknown record type "
                            + element.getClass()
                            + " encountered while opening the operator.");
        }
    }
    recoveredStreamElements = null;
}
⚠
Повтор запроса после сбоя

Документация называет гарантии асинхронного оператора exactly-once и тут же пишет, что после восстановления запросы отправляются заново. Внешний вызов при этом может выполниться дважды. Чтение риска продавца повторять безопасно. Если асинхронная функция отправляет SMS или списывает деньги, после восстановления клиент получит второе сообщение. Такие вызовы делают идемпотентными, например передают в API ключ запроса, по которому повтор узнаётся.

Кэш рядом с оператором

Продавцов в PayGuard 50, их риск меняется редко. Ходить в базу за каждым алертом незачем. AlertEnrichment держит в каждой подзадаче ConcurrentHashMap с риском и временем, до которого запись считается свежей. Срок жизни 5 минут. Значит, изменение в базе дойдёт до алертов с задержкой до пяти минут, и это осознанная плата за нагрузку на базу. Кэш живёт в памяти JVM и в чекпоинт не попадает, после рестарта он наполняется заново.

Кэш без ограничения размера годится для тысяч ключей. На миллионах продавцов он съест кучу TaskManager, и тогда берут библиотеку с вытеснением, например Caffeine. Если справочник большой и меняется часто, его лучше загружать в Flink потоком изменений и держать в состоянии. Как получить такой поток из PostgreSQL, разберём в уроке 15.

Ещё одно решение снижает нагрузку сильнее кэша. Мы обогащаем алерты, их на порядки меньше, чем платежей. В прогоне из практики дедупликация пропустила к правилам 17214 платежей, а правила выдали 58 алертов, поэтому в базу ушло бы примерно в 300 раз меньше запросов, чем при обогащении каждого платежа.

Interval join

Теперь о соединении двух потоков. Платёж и подтверждение связаны идентификатором paymentId, и подтверждение приходит позже, в пределах пяти минут. Окно здесь не подходит. Платёж в 12:00:58 и подтверждение в 12:01:03 попадут в разные минутные окна и не встретятся.

Interval join задаёт интервал вокруг каждой записи левого потока. Записи b правого потока соединяются с записью a, если у них один ключ и выполняется условие a.ts + lowerBound <= b.ts <= a.ts + upperBound. В PayGuard левый поток это платежи, правый это подтверждения, границы 0 и 5 минут. Соединение работает только во времени события и выдаёт только совпавшие пары, это inner join. Временной меткой пары становится наибольшая из двух.

платежи подтверждения интервал от t до t + 5 мин платёж p1, время t раньше t пара выдана позже t + 5 мин когда watermark дойдёт сюда, p1 удаляется из состояния

Оператор держит обе стороны в keyed state. Каждая пришедшая запись кладётся в свой буфер и сверяется с буфером другой стороны. Чтобы буферы не росли бесконечно, на каждую запись ставится таймер очистки по времени события. Платёж с границами 0 и 5 минут живёт в состоянии, пока watermark не пройдёт t + 5 мин. Отсюда оценка объёма. Число платежей в состоянии примерно равно частоте платежей, умноженной на верхнюю границу. При 200 платежах в секунду это 200 × 300 = 60 000 записей. Расширишь интервал до часа, и состояние вырастет в 12 раз.

Запись, время которой уже позади watermark, в буфер не попадает. Если не задать побочный выход для поздних данных, она пропадает молча. В PayGuard watermark отстаёт от свежих платежей на 10 секунд, а генератор присылает часть платежей с опозданием до 30 секунд. Платёж, опоздавший больше чем на 10 секунд, пары не получит.

В исходниках

Буферы обеих сторон создаются как MapState в keyed state. Ключ карты имеет тип Long, это временная метка записи, значение хранит список записей с этой меткой.

apache/flink@release-2.2.1 · IntervalJoinOperator.java, строки 162-183
@Override
public void initializeState(StateInitializationContext context) throws Exception {
    super.initializeState(context);

    this.leftBuffer =
            context.getKeyedStateStore()
                    .getMapState(
                            new MapStateDescriptor<>(
                                    LEFT_BUFFER,
                                    LongSerializer.INSTANCE,
                                    new ListSerializer<>(
                                            new BufferEntrySerializer<>(leftTypeSerializer))));

    this.rightBuffer =
            context.getKeyedStateStore()
                    .getMapState(
                            new MapStateDescriptor<>(
                                    RIGHT_BUFFER,
                                    LongSerializer.INSTANCE,
                                    new ListSerializer<>(
                                            new BufferEntrySerializer<>(rightTypeSerializer))));
}

Обработка записи любой стороны. Поздняя запись уходит в побочный выход и дальше не обрабатывается. Остальные кладутся в свой буфер и сверяются со всеми временными метками другого буфера. Последние строки ставят таймер очистки. У правой стороны в PayGuard верхняя относительная граница равна нулю, поэтому подтверждение удаляется, как только watermark пройдёт его собственное время. Платёж с более ранним временем, пришедший после этого, оператор всё равно отбросил бы как поздний.

apache/flink@release-2.2.1 · IntervalJoinOperator.java, строки 232-262
if (isLate(ourTimestamp)) {
    sideOutput(ourValue, ourTimestamp, isLeft);
    return;
}

addToBuffer(ourBuffer, ourValue, ourTimestamp);

for (Map.Entry<Long, List<BufferEntry<OTHER>>> bucket : otherBuffer.entries()) {
    final long timestamp = bucket.getKey();

    if (timestamp < ourTimestamp + relativeLowerBound
            || timestamp > ourTimestamp + relativeUpperBound) {
        continue;
    }

    for (BufferEntry<OTHER> entry : bucket.getValue()) {
        if (isLeft) {
            collect((T1) ourValue, (T2) entry.element, ourTimestamp, timestamp);
        } else {
            collect((T1) entry.element, (T2) ourValue, timestamp, ourTimestamp);
        }
    }
}

long cleanupTime =
        (relativeUpperBound > 0L) ? ourTimestamp + relativeUpperBound : ourTimestamp;
if (isLeft) {
    internalTimerService.registerEventTimeTimer(CLEANUP_NAMESPACE_LEFT, cleanupTime);
} else {
    internalTimerService.registerEventTimeTimer(CLEANUP_NAMESPACE_RIGHT, cleanupTime);
}

Срабатывание таймера удаляет из буфера всю корзину с нужной временной меткой.

apache/flink@release-2.2.1 · IntervalJoinOperator.java, строки 314-330
switch (namespace) {
    case CLEANUP_NAMESPACE_LEFT:
        {
            long timestamp =
                    (upperBound <= 0L) ? timerTimestamp : timerTimestamp - upperBound;
            logger.trace("Removing from left buffer @ {}", timestamp);
            leftBuffer.remove(timestamp);
            break;
        }
    case CLEANUP_NAMESPACE_RIGHT:
        {
            long timestamp =
                    (lowerBound <= 0L) ? timerTimestamp + lowerBound : timerTimestamp;
            logger.trace("Removing from right buffer @ {}", timestamp);
            rightBuffer.remove(timestamp);
            break;
        }
i
Соединение пар многие ко многим

Interval join выдаёт пару для каждой записи другой стороны, которая попала в интервал. Генератор подтверждает и дубликаты платежей, поэтому одному платежу может достаться два подтверждения. Оператор выдаст две пары с одним paymentId. Если нужна ровно одна, дубликаты убирают до соединения или после него.

Ручной join на KeyedCoProcessFunction

Interval join видит только совпавшие пары. Он не скажет, что подтверждение не пришло. Для антифрода это важный сигнал. Когда нужна логика сложнее, соединение пишут руками на KeyedCoProcessFunction. У неё два метода обработки, по одному на вход, общее keyed state по ключу и таймеры. Документация описывает шаблон так. Одну сторону сохраняют в состоянии, при записи с другой стороны сверяются с ним, а таймер по watermark решает, что ждать больше нечего. Состоянием и его очисткой здесь управляешь ты сам, и ошибка в очистке превращается в утечку. Такой оператор ты напишешь в задании.

Сравнение со Spark

В Spark Structured Streaming соединение двух потоков тоже хранит обе стороны в хранилище состояния. Чтобы его чистить, Spark нужен watermark на входах и условие на время в самом соединении. Без них состояние растёт без ограничений. Внешние соединения потоков в Spark есть, и для них эти условия обязательны.

Во Flink interval join получает границы в between и чистит буферы таймерами по watermark. Внешнего варианта у него нет, его пишут на KeyedCoProcessFunction.

Отдельного оператора для асинхронных запросов в Spark нет. Обогащение там обычно пишут в mapPartitions и шлют запросы порциями, или соединяют поток со статической таблицей, которая перечитывается в каждом micro-batch. Во Flink асинхронный оператор встроен и участвует в чекпоинтах.

Проверь себя

Коллега обогащает платежи через HTTP-сервис скоринга с задержкой 50 мс. Параллелизм 4, capacity 100. Внутри asyncInvoke он пишет client.get(id).join() и только потом вызывает complete. Сколько записей в секунду выдаст оператор?

После подключения второго банка 3 % платежей перестали соединяться с подтверждениями. Подтверждения при этом приходят. Выяснилось, что часы банка отстают на 2 секунды. Что поменять в PaymentConfirmationJoin?

Асинхронная функция отправляет клиенту push-уведомление о подозрительном платеже. После падения TaskManager и восстановления из чекпоинта несколько клиентов получили уведомление дважды. Почему?

Что будет в конце

В стенде появится PostgreSQL со справочником продавцов. Каждый алерт перед отправкой в fraud.alerts получит поле merchantRisk из базы через асинхронный оператор с кэшем. Генератор начнёт присылать подтверждения от банка в топик payments.confirmations, а задание будет соединять их с платежами и писать пары в payments.confirmed.

Правило «подтверждение не пришло» относится к заданию. Файлы UnconfirmedPaymentFunction и его тест пока не создавай.

Шаг 1. PostgreSQL в Compose

Добавь в docker-compose.yml службу базы, переменные подключения и два новых топика.

что изменилось в docker-compose.yml
@@ -9,6 +9,9 @@       parallelism.default: 2
       jobmanager.memory.process.size: 1600m
       taskmanager.memory.process.size: 2048m
+    PAYGUARD_DB_URL: jdbc:postgresql://postgres:5432/payguard+    PAYGUARD_DB_USER: payguard+    PAYGUARD_DB_PASSWORD: payguard   volumes:
     - ./build/libs:/jobs:ro
     - ./data:/flink-data
@@ -45,10 +48,26 @@     entrypoint: ["/bin/bash", "-c"]
     command:
       - |
-        for topic in payments.raw payments.dlq payments.late merchant.stats card.sessions fraud.alerts rules.config; do+        for topic in payments.raw payments.dlq payments.late payments.confirmations payments.confirmed merchant.stats card.sessions fraud.alerts rules.config; do           /opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 \
             --create --if-not-exists --topic "$$topic" --partitions 4 --replication-factor 1
         done
++  postgres:+    image: postgres:17-alpine+    environment:+      POSTGRES_DB: payguard+      POSTGRES_USER: payguard+      POSTGRES_PASSWORD: payguard+    ports:+      - "5432:5432"+    volumes:+      - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro+    healthcheck:+      test: ["CMD-SHELL", "pg_isready -U payguard -d payguard"]+      interval: 5s+      timeout: 5s+      retries: 20 
   jobmanager:
     <<: *flink-common

Переменные PAYGUARD_DB_* добавлены в общий блок Flink. Их прочитает TaskManager, потому что соединение с базой открывается там, где работает подзадача. Образ PostgreSQL выполняет скрипты из /docker-entrypoint-initdb.d, когда каталог данных пуст. Том для данных базы мы не монтируем, поэтому после docker compose down справочник создаётся заново. Проверка healthcheck вызывает pg_isready, и флаг --wait дождётся, пока база начнёт принимать соединения.

Сам скрипт создаёт таблицу и 50 продавцов. Уровень риска считается от номера продавца. Каждый десятый получает HIGH, каждый третий из оставшихся MEDIUM, остальные LOW.

sql/init.sql
CREATE TABLE merchants (
    merchant_id TEXT PRIMARY KEY,
    risk_level  TEXT NOT NULL,
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT INTO merchants (merchant_id, risk_level)
SELECT 'm-' || lpad(n::text, 4, '0'),
       CASE WHEN n % 10 = 0 THEN 'HIGH' WHEN n % 3 = 0 THEN 'MEDIUM' ELSE 'LOW' END
FROM generate_series(0, 49) AS n;

Шаг 2. Зависимости

что изменилось в build.gradle.kts
@@ -33,6 +33,8 @@ 
     implementation("org.apache.flink:flink-connector-kafka:$kafkaConnectorVersion")
     implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
+    implementation("org.postgresql:postgresql:42.7.14")+    implementation("com.zaxxer:HikariCP:7.0.2") 
     runtimeOnly("org.slf4j:slf4j-simple:2.0.17")
 

Драйвера PostgreSQL и пула соединений HikariCP в образе Flink нет, поэтому обе библиотеки идут через implementation и попадают в толстый jar. Пул нужен, чтобы восемь потоков обогащения не открывали соединение на каждый запрос.

Шаг 3. Модели и настройки

Алерт получает поле риска. Пока алерт не прошёл обогащение, в поле стоит NOT_LOADED. Значение UNKNOWN означает, что база не ответила или продавца в ней нет. Отдельные значения помогут отличить в топике сбой обогащения от алерта, который его не проходил.

что изменилось в src/main/java/dev/study/payguard/model/FraudAlert.java
@@ -8,7 +8,11 @@         String merchantId,
         String paymentId,
         String detail,
-        long eventTimeMs) {+        long eventTimeMs,+        String merchantRisk) {++    public static final String RISK_NOT_LOADED = "NOT_LOADED";+    public static final String RISK_UNKNOWN = "UNKNOWN"; 
     public static FraudAlert of(String ruleId, PaymentEvent trigger, String detail) {
         return new FraudAlert(
@@ -18,6 +22,11 @@                 trigger.merchantId(),
                 trigger.paymentId(),
                 detail,
-                trigger.eventTimeMs());+                trigger.eventTimeMs(),+                RISK_NOT_LOADED);+    }++    public FraudAlert withMerchantRisk(String risk) {+        return new FraudAlert(alertId, ruleId, cardId, merchantId, paymentId, detail, eventTimeMs, risk);     }
 }

Подтверждение банка и результат соединения это два новых record.

src/main/java/dev/study/payguard/model/PaymentConfirmation.java
package dev.study.payguard.model;

/** Подтверждение платежа от банка-эмитента, приходит отдельным потоком. */
public record PaymentConfirmation(String paymentId, String status, long eventTimeMs) {
}
src/main/java/dev/study/payguard/model/ConfirmedPayment.java
package dev.study.payguard.model;

/** Платёж вместе с его подтверждением и задержкой между ними. */
public record ConfirmedPayment(String paymentId, String merchantId, long amountMinor, String status, long confirmationDelayMs) {
}

В настройки задания добавь имена двух топиков.

что изменилось в src/main/java/dev/study/payguard/job/JobConfig.java
@@ -13,6 +13,8 @@         String cardSessionsTopic,
         String alertsTopic,
         String rulesTopic,
+        String confirmationsTopic,+        String confirmedTopic,         int velocityMaxPayments,
         long amountSpikeLimitMinor,
         String groupId) implements Serializable {
@@ -28,6 +30,8 @@                 params.get("card-sessions-topic", "card.sessions"),
                 params.get("alerts-topic", "fraud.alerts"),
                 params.get("rules-topic", "rules.config"),
+                params.get("confirmations-topic", "payments.confirmations"),+                params.get("confirmed-topic", "payments.confirmed"),                 params.getInt("velocity-max-payments", 10),
                 params.getLong("amount-spike-limit-minor", 2_000_000),
                 params.get("group-id", "payguard"));

Шаг 4. Справочник продавцов

Обогащение не должно знать про JDBC. Интерфейс MerchantDirectory позволяет тесту подставить справочник, который отвечает мгновенно, падает или зависает.

src/main/java/dev/study/payguard/pipeline/MerchantDirectory.java
package dev.study.payguard.pipeline;

import java.util.Optional;

/** Справочник продавцов. Отдельный тип нужен, чтобы тест подставил медленную или падающую реализацию. */
public interface MerchantDirectory extends AutoCloseable {

    Optional<String> riskLevel(String merchantId) throws Exception;
}

Реализация на JDBC берёт адрес и пароль из переменных окружения. Соединения пула открываются только для чтения, размер пула передаётся снаружи.

src/main/java/dev/study/payguard/pipeline/JdbcMerchantDirectory.java
package dev.study.payguard.pipeline;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;

/** Читает уровень риска из PostgreSQL через пул соединений. Настройки берутся из переменных окружения. */
public final class JdbcMerchantDirectory implements MerchantDirectory {

    private static final String QUERY = "SELECT risk_level FROM merchants WHERE merchant_id = ?";

    private final HikariDataSource dataSource;

    private JdbcMerchantDirectory(HikariDataSource dataSource) {
        this.dataSource = dataSource;
    }

    public static JdbcMerchantDirectory fromEnvironment(int poolSize) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(System.getenv("PAYGUARD_DB_URL"));
        config.setUsername(System.getenv("PAYGUARD_DB_USER"));
        config.setPassword(System.getenv("PAYGUARD_DB_PASSWORD"));
        config.setMaximumPoolSize(poolSize);
        config.setReadOnly(true);
        return new JdbcMerchantDirectory(new HikariDataSource(config));
    }

    @Override
    public Optional<String> riskLevel(String merchantId) throws SQLException {
        try (Connection connection = dataSource.getConnection();
                PreparedStatement statement = connection.prepareStatement(QUERY)) {
            statement.setString(1, merchantId);
            try (ResultSet result = statement.executeQuery()) {
                return result.next() ? Optional.of(result.getString(1)) : Optional.empty();
            }
        }
    }

    @Override
    public void close() {
        dataSource.close();
    }
}

Шаг 5. Асинхронное обогащение

Создай pipeline/AlertEnrichment.java.

src/main/java/dev/study/payguard/pipeline/AlertEnrichment.java
package dev.study.payguard.pipeline;

import dev.study.payguard.model.FraudAlert;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.apache.flink.api.common.functions.OpenContext;
import org.apache.flink.metrics.Counter;
import org.apache.flink.streaming.api.functions.async.ResultFuture;
import org.apache.flink.streaming.api.functions.async.RichAsyncFunction;
import org.apache.flink.util.function.SerializableSupplier;

/** Дополняет алерт уровнем риска продавца, не блокируя поток обработки ожиданием базы. */
public class AlertEnrichment extends RichAsyncFunction<FraudAlert, FraudAlert> {

    private static final int THREADS = 8;
    private static final Duration CACHE_TTL = Duration.ofMinutes(5);

    private final SerializableSupplier<MerchantDirectory> directorySupplier;

    private transient MerchantDirectory directory;
    private transient ExecutorService executor;
    // Кэш без ограничения размера: продавцов тысячи, а не миллионы. На миллионах нужен Caffeine.
    private transient Map<String, CachedRisk> cache;
    private transient Counter lookupFailures;

    private record CachedRisk(String riskLevel, long expiresAtMs) {
    }

    public AlertEnrichment(SerializableSupplier<MerchantDirectory> directorySupplier) {
        this.directorySupplier = directorySupplier;
    }

    @Override
    public void open(OpenContext context) {
        directory = directorySupplier.get();
        executor = Executors.newFixedThreadPool(THREADS);
        cache = new ConcurrentHashMap<>();
        lookupFailures = getRuntimeContext().getMetricGroup().counter("merchantLookupFailures");
    }

    @Override
    public void asyncInvoke(FraudAlert alert, ResultFuture<FraudAlert> result) {
        CachedRisk cached = cache.get(alert.merchantId());
        if (cached != null && cached.expiresAtMs() > System.currentTimeMillis()) {
            result.complete(List.of(alert.withMerchantRisk(cached.riskLevel())));
            return;
        }
        CompletableFuture
                .supplyAsync(() -> lookup(alert.merchantId()), executor)
                .whenComplete((risk, error) -> result.complete(List.of(
                        alert.withMerchantRisk(error == null ? risk : onFailure()))));
    }

    @Override
    public void timeout(FraudAlert alert, ResultFuture<FraudAlert> result) {
        result.complete(List.of(alert.withMerchantRisk(onFailure())));
    }

    @Override
    public void close() throws Exception {
        if (executor != null) {
            executor.shutdownNow();
        }
        if (directory != null) {
            directory.close();
        }
    }

    private String lookup(String merchantId) {
        try {
            Optional<String> risk = directory.riskLevel(merchantId);
            String level = risk.orElse(FraudAlert.RISK_UNKNOWN);
            cache.put(merchantId, new CachedRisk(level, System.currentTimeMillis() + CACHE_TTL.toMillis()));
            return level;
        } catch (Exception e) {
            throw new IllegalStateException("Не удалось прочитать риск продавца " + merchantId, e);
        }
    }

    private String onFailure() {
        lookupFailures.inc();
        return FraudAlert.RISK_UNKNOWN;
    }
}

Обрати внимание на конструктор. Функция пересылается на кластер в сериализованном виде, а HikariDataSource сериализовать нельзя. Поэтому в поле лежит SerializableSupplier, и сам справочник создаётся в open, уже в TaskManager. Все поля, которые создаются в open, помечены transient. С ошибкой is not serializable ты встречался в уроке 8, здесь её избегаем заранее.

КОД
    @Override
    public void asyncInvoke(FraudAlert alert, ResultFuture<FraudAlert> result) {
        CachedRisk cached = cache.get(alert.merchantId());
        if (cached != null && cached.expiresAtMs() > System.currentTimeMillis()) {
            result.complete(List.of(alert.withMerchantRisk(cached.riskLevel())));
            return;
        }
        CompletableFuture
                .supplyAsync(() -> lookup(alert.merchantId()), executor)
                .whenComplete((risk, error) -> result.complete(List.of(
                        alert.withMerchantRisk(error == null ? risk : onFailure()))));
    }

    @Override
    public void timeout(FraudAlert alert, ResultFuture<FraudAlert> result) {
        result.complete(List.of(alert.withMerchantRisk(onFailure())));
    }
ПРОСТЫМИ СЛОВАМИ

asyncInvoke вызывается в потоке подзадачи на каждый алерт. Он должен вернуться быстро.

Сначала смотрим в кэш. Если риск продавца есть и ещё свежий, сразу завершаем запись готовым алертом. В базу не ходим.

Иначе отдаём запрос пулу из восьми потоков через supplyAsync и выходим из метода. Поток подзадачи свободен для следующей записи.

Когда пул получит ответ, whenComplete завершит запись. При ошибке базы вместо риска будет UNKNOWN, и счётчик merchantLookupFailures вырастет на единицу. Задание не упадёт.

timeout вызывается, если за 2 секунды ответа нет. Выдаём алерт с UNKNOWN вместо исключения, которое перезапустило бы задание.

Метод complete может вызваться дважды, если ответ базы пришёл одновременно с timeout. Оператор защищён от этого флагом completed в своём обработчике результата, второй вызов игнорируется.

Шаг 6. Подтверждения в генераторе

Генератор теперь для каждого платежа решает, пришлёт ли банк подтверждение. По умолчанию подтверждается 95 % платежей. Время подтверждения отстоит от времени платежа на случайную величину до трёх секунд.

src/main/java/dev/study/payguard/loadgen/ConfirmationMaker.java
package dev.study.payguard.loadgen;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.study.payguard.model.PaymentConfirmation;
import java.util.Optional;
import java.util.Random;

/** По записи платежа создаёт подтверждение от банка. Часть платежей подтверждения не получает. */
final class ConfirmationMaker {

    private static final int MAX_DELAY_MS = 3_000;

    private final ObjectMapper mapper = new ObjectMapper();
    private final Random random;
    private final double confirmedShare;

    ConfirmationMaker(Random random, double confirmedShare) {
        this.random = random;
        this.confirmedShare = confirmedShare;
    }

    Optional<String> confirmationFor(String rawPayment) {
        if (random.nextDouble() >= confirmedShare) {
            return Optional.empty();
        }
        try {
            JsonNode payment = mapper.readTree(rawPayment);
            long eventTime = payment.get("eventTimeMs").asLong() + random.nextInt(MAX_DELAY_MS);
            return Optional.of(mapper.writeValueAsString(
                    new PaymentConfirmation(payment.get("paymentId").asText(), "APPROVED", eventTime)));
        } catch (JsonProcessingException e) {
            return Optional.empty();
        }
    }
}
что изменилось в src/main/java/dev/study/payguard/loadgen/GeneratorConfig.java
@@ -11,7 +11,9 @@         double lateShare,
         double duplicateShare,
         double badShare,
-        double burstShare) {+        double burstShare,+        String confirmationsTopic,+        double confirmedShare) { 
     static GeneratorConfig fromArgs(String[] args) {
         Map<String, String> options = parseOptions(args);
@@ -22,7 +24,9 @@                 share(options, "late", 0.05),
                 share(options, "dup", 0.0),
                 share(options, "bad", 0.0),
-                share(options, "burst", 0.0));+                share(options, "burst", 0.0),+                options.getOrDefault("confirmations-topic", "payments.confirmations"),+                share(options, "confirm", 0.95));     }
 
     private static Map<String, String> parseOptions(String[] args) {
что изменилось в src/main/java/dev/study/payguard/loadgen/LoadGenerator.java
@@ -30,10 +30,14 @@             KafkaProducer<String, String> producer, RawRecordSource source, GeneratorConfig config)
             throws InterruptedException {
         int recordsPerTick = Math.max(1, config.ratePerSecond() / 10);
+        ConfirmationMaker confirmations = new ConfirmationMaker(new Random(), config.confirmedShare());         long sent = 0;
         while (!Thread.currentThread().isInterrupted()) {
             for (int i = 0; i < recordsPerTick; i++) {
-                producer.send(new ProducerRecord<>(config.topic(), source.next(System.currentTimeMillis())));+                String record = source.next(System.currentTimeMillis());+                producer.send(new ProducerRecord<>(config.topic(), record));+                confirmations.confirmationFor(record).ifPresent(confirmation ->+                        producer.send(new ProducerRecord<>(config.confirmationsTopic(), confirmation)));                 if (++sent % REPORT_EVERY_RECORDS == 0) {
                     System.out.println("Отправлено записей: " + sent);
                 }

Флаг --confirm задаёт долю подтверждённых платежей. С --confirm 0.5 половина платежей останется без пары, это пригодится в задании.

Шаг 7. Interval join

Создай pipeline/PaymentConfirmationJoin.java.

src/main/java/dev/study/payguard/pipeline/PaymentConfirmationJoin.java
package dev.study.payguard.pipeline;

import dev.study.payguard.model.ConfirmedPayment;
import dev.study.payguard.model.PaymentConfirmation;
import dev.study.payguard.model.PaymentEvent;
import java.time.Duration;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.functions.co.ProcessJoinFunction;
import org.apache.flink.util.Collector;

/** Соединяет платёж с подтверждением, пришедшим в течение пяти минут после него. */
public final class PaymentConfirmationJoin {

    public static final Duration MAX_DELAY = Duration.ofMinutes(5);

    public static SingleOutputStreamOperator<ConfirmedPayment> attach(
            DataStream<PaymentEvent> payments, DataStream<PaymentConfirmation> confirmations) {
        return payments.keyBy(PaymentEvent::paymentId)
                .intervalJoin(confirmations.keyBy(PaymentConfirmation::paymentId))
                .between(Duration.ZERO, MAX_DELAY)
                .process(new ToConfirmedPayment())
                .name("join-confirmations")
                .uid("join-confirmations");
    }

    private static final class ToConfirmedPayment
            extends ProcessJoinFunction<PaymentEvent, PaymentConfirmation, ConfirmedPayment> {

        @Override
        public void processElement(
                PaymentEvent payment, PaymentConfirmation confirmation, Context context, Collector<ConfirmedPayment> out) {
            out.collect(new ConfirmedPayment(
                    payment.paymentId(),
                    payment.merchantId(),
                    payment.amountMinor(),
                    confirmation.status(),
                    confirmation.eventTimeMs() - payment.eventTimeMs()));
        }
    }

    private PaymentConfirmationJoin() {
    }
}
КОД
    public static SingleOutputStreamOperator<ConfirmedPayment> attach(
            DataStream<PaymentEvent> payments, DataStream<PaymentConfirmation> confirmations) {
        return payments.keyBy(PaymentEvent::paymentId)
                .intervalJoin(confirmations.keyBy(PaymentConfirmation::paymentId))
                .between(Duration.ZERO, MAX_DELAY)
                .process(new ToConfirmedPayment())
                .name("join-confirmations")
                .uid("join-confirmations");
    }
ПРОСТЫМИ СЛОВАМИ

Метод принимает два потока и возвращает поток пар. Так он подключается в задании одной строкой и проверяется в тесте отдельно.

Обе стороны разбиты по paymentId. Interval join работает только на двух потоках с ключом, и ключи должны совпадать по типу.

between(Duration.ZERO, MAX_DELAY) означает, что подтверждение должно иметь время от момента платежа до пяти минут после него. Обе границы включены.

ProcessJoinFunction вызывается на каждую найденную пару и строит ConfirmedPayment с задержкой подтверждения.

Имя и uid вершины. uid понадобится, когда состояние соединения будет восстанавливаться из savepoint.

Шаг 8. Подключи всё в задании

В PayGuardJob появляются три константы обогащения. Размер пула соединений совпадает с числом потоков в AlertEnrichment, лишние соединения простаивали бы.

src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 45-47)
    private static final int DB_POOL_SIZE = 8;
    private static final Duration ENRICHMENT_TIMEOUT = Duration.ofSeconds(2);
    private static final int ENRICHMENT_CAPACITY = 200;

Источник подтверждений и стратегия watermark для него. Подтверждения не проходят разбор с DLQ, поэтому для них подходит LenientJsonSchema из урока 8. Допуск беспорядка и idleness взяты из PaymentWatermarks, чтобы оба входа соединения понимали время одинаково.

src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 192-206)
    private static KafkaSource<PaymentConfirmation> confirmationSource(JobConfig config) {
        return KafkaSource.<PaymentConfirmation>builder()
                .setBootstrapServers(config.bootstrapServers())
                .setTopics(config.confirmationsTopic())
                .setGroupId(config.groupId() + "-confirmations")
                .setStartingOffsets(OffsetsInitializer.latest())
                .setValueOnlyDeserializer(new LenientJsonSchema<>(PaymentConfirmation.class))
                .build();
    }

    private static WatermarkStrategy<PaymentConfirmation> confirmationWatermarks() {
        return WatermarkStrategy.<PaymentConfirmation>forBoundedOutOfOrderness(PaymentWatermarks.MAX_OUT_OF_ORDERNESS)
                .withTimestampAssigner((confirmation, kafkaTimestamp) -> confirmation.eventTimeMs())
                .withIdleness(PaymentWatermarks.IDLE_AFTER);
    }

Соединение подключается к тому же потоку payments после дедупликации, что и окна. Поток подтверждений создаётся в main, его же возьмёт правило из задания.

src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 62-65)
        DataStream<PaymentConfirmation> confirmations = env
                .fromSource(confirmationSource(config), confirmationWatermarks(), "payment-confirmations")
                .uid("payment-confirmations");
        attachConfirmedPayments(payments, confirmations, config);
src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 115-121)
    private static void attachConfirmedPayments(
            DataStream<PaymentEvent> payments, DataStream<PaymentConfirmation> confirmations, JobConfig config) {
        PaymentConfirmationJoin.attach(payments, confirmations)
                .sinkTo(KafkaSinks.<ConfirmedPayment>atLeastOnceJson(config.bootstrapServers(), config.confirmedTopic()))
                .name("confirmed-sink")
                .uid("confirmed-sink");
    }

Алерты обоих правил объединяются и проходят через асинхронный оператор перед приёмником. Объединение возвращает fraudAlerts, и в снапшоте оно уже содержит третий поток из задания. В практике метод вернёт только dynamicAlerts.union(amountAlerts). Асинхронный оператор встаёт в sinkAlerts перед приёмником.

src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 158-169)
        AsyncDataStream
                .unorderedWait(
                        allAlerts,
                        new AlertEnrichment(() -> JdbcMerchantDirectory.fromEnvironment(DB_POOL_SIZE)),
                        ENRICHMENT_TIMEOUT.toMillis(),
                        TimeUnit.MILLISECONDS,
                        ENRICHMENT_CAPACITY)
                .name("enrich-alerts")
                .uid("enrich-alerts")
                .sinkTo(KafkaSinks.<FraudAlert>atLeastOnceJson(config.bootstrapServers(), config.alertsTopic()))
                .name("alerts-sink")
                .uid("alerts-sink");

Лямбда в конструкторе AlertEnrichment имеет тип SerializableSupplier, поэтому Java сделает её сериализуемой. Внутри она ссылается только на статическую константу, и вместе с функцией на кластер не уедет ничего лишнего. Не забудь импорты AsyncDataStream, TimeUnit, Duration и WatermarkStrategy.

Шаг 9. Тесты

Тест обогащения запускает настоящий асинхронный оператор на MiniCluster с тремя справочниками. Первый отвечает сразу, второй всегда бросает исключение, третий засыпает на минуту. Третий случай с timeout 200 мс проверяет, что зависшая база даёт UNKNOWN и задание при этом не падает.

src/test/java/dev/study/payguard/pipeline/AlertEnrichmentTest.java
package dev.study.payguard.pipeline;

import static dev.study.payguard.testing.Payments.payment;
import static org.assertj.core.api.Assertions.assertThat;

import dev.study.payguard.model.FraudAlert;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration;
import org.apache.flink.streaming.api.datastream.AsyncDataStream;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.test.junit5.MiniClusterExtension;
import org.apache.flink.util.function.SerializableSupplier;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

class AlertEnrichmentTest {

    @RegisterExtension
    static final MiniClusterExtension CLUSTER = new MiniClusterExtension(
            new MiniClusterResourceConfiguration.Builder()
                    .setNumberTaskManagers(1)
                    .setNumberSlotsPerTaskManager(1)
                    .build());

    private static final class FixedRisk implements MerchantDirectory {
        @Override
        public Optional<String> riskLevel(String merchantId) {
            return merchantId.equals("m1") ? Optional.of("HIGH") : Optional.empty();
        }

        @Override
        public void close() {
        }
    }

    private static final class BrokenDirectory implements MerchantDirectory {
        @Override
        public Optional<String> riskLevel(String merchantId) throws Exception {
            throw new java.sql.SQLException("база недоступна");
        }

        @Override
        public void close() {
        }
    }

    private static final class HangingDirectory implements MerchantDirectory {
        @Override
        public Optional<String> riskLevel(String merchantId) throws InterruptedException {
            TimeUnit.MINUTES.sleep(1);
            return Optional.of("HIGH");
        }

        @Override
        public void close() {
        }
    }

    private static List<String> enrich(SerializableSupplier<MerchantDirectory> directory, Duration timeout) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        DataStream<FraudAlert> alerts = env.fromData(List.of(
                FraudAlert.of("r", payment("p1", "c1", "m1", 100, 1_000), "d"),
                FraudAlert.of("r", payment("p2", "c1", "m2", 100, 2_000), "d")));
        return AsyncDataStream
                .unorderedWait(alerts, new AlertEnrichment(directory), timeout.toMillis(), TimeUnit.MILLISECONDS, 10)
                .map(alert -> alert.merchantId() + "=" + alert.merchantRisk())
                .executeAndCollect(10);
    }

    @Test
    void addsRiskFromDirectory() throws Exception {
        assertThat(enrich(FixedRisk::new, Duration.ofSeconds(10))).containsExactlyInAnyOrder("m1=HIGH", "m2=UNKNOWN");
    }

    @Test
    void directoryFailureDoesNotFailJob() throws Exception {
        assertThat(enrich(BrokenDirectory::new, Duration.ofSeconds(10))).containsExactlyInAnyOrder("m1=UNKNOWN", "m2=UNKNOWN");
    }

    @Test
    void slowDirectoryEndsWithTimeoutValue() throws Exception {
        assertThat(enrich(HangingDirectory::new, Duration.ofMillis(200))).containsExactlyInAnyOrder("m1=UNKNOWN", "m2=UNKNOWN");
    }
}

Тест соединения проверяет границы интервала. Подтверждение p1 пришло через 2 секунды и соединяется. Подтверждение p2 пришло через 998 секунд, это больше пяти минут, и пары нет.

src/test/java/dev/study/payguard/pipeline/PaymentConfirmationJoinTest.java
package dev.study.payguard.pipeline;

import static dev.study.payguard.testing.Payments.payment;
import static dev.study.payguard.testing.Payments.streamOf;
import static org.assertj.core.api.Assertions.assertThat;

import dev.study.payguard.model.ConfirmedPayment;
import dev.study.payguard.model.PaymentConfirmation;
import java.util.List;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.test.junit5.MiniClusterExtension;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

class PaymentConfirmationJoinTest {

    @RegisterExtension
    static final MiniClusterExtension CLUSTER = new MiniClusterExtension(
            new MiniClusterResourceConfiguration.Builder()
                    .setNumberTaskManagers(1)
                    .setNumberSlotsPerTaskManager(2)
                    .build());

    @Test
    void joinsOnlyConfirmationsInsideInterval() throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(2);
        DataStream<PaymentConfirmation> confirmations = env
                .fromData(List.of(
                        new PaymentConfirmation("p1", "OK", 3_000),
                        new PaymentConfirmation("p2", "OK", 1_000_000)))
                .assignTimestampsAndWatermarks(WatermarkStrategy.<PaymentConfirmation>forMonotonousTimestamps()
                        .withTimestampAssigner((confirmation, ts) -> confirmation.eventTimeMs()));

        List<ConfirmedPayment> joined = PaymentConfirmationJoin.attach(
                        streamOf(env, List.of(
                                payment("p1", "c1", "m1", 100, 1_000),
                                payment("p2", "c1", "m1", 200, 2_000))),
                        confirmations)
                .executeAndCollect(10);

        assertThat(joined).containsExactly(new ConfirmedPayment("p1", "m1", 100, "OK", 2_000));
    }
}
терминал
./gradlew test

Шаг 10. Собери и запусти

Сначала сборка, потом стенд. Compose создаст службу postgres и пересоздаст kafka-init с новыми топиками.

терминал
./gradlew shadowJar
docker compose up -d --wait
docker compose exec jobmanager flink list

Останови задание прошлого урока командой flink cancel <JobID> и отправь новое. Генератор запусти во втором терминале.

терминал
scripts/submit.sh
./gradlew loadgen --args="--rate 200 --bad 0.01"
вывод
Job has been submitted with JobID 4d6d6b03b004c5a5d4c28d75fe483736

Проверь справочник в базе. Ключ -T отключает псевдотерминал, вывод тогда удобно передавать дальше. Каждый -c выполняет одну команду.

терминал
docker compose exec -T postgres psql -U payguard -d payguard \
  -c '\dt' -c 'SELECT risk_level, count(*) FROM merchants GROUP BY risk_level'
вывод
           List of relations
 Schema |   Name    | Type  |  Owner
--------+-----------+-------+----------
 public | merchants | table | payguard
(1 row)

 risk_level | count
------------+-------
 MEDIUM     |    15
 HIGH       |     5
 LOW        |    30
(3 rows)

Пять продавцов с номерами, кратными десяти, получили HIGH. Из оставшихся 45 номеров на три делится 15, это MEDIUM.

Через минуту-другую посмотри на граф задания.

терминал
JOB=$(curl -s localhost:8081/jobs | jq -r '.jobs[] | select(.status == "RUNNING") | .id')
curl -s localhost:8081/jobs/$JOB | jq -c '{name, state, vertices: [.vertices[] | {name, parallelism, status}]}'
вывод
{"name":"payguard","state":"RUNNING","vertices":[{"name":"Source: payments-raw -> split-parsed -> (dlq-sink: Writer -> dlq-sink: Committer, lateness-probe)","parallelism":2,"status":"RUNNING"},{"name":"Source: rules-config","parallelism":2,"status":"RUNNING"},{"name":"Source: payment-confirmations","parallelism":2,"status":"RUNNING"},{"name":"deduplicate","parallelism":2,"status":"RUNNING"},{"name":"merchant-stats-1m -> (merchant-stats-sink: Writer -> merchant-stats-sink: Committer, too-late-sink: Writer -> too-late-sink: Committer)","parallelism":2,"status":"RUNNING"},{"name":"card-sessions -> card-sessions-sink: Writer -> card-sessions-sink: Committer","parallelism":2,"status":"RUNNING"},{"name":"rules-dynamic","parallelism":2,"status":"RUNNING"},{"name":"rule-amount-spike","parallelism":2,"status":"RUNNING"},{"name":"join-confirmations -> confirmed-sink: Writer -> confirmed-sink: Committer","parallelism":2,"status":"RUNNING"},{"name":"rule-unconfirmed","parallelism":2,"status":"RUNNING"},{"name":"enrich-alerts -> alerts-sink: Writer -> alerts-sink: Committer","parallelism":2,"status":"RUNNING"}]}

Этот вывод снят со снапшота, где задание уже решено, поэтому в нём есть вершина rule-unconfirmed. У тебя её пока нет. Соединение join-confirmations сцеплено со своим приёмником, и enrich-alerts тоже. Перед обоими операторами стоит перераспределение, keyBy или объединение нескольких потоков, поэтому каждый начинает свою вершину.

Теперь метрики двух новых вершин. Команда та же, что в уроке 6, меняется только начало имени вершины.

терминал
for NAME in join-confirmations enrich-alerts; do
  V=$(curl -s localhost:8081/jobs/$JOB | jq -r --arg n "$NAME" '.vertices[] | select(.name | startswith($n)) | .id')
  curl -s "localhost:8081/jobs/$JOB/vertices/$V/subtasks/metrics?get=numRecordsIn,numRecordsOut,busyTimeMsPerSecond,backPressuredTimeMsPerSecond&agg=min,max,sum" | jq -c .
done
вывод
[{"id":"numRecordsIn","min":16895.0,"max":16950.0,"sum":33845.0},{"id":"numRecordsOut","min":8125.0,"max":8162.0,"sum":16287.0},{"id":"busyTimeMsPerSecond","min":4.0,"max":5.0,"sum":9.0},{"id":"backPressuredTimeMsPerSecond","min":0.0,"max":0.0,"sum":0.0}]
[{"id":"numRecordsIn","min":25.0,"max":33.0,"sum":58.0},{"id":"numRecordsOut","min":25.0,"max":33.0,"sum":58.0},{"id":"busyTimeMsPerSecond","min":0.0,"max":0.0,"sum":0.0},{"id":"backPressuredTimeMsPerSecond","min":0.0,"max":0.0,"sum":0.0}]

Первая строка относится к соединению. На вход пришло 33845 записей, это платежи после дедупликации и подтверждения вместе. В том же прогоне deduplicate выдал 17214 платежей, источник подтверждений выдал 16659. На выход ушло 16287 пар, их записал приёмник confirmed-sink. Подтверждений больше, чем пар, на 372. Эта разница складывается из двух эффектов, которые видны в коде. Опоздавший больше чем на 10 секунд платёж отбрасывается в isLate, и его подтверждение остаётся без пары. Подтверждение дубликата, наоборот, даёт лишнюю пару. Подзадачи нагружены поровну, у paymentId нет горячего ключа.

Вторая строка относится к обогащению. Через него прошло 58 алертов, ровно столько, сколько выдал rules-dynamic. Занятость 0 мс в секунду, асинхронный оператор почти всё время ждёт работы. Чекпоинты в стенде пока выключены, их включим в уроке 11, поэтому сохранение очереди в состоянии здесь ещё не участвует. Счётчик merchantLookupFailures ищи в Web UI на вкладке Metrics вершины enrich-alerts. Если он растёт, база отвечает с ошибками или не успевает за 2 секунды.

Прочитай результат соединения и обогащённые алерты той же командой, что в уроке 6.

терминал
for TOPIC in payments.confirmed fraud.alerts; do
  docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
    --bootstrap-server localhost:9092 --topic $TOPIC --from-beginning --max-messages 3 2>/dev/null
done
вывод
{"paymentId":"32b69c01-33ad-25f0-5bdf-cf5dd3d6a016","merchantId":"m-0043","amountMinor":10662,"status":"APPROVED","confirmationDelayMs":1638}
{"paymentId":"d68af3a1-6705-47c2-8833-b4376d099225","merchantId":"m-0040","amountMinor":37077,"status":"APPROVED","confirmationDelayMs":1107}
{"paymentId":"77d7b4db-62cf-47ed-6969-e9e851a630d4","merchantId":"m-0044","amountMinor":38254,"status":"APPROVED","confirmationDelayMs":66}
{"alertId":"velocity:d1fb0828-9f4e-117c-c56d-030f9826c04d","ruleId":"velocity","cardId":"c-4735","merchantId":"m-0010","paymentId":"d1fb0828-9f4e-117c-c56d-030f9826c04d","detail":"11 платежей за минуту","eventTimeMs":1794085961857,"merchantRisk":"HIGH"}
{"alertId":"velocity:87e03309-430e-10d1-72be-3b4fffc3466e","ruleId":"velocity","cardId":"c-3878","merchantId":"m-0011","paymentId":"87e03309-430e-10d1-72be-3b4fffc3466e","detail":"11 платежей за минуту","eventTimeMs":1794085963440,"merchantRisk":"LOW"}
{"alertId":"velocity:674bdffe-2c08-8c84-6cc0-1484c9220c12","ruleId":"velocity","cardId":"c-3876","merchantId":"m-0000","paymentId":"674bdffe-2c08-8c84-6cc0-1484c9220c12","detail":"11 платежей за минуту","eventTimeMs":1794085963986,"merchantRisk":"HIGH"}

Все задержки подтверждений меньше 3000 мс, как задано в генераторе. Риск в алертах совпадает с init.sql. Номера m-0010 и m-0000 делятся на десять и получили HIGH, номер m-0011 не делится ни на десять, ни на три и получил LOW. Значений NOT_LOADED и UNKNOWN в этих записях нет, база ответила на все запросы.

Что будет, если остановить базу командой docker compose stop postgres, можно сказать по коду. Задание продолжит работать. Алерт по продавцу из кэша ещё до пяти минут получит прежний риск. Остальные алерты получат UNKNOWN не позже чем через 2 секунды, по ошибке из пула или по timeout, и счётчик ошибок начнёт расти. Мы этот сценарий на стенде не снимали, проверь его сам и верни базу командой docker compose start postgres.

Задание. Платёж без подтверждения

Interval join выдаёт только пары. Платёж, который банк так и не подтвердил, в payments.confirmed не появится, и никто об этом не узнает. Для антифрода такой платёж подозрителен. Напиши правило, которое выдаёт алерт, если подтверждение не пришло за пять минут по времени события.

  1. Создай pipeline/UnconfirmedPaymentFunction на основе KeyedCoProcessFunction<String, PaymentEvent, PaymentConfirmation, FraudAlert>. Ключ это paymentId, срок ожидания передаётся в конструктор в миллисекундах.
  2. Алерт строй через FraudAlert.of с идентификатором правила unconfirmed.
  3. Подтверждение может прийти раньше платежа. Это нормальный случай, алерта быть не должно.
  4. После того как судьба платежа решена, состояние и таймеры по этому ключу должны быть очищены.
  5. Подключи правило в PayGuardJob с именем и uid rule-unconfirmed. Срок возьми из PaymentConfirmationJoin.MAX_DELAY. Алерты правила должны пройти через обогащение вместе с остальными.
  6. Напиши UnconfirmedPaymentFunctionTest на harness с двумя входами.

Готово, если выполнены все условия.

  1. Тесты проверяют четыре случая. Подтверждения нет, и алерт выдан. Подтверждение пришло вовремя, алерта нет и таймеров не осталось. Подтверждение пришло раньше платежа, алерта нет. Watermark сдвинулся только на одном входе, алерта ещё нет.
  2. ./gradlew test проходит.
  3. В графе на стенде появилась вершина rule-unconfirmed, и её вход равен входу join-confirmations.

Подсказка первая. Harness для такой функции создаёт ProcessFunctionTestHarnesses.forKeyedCoProcessFunction. Ему нужны функция, два селектора ключа и тип ключа.

Подсказка вторая. Watermark оператора с двумя входами равен минимуму по входам, это ты видел в уроке 8. В тесте двигай watermark каждого входа отдельно. На стенде помни, что топик подтверждений с --confirm 0 замолчит, и watermark этого входа пойдёт вперёд только через минуту по idleness.

Подсказка третья. Чтобы удалить таймер, нужно знать точное время, на которое он поставлен. Подумай, из какого состояния его можно вычислить.

Ответ

Глоссарий урока

Обогащение (enrichment)
Добавление к записи потока данных из внешнего источника, например уровня риска продавца из базы.
Async I/O
Механизм Flink для запросов во внешние системы без остановки подзадачи. Подзадача отправляет много запросов сразу и выдаёт результаты по мере ответов.
AsyncFunction
Интерфейс функции асинхронного оператора. Метод asyncInvoke запускает запрос и сразу возвращается, метод timeout решает, что делать с зависшим запросом. RichAsyncFunction добавляет open, close и доступ к метрикам.
ResultFuture
Объект, через который асинхронная функция сообщает Flink результат записи. Вызов complete отдаёт результат, completeExceptionally роняет подзадачу.
Capacity
Наибольшее число записей в очереди асинхронного оператора. Когда очередь полна, оператор перестаёт брать записи со входа и включается backpressure.
Timeout
Предельное время на запрос асинхронного оператора, отсчитывается от постановки записи в очередь. По умолчанию истечение перезапускает задание.
Режимы ordered и unordered
Порядок выдачи результатов асинхронного оператора. Ordered выдаёт в порядке входа, unordered по мере готовности, но не через границу watermark.
Head-of-line blocking
Ситуация, когда медленный первый элемент очереди задерживает всех за ним, хотя их работа уже сделана.
Mailbox
Очередь дел потока подзадачи Flink. Обработка записей, таймеры и результаты асинхронных запросов выполняются через неё в одном потоке.
Backpressure
Обратное давление. Медленный оператор перестаёт принимать данные, буферы перед ним заполняются, и замедление доходит до источника.
Состояние оператора (operator state)
Состояние, привязанное к подзадаче, без ключа. В нём асинхронный оператор хранит записи своей очереди для чекпоинта.
Keyed state
Состояние, которое Flink хранит отдельно для каждого ключа. В нём interval join держит буферы обеих сторон.
Interval join
Соединение двух потоков с ключом, при котором запись правого потока должна попасть во временной интервал вокруг записи левого. Работает во времени события и выдаёт только совпавшие пары.
Inner join
Соединение, которое выдаёт только пары с совпадением. Записи без пары не выдаются вовсе.
ProcessJoinFunction
Функция, которую interval join вызывает на каждую найденную пару. Через контекст видны временные метки обеих записей.
KeyedCoProcessFunction
Функция с двумя входами, разбитыми по одному ключу. Даёт keyed state и таймеры, на ней пишут соединения с любой логикой.
Watermark
Отметка времени события в потоке, после которой более ранних событий оператор не ждёт. У оператора с двумя входами равна минимуму по входам.
Таймер (timer)
Отложенный вызов функции, привязанный к ключу. Таймер по времени события срабатывает, когда watermark дошёл до заданного момента.
Кэш (cache)
Копия данных рядом с кодом, чтобы не ходить за ними каждый раз. Здесь это словарь в памяти подзадачи со сроком жизни записей.
Идемпотентность
Свойство операции, при котором повтор не меняет результат. Повторное чтение идемпотентно, повторная отправка SMS нет.
PostgreSQL
Реляционная база данных с открытым кодом. В PayGuard хранит справочник продавцов.
JDBC
Стандартный интерфейс Java для работы с реляционными базами. Его вызовы блокируют поток до ответа базы.
HikariCP
Библиотека пула соединений для JDBC. Держит открытые соединения с базой и выдаёт их потокам по запросу.
CompletableFuture
Класс Java для результата, который появится позже. supplyAsync запускает работу в заданном пуле потоков, whenComplete выполняет код по её окончании.
Пул потоков (ExecutorService)
Набор рабочих потоков Java, которые по очереди выполняют переданные задачи. В AlertEnrichment он выполняет блокирующие запросы к базе.
Healthcheck
Команда в Docker Compose, по которой Docker решает, готов ли контейнер. Флаг --wait ждёт её успеха.
Spark Structured Streaming
Потоковый движок Spark. Соединение двух потоков в нём держит обе стороны в хранилище состояния и чистит его по watermark и условию на время.

Урок 10 · Время и состояние

Сложные шаблоны на CEP

После урока ты сможешь описать мошенничество как последовательность событий, выбрать нужную смежность и предсказать, сколько состояния и задержки даст такой шаблон.

Атака, которую не видно в одном платеже

Мошенник купил в даркнете пачку номеров карт и не знает, какие из них живые. Он проверяет каждую карту серией платежей на пару евро. Если они прошли, следом идёт крупная покупка. Такую схему называют card testing. Каждый платёж по отдельности выглядит невинно, а правило частоты из урока 8 не сработает, потому что платежей всего шесть.

Подозрителен порядок. Несколько мелких платежей подряд и сразу крупный, причём по одной карте и за несколько минут. Вторая схема ещё проще. Карту скопировали, и два платежа подряд пришли из разных стран, хотя за десять минут между ними никто не успел бы перелететь границу. Её называют impossible travel.

Правила такого вида описывают порядок событий, и для них во Flink есть отдельная библиотека FlinkCEP. CEP расшифровывается как complex event processing, обработка сложных событий. Ты описываешь шаблон последовательности, а библиотека ищет его в потоке каждой карты и вызывает твою функцию на каждое совпадение.

Кодовый замок в подъезде

Представь домофон с кодом 1-2-3. Замок помнит, сколько верных цифр уже нажато. Нажал 1, он ждёт 2. Нажал 7, и у строгого замка набор сбрасывается. Если между нажатиями прошло больше минуты, набор тоже сбрасывается. Код совпал, дверь открылась.

Шаблон CEP устроен так же. Набранная часть кода называется частичным совпадением, сброс по времени задаёт within, а правила, что делать с лишней цифрой, называются смежностью. Есть строгий замок, который сбрасывается на любой лишней цифре. Есть терпеливый, который пропускает цифры не из кода. Третий вариант уже не похож на замок. Он помнит все способы, которыми из нажатых цифр можно сложить код, и открывает дверь на каждый такой способ.

У настоящего замка набор один. У CEP частичных совпадений по ключу может быть много одновременно, потому что каждое новое событие может начать ещё одну попытку. Каждая попытка лежит в состоянии оператора, пока не закончится совпадением или сбросом.

Шаблон из шагов

Шаблон собирается цепочкой вызовов. Pattern.begin("probes") открывает первый шаг с именем. where задаёт условие, которому должно удовлетворять событие шага. Квантификатор timesOrMore(3) разрешает шагу принять три события и больше. next("cash-out") добавляет следующий шаг, а within ограничивает время от первого события до последнего. Так выглядит шаблон проверки карты в PayGuard.

src/main/java/dev/study/payguard/pipeline/FraudPatterns.java (строки 30-39)
    /** Проверка карты: несколько мелких платежей подряд и затем крупный. */
    public static Pattern<PaymentEvent, ?> cardTesting() {
        return Pattern.<PaymentEvent>begin("probes", AfterMatchSkipStrategy.skipPastLastEvent())
                .where(SimpleCondition.of(payment -> payment.amountMinor() < SMALL_AMOUNT_MINOR))
                .timesOrMore(MIN_PROBES)
                .consecutive()
                .next("cash-out")
                .where(SimpleCondition.of(payment -> payment.amountMinor() > LARGE_AMOUNT_MINOR))
                .within(CARD_TESTING_WINDOW);
    }

Условия бывают двух видов. SimpleCondition смотрит только на текущее событие. IterativeCondition получает ещё и контекст, через который видны события, уже принятые в предыдущие шаги этого же совпадения. Условие «страна отличается от страны прошлого платежа» без контекста не написать, и оно станет твоим заданием.

Три вида смежности

Между шагами шаблона ставится одна из трёх связок. next требует строгой смежности, следующее событие ключа обязано подойти. followedBy даёт ослабленную смежность, неподходящие события пропускаются до первого подходящего. followedByAny даёт недетерминированную ослабленную смежность, при которой пропускать можно и подходящие события, и совпадений становится больше.

Документация разбирает пример. Шаблон «a, потом b» на последовательности a, c, b1, b2 даёт разные ответы. Строгая смежность совпадений не находит, потому что c сразу после a сбрасывает попытку. Ослабленная находит {a b1}. Недетерминированная находит {a b1} и {a b2}.

Внутри шага с квантификатором действует своя смежность, между событиями самого шага. По умолчанию она ослабленная. consecutive() делает её строгой, allowCombinations() недетерминированной. В PayGuard мелкие платежи должны идти подряд, поэтому стоит consecutive().

В исходниках

Проверим, что связки отличаются только стратегией потребления событий. followedBy создаёт следующий шаг со стратегией SKIP_TILL_NEXT. Рядом в том же файле next передаёт STRICT, а followedByAny передаёт SKIP_TILL_ANY.

apache/flink@release-2.2.1 · Pattern.java, строки 305-315
/**
 * Appends a new pattern to the existing one. The new pattern enforces non-strict temporal
 * contiguity. This means that a matching event of this pattern and the preceding matching event
 * might be interleaved with other events which are ignored.
 *
 * @param name Name of the new pattern
 * @return A new pattern which is appended to this one
 */
public Pattern<T, T> followedBy(final String name) {
    return new Pattern<>(name, this, ConsumingStrategy.SKIP_TILL_NEXT, afterMatchSkipStrategy);
}

Смежность внутри шага хранит квантификатор в отдельном поле. Его начальное значение и есть то «по умолчанию ослабленная», о котором говорит документация.

apache/flink@release-2.2.1 · Quantifier.java, строки 43-50
public class Quantifier {

    private final EnumSet<QuantifierProperty> properties;

    private final ConsumingStrategy consumingStrategy;

    private ConsumingStrategy innerConsumingStrategy = ConsumingStrategy.SKIP_TILL_NEXT;

Компилятор шаблона превращает стратегию в условие ребра IGNORE, по которому автомат пропускает событие и остаётся в том же состоянии. У STRICT такого ребра нет совсем, поэтому любое неподходящее событие обрывает попытку. У SKIP_TILL_NEXT ребро срабатывает на событиях, которые не проходят условие шага. У SKIP_TILL_ANY условие ребра всегда истинно, и автомат может пропустить даже подходящее событие.

apache/flink@release-2.2.1 · NFACompiler.java, строки 963-992
private IterativeCondition<T> getIgnoreCondition(Pattern<T, ?> pattern) {
    Quantifier.ConsumingStrategy consumingStrategy =
            pattern.getQuantifier().getConsumingStrategy();
    if (headOfGroup(pattern)) {
        // for the head pattern of a group pattern, we should consider the inner consume
        // strategy
        // of the group pattern if the group pattern is not the head of the TIMES/LOOPING
        // quantifier;
        // otherwise, we should consider the consume strategy of the group pattern
        if (isCurrentGroupPatternFirstOfLoop()) {
            consumingStrategy = currentGroupPattern.getQuantifier().getConsumingStrategy();
        } else {
            consumingStrategy =
                    currentGroupPattern.getQuantifier().getInnerConsumingStrategy();
        }
    }

    IterativeCondition<T> ignoreCondition = null;
    switch (consumingStrategy) {
        case STRICT:
            ignoreCondition = null;
            break;
        case SKIP_TILL_NEXT:
            ignoreCondition =
                    new RichNotCondition<>((IterativeCondition<T>) pattern.getCondition());
            break;
        case SKIP_TILL_ANY:
            ignoreCondition = BooleanConditions.trueFunction();
            break;
    }

Попробуй сам. Модель перебирает все совпадения шаблона «три мелких платежа и больше, потом крупный» так, как это делает CEP без стратегии пропуска. М означает мелкий платёж, О обычный, К крупный.

Смежность и число совпадений

Выбери смежность и последовательность платежей.

Атака генератора состоит из пяти мелких платежей и одного крупного. Даже со строгой смежностью шаблон находит три совпадения, они начинаются с первого, второго и третьего мелкого платежа. Включи ослабленную смежность внутри серии на примере с разрывом, и совпадений станет больше, потому что обычный платёж теперь пропускается. Поэтому в PayGuard у шаблона стоит стратегия пропуска после совпадения, о ней чуть ниже.

Шаблон становится автоматом

При запуске задания NFACompiler переводит шаблон в недетерминированный конечный автомат, NFA (nondeterministic finite automaton). Состояния автомата соответствуют шагам шаблона, а у каждого состояния есть рёбра трёх видов. TAKE принимает событие в шаг. IGNORE пропускает событие. PROCEED переходит к следующему шагу, не тратя событие. Недетерминированным автомат называют потому, что из одного состояния на одно событие могут сработать несколько рёбер сразу, и попытка раздваивается.

началождёт мелкий probesпринято 1, 2, 3 и больше cash-outждёт крупный совпадение TAKE мелкий TAKE мелкий, петля с трёх мелких TAKE крупный любой другой платёж, ребра IGNORE нет, попытка обрывается

Схема упрощённая. Компилятор разворачивает timesOrMore(3) в цепочку из трёх обязательных копий шага и петлю за ними, а начальное состояние никогда не обрывается, поэтому каждый мелкий платёж начинает новую попытку.

В исходниках

Посмотрим, как автомат обрабатывает событие. Метод doProcess проходит по всем частичным совпадениям ключа и для каждого вычисляет новые состояния. Если состояний получилось больше одного, попытка раздвоилась. Дошедшие до конца становятся кандидатами в совпадения, остальные сохраняются до следующего события.

apache/flink@release-2.2.1 · NFA.java, строки 363-392
// iterate over all current computations
for (ComputationState computationState : nfaState.getPartialMatches()) {
    final Collection<ComputationState> newComputationStates =
            computeNextStates(sharedBufferAccessor, computationState, event, timerService);

    if (newComputationStates.size() != 1) {
        nfaState.setStateChanged();
    } else if (!newComputationStates.iterator().next().equals(computationState)) {
        nfaState.setStateChanged();
    }

    // delay adding new computation states in case a stop state is reached and we discard
    // the path.
    final Collection<ComputationState> statesToRetain = new ArrayList<>();
    // if stop state reached in this path
    boolean shouldDiscardPath = false;
    for (final ComputationState newComputationState : newComputationStates) {

        if (isStartState(computationState) && newComputationState.getStartTimestamp() > 0) {
            nfaState.setNewStartPartiailMatch();
        }

        if (isFinalState(newComputationState)) {
            potentialMatches.add(newComputationState);
        } else if (isStopState(newComputationState)) {
            // reached stop state. release entry for the stop state
            shouldDiscardPath = true;
            sharedBufferAccessor.releaseNode(
                    newComputationState.getPreviousBufferEntry(),
                    newComputationState.getVersion());

Число частичных совпадений по ключу ничем не ограничено, кроме самого шаблона и времени. Поэтому followedByAny и allowCombinations на длинной серии событий умножают работу и состояние.

Стратегия пропуска после совпадения

Когда совпадение найдено, оставшиеся попытки, которые делят с ним события, по умолчанию продолжают жить. Это стратегия NO_SKIP, и она дала бы три алерта на одну атаку. Стратегия skipPastLastEvent() выбрасывает все попытки, начатые до конца найденного совпадения. На атаке генератора остаётся одно совпадение, самое длинное, потому что оно начинается раньше остальных. Стратегию передают первым шагом в Pattern.begin.

Время и порядок событий

Шаблон описывает порядок, а события из Kafka приходят с беспорядком. Если обработать их в порядке прихода, опоздавший мелкий платёж встанет после крупного, и совпадение потеряется. Поэтому оператор CEP по умолчанию работает во времени события и сначала складывает события в буфер.

В исходниках

В CepOperator.processElement событие с меткой больше текущего watermark попадает в буфер. Событие с меткой не больше watermark считается опоздавшим. Если побочный выход для опоздавших не задан, оно молча выбрасывается, и растёт только счётчик numLateRecordsDropped.

apache/flink@release-2.2.1 · CepOperator.java, строки 248-270
} else {

    long timestamp = element.getTimestamp();
    IN value = element.getValue();

    // In event-time processing we assume correctness of the watermark.
    // Events with timestamp smaller than or equal with the last seen watermark are
    // considered late.
    // Late events are put in a dedicated side output, if the user has specified one.

    if (timestamp > timerService.currentWatermark()) {

        // we have an event with a valid timestamp, so
        // we buffer it until we receive the proper watermark.

        bufferEvent(value, timestamp);

    } else if (lateDataOutputTag != null) {
        output.collect(lateDataOutputTag, element);
    } else {
        numLateRecordsDropped.inc();
    }
}

Буфер разбирается по таймеру, когда приходит watermark. События с метками не больше watermark отдаются автомату по возрастанию времени, затем автомат сдвигает своё время до watermark и отбрасывает попытки, которые вышли за within.

apache/flink@release-2.2.1 · CepOperator.java, строки 303-326
// STEP 1
PriorityQueue<Long> sortedTimestamps = getSortedTimestamps();
NFAState nfaState = getNFAState();

// STEP 2
while (!sortedTimestamps.isEmpty()
        && sortedTimestamps.peek() <= timerService.currentWatermark()) {
    long timestamp = sortedTimestamps.poll();
    advanceTime(nfaState, timestamp);
    try (Stream<IN> elements = sort(elementQueueState.get(timestamp))) {
        elements.forEachOrdered(
                event -> {
                    try {
                        processEvent(nfaState, event, timestamp);
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                });
    }
    elementQueueState.remove(timestamp);
}

// STEP 3
advanceTime(nfaState, timerService.currentWatermark());

Проверка within сравнивает разницу между текущим временем и временем первого события попытки с размером окна. Начальное состояние под проверку не попадает.

Отсюда два практических следствия. Алерт CEP появляется не раньше, чем watermark пройдёт метку последнего события совпадения. В PayGuard допуск беспорядка 10 секунд, значит задержка алерта не меньше 10 секунд времени события. Платёж, опоздавший сильнее допуска, в шаблон не попадёт вовсе.

Что лежит в состоянии

Оператор CEP держит по каждому ключу три вещи. Это состояние автомата со списком частичных совпадений, буфер событий, ждущих watermark, и общий буфер событий, на которые ссылаются попытки.

В исходниках

Все три структуры создаются как keyed state, значит попадают в чекпоинт и переезжают вместе с ключом при смене параллелизма. Общий буфер SharedBuffer хранит каждое событие один раз, а попытки ссылаются на него, поэтому десять попыток с общим началом не копируют события десять раз.

apache/flink@release-2.2.1 · CepOperator.java, строки 183-201
computationStates =
        context.getKeyedStateStore()
                .getState(
                        new ValueStateDescriptor<>(
                                NFA_STATE_NAME, new NFAStateSerializer()));

partialMatches =
        new SharedBuffer<>(
                context.getKeyedStateStore(),
                inputSerializer,
                SharedBufferCacheConfig.of(getOperatorConfig().getConfiguration()));

elementQueueState =
        context.getKeyedStateStore()
                .getMapState(
                        new MapStateDescriptor<>(
                                EVENT_QUEUE_STATE_NAME,
                                LongSerializer.INSTANCE,
                                new ListSerializer<>(inputSerializer)));

Документация прямо советует ставить within() или until() у шагов с oneOrMore, иначе состоянию нечем очищаться.

⚠
Шаблон без within растёт вечно

Попытка, которая так и не дождалась последнего шага, без ограничения по времени остаётся в состоянии навсегда. У карты, которая один раз сделала мелкий платёж, попытка card testing висела бы годами. Ставь within на каждый шаблон и выбирай его по бизнес-смыслу. Для проверки карты это пять минут, для перелёта десять.

CEP или своя функция

Ту же проверку карты можно написать на KeyedProcessFunction из урока 7. Счётчик мелких платежей в ValueState, сброс на обычном платеже, таймер на пять минут. Для одного правила кода немного, и ты точно знаешь, сколько состояния занято. CEP выигрывает, когда шаблонов много, у них разные смежности и квантификаторы, и аналитики хотят читать правило как описание последовательности.

ВопросKeyedProcessFunctionCEP
Порядок событий во времениСортировку и буфер пишешь самБуфер до watermark встроен
Объём состоянияРовно то, что объявилЗависит от смежности и числа попыток
Несколько вариантов совпаденияКаждую ветку кодируешь рукамиАвтомат ведёт ветки сам
Таймаут неполного совпаденияТаймер и очистка рукамиwithin и обработчик таймаутов

В Spark Structured Streaming библиотеки шаблонов нет. Похожую логику там пишут в flatMapGroupsWithState или transformWithState, то есть так же вручную, как на KeyedProcessFunction. Во Flink SQL шаблоны описывает оператор MATCH_RECOGNIZE из стандарта SQL, и документация говорит, что внутри он использует ту же библиотеку CEP.

Проверь себя

Нужно ловить цепочку «вход с нового устройства, смена пароля, вывод денег». Между ними пользователь смотрит баланс и выписки. Какая связка между шагами подходит?

Коллега выкатил шаблон с oneOrMore().allowCombinations() и без within. Через сутки чекпоинт CEP-оператора вырос в десятки раз. Что случилось?

На стенде генератор прислал одну атаку card testing и остановился. Алерта в топике нет уже минуту. Почему?

Что будет в конце

В задании появится вершина cep-card-testing. Она ищет по каждой карте три мелких платежа подряд и сразу за ними крупный, а алерты уходят через обогащение в fraud.alerts вместе с остальными. Генератор научится присылать такие атаки по флагу --card-testing.

Правило impossible travel относится к заданию. Методы impossibleTravel и impossibleTravelAlerts, класс DifferentCountry и их тесты пока не пиши.

Шаг 1. Зависимость

что изменилось в build.gradle.kts
@@ -33,6 +33,7 @@ 
     implementation("org.apache.flink:flink-connector-kafka:$kafkaConnectorVersion")
     implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
+    implementation("org.apache.flink:flink-cep:$flinkVersion")     implementation("org.postgresql:postgresql:42.7.14")
     implementation("com.zaxxer:HikariCP:7.0.2")
 

Библиотека flink-cep выходит вместе с Flink, поэтому версия берётся из той же переменной flinkVersion. Мы кладём её в толстый jar через implementation.

В исходниках

Документация CEP пишет, что библиотеки нет в бинарном дистрибутиве. Сборка дистрибутива в теге 2.2.1 говорит обратное и кладёт jar в lib/ с пометкой, что он нужен для MATCH_RECOGNIZE.

apache/flink@release-2.2.1 · bin.xml, строки 95-101
<!-- CEP (required for SQL MATCH_RECOGNIZE) -->
<file>
	<source>../flink-libraries/flink-cep/target/flink-cep-${project.version}.jar</source>
	<outputDirectory>lib/</outputDirectory>
	<destName>flink-cep-${project.version}.jar</destName>
	<fileMode>0644</fileMode>
</file>

Классы из пакета org.apache.flink. всегда грузятся родительским загрузчиком, это мы уже встречали в ловушке урока 14.

apache/flink@release-2.2.1 · CoreOptions.java, строки 107-126
public static final ConfigOption<List<String>> ALWAYS_PARENT_FIRST_LOADER_PATTERNS =
        ConfigOptions.key("classloader.parent-first-patterns.default")
                .stringType()
                .asList()
                .defaultValues(
                        ArrayUtils.concat(
                                new String[] {
                                    "java.",
                                    "scala.",
                                    "org.apache.flink.",
                                    "com.esotericsoftware.kryo",
                                    "org.apache.hadoop.",
                                    "javax.annotation.",
                                    "org.xml",
                                    "javax.xml",
                                    "org.apache.xerces",
                                    "org.w3c",
                                    "org.rocksdb."
                                },
                                PARENT_FIRST_LOGGING_PATTERNS))

Если jar лежит в lib/ кластера, работают его классы, а копия в нашем jar не используется. Версии совпадают, поэтому конфликта нет. Копия в jar страхует от образа, где библиотеку из lib/ убрали. Проверить свой образ можно командой docker compose exec jobmanager ls /opt/flink/lib, мы этот вывод на стенде не снимали.

Шаг 2. Атаки в генераторе

Генератор получает два новых флага. --card-testing задаёт долю случаев, когда вместо обычного платежа начинается атака проверки карты. --travel делает то же для двух платежей из разных стран, он понадобится в задании.

что изменилось в src/main/java/dev/study/payguard/loadgen/GeneratorConfig.java
@@ -12,6 +12,8 @@         double duplicateShare,
         double badShare,
         double burstShare,
+        double cardTestingShare,+        double travelShare,         String confirmationsTopic,
         double confirmedShare) {
 
@@ -25,6 +27,8 @@                 share(options, "dup", 0.0),
                 share(options, "bad", 0.0),
                 share(options, "burst", 0.0),
+                share(options, "card-testing", 0.0),+                share(options, "travel", 0.0),                 options.getOrDefault("confirmations-topic", "payments.confirmations"),
                 share(options, "confirm", 0.95));
     }

Источник записей теперь берёт доли прямо из конфигурации, конструктор с пятью числами подряд стало слишком легко перепутать.

что изменилось в src/main/java/dev/study/payguard/loadgen/RawRecordSource.java
@@ -20,14 +20,18 @@     private final double duplicateShare;
     private final double badShare;
     private final double burstShare;
+    private final double cardTestingShare;+    private final double travelShare;     private String previousRecord;
 
-    RawRecordSource(PaymentFactory factory, Random random, double duplicateShare, double badShare, double burstShare) {+    RawRecordSource(PaymentFactory factory, Random random, GeneratorConfig config) {         this.factory = factory;
         this.random = random;
-        this.duplicateShare = duplicateShare;-        this.badShare = badShare;-        this.burstShare = burstShare;+        this.duplicateShare = config.duplicateShare();+        this.badShare = config.badShare();+        this.burstShare = config.burstShare();+        this.cardTestingShare = config.cardTestingShare();+        this.travelShare = config.travelShare();     }
 
     String next(long nowMs) {
@@ -36,6 +40,14 @@         }
         if (random.nextDouble() < burstShare) {
             pendingBurst.addAll(factory.burst(nowMs, BURST_SIZE));
+            return remember(pendingBurst.poll());+        }+        if (random.nextDouble() < cardTestingShare) {+            pendingBurst.addAll(factory.cardTestingAttack(nowMs));+            return remember(pendingBurst.poll());+        }+        if (random.nextDouble() < travelShare) {+            pendingBurst.addAll(factory.impossibleTravelAttack(nowMs));             return remember(pendingBurst.poll());
         }
         if (random.nextDouble() < badShare) {
что изменилось в src/main/java/dev/study/payguard/loadgen/LoadGenerator.java
@@ -17,9 +17,7 @@     public static void main(String[] args) throws InterruptedException {
         GeneratorConfig config = GeneratorConfig.fromArgs(args);
         Random random = new Random();
-        RawRecordSource source = new RawRecordSource(-                new PaymentFactory(random, config.lateShare()), random,-                config.duplicateShare(), config.badShare(), config.burstShare());+        RawRecordSource source = new RawRecordSource(new PaymentFactory(random, config.lateShare()), random, config); 
         try (KafkaProducer<String, String> producer = new KafkaProducer<>(producerProperties(config))) {
             produceForever(producer, source, config);

Атака складывается в ту же очередь pendingBurst, что и серия из урока 8. Шесть платежей одной карты уходят в Kafka друг за другом с метками через 1 мс. Между ними в топик попадут платежи других карт, но шаблону это не мешает. Поток разбит по cardId, и автомат каждой карты видит только её платежи.

что изменилось в src/main/java/dev/study/payguard/loadgen/PaymentFactory.java
@@ -38,15 +38,42 @@         return payments;
     }
 
+    /** Проверка украденной карты: пять мелких платежей и сразу крупный. */+    List<PaymentEvent> cardTestingAttack(long nowMs) {+        String card = randomCard();+        List<PaymentEvent> payments = new ArrayList<>();+        for (int i = 0; i < 5; i++) {+            payments.add(paymentOn(card, nextMerchant(), 100 + random.nextInt(300), nowMs + i));+        }+        payments.add(paymentOn(card, nextMerchant(), 50_000 + random.nextInt(50_000), nowMs + 5));+        return payments;+    }++    /** Два платежа по одной карте из разных стран подряд: карту скопировали. */+    List<PaymentEvent> impossibleTravelAttack(long nowMs) {+        String card = randomCard();+        PaymentEvent home = paymentOn(card, nextMerchant(), 5_000, nowMs);+        PaymentEvent abroad = paymentInCountry(card, nextMerchant(), 5_000, nowMs + 1, "XX");+        return List.of(home, abroad);+    }+     private PaymentEvent paymentOn(String card, String merchant, long amountMinor, long eventTimeMs) {
+        return paymentInCountry(card, merchant, amountMinor, eventTimeMs, homeCountry(card));+    }++    private PaymentEvent paymentInCountry(String card, String merchant, long amountMinor, long eventTimeMs, String country) {         return new PaymentEvent(
                 new UUID(random.nextLong(), random.nextLong()).toString(),
                 card,
                 merchant,
                 amountMinor,
                 "EUR",
-                COUNTRIES.get(random.nextInt(COUNTRIES.size())),+                country,                 eventTimeMs);
+    }++    private static String homeCountry(String card) {+        return COUNTRIES.get(Math.floorMod(card.hashCode(), COUNTRIES.size()));     }
 
     private String randomCard() {
⚠
Почему у карты появилась домашняя страна

До этого урока генератор выбирал страну случайно для каждого платежа. Пока страну никто не сравнивал, это было безобидно. Первый же прогон правила impossible travel выдал 869 ложных алертов, потому что два соседних платежа одной карты в четырёх случаях из пяти приходили из разных стран. Теперь страна вычисляется из хеша номера карты методом homeCountry, и обычные платежи карты всегда идут из одной страны. Чужую страну XX даёт только атака по флагу --travel.

Шаг 3. Шаблон проверки карты

Создай pipeline/FraudPatterns.java. Ниже части файла, которые относятся к практике. В снапшоте в том же файле лежит и решение задания, поэтому константы и импорт для него тоже видны. Их можно добавить сейчас или в задании.

src/main/java/dev/study/payguard/pipeline/FraudPatterns.java (строки 1-39)
package dev.study.payguard.pipeline;

import dev.study.payguard.model.FraudAlert;
import dev.study.payguard.model.PaymentEvent;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.apache.flink.cep.CEP;
import org.apache.flink.cep.functions.PatternProcessFunction;
import org.apache.flink.cep.nfa.aftermatch.AfterMatchSkipStrategy;
import org.apache.flink.cep.pattern.Pattern;
import org.apache.flink.cep.pattern.conditions.IterativeCondition;
import org.apache.flink.cep.pattern.conditions.SimpleCondition;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.KeyedStream;
import org.apache.flink.util.Collector;

/** Последовательности платежей, по которым видно мошенничество. Ключ потока это cardId. */
public final class FraudPatterns {

    public static final String CARD_TESTING_RULE = "card-testing";
    public static final String IMPOSSIBLE_TRAVEL_RULE = "impossible-travel";

    static final long SMALL_AMOUNT_MINOR = 500;
    static final long LARGE_AMOUNT_MINOR = 30_000;
    private static final int MIN_PROBES = 3;
    private static final Duration CARD_TESTING_WINDOW = Duration.ofMinutes(5);
    private static final Duration TRAVEL_WINDOW = Duration.ofMinutes(10);

    /** Проверка карты: несколько мелких платежей подряд и затем крупный. */
    public static Pattern<PaymentEvent, ?> cardTesting() {
        return Pattern.<PaymentEvent>begin("probes", AfterMatchSkipStrategy.skipPastLastEvent())
                .where(SimpleCondition.of(payment -> payment.amountMinor() < SMALL_AMOUNT_MINOR))
                .timesOrMore(MIN_PROBES)
                .consecutive()
                .next("cash-out")
                .where(SimpleCondition.of(payment -> payment.amountMinor() > LARGE_AMOUNT_MINOR))
                .within(CARD_TESTING_WINDOW);
    }
КОД
    public static Pattern<PaymentEvent, ?> cardTesting() {
        return Pattern.<PaymentEvent>begin("probes", AfterMatchSkipStrategy.skipPastLastEvent())
                .where(SimpleCondition.of(payment -> payment.amountMinor() < SMALL_AMOUNT_MINOR))
                .timesOrMore(MIN_PROBES)
                .consecutive()
                .next("cash-out")
                .where(SimpleCondition.of(payment -> payment.amountMinor() > LARGE_AMOUNT_MINOR))
                .within(CARD_TESTING_WINDOW);
ПРОСТЫМИ СЛОВАМИ

Метод возвращает шаблон над платежами. Знак вопроса во втором параметре типа означает, что тип последнего шага нам не важен.

Первый шаг называется probes. Вторым аргументом передаём стратегию пропуска, после совпадения выбрасываются все попытки, начатые до его конца. Без неё одна атака дала бы три алерта.

Шаг принимает только платежи меньше 500 минимальных единиц, то есть меньше пяти евро.

Таких платежей должно быть три или больше.

И они должны идти подряд. Обычный платёж посреди серии обрывает попытку.

Следующий шаг cash-out со строгой смежностью. Крупный платёж обязан идти сразу за последним мелким.

Крупный значит больше 30 000 минимальных единиц, то есть больше 300 евро.

Весь шаблон от первого мелкого до крупного должен уложиться в пять минут времени события.

Шаблон подключается к потоку методом CEP.pattern. Он принимает поток с ключом, поэтому совпадения ищутся отдельно для каждой карты. Имя и uid вершины нужны, как и раньше, для Web UI и для восстановления состояния из savepoint.

src/main/java/dev/study/payguard/pipeline/FraudPatterns.java (строки 49-54)
    public static DataStream<FraudAlert> cardTestingAlerts(KeyedStream<PaymentEvent, String> byCard) {
        return CEP.pattern(byCard, cardTesting())
                .process(new AlertOnMatch(CARD_TESTING_RULE, "cash-out", "мелкие платежи и сразу крупный"))
                .name("cep-card-testing")
                .uid("cep-card-testing");
    }

Функция совпадения общая для обоих правил. CEP передаёт ей словарь, где ключ это имя шага, а значение это список принятых в шаг событий. Алерт строится по первому событию шага, имя которого передано в конструкторе. Для проверки карты это крупный платёж, и alertId будет вида card-testing:<paymentId>. Повторная обработка той же атаки даст тот же идентификатор.

src/main/java/dev/study/payguard/pipeline/FraudPatterns.java (строки 75-95)
    private static final class AlertOnMatch extends PatternProcessFunction<PaymentEvent, FraudAlert> {

        private final String ruleId;
        private final String triggerStep;
        private final String detail;

        AlertOnMatch(String ruleId, String triggerStep, String detail) {
            this.ruleId = ruleId;
            this.triggerStep = triggerStep;
            this.detail = detail;
        }

        @Override
        public void processMatch(Map<String, List<PaymentEvent>> match, Context context, Collector<FraudAlert> out) {
            out.collect(FraudAlert.of(ruleId, match.get(triggerStep).get(0), detail));
        }
    }

    private FraudPatterns() {
    }
}

Шаг 4. Подключи правило в задании

Алерты CEP объединяются с остальными до обогащения. В снапшоте объединение уже содержит и поток из задания. В практике добавь в union только FraudPatterns.cardTestingAlerts(byCard) и импорт FraudPatterns.

src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 155-159)
        return dynamicAlerts.union(
                amountAlerts,
                unconfirmedAlerts,
                FraudPatterns.cardTestingAlerts(byCard),
                FraudPatterns.impossibleTravelAlerts(byCard));

Поток byCard тот же, что у правил из уроков 7 и 8. Каждый его потребитель получает свою копию платежей по сети, поэтому у новой вершины вход равен входу rules-dynamic.

Шаг 5. Тесты

Тест запускает настоящий оператор CEP на MiniCluster. Это важно, потому что CEP работает во времени события, и без watermark совпадений не будет. Вспомогательный streamOf из прошлых уроков назначает метки по времени платежа. Когда данные кончаются, watermark уходит в бесконечность, и буфер CEP разбирается целиком. Ниже три теста на проверку карты. Они проверяют срабатывание, обрыв серии обычным платежом и предел в пять минут.

src/test/java/dev/study/payguard/pipeline/FraudPatternsTest.java (строки 1-71)
package dev.study.payguard.pipeline;

import static dev.study.payguard.testing.Payments.payment;
import static dev.study.payguard.testing.Payments.streamOf;
import static org.assertj.core.api.Assertions.assertThat;

import dev.study.payguard.model.FraudAlert;
import dev.study.payguard.model.PaymentEvent;
import java.util.List;
import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration;
import org.apache.flink.streaming.api.datastream.KeyedStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.test.junit5.MiniClusterExtension;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

class FraudPatternsTest {

    @RegisterExtension
    static final MiniClusterExtension CLUSTER = new MiniClusterExtension(
            new MiniClusterResourceConfiguration.Builder()
                    .setNumberTaskManagers(1)
                    .setNumberSlotsPerTaskManager(2)
                    .build());

    private static KeyedStream<PaymentEvent, String> byCard(List<PaymentEvent> payments) {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(2);
        return streamOf(env, payments).keyBy(PaymentEvent::cardId);
    }

    private static PaymentEvent inCountry(String id, String country, long time) {
        return new PaymentEvent(id, "c1", "m1", 1_000, "EUR", country, time);
    }

    @Test
    void cardTestingFiresOnSmallPaymentsThenLargeOne() throws Exception {
        List<PaymentEvent> payments = List.of(
                payment("p1", "c1", "m1", 100, 1_000),
                payment("p2", "c1", "m1", 100, 2_000),
                payment("p3", "c1", "m1", 100, 3_000),
                payment("p4", "c1", "m1", 90_000, 4_000));

        List<FraudAlert> alerts = FraudPatterns.cardTestingAlerts(byCard(payments)).executeAndCollect(10);

        assertThat(alerts).extracting(FraudAlert::alertId).containsExactly("card-testing:p4");
    }

    @Test
    void cardTestingIgnoresBrokenSequence() throws Exception {
        List<PaymentEvent> payments = List.of(
                payment("p1", "c1", "m1", 100, 1_000),
                payment("p2", "c1", "m1", 100, 2_000),
                payment("p3", "c1", "m1", 5_000, 3_000),
                payment("p4", "c1", "m1", 100, 4_000),
                payment("p5", "c1", "m1", 90_000, 5_000));

        assertThat(FraudPatterns.cardTestingAlerts(byCard(payments)).executeAndCollect(10)).isEmpty();
    }

    @Test
    void cardTestingRespectsTimeLimit() throws Exception {
        long tooLate = 1_000 + 6 * 60_000;
        List<PaymentEvent> payments = List.of(
                payment("p1", "c1", "m1", 100, 1_000),
                payment("p2", "c1", "m1", 100, 2_000),
                payment("p3", "c1", "m1", 100, 3_000),
                payment("p4", "c1", "m1", 90_000, tooLate));

        assertThat(FraudPatterns.cardTestingAlerts(byCard(payments)).executeAndCollect(10)).isEmpty();
    }

Тест генератора проверяет, что атака начинается мелким платежом и заканчивается крупным.

что изменилось в src/test/java/dev/study/payguard/loadgen/RawRecordSourceTest.java
@@ -12,7 +12,9 @@ 
     private static RawRecordSource sourceWith(double duplicateShare, double badShare) {
         Random random = new Random(42);
-        return new RawRecordSource(new PaymentFactory(random, 0.0), random, duplicateShare, badShare, 0.0);+        GeneratorConfig config = GeneratorConfig.fromArgs(new String[] {+                "--dup", String.valueOf(duplicateShare), "--bad", String.valueOf(badShare)});+        return new RawRecordSource(new PaymentFactory(random, 0.0), random, config);     }
 
     @Test
@@ -35,6 +37,22 @@     }
 
     @Test
+    void cardTestingAttackEndsWithLargePayment() throws Exception {+        Random random = new Random(42);+        GeneratorConfig config = GeneratorConfig.fromArgs(new String[] {"--card-testing", "1.0"});+        RawRecordSource source = new RawRecordSource(new PaymentFactory(random, 0.0), random, config);+        com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();++        long[] amounts = new long[6];+        for (int i = 0; i < amounts.length; i++) {+            amounts[i] = mapper.readTree(source.next(NOW)).get("amountMinor").asLong();+        }++        assertThat(amounts[0]).isLessThan(500);+        assertThat(amounts[5]).isGreaterThan(30_000);+    }++    @Test     void shareOutsideRangeIsRejected() {
         String[] args = {"--dup", "1.5"};
         assertThatThrownBy(() -> GeneratorConfig.fromArgs(args))
терминал
./gradlew test

Шаг 6. Собери и запусти

терминал
./gradlew shadowJar
docker compose up -d --wait
docker compose exec jobmanager flink list

Останови прошлое задание командой flink cancel <JobID> и отправь новое. Генератор запусти во втором терминале с атаками. Флаг --card-testing 0.002 означает, что примерно на 500 обычных шагов генератора придётся одна атака. --burst оставляет серии для правила частоты.

терминал
scripts/submit.sh
./gradlew loadgen --args="--rate 100 --burst 0.001 --card-testing 0.002"
вывод
Job has been submitted with JobID 01b9c903592784f694c70be4862860d4

Посмотри на граф задания той же командой, что в уроке 9.

терминал
JOB=$(curl -s localhost:8081/jobs | jq -r '.jobs[] | select(.status == "RUNNING") | .id')
curl -s localhost:8081/jobs/$JOB | jq -c '{name, state, vertices: [.vertices[] | {name, parallelism, status}]}'
вывод
{"name":"payguard","state":"RUNNING","vertices":[{"name":"Source: payments-raw -> split-parsed -> (dlq-sink: Writer -> dlq-sink: Committer, lateness-probe)","parallelism":2,"status":"RUNNING"},{"name":"Source: rules-config","parallelism":2,"status":"RUNNING"},{"name":"Source: payment-confirmations","parallelism":2,"status":"RUNNING"},{"name":"deduplicate","parallelism":2,"status":"RUNNING"},{"name":"merchant-stats-1m -> (merchant-stats-sink: Writer -> merchant-stats-sink: Committer, too-late-sink: Writer -> too-late-sink: Committer)","parallelism":2,"status":"RUNNING"},{"name":"card-sessions -> card-sessions-sink: Writer -> card-sessions-sink: Committer","parallelism":2,"status":"RUNNING"},{"name":"rules-dynamic","parallelism":2,"status":"RUNNING"},{"name":"rule-amount-spike","parallelism":2,"status":"RUNNING"},{"name":"join-confirmations -> confirmed-sink: Writer -> confirmed-sink: Committer","parallelism":2,"status":"RUNNING"},{"name":"rule-unconfirmed","parallelism":2,"status":"RUNNING"},{"name":"cep-card-testing","parallelism":2,"status":"RUNNING"},{"name":"cep-impossible-travel","parallelism":2,"status":"RUNNING"},{"name":"enrich-alerts -> alerts-sink: Writer -> alerts-sink: Committer","parallelism":2,"status":"RUNNING"}]}

Вывод снят со снапшота, где задание уже решено, поэтому в нём есть cep-impossible-travel. У тебя её пока нет. Вершина cep-card-testing отдельная, перед ней стоит keyBy по карте, а после неё объединение потоков.

Теперь метрики вершины. Цикл тот же, что в уроке 9, меняется только имя.

терминал
for NAME in cep-card-testing; do
  V=$(curl -s localhost:8081/jobs/$JOB | jq -r --arg n "$NAME" '.vertices[] | select(.name | startswith($n)) | .id')
  curl -s "localhost:8081/jobs/$JOB/vertices/$V/subtasks/metrics?get=numRecordsIn,numRecordsOut,busyTimeMsPerSecond,backPressuredTimeMsPerSecond&agg=min,max,sum" | jq -c .
done
вывод
[{"id":"numRecordsIn","min":8554.0,"max":8611.0,"sum":17165.0},{"id":"numRecordsOut","min":17.0,"max":21.0,"sum":38.0},{"id":"busyTimeMsPerSecond","min":2.0,"max":2.0,"sum":4.0},{"id":"backPressuredTimeMsPerSecond","min":0.0,"max":0.0,"sum":0.0}]

На вход пришло 17165 платежей, столько же, сколько у rules-dynamic в этом прогоне. Оба оператора читают один поток byCard. На выход ушло 38 совпадений, по одному на атаку благодаря skipPastLastEvent. Подзадачи нагружены поровну, занятость 2 мс в секунду. Автомат с одной-двумя попытками на карту почти ничего не стоит.

В отдельном прогоне на 60 секунд с теми же флагами правило card testing выдало 6 алертов, а правило частоты 7. У всех алертов было заполнено поле merchantRisk из PostgreSQL, они прошли обогащение вместе с остальными.

Отдельно загляни в Web UI на вкладку Metrics вершины cep-card-testing и найди счётчик numLateRecordsDropped. Он показывает платежи, которые пришли позже watermark и в шаблон не попали. По умолчанию генератор задерживает 5 % платежей на время до 30 секунд, долю меняет флаг --late. При допуске 10 секунд часть таких платежей окажется за watermark, и счётчик будет расти. Значение счётчика мы на стенде не снимали.

Задание. Невозможный перелёт

Карту скопировали, и копией расплачиваются в другой стране. Напиши правило, которое выдаёт алерт, если два соседних платежа одной карты пришли из разных стран и между ними прошло меньше десяти минут времени события.

  1. Добавь в FraudPatterns метод impossibleTravel(), который возвращает Pattern<PaymentEvent, ?>. Первый шаг называется first, второй second.
  2. Условие второго шага сравнивает страну с первым платежом, поэтому оно должно быть IterativeCondition.
  3. Добавь метод impossibleTravelAlerts(KeyedStream<PaymentEvent, String>). Правило называется impossible-travel, текст алерта «платежи из разных стран за 10 минут», имя и uid вершины cep-impossible-travel. Алерт строится по второму платежу.
  4. Подключи правило в PayGuardJob, его алерты должны пройти обогащение.
  5. Напиши тесты в FraudPatternsTest.

Готово, если выполнены все условия.

  1. Платежи DE, DE, ES подряд дают ровно один алерт impossible-travel:p3.
  2. Платежи DE и ES с разницей 11 минут алерта не дают.
  3. ./gradlew test проходит.
  4. На стенде появилась вершина cep-impossible-travel, и с флагом генератора --travel её выход растёт, а без флага остаётся нулевым.

Подсказка первая. Первый шаг может вообще не иметь условия, тогда в него подходит любой платёж.

Подсказка вторая. Контекст условия отдаёт события уже принятых шагов по имени шага. Метод вызывай как можно реже, документация предупреждает, что он ищет события в общем буфере.

Подсказка третья. Подумай, какая смежность нужна между шагами и что станет с алертами на карте, которая три раза подряд меняет страну, без стратегии пропуска.

Ответ

Глоссарий урока

CEP (complex event processing)
Обработка сложных событий. Поиск в потоке последовательностей событий по шаблону. Во Flink это библиотека FlinkCEP, артефакт flink-cep.
Шаблон (pattern)
Описание искомой последовательности из именованных шагов с условиями, квантификаторами, смежностью и ограничением по времени.
Шаг шаблона
Именованная часть шаблона, например probes или cash-out. По имени шага функция совпадения получает принятые в него события.
Частичное совпадение (partial match)
Начатая, но ещё не законченная попытка совпасть с шаблоном. Хранится в состоянии оператора, пока не завершится, не оборвётся или не истечёт.
NFA
Недетерминированный конечный автомат. В него компилятор CEP переводит шаблон. Из одного состояния на одно событие могут сработать несколько переходов, и попытка раздваивается.
Смежность (contiguity)
Правило, какие события можно пропустить между шагами шаблона. Строгая задаётся next, ослабленная followedBy, недетерминированная ослабленная followedByAny.
Квантификатор (quantifier)
Сколько событий принимает шаг. Например times(3), timesOrMore(3), oneOrMore(), optional().
consecutive и allowCombinations
Смежность между событиями внутри шага с квантификатором. consecutive делает её строгой, allowCombinations недетерминированной, по умолчанию она ослабленная.
within
Предельное время от первого до последнего события совпадения. Попытки, которые не уложились, выбрасываются и освобождают состояние.
IterativeCondition
Условие шага, которое видит события, уже принятые в другие шаги этого совпадения, через getEventsForPattern.
SimpleCondition
Условие шага, которое смотрит только на текущее событие. Создаётся из лямбды методом SimpleCondition.of.
Стратегия пропуска (AfterMatchSkipStrategy)
Что делать с остальными попытками после найденного совпадения. NO_SKIP оставляет все, skipPastLastEvent выбрасывает начатые до конца совпадения.
PatternProcessFunction
Функция, которую CEP вызывает на каждое совпадение. Получает словарь от имени шага к списку событий.
CepOperator
Оператор Flink, который исполняет шаблон. Буферизует события до watermark, ведёт автомат и хранит всё в keyed state.
SharedBuffer
Общий буфер событий оператора CEP в keyed state. Хранит каждое событие один раз, попытки ссылаются на него.
MATCH_RECOGNIZE
Оператор стандарта SQL для поиска шаблонов в строках таблицы. Во Flink SQL работает поверх библиотеки CEP.
Watermark
Отметка времени события в потоке, после которой более ранних событий оператор не ждёт. CEP обрабатывает события только до текущего watermark.
Состояние с ключом (keyed state)
Состояние, которое Flink хранит отдельно для каждого ключа. CEP держит в нём автомат и буферы каждой карты.
KeyedProcessFunction
Функция над потоком с ключом с доступом к состоянию и таймерам. На ней правило последовательности пишут вручную.
Card testing
Мошенническая проверка украденных карт мелкими платежами перед крупной покупкой.
Impossible travel
Признак скопированной карты, когда соседние платежи пришли из мест, между которыми нельзя успеть переместиться.
Parent-first
Порядок загрузки классов, при котором класс сначала ищется в библиотеках кластера и только потом в jar задания. Для пакета org.apache.flink. он действует всегда.
MiniCluster
Кластер Flink внутри одного процесса JVM для тестов. На нём работают настоящие операторы со временем и состоянием.
Spark Structured Streaming
Потоковый движок Spark. Логику последовательностей в нём пишут вручную в flatMapGroupsWithState или transformWithState.

Урок 11 · Отказоустойчивость

Чекпоинты изнутри. Как Flink снимает согласованный снимок, не останавливая поток

После урока ты проследишь путь чекпоинта по коду Flink от координатора до подтверждения, включишь RocksDB с инкрементальными снимками и проверишь, что PayGuard переживает убийство TaskManager без потери состояния.

Что теряет PayGuard при падении TaskManager

К этому уроку в задании живут корзины правила частоты, флаги дедупликации, окна по продавцам, сессии карт и частичные совпадения CEP. Всё это лежит в куче TaskManager. В конце урока 7 REST показал "completed":0, чекпоинтов не было ни одного. Если сейчас процесс убить, задание перезапустится с пустым состоянием. Правило частоты забудет, что карта уже сделала девять платежей за минуту, и мошенник получит ещё одну попытку.

Нужен снимок состояния всех подзадач и позиций чтения в Kafka, сделанный в один логический момент. Остановить поток ради снимка нельзя, PayGuard обрабатывает 200 платежей в секунду. Снимать каждую подзадачу по своему таймеру тоже нельзя. Тогда одна подзадача сохранит состояние до платежа, а соседняя после него, и при восстановлении платёж будет учтён наполовину.

Переучёт в сортировочном центре

Представь сортировочный центр, где посылки едут по лентам от приёмных окон к стеллажам. Каждый стеллаж ведёт свой журнал, сколько посылок к нему приехало. Директору нужен переучёт, но остановить ленты нельзя, иначе встанет доставка.

Директор просит каждое приёмное окно положить на ленту красную карточку с номером переучёта. Окно записывает, сколько посылок оно уже приняло, и продолжает работу. Карточка едет по ленте вместе с посылками и никого не обгоняет. Кладовщик стеллажа, увидев карточку, переписывает свой журнал в ведомость. Если к стеллажу идут две ленты, карточка по одной из них придёт раньше. Тогда кладовщик откладывает посылки, приехавшие на этой ленте после карточки, в сторону и продолжает разбирать вторую ленту, пока карточка не придёт и по ней. Только тогда он переписывает журнал и берётся за отложенное.

Во Flink карточка называется барьером чекпоинта (checkpoint barrier), директор это координатор чекпоинтов в JobManager, а откладывание посылок называется выравниванием (alignment). Документация ссылается на статью «Lightweight Asynchronous Snapshots for Distributed Dataflows», где алгоритм назван asynchronous barrier snapshotting. Он вырос из алгоритма Chandy-Lamport для распределённых снимков и приспособлен к модели исполнения Flink.

Путь чекпоинта по коду

Проследим один чекпоинт от таймера в JobManager до записи «завершён». Шагов семь, и каждый подтверждён кодом ниже.

1
Координатор выбирает, кого будить

CheckpointCoordinator по интервалу строит план. Команду получают только источники, ждать подтверждения будут от всех подзадач.

2
Источник вставляет барьер

Подзадача источника запоминает свои смещения Kafka и отправляет барьер во все выходные каналы впереди новых записей.

3
Барьер едет по сети вместе с данными

Он стоит в том же буфере, что и записи, и обгонять их не умеет, пока чекпоинт выровненный.

4
Оператор выравнивает входы

Канал, по которому барьер уже пришёл, блокируется. Когда барьер придёт по всем каналам, оператор снимает снимок и пропускает барьер дальше.

5
Синхронная и асинхронная части

Синхронно фиксируется точка снимка, а выгрузка файлов в хранилище идёт в отдельном потоке, пока задача обрабатывает следующие записи.

6
Подзадача подтверждает

Описание выгруженного состояния уходит координатору. Когда подтвердили все, чекпоинт считается завершённым.

7
Уведомление о завершении

Координатор рассылает подзадачам notifyCheckpointComplete. Источник Kafka в этот момент коммитит смещения группы.

В исходниках

Шаг 1. План чекпоинта, когда все задачи работают. В список «кого запустить» попадают только sourceTasks, в список «кого ждать» попадают все задачи.

apache/flink@release-2.2.1 · DefaultCheckpointPlanCalculator.java, строки 158-173
private CheckpointPlan calculateWithAllTasksRunning() {
    List<Execution> executionsToTrigger =
            sourceTasks.stream()
                    .map(ExecutionVertex::getCurrentExecutionAttempt)
                    .collect(Collectors.toList());

    List<Execution> tasksToWaitFor = createTaskToWaitFor(allTasks);

    return new DefaultCheckpointPlan(
            Collections.unmodifiableList(executionsToTrigger),
            Collections.unmodifiableList(tasksToWaitFor),
            Collections.unmodifiableList(allTasks),
            Collections.emptyList(),
            Collections.emptyList(),
            allowCheckpointsAfterTasksFinished);
}

Сам вызов идёт по этому списку. В опции чекпоинта координатор кладёт режим (isExactlyOnceMode), признак unaligned и таймаут выравнивания, и они едут к задачам вместе с номером.

apache/flink@release-2.2.1 · CheckpointCoordinator.java, строки 848-867
final CheckpointOptions checkpointOptions =
        CheckpointOptions.forConfig(
                type,
                checkpoint.getCheckpointStorageLocation().getLocationReference(),
                isExactlyOnceMode,
                unalignedCheckpointsEnabled,
                alignedCheckpointTimeout);

// send messages to the tasks to trigger their checkpoints
List<CompletableFuture<Acknowledge>> acks = new ArrayList<>();
for (Execution execution : checkpoint.getCheckpointPlan().getTasksToTrigger()) {
    if (request.props.isSynchronous()) {
        acks.add(
                execution.triggerSynchronousSavepoint(
                        checkpointId, timestamp, checkpointOptions));
    } else {
        acks.add(execution.triggerCheckpoint(checkpointId, timestamp, checkpointOptions));
    }
}
return FutureUtils.waitForAll(acks);

Промежуточные операторы команду от координатора не получают вовсе. Чекпоинт до них доезжает только барьером по потоку данных.

В исходниках

Шаги 2 и 5 на стороне подзадачи. Метод checkpointState пронумеровал фазы комментариями. Посмотри на порядок. Барьер уходит дальше (шаг 2 в коде) раньше, чем снимается состояние (шаг 5 в коде).

apache/flink@release-2.2.1 · SubtaskCheckpointCoordinatorImpl.java, строки 335-364
// Step (2): Send the checkpoint barrier downstream
LOG.debug(
        "Task {} broadcastEvent at {}, triggerTime {}, passed time {}",
        taskName,
        System.currentTimeMillis(),
        metadata.getTimestamp(),
        System.currentTimeMillis() - metadata.getTimestamp());
CheckpointBarrier checkpointBarrier =
        new CheckpointBarrier(metadata.getCheckpointId(), metadata.getTimestamp(), options);
operatorChain.broadcastEvent(checkpointBarrier, options.isUnalignedCheckpoint());

// Step (3): Register alignment timer to timeout aligned barrier to unaligned barrier
registerAlignmentTimer(metadata.getCheckpointId(), operatorChain, checkpointBarrier);

// Step (4): Prepare to spill the in-flight buffers for input and output
if (options.needsChannelState()) {
    // output data already written while broadcasting event
    channelStateWriter.finishOutput(metadata.getCheckpointId());
}

// Step (5): Take the state snapshot. This should be largely asynchronous, to not impact
// progress of the
// streaming topology

Map<OperatorID, OperatorSnapshotFutures> snapshotFutures =
        CollectionUtil.newHashMapWithExpectedSize(operatorChain.getNumberOfOperators());
try {
    if (takeSnapshotSync(
            snapshotFutures, metadata, metrics, options, operatorChain, isRunning)) {
        finishAndReportAsync(

Порядок безопасен, потому что шаги идут подряд в одном методе, и между отправкой барьера и снимком оператор не получает новых записей. Нижестоящие операторы получают барьер как можно раньше. takeSnapshotSync фиксирует состояние, а finishAndReportAsync отдаёт выгрузку асинхронному потоку. Шаг 3 в коде регистрирует таймер выравнивания, о нём ниже в разделе про unaligned-чекпоинты.

Выравнивание на двух входах

У оператора deduplicate два входных канала, по одному от каждой подзадачи источника. Барьер по ним приходит в разное время, потому что очереди разной длины. Пройди анимацию по шагам в обоих режимах и сравни, что попадёт в снимок.

Барьер чекпоинта на операторе с двумя входами

В режиме exactly-once снимок содержит a1, b1, b2 и b3, ровно то, что источники отдали до барьера. В режиме at-least-once в снимок попадают a2 и a3, которые источник 1 отдал уже после того, как запомнил смещение. После восстановления он отдаст их снова, и счётчик в состоянии увеличится на них второй раз. Отсюда и названия режимов. Записи не теряются ни в одном из них, но без выравнивания часть может быть учтена дважды.

В исходниках

Шаг 4. SingleCheckpointBarrierHandler получает барьер из канала. Старый или уже обработанный барьер только снимает блокировку. Новый отмечается в наборе выровненных каналов, и обработка уходит в текущее состояние автомата.

apache/flink@release-2.2.1 · SingleCheckpointBarrierHandler.java, строки 213-235
@Override
public void processBarrier(
        CheckpointBarrier barrier, InputChannelInfo channelInfo, boolean isRpcTriggered)
        throws IOException {
    long barrierId = barrier.getId();
    LOG.debug("{}: Received barrier from channel {} @ {}.", taskName, channelInfo, barrierId);

    if (currentCheckpointId > barrierId
            || (currentCheckpointId == barrierId && !isCheckpointPending())) {
        if (!barrier.getCheckpointOptions().isUnalignedCheckpoint()) {
            inputs[channelInfo.getGateIdx()].resumeConsumption(channelInfo);
        }
        return;
    }

    checkNewCheckpoint(barrier);
    checkState(currentCheckpointId == barrierId);

    markCheckpointAlignedAndTransformState(
            channelInfo,
            barrier,
            state -> state.barrierReceived(context, channelInfo, barrier, !isRpcTriggered));
}

Состояние выровненного режима блокирует канал, на котором пришёл барьер. Когда пришли все барьеры, запускается снимок и все каналы открываются разом.

apache/flink@release-2.2.1 · AbstractAlignedBarrierHandlerState.java, строки 52-77
@Override
public final BarrierHandlerState barrierReceived(
        Controller controller,
        InputChannelInfo channelInfo,
        CheckpointBarrier checkpointBarrier,
        boolean markChannelBlocked)
        throws IOException, CheckpointException {
    checkState(!checkpointBarrier.getCheckpointOptions().isUnalignedCheckpoint());

    if (markChannelBlocked) {
        state.blockChannel(channelInfo);
    }

    if (controller.allBarriersReceived()) {
        return triggerGlobalCheckpoint(controller, checkpointBarrier);
    }

    return convertAfterBarrierReceived(state);
}

protected WaitingForFirstBarrier triggerGlobalCheckpoint(
        Controller controller, CheckpointBarrier checkpointBarrier) throws IOException {
    controller.triggerGlobalCheckpoint(checkpointBarrier);
    state.unblockAllChannels();
    return new WaitingForFirstBarrier(state.getInputs());
}

Блокировка здесь значит, что сетевой стек перестаёт отдавать оператору буферы этого канала. Данные не выбрасываются, они ждут в очереди канала, как отложенные посылки у кладовщика.

В исходниках

Какой обработчик барьеров получит задача, решает режим execution.checkpointing.mode. Для exactly-once создаётся SingleCheckpointBarrierHandler, для at-least-once создаётся CheckpointBarrierTracker. Unaligned вместе с at-least-once запрещён явно.

apache/flink@release-2.2.1 · InputProcessorUtil.java, строки 101-130
switch (checkpointingMode) {
    case EXACTLY_ONCE:
        int numberOfChannels =
                (int)
                        Arrays.stream(inputs)
                                .mapToLong(gate -> gate.getChannelInfos().size())
                                .sum();
        return createBarrierHandler(
                toNotifyOnCheckpoint,
                jobConf,
                config,
                checkpointCoordinator,
                taskName,
                mailboxExecutor,
                timerService,
                inputs,
                clock,
                numberOfChannels);
    case AT_LEAST_ONCE:
        if (CheckpointingOptions.isUnalignedCheckpointEnabled(jobConf)) {
            throw new IllegalStateException(
                    "Cannot use unaligned checkpoints with AT_LEAST_ONCE "
                            + "checkpointing mode");
        }
        int numInputChannels =
                Arrays.stream(inputs)
                        .mapToInt(CheckpointableInput::getNumberOfInputChannels)
                        .sum();
        return new CheckpointBarrierTracker(
                numInputChannels,

Описание трекера прямо говорит, что каналы он не блокирует и поэтому даёт только «at least once».

apache/flink@release-2.2.1 · CheckpointBarrierTracker.java, строки 47-56
/**
 * The {@link CheckpointBarrierTracker} keeps track of what checkpoint barriers have been received
 * from which input channels. Once it has observed all checkpoint barriers for a checkpoint ID, it
 * notifies its listener of a completed checkpoint.
 *
 * <p>Unlike the {@link SingleCheckpointBarrierHandler}, the BarrierTracker does not block the input
 * channels that have sent barriers, so it cannot be used to gain "exactly-once" processing
 * guarantees. It can, however, be used to gain "at least once" processing guarantees.
 *
 * <p>NOTE: This implementation strictly assumes that newer checkpoints have higher checkpoint IDs.

По умолчанию режим EXACTLY_ONCE, мы его не меняем. Документация добавляет полезную деталь. Выравнивание нужно только операторам с несколькими входными каналами, то есть после keyBy, union или соединения потоков. Цепочка из map и filter с одним каналом даёт exactly-once даже в режиме at-least-once.

💡
Exactly-once здесь говорит о состоянии

Режим чекпоинта гарантирует, что каждая запись отразится в состоянии Flink ровно один раз. Записи после восстановления всё равно читаются из Kafka повторно, и приёмник может отправить их наружу дважды. Как довести гарантию до топика fraud.alerts, разберём в уроке 12.

Когда выравнивание мешает

Выравнивание стоит дёшево, пока каналы текут ровно. При backpressure барьер стоит в очереди за всеми буферами канала, и чекпоинт ждёт, пока они разберутся. В уроке 18 мы замедлим одну вершину на 20 мс на запись, и выровненные чекпоинты вырастут до 25651 и 61137 мс вместо обычных 300 мс.

Unaligned-чекпоинт (execution.checkpointing.unaligned.enabled) разрешает барьеру обогнать данные в буферах. Оператор реагирует на первый пришедший барьер, сразу отправляет его дальше и сохраняет обогнанные буферы входа и выхода как часть чекпоинта. Такой чекпоинт ближе к исходному Chandy-Lamport. Платой становится объём. В том же опыте урока 18 unaligned-чекпоинты занимали 129-293 мс и записывали 705-1011 КиБ данных в полёте. Настройка execution.checkpointing.aligned-checkpoint-timeout даёт компромисс. Чекпоинт начинается выровненным и переключается в unaligned, если выравнивание длится дольше таймаута. Это и есть таймер из шага 3 в коде выше. Savepoint всегда выровненный.

Подтверждение и завершение

В исходниках

Шаг 6 на стороне TaskManager. Когда асинхронная часть закончилась, AsyncCheckpointRunnable отправляет отчёт. В нём два размера. BytesPersistedOfThisCheckpoint это сколько записано именно этим чекпоинтом, TotalBytesPersisted это полный размер состояния.

apache/flink@release-2.2.1 · AsyncCheckpointRunnable.java, строки 235-246
taskEnvironment
        .getTaskStateManager()
        .reportTaskStateSnapshots(
                checkpointMetaData,
                checkpointMetrics
                        .setBytesPersistedOfThisCheckpoint(
                                acknowledgedTaskStateSnapshot.getCheckpointedSize())
                        .setTotalBytesPersisted(
                                acknowledgedTaskStateSnapshot.getStateSize())
                        .build(),
                hasAckState ? acknowledgedTaskStateSnapshot : null,
                hasLocalState ? localTaskStateSnapshot : null);

В REST эти размеры видны как checkpointed_size и state_size. На практике по их разнице видно, работает ли инкрементальность.

Шаг 6 на стороне координатора. Сначала общие файлы из подтверждения регистрируются в реестре SharedStateRegistry, это пригодится инкрементальным чекпоинтам. Потом подтверждение отмечается в PendingCheckpoint, и последнее превращает его в завершённый.

apache/flink@release-2.2.1 · CheckpointCoordinator.java, строки 1244-1268
    if (checkpoint == null || !checkpoint.getProps().isSavepoint()) {
        message.getSubtaskState()
                .registerSharedStates(
                        completedCheckpointStore.getSharedStateRegistry(),
                        checkpointId);
    }
}

if (checkpoint != null && !checkpoint.isDisposed()) {

    switch (checkpoint.acknowledgeTask(
            message.getTaskExecutionId(),
            message.getSubtaskState(),
            message.getCheckpointMetrics())) {
        case SUCCESS:
            LOG.debug(
                    "Received acknowledge message for checkpoint {} from task {} of job {} at {}.",
                    checkpointId,
                    message.getTaskExecutionId(),
                    message.getJob(),
                    taskManagerLocationInfo);

            if (checkpoint.isFullyAcknowledged()) {
                completePendingCheckpoint(checkpoint);
            }

Шаг 7. После записи завершённого чекпоинта в хранилище координатор рассылает уведомление.

apache/flink@release-2.2.1 · CheckpointCoordinator.java, строки 1434-1444
if (!props.isSavepoint() || props.isSynchronous()) {
    // drop those pending checkpoints that are at prior to the completed one
    dropSubsumedCheckpoints(checkpointId);

    // send the "notify complete" call to all vertices, coordinators, etc.
    sendAcknowledgeMessages(
            pendingCheckpoint.getCheckpointPlan().getTasksToCommitTo(),
            checkpointId,
            completedCheckpoint.getTimestamp(),
            extractIdIfDiscardedOnSubsumed(lastSubsumed));
}

В исходниках

Что делает с этим уведомлением источник Kafka. В snapshotState смещения кладутся в состояние чекпоинта и в очередь на коммит. В notifyCheckpointComplete они коммитятся в группу потребителей.

apache/flink-connector-kafka@v5.0.0 · KafkaSourceReader.java, строки 125-152
@Override
public void notifyCheckpointComplete(long checkpointId) throws Exception {
    LOG.debug("Committing offsets for checkpoint {}", checkpointId);
    if (!commitOffsetsOnCheckpoint) {
        return;
    }

    Map<TopicPartition, OffsetAndMetadata> committedPartitions =
            offsetsToCommit.get(checkpointId);
    if (committedPartitions == null) {
        LOG.debug("Offsets for checkpoint {} have already been committed.", checkpointId);
        return;
    }

    if (committedPartitions.isEmpty()) {
        LOG.debug("There are no offsets to commit for checkpoint {}.", checkpointId);
        removeAllOffsetsToCommitUpToCheckpoint(checkpointId);
        return;
    }

    ((KafkaSourceFetcherManager) splitFetcherManager)
            .commitOffsets(
                    committedPartitions,
                    (ignored, e) -> {
                        // The offset commit here is needed by the external monitoring. It won't
                        // break Flink job's correctness if we fail to commit the offset here.
                        if (e != null) {
                            kafkaSourceReaderMetrics.recordFailedCommit();

Комментарий в коде говорит прямо. Коммит нужен внешнему мониторингу, и его сбой корректность задания не ломает. При восстановлении Flink берёт смещения из чекпоинта и в группу Kafka не смотрит. Из этого следует, что лаг группы в Kafka может отставать от реального положения задания на интервал чекпоинта.

Что лежит в чекпоинте

Каталог чекпоинтов задаёт execution.checkpointing.dir. Внутри для каждого задания своя папка с именем jobId, а в ней три вида содержимого.

В исходниках

Имена фиксированы в коде хранилища.

apache/flink@release-2.2.1 · AbstractFsCheckpointStorageAccess.java, строки 73-86
/** The prefix of the directory containing the data exclusive to a checkpoint. */
public static final String CHECKPOINT_DIR_PREFIX = "chk-";

/** The name of the directory for shared checkpoint state. */
public static final String CHECKPOINT_SHARED_STATE_DIR = "shared";

/**
 * The name of the directory for state not owned/released by the master, but by the
 * TaskManagers.
 */
public static final String CHECKPOINT_TASK_OWNED_STATE_DIR = "taskowned";

/** The name of the metadata files in checkpoints / savepoints. */
public static final String METADATA_FILE_NAME = "_metadata";

В chk-N лежат данные, которые принадлежат только этому чекпоинту, и файл _metadata. Метаданные пишет JobManager, в них состояния всех операторов по их идентификаторам со ссылками на файлы. Без _metadata папка бесполезна. В shared лежат файлы, которые несколько чекпоинтов используют совместно, это файлы инкрементальных снимков RocksDB. В taskowned лежит то, чем распоряжаются сами TaskManager.

После нашего прогона в data/checkpoints/959e9026583b695ae1f4a2b0195cd313 лежали chk-1, chk-2, chk-3, shared со 178 файлами и taskowned. Состояние в _metadata привязано к идентификаторам операторов, поэтому в уроке 2 мы так настойчиво давали каждому оператору uid.

Где живёт состояние между чекпоинтами

State backend решает, где хранится рабочее состояние и как из него делается снимок. Во Flink 2.2 их три, ключ state.backend.type принимает hashmap, rocksdb и forst.

BackendГде живёт состояниеСнимокКогда брать
hashmapОбъекты в куче JVMПолный, каждый раз всё состояниеСостояние помещается в кучу, важна скорость доступа
rocksdbВстроенная RocksDB на локальном диске TaskManager, данные сериализованыПолный или инкрементальныйСостояние больше кучи, нужны быстрые чекпоинты большого состояния
forstForSt, ответвление RocksDB, хранит SST-файлы на удалённой файловой системеЛёгкий, файлы уже лежат в удалённом хранилищеЭксперименты. Документация прямо пишет, что он экспериментальный и для прода готов не полностью

RocksDB хранит данные в неизменяемых SST-файлах (Sorted String Table). Новый файл появляется при сбросе памяти на диск и при слиянии (compaction), старые файлы не переписываются. На этом и держится инкрементальный чекпоинт. Достаточно выгрузить файлы, которых ещё нет в хранилище, и сослаться на уже выгруженные.

В исходниках

Разбор файлов локальной копии RocksDB перед выгрузкой. Если SST-файл с тем же именем уже был выгружен прошлым подтверждённым чекпоинтом, в список попадает ссылка на старый файл. Остальные файлы идут на выгрузку.

apache/flink@release-2.2.1 · RocksIncrementalSnapshotStrategy.java, строки 413-433
private void createUploadFilePaths(
        Path[] files,
        List<HandleAndLocalPath> sstFiles,
        List<Path> sstFilePaths,
        List<Path> miscFilePaths) {
    for (Path filePath : files) {
        final String fileName = filePath.getFileName().toString();

        if (fileName.endsWith(SST_FILE_SUFFIX)) {
            Optional<StreamStateHandle> uploaded = previousSnapshot.getUploaded(fileName);
            if (uploaded.isPresent()
                    && checkpointStreamFactory.couldReuseStateHandle(uploaded.get())) {
                sstFiles.add(HandleAndLocalPath.of(uploaded.get(), fileName));
            } else {
                sstFilePaths.add(filePath); // re-upload
            }
        } else {
            miscFilePaths.add(filePath);
        }
    }
}

Ссылки на общие файлы попадают в SharedStateRegistry координатора, который мы видели выше. По документации история инкрементальных чекпоинтов не растёт бесконечно, слияния RocksDB со временем заменяют старые файлы, и старые чекпоинты вычищаются автоматически. Документация предупреждает о побочном эффекте. С инкрементальными чекпоинтами поле Checkpointed Data Size в Web UI показывает размер приращения. Полный размер состояния ищи в соседнем поле.

Восстановление и стратегии перезапуска

Когда подзадача падает, планировщик спрашивает стратегию перезапуска, можно ли перезапускать и через сколько. Потом координатор берёт последний завершённый чекпоинт, раздаёт состояния операторам по uid, и источники продолжают читать с сохранённых смещений.

В исходниках

Восстановление в координаторе. Берётся последний завершённый чекпоинт из хранилища, факт восстановления попадает в статистику, и дальше состояния распределяются по задачам.

apache/flink@release-2.2.1 · CheckpointCoordinator.java, строки 1774-1803
CompletedCheckpoint latest = completedCheckpointStore.getLatestCheckpoint();

if (latest == null) {
    LOG.info("No checkpoint found during restore.");

    if (errorIfNoCheckpoint) {
        throw new IllegalStateException("No completed checkpoint available");
    }

    LOG.debug("Resetting the master hooks.");
    MasterHooks.reset(masterHooks.values(), LOG);

    if (operatorCoordinatorRestoreBehavior
            == OperatorCoordinatorRestoreBehavior.RESTORE_OR_RESET) {
        // we let the JobManager-side components know that there was a recovery,
        // even if there was no checkpoint to recover from, yet
        LOG.info("Resetting the Operator Coordinators to an empty state.");
        restoreStateToCoordinators(
                OperatorCoordinator.NO_CHECKPOINT, Collections.emptyMap());
    }

    return OptionalLong.empty();
}

statsTracker.reportRestoredCheckpoint(
        latest.getCheckpointID(),
        latest.getProperties(),
        latest.getExternalPointer(),
        latest.getStateSize());

Вызов reportRestoredCheckpoint увеличивает счётчик restored в REST /jobs/<id>/checkpoints. По нему скрипт из практики поймёт, что задание действительно поднялось из чекпоинта.

Стратегию мы выбираем exponential-delay. При частых сбоях она увеличивает паузу между попытками и тем самым бережёт Kafka и базу от лавины переподключений. При редких сбоях задание поднимается за секунду. Документация называет её стратегией по умолчанию при включённых чекпоинтах, но мы задаём её явно, чтобы настройки были видны в Compose.

В исходниках

Решение о перезапуске. Сбой, который пришёл, пока прошлый перезапуск ещё не начался, сливается с ним. Если после прошлого перезапуска прошло больше reset-backoff-threshold, счётчик попыток обнуляется.

apache/flink@release-2.2.1 · ExponentialDelayRestartBackoffTimeStrategy.java, строки 111-126
@Override
public boolean notifyFailure(Throwable cause) {
    long now = clock.absoluteTimeMillis();

    // Merge multiple failures into one attempt if there are tasks will be restarted later.
    if (now <= nextRestartTimestamp) {
        return false;
    }

    if ((now - nextRestartTimestamp) >= resetBackoffThresholdMS) {
        setInitialBackoff();
    }
    nextRestartTimestamp = now + calculateActualBackoffTime();
    currentRestartAttempt++;
    return true;
}

Сама пауза. Начальная задержка умножается на множитель в степени номера попытки, к ней прибавляется случайный сдвиг jitter, результат обрезается сверху max-backoff и снизу начальной задержкой.

apache/flink@release-2.2.1 · ExponentialDelayRestartBackoffTimeStrategy.java, строки 183-191
private long calculateActualBackoffTime() {
    long currentBackoffTime =
            (long) (initialBackoffMS * Math.pow(backoffMultiplier, currentRestartAttempt));
    return Math.max(
            initialBackoffMS,
            Math.min(
                    currentBackoffTime + calculateJitterBackoffMS(currentBackoffTime),
                    maxBackoffMS));
}

С нашими настройками паузы такие. Первая попытка ждёт от 1 до 1,1 с, потому что нижнюю границу задаёт начальная задержка. Дальше около 2, 4, 8, 16 и 32 с с разбросом 10 %, а начиная с седьмой попытки 60 с. Десять минут без сбоев возвращают паузу к одной секунде. Ограничение числа попыток attempts-before-reset-backoff по умолчанию равно Integer.MAX_VALUE, то есть задание пытается подняться бесконечно.

Сравнение со Spark

В Spark Structured Streaming согласованная точка получается сама собой из устройства micro-batch. Перед запуском порции драйвер пишет в checkpointLocation журнал смещений, после неё журнал коммитов, а хранилище состояния сохраняет новую версию на каждую порцию. Барьеры там не нужны, потому что все задачи порции заканчиваются до начала следующей.

У Flink операторы работают непрерывно, и естественной границы нет. Её создаёт барьер, который едет вместе с данными. Чекпоинт снимается раз в интервал, обычно десятки секунд, и между чекпоинтами записи идут без задержки. Каталог чекпоинтов Flink по смыслу похож на checkpointLocation, но привязан к jobId. Новое задание с ним само не свяжется, для этого есть запуск из savepoint или чекпоинта, о нём урок 13.

⚠
Чекпоинт без uid не переживёт изменение кода

В _metadata состояние привязано к идентификатору оператора. Если uid не задан, Flink выводит его из структуры графа, и любая новая вершина выше по графу его поменяет. Перезапуск после сбоя этого не заметит, граф тот же. Обновление кода с восстановлением из чекпоинта потеряет состояние или откажется стартовать.

Проверь себя

Вершину обогащения замедлил внешний сервис, Web UI показывает backpressure, и чекпоинты стали идти по минуте вместо 300 мс. Состояние при этом не выросло. Что поможет чекпоинтам?

Чекпоинт 41 завершился, и сразу после этого TaskManager упал. Коммит смещений в Kafka за чекпоинт 41 не успел пройти. Что будет после восстановления?

Коллега видит в Web UI, что каждый чекпоинт пишет около 4,8 МиБ, и делает вывод, что состояние задания весит 4,8 МиБ. Что ты ему ответишь?

Что будет в конце

Кластер хранит состояние в RocksDB и каждые 30 секунд делает инкрементальный чекпоинт в data/checkpoints. После падения задание перезапускается по экспоненциальной задержке и продолжает с последнего чекпоинта. Скрипт chaos-kill-tm.sh убивает TaskManager и меряет время восстановления.

Скрипт отчёта scripts/checkpoint-report.sh относится к заданию, его пока не создавай.

Шаг 1. Чекпоинты и перезапуск в Compose

Код задания для чекпоинтов почти не меняется. Всё включается настройками кластера в FLINK_PROPERTIES, и ещё TaskManager получает политику перезапуска контейнера.

что изменилось в docker-compose.yml
@@ -9,6 +9,22 @@       parallelism.default: 2
       jobmanager.memory.process.size: 1600m
       taskmanager.memory.process.size: 2048m
+      state.backend.type: rocksdb+      execution.checkpointing.dir: file:///flink-data/checkpoints+      execution.checkpointing.savepoint-dir: file:///flink-data/savepoints+      execution.checkpointing.incremental: true+      execution.checkpointing.interval: 30s+      execution.checkpointing.min-pause: 10s+      execution.checkpointing.timeout: 5min+      execution.checkpointing.tolerable-failed-checkpoints: 3+      execution.checkpointing.externalized-checkpoint-retention: RETAIN_ON_CANCELLATION+      execution.checkpointing.num-retained: 3+      restart-strategy.type: exponential-delay+      restart-strategy.exponential-delay.initial-backoff: 1s+      restart-strategy.exponential-delay.max-backoff: 1min+      restart-strategy.exponential-delay.backoff-multiplier: 2.0+      restart-strategy.exponential-delay.reset-backoff-threshold: 10min+      restart-strategy.exponential-delay.jitter-factor: 0.1     PAYGUARD_DB_URL: jdbc:postgresql://postgres:5432/payguard
     PAYGUARD_DB_USER: payguard
     PAYGUARD_DB_PASSWORD: payguard
@@ -78,5 +94,6 @@   taskmanager:
     <<: *flink-common
     command: taskmanager
+    restart: unless-stopped     depends_on:
       - jobmanager
КОД
      state.backend.type: rocksdb
      execution.checkpointing.dir: file:///flink-data/checkpoints
      execution.checkpointing.savepoint-dir: file:///flink-data/savepoints
      execution.checkpointing.incremental: true
      execution.checkpointing.interval: 30s
      execution.checkpointing.min-pause: 10s
      execution.checkpointing.timeout: 5min
      execution.checkpointing.tolerable-failed-checkpoints: 3
      execution.checkpointing.externalized-checkpoint-retention: RETAIN_ON_CANCELLATION
      execution.checkpointing.num-retained: 3
      restart-strategy.type: exponential-delay
      restart-strategy.exponential-delay.initial-backoff: 1s
      restart-strategy.exponential-delay.max-backoff: 1min
      restart-strategy.exponential-delay.backoff-multiplier: 2.0
      restart-strategy.exponential-delay.reset-backoff-threshold: 10min
      restart-strategy.exponential-delay.jitter-factor: 0.1
ПРОСТЫМИ СЛОВАМИ

Состояние живёт в RocksDB на диске TaskManager. Куча освобождается, и снимок можно делать инкрементально.

Чекпоинты и savepoint пишутся в /flink-data. Эта папка смонтирована из ./data проекта ещё в прошлых уроках, поэтому снимки переживут контейнер. В проде здесь будет S3 или HDFS, общая для JobManager и всех TaskManager.

Инкрементальные чекпоинты включены. Без этой строки RocksDB каждый раз выгружала бы всё состояние.

Интервал 30 секунд. Ключ без значения по умолчанию, и пока его нет, чекпоинтов нет вовсе. Пауза 10 секунд гарантирует, что между концом одного чекпоинта и началом следующего задание работает спокойно.

Чекпоинт, который не уложился в 5 минут, отменяется. Три неудачи подряд по таймауту или в асинхронной фазе задание терпит, четвёртая вызывает перезапуск. По умолчанию допуск ноль. Сбой синхронной фазы на TaskManager перезапускает задачу всегда.

RETAIN_ON_CANCELLATION оставляет чекпоинты на диске после отмены задания, из них можно стартовать вручную. Хранятся три последних, по умолчанию один.

Стратегия перезапуска с экспоненциальной паузой. Первая пауза 1 секунда, каждая следующая вдвое длиннее, но не больше минуты.

Десять минут без сбоев сбрасывают паузу к начальной. Jitter 0,1 сдвигает каждую паузу случайно на 10 %, чтобы десяток заданий после общей аварии не ломились в Kafka одновременно.

Строка restart: unless-stopped у TaskManager относится к Docker. Если процесс TaskManager завершится сам, например от нехватки памяти, Docker поднимет контейнер снова, и в кластере опять появятся слоты. Без неё кластер остался бы без слотов, и перезапуски Flink ни к чему бы не привели.

Шаг 2. С какого места читать Kafka

Источники платежей и подтверждений раньше начинали с latest. При восстановлении из чекпоинта это неважно, но при новой отправке задания без чекпоинта latest пропустит всё, что накопилось в топике за время простоя.

что изменилось в src/main/java/dev/study/payguard/job/PayGuardJob.java
@@ -39,6 +39,7 @@ import org.apache.flink.streaming.api.datastream.KeyedStream;
 import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
 import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.kafka.clients.consumer.OffsetResetStrategy; 
 /** Точка входа Flink-задания PayGuard. */
 public final class PayGuardJob {
@@ -179,7 +180,7 @@                 .setBootstrapServers(config.bootstrapServers())
                 .setTopics(config.rawTopic())
                 .setGroupId(config.groupId())
-                .setStartingOffsets(OffsetsInitializer.latest())+                .setStartingOffsets(OffsetsInitializer.committedOffsets(OffsetResetStrategy.LATEST))                 .setValueOnlyDeserializer(new ParsedRecordSchema())
                 .build();
     }
@@ -199,7 +200,7 @@                 .setBootstrapServers(config.bootstrapServers())
                 .setTopics(config.confirmationsTopic())
                 .setGroupId(config.groupId() + "-confirmations")
-                .setStartingOffsets(OffsetsInitializer.latest())+                .setStartingOffsets(OffsetsInitializer.committedOffsets(OffsetResetStrategy.LATEST))                 .setValueOnlyDeserializer(new LenientJsonSchema<>(PaymentConfirmation.class))
                 .build();
     }

committedOffsets(OffsetResetStrategy.LATEST) начинает с последнего закоммиченного смещения группы groupId. Если группа партицию ещё не читала, срабатывает запасная стратегия LATEST. Источник правил оставлен на earliest, ему нужна вся история правил. Класс OffsetResetStrategy приходит из клиента Kafka, новой зависимости не нужно.

В исходниках

Почему инициализатор смещений не мешает восстановлению. Перечислитель партиций восстанавливает из чекпоинта уже назначенные партиции и при поиске изменений вычитает их из найденных. Инициализатор применяется только к оставшимся новым партициям.

apache/flink-connector-kafka@v5.0.0 · KafkaSourceEnumerator.java, строки 520-536
@VisibleForTesting
PartitionChange getPartitionChange(
        Set<TopicPartition> fetchedPartitions, boolean initialDiscovery) {
    final Set<TopicPartition> removedPartitions = new HashSet<>();
    Set<TopicPartition> newPartitions = new HashSet<>(fetchedPartitions);
    Consumer<TopicPartition> dedupOrMarkAsRemoved =
            (tp) -> {
                if (!newPartitions.remove(tp)) {
                    removedPartitions.add(tp);
                }
            };

    assignedSplits.keySet().forEach(dedupOrMarkAsRemoved);
    pendingPartitionSplitAssignment.forEach(
            (reader, splits) ->
                    splits.forEach(
                            split -> dedupOrMarkAsRemoved.accept(split.getTopicPartition())));

Этот же вызов всплывёт в уроке 14. Если положить SQL-коннектор Kafka в lib/ того же кластера, flink run упадёт с java.lang.NoSuchMethodError: ...OffsetsInitializer.committedOffsets(org.apache.kafka.clients.consumer.OffsetResetStrategy), потому что в SQL-jar клиент Kafka перепакован в другой пакет.

Шаг 3. Собери, подними и отправь

терминал
./gradlew shadowJar
docker compose up -d --wait
scripts/submit.sh
./gradlew loadgen --args="--rate 200 --dup 0.02 --bad 0.01 --burst 0.002"

Порядок прежний, сначала jar, потом контейнеры. Если кластер уже работает с конфигурацией прошлого урока, docker compose up -d --wait пересоздаст JobManager и TaskManager, потому что изменилось их окружение. Генератор даёт 200 платежей в секунду с 2 % повторов, 1 % битых записей и сериями по картам.

вывод
Job has been submitted with JobID 959e9026583b695ae1f4a2b0195cd313

Шаг 4. Прочитай статистику чекпоинтов

Через полторы минуты пройдут три чекпоинта. Их статистику отдаёт REST JobManager.

терминал
JOB=$(curl -s localhost:8081/jobs | jq -r '.jobs[] | select(.status == "RUNNING") | .id')
curl -s localhost:8081/jobs/$JOB/checkpoints | jq '.counts, .history[0]'

В ответе counts содержит счётчики, а history последние чекпоинты от нового к старому. У каждого есть end_to_end_duration в миллисекундах, checkpointed_size и state_size в байтах. Ниже сводка с нашего стенда, размеры пересчитаны в КиБ.

сводка чекпоинтов, прогон стенда
{"counts":{"restored":0,"total":3,"in_progress":0,"completed":3,"failed":0},"recent":[{"id":3,"status":"COMPLETED","ms":390,"state_kb":12251,"written_kb":4825},{"id":2,"status":"COMPLETED","ms":318,"state_kb":8011,"written_kb":4831},{"id":1,"status":"COMPLETED","ms":328,"state_kb":3756,"written_kb":3756}]}

У первого чекпоинта записано столько же, сколько весит состояние. Предыдущего снимка нет, и все SST-файлы выгружаются впервые. У второго и третьего состояние выросло до 8011 и 12251 КиБ, а записано около 4,8 МиБ. Это и есть инкрементальность, остальные файлы взяты ссылками из shared. В другом прогоне те же два первых чекпоинта заняли 259 и 282 мс, второй записал 4591 КиБ при состоянии 5899 КиБ. Все чекпоинты укладываются в треть секунды при интервале 30 секунд.

Загляни в каталог на своей машине. В data/checkpoints/<jobId> появятся chk-N с файлом _metadata, shared и taskowned. Завершённых chk-N хранится не больше трёх, лишние удаляются по num-retained.

Шаг 5. Убей TaskManager

Положи в scripts/chaos-kill-tm.sh скрипт, который проверяет восстановление.

scripts/chaos-kill-tm.sh
#!/usr/bin/env bash
# Убивает TaskManager без предупреждения, ждёт восстановления задания и печатает, сколько оно заняло.
set -euo pipefail
REST="${FLINK_REST:-http://localhost:8081}"
TIMEOUT_SECONDS=180

job_id=$(curl -s "$REST/jobs" | jq -r '[.jobs[] | select(.status == "RUNNING")][0].id')
if [ "$job_id" = "null" ]; then
  echo "Нет запущенных заданий" >&2
  exit 1
fi
restored_before=$(curl -s "$REST/jobs/$job_id/checkpoints" | jq '.counts.restored')

echo "Убиваю TaskManager"
docker compose kill -s SIGKILL taskmanager
started=$(date +%s)
sleep 2
docker compose up -d taskmanager

while true; do
  restored=$(curl -s "$REST/jobs/$job_id/checkpoints" | jq '.counts.restored')
  running=$(curl -s "$REST/jobs/$job_id" | jq '[.vertices[] | select(.status == "RUNNING")] | length')
  total=$(curl -s "$REST/jobs/$job_id" | jq '.vertices | length')
  if [ "$restored" -gt "$restored_before" ] && [ "$running" -eq "$total" ]; then
    echo "Задание восстановлено из чекпоинта за $(( $(date +%s) - started )) с"
    exit 0
  fi
  if [ $(( $(date +%s) - started )) -gt "$TIMEOUT_SECONDS" ]; then
    echo "Задание не восстановилось за $TIMEOUT_SECONDS с" >&2
    exit 1
  fi
  sleep 2
done
КОД
job_id=$(curl -s "$REST/jobs" | jq -r '[.jobs[] | select(.status == "RUNNING")][0].id')
if [ "$job_id" = "null" ]; then
  echo "Нет запущенных заданий" >&2
  exit 1
fi
restored_before=$(curl -s "$REST/jobs/$job_id/checkpoints" | jq '.counts.restored')

echo "Убиваю TaskManager"
docker compose kill -s SIGKILL taskmanager
started=$(date +%s)
sleep 2
docker compose up -d taskmanager

while true; do
  restored=$(curl -s "$REST/jobs/$job_id/checkpoints" | jq '.counts.restored')
  running=$(curl -s "$REST/jobs/$job_id" | jq '[.vertices[] | select(.status == "RUNNING")] | length')
  total=$(curl -s "$REST/jobs/$job_id" | jq '.vertices | length')
  if [ "$restored" -gt "$restored_before" ] && [ "$running" -eq "$total" ]; then
    echo "Задание восстановлено из чекпоинта за $(( $(date +%s) - started )) с"
    exit 0
  fi
ПРОСТЫМИ СЛОВАМИ

Находим запущенное задание. Если его нет, выходим с ошибкой, ломать нечего.

Запоминаем счётчик restored до аварии. Новое восстановление его увеличит, и по этому признаку мы отличим восстановление из чекпоинта от простого перезапуска.

SIGKILL убивает процесс мгновенно, без закрытия соединений и без последнего чекпоинта. Так выглядит падение машины или убийство по памяти.

Через две секунды поднимаем контейнер снова. Явная команда не зависит от политики перезапуска Docker.

Каждые две секунды опрашиваем REST. Готово, когда счётчик восстановлений вырос и все вершины снова в RUNNING.

Если за три минуты задание не поднялось, скрипт завершается с ошибкой. Это пригодится, когда скрипт попадёт в автоматическую проверку.

терминал
chmod +x scripts/chaos-kill-tm.sh
scripts/chaos-kill-tm.sh

На нашем стенде при 200 платежах в секунду скрипт сообщил о восстановлении за 23 секунды. В это время входят пауза перед стартом контейнера, регистрация нового TaskManager, раскладка 13 вершин по слотам и чтение состояния из чекпоинта. Чекпоинт, который шёл в момент убийства, получил статус FAILED. Это ожидаемо, его подзадачи умерли до подтверждения, и задание откатилось к предыдущему завершённому.

Проверь, что состояние действительно восстановилось. Повтори запрос из шага 4, счётчик restored станет равен 1. Платежи, обработанные после последнего завершённого чекпоинта, прочитаются из Kafka повторно. Состояние учтёт их один раз, ведь оно откатилось к моменту барьера. Алерты в fraud.alerts за этот промежуток могут прийти дважды, это задача урока 12.

Задание. Отчёт по чекпоинтам

Каждый раз писать запрос к REST неудобно. Напиши скрипт scripts/checkpoint-report.sh, который печатает сводку чекпоинтов запущенного задания в одну строку на чекпоинт.

  1. Адрес REST берётся из переменной FLINK_REST, по умолчанию http://localhost:8081, как в остальных скриптах проекта.
  2. Скрипт сам находит первое задание в статусе RUNNING. Если такого нет, печатает сообщение в stderr и завершается с кодом 1.
  3. Первая строка показывает число завершённых, неудачных чекпоинтов и восстановлений.
  4. Дальше идут не больше пяти последних чекпоинтов. Для каждого номер, статус, длительность в миллисекундах, сколько записано и полный размер состояния в КиБ.

Готово, если на работающем стенде скрипт печатает строки вида «номер, статус, длительность, записано, состояние», а после chaos-kill-tm.sh в первой строке видно одно восстановление и в списке есть чекпоинт со статусом FAILED.

Подсказка первая. Все нужные поля есть в ответе /jobs/<id>/checkpoints, в частях counts и history. Открой его через jq . и найди имена полей.

Подсказка вторая. В jq строку собирают интерполяцией "\(.id)", срез массива пишется .[:5], а округление вниз делает floor.

Ответ

Глоссарий урока

Чекпоинт (checkpoint)
Согласованный снимок состояния всех операторов и позиций чтения источников. Flink делает его автоматически по интервалу и использует для восстановления после сбоя.
Барьер чекпоинта (checkpoint barrier)
Служебное событие с номером чекпоинта, которое источник вставляет в поток. Едет вместе с записями и делит поток на часть до чекпоинта и часть после.
Координатор чекпоинтов (CheckpointCoordinator)
Компонент JobManager, который запускает чекпоинты, собирает подтверждения подзадач, хранит завершённые чекпоинты и восстанавливает из них задание.
Выравнивание (alignment)
Блокировка входного канала, по которому барьер уже пришёл, до прихода барьеров по остальным каналам. Нужно для exactly-once в состоянии.
Unaligned-чекпоинт
Режим, в котором барьер обгоняет данные в сетевых буферах, а обогнанные буферы сохраняются в чекпоинт. Чекпоинты при backpressure становятся быстрее, зато больше по объёму.
Алгоритм Chandy-Lamport
Классический алгоритм распределённого снимка через маркеры в каналах. Подход Flink основан на нём и приспособлен к потоковым графам.
Exactly-once
Гарантия, что каждая запись отразится в состоянии ровно один раз даже при сбоях. Для внешних систем требует отдельной поддержки в приёмнике.
At-least-once
Гарантия, что ни одна запись не потеряется, но после сбоя часть записей может быть учтена повторно.
State backend
Способ хранения рабочего состояния и снятия снимка. Во Flink 2.2 это hashmap, rocksdb и экспериментальный forst.
RocksDB
Встраиваемое хранилище ключ-значение на диске. Flink кладёт его внутрь TaskManager и хранит там состояние в сериализованном виде.
ForSt
Ответвление RocksDB для Flink, которое хранит файлы состояния на удалённой файловой системе. Во Flink 2.2 экспериментальное.
SST-файл (Sorted String Table)
Неизменяемый отсортированный файл данных RocksDB. Неизменяемость позволяет выгружать в чекпоинт только новые файлы.
Инкрементальный чекпоинт
Чекпоинт, который выгружает только файлы, появившиеся после прошлого подтверждённого чекпоинта, и ссылается на уже выгруженные.
Файл _metadata
Файл в папке chk-N, который пишет JobManager. Содержит состояния операторов по uid и ссылки на файлы с данными.
Стратегия перезапуска (restart strategy)
Правило, по которому Flink решает, перезапускать ли задание после сбоя и через какую паузу.
Exponential-delay
Стратегия перезапуска, у которой пауза растёт в заданное число раз с каждой попыткой до верхнего предела и сбрасывается после долгой работы без сбоев.
Jitter
Случайный сдвиг паузы перезапуска. Не даёт многим заданиям после общей аварии перезапускаться одновременно.
Смещение (offset)
Номер записи в партиции Kafka. Источник Flink хранит смещения в чекпоинте и после восстановления продолжает с них.
Группа потребителей (consumer group)
Имя, под которым Kafka запоминает закоммиченные смещения читателя. Flink коммитит в неё смещения после завершения чекпоинта для мониторинга.
Backpressure
Состояние, когда нижняя вершина не успевает и верхние ждут свободных буферов. Замедляет и выровненные чекпоинты.
Savepoint
Снимок, который делают вручную для обновления или переноса задания. Всегда выровненный. Подробно в уроке 13.
checkpointLocation в Spark
Каталог Spark Structured Streaming с журналом смещений, журналом коммитов и версиями хранилища состояния по каждой порции.

Урок 12 · Отказоустойчивость

Exactly-once до конца, от чекпоинта до потребителя Kafka

После урока ты сможешь включить транзакционную запись алертов в Kafka, объяснить, когда потребитель увидит повтор, и выбрать между транзакциями и идемпотентной записью.

Алерт, который пришёл дважды

В уроке 11 мы убили TaskManager, и задание продолжило работу с последнего чекпоинта. Состояние откатилось к барьеру, платежи после него прочитались из Kafka повторно, и счётчики учли их один раз. С алертами вышло хуже. Приёмник успел отправить их в fraud.alerts до падения. После восстановления те же платежи дали те же алерты, и они ушли в топик ещё раз.

Для антифрода повтор дорог. Сервис, который читает fraud.alerts, звонит клиенту или блокирует карту. Два одинаковых алерта означают два звонка, и через месяц операторы начнут пропускать дубли вместе с настоящими сигналами. В этом уроке гарантия Flink дотягивается от состояния до внешней системы.

Три уровня гарантий

Гарантию доставки задаёт приёмник. У приёмника Kafka их три, и описание класса KafkaSink перечисляет, что каждая обещает.

Режим приёмникаЧто увидит потребитель после сбояЦена
NONEЗаписи могут потеряться при проблемах брокера и повториться при перезапуске FlinkНичего не ждёт
AT_LEAST_ONCEНичего не теряется, повторы возможныНа чекпоинте ждёт подтверждения всех отправленных записей
EXACTLY_ONCEНи потерь, ни повторов, если потребитель читает только зафиксированные данныеЗаписи видны только после чекпоинта

Exactly-once в уроке 11 касался состояния. Документация Flink уточняет, что повторная обработка событий при этом остаётся, просто каждое событие влияет на состояние ровно один раз. Чтобы гарантия дошла до внешней системы, нужны два условия. Источник должен уметь перечитать данные, и Kafka это умеет. Приёмник должен быть транзакционным или идемпотентным, то есть повторная запись не должна менять результат.

Spark Structured Streaming тоже держится на воспроизводимом источнике и идемпотентном приёмнике. Его документация прямо пишет, что запись в Kafka там только at-least-once и повторы при перезапуске он предотвратить не может. Flink пишет в Kafka транзакциями, потому что его приёмник участвует в чекпоинте.

Вёрстка номера и подпись в печать

Представь редакцию ежедневной газеты. Весь день журналисты сдают заметки, и верстальщик ставит их на полосы будущего номера. Читатели этих полос не видят. Вечером главный редактор проверяет, что номер собран целиком, и подписывает его в печать. Только тогда типография печатает тираж, и заметки попадают в киоски.

Если ночью в редакции отключат свет до подписи, вёрстку выбросят. Утром журналисты восстановят заметки по черновикам и сдадут их в следующий номер. Читатель из киоска увидит каждую заметку один раз. Корректор, который ходит в типографию и читает сырые полосы, увидит и выброшенную вёрстку, и новую, так что часть заметок прочитает дважды. Есть и третий исход. Если типография ждёт подписи слишком долго, она сама разбирает вёрстку, и подписывать становится нечего.

В PayGuard заметки это алерты, вёрстка это открытая транзакция Kafka, подпись редактора это завершённый чекпоинт. Читатель из киоска это потребитель с isolation.level=read_committed, корректор читает с read_uncommitted. Срок, после которого брокер сам прерывает транзакцию, задаёт transaction.timeout.ms.

Двухфазная фиксация в приёмнике Flink

Транзакционный приёмник Flink состоит из двух операторов. SinkWriter пишет записи во внешнюю систему так, чтобы их ещё никто не видел. Committer делает их видимыми. Между ними ходят committable, короткие описания подготовленных данных. Для Kafka это имя транзакции, producerId и эпоха продюсера.

Первая фаза называется предкоммитом. Когда к Writer подходит барьер чекпоинта N, Writer сбрасывает буферы продюсера, помечает транзакцию подготовленной и отправляет её committable в Committer. Committer кладёт committable в своё состояние, и оно попадает в снимок N.

Вторая фаза наступает после того, как JobManager собрал подтверждения от всех подзадач и объявил чекпоинт N завершённым. Каждый оператор получает вызов notifyCheckpointComplete, и Committer фиксирует транзакции до N включительно.

Порядок фаз решает всё. Упадёт задание между фазами, и committable уже лежит в завершённом чекпоинте, поэтому после восстановления Committer зафиксирует транзакцию сам. Упадёт до завершения чекпоинта, и восстановление пойдёт из N-1. Транзакция N тогда так и останется незафиксированной, Writer её прервёт, а записи появятся заново из перечитанных платежей.

В исходниках

Интерфейс SupportsCommitter описывает это разделение ролей. Writer делает предкоммит и создаёт committable на чекпоинте, Committer фиксирует.

apache/flink@release-2.2.1 · SupportsCommitter.java, строки 27-41
/**
 * A mixin interface for a {@link Sink} which supports exactly-once semantics using a two-phase
 * commit protocol. The {@link Sink} consists of a {@link CommittingSinkWriter} that performs the
 * precommits and a {@link Committer} that actually commits the data. To facilitate the separation
 * the {@link CommittingSinkWriter} creates <i>committables</i> on checkpoint or end of input and
 * the sends it to the {@link Committer}.
 *
 * <p>The {@link Sink} needs to be serializable. All configuration should be validated eagerly. The
 * respective sink writers and committers are transient and will only be created in the subtasks on
 * the taskmanagers.
 *
 * @param <CommittableT> The type of the committables.
 */
@Public
public interface SupportsCommitter<CommittableT> {

Первая фаза. Оператор Writer вызывает flush и emitCommittables в prepareSnapshotPreBarrier, то есть до того, как барьер уйдёт дальше по графу. Committable успевает дойти до Committer раньше барьера.

apache/flink@release-2.2.1 · SinkWriterOperator.java, строки 187-195
@Override
public void prepareSnapshotPreBarrier(long checkpointId) throws Exception {
    super.prepareSnapshotPreBarrier(checkpointId);
    if (!endOfInput) {
        sinkWriter.flush(false);
        emitCommittables(checkpointId);
    }
    // no records are expected to emit after endOfInput
}

Вторая фаза и восстановление. При старте из чекпоинта Committer сразу фиксирует всё, что нашёл в своём состоянии. В обычной работе фиксация запускается из notifyCheckpointComplete, и берутся committable всех чекпоинтов до завершённого.

apache/flink@release-2.2.1 · CommitterOperator.java, строки 135-162
    if (checkpointId.isPresent()) {
        committableCollectorState.get().forEach(cc -> committableCollector.merge(cc));
        lastCompletedCheckpointId = checkpointId.getAsLong();
        // try to re-commit recovered transactions as quickly as possible
        commitAndEmitCheckpoints(lastCompletedCheckpointId);
    }
}

@Override
public void snapshotState(StateSnapshotContext context) throws Exception {
    super.snapshotState(context);
    // It is important to copy the collector to not mutate the state.
    committableCollectorState.update(Collections.singletonList(committableCollector.copy()));
}

@Override
public void endInput() throws Exception {
    if (!isCheckpointingEnabled || isBatchMode) {
        // There will be no final checkpoint, all committables should be committed here
        commitAndEmitCheckpoints(Long.MAX_VALUE);
    }
}

@Override
public void notifyCheckpointComplete(long checkpointId) throws Exception {
    super.notifyCheckpointComplete(checkpointId);
    commitAndEmitCheckpoints(Math.max(lastCompletedCheckpointId, checkpointId));
}

Из этого следует требование к любому Committer. Одна и та же транзакция может прийти на фиксацию дважды, и описание интерфейса требует, чтобы повторная фиксация ничего не меняла.

apache/flink@release-2.2.1 · Committer.java, строки 26-34
/**
 * The {@code Committer} is responsible for committing the data staged by the {@link
 * CommittingSinkWriter} in the second step of a two-phase commit protocol.
 *
 * <p>A commit must be idempotent: If some failure occurs in Flink during commit phase, Flink will
 * restart from previous checkpoint and re-attempt to commit all committables. Thus, some or all
 * committables may have already been committed. These {@link CommitRequest}s must not change the
 * external system and implementers are asked to signal {@link
 * CommitRequest#signalAlreadyCommitted()}.

Путь алерта через чекпоинт

Пройди по шагам, что происходит с одним алертом между двумя чекпоинтами. Обрати внимание, в какой момент его впервые увидит потребитель.

E
enrich-alerts
W
alerts-sink, Writer
K
Kafka, fraud.alerts
C
alerts-sink, Committer
J
JobManager
R
Потребитель read_committed
Нажми «Следующий шаг»

Что Writer делает на чекпоинте

У Writer приёмника Kafka в режиме EXACTLY_ONCE одна открытая транзакция на подзадачу в каждый момент. Чекпоинт закрывает её для записи и сразу открывает следующую, поэтому поток алертов не останавливается.

В исходниках

Класс ExactlyOnceKafkaWriter. Метод prepareCommit вызывается в первой фазе. Если в транзакции есть записи, он создаёт committable и помечает транзакцию. Пустую транзакцию он не фиксирует, продюсер возвращается в пул. Метод snapshotState сохраняет незавершённые транзакции в состояние Writer и открывает транзакцию под номер следующего чекпоинта.

apache/flink-connector-kafka@v5.0.0 · ExactlyOnceKafkaWriter.java, строки 220-248
@Override
public Collection<KafkaCommittable> prepareCommit() {
    // only return a KafkaCommittable if the current transaction has been written some data
    if (currentProducer.hasRecordsInTransaction()) {
        KafkaCommittable committable = KafkaCommittable.of(currentProducer);
        LOG.debug("Prepare {}.", committable);
        currentProducer.precommitTransaction();
        return Collections.singletonList(committable);
    }

    // otherwise, we recycle the producer (the pool will reset the transaction state)
    producerPool.recycle(currentProducer);
    return Collections.emptyList();
}

@Override
public List<KafkaWriterState> snapshotState(long checkpointId) throws IOException {
    // recycle committed producers
    TransactionFinished finishedTransaction;
    while ((finishedTransaction = backchannel.poll()) != null) {
        producerPool.recycleByTransactionId(
                finishedTransaction.getTransactionId(), finishedTransaction.isSuccess());
    }
    // persist the ongoing transactions into the state; these will not be aborted on restart
    Collection<CheckpointTransaction> ongoingTransactions =
            producerPool.getOngoingTransactions();
    currentProducer = startTransaction(checkpointId + 1);
    return createSnapshots(ongoingTransactions);
}

В графе задания у каждого приёмника Kafka есть пара Writer -> Committer, даже у dlq-sink в режиме AT_LEAST_ONCE. Дело в том, что KafkaSink всегда объявляет двухфазный протокол. Без EXACTLY_ONCE он отдаёт Committer, который ничего не делает.

apache/flink-connector-kafka@v5.0.0 · KafkaSink.java, строки 112-125
@Internal
@Override
public Committer<KafkaCommittable> createCommitter(CommitterInitContext context) {
    if (deliveryGuarantee == DeliveryGuarantee.EXACTLY_ONCE) {
        return new KafkaCommitter(
                kafkaProducerConfig,
                transactionalIdPrefix,
                context.getTaskInfo().getIndexOfThisSubtask(),
                context.getTaskInfo().getAttemptNumber(),
                transactionNamingStrategy == TransactionNamingStrategy.POOLING,
                FlinkKafkaInternalProducer::new);
    }
    return new NoopCommitter();
}

Транзакции Kafka и read_committed

Транзакция в Kafka принадлежит продюсеру с настройкой transactional.id. Брокер знает этого продюсера по паре producerId и эпоха. Когда новый продюсер с тем же transactional.id регистрируется вызовом initTransactions, брокер завершает транзакцию прежнего владельца, и старый продюсер больше ничего не зафиксирует. Это называется fencing. Он защищает от продюсера на «мёртвой» машине, который вдруг ожил и продолжил писать.

Потребитель сам выбирает, что читать. По умолчанию isolation.level=read_uncommitted, и он получает всё, включая записи прерванных транзакций. С read_committed он получает только зафиксированные транзакционные записи и не читает дальше LSO (last stable offset), смещения перед первой открытой транзакцией. Обычные записи без транзакций он получает в обоих режимах.

Отсюда два практических следствия. Транзакционный приёмник бесполезен, если команда, которая читает топик, не включила read_committed. И потребитель read_committed стоит на месте, пока в партиции висит чужая открытая транзакция.

В исходниках

Как Flink называет транзакции. Имя состоит из префикса, номера подзадачи и смещения, которое почти всегда равно номеру чекпоинта. Поэтому в логах ты увидишь имена вроде payguard-alerts-0-11.

apache/flink-connector-kafka@v5.0.0 · TransactionalIdFactory.java, строки 28-45
/**
 * Constructs a transactionalId with the following format {@code
 * transactionalIdPrefix-subtaskId-checkpointOffset}.
 *
 * @param transactionalIdPrefix prefix for the id
 * @param subtaskId describing the subtask which is opening the transaction
 * @param checkpointOffset an always incrementing number usually capturing the number of
 *     checkpoints taken by the subtask
 * @return transactionalId
 */
public static String buildTransactionalId(
        String transactionalIdPrefix, int subtaskId, long checkpointOffset) {
    return transactionalIdPrefix
            + TRANSACTIONAL_ID_DELIMITER
            + subtaskId
            + TRANSACTIONAL_ID_DELIMITER
            + checkpointOffset;
}

Стратегия по умолчанию INCREMENTING на каждый чекпоинт берёт новое имя. Описание перечисления предупреждает, что брокер держит метаданные каждого имени в памяти 7 дней. При интервале 15 секунд и двух подзадачах это до 80 тысяч имён за неделю на один приёмник.

apache/flink-connector-kafka@v5.0.0 · TransactionNamingStrategy.java, строки 26-45
/**
 * The strategy to name transactions. Naming strategy has implications on the resource consumption
 * on the broker because each unique transaction name requires the broker to keep some metadata in
 * memory for 7 days.
 *
 * <p>All naming strategies use the format {@code transactionalIdPrefix-subtask-offset} where offset
 * is calculated differently.
 */
@PublicEvolving
public enum TransactionNamingStrategy {
    /**
     * The offset of the transaction name is a monotonically increasing number that mostly
     * corresponds to the checkpoint id. This strategy is wasteful in terms of resource consumption
     * on the broker.
     *
     * <p>This is exactly the same behavior as in flink-connector-kafka 3.X.
     *
     * <p>Switching to this strategy from {@link #POOLING} is not supported.
     */
    INCREMENTING(TransactionNamingStrategyImpl.INCREMENTING, TransactionAbortStrategyImpl.PROBING),

Префикс обязан быть уникальным на весь кластер Kafka. Если тестовая копия задания запустится с тем же префиксом, её продюсеры будут вытеснять продюсеры боевой копии, и транзакции обеих начнут прерываться.

Падение посреди транзакции

Схема показывает одну партицию fraud.alerts и одну подзадачу Writer. Транзакция 12 успела зафиксироваться. Транзакцию 13 убил SIGKILL, её записи b1 и b2 уже лежат в логе. Задание стартует из чекпоинта 12, перечитывает платежи и пишет b1 и b2 заново в транзакцию с тем же именем и новой эпохой.

чекпоинт 11 чекпоинт 12 SIGKILL старт из 12 чекпоинт 13 Транзакции Writer Лог партиции read_committed read_uncommitted …-0-12, зафиксирована …-0-13, прервана …-0-13, новая эпоха a1 a2 a3 b1 b2 b1 b2 a1 a2 a3 b1 b2 a1 a2 a3 b1 b2 b1 b2 a1-a3 видны после фиксации 12 b1, b2 после фиксации 13 повтор b1 и b2 Итог. read_committed получил пять записей без повторов, read_uncommitted семь, из них две копии.

В исходниках

Что делает Writer при старте. Сначала он прерывает «зависшие» транзакции прошлого запуска, потом открывает транзакцию под номер чекпоинта, следующего за восстановленным. Для старта из чекпоинта 12 это снова номер 13.

apache/flink-connector-kafka@v5.0.0 · ExactlyOnceKafkaWriter.java, строки 190-205
@Override
public void initialize() {
    // Workaround for FLINK-37612: ensure that we are not leaking producers
    try {
        abortLingeringTransactions(
                checkNotNull(recoveredStates, "recoveredStates"), restoredCheckpointId + 1);
        this.currentProducer = startTransaction(restoredCheckpointId + 1);
    } catch (Throwable t) {
        try {
            close();
        } catch (Exception e) {
            t.addSuppressed(e);
        }
        throw t;
    }
}

Прерывание устроено через ту же регистрацию продюсера. Комментарий в коде говорит, что получение продюсера уже вызывает initTransactions, а он отменяет транзакцию.

apache/flink-connector-kafka@v5.0.0 · ExactlyOnceKafkaWriter.java, строки 334-347
private TransactionAbortStrategyContextImpl getTransactionAbortStrategyContext(
        long startCheckpointId, List<String> prefixesToAbort) {
    TransactionAbortStrategyImpl.TransactionAborter aborter =
            transactionalId -> {
                // getTransactionalProducer already calls initTransactions, which cancels the
                // transaction
                FlinkKafkaInternalProducer<byte[], byte[]> producer =
                        producerPool.getTransactionalProducer(transactionalId, 0);
                LOG.debug("Aborting transaction {}", transactionalId);
                producer.flush();
                short epoch = producer.getEpoch();
                producerPool.recycle(producer);
                return epoch;
            };

transaction.timeout.ms и длина простоя

Подготовленная транзакция живёт на брокере, пока её не зафиксируют или не прервут. Брокер ждёт не бесконечно. Через transaction.timeout.ms после первой записи он прерывает транзакцию сам. Если Committer придёт фиксировать её позже, фиксировать будет нечего.

Опасный сценарий выглядит так. Чекпоинт 12 завершён, но TaskManager упал до вызова commitTransaction. Кластер простоял ночь. Утром задание стартует из чекпоинта 12, Committer находит в состоянии committable транзакции 12 и пытается её зафиксировать. Брокер давно её прервал. Платежи до барьера 12 повторно не прочитаются, ведь источник продолжит со смещений чекпоинта 12. Алерты из этой транзакции потеряны.

В исходниках

Описание KafkaSink требует, чтобы таймаут транзакции был намного больше суммы самого долгого чекпоинта и самого долгого восстановления.

apache/flink-connector-kafka@v5.0.0 · KafkaSink.java, строки 62-71
* <li>{@link DeliveryGuarantee#EXACTLY_ONCE}: In this mode the KafkaSink will write all messages in
*     a Kafka transaction that will be committed to Kafka on a checkpoint. Thus, if the consumer
*     reads only committed data (see Kafka consumer config isolation.level), no duplicates will be
*     seen in case of a Flink restart. However, this delays record writing effectively until a
*     checkpoint is written, so adjust the checkpoint duration accordingly. Please ensure that you
*     use unique {@link #transactionalIdPrefix}s across your applications running on the same Kafka
*     cluster such that multiple running jobs do not interfere in their transactions! Additionally,
*     it is highly recommended to tweak Kafka transaction timeout (link) >> maximum checkpoint
*     duration + maximum restart duration or data loss may happen when Kafka expires an uncommitted
*     transaction.

Значение по умолчанию у приёмника один час. Конструктор строителя кладёт его в настройки продюсера.

apache/flink-connector-kafka@v5.0.0 · KafkaSinkBuilder.java, строки 65-65
private static final Duration DEFAULT_KAFKA_TRANSACTION_TIMEOUT = Duration.ofHours(1);
apache/flink-connector-kafka@v5.0.0 · KafkaSinkBuilder.java, строки 80-89
KafkaSinkBuilder() {
    kafkaProducerConfig = new Properties();
    kafkaProducerConfig.put(
            ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class.getName());
    kafkaProducerConfig.put(
            ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class.getName());
    kafkaProducerConfig.put(
            ProducerConfig.TRANSACTION_TIMEOUT_CONFIG,
            (int) DEFAULT_KAFKA_TRANSACTION_TIMEOUT.toMillis());
}

Что Committer делает с прерванной транзакцией. ProducerFencedException и InvalidTxnStateException он считает известными причинами и вызывает signalFailedWithKnownReason.

apache/flink-connector-kafka@v5.0.0 · KafkaCommitter.java, строки 101-122
try {
    producer = writerProducer.orElseGet(() -> getProducer(committable));
    producer.commitTransaction();
    backchannel.send(TransactionFinished.successful(committable.getTransactionalId()));
} catch (RetriableException e) {
    LOG.warn(
            "Encountered retriable exception while committing {}.", transactionalId, e);
    request.retryLater();
} catch (ProducerFencedException e) {
    logFencedRequest(request, e);
    handleFailedTransaction(producer);
    request.signalFailedWithKnownReason(e);
} catch (InvalidTxnStateException e) {
    // This exception only occurs when aborting after a commit or vice versa.
    // It does not appear on double commits or double aborts.
    LOG.error(
            "Unable to commit transaction ({}) because it's in an invalid state. "
                    + "Most likely the transaction has been aborted for some reason. Please check the Kafka logs for more details.",
            request,
            e);
    handleFailedTransaction(producer);
    request.signalFailedWithKnownReason(e);

Описание интерфейса объясняет, что это значит. Известная причина только пишется в лог, committable выбрасывается, задание работает дальше. Неизвестная причина роняет задание.

apache/flink@release-2.2.1 · Committer.java, строки 66-80
/**
 * The commit failed for known reason and should not be retried.
 *
 * <p>Currently calling this method only logs the error, discards the comittable and
 * continues. In the future the behaviour might be configurable.
 */
void signalFailedWithKnownReason(Throwable t);

/**
 * The commit failed for unknown reason and should not be retried.
 *
 * <p>Currently calling this method fails the job. In the future the behaviour might be
 * configurable.
 */
void signalFailedWithUnknownReason(Throwable t);

Потеря получается тихой. Задание в статусе RUNNING, и только строка уровня WARN или ERROR в логе Committer говорит, что данные, скорее всего, пропали. Такие строки в логах надо искать специально.

⚠
Час от Flink против 15 минут брокера

Брокер ограничивает таймаут сверху настройкой transaction.max.timeout.ms, по умолчанию 900000 мс, это 15 минут. Документация Kafka предупреждает, что продюсер с таймаутом больше этого предела получит InvalidTxnTimeoutException. Значение приёмника по умолчанию, один час, в этот предел не влезает. Либо задай таймаут не больше 15 минут, как в нашем проекте, либо подними предел на брокере, если простой дольше 15 минут для тебя реален.

Цена в задержке

Транзакция фиксируется только после завершения чекпоинта. Алерт, созданный сразу после барьера, ждёт целый интервал, у нас 15 секунд, плюс время самого чекпоинта. Описание KafkaSink прямо говорит, что запись фактически откладывается до чекпоинта и интервал надо подбирать с учётом этого.

Интервал нельзя уменьшать бесконечно. Каждый чекпоинт выгружает изменения RocksDB, и у нас он длится около трёхсот миллисекунд. При интервале в секунду снимки шли бы почти без перерыва, а брокер копил бы имена транзакций, по одному на чекпоинт и подзадачу. Есть и вторая сторона задержки. Пока транзакция открыта, потребители read_committed не читают дальше LSO, поэтому после падения они стоят, пока восстановленный Writer не прервёт старую транзакцию или её не прервёт брокер по таймауту.

Идемпотентность как альтернатива

Есть второй путь к тому же результату. Приёмник пишет в режиме at-least-once, повторы случаются, но запись построена так, что повтор ничего не меняет. Для этого нужен детерминированный ключ, одинаковый при каждой повторной обработке. У нас он уже есть. Идентификатор алерта собран из правила и платежа, поэтому повторный алерт получает тот же alertId.

src/main/java/dev/study/payguard/model/FraudAlert.java (строки 17-27)
    public static FraudAlert of(String ruleId, PaymentEvent trigger, String detail) {
        return new FraudAlert(
                ruleId + ":" + trigger.paymentId(),
                ruleId,
                trigger.cardId(),
                trigger.merchantId(),
                trigger.paymentId(),
                detail,
                trigger.eventTimeMs(),
                RISK_NOT_LOADED);
    }

В PostgreSQL ту же идею даёт INSERT ... ON CONFLICT DO UPDATE. Строка с тем же первичным ключом перезаписывается, и повторный итог окна оставляет таблицу в том же состоянии. Документация JDBC-коннектора Flink так и пишет. Сам приёмник даёт at-least-once, а exactly-once получается через upsert или идемпотентные запросы. Страница описывает старый вызов JdbcSink.sink, в проекте мы возьмём новый строитель. У него, кроме buildAtLeastOnce, есть buildExactlyOnce, который принимает XADataSource, то есть требует от базы поддержки XA-транзакций.

ПодходКогда подходитЧто требует
Транзакции KafkaТопик читают чужие сервисы, ключа для дедупликации у них нетПотребители с read_committed, задержку в интервал чекпоинта, правильный таймаут
Upsert по ключуПриёмник это таблица с первичным ключомДетерминированный ключ и одинаковое значение при повторе
At-least-once и дедупликация у потребителяНужна минимальная задержкаДетерминированный идентификатор записи и хранилище уже виденных идентификаторов у потребителя
i
Побочные эффекты внутри операторов не защищены

Гарантия покрывает состояние Flink и транзакционный приёмник. Если оператор сам отправляет SMS или вызывает платёжный API, после восстановления он сделает это снова для всех перечитанных платежей. Такие действия выноси за приёмник или делай идемпотентными на стороне сервиса, например по тому же alertId.

Проверь себя

Приёмник алертов работает в EXACTLY_ONCE. После перезапуска Flink команда колл-центра жалуется на повторные алерты. Их сервис использует стандартный KafkaConsumer, в настройках только адрес и группа. В чём причина?

Чекпоинт 40 завершился, и TaskManager тут же упал, не успев зафиксировать транзакцию. Kafka работает, кластер Flink вернули через шесть часов. Таймаут транзакции 15 минут. Что будет с алертами транзакции 40?

Команда блокировки карт хочет видеть алерт не позже чем через секунду и без повторов. Интервал чекпоинта 15 секунд, уменьшать его нельзя. Что предложишь?

Что будет в конце

Алерты пишутся в fraud.alerts транзакциями. Подтверждённые платежи в payments.confirmed остаются в режиме AT_LEAST_ONCE, это контрольная группа. Мы дважды убьём TaskManager под нагрузкой и посчитаем повторы в обоих топиках скриптом verify-no-duplicates.sh.

Итоги продавцов в PostgreSQL относятся к заданию. Файл JdbcSinks.java, таблицу merchant_stats и зависимости JDBC пока не создавай, итоги в этой части урока по-прежнему идут в топик merchant.stats.

Шаг 1. Приёмник с транзакциями

В KafkaSinks появляется второй метод сборки. Общая часть, сериализация в JSON, выносится в отдельный метод, чтобы два приёмника не расходились.

что изменилось в src/main/java/dev/study/payguard/job/KafkaSinks.java
@@ -1,21 +1,43 @@ package dev.study.payguard.job;
 
+import java.time.Duration;+import java.util.Properties; import org.apache.flink.connector.base.DeliveryGuarantee;
 import org.apache.flink.connector.kafka.sink.KafkaRecordSerializationSchema;
 import org.apache.flink.connector.kafka.sink.KafkaSink;
 import org.apache.flink.formats.json.JsonSerializationSchema;
+import org.apache.kafka.clients.producer.ProducerConfig; 
 /** Единая сборка приёмников Kafka, которые пишут объекты как JSON. */
 final class KafkaSinks {
 
+    // Транзакция должна пережить простой между чекпоинтом и восстановлением, иначе брокер её отменит.+    private static final Duration TRANSACTION_TIMEOUT = Duration.ofMinutes(15);+     static <T> KafkaSink<T> atLeastOnceJson(String bootstrapServers, String topic) {
         return KafkaSink.<T>builder()
                 .setBootstrapServers(bootstrapServers)
-                .setRecordSerializer(KafkaRecordSerializationSchema.<T>builder()-                        .setTopic(topic)-                        .setValueSerializationSchema(new JsonSerializationSchema<T>())-                        .build())+                .setRecordSerializer(jsonSerializer(topic))                 .setDeliveryGuarantee(DeliveryGuarantee.AT_LEAST_ONCE)
+                .build();+    }++    static <T> KafkaSink<T> exactlyOnceJson(String bootstrapServers, String topic, String transactionalIdPrefix) {+        Properties producer = new Properties();+        producer.setProperty(ProducerConfig.TRANSACTION_TIMEOUT_CONFIG, String.valueOf(TRANSACTION_TIMEOUT.toMillis()));+        return KafkaSink.<T>builder()+                .setBootstrapServers(bootstrapServers)+                .setRecordSerializer(jsonSerializer(topic))+                .setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE)+                .setTransactionalIdPrefix(transactionalIdPrefix)+                .setKafkaProducerConfig(producer)+                .build();+    }++    private static <T> KafkaRecordSerializationSchema<T> jsonSerializer(String topic) {+        return KafkaRecordSerializationSchema.<T>builder()+                .setTopic(topic)+                .setValueSerializationSchema(new JsonSerializationSchema<T>())                 .build();
     }
 
КОД
    static <T> KafkaSink<T> exactlyOnceJson(String bootstrapServers, String topic, String transactionalIdPrefix) {
        Properties producer = new Properties();
        producer.setProperty(ProducerConfig.TRANSACTION_TIMEOUT_CONFIG, String.valueOf(TRANSACTION_TIMEOUT.toMillis()));
        return KafkaSink.<T>builder()
                .setBootstrapServers(bootstrapServers)
                .setRecordSerializer(jsonSerializer(topic))
                .setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE)
                .setTransactionalIdPrefix(transactionalIdPrefix)
                .setKafkaProducerConfig(producer)
                .build();
    }
ПРОСТЫМИ СЛОВАМИ

Метод принимает третий параметр, префикс имён транзакций. Он должен быть уникальным для каждого приёмника на весь кластер Kafka.

Готовим настройки продюсера. transaction.timeout.ms ставим в 15 минут из константы TRANSACTION_TIMEOUT. Это ровно предел брокера по умолчанию, значение приёмника в час брокер бы отверг.

Адрес брокера и сериализатор те же, что у приёмника at-least-once.

EXACTLY_ONCE включает транзакционный Writer и настоящий Committer. Без префикса build() упадёт на проверке.

setKafkaProducerConfig добавляет наши настройки к тем, что строитель положил сам. Сериализаторы ключа и значения при этом остаются прежними.

Новых зависимостей нет, транзакции умеет тот же flink-connector-kafka. Класс ProducerConfig приходит из клиента Kafka, который подтягивает коннектор.

Шаг 2. Префикс в настройках задания

Префикс передаётся аргументом, чтобы тестовая копия задания на том же кластере Kafka могла взять другой. Добавь в record JobConfig поле после alertsTopic.

src/main/java/dev/study/payguard/job/JobConfig.java (строки 13-14)
        String alertsTopic,
        String alertsTransactionalIdPrefix,

И его чтение в fromArgs на том же месте.

src/main/java/dev/study/payguard/job/JobConfig.java (строки 30-31)
                params.get("alerts-topic", "fraud.alerts"),
                params.get("alerts-transactional-id-prefix", "payguard-alerts"),

Шаг 3. Подключи приёмник алертов

В PayGuardJob меняется только последний приёмник. Имя и uid остаются прежними.

src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 164-176)
        AsyncDataStream
                .unorderedWait(
                        allAlerts,
                        new AlertEnrichment(() -> JdbcMerchantDirectory.fromEnvironment(DB_POOL_SIZE)),
                        ENRICHMENT_TIMEOUT.toMillis(),
                        TimeUnit.MILLISECONDS,
                        ENRICHMENT_CAPACITY)
                .name("enrich-alerts")
                .uid("enrich-alerts")
                .sinkTo(KafkaSinks.<FraudAlert>exactlyOnceJson(
                        config.bootstrapServers(), config.alertsTopic(), config.alertsTransactionalIdPrefix()))
                .name("alerts-sink")
                .uid("alerts-sink");

Приёмник подтверждений confirmed-sink не трогай. Он останется at-least-once, и на нём будет видно, что даёт обычный режим при тех же падениях.

Шаг 4. Интервал чекпоинта

В docker-compose.yml интервал уменьшается с 30 до 15 секунд. Это потолок задержки алерта для потребителя read_committed.

docker-compose.yml (строки 9-17)
      parallelism.default: 2
      jobmanager.memory.process.size: 1600m
      taskmanager.memory.process.size: 2048m
      execution.checkpointing.interval: 15s
      state.backend.type: rocksdb
      execution.checkpointing.dir: file:///flink-data/checkpoints
      execution.checkpointing.savepoint-dir: file:///flink-data/savepoints
      execution.checkpointing.incremental: true
      execution.checkpointing.min-pause: 10s

Строки KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 и KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 в описании Kafka стоят с первого урока, и теперь они нужны. Документация Kafka предупреждает, что по умолчанию транзакциям нужен кластер минимум из трёх брокеров. Это настройка внутреннего топика __transaction_state, где брокер хранит состояние транзакций. На стенде с одним брокером без этих строк транзакции не заработают.

Шаг 5. Скрипт подсчёта повторов

Создай scripts/verify-no-duplicates.sh. Он читает топик глазами потребителя с выбранным уровнем изоляции и считает идентификаторы, которые встретились больше одного раза.

scripts/verify-no-duplicates.sh
#!/usr/bin/env bash
# Читает топик как потребитель с заданным уровнем изоляции и считает идентификаторы, встретившиеся дважды.
# Использование: verify-no-duplicates.sh <топик> <поле-идентификатор> [read_committed|read_uncommitted]
set -euo pipefail
TOPIC="$1"
ID_FIELD="$2"
ISOLATION="${3:-read_committed}"

records=$(docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 \
  --topic "$TOPIC" \
  --from-beginning \
  --isolation-level "$ISOLATION" \
  --timeout-ms 10000 2>/dev/null | jq -r --arg field "$ID_FIELD" '.[$field]')

total=$(printf '%s\n' "$records" | grep -c . || true)
duplicates=$(printf '%s\n' "$records" | sort | uniq -d | grep -c . || true)
echo "$TOPIC [$ISOLATION]: записей $total, идентификаторов с повтором $duplicates"
КОД
records=$(docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 \
  --topic "$TOPIC" \
  --from-beginning \
  --isolation-level "$ISOLATION" \
  --timeout-ms 10000 2>/dev/null | jq -r --arg field "$ID_FIELD" '.[$field]')

total=$(printf '%s\n' "$records" | grep -c . || true)
duplicates=$(printf '%s\n' "$records" | sort | uniq -d | grep -c . || true)
echo "$TOPIC [$ISOLATION]: записей $total, идентификаторов с повтором $duplicates"
ПРОСТЫМИ СЛОВАМИ

Запускаем консольного потребителя внутри контейнера Kafka. Флаг -T отключает псевдотерминал, чтобы вывод без искажений шёл дальше по конвейеру.

Внутри контейнера брокер слушает localhost:9092.

--from-beginning читает топик с первой записи, поэтому в счёт попадает всё, что когда-либо туда писали.

--isolation-level выбирает режим потребителя. Это та же настройка isolation.level, что у KafkaConsumer.

--timeout-ms 10000 завершает чтение, если 10 секунд не пришло ни одной записи. Без него потребитель ждал бы вечно. Сообщения потребителя в stderr отбрасываем, а jq вынимает из каждого JSON поле-идентификатор.

Считаем непустые строки, это число записей.

sort | uniq -d оставляет идентификаторы, встретившиеся больше одного раза, и мы считаем их количество.

Печатаем итог одной строкой.

Шаг 6. Чистый стенд, запуск и два падения

Скрипт читает топики с начала. Если в fraud.alerts остались алерты прошлых уроков, записанные без транзакций, потребитель read_committed получит их как обычные записи вместе с их повторами. Поэтому начни с чистого стенда. У Kafka в нашем Compose нет тома, и docker compose down удаляет топики.

терминал
docker compose down
./gradlew shadowJar
docker compose up -d --wait
scripts/submit.sh
./gradlew loadgen --args="--rate 200 --dup 0.02 --bad 0.01 --burst 0.002"
вывод
Job has been submitted with JobID ecae15caffd4514a0e8d686d56059fc5

Во втором терминале проверь, что у приёмников появилась пара Writer и Committer. Фильтр test оставляет вершины, в имени которых есть один из двух приёмников.

терминал
JOB=$(curl -s localhost:8081/jobs | jq -r '.jobs[] | select(.status == "RUNNING") | .id')
curl -s localhost:8081/jobs/$JOB | jq -r '.vertices[].name | select(test("alerts-sink|confirmed-sink"))'
вывод
join-confirmations -> confirmed-sink: Writer -> confirmed-sink: Committer
enrich-alerts -> alerts-sink: Writer -> alerts-sink: Committer

Снаружи оба приёмника выглядят одинаково. Разница внутри, у confirmed-sink Committer пустой. В логе TaskManager при старте будут предупреждения Name collision: Group already contains a Metric with the name 'pendingCommittables'. Metric will not be reported. Они говорят только о том, что одна метрика с повторяющимся именем не попадёт в отчёт. На запись это не влияет.

Дай заданию поработать минуту, затем дважды убей TaskManager скриптом из урока 11. Между запусками подожди хотя бы один чекпоинт, это 15 секунд.

терминал
scripts/chaos-kill-tm.sh
scripts/chaos-kill-tm.sh
вывод
Убиваю TaskManager
Задание восстановлено из чекпоинта за 24 с
Убиваю TaskManager
Задание восстановлено из чекпоинта за 19 с

Шаг 7. Посчитай повторы

Останови генератор, чтобы топики перестали расти, и запусти проверку трижды. Алерты читаем в обоих режимах, подтверждения только в read_committed. Транзакций в payments.confirmed нет, и режим для него ничего не меняет.

терминал
scripts/verify-no-duplicates.sh fraud.alerts alertId read_committed
scripts/verify-no-duplicates.sh fraud.alerts alertId read_uncommitted
scripts/verify-no-duplicates.sh payments.confirmed paymentId read_committed
сводка прогона стенда
fraud.alerts [read_committed]: записей 290, повторов 0
fraud.alerts [read_uncommitted]: записей 301, повторов 8
payments.confirmed (AT_LEAST_ONCE) [read_committed]: записей 32075, повторов 1848

Сводка собрана из вывода скрипта на нашем стенде при 200 платежах в секунду. Читай её построчно.

  • В read_committed 290 алертов и ни одного повтора. Два падения не оставили следа для потребителя.
  • В read_uncommitted на 11 записей больше, и у 8 алертов есть копия. Это записи транзакций, которые убил SIGKILL. После восстановления задание перечитало платежи и записало те же алерты в новые транзакции. Оставшиеся три записи без пары, скорее всего, лежали в транзакции, которая ещё была открыта в момент проверки.
  • Подтверждения в режиме AT_LEAST_ONCE получили 1848 повторов. Приёмник после каждого восстановления заново отправил то, что задание выдало после последнего завершённого чекпоинта, и эти записи видны любому потребителю.

Шаг 8. Посмотри на задержку

Запусти генератор снова и открой в двух терминалах потребителей fraud.alerts с разной изоляцией.

терминал
docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 --topic fraud.alerts --isolation-level read_committed

Во втором терминале та же команда с read_uncommitted. Смотри на ритм. Потребитель read_uncommitted получает алерты по мере появления. Потребитель read_committed должен получать их пачками, примерно раз в 15 секунд, сразу после завершения очередного чекпоинта. Сверь моменты пачек с временем завершения чекпоинтов в Web UI на вкладке Checkpoints.

Шаг 9. Ловушка, на которую мы наступили позже

В уроке 20 этот же приёмник сломался в Kubernetes. Под Kafka перезапустился из-за нехватки памяти, а тома у него не было. Брокер потерял топики и журнал транзакций __transaction_state. Задание восстановилось из savepoint, и Committer попытался зафиксировать подготовленную транзакцию, о которой новый брокер ничего не знал.

исключение задания, урок 20
java.lang.IllegalStateException: Failed to commit KafkaCommittable{producerId=31, epoch=2, transactionalId='payguard-alerts-0-11', producer=null}
Caused by: org.apache.kafka.common.errors.InvalidPidMappingException: The producer attempted to use a producer id which is not currently assigned to its transactional id.

Задание восстанавливалось 17 раз, 11 чекпоинтов закончились FAILED, и состояние RESTARTING не кончалось. Обрати внимание на producer=null. Этот committable восстановлен из состояния, живого продюсера Writer за ним нет. Committer делал ровно то, что мы видели в CommitterOperator.initializeState.

В исходниках

Почему задание не пропустило транзакцию и не пошло дальше. Список известных причин в KafkaCommitter заканчивается на UnknownProducerIdException. InvalidPidMappingException попадает в общий catch, и запрос завершается как неизвестный отказ, который роняет задание. После перезапуска тот же committable снова восстанавливается из чекпоинта, и круг повторяется.

apache/flink-connector-kafka@v5.0.0 · KafkaCommitter.java, строки 123-149
} catch (UnknownProducerIdException e) {
    LOG.error(
            "Unable to commit transaction ({}) " + UNKNOWN_PRODUCER_ID_ERROR_MESSAGE,
            request,
            e);
    handleFailedTransaction(producer);
    request.signalFailedWithKnownReason(e);
} catch (InterruptException e) {
    // note that we do not attempt to recover from this exception; producer is likely
    // left in an inconsistent state
    LOG.info(
            "Committing transaction ({}) was interrupted. This most likely happens because the task is being cancelled.",
            request,
            e);
    // reset the interrupt flag that is set when InterruptException is created
    Thread.interrupted();
    // propagate interruption through java.lang.InterruptedException instead
    throw new InterruptedException(e.getMessage());
} catch (Exception e) {
    LOG.error(
            "Transaction ({}) encountered error and data has been potentially lost.",
            request,
            e);
    closeCommitterProducer(producer);
    // cause failover
    request.signalFailedWithUnknownReason(e);
}

Лечение в два шага. Чтобы не повторилось, Kafka должна жить на постоянном томе, тогда __transaction_state переживает перезапуск брокера. Мы проверили это в уроке 20, после удаления пода Kafka с томом задание через 90 секунд работало, чекпоинты шли без отказов. Чтобы выйти из уже начавшегося цикла, задание запускают без состояния приёмника. Как это делать аккуратно и что при этом теряется, разберём в уроке 21.

Задание. Итоги продавцов в PostgreSQL

Минутные итоги по продавцам сейчас уходят в топик merchant.stats в режиме at-least-once. После каждого падения там появляются повторы. Отчётам нужна таблица, в которой у каждого продавца одна строка на окно. Перенеси итоги в PostgreSQL и сделай запись идемпотентной.

  1. Добавь в build.gradle.kts JDBC-коннектор Flink версии 4.1.0-2.2, модули flink-connector-jdbc-core и flink-connector-jdbc-postgres. Драйвер PostgreSQL в проекте уже есть.
  2. Опиши в sql/init.sql таблицу merchant_stats с колонками продавца, начала и конца окна, числа платежей, суммы и времени обновления. Первичный ключ выбери так, чтобы одно окно продавца было одной строкой.
  3. Напиши класс JdbcSinks с методом merchantStats(), который возвращает приёмник для MerchantStats. Запрос вставки при конфликте ключа обновляет итоги.
  4. В PayGuardJob замени приёмник итогов на новый, назови его merchant-stats-postgres с таким же uid. Поле merchantStatsTopic из JobConfig и топик merchant.stats из kafka-init больше не нужны, убери их.

Готово, если после двух убийств TaskManager под нагрузкой в 200 платежей в секунду задание работает, в графе есть вершина с merchant-stats-postgres: Writer, а в таблице ровно по одной строке на продавца и окно для всех 50 продавцов.

Подсказки.

  • Новый строитель приёмника лежит в пакете org.apache.flink.connector.jdbc.core.datastream.sink, класс JdbcSink. Нужен вариант at-least-once, транзакции здесь не понадобятся.
  • Адрес базы, пользователь и пароль уже есть в окружении JobManager и TaskManager, переменные PAYGUARD_DB_URL, PAYGUARD_DB_USER и PAYGUARD_DB_PASSWORD.
  • Окно продавца с допуском опоздания может сработать для одного окна несколько раз. Подумай, что должно произойти со строкой при втором срабатывании.
  • Образ PostgreSQL выполняет скрипты из docker-entrypoint-initdb.d только при создании пустой базы. Чтобы новая таблица появилась, контейнер базы придётся пересоздать вместе с его томом.

Ответ

Глоссарий урока

At-least-once
Гарантия, при которой ни одна запись не теряется, но после сбоя часть записей может прийти повторно.
Exactly-once
Гарантия, при которой каждое событие влияет на результат ровно один раз. Для состояния Flink её дают чекпоинты, для внешней системы нужен транзакционный или идемпотентный приёмник.
End-to-end exactly-once
Exactly-once от источника до внешнего приёмника. Требует воспроизводимого источника и транзакционного или идемпотентного приёмника.
Двухфазная фиксация (two-phase commit)
Протокол из подготовки и фиксации. Во Flink подготовка идёт на барьере чекпоинта, фиксация после его завершения.
Предкоммит (pre-commit)
Первая фаза. Writer сбрасывает буферы, помечает транзакцию подготовленной и передаёт её описание в Committer.
SinkWriter
Оператор приёмника, который пишет записи во внешнюю систему. В транзакционном режиме пишет так, чтобы данные ещё не были видны.
Committer
Оператор приёмника, который делает подготовленные данные видимыми после завершения чекпоинта. Повторная фиксация не должна ничего менять.
Committable
Описание подготовленных данных, которое Writer передаёт Committer. У Kafka это имя транзакции, producerId и эпоха.
notifyCheckpointComplete
Вызов, который оператор получает после того, как JobManager объявил чекпоинт завершённым. По нему Committer фиксирует транзакции.
Транзакция Kafka
Группа записей одного продюсера, которая становится видна потребителям read_committed целиком после фиксации или не становится видна вовсе.
transactional.id
Имя транзакционного продюсера в Kafka. Flink собирает его из префикса, номера подзадачи и номера чекпоинта.
producerId и эпоха
Числа, по которым брокер узнаёт продюсера. Новый продюсер с тем же transactional.id получает новую эпоху, и старый больше не может фиксировать.
Fencing
Вытеснение старого продюсера новым с тем же transactional.id. Попытка старого продолжить заканчивается ProducerFencedException.
isolation.level
Настройка потребителя Kafka. read_uncommitted, значение по умолчанию, отдаёт всё, включая прерванные транзакции. read_committed отдаёт только зафиксированные.
LSO (last stable offset)
Смещение перед первой открытой транзакцией в партиции. Потребитель read_committed не читает дальше него.
transaction.timeout.ms
Настройка продюсера Kafka. Через это время брокер сам прерывает незафиксированную транзакцию. Приёмник Flink по умолчанию ставит час.
transaction.max.timeout.ms
Настройка брокера Kafka, предел для transaction.timeout.ms. По умолчанию 15 минут.
__transaction_state
Внутренний топик Kafka, где брокер хранит состояние транзакций. Без постоянного тома теряется при перезапуске брокера.
InvalidPidMappingException
Ошибка Kafka, когда producerId не связан с transactional.id на брокере. У нас возникла после потери журнала транзакций.
Идемпотентность
Свойство операции, при котором повторное выполнение даёт тот же результат, что и однократное.
Upsert
Вставка, которая при существующем ключе обновляет строку. В PostgreSQL пишется как INSERT ... ON CONFLICT DO UPDATE.
Детерминированный идентификатор
Идентификатор, который вычисляется из содержимого записи и совпадает при повторной обработке. У нас alertId из правила и платежа.
XA-транзакция
Стандарт распределённых транзакций для баз данных. JDBC-приёмник Flink использует его в режиме exactly-once.
JDBC
Стандартный интерфейс Java для работы с реляционными базами. Коннектор Flink для него выходит отдельно, как и коннектор Kafka.
PostgreSQL
Реляционная база данных. В проекте хранит справочник продавцов и итоги по окнам.

Урок 13 · Отказоустойчивость

Savepoint, эволюция схемы состояния и смена параллелизма

После урока ты выкатишь новую версию задания с изменённым типом состояния и другим параллелизмом, не потеряв ни одного профиля карты, и прочитаешь состояние прямо из снимка.

Релиз, который стирает память

Аналитики просят добавить в профиль карты страну последнего платежа. В коде это одно новое поле в record. Профиль хранится в состоянии задания, и за дни работы там накопились счётчики по тысячам карт. Если остановить задание командой cancel и запустить новый jar с чистого листа, профили начнутся с нуля. Вместе с ними пропадут дедупликация, открытые окна и частичные совпадения CEP.

Чекпоинт из урока 11 спасает от падений. Его создаёт и удаляет сам Flink, и рассчитан он на перезапуск того же кода. Для плановых изменений есть savepoint, снимок, который делаешь ты и который Flink никогда не удаляет сам. Сегодня мы разберём четыре вещи. Как savepoint находит оператор в новой версии программы. Какие правки типа состояния он переживает. Как состояние делится между другим числом подзадач. И как прочитать снимок без запуска задания.

Переезд с подписанными коробками

Переезжая, ты складываешь вещи в коробки и пишешь на каждой, в какую комнату её нести. Грузчики в новой квартире твоих вещей не знают и читают только надписи. Коробку с надписью «кабинет» поставят в кабинет. Если кабинета в новой квартире нет, грузчик остановится и спросит, что делать. Ответ «оставьте в кладовке, разберусь сам» и есть флаг --allowNonRestoredState.

Внутри каждой коробки лежит опись. Если в новом шкафу появилась лишняя полка, вещи по описи встанут на свои места, новая полка останется пустой. Если в описи написано «книги», а шкаф теперь ждёт обувь, вещи не встанут никуда. Книги ты заранее разложил по 128 пронумерованным коробкам по жёсткому правилу. В старой квартире было два стеллажа, в новой четыре. Коробки переставляют целиком, перекладывать книги не нужно. Число коробок поменять нельзя, оно определилось в день упаковки.

Во Flink надпись на коробке называется uid оператора, опись это снимок сериализатора, пронумерованные коробки это группы ключей (key groups), а стеллажи это подзадачи. Чекпоинт похож на вещи, разложенные по местам в той же квартире. После аварии их находят там, где оставили. Savepoint это коробки для переезда. Паковать их дольше, зато их можно везти в другую квартиру.

Чекпоинт и savepoint

Оба снимка создаются одним механизмом барьеров из урока 11. Отличаются они тем, кто ими владеет и для чего они нужны.

ЧекпоинтSavepoint
Кто создаётFlink, по интервалуТы, командой CLI или через REST
Кто удаляетFlink. При остановке задания тоже, если не включено хранениеТолько ты. Flink не трогает его ни после остановки, ни после восстановления
ФорматРодной для бэкенда состояния, у RocksDB бывает инкрементальнымCanonical по умолчанию, по запросу native
Для чегоВосстановление после сбояНовый код, другой параллелизм, новая версия Flink, смена бэкенда

Формат canonical един для всех бэкендов состояния. Снимок, снятый с RocksDB, можно восстановить в HashMap и наоборот. Документация называет его самым стабильным форматом с наибольшей совместимостью между версиями и схемами. Формат native пишет то, что бэкенду удобно, у RocksDB это SST-файлы. Он быстрее создаётся и восстанавливается, но сменить бэкенд с ним нельзя. Эволюция схемы и чтение через State Processor API для него помечены в документации как работающие без гарантий.

В исходниках

Какой формат получится, если флаг --type не указан. Смотри на константу DEFAULT в конце перечисления.

apache/flink@release-2.2.1 · SavepointFormatType.java, строки 28-43
/** Describes the binary format in which a savepoint should be taken. */
@PublicEvolving
public enum SavepointFormatType implements DescribedEnum {
    /** A canonical, common for all state backends format. It lets users switch state backends. */
    CANONICAL(
            "A canonical, common for all state backends format. It lets you switch state backends."),

    /**
     * A format specific for the chosen state backend, in its native binary format. Might be faster
     * to take and restore from than the canonical one.
     */
    NATIVE(
            "A format specific for the chosen state backend, in its native binary format."
                    + " Might be faster to take and restore from than the canonical one.");
    private final InlineElement description;
    public static final SavepointFormatType DEFAULT = SavepointFormatType.CANONICAL;

По умолчанию снимается canonical. В нашем скрипте тип всё равно указан явно, чтобы через год никто не гадал, в каком формате лежат снимки.

В нашем docker-compose.yml с урока 11 стоит externalized-checkpoint-retention: RETAIN_ON_CANCELLATION, поэтому последние чекпоинты переживают и cancel. Запустить задание из такого чекпоинта тоже можно. Таблица в документации разрешает выровненному чекпоинту смену кода и параллелизма, а для эволюции схемы ставит знак «работает без гарантий». Для плановых релизов бери savepoint.

stop и cancel

Команда stop (flink stop) останавливает задание аккуратно. Источники получают просьбу отправить последний барьер, по нему снимается savepoint, и только после его успеха источники завершаются. Задание переходит в состояние FINISHED, CLI печатает путь к снимку. Команда cancel (flink cancel) останавливает задание сразу и savepoint не снимает. Старый флаг cancel --withSavepoint объявлен устаревшим, документация советует вместо него stop. Есть и третья команда, flink savepoint. Она снимает снимок и оставляет задание работать, так удобно получить копию состояния для анализа.

⚠
Флаг --drain нужен только при окончательной остановке

С флагом --drain перед последним барьером уходит MAX_WATERMARK. Все таймеры по времени события срабатывают, и минутные окна продавцов закроются досрочно с неполными суммами. Документация прямо предупреждает, что продолжение задания после такого снимка может дать неверные результаты. Для релиза используй stop без этого флага.

Как состояние находит свой оператор

Внутри savepoint лежит отображение «идентификатор оператора → состояние». Идентификатор (OperatorID) это 16 байт. Если у оператора задан uid, байты получаются хешем от этой строки. Если не задан, Flink выводит их из положения оператора в графе, и правка топологии может их сдвинуть. Об этом шла речь в уроке 2.

В исходниках

Как строка uid превращается в идентификатор при сборке графа. Хеш murmur3 на 128 бит от UTF-8 байтов строки, и больше ничего в него не входит.

apache/flink@release-2.2.1 · StreamGraphHasherV2.java, строки 134-149
public static byte[] generateUserSpecifiedHash(String operatorUid) {
    final HashFunction hashFunction = getHashFunction();

    return generateUserSpecifiedHash(operatorUid, hashFunction.newHasher());
}

private static HashFunction getHashFunction() {
    return Hashing.murmur3_128(0);
}

/** Generates a hash from a user-specified ID. */
private static byte[] generateUserSpecifiedHash(String operatorUid, Hasher hasher) {
    hasher.putString(operatorUid, Charset.forName("UTF-8"));

    return hasher.hash().asBytes();
}

State Processor API считает идентификатор той же функцией. Поэтому в задании урока достаточно передать строку card-profile, и инспектор найдёт состояние оператора в снимке.

apache/flink@release-2.2.1 · OperatorIDGenerator.java, строки 43-46
public static OperatorID fromUid(String uid) {
    byte[] hash = Hashing.murmur3_128(0).newHasher().putString(uid, UTF_8).hash().asBytes();
    return new OperatorID(hash);
}

Отсюда следует простое правило. Одинаковый uid в двух версиях программы даёт одинаковый идентификатор, и состояние находит оператор. Имя оператора, которое задаёт .name(), в этом не участвует.

При запуске из снимка JobManager сначала читает его метаданные и проверяет, что каждому сохранённому состоянию нашёлся оператор. Это делает метод Checkpoints.loadAndValidateCheckpoint.

В исходниках

Первый шаг. Для каждой вершины нового графа в таблицу кладутся оба идентификатора оператора, выведенный из графа и заданный через uid.

apache/flink@release-2.2.1 · Checkpoints.java, строки 153-162
// generate mapping from operator to task
Map<OperatorID, ExecutionJobVertex> operatorToJobVertexMapping = new HashMap<>();
for (ExecutionJobVertex task : tasks.values()) {
    for (OperatorIDPair operatorIDPair : task.getOperatorIDs()) {
        operatorToJobVertexMapping.put(operatorIDPair.getGeneratedOperatorID(), task);
        operatorIDPair
                .getUserDefinedOperatorID()
                .ifPresent(id -> operatorToJobVertexMapping.put(id, task));
    }
}

Второй шаг. Для состояния, которому оператор не нашёлся, есть три исхода. С флагом allowNonRestoredState оно пропускается с записью в лог. Без флага пустое состояние тоже пропускается. Непустое приводит к исключению.

apache/flink@release-2.2.1 · Checkpoints.java, строки 194-213
} else if (allowNonRestoredState) {
    LOG.info(
            "Skipping savepoint state for operator {}.", operatorState.getOperatorID());
} else {
    if (operatorState.getCoordinatorState() != null) {
        throwNonRestoredStateException(
                checkpointPointer, operatorState.getOperatorID());
    }

    for (OperatorSubtaskState operatorSubtaskState : operatorState.getStates()) {
        if (operatorSubtaskState.hasState()) {
            throwNonRestoredStateException(
                    checkpointPointer, operatorState.getOperatorID());
        }
    }

    LOG.info(
            "Skipping empty savepoint state for operator {}.",
            operatorState.getOperatorID());
}

Текст исключения задан чуть ниже в том же файле, в throwNonRestoredStateException. Он начинается словами Failed to rollback to checkpoint/savepoint и заканчивается советом поставить --allowNonRestoredState. В обратную сторону проверки нет. Новый оператор, которого не было в снимке, просто стартует с пустым состоянием.

Флаг --allowNonRestoredState выбрасывает состояние насовсем. Ставь его, только когда оператор удалён намеренно. Если же ты случайно поменял uid, флаг тихо обнулит состояние оператора, и задание запустится как ни в чём не бывало. Правильное лечение в этом случае вернуть старый uid.

Эволюция схемы состояния

Значения состояния лежат в снимке байтами. Прочитать их может только сериализатор того же формата, поэтому рядом с данными Flink кладёт снимок сериализатора (serializer snapshot). Это описание формата, например список полей POJO с их типами. При восстановлении новый сериализатор сравнивает себя со старым снимком. Возможны четыре ответа. Совместим как есть. Совместим после миграции, тогда старые байты читаются старым сериализатором и переписываются новым. Совместим после перенастройки. Несовместим, тогда восстановление падает.

Для POJO документация задаёт правила.

  • Поля можно удалять. Старое значение удалённого поля пропадёт в следующих снимках.
  • Поля можно добавлять. Новое поле получит значение по умолчанию для своего типа в Java.
  • Тип объявленного поля менять нельзя.
  • Имя класса, включая пакет, менять нельзя.

Record во Flink 2.2 идёт тем же путём. TypeExtractor признаёт record типом POJO, даже без конструктора по умолчанию и сеттеров, и сериализует его через PojoSerializer. Наш CardProfile как раз record.

В исходниках

Проверка совместимости в PojoSerializerSnapshot.resolveSchemaCompatibility. Сначала сравнивается класс, потом сериализаторы полей, которые есть в обеих версиях.

apache/flink@release-2.2.1 · PojoSerializerSnapshot.java, строки 208-225
if (previousPojoClass != snapshotData.getPojoClass()) {
    return TypeSerializerSchemaCompatibility.incompatible();
}

if (registeredSubclassSerializerSnapshots.hasAbsentKeysOrValues()) {
    return TypeSerializerSchemaCompatibility.incompatible();
}

if (nonRegisteredSubclassSerializerSnapshots.hasAbsentKeysOrValues()) {
    return TypeSerializerSchemaCompatibility.incompatible();
}

final IntermediateCompatibilityResult<T> preExistingFieldSerializersCompatibility =
        getCompatibilityOfPreExistingFields(fieldSerializerSnapshots);

if (preExistingFieldSerializersCompatibility.isIncompatible()) {
    return TypeSerializerSchemaCompatibility.incompatible();
}

Если класс и старые поля совпали, а набор полей изменился, ответ будет «совместим после миграции».

apache/flink@release-2.2.1 · PojoSerializerSnapshot.java, строки 383-390
private boolean newPojoSerializerIsCompatibleAfterMigration(
        IntermediateCompatibilityResult<T> fieldSerializerCompatibility,
        IntermediateCompatibilityResult<T> preExistingRegistrationsCompatibility,
        LinkedOptionalMap<Field, TypeSerializerSnapshot<?>> fieldSerializerSnapshots) {
    return newPojoHasNewOrRemovedFields(fieldSerializerSnapshots)
            || fieldSerializerCompatibility.isCompatibleAfterMigration()
            || preExistingRegistrationsCompatibility.isCompatibleAfterMigration();
}

Каким станет новое поле record после миграции, решает JavaRecordBuilderFactory. Если имена или порядок компонент изменились, он строит таблицу перестановки аргументов конструктора. Новые примитивные поля получают значение по умолчанию, остальные новые поля остаются null.

apache/flink@release-2.2.1 · JavaRecordBuilderFactory.java, строки 139-163
// If the field names / order changed we know that we are migrating the records and arg
// index remapping may be necessary
boolean migrating = !previousFields.equals(componentNames);
if (migrating) {
    // If the order / index of arguments changed in the new record class we have to map
    // it, otherwise we pass the wrong arguments to the constructor
    int[] argIndexMapping = new int[fields.length];
    for (int i = 0; i < fields.length; i++) {
        Field field = fields[i];
        // There may be (removed) null fields due to schema evolution
        argIndexMapping[i] =
                field == null ? -1 : componentNames.indexOf(fields[i].getName());
    }

    // We have to initialize newly added primitive fields to their correct default value
    Object[] defaultValues = new Object[componentNames.size()];
    for (int i = 0; i < componentNames.size(); i++) {
        Class<?> fieldType = componentTypes[i];
        boolean newPrimitive =
                fieldType.isPrimitive()
                        && !previousFields.contains(componentNames.get(i));
        defaultValues[i] = newPrimitive ? Defaults.defaultValue(fieldType) : null;
    }
    return new JavaRecordBuilderFactory<>(
            recordConstructor, argIndexMapping, defaultValues);

Для нашего релиза это значит следующее. Профили, восстановленные из старого снимка, получат lastCountry == null и будут жить так до первого нового платежа по карте. Код, который читает это поле, обязан пережить null.

💡
Переименование поля равно удалению и добавлению

Поля сопоставляются по имени. Если переименовать totalAmountMinor в totalMinor, Flink увидит удалённое поле и новое. Задание запустится без единой ошибки, а суммы всех карт станут нулём. Тесты на harness это не поймают, потому что в них один и тот же класс с обеих сторон, об этом урок 16.

Ещё три ограничения из той же страницы документации. Ключ состояния эволюционировать не может, его байты служат адресом, и у RocksDB сравниваются побайтно. Типы, которые ушли в Kryo, эволюции не поддерживают вовсе. Наше задание с урока 3 запрещает Kryo настройкой pipeline.generic-types: false, поэтому для такого типа Flink откажется создавать сериализатор и задание упадёт сразу, до первого снимка. Если правка всё-таки несовместима, остаются свой сериализатор или переписывание снимка через State Processor API.

Rescale через группы ключей

В уроке 4 мы считали, куда попадает ключ. Номер группы равен murmurHash(key.hashCode()) % maxParallelism и от параллелизма не зависит. Подзадача владеет непрерывным диапазоном групп. Теперь посмотрим на тот же механизм со стороны восстановления. Состояние в снимке разложено по группам, и при смене параллелизма Flink переносит его целыми группами. Такой запуск с другим параллелизмом называют rescale.

Восстановление с другим параллелизмом

Поставь 2 и 4 при 128 группах. Сменят владельца 96 групп, при этом каждая новая подзадача читает кусок ровно одной старой. Поставь 2 и 3, и средняя подзадача соберёт своё состояние из двух старых. Выбери 4 группы и параллелизм 6, чтобы увидеть отказ.

В исходниках

Раскладкой состояния при восстановлении занимается StateAssignmentOperation. Если параллелизм не изменился, подзадача получает файлы своей прежней подзадачи как есть. Иначе для неё собираются пересечения со всеми старыми файлами.

apache/flink@release-2.2.1 · StateAssignmentOperation.java, строки 315-330
if (newParallelism == oldParallelism) {
    if (operatorState.getState(subTaskIndex) != null) {
        subManagedKeyedState =
                operatorState.getState(subTaskIndex).getManagedKeyedState().asList();
        subRawKeyedState = operatorState.getState(subTaskIndex).getRawKeyedState().asList();
    } else {
        subManagedKeyedState = emptyList();
        subRawKeyedState = emptyList();
    }
} else {
    subManagedKeyedState =
            getManagedKeyedStateHandles(
                    operatorState, keyGroupPartitions.get(subTaskIndex));
    subRawKeyedState =
            getRawKeyedStateHandles(operatorState, keyGroupPartitions.get(subTaskIndex));
}

Диапазоны для нового параллелизма нарезаются той же функцией, что используется при обработке записей.

apache/flink@release-2.2.1 · StateAssignmentOperation.java, строки 680-691
public static List<KeyGroupRange> createKeyGroupPartitions(
        int numberKeyGroups, int parallelism) {
    Preconditions.checkArgument(numberKeyGroups >= parallelism);
    List<KeyGroupRange> result = new ArrayList<>(parallelism);

    for (int i = 0; i < parallelism; ++i) {
        result.add(
                KeyGroupRangeAssignment.computeKeyGroupRangeForOperatorIndex(
                        numberKeyGroups, parallelism, i));
    }
    return result;
}
apache/flink@release-2.2.1 · KeyGroupRangeAssignment.java, строки 93-106
public static KeyGroupRange computeKeyGroupRangeForOperatorIndex(
        int maxParallelism, int parallelism, int operatorIndex) {

    checkParallelismPreconditions(parallelism);
    checkParallelismPreconditions(maxParallelism);

    Preconditions.checkArgument(
            maxParallelism >= parallelism,
            "Maximum parallelism must not be smaller than parallelism.");

    int start = ((operatorIndex * maxParallelism + parallelism - 1) / parallelism);
    int end = ((operatorIndex + 1) * maxParallelism - 1) / parallelism;
    return new KeyGroupRange(start, end);
}

Комментарий к createKeyGroupPartitions требует, чтобы эта нарезка совпадала с KeyGroupStreamPartitioner. Иначе запись пришла бы в подзадачу, у которой нет состояния её ключа. Формулы диапазона и computeOperatorIndexForKeyGroup из урока 4 согласованы, и симуляция выше считает по ним обеим.

Состояние без ключа (operator state), например список назначенных источнику партиций Kafka, на группы не делится. В том же методе его раздаёт по кругу RoundRobinOperatorStateRepartitioner.

Предел, заданный при первом запуске

Число групп, оно же максимальный параллелизм (maxParallelism), записано в снимке у каждого оператора. При восстановлении оно сверяется с новым графом дважды.

В исходниках

Первая проверка. Подзадач не может быть больше, чем групп в снимке.

apache/flink@release-2.2.1 · StateAssignmentOperation.java, строки 700-713
private static void checkParallelismPreconditions(
        OperatorState operatorState, ExecutionJobVertex executionJobVertex) {
    // ----------------------------------------max parallelism
    // preconditions-------------------------------------

    if (operatorState.getMaxParallelism() < executionJobVertex.getParallelism()) {
        throw new IllegalStateException(
                "The state for task "
                        + executionJobVertex.getJobVertexId()
                        + " can not be restored. The maximum parallelism ("
                        + operatorState.getMaxParallelism()
                        + ") of the restored state is lower than the configured parallelism ("
                        + executionJobVertex.getParallelism()
                        + "). Please reduce the parallelism of the task to be lower or equal to the maximum parallelism.");

Вторая проверка касается самого числа групп. Если ты его не задавал, планировщик вычислил значение сам и пометил его как автоматическое. Такое значение разрешено заменить числом из снимка. Заданное явно заменить нельзя.

apache/flink@release-2.2.1 · SchedulerBase.java, строки 357-377
int maxParallelism = vertex.getMaxParallelism();
final boolean autoConfigured;
// if no max parallelism was configured by the user, we calculate and set a default
if (maxParallelism == JobVertex.MAX_PARALLELISM_DEFAULT) {
    maxParallelism = defaultMaxParallelismFunc.apply(vertex);
    autoConfigured = true;
} else {
    autoConfigured = false;
}

VertexParallelismInformation parallelismInfo =
        new DefaultVertexParallelismInfo(
                parallelism,
                maxParallelism,
                // Allow rescaling if the max parallelism was not set explicitly by the
                // user
                (newMax) ->
                        autoConfigured
                                ? Optional.empty()
                                : Optional.of(
                                        "Cannot override a configured max parallelism."));

Наш проект maxParallelism не задаёт, поэтому новое задание молча примет 128 групп из снимка, даже если по формуле из урока 4 для нового параллелизма вышло бы 256. Если же в новой версии появится env.setMaxParallelism(1024) при снимке со 128 группами, loadAndValidateCheckpoint бросит исключение с текстом Max parallelism mismatch between checkpoint/savepoint state and new program. Сменить число групп у работающего состояния можно только переписав снимок через State Processor API.

Сравнение со Spark

В Spark Structured Streaming состояние живёт в checkpoint location конкретного запроса. Оно разбито на spark.sql.shuffle.partitions частей, и это число нельзя менять после первого запуска. Список изменений запроса, с которыми можно продолжить с того же каталога, задан в документации Spark, и смена ключей или агрегатов stateful-операции туда не входит.

У Flink снимок отделён от программы и привязан к операторам через uid. Параллелизм меняется при каждом запуске в пределах числа групп. Тип состояния может эволюционировать по правилам сериализатора, а всё остальное можно переписать отдельной пакетной программой.

Снимок как база данных

Документация State Processor API предлагает смотреть на savepoint как на базу данных. Каждый uid служит отдельной схемой. Состояние с ключом одного оператора превращается в таблицу, где одна колонка хранит ключ и по колонке приходится на каждое состояние. Читать такую базу можно обычной программой DataStream в режиме BATCH. Тем же API можно записать новый снимок, например заполнить состояние из истории до первого запуска, сменить uid или число групп. В задании урока ты напишешь инспектор, который печатает самые активные карты прямо из снимка. Запущенное задание ему не нужно.

Проверь себя

В record CardProfile переименовали totalAmountMinor в totalMinor и запустили задание из savepoint. Что произойдёт?

Задание год работало с параллелизмом 2 и автоматическим maxParallelism 128. Команда добавила env.setMaxParallelism(1024) «на вырост» и запускает из savepoint с -p 8. Что будет?

Из задания убрали правило rule-amount-spike с состоянием по картам. Запуск из savepoint падает со словами «operator is not available in the new program». Что делать?

Что будет в конце

В PayGuard появится оператор card-profile, который ведёт профиль каждой карты. Ты запустишь первую версию, остановишь задание со savepoint, добавишь в профиль поле и продолжишь с того же места. Потом ещё раз остановишь задание и запустишь его с параллелизмом 4. Файл StateInspector.java и зависимость flink-state-processor-api относятся к заданию, их пока создавать не нужно.

Шаг 1. Профиль карты, первая версия

Создай src/main/java/dev/study/payguard/model/CardProfile.java. Ниже состояние файла до правки. В снапшоте урока его нет, потому что на шаге 6 ты заменишь его второй версией.

model/CardProfile.java, версия 1, до правки
package dev.study.payguard.model;

/** Накопленный профиль карты. Лежит в состоянии, поэтому менять его можно только по правилам эволюции схемы. */
public record CardProfile(long paymentCount, long totalAmountMinor) {

    public static final CardProfile EMPTY = new CardProfile(0, 0);

    public CardProfile plus(PaymentEvent payment) {
        return new CardProfile(paymentCount + 1, totalAmountMinor + payment.amountMinor());
    }
}

Record неизменяемый, поэтому метод plus возвращает новый профиль. Константа EMPTY избавляет функцию от проверок на пустое значение.

Шаг 2. Функция профиля

Создай src/main/java/dev/study/payguard/pipeline/CardProfileFunction.java. Этот файл не изменится во второй версии.

src/main/java/dev/study/payguard/pipeline/CardProfileFunction.java
package dev.study.payguard.pipeline;

import dev.study.payguard.model.CardProfile;
import dev.study.payguard.model.PaymentEvent;
import java.io.IOException;
import org.apache.flink.api.common.functions.OpenContext;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;

/** Ведёт профиль каждой карты. На выход отдаёт профиль после обновления. */
public class CardProfileFunction extends KeyedProcessFunction<String, PaymentEvent, CardProfile> {

    public static final String UID = "card-profile";
    public static final String STATE_NAME = "profile";

    private transient ValueState<CardProfile> profile;

    @Override
    public void open(OpenContext context) {
        profile = getRuntimeContext().getState(new ValueStateDescriptor<>(STATE_NAME, CardProfile.class));
    }

    @Override
    public void processElement(PaymentEvent payment, Context context, Collector<CardProfile> out) throws IOException {
        CardProfile current = profile.value();
        CardProfile updated = (current == null ? CardProfile.EMPTY : current).plus(payment);
        profile.update(updated);
        out.collect(updated);
    }
}
КОД
public class CardProfileFunction extends KeyedProcessFunction<String, PaymentEvent, CardProfile> {

    public static final String UID = "card-profile";
    public static final String STATE_NAME = "profile";

    private transient ValueState<CardProfile> profile;

    @Override
    public void open(OpenContext context) {
        profile = getRuntimeContext().getState(new ValueStateDescriptor<>(STATE_NAME, CardProfile.class));
    }

    @Override
    public void processElement(PaymentEvent payment, Context context, Collector<CardProfile> out) throws IOException {
        CardProfile current = profile.value();
        CardProfile updated = (current == null ? CardProfile.EMPTY : current).plus(payment);
        profile.update(updated);
        out.collect(updated);
    }
ПРОСТЫМИ СЛОВАМИ

Функция работает на потоке с ключом. Ключ это номер карты, на вход приходит платёж, на выход профиль.

Две публичные константы. UID станет надписью на коробке со состоянием оператора, STATE_NAME именем состояния внутри неё. Инспектор из задания прочитает снимок по этим же строкам, поэтому они вынесены в одно место.

Ссылка на состояние объявлена transient. Сама функция сериализуется при отправке на кластер, а состояние ей выдаёт среда выполнения в open.

Дескриптор описывает состояние именем и классом значения. По классу Flink строит PojoSerializer, и его снимок попадёт в savepoint вместе с данными.

На каждый платёж читаем профиль текущей карты. Для новой карты там null, и мы начинаем с EMPTY.

Записываем обновлённый профиль обратно и отдаём его дальше по потоку.

Шаг 3. Подключи оператор к заданию

Профили будут уходить в новый топик card.profiles. Добавь его имя в настройки задания.

что изменилось в src/main/java/dev/study/payguard/job/JobConfig.java
@@ -10,6 +10,7 @@         String dlqTopic,
         String lateTopic,
         String cardSessionsTopic,
+        String cardProfilesTopic,         String alertsTopic,
         String alertsTransactionalIdPrefix,
         String rulesTopic,
@@ -27,6 +28,7 @@                 params.get("dlq-topic", "payments.dlq"),
                 params.get("late-topic", "payments.late"),
                 params.get("card-sessions-topic", "card.sessions"),
+                params.get("card-profiles-topic", "card.profiles"),                 params.get("alerts-topic", "fraud.alerts"),
                 params.get("alerts-transactional-id-prefix", "payguard-alerts"),
                 params.get("rules-topic", "rules.config"),

В задании оператор встаёт на поток byCard, уже разбитый по карте. Тот же поток кормит правила, так что новый keyBy не нужен.

что изменилось в src/main/java/dev/study/payguard/job/PayGuardJob.java
@@ -1,5 +1,6 @@ package dev.study.payguard.job;
 
+import dev.study.payguard.model.CardProfile; import dev.study.payguard.model.CardSession;
 import dev.study.payguard.model.ConfirmedPayment;
 import dev.study.payguard.model.FraudAlert;
@@ -11,6 +12,7 @@ import dev.study.payguard.model.RuleConfig;
 import dev.study.payguard.pipeline.AlertEnrichment;
 import dev.study.payguard.pipeline.AmountSpikeRule;
+import dev.study.payguard.pipeline.CardProfileFunction; import dev.study.payguard.pipeline.CardSessionWindow;
 import dev.study.payguard.pipeline.DeduplicateFunction;
 import dev.study.payguard.pipeline.DynamicRules;
@@ -60,6 +62,7 @@         attachCardSessions(payments, config);
 
         KeyedStream<PaymentEvent, String> byCard = payments.keyBy(PaymentEvent::cardId);
+        attachCardProfiles(byCard, config); 
         DataStream<PaymentConfirmation> confirmations = env
                 .fromSource(confirmationSource(config), confirmationWatermarks(), "payment-confirmations")
@@ -112,6 +115,15 @@                 .sinkTo(KafkaSinks.<CardSession>atLeastOnceJson(config.bootstrapServers(), config.cardSessionsTopic()))
                 .name("card-sessions-sink")
                 .uid("card-sessions-sink");
+    }++    private static void attachCardProfiles(KeyedStream<PaymentEvent, String> byCard, JobConfig config) {+        byCard.process(new CardProfileFunction())+                .name("card-profile")+                .uid(CardProfileFunction.UID)+                .sinkTo(KafkaSinks.<CardProfile>atLeastOnceJson(config.bootstrapServers(), config.cardProfilesTopic()))+                .name("card-profiles-sink")+                .uid("card-profiles-sink");     }
 
     private static void attachConfirmedPayments(

У оператора и у приёмника свои uid. Приёмник Kafka тоже хранит состояние, поэтому без uid он при следующей правке графа потерял бы его. Гарантия at-least-once здесь достаточна, повтор профиля после сбоя просто перезапишет то же значение у потребителя. Топик создаёт служба kafka-init, допиши его в список.

что изменилось в docker-compose.yml
@@ -64,7 +64,7 @@     entrypoint: ["/bin/bash", "-c"]
     command:
       - |
-        for topic in payments.raw payments.dlq payments.late payments.confirmations payments.confirmed card.sessions fraud.alerts rules.config; do+        for topic in payments.raw payments.dlq payments.late payments.confirmations payments.confirmed card.sessions card.profiles fraud.alerts rules.config; do           /opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 \
             --create --if-not-exists --topic "$$topic" --partitions 4 --replication-factor 1
         done

Шаг 4. Запусти первую версию

терминал
./gradlew shadowJar
docker compose up -d --wait
scripts/submit.sh
./gradlew loadgen --args="--rate 200 --dup 0.02 --bad 0.01 --burst 0.002"

Compose пересоздаст kafka-init, потому что изменилась его команда, и тот создаст card.profiles. Остальные команды знакомы по прошлым урокам. Генератор оставь работать во втором терминале. В списке вершин задания появится новая, оператор и приёмник сцеплены в одну цепочку.

терминал
JOB=$(curl -s localhost:8081/jobs | jq -r '.jobs[] | select(.status == "RUNNING") | .id')
curl -s localhost:8081/jobs/$JOB | jq -c '.vertices[] | select(.name | startswith("card-profile")) | {name, parallelism, status}'
вывод
{"name":"card-profile -> card-profiles-sink: Writer -> card-profiles-sink: Committer","parallelism":2,"status":"RUNNING"}

Подожди минуту, чтобы профили накопились, и проверь, что чекпоинты идут. Команда та же, что в уроке 11, счётчик restored пока равен нулю.

Шаг 5. Останови задание со savepoint

Создай scripts/stop-with-savepoint.sh и сделай его исполняемым.

scripts/stop-with-savepoint.sh
#!/usr/bin/env bash
# Останавливает задание, сняв savepoint, и печатает путь к нему. Этот путь нужен для запуска новой версии.
set -euo pipefail
REST="${FLINK_REST:-http://localhost:8081}"

job_id=$(curl -s "$REST/jobs" | jq -r '[.jobs[] | select(.status == "RUNNING")][0].id')
if [ "$job_id" = "null" ]; then
  echo "Нет запущенных заданий" >&2
  exit 1
fi

docker compose exec -T jobmanager flink stop --type canonical "$job_id" \
  | grep -oE 'file:[^ ]+savepoint-[^ ]+' | sed 's/\.$//'

Скрипт находит запущенное задание через REST и вызывает flink stop внутри контейнера JobManager. Флаг -T у docker compose exec отключает псевдотерминал, иначе в вывод попадут управляющие символы и grep не найдёт путь. Флаг --type canonical выбирает переносимый формат. Каталог снимка не передаём, его задаёт execution.checkpointing.savepoint-dir из docker-compose.yml. CLI печатает строку вида Savepoint completed. Path: ..., grep вырезает из неё путь, sed срезает точку в конце.

терминал
chmod +x scripts/stop-with-savepoint.sh
SP=$(scripts/stop-with-savepoint.sh)
echo "$SP"
вывод
file:/flink-data/savepoints/savepoint-1687c2-d2b5454c1f19

Имя каталога собрано из начала идентификатора задания и случайного суффикса. Путь указан изнутри контейнера. На твоей машине тот же каталог лежит в data/savepoints проекта, потому что ./data смонтирована в /flink-data. Внутри есть файл _metadata со списком операторов и снимками сериализаторов, рядом лежат файлы с данными состояния. Задание после команды перешло в FINISHED. Генератор продолжает писать в Kafka, и эти платежи никуда не денутся, новая версия начнёт читать со смещений, сохранённых в снимке.

Шаг 6. Вторая версия профиля

Замени CardProfile.java. В record появилось поле lastCountry, константа и метод plus его учитывают.

src/main/java/dev/study/payguard/model/CardProfile.java
package dev.study.payguard.model;

/** Накопленный профиль карты. Лежит в состоянии, поэтому менять его можно только по правилам эволюции схемы. */
public record CardProfile(long paymentCount, long totalAmountMinor, String lastCountry) {

    public static final CardProfile EMPTY = new CardProfile(0, 0, null);

    public CardProfile plus(PaymentEvent payment) {
        return new CardProfile(paymentCount + 1, totalAmountMinor + payment.amountMinor(), payment.country());
    }
}

Поле добавлено в конец, но порядок здесь не важен. Сериализатор сопоставляет поля по имени, а перестановку аргументов конструктора берёт на себя JavaRecordBuilderFactory. Тип String ссылочный, поэтому у восстановленных профилей поле будет null, пока по карте не придёт новый платёж. Теперь добавь тест функции.

src/test/java/dev/study/payguard/pipeline/CardProfileFunctionTest.java
package dev.study.payguard.pipeline;

import static dev.study.payguard.testing.Payments.payment;
import static org.assertj.core.api.Assertions.assertThat;

import dev.study.payguard.model.CardProfile;
import dev.study.payguard.model.PaymentEvent;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.streaming.util.KeyedOneInputStreamOperatorTestHarness;
import org.apache.flink.streaming.util.ProcessFunctionTestHarnesses;
import org.junit.jupiter.api.Test;

class CardProfileFunctionTest {

    @Test
    void accumulatesPerCard() throws Exception {
        KeyedOneInputStreamOperatorTestHarness<String, PaymentEvent, CardProfile> harness =
                ProcessFunctionTestHarnesses.forKeyedProcessFunction(
                        new CardProfileFunction(), PaymentEvent::cardId, Types.STRING);

        harness.processElement(payment("p1", "c1", "m1", 100, 1_000), 1_000);
        harness.processElement(payment("p2", "c1", "m1", 200, 2_000), 2_000);

        assertThat(harness.extractOutputValues()).last().isEqualTo(new CardProfile(2, 300, "DE"));
    }
}

Тест прогоняет два платежа одной карты через harness из урока 7 и проверяет последний профиль. Страна DE берётся из фабрики тестовых платежей. На нашем стенде прошли все 40 тестов проекта.

терминал
./gradlew test shadowJar
docker compose exec -T jobmanager flink run --detached \
  --fromSavepoint "$SP" \
  /jobs/payguard-1.0.0.jar

Папка build/libs смонтирована в контейнер, поэтому новый jar виден сразу, пересоздавать контейнеры не нужно. Скрипт submit.sh здесь не подходит, он передаёт свои аргументы заданию, а флаг --fromSavepoint нужен самому CLI и должен стоять до пути к jar. Короткая форма флага -s. Если бы мы удалили оператор, сюда же добавился бы --allowNonRestoredState.

терминал
JOB=$(curl -s localhost:8081/jobs | jq -r '.jobs[] | select(.status == "RUNNING") | .id')
curl -s localhost:8081/jobs/$JOB/checkpoints | jq '.counts.restored, .latest.restored.external_path'
curl -s localhost:8081/jobs/$JOB/exceptions | jq '.exceptionHistory.entries | length'

Первая команда покажет 1 восстановление и путь к снимку, вторая 0, то есть исключений не было. Так и было на нашем стенде, запуск второй версии из снимка первой прошёл без ошибок. Посмотри на сами профили.

терминал
docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 --topic card.profiles --from-beginning --max-messages 3 2>/dev/null

С начала топика ты увидишь записи первой версии без поля страны. Записи второй версии выглядят так.

вывод
{"paymentCount":1,"totalAmountMinor":13896,"lastCountry":"ES"}
{"paymentCount":1,"totalAmountMinor":11869,"lastCountry":"PL"}
{"paymentCount":1,"totalAmountMinor":39786,"lastCountry":"ES"}

В записи нет номера карты, приёмник пишет только значение. Топик здесь служит следом работы оператора. Полную картину по картам даст инспектор из задания. Профиль уходит в топик после каждого платежа, поэтому lastCountry в топике всегда заполнено. Значение null живёт только в состоянии у карт, которые с момента релиза молчат.

Шаг 7. Смени параллелизм

терминал
SP=$(scripts/stop-with-savepoint.sh)
docker compose exec -T jobmanager flink run --detached -p 4 \
  --fromSavepoint "$SP" \
  /jobs/payguard-1.0.0.jar
curl -s localhost:8081/overview | jq '{slots: .["slots-total"], free: .["slots-available"]}'

Флаг -p 4 заменяет parallelism.default: 2 для всех операторов, у которых параллелизм не задан в коде. Все вершины получили по 4 подзадачи и уместились в 4 слота единственного TaskManager благодаря slot sharing из урока 2. На нашем стенде заняты все 4 слота из 4, задание поднялось из снимка без ошибок.

Состояние профилей было в двух подзадачах, с группами 0-63 и 64-127. Теперь диапазонов четыре, 0-31, 32-63, 64-95 и 96-127. Подзадачи 0 и 1 берут половины старой подзадачи 0, подзадачи 2 и 3 половины старой подзадачи 1. Это та же картина, что в симуляции на вкладке теории при 2 и 4. Значение maxParallelism мы не задавали, поэтому для параллелизма 4 планировщик и так вычислил 128, и спорить со снимком ему не пришлось.

Что пойдёт не так

Мы не ловили эти ошибки на стенде, но их тексты заданы в исходниках, которые разобраны на вкладке теории. По фрагменту сообщения легко понять, какая проверка сработала.

Что сделалиФрагмент сообщенияКак лечить
Удалили оператор с состоянием или сменили его uidCannot map checkpoint/savepoint state for operatorВернуть старый uid. Если удаление намеренное, запустить с --allowNonRestoredState
Задали maxParallelism, отличный от снимкаMax parallelism mismatch between checkpoint/savepoint state and new programУбрать настройку или задать то же число, что в снимке
Подзадач больше, чем группThe state for task ... can not be restored. The maximum parallelismУменьшить параллелизм до числа групп

Задание. Инспектор состояния

Дежурному нужно знать, какие карты самые активные, и он не хочет ради этого трогать запущенное задание. Напиши класс dev.study.payguard.job.StateInspector, который читает savepoint через State Processor API.

  1. Добавь в build.gradle.kts зависимость org.apache.flink:flink-state-processor-api той же версии, что Flink. Подумай, в какую конфигурацию её положить. В дистрибутиве Flink этот jar лежит в opt/, в lib/ его нет.
  2. Путь к снимку принимай аргументом --savepoint.
  3. Прочитай состояние с ключом оператора card-profile и собери пары «карта и профиль».
  4. Напечатай общее число карт в состоянии и пять карт с наибольшим числом платежей.
  5. Запусти инспектор на кластере через flink run с флагом -c на снимке второй версии. Учти, что инспектор тоже задание и ему нужен свободный слот.

Готово, если команда завершается без исключений и печатает строку с числом карт и пять строк топа, отсортированных по убыванию paymentCount.

Подсказки. Строки uid и имя состояния уже вынесены в CardProfileFunction, бери их оттуда. State Processor API работает только в режиме BATCH. Дескриптор состояния в читателе должен совпадать с дескриптором в функции по имени и классу. У DataStream есть несколько способов забрать результат в клиент, и один из них ведёт себя неожиданно при большом лимите. Прочитай его код до того, как выбирать.

Ответ

Глоссарий урока

Savepoint
Снимок состояния задания, который создаёшь ты, командой или через REST. Flink его не удаляет. Нужен для смены кода, параллелизма, версии Flink и бэкенда состояния.
Чекпоинт (checkpoint)
Снимок, который Flink снимает сам по интервалу и удаляет, когда он больше не нужен. Предназначен для восстановления после сбоя.
Формат canonical
Формат savepoint, единый для всех бэкендов состояния. Выбирается по умолчанию, позволяет сменить бэкенд и лучше всего переживает эволюцию схемы.
Формат native
Формат savepoint, родной для бэкенда, у RocksDB это SST-файлы. Быстрее создаётся и восстанавливается, но не позволяет сменить бэкенд.
uid
Постоянный идентификатор оператора, заданный в коде. Из него хешем получается OperatorID, по которому состояние в снимке находит оператор.
OperatorID
16-байтовый идентификатор оператора внутри Flink. Получается хешем murmur3 от uid или выводится из положения оператора в графе.
stop
Команда CLI, которая снимает savepoint и после его успеха завершает источники. Задание переходит в FINISHED.
cancel
Команда CLI, которая останавливает задание сразу, без savepoint.
--allowNonRestoredState
Флаг запуска, который разрешает выбросить состояние операторов, отсутствующих в новой программе. Короткая форма -n.
--drain и MAX_WATERMARK
Флаг команды stop, который перед последним барьером отправляет максимальный watermark. Срабатывают все таймеры по времени события. Подходит только для окончательной остановки.
Эволюция схемы (schema evolution)
Изменение типа состояния между версиями программы с сохранением данных. Для POJO и Avro Flink мигрирует состояние сам.
Снимок сериализатора (serializer snapshot)
Описание формата, в котором записано состояние. Лежит в снимке рядом с данными. По нему новый сериализатор решает, совместим ли он со старыми байтами.
POJO
Обычный Java-класс, который Flink разбирает на поля и сериализует сам. Record во Flink 2.2 обрабатывается как POJO.
Record
Неизменяемый класс Java с компонентами в заголовке. Компилятор сам создаёт конструктор, геттеры, equals и hashCode.
Kryo
Универсальная библиотека сериализации, которой Flink пользуется для незнакомых типов. Не поддерживает эволюцию схемы. В PayGuard запрещена настройкой pipeline.generic-types: false.
Группа ключей (key group)
Единица разбиения ключей и состояния. Номер группы зависит только от ключа и числа групп, поэтому при смене параллелизма состояние переносится целыми группами.
Максимальный параллелизм (maxParallelism)
Число групп ключей оператора и верхняя граница его параллелизма. Записано в снимке. Автоматически вычисленное значение уступает числу из снимка, заданное явно должно с ним совпадать.
Rescale
Запуск задания из снимка с другим параллелизмом. Состояние с ключом раздаётся новыми диапазонами групп, состояние без ключа по кругу.
Состояние без ключа (operator state)
Состояние, привязанное к подзадаче оператора, например список партиций источника. При rescale раздаётся по кругу.
State Processor API
Библиотека Flink для чтения, записи и правки снимков пакетной программой. Подключается артефактом flink-state-processor-api.
Режим BATCH
Режим выполнения для конечных данных. Программа завершается, когда вход прочитан. State Processor API работает только в нём.
CloseableIterator
Итератор Flink, который нужно закрыть после использования. Его возвращает executeAndCollect(), закрытие освобождает ресурсы кластера.
RocksDB
Встраиваемое хранилище ключ-значение на диске. Бэкенд состояния PayGuard с урока 11.
Apache Kafka
Распределённый журнал сообщений. Смещения чтения источника хранятся в снимке, поэтому новая версия задания продолжает с того же места.
Slot sharing
Размещение подзадач разных операторов в одном слоте. Благодаря ему задание с параллелизмом 4 помещается в 4 слота.

Урок 14 · SQL и интеграции

Flink SQL. Потоковый запрос, поток изменений и отдельный кластер

После урока ты сможешь прочитать changelog и план SQL-задания, предсказать размер его состояния и запустить выручку продавцов из Kafka в PostgreSQL чистым SQL на отдельном кластере.

Отчёт, ради которого жалко писать оператор

Финансовому отделу нужна выручка каждого продавца по минутам в PostgreSQL. Завтра они попросят такую же выручку по уровню риска. На DataStream API каждый новый срез означает окно, функцию агрегации, приёмник и ревью Java-кода. Flink SQL описывает эту задачу одним запросом, и работает он на том же рантайме, с теми же чекпоинтами и теми же бэкендами состояния.

Запрос на потоке устроен иначе, чем запрос к базе, потому что он никогда не заканчивается. Отсюда три вопроса, из-за которых SQL-задания падают и разрастаются в проде. Что приходит в приёмник, когда уже отправленная строка результата изменилась? Где лежит состояние запроса и кто его чистит? Откуда кластер берёт коннекторы, если jar с кодом больше нет?

Турнирная таблица

Представь футбольный чемпионат. Матчи идут один за другим, это поток событий. Турнирная таблица это результат запроса над ними. После каждого матча её не считают заново с первого тура, к строке команды прибавляют новые очки.

Сообщить читателям об изменении можно тремя способами. Газета печатает поправку из двух строк, «снять Ротор 10 очков» и «поставить Ротор 13 очков». Табло на стадионе находит строку по названию команды и перезаписывает её. Итоги тура публикуют один раз, когда все матчи сыграны, и правок к ним не бывает. Во Flink этим способам соответствуют три режима потока изменений.

Динамическая таблица и changelog

Каждая запись топика для Flink SQL это вставка в таблицу. Такая таблица называется динамической (dynamic table). Внутри она не материализована, это только взгляд на поток. Запрос над ней называется непрерывным (continuous query). В любой момент его результат по смыслу равен результату такого же пакетного запроса над снимком входа.

Результат запроса тоже динамическая таблица. Наружу она выходит как changelog, то есть поток изменений, где каждая строка несёт пометку вида изменения. Этих пометок четыре.

ПометкаЗначение в RowKindЧто сообщает
+IINSERTПоявилась новая строка
-UUPDATE_BEFOREСтарое значение строки, которая сейчас изменится
+UUPDATE_AFTERНовое значение строки
-DDELETEСтрока удалена

Прогони девять платежей через три варианта запроса. Посмотри, сколько записей уходит в приёмник на одни и те же данные и что происходит с состоянием. Водяной знак в модели отстаёт на 10 секунд и двигается с каждой записью, а платёж со временем 00:58 приходит с опозданием.

Поток превращается в таблицу

Поток платежей

Таблица результата

Changelog

Первый вариант похож на газету с поправками и называется retract. Изменение кодируется двумя записями, -U и +U. Второй похож на табло и называется upsert. Изменение занимает одну запись +U, зато приёмник обязан знать уникальный ключ. Третий вариант называется append-only, в нём есть только +I, как в итогах тура.

В исходниках

Четыре пометки это обычное перечисление в ядре Flink. Посмотри на комментарии к двум видам обновления, они объясняют, зачем нужен -U.

apache/flink@release-2.2.1 · RowKind.java, строки 30-52
/** Insertion operation. */
INSERT("+I", (byte) 0),

/**
 * Update operation with the previous content of the updated row.
 *
 * <p>This kind SHOULD occur together with {@link #UPDATE_AFTER} for modelling an update that
 * needs to retract the previous row first. It is useful in cases of a non-idempotent update,
 * i.e., an update of a row that is not uniquely identifiable by a key.
 */
UPDATE_BEFORE("-U", (byte) 1),

/**
 * Update operation with new content of the updated row.
 *
 * <p>This kind CAN occur together with {@link #UPDATE_BEFORE} for modelling an update that
 * needs to retract the previous row first. OR it describes an idempotent update, i.e., an
 * update of a row that is uniquely identifiable by a key.
 */
UPDATE_AFTER("+U", (byte) 2),

/** Deletion operation. */
DELETE("-D", (byte) 3);

Запись -U нужна, когда строку нельзя однозначно найти по ключу. Если ключ есть, обновление идемпотентно, то есть его повтор не меняет результат, и хватает +U. Именно так определены готовые наборы видов строк.

apache/flink@release-2.2.1 · ChangelogMode.java, строки 41-63
private static final ChangelogMode UPSERT =
        ChangelogMode.newBuilder()
                .addContainedKind(RowKind.INSERT)
                .addContainedKind(RowKind.UPDATE_AFTER)
                .addContainedKind(RowKind.DELETE)
                .keyOnlyDeletes(true)
                .build();

private static final ChangelogMode UPSERT_WITH_FULL_DELETES =
        ChangelogMode.newBuilder()
                .addContainedKind(RowKind.INSERT)
                .addContainedKind(RowKind.UPDATE_AFTER)
                .addContainedKind(RowKind.DELETE)
                .keyOnlyDeletes(false)
                .build();

private static final ChangelogMode ALL =
        ChangelogMode.newBuilder()
                .addContainedKind(RowKind.INSERT)
                .addContainedKind(RowKind.UPDATE_BEFORE)
                .addContainedKind(RowKind.UPDATE_AFTER)
                .addContainedKind(RowKind.DELETE)
                .build();

В наборе UPSERT нет UPDATE_BEFORE, в наборе ALL есть все четыре вида. Этим классом коннектор сообщает планировщику, какие строки он выдаёт или принимает.

Вид потока ты не выбираешь руками. Его выводит планировщик, и по этому выводу понятно, примет ли запрос твой приёмник. JDBC-коннектор с PRIMARY KEY в DDL работает в режиме upsert. Без первичного ключа он работает в режиме append и обновлений принять не может. Для приёмника в PostgreSQL первичный ключ полезен и при чистых вставках. Если после восстановления окно будет записано повторно, строка перезапишется и дубля не появится.

В исходниках

Программа вывода режимов обходит план и для каждого узла решает, какие изменения он производит. Сравни обычную агрегацию и оконную.

apache/flink@release-2.2.1 · FlinkChangelogModeInferenceProgram.scala, строки 192-207
case agg: StreamPhysicalGroupAggregate =>
  // agg support all changes in input
  val children = visitChildren(agg, ModifyKindSetTrait.ALL_CHANGES)
  val inputModifyKindSet = getModifyKindSet(children.head)
  val builder = ModifyKindSet
    .newBuilder()
    .addContainedKind(ModifyKind.INSERT)
    .addContainedKind(ModifyKind.UPDATE)
  if (
    inputModifyKindSet.contains(ModifyKind.UPDATE) ||
    inputModifyKindSet.contains(ModifyKind.DELETE)
  ) {
    builder.addContainedKind(ModifyKind.DELETE)
  }
  val providedTrait = new ModifyKindSetTrait(builder.build())
  createNewNode(agg, children, providedTrait, requiredTrait, requester)
apache/flink@release-2.2.1 · FlinkChangelogModeInferenceProgram.scala, строки 244-255
case window: StreamPhysicalWindowAggregate =>
  // WindowAggregate and WindowTableAggregate support all changes in input
  val children = visitChildren(window, ModifyKindSetTrait.ALL_CHANGES)
  // TODO support early / late fire and then this node may produce update records
  val providedTrait = new ModifyKindSetTrait(ModifyKindSet.INSERT_ONLY)
  createNewNode(window, children, providedTrait, requiredTrait, requester)

case _: StreamPhysicalWindowRank | _: StreamPhysicalWindowDeduplicate =>
  // WindowAggregate, WindowRank, WindowDeduplicate support insert-only in input
  val children = visitChildren(rel, ModifyKindSetTrait.INSERT_ONLY)
  val providedTrait = ModifyKindSetTrait.INSERT_ONLY
  createNewNode(rel, children, providedTrait, requiredTrait, requester)

Обычный GROUP BY всегда объявляет INSERT и UPDATE, а если на входе были обновления, то ещё и DELETE. Оконная агрегация и WindowRank объявляют только вставки. Комментарий TODO объясняет причину, в этом узле пока нет раннего и позднего срабатывания окна.

Оконные TVF

TVF (table-valued function) это функция, которая принимает таблицу и возвращает таблицу. Функция TUMBLE добавляет к входным колонкам три новых, window_start, window_end и window_time. Есть ещё HOP, CUMULATE и SESSION. Запрос нашего проекта выглядит так.

sql/merchant_revenue.sql (строки 38-41)
INSERT INTO merchant_revenue_1m
SELECT merchantId, window_start, COUNT(*), SUM(amountMinor)
FROM TABLE(TUMBLE(TABLE payments, DESCRIPTOR(event_time), INTERVAL '1' MINUTE))
GROUP BY merchantId, window_start, window_end;

Окном этот запрос делает группировка. В GROUP BY обязаны стоять обе колонки, начало и конец окна. Тогда планировщик выбирает специальный оператор, который выдаёт один итог на окно и потом освобождает состояние.

В исходниках

Проверка группировки занимает несколько строк. Смотри на последнее выражение, оно требует и начало, и конец.

apache/flink@release-2.2.1 · WindowUtil.scala, строки 59-72
/** Returns true if the grouping keys contain window_start and window_end properties. */
def groupingContainsWindowStartEnd(
    grouping: ImmutableBitSet,
    windowProperties: RelWindowProperties): Boolean = {
  if (windowProperties != null) {
    val windowStarts = windowProperties.getWindowStartColumns
    val windowEnds = windowProperties.getWindowEndColumns
    val hasWindowStart = !windowStarts.intersect(grouping).isEmpty
    val hasWindowEnd = !windowEnds.intersect(grouping).isEmpty
    hasWindowStart && hasWindowEnd
  } else {
    false
  }
}

Если забыть window_end, проверка вернёт false. Запрос при этом останется законным и превратится в обычную агрегацию с обновлениями и состоянием без срока жизни. Ошибки при запуске не будет.

Дальше узел плана собирает обычный оператор с состоянием по ключу. SQL здесь кончается, начинается тот же граф, что и в DataStream.

apache/flink@release-2.2.1 · StreamExecWindowAggregate.java, строки 224-240
final OneInputStreamOperator<RowData, RowData> windowOperator =
        windowAggOperatorBuilder.build();

final OneInputTransformation<RowData, RowData> transform =
        ExecNodeUtil.createOneInputTransformation(
                inputTransform,
                createTransformationMeta(WINDOW_AGGREGATE_TRANSFORMATION, config),
                SimpleOperatorFactory.of(windowOperator),
                InternalTypeInfo.of(getOutputType()),
                inputTransform.getParallelism(),
                WINDOW_AGG_MEMORY_RATIO,
                false);

// set KeyType and Selector for state
transform.setStateKeySelector(selector);
transform.setStateKeyType(selector.getProducedType());
return transform;

Ограничение оператора описано в комментарии к его классу.

apache/flink@release-2.2.1 · WindowAggOperator.java, строки 89-101
 * <p>Note: currently, {@link WindowAggOperator} doesn't support early-fire and late-arrival. Thus
 * late elements (elements belong to emitted windows) will be simply dropped.
 *
 * <p>See more in {@link SlicingSyncStateWindowProcessor} and {@link
 * UnslicingSyncStateWindowProcessor}.
 */
@Internal
public final class WindowAggOperator<K, W> extends TableStreamOperator<RowData>
        implements OneInputStreamOperator<RowData, RowData>, Triggerable<K, W>, KeyContext {

    private static final long serialVersionUID = 1L;

    private static final String LATE_ELEMENTS_DROPPED_METRIC_NAME = "numLateRecordsDropped";

Опоздавшая запись просто отбрасывается и увеличивает счётчик numLateRecordsDropped. В DataStream-задании проекта такие записи можно было поймать через sideOutputLateData, здесь этой возможности нет. Срабатывание таймера показывает, откуда берётся маленькое состояние окна.

apache/flink@release-2.2.1 · WindowAggOperator.java, строки 258-265
private void onTimer(InternalTimer<K, W> timer) throws Exception {
    setCurrentKey(timer.getKey());
    W window = timer.getNamespace();
    windowProcessor.fireWindow(timer.getTimestamp(), window);
    windowProcessor.clearWindow(timer.getTimestamp(), window);
    // we don't need to clear window timers,
    // because there should only be one timer for each window now, which is current timer.
}

Сразу за fireWindow идёт clearWindow. Итог вышел, состояние этого окна удалено.

План задания, Local и Global

Текст запроса разбирает и оптимизирует планировщик, построенный на Apache Calcite. Результат это граф операторов, который виден в Web UI или в выводе команды EXPLAIN. Если ты читал вывод explain() в Spark, то видел там partial_sum перед обменом и финальную сумму после него. Во Flink оконная агрегация делится так же, вершины называются LocalWindowAggregate и GlobalWindowAggregate.

Sourceподзадача 1 Sourceподзадача 2 LocalWindowAggregateбуфер в managed memory, без обмена LocalWindowAggregateмного записей m-0000, одна сумма GlobalWindowAggregateсостояние окна по ключу, итог GlobalWindowAggregateсостояние окна по ключу, итог сеть, хеш по ключутолько частичные суммы

Выигрыш особенно заметен на перекосе. У нас продавец m-0000 получает 30 % платежей. Без локальной фазы все его записи идут по сети в одну подзадачу, и каждая обращается к состоянию. С локальной фазой по сети идёт одна частичная сумма на ключ при каждом сбросе буфера.

В исходниках

Правило оптимизатора перечисляет условия разбиения на две фазы. Посмотри, что мешает ему сработать.

apache/flink@release-2.2.1 · TwoStageOptimizedWindowAggregateRule.java, строки 86-108
// the two-phase optimization must be enabled
if (getAggPhaseStrategy(tableConfig) == AggregatePhaseStrategy.ONE_PHASE) {
    return false;
}

// processing time window doesn't support two-phase,
// otherwise the processing-time can't be materialized in a single node
if (!windowing.isRowtime()) {
    return false;
}

// session window doesn't support two-phase,
// otherwise window assigner results may be different
if (windowing.getWindow() instanceof SessionWindowSpec) {
    return false;
}

// all aggregate function should support merge() method
if (!AggregateUtil.doAllSupportPartialMerge(windowAgg.aggInfoList().aggInfos())) {
    return false;
}

return !isInputSatisfyRequiredDistribution(realInput, windowAgg.grouping());

Две фазы отменяются при ONE_PHASE, для окон по времени обработки, для сессионных окон и для функций без операции слияния. SUM и COUNT из нашего запроса сливать можно. Значение table.optimizer.agg-phase-strategy по умолчанию равно AUTO, и на стенде запрос с окном разбился на две фазы без единой настройки.

Локальный узел собирает оператор с буфером.

apache/flink@release-2.2.1 · StreamExecLocalWindowAggregate.java, строки 194-200
WindowBuffer.LocalFactory bufferFactory =
        new RecordsWindowBuffer.LocalFactory(
                keySer, valueSer, new LocalAggCombiner.Factory(generatedAggsHandler));

final OneInputStreamOperator<RowData, RowData> localAggOperator =
        new LocalSlicingWindowAggOperator(
                selector, sliceAssigner, bufferFactory, shiftTimeZone);

Сброс буфера происходит в методах этого оператора.

apache/flink@release-2.2.1 · LocalSlicingWindowAggOperator.java, строки 108-135
@Override
public void processElement(StreamRecord<RowData> element) throws Exception {
    RowData inputRow = element.getValue();
    RowData key = keySelector.getKey(inputRow);
    long sliceEnd = sliceAssigner.assignSliceEnd(inputRow, CLOCK_SERVICE);
    // may flush to output if buffer is full
    windowBuffer.addElement(key, sliceEnd, inputRow);
}

@Override
public void processWatermark(Watermark mark) throws Exception {
    if (mark.getTimestamp() > currentWatermark) {
        currentWatermark = mark.getTimestamp();
        if (currentWatermark >= nextTriggerWatermark) {
            // we only need to call advanceProgress() when current watermark may trigger window
            windowBuffer.advanceProgress(currentWatermark);
            nextTriggerWatermark =
                    getNextTriggerWatermark(
                            currentWatermark, windowInterval, shiftTimezone, useDayLightSaving);
        }
    }
    super.processWatermark(mark);
}

@Override
public void prepareSnapshotPreBarrier(long checkpointId) throws Exception {
    windowBuffer.flush();
}

Комментарий в processElement называет первую причину, буфер переполнился. Вторая причина это водяной знак, который дошёл до конца окна. Третья это барьер чекпоинта, перед ним буфер сбрасывается целиком, и хранить его содержимое в снимке незачем.

Состояние, которого не видно в запросе

В DataStream ты сам объявляешь ValueState и сам ставишь таймер на его очистку. В SQL состояние заводит планировщик. Запрос GROUP BY merchantId помнит счётчик каждого продавца вечно, потому что новая запись может прийти в любой момент. Обычный JOIN двух потоков держит в состоянии обе таблицы целиком. Такие запросы выглядят безобидно и на карточках с миллионами значений заполняют диск.

Ограничить рост помогает table.exec.state.ttl, время хранения ключа без обновлений. На оконные операторы и interval join эта настройка не влияет, их состояние очищается, когда время проходит границу окна.

В исходниках

Посмотри на значение по умолчанию и на описание.

apache/flink@release-2.2.1 · ExecutionConfigOptions.java, строки 49-59
public static final ConfigOption<Duration> IDLE_STATE_RETENTION =
        key("table.exec.state.ttl")
                .durationType()
                .defaultValue(Duration.ofMillis(0))
                .withDescription(
                        "Specifies a minimum time interval for how long idle state "
                                + "(i.e. state which was not updated), will be retained. State will never be "
                                + "cleared until it was idle for less than the minimum time, and will be cleared "
                                + "at some time after it was idle. Default is never clean-up the state. "
                                + "NOTE: Cleaning up state requires additional overhead for bookkeeping. "
                                + "Default value is 0, which means that it will never clean up state.");

Ноль означает, что состояние не очистится никогда. Если задать час, ключ проживёт без обновлений минимум час и будет удалён когда-то после этого.

⚠
TTL меняет результат, и savepoint не спасёт правку запроса

После удаления состояния запрос забывает, что видел этот ключ. Счётчик продавца, молчавшего дольше TTL, начнётся заново с единицы и перезапишет строку в приёмнике. Отдельная проблема с обновлениями. Документация прямо говорит, что savepoint поддержан, только пока не меняются ни запрос, ни версия Flink. Новый фильтр может перестроить план, и состояние не найдёт своих операторов. Механизмы эволюции из урока 13 к SQL-заданиям не переносятся.

Mini-batch

Обычная агрегация обрабатывает записи по одной, то есть читает аккумулятор из состояния, обновляет и записывает обратно. На RocksDB это дорого, особенно на перекосе. Режим mini-batch копит пачку записей в буфере и обращается к состоянию один раз на ключ. Цена этого выигрыша равна допустимой задержке.

В исходниках

Три настройки включаются вместе. Обрати внимание на примечание у размера.

apache/flink@release-2.2.1 · ExecutionConfigOptions.java, строки 645-680
public static final ConfigOption<Boolean> TABLE_EXEC_MINIBATCH_ENABLED =
        key("table.exec.mini-batch.enabled")
                .booleanType()
                .defaultValue(false)
                .withDescription(
                        "Specifies whether to enable MiniBatch optimization. "
                                + "MiniBatch is an optimization to buffer input records to reduce state access. "
                                + "This is disabled by default. To enable this, users should set this config to true. "
                                + "NOTE: If mini-batch is enabled, 'table.exec.mini-batch.allow-latency' and "
                                + "'table.exec.mini-batch.size' must be set.");

@Documentation.TableOption(execMode = Documentation.ExecMode.STREAMING)
public static final ConfigOption<Duration> TABLE_EXEC_MINIBATCH_ALLOW_LATENCY =
        key("table.exec.mini-batch.allow-latency")
                .durationType()
                .defaultValue(Duration.ofMillis(0))
                .withDescription(
                        "The maximum latency can be used for MiniBatch to buffer input records. "
                                + "MiniBatch is an optimization to buffer input records to reduce state access. "
                                + "MiniBatch is triggered with the allowed latency interval and when the maximum number of buffered records reached. "
                                + "NOTE: If "
                                + TABLE_EXEC_MINIBATCH_ENABLED.key()
                                + " is set true, its value must be greater than zero.");

@Documentation.TableOption(execMode = Documentation.ExecMode.STREAMING)
public static final ConfigOption<Long> TABLE_EXEC_MINIBATCH_SIZE =
        key("table.exec.mini-batch.size")
                .longType()
                .defaultValue(-1L)
                .withDescription(
                        "The maximum number of input records can be buffered for MiniBatch. "
                                + "MiniBatch is an optimization to buffer input records to reduce state access. "
                                + "MiniBatch is triggered with the allowed latency interval and when the maximum number of buffered records reached. "
                                + "NOTE: MiniBatch only works for non-windowed aggregations currently. If "
                                + TABLE_EXEC_MINIBATCH_ENABLED.key()
                                + " is set true, its value must be positive.");

Эти настройки относятся к агрегации без окна. У оконных TVF свой буфер работает всегда, он расположен в managed memory, поэтому не создаёт нагрузки на сборщик мусора. Это написано в разделе о настройке производительности. Для обычного GROUP BY от mini-batch зависит ещё и разбиение на две фазы.

apache/flink@release-2.2.1 · TwoStageOptimizedAggregateRule.java, строки 85-94
@Override
public boolean matches(RelOptRuleCall call) {
    TableConfig tableConfig = unwrapTableConfig(call);
    boolean isMiniBatchEnabled =
            tableConfig.get(ExecutionConfigOptions.TABLE_EXEC_MINIBATCH_ENABLED);
    boolean isTwoPhaseEnabled =
            getAggPhaseStrategy(tableConfig) != AggregatePhaseStrategy.ONE_PHASE;

    return isMiniBatchEnabled && isTwoPhaseEnabled && matchesTwoStage(call.rel(0), call.rel(2));
}

Сравни с правилом для окон выше, там этого условия нет.

Почему коннекторы SQL лежат в lib

У запроса из файла нет jar с зависимостями. Строку 'connector' = 'kafka' планировщик превращает в класс через поиск фабрик на classpath. Поэтому коннектор надо либо передать клиенту флагом --jar или --library, либо положить толстый SQL-jar в /lib дистрибутива. Второй способ удобен тем, что версия коннектора управляется в одном месте для всех заданий кластера.

В исходниках

Фабрики находятся через стандартный механизм Java, ServiceLoader. Именно он читает файлы META-INF/services, которые мы склеивали в уроке 1.

apache/flink@release-2.2.1 · FactoryUtil.java, строки 789-802
static List<Factory> discoverFactories(ClassLoader classLoader) {
    final Iterator<Factory> serviceLoaderIterator =
            ServiceLoader.load(Factory.class, classLoader).iterator();

    final List<Factory> loadResults = new ArrayList<>();
    while (true) {
        try {
            // error handling should also be applied to the hasNext() call because service
            // loading might cause problems here as well
            if (!serviceLoaderIterator.hasNext()) {
                break;
            }

            loadResults.add(serviceLoaderIterator.next());

Дальше среди найденных выбирается фабрика с нужным именем. Если её нет, ты увидишь сообщение со списком всех доступных имён.

apache/flink@release-2.2.1 · FactoryUtil.java, строки 544-562
final List<Factory> matchingFactories =
        foundFactories.stream()
                .filter(f -> f.factoryIdentifier().equals(factoryIdentifier))
                .collect(Collectors.toList());

if (matchingFactories.isEmpty()) {
    throw new ValidationException(
            String.format(
                    "Could not find any factory for identifier '%s' that implements '%s' in the classpath.\n\n"
                            + "Available factory identifiers are:\n\n"
                            + "%s",
                    factoryIdentifier,
                    factoryClass.getName(),
                    foundFactories.stream()
                            .map(Factory::factoryIdentifier)
                            .distinct()
                            .sorted()
                            .collect(Collectors.joining("\n"))));
}

У /lib есть цена. Классы оттуда попадают в родительский загрузчик, а для пакета org.apache.flink. родитель всегда главнее jar задания.

apache/flink@release-2.2.1 · CoreOptions.java, строки 107-126
public static final ConfigOption<List<String>> ALWAYS_PARENT_FIRST_LOADER_PATTERNS =
        ConfigOptions.key("classloader.parent-first-patterns.default")
                .stringType()
                .asList()
                .defaultValues(
                        ArrayUtils.concat(
                                new String[] {
                                    "java.",
                                    "scala.",
                                    "org.apache.flink.",
                                    "com.esotericsoftware.kryo",
                                    "org.apache.hadoop.",
                                    "javax.annotation.",
                                    "org.xml",
                                    "javax.xml",
                                    "org.apache.xerces",
                                    "org.w3c",
                                    "org.rocksdb."
                                },
                                PARENT_FIRST_LOGGING_PATTERNS))

В SQL-jar Kafka перепакована в другой пакет.

apache/flink-connector-kafka@v5.0.0 · pom.xml, строки 99-104
<relocations>
    <relocation>
        <pattern>org.apache.kafka</pattern>
        <shadedPattern>org.apache.flink.kafka.shaded.org.apache.kafka</shadedPattern>
    </relocation>
</relocations>

Сам OffsetsInitializer перепакованным не становится, он остаётся в org.apache.flink.connector.kafka. Меняется только тип аргумента его метода.

apache/flink-connector-kafka@v5.0.0 · OffsetsInitializer.java, строки 111-123
/**
 * Get an {@link OffsetsInitializer} which initializes the offsets to the committed offsets. Use
 * the given {@link OffsetResetStrategy} to initialize the offsets if the committed offsets does
 * not exist.
 *
 * @param offsetResetStrategy the offset reset strategy to use when the committed offsets do not
 *     exist.
 * @return an {@link OffsetsInitializer} which initializes the offsets to the committed offsets.
 */
static OffsetsInitializer committedOffsets(OffsetResetStrategy offsetResetStrategy) {
    return new ReaderHandledOffsetsInitializer(
            KafkaPartitionSplit.COMMITTED_OFFSET, offsetResetStrategy);
}

А наше DataStream-задание скомпилировано с обычным org.apache.kafka.clients.consumer.OffsetResetStrategy.

src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 196-196)
                .setStartingOffsets(OffsetsInitializer.committedOffsets(OffsetResetStrategy.LATEST))

Если SQL-jar лежит в /lib, загрузчик отдаёт заданию класс OffsetsInitializer из него, и метода с такой сигнатурой там нет. На практике это выглядит как NoSuchMethodError, разберём его на вкладке «Практика».

Сравнение со Spark SQL

Многое покажется знакомым. Structured Streaming тоже описывает поток как растущую таблицу. Разница в том, что происходит на выходе.

ВопросSpark Structured StreamingFlink SQL
Как описан выводРежим Append, Update или Complete задаётся на весь запросВид изменения записан в каждой строке changelog
Откуда режимВыбираешь сам в outputModeВыводит планировщик по операторам и приёмнику
ОкноФункция window() в группировкеTVF в FROM, начало и конец окна в GROUP BY
Водяной знакwithWatermark в запросеWATERMARK FOR в DDL таблицы
Две фазы агрегацииpartial_sum и финальная суммаLocalWindowAggregate и GlobalWindowAggregate
ОптимизаторCatalystОснован на Apache Calcite
Подключение коннектора--packages или --jars при запуске/lib кластера, --jar у клиента или jar задания

Проверь себя

Коллега написал TUMBLE на минуту и GROUP BY merchantId, window_start. Задание работает, но строки в приёмнике обновляются на каждый платёж, а состояние растёт сутки за сутками. В чём дело?

Запрос GROUP BY merchantId считает платежи с нарастающим итогом, table.exec.state.ttl равен часу. Продавец молчал два часа и получил платёж. Что окажется в строке приёмника?

Ради SQL-задания в /lib общего кластера положили flink-sql-connector-kafka. После этого старое DataStream-задание с коннектором в своём jar падает с NoSuchMethodError на committedOffsets. Как починить надёжнее всего?

Что будет в конце

Рядом с основным кластером работает второй, для SQL. На нём запущено задание из файла sql/merchant_revenue.sql, которое пишет выручку продавцов по минутам в таблицу PostgreSQL. Java-кода в этой части нет, и build.gradle.kts пока не меняется. Файлы src/main/resources/sql/risk_revenue.sql и SqlJobRunner.java относятся к заданию, сейчас их создавать не нужно.

Шаг 1. Почему нельзя добавить коннектор в общий образ

Простой путь это положить flink-sql-connector-kafka в lib/ основного кластера. Тогда отправка нашего DataStream-задания перестаёт проходить.

вывод flink run
java.lang.NoSuchMethodError: ...OffsetsInitializer.committedOffsets(org.apache.kafka.clients.consumer.OffsetResetStrategy)

Причину мы разобрали в теории. Класс org.apache.flink.* пришёл из lib/, его метод ждёт перепакованный тип, а задание скомпилировано с обычным. Лечение здесь архитектурное. SQL живёт на отдельном кластере со своим образом, DataStream-задания остаются на старом.

Шаг 2. Образ с коннекторами

Создай docker/Dockerfile.

docker/Dockerfile
# Образ Flink с коннекторами для SQL. Для обычного DataStream-задания они не нужны: там всё лежит в jar.
FROM flink:2.2.1-java17

ARG MAVEN=https://repo1.maven.org/maven2

ADD --chown=flink:flink $MAVEN/org/apache/flink/flink-sql-connector-kafka/5.0.0-2.2/flink-sql-connector-kafka-5.0.0-2.2.jar /opt/flink/lib/
ADD --chown=flink:flink $MAVEN/org/apache/flink/flink-connector-jdbc-core/4.1.0-2.2/flink-connector-jdbc-core-4.1.0-2.2.jar /opt/flink/lib/
ADD --chown=flink:flink $MAVEN/org/apache/flink/flink-connector-jdbc-postgres/4.1.0-2.2/flink-connector-jdbc-postgres-4.1.0-2.2.jar /opt/flink/lib/
ADD --chown=flink:flink $MAVEN/org/postgresql/postgresql/42.7.14/postgresql-42.7.14.jar /opt/flink/lib/
# Обязательные зависимости JDBC-коннектора 4.x: они не входят в его jar, а без них SQL падает с ClassNotFoundException.
ADD --chown=flink:flink $MAVEN/io/openlineage/openlineage-java/1.32.0/openlineage-java-1.32.0.jar /opt/flink/lib/
ADD --chown=flink:flink $MAVEN/io/openlineage/openlineage-sql-java/1.32.0/openlineage-sql-java-1.32.0.jar /opt/flink/lib/

Инструкция ADD скачивает файл по URL прямо при сборке образа, а --chown отдаёт его пользователю flink, от имени которого работает процесс. В lib/ ложатся толстый коннектор Kafka, две части JDBC-коннектора и драйвер PostgreSQL.

Две последние строки появились после реальной ошибки. Без них SQL с JDBC-коннектором завершается так.

вывод sql-client.sh
[ERROR] Could not execute SQL statement. Reason: java.lang.ClassNotFoundException: io.openlineage.client.utils.jdbc.JdbcExtractor

OpenLineage это библиотека для отслеживания происхождения данных. В POM артефакта flink-connector-jdbc-core версии 4.1.0-2.2 она указана обычной зависимостью, поэтому в его jar не входит. В образ её надо добавить отдельно, это файлы openlineage-java и openlineage-sql-java 1.32.0.

Шаг 3. Второй кластер в Compose

Добавь в docker-compose.yml общий блок настроек и две службы.

что изменилось в docker-compose.yml
@@ -31,6 +31,27 @@   volumes:
     - ./build/libs:/jobs:ro
     - ./data:/flink-data
++# Отдельный кластер для SQL: толстые коннекторы в lib/ конфликтуют с коннекторами из jar DataStream-задания.+x-flink-sql-common: &flink-sql-common+  image: payguard-flink-sql:2.2.1+  build: ./docker+  profiles: [sql]+  environment:+    FLINK_PROPERTIES: |+      jobmanager.rpc.address: sql-jobmanager+      taskmanager.numberOfTaskSlots: 8+      parallelism.default: 2+      jobmanager.memory.process.size: 1600m+      taskmanager.memory.process.size: 3072m+      state.backend.type: rocksdb+      execution.checkpointing.dir: file:///flink-data/sql-checkpoints+      execution.checkpointing.interval: 15s+      restart-strategy.type: exponential-delay+  volumes:+    - ./build/libs:/jobs:ro+    - ./data:/flink-data+    - ./sql:/sql:ro 
 services:
   kafka:
@@ -97,3 +118,15 @@     restart: unless-stopped
     depends_on:
       - jobmanager
++  sql-jobmanager:+    <<: *flink-sql-common+    command: jobmanager+    ports:+      - "8082:8081"++  sql-taskmanager:+    <<: *flink-sql-common+    command: taskmanager+    depends_on:+      - sql-jobmanager
КОД
# Отдельный кластер для SQL: толстые коннекторы в lib/ конфликтуют с коннекторами из jar DataStream-задания.
x-flink-sql-common: &flink-sql-common
  image: payguard-flink-sql:2.2.1
  build: ./docker
  profiles: [sql]
  environment:
    FLINK_PROPERTIES: |
      jobmanager.rpc.address: sql-jobmanager
      taskmanager.numberOfTaskSlots: 8
      parallelism.default: 2
      jobmanager.memory.process.size: 1600m
      taskmanager.memory.process.size: 3072m
      state.backend.type: rocksdb
      execution.checkpointing.dir: file:///flink-data/sql-checkpoints
      execution.checkpointing.interval: 15s
      restart-strategy.type: exponential-delay
  volumes:
    - ./build/libs:/jobs:ro
    - ./data:/flink-data
    - ./sql:/sql:ro
ПРОСТЫМИ СЛОВАМИ

x-flink-sql-common это YAML-якорь, общий кусок настроек для двух служб. Compose игнорирует ключи с префиксом x- и разрешает на них ссылаться.

image вместе с build велит собрать образ из папки docker и дать ему имя.

profiles: [sql] означает, что службы стартуют только при флаге --profile sql. Обычный docker compose up их не трогает.

jobmanager.rpc.address указывает на собственный JobManager этого кластера. Память и слоты задаём отдельно от основного.

Бэкенд RocksDB и чекпоинты каждые 15 с работают для SQL так же, как для DataStream. Каталог свой, чтобы снимки двух кластеров не смешивались.

Папка ./sql смонтирована только для чтения, из неё клиент возьмёт скрипт.

Порт JobManager отображён на 8082, основной кластер остаётся на 8081.

Шаг 4. Таблица в PostgreSQL

Допиши таблицу результата в конец sql/init.sql.

sql/init.sql (строки 22-28)
CREATE TABLE merchant_revenue_1m (
    merchant_id   TEXT NOT NULL,
    window_start  TIMESTAMP NOT NULL,
    payment_count BIGINT NOT NULL,
    amount_minor  BIGINT NOT NULL,
    PRIMARY KEY (merchant_id, window_start)
);

Первичный ключ здесь совпадает с ключом в DDL Flink, по нему идёт upsert. Образ PostgreSQL выполняет init.sql только при пустом каталоге данных. Если стенд уже поднимался, сначала останови его командой docker compose --profile sql down, тогда новый контейнер создаст таблицы заново.

Шаг 5. Запрос

Создай sql/merchant_revenue.sql.

sql/merchant_revenue.sql
-- Выручка продавцов по минутам: из Kafka в PostgreSQL чистым SQL.
SET 'pipeline.name' = 'payguard-sql-revenue';

CREATE TABLE payments (
    paymentId   STRING,
    cardId      STRING,
    merchantId  STRING,
    amountMinor BIGINT,
    currency    STRING,
    country     STRING,
    eventTimeMs BIGINT,
    event_time  AS TO_TIMESTAMP_LTZ(eventTimeMs, 3),
    WATERMARK FOR event_time AS event_time - INTERVAL '10' SECOND
) WITH (
    'connector' = 'kafka',
    'topic' = 'payments.raw',
    'properties.bootstrap.servers' = 'kafka:9092',
    'properties.group.id' = 'payguard-sql',
    'scan.startup.mode' = 'latest-offset',
    'format' = 'json',
    'json.ignore-parse-errors' = 'true'
);

CREATE TABLE merchant_revenue_1m (
    merchant_id   STRING,
    window_start  TIMESTAMP(3),
    payment_count BIGINT,
    amount_minor  BIGINT,
    PRIMARY KEY (merchant_id, window_start) NOT ENFORCED
) WITH (
    'connector' = 'jdbc',
    'url' = 'jdbc:postgresql://postgres:5432/payguard',
    'table-name' = 'merchant_revenue_1m',
    'username' = 'payguard',
    'password' = 'payguard'
);

INSERT INTO merchant_revenue_1m
SELECT merchantId, window_start, COUNT(*), SUM(amountMinor)
FROM TABLE(TUMBLE(TABLE payments, DESCRIPTOR(event_time), INTERVAL '1' MINUTE))
GROUP BY merchantId, window_start, window_end;

Разберём объявление источника.

КОД
CREATE TABLE payments (
    paymentId   STRING,
    cardId      STRING,
    merchantId  STRING,
    amountMinor BIGINT,
    currency    STRING,
    country     STRING,
    eventTimeMs BIGINT,
    event_time  AS TO_TIMESTAMP_LTZ(eventTimeMs, 3),
    WATERMARK FOR event_time AS event_time - INTERVAL '10' SECOND
) WITH (
    'connector' = 'kafka',
    'topic' = 'payments.raw',
    'properties.bootstrap.servers' = 'kafka:9092',
    'properties.group.id' = 'payguard-sql',
    'scan.startup.mode' = 'latest-offset',
    'format' = 'json',
    'json.ignore-parse-errors' = 'true'
);
ПРОСТЫМИ СЛОВАМИ

Поля повторяют JSON нашего PaymentEvent, поэтому имена в camelCase.

event_time это вычисляемая колонка. TO_TIMESTAMP_LTZ превращает миллисекунды в метку времени, и в сообщении Kafka такого поля нет.

WATERMARK FOR делает колонку атрибутом времени события и разрешает опоздание на 10 с. Этот же приём из урока 5 здесь занимает одну строку.

connector, topic и адрес брокера связывают таблицу с топиком. Сама таблица данных не хранит.

latest-offset велит читать только новые записи при первом запуске.

json.ignore-parse-errors пропускает записи и поля с ошибками разбора вместо падения задания. Нашей DLQ из урока 3 в этом запросе нет, поэтому битые записи никуда не сохраняются.

Приёмник merchant_revenue_1m объявлен с PRIMARY KEY ... NOT ENFORCED. Эти слова значат, что Flink не проверяет уникальность сам и верит, что её обеспечивает внешняя система. SET 'pipeline.name' задаёт имя задания, которое ты увидишь в Web UI.

Шаг 6. Запусти

Порядок команд прежний, сначала сборка, потом Compose. Иначе смонтируется пустая build/libs.

терминал
./gradlew shadowJar
docker compose --profile sql up -d --wait
docker compose --profile sql exec sql-jobmanager sql-client.sh -f /sql/merchant_revenue.sql

Флаг --profile sql включает службы профиля вместе с обычными, поэтому поднимутся оба кластера. Первый запуск дольше из-за сборки образа. Флаг -f у SQL Client выполняет файл по одной инструкции и завершается на первой ошибке. INSERT клиент по умолчанию выполняет асинхронно, то есть отправляет задание и выходит, а оно продолжает работать на кластере. Открой http://localhost:8082 и убедись, что задание payguard-sql-revenue работает.

Дай нагрузку.

терминал
./gradlew loadgen --args="--rate 200"

Генератор занимает терминал, поэтому через пару минут посмотри на результат из второго.

второй терминал
docker compose exec postgres psql -U payguard -d payguard \
  -c "SELECT count(*) AS rows_total, sum(payment_count) AS payments FROM merchant_revenue_1m"
docker compose exec postgres psql -U payguard -d payguard \
  -c "SELECT * FROM merchant_revenue_1m ORDER BY amount_minor DESC LIMIT 5"

В нашем прогоне за 100 с при 200 платежах в секунду таблица получила 100 строк с суммой 15423 платежа. Цифры объясняются так. Продавцов 50, значит, закрылось две минуты. 15423 платежа при такой скорости это около 77 с потока. Остальные ещё лежали в открытом окне или были отброшены как опоздавшие, потому что генератор задерживает часть платежей на срок до 30 с, а водяной знак разрешает 10 с. Пока первая минута не закроется, строк в таблице не будет совсем. Во втором запросе первым должен стоять m-0000, ему генератор отдаёт 30 % платежей.

Шаг 7. Посмотри на changelog своими глазами

Необязательный эксперимент. Запусти клиент без -f командой docker compose --profile sql exec sql-jobmanager sql-client.sh, вставь CREATE TABLE payments из скрипта и выполни два запроса ниже.

SQL Client
SET 'sql-client.execution.result-mode' = 'tableau';
SELECT merchantId, COUNT(*) AS cnt FROM payments GROUP BY merchantId;

EXPLAIN CHANGELOG_MODE
SELECT merchantId, window_start, COUNT(*)
FROM TABLE(TUMBLE(TABLE payments, DESCRIPTOR(event_time), INTERVAL '1' MINUTE))
GROUP BY merchantId, window_start, window_end;

Режим tableau печатает результат прямо в терминал с колонкой op, в ней появятся пометки +I, -U и +U. Запрос останови через Ctrl+C. Вывод EXPLAIN CHANGELOG_MODE показывает для каждого узла поле changelogMode. Мы выполнили EXPLAIN CHANGELOG_MODE для обоих запросов на стенде. Ниже раздел Optimized Physical Plan, длинные списки полей у источника обрезаны.

вывод, GROUP BY merchantId
GroupAggregate(groupBy=[merchantId], select=[merchantId, COUNT(*) AS cnt], changelogMode=[I,UA])
+- Exchange(distribution=[hash[merchantId]], changelogMode=[I])
   +- Calc(select=[merchantId], changelogMode=[I])
      +- TableSourceScan(table=[[default_catalog, default_database, payments, ...]], ..., changelogMode=[I])
вывод, окно TUMBLE
Calc(select=[merchantId, window_start, EXPR$2], changelogMode=[I])
+- GlobalWindowAggregate(groupBy=[merchantId], window=[TUMBLE(slice_end=[$slice_end], size=[1 min])], ..., changelogMode=[I])
   +- Exchange(distribution=[hash[merchantId]], changelogMode=[I])
      +- LocalWindowAggregate(groupBy=[merchantId], window=[TUMBLE(time_col=[event_time], size=[1 min])], ..., changelogMode=[I])
         +- Calc(select=[merchantId, Reinterpret(TO_TIMESTAMP_LTZ(eventTimeMs, 3)) AS event_time], changelogMode=[I])
            +- TableSourceScan(table=[[default_catalog, default_database, payments, ...]], ..., changelogMode=[I])

Читай снизу вверх. Источник Kafka в обоих планах выдаёт только вставки, I. Обычная агрегация на выходе даёт [I,UA], то есть вставку и обновление после изменения (+U). Каждый новый платёж меняет уже выданный счётчик продавца, поэтому такой поток примет только приёмник с поддержкой upsert. Оконная агрегация выдаёт только I. Окно закрывается по водяному знаку один раз, и его строка больше не меняется, поэтому результат можно писать даже в приёмник, который умеет только дописывать. Заодно видно, что оптимизатор сам разбил оконный запрос на две фазы. LocalWindowAggregate считает частичные суммы до обмена по сети, GlobalWindowAggregate складывает их после Exchange. Ту же схему для перекоса урок 18 строит руками на DataStream.

Задание. Выручка по уровню риска и запуск из jar

Финансы просят новый отчёт. Каждую минуту для каждого уровня риска продавца нужны три продавца с самой большой выручкой. Уровень риска лежит в таблице merchants в PostgreSQL. Плюс платформа требует, чтобы SQL проходил ту же сборку и тот же релиз, что и код, поэтому скрипт должен ехать в jar.

  1. Создай таблицу risk_revenue в sql/init.sql с полями risk_level, window_start, top_rank, merchant_id, amount_minor. Подбери первичный ключ так, чтобы повторная запись окна перезаписывала строки.
  2. Напиши src/main/resources/sql/risk_revenue.sql. Обогати платёж уровнем риска через lookup join с кэшем на 10000 строк и 5 минут. Продавец без записи в справочнике получает уровень UNKNOWN. Посчитай выручку в минутном окне TVF и оставь топ-3 через Window Top-N.
  3. Напиши класс dev.study.payguard.job.SqlJobRunner. Он получает имя скрипта аргументом --script, читает его из ресурсов jar и выполняет инструкции по очереди через TableEnvironment.
  4. Добавь нужную зависимость в build.gradle.kts так, чтобы она не попала в толстый jar.
  5. Отправь задание на SQL-кластер через flink run.

Критерии готовности такие. В Web UI на порту 8082 в плане есть вершины LookupJoin, LocalWindowAggregate, GlobalWindowAggregate и WindowRank. В таблице не больше трёх строк на уровень риска и минуту. В верхней строке уровня HIGH стоит горячий продавец m-0000.

Подсказки. Для lookup join нужен атрибут времени обработки, посмотри функцию PROCTIME() и конструкцию FOR SYSTEM_TIME AS OF. Window Top-N требует, чтобы PARTITION BY содержал и начало, и конец окна. Точка входа в толстом jar занята основным заданием, поэтому главный класс придётся указать флагом. При разборе скрипта подумай, на чём разрезать текст, чтобы инструкции и комментарии не сломали executeSql.

Ответ

Глоссарий урока

Динамическая таблица (dynamic table)
Таблица, содержимое которой меняется во времени. Определена над потоком и внутри не материализована.
Непрерывный запрос (continuous query)
Запрос над динамической таблицей, который не завершается и обновляет результат по мере прихода данных.
Changelog (поток изменений)
Поток строк, каждая из которых описывает изменение таблицы и несёт пометку вида изменения.
RowKind
Перечисление Flink с видами изменений строки +I, -U, +U, -D.
ChangelogMode
Класс, в котором коннектор перечисляет виды строк, которые он выдаёт или принимает.
Append-only
Поток изменений только со вставками +I. Строки результата никогда не меняются.
Retract
Режим, в котором обновление занимает две записи, отмену старого значения -U и новое значение +U.
Upsert
Режим, в котором обновление занимает одну запись +U. Приёмник находит строку по уникальному ключу.
Идемпотентность
Свойство операции, при котором её повтор не меняет результат. Запись по первичному ключу идемпотентна.
TVF (table-valued function)
Функция, которая принимает и возвращает таблицу. TUMBLE, HOP, CUMULATE и SESSION добавляют к строкам границы окна.
Водяной знак (watermark)
Отметка в потоке, которая сообщает, что записей старше этого времени больше не ожидается. Закрывает окна. Подробно в уроке 5.
Планировщик (planner)
Компонент Flink, который превращает текст SQL в граф операторов и оптимизирует его.
Apache Calcite
Библиотека разбора и оптимизации SQL. На ней основан планировщик Flink.
Catalyst
Оптимизатор запросов Spark SQL.
Spark SQL и Structured Streaming
Модуль SQL в Apache Spark и его потоковый режим, который описывает поток как растущую таблицу.
Local и Global aggregate
Две фазы агрегации. Локальная считает частичные суммы до обмена по сети, глобальная объединяет их по ключу.
Mini-batch
Режим, в котором агрегация копит пачку записей и обращается к состоянию один раз на ключ. Экономит состояние ценой задержки.
Managed memory
Область памяти TaskManager вне кучи, которой распоряжается сам Flink. Подробно в уроке 19.
TTL состояния
Срок хранения ключа без обновлений. В SQL задаётся настройкой table.exec.state.ttl, значение 0 отключает очистку.
RocksDB
Встраиваемое хранилище ключ-значение на диске. Один из бэкендов состояния Flink.
SQL Client
Консольный клиент Flink, который выполняет SQL интерактивно или из файла.
Lookup join
Соединение, в котором каждая запись потока запрашивает значение из внешней системы в момент обработки.
Window Top-N
Запрос с ROW_NUMBER, который выдаёт N лучших строк на окно один раз, когда окно закрылось.
JDBC-коннектор
Коннектор Flink для реляционных баз через стандартный драйвер Java. Умеет читать, искать по ключу и писать.
PostgreSQL
Реляционная база данных. В проекте хранит справочник продавцов и итоги.
OpenLineage
Открытый стандарт и библиотека для отслеживания происхождения данных. Нужна JDBC-коннектору 4.x при запуске.
SQL-jar (толстый коннектор)
Jar со всеми зависимостями коннектора, включая перепакованную в другой пакет библиотеку Kafka.
Родительский загрузчик (parent-first)
Порядок загрузки, при котором класс сначала ищется в lib/ кластера. Для org.apache.flink. действует всегда.
ServiceLoader
Механизм Java, который находит реализации интерфейса по файлам META-INF/services.
Профиль Compose
Метка службы в Docker Compose. Служба с профилем стартует, только если этот профиль включён.
DDL
Часть SQL, которая описывает объекты, например CREATE TABLE. Во Flink в DDL указаны коннектор, формат и водяной знак.
Атрибут времени
Колонка, которую Flink считает временем записи. Бывает временем события с водяным знаком или временем обработки через PROCTIME().
Calc
Вершина плана, которая выполняет проекцию и фильтр без состояния.
ConstraintEnforcer
Оператор перед приёмником, который проверяет ограничения колонок, например NOT NULL. Его добавляет сборка приёмника.
DLQ (dead letter queue)
Отдельный топик для записей, которые не удалось обработать. В проекте это payments.dlq из урока 3.

Урок 15 · SQL и интеграции

CDC из PostgreSQL и таблица озера на Paimon

После урока ты сможешь транслировать изменения таблицы PostgreSQL в компактируемый топик Kafka, складывать алерты в таблицу Paimon с первичным ключом и не уронить базу забытым слотом репликации.

Справочник живёт в чужой базе

Уровень риска продавца лежит в таблице merchants в PostgreSQL. Аналитик поднял продавцу риск до HIGH, и это нужно узнать сразу нескольким системам. Первое, что приходит в голову, это опрашивать таблицу запросом по updated_at.

У опроса есть известные дыры. Удалённая строка в результат запроса не попадёт. Два изменения между опросами сольются в одно. В нашей таблице есть ещё и своя ловушка. В sql/init.sql у updated_at стоит только DEFAULT now(), триггера нет, поэтому UPDATE уровня риска колонку не трогает, и опрос такого изменения вообще не увидит.

CDC (change data capture, захват изменений) решает задачу с другого конца. База и так записывает каждое изменение в свой журнал, и коннектор читает этот журнал как поток событий. В этом уроке мы построим две линии, которые показаны на схеме. Верхняя переносит merchants в топик Kafka. Нижняя складывает алерты в таблицу озера данных, чтобы по ним можно было строить отчёты.

SQL-кластер, порт 8082 PostgreSQLmerchants, WALслот payguard_merchants payguard-cdc-merchantspostgres-cdc, затем upsert-kafka Kafkamerchants.changelog, compact Kafkafraud.alerts payguard-alerts-lakekafka, затем paimon Paimonfraud_alerts Пакетныйотчёт

WAL и логическое декодирование

WAL (write-ahead log) это журнал упреждающей записи PostgreSQL. Его правило простое. Изменение попадает в файлы данных только после того, как оно записано в журнал, поэтому после падения база может доиграть журнал и восстановиться. Каждая позиция в журнале имеет номер, он называется LSN (log sequence number).

WAL рассчитан на восстановление самой базы и описывает изменения на физическом уровне. Логическое декодирование превращает его в поток понятных изменений строк. Чтобы журнал содержал нужные для этого данные, настройка wal_level должна быть равна logical. По умолчанию там replica, а поменять значение можно только при старте сервера.

Формат потока определяет плагин вывода. Стандартный плагин PostgreSQL называется pgoutput, на нём работает встроенная логическая репликация. У коннектора значение по умолчанию другое, decoderbufs, и этот плагин должен быть установлен на сервере, поэтому мы явно указываем pgoutput.

Слот как абонентский ящик

Представь абонентский ящик на почте. Письма складывают туда и хранят, пока абонент их не заберёт. Почта не знает, жив ли абонент. Если он переехал и забыл закрыть ящик, письма продолжат копиться, и однажды их негде станет хранить.

Логический слот репликации устроен так же. Это объект на сервере, который помнит позицию своего читателя и запрещает удалять журнал после неё. Документация PostgreSQL прямо пишет, что слот живёт независимо от подключения, переживает падения и ничего не знает о состоянии получателя. Читать один слот в каждый момент может только один получатель, поэтому коннектор советует заводить отдельное имя на каждую таблицу, иначе ты увидишь ошибку replication slot "flink" is active for PID.

В модели ниже одна клетка это журнал, накопившийся за один интервал чекпоинта. Позиция слота отстаёт на три интервала, как при настройке коннектора по умолчанию. Останови задание и нажимай на интервал, потом удали слот. Числа здесь условные, настоящий темп роста зависит от нагрузки на базу.

Что держит журнал на диске

Задание работает. Слот двигается вслед за чекпоинтами, старые сегменты освобождаются.

В исходниках

Проверим две вещи. Первая, что будет со слотом при остановке задания. Коннектор настраивает Debezium, встроенную библиотеку чтения журнала, отдельно для временного и для основного чтения. В первом фрагменте временный слот удаляется при остановке. Во втором основной слот сохраняется, комментарий говорит об этом прямо.

apache/flink-cdc@release-3.7.0 · PostgresSourceFetchTaskContext.java, строки 139-147
.with(
        SLOT_NAME.name(),
        ((PostgresSourceConfig) sourceConfig)
                .getSlotNameForBackfillTask())
// drop slot for backfill stream split
.with(DROP_SLOT_ON_STOP.name(), true)
// Disable heartbeat event in snapshot split fetcher
.with(Heartbeat.HEARTBEAT_INTERVAL, 0)
.build());
apache/flink-cdc@release-3.7.0 · PostgresSourceFetchTaskContext.java, строки 164-169
dbzConfig =
        new PostgresConnectorConfig(
                builder
                        // never drop slot for stream split, which is also global split
                        .with(DROP_SLOT_ON_STOP.name(), false)
                        .build());

Сам коннектор вызывает удаление основного слота в единственном месте. Условие срабатывает, когда задание запущено в режиме snapshot, то есть читает только снимок и не собирается идти дальше.

apache/flink-cdc@release-3.7.0 · PostgresSourceReader.java, строки 161-168
if (this.sourceConfig.getStartupOptions().isSnapshotOnly()
        && streamSplit
                .getStartingOffset()
                .isAtOrAfter(streamSplit.getEndingOffset())) {
    PostgresDialect dialect = (PostgresDialect) this.dialect;
    boolean removed = dialect.removeSlot(dialect.getSlotName());
    LOG.info("Remove slot '{}' result is {}.", dialect.getSlotName(), removed);
}

Отсюда правило. Отмена или падение обычного потокового задания оставляет слот на сервере, и убирать его придётся тебе.

Вторая вещь, когда слот сдвигается. Читатель подтверждает позицию в обработчике завершённого чекпоинта и придерживает несколько последних номеров, чтобы задание могло откатиться на недавний чекпоинт.

apache/flink-cdc@release-3.7.0 · PostgresSourceReader.java, строки 132-152
public void notifyCheckpointComplete(long checkpointId) throws Exception {
    this.minHeap.add(checkpointId);
    if (this.minHeap.size() <= this.lsnCommitCheckpointsDelay) {
        LOG.info("Pending checkpoints '{}'.", this.minHeap);
        return;
    }
    final long checkpointIdToCommit = this.minHeap.poll();
    LOG.info(
            "Pending checkpoints '{}', to be committed checkpoint id '{}', isCommitOffset is {}.",
            this.minHeap,
            checkpointIdToCommit,
            isCommitOffset());

    // After all snapshot splits are finished, update stream split's metadata and reset start
    // offset, which maybe smaller than before.
    // In case that new start-offset of stream split has been recycled, don't commit offset
    // during new table added phase.
    if (isCommitOffset()) {
        super.notifyCheckpointComplete(checkpointIdToCommit);
    }
}
apache/flink-cdc@release-3.7.0 · PostgresSourceOptions.java, строки 81-89
public static final ConfigOption<Integer> SCAN_LSN_COMMIT_CHECKPOINTS_DELAY =
        ConfigOptions.key("scan.lsn-commit.checkpoints-num-delay")
                .intType()
                .defaultValue(3)
                .withDescription(
                        "The number of checkpoint delays before starting to commit the LSN offsets.\n"
                                + "By setting this to higher value, the offset that is consumed by global slot will be "
                                + "committed after multiple checkpoint delays instead of after each checkpoint completion.\n"
                                + "This allows continuous recycle of log files in stream phase.");

При интервале 15 с из нашего Compose и задержке 3 слот держит журнал минимум за 45 с. Других вызовов подтверждения позиции в этом источнике нет, поэтому задание без чекпоинтов оставит слот на месте, хотя данные будет читать исправно.

⚠
Забытый слот опаснее упавшего задания

Слот мешает удалять и WAL, и строки системных каталогов, которые ему нужны, а значение max_slot_wal_keep_size по умолчанию равно -1, то есть журнал может расти без ограничений. В крайнем случае база остановится, защищаясь от переполнения счётчика транзакций. Поэтому за pg_replication_slots нужен отдельный алерт. Лимит спасает диск, но у него есть цена. Слот, отставший сильнее лимита, теряет журнал, получает wal_status = lost и больше не годится для чтения, так что задание придётся запускать заново со снимка.

Снимок плюс журнал

Журнал старше слота уже удалён, и начальное содержимое таблицы в нём взять неоткуда. Поэтому коннектор в режиме initial сначала читает таблицу, а потом переключается на журнал. Всё сложное здесь в стыке. Пока идёт чтение таблицы, строки продолжают меняться.

Инкрементальный снимок (scan.incremental.snapshot.enabled = true) режет таблицу на чанки по первичному ключу и раздаёт их параллельным читателям. По документации он читает параллельно, делает чекпоинты между чанками и обходится без глобальной блокировки на чтение. По умолчанию этот режим выключен, поэтому в нашем SQL он включён явно.

Каждый чанк читается по одной схеме. Слово watermark здесь означает позицию LSN и никак не связано с watermark времени события из урока 5. Пройди шаги.

T
Таблица
W
WAL и слот
R
Читатель чанка
F
Дальше в задание
Нажми «Следующий шаг»

В исходниках

Базовый класс чтения чанка показывает ту же последовательность. Логи пронумерованы, смотри на них. Сначала нижняя граница и сам снимок.

apache/flink-cdc@release-3.7.0 · AbstractScanFetchTask.java, строки 60-71
final Offset lowWatermark = dialect.displayCurrentOffset(sourceConfig);
LOG.info(
        "Snapshot step 1 - Determining low watermark {} for split {}",
        lowWatermark,
        snapshotSplit);
dispatchLowWaterMarkEvent(context, snapshotSplit, lowWatermark);
if (snapshotPhaseHooks.getPostLowWatermarkAction() != null) {
    snapshotPhaseHooks.getPostLowWatermarkAction().accept(sourceConfig, snapshotSplit);
}

LOG.info("Snapshot step 2 - Snapshotting data");
executeDataSnapshot(context);

Затем верхняя граница. Если дозаполнение отключено настройкой, границы приравниваются, и комментарий предупреждает, что гарантия падает до at-least-once.

apache/flink-cdc@release-3.7.0 · AbstractScanFetchTask.java, строки 83-91
Offset highWatermark =
        context.getSourceConfig().isSkipSnapshotBackfill()
                ? lowWatermark
                : dialect.displayCurrentOffset(sourceConfig);
LOG.info(
        "Snapshot step 3 - Determining high watermark {} for split {}",
        highWatermark,
        snapshotSplit);
dispatchHighWaterMarkEvent(context, snapshotSplit, highWatermark);

Дозаполнение запускается только тогда, когда между границами что-то есть.

apache/flink-cdc@release-3.7.0 · AbstractScanFetchTask.java, строки 104-113
if (!streamBackfillRequired) {
    LOG.info(
            "Skip the backfill {} for split {}: low watermark >= high watermark",
            backfillStreamSplit,
            snapshotSplit);
    dispatchEndWaterMarkEvent(
            context, backfillStreamSplit, backfillStreamSplit.getEndingOffset());
} else {
    executeBackfillTask(context, backfillStreamSplit);
}

В PostgreSQL дозаполнению нужен собственный слот, потому что слот видит журнал только с момента создания. Он создаётся до снимка и удаляется после. Его имя собрано из slot.name и номера подзадачи, поэтому при параллелизме 2 у нас на время снимка появятся payguard_merchants_0 и payguard_merchants_1. Держи это в уме, настройка max_replication_slots по умолчанию разрешает всего 10 слотов на сервер.

apache/flink-cdc@release-3.7.0 · PostgresScanFetchTask.java, строки 77-92
try {
    // create slot here,  because a slot can only read wal-log after its own creation.
    // if skip backfill, no need to create slot here
    maybeCreateSlotForBackFillReadTask(
            ctx.getConnection(),
            ctx.getReplicationConnection(),
            sourceConfig.getSlotNameForBackfillTask(),
            ctx.getPluginName(),
            sourceConfig.isSkipSnapshotBackfill());
    super.execute(context);
} finally {
    // remove slot after snapshot slit finish
    maybeDropSlotForBackFillReadTask(
            (PostgresReplicationConnection) ctx.getReplicationConnection(),
            sourceConfig.isSkipSnapshotBackfill());
}
apache/flink-cdc@release-3.7.0 · PostgresSourceConfig.java, строки 140-147
/**
 * Returns the slot name for backfill task.
 *
 * @return backfill task slot name
 */
public String getSlotNameForBackfillTask() {
    return getDbzProperties().getProperty(SLOT_NAME.name()) + "_" + getSubtaskId();
}

Потоковое чтение выдаётся читателю, когда все чанки распределены и закончены. Причина указана в комментарии. Иначе записи из журнала могли бы обогнать записи снимка с тем же первичным ключом.

apache/flink-cdc@release-3.7.0 · HybridSplitAssigner.java, строки 185-195
} else if (isInitialAssigningFinished(snapshotSplitAssigner.getAssignerStatus())) {
    // we need to wait snapshot-assigner to be finished before
    // assigning the stream split. Otherwise, records emitted from stream split
    // might be out-of-order in terms of same primary key with snapshot splits.
    isStreamSplitAssigned = true;
    enumeratorMetrics.enterStreamReading();
    StreamSplit streamSplit = createStreamSplit();
    LOG.trace(
            "SnapshotSplitAssigner is finished: creating a new stream split {}",
            streamSplit);
    return Optional.of(streamSplit);

Со Spark разница здесь в числе компонентов. Там такую линию обычно собирают из отдельных частей. Debezium в Kafka Connect пишет в топик, Structured Streaming читает топик и через foreachBatch делает MERGE в таблицу озера. Flink CDC читает журнал внутри источника задания, и позиция в WAL попадает в тот же чекпоинт, что и состояние операторов.

REPLICA IDENTITY и поле before

Настройка таблицы REPLICA IDENTITY решает, какие старые значения PostgreSQL кладёт в журнал при UPDATE и DELETE.

ЗначениеЧто попадает в журнал
DEFAULTСтарые значения колонок первичного ключа. Режим по умолчанию для обычных таблиц
USING INDEXСтарые значения колонок указанного уникального индекса
FULLСтарые значения всех колонок строки
NOTHINGНичего про старую строку

Документация добавляет важную оговорку. Обычно старое значение колонки пишется, только если оно отличается от нового. Первичный ключ при смене уровня риска не меняется, поэтому в режиме DEFAULT старой строки в журнале нет совсем. А коннектору в режиме changelog-mode = all она нужна, потому что он обязан выдать пару записей -U и +U.

В исходниках

Посмотрим, как событие Debezium превращается в строки Flink. Записи снимка приходят с операцией READ и становятся INSERT. На обновление в режиме ALL сначала извлекается поле before, и оно сразу уходит в проверку.

apache/flink-cdc@release-3.7.0 · RowDataDebeziumDeserializeSchema.java, строки 129-151
if (op == Envelope.Operation.CREATE || op == Envelope.Operation.READ) {
    GenericRowData insert = extractAfterRow(value, valueSchema);
    validator.validate(insert, RowKind.INSERT);
    insert.setRowKind(RowKind.INSERT);
    emit(record, insert, out);
} else if (op == Envelope.Operation.DELETE) {
    GenericRowData delete = extractBeforeRow(value, valueSchema);
    validator.validate(delete, RowKind.DELETE);
    delete.setRowKind(RowKind.DELETE);
    emit(record, delete, out);
} else {
    if (changelogMode == DebeziumChangelogMode.ALL) {
        GenericRowData before = extractBeforeRow(value, valueSchema);
        validator.validate(before, RowKind.UPDATE_BEFORE);
        before.setRowKind(RowKind.UPDATE_BEFORE);
        emit(record, before, out);
    }

    GenericRowData after = extractAfterRow(value, valueSchema);
    validator.validate(after, RowKind.UPDATE_AFTER);
    after.setRowKind(RowKind.UPDATE_AFTER);
    emit(record, after, out);
}

Проверка для PostgreSQL падает с исключением на пустой строке. Текст исключения это и есть та ошибка, которую ты увидишь в логе.

apache/flink-cdc@release-3.7.0 · PostgresValueValidator.java, строки 29-46
private static final String REPLICA_IDENTITY_EXCEPTION =
        "The \"before\" field of UPDATE/DELETE message is null, "
                + "please check the Postgres table has been set REPLICA IDENTITY to FULL level. "
                + "You can update the setting by running the command in Postgres 'ALTER TABLE %s REPLICA IDENTITY FULL'. "
                + "Please see more in Debezium documentation: https://debezium.io/documentation/reference/1.9/connectors/postgresql.html#postgresql-replica-identity";

private final String schemaTable;

public PostgresValueValidator(String schema, String table) {
    this.schemaTable = schema + "." + table;
}

@Override
public void validate(RowData rowData, RowKind rowKind) throws Exception {
    if (rowData == null) {
        throw new IllegalStateException(String.format(REPLICA_IDENTITY_EXCEPTION, schemaTable));
    }
}

Условие changelogMode == ALL показывает и запасной выход. Если FULL на таблице включить нельзя, у коннектора есть changelog-mode = upsert, в котором поле before не запрашивается. Этому режиму нужен первичный ключ. Наш курс использует FULL, а его цена следует из определения выше, на каждое обновление в журнал пишется вся старая строка.

Коннектор доступен и из DataStream API через PostgresSourceBuilder, и SQL-таблица в режиме параллельного чтения использует тот же класс. Различие есть в обязательности имени слота. В построителе у него указано значение по умолчанию flink, а SQL-фабрика требует задать slot.name явно.

В исходниках

Первый фрагмент из построителя, второй из SQL-фабрики. Запомни, что значение по умолчанию у построителя опасно. Два задания без явного имени полезут в один слот.

apache/flink-cdc@release-3.7.0 · PostgresSourceBuilder.java, строки 128-141
/**
 * The name of the PostgreSQL logical decoding slot that was created for streaming changes from
 * a particular plug-in for a particular database/schema. The server uses this slot to stream
 * events to the connector that you are configuring. Default is "flink".
 *
 * <p>Slot names must conform to <a
 * href="https://www.postgresql.org/docs/current/static/warm-standby.html#STREAMING-REPLICATION-SLOTS-MANIPULATION">PostgreSQL
 * replication slot naming rules</a>, which state: "Each replication slot has a name, which can
 * contain lower-case letters, numbers, and the underscore character."
 */
public PostgresSourceBuilder<T> slotName(String slotName) {
    this.configFactory.slotName(slotName);
    return this;
}
apache/flink-cdc@release-3.7.0 · PostgreSQLTableFactory.java, строки 202-212
public Set<ConfigOption<?>> requiredOptions() {
    Set<ConfigOption<?>> options = new HashSet<>();
    options.add(HOSTNAME);
    options.add(USERNAME);
    options.add(PASSWORD);
    options.add(DATABASE_NAME);
    options.add(SCHEMA_NAME);
    options.add(TABLE_NAME);
    options.add(SLOT_NAME);
    return options;
}

Upsert-kafka и компактируемый топик

Коннектор upsert-kafka пишет changelog в Kafka с такой логикой. Первичный ключ таблицы становится ключом сообщения Kafka, новая версия строки становится значением, а удаление превращается в сообщение с пустым значением (tombstone). Все изменения одного ключа попадают в одну партицию, поэтому их порядок сохраняется.

Топик с настройкой cleanup.policy=compact хранит для каждого ключа как минимум последнее значение, старые версии брокер со временем вычищает. Такой топик работает как таблица. Новый потребитель читает его с начала и получает весь справочник без обращения к PostgreSQL. Компактирование не трогает активный сегмент, поэтому только что записанные версии какое-то время лежат рядом, и ты увидишь это в практике.

В исходниках

Без первичного ключа таблица не создастся, потому что ключ сообщения брать неоткуда. Сразу под этой проверкой в файле объявлен режим приёмника. В нём нет UPDATE_BEFORE, поэтому на один UPDATE в базе в топик уходит одна запись.

apache/flink-connector-kafka@v5.0.0 · UpsertKafkaDynamicTableFactory.java, строки 306-313
private static void validatePKConstraints(int[] schema) {
    if (schema.length == 0) {
        throw new ValidationException(
                "'upsert-kafka' tables require to define a PRIMARY KEY constraint. "
                        + "The PRIMARY KEY specifies which columns should be read from or write to the Kafka message key. "
                        + "The PRIMARY KEY also defines records in the 'upsert-kafka' table should update or delete on which keys.");
    }
}
apache/flink-connector-kafka@v5.0.0 · UpsertKafkaDynamicTableFactory.java, строки 395-400
public static final ChangelogMode SINK_CHANGELOG_MODE =
        ChangelogMode.newBuilder()
                .addContainedKind(RowKind.INSERT)
                .addContainedKind(RowKind.UPDATE_AFTER)
                .addContainedKind(RowKind.DELETE)
                .build();

Сериализация показывает, как рождается tombstone. Для DELETE и UPDATE_BEFORE значение становится null, остальные записи пишутся как обычные INSERT, чтобы их принял формат вроде JSON, понимающий только вставки. До ветки с UPDATE_BEFORE наши записи не доходят, потому что режим приёмника таких строк не объявляет. Это видно и в замере из практики, один UPDATE дал одну запись без tombstone.

apache/flink-connector-kafka@v5.0.0 · DynamicKafkaRecordSerializationSchema.java, строки 144-155
if (upsertMode) {
    if (kind == RowKind.DELETE || kind == RowKind.UPDATE_BEFORE) {
        // transform the message as the tombstone message
        valueSerialized = null;
    } else {
        // make the message to be INSERT to be compliant with the INSERT-ONLY format
        final RowData valueRow =
                DynamicKafkaRecordSerializationSchema.createProjectedRow(
                        consumedRow, kind, valueFieldGetters);
        valueRow.setRowKind(RowKind.INSERT);
        valueSerialized = valueSerialization.serialize(valueRow);
    }

Lakehouse и Paimon

Алерты в Kafka нужны для реакции, а аналитикам нужен SQL по истории за месяцы. Lakehouse закрывает это файлами в дешёвом хранилище, поверх которых лежат метаданные таблицы. Метаданные дают то, чего нет у папки с Parquet, это атомарную публикацию изменений и обновление строк по ключу. Apache Paimon как раз такой формат таблиц, и читать его можно разными движками, в том числе Spark.

Устройство таблицы с первичным ключом удобно смотреть сверху вниз. Партиция делит данные по значению колонки, у нас по дню. Внутри партиции бакеты, каждый со своим LSM-деревом (log-structured merge-tree). Писатель накапливает записи, сортирует их и сбрасывает новым файлом, а compaction потом сливает файлы, чтобы чтению не приходилось перебирать много версий одного ключа.

snapshot/snapshot-8видимое состояние таблицы schema/версии схемы manifest/какие файлы добавлены и убраны event_day=2026-11-07/ bucket-0 bucket-1 data-*.parquet data-*.parquet changelog-*.parquet своё LSM-дерево data-*.parquet changelog-*.parquet своё LSM-дерево

Изменения становятся видимыми, когда опубликован снимок таблицы. Снимок ссылается на схему и списки манифестов, а манифесты перечисляют живые файлы. Потоковая запись публикует изменения на чекпоинтах Flink, поэтому документация Paimon требует их включить. Интервал чекпоинта у нас 15 с, значит, столько же примерно ждать свежих алертов в отчёте.

Механизм слияния (merge engine) задаёт, как записи с одним ключом складываются в одну логическую строку. По умолчанию это deduplicate, он оставляет последнюю. Идентификатор нашего алерта детерминирован, он собран из правила и платежа, поэтому повторная обработка даёт тот же ключ, и строка останется одна.

Настройка bucket выбирает режим распределения. Положительное число включает фиксированные бакеты, номер считается как хеш ключа бакета по модулю их числа. Без настройки таблица работает с динамическими бакетами (-1), и тогда в одну партицию может писать только одно задание. Ключом бакета по умолчанию служит первичный ключ без колонок партиции, поэтому при ключе (event_day, alert_id) хеш считается от alert_id.

i
Три разных снимка в одном уроке

Чекпоинт Flink это снимок состояния задания. Снимок CDC это начальное чтение таблицы PostgreSQL. Снимок Paimon это опубликованная версия таблицы, файл snapshot/snapshot-N. Связь между ними одна, публикация снимка Paimon при потоковой записи привязана к чекпоинту Flink.

Почему Paimon в этом курсе

Документация Paimon называет таблицы с первичным ключом основным инструментом для CDC и upsert-потоков, а сборка paimon-flink-2.2 версии 2.1.0 проверена на нашем стенде. Рантайм Iceberg под Flink 2.2 появился только в Iceberg 1.12.0, линия 1.11.0 заканчивалась на Flink 2.1, и на стенде курса Iceberg не запускался.

Та же таблица в пакетном режиме

Писатель работает непрерывно, а читателю это не мешает. Запрос в режиме execution.runtime-mode = batch читает один снимок и завершается, писатель в этот момент продолжает работать. Повтори запрос, и он прочитает снимок поновее. Тот же SELECT в режиме streaming сначала прочитает текущее содержимое, потом продолжит следить за изменениями и сам не завершится. Для запроса к старой версии есть подсказка /*+ OPTIONS('scan.snapshot-id' = '1') */, это чтение в прошлое (time travel).

Проверь себя

Задание CDC отменили месяц назад и забыли. Таблицу никто больше не читает, но диск базы продолжает заполняться каталогом pg_wal. Что делать в первую очередь?

После первого UPDATE задание ушло в цикл RESTARTING с ошибкой про поле before. Коллега выполнил ALTER TABLE merchants REPLICA IDENTITY FULL, но цикл продолжается. Почему и что дальше?

Потоковое задание пишет в таблицу Paimon уже десять минут, в Web UI оно RUNNING и записи идут. Пакетный запрос возвращает ноль строк. В конфиге кластера нет execution.checkpointing.interval. В чём дело?

Что будет в конце

На стенде работают два кластера, основной на порту 8081 и SQL на порту 8082. Изменение строки в merchants появляется в топике merchants.changelog, а алерты складываются в таблицу Paimon на диске. Файл задания мы пока не создаём, он появится на вкладке «Задание». Файл build.gradle.kts в этом уроке не меняется, потому что новые коннекторы лежат в образе SQL-кластера и в jar не попадают.

Шаг 1. Добавь коннекторы в образ

Открой docker/Dockerfile из урока 14 и допиши три файла в /opt/flink/lib.

что изменилось в docker/Dockerfile
@@ -10,3 +10,7 @@ # Обязательные зависимости JDBC-коннектора 4.x: они не входят в его jar, а без них SQL падает с ClassNotFoundException.
 ADD --chown=flink:flink $MAVEN/io/openlineage/openlineage-java/1.32.0/openlineage-java-1.32.0.jar /opt/flink/lib/
 ADD --chown=flink:flink $MAVEN/io/openlineage/openlineage-sql-java/1.32.0/openlineage-sql-java-1.32.0.jar /opt/flink/lib/
+ADD --chown=flink:flink $MAVEN/org/apache/flink/flink-sql-connector-postgres-cdc/3.7.0-2.2/flink-sql-connector-postgres-cdc-3.7.0-2.2.jar /opt/flink/lib/+ADD --chown=flink:flink $MAVEN/org/apache/paimon/paimon-flink-2.2/2.1.0/paimon-flink-2.2-2.1.0.jar /opt/flink/lib/+# Paimon обращается к классам Hadoop даже для локальных файлов.+ADD --chown=flink:flink $MAVEN/org/apache/flink/flink-shaded-hadoop-2-uber/2.8.3-10.0/flink-shaded-hadoop-2-uber-2.8.3-10.0.jar /opt/flink/lib/
КОД
ADD --chown=flink:flink $MAVEN/org/apache/flink/flink-sql-connector-postgres-cdc/3.7.0-2.2/flink-sql-connector-postgres-cdc-3.7.0-2.2.jar /opt/flink/lib/
ADD --chown=flink:flink $MAVEN/org/apache/paimon/paimon-flink-2.2/2.1.0/paimon-flink-2.2-2.1.0.jar /opt/flink/lib/
# Paimon обращается к классам Hadoop даже для локальных файлов.
ADD --chown=flink:flink $MAVEN/org/apache/flink/flink-shaded-hadoop-2-uber/2.8.3-10.0/flink-shaded-hadoop-2-uber-2.8.3-10.0.jar /opt/flink/lib/
ПРОСТЫМИ СЛОВАМИ

Толстый jar коннектора postgres-cdc. Версия 3.7.0-2.2 читается как версия Flink CDC плюс версия Flink, под которую он собран.

Коннектор и каталог Paimon. Сборка привязана к Flink 2.2 через имя артефакта paimon-flink-2.2, а 2.1.0 это уже версия самого Paimon.

Комментарий объясняет следующую строку. Склад у нас на локальном диске, но классы Hadoop всё равно нужны.

Сборка Hadoop от проекта Flink, все классы в одном jar.

Мы проверили, что будет без последней строки. Paimon падает с такой ошибкой.

вывод
java.lang.ClassNotFoundException: org.apache.hadoop.conf.Configuration

Шаг 2. Настрой PostgreSQL и Kafka

В docker-compose.yml меняются две службы.

что изменилось в docker-compose.yml
@@ -89,9 +89,13 @@           /opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 \
             --create --if-not-exists --topic "$$topic" --partitions 4 --replication-factor 1
         done
+        /opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 \+          --create --if-not-exists --topic merchants.changelog --partitions 4 --replication-factor 1 \+          --config cleanup.policy=compact 
   postgres:
     image: postgres:17-alpine
+    command: ["postgres", "-c", "wal_level=logical"]     environment:
       POSTGRES_DB: payguard
       POSTGRES_USER: payguard

В kafka-init появляется топик merchants.changelog. Флаг --config cleanup.policy=compact делает его компактируемым, поэтому он создаётся отдельной командой вне общего цикла. В postgres строка command запускает сервер с wal_level=logical. Этот параметр читается только при старте сервера, поэтому он задан в аргументах запуска.

Шаг 3. Включи REPLICA IDENTITY

что изменилось в sql/init.sql
@@ -3,6 +3,9 @@     risk_level  TEXT NOT NULL,
     updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
 );
++-- CDC-коннектору нужны прежние значения строки в журнале, иначе UPDATE ломает задание.+ALTER TABLE merchants REPLICA IDENTITY FULL; 
 INSERT INTO merchants (merchant_id, risk_level)
 SELECT 'm-' || lpad(n::text, 4, '0'),

Строка стоит сразу после создания таблицы. Если её забыть, снимок прочитается нормально, топик заполнится, и всё будет выглядеть рабочим до первого UPDATE. После него задание уходит в цикл RESTARTING.

лог
java.lang.IllegalStateException: The "before" field of UPDATE/DELETE message is null, please check the Postgres table has been set REPLICA IDENTITY to FULL level.

Лечение в уже работающей системе требует порядка, потому что плохая запись осталась в журнале, а слот будет отдавать её снова. Сначала отмени задание, удали слот, выполни ALTER TABLE merchants REPLICA IDENTITY FULL и запусти задание заново со снимка. Повторный снимок топику не вредит, у каждой строки тот же ключ.

Шаг 4. Собери и подними стенд

Команды выполняй именно в таком порядке.

терминал
./gradlew shadowJar
docker compose --profile sql down -v
docker compose --profile sql up -d --wait --build

Сборка идёт первой, иначе Docker смонтирует пустую build/libs, и jar в контейнере не появится. На этом мы реально споткнулись.

Флаг -v у down удаляет анонимные тома контейнеров. Это важно для PostgreSQL, потому что образ выполняет init.sql только на пустом каталоге данных, и старая база осталась бы без новой строки ALTER TABLE. Папка ./data смонтирована как обычный каталог и при этом не удаляется. Флаг --build заставляет пересобрать образ. Без него Compose увидит готовый payguard-flink-sql:2.2.1 из урока 14 и новых коннекторов в кластере не будет. Флаг --profile sql включает службы SQL-кластера, ты уже знаешь его по уроку 14.

Шаг 5. Запусти CDC

Создай sql/cdc_merchants.sql.

sql/cdc_merchants.sql
-- Изменения таблицы merchants в реальном времени: снимок плюс журнал WAL, на выходе компактируемый топик.
SET 'pipeline.name' = 'payguard-cdc-merchants';

CREATE TABLE merchants_cdc (
    merchant_id STRING,
    risk_level  STRING,
    updated_at  TIMESTAMP(6) WITH LOCAL TIME ZONE,
    PRIMARY KEY (merchant_id) NOT ENFORCED
) WITH (
    'connector' = 'postgres-cdc',
    'hostname' = 'postgres',
    'port' = '5432',
    'username' = 'payguard',
    'password' = 'payguard',
    'database-name' = 'payguard',
    'schema-name' = 'public',
    'table-name' = 'merchants',
    'slot.name' = 'payguard_merchants',
    'scan.incremental.snapshot.enabled' = 'true',
    'decoding.plugin.name' = 'pgoutput'
);

CREATE TABLE merchants_changelog (
    merchant_id STRING,
    risk_level  STRING,
    PRIMARY KEY (merchant_id) NOT ENFORCED
) WITH (
    'connector' = 'upsert-kafka',
    'topic' = 'merchants.changelog',
    'properties.bootstrap.servers' = 'kafka:9092',
    'key.format' = 'json',
    'value.format' = 'json'
);

INSERT INTO merchants_changelog SELECT merchant_id, risk_level FROM merchants_cdc;
КОД
    'connector' = 'postgres-cdc',
    'hostname' = 'postgres',
    'port' = '5432',
    'username' = 'payguard',
    'password' = 'payguard',
    'database-name' = 'payguard',
    'schema-name' = 'public',
    'table-name' = 'merchants',
    'slot.name' = 'payguard_merchants',
    'scan.incremental.snapshot.enabled' = 'true',
    'decoding.plugin.name' = 'pgoutput'
ПРОСТЫМИ СЛОВАМИ

Строка connector выбирает коннектор, следующие строки говорят, куда подключаться и под каким пользователем. Значения совпадают с настройками службы postgres в Compose.

База, схема и таблица, которые мы читаем.

Имя слота на сервере. Обязательная настройка, и каждому заданию нужно своё имя.

Режим снимка по чанкам. По умолчанию выключен.

Плагин декодирования. Значение по умолчанию decoderbufs должно быть установлено на сервере, мы его не ставим и берём стандартный pgoutput.

Таблица-приёмник в строках 23-33 содержит PRIMARY KEY, он станет ключом сообщения. Колонку updated_at мы намеренно не переносим. Формат JSON указан отдельно для ключа и для значения. Отправь задание на SQL-кластер.

терминал
docker compose --profile sql exec -T sql-jobmanager sql-client.sh -f /sql/cdc_merchants.sql

Флаг -f выполняет файл целиком, флаг -T у exec отключает псевдотерминал, в скрипте он не нужен. Клиент отправит INSERT и завершится, а задание останется работать на кластере, потому что по умолчанию DML выполняется асинхронно.

В исходниках

Значение по умолчанию у настройки table.dml-sync равно false, описание говорит об асинхронном запуске. Именно поэтому терминал вернётся сразу.

apache/flink@release-2.2.1 · TableConfigOptions.java, строки 75-83
public static final ConfigOption<Boolean> TABLE_DML_SYNC =
        key("table.dml-sync")
                .booleanType()
                .defaultValue(false)
                .withDescription(
                        "Specifies if the DML job (i.e. the insert operation) is executed asynchronously or synchronously. "
                                + "By default, the execution is async, so you can submit multiple DML jobs at the same time. "
                                + "If set this option to true, the insert operation will wait for the job to finish.");

Посмотри на слот со стороны базы.

терминал
docker compose exec postgres psql -U payguard -d payguard -c "SELECT slot_name, plugin, active, restart_lsn, confirmed_flush_lsn, wal_status, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained FROM pg_replication_slots;"

Должна быть строка payguard_merchants с плагином pgoutput и active, равным t. Колонка retained показывает, сколько журнала удерживает слот, разницу считает pg_wal_lsn_diff. Этот же запрос подойдёт для алерта. В момент снимка рядом могут появиться временные слоты с суффиксами _0 и _1.

Шаг 6. Поменяй строку и прочитай топик

терминал
docker compose exec postgres psql -U payguard -d payguard \
  -c "update merchants set risk_level='HIGH' where merchant_id='m-0007'"

docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 --topic merchants.changelog --from-beginning \
  --formatter-property print.key=true --timeout-ms 10000 2>/dev/null | grep m-0007

Настройка print.key=true велит печатать ключ перед значением через табуляцию, а --timeout-ms завершает чтение, когда новых записей нет 10 с. Изменение появилось в топике.

вывод
{"merchant_id":"m-0007"}	{"merchant_id":"m-0007","risk_level":"HIGH"}

Слева ключ, справа значение. Рядом с этой строкой лежит прежняя с LOW, она из снимка. Компактирование её пока не убрало, потому что активный сегмент не компактируется. Для потребителя это безопасно, последняя запись по ключу побеждает.

Посчитай записи, заменив grep m-0007 на wc -l. На чистом стенде мы получили 50 записей сразу после снимка, по одной на продавца из init.sql, и 51 после одного UPDATE. Повторяется по ключу только m-0007. В первом, отладочном прогоне вышло 52. Там строку меняли и до включения REPLICA IDENTITY FULL, и после него, и лишняя запись скорее всего осталась от той отладки. Приёмник по умолчанию даёт гарантию at-least-once, а повтор по тому же ключу компактируемому топику не вредит.

Шаг 7. Сложи алерты в озеро

Создай sql/paimon_alerts.sql.

sql/paimon_alerts.sql
-- Алерты из Kafka в таблицу озера данных. Первичный ключ убирает повторы при перезапусках.
SET 'pipeline.name' = 'payguard-alerts-lake';

CREATE CATALOG lake WITH (
    'type' = 'paimon',
    'warehouse' = 'file:/flink-data/lake'
);

CREATE TABLE IF NOT EXISTS lake.`default`.fraud_alerts (
    alert_id      STRING,
    rule_id       STRING,
    card_id       STRING,
    merchant_id   STRING,
    merchant_risk STRING,
    event_day     STRING,
    event_time    TIMESTAMP(3),
    PRIMARY KEY (event_day, alert_id) NOT ENFORCED
) PARTITIONED BY (event_day) WITH (
    'bucket' = '2',
    'changelog-producer' = 'input'
);

CREATE TEMPORARY TABLE alerts_topic (
    alertId      STRING,
    ruleId       STRING,
    cardId       STRING,
    merchantId   STRING,
    merchantRisk STRING,
    eventTimeMs  BIGINT
) WITH (
    'connector' = 'kafka',
    'topic' = 'fraud.alerts',
    'properties.bootstrap.servers' = 'kafka:9092',
    'properties.group.id' = 'payguard-alerts-lake',
    'properties.isolation.level' = 'read_committed',
    'scan.startup.mode' = 'earliest-offset',
    'format' = 'json'
);

INSERT INTO lake.`default`.fraud_alerts
SELECT alertId, ruleId, cardId, merchantId, merchantRisk,
       DATE_FORMAT(TO_TIMESTAMP_LTZ(eventTimeMs, 3), 'yyyy-MM-dd'),
       CAST(TO_TIMESTAMP_LTZ(eventTimeMs, 3) AS TIMESTAMP(3))
FROM alerts_topic;
КОД
CREATE CATALOG lake WITH (
    'type' = 'paimon',
    'warehouse' = 'file:/flink-data/lake'
);

CREATE TABLE IF NOT EXISTS lake.`default`.fraud_alerts (
    alert_id      STRING,
    rule_id       STRING,
    card_id       STRING,
    merchant_id   STRING,
    merchant_risk STRING,
    event_day     STRING,
    event_time    TIMESTAMP(3),
    PRIMARY KEY (event_day, alert_id) NOT ENFORCED
) PARTITIONED BY (event_day) WITH (
    'bucket' = '2',
    'changelog-producer' = 'input'
);
ПРОСТЫМИ СЛОВАМИ

Каталог Paimon. warehouse это корневая папка всех таблиц. Она лежит на томе ./data, который виден и JobManager, и TaskManager. В проде здесь был бы адрес объектного хранилища.

Таблица создаётся в каталоге и переживает перезапуск клиента, поэтому IF NOT EXISTS позволяет запускать файл повторно.

Обычные колонки алерта и день события, по которому мы партиционируем.

В первичный ключ входит колонка партиции, поэтому строка не может переехать в другую партицию, и особый режим cross partition upsert не нужен. Повтор alert_id в пределах дня схлопнется в одну строку.

Два фиксированных бакета в каждой партиции. changelog-producer = input велит считать входной поток полным changelog и писать его в отдельные файлы для потоковых читателей. У нас вход состоит из вставок, поэтому никаких затрат на вычисление старых значений нет.

Таблица alerts_topic объявлена как TEMPORARY, поэтому живёт только в сессии и не записывается в каталог Paimon. Настройка properties.isolation.level = read_committed нужна, чтобы не читать незафиксированные алерты, разницу мы измерили в уроке 12.

Запусти основное задание, задание озера и генератор с редкими сериями. Генератор занимает терминал, поэтому он идёт последним.

терминал
scripts/submit.sh
docker compose --profile sql exec -T sql-jobmanager sql-client.sh -f /sql/paimon_alerts.sql
./gradlew loadgen --args="--rate 100 --burst 0.001 --card-testing 0.002"

Флаги --burst и --card-testing задают доли платежей, из которых вырастают серии для правил velocity и card-testing. Через пару чекпоинтов посмотри на папку таблицы из второго терминала.

терминал
ls -F data/lake/default.db/fraud_alerts
вывод
event_day=2026-11-07/ manifest/ schema/ snapshot/

Имя партиции у тебя будет с другой датой. Внутри неё лежат bucket-0 и bucket-1 с файлами data-*.parquet и changelog-*.parquet. Файл snapshot/LATEST хранит номер последнего снимка. Открой файл снимка с этим номером, это JSON, поля totalRecordCount и deltaRecordCount показывают, сколько записей в таблице и сколько добавил этот коммит.

Шаг 8. Убери слот за собой

Когда закончишь эксперименты, отмени задание CDC и удали слот. Команда flink list покажет идентификатор задания. Слот с подключённым получателем удалить нельзя, поэтому порядок такой.

терминал
docker compose --profile sql exec -T sql-jobmanager flink list
docker compose --profile sql exec -T sql-jobmanager flink cancel <job-id>
docker compose exec postgres psql -U payguard -d payguard \
  -c "SELECT pg_drop_replication_slot('payguard_merchants');"

Задание. Пакетный отчёт по таблице озера

Пока задание payguard-alerts-lake пишет алерты, напиши файл sql/paimon_report.sql. Он должен посчитать, сколько алертов дало каждое правило для каждого уровня риска продавца, и показать результат в терминале.

  1. Запрос читает таблицу fraud_alerts из каталога Paimon.
  2. В результате три колонки, правило, уровень риска и число алертов. Сверху самые частые сочетания.
  3. Файл запускается той же командой sql-client.sh -f, что и остальные, на SQL-кластере.

Готово, если команда завершается сама и печатает таблицу прямо в терминал. В Web UI на порту 8082 пакетное задание завершается, а payguard-alerts-lake продолжает работать. При повторном запуске через минуту числа растут.

Подсказки. Посмотри, какая настройка переводит клиент в пакетный режим и какая отвечает за вид результата в терминале. Подумай, что клиент помнит о каталоге после завершения предыдущего запуска. В потоковом режиме ORDER BY по произвольной колонке не работает, и документация объясняет почему.

Ответ

Глоссарий урока

CDC (change data capture)
Захват изменений. Изменения строк читаются из журнала базы и идут потоком в другие системы.
PostgreSQL
Реляционная база данных с открытым кодом. В курсе в ней живут справочник продавцов и итоги.
WAL (write-ahead log)
Журнал упреждающей записи PostgreSQL. Изменение попадает в файлы данных только после записи в него.
LSN (log sequence number)
Номер позиции в WAL.
Логическое декодирование (logical decoding)
Механизм PostgreSQL, который превращает WAL в поток изменений строк для внешнего читателя. Требует wal_level = logical.
pgoutput
Стандартный плагин вывода логического декодирования PostgreSQL, на нём работает встроенная логическая репликация.
Слот репликации (replication slot)
Объект на сервере PostgreSQL, который хранит позицию читателя и запрещает удалять журнал после неё. Живёт независимо от подключения.
REPLICA IDENTITY
Настройка таблицы PostgreSQL, которая решает, какие старые значения строки записать в журнал при UPDATE и DELETE.
Debezium
Библиотека чтения журналов баз данных. Flink CDC использует её классы внутри своих источников.
Инкрементальный снимок (incremental snapshot)
Начальное чтение таблицы частями по первичному ключу. Чанки читаются параллельно и без глобальной блокировки на чтение.
Дозаполнение (backfill)
Чтение журнала между нижней и верхней границами чанка, чтобы влить изменения, случившиеся во время запроса.
Changelog
Поток записей с видом изменения, +I вставка, -U и +U старая и новая версии при обновлении, -D удаление. Подробно в уроке 14.
Upsert
Запись, которая вставляет строку или заменяет существующую с тем же ключом.
Tombstone
Сообщение Kafka с ключом и пустым значением. Означает удаление этого ключа.
Компактируемый топик (compacted topic)
Топик Kafka с cleanup.policy=compact. Брокер хранит для каждого ключа как минимум последнее значение.
Apache Kafka
Распределённый журнал сообщений. Записи лежат в топиках и хранятся заданное время.
Топик (topic)
Именованный журнал в Kafka, разбитый на партиции.
Чекпоинт (checkpoint)
Согласованный снимок состояния задания Flink. С него задание продолжает работу после сбоя.
Lakehouse
Таблицы из файлов в дешёвом хранилище с метаданными, которые дают атомарную публикацию изменений. Читать такую таблицу можно разными движками.
Apache Paimon
Формат таблиц для озера данных с упором на потоковую запись и обновления по первичному ключу.
Apache Iceberg
Другой открытый формат таблиц для озера данных. В этом курсе на стенде не используется.
Parquet
Колоночный формат файлов. По умолчанию Paimon хранит в нём данные.
Бакет (bucket)
Группа файлов внутри партиции таблицы Paimon. У каждого бакета таблицы с ключом своё LSM-дерево.
LSM-дерево (log-structured merge-tree)
Способ хранения, при котором записи копятся в памяти, сбрасываются отсортированными файлами и потом сливаются.
Compaction
В Paimon фоновое слияние файлов бакета, которое сокращает работу при чтении.
Снимок таблицы Paimon
Опубликованное состояние таблицы, файл snapshot/snapshot-N со ссылками на схему и манифесты.
Манифест (manifest)
Файл метаданных Paimon, который перечисляет добавленные и убранные файлы данных.
Механизм слияния (merge engine)
Правило Paimon, по которому записи с одним ключом дают одну строку. По умолчанию deduplicate, он оставляет последнюю.
Чтение в прошлое (time travel)
Запрос к одной из сохранённых старых версий таблицы.
Apache Hadoop
Платформа распределённого хранения и вычислений. Paimon обращается к её классам, поэтому нужен jar даже при работе с локальным диском.
At-least-once
Гарантия доставки, при которой запись не теряется, но может прийти повторно.

Урок 16 · Прод

Пирамида тестов Flink, от одной функции до Kafka в контейнере

После урока ты сможешь для любой ошибки в задании назвать самый дешёвый тест, который её поймает, и соберёшь для PayGuard проверку графа, restore-тест, сквозной тест и CI.

Зелёные тесты и упавший запуск

В уроке 8 мы уже видели такую сцену. Тесты правил проходят, jar собирается, а отправка на кластер падает ещё до старта задания.

вывод flink run
RuleConfig[velocityMaxPayments=10, blockedCountries=[]] is not serializable. The object probably contains or references non serializable fields.

Тест на harness этой ошибки не видел. Он проверял функцию, и функция работала правильно. Сломалось другое свойство, функцию нельзя отправить по сети на TaskManager.

Вторая ошибка опаснее, потому что молчит. Оператор без uid работает как обычно. Flink сам выводит его идентификатор из положения в графе, и документация предупреждает, что такой идентификатор меняется при правке задания. Узнаешь ты об этом в день релиза, когда новая версия не сможет сопоставить состояние из savepoint со своими операторами.

У задания Flink много свойств, которые нельзя проверить одним видом теста. Логика, сериализуемость, восстановление, транзакции и упаковка ломаются независимо. Поэтому тестов нужно несколько видов, и у каждого своя цена.

Проверка квартиры после ремонта

Электрик сдаёт квартиру. Сначала он подносит тестер к каждой розетке, это быстро и точно показывает неисправную. Затем выключает свет и включает снова, чтобы посмотреть, помнит ли термостат своё расписание. Потом читает схему щитка при выключенном токе и видит автомат без подписи, хотя тот исправно работает. Дальше питает квартиру целиком от переносного генератора. В конце подключает её к городской сети.

В тестах Flink этим шагам соответствуют функция на harness, restore-тест, топология без запуска, MiniCluster и сквозной тест в контейнерах. Генератор проверяет всю проводку и обходится без города. Городская сеть проверяет ввод в дом, и стоит это дороже всего. Выбери ошибку и посмотри, какие уровни её заметят.

Какую ошибку видит каждый уровень

Строки про RuleConfig, забытый uid и поле вместо состояния проверены поломкой кода на копии снапшота и запуском ./gradlew test. Остальные строки выведены из исходников и кода тестов, запуском с поломкой мы их не проверяли.

УровеньЧто запускаетсяГде в PayGuardГде в CI
Функция на harnessОдин оператор в макете окруженияCardProfileFunctionTest и другие тесты из прошлых уроков./gradlew test
Restore на harnessСнимок одного оператора и подъём второго из негоCardProfileRestoreTest./gradlew test
ТопологияПостроение графа, заданий нетPipelineTopologyTest./gradlew test
MiniClusterЧасть конвейера на встроенном кластереMerchantStatsWindowTest, AlertEnrichmentTest./gradlew test
E2EВесь граф на встроенном кластере, Kafka и PostgreSQL в контейнерахPayGuardE2ETest./gradlew e2eTest

Уровень 1. Функция на harness

Test harness создаёт настоящий оператор Flink внутри макета окружения. Сети, слотов и потоков задания здесь нет. Ты сам подаёшь запись через processElement, двигаешь время через processWatermark и setProcessingTime и читаешь выход через extractOutputValues. Такой тест быстрый и детерминированный, поэтому граничные случаи пишут здесь.

В Spark потоковую логику обычно проверяют на локальной сессии с master("local[*]") и источником MemoryStream, то есть запускают настоящий запрос. Во Flink такой запуск тоже есть, это MiniCluster. Harness даёт ещё один уровень ниже него, где оператор можно вызвать напрямую и подвинуть его время вручную.

В исходниках

Почему harness пропустил RuleConfig. Функцию DynamicRules в граф добавляет process из BroadcastConnectedStream, и он прогоняет её через clean окружения. Третий аргумент true включает проверку сериализуемости.

apache/flink@release-2.2.1 · StreamExecutionEnvironment.java, строки 2112-2118
public <F> F clean(F f) {
    if (getConfig().isClosureCleanerEnabled()) {
        ClosureCleaner.clean(f, getConfig().getClosureCleanerLevel(), true);
    }
    ClosureCleaner.ensureSerializable(f);
    return f;
}

Сама проверка пытается сериализовать объект. Строки с сообщением дают ровно тот текст, что мы получили от flink run.

apache/flink@release-2.2.1 · ClosureCleaner.java, строки 147-172
if (checkSerializable) {
    try {
        InstantiationUtil.serializeObject(func);
    } catch (Exception e) {
        String functionType = getSuperClassOrInterfaceName(func.getClass());

        String msg =
                functionType == null
                        ? (func + " is not serializable.")
                        : ("The implementation of the "
                                + functionType
                                + " is not serializable.");

        if (closureAccessed) {
            msg +=
                    " The implementation accesses fields of its enclosing class, which is "
                            + "a common reason for non-serializability. "
                            + "A common solution is to make the function a proper (non-inner) class, or "
                            + "a static inner class.";
        } else {
            msg += " The object probably contains or references non serializable fields.";
        }

        throw new InvalidProgramException(msg, e);
    }
}

Теперь конструктор harness. Функция сюда приходит уже упакованной в оператор, и clean для неё никто не вызывает. Чистится только селектор ключа, и последний аргумент false отключает проверку даже для него.

apache/flink@release-2.2.1 · KeyedOneInputStreamOperatorTestHarness.java, строки 59-74
public KeyedOneInputStreamOperatorTestHarness(
        StreamOperatorFactory<OUT> operatorFactory,
        final KeySelector<IN, K> keySelector,
        TypeInformation<K> keyType,
        int maxParallelism,
        int numSubtasks,
        int subtaskIndex)
        throws Exception {
    super(operatorFactory, maxParallelism, numSubtasks, subtaskIndex);

    ClosureCleaner.clean(keySelector, ExecutionConfig.ClosureCleanerLevel.RECURSIVE, false);
    config.setStatePartitioner(0, keySelector);
    config.setStateKeySerializer(
            keyType.createSerializer(executionConfig.getSerializerConfig()));
    config.serializeAllConfigs();
}

Harness проверяет поведение оператора. Вопрос о том, можно ли этот оператор отправить на кластер, он перед собой не ставит.

Уровень 2. Restore-тест

Если функция держит данные в поле вместо состояния Flink, обычный тест на harness этого не заметит. Пока процесс жив, поле ведёт себя как состояние. Restore-тест снимает снимок с одного экземпляра оператора, поднимает из него второй и подаёт ещё одну запись. Так он воспроизводит перезапуск задания прямо в памяти.

В исходниках

Метод snapshot из harness отдаёт состояние в том виде, в каком его получил бы JobManager. Внутри вызывается настоящий snapshotState оператора.

apache/flink@release-2.2.1 · AbstractStreamOperatorTestHarness.java, строки 730-736
/**
 * Calls {@link StreamOperator#snapshotState(long, long, CheckpointOptions,
 * org.apache.flink.runtime.state.CheckpointStreamFactory)}.
 */
public OperatorSubtaskState snapshot(long checkpointId, long timestamp) throws Exception {
    return snapshotWithLocalState(checkpointId, timestamp).getJobManagerOwnedState();
}
apache/flink@release-2.2.1 · AbstractStreamOperatorTestHarness.java, строки 751-765
public OperatorSnapshotFinalizer snapshotWithLocalState(
        long checkpointId, long timestamp, SnapshotType checkpointType) throws Exception {

    CheckpointStorageLocationReference locationReference =
            CheckpointStorageLocationReference.getDefault();
    OperatorSnapshotFutures operatorStateResult =
            operator.snapshotState(
                    checkpointId,
                    timestamp,
                    new CheckpointOptions(checkpointType, locationReference),
                    checkpointStorageAccess.resolveCheckpointStorageLocation(
                            checkpointId, locationReference));

    return OperatorSnapshotFinalizer.create(operatorStateResult);
}

При подъёме снимок кладётся под идентификатор текущего оператора. Смотри на operator.getOperatorID(). Идентификатор старого экземпляра нигде не сравнивается. Новый harness по умолчанию получает случайный new OperatorID().

apache/flink@release-2.2.1 · AbstractStreamOperatorTestHarness.java, строки 625-633
if (jmOperatorStateHandles != null) {

    TaskStateSnapshot jmTaskStateSnapshot = new TaskStateSnapshot();
    jmTaskStateSnapshot.putSubtaskStateByOperatorID(
            operator.getOperatorID(), jmOperatorStateHandles);

    taskStateManager.setReportedCheckpointId(restoredCheckpointId);
    taskStateManager.setJobManagerTaskStateSnapshotsByCheckpointId(
            Collections.singletonMap(restoredCheckpointId, jmTaskStateSnapshot));

Отсюда следует граница этого теста. Он докажет, что оператор сохраняет и читает своё состояние. Сопоставление по uid он не проверяет, эту работу мы отдадим следующему уровню.

Вторая граница в том, что на обеих сторонах стоит один и тот же класс. Изменение схемы состояния такой тест не увидит. Для этого harness умеет читать снимок из файла, а OperatorSnapshotUtil.writeStateHandle умеет его записать. Снимок старой версии можно положить в ресурсы теста и поднимать из него каждую новую версию. В PayGuard мы это проверяли руками в уроке 13, когда запускали v2 с новым полем из savepoint версии v1.

apache/flink@release-2.2.1 · AbstractStreamOperatorTestHarness.java, строки 513-515
public void initializeState(String operatorStateSnapshotPath) throws Exception {
    initializeState(OperatorSnapshotUtil.readStateHandle(operatorStateSnapshotPath));
}

Третья граница это хранилище состояния. По умолчанию harness держит его в куче, хотя в нашем Compose стоит state.backend.type: rocksdb. Заменить хранилище можно методом setStateBackend.

apache/flink@release-2.2.1 · AbstractStreamOperatorTestHarness.java, строки 140-141
// use this as default for tests
protected StateBackend stateBackend = new HashMapStateBackend();

Уровень 3. Топология без запуска

Самый дешёвый способ проверить задание целиком это построить его граф и посмотреть на него. Метод env.getStreamGraph() вызывает весь код описания задания, но ничего не отправляет на исполнение. Kafka, база и кластер для этого не нужны. Такой тест поймает все ошибки, которые возникают при описании графа, и в их числе RuleConfig без Serializable.

В исходниках

Граф получается вызовом генератора, запуска в этом коде нет. Обрати внимание на @Internal. Это внутренний метод, и при обновлении Flink тест может потребовать правки.

apache/flink@release-2.2.1 · StreamExecutionEnvironment.java, строки 2033-2045
@Internal
public StreamGraph getStreamGraph(boolean clearTransformations) {
    final StreamGraph streamGraph = getStreamGraph(transformations);
    if (clearTransformations) {
        transformations.clear();
    }
    return streamGraph;
}

private StreamGraph getStreamGraph(List<Transformation<?>> transformations) {
    synchronizeClusterDatasetStatus();
    return getStreamGraphGenerator(transformations).generate();
}

Генератор переносит uid из описания оператора в узел графа, только если ты его задал. Значит, null в узле означает забытый uid. В этом же месте видна встроенная альтернатива, настройка pipeline.auto-generate-uids. При false Flink сам бросит исключение на первом операторе без uid.

apache/flink@release-2.2.1 · StreamGraphGenerator.java, строки 546-567
if (transform.getUid() != null) {
    streamGraph.setTransformationUID(transform.getId(), transform.getUid());
}
if (transform.getAdditionalMetricVariables() != null) {
    streamGraph.setAdditionalMetricVariables(
            transform.getId(), transform.getAdditionalMetricVariables());
}
if (transform.getUserProvidedNodeHash() != null) {
    streamGraph.setTransformationUserHash(
            transform.getId(), transform.getUserProvidedNodeHash());
}

if (!streamGraph.getExecutionConfig().hasAutoGeneratedUIDsEnabled()) {
    if (transform instanceof PhysicalTransformation
            && transform.getUserProvidedNodeHash() == null
            && transform.getUid() == null) {
        throw new IllegalStateException(
                "Auto generated UIDs have been disabled "
                        + "but no UID or hash has been assigned to operator "
                        + transform.getName());
    }
}

Узел отдаёт значение через обычный геттер, на него и опирается наш тест.

apache/flink@release-2.2.1 · StreamNode.java, строки 388-394
public String getTransformationUID() {
    return transformationUID;
}

void setTransformationUID(String transformationId) {
    this.transformationUID = transformationId;
}

Следующий шаг после StreamGraph это JobGraph, тот граф, что уходит на кластер. Метод без аргументов помечен @VisibleForTesting, он как раз для таких проверок.

apache/flink@release-2.2.1 · StreamGraph.java, строки 1160-1173
/** Gets the assembled {@link JobGraph} with a random {@link JobID}. */
@VisibleForTesting
public JobGraph getJobGraph() {
    return getJobGraph(Thread.currentThread().getContextClassLoader(), jobId);
}

public JobGraph getJobGraph(ClassLoader userClassLoader) {
    return getJobGraph(userClassLoader, jobId);
}

/** Gets the assembled {@link JobGraph} with a specified {@link JobID}. */
public JobGraph getJobGraph(ClassLoader userClassLoader, @Nullable JobID jobID) {
    return StreamingJobGraphGenerator.createJobGraph(userClassLoader, this, jobID);
}

С сериализаторами всё тоньше. В уроке 3 мы запретили Kryo настройкой pipeline.generic-types: false. Запрет срабатывает при создании сериализатора, а создаются они в двух разных местах и в разное время.

В исходниках

Запрет живёт в типе, который ушёл бы в Kryo.

apache/flink@release-2.2.1 · GenericTypeInfo.java, строки 84-93
public TypeSerializer<T> createSerializer(SerializerConfig config) {
    if (config.hasGenericTypesDisabled()) {
        throw new UnsupportedOperationException(
                "Generic types have been disabled in the ExecutionConfig and type "
                        + this.typeClass.getName()
                        + " is treated as a generic type.");
    }

    return new KryoSerializer<T>(this.typeClass, config);
}

Сериализаторы для типов на рёбрах графа создаёт StreamGraph при добавлении оператора. Такая ошибка всплывёт уже в тесте топологии.

apache/flink@release-2.2.1 · StreamGraph.java, строки 1183-1187
private <T> TypeSerializer<T> createSerializer(TypeInformation<T> typeInfo) {
    return typeInfo != null && !(typeInfo instanceof MissingTypeInfo)
            ? typeInfo.createSerializer(executionConfig.getSerializerConfig())
            : null;
}

Сериализатор состояния создаётся в момент getState, а мы вызываем его из open, то есть на работающей подзадаче. Граф об этом типе ещё ничего не знает. Такую ошибку покажет только запуск с тем же окружением.

apache/flink@release-2.2.1 · StreamingRuntimeContext.java, строки 203-208
@Override
public <T> ValueState<T> getState(ValueStateDescriptor<T> stateProperties) {
    KeyedStateStore keyedStateStore = checkPreconditionsAndGetKeyedStateStore(stateProperties);
    stateProperties.initializeSerializerUnlessSet(this::createSerializer);
    return keyedStateStore.getState(stateProperties);
}

Harness берёт конфигурацию из MockEnvironmentBuilder, там лежит обычный new ExecutionConfig() с разрешённым Kryo. Поэтому тесты обязаны брать окружение из той же фабрики, что и прод, иначе они проверяют другие условия. Для этого в практике появится метод createEnvironment.

Уровень 4. MiniCluster

MiniCluster это встроенный кластер Flink внутри JVM теста. Записи идут через настоящие подзадачи, параллелизм и водяные знаки. Источники и приёмники здесь можно подменить, как MerchantStatsWindowTest подаёт готовый список платежей через fromData. Документация советует три вещи. Запускай с параллелизмом больше 1, потому что часть ошибок видна только так. Делай кластер общим для тестов класса, потому что старт и остановка съедают большую часть времени. Состояние можно проверить настоящим перезапуском, если тестовая функция бросит исключение при включённых чекпоинтах.

В исходниках

Самое полезное свойство расширения JUnit в том, что код задания менять не нужно. Перед каждым тестом оно регистрирует своё окружение как контекстное.

apache/flink@release-2.2.1 · MiniClusterExtension.java, строки 257-272
private void registerEnv(InternalMiniClusterExtension internalMiniClusterExtension) {
    final Configuration configuration =
            internalMiniClusterExtension.getMiniCluster().getConfiguration();

    final int defaultParallelism =
            configuration
                    .getOptional(CoreOptions.DEFAULT_PARALLELISM)
                    .orElse(internalMiniClusterExtension.getNumberSlots());

    TestStreamEnvironment.setAsContext(
            internalMiniClusterExtension.getMiniCluster(), defaultParallelism);
    this.streamExecutionEnvironment =
            new TestStreamEnvironment(
                    internalMiniClusterExtension.getMiniCluster(),
                    internalMiniClusterExtension.getNumberSlots());
}

Фабрика принимает конфигурацию вызывающего кода и применяет её через env.configure. Поэтому GENERIC_TYPES=false из нашего метода доезжает до тестового окружения.

apache/flink@release-2.2.1 · TestStreamEnvironment.java, строки 106-118
StreamExecutionEnvironmentFactory factory =
        conf -> {
            TestStreamEnvironment env =
                    new TestStreamEnvironment(
                            miniCluster, conf, parallelism, jarFiles, classpaths);

            randomizeConfiguration(miniCluster, conf);

            env.configure(conf, env.getUserClassloader());
            return env;
        };

initializeContextEnvironment(factory);

Осталось посмотреть, кто читает эту фабрику. Вызов getExecutionEnvironment берёт её, если она есть, и создаёт локальное окружение, если нет. При flink run фабрику ставит клиент через ClientUtils. Значит, один и тот же createEnvironment работает на кластере, поверх MiniCluster в E2E и локально в тесте топологии, где расширения нет и граф просто строится.

apache/flink@release-2.2.1 · StreamExecutionEnvironment.java, строки 2194-2198
public static StreamExecutionEnvironment getExecutionEnvironment(Configuration configuration) {
    return Utils.resolveFactory(threadLocalContextEnvironmentFactory, contextEnvironmentFactory)
            .map(factory -> factory.createExecutionEnvironment(configuration))
            .orElseGet(() -> StreamExecutionEnvironment.createLocalEnvironment(configuration));
}

Уровень 5. Сквозной тест в контейнерах

Документация предлагает подменять источники и приёмники тестовыми. Это хорошо для логики, но транзакции Kafka и SQL к настоящей базе так не проверить. Библиотека Testcontainers запускает из теста настоящие контейнеры и сама их убирает. В PayGuard сквозной тест строит весь граф через buildPipeline, работает на MiniCluster и общается с настоящими Kafka и PostgreSQL.

Алерты пишутся с гарантией EXACTLY_ONCE, и по документации коннектора такая транзакция фиксируется на чекпоинте. Потребитель с read_committed не увидит алерт, пока чекпоинт не пройдёт. Поэтому в тесте включены чекпоинты каждую секунду, настройки кластера из Compose сюда не приходят. Тот же rocksdb из Compose в этом тесте тоже не действует, задание живёт на настройках по умолчанию.

⚠
Ни один уровень не запускает твой jar

Все пять уровней работают на тестовом classpath Gradle. Толстый jar, образ Flink и каталог lib в них не участвуют. Ошибка NoSuchMethodError из урока 14, когда SQL-коннектор лежит в lib кластера, пройдёт через все тесты. Её покажет только запуск собранного jar на настоящем образе. В PayGuard такой проверки в CI пока нет, её мы делали руками в каждом уроке.

Порядок в CI

Быстрые тесты идут первыми и не требуют Docker. Сквозной тест запускается отдельным заданием и только после успешной сборки, чтобы не тратить время на контейнеры, если код уже сломан. Локально удобно гонять ./gradlew test перед каждым коммитом и запускать сквозной тест по необходимости.

Проверь себя

Коллега положил в новую функцию поле с несериализуемым клиентом. Тесты функции на harness зелёные. Какой самый дешёвый тест поймает ошибку до отправки на кластер?

Перед релизом переименовали uid у оператора дедупликации. Restore-тест зелёный, а после релиза из savepoint состояние дедупликации к оператору не привязывается. Почему тест этого не показал?

PayGuardE2ETest зелёный, все 45 обычных тестов зелёные. На стенде с образом Flink и коннектором в lib команда flink run падает с NoSuchMethodError. Что не так с проверками?

Что будет в конце

В проекте появятся проверка графа, restore-тест, сквозной тест на настоящих Kafka и PostgreSQL, отдельная задача Gradle для него и файл CI. Итог прогона на нашем стенде был такой. Обычных тестов 45, вместе с тестом из задания, и все зелёные. Сквозной тест занял 7 с. Файл GitHub Actions мы не запускали ни разу, поэтому считай его проверенным только глазами.

Файл решения задания DeduplicateRestoreTest.java пока создавать не нужно. Без него обычных тестов будет на один меньше.

Шаг 1. Отдели описание графа от запуска

Весь граф был описан прямо в main и там же запускался. Тест такой метод вызвать не сможет, потому что env.execute начнёт работу. В PayGuardJob.java разделим его на три метода.

что изменилось в src/main/java/dev/study/payguard/job/PayGuardJob.java
@@ -51,12 +51,20 @@     private static final int ENRICHMENT_CAPACITY = 200;
 
     public static void main(String[] args) throws Exception {
-        JobConfig config = JobConfig.fromArgs(args);-+        StreamExecutionEnvironment env = createEnvironment();+        buildPipeline(env, JobConfig.fromArgs(args));+        env.execute("payguard");+    }++    /** Окружение с запретом на Kryo. Тесты берут его же, чтобы проверять те же условия, что и в проде. */+    public static StreamExecutionEnvironment createEnvironment() {         Configuration flinkConfig = new Configuration();
         flinkConfig.set(PipelineOptions.GENERIC_TYPES, false);
-        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(flinkConfig);-+        return StreamExecutionEnvironment.getExecutionEnvironment(flinkConfig);+    }++    /** Описывает весь граф задания, но не запускает его. Отдельный метод нужен тестам топологии и E2E. */+    public static void buildPipeline(StreamExecutionEnvironment env, JobConfig config) {         DataStream<PaymentEvent> payments = parsePayments(env, config);
         attachMerchantStats(payments, config);
         attachCardSessions(payments, config);
@@ -70,8 +78,6 @@         attachConfirmedPayments(payments, confirmations, config);
         DataStream<FraudAlert> unconfirmedAlerts = unconfirmedAlerts(payments, confirmations);
         sinkAlerts(fraudAlerts(env, byCard, unconfirmedAlerts, config), config);
--        env.execute("payguard");     }
 
     /** Разбирает сырые записи: битые уходят в DLQ, остальные становятся очищенным потоком платежей. */
КОД
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = createEnvironment();
        buildPipeline(env, JobConfig.fromArgs(args));
        env.execute("payguard");
    }

    /** Окружение с запретом на Kryo. Тесты берут его же, чтобы проверять те же условия, что и в проде. */
    public static StreamExecutionEnvironment createEnvironment() {
        Configuration flinkConfig = new Configuration();
        flinkConfig.set(PipelineOptions.GENERIC_TYPES, false);
        return StreamExecutionEnvironment.getExecutionEnvironment(flinkConfig);
    }

    /** Описывает весь граф задания, но не запускает его. Отдельный метод нужен тестам топологии и E2E. */
    public static void buildPipeline(StreamExecutionEnvironment env, JobConfig config) {
ПРОСТЫМИ СЛОВАМИ

main теперь занимает три строки. Он создаёт окружение, описывает граф и запускает его. Поведение на кластере осталось прежним.

createEnvironment хранит запрет на Kryo. Тесты вызовут именно его и получат те же условия, что и прод. Если написать в тесте свой getExecutionEnvironment, запрет потеряется молча.

GENERIC_TYPES со значением false это настройка pipeline.generic-types из урока 3.

buildPipeline только описывает граф. Он ничего не исполняет и принимает окружение снаружи, поэтому тест топологии и E2E вызовут его со своим окружением.

Шаг 2. Настройки базы, доступные из теста

Настройки PostgreSQL читались прямо из System.getenv. Поменять переменные окружения работающей JVM из Java-кода нельзя, а адрес базы в контейнере Testcontainers становится известен только после запуска контейнера. MiniCluster работает в той же JVM, что и тест, поэтому подойдут системные свойства. Создай в пакете job новый файл.

src/main/java/dev/study/payguard/job/DbSettings.java
package dev.study.payguard.job;

/** Подключение к PostgreSQL. На кластере берётся из переменных окружения, в тесте из системных свойств той же JVM. */
public record DbSettings(String url, String user, String password) {

    public static DbSettings resolve() {
        return new DbSettings(read("PAYGUARD_DB_URL"), read("PAYGUARD_DB_USER"), read("PAYGUARD_DB_PASSWORD"));
    }

    private static String read(String name) {
        String fromEnvironment = System.getenv(name);
        return fromEnvironment != null ? fromEnvironment : System.getProperty(name);
    }
}

Сначала читается переменная окружения, и на кластере ничего не меняется. Если её нет, берётся системное свойство. Теперь замени прямые вызовы в двух местах.

что изменилось в src/main/java/dev/study/payguard/job/JdbcSinks.java
@@ -18,6 +18,7 @@             """;
 
     static JdbcSink<MerchantStats> merchantStats() {
+        DbSettings db = DbSettings.resolve();         return JdbcSink.<MerchantStats>builder()
                 .withQueryStatement(UPSERT_MERCHANT_STATS, (statement, stats) -> {
                     statement.setString(1, stats.merchantId());
@@ -32,10 +33,10 @@                         .withMaxRetries(3)
                         .build())
                 .buildAtLeastOnce(new JdbcConnectionOptions.JdbcConnectionOptionsBuilder()
-                        .withUrl(System.getenv("PAYGUARD_DB_URL"))+                        .withUrl(db.url())                         .withDriverName("org.postgresql.Driver")
-                        .withUsername(System.getenv("PAYGUARD_DB_USER"))-                        .withPassword(System.getenv("PAYGUARD_DB_PASSWORD"))+                        .withUsername(db.user())+                        .withPassword(db.password())                         .build());
     }
 
что изменилось в src/main/java/dev/study/payguard/pipeline/JdbcMerchantDirectory.java
@@ -2,13 +2,14 @@ 
 import com.zaxxer.hikari.HikariConfig;
 import com.zaxxer.hikari.HikariDataSource;
+import dev.study.payguard.job.DbSettings; import java.sql.Connection;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.util.Optional;
 
-/** Читает уровень риска из PostgreSQL через пул соединений. Настройки берутся из переменных окружения. */+/** Читает уровень риска из PostgreSQL через пул соединений. Настройки подключения описаны в DbSettings. */ public final class JdbcMerchantDirectory implements MerchantDirectory {
 
     private static final String QUERY = "SELECT risk_level FROM merchants WHERE merchant_id = ?";
@@ -20,10 +21,11 @@     }
 
     public static JdbcMerchantDirectory fromEnvironment(int poolSize) {
+        DbSettings db = DbSettings.resolve();         HikariConfig config = new HikariConfig();
-        config.setJdbcUrl(System.getenv("PAYGUARD_DB_URL"));-        config.setUsername(System.getenv("PAYGUARD_DB_USER"));-        config.setPassword(System.getenv("PAYGUARD_DB_PASSWORD"));+        config.setJdbcUrl(db.url());+        config.setUsername(db.user());+        config.setPassword(db.password());         config.setMaximumPoolSize(poolSize);
         config.setReadOnly(true);
         return new JdbcMerchantDirectory(new HikariDataSource(config));

Шаг 3. Тест топологии

Создай src/test/java/dev/study/payguard/job/PipelineTopologyTest.java.

src/test/java/dev/study/payguard/job/PipelineTopologyTest.java
package dev.study.payguard.job;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.List;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.graph.StreamGraph;
import org.apache.flink.streaming.api.graph.StreamNode;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

/** Проверяет граф задания, не запуская его: ошибки сериализации и пропущенные uid видны за секунды. */
class PipelineTopologyTest {

    private static StreamGraph graph;

    @BeforeAll
    static void buildGraph() {
        System.setProperty("PAYGUARD_DB_URL", "jdbc:postgresql://unused:5432/unused");
        System.setProperty("PAYGUARD_DB_USER", "unused");
        System.setProperty("PAYGUARD_DB_PASSWORD", "unused");

        StreamExecutionEnvironment env = PayGuardJob.createEnvironment();
        PayGuardJob.buildPipeline(env, JobConfig.fromArgs(new String[0]));
        graph = env.getStreamGraph();
    }

    @Test
    void everyOperatorHasStableUid() {
        List<String> withoutUid = graph.getStreamNodes().stream()
                .filter(node -> node.getTransformationUID() == null)
                .map(StreamNode::getOperatorName)
                .toList();

        assertThat(withoutUid).isEmpty();
    }

    @Test
    void uidsAreUnique() {
        List<String> uids = graph.getStreamNodes().stream().map(StreamNode::getTransformationUID).toList();

        assertThat(uids).doesNotHaveDuplicates();
    }

    @Test
    void graphTranslatesToJobGraph() {
        assertThat(graph.getJobGraph().getNumberOfVertices()).isGreaterThan(1);
    }
}
КОД
    @BeforeAll
    static void buildGraph() {
        System.setProperty("PAYGUARD_DB_URL", "jdbc:postgresql://unused:5432/unused");
        System.setProperty("PAYGUARD_DB_USER", "unused");
        System.setProperty("PAYGUARD_DB_PASSWORD", "unused");

        StreamExecutionEnvironment env = PayGuardJob.createEnvironment();
        PayGuardJob.buildPipeline(env, JobConfig.fromArgs(new String[0]));
        graph = env.getStreamGraph();
    }

    @Test
    void everyOperatorHasStableUid() {
        List<String> withoutUid = graph.getStreamNodes().stream()
                .filter(node -> node.getTransformationUID() == null)
                .map(StreamNode::getOperatorName)
                .toList();

        assertThat(withoutUid).isEmpty();
    }
ПРОСТЫМИ СЛОВАМИ

Граф строится один раз на весь класс, тесты только смотрят на него.

Заглушки настроек нужны потому, что JDBC-приёмник читает настройки уже при описании графа, а конструктор JdbcConnectionOptions не принимает пустой адрес. Соединения с unused никто не открывает.

Окружение и граф берутся из тех же методов, что и в проде. Адрес Kafka остаётся значением по умолчанию, его тоже никто не использует.

getStreamGraph превращает описание в граф узлов. Если функция несериализуема, тест упадёт ещё раньше, в buildPipeline, и упадёт весь класс.

Первый тест собирает имена всех узлов без uid и требует пустой список. Сообщение AssertJ сразу называет виноватых.

Тесты uidsAreUnique и graphTranslatesToJobGraph проверяют, что идентификаторы не повторяются и что граф переводится в JobGraph. Запусти только этот класс.

терминал
./gradlew test --tests '*PipelineTopologyTest'

Флаг --tests отбирает тесты по шаблону имени. Кавычки нужны, чтобы оболочка не раскрыла звёздочку сама. Теперь проверь, что тест умеет падать, потому что тест, который никогда не падает, ничего не доказывает. Мы сделали две поломки на копии снапшота, повтори их и верни код обратно.

  • Убери .uid("dlq-sink") в PayGuardJob. Упадут everyOperatorHasStableUid и uidsAreUnique. Первый назовёт сразу два узла, dlq-sink: Writer и dlq-sink: Committer, потому что один вызов sinkTo разворачивается в два оператора. Второй увидит в списке повторяющийся null.
  • Убери implements Serializable у RuleConfig. Класс упадёт с тем же сообщением, что и flink run из урока 8. Тест DynamicRulesTest на harness при этом останется зелёным.

Шаг 4. Restore-тест

Создай src/test/java/dev/study/payguard/pipeline/CardProfileRestoreTest.java.

src/test/java/dev/study/payguard/pipeline/CardProfileRestoreTest.java
package dev.study.payguard.pipeline;

import static dev.study.payguard.testing.Payments.payment;
import static org.assertj.core.api.Assertions.assertThat;

import dev.study.payguard.model.CardProfile;
import dev.study.payguard.model.PaymentEvent;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.runtime.checkpoint.OperatorSubtaskState;
import org.apache.flink.streaming.api.operators.KeyedProcessOperator;
import org.apache.flink.streaming.util.KeyedOneInputStreamOperatorTestHarness;
import org.junit.jupiter.api.Test;

/** Снимает состояние в одном операторе и восстанавливает в другом, как при перезапуске задания. */
class CardProfileRestoreTest {

    @Test
    void profileSurvivesRestart() throws Exception {
        OperatorSubtaskState snapshot;
        try (var before = newHarness()) {
            before.open();
            before.processElement(payment("p1", "c1", "m1", 100, 1_000), 1_000);
            before.processElement(payment("p2", "c1", "m1", 200, 2_000), 2_000);
            snapshot = before.snapshot(1L, 2_000);
        }

        try (var after = newHarness()) {
            after.initializeState(snapshot);
            after.open();
            after.processElement(payment("p3", "c1", "m1", 400, 3_000), 3_000);

            assertThat(after.extractOutputValues()).containsExactly(new CardProfile(3, 700, "DE"));
        }
    }

    private static KeyedOneInputStreamOperatorTestHarness<String, PaymentEvent, CardProfile> newHarness() throws Exception {
        return new KeyedOneInputStreamOperatorTestHarness<>(
                new KeyedProcessOperator<>(new CardProfileFunction()), PaymentEvent::cardId, Types.STRING);
    }
}
КОД
    @Test
    void profileSurvivesRestart() throws Exception {
        OperatorSubtaskState snapshot;
        try (var before = newHarness()) {
            before.open();
            before.processElement(payment("p1", "c1", "m1", 100, 1_000), 1_000);
            before.processElement(payment("p2", "c1", "m1", 200, 2_000), 2_000);
            snapshot = before.snapshot(1L, 2_000);
        }

        try (var after = newHarness()) {
            after.initializeState(snapshot);
            after.open();
            after.processElement(payment("p3", "c1", "m1", 400, 3_000), 3_000);

            assertThat(after.extractOutputValues()).containsExactly(new CardProfile(3, 700, "DE"));
        }
    }

    private static KeyedOneInputStreamOperatorTestHarness<String, PaymentEvent, CardProfile> newHarness() throws Exception {
        return new KeyedOneInputStreamOperatorTestHarness<>(
                new KeyedProcessOperator<>(new CardProfileFunction()), PaymentEvent::cardId, Types.STRING);
    }
ПРОСТЫМИ СЛОВАМИ

Первый harness играет задание до падения. open создаёт состояние, и два платежа копятся в профиле карты c1.

snapshot снимает состояние. Аргументы это номер чекпоинта и его время, сами значения на результат здесь не влияют. После закрытия первого harness снимок остаётся живым.

Второй harness это задание после рестарта. initializeState вызывается строго до open, иначе harness бросит исключение, в исходниках эта проверка стоит первой.

Третий платёж и проверка. Значения 3 платежа и 700 единиц получаются, только если первые два платежа пережили рестарт. extractOutputValues вернёт выход только нового экземпляра, поэтому в нём один элемент.

Настоящий KeyedProcessOperator оборачивает функцию, и harness получает селектор ключа. Метод вынесен отдельно, чтобы оба экземпляра были созданы одинаково.

Проверь и его поломкой. Замени в CardProfileFunction состояние обычным полем. На копии снапшота CardProfileFunctionTest остался зелёным, а этот тест упал и показал профиль из одного платежа на 400 единиц. Именно такую ошибку он и должен ловить.

Шаг 5. Отдельная задача Gradle для E2E

Контейнеры не должны запускаться при каждом ./gradlew test. Сквозной тест помечается тегом JUnit, и две задачи выбирают тесты по этому тегу.

что изменилось в build.gradle.kts
@@ -51,10 +51,30 @@     testImplementation("org.apache.flink:flink-test-utils:$flinkVersion")
     testImplementation("org.apache.flink:flink-streaming-java:$flinkVersion:tests")
     testImplementation("org.apache.flink:flink-runtime:$flinkVersion:tests")
+    testImplementation(platform("org.testcontainers:testcontainers-bom:2.0.5"))+    testImplementation("org.testcontainers:testcontainers-junit-jupiter")+    testImplementation("org.testcontainers:testcontainers-kafka")+    testImplementation("org.testcontainers:testcontainers-postgresql") }
 
 tasks.test {
-    useJUnitPlatform()+    useJUnitPlatform { excludeTags("e2e") }+}++// Отдельная задача: E2E поднимает контейнеры и идёт минуты, а обычные тесты должны укладываться в секунды.+tasks.register<Test>("e2eTest") {+    group = "verification"+    description = "Сквозные тесты на Kafka и PostgreSQL в контейнерах"+    testClassesDirs = sourceSets.test.get().output.classesDirs+    classpath = sourceSets.test.get().runtimeClasspath+    useJUnitPlatform { includeTags("e2e") }+    shouldRunAfter(tasks.test)+}++// В openlineage-sql-java лежит старая копия commons-lang3. С ней Testcontainers молча не копирует файлы в контейнер,+// поэтому настоящая библиотека должна стоять на classpath раньше.+tasks.withType<Test>().configureEach {+    classpath = files(configurations.testRuntimeClasspath.get().filter { it.name.startsWith("commons-lang3-") }) + classpath }
 
 tasks.shadowJar {
КОД
    testImplementation(platform("org.testcontainers:testcontainers-bom:2.0.5"))
    testImplementation("org.testcontainers:testcontainers-junit-jupiter")
    testImplementation("org.testcontainers:testcontainers-kafka")
    testImplementation("org.testcontainers:testcontainers-postgresql")
}

tasks.test {
    useJUnitPlatform { excludeTags("e2e") }
}

// Отдельная задача: E2E поднимает контейнеры и идёт минуты, а обычные тесты должны укладываться в секунды.
tasks.register<Test>("e2eTest") {
    group = "verification"
    description = "Сквозные тесты на Kafka и PostgreSQL в контейнерах"
    testClassesDirs = sourceSets.test.get().output.classesDirs
    classpath = sourceSets.test.get().runtimeClasspath
    useJUnitPlatform { includeTags("e2e") }
    shouldRunAfter(tasks.test)
}
ПРОСТЫМИ СЛОВАМИ

platform подключает BOM, файл с согласованными версиями библиотек Testcontainers. Поэтому у трёх модулей ниже версия не указана.

Обычная задача test выбрасывает всё, что помечено тегом e2e.

Своей задаче типа Test надо явно сказать, где лежат скомпилированные тесты и какой classpath брать. Значения те же, что у обычных тестов.

includeTags оставляет только сквозные тесты. shouldRunAfter просит запускать их после обычных, если в одной команде указаны обе задачи, и зависимости между ними не создаёт.

Блок про commons-lang3 в конце этого diff разберём в шаге 7, это следствие настоящего расследования. В комментарии к задаче стоит оценка «минуты», на нашем стенде тест занял 7 с. При первом запуске добавится скачивание образов.

Шаг 6. Сквозной тест

Создай src/test/java/dev/study/payguard/job/PayGuardE2ETest.java.

src/test/java/dev/study/payguard/job/PayGuardE2ETest.java
package dev.study.payguard.job;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.apache.flink.core.execution.JobClient;
import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.test.junit5.MiniClusterExtension;
import org.apache.kafka.clients.admin.Admin;
import org.apache.kafka.clients.admin.NewTopic;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.kafka.KafkaContainer;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.MountableFile;

/** Настоящие Kafka и PostgreSQL, настоящий граф задания, настоящие транзакции и чекпоинты. */
@Tag("e2e")
@Testcontainers
class PayGuardE2ETest {

    private static final Duration DEADLINE = Duration.ofMinutes(2);
    private static final int BURST_SIZE = 12;

    @Container
    private static final KafkaContainer KAFKA = new KafkaContainer("apache/kafka:4.3.2");

    @Container
    private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:17-alpine")
            .withDatabaseName("payguard")
            .withUsername("payguard")
            .withPassword("payguard")
            .withCopyFileToContainer(MountableFile.forHostPath("sql/init.sql"), "/docker-entrypoint-initdb.d/init.sql");

    @RegisterExtension
    private static final MiniClusterExtension FLINK = new MiniClusterExtension(
            new MiniClusterResourceConfiguration.Builder().setNumberTaskManagers(1).setNumberSlotsPerTaskManager(8).build());

    @BeforeAll
    static void createTopics() throws Exception {
        System.setProperty("PAYGUARD_DB_URL", POSTGRES.getJdbcUrl());
        System.setProperty("PAYGUARD_DB_USER", POSTGRES.getUsername());
        System.setProperty("PAYGUARD_DB_PASSWORD", POSTGRES.getPassword());

        List<NewTopic> topics = List.of("payments.raw", "payments.dlq", "payments.late", "payments.confirmations",
                        "payments.confirmed", "card.sessions", "card.profiles", "fraud.alerts", "rules.config")
                .stream().map(name -> new NewTopic(name, 2, (short) 1)).toList();
        try (Admin admin = Admin.create(Map.<String, Object>of("bootstrap.servers", KAFKA.getBootstrapServers()))) {
            admin.createTopics(topics).all().get();
        }
    }

    @Test
    void burstOnCardBecomesCommittedEnrichedAlertAndGarbageGoesToDlq() throws Exception {
        StreamExecutionEnvironment env = PayGuardJob.createEnvironment();
        env.enableCheckpointing(1_000);
        env.setParallelism(2);
        PayGuardJob.buildPipeline(env, JobConfig.fromArgs(new String[] {"--bootstrap", KAFKA.getBootstrapServers()}));
        JobClient job = env.executeAsync("payguard-e2e");

        try (KafkaProducer<String, String> producer = newProducer();
                KafkaConsumer<String, String> consumer = newConsumer()) {
            consumer.subscribe(List.of("fraud.alerts", "payments.dlq"));
            String alert = null;
            String rejected = null;
            Instant deadline = Instant.now().plus(DEADLINE);

            // Источник читает с конца топика и стартует не сразу, поэтому пачку шлём, пока не увидим результат.
            while ((alert == null || rejected == null) && Instant.now().isBefore(deadline)) {
                sendBurst(producer);
                for (ConsumerRecord<String, String> record : consumer.poll(Duration.ofSeconds(1))) {
                    if (record.topic().equals("fraud.alerts") && record.value().contains("\"velocity\"")) {
                        alert = record.value();
                    } else if (record.topic().equals("payments.dlq")) {
                        rejected = record.value();
                    }
                }
            }

            assertThat(alert).contains("\"cardId\":\"c-e2e\"").contains("\"merchantRisk\":\"HIGH\"");
            assertThat(rejected).contains("not-json");
        } finally {
            job.cancel().get();
        }
    }

    private static void sendBurst(KafkaProducer<String, String> producer) {
        long now = System.currentTimeMillis();
        for (int i = 0; i < BURST_SIZE; i++) {
            String json = """
                    {"paymentId":"%s","cardId":"c-e2e","merchantId":"m-0000","amountMinor":1000,\
                    "currency":"EUR","country":"DE","eventTimeMs":%d}""".formatted(UUID.randomUUID(), now);
            producer.send(new ProducerRecord<>("payments.raw", "c-e2e", json));
        }
        producer.send(new ProducerRecord<>("payments.raw", "c-e2e", "not-json"));
        producer.flush();
    }

    private static KafkaProducer<String, String> newProducer() {
        return new KafkaProducer<>(Map.<String, Object>of(
                ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA.getBootstrapServers(),
                ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName(),
                ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()));
    }

    private static KafkaConsumer<String, String> newConsumer() {
        return new KafkaConsumer<>(Map.<String, Object>of(
                ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA.getBootstrapServers(),
                ConsumerConfig.GROUP_ID_CONFIG, "e2e-check",
                ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest",
                ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed",
                ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName(),
                ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()));
    }
}
КОД
@Tag("e2e")
@Testcontainers
class PayGuardE2ETest {

    private static final Duration DEADLINE = Duration.ofMinutes(2);
    private static final int BURST_SIZE = 12;

    @Container
    private static final KafkaContainer KAFKA = new KafkaContainer("apache/kafka:4.3.2");

    @Container
    private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:17-alpine")
            .withDatabaseName("payguard")
            .withUsername("payguard")
            .withPassword("payguard")
            .withCopyFileToContainer(MountableFile.forHostPath("sql/init.sql"), "/docker-entrypoint-initdb.d/init.sql");

    @RegisterExtension
    private static final MiniClusterExtension FLINK = new MiniClusterExtension(
            new MiniClusterResourceConfiguration.Builder().setNumberTaskManagers(1).setNumberSlotsPerTaskManager(8).build());

    @BeforeAll
    static void createTopics() throws Exception {
        System.setProperty("PAYGUARD_DB_URL", POSTGRES.getJdbcUrl());
        System.setProperty("PAYGUARD_DB_USER", POSTGRES.getUsername());
        System.setProperty("PAYGUARD_DB_PASSWORD", POSTGRES.getPassword());

        List<NewTopic> topics = List.of("payments.raw", "payments.dlq", "payments.late", "payments.confirmations",
                        "payments.confirmed", "card.sessions", "card.profiles", "fraud.alerts", "rules.config")
                .stream().map(name -> new NewTopic(name, 2, (short) 1)).toList();
        try (Admin admin = Admin.create(Map.<String, Object>of("bootstrap.servers", KAFKA.getBootstrapServers()))) {
            admin.createTopics(topics).all().get();
        }
    }
ПРОСТЫМИ СЛОВАМИ

@Tag("e2e") связывает класс с задачей из шага 5. @Testcontainers сам запустит и остановит поля с аннотацией @Container.

Образы заданы точно, как и везде в курсе. PostgreSQL получает при старте тот же sql/init.sql, что и стенд Compose, поэтому схема и справочник продавцов настоящие.

MiniCluster с одним TaskManager на 8 слотов. Этого хватает на весь граф при параллелизме 2.

Адрес базы становится известен только после старта контейнера. Он уходит в системные свойства, которые прочитает DbSettings.

Топики создаются заранее, по две партиции на каждый.

КОД
    @Test
    void burstOnCardBecomesCommittedEnrichedAlertAndGarbageGoesToDlq() throws Exception {
        StreamExecutionEnvironment env = PayGuardJob.createEnvironment();
        env.enableCheckpointing(1_000);
        env.setParallelism(2);
        PayGuardJob.buildPipeline(env, JobConfig.fromArgs(new String[] {"--bootstrap", KAFKA.getBootstrapServers()}));
        JobClient job = env.executeAsync("payguard-e2e");

        try (KafkaProducer<String, String> producer = newProducer();
                KafkaConsumer<String, String> consumer = newConsumer()) {
            consumer.subscribe(List.of("fraud.alerts", "payments.dlq"));
            String alert = null;
            String rejected = null;
            Instant deadline = Instant.now().plus(DEADLINE);

            // Источник читает с конца топика и стартует не сразу, поэтому пачку шлём, пока не увидим результат.
            while ((alert == null || rejected == null) && Instant.now().isBefore(deadline)) {
                sendBurst(producer);
                for (ConsumerRecord<String, String> record : consumer.poll(Duration.ofSeconds(1))) {
                    if (record.topic().equals("fraud.alerts") && record.value().contains("\"velocity\"")) {
                        alert = record.value();
                    } else if (record.topic().equals("payments.dlq")) {
                        rejected = record.value();
                    }
                }
            }

            assertThat(alert).contains("\"cardId\":\"c-e2e\"").contains("\"merchantRisk\":\"HIGH\"");
            assertThat(rejected).contains("not-json");
        } finally {
            job.cancel().get();
        }
    }
ПРОСТЫМИ СЛОВАМИ

Настоящий граф через настоящую фабрику. Чекпоинты каждую секунду нужны, чтобы транзакция с алертами фиксировалась быстро. Аргумент --bootstrap читает JobConfig, так задание получает адрес Kafka из контейнера.

executeAsync запускает задание и сразу возвращает управление. Обычный execute ждал бы конца потока, которого нет.

Потребитель читает алерты и DLQ. В newConsumer задан read_committed, поэтому он увидит только зафиксированные транзакции. Так тест проверяет, что транзакция с алертом действительно фиксируется.

Источник читает с конца топика и стартует не сразу. Одна посылка могла бы пропасть, поэтому пачка отправляется в цикле, пока не появится результат или не выйдет дедлайн в две минуты.

Проверяем три вещи сразу. Правило velocity сработало, потому что в пачке 12 платежей по карте при пороге 10. Обогащение сходило в настоящую базу и принесло HIGH для m-0000, так задано в init.sql. Битая запись попала в DLQ.

job.cancel в finally останавливает задание, даже если проверка упала.

Docker должен быть запущен. Поднимать стенд из Compose не нужно, контейнеры тест создаёт себе сам.

терминал
./gradlew test
./gradlew e2eTest

Первая команда идёт без Docker. Результаты смотри в build/reports/tests/test/index.html и build/reports/tests/e2eTest/index.html. На нашем стенде это 45 тестов в первом отчёте, вместе с тестом из задания, и один сквозной за 7 с во втором.

Шаг 7. Как мы нашли проблему с commons-lang3

Этот шаг про диагностику. При первом запуске сквозной тест повис на 60 с и упал так.

вывод теста
Timed out waiting for log output matching '.*Transitioning from RECOVERY to RUNNING.*'

Сообщение наводит на мысль о медленной Kafka. Но логов контейнера не было совсем. Значит, Kafka внутри него даже не начала запускаться. Такая подсказка важнее текста ошибки, поэтому мы пошли смотреть, что происходит в самом тесте. В стандартном потоке ошибок лежала строка, которую легко было пропустить. В HTML-отчёте Gradle такой поток показан у класса теста отдельно.

stderr теста
Exception in thread "Thread-6" java.lang.NoSuchMethodError: 'java.lang.String org.apache.commons.lang3.SystemProperties.getUserName(java.lang.String)'

Причина оказалась вот в чём. Testcontainers копирует скрипт старта в контейнер Kafka tar-архивом, который пакует в отдельном потоке. Этот поток вызвал метод commons-lang3 и умер, потому что такого метода не нашлось. Docker получил пустой архив и ответил 200, скрипт старта в контейнере не появился. Сам тест об этом не узнал и 60 с честно ждал строку в логе.

Откуда взялась старая версия. Библиотека openlineage-sql-java-1.32.0.jar содержит внутри себя копию commons-lang3 и стоит раньше на classpath. В проект она приходит транзитивно, из JDBC-коннектора. Проверить оба факта можно самому.

терминал
./gradlew dependencyInsight --dependency openlineage-sql-java --configuration testRuntimeClasspath
find ~/.gradle/caches/modules-2/files-2.1/io.openlineage -name 'openlineage-sql-java-1.32.0.jar' \
  -exec unzip -l {} \; | grep lang3/SystemProperties

Первая команда показывает цепочку, по которой зависимость попала в указанную конфигурацию, и там будет flink-connector-jdbc-core. Вторая находит jar в кэше Gradle и показывает в нём класс SystemProperties из чужого пакета.

Первая мысль это исключить jar из тестовых зависимостей. Она не работает, потому что он нужен самому коннектору.

вывод теста после исключения jar
NoClassDefFoundError: io/openlineage/sql/OpenLineageSql

Рабочее решение в том, чтобы поставить настоящую библиотеку раньше на classpath. Для всех задач типа Test мы берём jar commons-lang3 из разрешённых зависимостей и дописываем его в начало. Это строки 76-78 файла сборки из шага 5. Первым найденным классом становится настоящий, а openlineage остаётся на месте и работает.

Похожая ловушка с лишним коннектором была в уроке 14. В Java порядок на classpath решает, какой из одинаковых классов загрузится, поэтому при странном NoSuchMethodError первым делом ищи вторую копию класса.

Шаг 8. CI

Положи в проект .github/workflows/ci.yml.

.github/workflows/ci.yml
name: ci

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"
      - uses: gradle/actions/setup-gradle@v4
      - run: ./gradlew test shadowJar --console=plain
      - uses: actions/upload-artifact@v4
        with:
          name: payguard-jar
          path: build/libs/payguard-*.jar

  e2e:
    runs-on: ubuntu-24.04
    needs: build
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"
      - uses: gradle/actions/setup-gradle@v4
      - run: ./gradlew e2eTest --console=plain

Блок on запускает файл на каждый push в main и на каждый pull request. Задание build гонит обычные тесты и собирает толстый jar, а upload-artifact сохраняет его как артефакт прогона. Действие setup-gradle настраивает Gradle и его кэш. Флаг --console=plain убирает анимированный прогресс, который только мешает читать лог. Задание e2e благодаря needs: build стартует только после успеха первого. Отдельной настройки Docker в нём нет, расчёт на то, что на раннере Ubuntu от GitHub он уже стоит.

Этот файл мы ни разу не запускали. Считай первый прогон проверкой самого файла.

Задание. Дедупликация должна пережить рестарт

В задании PayGuard повтор платежа отсекает DeduplicateFunction. Если она забудет увиденные платежи при рестарте, после каждого падения повторы пройдут дальше и правила посчитают их как новые платежи. Напиши тест, который это проверяет.

  1. Создай src/test/java/dev/study/payguard/pipeline/DeduplicateRestoreTest.java.
  2. Подай платёж в первый экземпляр оператора и сними снимок.
  3. Подними второй экземпляр из снимка и подай в него тот же платёж и ещё один новый.
  4. Проверь выход второго экземпляра.

Готово, если выполнены все условия.

  • Команда ./gradlew test --tests '*DeduplicateRestoreTest' зелёная.
  • В отчёте ./gradlew test стало 45 тестов.
  • Тест падает, если заменить в DeduplicateFunction состояние обычным полем с набором строк. Верни код после проверки.
  • Тест нельзя обмануть функцией, которая отбрасывает вообще все платежи.

Подсказки. Образец лежит в CardProfileRestoreTest. Посмотри, по какому полю функция ключуется в PayGuardJob, селектор ключа в тесте должен быть таким же. В Payments есть готовый paymentOnCard. Последнее условие подсказывает, зачем в третьем пункте нужен новый платёж.

Ответ

Глоссарий урока

Test harness
Набор классов из тестовых jar Flink. Создаёт оператор в макете окружения и даёт вручную подавать записи, двигать время и снимать снимки. Сети и кластера там нет.
Restore-тест
Тест, который снимает снимок состояния с одного экземпляра оператора и поднимает из него другой. Воспроизводит перезапуск задания в памяти.
Тест топологии
Тест, который строит граф задания и проверяет его свойства, ничего не запуская. Так называем его в этом курсе, отдельного термина у Flink нет.
StreamGraph
Граф задания, в котором каждый оператор описан отдельным узлом. Получается из описания задания на клиенте.
JobGraph
Граф, который отправляется на кластер. Цепочки операторов в нём уже объединены в одну вершину.
uid
Постоянный идентификатор оператора, который задаёшь ты. По нему Flink находит состояние оператора в savepoint.
OperatorID
Внутренний идентификатор оператора во Flink. Состояние в снимке хранится под ним.
MiniCluster
Встроенный кластер Flink, работающий в той же JVM, что и тест. В JUnit 5 его поднимает расширение MiniClusterExtension.
E2E, сквозной тест
Тест, который проверяет всю систему вместе с настоящими внешними зависимостями. В PayGuard это Kafka и PostgreSQL в контейнерах.
Testcontainers
Библиотека, которая запускает контейнеры Docker из кода теста и убирает их после него.
JUnit
Основная библиотека модульных тестов в Java. Тег @Tag помечает тесты, чтобы запускать их выборочно.
AssertJ
Библиотека проверок для тестов на Java с цепочками вроде assertThat(x).isEmpty() и подробными сообщениями о падении.
Сериализация
Превращение объекта в байты, чтобы передать его по сети или сохранить. Функции Flink передаются на TaskManager через сериализацию Java, поэтому их поля обязаны быть Serializable.
ClosureCleaner
Класс Flink, который чистит функцию от лишних ссылок и проверяет, что её можно сериализовать. Вызывается при добавлении функции в граф.
Kryo
Библиотека сериализации общего назначения. Flink включает её для типов, которые сам разобрать не может, и считает их чёрным ящиком. Эволюция схемы для таких типов не работает, поэтому в PayGuard Kryo запрещён.
Savepoint
Снимок состояния задания, сделанный по команде. Из него запускают новую версию после обновления.
Чекпоинт (checkpoint)
Снимок состояния задания, который Flink снимает сам по расписанию ради восстановления после падения.
Состояние (state)
Данные, которые оператор хранит между записями и которые входят в снимки.
TTL
Срок жизни записи состояния. По его истечении запись считается удалённой.
Водяной знак (watermark)
Метка в потоке, которая сообщает, до какого момента времени события уже пришли.
RocksDB
Встраиваемая база данных на диске. Хранилище состояния Flink для больших объёмов.
Apache Kafka
Распределённый журнал сообщений. В PayGuard это и источник платежей, и приёмник алертов.
Транзакция Kafka
Группа записей, которая становится видна потребителям с read_committed только после фиксации. Коннектор Flink фиксирует её на чекпоинте.
Exactly-once
Гарантия, при которой каждая запись влияет на результат ровно один раз, в том числе после сбоя.
DLQ (dead letter queue)
Отдельный топик для записей, которые не удалось обработать. Они не роняют задание и остаются доступны для разбора.
PostgreSQL
Реляционная база данных. В PayGuard хранит справочник продавцов и итоги.
Classpath
Упорядоченный список jar и каталогов, где JVM ищет классы. Из одинаковых классов загружается первый найденный.
commons-lang3
Библиотека вспомогательных классов Apache Commons. Её использует Testcontainers.
OpenLineage
Стандарт и библиотеки для сбора данных о происхождении данных. JDBC-коннектор Flink тянет его транзитивно.
Транзитивная зависимость
Библиотека, которая попала в проект потому, что её требует другая зависимость.
BOM (bill of materials)
Файл с согласованными версиями группы библиотек. Подключается в Gradle через platform, и версии отдельных модулей указывать не нужно.
Толстый jar (fat jar)
Один файл с твоим кодом и всеми его зависимостями. Этот jar отправляется на кластер.
GitHub Actions
Сервис CI от GitHub. Запускает задания из файлов в каталоге .github/workflows на каждое указанное событие.
CI (непрерывная интеграция)
Автоматическая сборка и запуск тестов на каждое изменение кода.
Раннер (runner)
Машина, на которой GitHub Actions выполняет задания.
Docker
Программа, которая запускает приложения в изолированных контейнерах из готовых образов.

Урок 17 · Прод

Мониторинг. Метрики Flink, Prometheus, Grafana и алерты

После урока ты сможешь вывести метрики PayGuard в Prometheus, собрать дашборд в Grafana и написать правила алертов, которые срабатывают и тогда, когда метрика пропала вместе с упавшим процессом.

Задание в статусе RUNNING, а алертов нет

Представь ночь. Web UI показывает задание payguard в статусе RUNNING, генератор пишет платежи, лаг Kafka нулевой. При этом команда антифрода уже час не получает ни одного алерта. Причина в чекпоинтах, они падают один за другим. В уроке 12 мы сделали приёмник алертов транзакционным, и потребитель с read_committed видит алерт только после завершённого чекпоинта. Нет чекпоинтов, нет и алертов, хотя каждый оператор честно работает.

Web UI такое покажет, если в него посмотреть. Ночью в него никто не смотрит. Нужна система, которая сама снимает показатели со всех процессов кластера, хранит их историю и будит дежурного, когда показатель вышел за границу. В промышленной эксплуатации Flink это обычно связка из трёх частей. Flink публикует метрики, Prometheus их собирает и проверяет правила, Grafana рисует графики.

Прикроватный монитор и пост медсестры

В реанимации у каждой койки стоит монитор. Датчики снимают пульс, давление и насыщение крови кислородом, а центральный пост собирает показания со всех палат. Медсестра не смотрит в экраны без перерыва. Монитор сам подаёт сигнал, если пульс вышел за границу и держится там несколько секунд, а короткий скачок от кашля тревоги не вызывает.

Самая неприятная поломка здесь тихая. Прищепка датчика соскользнула с пальца, и монитор перестал получать пульс. Порог «пульс выше 140» в этот момент сравнивать не с чем, поэтому он молчит. Для этого у мониторов есть отдельная тревога «датчик отключён», и она срабатывает на отсутствие сигнала.

Во Flink датчики это метрики внутри JobManager и TaskManager. Центральный пост это Prometheus, он сам обходит процессы и забирает показания. Пороги с выдержкой это правила алертов с параметром for. Тревогу «датчик отключён» придётся написать самому, в PayGuard это правило на функции absent. Ниже ты увидишь, что без неё падение TaskManager проходит мимо правила на лаг.

Процесс TaskManager Операторcounter.inc() MetricGroupобласть и метки PrometheusReporterHTTP :9249 /metrics Prometheusопрос раз в 10 справила alerts.yml Grafanaдашборд PayGuard Алертыpending, firing стрелка от Prometheus: он сам приходит за данными

Из чего состоит метрика Flink

Метрика во Flink это именованное значение, которое регистрируют в группе метрик (MetricGroup). Типов четыре.

ТипЧто хранитПример в PayGuard
CounterЦелое число, которое увеличивают и могут уменьшать методами inc и decЧисло алертов по правилу velocity
GaugeЗначение, которое вычисляется в момент чтенияВремя последнего успешного чекпоинта, занятая куча
MeterСкорость событий в секундуnumRecordsOutPerSecond источника
HistogramРаспределение значений и его квантилиЗадержка маркеров latency tracking, в PayGuard выключена

Каждая метрика живёт в дереве групп. Корень дерева задаёт процесс, JobManager или TaskManager. Дальше идут задание, задача (task), оператор. Это системная область (scope), её строит сам Flink. Внутри своей функции ты получаешь группу оператора через getRuntimeContext().getMetricGroup() и можешь добавить к ней свои уровни методом addGroup. Это пользовательская область.

У группы есть переменные. Для оператора это хост, идентификатор TaskManager, имя задания, имя задачи, номер подзадачи, имя оператора и ещё несколько. Метод addGroup(key, value) с двумя аргументами добавляет свою переменную, и в PayGuard так появится переменная rule.

В исходниках

Проверим, что делает addGroup с двумя аргументами. Он создаёт две вложенные группы, группу ключа и группу значения.

apache/flink@release-2.2.1 · AbstractMetricGroup.java, строки 480-488
@Override
public MetricGroup addGroup(String name) {
    return addGroup(name, ChildType.GENERIC);
}

@Override
public MetricGroup addGroup(String key, String value) {
    return addGroup(key, ChildType.KEY).addGroup(value, ChildType.VALUE);
}

Группа значения кладёт пару в переменные и не добавляет себя в логическую область. Логическая область группы значения равна области родителя, то есть ключа.

apache/flink@release-2.2.1 · GenericValueMetricGroup.java, строки 48-56
@Override
protected void putVariables(Map<String, String> variables) {
    variables.put(ScopeFormat.asVariable(this.key), value);
}

@Override
protected String createLogicalScope(CharacterFilter filter, char delimiter) {
    return parent.getLogicalScope(filter, delimiter);
}

Из этого следует правило, важное для Prometheus. Ключ rule войдёт в имя метрики, а значение velocity станет меткой. Если бы мы написали addGroup("velocity") с одним аргументом, каждое правило получило бы своё имя метрики, и сложить их одним запросом стало бы неудобно.

Meter заслуживает отдельного взгляда, потому что на нём построены почти все метрики «в секунду». Число записей на выходе оператора Flink считает счётчиком, а скорость получает обёрткой MeterView над этим счётчиком.

В исходниках

Группа ввода и вывода оператора регистрирует скорость как MeterView поверх счётчика записей.

apache/flink@release-2.2.1 · InternalOperatorIOMetricGroup.java, строки 44-58
public InternalOperatorIOMetricGroup(InternalOperatorMetricGroup parentMetricGroup) {
    super(parentMetricGroup);
    numRecordsIn = parentMetricGroup.counter(MetricNames.IO_NUM_RECORDS_IN);
    numRecordsOut = parentMetricGroup.counter(MetricNames.IO_NUM_RECORDS_OUT);
    numRecordsInRate =
            parentMetricGroup.meter(
                    MetricNames.IO_NUM_RECORDS_IN_RATE, new MeterView(numRecordsIn));
    numRecordsOutRate =
            parentMetricGroup.meter(
                    MetricNames.IO_NUM_RECORDS_OUT_RATE, new MeterView(numRecordsOut));
    numBytesIn = parentMetricGroup.counter(MetricNames.IO_NUM_BYTES_IN);
    numBytesOut = parentMetricGroup.counter(MetricNames.IO_NUM_BYTES_OUT);
    parentMetricGroup.meter(MetricNames.IO_NUM_BYTES_IN_RATE, new MeterView(numBytesIn));
    parentMetricGroup.meter(MetricNames.IO_NUM_BYTES_OUT_RATE, new MeterView(numBytesOut));

По умолчанию окно у MeterView шестьдесят секунд.

apache/flink@release-2.2.1 · MeterView.java, строки 41-41
private static final int DEFAULT_TIME_SPAN_IN_SECONDS = 60;

Скорость считается как разница между текущим значением счётчика и самым старым значением в кольцевом буфере, делённая на длину окна.

apache/flink@release-2.2.1 · MeterView.java, строки 97-108
@Override
public double getRate() {
    return currentRate;
}

@Override
public void update() {
    time = (time + 1) % values.length;
    values[time] = counter.getCount();
    currentRate =
            ((double) (values[time] - values[(time + 1) % values.length]) / timeSpanInSeconds);
}

Значит, numRecordsOutPerSecond показывает среднее за последнюю минуту. После остановки потока метрика опускается к нулю плавно, в течение минуты. Для правила алерта это лишняя инерция, которую надо держать в голове.

Как Prometheus получает метрики

Flink сам никуда метрики не отправляет. Их выносит наружу репортер (reporter), подключаемый модуль, который создаётся в каждом JobManager и TaskManager при старте. Репортер для Prometheus работает по модели pull. Он поднимает внутри процесса маленький HTTP-сервер, а Prometheus по расписанию приходит к каждому процессу и забирает текущие значения. Такой поход называется опросом (scrape). Jar репортера уже лежит в папке plugins образа Flink, отдельно его скачивать не нужно.

В исходниках

Фабрика берёт порт из настройки port, по умолчанию 9249. Значение может быть диапазоном.

apache/flink@release-2.2.1 · PrometheusReporterFactory.java, строки 27-39
public class PrometheusReporterFactory implements MetricReporterFactory {

    static final String ARG_PORT = "port";
    private static final String DEFAULT_PORT = "9249";

    @Override
    public PrometheusReporter createMetricReporter(Properties properties) {
        MetricConfig metricConfig = (MetricConfig) properties;
        String portsConfig = metricConfig.getString(ARG_PORT, DEFAULT_PORT);
        PortRange portRange = new PortRange(portsConfig);

        return new PrometheusReporter(portRange);
    }

Репортер перебирает порты диапазона и поднимает HTTP-сервер на первом свободном. Если не удалось ни на одном, процесс получает исключение.

apache/flink@release-2.2.1 · PrometheusReporter.java, строки 47-65
PrometheusReporter(PortRange portRange) {
    Iterator<Integer> portsIterator = portRange.getPortsIterator();
    while (portsIterator.hasNext()) {
        port = portsIterator.next();
        try {
            httpServer = new HTTPServer(new InetSocketAddress(port), this.registry);
            log.info("Started PrometheusReporter HTTP server on port {}.", port);
            break;
        } catch (IOException ioe) { // assume port conflict
            log.debug("Could not start PrometheusReporter HTTP server on port {}.", port, ioe);
        }
    }

    if (httpServer == null) {
        throw new RuntimeException(
                "Could not start PrometheusReporter HTTP server on any configured port. Ports: "
                        + portRange);
    }
}

Диапазон нужен, когда на одной машине несколько процессов Flink. В нашем Compose каждый процесс в своём контейнере, поэтому хватает одного порта.

Теперь самое практичное. По какому имени искать метрику в Prometheus и какие у неё метки. Ответ даёт метод, который вызывается при регистрации каждой метрики.

В исходниках

Все переменные группы превращаются в метки. У ключа срезаются угловые скобки, у значения недопустимые символы заменяются фильтром.

apache/flink@release-2.2.1 · AbstractPrometheusReporter.java, строки 98-111
public void notifyOfAddedMetric(
        final Metric metric, final String metricName, final MetricGroup group) {

    List<String> dimensionKeys = new LinkedList<>();
    List<String> dimensionValues = new LinkedList<>();
    for (final Map.Entry<String, String> dimension : group.getAllVariables().entrySet()) {
        final String key = dimension.getKey();
        dimensionKeys.add(
                CHARACTER_FILTER.filterCharacters(key.substring(1, key.length() - 1)));
        dimensionValues.add(labelValueCharactersFilter.filterCharacters(dimension.getValue()));
    }

    final String scopedMetricName = getScopedName(metricName, group);
    final String helpString = metricName + " (scope: " + getLogicalScope(group) + ")";

Имя собирается из префикса flink_, логической области группы и имени метрики.

apache/flink@release-2.2.1 · AbstractPrometheusReporter.java, строки 142-147
private static String getScopedName(String metricName, MetricGroup group) {
    return SCOPE_PREFIX
            + getLogicalScope(group)
            + SCOPE_SEPARATOR
            + CHARACTER_FILTER.filterCharacters(metricName);
}

Логическая область берётся у самой группы с разделителем _, а фильтр меняет всё, кроме латиницы, цифр, двоеточия и подчёркивания, на подчёркивание.

apache/flink@release-2.2.1 · AbstractPrometheusReporter.java, строки 263-266
private static String getLogicalScope(MetricGroup group) {
    return LogicalScopeProvider.castFrom(group)
            .getLogicalScope(CHARACTER_FILTER, SCOPE_SEPARATOR);
}
apache/flink@release-2.2.1 · AbstractPrometheusReporter.java, строки 68-74
@VisibleForTesting
static String replaceInvalidChars(final String input) {
    // https://prometheus.io/docs/instrumenting/writing_exporters/
    // Only [a-zA-Z0-9:_] are valid in metric names, any other characters should be sanitized to
    // an underscore.
    return UNALLOWED_CHAR_PATTERN.matcher(input).replaceAll("_");
}

Логическая область состоит из названий уровней, у оператора в TaskManager это taskmanager_job_task_operator. Поэтому счётчик алертов PayGuard называется flink_taskmanager_job_task_operator_rule_alerts, а имя источника Source: payments-raw превращается в метку operator_name="Source:_payments_raw". Документация говорит, что недопустимые символы удаляются. Код их заменяет, и правила PayGuard написаны по коду.

Отсюда следствие про настройки metrics.scope.*. Они задают формат идентификатора вроде localhost.taskmanager.1234.payguard.deduplicate.0.numRecordsIn, и класс ScopeFormats читает их из конфигурации. Такими идентификаторами пользуются репортеры без меток, например JMX и Graphite. Репортер Prometheus строит имя по логической области, поэтому смена этих форматов имён в Prometheus не меняет. Состав меток регулируется другой настройкой, metrics.reporter.<имя>.scope.variables.excludes.

Четыре типа превращаются в два

В Prometheus свои типы метрик, и они не совпадают с типами Flink. Репортер переводит одни в другие.

В исходниках

При создании коллектора Gauge, Counter и Meter попадают в одну ветку и становятся Gauge в Prometheus. Histogram получает своего посредника.

apache/flink@release-2.2.1 · AbstractPrometheusReporter.java, строки 155-175
Collector collector;
switch (metric.getMetricType()) {
    case GAUGE:
    case COUNTER:
    case METER:
        collector =
                io.prometheus.client.Gauge.build()
                        .name(scopedMetricName)
                        .help(helpString)
                        .labelNames(toArray(dimensionKeys))
                        .create();
        break;
    case HISTOGRAM:
        collector =
                new HistogramSummaryProxy(
                        (Histogram) metric,
                        scopedMetricName,
                        helpString,
                        dimensionKeys,
                        dimensionValues);
        break;

Значение Counter отдаётся как его текущее число, значение Meter как его скорость.

apache/flink@release-2.2.1 · AbstractPrometheusReporter.java, строки 296-312
private static io.prometheus.client.Gauge.Child gaugeFrom(Counter counter) {
    return new io.prometheus.client.Gauge.Child() {
        @Override
        public double get() {
            return (double) counter.getCount();
        }
    };
}

private static io.prometheus.client.Gauge.Child gaugeFrom(Meter meter) {
    return new io.prometheus.client.Gauge.Child() {
        @Override
        public double get() {
            return meter.getRate();
        }
    };
}

Посредник гистограммы отдаёт тип Summary. Комментарий объясняет, почему не SummaryMetricFamily. Сумму всех значений у гистограммы Flink взять негде, поэтому в выводе есть число наблюдений и квантили .5, .75, .95, .98, .99 и .999, а ряда _sum нет.

apache/flink@release-2.2.1 · AbstractPrometheusReporter.java, строки 336-351
@Override
public List<MetricFamilySamples> collect() {
    // We cannot use SummaryMetricFamily because it is impossible to get a sum of all values
    // (at least for Dropwizard histograms,
    // whose snapshot's values array only holds a sample of recent values).

    List<MetricFamilySamples.Sample> samples = new LinkedList<>();
    for (Map.Entry<List<String>, Histogram> labelValuesToHistogram :
            histogramsByLabelValues.entrySet()) {
        addSamples(
                labelValuesToHistogram.getKey(),
                labelValuesToHistogram.getValue(),
                samples);
    }
    return Collections.singletonList(
            new MetricFamilySamples(metricName, Type.SUMMARY, helpString, samples));

Counter становится Gauge потому, что счётчик Prometheus умеет только расти, а у Counter во Flink есть метод dec. Документация PromQL велит применять rate и increase только к счётчикам. Сам язык тип ряда не проверяет. Функция смотрит на значения и любое уменьшение считает сбросом счётчика, после которого отсчёт начался с нуля. На метриках Flink, которые по смыслу только растут, это работает. Правило PayGuard на increase от числа перезапусков на стенде сработало ровно тогда, когда должно было. Ошибётся функция на значении, которое законно уменьшается, поэтому dec в своих счётчиках лучше не вызывать.

Сброс случается и без dec. После перезапуска задачи её метрики регистрируются заново и начинают с нуля. Для rate это обычный сброс. Меняются и метки вроде task_attempt_id, поэтому в запросах ряды почти всегда складывают через sum by по тем меткам, которые нужны на графике.

Что смотреть у потокового задания

Метрик много. На нашем стенде при работающем задании Prometheus знал 374 разных имени, и у каждого десятки рядов с разными метками. Чтобы не утонуть, удобно опираться на две известные схемы. RED описывает сервис со стороны того, кто его вызывает, через скорость (Rate), ошибки (Errors) и длительность (Duration). USE описывает ресурс через загрузку (Utilization), насыщение (Saturation) и ошибки (Errors). Задание Flink одновременно и сервис для команды антифрода, и потребитель ресурсов, поэтому полезны обе.

ВопросМетрика FlinkГде живёт
Сколько платежей входит (Rate)numRecordsOutPerSecond оператора источникаTaskManager
Насколько отстали от топика (Saturation)лаг консьюмера Kafka records_lag_maxTaskManager
Насколько отстали по времени (Duration)currentEmitEventTimeLag источникаTaskManager
Насколько занят оператор (Utilization)busyTimeMsPerSecondTaskManager
Ждёт ли оператор соседа (Saturation)backPressuredTimeMsPerSecondTaskManager
Падает ли задание (Errors)numRestartsJobManager
Идут ли чекпоинты (Errors, Duration)lastCheckpointCompletedTimestamp, numberOfFailedCheckpoints, lastCheckpointDurationJobManager
Хватает ли памяти (Utilization)Status.JVM.Memory.Heap.Used, время GCкаждый процесс

Метрики busyTimeMsPerSecond и backPressuredTimeMsPerSecond показывают, сколько миллисекунд из каждой секунды задача работала и сколько ждала свободного буфера у следующего оператора. Как по ним найти узкое место, подробно разберём в уроке 18. Здесь нам достаточно, что backpressure больше 500 мс в секунду пять минут подряд это повод для предупреждения.

Колонка «где живёт» важнее, чем кажется. Метрики JobManager переживают падение TaskManager. Метрики TaskManager исчезают вместе с ним. Проверим это на двух метриках источника.

В исходниках

Отставание по времени события источник считает в TaskManager. Это текущее время на часах TaskManager минус время события последней выданной записи. Если источник простаивает, вместо текущего времени берётся момент начала простоя.

apache/flink@release-2.2.1 · InternalSourceReaderMetricGroup.java, строки 154-167
/**
 * This is a rough approximation. If the source is busy, we assume that <code>
 * emit time == now()
 * </code>. If it's idling, we just take the time it started idling as the last emit time.
 */
private long getLastEmitTime() {
    return isIdling() ? idleStartTime : clock.absoluteTimeMillis();
}

long getEmitTimeLag() {
    return lastEventTime != TimestampAssigner.NO_TIMESTAMP
            ? getLastEmitTime() - lastEventTime
            : UNDEFINED;
}

Время события пишет генератор по своим часам, а вычитается оно из часов TaskManager. На стенде метрика показала минус 23 мс. Задание не опережает время, просто часы генератора на машине разработчика и часы контейнера немного расходятся. На проде с синхронизацией времени через NTP расхождение будет того же порядка, и порог в секундах оно не задевает.

Лаг Kafka коннектор берёт у клиента Kafka. Все метрики консьюмера регистрируются как Gauge в группе KafkaSourceReader.KafkaConsumer внутри группы оператора.

apache/flink-connector-kafka@v5.0.0 · KafkaSourceReaderMetrics.java, строки 126-139
public void registerKafkaConsumerMetrics(KafkaConsumer<?, ?> kafkaConsumer) {
    final Map<MetricName, ? extends Metric> kafkaConsumerMetrics = kafkaConsumer.metrics();
    if (kafkaConsumerMetrics == null) {
        LOG.warn("Consumer implementation does not support metrics");
        return;
    }

    final MetricGroup kafkaConsumerMetricGroup =
            kafkaSourceReaderMetricGroup.addGroup(KAFKA_CONSUMER_METRIC_GROUP);

    kafkaConsumerMetrics.forEach(
            (name, metric) ->
                    kafkaConsumerMetricGroup.gauge(name.name(), () -> metric.metricValue()));
}

Консьюмер работает внутри подзадачи источника, то есть в TaskManager. Отсюда длинное имя flink_taskmanager_job_task_operator_KafkaSourceReader_KafkaConsumer_records_lag_max, где дефисы имени Kafka records-lag-max заменены подчёркиваниями. Когда TaskManager нет, консьюмера нет, и этого ряда в Prometheus тоже нет.

Почему одного лага мало

Лаг Kafka выглядит как универсальный показатель здоровья. Отстали от топика, значит, что-то не так. У него три слепых пятна.

  • Лаг пропадает вместе с TaskManager. Prometheus перестаёт получать ряд, выражение с порогом возвращает пустой результат, и правило молчит. Так и вышло на стенде. TaskManager был остановлен 100 секунд, правило на чекпоинты сработало, правило на лаг нет.
  • Лаг бывает нулевым у больного задания. Источник читает быстро, а чекпоинты падают. Транзакции Kafka не фиксируются, и алерты не доходят до потребителей с read_committed. Это ловят правила на время последнего успешного чекпоинта и на число неудачных.
  • Лаг в записях не говорит о времени. Пять тысяч записей при 200 платежах в секунду означают 25 секунд опоздания. Ночью при десяти платежах в секунду это уже восемь минут. Время показывает currentEmitEventTimeLag.

Модель ниже проигрывает правила PayGuard так, как их считает Prometheus, с шагом 10 секунд. Нажми «Пуск», через несколько шагов останови TaskManager, а потом верни его. Посмотри, когда загорается каждое правило и в какой момент появляется столбик лага.

Пульт дежурного

Модель упрощена. Вход 200 платежей в секунду, чекпоинт раз в 15 секунд, задание поднимается через 15 секунд после возврата TaskManager, как на стенде. Скорость, с которой задание догоняет топик, подобрана так, чтобы через минуту после возврата лаг был около 11 тысяч, как в замере. Пока задачам негде работать, каждая попытка чекпоинта считается неудачной.

Обрати внимание на две вещи. Пока TaskManager остановлен, полоса KafkaLagHigh серая, ряда нет. Тревогу поднимает SourceSilent на absent, как монитор на соскользнувшую прищепку. После возврата лаг большой, но правило висит в pending и ждёт две минуты. Если задание догонит топик раньше, алерт так и не загорится. При короткой аварии это правильно, будить человека уже незачем. Останови TaskManager подольше, и лаг продержится выше порога достаточно, чтобы правило дошло до firing.

⚠
Правило на порог молчит, когда нет данных

Выражение метрика > 5000 возвращает ряды, где условие выполнено. Если ряда нет совсем, результат пустой, и Prometheus считает, что всё в порядке. На каждую метрику, которая живёт в TaskManager и на которой держится алерт, нужна пара в виде правила на absent или на встроенную метрику Prometheus up, которая равна нулю, когда цель опроса не ответила.

Правило алерта и его состояния

Правило алерта в Prometheus это выражение PromQL и выдержка for. Prometheus вычисляет выражение каждые evaluation_interval секунд. Пока результат пустой, правило неактивно. Как только выражение вернуло хотя бы один ряд, алерт переходит в состояние pending. Если условие держится дольше for, алерт становится firing. Без for он загорается сразу. Пропал результат хотя бы на одну проверку, и отсчёт начинается заново.

Firing в Prometheus ещё не звонок дежурному. Рассылкой в почту, Slack или систему дежурств занимается отдельный компонент Alertmanager. На учебном стенде его нет, алерты видны в интерфейсе Prometheus на вкладке Alerts и через его HTTP API. Что делать по каждому алерту, мы запишем в runbook в уроке 21.

Выдержку подбирают под природу метрики. Перезапуск задания событие редкое и важное, ему выдержка не нужна. Один неудачный чекпоинт после деплоя на стенде урока 20 оказался нормой, подзадачи нового TaskManager ещё разворачивались. Поэтому правило на неудачные чекпоинты смотрит на серию, больше двух за десять минут. Это меньше, чем tolerable-failed-checkpoints: 3 из урока 11, после которого задание само уходит в перезапуск, так что предупреждение придёт раньше падения.

Сравнение со Spark

В Spark Structured Streaming главный источник сведений о потоковом запросе это прогресс каждого micro-batch. В объекте StreamingQueryProgress видно, сколько строк пришло и обработано в секунду и сколько длился батч, и по этим цифрам строят алерты.

Во Flink батчей нет, и мерить длительность нечего. Здоровье задания видно по непрерывным метрикам операторов в TaskManager и по чекпоинтам в JobManager. Роль «батч опоздал» здесь играют отставание по времени события и время с последнего успешного чекпоинта.

ℹ
Метка размножает ряды

Каждое новое значение метки создаёт новый временной ряд в Prometheus. Метка rule с пятью правилами даёт пять рядов на подзадачу. Номер карты в метке дал бы ряд на каждую карту, а в состоянии профиля на стенде было 8563 ключа. Число рядов называют кардинальностью, и от неё зависят память Prometheus и скорость запросов. В метки кладут только то, у чего мало значений.

Проверь себя

Ночью единственный TaskManager упал и не поднимается. Из правил настроено только «лаг больше 5000 две минуты». Что увидит дежурный?

Лаг нулевой, busy у всех операторов низкий, но команда антифрода не получает алертов уже 20 минут. Какое правило PayGuard должно было сработать первым?

Коллега хочет видеть в Grafana число платежей по каждой карте и пишет addGroup("card", cardId).counter("payments"). Что будет?

Что будет в конце

Рядом с кластером работают Prometheus на порту 9090 и Grafana на порту 3000. Prometheus раз в 10 секунд опрашивает JobManager и TaskManager и проверяет пять правил алертов. В Grafana сам появляется дашборд PayGuard с девятью панелями, от входного потока до сборки мусора. В задании появился свой оператор, который считает алерты по каждому правилу антифрода.

Правило PayGuardKafkaLagHigh и панель «Лаг Kafka» относятся к заданию урока, их пока не добавляй. Ниже файлы показаны без них.

Шаг 1. Счётчик алертов по правилам

Системные метрики Flink ничего не знают о бизнесе. Сколько алертов выдаёт правило velocity и не замолчало ли card-testing, может сказать только твой код. Создай src/main/java/dev/study/payguard/pipeline/AlertMetrics.java.

src/main/java/dev/study/payguard/pipeline/AlertMetrics.java
package dev.study.payguard.pipeline;

import dev.study.payguard.model.FraudAlert;
import java.util.HashMap;
import java.util.Map;
import org.apache.flink.api.common.functions.OpenContext;
import org.apache.flink.api.common.functions.RichMapFunction;
import org.apache.flink.metrics.Counter;

/** Считает алерты по правилам. Запись не меняет, поэтому оператор можно ставить в любое место потока. */
public class AlertMetrics extends RichMapFunction<FraudAlert, FraudAlert> {

    private transient Map<String, Counter> countersByRule;

    @Override
    public void open(OpenContext context) {
        countersByRule = new HashMap<>();
    }

    @Override
    public FraudAlert map(FraudAlert alert) {
        countersByRule.computeIfAbsent(alert.ruleId(), this::newCounter).inc();
        return alert;
    }

    // Пара «имя группы и значение» превращается в метку Prometheus: rule="velocity".
    private Counter newCounter(String ruleId) {
        return getRuntimeContext().getMetricGroup().addGroup("rule", ruleId).counter("alerts");
    }
}
КОД
public class AlertMetrics extends RichMapFunction<FraudAlert, FraudAlert> {

    private transient Map<String, Counter> countersByRule;

    @Override
    public void open(OpenContext context) {
        countersByRule = new HashMap<>();
    }

    @Override
    public FraudAlert map(FraudAlert alert) {
        countersByRule.computeIfAbsent(alert.ruleId(), this::newCounter).inc();
        return alert;
    }

    // Пара «имя группы и значение» превращается в метку Prometheus: rule="velocity".
    private Counter newCounter(String ruleId) {
        return getRuntimeContext().getMetricGroup().addGroup("rule", ruleId).counter("alerts");
    }
ПРОСТЫМИ СЛОВАМИ

Обычный map, который возвращает запись без изменений. RichMapFunction нужна ради доступа к контексту выполнения, а через него к группе метрик.

Карта счётчиков помечена transient. Функцию Flink сериализует при отправке на кластер, а счётчики живут только внутри запущенной подзадачи.

В open карту создаём заново. Метод вызывается в каждой подзадаче перед первой записью.

На каждую запись берём счётчик правила и увеличиваем его. computeIfAbsent регистрирует счётчик при первом алерте этого правила, поэтому список правил коду знать не нужно.

Регистрация. addGroup("rule", ruleId) делает ключ частью имени, а значение меткой. В Prometheus получится flink_taskmanager_job_task_operator_rule_alerts{rule="velocity"}.

Правил в PayGuard пять, поэтому рядов будет пять на подзадачу. Счётчик считает алерты, прошедшие через оператор. Если чекпоинт не завершится и задание восстановится, часть алертов пройдёт через него второй раз, и счётчик их тоже учтёт. Для графика скорости это не важно, для точного учёта есть сам топик fraud.alerts.

Вставь оператор перед приёмником алертов в PayGuardJob.

что изменилось в src/main/java/dev/study/payguard/job/PayGuardJob.java
@@ -11,6 +11,7 @@ import dev.study.payguard.model.RejectedRecord;
 import dev.study.payguard.model.RuleConfig;
 import dev.study.payguard.pipeline.AlertEnrichment;
+import dev.study.payguard.pipeline.AlertMetrics; import dev.study.payguard.pipeline.AmountSpikeRule;
 import dev.study.payguard.pipeline.CardProfileFunction;
 import dev.study.payguard.pipeline.CardSessionWindow;
@@ -188,6 +189,9 @@                         ENRICHMENT_CAPACITY)
                 .name("enrich-alerts")
                 .uid("enrich-alerts")
+                .map(new AlertMetrics())+                .name("alert-metrics")+                .uid("alert-metrics")                 .sinkTo(KafkaSinks.<FraudAlert>exactlyOnceJson(
                         config.bootstrapServers(), config.alertsTopic(), config.alertsTransactionalIdPrefix()))
                 .name("alerts-sink")

Имя оператора попадёт в метку operator_name, а uid нужен, как и везде, чтобы savepoint нашёл состояние операторов после изменения графа. Своего состояния у alert-metrics нет.

Шаг 2. Включи репортер и добавь сервисы

Все изменения в docker-compose.yml.

что изменилось в docker-compose.yml
@@ -25,6 +25,9 @@       restart-strategy.exponential-delay.backoff-multiplier: 2.0
       restart-strategy.exponential-delay.reset-backoff-threshold: 10min
       restart-strategy.exponential-delay.jitter-factor: 0.1
+      metrics.reporter.prom.factory.class: org.apache.flink.metrics.prometheus.PrometheusReporterFactory+      metrics.reporter.prom.port: 9249+      metrics.latency.interval: 0     PAYGUARD_DB_URL: jdbc:postgresql://postgres:5432/payguard
     PAYGUARD_DB_USER: payguard
     PAYGUARD_DB_PASSWORD: payguard
@@ -123,6 +126,26 @@     depends_on:
       - jobmanager
 
+  prometheus:+    image: prom/prometheus:v3.16.0+    ports:+      - "9090:9090"+    volumes:+      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro+      - ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro++  grafana:+    image: grafana/grafana:13.3.0+    ports:+      - "3000:3000"+    environment:+      GF_AUTH_ANONYMOUS_ENABLED: "true"+      GF_AUTH_ANONYMOUS_ORG_ROLE: Admin+      GF_AUTH_DISABLE_LOGIN_FORM: "true"+    volumes:+      - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro+      - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro+   sql-jobmanager:
     <<: *flink-sql-common
     command: jobmanager
  • metrics.reporter.prom.factory.class подключает репортер. Слово prom это произвольное имя репортера, по нему группируются остальные его настройки. Переменная FLINK_PROPERTIES общая для JobManager и TaskManager, поэтому HTTP-сервер поднимется в обоих.
  • metrics.reporter.prom.port: 9249 совпадает со значением по умолчанию. Мы пишем его явно, чтобы порт был виден рядом с prometheus.yml, где на него ссылаются.
  • metrics.latency.interval: 0 тоже значение по умолчанию. Строка напоминает, что latency tracking выключен сознательно. Маркеры задержки дают гистограммы между каждой парой подзадач, и документация советует включать их только для отладки.
  • Prometheus получает два файла только для чтения, конфигурацию и правила. Тома под данные нет, история метрик пропадёт при пересоздании контейнера, для учебного стенда это нормально.
  • Grafana пускает без логина с правами администратора. Это удобно на своей машине и недопустимо на общем сервере. Папка provisioning описывает источник данных и откуда брать дашборды, папка dashboards хранит сам JSON.

Отдельный SQL-кластер из уроков 14 и 15 использует свой блок настроек, репортера в нём нет, и Prometheus его не опрашивает.

Шаг 3. Конфигурация Prometheus

Создай monitoring/prometheus.yml.

monitoring/prometheus.yml
global:
  scrape_interval: 10s
  evaluation_interval: 10s

rule_files:
  - /etc/prometheus/alerts.yml

scrape_configs:
  - job_name: flink
    static_configs:
      - targets: ["jobmanager:9249", "taskmanager:9249"]

scrape_interval задаёт частоту опроса, evaluation_interval частоту проверки правил. Десять секунд дают Prometheus шесть точек в минуту, этого хватает для rate за минуту. В targets адреса внутри сети Compose, порт 9249 наружу не проброшен. Все ряды получат метку job="flink" по имени блока и метку instance с адресом цели. Не путай её с меткой job_name, которая пришла из Flink и содержит имя задания. Список static_configs знает ровно перечисленные адреса. Второй TaskManager в этот список сам не попадёт, в Kubernetes для этого используют обнаружение целей.

Шаг 4. Правила алертов

Создай monitoring/alerts.yml с пятью правилами. Шестое, на лаг, допишешь в задании.

monitoring/alerts.yml (строки 1-42)
groups:
  - name: payguard-flink
    rules:
      - alert: PayGuardJobRestarted
        expr: increase(flink_jobmanager_job_numRestarts{job_name="payguard"}[5m]) > 0
        labels:
          severity: warning
        annotations:
          summary: "Задание payguard перезапускалось за последние 5 минут"

      - alert: PayGuardCheckpointStale
        expr: time() - flink_jobmanager_job_lastCheckpointCompletedTimestamp{job_name="payguard"} / 1000 > 120
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Больше двух минут нет успешного чекпоинта: растёт объём повторной обработки и зависли транзакции Kafka"

      - alert: PayGuardCheckpointsFailing
        expr: increase(flink_jobmanager_job_numberOfFailedCheckpoints{job_name="payguard"}[10m]) > 2
        labels:
          severity: warning
        annotations:
          summary: "За 10 минут упало больше двух чекпоинтов"

      - alert: PayGuardBackpressure
        expr: max by (task_name) (flink_taskmanager_job_task_backPressuredTimeMsPerSecond{job_name="payguard"}) > 500
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.task_name }} больше половины времени ждёт следующий оператор"

      # Метрики источника живут в TaskManager и пропадают вместе с ним, поэтому правило на лаг в это время молчит.
      - alert: PayGuardSourceSilent
        expr: absent(flink_taskmanager_job_task_operator_numRecordsOutPerSecond{job_name="payguard", operator_name="Source:_payments_raw"})
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "От источника payments.raw нет метрик: задание или TaskManager не работает"
КОД
      - alert: PayGuardJobRestarted
        expr: increase(flink_jobmanager_job_numRestarts{job_name="payguard"}[5m]) > 0
        labels:
          severity: warning
        annotations:
          summary: "Задание payguard перезапускалось за последние 5 минут"

      - alert: PayGuardCheckpointStale
        expr: time() - flink_jobmanager_job_lastCheckpointCompletedTimestamp{job_name="payguard"} / 1000 > 120
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Больше двух минут нет успешного чекпоинта: растёт объём повторной обработки и зависли транзакции Kafka"

      - alert: PayGuardCheckpointsFailing
        expr: increase(flink_jobmanager_job_numberOfFailedCheckpoints{job_name="payguard"}[10m]) > 2
        labels:
          severity: warning
        annotations:
          summary: "За 10 минут упало больше двух чекпоинтов"
ПРОСТЫМИ СЛОВАМИ

increase(...[5m]) считает, на сколько вырос счётчик перезапусков за пять минут. Больше нуля значит, что задание падало. for нет, алерт загорается на первой же проверке и гаснет через пять минут после последнего перезапуска.

Метка severity пригодится Alertmanager для маршрутизации. В аннотации summary текст для человека.

lastCheckpointCompletedTimestamp хранит время в миллисекундах, а time() возвращает секунды, отсюда деление на 1000. Деление выполняется раньше вычитания. Правило срабатывает, если успешного чекпоинта нет больше двух минут, и это держится ещё минуту.

Критичность выше, потому что без чекпоинтов транзакции Kafka не фиксируются, и алерты не видны потребителям.

Серия неудач, больше двух за десять минут. Одиночный отказ после деплоя сюда не попадёт.

Правило PayGuardBackpressure берёт максимум по task_name, чтобы в алерте было видно, какая задача страдает, а подзадачи одной задачи схлопнулись в один ряд. Пять минут выдержки отсекают короткие всплески, например во время чекпоинта.

Правило PayGuardSourceSilent появилось после аварийного сценария, который ты повторишь в шаге 8. Функция absent возвращает ряд со значением 1, если выражение внутри не нашло ни одного ряда, и пустой результат, если нашло. Так «нет данных» превращается в условие, на которое можно повесить алерт. Значение метки operator_name записано так, как его выдал фильтр репортера.

Шаг 5. Grafana из файлов

Grafana умеет при старте читать источники данных и дашборды из файлов. Это называется provisioning, и тогда дашборд живёт в git вместе с кодом. Создай источник данных monitoring/grafana/provisioning/datasources/prometheus.yml.

monitoring/grafana/provisioning/datasources/prometheus.yml
apiVersion: 1

datasources:
  - name: Prometheus
    uid: prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true

Адрес http://prometheus:9090 внутренний, запросы делает сервер Grafana (access: proxy). uid: prometheus фиксирован, по нему на источник ссылается каждая панель. Без явного uid Grafana сгенерирует случайный, и JSON дашборда перестанет совпадать.

Провайдер дашбордов monitoring/grafana/provisioning/dashboards/payguard.yml говорит, в какой папке лежат JSON-файлы.

monitoring/grafana/provisioning/dashboards/payguard.yml
apiVersion: 1

providers:
  - name: payguard
    type: file
    options:
      path: /var/lib/grafana/dashboards

Сам дашборд monitoring/grafana/dashboards/payguard.json. Начало файла и первая панель.

monitoring/grafana/dashboards/payguard.json (строки 1-39)
{
  "uid": "payguard",
  "title": "PayGuard",
  "schemaVersion": 41,
  "refresh": "10s",
  "time": {
    "from": "now-15m",
    "to": "now"
  },
  "timezone": "browser",
  "panels": [
    {
      "id": 1,
      "title": "Платежей в секунду на входе",
      "type": "timeseries",
      "gridPos": {
        "x": 0,
        "y": 0,
        "w": 12,
        "h": 8
      },
      "datasource": {
        "type": "prometheus",
        "uid": "prometheus"
      },
      "fieldConfig": {
        "defaults": {
          "unit": "ops"
        },
        "overrides": []
      },
      "targets": [
        {
          "refId": "A",
          "expr": "sum(flink_taskmanager_job_task_operator_numRecordsOutPerSecond{job_name=\"payguard\",operator_name=\"Source:_payments_raw\"})",
          "legendFormat": "payments.raw"
        }
      ]
    },

Каждая панель устроена одинаково. gridPos задаёт место на сетке шириной 24 клетки, datasource ссылается на источник по uid, unit говорит Grafana, как подписывать ось, а в targets лежат запросы PromQL. Место справа от первой панели (x 12, y 0) оставлено под панель лага из задания. Остальные панели идут сразу за первой.

monitoring/grafana/dashboards/payguard.json (строки 68-313)
    {
      "id": 3,
      "title": "Отставание по времени события",
      "type": "timeseries",
      "gridPos": {
        "x": 0,
        "y": 8,
        "w": 12,
        "h": 8
      },
      "datasource": {
        "type": "prometheus",
        "uid": "prometheus"
      },
      "fieldConfig": {
        "defaults": {
          "unit": "ms"
        },
        "overrides": []
      },
      "targets": [
        {
          "refId": "A",
          "expr": "max(flink_taskmanager_job_task_operator_currentEmitEventTimeLag{job_name=\"payguard\",operator_name=\"Source:_payments_raw\"})",
          "legendFormat": "источник"
        }
      ]
    },
    {
      "id": 4,
      "title": "Занятость и backpressure (мс в секунду)",
      "type": "timeseries",
      "gridPos": {
        "x": 12,
        "y": 8,
        "w": 12,
        "h": 8
      },
      "datasource": {
        "type": "prometheus",
        "uid": "prometheus"
      },
      "fieldConfig": {
        "defaults": {
          "unit": "short"
        },
        "overrides": []
      },
      "targets": [
        {
          "refId": "A",
          "expr": "max by (task_name) (flink_taskmanager_job_task_busyTimeMsPerSecond{job_name=\"payguard\"})",
          "legendFormat": "busy {{task_name}}"
        },
        {
          "refId": "B",
          "expr": "max by (task_name) (flink_taskmanager_job_task_backPressuredTimeMsPerSecond{job_name=\"payguard\"})",
          "legendFormat": "backpressure {{task_name}}"
        }
      ]
    },
    {
      "id": 5,
      "title": "Длительность чекпоинта",
      "type": "timeseries",
      "gridPos": {
        "x": 0,
        "y": 16,
        "w": 12,
        "h": 8
      },
      "datasource": {
        "type": "prometheus",
        "uid": "prometheus"
      },
      "fieldConfig": {
        "defaults": {
          "unit": "ms"
        },
        "overrides": []
      },
      "targets": [
        {
          "refId": "A",
          "expr": "flink_jobmanager_job_lastCheckpointDuration{job_name=\"payguard\"}",
          "legendFormat": "длительность"
        }
      ]
    },
    {
      "id": 6,
      "title": "Размер чекпоинта",
      "type": "timeseries",
      "gridPos": {
        "x": 12,
        "y": 16,
        "w": 12,
        "h": 8
      },
      "datasource": {
        "type": "prometheus",
        "uid": "prometheus"
      },
      "fieldConfig": {
        "defaults": {
          "unit": "bytes"
        },
        "overrides": []
      },
      "targets": [
        {
          "refId": "A",
          "expr": "flink_jobmanager_job_lastCheckpointSize{job_name=\"payguard\"}",
          "legendFormat": "записано"
        },
        {
          "refId": "B",
          "expr": "flink_jobmanager_job_lastCheckpointFullSize{job_name=\"payguard\"}",
          "legendFormat": "полное состояние"
        }
      ]
    },
    {
      "id": 7,
      "title": "Перезапуски и неудачные чекпоинты",
      "type": "timeseries",
      "gridPos": {
        "x": 0,
        "y": 24,
        "w": 12,
        "h": 8
      },
      "datasource": {
        "type": "prometheus",
        "uid": "prometheus"
      },
      "fieldConfig": {
        "defaults": {
          "unit": "short"
        },
        "overrides": []
      },
      "targets": [
        {
          "refId": "A",
          "expr": "flink_jobmanager_job_numRestarts{job_name=\"payguard\"}",
          "legendFormat": "перезапуски"
        },
        {
          "refId": "B",
          "expr": "flink_jobmanager_job_numberOfFailedCheckpoints{job_name=\"payguard\"}",
          "legendFormat": "неудачные чекпоинты"
        }
      ]
    },
    {
      "id": 8,
      "title": "Алерты по правилам, в минуту",
      "type": "timeseries",
      "gridPos": {
        "x": 12,
        "y": 24,
        "w": 12,
        "h": 8
      },
      "datasource": {
        "type": "prometheus",
        "uid": "prometheus"
      },
      "fieldConfig": {
        "defaults": {
          "unit": "short"
        },
        "overrides": []
      },
      "targets": [
        {
          "refId": "A",
          "expr": "sum by (rule) (rate(flink_taskmanager_job_task_operator_rule_alerts{job_name=\"payguard\"}[1m])) * 60",
          "legendFormat": "{{rule}}"
        }
      ]
    },
    {
      "id": 9,
      "title": "Куча TaskManager",
      "type": "timeseries",
      "gridPos": {
        "x": 0,
        "y": 32,
        "w": 12,
        "h": 8
      },
      "datasource": {
        "type": "prometheus",
        "uid": "prometheus"
      },
      "fieldConfig": {
        "defaults": {
          "unit": "bytes"
        },
        "overrides": []
      },
      "targets": [
        {
          "refId": "A",
          "expr": "flink_taskmanager_Status_JVM_Memory_Heap_Used",
          "legendFormat": "занято"
        },
        {
          "refId": "B",
          "expr": "flink_taskmanager_Status_JVM_Memory_Heap_Max",
          "legendFormat": "максимум"
        }
      ]
    },
    {
      "id": 10,
      "title": "Время GC в секунду",
      "type": "timeseries",
      "gridPos": {
        "x": 12,
        "y": 32,
        "w": 12,
        "h": 8
      },
      "datasource": {
        "type": "prometheus",
        "uid": "prometheus"
      },
      "fieldConfig": {
        "defaults": {
          "unit": "ms"
        },
        "overrides": []
      },
      "targets": [
        {
          "refId": "A",
          "expr": "rate(flink_taskmanager_Status_JVM_GarbageCollector_All_Time[1m])",
          "legendFormat": "GC"
        }
      ]
    }
  ]
}

Что в них стоит заметить.

  • Отставание по времени события берётся как максимум по подзадачам источника. Отстающая подзадача важнее средней.
  • На панели занятости два запроса, busy и backpressure, оба максимумом по задаче. Читать эту пару научимся в уроке 18.
  • Размер чекпоинта показан двумя рядами. lastCheckpointSize это сколько записано в последний раз, lastCheckpointFullSize это полный объём состояния. При инкрементальных чекпоинтах RocksDB первый заметно меньше второго.
  • Алерты в минуту считаются как rate за минуту, умноженный на 60, и складываются по метке rule. После перезапуска задачи счётчики начинают с нуля, и rate корректно принимает это за сброс.
  • Время GC берётся из Status.JVM.GarbageCollector.All.Time, суммы по всем сборщикам. Это растущий итог в миллисекундах, а rate превращает его в миллисекунды сборки на секунду работы.

Шаг 6. Собери, подними и отправь

терминал
./gradlew shadowJar
docker compose up -d --wait
./scripts/submit.sh
./gradlew loadgen --args="--rate 200"

Сначала сборка, потом up, иначе Docker смонтирует пустую build/libs. Это та же ловушка, что и раньше. Флаг --wait дожидается запуска всех контейнеров, включая новые Prometheus и Grafana. Генератор оставь работать в отдельном терминале.

Проверь, что Prometheus видит обе цели. Его HTTP API отвечает на порту 9090.

терминал
curl -s localhost:9090/api/v1/targets \
  | jq -r '.data.activeTargets[] | "\(.scrapeUrl) \(.health)"'

Для каждой цели должно стоять up. Если у taskmanager:9249 стоит down, посмотри лог TaskManager. При старте репортер пишет строку Started PrometheusReporter HTTP server on port 9249, это текст из кода, который мы читали в теории.

Посчитай, сколько разных имён метрик Prometheus уже знает.

терминал
curl -s localhost:9090/api/v1/label/__name__/values | jq '.data | length'

На нашем стенде при работающем задании было 374 имени. Точное число у тебя может отличаться, оно зависит от операторов в задании и версии клиента Kafka.

Шаг 7. Прочитай дашборд

Открой http://localhost:3000, логин не спросят, дашборд PayGuard уже в списке. Вот что показали панели на нашем стенде при 200 платежах в секунду.

ПанельЗначениеКак читать
Платежей в секунду на входе193Близко к заданным 200. Meter усредняет за минуту, поэтому за изменением нагрузки он следует с опозданием
Отставание по времени событияминус 23 мсЗадание успевает за потоком. Минус означает расхождение часов генератора и контейнера
Длительность чекпоинта286 мсМного меньше интервала 15 с, запас огромный
Размер чекпоинтазаписано 3475883 байт, полное состояние 12811127 байтИнкрементальный чекпоинт записал около 27 % состояния
Алерты по правилам43 в минуту в суммеЕсли линия одного правила упала в ноль, а платежи идут, правило сломано
Куча TaskManager431 МБ из 698 МБЗапас есть. Как Flink делит память процесса, разберём в уроке 19

Лаг Kafka в этом прогоне был 0. Панель для него ты добавишь в задании.

Любой запрос панели можно выполнить и без Grafana. Например, алерты по правилам за последнюю минуту.

терминал
curl -s localhost:9090/api/v1/query \
  --data-urlencode 'query=sum by (rule) (rate(flink_taskmanager_job_task_operator_rule_alerts{job_name="payguard"}[1m])) * 60' \
  | jq -r '.data.result[] | "\(.metric.rule) \(.value[1])"'

Флаг --data-urlencode кодирует фигурные скобки и кавычки запроса. curl с этим флагом сам переключается на POST, и Prometheus принимает запрос в теле.

Шаг 8. Сломай задание и посмотри на алерты

Состояние правил удобно смотреть на странице http://localhost:9090/alerts или через API.

терминал
curl -s localhost:9090/api/v1/alerts \
  | jq -r '.data.alerts[] | "\(.labels.alertname) \(.state)"'

Пустой вывод значит, что ни одно правило не в pending и не в firing. Первый сценарий это внезапная смерть TaskManager. Скрипт из урока 11 убивает его сигналом SIGKILL, поднимает снова и ждёт восстановления из чекпоинта.

терминал
./scripts/chaos-kill-tm.sh

На стенде задание восстановилось за 15 секунд, а PayGuardJobRestarted перешёл в firing через 25 секунд после убийства. Разница складывается из ожидания следующего опроса и следующей проверки правил, каждое до 10 секунд. Через пять минут без новых перезапусков алерт погаснет сам.

Второй сценарий длиннее. TaskManager останавливается штатно и 100 секунд не возвращается. У сервиса в Compose стоит restart: unless-stopped, а ручная остановка этой политикой не перезапускается.

терминал
docker compose stop taskmanager
sleep 100
docker compose start taskmanager

Во время простоя на стенде PayGuardCheckpointsFailing перешёл в firing. Задаче негде работать, и каждая попытка чекпоинта заканчивается отказом. Правило на лаг, которое ты напишешь в задании, в первом прогоне этого сценария уже было в alerts.yml и не сработало ни разу. Метрика лага живёт в TaskManager и исчезла вместе с ним. Именно после этого прогона мы добавили PayGuardSourceSilent на absent. По своему выражению оно загорается через минуту после того, как ряд источника пропал. Повтори сценарий и проверь это сам, на нашем стенде повторный прогон с этим правилом не записан.

Через 60 секунд после возврата TaskManager лаг на стенде был 11220 записей, а правило на лаг стояло в pending и ждало свои две минуты. За время простоя в топике накопились платежи, и задание их догоняет. Если оно догонит быстрее, чем пройдёт выдержка, алерт не загорится. Это то же поведение, что ты видел в модели на вкладке теории.

⚠
Правило, которое ни разу не срабатывало, не проверено

Ошибку в правиле на лаг мы нашли только потому, что сломали стенд нарочно. Опечатка в имени метрики или в значении метки даёт тот же эффект, выражение всегда пустое, и Prometheus молча считает, что всё хорошо. Каждое новое правило стоит один раз довести до firing руками.

Задание. Алерт и панель на лаг Kafka

В monitoring/alerts.yml пока нет правила на отставание от топика payments.raw. На дашборде пустует место справа от первой панели. Добавь и то и другое.

  1. Правило PayGuardKafkaLagHigh в группе payguard-flink. Оно срабатывает, если суммарный лаг источника payments-raw по всем подзадачам больше 5000 записей и держится так две минуты. Критичность critical, в аннотации summary текущее значение лага.
  2. Панель «Лаг Kafka, записей» в payguard.json на месте x 12, y 0, ширина 12, высота 8, тем же запросом, что и в правиле.

Готово, если после перезапуска Prometheus и Grafana панель показывает лаг около нуля при работающем генераторе, а после сценария с остановкой TaskManager на 100 секунд правило переходит в pending вскоре после возврата TaskManager.

Подсказки.

  • Найди точное имя метрики запросом к /api/v1/label/__name__/values и отфильтруй его по lag через jq или grep.
  • Метрик лага у источника несколько. Посмотри, какие метки есть у нужного ряда, и подумай, по какой из них схлопывать, чтобы на подзадачу остался один ряд.
  • Значение метки operator_name бери из готовых правил. Без фильтра по оператору в сумму попадут источники правил и подтверждений.
  • Prometheus в нашем Compose не перечитывает правила сам. После правки файла перезапусти контейнер.

Ответ

Глоссарий урока

Метрика (metric)
Именованное числовое значение, которое Flink или твой код обновляет во время работы. Метрики собирают в системы мониторинга и строят по ним графики и алерты.
Группа метрик (MetricGroup)
Узел дерева, в котором регистрируются метрики. Группы идут от процесса к заданию, задаче и оператору. Метод addGroup добавляет свои уровни.
Counter
Тип метрики Flink, целое число с методами inc и dec. В Prometheus экспортируется как Gauge.
Gauge
Метрика, значение которой вычисляется в момент чтения, например занятая куча. В Prometheus так же называется тип ряда, который может расти и падать.
Meter
Метрика Flink со скоростью событий в секунду. Реализация MeterView усредняет за 60 секунд. В Prometheus отдаётся как Gauge со значением скорости.
Histogram
Метрика Flink с распределением значений. В Prometheus превращается в Summary с квантилями.
Summary
Тип метрики Prometheus с готовыми квантилями и числом наблюдений. Из гистограмм Flink приходит без ряда суммы.
Область метрики (scope)
Путь группы в дереве метрик. Системную область задаёт Flink, пользовательскую ты добавляешь через addGroup. Репортер Prometheus строит имя по логической области, из названий уровней.
Переменная группы и метка (label)
Пара ключ и значение, привязанная к группе метрик, например имя оператора. Репортер Prometheus превращает каждую переменную в метку ряда.
Кардинальность
Число разных временных рядов. Растёт с каждым новым значением метки и определяет, сколько памяти нужно Prometheus.
Временной ряд (time series)
Последовательность значений одной метрики с одним набором меток во времени. Единица хранения в Prometheus.
Репортер (reporter)
Подключаемый модуль Flink, который выносит метрики во внешнюю систему. Создаётся в каждом JobManager и TaskManager при старте.
Prometheus
Система мониторинга, которая сама опрашивает цели по HTTP, хранит временные ряды и проверяет правила алертов.
Опрос (scrape)
Запрос Prometheus к HTTP-адресу цели за текущими значениями метрик. Частоту задаёт scrape_interval.
PromQL
Язык запросов Prometheus. На нём пишут панели Grafana и выражения правил.
rate и increase
Функции PromQL для растущих счётчиков. increase даёт прирост за окно, rate прирост в секунду. Падение значения считают сбросом счётчика.
absent
Функция PromQL, которая возвращает 1, когда выражение внутри не нашло ни одного ряда. Нужна для алертов на пропавшие метрики.
Правило алерта (alerting rule)
Выражение PromQL с именем, метками и выдержкой. Пока выражение возвращает ряды, алерт активен.
Выдержка for, pending и firing
Состояния алерта в Prometheus. Pending означает, что условие выполнено, но держится меньше выдержки for. Firing означает, что выдержка пройдена.
Alertmanager
Компонент экосистемы Prometheus, который группирует алерты и рассылает уведомления в почту, мессенджеры и системы дежурств. На учебном стенде не установлен.
Grafana
Приложение для дашбордов. Берёт данные из Prometheus и рисует панели по запросам PromQL.
Provisioning
Настройка Grafana из файлов при старте. Источники данных и дашборды описываются в YAML и JSON и хранятся в git.
Дашборд (dashboard)
Набор панелей Grafana на одном экране. У PayGuard он описан в payguard.json.
Лаг Kafka (consumer lag)
Сколько записей в партиции ещё не прочитано потребителем. Метрика клиента Kafka, во Flink живёт в TaskManager вместе с источником.
Отставание по времени события (currentEmitEventTimeLag)
Метрика источника, разница между часами TaskManager и временем события последней выданной записи.
busyTimeMsPerSecond
Сколько миллисекунд в секунду задача была занята обработкой. 1000 означает полную загрузку. Подробно в уроке 18.
backPressuredTimeMsPerSecond
Сколько миллисекунд в секунду задача ждала свободного буфера у следующего оператора. Признак backpressure, разбирается в уроке 18.
RED и USE
Две схемы выбора метрик. RED для сервиса, скорость, ошибки и длительность. USE для ресурса, загрузка, насыщение и ошибки.
Куча (heap)
Область памяти JVM для объектов Java. Её заполнение и время сборки мусора показывает дашборд.
Сборка мусора (GC)
Работа JVM по освобождению памяти от ненужных объектов. Долгие паузы GC останавливают обработку записей.

Урок 18 · Прод

Производительность. Где задание теряет время и как разгрузить горячий ключ

После урока ты по трём метрикам найдёшь медленный оператор, посмотришь на него через flame graph, выберешь режим чекпоинтов под backpressure и разложишь горячего продавца на части двухфазной агрегацией.

Лаг растёт, а виноватым выглядит не тот оператор

Представь обычный вечер дежурства. Лаг Kafka у PayGuard растёт, чекпоинты вместо трёхсот миллисекунд идут по минуте. В Web UI одна вершина залита чёрным, это deduplicate с пометкой HIGH. Первое желание понятно. Поднять дедупликации параллелизм или переписать её состояние.

Это ошибка, и в уроке мы воспроизведём её на стенде. Дедупликация почти ничего не делает. Она стоит, потому что ей некуда отдать записи. Тормозит оператор ниже по потоку, который пишет профиль карты в медленную внешнюю систему. Урок про то, как отличить жертву от причины, чем смотреть внутрь причины и что делать, когда причина в данных, у которых один ключ получает треть потока.

Механику кредитов и буферов мы разобрали в уроке 4, выравнивание барьеров в уроке 11, сами метрики и алерты в уроке 17. Здесь мы ими пользуемся.

Пробка перед сужением

На трассе ремонт, из трёх полос осталась одна. Перед сужением машины стоят бампер к бамперу, хотя у каждой исправный мотор. За сужением дорога пустая, и водители там скучают. Само сужение работает на пределе, через него всё время кто-то едет.

Если спросить водителя в хвосте пробки, почему он стоит, он скажет, что стоит машина впереди. Это правда, и она ничего не объясняет. Чтобы найти ремонт, надо ехать вдоль пробки вперёд до первого места, где машины движутся медленно и при этом никого перед собой не ждут.

В задании Flink всё так же. Вершины выше узкого места стоят в backpressure, вершины ниже и рядом простаивают, а само узкое место занято всё время. Искать его нужно от источника вниз по потоку.

Три счётчика одной секунды

Каждая подзадача отчитывается тремя метриками. idleTimeMsPerSecond показывает, сколько миллисекунд из секунды она ждала входных данных. backPressuredTimeMsPerSecond показывает, сколько она ждала свободный буфер на выходе, урок 4 разбирал, откуда это ожидание берётся. busyTimeMsPerSecond это всё остальное время. В сумме выходит около 1000.

Слово «всё остальное» здесь точное. Flink не измеряет занятость напрямую, он вычитает из секунды два ожидания. Поэтому в busy попадает любое время внутри твоей функции, включая сон, синхронный HTTP-запрос и паузу на запись в базу. Процессор при этом может быть свободен.

В исходниках

Метрика занятости регистрируется как вычисляемая величина. Посмотри на формулу. Таймер простоя и время backpressure складываются, а busy получается остатком до 1000.

apache/flink@release-2.2.1 · TaskIOMetricGroup.java, строки 280-284
@VisibleForTesting
double getBusyTimePerSecond() {
    double busyTime = idleTimePerSecond.getValue() + getBackPressuredTimeMsPerSecond();
    return busyTimeEnabled ? 1000.0 - Math.min(busyTime, 1000.0) : Double.NaN;
}

Отсюда два вывода. Подзадача, которая спит в Thread.sleep, покажет busy 1000. Если busyTime выключен, метрика вернёт NaN, такое бывает у старых источников.

Теперь REST-ручка /backpressure, которую рисует вкладка Back Pressure в Web UI. Она берёт те же миллисекунды, делит на 1000 и раскладывает по трём уровням.

apache/flink@release-2.2.1 · JobVertexBackPressureHandler.java, строки 195-215
private double getMsPerSecondMetricAsRatio(
        ComponentMetricStore metricStore, String metricName) {
    return Double.valueOf(metricStore.getMetric(metricName, "0")) / 1_000;
}

/**
 * Returns the back pressure level as a String.
 *
 * @param backPressureRatio Ratio of back pressures samples to total number of samples.
 * @return Back pressure level ('ok', 'low', or 'high')
 */
private static JobVertexBackPressureInfo.VertexBackPressureLevel getBackPressureLevel(
        double backPressureRatio) {
    if (backPressureRatio <= 0.10) {
        return JobVertexBackPressureInfo.VertexBackPressureLevel.OK;
    } else if (backPressureRatio <= 0.5) {
        return JobVertexBackPressureInfo.VertexBackPressureLevel.LOW;
    } else {
        return JobVertexBackPressureInfo.VertexBackPressureLevel.HIGH;
    }
}

Значит, уровень high означает больше 500 мс ожидания в секунду. Когда на стенде ручка ответит ratio 0.999, это ровно те 999 мс из метрики, пересчитанные в долю.

У графа в Web UI есть особенность, о которой говорит документация. Вершина показывает максимум по подзадачам. Если одна подзадача занята на 1000, а три другие на 100, вершина всё равно будет красной. Такая картина говорит о перекосе данных, медленный код дал бы высокую занятость всем подзадачам. Перекос мы разберём в конце теории.

Найди узкое место сам

Ниже упрощённая модель нашего графа. Поток 200 платежей в секунду, у каждой вершины две подзадачи, значит, на подзадачу приходится 100 записей в секунду. Ползунок добавляет задержку в оператор slow-down, который в практике встанет после card-profile. Числа в модели посчитаны по той же формуле, что в TaskIOMetricGroup. Красная часть полосы это busy, тёмная это back, синяя это idle.

Какая вершина тормозит задание

Двигай ползунок и смотри, как меняется цвет вершин.

Пока задержка меньше 10 мс, подзадача успевает за свои 100 записей, и граф синий. Стоит перейти этот порог, и картина переворачивается сразу во всём графе. Узкий оператор краснеет. Источник и дедупликация становятся тёмными, хотя их работа не изменилась. Соседние ветки синеют сильнее, потому что дедупликация стала отдавать им меньше записей.

Правило поиска выходит такое. Иди от источника вниз и остановись на первой вершине, у которой busy высокий, а back около нуля. Тёмные вершины перед ней ни в чём не виноваты. Если такое состояние продержится пять минут, правило PayGuardBackpressure из урока 17 сработает именно на дедупликации. Алерт показывает, откуда начать поиск, дальше по графу ты идёшь сам.

Что внутри узкого места

Метрики говорят, где тормозит. Почему тормозит, показывает flame graph. Это картинка из стеков вызовов, собранных много раз подряд. Каждый метод нарисован полосой, и её ширина пропорциональна тому, в скольких снимках стека он встретился. Широкая полоса наверху стопки означает метод, в котором поток проводит больше всего времени.

Во Flink flame graph строит JobManager. Он просит TaskManager несколько раз снять стек потока подзадачи и складывает результаты. Функция выключена по умолчанию, потому что снятие стеков само стоит ресурсов.

В исходниках

Ключ включения и его значение по умолчанию.

apache/flink@release-2.2.1 · RestOptions.java, строки 227-233
/** Enables the experimental flame graph feature. */
@Documentation.Section(Documentation.Sections.EXPERT_REST)
public static final ConfigOption<Boolean> ENABLE_FLAMEGRAPH =
        key("rest.flamegraph.enabled")
                .booleanType()
                .defaultValue(false)
                .withDescription("Enables the experimental flame graph feature.");

Тип графа выбирается по состоянию потока Java в момент снимка. On-CPU берёт потоки в состоянии RUNNABLE, Off-CPU берёт ждущие и заблокированные. Полный граф (в интерфейсе он называется Mixed) берёт всё.

apache/flink@release-2.2.1 · VertexFlameGraphFactory.java, строки 48-75
/**
 * Converts {@link VertexThreadInfoStats} into a FlameGraph representing blocked (Off-CPU)
 * threads.
 *
 * <p>Includes threads in states Thread.State.[TIMED_WAITING, BLOCKED, WAITING].
 *
 * @param sample Thread details sample containing stack traces.
 * @return FlameGraph data structure.
 */
public static VertexFlameGraph createOffCpuFlameGraph(VertexThreadInfoStats sample) {
    EnumSet<Thread.State> included =
            EnumSet.of(Thread.State.TIMED_WAITING, Thread.State.BLOCKED, Thread.State.WAITING);
    return createFlameGraphFromSample(sample, included);
}

/**
 * Converts {@link VertexThreadInfoStats} into a FlameGraph representing actively running
 * (On-CPU) threads.
 *
 * <p>Includes threads in states Thread.State.[RUNNABLE, NEW].
 *
 * @param sample Thread details sample containing stack traces.
 * @return FlameGraph data structure
 */
public static VertexFlameGraph createOnCpuFlameGraph(VertexThreadInfoStats sample) {
    EnumSet<Thread.State> included = EnumSet.of(Thread.State.RUNNABLE, Thread.State.NEW);
    return createFlameGraphFromSample(sample, included);
}

Поток в Thread.sleep или в ожидании ответа по сокету находится в TIMED_WAITING или WAITING. Значит, наш тормоз в On-CPU графе не появится вовсе, хотя busy у вершины 1000. Его покажут Off-CPU и полный граф.

Снимки всех подзадач вершины складываются в один граф. Это объясняет число, которое мы увидим на стенде.

apache/flink@release-2.2.1 · VertexFlameGraphFactory.java, строки 79-86
final NodeBuilder root = new NodeBuilder("root");
for (Collection<ThreadInfoSample> threadInfoSubSamples :
        sample.getSamplesBySubtask().values()) {
    for (ThreadInfoSample threadInfo : threadInfoSubSamples) {
        if (threadStates.contains(threadInfo.getThreadState())) {
            StackTraceElement[] traces = cleanLambdaNames(threadInfo.getStackTrace());
            root.incrementHitCount();
            NodeBuilder parent = root;

По умолчанию rest.flamegraph.num-samples равен 100. У вершины две подзадачи, и корень графа насчитает 200 снимков. Документация предупреждает о побочном эффекте сложения. Если одна подзадача из многих занята на 100 %, её вклад растворится, и для поиска перекоса надо открывать граф отдельной подзадачи.

Что обычно находится в широкой полосе у медленного оператора.

  • Синхронный вызов внешней системы. Полоса уходит в сокет или драйвер базы, и её видно в Off-CPU графе. Лечится асинхронным вводом-выводом из урока 9 или пакетной записью.
  • Сериализация. Между вершинами записи всегда превращаются в байты. Если тип ушёл в Kryo, в On-CPU графе вырастают методы KryoSerializer. В PayGuard такое невозможно с урока 3, где мы запретили обобщённые типы.
  • Доступ к состоянию. В RocksDB каждое чтение и запись проходит через сериализацию и может читать диск. Об этом урок 19.
  • Сборка мусора. Её нет в стеке задачи, зато её видно в метриках JVM из урока 17.

У сериализации есть ещё одно место, о котором мало кто помнит. Внутри цепочки операторов записи по сети не идут, но Flink всё равно копирует каждую запись перед следующим оператором цепочки. Копию снимает тот же сериализатор типа.

В исходниках

При сборке цепочки Flink выбирает выход оператора. Без повторного использования объектов (object reuse) берётся копирующий вариант.

apache/flink@release-2.2.1 · OperatorChain.java, строки 909-924
WatermarkGaugeExposingOutput<StreamRecord<IN>> currentOperatorOutput;
if (containingTask.getExecutionConfig().isObjectReuseEnabled()) {
    currentOperatorOutput =
            new ChainingOutput<>(
                    operator, recordsOutCounter, operator.getMetricGroup(), outputTag);
} else {
    TypeSerializer<IN> inSerializer =
            operatorConfig.getTypeSerializerIn1(userCodeClassloader);
    currentOperatorOutput =
            new CopyingChainingOutput<>(
                    operator,
                    inSerializer,
                    recordsOutCounter,
                    operator.getMetricGroup(),
                    outputTag);
}

И сама копия перед передачей следующему оператору.

apache/flink@release-2.2.1 · CopyingChainingOutput.java, строки 64-75
@Override
protected <X> void pushToOperator(StreamRecord<X> record) {
    try {
        // we know that the given outputTag matches our OutputTag so the record
        // must be of the type that our operator (and Serializer) expects.
        @SuppressWarnings("unchecked")
        StreamRecord<T> castRecord = (StreamRecord<T>) record;

        numRecordsOut.inc();
        numRecordsIn.inc();
        StreamRecord<T> copy = castRecord.copy(serializer.copy(castRecord.getValue()));
        recordProcessor.accept(copy);

Настройка pipeline.object-reuse убирает эту копию. Описание ключа честно предупреждает, что код, который хранит ссылку на входной объект или меняет объект после отправки, начнёт ломаться. Для неизменяемых record-типов PayGuard риск маленький, но в курсе ключ не включён, и выигрыш надо мерить на своём задании.

apache/flink@release-2.2.1 · PipelineOptions.java, строки 200-206
public static final ConfigOption<Boolean> OBJECT_REUSE =
        key("pipeline.object-reuse")
                .booleanType()
                .defaultValue(false)
                .withDescription(
                        "When enabled objects that Flink internally uses for deserialization and passing"
                                + " data to user-code functions will be reused. Keep in mind that this can lead to bugs when the"

Чекпоинты в пробке

Из урока 11 ты знаешь, почему при backpressure выровненный чекпоинт медленный. Барьер стоит в очереди канала за всеми буферами, которые там скопились. Unaligned-чекпоинт разрешает барьеру обогнать эти буферы и сохраняет их вместе со снимком. Есть третий путь, его в уроке 11 не было. Можно сделать так, чтобы очередь перед барьером стала короче.

Этим занимается buffer debloating. Подзадача-получатель меряет свою пропускную способность и подбирает размер сетевого буфера так, чтобы данные в полёте (in-flight data) разбирались примерно за заданное время. По умолчанию это одна секунда. Количество буферов при этом не меняется, уменьшается только их полезный размер.

В исходниках

Целевое время и выключатель. Механизм по умолчанию выключен.

apache/flink@release-2.2.1 · TaskManagerOptions.java, строки 480-497
/** The total time for which automated adjusted buffers should be fully consumed. */
@Documentation.Section(Documentation.Sections.ALL_TASK_MANAGER_NETWORK)
public static final ConfigOption<Duration> BUFFER_DEBLOAT_TARGET =
        ConfigOptions.key("taskmanager.network.memory.buffer-debloat.target")
                .durationType()
                .defaultValue(Duration.ofSeconds(1))
                .withDescription(
                        "The target total time after which buffered in-flight data should be fully consumed. "
                                + "This configuration option will be used, in combination with the measured throughput, to adjust the amount of in-flight data.");

@Documentation.Section(Documentation.Sections.ALL_TASK_MANAGER_NETWORK)
public static final ConfigOption<Boolean> BUFFER_DEBLOAT_ENABLED =
        ConfigOptions.key("taskmanager.network.memory.buffer-debloat.enabled")
                .booleanType()
                .defaultValue(false)
                .withDescription(
                        "The switch of the automatic buffered debloating feature. "
                                + "If enabled the amount of in-flight data will be adjusted automatically accordingly to the measured throughput.");

Сама формула в BufferDebloater. Желаемый объём данных в полёте равен пропускной способности, умноженной на целевое время. Новый размер буфера сглаживается скользящим средним и делится на число занятых буферов.

apache/flink@release-2.2.1 · BufferDebloater.java, строки 99-113
public OptionalInt recalculateBufferSize(long currentThroughput, int buffersInUse) {
    int actualBuffersInUse = Math.max(1, buffersInUse);
    long desiredTotalBufferSizeInBytes =
            (currentThroughput * targetTotalTime) / MILLIS_IN_SECOND;

    int newSize =
            bufferSizeEMA.calculateBufferSize(
                    desiredTotalBufferSizeInBytes, actualBuffersInUse);

    lastEstimatedTimeToConsumeBuffers =
            Duration.ofMillis(
                    newSize
                            * actualBuffersInUse
                            * MILLIS_IN_SECOND
                            / Math.max(1, currentThroughput));

Пересчёт запускает входной шлюз подзадачи. Он берёт измеренную пропускную способность и рассылает новый размер всем своим каналам, а те сообщают его отправителям.

apache/flink@release-2.2.1 · SingleInputGate.java, строки 461-472
@Override
public void triggerDebloating() {
    if (isFinished() || closeFuture.isDone()) {
        return;
    }

    checkState(bufferDebloater != null, "Buffer debloater should not be null");
    final long currentThroughput = throughputCalculator.calculateThroughput();
    bufferDebloater
            .recalculateBufferSize(currentThroughput, getBuffersInUseCount())
            .ifPresent(this::announceBufferSize);
}

Медленный получатель с пропускной способностью в десятки записей в секунду получит маленькие буферы. Барьеру придётся ждать в очереди меньше байтов, и выравнивание закончится быстрее.

Компромисс между двумя режимами из урока 11 задаёт таймаут выравнивания. Чекпоинт начинается выровненным и переключается на unaligned, если выравнивание затянулось.

В исходниках

Ключ работает только при включённых unaligned-чекпоинтах. Значение по умолчанию 0, то есть с включённым unaligned каждый чекпоинт сразу стартует невыровненным.

apache/flink@release-2.2.1 · CheckpointingOptions.java, строки 564-584
public static final ConfigOption<Duration> ALIGNED_CHECKPOINT_TIMEOUT =
        ConfigOptions.key("execution.checkpointing.aligned-checkpoint-timeout")
                .durationType()
                .defaultValue(Duration.ofSeconds(0L))
                .withDeprecatedKeys("execution.checkpointing.alignment-timeout")
                .withDescription(
                        Description.builder()
                                .text(
                                        "Only relevant if %s is enabled.",
                                        TextElement.code(ENABLE_UNALIGNED.key()))
                                .linebreak()
                                .linebreak()
                                .text(
                                        "If timeout is 0, checkpoints will always start unaligned.")
                                .linebreak()
                                .linebreak()
                                .text(
                                        "If timeout has a positive value, checkpoints will start aligned. "
                                                + "If during checkpointing, checkpoint start delay exceeds this timeout, alignment "
                                                + "will timeout and checkpoint barrier will start working as unaligned checkpoint.")
                                .build());

Если поставить, например, 10 секунд, спокойные чекпоинты останутся выровненными и маленькими, а в пробке переключатся и не будут ждать минуту.

Вот что получилось на стенде при одной и той же нагрузке 200 платежей в секунду и задержке 20 мс на запись. Колонка про данные взята из статистики чекпоинта Processed (persisted) in-flight data.

РежимДлительность чекпоинтаДанные в полётеЛаг Kafka в момент замера
Без тормоза, выровненный223-367 мспочти нет0
Тормоз, выровненный25651 и 61137 мсочередь перед барьером1340 и растёт
Тормоз, unaligned129-293 мсзаписано 705-1011 КиБ10760
Тормоз, выровненный с debloat1540-2368 мсдо барьера проходит 29-54 КиБ16421
⚠
Ни один режим не лечит перегрузку

Посмотри на последнюю колонку. Лаг растёт при любом режиме, потому что оператор по-прежнему успевает 50 записей в секунду на подзадачу при нужных 100. Unaligned и debloat спасают чекпоинты, а с ними и время восстановления. Саму пробку убирает только работа с узким местом, то есть быстрее код, больше параллелизма или асинхронный вызов.

Как выбирать. Unaligned даёт самые короткие чекпоинты, но пишет в снимок данные в полёте, и на широком графе это сотни мегабайт. Debloat ничего лишнего не пишет и ускоряет выровненные чекпоинты в десятки раз, зато реагирует на изменение потока с задержкой и плохо работает на операторах с несколькими входами разной скорости, об этом прямо сказано в документации. Их можно включать вместе, тогда unaligned-чекпоинты станут меньше.

Горячий ключ

Вторая частая причина красной вершины лежит в данных. Генератор отдаёт продавцу m-0000 30 % платежей. Окно merchant-stats-1m группирует по продавцу, все записи одного ключа обрабатывает одна подзадача, и при параллелизме 4 разброс входа между подзадачами на стенде получился 11476 против 29551, в 2,6 раза. Урок 4 показал, почему больше параллелизма здесь не поможет.

Лечение называется двухфазной агрегацией с солью (salting). Горячий ключ на время разбивают на части. К продавцу приписывают номер от 0 до N-1, и записи одного продавца расходятся по N разным ключам. Первая фаза считает частичный итог по каждой части. Вторая фаза группирует частичные итоги по настоящему продавцу и складывает их. Во вторую фазу приходит N записей на продавца за окно вместо тысяч платежей.

m-000030 % платежей Фаза 1, ключ m-0000#соль #0, #4, #8, #12 #1, #5, #9, #13 #2, #6, #10, #14 #3, #7, #11, #15 четыре подзадачи, раскладка условная 16 частичных итоговза минуту Фаза 2 m-0000сумма частей

Соль подходит не для каждой агрегации. Сумма и количество складываются из частей без потерь. Среднее надо считать как сумму и количество и делить уже во второй фазе. Число уникальных значений из частей не складывается, для него Flink SQL использует отдельный приём с корзинами по значению (split distinct).

В Flink SQL двухфазная агрегация уже встроена. В плане из урока 14 ты видел пару LocalWindowAggregate и GlobalWindowAggregate. Там первая фаза частично складывает записи ещё до сети, на стороне отправителя. В DataStream API такой оптимизации нет, ключ после keyBy закреплён за своей группой ключей, и разложить его можешь только ты сам.

Сравнение со Spark

В Spark Structured Streaming медленный приёмник делает длиннее каждый micro-batch. Следующий batch стартует позже и забирает накопившиеся смещения. Чтобы batch не разрастался, его ограничивают опцией источника Kafka maxOffsetsPerTrigger. Чтобы найти медленное место, смотрят длительность стадий и задач в Spark UI.

Во Flink конвейер непрерывный, поэтому задержка одного оператора сразу превращается в backpressure по всему графу выше него. Ограничитель скорости здесь не нужен, источник сам перестаёт читать быстрее, чем граф успевает обработать. Для перекоса в пакетном Spark есть адаптивное выполнение запросов, которое умеет делить перекошенную партицию при соединении. В DataStream Flink такой автоматики нет, и для горячего ключа ты пишешь две фазы руками.

Проверь себя

В графе три вершины подряд. Источник показывает back 970, enrich-alerts back 940, alerts-sink busy 990 и back 0. Куда смотреть в первую очередь?

У вершины busy 1000, но в On-CPU flame graph почти пусто, а загрузка процессора TaskManager 5 %. Что вероятнее всего происходит?

Коллега включил unaligned-чекпоинты, и они стали идти 200 мс вместо минуты. Он закрывает инцидент с растущим лагом. Что ты ему скажешь?

Что будет в конце

Ты встроишь в задание учебный тормоз и включишь его флагом. На стенде увидишь, как одна медленная вершина раскрашивает весь граф, найдёшь её скриптом по REST, снимешь flame graph и сравнишь три режима чекпоинтов. Цифры в тексте получены на стенде курса при 200 платежах в секунду и параллелизме 2.

Файлы задания, это класс SaltedMerchantStats и его тест, пока не создавай. В снапшоте solutions/lesson-18 они уже есть, и строки 92-104 в PayGuardJob тоже относятся к заданию.

Шаг 1. Учебный тормоз

Создай src/main/java/dev/study/payguard/pipeline/SlowDown.java. Он изображает приёмник, который на каждую запись ходит во внешнюю систему и ждёт ответа.

src/main/java/dev/study/payguard/pipeline/SlowDown.java
package dev.study.payguard.pipeline;

import org.apache.flink.api.common.functions.MapFunction;

/** Учебный тормоз: имитирует медленную запись во внешнюю систему, чтобы вызвать backpressure. */
public class SlowDown<T> implements MapFunction<T, T> {

    private final long delayMs;

    public SlowDown(long delayMs) {
        this.delayMs = delayMs;
    }

    @Override
    public T map(T value) throws InterruptedException {
        if (delayMs > 0) {
            Thread.sleep(delayMs);
        }
        return value;
    }
}
КОД
    @Override
    public T map(T value) throws InterruptedException {
        if (delayMs > 0) {
            Thread.sleep(delayMs);
        }
        return value;
    }
ПРОСТЫМИ СЛОВАМИ

Обычный map, который возвращает запись без изменений. Он нужен только ради паузы.

При нулевой задержке оператор ничего не делает, поэтому его можно оставить в графе навсегда и включать флагом.

Thread.sleep держит поток задачи. Для Flink это время работы оператора, и оно попадёт в busy. Процессор при этом свободен.

Запись уходит дальше, к приёмнику Kafka.

Задержку берём из аргументов. В record JobConfig добавь поле.

src/main/java/dev/study/payguard/job/JobConfig.java (строки 21-21)
        long slowMs,

И его чтение в fromArgs на той же позиции. По умолчанию тормоз выключен.

src/main/java/dev/study/payguard/job/JobConfig.java (строки 41-41)
                params.getLong("slow-ms", 0),

Встрой оператор после card-profile. Вызов returns нужен, потому что SlowDown обобщённый, и без подсказки Java сотрёт тип записи. Запрет обобщённых типов из урока 3 тогда уронит задание при сборке графа.

src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 133-144)
    private static void attachCardProfiles(KeyedStream<PaymentEvent, String> byCard, JobConfig config) {
        byCard.process(new CardProfileFunction())
                .name("card-profile")
                .uid(CardProfileFunction.UID)
                .map(new SlowDown<CardProfile>(config.slowMs()))
                .returns(CardProfile.class)
                .name("slow-down")
                .uid("slow-down")
                .sinkTo(KafkaSinks.<CardProfile>atLeastOnceJson(config.bootstrapServers(), config.cardProfilesTopic()))
                .name("card-profiles-sink")
                .uid("card-profiles-sink");
    }

Между card-profile и slow-down нет смены ключа и параллелизма, поэтому Flink сцепит их в одну вершину. В интерфейсе она будет называться card-profile -> slow-down.

Шаг 2. Flame graph и переключатель debloat в Compose

В docker-compose.yml появились две строки в общих настройках Flink.

что изменилось в docker-compose.yml
@@ -28,6 +28,8 @@       metrics.reporter.prom.factory.class: org.apache.flink.metrics.prometheus.PrometheusReporterFactory
       metrics.reporter.prom.port: 9249
       metrics.latency.interval: 0
+      rest.flamegraph.enabled: true+      taskmanager.network.memory.buffer-debloat.enabled: ${BUFFER_DEBLOAT:-false}     PAYGUARD_DB_URL: jdbc:postgresql://postgres:5432/payguard
     PAYGUARD_DB_USER: payguard
     PAYGUARD_DB_PASSWORD: payguard

rest.flamegraph.enabled включает вкладку Flame Graph в Web UI и REST-ручку под ней. На проде документация советует считать её экспериментальной.

Во второй строке значение берётся из переменной окружения. Запись ${BUFFER_DEBLOAT:-false} Docker Compose раскрывает сам. Если переменная BUFFER_DEBLOAT не задана, подставится false. Так один файл обслуживает оба опыта.

Шаг 3. Скрипт нагрузки по вершинам

Смотреть каждую вершину в интерфейсе долго. Создай scripts/vertex-load.sh, он печатает одну строку на вершину.

scripts/vertex-load.sh
#!/usr/bin/env bash
# Для каждой вершины задания печатает занятость, долю ожидания следующего оператора и разброс записей по подзадачам.
set -euo pipefail
REST="${FLINK_REST:-http://localhost:8081}"

job_id=$(curl -s "$REST/jobs" | jq -r '[.jobs[] | select(.status == "RUNNING")][0].id')
if [ "$job_id" = "null" ]; then
  echo "Нет запущенных заданий" >&2
  exit 1
fi

printf '%-46s %6s %6s %12s %12s\n' "вершина" "busy" "back" "вход мин" "вход макс"
curl -s "$REST/jobs/$job_id" | jq -r '.vertices[] | "\(.id) \(.name)"' | while read -r id name; do
  metrics=$(curl -s "$REST/jobs/$job_id/vertices/$id/subtasks/metrics?get=busyTimeMsPerSecond,backPressuredTimeMsPerSecond,numRecordsIn")
  read -r busy back min max < <(echo "$metrics" | jq -r '
    (map({(.id): .}) | add) as $m
    | "\($m.busyTimeMsPerSecond.max // 0) \($m.backPressuredTimeMsPerSecond.max // 0) \($m.numRecordsIn.min // 0) \($m.numRecordsIn.max // 0)"')
  printf '%-46.46s %6.0f %6.0f %12.0f %12.0f\n' "$name" "$busy" "$back" "$min" "$max"
done
КОД
curl -s "$REST/jobs/$job_id" | jq -r '.vertices[] | "\(.id) \(.name)"' | while read -r id name; do
  metrics=$(curl -s "$REST/jobs/$job_id/vertices/$id/subtasks/metrics?get=busyTimeMsPerSecond,backPressuredTimeMsPerSecond,numRecordsIn")
  read -r busy back min max < <(echo "$metrics" | jq -r '
    (map({(.id): .}) | add) as $m
    | "\($m.busyTimeMsPerSecond.max // 0) \($m.backPressuredTimeMsPerSecond.max // 0) \($m.numRecordsIn.min // 0) \($m.numRecordsIn.max // 0)"')
  printf '%-46.46s %6.0f %6.0f %12.0f %12.0f\n' "$name" "$busy" "$back" "$min" "$max"
ПРОСТЫМИ СЛОВАМИ

Берём из описания задания пары «идентификатор вершины и имя». Цикл while read разбирает их по одной.

Ручка subtasks/metrics сразу агрегирует метрику по всем подзадачам вершины и отдаёт min, max, avg и sum. Мы просим три метрики.

Ответ это массив объектов с полем id. Выражение jq превращает его в словарь по имени метрики, чтобы обращаться к полям по имени.

Для busy и back берём максимум по подзадачам, как это делает граф в Web UI. Для numRecordsIn берём минимум и максимум, их отношение показывает перекос. Запись // 0 подставляет ноль, если метрики у вершины нет.

read -r с подстановкой процесса кладёт четыре числа в переменные, а printf печатает строку таблицы. Шаблон %-46.46s выравнивает имя по левому краю и обрезает его до 46 символов.

Сделай скрипт исполняемым командой chmod +x scripts/vertex-load.sh. Метрика numRecordsIn накопительная, она растёт с запуска подзадачи. Сравнивай минимум и максимум внутри одного вызова, между вызовами они разные.

Шаг 4. Базовый замер

Собери jar и подними стенд в таком порядке, как в уроке 1. Потом отправь задание без тормоза и запусти генератор во втором терминале.

терминал
./gradlew shadowJar
docker compose up -d --wait
scripts/submit.sh
./gradlew loadgen --args="--rate 200"

Подожди минуту и сними замер.

второй терминал
scripts/vertex-load.sh
scripts/checkpoint-report.sh

На стенде курса busy всех вершин был меньше 30, back около нуля, чекпоинты шли 223-367 мс. Эти числа и есть норма, с которой мы будем сравнивать.

Шаг 5. Включи тормоз

Отмени задание и отправь его заново с задержкой 20 мс. Состояние для опыта не нужно. Смещения Kafka группа payguard хранит сама, поэтому новое задание продолжит с места, где остановилось старое.

терминал
JOB=$(curl -s localhost:8081/jobs | jq -r '[.jobs[] | select(.status == "RUNNING")][0].id')
docker compose exec -T jobmanager flink cancel "$JOB"
scripts/submit.sh --slow-ms 20

Первая строка достаёт идентификатор запущенного задания из REST, так же как это делают наши скрипты. Аргумент --slow-ms 20 скрипт submit.sh передаёт заданию, его прочитает JobConfig.

Посчитай заранее. На подзадачу card-profile -> slow-down приходится около 100 записей в секунду, а по 20 мс на запись она успевает не больше 50. Через минуту снова запусти scripts/vertex-load.sh. Вот что получилось у нас.

ВершинаbusybackЧто это значит
card-profile -> slow-down10000 по формуле busyУзкое место, занята вся секунда
deduplicateне больше 1 по формуле999Жертва, ждёт свободный буфер для card-profile
Остальные вершины1-47не записалиПростаивают, общий предок отдаёт им меньше записей

Отчёт по чекпоинтам показал 25651 мс и 61137 мс вместо прежних 300 мс. Лаг Kafka в момент замера был 1340 записей и рос. Если у тебя запущены Prometheus и Grafana из урока 17, на панели лага видно то же самое.

Шаг 6. Спроси REST про backpressure

Вкладка Back Pressure в Web UI берёт данные из ручки /backpressure. Её удобно звать из скрипта.

терминал
JOB=$(curl -s localhost:8081/jobs | jq -r '[.jobs[] | select(.status == "RUNNING")][0].id')
VERTEX=$(curl -s localhost:8081/jobs/$JOB | jq -r '.vertices[] | select(.name | contains("deduplicate")) | .id')
curl -s localhost:8081/jobs/$JOB/vertices/$VERTEX/backpressure \
  | jq '{level: .backpressureLevel, subtasks: [.subtasks[] | {subtask, ratio, busyRatio}]}'

Вторая строка находит идентификатор вершины по части имени. Последняя оставляет из ответа уровень вершины и по каждой подзадаче долю backpressure и долю занятости. У нас ручка вернула уровень high и долю 0.999. Это те же 999 мс из таблицы выше, делённые на 1000. Если запросить ту же ручку для slow-down, уровень будет ok, потому что узкое место само никого не ждёт.

Шаг 7. Flame graph

Открой http://localhost:8081, зайди в задание и кликни по вершине card-profile -> slow-down. Справа появится вкладка Flame Graph с переключателем On-CPU, Off-CPU и Mixed. То же можно получить через REST.

терминал
VERTEX=$(curl -s localhost:8081/jobs/$JOB | jq -r '.vertices[] | select(.name | contains("slow-down")) | .id')
curl -s "localhost:8081/jobs/$JOB/vertices/$VERTEX/flamegraph?type=full" | jq '{endTimestamp, samples: .data.value}'

Параметр type принимает full, on_cpu и off_cpu. Первый запрос только запускает сбор снимков, и в ответе будет endTimestamp равный -3 без данных. Так VertexFlameGraph.waiting() помечает граф, который ещё строится. Повтори запрос через несколько секунд.

У нас полный граф собрал 200 снимков. Это совпадает с умолчанием в 100 снимков, сложенных по двум подзадачам. Самая толстая стопка шла по цепочке StreamTask.runMailboxLoop, processInput, emitNext. Это главный цикл подзадачи из урока 1, который читает запись и проталкивает её по цепочке операторов. Выше по стопке, в вызовах операторов цепочки, ищи SlowDown.map и Thread.sleep. В режиме on_cpu этот кусок исчезнет, потому что спящий поток находится в состоянии TIMED_WAITING.

В интерфейсе граф читается снизу вверх. Нижняя полоса это корень, над ней методы, которые он вызвал. Ширина полосы это доля снимков, а цвет ничего не значит. Наведи курсор на полосу, и интерфейс покажет полное имя метода с номером строки.

Шаг 8. Unaligned-чекпоинты под той же нагрузкой

Перезапусти задание с включёнными unaligned-чекпоинтами. Настройку можно передать прямо в flink run, не трогая Compose.

терминал
JOB=$(curl -s localhost:8081/jobs | jq -r '[.jobs[] | select(.status == "RUNNING")][0].id')
docker compose exec -T jobmanager flink cancel "$JOB"
docker compose exec -T jobmanager flink run --detached \
  -Dexecution.checkpointing.unaligned.enabled=true \
  /jobs/payguard-1.0.0.jar --slow-ms 20

Флаг -D задаёт настройку только этому заданию. Он должен стоять до пути к jar, иначе CLI примет его за аргумент программы. По этой же причине мы не используем здесь submit.sh.

Чекпоинты стали 129-293 мс, в каждом записано 705-1011 КиБ данных в полёте. Эти байты барьер обогнал и сохранил. В Web UI на вкладке Checkpoints колонка Processed (persisted) in-flight data показывает их по каждой подзадаче. Лаг при этом был 10760 и продолжал расти.

Шаг 9. Buffer debloating

Вернись к выровненным чекпоинтам и включи debloat через переменную из шага 2. Compose увидит изменившуюся настройку и пересоздаст контейнеры JobManager и TaskManager. Кластер без высокой доступности при этом теряет запущенное задание, поэтому его надо отправить снова.

терминал
BUFFER_DEBLOAT=true docker compose up -d --wait
scripts/submit.sh --slow-ms 20

Чекпоинты пошли за 1540-2368 мс, через барьер проходит 29-54 КиБ данных. Это примерно столько, сколько медленная подзадача разбирает за целевую секунду. Без debloat в той же очереди стояли буферы полного размера, по умолчанию 32 КиБ, и выравнивание занимало десятки секунд. Лаг дошёл до 16421 и тоже рос.

Закончи опыт. Отмени задание, верни Compose к обычным настройкам командой docker compose up -d --wait без переменной и отправь задание без тормоза через scripts/submit.sh.

i
Как это выглядит в проде

Порядок разбора всегда один. Алерт на backpressure или лаг, потом граф с busy и back от источника вниз, потом flame graph узкой вершины, потом решение по причине. Режим чекпоинтов выбирают заранее, чтобы пробка не превращалась ещё и в долгое восстановление.

Задание. Разгрузи горячего продавца

При параллелизме 4 окно merchant-stats-1m получает вход с разбросом в 2,6 раза между подзадачами, потому что продавец m-0000 забирает 30 % платежей. Перепиши минутную статистику по продавцам в две фазы с солью.

  1. Создай класс SaltedMerchantStats в пакете pipeline с двумя методами. Первая фаза считает минутный итог по ключу «продавец плюс соль» и называется merchant-stats-salted. Вторая фаза складывает частичные итоги по настоящему продавцу и называется merchant-stats-merged.
  2. Добавь флаг --stats-salts. Значение 0 или 1 оставляет старое окно, большее значение включает две фазы. В Postgres должны уходить итоги второй фазы, опоздавшие платежи по-прежнему уходят в payments.late.
  3. Напиши тест на MiniCluster с параллелизмом 4. Сто платежей одного продавца в одной минуте после двух фаз должны дать ровно одну строку с правильными количеством и суммой.
  4. Запусти задание на стенде с параллелизмом 4, сначала без соли, потом с --stats-salts 16, и сравни вывод scripts/vertex-load.sh.

Готово, если тест зелёный, разброс входа первой фазы заметно меньше 2,6 раза, вторая фаза получает сотни записей вместо тысяч, а итог по m-0000 за минуту остаётся около 30 % всех платежей.

Подсказки.

  • Соль должна быть детерминированной. Flink вычисляет ключ записи и при отправке, и при доступе к состоянию, поэтому случайное число здесь не годится. Подумай, какое поле платежа уже равномерно случайно.
  • Вторая фаза должна попасть в то же минутное окно, что и первая. Посмотри, какое время события получает запись, которую выпустило окно.
  • Подумай, что случится со второй фазой, если первая из-за допуска опозданий выпустит итог одного окна дважды.
  • Параллелизм 4 займёт все четыре слота стенда. Флаг -p нужен самому flink run, поэтому submit.sh здесь не подойдёт.

Ответ

Глоссарий урока

Обратное давление (backpressure)
Состояние, при котором подзадача не может отдать данные дальше, потому что у неё нет свободного выходного буфера. Распространяется вверх по графу до источника.
Узкое место (bottleneck)
Первая вершина по ходу потока, которая занята почти всю секунду и сама никого не ждёт. Ограничивает пропускную способность всего задания.
busyTimeMsPerSecond
Метрика подзадачи. Миллисекунды из секунды, когда подзадача не ждала ни входа, ни выхода. Вычисляется как остаток, поэтому включает сон и синхронные вызовы внутри функции.
backPressuredTimeMsPerSecond
Метрика подзадачи. Миллисекунды из секунды, когда подзадача ждала свободный буфер на выходе.
idleTimeMsPerSecond
Метрика подзадачи. Миллисекунды из секунды, когда подзадаче нечего было обрабатывать.
Вершина (job vertex)
Узел графа задания в Web UI. Это один оператор или цепочка операторов, сцепленных в одну задачу.
Flame graph
Картинка из многократно снятых стеков вызовов. Ширина полосы метода пропорциональна числу снимков, где он встретился.
On-CPU и Off-CPU
Типы flame graph во Flink. On-CPU берёт потоки в состоянии RUNNABLE, Off-CPU берёт ждущие и заблокированные, Mixed берёт все.
Buffer debloating
Механизм, который подстраивает размер сетевых буферов под измеренную пропускную способность, чтобы данные в полёте разбирались за целевое время. Включается ключом taskmanager.network.memory.buffer-debloat.enabled.
Данные в полёте (in-flight data)
Записи, которые лежат в сетевых буферах между подзадачами и ещё не обработаны получателем.
Unaligned-чекпоинт
Чекпоинт, при котором барьер обгоняет данные в буферах, а обогнанные данные сохраняются вместе со снимком. Подробно в уроке 11.
Таймаут выравнивания
Ключ execution.checkpointing.aligned-checkpoint-timeout. Чекпоинт начинается выровненным и переключается в unaligned, если выравнивание длится дольше.
Повторное использование объектов (object reuse)
Ключ pipeline.object-reuse. Убирает копирование записи между операторами цепочки. Опасен для кода, который хранит или меняет входные объекты.
Сериализация
Превращение объекта в байты и обратно. Во Flink нужна для сети, для состояния в RocksDB и для копий внутри цепочки.
Kryo
Универсальная библиотека сериализации, которую Flink берёт для типов, не распознанных его системой типов. Медленнее родных сериализаторов, в PayGuard запрещена с урока 3.
Перекос данных (data skew)
Неравномерная раскладка записей по подзадачам, обычно из-за ключа, который встречается намного чаще других.
Горячий ключ
Значение ключа с непропорционально большой долей потока. В PayGuard это продавец m-0000 с 30 % платежей.
Соль (salting)
Добавка к ключу, которая разбивает один горячий ключ на несколько. После частичной агрегации соль убирают.
Двухфазная агрегация (local-global)
Агрегация в два шага. Сначала частичные итоги по частям ключа, потом их сложение по настоящему ключу. Во Flink SQL встроена как LocalWindowAggregate и GlobalWindowAggregate.
Лаг Kafka (consumer lag)
Разница между последним смещением в партиции Kafka и смещением, до которого дочитал потребитель. Растёт, если задание не успевает.
RocksDB
Встраиваемое хранилище ключ-значение на диске. Один из бэкендов состояния Flink, каждое обращение к нему проходит через сериализацию.
Spark UI и maxOffsetsPerTrigger
Веб-интерфейс Spark со стадиями и задачами и опция источника Kafka в Structured Streaming, которая ограничивает число смещений на один micro-batch.

Урок 19 · Прод

Память TaskManager, RocksDB, ForSt и расчёт ресурсов под большое состояние

После урока ты разложишь taskmanager.memory.process.size на области так же, как это делает Flink, поймёшь, сколько памяти получает RocksDB в каждом слоте, и посчитаешь ресурсы под нагрузку, которой у тебя на стенде ещё нет.

Под убит, а куча заполнена наполовину

Типичная история первого месяца в Kubernetes. Задание с RocksDB работает неделю, потом под TaskManager начинает перезапускаться со статусом OOMKilled. Графики кучи JVM спокойные, заполнено процентов шестьдесят, исключений в логе нет. Процесс убивает ядро Linux, потому что контейнер вышел за свой лимит памяти.

Причина почти всегда в памяти за пределами кучи. RocksDB живёт в нативной памяти, туда же JVM кладёт стеки потоков, скомпилированный код и метаданные классов. Если ты не знаешь, сколько каждая из этих частей может занять, лимит контейнера становится лотереей. Flink решает задачу явной моделью памяти. Ты задаёшь один размер процесса, а Flink до старта JVM делит его на области с известными границами и передаёт JVM соответствующие флаги.

У Spark похожая забота устроена иначе. Там размер контейнера складывается из spark.executor.memory под кучу и отдельного spark.executor.memoryOverhead под всё остальное. Во Flink вне кучи лежит большая и важная часть, управляемая память под RocksDB, поэтому делить нужно точнее.

Гермомешок на сплав

Перед сплавом инструктор выдаёт каждому гермомешок на 60 литров и правила укладки. Сначала кладётся обязательное, аптечка и запасная обувь, у них фиксированный объём. Потом десятая часть мешка отводится на непредвиденное, но не меньше трёх литров и не больше десяти. Из оставшегося 40 % уходит под еду, 10 % под воду, а одежда занимает всё, что осталось. Если ты объявишь, что мешок 60 литров и вещей у тебя на 80, инструктор не пустит тебя в лодку ещё на берегу.

TaskManager собирается точно так же. Мешок это taskmanager.memory.process.size. Аптечка и обувь это Metaspace и память фреймворка. Запас на непредвиденное это JVM Overhead, доля с нижней и верхней границей. Еда это managed memory, вода это сетевая память, одежда это куча задач. Конфликт настроек Flink ловит на берегу, при старте процесса, и TaskManager просто не запускается.

Карта памяти TaskManager

Ниже раскладка нашего стенда, у TaskManager process.size 2048m и доля managed 0,4. Цифры взяты из REST после запуска, их же покажет скрипт в практике.

FW128 Task Heap537 FW off128 Network158 Managed634 Metaspace256 Overhead204 куча JVM, -Xmx 665 direct, 286 нативная, RocksDB вне Total Flink Total Flink Memory 1587 МиБ 460 МиБ Total Process Memory 2048 МиБ = размер контейнера
ОбластьЧто в ней живётКак задаётся
Framework Heap и Framework Off-HeapОбъекты самого TaskManager, не твоего кодаПо 128m, обычно не трогают
Task HeapОбъекты операторов, записи в полёте, состояние HashMapStateBackendОстаток после всех остальных областей
Task Off-HeapDirect-буферы и нативная память твоего кода и библиотек0 по умолчанию
NetworkСетевые буферы между подзадачами из урока 4Доля 0,1 от Total Flink, не меньше 64m
ManagedКэш и memtable RocksDB, сортировки пакетных операторовДоля 0,4 от Total Flink
JVM MetaspaceМетаданные загруженных классов256m
JVM OverheadСтеки потоков, кэш JIT, внутренние структуры JVMДоля 0,1 от процесса, от 192m до 1g

По этим областям Flink собирает флаги JVM. В -Xmx попадает сумма двух куч, в -XX:MaxDirectMemorySize сумма двух off-heap и сетевой памяти, в -XX:MaxMetaspaceSize размер Metaspace. Managed memory и Overhead ни одним флагом не ограничены. За managed отвечает сам Flink, а Overhead остаётся запасом внутри лимита контейнера.

Как Flink делит process.size

Порядок вычислений жёсткий, и от него зависят формулы. Обозначим размер процесса P.

  1. Overhead = 0,1 × P с обрезкой по границам 192m и 1g. Для P = 2048 МиБ получается 204,8 МиБ.
  2. Total Flink = P − Metaspace − Overhead = 2048 − 256 − 204,8 = 1587,2 МиБ.
  3. Managed = 0,4 × Total Flink = 634,9 МиБ. Network = 0,1 × Total Flink = 158,7 МиБ с обрезкой снизу по 64m.
  4. Task Heap = Total Flink − 128 − 128 − 0 − Managed − Network = 537,6 МиБ.

Куча задач получается последней и забирает остаток. Поэтому, подняв долю managed, ты отнимаешь память именно у кучи, и при маленьком процессе она может уйти в минус.

В исходниках

Точка входа выбирает одну из трёх веток по тому, какие размеры заданы явно. Обрати внимание на приоритет. Если заданы куча задач и managed, считается от них. Если задан Total Flink, от него. Размер процесса используется, только когда нет ни того, ни другого.

apache/flink@release-2.2.1 · ProcessMemoryUtils.java, строки 72-87
public CommonProcessMemorySpec<FM> memoryProcessSpecFromConfig(Configuration config) {
    if (options.getRequiredFineGrainedOptions().stream().allMatch(config::contains)) {
        // all internal memory options are configured, use these to derive total Flink and
        // process memory
        return deriveProcessSpecWithExplicitInternalMemory(config);
    } else if (config.contains(options.getTotalFlinkMemoryOption())) {
        // internal memory options are not configured, total Flink memory is configured,
        // derive from total flink memory
        return deriveProcessSpecWithTotalFlinkMemory(config);
    } else if (config.contains(options.getTotalProcessMemoryOption())) {
        // total Flink memory is not configured, total process memory is configured,
        // derive from total process memory
        return deriveProcessSpecWithTotalProcessMemory(config);
    }
    return failBecauseRequiredOptionsNotConfigured();
}

Ветка от размера процесса сначала вычисляет Metaspace и Overhead и только потом вычитает их, получая Total Flink.

apache/flink@release-2.2.1 · ProcessMemoryUtils.java, строки 109-121
private CommonProcessMemorySpec<FM> deriveProcessSpecWithTotalProcessMemory(
        Configuration config) {
    MemorySize totalProcessMemorySize =
            getMemorySizeFromConfig(config, options.getTotalProcessMemoryOption());
    JvmMetaspaceAndOverhead jvmMetaspaceAndOverhead =
            deriveJvmMetaspaceAndOverheadWithTotalProcessMemory(config, totalProcessMemorySize);
    MemorySize totalFlinkMemorySize =
            totalProcessMemorySize.subtract(
                    jvmMetaspaceAndOverhead.getTotalJvmMetaspaceAndOverheadSize());
    FM flinkInternalMemory =
            flinkMemoryUtils.deriveFromTotalFlinkMemory(config, totalFlinkMemorySize);
    return new CommonProcessMemorySpec<>(flinkInternalMemory, jvmMetaspaceAndOverhead);
}

Дальше внутри Total Flink. Managed и сетевая память выводятся из долей, а куча задач получает то, что осталось после вычитания всех остальных областей. Проверка перед вычитанием и есть тот случай, когда одежда не влезла в мешок.

apache/flink@release-2.2.1 · TaskExecutorFlinkMemoryUtils.java, строки 145-174
} else {
    // task heap memory is not configured
    // derive managed memory and network memory, leave the remaining to task heap memory
    managedMemorySize =
            deriveManagedMemoryAbsoluteOrWithFraction(config, totalFlinkMemorySize);

    networkMemorySize = deriveNetworkMemoryWithFraction(config, totalFlinkMemorySize);
    final MemorySize totalFlinkExcludeTaskHeapMemorySize =
            frameworkHeapMemorySize
                    .add(frameworkOffHeapMemorySize)
                    .add(taskOffHeapMemorySize)
                    .add(managedMemorySize)
                    .add(networkMemorySize);
    if (totalFlinkExcludeTaskHeapMemorySize.getBytes() > totalFlinkMemorySize.getBytes()) {
        throw new IllegalConfigurationException(
                "Sum of configured Framework Heap Memory ("
                        + frameworkHeapMemorySize.toHumanReadableString()
                        + "), Framework Off-Heap Memory ("
                        + frameworkOffHeapMemorySize.toHumanReadableString()
                        + "), Task Off-Heap Memory ("
                        + taskOffHeapMemorySize.toHumanReadableString()
                        + "), Managed Memory ("
                        + managedMemorySize.toHumanReadableString()
                        + ") and Network Memory ("
                        + networkMemorySize.toHumanReadableString()
                        + ") exceed configured Total Flink Memory ("
                        + totalFlinkMemorySize.toHumanReadableString()
                        + ").");
    }
    taskHeapMemorySize = totalFlinkMemorySize.subtract(totalFlinkExcludeTaskHeapMemorySize);

Доля умножается на базу, результат обрезается по min и max. Для сети и Overhead границы заданы опциями, для managed это ноль и бесконечность.

apache/flink@release-2.2.1 · ProcessMemoryUtils.java, строки 305-317
public static MemorySize deriveWithFraction(
        String memoryDescription, MemorySize base, RangeFraction rangeFraction) {
    MemorySize relative = base.multiply(rangeFraction.getFraction());
    return capToMinMax(memoryDescription, relative, rangeFraction);
}

public static MemorySize deriveWithInverseFraction(
        String memoryDescription, MemorySize base, RangeFraction rangeFraction) {
    checkArgument(rangeFraction.getFraction() < 1);
    MemorySize relative =
            base.multiply(rangeFraction.getFraction() / (1 - rangeFraction.getFraction()));
    return capToMinMax(memoryDescription, relative, rangeFraction);
}

Значения по умолчанию для JVM-областей лежат в TaskManagerOptions. Metaspace 256m, Overhead от 192m до 1g.

apache/flink@release-2.2.1 · TaskManagerOptions.java, строки 535-560
public static final ConfigOption<MemorySize> JVM_METASPACE =
        key("taskmanager.memory.jvm-metaspace.size")
                .memoryType()
                .defaultValue(MemorySize.parse("256m"))
                .withDescription("JVM Metaspace Size for the TaskExecutors.");

/** Min JVM Overhead size for the TaskExecutors. */
@Documentation.Section(Documentation.Sections.COMMON_MEMORY)
public static final ConfigOption<MemorySize> JVM_OVERHEAD_MIN =
        key("taskmanager.memory.jvm-overhead.min")
                .memoryType()
                .defaultValue(MemorySize.parse("192m"))
                .withDescription(
                        "Min JVM Overhead size for the TaskExecutors. This is off-heap memory reserved for JVM"
                                + " overhead, such as thread stack space, compile cache, etc. This includes native memory but not direct"
                                + " memory, and will not be counted when Flink calculates JVM max direct memory size parameter. The size"
                                + " of JVM Overhead is derived to make up the configured fraction of the Total Process Memory. If the"
                                + " derived size is less/greater than the configured min/max size, the min/max size will be used. The"
                                + " exact size of JVM Overhead can be explicitly specified by setting the min/max size to the same value.");

/** Max JVM Overhead size for the TaskExecutors. */
@Documentation.Section(Documentation.Sections.COMMON_MEMORY)
public static final ConfigOption<MemorySize> JVM_OVERHEAD_MAX =
        key("taskmanager.memory.jvm-overhead.max")
                .memoryType()
                .defaultValue(MemorySize.parse("1g"))

Калькулятор ниже повторяет эти вычисления в том же порядке, с теми же значениями по умолчанию и с тем же округлением. Поля без значения считаются незаданными, как отсутствующий ключ в конфиге. Кнопки подставляют три настоящих случая из курса. Сначала стенд, потом конфликт, на который мы нарвались в этом уроке, и старый конфиг из урока 1.

Калькулятор памяти TaskManager

ПолеЗначение

С кнопкой «Стенд курса» калькулятор выдаёт ровно те числа, что показал TaskManager на стенде, включая ёмкость блочного кэша RocksDB до байта. Попробуй сдвинуть managed.fraction к 0,8 при 2048 МиБ и посмотри, как тает Task Heap. Потом поставь 768 МиБ и долю 0,4, и TaskManager не стартует.

Когда настройки спорят

Опасно задавать сразу несколько размеров. Каждый новый явный размер убирает у Flink свободу, и в какой-то момент области перестают сходиться. Тогда ошибка всплывает на старте TaskManager, задание даже не начнёт разворачиваться.

В исходниках

Если заданы и размер процесса, и Total Flink, Overhead перестаёт быть долей и становится разностью. Когда разность отрицательная, Flink бросает исключение с текстом, который мы увидим в практике.

apache/flink@release-2.2.1 · ProcessMemoryUtils.java, строки 189-206
private MemorySize deriveJvmOverheadFromTotalFlinkMemoryAndOtherComponents(
        Configuration config, MemorySize totalFlinkMemorySize) {
    MemorySize totalProcessMemorySize =
            getMemorySizeFromConfig(config, options.getTotalProcessMemoryOption());
    MemorySize jvmMetaspaceSize =
            getMemorySizeFromConfig(config, options.getJvmOptions().getJvmMetaspaceOption());
    MemorySize totalFlinkAndJvmMetaspaceSize = totalFlinkMemorySize.add(jvmMetaspaceSize);
    if (totalProcessMemorySize.getBytes() < totalFlinkAndJvmMetaspaceSize.getBytes()) {
        throw new IllegalConfigurationException(
                "The configured Total Process Memory size (%s) is less than the sum of the derived "
                        + "Total Flink Memory size (%s) and the configured or default JVM Metaspace size  (%s).",
                totalProcessMemorySize.toHumanReadableString(),
                totalFlinkMemorySize.toHumanReadableString(),
                jvmMetaspaceSize.toHumanReadableString());
    }
    MemorySize jvmOverheadSize = totalProcessMemorySize.subtract(totalFlinkAndJvmMetaspaceSize);
    sanityCheckJvmOverhead(config, jvmOverheadSize, totalProcessMemorySize);
    return jvmOverheadSize;

Все такие ошибки оборачиваются в одно сообщение с общим префиксом. По нему конфликт памяти легко найти в логе.

apache/flink@release-2.2.1 · TaskExecutorProcessUtils.java, строки 148-156
public static TaskExecutorProcessSpec processSpecFromConfig(final Configuration config) {
    try {
        return createMemoryProcessSpec(
                config, PROCESS_MEMORY_UTILS.memoryProcessSpecFromConfig(config));
    } catch (IllegalConfigurationException e) {
        throw new IllegalConfigurationException(
                "TaskManager memory configuration failed: " + e.getMessage(), e);
    }
}
💡
Задавай один размер

Для контейнеров документация советует задавать только taskmanager.memory.process.size, равный лимиту контейнера. Остальное меняй долями. Доля подстраивается под размер процесса, а явный размер в мегабайтах однажды перестанет сходиться с новым лимитом пода.

Куда идёт RocksDB

Из урока 7 ты знаешь, что RocksDB хранит состояние как пары байтов на локальном диске. Устроен он как LSM-дерево. Запись сначала попадает в memtable, отсортированную таблицу в памяти. Заполненная memtable сбрасывается на диск неизменяемым SST-файлом. Фоновые компакции сливают SST-файлы, выбрасывают перезаписанные версии и удалённые ключи, и на этом же шаге работает фильтр TTL из урока 7. Чтение ищет ключ в memtable, потом в блочном кэше, потом в файлах на диске, а фильтры Блума позволяют пропускать файлы, где ключа точно нет.

Памяти RocksDB нужно на три вещи, на memtable, на блочный кэш и на индексы с фильтрами. По умолчанию Flink ограничивает все три размером managed memory слота. Внутри слота все экземпляры RocksDB делят один кэш и один менеджер буферов записи (write buffer manager), и память memtable засчитывается в тот же кэш.

В исходниках

Managed memory TaskManager делится между слотами поровну. Профиль слота по умолчанию получает свою часть делением на число слотов.

apache/flink@release-2.2.1 · TaskExecutorResourceUtils.java, строки 115-130
@VisibleForTesting
public static ResourceProfile generateDefaultSlotResourceProfile(
        TaskExecutorResourceSpec taskExecutorResourceSpec, int numberOfSlots) {
    final ResourceProfile.Builder resourceProfileBuilder =
            ResourceProfile.newBuilder()
                    .setCpuCores(taskExecutorResourceSpec.getCpuCores().divide(numberOfSlots))
                    .setTaskHeapMemory(
                            taskExecutorResourceSpec.getTaskHeapSize().divide(numberOfSlots))
                    .setTaskOffHeapMemory(
                            taskExecutorResourceSpec.getTaskOffHeapSize().divide(numberOfSlots))
                    .setManagedMemory(
                            taskExecutorResourceSpec
                                    .getManagedMemorySize()
                                    .divide(numberOfSlots))
                    .setNetworkMemory(
                            taskExecutorResourceSpec.getNetworkMemSize().divide(numberOfSlots));

Память слота RocksDB по умолчанию берёт из managed, флаг включён, пока его не выключили.

apache/flink@release-2.2.1 · RocksDBMemoryConfiguration.java, строки 122-126
public boolean isUsingManagedMemory() {
    return useManagedMemory != null
            ? useManagedMemory
            : RocksDBOptions.USE_MANAGED_MEMORY.defaultValue();
}

Из бюджета слота создаётся общий кэш, а менеджер буферов записи получает этот кэш аргументом. Поэтому memtable и блоки данных конкурируют за одну и ту же память.

apache/flink@release-2.2.1 · RocksDBMemoryControllerUtils.java, строки 54-63
long calculatedCacheCapacity =
        RocksDBMemoryControllerUtils.calculateActualCacheCapacity(
                totalMemorySize, writeBufferRatio);
final Cache cache = factory.createCache(calculatedCacheCapacity, highPriorityPoolRatio);

long writeBufferManagerCapacity =
        RocksDBMemoryControllerUtils.calculateWriteBufferManagerCapacity(
                totalMemorySize, writeBufferRatio);
final WriteBufferManager wbm =
        factory.createWriteBufferManager(writeBufferManagerCapacity, cache);

Ёмкость кэша и менеджера считается по двум формулам. Комментарий над ними объясняет, откуда тройка, менеджер буферов может превысить свою ёмкость в полтора раза.

apache/flink@release-2.2.1 · RocksDBMemoryControllerUtils.java, строки 96-113
public static long calculateActualCacheCapacity(long totalMemorySize, double writeBufferRatio) {
    return (long) ((3 - writeBufferRatio) * totalMemorySize / 3);
}

/**
 * Calculate the actual memory capacity of write buffer manager, which would be shared among
 * rocksDB instance(s). The formula to use here could refer to the doc of {@link
 * #calculateActualCacheCapacity(long, double)}.
 *
 * @param totalMemorySize Total off-heap memory size reserved for RocksDB instance(s).
 * @param writeBufferRatio The ratio of total memory size which would be reserved for write
 *     buffer manager and its over-capacity part.
 * @return The actual calculated write buffer manager capacity.
 */
@VisibleForTesting
static long calculateWriteBufferManagerCapacity(long totalMemorySize, double writeBufferRatio) {
    return (long) (2 * totalMemorySize * writeBufferRatio / 3);
}

Проверим на стенде. Managed 665719939 байт на 4 слота дают 166429984 байта на слот. При write-buffer-ratio 0,5 кэш равен 2,5 × 166429984 / 3 = 138691653 байта. Ровно это значение показала метрика block_cache_capacity. Совпадение до байта значит, что весь managed слота достался RocksDB. Так и должно быть, других потребителей managed memory в слоте нашего задания нет.

Отсюда несколько практических следствий.

  • Удвоил число слотов на том же TaskManager, и кэш каждого слота уменьшился вдвое. Состояние осталось прежним, промахов кэша станет больше.
  • Каждое состояние в RocksDB это отдельное семейство колонок (column family) со своими memtable. Задание с десятком состояний делит тот же бюджет на большее число буферов записи.
  • Частые сбросы memtable лечатся ростом state.backend.rocksdb.memory.write-buffer-ratio, промахи на чтении ростом managed. Документация советует сначала просто дать больше managed, доля 0,4 консервативна для процессов в несколько гигабайт.
  • С HashMapStateBackend managed memory стримингу не нужна. Документация рекомендует поставить её в ноль, тогда куча задач получит эту память.
⚠
Выключенный memory.managed и OOMKilled

С state.backend.rocksdb.memory.managed: false каждый RocksDB выделяет память по своим настройкам на каждое семейство колонок. Документация оценивает верх примерно в 140 МБ на каждое состояние каждой задачи в каждом слоте, и таймеры тоже считаются. Flink эту память не учитывает, и под уходит за лимит. Если под всё же растёт при включённом контроле, документация предлагает переменную MALLOC_ARENA_MAX=1 или больший JVM Overhead.

ForSt и State V2

RocksDB держит всё состояние на локальном диске TaskManager. Размер состояния упирается в диск, а при восстановлении файлы надо скачать из хранилища чекпоинтов, и время растёт вместе с размером. Во Flink 2.0 появилось разделённое хранение состояния (disaggregated state). Его основа это бэкенд ForSt. Он устроен почти так же, как RocksDB, и настраивается похожими ключами, но кладёт SST-файлы во внешнее хранилище вроде S3 и использует локальный диск как кэш. Документация помечает эту часть как экспериментальную.

Удалённое хранилище отвечает за миллисекунды, а memtable и кэш RocksDB за микросекунды. Если оператор будет ждать каждого чтения, пропускная способность рухнет. Поэтому вместе с ForSt пришёл State V2, асинхронный API состояния. Метод вроде asyncValue() возвращает StateFuture, а продолжение обработки ты пишешь цепочкой thenAccept или thenCompose. Пока одно чтение в пути, задача берёт следующие записи. Записи одного ключа Flink всё равно обрабатывает строго по порядку прихода, а твой код в продолжениях выполняется в потоке задачи, поэтому гонок в нём нет.

В DataStream асинхронный режим включается вызовом enableAsyncState() на KeyedStream и требует переписать функции на новые интерфейсы состояния. В SQL переписывать ничего не нужно, хватает настройки table.exec.async-state.enabled. Асинхронные версии есть у агрегации без DISTINCT, соединений, Top-1 и Top-N на добавлениях, дедупликации по времени события и оконных агрегатов, остальные операторы молча вернутся к синхронному доступу.

В исходниках

Описание класса прямо говорит, ради чего он существует, и аннотация @Experimental подтверждает статус. Наследование от AbstractManagedMemoryStateBackend значит, что ForSt тоже берёт память из managed слота.

apache/flink@release-2.2.1 · ForStStateBackend.java, строки 89-99
/**
 * A {@link org.apache.flink.runtime.state.StateBackend} that stores its state in a ForSt instance.
 * This state backend can store very large state that exceeds memory even disk and spills to remote
 * storage.
 *
 * <p>The behavior of the ForSt instances can be parametrized by setting ForSt Options using the
 * methods {@link #setForStOptions(ForStOptionsFactory)}.
 */
@Experimental
public class ForStStateBackend extends AbstractManagedMemoryStateBackend
        implements ConfigurableStateBackend {

Именно этот бэкенд умеет асинхронный доступ, у него метод возвращает true.

apache/flink@release-2.2.1 · ForStStateBackend.java, строки 399-402
@Override
public boolean supportsAsyncKeyedStateBackend() {
    return true;
}

Основной каталог SST-файлов по умолчанию совпадает с каталогом чекпоинтов. Отсюда лёгкие чекпоинты, файлы уже лежат там, куда их пришлось бы копировать.

apache/flink@release-2.2.1 · ForStOptions.java, строки 55-67
/** The remote directory where ForSt puts its SST files. */
@Documentation.Section(Documentation.Sections.STATE_BACKEND_FORST)
public static final ConfigOption<String> PRIMARY_DIRECTORY =
        ConfigOptions.key("state.backend.forst.primary-dir")
                .stringType()
                .defaultValue(CHECKPOINT_DIR_AS_PRIMARY_SHORTCUT)
                .withDescription(
                        String.format(
                                "The primary directory where ForSt puts its SST files. By default, it will be the same as the checkpoint directory. "
                                        + "Recognized shortcut name is '%s', which means that ForSt shares the directory with checkpoint, "
                                        + "and '%s', which means that ForSt will use the local directory of TaskManager.",
                                CHECKPOINT_DIR_AS_PRIMARY_SHORTCUT,
                                LOCAL_DIR_AS_PRIMARY_SHORTCUT));

Синхронные операторы ForSt по умолчанию держит локально, разделение включается только для асинхронного API. Документация прямо пишет, что для небольшого состояния локальный бэкенд с синхронным доступом остаётся лучшим выбором. PayGuard в этом уроке остаётся на RocksDB, а ForSt мы попробуем на SQL-задании.

Capacity planning

Расчёт ресурсов под большое состояние начинается с вопроса, из чего состоит состояние и сколько каждая часть живёт. Удобно разделить его на две группы.

  • Состояние на ключ без срока. Профиль карты CardProfile живёт, пока существует карта, его объём равен числу карт, умноженному на размер записи.
  • Состояние на событие со сроком. Ключ дедупликации живёт час по TTL, ожидающий подтверждения платёж до 5 минут, буфер CEP до 10 минут. Его объём равен скорости потока, умноженной на срок и на размер записи.

Получается формула S = Nключей × sключа + R × Σ(Ti × si). Размеры записей лучше брать из замеров на стенде, по росту размера чекпоинта из урока 11 или по метрикам RocksDB. Оценка «на глаз» по полям класса почти всегда занижена, потому что в RocksDB к записи добавляются группа ключей, сериализованный ключ, индексы и версии до компакции.

Дальше из размера состояния выводятся три вещи. Диск под рабочий каталог RocksDB с запасом на компакции. Хранилище чекпоинтов с запасом на несколько сохранённых снимков. Память под кэш, которой не обязательно вмещать всё состояние, достаточно горячей части, к которой задание обращается постоянно. Параллелизм документация советует выбирать так, чтобы в обычной работе не было постоянного backpressure, и оставлять запас на догонку после восстановления. Максимальный параллелизм выбери сразу с запасом, это ты помнишь по урокам 4 и 13.

Сравнение со Spark

В Spark Structured Streaming состояние по умолчанию живёт в памяти экзекутора, а RocksDB подключается отдельным провайдером хранилища состояния. Его память настраивается своими ключами и не входит в единую модель с кучей. Во Flink RocksDB по умолчанию ограничен managed memory слота, и один размер процесса задаёт границы всех областей разом.

Проверь себя

Под TaskManager в Kubernetes регулярно получает OOMKilled. Куча заполнена на 50 %, в конфиге стоит state.backend.rocksdb.memory.managed false, в задании 12 состояний. Что сделать первым?

Задание на HashMapStateBackend падает с OutOfMemoryError Java heap space. Размер процесса 4g, остальные настройки по умолчанию. Какое изменение даст куче больше всего памяти, не трогая размер пода?

На TaskManager с RocksDB подняли число слотов с 4 до 8, ничего больше не меняя, и параллелизм задания вырос до 8. Задержка чтения состояния выросла. Почему?

Что будет в конце

Стенд показывает, на какие области Flink разбил память TaskManager, и ты сверишь их с калькулятором. RocksDB отдаёт в Prometheus метрики своего кэша и размера состояния. Рядом на SQL-кластере работает задание с ForSt и асинхронным доступом к состоянию. Java-код в этом уроке не меняется, правки только в Compose, скриптах и SQL. Задание урока решается расчётом, файлов для него создавать не нужно.

Шаг 1. Настройки памяти и метрики RocksDB

В docker-compose.yml к общему блоку Flink добавляются девять строк.

что изменилось в docker-compose.yml
@@ -29,6 +29,15 @@       metrics.reporter.prom.port: 9249
       metrics.latency.interval: 0
       rest.flamegraph.enabled: true
+      taskmanager.memory.managed.fraction: 0.4+      state.backend.rocksdb.memory.managed: true+      state.backend.rocksdb.metrics.block-cache-usage: true+      state.backend.rocksdb.metrics.block-cache-capacity: true+      state.backend.rocksdb.metrics.estimate-num-keys: true+      state.backend.rocksdb.metrics.estimate-live-data-size: true+      state.backend.rocksdb.metrics.num-running-compactions: true+      state.backend.rocksdb.metrics.background-errors: true+      state.backend.latency-track.keyed-state-enabled: true       taskmanager.network.memory.buffer-debloat.enabled: ${BUFFER_DEBLOAT:-false}
     PAYGUARD_DB_URL: jdbc:postgresql://postgres:5432/payguard
     PAYGUARD_DB_USER: payguard
КОД
      taskmanager.memory.managed.fraction: 0.4
      state.backend.rocksdb.memory.managed: true
      state.backend.rocksdb.metrics.block-cache-usage: true
      state.backend.rocksdb.metrics.block-cache-capacity: true
      state.backend.rocksdb.metrics.estimate-num-keys: true
      state.backend.rocksdb.metrics.estimate-live-data-size: true
      state.backend.rocksdb.metrics.num-running-compactions: true
      state.backend.rocksdb.metrics.background-errors: true
      state.backend.latency-track.keyed-state-enabled: true
ПРОСТЫМИ СЛОВАМИ

Доля managed memory записана явно, хотя 0,4 и так значение по умолчанию. Так она видна в конфиге, и её удобно менять для экспериментов.

RocksDB живёт в бюджете managed memory слота. Это тоже значение по умолчанию, строка нужна как напоминание, что выключать его в контейнере опасно.

Две метрики кэша, ёмкость и занятый объём. В занятый входят и memtable, потому что менеджер буферов записи учитывает их в том же кэше.

Оценка числа ключей и объёма живых данных в SST-файлах каждого состояния.

Число идущих компакций и счётчик фоновых ошибок. Вторая метрика выше нуля значит, что RocksDB не может писать, чаще всего кончился диск.

Замер задержек обращений к состоянию, например чтения и записи ValueState. Ключ записан старым именем, о нём ниже.

Все нативные метрики RocksDB выключены по умолчанию, и включать их стоит выборочно. Каждая метрика это опрос свойства RocksDB по каждому состоянию каждой подзадачи.

В исходниках

Ключ замера задержек в Flink 2.2 называется state.latency-track.keyed-state-enabled. Имя с state.backend осталось как устаревшее и пока принимается.

apache/flink@release-2.2.1 · StateLatencyTrackOptions.java, строки 30-37
@Documentation.Section(Documentation.Sections.STATE_LATENCY_TRACKING)
public static final ConfigOption<Boolean> LATENCY_TRACK_ENABLED =
        ConfigOptions.key("state.latency-track.keyed-state-enabled")
                .booleanType()
                .defaultValue(false)
                .withDeprecatedKeys("state.backend.latency-track.keyed-state-enabled")
                .withDescription(
                        "Whether to track latency of keyed state operations, e.g value state put/get/clear.");

Работает и старый ключ, но в новых конфигах пиши новое имя. Устаревшие ключи однажды убирают, и тогда настройка молча перестанет действовать.

Шаг 2. Скрипт разбора памяти

Создай scripts/tm-memory.sh и сделай его исполняемым командой chmod +x scripts/tm-memory.sh.

scripts/tm-memory.sh
#!/usr/bin/env bash
# Показывает в мебибайтах, на какие области Flink разбил память TaskManager.
set -euo pipefail
REST="${FLINK_REST:-http://localhost:8081}"

tm_id=$(curl -s "$REST/taskmanagers" | jq -r '.taskmanagers[0].id')
curl -s "$REST/taskmanagers/$tm_id" | jq '.memoryConfiguration | map_values(. / 1048576 | floor)'
КОД
REST="${FLINK_REST:-http://localhost:8081}"

tm_id=$(curl -s "$REST/taskmanagers" | jq -r '.taskmanagers[0].id')
curl -s "$REST/taskmanagers/$tm_id" | jq '.memoryConfiguration | map_values(. / 1048576 | floor)'
ПРОСТЫМИ СЛОВАМИ

Адрес REST можно переопределить переменной FLINK_REST, по умолчанию основной кластер на 8081.

Пустая строка отделяет настройку от работы.

Список TaskManager, из него берётся идентификатор первого.

Карточка TaskManager содержит объект memoryConfiguration с размерами областей в байтах. map_values делит каждое значение на 1048576 и отбрасывает дробную часть, получаются мебибайты.

Шаг 3. Перезапусти стенд и сверь память

Изменился FLINK_PROPERTIES, поэтому Compose пересоздаст JobManager и TaskManager. Запущенное задание при этом пропадёт. Состояние для урока не важно, задание можно отправить заново. Если хочешь его сохранить, сначала останови его со savepoint, как в уроке 13.

терминал
./gradlew shadowJar
docker compose up -d --wait
scripts/submit.sh
./gradlew loadgen --args="--rate 200"

Во втором терминале запусти скрипт.

терминал
scripts/tm-memory.sh

Скрипт печатает JSON с десятью полями. На стенде курса получились такие значения в мебибайтах.

ПолеМиБОткуда
frameworkHeap128Значение по умолчанию
taskHeap537Остаток Total Flink
frameworkOffHeap128Значение по умолчанию
taskOffHeap0Значение по умолчанию
networkMemory1580,1 × 1587,2
managedMemory6340,4 × 1587,2
jvmMetaspace256Значение по умолчанию
jvmOverhead2040,1 × 2048
totalFlinkMemory15872048 − 256 − 204,8
totalProcessMemory2048Из конфига

Сумма шести областей Total Flink даёт 1585, на два меньше totalFlinkMemory. Это не ошибка. Скрипт отбрасывает дробную часть у каждого поля отдельно, а у пяти полей она есть. Нажми в калькуляторе «Стенд курса», числа совпадут построчно. Куча JVM здесь 128 + 537 = 665 МиБ, это около 697 миллионов байт. Панель Grafana в уроке 17 показывала максимум кучи TaskManager 698 МБ, та же величина с точностью до округления и учёта сборщика мусора.

Шаг 4. Метрики RocksDB

Через минуту работы спроси у Prometheus метрики состояния profile, это профиль карты из CardProfileFunction. Имя состояния входит в имя метрики, поэтому регулярное выражение отбирает все метрики RocksDB этого состояния.

терминал
curl -s localhost:9090/api/v1/query \
  --data-urlencode 'query={__name__=~"flink_taskmanager_job_task_operator_profile_rocksdb_.*"}' \
  | jq -r '.data.result[] | "\(.metric.__name__) \(.value[1])"'

Флаг --data-urlencode кодирует фигурные скобки и кавычки запроса для URL. Prometheus вернёт по строке на каждую метрику каждой подзадачи. Через 60 секунд при 200 платежах в секунду значения были такими.

МетрикаЗначениеКак читать
rocksdb_estimate_num_keys8563Оценка RocksDB. Генератор выдаёт 5000 карт, поэтому для точного счёта ключей она не годится, только для порядка величины
rocksdb_estimate_live_data_size41162 байтаДанные в SST-файлах. Почти всё записанное за минуту ещё лежит в memtable, поэтому число маленькое
rocksdb_block_cache_capacity138691653 байта, 132 МиБРовно формула из теории и строка калькулятора
rocksdb_block_cache_usage32362256 байт, 31 МиБЗанято блоками и memtable всех состояний слота

Сочетание последних двух строк объясняет, как читать кэш. Ёмкость фиксирована, а заполнение растёт, пока не упрётся в неё. Если usage долго стоит у capacity, а задержки чтения из замера состояния растут, кэша не хватает. На стенде с 5000 картами до этого далеко. Метрики num_running_compactions и background_errors полезны в алертах урока 17. Первая показывает, не захлёбывается ли RocksDB компакциями, вторая обязана быть нулём.

Шаг 5. Когда память не сходится

Проверим на себе, как выглядит конфликт. Временно поменяй в общем блоке FLINK_PROPERTIES значение taskmanager.memory.process.size на 1g и добавь строку taskmanager.memory.flink.size: 2g. JobManager эти ключи не читает, упадёт только TaskManager. Пересоздай контейнеры и открой лог.

терминал
docker compose up -d
docker compose logs taskmanager | grep -A1 IllegalConfigurationException
лог TaskManager
IllegalConfigurationException: TaskManager memory configuration failed: The configured Total Process Memory size (1024.000mb ...) is less than the sum of the derived Total Flink Memory size (2.000gb ...)

Это ветка из цитаты deriveJvmOverheadFromTotalFlinkMemoryAndOtherComponents. Явный Total Flink важнее размера процесса, и Overhead считается разностью, которая здесь отрицательна. Кнопка «process 1g и flink 2g» в калькуляторе выдаёт ту же ошибку. Из урока 1 ты знаешь второй случай. Старый конфиг с явными кучей задач, managed и Total Flink оставлял сети 0 байт, и TaskManager падал с Derived Network Memory size (0 bytes) is not in configured Network Memory range. Его воспроизводит третья кнопка. Лечение в обоих случаях одно, убрать лишние явные размеры и оставить process.size. Верни строки как были, выполни docker compose up -d --wait и отправь задание заново.

Шаг 6. ForSt на SQL-кластере

Асинхронный доступ к состоянию проще всего попробовать в SQL, где не нужно переписывать функции. Создай sql/merchant_revenue_forst.sql. Это тот же подсчёт выручки продавцов из урока 14, только без окна, чтобы агрегат держал состояние постоянно.

sql/merchant_revenue_forst.sql
-- Тот же расчёт выручки, но состояние лежит в ForSt и читается асинхронно.
SET 'pipeline.name' = 'payguard-sql-forst';
SET 'state.backend.type' = 'forst';
SET 'table.exec.async-state.enabled' = 'true';

CREATE TABLE payments (
    paymentId   STRING,
    cardId      STRING,
    merchantId  STRING,
    amountMinor BIGINT,
    currency    STRING,
    country     STRING,
    eventTimeMs BIGINT,
    event_time  AS TO_TIMESTAMP_LTZ(eventTimeMs, 3),
    WATERMARK FOR event_time AS event_time - INTERVAL '10' SECOND
) WITH (
    'connector' = 'kafka',
    'topic' = 'payments.raw',
    'properties.bootstrap.servers' = 'kafka:9092',
    'properties.group.id' = 'payguard-sql-forst',
    'scan.startup.mode' = 'latest-offset',
    'format' = 'json',
    'json.ignore-parse-errors' = 'true'
);

CREATE TABLE merchant_revenue_forst (
    merchant_id   STRING,
    payment_count BIGINT,
    amount_minor  BIGINT,
    PRIMARY KEY (merchant_id) NOT ENFORCED
) WITH (
    'connector' = 'jdbc',
    'url' = 'jdbc:postgresql://postgres:5432/payguard',
    'table-name' = 'merchant_revenue_forst',
    'username' = 'payguard',
    'password' = 'payguard'
);

INSERT INTO merchant_revenue_forst
SELECT merchantId, COUNT(*), SUM(amountMinor) FROM payments GROUP BY merchantId;

Две строки SET в начале работают только для этого задания. state.backend.type заменяет RocksDB из конфига SQL-кластера на ForSt, table.exec.async-state.enabled выбирает асинхронные версии операторов. Мини-пакеты и двухфазную агрегацию асинхронный режим пока не поддерживает, у нас они и так выключены. Таблица для результата добавляется в sql/init.sql.

что изменилось в sql/init.sql
@@ -38,3 +38,9 @@     amount_minor BIGINT NOT NULL,
     PRIMARY KEY (risk_level, window_start, top_rank)
 );
++CREATE TABLE merchant_revenue_forst (+    merchant_id   TEXT PRIMARY KEY,+    payment_count BIGINT NOT NULL,+    amount_minor  BIGINT NOT NULL+);

Postgres выполняет init.sql только при первом запуске на пустой базе. Если контейнер живёт с прошлых уроков, новой таблицы в нём нет. Проще всего пересоздать стенд командой docker compose --profile sql down -v и поднять заново. Флаг -v удаляет анонимные тома контейнеров, каталог data с чекпоинтами остаётся. После этого повтори команды шага 3.

терминал
docker compose --profile sql up -d --wait
docker compose --profile sql exec -T sql-jobmanager sql-client.sh -f /sql/merchant_revenue_forst.sql

Флаг -T отключает псевдотерминал, он не нужен, когда SQL Client читает файл. Через пару минут проверь, какой бэкенд достался заданию и как идут его чекпоинты.

терминал
job=$(curl -s localhost:8082/jobs/overview \
  | jq -r '.jobs[] | select(.name == "payguard-sql-forst" and .state == "RUNNING") | .jid')
curl -s localhost:8082/jobs/$job/checkpoints/config | jq -r .state_backend
FLINK_REST=http://localhost:8082 scripts/checkpoint-report.sh
docker compose exec postgres psql -U payguard -d payguard \
  -c "select count(*), sum(payment_count) from merchant_revenue_forst"

Задание ищется по имени из pipeline.name, потому что на SQL-кластере могут работать задания прошлых уроков. Скрипт checkpoint-report.sh берёт первое запущенное задание, если там их несколько, смотри историю чекпоинтов в Web UI на 8082. На стенде state_backend вернул ForStStateBackend. Чекпоинты занимали 32-41 мс при размере 118-125 КиБ, в графе была вершина GroupAggregate[4], а в таблице оказалось 50 продавцов и 16200 платежей.

Маленький размер чекпоинта здесь не заслуга ForSt, у 50 продавцов просто мало состояния. Основной каталог ForSt по умолчанию совпадает с каталогом чекпоинтов, у нас это локальный том /flink-data/sql-checkpoints. В проде там был бы S3, и выгода от лёгкого восстановления появилась бы на гигабайтах состояния. Останови задание, когда насмотришься, через Web UI или flink cancel.

Задание. Посчитай ресурсы под рост

Бизнес обещает через год 5000 платежей в секунду и 5 миллионов активных карт. Сейчас на стенде 200 платежей в секунду и 5000 карт. Оцени, сколько состояния будет у PayGuard в текущем виде, и предложи конфигурацию TaskManager под эту нагрузку. Кода писать не нужно, результат это расчёт.

  1. Перечисли состояния задания по операторам и раздели их на состояние на карту без срока и состояние на платёж со сроком. Сроки найди в коде, это константы вроде REMEMBER_FOR, MAX_DELAY и окна CEP.
  2. Возьми размер записи из замеров стенда. Подойдёт рост размера чекпоинтов в уроке 11, там при 200 платежах в секунду состояние выросло с 1882 до 5899 КиБ между первым и вторым чекпоинтом с интервалом 15 секунд.
  3. Посчитай размер состояния по формуле из теории. Дай реалистичную оценку и верхнюю границу.
  4. Выбери параллелизм, число TaskManager, слотов, process.size и долю managed. Проверь раскладку калькулятором и выпиши, сколько блочного кэша получит каждый слот.
  5. Оцени локальный диск на TaskManager и объём хранилища чекпоинтов.

Готово, если у каждого числа есть явное допущение и ты можешь сказать, какой замер на нагрузочном стенде его подтвердит или опровергнет.

Подсказки. В росте чекпоинта из урока 11 ничего ещё не успело истечь, поэтому он показывает полный след платежа во всех операторах. Пропускную способность одной подзадачи можно прикинуть по уроку 18, там при 200 платежах в секунду и параллелизме 2 занятость всех вершин была меньше 30 мс в секунду. И не забудь про число партиций Kafka, оно ограничивает параллелизм источника.

Ответ

Глоссарий урока

Total Process Memory
Вся память процесса TaskManager, ключ taskmanager.memory.process.size. В контейнере равна его лимиту.
Total Flink Memory
Память процесса без JVM Metaspace и JVM Overhead. Из неё выделяются кучи, off-heap, сетевая и managed memory.
Managed memory
Нативная память вне кучи, которую распределяет сам Flink. В потоковых заданиях её получает RocksDB или ForSt, по умолчанию 0,4 от Total Flink Memory.
Task Heap
Куча JVM для операторов и пользовательского кода. Получает остаток Total Flink Memory после всех остальных областей.
Память фреймворка (framework memory)
Куча и off-heap память под внутренние нужды TaskManager, по 128 МиБ. В слоты не раздаётся.
Сетевая память (network memory)
Direct-память под сетевые буферы между подзадачами. Доля 0,1 от Total Flink Memory, не меньше 64 МиБ.
JVM Overhead
Запас нативной памяти под стеки потоков, кэш JIT и внутренние структуры JVM. Доля 0,1 от процесса в границах 192 МиБ и 1 ГиБ.
JVM Metaspace
Область JVM под метаданные загруженных классов, по умолчанию 256 МиБ, ограничена флагом -XX:MaxMetaspaceSize.
Off-heap и direct-память
Память вне кучи JVM. Direct-буферы Java ограничены флагом -XX:MaxDirectMemorySize, прочая нативная память ничем внутри JVM не ограничена.
OOMKilled
Статус контейнера Kubernetes, который убило ядро Linux за превышение лимита памяти. Исключения в JVM при этом не возникает.
Kubernetes
Система запуска контейнеров на кластере машин. Каждому контейнеру задаётся лимит памяти. Подробно в уроке 20.
RocksDB
Встраиваемое хранилище ключей и значений на диске, устроенное как LSM-дерево. Во Flink служит бэкендом состояния для больших объёмов.
LSM-дерево (log-structured merge tree)
Устройство хранилища, где записи копятся в памяти и сбрасываются на диск неизменяемыми файлами, а фоновые слияния поддерживают порядок.
Memtable
Отсортированная таблица RocksDB в памяти, куда попадают свежие записи. Заполненная memtable сбрасывается на диск SST-файлом.
SST-файл (Sorted String Table)
Неизменяемый файл RocksDB с отсортированными парами ключ и значение.
Компакция (compaction)
Фоновое слияние SST-файлов RocksDB. Убирает перезаписанные версии и удалённые ключи, на этом шаге работает очистка по TTL.
Блочный кэш (block cache)
Кэш RocksDB в памяти для блоков данных, индексов и фильтров из SST-файлов. Во Flink общий для всех состояний слота.
Менеджер буферов записи (write buffer manager)
Компонент RocksDB, ограничивающий общую память всех memtable. Во Flink учитывает её в блочном кэше слота.
Фильтр Блума (Bloom filter)
Компактная структура, которая быстро отвечает, что ключа в файле точно нет. Позволяет RocksDB не читать лишние SST-файлы.
Семейство колонок (column family)
Отдельное пространство ключей внутри RocksDB со своими memtable. Во Flink каждое состояние оператора это своё семейство колонок.
ForSt
Бэкенд состояния Flink 2.x, устроенный почти так же, как RocksDB. Умеет держать SST-файлы во внешнем хранилище и работает с асинхронным доступом. Экспериментальный.
Разделённое хранение состояния (disaggregated state)
Режим Flink 2.x, в котором основная копия состояния лежит во внешнем хранилище вроде S3, а локальный диск служит кэшем.
State V2
Новый API состояния Flink 2.x с асинхронными методами, которые возвращают StateFuture. Включается вызовом enableAsyncState().
StateFuture
Результат асинхронного обращения к состоянию. Продолжение обработки задаётся методами thenAccept, thenApply, thenCompose.
Capacity planning
Расчёт ресурсов задания заранее, по размеру состояния, скорости потока и запасу на восстановление.
Prometheus
Система сбора метрик. Периодически опрашивает процессы Flink и хранит временные ряды, подробно в уроке 17.
Память экзекутора Spark
spark.executor.memory задаёт кучу экзекутора, spark.executor.memoryOverhead память вне кучи. Вместе они дают размер контейнера.

Урок 20 · Прод

Flink в Kubernetes через Flink Kubernetes Operator

После урока ты развернёшь PayGuard в minikube ресурсом FlinkDeployment, выберешь режим апгрейда под своё задание и проверишь, что состояние переживает гибель JobManager, рестарт Kafka и смену параллелизма.

Релиз в пятницу вечером

В уроке 13 ты менял параллелизм руками. Сначала flink stop со снимком, потом копируешь путь savepoint из вывода, потом запускаешь задание с флагами -s и -p. Каждый шаг можно перепутать, и всё это время кто-то сидит у терминала.

В Kubernetes к этому добавляются две беды. Поды переезжают без спроса. Узел уходит на обслуживание, контейнер превышает лимит памяти, и Kubernetes пересоздаёт его в другом месте, не вызывая твоих скриптов. Кроме того, любая ручная процедура со временем расходится с тем, что реально запущено, и через месяц никто не помнит, с какими флагами стартовало задание.

Flink Kubernetes Operator решает обе задачи. Ты описываешь желаемое задание в YAML, оператор сам останавливает старую версию со снимком, поднимает новую и следит, чтобы кластер совпадал с описанием. В этом уроке PayGuard переезжает в minikube, и мы проверим три вещи. Задание должно пережить гибель JobManager, рестарт Kafka и смену параллелизма без потери состояния.

Список на холодильнике

На дверце холодильника висит лист «должно быть всегда». Два литра молока, десяток яиц, хлеб. Каждый вечер кто-то из семьи открывает холодильник, сверяет полки со списком и докупает разницу. Ему не нужно помнить, что он покупал вчера. Достаточно сравнить лист и полки.

Если на листе исправить «десяток яиц» на «полтора десятка», вечером докупят пять, и чеки за прошлую неделю никто перечитывать не будет. Рядом висит магнитная доска, на ней проверяющий пишет, что нашёл на полках и чем кончился поход в магазин. Остальные члены семьи смотрят на доску и не открывают холодильник.

Лист это spec ресурса FlinkDeployment. Проверяющий это оператор, полки это кластер Flink, доска это status. Вечерний обход называется reconcile. Он запускается при каждом изменении ресурса и ещё раз в минуту на всякий случай. Самое интересное начинается, когда на полке стоит кастрюля супа. Холодильник меньшего размера просто так не поменяешь, суп надо куда-то переложить. Как перекладывать состояние задания при замене кластера, решает режим апгрейда.

Почему в проде Application

В уроке 2 мы сравнили режимы Session и Application. В Session кластер живёт сам по себе, задания делят его TaskManager, а jar загружает клиент. В Application кластер создаётся под одно задание, main выполняется в JobManager, и jar уже лежит в образе.

В Kubernetes разница становится решающей. Образ в режиме Application содержит и версию Flink, и твой код. Откатить релиз значит вернуть предыдущий тег образа. Упавший TaskManager задевает только своё задание. Апгрейд превращается в пересоздание небольшого кластера целиком, и именно так оператор его выполняет. Для этого достаточно образа из двух строк, его мы соберём в практике.

Session у оператора тоже есть. Ресурс FlinkDeployment без блока job поднимает пустой кластер, а задания в него отправляются ресурсами FlinkSessionJob. Так удобно держать много мелких заданий или песочницу для SQL. Для PayGuard с его состоянием и exactly-once изоляция важнее экономии ресурсов.

У FlinkDeployment есть ещё поле mode со значениями native и standalone. В native поды TaskManager просит у Kubernetes сам ResourceManager JobManager. В standalone их создаёт оператор отдельным Deployment. Мы поле не задаём, значит работаем в native.

FlinkDeployment и цикл reconcile

Kubernetes умеет хранить собственные типы объектов. Тип описывается ресурсом CRD (custom resource definition), и после этого kubectl работает с ним так же, как с подами. Оператор при установке регистрирует несколько таких типов, нам важны FlinkDeployment и FlinkStateSnapshot. Сам оператор это обычное приложение в поде, которое подписано на изменения этих ресурсов.

Каждый проход reconcile состоит из трёх фаз. Observe опрашивает JobManager через REST и API Kubernetes и записывает в status, что реально работает. Validate проверяет spec теми же правилами, что и webhook при kubectl apply. Reconcile сравнивает новую spec с той, которую оператор применил последней, она хранится в status.reconciliationStatus.lastReconciledSpec. От разницы зависит действие. Совпадают спецификации, ничего не происходит. Изменилось поле, влияющее на задание, запускается цикл апгрейда.

В исходниках

Как оператор решает, что смена параллелизма требует апгрейда. У поля parallelism в JobSpec стоит аннотация с типом изменения SCALE, но только для режима STANDALONE.

apache/flink-kubernetes-operator@release-1.16.1 · JobSpec.java, строки 47-60
private String jarURI;

/** Parallelism of the Flink job. */
@SpecDiff(value = DiffType.SCALE, mode = KubernetesDeploymentMode.STANDALONE)
private int parallelism;

/** Fully qualified main class name of the Flink job. */
private String entryClass;

/** Arguments for the Flink job main class. */
private String[] args = new String[0];

/** Desired state for the job. */
private JobState state = JobState.RUNNING;

Построитель разницы проверяет, относится ли аннотация к текущему режиму. Если нет, тип изменения становится UPGRADE.

apache/flink-kubernetes-operator@release-1.16.1 · ReflectiveDiffBuilder.java, строки 113-124
} else if (field.isAnnotationPresent(SpecDiff.class)) {
    var annotation = field.getAnnotation(SpecDiff.class);
    var modes = annotation.mode();
    boolean modeApplies =
            modes.length == 0 || Arrays.asList(modes).contains(deploymentMode);
    if (rightField != null || !annotation.onNullIgnore()) {
        diffBuilder.append(
                field.getName(),
                leftField,
                rightField,
                modeApplies ? annotation.value() : UPGRADE);
    }

Режим по умолчанию native, поле mode у нас пустое.

apache/flink-kubernetes-operator@release-1.16.1 · KubernetesDeploymentMode.java, строки 40-46
public static KubernetesDeploymentMode getDeploymentMode(FlinkDeployment flinkDeployment) {
    return getDeploymentMode(flinkDeployment.getSpec());
}

public static KubernetesDeploymentMode getDeploymentMode(FlinkDeploymentSpec spec) {
    return spec.getMode() == null ? KubernetesDeploymentMode.NATIVE : spec.getMode();
}

Значит, в нашем стенде patch параллелизма всегда означает остановку задания и запуск нового кластера. Как именно останавливать, решает режим апгрейда, к нему мы сейчас придём.

lifecycleState, ответ оператора

Чтобы понять, где сейчас ресурс, не нужно читать весь status. Оператор сводит его в одно поле status.lifecycleState, и kubectl get flinkdep показывает его отдельной колонкой. В нашем стенде встретятся четыре значения.

ЗначениеЧто оно значит
CREATEDРесурс создан, оператор его ещё ни разу не применял
UPGRADINGИдёт апгрейд, старое задание останавливается или новое разворачивается
DEPLOYEDНовая spec развёрнута, но ещё не признана стабильной и может быть откачена
STABLEЗадание работает, spec признана стабильной

Ещё бывают SUSPENDED, FAILED, ROLLING_BACK и ROLLED_BACK. Откат включается настройкой kubernetes.operator.deployment.rollback.enabled, по умолчанию он выключен, и в уроке мы его не трогаем.

В исходниках

Поле вычисляется при каждом чтении статуса, сеттера у него нет. Сначала смотрится состояние цикла апгрейда.

apache/flink-kubernetes-operator@release-1.16.1 · CommonStatus.java, строки 77-90
var reconciliationStatus = getReconciliationStatus();

if (reconciliationStatus.isBeforeFirstDeployment()) {
    return StringUtils.isEmpty(error)
            ? ResourceLifecycleState.CREATED
            : ResourceLifecycleState.FAILED;
}

switch (reconciliationStatus.getState()) {
    case UPGRADING:
        return ResourceLifecycleState.UPGRADING;
    case ROLLING_BACK:
        return ResourceLifecycleState.ROLLING_BACK;
}

В конце метода выбор между STABLE и DEPLOYED. Стабильность значит, что последняя применённая spec совпадает с последней стабильной.

apache/flink-kubernetes-operator@release-1.16.1 · CommonStatus.java, строки 119-125
if (reconciliationStatus.getState() == ReconciliationState.ROLLED_BACK) {
    return ResourceLifecycleState.ROLLED_BACK;
} else if (reconciliationStatus.isLastReconciledSpecStable()) {
    return ResourceLifecycleState.STABLE;
}

return ResourceLifecycleState.DEPLOYED;

Когда spec становится стабильной, решает checkAndUpdateStableSpec. Комментарий в коде говорит прямо. Работающее задание сейчас всегда помечается стабильным.

apache/flink-kubernetes-operator@release-1.16.1 · ReconciliationUtils.java, строки 505-516
public static void checkAndUpdateStableSpec(CommonStatus<?> status) {
    var flinkJobStatus = status.getJobStatus().getState();

    if (status.getReconciliationStatus().getState() != ReconciliationState.DEPLOYED) {
        return;
    }

    if (flinkJobStatus == RUNNING) {
        // Running jobs are currently always marked stable
        status.getReconciliationStatus().markReconciledSpecAsStable();
        return;
    }

Отсюда практический вывод. STABLE означает, что задание дошло до RUNNING. О том, что чекпоинты идут и лаг не растёт, это поле ничего не говорит, за этим следят метрики из урока 17.

Три режима апгрейда

Поле spec.job.upgradeMode отвечает на вопрос про суп из холодильника. Оно задаёт, как остановить старое задание и откуда новое возьмёт состояние.

statelesslast-statesavepoint
ОстановкаОтмена задания, кластер удаляется вместе с HA-метаданнымиКластер удаляется, HA-метаданные остаютсяstop-with-savepoint
Откуда состояниеПустоеПоследний чекпоинт из HA-метаданныхSavepoint, снятый при остановке
jobIdНовыйПрежнийНовый
Что требуетсяНичегоЧекпоинты и HAКаталог savepoint и задание в RUNNING
Ресурс FlinkStateSnapshotНетНетДа

Режим savepoint самый аккуратный. Снимок делается в момент остановки, задание не перечитывает ни одной записи, и снимок остаётся точкой для отката. Его слабость в том, что savepoint снимается только с работающего задания. Режим last-state быстрее и работает даже с падающим заданием, но после рестарта Flink повторит записи, пришедшие после последнего чекпоинта. Для exactly-once приёмников из урока 12 это безопасно, остальные увидят повторы. Режим stateless годится только для заданий без состояния.

Попробуй сам. Выбери режим и состояние задания, затем примени patch с параллелизмом 3. Шаги повторяют ветки кода оператора при настройках по умолчанию.

Что оператор делает после patch parallelism

    Сравни savepoint с заданием в RUNNING и в RESTARTING. Во втором случае оператор не ждёт, пока задание поправится, он молча переходит на last-state. Это поведение по умолчанию, и о нём полезно знать до того, как оно случится в проде.

    В исходниках

    Выбор способа апгрейда живёт в AbstractJobReconciler.getJobUpgrade. Режим stateless готов всегда.

    apache/flink-kubernetes-operator@release-1.16.1 · AbstractJobReconciler.java, строки 218-223
    var upgradeMode = resource.getSpec().getJob().getUpgradeMode();
    
    if (upgradeMode == UpgradeMode.STATELESS) {
        LOG.info("Stateless job, ready for upgrade");
        return JobUpgrade.stateless(ReconciliationUtils.isJobInTerminalState(status));
    }
    

    Режим savepoint требует работающего задания. Иначе, если сменилась версия Flink или включён флаг last-state-fallback.enabled, режим подменяется на last-state.

    apache/flink-kubernetes-operator@release-1.16.1 · AbstractJobReconciler.java, строки 247-264
    if (upgradeMode == UpgradeMode.SAVEPOINT) {
        if (running) {
            LOG.info("Job is in running state, ready for upgrade with savepoint");
            return JobUpgrade.savepoint(false);
        } else if (versionChanged
                || deployConfig.get(
                        KubernetesOperatorConfigOptions
                                .OPERATOR_JOB_UPGRADE_LAST_STATE_FALLBACK_ENABLED)) {
            LOG.info("Falling back to last-state upgrade mode from savepoint");
            ctx.getResource()
                    .getSpec()
                    .getJob()
                    .setUpgradeMode(upgradeMode = UpgradeMode.LAST_STATE);
        } else {
            LOG.info("Last-state fallback is disabled, waiting for upgradable state");
            return JobUpgrade.pendingUpgrade();
        }
    }
    

    Флаг по умолчанию включён.

    apache/flink-kubernetes-operator@release-1.16.1 · KubernetesOperatorConfigOptions.java, строки 560-566
    @Documentation.Section(SECTION_DYNAMIC)
    public static final ConfigOption<Boolean> OPERATOR_JOB_UPGRADE_LAST_STATE_FALLBACK_ENABLED =
            operatorConfig("job.upgrade.last-state-fallback.enabled")
                    .booleanType()
                    .defaultValue(true)
                    .withDescription(
                            "Enables last-state fallback for savepoint upgrade mode. When the job is not running thus savepoint cannot be triggered but HA metadata is available for last state restore the operator can initiate the upgrade process when the flag is enabled.");
    

    Если базовая логика не нашла способа, наследник для кластеров Application проверяет HA-метаданные. Когда они есть, апгрейд идёт через last-state даже для задания, которое не в RUNNING.

    apache/flink-kubernetes-operator@release-1.16.1 · ApplicationReconciler.java, строки 109-123
    var availableUpgradeMode = super.getJobUpgrade(ctx, deployConfig);
    
    if (availableUpgradeMode.isAvailable()) {
        return availableUpgradeMode;
    }
    var flinkService = ctx.getFlinkService();
    
    if (availableUpgradeMode.isAllowFallback()
            && HighAvailabilityMode.isHighAvailabilityModeActivated(deployConfig)
            && HighAvailabilityMode.isHighAvailabilityModeActivated(ctx.getObserveConfig())
            && flinkService.isHaMetadataAvailable(deployConfig)) {
        LOG.info(
                "Job is not running but HA metadata is available for last state restore, ready for upgrade");
        return JobUpgrade.lastStateUsingHaMeta();
    }
    

    В исходниках

    Что значит «остановить» в каждом режиме. Перечисление SuspendMode отвечает на два вопроса. Удалять ли кластер, и удалять ли HA-метаданные. Сохраняет их только LAST_STATE.

    apache/flink-kubernetes-operator@release-1.16.1 · SuspendMode.java, строки 24-37
    public enum SuspendMode {
        NOOP,
        STATELESS,
        SAVEPOINT,
        CANCEL,
        LAST_STATE;
    
        public boolean deleteCluster() {
            return this == STATELESS || this == LAST_STATE;
        }
    
        public boolean deleteHaMeta() {
            return this != LAST_STATE;
        }
    

    Сама остановка. Для SAVEPOINT вызывается savepointJobOrError, внутри него stopWithSavepoint клиента REST. Для STATELESS задание просто отменяется. Ветки для LAST_STATE в switch нет, задание не трогают, кластер удаляют вместе с ним, и HA-метаданные остаются на месте.

    apache/flink-kubernetes-operator@release-1.16.1 · AbstractFlinkService.java, строки 333-360
    var status = deployment.getStatus();
    String savepointPath = null;
    try (var clusterClient = getClusterClient(conf)) {
        switch (suspendMode) {
            case SAVEPOINT:
                savepointPath = savepointJobOrError(clusterClient, status, conf);
                break;
            case STATELESS:
                if (status.isJobCancellable()) {
                    try {
                        cancelJobOrError(clusterClient, status, true);
                    } catch (Exception ex) {
                        // We can simply delete the deployment for stateless
                    }
                }
                break;
            case CANCEL:
                if (!cancelJobOrError(clusterClient, status, false)) {
                    // This is async we need to return
                    return CancelResult.pending();
                }
                break;
        }
    }
    if (suspendMode.deleteCluster() || deleteCluster) {
        deleteClusterDeployment(
                deployment.getMetadata(), status, conf, suspendMode.deleteHaMeta());
    }
    

    Поэтому last-state не тратит время на снимок. Новый JobManager находит последний чекпоинт по тем же ConfigMap, которые читал бы после обычного падения.

    ⚠
    По умолчанию состояние выбрасывается

    Если не указать upgradeMode, оператор возьмёт stateless. Это видно в JobSpec, строка private UpgradeMode upgradeMode = UpgradeMode.STATELESS. Любая правка spec, даже лимита CPU, тогда запустит задание с пустым состоянием. Дедупликация забудет идентификаторы, профили карт обнулятся. В манифесте PayGuard режим задан явно.

    FlinkStateSnapshot, снимок как ресурс

    В режиме savepoint оператор оформляет снятый при апгрейде снимок отдельным ресурсом FlinkStateSnapshot. У ресурса есть своё поле состояния с ходом TRIGGER_PENDING, IN_PROGRESS, COMPLETED или FAILED и путь к файлам. Так история снимков видна командой kubectl get flinkstatesnapshots, без похода в REST JobManager.

    Снимок можно заказать и вручную. Для этого создают свой ресурс FlinkStateSnapshot со ссылкой jobReference на FlinkDeployment и блоком savepoint. У ручных снимков поле disposeOnDelete по умолчанию включено, и удаление ресурса сотрёт файлы savepoint. Снимки апгрейда по умолчанию при удалении ресурса не стираются.

    В исходниках

    После остановки со снимком оператор создаёт ресурс и записывает путь в статус задания. Путь из upgradeSavepointPath потом передаётся новому кластеру.

    apache/flink-kubernetes-operator@release-1.16.1 · AbstractJobReconciler.java, строки 445-454
    var snapshotCrOpt =
            FlinkStateSnapshotUtils.createUpgradeSnapshotResource(
                    conf,
                    ctx.getOperatorConfig(),
                    ctx.getKubernetesClient(),
                    ctx.getResource(),
                    savepointFormatType,
                    savepointLocation);
    var jobStatus = ctx.getResource().getStatus().getJobStatus();
    jobStatus.setUpgradeSavepointPath(savepointLocation);
    

    Имя ресурса собирается из имени FlinkDeployment, типа снимка, причины и времени в миллисекундах. На стенде получилось payguard-savepoint-upgrade-1794126582605.

    apache/flink-kubernetes-operator@release-1.16.1 · FlinkStateSnapshotUtils.java, строки 188-198
    public static String getFlinkStateSnapshotName(
            SnapshotType snapshotType,
            SnapshotTriggerType triggerType,
            AbstractFlinkResource<?, ?> referencedResource) {
        return String.format(
                "%s-%s-%s-%d",
                referencedResource.getMetadata().getName(),
                snapshotType.name().toLowerCase(),
                triggerType.name().toLowerCase(),
                System.currentTimeMillis());
    }
    

    HA на Kubernetes

    Режим last-state и восстановление после гибели JobManager держатся на сервисах высокой доступности, HA. Без них новый JobManager не знает, какие чекпоинты делал предыдущий, и продолжить с последнего из них не может. С настройкой high-availability.type: kubernetes Flink хранит эти сведения в ConfigMap, обычных объектах Kubernetes для небольших конфигурационных данных.

    Устроено это в два слоя. В ConfigMap лежат только указатели, а сами метаданные, то есть граф задания и описания завершённых чекпоинтов, Flink пишет файлами в high-availability.storageDir. ConfigMap ограничен мегабайтом, а метаданные бывают больше. Тот же ConfigMap кластера служит замком для выбора лидера. Если JobManager несколько, лидером становится тот, кто успел записать в него свой идентификатор.

    У нашего задания после запуска оказалось пять ConfigMap. Два из них относятся к HA, это payguard-cluster-config-map и payguard-<jobId>-config-map. В flink-config-payguard лежит конфигурация Flink для подов, в pod-template-payguard шаблон пода TaskManager, в autoscaler-payguard оператор хранит данные автоскейлера.

    В исходниках

    Выбор лидера в KubernetesLeaderElectionHaServices настраивается на ConfigMap кластера.

    apache/flink@release-2.2.1 · KubernetesLeaderElectionHaServices.java, строки 116-131
    private static LeaderElectionDriverFactory createDriverFactory(
            FlinkKubeClient kubeClient,
            KubernetesConfigMapSharedWatcher configMapSharedWatcher,
            Executor watchExecutorService,
            String clusterId,
            String lockIdentity,
            Configuration configuration) {
        final KubernetesLeaderElectionConfiguration leaderElectionConfiguration =
                new KubernetesLeaderElectionConfiguration(
                        getClusterConfigMap(clusterId), lockIdentity, configuration);
        return new KubernetesLeaderElectionDriverFactory(
                kubeClient,
                leaderElectionConfiguration,
                configMapSharedWatcher,
                watchExecutorService);
    }
    

    Хранилище чекпоинтов берёт отдельный ConfigMap на каждое задание, его имя собрано из идентификатора кластера и jobId. Имя ConfigMap кластера получается тем же способом.

    apache/flink@release-2.2.1 · KubernetesLeaderElectionHaServices.java, строки 143-165
    @Override
    protected CheckpointRecoveryFactory createCheckpointRecoveryFactory() {
        return KubernetesCheckpointRecoveryFactory.withoutLeadershipValidation(
                kubeClient, configuration, ioExecutor, clusterId, this::getJobSpecificConfigMap);
    }
    
    private String getJobSpecificConfigMap(JobID jobID) {
        return clusterId + NAME_SEPARATOR + jobID.toString() + NAME_SEPARATOR + "config-map";
    }
    
    @Override
    protected ExecutionPlanStore createExecutionPlanStore() throws Exception {
        return KubernetesUtils.createExecutionPlanStore(
                configuration, kubeClient, getClusterConfigMap(), lockIdentity);
    }
    
    private String getClusterConfigMap() {
        return getClusterConfigMap(clusterId);
    }
    
    private static String getClusterConfigMap(String clusterId) {
        return clusterId + NAME_SEPARATOR + "cluster-config-map";
    }
    

    Почему в ConfigMap только указатель, объясняет комментарий к KubernetesStateHandleStore.

    apache/flink@release-2.2.1 · KubernetesStateHandleStore.java, строки 62-69
    /**
     * Class which stores state via the provided {@link RetrievableStateStorageHelper} and writes the
     * returned state handle to ConfigMap.
     *
     * <p>Added state is persisted via {@link RetrievableStateHandle RetrievableStateHandles}, which in
     * turn are written to ConfigMap. This level of indirection is necessary to keep the amount of data
     * in ConfigMap small. ConfigMap is build for data less than 1MB whereas state can grow to multiple
     * MBs and GBs.
    
    apache/flink@release-2.2.1 · HighAvailabilityOptions.java, строки 74-82
    public static final ConfigOption<String> HA_STORAGE_PATH =
            key("high-availability.storageDir")
                    .stringType()
                    .noDefaultValue()
                    .withDeprecatedKeys(
                            "high-availability.zookeeper.storageDir",
                            "recovery.zookeeper.storageDir")
                    .withDescription(
                            "File system path (URI) where Flink persists metadata in high-availability setups.");
    

    Из этого следует требование к тому. Каталог storageDir должен пережить под JobManager, иначе указатели в ConfigMap будут вести в пустоту.

    Остаётся вопрос, почему после рестарта пода JobManager задание продолжает работу с тем же jobId. Ведь хранилище чекпоинтов ищет ConfigMap по jobId. Ответ в операторе. Перед развёртыванием он сам выбирает jobId и записывает его во внутренний ключ конфигурации $internal.pipeline.job-id. Новый под JobManager стартует с той же конфигурацией, запускает задание с тем же jobId и находит его ConfigMap.

    В исходниках

    Метод setJobIdIfNecessary в ApplicationReconciler выдаёт новый jobId при каждом развёртывании, кроме last-state. Это объясняет строку jobId в таблице режимов.

    apache/flink-kubernetes-operator@release-1.16.1 · ApplicationReconciler.java, строки 237-248
    // Rotate job id when not last-state deployment
    if (status.getJobStatus().getJobId() == null || !lastStateDeploy) {
        String jobId = JobID.generate().toHexString();
        // record before first deployment to ensure we use it on any retry
        status.getJobStatus().setJobId(jobId);
        LOG.info("Assigning JobId override to {}", jobId);
        statusRecorder.patchAndCacheStatus(resource, client);
    }
    
    String jobId = status.getJobStatus().getJobId();
    LOG.debug("Setting {} to {}", PipelineOptionsInternal.PIPELINE_FIXED_JOB_ID, jobId);
    deployConfig.set(PipelineOptionsInternal.PIPELINE_FIXED_JOB_ID, jobId);
    

    Рестарт пода Kubernetes делает сам, без развёртывания через оператор, поэтому jobId не меняется. Апгрейд в режиме savepoint идёт через развёртывание, и jobId новый. Новый идентификатор нужен, чтобы чекпоинты новой версии не попали в каталог старой. Ссылки на задачи FLINK-19358 и FLINK-29109 в комментарии ведут к истории этой проблемы.

    Сравнение со Spark

    Spark на Kubernetes запускается через spark-submit или spark-operator с ресурсом SparkApplication. Драйвер живёт в отдельном поде и сам создаёт поды экзекуторов. Состояние стримингового запроса Spark хранит в каталоге checkpointLocation, и новая версия кода продолжает работу, если запустить её с тем же каталогом.

    Во Flink роль драйвера выполняет JobManager. Оператор Flink при апгрейде сам снимает savepoint, пересоздаёт кластер, подставляет путь к снимку и ведёт историю снимков ресурсами. После падения JobManager задание восстанавливается из HA-метаданных без участия оператора.

    Состояние за пределами Flink

    Savepoint хранит состояние Flink, но exactly-once из урока 12 держится ещё и на журнале транзакций Kafka. В снимке лежат подготовленные транзакции приёмника, и после восстановления Flink их фиксирует. Брокер должен помнить, что такие транзакции были.

    В первой версии стенда у Kafka не было тома. Во время апгрейда minikube упёрся в память, под Kafka перезапустился и поднялся с пустым диском. Брокер потерял топики и внутренний топик __transaction_state. Задание восстановилось из savepoint и не смогло зафиксировать транзакцию, о которой брокер уже не знал. Дальше пошли бесконечные рестарты. Полный разбор этого инцидента ждёт тебя в уроке 21, здесь из него один вывод. У каждой системы, на которую опирается exactly-once, данные должны лежать на постоянном томе.

    i
    Под без тома теряет всё при каждом рестарте

    Файловая система контейнера живёт столько же, сколько контейнер. Всё, что должно пережить рестарт, кладут на PersistentVolumeClaim. Для Flink это чекпоинты, savepoint и HA, для Kafka её журналы. В проде Kafka и PostgreSQL обычно управляемые сервисы, и об их дисках заботится провайдер.

    Проверь себя

    Коллега завёл FlinkDeployment для задания с дедупликацией и забыл поле upgradeMode. Через неделю он поднял лимит CPU у TaskManager. Что стало с состоянием?

    Задание в режиме savepoint с включённым HA уходит в RESTARTING из-за ошибки в коде. Ты выкатываешь образ с исправлением. Что сделает оператор при настройках по умолчанию?

    После апгрейда задание восстановилось из savepoint и сразу ушло в бесконечные рестарты с InvalidPidMappingException при коммите в Kafka. Незадолго до этого перезапускался под Kafka без тома. Где причина?

    Что будет в конце

    В minikube работают оператор, Kafka на постоянном томе, PostgreSQL, генератор платежей и PayGuard в режиме Application с HA. Задание переживает удаление пода JobManager и рестарт Kafka. Новых классов Java в этом уроке нет, добавляются образ, манифесты Kubernetes и скрипт стенда. Ответ на задание тоже состоит из команд, новых файлов для него создавать не нужно.

    Шаг 1. Что поставить

    Понадобятся minikube, kubectl и Helm. Minikube запускает однонодовый кластер Kubernetes внутри контейнера Docker. kubectl это клиент API Kubernetes. Helm ставит приложения в Kubernetes готовыми пакетами, они называются чартами. Стенд из Compose можно остановить, памяти он заберёт заметно.

    Шаг 2. Образ задания

    В режиме Application jar должен лежать в образе. Создай docker/job.Dockerfile.

    docker/job.Dockerfile
    # Образ задания: внутри официальный Flink и наш jar. Для Application Mode кластер и код выкатываются одним образом.
    FROM flink:2.2.1-java17
    COPY build/libs/payguard-1.0.0.jar /opt/flink/usrlib/payguard.jar
    

    Базовый образ тот же, что в Compose, поэтому версия Flink совпадает с проверенной. Каталог /opt/flink/usrlib Flink добавляет в classpath задания, об этом шла речь в уроке 2. Путь к jar мы позже укажем в FlinkDeployment.

    Шаг 3. Скрипт стенда

    Создай scripts/k8s-up.sh. Он поднимает кластер, ставит оператор, создаёт инфраструктуру и загружает образ.

    scripts/k8s-up.sh
    #!/usr/bin/env bash
    # Поднимает учебный стенд в minikube: кластер Kubernetes, оператор Flink, инфраструктуру и образ задания.
    set -euo pipefail
    OPERATOR_VERSION=1.16.1
    
    # Размер памяти применяется только при создании кластера. Старый профиль сначала удаляют: minikube delete.
    minikube start --cpus=6 --memory=9g --driver=docker
    helm repo add flink-operator "https://downloads.apache.org/flink/flink-kubernetes-operator-$OPERATOR_VERSION/"
    helm upgrade --install flink-kubernetes-operator flink-operator/flink-kubernetes-operator \
      --version "$OPERATOR_VERSION" --set webhook.create=false --wait --timeout 15m
    
    kubectl create secret generic payguard-db --from-literal=password=payguard --dry-run=client -o yaml | kubectl apply -f -
    kubectl create configmap payguard-sql --from-file=sql/init.sql --dry-run=client -o yaml | kubectl apply -f -
    kubectl apply -f k8s/infra.yaml
    
    ./gradlew shadowJar --console=plain -q
    docker build -f docker/job.Dockerfile -t payguard-job:1.0.0 .
    minikube image load payguard-job:1.0.0
    
    КОД
    # Размер памяти применяется только при создании кластера. Старый профиль сначала удаляют: minikube delete.
    minikube start --cpus=6 --memory=9g --driver=docker
    helm repo add flink-operator "https://downloads.apache.org/flink/flink-kubernetes-operator-$OPERATOR_VERSION/"
    helm upgrade --install flink-kubernetes-operator flink-operator/flink-kubernetes-operator \
      --version "$OPERATOR_VERSION" --set webhook.create=false --wait --timeout 15m
    
    kubectl create secret generic payguard-db --from-literal=password=payguard --dry-run=client -o yaml | kubectl apply -f -
    kubectl create configmap payguard-sql --from-file=sql/init.sql --dry-run=client -o yaml | kubectl apply -f -
    kubectl apply -f k8s/infra.yaml
    
    ./gradlew shadowJar --console=plain -q
    docker build -f docker/job.Dockerfile -t payguard-job:1.0.0 .
    minikube image load payguard-job:1.0.0
    
    ПРОСТЫМИ СЛОВАМИ

    Комментарий предупреждает о ловушке, на которую мы наступили. Размер памяти задаётся только при создании кластера.

    minikube start создаёт кластер. --cpus=6 и --memory=9g задают ресурсы узла, --driver=docker велит запустить узел контейнером Docker.

    helm repo add регистрирует репозиторий чартов оператора. Адрес содержит версию, поэтому чарт совпадает с релизом 1.16.1.

    helm upgrade --install ставит оператор или обновляет уже установленный. webhook.create=false отключает webhook проверки ресурсов, иначе пришлось бы сначала ставить cert-manager для его сертификатов. --wait ждёт готовности подов, --timeout 15m даёт на это пятнадцать минут вместо пяти по умолчанию.

    Secret payguard-db хранит пароль PostgreSQL. Связка --dry-run=client -o yaml | kubectl apply -f - превращает «создать» в «создать или обновить», и скрипт можно запускать повторно.

    ConfigMap payguard-sql несёт sql/init.sql, PostgreSQL выполнит его при первом старте.

    Применяем манифест инфраструктуры, его разберём в следующем шаге.

    Собираем jar, без него Dockerfile нечего копировать. --console=plain и -q убирают индикаторы прогресса и лишний вывод.

    Собираем образ из docker/job.Dockerfile. Точка в конце задаёт контекст сборки, корень проекта.

    У minikube своё хранилище образов внутри узла. minikube image load копирует туда образ, и поды найдут его без реестра.

    терминал
    chmod +x scripts/k8s-up.sh
    ./scripts/k8s-up.sh
    

    Если minikube у тебя уже запускался раньше, скрипт может сработать не так, как написано. Существующий профиль сохраняет свой размер памяти, и minikube честно об этом пишет, но продолжает запуск со старыми 4 ГиБ.

    вывод minikube start
    You cannot change the memory size for an existing minikube cluster. Please first delete the cluster.
    

    Стенд из JobManager на 1600m, TaskManager на 2048m, Kafka, PostgreSQL и оператора в 4 ГиБ не помещается. У нас docker stats показал узел minikube на 3.93GiB из 4GiB, и API Kubernetes перестал отвечать.

    вывод kubectl
    Unable to connect to the server: net/http: TLS handshake timeout
    

    Лечение одно. Удали профиль командой minikube delete и запусти скрипт заново.

    Вторая ловушка ждёт на первом запуске. Образ оператора скачивается долго, и Helm с таймаутом по умолчанию в пять минут не дождался готовности.

    вывод helm
    Error: resource not ready ... context deadline exceeded
    

    Оператор при этом поднялся сам через несколько минут, ошибка относилась только к ожиданию. Поэтому в скрипте стоит --timeout 15m.

    Шаг 4. Инфраструктура в кластере

    Создай k8s/infra.yaml. Здесь Kafka, задание Kubernetes для создания топиков, PostgreSQL и два тома.

    k8s/infra.yaml
    # Учебная инфраструктура внутри кластера. В проде Kafka и PostgreSQL это отдельные управляемые сервисы.
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: kafka
    spec:
      replicas: 1
      strategy:
        type: Recreate
      selector:
        matchLabels: { app: kafka }
      template:
        metadata:
          labels: { app: kafka }
        spec:
          containers:
            - name: kafka
              image: apache/kafka:4.3.2
              env:
                - { name: KAFKA_NODE_ID, value: "1" }
                - { name: KAFKA_PROCESS_ROLES, value: "broker,controller" }
                - { name: KAFKA_CONTROLLER_QUORUM_VOTERS, value: "1@localhost:9093" }
                - { name: KAFKA_CONTROLLER_LISTENER_NAMES, value: CONTROLLER }
                - { name: KAFKA_LISTENERS, value: "INTERNAL://:9092,CONTROLLER://:9093" }
                - { name: KAFKA_ADVERTISED_LISTENERS, value: "INTERNAL://kafka:9092" }
                - { name: KAFKA_LISTENER_SECURITY_PROTOCOL_MAP, value: "INTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT" }
                - { name: KAFKA_INTER_BROKER_LISTENER_NAME, value: INTERNAL }
                - { name: KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR, value: "1" }
                - { name: KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR, value: "1" }
                - { name: KAFKA_TRANSACTION_STATE_LOG_MIN_ISR, value: "1" }
                # Без тома брокер после рестарта забывает топики и транзакции, и exactly-once задание не может зафиксировать коммит.
                - { name: KAFKA_LOG_DIRS, value: /var/lib/kafka/data }
              volumeMounts:
                - { name: kafka-data, mountPath: /var/lib/kafka/data }
          securityContext:
            fsGroup: 1000
          volumes:
            - name: kafka-data
              persistentVolumeClaim:
                claimName: kafka-data
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: kafka
    spec:
      selector: { app: kafka }
      ports:
        - { port: 9092 }
    ---
    apiVersion: batch/v1
    kind: Job
    metadata:
      name: kafka-init
    spec:
      backoffLimit: 20
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: init
              image: apache/kafka:4.3.2
              command: ["/bin/bash", "-c"]
              args:
                - |
                  set -e
                  for topic in payments.raw payments.dlq payments.late payments.confirmations payments.confirmed card.sessions card.profiles fraud.alerts rules.config; do
                    /opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 --create --if-not-exists \
                      --topic "$topic" --partitions 4 --replication-factor 1
                  done
    ---
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: postgres
    spec:
      replicas: 1
      selector:
        matchLabels: { app: postgres }
      template:
        metadata:
          labels: { app: postgres }
        spec:
          containers:
            - name: postgres
              image: postgres:17-alpine
              env:
                - { name: POSTGRES_DB, value: payguard }
                - { name: POSTGRES_USER, value: payguard }
                - name: POSTGRES_PASSWORD
                  valueFrom:
                    secretKeyRef: { name: payguard-db, key: password }
              volumeMounts:
                - { name: init, mountPath: /docker-entrypoint-initdb.d }
          volumes:
            - name: init
              configMap: { name: payguard-sql }
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: postgres
    spec:
      selector: { app: postgres }
      ports:
        - { port: 5432 }
    ---
    apiVersion: v1
    kind: PersistentVolumeClaim
    metadata:
      name: kafka-data
    spec:
      accessModes: [ReadWriteOnce]
      resources:
        requests:
          storage: 5Gi
    ---
    apiVersion: v1
    kind: PersistentVolumeClaim
    metadata:
      name: flink-data
    spec:
      accessModes: [ReadWriteOnce]
      resources:
        requests:
          storage: 5Gi
    

    Deployment это объект Kubernetes, который держит заданное число одинаковых подов и пересоздаёт их при гибели. Service даёт подам постоянное имя в DNS кластера. Благодаря Service kafka задание обращается к брокеру по адресу kafka:9092, как в Compose, и менять настройки задания не нужно. Самое важное в манифесте это том Kafka.

    КОД
                # Без тома брокер после рестарта забывает топики и транзакции, и exactly-once задание не может зафиксировать коммит.
                - { name: KAFKA_LOG_DIRS, value: /var/lib/kafka/data }
              volumeMounts:
                - { name: kafka-data, mountPath: /var/lib/kafka/data }
          securityContext:
            fsGroup: 1000
          volumes:
            - name: kafka-data
              persistentVolumeClaim:
                claimName: kafka-data
    
    ПРОСТЫМИ СЛОВАМИ

    Комментарий объясняет, зачем всё это. Без тома брокер после рестарта забывает топики и транзакции.

    KAFKA_LOG_DIRS переносит журналы брокера в отдельный каталог.

    Монтируем в этот каталог том kafka-data.

    fsGroup назначает группу файлам тома, чтобы процесс Kafka внутри контейнера мог в него писать.

    Том берётся из PersistentVolumeClaim kafka-data, заявки на 5 ГиБ в конце файла. В minikube её выполняет встроенный провайдер дисков на узле.

    Стратегия Recreate у Deployment Kafka нужна из-за тома. По умолчанию Kubernetes при одной реплике поднимает новый под раньше, чем гасит старый. На одном узле том ReadWriteOnce смонтируется в оба пода, и два брокера получат один каталог журналов. На разных узлах новый под не сможет смонтировать том, пока его держит старый. Recreate сначала останавливает старый под.

    Задание kafka-init это объект Job, он выполняет под до успешного завершения. Пока брокер не готов, команда падает, и restartPolicy: OnFailure с backoffLimit: 20 перезапускают её, пока топики не создадутся. У PostgreSQL тома нет. После рестарта он заново выполнит init.sql и потеряет итоги по продавцам. Справочник продавцов восстановится из того же скрипта, для учебного стенда этого хватает.

    Том flink-data общий для JobManager и TaskManager, туда пойдут чекпоинты, savepoint и HA. Режим ReadWriteOnce разрешает монтировать том на одном узле. В minikube узел один, и все поды видят один каталог. На настоящем кластере из нескольких узлов эти каталоги кладут в S3 или другое объектное хранилище.

    Шаг 5. Описание задания

    Создай k8s/payguard.yaml. Это и есть желаемое состояние, которое оператор будет поддерживать.

    k8s/payguard.yaml
    apiVersion: flink.apache.org/v1beta1
    kind: FlinkDeployment
    metadata:
      name: payguard
    spec:
      image: payguard-job:1.0.0
      imagePullPolicy: IfNotPresent
      flinkVersion: v2_2
      serviceAccount: flink
      flinkConfiguration:
        taskmanager.numberOfTaskSlots: "2"
        state.backend.type: rocksdb
        execution.checkpointing.incremental: "true"
        execution.checkpointing.interval: 15s
        execution.checkpointing.min-pause: 10s
        execution.checkpointing.dir: file:///flink-data/checkpoints
        execution.checkpointing.savepoint-dir: file:///flink-data/savepoints
        execution.checkpointing.externalized-checkpoint-retention: RETAIN_ON_CANCELLATION
        high-availability.type: kubernetes
        high-availability.storageDir: file:///flink-data/ha
        restart-strategy.type: exponential-delay
        metrics.reporter.prom.factory.class: org.apache.flink.metrics.prometheus.PrometheusReporterFactory
        metrics.reporter.prom.port: "9249"
      podTemplate:
        spec:
          securityContext:
            fsGroup: 9999
          containers:
            - name: flink-main-container
              env:
                - { name: PAYGUARD_DB_URL, value: "jdbc:postgresql://postgres:5432/payguard" }
                - { name: PAYGUARD_DB_USER, value: payguard }
                - name: PAYGUARD_DB_PASSWORD
                  valueFrom:
                    secretKeyRef: { name: payguard-db, key: password }
              volumeMounts:
                - { name: flink-data, mountPath: /flink-data }
          volumes:
            - name: flink-data
              persistentVolumeClaim:
                claimName: flink-data
      jobManager:
        resource:
          memory: 1600m
          cpu: 0.5
      taskManager:
        resource:
          memory: 2048m
          cpu: 1
      job:
        jarURI: local:///opt/flink/usrlib/payguard.jar
        parallelism: 2
        upgradeMode: savepoint
        state: running
    

    Первые строки выбирают образ и версию API Flink. imagePullPolicy: IfNotPresent велит не ходить в реестр, если образ уже есть на узле, у нас его туда положил minikube image load. serviceAccount: flink это учётная запись, которую создал чарт оператора. От её имени JobManager создаёт поды TaskManager и пишет HA ConfigMap. Блок flinkConfiguration заменяет переменную FLINK_PROPERTIES из Compose.

    КОД
      flinkConfiguration:
        taskmanager.numberOfTaskSlots: "2"
        state.backend.type: rocksdb
        execution.checkpointing.incremental: "true"
        execution.checkpointing.interval: 15s
        execution.checkpointing.min-pause: 10s
        execution.checkpointing.dir: file:///flink-data/checkpoints
        execution.checkpointing.savepoint-dir: file:///flink-data/savepoints
        execution.checkpointing.externalized-checkpoint-retention: RETAIN_ON_CANCELLATION
        high-availability.type: kubernetes
        high-availability.storageDir: file:///flink-data/ha
        restart-strategy.type: exponential-delay
        metrics.reporter.prom.factory.class: org.apache.flink.metrics.prometheus.PrometheusReporterFactory
        metrics.reporter.prom.port: "9249"
    
    ПРОСТЫМИ СЛОВАМИ

    Два слота на TaskManager. При параллелизме 2 хватит одного TaskManager, при 3 понадобится второй.

    Состояние в RocksDB, как в уроке 19, и инкрементальные чекпоинты из урока 11.

    Чекпоинт каждые 15 секунд и не меньше 10 секунд паузы между ними.

    Чекпоинты и savepoint пишутся на общий том /flink-data. Каталог savepoint обязателен для режима savepoint, без него оператору некуда снимать снимок.

    RETAIN_ON_CANCELLATION сохраняет последний чекпоинт при отмене задания. Это страховка на случай апгрейда stateless или ручной отмены.

    HA через Kubernetes, файлы метаданных в /flink-data/ha.

    Стратегия перезапуска с растущей паузой. Подробно о ней в уроке 21.

    Репортер Prometheus из урока 17 на порту 9249. Prometheus в этом стенде нет, порт пригодится при подключении мониторинга кластера.

    Ключа kubernetes.cluster-id в конфигурации нет. Оператор подставляет его сам из имени ресурса, поэтому все объекты кластера называются с префиксом payguard.

    Блок podTemplate общий для подов JobManager и TaskManager. Контейнер обязан называться flink-main-container, по этому имени оператор находит основной контейнер и дополняет его своими настройками. В контейнер передаются три переменные окружения для PostgreSQL, пароль берётся из Secret через secretKeyRef и в манифест не попадает. Том flink-data монтируется в /flink-data. fsGroup: 9999 отдаёт файлы тома группе flink, в официальном образе у неё и у пользователя flink идентификатор 9999.

    Блоки jobManager и taskManager задают ресурсы. Память здесь означает весь процесс, ту самую величину process.size, которую мы раскладывали в уроке 19.

    В исходниках

    Как поле memory из манифеста попадает в конфигурацию Flink. FlinkConfigBuilder записывает его в TOTAL_PROCESS_MEMORY нужного процесса, а CPU в отдельную настройку Kubernetes.

    apache/flink-kubernetes-operator@release-1.16.1 · FlinkConfigBuilder.java, строки 612-626
    private void setResource(Resource resource, Configuration effectiveConfig, boolean isJM) {
        if (resource != null) {
            var memoryConfigOption =
                    isJM
                            ? JobManagerOptions.TOTAL_PROCESS_MEMORY
                            : TaskManagerOptions.TOTAL_PROCESS_MEMORY;
            if (resource.getMemory() != null) {
                effectiveConfig.setString(
                        memoryConfigOption.key(),
                        ResourceConfigUtils.parseResourceMemoryString(resource.getMemory()));
            }
    
            configureCpu(resource, effectiveConfig, isJM);
        }
    }
    

    Значит, memory: 2048m у TaskManager даёт taskmanager.memory.process.size 2048m. Раскладка областей будет та же, что в калькуляторе урока 19, а лимит контейнера совпадёт с размером процесса.

    Блок job описывает само задание. jarURI со схемой local:// указывает на файл внутри образа. Главный класс возьмётся из манифеста jar, как в уроке 1. parallelism: 2 и upgradeMode: savepoint мы будем менять и проверять в задании. state: running значит, что задание должно работать. Значение suspended остановило бы его тем способом, который задаёт upgradeMode.

    Шаг 6. Запусти задание

    терминал
    kubectl apply -f k8s/payguard.yaml
    kubectl get flinkdep payguard
    

    flinkdep это короткое имя типа FlinkDeployment. Когда развёртывание закончится, колонка JOB STATUS покажет RUNNING, а LIFECYCLE STATE покажет STABLE. Именно так было на нашем стенде. Теперь открой доступ к REST JobManager. Native-интеграция создаёт для него Service payguard-rest.

    терминал
    kubectl port-forward svc/payguard-rest 18081:8081
    

    port-forward пробрасывает порт 18081 твоей машины в под за этим Service. Команда работает, пока открыт терминал. Во втором терминале спроси кластер о себе и о задании.

    терминал
    curl -s localhost:18081/overview | jq .
    JOB=$(kubectl get flinkdep payguard -o jsonpath='{.status.jobStatus.jobId}')
    curl -s localhost:18081/jobs/$JOB/checkpoints/config | jq .state_backend
    kubectl get configmaps
    

    jsonpath вытаскивает jobId из статуса ресурса, оператор записал его туда при развёртывании. На стенде получилось следующее.

    Что смотрелиРезультат
    /overviewtaskmanagers 1, slots-total 2, jobs-running 1, flink-version 2.2.1
    Бэкенд состоянияEmbeddedRocksDBStateBackend
    Длительность чекпоинта105 мс
    ConfigMapautoscaler-payguard, flink-config-payguard, payguard-<jobId>-config-map, payguard-cluster-config-map, pod-template-payguard

    Один TaskManager на два слота ровно под параллелизм 2. Его под создал ResourceManager JobManager. Оператор через native-интеграцию создал только Deployment JobManager.

    Шаг 7. Генератор внутри кластера

    Генератору нужен адрес kafka:9092, который виден только внутри кластера. Запустим его подом из того же образа, jar содержит и задание, и генератор. Создай k8s/loadgen.yaml.

    k8s/loadgen.yaml
    # Генератор платежей внутри кластера. Запускается из того же образа: jar содержит и задание, и генератор.
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: loadgen
    spec:
      replicas: 1
      selector:
        matchLabels: { app: loadgen }
      template:
        metadata:
          labels: { app: loadgen }
        spec:
          containers:
            - name: loadgen
              image: payguard-job:1.0.0
              imagePullPolicy: IfNotPresent
              # Логгер не входит в jar задания, его берём из lib образа Flink.
              command: ["java", "-cp", "/opt/flink/usrlib/payguard.jar:/opt/flink/lib/*", "dev.study.payguard.loadgen.LoadGenerator"]
              args: ["--bootstrap", "kafka:9092", "--rate", "200", "--dup", "0.02"]
    

    Первая версия манифеста запускала java -cp /opt/flink/usrlib/payguard.jar без второго элемента classpath, и под сразу падал.

    kubectl logs deploy/loadgen
    java.lang.NoClassDefFoundError: org/slf4j/LoggerFactory
    

    В кластере Flink логгер уже есть, поэтому slf4j в jar задания не попадает. Генератор запускается обычной JVM, без Flink вокруг, и логгера не находит. Лечение в строке command. В classpath добавлен /opt/flink/lib/*, там лежит логгер образа. Команда в манифесте запускается без shell, звёздочку в classpath разворачивает сама JVM.

    терминал
    kubectl apply -f k8s/loadgen.yaml
    kubectl logs -f deploy/loadgen
    

    Шаг 8. Убей JobManager

    Проверим HA. Native-интеграция ставит подам метки app с именем кластера и component с ролью, по ним и выберем под JobManager.

    терминал
    kubectl delete pod -l app=payguard,component=jobmanager
    kubectl get pods -l app=payguard -w
    

    Флаг -w следит за изменениями. Deployment сразу создаст новый под. port-forward держится за конкретный под и после его удаления завершится, запусти его заново. Затем посмотри, откуда задание восстановилось.

    терминал
    curl -s localhost:18081/jobs/$JOB/checkpoints | jq .latest.restored
    

    На стенде задание снова было в RUNNING через 34 с после удаления. jobId остался прежним, 0de8a1877f17c73f80cd4c109fc665d4, а восстановление шло из чекпоинта file:/flink-data/checkpoints/0de8a1877f17c73f80cd4c109fc665d4/chk-10. Оператор в этом не участвовал. Новый JobManager взял jobId из своей конфигурации и нашёл указатель на chk-10 в ConfigMap задания.

    Шаг 9. Перезапусти Kafka

    Теперь проверим том брокера. Генератор пишет 200 платежей в секунду.

    терминал
    kubectl delete pod -l app=kafka
    curl -s localhost:18081/jobs/$JOB/checkpoints | jq .counts
    kubectl exec deploy/kafka -- /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list
    

    Через 90 с задание было в RUNNING, чекпоинты шли дальше. Счётчик completed вырос с 2488 до 2494, failed остался 0. Все топики на месте, включая __transaction_state. Бесконечных рестартов нет.

    Шаг 10. Что ещё ломалось

    До появления тома у Kafka этот же рестарт закончился плохо. Под Kafka перезапустился во время апгрейда, брокер поднялся пустым, остались только топики payments.raw, payments.confirmations и rules.config, которые клиенты создали автоматически. Задание восстановилось из savepoint и упало на фиксации транзакции.

    исключение задания
    java.lang.IllegalStateException: Failed to commit KafkaCommittable{producerId=31, epoch=2, transactionalId='payguard-alerts-0-11', producer=null}
    Caused by: org.apache.kafka.common.errors.InvalidPidMappingException: The producer attempted to use a producer id which is not currently assigned to its transactional id.
    

    Задание восстанавливалось 17 раз, 11 чекпоинтов закончились FAILED, состояние RESTARTING не прекращалось. Почему коннектор не пропускает эту ошибку и чего стоило восстановление, разберём в уроке 21. Профилактика уже в манифесте, это том kafka-data и стратегия Recreate.

    После инцидента minikube пришлось пересоздавать, и это принесло ещё две ошибки. Сначала minikube delete не смог удалить контейнер узла.

    вывод minikube delete
    docker rm -f -v minikube: context deadline exceeded
    

    Том узла остался, и новый узел поднялся со старыми данными etcd, базы, где Kubernetes хранит свои объекты. Ресурсы вернулись, а образ задания из хранилища узла пропал.

    kubectl describe pod
    ErrImagePull: pull access denied for payguard-job
    

    Kubernetes не нашёл образ на узле и пошёл за ним в Docker Hub, где такого образа нет. Лечение в повторном minikube image load payguard-job:1.0.0. Запомни правило. После любого пересоздания узла образ загружают заново.

    Задание. Параллелизм 3 без потери состояния

    PayGuard работает с параллелизмом 2, генератор даёт 200 платежей в секунду. Подними параллелизм до 3 так, чтобы задание продолжило с того же состояния. Новых файлов не создавай, всё делается командами kubectl и запросами к REST.

    1. Измени spec.job.parallelism у ресурса payguard, не редактируя k8s/payguard.yaml руками.
    2. Пронаблюдай смену lifecycleState и засеки время от изменения до STABLE.
    3. Найди снимок, который снял оператор, и его путь.
    4. Докажи по REST, что новое задание восстановлено именно из этого снимка и что у всех вершин параллелизм 3.
    5. Посмотри, сколько стало TaskManager и слотов, и объясни число.
    6. Проверь счётчик неудачных чекпоинтов и найди в логе JobManager причину, если он вырос.

    Готово, если ресурс FlinkStateSnapshot в состоянии COMPLETED, путь из него совпадает с latest.restored.external_path нового задания, counts.restored равен 1, а вершины работают с параллелизмом 3.

    Подсказки. Для изменения одного поля подходит kubectl patch с типом merge. После апгрейда jobId сменится, поэтому перечитай его из статуса ресурса. Помни про port-forward, старый под JobManager исчезнет вместе с ним.

    Ответ

    Глоссарий урока

    Kubernetes
    Система, которая запускает контейнеры на кластере машин, следит за их числом и перезапускает погибшие.
    Под (pod)
    Наименьшая единица запуска в Kubernetes. Один или несколько контейнеров с общей сетью и томами. У нас каждый JobManager и TaskManager живёт в своём поде.
    minikube
    Учебный кластер Kubernetes из одного узла на твоей машине. С драйвером docker узел работает контейнером Docker.
    kubectl
    Командный клиент API Kubernetes. Создаёт, читает, меняет и удаляет объекты кластера.
    Helm
    Менеджер пакетов для Kubernetes. Пакет называется чартом и содержит шаблоны манифестов с настройками.
    Flink Kubernetes Operator
    Приложение в Kubernetes, которое следит за ресурсами FlinkDeployment и приводит кластеры Flink к описанию в них. В других уроках словом «оператор» называют шаг задания, здесь речь о контроллере.
    CRD (custom resource definition)
    Описание собственного типа объектов Kubernetes. После регистрации с ресурсами этого типа работают через kubectl, как с подами.
    FlinkDeployment
    Ресурс оператора, который описывает кластер Flink и, в режиме Application, задание в нём.
    FlinkSessionJob
    Ресурс оператора для задания, которое отправляется в уже работающий кластер Session.
    FlinkStateSnapshot
    Ресурс оператора для одного savepoint или чекпоинта. Хранит состояние операции и путь к файлам.
    Reconcile
    Проход контроллера, который сравнивает желаемое состояние из spec с фактическим и выполняет действия, сводящие разницу к нулю.
    spec и status
    Две части объекта Kubernetes. В spec пользователь пишет, что хочет, в status контроллер пишет, что получилось.
    lifecycleState
    Поле статуса FlinkDeployment, сводная стадия ресурса. Например UPGRADING во время апгрейда и STABLE после успешного запуска.
    upgradeMode
    Поле spec.job, которое задаёт, как оператор переносит состояние при апгрейде. Значения stateless, last-state и savepoint, по умолчанию stateless.
    Savepoint
    Снимок состояния задания, который снимают по команде для обновления или переноса. Подробно в уроке 13.
    Чекпоинт (checkpoint)
    Снимок состояния, который Flink делает сам по расписанию для восстановления после сбоя. Подробно в уроке 11.
    HA (high availability)
    Сервисы высокой доступности Flink. Хранят сведения о заданиях и чекпоинтах вне JobManager, чтобы новый JobManager продолжил работу предыдущего.
    Выбор лидера (leader election)
    Механизм, по которому из нескольких JobManager активным становится один. В Kubernetes HA замком служит ConfigMap кластера.
    ConfigMap
    Объект Kubernetes для небольших данных конфигурации, до мегабайта. Flink HA хранит в нём указатели на метаданные.
    Secret
    Объект Kubernetes для паролей и ключей. Под получает значение через переменную окружения или файл, и в манифест оно не попадает.
    PersistentVolumeClaim (PVC)
    Заявка пода на постоянный диск. Данные на нём переживают рестарт и пересоздание пода.
    ReadWriteOnce
    Режим доступа к тому, при котором его монтируют на запись поды только одного узла.
    Deployment
    Объект Kubernetes, который держит заданное число одинаковых подов и пересоздаёт погибшие. Стратегия Recreate сначала гасит старый под, потом поднимает новый.
    Service
    Объект Kubernetes с постоянным именем и адресом для группы подов. Через него к подам обращаются по имени в DNS кластера.
    port-forward
    Команда kubectl, которая пробрасывает локальный порт в конкретный под. Завершается, если под исчез.
    podTemplate
    Шаблон пода в FlinkDeployment. Оператор накладывает на него свои настройки и создаёт по нему поды JobManager и TaskManager.
    Webhook и cert-manager
    Webhook проверяет ресурсы при kubectl apply. Ему нужен TLS-сертификат, который выпускает cert-manager. В стенде webhook отключён.
    Режим Application
    Развёртывание, при котором кластер Flink создаётся под одно задание, а main выполняется в JobManager. Подробно в уроке 2.
    Режим Session
    Развёртывание, при котором кластер работает независимо от заданий и принимает их от клиентов.
    Native и standalone
    Способы запуска Flink в Kubernetes. В native поды TaskManager просит сам Flink, в standalone их создаёт оператор.
    JobManager
    Процесс, который управляет заданием и координирует чекпоинты. Здесь он же выполняет main задания.
    TaskManager
    Рабочий процесс Flink со слотами, в которых выполняются подзадачи.
    ResourceManager
    Компонент JobManager, который ведёт учёт слотов. В Kubernetes он сам заказывает поды TaskManager.
    jobId
    Идентификатор задания из 32 шестнадцатеричных знаков. Оператор фиксирует его в конфигурации, по нему HA находит чекпоинты.
    __transaction_state
    Внутренний топик Kafka, где брокер хранит состояние транзакций и связь transactional.id с producerId.
    Apache Kafka
    Распределённый журнал сообщений. Источник платежей и приёмник алертов PayGuard.
    spark-operator
    Оператор Kubernetes для Spark. Описывает приложение ресурсом SparkApplication и запускает под драйвера.
    Автоскейлер (autoscaler)
    Модуль оператора, который сам меняет параллелизм вершин задания по метрикам загрузки.

    Урок 21 · Прод

    Эксплуатация. Разбор аварии, хаос-тест, защита Kafka и обновление версий

    После урока ты сможешь разобрать аварию потокового задания по исходникам коннектора, проверить стенд итоговым хаос-тестом, закрыть Kafka паролем, выпустить образ из CI и решить, готов ли стек к новой версии Flink.

    Задание, которое чинит себя до бесконечности

    В уроке 20 мы меняли параллелизм PayGuard в Kubernetes, и посреди апгрейда стенд сломался. Задание ушло в состояние RESTARTING и больше из него не вышло. Оно восстановилось 17 раз, 11 чекпоинтов закончились FAILED, и в истории исключений каждый раз стояла одна и та же пара строк.

    исключение задания
    java.lang.IllegalStateException: Failed to commit KafkaCommittable{producerId=31, epoch=2, transactionalId='payguard-alerts-0-11', producer=null}
    Caused by: org.apache.kafka.common.errors.InvalidPidMappingException: The producer attempted to use a producer id which is not currently assigned to its transactional id.
    

    Обычные сбои Flink переживает сам, это мы проверяли в уроках 11 и 12. Этот не прошёл бы и через сутки. Причина лежала в сохранённом состоянии, и каждый рестарт аккуратно поднимал её обратно.

    Начнём с разбора этой аварии в том виде, в каком его пишут в продакшене. Такой документ называется постмортем (postmortem). В нём записывают, что случилось, почему, чем заплатили и что меняют, чтобы не повторилось. Виноватого человека в постмортеме не ищут, ищут слабое место системы.

    Квитанция из часовой мастерской

    Ты сдал часы в ремонт и получил квитанцию номер 31 на заказ 11. Мастерская записала в журнал, что заказ 11 оформлен по квитанции 31. Ночью мастерскую затопило, журнал размок, и утром приёмщица завела новый, пустой.

    Ты приходишь с квитанцией. Приёмщица смотрит в журнал и отвечает, что квитанция 31 к заказу 11 не относится. Человек пошёл бы выяснять. Робот с инструкцией «приходи с квитанцией, пока не отдадут часы» будет возвращаться каждый день с той же бумажкой и получать тот же ответ.

    В аварии квитанцией была пара producerId и эпоха, которую Flink сохранил в снимке состояния. Журналом мастерской был служебный топик Kafka __transaction_state. Роботом был цикл рестартов. А затопила мастерскую перезагрузка пода Kafka, у которого не было постоянного тома.

    Хронология аварии

    Пройди аварию по шагам. Переключатель показывает ту же последовательность для Kafka с томом. Такой вариант мы проверили в уроке 20 уже после лечения.

    Апгрейд, рестарт Kafka и цикл рестартов

    Что лежало в снимке

    Двухфазную фиксацию приёмника Kafka мы разобрали в уроке 12, здесь нужна одна деталь. На чекпоинте Writer сбрасывает открытую транзакцию и передаёт Committer объект committable. В нём записаны имя транзакции transactionalId, номер продюсера producerId и эпоха. Эпоха это счётчик поколений продюсера, по ней брокер отличает живого продюсера от устаревшего. Когда чекпоинт завершён, Committer фиксирует транзакцию. Если задание упало раньше, committable восстанавливается из состояния и фиксируется после рестарта.

    Имя транзакции из исключения расшифровывается по коду коннектора.

    В исходниках

    Фабрика имён транзакций в коннекторе Kafka. Смотри на формат в комментарии и на порядок склейки.

    apache/flink-connector-kafka@v5.0.0 · TransactionalIdFactory.java, строки 28-45
    /**
     * Constructs a transactionalId with the following format {@code
     * transactionalIdPrefix-subtaskId-checkpointOffset}.
     *
     * @param transactionalIdPrefix prefix for the id
     * @param subtaskId describing the subtask which is opening the transaction
     * @param checkpointOffset an always incrementing number usually capturing the number of
     *     checkpoints taken by the subtask
     * @return transactionalId
     */
    public static String buildTransactionalId(
            String transactionalIdPrefix, int subtaskId, long checkpointOffset) {
        return transactionalIdPrefix
                + TRANSACTIONAL_ID_DELIMITER
                + subtaskId
                + TRANSACTIONAL_ID_DELIMITER
                + checkpointOffset;
    }
    

    Значит, payguard-alerts-0-11 это префикс из настройки задания alerts-transactional-id-prefix, подзадача 0 и одиннадцатый номер чекпоинта у этой подзадачи. Одна подзадача приёмника, один конкретный снимок.

    Почему брокер не узнал транзакцию

    У Kafka транзакции ведёт координатор транзакций, это роль внутри брокера. Он хранит, какому producerId и какой эпохе принадлежит каждый transactional.id, и пишет эти связи в __transaction_state. Обычный продюсер при старте спрашивает у координатора свой producerId. Восстановленный продюсер Flink этого не делает.

    В исходниках

    Как Committer получает продюсера для восстановленного committable. Если живого продюсера от Writer нет, а после рестарта его нет никогда, создаётся служебный и вызывается resumeTransaction с сохранёнными числами.

    apache/flink-connector-kafka@v5.0.0 · KafkaCommitter.java, строки 211-224
    /**
     * Creates a producer that can commit into the same transaction as the upstream producer that
     * was serialized into {@link KafkaCommittable}.
     */
    private FlinkKafkaInternalProducer<?, ?> getProducer(KafkaCommittable committable) {
        if (committingProducer == null) {
            committingProducer =
                    producerFactory.apply(kafkaProducerConfig, committable.getTransactionalId());
        } else {
            committingProducer.setTransactionId(committable.getTransactionalId());
        }
        committingProducer.resumeTransaction(committable.getProducerId(), committable.getEpoch());
        return committingProducer;
    }
    

    А вот что делает сам resumeTransaction. Прочитай первую строку комментария.

    apache/flink-connector-kafka@v5.0.0 · FlinkKafkaInternalProducer.java, строки 294-323
    /**
     * Instead of obtaining producerId and epoch from the transaction coordinator, re-use previously
     * obtained ones, so that we can resume transaction after a restart. Implementation of this
     * method is based on {@link KafkaProducer#initTransactions}.
     * https://github.com/apache/kafka/commit/5d2422258cb975a137a42a4e08f03573c49a387e#diff-f4ef1afd8792cd2a2e9069cd7ddea630
     */
    public void resumeTransaction(long producerId, short epoch) {
        checkState(!isInTransaction(), "Already in transaction %s", transactionalId);
        checkState(
                producerId >= 0 && epoch >= 0,
                "Incorrect values for producerId %s and epoch %s",
                producerId,
                epoch);
        LOG.info(
                "Attempting to resume transaction {} with producerId {} and epoch {}",
                transactionalId,
                producerId,
                epoch);
    
        Object transactionManager = getTransactionManager();
        synchronized (transactionManager) {
            Object txnPartitionMap = getField(transactionManager, "txnPartitionMap");
    
            transitionTransactionManagerStateTo(transactionManager, "INITIALIZING");
            invoke(txnPartitionMap, "reset");
    
            setField(
                    transactionManager,
                    PRODUCER_ID_AND_EPOCH_FIELD_NAME,
                    createProducerIdAndEpoch(producerId, epoch));
    

    Продюсер берёт пару из снимка и через рефлексию кладёт её в TransactionManager клиента Kafka, к координатору он не обращается. Брокер впервые видит эту пару в запросе на коммит. Пока журнал цел, он находит там связь payguard-alerts-0-11 с producerId 31 и фиксирует транзакцию. После потери журнала связи нет, и брокер отвечает InvalidPidMappingException. Текст ошибки говорит ровно об этом. Продюсер использует producerId, который сейчас не назначен его transactional id.

    Почему Committer не пропустил ошибку

    В уроке 12 мы видели, что список известных ошибок в KafkaCommitter заканчивается на UnknownProducerIdException. Теперь посмотрим, что каждая категория значит для задания. Это решает интерфейс Flink, коннектор только выбирает категорию.

    В исходниках

    Первая ветка в KafkaCommitter.commit. Ошибку из семейства RetriableException коннектор просит повторить позже.

    apache/flink-connector-kafka@v5.0.0 · KafkaCommitter.java, строки 101-112
    try {
        producer = writerProducer.orElseGet(() -> getProducer(committable));
        producer.commitTransaction();
        backchannel.send(TransactionFinished.successful(committable.getTransactionalId()));
    } catch (RetriableException e) {
        LOG.warn(
                "Encountered retriable exception while committing {}.", transactionalId, e);
        request.retryLater();
    } catch (ProducerFencedException e) {
        logFencedRequest(request, e);
        handleFailedTransaction(producer);
        request.signalFailedWithKnownReason(e);
    

    Что Flink обещает для двух видов окончательного отказа. Описание в интерфейсе Committer.CommitRequest.

    apache/flink@release-2.2.1 · Committer.java, строки 66-80
    /**
     * The commit failed for known reason and should not be retried.
     *
     * <p>Currently calling this method only logs the error, discards the comittable and
     * continues. In the future the behaviour might be configurable.
     */
    void signalFailedWithKnownReason(Throwable t);
    
    /**
     * The commit failed for unknown reason and should not be retried.
     *
     * <p>Currently calling this method fails the job. In the future the behaviour might be
     * configurable.
     */
    void signalFailedWithUnknownReason(Throwable t);
    

    И как это реализовано в рантайме. Строку из исключения нашей аварии ты увидишь в последнем методе.

    apache/flink@release-2.2.1 · CommitRequestImpl.java, строки 75-88
    @Override
    public void signalFailedWithKnownReason(Throwable t) {
        state = CommitRequestState.FAILED;
        metricGroup.getNumCommittablesFailureCounter().inc();
        // let the user configure a strategy for failing and apply it here
    }
    
    @Override
    public void signalFailedWithUnknownReason(Throwable t) {
        state = CommitRequestState.FAILED;
        metricGroup.getNumCommittablesFailureCounter().inc();
        // let the user configure a strategy for failing and apply it here
        throw new IllegalStateException("Failed to commit " + committable, t);
    }
    

    Получается три исхода. Повторяемая ошибка уходит на повтор без падения. Известная причина пишется в лог и выбрасывает committable, задание работает дальше, а эта транзакция пропадает. Всё остальное бросает IllegalStateException с текстом Failed to commit, и это первая строка нашего исключения.

    Логика у такого деления простая. Про известные ошибки коннектор знает, что транзакция уже отменена или вытеснена, и решает идти дальше с записью в лог. Про неизвестную ошибку он не может сказать, дошли данные до топика или нет. Молча выбросить такую транзакцию значит потерять алерты без следа, поэтому выбран громкий отказ.

    Коннектор 5.0.0 собран с клиентом Kafka 4.2.0, версия видна в его pom.xml.

    apache/flink-connector-kafka@v5.0.0 · pom.xml, строки 51-57
    <properties>
    	<flink.parent.artifactId>flink-connector-kafka-parent</flink.parent.artifactId>
    
    	<!-- Main Dependencies -->
    	<confluent.version>7.9.2</confluent.version>
    	<flink.version>2.1.0</flink.version>
    	<kafka.version>4.2.0</kafka.version>
    

    В исходниках этого клиента InvalidPidMappingException наследует ApplicationRecoverableException. В описании этого класса сказано, что ошибка фатальна для продюсера и приложение должно его пересоздать. Повторяемой она не считается, в ветку retryLater не попадает и доходит до общего catch.

    Почему рестарты не кончались

    После отказа задачи в дело вступает стратегия перезапуска. В k8s/payguard.yaml из урока 20 задан только её тип exponential-delay, остальные параметры остались по умолчанию.

    В исходниках

    Значение по умолчанию для числа попыток. Обрати внимание на аннотацию, документация показывает его словом infinite.

    apache/flink@release-2.2.1 · RestartStrategyOptions.java, строки 301-313
    @Documentation.OverrideDefault("infinite")
    public static final ConfigOption<Integer> RESTART_STRATEGY_EXPONENTIAL_DELAY_ATTEMPTS =
            ConfigOptions.key("restart-strategy.exponential-delay.attempts-before-reset-backoff")
                    .intType()
                    .defaultValue(Integer.MAX_VALUE)
                    .withDescription(
                            Description.builder()
                                    .text(
                                            "The number of times that Flink retries the execution before failing the job if %s has been set to %s. "
                                                    + "The number will be reset once the backoff is reset to its initial value.",
                                            code(RESTART_STRATEGY.key()),
                                            code(EXPONENTIAL_DELAY.getMainValue()))
                                    .build());
    

    Как стратегия решает, можно ли перезапускать, и как считает попытки.

    apache/flink@release-2.2.1 · ExponentialDelayRestartBackoffTimeStrategy.java, строки 98-126
    @Override
    public boolean canRestart() {
        return currentRestartAttempt <= attemptsBeforeResetBackoff;
    }
    
    @Override
    public long getBackoffTime() {
        checkState(
                nextRestartTimestamp != DEFAULT_NEXT_RESTART_TIMESTAMP,
                "Please call notifyFailure first.");
        return Math.max(0, nextRestartTimestamp - clock.absoluteTimeMillis());
    }
    
    @Override
    public boolean notifyFailure(Throwable cause) {
        long now = clock.absoluteTimeMillis();
    
        // Merge multiple failures into one attempt if there are tasks will be restarted later.
        if (now <= nextRestartTimestamp) {
            return false;
        }
    
        if ((now - nextRestartTimestamp) >= resetBackoffThresholdMS) {
            setInitialBackoff();
        }
        nextRestartTimestamp = now + calculateActualBackoffTime();
        currentRestartAttempt++;
        return true;
    }
    

    При лимите Integer.MAX_VALUE метод canRestart отвечает true практически всегда. Счётчик к тому же обнуляется, если между отказами прошло больше порога сброса. Растёт только пауза между попытками.

    Бесконечные попытки хороши для сбоев окружения. Упал TaskManager, моргнула сеть, база ушла на минуту, и задание вернётся само без участия человека. Для ошибки в состоянии они бесполезны. Лимит попыток перевёл бы задание в FAILED, и это был бы более заметный сигнал, но данные он бы не спас. Поэтому цикл ловят алертом PayGuardJobRestarted из урока 17, а runbook объясняет дежурному, как отличить цикл от разового сбоя.

    В Spark упавшая задача повторяется spark.task.maxFailures раз, по умолчанию четыре, после чего запрос Structured Streaming останавливается с исключением, и перезапускать его должно приложение или планировщик. Во Flink с exponential-delay перезапусками занимается сам JobManager, и по умолчанию число попыток не ограничено.

    Как вышли из цикла и чем заплатили

    Лечение шло в два этапа. Первый убирает причину. Брокер Kafka в k8s/infra.yaml получил постоянный том (PersistentVolumeClaim, PVC) kafka-data, и каталог журналов теперь лежит на нём.

    k8s/infra.yaml (строки 31-40)
                # Без тома брокер после рестарта забывает топики и транзакции, и exactly-once задание не может зафиксировать коммит.
                - { name: KAFKA_LOG_DIRS, value: /var/lib/kafka/data }
              volumeMounts:
                - { name: kafka-data, mountPath: /var/lib/kafka/data }
          securityContext:
            fsGroup: 1000
          volumes:
            - name: kafka-data
              persistentVolumeClaim:
                claimName: kafka-data
    

    Второй этап выводит задание из цикла. Удалили ресурс FlinkDeployment и ConfigMap с метаданными HA. Без второго шага ничего не вышло бы. Из урока 20 ты помнишь, что новый JobManager берёт указатель на последний чекпоинт из этих ConfigMap, и задание снова подняло бы тот же committable. Новый FlinkDeployment без initialSavepointPath стартует с пустым состоянием, так описано в документации оператора.

    Цена оказалась заметной. Пропали профили карт, счётчики окон и незавершённые совпадения CEP, то есть всё, что задание узнало о картах с момента запуска. Правила, которым нужна история карты, первое время её не видят. Пропали позиции чтения, хотя здесь они уже ничего не стоили, топики исчезли вместе с диском брокера. Алерты из той подготовленной транзакции потеряны тоже, они существовали только в журнале брокера.

    Путь аккуратнее, который мы не проверяли

    Выбросить можно было только состояние приёмника. Flink сопоставляет состояние из снимка с операторами по uid, это урок 13. Приёмник Sink V2 разворачивается в два оператора, и uid у них выводятся из uid приёмника.

    В исходниках

    Как транслятор приёмника раздаёт uid своим частям.

    apache/flink@release-2.2.1 · SinkTransformationTranslator.java, строки 494-509
    private void concatUid(
            Transformation<?> subTransformation, @Nullable String transformationName) {
        if (transformationName != null && transformation.getUid() != null) {
            // Use the same uid pattern than for Sink V1. We deliberately decided to use the uid
            // pattern of Flink 1.13 because 1.14 did not have a dedicated committer operator.
            if (transformationName.equals(ConfigConstants.COMMITTER_NAME)) {
                final String committerFormat = "Sink Committer: %s";
                subTransformation.setUid(
                        String.format(committerFormat, transformation.getUid()));
                return;
            }
            // Set the writer operator uid to the sinks uid to support state migrations
            if (transformationName.equals(ConfigConstants.WRITER_NAME)) {
                subTransformation.setUid(transformation.getUid());
                return;
            }
    

    Writer получает uid самого приёмника, у нас alerts-sink, а Committer получает Sink Committer: alerts-sink. Если сменить uid приёмника, состояние обоих операторов в снимке останется без хозяина. Такой снимок Flink примет только с разрешением отбросить лишнее, это флаг allowNonRestoredState. В операторе Kubernetes для этого есть два поля спецификации задания.

    apache/flink-kubernetes-operator@release-1.16.1 · JobSpec.java, строки 70-91
    /**
     * Savepoint path used by the job the first time it is deployed or during savepoint
     * redeployments (triggered by changing the savepointRedeployNonce).
     */
    @SpecDiff(DiffType.IGNORE)
    private String initialSavepointPath;
    
    /**
     * Nonce used to manually trigger checkpoint for the running job. In order to trigger a
     * checkpoint, change the number to a different non-null value.
     */
    @SpecDiff(DiffType.IGNORE)
    @Deprecated
    private Long checkpointTriggerNonce;
    
    /** Upgrade mode of the Flink job. */
    @SpecDiff(DiffType.IGNORE)
    private UpgradeMode upgradeMode = UpgradeMode.STATELESS;
    
    /** Allow checkpoint state that cannot be mapped to any job vertex in tasks. */
    @SpecDiff(DiffType.IGNORE)
    private Boolean allowNonRestoredState;
    

    План получается такой. Сменить uid приёмника алертов в коде, выпустить образ, задать в FlinkDeployment путь к последнему savepoint или сохранённому чекпоинту и allowNonRestoredState: true. Профили карт и окна восстановятся, приёмник начнёт с чистого листа. На стенде мы этот путь не проходили. В runbook он записан как вариант, который сначала прогоняют на копии. Отдельно в нём придётся проверить позиции источника, если топики пересоздавались.

    ⚠
    Рестарт контейнера в Docker и в Kubernetes устроен по-разному

    В итоговом хаос-тесте мы перезапускаем Kafka командой docker compose restart, и задание это переживает. Docker запускает тот же контейнер, и его файловая система остаётся на месте. Kubernetes при перезапуске создаёт контейнер заново из образа, и всё, что лежало вне томов, пропадает. Поэтому в Compose эта авария не воспроизводится. В Kubernetes она случилась при первой же нехватке памяти. Любой сервис, который хранит данные, в Kubernetes живёт на постоянном томе.

    Постмортем на одну страницу

    Вот как этот разбор выглядит в виде документа. Формат у команд разный, а разделы почти всегда одни и те же.

    РазделЧто записали
    ВлияниеПока шёл цикл, алерты не выходили в fraud.alerts. После лечения потеряны состояние карт и алерты незафиксированной транзакции
    ХронологияPatch parallelism, savepoint оператора, нехватка памяти в minikube, рестарт пода Kafka, восстановление из savepoint, 17 восстановлений и 11 неудачных чекпоинтов, удаление FlinkDeployment и ConfigMap HA, запуск без состояния
    Корневая причинаУ Kafka не было постоянного тома, и брокер после рестарта потерял __transaction_state
    Что усилилоСтенд работал на пределе памяти. KafkaCommitter относит InvalidPidMappingException к неизвестным ошибкам. Число рестартов не ограничено
    Как обнаружилиСостояние RESTARTING в kubectl get flinkdep и одинаковая запись в истории исключений
    Что сделалиPVC kafka-data для брокера. Проверка удаления пода Kafka под нагрузкой, через 90 с задание RUNNING, чекпоинты без отказов
    Что осталосьРаздел runbook про цикл рестартов. В проде Kafka с репликацией журнала транзакций, на стенде у него один экземпляр

    Последний пункт про репликацию стоит раскрыть. В docker-compose.yml и k8s/infra.yaml переменная KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR равна 1, потому что брокер один. В кластере из нескольких брокеров у журнала транзакций несколько реплик, и потеря одного диска его не уничтожает.

    Хаос-тест как привычка

    Авария случилась на апгрейде, который мы не задумывали как проверку отказов. Хаос-тестирование (chaos testing) делает такие проверки плановыми. На работающей системе по очереди ломают по одному компоненту и сверяют, что обещания держатся. У PayGuard обещаний два. Задание возвращается само, и в fraud.alerts для потребителя с read_committed нет дублей.

    Скрипт final-chaos.sh из практики собран из инструментов прошлых уроков и проверяет три разных пути восстановления.

    • Убитый TaskManager. Задание восстанавливается из последнего чекпоинта, как в уроке 11.
    • Остановленный PostgreSQL. JDBC-приёмник из урока 12 не может открыться, задание перезапускается, пока база не вернётся.
    • Перезапуск Kafka. Клиенты переподключаются, и задание должно пережить это без рестарта.

    Падение JobManager в Compose не проверяется. HA на этом стенде нет, и задание пропало бы вместе с процессом. Этот сценарий мы прошли в Kubernetes в уроке 20.

    Пользу приносит регулярность. Тест стоит гонять после каждого изменения конфигурации, версии или топологии и периодически на стенде, похожем на прод. Сбой, найденный днём на стенде, стоит одного исправления. Тот же сбой ночью в проде стоит алертов, которые не дошли до банка.

    Kafka с паролем

    До этого урока брокер отвечал любому, кто дотянулся до порта. SASL (Simple Authentication and Security Layer) это способ аутентификации в протоколе Kafka. При подключении клиент представляется, брокер сверяет учётные данные и только после этого обрабатывает запросы. Механизм PLAIN передаёт логин и пароль открытым текстом, поэтому в проде его используют только поверх TLS, и такое сочетание называется SASL_SSL. На стенде включён SASL_PLAINTEXT без шифрования, чтобы не заводить сертификаты, и в коде это помечено комментарием.

    Клиенту нужны три свойства Kafka, security.protocol, sasl.mechanism и sasl.jaas.config. Документация коннектора показывает их в том же виде. JAAS (Java Authentication and Authorization Service) это стандартный механизм Java, в нём модуль входа и его параметры записываются одной строкой. Пароль в проекте приходит из переменной окружения, поэтому его нет ни в git, ни в jar.

    В исходниках

    Куда попадают свойства, которые мы передаём источнику. Смотри на поле props.

    apache/flink-connector-kafka@v5.0.0 · KafkaSource.java, строки 98-116
    @PublicEvolving
    public class KafkaSource<OUT>
            implements LineageVertexProvider,
                    Source<OUT, KafkaPartitionSplit, KafkaSourceEnumState>,
                    ResultTypeQueryable<OUT> {
        private static final Logger LOG = LoggerFactory.getLogger(KafkaSource.class);
        private static final long serialVersionUID = -8755372893283732098L;
        // Users can choose only one of the following ways to specify the topics to consume from.
        private final KafkaSubscriber subscriber;
        // Users can specify the starting / stopping offset initializer.
        private final OffsetsInitializer startingOffsetsInitializer;
        private final OffsetsInitializer stoppingOffsetsInitializer;
        // Boundedness
        private final Boundedness boundedness;
        private final KafkaRecordDeserializationSchema<OUT> deserializationSchema;
        // The configurations.
        private final Properties props;
        // Client rackId callback
        private final SerializableSupplier<String> rackIdSupplier;
    

    И на то, от чего наследуется любой источник Flink.

    apache/flink@release-2.2.1 · SourceReaderFactory.java, строки 25-31
    /**
     * A factory for creating source reader instances.
     *
     * @param <T> The type of the output elements.
     */
    @Public
    public interface SourceReaderFactory<T, SplitT extends SourceSplit> extends Serializable {
    

    Переменная окружения читается в main при построении графа. Пароль ложится в поле props, а источник сериализуется вместе с графом задания и уезжает на JobManager и TaskManager. Комментарий в KafkaSecurity об этом и предупреждает, в jar пароля нет, а в графе он есть. Для учебного стенда это приемлемо. В проде пароль лучше читать на стороне TaskManager из смонтированного секрета. Клиент Kafka 4.2.0 умеет подставлять значения через config.providers, это видно в его классе AbstractConfig, но с Flink мы такую схему не проверяли.

    Защищать стоит и сам Flink. В чек-листе готовности к проду документация Flink прямо пишет, что кластер спроектирован для исполнения присланного кода, поэтому REST и Web UI нельзя выставлять в открытую сеть. TLS для внутренних соединений и REST настраивается отдельно, это раздел SSL Setup.

    Выпуск образа из CI

    В уроке 16 мы завели ci.yml, он гоняет тесты на каждый push. Теперь к нему добавляется release.yml, который срабатывает на тег версии вида v1.0.0. Он прогоняет быстрые и сквозные тесты, собирает толстый jar, строит образ по docker/job.Dockerfile из урока 20 и публикует его в реестр контейнеров GitHub (GitHub Container Registry, ghcr.io) с тегом, равным имени тега git.

    Дальше выкатка сводится к одной правке. В FlinkDeployment меняется поле image, оператор снимает savepoint и поднимает задание на новом образе, это режим savepoint из урока 20. Откат делается той же правкой назад. Поэтому образ помечается неизменяемой версией. Тег latest в этой схеме опасен по той же причине, что и в уроке 1, им нельзя сказать, какой код сейчас работает.

    Честная оговорка. release.yml лежит в снапшоте, но на GitHub не запускался, публикацию в ghcr.io мы не проверяли.

    Почему курс на Flink 2.2.1, если вышел 2.3.0

    На главной странице курса обещано разобрать этот выбор. Flink 2.3.0 уже есть на Maven Central и в Docker Hub, и тег flink:latest указывает на него. Обновление потокового задания затрагивает четыре вещи, и каждую проверяют отдельно.

    Есть ли коннекторы под новую версию

    Коннекторы Flink живут в отдельных репозиториях и выходят по своему графику. Версия артефакта состоит из двух частей, 5.0.0-2.2 значит коннектор 5.0.0, собранный под Flink 2.2. На 8 ноября 2026 года в Maven Central у flink-connector-kafka последние версии 5.0.0-2.1 и 5.0.0-2.2, у flink-connector-jdbc-core 4.1.0-2.1 и 4.1.0-2.2. На странице загрузок Flink для Kafka Connector 5.0.0 указана совместимость с 2.1.x и 2.2.x. Артефактов под 2.3 нет ни у одного из двух.

    В исходниках

    Матрица CI коннектора на теге v5.0.0. По ней видно, на каких версиях Flink его тестируют.

    apache/flink-connector-kafka@v5.0.0 · push_pr.yml, строки 28-36
    compile_and_test:
      strategy:
        matrix:
          flink: &flink_versions [ 2.2.0, 2.1.1 ]
          jdk: &jdk_versions [ '11, 17, 21' ]
      uses: apache/flink-connector-shared-utils/.github/workflows/ci.yml@ci_utils
      with:
        flink_version: ${{ matrix.flink }}
        jdk_version: ${{ matrix.jdk }}
    

    Для сравнения список версий Flink, которые знает оператор 1.16.1.

    apache/flink-kubernetes-operator@release-1.16.1 · FlinkVersion.java, строки 47-53
    v1_19(1, 19),
    v1_20(1, 20),
    v2_0(2, 0),
    v2_1(2, 1),
    v2_2(2, 2),
    v2_3(2, 3),
    v2_4(2, 4);
    

    Оператор версию 2.3 уже знает, в его перечислении есть v2_3 и даже v2_4. Блокирует обновление коннектор, на теге v5.0.0 его не тестировали на 2.3.

    В Spark модуль Kafka для Structured Streaming выпускается вместе со Spark и имеет тот же номер версии. Вопроса «вышел ли коннектор» при обновлении Spark не возникает. Во Flink этот вопрос первый, и ответ на него часто приходит через несколько недель после релиза движка.

    Совместим ли код

    Классы Flink помечены аннотациями стабильности, и документация по обновлению даёт для них таблицу гарантий. @Public между минорными версиями сохраняет совместимость по исходникам, но код может потребовать перекомпиляции. @PublicEvolving между минорными версиями не гарантирует ничего. Класс без аннотации считается внутренним. @Experimental не обещает совместимости даже между патч-версиями. KafkaSource в цитате выше помечен @PublicEvolving. Сам KafkaSink тоже помечен @PublicEvolving и реализует интерфейс Flink, у которого стоит @Experimental.

    В исходниках

    Объявление приёмника Kafka и интерфейса, который он реализует для работы после Committer.

    apache/flink-connector-kafka@v5.0.0 · KafkaSink.java, строки 76-80
    @PublicEvolving
    public class KafkaSink<IN>
            implements LineageVertexProvider,
                    TwoPhaseCommittingStatefulSink<IN, KafkaWriterState, KafkaCommittable>,
                    SupportsPostCommitTopology<KafkaCommittable> {
    
    apache/flink@release-2.2.1 · SupportsPostCommitTopology.java, строки 25-32
    /**
     * Allows expert users to implement a custom topology after {@link Committer}.
     *
     * <p>It is recommended to use immutable committables because mutating committables can have
     * unexpected side-effects.
     */
    @Experimental
    public interface SupportsPostCommitTopology<CommittableT> {
    

    Коннектор связан с API, которое между минорными версиями Flink может меняться без предупреждения. Поэтому jar, собранный с 5.0.0-2.2, на 2.3 никто не обещает, и коннектор пересобирают под каждую минорную версию.

    В исходниках

    Похожая хрупкость есть и со стороны клиента Kafka. Тот же FlinkKafkaInternalProducer достаёт внутренние поля продюсера рефлексией. Смотри, что происходит, если поля нет.

    apache/flink-connector-kafka@v5.0.0 · FlinkKafkaInternalProducer.java, строки 280-292
    /**
     * Gets and returns the field {@code fieldName} from the given Object {@code object} using
     * reflection.
     */
    private static Object getField(Object object, Class<?> clazz, String fieldName) {
        try {
            Field field = clazz.getDeclaredField(fieldName);
            field.setAccessible(true);
            return field.get(object);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException("Incompatible KafkaProducer version", e);
        }
    }
    

    Если в своей сборке поднять kafka-clients выше версии, с которой собран коннектор, и в новой версии внутреннее поле переименуют, exactly-once приёмник упадёт с этим сообщением, как только понадобится resumeTransaction. Версию клиента Kafka разумно оставлять той, что приезжает с коннектором.

    Совместимо ли состояние

    Сама процедура обновления движка описана в документации в три шага. Остановить задание со savepoint на старой версии, обновить кластер, запустить задание на новой версии из этого savepoint. Есть два способа. При обновлении на месте (in-place) старый кластер останавливают и обновляют. При теневой копии (shadow copy) рядом поднимают новый, запускают задание из savepoint там и гасят старый, только когда всё работает. Для PayGuard в Kubernetes это смена образа и flinkVersion в FlinkDeployment с режимом savepoint.

    Таблица совместимости savepoint в документации 2.2 заканчивается на столбце 1.20.x, про переход 2.2 на 2.3 в ней ничего нет. Поэтому совместимость состояния проверяют двумя способами. Читают release notes новой версии и прогоняют restore-тест из урока 16 на зависимостях новой версии, а потом поднимают теневую копию из настоящего savepoint.

    Что изменилось в поведении

    Release notes, заметки к выпуску, перечисляют изменения, которые могут задеть работающее задание. В заметках к 2.3 есть, например, исправление MiniBatchGroupAggFunction, которая в одном из режимов mini-batch агрегации молча теряла записи, и чекпоинты во время восстановления из unaligned-чекпоинта. Первое касается SQL-заданий урока 14, второе урока 18. При переходе через несколько версий читают заметки к каждой промежуточной.

    ПроверкаГде смотретьИтог для 2.3.0 в ноябре 2026
    КоннекторыMaven Central, страница загрузок Flink, CI коннектораKafka и JDBC собраны только под 2.1 и 2.2
    КодАннотации стабильности, сборка и тесты на новой версииНечего собирать, пока нет коннекторов
    СостояниеRelease notes, restore-тест, теневая копияПроверять после выхода коннекторов
    ПоведениеRelease notes каждой промежуточной версииИзменения в SQL и восстановлении стоит учесть
    ОператорПеречисление FlinkVersion, документация оператора1.16.1 знает v2_3
    💡
    Обновление начинается с самого медленного компонента

    Свежая версия движка ничего не даёт, пока под неё не вышли коннекторы, которыми пользуется задание. Поэтому в проде версию Flink выбирают по самому отстающему коннектору и закрепляют её везде, от образа до Gradle. Когда выйдет flink-connector-kafka под 2.3, PayGuard надо будет провести через все пять проверок из таблицы, и только потом менять образ.

    Что ты построил за курс

    PayGuard прошёл путь от задания, которое печатало платежи в лог, до системы, которую можно передать дежурной смене.

    Часть системыУроки
    Чтение из Kafka, разбор, DLQ для битых записей, свои типы без Kryo1-4
    Время события, окна, состояние карт с TTL, правила без перезапуска, обогащение из PostgreSQL, CEP5-10
    Чекпоинты на RocksDB, exactly-once в Kafka, идемпотентная запись в PostgreSQL, savepoint и rescale11-13
    Отчёты на Flink SQL, CDC и Paimon14-15
    Пять уровней тестов, метрики и алерты, поиск узких мест, расчёт памяти, Kubernetes с HA16-20
    Разбор аварии, хаос-тест, SASL, выпуск образа, проверка обновления21

    Что делать дальше

    • Довести безопасность до прода. SASL_SSL с настоящими сертификатами для Kafka, TLS для REST и внутренних соединений Flink, пароли из секретов на стороне TaskManager.
    • Попробовать автоскейлер оператора. Он меняет параллелизм по нагрузке, и в уроке 20 мы его не трогали.
    • Следить за выходом flink-connector-kafka под 2.3 и пройти на PayGuard всю проверку совместимости из этого урока, с теневой копией и restore-тестом.
    • Перенести Kafka и PostgreSQL в управляемые сервисы или кластеры с репликацией и повторить хаос-тест уже против них.
    • Читать исходники дальше. Когда в логах появится незнакомое исключение, ищи его в коде коннектора и рантайма, как в этом уроке. Обычно ветка, куда оно попадает, объясняет поведение задания лучше документации.

    Проверь себя

    После сбоя брокера задание крутится в RESTARTING с InvalidPidMappingException при коммите. Коллега предлагает увеличить паузу между рестартами и подождать. Что делать?

    Во время хаос-теста PostgreSQL лежал 30 секунд, и счётчик восстановлений вырос с 1 до 5. Это ошибка в PayGuard?

    Платформенная команда предлагает поменять образ PayGuard на flink:2.3.0-java17, раз он вышел. В jar лежит flink-connector-kafka 5.0.0-2.2. Что ответить?

    Что будет в конце

    Стенд из Compose проходит итоговый хаос-тест без дублей в fraud.alerts. Тот же стенд с надстройкой SASL пускает к брокеру только клиентов с паролем, и PayGuard вместе с генератором работают через неё. В репозитории появляется сценарий выпуска образа. Файлы ops/runbook.md и ops/production-readiness.md из снапшота относятся к заданию, пока их создавать не нужно.

    Шаг 1. Учётные данные Kafka из окружения

    Создай src/main/java/dev/study/payguard/job/KafkaSecurity.java. Класс собирает свойства подключения к Kafka для всех клиентов проекта, источников, приёмников и генератора.

    src/main/java/dev/study/payguard/job/KafkaSecurity.java
    package dev.study.payguard.job;
    
    import java.util.Properties;
    import org.apache.kafka.clients.CommonClientConfigs;
    import org.apache.kafka.common.config.SaslConfigs;
    
    /**
     * Настройки подключения к защищённой Kafka. Логин и пароль приходят из окружения и не попадают в jar,
     * но едут в графе задания вместе со свойствами источника.
     */
    public final class KafkaSecurity {
    
        private static final String LOGIN_MODULE = "org.apache.kafka.common.security.plain.PlainLoginModule";
    
        public static Properties fromEnvironment() {
            Properties properties = new Properties();
            String user = System.getenv("PAYGUARD_KAFKA_USER");
            if (user == null) {
                return properties;
            }
            // ponytail: учебный стенд без TLS, в проде здесь SASL_SSL, иначе пароль уходит открытым текстом.
            properties.setProperty(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "SASL_PLAINTEXT");
            properties.setProperty(SaslConfigs.SASL_MECHANISM, "PLAIN");
            properties.setProperty(SaslConfigs.SASL_JAAS_CONFIG, "%s required username=\"%s\" password=\"%s\";"
                    .formatted(LOGIN_MODULE, user, System.getenv("PAYGUARD_KAFKA_PASSWORD")));
            return properties;
        }
    
        private KafkaSecurity() {
        }
    }
    
    КОД
        public static Properties fromEnvironment() {
            Properties properties = new Properties();
            String user = System.getenv("PAYGUARD_KAFKA_USER");
            if (user == null) {
                return properties;
            }
            // ponytail: учебный стенд без TLS, в проде здесь SASL_SSL, иначе пароль уходит открытым текстом.
            properties.setProperty(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "SASL_PLAINTEXT");
            properties.setProperty(SaslConfigs.SASL_MECHANISM, "PLAIN");
            properties.setProperty(SaslConfigs.SASL_JAAS_CONFIG, "%s required username=\"%s\" password=\"%s\";"
                    .formatted(LOGIN_MODULE, user, System.getenv("PAYGUARD_KAFKA_PASSWORD")));
            return properties;
        }
    
    ПРОСТЫМИ СЛОВАМИ

    Метод всегда возвращает новый объект Properties, поэтому вызывающий код может спокойно дописывать в него свои ключи.

    Если переменной PAYGUARD_KAFKA_USER нет, свойства пустые, и клиент подключается без аутентификации. Так старый стенд и тесты работают без изменений.

    Протокол SASL_PLAINTEXT включает аутентификацию без шифрования. Комментарий напоминает, что в проде здесь стоит SASL_SSL.

    Механизм PLAIN передаёт логин и пароль как есть, брокер сверяет их со своим списком.

    Строка JAAS называет модуль входа клиента Kafka и передаёт ему логин и пароль из окружения. Кавычки вокруг значений и точка с запятой в конце обязательны по синтаксису JAAS.

    Шаг 2. Подключи свойства ко всем клиентам

    В KafkaSinks приёмник at-least-once получает свойства через setKafkaProducerConfig. В exactly-once приёмнике объект из fromEnvironment становится основой, и в него дописывается тайм-аут транзакции из урока 12.

    что изменилось в src/main/java/dev/study/payguard/job/KafkaSinks.java
    @@ -19,11 +19,12 @@                 .setBootstrapServers(bootstrapServers)
                     .setRecordSerializer(jsonSerializer(topic))
                     .setDeliveryGuarantee(DeliveryGuarantee.AT_LEAST_ONCE)
    +                .setKafkaProducerConfig(KafkaSecurity.fromEnvironment())                 .build();
         }
     
         static <T> KafkaSink<T> exactlyOnceJson(String bootstrapServers, String topic, String transactionalIdPrefix) {
    -        Properties producer = new Properties();+        Properties producer = KafkaSecurity.fromEnvironment();         producer.setProperty(ProducerConfig.TRANSACTION_TIMEOUT_CONFIG, String.valueOf(TRANSACTION_TIMEOUT.toMillis()));
             return KafkaSink.<T>builder()
                     .setBootstrapServers(bootstrapServers)
    

    Все три источника в PayGuardJob получают те же свойства через setProperties. Метод копирует ключи в общий набор свойств источника, поэтому порядок вызовов в строителе не важен.

    что изменилось в src/main/java/dev/study/payguard/job/PayGuardJob.java
    @@ -211,6 +211,7 @@     private static KafkaSource<ParsedRecord> paymentSource(JobConfig config) {
             return KafkaSource.<ParsedRecord>builder()
                     .setBootstrapServers(config.bootstrapServers())
    +                .setProperties(KafkaSecurity.fromEnvironment())                 .setTopics(config.rawTopic())
                     .setGroupId(config.groupId())
                     .setStartingOffsets(OffsetsInitializer.committedOffsets(OffsetResetStrategy.LATEST))
    @@ -221,6 +222,7 @@     private static KafkaSource<RuleConfig> ruleSource(JobConfig config) {
             return KafkaSource.<RuleConfig>builder()
                     .setBootstrapServers(config.bootstrapServers())
    +                .setProperties(KafkaSecurity.fromEnvironment())                 .setTopics(config.rulesTopic())
                     .setGroupId(config.groupId() + "-rules")
                     .setStartingOffsets(OffsetsInitializer.earliest())
    @@ -231,6 +233,7 @@     private static KafkaSource<PaymentConfirmation> confirmationSource(JobConfig config) {
             return KafkaSource.<PaymentConfirmation>builder()
                     .setBootstrapServers(config.bootstrapServers())
    +                .setProperties(KafkaSecurity.fromEnvironment())                 .setTopics(config.confirmationsTopic())
                     .setGroupId(config.groupId() + "-confirmations")
                     .setStartingOffsets(OffsetsInitializer.committedOffsets(OffsetResetStrategy.LATEST))
    

    Генератор живёт в том же jar и берёт класс из пакета задания.

    что изменилось в src/main/java/dev/study/payguard/loadgen/LoadGenerator.java
    @@ -1,5 +1,6 @@ package dev.study.payguard.loadgen;
     
    +import dev.study.payguard.job.KafkaSecurity; import java.time.Duration;
     import java.util.Properties;
     import java.util.Random;
    @@ -45,7 +46,7 @@     }
     
         private static Properties producerProperties(GeneratorConfig config) {
    -        Properties properties = new Properties();+        Properties properties = KafkaSecurity.fromEnvironment();         properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, config.bootstrapServers());
             properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
             properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    

    Пока переменные окружения не заданы, поведение проекта не меняется. Это пригодится в шаге 4, где хаос-тест идёт на обычном стенде без паролей.

    Шаг 3. Надстройка Compose с SASL

    Положи рядом с docker-compose.yml файл docker-compose.sasl.yml. Это надстройка (override file). Compose читает основной файл, потом надстройку и сливает их. Словари вроде environment объединяются по ключам, а списки вроде command заменяются целиком.

    docker-compose.sasl.yml
    # Надстройка над docker-compose.yml: брокер требует логин и пароль, анонимные клиенты получают отказ.
    # Пароли приходят из файла .env рядом с этим файлом, в репозиторий он не попадает.
    x-flink-kafka-auth: &flink-kafka-auth
      PAYGUARD_KAFKA_USER: payguard
      PAYGUARD_KAFKA_PASSWORD: ${KAFKA_PAYGUARD_PASSWORD}
    
    services:
      kafka:
        environment:
          KAFKA_ADMIN_PASSWORD: ${KAFKA_ADMIN_PASSWORD}
          KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:SASL_PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:SASL_PLAINTEXT
          KAFKA_SASL_ENABLED_MECHANISMS: PLAIN
          KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL: PLAIN
          KAFKA_LISTENER_NAME_INTERNAL_PLAIN_SASL_JAAS_CONFIG: >-
            org.apache.kafka.common.security.plain.PlainLoginModule required
            username="admin" password="${KAFKA_ADMIN_PASSWORD}"
            user_admin="${KAFKA_ADMIN_PASSWORD}" user_payguard="${KAFKA_PAYGUARD_PASSWORD}";
          KAFKA_LISTENER_NAME_EXTERNAL_PLAIN_SASL_JAAS_CONFIG: >-
            org.apache.kafka.common.security.plain.PlainLoginModule required
            username="admin" password="${KAFKA_ADMIN_PASSWORD}"
            user_admin="${KAFKA_ADMIN_PASSWORD}" user_payguard="${KAFKA_PAYGUARD_PASSWORD}";
        healthcheck:
          test:
            - CMD-SHELL
            - >-
              printf 'security.protocol=SASL_PLAINTEXT\nsasl.mechanism=PLAIN\nsasl.jaas.config=org.apache.kafka.common.security.plain.PlainLoginModule required username="admin" password="%s";\n'
              "$$KAFKA_ADMIN_PASSWORD" > /tmp/client.properties
              && /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --command-config /tmp/client.properties --list
    
      kafka-init:
        environment:
          KAFKA_ADMIN_PASSWORD: ${KAFKA_ADMIN_PASSWORD}
        command:
          - |
            printf 'security.protocol=SASL_PLAINTEXT\nsasl.mechanism=PLAIN\nsasl.jaas.config=org.apache.kafka.common.security.plain.PlainLoginModule required username="admin" password="%s";\n' "$$KAFKA_ADMIN_PASSWORD" > /tmp/client.properties
            for topic in payments.raw payments.dlq payments.late payments.confirmations payments.confirmed card.sessions card.profiles fraud.alerts rules.config; do
              /opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 --command-config /tmp/client.properties \
                --create --if-not-exists --topic "$$topic" --partitions 4 --replication-factor 1
            done
            /opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 --command-config /tmp/client.properties \
              --create --if-not-exists --topic merchants.changelog --partitions 4 --replication-factor 1 \
              --config cleanup.policy=compact
    
      jobmanager:
        environment: *flink-kafka-auth
    
      taskmanager:
        environment: *flink-kafka-auth
    

    Разберём по блокам.

    • Якорь x-flink-kafka-auth задаёт переменные для JobManager и TaskManager. Их читает KafkaSecurity. Пароль подставляется из файла .env.
    • KAFKA_LISTENER_SECURITY_PROTOCOL_MAP переводит слушатели INTERNAL и EXTERNAL на SASL_PLAINTEXT. Слушатель CONTROLLER для внутреннего кворума KRaft остаётся без пароля.
    • Две строки JAAS задают серверную сторону PLAIN для каждого слушателя. Пары user_admin и user_payguard объявляют учётные записи брокера и их пароли, а username и password брокер использует сам для связи между брокерами.
    • Проверка здоровья и kafka-init теперь ходят с учёткой admin. Скрипт пишет файл client.properties и передаёт его через --command-config. Двойной $$ нужен, чтобы Compose не подставил переменную сам и оставил её оболочке контейнера.

    Команда kafka-init в надстройке заменяет исходную целиком, поэтому в ней повторён весь список топиков вместе с компактируемым merchants.changelog из урока 15. Забыть один топик при таком копировании легко, первая версия надстройки так и потеряла merchants.changelog. SQL-кластер в режиме SASL мы не запускали.

    Пароли лежат в .env.example. Это образец, настоящий .env создаётся копией и в git не попадает.

    .env.example
    KAFKA_ADMIN_PASSWORD=admin-secret
    KAFKA_PAYGUARD_PASSWORD=payguard-secret
    
    что изменилось в .gitignore
    @@ -1,3 +1,4 @@ build/
     .gradle/
     data/
    +.env

    Шаг 4. Итоговый хаос-тест

    Создай scripts/final-chaos.sh. Он по очереди ломает три компонента стенда и в конце проверяет дубли.

    scripts/final-chaos.sh
    #!/usr/bin/env bash
    # Итоговый тест: валит компоненты стенда по очереди и сверяет отсутствие дублей в fraud.alerts.
    # Падение JobManager проверяется в Kubernetes (урок 20): в этом стенде нет HA, и задание пропало бы вместе с ним.
    set -euo pipefail
    PAUSE="${PAUSE:-45}"
    
    step() {
      echo
      echo "== $1"
      shift
      "$@"
      sleep "$PAUSE"
      ./scripts/checkpoint-report.sh | head -1
    }
    
    step "Убиваем TaskManager" ./scripts/chaos-kill-tm.sh
    step "Останавливаем PostgreSQL на 30 секунд" sh -c 'docker compose stop postgres > /dev/null; sleep 30; docker compose start postgres > /dev/null'
    step "Перезапускаем Kafka" docker compose restart kafka
    
    echo
    ./scripts/verify-no-duplicates.sh fraud.alerts alertId read_committed
    
    КОД
    step() {
      echo
      echo "== $1"
      shift
      "$@"
      sleep "$PAUSE"
      ./scripts/checkpoint-report.sh | head -1
    }
    
    step "Убиваем TaskManager" ./scripts/chaos-kill-tm.sh
    step "Останавливаем PostgreSQL на 30 секунд" sh -c 'docker compose stop postgres > /dev/null; sleep 30; docker compose start postgres > /dev/null'
    step "Перезапускаем Kafka" docker compose restart kafka
    
    echo
    ./scripts/verify-no-duplicates.sh fraud.alerts alertId read_committed
    
    ПРОСТЫМИ СЛОВАМИ

    Функция step печатает заголовок шага, выполняет переданную команду, ждёт PAUSE секунд и печатает первую строку отчёта о чекпоинтах из урока 11. В ней три счётчика, завершённые, неудачные и восстановления.

    Первый шаг вызывает chaos-kill-tm.sh из урока 11. Тот убивает TaskManager сигналом SIGKILL и ждёт, пока задание восстановится из чекпоинта.

    Второй шаг останавливает PostgreSQL на 30 секунд и запускает обратно. Вывод Compose уходит в /dev/null, чтобы не мешать отчёту.

    Третий шаг перезапускает контейнер Kafka. Задание должно переподключиться без рестарта.

    В конце verify-no-duplicates.sh из урока 12 читает fraud.alerts с read_committed и считает идентификаторы алертов, встретившиеся дважды.

    Собери проект и подними обычный стенд. Переменная COMPOSE_FILE в этот момент не должна быть задана.

    терминал
    ./gradlew shadowJar
    docker compose up -d --wait
    ./scripts/submit.sh
    ./gradlew loadgen --args="--rate 200 --dup 0.02"
    

    Генератор пишет 200 платежей в секунду и дублирует 2 % из них, чтобы дедупликация из урока 7 тоже работала под нагрузкой. Во втором терминале дай заданию сделать несколько чекпоинтов и запусти тест.

    терминал
    PAUSE=45 ./scripts/final-chaos.sh
    

    Переменная PAUSE задаёт паузу после каждого шага. 45 секунд хватает на три чекпоинта при интервале 15 секунд. Итог нашего прогона сведён в таблицу, это первая строка отчёта после каждого шага.

    МоментЗавершеноНеудачныхВосстановленийЧто произошло
    До хаоса500Чекпоинт 5 занял 258 мс, записано 5081 КиБ при состоянии 6646 КиБ
    Убит TaskManager811Скрипт напечатал «Задание восстановлено из чекпоинта за 13 с». Чекпоинт в момент убийства упал
    PostgreSQL выключен на 30 с1025Задание перезапускалось четыре раза, пока базы не было
    Kafka перезапущена1425Счётчик восстановлений не вырос, задание пережило рестарт брокера без перезапуска

    Последняя строка вывода скрипта.

    вывод
    fraud.alerts [read_committed]: записей 7, идентификаторов с повтором 0
    

    Для сравнения запусти проверку с уровнем изоляции read_uncommitted.

    терминал
    ./scripts/verify-no-duplicates.sh fraud.alerts alertId read_uncommitted
    
    вывод
    fraud.alerts [read_uncommitted]: записей 12, идентификаторов с повтором 2
    

    Лишние пять записей и два повтора это данные транзакций, которые отменились при восстановлениях. Потребитель read_committed их не видит, так и работает exactly-once из урока 12.

    Теперь посмотри, что задание пережило. История исключений доступна через REST.

    терминал
    JOB=$(curl -s localhost:8081/jobs | jq -r '[.jobs[] | select(.status == "RUNNING")][0].id')
    curl -s localhost:8081/jobs/$JOB/exceptions | jq -r '.exceptionHistory.entries[].exceptionName'
    

    Первая команда находит идентификатор запущенного задания, вторая печатает имена исключений из истории. У нас там было пять записей. Три раза java.io.IOException: unable to open JDBC writer, это рестарты без базы, каждый падал на открытии JDBC-приёмника, а exponential-delay растягивал паузы между ними. Ещё FlinkExpectedException: The TaskExecutor is shutting down. и ResourceManagerException: TaskManager with id 192.168.97.5:38213-1d33ea is no longer reachable., это следы убитого TaskManager. Задание в конце RUNNING.

    Такая история и есть материал для runbook. Каждое исключение здесь ожидаемое, и дежурный должен уметь отличить его от исключения из аварии в теории.

    Шаг 5. Стенд с SASL

    Останови обычный стенд и включи надстройку.

    терминал
    docker compose down
    cp .env.example .env
    export COMPOSE_FILE=docker-compose.yml:docker-compose.sasl.yml
    docker compose up -d --wait
    

    cp создаёт рабочий .env из образца, Compose читает его автоматически из папки проекта. Переменная COMPOSE_FILE перечисляет файлы через двоеточие и действует как пара флагов -f. Мы выбрали её по двум причинам. Все скрипты в scripts/ вызывают docker compose без флагов и тоже подхватят надстройку. Сохранить строку с двумя -f в переменной и раскрыть её как команду в zsh не получится, zsh не делит значение переменной на слова. В нашем прогоне брокер стал healthy через 6 секунд, kafka-init создал топики с учёткой admin.

    Проверь, что анонимный клиент больше не проходит.

    терминал
    timeout 30 docker compose exec -T kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list; echo $?
    

    Команда без --command-config не получает ответа и висит до таймаута. timeout обрывает её и возвращает код 124, его печатает echo $?. Причина видна в логе брокера, docker compose logs kafka.

    лог брокера
    WARN ... Unexpected error from /127.0.0.1 ...; closing connection
    org.apache.kafka.common.errors.InvalidRequestException: Unexpected Kafka request of type METADATA during SASL handshake.
    

    Клиент сразу отправил запрос метаданных, а брокер на этом слушателе ждал рукопожатие SASL. Он закрыл соединение, клиент переподключался и повторял то же самое.

    Запусти задание и генератор с учётными данными.

    терминал
    ./scripts/submit.sh
    PAYGUARD_KAFKA_USER=payguard PAYGUARD_KAFKA_PASSWORD=payguard-secret ./gradlew loadgen --args="--rate 200"
    

    Заданию переменные передавать не нужно. flink run выполняется внутри контейнера JobManager, а надстройка уже положила туда PAYGUARD_KAFKA_USER и пароль. Генератор работает на твоей машине, поэтому переменные заданы прямо перед командой и действуют только на неё. Через 90 секунд проверь чекпоинты.

    терминал
    ./scripts/checkpoint-report.sh | head -1
    
    вывод
    Завершено: 7 неудачных: 0 восстановлений: 0
    

    Исключений в истории ноль, задание RUNNING. В Web UI источник payments-raw прочитал 17240 записей, payment-confirmations 16400. Значит, оба потока идут через SASL. Файл .env должен появиться до up. Без него Compose подставит вместо паролей пустые строки и предупредит об этом при старте.

    Вернуть обычный стенд можно так.

    терминал
    docker compose down
    unset COMPOSE_FILE
    

    Шаг 6. Выпуск образа

    Создай .github/workflows/release.yml.

    .github/workflows/release.yml
    name: release
    
    on:
      push:
        tags: ["v*"]
    
    jobs:
      image:
        runs-on: ubuntu-24.04
        permissions:
          contents: read
          packages: write
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-java@v4
            with:
              distribution: temurin
              java-version: "17"
          - uses: gradle/actions/setup-gradle@v4
          - run: ./gradlew test e2eTest shadowJar --console=plain
          - uses: docker/login-action@v3
            with:
              registry: ghcr.io
              username: ${{ github.actor }}
              password: ${{ secrets.GITHUB_TOKEN }}
          - run: |
              image="ghcr.io/${{ github.repository }}/payguard-job:${{ github.ref_name }}"
              docker build -f docker/job.Dockerfile -t "$image" .
              docker push "$image"
    

    Сценарий запускается только на push тега, который начинается с v. Права packages: write разрешают встроенному токену GITHUB_TOKEN писать в реестр, а contents: read оставляет репозиторий только для чтения. Шаг Gradle гоняет быстрые тесты, сквозные тесты на Testcontainers из урока 16 и собирает толстый jar. Отличие от ci.yml в том, что всё делается в одном задании, и образ строится только после зелёных тестов. Имя образа собирается из имени репозитория, а тег из github.ref_name, то есть из имени тега git. Выпуск запускается так.

    терминал
    git tag v1.0.0
    git push origin v1.0.0
    

    Этот сценарий мы на GitHub не запускали. Он лежит в снапшоте как образец, проверь его на своём репозитории. В k8s/payguard.yaml после этого в поле image пишется полное имя образа из ghcr.io, а для приватного пакета кластеру понадобится секрет для доступа к реестру.

    Шаг 7. Если цикл уже начался

    Повтори порядок действий из теории на Kubernetes-стенде урока 20. Вывод этих команд мы не сохраняли, поэтому здесь только последовательность. Сначала убедись, что у Kafka есть том, иначе цикл повторится при следующем рестарте брокера.

    терминал
    kubectl get pvc kafka-data
    kubectl apply -f k8s/infra.yaml
    kubectl delete flinkdep payguard
    kubectl get configmap
    kubectl delete configmap payguard-cluster-config-map payguard-<jobId>-config-map
    kubectl apply -f k8s/payguard.yaml
    

    kubectl get configmap покажет ConfigMap кластера, в уроке 20 их было пять. Удалять нужно две с метаданными HA, payguard-cluster-config-map и payguard-<jobId>-config-map с идентификатором задания из списка. Остальные оператор создаст заново. После apply задание стартует без состояния.

    Если хочешь сохранить состояние карт, есть аккуратный путь из теории, который мы не проверяли. Меняется uid приёмника алертов вот в этом месте PayGuardJob.

    src/main/java/dev/study/payguard/job/PayGuardJob.java (строки 205-208)
                    .sinkTo(KafkaSinks.<FraudAlert>exactlyOnceJson(
                            config.bootstrapServers(), config.alertsTopic(), config.alertsTransactionalIdPrefix()))
                    .name("alerts-sink")
                    .uid("alerts-sink");
    

    После выпуска образа с новым uid в спецификации задания задаются initialSavepointPath с путём к последнему сохранённому снимку и allowNonRestoredState: true. Прогони это сначала на копии стенда.

    Задание. Runbook и чек-лист готовности

    PayGuard готов к передаче дежурной смене. Напиши два документа в папке ops/.

    1. ops/runbook.md. На каждый алерт из monitoring/alerts.yml урока 17 раздел с заголовком по имени алерта. В разделе первая команда, которую выполняет дежурный, как по её выводу отличить причины и что делать в каждом случае. Алерты, которые обычно идут вместе, можно описать в одном разделе. Отдельно опиши плановые операции, выкатку новой версии, откат и повторную обработку данных.
    2. ops/production-readiness.md. Чек-лист, который команда проходит перед первым запуском задания в проде. Пункты сгруппированы по темам, код, устойчивость, масштаб, наблюдаемость и безопасность. Каждый пункт можно проверить фактом, тестом, конфигом или прогоном.

    Готово, если у каждого из шести алертов есть раздел, в каждом разделе есть конкретная команда из скриптов проекта или REST, и каждый пункт чек-листа ты можешь связать с уроком, где он проверялся.

    Подсказки. Возьми за основу историю исключений из шага 4 практики и аварию из теории, хороший runbook отличает ожидаемые исключения от опасных. Вспомни, какой алерт молчит, когда пропал TaskManager, и чем это закрыто в уроке 17. Для чек-листа пролистай задания прошлых уроков, почти каждое закрывает один пункт. В документации Flink есть свой Production Readiness Checklist, сравни с ним.

    Ответ

    Глоссарий урока

    Постмортем (postmortem)
    Документ о разборе аварии. В нём хронология, влияние, корневая причина, усилившие факторы и действия, чтобы авария не повторилась. Виноватых в нём не ищут.
    Runbook
    Инструкция для дежурного. На каждый алерт в ней записано, что проверить первым, как отличить причины и что делать.
    Хаос-тестирование (chaos testing)
    Плановые отказы компонентов на работающей системе с проверкой, что гарантии сохраняются и система восстанавливается сама.
    InvalidPidMappingException
    Ошибка брокера Kafka. Продюсер прислал producerId, который не связан с его transactional.id. В клиенте 4.2.0 наследует ApplicationRecoverableException и требует пересоздать продюсера.
    __transaction_state
    Служебный топик Kafka, где координатор транзакций хранит связи transactional.id с producerId, эпохой и состоянием транзакций.
    Координатор транзакций (transaction coordinator)
    Роль брокера Kafka, которая выдаёт продюсерам producerId и эпоху, ведёт их транзакции и фиксирует или отменяет их.
    producerId и эпоха (epoch)
    Номер продюсера и счётчик его поколений. По паре брокер отличает живого продюсера от устаревшего и не даёт старому зафиксировать транзакцию.
    transactional.id
    Постоянное имя транзакционного продюсера Kafka. Приёмник Flink строит его из префикса, номера подзадачи и номера чекпоинта.
    Committable
    Объект, который Writer приёмника передаёт Committer на чекпоинте. У Kafka в нём имя транзакции, producerId и эпоха. Хранится в состоянии, пока транзакция не зафиксирована.
    Committer
    Оператор приёмника Sink V2, который фиксирует подготовленные транзакции после завершения чекпоинта. Его uid строится как Sink Committer плюс uid приёмника.
    Exponential-delay
    Стратегия перезапуска Flink, при которой пауза между попытками растёт до предела. По умолчанию число попыток не ограничено.
    allowNonRestoredState
    Разрешение запустить задание из снимка, в котором есть состояние без подходящего оператора. Такое состояние отбрасывается.
    uid оператора
    Постоянный идентификатор оператора, по которому Flink находит его состояние в savepoint и чекпоинте.
    Savepoint
    Снимок состояния задания, который делают по команде, например перед апгрейдом. Из него запускают новую версию задания.
    Exactly-once
    Гарантия, что каждая запись отражается в результате ровно один раз, даже при сбоях. В Kafka держится на транзакциях и read_committed.
    read_committed
    Уровень изоляции потребителя Kafka, при котором он видит только данные зафиксированных транзакций. read_uncommitted видит и отменённые.
    PersistentVolumeClaim (PVC)
    Заявка пода Kubernetes на постоянный том. Данные на нём переживают перезапуск и пересоздание контейнера.
    ConfigMap
    Объект Kubernetes с данными в виде ключей и значений. Flink HA хранит в нём лидера и указатель на последний чекпоинт.
    FlinkDeployment
    Ресурс оператора Flink в Kubernetes, описывающий кластер и задание. Оператор приводит кластер к его спецификации.
    SASL
    Simple Authentication and Security Layer. Способ аутентификации в протоколе Kafka, клиент представляется до первых запросов.
    PLAIN
    Механизм SASL, который передаёт логин и пароль как есть. Без TLS пароль виден в сети.
    TLS, SASL_SSL и SASL_PLAINTEXT
    TLS шифрует соединение. SASL_SSL это аутентификация SASL поверх TLS, вариант для прода. SASL_PLAINTEXT это аутентификация без шифрования, вариант учебного стенда.
    JAAS
    Java Authentication and Authorization Service. Стандартный механизм Java, клиент Kafka читает из строки JAAS модуль входа, логин и пароль.
    Надстройка Compose (override file)
    Дополнительный файл Compose, который сливается с основным. Переменная COMPOSE_FILE перечисляет файлы через двоеточие.
    GitHub Actions
    Система CI в GitHub. Сценарии лежат в .github/workflows и запускаются по событиям репозитория, например push или тегу.
    GitHub Container Registry (ghcr.io)
    Реестр образов контейнеров при GitHub. Сценарий публикует туда образ встроенным токеном GITHUB_TOKEN.
    Аннотации стабильности
    Метки Public, PublicEvolving и Experimental на классах Flink. По ним видно, какую совместимость Flink обещает между версиями. PublicEvolving между минорными версиями не обещает ничего.
    Release notes
    Заметки к выпуску. Список изменений версии, которые могут задеть работающие задания.
    Теневая копия (shadow copy)
    Способ обновления, при котором новый кластер поднимают рядом со старым, запускают задание из savepoint и гасят старый после проверки.
    Production readiness checklist
    Список проверок перед первым запуском задания в проде. У Flink есть свой в документации.
    Apache Kafka
    Распределённый журнал сообщений. Записи хранятся в топиках и читаются повторно.

    Общий глоссарий

    Все термины курса, 426 штук. Определения те же, что во всплывающих подсказках.

    --allowNonRestoredState
    Флаг запуска, который разрешает выбросить состояние операторов, отсутствующих в новой программе. Короткая форма -n.
    --drain и MAX_WATERMARK
    Флаг команды stop, который перед последним барьером отправляет максимальный watermark. Срабатывают все таймеры по времени события. Подходит только для окончательной остановки.
    __transaction_state
    Внутренний топик Kafka, где брокер хранит состояние транзакций. Без постоянного тома теряется при перезапуске брокера.
    absent
    Функция PromQL, которая возвращает 1, когда выражение внутри не нашло ни одного ряда. Нужна для алертов на пропавшие метрики.
    AggregateFunction
    Функция окна, которая складывает записи в аккумулятор по одной. В состоянии хранит только аккумулятор.
    Alertmanager
    Компонент экосистемы Prometheus, который группирует алерты и рассылает уведомления в почту, мессенджеры и системы дежурств. На учебном стенде не установлен.
    allowedLateness
    Сколько времени окно живёт в состоянии после срабатывания, принимая опоздавшие записи. По умолчанию ноль.
    allowNonRestoredState
    Разрешение запустить задание из снимка, в котором есть состояние без подходящего оператора. Такое состояние отбрасывается.
    Apache Calcite
    Библиотека разбора и оптимизации SQL. На ней основан планировщик Flink.
    Apache Flink
    Движок для обработки потоков данных с состоянием. Программа на нём работает непрерывно и переживает падения без потери результатов.
    Apache Hadoop
    Платформа распределённого хранения и вычислений. Paimon обращается к её классам, поэтому нужен jar даже при работе с локальным диском.
    Apache Iceberg
    Другой открытый формат таблиц для озера данных. В этом курсе на стенде не используется.
    Apache Kafka
    Распределённый журнал сообщений. Записи лежат в топиках и хранятся заданное время, поэтому читать их можно повторно.
    Apache Paimon
    Формат таблиц для озера данных с упором на потоковую запись и обновления по первичному ключу.
    Append-only
    Поток изменений только со вставками +I. Строки результата никогда не меняются.
    AssertJ
    Библиотека проверок для тестов на Java с цепочками вроде assertThat(x).isEmpty() и подробными сообщениями о падении.
    Assigner (WindowAssigner)
    Часть оконного оператора, которая по времени записи решает, в какие окна она попадает.
    Async I/O
    Механизм Flink для запросов во внешние системы без остановки подзадачи. Подзадача отправляет много запросов сразу и выдаёт результаты по мере ответов.
    AsyncFunction
    Интерфейс функции асинхронного оператора. Метод asyncInvoke запускает запрос и сразу возвращается, метод timeout решает, что делать с зависшим запросом. RichAsyncFunction добавляет open, close и доступ к метрикам.
    At least once
    Гарантия доставки, при которой запись не теряется, но при сбое может прийти повторно. Для приёмника Kafka требует чекпоинтов.
    At-least-once
    Гарантия, что ни одна запись не потеряется, но после сбоя часть записей может быть учтена повторно.
    Backlog
    Число буферов, которые ждут отправки в подчасти. Передаётся вместе с каждым буфером, чтобы получатель заранее запросил место.
    Backpressure
    Обратное давление. Медленный оператор перестаёт принимать данные, буферы перед ним заполняются, и замедление доходит до источника.
    backPressuredTimeMsPerSecond
    Сколько миллисекунд в секунду задача ждала свободного буфера у следующего оператора. Признак backpressure, разбирается в уроке 18.
    BOM (bill of materials)
    Файл с согласованными версиями группы библиотек. Подключается в Gradle через platform, и версии отдельных модулей указывать не нужно.
    broadcast
    Стратегия, которая отправляет каждую запись всем подзадачам получателя.
    Broadcast state
    Состояние оператора, копия которого есть в каждой его подзадаче. Пишется только со стороны широковещательного потока, читается при обработке остальных записей. Устроено как словарь и живёт в куче JVM.
    Broadcast variable в Spark
    Значение, которое драйвер Spark один раз рассылает на экзекуторы для чтения. Поменять его можно только созданием новой переменной.
    Buffer debloating
    Механизм, который подстраивает размер сетевых буферов под измеренную пропускную способность, чтобы данные в полёте разбирались за целевое время. Включается ключом taskmanager.network.memory.buffer-debloat.enabled.
    busyTimeMsPerSecond
    Сколько миллисекунд в секунду задача была занята обработкой. 1000 означает полную загрузку. Подробно в уроке 18.
    busyTimeMsPerSecond и соседи
    Метрики подзадачи. Показывают, сколько миллисекунд в секунду она работала, ждала вход и стояла из-за backpressure. В сумме около 1000.
    Calc
    Вершина плана, которая выполняет проекцию и фильтр без состояния.
    cancel
    Команда CLI, которая останавливает задание сразу, без savepoint.
    Capacity
    Наибольшее число записей в очереди асинхронного оператора. Когда очередь полна, оператор перестаёт брать записи со входа и включается backpressure.
    Capacity planning
    Расчёт ресурсов задания заранее, по размеру состояния, скорости потока и запасу на восстановление.
    Card testing
    Мошенническая проверка украденных карт мелкими платежами перед крупной покупкой.
    Catalyst
    Оптимизатор запросов Spark SQL.
    CDC (change data capture)
    Захват изменений. Изменения строк читаются из журнала базы и идут потоком в другие системы.
    CEP (complex event processing)
    Обработка сложных событий. Поиск в потоке последовательностей событий по шаблону. Во Flink это библиотека FlinkCEP, артефакт flink-cep.
    CepOperator
    Оператор Flink, который исполняет шаблон. Буферизует события до watermark, ведёт автомат и хранит всё в keyed state.
    Changelog
    Поток записей с видом изменения, +I вставка, -U и +U старая и новая версии при обновлении, -D удаление. Подробно в уроке 14.
    Changelog (поток изменений)
    Поток строк, каждая из которых описывает изменение таблицы и несёт пометку вида изменения.
    ChangelogMode
    Класс, в котором коннектор перечисляет виды строк, которые он выдаёт или принимает.
    checkpointLocation в Spark
    Каталог Spark Structured Streaming с журналом смещений, журналом коммитов и версиями хранилища состояния по каждой порции.
    CI (непрерывная интеграция)
    Автоматическая сборка и запуск тестов на каждое изменение кода.
    Classpath
    Упорядоченный список jar и каталогов, где JVM ищет классы. Из одинаковых классов загружается первый найденный.
    CloseableIterator
    Итератор Flink, который нужно закрыть после использования. Его возвращает executeAndCollect(), закрытие освобождает ресурсы кластера.
    ClosureCleaner
    Класс Flink, который чистит функцию от лишних ссылок и проверяет, что её можно сериализовать. Вызывается при добавлении функции в граф.
    Committable
    Описание подготовленных данных, которое Writer передаёт Committer. У Kafka это имя транзакции, producerId и эпоха.
    Committer
    Оператор приёмника, который делает подготовленные данные видимыми после завершения чекпоинта. Повторная фиксация не должна ничего менять.
    commons-lang3
    Библиотека вспомогательных классов Apache Commons. Её использует Testcontainers.
    Compaction
    В Paimon фоновое слияние файлов бакета, которое сокращает работу при чтении.
    CompletableFuture
    Класс Java для результата, который появится позже. supplyAsync запускает работу в заданном пуле потоков, whenComplete выполняет код по её окончании.
    ConfigMap
    Объект Kubernetes для небольших данных конфигурации, до мегабайта. Flink HA хранит в нём указатели на метаданные.
    consecutive и allowCombinations
    Смежность между событиями внутри шага с квантификатором. consecutive делает её строгой, allowCombinations недетерминированной, по умолчанию она ослабленная.
    ConstraintEnforcer
    Оператор перед приёмником, который проверяет ограничения колонок, например NOT NULL. Его добавляет сборка приёмника.
    Counter
    Тип метрики Flink, целое число с методами inc и dec. В Prometheus экспортируется как Gauge.
    CRD (custom resource definition)
    Описание собственного типа объектов Kubernetes. После регистрации с ресурсами этого типа работают через kubectl, как с подами.
    DataStream
    Основной API Flink для потоковых программ на Java. Объект описывает поток записей одного типа и план его обработки.
    DDL
    Часть SQL, которая описывает объекты, например CREATE TABLE. Во Flink в DDL указаны коннектор, формат и водяной знак.
    Debezium
    Библиотека чтения журналов баз данных. Flink CDC использует её классы внутри своих источников.
    Deployment
    Объект Kubernetes, который держит заданное число одинаковых подов и пересоздаёт погибшие. Стратегия Recreate сначала гасит старый под, потом поднимает новый.
    Dispatcher
    Компонент JobManager. Даёт REST-интерфейс для отправки заданий, запускает для каждого новый JobMaster и обслуживает Web UI.
    DLQ (dead letter queue)
    Топик или очередь для записей, которые не удалось обработать. Хранит их для разбора и повторной отправки.
    Docker
    Программа, которая запускает приложения в изолированных контейнерах из готовых образов.
    Docker Compose
    Инструмент, который поднимает несколько контейнеров по описанию из одного YAML-файла.
    Driver (Spark)
    Процесс Spark, который выполняет main и планирует работу приложения.
    E2E, сквозной тест
    Тест, который проверяет всю систему вместе с настоящими внешними зависимостями. В PayGuard это Kafka и PostgreSQL в контейнерах.
    Encoder
    Механизм Spark, который описывает перевод объектов Dataset во внутренний формат. Ближайший аналог TypeInformation.
    End-to-end exactly-once
    Exactly-once от источника до внешнего приёмника. Требует воспроизводимого источника и транзакционного или идемпотентного приёмника.
    Evictor
    Необязательная часть оконного оператора, которая убирает записи из окна до или после вызова функции. Отключает предварительное сложение.
    Exactly once
    Гарантия, при которой каждая запись влияет на результат один раз. Разбирается в уроке 12.
    Exactly-once
    Гарантия, что каждая запись отразится в состоянии ровно один раз даже при сбоях. Для внешних систем требует отдельной поддержки в приёмнике.
    ExecutionGraph
    Параллельная версия JobGraph, которую держит JobMaster. На каждую вершину в нём столько элементов, сколько задано параллелизмом.
    Executor (Spark)
    Рабочий процесс Spark, который выполняет задачи. Похож на TaskManager.
    Exponential-delay
    Стратегия перезапуска, у которой пауза растёт в заданное число раз с каждой попыткой до верхнего предела и сбрасывается после долгой работы без сбоев.
    Fencing
    Вытеснение старого продюсера новым с тем же transactional.id. Попытка старого продолжить заканчивается ProducerFencedException.
    Flame graph
    Картинка из многократно снятых стеков вызовов. Ширина полосы метода пропорциональна числу снимков, где он встретился.
    Flink Kubernetes Operator
    Приложение в Kubernetes, которое следит за ресурсами FlinkDeployment и приводит кластеры Flink к описанию в них. В других уроках словом «оператор» называют шаг задания, здесь речь о контроллере.
    FlinkDeployment
    Ресурс оператора, который описывает кластер Flink и, в режиме Application, задание в нём.
    FlinkSessionJob
    Ресурс оператора для задания, которое отправляется в уже работающий кластер Session.
    FlinkStateSnapshot
    Ресурс оператора для одного savepoint или чекпоинта. Хранит состояние операции и путь к файлам.
    ForSt
    Ответвление RocksDB для Flink, которое хранит файлы состояния на удалённой файловой системе. Во Flink 2.2 экспериментальное.
    FORWARD
    Способ передачи, при котором подзадача отправляет записи подзадаче следующего оператора с тем же номером. Без него операторы не склеиваются.
    Gauge
    Метрика, значение которой вычисляется в момент чтения, например занятая куча. В Prometheus так же называется тип ряда, который может расти и падать.
    Generic-тип
    Тип, который Flink не смог разобрать по полям. Описывается через GenericTypeInfo и сериализуется через Kryo.
    GitHub Actions
    Сервис CI от GitHub. Запускает задания из файлов в каталоге .github/workflows на каждое указанное событие.
    GitHub Container Registry (ghcr.io)
    Реестр образов контейнеров при GitHub. Сценарий публикует туда образ встроенным токеном GITHUB_TOKEN.
    Global окно
    Одно окно на ключ на всё время работы. По умолчанию не срабатывает никогда, требует своего trigger.
    Grafana
    Приложение для дашбордов. Берёт данные из Prometheus и рисует панели по запросам PromQL.
    HA (high availability)
    Сервисы высокой доступности Flink. Хранят сведения о заданиях и чекпоинтах вне JobManager, чтобы новый JobManager продолжил работу предыдущего.
    Harness (тестовый стенд)
    Обёртка из тестовых артефактов Flink. Запускает один оператор в JVM теста и даёт доступ к его выходам.
    HashMapStateBackend
    Бэкенд по умолчанию. Хранит состояние Java-объектами в куче TaskManager, быстрый и ограниченный памятью.
    Head-of-line blocking
    Ситуация, когда медленный первый элемент очереди задерживает всех за ним, хотя их работа уже сделана.
    Healthcheck
    Команда в Docker Compose, по которой Docker решает, готов ли контейнер. Флаг --wait ждёт её успеха.
    Helm
    Менеджер пакетов для Kubernetes. Пакет называется чартом и содержит шаблоны манифестов с настройками.
    HikariCP
    Библиотека пула соединений для JDBC. Держит открытые соединения с базой и выдаёт их потокам по запросу.
    Histogram
    Метрика Flink с распределением значений. В Prometheus превращается в Summary с квантилями.
    idleTimeMsPerSecond
    Метрика подзадачи. Миллисекунды из секунды, когда подзадаче нечего было обрабатывать.
    Impossible travel
    Признак скопированной карты, когда соседние платежи пришли из мест, между которыми нельзя успеть переместиться.
    Inner join
    Соединение, которое выдаёт только пары с совпадением. Записи без пары не выдаются вовсе.
    InputChannel
    Канал внутри InputGate, который читает одну подчасть одного отправителя. Локальный читает её в памяти того же TaskManager, удалённый получает буферы по сети.
    InputGate
    Входной шлюз подзадачи-получателя. Собирает данные со всех отправителей, у каждого отправителя свой канал.
    Interval join
    Соединение двух потоков с ключом, при котором запись правого потока должна попасть во временной интервал вокруг записи левого. Работает во времени события и выдаёт только совпавшие пары.
    InvalidPidMappingException
    Ошибка Kafka, когда producerId не связан с transactional.id на брокере. У нас возникла после потери журнала транзакций.
    isolation.level
    Настройка потребителя Kafka. read_uncommitted, значение по умолчанию, отдаёт всё, включая прерванные транзакции. read_committed отдаёт только зафиксированные.
    IterativeCondition
    Условие шага, которое видит события, уже принятые в другие шаги этого совпадения, через getEventsForPattern.
    JAAS
    Java Authentication and Authorization Service. Стандартный механизм Java, клиент Kafka читает из строки JAAS модуль входа, логин и пароль.
    Jackson
    Популярная Java-библиотека для JSON. Главный класс ObjectMapper превращает текст в объекты и обратно.
    JDBC
    Стандартный интерфейс Java для работы с реляционными базами. Его вызовы блокируют поток до ответа базы.
    JDBC-коннектор
    Коннектор Flink для реляционных баз через стандартный драйвер Java. Умеет читать, искать по ключу и писать.
    Jitter
    Случайный сдвиг паузы перезапуска. Не даёт многим заданиям после общей аварии перезапускаться одновременно.
    JobGraph
    Граф, который отправляется на кластер. Цепочки операторов в нём уже объединены в одну вершину.
    JobGraph и JobVertex
    Граф, в котором операторы склеены в цепочки. Каждая цепочка это одна вершина (JobVertex). Строится из StreamGraph классом StreamingJobGraphGenerator.
    jobId
    Идентификатор задания из 32 шестнадцатеричных знаков. Оператор фиксирует его в конфигурации, по нему HA находит чекпоинты.
    JobManager
    Процесс, который управляет заданиями, раздаёт работу и координирует снимки. Подробно в уроке 2.
    JobMaster
    Компонент JobManager, отвечающий за одно задание. Держит его ExecutionGraph, просит слоты, развёртывает подзадачи, реагирует на сбои.
    jq
    Консольная утилита для разбора и преобразования JSON.
    JUnit
    Основная библиотека модульных тестов в Java. Тег @Tag помечает тесты, чтобы запускать их выборочно.
    JVM Metaspace
    Область JVM под метаданные загруженных классов, по умолчанию 256 МиБ, ограничена флагом -XX:MaxMetaspaceSize.
    JVM Overhead
    Запас нативной памяти под стеки потоков, кэш JIT и внутренние структуры JVM. Доля 0,1 от процесса в границах 192 МиБ и 1 ГиБ.
    keyBy
    Вызов DataStream API, который разбивает поток по ключу. Все записи с одним ключом попадают в одну подзадачу следующего оператора.
    keyBy и keyed state
    keyBy раскладывает поток по ключу, после чего каждый ключ получает своё состояние в одной подзадаче.
    Keyed state
    Состояние, которое Flink хранит отдельно для каждого ключа. В нём interval join держит буферы обеих сторон.
    KeyedBroadcastProcessFunction
    Функция для потока с ключом, соединённого с широковещательным. processElement обрабатывает записи с ключом и читает broadcast state, processBroadcastElement обрабатывает правила и пишет в него.
    KeyedCoProcessFunction
    Функция с двумя входами, разбитыми по одному ключу. Даёт keyed state и таймеры, на ней пишут соединения с любой логикой.
    KeyedProcessFunction
    Функция нижнего уровня DataStream API для потока после keyBy. Метод processElement вызывается на каждую запись, onTimer при срабатывании таймера.
    Kryo
    Универсальная библиотека сериализации для Java, запасной путь Flink для нераспознанных типов.
    kubectl
    Командный клиент API Kubernetes. Создаёт, читает, меняет и удаляет объекты кластера.
    Kubernetes
    Система управления контейнерами. Flink на ней может сам запрашивать новые TaskManager. Разбирается в уроке 20.
    Lakehouse
    Таблицы из файлов в дешёвом хранилище с метаданными, которые дают атомарную публикацию изменений. Читать такую таблицу можно разными движками.
    lifecycleState
    Поле статуса FlinkDeployment, сводная стадия ресурса. Например UPGRADING во время апгрейда и STABLE после успешного запуска.
    ListState
    Вид состояния со списком на ключ. Подходит, когда нужны сами элементы, и опасен, когда список растёт без ограничения.
    Local и Global aggregate
    Две фазы агрегации. Локальная считает частичные суммы до обмена по сети, глобальная объединяет их по ключу.
    Long.MIN_VALUE и Long.MAX_VALUE
    Наименьшее и наибольшее значения типа long в Java. Первое означает, что watermark ещё не было, второе обещает, что старых данных от входа не будет никогда.
    Lookup join
    Соединение, в котором каждая запись потока запрашивает значение из внешней системы в момент обработки.
    LSM-дерево (log-structured merge tree)
    Устройство хранилища, где записи копятся в памяти и сбрасываются на диск неизменяемыми файлами, а фоновые слияния поддерживают порядок.
    LSM-дерево (log-structured merge-tree)
    Способ хранения, при котором записи копятся в памяти, сбрасываются отсортированными файлами и потом сливаются.
    LSN (log sequence number)
    Номер позиции в WAL.
    LSO (last stable offset)
    Смещение перед первой открытой транзакцией в партиции. Потребитель read_committed не читает дальше него.
    Mailbox
    Очередь дел потока подзадачи Flink. Обработка записей, таймеры и результаты асинхронных запросов выполняются через неё в одном потоке.
    Managed memory
    Область памяти TaskManager вне кучи, которой распоряжается сам Flink. Подробно в уроке 19.
    mapGroupsWithState
    Операция Spark Structured Streaming для произвольного состояния по группе. Один объект состояния и один тайм-аут на группу, вызов раз в micro-batch.
    MapState
    Вид состояния со словарём на ключ. Удобен, когда у ключа много независимых частей, например корзины времени.
    MATCH_RECOGNIZE
    Оператор стандарта SQL для поиска шаблонов в строках таблицы. Во Flink SQL работает поверх библиотеки CEP.
    Memtable
    Отсортированная таблица RocksDB в памяти, куда попадают свежие записи. Заполненная memtable сбрасывается на диск SST-файлом.
    Meter
    Метрика Flink со скоростью событий в секунду. Реализация MeterView усредняет за 60 секунд. В Prometheus отдаётся как Gauge со значением скорости.
    Micro-batch
    Режим Spark Structured Streaming, при котором записи копятся и обрабатываются небольшими пакетными задачами.
    Mini-batch
    Режим, в котором агрегация копит пачку записей и обращается к состоянию один раз на ключ. Экономит состояние ценой задержки.
    MiniCluster
    Полноценный Flink внутри JVM теста. Позволяет проверить кусок конвейера с таймерами и состоянием без Docker.
    minikube
    Учебный кластер Kubernetes из одного узла на твоей машине. С драйвером docker узел работает контейнером Docker.
    MurmurHash
    Быстрая хеш-функция, которая перемешивает биты числа. Flink применяет её к hashCode ключа, чтобы похожие ключи расходились по разным группам.
    Namespace состояния
    Второй уровень адресации keyed state после ключа. Оконный оператор использует окно как namespace, поэтому у ключа может быть много окон сразу.
    Native и standalone
    Способы запуска Flink в Kubernetes. В native поды TaskManager просит сам Flink, в standalone их создаёт оператор.
    Netty
    Java-библиотека для сетевых приложений. Flink передаёт через неё буферы между TaskManager.
    NFA
    Недетерминированный конечный автомат. В него компилятор CEP переводит шаблон. Из одного состояния на одно событие могут сработать несколько переходов, и попытка раздваивается.
    NoResourceAvailableException
    Исключение, которым кончается запрос слотов, если кластер не может их дать.
    notifyCheckpointComplete
    Вызов, который оператор получает после того, как JobManager объявил чекпоинт завершённым. По нему Committer фиксирует транзакции.
    Off-heap и direct-память
    Память вне кучи JVM. Direct-буферы Java ограничены флагом -XX:MaxDirectMemorySize, прочая нативная память ничем внутри JVM не ограничена.
    On-CPU и Off-CPU
    Типы flame graph во Flink. On-CPU берёт потоки в состоянии RUNNABLE, Off-CPU берёт ждущие и заблокированные, Mixed берёт все.
    OOMKilled
    Статус контейнера Kubernetes, который убило ядро Linux за превышение лимита памяти. Исключения в JVM при этом не возникает.
    OpenLineage
    Открытый стандарт и библиотека для отслеживания происхождения данных. Нужна JDBC-коннектору 4.x при запуске.
    OperatorID
    16-байтовый идентификатор оператора внутри Flink. Получается хешем murmur3 от uid или выводится из положения оператора в графе.
    OutputTag
    Типизированная именованная метка побочного выхода. Создаётся как анонимный подкласс, чтобы Flink узнал параметр типа.
    Parent-first
    Порядок загрузки классов, при котором класс сначала ищется в библиотеках кластера и только потом в jar задания. Для пакета org.apache.flink. он действует всегда.
    Parquet
    Колоночный формат файлов. По умолчанию Paimon хранит в нём данные.
    PatternProcessFunction
    Функция, которую CEP вызывает на каждое совпадение. Получает словарь от имени шага к списку событий.
    PersistentVolumeClaim (PVC)
    Заявка пода на постоянный диск. Данные на нём переживают рестарт и пересоздание пода.
    pgoutput
    Стандартный плагин вывода логического декодирования PostgreSQL, на нём работает встроенная логическая репликация.
    PLAIN
    Механизм SASL, который передаёт логин и пароль как есть. Без TLS пароль виден в сети.
    podTemplate
    Шаблон пода в FlinkDeployment. Оператор накладывает на него свои настройки и создаёт по нему поды JobManager и TaskManager.
    POJO
    Обычный Java-класс, который Flink разбирает по полям. Требования это публичный класс, конструктор без аргументов и доступные поля. Record также подходит, если он публичный.
    port-forward
    Команда kubectl, которая пробрасывает локальный порт в конкретный под. Завершается, если под исчез.
    PostgreSQL
    Реляционная база данных с открытым кодом. В PayGuard хранит справочник продавцов.
    ProcessFunction
    Самая гибкая пользовательская функция DataStream API. Получает запись и контекст, через контекст умеет писать в побочные выходы.
    ProcessJoinFunction
    Функция, которую interval join вызывает на каждую найденную пару. Через контекст видны временные метки обеих записей.
    ProcessWindowFunction
    Функция окна, которая получает все записи окна разом и контекст с его границами. Без AggregateFunction хранит все записи окна.
    producerId и эпоха
    Числа, по которым брокер узнаёт продюсера. Новый продюсер с тем же transactional.id получает новую эпоху, и старый больше не может фиксировать.
    producerId и эпоха (epoch)
    Номер продюсера и счётчик его поколений. По паре брокер отличает живого продюсера от устаревшего и не даёт старому зафиксировать транзакцию.
    Production readiness checklist
    Список проверок перед первым запуском задания в проде. У Flink есть свой в документации.
    Prometheus
    Система мониторинга, которая сама опрашивает цели по HTTP, хранит временные ряды и проверяет правила алертов.
    PromQL
    Язык запросов Prometheus. На нём пишут панели Grafana и выражения правил.
    Provisioning
    Настройка Grafana из файлов при старте. Источники данных и дашборды описываются в YAML и JSON и хранятся в git.
    rate и increase
    Функции PromQL для растущих счётчиков. increase даёт прирост за окно, rate прирост в секунду. Падение значения считают сбросом счётчика.
    read_committed
    Уровень изоляции потребителя Kafka, при котором он видит только данные зафиксированных транзакций. read_uncommitted видит и отменённые.
    ReadWriteOnce
    Режим доступа к тому, при котором его монтируют на запись поды только одного узла.
    rebalance
    Стратегия, которая раздаёт записи по кругу всем подзадачам получателя. Выравнивает нагрузку, но теряет связь с ключом.
    Reconcile
    Проход контроллера, который сравнивает желаемое состояние из spec с фактическим и выполняет действия, сводящие разницу к нулю.
    Record
    Вид неизменяемого класса в Java. Компилятор сам создаёт конструктор, геттеры, equals и hashCode.
    RED и USE
    Две схемы выбора метрик. RED для сервиса, скорость, ошибки и длительность. USE для ресурса, загрузка, насыщение и ошибки.
    Redis
    Хранилище ключ-значение в памяти, к которому обращаются по сети. Частый выбор для внешних счётчиков в обычных сервисах.
    reduce
    Агрегация на KeyedStream, которая складывает новую запись с предыдущим результатом того же ключа и выдаёт итог на каждой записи.
    ReduceFunction
    Функция, которая сворачивает две записи одного типа в одну. Как и AggregateFunction, считает по мере прихода записей.
    ReducingState и AggregatingState
    Виды состояния с одним свёрнутым значением на ключ. Каждое add сразу объединяется с накопленным через ReduceFunction или AggregateFunction.
    Release notes
    Заметки к выпуску. Список изменений версии, которые могут задеть работающие задания.
    REPLICA IDENTITY
    Настройка таблицы PostgreSQL, которая решает, какие старые значения строки записать в журнал при UPDATE и DELETE.
    rescale
    Стратегия, которая раздаёт записи по кругу только своему подмножеству получателей. Связей меньше, чем у rebalance, и часть передач остаётся локальной.
    ResourceManager
    Компонент JobManager, который ведёт учёт слотов кластера и раздаёт их заданиям. На Kubernetes и YARN также запрашивает новые TaskManager.
    REST-интерфейс
    HTTP-интерфейс JobManager на порту 8081. Через него работает Web UI, и им же удобно пользоваться из скриптов.
    Restore-тест
    Тест, который снимает снимок состояния с одного экземпляра оператора и поднимает из него другой. Воспроизводит перезапуск задания в памяти.
    ResultFuture
    Объект, через который асинхронная функция сообщает Flink результат записи. Вызов complete отдаёт результат, completeExceptionally роняет подзадачу.
    ResultPartition
    Выходные данные одной подзадачи-отправителя. Разбита на подчасти, по одной на каждую подзадачу получателя.
    Retract
    Режим, в котором обновление занимает две записи, отмену старого значения -U и новое значение +U.
    RocksDB
    Встраиваемое хранилище ключ-значение на диске. Во Flink служит бэкендом для состояния больше памяти, каждое обращение сериализует ключ и значение.
    RowKind
    Перечисление Flink с видами изменений строки +I, -U, +U, -D.
    Runbook
    Инструкция для дежурного. На каждый алерт в ней записано, что проверить первым, как отличить причины и что делать.
    SASL
    Simple Authentication and Security Layer. Способ аутентификации в протоколе Kafka, клиент представляется до первых запросов.
    Savepoint
    Снимок состояния задания, который ты создаёшь вручную, чтобы остановить задание и продолжить его с того же места. Разбирается в уроке 13.
    Secret
    Объект Kubernetes для паролей и ключей. Под получает значение через переменную окружения или файл, и в манифест оно не попадает.
    Service
    Объект Kubernetes с постоянным именем и адресом для группы подов. Через него к подам обращаются по имени в DNS кластера.
    ServiceLoader
    Механизм Java, который находит реализации интерфейса по файлам META-INF/services.
    SharedBuffer
    Общий буфер событий оператора CEP в keyed state. Хранит каждое событие один раз, попытки ссылаются на него.
    Shuffle
    Перераспределение данных между параллельными задачами по ключу. В Spark пишет промежуточные файлы на диск между стадиями, в потоковом Flink идёт конвейером через сетевые буферы.
    SimpleCondition
    Условие шага, которое смотрит только на текущее событие. Создаётся из лямбды методом SimpleCondition.of.
    SinkWriter
    Оператор приёмника, который пишет записи во внешнюю систему. В транзакционном режиме пишет так, чтобы данные ещё не были видны.
    Sliding окно
    Окна длины size, новое начинается каждые slide. Окна пересекаются, и запись попадает в size / slide окон.
    Slot sharing
    Размещение подзадач разных вершин одного задания в одном слоте. Включено по умолчанию. Задаётся через группу, по умолчанию она называется default.
    Spark SQL и Structured Streaming
    Модуль SQL в Apache Spark и его потоковый режим, который описывает поток как растущую таблицу.
    Spark Structured Streaming
    Потоковый движок Apache Spark. Окна задаёт функциями window и session_window, а порог опоздания через withWatermark.
    Spark UI и maxOffsetsPerTrigger
    Веб-интерфейс Spark со стадиями и задачами и опция источника Kafka в Structured Streaming, которая ограничивает число смещений на один micro-batch.
    spark-operator
    Оператор Kubernetes для Spark. Описывает приложение ресурсом SparkApplication и запускает под драйвера.
    spec и status
    Две части объекта Kubernetes. В spec пользователь пишет, что хочет, в status контроллер пишет, что получилось.
    SQL Client
    Консольный клиент Flink, который выполняет SQL интерактивно или из файла.
    SQL-jar (толстый коннектор)
    Jar со всеми зависимостями коннектора, включая перепакованную в другой пакет библиотеку Kafka.
    SST-файл (Sorted String Table)
    Неизменяемый отсортированный файл данных RocksDB. Неизменяемость позволяет выгружать в чекпоинт только новые файлы.
    Standalone
    Способ запуска кластера без системы управления ресурсами. Процессы поднимаешь сам, как у нас в Docker Compose, и новых TaskManager кластер запросить не может.
    State backend
    Способ хранения рабочего состояния и снятия снимка. Во Flink 2.2 это hashmap, rocksdb и экспериментальный forst.
    State Processor API
    Библиотека Flink для чтения, записи и правки снимков пакетной программой. Подключается артефактом flink-state-processor-api.
    State V2
    Новый API состояния Flink 2.x с асинхронными методами, которые возвращают StateFuture. Включается вызовом enableAsyncState().
    StateFuture
    Результат асинхронного обращения к состоянию. Продолжение обработки задаётся методами thenAccept, thenApply, thenCompose.
    StatusWatermarkValve
    Клапан на входе задачи. Помнит watermark каждого входного канала и пропускает дальше минимум, только когда он вырос.
    stop
    Команда CLI, которая снимает savepoint и после его успеха завершает источники. Задание переходит в FINISHED.
    StreamGraph
    Логический граф программы, где каждый оператор отдельный узел. Создаётся при вызове env.execute. Клиент во Flink 2.2 отправляет на кластер именно его.
    Summary
    Тип метрики Prometheus с готовыми квантилями и числом наблюдений. Из гистограмм Flink приходит без ряда суммы.
    Task Heap
    Куча JVM для операторов и пользовательского кода. Получает остаток Total Flink Memory после всех остальных областей.
    TaskManager
    Рабочий процесс, в котором выполняются подзадачи. Похож на экзекутор Spark.
    Test harness
    Тестовая обвязка Flink, которая запускает один оператор без кластера и даёт управлять watermark, временем и состоянием. ProcessFunctionTestHarnesses создаёт её для функций.
    Testcontainers
    Библиотека, которая запускает контейнеры Docker из кода теста и убирает их после него.
    Timeout
    Предельное время на запрос асинхронного оператора, отсчитывается от постановки записи в очередь. По умолчанию истечение перезапускает задание.
    TimestampAssigner
    Функция стратегии watermark, которая достаёт метку времени из записи. Получает запись и метку, пришедшую от источника.
    TLS, SASL_SSL и SASL_PLAINTEXT
    TLS шифрует соединение. SASL_SSL это аутентификация SASL поверх TLS, вариант для прода. SASL_PLAINTEXT это аутентификация без шифрования, вариант учебного стенда.
    Tombstone
    Сообщение Kafka с ключом и пустым значением. В топиках с компактированием означает удаление ключа.
    Total Flink Memory
    Память процесса без JVM Metaspace и JVM Overhead. Из неё выделяются кучи, off-heap, сетевая и managed memory.
    Total Process Memory
    Вся память процесса TaskManager, ключ taskmanager.memory.process.size. В контейнере равна его лимиту.
    transaction.max.timeout.ms
    Настройка брокера Kafka, предел для transaction.timeout.ms. По умолчанию 15 минут.
    transaction.timeout.ms
    Настройка продюсера Kafka. Через это время брокер сам прерывает незафиксированную транзакцию. Приёмник Flink по умолчанию ставит час.
    transactional.id
    Имя транзакционного продюсера в Kafka. Flink собирает его из префикса, номера подзадачи и номера чекпоинта.
    Trigger
    Часть оконного оператора, которая решает, когда выдать результат окна и очистить ли его содержимое.
    TTL
    Срок жизни записи состояния. По его истечении запись считается удалённой.
    TTL (time-to-live)
    Срок жизни значения состояния. Задаётся через StateTtlConfig на дескрипторе и во Flink 2.2 считается только по времени обработки.
    TTL состояния
    Срок хранения ключа без обновлений. В SQL задаётся настройкой table.exec.state.ttl, значение 0 отключает очистку.
    Tumbling окно
    Окна одной длины, которые идут встык и не пересекаются. Каждая запись попадает ровно в одно окно.
    TVF (table-valued function)
    Функция, которая принимает и возвращает таблицу. TUMBLE, HOP, CUMULATE и SESSION добавляют к строкам границы окна.
    TypeInformation
    Описание типа данных во Flink. Определяется на клиенте при описании плана и создаёт сериализатор.
    uid
    Постоянный идентификатор оператора, заданный вручную. По нему Flink находит состояние оператора при восстановлении из savepoint.
    uid оператора
    Постоянный идентификатор оператора, по которому Flink находит его состояние в savepoint и чекпоинте.
    Unaligned-чекпоинт
    Режим, в котором барьер обгоняет данные в сетевых буферах, а обогнанные буферы сохраняются в чекпоинт. Чекпоинты при backpressure становятся быстрее, зато больше по объёму.
    upgradeMode
    Поле spec.job, которое задаёт, как оператор переносит состояние при апгрейде. Значения stateless, last-state и savepoint, по умолчанию stateless.
    Upsert
    Запись по ключу, которая заменяет прежнее значение, если оно есть, и добавляет новое, если нет.
    ValueState
    Вид состояния с одним значением на ключ. Методы value() и update().
    WAL (write-ahead log)
    Журнал упреждающей записи PostgreSQL. Изменение попадает в файлы данных только после записи в него.
    Watermark
    Служебный элемент потока с меткой T. Обещает операторам, что событий с меткой T и раньше больше не будет, и двигает их время события.
    Web UI
    Панель Flink в браузере. Показывает граф задания, метрики, чекпоинты и ошибки.
    Webhook и cert-manager
    Webhook проверяет ресурсы при kubectl apply. Ему нужен TLS-сертификат, который выпускает cert-manager. В стенде webhook отключён.
    Window Top-N
    Запрос с ROW_NUMBER, который выдаёт N лучших строк на окно один раз, когда окно закрылось.
    within
    Предельное время от первого до последнего события совпадения. Попытки, которые не уложились, выбрасываются и освобождают состояние.
    withWatermark в Spark
    Способ задать watermark в Spark Structured Streaming. Берёт наибольшее время события по всему запросу минус порог и применяет его с начала следующего micro-batch.
    XA-транзакция
    Стандарт распределённых транзакций для баз данных. JDBC-приёмник Flink использует его в режиме exactly-once.
    YARN
    Менеджер ресурсов из экосистемы Hadoop, на котором тоже можно запускать Flink.
    Автоскейлер (autoscaler)
    Модуль оператора, который сам меняет параллелизм вершин задания по метрикам загрузки.
    Аккумулятор (accumulator)
    Промежуточный итог AggregateFunction, например счётчик и сумма. Хранится в состоянии вместо самих записей.
    Алгоритм Chandy-Lamport
    Классический алгоритм распределённого снимка через маркеры в каналах. Подход Flink основан на нём и приспособлен к потоковым графам.
    Аннотации стабильности
    Метки Public, PublicEvolving и Experimental на классах Flink. По ним видно, какую совместимость Flink обещает между версиями. PublicEvolving между минорными версиями не обещает ничего.
    Атрибут времени
    Колонка, которую Flink считает временем записи. Бывает временем события с водяным знаком или временем обработки через PROCTIME().
    Бакет (bucket)
    Группа файлов внутри партиции таблицы Paimon. У каждого бакета таблицы с ключом своё LSM-дерево.
    Барьер чекпоинта (checkpoint barrier)
    Служебное событие с номером чекпоинта, которое источник вставляет в поток. Едет вместе с записями и делит поток на часть до чекпоинта и часть после.
    Блочный кэш (block cache)
    Кэш RocksDB в памяти для блоков данных, индексов и фильтров из SST-файлов. Во Flink общий для всех состояний слота.
    Бэкенд состояния (state backend)
    Хранилище, в котором живёт состояние операторов. Выбирается ключом state.backend.type.
    Вершина (job vertex)
    Узел графа задания в Web UI. Это один оператор или цепочка операторов, сцепленных в одну задачу.
    Водяной знак (watermark)
    Отметка в потоке, которая сообщает, что записей старше этого времени больше не ожидается. Закрывает окна. Подробно в уроке 5.
    Временной ряд (time series)
    Последовательность значений одной метрики с одним набором меток во времени. Единица хранения в Prometheus.
    Время обработки (processing time)
    Показания часов машины, на которой оператор видит запись. Ждать ничего не нужно, но результат зависит от скорости и перезапусков.
    Время события (event time)
    Момент, когда событие случилось, записанный в самой записи. У платежа это поле eventTimeMs. Результат по нему воспроизводим при любом повторном прогоне.
    Выбор лидера (leader election)
    Механизм, по которому из нескольких JobManager активным становится один. В Kubernetes HA замком служит ConfigMap кластера.
    Выдержка for, pending и firing
    Состояния алерта в Prometheus. Pending означает, что условие выполнено, но держится меньше выдержки for. Firing означает, что выдержка пройдена.
    Выравнивание (alignment)
    Блокировка входного канала, по которому барьер уже пришёл, до прихода барьеров по остальным каналам. Нужно для exactly-once в состоянии.
    Генератор watermark (WatermarkGenerator)
    Объект, который видит метки записей и периодически выпускает watermark. Источник создаёт по генератору на каждый сплит.
    Гистограмма (Histogram)
    Метрика Flink, которая хранит распределение значений. В REST видна как набор чисел с суффиксами _min, _max, _p99 и другими.
    Горячий ключ
    Значение ключа с непропорционально большой долей потока. В PayGuard это продавец m-0000 с 30 % платежей.
    Группа ключей (key group)
    Единица разбиения ключей и состояния. Номер группы равен murmurHash от hashCode ключа по модулю maxParallelism. При смене параллелизма группы переходят между подзадачами целиком.
    Группа метрик (MetricGroup)
    Узел дерева, в котором регистрируются метрики. Группы идут от процесса к заданию, задаче и оператору. Метод addGroup добавляет свои уровни.
    Группа потребителей (consumer group)
    Имя, под которым Kafka запоминает закоммиченные смещения читателя. Flink коммитит в неё смещения после завершения чекпоинта для мониторинга.
    Данные в полёте (in-flight data)
    Записи, которые лежат в сетевых буферах между подзадачами и ещё не обработаны получателем.
    Дашборд (dashboard)
    Набор панелей Grafana на одном экране. У PayGuard он описан в payguard.json.
    Двухфазная агрегация (local-global)
    Агрегация в два шага. Сначала частичные итоги по частям ключа, потом их сложение по настоящему ключу. Во Flink SQL встроена как LocalWindowAggregate и GlobalWindowAggregate.
    Двухфазная фиксация (two-phase commit)
    Протокол из подготовки и фиксации. Во Flink подготовка идёт на барьере чекпоинта, фиксация после его завершения.
    Дедупликация
    Удаление повторов одной и той же записи. В PayGuard делается по paymentId с памятью на час.
    Дескриптор состояния (MapStateDescriptor)
    Описание состояния, имя и типы ключа и значения. По нему оператор находит состояние, поэтому один и тот же дескриптор передают в broadcast и в функцию.
    Дескриптор состояния (state descriptor)
    Описание состояния с именем и типом значения. По имени Flink находит состояние при восстановлении, на дескрипторе включается TTL.
    Детерминированный идентификатор
    Идентификатор, который вычисляется из содержимого записи и совпадает при повторной обработке. У нас alertId из правила и платежа.
    Детерминированный код
    Код, который при одинаковом входе всегда даёт одинаковый результат. Не читает часы, случайные числа и внешние системы.
    Динамическая таблица (dynamic table)
    Таблица, содержимое которой меняется во времени. Определена над потоком и внутри не материализована.
    Дозаполнение (backfill)
    Чтение журнала между нижней и верхней границами чанка, чтобы влить изменения, случившиеся во время запроса.
    Допуск беспорядка (out-of-orderness)
    Насколько запись может отстать от самой свежей. В forBoundedOutOfOrderness watermark равен максимуму меток минус допуск минус 1 мс.
    Задание (job)
    Программа Flink, отправленная на кластер. В потоковом режиме работает, пока её не остановят.
    Идемпотентность
    Свойство операции, при котором повтор не меняет результат. Повторное чтение идемпотентно, повторная отправка SMS нет.
    Инкрементальный снимок (incremental snapshot)
    Начальное чтение таблицы частями по первичному ключу. Чанки читаются параллельно и без глобальной блокировки на чтение.
    Инкрементальный чекпоинт
    Чекпоинт, который выгружает только файлы, появившиеся после прошлого подтверждённого чекпоинта, и ссылается на уже выгруженные.
    Кардинальность
    Число разных временных рядов. Растёт с каждым новым значением метки и определяет, сколько памяти нужно Prometheus.
    Кардинальность ключей
    Число разных значений ключа, которое увидит оператор за время жизни состояния. Главный множитель размера состояния.
    Квантификатор (quantifier)
    Сколько событий принимает шаг. Например times(3), timesOrMore(3), oneOrMore(), optional().
    Клиент (client)
    Программа, которая готовит задание и отправляет его на кластер. Это JVM команды flink run или твоего Java-приложения. В исполнение задания не входит.
    Компактируемый топик (compacted topic)
    Топик Kafka с cleanup.policy=compact. Брокер хранит для каждого ключа как минимум последнее значение.
    Компакция (compaction)
    Фоновое слияние SST-файлов RocksDB. Убирает перезаписанные версии и удалённые ключи, на этом шаге работает очистка по TTL.
    Коннектор (connector)
    Библиотека, связывающая Flink с внешней системой. Выходит отдельно от Flink и собирается под его конкретную версию.
    Консольные клиенты Kafka
    Скрипты kafka-console-producer.sh и kafka-console-consumer.sh из поставки Kafka. Первый отправляет каждую строку ввода сообщением, второй печатает сообщения топика.
    Координатор транзакций (transaction coordinator)
    Роль брокера Kafka, которая выдаёт продюсерам producerId и эпоху, ведёт их транзакции и фиксирует или отменяет их.
    Координатор чекпоинтов (CheckpointCoordinator)
    Компонент JobManager, который запускает чекпоинты, собирает подтверждения подзадач, хранит завершённые чекпоинты и восстанавливает из них задание.
    Корзина (bucket)
    Отрезок времени фиксированной длины, по которому копится сумма. Вместо всех событий хранится одно число на отрезок.
    Кредит (credit)
    Число свободных буферов, о котором получатель сообщил отправителю. Отправитель шлёт буфер с данными, только если кредит больше нуля.
    Куча (heap)
    Область памяти JVM для объектов Java. Её заполнение и время сборки мусора показывает дашборд.
    Кэш (cache)
    Копия данных рядом с кодом, чтобы не ходить за ними каждый раз. Здесь это словарь в памяти подзадачи со сроком жизни записей.
    Лаг Kafka (consumer lag)
    Сколько записей в партиции ещё не прочитано потребителем. Метрика клиента Kafka, во Flink живёт в TaskManager вместе с источником.
    Логическое декодирование (logical decoding)
    Механизм PostgreSQL, который превращает WAL в поток изменений строк для внешнего читателя. Требует wal_level = logical.
    Максимальный параллелизм (maxParallelism)
    Число групп ключей оператора и верхняя граница его параллелизма. Без явной настройки вычисляется из параллелизма, минимум 128. После первого запуска не меняется без потери состояния.
    Манифест (manifest)
    Файл метаданных Paimon, который перечисляет добавленные и убранные файлы данных.
    Менеджер буферов записи (write buffer manager)
    Компонент RocksDB, ограничивающий общую память всех memtable. Во Flink учитывает её в блочном кэше слота.
    Метка времени (timestamp)
    Число миллисекунд, привязанное к записи во Flink. У записи Kafka есть своя метка, по умолчанию её ставит продюсер в момент отправки.
    Метрика (metric)
    Именованное числовое значение, которое Flink или твой код обновляет во время работы. Метрики собирают в системы мониторинга и строят по ним графики и алерты.
    Механизм слияния (merge engine)
    Правило Paimon, по которому записи с одним ключом дают одну строку. По умолчанию deduplicate, он оставляет последнюю.
    Надстройка Compose (override file)
    Дополнительный файл Compose, который сливается с основным. Переменная COMPOSE_FILE перечисляет файлы через двоеточие.
    Непрерывный запрос (continuous query)
    Запрос над динамической таблицей, который не завершается и обновляет результат по мере прихода данных.
    Область метрики (scope)
    Путь группы в дереве метрик. Системную область задаёт Flink, пользовательскую ты добавляешь через addGroup. Репортер Prometheus строит имя по логической области, из названий уровней.
    Обогащение (enrichment)
    Добавление к записи потока данных из внешнего источника, например уровня риска продавца из базы.
    Обратное давление (backpressure)
    Ситуация, когда следующий оператор не успевает и сдерживает предыдущий. Подробно в уроке 4.
    Окно (window)
    Оператор, который собирает записи за отрезок времени и выдаёт результат, когда watermark проходит конец отрезка. Тема урока 6.
    Оператор (operator)
    Один шаг обработки в задании, например источник, фильтр или окно.
    Оператор с двумя входами (two-input operator)
    Оператор, который читает два потока, например после connect. Его watermark равен минимуму watermark двух входов.
    Опрос (scrape)
    Запрос Prometheus к HTTP-адресу цели за текущими значениями метрик. Частоту задаёт scrape_interval.
    Отставание по времени события (currentEmitEventTimeLag)
    Метрика источника, разница между часами TaskManager и временем события последней выданной записи.
    Память фреймворка (framework memory)
    Куча и off-heap память под внутренние нужды TaskManager, по 128 МиБ. В слоты не раздаётся.
    Память экзекутора Spark
    spark.executor.memory задаёт кучу экзекутора, spark.executor.memoryOverhead память вне кучи. Вместе они дают размер контейнера.
    Параллелизм (parallelism)
    Число подзадач вершины. Задаётся в коде, флагом -p или ключом parallelism.default.
    Партиция (partition)
    Часть топика Kafka с собственным порядком записей. Единица параллельного чтения.
    Перекос данных (data skew)
    Неравномерная раскладка записей по подзадачам, обычно из-за ключа, который встречается намного чаще других.
    Перекос ключей (data skew)
    Неравная нагрузка подзадач после keyBy. Чаще всего возникает из-за горячего ключа, на который приходится большая доля записей.
    Переменная группы и метка (label)
    Пара ключ и значение, привязанная к группе метрик, например имя оператора. Репортер Prometheus превращает каждую переменную в метку ряда.
    Плавающий буфер (floating buffer)
    Буфер из общего запаса входного шлюза, который канал берёт, когда у отправителя накопилась очередь. Во Flink 2.2.1 их 8 на шлюз.
    Планировщик (planner)
    Компонент Flink, который превращает текст SQL в граф операторов и оптимизирует его.
    Побочный выход (side output)
    Дополнительный выход оператора со своим типом. Запись попадает туда по метке OutputTag.
    Повторное использование объектов (object reuse)
    Ключ pipeline.object-reuse. Убирает копирование записи между операторами цепочки. Опасен для кода, который хранит или меняет входные объекты.
    Под (pod)
    Наименьшая единица запуска в Kubernetes. Один или несколько контейнеров с общей сетью и томами. У нас каждый JobManager и TaskManager живёт в своём поде.
    Подзадача (subtask)
    Один параллельный экземпляр оператора. При параллелизме 4 у оператора четыре подзадачи.
    Подчасть (subpartition)
    Очередь буферов внутри ResultPartition, предназначенная одной подзадаче получателя.
    Позднее событие (late event)
    Запись, чья метка времени не больше текущего watermark оператора. Watermark сам её не удаляет, решение принимает оператор, например окно.
    Позднее срабатывание (late firing)
    Повторная выдача итога окна, вызванная опоздавшей записью в пределах allowedLateness. Содержит полный обновлённый итог.
    Постмортем (postmortem)
    Документ о разборе аварии. В нём хронология, влияние, корневая причина, усилившие факторы и действия, чтобы авария не повторилась. Виноватых в нём не ищут.
    Правило алерта (alerting rule)
    Выражение PromQL с именем, метками и выдержкой. Пока выражение возвращает ряды, алерт активен.
    Предкоммит (pre-commit)
    Первая фаза. Writer сбрасывает буферы, помечает транзакцию подготовленной и передаёт её описание в Committer.
    Преобразование (transformation)
    Шаг в плане задания, например map или process. Из таких шагов Flink позже строит граф.
    Простой (idleness)
    Состояние входа, по которому дольше таймаута нет записей. Простаивающий вход не участвует в минимуме watermark. Включается через withIdleness.
    Простой входа (idleness)
    Признак входа, по которому дольше таймаута нет записей. Простаивающий вход не участвует в минимуме watermark. Включается через withIdleness.
    Профиль Compose
    Метка службы в Docker Compose. Служба с профилем стартует, только если этот профиль включён.
    Пул потоков (ExecutorService)
    Набор рабочих потоков Java, которые по очереди выполняют переданные задачи. В AlertEnrichment он выполняет блокирующие запросы к базе.
    Разбиение BROADCAST
    Способ раздачи записей, при котором каждая запись копируется во все каналы к следующему оператору. Его ставит BroadcastPartitioner.
    Разделённое хранение состояния (disaggregated state)
    Режим Flink 2.x, в котором основная копия состояния лежит во внешнем хранилище вроде S3, а локальный диск служит кэшем.
    Раннер (runner)
    Машина, на которой GitHub Actions выполняет задания.
    Режим Application
    Кластер создаётся на одно приложение, а main выполняется в JobManager. Jar уже лежит на classpath кластера.
    Режим BATCH
    Режим выполнения для конечных данных. Программа завершается, когда вход прочитан. State Processor API работает только в нём.
    Режим per-job
    Прежний режим развёртывания, удалённый во Flink 2.0. Вместо него используется Application.
    Режим Session
    Кластер работает независимо от заданий и обслуживает несколько сразу. main выполняется в клиенте.
    Режимы ordered и unordered
    Порядок выдачи результатов асинхронного оператора. Ordered выдаёт в порядке входа, unordered по мере готовности, но не через границу watermark.
    Репортер (reporter)
    Подключаемый модуль Flink, который выносит метрики во внешнюю систему. Создаётся в каждом JobManager и TaskManager при старте.
    Родительский загрузчик (parent-first)
    Порядок загрузки, при котором класс сначала ищется в lib/ кластера. Для org.apache.flink. действует всегда.
    Сборка мусора (GC)
    Работа JVM по освобождению памяти от ненужных объектов. Долгие паузы GC останавливают обработку записей.
    Семейство колонок (column family)
    Отдельное пространство ключей внутри RocksDB со своими memtable. Во Flink каждое состояние оператора это своё семейство колонок.
    Сериализатор (TypeSerializer)
    Объект, который переводит записи в байты и обратно и умеет копировать их.
    Сериализация
    Превращение объекта в байты, чтобы передать его по сети или сохранить. Функции Flink передаются на TaskManager через сериализацию Java, поэтому их поля обязаны быть Serializable.
    Сериализация Java (Serializable)
    Встроенный в Java способ превратить объект в байты. Через неё функции задания уезжают с клиента на TaskManager, поэтому всё, на что ссылаются их поля, должно её поддерживать.
    Сессионное окно (session window)
    Окно без заранее известных границ. Закрывается, когда между записями ключа проходит пауза дольше заданной.
    Сетевая память (network memory)
    Direct-память под сетевые буферы между подзадачами. Доля 0,1 от Total Flink Memory, не меньше 64 МиБ.
    Сетевой буфер (network buffer)
    Участок памяти, куда складываются записи перед отправкой другому TaskManager. Уходит заполненным или по таймауту.
    Слияние таймеров (timer coalescing)
    Округление времени таймеров, чтобы много событий одного ключа делили один таймер. Уменьшает число таймеров и размер снимка.
    Слот (task slot)
    Единица ресурсов TaskManager, в которой выполняются подзадачи. Их число задаёт ключ taskmanager.numberOfTaskSlots.
    Слот репликации (replication slot)
    Объект на сервере PostgreSQL, который хранит позицию читателя и запрещает удалять журнал после неё. Живёт независимо от подключения.
    Смежность (contiguity)
    Правило, какие события можно пропустить между шагами шаблона. Строгая задаётся next, ослабленная followedBy, недетерминированная ослабленная followedByAny.
    Смещение (offset)
    Порядковый номер записи в партиции Kafka. По нему потребитель знает, откуда читать дальше.
    Снимок сериализатора (serializer snapshot)
    Описание формата, в котором записано состояние. Лежит в снимке рядом с данными. По нему новый сериализатор решает, совместим ли он со старыми байтами.
    Снимок таблицы Paimon
    Опубликованное состояние таблицы, файл snapshot/snapshot-N со ссылками на схему и манифесты.
    Соль (salting)
    Приём против перекоса. К горячему ключу дописывают случайный суффикс, считают частичные итоги, а потом сливают их во второй фазе.
    Состояние (state)
    Данные, которые оператор хранит между записями, например счётчик платежей карты.
    Состояние без ключа (operator state)
    Состояние, привязанное к подзадаче оператора, например список партиций источника. При rescale раздаётся по кругу.
    Состояние оператора (operator state)
    Состояние, привязанное к подзадаче, без ключа. В нём асинхронный оператор хранит записи своей очереди для чекпоинта.
    Состояние с ключом (keyed state)
    Состояние оператора после keyBy, разложенное по ключам. Функция видит только значение текущего ключа, Flink переключает ключ перед каждой записью и каждым таймером.
    Сплит (split)
    Кусок работы источника, который читает одна подзадача. У источника Kafka один сплит равен одной партиции топика.
    Стратегия watermark (WatermarkStrategy)
    Описание того, как задание понимает время. Создаёт TimestampAssigner и генератор watermark, передаётся в fromSource.
    Стратегия перезапуска (restart strategy)
    Правило, по которому Flink решает, перезапускать ли задание после сбоя и с какой задержкой.
    Стратегия пропуска (AfterMatchSkipStrategy)
    Что делать с остальными попытками после найденного совпадения. NO_SKIP оставляет все, skipPastLastEvent выбрасывает начатые до конца совпадения.
    Стратегия распределения (partitioner)
    Правило на ребре графа, которое выбирает подзадачу-получателя для каждой записи. Имя стратегии Web UI пишет на ребре, например HASH или REBALANCE.
    Схема десериализации (DeserializationSchema)
    Класс, превращающий байты записи источника в объект Java. Работает внутри источника, до того как запись получит метку времени.
    Счётчик (Counter)
    Метрика Flink, которая только растёт. В пробе считает поздние события.
    Таймаут выравнивания
    Ключ execution.checkpointing.aligned-checkpoint-timeout. Чекпоинт начинается выровненным и переключается в unaligned, если выравнивание длится дольше.
    Таймер (timer)
    Отложенный вызов оператора для конкретного ключа и окна. Таймер по времени события срабатывает, когда его догоняет watermark.
    Теневая копия (shadow copy)
    Способ обновления, при котором новый кластер поднимают рядом со старым, запускают задание из savepoint и гасят старый после проверки.
    Тест топологии
    Тест, который строит граф задания и проверяет его свойства, ничего не запуская. Так называем его в этом курсе, отдельного термина у Flink нет.
    Тестовый стенд (harness)
    Обёртка из тестового jar Flink, которая запускает один оператор в JVM теста и позволяет подавать ему записи и watermark.
    Толстый jar (fat jar)
    Один файл с твоим кодом и всеми его зависимостями. Его собирает плагин Shadow для Gradle.
    Топик (topic)
    Именованный журнал в Kafka, разбитый на партиции.
    Транзакция Kafka
    Группа записей одного продюсера, которая становится видна потребителям read_committed целиком после фиксации или не становится видна вовсе.
    Транзитивная зависимость
    Библиотека, которая попала в проект потому, что её требует другая зависимость.
    Узкое место (bottleneck)
    Первая вершина по ходу потока, которая занята почти всю секунду и сама никого не ждёт. Ограничивает пропускную способность всего задания.
    Файл _metadata
    Файл в папке chk-N, который пишет JobManager. Содержит состояния операторов по uid и ссылки на файлы с данными.
    Фильтр Блума (Bloom filter)
    Компактная структура, которая быстро отвечает, что ключа в файле точно нет. Позволяет RocksDB не читать лишние SST-файлы.
    Формат canonical
    Формат savepoint, единый для всех бэкендов состояния. Выбирается по умолчанию, позволяет сменить бэкенд и лучше всего переживает эволюцию схемы.
    Формат native
    Формат savepoint, родной для бэкенда, у RocksDB это SST-файлы. Быстрее создаётся и восстанавливается, но не позволяет сменить бэкенд.
    Хаос-тестирование (chaos testing)
    Плановые отказы компонентов на работающей системе с проверкой, что гарантии сохраняются и система восстанавливается сама.
    Цепочка операторов (operator chain)
    Операторы, которые работают в одном потоке и передают записи вызовом метода, без сети. Разрывается при keyBy, разном параллелизме или разных группах slot sharing.
    Частичное совпадение (partial match)
    Начатая, но ещё не законченная попытка совпасть с шаблоном. Хранится в состоянии оператора, пока не завершится, не оборвётся или не истечёт.
    Чекпоинт (checkpoint)
    Согласованный снимок состояния, который Flink снимает автоматически. Подробно в уроке 11.
    Чтение в прошлое (time travel)
    Запрос к одной из сохранённых старых версий таблицы.
    Шаблон (pattern)
    Описание искомой последовательности из именованных шагов с условиями, квантификаторами, смежностью и ограничением по времени.
    Шаг шаблона
    Именованная часть шаблона, например probes или cash-out. По имени шага функция совпадения получает принятые в него события.
    Широковещательный поток (BroadcastStream)
    Поток, каждая запись которого доставляется во все подзадачи следующего оператора. Получается вызовом broadcast с дескрипторами состояния.
    Эволюция схемы (schema evolution)
    Изменение типа состояния между версиями программы с сохранением данных. Для POJO и Avro Flink мигрирует состояние сам.
    Эксклюзивный буфер (exclusive buffer)
    Буфер, закреплённый за одним удалённым каналом. Во Flink 2.2.1 их 2 на канал.
    Ядовитая запись (poison pill)
    Запись, на которой обработка падает при каждой попытке и из-за которой задание после перезапуска снова возвращается к той же ошибке.