مشكلة الـ 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
التعليقات
شاركنا رأيك في هذه المقالة أو اسأل عن أي شيء يخصها