Потоковая обработка
Stream-Table Duality
В 2016 году LinkedIn обрабатывал 4.5 триллиона сообщений в Kafka в день. Им нужно было показывать live-счётчики ("123 пользователя смотрят эту вакансию прямо сейчас") без отдельной БД. Решение - построить TABLE прямо поверх потока. Так родился Kafka Streams и идея stream-table duality.
- **LinkedIn (2016)**: 4.5 трлн сообщений Kafka в день, live-счётчики через KTable
- **Confluent ksqlDB**: SQL-надстройка над Kafka Streams, используется в Lyft, Walmart, Bosch
- **Pinterest**: Apache Flink для materialized views поверх Kafka, 100+ ТБ state
- **Uber Athenadb**: TABLE через materialized views для real-time pricing
- **Yelp Joinery**: stream-stream joins для match orders и payments в real-time
Materialized views: таблица из потока
Stream-table duality - центральная идея Kafka Streams и подобных систем. Любая таблица может быть представлена как поток её изменений, и любой поток ключ-значение может быть свёрнут в таблицу через агрегацию по последнему значению на ключ. Materialized view - это и есть таблица, материализованная из потока.
**Формально:** таблица T = aggregate(stream S, by_key, last_value). Поток S = changelog(table T). Если поток упорядочен по ключу, эти операции инвертны: table -> stream -> table даёт исходную таблицу.
Что такое materialized view в Kafka Streams?
Changelog streams
Changelog stream - это поток обновлений таблицы: каждое сообщение содержит ключ и новое значение (или null для удаления). Compaction Kafka хранит только последнее сообщение на ключ, поэтому changelog топик становится "физически" таблицей: размер ограничен количеством уникальных ключей, а не временем.
**Свойства:** идемпотентность (повтор сообщения = повтор последнего значения = no-op), сериализуемость (можно восстановить таблицу с нуля проиграв changelog), tombstone (key, null) обозначает удаление и удаляется compaction после retention.
Почему changelog топик использует cleanup.policy=compact, а не delete?
ksqlDB: SQL над потоками
ksqlDB - надстройка над Kafka Streams, дающая SQL-синтаксис. Позволяет создавать STREAMs и TABLEs, делать join-ы, агрегации, окна без написания Java кода. Внутри генерирует Kafka Streams топологию.
**Pull vs Push query:** pull читает текущий snapshot из state store (как обычный SELECT). Push возвращает поток изменений в реальном времени. Оба используют одну и ту же KTable внутри.
В ksqlDB STREAM и TABLE - это одно и то же физически?
Streaming joins: stream-stream и stream-table
Stream-table join обогащает каждое событие потока данными из таблицы по ключу. Это lookup: для события приходит текущее значение из TABLE. Идеально для обогащения транзакций профилем пользователя.
Stream-stream join сложнее: нужно сопоставить два события из разных потоков, но они не приходят одновременно. Решение - временное окно: "если событие B пришло в течение N минут после события A с тем же ключом, выдай пару". Без окна join был бы бесконечно растущей таблицей.
**Подводный камень:** join требует co-partitioning. Оба потока должны быть партиционированы по одному ключу и иметь одинаковое число партиций. Иначе ksqlDB сделает auto-repartition (дорогой shuffle через промежуточный топик).
TABLE в ksqlDB - это таблица как в обычной БД, отдельная сущность от Kafka.
TABLE - это представление поверх changelog топика. Физически данные хранятся в Kafka (для durability) и в RocksDB (для query latency). При падении worker-а state восстанавливается из changelog топика.
Нет отдельной БД, нет двойного хранения - это и есть stream-table duality в действии. Если думать о TABLE как о PostgreSQL-таблице, инженер начнёт искать ETL job-ы между Kafka и БД, которых не существует. Понимание единого хранилища меняет дизайн системы целиком.
Зачем stream-stream join требует временное окно?
Ключевые идеи
- Stream-table duality: таблица = aggregate(stream, by key, last value). Stream = changelog(table). Преобразования взаимно обратны
- Materialized view = таблица из потока + локальный state store (RocksDB) + changelog топик для recovery
- ksqlDB даёт SQL-синтаксис над Kafka Streams. STREAM и TABLE - разные семантики над одним механизмом топик плюс state store
- Stream-table join = lookup по ключу. Stream-stream join требует временного окна, иначе state растёт бесконечно. Оба требуют co-partitioning
Связанные темы
Stream-table duality - центральная ментальная модель для всех streaming-систем; она связывает CDC, event sourcing, CQRS и Kafka Streams в одну архитектурную картину:
- Change Data Capture (CDC) — обратная сторона duality: table -> changelog
- Kafka Streams — первичная реализация stream-table duality
- Event Sourcing — stream как source of truth, table как projection
- CQRS — stream для commands, materialized view для queries
Вопросы для размышления
- Как бы построили counter "online users" через KTable? Какой changelog топик нужен?
- У вас два потока: clicks и impressions. Как соединить их в pairs для расчёта CTR?
- Почему stream-stream join без окна был бы неправильным дизайном?
- В каких случаях ksqlDB не подходит и нужно писать Kafka Streams на Java напрямую?
Связанные уроки
- stream-13 — CDC - ключевой паттерн для понимания stream-table duality
- db-02-relational-model — Таблица как частный случай стрима с единственным состоянием
- prob-17 — Марковские цепи: поток - это марковские переходы, таблица - стационарное состояние
- stream-15 — Понимание duality открывает путь к оконным функциям
- aie-08-streaming — LLM streaming - приложение stream-table duality для AI систем