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

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

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

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

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

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

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

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

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

тип алиас Справка за файла Дълъг
тип алиас Потребителска справка Дълъг

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

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

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

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

Трябва да реализираме следното:

интерфейс ЧатRestApi {
  fun вземиПоследно(nInt): Списък<ЧатСъобщениеUI>
}

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

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

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

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

интерфейс ChatMessageRepository {
  fun findLast(nInt): Списък<Съобщение в чата>
}
данни клас FileHeadRemote(
  val ид: Справка за файла,
  val име: String
)
интерфейс FileRemoteApi {
  fun getHeadById(идСправка за файла): FileHeadRemote
  fun getHeadsByIds(идЗадай<Справка за файла): Задай<FileHeadRemote>
  fun getHeadsByIds(идСписък<Справка за файла): Списък<FileHeadRemote>
  fun getHeadsByChat(): Списък<FileHeadRemote>
}
данни клас UserRemote(
  val ид: Потребителска справка,
  val име: String
)
интерфейс UserRemoteApi {
  fun getUserById(идПотребителска справка): UserRemote
  fun getUsersByIds(идЗадай<Потребителска справка): Задай<UserRemote>
  fun getUsersByIds(идСписък<Потребителска справка): Списък<UserRemote>
}

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

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

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

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

class ChatRestController(
  private val messageRepository: ChatMessageRepository,
  private val userRepository: UserRemoteApi,
  private val fileRepository: FileRemoteApi
) : ЧатRestApi {
  override fun вземиПоследно(nInt) =
    messageRepository.findLast(n)
      .map it.toFrontModel() }
  
  private fun Съобщение в чата.toFrontModel(): ЧатСъобщениеUI =
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$this must be persisted"),
      author = userRepository.getUserById(author).toFrontReference(),
      message = message,
      files = files?.let files ->
        fileRepository.getHeadsByIds(files)
          .map it.toFrontReference() }
      } ?: listOf(),
      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 вземиПоследно(nInt) =
  messageRepository.findLast(n).паралелноПоток()
    .мап it.toFrontModel() }
    .събери(къмСписък())

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

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

private fun Съобщение в чата.toFrontModel(): ЧатСъобщениеUI =
  CompletableFuture.supplyAsync {
    userRepository.getUserById(author).toFrontReference()
  }.thenCombine(
    files?.let {
      CompletableFuture.supplyAsync {
        fileRepository.getHeadsByIds(files).map it.toFrontReference() }
      }
    } ?: CompletableFuture.completedFuture(listOf())
  ) authorfiles ->
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$this must be persisted"),
      author = author,
      message = message,
      files = files,
      forwardFrom = forwardFrom?.toFrontModel(),
      replyTo = replyTo?.toFrontModel()
    )
  }.get()!!

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

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

private fun Съобщение в чата.toFrontModel(): ЧатСъобщениеUI =
  join(
    userRepository.getUserById(author).toFrontReference() },
    files?.let fileRepository.getHeadsByIds(файлове)
      .map it.toFrontReference() } } ?: listOf() }
  ).let (автор, файлове) ->
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$this must be persisted"),
      автор = автор,
      message = message,
      файлове = файлове,
      forwardFrom = forwardFrom?.toFrontModel(),
      replyTo = replyTo?.toFrontModel()
    )
  }

Където:

fun <ABjoin(a: () -> Ab: () -> B) =
  runBlocking(IO{
    awaitAll(async a() }async b() })
  }.let {
    it[0as to it[1as B
  }

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

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

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

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

интерфейс UserRemoteApi {
  fun getUserById(идПотребителска справка): UserRemote
  fun getUsersByIds(идЗадай<Потребителска справка): Задай<UserRemote> =
    ид.parallelStream()
      .мап getUserById(it}.collect(toSet())
  fun getUsersByIds(идСписък<Потребителска справка): Списък<UserRemote> =
    ид.parallelStream()
      .мап getUserById(it}.събери(къмСписък())
}

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

  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