استخراج الفواتير: قصة تحسين واجهة برمجة تطبيقات GraphQL الداخلية

نشرت: 2022-03-11

إحدى الأولويات الرئيسية لفريق Toptal الهندسي هي الانتقال إلى بنية قائمة على الخدمة. كان العنصر الأساسي في المبادرة هو Billing Extraction ، وهو مشروع عزلنا فيه وظائف الفوترة من منصة Toptal لنشرها كخدمة منفصلة.

خلال الأشهر القليلة الماضية ، قمنا باستخراج الجزء الأول من الوظيفة. لدمج الفوترة مع الخدمات الأخرى ، استخدمنا كلاً من واجهة برمجة تطبيقات غير متزامنة (قائمة على كافكا) وواجهة برمجة تطبيقات متزامنة (تعتمد على HTTP).

هذه المقالة عبارة عن سجل لجهودنا نحو تحسين واستقرار واجهة برمجة التطبيقات المتزامنة.

النهج التدريجي

كانت هذه هي المرحلة الأولى من مبادرتنا. في رحلتنا لاستخراج الفواتير بالكامل ، نسعى جاهدين للعمل بطريقة تدريجية لتقديم تغييرات صغيرة وآمنة للإنتاج. (شاهد الشرائح من حديث ممتاز عن جانب آخر من هذا المشروع: الاستخراج التدريجي لمحرك من تطبيق ريلز.)

كانت نقطة البداية هي منصة Toptal ، وهي عبارة عن تطبيق Ruby on Rails متآلف. بدأنا بتحديد اللحامات بين الفوترة ومنصة Toptal على مستوى البيانات. كان النهج الأول هو استبدال علاقات Active Record (AR) باستدعاءات الطريقة العادية. بعد ذلك ، احتجنا إلى تنفيذ استدعاء REST لخدمة الفوترة التي تجلب البيانات التي يتم إرجاعها بواسطة الطريقة.

قمنا بنشر خدمة فواتير صغيرة للوصول إلى نفس قاعدة البيانات مثل النظام الأساسي. تمكنا من الاستعلام عن الفواتير إما باستخدام واجهة برمجة تطبيقات HTTP أو من خلال الاستدعاءات المباشرة لقاعدة البيانات. سمح لنا هذا النهج بتنفيذ احتياطي آمن ؛ في حالة فشل طلب HTTP لأي سبب (التنفيذ غير الصحيح ، مشكلة الأداء ، مشاكل النشر) ، قمنا باستخدام مكالمة مباشرة وأعدنا النتيجة الصحيحة إلى المتصل.

لجعل الانتقالات آمنة وسلسة ، استخدمنا علامة ميزة للتبديل بين HTTP والمكالمات المباشرة. لسوء الحظ ، ثبت أن المحاولة الأولى التي تم تنفيذها باستخدام REST كانت بطيئة بشكل غير مقبول. تسبب ببساطة استبدال علاقات AR بالطلبات البعيدة في حدوث أعطال عند تمكين HTTP. على الرغم من أننا قمنا بتمكينه فقط لنسبة صغيرة نسبيًا من المكالمات ، استمرت المشكلة.

كنا نعلم أننا بحاجة إلى نهج مختلف جذريًا.

واجهة برمجة تطبيقات الفوترة الداخلية (المعروفة أيضًا باسم B2B)

قررنا استبدال REST بـ GraphQL (GQL) للحصول على مزيد من المرونة من جانب العميل. أردنا اتخاذ قرارات تستند إلى البيانات خلال هذا الانتقال لنتمكن من التنبؤ بالنتائج هذه المرة.

للقيام بذلك ، قمنا بتجهيز كل طلب من منصة Toptal (monolith) إلى الفواتير والمعلومات التفصيلية المسجلة: وقت الاستجابة ، والمعلمات ، والأخطاء ، وحتى تتبع المكدس عليها (لفهم أجزاء النظام الأساسي التي تستخدم الفواتير). سمح لنا هذا باكتشاف النقاط الساخنة - الأماكن في الكود التي ترسل العديد من الطلبات أو تلك التي تسبب ردودًا بطيئة. بعد ذلك ، باستخدام stacktrace والمعلمات ، يمكننا إعادة إنتاج المشكلات محليًا ولدينا حلقة ملاحظات قصيرة للعديد من الإصلاحات.

لتجنب المفاجآت السيئة في الإنتاج ، أضفنا مستوى آخر من أعلام الميزات. كان لدينا علامة واحدة لكل طريقة في واجهة برمجة التطبيقات للانتقال من REST إلى GraphQL. كنا نقوم بتمكين HTTP تدريجيًا ونراقب ما إذا ظهر "شيء سيء" في السجلات.

في معظم الحالات ، كان "الشيء السيئ" هو إما وقت استجابة طويل (متعدد الثواني) ، أو 429 Too Many Requests ، أو 502 Bad Gateway . لقد استخدمنا عدة أنماط لإصلاح هذه المشكلات: التحميل المسبق وتخزين البيانات مؤقتًا ، والحد من البيانات التي يتم جلبها من الخادم ، وإضافة الارتعاش ، وتحديد المعدل.

التحميل المسبق والتخزين المؤقت

كانت المشكلة الأولى التي لاحظناها هي تدفق الطلبات المرسلة من فئة / عرض واحد ، على غرار مشكلة N + 1 في SQL.

لم يعمل التحميل المسبق لـ Active Record عبر حدود الخدمة ، ونتيجة لذلك ، كان لدينا صفحة واحدة ترسل حوالي 1000 طلب إلى الفوترة مع كل إعادة تحميل. ألف طلب من صفحة واحدة! لم يكن الوضع في بعض الوظائف الخلفية أفضل بكثير. فضلنا تقديم عشرات الطلبات بدلاً من الآلاف.

كانت إحدى وظائف الخلفية هي جلب بيانات الوظيفة (دعنا نسمي هذا النموذج Product ) والتحقق مما إذا كان يجب وضع علامة على المنتج على أنه غير نشط بناءً على بيانات الفوترة (على سبيل المثال ، سنسمي نموذج BillingRecord ). على الرغم من جلب المنتجات على دفعات ، فقد تم طلب بيانات الفوترة في كل مرة كانت مطلوبة. احتاج كل منتج إلى سجلات الفواتير ، لذا أدت معالجة كل منتج إلى طلب خدمة الفوترة لجلبها. وهذا يعني طلبًا واحدًا لكل منتج وأدى إلى إرسال حوالي 1000 طلب من تنفيذ وظيفة واحدة.

لإصلاح ذلك ، أضفنا التحميل المسبق المجمّع لسجلات الفواتير. لكل دفعة من المنتجات التي يتم جلبها من قاعدة البيانات ، طلبنا سجلات الفواتير مرة واحدة ثم قمنا بتعيينها للمنتجات المعنية:

 # fetch all required billing records and assign them to respective products def cache_billing_records(products) # array of billing records billing_records = Billing::QueryService .billing_records_for_products(*products) indexed_records = billing_records.group_by(&:product_gid) products.each do |p| e.cache_billing_records!(indexed_records[p.gid].to_a) } end end

مع دفعات من 100 وطلب واحد لخدمة الفوترة لكل دفعة ، انتقلنا من ~ 1000 طلب لكل وظيفة إلى ~ 10.

العميل ينضم

عملت طلبات التجميع والتخزين المؤقت لسجلات الفواتير بشكل جيد عندما كان لدينا مجموعة من المنتجات واحتجنا إلى سجلات الفواتير الخاصة بهم. ولكن ماذا عن الطريقة الأخرى: إذا جلبنا سجلات الفواتير ثم حاولنا استخدام منتجات كل منهما ، التي تم جلبها من قاعدة بيانات النظام الأساسي؟

كما هو متوقع ، تسبب هذا في مشكلة N + 1 أخرى ، هذه المرة على جانب النظام الأساسي. عندما كنا نستخدم المنتجات لجمع سجلات الفوترة N ، كنا نجري استعلامات قاعدة بيانات N.

كان الحل هو جلب جميع المنتجات المطلوبة دفعة واحدة ، وتخزينها على هيئة تجزئة مفهرسة بواسطة المعرف ، ثم تخصيصها لسجلات الفواتير الخاصة بكل منها. التنفيذ المبسط هو:

 def product_billing_records(products) products_by_gid = products.index_by(&:gid) product_gids = products_by_gid.keys.compact return [] if product_gids.blank? billing_records = fetch_billing_records(product_gids: product_gids) billing_records.each do |billing_record| billing_record.preload_product!( products_by_gid[billing_record.product_gid] ) end end

إذا كنت تعتقد أن الأمر مشابه لضم التجزئة ، فأنت لست وحدك.

التصفية من جانب الخادم والجلب الناقص

لقد واجهنا أسوأ ارتفاع في الطلبات ومشكلات N + 1 على جانب النظام الأساسي. ومع ذلك ، ما زلنا نتلقّى ردودًا بطيئة. لقد حددنا أنها كانت ناتجة عن تحميل الكثير من البيانات على النظام الأساسي وتصفيتها هناك (التصفية من جانب العميل). كان تحميل البيانات إلى الذاكرة ، وتسلسلها ، وإرسالها عبر الشبكة ، وإلغاء التسلسل فقط لإسقاط معظمها ، إهدارًا هائلاً. كان مناسبًا أثناء التنفيذ لأن لدينا نقاط نهاية عامة وقابلة لإعادة الاستخدام. أثناء العمليات ، ثبت أنه غير قابل للاستخدام. كنا بحاجة إلى شيء أكثر تحديدًا.

لقد عالجنا المشكلة عن طريق إضافة عوامل التصفية إلى GraphQL. كان نهجنا مشابهًا للتحسين المعروف جيدًا والذي يتكون من نقل التصفية من مستوى التطبيق إلى استعلام قاعدة البيانات ( find_all مقابل where في Rails). في عالم قاعدة البيانات ، يكون هذا الأسلوب واضحًا ومتاحًا كـ WHERE في استعلام SELECT . في هذه الحالة ، تطلب الأمر منا تنفيذ معالجة الاستعلام بأنفسنا (في الفوترة).

نشرنا عوامل التصفية وانتظرنا رؤية تحسن في الأداء. بدلاً من ذلك ، رأينا أخطاء 502 على النظام الأساسي (وشاهدها مستخدمونا أيضًا). ليست جيدة. ليس جيدا علي الاطلاق!

لماذا حدث ذلك؟ كان ينبغي أن يؤدي هذا التغيير إلى تحسين وقت الاستجابة ، وليس كسر الخدمة. لقد أدخلنا خطأ خفي عن غير قصد. احتفظنا بكلا إصداري API (GQL و REST) ​​من جانب العميل. قمنا بالتبديل تدريجيًا بعلامة ميزة. قدمت النسخة الأولى المؤسفة التي نشرناها انحدارًا في فرع REST القديم. ركزنا اختباراتنا على فرع GQL ، لذلك فاتنا مشكلة الأداء في REST. الدرس المستفاد: إذا كانت معلمات البحث مفقودة ، فقم بإرجاع مجموعة فارغة ، وليس كل ما لديك في قاعدة البيانات الخاصة بك.

ألق نظرة على بيانات NewRelic الخاصة بالفوترة. لقد قمنا بنشر التغييرات باستخدام التصفية من جانب الخادم أثناء فترة هدوء حركة المرور (قمنا بإيقاف تشغيل حركة مرور الفوترة بعد مواجهة مشكلات في النظام الأساسي). يمكنك أن ترى أن الاستجابات أسرع وأكثر قابلية للتنبؤ بعد النشر.

الصورة: بيانات NewRelic لخدمة الفواتير. الاستجابات أسرع بعد النشر.

لم يكن من الصعب جدًا إضافة عوامل تصفية إلى مخطط GQL. كانت المواقف التي تألقت فيها GraphQL حقًا هي الحالات التي جلبنا فيها عددًا كبيرًا جدًا من الحقول ، وليس الكثير من العناصر. مع REST ، كنا نرسل جميع البيانات التي ربما تكون مطلوبة. أجبرنا إنشاء نقطة نهاية عامة على حزمها بجميع البيانات والجمعيات المستخدمة على النظام الأساسي.

مع GQL ، تمكنا من اختيار الحقول. بدلاً من جلب أكثر من 20 حقلاً تتطلب تحميل العديد من جداول قاعدة البيانات ، اخترنا فقط ثلاثة إلى خمسة حقول كانت مطلوبة. سمح لنا ذلك بإزالة الارتفاعات المفاجئة في استخدام الفواتير أثناء عمليات نشر النظام الأساسي نظرًا لاستخدام بعض هذه الاستعلامات من خلال وظائف إعادة فهرسة البحث المرن التي يتم تشغيلها أثناء النشر. كأثر جانبي إيجابي ، جعل عمليات النشر أسرع وأكثر موثوقية.

أسرع طلب هو الذي لا تقدمه

لقد حددنا عدد الكائنات التي تم جلبها وكمية البيانات المعبأة في كل كائن. ماذا يمكن أن نفعل؟ ربما لا تجلب البيانات على الإطلاق؟

لقد لاحظنا منطقة أخرى بها مجال للتحسين: كنا نستخدم تاريخ إنشاء آخر سجل فوترة في النظام الأساسي بشكل متكرر وفي كل مرة كنا نطلب الفواتير لجلبها. قررنا أنه بدلاً من جلبه بشكل متزامن في كل مرة كانت هناك حاجة إليه ، يمكننا تخزينه مؤقتًا استنادًا إلى الأحداث المرسلة من الفوترة.

خططنا مسبقًا ، وأعدنا المهام (من أربعة إلى خمسة) ، وبدأنا العمل لإنجازها في أقرب وقت ممكن ، حيث كانت تلك الطلبات تولد عبئًا كبيرًا. كان أمامنا أسبوعان من العمل.

لحسن الحظ ، بعد فترة وجيزة من بدءنا ، ألقينا نظرة ثانية على المشكلة وأدركنا أنه يمكننا استخدام البيانات الموجودة بالفعل على النظام الأساسي ولكن في شكل مختلف. بدلاً من إضافة جداول جديدة إلى ذاكرة التخزين المؤقت للبيانات من كافكا ، أمضينا بضعة أيام في مقارنة البيانات من الفوترة والنظام الأساسي. استشرنا أيضًا خبراء المجال حول ما إذا كان بإمكاننا استخدام بيانات النظام الأساسي.

أخيرًا ، استبدلنا المكالمة البعيدة باستعلام DB. كان ذلك فوزًا هائلاً من وجهة نظر الأداء وعبء العمل. لقد وفرنا أيضًا أكثر من أسبوع من وقت التطوير.

الصورة: الأداء وعبء العمل باستخدام استعلام قاعدة بيانات بدلاً من مكالمة بعيدة.

توزيع الحمولة

كنا ننفذ وننشر تلك التحسينات واحدة تلو الأخرى ، ومع ذلك لا تزال هناك حالات عندما استجابت الفواتير بـ 429 Too Many Requests . كان بإمكاننا زيادة حد الطلب على Nginx لكننا أردنا فهم المشكلة بشكل أفضل ، حيث كان ذلك تلميحًا إلى أن الاتصال لا يتصرف كما هو متوقع. كما قد تتذكر ، يمكننا تحمل هذه الأخطاء في الإنتاج ، لأنها لم تكن مرئية للمستخدمين النهائيين (بسبب الرجوع إلى الاتصال المباشر).

حدث الخطأ كل يوم أحد ، عندما تقوم المنصة بجدولة رسائل تذكير لأعضاء شبكة المواهب فيما يتعلق بالجداول الزمنية المتأخرة. لإرسال التذكيرات ، تقوم الوظيفة بجلب بيانات الفوترة للمنتجات ذات الصلة ، والتي تتضمن آلاف السجلات. كان أول شيء فعلناه لتحسينه هو تجميع بيانات الفوترة وتحميلها مسبقًا ، وجلب الحقول المطلوبة فقط. كلاهما حيل معروفة ، لذلك لن نخوض في التفاصيل هنا.

انتشرنا وانتظرنا الأحد التالي. كنا واثقين من أننا قد أصلحنا المشكلة. ومع ذلك ، ظهر الخطأ مرة أخرى يوم الأحد.

تم استدعاء خدمة الفواتير ليس فقط أثناء الجدولة ولكن أيضًا عند إرسال تذكير إلى أحد أعضاء الشبكة. يتم إرسال التذكيرات في وظائف خلفية منفصلة (باستخدام Sidekiq) ، لذلك كان التحميل المسبق غير وارد. في البداية ، افترضنا أنها لن تكون مشكلة لأنه ليس كل منتج يحتاج إلى تذكير ولأن التذكيرات تُرسل كلها مرة واحدة. تم جدولة التذكيرات الساعة 5 مساءً في المنطقة الزمنية لعضو الشبكة. لقد فاتنا تفاصيل مهمة ، على الرغم من ذلك: لا يتم توزيع أعضائنا عبر المناطق الزمنية بشكل موحد.

كنا نقوم بجدولة رسائل تذكير لآلاف أعضاء الشبكة ، يعيش حوالي 25٪ منهم في منطقة زمنية واحدة. يعيش حوالي 15٪ في المنطقة الزمنية الثانية الأكثر اكتظاظًا بالسكان. مع اقتراب الساعة الخامسة مساءً في تلك المناطق الزمنية ، كان علينا إرسال مئات التذكيرات في وقت واحد. وهذا يعني تدفق مئات الطلبات إلى خدمة الفوترة ، وهو ما يفوق قدرة الخدمة على التعامل معها.

لم يكن من الممكن تحميل بيانات الفوترة مسبقًا لأنه تمت جدولة التذكيرات في وظائف مستقلة. لم نتمكن من جلب عدد أقل من الحقول من الفوترة ، حيث قمنا بالفعل بتحسين هذا الرقم. كان نقل أعضاء الشبكة إلى مناطق زمنية أقل كثافة سكانية غير وارد أيضًا. إذا، مالذي فعلناه؟ نقلنا التذكيرات ، قليلاً فقط.

أضفنا عدم الاستقرار إلى الوقت الذي تمت فيه جدولة التذكيرات لتجنب موقف يتم فيه إرسال جميع التذكيرات في نفس الوقت بالضبط. بدلاً من الجدولة في الساعة 5 مساءً بشكل حاد ، قمنا بجدولتها في نطاق دقيقتين ، بين 5:59 مساءً و 6:01 مساءً.

لقد نشرنا الخدمة وانتظرنا يوم الأحد التالي ، واثقين من أننا قد أصلحنا المشكلة أخيرًا. لسوء الحظ ، ظهر الخطأ مرة أخرى يوم الأحد.

كنا في حيرة. وفقًا لحساباتنا ، كان يجب أن يتم توزيع الطلبات على فترة دقيقتين ، مما يعني أنه كان لدينا ، على الأكثر ، طلبان في الثانية. لم يكن هذا شيئًا لا تستطيع الخدمة التعامل معه. قمنا بتحليل سجلات وتوقيتات طلبات الفوترة وأدركنا أن تطبيق عدم الاستقرار لدينا لم ينجح ، لذلك كانت الطلبات لا تزال تظهر في مجموعة ضيقة.

الصورة: عدد كبير من الطلبات ناتج عن عدم ملاءمة التنفيذ.

ما سبب هذا السلوك؟ كانت هذه هي الطريقة التي ينفذ بها Sidekiq الجدولة. تقوم باستقصاء redis كل 10-15 ثانية وبسبب ذلك ، لا يمكنها تقديم دقة ثانية واحدة. لتحقيق توزيع موحد للطلبات ، استخدمنا Sidekiq::Limiter - فئة مقدمة من Sidekiq Enterprise. استخدمنا محدد النافذة الذي سمح بثمانية طلبات لنافذة متحركة مدتها ثانية واحدة. لقد اخترنا هذه القيمة لأننا كان لدينا حد Nginx من 10 طلبات في الثانية في الفوترة. احتفظنا برمز الارتعاش لأنه يوفر تشتتًا خشنًا للطلب: لقد وزع وظائف Sidekiq على مدار دقيقتين. ثم تم استخدام Sidekiq Limiter لضمان معالجة كل مجموعة من الوظائف دون كسر الحد المحدد.

مرة أخرى نشرناها وانتظرنا يوم الأحد. كنا واثقين من أننا قد أصلحنا المشكلة أخيرًا - وقد فعلنا ذلك. اختفى الخطأ.

تحسين API: Nihil Novi Sub Sole

أعتقد أنك لم تتفاجأ بالحلول التي استخدمناها. التجميع ، والتصفية من جانب الخادم ، وإرسال الحقول المطلوبة فقط ، وتحديد المعدل ليست تقنيات جديدة. لقد استخدمها مهندسو البرمجيات المتمرسون بلا شك في سياقات مختلفة.

التحميل المسبق لتجنب N + 1؟ لدينا في كل ORM. الهاش ينضم؟ حتى MySQL تمتلكها الآن. الجلب؟ SELECT * مقابل SELECT field هو خدعة معروفة. نشر العبء؟ إنه ليس مفهومًا جديدًا أيضًا.

فلماذا كتبت هذا المقال؟ لماذا لم نفعل ذلك بشكل صحيح منذ البداية ؟ كالعادة ، السياق هو المفتاح. بدت العديد من هذه التقنيات مألوفة فقط بعد أن قمنا بتنفيذها أو فقط عندما لاحظنا مشكلة في الإنتاج يجب حلها ، وليس عندما نحدق في الكود.

كان هناك العديد من التفسيرات المحتملة لذلك. في معظم الأوقات ، كنا نحاول القيام بأبسط شيء يمكن أن يعمل لتجنب الإفراط في الهندسة. بدأنا بحل REST الممل ثم انتقلنا إلى GQL. قمنا بنشر التغييرات خلف علامة الميزة ، ورصدنا كيف يتصرف كل شيء مع جزء صغير من حركة المرور ، وقمنا بتطبيق التحسينات على أساس بيانات العالم الحقيقي.

كان أحد اكتشافاتنا أنه من السهل التغاضي عن تدهور الأداء عند إعادة البناء (ويمكن معاملة الاستخراج على أنه إعادة بناء كبيرة). تعني إضافة حد صارم أننا قطعنا الروابط التي تمت إضافتها لتحسين الكود. لم يكن الأمر واضحًا حتى قمنا بقياس الأداء. أخيرًا ، في بعض الحالات ، لا يمكننا إعادة إنتاج حركة مرور الإنتاج في بيئة التطوير.

لقد سعينا جاهدين للحصول على سطح صغير من واجهة برمجة تطبيقات HTTP العالمية لخدمة الفوترة. نتيجة لذلك ، حصلنا على مجموعة من نقاط النهاية / الاستعلامات العامة التي كانت تحمل البيانات المطلوبة في حالات الاستخدام المختلفة. وهذا يعني أنه في كثير من حالات الاستخدام ، كانت معظم البيانات عديمة الفائدة. إنها نوع من المقايضة بين DRY و YAGNI: مع DRY ، لدينا نقطة نهاية / استعلام واحد فقط يعيد سجلات الفواتير بينما مع YAGNI ، ينتهي بنا الأمر ببيانات غير مستخدمة في نقطة النهاية تضر بالأداء فقط.

لاحظنا أيضًا مقايضة أخرى عند مناقشة التوتر مع فريق الفوترة. من وجهة نظر العميل (النظام الأساسي) ، يجب أن يحصل كل طلب على استجابة عندما تحتاجه المنصة. يجب إخفاء مشكلات الأداء والحمل الزائد للخادم وراء تجريد خدمة الفواتير. من وجهة نظر خدمة الفوترة ، نحتاج إلى إيجاد طرق لجعل العملاء على دراية بخصائص أداء الخادم لتحمل العبء.

مرة أخرى ، لا شيء هنا جديد أو غير مسبوق. يتعلق الأمر بتحديد الأنماط المعروفة في السياقات المختلفة وفهم المفاضلات التي أدخلتها التغييرات. لقد تعلمنا ذلك بالطريقة الصعبة ونأمل أن ننقذك من تكرار أخطائنا. بدلاً من تكرار أخطائنا ، لا شك أنك سترتكب أخطاءً بنفسك وتتعلم منها.

شكر خاص لزملائي وزملائي الذين شاركوا في جهودنا:

  • مقار إرموخين
  • غابرييل رينزي
  • صموئيل فيجا كاباليرو
  • لوكا جيدي