Cara Menghindari Cache Stampede

API7.ai

December 29, 2022

OpenResty (NGINX + Lua)

Pada artikel sebelumnya, kita mempelajari beberapa teknik optimasi performa tinggi dengan shared dict dan lru cache. Namun, kami meninggalkan masalah penting yang pantas mendapatkan artikelnya sendiri hari ini, yaitu "Cache Stampede".

Apa itu Cache Stampede?

Mari kita bayangkan sebuah skenario.

Sumber data berada di database MySQL, data yang di-cache berada di shared dict, dan waktu habisnya cache adalah 60 detik. Selama 60 detik data berada di cache, semua permintaan mengambil data dari cache alih-alih dari MySQL. Namun, setelah lebih dari 60 detik, data yang di-cache akan kedaluwarsa. Jika ada sejumlah besar permintaan bersamaan, tidak ada data di cache yang dapat di-query. Kemudian fungsi query dari sumber data akan dipicu, dan semua permintaan ini akan menuju ke database MySQL, yang secara langsung akan menyebabkan server database terblokir atau bahkan mati.

Fenomena ini dapat disebut sebagai "Cache Stampede", dan terkadang juga disebut sebagai Dog-Piling. Tidak ada kode terkait cache yang muncul di bagian sebelumnya yang memiliki penanganan yang sesuai. Berikut adalah contoh pseudo-code yang memiliki potensi untuk menyebabkan cache stampede.

local value = get_from_cache(key) if not value then value = query_db(sql) set_to_cache(value, timeout = 60) end return value

Pseudo-code tersebut terlihat seperti logikanya benar, dan Anda tidak akan memicu cache stampede menggunakan unit test, atau end-to-end test. Hanya tes stres yang panjang yang akan menemukan masalahnya. Setiap 60 detik, database akan mengalami lonjakan query yang teratur. Namun, jika Anda menetapkan waktu kedaluwarsa cache yang lebih lama di sini, kemungkinan masalah cache storm terdeteksi akan berkurang.

Bagaimana cara menghindarinya?

Mari kita bagi diskusi menjadi beberapa kasus yang berbeda.

1. Memperbarui cache secara proaktif

Dalam pseudo-code di atas, cache diperbarui secara pasif dan hanya akan menuju ke database untuk mencari data baru ketika ada permintaan tetapi ditemukan kegagalan cache. Oleh karena itu, mengubah cara pembaruan cache dari pasif menjadi aktif dapat menghindari masalah cache stampede.

Di OpenResty, kita dapat mengimplementasikannya seperti ini.

Pertama, kita menggunakan ngx.timer.every untuk membuat tugas timer yang berjalan setiap menit untuk mengambil data terbaru dari database MySQL dan memasukkannya ke dalam shared dict:

local function query_db(premature, sql) local value = query_db(sql) set_to_cache(value, timeout = 60) end local ok, err = ngx.timer.every(60, query_db, sql)

Kemudian, dalam logika kode yang menangani permintaan, kita perlu menghapus bagian yang melakukan query ke MySQL dan hanya menyimpan bagian kode yang mengambil cache dari shared dict.

local value = get_from_cache(key) return value

Dua potongan pseudo-code di atas dapat membantu kita menghindari masalah cache stampede. Namun, pendekatan ini tidak sempurna, setiap cache harus sesuai dengan tugas periodik (ada batas atas jumlah timer di OpenResty), dan waktu kedaluwarsa cache serta waktu siklus tugas terjadwal harus sesuai dengan baik. Jika ada kesalahan selama periode ini, permintaan mungkin terus mendapatkan data kosong.

Jadi, dalam proyek nyata, kita biasanya menggunakan penguncian untuk menyelesaikan masalah cache stampede. Berikut adalah beberapa metode penguncian yang berbeda, Anda dapat memilih sesuai kebutuhan Anda.

2. lua-resty-lock

Ketika berbicara tentang menambahkan kunci, Anda mungkin merasa sulit, berpikir bahwa itu adalah operasi yang berat, dan bagaimana jika terjadi deadlock dan Anda harus menangani cukup banyak pengecualian.

Kita dapat mengurangi kekhawatiran ini dengan menggunakan library lua-resty-lock di OpenResty untuk menambahkan kunci. lua-resty-lock adalah library resty OpenResty, yang berbasis pada shared dict dan menyediakan API kunci non-blocking. Mari kita lihat contoh sederhana.

resty --shdict='locks 1m' -e 'local resty_lock = require "resty.lock" local lock, err = resty_lock:new("locks") local elapsed, err = lock:lock("my_key") -- query db and update cache local ok, err = lock:unlock() ngx.say("unlock: ", ok)'

Karena lua-resty-lock diimplementasikan menggunakan shared dict, kita perlu mendeklarasikan nama dan ukuran shdict terlebih dahulu dan kemudian menggunakan metode new untuk membuat objek lock baru. Dalam potongan kode di atas, kita hanya melewatkan parameter pertama, yaitu nama shdict. Metode new memiliki parameter kedua, yang dapat digunakan untuk menentukan waktu kedaluwarsa, waktu tunggu untuk kunci, dan banyak parameter lainnya. Di sini kita mempertahankan nilai default. Parameter ini digunakan untuk menghindari deadlock dan pengecualian lainnya.

Kemudian kita dapat memanggil metode lock untuk mencoba mendapatkan kunci. Jika kita berhasil mendapatkan kunci, kita dapat memastikan bahwa hanya satu permintaan yang menuju ke sumber data untuk memperbarui data pada saat yang sama. Namun, jika penguncian gagal karena perebutan, waktu tunggu habis, dll., maka data diambil dari cache yang sudah kedaluwarsa dan dikembalikan ke peminta. Ini membawa kita ke API get_stale yang diperkenalkan dalam pelajaran sebelumnya.

local elapsed, err = lock:lock("my_key") # elapsed to nil berarti penguncian gagal. Nilai kembalian err adalah salah satu dari timeout, locked if not elapsed and err then dict:get_stale("my_key") end

Jika lock berhasil, maka aman untuk melakukan query ke database dan memperbarui hasilnya ke cache, dan akhirnya, kita memanggil antarmuka unlock untuk melepaskan kunci.

Menggabungkan lua-resty-lock dan get_stale, kita memiliki solusi sempurna untuk masalah cache stampede. Dokumentasi untuk lua-resty-lock memberikan kode yang sangat lengkap untuk menanganinya. Jika tertarik, Anda dapat memeriksanya di sini.

Mari kita lebih dalam dan melihat bagaimana antarmuka lock mengimplementasikan penguncian. Ketika kita menemukan beberapa implementasi yang menarik, kita selalu ingin melihat bagaimana itu diimplementasikan dalam kode sumber, yang merupakan salah satu keuntungan dari open source.

local ok, err = dict:add(key, true, exptime) if ok then cdata.key_id = ref_obj(key) self.key = key return 0 end

Seperti yang disebutkan dalam artikel shared dict, semua API shared dict adalah operasi atomik dan tidak perlu khawatir tentang persaingan. Jadi, ide yang baik untuk menggunakan shared dict untuk menandai status kunci.

Implementasi lock di atas menggunakan dict:add untuk mencoba mengatur kunci: jika kunci tidak ada dalam memori bersama, add akan mengembalikan sukses, menunjukkan bahwa penguncian berhasil; permintaan bersamaan lainnya akan mengembalikan kegagalan ketika mereka mencapai logika baris kode dict:add, dan kemudian kode dapat memilih untuk kembali langsung, atau mencoba beberapa kali berdasarkan informasi err yang dikembalikan.

3. lua-resty-shcache

Dalam implementasi lua-resty-lock di atas, Anda perlu menangani penguncian, pelepasan kunci, pengambilan data kedaluwarsa, percobaan ulang, penanganan pengecualian, dan masalah lainnya, yang masih cukup rumit.

Berikut adalah pembungkus sederhana untuk Anda: lua-resty-shcache, yang merupakan library lua-resty dari Cloudflare, ini melakukan lapisan enkapsulasi di atas shared dictionary dan penyimpanan eksternal dan menyediakan fungsi tambahan untuk serialisasi dan deserialisasi, sehingga Anda tidak perlu peduli dengan detail di atas:

local shcache = require("shcache") local my_cache_table = shcache:new( ngx.shared.cache_dict { external_lookup = lookup, encode = cmsgpack.pack, decode = cmsgpack.decode, }, { positive_ttl = 10, -- cache good data for 10s negative_ttl = 3, -- cache failed lookup for 3s name = 'my_cache', -- "named" cache, useful for debug / report } ) local my_table, from_cache = my_cache_table:load(key)

Kode contoh ini diambil dari contoh resmi dan telah menyembunyikan semua detail. Library enkapsulasi cache ini bukan pilihan terbaik, tetapi ini adalah bahan pembelajaran yang baik untuk pemula. Artikel berikutnya akan memperkenalkan beberapa enkapsulasi lain yang lebih baik dan lebih umum digunakan.

4. Direktif NGINX

Jika Anda tidak menggunakan library lua-resty OpenResty, Anda juga dapat menggunakan direktif konfigurasi NGINX untuk penguncian dan pengambilan data kedaluwarsa: proxy_cache_lock dan proxy_cache_use_stale. Namun, kami tidak merekomendasikan penggunaan direktif NGINX di sini, karena tidak cukup fleksibel, dan performanya tidak sebaik kode Lua.

Ringkasan

Cache stampede, seperti masalah race yang telah kami sebutkan berulang kali sebelumnya, sulit dideteksi melalui tinjauan kode dan tes. Cara terbaik untuk menyelesaikannya adalah dengan meningkatkan pengkodean Anda atau menggunakan library enkapsulasi.

Satu pertanyaan terakhir: Bagaimana Anda menangani cache stampede dan sejenisnya dalam bahasa dan platform yang Anda kenal? Apakah ada cara yang lebih baik daripada OpenResty? Jangan ragu untuk berbagi dengan saya di komentar.