بنناجح
المرحلة 2: الآليات · الدرس 2 من 4 · 25 دقيقة

الـ Deadlocks

أي نظام فيه Locks واستنا لازم يقابل اللحظة دي: اتنين، كل واحد ماسك حاجة ومستني اللي مع التاني، ومحدش هيسيب. الدرس ده بيشرح إزاي الـ Deadlock بيتكوّن، وإزاي MySQL و PostgreSQL بيكشفوه ويحلّوه، وإزاي تكتب كود يقلّله، وإيه اللي لازم الأبلكيشن يعمله لما يحصل.

في الدرس ده هتتعلم
  • تعرّف الـ Deadlock وشروطه الأربعة
  • تفهم Wait-for Graph وإزاي الداتابيز بتكشف الـ Cycle
  • تفرّق بين Detection و Prevention (Wait-Die و Wound-Wait) و Timeouts
  • تقرا تقرير Deadlock في MySQL و PostgreSQL
  • تقلّل الـ Deadlocks بترتيب ثابت وترانزاكشنز قصيرة و Retry صح

1. يعني إيه Deadlock؟

أشهر شكل: تحويلين في نفس اللحظة، واحد من علي لمنى، والتاني من منى لعلي.

تحويلين عكس بعضجرّب بنفسك
في الداتابيزali1000mona1000
علي ← منى (100)⏱منى ← علي (50)
  1. BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 'ali';X(ali) ✓1
  2. 2BEGIN; UPDATE accounts SET balance = balance - 50 WHERE id = 'mona';X(mona) ✓
  3. UPDATE accounts SET balance = balance + 100 WHERE id = 'mona';⏳ مستنية T23
  4. 4UPDATE accounts SET balance = balance + 50 WHERE id = 'ali';⏳ مستنية T1
  5. ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transactionالداتابيز اختارت T2 ضحية
  6. -- الـ UPDATE بيكمل COMMIT;T1 نجحت6
▶

دوس ▶ شغّل، أو امشي خطوة خطوة بالأسهم.

الـ Deadlock مش Bug في الداتابيز، هو نتيجة طبيعية لأي نظام فيه Locks. الداتابيز بتحله بإنها تلغي واحد، والباقي شغلك.

2. شروط Coffman الأربعة

الـ Deadlock بيحصل لو وبس لو الأربع شروط دول اتحققوا مع بعض (Coffman وزمايله، 1971):

الشرط معناه في الداتابيز
Mutual Exclusion المورد مينفعش يتشارك X Lock مع واحد بس
Hold and Wait ماسك حاجة ومستني حاجة ماسك صف ومستني صف تاني
No Preemption محدش يقدر ياخد اللي معاك بالعافية الـ Lock مش بيتسحب، بيتفك بس في الـ COMMIT/ROLLBACK
Circular Wait فيه دايرة استنا T1 ← T2 ← T1

كسر أي شرط واحد يمنع الـ Deadlock. والترتيب الثابت بيكسر الـ Circular Wait. وأخد كل الـ Locks مرة واحدة في الأول بيكسر الـ Hold and Wait. والـ Wound-Wait (تحت) بيكسر الـ No Preemption.

3. الـ Deadlock المخفي: Lock Upgrade

مش لازم صفين عشان يحصل Deadlock. صف واحد كفاية:

Deadlock على صف واحدجرّب بنفسك
في الداتابيزstock5
T1⏱T2
  1. SELECT stock FROM products WHERE id = 7 FOR SHARE;S ✓1
  2. 2SELECT stock FROM products WHERE id = 7 FOR SHARE;S ✓
  3. UPDATE products SET stock = 4 WHERE id = 7;⏳ عايزة X ومستنية S بتاع T23
  4. 4UPDATE products SET stock = 4 WHERE id = 7;⏳ عايزة X ومستنية S بتاع T1
  5. Deadlock foundواحدة اتلغت
▶

دوس ▶ شغّل، أو امشي خطوة خطوة بالأسهم.

لو هتكتب في الصف، خد X من أول قراية. الـ Shared Lock اللي بيترقّى لـ X هو أشهر Deadlock على صف واحد.

4. الكشف: Wait-for Graph

الداتابيز بتحافظ على الـ Graph ده، ولما تلاقي Cycle تختار Victim وتعمله ROLLBACK:

MySQL (InnoDB) PostgreSQL
إمتى بيدوّر فوراً مع كل استنا جديد (innodb_deadlock_detect = ON) بعد ما الترانزاكشن تستنى deadlock_timeout (افتراضي 1 ثانية)
مين الضحية الترانزاكشن “الأصغر”: أقل عدد صفوف اتضافت أو اتعدلت أو اتمسحت (أرخص Rollback) غالباً الترانزاكشن اللي اكتشفت الدايرة، والـ Docs نفسها بتقول إنها صعب تتوقع مين
الـ Error 1213 · SQLSTATE 40001 deadlock detected · SQLSTATE 40P01
تشوف آخر واحد SHOW ENGINE INNODB STATUS ← LATEST DETECTED DEADLOCK في الـ Log (مع log_lock_waits = on تشوف الاستنا الطويل كمان)

PostgreSQL بيستنى ثانية قبل ما يدوّر لأن الكشف مكلف، وأغلب حالات الاستنا بتخلص لوحدها في أقل من ثانية. وفي MySQL، على أنظمة فيها Concurrency عالي جداً، الكشف الفوري نفسه ممكن يبقى بطيء، وفيه ناس بتقفله وتعتمد على innodb_lock_wait_timeout بس.

SHOW ENGINE INNODB STATUS (مختصر)
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 4211, ACTIVE 2 sec starting index read
UPDATE accounts SET balance = balance + 100 WHERE id = 'mona'
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS ... index PRIMARY of table `shop`.`accounts` ... lock_mode X locks rec but not gap
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index PRIMARY of table `shop`.`accounts` ... lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 4212, ACTIVE 2 sec starting index read
UPDATE accounts SET balance = balance + 50 WHERE id = 'ali'
...
*** WE ROLL BACK TRANSACTION (2)

اقراه كده: كل ترانزاكشن ماسكة إيه (HOLDS)، ومستنية إيه (WAITING)، والجملة اللي كانت بتنفذها. ولو شفت lock_mode X من غير rec but not gap، يبقى ده Next-Key Lock، وغالباً السبب Range Scan أو Index ناقص.

5. المنع: Wait-Die و Wound-Wait

بدل ما نستنى الدايرة تتكوّن ونكشفها، ممكن نمنعها من الأول. الفكرة: كل ترانزاكشن ليها Timestamp (وقت ما بدأت)، والأقدم ليه أولوية. ولما ترانزاكشن تيجي تستنى، بنقرر حسب العمر:

Ti (اللي عايزة) أقدم من Tj (الماسكة) Ti أحدث من Tj
Wait-Die (مش بيسحب) Ti تستنى Ti تموت (Abort) وتبدأ تاني بنفس الـ Timestamp
Wound-Wait (بيسحب) Ti تجرح Tj (Tj تعمل Abort) Ti تستنى

في الاتنين، الاستنا بيحصل في اتجاه واحد بس (يا القديم يستنى الجديد، يا العكس)، فمستحيل تتكوّن دايرة. والترانزاكشن اللي بتموت بتحتفظ بالـ Timestamp الأصلي، فهتكبر وفي الآخر هتبقى الأقدم وتعدي (مفيش Starvation).

العيب: بيلغوا ترانزاكشنز كتير مكانش هيحصلها Deadlock أصلاً. علشان كده الداتابيز العلائقية الكبيرة بتفضّل الكشف. بس الأنظمة الموزعة بتستخدم الفكرة دي، زي Google Spanner اللي بيستخدم Wound-Wait.

6. الـ Timeouts

أبسط حل: أي ترانزاكشن تستنى أكتر من وقت معين تتلغى، سواء فيه Deadlock أو لأ.

timeouts.sql
-- MySQL: افتراضي 50 ثانية (كتير جداً لـ Web request)
SET SESSION innodb_lock_wait_timeout = 5;
-- ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
-- ⚠️ في MySQL الـ Timeout بيلغي "الجملة" بس، مش الترانزاكشن كلها، إلا لو innodb_rollback_on_timeout = ON

-- PostgreSQL: افتراضي 0 = يستنى للأبد
SET lock_timeout = '5s';
-- ERROR: canceling statement due to lock timeout   (SQLSTATE 55P03)

الـ Timeout مفيد كحماية: Web request مينفعش يستنى 50 ثانية Lock، الأحسن يفشل بسرعة ويقول للمستخدم يحاول تاني. والنقطة المهمة في MySQL: بعد الـ Timeout، الترانزاكشن لسه مفتوحة والجمل اللي قبل كده لسه موجودة. لازم الكود يعمل ROLLBACK بنفسه.

7. تقلّل الـ Deadlocks إزاي؟

مستحيل تمنعهم 100% في نظام حقيقي. الهدف إنهم يبقوا نادرين، ولما يحصلوا الكود يتعامل:

  1. ترتيب ثابت: لو بتعدّل كذا صف، اقفلهم بترتيب ثابت (ORDER BY id ... FOR UPDATE). ده أقوى قاعدة.
  2. ترانزاكشنز قصيرة: مفيش HTTP calls ولا إيميلات ولا حسابات تقيلة جوه الترانزاكشن. كل ده قبلها أو بعدها.
  3. Indexes صح: UPDATE بشرط على عمود من غير Index بيقفل صفوف كتير جداً (وفي InnoDB كمان Gaps)، والصفوف الكتير = فرص تقاطع أكتر.
  4. اكتب من أول قراية: FOR UPDATE بدل FOR SHARE لو هتعدّل.
  5. Batches صغيرة: UPDATE على مليون صف = Locks كتير لمدة طويلة. قسّمه لـ 1000 صف في كل ترانزاكشن.
  6. وأهم حاجة: Retry. الـ Deadlock والـ Serialization Failure مش Errors عادية، دول معناهم “حاول تاني”. الترانزاكشن اللي اتلغت ماعملتش أي أثر، فإعادتها من الأول آمنة. (في Laravel: DB::transaction($fn, attempts: 3)، في درس Laravel.)

رتّب الـ Locks، وقصّر الترانزاكشن، وحط Index، واعمل Retry. وأي Deadlock بيتكرر كتير في الـ Logs معناه إن فيه كود بيقفل بترتيب مختلف.

8. أسئلة الإنترفيو

فكّر في إجابتك الأول، وبعدين افتح السؤال.

01إيه شروط حدوث الـ Deadlock؟ وأنهي واحد أسهل تكسره في الأبلكيشن؟

شروط Coffman: Mutual Exclusion و Hold and Wait و No Preemption و Circular Wait. الأسهل في الأبلكيشن هو Circular Wait: تقفل كل الصفوف بترتيب ثابت (مثلاً بالـ id)، فمستحيل تتكوّن دايرة.

02إزاي ممكن يحصل Deadlock على صف واحد بس؟

Lock Upgrade: ترانزاكشنين ياخدوا Shared Lock على نفس الصف (FOR SHARE)، وبعدين الاتنين يحاولوا يكتبوا فمحتاجين X. كل واحد مستني الـ S بتاع التاني يتفك. الحل إنك تاخد FOR UPDATE من الأول لو ناوي تكتب.

03الفرق بين Wait-Die و Wound-Wait؟

الاتنين بيستخدموا عمر الترانزاكشن. في Wait-Die: القديمة تستنى الجديدة، والجديدة اللي عايزة حاجة مع قديمة تموت. في Wound-Wait: القديمة تجبر الجديدة الماسكة تعمل Abort، والجديدة تستنى القديمة. الاستنا في اتجاه واحد فمفيش دايرة، والميتة بتحتفظ بالـ Timestamp فمفيش Starvation.

04MySQL و PostgreSQL بيكشفوا الـ Deadlock إزاي؟ وبيختاروا الضحية إزاي؟

الاتنين بيدوّروا على Cycle في Wait-for Graph. InnoDB بيكشف فوراً مع كل استنا ويلغي الترانزاكشن الأرخص في الـ Rollback (أقل صفوف اتعدلت). PostgreSQL بيستنى deadlock_timeout (ثانية) قبل ما يدوّر، والضحية غالباً اللي عملت الكشف، ومفيش ضمان مين.

05حصل Deadlock. الأبلكيشن يعمل إيه؟

يعمل Retry للترانزاكشن كلها من الأول، مش للجملة اللي فشلت بس، لأن الـ ROLLBACK لغى كل حاجة بما فيها القرايات. ولازم مفيش side effects بره الداتابيز جوه الترانزاكشن، وعدد محاولات محدود مع backoff صغير.

06إيه اللي بيحصل بعد Lock wait timeout في MySQL؟

افتراضياً بيتلغي الـ statement بس والترانزاكشن تفضل مفتوحة بالجمل اللي قبل كده. لازم الكود يعمل ROLLBACK بنفسه، أو تفعّل innodb_rollback_on_timeout. والافتراضي 50 ثانية، وده كتير جداً لأي Web request.

علّم عليه لما تقدر تجاوب أسئلة الإنترفيو من غير ما تبص.