Иван Пономарёв — Kafka Streams API: шаг за рамки Hello World

Иван Пономарёв — Kafka Streams API: шаг за рамки Hello World

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

Тем, кто решит впервые попробовать создать рабочее приложение при помощи Kafka Streams API, предстоит освоить немало новых вещей и соответствующим образом настроить мышление.

- С чего начать?
- Как работает хранение и репликация локального состояния?
- Что такое RocksDB и как её возможности используются в Kafka Streams «под капотом»?
- Что за страшные слова: «репартиционирование» и «копартиционирование»?
- Какова семантика джойнов и оконных операций?
- Как писать тесты?
- Как отлаживать систему «на ходу»?
- Что делать с исключениями?

На эти вопросы мы попытаемся ответить, по пути рассмотрев несколько демо-примеров кода с использованием Spring, двигаясь от простого к сложному.

Доклад представляет собой расширенную версию доклада на конференции JPoint 2019.

3fc5b5eb32bd3b48d7810fd67b37f9a1?s=128

Moscow JUG

July 25, 2019
Tweet

Transcript

  1. 1.

    Kafka Streams API Шаг за рамки Hello World Иван Пономарёв,

    КУРС/МФТИ  @inponomarev ponomarev@corchestra.ru 1
  2. 2.

    Tech Lead at KURS ERP systems & Java background Speaker

    at JPoint, Devoops, Heisenbug, JUG.MSK, PermDevDay, DevopsForum, Стачка etc. Текущий проект: Real-time Webscraping 2
  3. 3.

    3

  4. 4.

    Celesta & 2bass — хабрапосты: Миграция схемы данных без головной боли: идемпотентность

    и конвергентность для DDL-скриптов Celesta 7.x: ORM, миграции и тестирование «в одном флаконе» 4
  5. 11.

    Зачем нам Kafka? Web-scraping в реальном времени 500 запросов/сек (да,

    это очень мало!) Удобные штуки «из коробки»: Персистентный, но «подрезаемый» лог Microbatching 8
  6. 15.

    Зачем нам Streams API? Real-time stream processing Stream-like API (map

    / reduce) Под капотом: Ребалансировка Внутреннее состояние обработчиков (репликация) Легкое масштабирование 9
  7. 16.

    Disclaimer #1: предполагается базовое понимание Кафки Виктор Гамов — Все

    стримы ведут в Кафку (jug.msk.ru 23/04/2018) Виктор Гамов — Kafka Streams IQ: «Зачем нам база данных?» (jug.msk.ru 08/05/2019) 10
  8. 17.

    Disclaimer #2: доклад не о жизни в production! Доклады о

    жизни в production: Григорий Кошелев — Когда всё пошло по Кафке (JPoint 2019) Никита Сальников-Тарновский — Streaming architecture — шаг за рамки примеров кода (devclub.eu 2019.03.26) 11
  9. 21.

    Наш план 1. Конфигурация приложения. Простые (stateless) трансформации 2. Трансформации

    с использованием локального состояния 3. Дуализм «поток—таблица» и табличные join-ы 4. Время и оконные операции 15
  10. 22.

    Kafka Streams API: общая структура KStreams-приложения StreamsConfig config = ...;

    //Здесь устанавливаем всякие опции Topology topology = new StreamsBuilder() //Здесь строим топологию ....build(); 16
  11. 24.

    Kafka Streams API: общая структура KStreams-приложения StreamsConfig config = ...;

    //Здесь устанавливаем всякие опции Topology topology = new StreamsBuilder() //Здесь строим топологию ....build(); //Это за нас делает SPRING-KAFKA KafkaStreams streams = new KafkaStreams(topology, config); streams.start(); ... streams.close(); 18
  12. 27.

    Легенда Идут футбольные матчи (меняется счёт) Делаются ставки: H, D,

    A. Поток ставок, ключ: Cyprus-Belgium:A Поток ставок, значение: class Bet { String bettor; //John Doe String match; //Cyprus-Belgium Outcome outcome; //A (or H or D) long amount; //100 double odds; //1.7 long timestamp; //1554215083998 } 20
  13. 28.

    @Bean KafkaConfiguration //ВАЖНО! @Bean(name = KafkaStreamsDefaultConfiguration .DEFAULT_STREAMS_CONFIG_BEAN_NAME) public KafkaStreamsConfiguration getStreamsConfig()

    { Map<String, Object> props = new HashMap<>(); //ВАЖНО! props.put(StreamsConfig.APPLICATION_ID_CONFIG, "stateless-demo-app"); //ВАЖНО! props.put(StreamsConfig.NUM_STREAM_THREADS_CONFIG, 4); props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); ... KafkaStreamsConfiguration streamsConfig = new KafkaStreamsConfiguration(props); return streamsConfig; } 21
  14. 29.

    @Bean NewTopic @Bean NewTopic getFilteredTopic() { Map<String, String> props =

    new HashMap<>(); props.put( TopicConfig.CLEANUP_POLICY_CONFIG, TopicConfig.CLEANUP_POLICY_COMPACT); return new NewTopic("mytopic", 10, (short) 1).configs(props); } 22
  15. 30.

    @Bean Topology @Bean public Topology createTopology(StreamsBuilder streamsBuilder) { KStream<String, Bet>

    input = streamsBuilder.stream(...); KStream<String, Long> gain = input.mapValues(v -> Math.round(v.getAmount() * v.getOdds())) gain.to(GAIN_TOPIC, Produced.with(Serdes.String(), new JsonSerde<>(Long.class))); return streamsBuilder.build(); } 23
  16. 31.

    TopologyTestDriver: создание KafkaStreamsConfiguration config = new KafkaConfiguration() .getStreamsConfig(); StreamsBuilder sb

    = new StreamsBuilder(); Topology topology = new TopologyConfiguration().createTopology(sb); TopologyTestDriver topologyTestDriver = new TopologyTestDriver(topology, config.asProperties()); 24
  17. 32.

    TopologyTestDriver: использование Bet bet = Bet.builder() .bettor("John Doe") .match("Germany-Belgium") .outcome(Outcome.H)

    .amount(100) .odds(1.7).build(); topologyTestDriver.pipeInput( betFactory.create(BET_TOPIC, bet.key(), bet)); 25
  18. 33.

    TopologyTestDriver: использование ProducerRecord<String, Long> record = topologyTestDriver.readOutput( GAIN_TOPIC, new StringDeserializer(),

    new JsonDeserializer<>(Long.class) ); assertEquals(bet.key(), record.key()); assertEquals(170L, record.value().longValue()); 26
  19. 36.

    Если всё совсем развалилось В Спринге всё сложнее (см. код)

    streams.setUncaughtExceptionHandler( (Thread thread, Throwable throwable) -> { . . . }); 28
  20. 39.

    Простое ветвление стримов Java-стримы так не могут: KStream<..> foo =

    ... KStream<..> bar = foo.mapValues(…).map... to... Kstream<..> baz = foo.filter(…).map... forEach... 31
  21. 40.

    Ветвление стримов по условию Не используйте KStream.branch, используйте KafkaStreamsBrancher! new

    KafkaStreamsBrancher<String, String>() .branch((key, value) -> value.contains("A"), ks -> ks.to("A")) .branch((key, value) -> value.contains("B"), ks -> ks.to("B")) .defaultBranch(ks -> ks.to("C")) .onTopOf(builder.stream("source")) .map(...) 32
  22. 41.
  23. 42.

    Наш план 1. Конфигурация приложения. Простые (stateless) трансформации 2. Трансформации

    с использованием локального состояния 3. Дуализм «поток—таблица» и табличные join-ы 4. Время и оконные операции 34
  24. 43.

    Локальное состояние Facebook’s RocksDB — что это и зачем? Embedded key/value storage

    LSM Tree (Log-Structured Merge-Tree) High-performant (data locality) Persistent, optimized for SSD 35
  25. 44.

    RocksDB похож на TreeMap<K,V> Сохранение K,V в бинарном формате Лексикографическая

    сортировка Iterator (snapshot view) Удаление диапазона (deleteRange) 36
  26. 46.

    @Bean Topology KStream<String, Bet> input = streamsBuilder. stream(BET_TOPIC, Consumed.with(Serdes.String(), new

    JsonSerde<>(Bet.class))); KStream<String, Long> counted = new TotallingTransformer() .transformStream(streamsBuilder, input); 38
  27. 47.

    Суммирование ставок @Override public KeyValue<String, Long> transform(String key, Bet value,

    KeyValueStore<String, Long> stateStore) { long current = Optional .ofNullable(stateStore.get(key)) .orElse(0L); current += value.getAmount(); stateStore.put(key, current); return KeyValue.pair(key, current); } 39
  28. 48.

    StateStore доступен в тестах @Test void testTopology() { topologyTestDriver.pipeInput(...); topologyTestDriver.pipeInput(...);

    KeyValueStore<String, Long> store = topologyTestDriver .getKeyValueStore(TotallingTransformer.STORE_NAME); assertEquals(..., store.get(...)); assertEquals(..., store.get(...)); } 40
  29. 50.

    Подробнее о ребалансировке Matthias J. Sax Everything You Always Wanted

    to Know About Kafka’s Rebalance Protocol but Were Afraid to Ask (Kafka Summit London, 2019) 42
  30. 55.

    Репартиционирование Явное при помощи through(String topic, Produced<K, V> produced) Неявное

    при операциях, меняющих ключ + stateful-операциях 46
  31. 56.

    Дублирующееся неявное репартиционирование KStream source = builder.stream("topic1"); KStream mapped =

    source.map(...); KTable counts = mapped.groupByKey().aggregate(...); KStream sink = mapped.leftJoin(counts, ...); 47
  32. 57.

    Избавляемся от дублирующегося репартиционирования KStream source = builder.stream("topic1"); KStream shuffled

    = source.map(...).through("topic2",..); KTable counts = shuffled.groupByKey().aggregate(...); KStream sink = shuffled.leftJoin(counts, ...); 48
  33. 58.

    Ключ лучше лишний раз не трогать Key only: selectKey Key

    and Value Value Only map mapValues flatMap flatMapValues transform transformValues flatTransform flatTransformValues 49
  34. 60.

    Наш план 1. Конфигурация приложения. Простые (stateless) трансформации 2. Трансформации

    с использованием локального состояния 3. Дуализм «поток—таблица» и табличные join-ы 4. Время и оконные операции 51
  35. 64.
  36. 65.

    Переписываем totalling app при помощи KTable KTable<String, Long> totals =

    input.groupByKey().aggregate( () -> 0L, (k, v, a) -> a + Math.round(v.getAmount() * v.getOdds()), Materialized.with(Serdes.String(), Serdes.Long()) ); $kafka-topics --zookeeper localhost --describe Topic: table2-demo-KSTREAM-AGGREGATE-STATE-STORE-0000000001-changelog PartitionCount:10 ReplicationFactor:1 Configs:cleanup.policy=compact 56
  37. 66.

    Получаем таблицу счетов матчей KStream<String, Score> scores = eventScores.flatMap((k, v)

    -> Stream.of(Outcome.H, Outcome.A).map(o -> KeyValue.pair(String.format("%s:%s", k, o), v)) .collect(Collectors.toList())) .mapValues(EventScore::getScore); KTable<String, Score> tableScores = scores.groupByKey(Grouped.with(...).reduce((a, b) -> b); $kafka-topics --zookeeper localhost --list table2-demo-KSTREAM-REDUCE-STATE-STORE-0000000006-repartition table2-demo-KSTREAM-REDUCE-STATE-STORE-0000000006-changelog 57
  38. 67.

    Демо: Объединяем сумму ставок с текущим счётом KTable<String, String> joined

    = totals.join(tableScores, (total, eventScore) -> String.format("(%s)\t%d", eventScore, total)); 58
  39. 78.

    Наш план 1. Конфигурация приложения. Простые (stateless) трансформации 2. Трансформации

    с использованием локального состояния 3. Дуализм «поток—таблица» и табличные join-ы 4. Время и оконные операции 69
  40. 79.

    Сохранение Timestamped-значений в RocksDB WindowKeySchema.java static Bytes toStoreKeyBinary(byte[] serializedKey, long

    timestamp, int seqnum) { ByteBuffer buf = ByteBuffer.allocate( serializedKey.length + TIMESTAMP_SIZE + SEQNUM_SIZE); buf.put(serializedKey); buf.putLong(timestamp); buf.putInt(seqnum); return Bytes.wrap(buf.array()); } 70
  41. 81.

    Демо: Windowed Joins «Послегольщик» — игрок, пытающийся протолкнуть правильную ставку

    в момент смены счёта в матче Штамп времени ставки и события смены счёта должны «почти совпадать». 72
  42. 82.

    Время, вперёд! (Ещё время можно извлечь из WallClock и RecordMetadata.)

    KStream<String, Bet> bets = streamsBuilder.stream(BET_TOPIC, Consumed.with( Serdes...) .withTimestampExtractor( (record, previousTimestamp) -> ((Bet) record.value()).getTimestamp() )); 73
  43. 83.

    Демо: Windowed Joins По событию смены счёта понимаем, какая ставка

    будет «правильной»: Score current = Optional.ofNullable(stateStore.get(key)) .orElse(new Score()); stateStore.put(key, value.getScore()); Outcome currenOutcome = value.getScore().getHome() > current.getHome() ? Outcome.H : Outcome.A; 74
  44. 84.

    Демо: Windowed Joins KStream<String, String> join = bets.join(outcomes, (bet, sureBet)

    -> String.format("%s %dms before goal", bet.getBettor(), sureBet.getTimestamp() - bet.getTimestamp()), JoinWindows.of(Duration.ofSeconds(1)).before(Duration.ZERO) Joined.with(Serdes.... )); 75
  45. 85.

    Tumbling window Источник: Kafka Streams in Action TimeWindowedKStream<..., ...> windowed

    = stream.groupByKey() .windowedBy(TimeWindows.of(Duration.ofSeconds(20))); 76
  46. 86.

    Tumbling window TimeWindowedKStream<..., ...> windowed = stream.groupByKey() .windowedBy(TimeWindows.of(Duration.ofSeconds(20))); KTable<Windowed<...>, Long>

    count = windowed.count(); /* * Windowed<K> interface: * - K key() * - Window window() * -- Instant startTime() * -- Instant endTime() */ 77
  47. 87.

    Hopping Window Источник: Kafka Streams in Action TimeWindowedKStream<..., ...> windowed

    = stream.groupByKey() .windowedBy(TimeWindows.of(Duration.ofSeconds(20)) .advanceBy(Duration.ofSeconds(10))); 78
  48. 90.

    Иногда нужны не окна, а Punctuator class MyTransformer implements Transformer<...>

    { @Override public void init(ProcessorContext context) { context.schedule( Duration.ofSeconds(10), PunctuationType.WALL_CLOCK_TIME, timestamp->{. . .}); } 81
  49. 91.

    Наш план 1. Конфигурация приложения. Простые (stateless) трансформации 2. Трансформации

    с использованием локального состояния 3. Дуализм «поток—таблица» и табличные join-ы 4. Время и оконные операции Пора закругляться! 82
  50. 92.

    Kafka Streams in Action William Bejeck, “Kafka Streams in Action”,

    November 2018 Примеры кода для Kafka 1.x 83
  51. 94.

    Другие источники Исходники! docs.confluent.io: Streams Developer Guide Getting Your Feet

    Wet with Stream Processing (Confluent tutorials) https://github.com/apache/kafka/ https://github.com/spring-projects/spring-kafka 85
  52. 98.

    Некоторые итоги Kafka StreamsAPI — это удобная абстракция над «сырой» Кафкой Чтобы

    начать пользоваться, надо настроить мышление под потоковую обработку 87
  53. 99.

    Некоторые итоги Kafka StreamsAPI — это удобная абстракция над «сырой» Кафкой Чтобы

    начать пользоваться, надо настроить мышление под потоковую обработку Технология переживает бурное развитие + живой community, есть шанс повлиять на процесс самому - публичные интерфейсы изменяются очень быстро 87