صمت إعدام روبي: سكك المعاملات / PostgreSQL Thriller

هذه قصة حول لماذا يجب ألا تتجاهل الأخطاء أبدًا عندما تكون داخل معاملة في قاعدة بيانات. معرفة كيفية استخدام المعاملات بشكل صحيح وما يجب فعله عند استخدامها ليس خيارًا. المفسد: سيكون حول الأقفال الاستشارية في PostgreSQL!


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


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


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


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


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


تندفع أقفال المعاملات إلى الإنقاذ


بالنسبة لكل شخص يعمل مع قواعد البيانات (العلائقية) ، فإن الإجابة واضحة: استخدام المعاملات !


من المهم أن تتذكر أنه في معظم قواعد إدارة قواعد البيانات الموزعة ، سيتم حظر السجلات التي تم تحديثها داخل المعاملة ولن يكون من الممكن الوصول إليها للتغييرات التي تتم من خلال عمليات أخرى حتى يتم إكمال هذه المعاملة. سيتم أيضًا تأمين السجلات المحددة باستخدام SELECT FOR UPDATE .


بالضبط قضيتنا! لقد أنهيت مهمة استيراد السلع الفردية في معاملة وقمت بحظر سجل المهام في البداية:


 ActiveRecord::Base.transaction do task = Import::Task.lock.find_by(id: id) # SELECT … FOR UPDATE  «    » return unless task #  - ? ,    ! #     task.destroy end 

الآن ، عندما يريد المستخدم إلغاء الاستيراد ، ستحذف عملية إيقاف الاستيراد مهام عمليات الاستيراد التي لم تبدأ بعد وستضطر إلى الانتظار حتى اكتمال عمليات الاستيراد الموجودة بالفعل:


 user.import_tasks.delete_all #        

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


ليس بهذه السرعة ...


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


لم تكن الأخطاء في السجلات مشجعة سواء: PG::InFailedSqlTransaction مع تراجع يؤدي إلى التعليمات البرمجية التي نفذت SELECT الأبرياء. ما الذي يحدث على الإطلاق؟


بعد يوم كامل من تصحيح الأخطاء ، حددت ثلاثة أسباب رئيسية للمشكلات:


  1. الإدراج التنافسي للسجلات المتضاربة في قاعدة البيانات.
  2. الإلغاء التلقائي للمعاملات في PostgreSQL بعد الأخطاء.
  3. صمت المشاكل (استثناءات روبي) في كود التطبيق.

المشكلة الأولى: الإدراج التنافسي للإدخالات المتضاربة


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


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


المشكلة الثانية: إلغاء المعاملة تلقائيًا PostgreSQL بعد الأخطاء


بالطبع ، منعنا إنشاء مهام مكررة على مستوى قاعدة البيانات باستخدام DDL التالي:


 ALTER TABLE product_deps ADD UNIQUE (user_id, characteristics); 

إذا قامت معاملة جارية بإدراج سجل جديد وحاولت المعاملة ب إدراج سجل بنفس قيم حقلي user_id characteristics ، فستتلقى المعاملة ب خطأ:


 BEGIN; INSERT INTO product_deps (user_id, characteristics) VALUES (1, '{"same": "value"}'); -- Now it will block until first transaction will be finished ERROR: duplicate key value violates unique constraint "product_deps_user_id_characteristics_key" DETAIL: Key (user_id, characteristics)=(1, {"same": "value"}) already exists. -- And will throw an error when first transaction have commited and it is become clear that we have a conflict 

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


 SELECT * FROM products; ERROR: current transaction is aborted, commands ignored until end of transaction block 

حسنًا ، ليس من الضروري تمامًا أن نقول إن كل ما تم إدخاله في قاعدة البيانات في هذه المعاملة لن يتم حفظه:


 COMMIT; --      ,   ROLLBACK --           

المشكلة الثالثة: الصمت


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


 def process_stuff(data) # ,   rescue StandardError nil #  ,  end 

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


العاصفة المثالية


وهذه هي الطريقة التي اجتمعت بها كل هذه العوامل الثلاثة لخلق الكمال العاصفة خطأ:


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

بديل لأقفال المعاملات في PostgreSQL


البحث عن rescue في رمز التطبيق وإعادة كتابة كل منطق الاستيراد ليس خيارًا. وقت طويل. كنت بحاجة إلى حل سريع ووجدته في postgres! لديها حل مدمج للأقفال ، بديل لقفل السجلات في المعاملات ، أقفال استشارية للاجتماعات. استخدمتها على النحو التالي:


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


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


 SELECT pg_advisory_lock_shared(42, user.id); 

يمكن أخذ أقفال مشتركة على نفس المفتاح في وقت واحد من قبل أي عدد من الجلسات.


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


 SELECT pg_advisory_lock(42, user.id) 

هذا كل ما في الأمر! الآن سينتظر "الإلغاء" حتى تكتمل جميع الواردات "الجارية" من السلع الفردية.


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


 transaction do execute("SET LOCAL lock_timeout = '30s'") execute("SELECT pg_advisory_lock(42, user.id)") rescue ActiveRecord::LockWaitTimeout nil #    (     ) end 

من الآمن اكتشاف خطأ خارج كتلة transaction ، لأن ActiveRecord سوف يتراجع عن المعاملة بالفعل .


ولكن ماذا تفعل مع الإدراج التنافسي للسجلات المتطابقة؟


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


  • INSERT … ON CONFLICT UPDATE (متوفر منذ PostgreSQL 9.5) في المعاملة الثانية سيتم قفله حتى اكتمال المعاملة الأولى ثم سيعيد السجل الذي تم إدراجه بواسطة المعاملة الأولى.
  • تأمين بعض السجلات العامة في المعاملة قبل تشغيل عمليات التحقق لإدراج سجل جديد. سننتظر هنا حتى يصبح السجل المدرج في معاملة أخرى مرئيًا ولا يمكن التحقق من الصحة بشكل كامل.
  • خذ نوعًا من قفل التوصية العامة - التأثير هو نفسه لحظر السجل العام.

حسنًا ، إذا لم تكن خائفًا من العمل مع أخطاء المستوى الأساسي ، يمكنك فقط التقاط خطأ التفرد:


 def import_all_the_things #   ,   Dep.create(user_id, chars) rescue ActiveRecord::RecordNotUnique retry end 

فقط تأكد من أن هذا الرمز لم يعد ملفوفًا في معاملة.


لماذا تم حظرهم؟

تقيّد القيود الفريدة والحصرية النزاعات المحتملة من خلال منع تسجيلها في نفس الوقت. على سبيل المثال ، إذا كان لديك قيد فريد على عمود صحيح وأدخلت إحدى المعاملات صفًا بقيمة 5 ، فسيتم حظر المعاملات الأخرى التي تحاول أيضًا إدراج 5 ، ولكن ستنجح المعاملات التي تحاول إدراج 6 أو 4 على الفور ، دون حظر. نظرًا لأن الحد الأدنى من مستوى عزل المعاملات الفعلي في PostgreSQL هو READ COMMITED ، فلا يحق للمعاملة أن ترى تغييرات غير ملتزم بها من المعاملات الأخرى. لذلك ، لا يمكن قبول أو INSERT مع قيمة متضاربة حتى تقوم المعاملة الأولى بتغييراتها (ثم تتلقى الثانية خطأً فريدًا) أو تتراجع (ثم ينجح الإدراج في المعاملة الثانية). اقرأ المزيد عن هذا في مقال لمؤلف قيود EXCLUDE .

منع حدوث كارثة مستقبلية


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


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


 #     module NoTransactionAllowed class InTransactionError < RuntimeError; end def call(*) return super unless in_transaction? raise InTransactionError, "#{self.class.name} doesn't work reliably within a DB transaction" end def in_transaction? connection = ApplicationRecord.connection # service transactions (tests and database_cleaner) are not joinable connection.transaction_open? && connection.current_transaction.joinable? end end #    class Deps::Import < BaseService prepend NoTransactionAllowed def call do_import rescue ActiveRecord::RecordNotUnique retry end end 

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


الملخص


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


ولا تبالغ في التعامل مع المعاملات في قاعدة البيانات. هذه ليست حلا سحريا. استخدم أداة عزل الأحجار الكريمة الخاصة بنا و after_commit_everywhere - ستساعد هذه المعاملات في أن تصبح مضمونة تمامًا.


ماذا تقرأ


روبي استثنائي بواسطة أفدي جريم . سيعلمك هذا الكتاب القصير كيفية التعامل مع الاستثناءات الموجودة في Ruby وكيفية تصميم نظام استثناء لتطبيقك بشكل صحيح.


استخدام المعاملات الذرية لتشغيل واجهة برمجة تطبيقات Idempotent بواسطةBrandur. تحتوي مدونته على العديد من المقالات المفيدة حول موثوقية التطبيق ، Ruby و PostgreSQL.

Source: https://habr.com/ru/post/ar418147/


All Articles