ملخص المقال: المعاملة وُجدت لتبقي القيود على بياناتك صحيحة، لا لتغلّف الآثار الجانبية. دليل لتحديد الحد العملي: متى تدمج الكتابات ومتى تفصلها، وكيف تتعامل مع أثر لا يمكن التراجع عنه دون أن تقفل قاعدتك على نفسها.
طريقة القراءة: ابدأ بفهم المشكلة، ثم راجع القرار أو الخطوات العملية في الأقسام التالية، واستفد من المخطط لتصور الأجزاء المرتبطة ببعضها.
ماذا ستخرج به من هذا المقال؟
كل دالة تكتب في نظامك تتخذ قرارًا صامتًا: ما الذي يُحسم معًا، وما الذي يُترك للمصادفة. والقرار الخاطئ لا يظهر كاستثناء في السجل، بل كسلوك غريب يصعب تعليله: طلب يتأخر عند الذروة، رصيد لا يطابق كشف الحساب، أو مهمة ليلية تتجمّد فوق جدول مقفل. حدود BEGIN وCOMMIT وحدها تحدّد ذلك.
القاعدة التي تحسمه واضحة: المعاملة وُجدت لتبقي القيود على بياناتك صحيحة، لا لتغلّف الآثار الجانبية. وعليها نبني أمثلة لحدود المعاملة في الخدمات الموزعة، ثم المعاملة التعويضية لِأثرٍ لا يقبل التراجع، ثم مضاعفات القفل والإدخال.
القاعدة الذهبية: المعاملة تصون قيودًا، ولا تغلّف آثارًا
سلامة المعاملة ليست وعدًا للعالم، بل وعدًا بحدودك أنت: مجموعة الكتابات تُقرأ إمّا كاملة أو لا شيء. هذا هو الحد الأقصى لما تقدّمه المعاملة. أما نداء HTTP، أو رسالة إلى وسيط، أو بريد، أو استدعاء مصرف، فهي خارج النطاق ولا تملك مسار تراجع. خادم يوافق ثم ينقطع: المعاملة «تنجح» بينما العالم الخارجي معلّق في حالة لا يعرف أحد حالتها.
الاختبار الحاسم: لو أُعيدت المعاملة إلى ما قبلها، فما الذي يجب التراجع عنه خارج القاعدة؟ إن كان الجواب «لا شيء» فالحد صحيح. وإن كان «نحتاج ردّ المبلغ وتحرير الحجز وسحب الرسالة» فقد تجاوزت النطاق، فتحتاج خطة تعويض صريحة.
| داخل المعاملة | خارج المعاملة |
|---|---|
| كتابة سجلات يربطها قيد تجاري واحد | استدعاء مصرف أو واجهة خارجية |
| فحص القيد: رصيد كافٍ، مخزون غير سالب | إرسال بريد أو إشعار |
| تسجيل نية النشر في Outbox | نشر الرسالة على الوسيط |
| كتابة سجل تدقيق | تحديث ذاكرة تخزين مؤقت |
// داخل المعاملة: قيود على بياناتك وحدها
$db->transaction(function () use ($order, $lines) {
$orders->insert($order);
foreach ($lines as $line) {
$stock->decrementOrFail($line->sku, $line->qty); // لا مخزون سالب
}
$outbox->append('order.created', ['order_id' => $order->id]);
});
// خارج المعاملة: آثار لا يملكها محرك قاعدة البيانات
$outbox->relay(); // نشر الحدث إلى الوسيط
$mailer->send($order->id); // إشعار المستخدم
$payments->initiate($order->id); // بوابة الدفع — أثر لا يُتراجَعالاختبار في خمس ثوانٍ
ضع نداءً خارجيًا بطيئًا في منتصف معاملتك وقِس مدتها. إن تضاعف زمن الاستجابة بمقدار زمن النداء، فأنت تحمل قفلًا تجاريًا بسبب شيء لا تملكه.
متى تكون المعاملة فخًا: الفتح الزائد والسقوط الزائد
الخطآن المتقابلان يبدوان متشابهين في مراجعة الكود، ويظهران معًا في الأنظمة كثيرة تغيّر أصحابها.
علامات الفتح الزائد
- نداء شبكي داخلها: زمن غير معروف ومصدر فشل لا تعرف حدوده.
- انتظار بشري: لا تفتح معاملة ثم تطلب من المستخدم بيانات.
- صفوف ساخنة مشتركة: عدّاد أو رصيد خزينة يلمسه كل طلب.
- حجم يتناسب مع المُدخل: عدد الأقفال يكبر مع عدد عناصر الطلب.
- خدمات داخلية متسلسلة: كل طبقة تبدأ معاملة، فتتكاثر بلا داعٍ.
علامات السقوط الزائد
- قيد تجاري ممزّق: قيد الرصيد يبقى صحيحًا فقط إذا حُسم الطرفان معًا؛ فصلهما يخلق حالة وسطى يراها المستخدم.
- قراءة ثم كتابة بلا قفل: تقرأ رصيدًا ثم تكتب بلا
FOR UPDATEأو تحقق من نسخة. - مفتاح تكرارية في معاملة أخرى: إن سُجّل بعيدًا عن الأثر الذي يحميه، فلم يحمِ شيئًا.
السؤال الفاصل: «إن فشل هذا السطر، هل يجب التراجع عن كل ما فوقه؟» إن كان الجواب لا — لأن السطر جذر مستقل — فهو حدّ معاملة. وإن كان نعم، فهو داخله.
حدود المعاملة عبر الخدمات الموزعة
متى تكفي معاملة محلية واحدة
إذا كان كل المشاركين في قاعدة واحدة يديرها مدير المعاملات نفسه، فالجواب المتين معاملة ACID واحدة. تقسيمها معاملتين ليس تحسّنًا معماريًا، بل فتح لباب لحالة وسطى لا يملكها أحد.
ساجا: معاملات محلية مع تعويض
عندما يشارك أكثر من مورد — قاعدتان، أو خدمة داخلية، أو طرف ثالث — لا يمكنك لفّهم في ذرّية واحدة. الحل العملي هو الساجا: كل خطوة معاملة محلية مستقلة، وفشل خطوة يُطلق خطوات تعويض. ولها شكلان: تلقائي (choreography) يتفاعل فيه المشاركون عبر الأحداث ويصلح لعدد صغير فقط، وتنسيقي (orchestration) تمسك فيه خدمة مركزية الحالة وتقرر الخطوة التالية. ويضاف إليه تنفيذ متين (durable execution) يخزّن سجل الخطوات، فيصبح كل commit حدًّا قابلًا للاستئناف.
متى يكون 2PC اختيارًا خاطئًا
المعاملة الموزعة على مرحلتين (XA) تعطي ذرّية حقيقية عبر الموارد، لكن ثمنها تشغيلي: القائد يصبح نقطة فشل وحيد، والأقفال تبقى محتجزة عبر الشبكة، وأقلّ المشاركين توافرًا يفرض على الجميع. الساجا مع Outbox ومفاتيح تكرارية تغطّي الحاجة العملية نفسها بتكلفة صيانة أفضل.
الخطأ الشائع: افتراض أن XA يحرّرك من التخطيط
المعاملة الموزعة تحرّرك من كتابة كود تعويض، لكنها لا تحرّرك من التوافر ولا من الحجب.
المعاملة التعويضية: تراجعٌ لا يتراجع
التعويض ليس ROLLBACK بمظهر جديد، بل خطوة أمامية جديدة تُعيد الحالة التجارية إلى معنى مقبول دون أن تمحو الأثر الذي سبق. الرسالة التي خرجت لا تعود، والرسوم التي اقتُرضت لا تُلغى، والبيان الصادر يبقى. فالتعويض يصحّح الدلالة لا السجل:
| الخطوة الأمامية | ما يعنيه التعويض | ما لا يفعله |
|---|---|---|
| حجز المخزون | تحرير الحجز | حذف السجل؛ يبقى للتدقيق |
| خصم المبلغ | قيد عكسي في الدفتر | طلب إلغاء لدى البوابة |
| إصدار فاتورة | إشعار دائن | حذفها؛ المستند واجب ضريبي |
| إرسال رسالة | رسالة تصحيح | سحبها من صندوق المستلم |
| إنشاء حساب | تعطيل الحساب | حذفه؛ قد تكون عليه قيود |
شرطان يعلوان كل تعويض: أن يكون قابلًا للتكرار بلا أثر (idempotent) لأنه سيُعاد بعد انقطاع الشبكة، وأن يكون محميًا بشرط مسبق واضح: كيف تعرف أنه لم يُنفَّذ بعد؟ الجواب المعتاد عمود حالة على الأصل (مثال: compensated_at) مع قيد تفرّد. والأهم: يُكتب التعويض في مسار العمل نفسه، لا في try/catch يبتلع الاستثناء.
مضاعفات القفل والإدخال
القيد الأقوى على المعاملة ليس زمن تنفيذها بل ما تسكه من أقفال وإصدارات. كل قفل يبقى محتجزًا حتى COMMIT، وكتلة تحديثات في نموذج MVCC تترك إصدارات ميتة لا تُنظَّف إلا بعد الالتزام. دفعة من عشرين ألف صف في معاملة واحدة تعني عشرين ألف قفل، وعشرين ألف نسخة ميتة، وانتشار ثنائيات يجب أن يمرّ كاملًا. والنتيجة تضخّم جداول، وتأخّر تنظيف vacuum، وتدهور تدريجي في الإنتاجية يبدو كأنه فشل في بنية تحتية.
القاعدة العملية: عدد الموارد التي تمسك أقفالها يبقى ثابتًا مهما كبر المُدخل. عالج ذلك بالدفعات: التزم، ثم ابدأ دفعة جديدة، مع علامة تقدّم للاستئناف.
-- الدفعة: 20k صف في معاملة واحدة = 20k قفل
UPDATE batch_results
SET state = 'done'
WHERE job_id = $1 AND state = 'pending'
LIMIT 1000; -- آخر الدفعة، ثم COMMIT، ثم التالية
-- ترتيب ثابت للقفل: يمنع الأمان بين عمليتين متعاكستين
SELECT id, balance FROM accounts
WHERE id IN ($1, $2)
ORDER BY id -- الضمان هنا، لا في الكود
FOR UPDATE;
-- بديل متفائل: تحقق من النسخة عند الكتابة
UPDATE accounts
SET balance = balance - $1, version = version + 1
WHERE id = $2 AND version = $3; -- صفر صفوف = تعارضمضاعف الإدخال (input amplification) هو الصورة المقابلة: الدالة ثابتة الحجم لكن مُدخلها ليس. استدعاء يمرّ على ثلاث خدمات داخلية تبدأ كل واحدة معاملتها = ثلاث معاملات بدل واحدة. وطلب بخمسمئة سطر ينفّذ خمسمئة UPDATE داخل معاملة واحدة = خمسمئة قفل. المعالجة: تجميع الكتابة في INSERT متعدد القيم، وإزالة التكرار قبل الحلقة.
وأخيرًا الأمان (deadlock): ليس عطلًا بل آلية حماية تختار ضحية وتُلغي معاملتها. الخطر ليس الحدث بل العاصفة التي تليه: عشرات المعاملات تُعاد محاولتها في اللحظة نفسها فتتضارب مجددًا.
ثلاثة أسئلة تضع الحد الصحيح
عند مراجعة أي دالة تكتب في القاعدة، اطرح ثلاثة أسئلة بالترتيب: هل يجب أن يفشل كل ما في هذه المعاملة معًا إن فشل أحدها؟ إن كان لا، فضع حدًّا عند هذا السطر. هل تحتاج إحدى الخطوات إلى مورد خارج القاعدة؟ عندئذ افصل بالضبط عند تلك الخطوة. وهل عدد الأقفال يبقى ثابتًا مهما كبر حجم الطلب؟
مثال ملموس: تحويل بين حسابين
داخل معاملة واحدة: قفل الحسابين بترتيب المعرّف، التحقق من كفاية الرصيد، قيد مزدوج في دفتر الأستاذ، ثم COMMIT. لا شبكة ولا بريد ولا بوابة دفع داخل هذا النطاق. ثم يُرسل التحويل إلى البنك المقابل بعد الالتزام. إن فشل الاتصال، لا يستدعي النظام «تراجعًا»؛ يصدر قيدًا عكسيًا وعلمًا في جدول التعويضات. الحد هنا هو أصغر وحدة تبقي قيود دفترك صحيحة، وما عداها أثرٌ يُدار بخطوات تعويضية.
أخطاء شائعة
- نداء خارجي داخل المعاملة: زمن غير معروف وأقفال محتجزة، ومخاطر لا تملكها.
- معاملة واحدة لكل الدفعة: القفل يتناسب مع حجم الطلب، فتتحوّل دفعة صغيرة إلى حادثة إنتاجية.
- الاعتماد على ترتيب تنفيذ الكود: افرض ترتيب الأقفال بـ
ORDER BYقبلFOR UPDATE. - اعتبار الحذف تراجعًا: حذف سجل وسيلة الدفع لا يسترجع مبلغًا، ولا يمحو أثرًا في كشف ضريبي.
- catch يبتلع الاستثناء خارج المعاملة: فيلتفّ الفشل ولا يُسجَّل التعويض، فتبقى الحالة الوسطى بلا أثر.
قائمة تحقق
- لكل دالة كتابة: أجب في جملة واحدة عن سبب ضمّ هذه السطور إلى معاملة واحدة.
- ابحث في مسارات المعاملات عن أي استدعاء شبكة أو ناشر رسائل، وأخرجه بحدّ صريح.
- رتّب الأقفال في الاستعلام نفسه لا في ترتيب تنفيذ الشيفرة.
- قسّم كل عملية جماعية إلى دفعات قابلة لإعادة التنفيذ بعد الانقطاع.
- اجعل كل خطوة تعويضية قابلة للتكرار ومكتوبة في سجل، لا في ذاكرة العملية.
- راقب زمن المعاملة وعدد الأقفال لكل معاملة، قبل أن يراقبك المستخدمون.
الأسئلة الشائعة
كيف أكتشف أن معاملتي أوسع من اللازم دون انتظار الإنتاج؟
راقب زمن المعاملة عند المئين التسعين: إن اقترب من مئات الأجزاء من الألف من الثانية، فأنت تحمل قفلًا تجاريًا طويلًا. وقس عدد الصفوف المُعدَّلة لكل معاملة: إن كان يتبع حجم الطلب، فالحد مشكّل خطأ.
أستخدم ORM يغلّف كل شيء بمعاملة واحدة، كيف أضع حدًا صحيحًا؟
المشكلة ليست الأداة بل مستوى فتح المعاملة. اجعل المعاملة تطابق حالة استخدام كاملة، لا جدولًا واحدًا ولا استعلامًا واحدًا. في أطر مثل Laravel استخدم DB::transaction() حول خدمة حالة الاستخدام، وأبقِ الاستدعاء الشبكي خارج الإغلاق.
هل المعاملة الموزعة (2PC) هي الحل حين تتعدد قواعد البيانات؟
توفّر ذرّية حقيقية، وثمنها تشغيلي: قائد يصبح نقطة فشل واحدة، وأقفال محتجزة عبر الشبكة، وأي مشارِك منخفض التوافر يخفض موثوقية الجميع. في أغلب الأنظمة الإنتاجية تغطّي الساجا مع Outbox ومفاتيح تكرارية الحاجة العملية نفسها، وتترك أثرًا قابلًا للتدقيق عند كل خطوة.
الخلاصة
ما يجب أن يبقى معك
- المعاملة تصون قيودًا على بياناتك؛ لا تحمي آثارًا في أنظمة لا تملكها.
- اختبار الحد بسيط: ما الذي يجب التراجع عنه خارج القاعدة إن أُعيدت المعاملة؟
- افصل عند المورد الخارجي بالضبط، وسجّل نية النشر داخل المعاملة عبر Outbox.
- عدد الأقفال يجب ألّا يتناسب مع حجم المُدخل؛ الدفعات والترتيب الثابت يحلّان المشكلة.
- التعويض خطوة أمامية جديدة تصحّح الدلالة، لا حذف ينسخ الماضي.
الخطوة التالية
حدود المعاملة تحدد ما الذي يُحسم معًا؛ والسؤال التالي هو كيف ترى هذه المعاملات بعضها بعضًا عندما تتزامن. ابدأ بمستويات العزل: ما الفرق بين READ COMMITTED وREPEATABLE READ وSERIALIZABLE، ولماذا ينتج SERIALIZATION_FAILURE وأنت لم تكتب شيئًا متعارضًا.
كيف تستخدم هذا المقال في عمل حقيقي؟
ابدأ بمراجعة عملية: اختر ثلاثة مسارات كتابة في خدمتك، وارسم حدود المعاملات فوقها، ثم اسأل عن كل سطر داخل الحد: هل هو قيد على بياناتنا، أم استدعاء لطرف لا نملكه؟ لا تبدأ بهندسة معقّدة؛ ابدأ بإخراج نداءات الشبكة من النطاق، وتقسيم أكبر عملية ليلية، وإضافة ترتيب أقفال موحّد. وأخيرًا احترم القيود المؤسسية: في بعض الأنظمة المالية يفرض التنظيم حدودًا أوسع مما يقترحه هذا المقال، وعندها يُتخذ قرار الموازنة بين القيد المؤسسي وصحّة النظام صراحةً.
نقطة تحقق
مخطط يربط عناصر الموضوع
flowchart LR
A["طلب المستخدم"]
B["التحقق من المدخلات"]
C["خدمة الأعمال"]
D["المعاملات والتخزين"]
E["الاستجابة والمراقبة"]
A --> B
B --> C
C --> D
D --> E
التسلسل: طلب المستخدم ← التحقق من المدخلات ← خدمة الأعمال ← المعاملات والتخزين ← الاستجابة والمراقبة.
سيناريو تطبيقي ومراجعة
افترض أنك مسؤول عن تطبيق ما ورد في هذا المقال داخل فريق عمل. ابدأ بتحديد الهدف الذي يقيس النجاح في موضوع حدود المعاملات في قواعد البيانات: ماذا تضع في معاملة واحدة ومتى تقسّم، ثم اكتب المدخلات المطلوبة والقيود والصلاحيات، وجرب حالة طبيعية وأخرى استثنائية قبل الاعتماد.
- حدد الافتراضات التي تحتاج إلى تأكيد في بيئتك.
- حوّل القرارات الأساسية الواردة أعلاه إلى خطوات أو اختبارات قابلة للتنفيذ.
- وثّق طريقة التراجع أو التصحيح إذا فشل أحد الاختبارات.
أسئلة للمراجعة
كيف أبدأ بتطبيق هذا الموضوع؟
اختر حالة استخدام واحدة، وطبّق أصغر جزء يحقق قيمة قابلة للقياس؛ ثم قارن النتيجة بالمتطلبات.
كيف أتحقق من جودة الحل؟
اختبر المسار المعتاد ومسار الخطأ، وراجع الأمان وسلامة البيانات والأداء وفق طبيعة الموضوع، ثم أعد الاختبار بعد أي تعديل.
الرد على التعليق