Cara Meningkatkan Kinerja Aplikasi ASP.NET di Web Farm dengan Caching
Diterbitkan: 2022-03-11Hanya ada dua hal sulit dalam Ilmu Komputer: pembatalan cache dan penamaan hal.
- Pengarang: Phil Karlton
Pengantar Singkat tentang Caching
Caching adalah teknik yang ampuh untuk meningkatkan kinerja melalui trik sederhana: Alih-alih melakukan pekerjaan yang mahal (seperti perhitungan rumit atau kueri basis data yang kompleks) setiap kali kita membutuhkan hasil, sistem dapat menyimpan – atau men-cache – hasil pekerjaan itu dan dengan mudah berikan itu saat berikutnya diminta tanpa perlu melakukan kembali pekerjaan itu (dan oleh karena itu, dapat merespons dengan sangat cepat).
Tentu saja, seluruh ide di balik caching hanya berfungsi selama hasil yang kami tembolok tetap valid. Dan di sini kita sampai pada bagian tersulit yang sebenarnya dari masalah ini: Bagaimana kita menentukan kapan item yang di-cache menjadi tidak valid dan perlu dibuat ulang?
dan sempurna untuk memecahkan masalah caching web farm terdistribusi.
Biasanya, aplikasi web biasa harus menangani volume permintaan baca yang jauh lebih tinggi daripada permintaan tulis. Itulah sebabnya aplikasi web khas yang dirancang untuk menangani beban tinggi dirancang agar dapat diskalakan dan didistribusikan, digunakan sebagai kumpulan node tingkat web, biasanya disebut farm. Semua fakta ini berdampak pada penerapan caching.
Dalam artikel ini, kami fokus pada peran yang dapat dimainkan caching dalam memastikan throughput tinggi dan kinerja aplikasi web yang dirancang untuk menangani beban tinggi, dan saya akan menggunakan pengalaman dari salah satu proyek saya dan memberikan solusi berbasis ASP.NET sebagai ilustrasi.
Masalah Menangani Beban Tinggi
Masalah sebenarnya yang harus saya pecahkan bukanlah yang asli. Tugas saya adalah membuat prototipe aplikasi web monolitik ASP.NET MVC mampu menangani beban tinggi.
Langkah-langkah yang diperlukan untuk meningkatkan kemampuan throughput dari aplikasi web monolitik adalah:
- Aktifkan untuk menjalankan banyak salinan aplikasi web secara paralel, di belakang penyeimbang beban, dan melayani semua permintaan serentak secara efektif (yaitu, membuatnya skalabel).
- Profil aplikasi untuk mengungkapkan kemacetan kinerja saat ini dan mengoptimalkannya.
- Gunakan caching untuk meningkatkan throughput permintaan baca, karena ini biasanya merupakan bagian penting dari keseluruhan beban aplikasi.
Strategi caching sering kali melibatkan penggunaan beberapa server caching middleware, seperti Memcached atau Redis, untuk menyimpan nilai yang di-cache. Meskipun adopsi tinggi dan penerapannya terbukti, ada beberapa kelemahan dari pendekatan ini, termasuk:
- Latensi jaringan yang diperkenalkan dengan mengakses server cache yang terpisah dapat dibandingkan dengan latensi untuk mencapai database itu sendiri.
- Struktur data tingkat web mungkin tidak cocok untuk serialisasi dan deserialisasi di luar kotak. Untuk menggunakan server cache, struktur data tersebut harus mendukung serialisasi dan deserialisasi, yang memerlukan upaya pengembangan tambahan yang berkelanjutan.
- Serialisasi dan deserialisasi menambahkan overhead runtime dengan efek buruk pada kinerja.
Semua masalah ini relevan dalam kasus saya, jadi saya harus mencari opsi alternatif.
Cache dalam memori ASP.NET bawaan ( System.Web.Caching.Cache
) sangat cepat dan dapat digunakan tanpa overhead serialisasi dan deserialisasi, baik selama pengembangan maupun saat runtime. Namun, cache dalam memori ASP.NET juga memiliki kekurangannya sendiri:
- Setiap node tingkat web membutuhkan salinan nilai cache-nya sendiri. Hal ini dapat mengakibatkan konsumsi tingkat basis data yang lebih tinggi pada awal atau daur ulang node yang dingin.
- Setiap node tingkat web harus diberi tahu ketika node lain membuat bagian cache tidak valid dengan menulis nilai yang diperbarui. Karena cache didistribusikan dan tanpa sinkronisasi yang tepat, sebagian besar node akan mengembalikan nilai lama yang biasanya tidak dapat diterima.
Jika beban tingkat basis data tambahan tidak akan menyebabkan kemacetan dengan sendirinya, maka mengimplementasikan cache yang didistribusikan dengan benar sepertinya tugas yang mudah untuk ditangani, bukan? Yah, itu bukan tugas yang mudah , tetapi itu mungkin . Dalam kasus saya, tolok ukur menunjukkan bahwa tingkat basis data seharusnya tidak menjadi masalah, karena sebagian besar pekerjaan terjadi di tingkat web. Jadi, saya memutuskan untuk menggunakan cache dalam memori ASP.NET dan fokus pada penerapan sinkronisasi yang tepat.
Memperkenalkan Solusi berbasis ASP.NET
Seperti yang dijelaskan, solusi saya adalah menggunakan cache dalam memori ASP.NET alih-alih server caching khusus. Ini memerlukan setiap node dari web farm memiliki cache sendiri, menanyakan database secara langsung, melakukan perhitungan yang diperlukan, dan menyimpan hasil dalam cache. Dengan cara ini, semua operasi cache akan sangat cepat berkat sifat cache dalam memori. Biasanya, item yang di-cache memiliki masa pakai yang jelas dan menjadi basi pada beberapa perubahan atau penulisan data baru. Jadi, dari logika aplikasi web, biasanya jelas kapan item cache harus dibatalkan.
Satu-satunya masalah yang tersisa di sini adalah ketika salah satu node membatalkan item cache di cache-nya sendiri, tidak ada node lain yang tahu tentang pembaruan ini. Jadi, permintaan berikutnya yang dilayani oleh node lain akan memberikan hasil yang basi. Untuk mengatasi ini, setiap node harus berbagi pembatalan cache dengan node lain. Setelah menerima pembatalan seperti itu, node lain dapat dengan mudah menghapus nilai cache mereka dan mendapatkan yang baru pada permintaan berikutnya.
Di sini, Redis bisa ikut bermain. Kekuatan Redis, dibandingkan dengan solusi lain, berasal dari kemampuan Pub/Sub-nya. Setiap klien dari server Redis dapat membuat saluran dan mempublikasikan beberapa data di dalamnya. Klien lain mana pun dapat mendengarkan saluran itu dan menerima data terkait, sangat mirip dengan sistem yang digerakkan oleh peristiwa apa pun. Fungsionalitas ini dapat digunakan untuk bertukar pesan pembatalan cache antar node, sehingga semua node dapat membatalkan cache mereka saat dibutuhkan.
Cache dalam memori ASP.NET mudah dalam beberapa hal dan rumit dalam hal lain. Secara khusus, ini sangat mudah karena berfungsi sebagai peta pasangan kunci/nilai, namun ada banyak kerumitan yang terkait dengan strategi dan dependensi pembatalannya.
Untungnya, kasus penggunaan tipikal cukup sederhana, dan memungkinkan untuk menggunakan strategi pembatalan default untuk semua item, memungkinkan setiap item cache hanya memiliki satu ketergantungan paling banyak. Dalam kasus saya, saya mengakhiri dengan kode ASP.NET berikut untuk antarmuka layanan caching. (Perhatikan bahwa ini bukan kode sebenarnya, karena saya menghilangkan beberapa detail demi kesederhanaan dan lisensi kepemilikan.)
public interface ICacheKey { string Value { get; } } public interface IDataCacheKey : ICacheKey { } public interface ITouchableCacheKey : ICacheKey { } public interface ICacheService { int ItemsCount { get; } T Get<T>(IDataCacheKey key, Func<T> valueGetter); T Get<T>(IDataCacheKey key, Func<T> valueGetter, ICacheKey dependencyKey); }
Di sini, layanan cache pada dasarnya memungkinkan dua hal. Pertama, ini memungkinkan penyimpanan hasil dari beberapa fungsi pengambil nilai dengan cara yang aman. Kedua, ini memastikan bahwa nilai saat itu selalu dikembalikan saat diminta. Setelah item cache menjadi basi atau secara eksplisit dikeluarkan dari cache, pengambil nilai dipanggil lagi untuk mengambil nilai saat ini. Kunci cache diabstraksikan oleh antarmuka ICacheKey
, terutama untuk menghindari hard-coding string kunci cache di seluruh aplikasi.
Untuk membatalkan item cache, saya memperkenalkan layanan terpisah, yang terlihat seperti ini:
public interface ICacheInvalidator { bool IsSessionOpen { get; } void OpenSession(); void CloseSession(); void Drop(IDataCacheKey key); void Touch(ITouchableCacheKey key); void Purge(); }
Selain metode dasar menjatuhkan item dengan data dan tombol sentuh, yang hanya memiliki item data dependen, ada beberapa metode yang terkait dengan semacam "sesi".

Aplikasi web kami menggunakan Autofac untuk injeksi dependensi, yang merupakan implementasi dari pola desain inversion of control (IoC) untuk manajemen dependensi. Fitur ini memungkinkan pengembang untuk membuat kelas mereka tanpa perlu khawatir tentang dependensi, karena wadah IoC mengelola beban itu untuk mereka.
Layanan cache dan invalidator cache memiliki siklus hidup yang sangat berbeda terkait IoC. Layanan cache didaftarkan sebagai singleton (satu instans, dibagikan di antara semua klien), sedangkan invalidator cache terdaftar sebagai instans per permintaan (instance terpisah dibuat untuk setiap permintaan masuk). Mengapa?
Jawabannya ada hubungannya dengan kehalusan tambahan yang perlu kami tangani. Aplikasi web menggunakan arsitektur Model-View-Controller (MVC), yang membantu terutama dalam pemisahan masalah UI dan logika. Jadi, tindakan pengontrol khas dibungkus ke dalam subkelas dari ActionFilterAttribute
. Dalam kerangka ASP.NET MVC, atribut C# seperti itu digunakan untuk menghiasi logika tindakan pengontrol dalam beberapa cara. Atribut tertentu bertanggung jawab untuk membuka koneksi database baru dan memulai transaksi di awal tindakan. Juga, di akhir tindakan, subkelas atribut filter bertanggung jawab untuk melakukan transaksi jika berhasil dan mengembalikannya jika gagal.
Jika pembatalan cache terjadi tepat di tengah-tengah transaksi, mungkin ada kondisi balapan dimana permintaan berikutnya ke node tersebut akan berhasil mengembalikan nilai lama (masih terlihat oleh transaksi lain) ke dalam cache. Untuk menghindari hal ini, semua pembatalan ditunda sampai transaksi dilakukan. Setelah itu, item cache aman untuk dikeluarkan dan, jika terjadi kegagalan transaksi, tidak perlu modifikasi cache sama sekali.
Itulah tujuan yang tepat dari bagian terkait "sesi" di invalidator cache. Juga, itulah tujuan hidupnya terikat pada permintaan. Kode ASP.NET tampak seperti ini:
class HybridCacheInvalidator : ICacheInvalidator { ... public void Drop(IDataCacheKey key) { if (key == null) throw new ArgumentNullException("key"); if (!IsSessionOpen) throw new InvalidOperationException("Session must be opened first."); _postponedRedisMessages.Add(new Tuple<string, string>("drop", key.Value)); } ... public void CloseSession() { if (!IsSessionOpen) return; _postponedRedisMessages.ForEach(m => PublishRedisMessageSafe(m.Item1, m.Item2)); _postponedRedisMessages = null; } ... }
Metode PublishRedisMessageSafe
di sini bertanggung jawab untuk mengirim pesan (argumen kedua) ke saluran tertentu (argumen pertama). Sebenarnya, ada saluran terpisah untuk jatuhkan dan sentuh, sehingga penangan pesan untuk masing-masing saluran tahu persis apa yang harus dilakukan - jatuhkan/sentuh kunci yang sama dengan muatan pesan yang diterima.
Salah satu bagian yang sulit adalah mengelola koneksi ke server Redis dengan benar. Jika server mati karena alasan apa pun, aplikasi harus terus berfungsi dengan benar. Ketika Redis kembali online, aplikasi akan mulai menggunakannya kembali dengan lancar dan bertukar pesan dengan node lain lagi. Untuk mencapai ini, saya menggunakan perpustakaan StackExchange.Redis dan logika manajemen koneksi yang dihasilkan diimplementasikan sebagai berikut:
class HybridCacheService : ... { ... public void Initialize() { try { Multiplexer = ConnectionMultiplexer.Connect(_configService.Caching.BackendServerAddress); ... Multiplexer.ConnectionFailed += (sender, args) => UpdateConnectedState(); Multiplexer.ConnectionRestored += (sender, args) => UpdateConnectedState(); ... } catch (Exception ex) { ... } } private void UpdateConnectedState() { if (Multiplexer.IsConnected && _currentCacheService is NoCacheServiceStub) { _inProcCacheInvalidator.Purge(); _currentCacheService = _inProcCacheService; _logger.Debug("Connection to remote Redis server restored, switched to in-proc mode."); } else if (!Multiplexer.IsConnected && _currentCacheService is InProcCacheService) { _currentCacheService = _noCacheStub; _logger.Debug("Connection to remote Redis server lost, switched to no-cache mode."); } } }
Di sini, ConnectionMultiplexer
adalah jenis dari perpustakaan StackExchange.Redis, yang bertanggung jawab untuk pekerjaan transparan dengan Redis yang mendasarinya. Bagian penting di sini adalah, ketika simpul tertentu kehilangan koneksi ke Redis, ia kembali ke mode tidak ada cache untuk memastikan tidak ada permintaan yang akan menerima data basi. Setelah koneksi dipulihkan, node mulai menggunakan cache dalam memori lagi.
Berikut adalah contoh tindakan tanpa penggunaan layanan cache ( SomeActionWithoutCaching
) dan operasi identik yang menggunakannya ( SomeActionUsingCache
):
class SomeController : Controller { public ISomeService SomeService { get; set; } public ICacheService CacheService { get; set; } ... public ActionResult SomeActionWithoutCaching() { return View( SomeService.GetModelData() ); } ... public ActionResult SomeActionUsingCache() { return View( CacheService.Get( /* Cache key creation omitted */, () => SomeService.GetModelData() ); ); } }
Cuplikan kode dari implementasi ISomeService
dapat terlihat seperti ini:
class DefaultSomeService : ISomeService { public ICacheInvalidator _cacheInvalidator; ... public SomeModel GetModelData() { return /* Do something to get model data. */; } ... public void SetModelData(SomeModel model) { /* Do something to set model data. */ _cacheInvalidator.Drop(/* Cache key creation omitted */); } }
Pembandingan dan Hasil
Setelah caching kode ASP.NET siap, saatnya untuk menggunakannya dalam logika aplikasi web yang ada, dan benchmarking dapat berguna untuk memutuskan di mana harus menempatkan sebagian besar upaya penulisan ulang kode untuk menggunakan caching. Sangat penting untuk memilih beberapa kasus penggunaan yang paling umum atau kritis secara operasional untuk dijadikan tolok ukur. Setelah itu, alat seperti Apache jMeter dapat digunakan untuk dua hal:
- Untuk membandingkan kasus penggunaan utama ini melalui permintaan HTTP.
- Untuk mensimulasikan beban tinggi untuk node web yang sedang diuji.
Untuk mendapatkan profil kinerja, profiler apa pun yang mampu melampirkan ke proses pekerja IIS dapat digunakan. Dalam kasus saya, saya menggunakan JetBrains dotTrace Performance. Setelah beberapa waktu bereksperimen untuk menentukan parameter jMeter yang benar (seperti konkuren dan jumlah permintaan), menjadi mungkin untuk mulai mengumpulkan snapshot kinerja, yang sangat membantu dalam mengidentifikasi hotspot dan kemacetan.
Dalam kasus saya, beberapa kasus penggunaan menunjukkan bahwa sekitar 15%-45% keseluruhan waktu eksekusi kode dihabiskan dalam pembacaan database dengan hambatan yang jelas. Setelah saya menerapkan caching, kinerja hampir dua kali lipat (yaitu, dua kali lebih cepat) untuk sebagian besar dari mereka.
Kesimpulan
Seperti yang Anda lihat, kasus saya mungkin tampak seperti contoh dari apa yang biasanya disebut "menciptakan kembali roda": Mengapa repot-repot mencoba membuat sesuatu yang baru, ketika sudah ada praktik terbaik yang diterapkan secara luas di luar sana? Cukup siapkan Memcached atau Redis, dan lepaskan.
Saya sangat setuju bahwa penggunaan praktik terbaik biasanya merupakan pilihan terbaik. Tetapi sebelum menerapkan praktik terbaik secara membabi buta, seseorang harus bertanya pada diri sendiri: Seberapa dapat diterapkannya “praktik terbaik” ini? Apakah itu cocok dengan kasus saya?
Cara saya melihatnya, opsi yang tepat dan analisis tradeoff adalah suatu keharusan saat membuat keputusan penting, dan itulah pendekatan yang saya pilih karena masalahnya tidak begitu mudah. Dalam kasus saya, ada banyak faktor yang perlu dipertimbangkan, dan saya tidak ingin mengambil solusi satu ukuran untuk semua ketika itu mungkin bukan pendekatan yang tepat untuk masalah yang dihadapi.
Pada akhirnya, dengan caching yang tepat, saya mendapatkan peningkatan kinerja hampir 50% dari solusi awal.