تنازع الأقفال الذي لم يظهر في خطة الاستعلام
حتى ألف عامل Go متزامن، كلٌّ منهم يكتب صفوف مستخدمه الخاص فقط في MySQL — ومع ذلك ظل InnoDB يكتشف حالات deadlock بينهم. آلية أقفال الفجوات، مقارنة الحلول، والحل البسيط الذي اخترناه عمدًا.
من بين كل الأخطاء التي صحّحتها في حياتي، المفضّل عندي — بالطريقة التي لا تحب بها خطأً إلا بعد إصلاحه — هو ذاك الذي كانت فيه قاعدة البيانات تفعل بالضبط ما وعدت به، وأنا ببساطة لم أصدّقها. الوضع كان كالتالي: مهمة ليلية لإرسال إشعارات التوصيات مكتوبة بلغة Go، تتوزّع على ما يصل إلى ألف goroutine متزامنة، كلٌّ منها يكتب صفوف مستخدمه الخاص في جدول MySQL (InnoDB) — عملية upsert لصف جدولة ذلك المستخدم، مع بضع عمليات إدراج في سجلّات، داخل معاملة قصيرة. عمّال مختلفون، مستخدمون مختلفون، صفوف مختلفة، ولا تداخل بحكم التصميم. ومع ذلك، تحت الحمل، ظلّت المهمة تتعثّر: حالات deadlock يكتشفها InnoDB، معاملات تُختار ضحايا ويُتراجَع عنها، وعمّال ينتظرون أقفالًا على صفوف لا يلمسها أحد غيرهم. هذا ليس ما يُفترض أن يحدث — الجميع يعرف أن InnoDB يستخدم قفلًا على مستوى الصف.
اتّضح أن «القفل على مستوى الصف» عبارة كنت أستخدمها دون أن أمتلك تفاصيلها.
العَرَض بدقة
- كل عامل ينفّذ معاملة متواضعة:
INSERT ... ON DUPLICATE KEY UPDATEعلى جدول الجدولة بمفتاح معرّف مستخدمه، تليها إدراجات السجل. - عند تزامن منخفض، كل شيء على ما يرام. مع اتساع التوزيع ظهرت أخطاء deadlock — يكتشف InnoDB حلقة ويتراجع عن إحدى المعاملات — إلى جانب انتظارات أقفال عادية.
- إعادة محاولة الضحايا كانت «تنجح»، وهذا هو الفخ بالضبط: كل إعادة محاولة تدخل اليانصيب نفسه من جديد، فتعرج المهمة إلى النهاية وهي تُنفق تزامنها على العمل ثم التراجع ثم الإعادة.
المشتبه بهم البديهيون (معاملات طويلة، commit منسي، أقفال على مستوى التطبيق) خرجوا جميعًا أبرياء. التصادمات كانت حقيقية، وكانت داخل InnoDB.
ما الذي كان يُقفَل فعلًا
يبدأ التشخيص بقراءة تقرير المحرّك نفسه بدل التنظير فوقه — الأمر SHOW ENGINE INNODB STATUS يطبع القصة الكاملة لآخر deadlock مكتشف، بما في ذلك الأقفال التي كانت كل معاملة تحملها وتلك التي كانت تنتظرها.
الاعتقاد الخاطئ الجوهري أولًا: InnoDB لا يقفل الصفوف، بل يقفل مداخل الفهرس والفجوات بينها. تخيّل الفهرس الفريد على user_id كخطّ أعداد عليه المداخل الموجودة:
... [517] ---فجوة--- [519] ---فجوة--- [524] ---فجوة--- [530] ...
ثلاثة أنواع من الأقفال تعيش على هذا الخط:
- قفل السجل (record lock) — مدخل فهرس واحد. هذا ما يقصده الناس بعبارة «قفل الصف».
- قفل الفجوة (gap lock) — المساحة الفارغة بين المداخل. وظيفته منع «الأشباح» (phantoms): عند مستوى العزل الافتراضي
REPEATABLE READ، بمجرد أن تفحص معاملة نطاقًا ما، لا يجوز لأحد الإدراج فيه حتى تنتهي تلك المعاملة. أما قفل المفتاح التالي (next-key lock) فهو قفل سجل زائد الفجوة التي قبله، وهو ما تأخذه العبارات فعليًا عند هذا المستوى من العزل. - قفل نيّة الإدراج (insert-intention lock) — إشارة من المعاملة بأنها على وشك الإدراج في فجوة. نيّات الإدراج متوافقة فيما بينها، لكنها يجب أن تنتظر خلف أي قفل فجوة يغطي ذلك الموضع.
والآن عملية upsert. إن INSERT ... ON DUPLICATE KEY UPDATE ليست كتابة نقطية — عليها أن تتحقق مما إذا كان المفتاح الفريد موجودًا، ثم تُدرج أو تُحدّث، ثم تدافع عن تلك الإجابة لبقية عمر المعاملة. هذا الدفاع هو أقفال المفتاح التالي/الفجوة حول موضع المفتاح. شغّل مئات من هذه العمليات بتزامن على مفاتيح متجاورة، وستتجمّع الحلقة الكلاسيكية من تلقاء نفسها:
العامل A (المستخدم 519): يحمل قفل فجوة يغطي (517, 524)
العامل B (المستخدم 524): يحمل قفل فجوة يغطي (519, 530)
A: يريد نيّة إدراج داخل نطاق B ← ينتظر B
B: يريد نيّة إدراج داخل نطاق A ← ينتظر A ← حلقة
كلٌّ منهما يحمل ما يحتاجه الآخر. كاشف الـ deadlock في InnoDB يرصد الحلقة خلال أجزاء من الثانية، يختار ضحية، ويتراجع عنها بالخطأ 1213. عبارة «صفوف مختلفة» كانت صحيحة على المستوى المنطقي وغير ذات صلة على مستوى الفهرس: لم يكن العمّال يتنازعون على صفوف أصلًا — بل على نطاقات متداخلة من الفهرس الفريد المكتظّ نفسه، وألف عامل على نطاق user_id مكتظّ يضمن التداخل.
البدائل، مرتّبةً
يحتاج الـ deadlock إلى معاملتين تحملان أقفالًا متعارضة في الوقت نفسه؛ كل حل حقيقي يزيل أحد المكوّنات. القائمة، بالترتيب الذي سأتناولها به اليوم:
- تسلسل الكتابات المتعارضة داخل العملية — كاتب واحد في كل مرة عبر القسم الحرج، خلف
sync.Mutex. ثلاثة أسطر، ويصبح الـ deadlock مستحيلًا داخل العملية. - منتِج/مستهلك: N عامل وكاتب واحد لقاعدة البيانات. أبقِ التوزيع المتوازي للعمل المكلف، لكن أرسل النتائج عبر قناة إلى goroutine كاتبة واحدة تدفع على دفعات («كل 500 صف أو كل ثانيتين»). نفس خاصية الكاتب الواحد التي في الـ mutex، لكنها معبَّر عنها في البنية بدل أن تكون مخبأة في قفل — والدفعات تجعلها أسرع لا أكثر أمانًا فحسب.
- تقسيم على مراحل مع كتابة بالجملة. أنجز كل العمل المتوازي أولًا؛ ثم عبارة
INSERT ... ON DUPLICATE KEY UPDATEواحدة متعددة الصفوف (مقسّمة إلى دفعات، مرتّبة بالمفتاح — عمليتا upsert بالجملة تضربان الصفوف بترتيبين مختلفين هما بذاتهما deadlock كلاسيكي) تكتب كل شيء. عبارة واحدة لكل دفعة تعني جولات أقل ولا تعارض بين العمال إطلاقًا، وتبقى صحيحة حتى لو شغّلت المهمة لاحقًا في عدة حاويات. - فصل الإدراج عن التحديث. عاصفة أقفال الفجوات موجودة لأن الـ upsert لا يعرف إن كان المفتاح موجودًا. إذا أُنشئت الصفوف مسبقًا مرة واحدة (إدراج بالجملة عند بدء المهمة)، تصبح كتابة كل عامل
UPDATE ... WHERE user_id = ?بسيطة — قفل سجل على مدخل واحد، لا قفل فجوة، مع الحفاظ الكامل على التوازي. لكنه حل دقيق: أمانه بسبب الإنشاء المسبق، وأول refactoring «يبسّطه» إلى upsert يعيد الخطأ بصمت. - النزول إلى
READ COMMITTEDلمعاملات المهمة، وهو ما يعطّل معظم أقفال الفجوات. سطر واحد — وتغيير في الدلالات (تختفي حماية الأشباح داخل المعاملة) يسهل تطبيقه خطأً لكل اتصال على حدة، ولا يعلّم أحدًا شيئًا.
| الخيار | مقاومة الـ deadlock | الإنتاجية | التعقيد | يصمد مع عدة نسخ |
|---|---|---|---|---|
| 1 · mutex | داخل العملية فقط | كافية | ~3 أسطر | لا |
| 2 · كاتب واحد عبر قناة | داخل العملية فقط | أفضل (دفعات) | صغير | لا |
| 3 · مراحل + كتابة بالجملة | بنيويًا | الأفضل | إعادة هيكلة متوسطة | نعم |
| 4 · إنشاء مسبق + UPDATE | نعم، بدقّة | الأفضل توازيًا | صغير لكنه هش | نعم |
| 5 · تخفيض العزل | غالبًا | كافية | سطر واحد بتكلفة خفية | جزئيًا |
الخيار البسيط، ولماذا صمد
اخترنا الخيار 1: تحديد سقف للتوزيع وتسلسل كتابات قاعدة البيانات خلف mutex على مستوى العملية — مع نقل إرسال رسالة الإشعار إلى خارج القسم الحرج، بحيث يحرس القفل أجزاء من الثانية من SQL لا استدعاءً شبكيًا لمنصة المراسلة. الجزء المكلف من كل تكرار (تركيب الرسائل وإرسالها) بقي متوازيًا بالكامل؛ الجزء الرخيص (عبارتا SQL) سار في طابور واحد.
المدخلات الصادقة للقرار: هذه مهمة ليلية موعدها النهائي «قبل الصباح»، لا مسار حسّاس للاستجابة. التوازي الذي كنا ندافع عنه كان أصلًا سالب المحصّلة — العمال ينفقون تزامنهم في انتظار أقفال فجوات بعضهم البعض وإعادة تشغيل معاملات متراجَع عنها، فكان SQL في طابور واحد أسرع عمليًا لا أبطأ. والخيارات 3–5 كانت كلها تعني تغييرات أعلى مخاطرة على مهمة تعمل، من أجل سقف أداء لم يطلبه أحد.
السقف المعروف للـ mutex مكتوب في التصميم: هو يسلسل داخل عملية واحدة فقط، فيتوقف عن العمل يوم تُشغَّل المهمة في عدة حاويات متوازية. لمهمة مجدولة واحدة كان ذلك مقبولًا، والكتابة بالجملة على مراحل (الخيار 3) مسجّلة رسميًا بوصفها الخليفة المخطط له — مع الخيار 2 كخطوة وسطى منخفضة الكلفة إذا كبرت الدفعة داخل العملية نفسها.
ما أحتفظ به من هذا الخطأ
- «القفل على مستوى الصف» هو قفل على مستوى الفهرس. السؤال ليس أبدًا «أي صفوف تغيّرها هذه العبارة؟» بل «أي فهرس تمشي عليه، وما النطاقات التي يقفلها ذلك المشي؟». عمليات upsert والإدراج هي عمليات نطاقات متنكّرة.
- المحرّك سيخبرك بما أقفله. يسمّي
SHOW ENGINE INNODB STATUSالفهارس وأنماط الأقفال والانتظارات بدقة في كل deadlock مكتشف؛ ويعرضperformance_schema.data_locksالصورة الحية. عشرون دقيقة هناك تغلب يومًا من التنظير فوق قاعدة البيانات. - أقفال الفجوات ميزة تعمل كما صُمّمت. إن
REPEATABLE READيدافع عن اتساقٍ قد لا تدري أنك تعتمد عليه؛ إطفاؤه قرار حقيقي لا مقبض ضبط. - طابِق الحل مع غلاف الأداء المطلوب، لا مع كبريائك. حقّق mutex مع سقف تزامن الموعدَ النهائي للدفعة بأسطر قليلة قابلة للمراجعة، وبقي الجدول المرتّب أعلاه في ملاحظات التصميم لليوم الذي يتغيّر فيه الغلاف.
كلّفنا هذا الخطأ فترة من التحديق الحائر تحديدًا لأن النموذج الذهني كان صحيحًا تقريبًا. وهذا هو صنف الأخطاء الذي يستحق التدوين: الإصلاح كان صغيرًا؛ الفهم كان هو المنتَج.