Проблеми пакетної обробки запитів та їх вирішення (частина 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 виходять так, що це не вимагатиме зайвих викликів. Але перетворення їх у ChatMessageUI призведе до рекурсії, тобто показники лічильників викликів можуть сильно зрости. Як ми зазначили раніше, припустимо, що великої вкладеності у нас не буває і ланцюжок обмежений трьома повідомленнями.

У результаті отримаємо від двох до шести викликів зовнішніх сервісів на одне повідомлення та один JPA-дзвінок на весь пакет повідомлень. Загальна кількість викликів варіюватиметься від 2*N+1 до 6*N+1. Скільки це у реальних одиницях? Припустимо, для відтворення сторінки потрібно 20 повідомлень. Щоб їх отримати, знадобиться від 4 до 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

Додати коментар або відгук