Flink начинался с противоположной философии большинства big-data систем 2010-х. Hadoop, Spark, Hive — все были batch-engines. Streaming у них появился позже как дополнение к batch: Spark Streaming делал micro-batches, чтобы переиспользовать batch-runtime.
Spark Structured Streaming: micro-batch и его последствия Это позволяло быстро поддержать streaming use case, но накладывало ограничения: latency была привязана к size батча, exactly-once требовал отдельных усилий, event-time обрабатывался через workaround.
Flink выбрал обратный путь. Stream — это фундаментальная абстракция. Batch — это streaming-граф над ограниченным потоком данных. Эта философия определяет всё: runtime-архитектуру, API дизайн, как работают checkpoints, что значит “exactly-once”. Без понимания этой философии многие архитектурные решения Flink выглядят странно или избыточно.
В этом уроке разберём, что значит “stream first” на практике, и как Flink в 2026 году реализует unified DataStream API для streaming и batch режимов.
Историческое расхождение Hadoop vs streaming
Чтобы понять Flink, нужно вспомнить контекст. В 2003-2004 годах Google опубликовал две статьи: MapReduce (2004, Dean & Ghemawat) и GFS (2003). Идея MapReduce: дайте задачу, мы её разобьём на map и reduce фазы, raspараллелим по тысячам машин, обработаем огромный датасет, выдадим результат. Эта модель оптимизирована под batch: входной датасет известен, конечен, статичен. Время обработки — часы (или дни) — приемлемо.
Hadoop (2006+) реализовал эту модель в opensource, и она стала доминирующей парадигмой big-data 2007-2014. Spark (2010+) улучшил MapReduce — добавил in-memory DAG-планирование вместо disk-shuffles между MR jobs — но философски остался batch-engine. Spark Streaming (2013) был добавлен поверх как micro-batch wrapper: разрезали поток на маленькие batches (1 секунда, 100ms), исполняли каждый как обычный Spark job.
Параллельно в индустрии вырастал другой класс задач: реальные streaming-приложения. Twitter Storm (2011), LinkedIn Kafka (2011) + Samza (2013), Google MillWheel (2013) — всё это streaming-first системы, которые обрабатывают данные по мере поступления, не дожидаясь батча.
Flink (2014, форк Stratosphere) сделал ставку на streaming-first архитектуру. Стартовое предположение: поток данных бесконечен, и runtime должен быть оптимизирован под это. А батч — это просто частный случай: поток с известным концом.
Что значит “stream first” в runtime
На практике “stream first” означает несколько вещей.
Pipeline исполняется как continuous
В Flink job — это continuous pipeline, а не batch-jobs. Когда вы запускаете job, все операторы стартуют одновременно, и данные текут через них post-completion только для bounded sources (когда source закрылся). В streaming-режиме операторы не закрываются никогда.
Это даёт несколько преимуществ:
- Низкая latency. Элемент проходит через pipeline без блокировок: source -> map -> keyBy -> window -> sink. Каждый шаг — несколько микросекунд.
- Backpressure через flow control. Если downstream-оператор медленный, upstream блокируется через credit-based flow control (модуль 03). Нет очередей сообщений между операторами — данные просто медленнее текут.
- Native event-time. Watermarks (модуль 08) — это специальные сигналы в потоке, которые движутся вместе с данными. Это естественно для streaming, но в batch-runtime требует workaround.
Operator state живёт долго
Операторы в Flink stateful. State (модуль 05) хранится в state backend и обновляется в runtime. Это значит:
- Window-операторы накапливают элементы окна в state (например, в ListState).
- Aggregation-операторы хранят промежуточное состояние в ValueState.
- CEP-операторы (модуль 15) хранят NFA state на каждый pattern instance.
State переживает rescaling, restart, savepoint/restore. В batch-runtime state живёт только в течение одного job-а; в streaming — годами.
Checkpointing — не дополнительный механизм, а core
В Spark exactly-once делается через idempotent writes и atomic commits на уровне batch-job. В Flink — через continuous checkpointing (модуль 06): каждые N секунд (типично 30s-5min) делается snapshot всего state всех операторов, согласованный по алгоритму Чанди-Лэмпорта. Чекпоинт — это атомарный moment-in-time снимок всего pipeline.
Это сложнее, чем batch exactly-once, но даёт гарантии end-to-end exactly-once с continuous low latency. Без checkpointing streaming exactly-once невозможно.
Boundedness.BOUNDED: как batch выражается в streaming API
В Flink 2.x DataStream API — это единственный публичный API для job-ов (Table/SQL — отдельный поверхностный слой, который компилируется в DataStream). DataSet API удалён в 2.0. Это значит: и streaming, и batch пишутся одним API.
Различие между streaming и batch — в типе источника и execution mode.
Когда вы строите DataStream:
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
KafkaSource<String> source = KafkaSource.<String>builder()
.setBootstrapServers("localhost:9092")
.setTopics("events")
.setStartingOffsets(OffsetsInitializer.earliest())
.setBounded(OffsetsInitializer.latest())
.setValueOnlyDeserializer(new SimpleStringSchema())
.build();
DataStream<String> stream = env.fromSource(source,
WatermarkStrategy.noWatermarks(), "kafka-source");
.setBounded(OffsetsInitializer.latest()) — превращает Kafka source в bounded source: он читает от earliest до latest (на момент старта job-а) и завершается. Этот же job в streaming-режиме (без .setBounded) был бы unbounded и работал бы вечно.
Execution mode: STREAMING vs BATCH
Когда все sources в job-е bounded, Flink может выбрать один из двух execution modes:
env.setRuntimeMode(RuntimeExecutionMode.STREAMING); // default
env.setRuntimeMode(RuntimeExecutionMode.BATCH);
env.setRuntimeMode(RuntimeExecutionMode.AUTOMATIC); // выберет BATCH если все sources bounded
Различие в runtime — фундаментальное.
Когда BATCH mode полезен:
- Backfill of streaming pipeline. У вас есть streaming-job, который обрабатывает Kafka realtime. Историческая обработка — год архива данных. Запустить тот же job в BATCH mode на bounded Kafka source — оптимально по ресурсам.
- ETL-style jobs. Periodic batch jobs, которые читают из S3/HDFS и пишут результаты. BATCH-runtime эффективнее, чем гонять streaming-pipeline на bounded input.
- Reprocessing с новой логикой. Поменяли логику оператора — запустили в BATCH-mode на исторических данных, перезагрузили state, переключились на streaming.
Главный класс: ExecutionConfig + StreamGraph
В runtime эта философия выражается через org.apache.flink.streaming.api.environment.StreamExecutionEnvironment#getStreamGraph, который строит StreamGraph с учётом execution mode и boundedness sources.
Ключевые места кода:
// StreamExecutionEnvironment, упрощённо:
public JobClient executeAsync(StreamGraph streamGraph) throws Exception {
// ...
// RuntimeExecutionMode определяет StreamGraph properties:
// - в STREAMING: ScheduleMode.EAGER, ResultPartitionType.PIPELINED
// - в BATCH: ScheduleMode.LAZY, ResultPartitionType.BLOCKING
// ...
}
Для STREAMING режима ResultPartitionType.PIPELINED: оператор пишет байты в network buffer и сразу отдаёт consumer-у. Если consumer медленный — backpressure через credit.
Для BATCH режима ResultPartitionType.BLOCKING: оператор пишет байты в network buffer полностью, и только когда буфер закроется — consumer начинает читать. Это позволяет fault tolerance через re-execution: если consumer упал, мы можем перезапустить его и переиграть buffered результат, без перезапуска upstream stage.
Конкретный класс, отвечающий за выбор — org.apache.flink.streaming.api.graph.StreamGraphGenerator, и далее org.apache.flink.streaming.api.graph.StreamingJobGraphGenerator превращает StreamGraph в JobGraph.
Unified Table API: SQL поверх streaming
Table API и SQL — отдельный слой поверх DataStream. Но философия “stream first” сохраняется: каждая Table — это changelog stream под капотом.
CREATE TABLE Orders (
order_id BIGINT,
user_id BIGINT,
amount DECIMAL(10, 2),
ts TIMESTAMP(3),
WATERMARK FOR ts AS ts - INTERVAL '5' SECONDS
) WITH (
'connector' = 'kafka',
'topic' = 'orders',
...
);
SELECT user_id, SUM(amount) AS total
FROM Orders
GROUP BY user_id;
Этот SELECT в Flink SQL — streaming aggregation. Результат: changelog stream с операциями +I (insert), -U (update before), +U (update after). Каждый раз, когда приходит новый order для user_id=42, появляется -U (старый total) + +U (новый total).
Это не batch SELECT. Это continuous query над unbounded stream. Та же query на bounded источнике (с 'scan.bounded.mode' = 'latest-offset') даёт обычный batch SELECT — финальный snapshot после обработки всех данных.
Унифицированность: одна SELECT, два режима выполнения, в зависимости от boundedness источника.
В Apache Beam (Dataflow model) это же различие выражается через триггеры: WatermarkBasedTrigger vs RepeatedlyForever. Flink не использует Beam, но философию заимствует — стрим — фундамент, батч — частный случай с terminal watermark.
Почему “batch first” не работает для streaming
Альтернативная философия — batch first — была у Spark Streaming. Идея: возьмём batch-runtime, разрежем поток на маленькие batches (micro-batches), исполним каждый.
Проблемы такого подхода:
Latency привязана к size батча
Если micro-batch = 1 секунда, минимальная latency = 1 секунда (а в среднем — 0.5 с). Чтобы получить миллисекундную latency, нужно micro-batch = 10 мс, но overhead на запуск batch-job такой, что throughput катастрофически падает.
Flink, не имея micro-batch концепции, имеет per-element latency в диапазоне миллисекунд — потому что элемент идёт через operator chain без блокировок.
Spark Structured Streaming: watermarks и опоздавшие данныеWindow-семантика странная
В Spark Structured Streaming окна определяются на уровне query (window(col("ts"), "5 minutes")). Но runtime — micro-batch. Если ваше окно 5 минут, а micro-batch 1 секунда, runtime должен буферизировать данные нескольких micro-batches до триггера окна. Это работает, но complexity ложится на user и не natively выражено.
В Flink окно — это first-class concept оператора. WindowOperator буферизирует элементы окна в state, и при срабатывании trigger-а (по watermark или processing time) — испускает результат. Просто и natively.
Event-time через workaround
В batch-first моделях event-time обрабатывается через groupBy(window(timestamp)). Это работает, но требует sortBy timestamp на каждом batch boundary, что дорого. Late data — отдельная проблема: micro-batches уже отправили результат, как доехавшийся late event повлияет?
В Flink event-time — через watermarks (модуль 08). Watermark — это signal “событий с timestamp ≤ T больше не будет”. WindowOperator закрывает окно по watermark и испускает result. Late events после watermark обрабатываются через allowedLateness. Чистая модель.
Exactly-once требует idempotency
Spark exactly-once: каждый micro-batch имеет уникальный ID, и output sink должен быть idempotent (write-or-skip по batch ID). Это работает с многими sinks (S3, HDFS), но не работает с Kafka transactional producer без extra coordination.
Flink exactly-once: через 2PC protocol (модуль 12). Каждый checkpoint — атомарный moment-in-time снимок всего pipeline + state + offsets sources + transactions sinks. Это нативно работает с Kafka transactional producer, JDBC XA, FileSystem (через rename), и так далее.
Три уровня доставки: at-most, at-least, exactly-once Управление offset в Kafka-консьюмерахГде stream-first проигрывает
Будем честны. Не во всех случаях stream-first архитектура лучше.
Скорость batch ETL
Если у вас разовый ETL job на 10 TB данных, batch-runtime (Spark) обычно быстрее, чем streaming-runtime. Причины:
- Batch может делать sort-merge join, который оптимально использует диск (vs streaming join, который держит state в памяти).
- Batch может агрессивно использовать column pruning, predicate pushdown, vectorized processing.
- Batch не платит за continuous overhead (checkpointing, watermark propagation).
Flink в BATCH mode пытается закрыть этот gap, но всё ещё проигрывает Spark на чистом batch для типичных DWH-задач.
Простота ad-hoc анализа
spark.sql("SELECT * FROM ... WHERE ... GROUP BY ...") в Jupyter — простой workflow для data scientist. Flink здесь сложнее: streaming SELECT возвращает unbounded result, который сложно положить в DataFrame для дальнейшего анализа.
Для ad-hoc data exploration data scientists обычно используют Spark / Trino / DuckDB, а не Flink.
Operational complexity
Streaming-pipeline сложнее эксплуатировать, чем batch:
- Нужно мониторить checkpoint durations, backpressure, watermark lag.
- Нужно правильно настраивать state TTL.
- Нужно понимать savepoint upgrade workflow.
Batch — запустил, дождался завершения, проверил output. Простота.
Flink выбирает быть good at streaming, decent at batch. Если у вас pure batch workloads — Spark или Trino. Если у вас streaming или mixed — Flink.
Практическое следствие
Когда вы пишете Flink-программу, постоянно держите в голове: это будет работать как continuous pipeline. Это меняет интуицию:
- Не делайте долгих синхронных операций в map(). В batch это норм (несколько секунд на элемент ок), в streaming — гарантированный backpressure.
- Берегите state. Каждый byte состояния — это byte, который пойдёт в каждый checkpoint. Если state растёт unbounded, рано или поздно job упадёт. Используйте TTL.
- Понимайте, что watermarks — это сигналы в потоке. Они влияют на window firing. Если watermark stuck — окна не закрываются — output не появляется.
- Понимайте, что rescaling — это не runtime операция. Чтобы изменить parallelism, нужно сделать savepoint, остановить job, запустить с новым parallelism из savepoint. (С Adaptive Scheduler — частично можно без savepoint, но это отдельная тема, модуль 11.)
Если приходите в Flink из Spark, отучите себя от batch-интуиций. Думать “ну я запущу stream-job и через час он закончится” — это путь к багам. Stream-job не “заканчивается” — он работает, пока кто-то его не остановит. Если ваш use case — финальный (исторический backfill), используйте BATCH mode явно.
Что дальше
Следующий урок — про state и time как first-class citizens. Откуда эта идея пришла исторически (Storm, MillWheel, Dataflow), и как именно Flink реализует первоклассный state в runtime.
После — урок про эволюцию Flink от 1.x к 2.2: что было удалено, что добавлено, как мигрировать.