Toplu sorgu işleme sorunları ve çözümleri (bölüm 1)

Toplu sorgu işleme sorunları ve çözümleri (bölüm 1)Hemen hemen tüm modern yazılım ürünleri çeşitli hizmetlerden oluşur. Çoğu zaman, hizmetler arası kanalların uzun yanıt süreleri performans sorunlarının kaynağı haline gelir. Bu tür bir problemin standart çözümü, birden fazla servisler arası talebi toplu işlem adı verilen tek bir pakette toplamaktır.

Toplu işleme kullanırsanız performans veya kod netliği açısından sonuçlardan memnun kalmayabilirsiniz. Bu yöntem arayan kişi için düşündüğünüz kadar kolay değildir. Farklı amaçlar için ve farklı durumlarda çözümler büyük ölçüde farklılık gösterebilir. Spesifik örnekler kullanarak çeşitli yaklaşımların artılarını ve eksilerini göstereceğim.

Gösteri projesi

Konuyu netleştirmek için şu anda üzerinde çalıştığım uygulamadaki hizmetlerden birine bir örnek verelim.

Örnekler için platform seçiminin açıklamasıDüşük performans sorunu oldukça geneldir ve belirli dilleri veya platformları etkilemez. Bu makalede sorunları ve çözümleri göstermek için Spring + Kotlin kod örnekleri kullanılacaktır. Kotlin, Java ve C# geliştiricileri için de aynı derecede anlaşılabilir (veya anlaşılmaz) olup ayrıca kod, Java'ya göre daha kompakt ve anlaşılırdır. Saf Java geliştiricileri için anlaşılmasını kolaylaştırmak için Kotlin'in kara büyüsünden kaçınacağım ve yalnızca beyaz büyüyü (Lombok ruhuyla) kullanacağım. Bir kaç tane uzatma yöntemi olacak ama bunlar aslında tüm Java programcılarının statik yöntemler olarak aşina olduğu, dolayısıyla yemeğin tadını bozmayacak küçük bir şeker olacak.
Evrak onay hizmeti bulunmaktadır. Birisi bir belge oluşturur ve onu tartışmaya sunar, bu sırada düzenlemeler yapılır ve sonunda belge üzerinde anlaşmaya varılır. Onay hizmetinin kendisi belgeler hakkında hiçbir şey bilmiyor: bu, burada dikkate almayacağımız küçük ek işlevlere sahip yalnızca onaylayanların sohbetidir.

Yani, her birinde önceden tanımlanmış bir katılımcı grubunun bulunduğu sohbet odaları (belgelere karşılık gelen) vardır. Normal sohbetlerde olduğu gibi mesajlar metin ve dosyalar içerir ve yanıtlanabilir veya iletilebilir:

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
)

Dosya ve kullanıcı bağlantıları diğer alan adlarına bağlantılardır. İşte böyle yaşıyoruz:

typealias FileReference Long
typealias UserReference Long

Kullanıcı verileri Keycloak'ta saklanır ve REST aracılığıyla alınır. Aynı şey dosyalar için de geçerlidir: dosyalar ve bunlarla ilgili meta bilgiler ayrı bir dosya depolama hizmetinde bulunur.

Bu hizmetlere yapılan tüm çağrılar ağır istekler. Bu, bu isteklerin taşınmasına ilişkin ek yükün, bunların üçüncü taraf bir hizmet tarafından işlenmesi için gereken süreden çok daha fazla olduğu anlamına gelir. Test tezgahlarımızda bu tür hizmetler için tipik çağrı süresi 100 ms'dir, dolayısıyla gelecekte bu numaraları kullanacağız.

Gerekli tüm bilgileri içeren son N mesajı almak için basit bir REST denetleyici yapmamız gerekiyor. Yani ön uçtaki mesaj modelinin hemen hemen aynı olduğuna ve tüm verilerin gönderilmesi gerektiğine inanıyoruz. Ön uç modeli arasındaki fark, dosyanın ve kullanıcının bağlantı kurabilmesi için biraz şifresi çözülmüş bir biçimde sunulmasının gerekmesidir:

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

Aşağıdakileri uygulamamız gerekiyor:

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

UI postfix, ön uç için DTO modelleri, yani REST aracılığıyla hizmet vermemiz gereken şey anlamına gelir.

Burada şaşırtıcı olabilecek şey, herhangi bir sohbet kimliğini aktarmamamız ve hatta ChatMessage/ChatMessageUI modelinde bile bir tane olmamasıdır. Örneklerin kodunu karmaşıklaştırmamak için bunu kasıtlı olarak yaptım (sohbetler izole edilmiştir, dolayısıyla yalnızca bir tane olduğunu varsayabiliriz).

Felsefi ara sözHem ChatMessageUI sınıfı hem de ChatRestApi.getLast yöntemi, aslında sıralı bir Küme olduğunda List veri türünü kullanır. JDK'da bu kötü bir durumdur, dolayısıyla arayüz düzeyinde öğelerin sırasını bildirmek (ekleme ve çıkarma sırasında sıranın korunması) işe yaramayacaktır. Bu nedenle, sıralı bir Set'e ihtiyaç duyulan durumlarda bir Liste kullanmak yaygın bir uygulama haline geldi (aynı zamanda bir LinkedHashSet de var, ancak bu bir arayüz değil).
Önemli sınırlama: Uzun yanıt veya aktarım zincirlerinin olmadığını varsayacağız. Yani varlar ama uzunlukları üç mesajı geçmiyor. Tüm mesaj zincirinin ön uca iletilmesi gerekir.

Harici hizmetlerden veri almak için aşağıdaki API'ler vardır:

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

Harici hizmetlerin başlangıçta toplu işleme için ve her iki versiyonda da sağlandığı görülebilir: Set aracılığıyla (öğelerin sırasını korumadan, benzersiz anahtarlarla) ve Liste aracılığıyla (kopyalar olabilir - sıra korunur).

Basit uygulamalar

Saf uygulama

REST denetleyicimizin ilk saf uygulaması çoğu durumda şuna benzer:

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

Her şey çok açık ve bu büyük bir artı.

Toplu işlemeyi kullanıyoruz ve verileri harici bir hizmetten toplu olarak alıyoruz. Peki üretkenliğimize ne olacak?

Her mesaj için, yazar alanındaki verileri almak için UserRemoteApi'ye bir çağrı ve tüm ekli dosyaları almak için FileRemoteApi'ye bir çağrı yapılacaktır. Öyle görünüyor. ChatMessage için forwardFrom ve answerTo alanlarının gereksiz çağrılara gerek kalmayacak şekilde elde edildiğini varsayalım. Ancak bunları ChatMessageUI'ye dönüştürmek özyinelemeye yol açacaktır, yani çağrı sayaçları önemli ölçüde artabilir. Daha önce de belirttiğimiz gibi çok fazla iç içe geçmemizin olmadığını ve zincirin üç mesajla sınırlı olduğunu varsayalım.

Sonuç olarak, mesaj başına harici hizmetlere iki ila altı çağrı ve tüm mesaj paketi için bir JPA çağrısı alacağız. Toplam çağrı sayısı 2*N+1 ila 6*N+1 arasında değişecektir. Bu gerçek birimlerde ne kadar? Diyelim ki bir sayfayı oluşturmak için 20 mesaj gerekiyor. Bunları almak 4 saniyeden 10 saniyeye kadar sürecektir. Korkunç! 500 ms içinde tutmak istiyorum. Ve ön uçta kesintisiz kaydırma yapmayı hayal ettikleri için bu uç noktanın performans gereksinimleri iki katına çıkarılabilir.

Artıları:

  1. Kod kısa ve özdür ve kendini belgelemektedir (destek ekibinin rüyası).
  2. Kod basittir, bu nedenle kendinizi ayağınızdan vurma fırsatı neredeyse yoktur.
  3. Toplu işleme yabancı bir şeye benzemiyor ve organik olarak mantığa entegre ediliyor.
  4. Mantıksal değişiklikler kolaylıkla yapılabilecek ve lokal olacaktır.

Eksi:

Çok küçük paketler nedeniyle korkunç performans.

Bu yaklaşıma basit hizmetlerde ya da prototiplerde oldukça sık rastlamak mümkündür. Değişiklik yapma hızı önemliyse, sistemi karmaşıklaştırmaya pek değmez. Aynı zamanda, çok basit hizmetimiz için performans berbattır, dolayısıyla bu yaklaşımın uygulanabilirlik kapsamı çok dardır.

Saf paralel işleme

Tüm mesajları paralel olarak işlemeye başlayabilirsiniz; bu, mesaj sayısına bağlı olarak zamandaki doğrusal artıştan kurtulmanızı sağlayacaktır. Bu özellikle iyi bir yol değildir çünkü harici hizmette büyük bir tepe yüküne neden olacaktır.

Paralel işlemeyi uygulamak çok basittir:

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

Paralel mesaj işlemeyi kullanarak ideal olarak 300-700 ms elde ederiz; bu, saf bir uygulamadan çok daha iyidir, ancak yine de yeterince hızlı değildir.

Bu yaklaşımla userRepository ve fileRepository'ye yapılan istekler eşzamanlı olarak yürütülecektir, bu da pek verimli değildir. Bunu düzeltmek için çağrı mantığını oldukça değiştirmeniz gerekecek. Örneğin, CompletionStage (aka CompletableFuture) aracılığıyla:

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

Başlangıçta basit olan haritalama kodunun daha az anlaşılır hale geldiği görülebilir. Bunun nedeni, harici hizmetlere yapılan çağrıları, sonuçların kullanıldığı yerden ayırmak zorunda kalmamızdı. Bu kendi başına kötü değil. Ancak çağrıları birleştirmek pek zarif görünmüyor ve tipik bir reaktif "erişteye" benziyor.

Eşyordamları kullanırsanız her şey daha düzgün görünecektir:

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

Nerede:

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

Teorik olarak, bu tür paralel işlemeyi kullanarak 200-400 ms elde edeceğiz ki bu zaten beklentilerimize yakın.

Ne yazık ki, bu kadar iyi bir paralellik mevcut değil ve ödenecek bedel oldukça acımasız: aynı anda yalnızca birkaç kullanıcının çalışmasıyla, hizmetler üzerine bir talep yağmuru düşecek ve bunlar zaten paralel olarak işlenmeyecek, bu yüzden biz hüzünlü 4'lü yıllarımıza döneceğiz.

Böyle bir hizmeti kullanırken sonucum 1300 mesajın işlenmesi için 1700-20 ms'dir. Bu, ilk uygulamaya göre daha hızlıdır ancak yine de sorunu çözmez.

Paralel sorguların alternatif kullanımlarıÜçüncü taraf hizmetleri toplu işleme sağlamıyorsa ne olur? Örneğin, toplu işlem uygulamasının eksikliğini arayüz yöntemlerinin içinde gizleyebilirsiniz:

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

Gelecek sürümlerde toplu işleme görmeyi umuyorsanız bu mantıklı olacaktır.
Artıları:

  1. Mesaj tabanlı paralel işlemeyi kolayca uygulayın.
  2. İyi ölçeklenebilirlik.

Eksileri:

  1. Farklı hizmetlere yönelik istekleri paralel olarak işlerken veri edinimini işlenmesinden ayırma ihtiyacı.
  2. Üçüncü taraf hizmetlerde artan yük.

Naif yaklaşımın uygulanabilirlik kapsamının yaklaşık olarak aynı olduğu görülmektedir. Başkalarının acımasızca sömürülmesi nedeniyle hizmetinizin performansını birkaç kat artırmak istiyorsanız paralel istek yöntemini kullanmak mantıklıdır. Örneğimizde performans 2,5 kat arttı ancak bu açıkça yeterli değil.

Önbelleğe almak

Harici hizmetler için JPA ruhuna uygun olarak önbelleğe alma yapabilirsiniz, yani alınan nesneleri bir oturum içinde bir daha alınmayacak şekilde (toplu işleme sırasında da dahil) saklayabilirsiniz. Bu tür önbellekleri kendiniz yapabilirsiniz, Spring'i @Cacheable ile kullanabilirsiniz, ayrıca EhCache gibi hazır bir önbelleği her zaman manuel olarak kullanabilirsiniz.

Yaygın bir sorun, önbelleklerin yalnızca isabetleri olması durumunda yararlı olmasıdır. Bizim durumumuzda, yazar alanındaki isabetler çok muhtemeldir (diyelim ki %50), ancak dosyalar üzerinde hiçbir isabet olmayacaktır. Bu yaklaşım bazı iyileştirmeler sağlayacaktır ancak performansı radikal bir şekilde değiştirmeyecektir (ve bir ilerlemeye ihtiyacımız var).

Oturumlar arası (uzun) önbellekler karmaşık geçersiz kılma mantığı gerektirir. Genel olarak, performans sorunlarını oturumlar arası önbellekleri kullanarak çözmeye ne kadar geç başlarsanız o kadar iyidir.

Artıları:

  1. Kodu değiştirmeden önbelleğe almayı uygulayın.
  2. Üretkenliği birkaç kez artırdı (bazı durumlarda).

Eksileri:

  1. Yanlış kullanıldığında performansın düşmesi olasılığı.
  2. Özellikle uzun önbelleklerde büyük bellek yükü.
  3. Karmaşık geçersiz kılma, hataların çalışma zamanında yeniden üretilmesi zor sorunlara yol açması.

Çoğu zaman önbellekler yalnızca tasarım sorunlarını hızlı bir şekilde düzeltmek için kullanılır. Bu kullanılmamaları gerektiği anlamına gelmez. Ancak bunlara her zaman dikkatli davranmalı ve öncelikle ortaya çıkan performans kazanımını değerlendirmeli ve ancak ondan sonra karar vermelisiniz.

Örneğimizde önbellekler %25 civarında performans artışı sağlayacaktır. Aynı zamanda, önbelleklerin pek çok dezavantajı vardır, bu yüzden onları burada kullanmayacağım.

sonuçlar

Bu nedenle, toplu işlemeyi kullanan bir hizmetin basit bir uygulamasına ve bunu hızlandırmanın bazı basit yollarına baktık.

Tüm bu yöntemlerin temel avantajı, pek çok hoş sonucun ortaya çıktığı basitliktir.

Bu yöntemlerle ilgili yaygın bir sorun, öncelikle paketlerin boyutundan kaynaklanan düşük performanstır. Dolayısıyla bu çözümler size uymuyorsa daha radikal yöntemleri düşünmeye değer.

Çözüm arayabileceğiniz iki ana yön vardır:

  • verilerle eşzamansız çalışma (paradigma değişimi gerektirdiğinden bu makalede ele alınmamıştır);
  • Senkron işlemeyi sürdürürken partilerin büyütülmesi.

Grupların genişletilmesi, harici çağrıların sayısını büyük ölçüde azaltacak ve aynı zamanda kodu senkronize tutacaktır. Makalenin bir sonraki kısmı bu konuya ayrılacaktır.

Kaynak: habr.com

Yorum ekle