Праблемы пакетнай апрацоўкі запытаў і іх рашэнні (частка 1)

Праблемы пакетнай апрацоўкі запытаў і іх рашэнні (частка 1)Практычна ўсе сучасныя праграмныя прадукты складаюцца з некалькіх сэрвісаў. Часта вялікі час водгуку міжсэрвісных каналаў становіцца крыніцай праблем з прадукцыйнасцю. Стандартнае рашэнне такога роду праблем - гэта ўпакоўка некалькіх міжсэрвісных запытаў у адзін пакет, якую называюць пакетнай апрацоўкай (batching).

Калі вы выкарыстоўваеце пакетную апрацоўку, вас можа не ўладкоўваць яе вынік з пункта гледжання прадукцыйнасці або зразумеласці кода. Гэты метад не так просты для задзірлівага боку, як можна падумаць. Для розных мэт і ў розных сітуацыях рашэнні могуць моцна адрознівацца. На канкрэтных прыкладах я пакажу плюсы і мінусы некалькіх падыходаў.

Дэманстрацыйны праект

Для нагляднасці разгледзім прыклад аднаго з сэрвісаў у дадатку, над якім я цяпер працую.

Тлумачэнне па выбары платформы для прыкладаўПраблема дрэннай прадукцыйнасці дастаткова агульная і не тычыцца нейкіх канкрэтных моў і платформаў. У гэтым артыкуле для дэманстрацыі задач і рашэнняў будуць выкарыстоўвацца прыклады кода на Spring + Kotlin. Kotlin аднолькава зразумелы (або незразумелы) Java-і C#- распрацоўнікам, акрамя таго, код атрымліваецца больш кампактным і зразумелым, чым на Java. Каб палегчыць разуменне для чыстых Java-распрацоўнікаў, я буду пазбягаць чорнай магіі Kotlin і выкарыстоўваць толькі белую (у духу Lombok). Будзе трохі extension-метадаў, але яны насамрэч знаёмыя ўсім Java-праграмістам як static-метады, так што гэта будзе невялікім цукрам, які не сапсуе густ стравы.
Ёсць сэрвіс узгаднення дакументаў. Хтосьці стварае дакумент і выносіць яго на абмеркаванне, у працэсе якога робяцца праўкі, і ў канчатковым выніку дакумент адпавядае. Сам сэрвіс узгаднення нічога не ведае пра дакументы: гэта проста чат якія ўзгадняюць з невялікімі дадатковымі функцыямі, якія мы тут разглядаць не будзем.

Такім чынам, ёсць пакоі чатаў (адпавядаюць дакументам) з вызначаным наборам удзельнікаў у кожнай з іх. Як у звычайных чатах, паведамленні ўтрымоўваюць тэкст і файлы і могуць быць адказамі (reply) і перасылкамі (forward):

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 мс, так што ў далейшым будзем выкарыстоўваць гэтыя лічбы.

Нам трэба зрабіць просты 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 азначае DTO-мадэлькі для фронтэнда, гэта значыць тое, што мы павінны аддаць праз REST.

Тут можа здацца дзіўным тое, што мы не перадаем ніякага ідэнтыфікатара чата і нават у мадэлі ChatMessage/ChatMessageUI яго няма. Я зрабіў гэта наўмысна, каб не загрувашчваць код прыкладаў (чаты ізаляваныя, так што можна лічыць, што ў нас ён наогул адзін).

Філасофскі адступІ ў класе ChatMessageUI, і ў метады ChatRestApi.getLast выкарыстоўваецца тып дадзеных List, тады як на самай справе гэта спарадкаваны Set. У JDK з гэтым усё дрэнна, таму дэклараваць парадак элементаў на ўзроўні інтэрфейсу (захаванне парадку пры даданні і выманні) не атрымаецца. Так што агульнай практыкай стала выкарыстанне List у тых выпадках, калі патрэбен спарадкаваны Set (яшчэ ёсць 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 для атрымання дадзеных па полі author і адзін выклік FileRemoteApi для атрымання ўсіх прыкладзеных файлаў. Здаецца, усё. Дапушчальны, што палі forwardFrom і replyTo для ChatMessage атрымліваюцца так, што гэта не запатрабуе лішніх выклікаў. Але вось ператварэнне іх у Chat MessageUI прывядзе да рэкурсіі, гэта значыць паказчыкі лічыльнікаў выклікаў могуць моцна вырасці. Як мы адзначылі раней, дапусцім, што вялікай укладзенасці ў нас не бывае і ланцужок абмежаваны трыма паведамленнямі.

У выніку атрымаем ад двух да шасці выклікаў знешніх сэрвісаў на адно паведамленне і адзін JPA-выклік на ўвесь пакет паведамленняў. Агульная колькасць выклікаў будзе вар'іравацца ад 2*N+1 да 6*N+1. Колькі гэта ў рэальных адзінках? Дапусцім, для адмалёўкі старонкі трэба 20 паведамленняў. Каб іх атрымаць, спатрэбіцца ад 4 c да 10 с. Жахліва! Хацелася б укласціся ў 500 мс. А паколькі ва Франтэндзе марылі зрабіць бясшвоўны скролл, патрабаванні да прадукцыйнасці гэтага endpoint можна падвойваць.

Плюсы:

  1. Код кароткі і самадакументуемы (мара сапарта).
  2. Код просты, таму магчымасцяў стрэліць у нагу амаль няма.
  3. Пакетная апрацоўка не выглядае чымсьці чужародным і арганічна ўпісана ў логіку.
  4. Змены логікі будуць уносіцца лёгка і будуць лакальнымі.

мінус:

Жудасная прадукцыйнасць, злучаная з тым, што пакеты атрымліваюцца вельмі маленькімі.

Такі падыход досыць часта можна ўбачыць у простых сэрвісах або ў прататыпах. Калі важная хуткасць занясення змен, ці наўрад варта ўскладняць сістэму. У той жа час для нашага вельмі простага сэрвісу прадукцыйнасць атрымліваецца жудаснай, так што рамкі дастасавальнасці ў такога падыходу вельмі вузкія.

Наіўная паралельная апрацоўка

Можна запусціць апрацоўку ўсіх паведамленняў паралельна - гэта дазволіць пазбавіцца ад лінейнага росту часу ў залежнасці ад колькасці паведамленняў. Гэта не асабліва добры шлях, таму што ён прывядзе да вялікай пікавай нагрузкі на знешні сэрвіс.

Укараніць паралельную апрацоўку вельмі проста:

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

Выкарыстоўваючы паралельную апрацоўку паведамленняў, атрымаем 300-700 мc у ідэале, што нашмат лепш, чым пры наіўнай рэалізацыі, але па-ранейшаму недастаткова хутка.

Пры такім падыходзе запыты да userRepository і fileRepository будуць выконвацца сінхронна, што не вельмі эфектыўна. Каб гэта выправіць, давядзецца дастаткова моцна змяніць логіку выклікаў. Напрыклад, праз CompletionStage (aka 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 мc, што ўжо блізка да нашых чаканняў.

На жаль, такога добрага распаралельвання не бывае, ды і расплата даволі жорсткая: пры адначасовай працы ўсяго некалькіх карыстальнікаў на сэрвісы абрынецца шквал запытаў, якія ўсё роўна не будуць апрацоўвацца паралельна, так што мы вернемся да нашых сумных 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 разы, але гэтага відавочна недастаткова.

кэшаванне

Можна зрабіць кэшаванне ў духу JPA для вонкавых сэрвісаў, гэта значыць у рамках сесіі захоўваць атрыманыя аб'екты, каб не атрымліваць іх яшчэ раз (у тым ліку пры пакетнай апрацоўцы). Можна зрабіць такія кэшы самому, можна выкарыстоўваць Spring з яго @Cacheable, плюс заўсёды можна выкарыстоўваць гатовы кэш накшталт EhCache уручную.

Агульная праблема будзе звязана з тым, што ад кэшаў ёсць толк, толькі калі ёсць трапленні. У нашым выпадку вельмі верагодныя трапленні па полі author (дапусцім, 50%), а трапленняў па файлах не будзе наогул. Некаторыя паляпшэнні гэты падыход дасць, але радыкальна прадукцыйнасць не зменіць (а нам патрэбен прарыў).

Міжсесійныя (доўгія) кэшы патрабуюць складанай логікі інвалідацыі. Наогул, чым пазней вы скаціцеся да таго, што будзеце вырашаць праблемы прадукцыйнасці з дапамогай міжсесійных кэшаў, тым лепш.

Плюсы:

  1. Укараненне кэшавання без змены кода.
  2. Прырост прадукцыйнасці ў некалькі разоў (у некаторых выпадках).

Мінусы:

  1. Магчымасць зніжэння прадукцыйнасці пры няправільным выкарыстанні.
  2. Вялікія накладныя выдаткі памяці, асабліва з доўгімі кэшамі.
  3. Складаная інвалідацыя, памылкі ў якой будуць прыводзіць да цяжкаўзнаўляльных праблем у рантайме.

Вельмі часта кэшы выкарыстоўваюцца толькі для таго, каб хутка залатаць праблемы праектавання. Гэта не азначае, што іх не трэба выкарыстоўваць. Аднак заўсёды варта ставіцца да іх з асцярожнасцю і спачатку ацэньваць атрыманы прырост прадукцыйнасці, а ўжо потым прымаць рашэнне.

У нашым прыкладзе ад кэшаў будзе прырост прадукцыйнасці ў раёне 25%. Пры гэтым мінусаў у кэшаў даволі шмат, так што я б не стаў іх тут выкарыстоўваць.

Вынікі

Такім чынам, мы разгледзелі наіўную рэалізацыю сэрвісу, які выкарыстоўвае пакетную апрацоўку, і некалькі простых спосабаў яе паскорыць.

Галоўная добрая якасць усіх гэтых метадаў - прастата, з якой ёсць шмат прыемных следстваў.

Агульнай праблемай гэтых спосабаў з'яўляецца дрэнная прадукцыйнасць, звязаная ў першую чаргу з памерам пакетаў. Таму калі гэтыя рашэнні вам не падыдуць, то варта разгледзець больш радыкальныя метады.

Ёсць два асноўныя напрамкі, у якіх можна пашукаць рашэнні:

  • асінхронная праца з дадзенымі (патрабуе змены парадыгмы, таму ў гэтым артыкуле не разглядаецца);
  • узбуйненне пачкаў пры захаванні сінхроннай апрацоўкі.

Узбуйненне пачкаў дазволіць моцна скараціць колькасць вонкавых выклікаў і пры гэтым захаваць код сінхронным. Гэтай тэме будзе прысвечана наступная частка артыкула.

Крыніца: habr.com

Дадаць каментар