Masalah pemrosesan kueri batch dan solusinya (bagian 1)

Masalah pemrosesan kueri batch dan solusinya (bagian 1)Hampir semua produk perangkat lunak modern terdiri dari beberapa layanan. Seringkali, waktu respons yang lama pada saluran antarlayanan menjadi sumber masalah kinerja. Solusi standar untuk masalah seperti ini adalah dengan mengemas beberapa permintaan antar layanan ke dalam satu paket, yang disebut batching.

Jika Anda menggunakan pemrosesan batch, Anda mungkin tidak puas dengan hasilnya dalam hal kinerja atau kejelasan kode. Metode ini tidak semudah yang Anda bayangkan bagi penelepon. Untuk tujuan yang berbeda dan dalam situasi yang berbeda, solusinya bisa sangat bervariasi. Dengan menggunakan contoh spesifik, saya akan menunjukkan pro dan kontra dari beberapa pendekatan.

Proyek demonstrasi

Agar lebih jelas mari kita lihat contoh salah satu layanan pada aplikasi yang sedang saya kerjakan.

Penjelasan pemilihan platform misalnyaMasalah kinerja yang buruk cukup umum dan tidak mempengaruhi bahasa atau platform tertentu. Artikel ini akan menggunakan contoh kode Spring + Kotlin untuk menunjukkan masalah dan solusi. Kotlin sama-sama dapat dipahami (atau tidak dapat dipahami) oleh pengembang Java dan C#, selain itu, kodenya lebih ringkas dan mudah dipahami dibandingkan di Java. Untuk memudahkan pemahaman bagi pengembang Java murni, saya akan menghindari ilmu hitam Kotlin dan hanya menggunakan ilmu putih (dalam semangat Lombok). Akan ada beberapa metode ekstensi, tetapi sebenarnya metode tersebut familiar bagi semua pemrogram Java sebagai metode statis, jadi ini akan menjadi sedikit gula yang tidak akan merusak rasa hidangan.
Ada layanan persetujuan dokumen. Seseorang membuat dokumen dan menyerahkannya untuk didiskusikan, di mana dilakukan pengeditan, dan pada akhirnya dokumen tersebut disetujui. Layanan persetujuan itu sendiri tidak tahu apa-apa tentang dokumen: ini hanya obrolan pemberi persetujuan dengan fungsi tambahan kecil yang tidak akan kami pertimbangkan di sini.

Jadi, ada ruang obrolan (sesuai dengan dokumen) dengan sekumpulan peserta yang telah ditentukan sebelumnya di masing-masing ruang. Seperti dalam obrolan biasa, pesan berisi teks dan file dan dapat dibalas atau diteruskan:

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
)

Tautan file dan pengguna adalah tautan ke domain lain. Di sini kita hidup seperti ini:

typealias FileReference Long
typealias UserReference Long

Data pengguna disimpan di Keycloak dan diambil melalui REST. Hal yang sama berlaku untuk file: file dan metainformasi tentangnya berada di layanan penyimpanan file terpisah.

Semua panggilan ke layanan ini adalah permintaan berat. Artinya, biaya overhead untuk mengirimkan permintaan ini jauh lebih besar dibandingkan waktu yang dibutuhkan untuk memproses permintaan tersebut oleh layanan pihak ketiga. Di bangku pengujian kami, waktu panggilan tipikal untuk layanan tersebut adalah 100 ms, jadi kami akan menggunakan angka-angka ini di masa mendatang.

Kita perlu membuat pengontrol REST sederhana untuk menerima N pesan terakhir dengan semua informasi yang diperlukan. Artinya, kami yakin model pesan di frontend hampir sama dan semua data perlu dikirim. Perbedaan antara model front-end adalah bahwa file dan pengguna perlu disajikan dalam bentuk yang sedikit didekripsi untuk membuat keduanya tertaut:

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

Kita perlu menerapkan hal-hal berikut:

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

Postfix UI berarti model DTO untuk frontend, yaitu apa yang harus kita layani melalui REST.

Yang mungkin mengejutkan di sini adalah kami tidak memberikan ID obrolan apa pun dan bahkan model ChatMessage/ChatMessageUI pun tidak memilikinya. Saya melakukan ini dengan sengaja agar tidak mengacaukan kode contoh (obrolan diisolasi, sehingga kita dapat berasumsi bahwa kita hanya memiliki satu).

Penyimpangan filosofisKelas ChatMessageUI dan metode ChatRestApi.getLast menggunakan tipe data Daftar padahal sebenarnya itu adalah Set yang diurutkan. Ini buruk di JDK, jadi mendeklarasikan urutan elemen di tingkat antarmuka (menjaga urutan saat menambah dan menghapus) tidak akan berfungsi. Jadi sudah menjadi praktik umum untuk menggunakan Daftar jika diperlukan Set yang diurutkan (ada juga LinkedHashSet, tetapi ini bukan antarmuka).
Batasan penting: Kami berasumsi bahwa tidak ada rantai balasan atau transfer yang panjang. Artinya, mereka ada, tetapi panjangnya tidak melebihi tiga pesan. Seluruh rangkaian pesan harus dikirim ke frontend.

Untuk menerima data dari layanan eksternal ada API berikut:

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

Dapat dilihat bahwa layanan eksternal pada awalnya menyediakan pemrosesan batch, dan dalam kedua versi: melalui Set (tanpa mempertahankan urutan elemen, dengan kunci unik) dan melalui Daftar (mungkin ada duplikat - urutannya dipertahankan).

Implementasi sederhana

Implementasi yang naif

Implementasi naif pertama dari pengontrol REST kami akan terlihat seperti ini dalam banyak kasus:

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

Semuanya sangat jelas, dan ini merupakan nilai tambah yang besar.

Kami menggunakan pemrosesan batch dan menerima data dari layanan eksternal secara batch. Tapi apa yang terjadi dengan produktivitas kita?

Untuk setiap pesan, satu panggilan ke UserRemoteApi akan dilakukan untuk mendapatkan data pada kolom penulis dan satu panggilan ke FileRemoteApi untuk mendapatkan semua file terlampir. Sepertinya itu saja. Katakanlah bidang forwardFrom dan replyTo untuk ChatMessage diperoleh sedemikian rupa sehingga tidak memerlukan panggilan yang tidak perlu. Namun mengubahnya menjadi ChatMessageUI akan menyebabkan rekursi, yaitu penghitung panggilan dapat meningkat secara signifikan. Seperti yang kami sebutkan sebelumnya, mari kita asumsikan bahwa kita tidak memiliki banyak pesan dan rantainya terbatas pada tiga pesan.

Hasilnya, kita akan mendapatkan dua hingga enam panggilan ke layanan eksternal per pesan dan satu panggilan JPA untuk seluruh paket pesan. Jumlah total panggilan akan bervariasi dari 2*N+1 hingga 6*N+1. Berapa ini dalam satuan riil? Katakanlah dibutuhkan 20 pesan untuk merender sebuah halaman. Untuk menerimanya, diperlukan waktu 4 hingga 10 detik. Sangat buruk! Saya ingin menyimpannya dalam 500 ms. Dan karena mereka bermimpi membuat pengguliran mulus di frontend, persyaratan kinerja untuk titik akhir ini bisa berlipat ganda.

Pro:

  1. Kode ini ringkas dan mendokumentasikan diri sendiri (impian tim pendukung).
  2. Kodenya sederhana, jadi hampir tidak ada peluang untuk menyalahkan diri sendiri.
  3. Pemrosesan batch tidak terlihat seperti sesuatu yang asing dan terintegrasi secara organik ke dalam logika.
  4. Perubahan logika akan dilakukan dengan mudah dan bersifat lokal.

dikurangi:

Performa buruk karena paket yang sangat kecil.

Pendekatan ini cukup sering terlihat pada layanan sederhana atau prototipe. Jika kecepatan melakukan perubahan itu penting, maka tidak ada gunanya mempersulit sistem. Pada saat yang sama, untuk layanan kami yang sangat sederhana, kinerjanya sangat buruk, sehingga cakupan penerapan pendekatan ini sangat sempit.

Pemrosesan paralel yang naif

Anda dapat mulai memproses semua pesan secara paralel - ini akan menghilangkan peningkatan waktu linier tergantung pada jumlah pesan. Ini bukan jalur yang baik karena akan mengakibatkan beban puncak yang besar pada layanan eksternal.

Menerapkan pemrosesan paralel sangat sederhana:

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

Dengan menggunakan pemrosesan pesan paralel, idealnya kami mendapatkan 300–700 ms, yang jauh lebih baik dibandingkan dengan implementasi naif, namun masih belum cukup cepat.

Dengan pendekatan ini, permintaan ke userRepository dan fileRepository akan dieksekusi secara sinkron, yang sangat tidak efisien. Untuk memperbaikinya, Anda harus banyak mengubah logika panggilan. Misalnya, melalui CompletionStage (alias 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()!!

Terlihat bahwa kode pemetaan yang awalnya sederhana menjadi kurang dipahami. Hal ini karena kami harus memisahkan panggilan ke layanan eksternal dari tempat hasilnya digunakan. Ini sendiri tidaklah buruk. Namun menggabungkan panggilan tidak terlihat terlalu elegan dan menyerupai “mie” reaktif pada umumnya.

Jika Anda menggunakan coroutine, semuanya akan terlihat lebih baik:

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

Dimana:

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

Secara teoritis, dengan menggunakan pemrosesan paralel seperti itu, kami akan mendapatkan 200–400 ms, yang sudah mendekati ekspektasi kami.

Sayangnya, paralelisasi yang baik seperti itu tidak ada, dan harga yang harus dibayar cukup kejam: dengan hanya beberapa pengguna yang bekerja pada waktu yang sama, rentetan permintaan akan jatuh pada layanan, yang tidak akan diproses secara paralel, jadi kami akan kembali ke 4 detik kita yang menyedihkan.

Hasil saya saat menggunakan layanan tersebut adalah 1300–1700 ms untuk memproses 20 pesan. Ini lebih cepat dibandingkan implementasi pertama, namun masih belum menyelesaikan masalah.

Penggunaan alternatif kueri paralelBagaimana jika layanan pihak ketiga tidak menyediakan pemrosesan batch? Misalnya, Anda dapat menyembunyikan kekurangan implementasi pemrosesan batch di dalam metode antarmuka:

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

Ini masuk akal jika Anda berharap melihat pemrosesan batch di versi mendatang.
Pro:

  1. Implementasikan pemrosesan paralel berbasis pesan dengan mudah.
  2. Skalabilitas yang bagus.

Cons:

  1. Kebutuhan untuk memisahkan perolehan data dari pemrosesannya saat memproses permintaan ke layanan berbeda secara paralel.
  2. Peningkatan beban pada layanan pihak ketiga.

Terlihat bahwa cakupan penerapannya kurang lebih sama dengan pendekatan naif. Masuk akal untuk menggunakan metode permintaan paralel jika Anda ingin meningkatkan kinerja layanan Anda beberapa kali karena eksploitasi pihak lain tanpa ampun. Dalam contoh kami, kinerja meningkat 2,5 kali lipat, tetapi ini jelas tidak cukup.

caching

Anda dapat melakukan caching dalam semangat JPA untuk layanan eksternal, yaitu menyimpan objek yang diterima dalam suatu sesi agar tidak menerimanya lagi (termasuk selama pemrosesan batch). Anda dapat membuat cache tersebut sendiri, Anda dapat menggunakan Spring dengan @Cacheable-nya, ditambah lagi Anda selalu dapat menggunakan cache yang sudah jadi seperti EhCache secara manual.

Masalah yang umum terjadi adalah cache hanya berguna jika ada yang berhasil. Dalam kasus kami, kemungkinan besar terjadi klik pada bidang penulis (katakanlah, 50%), tetapi tidak akan ada klik pada file sama sekali. Pendekatan ini akan memberikan beberapa perbaikan, namun tidak akan mengubah kinerja secara radikal (dan kita memerlukan terobosan).

Cache intersesi (panjang) memerlukan logika pembatalan yang rumit. Secara umum, semakin lama Anda menyelesaikan masalah kinerja menggunakan cache intersesi, semakin baik.

Pro:

  1. Menerapkan caching tanpa mengubah kode.
  2. Peningkatan produktivitas beberapa kali (dalam beberapa kasus).

Cons:

  1. Kemungkinan berkurangnya performa jika digunakan secara tidak benar.
  2. Overhead memori yang besar, terutama dengan cache yang panjang.
  3. Pembatalan yang rumit, kesalahan yang akan menyebabkan masalah yang sulit direproduksi saat runtime.

Seringkali, cache hanya digunakan untuk memperbaiki masalah desain dengan cepat. Ini tidak berarti mereka tidak boleh digunakan. Namun, Anda harus selalu memperlakukannya dengan hati-hati dan terlebih dahulu mengevaluasi peningkatan kinerja yang dihasilkan, baru kemudian mengambil keputusan.

Dalam contoh kita, cache akan memberikan peningkatan kinerja sekitar 25%. Pada saat yang sama, cache memiliki banyak kelemahan, jadi saya tidak akan menggunakannya di sini.

Hasil

Jadi, kami melihat implementasi naif dari layanan yang menggunakan pemrosesan batch, dan beberapa cara sederhana untuk mempercepatnya.

Keuntungan utama dari semua metode ini adalah kesederhanaannya, yang memiliki banyak konsekuensi menyenangkan.

Masalah umum pada metode ini adalah kinerja yang buruk, terutama karena ukuran paket. Oleh karena itu, jika solusi ini tidak cocok untuk Anda, ada baiknya mempertimbangkan metode yang lebih radikal.

Ada dua arah utama di mana Anda dapat mencari solusi:

  • pekerjaan asinkron dengan data (memerlukan perubahan paradigma, sehingga tidak dibahas dalam artikel ini);
  • pembesaran batch sambil mempertahankan pemrosesan yang sinkron.

Pembesaran batch akan sangat mengurangi jumlah panggilan eksternal dan pada saat yang sama menjaga kode tetap sinkron. Bagian selanjutnya dari artikel ini akan dikhususkan untuk topik ini.

Sumber: www.habr.com

Tambah komentar