
مرحباً بالجميع ، اسمي أندري ، وأنا مطور. منذ وقت طويل - على ما يبدو ، يوم الجمعة الماضي - كان لدى فريقنا مشروع حيث كانوا بحاجة إلى البحث عن المكونات التي تشكل المنتجات. لنفترض تكوين النقانق. في بداية المشروع ، لم يكن هناك حاجة إلى الكثير من البحث: لإظهار جميع الوصفات التي يحتوي عليها المكون المطلوب بكمية معينة ؛ كرر لمكونات N.
ومع ذلك ، في المستقبل ، تم التخطيط لزيادة عدد المنتجات والمكونات بشكل ملحوظ ، ويجب ألا يتماشى البحث مع الحجم المتزايد للبيانات فحسب ، بل يوفر أيضًا خيارات إضافية - على سبيل المثال ، التجميع التلقائي لوصف المنتج بناءً على مكوناته السائدة.
المتطلبات- قم بإنشاء بحث على Elacsticsearch باستخدام قاعدة بيانات تحتوي على 50000 وثيقة على الأقل.
- توفير استجابة عالية السرعة للطلبات - أقل من 300 مللي ثانية.
- للتأكد من أن الطلبات كانت صغيرة ، وكانت الخدمة متاحة حتى في ظروف أسوأ الإنترنت عبر الهاتف النقال.
- اجعل منطق البحث بديهيًا قدر الإمكان من منظور تجربة المستخدم. كان من الضروري أن تعكس الواجهة منطق البحث - والعكس صحيح.
- قم بتقليل عدد التداخلات بين عناصر النظام للحصول على أداء أعلى وتبعيات أقل.
- لتوفير فرصة في أي وقت لاستكمال الخوارزمية بشروط جديدة (على سبيل المثال ، الإنشاء التلقائي لوصف المنتج).
- تقديم المزيد من الدعم لجزء البحث من المشروع بسيط ومريح قدر الإمكان.
قررنا عدم التسرع والبدء البسيط.
بادئ ذي بدء ، قمنا بتخزين جميع مكونات تكوين المنتج في قاعدة بيانات ، بعد أن تلقينا 10000 إدخال في البداية. لسوء الحظ ، حتى في هذا الحجم ، استغرق البحث في قاعدة البيانات الكثير من الوقت ، حتى مع الأخذ في الاعتبار استخدام الصلات والفهارس. وفي المستقبل القريب ، كان من المفترض أن يتجاوز عدد السجلات 50000. بالإضافة إلى ذلك ، أصر العميل على استخدام Elasticsearch (فيما يلي - ES) ، لأنه جاء عبر هذه الأداة ، وعلى ما يبدو ، كان لديه مشاعر دافئة. لم نكن نعمل مع ES من قبل ، لكننا عرفنا عن مزاياها واتفقنا مع هذا الاختيار ، لأنه ، على سبيل المثال ، كان من المخطط أن يكون لدينا غالبًا إدخالات جديدة (وفقًا لتقديرات مختلفة من 50 إلى 500 يوميًا) ، والتي ستكون ضرورية تعطي للمستخدم على الفور.
قررنا التخلي عن الطبقات الداخلية على مستوى برنامج التشغيل واستخدام طلبات REST ببساطة ، لأن المزامنة مع قاعدة البيانات تتم فقط في وقت إنشاء المستند ولم تعد هناك حاجة إليه. كانت هذه ميزة أخرى - حتى إرسال استعلامات البحث مباشرة إلى ES من متصفح.
لقد جمعنا أول نموذج أولي نقلنا الهيكل من قاعدة بيانات (PostgreSQL) إلى مستندات ES:
{"mappings" : { "recipe" : { "_source" : { "enabled" : true }, "properties" : { "recipe_id" : {"type" : "integer"}, "recipe_name" : {"type" : "text"}, "ingredients" : { "type" : "nested", "properties": { "ingredient_id": "integer", "ingredient_name": "string", "manufacturer_id": "integer", "manufacturer_name": "string", "percent": "float" } } } } }}
بناءً على هذا التعيين ، نحصل على المستند التالي تقريبًا (لا يمكننا عرض العامل من المشروع بسبب NDA):
{ "recipe_id": 1, "recipe_name": "AAA & BBB", "ingredients": [ { "ingredient_id": 1, "ingredient_name": "AAA", "manufacturer_id": 3, "manufacturer_name": "Manufacturer 3", "percent": 1 }, { "ingredient_id": 2, "ingredient_name": "BBB", "manufacturer_id": 4, "manufacturer_name": "Manufacturer 4", "percent": 3 } ] }
كل هذا تم باستخدام حزمة Elasticsearch PHP. قررت امتدادات Laravel (Elastiquent ، Laravel Scout ، وما إلى ذلك) عدم استخدامه لسبب واحد - طلب العميل أداءً عاليًا ، حتى النقطة المذكورة أعلاه أن "300 مللي ثانية للطلب كثير". وجميع حزم Laravel كانت بمثابة نفقات إضافية إضافية وتباطأت. كان من الممكن القيام بذلك مباشرة في Guzzle ، لكننا قررنا عدم الذهاب إلى أقصى الحدود.
أولاً ، تم إجراء أبسط بحث عن الوصفات مباشرةً على المصفوفات. نعم ، تم نقل كل هذا إلى ملفات التكوين ، لكن الطلب على كل حال أصبح كبيرًا جدًا. تم البحث على المستندات المرفقة (نفس المكونات) ، على التعبيرات المنطقية باستخدام "should" و "must" ، وكان هناك أيضًا توجيه للمرور الإلزامي على المستندات المرفقة - ونتيجة لذلك ، استهلك الطلب من مائة سطر ، وكان حجمه من ثلاثة كيلوبايت.
لا تنسى متطلبات سرعة وحجم الاستجابة - في ذلك الوقت تم تنسيق الإجابات في واجهة برمجة التطبيقات بطريقة تزيد من كمية المعلومات المفيدة: تم تقليل المفاتيح في كل كائن json إلى حرف واحد. لذلك ، أصبحت الاستفسارات في ESs لبضعة كيلوبايت ترفًا غير مقبول.
وفي تلك اللحظة ، أدركنا أن بناء استعلامات عملاقة في شكل صفائف ترابطية في PHP هو نوع من الإدمان الشرس. بالإضافة إلى ذلك ، أصبحت وحدات التحكم غير قابلة للقراءة تمامًا ، انظر بنفسك:
public function searchSimilar() { $conditions[] = [ "nested" => [ "path" => "ingredients", "score_mode" => "max", "query" => [ "bool" => [ "must" => [ ["term" => ["ingredients.ingredient_id" => $ingredient_id]], ["range" => ["ingredients.percent"=>[ "lte"=>$percent + 5, "gte"=>$percent - 5 ]]] ] ] ] ] ]; $parameters['body']['query']['bool']['should'][0]['bool']['should'] = $conditions; $equal_conditions[] = [ "nested" => [ "path" => "flavors", "query" => [ "bool" => [ "must" => [ ["term" => ["ingredients.percent" => $percent]] ] ] ] ] ]; $parameters['body']['query']['bool']['should'][1]['bool']['must'] = $equal_conditions; return $this->client->search($parameters); }
الانحدار الغنائي: عندما يتعلق الأمر بالحقول المتداخلة في المستند ، اتضح أنه لا يمكننا تلبية استعلام النموذج:
"query": { "bool": { "nested": { "bool": { "should": [ ... ] } } } }
لسبب واحد بسيط - لا يمكنك إجراء بحث متعدد داخل مرشح متداخل. لذلك ، كان علي القيام بذلك:
"query": { "bool": { "should": [ {"nested": { "path": "flavors", "score_mode": "max", "query": { "bool": { ... } } }} ] } }
أي في البداية ، تم الإعلان عن مصفوفة من الشروط ، وفي كل حالة تم استدعاء البحث بواسطة الحقل المتداخل. من وجهة نظر Elasticsearch ، هذا أكثر صحة ومنطقية. ونتيجة لذلك ، رأينا أنفسنا أن هذا كان منطقيًا عندما أضفنا مصطلحات بحث إضافية.
وهنا اكتشفنا نماذج
google المضمنة في ES. وقع الاختيار على Moustache - محرك قالب مناسب إلى حد ما بدون منطق. كان من الممكن وضع نص الطلب بالكامل وجميع البيانات المرسلة إليه عمليًا دون تغييرات ، ونتيجة لذلك أخذ الطلب النهائي النموذج:
{ "template": "template1", "params": params{} }
تبين أن جسم القالب متواضع وقابل للقراءة - فقط JSON وتوجيهات Moustache نفسها. يتم تخزين القالب في Elasticsearch نفسه ويسمى بالاسم.
/* search_similar.mustache */ { : { : { : [ {: { : {{ minimumShouldMatch }}, : [ {{#ingredientsList}} // mustache ingredientsList {{#ingredients}} // ingredients {: { : , : , : { : { : [ {: {: {{ id }} }}, {: { : { : {{ lte }}, : {{ gte }} }}} ] } } }} {{^isLast}},{{/isLast}} // {{/ingredients}} {{/ingredientsList}} ] }} ] } } } /* */ { : , : { : 1, : { : [ {: 1, : 10, : 5, : true } ] } } }
ونتيجة لذلك ، حصلنا في المخرج على قالب مررنا فيه ببساطة مجموعة من المكونات الضرورية. منطقياً ، لم يختلف الطلب كثيراً عما هو مشروط مما يلي:
SELECT * FROM ingredients LEFT JOIN recipes ON recipes.id = ingredient.recipe_id WHERE ingredients.id in (1,2,3) AND ingredients.id not in (4,5,6) AND ingredients.percent BETWEEN 10.0 AND 20.0
لكنه عمل بشكل أسرع ، وكان أساسًا جاهزًا لمزيد من الطلبات.
هنا ، بالإضافة إلى النسبة المئوية للبحث ، كنا بحاجة إلى عدة أنواع أخرى من العمليات: بحث بالاسم بين المكونات والمجموعات وأسماء الوصفات ؛ البحث بمعرف المكون مع مراعاة تحمل محتواه في الوصفة ؛ نفس الاستعلام ، ولكن مع حساب النتائج تحت أربعة شروط (تم لاحقًا إعادة تصميمه لمهمة أخرى) ، وكذلك الاستعلام النهائي.
يتطلب الطلب المنطق التالي: لكل مكون هناك خمس علامات تربطه بأي مجموعة. حسب لحم الخنزير ولحم البقر من اللحم والدجاج والديك الرومي من الدواجن. تقع كل علامة على المستوى الخاص بها. استنادًا إلى هذه العلامات ، يمكننا إنشاء وصف شرطي للوصفة ، مما سمح لنا بإنشاء شجرة بحث و / أو وصف تلقائيًا. على سبيل المثال ، لحم السجق والحليب مع التوابل والكبد والصويا والدجاج حلال. يمكن أن تحتوي وصفة واحدة على مكونات متعددة بنفس البطاقة. سمح لنا هذا بعدم ملء سلسلة العلامات بأيدينا - بناءً على تركيبة الوصفة ، يمكننا بالفعل وصفها بوضوح. لقد تغير هيكل الوثيقة المرفقة أيضًا:
{ "ingredient_id": 1, "ingredient_name": "AAA", "manufacturer_id": 3, "manufacturer_name": "Manufacturer 3", "percent": 1, "level_1": 2, "level_2": 4, "level_3": 6, "level_4": 7, "level_5": 12 }
كما كانت هناك حاجة لتحديد البحث بشرط "نقاء" الوصفة. على سبيل المثال ، كنا بحاجة إلى وصفة حيث لن يكون هناك سوى لحم البقر والملح والفلفل. ثم كان علينا التخلص من الوصفات حيث كان لحم البقر فقط في المستوى الأول والتوابل فقط في المستوى الثاني (العلامة الأولى للبهارات كانت صفر). هنا اضطررت للغش: حيث أن الشارب هو قالب بدون منطق ، لا يمكن الحديث عن أي حسابات ؛ هنا كان مطلوبًا تنفيذ جزء من البرنامج النصي في الطلب بلغة ES النصية - غير مؤلم. تركيبتها أقرب ما يمكن إلى Java ، لذلك لم تكن هناك صعوبات. ونتيجة لذلك ، كان لدينا قالب شارب يولد JSON ، حيث تم تنفيذ جزء من الحسابات ، أي الفرز والتصفية ، على P مؤلم:
"filter": [ {{#levelsList}} {{#levels}} {"script": { "script": " int total=0; for (ingredient in params._source.ingredients){ if ([0,{{tag}}].contains(ingredient.level_{{id}})) total+=1; } return (total==params._source.ingredients.length); " }} {{^isLast}},{{/isLast}} {{/levels}} {{/levelsList}} ]
فيما يلي ، يتم تنسيق نص البرنامج النصي لسهولة القراءة ، ولا يمكن استخدام فواصل الأسطر في الطلبات.
وبحلول ذلك الوقت ، أزلنا التسامح مع محتوى المكون ووجدنا عنق زجاجة - يمكننا التفكير في نقانق لحم البقر فقط لأن هذا المكون موجود هناك. ثم أضفنا - كل ذلك على نفس البرامج النصية غير المؤلمة - التصفية بشرط أن يسود هذا المكون في التكوين:
"filter": [ {"script":{ "script": " double nest=0,rest=0; for (ingredient in params._source.ingredients){ if([{{#tags}}{{tagId}}{{^isLast}},{{/isLast}}{{/tags}}].contains(flavor.level_{{tags.0.levelId}})){ nest+= ingredient.percent; }else{ if (ingredient.percent>rest){rest = ingredient.percent} } } return(nest>=rest); " }} ]
كما ترون ، افتقرت Elasticsearch إلى أشياء كثيرة لهذا المشروع ، لذلك كان يجب تجميعها من "الوسائل المتاحة". لكن هذا ليس مفاجئًا - فالمشروع غير اعتيادي بما يكفي لجهاز يستخدم للبحث عن نص كامل.
في إحدى المراحل المتوسطة من المشروع ، كنا بحاجة إلى الشيء التالي: عرض قائمة بجميع مجموعات المكونات المتاحة وعدد المواضع في كل منها. تم الكشف عن نفس المشكلة هنا كما في الاستعلام السائد: من أصل 10000 وصفة ، تم إنشاء حوالي 10 مجموعات بناءً على المحتوى. ومع ذلك ، تبين أن ما مجموعه حوالي 40000 وصفات موجودة في هذه المجموعات ، والتي لا تتوافق مع الواقع على الإطلاق. ثم بدأنا الحفر نحو استعلامات موازية.
الطلب الأول تلقينا قائمة بجميع المجموعات الموجودة في المستوى الأول بدون عدد الإدخالات. بعد ذلك ، تم إنشاء طلب متعدد: لكل مجموعة ، تم تقديم طلب لاستلام العدد الحقيقي للوصفات وفقًا لمبدأ النسبة المئوية السائدة. تم جمع كل هذه الطلبات في واحد وإرسالها إلى Elasticsearch. كان وقت الاستجابة للطلب العام يساوي وقت معالجة الطلب الأبطأ. التجميع الكلي جعل من الممكن موازنتها. استغرق المنطق المشابه (فقط من خلال التجميع حسب الشرط في الاستعلام) في SQL حوالي 15 مرة من الوقت.
/* */ $params = config('elastic.params'); $params['body'] = config('elastic.top_list'); return (Elastic::getClient()->search($params))['aggregations']['tags']['buckets']; /* */
بعد ذلك ، احتجنا إلى تقييم:
- كم عدد الوصفات المتاحة للتكوين الحالي ؛
- ما هي المكونات الأخرى التي يمكن أن نضيفها إلى التركيبة (أحيانًا نضيف المكون ونحصل على عينة فارغة) ؛
- ما هي المكونات من بين العناصر المحددة التي يمكننا وضع علامة عليها باعتبارها المكونات الوحيدة على هذا المستوى.
بناءً على المهمة ، قمنا بدمج منطق آخر طلب تم استلامه لقائمة الوصفات ومنطق الحصول على أرقام دقيقة من قائمة جميع المجموعات المتاحة:
/* */ : { // :{ // :{ : , : { : }, : [ {{#exclude}}{{ id }},{{/exclude}} 0] }, : { : {} } // , } } /* */ foreach ($not_only as $element) { $parameters['body'][] = config('elastic.params'); $parameters['body'][] = self::getParamsBody( $body, collect($only->all())->push($element), $max_level, 0, 0 ); } /* */ $parameters['body'][] = config('elastic.params'); $parameters['body'][] = self::getParamsBody( $body, $only, $max_level, $from, $size') ); /* */ $parameters['max_concurrent_searches'] = 1 + $not_only->count(); return (Elastic::getClient()->msearchTemplate($parameters))['responses'];
ونتيجة لذلك ، تلقينا طلبًا يعثر على جميع الوصفات اللازمة وعددها الإجمالي (تم أخذه من الاستجابة ["الزيارات"] ["الإجمالي"]). من أجل البساطة ، تم تسجيل هذا الطلب في المركز الأخير في القائمة.
بالإضافة إلى ذلك ، من خلال التجميع ، تلقينا جميع مكونات الهوية للمستوى التالي. لكل مكون لم يتم وضع علامة "فريد" عليه ، أنشأنا استعلامًا وضعنا علامة عليه وفقًا لذلك ، ثم عدنا ببساطة عدد المستندات التي تم العثور عليها. إذا كان أكبر من الصفر ، فقد تم اعتبار المكون متاحًا لتعيين المفتاح "مفرد". أعتقد هنا أنه يمكنك استعادة القالب بأكمله بدوني ، والذي حصلنا عليه عند الإخراج:
{ : {{ from }}, : {{ size }}, : { : { : [ {{#ingredientTags}} {{#tagList}} {: { : [ {: {: {{ tagId }} }} ] }} {{^isLast}},{{/isLast}} {{/tagList}} {{/ingredientTags}} ], : [ {:{ : }} {{#levelsList}}, {{#levels}} {: { : }} {{^isLast}},{{/isLast}} {{/levels}} {{/levelsList}} ] } }, : { :{ :{ : , : { : }, : [ {{#exclude}}{{ id }},{{/exclude}} 0] }, : { : {} } } }, : [ {: {: }} ] }
بالطبع ، نقوم بتخزين جزء من هذا الكومة من القوالب والاستعلامات (مثل صفحة جميع المجموعات المتاحة مع عدد الوصفات المتاحة) ، مما يضيف إلينا بعض الأداء في الصفحة الرئيسية. جعل هذا القرار من الممكن جمع البيانات الرئيسية في 50 مللي ثانية.
نتائج المشروعأجرينا بحثًا في قاعدة البيانات لما لا يقل عن 50000 مستند على Elasticsearch ، مما يسمح لك بالبحث عن المكونات في المنتجات والحصول على وصف للمنتج من المكونات الموجودة فيه. ستنمو قاعدة البيانات هذه قريبًا بنحو ست مرات (يتم إعداد البيانات) ، لذلك نحن سعداء جدًا بنتائجنا و Elasticsearch كأداة بحث.
فيما يتعلق بمسألة الأداء ، استوفينا متطلبات المشروع ، ويسعدنا أنفسنا أن متوسط وقت الاستجابة للطلب هو 250-300 مللي ثانية.
بعد ثلاثة أشهر من بدء العمل مع Elasticsearch ، لم يعد الأمر محيرًا وغير اعتيادي. ومزايا التشكيل واضحة: إذا رأينا أن الطلب يصبح كبيرًا جدًا مرة أخرى ، فإننا ببساطة ننقل المنطق الإضافي إلى القالب ونرسل الطلب الأصلي مرة أخرى إلى الخادم دون أي تغييرات تقريبًا.
"كل التوفيق وشكرا للأسماك!" (ج)
ملاحظة: في اللحظة الأخيرة ، كنا بحاجة أيضًا إلى الفرز حسب الأحرف الروسية في الاسم. ثم اتضح أن Elasticsearch لا يفهم الأبجدية الروسية بشكل كاف. النقانق المشروطة "Ultra mega pork 9000 سعرة حرارية" تحولت داخل الفرز ببساطة إلى "9000" وكانت في نهاية القائمة. كما اتضح ، يتم حل هذه المشكلة بسهولة عن طريق تحويل الأحرف الروسية إلى تدوين unicode للنموذج u042B.