Halo semuanya. Nama saya Alexander, saya adalah pengembang Java di grup perusahaan Tinkoff.
Dalam artikel ini saya ingin berbagi pengalaman saya dalam memecahkan masalah yang terkait dengan sinkronisasi kondisi cache dalam sistem terdistribusi. Kami bertemu mereka, memecah aplikasi monolitik menjadi layanan
mikro . Jelas, kita akan berbicara tentang caching data di level JVM, karena dengan cache eksternal, masalah sinkronisasi diselesaikan di luar konteks aplikasi.
Dalam artikel ini, saya akan berbicara tentang pengalaman kami beralih ke arsitektur berorientasi layanan, disertai dengan kepindahan ke Kubernetes, dan tentang penyelesaian masalah terkait. Kami akan mempertimbangkan pendekatan untuk mengorganisasikan sistem penyimpanan terdistribusi In-Memory Data Grid (IMDG), kelebihan dan kekurangannya, karena itu kami memutuskan untuk menulis solusi kami sendiri.
Artikel ini membahas proyek backend yang ditulis dalam Java. Oleh karena itu, kami juga akan berbicara tentang standar di bidang caching sementara di memori. Mari kita bahas spesifikasi JSR-107, spesifikasi JSR-347 yang gagal, dan fitur caching di Spring. Selamat datang di kucing!
Dan mari kita potong aplikasi menjadi layanan ...
Kami akan beralih ke arsitektur berorientasi layanan dan pindah ke Kubernetes - itulah yang kami putuskan sedikit lebih dari 6 bulan lalu. Untuk waktu yang lama, proyek kami adalah monolit, banyak masalah yang berkaitan dengan akumulasi utang teknis, dan kami menulis modul aplikasi baru sama sekali sebagai layanan terpisah. Akibatnya, transisi ke arsitektur berorientasi layanan dan pemotongan monolit tidak bisa dihindari.
Aplikasi kami dimuat, rata-rata 500 rps datang ke layanan web (pada puncaknya mencapai 900 rps). Untuk mengumpulkan seluruh model data dalam menanggapi setiap permintaan, Anda harus membuka berbagai cache beberapa ratus kali.
Kami mencoba untuk pergi ke cache jarak jauh tidak lebih dari tiga kali per permintaan, tergantung pada set data yang diperlukan, dan pada cache JVM internal, bebannya mencapai 90.000 rps per cache. Kami memiliki sekitar 30 cache seperti itu untuk berbagai entitas dan DTO-shki. Pada beberapa cache yang dimuat, kami bahkan tidak mampu menghapus nilainya, karena hal ini dapat menyebabkan peningkatan pada waktu respons layanan web dan crash pada aplikasi.
Ini adalah bagaimana pemantauan beban terlihat, dihapus dari cache internal pada setiap node di siang hari. Menurut profil pemuatan, mudah untuk melihat bahwa sebagian besar permintaan adalah data yang dibaca. Beban tulis yang seragam adalah karena memperbarui nilai dalam cache pada frekuensi yang diberikan.
Waktu henti tidak berlaku untuk aplikasi kami. Oleh karena itu, untuk tujuan penyebaran yang mulus, kami selalu menyeimbangkan semua lalu lintas masuk ke dua node dan menggunakan aplikasi menggunakan metode Pembaruan Bergulir. Kubernetes menjadi solusi infrastruktur ideal kami saat beralih ke layanan. Jadi, kami memecahkan beberapa masalah sekaligus.
Masalah terus-menerus memesan dan menyiapkan infrastruktur untuk layanan baru
Kami diberi namespace di cluster untuk setiap sirkuit, yang kami miliki tiga: dev - untuk pengembang, qa - untuk penguji, prod - untuk klien.
Dengan namespace disorot, menambahkan layanan atau aplikasi baru bermula untuk menulis empat manifes: Penempatan, Layanan, Masuk, dan ConfigMap.
Toleransi beban tinggi
Bisnis ini berkembang dan terus tumbuh - setahun yang lalu beban rata-rata dua kali lebih sedikit dari yang sekarang.
Penskalaan horisontal di Kubernetes memungkinkan Anda untuk meratakan skala ekonomis dengan meningkatnya beban kerja proyek yang dikembangkan.
Pemeliharaan, pengumpulan dan pemantauan log
Hidup menjadi jauh lebih mudah ketika tidak perlu menambahkan log ke sistem logging ketika menambahkan setiap node, mengkonfigurasi pagar metrik (kecuali Anda memiliki sistem pemantauan push), melakukan pengaturan jaringan dan cukup menginstal perangkat lunak yang diperlukan untuk operasi.
Tentu saja, semua ini dapat diotomatisasi menggunakan Ansible atau Terraform, tetapi pada akhirnya, menulis banyak manifes untuk setiap layanan jauh lebih mudah.
Keandalan tinggi
Mekanisme built-in k8s sampel Liveness- dan Readiness-memungkinkan Anda untuk tidak khawatir bahwa aplikasi mulai melambat atau sepenuhnya berhenti merespons.
Kubernetes sekarang mengontrol siklus hidup pod perapian berisi kontainer aplikasi dan lalu lintas yang diarahkan ke sana.
Seiring dengan fasilitas yang dijelaskan, kami perlu menyelesaikan sejumlah masalah untuk membuat layanan yang sesuai untuk penskalaan horizontal dan penggunaan model data umum untuk banyak layanan. Itu perlu untuk memecahkan dua masalah:
- Status aplikasi. Ketika proyek ditempatkan di kluster k8s, pod dengan wadah versi baru dari aplikasi mulai dibuat yang tidak terkait dengan keadaan pod dari versi sebelumnya. Pod aplikasi baru dapat dinaikkan pada server cluster sewenang-wenang yang memenuhi batasan yang ditentukan. Juga, sekarang setiap kontainer aplikasi yang berjalan di dalam pod Kubernetes dapat dimusnahkan kapan saja jika probe Liveness mengatakan bahwa itu perlu dimulai ulang.
- Konsistensi data. Hal ini diperlukan untuk menjaga konsistensi dan integritas data satu sama lain di semua node. Ini terutama benar jika banyak node bekerja dalam model data tunggal. Tidak dapat diterima bahwa ketika permintaan ke simpul aplikasi yang berbeda dalam respons, data yang tidak konsisten datang ke klien.
Dalam pengembangan modern dari sistem yang dapat diskalakan, arsitektur Stateless adalah solusi untuk masalah di atas. Kami menyingkirkan masalah pertama dengan memindahkan semua statika ke penyimpanan cloud S3.
Namun, karena kebutuhan untuk menggabungkan model data yang kompleks dan menghemat waktu respons layanan web kami, kami tidak dapat menolak untuk menyimpan data dalam cache di-memori. Untuk mengatasi masalah kedua, mereka menulis perpustakaan untuk menyinkronkan keadaan cache internal masing-masing node.
Kami menyinkronkan cache pada node yang terpisah
Sebagai data awal kami memiliki sistem terdistribusi yang terdiri dari N node. Setiap node memiliki sekitar 20 cache dalam memori, data yang diperbarui beberapa kali per jam.
Sebagian besar cache memiliki kebijakan refresh data TTL (time-to-live), beberapa data diperbarui dengan operasi CRON setiap 20 menit karena beban yang tinggi. Beban kerja pada cache bervariasi dari beberapa ribu rps di malam hari hingga beberapa puluh ribu di siang hari. Beban puncak, sebagai suatu peraturan, tidak melebihi 100.000 rps. Jumlah catatan dalam penyimpanan sementara tidak melebihi beberapa ratus ribu dan ditempatkan di tumpukan satu node.
Tugas kami adalah mencapai konsistensi data antara cache yang sama pada node yang berbeda, serta waktu respons sesingkat mungkin. Pertimbangkan apa yang umumnya ada cara untuk menyelesaikan masalah ini.
Solusi pertama dan paling sederhana yang terlintas dalam pikiran adalah untuk meletakkan semua informasi dalam cache jarak jauh. Dalam hal ini, Anda dapat sepenuhnya menghilangkan status aplikasi, tidak memikirkan masalah dalam mencapai konsistensi dan memiliki satu jalur akses ke gudang data sementara.
Metode penyimpanan data sementara ini cukup sederhana, dan kami menggunakannya. Kami men-cache bagian dari data di
Redis , yang merupakan penyimpanan data NoSQL dalam RAM. Di Redis, kami biasanya merekam kerangka respons layanan web, dan untuk setiap permintaan kami perlu memperkaya data ini dengan informasi yang relevan, yang mengharuskan pengiriman beberapa ratus permintaan ke cache lokal.
Jelas, kami tidak dapat mengambil data cache internal untuk penyimpanan jarak jauh, karena biaya pengiriman volume lalu lintas melalui jaringan tidak akan memungkinkan kami untuk memenuhi waktu respons yang diperlukan.
Opsi kedua adalah menggunakan
In-Memory Data Grid (IMDG), yang merupakan cache In-memory terdistribusi. Skema solusi semacam itu adalah sebagai berikut:
Arsitektur IMDG didasarkan pada prinsip Partisi Data dari cache internal masing-masing node. Bahkan, ini bisa disebut tabel hash yang didistribusikan pada sekelompok node. IMDG dianggap sebagai salah satu implementasi tercepat penyimpanan terdistribusi sementara.
Ada banyak implementasi IMDG, yang paling populer adalah
Hazelcast . Cache terdistribusi memungkinkan Anda untuk menyimpan data dalam RAM pada beberapa node aplikasi dengan tingkat keandalan dan pemeliharaan konsistensi yang dapat diterima, yang dicapai dengan replikasi data.
Tugas membangun cache terdistribusi seperti itu tidak mudah, namun, menggunakan solusi IMDG yang sudah jadi bagi kita bisa menjadi pengganti yang baik untuk cache JVM dan menghilangkan masalah replikasi, konsistensi dan distribusi data antara semua node aplikasi.
Sebagian besar vendor IMDG untuk aplikasi Java menerapkan
JSR-107 , API Java standar untuk bekerja dengan cache internal. Secara umum, standar ini memiliki cerita yang agak besar, yang akan saya bahas secara lebih rinci di bawah ini.
Sekali waktu ada ide untuk mengimplementasikan antarmuka Anda untuk berinteraksi dengan IMDG -
JSR 347 . Tetapi implementasi API semacam itu tidak menerima dukungan yang memadai dari komunitas Java, dan sekarang kami memiliki antarmuka tunggal untuk berinteraksi dengan cache dalam memori, terlepas dari arsitektur aplikasi kami. Baik atau buruk adalah pertanyaan lain, tetapi ini memungkinkan kita untuk sepenuhnya mengabaikan semua kesulitan dalam mengimplementasikan cache dalam-memori terdistribusi dan bekerja dengannya sebagai cache dari aplikasi monolitik.
Meskipun ada keuntungan jelas menggunakan IMDG, solusi ini masih lebih lambat dari cache JVM standar, karena overhead memastikan replikasi data yang berkelanjutan didistribusikan antara beberapa node JVM, serta membuat cadangan data ini. Dalam kasus kami, jumlah data untuk penyimpanan sementara tidak begitu besar, data dengan margin sesuai dalam memori satu aplikasi, sehingga alokasi mereka untuk beberapa JVM sepertinya solusi yang tidak perlu. Dan lalu lintas jaringan tambahan antara node aplikasi di bawah beban berat dapat sangat memengaruhi kinerja dan meningkatkan waktu respons layanan web. Pada akhirnya, kami memutuskan untuk menulis solusi kami sendiri untuk masalah ini.
Kami meninggalkan cache dalam memori sebagai penyimpanan sementara data, dan untuk menjaga konsistensi kami menggunakan manajer antrian RabbitMQ. Kami mengadopsi pola desain perilaku
Penerbit-Pelanggan , dan mempertahankan relevansi data dengan menghapus entri yang dimodifikasi dari cache setiap node. Skema solusinya adalah sebagai berikut:
Diagram menunjukkan sekelompok N node, masing-masing memiliki cache In-memory standar. Semua node menggunakan model data umum dan harus konsisten. Pada akses pertama ke cache dengan kunci sewenang-wenang, nilai dalam cache tidak ada, dan kami memasukkan nilai aktual dari database ke dalamnya. Dengan perubahan apa pun - hapus catatan.
Informasi aktual dalam respons cache di sini disediakan dengan menyinkronkan penghapusan entri ketika diubah pada salah satu node. Setiap node dalam sistem memiliki antrian di manajer antrian RabbitMQ. Perekaman ke semua antrian dilakukan melalui titik akses Jenis-topik umum. Ini berarti bahwa pesan yang dikirim ke Topik termasuk dalam semua antrian yang terkait dengannya. Jadi, ketika mengubah nilai pada setiap simpul sistem, nilai ini akan dihapus dari penyimpanan sementara setiap node, dan akses selanjutnya akan memulai penulisan nilai saat ini ke cache dari database.
Omong-omong, mekanisme PUB / SUB serupa ada di Redis. Tapi, menurut saya, masih lebih baik menggunakan manajer antrian untuk bekerja dengan antrian, dan RabbitMQ sempurna untuk tugas kami.
JSR 107 standar dan implementasinya
API Java Cache standar untuk penyimpanan sementara data dalam memori (spesifikasi
JSR-107 ) memiliki sejarah yang agak panjang, telah dikembangkan selama 12 tahun.
Selama sekian lama, pendekatan untuk pengembangan perangkat lunak telah berubah, monolit telah digantikan oleh arsitektur layanan mikro. Karena kurangnya spesifikasi untuk Cache API, bahkan ada permintaan untuk mengembangkan cache API untuk sistem terdistribusi
JSR-347 (Grid Data untuk Platform Java). Tetapi setelah rilis lama JSR-107 dan rilis JCache, permintaan untuk membuat spesifikasi terpisah untuk sistem terdistribusi ditarik.
Selama 12 tahun di pasaran, tempat penyimpanan data sementara telah berubah dari HashMap ke ConcurrentHashMap dengan rilis Java 1.5, dan kemudian banyak implementasi open source siap pakai dari caching dalam memori muncul.
Setelah rilis JSR-107, solusi vendor mulai secara bertahap mengimplementasikan spesifikasi baru. Untuk JCache, bahkan ada penyedia yang berspesialisasi dalam caching terdistribusi - Data Grids, spesifikasi yang belum pernah diterapkan.
Pertimbangkan untuk apa paket
javax.cache terdiri , dan bagaimana cara mendapatkan instance cache untuk aplikasi kita:
CachingProvider provider = Caching.getCachingProvider("org.cache2k.jcache.provider.JCacheProvider"); CacheManager cacheManager = provider.getCacheManager(); CacheConfiguration<Integer, String> config = new MutableConfiguration<Integer, String>() .setTypes(Integer.class, String.class) .setReadThrough(true) . . .; Cache<Integer, String> cache = cacheManager.createCache(cacheName, config);
Di sini Caching adalah boot loader untuk CachingProvider.
Dalam kasus kami, JCacheProvider, yang merupakan implementasi cache2k dari penyedia JSR-107
SPI , akan dimuat dari ClassLoader. Untuk loader, Anda tidak bisa menentukan implementasi penyedia, tetapi kemudian akan mencoba memuat implementasi yang ada di dalamnya
META-INF / services / javax.cache.spi.CachingProvider
Bagaimanapun, di ClassLoader harus ada satu implementasi CachingProvider.
Jika Anda menggunakan pustaka javax.cache tanpa implementasi apa pun, pengecualian akan muncul saat Anda mencoba membuat JCache. Tujuan penyedia adalah untuk membuat dan mengelola siklus hidup CacheManager, yang, pada gilirannya, bertanggung jawab untuk mengelola dan mengkonfigurasi cache. Jadi, untuk membuat cache, Anda harus pergi dengan cara berikut:
Tembolok standar yang dibuat menggunakan CacheManager harus memiliki konfigurasi yang kompatibel dengan implementasi. Konfigurasi Cache parameterized standar yang disediakan oleh javax.cache dapat diperluas ke implementasi CacheProvider tertentu.
Hari ini, ada lusinan implementasi berbeda dari spesifikasi JSR-107:
Ehcache ,
Guava ,
caffeine ,
cache2k . Banyak implementasinya adalah In-Memory Data Grid dalam sistem terdistribusi -
Hazelcast ,
Oracle Coherence .
Ada juga banyak implementasi penyimpanan sementara yang tidak mendukung API standar. Untuk waktu yang lama dalam proyek kami, kami menggunakan Ehcache 2, yang tidak kompatibel dengan JCache (implementasi spesifikasi muncul dengan Ehcache 3). Kebutuhan untuk transisi ke implementasi yang kompatibel dengan JCache muncul dengan kebutuhan untuk memantau status cache dalam memori. Menggunakan standar MetricRegistry, dimungkinkan untuk mempercepat pemantauan hanya dengan bantuan implementasi JCacheGaugeSet, yang mengumpulkan metrik dari JCache standar.
Bagaimana memilih implementasi cache Dalam-memori yang sesuai untuk proyek Anda? Mungkin Anda harus memperhatikan hal-hal berikut:
- Apakah Anda memerlukan dukungan untuk spesifikasi JSR-107.
- Perlu juga diperhatikan kecepatan implementasi yang dipilih. Di bawah beban berat, kinerja cache internal dapat memiliki dampak signifikan pada waktu respons sistem Anda.
- Dukungan di Musim Semi. Jika Anda menggunakan kerangka kerja terkenal di proyek Anda, perlu mempertimbangkan fakta bahwa tidak setiap implementasi cache JVM memiliki CacheManager yang kompatibel di Spring.
Jika Anda secara aktif menggunakan Spring dalam proyek Anda, seperti kami, maka untuk caching data Anda kemungkinan besar mengikuti pendekatan berorientasi aspek (AOP) dan menggunakan penjelasan @Cacheable. Spring menggunakan SPI CacheManager miliknya sendiri agar aspek-aspeknya berfungsi. Kacang berikut ini diperlukan agar cache musim semi bekerja:
@Bean public org.springframework.cache.CacheManager cacheManager() { CachingProvider provider = Caching.getCachingProvider(); CacheManager cacheManager = provider.getCacheManager(); return new JCacheCacheManager(cacheManager); }
Untuk bekerja dengan cache dalam paradigma AOP, pertimbangan transaksional juga harus dipertimbangkan. Cache musim semi harus mendukung manajemen transaksi. Untuk tujuan ini, pegas CacheManager mewarisi properti AbstractTransactionSupportingCacheManager, yang dapat digunakan untuk menyinkronkan operasi put- / mengusir yang dilakukan dalam transaksi dan hanya menjalankannya setelah transaksi yang berhasil dilakukan.
Contoh di atas menunjukkan penggunaan pembungkus JCacheCacheManager untuk manajer spesifikasi cache. Ini berarti bahwa implementasi JSR-107 juga memiliki kompatibilitas dengan Spring CacheManager. Ini adalah alasan lain untuk memilih cache dalam memori dengan dukungan untuk spesifikasi JSR untuk proyek Anda. Tetapi jika Anda masih tidak membutuhkan dukungan ini, tetapi saya benar-benar ingin menggunakan @Cacheable, maka Anda memiliki dukungan untuk dua solusi cache internal lagi: EhCacheCacheManager dan CaffeineCacheManager.
Saat memilih implementasi cache Dalam-memori, kami tidak memperhitungkan dukungan IMDG untuk sistem terdistribusi, seperti yang disebutkan sebelumnya. Untuk menjaga kinerja cache JVM pada sistem kami, kami menulis solusi kami sendiri.
Menghapus Cache dalam Sistem Terdistribusi
IMDG modern yang digunakan dalam proyek-proyek dengan arsitektur microservice memungkinkan Anda untuk mendistribusikan data dalam memori antara semua simpul kerja sistem menggunakan partisi data yang dapat diskalakan dengan tingkat redundansi yang diperlukan.
Dalam hal ini, ada banyak masalah yang terkait dengan sinkronisasi, konsistensi data, dan sebagainya, belum lagi peningkatan waktu akses ke penyimpanan sementara. Skema seperti itu berlebihan jika jumlah data yang digunakan cocok dengan RAM satu simpul, dan untuk menjaga konsistensi data, cukup dengan menghapus entri ini pada semua node untuk setiap perubahan dalam nilai cache.
Saat mengimplementasikan solusi seperti itu, ide pertama yang muncul di pikiran adalah menggunakan beberapa EventListener, di JCache ada CacheEntryRemovedListener untuk acara menghapus entri dari cache. Tampaknya cukup untuk menambahkan implementasi Listener Anda sendiri, yang akan mengirim pesan ke topik ketika catatan dihapus, dan cache eutektik pada semua node siap - asalkan setiap node mendengarkan peristiwa dari antrian yang terkait dengan topik umum, seperti yang ditunjukkan pada diagram di atas.
Saat menggunakan solusi seperti itu, data pada node yang berbeda akan menjadi tidak konsisten karena fakta bahwa EventLists dalam proses implementasi JCache setelah peristiwa terjadi. Artinya, jika tidak ada catatan dalam cache lokal untuk kunci yang diberikan, dan ada catatan untuk kunci yang sama pada simpul lain, acara tidak akan dikirim ke topik.
Pertimbangkan cara-cara lain apa yang ada untuk menangkap peristiwa nilai yang dihapus dari cache lokal.
Dalam paket javax.cache.event, di sebelah EventListeners ada juga CacheEntryEventFilter, yang, menurut JavaDoc, digunakan untuk memeriksa acara CacheEntryEvent apa pun sebelum meneruskan acara ini ke CacheEntryListener, apakah itu catatan, penghapusan, pembaruan, atau peristiwa yang berkaitan dengan catatan kadaluwarsa dari catatan kadaluwarsa. dalam cache. Saat menggunakan filter, masalah kita akan tetap ada, karena logika akan dieksekusi setelah peristiwa CacheEntryEvent dicatat dan setelah operasi CRUD dilakukan dalam cache.
Namun demikian, dimungkinkan untuk menangkap inisiasi suatu peristiwa untuk menghapus catatan dari cache. Untuk melakukan ini, gunakan alat bawaan di JCache yang memungkinkan Anda untuk menggunakan spesifikasi API untuk menulis dan memuat data dari sumber eksternal, jika mereka tidak ada dalam cache. Ada dua antarmuka untuk ini dalam paket javax.cache.integration:
- CacheLoader - untuk memuat data yang diminta oleh kunci, jika tidak ada entri dalam cache.
- CacheWriter - untuk menggunakan penulisan, penghapusan, dan pembaruan data pada sumber daya eksternal saat menjalankan operasi cache yang sesuai.
Untuk memastikan konsistensi, metode CacheWriter adalah atomik sehubungan dengan operasi cache yang sesuai. Kami tampaknya telah menemukan solusi untuk masalah kami.
Sekarang kita dapat menjaga konsistensi respon cache In-memory pada node ketika menggunakan implementasi CacheWriter, yang mengirimkan peristiwa ke topik RabbitMQ setiap kali ada perubahan dalam catatan dalam cache lokal.
Kesimpulan
Dalam pengembangan proyek apa pun, ketika mencari solusi yang cocok untuk masalah yang muncul, perlu untuk mempertimbangkan kekhususannya. Dalam kasus kami, fitur karakteristik model data proyek, kode warisan yang diwariskan, dan sifat beban tidak memungkinkan menggunakan solusi apa pun yang ada untuk masalah caching terdistribusi.
Sangat sulit untuk membuat implementasi universal yang berlaku untuk sistem yang dikembangkan. Untuk setiap implementasi seperti itu, ada kondisi optimal untuk digunakan. Dalam kasus kami, spesifikasi proyek menyebabkan solusi yang dijelaskan dalam artikel ini. Jika seseorang memiliki masalah yang sama, kami akan dengan senang hati membagikan solusi kami dan menerbitkannya di GitHub.