Hampir semua produk perangkat lunak modern terdiri dari beberapa layanan. Seringkali, waktu respons saluran antar-layanan yang lama menjadi sumber masalah kinerja. Solusi standar untuk masalah semacam ini adalah mengemas beberapa permintaan interservice ke dalam satu paket, yang disebut batching.
Jika Anda menggunakan pemrosesan batch, Anda mungkin tidak senang dengan hasilnya dalam hal kinerja atau kelengkapan kode. Metode ini tidak semudah penelepon karena Anda mungkin berpikir. Untuk tujuan yang berbeda dan dalam situasi yang berbeda, keputusan dapat sangat bervariasi. Pada contoh spesifik, saya akan menunjukkan pro dan kontra dari beberapa pendekatan.
Proyek demo
Untuk kejelasan, pertimbangkan contoh salah satu layanan dalam aplikasi yang sedang saya kerjakan.
Penjelasan tentang pilihan platform untuk contohMasalah kinerja yang buruk cukup umum dan tidak berlaku untuk bahasa dan platform tertentu. Artikel ini akan menggunakan contoh kode Spring + Kotlin untuk menunjukkan tugas dan solusi. Kotlin sama-sama dapat dimengerti (atau tidak bisa dipahami) untuk pengembang Java dan C #, di samping itu, kode ini lebih kompak dan dapat dimengerti daripada di Jawa. Untuk memudahkan pemahaman bagi pengembang Java murni, saya akan menghindari sihir hitam Kotlin dan hanya menggunakan putih (dalam semangat Lombok). Akan ada beberapa metode ekstensi, tetapi mereka sebenarnya akrab bagi semua programmer 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 diskusi, di mana penyuntingan dilakukan, dan akhirnya dokumen itu konsisten. Layanan rekonsiliasi itu sendiri tidak tahu apa-apa tentang dokumen: itu hanya obrolan koordinator dengan fungsi tambahan kecil, yang tidak akan kami pertimbangkan di sini.
Jadi, ada ruang obrolan (sesuai dengan dokumen) dengan satu set peserta yang telah ditentukan di masing-masing. Seperti dalam obrolan biasa, pesan berisi teks dan file dan dapat menjadi balasan dan penerusan:
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 ke file dan pengguna adalah tautan ke domain lain. Ia hidup bersama kita seperti ini:
typealias FileReference = Long
typealias UserReference = Long
Data pengguna disimpan dalam Keycloak dan diambil melalui REST. Hal yang sama berlaku untuk file: file dan meta-informasi tentang mereka tinggal di layanan penyimpanan file terpisah.
Semua panggilan ke layanan ini adalah
permintaan berat . Ini berarti bahwa biaya overhead untuk mengangkut permintaan ini jauh lebih besar daripada waktu yang diperlukan untuk memprosesnya dengan layanan pihak ketiga. Pada uji kami, waktu panggilan khas untuk layanan tersebut adalah 100 ms, jadi di masa mendatang kami akan menggunakan nomor-nomor ini.
Kita perlu membuat pengontrol REST sederhana untuk menerima pesan N terakhir dengan semua informasi yang diperlukan. Artinya, kami percaya bahwa di frontend, model pesan hampir sama dan kami perlu mengirim semua data. Perbedaan antara model frontend adalah bahwa file dan pengguna harus disajikan dalam bentuk yang sedikit didekripsi untuk membuat tautan:
/** */
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 yang berikut:
interface ChatRestApi {
fun getLast ( n : Int ) : List < ChatMessageUI >
}
UI Postfix berarti model DTO untuk frontend, yaitu, apa yang harus kita berikan melalui REST.
Mungkin tampak mengejutkan di sini bahwa kami tidak melewati pengenal obrolan apa pun dan bahkan dalam model ChatMessage / ChatMessageUI tidak. Saya melakukan ini dengan sengaja, agar tidak mengacaukan kode untuk contoh (obrolan terisolasi, sehingga kita dapat berasumsi bahwa kita punya satu sama sekali).
Retret FilsafatBaik kelas ChatMessageUI dan metode ChatRestApi.getLast menggunakan tipe data Daftar, sedangkan ini sebenarnya adalah Set yang dipesan. Di JDK, ini semua buruk, jadi mendeklarasikan urutan elemen di tingkat antarmuka (menjaga urutan saat menambahkan dan mengekstrak) akan gagal. Jadi itu adalah praktik umum untuk menggunakan Daftar jika Anda memerlukan Set yang dipesan (masih ada LinkedHashSet, tetapi ini bukan antarmuka).
Keterbatasan penting: kami berasumsi bahwa tidak ada rantai panjang tanggapan atau ke depan. Yaitu, tetapi panjangnya tidak melebihi tiga pesan. Rantai pesan front-end harus ditransmisikan secara keseluruhan.
Untuk menerima data dari layanan eksternal, ada API seperti itu:
interface ChatMessageRepository {
fun findLast ( n : Int ) : List < ChatMessage >
}
data class FileHeadRemote (
val id : FileReference ,
val name : String
)
interface FileRemoteApi {
fun getHeadById ( id : FileReference ) : FileHeadRemote
fun getHeadsByIds ( id : Set < FileReference > ) : Set < FileHeadRemote >
fun getHeadsByIds ( id : List < FileReference > ) : List < FileHeadRemote >
fun getHeadsByChat () : List < FileHeadRemote >
}
data class UserRemote (
val id : UserReference ,
val name : String
)
interface UserRemoteApi {
fun getUserById ( id : UserReference ) : UserRemote
fun getUsersByIds ( id : Set < UserReference > ) : Set < UserRemote >
fun getUsersByIds ( id : List < UserReference > ) : List < UserRemote >
}
Dapat dilihat bahwa pemrosesan batch pada awalnya disediakan untuk layanan eksternal, dan dalam kedua kasus: melalui Set (tanpa menjaga urutan elemen, dengan kunci unik) dan melalui Daftar (mungkin ada duplikat - urutannya dipertahankan).
Implementasi sederhana
Implementasi naif
Implementasi naif pertama dari pengendali 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 ( n : Int ) =
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 dalam batch. Tetapi apa yang terjadi dengan kinerja?
Untuk setiap pesan, satu panggilan ke UserRemoteApi akan dilakukan untuk mendapatkan data di bidang penulis dan satu panggilan ke FileRemoteApi untuk menerima semua file yang dilampirkan. Sepertinya semuanya. Asumsikan bahwa bidang forwardFrom dan replyTo untuk ChatMessage diperoleh sehingga ini tidak memerlukan panggilan tambahan. Tetapi mengubahnya menjadi ChatMessageUI akan menyebabkan rekursi, yaitu, kinerja jumlah panggilan dapat sangat meningkat. Seperti yang kami catat sebelumnya, katakanlah bahwa kami tidak memiliki banyak sarang dan rantai dibatasi hingga tiga pesan.
Sebagai hasilnya, kami mendapatkan dua hingga enam panggilan ke layanan eksternal per pesan dan satu panggilan JPA ke seluruh paket pesan. Jumlah total panggilan akan bervariasi dari 2 * N + 1 hingga 6 * N + 1. Berapa ini dalam satuan nyata? Misalkan Anda membutuhkan 20 posting untuk membuat halaman. Untuk mendapatkannya, Anda perlu dari 4 hingga 10 detik. Mengerikan Saya ingin memenuhi 500 ms. Dan karena ujung depan bermimpi membuat gulungan tanpa batas, persyaratan kinerja titik akhir ini dapat digandakan.
Pro:- Kode ini ringkas dan mendokumentasikan diri sendiri (impian dukungan).
- Kode ini sederhana, jadi hampir tidak ada peluang untuk menembak di kaki.
- Pemrosesan batch tidak terlihat asing dan secara organik cocok dengan logika.
- Perubahan logika akan dibuat dengan mudah dan akan bersifat lokal.
Minus:Performa yang mengerikan karena paket-paketnya sangat kecil.
Pendekatan ini sering dapat dilihat dalam layanan sederhana atau dalam prototipe. Jika kecepatan perubahan itu penting, sulit untuk menyulitkan 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 linear dalam waktu tergantung pada jumlah pesan. Ini bukan cara yang baik, karena akan menyebabkan beban puncak yang besar pada layanan eksternal.
Menerapkan pemrosesan paralel sangat sederhana:
override fun getLast ( n : Int ) =
messageRepository . findLast ( n ) . parallelStream ()
. map { it . toFrontModel () }
. collect ( toList ())
Menggunakan pemrosesan pesan paralel, kami mendapatkan 300-700 ms idealnya, yang jauh lebih baik daripada dengan implementasi yang naif, tetapi masih belum cukup cepat.
Dengan pendekatan ini, permintaan ke userRepository dan fileRepository akan dijalankan secara serempak, yang sangat tidak efisien. Untuk mengatasinya, 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 ())
) { author , files ->
ChatMessageUI (
id = id ?: throw IllegalStateException ( " $ this must be persisted" ) ,
author = author ,
message = message ,
files = files ,
forwardFrom = forwardFrom ?. toFrontModel () ,
replyTo = replyTo ?. toFrontModel ()
)
} . get () !!
Dapat dilihat bahwa kode pemetaan yang awalnya sederhana menjadi kurang jelas. Ini karena kami harus memisahkan panggilan layanan eksternal dari tempat hasil digunakan. Ini sendiri tidak buruk. Tetapi kombinasi panggilan tidak terlihat sangat elegan dan menyerupai "mie" reaktif yang khas.
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 < A , B > join ( a : () -> A , b : () -> B ) =
runBlocking ( IO ) {
awaitAll ( async { a () } , async { b () } )
} . let {
it [ 0 ] as A to it [ 1 ] as B
}
Secara teoritis, menggunakan pemrosesan paralel seperti itu, kami mendapatkan 200-400 ms, yang sudah dekat dengan harapan kami.
Sayangnya, paralelisasi yang baik tidak terjadi, dan pengembaliannya cukup kejam: dengan hanya beberapa pengguna yang bekerja pada saat yang sama, banyak permintaan akan jatuh pada layanan, yang masih tidak akan diproses secara paralel, jadi kami akan kembali ke 4 s kami yang menyedihkan.
Hasil saya ketika menggunakan layanan tersebut adalah 1300-1700 ms untuk memproses 20 pesan. Ini lebih cepat dari pada implementasi pertama, tetapi masih tidak menyelesaikan masalah.
Alternatif penggunaan kueri paralelBagaimana jika pemrosesan batch tidak disediakan dalam layanan pihak ketiga? Misalnya, Anda dapat menyembunyikan kurangnya implementasi pemrosesan batch di dalam metode antarmuka:
interface UserRemoteApi {
fun getUserById ( id : UserReference ) : UserRemote
fun getUsersByIds ( id : Set < UserReference > ) : Set < UserRemote > =
id . parallelStream ()
. map { getUserById ( it ) } . collect ( toSet ())
fun getUsersByIds ( id : List < UserReference > ) : List < UserRemote > =
id . parallelStream ()
. map { getUserById ( it ) } . collect ( toList ())
}
Ini masuk akal jika ada harapan bahwa pemrosesan batch akan muncul di versi mendatang.
Pro:- Implementasi yang mudah dari pemrosesan pesan secara bersamaan.
- Skalabilitas yang baik.
Cons:- Kebutuhan untuk memisahkan penerimaan data dari pemrosesan mereka dalam permintaan pemrosesan paralel untuk berbagai layanan.
- Peningkatan muatan pada layanan pihak ketiga.
Dapat dilihat bahwa ruang lingkup penerapannya kurang lebih sama dengan pendekatan naif. Menggunakan metode kueri paralel masuk akal jika Anda ingin meningkatkan kinerja layanan Anda beberapa kali karena eksploitasi tanpa ampun dari orang lain. Dalam contoh kami, produktivitas meningkat 2,5 kali, tetapi ini jelas tidak cukup.
Caching
Anda bisa melakukan caching gaya JPA untuk layanan eksternal, yaitu, menyimpan objek yang diterima dalam sesi agar tidak menerimanya lagi (termasuk selama pemrosesan batch). Anda dapat membuat cache seperti itu sendiri, Anda dapat menggunakan Spring dengan @Cacheable-nya, ditambah Anda selalu dapat menggunakan cache yang sudah jadi seperti EhCache secara manual.
Masalah umum akan terkait dengan fakta bahwa ada rasa yang baik dari cache hanya jika ada hit. Dalam kasus kami, klik pada bidang penulis (katakanlah, 50%) sangat mungkin, dan tidak akan ada hit pada file sama sekali. Pendekatan ini akan membawa beberapa perbaikan, tetapi kinerjanya tidak akan berubah secara radikal (dan kita perlu terobosan).
Tembolok intersersi (panjang) memerlukan logika pembatalan yang rumit. Secara umum, semakin Anda sampai pada titik bahwa Anda akan menyelesaikan masalah kinerja dengan cache intersessional, semakin baik.
Pro:- Terapkan caching tanpa mengubah kode.
- Kinerja meningkat beberapa kali (dalam beberapa kasus).
Cons:- Kemungkinan untuk mengurangi kinerja jika digunakan secara tidak benar.
- Overhead memori besar, terutama dengan cache yang panjang.
- Invalidation yang kompleks, kesalahan yang akan menyebabkan masalah yang sulit di runtime.
Sangat sering, cache hanya digunakan untuk dengan cepat memperbaiki masalah desain. Ini tidak berarti bahwa mereka tidak perlu digunakan. Namun, selalu bermanfaat untuk memperlakukan mereka dengan hati-hati dan pertama mengevaluasi perolehan kinerja yang dihasilkan, dan baru kemudian membuat keputusan.
Dalam contoh kami, cache akan memiliki peningkatan kinerja sekitar 25%. Pada saat yang sama, cache memiliki banyak kelemahan, jadi saya tidak akan menggunakannya di sini.
Ringkasan
Jadi, kami melihat implementasi naif dari layanan yang menggunakan pemrosesan batch, dan beberapa cara sederhana untuk mempercepatnya.
Keuntungan utama dari semua metode ini adalah kesederhanaan, dari mana ada banyak konsekuensi yang menyenangkan.
Masalah umum dengan metode ini adalah kinerja yang buruk, terutama karena ukuran paket. Karena itu, jika solusi ini tidak cocok untuk Anda, maka ada baiknya mempertimbangkan metode yang lebih radikal.
Ada dua area utama di mana Anda dapat mencari solusi:
- Kerja asinkron dengan data (memerlukan perubahan paradigma, oleh karena itu, artikel ini tidak dipertimbangkan)
- memperbesar paket sambil mempertahankan pemrosesan yang sinkron.
Pembesaran bundel akan sangat mengurangi jumlah panggilan eksternal dan pada saat yang sama menjaga kode sinkron. Bagian selanjutnya dari artikel ini akan dikhususkan untuk topik ini.