Keajaiban komunikasi antara worker NGINX: salah satu struktur data terpenting `shared dict`
API7.ai
October 27, 2022
Seperti yang telah kami katakan dalam artikel sebelumnya, table adalah satu-satunya struktur data di Lua. Ini sesuai dengan shared dict, yang merupakan struktur data paling penting yang dapat Anda gunakan dalam pemrograman OpenResty. Ini mendukung penyimpanan data, pembacaan, penghitungan atomik, dan operasi antrian.
Berdasarkan shared dict, Anda dapat mengimplementasikan caching dan komunikasi antara beberapa Worker, serta pembatasan laju, statistik lalu lintas, dan fungsi lainnya. Anda dapat menggunakan shared dict sebagai Redis sederhana, kecuali bahwa data dalam shared dict tidak persisten, sehingga Anda harus mempertimbangkan kehilangan data yang disimpan.
Beberapa cara berbagi data
Dalam menulis kode Lua OpenResty, Anda pasti akan menemui berbagi data antara Worker yang berbeda dalam berbagai fase permintaan. Anda mungkin juga perlu berbagi data antara kode Lua dan C.
Jadi, sebelum kami secara resmi memperkenalkan API shared dict, mari kita pahami terlebih dahulu metode berbagi data umum di OpenResty dan belajar bagaimana memilih metode berbagi data yang lebih sesuai sesuai dengan situasi saat ini.
Pertama adalah variabel di NGINX. Ini dapat berbagi data antara modul C NGINX. Secara alami, ini juga dapat berbagi data antara modul C dan lua-nginx-module yang disediakan oleh OpenResty, seperti dalam kode berikut.
location /foo { set $my_var ''; # baris ini diperlukan untuk membuat $my_var pada waktu konfigurasi content_by_lua_block { ngx.var.my_var = 123; ... } }
Namun, menggunakan variabel NGINX untuk berbagi data lambat karena melibatkan pencarian hash dan alokasi memori. Juga, pendekatan ini memiliki batasan bahwa ini hanya dapat digunakan untuk menyimpan string dan tidak dapat mendukung tipe Lua yang kompleks.
Kedua adalah ngx.ctx, yang dapat berbagi data antara fase yang berbeda dari permintaan yang sama. Ini adalah table Lua biasa, sehingga cepat dan dapat menyimpan berbagai objek Lua. Siklus hidupnya adalah tingkat permintaan; ketika permintaan berakhir, ngx.ctx dihancurkan.
Berikut adalah skenario penggunaan tipikal di mana kami menggunakan ngx.ctx untuk cache panggilan yang mahal seperti variabel NGINX dan menggunakannya di berbagai tahap.
location /test { rewrite_by_lua_block { ngx.ctx.host = ngx.var.host } access_by_lua_block { if (ngx.ctx.host == 'api7.ai') then ngx.ctx.host = 'test.com' end } content_by_lua_block { ngx.say(ngx.ctx.host) } }
Dalam kasus ini, jika Anda menggunakan curl untuk mengaksesnya.
curl -i 127.0.0.1:8080/test -H 'host:api7.ai'
Maka ini akan mencetak test.com, menunjukkan bahwa ngx.ctx berbagi data di berbagai tahap. Tentu saja, Anda juga dapat memodifikasi contoh di atas dengan menyimpan objek yang lebih kompleks seperti table alih-alih string sederhana untuk melihat apakah ini memenuhi harapan Anda.
Namun, catatan khusus di sini adalah karena siklus hidup ngx.ctx adalah tingkat permintaan, ini tidak melakukan cache di tingkat modul. Misalnya, saya melakukan kesalahan dengan menggunakan ini di file foo.lua saya.
local ngx_ctx = ngx.ctx local function bar() ngx_ctx.host = 'test.com' end
Kita harus memanggil dan melakukan cache di tingkat fungsi.
local ngx = ngx local function bar() ngx_ctx.host = 'test.com' end
Ada banyak detail lagi tentang ngx.ctx, yang akan kita lanjutkan eksplorasinya nanti di bagian optimasi kinerja.
Pendekatan ketiga menggunakan variabel tingkat modul untuk berbagi data di semua permintaan dalam Worker yang sama. Tidak seperti variabel NGINX dan ngx.ctx sebelumnya, pendekatan ini sedikit kurang dapat dipahami. Tapi jangan khawatir, konsepnya abstrak, dan kode datang terlebih dahulu, jadi mari kita lihat contoh untuk memahami variabel tingkat modul.
-- mydata.lua local _M = {} local data = { dog = 3, cat = 4, pig = 5, } function _M.get_age(name) return data[name] end return _M
Konfigurasi di nginx.conf adalah sebagai berikut.
location /lua { content_by_lua_block { local mydata = require "mydata" ngx.say(mydata.get_age("dog")) } }
Dalam contoh ini, mydata adalah modul yang hanya dimuat sekali oleh proses Worker, dan semua permintaan yang diproses oleh Worker setelah itu berbagi kode dan data dari modul mydata.
Secara alami, variabel data dalam modul mydata adalah variabel tingkat modul yang terletak di tingkat atas modul, yaitu di awal modul, dan dapat diakses oleh semua fungsi.
Jadi, Anda dapat menempatkan data yang perlu dibagikan antara permintaan dalam variabel tingkat atas modul. Namun, penting untuk dicatat bahwa kita umumnya hanya menggunakan cara ini untuk menyimpan data yang hanya dibaca. Jika operasi penulisan terlibat, Anda harus sangat berhati-hati karena mungkin ada race condition, yang merupakan bug yang sulit untuk dilacak.
Kita dapat mengalami ini dengan contoh yang paling disederhanakan berikut.
-- mydata.lua local _M = {} local data = { dog = 3, cat = 4, pig = 5, } function _M.incr_age(name) data[name] = data[name] + 1 return data[name] end return _M
Dalam modul, kita menambahkan fungsi incr_age, yang memodifikasi data dalam tabel data.
Kemudian, dalam kode pemanggilan, kita menambahkan baris paling kritis ngx.sleep(5), di mana sleep adalah operasi yield.
location /lua { content_by_lua_block { local mydata = require "mydata" ngx.say(mydata. incr_age("dog")) ngx.sleep(5) -- yield API ngx.say(mydata. incr_age("dog")) } }
Tanpa baris kode sleep ini (atau operasi IO non-blocking lainnya, seperti mengakses Redis, dll.), tidak akan ada operasi yield, tidak ada persaingan, dan output akhir akan berurutan.
Tetapi ketika kita menambahkan baris kode ini, bahkan jika hanya dalam 5 detik tidur, permintaan lain kemungkinan akan memanggil fungsi mydata.incr_age dan memodifikasi nilai variabel, sehingga menyebabkan angka output akhir tidak berurutan. Logikanya tidak sesederhana itu dalam kode aktual, dan bug jauh lebih sulit untuk dilacak.
Jadi, kecuali Anda yakin tidak ada operasi yield di antara yang akan memberikan kontrol ke loop event NGINX, saya sarankan untuk menjaga variabel tingkat modul Anda hanya untuk dibaca.
Pendekatan keempat dan terakhir menggunakan shared dict untuk berbagi data yang dapat dibagikan di antara beberapa worker.
Pendekatan ini didasarkan pada implementasi pohon merah-hitam, yang berkinerja baik. Namun, ini memiliki batasannya: Anda harus mendeklarasikan ukuran memori bersama dalam file konfigurasi NGINX sebelumnya, dan ini tidak dapat diubah saat runtime:
lua_shared_dict dogs 10m;
Shared dict juga hanya melakukan cache data string dan tidak mendukung tipe data Lua yang kompleks. Ini berarti bahwa ketika saya perlu menyimpan tipe data kompleks seperti table, saya harus menggunakan JSON atau metode lain untuk melakukan serialisasi dan deserialisasi, yang secara alami akan menyebabkan banyak kehilangan kinerja.
Bagaimanapun, tidak ada solusi sempurna di sini, dan tidak ada cara yang sempurna untuk berbagi data. Anda harus menggabungkan beberapa metode sesuai dengan kebutuhan dan skenario Anda.
Shared dict
Kami telah menghabiskan banyak waktu untuk mempelajari bagian berbagi data di atas, dan beberapa dari Anda mungkin bertanya-tanya: sepertinya mereka tidak langsung terkait dengan shared dict. Apakah itu tidak sesuai topik?
Sebenarnya, tidak. Silakan pikirkan: mengapa ada shared dict di OpenResty? Ingatlah bahwa tiga metode pertama berbagi data semuanya berada di tingkat permintaan atau tingkat Worker individu. Oleh karena itu, dalam implementasi OpenResty saat ini, hanya shared dict yang dapat menyelesaikan berbagi data antara Worker, memungkinkan komunikasi antara Worker, yang merupakan nilai keberadaannya.
Menurut saya, memahami mengapa teknologi ada dan mengetahui perbedaan dan keunggulannya dibandingkan dengan teknologi serupa lainnya jauh lebih penting daripada hanya mahir memanggil API yang disediakannya. Visi teknis ini memberi Anda tingkat wawasan dan pemahaman yang mendalam, dan bisa dibilang merupakan perbedaan penting antara insinyur dan arsitek.
Kembali ke shared dict, yang menyediakan lebih dari 20 API Lua untuk umum, semuanya bersifat atomik, sehingga Anda tidak perlu khawatir tentang persaingan dalam kasus beberapa Worker dan konkurensi tinggi.
API ini semua memiliki dokumen resmi yang detail, jadi saya tidak akan membahas semuanya. Saya ingin menekankan sekali lagi bahwa tidak ada kursus teknis yang dapat menggantikan pembacaan dokumen resmi dengan cermat. Tidak ada yang bisa melewatkan prosedur yang memakan waktu dan bodoh ini.
Selanjutnya, mari kita lanjutkan melihat API shared dict, yang dapat dibagi menjadi tiga kategori: baca/tulis dict, operasi antrian, dan manajemen.
Baca/tulis dict
Mari kita lihat terlebih dahulu kelas baca dan tulis dict. Dalam versi aslinya, hanya ada API untuk kelas baca dan tulis dict, fitur paling umum dari kamus bersama. Berikut adalah contoh paling sederhana.
$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs dict:set("Tom", 56) print(dict:get("Tom"))'
Selain set, OpenResty juga menyediakan empat metode penulisan: safe_set, add, safe_add, dan replace. Arti dari awalan safe di sini adalah bahwa jika memori penuh, alih-alih menghapus data lama sesuai dengan LRU, penulisan akan gagal dan mengembalikan kesalahan no memory.
Selain get, OpenResty juga menyediakan metode get_stale untuk membaca data, yang memiliki nilai kembalian tambahan untuk data yang kedaluwarsa dibandingkan dengan metode get.
value, flags, stale = ngx.shared.DICT:get_stale(key)
Anda juga dapat memanggil metode delete untuk menghapus kunci yang ditentukan, yang setara dengan set(key, nil).
Operasi Antrian
Berbicara tentang operasi antrian, ini adalah tambahan yang lebih baru di OpenResty yang menyediakan antarmuka yang mirip dengan Redis. Setiap elemen dalam antrian dijelaskan oleh ngx_http_lua_shdict_list_node_t.
typedef struct { ngx_queue_t queue; uint32_t value_len; uint8_t value_type; u_char data[1]; } ngx_http_lua_shdict_list_node_t;
Saya telah memposting PR dari API antrian ini di artikel. Jika Anda tertarik dengan ini, Anda dapat mengikuti dokumentasi, kasus uji, dan kode sumber untuk menganalisis implementasi spesifik.
Namun, tidak ada contoh kode yang sesuai untuk lima API antrian berikut dalam dokumentasi, jadi saya akan memperkenalkannya secara singkat di sini.
lpush``/``rpushberarti menambahkan elemen di kedua ujung antrian.lpop``/``rpop, yang mengeluarkan elemen di kedua ujung antrian.llen, yang menunjukkan jumlah elemen yang dikembalikan ke antrian.
Jangan lupa alat berguna lain yang kita bahas di artikel terakhir: kasus uji. Kita biasanya dapat menemukan kode yang sesuai dalam kasus uji jika tidak ada dalam dokumentasi. Tes yang terkait dengan antrian tepatnya ada di file 145-shdict-list.t.
=== TEST 1: lpush & lpop --- http_config lua_shared_dict dogs 1m; --- config location = /test { content_by_lua_block { local dogs = ngx.shared.dogs local len, err = dogs:lpush("foo", "bar") if len then ngx.say("push success") else ngx.say("push err: ", err) end local val, err = dogs:llen("foo") ngx.say(val, " ", err) local val, err = dogs:lpop("foo") ngx.say(val, " ", err) local val, err = dogs:llen("foo") ngx.say(val, " ", err) local val, err = dogs:lpop("foo") ngx.say(val, " ", err) } } --- request GET /test --- response_body push success 1 nil bar nil 0 nil nil nil --- no_error_log [error]
Manajemen
API manajemen terakhir juga merupakan tambahan yang lebih baru dan merupakan permintaan populer di komunitas. Salah satu contoh paling tipikal adalah penggunaan memori bersama. Misalnya, jika seorang pengguna meminta ruang 100M sebagai shared dict, apakah 100M ini cukup? Berapa banyak kunci yang disimpan di dalamnya, dan kunci apa saja itu? Ini semua adalah pertanyaan yang otentik.
Untuk jenis masalah ini, OpenResty resmi berharap pengguna menggunakan grafik api untuk menyelesaikannya, yaitu dengan cara non-invasif, menjaga basis kode tetap efisien dan rapi, alih-alih menyediakan API invasif untuk mengembalikan hasil secara langsung.
Tetapi dari sudut pandang ramah pengguna, API manajemen ini masih sangat penting. Lagi pula, proyek open source dirancang untuk memenuhi kebutuhan produk, bukan untuk memamerkan teknologi itu sendiri. Jadi, mari kita lihat API manajemen berikut yang akan ditambahkan nanti.
Pertama adalah get_keys(max_count?), yang secara default hanya mengembalikan 1024 kunci pertama; jika Anda mengatur max_count ke 0, ini akan mengembalikan semua kunci. Kemudian datang capacity dan free_space, keduanya adalah bagian dari repositori lua-resty-core, jadi Anda perlu require mereka sebelum menggunakannya.
require "resty.core.shdict" local cats = ngx.shared.cats local capacity_bytes = cats:capacity() local free_page_bytes = cats:free_space()
Mereka mengembalikan ukuran memori bersama (ukuran yang dikonfigurasi dalam lua_shared_dict) dan jumlah byte halaman bebas. Karena shared dict dialokasikan per halaman, bahkan jika free_space mengembalikan 0, mungkin masih ada ruang di halaman yang dialokasikan. Oleh karena itu, nilai kembalinya tidak mewakili berapa banyak memori bersama yang digunakan.
Ringkasan
Dalam praktiknya, kita sering menggunakan caching multi-level, dan proyek OpenResty resmi juga memiliki paket caching. Bisakah Anda menemukan proyek mana mereka? Atau apakah Anda tahu beberapa perpustakaan lua-resty lain yang mengemas caching?
Anda dipersilakan untuk membagikan artikel ini dengan rekan dan teman Anda sehingga kita dapat berkomunikasi dan meningkatkan bersama.