Проблеми с пакетната обработка на заявки и техните решения (част 1)

Проблеми с пакетната обработка на заявки и техните решения (част 1)Практически всички съвременни софтуерни продукти се състоят от няколко услуги. Често дългото време за отговор на междусервисните канали става източник на проблеми с производителността. Стандартното решение на такива проблеми е пакетиране на няколко междусервисни заявки в един пакет, което наричаме пакетна обработка (batching).

Ако използвате пакетна обработка, може да не сте удовлетворени от резултата ѝ по отношение на производителността или разбираемостта на кода. Този метод не е толкова прост за извикващата страна, колкото може да изглежда. За различни цели и в различни ситуации решенията могат значително да се различават. С конкретни примери ще покажа плюсовете и минусите на няколко подхода.

Демонстрационен проект

За илюстрация ще разгледаме пример на една от услугите в приложението, над което в момента работя.

Обяснение по избора на платформа за примеритеПроблемът с лошата производителност е доста общ и не се отнася до конкретни езици и платформи. В тази статия за демонстрация на задачи и решения ще се използват примери на код на Spring + Kotlin. Kotlin е еднакво разбираем (или неразбираем) за Java и C# разработчиците, освен това, кодът става по-компактен и разбираем в сравнение с Java. За да улесня разбираемостта за чистите Java разработчици, ще избягвам черната магия на Kotlin и ще използвам само бялата (в духа на Lombok). Ще има малко extension методи, но те наистина са познати на всички Java програмисти като статични методи, така че това ще бъде малко захарче, което не ще развали вкуса на ястието.
Има услуга за одобрение на документи. Някой създава документ и го представя за обсъждане, в процеса на което се правят корекции, и в крайна сметка документът се одобрява. Самата услуга за одобрение не знае нищо за документите: това е просто чат между одобряващите с малко допълнителни функции, които няма да разглеждаме тук.

И така, има стаи за чат (отговарящи на документите) с предварително определен набор от участници в тях. Както в обикновените чатове, съобщенията съдържат текст и файлове и могат да бъдат отговори (reply) и препратки (forward):

данни клас СъобщениеЧат(
  // nullable так как появляется только после persist
  вал ид: Дълъг? = null,
  /** Ссылка на автора */
  вал автор: ПотребителскаСправка,
  /** Сообщение */
  вал съобщение: Стринг,
  /** Ссылки на аттачи */
  // из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
  вал файлове: Списък<СправкаЗаФайл>? = null,
  /** Если является ответом, то здесь будет оригинал */
  вал отговорНа: СъобщениеЧат? = null,
  /** Если является пересылкой, то здесь будет оригинал */
  вал препращанеОт: СъобщениеЧат? = null
)

Връзките към файла и потребителя – това са връзки към други домейни. При нас това функционира така:

типизиране СправкаЗаФайл = Дълъг
типизиране ПотребителскаСправка = Дълъг

Данните за потребителите се съхраняват в Keycloak и се получават чрез REST. Същото важи и за файловете: файловете и метаинформацията за тях съществуват в отделен сервиз за файлово хранилище.

Всички повиквания на тези услуги са тежки заявки.Това означава, че разходите за транспортиране на тези заявки са много по-големи от времето за обработка от страна на услугата. На нашите тестови среди типичното време за повикване на такива услуги е 100 мс, така че по-късно ще използваме тези стойности.

Необходимо е да създадем прост REST контролер за получаване на последните N съобщения с всичката необходима информация. Тоест приемаме, че в фронтенда моделът на съобщенията е почти същият и трябва да прехвърлим всички данни. Разликата в модела за фронтенда е, че файлът и потребителят трябва да бъдат представени в малко декодирана форма, за да станат линкове:

/** В таком виде отдаются ссылки на сущности для фронта */
данни клас Референтен интерфейс(
  /** Идентификатор для url */
  вал реф: Стринг,
  /** Видимое пользователю название ссылки */
  вал име: Стринг
)
данни клас Интерфейс за чат съобщения(
  вал ид: Дълъг,
  /** Ссылка на автора */
  вал автор: Референтен интерфейс,
  /** Сообщение */
  вал съобщение: Стринг,
  /** Ссылки на аттачи */
  вал файлове: Списък<Референтен интерфейс>,
  /** Если являтся ответом, то здесь будет оригинал */
  вал отговорНа: Интерфейс за чат съобщения? = null,
  /** Если являтся пересылкой, то здесь будет оригинал */
  вал препращанеОт: Интерфейс за чат съобщения? = null
)

Необходимо е да реализираме следното:

интерфейс ChatRestApi {
  забавление getLast(n: Int): Списък<Интерфейс за чат съобщения>
}

Постфикс UI означава DTO моделчета за фронтенда, тоест това, което трябва да върнем чрез REST.

Тук може да изглежда учудващо, че не предаваме никакъв идентификатор на чата и дори в модела ChatMessage/ChatMessageUI той липсва. Направих го умишлено, за да не претоварвам кода с примери (чатовете са изолирани, така че можем да приемем, че имаме само един).

Философско отклонениеКакто в класа ChatMessageUI, така и в метода ChatRestApi.getLast се използва типът данни List, докато всъщност това е подреден Set. В JDK нещата са зле, така че да се декларира реда на елементите на ниво интерфейс (запазване на реда при добавяне и извличане) няма да е възможно. Така че обща практика е да се използва List в случаи, когато се нуждаем от подреден Set (има и LinkedHashSet, но това не е интерфейс).
Важно ограничение: ще приемем, че дълги вериги от отговори или преноси не съществуват. Тоест те съществуват, но дължината им не надвишава три съобщения. Във фронтенда веригата от съобщения трябва да бъде предадена изцяло.

За получаване на данни от външни услуги има следните API:

интерфейс ChatMessageRepository {
  забавление findLast(n: Int): Списък<СъобщениеЧат>
}
данни клас FileHeadRemote(
  вал ид: СправкаЗаФайл,
  вал име: Стринг
)
интерфейс FileRemoteApi {
  забавление getHeadById(id: СправкаЗаФайл): FileHeadRemote
  забавление getHeadsByIds(id: Set<СправкаЗаФайл>): Set<FileHeadRemote>
  забавление getHeadsByIds(id: Списък<СправкаЗаФайл>): Списък<FileHeadRemote>
  забавление getHeadsByChat(): Списък<FileHeadRemote>
}
данни клас UserRemote(
  вал ид: ПотребителскаСправка,
  вал име: Стринг
)
интерфейс UserRemoteApi {
  забавление getUserById(id: ПотребителскаСправка): UserRemote
  забавление getUsersByIds(id: Set<ПотребителскаСправка>): Set<UserRemote>
  забавление getUsersByIds(id: Списък<ПотребителскаСправка>): Списък<UserRemote>
}

Лесно е да се види, че във външните услуги първоначално е предвидена пакетна обработка, и в двата варианта: чрез Set (без запазване на реда на елементите, с уникални ключове) и чрез List (възможни са дубликати — редът се запазва).

Прости реализации

Наивна реализация

Първата наивна реализация на нашия REST контролер в повечето случаи ще изглежда така:

клас ChatRestController(
  private val messageRepository: ChatMessageRepository,
  private val userRepository: UserRemoteApi,
  private val fileRepository: FileRemoteApi
) : ChatRestApi {
  override fun getLast(n: Int) =
    messageRepository.findLast(n)
      .карта { то.toFrontModel() }
  
  private fun СъобщениеЧат.toFrontModel(): Интерфейс за чат съобщения =
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$това трябва да бъде запазено"),
      author = userRepository.getUserById(author).toFrontReference(),
      message = message,
      files = files?.let { файлове ->
        fileRepository.getHeadsByIds(файлове)
          .карта { то.toFrontReference() }
      } ?: списъкНа(),
      forwardFrom = forwardFrom?.toFrontModel(),
      replyTo = replyTo?.toFrontModel()
    )
}

Всичко е напълно ясно и това е голям плюс.

Използваме пакетна обработка и получаваме данни от външен сервис на пакети. Но какво става с производителността?

За всяко съобщение ще бъде направен един извик на UserRemoteApi за получаване на данни по полето author и един извик на FileRemoteApi за получаване на всички приложени файлове. Въпреки че изглежда всичко е просто. Да предположим, че полетата forwardFrom и replyTo за ChatMessage се получават по начин, който не изисква излишни извиквания. Но преобразуването им в ChatMessageUI ще доведе до рекурсия, което означава, че броят на извикванията може да нарасне значително. Както споменахме по-рано, да предположим, че имаме малка дълбочина на вложеност и веригата е ограничена до три съобщения.

В крайна сметка ще получим от два до шест извиквания на външни услуги за едно съобщение и един JPA извик за целия пакет съобщения. Общият брой на извикванията ще варира от 2*N+1 до 6*N+1. Колко е това в реални единици? Да предположим, че за визуализиране на страницата са нужни 20 съобщения. За да ги получим, ще са необходими от 4 секунди до 10 секунди. Ужасно! Искаме да успеем в 500 мс. А тъй като на фронтенда мечтаят за безшевен скрол, изискванията за производителността на този крайна точка могат да се удвоят.

Плюсове:

  1. Кодът е кратък и самодокументиран (мечта на поддръжката).
  2. Кодът е прост, така че възможностите за грешки са почти нулеви.
  3. Пакетната обработка не изглежда чужда и органично се вписва в логиката.
  4. Промените в логиката ще бъдат направени лесно и локално.

Минус:

Ужасна производителност, свързана с факта, че пакетите са много малки.

Такъв подход често може да се види в простите услуги или в прототипи. Ако скоростта на промени е важна, едва ли трябва да се усложнява системата. В същото време за нашата много проста услуга производителността е ужасна, така че обхватът на приложимостта на такъв подход е много тясен.

Наивна паралелна обработка

Може да се започне обработка на всички съобщения паралелно – това ще позволи да се избегне линейния ръст на времето в зависимост от броя на съобщенията. Това не е особено добър път, тъй като ще доведе до голям пик на натоварването на външния сервис.

Внедряването на паралелна обработка е изключително лесно:

override fun getLast(n: Int) =
  messageRepository.findLast(n).parallelStream()
    .map { то.toFrontModel() }
    .collect(toList())

Чрез паралелната обработка на съобщения, можем да постигнем 300–700 мс в идеални условия, което е значително по-добре от наивна реализация, но все пак не е достатъчно бързо.

При този подход, заявките към userRepository и fileRepository ще се изпълняват синхронно, което не е особено ефективно. За да се поправи това, ще е нужно значително да се промени логиката на извикванията. Например, чрез CompletionStage (известен още като CompletableFuture):

private fun СъобщениеЧат.toFrontModel(): Интерфейс за чат съобщения =
  CompletableFuture.supplyAsync {
    userRepository.getUserById(author).toFrontReference()
  }.thenCombine(
    files?.let {
      CompletableFuture.supplyAsync {
        fileRepository.getHeadsByIds(files).карта { то.toFrontReference() }
      }
    } ?: CompletableFuture.completedFuture(списъкНа())
  ) { author, файлове ->
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$това трябва да бъде запазено"),
      author = author,
      message = message,
      files = файлове,
      forwardFrom = forwardFrom?.toFrontModel(),
      replyTo = replyTo?.toFrontModel()
    )
  }.get()!!

От видно е, че първоначално простият код за мапинг стана по-малко разбираем. Това се дължи на факта, че трябваше да отделим извикванията на външни услуги от местата, където се използват резултатите. Самото по себе си това не е лошо. Но комбинирането на извиквания изглежда не особено елегантно и напомня типичната реактивна "лапша".

Ако използваме корутини, всичко ще изглежда по-добре:

private fun СъобщениеЧат.toFrontModel(): Интерфейс за чат съобщения =
  присъединете се(
    { userRepository.getUserById(author).toFrontReference() },
    { files?.let { fileRepository.getHeadsByIds(files)
      .карта { то.toFrontReference() } } ?: списъкНа() }
  ).let { (автор, файлове) ->
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$това трябва да бъде запазено"),
      автор = автор,
      message = message,
      файлове = файлове,
      forwardFrom = forwardFrom?.toFrontModel(),
      replyTo = replyTo?.toFrontModel()
    )
  }

Където:

забавление <A, Б> присъединете се(a: () -> A, b: () -> Б) =
  runBlocking(IO) {
    awaitAll(асинхронно { a() }, асинхронно { b() })
  }.let {
    то[0] като A към то[1] като Б
  }

Теоретично, използвайки такава паралелна обработка, можем да постигнем 200–400 мс, което вече е близо до нашите очаквания.

За съжаление, такова добро разпаралеляване не съществува, а и последиците са доста сурови: при едновременна работа на само няколко потребители, услугите ще бъдат засипани от порой от заявки, които все пак няма да се обработват паралелно, така че ще се върнем обратно на нашите тъжни 4 секунди.

Моят резултат при използване на такъв сервис е 1300–1700 мс за обработка на 20 съобщения. Това е по-бързо от първата реализация, но все пак не решава проблема.

Алтернативно приложение на паралелни заявкиКакво ще стане, ако в страничните услуги не е предвидена пакетна обработка? Например, можем да скрием липсата на реализирана пакетна обработка в методите на интерфейсите:

интерфейс UserRemoteApi {
  забавление getUserById(id: ПотребителскаСправка): UserRemote
  забавление getUsersByIds(id: Set<ПотребителскаСправка>): Set<UserRemote> =
    id.parallelStream()
      .map { getUserById(то) }.collect(toSet())
  забавление getUsersByIds(id: Списък<ПотребителскаСправка>): Списък<UserRemote> =
    id.parallelStream()
      .map { getUserById(то) }.collect(toList())
}

Това има смисъл, ако имаме надежда за появата на пакетна обработка в следващите версии.
Плюсове:

  1. Лесно внедряване на паралелна обработка на съобщения.
  2. Добра мащабируемост.

Недостатъци:

  1. Необходимост от отделяне на получаването на данни от тяхната обработка при паралелна обработка на заявки към различни услуги.
  2. Повишена натовареност на страничните услуги.

Ясно е, че обхватът на приложимост е донякъде подобен на наивния подход. Използването на метода на паралелни заявки има смисъл само ако искате многократно да увеличите производителността на услугата си за сметка на безмилостната експлоатация на чужди ресурси. В нашия пример производителността нарасна с 2,5 пъти, но това е явно недостатъчно.

Кеширане

Може да се направи кеширане в духа на JPA за външни услуги, тоест в рамките на сесията да се съхраняват получените обекти, за да не се търсят отново (включително при пакетна обработка). Можете да направите такива кешове сами, можете да използвате Spring с @Cacheable, а винаги можете да използвате готов кеш като EhCache ръчно.

Общият проблем ще бъде свързан с това, че кешовете дават полза само при наличието на попадения. В нашия случай вероятността за попадения по полето author е доста висока (да предположим, 50%), а попадения по файловете няма да има изобщо. Този подход ще даде известни подобрения, но няма да промени радикално производителността (а ни трябва пробив).

Междусесийните (дълги) кешове изискват сложна логика за инвалидация. Всъщност, колкото по-късно решите да се справяте с проблемите на производителността, използвайки междусесийни кешове, толкова по-добре.

Плюсове:

  1. Внедряване на кеширане без промяна на кода.
  2. Увеличение на производителността многократно (в някои случаи).

Недостатъци:

  1. Възможност за намаляване на производителността при неправилна употреба.
  2. Високи разходи за памет, особено с дълги кешове.
  3. Сложна инвалидация, чийто грешки биха довели до трудно воспроизводими проблеми в реално време.

Много често кешовете се използват само за бързо запълване на проблеми с проектирането. Това не означава, че не трябва да се използват. Въпреки това, винаги трябва да се отнасяте към тях с внимание и първо да оцените полученото увеличение на производителността, а чак след това да вземете решение.

В нашия пример кешовете ще доведат до увеличаване на производителността с около 25%. Важно е да се отбележи, че кешовете имат доста недостатъци, затова аз не бих ги използвал тук.

Итог

Така че, разгледахме наивната реализация на услугата, използваща пакетна обработка, и няколко прости начина за ускоряването ѝ.

Основното предимство на всички тези методи е простотата, от която произтичат много приятни следствия.

Общата проблема на тези методи е ниската производителност, свързана предимно с размера на пакетите. Така че, ако тези решения не ви подхождат, заслужава си да разгледате по-радикални методи.

Има две основни направления, в които можете да търсите решения:

  • асинхронна работа с данни (изисква смяна на парадигмата, затова в тази статия не се разглежда);
  • угрупняване на пакетите, запазвайки синхронната обработка.

Угрупняването на пакетите ще позволи значително да се намали броят на външните повиквания, като същевременно запази кода синхронен. На тази тема ще бъде посветена следващата част на статията.

Източник: habr.com

Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри 🔥 Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри | ProHoster