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

مبدأ الـ Isolation - مستويات العزل

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

وقت القراءة: ≈ 20 دقيقة (بمعدل فنجان واحد من القهوة 😊)

المقدمة


مبدأ الـ Isolation يضمن أن كل Transaction تعمل بشكل منعزل عن باقي الـ
Transactions الأخرى
بمعنى أن كل Transaction لا تتداخل مع الأخرى ولا تؤثر عليها ولا تتأثر بها
فمثلًا إذا كان هناك Transaction تقوم بقراءة البيانات في هذه اللحظة وهناك
Transaction أخرى تقوم بتعديل البيانات في نفس الوقت
فيجب على الـ Transaction الأولى أن تقرأ البيانات كما هي في اللحظة التي قرأتها
حتى لو قامت الـ Transaction الثانية بتعديلها فإن الـ Transaction الأولى لن
ترى هذه التعديلات حتى تنتهي من القراءة

مثال على مشكلة عدم العزل

لنفترض أن لدينا البيانات التالية

Players Table
+----+--------+-------+--------------+
| id | name   | score | country_code |
+----+--------+-------+--------------+
| 10 | Ahmed  | 100   | EG           |
| 20 | Ali    | 200   | EG           |
| 30 | Yehia  | 300   | PS           |
| 40 | Omar   | 400   | EG           |
| 50 | Ismail | 500   | PS           |
+----+-------+--------+--------------+

لنفترض أن لدينا مجموعة من اللاعبين وكل لاعب لديه مجموعة من النقاط
ونريد أن نقوم بعمل عملتين متتاليتين في نفس الوقت

الأولى أن نحسب أعلى لاعب من حيث النقاط

SELECT *
FROM players
ORDER BY score DESC
LIMIT 1;

الثانية أن نحضر أعلى 3 لاعبين في مصر من حيث النقاط

SELECT *
FROM players
WHERE country_code = 'EG'
ORDER BY score DESC
LIMIT 3;

لنفترض أننا قمنا بتنفيذ العمليتين في نفس الوقت بشكل متتالي لنقوم بعمل تقرير ما
عن اللاعبين
السؤال هنا .. هل يمكن أن نواجه مشكلة ؟

الإجابة هي نعم

لأننا نقوم بعمل خطوتين في نفس الوقت
الأولى أن نحسب أعلى لاعب من حيث النقاط والثانية أن نحضر أعلى 3 لاعبين في مصر
من حيث النقاط

أين المشكلة ؟

المشكلة أنه قد يكون هناك Query أخرى تعمل على نفس البيانات في نفس الوقت
تقوم بإضافة نقاط جديدة للاعب 10 وتقوم إنقاص نقاط اللاعب 20 وتقوم بحذف اللاعب
30 .. وهكذا

بالتالي أثناء ما الـ Query الأولى تقوم بخطوتها الأولى باحضار أعلى لاعب من حيث
النقاط وهو اللاعب Ismael بـ 500 نقطة
قد تأتي الـ Query الثانية في نفس اللحظة تجعل نقاط اللاعب Omar تصبح 1000
نقطة وتجعله أعلى لاعب
هكذا عندما تريد عمل Query تحضر لك أعلى 3 لاعبين في مصر ستجد أن اللاعب Omar
هو الأعلى بـ 1000 نقطة

النتيجة النهائية للـ Query الأولى ستكون كالتالي

Top Player
+----+--------+-------+--------------+
| id | name   | score | country_code |
+----+--------+-------+--------------+
| 50 | Ismail | 500   | PS           |
+----+-------+--------+--------------+

Top 3 Players in Egypt
+----+--------+-------+--------------+
| id | name   | score | country_code |
+----+--------+-------+--------------+
| 40 | Omar   | 1000  | EG           |
| 10 | Ahmed  | 100   | EG           |
| 20 | Ali    | 200   | EG           |
+----+-------+--------+--------------+

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

وهنا يأتي دور مبدأ الـ Isolation لحل هذه المشكلة
فمبدأ الـ Isolation يضمن لك أن كل Query تعمل بشكل منفصل عن الأخرى ولا تتداخل
معها ولا تؤثر عليها
عن طريق تقديم حلول لحل هذه المشكلة والمشاكل المشابهة، ومن ضمن هذه الحلول مفهوم
الـ Lock

مستويات العزل الخاصة بالـ Isolation

هناك مستويات مختلفة للـ Isolation وهي تعبر عن مدى العزل بين الـ Transactions
ومدى تأثير كل Transaction على الأخرى

هناك 5 مستويات رئيسية للـ Isolation وهي

Read Uncommitted

هذا المستوى يسمح بقراءة البيانات التي لم تتم تأكيدها بعد أي لم تتم COMMIT
لها بعد
هذا قد يؤدي إلى مشاكل مثل مشكلة الـ Dirty Read

Read Committed

هذا المستوى يسمح بقراءة البيانات التي تم تأكيدها فقط أي تم COMMIT لها
ولا يسمح بقراءة البيانات التي لم تتم تأكيدها بعد
يعد هذا المستوى هو المستوى الافتراضي لمعظم قواعد البيانات

Repeatable Read

هذا المستوى يعزل الـ Transaction عن العالم الخارجي وتجعلها لا تتأثر بأي
تعديلات تحدث على البيانات خارج الـ Transaction
يعد نقيض مشكلة تسمى Non-Repeatable Read

Serializable

فكرة المستوى هذا هو أنه يجعل الـ Transaction تعمل بشكل متسلسل وتتنفذ بشكل
تتابعي ولا تتداخل مع الـ Transactions الأخرى
بالتالي يضمن لك أن كل Transaction لا تنفذ في نفس الوقت مع الأخرى ولا
تتداخل معها
يعد أفضل مستوى للعزل وأكثرها تكلفة وأبطأها لأنه يجعل الـ Transaction تعمل
بشكل متسلسل

Snapshot

هذا المستوى يعتمد على فكرة أنه يقوم بعمل Snapshot للبيانات أي نسخة شاملة
للبيانات في اللحظة التي تبدأ فيها الـ Transaction
بالتالي لا تتأثر الـ Transaction بأي تعديلات تحدث على البيانات خارج الـ
Transaction
غالبًا ما يستخدم الـ timestamp لعمل Snapshot للبيانات ومعرفة شكل البيانات
في اللحظة التي بدأت فيها الـ Transaction عن طريق الـ timestamp
ومثل الـ Serializable يعد هذا المستوى من أفضل المستويات للعزل وأكثرها
تكلفة وأبطأها


معظم قواعد البيانات تستخدم الـ Read Committed أو الـ Repeatable Read
كمستويات افتراضية
لكن بالطبع يمكنك تغييرها واختيار مستوى الـ Isolation الذي تريده حسب الحاجة

المشاكل الشائعة في الـ Isolation

Dirty Read

هذه المشكلة تحدث عندما تقوم Query بقراءة بيانات معينة
وفي نفس الوقت هناك Query أخرى في نفس الوقت قامت بعمل تعديل على هذه البيانات
ولكن لم تقم بعمل COMMIT لها بعد
بالتالي عندما تقوم Query الأولى بقراءة هذه البيانات ستجدها قد تغيرت ولم تعد
كما كانت
لأنها قرأت بيانات قد تم تعديلها ولم تقم بعمل COMMIT لها بعد

مثًلا يوجد Query أضافت بعض البيانات ولكن لم تقم بعمل COMMIT لها
ثم جائت Query أخرى وقامت بقراءة هذه البيانات، في هذه اللحظة هذه القراءة تسمى
Dirty Read لأن هذه البيانات لم تتم تأكيدها بعد
ويمكن في أي لحظة لا يتم عمل COMMIT لها وتتم عملية ROLLBACK وبالتالي البيانات
التي قرأتها لم تعد لها وجود وكأنها لم تكن

بالتالي الـ Dirty Read تحدث عندما تقوم بقراءة بيانات قد تم تعديلها ولم تقم
بعمل COMMIT لها بعد

وقد تتم عمل تعديلات أخرى عليها أو عمل ROLLBACK لها

مثال عملي على Dirty Read

ملحوظة: هذه المشكلة تحدث عندما تكون مستوى الـ Isolation هو Read Uncommitted
وجميع قواعد البيانات لا تجعل هذا المستوى هو المستوى الافتراضي للـ Isolation
لذا لن تقابل هذه المشكلة في الحالات العادية
في هذا المثال سنفترض أن مستوى الـ Isolation هو Read Uncommitted لغرض التوضيح فقط

لذا لسبب ما سنفترض أنك جعلت مستوى الـ Isolation هو Read Uncommitted

DB::statement('SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED');

لكن أؤكد لك أن هذا ليس تصرفًا حكيمًا


لنفترض أنك تريد عمل تقرير يومي يحتوي على:

  • عرض اسم أعلى لاعب من حيث النقاط
  • عرض أعلى ثلاث لاعبين من حيث النقاط في مصر فقط

وهذا التقرير يعمل بشكل تلقائي كل يوم في الساعة 12 ظهرًا على سبيل المثال

public function getTopPlayers()
{
    $date = now()->format('Y-m-d H:i:s');
    $topPlayer = Player::orderBy('score', 'desc')->first();

    sleep(3); // Wait for 3 seconds for any reason
    // Any other logic here
    // i am a nice logic ...

    $topPlayersInEgypt = Player::query()
        ->where('country_code', 'EG')
        ->orderBy('score', 'desc')
        ->take(3)
        ->get();


    $report = [
        'name' => 'Top Players Report',
        'date' => $date,
        'description' => 'This report shows the top players in the game',
        'data' => [
            'top_player' => $topPlayer,
            'top_players_in_egypt' => $topPlayersInEgypt,
        ],
    ];

    NotificationService::sendReport($report);
}

حسنًا هذه الدالة تقوم بعمل عدة Query لتقوم بعرض أعلى لاعب من حيث النقاط وتعرض
أعلى ثلاث لاعبين من حيث النقاط في مصر فقط
وقمت بعمل sleep لثلاث ثواني لأي سبب ما لمجرد محاكاة الوقت لأي سبب
ثم نرسل التقرير إلى أي مكان بواسطة NotificationService

  • أول خطوة يقوم بقراءة جدول الـ Scores ويجلب أعلى لاعب من حيث النقاط
    النتيجة:
    "top_player": {
        "id": 50,
        "name": "Ismail",
        "score": 500
    }
    
  • ثاني خطوة يقوم بقراءة جدول الـ Scores ويجلب أعلى ثلاث لاعبين من حيث النقاط في مصر فقط
    النتيجة:
    "top_players_in_egypt": [
        {
            "id": 40,
            "name": "Omar",
            "score": 400
        },
        {
            "id": 20,
            "name": "Ali",
            "score": 200
        },
        {
            "id": 10,
            "name": "Ahmed",
            "score": 100
        }
    ]
    

لنفترض أن هناك Query أخرى تم تنفيذها بشكل متزامن أو موازي وقام بإضافة نقاط
جديدة للاعب 10 ولم يقم بعمل COMMIT لها بعد
ولنتخيل أنه جعل نقاط اللاعب Omar تساوي 1000 نقطة

public function updatePlayerScore()
{
    DB::beginTransaction();

    $player = Player::where('name', '=', 'Omar')->update(['score' => 1000]);

    // Any other logic here
    // i am a nice logic ...
    // logic logic logic ...

    DB::commit();
}

تخيل أن الدالة updatePlayerScore تم تنفيذها بشكل متزامن مع الدالة
getTopPlayers كل دالة أو كل Query نفذت في Request مختلف في نفس الوقت
الدالة updatePlayerScore قامت بتحديث نقاط اللاعب Omar وجعلته 1000 نقطة
ولنفترض أنها قامت بهذا التعديل بعد ما الدالة الأولى getTopPlayers نفذت الخطوة
الأولى وأحضرت لاعب من حيث النقاط ولكنها لم تنتهي بعد

بالتالي ترتيب الخطوات كان كالتالي:

  1. الدالة الأولى قامت بقراءة البيانات وجلب أعلى لاعب من حيث النقاط وكان اللاعب Ismail بـ 500 نقطة
  2. فجأة الدالة الثانية بشكل متزامن قامت بتحديث نقاط للاعب Omar وجعلته 1000 نقطة
  3. الدالة الأولى قامت بقراءة البيانات وجلب أعلى ثلاث لاعبين من حيث النقاط في مصر
    وهم Omar بـ 1000 نقطة و Ali بـ 200 نقطة و Ahmed بـ 100 نقطة

بالتالي النتيجة النهائية للدالة الأولى ستكون كالتالي

{
  "name": "Top Players Report",
  "date": "2025-01-31 12:00:00",
  "description": "This report shows the top players in the game",
  "data": {
    "top_player": {
      "id": 50,
      "name": "Ismail",
      "score": 500
    },
    "top_players_in_egypt": [
      {
        "id": 40,
        "name": "Omar",
        "score": 1000
      },
      {
        "id": 20,
        "name": "Ali",
        "score": 200
      },
      {
        "id": 10,
        "name": "Ahmed",
        "score": 100
      }
    ]
  }
}

هل هذه النتيجة صحيحة ؟
الإجابة هي لا

لاحظ أننا قلنا أن أعلى لاعب من حيث النقاط هو Ismail بـ 500 نقطة
ولكن عندما تنظر إلى أعلى ثلاث لاعبين من حيث النقاط في مصر فقط وجدت أن Omar هو
الأعلى بـ 1000 نقطة
فلماذا لم يتم اعتبار Omar هو الأعلى في النقاط بدلًا من Ismail ؟

لأنك إن تتبعت ترتيب الخطوات وسجلت تاريخ تنفيذ كل خطوة ستجد التالي
أولًا لنفترض أن الساعة الآن 12:00:00

  1. الدالة الأولى getTopPlayers قامت بتنفيذ الـ Query الأولى وأحضرت لاعب من حيث النقاط وكان اللاعب Ismail بـ 500 نقطة
    الساعة كان 12:00:00
  2. الدالة الثانية updatePlayerScore قامت بتحديث نقاط لاعب Omar وجعلته 1000 نقطة
    الساعة كانت 12:00:01
  3. الدالة الأولى getTopPlayers أكملت وقامت بتنفيذ الـ Query الثانية وأحضرت ثلاث لاعبين من حيث النقاط في مصر
    وهم Omar بـ 1000 نقطة و Ali بـ 200 نقطة و Ahmed بـ 100 نقطة
    الساعة كانت 12:00:02

بالتالي النتيجة النهائية للدالة الأولى getTopPlayers كانت كالتالي
أن Ismail هو الأعلى بـ 500 نقطة برغم بأن Omar بـ 1000 نقطة

بالتالي النتيجة النهائية خاطئة وغير منطقية

هذه المشكلة تسمى Dirty Read وحدثت بسبب أن الدالة الأولى قامت بقراءة البيانات
في لحظة معينة
وهذه البيانات تم تعديلها من قبل الدالة الثانية في نفس اللحظة

وحتى لو قامت الدالة الثانية بعمل COMMIT لها فإن الدالة الأولى قرأت البيانات
الأولى الخاصة بالـ top_player قبل الـ COMMIT
وقرأت البيانات الثانية الخاصة بالـ top_players_in_egypt بعد الـ COMMIT
فهنا ظهرت المشكلة والتناقض

وأيضًا تخيل معي أن البيانات التي تم تغيرها في الدالة updatePlayerScore تم
التراجع عنها أي تم عمل ROLLBACK لها

بالتالي البيانات التي قرأتها الدالة الأولى لم تعد لها وجود وكأنها لم تكن
بالتالي التقرير الذي أرسلته الدالة الأولى يحتوي على بيانات غير صحيحة ولم تعد
لها وجود
بعد الـ ROLLBACK

هكذا الدالة الأولى قرأت بيانات معينة في لحظة احضار الـ top_player
ثم قامت الدالة الثانية بتعديل هذه البيانات ولم تقم بعمل COMMIT لها بعد
ثم قامت الدالة الأولى بقراءة البيانات مرة أخرى لاحضار الـ
top_players_in_egypt
بالتالي النتيجة النهائية للدالة الأولى ستكون خاطئة وغير صحيحة
وفوق هذا الدالة الثانية قامت بعمل ROLLBACK للبيانات التي قامت بتعديلها
بالتالي البيانات التي قرأتها الدالة الأولى لم تعد لها وجود وكأنها لم تكن

وهكذا من الأمور فالـ Dirty Read تحدث عندما تقوم بقراءة بيانات قد تم تعديلها
ولم تقم بعمل COMMIT لها بعد
بالتالي هذه البيانات ليست مستقرة بعد وقد تتغير في أي لحظة فمثلًا يتم التعديل
عليها مرة أخرى أو يتم عمل ROLLBACK لها

حل مشكلة Dirty Read

لحل هذه المشكلة يجب عليناأولًا وضع الدالة داخل Transaction لجعل الدالة
تتبنى مبدأ الـ Isolation

public function getTopPlayers()
{
    DB::transaction(function () {
        $date = now()->format('Y-m-d H:i:s');
        $topPlayer = Player::orderBy('score', 'desc')->first();

        sleep(3); // Wait for 3 seconds for any reason
        // Any other logic here
        // i am a nice logic ...

        $topPlayersInEgypt = Player::query()
            ->where('country_code', 'EG')
            ->orderBy('score', 'desc')
            ->take(3)
            ->get();


        $report = [
            'name' => 'Top Players Report',
            'date' => $date,
            'description' => 'This report shows the top players in the game',
            'data' => [
                'top_player' => $topPlayer,
                'top_players_in_egypt' => $topPlayersInEgypt,
            ],
        ];

        NotificationService::sendReport($report);
    });
}

الخطوة الثانية هي تغيير مستوى الـ Isolation إلى Repeatable Read
لأن هذه المشكلة تحدث عندما تكون مستوى الـ Isolation هو Read Uncommitted
وهو ليس التصرف الافتراضي لقواعد البيانات وغالبًا من غير المنطقي أن تكون مستوى الـ
Isolation هو Read Uncommitted
وإلا فإنك ستواجه مشاكل كثيرة ومعظم فوائد الـ Transaction ستذهب
هباءًا

لذا يجب علينا تغيير مستوى الـ Isolation إلى Repeatable Read

DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');

هنا بوضع الدالة داخل Transaction وتغيير مستوى الـ Isolation إلى
Repeatable Read
ضمننا أن البيانات التي قرأتها الدالة ستكون كما هي حتى تنتهي العملية
والنتجية ستكون الآن سليمة وصحيحة

{
  "name": "Top Players Report",
  "date": "2025-01-31 12:00:00",
  "description": "This report shows the top players in the game",
  "data": {
    "top_player": {
      "id": 50,
      "name": "Ismail",
      "score": 500
    },
    "top_players_in_egypt": [
      {
        "id": 40,
        "name": "Omar",
        "score": 400
      },
      {
        "id": 20,
        "name": "Ali",
        "score": 200
      },
      {
        "id": 10,
        "name": "Ahmed",
        "score": 100
      }
    ]
  }
}

وبالطبع كما قلنا في Laravel يمكنك استخدام DB::transaction أو استخدام الـ
DB::beginTransaction والـ DB::commit والـ DB::rollBack

أظننا وضحنا كيف أن الـ Transaction بطبيعتها تحقق الأربع مبادئ الـ ACID ومن
ضمنها مبدأ الـ Isolation
لذا بوضع الدالة داخل Transaction هكذا ضمننا أن البيانات التي قرأتها الدالة
ستكون كما هي حتى تنتهي العملية
بالتالي أي تغيير أو تعديل أو حذف من قبل أي Transaction أخرى لن تظهر للـ
Transaction الحالية

كأن الـ Transaction لديها نسختها الخاصة من البيانات في اللحظة التي بدأت فيها
العملية .. أو كأن الزمن توقف في الـ Transaction الحالية


ملحوظة: بالطبع هذا مثال بسيط ومحاكاة للمشكلة فقط
وكما قلنا أن هذه المشكلة تحدث عندما تكون مستوى الـ Isolation هو Read Uncommitted
لكن برغم ذلك المثال الذي عرضته لك حتى لو لم تكون مستوى الـ Isolation هو Read Uncommitted
فطبيعة المثال الذي عرضته هو مشكلة أخرى تسمعى Non-Repeatable Read وتشبه الـ Dirty Read لكن في حالة أننا نقرأ البيانات التي تم عمل لها COMMIT لها
وسنشرحه بالتفصيل لاحقًا

Phantom Read

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

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

فمثلًا قد تقوم Query بقراءة اللاعبين الذين لديهم نقاط أكبر من 200
في المرة الأولى قد تجد 3 لاعبين
ثم عندما تقرر عمل تقرير عنهم وتحاول تنفيذ الـ Query مرة أخرى
تجدهم قد أصبحوا 4 لاعبين أو 5 لاعبين وهكذا

مثال عملي على Phantom Read

لدينا نفس الجدول السابق

Players Table
+----+--------+-------+--------------+
| id | name   | score | country_code |
+----+--------+-------+--------------+
| 10 | Ahmed  | 100   | EG           |
| 20 | Ali    | 200   | EG           |
| 30 | Yehia  | 300   | PS           |
| 40 | Omar   | 400   | EG           |
| 50 | Ismail | 500   | PS           |
+----+-------+--------+--------------+

لنفترض أنك الآن تريد إحضار اللاعبين الذين لديهم نقاط أكبر من 200 نقطة

SELECT *
FROM players
WHERE score > 200;

النتيجة بالطبع ستكون

+----+--------+-------+--------------+
| id | name   | score | country_code |
+----+--------+-------+--------------+
| 30 | Yehia  | 300   | PS           |
| 40 | Omar   | 400   | EG           |
| 50 | Ismail | 500   | PS           |
+----+-------+--------+--------------+

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

+----+--------+-------+--------------+
| id | name   | score | country_code |
+----+--------+-------+--------------+
| 30 | Yehia  | 300   | PS           |
| 40 | Omar   | 400   | EG           |
| 50 | Ismail | 500   | PS           |
| 60 | Tamer  | 250   | EG           |
+----+-------+--------+--------------+

الشخص الذي يدعى Tamer الذي ظهر من العدم بعد أن قمت بتنفيذ الـ Query مرة أخرى
هو ما يسمى بـ Phantom Read
لأنه ظهر من العدم وكأنه شبح بعد ما قمت بتنفيذ الـ Query مرتين بشكل متتالي

لنر تطبيق عملي لهذه المشكلة ككود،

public function getHighScoringPlayers()
{
    $players = Player::where('score', '>', 200)->get();

    sleep(3); // Wait for 3 seconds for any reason
    // Any other logic here
    // i am a nice logic ...

    $players = Player::where('score', '>', 200)->get();
}

هنا قمنا بعمل Query لجلب اللاعبين الذين لديهم نقاط أكبر من 200
ثم قمنا بعمل sleep لثلاث ثواني ثم قمنا بعمل نفس الـ Query مرة أخرى لأي سبب
كان في نفس الدالة

ولنفترض أن هناك Query أخرى تم تنفيذها في نفس اللحظة وقامت بإضافة لاعب جديد
يدعى Tamer ولديه 250 نقطة

public function addPlayer()
{
    DB::beginTransaction();

    Player::create([
        'name' => 'Tamer',
        'score' => 250,
        'country_code' => 'EG',
    ]);

    DB::commit();
}

ستلاحظ أن الدالة addPlayer قامت بإضافة لاعب جديد يدعى Tamer ولديه 250
نقطة
وقامت بعمل COMMIT لها بالفعل

بالتالي عندما تكمل الدالة getHighScoringPlayers عملها وتنفذ الـ Query مرة
أخرى ستجد أن هناك لاعب جديد ظهر من العدم

لأن الترتيب كان كالتالي

  1. الدالة getHighScoringPlayers قامت بقراءة اللاعبين الذين لديهم نقاط أكبر من 200
    وأحضرت بين Yehia و Omar و Ismail
  2. الدالة addPlayer قامت بإضافة لاعب جديد يدعى Tamer ولديه 250 نقطة
  3. الدالة getHighScoringPlayers أكملت التنفيذ وقامت مرة أخرى بقراءة اللاعبين الذين لديهم نقاط أكبر من 200
    وأحضرت بين Yehia و Omar و Ismail و Tamer

في المرة الأولى لم يكن هناك لاعب يدعى Tamer ولكن في المرة الثانية ظهر Tamer
من العدم
وهذا شيء هو ما يسمى بـ Phantom Read

حل مشكلة Phantom Read

لكننا نريد أن تكون الدالة getHighScoringPlayers مستقلة ومنعزلة عن أي تغييرات
تحدث في البيانات
بمعنى من لحظة تنفيذ الدالة لنهايتها يجب أن تكون الـ Database كما هي ولا تتغير

وهنا الحل سيكون مثل ما فعلنا في الـ Dirty Read بوضع الدالة داخل Transaction
بكل بساطة

public function getHighScoringPlayers()
{
    DB::transaction(function () {
        $players = Player::where('score', '>', 200)->get();

        sleep(3); // Wait for 3 seconds for any reason
        // Any other logic here
        // i am a nice logic ...

        $players = Player::where('score', '>', 200)->get();
    });
}

بوضع الدالة داخل Transaction ضمننا أن البيانات التي قرأتها الدالة ستكون كما هي
حتى تنتهي العملية
وبالتالي لن تظهر لك أي تغييرات تحدث في البيانات من قبل أي Query أخرى

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

Non-Repeatable Read

الـ Non-Repeatable Read هو أنك تحاول قراءة البيانات مرتين في نفس الوقت وكل مرة
حصلت على نتيجة مختلفة
لأن أي بيانات قمت بقرأتها قد تتغير في أي لحظة
بالتالي إذا قرأتها مرة أخرى قد تجدها تغيرت وليست كما هي عندما قرأتها في البداية
لأنه دائمًا يوجد احتمال أن هناك Query أخرى تم تنفيذها وقامت بتعديل البيانات

هنا نحن لا نتكلم عن الـ Phantom Read حيث أن هناك بيانات تظهر من العدم
ولا نتحدث عن الـ Dirty Read حيث أن البيانات قد تكون غير مستقرة وقد تتغير في أي
لحظة سواء أنها لم تتم عمل COMMIT لها أو تم عمل ROLLBACK لها

نحن نتحدث في الـ Non-Repeatable Read عن بيانات محددة وثابتة أنت قرأتها فمثلًا
WHERE id = 10
هنا أنت تجلب بيانات شخص معين ولكن بيانات هذا الشخص عندما قمت بقراءتها مرتين في
نفس الوقت تجدها تغيرت
وهذا يعني أن في نفس ذات اللحظة قد تم التعديل عن نفس ذات الشخص من قبل Query
أخرى

مثال عملي على Non-Repeatable Read

لنفترض أنك تريد عرض نقاط اللاعب صاحب الـ id رقم 10

public function getPlayerScore()
{
    $player = Player::find(10);

    sleep(3); // Wait for 3 seconds for any reason
    // Any other logic here
    // i am a nice logic ...

    $player = Player::find(10);
}

ولاحظ أننا قمنا بقراءة نقاط اللاعب 10 مرتين في نفس الوقت لسبب ما

ولنتخيل أننا لدينا دالة أخرى تقوم بتحديث نقاط نفس اللاعب صاحب الـ id رقم 10
في نفس الوقت

public function updatePlayerScore()
{
    DB::beginTransaction();

    Player::find(10)->update(['score' => 200]);

    DB::commit();
}

الدالة updatePlayerScore قمنا بتحديث نقاط اللاعب 10 وجعلناها 200 نقطة
وقمنا بعمل COMMIT لها
وتم تنفيذ الدالة في نفس الوقت الذي تم فيه تنفيذ الدالة getPlayerScore

بالتالي ستجد أن النتيجة النهائية للدالة getPlayerScore ستكون كالتالي
في القراءة الأولى ستجد أن نقاط اللاعب 10 هي 100
وفي القراءة الثانية ستجد أن نقاط اللاعب 10 هي 200

لاحظ أن بيانات اللاعب 10 تغيرت في نفس الدالة فجأة بسبب الدالة الأخرى التي قامت
بتحديث البيانات في نفس الوقت
لأنه بالطبع عندما قامت الدالة الأولى بقراءة البيانات كانت النقاط 100
ثم في نفس الوقت قامت الدالة الثانية بتحديث النقاط إلى 200
فعندما قامت الدالة الأولى بقراءة البيانات مرة أخرى وجدت أن النقاط تغيرت إلى
200

وهذا ما يسمى بـ Non-Repeatable Read
بمعنى أن البيانات التي قرأتها قد تتغير في أي لحظة وليست ثابتة برغم أنك قمت
بقراءتها مرتين في نفس الوقت في نفس الدالة
بسبب أن في ذات هذه اللحظة قد تم تعديل البيانات من قبل Query أخرى

حل مشكلة Non-Repeatable Read

وهنا الحل كالعادة سيكون بوضع الدالة داخل Transaction لأنها تحقق مبدأ الـ
Isolation
وتعزل الدالة عن أي تغييرات تحدث في البيانات

public function getPlayerScore()
{
    DB::transaction(function () {
        $player = Player::find(10);

        sleep(3); // Wait for 3 seconds for any reason
        // Any other logic here
        // i am a nice logic ...

        $player = Player::find(10); // same data
    });
}

لكن بشرط أن يكون مستوى الـ Isolation هو Repeatable Read لأنه يمنع الـ
Non-Repeatable Read
أما إذا كان مستوى الـ Isolation هو Read Committed فإنك ستواجه مشكلة الـ
Non-Repeatable Read حتى داخل Transaction

والسبب هو أن الـ Read Committed يجعل الـ Transaction تقرأ البيانات التي تم
تعديلها وتم عمل COMMIT لها من قبل Transaction أخرى
لذا حتى داخل Transaction قد تواجه مشكلة الـ Non-Repeatable Read لو كان مستوى
الـ Isolation هو Read Committed

Repeatable Read

هذا المستوى يحل مشكلة الـ Non-Repeatable Read
عندما تكون مستوى الـ Isolation هو Repeatable Read فإنك تضمن أن البيانات التي
قرأتها ستكون كما هي حتى تنتهي العملية

فهو بكل بساطة يقوم بتخزين البيانات التي قرأتها في البداية على مستوى الـ
Transaction
بالتالي عندما تقرأ البيانات مرة أخرى ستجد أنها لم تتغير وهي كما قرأتها في
البداية
وحتى لو تم تعديل البيانات من قبل Transaction أخرى فلن تظهر لك هذه التغييرات
لأن البيانات تم تخزين نسخة منها على مستوى الـ Transaction عندما قمت بقراءتها
لأول مرة

بالتالي في وضع الـ Repeatable Read الـ Transaction كأنها في كبسولة زمنية
خاصة بها لا تعرف أي شيء عن العالم الخارجي
ولا تتأثر بأي تغييرات تحدث في البيانات من قبل Transaction أخرى

Lost Update

هذه المشكلة تحدث عندما يكون لدينا Query تعدل قيمة معينة في البيانات
وتوجد Query أخرى تعدل نفس القيمة في البيانات

بالتالي أحدى الـ Query ستفقد التعديل الذي قامت به والـ Query الأخرى ستقوم
بعمل Override للتعديل الذي قامت به الـ Query الأولى
هكذا فقدنا التعديل الذي قامت به الـ Query الأولى وكأنه لم يحدث

فعلى سبيل المثال لنفترض أن لدينا لاعب يدعى Ahmed ولديه 100 نقطة
قام بكسب 100 نقطة في لعبة ما
وفي نفس الوقت حصل على نقاط اضافية لتفوقه على اللاعبين الآخرين وحصل على 200
نقطة

هكذا لدينا Query تقوم بتحديث نقاط وتضيف 100 نقطة لـ Ahmed
وفي نفس الوقت لدينا Query أخرى تقوم بتحديث نقاط وتضيف 200 نقطة لـ Ahmed

المتوقع أن يكون لدينا Ahmed يملك 400 نقطة
لأن 100 نقطة + 100 نقطة من الـ Query الأولى + 200 نقطة من الـ Query
الثانية

لكن تتفاجيء عندما تجد أن Ahmed يملك 300 نقطة فقط
السبب كلا الـ Query تعملان على نفس البيانات وفي نفس الوقت
بالتالي كل Query تملك نسخة من بيانات اللعب Ahmed والذي يملك 100 نقطة
حاليًا
بالتالي الـ Query الأولى تقوم بتحديث النقاط وتضيف 100 نقطة لـ Ahmed وتصبح
لديه 200 نقطة
والـ Query الثانية تقوم بتحديث النقاط وتضيف 200 نقطة لـ Ahmed وتصبح لديه
300 نقطة

هنا فقدنا التعديل الذي قامت به الـ Query الأولى وكأنه لم يحدث
بسبب أن الـ Query الثانية قامت بعمل Override للتعديل الذي قامت به الـ
Query الأولى

مثال عملي على Lost Update

لنفترض أن لدينا نفس الجدول السابق

Players Table
+----+--------+-------+--------------+
| id | name   | score | country_code |
+----+--------+-------+--------------+
| 10 | Ahmed  | 100   | EG           |
| 20 | Ali    | 200   | EG           |
| 30 | Yehia  | 300   | PS           |
| 40 | Omar   | 400   | EG           |
| 50 | Ismail | 500   | PS           |
+----+-------+--------+--------------+

وكما قلنا فأن اللاعب Ahmed يملك 100 نقطة
ولقد كسب في جولة ما وحصل على 100 نقطة أخرى
بالإضافة إلى أنه حصل على نقاط إضافية لتفوقه على اللاعبين الآخرين وحصل على 200
نقطة

هنا لدينا الدالة الأولى تقوم بتحديث نقاط Ahmed وتضيف 100 نقطة له

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

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

    $player->save();
}

والدالة الثانية تقوم بإضافة 200 نقطة لـ Ahmed

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

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

    $player->save();
}

وبالطبع لنفترض أن الدالتين تم تنفيذهما في نفس الوقت كل Query حدثت في Request
مختلف في نفس الوقت
كلا الدالتين ستنفذ الـ Query التالية Player::where('id', '=', 10)->first();
في نفس الوقت
لتحصل كل منهما على نسخة من بيانات اللاعب Ahmed والذي يملك 100 نقطة
ركز هنا كلا الدالتين تملك نفس بيانات اللاعب Ahmed عندما كان يملك 100 نقطة
الدالة الأولى تقوم بتحديث النقاط وتضيف 100 نقطة لـ Ahmed وتصبح لديه 200
نقطة
والدالة الثانية تقوم بتحديث النقاط وتضيف 200 نقطة لـ Ahmed وتصبح لديه 300
نقطة

في هذه اللحظة سواء تم تنفيذ الدالة الأولى أولًا أو الدالة الثانية أولًا ستجد أن
Ahmed يملك 300 نقطة أو 200 نقطة
وليس 400 نقطة كما كان متوقعًا لأن كلا الدالتين حصلا على نسخة من بيانات اللاعب
Ahmed عندما كان يملك 100 نقطة

حل مشكلة Lost Update باستخدام Transaction

هنا أظنك تضع ابتسامة على وجهك وستقول لي الآن أن الحل سيكون بوضع الدالتين داخل
Transaction
حسنًا ...أنت على حق .. لكن هذا نصف الحل لما ؟ دعونا نرى

نضع كود الدالة الأولى داخل Transaction

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

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

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

ونضع كود الدالة الثانية داخل Transaction

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

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

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

هنا عندما تقوم بتنفيذ الدالتين في نفس الوقت ستجد ... ااا .. ستجد مشكلة ؟
نعم ستجد مشكلة

إن كنت تستخدم SQLite ستجد رسالة تقول لك

Illuminate\Database\QueryException  SQLSTATE[HY000]: General error: 5 database is locked (Connection: sqlite, SQL: update "players" set "score" = 300, "updated_at" = 2025-02-01 17:06:05 where "id" = 10).

الرسالة تقول لك database is locked وتعني أن الـ Database مقفلة ولا يمكنك
تنفيذ الـ Query
أي هناك Transaction تقوم بعمل Query على نفس البيانات ولم تنتهي بعد لذا تم
وضع Lock على الـ Database

في الـ SQLite تقوم بوضع Lock على الـ Database لمنع أي Query أخرى من
التنفيذ على نفس البيانات

هذا يعني أن أحدى الدالتين قامت بتنفيذ الـ Query أولًا وقامت بوضع Lock على الـ
Database
وقامت بتنفيذ التعديل بنجاح
والدالة الثانية عندما حاولت تنفيذ الـ Query وجدت أن الـ Database مقفلة ولا
يمكنها تنفيذ الـ Query
بالتالي قامت برمي هذا الـ Exception

بالتالي هناك دالة نفذت بنجاح ودالة أخرى فشلت في التنفيذ
بالتالي لم نحل المشكلة بالكامل ومازلنا نعاني من مشكلة الـ Lost Update

لكن ما المشكلة الحقيقية هنا ؟

المشكلة الحقيقية أنه مازال كلا الدالتين قد حصلت على نسخة من بيانات اللاعب
Ahmed عندما كان يملك 100 نقطة
بالتالي سواء قامت الدالة الأولى بتنفيذ الـ Query بنجاح
مازلت الدالة الثانية تملك نسخة قديمة من بيانات اللاعب Ahmed
بالتالي إذا قامت الدالة الثانية بتنفيذ الـ Query بنجاح ستقوم بعمل Override
للتعديل الذي قامت به الدالة الأولى
لذالك تم عمل Lock على الـ Database لمنع هذا الأمر وجعل الدالة الثانية ترمي
Exception لمنعها من التنفيذ

هذا الأمر وهذا السلوك منطقي لمنع أي Query تملك نسخة قديمة من البيانات من
التنفيذ
لكن هذا لم يحل المشكلة بالكامل

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

أي علينا إخبار الدالة الثانية بعد أن تقابل الـ Exception أن تقوم بإعادة قراءة
البيانات مجددًا وتعيد تنفيذ الـ Query

في الـ Laravel وبالتحديد في الـ Transaction يمكننا استخدام اخباره بأن يقوم
بإعادة قراءة البيانات مجددًا

نحدث الـ Transaction بأن يقوم بإعادة نفسه مرة أخرى في حالة حدوث Exception في
كلا الدالتين

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

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

        $player->save();
    }, 2); // أعد التنفيذ مرتين على الأقل حتى تنجح
}
public function addBonusPoints()
{
    DB::transaction(function () {
        $player = Player::where('id', '=', 10)->first();
        $player->score += 200;

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

        $player->save();
    }, 2); // أعد التنفيذ مرتين على الأقل حتى تنجح
}

ماذا تلاحظ ؟

دالة الـ Transaction لديها القدرة على إعادة نفسها مرة أخرى في حالة حدوث
Exception
ويمكنك تحديد عدد المرات التي تريد إعادة الـ Transaction في حالة حدوث
Exception عن طريق الـ Parameter الثاني
لذا ستلاحظ أننا قمنا بإعطاءه رقم 2 كقيمة للـ Parameter الثاني الخاص بالـ
Transaction
لنخبره أنه على الأقل نفذ الـ Transaction مرتين حتى تنجح

هذه من مميزات أنك تستخدم الـ ORM الخاصة باللغة مثل Eloquent في Laravel
لأنه يوفر لك مميزات مثل هذه لتسهيل عليك العمل

بمعنى أن مفهوم الـ Retry للـ Transaction هذا يعد نظام يحتاج للتفكير وكيف
يمكنك تنفيذه بنفسك دون أخطاء
لكن في Laravel أو في أي ORM ستجد أنها توفر لك هذه الميزة بكل بساطة

وبالطبع نحن نقوم بعمل Retry للدالتين لأنك لا تعرف أي دالة ستنفذ أولًا

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

هكذا تم حل مشكلة الـ Lost Update


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

لذا يجب عليك أن تقوم بوضع Lock على البيانات التي تقوم بتعديلها بشكل يدوي
لمنع وحل مشكلة الـ Lost Update
في الـ SQL عليك كتابة الـ Query الخاصة بالـ Lock بنفسك هكذا

SELECT * FROM players WHERE id = 10 FOR UPDATE;

على أي حال نحن نستخدم Laravel وكما قلنا وأنا أحاول شرح الأمور بشكل عملي على أي
ODM
لذا سنتحدث عن كيفية تطبيق الـ Lock في الـ Laravel في حالة استخدام MySQL

لكن أولًا لنرى الجدول التالي
الذي يوضح مستويات الـ Isolation ومشاكل التي تحدث في كل مستوى

جدول مستويات العزل والمشاكل

Isolation Level Dirty Read Lost Update Non-Repeatable Read Phantom Read
Read Uncommitted
Read Committed
Repeatable Read
Serializable
Snapshot

علامة تعني أمان وأن هذه المشكلة لن تحدث
علامة تعني أن هذه المشكلة قد تحدث


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