Обработка плохих сообщений с использованием API-интерфейсов Kafka Streams

У меня есть поток потока основной обработки, который выглядит как

master topic -> my processing in a mapper/filter -> output topics

и мне интересно, как лучше всего обращаться с "плохими сообщениями". Возможно, это могут быть такие вещи, как сообщения, которые я не могу десериализовать должным образом, или, возможно, некорректная обработка логики обработки/фильтрации (у меня нет внешних зависимостей, поэтому не должно быть временных ошибок такого рода).

Я рассматривал возможность переноса всего кода обработки/фильтрации в try catch, и если было создано исключение, то маршрутизация в "тему ошибки". Затем я смогу изучить сообщение и изменить его или исправить мой код, а затем воспроизвести его на мастер. Если я допускаю распространение каких-либо исключений, поток, похоже, застрял, и больше сообщений не было получено.

  • Этот подход считается наилучшей практикой?
  • Есть ли удобный способ управления потоками Kafka? Я не думаю, что есть концепция DLQ...
  • Каковы альтернативные способы остановки Kafka jamming при "плохом сообщении"?
  • Какие существуют альтернативные подходы к обработке ошибок?

Для полноты здесь мой код (псевдо-иш):

class Document {
    // Fields
}

class AnalysedDocument {

    Document document;
    String rawValue;
    Exception exception;
    Analysis analysis;

    // All being well
    AnalysedDocument(Document document, Analysis analysis) {...}

    // Analysis failed
    AnalysedDocument(Document document, Exception exception) {...}

    // Deserialisation failed
    AnalysedDocument(String rawValue, Exception exception) {...}
}

KStreamBuilder builder = new KStreamBuilder();
KStream<String, AnalysedPolecatDocument> analysedDocumentStream = builder
    .stream(Serdes.String(), Serdes.String(), "master")
    .mapValues(new ValueMapper<String, AnalysedDocument>() {
         @Override
         public AnalysedDocument apply(String rawValue) {
             Document document;
             try {
                 // Deserialise
                 document = ...
             } catch (Exception e) {
                 return new AnalysedDocument(rawValue, exception);
             }
             try {
                 // Perform analysis
                 Analysis analysis = ...
                 return new AnalysedDocument(document, analysis);
             } catch (Exception e) {
                 return new AnalysedDocument(document, exception);
             }
         }
    });

// Branch based on whether analysis mapping failed to produce errorStream and successStream
errorStream.to(Serdes.String(), customPojoSerde(), "error");
successStream.to(Serdes.String(), customPojoSerde(), "analysed");

KafkaStreams streams = new KafkaStreams(builder, config);
streams.start();

Любая помощь очень ценится.

Ответ 1

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

Один комментарий об обработке ошибок де-сериализации: обработка этих ошибок вручную требует, чтобы вы выполняли де-сериализацию "вручную". Это означает, что вам нужно настроить ByteArraySerde для ключа и значения для темы ввода-вывода вашего приложения Streams и добавить map(), который выполняет де-сериализацию (т.е. KStream<byte[],byte[]> -> map() -> KStream<keyType,valueType> - или наоборот, если вы также хотите поймать исключения для сериализации). В противном случае вы не можете try-catch исключения десериализации.

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

Обновление

Эти проблемы решаются с помощью KIP-161 и будут включены в следующую версию 1.0.0. Он позволяет регистрировать обратный вызов через параметр default.deserialization.exception.handler. Обработчик будет вызываться каждый раз, когда исключение возникает во время десериализации и позволяет вам вернуть DeserializationResponse (CONTINUE → удалить запись, или FAIL, которая по умолчанию).

Обновление 2

С KIP-210 (будет частью Kafka 1.1) также можно обрабатывать ошибки на стороне производителя, аналогично пользовательская часть, зарегистрировав ProductionExceptionHandler через config default.production.exception.handler, который может вернуть CONTINUE.

Ответ 2

Обновление 20 октября 2017 года: Kafka 1.0, который будет выпущен в октябре 2017 года, добавит лучшую обработку для сообщений об ошибках ( "ядовитые таблетки" ) через KIP-161: обработчики исключений десериализации потоков. Я могу обновить этот ответ с прямой ссылкой на документы 1.0 после того, как они будут доступны.

Это может быть нечто вроде сообщений, которые я не могу десериализовать правильно [...]

Хорошо, мой ответ здесь посвящен проблемам сериализации (de), поскольку это может быть самым сложным сценарием для большинства пользователей.

[...] или, возможно, некорректная обработка логики обработки/фильтрации (у меня нет внешних зависимостей, поэтому не должно быть временных ошибок такого рода).

То же мышление (для десериализации) также может быть применено к ошибкам в логике обработки. Здесь большинство людей склонны тянуться к варианту 2 ниже (минус часть десериализации), но YMMV.

Я рассматривал возможность переноса всего кода обработки/фильтрации в try catch, и если было создано исключение, то маршрутизация в "тему ошибки". Затем я смогу изучить сообщение и изменить его или исправить мой код, а затем воспроизвести его на мастер. Если я допускаю распространение каких-либо исключений, поток, похоже, застрял, и больше сообщений не было получено.

  • Этот подход считается наилучшей практикой?

Да, на данный момент это путь. По сути, два наиболее распространенных шаблона: (1) пропускать поврежденные сообщения или (2) отправлять поврежденные записи в тему карантина, а также очередь с мертвой буквой.

  • Есть ли удобный способ управления потоками Kafka? Я не думаю, что есть концепция DLQ...

Да, есть способ справиться с этим, включая использование очереди с мертвой буквой. Впрочем, это (по крайней мере ИМХО) еще не так удобно. Если у вас есть обратная связь о том, как API должен позволить вам справиться с этим - например, через новый или обновленный метод, настройку конфигурации ( "если сериализация/десериализация не удастся отправить проблемную запись в эту тему карантина" ), пожалуйста, сообщите нам об этом.: -)

  • Каковы альтернативные способы остановки Kafka jamming при "плохом сообщении"?
  • Какие существуют альтернативные подходы к обработке ошибок?

См. мои примеры ниже.

FWIW, сообщество Kafka также обсуждает добавление нового инструмента CLI, который позволяет пропустить поврежденные сообщения. Однако, как пользователь API Kafka Streams, я думаю, что в идеале вы хотите обрабатывать такие сценарии непосредственно в своем коде и отбрасывать утилиты CLI только в крайнем случае.

Вот несколько шаблонов для DSL Kafka Streams для обработки поврежденных записей/сообщений, например, "ядовитых таблеток". Это взято из http://docs.confluent.io/current/streams/faq.html#handling-corrupted-records-and-deserialization-errors-poison-pill-messages

Вариант 1: Пропустить испорченные записи с помощью flatMap

Это, возможно, большинство пользователей хотели бы сделать.

  • Мы используем flatMap, потому что он позволяет выводить нулевые, одно и более выходные записи на входную запись. В случае поврежденной записи мы ничего не выводим (нулевые записи), тем самым игнорируя/пропуская поврежденную запись.
  • Воспользуйтесь этим подходом по сравнению с другими, перечисленными здесь: нам нужно вручную десериализовать запись только один раз!
  • Недостаток этого подхода: flatMap "отмечает" входной поток для потенциального повторного разбиения данных, т.е. если вы выполняете операцию на основе ключа, такую ​​как группировки (groupBy/groupByKey) или присоединяетесь впоследствии, данные будут перераспределены за кулисами. Поскольку это может быть дорогостоящим шагом, мы не хотим, чтобы это происходило без необходимости. Если вы ЗНАЕТЕ, что ключи записи всегда действительны или что вам не нужно работать с клавишами (таким образом, сохраняя их как "необработанные" клавиши в формате byte[]), вы можете перейти от flatMap в flatMapValues, что не приведет к повторному разделению данных, даже если вы позже присоединитесь к группе/агрегируете поток.

Пример кода:

Serde<byte[]> bytesSerde = Serdes.ByteArray();
Serde<String> stringSerde = Serdes.String();
Serde<Long> longSerde = Serdes.Long();

// Input topic, which might contain corrupted messages
KStream<byte[], byte[]> input = builder.stream(bytesSerde, bytesSerde, inputTopic);

// Note how the returned stream is of type KStream<String, Long>,
// rather than KStream<byte[], byte[]>.
KStream<String, Long> doubled = input.flatMap(
    (k, v) -> {
      try {
        // Attempt deserialization
        String key = stringSerde.deserializer().deserialize(inputTopic, k);
        long value = longSerde.deserializer().deserialize(inputTopic, v);

        // Ok, the record is valid (not corrupted).  Let take the
        // opportunity to also process the record in some way so that
        // we haven't paid the deserialization cost just for "poison pill"
        // checking.
        return Collections.singletonList(KeyValue.pair(key, 2 * value));
      }
      catch (SerializationException e) {
        // log + ignore/skip the corrupted message
        System.err.println("Could not deserialize record: " + e.getMessage());
      }
      return Collections.emptyList();
    }
);

Вариант 2: очередь с мертвой буквой с branch

По сравнению с вариантом 1 (который игнорирует поврежденные записи) опция 2 сохраняет поврежденные сообщения, отфильтровывая их из "основного" входного потока и записывая их в тему карантина (думаю: очередь с мертвой буквой). Недостатком является то, что для действительных записей мы должны заплатить стоимость ручной десериализации дважды.

KStream<byte[], byte[]> input = ...;

KStream<byte[], byte[]>[] partitioned = input.branch(
    (k, v) -> {
      boolean isValidRecord = false;
      try {
        stringSerde.deserializer().deserialize(inputTopic, k);
        longSerde.deserializer().deserialize(inputTopic, v);
        isValidRecord = true;
      }
      catch (SerializationException ignored) {}
      return isValidRecord;
    },
    (k, v) -> true
);

// partitioned[0] is the KStream<byte[], byte[]> that contains
// only valid records.  partitioned[1] contains only corrupted
// records and thus acts as a "dead letter queue".
KStream<String, Long> doubled = partitioned[0].map(
    (key, value) -> KeyValue.pair(
        // Must deserialize a second time unfortunately.
        stringSerde.deserializer().deserialize(inputTopic, key),
        2 * longSerde.deserializer().deserialize(inputTopic, value)));

// Don't forget to actually write the dead letter queue back to Kafka!
partitioned[1].to(Serdes.ByteArray(), Serdes.ByteArray(), "quarantine-topic");

Вариант 3: Пропустить испорченные записи с помощью filter

Я только упоминаю это для полноты. Этот вариант выглядит как сочетание вариантов 1 и 2, но хуже, чем любой из них. По сравнению с вариантом 1 вы должны оплатить ручную стоимость десериализации для действительных записей дважды (плохо!). По сравнению с вариантом 2 вы теряете возможность сохранять поврежденные записи в очереди с мертвой буквой.

KStream<byte[], byte[]> validRecordsOnly = input.filter(
    (k, v) -> {
      boolean isValidRecord = false;
      try {
        bytesSerde.deserializer().deserialize(inputTopic, k);
        longSerde.deserializer().deserialize(inputTopic, v);
        isValidRecord = true;
      }
      catch (SerializationException e) {
        // log + ignore/skip the corrupted message
        System.err.println("Could not deserialize record: " + e.getMessage());
      }
      return isValidRecord;
    }
);
KStream<String, Long> doubled = validRecordsOnly.map(
    (key, value) -> KeyValue.pair(
        // Must deserialize a second time unfortunately.
        stringSerde.deserializer().deserialize(inputTopic, key),
        2 * longSerde.deserializer().deserialize(inputTopic, value)));

Любая помощь очень ценится.

Я надеюсь, что смогу помочь. Если да, я был бы признателен за ваши отзывы о том, как мы могли бы улучшить API Kafka Streams для обработки отказов/исключений лучшим/более удобным способом, чем сегодня.: -)