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

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

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

يمكنك متابعة السلسلة بالترتيب أو الانتقال مباشرة إلى أي مقال:


المقدمة

في مبدأ الـ Isolation - مستويات العزل من شرح مبدأ الـ 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 وكيفية التعامل مع البيانات

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

الـ Optimistic Lock

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

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

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

على عكس الـ Pessimistic Lock الذي يقوم بعمل Lock حقيقي على البيانات ويمنع أي Query أخرى من الوصول إليها

تطبيق بسيط على الـ 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 وكيف يمكنك تطبيقه بمثال بسيط
بالطبع يمكننا تجنب حدوث infinite Loop عن طريق وضع حد أقصى لعدد مرات المحاولة

وأيضًا كما تلاحظ فالـ Optimistic Lock يحل مشكلة الـ Lost Update بشكل فعال التي واجهتنا في المقالة السابقة
بدون الحاجة إلى عمل Lock حقيقي على البيانات
لأن الـ Optimistic Lock يعتمد على فكرة التحقق من الـ version أو الـ timestamp لكي يتأكد من عدم حدوث تعارض في البيانات
ويتأكد أن كل Query تقوم بقراءة أحدث نسخة من البيانات

الـ Pessimistic Lock

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

ولكن عرفنا أن التعامل مع الـ 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 توضح هذا

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 لتحقق منه بعد التعديل