Kunci untuk Kinerja Tinggi: `shared dict` dan Cache `lru`

API7.ai

December 22, 2022

OpenResty (NGINX + Lua)

Pada artikel sebelumnya, saya memperkenalkan teknik optimasi OpenResty dan alat tuning performa, yang melibatkan string, table, Lua API, LuaJIT, SystemTap, flame graphs, dll.

Ini adalah fondasi dari optimasi sistem, dan Anda perlu menguasainya dengan baik. Namun, hanya mengetahui hal-hal tersebut tidak cukup untuk menghadapi skenario bisnis yang sebenarnya. Dalam skenario bisnis yang lebih kompleks, mempertahankan performa tinggi adalah pekerjaan yang sistematis, bukan hanya optimasi pada level kode dan gateway. Ini akan melibatkan berbagai aspek seperti database, jaringan, protokol, cache, disk, dll, yang merupakan alasan keberadaan seorang arsitek.

Dalam artikel hari ini, mari kita lihat komponen yang memainkan peran sangat krusial dalam optimasi performa — cache, dan bagaimana penggunaannya serta optimasinya di OpenResty.

Cache

Pada level perangkat keras, sebagian besar perangkat keras komputer menggunakan cache untuk meningkatkan kecepatan. Misalnya, CPU memiliki cache multi-level, dan kartu RAID memiliki cache baca-tulis. Pada level perangkat lunak, database yang kita gunakan adalah contoh yang sangat baik dari desain cache. Ada cache dalam optimasi pernyataan SQL, desain indeks, dan baca-tulis disk.

Di sini, saya menyarankan Anda untuk mempelajari berbagai mekanisme caching MySQL sebelum merancang cache Anda sendiri. Materi yang saya rekomendasikan adalah buku High Performance MySQL: Optimization, Backups, and Replication. Ketika saya bertanggung jawab atas database bertahun-tahun yang lalu, saya banyak mendapatkan manfaat dari buku ini, dan banyak skenario optimasi lainnya kemudian juga mengadopsi desain MySQL.

Kembali ke cache, kita tahu bahwa sistem cache di lingkungan produksi perlu menemukan solusi terbaik berdasarkan skenario bisnis dan bottleneck sistemnya. Ini adalah seni keseimbangan.

Secara umum, cache memiliki dua prinsip.

  • Pertama, semakin dekat dengan permintaan pengguna, semakin baik. Misalnya, jangan mengirim permintaan HTTP jika Anda bisa menggunakan cache lokal. Kirim ke situs asal jika Anda bisa menggunakan CDN, dan jangan kirim ke database jika Anda bisa menggunakan cache OpenResty.
  • Kedua, cobalah untuk menggunakan proses ini dan cache lokal untuk menyelesaikannya. Karena melintasi proses, mesin, dan bahkan ruang server, overhead jaringan dari cache akan sangat besar, yang akan sangat terlihat dalam skenario konkurensi tinggi.

Di OpenResty, desain dan penggunaan cache juga mengikuti dua prinsip ini. Ada dua komponen cache di OpenResty: cache shared dict dan cache lru. Yang pertama hanya dapat menyimpan objek string, dan hanya ada satu salinan data yang di-cache, yang dapat diakses oleh setiap worker, sehingga sering digunakan untuk komunikasi data antar worker. Yang kedua dapat menyimpan semua objek Lua, tetapi hanya dapat diakses dalam satu proses worker. Ada sebanyak data yang di-cache seperti jumlah worker.

Dua tabel sederhana berikut dapat menggambarkan perbedaan antara shared dict dan cache lru:

Nama komponen cacheLingkup aksesTipe data cacheStruktur dataData kadaluarsa dapat diperolehJumlah APIPenggunaan memori
shared dictAntar beberapa workerobjek stringdict, queueya20+satu salinan data
lru cachedalam satu workersemua objek Luadicttidak4n salinan data (N = jumlah worker)

shared dict dan cache lru tidak ada yang lebih baik atau buruk. Mereka harus digunakan bersama sesuai dengan skenario Anda.

  • Jika Anda tidak perlu berbagi data antar worker, maka lru dapat menyimpan tipe data kompleks seperti array dan fungsi dan memiliki performa tertinggi, sehingga menjadi pilihan pertama.
  • Tetapi jika Anda perlu berbagi data antar worker, Anda dapat menambahkan cache shared dict berdasarkan cache lru untuk membentuk arsitektur cache dua tingkat.

Selanjutnya, mari kita lihat kedua metode caching ini secara detail.

Cache Shared dict

Dalam artikel Lua, kami telah memberikan pengenalan spesifik tentang shared dict, berikut adalah tinjauan singkat tentang penggunaannya:

$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs dict:set("Tom", 56) print(dict:get("Tom"))'

Anda perlu mendeklarasikan zona memori dogs di file konfigurasi NGINX terlebih dahulu, dan kemudian dapat digunakan dalam kode Lua. Jika Anda menemukan bahwa ruang yang dialokasikan untuk dogs tidak cukup selama penggunaan, Anda perlu memodifikasi file konfigurasi NGINX terlebih dahulu, dan kemudian memuat ulang NGINX untuk berlaku. Karena kita tidak dapat memperluas dan menyusut pada saat runtime.

Selanjutnya, mari kita fokus pada beberapa masalah terkait performa dalam cache shared dict.

Serialisasi data cache

Masalah pertama adalah serialisasi data cache. Karena hanya objek string yang dapat di-cache di shared dict, jika Anda ingin menyimpan array, Anda harus melakukan serialisasi sekali saat menyetel dan deserialisasi sekali saat mengambil:

resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs dict:set("Tom", require("cjson").encode({a=111})) print(require("cjson").decode(dict:get("Tom")).a)'

Namun, operasi serialisasi dan deserialisasi seperti ini sangat memakan CPU. Jika begitu banyak operasi dilakukan per permintaan, Anda dapat melihat konsumsinya pada flame graph.

Jadi, bagaimana cara menghindari konsumsi ini dalam kamus bersama? Tidak ada cara yang baik di sini, baik menghindari memasukkan array ke dalam kamus bersama pada level bisnis; atau merangkai string secara manual ke format JSON sendiri. Tentu saja, ini juga akan membawa konsumsi performa dari perangkaian string dan mungkin menyebabkan lebih banyak bug.

Sebagian besar serialisasi dapat dibongkar pada level bisnis. Anda dapat memecah isi array dan menyimpannya dalam kamus bersama sebagai string. Jika tidak berhasil, Anda juga dapat menyimpan array di lru, dan menggunakan ruang memori sebagai ganti kenyamanan dan performa program.

Selain itu, kunci dalam cache juga harus sesingkat dan bermakna mungkin, menghemat ruang dan memudahkan debugging selanjutnya.

Data kadaluarsa

Ada juga metode get_stale untuk membaca data di shared dict. Dibandingkan dengan metode get, ini memiliki nilai kembalian tambahan untuk data yang telah kadaluarsa:

resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs dict:set("Tom", 56, 0.01) ngx.sleep(0.02) local val, flags, stale = dict:get_stale("Tom") print(val)'

Dalam contoh di atas, data hanya di-cache di shared dict selama 0.01 detik, dan data telah kadaluarsa 0.02 detik setelah penyetelan. Pada saat ini, data tidak akan diperoleh melalui antarmuka get, tetapi data kadaluarsa mungkin juga diperoleh melalui get_stale. Alasan saya menggunakan kata "mungkin" di sini adalah karena ruang yang ditempati oleh data kadaluarsa memiliki kemungkinan tertentu untuk didaur ulang dan kemudian digunakan untuk data lain. Ini adalah algoritma LRU.

Melihat ini, Anda mungkin memiliki keraguan: apa gunanya mendapatkan data kadaluarsa? Jangan lupa bahwa apa yang kita simpan di shared dict adalah data cache. Bahkan jika data cache kadaluarsa, itu tidak berarti bahwa data sumber harus diperbarui.

Misalnya, sumber data disimpan di MySQL. Setelah kita mendapatkan data dari MySQL, kita menyetel timeout lima detik di shared dict. Kemudian, ketika data kadaluarsa, kita memiliki dua opsi:

  • Ketika data tidak ada, pergi ke MySQL untuk menanyakan lagi, dan menempatkan hasilnya dalam cache.
  • Menentukan apakah data MySQL telah berubah. Jika tidak ada perubahan, baca data kadaluarsa dalam cache, ubah waktu kadaluarsanya, dan buatnya terus berlaku.

Yang terakhir adalah solusi yang lebih optimal yang dapat berinteraksi dengan MySQL sesedikit mungkin sehingga semua permintaan klien mendapatkan data dari cache tercepat.

Pada saat ini, bagaimana menilai apakah data dalam sumber data telah berubah menjadi masalah yang perlu kita pertimbangkan dan selesaikan. Selanjutnya, mari kita ambil cache lru sebagai contoh untuk melihat bagaimana proyek nyata menyelesaikan masalah ini.

Cache lru

Hanya ada 5 antarmuka untuk cache lru: new, set, get, delete, dan flush_all. Hanya antarmuka get yang terkait dengan masalah di atas. Mari kita pahami terlebih dahulu bagaimana antarmuka ini digunakan:

resty -e 'local lrucache = require "resty.lrucache" local cache, err = lrucache.new(200) cache:set("dog", 32, 0.01) ngx.sleep(0.02) local data, stale_data = cache:get("dog") print(stale_data)'

Anda dapat melihat bahwa dalam cache lru, nilai kembalian kedua dari antarmuka get langsung adalah stale_data, alih-alih dibagi menjadi dua API berbeda, get dan get_stale, seperti di shared dict. Enkapsulasi antarmuka seperti ini lebih ramah untuk menggunakan data kadaluarsa.

Kami umumnya merekomendasikan menggunakan nomor versi untuk membedakan data yang berbeda dalam proyek nyata. Dengan cara ini, nomor versinya juga akan berubah setelah data berubah. Misalnya, indeks yang dimodifikasi di etcd dapat digunakan sebagai nomor versi untuk menandai apakah data telah berubah. Dengan konsep nomor versi, kita dapat membuat enkapsulasi sekunder sederhana dari cache lru. Misalnya, lihat kode pseudo berikut, diambil dari lrucache

local function (key, version, create_obj_fun, ...) local obj, stale_obj = lru_obj:get(key) -- Jika data belum kadaluarsa dan versi belum berubah, kembalikan data cache langsung if obj and obj._cache_ver == version then return obj end -- Jika data telah kadaluarsa, tetapi masih dapat diperoleh, dan versi belum berubah, langsung kembalikan data kadaluarsa dalam cache if stale_obj and stale_obj._cache_ver == version then lru_obj:set(key, obj, item_ttl) return stale_obj end -- Jika tidak ditemukan data kadaluarsa, atau nomor versi telah berubah, dapatkan data dari sumber data local obj, err = create_obj_fun(...) obj._cache_ver = version lru_obj:set(key, obj, item_ttl) return obj, err end

Dari kode ini, Anda dapat melihat bahwa dengan memperkenalkan konsep nomor versi, kami sepenuhnya menggunakan data kadaluarsa untuk mengurangi tekanan pada sumber data dan mencapai performa optimal ketika nomor versi tidak berubah.

Selain itu, dalam solusi di atas, ada optimasi besar yang potensial dalam hal kami memisahkan kunci dan nomor versi dan menggunakan nomor versi sebagai atribut nilai.

Kita tahu bahwa pendekatan yang lebih konvensional adalah menulis nomor versi ke dalam kunci. Misalnya, nilai kunci adalah key_1234. Praktik ini sangat umum, tetapi dalam lingkungan OpenResty, ini adalah pemborosan. Mengapa Anda mengatakan itu?

Berikan contoh, dan Anda akan mengerti. Jika nomor versi berubah setiap menit, maka key_1234 akan menjadi key_1235 setelah satu menit, dan 60 kunci berbeda dan 60 nilai akan dihasilkan dalam satu jam. Ini juga berarti Lua GC perlu mendaur ulang objek Lua di belakang 59 pasangan kunci-nilai. Pembuatan objek dan GC akan mengkonsumsi lebih banyak sumber daya jika Anda memperbarui lebih sering.

Tentu saja, konsumsi ini juga dapat dihindari hanya dengan memindahkan nomor versi dari kunci ke nilai. Tidak peduli seberapa sering kunci diperbarui, hanya ada dua objek Lua tetap yang ada. Dapat dilihat bahwa teknik optimasi seperti ini sangat cerdik. Namun, di balik teknik yang sederhana dan cerdik, Anda perlu memahami API OpenResty dan mekanisme caching secara mendalam.

Ringkasan

Meskipun dokumentasi OpenResty relatif detail, Anda perlu mengalami dan memahami bagaimana menggabungkannya dengan bisnis untuk menghasilkan efek optimasi terbesar. Dalam banyak kasus, hanya ada satu atau dua kalimat dalam dokumen, seperti data kadaluarsa, tetapi akan memiliki perbedaan performa yang besar.

Jadi, apakah Anda memiliki pengalaman serupa saat menggunakan OpenResty? Silakan tinggalkan pesan untuk berbagi dengan kami, dan Anda dipersilakan untuk membagikan artikel ini, mari kita belajar dan berkembang bersama.