Курс по Apache Flink 2.2 для Java-разработчика
Flink в проде
За 21 урок ты собираешь PayGuard, антифрод для потока платежей, и доводишь его от первого задания до Kubernetes, мониторинга и разбора аварий.
Что ты получишь
Курс рассчитан на разработчика, который уверенно пишет на Java и работал со Spark, но Flink ещё не трогал. Каждая тема сразу попадает в проект. К концу у тебя будет рабочая система с точной обработкой без дублей, правилами, которые меняются без перезапуска, запросами на SQL, сквозными тестами, дашбордом в Grafana и выкаткой через оператор в Kubernetes.
Теория
Как механизм устроен внутри, с анимациями и симуляторами, которые можно покрутить руками.
Исходники
Объяснения устройства Flink подтверждены кодом Flink со ссылкой на строки в теге release-2.2.1 и на раздел документации.
Практика
Команды, файлы и зависимости целиком. Код вставлен из снапшотов проекта, которые собирались и запускались.
Задание и ответ
Задача на проект и готовое решение для сверки. Ответ скрыт, пока ты сам его не откроешь.
Глоссарий
Отдельная вкладка в каждом уроке. Первое упоминание термина на вкладке подсвечено, подсказка открывается по наведению.
Настоящие цифры
Длительность чекпоинтов, дубли после падений и размеры состояния сняты с живого кластера на этой нагрузке.
Как проходить
- Читай вкладку «Теория» и решай квиз в её конце.
- Проходи «Практику» у себя в терминале. Каждый урок продолжает предыдущий.
- Сделай «Задание» сам, потом сверь с вкладкой «Ответ».
- Отставший или сломанный проект можно заменить папкой
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 мы её разберём отдельно.
Справка
Spark и Flink, словарь перевода
Если ты думаешь в терминах Spark, эта таблица подскажет ближайший аналог во Flink и отметит, где аналогия подводит. Подробности в указанных уроках.
Устройство кластера
| Spark | Flink | Где аналогия подводит | Урок |
|---|---|---|---|
| Driver | JobManager и клиент | Драйвер живёт всё время работы приложения и исполняет твой код. Клиент Flink только строит граф и отправляет его, дальше работает JobManager. | 2 |
| Executor | TaskManager | Экзекутор обычно привязан к одному приложению. TaskManager в режиме session обслуживает несколько заданий. | 2 |
| Core и task slot | Слот (task slot) | Слот делит память TaskManager, а процессорные ядра между слотами не делятся жёстко. Несколько операторов обычно живут в одном слоте. | 2 |
| DAG из stages | JobGraph и ExecutionGraph | Стадий с границами на shuffle нет. Операторы склеиваются в цепочки и работают одновременно. | 2, 4 |
| Task | Подзадача (subtask) | Task в Spark запускается на порцию данных и завершается. Подзадача потокового задания живёт, пока живёт задание. | 2 |
| spark-submit | flink run | Понятие близкое. В проде запуск чаще идёт через оператор Kubernetes. | 2, 20 |
Обработка данных
| Spark | Flink | Где аналогия подводит | Урок |
|---|---|---|---|
| Micro-batch в Structured Streaming | Обработка по одной записи | Записи идут через операторы сразу, порциями собираются только сетевые буферы. Задержка определяется буферами и логикой окна. | 1 |
| DataFrame и Dataset | DataStream и Table | DataStream описывает поток событий, операции со временем и состоянием там основные. | 3, 14 |
repartition, shuffle | keyBy, rebalance | Данные идут по сети непрерывно, без файлов shuffle на диске. Медленный получатель притормаживает отправителя. | 4 |
spark.sql.shuffle.partitions | Parallelism и max parallelism | Ключи распределяются по key groups, их число фиксируется при первом запуске с состоянием. | 4, 13 |
| Dynamic allocation | Adaptive Scheduler и Autoscaler | Смена параллелизма требует перезапуска задания с восстановлением состояния. | 20 |
| Broadcast variable | Broadcast state | Broadcast state меняется на лету, новые значения приходят отдельным потоком. | 8 |
foreachBatch | Sink | Записи уходят постоянно, а граница транзакции определяется чекпоинтом. | 12 |
Время и состояние
| Spark | Flink | Где аналогия подводит | Урок |
|---|---|---|---|
withWatermark | WatermarkStrategy | Watermark во Flink идёт внутри потока, его считает каждая партиция источника. Оператор с несколькими входами берёт минимум из них. | 5 |
| Trigger по времени | Срабатывание окна по watermark | Результат выходит, когда watermark прошёл конец окна, без расписания запусков. | 5, 6 |
mapGroupsWithState | KeyedProcessFunction | Таймеры здесь на каждый ключ, и срабатывают сразу по времени события или по часам машины. | 7 |
| State Store на RocksDB | State backend | Состояние лежит рядом с подзадачей и не требует обращения к внешней системе при обработке записи. | 7, 11, 19 |
| Checkpoint location | Чекпоинт | Spark пишет состояние в конце микробатча. Flink снимает согласованный срез на лету с помощью барьеров. | 11 |
| Запись в checkpoint при смене кода | Savepoint | Savepoint запускается вручную и переносим. Совместимость держится на uid операторов и сериализаторах. | 13 |
Эксплуатация
| Spark | Flink | Где аналогия подводит | Урок |
|---|---|---|---|
| Spark UI | Web UI и REST | Здесь ты смотришь на живой процесс. Главные показатели занятость, ожидание следующего оператора и watermark. | 2, 18 |
| Executor OOM | Модель памяти TaskManager | Память разбита на области с разными пределами, а RocksDB живёт вне кучи JVM. | 19 |
| Data skew в join | Перекос по ключу | Лекарство похожее (соль), но перекошенная подзадача тормозит весь граф через backpressure. | 4, 18 |
| Spark on Kubernetes | Flink Kubernetes Operator | Оператор сам делает savepoint при обновлении и перезапускает задание из него. | 20 |
Урок 1 · Основы
Зачем Flink, если есть Spark, и как поднять рабочий стенд
После урока ты сможешь объяснить, в каких задачах Flink выигрывает у Spark, и запустишь первое задание, которое читает поток платежей из Kafka.
Задача, ради которой всё затевается
Мошенник получил данные карты и проверяет их серией мелких платежей. Если система заметит серию через пять минут, деньги уже ушли. Если через секунду, банк успевает отклонить следующий платёж. Весь курс мы строим такую систему и называем её PayGuard.
У этой задачи три свойства, которые определяют выбор инструмента. Данные не заканчиваются, поэтому нельзя дождаться конца файла и посчитать результат. Решение зависит от истории карты за последние минуты, поэтому система обязана помнить прошлое. Ошибка стоит денег в обе стороны, поэтому потерянный или задвоенный платёж недопустим даже при падении сервера.
Apache Flink создан под такие условия. Это движок обработки потоков с состоянием, то есть программа на нём получает события по одному и держит между ними данные, которые переживают падения.
Шаттл и траволатор
Представь аэропорт. До выхода на посадку можно добраться автобусом-шаттлом, который отправляется каждые пять минут и забирает всех, кто успел подойти. А можно встать на траволатор, и он повезёт тебя в ту же секунду, как ты на него шагнул.
Spark Structured Streaming в обычном режиме работает как шаттл. Он копит записи, запускает маленькую пакетную задачу (micro-batch) и выдаёт результат всей порции разом. Flink работает как траволатор. Его операторы запущены постоянно, и запись идёт через них сразу после появления в источнике.
Сдвинь ползунок и посмотри, как интервал шаттла влияет на задержку. Модель упрощённая, в ней нет времени на саму обработку, зато видна причина разницы.
Гонка двух моделей
Нажми кнопку, и 40 событий за 10 секунд модельного времени поедут по обеим дорожкам.
Из этой разницы не следует, что Flink всегда быстрее по записям в секунду. На больших объёмах пакетная обработка очень эффективна, и Spark силён именно там. У Flink задержку определяют логика задания и сетевые буферы, у micro-batch ещё и интервал запуска.
В исходниках
Посмотрим, что такое запущенная подзадача Flink. Метод processInput вызывается в цикле на протяжении всей её жизни. Он просит следующую запись у обработчика входа и смотрит на статус. Понятия «порция» или «граница пакета» здесь нет.
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 ограничивает это ожидание.
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
Документация: Concepts overview, Flink Architecture
Четыре вещи, которые Flink берёт на себя
Написать цикл чтения из Kafka можно и на обычной Java. Flink ценен тем, что решает трудные части за тебя, и курс построен вокруг них.
- Время события. Платёж мог прийти с опозданием из-за сети, а считать его надо в той минуте, когда он случился. Об этом уроки 5 и 6.
- Состояние. Счётчик платежей карты живёт внутри задания, может занимать терабайты и читается без обращения к внешней базе. Об этом уроки 7 и 19.
- Восстановление без потерь. Flink регулярно снимает согласованный снимок всего задания и после падения продолжает с него. Об этом уроки 11 и 12.
- Изменение на ходу. Задание можно остановить со снимком, обновить код, сменить параллелизм и продолжить с того же места. Об этом урок 13.
Когда Flink не нужен
Ночной отчёт по таблице в озере данных Spark посчитает проще и дешевле. Переход не окупится и тогда, когда задержка в минуту никому не мешает и команда уже знает Spark. Простое преобразование топика без состояния часто проще сделать на Kafka Connect или маленьком консьюмере. Flink оправдан там, где одновременно нужны низкая задержка, состояние и корректность при сбоях.
Что мы построим
PayGuard читает платежи из Kafka, чистит их и проверяет правилами, а алерты отправляет обратно в Kafka. Цепочка будет расти от урока к уроку. Сегодня работает только левая часть схемы, источник и печать в лог.
Почему твой конфиг молчит
В папке проекта лежит твой Makefile с образом flink:latest и файлом flink-conf.yaml. Если выполнить make start, контейнеры поднимутся, в логе JobManager не будет ошибок, и при этом REST-интерфейс покажет "taskmanagers":0. Процесс TaskManager исполняет задания, и кластер без него не умеет ничего. Причин две. Первая в том, что flink:latest на момент проверки это Flink 2.3.0, а вторая в самом файле.
В исходниках
Flink 2.x читает настройки из файла с другим именем. Константа задаёт его жёстко.
public static final String FLINK_CONF_FILENAME = "config.yaml";
При загрузке берётся только этот файл, старое имя в коде больше не встречается.
// 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 подключается не туда, и ни один из процессов не считает это фатальной ошибкой.
Документация: Configuration, Docker Setup
Если перенести те же настройки в правильный файл, всплывут другие проблемы. Таблица показывает, что произойдёт с каждой строкой.
| Строка в твоём файле | Что с ней не так | Как правильно во 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 падает при старте и пишет, что вычисленный размер сетевой памяти равен нулю.
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 однажды обновится до версии, под которую твоего коннектора ещё нет. В курсе версия закреплена везде, от образа до зависимостей 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 при первом запуске.
rootProject.name = "payguard"
Второй файл описывает сборку и зависимости.
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.
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.
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, часть приходит с опозданием, часть дублируется, часть битая. Эта грязь понадобится нам в следующих уроках.
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;
}
}
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);
}
}
}
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;
}
}
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.
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 внутри проекта.
- Создай файл с правильным именем и ключами Flink 2.x. Сохрани смысл настроек, это 4 слота, параллелизм 2, состояние в куче и каталоги чекпоинтов в
/tmp. - Подними JobManager и TaskManager обычным
docker runв отдельной сети, смонтировав файл в/opt/flink/conf/config.yaml. Образ бериflink:2.2.1-java17. - Докажи, что настройки применились, запросами к 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 нет.
Ответ
В новом файле настройки сгруппированы вложенным YAML, как в родном config.yaml. Из памяти оставлен один ключ process.size, остальные области Flink вычислит сам. Ключи bind-host и rest.bind-address обязательны, потому что без них процесс слушает только localhost и TaskManager из соседнего контейнера до него не достучится.
# Старый flink-conf.yaml, переписанный на ключи Flink 2.x.
jobmanager:
rpc:
address: jobmanager
port: 6123
bind-host: 0.0.0.0
memory:
process:
size: 1600m
taskmanager:
bind-host: 0.0.0.0
numberOfTaskSlots: 4
memory:
process:
size: 1728m
rest:
bind-address: 0.0.0.0
address: 0.0.0.0
parallelism:
default: 2
state:
backend:
type: hashmap
execution:
checkpointing:
dir: file:///tmp/flink-checkpoints
savepoint-dir: file:///tmp/flink-savepoints
web:
submit:
enable: true
cancel:
enable: true
Запуск из корня проекта. Псевдоним jobmanager нужен, чтобы TaskManager нашёл его по адресу из конфига.
docker network create l1net
docker run -d --name l1jm --network l1net --network-alias jobmanager -p 18081:8081 \
-v "$PWD/conf/config.yaml:/opt/flink/conf/config.yaml:ro" flink:2.2.1-java17 jobmanager
docker run -d --name l1tm --network l1net \
-v "$PWD/conf/config.yaml:/opt/flink/conf/config.yaml:ro" flink:2.2.1-java17 taskmanager
curl -s localhost:18081/overview | jq -c '{tm: .taskmanagers, slots: .["slots-total"], version: .["flink-version"]}'
curl -s localhost:18081/jobmanager/config \
| jq -c '[.[] | select(.key | test("state.backend.type|numberOfTaskSlots|checkpointing.dir|parallelism.default"))]'
{"tm":1,"slots":4,"version":"2.2.1"}
[{"key":"parallelism.default","value":"2"},{"key":"taskmanager.numberOfTaskSlots","value":"4"},{"key":"execution.checkpointing.dir","value":"file:///tmp/flink-checkpoints"},{"key":"state.backend.type","value":"hashmap"}]
В логе JobManager появится строка config-parser-utils.sh: line 42: /opt/flink/conf/config.yaml: Read-only file system. Скрипт запуска пытается дописать файл, а смонтирован он только для чтения. На работу это не влияет, но переменная FLINK_PROPERTIES при таком монтировании не применится.
Убери за собой командами docker rm -f l1jm l1tm и docker network rm l1net. В проекте мы продолжим использовать FLINK_PROPERTIES, потому что все настройки видны в одном файле Compose.
Глоссарий урока
- 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, это написано прямо в разделе про архитектуру.
Со Spark роли похожи только частично, и таблица показывает, где сходство обманывает.
| Вопрос | Spark | Flink |
|---|---|---|
| Где выполняется main | В Driver, на твоей машине или в кластере по режиму | В клиенте при режиме Session, в JobManager при режиме Application |
| Кто планирует работу | Driver | JobMaster, отдельный на каждое задание |
| Кто исполняет | Executor | TaskManager |
| Единица исполнения | Task, живёт одну стадию и завершается | Подзадача, поток работает всю жизнь задания |
| Единица ресурсов | Ядра экзекутора | Слот, он разделяет только управляемую память и не изолирует CPU |
| Если отправитель отключился | Driver обязан жить до конца приложения | Клиент при режиме detached может уйти сразу после отправки |
Путь задания шаг за шагом
Жми кнопку и смотри, кто с кем говорит, когда ты запускаешь flink run для нашего задания с параллелизмом 2.
В исходниках
Начнём с клиента, и здесь есть сюрприз. Страница Deployment в документации до сих пор говорит, что клиент отправляет на кластер JobGraph. Посмотри, что передаётся в submitJob в коде версии 2.2.1. Команда flink run без флага --target выбирает как раз этого исполнителя.
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)
resultingConfiguration.set(DeploymentOptions.TARGET, RemoteExecutor.NAME);
DynamicPropertiesUtil.encodeDynamicProperties(commandLine, resultingConfiguration);
return resultingConfiguration;
Клиент сериализует пользовательские объекты и отправляет StreamGraph. Обработчик REST на стороне кластера вызывает gateway.submitJob, и дальше управление у Dispatcher. Вот что он делает с планом.
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 на задание».
Документация: Flink Architecture, Deployment overview
Три графа одного задания
Один и тот же код проходит через три представления. На стройке это эскиз архитектора, рабочие чертежи и наряды на конкретных людей.
- StreamGraph описывает логику. Каждый оператор твоей программы в нём отдельный узел.
- JobGraph описывает, что исполняется как единое целое. Его вершины (JobVertex) это цепочки операторов, которые будут жить в одном потоке.
- ExecutionGraph описывает параллельное исполнение. На каждую вершину приходится столько подзадач, сколько задано параллелизмом, и у каждой есть своё состояние и своя история попыток.
В исходниках
Где JobGraph появляется при обычном запуске. Для потокового задания планировщик по умолчанию это DefaultScheduler, и его фабрика получает план от JobMaster. Смотри на ветку с StreamGraph.
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());
}
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.
// 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);
Здесь видно, откуда берётся число подзадач. Каждая вершина создаёт массив размером с параллелизм и заполняет его в цикле.
this.taskVertices = new ExecutionVertex[parallelismInfo.getParallelism()];
// create all task vertices
for (int i = 0; i < this.parallelismInfo.getParallelism(); i++) {
ExecutionVertex vertex =
createExecutionVertex(
this,
i,
producedDataSets,
timeout,
createTimestamp,
executionHistorySizeLimit,
Документация: Glossary, Flink Architecture
Цепочки операторов
Цепочка (operator chain) соединяет несколько операторов в один поток, и запись идёт между ними обычным вызовом метода, без сети и сериализации. Каменщик сразу штукатурит свою стену, и материалы никто не перевозит. Документация объясняет выгоду меньшими расходами на передачу между потоками и буферизацию. Подробно о том, что происходит на разрыве цепочки, поговорим в уроке 4.
В исходниках
Условия склейки собраны в одном методе. Цепочка должна быть включена, операторы должны быть в одной группе slot sharing, их стратегии должны разрешать склейку, а связь должна допускать передачу без перемешивания.
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 цепочку разрывает. Запись может уйти в другую подзадачу, значит через вызов метода её не передать.
@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, в задании появится лишняя сетевая передача.
// 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();
}
Документация: Operators, Task Chaining and Resource Groups
Слоты и slot sharing
Слот это единица планирования в TaskManager. Каждый слот получает равную долю управляемой памяти, а ядра между ними не делятся. Документация говорит об этом прямо, поэтому четыре слота на одном CPU дадут четыре потока, которые спорят за процессор.
По умолчанию подзадачи разных вершин одного задания могут жить в одном слоте. Этот механизм называется slot sharing. Правило простое. В один разделяемый слот попадает не больше одной подзадачи от каждой вершины. Отсюда простая арифметика, которая встречается в работе постоянно. Заданию с одной группой нужно столько слотов, сколько составляет наибольший параллелизм среди его вершин.
В исходниках
Правило «одна подзадача вершины на слот» записано в комментарии к стратегии, которая раскладывает подзадачи по разделяемым слотам.
* 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.
Документация: Flink Architecture, Set Slot Sharing Group
Покрути планировщик. Поставь параллелизм 8 при четырёх слотах и посмотри на результат. Потом включи отдельные группы, и потребность в слотах вырастет втрое. Заодно цепочка разорвётся, потому что операторы из разных групп код склеивать запрещает. Порядок, в котором модель заполняет TaskManager, условный. Настоящий зависит от настройки taskmanager.load-balance.mode.
Планировщик слотов для PayGuard
RUNNING ещё ничего не гарантирует
Вернёмся к ночной аварии. Статус задания и состояние подзадач разные вещи, и разница видна в одном методе.
В исходниках
Планировщик сначала переводит задание в RUNNING и только потом запускает стратегию, которая просит слоты.
protected void startSchedulingInternal() {
log.info(
"Starting scheduling with scheduling strategy [{}]",
schedulingStrategy.getClass().getName());
transitionToRunning();
schedulingStrategy.startScheduling();
}
Подзадача считается работающей только после отчёта из потока на TaskManager. Сначала JobMaster отправляет описание подзадачи, TaskManager запускает поток, и уже поток проходит свои состояния.
taskDeploymentDescriptorFuture
.thenComposeAsync(
deploymentDescriptor ->
taskManagerGateway.submitTask(deploymentDescriptor, rpcTimeout),
executor)
.whenCompleteAsync(
(ack, failure) ->
handleDeploymentCompletionAndCleanup(
maybeOffloadedTaskRestoreCleanupRef, ack, failure),
jobMasterMainThreadExecutor);
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());
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>, в этом ответе видно настоящее положение дел. Этому полю мы и посвятим скрипт в задании.
Документация: REST API
Когда слотов не хватило
Кластер standalone не умеет сам поднимать новые TaskManager, ResourceManager распределяет только то, что уже подключилось. На Kubernetes или YARN он может запросить новые процессы. Поэтому в нашем стенде нехватка слотов кончается ошибкой, и поведение зависит от возраста кластера.
В исходниках
Первые минуты после старта кластер терпит нехватку, потому что TaskManager могут ещё подключаться. Длительность задаёт resourcemanager.standalone.start-up-time, а без него берётся slot.request.timeout, то есть пять минут.
private void startStartupPeriod() {
setFailUnfulfillableRequest(false);
final long startupPeriodMillis = startupPeriodTime.toMillis();
if (startupPeriodMillis > 0) {
scheduleRunAsync(
() -> setFailUnfulfillableRequest(true),
startupPeriodMillis,
TimeUnit.MILLISECONDS);
}
}
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.");
После этого периода менеджер слотов сообщает о нехватке заданию сразу.
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));
}
}
}
В пуле слотов этого задания такое уведомление отменяет все потоковые запросы, ожидающие слот.
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. Если запрос не удаётся выполнить в течение таймаута, он отменяется с другим текстом. Поэтому при поиске в логах проверяй обе строки.
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. Этот сценарий мы в стенде не прогоняли, описание составлено по коду.
Документация: Configuration, Standalone
Планирование идёт по регионам, связанным передачей данных. У нашей цепочки нет связей между подзадачами, поэтому часть подзадач может получить слоты и успеть стартовать, пока остальные ждут. В этот момент задание читает только часть партиций Kafka, а смотреть в мониторинге надо на статус вершин.
Групповой чат запуска
Тот же запуск глазами участников, вместе с вопросом, что случится при нехватке слотов.
Режимы Session и Application
Сегодня Flink разворачивается двумя способами. Они различаются временем жизни кластера и тем, где выполняется main. Третий режим per-job удалён во Flink 2.0, и в старых статьях он всё ещё встречается.
| Session | Application | |
|---|---|---|
| Кластер | Уже запущен и живёт независимо от заданий | Создаётся на приложение и останавливается вместе с ним |
| Где выполняется main | В клиенте | В JobManager |
| Изоляция | Задания делят TaskManager, упавший процесс задевает всех | Ресурсы только у этого приложения |
| Откуда берётся jar | Клиент загружает его на кластер | Он уже лежит на classpath кластера, в папке usrlib |
| Команда | flink run --target remote | flink run --target kubernetes-application |
В нашем стенде режим Session, потому что кластер поднимает Docker Compose заранее. Развёртывание в Kubernetes разберём в уроке 20. Учти одну тонкость стенда. Команда docker compose exec jobmanager flink run выполняется в контейнере JobManager, но клиент при этом отдельная JVM. Твой main работает в ней, и JobManager его кода не исполняет.
В исходниках
В режиме Application задание подаётся изнутри кластера. Исполнитель обращается к Dispatcher напрямую, минуя REST, а в остальном передаёт тот же StreamGraph.
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);
Документация: Deployment Modes, Release Notes 2.0, CLI
Проверь себя
В задании источник с параллелизмом 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 и внеси такие правки.
@@ -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 только из этой строки.
// 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 их видит.
/**
* 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();
}
Документация: Savepoints, Assigning Operator IDs
Шаг 2. Скрипт отправки
Создай 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.
Вывод 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 не нужен.
- Первая строка показывает число TaskManager, сколько слотов занято из общего числа и версию Flink.
- Для каждого задания со статусом RUNNING скрипт показывает его имя, статус и идентификатор.
- Под каждым заданием идут его вершины, у каждой параллелизм, статус и имя.
- Адрес кластера по умолчанию
http://localhost:8081, переменная окруженияFLINK_RESTего заменяет. - При ошибке в команде скрипт завершается, как и
submit.sh.
Готово, если при работающем задании вывод показывает одну вершину с параллелизмом 2 и два занятых слота, а после flink cancel остаётся только первая строка с нулём занятых слотов.
Подсказки. Адреса нужны такие, /overview, /jobs и /jobs/<id>. Занятые слоты придётся вычислить из двух полей. В имени поля с дефисом jq требует запись в скобках. Для вставки значений в строку в jq есть интерполяция \(...), а флаг -r убирает кавычки вокруг результата. Посмотри ответ /jobs глазами, прежде чем писать фильтр, названия полей статуса в разных адресах отличаются.
Ответ
Скрипт делает три запроса. Цикл идёт только по запущенным заданиям, поэтому завершённые не засоряют вывод.
#!/usr/bin/env bash
# Печатает состояние кластера и всех вершин запущенных заданий через REST API JobManager.
set -euo pipefail
REST="${FLINK_REST:-http://localhost:8081}"
curl -s "$REST/overview" | jq -r '"TaskManager: \(.taskmanagers) слоты: \(.["slots-total"] - .["slots-available"]) заняты из \(.["slots-total"]) версия: \(.["flink-version"])"'
for job_id in $(curl -s "$REST/jobs" | jq -r '.jobs[] | select(.status == "RUNNING") | .id'); do
curl -s "$REST/jobs/$job_id" | jq -r '"\nЗадание \(.name) [\(.state)] \(.jid)", (.vertices[] | " \(.parallelism)x \(.status) \(.name)")'
done
set -euo pipefail
REST="${FLINK_REST:-http://localhost:8081}"
curl -s "$REST/overview" | jq -r '"TaskManager: \(.taskmanagers) слоты: \(.["slots-total"] - .["slots-available"]) заняты из \(.["slots-total"]) версия: \(.["flink-version"])"'
for job_id in $(curl -s "$REST/jobs" | jq -r '.jobs[] | select(.status == "RUNNING") | .id'); do
curl -s "$REST/jobs/$job_id" | jq -r '"\nЗадание \(.name) [\(.state)] \(.jid)", (.vertices[] | " \(.parallelism)x \(.status) \(.name)")'
done
set -euo pipefail останавливает скрипт при первой ошибке.
${FLINK_REST:-http://localhost:8081} берёт адрес из переменной окружения, а без неё подставляет значение по умолчанию.
Первый запрос читает /overview. Занятые слоты вычисляются как разность slots-total и slots-available. Дефис в имени поля заставляет писать .["slots-total"].
Цикл берёт идентификаторы из /jobs. Там статус лежит в поле status, и нам нужны только значения RUNNING.
Для каждого задания запрашиваем /jobs/$job_id. Здесь статус лежит уже в поле state, а идентификатор в поле jid. Запятая в jq выдаёт сначала строку заголовка, потом по строке на вершину.
Статус вершины в выводе нужен потому, что статус задания RUNNING ставится раньше, чем появляются слоты. Об этом мы говорили в теории.
Проверь работу двумя запусками, с заданием и без него. Заодно убедись, что переменная работает, задав неверный адрес.
chmod +x scripts/cluster-status.sh
./scripts/cluster-status.sh
FLINK_REST=http://localhost:9999 ./scripts/cluster-status.sh; echo "код выхода $?"
При работающем задании скрипт покажет один TaskManager, два занятых слота из четырёх и единственную вершину с именем из трёх операторов. Без задания останется первая строка. С неверным адресом curl -s вернёт ненулевой код, и pipefail завершит скрипт с этим кодом.
Глоссарий урока
- Клиент (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).
В исходниках
Проверим три звена этой цепочки. Контракт функции прямо говорит, что исключение роняет операцию.
* @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;
Оператор вокруг твоей функции исключение не перехватывает. Никакого пропуска плохих записей на этом уровне нет.
@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, в документации оно обозначено как бесконечное.
.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()))
@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. Значит, сегодня одна брошенная наружу ошибка остановила бы задание насовсем.
Документация: Task Failure Recovery, Kafka connector
Линия розлива
Представь завод лимонада. Бутылки едут по ленте, автомат клеит этикетки. Бутылка со сколом может заклинить автомат, и тогда встанет вся линия. После ремонта на ленту приедет та же бутылка, потому что с неё и начали.
Поэтому перед автоматом ставят отбраковщик. Он сбрасывает плохую бутылку на боковой лоток и вешает бирку с причиной. Лента едет дальше, а контролёр смены разбирает лоток, когда дойдут руки.
В Flink лента называется основным потоком, лоток называется побочным выходом (side output), а ящик с бирками это топик для отбракованных записей, dead letter queue или DLQ. Выбери вариант и посмотри на судьбу задания. Модель упрощённая, в ней 16 записей и две из них битые.
Что делает одна битая запись
Нажми кнопку. Зелёные точки это нормальные платежи, красные битые.
Такой же цикл есть в реальных замерах курса. В уроке 15 задание с CDC после первого UPDATE уходит в цикл RESTARTING из-за одной и той же ошибки, и само оттуда не выходит.
DataStream это план
Прежде чем чинить, разберёмся, из чего строится задание. DataStream<T> внешне похож на Dataset<T> в Spark. Это неизменяемое описание потока с известным типом, и каждый вызов вроде map или process возвращает новый объект. Ни одна запись при этом никуда не едет.
В исходниках
Посмотри на метод, через который проходят преобразования с одним входом. Он создаёт узел плана OneInputTransformation, запоминает его в окружении и заворачивает в новый поток. Запуска операторов здесь нет.
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 должен знать уже на этапе описания плана, к этому мы вернёмся ниже.
Документация: DataStream overview, Operators
Основные преобразования, которые встретятся в курсе.
| Вызов | Что делает | Где встретится |
|---|---|---|
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.
В исходниках
Метка обязана быть анонимным подклассом. Из-за стирания типов обычный new OutputTag<RejectedRecord>("x") не знает свой параметр. У анонимного подкласса он записан в метаданных класса. Без фигурных скобок конструктор бросит InvalidTypesException.
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, только с меткой. Время записи сохраняется.
@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()));
}
Дальше каждый следующий оператор в цепочке подключён через свой выход, и этот выход сравнивает метки. Чужую запись он просто игнорирует.
@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 создаёт отдельное преобразование и заодно проверяет, что под одним именем не просят два разных типа. Метки сравниваются только по строке с именем, поэтому имя в пределах оператора должно быть уникальным.
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);
}
Это преобразование не порождает оператора. При переводе плана в граф оно становится виртуальным узлом, поэтому лишнего шага обработки побочный выход не добавляет.
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;
Документация: Side Outputs
Писать в побочный выход можно из ProcessFunction и родственных функций, полный список есть в документации по ссылке выше. У MapFunction и FlatMapFunction контекста с методом output нет, поэтому парсер у нас написан на ProcessFunction.
В Spark встроенного побочного выхода нет. Обычно строку разбирают через from_json, который на битых данных даёт null, и затем фильтруют результат на два направления внутри foreachBatch. Во Flink разделение происходит внутри одного оператора и на каждой записи.
Что класть в DLQ и что с ним делать
DLQ стоит проектировать как данные, которые придётся разбирать. Мы кладём три поля. Исходный текст нужен, чтобы после исправления парсера отправить запись обратно в payments.raw. Код причины позволяет считать отказы по видам. Время отказа показывает, когда началась проблема.
Лог TaskManager эту задачу не решает, потому что из него неудобно переигрывать записи. Топик Kafka хранит их заданное время и читается любым потребителем. Цена в том, что теперь задание зависит от записи в этот топик. Если продюсер получит ошибку, она дойдёт до задачи и уронит её.
В исходниках
Посмотрим, что значит AT_LEAST_ONCE у приёмника Kafka. Метод flush дожидается отправки всего накопленного и затем проверяет ошибки продюсера.
@Override
public void flush(boolean endOfInput) throws IOException, InterruptedException {
if (deliveryGuarantee != DeliveryGuarantee.NONE || endOfInput) {
LOG.debug("final flush={}", endOfInput);
currentProducer.flush();
}
checkAsyncException();
}
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);
}
}
Вызывает его оператор приёмника непосредственно перед барьером чекпоинта.
@Override
public void prepareSnapshotPreBarrier(long checkpointId) throws Exception {
super.prepareSnapshotPreBarrier(checkpointId);
if (!endOfInput) {
sinkWriter.flush(false);
emitCommittables(checkpointId);
}
Отсюда следует важное ограничение. Гарантия держится на чекпоинтах, а их у нас пока нет. Записи в DLQ всё равно доходят, потому что продюсер отправляет свои пачки сам, но обещать отсутствие потерь при падении мы сможем с урока 11.
Документация: Kafka connector
Зачем Flink знать твои типы
Тот же завод объясняет систему типов. Захваты автомата подогнаны под известную форму бутылки и работают быстро. Бутылку незнакомой формы приходится класть в универсальную коробку с пенопластом. Поместится почти всё, но коробка тяжелее, и без вскрытия не видно, что внутри.
Flink описывает каждый тип объектом TypeInformation, а тот создаёт TypeSerializer, который переводит объект в байты и умеет его копировать. Сериализатор работает при передаче по сети между TaskManager (урок 4) и при записи в состояние (урок 7). Он нужен и внутри одной цепочки операторов, где сети нет вовсе.
В исходниках
Между операторами цепочки запись передаётся вызовом метода, но перед этим копируется сериализатором. Так оператор не сможет испортить объект, который держит его сосед.
@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, по умолчанию выключено).
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.
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.
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.
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;
}
}
* 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.
Документация: Rules for POJO types
Правила легко проверить на практике. Таблицу ниже мы получили вызовом TypeInformation.of на Flink 2.2.1 в обычном тесте, кластер для этого не нужен. Некоторые строки удивляют.
| Тип | Что выбрал Flink | Комментарий |
|---|---|---|
public record PaymentEvent | PojoTypeInfo | Наш случай, все поля свои |
record без public | GenericTypeInfo | Весь объект уходит в Kryo |
public record с полем UUID | PojoTypeInfo, поле GenericType | Kryo спрятан внутри хорошего на вид типа |
Поле LocalDate | GenericType | При этом Instant и BigDecimal получают свои сериализаторы |
Поле List<String> | NullableList | Встроенная поддержка, нужен интерфейс и конкретный параметр |
Поле ArrayList<String> или просто List | GenericType | Те же данные, но условие не выполнено |
Поле 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-типа просят сериализатор. Флаг читается из конфигурации, которую мы передаём окружению.
@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);
}
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.
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. Значит, ошибка типа вылезет при запуске задания, а записи в этот момент ещё не пошли.
private <T> TypeSerializer<T> createSerializer(TypeInformation<T> typeInfo) {
return typeInfo != null && !(typeInfo instanceof MissingTypeInfo)
? typeInfo.createSerializer(executionConfig.getSerializerConfig())
: null;
}
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);
Документация: Disabling Kryo Fallback, State Schema Evolution
В Spark роль TypeInformation играет Encoder. В Java-коде его передают явно, например Encoders.bean или Encoders.kryo, поэтому выбор Kryo виден в строке вызова. Во Flink тип определяется отражением, и переход на Kryo случается молча. Настройка GENERIC_TYPES=false делает этот переход видимым.
Где побочный выход не поможет
Наш try/catch защищает только код внутри processElement. До него запись уже прошла источник, а там своя обработка ошибок.
В исходниках
Источник превращает байты в строку через SimpleStringSchema. Сообщение Kafka с пустым значением (tombstone) приходит сюда как null, и конструктор String бросает NullPointerException. Мы проверили это отдельным вызовом метода.
// ------------------------------------------------------------------------
@Override
public String deserialize(byte[] message) {
return new String(message, charset);
}
Обёртка коннектора передаёт значение как есть, а эмиттер оборачивает любую ошибку и не сдвигает смещение. Смещение обновляется только после успешного разбора.
@Override
public void deserialize(ConsumerRecord<byte[], byte[]> message, Collector<T> out)
throws IOException {
deserializationSchema.deserialize(message.value(), out);
}
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 решит вопрос.
default void deserialize(byte[] message, Collector<T> out) throws IOException {
T deserialize = deserialize(message);
if (deserialize != null) {
out.collect(deserialize);
}
}
Парсер ловит только 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. Появляются две группы зависимостей.
@@ -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-типы его не затронет.
package dev.study.payguard.model;
/** Запись, которую не удалось принять: исходный текст и причина отказа для разбора в DLQ. */
public record RejectedRecord(String raw, String reason, long rejectedAtMs) {
}
Шаг 3. Функция разбора
Создай 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 подойдёт для любого стенда.
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.
Сборку приёмника вынесем в отдельный класс, она ещё пригодится для алертов.
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.
@@ -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 теста. Он отдаёт в функцию записи и показывает все её выходы, в том числе побочные.
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"
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. Такую битую запись подмешивает генератор, вот его константа.
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 ушли почти все платежи. Причина в определении этого счётчика.
В исходниках
Оператор приёмника передаёт задаче свой счётчик. Комментарий объясняет смысл, это записи, отправленные во внешнюю систему.
// 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.
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();
}
}
Документация: REST API
Осталось убедиться, что битые записи не причинили заданию вреда и что чекпоинтов действительно пока нет.
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 или merchantId | missing-id |
| Сумма не больше нуля | non-positive-amount |
| Валюта отсутствует или это не три заглавные латинские буквы | bad-currency |
| Время события не больше нуля | missing-event-time |
Тест PaymentValidatorTest лежит рядом с тестом функции. В нём должны быть проверка корректного платежа и по одному отказу на каждую причину.
Готово, если выполнены все условия.
./gradlew testпроходит, и заглушки больше нет.- Новый тест обходится без Flink и без тестового стенда, это обычный JUnit.
- После
./gradlew shadowJarи нового запуска заданияpayments.dlqпродолжает получать толькоbad-json, потому что генератор создаёт корректные платежи.
Подсказка первая. Подумай, что сделает Jackson с record, если в JSON нет поля amountMinor типа long. Ответ объясняет формулировку некоторых условий.
Подсказка вторая. Подумай, почему валидатор удобнее сделать обычной статической функцией от PaymentEvent, и что от этого выигрывает тест.
Ответ
Валидатор не знает о Flink и не бросает исключений. Отказ здесь это обычный результат, поэтому он возвращается как значение. Класс закрыт от других пакетов, потому что пользуется им только ParsePaymentFunction. Сериализатор ему не нужен, по потоку объекты этого класса не ходят.
package dev.study.payguard.pipeline;
import dev.study.payguard.model.PaymentEvent;
import java.util.Optional;
/** Бизнес-проверки платежа. Возвращает код причины отказа, если платёж принимать нельзя. */
final class PaymentValidator {
private static final String THREE_LETTERS = "[A-Z]{3}";
static Optional<String> rejectionReason(PaymentEvent payment) {
if (isBlank(payment.paymentId()) || isBlank(payment.cardId()) || isBlank(payment.merchantId())) {
return Optional.of("missing-id");
}
if (payment.amountMinor() <= 0) {
return Optional.of("non-positive-amount");
}
if (payment.currency() == null || !payment.currency().matches(THREE_LETTERS)) {
return Optional.of("bad-currency");
}
if (payment.eventTimeMs() <= 0) {
return Optional.of("missing-event-time");
}
return Optional.empty();
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
private PaymentValidator() {
}
}
Условия написаны как <= 0 из-за поведения Jackson. Отсутствующее поле типа long он заполняет нулём, а String значением null. Мы проверили это прогоном. Платёж без amountMinor уходит в DLQ с причиной non-positive-amount. Отличить отсутствие суммы от нулевой суммы при таких типах нельзя, для этого поле должно быть Long.
package dev.study.payguard.pipeline;
import static org.assertj.core.api.Assertions.assertThat;
import dev.study.payguard.model.PaymentEvent;
import org.junit.jupiter.api.Test;
class PaymentValidatorTest {
private static PaymentEvent payment(String id, long amount, String currency, long time) {
return new PaymentEvent(id, "c1", "m1", amount, currency, "DE", time);
}
@Test
void acceptsCorrectPayment() {
assertThat(PaymentValidator.rejectionReason(payment("p1", 100, "EUR", 1))).isEmpty();
}
@Test
void rejectsMissingId() {
assertThat(PaymentValidator.rejectionReason(payment(" ", 100, "EUR", 1))).contains("missing-id");
}
@Test
void rejectsNonPositiveAmount() {
assertThat(PaymentValidator.rejectionReason(payment("p1", 0, "EUR", 1))).contains("non-positive-amount");
}
@Test
void rejectsBadCurrency() {
assertThat(PaymentValidator.rejectionReason(payment("p1", 100, "euro", 1))).contains("bad-currency");
}
@Test
void rejectsMissingEventTime() {
assertThat(PaymentValidator.rejectionReason(payment("p1", 100, "EUR", 0))).contains("missing-event-time");
}
}
Тест обходится без тестового стенда и выполняется за миллисекунды. Функцию проверяем отдельно, там интересна только маршрутизация. Итог прогона всего снапшота на стенде курса выглядит так. В нём 11 тестов, это 4 из генератора, 2 из функции и 5 из валидатора.
RESULT tests 11 fail 0
jar bytes 24333158
Что снапшот пока не закрывает
Эти случаи в код курса не вошли, поэтому реши сам, закрывать ли их у себя. Все проверены прогоном на тестовом стенде и отдельными вызовами.
- Текст
null. Jackson возвращает Javanull, и валидатор падает сNullPointerException, которое никто не ловит. В отличие от остальных битых записей, эта уронит задание. Добавь в функцию проверкуpayment == nullперед вызовом валидатора и отправляй такую запись в DLQ сbad-json. - Лишние поля. Если новые поля партнёра не считаются ошибкой, настрой
mapperчерезconfigure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)вopen. - Tombstone в
payments.raw.SimpleStringSchemaпадает раньше функции. Нужна своя схема, которая возвращаетnullдляnull, контракт такую запись пропускает.
Заодно можно закрепить типы тестом. Вызов PojoTestUtils.assertSerializedAsPojoWithoutKryo(PaymentEvent.class) из flink-test-utils проходит для наших record и падает, если в них появится поле UUID. Ошибка всплывёт при обычной сборке.
Глоссарий урока
- 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 % платежей.
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. Для каждой записи она делает два шага.
- Номер группы ключей.
keyGroup = murmurHash(key.hashCode()) % maxParallelism. Здесьkey.hashCode()это обычный Java-хеш ключа, аmurmurHashперемешивает его биты, чтобы похожие строки не легли в соседние группы.maxParallelismзадаёт число групп, по умолчанию 128. - Номер подзадачи.
subtask = keyGroup * parallelism / maxParallelismв целых числах. Группы нарезаются непрерывными диапазонами, при параллелизме 4 подзадача 0 получает группы 0-31, подзадача 1 группы 32-63 и так далее.
Посчитаем вручную для нашего горячего продавца. Числа проверены вызовом KeyGroupRangeAssignment из flink-runtime 2.2.1 и отдельным расчётом на Python.
| Шаг | m-0000 | m-0007 |
|---|---|---|
hashCode() строки | -1131353760 | -1131353753 |
murmurHash от него | 1049440786 | 1500536744 |
| Остаток от деления на 128 | 18 | 40 |
| Подзадача при параллелизме 2 | 18 × 2 / 128 = 0 | 40 × 2 / 128 = 0 |
| Подзадача при параллелизме 4 | 18 × 4 / 128 = 0 | 40 × 4 / 128 = 1 |
| Подзадача при параллелизме 8 | 18 × 8 / 128 = 1 | 40 × 8 / 128 = 2 |
Хеши у двух строк почти одинаковые, отличаются на 7. После murmurHash они расходятся далеко, это и есть смысл второго хеширования. Обрати внимание, что номер группы от параллелизма не зависит. При смене параллелизма меняется только то, какая подзадача владеет группой 18.
В исходниках
Обе формулы живут в классе KeyGroupRangeAssignment. Сначала ключ превращается в номер группы, на вход идёт обычный hashCode().
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;
}
Потом группа превращается в номер подзадачи одной целочисленной формулой.
public static int computeOperatorIndexForKeyGroup(
int maxParallelism, int parallelism, int keyGroupId) {
return keyGroupId * parallelism / maxParallelism;
}
Сама стратегия на ребре берёт ключ из записи и вызывает эту функцию. Число каналов numberOfChannels равно параллелизму получателя.
@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() и массивы.
Документация: Stateful Stream Processing, Operators
Максимальный параллелизм выбирают один раз
Группа ключей это единица, которой Flink переносит состояние при смене параллелизма. Отсюда два последствия. Параллелизм оператора с состоянием не может превысить maxParallelism, потому что подзадаче без единой группы нечего делать. И сменить maxParallelism после первого запуска без потери состояния нельзя, ведь от него зависит номер группы каждого ключа.
В исходниках
Если maxParallelism не задан, планировщик вычисляет его из параллелизма вершины.
public static int getDefaultMaxParallelism(JobVertex vertex) {
return getDefaultMaxParallelism(vertex.getParallelism());
}
public static int getDefaultMaxParallelism(int parallelism) {
return KeyGroupRangeAssignment.computeDefaultMaxParallelism(
normalizeParallelism(parallelism));
}
Формула берёт полтора параллелизма, округляет вверх до степени двойки и держит результат в пределах от 128 до 32768.
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. Код с этим расходится, верь коду.
Документация: Parallel Execution, Production Readiness Checklist
Задание стартовало с параллелизмом 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.
// 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 начинает со случайного канала, чтобы разные отправители не били в одного получателя одновременно, и дальше идёт по кругу.
@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 подзадач.
} 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.
// set strategy name so that web interface can show it.
jobEdge.setShipStrategyName(partitioner.toString());
Документация: Physical Partitioning
Посмотри, как выглядит раскладка на практике. Модель использует те же формулы, что 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.
В исходниках
Внутри цепочки запись передаётся следующему оператору обычным вызовом метода. Сериализации, буферов и сети здесь нет, счётчики увеличиваются и сразу вызывается обработчик.
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() между простыми операторами стоит заметно дороже самого оператора.
Документация: Task Chaining and Resource Groups
Сетевой стек
Когда цепочка разорвана, у отправителя появляется результирующая партиция (ResultPartition). Она разбита на подчасти (subpartition), по одной на каждую подзадачу получателя. На стороне получателя её встречает входной шлюз (InputGate) с каналами (InputChannel), по одному на каждого отправителя. Если отправитель работает в том же TaskManager, канал локальный и читает подчасть напрямую. Если в другом, канал удалённый, и данные идут по TCP через библиотеку Netty.
Записи едут сетевыми буферами по 32 КиБ. Это размер по умолчанию ключа taskmanager.memory.segment-size. Буфер уходит, когда заполнится или когда истечёт execution.buffer-timeout.interval, это ты видел в уроке 1.
Главный вопрос в том, что делать, если получатель не успевает. Flink отвечает управлением потоком на кредитах (credit-based flow control). Отправитель шлёт буфер только тогда, когда получатель заранее сообщил, что ему есть куда этот буфер положить. Пройди по шагам.
В исходниках
Начнём с получателя. Когда в канале освобождается буфер, растёт счётчик невыданного кредита. Сообщение отправителю уходит, если до этого счётчик был нулём, иначе сообщение уже в пути и заберёт свежий кредит с собой.
@Override
public void notifyBufferAvailable(int numAvailableBuffers) throws IOException {
if (numAvailableBuffers > 0 && unannouncedCredit.getAndAdd(numAvailableBuffers) == 0) {
notifyCreditAvailable();
}
}
Само сообщение AddCredit забирает и обнуляет накопленный кредит в момент отправки.
@Override
Object buildMessage() {
int credits = inputChannel.getAndResetUnannouncedCredit();
return credits > 0
? new NettyMessage.AddCredit(credits, inputChannel.getInputChannelId())
: null;
}
Каждый пришедший буфер несёт backlog отправителя, и получатель сразу просит плавающие буферы под всю очередь плюс начальный кредит.
public void onSenderBacklog(int backlog) throws IOException {
notifyBufferAvailable(bufferManager.requestFloatingBuffers(backlog + initialCredit));
}
На стороне отправителя каждый буфер с данными списывает один кредит. Отправить буфер без кредита нельзя, это считается ошибкой. События, например барьер чекпоинта, кредита не требуют.
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 в классах настроек этой версии нет, хотя страница о сетевой памяти их ещё упоминает.
int buffersPerChannel = 2;
int extraBuffersPerGate = 8;
Optional<Integer> maxRequiredBuffersPerGate =
configuration.getOptional(
NettyShuffleEnvironmentOptions.NETWORK_READ_MAX_REQUIRED_BUFFERS_PER_GATE);
int maxBuffersPerChannel = 10;
Документация: Network memory tuning guide
На стенде курса один TaskManager, поэтому все каналы там локальные. Кредиты в них не участвуют, получатель читает подчасть напрямую, и сдерживание работает через пул буферов отправителя, о котором следующий раздел.
Откуда берётся backpressure
Обратное давление (backpressure) это состояние, при котором подзадача хочет отправить данные дальше и не может, потому что у неё нет свободного выходного буфера. Буферы не возвращаются, потому что получатель не выдаёт кредит. Кредит не выдаётся, потому что получатель не успевает разбирать вход. Так задержка одного медленного оператора поднимается вверх по графу до самого источника, и источник просто перестаёт читать Kafka быстрее, чем задание успевает обработать. Ни данные, ни память при этом не теряются, растёт только лаг.
У этого механизма есть следствие, которое касается перекоса. Пул буферов отправителя становится недоступным, как только хотя бы одна его подчасть набрала 10 буферов. Если подзадача с горячим ключом не успевает, её подчасть у каждого отправителя заполняется, и отправители встают целиком. Остальные получатели в это время простаивают, хотя работы для них хватает.
В исходниках
Пул отправителя считает буферы по подчастям. Подчасть, дошедшая до предела, делает весь пул недоступным.
if (targetChannel != UNKNOWN_CHANNEL) {
if (++subpartitionBuffersCount[targetChannel] == maxBuffersPerChannel) {
unavailableSubpartitionsCount++;
}
}
@GuardedBy("availableMemorySegments")
private boolean shouldBeAvailable() {
assert Thread.holdsLock(availableMemorySegments);
return !availableMemorySegments.isEmpty() && unavailableSubpartitionsCount == 0;
}
Подзадача замечает это в главном цикле. Если выход недоступен, она приостанавливает обработку входа и начинает отсчёт времени backpressure. Если недоступен вход, считается время простоя.
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, складывается из этого мягкого ожидания и жёсткого, когда буфер пришлось ждать посреди записи.
public long getBackPressuredTimeMsPerSecond() {
return getSoftBackPressuredTimePerSecond().getValue()
+ getHardBackPressuredTimePerSecond().getValue();
}
Документация советует читать три метрики вместе. busyTimeMsPerSecond, idleTimeMsPerSecond и backPressuredTimeMsPerSecond в сумме дают около 1000 мс. Виноват обычно первый оператор по ходу потока, у которого busy высокий, а backpressure нулевой.
Документация: Monitoring Back Pressure
Все записи одного ключа обрабатывает одна подзадача. При параллелизме 8 она всё равно получит 30 % потока, только остальные станут ещё свободнее. Лечат перекос двухфазной агрегацией с солью, когда ключ временно дробят на части, считают частичные итоги и потом сливают. Это мы сделаем в уроке 18.
Сравнение с shuffle в Spark
В Spark shuffle разделяет задание на стадии. Задачи первой стадии сортируют результат по целевым партициям и пишут его в файлы на локальном диске, задачи следующей стадии потом читают нужные блоки. Файлы переживают падение задачи-получателя, и Spark может перезапустить только её.
Потоковый Flink передаёт данные конвейером. Запись уходит получателю сразу, как только заполнился буфер или истёк таймаут, на диск по дороге ничего не пишется. Задержка получается маленькой, зато обе стороны обязаны работать одновременно, а при падении восстановление идёт от чекпоинта. Медленного получателя Flink сдерживает кредитами и backpressure, в Spark ту же роль играет то, что следующая стадия просто стартует позже.
Документация: Execution Mode, Spark Shuffle operations, Spark Structured Streaming notes
Проверь себя
Агрегат по 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 складывает два итога одного продавца.
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 печать сырых платежей заменяется на подсчёт итогов.
@@ -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, это состояние с ключом, и оно своё у каждого продавца. Результат уходит дальше на каждой записи, поэтому в выводе будет поток постепенно растущих итогов.
@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, держит и его итог, и сетевых обращений к чужому состоянию нет.
Документация: Operators
Шаг 3. Собери и запусти
./gradlew test shadowJar
docker compose up -d --wait
Если поднять Compose до первой сборки, Docker смонтирует пустую папку build/libs, и jar в контейнере не появится. Мы на это нарвались при подготовке курса. Если стенд уже работает, новый jar появится в смонтированной папке сам.
Останови задание прошлого урока через flink cancel, если оно ещё работает. Затем отправь новое и запусти генератор. Флаг --rate задаёт число платежей в секунду, --bad долю битых записей.
scripts/submit.sh
./gradlew loadgen --args="--rate 200 --bad 0.01"
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.
- Создай класс
dev.study.payguard.job.KeyDistributionсо статическим методомloadBySubtask. На вход набор ключей, функция веса ключа, параллелизм иmaxParallelism. На выход нагрузка по номеру подзадачи. - Добавь метод
skew, который возвращает отношение самой загруженной подзадачи к средней. 1,0 значит идеально ровно. - Напиши тест
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, чтобы подзадачи шли по порядку.
Ответ
Утилита вызывает KeyGroupRangeAssignment.assignKeyToParallelOperator. Это тот же метод, что вызывает KeyGroupStreamPartitioner на ребре, поэтому расчёт совпадает с кластером до записи. Вес передаётся функцией, так один класс считает и число ключей, и поток платежей.
package dev.study.payguard.job;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.ToLongFunction;
import org.apache.flink.runtime.state.KeyGroupRangeAssignment;
/** Считает, какую долю нагрузки получит каждая параллельная копия оператора после keyBy. */
public final class KeyDistribution {
/** Возвращает нагрузку по номеру подзадачи для набора ключей с их весами. */
public static <K> Map<Integer, Long> loadBySubtask(
Iterable<K> keys, ToLongFunction<K> weight, int parallelism, int maxParallelism) {
Map<Integer, Long> load = new TreeMap<>();
for (K key : keys) {
int subtask = KeyGroupRangeAssignment.assignKeyToParallelOperator(key, maxParallelism, parallelism);
load.merge(subtask, weight.applyAsLong(key), Long::sum);
}
return load;
}
/** Отношение самой загруженной копии к средней. 1.0 значит идеально ровно. */
public static double skew(Map<Integer, Long> load, int parallelism) {
long total = load.values().stream().mapToLong(Long::longValue).sum();
long max = load.values().stream().mapToLong(Long::longValue).max().orElse(0);
double average = (double) total / parallelism;
return average == 0 ? 0 : max / average;
}
private KeyDistribution() {
}
}
Подзадачи, на которые не попал ни один ключ, в карту не попадут. Поэтому среднее в skew делится на переданный параллелизм, размер карты для этого не годится.
package dev.study.payguard.job;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class KeyDistributionTest {
private static final int PARALLELISM = 4;
private static final int MAX_PARALLELISM = 128;
private static List<String> merchants() {
List<String> merchants = new ArrayList<>();
for (int i = 0; i < 50; i++) {
merchants.add("m-%04d".formatted(i));
}
return merchants;
}
@Test
void equalWeightsSpreadReasonablyEvenly() {
Map<Integer, Long> load = KeyDistribution.loadBySubtask(merchants(), key -> 1L, PARALLELISM, MAX_PARALLELISM);
assertThat(KeyDistribution.skew(load, PARALLELISM)).isLessThan(1.5);
}
@Test
void hotKeyMakesOneSubtaskMuchBusier() {
long hotWeight = 21;
Map<Integer, Long> load = KeyDistribution.loadBySubtask(
merchants(), key -> key.equals("m-0000") ? hotWeight * 49 : 1L, PARALLELISM, MAX_PARALLELISM);
assertThat(KeyDistribution.skew(load, PARALLELISM)).isGreaterThan(2.0);
}
@Test
void sameKeyAlwaysLandsOnSameSubtask() {
Map<Integer, Long> first = KeyDistribution.loadBySubtask(List.of("m-0007"), key -> 1L, PARALLELISM, MAX_PARALLELISM);
Map<Integer, Long> second = KeyDistribution.loadBySubtask(List.of("m-0007"), key -> 1L, PARALLELISM, MAX_PARALLELISM);
assertThat(first).isEqualTo(second);
}
}
Во втором тесте вес горячего ключа взят с большим запасом, 21 × 49 против 1. Это больше 95 % потока, и порог 2,0 выполняется надёжно. С реальными 30 % при параллелизме 4 перекос получается 1,6, и такой тест упал бы. Мы прогнали класс с весами генератора, 21 у m-0000 и 1 у остальных.
| Параллелизм | Ключей по подзадачам | Нагрузка по подзадачам, в семидесятых долях | skew |
|---|---|---|---|
| 2 | 22, 28 | 42, 28 | 1,20 |
| 4 | 8, 14, 11, 17 | 28, 14, 11, 17 | 1,60 |
| 8 | 3, 5, 7, 7, 6, 5, 6, 11 | 3, 25, 7, 7, 6, 5, 6, 11 | 2,86 |
Таблица подтверждает подсказку из теории. С ростом параллелизма перекос растёт, потому что горячая подзадача всё равно получает 30 %, а средняя доля падает. При параллелизме 4 разница между самой нагруженной и самой свободной подзадачей 28 / 11 ≈ 2,5. В замере урока 18 на четырёх подзадачах получилось 11476 против 29551, это 2,6 раза.
Проверить решение можно так.
./gradlew test --tests 'dev.study.payguard.job.KeyDistributionTest'
Флаг --tests запускает только указанный класс. Отчёт Gradle лежит в build/reports/tests/test/index.html.
Глоссарий урока
- 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 выпускается в отдельном методе, который зовут периодически.
// 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, задаёт настройка конвейера.
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.
Документация: Generating Watermarks, Builtin Watermark Generators
Разберём на числах при 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, она уходит в выход вместе с объектом.
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 этого сплита.
@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);
Генератор создаётся отдельно на каждый сплит, поэтому у каждой партиции свой максимум.
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. В этом операторе один генератор на подзадачу, и партиции к нему приходят уже перемешанными.
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<>();
Документация: Watermark Strategies and the Kafka Connector
Минимум по входам
Как только у источника несколько партиций, нужно правило слияния. Оно одно на весь Flink. Общий watermark равен минимуму по входам, потому что обещание «ничего старше T» верно, только если его дал каждый вход. Так работает источник по своим сплитам, и так работает любой оператор, получающий данные от нескольких подзадач, например после keyBy.
Одна отставшая партиция P1 задаёт время всего задания. Обе подзадачи после keyBy видят одинаковый watermark, потому что получают данные от одних и тех же подзадач источника. Это пригодится в практике, когда ты увидишь два одинаковых числа в REST.
В исходниках
Внутри источника watermark сплитов сливает CombinedWatermarkStatus. Берётся минимум по активным сплитам. Если активных не осталось, берётся максимум, и комментарий объясняет почему.
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.
// 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);
}
}
Наружу уходит вершина кучи, то есть минимум, и только если он вырос.
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 только растёт, поэтому время оператора никогда не идёт назад.
Документация: Timely Stream Processing
Тихая партиция и зависшее время
Минимум безжалостен к молчунам. В топике четыре партиции, генератор пишет в них без ключа, и сегодня все получают данные. В проде часто бывает иначе. Партицию читают, но в неё ночью никто не пишет, её watermark стоит, а вместе с ним стоит время всего задания. Окна не закрываются, состояние растёт, алертов нет, и при этом ни одной ошибки в логе.
Лекарство это withIdleness(timeout). Если по входу нет записей дольше таймаута, вход объявляется простаивающим (idle), и минимум считается без него.
В исходниках
Обёртка над любым генератором. Каждая запись сбрасывает таймер. При периодическом выпуске, если таймер истёк, вместо watermark выдаётся признак простоя.
@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 уже ушёл вперёд и обратно не вернётся.
if (combinedWatermark > this.combinedWatermark) {
this.combinedWatermark = combinedWatermark;
return true;
}
return false;
Документация: Dealing With Idle Sources
Партиция, которая молчала из-за сбоя продюсера, потом выдаёт накопленное со старыми метками. Пока она считалась простаивающей, общее время ушло вперёд, и её платежи станут поздними. Таймаут выбирай больше обычных пауз в данных. В проекте стоит одна минута.
Сравнение со Spark
В Spark Structured Streaming watermark задаётся вызовом withWatermark("timestamp", "10 minutes"). Движок берёт наибольшее время события, увиденное запросом по всем партициям сразу, вычитает порог и применяет результат с начала следующего micro-batch. Если запрос объединяет несколько потоков, по умолчанию берётся минимум их watermark, это задаёт spark.sql.streaming.multipleWatermarkPolicy.
Во Flink watermark считается по каждой партиции отдельно, сливается минимумом на каждом шаге графа и выходит каждые 200 мс. Отставшая партиция во Flink сдерживает время. В Spark её данные рискуют оказаться за порогом, потому что максимум задают быстрые партиции. Простой во Flink объявляется явно через withIdleness.
Документация: Spark, Handling Late Data and Watermarking
Стратегия 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.
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 остаётся как был.
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 и его тест удали, их работу теперь делают схема и следующий класс. Новый тест схемы проверяет те же три случая.
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.
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. Это единственное место, где задание решает, что такое время.
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 выглядит так.
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");
Метод сборки источника меняет тип и схему.
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"
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.
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. В задаче источника такой регистрации нет, у неё нет входа из сети.
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».
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 секунд мы выбрали наугад. Напиши оператор, который измеряет опоздания на живом потоке, чтобы выбирать допуск по данным.
- Класс
dev.study.payguard.pipeline.LatenessProbeпринимаетPaymentEventи отдаёт его же без изменений, все события, включая поздние. - Событие считается поздним, если его
eventTimeMsне больше текущего watermark оператора. - Метрика-счётчик
lateEventsсчитает поздние события. - Метрика-гистограмма
behindWatermarkMsполучает для каждого позднего события, на сколько миллисекунд оно старше watermark. Храни последние 10 000 значений. - В задании проба стоит сразу после
split-parsed, имя иuidу неёlateness-probe. Подсчёт сумм продавцов читает её выход. - Тест на тестовом стенде проверяет, что позднее событие не потерялось.
Готово, если ./gradlew test зелёный, задание работает на стенде, а в метриках подзадач вершины с источником есть lateEvents и перцентили behindWatermarkMs.
Подсказки. Обычной ProcessFunction без ключа таймеры недоступны, но узнать текущий watermark через контекст можно. Готовая реализация гистограммы есть в пакете org.apache.flink.runtime.metrics, модуль flink-runtime приходит вместе с flink-streaming-java. Тестовый стенд умеет подавать не только записи, но и watermark. Проба окажется в одной цепочке с источником, ищи её метрики по имени оператора.
Ответ
Проба это ProcessFunction без ключа. Метрики регистрируются в open, когда группа метрик подзадачи уже существует. Для гистограммы взят DescriptiveStatisticsHistogram с окном 10 000 значений, он считает перцентили по последним значениям и не тянет внешних библиотек.
package dev.study.payguard.pipeline;
import dev.study.payguard.model.PaymentEvent;
import org.apache.flink.api.common.functions.OpenContext;
import org.apache.flink.metrics.Counter;
import org.apache.flink.metrics.Histogram;
import org.apache.flink.runtime.metrics.DescriptiveStatisticsHistogram;
import org.apache.flink.streaming.api.functions.ProcessFunction;
import org.apache.flink.util.Collector;
/** Измеряет, насколько события отстают от watermark. По этим числам выбирают допуск беспорядка. */
public class LatenessProbe extends ProcessFunction<PaymentEvent, PaymentEvent> {
private static final int HISTOGRAM_WINDOW = 10_000;
private transient Counter lateEvents;
private transient Histogram behindWatermarkMs;
@Override
public void open(OpenContext context) {
lateEvents = getRuntimeContext().getMetricGroup().counter("lateEvents");
behindWatermarkMs = getRuntimeContext().getMetricGroup()
.histogram("behindWatermarkMs", new DescriptiveStatisticsHistogram(HISTOGRAM_WINDOW));
}
@Override
public void processElement(PaymentEvent payment, Context context, Collector<PaymentEvent> out) {
long watermark = context.timerService().currentWatermark();
if (payment.eventTimeMs() <= watermark) {
lateEvents.inc();
behindWatermarkMs.update(watermark - payment.eventTimeMs());
}
out.collect(payment);
}
}
Сравнение <= совпадает с определением позднего события. Watermark T обещает, что событий с меткой T и раньше больше не будет. Запись уходит дальше в любом случае, проба только смотрит.
В исходниках
Почему currentWatermark() работает без ключа. Оператор ProcessOperator сам запоминает каждый пришедший watermark и отдаёт его через контекст. Регистрация таймера здесь бросила бы UnsupportedOperationException.
@Override
public void processWatermark(Watermark mark) throws Exception {
super.processWatermark(mark);
this.currentWatermark = mark.getTimestamp();
}
@Override
public long currentWatermark() {
return currentWatermark;
}
@Override
public void registerProcessingTimeTimer(long time) {
throw new UnsupportedOperationException(UNSUPPORTED_REGISTER_TIMER_MSG);
}
@Override
public void registerEventTimeTimer(long time) {
throw new UnsupportedOperationException(UNSUPPORTED_REGISTER_TIMER_MSG);
}
Поле начинается с Long.MIN_VALUE, поэтому до первого watermark поздних событий не бывает.
В задании проба стоит между развилкой и подсчётом сумм.
DataStream<PaymentEvent> payments = split
.process(new LatenessProbe())
.name("lateness-probe")
.uid("lateness-probe");
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");
Тест подаёт событие, затем watermark 10 000, затем событие с меткой 4 000, оно позднее. На выходе должны быть оба.
package dev.study.payguard.pipeline;
import static org.assertj.core.api.Assertions.assertThat;
import dev.study.payguard.model.PaymentEvent;
import org.apache.flink.streaming.api.watermark.Watermark;
import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness;
import org.apache.flink.streaming.util.ProcessFunctionTestHarnesses;
import org.junit.jupiter.api.Test;
class LatenessProbeTest {
private static PaymentEvent paymentAt(long eventTimeMs) {
return new PaymentEvent("p", "c", "m", 100, "EUR", "DE", eventTimeMs);
}
@Test
void lateEventsAreStillForwardedDownstream() throws Exception {
OneInputStreamOperatorTestHarness<PaymentEvent, PaymentEvent> harness =
ProcessFunctionTestHarnesses.forProcessFunction(new LatenessProbe());
harness.processElement(paymentAt(5_000), 5_000);
harness.processWatermark(new Watermark(10_000));
harness.processElement(paymentAt(4_000), 4_000);
assertThat(harness.extractOutputValues()).containsExactly(paymentAt(5_000), paymentAt(4_000));
}
}
В нашем прогоне прошло 16 тестов, падений не было. После отправки задания граф выглядит так. Проба вошла в цепочку источника, перед to-merchant-total.
{"name":"payguard","state":"RUNNING","vertices":[{"name":"Source: payments-raw -> split-parsed -> (dlq-sink: Writer -> dlq-sink: Committer, lateness-probe -> to-merchant-total)","parallelism":2,"status":"RUNNING"},{"name":"merchant-running-total -> Sink: stdout","parallelism":2,"status":"RUNNING"}]}
Метрики пробы ищи у первой вершины. Сначала выведи имена, в которых есть её метрики, потом запроси нужные со сложением и максимумом по подзадачам.
V=$(curl -s localhost:8081/jobs/$JOB | jq -r '.vertices[0].id')
curl -s localhost:8081/jobs/$JOB/vertices/$V/subtasks/metrics | jq -r '.[].id' | grep lateness-probe
curl -s "localhost:8081/jobs/$JOB/vertices/$V/subtasks/metrics?get=lateness-probe.lateEvents,lateness-probe.behindWatermarkMs_p99&agg=sum,max" | jq -c .
В исходниках
Имя метрики оператора в REST складывается из имени оператора и имени метрики через точку.
addMetric(attempt, operatorInfo.operatorName + "." + name, metric);
// If the attempt is representative one, its metrics can be updated to the
// subtask and task metric store.
if (isRepresentativeAttempt) {
/**
* As the WebInterface does not account for operators (because it can't) we
* don't divide by operator and instead use the concatenation of subtask
* index, operator name and metric name as the name.
*/
addMetric(subtask, operatorInfo.operatorName + "." + name, metric);
addMetric(
task,
operatorInfo.subtaskIndex
+ "."
+ operatorInfo.operatorName
+ "."
+ name,
metric);
Гистограмма в REST раскладывается на набор чисел с суффиксами. Для выбора допуска смотри на _p99 и _max.
case METRIC_CATEGORY_HISTOGRAM:
MetricDump.HistogramDump histogram = (MetricDump.HistogramDump) metric;
target.addMetric(name + "_min", String.valueOf(histogram.min));
target.addMetric(name + "_max", String.valueOf(histogram.max));
target.addMetric(name + "_mean", String.valueOf(histogram.mean));
target.addMetric(name + "_median", String.valueOf(histogram.median));
target.addMetric(name + "_stddev", String.valueOf(histogram.stddev));
target.addMetric(name + "_p75", String.valueOf(histogram.p75));
target.addMetric(name + "_p90", String.valueOf(histogram.p90));
target.addMetric(name + "_p95", String.valueOf(histogram.p95));
target.addMetric(name + "_p98", String.valueOf(histogram.p98));
target.addMetric(name + "_p99", String.valueOf(histogram.p99));
target.addMetric(name + "_p999", String.valueOf(histogram.p999));
break;
Документация: Metrics, Histogram
Вывода этих команд у нас нет, числа на твоём стенде свои. Читай их так. Доля lateEvents от всех платежей показывает, сколько данных потеряет окно с таким допуском. Сумма behindWatermarkMs_p99 и текущего допуска даёт допуск, при котором поздними останется около 1 % из тех, что опаздывают сейчас. По коду генератора ожидай около 3 % поздних и отставание от watermark до 20 секунд.
Глоссарий урока
- Время события (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 | Отрезки одной длины встык, без пересечений | 1 | Watermark прошёл конец отрезка | Оборот продавца за каждую минуту |
| Sliding | Отрезки длины size, новый начинается каждые slide | size / slide | Watermark прошёл конец отрезка | Платежи карты за 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, а запись без времени события роняет задание с понятным текстом.
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 года.
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), это нужно, например, для суточных окон по московскому времени.
Документация: Tumbling Windows
Почему sliding окно дорого стоит
В исходниках
У sliding окна тот же метод строит список. Цикл идёт от последнего начала назад с шагом slide, пока окно ещё накрывает запись.
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 окон на запись.
Документация: Useful state size considerations
Как сессии склеиваются
Сессионное окно заранее не знает своих границ. Поэтому каждая запись сначала получает собственное окно длиной в паузу, а потом оператор склеивает его с соседними.
В исходниках
Assigner сессий возвращает окно от времени записи до этого времени плюс пауза.
@Override
public Collection<TimeWindow> assignWindows(
Object element, long timestamp, WindowAssignerContext context) {
return Collections.singletonList(new TimeWindow(timestamp, timestamp + sessionTimeout));
}
Склеиваются окна, которые пересекаются или касаются друг друга. Результат накрывает оба окна.
/**
* 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, то есть окном.
@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 окна его не трогают. Сессии без него дадут неверный результат. Конец сессии всегда равен времени последнего платежа плюс пауза.
Документация: Session Windows
Global окно устроено проще всех. Все записи ключа попадают в одно окно, а trigger по умолчанию у него никогда не срабатывает.
В исходниках
Фабрика create подставляет NeverTrigger, комментарий прямо предупреждает, что без своего trigger вычислений не будет.
/**
* 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 и логикой очистки.
Документация: Global Windows
Из чего собран оконный оператор
За вызовами window(...) и aggregate(...) стоит один оператор WindowOperator из четырёх частей.
- Assigner решает, в какие окна попадает запись. Мы его только что разобрали.
- Trigger решает, когда окно готово отдать результат и нужно ли после этого очистить его содержимое. У каждого assigner есть trigger по умолчанию.
- Evictor может выкинуть часть записей из окна до или после вызова функции. Он необязателен и дорого обходится, об этом ниже.
- Функция окна считает результат. Это
ReduceFunction,AggregateFunctionилиProcessWindowFunction.
В исходниках
Вот путь одной записи через оператор для окон без склейки. Assigner вызывается в начале processElement, дальше цикл по окнам записи.
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 окна, поэтому у одного продавца одновременно может жить несколько окон.
Документация: Window Lifecycle
Когда окно срабатывает
В исходниках
Trigger по умолчанию для окон по времени события называется EventTimeTrigger. Посмотри, что он делает на каждую запись и на таймер.
@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 на единицу меньше конца, потому что конец окна в него не входит.
/**
* 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.
Документация: Triggers
AggregateFunction или ProcessWindowFunction
ProcessWindowFunction получает все записи окна разом и вдобавок контекст с границами окна. AggregateFunction видит записи по одной, складывает их в аккумулятор и о границах окна ничего не знает. Разница в стоимости решается в момент построения оператора.
В исходниках
Вызов aggregate с двумя функциями строит состояние типа AggregatingStateDescriptor. В нём лежит один аккумулятор на окно.
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. Это список всех записей окна.
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 должен их видеть. Предварительное сложение пропадает.
Документация: ProcessWindowFunction with Incremental Aggregation, Evictors
Посчитаем для PayGuard. Примерно 200 платежей в секунду, окно в минуту, значит около 12000 платежей в окне на всех продавцов. С ProcessWindowFunction все они лежат в состоянии до конца окна и ещё минуту допуска. С AggregateFunction в состоянии 50 аккумуляторов по два числа. Две функции можно соединить. Аккумулятор копится по мере прихода записей, а ProcessWindowFunction в конце получает один готовый результат и добавляет к нему границы окна. Так сделан наш MerchantStatsWindow.
Опоздавшие платежи
У watermark есть задержка, но часть платежей приходит ещё позже. По умолчанию допуск опозданий равен нулю, и такая запись молча отбрасывается. Метрика numLateRecordsDropped на операторе увеличится на единицу, и больше ты ничего не узнаешь. Два вызова меняют это поведение. allowedLateness держит окно в состоянии дольше, и опоздавшая запись вызывает пересчёт. sideOutputLateData отправляет записи, которые не принял уже никто, в побочный выход.
В исходниках
Окно считается опоздавшим, когда watermark дошёл до его времени очистки. Время очистки равно maxTimestamp плюс допуск.
/**
* 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());
}
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 запись уходит в побочный выход, только если её не приняло ни одно окно. Без тега она просто считается в метрике.
// 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 окнами, которую видно в визуализации. Опоздавший платёж может не попасть в старые окна и при этом попасть в одно ещё живое. Тогда в побочный выход он не уйдёт. Старые окна так и останутся без него.
Документация: Allowed Lateness, Side Outputs
При допуске больше нуля каждое позднее срабатывание выдаёт итог окна заново, уже с опоздавшим платежом. В топике 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 удали, он больше не нужен.
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. Класс открытый, потому что им же воспользуется окно сессий из задания.
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. Все настройки окна собраны в одном классе, а задание только подключает его к потоку.
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 появляются три топика. Поле для сессий карты пригодится в задании, заведи его сразу, чтобы не возвращаться к этому файлу.
@@ -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.
@@ -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 остаётся оглавлением задания.
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");
}
Окно и оба его приёмника.
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.
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, чтобы сработали все таймеры и сбросилось состояние окон.
/**
* 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.
@Override
protected void advanceToEndOfEventTime() {
output.emitWatermark(Watermark.MAX_WATERMARK);
}
Поэтому в тесте последнее окно тоже выдаст итог, хотя платежей после него нет. На стенде с Kafka источник бесконечен, и последнее окно ждёт настоящего watermark.
Документация: Testing
Теперь сам тест, 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 с.
private static final long MAX_LATENESS_MS = 30_000;
Платёж уходит в побочный выход, только когда watermark обогнал его время больше чем на допуск. Watermark отстаёт от самого свежего платежа на 10 с, допуск равен 60 с, поэтому отбросить можно только платёж, опоздавший больше чем на 70 с. Чтобы увидеть побочный выход в работе, временно поставь ALLOWED_LATENESS в ноль и повтори прогон. Потом верни минуту, следующие уроки рассчитывают на неё.
Задание. Сессии карты
Антифроду нужна картина поведения карты. Мошенник проверяет карту серией платежей подряд, а обычный покупатель платит редко. Собери платежи каждой карты в сессии. Сессия продолжается, пока паузы между платежами по времени события короче 30 секунд.
- Создай record
model/CardSessionс полямиcardId,sessionStartMs,sessionEndMs,paymentCount,amountMinor. - Создай
pipeline/CardSessionWindowс публичной константой паузы и методомattach(DataStream<PaymentEvent>), по образцуMerchantStatsWindow. Оператору дай имя и uidcard-sessions. - В состоянии сессии не должны копиться сами платежи.
- Подключи окно в
PayGuardJobи пиши результат в топикcard.sessionsприёмником с именем и uidcard-sessions-sink. - Напиши
CardSessionWindowTestна MiniCluster. Два платежа одной карты с паузой меньше 30 с и третий после длинной паузы должны дать ровно две сессии.
Готово, если выполнены все условия.
./gradlew testпроходит.- В графе задания на стенде появилась третья вершина
card-sessions. - В
card.sessionsидут записи, и у сессии из одного платежаsessionEndMsбольшеsessionStartMsровно на 30000.
Подсказка первая. Посмотри, какой из классов практики можно взять без изменений, и вспомни, какой его метод впервые начнёт вызываться.
Подсказка вторая. Прежде чем писать ожидания в тесте, посчитай границы сессий вручную. Каждый платёж открывает окно от своего времени до времени плюс пауза, а пересекающиеся окна сливаются.
Ответ
Модель повторяет MerchantStats, только вместо продавца карта, а вместо минуты границы сессии.
package dev.study.payguard.model;
/** Серия платежей по карте, в которой паузы короче заданного интервала. */
public record CardSession(String cardId, long sessionStartMs, long sessionEndMs, long paymentCount, long amountMinor) {
}
Окно собрано из тех же частей, что и минутное. PaymentTotals используется как есть, и здесь впервые работает его метод merge. Он складывает аккумуляторы двух сессий, когда новый платёж их соединяет. Вторая функция ToCardSession добавляет границы сессии, без неё их не узнать. Допуск опозданий здесь не задан. Сессия закрывается через 30 с после последнего платежа и ещё 10 с задержки watermark, для антифрода это и так долго.
package dev.study.payguard.pipeline;
import dev.study.payguard.model.CardSession;
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.EventTimeSessionWindows;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;
/** Склеивает платежи по карте в сессии: пауза дольше {@link #SESSION_GAP} закрывает сессию. */
public final class CardSessionWindow {
public static final Duration SESSION_GAP = Duration.ofSeconds(30);
public static SingleOutputStreamOperator<CardSession> attach(DataStream<PaymentEvent> payments) {
return payments
.keyBy(PaymentEvent::cardId)
.window(EventTimeSessionWindows.withGap(SESSION_GAP))
.aggregate(new PaymentTotals(), new ToCardSession())
.name("card-sessions")
.uid("card-sessions");
}
private static final class ToCardSession
extends ProcessWindowFunction<PaymentTotals.Accumulator, CardSession, String, TimeWindow> {
@Override
public void process(
String cardId,
Context context,
Iterable<PaymentTotals.Accumulator> totals,
Collector<CardSession> out) {
PaymentTotals.Accumulator total = totals.iterator().next();
out.collect(new CardSession(
cardId, context.window().getStart(), context.window().getEnd(), total.count(), total.amountMinor()));
}
}
private CardSessionWindow() {
}
}
В задании окно подключается к тому же потоку payments, что и минутные итоги. У потока теперь два потребителя, и каждый платёж уходит в оба.
CardSessionWindow.attach(payments)
.sinkTo(KafkaSinks.<CardSession>atLeastOnceJson(config.bootstrapServers(), config.cardSessionsTopic()))
.name("card-sessions-sink")
.uid("card-sessions-sink");
Тест проверяет склейку и разрыв. Платёж в 1 с открывает окно до 31 с, платёж в 20 с открывает окно до 50 с. Окна пересекаются и сливаются в сессию от 1 до 50 с. Платёж в 100 с отстоит от 50 с дальше паузы и начинает вторую сессию от 100 до 130 с.
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.CardSession;
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 CardSessionWindowTest {
@RegisterExtension
static final MiniClusterExtension CLUSTER = new MiniClusterExtension(
new MiniClusterResourceConfiguration.Builder()
.setNumberTaskManagers(1)
.setNumberSlotsPerTaskManager(2)
.build());
@Test
void pauseLongerThanGapStartsNewSession() throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
List<PaymentEvent> payments = List.of(
payment("p1", "c1", "m1", 100, 1_000),
payment("p2", "c1", "m1", 200, 20_000),
payment("p3", "c1", "m1", 400, 100_000));
List<CardSession> sessions = CardSessionWindow.attach(streamOf(env, payments)).executeAndCollect(10);
assertThat(sessions).containsExactlyInAnyOrder(
new CardSession("c1", 1_000, 50_000, 2, 300),
new CardSession("c1", 100_000, 130_000, 1, 400));
}
}
Итог прогона всех тестов снапшота на стенде курса.
RESULT tests 18 fail 0
jar bytes 24351512
После сборки и нового запуска граф задания выглядит так.
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":"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"}]}
Вершин три. keyBy по продавцу и keyBy по карте требуют двух разных перераспределений, поэтому каждое окно стоит в своей вершине. Приёмники сцеплены с окнами.
docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server localhost:9092 --topic card.sessions --from-beginning --max-messages 3 2>/dev/null
{"cardId":"c-3710","sessionStartMs":1794085556146,"sessionEndMs":1794085590732,"paymentCount":2,"amountMinor":29438}
{"cardId":"c-2015","sessionStartMs":1794085560732,"sessionEndMs":1794085590732,"paymentCount":1,"amountMinor":41989}
{"cardId":"c-1722","sessionStartMs":1794085560732,"sessionEndMs":1794085590732,"paymentCount":1,"amountMinor":4280}
У c-2015 один платёж, и сессия длится ровно 30000 мс. У c-3710 два платежа, сессия кончается через 30 с после второго, значит, второй платёж был в 1794085560732. Карт 5000, поэтому почти все сессии при 200 платежах в секунду состоят из одного-двух платежей. Метрики вершины card-sessions на нашем стенде показали 17604 платежа на входе и 3248 сессий на выходе. Подзадачи получили 8734 и 8870 платежей, у карт нет горячего ключа, и перекоса тоже нет.
Глоссарий урока
- Окно (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.
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, и делает результат текущим ключом бэкенда состояния.
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);
}
Отсюда следует правило, о котором часто забывают. Ключ вычисляется заново в принимающем операторе, поэтому селектор обязан быть детерминированным. Если он вернёт для того же платежа другое значение, запись придёт в одну подзадачу, а искать состояние будет по чужому ключу.
Документация: Working with State, Stateful Stream Processing
Где лежат значения
Объект ValueState в твоей функции ничего не хранит, это окно в бэкенд состояния (state backend). Бэкенд выбирается настройкой state.backend.type, и если её нет, работает HashMapStateBackend. В нашем docker-compose.yml настройки нет, значит, всё состояние PayGuard сейчас живёт в куче JVM TaskManager.
В исходниках
У HashMapStateBackend состояние лежит в StateTable. Комментарий к полю говорит, что внешний массив разбит по группам ключей. Группы ключей ты знаешь по уроку 4, это единица, которую Flink переносит между подзадачами при смене параллелизма.
/**
* 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. Ключ и его группу таблица берёт из контекста, то есть из того самого текущего ключа.
@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);
}
/**
* 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 дороже, зато состояние может быть больше памяти.
@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 кладут туда само окно, поэтому у одного ключа может быть несколько окон с отдельным состоянием.
Документация: State Backends
Для сравнения бэкендов в документации есть простое правило. 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 вместе образуют таймер.
@Override
public void registerEventTimeTimer(N namespace, long time) {
eventTimeTimersQueue.add(
new TimerHeapInternalTimer<>(time, (K) keyContext.getCurrentKey(), namespace));
}
Очередь таймеров в куче устроена как множество. Повторное добавление такого же таймера ничего не меняет, именно поэтому серия дала один таймер.
public boolean add(@Nonnull T element) {
return getDedupMapForElement(element).putIfAbsent(element, element) == null
&& super.add(element);
}
Когда приходит watermark, сервис достаёт таймеры по порядку, пока их время не больше watermark. Перед каждым вызовом он делает ключ таймера текущим, поэтому в onTimer состояние снова принадлежит нужной карте.
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 вызывается прямо в потоке задачи. Методы никогда не работают одновременно, и синхронизация состояния не нужна.
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». Таймер один на ключ и момент, поэтому округление времени таймера уменьшает их число. Таймеры тоже входят в снимок, и миллион лишних таймеров удлиняет чекпоинт.
Документация: Process Function
TTL, срок хранения
Таймер нужен, когда момент очистки зависит от логики, например от времени события. Если достаточно «забыть ключ через час после записи», проще включить TTL. Это настройка дескриптора, а Flink сам перестанет отдавать просроченное значение и со временем удалит его.
В исходниках
Первое, что видно в StateTtlConfig, это единственная временная шкала. TTL считается только по времени обработки.
/** This option configures time scale to use for ttl. */
@PublicEvolving
public enum TtlTimeCharacteristic {
/** Processing time. */
ProcessingTime
}
Значения по умолчанию в построителе. Срок продлевается при создании и при записи, чтение его не продлевает. Просроченное значение не возвращается никогда, даже если физически ещё лежит в бэкенде. Фоновая очистка включена.
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.
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.
Документация: Working with State, раздел State Time-To-Live
Если задание отстало на два часа и догоняет Kafka, платежи двухчасовой давности пролетают за минуты. Для TTL между ними прошли минуты, и дубликат будет пойман. В обратную сторону это тоже работает. Повтор, который пришёл через 61 минуту по часам TaskManager, пройдёт, даже если по времени события он рядом с оригиналом. Когда логика привязана к времени события, используй таймер.
Кардинальность ключей
Размер состояния с ключом равен числу ключей, умноженному на размер состояния одного ключа. Оба множителя растут незаметно.
Первый множитель задаёт поле в keyBy. Карт в генераторе 5000, и состояние правила частоты ограничено этим числом. Дедупликация разложена по paymentId, и каждый платёж там новый ключ. Без TTL состояние дедупликации растёт вечно, с TTL в час оно держится около «скорость умножить на час», то есть 720 000 ключей при 200 платежах в секунду. Прежде чем писать keyBy, спроси себя, сколько разных значений будет у поля через месяц.
Второй множитель зависит от того, что ты хранишь на ключ. Самое простое правило частоты держит в ListState все платежи карты за минуту и на каждом платеже пересчитывает список. Под атакой это сотни объектов на карту. PayGuard хранит корзины, то есть в MapState лежит «начало 10-секундного интервала → число платежей». Корзин на карту не больше семи, сколько бы платежей ни было.
У корзин есть цена, это точность. Правило берёт корзину целиком, если её конец позже начала минуты. Платёж, сделанный 65 секунд назад, может попасть в счёт, и окно фактически плавает от 60 до 70 секунд. Для антифрода это нормально. Если нужна точность до секунды, уменьши корзину, и таймеров станет больше.
В исходниках
Деталь, которая влияет на кардинальность в куче. MapState.remove у HeapMapState удаляет запись ключа целиком, когда словарь опустел.
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 остаётся в таблице состояния.
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, и потребитель сможет отбросить повтор.
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, поэтому её состояние это один флаг на платёж.
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 он никому не нужен.
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. Лимит приходит через конструктор, поэтому его можно менять параметром запуска без пересборки.
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 появляются топик алертов и лимиты правил. Поле лимита суммы пригодится в задании, заведи его сразу, чтобы не возвращаться к файлу.
@@ -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 теперь возвращает очищенный поток, и окна продавцов и сессий карт получают его без изменений в своём коде.
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.
KeyedStream<PaymentEvent, String> byCard = payments.keyBy(PaymentEvent::cardId);
sinkAlerts(fraudAlerts(byCard, config), config);
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. Приёмник получает готовый поток алертов.
.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.
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.
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, тот же класс, что разбирали в теории.
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 делает то же самое, если не включено асинхронное состояние.
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.
public void setProcessingTime(long time) throws Exception {
processingTimeService.setCurrentTime(time);
}
public void setStateTtlProcessingTime(long timeStamp) {
ttlTimeProvider.setCurrentTimestamp(timeStamp);
}
Чего harness не проверит, так это сериализацию функции при отправке задания. Её ловит только настоящий запуск, в уроке 8 мы на это наткнёмся.
Документация: Testing
./gradlew test --tests 'dev.study.payguard.pipeline.DeduplicateFunctionTest' --tests 'dev.study.payguard.pipeline.VelocityRuleTest'
Флаг --tests можно повторять, тогда запустятся оба класса. Все шесть тестов должны пройти. Попробуй ради эксперимента убрать строку с enableTimeToLive. Тест paymentIsForgottenAfterTtl упадёт, на выходе останется один платёж.
Шаг 7. Генератор с сериями
Правилу частоты нужна атака, а случайные платежи по 5000 картам почти никогда не дают 11 платежей карты за минуту. Генератор учится делать серию из 25 платежей по одной карте подряд. Новая опция --burst задаёт долю, с которой серия начинается вместо обычного платежа.
@@ -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 переиспользует его для одной карты. Время событий серии идёт с шагом в миллисекунду, а суммы мелкие, так и выглядит проверка украденной карты.
@@ -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 складывает серию в очередь и отдаёт по одному платежу за вызов, поэтому темп генератора не меняется.
@@ -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;
}
Осталось передать долю серий в конструктор и поправить старый тест под новую сигнатуру.
@@ -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);
@@ -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 минут потрачено больше лимита.
- Создай
pipeline/AmountSpikeRuleнаKeyedProcessFunctionс идентификатором правилаamount-spike. Окно 10 минут по времени события, лимит в минимальных единицах валюты приходит через конструктор. - В состоянии не должны копиться сами платежи. Используй
BucketedSumи подбери размер корзины так, чтобы корзин на карту было не больше одиннадцати. - Старые корзины должны уходить из состояния по таймеру. На одну корзину не больше одного таймера.
- Не больше одного алерта на карту за 10 минут. В
detailнапиши, сколько потрачено. - Подключи правило к тому же потоку
byCardс именем и uidrule-amount-spike. Алерты обоих правил должны уходить вfraud.alertsчерез один приёмникalerts-sink. Лимит бери изamount-spike-limit-minorвJobConfig. - Напиши
AmountSpikeRuleTestна harness. Два платежа по 600 с лимитом 1000 дают алерт, если между ними 5 минут, и не дают, если больше 10 минут.
Готово, если выполнены все условия.
./gradlew testпроходит.- В графе задания на стенде есть вершина
rule-amount-spikeрядом сrule-velocity. - С заниженным лимитом в
fraud.alertsпоявляются записи с"ruleId":"amount-spike".
Подсказка первая. Сравни это правило с правилом частоты построчно. Отличий меньше, чем кажется.
Подсказка вторая. Подумай заранее, сколько алертов даст правило с лимитом по умолчанию на нашем генераторе. Посчитай, сколько в среднем платежей приходится на карту за 10 минут при 200 платежах в секунду и какая у них средняя сумма.
Ответ
Правило повторяет VelocityRule. Окно 10 минут, корзина минута, поэтому корзин на карту не больше одиннадцати. В корзину добавляется сумма платежа вместо единицы. Таймер стоит на конце корзины плюс окно, и все платежи одной минуты делят его.
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;
/** Правило суммы: потрачено слишком много по одной карте за 10 минут. Ключ потока это cardId. */
public class AmountSpikeRule extends KeyedProcessFunction<String, PaymentEvent, FraudAlert> {
public static final String RULE_ID = "amount-spike";
private static final Duration WINDOW = Duration.ofMinutes(10);
private static final Duration BUCKET = Duration.ofMinutes(1);
private final long limitMinor;
private transient BucketedSum amounts;
private transient ValueState<Long> lastAlertAtMs;
public AmountSpikeRule(long limitMinor) {
this.limitMinor = limitMinor;
}
@Override
public void open(OpenContext context) {
amounts = new BucketedSum(getRuntimeContext().getMapState(
new MapStateDescriptor<>("amounts", 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();
amounts.add(eventTime, payment.amountMinor());
context.timerService().registerEventTimeTimer(amounts.bucketEnd(eventTime) + WINDOW.toMillis());
long spent = amounts.sumSince(eventTime - WINDOW.toMillis());
if (spent > limitMinor && alertIsDue(eventTime)) {
lastAlertAtMs.update(eventTime);
out.collect(FraudAlert.of(RULE_ID, payment, "потрачено " + spent + " за 10 минут"));
}
}
@Override
public void onTimer(long timestamp, OnTimerContext context, Collector<FraudAlert> out) throws Exception {
amounts.evictBefore(timestamp - WINDOW.toMillis());
}
private boolean alertIsDue(long eventTime) throws Exception {
Long last = lastAlertAtMs.value();
return last == null || eventTime - last >= WINDOW.toMillis();
}
}
Имя last-alert-at совпадает с именем в правиле частоты, и конфликта здесь нет. Состояние принадлежит оператору, у каждого правила свой оператор со своим uid.
Тест проверяет обе стороны окна. Платежи на 1 с и на 300 с лежат в пределах 10 минут, сумма 1200 больше лимита. Платёж на 700 с отстоит от первого больше чем на 10 минут, а корзина от 0 до 60 с кончается раньше начала окна, 100 с, и не учитывается.
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 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 AmountSpikeRuleTest {
private KeyedOneInputStreamOperatorTestHarness<String, PaymentEvent, FraudAlert> harness;
@BeforeEach
void setUp() throws Exception {
harness = ProcessFunctionTestHarnesses.forKeyedProcessFunction(
new AmountSpikeRule(1_000), PaymentEvent::cardId, Types.STRING);
}
@Test
void alertsWhenSumOverWindowExceedsLimit() throws Exception {
harness.processElement(payment("p1", "c1", "m1", 600, 1_000), 1_000);
assertThat(harness.extractOutputValues()).isEmpty();
harness.processElement(payment("p2", "c1", "m1", 600, 300_000), 300_000);
assertThat(harness.extractOutputValues()).singleElement()
.satisfies(alert -> assertThat(alert.ruleId()).isEqualTo(AmountSpikeRule.RULE_ID));
}
@Test
void spendingOutsideWindowDoesNotCount() throws Exception {
harness.processElement(payment("p1", "c1", "m1", 600, 1_000), 1_000);
harness.processElement(payment("p2", "c1", "m1", 600, 700_000), 700_000);
assertThat(harness.extractOutputValues()).isEmpty();
}
}
В задании оба правила подключены к одному разложению по карте, их алерты сливаются через union и идут в общий приёмник.
/** Все правила антифрода, сведённые в один поток алертов. */
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");
DataStream<FraudAlert> amountAlerts = byCard
.process(new AmountSpikeRule(config.amountSpikeLimitMinor()))
.name("rule-amount-spike")
.uid("rule-amount-spike");
return velocityAlerts.union(amountAlerts);
}
private static void sinkAlerts(DataStream<FraudAlert> allAlerts, JobConfig config) {
allAlerts
.sinkTo(KafkaSinks.<FraudAlert>atLeastOnceJson(config.bootstrapServers(), config.alertsTopic()))
.name("alerts-sink")
.uid("alerts-sink");
}
Полная разница задания с прошлым уроком.
@@ -1,21 +1,26 @@ package dev.study.payguard.job;
import dev.study.payguard.model.CardSession;
+import dev.study.payguard.model.FraudAlert; import dev.study.payguard.model.MerchantStats;
import dev.study.payguard.model.ParsedRecord;
import dev.study.payguard.model.PaymentEvent;
import dev.study.payguard.model.RejectedRecord;
+import dev.study.payguard.pipeline.AmountSpikeRule; import dev.study.payguard.pipeline.CardSessionWindow;
+import dev.study.payguard.pipeline.DeduplicateFunction; import dev.study.payguard.pipeline.LatenessProbe;
import dev.study.payguard.pipeline.MerchantStatsWindow;
import dev.study.payguard.pipeline.ParsedRecordSchema;
import dev.study.payguard.pipeline.PaymentWatermarks;
import dev.study.payguard.pipeline.SplitParsedRecords;
+import dev.study.payguard.pipeline.VelocityRule; 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.DataStream;
+import org.apache.flink.streaming.api.datastream.KeyedStream; import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
@@ -32,6 +37,9 @@ DataStream<PaymentEvent> payments = parsePayments(env, config);
attachMerchantStats(payments, config);
attachCardSessions(payments, config);
++ KeyedStream<PaymentEvent, String> byCard = payments.keyBy(PaymentEvent::cardId);+ sinkAlerts(fraudAlerts(byCard, config), config);
env.execute("payguard");
}
@@ -53,7 +61,11 @@ return split
.process(new LatenessProbe())
.name("lateness-probe")
- .uid("lateness-probe");+ .uid("lateness-probe")+ .keyBy(PaymentEvent::paymentId)+ .process(new DeduplicateFunction())+ .name("deduplicate")+ .uid("deduplicate"); }
private static void attachMerchantStats(DataStream<PaymentEvent> payments, JobConfig config) {
@@ -75,6 +87,27 @@ .uid("card-sessions-sink");
}
+ /** Все правила антифрода, сведённые в один поток алертов. */+ 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");+ DataStream<FraudAlert> amountAlerts = byCard+ .process(new AmountSpikeRule(config.amountSpikeLimitMinor()))+ .name("rule-amount-spike")+ .uid("rule-amount-spike");++ return velocityAlerts.union(amountAlerts);+ }++ private static void sinkAlerts(DataStream<FraudAlert> allAlerts, JobConfig config) {+ allAlerts+ .sinkTo(KafkaSinks.<FraudAlert>atLeastOnceJson(config.bootstrapServers(), config.alertsTopic()))+ .name("alerts-sink")+ .uid("alerts-sink");+ }+ private static KafkaSource<ParsedRecord> paymentSource(JobConfig config) {
return KafkaSource.<ParsedRecord>builder()
.setBootstrapServers(config.bootstrapServers())
Итог прогона всех тестов снапшота на стенде курса.
RESULT tests 26 fail 0
jar bytes 24362847
Граф задания после отправки.
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":"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":"rule-velocity","parallelism":2,"status":"RUNNING"},{"name":"rule-amount-spike","parallelism":2,"status":"RUNNING"},{"name":"alerts-sink: Writer -> alerts-sink: Committer","parallelism":2,"status":"RUNNING"}]}
Вершин семь. Дедупликация стоит отдельно, потому что перед ней keyBy по платежу. Правила используют один KeyedStream, но у каждого свой вход через сеть, и сцепить их нельзя. Приёмник алертов тоже отдельная вершина, хотя рёбра к нему прямые. У него два входа, а сцепление требует ровно одного.
В исходниках
Первое условие сцепления в генераторе графа задания проверяет число входящих рёбер у принимающего оператора.
public static boolean isChainable(
StreamEdge edge, StreamGraph streamGraph, boolean allowChainWithDefaultParallelism) {
StreamNode downStreamVertex = streamGraph.getTargetVertex(edge);
return downStreamVertex.getInEdges().size() == 1
&& isChainableInput(edge, streamGraph, allowChainWithDefaultParallelism);
}
После union у приёмника два ребра, от rule-velocity и от rule-amount-spike, поэтому он начинает собственную цепочку.
На нашем стенде вершина rule-amount-spike получила 17264 платежа и выдала 0 алертов. Это ожидаемо. При 200 платежах в секунду на одну из 5000 карт приходится 2,4 платежа в минуту, то есть около 24 за 10 минут. Обычный платёж генератора стоит от 100 до 50 099 минимальных единиц, в среднем около 25 000, и за 10 минут карта тратит порядка 600 000. Серия из 25 мелких платежей добавляет меньше 130 000. До лимита 2 000 000 далеко. Чтобы увидеть правило в работе, отправь задание с заниженным лимитом, параметры submit.sh передаёт заданию как есть.
scripts/submit.sh --amount-spike-limit-minor 300000
Перед этим отмени текущее задание. В fraud.alerts должны появиться записи с "ruleId":"amount-spike" и текстом «потрачено ... за 10 минут». У каждой карты такой алерт будет не чаще раза в 10 минут.
Глоссарий урока
- Состояние с ключом (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, покупатели это платежи. Каждый платёж попадает в одну подзадачу, ту, что отвечает за его карту. Каждое правило попадает во все.
Как устроен broadcast state
Поток правил становится широковещательным после вызова broadcast(descriptor). Метод ставит разбиение BROADCAST, при котором каждая запись уходит во все подзадачи следующего оператора, и запоминает дескрипторы состояния. Дескриптор MapStateDescriptor задаёт имя и типы ключа и значения, так что broadcast state устроен как словарь.
Затем поток платежей с ключом соединяется с ним через connect, и в process передаётся KeyedBroadcastProcessFunction. У функции два главных метода. processElement вызывается для каждого платежа, processBroadcastElement для каждого правила.
В исходниках
Метод broadcast у DataStream ставит BroadcastPartitioner и заворачивает поток вместе с дескрипторами в BroadcastStream.
@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.
@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. Чем они отличаются, посмотрим в следующем разделе.
Документация: The Broadcast State Pattern, Broadcasting
Почему писать можно только со стороны правил
Подзадачи Flink не переговариваются между собой. Каждая хранит свою копию broadcast state, и копии совпадают по одной причине. Все подзадачи получают одни и те же правила и делают с ними одно и то же. Поэтому код processBroadcastElement обязан быть детерминированным. Если он смотрит на часы машины, на случайное число или во внешнюю базу, копии разойдутся.
Платёж приходит только в одну подзадачу. Если бы processElement мог менять broadcast state, изменение попало бы в одну копию из нескольких. Поэтому сторона платежей читает правила, но писать в них не может. Зато ей доступны состояние с ключом и таймеры. У стороны правил таймеров нет, взамен она может пройтись по состоянию всех ключей своей подзадачи методом applyToKeyedState.
В исходниках
Описание KeyedBroadcastProcessFunction прямо распределяет права между двумя методами.
* <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.
@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, а его описание предупреждает, что значения отдаются без копирования.
/**
* 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, и код правил только читает его поля.
Документация: BroadcastProcessFunction and KeyedBroadcastProcessFunction
Порядок, снимки и память
Детерминированный код ещё не гарантирует одинаковых копий. Если правила читают две подзадачи источника, каждая подзадача оператора получает записи от обеих по двум разным каналам. Внутри канала порядок сохраняется. Как перемешаются два канала, у каждой подзадачи решается по-своему. Документация говорит прямо, что порядок правил в разных подзадачах может различаться и обновление не должно от него зависеть.
У нас обновление устроено по принципу «последнее побеждает», ключ current просто перезаписывается. Если аналитик отправит два сообщения подряд и они лягут в разные партиции rules.config, подзадачи могут закончить на разных версиях. На учебном стенде у топика четыре партиции, и мы отправляем одно правило, поэтому это не мешает. В проде делай топик правил с одной партицией или добавь в правило номер версии и принимай только более новую.
При чекпоинте свою копию сохраняет каждая подзадача, так что в снимке broadcast state лежит столько раз, каков параллелизм. Зато при восстановлении подзадачи не читают один и тот же файл. После восстановления или смены параллелизма каждая подзадача получает целую копию, и поэтому копии обязаны совпадать.
В исходниках
Описание интерфейса BroadcastState объясняет, как копии раздаются при восстановлении и почему расхождение копий даёт непредсказуемый результат.
/**
* 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.
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.
Документация: Broadcast State, Important Considerations
Пороги и списки стран весят килобайты, и копия в каждой подзадаче ничего не стоит. Справочник продавцов на миллионы строк сюда класть нельзя. Он займёт кучу каждого TaskManager столько раз, сколько у оператора подзадач на этой машине, и столько же раз попадёт в чекпоинт. Для больших справочников есть Async I/O, о нём урок 9.
Путь одного правила
Пройди по шагам, что происходит с сообщением аналитика от консоли до проверки следующего платежа.
Два входа и одно время
У оператора rules-dynamic два входа, и у каждого свой watermark. Правило слияния ты знаешь по уроку 5. Время оператора равно минимуму по входам, потому что обещание «старше T ничего не придёт» верно, только если его дал каждый вход. Для двух входов действует тот же минимум.
Теперь представь, что поток правил подключён с WatermarkStrategy.noWatermarks(). Выбор кажется естественным, ведь у настроек нет времени события. Такой вход ни разу не пришлёт watermark, его время навсегда остаётся минимально возможным, и минимум по двум входам тоже. Время оператора стоит на месте, таймеры по времени события не срабатывают. В DynamicRules это таймеры, которые выбрасывают старые корзины счётчика, поэтому состояние растёт без предела. Алерты при этом приходят, исключений в логе нет.
Выбери стратегию для входа правил и запусти пять минут времени событий. Слева видно watermark входов и оператора, справа число корзин одной карты в состоянии.
Два входа одного оператора
Выбери стратегию для входа правил и нажми кнопку.
Модель повторяет DynamicRules. Платёж приходит раз в 10 секунд, корзина равна 10 секундам, окно минуте, допуск беспорядка платежей 10 секундам, как в PaymentWatermarks.
В исходниках
Проследим watermark от сети до оператора. Выход сетевого входа знает свой номер и вызывает у оператора processWatermark1 или processWatermark2.
@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, только если общий вырос.
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. Сейчас нас интересует начальное значение каждого входа.
/** 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, его генератор ничего не выпускает.
/**
* 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 пуст в обоих методах, так что и признака простоя от него не будет.
Документация: Watermarks in Parallel Streams, Writing WatermarkGenerators
Исправление в нашем проекте называется 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 с двумя полями, лимитом платежей карты в минуту и списком заблокированных стран.
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 упал ещё до старта задания.
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 окружения.
@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 его пропускает.
Документация: Data Types & Serialization
В JobConfig добавь имя топика правил. Умолчание rules.config совпадает с топиком, который уже создаёт служба kafka-init.
@@ -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.
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.
@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.
@PublicEvolving
default void deserialize(byte[] message, Collector<T> out) throws IOException {
T deserialize = deserialize(message);
if (deserialize != null) {
out.collect(deserialize);
}
}
Пустой результат просто не попадает в поток, и задание продолжает работать с прежними правилами.
Теперь о том, что было бы без этой схемы. Стратегия перезапуска по умолчанию зависит от того, включены ли чекпоинты. Описание настройки restart-strategy.type говорит об этом прямо.
"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());
Документация: Apache Kafka Connector, Restart Strategies
Шаг 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 относятся к заданию, их пока не пиши. Начало файла до первой из них.
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.
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().
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. Третий тест в файле снапшота относится к заданию, поэтому возьми строки до него и закрой класс фигурной скобкой.
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 появляется второй источник и оператор с двумя входами.
@@ -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
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, а наружу показывается их минимум.
public AbstractTwoInputStreamTask(Environment env) throws Exception {
super(env);
input1WatermarkGauge = new WatermarkGauge();
input2WatermarkGauge = new WatermarkGauge();
minInputWatermarkGauge = new MinWatermarkGauge(input1WatermarkGauge, input2WatermarkGauge);
}
Измеритель входа начинает с Long.MIN_VALUE и меняется, только когда по входу пришёл watermark. Минимум считается обычным min.
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;
}
}
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.
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 отдаёт именно эту метрику по каждой подзадаче вершины.
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));
}
}
Документация: REST API
Задание. Блокировка стран
В RuleConfig уже есть поле blockedCountries, но DynamicRules его не читает. Допиши правило, которое срабатывает на платежи из заблокированных стран.
- Если страна платежа есть в списке
blockedCountriesтекущих настроек, функция выдаёт алерт. Идентификатор правилаblocked-country, объяви его константой рядом сVELOCITY_RULE. В полеdetailнапиши, какая страна заблокирована. - Алерт выдаётся на каждый такой платёж, без ограничения «раз в минуту», как у velocity.
- Сообщение без поля
blockedCountriesне должно ронять задание. - Добавь в
DynamicRulesTestтест. Пришли правило с одной страной и проверь, что первый же платёж из неё даёт ровно один алерт с идентификаторомblocked-country:<paymentId>.
Готово, если ./gradlew test проходит, а на стенде после правила с ["PL"] в fraud.alerts появляются алерты blocked-country, примерно на каждый пятый платёж.
Подсказки. Заготовка paymentOnCard из testing/Payments создаёт платёж из страны DE. Подумай, что Jackson положит в поле record, если в JSON такого поля нет. Помни, что настройки в processElement только читаются, менять список нельзя.
Ответ
Константа правила стоит рядом с идентификатором velocity.
public static final String VELOCITY_RULE = "velocity";
public static final String BLOCKED_COUNTRY_RULE = "blocked-country";
Проверка вызывается из processElement до правила частоты. Оба правила получают один и тот же объект настроек, прочитанный из broadcast state один раз.
@Override
public void processElement(PaymentEvent payment, ReadOnlyContext context, Collector<FraudAlert> out) throws Exception {
RuleConfig rules = currentRules(context.getBroadcastState(RULES));
checkBlockedCountry(payment, rules, out);
checkVelocity(payment, rules, context, out);
}
Сама проверка.
private void checkBlockedCountry(PaymentEvent payment, RuleConfig rules, Collector<FraudAlert> out) {
List<String> blocked = rules.blockedCountries();
if (blocked != null && blocked.contains(payment.country())) {
out.collect(FraudAlert.of(BLOCKED_COUNTRY_RULE, payment, "страна " + payment.country() + " заблокирована"));
}
}
Если в сообщении нет поля blockedCountries, Jackson передаст в конструктор record null, поэтому сначала проверка на null. Состояние с ключом правилу не нужно. Решение зависит только от платежа и настроек, а идентификатор алерта строится из правила и paymentId, так что повторная обработка того же платежа даст тот же алерт. Снять блокировку можно новым сообщением с пустым списком.
Тест присылает правило с DE и подаёт один платёж из DE.
@Test
void blockedCountryAlertsOnFirstPayment() throws Exception {
harness.processBroadcastElement(new RuleConfig(100, List.of("DE")), 0);
pay("p1", 1_000);
assertThat(alertIds()).containsExactly("blocked-country:p1");
}
}
Проверь сборку и стенд. В нашем прогоне весь набор, 25 тестов, прошёл без падений.
./gradlew test shadowJar
docker compose exec jobmanager flink cancel <JobID>
scripts/submit.sh
./gradlew loadgen
echo '{"velocityMaxPayments":1000,"blockedCountries":["PL"]}' | docker compose exec -T kafka \
/opt/kafka/bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic rules.config
docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 \
--topic fraud.alerts --from-beginning --timeout-ms 10000 2>/dev/null | grep -c '"ruleId":"blocked-country"'
--timeout-ms завершает чтение, если 10 секунд нет новых записей, а grep -c считает строки с нужным правилом. На нашем стенде за 25 секунд при 100 платежах в секунду пришло 539 алертов blocked-country, задание не перезапускалось. Это около пятой части от 2500 платежей, и так и должно быть, генератор выбирает страну равномерно из пяти.
private static final List<String> COUNTRIES = List.of("DE", "FR", "PL", "ES", "NL");
Глоссарий урока
- 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, то есть в очередь дел самой подзадачи, и обрабатывается её потоком. Поэтому выдавать записи дальше из потока пула безопасно, гонок с обработкой входа нет.
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 и счётчик в метриках.
@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());
}
Документация: Asynchronous I/O for External Data Access
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, который поднимается вверх по графу.
private ResultFuture<OUT> addToWorkQueue(StreamElement streamElement)
throws InterruptedException {
Optional<ResultFuture<OUT>> queueEntry;
while (!(queueEntry = queue.tryPut(streamElement)).isPresent()) {
mailboxExecutor.yield();
}
return queueEntry.get();
}
Сама проверка места в упорядоченной очереди. Место освобождается, только когда запись покинула очередь, то есть её результат выдан дальше. Готовый, но не выданный результат продолжает занимать место.
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);
Документация: Asynchronous I/O, параметры Timeout и Capacity
Ordered и unordered
Запросы возвращаются в случайном порядке. В режиме unordered результат уходит дальше сразу, как только готов. В режиме ordered он ждёт, пока выйдут все записи, пришедшие раньше. Один медленный запрос в голове очереди задерживает всех за ним, и они продолжают занимать места. Такую ситуацию называют head-of-line blocking.
Попробуй сам. Каждый восьмой запрос в модели в восемь раз медленнее остальных. Запусти в режиме unordered, потом включи ordered и запусти снова. Серия задержек в обоих прогонах одинаковая.
Очередь асинхронного оператора
Нажми кнопку, и модель прогонит 4 секунды работы одной подзадачи с бесконечной очередью записей на входе.
С watermark режим unordered ведёт себя сложнее. Записи между двумя watermark могут обгонять друг друга. Watermark не обгоняет записи до него, и записи после него не обгоняют его самого. При частых watermark unordered теряет часть своего преимущества. В PayGuard порядок алертов неважен, поэтому стоит unorderedWait.
В исходниках
Упорядоченная очередь считает готовой только голову. Пока первая запись не завершена, hasCompletedElements возвращает false, сколько бы записей за ней ни было готово.
@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. Обгон возможен только внутри сегмента.
/**
* 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).
*/
Документация: Asynchronous I/O, разделы Order of Results и Event Time
Timeout
Timeout защищает от запросов, которые не вернутся никогда. Его отсчёт начинается, когда запись встала в очередь оператора. Время в пуле потоков AlertEnrichment тоже входит в эти 2 секунды. Если пул перегружен, запись может истечь, даже не дойдя до базы. По умолчанию по истечении timeout функция завершает запись исключением, а исключение, как мы видели, перезапускает задание. Поэтому AlertEnrichment переопределяет метод timeout и выдаёт алерт с риском UNKNOWN.
В исходниках
Таймер регистрируется в processElement сразу после того, как запись получила место в очереди, и до вызова пользовательской функции.
} 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.
/**
* {@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."));
}
Документация: Asynchronous I/O, раздел Timeout Handling
Что попадает в чекпоинт
Запрос в полёте нельзя сохранить на диск, соединение с базой не сериализуется. Flink сохраняет то, что можно, то есть входные записи из очереди. Они лежат в состоянии оператора (operator state), которое не привязано к ключу. После восстановления оператор при открытии отправляет их в processElement заново, и запросы уходят повторно.
В исходниках
При снимке весь список записей очереди кладётся в списочное состояние оператора. Метод values возвращает входные элементы, результаты в снимок не попадают.
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);
}
}
При открытии оператора восстановленные элементы снова проходят через обработку, как будто только что пришли со входа.
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;
}
Документация: Asynchronous I/O, раздел Fault Tolerance Guarantees, Working with State
Документация называет гарантии асинхронного оператора 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. Временной меткой пары становится наибольшая из двух.
Оператор держит обе стороны в keyed state. Каждая пришедшая запись кладётся в свой буфер и сверяется с буфером другой стороны. Чтобы буферы не росли бесконечно, на каждую запись ставится таймер очистки по времени события. Платёж с границами 0 и 5 минут живёт в состоянии, пока watermark не пройдёт t + 5 мин. Отсюда оценка объёма. Число платежей в состоянии примерно равно частоте платежей, умноженной на верхнюю границу. При 200 платежах в секунду это 200 × 300 = 60 000 записей. Расширишь интервал до часа, и состояние вырастет в 12 раз.
Запись, время которой уже позади watermark, в буфер не попадает. Если не задать побочный выход для поздних данных, она пропадает молча. В PayGuard watermark отстаёт от свежих платежей на 10 секунд, а генератор присылает часть платежей с опозданием до 30 секунд. Платёж, опоздавший больше чем на 10 секунд, пары не получит.
В исходниках
Буферы обеих сторон создаются как MapState в keyed state. Ключ карты имеет тип Long, это временная метка записи, значение хранит список записей с этой меткой.
@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 пройдёт его собственное время. Платёж с более ранним временем, пришедший после этого, оператор всё равно отбросил бы как поздний.
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);
}
Срабатывание таймера удаляет из буфера всю корзину с нужной временной меткой.
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;
}
Документация: Joining, раздел Interval Join
Interval join выдаёт пару для каждой записи другой стороны, которая попала в интервал. Генератор подтверждает и дубликаты платежей, поэтому одному платежу может достаться два подтверждения. Оператор выдаст две пары с одним paymentId. Если нужна ровно одна, дубликаты убирают до соединения или после него.
Ручной join на KeyedCoProcessFunction
Interval join видит только совпавшие пары. Он не скажет, что подтверждение не пришло. Для антифрода это важный сигнал. Когда нужна логика сложнее, соединение пишут руками на KeyedCoProcessFunction. У неё два метода обработки, по одному на вход, общее keyed state по ключу и таймеры. Документация описывает шаблон так. Одну сторону сохраняют в состоянии, при записи с другой стороны сверяются с ним, а таймер по watermark решает, что ждать больше нечего. Состоянием и его очисткой здесь управляешь ты сам, и ошибка в очистке превращается в утечку. Такой оператор ты напишешь в задании.
Документация: Process Function, раздел Low-level Joins
Сравнение со 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 службу базы, переменные подключения и два новых топика.
@@ -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.
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. Зависимости
@@ -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 означает, что база не ответила или продавца в ней нет. Отдельные значения помогут отличить в топике сбой обогащения от алерта, который его не проходил.
@@ -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.
package dev.study.payguard.model;
/** Подтверждение платежа от банка-эмитента, приходит отдельным потоком. */
public record PaymentConfirmation(String paymentId, String status, long eventTimeMs) {
}
package dev.study.payguard.model;
/** Платёж вместе с его подтверждением и задержкой между ними. */
public record ConfirmedPayment(String paymentId, String merchantId, long amountMinor, String status, long confirmationDelayMs) {
}
В настройки задания добавь имена двух топиков.
@@ -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 позволяет тесту подставить справочник, который отвечает мгновенно, падает или зависает.
package dev.study.payguard.pipeline;
import java.util.Optional;
/** Справочник продавцов. Отдельный тип нужен, чтобы тест подставил медленную или падающую реализацию. */
public interface MerchantDirectory extends AutoCloseable {
Optional<String> riskLevel(String merchantId) throws Exception;
}
Реализация на JDBC берёт адрес и пароль из переменных окружения. Соединения пула открываются только для чтения, размер пула передаётся снаружи.
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.
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 % платежей. Время подтверждения отстоит от времени платежа на случайную величину до трёх секунд.
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();
}
}
}
@@ -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) {
@@ -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.
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, лишние соединения простаивали бы.
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, чтобы оба входа соединения понимали время одинаково.
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, его же возьмёт правило из задания.
DataStream<PaymentConfirmation> confirmations = env
.fromSource(confirmationSource(config), confirmationWatermarks(), "payment-confirmations")
.uid("payment-confirmations");
attachConfirmedPayments(payments, confirmations, config);
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 перед приёмником.
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 и задание при этом не падает.
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 секунд, это больше пяти минут, и пары нет.
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 не появится, и никто об этом не узнает. Для антифрода такой платёж подозрителен. Напиши правило, которое выдаёт алерт, если подтверждение не пришло за пять минут по времени события.
- Создай
pipeline/UnconfirmedPaymentFunctionна основеKeyedCoProcessFunction<String, PaymentEvent, PaymentConfirmation, FraudAlert>. Ключ этоpaymentId, срок ожидания передаётся в конструктор в миллисекундах. - Алерт строй через
FraudAlert.ofс идентификатором правилаunconfirmed. - Подтверждение может прийти раньше платежа. Это нормальный случай, алерта быть не должно.
- После того как судьба платежа решена, состояние и таймеры по этому ключу должны быть очищены.
- Подключи правило в
PayGuardJobс именем и uidrule-unconfirmed. Срок возьми изPaymentConfirmationJoin.MAX_DELAY. Алерты правила должны пройти через обогащение вместе с остальными. - Напиши
UnconfirmedPaymentFunctionTestна harness с двумя входами.
Готово, если выполнены все условия.
- Тесты проверяют четыре случая. Подтверждения нет, и алерт выдан. Подтверждение пришло вовремя, алерта нет и таймеров не осталось. Подтверждение пришло раньше платежа, алерта нет. Watermark сдвинулся только на одном входе, алерта ещё нет.
./gradlew testпроходит.- В графе на стенде появилась вершина
rule-unconfirmed, и её вход равен входуjoin-confirmations.
Подсказка первая. Harness для такой функции создаёт ProcessFunctionTestHarnesses.forKeyedCoProcessFunction. Ему нужны функция, два селектора ключа и тип ключа.
Подсказка вторая. Watermark оператора с двумя входами равен минимуму по входам, это ты видел в уроке 8. В тесте двигай watermark каждого входа отдельно. На стенде помни, что топик подтверждений с --confirm 0 замолчит, и watermark этого входа пойдёт вперёд только через минуту по idleness.
Подсказка третья. Чтобы удалить таймер, нужно знать точное время, на которое он поставлен. Подумай, из какого состояния его можно вычислить.
Ответ
Функция держит по ключу два значения. waiting-payment хранит платёж, который ждёт подтверждения. confirmation-seen отмечает подтверждение, пришедшее раньше платежа.
package dev.study.payguard.pipeline;
import dev.study.payguard.model.FraudAlert;
import dev.study.payguard.model.PaymentConfirmation;
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.co.KeyedCoProcessFunction;
import org.apache.flink.util.Collector;
/** Алерт, если подтверждение не пришло вовремя. Interval join этого не умеет: он видит только совпавшие пары. */
public class UnconfirmedPaymentFunction
extends KeyedCoProcessFunction<String, PaymentEvent, PaymentConfirmation, FraudAlert> {
public static final String RULE_ID = "unconfirmed";
private final long timeoutMs;
private transient ValueState<PaymentEvent> waitingPayment;
private transient ValueState<Boolean> confirmationSeen;
public UnconfirmedPaymentFunction(long timeoutMs) {
this.timeoutMs = timeoutMs;
}
@Override
public void open(OpenContext context) {
waitingPayment = getRuntimeContext().getState(new ValueStateDescriptor<>("waiting-payment", PaymentEvent.class));
confirmationSeen = getRuntimeContext().getState(new ValueStateDescriptor<>("confirmation-seen", Boolean.class));
}
@Override
public void processElement1(PaymentEvent payment, Context context, Collector<FraudAlert> out) throws IOException {
if (confirmationSeen.value() != null) {
confirmationSeen.clear();
return;
}
waitingPayment.update(payment);
context.timerService().registerEventTimeTimer(payment.eventTimeMs() + timeoutMs);
}
@Override
public void processElement2(PaymentConfirmation confirmation, Context context, Collector<FraudAlert> out)
throws IOException {
PaymentEvent payment = waitingPayment.value();
if (payment == null) {
confirmationSeen.update(true);
context.timerService().registerEventTimeTimer(confirmation.eventTimeMs() + timeoutMs);
return;
}
context.timerService().deleteEventTimeTimer(payment.eventTimeMs() + timeoutMs);
waitingPayment.clear();
}
@Override
public void onTimer(long timestamp, OnTimerContext context, Collector<FraudAlert> out) throws IOException {
PaymentEvent payment = waitingPayment.value();
if (payment != null) {
out.collect(FraudAlert.of(RULE_ID, payment, "нет подтверждения за " + timeoutMs / 1000 + " секунд"));
waitingPayment.clear();
}
confirmationSeen.clear();
}
}
Разберём три пути. Платёж пришёл первым. Он сохраняется, и ставится таймер на время платежа плюс срок. Если подтверждение успело, processElement2 находит платёж, вычисляет время таймера из его eventTimeMs, удаляет таймер и очищает состояние. Если не успело, срабатывает onTimer и выдаёт алерт.
Подтверждение пришло первым. Платежа ещё нет, поэтому функция запоминает сам факт подтверждения и ставит таймер очистки на время подтверждения плюс срок. Когда придёт платёж, флаг снимается, и платёж в состояние не кладётся. Без таймера флаг мог бы остаться навсегда. Генератор, например, подтверждает и дубликаты платежей. Второе подтверждение приходит, когда платёж уже обработан и состояние очищено, и ставит флаг, который больше никто не снимет.
Таймер в onTimer общий для обоих случаев. Если в состоянии есть платёж, это алерт. В любом случае флаг очищается, и по ключу ничего не остаётся.
Подключение в задании. Оба потока разбиты по paymentId и соединены через connect. Алерты правила объединяются с остальными до обогащения.
/** Алерт по платежу, подтверждение которого не пришло вовремя. */
private static DataStream<FraudAlert> unconfirmedAlerts(
DataStream<PaymentEvent> payments, DataStream<PaymentConfirmation> confirmations) {
return payments.keyBy(PaymentEvent::paymentId)
.connect(confirmations.keyBy(PaymentConfirmation::paymentId))
.process(new UnconfirmedPaymentFunction(PaymentConfirmationJoin.MAX_DELAY.toMillis()))
.name("rule-unconfirmed")
.uid("rule-unconfirmed");
}
Тест проверяет все четыре случая. Последний тест двигает watermark только первого входа. Watermark оператора остаётся минимальным из двух, поэтому таймер не срабатывает.
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.PaymentConfirmation;
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.KeyedTwoInputStreamOperatorTestHarness;
import org.apache.flink.streaming.util.ProcessFunctionTestHarnesses;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class UnconfirmedPaymentFunctionTest {
private static final long TIMEOUT = 300_000;
private KeyedTwoInputStreamOperatorTestHarness<String, PaymentEvent, PaymentConfirmation, FraudAlert> harness;
@BeforeEach
void setUp() throws Exception {
harness = ProcessFunctionTestHarnesses.forKeyedCoProcessFunction(
new UnconfirmedPaymentFunction(TIMEOUT),
PaymentEvent::paymentId,
PaymentConfirmation::paymentId,
Types.STRING);
}
private void advanceBothWatermarks(long time) throws Exception {
harness.processWatermark1(new Watermark(time));
harness.processWatermark2(new Watermark(time));
}
@Test
void alertsWhenConfirmationNeverArrives() throws Exception {
harness.processElement1(paymentOnCard("p1", "c1", 1_000), 1_000);
advanceBothWatermarks(1_000 + TIMEOUT);
assertThat(harness.extractOutputValues()).singleElement()
.satisfies(alert -> assertThat(alert.alertId()).isEqualTo("unconfirmed:p1"));
}
@Test
void confirmationInTimeCancelsAlert() throws Exception {
harness.processElement1(paymentOnCard("p1", "c1", 1_000), 1_000);
harness.processElement2(new PaymentConfirmation("p1", "OK", 5_000), 5_000);
advanceBothWatermarks(1_000 + TIMEOUT);
assertThat(harness.extractOutputValues()).isEmpty();
assertThat(harness.numEventTimeTimers()).isZero();
}
@Test
void confirmationArrivingBeforePaymentAlsoCounts() throws Exception {
harness.processElement2(new PaymentConfirmation("p1", "OK", 900), 900);
harness.processElement1(paymentOnCard("p1", "c1", 1_000), 1_000);
advanceBothWatermarks(10 * TIMEOUT);
assertThat(harness.extractOutputValues()).isEmpty();
}
@Test
void timerDoesNotFireUntilSlowerInputCatchesUp() throws Exception {
harness.processElement1(paymentOnCard("p1", "c1", 1_000), 1_000);
harness.processWatermark1(new Watermark(1_000 + TIMEOUT));
assertThat(harness.extractOutputValues()).isEmpty();
}
}
Итог прогона всех тестов снапшота на стенде курса. Jar вырос до 26 МБ за счёт драйвера PostgreSQL и HikariCP.
RESULT tests 33 fail 0
jar bytes 26012929
На стенде вершина rule-unconfirmed получила 33845 записей, столько же, сколько join-confirmations. Оба оператора читают одни и те же два потока, разбитые по одному ключу.
[{"id":"numRecordsIn","min":16895.0,"max":16950.0,"sum":33845.0},{"id":"numRecordsOut","min":0.0,"max":0.0,"sum":0.0},{"id":"busyTimeMsPerSecond","min":2.0,"max":2.0,"sum":4.0},{"id":"backPressuredTimeMsPerSecond","min":0.0,"max":0.0,"sum":0.0}]
Алертов в этом замере ещё ноль, и так должно быть. Таймер ждёт пять минут времени события, поэтому первый алерт появится не раньше, чем watermark обоих входов уйдёт на пять минут вперёд от первого неподтверждённого платежа. Чтобы увидеть алерты, оставь генератор работать дольше пяти минут, можно с --confirm 0.5, и отфильтруй топик алертов.
docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server localhost:9092 --topic fraud.alerts --from-beginning --timeout-ms 5000 2>/dev/null \
| jq -c 'select(.ruleId == "unconfirmed") | {paymentId, merchantRisk, detail}' | head -3
У каждого такого алерта будет detail «нет подтверждения за 300 секунд» и заполненный merchantRisk, потому что правило стоит до обогащения. Мы этот вывод на стенде не снимали, поэтому листинга здесь нет.
Глоссарий урока
- Обогащение (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.
/** Проверка карты: несколько мелких платежей подряд и затем крупный. */
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.
/**
* 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);
}
Смежность внутри шага хранит квантификатор в отдельном поле. Его начальное значение и есть то «по умолчанию ослабленная», о котором говорит документация.
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 условие ребра всегда истинно, и автомат может пропустить даже подходящее событие.
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;
}
Документация: Combining Patterns, Contiguity within looping patterns
Попробуй сам. Модель перебирает все совпадения шаблона «три мелких платежа и больше, потом крупный» так, как это делает CEP без стратегии пропуска. М означает мелкий платёж, О обычный, К крупный.
Смежность и число совпадений
Выбери смежность и последовательность платежей.
Атака генератора состоит из пяти мелких платежей и одного крупного. Даже со строгой смежностью шаблон находит три совпадения, они начинаются с первого, второго и третьего мелкого платежа. Включи ослабленную смежность внутри серии на примере с разрывом, и совпадений станет больше, потому что обычный платёж теперь пропускается. Поэтому в PayGuard у шаблона стоит стратегия пропуска после совпадения, о ней чуть ниже.
Шаблон становится автоматом
При запуске задания NFACompiler переводит шаблон в недетерминированный конечный автомат, NFA (nondeterministic finite automaton). Состояния автомата соответствуют шагам шаблона, а у каждого состояния есть рёбра трёх видов. TAKE принимает событие в шаг. IGNORE пропускает событие. PROCEED переходит к следующему шагу, не тратя событие. Недетерминированным автомат называют потому, что из одного состояния на одно событие могут сработать несколько рёбер сразу, и попытка раздваивается.
Схема упрощённая. Компилятор разворачивает timesOrMore(3) в цепочку из трёх обязательных копий шага и петлю за ними, а начальное состояние никогда не обрывается, поэтому каждый мелкий платёж начинает новую попытку.
В исходниках
Посмотрим, как автомат обрабатывает событие. Метод doProcess проходит по всем частичным совпадениям ключа и для каждого вычисляет новые состояния. Если состояний получилось больше одного, попытка раздвоилась. Дошедшие до конца становятся кандидатами в совпадения, остальные сохраняются до следующего события.
// 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 на длинной серии событий умножают работу и состояние.
Документация: FlinkCEP
Стратегия пропуска после совпадения
Когда совпадение найдено, оставшиеся попытки, которые делят с ним события, по умолчанию продолжают жить. Это стратегия NO_SKIP, и она дала бы три алерта на одну атаку. Стратегия skipPastLastEvent() выбрасывает все попытки, начатые до конца найденного совпадения. На атаке генератора остаётся одно совпадение, самое длинное, потому что оно начинается раньше остальных. Стратегию передают первым шагом в Pattern.begin.
Время и порядок событий
Шаблон описывает порядок, а события из Kafka приходят с беспорядком. Если обработать их в порядке прихода, опоздавший мелкий платёж встанет после крупного, и совпадение потеряется. Поэтому оператор CEP по умолчанию работает во времени события и сначала складывает события в буфер.
В исходниках
В CepOperator.processElement событие с меткой больше текущего watermark попадает в буфер. Событие с меткой не больше watermark считается опоздавшим. Если побочный выход для опоздавших не задан, оно молча выбрасывается, и растёт только счётчик numLateRecordsDropped.
} 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.
// 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 секунд времени события. Платёж, опоздавший сильнее допуска, в шаблон не попадёт вовсе.
Документация: Handling Lateness in Event Time, After Match Skip Strategy
Что лежит в состоянии
Оператор CEP держит по каждому ключу три вещи. Это состояние автомата со списком частичных совпадений, буфер событий, ждущих watermark, и общий буфер событий, на которые ссылаются попытки.
В исходниках
Все три структуры создаются как keyed state, значит попадают в чекпоинт и переезжают вместе с ключом при смене параллелизма. Общий буфер SharedBuffer хранит каждое событие один раз, а попытки ссылаются на него, поэтому десять попыток с общим началом не копируют события десять раз.
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, иначе состоянию нечем очищаться.
Документация: Individual Patterns
Попытка, которая так и не дождалась последнего шага, без ограничения по времени остаётся в состоянии навсегда. У карты, которая один раз сделала мелкий платёж, попытка card testing висела бы годами. Ставь within на каждый шаблон и выбирай его по бизнес-смыслу. Для проверки карты это пять минут, для перелёта десять.
CEP или своя функция
Ту же проверку карты можно написать на KeyedProcessFunction из урока 7. Счётчик мелких платежей в ValueState, сброс на обычном платеже, таймер на пять минут. Для одного правила кода немного, и ты точно знаешь, сколько состояния занято. CEP выигрывает, когда шаблонов много, у них разные смежности и квантификаторы, и аналитики хотят читать правило как описание последовательности.
| Вопрос | KeyedProcessFunction | CEP |
|---|---|---|
| Порядок событий во времени | Сортировку и буфер пишешь сам | Буфер до watermark встроен |
| Объём состояния | Ровно то, что объявил | Зависит от смежности и числа попыток |
| Несколько вариантов совпадения | Каждую ветку кодируешь руками | Автомат ведёт ветки сам |
| Таймаут неполного совпадения | Таймер и очистка руками | within и обработчик таймаутов |
В Spark Structured Streaming библиотеки шаблонов нет. Похожую логику там пишут в flatMapGroupsWithState или transformWithState, то есть так же вручную, как на KeyedProcessFunction. Во Flink SQL шаблоны описывает оператор MATCH_RECOGNIZE из стандарта SQL, и документация говорит, что внутри он использует ту же библиотеку CEP.
Документация: Pattern Recognition
Проверь себя
Нужно ловить цепочку «вход с нового устройства, смена пароля, вывод денег». Между ними пользователь смотрит баланс и выписки. Какая связка между шагами подходит?
Коллега выкатил шаблон с oneOrMore().allowCombinations() и без within. Через сутки чекпоинт CEP-оператора вырос в десятки раз. Что случилось?
На стенде генератор прислал одну атаку card testing и остановился. Алерта в топике нет уже минуту. Почему?
Что будет в конце
В задании появится вершина cep-card-testing. Она ищет по каждой карте три мелких платежа подряд и сразу за ними крупный, а алерты уходят через обогащение в fraud.alerts вместе с остальными. Генератор научится присылать такие атаки по флагу --card-testing.
Правило impossible travel относится к заданию. Методы impossibleTravel и impossibleTravelAlerts, класс DifferentCountry и их тесты пока не пиши.
Шаг 1. Зависимость
@@ -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.
<!-- 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.
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, мы этот вывод на стенде не снимали.
Документация: FlinkCEP Getting Started
Шаг 2. Атаки в генераторе
Генератор получает два новых флага. --card-testing задаёт долю случаев, когда вместо обычного платежа начинается атака проверки карты. --travel делает то же для двух платежей из разных стран, он понадобится в задании.
@@ -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));
}
Источник записей теперь берёт доли прямо из конфигурации, конструктор с пятью числами подряд стало слишком легко перепутать.
@@ -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) {
@@ -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, и автомат каждой карты видит только её платежи.
@@ -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. Ниже части файла, которые относятся к практике. В снапшоте в том же файле лежит и решение задания, поэтому константы и импорт для него тоже видны. Их можно добавить сейчас или в задании.
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.
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>. Повторная обработка той же атаки даст тот же идентификатор.
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.
return dynamicAlerts.union(
amountAlerts,
unconfirmedAlerts,
FraudPatterns.cardTestingAlerts(byCard),
FraudPatterns.impossibleTravelAlerts(byCard));
Поток byCard тот же, что у правил из уроков 7 и 8. Каждый его потребитель получает свою копию платежей по сети, поэтому у новой вершины вход равен входу rules-dynamic.
Шаг 5. Тесты
Тест запускает настоящий оператор CEP на MiniCluster. Это важно, потому что CEP работает во времени события, и без watermark совпадений не будет. Вспомогательный streamOf из прошлых уроков назначает метки по времени платежа. Когда данные кончаются, watermark уходит в бесконечность, и буфер CEP разбирается целиком. Ниже три теста на проверку карты. Они проверяют срабатывание, обрыв серии обычным платежом и предел в пять минут.
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();
}
Тест генератора проверяет, что атака начинается мелким платежом и заканчивается крупным.
@@ -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, и счётчик будет расти. Значение счётчика мы на стенде не снимали.
Задание. Невозможный перелёт
Карту скопировали, и копией расплачиваются в другой стране. Напиши правило, которое выдаёт алерт, если два соседних платежа одной карты пришли из разных стран и между ними прошло меньше десяти минут времени события.
- Добавь в
FraudPatternsметодimpossibleTravel(), который возвращаетPattern<PaymentEvent, ?>. Первый шаг называетсяfirst, второйsecond. - Условие второго шага сравнивает страну с первым платежом, поэтому оно должно быть
IterativeCondition. - Добавь метод
impossibleTravelAlerts(KeyedStream<PaymentEvent, String>). Правило называетсяimpossible-travel, текст алерта «платежи из разных стран за 10 минут», имя и uid вершиныcep-impossible-travel. Алерт строится по второму платежу. - Подключи правило в
PayGuardJob, его алерты должны пройти обогащение. - Напиши тесты в
FraudPatternsTest.
Готово, если выполнены все условия.
- Платежи DE, DE, ES подряд дают ровно один алерт
impossible-travel:p3. - Платежи DE и ES с разницей 11 минут алерта не дают.
./gradlew testпроходит.- На стенде появилась вершина
cep-impossible-travel, и с флагом генератора--travelеё выход растёт, а без флага остаётся нулевым.
Подсказка первая. Первый шаг может вообще не иметь условия, тогда в него подходит любой платёж.
Подсказка вторая. Контекст условия отдаёт события уже принятых шагов по имени шага. Метод вызывай как можно реже, документация предупреждает, что он ищет события в общем буфере.
Подсказка третья. Подумай, какая смежность нужна между шагами и что станет с алертами на карте, которая три раза подряд меняет страну, без стратегии пропуска.
Ответ
Шаблон состоит из двух шагов. Первый без условия принимает любой платёж. Второй идёт через next, потому что сравнивать надо именно соседние платежи. С followedBy платёж из Германии нашёл бы пару в любом следующем платеже из другой страны в течение десяти минут, даже если между ними были обычные платежи из Германии.
/** Два платежа подряд из разных стран за время, за которое туда не добраться. */
public static Pattern<PaymentEvent, ?> impossibleTravel() {
return Pattern.<PaymentEvent>begin("first", AfterMatchSkipStrategy.skipPastLastEvent())
.next("second")
.where(new DifferentCountry())
.within(TRAVEL_WINDOW);
}
Условие получает из контекста события шага first. В этом шаблоне там всегда ровно один платёж, но цикл не ломается и на шаге с квантификатором. Контекст вызывается один раз на проверку.
private static final class DifferentCountry extends IterativeCondition<PaymentEvent> {
@Override
public boolean filter(PaymentEvent payment, Context<PaymentEvent> context) throws Exception {
for (PaymentEvent first : context.getEventsForPattern("first")) {
if (!first.country().equals(payment.country())) {
return true;
}
}
return false;
}
}
Стратегия skipPastLastEvent здесь тоже нужна. Каждый платёж начинает новую попытку в шаге first. Без стратегии на последовательности DE, ES, FR второй платёж стал бы и концом первого совпадения, и началом второго. С ней попытка, начатая на ES, выбрасывается после первого совпадения, и алерт будет один.
Подключение повторяет правило из практики, отличаются только имя шага для алерта и текст.
public static DataStream<FraudAlert> impossibleTravelAlerts(KeyedStream<PaymentEvent, String> byCard) {
return CEP.pattern(byCard, impossibleTravel())
.process(new AlertOnMatch(IMPOSSIBLE_TRAVEL_RULE, "second", "платежи из разных стран за 10 минут"))
.name("cep-impossible-travel")
.uid("cep-impossible-travel");
}
return dynamicAlerts.union(
amountAlerts,
unconfirmedAlerts,
FraudPatterns.cardTestingAlerts(byCard),
FraudPatterns.impossibleTravelAlerts(byCard));
Тесты. Первый проверяет, что пара DE, DE алерта не даёт. Пара DE, ES даёт ровно один алерт. Второй проверяет within. Разница 11 минут больше окна, попытка истекает при сдвиге времени, и совпадения нет.
private static PaymentEvent inCountry(String id, String country, long time) {
return new PaymentEvent(id, "c1", "m1", 1_000, "EUR", country, time);
}
@Test
void impossibleTravelFiresOnCountryChange() throws Exception {
List<PaymentEvent> payments = List.of(
inCountry("p1", "DE", 1_000),
inCountry("p2", "DE", 2_000),
inCountry("p3", "ES", 3_000));
List<FraudAlert> alerts = FraudPatterns.impossibleTravelAlerts(byCard(payments)).executeAndCollect(10);
assertThat(alerts).extracting(FraudAlert::alertId).containsExactly("impossible-travel:p3");
}
@Test
void travelIsPossibleWhenEnoughTimePassed() throws Exception {
List<PaymentEvent> payments = List.of(
inCountry("p1", "DE", 1_000),
inCountry("p2", "ES", 1_000 + 11 * 60_000));
assertThat(FraudPatterns.impossibleTravelAlerts(byCard(payments)).executeAndCollect(10)).isEmpty();
}
}
Итог прогона всех тестов снапшота на стенде курса. Jar вырос примерно на 3,4 МБ по сравнению с уроком 9.
RESULT tests 39 fail 0
jar bytes 29440528
Метрики вершины cep-impossible-travel в том же прогоне, что и метрики из практики.
[{"id":"numRecordsIn","min":8555.0,"max":8615.0,"sum":17170.0},{"id":"numRecordsOut","min":28.0,"max":38.0,"sum":66.0},{"id":"busyTimeMsPerSecond","min":4.0,"max":4.0,"sum":8.0},{"id":"backPressuredTimeMsPerSecond","min":0.0,"max":0.0,"sum":0.0}]
66 совпадений. Обычные платежи карты идут из домашней страны, поэтому по коду генератора смена страны возможна только в атаке с XX. Значит, в этом прогоне генератор работал и с флагом --travel. Занятость 4 мс в секунду, вдвое больше, чем у проверки карты. Шаблон из двух шагов без условия на первом заводит попытку на каждый платёж, и условие второго шага ходит в общий буфер за первым платежом.
Прочитай топик алертов той же командой, что в уроке 9.
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:4b54d966-cae3-9a4a-1f33-21b23bb6b90b","ruleId":"velocity","cardId":"c-4634","merchantId":"m-0045","paymentId":"4b54d966-cae3-9a4a-1f33-21b23bb6b90b","detail":"11 платежей за минуту","eventTimeMs":1794086108951,"merchantRisk":"MEDIUM"}
{"alertId":"impossible-travel:0796f21d-8beb-f95b-03e3-422181aacf1c","ruleId":"impossible-travel","cardId":"c-0118","merchantId":"m-0028","paymentId":"0796f21d-8beb-f95b-03e3-422181aacf1c","detail":"платежи из разных стран за 10 минут","eventTimeMs":1794086100471,"merchantRisk":"LOW"}
{"alertId":"impossible-travel:057820bd-dc96-372e-df7f-0ff77f242c3b","ruleId":"impossible-travel","cardId":"c-0475","merchantId":"m-0044","paymentId":"057820bd-dc96-372e-df7f-0ff77f242c3b","detail":"платежи из разных стран за 10 минут","eventTimeMs":1794086104352,"merchantRisk":"LOW"}
Алерты impossible travel идут вперемешку с алертами частоты и уже обогащены риском продавца. Обрати внимание на порядок меток. Второй алерт старше первого на 8 секунд. Алерт частоты выдаётся сразу на платеже, а CEP ждёт watermark. Вдобавок обогащение в режиме unordered выдаёт алерты по мере ответов базы. Поэтому порядок в топике не совпадает с порядком событий.
Глоссарий урока
- 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 до записи «завершён». Шагов семь, и каждый подтверждён кодом ниже.
CheckpointCoordinator по интервалу строит план. Команду получают только источники, ждать подтверждения будут от всех подзадач.
Подзадача источника запоминает свои смещения Kafka и отправляет барьер во все выходные каналы впереди новых записей.
Он стоит в том же буфере, что и записи, и обгонять их не умеет, пока чекпоинт выровненный.
Канал, по которому барьер уже пришёл, блокируется. Когда барьер придёт по всем каналам, оператор снимает снимок и пропускает барьер дальше.
Синхронно фиксируется точка снимка, а выгрузка файлов в хранилище идёт в отдельном потоке, пока задача обрабатывает следующие записи.
Описание выгруженного состояния уходит координатору. Когда подтвердили все, чекпоинт считается завершённым.
Координатор рассылает подзадачам notifyCheckpointComplete. Источник Kafka в этот момент коммитит смещения группы.
В исходниках
Шаг 1. План чекпоинта, когда все задачи работают. В список «кого запустить» попадают только sourceTasks, в список «кого ждать» попадают все задачи.
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 и таймаут выравнивания, и они едут к задачам вместе с номером.
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);
Промежуточные операторы команду от координатора не получают вовсе. Чекпоинт до них доезжает только барьером по потоку данных.
Документация: Stateful Stream Processing
В исходниках
Шаги 2 и 5 на стороне подзадачи. Метод checkpointState пронумеровал фазы комментариями. Посмотри на порядок. Барьер уходит дальше (шаг 2 в коде) раньше, чем снимается состояние (шаг 5 в коде).
// 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 получает барьер из канала. Старый или уже обработанный барьер только снимает блокировку. Новый отмечается в наборе выровненных каналов, и обработка уходит в текущее состояние автомата.
@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));
}
Состояние выровненного режима блокирует канал, на котором пришёл барьер. Когда пришли все барьеры, запускается снимок и все каналы открываются разом.
@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 запрещён явно.
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».
/**
* 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 vs. At Least 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 всегда выровненный.
Документация: Checkpointing under backpressure
Подтверждение и завершение
В исходниках
Шаг 6 на стороне TaskManager. Когда асинхронная часть закончилась, AsyncCheckpointRunnable отправляет отчёт. В нём два размера. BytesPersistedOfThisCheckpoint это сколько записано именно этим чекпоинтом, TotalBytesPersisted это полный размер состояния.
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, и последнее превращает его в завершённый.
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. После записи завершённого чекпоинта в хранилище координатор рассылает уведомление.
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 они коммитятся в группу потребителей.
@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 может отставать от реального положения задания на интервал чекпоинта.
Документация: Kafka, Consumer Offset Committing
Что лежит в чекпоинте
Каталог чекпоинтов задаёт execution.checkpointing.dir. Внутри для каждого задания своя папка с именем jobId, а в ней три вида содержимого.
В исходниках
Имена фиксированы в коде хранилища.
/** 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, данные сериализованы | Полный или инкрементальный | Состояние больше кучи, нужны быстрые чекпоинты большого состояния |
forst | ForSt, ответвление RocksDB, хранит SST-файлы на удалённой файловой системе | Лёгкий, файлы уже лежат в удалённом хранилище | Эксперименты. Документация прямо пишет, что он экспериментальный и для прода готов не полностью |
RocksDB хранит данные в неизменяемых SST-файлах (Sorted String Table). Новый файл появляется при сбросе памяти на диск и при слиянии (compaction), старые файлы не переписываются. На этом и держится инкрементальный чекпоинт. Достаточно выгрузить файлы, которых ещё нет в хранилище, и сослаться на уже выгруженные.
В исходниках
Разбор файлов локальной копии RocksDB перед выгрузкой. Если SST-файл с тем же именем уже был выгружен прошлым подтверждённым чекпоинтом, в список попадает ссылка на старый файл. Остальные файлы идут на выгрузку.
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 показывает размер приращения. Полный размер состояния ищи в соседнем поле.
Документация: State Backends, Incremental Checkpoints
Восстановление и стратегии перезапуска
Когда подзадача падает, планировщик спрашивает стратегию перезапуска, можно ли перезапускать и через сколько. Потом координатор берёт последний завершённый чекпоинт, раздаёт состояния операторам по uid, и источники продолжают читать с сохранённых смещений.
В исходниках
Восстановление в координаторе. Берётся последний завершённый чекпоинт из хранилища, факт восстановления попадает в статистику, и дальше состояния распределяются по задачам.
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, счётчик попыток обнуляется.
@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 и снизу начальной задержкой.
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, то есть задание пытается подняться бесконечно.
Документация: Task Failure Recovery
Сравнение со Spark
В Spark Structured Streaming согласованная точка получается сама собой из устройства micro-batch. Перед запуском порции драйвер пишет в checkpointLocation журнал смещений, после неё журнал коммитов, а хранилище состояния сохраняет новую версию на каждую порцию. Барьеры там не нужны, потому что все задачи порции заканчиваются до начала следующей.
У Flink операторы работают непрерывно, и естественной границы нет. Её создаёт барьер, который едет вместе с данными. Чекпоинт снимается раз в интервал, обычно десятки секунд, и между чекпоинтами записи идут без задержки. Каталог чекпоинтов Flink по смыслу похож на checkpointLocation, но привязан к jobId. Новое задание с ним само не свяжется, для этого есть запуск из savepoint или чекпоинта, о нём урок 13.
В _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 получает политику перезапуска контейнера.
@@ -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 пропустит всё, что накопилось в топике за время простоя.
@@ -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, новой зависимости не нужно.
В исходниках
Почему инициализатор смещений не мешает восстановлению. Перечислитель партиций восстанавливает из чекпоинта уже назначенные партиции и при поиске изменений вычитает их из найденных. Инициализатор применяется только к оставшимся новым партициям.
@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 скрипт, который проверяет восстановление.
#!/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, который печатает сводку чекпоинтов запущенного задания в одну строку на чекпоинт.
- Адрес REST берётся из переменной
FLINK_REST, по умолчаниюhttp://localhost:8081, как в остальных скриптах проекта. - Скрипт сам находит первое задание в статусе
RUNNING. Если такого нет, печатает сообщение в stderr и завершается с кодом 1. - Первая строка показывает число завершённых, неудачных чекпоинтов и восстановлений.
- Дальше идут не больше пяти последних чекпоинтов. Для каждого номер, статус, длительность в миллисекундах, сколько записано и полный размер состояния в КиБ.
Готово, если на работающем стенде скрипт печатает строки вида «номер, статус, длительность, записано, состояние», а после chaos-kill-tm.sh в первой строке видно одно восстановление и в списке есть чекпоинт со статусом FAILED.
Подсказка первая. Все нужные поля есть в ответе /jobs/<id>/checkpoints, в частях counts и history. Открой его через jq . и найди имена полей.
Подсказка вторая. В jq строку собирают интерполяцией "\(.id)", срез массива пишется .[:5], а округление вниз делает floor.
Ответ
Скрипт повторяет начало chaos-kill-tm.sh и дальше делает один запрос к REST. Всю работу по форматированию берёт на себя jq.
#!/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
curl -s "$REST/jobs/$job_id/checkpoints" | jq -r '
"Завершено: \(.counts.completed) неудачных: \(.counts.failed) восстановлений: \(.counts.restored)",
(.history[:5][] | "#\(.id) \(.status) \(.end_to_end_duration) мс записано \(.checkpointed_size / 1024 | floor) КиБ состояние \(.state_size / 1024 | floor) КиБ")'
Флаг jq -r печатает строки без кавычек. Выражение выдаёт два результата через запятую. Первый это строка со счётчиками из counts. Второй разворачивает первые пять элементов history в отдельные строки. Список в history уже отсортирован от нового к старому, поэтому .[:5] даёт последние чекпоинты. Размеры приходят в байтах, делим на 1024 и округляем вниз.
Поле checkpointed_size печатается как «записано», state_size как «состояние». Это те самые BytesPersistedOfThisCheckpoint и TotalBytesPersisted, которые отправляет AsyncCheckpointRunnable. На нашем стенде скрипт показал второй чекпоинт за 282 мс, записано 4591 КиБ при состоянии 5899 КиБ.
Проверка. Запусти скрипт на работающем стенде, затем scripts/chaos-kill-tm.sh, затем скрипт ещё раз. Во второй раз в первой строке будет «восстановлений 1», а среди чекпоинтов появится строка со статусом FAILED.
chmod +x scripts/checkpoint-report.sh
scripts/checkpoint-report.sh
scripts/chaos-kill-tm.sh
scripts/checkpoint-report.sh
Глоссарий урока
- Чекпоинт (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 транзакциями, потому что его приёмник участвует в чекпоинте.
Документация: Exactly Once End-to-end, Fault Tolerance Guarantees of Data Sources and Sinks, Spark, Writing Data to 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 фиксирует.
/**
* 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 раньше барьера.
@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 всех чекпоинтов до завершённого.
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. Одна и та же транзакция может прийти на фиксацию дважды, и описание интерфейса требует, чтобы повторная фиксация ничего не меняла.
/**
* 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()}.
Документация: Data Sinks, SupportsCommitter
Путь алерта через чекпоинт
Пройди по шагам, что происходит с одним алертом между двумя чекпоинтами. Обрати внимание, в какой момент его впервые увидит потребитель.
Что Writer делает на чекпоинте
У Writer приёмника Kafka в режиме EXACTLY_ONCE одна открытая транзакция на подзадачу в каждый момент. Чекпоинт закрывает её для записи и сразу открывает следующую, поэтому поток алертов не останавливается.
В исходниках
Класс ExactlyOnceKafkaWriter. Метод prepareCommit вызывается в первой фазе. Если в транзакции есть записи, он создаёт committable и помечает транзакцию. Пустую транзакцию он не фиксирует, продюсер возвращается в пул. Метод snapshotState сохраняет незавершённые транзакции в состояние Writer и открывает транзакцию под номер следующего чекпоинта.
@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, который ничего не делает.
@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 Sink, Fault Tolerance
Транзакции 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.
/**
* 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 тысяч имён за неделю на один приёмник.
/**
* 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. Если тестовая копия задания запустится с тем же префиксом, её продюсеры будут вытеснять продюсеры боевой копии, и транзакции обеих начнут прерываться.
Документация: Kafka, isolation.level, Kafka, transactional.id, Kafka connector, ProducerFencedException
Падение посреди транзакции
Схема показывает одну партицию fraud.alerts и одну подзадачу Writer. Транзакция 12 успела зафиксироваться. Транзакцию 13 убил SIGKILL, её записи b1 и b2 уже лежат в логе. Задание стартует из чекпоинта 12, перечитывает платежи и пишет b1 и b2 заново в транзакцию с тем же именем и новой эпохой.
В исходниках
Что делает Writer при старте. Сначала он прерывает «зависшие» транзакции прошлого запуска, потом открывает транзакцию под номер чекпоинта, следующего за восстановленным. Для старта из чекпоинта 12 это снова номер 13.
@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, а он отменяет транзакцию.
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;
};
Документация: Kafka Sink
transaction.timeout.ms и длина простоя
Подготовленная транзакция живёт на брокере, пока её не зафиксируют или не прервут. Брокер ждёт не бесконечно. Через transaction.timeout.ms после первой записи он прерывает транзакцию сам. Если Committer придёт фиксировать её позже, фиксировать будет нечего.
Опасный сценарий выглядит так. Чекпоинт 12 завершён, но TaskManager упал до вызова commitTransaction. Кластер простоял ночь. Утром задание стартует из чекпоинта 12, Committer находит в состоянии committable транзакции 12 и пытается её зафиксировать. Брокер давно её прервал. Платежи до барьера 12 повторно не прочитаются, ведь источник продолжит со смещений чекпоинта 12. Алерты из этой транзакции потеряны.
В исходниках
Описание KafkaSink требует, чтобы таймаут транзакции был намного больше суммы самого долгого чекпоинта и самого долгого восстановления.
* <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.
Значение по умолчанию у приёмника один час. Конструктор строителя кладёт его в настройки продюсера.
private static final Duration DEFAULT_KAFKA_TRANSACTION_TIMEOUT = Duration.ofHours(1);
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.
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 выбрасывается, задание работает дальше. Неизвестная причина роняет задание.
/**
* 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 говорит, что данные, скорее всего, пропали. Такие строки в логах надо искать специально.
Документация: Kafka, transaction.timeout.ms, Kafka, transaction.max.timeout.ms
Брокер ограничивает таймаут сверху настройкой transaction.max.timeout.ms, по умолчанию 900000 мс, это 15 минут. Документация Kafka предупреждает, что продюсер с таймаутом больше этого предела получит InvalidTxnTimeoutException. Значение приёмника по умолчанию, один час, в этот предел не влезает. Либо задай таймаут не больше 15 минут, как в нашем проекте, либо подними предел на брокере, если простой дольше 15 минут для тебя реален.
Цена в задержке
Транзакция фиксируется только после завершения чекпоинта. Алерт, созданный сразу после барьера, ждёт целый интервал, у нас 15 секунд, плюс время самого чекпоинта. Описание KafkaSink прямо говорит, что запись фактически откладывается до чекпоинта и интервал надо подбирать с учётом этого.
Интервал нельзя уменьшать бесконечно. Каждый чекпоинт выгружает изменения RocksDB, и у нас он длится около трёхсот миллисекунд. При интервале в секунду снимки шли бы почти без перерыва, а брокер копил бы имена транзакций, по одному на чекпоинт и подзадачу. Есть и вторая сторона задержки. Пока транзакция открыта, потребители read_committed не читают дальше LSO, поэтому после падения они стоят, пока восстановленный Writer не прервёт старую транзакцию или её не прервёт брокер по таймауту.
Идемпотентность как альтернатива
Есть второй путь к тому же результату. Приёмник пишет в режиме at-least-once, повторы случаются, но запись построена так, что повтор ничего не меняет. Для этого нужен детерминированный ключ, одинаковый при каждой повторной обработке. У нас он уже есть. Идентификатор алерта собран из правила и платежа, поэтому повторный алерт получает тот же alertId.
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 и дедупликация у потребителя | Нужна минимальная задержка | Детерминированный идентификатор записи и хранилище уже виденных идентификаторов у потребителя |
Документация: JDBC Connector, PostgreSQL, ON CONFLICT
Гарантия покрывает состояние 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, выносится в отдельный метод, чтобы два приёмника не расходились.
@@ -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.
String alertsTopic,
String alertsTransactionalIdPrefix,
И его чтение в fromArgs на том же месте.
params.get("alerts-topic", "fraud.alerts"),
params.get("alerts-transactional-id-prefix", "payguard-alerts"),
Шаг 3. Подключи приёмник алертов
В PayGuardJob меняется только последний приёмник. Имя и uid остаются прежними.
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.
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. Он читает топик глазами потребителя с выбранным уровнем изоляции и считает идентификаторы, которые встретились больше одного раза.
#!/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_committed290 алертов и ни одного повтора. Два падения не оставили следа для потребителя. - В
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 попытался зафиксировать подготовленную транзакцию, о которой новый брокер ничего не знал.
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 снова восстанавливается из чекпоинта, и круг повторяется.
} 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 connector, Troubleshooting
Лечение в два шага. Чтобы не повторилось, Kafka должна жить на постоянном томе, тогда __transaction_state переживает перезапуск брокера. Мы проверили это в уроке 20, после удаления пода Kafka с томом задание через 90 секунд работало, чекпоинты шли без отказов. Чтобы выйти из уже начавшегося цикла, задание запускают без состояния приёмника. Как это делать аккуратно и что при этом теряется, разберём в уроке 21.
Задание. Итоги продавцов в PostgreSQL
Минутные итоги по продавцам сейчас уходят в топик merchant.stats в режиме at-least-once. После каждого падения там появляются повторы. Отчётам нужна таблица, в которой у каждого продавца одна строка на окно. Перенеси итоги в PostgreSQL и сделай запись идемпотентной.
- Добавь в
build.gradle.ktsJDBC-коннектор Flink версии4.1.0-2.2, модулиflink-connector-jdbc-coreиflink-connector-jdbc-postgres. Драйвер PostgreSQL в проекте уже есть. - Опиши в
sql/init.sqlтаблицуmerchant_statsс колонками продавца, начала и конца окна, числа платежей, суммы и времени обновления. Первичный ключ выбери так, чтобы одно окно продавца было одной строкой. - Напиши класс
JdbcSinksс методомmerchantStats(), который возвращает приёмник дляMerchantStats. Запрос вставки при конфликте ключа обновляет итоги. - В
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только при создании пустой базы. Чтобы новая таблица появилась, контейнер базы придётся пересоздать вместе с его томом.
Ответ
Зависимости. Версия JDBC-коннектора читается так же, как у Kafka, версия коннектора плюс версия Flink. Модуль -postgres добавляет диалект PostgreSQL.
@@ -18,6 +18,7 @@
val flinkVersion = "2.2.1"
val kafkaConnectorVersion = "5.0.0-2.2"
+val jdbcConnectorVersion = "4.1.0-2.2" val jacksonVersion = "2.21.0"
// Классы, которые уже лежат в образе Flink: нужны для компиляции и тестов, в jar не попадают.
@@ -34,6 +35,8 @@ 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.apache.flink:flink-connector-jdbc-core:$jdbcConnectorVersion")+ implementation("org.apache.flink:flink-connector-jdbc-postgres:$jdbcConnectorVersion") implementation("org.postgresql:postgresql:42.7.14")
implementation("com.zaxxer:HikariCP:7.0.2")
Таблица. Первичный ключ из продавца и начала окна. Конец окна определяется началом, поэтому в ключ он не входит.
@@ -8,3 +8,13 @@ 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;
++CREATE TABLE merchant_stats (+ merchant_id TEXT NOT NULL,+ window_start_ms BIGINT NOT NULL,+ window_end_ms BIGINT NOT NULL,+ payment_count BIGINT NOT NULL,+ amount_minor BIGINT NOT NULL,+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),+ PRIMARY KEY (merchant_id, window_start_ms)+);Приёмник целиком.
package dev.study.payguard.job;
import dev.study.payguard.model.MerchantStats;
import org.apache.flink.connector.jdbc.JdbcConnectionOptions;
import org.apache.flink.connector.jdbc.JdbcExecutionOptions;
import org.apache.flink.connector.jdbc.core.datastream.sink.JdbcSink;
/** Приёмники PostgreSQL. Повторная запись той же строки безопасна благодаря ON CONFLICT. */
final class JdbcSinks {
private static final String UPSERT_MERCHANT_STATS = """
INSERT INTO merchant_stats (merchant_id, window_start_ms, window_end_ms, payment_count, amount_minor)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (merchant_id, window_start_ms) DO UPDATE
SET payment_count = EXCLUDED.payment_count,
amount_minor = EXCLUDED.amount_minor,
updated_at = now()
""";
static JdbcSink<MerchantStats> merchantStats() {
return JdbcSink.<MerchantStats>builder()
.withQueryStatement(UPSERT_MERCHANT_STATS, (statement, stats) -> {
statement.setString(1, stats.merchantId());
statement.setLong(2, stats.windowStartMs());
statement.setLong(3, stats.windowEndMs());
statement.setLong(4, stats.paymentCount());
statement.setLong(5, stats.amountMinor());
})
.withExecutionOptions(JdbcExecutionOptions.builder()
.withBatchSize(200)
.withBatchIntervalMs(1_000)
.withMaxRetries(3)
.build())
.buildAtLeastOnce(new JdbcConnectionOptions.JdbcConnectionOptionsBuilder()
.withUrl(System.getenv("PAYGUARD_DB_URL"))
.withDriverName("org.postgresql.Driver")
.withUsername(System.getenv("PAYGUARD_DB_USER"))
.withPassword(System.getenv("PAYGUARD_DB_PASSWORD"))
.build());
}
private JdbcSinks() {
}
}
Запрос использует ON CONFLICT (merchant_id, window_start_ms) DO UPDATE. При повторе после восстановления строка перезаписывается теми же числами. При позднем срабатывании окна она получает обновлённые итоги, и поэтому здесь нужен DO UPDATE. Вариант DO NOTHING тоже убрал бы повторы, но оставил бы в таблице первый, неполный итог. Значения EXCLUDED это строка, которую пытались вставить.
Приёмник собран через buildAtLeastOnce. Строки копятся пачками до 200 штук или до секунды, неудачная пачка повторяется до трёх раз. Гарантию at-least-once дают чекпоинты, на них Writer дописывает накопленное. Повторы безопасны благодаря ключу и upsert, поэтому XA-транзакции с их задержкой здесь не нужны.
Задание и настройки. В PayGuardJob итоги уходят в новый приёмник, лишнее поле и топик удалены.
@@ -98,9 +98,9 @@ private static void attachMerchantStats(DataStream<PaymentEvent> payments, JobConfig config) {
SingleOutputStreamOperator<MerchantStats> merchantStats = MerchantStatsWindow.attach(payments);
merchantStats
- .sinkTo(KafkaSinks.<MerchantStats>atLeastOnceJson(config.bootstrapServers(), config.merchantStatsTopic()))- .name("merchant-stats-sink")- .uid("merchant-stats-sink");+ .sinkTo(JdbcSinks.merchantStats())+ .name("merchant-stats-postgres")+ .uid("merchant-stats-postgres"); merchantStats.getSideOutput(MerchantStatsWindow.TOO_LATE)
.sinkTo(KafkaSinks.<PaymentEvent>atLeastOnceJson(config.bootstrapServers(), config.lateTopic()))
.name("too-late-sink")
@@ -170,7 +170,8 @@ ENRICHMENT_CAPACITY)
.name("enrich-alerts")
.uid("enrich-alerts")
- .sinkTo(KafkaSinks.<FraudAlert>atLeastOnceJson(config.bootstrapServers(), config.alertsTopic()))+ .sinkTo(KafkaSinks.<FraudAlert>exactlyOnceJson(+ config.bootstrapServers(), config.alertsTopic(), config.alertsTransactionalIdPrefix())) .name("alerts-sink")
.uid("alerts-sink");
}
@@ -9,9 +9,9 @@ String rawTopic,
String dlqTopic,
String lateTopic,
- String merchantStatsTopic, String cardSessionsTopic,
String alertsTopic,
+ String alertsTransactionalIdPrefix, String rulesTopic,
String confirmationsTopic,
String confirmedTopic,
@@ -26,9 +26,9 @@ 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("alerts-topic", "fraud.alerts"),
+ params.get("alerts-transactional-id-prefix", "payguard-alerts"), params.get("rules-topic", "rules.config"),
params.get("confirmations-topic", "payments.confirmations"),
params.get("confirmed-topic", "payments.confirmed"),
@@ -9,11 +9,11 @@ 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.interval: 30s execution.checkpointing.min-pause: 10s
execution.checkpointing.timeout: 5min
execution.checkpointing.tolerable-failed-checkpoints: 3
@@ -64,7 +64,7 @@ entrypoint: ["/bin/bash", "-c"]
command:
- |
- for topic in payments.raw payments.dlq payments.late payments.confirmations payments.confirmed merchant.stats card.sessions fraud.alerts rules.config; do+ for topic in payments.raw payments.dlq payments.late payments.confirmations payments.confirmed 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
Проверка. Флаги -s, -f и -v у docker compose rm останавливают контейнер базы, не спрашивают подтверждения и удаляют его анонимный том. Новый контейнер создаст базу заново и выполнит init.sql.
./gradlew shadowJar
docker compose rm -sfv postgres
docker compose up -d --wait
scripts/submit.sh
./gradlew loadgen --args="--rate 200 --dup 0.02 --bad 0.01 --burst 0.002"
Убей TaskManager дважды, как в практике, и посмотри на таблицы и граф.
scripts/chaos-kill-tm.sh
scripts/chaos-kill-tm.sh
docker compose exec -T postgres psql -U payguard -d payguard -c '\dt'
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("merchant-stats"))'
List of relations
Schema | Name | Type | Owner
--------+----------------+-------+----------
public | merchant_stats | table | payguard
public | merchants | table | payguard
(2 rows)
merchant-stats-1m -> (merchant-stats-postgres: Writer -> merchant-stats-postgres: Committer, too-late-sink: Writer -> too-late-sink: Committer)
Посчитай строки и продавцов запросом select count(*), count(distinct merchant_id) from merchant_stats. На нашем стенде после двух падений получилось вот что.
merchant_stats в Postgres: 150 строк, 50 продавцов
Это три минутных окна по 50 продавцов. После каждого восстановления приёмник at-least-once заново отправляет итоги, выданные после последнего чекпоинта, и ON CONFLICT превращает их в обновления существующих строк. Без него повторная вставка нарушила бы первичный ключ, и запись пачки закончилась бы ошибкой базы.
Глоссарий урока
- 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 в конце перечисления.
/** 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. В нашем скрипте тип всё равно указан явно, чтобы через год никто не гадал, в каком формате лежат снимки.
Документация: Savepoints, Savepoint format, Checkpoints vs. Savepoints
В нашем 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 перед последним барьером уходит MAX_WATERMARK. Все таймеры по времени события срабатывают, и минутные окна продавцов закроются досрочно с неполными суммами. Документация прямо предупреждает, что продолжение задания после такого снимка может дать неверные результаты. Для релиза используй stop без этого флага.
Как состояние находит свой оператор
Внутри savepoint лежит отображение «идентификатор оператора → состояние». Идентификатор (OperatorID) это 16 байт. Если у оператора задан uid, байты получаются хешем от этой строки. Если не задан, Flink выводит их из положения оператора в графе, и правка топологии может их сдвинуть. Об этом шла речь в уроке 2.
В исходниках
Как строка uid превращается в идентификатор при сборке графа. Хеш murmur3 на 128 бит от UTF-8 байтов строки, и больше ничего в него не входит.
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, и инспектор найдёт состояние оператора в снимке.
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(), в этом не участвует.
Документация: Savepoints, Assigning Operator IDs, Upgrading Applications, Matching Operator State
При запуске из снимка JobManager сначала читает его метаданные и проверяет, что каждому сохранённому состоянию нашёлся оператор. Это делает метод Checkpoints.loadAndValidateCheckpoint.
В исходниках
Первый шаг. Для каждой вершины нового графа в таблицу кладутся оба идентификатора оператора, выведенный из графа и заданный через uid.
// 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 оно пропускается с записью в лог. Без флага пустое состояние тоже пропускается. Непустое приводит к исключению.
} 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. В обратную сторону проверки нет. Новый оператор, которого не было в снимке, просто стартует с пустым состоянием.
Документация: Command-Line Interface, Starting a Job from a Savepoint
Флаг --allowNonRestoredState выбрасывает состояние насовсем. Ставь его, только когда оператор удалён намеренно. Если же ты случайно поменял uid, флаг тихо обнулит состояние оператора, и задание запустится как ни в чём не бывало. Правильное лечение в этом случае вернуть старый uid.
Эволюция схемы состояния
Значения состояния лежат в снимке байтами. Прочитать их может только сериализатор того же формата, поэтому рядом с данными Flink кладёт снимок сериализатора (serializer snapshot). Это описание формата, например список полей POJO с их типами. При восстановлении новый сериализатор сравнивает себя со старым снимком. Возможны четыре ответа. Совместим как есть. Совместим после миграции, тогда старые байты читаются старым сериализатором и переписываются новым. Совместим после перенастройки. Несовместим, тогда восстановление падает.
Для POJO документация задаёт правила.
- Поля можно удалять. Старое значение удалённого поля пропадёт в следующих снимках.
- Поля можно добавлять. Новое поле получит значение по умолчанию для своего типа в Java.
- Тип объявленного поля менять нельзя.
- Имя класса, включая пакет, менять нельзя.
Record во Flink 2.2 идёт тем же путём. TypeExtractor признаёт record типом POJO, даже без конструктора по умолчанию и сеттеров, и сериализует его через PojoSerializer. Наш CardProfile как раз record.
В исходниках
Проверка совместимости в PojoSerializerSnapshot.resolveSchemaCompatibility. Сначала сравнивается класс, потом сериализаторы полей, которые есть в обеих версиях.
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();
}
Если класс и старые поля совпали, а набор полей изменился, ответ будет «совместим после миграции».
private boolean newPojoSerializerIsCompatibleAfterMigration(
IntermediateCompatibilityResult<T> fieldSerializerCompatibility,
IntermediateCompatibilityResult<T> preExistingRegistrationsCompatibility,
LinkedOptionalMap<Field, TypeSerializerSnapshot<?>> fieldSerializerSnapshots) {
return newPojoHasNewOrRemovedFields(fieldSerializerSnapshots)
|| fieldSerializerCompatibility.isCompatibleAfterMigration()
|| preExistingRegistrationsCompatibility.isCompatibleAfterMigration();
}
Каким станет новое поле record после миграции, решает JavaRecordBuilderFactory. Если имена или порядок компонент изменились, он строит таблицу перестановки аргументов конструктора. Новые примитивные поля получают значение по умолчанию, остальные новые поля остаются null.
// 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.
Документация: State Schema Evolution, Data Types & Serialization
Поля сопоставляются по имени. Если переименовать 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. Если параллелизм не изменился, подзадача получает файлы своей прежней подзадачи как есть. Иначе для неё собираются пересечения со всеми старыми файлами.
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));
}
Диапазоны для нового параллелизма нарезаются той же функцией, что используется при обработке записей.
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;
}
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 согласованы, и симуляция выше считает по ним обеим.
Документация: Stateful Stream Processing, Keyed State, Parallel Execution
Состояние без ключа (operator state), например список назначенных источнику партиций Kafka, на группы не делится. В том же методе его раздаёт по кругу RoundRobinOperatorStateRepartitioner.
Предел, заданный при первом запуске
Число групп, оно же максимальный параллелизм (maxParallelism), записано в снимке у каждого оператора. При восстановлении оно сверяется с новым графом дважды.
В исходниках
Первая проверка. Подзадач не может быть больше, чем групп в снимке.
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.");
Вторая проверка касается самого числа групп. Если ты его не задавал, планировщик вычислил значение сам и пометил его как автоматическое. Такое значение разрешено заменить числом из снимка. Заданное явно заменить нельзя.
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.
Документация: Savepoints, FAQ, 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 ты заменишь его второй версией.
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. Этот файл не изменится во второй версии.
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. Добавь его имя в настройки задания.
@@ -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 не нужен.
@@ -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, допиши его в список.
@@ -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 и сделай его исполняемым.
#!/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 его учитывают.
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, пока по карте не придёт новый платёж. Теперь добавь тест функции.
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, и спорить со снимком ему не пришлось.
Что пойдёт не так
Мы не ловили эти ошибки на стенде, но их тексты заданы в исходниках, которые разобраны на вкладке теории. По фрагменту сообщения легко понять, какая проверка сработала.
| Что сделали | Фрагмент сообщения | Как лечить |
|---|---|---|
Удалили оператор с состоянием или сменили его uid | Cannot 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.
- Добавь в
build.gradle.ktsзависимостьorg.apache.flink:flink-state-processor-apiтой же версии, что Flink. Подумай, в какую конфигурацию её положить. В дистрибутиве Flink этот jar лежит вopt/, вlib/его нет. - Путь к снимку принимай аргументом
--savepoint. - Прочитай состояние с ключом оператора
card-profileи собери пары «карта и профиль». - Напечатай общее число карт в состоянии и пять карт с наибольшим числом платежей.
- Запусти инспектор на кластере через
flink runс флагом-cна снимке второй версии. Учти, что инспектор тоже задание и ему нужен свободный слот.
Готово, если команда завершается без исключений и печатает строку с числом карт и пять строк топа, отсортированных по убыванию paymentCount.
Подсказки. Строки uid и имя состояния уже вынесены в CardProfileFunction, бери их оттуда. State Processor API работает только в режиме BATCH. Дескриптор состояния в читателе должен совпадать с дескриптором в функции по имени и классу. У DataStream есть несколько способов забрать результат в клиент, и один из них ведёт себя неожиданно при большом лимите. Прочитай его код до того, как выбирать.
Ответ
Библиотека ложится в implementation, то есть внутрь толстого jar. В дистрибутиве Flink её jar кладётся в opt/, так написано в flink-dist/src/main/assemblies/opt.xml. Из opt/ классы не загружаются, и с flinkProvided класс SavepointReader не нашёлся бы при запуске.
@@ -35,6 +35,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.apache.flink:flink-state-processor-api:$flinkVersion") implementation("org.apache.flink:flink-connector-jdbc-core:$jdbcConnectorVersion")
implementation("org.apache.flink:flink-connector-jdbc-postgres:$jdbcConnectorVersion")
implementation("org.postgresql:postgresql:42.7.14")
Сам инспектор.
package dev.study.payguard.job;
import dev.study.payguard.model.CardProfile;
import dev.study.payguard.pipeline.CardProfileFunction;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.apache.flink.api.common.RuntimeExecutionMode;
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.runtime.state.hashmap.HashMapStateBackend;
import org.apache.flink.state.api.OperatorIdentifier;
import org.apache.flink.state.api.SavepointReader;
import org.apache.flink.state.api.functions.KeyedStateReaderFunction;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.util.CloseableIterator;
import org.apache.flink.util.Collector;
import org.apache.flink.util.ParameterTool;
/** Читает профили карт прямо из savepoint и печатает самые активные. Задание для этого запускать не нужно. */
public final class StateInspector {
private static final int TOP_SIZE = 5;
/** Строка отчёта: карта и её профиль. */
public record CardRow(String cardId, CardProfile profile) {
}
public static void main(String[] args) throws Exception {
String savepointPath = ParameterTool.fromArgs(args).getRequired("savepoint");
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setRuntimeMode(RuntimeExecutionMode.BATCH);
SavepointReader savepoint = SavepointReader.read(env, savepointPath, new HashMapStateBackend());
List<CardRow> rows = new ArrayList<>();
try (CloseableIterator<CardRow> results = savepoint
.readKeyedState(OperatorIdentifier.forUid(CardProfileFunction.UID), new ProfileReader())
.executeAndCollect()) {
results.forEachRemaining(rows::add);
}
System.out.println("Карт в состоянии: " + rows.size());
rows.stream()
.sorted(Comparator.comparingLong((CardRow row) -> row.profile().paymentCount()).reversed())
.limit(TOP_SIZE)
.forEach(System.out::println);
}
private static final class ProfileReader extends KeyedStateReaderFunction<String, CardRow> {
private transient ValueState<CardProfile> profile;
@Override
public void open(OpenContext context) {
profile = getRuntimeContext().getState(
new ValueStateDescriptor<>(CardProfileFunction.STATE_NAME, CardProfile.class));
}
@Override
public void readKey(String cardId, Context context, Collector<CardRow> out) throws IOException {
out.collect(new CardRow(cardId, profile.value()));
}
}
private StateInspector() {
}
}
public static void main(String[] args) throws Exception {
String savepointPath = ParameterTool.fromArgs(args).getRequired("savepoint");
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setRuntimeMode(RuntimeExecutionMode.BATCH);
SavepointReader savepoint = SavepointReader.read(env, savepointPath, new HashMapStateBackend());
List<CardRow> rows = new ArrayList<>();
try (CloseableIterator<CardRow> results = savepoint
.readKeyedState(OperatorIdentifier.forUid(CardProfileFunction.UID), new ProfileReader())
.executeAndCollect()) {
results.forEachRemaining(rows::add);
}
System.out.println("Карт в состоянии: " + rows.size());
rows.stream()
.sorted(Comparator.comparingLong((CardRow row) -> row.profile().paymentCount()).reversed())
.limit(TOP_SIZE)
.forEach(System.out::println);
Берём путь к снимку из аргументов. getRequired сразу падает с понятной ошибкой, если аргумент забыли.
Обычное окружение DataStream в режиме BATCH. Снимок конечен, и программа должна завершиться, когда прочитает его до конца.
SavepointReader.read загружает метаданные снимка. Третий аргумент задаёт бэкенд, через который читается состояние. Задание пишет состояние в RocksDB, но снимок в формате canonical, а этот формат одинаков для всех бэкендов. Поэтому подходит лёгкий HashMapStateBackend.
readKeyedState строит поток по состоянию оператора с данным uid. Каждый ключ пройдёт через ProfileReader. executeAndCollect() без аргументов запускает программу и отдаёт итератор, который подтягивает результаты по мере готовности. try с ресурсами закрывает итератор и освобождает ресурсы кластера.
Печатаем число карт, потом сортируем по числу платежей по убыванию и берём первые пять.
Читатель ProfileReader устроен как обычная функция с состоянием. В open он регистрирует тот же дескриптор, что CardProfileFunction, а readKey вызывается по одному разу на каждый ключ в снимке. Собирать все карты в список допустимо, их тысячи. Для миллионов ключей сортировку стоило бы делать в самой программе Flink, а в клиент забирать только топ.
Инспектор тоже задание Flink, и ему нужен хотя бы один свободный слот. После шага 7 задание PayGuard занимает все 4 слота, поэтому сначала останови его со savepoint, прочитай этот снимок и верни задание командой из шага 7.
./gradlew shadowJar
SP=$(scripts/stop-with-savepoint.sh)
docker compose exec -T jobmanager flink run \
-c dev.study.payguard.job.StateInspector \
/jobs/payguard-1.0.0.jar --savepoint "$SP"
Флаг -c указывает класс с main, иначе CLI возьмёт PayGuardJob из манифеста. Флага --detached нет, клиент должен дождаться результатов. Метод main выполняется в клиенте, поэтому печать идёт прямо в терминал. Аргументы после jar достаются программе. Ниже вывод с нашего стенда после полутора минут генератора на 200 платежей в секунду. Строк предупреждений JVM в нём больше, мы их убрали.
Job has been submitted with JobID a2e9fe5b387ee9bd5a5938070cde5d3f
Карт в состоянии: 4826
CardRow[cardId=c-4868, profile=CardProfile[paymentCount=12, totalAmountMinor=275240, lastCountry=FR]]
CardRow[cardId=c-2932, profile=CardProfile[paymentCount=11, totalAmountMinor=188880, lastCountry=FR]]
CardRow[cardId=c-1396, profile=CardProfile[paymentCount=11, totalAmountMinor=220030, lastCountry=NL]]
CardRow[cardId=c-4796, profile=CardProfile[paymentCount=10, totalAmountMinor=228177, lastCountry=FR]]
CardRow[cardId=c-3253, profile=CardProfile[paymentCount=10, totalAmountMinor=308041, lastCountry=ES]]
Инспектор сам стал заданием со своим JobID. Строки карт печатает стандартный toString у record. Генератор создаёт около пяти тысяч карт, поэтому почти все они уже попали в состояние.
Ловушка executeAndCollect с лимитом
Первая версия инспектора вызывала executeAndCollect(Integer.MAX_VALUE). Казалось, что это значит «забери всё». Запуск упал раньше, чем прочиталась первая карта.
java.lang.OutOfMemoryError: Requested array size exceeds VM limit
В исходниках
Посмотри, что делает вариант с лимитом до того, как прочитать хотя бы одну запись.
public List<T> executeAndCollect(String jobExecutionName, int limit) throws Exception {
Preconditions.checkState(limit > 0, "Limit must be greater than 0");
try (ClientAndIterator<T> clientAndIterator =
executeAndCollectWithClient(jobExecutionName)) {
List<T> results = new ArrayList<>(limit);
while (limit > 0 && clientAndIterator.iterator.hasNext()) {
results.add(clientAndIterator.iterator.next());
limit--;
}
return results;
}
}
Лимит уходит в начальную ёмкость ArrayList, и JVM пытается выделить массив на два миллиарда ссылок. Такой массив больше допустимого размера, отсюда ошибка. Вариант без аргументов возвращает CloseableIterator и ничего заранее не выделяет. Его комментарий требует закрыть итератор, чтобы освободить ресурсы кластера, поэтому он стоит в try с ресурсами.
Документация: State Processor API, Reading State
Что проверить ещё. Верни задание, дай ему поработать минуту, сними новый снимок и запусти инспектор снова. Число карт будет не меньше прежнего, и у карт из топа paymentCount только вырастет, ведь счётчик в состоянии не уменьшается. Если число упало, значит задание стартовало без снимка.
Глоссарий урока
- 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 | Что сообщает |
|---|---|---|
+I | INSERT | Появилась новая строка |
-U | UPDATE_BEFORE | Старое значение строки, которая сейчас изменится |
+U | UPDATE_AFTER | Новое значение строки |
-D | DELETE | Строка удалена |
Прогони девять платежей через три варианта запроса. Посмотри, сколько записей уходит в приёмник на одни и те же данные и что происходит с состоянием. Водяной знак в модели отстаёт на 10 секунд и двигается с каждой записью, а платёж со временем 00:58 приходит с опозданием.
Поток превращается в таблицу
Поток платежей
Таблица результата
Changelog
Первый вариант похож на газету с поправками и называется retract. Изменение кодируется двумя записями, -U и +U. Второй похож на табло и называется upsert. Изменение занимает одну запись +U, зато приёмник обязан знать уникальный ключ. Третий вариант называется append-only, в нём есть только +I, как в итогах тура.
В исходниках
Четыре пометки это обычное перечисление в ядре Flink. Посмотри на комментарии к двум видам обновления, они объясняют, зачем нужен -U.
/** 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. Именно так определены готовые наборы видов строк.
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 есть все четыре вида. Этим классом коннектор сообщает планировщику, какие строки он выдаёт или принимает.
Документация: Dynamic Tables, User-defined Sources & Sinks
Вид потока ты не выбираешь руками. Его выводит планировщик, и по этому выводу понятно, примет ли запрос твой приёмник. JDBC-коннектор с PRIMARY KEY в DDL работает в режиме upsert. Без первичного ключа он работает в режиме append и обновлений принять не может. Для приёмника в PostgreSQL первичный ключ полезен и при чистых вставках. Если после восстановления окно будет записано повторно, строка перезапишется и дубля не появится.
В исходниках
Программа вывода режимов обходит план и для каждого узла решает, какие изменения он производит. Сравни обычную агрегацию и оконную.
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)
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 объясняет причину, в этом узле пока нет раннего и позднего срабатывания окна.
Документация: JDBC SQL Connector, Group Aggregation
Оконные TVF
TVF (table-valued function) это функция, которая принимает таблицу и возвращает таблицу. Функция TUMBLE добавляет к входным колонкам три новых, window_start, window_end и window_time. Есть ещё HOP, CUMULATE и SESSION. Запрос нашего проекта выглядит так.
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 обязаны стоять обе колонки, начало и конец окна. Тогда планировщик выбирает специальный оператор, который выдаёт один итог на окно и потом освобождает состояние.
В исходниках
Проверка группировки занимает несколько строк. Смотри на последнее выражение, оно требует и начало, и конец.
/** 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.
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;
Ограничение оператора описано в комментарии к его классу.
* <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, здесь этой возможности нет. Срабатывание таймера показывает, откуда берётся маленькое состояние окна.
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. Итог вышел, состояние этого окна удалено.
Документация: Windowing TVF, Window Aggregation
План задания, Local и Global
Текст запроса разбирает и оптимизирует планировщик, построенный на Apache Calcite. Результат это граф операторов, который виден в Web UI или в выводе команды EXPLAIN. Если ты читал вывод explain() в Spark, то видел там partial_sum перед обменом и финальную сумму после него. Во Flink оконная агрегация делится так же, вершины называются LocalWindowAggregate и GlobalWindowAggregate.
Выигрыш особенно заметен на перекосе. У нас продавец m-0000 получает 30 % платежей. Без локальной фазы все его записи идут по сети в одну подзадачу, и каждая обращается к состоянию. С локальной фазой по сети идёт одна частичная сумма на ключ при каждом сбросе буфера.
В исходниках
Правило оптимизатора перечисляет условия разбиения на две фазы. Посмотри, что мешает ему сработать.
// 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, и на стенде запрос с окном разбился на две фазы без единой настройки.
Локальный узел собирает оператор с буфером.
WindowBuffer.LocalFactory bufferFactory =
new RecordsWindowBuffer.LocalFactory(
keySer, valueSer, new LocalAggCombiner.Factory(generatedAggsHandler));
final OneInputStreamOperator<RowData, RowData> localAggOperator =
new LocalSlicingWindowAggOperator(
selector, sliceAssigner, bufferFactory, shiftTimeZone);
Сброс буфера происходит в методах этого оператора.
@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 называет первую причину, буфер переполнился. Вторая причина это водяной знак, который дошёл до конца окна. Третья это барьер чекпоинта, перед ним буфер сбрасывается целиком, и хранить его содержимое в снимке незачем.
Документация: Performance Tuning, EXPLAIN
Состояние, которого не видно в запросе
В DataStream ты сам объявляешь ValueState и сам ставишь таймер на его очистку. В SQL состояние заводит планировщик. Запрос GROUP BY merchantId помнит счётчик каждого продавца вечно, потому что новая запись может прийти в любой момент. Обычный JOIN двух потоков держит в состоянии обе таблицы целиком. Такие запросы выглядят безобидно и на карточках с миллионами значений заполняют диск.
Ограничить рост помогает table.exec.state.ttl, время хранения ключа без обновлений. На оконные операторы и interval join эта настройка не влияет, их состояние очищается, когда время проходит границу окна.
В исходниках
Посмотри на значение по умолчанию и на описание.
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.");
Ноль означает, что состояние не очистится никогда. Если задать час, ключ проживёт без обновлений минимум час и будет удалён когда-то после этого.
Документация: Streaming Concepts, Configuration
После удаления состояния запрос забывает, что видел этот ключ. Счётчик продавца, молчавшего дольше TTL, начнётся заново с единицы и перезапишет строку в приёмнике. Отдельная проблема с обновлениями. Документация прямо говорит, что savepoint поддержан, только пока не меняются ни запрос, ни версия Flink. Новый фильтр может перестроить план, и состояние не найдёт своих операторов. Механизмы эволюции из урока 13 к SQL-заданиям не переносятся.
Mini-batch
Обычная агрегация обрабатывает записи по одной, то есть читает аккумулятор из состояния, обновляет и записывает обратно. На RocksDB это дорого, особенно на перекосе. Режим mini-batch копит пачку записей в буфере и обращается к состоянию один раз на ключ. Цена этого выигрыша равна допустимой задержке.
В исходниках
Три настройки включаются вместе. Обрати внимание на примечание у размера.
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 зависит ещё и разбиение на две фазы.
@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));
}
Сравни с правилом для окон выше, там этого условия нет.
Документация: Performance Tuning, Configuration
Почему коннекторы SQL лежат в lib
У запроса из файла нет jar с зависимостями. Строку 'connector' = 'kafka' планировщик превращает в класс через поиск фабрик на classpath. Поэтому коннектор надо либо передать клиенту флагом --jar или --library, либо положить толстый SQL-jar в /lib дистрибутива. Второй способ удобен тем, что версия коннектора управляется в одном месте для всех заданий кластера.
В исходниках
Фабрики находятся через стандартный механизм Java, ServiceLoader. Именно он читает файлы META-INF/services, которые мы склеивали в уроке 1.
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());
Дальше среди найденных выбирается фабрика с нужным именем. Если её нет, ты увидишь сообщение со списком всех доступных имён.
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 задания.
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 перепакована в другой пакет.
<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. Меняется только тип аргумента его метода.
/**
* 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.
.setStartingOffsets(OffsetsInitializer.committedOffsets(OffsetResetStrategy.LATEST))
Если SQL-jar лежит в /lib, загрузчик отдаёт заданию класс OffsetsInitializer из него, и метода с такой сигнатурой там нет. На практике это выглядит как NoSuchMethodError, разберём его на вкладке «Практика».
Документация: Connectors and Formats, Debugging Classloading
Сравнение со Spark SQL
Многое покажется знакомым. Structured Streaming тоже описывает поток как растущую таблицу. Разница в том, что происходит на выходе.
| Вопрос | Spark Structured Streaming | Flink 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-задания перестаёт проходить.
java.lang.NoSuchMethodError: ...OffsetsInitializer.committedOffsets(org.apache.kafka.clients.consumer.OffsetResetStrategy)
Причину мы разобрали в теории. Класс org.apache.flink.* пришёл из lib/, его метод ждёт перепакованный тип, а задание скомпилировано с обычным. Лечение здесь архитектурное. SQL живёт на отдельном кластере со своим образом, DataStream-задания остаются на старом.
Шаг 2. Образ с коннекторами
Создай 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-коннектором завершается так.
[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 общий блок настроек и две службы.
@@ -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.
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.
-- Выручка продавцов по минутам: из 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 из скрипта и выполни два запроса ниже.
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, длинные списки полей у источника обрезаны.
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])
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.
- Создай таблицу
risk_revenueвsql/init.sqlс полямиrisk_level,window_start,top_rank,merchant_id,amount_minor. Подбери первичный ключ так, чтобы повторная запись окна перезаписывала строки. - Напиши
src/main/resources/sql/risk_revenue.sql. Обогати платёж уровнем риска через lookup join с кэшем на 10000 строк и 5 минут. Продавец без записи в справочнике получает уровеньUNKNOWN. Посчитай выручку в минутном окне TVF и оставь топ-3 через Window Top-N. - Напиши класс
dev.study.payguard.job.SqlJobRunner. Он получает имя скрипта аргументом--script, читает его из ресурсов jar и выполняет инструкции по очереди черезTableEnvironment. - Добавь нужную зависимость в
build.gradle.ktsтак, чтобы она не попала в толстый jar. - Отправь задание на 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.
Ответ
Зависимость flink-table-api-java идёт через flinkProvided. Планировщик и рантайм таблиц уже лежат в образе, в толстом jar они не нужны.
@@ -31,6 +31,7 @@ flinkProvided("org.apache.flink:flink-clients:$flinkVersion")
flinkProvided("org.apache.flink:flink-connector-base:$flinkVersion")
flinkProvided("org.apache.flink:flink-json:$flinkVersion")
+ flinkProvided("org.apache.flink:flink-table-api-java:$flinkVersion")
implementation("org.apache.flink:flink-connector-kafka:$kafkaConnectorVersion")
implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
Таблица в PostgreSQL. Ключ включает top_rank, потому что результат это занятые места в топе за окно.
CREATE TABLE risk_revenue (
risk_level TEXT NOT NULL,
window_start TIMESTAMP NOT NULL,
top_rank BIGINT NOT NULL,
merchant_id TEXT NOT NULL,
amount_minor BIGINT NOT NULL,
PRIMARY KEY (risk_level, window_start, top_rank)
);
Скрипт целиком.
-- Выручка по уровню риска продавца. Обогащение идёт через lookup join с кэшем.
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),
proc_time AS PROCTIME(),
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-risk-sql',
'scan.startup.mode' = 'latest-offset',
'format' = 'json',
'json.ignore-parse-errors' = 'true'
);
CREATE TABLE merchants (
merchant_id STRING,
risk_level STRING,
PRIMARY KEY (merchant_id) NOT ENFORCED
) WITH (
'connector' = 'jdbc',
'url' = 'jdbc:postgresql://postgres:5432/payguard',
'table-name' = 'merchants',
'username' = 'payguard',
'password' = 'payguard',
'lookup.cache' = 'PARTIAL',
'lookup.partial-cache.max-rows' = '10000',
'lookup.partial-cache.expire-after-write' = '5min'
);
CREATE TABLE risk_revenue (
risk_level STRING,
window_start TIMESTAMP(3),
top_rank BIGINT,
merchant_id STRING,
amount_minor BIGINT,
PRIMARY KEY (risk_level, window_start, top_rank) NOT ENFORCED
) WITH (
'connector' = 'jdbc',
'url' = 'jdbc:postgresql://postgres:5432/payguard',
'table-name' = 'risk_revenue',
'username' = 'payguard',
'password' = 'payguard'
);
CREATE TEMPORARY VIEW enriched AS
SELECT p.merchantId, p.amountMinor, p.event_time, COALESCE(m.risk_level, 'UNKNOWN') AS risk_level
FROM payments AS p
LEFT JOIN merchants FOR SYSTEM_TIME AS OF p.proc_time AS m
ON p.merchantId = m.merchant_id;
INSERT INTO risk_revenue
SELECT risk_level, window_start, top_rank, merchantId, amount_minor
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY risk_level, window_start, window_end ORDER BY amount_minor DESC) AS top_rank
FROM (
SELECT risk_level, merchantId, window_start, window_end, SUM(amountMinor) AS amount_minor
FROM TABLE(TUMBLE(TABLE enriched, DESCRIPTOR(event_time), INTERVAL '1' MINUTE))
GROUP BY risk_level, merchantId, window_start, window_end
)
)
WHERE top_rank <= 3;
CREATE TEMPORARY VIEW enriched AS
SELECT p.merchantId, p.amountMinor, p.event_time, COALESCE(m.risk_level, 'UNKNOWN') AS risk_level
FROM payments AS p
LEFT JOIN merchants FOR SYSTEM_TIME AS OF p.proc_time AS m
ON p.merchantId = m.merchant_id;
INSERT INTO risk_revenue
SELECT risk_level, window_start, top_rank, merchantId, amount_minor
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY risk_level, window_start, window_end ORDER BY amount_minor DESC) AS top_rank
FROM (
SELECT risk_level, merchantId, window_start, window_end, SUM(amountMinor) AS amount_minor
FROM TABLE(TUMBLE(TABLE enriched, DESCRIPTOR(event_time), INTERVAL '1' MINUTE))
GROUP BY risk_level, merchantId, window_start, window_end
)
)
WHERE top_rank <= 3;
Временное представление enriched дописывает к платежу уровень риска. FOR SYSTEM_TIME AS OF p.proc_time означает lookup join, запрос к справочнику на момент обработки записи.
LEFT JOIN и COALESCE сохраняют платёж неизвестного продавца с уровнем UNKNOWN. С обычным JOIN он бы пропал из выручки.
Внутренний запрос считает выручку по паре уровень и продавец в минутном окне, как в практике.
ROW_NUMBER нумерует продавцов внутри уровня и окна. В PARTITION BY стоят оба конца окна, поэтому планировщик выбирает оконный Top-N, который выдаёт итог один раз и чистит состояние.
WHERE top_rank <= 3 оставляет три верхних места.
Кэш настроен тремя опциями lookup.* в DDL таблицы merchants. Он снижает нагрузку на PostgreSQL, но новый уровень риска применится только после истечения записи, в нашем случае через 5 минут. Результат lookup join при этом не пересчитывается задним числом, так описывает этот join документация.
Запускающий класс.
package dev.study.payguard.job;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.apache.flink.table.api.EnvironmentSettings;
import org.apache.flink.table.api.TableEnvironment;
import org.apache.flink.util.ParameterTool;
/** Запускает SQL-скрипт из ресурсов jar. Так SQL-задание проходит ту же сборку и тот же релиз, что и код. */
public final class SqlJobRunner {
public static void main(String[] args) throws IOException {
String script = ParameterTool.fromArgs(args).getRequired("script");
TableEnvironment tableEnv = TableEnvironment.create(EnvironmentSettings.inStreamingMode());
for (String statement : readResource(script).split(";\\s*\\n")) {
if (!withoutComments(statement).isBlank()) {
tableEnv.executeSql(statement);
}
}
}
private static String readResource(String name) throws IOException {
try (InputStream stream = SqlJobRunner.class.getResourceAsStream("/sql/" + name)) {
if (stream == null) {
throw new IllegalArgumentException("В jar нет скрипта sql/" + name);
}
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
}
private static String withoutComments(String statement) {
return statement.replaceAll("(?m)^--.*$", "");
}
private SqlJobRunner() {
}
}
Текст режется по точке с запятой в конце строки. Это простое решение со своим ограничением, точка с запятой в конце строки внутри литерала разрежет инструкцию. Для наших скриптов этого хватает. Метод withoutComments нужен, чтобы хвост из одних комментариев или пробелов не попал в executeSql. TableEnvironment.create со стриминговыми настройками работает без StreamExecutionEnvironment, потому что DataStream тут не нужен.
./gradlew shadowJar
docker compose --profile sql down
docker compose --profile sql up -d --wait
docker compose --profile sql exec sql-jobmanager flink run --detached \
-c dev.study.payguard.job.SqlJobRunner /jobs/payguard-1.0.0.jar --script risk_revenue.sql
Флаг -c задаёт главный класс вместо Main-Class из манифеста, а всё после пути к jar уходит аргументами в main. Команда down нужна только затем, чтобы PostgreSQL заново выполнил init.sql с новой таблицей. Она останавливает и задание из практики, запусти его снова, если оно нужно. Нагрузку даёт тот же ./gradlew loadgen --args="--rate 200" в отдельном терминале.
План нашего прогона выглядит так, те же имена ты увидишь в Web UI. Каждая строка это одна цепочка операторов, то есть одна вершина графа.
Source: payments[1] -> Calc[2] -> LookupJoin[3] -> Calc[4] -> LocalWindowAggregate[5]
GlobalWindowAggregate[7] -> Calc[8]
WindowRank[10] -> Calc[11] -> ConstraintEnforcer[12] -> Sink: risk_revenue[12]
Первая цепочка работает без обмена по сети, поэтому и lookup join, и локальная сумма выполняются в той же подзадаче, что читает Kafka. Calc это проекция и фильтр. Потом идёт обмен по ключу группировки, и GlobalWindowAggregate собирает частичные суммы. Следующий обмен ведёт в WindowRank, который строит топ.
В исходниках
Оператор ConstraintEnforcer в запросе никто не писал. Его добавляет сборка приёмника.
/**
* Apply an operator to filter or report error to process not-null values for not-null fields.
*/
private Transformation<RowData> applyConstraintValidations(
Transformation<RowData> inputTransform,
ExecNodeConfig config,
RowType physicalRowType) {
final Optional<ConstraintEnforcerExecutor> enforcerExecutor =
ConstraintEnforcerExecutor.create(
physicalRowType,
config.get(ExecutionConfigOptions.TABLE_EXEC_SINK_NOT_NULL_ENFORCER),
config.get(ExecutionConfigOptions.TABLE_EXEC_SINK_TYPE_LENGTH_ENFORCER),
config.get(
ExecutionConfigOptions.TABLE_EXEC_SINK_NESTED_CONSTRAINT_ENFORCER));
Это проверка ограничений приёмника, например NOT NULL. Какое действие выполнять при нарушении, определяет настройка, чьё значение передаётся здесь же.
Документация: Window Top-N, Joins
Проверь результат запросом.
docker compose exec postgres psql -U payguard -d payguard -c \
"SELECT risk_level, window_start, top_rank, merchant_id, amount_minor
FROM risk_revenue ORDER BY window_start DESC, risk_level, top_rank LIMIT 12"
В нашем прогоне на 100 с при 200 платежах в секунду для каждого уровня вышло три места, а на верхней строке уровня HIGH стоял m-0000 с выручкой 86320099 в минимальных единицах. Этот продавец горячий, и в init.sql у него номер 0, поэтому уровень HIGH.
Глоссарий урока
- Динамическая таблица (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. Нижняя складывает алерты в таблицу озера данных, чтобы по ним можно было строить отчёты.
WAL и логическое декодирование
WAL (write-ahead log) это журнал упреждающей записи PostgreSQL. Его правило простое. Изменение попадает в файлы данных только после того, как оно записано в журнал, поэтому после падения база может доиграть журнал и восстановиться. Каждая позиция в журнале имеет номер, он называется LSN (log sequence number).
WAL рассчитан на восстановление самой базы и описывает изменения на физическом уровне. Логическое декодирование превращает его в поток понятных изменений строк. Чтобы журнал содержал нужные для этого данные, настройка wal_level должна быть равна logical. По умолчанию там replica, а поменять значение можно только при старте сервера.
Формат потока определяет плагин вывода. Стандартный плагин PostgreSQL называется pgoutput, на нём работает встроенная логическая репликация. У коннектора значение по умолчанию другое, decoderbufs, и этот плагин должен быть установлен на сервере, поэтому мы явно указываем pgoutput.
Документация: Write-Ahead Logging, wal_level, pgoutput
Слот как абонентский ящик
Представь абонентский ящик на почте. Письма складывают туда и хранят, пока абонент их не заберёт. Почта не знает, жив ли абонент. Если он переехал и забыл закрыть ящик, письма продолжат копиться, и однажды их негде станет хранить.
Логический слот репликации устроен так же. Это объект на сервере, который помнит позицию своего читателя и запрещает удалять журнал после неё. Документация PostgreSQL прямо пишет, что слот живёт независимо от подключения, переживает падения и ничего не знает о состоянии получателя. Читать один слот в каждый момент может только один получатель, поэтому коннектор советует заводить отдельное имя на каждую таблицу, иначе ты увидишь ошибку replication slot "flink" is active for PID.
В модели ниже одна клетка это журнал, накопившийся за один интервал чекпоинта. Позиция слота отстаёт на три интервала, как при настройке коннектора по умолчанию. Останови задание и нажимай на интервал, потом удали слот. Числа здесь условные, настоящий темп роста зависит от нагрузки на базу.
Что держит журнал на диске
Задание работает. Слот двигается вслед за чекпоинтами, старые сегменты освобождаются.
В исходниках
Проверим две вещи. Первая, что будет со слотом при остановке задания. Коннектор настраивает Debezium, встроенную библиотеку чтения журнала, отдельно для временного и для основного чтения. В первом фрагменте временный слот удаляется при остановке. Во втором основной слот сохраняется, комментарий говорит об этом прямо.
.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());
dbzConfig =
new PostgresConnectorConfig(
builder
// never drop slot for stream split, which is also global split
.with(DROP_SLOT_ON_STOP.name(), false)
.build());
Сам коннектор вызывает удаление основного слота в единственном месте. Условие срабатывает, когда задание запущено в режиме snapshot, то есть читает только снимок и не собирается идти дальше.
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);
}
Отсюда правило. Отмена или падение обычного потокового задания оставляет слот на сервере, и убирать его придётся тебе.
Вторая вещь, когда слот сдвигается. Читатель подтверждает позицию в обработчике завершённого чекпоинта и придерживает несколько последних номеров, чтобы задание могло откатиться на недавний чекпоинт.
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);
}
}
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 с. Других вызовов подтверждения позиции в этом источнике нет, поэтому задание без чекпоинтов оставит слот на месте, хотя данные будет читать исправно.
Документация: Postgres CDC Connector, Logical Decoding Concepts
Слот мешает удалять и WAL, и строки системных каталогов, которые ему нужны, а значение max_slot_wal_keep_size по умолчанию равно -1, то есть журнал может расти без ограничений. В крайнем случае база остановится, защищаясь от переполнения счётчика транзакций. Поэтому за pg_replication_slots нужен отдельный алерт. Лимит спасает диск, но у него есть цена. Слот, отставший сильнее лимита, теряет журнал, получает wal_status = lost и больше не годится для чтения, так что задание придётся запускать заново со снимка.
Документация: max_slot_wal_keep_size, pg_replication_slots
Снимок плюс журнал
Журнал старше слота уже удалён, и начальное содержимое таблицы в нём взять неоткуда. Поэтому коннектор в режиме initial сначала читает таблицу, а потом переключается на журнал. Всё сложное здесь в стыке. Пока идёт чтение таблицы, строки продолжают меняться.
Инкрементальный снимок (scan.incremental.snapshot.enabled = true) режет таблицу на чанки по первичному ключу и раздаёт их параллельным читателям. По документации он читает параллельно, делает чекпоинты между чанками и обходится без глобальной блокировки на чтение. По умолчанию этот режим выключен, поэтому в нашем SQL он включён явно.
Каждый чанк читается по одной схеме. Слово watermark здесь означает позицию LSN и никак не связано с watermark времени события из урока 5. Пройди шаги.
В исходниках
Базовый класс чтения чанка показывает ту же последовательность. Логи пронумерованы, смотри на них. Сначала нижняя граница и сам снимок.
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.
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);
Дозаполнение запускается только тогда, когда между границами что-то есть.
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 слотов на сервер.
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());
}
/**
* Returns the slot name for backfill task.
*
* @return backfill task slot name
*/
public String getSlotNameForBackfillTask() {
return getDbzProperties().getProperty(SLOT_NAME.name()) + "_" + getSubtaskId();
}
Потоковое чтение выдаётся читателю, когда все чанки распределены и закончены. Причина указана в комментарии. Иначе записи из журнала могли бы обогнать записи снимка с тем же первичным ключом.
} 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);
Документация: Incremental Snapshot Reading, max_replication_slots
Со 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, и оно сразу уходит в проверку.
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 падает с исключением на пустой строке. Текст исключения это и есть та ошибка, которую ты увидишь в логе.
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, а его цена следует из определения выше, на каждое обновление в журнал пишется вся старая строка.
Документация: ALTER TABLE, REPLICA IDENTITY, changelog-mode
Коннектор доступен и из DataStream API через PostgresSourceBuilder, и SQL-таблица в режиме параллельного чтения использует тот же класс. Различие есть в обязательности имени слота. В построителе у него указано значение по умолчанию flink, а SQL-фабрика требует задать slot.name явно.
В исходниках
Первый фрагмент из построителя, второй из SQL-фабрики. Запомни, что значение по умолчанию у построителя опасно. Два задания без явного имени полезут в один слот.
/**
* 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;
}
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;
}
Документация: slot.name
Upsert-kafka и компактируемый топик
Коннектор upsert-kafka пишет changelog в Kafka с такой логикой. Первичный ключ таблицы становится ключом сообщения Kafka, новая версия строки становится значением, а удаление превращается в сообщение с пустым значением (tombstone). Все изменения одного ключа попадают в одну партицию, поэтому их порядок сохраняется.
Топик с настройкой cleanup.policy=compact хранит для каждого ключа как минимум последнее значение, старые версии брокер со временем вычищает. Такой топик работает как таблица. Новый потребитель читает его с начала и получает весь справочник без обращения к PostgreSQL. Компактирование не трогает активный сегмент, поэтому только что записанные версии какое-то время лежат рядом, и ты увидишь это в практике.
В исходниках
Без первичного ключа таблица не создастся, потому что ключ сообщения брать неоткуда. Сразу под этой проверкой в файле объявлен режим приёмника. В нём нет UPDATE_BEFORE, поэтому на один UPDATE в базе в топик уходит одна запись.
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.");
}
}
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.
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);
}
Документация: Upsert Kafka, Kafka, Log Compaction
Lakehouse и Paimon
Алерты в Kafka нужны для реакции, а аналитикам нужен SQL по истории за месяцы. Lakehouse закрывает это файлами в дешёвом хранилище, поверх которых лежат метаданные таблицы. Метаданные дают то, чего нет у папки с Parquet, это атомарную публикацию изменений и обновление строк по ключу. Apache Paimon как раз такой формат таблиц, и читать его можно разными движками, в том числе Spark.
Устройство таблицы с первичным ключом удобно смотреть сверху вниз. Партиция делит данные по значению колонки, у нас по дню. Внутри партиции бакеты, каждый со своим LSM-деревом (log-structured merge-tree). Писатель накапливает записи, сортирует их и сбрасывает новым файлом, а compaction потом сливает файлы, чтобы чтению не приходилось перебирать много версий одного ключа.
Изменения становятся видимыми, когда опубликован снимок таблицы. Снимок ссылается на схему и списки манифестов, а манифесты перечисляют живые файлы. Потоковая запись публикует изменения на чекпоинтах Flink, поэтому документация Paimon требует их включить. Интервал чекпоинта у нас 15 с, значит, столько же примерно ждать свежих алертов в отчёте.
Механизм слияния (merge engine) задаёт, как записи с одним ключом складываются в одну логическую строку. По умолчанию это deduplicate, он оставляет последнюю. Идентификатор нашего алерта детерминирован, он собран из правила и платежа, поэтому повторная обработка даёт тот же ключ, и строка останется одна.
Настройка bucket выбирает режим распределения. Положительное число включает фиксированные бакеты, номер считается как хеш ключа бакета по модулю их числа. Без настройки таблица работает с динамическими бакетами (-1), и тогда в одну партицию может писать только одно задание. Ключом бакета по умолчанию служит первичный ключ без колонок партиции, поэтому при ключе (event_day, alert_id) хеш считается от alert_id.
Чекпоинт 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).
Документация: Paimon, Basic Concepts, Primary-Key Tables, Data Distribution, Merge Engine, SQL Write, SQL Query, Iceberg, Multi-Engine Support
Проверь себя
Задание 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.
@@ -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 меняются две службы.
@@ -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
@@ -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.
-- Изменения таблицы 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, описание говорит об асинхронном запуске. Именно поэтому терминал вернётся сразу.
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.");
Документация: SQL Client
Посмотри на слот со стороны базы.
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.
-- Алерты из 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');"
Документация: pg_drop_replication_slot, DROP_REPLICATION_SLOT
Задание. Пакетный отчёт по таблице озера
Пока задание payguard-alerts-lake пишет алерты, напиши файл sql/paimon_report.sql. Он должен посчитать, сколько алертов дало каждое правило для каждого уровня риска продавца, и показать результат в терминале.
- Запрос читает таблицу
fraud_alertsиз каталога Paimon. - В результате три колонки, правило, уровень риска и число алертов. Сверху самые частые сочетания.
- Файл запускается той же командой
sql-client.sh -f, что и остальные, на SQL-кластере.
Готово, если команда завершается сама и печатает таблицу прямо в терминал. В Web UI на порту 8082 пакетное задание завершается, а payguard-alerts-lake продолжает работать. При повторном запуске через минуту числа растут.
Подсказки. Посмотри, какая настройка переводит клиент в пакетный режим и какая отвечает за вид результата в терминале. Подумай, что клиент помнит о каталоге после завершения предыдущего запуска. В потоковом режиме ORDER BY по произвольной колонке не работает, и документация объясняет почему.
Ответ
-- Пакетный отчёт по таблице озера: тот же SQL, но запрос читает снимок и завершается.
SET 'execution.runtime-mode' = 'batch';
SET 'sql-client.execution.result-mode' = 'tableau';
CREATE CATALOG lake WITH (
'type' = 'paimon',
'warehouse' = 'file:/flink-data/lake'
);
SELECT rule_id, merchant_risk, COUNT(*) AS alerts
FROM lake.`default`.fraud_alerts
GROUP BY rule_id, merchant_risk
ORDER BY alerts DESC;
Строка с execution.runtime-mode делает запрос конечным, он читает один снимок таблицы и завершается. Значение tableau у sql-client.execution.result-mode печатает результат таблицей в терминале.
Каталог приходится объявлять заново, потому что клиент запускается новым процессом с новой сессией. Сами данные и описание таблицы лежат в warehouse, и создавать таблицу второй раз не нужно. Сортировка по alerts разрешена именно в пакетном режиме. В потоковом основной порядок обязан идти по атрибуту времени.
docker compose --profile sql exec -T sql-jobmanager sql-client.sh -f /sql/paimon_report.sql
В нашем прогоне отчёт вернул 9 строк. Вот несколько из них.
| rule_id | merchant_risk | alerts |
|---|---|---|
| impossible-travel | LOW | 15 |
| velocity | LOW | 15 |
| impossible-travel | HIGH | 14 |
| card-testing | LOW | 13 |
Как проверить, что отчёт согласован с источником. Запусти его дважды с паузой в пару интервалов чекпоинта, числа должны только расти. Чтобы увидеть старую версию, добавь к таблице подсказку /*+ OPTIONS('scan.snapshot-id' = '1') */. Запрос прочитает самый первый снимок и вернёт меньшие числа, если он ещё не удалён сроком хранения.
Документация: Paimon, Quick Start, SQL Query, 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 собирается, а отправка на кластер падает ещё до старта задания.
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 включает проверку сериализуемости.
public <F> F clean(F f) {
if (getConfig().isClosureCleanerEnabled()) {
ClosureCleaner.clean(f, getConfig().getClosureCleanerLevel(), true);
}
ClosureCleaner.ensureSerializable(f);
return f;
}
Сама проверка пытается сериализовать объект. Строки с сообщением дают ровно тот текст, что мы получили от flink run.
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 отключает проверку даже для него.
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 проверяет поведение оператора. Вопрос о том, можно ли этот оператор отправить на кластер, он перед собой не ставит.
Документация: Testing
Уровень 2. Restore-тест
Если функция держит данные в поле вместо состояния Flink, обычный тест на harness этого не заметит. Пока процесс жив, поле ведёт себя как состояние. Restore-тест снимает снимок с одного экземпляра оператора, поднимает из него второй и подаёт ещё одну запись. Так он воспроизводит перезапуск задания прямо в памяти.
В исходниках
Метод snapshot из harness отдаёт состояние в том виде, в каком его получил бы JobManager. Внутри вызывается настоящий snapshotState оператора.
/**
* 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();
}
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().
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.
public void initializeState(String operatorStateSnapshotPath) throws Exception {
initializeState(OperatorSnapshotUtil.readStateHandle(operatorStateSnapshotPath));
}
Третья граница это хранилище состояния. По умолчанию harness держит его в куче, хотя в нашем Compose стоит state.backend.type: rocksdb. Заменить хранилище можно методом setStateBackend.
// use this as default for tests
protected StateBackend stateBackend = new HashMapStateBackend();
Документация: Testing, State Backends
Уровень 3. Топология без запуска
Самый дешёвый способ проверить задание целиком это построить его граф и посмотреть на него. Метод env.getStreamGraph() вызывает весь код описания задания, но ничего не отправляет на исполнение. Kafka, база и кластер для этого не нужны. Такой тест поймает все ошибки, которые возникают при описании графа, и в их числе RuleConfig без Serializable.
В исходниках
Граф получается вызовом генератора, запуска в этом коде нет. Обрати внимание на @Internal. Это внутренний метод, и при обновлении Flink тест может потребовать правки.
@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.
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());
}
}
Узел отдаёт значение через обычный геттер, на него и опирается наш тест.
public String getTransformationUID() {
return transformationUID;
}
void setTransformationUID(String transformationId) {
this.transformationUID = transformationId;
}
Следующий шаг после StreamGraph это JobGraph, тот граф, что уходит на кластер. Метод без аргументов помечен @VisibleForTesting, он как раз для таких проверок.
/** 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);
}
Документация: Production Readiness Checklist, Savepoints
С сериализаторами всё тоньше. В уроке 3 мы запретили Kryo настройкой pipeline.generic-types: false. Запрет срабатывает при создании сериализатора, а создаются они в двух разных местах и в разное время.
В исходниках
Запрет живёт в типе, который ушёл бы в Kryo.
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 при добавлении оператора. Такая ошибка всплывёт уже в тесте топологии.
private <T> TypeSerializer<T> createSerializer(TypeInformation<T> typeInfo) {
return typeInfo != null && !(typeInfo instanceof MissingTypeInfo)
? typeInfo.createSerializer(executionConfig.getSerializerConfig())
: null;
}
Сериализатор состояния создаётся в момент getState, а мы вызываем его из open, то есть на работающей подзадаче. Граф об этом типе ещё ничего не знает. Такую ошибку покажет только запуск с тем же окружением.
@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.
Документация: Data Types & Serialization
Уровень 4. MiniCluster
MiniCluster это встроенный кластер Flink внутри JVM теста. Записи идут через настоящие подзадачи, параллелизм и водяные знаки. Источники и приёмники здесь можно подменить, как MerchantStatsWindowTest подаёт готовый список платежей через fromData. Документация советует три вещи. Запускай с параллелизмом больше 1, потому что часть ошибок видна только так. Делай кластер общим для тестов класса, потому что старт и остановка съедают большую часть времени. Состояние можно проверить настоящим перезапуском, если тестовая функция бросит исключение при включённых чекпоинтах.
В исходниках
Самое полезное свойство расширения JUnit в том, что код задания менять не нужно. Перед каждым тестом оно регистрирует своё окружение как контекстное.
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 из нашего метода доезжает до тестового окружения.
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 и локально в тесте топологии, где расширения нет и граф просто строится.
public static StreamExecutionEnvironment getExecutionEnvironment(Configuration configuration) {
return Utils.resolveFactory(threadLocalContextEnvironmentFactory, contextEnvironmentFactory)
.map(factory -> factory.createExecutionEnvironment(configuration))
.orElseGet(() -> StreamExecutionEnvironment.createLocalEnvironment(configuration));
}
Документация: Testing Flink Jobs. Там описан MiniClusterWithClientResource для JUnit 4, расширение для JUnit 5 описано только в javadoc класса.
Уровень 5. Сквозной тест в контейнерах
Документация предлагает подменять источники и приёмники тестовыми. Это хорошо для логики, но транзакции Kafka и SQL к настоящей базе так не проверить. Библиотека Testcontainers запускает из теста настоящие контейнеры и сама их убирает. В PayGuard сквозной тест строит весь граф через buildPipeline, работает на MiniCluster и общается с настоящими Kafka и PostgreSQL.
Алерты пишутся с гарантией EXACTLY_ONCE, и по документации коннектора такая транзакция фиксируется на чекпоинте. Потребитель с read_committed не увидит алерт, пока чекпоинт не пройдёт. Поэтому в тесте включены чекпоинты каждую секунду, настройки кластера из Compose сюда не приходят. Тот же rocksdb из Compose в этом тесте тоже не действует, задание живёт на настройках по умолчанию.
Все пять уровней работают на тестовом 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 разделим его на три метода.
@@ -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 новый файл.
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);
}
}
Сначала читается переменная окружения, и на кластере ничего не меняется. Если её нет, берётся системное свойство. Теперь замени прямые вызовы в двух местах.
@@ -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());
}
@@ -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.
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.
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, и две задачи выбирают тесты по этому тегу.
@@ -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.
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 такой поток показан у класса теста отдельно.
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 из тестовых зависимостей. Она не работает, потому что он нужен самому коннектору.
NoClassDefFoundError: io/openlineage/sql/OpenLineageSql
Рабочее решение в том, чтобы поставить настоящую библиотеку раньше на classpath. Для всех задач типа Test мы берём jar commons-lang3 из разрешённых зависимостей и дописываем его в начало. Это строки 76-78 файла сборки из шага 5. Первым найденным классом становится настоящий, а openlineage остаётся на месте и работает.
Похожая ловушка с лишним коннектором была в уроке 14. В Java порядок на classpath решает, какой из одинаковых классов загрузится, поэтому при странном NoSuchMethodError первым делом ищи вторую копию класса.
Шаг 8. CI
Положи в проект .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. Если она забудет увиденные платежи при рестарте, после каждого падения повторы пройдут дальше и правила посчитают их как новые платежи. Напиши тест, который это проверяет.
- Создай
src/test/java/dev/study/payguard/pipeline/DeduplicateRestoreTest.java. - Подай платёж в первый экземпляр оператора и сними снимок.
- Подними второй экземпляр из снимка и подай в него тот же платёж и ещё один новый.
- Проверь выход второго экземпляра.
Готово, если выполнены все условия.
- Команда
./gradlew test --tests '*DeduplicateRestoreTest'зелёная. - В отчёте
./gradlew testстало 45 тестов. - Тест падает, если заменить в
DeduplicateFunctionсостояние обычным полем с набором строк. Верни код после проверки. - Тест нельзя обмануть функцией, которая отбрасывает вообще все платежи.
Подсказки. Образец лежит в CardProfileRestoreTest. Посмотри, по какому полю функция ключуется в PayGuardJob, селектор ключа в тесте должен быть таким же. В Payments есть готовый paymentOnCard. Последнее условие подсказывает, зачем в третьем пункте нужен новый платёж.
Ответ
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.runtime.checkpoint.OperatorSubtaskState;
import org.apache.flink.streaming.api.operators.KeyedProcessOperator;
import org.apache.flink.streaming.util.KeyedOneInputStreamOperatorTestHarness;
import org.junit.jupiter.api.Test;
/** Ответ к заданию урока 16: повтор платежа после рестарта всё ещё отсекается. */
class DeduplicateRestoreTest {
@Test
void duplicateAfterRestartIsDropped() throws Exception {
OperatorSubtaskState snapshot;
try (var before = newHarness()) {
before.open();
before.processElement(paymentOnCard("p1", "c1", 1_000), 1_000);
snapshot = before.snapshot(1L, 1_000);
}
try (var after = newHarness()) {
after.initializeState(snapshot);
after.open();
after.processElement(paymentOnCard("p1", "c1", 1_000), 1_000);
after.processElement(paymentOnCard("p2", "c1", 2_000), 2_000);
assertThat(after.extractOutputValues()).extracting(PaymentEvent::paymentId).containsExactly("p2");
}
}
private static KeyedOneInputStreamOperatorTestHarness<String, PaymentEvent, PaymentEvent> newHarness() throws Exception {
return new KeyedOneInputStreamOperatorTestHarness<>(
new KeyedProcessOperator<>(new DeduplicateFunction()), PaymentEvent::paymentId, Types.STRING);
}
}
Ключом служит paymentId, как в PayGuardJob. В первом экземпляре один платёж p1, его достаточно для снимка. Срок памяти о платеже равен часу, тест заканчивается намного раньше, поэтому TTL здесь не мешает.
Проверка containsExactly("p2") закрывает сразу две поломки. Отсутствие p1 доказывает, что память пережила рестарт. Присутствие p2 доказывает, что оператор вообще работает и не выбрасывает всё подряд. Проверка на пустой выход без второго платежа прошла бы и на сломанной функции.
Запусти тест и весь набор.
./gradlew test --tests '*DeduplicateRestoreTest'
./gradlew test
Теперь проверка поломкой. Замени поле ValueState<Boolean> на Set<String>, создай набор в open и отсекай повторы через seen.add(payment.paymentId()). Мы сделали это на копии снапшота. Твой тест упал, в выходе оказались p1 и p2. Вместе с ним упал старый DeduplicateFunctionTest.paymentIsForgottenAfterTtl, потому что вместе с состоянием пропал и TTL. Верни код, и оба снова будут зелёными.
Глоссарий урока
- 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 проходит мимо правила на лаг.
Из чего состоит метрика 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 с двумя аргументами. Он создаёт две вложенные группы, группу ключа и группу значения.
@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);
}
Группа значения кладёт пару в переменные и не добавляет себя в логическую область. Логическая область группы значения равна области родителя, то есть ключа.
@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") с одним аргументом, каждое правило получило бы своё имя метрики, и сложить их одним запросом стало бы неудобно.
Документация: Metrics
Meter заслуживает отдельного взгляда, потому что на нём построены почти все метрики «в секунду». Число записей на выходе оператора Flink считает счётчиком, а скорость получает обёрткой MeterView над этим счётчиком.
В исходниках
Группа ввода и вывода оператора регистрирует скорость как MeterView поверх счётчика записей.
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 шестьдесят секунд.
private static final int DEFAULT_TIME_SPAN_IN_SECONDS = 60;
Скорость считается как разница между текущим значением счётчика и самым старым значением в кольцевом буфере, делённая на длину окна.
@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. Значение может быть диапазоном.
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-сервер на первом свободном. Если не удалось ни на одном, процесс получает исключение.
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 каждый процесс в своём контейнере, поэтому хватает одного порта.
Документация: Metric Reporters
Теперь самое практичное. По какому имени искать метрику в Prometheus и какие у неё метки. Ответ даёт метод, который вызывается при регистрации каждой метрики.
В исходниках
Все переменные группы превращаются в метки. У ключа срезаются угловые скобки, у значения недопустимые символы заменяются фильтром.
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_, логической области группы и имени метрики.
private static String getScopedName(String metricName, MetricGroup group) {
return SCOPE_PREFIX
+ getLogicalScope(group)
+ SCOPE_SEPARATOR
+ CHARACTER_FILTER.filterCharacters(metricName);
}
Логическая область берётся у самой группы с разделителем _, а фильтр меняет всё, кроме латиницы, цифр, двоеточия и подчёркивания, на подчёркивание.
private static String getLogicalScope(MetricGroup group) {
return LogicalScopeProvider.castFrom(group)
.getLogicalScope(CHARACTER_FILTER, SCOPE_SEPARATOR);
}
@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 написаны по коду.
Документация: Metric Reporters, Prometheus
Отсюда следствие про настройки 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 получает своего посредника.
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 как его скорость.
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 нет.
@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_max | TaskManager |
| Насколько отстали по времени (Duration) | currentEmitEventTimeLag источника | TaskManager |
| Насколько занят оператор (Utilization) | busyTimeMsPerSecond | TaskManager |
| Ждёт ли оператор соседа (Saturation) | backPressuredTimeMsPerSecond | TaskManager |
| Падает ли задание (Errors) | numRestarts | JobManager |
| Идут ли чекпоинты (Errors, Duration) | lastCheckpointCompletedTimestamp, numberOfFailedCheckpoints, lastCheckpointDuration | JobManager |
| Хватает ли памяти (Utilization) | Status.JVM.Memory.Heap.Used, время GC | каждый процесс |
Метрики busyTimeMsPerSecond и backPressuredTimeMsPerSecond показывают, сколько миллисекунд из каждой секунды задача работала и сколько ждала свободного буфера у следующего оператора. Как по ним найти узкое место, подробно разберём в уроке 18. Здесь нам достаточно, что backpressure больше 500 мс в секунду пять минут подряд это повод для предупреждения.
Колонка «где живёт» важнее, чем кажется. Метрики JobManager переживают падение TaskManager. Метрики TaskManager исчезают вместе с ним. Проверим это на двух метриках источника.
В исходниках
Отставание по времени события источник считает в TaskManager. Это текущее время на часах TaskManager минус время события последней выданной записи. Если источник простаивает, вместо текущего времени берётся момент начала простоя.
/**
* 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 внутри группы оператора.
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 connector, Monitoring, Metrics, System metrics
Почему одного лага мало
Лаг 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.
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.
@@ -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.
@@ -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.
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 с пятью правилами. Шестое, на лаг, допишешь в задании.
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.
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-файлы.
apiVersion: 1
providers:
- name: payguard
type: file
options:
path: /var/lib/grafana/dashboards
Сам дашборд monitoring/grafana/dashboards/payguard.json. Начало файла и первая панель.
{
"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) оставлено под панель лага из задания. Остальные панели идут сразу за первой.
{
"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 в минуту в сумме | Если линия одного правила упала в ноль, а платежи идут, правило сломано |
| Куча TaskManager | 431 МБ из 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. На дашборде пустует место справа от первой панели. Добавь и то и другое.
- Правило
PayGuardKafkaLagHighв группеpayguard-flink. Оно срабатывает, если суммарный лаг источникаpayments-rawпо всем подзадачам больше 5000 записей и держится так две минуты. Критичностьcritical, в аннотацииsummaryтекущее значение лага. - Панель «Лаг Kafka, записей» в
payguard.jsonна местеx12,y0, ширина 12, высота 8, тем же запросом, что и в правиле.
Готово, если после перезапуска Prometheus и Grafana панель показывает лаг около нуля при работающем генераторе, а после сценария с остановкой TaskManager на 100 секунд правило переходит в pending вскоре после возврата TaskManager.
Подсказки.
- Найди точное имя метрики запросом к
/api/v1/label/__name__/valuesи отфильтруй его поlagчерезjqилиgrep. - Метрик лага у источника несколько. Посмотри, какие метки есть у нужного ряда, и подумай, по какой из них схлопывать, чтобы на подзадачу остался один ряд.
- Значение метки
operator_nameбери из готовых правил. Без фильтра по оператору в сумму попадут источники правил и подтверждений. - Prometheus в нашем Compose не перечитывает правила сам. После правки файла перезапусти контейнер.
Ответ
Правило в конце monitoring/alerts.yml.
- alert: PayGuardKafkaLagHigh
expr: |
sum(max by (subtask_index) (
flink_taskmanager_job_task_operator_KafkaSourceReader_KafkaConsumer_records_lag_max{
job_name="payguard", operator_name="Source:_payments_raw"}
)) > 5000
for: 2m
labels:
severity: critical
annotations:
summary: "Задание отстало от топика payments.raw на {{ $value }} записей"
Выражение читается изнутри наружу. Ряды records_lag_max отбираются по заданию и оператору источника платежей. max by (subtask_index) оставляет по одному ряду на подзадачу и отбрасывает остальные метки, например tm_id и task_attempt_id, которые меняются после перезапуска. sum складывает подзадачи в одно число. Порог 5000 при 200 платежах в секунду означает примерно 25 секунд отставания. for: 2m не даёт алерту загораться, пока задание догоняет топик после короткого рестарта. Выражение многострочное, поэтому записано блоком YAML через |. В аннотации {{ $value }} подставляет значение выражения в момент срабатывания.
Панель в payguard.json стоит второй в списке.
{
"id": 2,
"title": "Лаг Kafka, записей",
"type": "timeseries",
"gridPos": {
"x": 12,
"y": 0,
"w": 12,
"h": 8
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"targets": [
{
"refId": "A",
"expr": "sum(max by (subtask_index) (flink_taskmanager_job_task_operator_KafkaSourceReader_KafkaConsumer_records_lag_max{job_name=\"payguard\",operator_name=\"Source:_payments_raw\"}))",
"legendFormat": "payments.raw"
}
]
},
Запрос тот же, что в правиле, без порога. Единица short подписывает ось числами с сокращениями вроде 11K.
Как проверить.
docker compose restart prometheus grafana
curl -s localhost:9090/api/v1/rules | jq -r '.data.groups[].rules[].name'
docker compose stop taskmanager
sleep 100
docker compose start taskmanager
sleep 60
curl -s localhost:9090/api/v1/alerts | jq -r '.data.alerts[] | "\(.labels.alertname) \(.state)"'
Первый запрос должен перечислить шесть правил. На нашем стенде через 60 секунд после возврата TaskManager лаг был 11220 записей, и PayGuardKafkaLagHigh стоял в pending. Пока TaskManager остановлен, это правило молчит, и это ожидаемо, за этот промежуток отвечает PayGuardSourceSilent.
В исходниках
records-lag-max это метрика клиента Kafka, максимальный лаг среди партиций, которые читает консьюмер. Подзадача у нас читает две партиции из четырёх, и сумма максимумов по подзадачам может быть меньше полного лага. Для порога «отстали сильно» этой точности хватает. У коннектора есть и точная сумма, метрика pendingRecords, которая складывает records-lag всех партиций подзадачи.
public void maybeAddRecordsLagMetric(KafkaConsumer<?, ?> consumer, TopicPartition tp) {
// Lazily register pendingRecords
if (recordsLagMetrics == null) {
this.recordsLagMetrics = new ConcurrentHashMap<>();
this.sourceReaderMetricGroup.setPendingRecordsGauge(
() -> {
long pendingRecordsTotal = 0;
for (Metric recordsLagMetric : this.recordsLagMetrics.values()) {
pendingRecordsTotal +=
((Double) recordsLagMetric.metricValue()).longValue();
}
return pendingRecordsTotal;
});
}
recordsLagMetrics.computeIfAbsent(
tp, (ignored) -> getRecordsLagMetric(consumer.metrics(), tp));
}
В Prometheus она называется flink_taskmanager_job_task_operator_pendingRecords. На стенде мы её не проверяли, правило в снапшоте написано на records_lag_max. Если перейдёшь на pendingRecords, запроси её сначала через API и сравни с лагом группы потребителей от kafka-consumer-groups.sh.
Документация: Kafka connector, Monitoring
Шесть правил теперь закрывают разные отказы. Перезапуски, застрявшие и падающие чекпоинты видны по метрикам JobManager. Потерю TaskManager ловит absent. Перегрузку ловят backpressure и лаг. В уроке 21 ты напишешь на каждое из них runbook, где для дежурного будет записано, что проверить первым и как чинить.
Глоссарий урока
- Метрика (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.
@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 и раскладывает по трём уровням.
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 мс из метрики, пересчитанные в долю.
Документация: Monitoring Back Pressure
У графа в 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 несколько раз снять стек потока подзадачи и складывает результаты. Функция выключена по умолчанию, потому что снятие стеков само стоит ресурсов.
В исходниках
Ключ включения и его значение по умолчанию.
/** 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) берёт всё.
/**
* 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 и полный граф.
Снимки всех подзадач вершины складываются в один граф. Это объясняет число, которое мы увидим на стенде.
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 %, её вклад растворится, и для поиска перекоса надо открывать граф отдельной подзадачи.
Документация: Flame Graphs, Brendan Gregg, Flame Graphs
Что обычно находится в широкой полосе у медленного оператора.
- Синхронный вызов внешней системы. Полоса уходит в сокет или драйвер базы, и её видно в Off-CPU графе. Лечится асинхронным вводом-выводом из урока 9 или пакетной записью.
- Сериализация. Между вершинами записи всегда превращаются в байты. Если тип ушёл в Kryo, в On-CPU графе вырастают методы
KryoSerializer. В PayGuard такое невозможно с урока 3, где мы запретили обобщённые типы. - Доступ к состоянию. В RocksDB каждое чтение и запись проходит через сериализацию и может читать диск. Об этом урок 19.
- Сборка мусора. Её нет в стеке задачи, зато её видно в метриках JVM из урока 17.
У сериализации есть ещё одно место, о котором мало кто помнит. Внутри цепочки операторов записи по сети не идут, но Flink всё равно копирует каждую запись перед следующим оператором цепочки. Копию снимает тот же сериализатор типа.
В исходниках
При сборке цепочки Flink выбирает выход оператора. Без повторного использования объектов (object reuse) берётся копирующий вариант.
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);
}
И сама копия перед передачей следующему оператору.
@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 риск маленький, но в курсе ключ не включён, и выигрыш надо мерить на своём задании.
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"
Документация: Execution Configuration, Data Types & Serialization, State Backends
Чекпоинты в пробке
Из урока 11 ты знаешь, почему при backpressure выровненный чекпоинт медленный. Барьер стоит в очереди канала за всеми буферами, которые там скопились. Unaligned-чекпоинт разрешает барьеру обогнать эти буферы и сохраняет их вместе со снимком. Есть третий путь, его в уроке 11 не было. Можно сделать так, чтобы очередь перед барьером стала короче.
Этим занимается buffer debloating. Подзадача-получатель меряет свою пропускную способность и подбирает размер сетевого буфера так, чтобы данные в полёте (in-flight data) разбирались примерно за заданное время. По умолчанию это одна секунда. Количество буферов при этом не меняется, уменьшается только их полезный размер.
В исходниках
Целевое время и выключатель. Механизм по умолчанию выключен.
/** 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. Желаемый объём данных в полёте равен пропускной способности, умноженной на целевое время. Новый размер буфера сглаживается скользящим средним и делится на число занятых буферов.
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));
Пересчёт запускает входной шлюз подзадачи. Он берёт измеренную пропускную способность и рассылает новый размер всем своим каналам, а те сообщают его отправителям.
@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);
}
Медленный получатель с пропускной способностью в десятки записей в секунду получит маленькие буферы. Барьеру придётся ждать в очереди меньше байтов, и выравнивание закончится быстрее.
Документация: The Buffer Debloating Mechanism, Checkpointing under backpressure
Компромисс между двумя режимами из урока 11 задаёт таймаут выравнивания. Чекпоинт начинается выровненным и переключается на unaligned, если выравнивание затянулось.
В исходниках
Ключ работает только при включённых unaligned-чекпоинтах. Значение по умолчанию 0, то есть с включённым unaligned каждый чекпоинт сразу стартует невыровненным.
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 секунд, спокойные чекпоинты останутся выровненными и маленькими, а в пробке переключатся и не будут ждать минуту.
Документация: Checkpointing under backpressure
Вот что получилось на стенде при одной и той же нагрузке 200 платежей в секунду и задержке 20 мс на запись. Колонка про данные взята из статистики чекпоинта Processed (persisted) in-flight data.
| Режим | Длительность чекпоинта | Данные в полёте | Лаг Kafka в момент замера |
|---|---|---|---|
| Без тормоза, выровненный | 223-367 мс | почти нет | 0 |
| Тормоз, выровненный | 25651 и 61137 мс | очередь перед барьером | 1340 и растёт |
| Тормоз, unaligned | 129-293 мс | записано 705-1011 КиБ | 10760 |
| Тормоз, выровненный с debloat | 1540-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 записей на продавца за окно вместо тысяч платежей.
Соль подходит не для каждой агрегации. Сумма и количество складываются из частей без потерь. Среднее надо считать как сумму и количество и делить уже во второй фазе. Число уникальных значений из частей не складывается, для него Flink SQL использует отдельный приём с корзинами по значению (split distinct).
В Flink SQL двухфазная агрегация уже встроена. В плане из урока 14 ты видел пару LocalWindowAggregate и GlobalWindowAggregate. Там первая фаза частично складывает записи ещё до сети, на стороне отправителя. В DataStream API такой оптимизации нет, ключ после keyBy закреплён за своей группой ключей, и разложить его можешь только ты сам.
Документация: Local-Global Aggregation, Split Distinct Aggregation
Сравнение со Spark
В Spark Structured Streaming медленный приёмник делает длиннее каждый micro-batch. Следующий batch стартует позже и забирает накопившиеся смещения. Чтобы batch не разрастался, его ограничивают опцией источника Kafka maxOffsetsPerTrigger. Чтобы найти медленное место, смотрят длительность стадий и задач в Spark UI.
Во Flink конвейер непрерывный, поэтому задержка одного оператора сразу превращается в backpressure по всему графу выше него. Ограничитель скорости здесь не нужен, источник сам перестаёт читать быстрее, чем граф успевает обработать. Для перекоса в пакетном Spark есть адаптивное выполнение запросов, которое умеет делить перекошенную партицию при соединении. В DataStream Flink такой автоматики нет, и для горячего ключа ты пишешь две фазы руками.
Документация: Structured Streaming + Kafka, Spark, Optimizing Skew Join
Проверь себя
В графе три вершины подряд. Источник показывает 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. Он изображает приёмник, который на каждую запись ходит во внешнюю систему и ждёт ответа.
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 добавь поле.
long slowMs,
И его чтение в fromArgs на той же позиции. По умолчанию тормоз выключен.
params.getLong("slow-ms", 0),
Встрой оператор после card-profile. Вызов returns нужен, потому что SlowDown обобщённый, и без подсказки Java сотрёт тип записи. Запрет обобщённых типов из урока 3 тогда уронит задание при сборке графа.
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.
@@ -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, он печатает одну строку на вершину.
#!/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. Вот что получилось у нас.
| Вершина | busy | back | Что это значит |
|---|---|---|---|
card-profile -> slow-down | 1000 | 0 по формуле 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.
Порядок разбора всегда один. Алерт на backpressure или лаг, потом граф с busy и back от источника вниз, потом flame graph узкой вершины, потом решение по причине. Режим чекпоинтов выбирают заранее, чтобы пробка не превращалась ещё и в долгое восстановление.
Задание. Разгрузи горячего продавца
При параллелизме 4 окно merchant-stats-1m получает вход с разбросом в 2,6 раза между подзадачами, потому что продавец m-0000 забирает 30 % платежей. Перепиши минутную статистику по продавцам в две фазы с солью.
- Создай класс
SaltedMerchantStatsв пакетеpipelineс двумя методами. Первая фаза считает минутный итог по ключу «продавец плюс соль» и называетсяmerchant-stats-salted. Вторая фаза складывает частичные итоги по настоящему продавцу и называетсяmerchant-stats-merged. - Добавь флаг
--stats-salts. Значение 0 или 1 оставляет старое окно, большее значение включает две фазы. В Postgres должны уходить итоги второй фазы, опоздавшие платежи по-прежнему уходят вpayments.late. - Напиши тест на MiniCluster с параллелизмом 4. Сто платежей одного продавца в одной минуте после двух фаз должны дать ровно одну строку с правильными количеством и суммой.
- Запусти задание на стенде с параллелизмом 4, сначала без соли, потом с
--stats-salts 16, и сравни выводscripts/vertex-load.sh.
Готово, если тест зелёный, разброс входа первой фазы заметно меньше 2,6 раза, вторая фаза получает сотни записей вместо тысяч, а итог по m-0000 за минуту остаётся около 30 % всех платежей.
Подсказки.
- Соль должна быть детерминированной. Flink вычисляет ключ записи и при отправке, и при доступе к состоянию, поэтому случайное число здесь не годится. Подумай, какое поле платежа уже равномерно случайно.
- Вторая фаза должна попасть в то же минутное окно, что и первая. Посмотри, какое время события получает запись, которую выпустило окно.
- Подумай, что случится со второй фазой, если первая из-за допуска опозданий выпустит итог одного окна дважды.
- Параллелизм 4 займёт все четыре слота стенда. Флаг
-pнужен самомуflink run, поэтомуsubmit.shздесь не подойдёт.
Ответ
Класс целиком.
package dev.study.payguard.pipeline;
import dev.study.payguard.model.MerchantStats;
import dev.study.payguard.model.PaymentEvent;
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;
/**
* Двухфазная агрегация для горячего ключа. Первая фаза считает по продавцу с солью и разносит нагрузку по подзадачам,
* вторая складывает частичные итоги по настоящему продавцу.
*/
public final class SaltedMerchantStats {
/** Опоздания отключены намеренно: повторное срабатывание первой фазы удвоило бы частичный итог во второй. */
public static SingleOutputStreamOperator<MerchantStats> firstPhase(DataStream<PaymentEvent> payments, int salts) {
return payments
.keyBy(payment -> saltedKey(payment, salts))
.window(TumblingEventTimeWindows.of(MerchantStatsWindow.WINDOW_SIZE))
.sideOutputLateData(MerchantStatsWindow.TOO_LATE)
.aggregate(new PaymentTotals(), new PartialStats())
.name("merchant-stats-salted")
.uid("merchant-stats-salted");
}
public static SingleOutputStreamOperator<MerchantStats> secondPhase(DataStream<MerchantStats> partial) {
return partial
.keyBy(MerchantStats::merchantId)
.window(TumblingEventTimeWindows.of(MerchantStatsWindow.WINDOW_SIZE))
.reduce(SaltedMerchantStats::add)
.name("merchant-stats-merged")
.uid("merchant-stats-merged");
}
private static String saltedKey(PaymentEvent payment, int salts) {
return payment.merchantId() + "#" + Math.floorMod(payment.paymentId().hashCode(), salts);
}
private static String merchantOf(String saltedKey) {
return saltedKey.substring(0, saltedKey.indexOf('#'));
}
private static MerchantStats add(MerchantStats left, MerchantStats right) {
return new MerchantStats(
left.merchantId(),
left.windowStartMs(),
left.windowEndMs(),
left.paymentCount() + right.paymentCount(),
left.amountMinor() + right.amountMinor());
}
private static final class PartialStats
extends ProcessWindowFunction<PaymentTotals.Accumulator, MerchantStats, String, TimeWindow> {
@Override
public void process(
String saltedKey,
Context context,
Iterable<PaymentTotals.Accumulator> totals,
Collector<MerchantStats> out) {
PaymentTotals.Accumulator total = totals.iterator().next();
TimeWindow window = context.window();
out.collect(new MerchantStats(
merchantOf(saltedKey), window.getStart(), window.getEnd(), total.count(), total.amountMinor()));
}
}
private SaltedMerchantStats() {
}
}
Решения внутри класса.
- Соль равна
Math.floorMod(paymentId.hashCode(), salts). Идентификатор платежа случаен, поэтому части получаются почти равными, а одинаковый платёж всегда даёт одинаковый ключ.floorModнужен, потому чтоhashCodeбывает отрицательным. - Первая фаза использует тот же
PaymentTotals, что и старое окно. Сумма и количество складываются, поэтому частичный итог совместим со второй фазой.PartialStatsотрезает соль от ключа и кладёт вMerchantStatsнастоящего продавца с границами окна. - Вторая фаза это
reduceпо продавцу в таком же минутном окне. Она хранит один объект на ключ и окно и складывает части по мере прихода. - В первой фазе нет
allowedLateness. Окно с допуском опозданий выпускает итог повторно на каждый опоздавший платёж, и вторая фаза сложила бы обновлённый частичный итог с прежним. Опоздавшие платежи уходят в боковой выход и дальше вpayments.late.
Почему вторая фаза попадает в нужное окно. Её записи несут время события, которое им назначило окно первой фазы.
В исходниках
Перед вызовом функции окна оператор ставит всем её выходным записям одно время, максимум окна.
private void emitWindowContents(W window, ACC contents) throws Exception {
timestampedCollector.setAbsoluteTimestamp(window.maxTimestamp());
processContext.window = window;
userFunction.process(
triggerContext.key, window, processContext, contents, timestampedCollector);
}
Максимум окна на единицу меньше его конца.
@Override
public long maxTimestamp() {
return end - 1;
}
Для окна с 12:00 до 12:01 частичные итоги получат время 12:00:59.999 и попадут во второй фазе в то же окно. Сработает оно, когда watermark пройдёт конец минуты, то есть сразу после первой фазы.
Документация: Windows
Поле и чтение флага в JobConfig.
int statsSalts,
params.getInt("stats-salts", 0),
Подключение в PayGuardJob. Боковой выход опоздавших берётся у первой фазы, потому что именно она стоит на сыром потоке платежей. При выключенной соли firstStats это старое окно, и поведение не меняется.
boolean salted = config.statsSalts() > 1;
SingleOutputStreamOperator<MerchantStats> firstStats = salted
? SaltedMerchantStats.firstPhase(payments, config.statsSalts())
: MerchantStatsWindow.attach(payments);
DataStream<MerchantStats> merchantStats = salted ? SaltedMerchantStats.secondPhase(firstStats) : firstStats;
merchantStats
.sinkTo(JdbcSinks.merchantStats())
.name("merchant-stats-postgres")
.uid("merchant-stats-postgres");
firstStats.getSideOutput(MerchantStatsWindow.TOO_LATE)
.sinkTo(KafkaSinks.<PaymentEvent>atLeastOnceJson(config.bootstrapServers(), config.lateTopic()))
.name("too-late-sink")
.uid("too-late-sink");
Тест. MiniCluster из урока 16, параллелизм 4, соль 8. Платежи с временем 1000-1099 мс попадают в первую минуту, и после двух фаз ожидается одна строка.
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 java.util.stream.IntStream;
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 SaltedMerchantStatsTest {
@RegisterExtension
static final MiniClusterExtension CLUSTER = new MiniClusterExtension(
new MiniClusterResourceConfiguration.Builder()
.setNumberTaskManagers(1)
.setNumberSlotsPerTaskManager(4)
.build());
@Test
void saltingDoesNotChangeTotals() throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(4);
List<PaymentEvent> hotPayments = IntStream.range(0, 100)
.mapToObj(i -> payment("p" + i, "c" + i, "hot", 10, 1_000 + i))
.toList();
List<MerchantStats> stats = SaltedMerchantStats
.secondPhase(SaltedMerchantStats.firstPhase(streamOf(env, hotPayments), 8))
.executeAndCollect(10);
assertThat(stats).containsExactly(new MerchantStats("hot", 0, 60_000, 100, 1_000));
}
}
Запуск на стенде. Сначала без соли, потом с солью, каждый раз отменяя предыдущее задание.
./gradlew test --tests '*SaltedMerchantStatsTest' shadowJar
docker compose exec -T jobmanager flink run --detached -p 4 /jobs/payguard-1.0.0.jar
scripts/vertex-load.sh
docker compose exec -T jobmanager flink cancel "$JOB"
docker compose exec -T jobmanager flink run --detached -p 4 /jobs/payguard-1.0.0.jar --stats-salts 16
scripts/vertex-load.sh
Перед отменой получи $JOB той же командой, что в практике. Цифры на стенде при 200 платежах в секунду.
| Вершина | numRecordsIn, минимум | numRecordsIn, максимум | Разброс |
|---|---|---|---|
merchant-stats-1m без соли | 11476 | 29551 | 2,6 раза |
merchant-stats-salted, соль 16 | 4716 | 8433 | 1,8 раза |
merchant-stats-merged | 266 | 555 | частичные итоги |
Почему разброс не стал единицей. С солью 16 продавец m-0000 превратился в 16 ключей примерно по 1,9 % потока. Обычный продавец получает 70 % на 49 продавцов, около 1,4 %. Горячего ключа больше нет, а остаток неравномерности даёт раскладка 800 ключей по группам ключей и подзадачам, её ты видел в уроке 4.
Корректность проверь в Postgres. Возьми одну законченную минуту и сравни строку m-0000 с суммой по всем продавцам.
docker compose exec -T postgres psql -U payguard -d payguard -c \
"select merchant_id, payment_count from merchant_stats
where window_start_ms = (select max(window_start_ms) - 60000 from merchant_stats)
order by payment_count desc limit 3"
У нас за минуту на m-0000 пришлось 3285 платежей из 10969, это 30 %, ровно столько задано в PaymentFactory. Соль поменяла раскладку работы и не тронула результат.
Глоссарий урока
- Обратное давление (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 после запуска, их же покажет скрипт в практике.
| Область | Что в ней живёт | Как задаётся |
|---|---|---|
| Framework Heap и Framework Off-Heap | Объекты самого TaskManager, не твоего кода | По 128m, обычно не трогают |
| Task Heap | Объекты операторов, записи в полёте, состояние HashMapStateBackend | Остаток после всех остальных областей |
| Task Off-Heap | Direct-буферы и нативная память твоего кода и библиотек | 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.
- Overhead = 0,1 × P с обрезкой по границам 192m и 1g. Для P = 2048 МиБ получается 204,8 МиБ.
- Total Flink = P − Metaspace − Overhead = 2048 − 256 − 204,8 = 1587,2 МиБ.
- Managed = 0,4 × Total Flink = 634,9 МиБ. Network = 0,1 × Total Flink = 158,7 МиБ с обрезкой снизу по 64m.
- Task Heap = Total Flink − 128 − 128 − 0 − Managed − Network = 537,6 МиБ.
Куча задач получается последней и забирает остаток. Поэтому, подняв долю managed, ты отнимаешь память именно у кучи, и при маленьком процессе она может уйти в минус.
В исходниках
Точка входа выбирает одну из трёх веток по тому, какие размеры заданы явно. Обрати внимание на приоритет. Если заданы куча задач и managed, считается от них. Если задан Total Flink, от него. Размер процесса используется, только когда нет ни того, ни другого.
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.
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 и сетевая память выводятся из долей, а куча задач получает то, что осталось после вычитания всех остальных областей. Проверка перед вычитанием и есть тот случай, когда одежда не влезла в мешок.
} 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 это ноль и бесконечность.
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.
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"))
Документация: Set up TaskManager Memory, Set up Flink's Process Memory
Калькулятор ниже повторяет эти вычисления в том же порядке, с теми же значениями по умолчанию и с тем же округлением. Поля без значения считаются незаданными, как отсутствующий ключ в конфиге. Кнопки подставляют три настоящих случая из курса. Сначала стенд, потом конфликт, на который мы нарвались в этом уроке, и старый конфиг из урока 1.
Калькулятор памяти TaskManager
| Поле | Значение |
|---|
С кнопкой «Стенд курса» калькулятор выдаёт ровно те числа, что показал TaskManager на стенде, включая ёмкость блочного кэша RocksDB до байта. Попробуй сдвинуть managed.fraction к 0,8 при 2048 МиБ и посмотри, как тает Task Heap. Потом поставь 768 МиБ и долю 0,4, и TaskManager не стартует.
Когда настройки спорят
Опасно задавать сразу несколько размеров. Каждый новый явный размер убирает у Flink свободу, и в какой-то момент области перестают сходиться. Тогда ошибка всплывает на старте TaskManager, задание даже не начнёт разворачиваться.
В исходниках
Если заданы и размер процесса, и Total Flink, Overhead перестаёт быть долей и становится разностью. Когда разность отрицательная, Flink бросает исключение с текстом, который мы увидим в практике.
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;
Все такие ошибки оборачиваются в одно сообщение с общим префиксом. По нему конфликт памяти легко найти в логе.
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);
}
}
Документация: Memory Troubleshooting
Для контейнеров документация советует задавать только 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 делится между слотами поровну. Профиль слота по умолчанию получает свою часть делением на число слотов.
@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, флаг включён, пока его не выключили.
public boolean isUsingManagedMemory() {
return useManagedMemory != null
? useManagedMemory
: RocksDBOptions.USE_MANAGED_MEMORY.defaultValue();
}
Из бюджета слота создаётся общий кэш, а менеджер буферов записи получает этот кэш аргументом. Поэтому memtable и блоки данных конкурируют за одну и ту же память.
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);
Ёмкость кэша и менеджера считается по двум формулам. Комментарий над ними объясняет, откуда тройка, менеджер буферов может превысить свою ёмкость в полтора раза.
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 в слоте нашего задания нет.
Документация: State Backends, Memory Management, Tuning RocksDB Memory
Отсюда несколько практических следствий.
- Удвоил число слотов на том же TaskManager, и кэш каждого слота уменьшился вдвое. Состояние осталось прежним, промахов кэша станет больше.
- Каждое состояние в RocksDB это отдельное семейство колонок (column family) со своими memtable. Задание с десятком состояний делит тот же бюджет на большее число буферов записи.
- Частые сбросы memtable лечатся ростом
state.backend.rocksdb.memory.write-buffer-ratio, промахи на чтении ростом managed. Документация советует сначала просто дать больше managed, доля 0,4 консервативна для процессов в несколько гигабайт. - С
HashMapStateBackendmanaged memory стримингу не нужна. Документация рекомендует поставить её в ноль, тогда куча задач получит эту память.
С 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 слота.
/**
* 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.
@Override
public boolean supportsAsyncKeyedStateBackend() {
return true;
}
Основной каталог SST-файлов по умолчанию совпадает с каталогом чекпоинтов. Отсюда лёгкие чекпоинты, файлы уже лежат там, куда их пришлось бы копировать.
/** 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));
Документация: Disaggregated State Management, State V2
Синхронные операторы 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 добавляются девять строк.
@@ -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 осталось как устаревшее и пока принимается.
@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.");
Работает и старый ключ, но в новых конфигах пиши новое имя. Устаревшие ключи однажды убирают, и тогда настройка молча перестанет действовать.
Документация: Metrics
Шаг 2. Скрипт разбора памяти
Создай scripts/tm-memory.sh и сделай его исполняемым командой chmod +x 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 с десятью полями. На стенде курса получились такие значения в мебибайтах.
| Поле | МиБ | Откуда |
|---|---|---|
frameworkHeap | 128 | Значение по умолчанию |
taskHeap | 537 | Остаток Total Flink |
frameworkOffHeap | 128 | Значение по умолчанию |
taskOffHeap | 0 | Значение по умолчанию |
networkMemory | 158 | 0,1 × 1587,2 |
managedMemory | 634 | 0,4 × 1587,2 |
jvmMetaspace | 256 | Значение по умолчанию |
jvmOverhead | 204 | 0,1 × 2048 |
totalFlinkMemory | 1587 | 2048 − 256 − 204,8 |
totalProcessMemory | 2048 | Из конфига |
Сумма шести областей 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_keys | 8563 | Оценка RocksDB. Генератор выдаёт 5000 карт, поэтому для точного счёта ключей она не годится, только для порядка величины |
rocksdb_estimate_live_data_size | 41162 байта | Данные в SST-файлах. Почти всё записанное за минуту ещё лежит в memtable, поэтому число маленькое |
rocksdb_block_cache_capacity | 138691653 байта, 132 МиБ | Ровно формула из теории и строка калькулятора |
rocksdb_block_cache_usage | 32362256 байт, 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
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, только без окна, чтобы агрегат держал состояние постоянно.
-- Тот же расчёт выручки, но состояние лежит в 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.
@@ -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 под эту нагрузку. Кода писать не нужно, результат это расчёт.
- Перечисли состояния задания по операторам и раздели их на состояние на карту без срока и состояние на платёж со сроком. Сроки найди в коде, это константы вроде
REMEMBER_FOR,MAX_DELAYи окна CEP. - Возьми размер записи из замеров стенда. Подойдёт рост размера чекпоинтов в уроке 11, там при 200 платежах в секунду состояние выросло с 1882 до 5899 КиБ между первым и вторым чекпоинтом с интервалом 15 секунд.
- Посчитай размер состояния по формуле из теории. Дай реалистичную оценку и верхнюю границу.
- Выбери параллелизм, число TaskManager, слотов,
process.sizeи долю managed. Проверь раскладку калькулятором и выпиши, сколько блочного кэша получит каждый слот. - Оцени локальный диск на TaskManager и объём хранилища чекпоинтов.
Готово, если у каждого числа есть явное допущение и ты можешь сказать, какой замер на нагрузочном стенде его подтвердит или опровергнет.
Подсказки. В росте чекпоинта из урока 11 ничего ещё не успело истечь, поэтому он показывает полный след платежа во всех операторах. Пропускную способность одной подзадачи можно прикинуть по уроку 18, там при 200 платежах в секунду и параллелизме 2 занятость всех вершин была меньше 30 мс в секунду. И не забудь про число партиций Kafka, оно ограничивает параллелизм источника.
Ответ
Ниже один из разумных расчётов. Твои числа могут отличаться, если допущения другие и записаны явно.
Инвентаризация состояния
| Оператор | Ключ | Что хранит | Сколько живёт |
|---|---|---|---|
deduplicate | платёж | флаг seen | 1 час по TTL |
join-confirmations, rule-unconfirmed | платёж | платёж целиком в ожидании подтверждения | до 5 минут |
cep-card-testing, cep-impossible-travel | карта | буфер событий шаблона | 5 и 10 минут |
rules-dynamic, rule-amount-spike | карта | корзины за 1 и 10 минут, время последнего алерта | корзины до 10 минут, время алерта без срока |
card-sessions | карта | открытая сессия | 30 секунд тишины |
card-profile | карта | CardProfile | без срока |
merchant-stats-1m | продавец | окно минуты | 2 минуты, продавцов мало |
Допущения
- Д1. След одного платежа во всех операторах равен 1,4 КиБ. Из замера урока 11 (5899 − 1882) / (200 × 15) = 1,34 КиБ, округлено вверх. Число включает служебные данные RocksDB и версии, которые ещё не схлопнула компакция, поэтому это оценка сверху.
- Д2. Весь след живёт 10 минут, по самому длинному окну после дедупликации. Для соединения и подтверждения это с запасом, они освобождаются через 5 минут.
- Д3. С 10-й по 60-ю минуту от платежа остаётся только запись дедупликации, 100 байт. Это UUID из 36 символов, флаг, метка TTL на 8 байт и накладные расходы RocksDB на ключ. Замера отдельно по этому состоянию нет, это главное непроверенное допущение.
- Д4. Бессрочное состояние карты, профиль и два времени последнего алерта, занимает 150 байт.
Размер состояния
| Часть | Расчёт | Итог |
|---|---|---|
| Платежи за последние 10 минут | 5000 × 600 × 1,4 КиБ | 4,0 ГиБ |
| Хвост дедупликации | 5000 × 3000 × 100 байт | 1,4 ГиБ |
| Карты | 5 000 000 × 150 байт | 0,7 ГиБ |
| Реалистичная оценка | сумма | около 6 ГиБ |
| Верхняя граница | весь след 1,4 КиБ держится час, 5000 × 3600 × 1,4 КиБ | 24 ГиБ |
Состояние на событие в десятки раз больше состояния на карту. Рост числа карт добавляет немного. Рост скорости и сроков хранения умножает почти весь объём. Срок дедупликации в час здесь самый дорогой рычаг. Сократив его до 15 минут, ты уберёшь почти весь хвост.
Параллелизм
В уроке 18 одна подзадача обрабатывала 100 платежей в секунду и была занята меньше 30 мс в секунду, то есть до 0,3 мс на платёж в самой загруженной вершине. При параллелизме 8 на подзадачу придётся 625 платежей в секунду, занятость около 190 мс в секунду. Запас в пять раз нужен по двум причинам. Задание должно догонять поток после восстановления, а с гигабайтами состояния чтения из RocksDB станут медленнее, чем на стенде, где всё помещается в кэш. Число партиций payments.raw подними с 4 до 8 или больше, иначе часть подзадач источника будет простаивать. Максимальный параллелизм по умолчанию 128, этого хватит на рост в 16 раз без потери состояния.
Память
Два TaskManager по 4 слота, taskmanager.memory.process.size: 8192m, taskmanager.memory.managed.fraction: 0.6, остальное по умолчанию. Калькулятор даёт такую раскладку на каждый TaskManager.
| Область | МиБ |
|---|---|
| Task Heap | 1879 |
| Network | 711 |
| Managed | 4270, на слот 1067 |
| Блочный кэш слота | 889 |
| Менеджер буферов записи слота | 355 |
| JVM Overhead | 819 |
Восемь слотов дают около 6,9 ГиБ кэша. Этого хватает на горячую часть, платежи последних 10 минут, к которым обращаются соединение, CEP и корзины. Хвост дедупликации читается по новому ключу, которого в нём почти никогда нет, и такие чтения отсекают фильтры Блума в приоритетной части кэша. Состояние живёт вне кучи, поэтому 1,9 ГиБ кучи на четыре слота достаточно для записей в полёте, клиентов Kafka и буферов Async I/O. Сетевой памяти выходит больше, чем нужно восьми подзадачам. Её можно ограничить taskmanager.memory.network.max и отдать разницу managed. Лимит пода ставится равным process.size.
Диск и чекпоинты
Рабочий каталог RocksDB с запасом в два раза на компакции и файлы, общие с инкрементальными чекпоинтами. Это 6 ГиБ на TaskManager по реалистичной оценке и 24 ГиБ по верхней границе, том на 30 ГиБ закрывает оба случая. Хранилище чекпоинтов при num-retained: 3 и инкрементальных снимках оцени в 12-50 ГиБ. Общие файлы лежат один раз, но до компакции старые версии держатся несколько снимков.
Как проверить
Каждое допущение проверяется часовым прогоном на нагрузочном стенде с целевой скоростью. Размер состояния смотри в истории чекпоинтов через scripts/checkpoint-report.sh. Он должен выйти на плато примерно через час, когда начнёт истекать TTL дедупликации, и высота плато проверяет Д1-Д4 разом. Отношение block_cache_usage к block_cache_capacity и задержки из замера состояния покажут, хватает ли кэша. Занятость вершин из урока 18 покажет, верен ли запас по параллелизму. Если плато окажется выше 24 ГиБ, значит, в состоянии есть то, чего нет в таблице инвентаризации, и это стоит найти до продакшена.
Глоссарий урока
- 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.
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.
} 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 у нас пустое.
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 параллелизма всегда означает остановку задания и запуск нового кластера. Как именно останавливать, решает режим апгрейда, к нему мы сейчас придём.
Документация: Architecture, Custom Resource Overview
lifecycleState, ответ оператора
Чтобы понять, где сейчас ресурс, не нужно читать весь status. Оператор сводит его в одно поле status.lifecycleState, и kubectl get flinkdep показывает его отдельной колонкой. В нашем стенде встретятся четыре значения.
| Значение | Что оно значит |
|---|---|
CREATED | Ресурс создан, оператор его ещё ни разу не применял |
UPGRADING | Идёт апгрейд, старое задание останавливается или новое разворачивается |
DEPLOYED | Новая spec развёрнута, но ещё не признана стабильной и может быть откачена |
STABLE | Задание работает, spec признана стабильной |
Ещё бывают SUSPENDED, FAILED, ROLLING_BACK и ROLLED_BACK. Откат включается настройкой kubernetes.operator.deployment.rollback.enabled, по умолчанию он выключен, и в уроке мы его не трогаем.
В исходниках
Поле вычисляется при каждом чтении статуса, сеттера у него нет. Сначала смотрится состояние цикла апгрейда.
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 совпадает с последней стабильной.
if (reconciliationStatus.getState() == ReconciliationState.ROLLED_BACK) {
return ResourceLifecycleState.ROLLED_BACK;
} else if (reconciliationStatus.isLastReconciledSpecStable()) {
return ResourceLifecycleState.STABLE;
}
return ResourceLifecycleState.DEPLOYED;
Когда spec становится стабильной, решает checkAndUpdateStableSpec. Комментарий в коде говорит прямо. Работающее задание сейчас всегда помечается стабильным.
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.
Документация: Status and Lifecycle
Три режима апгрейда
Поле spec.job.upgradeMode отвечает на вопрос про суп из холодильника. Оно задаёт, как остановить старое задание и откуда новое возьмёт состояние.
| stateless | last-state | savepoint | |
|---|---|---|---|
| Остановка | Отмена задания, кластер удаляется вместе с 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 готов всегда.
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.
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();
}
}
Флаг по умолчанию включён.
@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.
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();
}
Документация: Job Management
В исходниках
Что значит «остановить» в каждом режиме. Перечисление SuspendMode отвечает на два вопроса. Удалять ли кластер, и удалять ли HA-метаданные. Сохраняет их только LAST_STATE.
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-метаданные остаются на месте.
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 потом передаётся новому кластеру.
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.
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());
}
Документация: Snapshot Management
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 кластера.
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 кластера получается тем же способом.
@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.
/**
* 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.
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 будут вести в пустоту.
Документация: Kubernetes HA Services, High Availability
Остаётся вопрос, почему после рестарта пода JobManager задание продолжает работу с тем же jobId. Ведь хранилище чекпоинтов ищет ConfigMap по jobId. Ответ в операторе. Перед развёртыванием он сам выбирает jobId и записывает его во внутренний ключ конфигурации $internal.pipeline.job-id. Новый под JobManager стартует с той же конфигурацией, запускает задание с тем же jobId и находит его ConfigMap.
В исходниках
Метод setJobIdIfNecessary в ApplicationReconciler выдаёт новый jobId при каждом развёртывании, кроме last-state. Это объясняет строку jobId в таблице режимов.
// 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, данные должны лежать на постоянном томе.
Файловая система контейнера живёт столько же, сколько контейнер. Всё, что должно пережить рестарт, кладут на 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.
# Образ задания: внутри официальный 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. Он поднимает кластер, ставит оператор, создаёт инфраструктуру и загружает образ.
#!/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 ГиБ.
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 перестал отвечать.
Unable to connect to the server: net/http: TLS handshake timeout
Лечение одно. Удали профиль командой minikube delete и запусти скрипт заново.
Вторая ловушка ждёт на первом запуске. Образ оператора скачивается долго, и Helm с таймаутом по умолчанию в пять минут не дождался готовности.
Error: resource not ready ... context deadline exceeded
Оператор при этом поднялся сам через несколько минут, ошибка относилась только к ожиданию. Поэтому в скрипте стоит --timeout 15m.
Шаг 4. Инфраструктура в кластере
Создай k8s/infra.yaml. Здесь Kafka, задание Kubernetes для создания топиков, PostgreSQL и два тома.
# Учебная инфраструктура внутри кластера. В проде 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. Это и есть желаемое состояние, которое оператор будет поддерживать.
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.
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, а лимит контейнера совпадёт с размером процесса.
Документация: Pod template, Reference
Блок 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 из статуса ресурса, оператор записал его туда при развёртывании. На стенде получилось следующее.
| Что смотрели | Результат |
|---|---|
/overview | taskmanagers 1, slots-total 2, jobs-running 1, flink-version 2.2.1 |
| Бэкенд состояния | EmbeddedRocksDBStateBackend |
| Длительность чекпоинта | 105 мс |
| ConfigMap | autoscaler-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.
# Генератор платежей внутри кластера. Запускается из того же образа: 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, и под сразу падал.
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 не смог удалить контейнер узла.
docker rm -f -v minikube: context deadline exceeded
Том узла остался, и новый узел поднялся со старыми данными etcd, базы, где Kubernetes хранит свои объекты. Ресурсы вернулись, а образ задания из хранилища узла пропал.
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.
- Измени
spec.job.parallelismу ресурсаpayguard, не редактируяk8s/payguard.yamlруками. - Пронаблюдай смену
lifecycleStateи засеки время от изменения доSTABLE. - Найди снимок, который снял оператор, и его путь.
- Докажи по REST, что новое задание восстановлено именно из этого снимка и что у всех вершин параллелизм 3.
- Посмотри, сколько стало TaskManager и слотов, и объясни число.
- Проверь счётчик неудачных чекпоинтов и найди в логе JobManager причину, если он вырос.
Готово, если ресурс FlinkStateSnapshot в состоянии COMPLETED, путь из него совпадает с latest.restored.external_path нового задания, counts.restored равен 1, а вершины работают с параллелизмом 3.
Подсказки. Для изменения одного поля подходит kubectl patch с типом merge. После апгрейда jobId сменится, поэтому перечитай его из статуса ресурса. Помни про port-forward, старый под JobManager исчезнет вместе с ним.
Ответ
Меняем одно поле и следим за ресурсом.
kubectl patch flinkdep payguard --type merge -p '{"spec":{"job":{"parallelism":3}}}'
kubectl get flinkdep payguard -w
--type merge сливает переданный JSON с текущим объектом, остальные поля spec не трогаются. По коду ресурс проходит UPGRADING, DEPLOYED и STABLE, промежуточные значения в выводе -w мелькают быстро. На стенде от patch до RUNNING и STABLE прошло 52 с.
kubectl get flinkstatesnapshots
Появился ресурс payguard-savepoint-upgrade-1794126582605 в состоянии COMPLETED с путём file:/flink-data/savepoints/savepoint-34e016-c18e5dd0fdb1. Первые шесть знаков после savepoint- взяты из jobId старого задания.
В исходниках
Имя каталога savepoint Flink собирает из префикса, начала jobId и случайного хвоста.
// generate the savepoint directory
final FileSystem fs = savepointBasePath.getFileSystem();
final String prefix = "savepoint-" + jobId.toString().substring(0, 6) + '-';
Exception latestException = null;
for (int attempt = 0; attempt < 10; attempt++) {
final Path path = new Path(savepointBasePath, FileUtils.getRandomFilename(prefix));
По имени каталога всегда видно, из какого задания снят снимок, даже когда ресурсов FlinkStateSnapshot уже нет.
Теперь проверяем новое задание. jobId сменился, перечитываем его и заново открываем port-forward.
kubectl port-forward svc/payguard-rest 18081:8081
JOB=$(kubectl get flinkdep payguard -o jsonpath='{.status.jobStatus.jobId}')
curl -s localhost:18081/jobs/$JOB/checkpoints | jq '{restored: .counts.restored, path: .latest.restored.external_path}'
curl -s localhost:18081/jobs/$JOB | jq '[.vertices[] | {name, parallelism}]'
curl -s localhost:18081/overview | jq '{taskmanagers, "slots-total"}'
Результаты на стенде. checkpoints.restored равен 1, latest.restored.external_path совпадает с путём из FlinkStateSnapshot. Параллелизм всех вершин 3. В /overview стало taskmanagers 2 и slots-total 4. Трём подзадачам каждой вершины нужно три слота, у одного TaskManager их два, и ResourceManager попросил у Kubernetes второй под. Новые поды назывались payguard-taskmanager-3-1 и payguard-taskmanager-3-2. Четвёртый слот простаивает.
Состояние разложилось по трём подзадачам через группы ключей, как в уроке 13. Максимальный параллелизм задания не менялся, поэтому снимок подошёл.
Остаётся счётчик неудачных чекпоинтов. Сразу после апгрейда он вырос на 1. Ищем причину в логе JobManager, его Deployment называется так же, как кластер.
kubectl logs deploy/payguard | grep 'Failed to trigger checkpoint'
Failed to trigger checkpoint for job 3f6791727f93c0f73bd891d57caaec79 since Checkpoint triggering task deduplicate (2/3) of job ... is not being executed at the moment. Aborting checkpoint. Failure reason: Not all required tasks are currently running..
Первый чекпоинт по расписанию сработал, пока подзадачи на новом TaskManager ещё разворачивались. Чекпоинт требует, чтобы работали все подзадачи, и координатор его отменил. Следующие чекпоинты прошли COMPLETED, и счётчик больше не рос. Для алертов из урока 17 это значит, что одиночный неудачный чекпоинт после деплоя норма, тревогу поднимают на серию подряд.
Ручной подбор параллелизма можно отдать автоскейлеру оператора. Он сам меняет параллелизм вершин по метрикам загрузки и выкатывает изменения тем же циклом апгрейда, если кластер не умеет применить их на месте. На нашем стенде автоскейлер не проверялся, это следующий шаг, описание в документации Autoscaler.
Глоссарий урока
- 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. Смотри на формат в комментарии и на порядок склейки.
/**
* 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 connector, Fault Tolerance
Почему брокер не узнал транзакцию
У Kafka транзакции ведёт координатор транзакций, это роль внутри брокера. Он хранит, какому producerId и какой эпохе принадлежит каждый transactional.id, и пишет эти связи в __transaction_state. Обычный продюсер при старте спрашивает у координатора свой producerId. Восстановленный продюсер Flink этого не делает.
В исходниках
Как Committer получает продюсера для восстановленного committable. Если живого продюсера от Writer нет, а после рестарта его нет никогда, создаётся служебный и вызывается resumeTransaction с сохранёнными числами.
/**
* 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. Прочитай первую строку комментария.
/**
* 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.
Документация: Kafka connector, ProducerFencedException
Почему Committer не пропустил ошибку
В уроке 12 мы видели, что список известных ошибок в KafkaCommitter заканчивается на UnknownProducerIdException. Теперь посмотрим, что каждая категория значит для задания. Это решает интерфейс Flink, коннектор только выбирает категорию.
В исходниках
Первая ветка в KafkaCommitter.commit. Ошибку из семейства RetriableException коннектор просит повторить позже.
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.
/**
* 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);
И как это реализовано в рантайме. Строку из исключения нашей аварии ты увидишь в последнем методе.
@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.
<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.
Документация: Kafka connector, Troubleshooting
Почему рестарты не кончались
После отказа задачи в дело вступает стратегия перезапуска. В k8s/payguard.yaml из урока 20 задан только её тип exponential-delay, остальные параметры остались по умолчанию.
В исходниках
Значение по умолчанию для числа попыток. Обрати внимание на аннотацию, документация показывает его словом infinite.
@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());
Как стратегия решает, можно ли перезапускать, и как считает попытки.
@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 практически всегда. Счётчик к тому же обнуляется, если между отказами прошло больше порога сброса. Растёт только пауза между попытками.
Документация: Task Failure Recovery, Exponential Delay Restart Strategy
Бесконечные попытки хороши для сбоев окружения. Упал TaskManager, моргнула сеть, база ушла на минуту, и задание вернётся само без участия человека. Для ошибки в состоянии они бесполезны. Лимит попыток перевёл бы задание в FAILED, и это был бы более заметный сигнал, но данные он бы не спас. Поэтому цикл ловят алертом PayGuardJobRestarted из урока 17, а runbook объясняет дежурному, как отличить цикл от разового сбоя.
В Spark упавшая задача повторяется spark.task.maxFailures раз, по умолчанию четыре, после чего запрос Structured Streaming останавливается с исключением, и перезапускать его должно приложение или планировщик. Во Flink с exponential-delay перезапусками занимается сам JobManager, и по умолчанию число попыток не ограничено.
Как вышли из цикла и чем заплатили
Лечение шло в два этапа. Первый убирает причину. Брокер Kafka в k8s/infra.yaml получил постоянный том (PersistentVolumeClaim, PVC) kafka-data, и каталог журналов теперь лежит на нём.
# Без тома брокер после рестарта забывает топики и транзакции, и 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 своим частям.
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 для этого есть два поля спецификации задания.
/**
* 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;
Документация: Savepoints, Allowing Non-Restored State, Operator, Job Management
План получается такой. Сменить uid приёмника алертов в коде, выпустить образ, задать в FlinkDeployment путь к последнему savepoint или сохранённому чекпоинту и allowNonRestoredState: true. Профили карт и окна восстановятся, приёмник начнёт с чистого листа. На стенде мы этот путь не проходили. В runbook он записан как вариант, который сначала прогоняют на копии. Отдельно в нём придётся проверить позиции источника, если топики пересоздавались.
В итоговом хаос-тесте мы перезапускаем 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.
@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.
/**
* 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 мы такую схему не проверяли.
Документация: Kafka connector, Security, SSL Setup
Защищать стоит и сам Flink. В чек-листе готовности к проду документация Flink прямо пишет, что кластер спроектирован для исполнения присланного кода, поэтому REST и Web UI нельзя выставлять в открытую сеть. TLS для внутренних соединений и REST настраивается отдельно, это раздел SSL Setup.
Документация: Production Readiness, Secure Flink Cluster Access
Выпуск образа из 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 его тестируют.
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.
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.
@PublicEvolving
public class KafkaSink<IN>
implements LineageVertexProvider,
TwoPhaseCommittingStatefulSink<IN, KafkaWriterState, KafkaCommittable>,
SupportsPostCommitTopology<KafkaCommittable> {
/**
* 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 никто не обещает, и коннектор пересобирают под каждую минорную версию.
Документация: API compatibility guarantees
В исходниках
Похожая хрупкость есть и со стороны клиента Kafka. Тот же FlinkKafkaInternalProducer достаёт внутренние поля продюсера рефлексией. Смотри, что происходит, если поля нет.
/**
* 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 |
Документация: API compatibility guarantees, Upgrading the Flink Framework Version, Compatibility Table, Release Notes Flink 2.3
Свежая версия движка ничего не даёт, пока под неё не вышли коннекторы, которыми пользуется задание. Поэтому в проде версию Flink выбирают по самому отстающему коннектору и закрепляют её везде, от образа до Gradle. Когда выйдет flink-connector-kafka под 2.3, PayGuard надо будет провести через все пять проверок из таблицы, и только потом менять образ.
Что ты построил за курс
PayGuard прошёл путь от задания, которое печатало платежи в лог, до системы, которую можно передать дежурной смене.
| Часть системы | Уроки |
|---|---|
| Чтение из Kafka, разбор, DLQ для битых записей, свои типы без Kryo | 1-4 |
| Время события, окна, состояние карт с TTL, правила без перезапуска, обогащение из PostgreSQL, CEP | 5-10 |
| Чекпоинты на RocksDB, exactly-once в Kafka, идемпотентная запись в PostgreSQL, savepoint и rescale | 11-13 |
| Отчёты на Flink SQL, CDC и Paimon | 14-15 |
| Пять уровней тестов, метрики и алерты, поиск узких мест, расчёт памяти, Kubernetes с HA | 16-20 |
| Разбор аварии, хаос-тест, SASL, выпуск образа, проверка обновления | 21 |
Что делать дальше
- Довести безопасность до прода. SASL_SSL с настоящими сертификатами для Kafka, TLS для REST и внутренних соединений Flink, пароли из секретов на стороне TaskManager.
- Попробовать автоскейлер оператора. Он меняет параллелизм по нагрузке, и в уроке 20 мы его не трогали.
- Следить за выходом
flink-connector-kafkaпод 2.3 и пройти на PayGuard всю проверку совместимости из этого урока, с теневой копией и restore-тестом. - Перенести Kafka и PostgreSQL в управляемые сервисы или кластеры с репликацией и повторить хаос-тест уже против них.
- Читать исходники дальше. Когда в логах появится незнакомое исключение, ищи его в коде коннектора и рантайма, как в этом уроке. Обычно ветка, куда оно попадает, объясняет поведение задания лучше документации.
Документация: Production Readiness Checklist, Operator Autoscaler
Проверь себя
После сбоя брокера задание крутится в 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 для всех клиентов проекта, источников, приёмников и генератора.
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.
@@ -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. Метод копирует ключи в общий набор свойств источника, поэтому порядок вызовов в строителе не важен.
@@ -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 и берёт класс из пакета задания.
@@ -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.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 не попадает.
KAFKA_ADMIN_PASSWORD=admin-secret
KAFKA_PAYGUARD_PASSWORD=payguard-secret
@@ -1,3 +1,4 @@ build/
.gradle/
data/
+.envШаг 4. Итоговый хаос-тест
Создай 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 секунд. Итог нашего прогона сведён в таблицу, это первая строка отчёта после каждого шага.
| Момент | Завершено | Неудачных | Восстановлений | Что произошло |
|---|---|---|---|---|
| До хаоса | 5 | 0 | 0 | Чекпоинт 5 занял 258 мс, записано 5081 КиБ при состоянии 6646 КиБ |
| Убит TaskManager | 8 | 1 | 1 | Скрипт напечатал «Задание восстановлено из чекпоинта за 13 с». Чекпоинт в момент убийства упал |
| PostgreSQL выключен на 30 с | 10 | 2 | 5 | Задание перезапускалось четыре раза, пока базы не было |
| Kafka перезапущена | 14 | 2 | 5 | Счётчик восстановлений не вырос, задание пережило рестарт брокера без перезапуска |
Последняя строка вывода скрипта.
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.
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.
.sinkTo(KafkaSinks.<FraudAlert>exactlyOnceJson(
config.bootstrapServers(), config.alertsTopic(), config.alertsTransactionalIdPrefix()))
.name("alerts-sink")
.uid("alerts-sink");
После выпуска образа с новым uid в спецификации задания задаются initialSavepointPath с путём к последнему сохранённому снимку и allowNonRestoredState: true. Прогони это сначала на копии стенда.
Задание. Runbook и чек-лист готовности
PayGuard готов к передаче дежурной смене. Напиши два документа в папке ops/.
ops/runbook.md. На каждый алерт изmonitoring/alerts.ymlурока 17 раздел с заголовком по имени алерта. В разделе первая команда, которую выполняет дежурный, как по её выводу отличить причины и что делать в каждом случае. Алерты, которые обычно идут вместе, можно описать в одном разделе. Отдельно опиши плановые операции, выкатку новой версии, откат и повторную обработку данных.ops/production-readiness.md. Чек-лист, который команда проходит перед первым запуском задания в проде. Пункты сгруппированы по темам, код, устойчивость, масштаб, наблюдаемость и безопасность. Каждый пункт можно проверить фактом, тестом, конфигом или прогоном.
Готово, если у каждого из шести алертов есть раздел, в каждом разделе есть конкретная команда из скриптов проекта или REST, и каждый пункт чек-листа ты можешь связать с уроком, где он проверялся.
Подсказки. Возьми за основу историю исключений из шага 4 практики и аварию из теории, хороший runbook отличает ожидаемые исключения от опасных. Вспомни, какой алерт молчит, когда пропал TaskManager, и чем это закрыто в уроке 17. Для чек-листа пролистай задания прошлых уроков, почти каждое закрывает один пункт. В документации Flink есть свой Production Readiness Checklist, сравни с ним.
Ответ
Runbook
# Runbook PayGuard
Каждый раздел начинается с алерта, который привёл сюда. Первым делом проверь Web UI задания и дашборд PayGuard в Grafana.
## PayGuardJobRestarted
1. `curl -s $REST/jobs/$JOB/exceptions | jq -r '.exceptionHistory.entries[0].stacktrace' | head -40`
2. Ошибка в коде или данных (`NullPointerException`, ошибка разбора) повторится после рестарта. Задание уйдёт в цикл рестартов, поэтому такую запись надо отправлять в DLQ, а код чинить релизом.
3. Ошибка окружения (потеря TaskManager, недоступен брокер) обычно проходит сама. Проверь, что перезапуски прекратились и лаг падает.
## PayGuardCheckpointStale и PayGuardCheckpointsFailing
1. `./scripts/checkpoint-report.sh`, смотри на долю подзадач, которые не успели.
2. Есть backpressure, чекпоинты выросли с секунд до минут: идём в раздел PayGuardBackpressure. На время разбора можно включить `execution.checkpointing.unaligned.enabled`.
3. Нет backpressure: проверь хранилище чекпоинтов и его ошибки в логах TaskManager.
4. Если нет успешного чекпоинта дольше `transaction.timeout.ms`, брокер отменит транзакции и после восстановления алерты этого интервала будут потеряны. Лимит 15 минут задан в `KafkaSinks`.
## PayGuardBackpressure и PayGuardKafkaLagHigh
1. `./scripts/vertex-load.sh`. Источник проблемы это самая верхняя по потоку вершина с `busy` около 1000 ниже вершин с высоким `back`.
2. Перекос: сравни минимум и максимум входа по подзадачам.
3. Внешняя система: сравни метрики приёмника и внешнего сервиса.
4. Настоящий рост нагрузки: увеличь параллелизм через savepoint.
## PayGuardSourceSilent
TaskManager или задание не работают. Метрик лага в этот момент нет. Посмотри `kubectl get pods`, `kubectl get flinkdep`, и лаг снаружи:
`kafka-consumer-groups.sh --bootstrap-server $BOOTSTRAP --describe --group payguard`.
## Плановые операции
- Новая версия кода: обнови образ в `FlinkDeployment`, оператор сам снимет savepoint.
- Откат: верни прошлый образ. Если новая версия успела изменить схему состояния, запускай из savepoint, снятого до выката.
- Обработка заново: новый `--group-id`, старт из нужной метки времени, новый `transactional-id-prefix`, отдельный топик результата.
Почему разделы устроены именно так.
PayGuardJobRestartedначинается с истории исключений, потому что по ней видно, повторяется ли причина. Ошибка в коде или данных возвращается после каждого рестарта, так было в аварии из теории и с битыми записями в уроке 3. Ошибки окружения, как в хаос-тесте этого урока, проходят сами.PayGuardCheckpointStaleиPayGuardCheckpointsFailingобъединены, у них общий разбор черезcheckpoint-report.shиз урока 11. Пункт про unaligned-чекпоинты опирается на замеры урока 18, где они вернули длительность к сотням миллисекунд под backpressure. Пункт проtransaction.timeout.msвзят из урока 12. Без успешного чекпоинта дольше 15 минут брокер отменит транзакции.PayGuardBackpressureиPayGuardKafkaLagHighразбираются черезvertex-load.shиз урока 18. Узкое место это самая верхняя по потоку вершина с busy около 1000. Пункт про перекос повторяет опыт с горячим продавцомm-0000из того же урока.PayGuardSourceSilentвынесен отдельно, потому что метрик лага в этот момент нет, это вывод урока 17. Лаг смотрят снаружи черезkafka-consumer-groups.sh.- Плановые операции опираются на урок 13 про savepoint и эволюцию схемы и на урок 20 про режим апгрейда
savepoint. Повторная обработка требует новый префикс транзакций, иначе новый приёмник столкнётся с транзакциями старого, это урок 12.
Чек-лист готовности
# Production readiness review
## Код
- [ ] У всех операторов есть `uid` (проверяет `PipelineTopologyTest`)
- [ ] `GENERIC_TYPES=false`, в состоянии нет Kryo
- [ ] Битая запись уходит в DLQ и не валит задание
- [ ] У каждого состояния есть TTL или обоснование его отсутствия
- [ ] Идентификаторы алертов детерминированы
## Устойчивость
- [ ] Интервал чекпоинта выбран осознанно и согласован с задержкой exactly-once
- [ ] `transaction.timeout.ms` больше интервала чекпоинта плюс простоя
- [ ] Чекпоинты лежат во внешнем хранилище, `RETAIN_ON_CANCELLATION`
- [ ] Включена HA, проверено падение JobManager
- [ ] Проверено восстановление из savepoint прошлой версии
- [ ] Проверено отключение каждой внешней системы
## Масштаб
- [ ] `pipeline.max-parallelism` задан заранее и не меняется
- [ ] Посчитан объём состояния и запас памяти
- [ ] Прогнан нагрузочный тест с запасом в два раза
## Наблюдаемость
- [ ] Дашборд показывает лаг, отставание, busy, backpressure, чекпоинты, рестарты
- [ ] Настроены алерты и у каждого есть раздел runbook
- [ ] Лаг измеряется и снаружи задания
## Безопасность
- [ ] Секреты передаются через менеджер секретов, их нет в коде и образе
- [ ] Включены TLS и аутентификация для Kafka и PostgreSQL
- [ ] Web UI и REST закрыты от внешней сети
| Пункт | Где проверялся |
|---|---|
| uid у всех операторов | Урок 13, сопоставление состояния. Урок 16, PipelineTopologyTest |
| Нет Kryo в состоянии, DLQ для битых записей | Урок 3 |
| TTL у состояния | Урок 7 |
| Детерминированные идентификаторы алертов | Урок 12, проверка дублей по alertId |
Интервал чекпоинта и transaction.timeout.ms | Урок 12, цена exactly-once в задержке |
Внешнее хранилище чекпоинтов, RETAIN_ON_CANCELLATION | Урок 11 |
| HA и падение JobManager | Урок 20, тот же jobId через 34 с |
| Восстановление из savepoint прошлой версии | Урок 13, запуск v2 из savepoint v1. Урок 16, restore-тест |
| Отключение внешних систем | Урок 21, итоговый хаос-тест |
pipeline.max-parallelism | Уроки 4 и 13 |
| Объём состояния и память | Урок 19 |
| Нагрузочный тест | Урок 18 |
| Дашборд, алерты, лаг снаружи | Урок 17 |
| Секреты, TLS, закрытый REST | Урок 20, секрет для PostgreSQL. Урок 21, SASL и раздел про безопасность |
Последний пункт чек-листа PayGuard пока не проходит. На стенде нет TLS ни у Kafka, ни у PostgreSQL. Чек-лист для того и нужен, чтобы такие пробелы были записаны до запуска.
Как проверить
Пройди runbook на стенде. Убей TaskManager через chaos-kill-tm.sh и выполни команды раздела PayGuardJobRestarted. Останови TaskManager через docker compose stop taskmanager и пройди раздел PayGuardSourceSilent. Если какая-то команда не работает или её вывод не помогает принять решение, раздел надо переписать. Чек-лист сравни с разделом Production Readiness Checklist документации Flink. Его пункты про max parallelism, uid, интервал чекпоинта, HA и закрытый доступ к кластеру в нашем списке есть.
Глоссарий урока
- Постмортем (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)
- Запись, на которой обработка падает при каждой попытке и из-за которой задание после перезапуска снова возвращается к той же ошибке.