Технологии

Flink

Как использовать Flink для решения широкого круга задач по System Design

Многие задачи по System Design требуют потоковой обработки. У нас есть непрерывный поток данных, который нужно обрабатывать, преобразовывать или анализировать в реальном времени.

Потоковая обработка на практике сложна и дорого обходится. Многие проблемы, которые кажутся задачами потоковой обработки (stream processing), на самом деле можно свести к пакетной обработке (batch processing), где мы бы использовали такие технологии, как Spark или Hadoop.

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

Самый простой пример - сервис, который читает клики из темы Kafka, выполняет тривиальное преобразование, например переформатирует данные для дальнейшей загрузки, и записывает результат в базу данных. Все просто.

Простая потоковая обработка с Kafka

Но дальше все быстро усложняется. Представим, что мы хотим отслеживать количество кликов пользователя за последние 5 минут. Из-за этого 5-минутного окна в нашей задаче появляется состояние. Каждое сообщение уже нельзя обрабатывать независимо, потому что нам нужно помнить количество из предыдущих сообщений. Хотя мы можем сделать это в нашем сервисе, просто сохраняя счетчики в памяти, это порождает множество новых проблем.

  • Если сервис упадет, он потеряет все состояние. По сути, счетчик за предыдущие 5 минут исчезнет. Теоретически сервис мог бы восстановиться, перечитав все сообщения из темы Kafka, но это медленно и дорого.
  • Другая проблема - масштабирование. Если мы хотим добавить новый экземпляр сервиса из-за роста нагрузки, нам нужно как-то перераспределить состояние между существующими и новыми экземплярами. Это уже довольно сложный процесс с большим количеством сценариев отказа.
  • А что, если события приходят с нарушением порядка или с задержкой? Такое вероятно, и это повлияет на точность наших подсчетов.

И дальше все становится только сложнее по мере добавления новой логики и состояния. К счастью, инженеры создают подобные системы уже не одно десятилетие и придумали полезные абстракции. Встречайте один из самых мощных движков потоковой обработки: Apache Flink.

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

  1. Сначала разберем, как Flink используют на практике. Велика вероятность, что на собеседовании вам попадется задача с потоковой обработкой, и Flink будет для нее мощным и гибким инструментом, если он действительно подходит.
  2. Затем посмотрим, как Flink работает под капотом на высоком уровне. Flink снимает с вас множество сложностей, но на интервью важно понимать, как именно он это делает, чтобы отвечать на уточняющие вопросы и обосновывать свой дизайн.

Базовые понятия

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

Flink - это движок потока данных. Это означает, что он построен вокруг идеи графа потока данных (dataflow graph). Граф потока данных - это направленный граф из узлов и ребер, который описывает вычисление. Узлы - это операции над данными, а ребра - потоки данных, которые передаются между операциями.

Базовый граф потока данных может выглядеть так:

Базовый граф потока данных

Во Flink узлы называются operators, а ребра - streams. У узлов в начале и в конце графа есть отдельные названия: sources и sinks. Наша задача как разработчиков - описать этот граф, а Flink берет на себя распределение ресурсов и выполнение вычислений.

Источники и приемники

Sources и sinks - это точки входа и выхода данных в приложении Flink.

  • Sources читают данные из внешних систем и превращают их в потоки Flink. Частые примеры: Kafka для очередей сообщений, Kinesis для потоковых данных AWS, файловые системы для пакетной обработки и кастомные источники для специализированных интеграций.
  • Sinks записывают данные из потоков Flink во внешние системы. Частые примеры: базы данных, такие как MySQL, PostgreSQL и MongoDB, хранилища аналитики, такие как Snowflake, BigQuery и Redshift, очереди сообщений, такие как Kafka и RabbitMQ, а также файловые системы, такие как HDFS и S3.

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

Хотя с помощью Flink можно создавать приложения для пакетной обработки (batch processing), мы бы не рекомендовали делать это в рамках собеседования. Технически это возможно, но интервьюеры хуже понимают этот подход, и поддерживать оптимальность решения гораздо сложнее.

Потоки

Если sources, sinks и operators - это узлы, то streams - это ребра нашего графа потока данных. Поток - это неограниченная последовательность элементов данных, которая проходит через систему. Можно представить его как бесконечный массив событий:

// Пример события в потоке
{
  "user_id": "67890",
  "action": "click",
  "timestamp": "2026-06-01T00:00:00.000Z",
  "page": "/products/12345"
}

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

Потоки во Flink не обязательно являются append-only журналами, как в Kafka. В абстракции потока нет никаких смещений (offsets) или ожиданий постоянного хранения. За надежность во Flink отвечают checkpoints, которые система периодически создает. Ниже мы еще подробно к ним вернемся.

Операторы

Operator - это (потенциально) stateful преобразование, которое обрабатывает один или несколько входных потоков и производит один или несколько выходных потоков. Операторы - это базовые строительные блоки нашего приложения для потоковой обработки. Чаще всего мы используем следующие операторы:

  • Map: преобразует каждый элемент по отдельности.
  • Filter: удаляет элементы, которые не соответствуют условию.
  • Reduce: объединяет элементы в рамках одного ключа.
  • Window: группирует элементы по времени или количеству.
  • Join: объединяет элементы из двух потоков.
  • FlatMap: преобразует каждый элемент в ноль или более элементов.
  • Aggregate: вычисляет агрегаты по окнам или ключам.

Если вы знакомы с map/reduce, полезно помнить, что операторы Flink могут играть роль и mapper'ов, и reducer'ов из MapReduce, но модель исполнения здесь совсем другая. Flink обрабатывает записи по одной в потоковом режиме, а не пакетами, как в MapReduce.

Вот простой пример операторов в действии:

DataStream<ClickEvent> clicks = // входной поток
clicks
  .keyBy(event -> event.getAdId())
  .window(TumblingEventTimeWindows.of(Time.minutes(5)))
  .reduce((a, b) -> new ClickEvent(a.getAdId(), a.getCount() + b.getCount()))

Вот что делает этот код:

  1. Берет входной поток кликов и партиционирует его по adId через оператор keyBy, создавая KeyedStream.
  2. Применяет к KeyedStream tumbling window длительностью 5 минут, которое группирует элементы с одинаковым ключом, попадающие в один и тот же 5-минутный период времени.
  3. Применяет функцию reduce к каждому окну. Она объединяет пары ClickEvent, создавая новый ClickEvent с тем же adId и суммой счетчиков.

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

Состояние

Операторы Flink являются stateful, то есть могут хранить внутреннее состояние между несколькими событиями. Это критически важно почти для любой нетривиальной потоковой обработки. Например, если мы хотим считать, сколько раз пользователь кликнул за последние 5 минут, нам нужно хранить информацию о предыдущих кликах: сколько их было и когда они произошли.

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

Flink поддерживает несколько типов состояния:

  • Value State: одно значение на ключ.
  • List State: список значений на ключ.
  • Map State: отображение значений на ключ.
  • Aggregating State: состояние для инкрементальных агрегаций.
  • Reducing State: состояние для инкрементальных сверток.

Вот простой пример использования состояния для подсчета кликов:

public class ClickCounter extends KeyedProcessFunction<String, ClickEvent, ClickCount> {
    private ValueState<Long> countState;
 
    @Override
    public void open(Configuration config) {
        ValueStateDescriptor<Long> descriptor =
            new ValueStateDescriptor<>("count", Long.class);
        countState = getRuntimeContext().getState(descriptor);
    }
 
    @Override
    public void processElement(ClickEvent event, Context ctx, Collector<ClickCount> out)
        throws Exception {
        Long count = countState.value();
        if (count == null) {
            count = 0L;
        }
        count++;
        countState.update(count);
        out.collect(new ClickCount(event.getUserId(), count));
    }
}

Вот что делает этот код:

  1. Создает класс ClickCounter, который наследуется от KeyedProcessFunction, обрабатывает клики, сгруппированные по строковому ключу userId, принимает ClickEvent и отдает ClickCount.
  2. Объявляет поле ValueState<Long>, в котором будет храниться число кликов для каждого пользователя.
  3. В методе open инициализирует это состояние с помощью дескриптора, который называет состояние "count" и указывает его тип как Long.
  4. В методе processElement, который вызывается для каждого входного события, читает текущее значение из состояния, при необходимости инициализирует его нулем, увеличивает, обновляет состояние и отправляет новый ClickCount с идентификатором пользователя и новым значением.

В итоге этот stateful-оператор поддерживает актуальный счетчик кликов для каждого пользователя и публикует обновленное значение при каждом новом клике. Важная концепция заключается в том, что Flink должен знать о нашем состоянии, чтобы он мог создавать контрольные точки и восстанавливать состояние в случае сбоя.

Watermarks

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

  • задержки в сети между источниками событий;
  • разная скорость обработки в разных разделах;
  • задержки или сбои в системе-источнике.

Watermarks - это решение Flink для данной проблемы. Watermark - это по сути временная метка, которая проходит через систему вместе с потоковыми данными и заявляет: "все события со временем раньше этой watermark уже пришли". Например, мы можем получить watermark, сообщающий о том, что 5:00 PM уже прошло, в 5:01:15 PM. Это гарантирует, что у нас будет достаточно времени для обработки всех данных, которые могли быть созданы в 4:59 PM, но поступили с задержкой. И обрабатывая watermarks вместе с остальными потоковыми данными, мы можем:

  1. решать, когда запускать вычисления по окнам;
  2. корректно обрабатывать поздно пришедшие события;
  3. поддерживать согласованную обработку времени события (event time) во всей распределенной системе.

Watermarks настраиваются на источнике потока. Стратегия watermark указывает Flink, сколько времени нужно ждать запоздавших событий. Flink поддерживает несколько стратегий watermark, но на практике чаще всего встречаются две:

  • Bounded Out-Of-Orderness: говорит Flink дожидаться событий, которые приходят в течение определенного времени после их временной метки (event timestamp).
  • No Watermarks: говорит Flink не ждать никаких запоздавших событий и обрабатывать события по мере их поступления.

Интервьюерам нравится видеть, что мы тщательно обдумываем последствия запоздавших и неупорядоченных событий. Bounded Out-Of-Orderness встречается очень часто, но в большинстве критичных систем его дополняют offline-процессом для доподсчета, чтобы даже очень поздние данные в итоге были учтены. Пример такого подхода есть в разборе Ad Click Aggregator.

Окна

Последняя базовая концепция, которую мы разберем, - это windows. Окно - это способ сгруппировать элементы потока по времени или по количеству. Это критически важно для агрегирования данных в потоковом контексте. Flink поддерживает несколько типов окон:

  • Tumbling Windows: окна фиксированного размера без пересечений.
  • Sliding Windows: окна фиксированного размера с пересечениями.
  • Session Windows: окна динамического размера, зависящие от активности.
  • Global Windows: кастомная логика работы с окнами.
Типы окон во Flink

В зависимости от типа окна Flink публикует новое значение, когда окно закрывается. Если мы создали tumbling window продолжительностью 5 минут и на вход подаем клики, то каждые 5 минут Flink будет отдавать новое значение, содержащее все клики за последние 5 минут.

Окна могут применяться как к потокам с ключами (keyed), так и к потокам без ключей (non-keyed), хотя чаще всего они используются с потоками, имеющими ключи. При применении к keyed потоку окна поддерживаются независимо для каждого ключа. Это позволяет рассматривать окно данных для конкретного пользователя, аккаунта или другого ключа.

Выбор окна может сильно влиять и на точность, и на производительность потокового приложения. Tumbling window продолжительностью 5 минут будет выдавать результат один раз в 5 минут. Sliding window продолжительностью 5 минут с интервалом в 1 минуту будет выдавать результат каждую минуту. Стоит отталкиваться от требований задачи, чтобы определить наименее ресурсоемкий тип окна, который обеспечит необходимую точность.

Окна тесно взаимодействуют с watermarks, чтобы определять, когда запускать вычисления и как обрабатывать запоздавшие события. Мы также можем настроить окна с допустимой задержкой (allowed lateness), чтобы обрабатывать события, которые поступают после закрытия окна, но до окончания заданного допустимого (grace) периода.

Базовое использование

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

Описание задачи

Задача (job) во Flink начинается со StreamExecutionEnvironment и обычно включает определение нашего источника (source), преобразований (transformations) и приемника (sink):

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
 
// Определяем источник, например Kafka
DataStream<ClickEvent> clicks = env
    .addSource(new FlinkKafkaConsumer<>("clicks", new ClickEventSchema(), properties));
 
// Определяем преобразования
DataStream<WindowedClicks> windowedClicks = clicks
    .keyBy(event -> event.getUserId())
    .window(TumblingEventTimeWindows.of(Time.minutes(5)))
    .aggregate(new ClickAggregator());
 
// Определяем приемник, например Elasticsearch
windowedClicks
    .addSink(new ElasticsearchSink.Builder<>(elasticsearchConfig).build());
 
// Запускаем задачу
env.execute("Задача обработки кликов");

Этот код должен легко читаться, если помнить о вышеупомянутых концепциях. Мы определяем источник, серию преобразований (операторов) и приемник. Источником выступает тема Kafka, из которой мы читаем. Преобразования - это серия операторов, которые мы применяем к данным. Приемник - это кластер Elasticsearch, в который мы записываем результаты.

Отправка задачи

Наш следующий шаг - отправить эту задачу в кластер Flink для выполнения. Это делается путем вызова метода execute у StreamExecutionEnvironment (который может быть как локальным, так и удаленным кластером). Когда мы это делаем, Flink выполняет следующее:

  1. Создает JobGraph: компилятор Flink преобразует наш логический поток данных (операции DataStream) в оптимизированный план выполнения.
  2. Отправляет в JobManager: JobGraph отправляется в JobManager, который служит координатором для нашего кластера Flink.
  3. Распределяет задачи: JobManager разбивает JobGraph на задачи и распределяет их между TaskManagers.
  4. Выполняет: TaskManagers выполняют задачи, причем каждая задача обрабатывает свою часть данных.

Примеры задач

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

Простой дашборд на Redis

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

DataStream<ClickEvent> clickstream = env
    .addSource(new FlinkKafkaConsumer<>("clicks", new JSONDeserializationSchema<>(ClickEvent.class), kafkaProps));
 
// Считаем метрики в 1-минутных окнах
DataStream<PageViewCount> pageViews = clickstream
    .keyBy(click -> click.getPageId())
    .window(TumblingProcessingTimeWindows.of(Time.minutes(1)))
    .aggregate(new CountAggregator());
 
// Пишем в Redis, откуда дашборд будет читать результат
pageViews.addSink(new RedisSink<>(redisConfig, new PageViewCountMapper()));

Здесь мы используем те же примитивы, которые обсуждали ранее, но сохраняем результаты в Redis для использования дашбордом. Это демонстрирует одну из сильных сторон Flink: гибкие источники и приемники позволяют нам встраивать Flink как часть более крупной системы.

Система обнаружения мошенничества

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

DataStream<Transaction> transactions = env
    .addSource(new FlinkKafkaConsumer<>("transactions",
                new KafkaAvroDeserializationSchema<>(Transaction.class), kafkaProps))
    .assignTimestampsAndWatermarks(
        WatermarkStrategy.<Transaction>forBoundedOutOfOrderness(Duration.ofSeconds(10))
            .withTimestampAssigner((event, timestamp) -> event.getTimestamp())
    );
 
// Расширяем транзакции информацией об аккаунте
DataStream<EnrichedTransaction> enrichedTransactions =
    transactions.keyBy(t -> t.getAccountId())
                .connect(accountInfoStream.keyBy(a -> a.getAccountId()))
                .process(new AccountEnrichmentFunction());
 
// Считаем velocity-метрики: несколько транзакций за короткое время
DataStream<VelocityAlert> velocityAlerts = enrichedTransactions
    .keyBy(t -> t.getAccountId())
    .window(SlidingEventTimeWindows.of(Time.minutes(30), Time.minutes(5)))
    .process(new VelocityDetector(3, 1000.0)); // Оповещение о 3+ транзакциях свыше $1000 за 30 минут
 
// Обнаружение шаблонов с помощью CEP для подозрительных последовательностей
Pattern<EnrichedTransaction, ?> fraudPattern = Pattern.<EnrichedTransaction>begin("small-tx")
    .where(tx -> tx.getAmount() < 10.0)
    .next("large-tx")
    .where(tx -> tx.getAmount() > 1000.0)
    .within(Time.minutes(5));
 
DataStream<PatternAlert> patternAlerts = CEP.pattern(
    enrichedTransactions.keyBy(t -> t.getCardId()), fraudPattern)
    .select(new PatternAlertSelector());
 
// Объединяем все оповещения и убираем дубликаты
DataStream<Alert> allAlerts = velocityAlerts.union(patternAlerts)
    .keyBy(Alert::getAlertId)
    .window(TumblingEventTimeWindows.of(Time.minutes(5)))
    .aggregate(new AlertDeduplicator());
 
// Пишем в Kafka и Elasticsearch
allAlerts.addSink(new FlinkKafkaProducer<>("alerts", new AlertSerializer(), kafkaProps));
allAlerts.addSink(ElasticsearchSink.builder(elasticsearchConfig).build());

Здесь мы ищем конкретные паттерны, связанные с мошенничеством: высокую скорость транзакций и специальные последовательности событий, которые на него указывают. Мы создаем поток оповещений и отправляем его в два приемника: один в Kafka для потребления другими системами (возможно, автоматизированной системой для деактивации аккаунта) и второй в Elasticsearch для выполнения запросов. В конечном итоге мы получаем архитектуру всей системы в одной задаче Flink.

Теперь, когда понятно, как использовать Flink, давайте посмотрим, как он работает под капотом. Архитектура Flink спроектирована так, чтобы давать гарантии exactly-once даже при сбоях, сохраняя при этом высокую пропускную способность и низкую задержку.

Архитектура кластера

Job Manager и Task Managers

Flink работает как распределенная система с двумя основными типами процессов:

  • Job Manager - координатор кластера Flink. Он отвечает за планирование задач, координацию контрольных точек и обработку сбоев. Можно воспринимать его как "супервайзера" всей системы.
  • Task Managers - воркеры, которые выполняют фактическую обработку данных. Каждый Task Manager предоставляет кластеру определенное количество слотов для обработки (processing slots).
Архитектура кластера Flink

Job Manager работают на основе лидера. Это означает, что существует один Job Manager , который отвечает за координацию работы в кластере. Высокая доступность достигается за счет развертывания нескольких Job Manager вместе и использования механизма на основе кворума (обычно ZooKeeper) для выбора лидера.

Когда мы отправляем задачу во Flink:

  1. Job Manager получает приложение и строит execution graph.
  2. Он распределяет задачи по доступным слотам в Task Manager.
  3. Task Manager запускают назначенные им задачи.
  4. Job Manager следит за выполнением и обрабатывает сбои.

Если вы не собеседуетесь на роль с сильным уклоном в data engineering, интервьюеры скорее всего не будут спрашивать нас об администрировании кластера Flink. Для большинства ролей достаточно понимать, что существуют Job Managers, которые принимают нашу задачу и координируют работу в кластере, и Task Managers, которые выполняют фактическую обработку данных.

Task slots и параллелизм

У каждого Task Manager есть один или несколько task slots. Это базовая единица планирования ресурсов во Flink. Task slot - это единица параллелизма, и по умолчанию количество слотов равно числу ядер на машине (но это можно переопределить, например, использовать слоты для представления фрагментов памяти или GPU).

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

Task slots во Flink

Слоты решают сразу несколько задач:

  1. Они изолируют память между задачами.
  2. Они контролируют количество параллельных экземпляров задач.
  3. Они позволяют разделять ресурсы между различными задачами в рамках одной job.

В результате каждый Task Manager предоставляет нам набор небольших атомарных ресурсов, которые можно распределять между задачами и операторами.

Управление состоянием

Наличие Job Manager и Task Manager позволяет распределять работу по кластеру, но само по себе не дает гарантий надежности. Одна из главных проблем потоковой обработки данных (особенно stateful систем), - как восстанавливаться после сбоев без потери данных. Именно этим и занимается механизм управления состоянием (state management) во Flink.

State Backends

Ранее мы уже говорили об абстракции состояния, которую Flink дает разработчикам. Этот API позволяет каждой задаче хранить состояние рядом с каждым оператором либо на уровне всей задачи, либо отдельно по каждому ключу. Само состояние хранится в бэкенде состояния (state backend), который является компонентом, управляющим хранением и извлечением состояния.

Flink предлагает разные state backends под разные сценарии:

  1. Memory State Backend: хранит состояние в куче (heap) JVM.
  2. FS State Backend: хранит состояние в файловой системе.
  3. RocksDB State Backend: хранит состояние в RocksDB (поддерживает состояние, превышающее объем памяти).

В большинстве случаев мы будем предпочитать memory state backend из-за его производительности. Но если оператору нужно хранить существенно больше состояния, чем помещается в памяти, у нас есть варианты выносить его на диск. Кроме того, все эти бэкенды можно настроить на хранение состояния в удаленном хранилище (например, S3, GCS и т.д.), если мы запускаем Flink в облачной среде.

Выбор state backend критически важен для production-систем. Memory state backend очень быстрый, но ограничен объемом RAM, а RocksDB может хранить терабайты состояния, но делает это ценой большей задержки.

Контрольные точки и exactly-once обработка

Состояние - это отлично, пока нам не нужно восстанавливаться после сбоя. Вот тут-то и становится важным создание контрольных точек (checkpointing). Механизм контрольных точек Flink основан на алгоритме Чанди-Лампорта для распределенных снимков (snapshots), что звучит сложнее, чем есть на самом деле.

Помните, как с помощью watermarking мы пропускаем через систему событие, которое говорит: "все события с timestamp <= T уже пришли". С контрольными точками мы делаем снимок состояния системы в конкретный момент времени, фактически после того как дошли все события до этой контрольной точки. Управляет этим процессом Job Manager.

Сначала Job Manager инициирует контрольную точку и отправляет к источникам "барьеры" контрольной точки (checkpoint barrier). Этот барьер - специальное событие, которое проходит по топологии задачи вместе с данными. Когда оператор получает барьеры со всех входов, он делает снимок своего состояния (сериализует его и сохраняет в своем бэкенде). Когда все операторы завершают создание снимков, контрольная точка считается завершенной и регистрируется в Job Manager.

Благодаря таким периодическим контрольным точкам мы можем восстановить состояние системы и продолжить обработку с нужной точки. Когда происходит сбой, мы полностью останавливаем систему ("stop the world") и восстанавливаемся из контрольной точки:

  1. Failure Detection: Job Manager замечает, что Task Manager больше не отправляет сигналы активности (heartbeats). Он помечает этот Task Manager как неисправный.
  2. Job Pause: вся задача ставится на паузу. Останавливаются даже те задачи, которые работали на здоровых Task Manager. Это важно, потому что Flink рассматривает задачу как единое целое для обеспечения согласованности.
  3. State Recovery: Flink извлекает самую последнюю контрольную точку из бэкенда состояния (которая может находиться в памяти, файловой системе или RocksDB в зависимости от нашей конфигурации).
  4. Task Redistribution: Job Manager перераспределяет все задачи, которые выполнялись на вышедшем из строя Task Manager, по оставшимся здоровым Task Manager. При необходимости он может перераспределить и другие задачи для балансировки нагрузки.
  5. State Restoration: каждая задача восстанавливает свое состояние из контрольной точки. Это означает, что каждый оператор получает назад ровно те данные, которые он обработал до контрольной точки.
  6. Source Rewind: операторы источников откатываются к своим позициям на момент контрольной точки. Например, потребитель Kafka вернется к смещению, которое у него было во время контрольной точки.
  7. Resume Processing: задача возобновляет обработку с контрольной точки. Поскольку контрольная точка содержит информацию о том, какие именно записи были обработаны, Flink гарантирует обработку exactly-once даже после сбоя.

Откат источника (source rewind) зависит от типа источника и доступности данных в нем. Для источников Kafka нам нужно иметь достаточное время хранения (retention), чтобы мы могли откатиться назад к смещению контрольной точки в теме Kafka.

За счет всей этой оркестрации мы и получаем обработку exactly-once. С точки зрения хранимого состояния каждое сообщение обрабатывается ровно один раз.

Flink гарантирует семантику exactly-once для внутренних операций с состоянием, но это не распространяется автоматически на внешние системы. Например, при выполнении вызовов API или записи во внешние базы данных мы все еще можем обработать одну и ту же запись несколько раз в случае сбоя и восстановления. Нам нужно реализовать идемпотентные операции или транзакционное поведение при взаимодействии со внешними системами, чтобы достичь настоящей сквозной обработки exactly-once.

На собеседовании

Flink естественно вписывается во многие задачи на интервью по System Design. Практически любая задача, где нужна обработка непрерывного потока данных в реальном времени, может быть хорошим кандидатом. В большинстве случаев на собеседованиях Flink читает данные из Kafka и пишет их в какую-то комбинацию баз данных или хранилищ данных (data warehouses).

Некоторые вещи, которые следует иметь в виду при использовании Flink на собеседовании:

  1. Flink обычно избыточен для простой потоковой обработки. Если нам просто нужно преобразовывать сообщения по мере их прохождения через Kafka, вероятно, достаточно настроить сервис, который является потребителем из Kafka.
  2. Flink требует заметных операционных накладных расходов. Нужно думать о развертывании, мониторинге и масштабировании кластера Flink.
  3. Управление состоянием - одновременно главная сила Flink и его самая большая операционная проблема. Будьте готовы объяснить, как вы будете контролировать рост состояния и восстановление.
  4. Выбор окна сильно влияет и на точность, и на расход ресурсов. Будьте готовы обосновать свои решения по выбору окон.
  5. Подумайте, действительно ли нам нужна обработка exactly-once. Она влечет за собой снижение производительности и усложнение архитектуры.

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

Даже если мы не используем Flink напрямую, у него можно позаимствовать несколько важных идей:

  1. Разделение временных доменов: разделение времени обработки (processing time) и времени события (event time) во Flink - это мощный паттерн, который можно применять ко многим задачам распределенных систем.
  2. Watermarks для отслеживания прогресса: концепция watermark может быть полезна в любой системе, которой необходимо отслеживать прогресс по неупорядоченным событиям.
  3. Паттерны управления состоянием: подход Flink к управлению состоянием, включая локальное состояние и контрольные точки, может помочь при проектировании других stateful распределенных систем.
  4. Обработка Exactly-Once: методы, которые Flink использует для достижения обработки exactly-once, могут быть применены в других потоковых системах.
  5. Изоляция ресурсов: управление ресурсами на основе слотов во Flink предоставляет понятный способ изолировать и разделять ресурсы в распределенной системе.

Если вам приходится проектировать потоковую систему без Flink, на решения его создателей точно стоит смотреть как на хороший ориентир.

Заключение

Flink - это мощный инструмент для потоковой обработки с отслеживанием состояния, который должен быть всегда под рукой для любого собеседования по System Design, включающего потоковые данные. Хотя это не всегда лучший инструмент для решения задачи, это мощная опция в вашем наборе инструментов, и некоторые архитектурные решения, принятые создателями Flink, могут быть применены к другим системам.

Перейдите на Premium, чтобы продолжить

Разблокируйте доступ к этой статье и всем остальным материалам с NowInterview Premium

Перейти на Premium