उच्च प्रदर्शन की कुंजी: `shared dict` और `lru` कैश
API7.ai
December 22, 2022
पिछले लेख में, मैंने OpenResty के ऑप्टिमाइज़ेशन तकनीकों और परफॉर्मेंस ट्यूनिंग टूल्स का परिचय दिया था, जिसमें string, table, Lua API, LuaJIT, SystemTap, flame graphs आदि शामिल हैं।
ये सिस्टम ऑप्टिमाइज़ेशन की आधारशिला हैं, और आपको इन्हें अच्छी तरह से समझना चाहिए। हालांकि, केवल इन्हें जानना ही वास्तविक व्यावसायिक परिदृश्यों का सामना करने के लिए पर्याप्त नहीं है। अधिक जटिल व्यावसायिक परिदृश्य में, उच्च प्रदर्शन बनाए रखना एक व्यवस्थित कार्य है, न कि केवल कोड और गेटवे-स्तरीय ऑप्टिमाइज़ेशन। इसमें डेटाबेस, नेटवर्क, प्रोटोकॉल, कैश, डिस्क आदि विभिन्न पहलू शामिल होंगे, जो एक आर्किटेक्ट के अस्तित्व का अर्थ है।
आज के लेख में, आइए प्रदर्शन ऑप्टिमाइज़ेशन में बहुत महत्वपूर्ण भूमिका निभाने वाले घटक - कैश पर एक नज़र डालें, और देखें कि इसे OpenResty में कैसे उपयोग और ऑप्टिमाइज़ किया जाता है।
कैश
हार्डवेयर स्तर पर, अधिकांश कंप्यूटर हार्डवेयर गति बढ़ाने के लिए कैश का उपयोग करते हैं। उदाहरण के लिए, CPU में मल्टी-लेवल कैश होते हैं, और RAID कार्ड में रीड-एंड-राइट कैश होते हैं। सॉफ्टवेयर स्तर पर, हम जिस डेटाबेस का उपयोग करते हैं, वह कैश डिज़ाइन का एक बहुत अच्छा उदाहरण है। SQL स्टेटमेंट ऑप्टिमाइज़ेशन, इंडेक्स डिज़ाइन, और डिस्क रीड और राइट में कैश होते हैं।
यहां, मैं आपको सलाह दूंगा कि अपना खुद का कैश डिज़ाइन करने से पहले MySQL के विभिन्न कैशिंग मैकेनिज्म के बारे में जानें। मैं आपको जो सामग्री सुझाता हूं, वह उत्कृष्ट पुस्तक High Performance MySQL: Optimization, Backups, and Replication है। जब मैं कई साल पहले डेटाबेस की जिम्मेदारी संभाल रहा था, तो मुझे इस पुस्तक से बहुत लाभ हुआ, और बाद में कई अन्य ऑप्टिमाइज़ेशन परिदृश्यों में भी MySQL के डिज़ाइन से प्रेरणा ली गई।
कैशिंग पर वापस आते हुए, हम जानते हैं कि एक प्रोडक्शन वातावरण में कैशिंग सिस्टम को अपने व्यावसायिक परिदृश्य और सिस्टम की बाधाओं के आधार पर सबसे अच्छा समाधान ढूंढना होता है। यह संतुलन की एक कला है।
सामान्य तौर पर, कैशिंग के दो सिद्धांत होते हैं।
- एक यह कि उपयोगकर्ता के अनुरोध के जितना करीब हो, उतना अच्छा। उदाहरण के लिए, यदि आप लोकल कैश का उपयोग कर सकते हैं तो HTTP अनुरोध न भेजें। यदि आप CDN का उपयोग कर सकते हैं तो इसे ओरिजिन साइट पर भेजें, और यदि आप OpenResty कैश का उपयोग कर सकते हैं तो इसे डेटाबेस पर न भेजें।
- दूसरा यह कि इस प्रक्रिया और लोकल कैश का उपयोग करके इसे हल करने का प्रयास करें। क्योंकि प्रक्रियाओं, मशीनों, और यहां तक कि सर्वर रूम के पार, कैशिंग का नेटवर्क ओवरहेड बहुत बड़ा होगा, जो उच्च-संयोजन परिदृश्यों में बहुत स्पष्ट होगा।
OpenResty में, कैश का डिज़ाइन और उपयोग भी इन दो सिद्धांतों का पालन करता है। OpenResty में दो कैश घटक हैं: shared dict कैश और lru कैश। पूर्व केवल स्ट्रिंग ऑब्जेक्ट्स को कैश कर सकता है, और कैश किए गए डेटा की केवल एक प्रति होती है, जिसे प्रत्येक वर्कर द्वारा एक्सेस किया जा सकता है, इसलिए इसका उपयोग अक्सर वर्कर्स के बीच डेटा संचार के लिए किया जाता है। बाद वाला सभी Lua ऑब्जेक्ट्स को कैश कर सकता है, लेकिन उन्हें केवल एकल वर्कर प्रक्रिया के भीतर ही एक्सेस किया जा सकता है। कैश किए गए डेटा की जितनी प्रतियां होती हैं, उतने ही वर्कर्स होते हैं।
निम्नलिखित दो सरल टेबल shared dict और lru कैश के बीच अंतर को दर्शाती हैं:
| कैश घटक नाम | एक्सेस स्कोप | कैश डेटा प्रकार | डेटा संरचना | पुराना डेटा प्राप्त किया जा सकता है | API की संख्या | मेमोरी उपयोग |
|---|---|---|---|---|---|---|
| shared dict | कई वर्कर्स के बीच | स्ट्रिंग ऑब्जेक्ट्स | डिक्ट, क्यू | हाँ | 20+ | डेटा की एक प्रति |
| lru cache | एकल वर्कर के भीतर | सभी Lua ऑब्जेक्ट्स | डिक्ट | नहीं | 4 | डेटा की n प्रतियां (N = वर्कर संख्या) |
shared dict और lru कैश अच्छे या बुरे नहीं हैं। इन्हें आपके परिदृश्य के अनुसार एक साथ उपयोग किया जाना चाहिए।
- यदि आपको वर्कर्स के बीच डेटा साझा करने की आवश्यकता नहीं है, तो
lruजटिल डेटा प्रकार जैसे एरे और फ़ंक्शन्स को कैश कर सकता है और सबसे अधिक प्रदर्शन प्रदान करता है, इसलिए यह पहली पसंद है। - लेकिन यदि आपको वर्कर्स के बीच डेटा साझा करने की आवश्यकता है, तो आप
lruकैश के आधार पर एकshareddict कैश जोड़ सकते हैं, जिससे दो-स्तरीय कैश आर्किटेक्चर बन सकता है।
आगे, आइए इन दो कैशिंग तरीकों को विस्तार से देखें।
Shared dict कैश
Lua के लेख में, हमने shared dict के बारे में विशेष परिचय दिया है, यहां इसके उपयोग का संक्षिप्त समीक्षा है:
$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs dict:set("Tom", 56) print(dict:get("Tom"))'
आपको पहले से ही NGINX कॉन्फ़िगरेशन फ़ाइल में मेमोरी ज़ोन dogs घोषित करना होगा, और फिर इसे Lua कोड में उपयोग किया जा सकता है। यदि आपको उपयोग के दौरान पता चलता है कि dogs के लिए आवंटित स्थान पर्याप्त नहीं है, तो आपको पहले NGINX कॉन्फ़िगरेशन फ़ाइल को संशोधित करना होगा, और फिर NGINX को रीलोड करना होगा। क्योंकि हम रनटाइम में विस्तार और संकुचन नहीं कर सकते।
आगे, आइए shared dict कैश में प्रदर्शन से संबंधित कई मुद्दों पर ध्यान केंद्रित करें।
कैश किए गए डेटा का सीरियलाइज़ेशन
पहली समस्या कैश किए गए डेटा का सीरियलाइज़ेशन है। चूंकि shared dict में केवल string ऑब्जेक्ट्स को कैश किया जा सकता है, यदि आप एक एरे को कैश करना चाहते हैं, तो आपको सेट करते समय एक बार सीरियलाइज़ करना होगा और प्राप्त करते समय एक बार डिसीरियलाइज़ करना होगा:
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)'
हालांकि, ऐसे सीरियलाइज़ेशन और डिसीरियलाइज़ेशन ऑपरेशन CPU-गहन होते हैं। यदि प्रति अनुरोध इतने सारे ऑपरेशन होते हैं, तो आप उनकी खपत को flame graph पर देख सकते हैं।
तो, shared डिक्शनरी में इस खपत से कैसे बचा जाए? यहां कोई अच्छा तरीका नहीं है, या तो व्यावसायिक स्तर पर एरे को shared डिक्शनरी में रखने से बचें; या खुद से स्ट्रिंग्स को JSON फॉर्मेट में मैन्युअल रूप से जोड़ें। हालांकि, यह भी स्ट्रिंग जोड़ने की प्रदर्शन खपत लाएगा और अधिक बग्स का कारण बन सकता है।
अधिकांश सीरियलाइज़ेशन को व्यावसायिक स्तर पर तोड़ा जा सकता है। आप एरे की सामग्री को तोड़ सकते हैं और उन्हें स्ट्रिंग्स के रूप में shared डिक्शनरी में संग्रहीत कर सकते हैं। यदि यह काम नहीं करता है, तो आप एरे को lru में कैश कर सकते हैं, और प्रोग्राम की सुविधा और प्रदर्शन के लिए मेमोरी स्थान का उपयोग कर सकते हैं।
इसके अलावा, कैश में कुंजी को यथासंभव छोटा और अर्थपूर्ण रखना चाहिए, जो स्थान बचाता है और बाद के डिबगिंग को सुविधाजनक बनाता है।
पुराना डेटा
shared dict में डेटा पढ़ने के लिए एक get_stale विधि भी है। get विधि की तुलना में, इसमें समय सीमा समाप्त हो चुके डेटा के लिए एक अतिरिक्त रिटर्न वैल्यू होती है:
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)'
उपरोक्त उदाहरण में, डेटा को केवल shared dict में 0.01 सेकंड के लिए कैश किया गया है, और सेट के बाद 0.02 सेकंड के बाद डेटा का समय समाप्त हो गया है। इस समय, डेटा को get इंटरफ़ेस के माध्यम से प्राप्त नहीं किया जाएगा, लेकिन समय सीमा समाप्त हो चुके डेटा को get_stale के माध्यम से प्राप्त किया जा सकता है। यहां मैं "संभव" शब्द का उपयोग करता हूं क्योंकि समय सीमा समाप्त हो चुके डेटा द्वारा लिया गया स्थान कुछ संभावना के साथ पुनर्चक्रित किया जा सकता है और फिर अन्य डेटा के लिए उपयोग किया जा सकता है। यह LRU एल्गोरिदम है।
इसे देखते हुए, आपके मन में संदेह हो सकता है: समय सीमा समाप्त हो चुके डेटा को प्राप्त करने का क्या उपयोग है? यह मत भूलिए कि हम shared dict में जो संग्रहीत करते हैं, वह कैश किया गया डेटा है। यहां तक कि यदि कैश किया गया डेटा समय सीमा समाप्त हो जाता है, तो इसका मतलब यह नहीं है कि स्रोत डेटा को अवश्य अपडेट किया गया हो।
उदाहरण के लिए, डेटा स्रोत MySQL में संग्रहीत है। हम MySQL से डेटा प्राप्त करने के बाद, shared dict में पांच सेकंड का टाइमआउट सेट करते हैं। फिर, जब डेटा समय सीमा समाप्त हो जाता है, तो हमारे पास दो विकल्प होते हैं:
- जब डेटा मौजूद नहीं होता है, तो MySQL में फिर से क्वेरी करें, और परिणाम को कैश में रखें।
- यह निर्धारित करें कि क्या MySQL डेटा बदल गया है। यदि कोई परिवर्तन नहीं हुआ है, तो कैश में समय सीमा समाप्त हो चुके डेटा को पढ़ें, इसके समय सीमा को संशोधित करें, और इसे प्रभावी बनाएं।
बाद वाला एक अधिक ऑप्टिमाइज़्ड समाधान है जो MySQL के साथ यथासंभव कम इंटरैक्शन करता है ताकि सभी क्लाइंट अनुरोध सबसे तेज़ कैश से डेटा प्राप्त कर सकें।
इस समय, डेटा स्रोत में डेटा बदल गया है या नहीं, यह निर्धारित करना एक समस्या बन जाती है जिसे हमें विचार और हल करने की आवश्यकता है। आगे, आइए lru कैश को एक उदाहरण के रूप में लें, और देखें कि एक वास्तविक परियोजना इस समस्या को कैसे हल करती है।
lru कैश
lru कैश के लिए केवल 5 इंटरफ़ेस हैं: new, set, get, delete, और flush_all। केवल get इंटरफ़ेस उपरोक्त समस्या से संबंधित है। आइए पहले समझें कि यह इंटरफ़ेस कैसे उपयोग किया जाता है:
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)'
आप देख सकते हैं कि lru कैश में, get इंटरफ़ेस का दूसरा रिटर्न वैल्यू सीधे stale_data है, बजाय shared dict की तरह दो अलग-अलग APIs, get और get_stale में विभाजित होने के। ऐसे इंटरफ़ेस एनकैप्सुलेशन समय सीमा समाप्त हो चुके डेटा का उपयोग करने के लिए अधिक अनुकूल है।
हम आमतौर पर वास्तविक परियोजनाओं में विभिन्न डेटा को अलग करने के लिए वर्जन नंबर का उपयोग करने की सलाह देते हैं। इस तरह, डेटा बदलने के बाद इसका वर्जन नंबर भी बदल जाएगा। उदाहरण के लिए, etcd में एक संशोधित इंडेक्स का उपयोग वर्जन नंबर के रूप में किया जा सकता है ताकि यह चिह्नित किया जा सके कि डेटा बदल गया है या नहीं। वर्जन नंबर की अवधारणा के साथ, हम lru कैश का एक सरल द्वितीयक एनकैप्सुलेशन कर सकते हैं। उदाहरण के लिए, निम्नलिखित स्यूडो-कोड को देखें, जो lrucache से लिया गया है:
local function (key, version, create_obj_fun, ...) local obj, stale_obj = lru_obj:get(key) -- यदि डेटा समय सीमा समाप्त नहीं हुआ है और वर्जन नहीं बदला है, तो कैश किए गए डेटा को सीधे वापस करें if obj and obj._cache_ver == version then return obj end -- यदि डेटा समय सीमा समाप्त हो चुका है, लेकिन अभी भी प्राप्त किया जा सकता है, और वर्जन नहीं बदला है, तो कैश में समय सीमा समाप्त हो चुके डेटा को सीधे वापस करें if stale_obj and stale_obj._cache_ver == version then lru_obj:set(key, obj, item_ttl) return stale_obj end -- यदि कोई समय सीमा समाप्त हो चुका डेटा नहीं मिला है, या वर्जन नंबर बदल गया है, तो डेटा स्रोत से डेटा प्राप्त करें local obj, err = create_obj_fun(...) obj._cache_ver = version lru_obj:set(key, obj, item_ttl) return obj, err end
इस कोड से, आप देख सकते हैं कि वर्जन नंबर की अवधारणा को शामिल करके, हम समय सीमा समाप्त हो चुके डेटा का पूरी तरह से उपयोग करते हैं ताकि डेटा स्रोत पर दबाव कम हो और वर्जन नंबर न बदलने पर सर्वोत्तम प्रदर्शन प्राप्त हो।
इसके अलावा, उपरोक्त समाधान में, एक बड़ा ऑप्टिमाइज़ेशन यह है कि हम कुंजी और वर्जन नंबर को अलग करते हैं और वर्जन नंबर को मान की एक विशेषता के रूप में उपयोग करते हैं।
हम जानते हैं कि अधिक पारंपरिक तरीका वर्जन नंबर को कुंजी में लिखना है। उदाहरण के लिए, कुंजी का मान key_1234 हो सकता है। यह प्रथा बहुत आम है, लेकिन OpenResty वातावरण में, यह एक बर्बादी है। आप ऐसा क्यों कहते हैं?
एक उदाहरण दें, और आप समझ जाएंगे। यदि वर्जन नंबर हर मिनट बदलता है, तो key_1234 एक मिनट के बाद key_1235 बन जाएगा, और एक घंटे में 60 अलग-अलग कुंजी और 60 मान उत्पन्न होंगे। इसका मतलब यह भी है कि Lua GC को 59 कुंजी-मान जोड़े के पीछे के Lua ऑब्जेक्ट्स को पुनर्चक्रित करने की आवश्यकता होगी। यदि आप अधिक बार अपडेट करते हैं, तो ऑब्जेक्ट निर्माण और GC अधिक संसाधनों की खपत करेगा।
बेशक, इन खपतों को केवल वर्जन नंबर को कुंजी से मान में स्थानांतरित करके भी टाला जा सकता है। चाहे कुंजी कितनी भी बार अपडेट हो, केवल दो निश्चित Lua ऑब्जेक्ट्स मौजूद होंगे। यह देखा जा सकता है कि ऐसे ऑप्टिमाइज़ेशन तकनीक बहुत चतुर हैं। हालांकि, सरल और चतुर तकनीकों के पीछे, आपको OpenResty के API और कैशिंग मैकेनिज्म को गहराई से समझने की आवश्यकता है।
सारांश
हालांकि OpenResty का दस्तावेज़ीकरण काफी विस्तृत है, आपको यह अनुभव और समझने की आवश्यकता है कि इसे व्यवसाय के साथ कैसे जोड़ा जाए ताकि सबसे बड़ा ऑप्टिमाइज़ेशन प्रभाव उत्पन्न हो। कई मामलों में, दस्तावेज़ में केवल एक या दो वाक्य होते हैं, जैसे कि पुराना डेटा, लेकिन इसका प्रदर्शन पर बहुत बड़ा प्रभाव होगा।
तो, क्या आपके पास OpenResty का उपयोग करते समय ऐसा अनुभव है? हमारे साथ साझा करने के लिए एक संदेश छोड़ें, और आप इस लेख को साझा करने के लिए स्वागत कर रहे हैं, आइए हम साथ में सीखें और प्रगति करें।