انتقال للمقال

مبدأ الـ Isolation - أنواع الـ Locks

السلام عليكم ورحمة الله وبركاته

وقت القراءة: ≈ 10 دقائق

المقدمة


في الجزء الأول من شرح مبدأ الـ Isolation تعرفنا على مستويات العزل المختلفة والمشاكل الشائعة مثل الـ Dirty Read والـ Phantom Read والـ Non-Repeatable Read والـ Lost Update

في هذا الجزء سنتعمق في موضوع الـ Lock وأنواعه وكيفية تطبيقه عمليًا

الـ Lock وأنواعها

دعونا نتكلم قليلًا عن الـ Lock وما هي أنواعها
الـ Lock وهو كما يوحي الاسم يقوم بقفل أو تجميد أو تأمين البيانات التي تقوم
بقراءتها ويمنع أي Query أو Transaction أخرى من التعديل عليها
بالتالي لنفترض أن الـ Query الأولى تقوم بعمل تقرير عن نقاط اللاعبين في جدول
الـ Players
هنا ستقوم الـ Query بقفل جدول الـ Players ومنع أي Query أخرى من التعديل أو
الحذف أو الإضافة على هذا الجدول حتى تنتهي العملية

بالتالي ضمنا أن البيانات التي تقرأها الـ Query الأولى ستكون كما هي حتى تنتهي
العملية

وهناك Lock على أكثر من مستوى، يعني يمكنك قفل البيانات

  • على مستوى الـ Database كاملة
  • على مستوى الـ Table فقط
  • على مستوى الـ Row فقط
  • ... وهكذا

وهناك نوعين من طريقة عزل البيانات تندرج تحت مبدأ الـ Isolation وهما الـ
Pessimistic Lock والـ Optimistic Lock
وكلاهما يتبنيان نهجًا مختلفًا في كيفية تنفيذ الـ Lock وكيفية التعامل مع البيانات

الـ Optimistic Lock

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

وهذا النوع من الـ Lock مفضل عن شقيقة المتشائم الـ Pessimistic Lock لأنه لا
يقوم بقفل البيانات مباشرة

لكن لتطبيقه فهناك عدة طرق وأساليب يمكنك استخدامها لتطبيق الـ Optimistic Lock

منها أنها يقوم بإضافة حقل جديد في الجدول يسمى version أو timestamp
بناءًا عليه يتحقق هل حدث تغير أم لا أو هل حدث تعارض أم لا

تطبيق بسيط على الـ Optimistic Lock

فعلى سبيل مثال في مثال الطالب Ahmed الذي يملك 100 نقطة ونحتاج لإضافة 100
نقطة له و 200 نقطة له في نفس الوقت ليصبح لديه 400 نقطة
رأينا أن في الـ Pessimistic Lock قام بعمل Lock على الـ Database ومنع الـ
Query الثانية من التنفيذ

أما في طريقة الـ Optimistic Lock فقد يكون لدينا حقل جديد في الجدول يسمى
version

Players Table
+----+--------+-------+--------------+---------+
| id | name   | score | country_code | version |
+----+--------+-------+--------------+---------+
| 10 | Ahmed  | 100   | EG           | 1       |
+----+-------+--------+--------------+---------+

بالتالي بفرض أن كلا الدالتين التالية تقوم بتحديث نقاط اللاعب Ahmed في نفس
الوقت

public function updatePlayerScore()
{
        $player = Player::where('id', '=', 10)->first();
        $player->score += 100;

        sleep(3); // محاكاة للتأخير لأي سبب

        $player->save();
}

public function addBonusPoints()
{
        $player = Player::where('id', '=', 10)->first();
        $player->score += 200;

        sleep(3); // محاكاة للتأخير لأي سبب

        $player->save();
}

هنا بالطبع سنواجه نفس المشكلة أن هناك دالة ستقوم بعمل Override للتعديل الذي
قامت به الدالة الأولى
لحل هذه المشكلة بالـ Pessimistic Lock استخدمنا الـ Transaction لعمل Lock
وعمل Retry مرتين على الأقل

أما في الـ Optimistic Lock لدينا الآن حقل version لكل لاعب
يمكننا بعد كل تعديل في كلا الدالتين أن نقوم بزيادة الـ version بقيمة 1
ثم قبل أن تقوم كلا الدالتين بعمل COMMIT للبيانات يتم التحقق من أخر قيمة للـ
version في الـ Database هل مازلت كما هي أم لا
فإذا كانت كما هي فنحن أول من قام بالتعديل ونستطيع عمل COMMIT للبيانات بكل
أمان
أما إذا كانت قد تغيرت فهذا يعني أن هناك Query أخرى سبقتنا في التعديل وعلينا أن
نعيد العملية من جديد لنحصل على النسخة الأخيرة من البيانات

هذا ما يعرف بالـ Optimistic Lock

لنطبقه عمليًا

public function updatePlayerScore()
{
    while (true) {
        $player = Player::where('id', 10)->first();
        $currentVersion = $player->version;
        $newScore = $player->score + 100;

        // محاولة التحديث بشرط أن تكون النسخة لم تتغير
        $updated = Player::where('id', 10)
            ->where('version', $currentVersion) // تحقق أن الإصدار لم يتغير (أهم خطوة)
            ->update([
                'score' => $newScore,
                'version' => $currentVersion + 1,
            ]);

        if ($updated) {
            break; // الانتهاء من العملية عند نجاح التحديث
        }

        // إذا فشل التحديث، نحاول مرة أخرى
    }
}

ونفس الشيء للدالة الثانية

ماذا فعلنا هنا ؟

قمنا بعمل While Loop لمحاكاة عملية الـ Retry في الـ Pessimistic Lock لكن
بدون الـ Transaction والـ Lock
لأننا لا نريد عمل Lock بالمعنى الحرفي، نريد عمل فكرة الـ Optimistic Lock
ثم بعد ما حصلنا بيانات اللاعب Ahmed نقوم بحفظ الـ version الحالية له أو الـ
timestamp في حالة أنك تستخدم timestamp بدلًا من version
ثم بعد ذلك نقوم بالتعديل الذي نريده
لكن قبل أن نقوم بعمل update للبيانات لكن مع شرط
where('version', $currentVersion) لضمان أننا نحدث نفس ذات النسخة التي
قرأناها أول مرة

هنا الـ update سيعيد لنا Boolean إذا نجح التحديث أم لا
بالتالي إذا نجح التحديث فهذا يعني أن قيمة الـ version كما هي نفس قيمة الـ
version التي بدأنا بها

وهذا يعني أننا أول من قام بالتعديل ونستطيع تحديث البيانات بأمان والخروج من الـ
While Loop

أما إذا فشل التحديث فهذا يعني أن قيمة الـ version قد تغيرت بالتالي هذا
يعني أن هناك Query أخرى قامت بالتعديل قبلنا
لذا سنعيد الـ While Loop من جديد لنحصل على النسخة الأخيرة من البيانات ونعيد
الكرة

هذا مجرد مثال توضيحي بسيط للـ Optimistic Lock وكيف يمكنك تطبيقه بمثال بسيط

الـ Pessimistic Lock

وهو النوع الذي يفترض دائمًا أن هناك احتمالية كبيرة لحدوث تعارض في البيانات، لذلك
يقوم بقفل البيانات مباشرة قبل أي عملية
ويمنع أي Query أو Transaction أخرى من الوصول إليها باستخدام فكرة الـ Lock
كما رأينا في مثال الـ Lost Update
ولكن عرفنا أن التعامل مع الـ Lock يختلف من قاعدة بيانات لأخرى
ففي الـ SQLite تقوم بوضع Lock على الـ Database بشكل تلقائي
أما في الـ MySQL فعليك بوضع Lock على البيانات التي تقوم بتعديلها بشكل يدوي

هنا نحن سنتعرف على كيفية تطبيق الـ Pessimistic Lock في الـ MySQL عند استخدام
Laravel
وكيفية وضع Lock على البيانات التي تقوم بتعديلها

هناك نوعين مشهوران من الـ Pessimistic Lock وهما الـ Shared Lock والـ
Exclusive Lock

Exclusive Lock

وهو كما يوحي الاسم يقوم بقفل البيانات بشكل كامل على كل Query ما عدا الـ
Query التي فعلت هذا الـ Lock بمعنى أن لا حد يستطيع الوصول إلى البيانات ولا
يستطيع تعديلها أو حذفها أو إضافة بيانات جديدة إلا الـ Query التي فعلت الـ
Exclusive Lock

في Laravel يمكنك تطبيق الـ Exclusive Lock بسهولة .. في الحقيقة لدينا دالة
تدعى lockForUpdate()

وهي تقوم بوضع Exclusive Lock على البيانات التي تقوم بتعديلها

public function updatePlayerScore()
{
    DB::transaction(function () {
        $player = Player::where('id', '=', 10)->lockForUpdate()->first();
        $player->score += 100;

        sleep(3); // محاكاة للتأخير لأي سبب

        $player->save();
    });
}

public function addBonusPoints()
{
    DB::transaction(function () {
        $player = Player::where('id', '=', 10)->lockForUpdate()->first();
        $player->score += 200;

        sleep(3); // محاكاة للتأخير لأي سبب

        $player->save();
    });
}

لاحظ أننا استخدمنا الـ lockForUpdate() في كلا الدالتين
هكذا إذا تم تنفيذ الدالتين في نفس الوقت ستجد أن الدالة الأولى ستقوم بعمل
Exclusive Lock على البيانات
والدالة الثانية عندما تحاول تنفيذ الـ Query ستجد أن البيانات مقفلة ولا تستطيع
تنفيذ الـ Query لذا ستنتظر حتى تزال الـ Lock ولم ترمي أي Exception

ثم عندما تنتهي الدالة الأولى من تنفيذ الـ Query وتقوم بعمل COMMIT للبيانات
سيتم إزالة الـ Lock عن البيانات وتستطيع الدالة الثانية بدأ تنفيذ الـ Query
الخاصة بها

بالتالي النتيجة ستكون أن اللاعب Ahmed سيملك 400 نقطة بعد تنفيذ الدالتين
لاحظ هنا أنه لم يحدث أي Lost Update ولم يحدث أي Override للتعديل الذي قامت
به الدالة الأولى ولم يحدث أي Exception للدالة الثانية
لأن الـ Exclusive Lock قام بقفل البيانات ومنع أي Query أخرى من التنفيذ حتى
من القراءة

بهذا المعنى عندما بدأت الدالة الأولى بتنفيذ الـ Query قامت بقفل البيانات ومنعت
الدالة الثانية من قراءة البيانات
بالتالي على عكس الأمثلة السابقة الدالة الثانية لن تقرأم أي بيانات قديمة ولأنها
لم تقم بالقراءة من الأسا بسبب الـ Exclusive Lock

بالتالي إذا تتبعنا خطوات التنفيذ فسيكون كالتالي

  • الدالة الأولى تقوم بتنفيذ الـ Query وتقوم بعمل Exclusive Lock على البيانات
  • الدالة الثانية تحاول تنفيذ الـ Query وتجد أن البيانات مقفلة وتنتظر حتى تزال الـ Lock
  • الدالة الأولى تقرأ بيانات اللاعب Ahmed الذي يملك 100 نقطة وتقوم بإضافة 100 نقطة له
  • الدالة الأولى تقوم بعمل COMMIT للبيانات ويتم ازالة الـ Lock عن البيانات
  • الدالة الثانية تقوم بتنفيذ الـ Query وتقوم بعمل Exclusive Lock على البيانات
  • الدالة الثانية تقرأ بيانات اللاعب Ahmed والذي أصبح يملك 200 نقطة وتقوم بإضافة 200 نقطة له
  • الدالة الثانية تقوم بعمل COMMIT للبيانات ويتم ازالة الـ Lock عن البيانات
  • اللاعب Ahmed يملك 400 نقطة

هكذا تم حل مشكلة الـ Lost Update عن طريق الـ Exclusive Lock


انتبه هنا

إذا كانت إحدى الدالتين لا تستخدم الـ Exclusive Lock فستحدث مشكلة الـ
Lost Update
حيث الدالة التي لا تستخدم القفل بقراءة البيانات القديمة وإجراء تعديلات كما يحلو
لها مما يؤدي لعمل Override على البيانات وحدوث الـ Lost Update

لكن السؤال لماذا تحدث مشكلة Lost Update ؟ لو أحدى الدالتين لم تستخدم الـ
Exclusive Lock
.. حسنًا .. أنت تعرف أن لا شيء مثالي في هذا العالم

هذا المشكلة لم أجد لها سبب محدد بسبب قلة علمي

لكن بالنسبة للحلول فهناك ثلاث حلول لها:

استخدام الـ Exclusive Lock في كلا الدالتين
لذا تأكد أن جميع العمليات التي تحتاج إلى تعديل نفس البيانات وتشعر أنه قد يحدث
تعارض بينها فعليك استخدام الـ Exclusive Lock

**استخدام الـ Serializable في الـ Transaction**
لكن بالطبع هذا يعتبر حلًا مكلفًا لأنه يقوم يجعل الـ Transaction تعمل بشكل تسلسلي
وهذا يعني أنها ستعمل بشكل بطيء

**استخدام الـ Optimistic Lock**
الحل الأخير هو أن تقوم بتطبيق الـ Optimistic Lock كما شرحناه في الأمثلة
السابقة
وهذه من أحدى الأسباب لتفضيل الـ Optimistic Lock على الـ Pessimistic Lock
لأنه كما قلنا أن الـ Optimistic Lock لا يقوم بقفل البيانات ولا يتعب نفسه في
تنفيذ الـ Lock والـ Retry
بل يقوم بتنفيذ الـ Query بشكل عادي ويتحقق من الـ version أو الـ timestamp
بعد الانتهاء من التعديل

Shared Lock

هذا النوع من الـ Lock يقوم بقفل البيانات ويسمح للـ Query الأخرى بقراءتها
فقط
ولكن لا يسمح لها بتعديلها أو حذفها أو إضافة بيانات جديدة
بالتالي الـ Query التي تفعل الـ Shared Lock هي الوحيدة التي تستطيع تعديل
البيانات وحذفها وإضافة بيانات جديدة
أما أي Query أخرى فهي تستطيع فقط قراءة البيانات ولا تستطيع تعديلها أو حذفها أو
إضافة بيانات جديدة حتى يزال الـ Lock

في Laravel يمكنك تطبيق الـ Shared Lock باستخدام دالة تدعى sharedLock()

public function updatePlayerScore()
{
    DB::transaction(function () {
        $player = Player::where('id', '=', 10)->sharedLock()->first();
        $player->score += 100;

        sleep(3); // محاكاة للتأخير لأي سبب

        $player->save();
    });
}

public function addBonusPoints()
{
    DB::transaction(function () {
        $player = Player::where('id', '=', 10)->sharedLock()->first();
        $player->score += 200;

        sleep(3); // محاكاة للتأخير لأي سبب

        $player->save();
    });
}

لقد وضعنا الـ Shared Lock في كلا الدالتين

الآن بحسب خبرتك الطويلة في قراءة هذه المقالة التي لا أشعر أنها ستنتهي قريبًا
إذا تم تنفيذ الدالتين في نفس الوقت ماذا سيحدث ؟

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

  • الدالة الأولى تقوم بتنفيذ الـ Query وتقوم بعمل Shared Lock على البيانات
  • الدالة الأولى تستمر في تنفيذ الـ Query وتقرأ بيانات اللاعب Ahmed الذي يملك 100
  • الدالة الثانية تحاول تنفيذ الـ Query وتجد أن البيانات مقفلة من قبل الـ Shared Lock
  • الدالة الثانية تقرأ بيانات اللاعب Ahmed الذي يملك 100 لأن الـ Shared Lock تسمح بالقراءة فقط
  • كلا الدالتين تمتلكان نسخة من البيانات القديمة لللاعب Ahmed الذي يملك 100
  • الدالة الأولى بما أنها من فعلت الـ Shared Lock فهي الوحيدة التي تستطيع تعديل البيانات وحذفها وإضافة بيانات جديدة
  • الدالة الأولى تقوم بإضافة 100 نقطة لللاعب Ahmed وتقوم بعمل COMMIT للبيانات ويتم ازالة الـ Lock
  • الدالة الثانية تحاول تعديل البيانات ... لكن لا تستطيع لأنها غير مسموح لها بذلك لأنها قرأت بيانات كان عليها الـ Shared Lock بالتالي لا تستطيع تعديلها
  • الدالة الثانية تقوم برمي Exception أو تقوم بعمل Retry للعملية

أظننا واجهنا مثال مشابه لما حدث في الأمثلة السابقة
على أي حال الـ Exception التي حصلت عليها الدالة الثانية هي بسبب أنها لم تستطع
تعديل البيانات لأنها قرأت بيانات كان عليها الـ Shared Lock
ورسالة الـ Exception توضح هذا

Illuminate\Database\QueryException  SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction (Connection: mysql, SQL: update `players` set `score` = 300, `players`.`updated_at` = 2025-02-07 19:18:32 where `id` = 10).

لاحظ أنه يقول لك try restarting transaction أي حاول تنفيذ الـ Transaction من
جديد
وهو فكرة الـ Retry التي تحدثنا عنها في الأمثلة السابقة
فهنا نستطيع فعل نفس الشيء باخبار الـ Transaction بأنه يجب عليك تنفيذ وتكرار
العملية عدد مرات معينة نحن نحددها حتى تنجح العملية

مراجعة على أهم النقاط في مبدأ الـ Isolation

  • مبدأ الـ Isolation يقوم بعزل الـ Transaction عن أي تغييرات تحدث في البيانات
  • يقوم بمنع أي Query أو Transaction أخرى من الوصول إلى البيانات التي تقوم بتعديلها
  • يوجد عدة مستويات للعزل منها
    • Read Uncommitted: يسمح لأي Transaction بقراءة البيانات حتى لو لم يتم عمل COMMIT للبيانات
    • Read Committed: يمنع أي Transaction من قراءة البيانات حتى يتم عمل COMMIT للبيانات
    • Repeatable Read: يعزل الـ Transaction عن أي تغييرات خارجية تحدث في البيانات
    • Serializable: ينفذ الـ Transaction بشكل تسلسلي ويمنع أي Transaction من التداخل مع الـ Transaction الأخرى
    • Snapshot: يقوم بعمل نسخة زمنية من البيانات ويعمل على قراءة هذه النسخة وليس البيانات الحالية
  • يوجد مشاكل تحدث في حالة عدم تطبيق مبدأ الـ Isolation مثل
    • Dirty Read: القدرة على قراءة البيانات قبل عمل COMMIT لها
    • Non-Repeatable Read
    • Phantom Read
    • Lost Update
  • يمكنك تطبيق مبدأ الـ Isolation بعدة طرق منها
    • Pessimistic Lock
      • له نوعان مهمين وهما
        • Exclusive Lock
        • Shared Lock
    • Optimistic Lock
      • يقوم على فكرة وجود حقل version أو timestamp لتحقق منه بعد التعديل