Проблеми на сериска обработка на барања и нивни решенија (дел 1)

Проблеми на сериска обработка на барања и нивни решенија (дел 1)Речиси сите современи софтверски производи се состојат од неколку услуги. Честопати, долгите времиња на одговор на меѓусервисните канали стануваат извор на проблеми со изведбата. Стандардно решение за овој вид на проблем е да се спакуваат повеќе интерсервисни барања во еден пакет, што се нарекува серија.

Ако користите сериска обработка, можеби нема да бидете задоволни со резултатите во однос на перформансите или јасноста на кодот. Овој метод не е толку лесен за повикувачот како што мислите. За различни цели и во различни ситуации, решенијата може многу да варираат. Користејќи конкретни примери, ќе ги покажам добрите и лошите страни на неколку пристапи.

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

За јасност, ајде да погледнеме пример на една од услугите во апликацијата на која работам моментално.

Објаснување на изборот на платформа за примериПроблемот со слабите перформанси е прилично општ и не влијае на ниту еден специфични јазици или платформи. Оваа статија ќе користи примери за кодови Spring + Kotlin за да ги демонстрира проблемите и решенијата. Котлин е подеднакво разбирлив (или неразбирлив) за развивачите на Java и C#, покрај тоа, кодот е покомпактен и разбирлив отколку во Java. За да биде полесно да се разбере за чистите програмери на Јава, ќе ја избегнам црната магија на Котлин и ќе ја користам само белата магија (во духот на Ломбок). Ќе има неколку методи за проширување, но тие всушност им се познати на сите Java програмери како статични методи, така што ова ќе биде мал шеќер што нема да го расипе вкусот на садот.
Постои услуга за одобрување документи. Некој креира документ и го доставува на дискусија, при што се прават уредувања и на крајот се договара документот. Самата услуга за одобрување не знае ништо за документи: тоа е само разговор на одобрувачи со мали дополнителни функции што нема да ги разгледаме овде.

Значи, постојат виртуелни простории за разговор (што одговараат на документи) со однапред дефиниран сет на учесници во секоја од нив. Како и во редовните разговори, пораките содржат текст и датотеки и можат да бидат одговори или проследени:

data class ChatMessage(
  // nullable так как появляется только после persist
  val id: Long? = null,
  /** Ссылка на автора */
  val author: UserReference,
  /** Сообщение */
  val message: String,
  /** Ссылки на аттачи */
  // из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
  val files: List<FileReference>? = null,
  /** Если является ответом, то здесь будет оригинал */
  val replyTo: ChatMessage? = null,
  /** Если является пересылкой, то здесь будет оригинал */
  val forwardFrom: ChatMessage? = null
)

Датотеките и корисничките врски се врски до други домени. Овде живееме вака:

typealias FileReference Long
typealias UserReference Long

Корисничките податоци се складираат во Keycloak и се преземаат преку REST. Истото важи и за датотеките: датотеките и метаинформациите за нив живеат во посебна услуга за складирање датотеки.

Сите повици кон овие услуги се тешки барања. Ова значи дека трошоците за транспорт на овие барања се многу поголеми од времето потребно за да бидат обработени од трета страна. На нашите тест клупи, типичното време на повик за такви услуги е 100 ms, така што ќе ги користиме овие броеви во иднина.

Треба да направиме едноставен REST контролер за да ги примаме последните N пораки со сите потребни информации. Односно, ние веруваме дека моделот на пораки во предниот дел е речиси ист и сите податоци треба да се испратат. Разликата помеѓу предниот модел е во тоа што датотеката и корисникот треба да бидат претставени во малку дешифрирана форма за да се направат врски:

/** В таком виде отдаются ссылки на сущности для фронта */
data class ReferenceUI(
  /** Идентификатор для url */
  val ref: String,
  /** Видимое пользователю название ссылки */
  val name: String
)
data class ChatMessageUI(
  val id: Long,
  /** Ссылка на автора */
  val author: ReferenceUI,
  /** Сообщение */
  val message: String,
  /** Ссылки на аттачи */
  val files: List<ReferenceUI>,
  /** Если являтся ответом, то здесь будет оригинал */
  val replyTo: ChatMessageUI? = null,
  /** Если являтся пересылкой, то здесь будет оригинал */
  val forwardFrom: ChatMessageUI? = null
)

Треба да го спроведеме следново:

interface ChatRestApi {
  fun getLast(nInt): List<ChatMessageUI>
}

UI postfix значи DTO модели за предниот дел, односно она што треба да го сервираме преку REST.

Она што може да биде изненадувачки овде е што не пренесуваме никаков проект за разговор, па дури и моделот ChatMessage/ChatMessageUI нема таков. Ова го направив намерно за да не ја натрупувам шифрата на примерите (чатовите се изолирани, па може да претпоставиме дека имаме само еден).

Филозофска дигресијаИ класата ChatMessageUI и методот ChatRestApi.getLast го користат типот на податоци List кога всушност тоа е подредено множество. Ова е лошо во JDK, така што објавувањето на редоследот на елементите на ниво на интерфејс (зачувување на редоследот при додавање и отстранување) нема да работи. Така, стана вообичаена практика да се користи Листа во случаи кога е потребно нарачано множество (има и LinkedHashSet, но ова не е интерфејс).
Важно ограничување: Ќе претпоставиме дека нема долги синџири на одговори или трансфери. Тоа е, тие постојат, но нивната должина не надминува три пораки. Целиот синџир на пораки мора да се пренесе до предниот дел.

За да примате податоци од надворешни услуги, постојат следниве API:

interface ChatMessageRepository {
  fun findLast(nInt): List<ChatMessage>
}
data class FileHeadRemote(
  val id: FileReference,
  val name: String
)
interface FileRemoteApi {
  fun getHeadById(idFileReference): FileHeadRemote
  fun getHeadsByIds(idSet<FileReference>): Set<FileHeadRemote>
  fun getHeadsByIds(idList<FileReference>): List<FileHeadRemote>
  fun getHeadsByChat(): List<FileHeadRemote>
}
data class UserRemote(
  val id: UserReference,
  val name: String
)
interface UserRemoteApi {
  fun getUserById(idUserReference): UserRemote
  fun getUsersByIds(idSet<UserReference>): Set<UserRemote>
  fun getUsersByIds(idList<UserReference>): List<UserRemote>
}

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

Едноставни имплементации

Наивна имплементација

Првата наивна имплементација на нашиот REST контролер ќе изгледа вака во повеќето случаи:

class ChatRestController(
  private val messageRepository: ChatMessageRepository,
  private val userRepository: UserRemoteApi,
  private val fileRepository: FileRemoteApi
) : ChatRestApi {
  override fun getLast(nInt) =
    messageRepository.findLast(n)
      .map it.toFrontModel() }
  
  private fun ChatMessage.toFrontModel(): ChatMessageUI =
    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 за да се добијат податоци за полето на авторот и еден повик до FileRemoteApi за да се добијат сите прикачени датотеки. Се чини дека тоа е тоа. Да речеме дека полињата forwardFrom и replyTo за ChatMessage се добиени на таков начин што ова не бара непотребни повици. Но, нивното претворање во ChatMessageUI ќе доведе до рекурзија, односно бројачите на повици може значително да се зголемат. Како што забележавме претходно, да претпоставиме дека немаме многу гнезда и ланецот е ограничен на три пораки.

Како резултат на тоа, ќе добиеме од два до шест повици до надворешни услуги по порака и еден JPA повик за целиот пакет пораки. Вкупниот број на повици ќе варира од 2*N+1 до 6*N+1. Колку е ова во реални единици? Да речеме дека се потребни 20 пораки за да се прикаже страница. За да ги примите, ќе бидат потребни од 4 до 10 секунди. Страшно! Би сакал да го задржам во рок од 500 ms. И бидејќи тие сонуваа да направат беспрекорно лизгање во предниот дел, барањата за изведба за оваа крајна точка може да се удвојат.

Позитивни:

  1. Кодот е концизен и самодокументиран (сон на тимот за поддршка).
  2. Кодот е едноставен, така што речиси и да нема можности да си пукате во нога.
  3. Сериската обработка не изгледа како нешто туѓо и органски е интегрирана во логиката.
  4. Логичките промени ќе се прават лесно и ќе бидат локални.

Минус:

Ужасни перформанси поради многу мали пакетчиња.

Овој пристап може да се види доста често во едноставни услуги или во прототипови. Ако брзината на правење промени е важна, тешко дека вреди да се комплицира системот. Во исто време, за нашата многу едноставна услуга перформансите се ужасни, така што опсегот на применливост на овој пристап е многу тесен.

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

Можете да започнете паралелно да ги обработувате сите пораки - ова ќе ви овозможи да се ослободите од линеарното зголемување на времето во зависност од бројот на пораки. Ова не е особено добра патека бидејќи ќе резултира со големо максимално оптоварување на надворешната услуга.

Спроведувањето на паралелна обработка е многу едноставно:

override fun getLast(nInt) =
  messageRepository.findLast(n).parallelStream()
    .map it.toFrontModel() }
    .collect(toList())

Користејќи паралелна обработка на пораки, идеално добиваме 300–700 ms, што е многу подобро отколку со наивна имплементација, но сепак не доволно брзо.

Со овој пристап, барањата до userRepository и fileRepository ќе се извршуваат синхроно, што не е многу ефикасно. За да го поправите ова, ќе треба многу да ја менувате логиката на повикот. На пример, преку CompletionStage (познато како CompletableFuture):

private fun ChatMessage.toFrontModel(): ChatMessageUI =
  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 ChatMessage.toFrontModel(): ChatMessageUI =
  join(
    userRepository.getUserById(author).toFrontReference() },
    files?.let fileRepository.getHeadsByIds(files)
      .map it.toFrontReference() } } ?: listOf() }
  ).let (author, files) ->
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$this must be persisted"),
      author = author,
      message = message,
      files = files,
      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 ms, што е веќе блиску до нашите очекувања.

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

Мојот резултат кога користам таква услуга е 1300–1700 ms за обработка на 20 пораки. Ова е побрзо отколку во првата имплементација, но сепак не го решава проблемот.

Алтернативни употреби на паралелни прашањаШто ако услугите од трети страни не обезбедуваат сериска обработка? На пример, можете да го скриете недостатокот на имплементација на сериска обработка во интерфејс методите:

interface UserRemoteApi {
  fun getUserById(idUserReference): UserRemote
  fun getUsersByIds(idSet<UserReference>): Set<UserRemote> =
    id.parallelStream()
      .map getUserById(it}.collect(toSet())
  fun getUsersByIds(idList<UserReference>): List<UserRemote> =
    id.parallelStream()
      .map getUserById(it}.collect(toList())
}

Ова има смисла ако се надевате дека ќе видите сериска обработка во идните верзии.
Позитивни:

  1. Лесно имплементирајте паралелна обработка базирана на пораки.
  2. Добра приспособливост.

Конс:

  1. Потребата да се одвои стекнувањето податоци од нивната обработка при паралелна обработка на барањата до различни услуги.
  2. Зголемено оптоварување на услуги од трети страни.

Може да се види дека опсегот на применливост е приближно ист како оној на наивниот пристап. Има смисла да се користи методот на паралелно барање ако сакате да ги зголемите перформансите на вашата услуга неколку пати поради безмилосно искористување на другите. Во нашиот пример, перформансите се зголемија за 2,5 пати, но ова очигледно не е доволно.

кеширање

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

Чест проблем би бил тоа што кешовите се корисни само ако имаат хитови. Во нашиот случај, погодоците на полето за автор се многу веројатни (да речеме, 50%), но воопшто нема да има хитови на датотеките. Овој пристап ќе обезбеди одредени подобрувања, но нема радикално да ги промени перформансите (и потребен ни е пробив).

Пресечните (долги) кешови бараат сложена логика за поништување. Општо земено, колку подоцна се фаќате за решавање на проблемите со перформансите користејќи кешови на интерсесија, толку подобро.

Позитивни:

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

Конс:

  1. Можност за намалени перформанси доколку се користи неправилно.
  2. Голема меморија преку глава, особено со долги кешови.
  3. Комплексна неважење, грешки во кои ќе доведат до проблеми кои тешко се репродуцираат во времето на траење.

Многу често, кешовите се користат само за брзо решавање на проблемите со дизајнот. Ова не значи дека не треба да се користат. Сепак, секогаш треба да ги третирате со претпазливост и прво да ја процените добиената добивка во перформансите и дури потоа да донесете одлука.

Во нашиот пример, кешовите ќе обезбедат зголемување на перформансите од околу 25%. Во исто време, кешовите имаат доста недостатоци, па затоа не би ги користел овде.

Резултатите од

Значи, погледнавме наивна имплементација на услуга која користи сериска обработка и неколку едноставни начини за нејзино забрзување.

Главната предност на сите овие методи е едноставноста, од која има многу пријатни последици.

Чест проблем со овие методи се слабите перформанси, пред се поради големината на пакетите. Затоа, ако овие решенија не ви одговараат, тогаш вреди да се разгледаат порадикални методи.

Постојат две главни насоки во кои можете да барате решенија:

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

Зголемувањето на сериите значително ќе го намали бројот на надворешни повици и во исто време ќе го задржи кодот синхрон. Следниот дел од статијата ќе биде посветен на оваа тема.

Извор: www.habr.com

Додадете коментар