Пакеттік сұраныстарды өңдеу мәселелері және оларды шешу (1 бөлім)

Пакеттік сұраныстарды өңдеу мәселелері және оларды шешу (1 бөлім)Қазіргі заманғы бағдарламалық өнімдердің барлығы дерлік бірнеше қызметтерден тұрады. Көбінесе, қызметаралық арналардың ұзақ жауап беру уақыттары өнімділік мәселелерінің көзіне айналады. Мұндай мәселенің стандартты шешімі бірнеше қызметаралық сұрауларды пакеттік деп аталатын бір бумаға жинау болып табылады.

Пакеттік өңдеуді пайдалансаңыз, өнімділік немесе код анықтығы тұрғысынан нәтижелерге риза болмауыңыз мүмкін. Бұл әдіс сіз ойлағандай қоңырау шалушыға оңай емес. Әртүрлі мақсаттарда және әртүрлі жағдайларда шешімдер әртүрлі болуы мүмкін. Нақты мысалдарды қолдана отырып, мен бірнеше тәсілдердің оң және теріс жақтарын көрсетемін.

Демонстрациялық жоба

Түсінікті болу үшін мен қазір жұмыс істеп жатқан қолданбадағы қызметтердің бірінің мысалын қарастырайық.

Мысалдар үшін платформа таңдауды түсіндіруНашар өнімділік мәселесі өте жалпы және ешқандай нақты тілдерге немесе платформаларға әсер етпейді. Бұл мақалада мәселелер мен шешімдерді көрсету үшін Spring + Kotlin код мысалдары пайдаланылады. Котлин Java және C# әзірлеушілеріне бірдей түсінікті (немесе түсініксіз), сонымен қатар, код Java-ға қарағанда ықшам және түсінікті. Таза Java әзірлеушілеріне түсінуді жеңілдету үшін мен Котлиннің қара магиясынан аулақ боламын және тек ақ сиқырды (Ломбок рухында) қолданамын. Бірнеше кеңейту әдістері болады, бірақ олар шын мәнінде барлық Java бағдарламашыларына статикалық әдістер ретінде таныс, сондықтан бұл тағамның дәмін бұзбайтын шағын қант болады.
Құжаттарды бекіту қызметі бар. Біреу құжат жасайды және оны талқылауға ұсынады, оның барысында түзетулер енгізіледі, ақырында құжат келісіледі. Бекіту қызметінің өзі құжаттар туралы ештеңе білмейді: бұл жай ғана шағын қосымша функциялары бар мақұлдаушылардың сөйлесуі, біз мұнда қарастырмаймыз.

Сонымен, олардың әрқайсысында қатысушылардың алдын ала анықталған жиынтығы бар сөйлесу бөлмелері (құжаттарға сәйкес) бар. Кәдімгі чаттардағыдай, хабарлар мәтін мен файлдарды қамтиды және жауаптар немесе қайта жіберу болуы мүмкін:

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 мс құрайды, сондықтан біз бұл нөмірлерді болашақта пайдаланамыз.

Барлық қажетті ақпаратпен соңғы N хабарламаларды алу үшін қарапайым REST контроллерін жасау керек. Яғни, біз фронтондағы хабарлама үлгісі дерлік бірдей және барлық деректерді жіберу қажет деп есептейміз. Фронт моделінің айырмашылығы мынада: файл мен пайдаланушыға сілтеме жасау үшін сәл шифрланған пішінде ұсынылуы керек:

/** В таком виде отдаются ссылки на сущности для фронта */
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 әдісі де Тізім деректер түрін пайдаланады, егер ол реттелген жиын болса. Бұл JDK-де нашар, сондықтан интерфейс деңгейінде элементтердің ретін жариялау (қосу және жою кезінде тәртіпті сақтау) жұмыс істемейді. Осылайша, реттелген жиын қажет болған жағдайда Тізімді пайдалану әдеттегі тәжірибеге айналды (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 арқылы (элементтердің ретін сақтамай, бірегей кілттермен) және Тізім арқылы (қайталануы мүмкін - тәртіп сақталады).

Қарапайым іске асырулар

Аңғал іске асыру

Біздің 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 қызметіне бір қоңырау және барлық тіркелген файлдарды алу үшін FileRemoteApi қызметіне бір қоңырау жасалады. Солай болған сияқты. ChatMessage үшін forwardFrom және replyTo өрістері қажетсіз қоңырауларды қажет етпейтіндей етіп алынды делік. Бірақ оларды ChatMessageUI-ге айналдыру рекурсияға әкеледі, яғни қоңыраулар есептегіштері айтарлықтай өсуі мүмкін. Жоғарыда атап өткеніміздей, бізде ұяшықтар көп емес және тізбек үш хабарламамен шектеледі делік.

Нәтижесінде біз бір хабарға сыртқы қызметтерге екіден алтыға дейін қоңырауды және хабарлардың бүкіл пакеті үшін бір JPA қоңырауын аламыз. Қоңыраулардың жалпы саны 2*N+1 мен 6*N+1 аралығында өзгереді. Бұл нақты бірліктерде қанша? Бетті көрсету үшін 20 хабарлама қажет делік. Оларды қабылдау үшін 4 секундтан 10 секундқа дейін уақыт қажет. Қорқынышты! Мен оны 500 мс ішінде сақтағым келеді. Және олар алдыңғы қатарда үздіксіз айналдыруды армандағандықтан, осы соңғы нүктеге қойылатын өнімділік талаптарын екі есе арттыруға болады.

Артықшылықтары:

  1. Код қысқа және өзін-өзі құжаттайды (қолдау көрсету тобының арманы).
  2. Код қарапайым, сондықтан өзіңізді аяққа атуға мүмкіндік жоқ.
  3. Пакеттік өңдеу бөтен нәрсеге ұқсамайды және логикаға органикалық түрде біріктірілген.
  4. Логикалық өзгерістер оңай жасалады және жергілікті болады.

Минус:

Өте кішкентай пакеттерге байланысты қорқынышты өнімділік.

Бұл тәсілді қарапайым қызметтерде немесе прототиптерде жиі көруге болады. Өзгерістерді енгізу жылдамдығы маңызды болса, жүйені қиындатудың қажеті жоқ. Сонымен қатар, біздің өте қарапайым қызметіміз үшін өнімділік қорқынышты, сондықтан бұл тәсілдің қолданылу аясы өте тар.

Аңғал параллельді өңдеу

Сіз барлық хабарламаларды параллельді өңдеуді бастай аласыз - бұл хабарламалар санына байланысты уақыттың сызықтық ұлғаюынан құтылуға мүмкіндік береді. Бұл өте жақсы жол емес, себебі ол сыртқы қызметке үлкен жүктемеге әкеледі.

Параллельді өңдеуді жүзеге асыру өте қарапайым:

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

Хабарламаларды параллель өңдеуді пайдалана отырып, біз мінсіз 300–700 мс аламыз, бұл аңғал іске асыруға қарағанда әлдеқайда жақсы, бірақ әлі де жеткілікті жылдам емес.

Бұл тәсілмен 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 мс аламыз, бұл біздің күткенімізге жақын.

Өкінішке орай, мұндай жақсы параллелизация жоқ және төлеуге болатын баға өте қатал: бір уақытта бірнеше пайдаланушы жұмыс істейтін болса, қызметтерге сұраныстардың көптігі түседі, олар бәрібір параллель өңделмейді, сондықтан біз біздің қайғылы 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 сияқты дайын кэшті қолмен пайдалана аласыз.

Кең таралған мәселе, кэштер хит болған жағдайда ғана пайдалы болады. Біздің жағдайда автор өрісіндегі соққылар өте ықтимал (айталық, 50%), бірақ файлдарда ешқандай хит болмайды. Бұл тәсіл кейбір жақсартуларды қамтамасыз етеді, бірақ ол өнімділікті түбегейлі өзгертпейді (және бізге серпіліс қажет).

Сеансаралық (ұзын) кэштер күрделі жарамсыз логиканы қажет етеді. Жалпы, сеансаралық кэштерді пайдаланып өнімділік мәселелерін шешуге неғұрлым кеш кіріссеңіз, соғұрлым жақсы.

Артықшылықтары:

  1. Кодты өзгертпей кэштеуді іске қосыңыз.
  2. Өнімділіктің бірнеше есе артуы (кейбір жағдайларда).

Кемшіліктері:

  1. Дұрыс пайдаланбаған жағдайда өнімділікті төмендету мүмкіндігі.
  2. Үлкен жад жүктемесі, әсіресе ұзақ кэштермен.
  3. Күрделі жарамсыздық, қателер орындалу уақытында қайта шығару қиын мәселелерге әкеледі.

Көбінесе кэштер дизайн мәселелерін жылдам түзету үшін ғана пайдаланылады. Бұл оларды пайдаланбау керек дегенді білдірмейді. Дегенмен, сіз оларға әрқашан сақтықпен қарауыңыз керек және алдымен нәтиженің жоғарылауын бағалаңыз, содан кейін ғана шешім қабылдаңыз.

Біздің мысалда кэштер өнімділікті шамамен 25% арттыруды қамтамасыз етеді. Сонымен қатар, кэштердің көптеген кемшіліктері бар, сондықтан мен оларды мұнда қолданбас едім.

Нәтижелері

Сонымен, біз пакеттік өңдеуді қолданатын қызметтің аңғал іске асырылуын және оны жылдамдатудың кейбір қарапайым жолдарын қарастырдық.

Барлық осы әдістердің басты артықшылығы - қарапайымдылық, оның көптеген жағымды салдары бар.

Бұл әдістермен жиі кездесетін мәселе - ең алдымен пакеттердің өлшеміне байланысты нашар өнімділік. Сондықтан, егер бұл шешімдер сізге сәйкес келмесе, онда радикалды әдістерді қарастырған жөн.

Шешімдерді іздеуге болатын екі негізгі бағыт бар:

  • деректермен асинхронды жұмыс (парадигманы өзгертуді қажет етеді, сондықтан бұл мақалада талқыланбайды);
  • синхронды өңдеуді сақтай отырып, партияларды ұлғайту.

Топтамаларды ұлғайту сыртқы қоңыраулардың санын айтарлықтай азайтады және сонымен бірге кодты синхронды түрде сақтайды. Мақаланың келесі бөлімі осы тақырыпқа арналады.

Ақпарат көзі: www.habr.com

пікір қалдыру