Problemy wsadowego przetwarzania zapytań i ich rozwiązania (część 1)

Problemy wsadowego przetwarzania zapytań i ich rozwiązania (część 1)Prawie wszystkie nowoczesne produkty programowe składają się z kilku usług. Często długie czasy reakcji kanałów międzyusługowych stają się źródłem problemów z wydajnością. Standardowym rozwiązaniem tego rodzaju problemu jest spakowanie wielu żądań między usługami w jeden pakiet, co nazywa się przetwarzaniem wsadowym.

Jeśli korzystasz z przetwarzania wsadowego, możesz nie być zadowolony z wyników pod względem wydajności lub przejrzystości kodu. Ta metoda nie jest tak łatwa dla dzwoniącego, jak mogłoby się wydawać. Do różnych celów i w różnych sytuacjach rozwiązania mogą się znacznie różnić. Na konkretnych przykładach pokażę zalety i wady kilku podejść.

Projekt demonstracyjny

Dla jasności spójrzmy na przykład jednej z usług w aplikacji, nad którą aktualnie pracuję.

Wyjaśnienie wyboru platformy na przykładachProblem słabej wydajności jest dość ogólny i nie dotyczy żadnych konkretnych języków ani platform. W tym artykule zostaną użyte przykłady kodu Spring + Kotlin w celu zademonstrowania problemów i rozwiązań. Kotlin jest równie zrozumiały (lub niezrozumiały) dla programistów Java i C#, w dodatku kod jest bardziej zwarty i zrozumiały niż w Javie. Aby było to łatwiejsze do zrozumienia dla programistów zajmujących się wyłącznie Javą, będę unikać czarnej magii Kotlina i używać tylko białej magii (w duchu Lomboka). Metod rozszerzeń będzie kilka, ale tak naprawdę są one znane wszystkim programistom Java jako metody statyczne, więc będzie to mały cukierek, który nie zepsuje smaku potrawy.
Istnieje usługa zatwierdzania dokumentów. Ktoś tworzy dokument i poddaje go dyskusji, podczas której wprowadzane są poprawki i ostatecznie dokument jest uzgadniany. Sama usługa zatwierdzania nie wie nic o dokumentach: to tylko czat osób zatwierdzających z niewielkimi dodatkowymi funkcjami, których tutaj nie będziemy rozważać.

Istnieją więc pokoje rozmów (odpowiadające dokumentom) z predefiniowanym zestawem uczestników w każdym z nich. Podobnie jak w przypadku zwykłych czatów, wiadomości zawierają tekst i pliki i mogą być odpowiedziami lub przesyłanymi dalej:

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
)

Linki do plików i użytkowników to linki do innych domen. Tutaj żyjemy tak:

typealias FileReference Long
typealias UserReference Long

Dane użytkownika są przechowywane w Keycloak i pobierane poprzez REST. To samo dotyczy plików: pliki i metainformacje na ich temat znajdują się w osobnej usłudze przechowywania plików.

Wszystkie połączenia z tymi usługami są ciężkie prośby. Oznacza to, że narzut związany z transportem tych żądań jest znacznie większy niż czas potrzebny na ich przetworzenie przez usługę strony trzeciej. Na naszych stanowiskach testowych typowy czas połączenia dla takich usług wynosi 100 ms, więc będziemy korzystać z tych numerów w przyszłości.

Musimy stworzyć prosty kontroler REST, który będzie odbierał N ostatnich wiadomości ze wszystkimi niezbędnymi informacjami. Oznacza to, że uważamy, że model wiadomości w interfejsie jest prawie taki sam i wszystkie dane muszą zostać przesłane. Różnica pomiędzy modelem front-endowym polega na tym, że plik i użytkownik muszą być przedstawione w lekko odszyfrowanej formie, aby były linkami:

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

Musimy wdrożyć następujące rozwiązania:

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

Postfix UI oznacza modele DTO dla frontendu, czyli to, co powinniśmy serwować poprzez REST.

Zaskakujące może być to, że nie przekazujemy żadnego identyfikatora czatu, a nawet model ChatMessage/ChatMessageUI go nie posiada. Zrobiłem to celowo, żeby nie zaśmiecać kodu przykładami (czaty są izolowane, więc możemy założyć, że mamy tylko jeden).

Dygresja filozoficznaZarówno klasa ChatMessageUI, jak i metoda ChatRestApi.getLast korzystają z typu danych List, gdy w rzeczywistości jest to uporządkowany Zbiór. To jest złe w JDK, więc deklarowanie kolejności elementów na poziomie interfejsu (zachowywanie kolejności przy dodawaniu i usuwaniu) nie będzie działać. Dlatego powszechną praktyką stało się używanie listy w przypadkach, gdy potrzebny jest uporządkowany zestaw (istnieje również LinkedHashSet, ale nie jest to interfejs).
Ważne ograniczenie: Założymy, że nie ma długich łańcuchów odpowiedzi ani przekazań. Oznacza to, że istnieją, ale ich długość nie przekracza trzech wiadomości. Cały łańcuch wiadomości musi zostać przesłany do frontendu.

Do odbioru danych z usług zewnętrznych służą następujące 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>
}

Widać, że usługi zewnętrzne początkowo przewidują przetwarzanie wsadowe i to w obu wersjach: poprzez Set (bez zachowania kolejności elementów, z unikalnymi kluczami) i poprzez List (mogą wystąpić duplikaty – kolejność zostaje zachowana).

Proste wdrożenia

Naiwne wdrożenie

Pierwsza naiwna implementacja naszego kontrolera REST będzie w większości przypadków wyglądać mniej więcej tak:

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

Wszystko jest bardzo przejrzyste i to jest duży plus.

Korzystamy z przetwarzania wsadowego i otrzymujemy dane z usługi zewnętrznej partiami. Ale co dzieje się z naszą produktywnością?

Dla każdej wiadomości zostanie wykonane jedno wywołanie UserRemoteApi w celu pobrania danych o polu autora i jedno wywołanie FileRemoteApi w celu pobrania wszystkich załączonych plików. Wygląda na to, że to wszystko. Załóżmy, że pola forwardFrom i AnswerTo dla ChatMessage są uzyskiwane w taki sposób, że nie wymaga to niepotrzebnych wywołań. Ale przekształcenie ich w ChatMessageUI doprowadzi do rekurencji, co oznacza, że ​​liczniki połączeń mogą znacznie wzrosnąć. Jak zauważyliśmy wcześniej, załóżmy, że nie mamy zbyt wielu zagnieżdżeń, a łańcuch jest ograniczony do trzech wiadomości.

W rezultacie na jedną wiadomość otrzymamy od dwóch do sześciu wywołań do usług zewnętrznych i jedno wywołanie JPA na cały pakiet wiadomości. Całkowita liczba połączeń będzie się wahać od 2*N+1 do 6*N+1. Ile to jest w rzeczywistych jednostkach? Załóżmy, że do wyrenderowania strony potrzeba 20 wiadomości. Aby je otrzymać, zajmie to od 4 s do 10 s. Straszny! Chciałbym zachować to w promieniu 500 ms. A ponieważ marzyli o płynnym przewijaniu w interfejsie użytkownika, wymagania wydajnościowe dla tego punktu końcowego można podwoić.

Plusy:

  1. Kod jest zwięzły i samodokumentujący się (marzenie zespołu wsparcia).
  2. Kod jest prosty, więc prawie nie ma możliwości strzelenia sobie w stopę.
  3. Przetwarzanie wsadowe nie wygląda na coś obcego i jest organicznie zintegrowane z logiką.
  4. Zmiany w logice będą łatwe i będą miały charakter lokalny.

Minus:

Straszna wydajność z powodu bardzo małych pakietów.

Takie podejście można spotkać dość często w prostych usługach lub prototypach. Jeśli szybkość wprowadzania zmian jest istotna, nie warto komplikować systemu. Jednocześnie, w przypadku naszej bardzo prostej usługi wydajność jest fatalna, zatem zakres zastosowania tego podejścia jest bardzo wąski.

Naiwne przetwarzanie równoległe

Możesz rozpocząć przetwarzanie wszystkich wiadomości równolegle - pozwoli to uniknąć liniowego przyrostu czasu w zależności od liczby wiadomości. Nie jest to szczególnie dobra ścieżka, ponieważ będzie skutkować dużym obciążeniem szczytowym usługi zewnętrznej.

Implementacja przetwarzania równoległego jest bardzo prosta:

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

Stosując równoległe przetwarzanie wiadomości, idealnie otrzymujemy 300–700 ms, co jest znacznie lepsze niż przy naiwnej implementacji, ale wciąż nie jest wystarczająco szybkie.

Przy takim podejściu żądania kierowane do userRepository i fileRepository będą wykonywane synchronicznie, co nie jest zbyt wydajne. Aby to naprawić, będziesz musiał sporo zmienić logikę połączeń. Na przykład poprzez 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()!!

Można zauważyć, że początkowo prosty kod mapujący stał się mniej zrozumiały. Dzieje się tak, ponieważ musieliśmy oddzielić wywołania usług zewnętrznych od miejsc, w których wykorzystywane są wyniki. To samo w sobie nie jest złe. Jednak łączenie rozmów nie wygląda zbyt elegancko i przypomina typowy reaktywny „makaron”.

Jeśli użyjesz współprogramów, wszystko będzie wyglądać przyzwoicie:

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

Gdzie:

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

Teoretycznie stosując takie przetwarzanie równoległe otrzymamy 200–400 ms, co jest już bliskie naszym oczekiwaniom.

Niestety, tak dobra równoległość nie istnieje, a cena, jaką trzeba zapłacić, jest dość okrutna: gdy tylko kilku użytkowników pracuje jednocześnie, na usługi spadnie lawina żądań, które i tak nie będą przetwarzane równolegle, więc my powrócimy do naszych smutnych 4 lat.

Mój wynik przy korzystaniu z takiej usługi to 1300–1700 ms na przetworzenie 20 wiadomości. Jest to szybsze niż w pierwszej implementacji, ale nadal nie rozwiązuje problemu.

Alternatywne zastosowania zapytań równoległychCo się stanie, jeśli usługi stron trzecich nie zapewniają przetwarzania wsadowego? Na przykład możesz ukryć brak implementacji przetwarzania wsadowego w metodach interfejsu:

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

Ma to sens, jeśli masz nadzieję zobaczyć przetwarzanie wsadowe w przyszłych wersjach.
Plusy:

  1. Z łatwością wdrażaj przetwarzanie równoległe oparte na komunikatach.
  2. Dobra skalowalność.

Wady:

  1. Konieczność oddzielenia pozyskiwania danych od ich przetwarzania przy równoległym przetwarzaniu żądań do różnych usług.
  2. Zwiększone obciążenie usług stron trzecich.

Można zauważyć, że zakres zastosowania jest w przybliżeniu taki sam, jak w przypadku podejścia naiwnego. Metoda żądania równoległego ma sens, jeśli chcesz kilkukrotnie zwiększyć wydajność swojej usługi z powodu bezlitosnego wykorzystywania innych. W naszym przykładzie wydajność wzrosła 2,5 razy, ale to wyraźnie nie wystarczy.

buforowanie

Można robić buforowanie w duchu JPA dla usług zewnętrznych, czyli przechowywać odebrane obiekty w ramach sesji, aby nie otrzymać ich ponownie (w tym podczas przetwarzania wsadowego). Możesz sam stworzyć takie pamięci podręczne, możesz użyć Springa z @Cacheable, a ponadto zawsze możesz ręcznie użyć gotowej pamięci podręcznej, takiej jak EhCache.

Częstym problemem byłoby to, że pamięci podręczne są przydatne tylko wtedy, gdy zawierają trafienia. W naszym przypadku trafienia w polu autora są bardzo prawdopodobne (powiedzmy 50%), ale nie będzie żadnych trafień w plikach. Takie podejście zapewni pewne ulepszenia, ale nie zmieni radykalnie wydajności (a potrzebujemy przełomu).

Międzysesyjne (długie) pamięci podręczne wymagają złożonej logiki unieważniania. Ogólnie rzecz biorąc, im później zajmiesz się rozwiązywaniem problemów z wydajnością przy użyciu pamięci podręcznej międzysesyjnej, tym lepiej.

Plusy:

  1. Implementuj buforowanie bez zmiany kodu.
  2. Kilkukrotnie zwiększona produktywność (w niektórych przypadkach).

Wady:

  1. Możliwość zmniejszenia wydajności w przypadku nieprawidłowego użycia.
  2. Duży narzut pamięci, szczególnie w przypadku długich pamięci podręcznych.
  3. Złożone unieważnienie, którego błędy doprowadzą do trudnych do odtworzenia problemów w czasie wykonywania.

Bardzo często pamięci podręczne służą jedynie do szybkiego łatania problemów projektowych. Nie oznacza to, że nie należy ich używać. Należy jednak zawsze traktować je ostrożnie i najpierw ocenić wynikający z tego wzrost wydajności, a dopiero potem podjąć decyzję.

W naszym przykładzie pamięci podręczne zapewnią wzrost wydajności o około 25%. Jednocześnie pamięci podręczne mają sporo wad, więc nie używałbym ich tutaj.

Wyniki

Przyjrzeliśmy się więc naiwnej implementacji usługi korzystającej z przetwarzania wsadowego i kilku prostym sposobom jego przyspieszenia.

Główną zaletą wszystkich tych metod jest prostota, z której wynika wiele przyjemnych konsekwencji.

Częstym problemem związanym z tymi metodami jest niska wydajność, głównie ze względu na rozmiar pakietów. Dlatego jeśli te rozwiązania Ci nie odpowiadają, warto rozważyć bardziej radykalne metody.

Istnieją dwa główne kierunki poszukiwania rozwiązań:

  • asynchroniczna praca z danymi (wymaga zmiany paradygmatu, dlatego nie jest omawiana w tym artykule);
  • powiększanie partii przy zachowaniu przetwarzania synchronicznego.

Powiększanie partii znacznie zmniejszy liczbę wywołań zewnętrznych, a jednocześnie zapewni synchronię kodu. Temu tematowi poświęcona zostanie dalsza część artykułu.

Źródło: www.habr.com

Dodaj komentarz