Багц асуулга боловсруулах асуудал ба тэдгээрийн шийдэл (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>
}

Postfix UI нь урд талын DTO загваруудыг, өөрөөр хэлбэл REST-ээр дамжуулан бидэнд үйлчлэх ёстой гэсэн үг юм.

Энд гайхмаар зүйл бол бид ямар ч чат танигч дамжуулаагүй бөгөөд ChatMessage/ChatMessageUI загварт ч байхгүй. Би жишээнүүдийн кодыг эмх замбараагүй болгохгүйн тулд үүнийг зориудаар хийсэн (чатууд тусгаарлагдсан тул бид зөвхөн нэг л байна гэж үзэж болно).

Философийн ухралтChatMessageUI анги болон ChatRestApi.getLast арга хоёулаа жагсаалтын өгөгдлийн төрлийг ашигладаг, гэхдээ энэ нь захиалгат багц юм. JDK-д энэ нь муу байгаа тул интерфейсийн түвшинд элементүүдийн дарааллыг зарлах (нэмэх, татах үед дарааллыг хадгалах) ажиллахгүй. Тиймээс дараалсан багц шаардлагатай тохиолдолд List-ийг ашиглах нь түгээмэл болсон (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-ээр (өөрөөр хэлбэл 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()!!

Анхны энгийн зураглалын код нь ойлгомжгүй болсон нь харагдаж байна. Учир нь бид гадаад үйлчилгээ рүү дуудлагыг үр дүнг ашиглаж байгаа газраас нь салгах шаардлагатай болсон. Энэ нь өөрөө муу биш юм. Гэхдээ дуудлагыг хослуулах нь тийм ч гоёмсог харагддаггүй бөгөөд ердийн реактив "гоймон" -той төстэй юм.

Хэрэв та coroutine ашигладаг бол бүх зүйл илүү сайхан харагдах болно:

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%), гэхдээ файлууд дээр ямар ч цохилт байхгүй болно. Энэ арга нь зарим сайжруулалтыг өгөх боловч бүтээмжийг эрс өөрчлөхгүй (мөн бидэнд нээлт хэрэгтэй).

Intersession (урт) кэш нь хүчингүй болгох нарийн төвөгтэй логик шаарддаг. Ерөнхийдөө, та завсрын кэш ашиглан гүйцэтгэлийн асуудлыг шийдэхийн тулд дараа нь эхлэх тусам сайн.

Нөхцөл:

  1. Кодыг өөрчлөхгүйгээр кэш хийх.
  2. Бүтээмж хэд хэдэн удаа нэмэгдсэн (зарим тохиолдолд).

Нөхцөл байдал:

  1. Буруу ашигласан тохиолдолд гүйцэтгэл буурах боломжтой.
  2. Том санах ойн ачаалал, ялангуяа урт кэштэй.
  3. Нарийн төвөгтэй хүчингүй болгох, алдаа нь ажиллах явцад дахин гаргахад хэцүү асуудлуудад хүргэдэг.

Ихэнх тохиолдолд кэшийг зөвхөн дизайны асуудлыг хурдан арилгахад ашигладаг. Энэ нь тэдгээрийг ашиглах ёсгүй гэсэн үг биш юм. Гэсэн хэдий ч та тэдэнд үргэлж болгоомжтой хандах хэрэгтэй бөгөөд эхлээд үр дүнгийн үр ашгийг үнэлж, дараа нь шийдвэр гаргах хэрэгтэй.

Бидний жишээн дээр кэш нь гүйцэтгэлийг 25% орчим нэмэгдүүлэх болно. Үүний зэрэгцээ кэш нь маш их сул талуудтай тул би энд ашиглахгүй.

Үр дүн

Тиймээс, бид багц боловсруулалтыг ашигладаг үйлчилгээний гэнэн хэрэгжилт, үүнийг хурдасгах хэд хэдэн энгийн аргуудыг авч үзсэн.

Эдгээр бүх аргын гол давуу тал нь энгийн байдал бөгөөд үүнээс олон таатай үр дагавар гардаг.

Эдгээр аргуудын нийтлэг асуудал бол муу гүйцэтгэл, юуны түрүүнд пакетуудын хэмжээтэй холбоотой байдаг. Тиймээс, эдгээр шийдлүүд танд тохирохгүй бол илүү радикал аргуудыг авч үзэх нь зүйтэй.

Та шийдлийг хайж болох хоёр үндсэн чиглэл байдаг:

  • өгөгдөлтэй асинхрон ажил (парадигмын өөрчлөлтийг шаарддаг тул энэ нийтлэлд үүнийг авч үзэхгүй);
  • синхрон боловсруулалтыг хадгалахын зэрэгцээ багцыг томруулах.

Багцыг томруулах нь гадаад дуудлагын тоог эрс багасгаж, кодын синхрончлолыг хадгалах болно. Өгүүллийн дараагийн хэсгийг энэ сэдэвт зориулах болно.

Эх сурвалж: www.habr.com

сэтгэгдэл нэмэх