مبدأ الـ Isolation - مستويات العزل
السلام عليكم ورحمة الله وبركاته
المقدمة
- مبادئ الـ ACID في إدارة الـ Transactions
- مبدأ الـ Atomicity
- مبدأ الـ Consistency
- مبدأ الـ Isolation - مستويات العزل (أنت هنا)
- مبدأ الـ Isolation - أنواع الـ Locks
- مبدأ الـ Durability
مبدأ الـ 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 نفذت الخطوة
الأولى وأحضرت لاعب من حيث النقاط ولكنها لم تنتهي بعد
بالتالي ترتيب الخطوات كان كالتالي:
- الدالة الأولى قامت بقراءة البيانات وجلب أعلى لاعب من حيث النقاط وكان اللاعب
Ismailبـ500نقطة - فجأة الدالة الثانية بشكل متزامن قامت بتحديث نقاط للاعب
Omarوجعلته1000نقطة - الدالة الأولى قامت بقراءة البيانات وجلب أعلى ثلاث لاعبين من حيث النقاط في
مصر
وهم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
- الدالة الأولى
getTopPlayersقامت بتنفيذ الـQueryالأولى وأحضرت لاعب من حيث النقاط وكان اللاعبIsmailبـ500نقطة
الساعة كان12:00:00 - الدالة الثانية
updatePlayerScoreقامت بتحديث نقاط لاعبOmarوجعلته1000نقطة
الساعة كانت12:00:01 - الدالة الأولى
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 مرة
أخرى ستجد أن هناك لاعب جديد ظهر من العدم
لأن الترتيب كان كالتالي
- الدالة
getHighScoringPlayersقامت بقراءة اللاعبين الذين لديهم نقاط أكبر من200
وأحضرت بينYehiaوOmarوIsmail - الدالة
addPlayerقامت بإضافة لاعب جديد يدعىTamerولديه250نقطة - الدالة
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