Проблеми групне обраде упита и њихова решења (1. део)

Проблеми групне обраде упита и њихова решења (1. део)Скоро сви савремени софтверски производи се састоје од неколико услуга. Често дуго времена одзива међусервисних канала постају извор проблема са перформансама. Стандардно решење за ову врсту проблема је спаковање више интер-сервисних захтева у један пакет, што се назива батцхинг.

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

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

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

Објашњење избора платформе за примереПроблем лоших перформанси је прилично уопштен и не односи се на неке специфичне језике или платформе. Овај чланак ће користити Спринг + Котлин примере кода да демонстрира проблеме и решења. Котлин је подједнако разумљив (или неразумљив) Јава и Ц# програмерима; поред тога, код је компактнији и разумљивији него у Јави. Да ствари буду лакше разумљиве за чисте Јава програмере, избећи ћу црну магију Котлина и користићу само белу магију (у духу Ломбока). Биће неколико метода проширења, али оне су заправо познате свим Јава програмерима као статичке методе, тако да ће ово бити мали шећер који неће покварити укус јела.
Постоји служба за одобравање докумената. Неко креира документ и предаје га на дискусију, током које се врше измене и на крају се документ усаглашава. Сама служба за одобравање не зна ништа о документима: то је само ћаскање одобравалаца са малим додатним функцијама које овде нећемо разматрати.

Дакле, постоје собе за ћаскање (које одговарају документима) са унапред дефинисаним скупом учесника у свакој од њих. Као иу редовним разговорима, поруке садрже текст и датотеке и могу бити одговори или прослеђени:

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

Кориснички подаци се чувају у Кеицлоак-у и примају преко РЕСТ-а. Исто важи и за датотеке: датотеке и метаинформације о њима живе у посебној услузи за складиштење датотека.

Сви позиви овим службама су тешке захтеве. То значи да су трошкови транспорта ових захтева много већи од времена које је потребно да их обради услуга треће стране. На нашим тестним столовима, типично време позива за такве услуге је 100 мс, тако да ћемо убудуће користити ове бројеве.

Морамо да направимо једноставан РЕСТ контролер да примамо последњих Н порука са свим потребним информацијама. То јест, верујемо да је у фронтенду модел поруке скоро исти и да је потребно послати све податке. Разлика између фронт-енд модела је у томе што датотека и корисник морају бити представљени у благо дешифрованом облику како би били повезани:

/** В таком виде отдаются ссылки на сущности для фронта */
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>
}

Постфик УИ значи ДТО моделе за фронтенд, односно оно што морамо да опслужујемо преко РЕСТ-а.

Оно што овде може бити изненађујуће је да ми не прослеђујемо ниједан идентификатор за ћаскање, па чак ни у моделу ЦхатМессаге/ЦхатМессагеУИ не постоји ниједан. Ово сам урадио намерно да не бих затрпао код примера (четови су изоловани, па можемо претпоставити да имамо само један).

Филозофска дигресијаИ класа ЦхатМессагеУИ и метода ЦхатРестАпи.гетЛаст користе тип података Лист, иако је то у ствари уређен скуп. У ЈДК-у је све ово лоше, тако да декларисање редоследа елемената на нивоу интерфејса (очување редоследа приликом додавања и преузимања) неће радити. Тако је постала уобичајена пракса да се Лист користи у случајевима када је потребан уређени сет (постоји и ЛинкедХасхСет, али ово није интерфејс).
Важно ограничење: Претпоставићемо да нема дугих ланаца одговора или трансфера. Односно, постоје, али њихова дужина не прелази три поруке. Цео ланац порука се мора пренети на фронтенд.

За примање података од спољних услуга постоје следећи АПИ-ји:

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>
}

Види се да екстерни сервиси иницијално обезбеђују пакетну обраду, и то у обе варијанте: преко Сета (без очувања редоследа елемената, са јединственим кључевима) и преко Листе (можда има дупликата – редослед се чува).

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

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

Прва наивна имплементација нашег РЕСТ контролера ће изгледати отприлике овако у већини случајева:

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()
    )
}

Све је врло јасно, а ово је велики плус.

Користимо групну обраду и примамо податке од екстерне услуге у групама. Али шта се дешава са нашом продуктивношћу?

За сваку поруку биће упућен један позив УсерРемотеАпи да би се добили подаци о пољу аутора и један позив ФилеРемотеАпи да би се добиле све приложене датотеке. Изгледа да је то то. Рецимо да су поља форвардФром и реплиТо за ЦхатМессаге добијена на такав начин да то не захтијева непотребне позиве. Али њихово претварање у ЦхатМессагеУИ ће довести до рекурзије, то јест, бројачи позива могу се значајно повећати. Као што смо раније приметили, претпоставимо да немамо много гнежђења и да је ланац ограничен на три поруке.

Као резултат, добићемо од два до шест позива ка екстерним сервисима по поруци и један ЈПА позив за цео пакет порука. Укупан број позива ће варирати од 2*Н+1 до 6*Н+1. Колико је ово у стварним јединицама? Рецимо да је потребно 20 порука да би се приказала страница. Да бисте их добили, биће вам потребно од 4 с до 10 с. Страшно! Желео бих да га задржим унутар 500мс. А пошто су сањали да направе беспрекорно скроловање на фронтенду, захтеви за перформансе за ову крајњу тачку могу се удвостручити.

Предности:

  1. Код је сажет и самодокументован (сан тима за подршку).
  2. Шифра је једноставна, тако да готово да нема могућности да себи пуцате у ногу.
  3. Батцх обрада не изгледа као нешто ванземаљско и органски је интегрисана у логику.
  4. Логичке промене ће се лако направити и биће локалне.

minus:

Ужасне перформансе због веома малих пакета.

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

Наивна паралелна обрада

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

Имплементација паралелне обраде је врло једноставна:

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

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

Са овим приступом, захтеви за усерРепоситори и филеРепоситори ће се извршавати синхроно, што није баш ефикасно. Да бисте ово поправили, мораћете доста да промените логику позива. На пример, преко ЦомплетионСтаге (ака ЦомплетаблеФутуре):

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 мс, што је већ близу нашим очекивањима.

Нажалост, тако добра паралелизација се не дешава, а цена коју треба платити је прилично сурова: са само неколико корисника који раде у исто време, услуге ће бити погођене налетом захтева који ионако неће бити обрађени паралелно, тако да ми вратиће се у наше тужне 4с.

Мој резултат при коришћењу таквог сервиса је 1300–1700 мс за обраду 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 пута, али то очигледно није довољно.

кеширање

Можете да урадите кеширање у духу ЈПА за екстерне услуге, односно да сачувате примљене објекте у оквиру сесије како их не бисте поново примили (укључујући и током групне обраде). Можете сами да направите такве кеш меморије, можете да користите Спринг са његовим @Цацхеабле, плус увек можете ручно да користите готову кеш меморију као што је ЕхЦацхе.

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

Међусесиони (дуги) кешови захтевају сложену логику поништавања. Уопштено говорећи, што касније пређете на решавање проблема са перформансама користећи кеш меморије међу сесијама, то боље.

Предности:

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

Против:

  1. Могућност смањења перформанси ако се користи неправилно.
  2. Велика количина меморије, посебно са дугим кеш меморијама.
  3. Сложено поништавање, грешке у којима ће довести до тешко репродуцираних проблема у току рада.

Врло често се кешови користе само за брзо отклањање проблема са дизајном. То не значи да их не треба користити. Међутим, увек треба да се према њима односите са опрезом и прво процените резултатски добитак у перформансама, па тек онда донесете одлуку.

У нашем примеру, кешови ће обезбедити повећање перформанси од око 25%. У исто време, кешови имају доста недостатака, тако да их овде не бих користио.

Резултати

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

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

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

Постоје два главна правца у којима можете тражити решења:

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

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

Извор: ввв.хабр.цом

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