Mga problema sa pagpoproseso ng batch na query at mga solusyon ng mga ito (bahagi 1)

Mga problema sa pagpoproseso ng batch na query at mga solusyon ng mga ito (bahagi 1)Halos lahat ng mga modernong produkto ng software ay binubuo ng ilang mga serbisyo. Kadalasan, ang mahabang oras ng pagtugon ng mga interservice channel ay nagiging mapagkukunan ng mga problema sa pagganap. Ang karaniwang solusyon sa ganitong uri ng problema ay ang pag-pack ng maramihang mga kahilingan sa interservice sa isang pakete, na tinatawag na batching.

Kung gumagamit ka ng batch processing, maaaring hindi ka nasisiyahan sa mga resulta sa mga tuntunin ng pagganap o kalinawan ng code. Ang pamamaraang ito ay hindi kasingdali ng tumatawag gaya ng iniisip mo. Para sa iba't ibang layunin at sa iba't ibang sitwasyon, ang mga solusyon ay maaaring mag-iba nang malaki. Gamit ang mga partikular na halimbawa, ipapakita ko ang mga kalamangan at kahinaan ng ilang mga diskarte.

Demonstration project

Para sa kalinawan, tingnan natin ang isang halimbawa ng isa sa mga serbisyo sa application na kasalukuyang ginagawa ko.

Paliwanag ng pagpili ng platform para sa mga halimbawaAng problema ng mahinang pagganap ay medyo pangkalahatan at hindi nakakaapekto sa anumang partikular na mga wika o platform. Gagamitin ng artikulong ito ang mga halimbawa ng Spring + Kotlin code para magpakita ng mga problema at solusyon. Ang Kotlin ay pantay na naiintindihan (o hindi maintindihan) sa mga developer ng Java at C#, bilang karagdagan, ang code ay mas compact at naiintindihan kaysa sa Java. Para mas madaling maunawaan para sa mga purong Java developer, iiwasan ko ang black magic ng Kotlin at gagamitin ko lang ang white magic (sa diwa ng Lombok). Magkakaroon ng ilang mga paraan ng extension, ngunit ang mga ito ay talagang pamilyar sa lahat ng Java programmer bilang mga static na pamamaraan, kaya ito ay magiging isang maliit na asukal na hindi masisira ang lasa ng ulam.
Mayroong serbisyo sa pag-apruba ng dokumento. May lumikha ng isang dokumento at isinumite ito para sa talakayan, kung saan ang mga pag-edit ay ginawa, at sa huli ang dokumento ay napagkasunduan. Ang serbisyo ng pag-apruba mismo ay walang alam tungkol sa mga dokumento: ito ay isang chat lamang ng mga approver na may maliliit na karagdagang function na hindi namin isasaalang-alang dito.

Kaya, may mga chat room (naaayon sa mga dokumento) na may paunang natukoy na hanay ng mga kalahok sa bawat isa sa kanila. Tulad ng sa mga regular na chat, naglalaman ang mga mensahe ng text at mga file at maaaring mga tugon o pasulong:

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
)

Ang mga link ng file at user ay mga link sa ibang mga domain. Dito tayo nabubuhay ng ganito:

typealias FileReference Long
typealias UserReference Long

Ang data ng user ay naka-imbak sa Keycloak at kinukuha sa pamamagitan ng REST. Ang parehong napupunta para sa mga file: ang mga file at metainformation tungkol sa mga ito ay nakatira sa isang hiwalay na serbisyo sa pag-iimbak ng file.

Ang lahat ng tawag sa mga serbisyong ito ay mabibigat na kahilingan. Nangangahulugan ito na ang overhead ng paghahatid ng mga kahilingang ito ay mas malaki kaysa sa oras na kinakailangan para maproseso ang mga ito ng isang third-party na serbisyo. Sa aming mga test bench, ang karaniwang oras ng tawag para sa mga naturang serbisyo ay 100 ms, kaya gagamitin namin ang mga numerong ito sa hinaharap.

Kailangan nating gumawa ng simpleng REST controller para matanggap ang huling N mensahe kasama ang lahat ng kinakailangang impormasyon. Iyon ay, naniniwala kami na ang modelo ng mensahe sa frontend ay halos pareho at lahat ng data ay kailangang ipadala. Ang pagkakaiba sa pagitan ng front-end na modelo ay ang file at ang user ay kailangang ipakita sa isang bahagyang naka-decrypt na form upang gawin silang mga link:

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

Kailangan nating ipatupad ang mga sumusunod:

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

Ang UI postfix ay nangangahulugang mga modelo ng DTO para sa frontend, iyon ay, kung ano ang dapat nating ihatid sa pamamagitan ng REST.

Ang maaaring nakakagulat dito ay hindi kami nagpapasa ng anumang chat ID at kahit na ang ChatMessage/ChatMessageUI na modelo ay wala nito. Sinadya ko ito para hindi makalat ang code ng mga halimbawa (isolated ang mga chat, para ipagpalagay natin na meron lang tayo).

Pilosopikal na paglihisParehong ginagamit ng klase ng ChatMessageUI at ng ChatRestApi.getLast ang uri ng data ng List kung sa katunayan ito ay isang nakaayos na Set. Ito ay masama sa JDK, kaya ang pagdedeklara ng pagkakasunud-sunod ng mga elemento sa antas ng interface (pinapanatili ang pagkakasunud-sunod kapag nagdadagdag at nag-aalis) ay hindi gagana. Kaya naging karaniwang kasanayan na ang paggamit ng isang Listahan sa mga kaso kung saan kailangan ang isang iniutos na Set (mayroon ding LinkedHashSet, ngunit hindi ito isang interface).
Mahalagang limitasyon: Ipagpalagay namin na walang mahabang chain ng mga tugon o paglilipat. Iyon ay, mayroon sila, ngunit ang kanilang haba ay hindi lalampas sa tatlong mga mensahe. Ang buong chain ng mga mensahe ay dapat ipadala sa frontend.

Upang makatanggap ng data mula sa mga panlabas na serbisyo mayroong mga sumusunod na 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>
}

Makikita na ang mga panlabas na serbisyo sa simula ay nagbibigay para sa pagproseso ng batch, at sa parehong mga bersyon: sa pamamagitan ng Set (nang hindi pinapanatili ang pagkakasunud-sunod ng mga elemento, na may natatanging mga susi) at sa pamamagitan ng Listahan (maaaring may mga duplicate - ang pagkakasunud-sunod ay napanatili).

Mga simpleng pagpapatupad

Walang muwang na pagpapatupad

Ang unang walang muwang na pagpapatupad ng aming REST controller ay magiging ganito sa karamihan ng mga kaso:

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

Ang lahat ay napakalinaw, at ito ay isang malaking plus.

Gumagamit kami ng batch processing at tumatanggap ng data mula sa isang panlabas na serbisyo sa mga batch. Ngunit ano ang mangyayari sa ating pagiging produktibo?

Para sa bawat mensahe, isang tawag sa UserRemoteApi ang gagawin para makakuha ng data sa field ng may-akda at isang tawag sa FileRemoteApi para makuha ang lahat ng naka-attach na file. Ganun daw. Sabihin nating ang forwardFrom at replyTo na mga field para sa ChatMessage ay nakuha sa paraang hindi ito nangangailangan ng mga hindi kinakailangang tawag. Ngunit ang paggawa ng mga ito sa ChatMessageUI ay hahantong sa recursion, iyon ay, ang mga call counter ay maaaring tumaas nang malaki. Gaya ng nabanggit natin kanina, ipagpalagay natin na wala tayong maraming pugad at ang kadena ay limitado sa tatlong mensahe.

Bilang resulta, makakatanggap kami ng dalawa hanggang anim na tawag sa mga panlabas na serbisyo bawat mensahe at isang tawag sa JPA para sa buong pakete ng mga mensahe. Ang kabuuang bilang ng mga tawag ay mag-iiba mula 2*N+1 hanggang 6*N+1. Magkano ito sa totoong units? Sabihin nating kailangan ng 20 mensahe para makapag-render ng isang page. Upang matanggap ang mga ito, aabutin ito mula 4 s hanggang 10 s. Grabe! Gusto kong panatilihin ito sa loob ng 500 ms. At dahil pinangarap nilang gumawa ng tuluy-tuloy na pag-scroll sa frontend, maaaring madoble ang mga kinakailangan sa pagganap para sa endpoint na ito.

Pros:

  1. Ang code ay maigsi at self-documenting (pangarap ng team ng suporta).
  2. Ang code ay simple, kaya halos walang mga pagkakataon na kunan ng larawan ang iyong sarili sa paa.
  3. Ang pagproseso ng batch ay hindi mukhang isang bagay na dayuhan at organikong isinama sa lohika.
  4. Madaling gagawin ang mga pagbabago sa lohika at magiging lokal.

Minus:

Kakila-kilabot na pagganap dahil sa napakaliit na packet.

Ang pamamaraang ito ay madalas na makikita sa mga simpleng serbisyo o sa mga prototype. Kung ang bilis ng paggawa ng mga pagbabago ay mahalaga, halos hindi sulit na gawing kumplikado ang system. Kasabay nito, para sa aming napakasimpleng serbisyo ang pagganap ay kahila-hilakbot, kaya ang saklaw ng pagkakalapat ng diskarteng ito ay napakaliit.

Walang muwang na parallel processing

Maaari mong simulan ang pagproseso ng lahat ng mga mensahe nang magkatulad - ito ay magbibigay-daan sa iyo upang mapupuksa ang linear na pagtaas sa oras depende sa bilang ng mga mensahe. Ito ay hindi isang partikular na magandang landas dahil ito ay magreresulta sa isang malaking peak load sa panlabas na serbisyo.

Ang pagpapatupad ng parallel processing ay napakasimple:

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

Gamit ang parallel na pagpoproseso ng mensahe, nakakakuha tayo ng 300–700 ms nang perpekto, na mas mahusay kaysa sa walang muwang na pagpapatupad, ngunit hindi pa rin sapat na mabilis.

Sa diskarteng ito, ang mga kahilingan sa userRepository at fileRepository ay isasagawa nang sabay-sabay, na hindi masyadong mahusay. Upang ayusin ito, kailangan mong baguhin nang husto ang logic ng tawag. Halimbawa, sa pamamagitan ng 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()!!

Makikita na ang unang simpleng mapping code ay naging hindi gaanong naiintindihan. Ito ay dahil kinailangan naming paghiwalayin ang mga tawag sa mga panlabas na serbisyo mula sa kung saan ginagamit ang mga resulta. Ito mismo ay hindi masama. Ngunit ang pagsasama-sama ng mga tawag ay hindi mukhang napaka-eleganteng at kahawig ng isang tipikal na reaktibong "noodle".

Kung gagamit ka ng mga coroutine, magiging mas disente ang lahat:

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

Saan:

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

Sa teorya, gamit ang parallel processing, makakakuha tayo ng 200–400 ms, na malapit na sa ating mga inaasahan.

Sa kasamaang palad, walang ganoong magandang parallelization, at ang presyo na babayaran ay medyo malupit: na may kaunting mga user lamang na nagtatrabaho nang sabay, isang barrage ng mga kahilingan ang mahuhulog sa mga serbisyo, na hindi pa rin mapoproseso nang magkatulad, kaya kami babalik sa ating malungkot na 4 s.

Ang aking resulta kapag gumagamit ng ganoong serbisyo ay 1300–1700 ms para sa pagproseso ng 20 mensahe. Ito ay mas mabilis kaysa sa unang pagpapatupad, ngunit hindi pa rin malutas ang problema.

Mga alternatibong paggamit ng mga parallel na queryPaano kung ang mga serbisyo ng third-party ay hindi nagbibigay ng batch processing? Halimbawa, maaari mong itago ang kakulangan ng pagpapatupad ng batch processing sa loob ng mga pamamaraan ng interface:

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

Makatuwiran ito kung umaasa kang makakita ng batch processing sa mga susunod na bersyon.
Pros:

  1. Madaling ipatupad ang parallel processing na nakabatay sa mensahe.
  2. Magandang scalability.

Cons:

  1. Ang pangangailangan na paghiwalayin ang pagkuha ng data mula sa pagproseso nito kapag pinoproseso ang mga kahilingan sa iba't ibang mga serbisyo nang magkatulad.
  2. Tumaas na pagkarga sa mga serbisyo ng third-party.

Makikita na ang saklaw ng applicability ay humigit-kumulang kapareho ng sa walang muwang na diskarte. Makatuwirang gamitin ang parallel request method kung gusto mong pataasin ang performance ng iyong serbisyo nang ilang beses dahil sa walang awa na pagsasamantala ng iba. Sa aming halimbawa, ang pagganap ay tumaas ng 2,5 beses, ngunit ito ay malinaw na hindi sapat.

pag-cache

Maaari mong gawin ang pag-cache sa diwa ng JPA para sa mga panlabas na serbisyo, iyon ay, mag-imbak ng mga natanggap na bagay sa loob ng isang session upang hindi matanggap muli ang mga ito (kabilang ang panahon ng pagproseso ng batch). Maaari kang gumawa ng ganoong mga cache sa iyong sarili, maaari mong gamitin ang Spring kasama ang @Cacheable nito, at maaari mong palaging gumamit ng handa na cache tulad ng EhCache nang manu-mano.

Ang isang karaniwang problema ay ang mga cache ay kapaki-pakinabang lamang kung mayroon silang mga hit. Sa aming kaso, ang mga hit sa field ng may-akda ay napakalamang (sabihin natin, 50%), ngunit walang mga hit sa mga file. Ang diskarte na ito ay magbibigay ng ilang mga pagpapabuti, ngunit hindi nito radikal na babaguhin ang pagganap (at kailangan namin ng isang pambihirang tagumpay).

Ang mga intersession (mahabang) cache ay nangangailangan ng kumplikadong lohika ng pagpapawalang-bisa. Sa pangkalahatan, mas mabuti kung mas mahusay kang malutas ang mga problema sa pagganap gamit ang mga intersession cache.

Pros:

  1. Ipatupad ang caching nang hindi binabago ang code.
  2. Nadagdagan ang pagiging produktibo nang maraming beses (sa ilang mga kaso).

Cons:

  1. Posibilidad ng pinababang pagganap kung ginamit nang hindi tama.
  2. Malaking memory overhead, lalo na sa mahabang cache.
  3. Kumplikadong invalidation, mga error kung saan hahantong sa mga problemang mahirap kopyahin sa runtime.

Kadalasan, ang mga cache ay ginagamit lamang upang mabilis na mag-patch ng mga problema sa disenyo. Hindi ito nangangahulugan na hindi sila dapat gamitin. Gayunpaman, dapat mong palaging tratuhin ang mga ito nang may pag-iingat at suriin muna ang resultang pakinabang sa pagganap, at pagkatapos ay gumawa ng desisyon.

Sa aming halimbawa, ang mga cache ay magbibigay ng pagtaas ng pagganap na humigit-kumulang 25%. Kasabay nito, ang mga cache ay may napakaraming disadvantages, kaya hindi ko gagamitin ang mga ito dito.

Mga resulta ng

Kaya, tumingin kami sa isang walang muwang na pagpapatupad ng isang serbisyo na gumagamit ng batch processing, at ilang simpleng paraan para mapabilis ito.

Ang pangunahing bentahe ng lahat ng mga pamamaraan na ito ay pagiging simple, mula sa kung saan mayroong maraming mga kaaya-ayang kahihinatnan.

Ang isang karaniwang problema sa mga pamamaraang ito ay ang mahinang pagganap, pangunahin dahil sa laki ng mga packet. Samakatuwid, kung ang mga solusyon na ito ay hindi angkop sa iyo, kung gayon ito ay nagkakahalaga ng pagsasaalang-alang ng higit pang mga radikal na pamamaraan.

Mayroong dalawang pangunahing direksyon kung saan maaari kang maghanap ng mga solusyon:

  • asynchronous na trabaho sa data (nangangailangan ng paradigm shift, kaya hindi tinalakay sa artikulong ito);
  • pagpapalaki ng mga batch habang pinapanatili ang kasabay na pagproseso.

Ang pagpapalaki ng mga batch ay lubos na makakabawas sa bilang ng mga panlabas na tawag at sa parehong oras ay panatilihing magkasabay ang code. Ang susunod na bahagi ng artikulo ay ilalaan sa paksang ito.

Pinagmulan: www.habr.com

Magdagdag ng komento