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

مشكلة الـ Race Condition وكيف نتعامل معها

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

المقدمة

في هذه المقالة سنتحدث عن مشكلة شهيرة جدًا وهي الـ Race Condition
سنتعرف على هذه المشكلة بشكل مفصل ونعرف كيف تحدث ولماذا هي خطيرة
وكيف نتجنبها باستخدام تقنيات مثل الـ Database Lock و الـ Cache Lock

كمفهوم وكحل يمكنك تطبيقه على أي لغة وأي framework آخر أيضًا
لكننا سنستخدم Laravel بمجرد ضرب المثال وللتوضيح فقط لا أكثر

ملحوظة: إذا لم تكن على دراية بالـ Database Transactions وأنواع الـ Lock المختلفة مثل الـ Exclusive Lock
أنصحك بقراءة مقالة مبدأ الـ Isolation - أنواع الـ Locks لأننا سنستخدم الـ Exclusive Lock في هذه المقالة

ما هي مشكلة الـ Race Condition ؟

الـ Race Condition وهي كما يوحي الاسم حالة السباق
تحدث عندما يحاول أكثر من مستخدم أو أكثر من عملية تنفيذ نفس الشيء في نفس الوقت على نفس البيانات
والنتيجة تعتمد على من يسبق الآخر في الوصول إلى البيانات وتعديلها
لذلك نسميها Race Condition لأنها تشبه السباق بين طرفين على نفس الهدف

تخيل أن لديك تطبيق لحجز غرف في فندق ويوجد غرفة واحدة فقط متبقية وهي الغرفة A1 على سبيل المثال ويوجد شخصان Ahmed و Ali يريدان حجز هذه الغرفة بالنسبة لكلاهما فإن الغرفة متاحة وغير محجوزة
فكلاهما سيقومان بعملية الحجز في نفس الوقت
كل منهما يقرأ أن الغرفة متاحة ثم يقوم بعملية الحجز

النتيجة ستكون أن كلاهما حصلا على رسالة نجاح في الحجز
بمعنى أن كلاهما يظن أنه حجز الغرفة بنجاح باسمه لكن في الحقيقة الغرفة تم حجزها من قبل أحدهما فقط
والمستخدم الآخر يظن أنه حجزه بنجاح لكنه في الحقيقة لم يحجزه
لأنه في هذه اللحظة ستحصل عملية Override لبيانات الحجز في الـ Database من قبل آخر عملية تصل وتعدل البيانات
هذه هي مشكلة الـ Race Condition


المشكلة تشبه إلى حد كبير مشكلة الـ Lost Update التي تحدثنا عنها في مقالة مبدأ الـ Isolation - مستويات العزل ومقالة مبدأ الـ Isolation - أنواع الـ Locks
حيث أن هناك عمليتين تتنافسان على تعديل نفس البيانات في نفس الوقت فكلاهما يقومان بقراءة نفس البيانات لكن كل منهما يقوم بتعديلها بشكل مستقل عن الآخر
ففي هذه اللحظة التعديل الذي يأتي في الأخير هو الذي سيبقى في الـ Database
وأما التعديل الأول فكأنه لم يحدث لأنه تم تعديله من قبل التعديل الثاني
وهذه هى مشكلة الـ Lost Update

أما في حالة الـ Race Condition فهي تحدث في سياق مختلف وحلها أيضًا مشابه جدًا لحل مشكلة الـ Lost Update باستخدام الـ Exclusive Lock
لكن هنا نسميها Race Condition لأنها مشكلة تحدث في سياق مختلف

نستطيع أن نقول أن الـ Lost Update هو نوع من أنواع الـ Race Condition
والـ Race Condition هو مصطلح أوسع يشمل أنواع مختلفة من المشاكل التي تحدث بسبب تعارض في الوصول إلى نفس البيانات في نفس الوقت
وأما الـ Lost Update فهو نوع محدد من الـ Race Condition يحدث عندما يكون هناك عمليتين تعدلان نفس البيانات في نفس الوقت تتسبب في فقدان التعديل الأول بسبب تعديله من قبل التعديل الثاني

على أي حال في هذه المقالة سنتدرج بالأمثلة خطوة بخطوة
سنبدأ بمثال بسيط ونبني عليه حتى نصل لمثال أكثر تعقيدًا
وكما قلنا سنستخدم Laravel لتوضيح الأمثلة لكن المفاهيم تنطبق على أي لغة أو framework آخر

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

التسابق لحجز آخر غرفة في فندق

لنبدأ بمثال بسيط
لنفترض أن لدينا جدول users يحتوي على المستخدمين وجدول rooms يحتوي على غرف متاحة للحجز

Users Table
+----+-------+---------+
| id | name  | balance |
+----+-------+---------+
| 1  | Ahmed | 5000.00 |
| 2  | Ali   | 5000.00 |
+----+-------+---------+

Rooms Table
+----+-------------+-----------+-----------+
| id | room_number | is_booked | booked_by |
+----+-------------+-----------+-----------+
| 1  | A1          | false     | NULL      |
| 2  | A2          | false     | NULL      |
| 3  | A3          | false     | NULL      |
+----+-------------+-----------+-----------+

لاحظ أن booked_by هو foreign key يشير إلى id في جدول users
بمعنى أنه يحفظ الـ id الخاص بالمستخدم الذي حجز الغرفة وليس اسمه مباشرة

ولدينا دالة بسيطة تقوم بحجز غرفة لمستخدم معين

public function bookRoom(int $roomId, User $user)
{
    $room = Room::find($roomId);

    if ($room->isBooked()) {
        return response()->json([
            'message' => 'Room is already booked',
            'success' => false,
        ], 409);
    }

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

    $room->update([
        'is_booked' => true,
        'booked_by' => $user->id,
    ]);

    return response()->json([
        'message' => 'Room booked successfully',
        'success' => true,
    ]);
}

الدالة تستقبل room_id وتقوم بالتحقق إذا كانت الغرفة محجوزة أم لا
إذا كانت الغرفة محجوزة بالفعل ترجع رسالة خطأ بأن الغرفة محجوزة
وإذا كانت الغرفة متاحة تقوم بتحديث بيانات الحجز في الـ Database
لاحظ أننا وضعنا sleep(3) لمحاكاة تأخير في العملية لأي سبب كان

أين المشكلة ؟

لو قام Ahmed و Ali بطلب حجز الغرفة A1 في نفس الوقت
فستحدث الخطوات التالية

    Ahmed                           Ali
      |                              |
      |  SELECT * FROM rooms         |  SELECT * FROM rooms
      |  WHERE id = 1                |  WHERE id = 1
      |  -> is_booked = false        |  -> is_booked = false
      |                              |
      |         sleep(3)...          |         sleep(3)...
      |                              |
      |  UPDATE rooms                |
      |  SET is_booked = true,       |
      |  SET booked_by = 1 (Ahmed)   |
      |                              |  UPDATE rooms
      |                              |  SET is_booked = true,
      |                              |  SET booked_by = 2 (Ali)  <- override
      |                              |
      v                              v
"Room booked successfully"       "Room booked successfully"

يقوم Ahmed بقراءة بيانات الغرفة A1 ويجدها متاحة لأن قيمة is_booked ستكون false
وفى نفس اللحظة يقرأ Ali بيانات الغرفة A1 ويجدها متاحة أيضًا لأن قيمة is_booked لا تزال false
ثم يقوم Ahmed بحجز الغرفة ويتم تغيير is_booked إلى true و booked_by إلى 1 وهو الـ id الخاص بـ Ahmed ثم يقوم Ali بحجز نفس الغرفة ويتم تغيير booked_by إلى 2 وهو الـ id الخاص بـ Ali

كلاهما حصل على نفس الرد:

// Ahmed
{ "message": "Room booked successfully", "success": true }

// Ali
{ "message": "Room booked successfully", "success": true }

لكن لو نظرنا في الـ Database:

Rooms Table
+----+-------------+-----------+-----------+
| id | room_number | is_booked | booked_by |
+----+-------------+-----------+-----------+
| 1  | A1          | true      | 2         |
| 2  | A2          | false     | NULL      |
| 3  | A3          | false     | NULL      |
+----+-------------+-----------+-----------+

Ahmed يظن أنه حجز الغرفة بنجاح لكنه في الحقيقة لم يحجزها
لأن Ali قام بعمل Override لبيانات الحجز التي كانت لـ Ahmed لأنه كان آخر من قام بالتعديل الغرفة في النهاية محجوزة بالـ id الخاص بـ Ali

هذه هي مشكلة الـ Race Condition في أبسط صورها

حل مشكلة الـ Race Condition

هناك عدة حلول لمشكلة الـ Race Condition
ومنها التي سنتحدث عنها في هذه المقالة هي استخدام الـ Database Lock والـ Cache Lock
وكل نوع له استخداماته ومميزاته وعيوبه
فمثلًا أن الـ Database Lock يعمل فقط على مستوى الـ Database وهو ممتاز لحماية العمليات التي تتعلق بالبيانات في الـ Database
لكن لو كانت المشكلة في سياق مختلف مثل عمليات غير متعلقة بالـ Database مثل إرسال بريد إلكتروني أو التواصل مع API خارجي
فيمكننا استخدام الـ Cache Lock لحماية هذه العمليات أيضًا

وفي حالة الـ Database Lock هناك أنواع وتقنيات مختلفة والتي تحدثنا عنها في مقالة مبدأ الـ Isolation - أنواع الـ Locks
في هذه المقالة سنستخدم الـ Exclusive Lock الذي يضمن أن أول Transaction يقوم بوضع Lock على الـ Row الذي يريد تعديله
ثم كل الـ Transaction الأخرى التي تحاول قراءة أو تعديل نفس الـ Row ستنتظر حتى يتم فك الـ Lock من قبل أول Transaction

حجز غرفة مع Database Lock

الآن لنحل هذه المشكلة باستخدام الـ Exclusive Lock
الآن عندما يدخل Ahmed لعملية الحجز ويقرأ بيانات الغرفة A1 فهذه تعد Transaction
وعندما يدخل Ali لعملية الحجز ويقرأ بيانات الغرفة A1 فهذه أيضًا تعد Transaction

هنا نحن سنقوم بوضع Exclusive Lock على الـ Row الخاص بالغرفة التي نريد حجزها
وأول Transaction تصل لهذا الـ Row ستقوم بوضع Lock عليه
بالتالي لو يصادف أن يكون Ahmed هو أول من يصل للـ Row الخاص بالغرفة A1 ويضع Lock عليه
فهكذا Ali عندما يحاول الوصول لنفس الـ Row الخاص بالغرفة A1 سيجد أن هناك Lock عليه
فسوف ينتظر حتى يتم فك الـ Lock من قبل Ahmed
ثم عندما يتم فك الـ Lock سوف يستطيع Ali الوصول للـ Row الخاص بالغرفة

في Laravel يمكنك استخدام DB::transaction() لفتح Transaction ثم استخدام lockForUpdate() لوضع Exclusive Lock على الـ Row الذي تريد تعديله

public function bookRoom(int $roomId, User $user)
{
    return DB::transaction(function () use ($roomId, $user) {

        $room = Room::where('id', $roomId)
            ->lockForUpdate()
            ->first();

        if ($room->isBooked()) {
            return response()->json([
                'message' => 'Room is already booked',
                'success' => false,
            ], 409);
        }

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

        $room->update([
            'is_booked' => true,
            'booked_by' => $user->id,
        ]);

        return response()->json([
            'message' => 'Room booked successfully',
            'success' => true,
        ]);
    });
}

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

هنا لدينا نفس الخطوات التي كانت في الدالة السابقة لكن مع إضافة خطوة وضع الـ Lock على الـ Row الخاص بالغرفة
عن طريق استخدام دالة lockForUpdate() لوضع Exclusive Lock على الـ Row الخاص بالغرفة التي نريد حجزه

هكذا عندما يصل Ahmed الـ Query ويضع Lock على الـ Row الخاص بالغرفة A1
سيقوم Ali أو أي Transaction أخرى بالإنتظار حتى يتم فك الـ Lock الذي وضعه Ahmed وعندما يكمل Ahmed عملية الحجز ويقوم بعمل COMMIT للبيانات يتم فك الـ Lock

الآن Ali يستطيع الوصول للـ Row الخاص بالغرفة A1
لكنه يصادف في النقطة الثانية أن الغرفة أصبحت محجوزة بالفعل بسبب الشرط if ($room->isBooked())
لذلك يحصل Ali على رسالة Exception جميلة بأن الغرفة محجوزة بالفعل ولا يستطيع حجزها

    Ahmed                                Ali
      |                                   |
      |  BEGIN TRANSACTION                |  BEGIN TRANSACTION
      |  SELECT ... lockForUpdate()       |
      |  -> is_booked = false             |  SELECT ... lockForUpdate()
      |  [LOCK]                           |  -> waiting because of LOCK
      |                                   |  .
      |  sleep(3)...                      |  .
      |                                   |  .
      |  UPDATE booked_by = 1             |  .
      |  COMMIT                           |  .
      |  [UNLOCK]                         |  .
      |                                   |  -> LOCK released, now can read the row
      |                                   |  -> is_booked = true -> Exception
      v                                   v
"Room booked successfully"          "Room is already booked"

لنتتبع خطوات التنفيذ:

أولًا Ahmed يدخل الـ Transaction ويضع Lock على الـ Row الخاص بالغرفة A1
في نفس الوقت Ali يدخل الـ Transaction ويحاول وضع Lock على نفس الـ Row الخاص بالغرفة A1 لكنه يجد أن هناك Lock عليه فينتظر حتى يتم فك الـ Lock من قبل Ahmed
سيقوم Ahmed بعملية الحجز ثم يعمل COMMIT للبيانات في الـ Database ويتم فك الـ Lock
الآن Ali يستطيع الوصول للـ Row الخاص بالغرفة A1 لكنه يجد أن الغرفة أصبحت محجوزة بالفعل بسبب الشرط فسيحصل على Exception بأن الغرفة محجوزة بالفعل وأنه لا يستطيع حجزها

// Ahmed
{ "message": "Room booked successfully", "success": true }

// Ali
{ "message": "Room is already booked", "success": false }

ولو نظرنا في الـ Database سنجد أن الغرفة A1 محجوزة من قبل Ahmed ولا أحد قام بعمل Override لبيانات الحجز

Rooms Table
+----+-------------+-----------+-------------+
| id | room_number | is_booked | booked_by   |
+----+-------------+-----------+-------------+
| 1  | A1          | true      | 1           |
| 2  | A2          | false     | NULL        |
| 3  | A3          | false     | NULL        |
+----+-------------+-----------+-------------+

هكذا حللنا المشكلة بشكل كامل و Ahmed حجز الغرفة بنجاح و Ali حصل على رسالة واضحة أن الغرفة محجوزة
ولا يوجد أي تعارض أو Override في البيانات

هكذا نكون قد حللنا مشكلة الـ Race Condition في هذا المثال البسيط باستخدام الـ Database Lock

ملحوظة: يجب أن نستخدم lockForUpdate() داخل DB::transaction() لأن الـ Lock يعمل فقط داخل Transaction
والـ lockForUpdate() يقوم بعمل Exclusive Lock على الـ Rows التي في نطاق الـ Query بمعنى لو قمنا بكتابة whereIn('id', [1, 2, 3]) هكذا الصفوف الثلاثة سيتم عمل lock عليها

التسجيل في دورة مرتين بالخطأ

المثال السابق كان يتحدث عن شخصين مختلفين يتسابقان لحجز نفس الغرفة في نفس الوقت
لكن الـ Race Condition لا تحتاج بالضرورة لشخصين مختلفين
يمكن أن تحدث من نفس الشخص

تخيل أن Ahmed لديه 5000 جنيه ويريد التسجيل في دورة Laravel Advanced وسعرها 500 جنيه لكنه ضغط على زر الشراء مرتين بالخطأ بسرعة
فأرسل المتصفح طلبين في نفس الوقت لنفس عملية التسجيل

لنفترض أن لدينا جدول courses يحتوي على الدورات المتاحة وجدول enrollments لتسجيل عمليات الاشتراك وربط المستخدم بالدورة

Courses Table
+----+------------------+--------+
| id | name             | price  |
+----+------------------+--------+
| 1  | Laravel Advanced | 500.00 |
+----+------------------+--------+

Enrollments Table
+----+---------+-----------+
| id | user_id | course_id |
+----+---------+-----------+

ولدينا دالة تقوم بتسجيل المستخدم في الدورة

public function enrollCourse(int $courseId, User $user)
{
    $course = Course::find($courseId);

    $alreadyEnrolled = Enrollment::query()
        ->where('user_id', $user->id)
        ->where('course_id', $courseId)
        ->exists();

    if ($alreadyEnrolled) {
        return response()->json([
            'message' => 'Already enrolled in this course',
            'success' => false,
        ], 409);
    }

    if (!$course->hasEnoughPrice($user->balance)) {
        return response()->json([
            'message' => 'Sorry, you do not have enough balance to enroll in this course',
            'success' => false,
        ], 400);
    }

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

    $user->decrement('balance', $course->price);

    Enrollment::create([
        'user_id' => $user->id,
        'course_id' => $courseId,
    ]);

    return response()->json([
        'message' => 'Enrolled successfully',
        'success' => true,
    ]);
}

لاحظ أن الدالة تتحقق أولًا إن كان المستخدم مسجل في هذه الدورة من قبل
عن طريق البحث في جدول enrollments
إذا وجدت صف فيه user_id يساوي id الخاص بالمستخدم و course_id يساوي id الخاص بالدورة فهذا يعني أن المستخدم مسجل بالفعل في هذه الدورة
في هذه الحالة ترجع رسالة خطأ بأن المستخدم مسجل بالفعل في هذه الدورة
ثم تتحقق إذا كان المستخدم لديه رصيد كافي لتسجيل في الدورة

إذا كان غير مسجل ولديه رصيد كافي تقوم بخصم المبلغ من رصيده في جدول users ثم تقوم بإنشاء record جديد في جدول enrollments لتسجيل اشتراك المستخدم في الدورة

أين المشكلة ؟

هنا برغم أنه لدينا validation تتحقق إن كان المستخدم مسجل من قبل
إلا أن هذه الـ validation لن تحمينا من مشكلة الـ Race Condition

لأن Ahmed ضغط على زر الشراء مرتين بالخطأ
فأرسل المتصفح طلبين في نفس الوقت
كلا الطلبين يبحثان في جدول enrollments ويجدان أنه لا يوجد تسجيل سابق لهذا المستخدم في هذه الدورة
فكلاهما يمر من الـ validation بنجاح ويعتبر أن التسجيل مسموح
ثم كلاهما يقوم بخصم المبلغ وإنشاء record جديد في جدول enrollments

    Click 1 (Ahmed)                       Click 2 (Ahmed)
      |                                     |
      |  SELECT * FROM enrollments          |  SELECT * FROM enrollments
      |  WHERE user_id=1 AND course_id=1    |  WHERE user_id=1 AND course_id=1
      |  -> not foun so can enroll          |  -> not found so can enroll
      |                                     |
      |         sleep(3)...                 |         sleep(3)...
      |                                     |
      |  UPDATE balance = balance - 500     |  UPDATE balance = balance - 500
      |  INSERT INTO enrollments            |  INSERT INTO enrollments
      |  (user_id=1, course_id=1)           |  (user_id=1, course_id=1)
      |                                     |
      v                                     v
"Enrolled successfully"             "Enrolled successfully"

هنا Ahmed حصل على رسالة Enrolled successfully مرتين
ويظن المسكين أنه اشترك في الدورة بنجاح
لكن في الحقيقة تم تسجيله في الدورة مرتين وتم خصم المبلغ مرتين

ولو نظرنا في الـ Database:

Users Table
+----+-------+---------+
| id | name  | balance |
+----+-------+---------+
| 1  | Ahmed | 4000.00 |
+----+-------+---------+

Enrollments Table
+----+---------+-----------+---------------------+
| id | user_id | course_id | created_at          |
+----+---------+-----------+---------------------+
| 1  | 1       | 1         | 2025-10-01 10:00:00 |
| 2  | 1       | 1         | 2025-10-01 10:00:00 |
+----+---------+-----------+---------------------+

هنا سنجد عدة مشاكل
تم خصم المبلغ مرتين من Ahmed بحيث أصبح رصيده 4000 بدلًا من 4500
وتم تسجيل عمليتي تسجيل في جدول enrollments لنفس المستخدم ونفس الدورة
رغم أن لدينا validation تتحقق من هذا الأمر وتمنعه

المشكلة هنا أن الـ validation كانت صحيحة وقت تنفيذها
لأن كلا الطلبين قرأ البيانات قبل أن يكتب أي منهما
فكلاهما وجد أن جدول enrollments فارغ لهذا المستخدم وهذه الدورة
ثم كلاهما مر من الشرط بنجاح وأكمل عملية التسجيل


الحل لهذه المشكلة هو استخدام الـ Database Lock كما فعلنا في المثال السابق

public function enrollCourse(int $courseId, User $user)
{
    return DB::transaction(function () use ($courseId, $user) {

        $course = Course::where('id', $courseId)
            ->lockForUpdate() // lock the course row
            ->first();

        $alreadyEnrolled = Enrollment::where('user_id', $user->id)
            ->where('course_id', $courseId)
            ->exists();

        if ($alreadyEnrolled) {
            return response()->json([
                'message' => 'Already enrolled in this course',
                'success' => false,
            ], 409);
        }

        if (!$course->hasEnoughPrice($user->balance)) {
            return response()->json([
                'message' => 'Insufficient balance',
                'success' => false,
            ], 400);
        }

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

        $user->decrement('balance', $course->price);

        Enrollment::create([
            'user_id' => $user->id,
            'course_id' => $courseId,
        ]);

        return response()->json([
            'message' => 'Enrolled successfully',
            'success' => true,
        ]);
    });
}

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

وعندما يتم فك الـ Lock من قبل الطلب الأول سوف يستطيع الطلب الثاني الوصول للـ Row الخاص بالدورة
لكن في هذه اللحظة عندما يبحث في جدول enrollments سيجد أن هناك record بالفعل لهذا المستخدم في هذه الدورة
فيحصل على رسالة أنه مسجل بالفعل

    Click 1 (Ahmed)                      Click 2 (Ahmed)
      |                                    |
      |  BEGIN TRANSACTION                 |  BEGIN TRANSACTION
      |  SELECT ... lockForUpdate()        |
      |  [LOCK on course row]              |  SELECT ... lockForUpdate()
      |                                    |  -> waiting because of LOCK
      |  SELECT FROM enrollments           |  .
      |  -> not found so can enroll        |  .
      |                                    |  .
      |  sleep(3)...                       |  .
      |                                    |  .
      |  UPDATE balance = balance - 500    |  .
      |  INSERT INTO enrollments           |  .
      |  COMMIT                            |  .
      |  [UNLOCK]                          |  .
      |                                    |  -> LOCK released
      |                                    |  SELECT FROM enrollments
      |                                    |  -> found so already enrolled
      v                                    v
"Enrolled successfully"             "Already enrolled in this course"

الطلب الأول يقفل الـ Row الخاص بالدورة ويسجل بنجاح
الطلب الثاني ينتظر حتى يتم فك الـ Lock
وعندما يتم فك الـ Lock ويبحث في جدول enrollments يجد أن هناك تسجيل بالفعل فيحصل على رسالة أنه مسجل من قبل

// Click 1
{ "message": "Enrolled successfully", "success": true }

// Click 2
{ "message": "Already enrolled in this course", "success": false }

ولو نظرنا في الـ Database:

Users Table
+----+-------+---------+
| id | name  | balance |
+----+-------+---------+
| 1  | Ahmed | 4500.00 |
+----+-------+---------+

Enrollments Table
+----+---------+-----------+
| id | user_id | course_id |
+----+---------+-----------+
| 1  | 1       | 1         |
+----+---------+-----------+

رصيد Ahmed أصبح 4500 بحيث تم خصم المبلغ مرة واحدة فقط
تم تسجيل عملية تسجيل واحدة فقط في جدول enrollments

ملحوظة: يفضل دائمًا إضافة Unique Constraint كطبقة حماية إضافية على مستوى الـ Database في حالتنا على الأعمدة user_id و course_id في جدول enrollments ويكون Composite Unique Key بحيث لا يسمح بوجود صفين بنفس user_id و course_id هكذا حتى لو فشل الـ Lock لأي سبب فإن الـ Database نفسها ستمنع إدخال record مكرر وسترمي Exception


لاحظ أننا في المثال السابق قمنا بوضع Lock على الـ Row الخاص بالدورة في جدول courses
وهذا الـ Lock سيقوم بمنع أي Transaction أخرى من الوصول لهذا الـ Row
حتى لو كانت هذه الـ Transaction لا تتعلق بعملية التسجيل في الدورة
أو أن هناك 100 يريد التسجيل في نفس الدورة في نفس الوقت
لو كل واحد منهم وضع Lock على نفس الـ Row الخاص بالدورة في جدول courses
وكانت عملية التسجيل تأخذ ثانية أو ثانيتين فهذا هناك أشخاص سينتظرون دقائق حتى يتم تنفيذ عملية التسجيل بسبب الـ Lock

الآن فكر معي في المثال السابق، كان المستخدم Ahmed يريد التسجيل في دورة Laravel Advanced
وقلنا أنه ضغط على زر الاشتراك مرتين بالخطأ في نفس الوقت هكذا أصبح Race Condition
لكن ماذا لو كان لدينا مستخدم آخر يدعى Ali يريد التسجيل في نفس الدورة في نفس الوقت
لذا قام Ali و Ahmed بالضغط على زر الاشتراك في نفس الوقت
هل ستحدث مشكلة الـ Race Condition بين Ahmed و Ali ؟

الإجابة هي لا

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

لكن لو كان هناك Business Rule جديدة تقول إن الدورة لها حد أقصى لعدد المشتركين مثلًا 100 مشترك فقط في هذه اللحظة ستحدث مشكلة الـ Race Condition
لأنه في هذه الحالة سنجد أن Ahmed و Ali قد يتسابقان على حجز آخر مقعد في الدورة مثل ما حدث في مثال حجز الغرفة في الفندق

على أي حالة بسبب أن الدورة ليس لها حد أقصى لعدد المشتركين في هذا المثال
فهنا لدينا مشكلة وهي أنه لو قام 100 شخص بالضغط على زر الاشتراك في نفس الوقت في نفس الدورة فكل واحد منهم سيقوم بوضع Lock على نفس الـ Row الخاص بالدورة في جدول courses
سينتظر ثاني أو ثانيتين حتى يتم تنفيذ عملية التسجيل بسبب الـ Lock

برغم من عدم وجود مشكلة الـ Race Condition بين أشخاص مختلفين في هذا المثال
لذا الـ Database Lock في هذا السياق والمثال قد لا يكون الحل الأمثل
لأنه سيؤدي إلى تأخير في تنفيذ عملية التسجيل بسبب الـ Lock على مستوى الـ Database

نحتاج لحل يحل مشكلة الـ Race Condition على مستوى نفس الشخص
هنا يظهر لنا نوع آخر من أنواع الـ Lock وهو الـ Cache Lock الذي يعمل على مستوى الـ Application وليس على مستوى الـ Database بالتالي كل شخص عندما يريد التسجيل في دورة معينة يقوم بوضع Lock خاص به على مستوى الـ Application بين نفسه وبين هذه الدورة وليس على مستوى الدورة بحد ذاتها في الـ Database بالتالي لن يكون هناك تأخير بسبب الـ Lock على مستوى الـ Database بين أشخاص مختلفين
فلو Ahmed ضغط على زر الاشتراك مرتين بالخطأ في نفس الوقت
سنقوم بوضع Cache Lock خاص بـ Ahmed و الدورة التي يريد الاشتراك فيها
فلو حاول Ahmed إرسال طلب ثاني في نفس الوقت سيجد أن هناك Lock فسيتم رفض الطلب الثاني مباشرة

قبل هذا دعونا أولًا نفهم ما هو الـ Cache Lock وكيف يعمل وما هي مميزاته وعيوبه

لمحة عن مشكلة الـ Double Submission

لكن قبل أن ننتقل لفهم وتطبيق الـ Cache Lock أريد أن أصالحك بشيء معين وهو لكي أكون أكثر دقة فهذا المثال في الحقيقة هو مثال لمشكلة أخرى وهي مشكلة الـ Double Submission وهي عندما يقوم المستخدم بإرسال نفس الطلب مرتين بالخطأ

وهي قد تكون نوعًا من أنواع الـ Race Condition لأن مشكلة الـ Double Submission تحدث عندما يقوم شخص واحد بالتنافس مع نفسه في نفس الوقت طريقة حل مشكلة الـ Double Submission تنقسم لشقين، الشق الأول هو حل مشكلة الـ Race Condition بين نفس الشخص في نفس الوقت كما فعلنا في المثال السابق
والشق الثاني هو حل مشكلة الـ Double Submission بشكل عام بحيث لا يسمح لأي شخص بإرسال نفس الطلب مرتين في أوقات مختلفة
وهذا الحل يحدث عن طريق استخدام Idempotency Key وهو يكون Unique Token يتم توليده وتخزيته في الـ Request في الـ Header من قبل الـ Frontend
ثم يقوم الـ Backend بتخزين هذا الـ Key في الـ Database أو في الـ Cache مع حالة الطلب
لو وصل Request جديد بنفس الـ Idempotency Key في هذه الحالة يدرك الـ Backend أن هذا الطلب مكرر ولا يقبله

لذلك لو أردت التعمق في هذه النقطة راجع مقالة مفهوم الـ Idempotency Key ومثال عملي عليه
فيها شرح كامل لمشكلة الـ Duplicate Submissions وكيفية حلها باستخدام Idempotency Key مع Cache Lock

بالطبع لن أشرح الـ Double Submission بشكل مفصل في هذه المقالة لأننا نريد التركيز على الـ Race Condition
لأن مشكلة الـ Double Submission لا يمكن حلها باستخدام الـ Lock فقط كما قلنا، بل تحتاج لتطبيق مفهوم الـ Idempotency Key أيضًا


دعنا نكمل في موضوع الـ Race Condition وننتقل للحديث عن نوع آخر من أنواع الـ Lock وهو الـ Cache Lock الذي يعمل على مستوى الـ Application وليس على مستوى الـ Database
قبل هذا دعونا أولًا نفهم ما هو الـ Cache Lock وكيف يعمل وما هي مميزاته وعيوبه

ما هو الـ Cache Lock ؟

في الأمثلة السابقة استخدمنا الـ Database Lock لحل مشكلة الـ Race Condition
والـ Database Lock ممتاز عندما تكون المشكلة متعلقة ببيانات في الـ Database
لكن ماذا لو كانت العملية التي نريد حمايتها ليست متعلقة بالـ Database فقط ؟ في هذه الحالة لا يمكننا استخدام lockForUpdate() لأنه يعمل فقط على مستوى الـ Database
هنا يأتي دور الـ Cache Lock

الـ Cache Lock هو Lock يعمل على مستوى الـ Application وليس على مستوى الـ Database
وهو ببساطة عن key يتم تخزينه في الـ Cache مع قيمة معينة ومدة صلاحية محددة
لو قمنا بعمل Lock على عملية ما نستطيع إنشاء key خاص بهذه العملية وتخزينه في الـ Cache
بالتالي أي شخص سيحاول الحصول على نفس الـ Lock بنفس الـ key سيجد أن هناك Lock بالفعل موجود في الـ Cache وبالتالي لا يستطيع الحصول عليه

في Laravel يمكنك إنشاء Cache Lock كالتالي:

$lock = Cache::lock('lock-key', 10);

الـ 10 هنا هو الـ TTL أو مدة الصلاحية بالثواني
بمعنى أن هذا الـ Lock سيبقى في الـ Cache لمدة 10 ثواني
وهذا الـ Lock سيتم فكه تلقائيًا بعد 10 ثواني أو إذا قمنا بفك الـ Lock يدويًا باستخدام $lock->release()

والـ Cache Lock له طريقتين للعمل:
الأولى هي الـ Blocking Lock وهي أي شخص سيحاول الحصول على الـ Lock وهذا الـ Lock موجود بالفعل في الـ Cache فسينتظر بعض الوقت حتى يتم فك الـ Lock من قبل الشخص الذي يملكه والثانية هي الـ Non-Blocking Lock وهي أي شخص سيحاول الحصول على الـ Lock وهذا الـ Lock موجود بالفعل في الـ Cache فسيحصل على false بالتالي نستطيع إرسال Exception أو رسالة خطأ مباشرة

سنستعرض بمثال بسيط لتوليد أكواد فريدة للمنتجات في نظام إدارة المنتجات

توليد أكواد فريدة للمنتج

لنفترض أن لدينا نظام لإدارة المنتجات
وكل منتج يجب أن يكون له كود فريد مثل PRD-0001 و PRD-0002 وهكذا
الكود يتم توليده تلقائيًا عند إضافة منتج جديد بحيث يأخذ الكود التالي بالترتيب

لدينا جدول products يحتوي على المنتجات وأكوادها

Products Table
+----+---------+----------+
| id | name    | code     |
+----+---------+----------+
| 1  | Laptop  | PRD-0001 |
| 2  | Phone   | PRD-0002 |
+----+---------+----------+

ولدينا UtilsService تحتوي على دالة تقوم بتوليد كود فريد للمنتج
تقرأ آخر كود موجود في الـ Database ثم تزيد عليه واحد لتوليد الكود التالي

class UtilsService
{
    public function generateProductCode(): string
    {
        $lastCode = Product::max('code') ?? 'PRD-0000';
        $nextNumber = Str::after($lastCode, 'PRD-') + 1;

        return 'PRD-' . Str::padLeft($nextNumber, 4, '0');
    }
}

ولدينا دالة تدعى createProduct تقوم بإنشاء منتج جديد باستخدام الكود الذي تم توليده من UtilsService

public function createProduct(string $name): array
{
    $code = $this->utilsService->generateProductCode();

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

    $product = Product::create([
        'name' => $name,
        'code' => $code,
    ]);

    return [
        'message' => "Product created: {$code}",
        'product' => $product,
        'success' => true
    ];
}

الدالة generateProductCode() تقرأ آخر كود في جدول products وتستخرج الرقم منه ثم تزيده بواحد وتولد كود جديد
فلو كان آخر كود هو PRD-0002 فالكود الجديد سيكون PRD-0003

أين المشكلة ؟

لو قمنا بإضافة منتجين جديدين في نفس الوقت منتج بإسم Laptop Pro ومنتج آخر بإسم Tablet X
الآن آخر منتج في الـ Database هو PRD-0002 بالتالي في حالة لدينا اثنين Request في نفس الوقت لتوليد كود جديد
فكلاهما سيقرأ أن آخر كود هو PRD-0002
وكلاهما سيولد الكود التالي وهو PRD-0003
بالتالي كلاهما سيخزن المنتج بنفس الكود

    First Request (Laptop Pro)            Second Request (Tablet X)
      |                                     |
      |  SELECT MAX(code)                   |  SELECT MAX(code)
      |  -> PRD-0002                        |  -> PRD-0002
      |  newCode = PRD-0003                 |  newCode = PRD-0003
      |                                     |
      |         sleep(3)...                 |         sleep(3)...
      |                                     |
      |  INSERT (Laptop Pro, PRD-0003)      |  INSERT (Tablet X, PRD-0003)
      |                                     |
      v                                     v
"Product created: PRD-0003"         "Product created: PRD-0003"

الآن لو نظرنا في الـ Database:

Products Table
+----+------------+----------+
| id | name       | code     |
+----+------------+----------+
| 1  | Laptop     | PRD-0001 |
| 2  | Phone      | PRD-0002 |
| 3  | Laptop Pro | PRD-0003 |
| 4  | Tablet X   | PRD-0003 |
+----+------------+----------+

لدينا منتجان مختلفان بنفس الكود PRD-0003 وهذا يعد مشكلة كبيرة

توليد أكواد فريدة مع Cache Lock

الآن لنحل هذه المشكلة باستخدام الـ Cache Lock
الفكرة هي أننا سنضع Lock على عملية توليد الكود بالكامل
بحيث لو حاول شخصان توليد كود في نفس الوقت فواحد فقط ينفذ والآخر ينتظر أو يحصل على رسالة خطأ
قلنا أنه لدينا نوعين من الـ Cache Lock وهما الـ Blocking Lock والـ Non-Blocking Lock

الـ Blocking Lock يجعل الطلب الثاني ينتظر حتى يتوفر الـ Lock بدلًا من رفضه فورًا
أما الـ Non-Blocking Lock فيرفض الطلب الثاني فورًا إذا كان هناك Lock بالفعل في الـ Cache

الآن في مثالنا أول طلب يصل لتوليد كود جديد سيضع Lock في الـ Cache بـ key معين مثل generate-product-code وصلاحية 10 ثواني
أي طلب آخر يصل في نفس الوقت ويحاول توليد كود جديد سيجد أن هناك Lock بالفعل موجود في الـ Cache يحمل نفس الـ key
في Laravel يمكننا إنشاء Cache Lock عن طريق دالة Cache::lock('lock-key', $seconds)

$lock = Cache::lock('generate-product-code', 10);

هكذا قمنا بإنشاء Cache Lock بمفتاح generate-product-code وصلاحية 10 ثواني
لكنه غير مستخدم حتى الآن

لكي نستخدمه لدينا طريقتين، الطريقة الأولى هي الـ Blocking Lock والطريقة الثانية هي الـ Non-Blocking Lock الآن قلنا أن هناك شخصين أو طلبين يريدان توليد كود جديد في نفس الوقت الطلب الأول سينشئ الـ Lock لأول مرة ويبدأ في توليد الكود

الطلب الثاني عندما يصل ويحاول توليد الكود سيجد أن هناك Lock بالفعل موجود في الـ Cache
في هذه الحالة لو استخدمنا فكرة الـ Non-Blocking Lock بحيث نعطيه Exception على الفور إذا وجد أن هناك Lock بالفعل في الـ Cache
أما لو استخدمنا فكرة الـ Blocking Lock فسنجعل الطلب الثاني ينتظر حتى يتم فك الـ Lock

هذا هو الفرق بين الـ Blocking Lock والـ Non-Blocking Lock
الـ Blocking Lock يجعل الطلب الثاني ينتظر حتى يتوفر الـ Lock
أما الـ Non-Blocking Lock فيرفض الطلب الثاني فورًا إذا كان هناك Lock بالفعل في الـ Cache

توليد أكواد فريدة مع Cache Lock Blocking

لنبدأ بالحل باستخدام الـ Blocking Lock
هنا كما قلنا فإن الـ Blocking Lock يجعل الطلب الثاني ينتظر حتى يتوفر الـ Lock بدلًا من رفضه فورًا

في Laravel يمكنك استخدام $lock->block($seconds) لنقوم بعمل Blocking للطلب الثاني لمدة معينة من الثواني
كأنك تقول له إنتظر حتى 10 ثواني للحصول على الـ Lock
إذا تم فك الـ Lock خلال هذه المدة سيتمكن الطلب الثاني من الحصول عليه ثم يكمل العملية

public function createProduct(string $name): array
{
    // Generate a lock
    $lock = Cache::lock('generate-product-code', 10);

    try {
        $lock->block(10); // If it's already locked, wait for up to 10 seconds

        $code = $this->utilsService->generateProductCode();

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

        $product = Product::create([
            'name' => $name,
            'code' => $code,
        ]);

        return [
            'message' => "Product created: {$code}",
            'success' => true,
            'product' => $product
        ];
    } catch (LockTimeoutException $e) {
        return [
            'message' => 'Timeout waiting for lock. Try again later.',
            'success' => false
        ];
    } finally {
        if($lock) {
            $lock->release();
        }
    }
}

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

أنشأنا Cache Lock وأعطيناه اسم generate-product-code وصلاحية 10 ثواني
ثم استخدمنا $lock->block(10) لنجعل الطلب الثاني ينتظر حتى يتوفر الـ Lock
بمعنى لو جاء طلبان في نفس اللحظة تمامًا
الطلب الأول سيحصل على الـ Lock فورًا وسيبدأ في تنفيذ العملية
الطلب الثاني سينتظر حتى 5 ثواني للحصول على الـ Lock
إذا توفر الـ Lock خلال هذه المدة أو أقل فسيتمكن الطلب الثاني من استكمال العملية
وإذا لم يتوفر خلال 10 ثواني فسيتم رمي Exception يدعى LockTimeoutException ونرجع رسالة للمستخدم

لاحظ أننا في الـ finally نستخدم $lock->release() لفك الـ Lock بعد الانتهاء من العملية
سواء تم تنفيذ العملية بنجاح أو تم رمي Exception بسبب انتهاء الوقت

    First Request (Laptop Pro)            Second Request (Tablet X)
      |                                     |
      |  $lock->block(10)                   |  $lock->block(10)
      |  -> Lock acquired                   |  -> waiting for Lock...
      |                                     |  .
      |  SELECT MAX(code)                   |  .
      |  -> PRD-0002                        |  .
      |  newCode = PRD-0003                 |  .
      |                                     |  .
      |  sleep(3)...                        |  .
      |                                     |  .
      |  INSERT (Laptop Pro, PRD-0003)      |  .
      |  $lock->release()                   |  .
      |                                     |  -> Lock acquired
      |                                     |  SELECT MAX(code)
      |                                     |  -> PRD-0003
      |                                     |  newCode = PRD-0004
      |                                     |
      |                                     |  sleep(3)...
      |                                     |
      |                                     |  INSERT (Tablet X, PRD-0004)
      |                                     |  $lock->release()
      v                                     v
"Product created: PRD-0003"         "Product created: PRD-0004"

هكذا كلا الطلبين حصلوا على كود فريد وتم حفظ المنتجات بدون أي تعارض
الطلب الأول حصل على الـ Lock أولًا وولد الكود PRD-0003 ثم فك الـ Lock
الطلب الثاني كان ينتظر حتى يتم فك الـ Lock من قبل الطلب الأول وعندما تم فك الـ Lock استطاع الطلب الثاني الحصول عليه وولد الكود التالي PRD-0004

ولو نظرنا في الـ Database:

Products Table
+----+------------+----------+
| id | name       | code     |
+----+------------+----------+
| 1  | Laptop     | PRD-0001 |
| 2  | Phone      | PRD-0002 |
| 3  | Laptop Pro | PRD-0003 |
| 4  | Tablet X   | PRD-0004 |
+----+------------+----------+

ملحوظة: قد تتساءل لماذا لا نضع الـ Cache Lock داخل دالة generateProductCode() في الـ UtilsService بدلًا من وضعه في createProduct ؟
السبب هو أن الـ Lock داخل generateProductCode() سيحمي فقط عملية توليد الكود وليس عملية حفظ المنتج في الـ Database
بمعنى أن الـ Lock سيتم فكه بعد توليد الكود مباشرة وقبل أن يتم حفظ المنتج
فلو الطلب الأول أو الـ Request الأول قام بتوليد الكود PRD-0003 وتم فك الـ Lock
في هذه اللحظة الطلب الثاني أو الـ Request سيرى أن الـ Lock تم فكه فسيقوم بتنفيذ الدالة generateProductCode()
ويقرأ آخر كود من الـ Database لكن والذي سيجده هو PRD-0002 لأن الطلب الأول لم يحفظ المنتج بعد في الـ Database
هكذا سيقوم الطلب الثاني بقراءة نفس الكود PRD-0002 ويولد PRD-0003 مرة أخرى وتحدث نفس المشكلة
لذلك يجب أن يغطي الـ Lock العملية بالكامل من قراءة آخر كود وحتى حفظ المنتج في الـ Database

وهذا رسم توضيحي يوضح المشكلة إذا وضعنا الـ Lock داخل دالة generateProductCode() فقط وليس على العملية بالكامل

    First Request (Laptop Pro)                 Second Request (Tablet X)
      |                                          |
      |  Lock (inside generateProductCode)       |
      |  Read max code -> PRD-0002               |
      |  Generate -> PRD-0003                    |
      |  Lock released                           |
      |                                          |  Lock (inside generateProductCode)
      |  sleep(3)... (saving not done yet)       |  Read max code -> still PRD-0002
      |                                          |  Generate -> PRD-0003 -> duplicate code
      |                                          |  Lock released
      |                                          |
      |                                          |  sleep(3)...
      |  INSERT (PRD-0003) OK                    |
      |                                          |  INSERT (PRD-0003) -> DUPLICATE
      v                                          v
"Product created: PRD-0003"              "Product created: PRD-0003"

توليد أكواد فريدة مع Cache Lock Non-Blocking

الـ Non-Blocking Lock يرفض الطلب الثاني فورًا بدلًا من جعله ينتظر
بمعنى لو الطلب الأول أنشأ الـ Lock في الـ Cache ثم جاء الطلب الثاني وحاول الحصول على نفس الـ Lock في نفس الوقت
فبدلًا من جعله ينتظر سيتم رفضه فورًا لأن هناك Lock بالفعل موجود في الـ Cache

هذه فلسفة الـ Non-Blocking Lock في التعامل مع الـ Race Condition
وهي تختلف عن فلسفة الـ Blocking Lock التي تجعل الطلب الثاني ينتظر حتى يتوفر الـ Lock

في Laravel يمكنك استخدام $lock->get() لترى إذا كان بإمكانك الحصول على الـ Lock أم لا
لو الدالة $lock->get() رجعت true فهذا يعني أنك أول شخص أو أول طلب يحصل على الـ Lock وإذا رجعت false فهذا يعني أن هناك شخص آخر بالفعل يحمل الـ Lock وبالتالي لا يمكنك الحصول عليه

لذا الفكرة ببساطة لتطبيق فلسفة الـ Non-Blocking Lock هي أن نستخدم $lock->get() بدلاً من $lock->block()
وفي حالة حصلنا على false فهذا يعني أن هناك Lock بالفعل موجود في الـ Cache وبالتالي نرمي Exception أو نرجع رسالة خطأ للمستخدم بأن يحاول لاحقًا

public function createProduct(string $name): array
{
    // Generate a lock
    $lock = Cache::lock('generate-product-code', 10);


    // Check if we can acquire the lock or not
    if ($lock->get()) {
        try {
            $code = $this->utilsService->generateProductCode();

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

            $product = Product::create([
                'name' => $name,
                'code' => $code,
            ]);

            return [
                'message' => "Product created: {$code}",
                'success' => true,
                'product' => $product
            ];
        } finally {
            if($lock) {
                $lock->release();
            }
        }
    }

    return [
        'message' => 'Could not create product. Try again later.',
        'success' => false
    ];
}

أظن أن الكود واضح وضوح الشمس
لدينا شرط بسيط هنا يحاول الحصول على الـ Lock باستخدام $lock->get()
لو حصلنا عليه إذا نحن أول من يحصل عليه ونكمل العملية
وإذا لم نحصل عليه فهذا يعني أن هناك شخصًا آخر بالفعل يحمل الـ Lock وبالتالي نعطي رسالة للمستخدم بأن يحاول لاحقًا

    First Request (Laptop Pro)            Second Request (Tablet X)
      |                                     |
      |  $lock->get()                       |  $lock->get()
      |  -> true (Lock acquired)            |  -> false (Lock NOT acquired!)
      |                                     |
      |  SELECT MAX(code)                   |  return "Try again later"
      |  -> PRD-0002                        |
      |  newCode = PRD-0003                 |
      |                                     |
      |  sleep(3)...                        |
      |                                     |
      |  INSERT (Laptop Pro, PRD-0003)      |
      |  $lock->release()                   |
      v                                     v
"Product created: PRD-0003"         "Try again later"

بالتالي الـ Non-Blocking Lock يحل المشكلة بطريقة مختلفة عن الـ Blocking Lock
بحيث أنه يمنع منعًا باتًا أي طلب ثاني من الحصول على الـ Lock إذا كان هناك Lock بالفعل موجود في الـ Cache أ أما الـ Blocking Lock فيسمح للطلب الثاني بالانتظار حتى يتم فك الـ Lock من قبل الطلب الأول

الآن لو نظرنا في الـ Database:

Products Table
+----+------------+----------+
| id | name       | code     |
+----+------------+----------+
| 1  | Laptop     | PRD-0001 |
| 2  | Phone      | PRD-0002 |
| 3  | Laptop Pro | PRD-0003 |
+----+------------+----------+

تم إنشاء منتج واحد فقط بكود PRD-0003 والطلب الثاني تم رفضه فورًا

متى نستخدم الـ Database Lock ومتى نستخدم الـ Cache Lock ؟

بعد أن تعرفنا على الـ Database Lock والـ Cache Lock السؤال الذي يطرح نفسه هو متى نستخدم كل واحد منهما ؟

الـ Database Lock مناسب عندما تكون المشكلة متعلقة بتعديل بيانات موجودة في الـ Database
مثل حجز غرفة موجودة أو تسجيل في دورة موجودة أو تحديث رصيد مستخدم
لأنه يعمل على مستوى الـ Row في الـ Database ويضمن أن لا أحد يقرأ أو يعدل نفس الـ Row أثناء العملية
ويحتاج أن يكون داخل DB::transaction() ويستخدم lockForUpdate()
يوجد بعض أنواع الـ Database Lock الأخرى مثل غير الـ Exclusive Lock مثل Shared Lock الذي يسمح بالقراءة فقط بدون تعديل
وشرحنا أنواع الـ Database Lock في مقالة مبدأ الـ Isolation - أنواع الـ Locks

الـ Cache Lock مناسب عندما تكون العملية أوسع من مجرد تعديل Row في الـ Database
مثل توليد أكواد فريدة أو إرسال بريد إلكتروني أو التواصل مع API خارجي
أو أي عملية تحتاج حماية من التزامن بغض النظر عن نوعها
ويعمل على مستوى الـ Application ولا يحتاج DB::transaction()

ملحوظة: يمكنك استخدام الـ Cache Lock مع الـ Database Lock معًا فكل منهما يعمل في مستوى مختلف
استخدم الـ Database Lock لحماية البيانات على مستوى الـ Database والـ Cache Lock لحماية العمليات على مستوى الـ Application

هناك طرق مختلفة لتحقيق الـ Cache Lock داخل Laravel
وتفاصيل لم أقدر على تغطيتها في هذه المقالة بسبب طولها لكن أظن أن المقالة كانت شاملة ومفيدة في شرح مفهوم الـ Race Condition وكيفية حله باستخدام الـ Database Lock والـ Cache Lock
وهذا ما يهمني وهو أن تفهم الفكرة العامة للمشكلة والحل والمفهوم العام للـ Cache Lock
أما استخدامنا لـ Laravel في الأمثلة فهو فقط لتوضيح الفكرة وليس لتعليمك كيفية استخدام Cache Lock في Laravel بالتفصيل
لأن التفاصيل تختلف من لغة لأخرى ومن Framework لآخر
لكن المفهوم يظل ثابتًا ولا يتغير بغض النظر عن اللغة أو الـ Framework الذي تستخدمه

الخلاصة

في هذه المقالة رأينا كيف يمكن أن تتحول عملية بسيطة جدًا مثل الحجز أو التسجيل أو توليد كود إلى مشكلة حقيقية إذا وصل أكثر من Request في نفس الوقت، ورأينا أن الحل ليس في الـ validation وحدها بل في التحكم في التزامن نفسه

لقد تعلمنا

  • كيف تحدث مشكلة الـ Race Condition عندما تتنافس أكثر من عملية على نفس البيانات
  • كيف يحل الـ Database Lock المشكلة عندما تكون العملية مرتبطة مباشرة بالـ Database
  • كيف يستخدم الـ Cache Lock عندما تكون المشكلة على مستوى أوسع من مجرد Row داخل الجدول
  • لماذا يجب أن يغطي الـ Lock العملية بالكامل وليس جزءًا منها فقط
  • متى يكون الـ Blocking Lock مناسبًا ومتى يكون الـ Non-Blocking Lock أفضل

النقطة الأهم هنا أننا نحاول منع المشاكل التي تحدث عندما يتنافس أكثر من Request في نفس الوقت على نفس البيانات أو نفس العملية
وكل نوع من أنواع الـ Lock له استخداماته ومميزاته وعيوبه
عندما نتعامل مع الـ Horizontal Scaling وهو أننا لدينا أكثر من نسخة أو Replica من التطبيق تعمل في نفس الوقت
في هذه اللحظة يصبح التعامل وتوقع مشكلة الـ Race Condition أمرًا ضروريًا جدًا لضمان سلامة البيانات وسير العمل بشكل صحيح

وهكذا نكون قد فهمنا الفكرة الأساسية وراء الـ Race Condition وكيف نتعامل معها بشكل عملي في Laravel


رسالة خاصة

أرسل ملاحظاتك أو رأيك بشكل خاص — لن يظهر للآخرين

التعليقات

شاركنا رأيك في هذه المقالة أو اسأل عن أي شيء يخصها