مبدأ الـ Isolation - أنواع الـ Locks
السلام عليكم ورحمة الله وبركاته
المقدمة
- مبادئ الـ ACID في إدارة الـ Transactions
- مبدأ الـ Atomicity
- مبدأ الـ Consistency
- مبدأ الـ Isolation - مستويات العزل
- مبدأ الـ Isolation - أنواع الـ Locks (أنت هنا)
- مبدأ الـ Durability
في الجزء الأول من شرح مبدأ الـ Isolation تعرفنا على مستويات العزل المختلفة والمشاكل الشائعة مثل الـ Dirty Read والـ Phantom Read والـ Non-Repeatable Read والـ Lost Update
في هذا الجزء سنتعمق في موضوع الـ Lock وأنواعه وكيفية تطبيقه عمليًا
الـ Lock وأنواعها
دعونا نتكلم قليلًا عن الـ Lock وما هي أنواعها
الـ Lock وهو كما يوحي الاسم يقوم بقفل أو تجميد أو تأمين البيانات التي تقوم
بقراءتها ويمنع أي Query أو Transaction أخرى من التعديل عليها
بالتالي لنفترض أن الـ Query الأولى تقوم بعمل تقرير عن نقاط اللاعبين في جدول
الـ Players
هنا ستقوم الـ Query بقفل جدول الـ Players ومنع أي Query أخرى من التعديل أو
الحذف أو الإضافة على هذا الجدول حتى تنتهي العملية
بالتالي ضمنا أن البيانات التي تقرأها الـ Query الأولى ستكون كما هي حتى تنتهي
العملية
وهناك Lock على أكثر من مستوى، يعني يمكنك قفل البيانات
- على مستوى الـ
Databaseكاملة - على مستوى الـ
Tableفقط - على مستوى الـ
Rowفقط - ... وهكذا
وهناك نوعين من طريقة عزل البيانات تندرج تحت مبدأ الـ Isolation وهما الـPessimistic Lock والـ Optimistic Lock
وكلاهما يتبنيان نهجًا مختلفًا في كيفية تنفيذ الـ Lock وكيفية التعامل مع البيانات
الـ Optimistic Lock
وهو النوع الذي يفترض دائمًا أن هناك احتمالية قليلة لحدوث تعارض في البيانات، لذلك
لا يقوم بقفل البيانات مباشرة
بل يتبع نهجًا مختلفًا في التعامل مع البيانات
وهو أن يفحص هل حدث تعارض في البيانات أم لا بعد الانتهاء من عملية التعديل
فإذا حدث تعارض يقوم بمنعها بأي طريقة مناسبة له وإن لم يحدث تعارض يقوم بعملCOMMIT للبيانات
وهذا النوع من الـ Lock مفضل عن شقيقة المتشائم الـ Pessimistic Lock لأنه لا
يقوم بقفل البيانات مباشرة
لكن لتطبيقه فهناك عدة طرق وأساليب يمكنك استخدامها لتطبيق الـ Optimistic Lock
منها أنها يقوم بإضافة حقل جديد في الجدول يسمى version أو timestamp
بناءًا عليه يتحقق هل حدث تغير أم لا أو هل حدث تعارض أم لا
تطبيق بسيط على الـ Optimistic Lock
فعلى سبيل مثال في مثال الطالب Ahmed الذي يملك 100 نقطة ونحتاج لإضافة 100
نقطة له و 200 نقطة له في نفس الوقت ليصبح لديه 400 نقطة
رأينا أن في الـ Pessimistic Lock قام بعمل Lock على الـ Database ومنع الـQuery الثانية من التنفيذ
أما في طريقة الـ Optimistic Lock فقد يكون لدينا حقل جديد في الجدول يسمىversion
Players Table
+----+--------+-------+--------------+---------+
| id | name | score | country_code | version |
+----+--------+-------+--------------+---------+
| 10 | Ahmed | 100 | EG | 1 |
+----+-------+--------+--------------+---------+
بالتالي بفرض أن كلا الدالتين التالية تقوم بتحديث نقاط اللاعب Ahmed في نفس
الوقت
public function updatePlayerScore()
{
$player = Player::where('id', '=', 10)->first();
$player->score += 100;
sleep(3); // محاكاة للتأخير لأي سبب
$player->save();
}
public function addBonusPoints()
{
$player = Player::where('id', '=', 10)->first();
$player->score += 200;
sleep(3); // محاكاة للتأخير لأي سبب
$player->save();
}
هنا بالطبع سنواجه نفس المشكلة أن هناك دالة ستقوم بعمل Override للتعديل الذي
قامت به الدالة الأولى
لحل هذه المشكلة بالـ Pessimistic Lock استخدمنا الـ Transaction لعمل Lock
وعمل Retry مرتين على الأقل
أما في الـ Optimistic Lock لدينا الآن حقل version لكل لاعب
يمكننا بعد كل تعديل في كلا الدالتين أن نقوم بزيادة الـ version بقيمة 1
ثم قبل أن تقوم كلا الدالتين بعمل COMMIT للبيانات يتم التحقق من أخر قيمة للـversion في الـ Database هل مازلت كما هي أم لا
فإذا كانت كما هي فنحن أول من قام بالتعديل ونستطيع عمل COMMIT للبيانات بكل
أمان
أما إذا كانت قد تغيرت فهذا يعني أن هناك Query أخرى سبقتنا في التعديل وعلينا أن
نعيد العملية من جديد لنحصل على النسخة الأخيرة من البيانات
هذا ما يعرف بالـ Optimistic Lock
لنطبقه عمليًا
public function updatePlayerScore()
{
while (true) {
$player = Player::where('id', 10)->first();
$currentVersion = $player->version;
$newScore = $player->score + 100;
// محاولة التحديث بشرط أن تكون النسخة لم تتغير
$updated = Player::where('id', 10)
->where('version', $currentVersion) // تحقق أن الإصدار لم يتغير (أهم خطوة)
->update([
'score' => $newScore,
'version' => $currentVersion + 1,
]);
if ($updated) {
break; // الانتهاء من العملية عند نجاح التحديث
}
// إذا فشل التحديث، نحاول مرة أخرى
}
}
ونفس الشيء للدالة الثانية
ماذا فعلنا هنا ؟
قمنا بعمل While Loop لمحاكاة عملية الـ Retry في الـ Pessimistic Lock لكن
بدون الـ Transaction والـ Lock
لأننا لا نريد عمل Lock بالمعنى الحرفي، نريد عمل فكرة الـ Optimistic Lock
ثم بعد ما حصلنا بيانات اللاعب Ahmed نقوم بحفظ الـ version الحالية له أو الـtimestamp في حالة أنك تستخدم timestamp بدلًا من version
ثم بعد ذلك نقوم بالتعديل الذي نريده
لكن قبل أن نقوم بعمل update للبيانات لكن مع شرطwhere('version', $currentVersion) لضمان أننا نحدث نفس ذات النسخة التي
قرأناها أول مرة
هنا الـ update سيعيد لنا Boolean إذا نجح التحديث أم لا
بالتالي إذا نجح التحديث فهذا يعني أن قيمة الـ version كما هي نفس قيمة الـversion التي بدأنا بها
وهذا يعني أننا أول من قام بالتعديل ونستطيع تحديث البيانات بأمان والخروج من الـWhile Loop
أما إذا فشل التحديث فهذا يعني أن قيمة الـ version قد تغيرت بالتالي هذا
يعني أن هناك Query أخرى قامت بالتعديل قبلنا
لذا سنعيد الـ While Loop من جديد لنحصل على النسخة الأخيرة من البيانات ونعيد
الكرة
هذا مجرد مثال توضيحي بسيط للـ Optimistic Lock وكيف يمكنك تطبيقه بمثال بسيط
الـ Pessimistic Lock
وهو النوع الذي يفترض دائمًا أن هناك احتمالية كبيرة لحدوث تعارض في البيانات، لذلك
يقوم بقفل البيانات مباشرة قبل أي عملية
ويمنع أي Query أو Transaction أخرى من الوصول إليها باستخدام فكرة الـ Lock
كما رأينا في مثال الـ Lost Update
ولكن عرفنا أن التعامل مع الـ Lock يختلف من قاعدة بيانات لأخرى
ففي الـ SQLite تقوم بوضع Lock على الـ Database بشكل تلقائي
أما في الـ MySQL فعليك بوضع Lock على البيانات التي تقوم بتعديلها بشكل يدوي
هنا نحن سنتعرف على كيفية تطبيق الـ Pessimistic Lock في الـ MySQL عند استخدامLaravel
وكيفية وضع Lock على البيانات التي تقوم بتعديلها
هناك نوعين مشهوران من الـ Pessimistic Lock وهما الـ Shared Lock والـExclusive Lock
Exclusive Lock
وهو كما يوحي الاسم يقوم بقفل البيانات بشكل كامل على كل Query ما عدا الـQuery التي فعلت هذا الـ Lock بمعنى أن لا حد يستطيع الوصول إلى البيانات ولا
يستطيع تعديلها أو حذفها أو إضافة بيانات جديدة إلا الـ Query التي فعلت الـExclusive Lock
في Laravel يمكنك تطبيق الـ Exclusive Lock بسهولة .. في الحقيقة لدينا دالة
تدعى lockForUpdate()
وهي تقوم بوضع Exclusive Lock على البيانات التي تقوم بتعديلها
public function updatePlayerScore()
{
DB::transaction(function () {
$player = Player::where('id', '=', 10)->lockForUpdate()->first();
$player->score += 100;
sleep(3); // محاكاة للتأخير لأي سبب
$player->save();
});
}
public function addBonusPoints()
{
DB::transaction(function () {
$player = Player::where('id', '=', 10)->lockForUpdate()->first();
$player->score += 200;
sleep(3); // محاكاة للتأخير لأي سبب
$player->save();
});
}
لاحظ أننا استخدمنا الـ lockForUpdate() في كلا الدالتين
هكذا إذا تم تنفيذ الدالتين في نفس الوقت ستجد أن الدالة الأولى ستقوم بعملExclusive Lock على البيانات
والدالة الثانية عندما تحاول تنفيذ الـ Query ستجد أن البيانات مقفلة ولا تستطيع
تنفيذ الـ Query لذا ستنتظر حتى تزال الـ Lock ولم ترمي أي Exception
ثم عندما تنتهي الدالة الأولى من تنفيذ الـ Query وتقوم بعمل COMMIT للبيانات
سيتم إزالة الـ Lock عن البيانات وتستطيع الدالة الثانية بدأ تنفيذ الـ Query
الخاصة بها
بالتالي النتيجة ستكون أن اللاعب Ahmed سيملك 400 نقطة بعد تنفيذ الدالتين
لاحظ هنا أنه لم يحدث أي Lost Update ولم يحدث أي Override للتعديل الذي قامت
به الدالة الأولى ولم يحدث أي Exception للدالة الثانية
لأن الـ Exclusive Lock قام بقفل البيانات ومنع أي Query أخرى من التنفيذ حتى
من القراءة
بهذا المعنى عندما بدأت الدالة الأولى بتنفيذ الـ Query قامت بقفل البيانات ومنعت
الدالة الثانية من قراءة البيانات
بالتالي على عكس الأمثلة السابقة الدالة الثانية لن تقرأم أي بيانات قديمة ولأنها
لم تقم بالقراءة من الأسا بسبب الـ Exclusive Lock
بالتالي إذا تتبعنا خطوات التنفيذ فسيكون كالتالي
- الدالة الأولى تقوم بتنفيذ الـ
Queryوتقوم بعملExclusive Lockعلى البيانات - الدالة الثانية تحاول تنفيذ الـ
Queryوتجد أن البيانات مقفلة وتنتظر حتى تزال الـLock - الدالة الأولى تقرأ بيانات اللاعب
Ahmedالذي يملك100نقطة وتقوم بإضافة100نقطة له - الدالة الأولى تقوم بعمل
COMMITللبيانات ويتم ازالة الـLockعن البيانات - الدالة الثانية تقوم بتنفيذ الـ
Queryوتقوم بعملExclusive Lockعلى البيانات - الدالة الثانية تقرأ بيانات اللاعب
Ahmedوالذي أصبح يملك200نقطة وتقوم بإضافة200نقطة له - الدالة الثانية تقوم بعمل
COMMITللبيانات ويتم ازالة الـLockعن البيانات - اللاعب
Ahmedيملك400نقطة
هكذا تم حل مشكلة الـ Lost Update عن طريق الـ Exclusive Lock
انتبه هنا
إذا كانت إحدى الدالتين لا تستخدم الـ Exclusive Lock فستحدث مشكلة الـLost Update
حيث الدالة التي لا تستخدم القفل بقراءة البيانات القديمة وإجراء تعديلات كما يحلو
لها مما يؤدي لعمل Override على البيانات وحدوث الـ Lost Update
لكن السؤال لماذا تحدث مشكلة Lost Update ؟ لو أحدى الدالتين لم تستخدم الـExclusive Lock
.. حسنًا .. أنت تعرف أن لا شيء مثالي في هذا العالم
هذا المشكلة لم أجد لها سبب محدد بسبب قلة علمي
لكن بالنسبة للحلول فهناك ثلاث حلول لها:
استخدام الـ Exclusive Lock في كلا الدالتين
لذا تأكد أن جميع العمليات التي تحتاج إلى تعديل نفس البيانات وتشعر أنه قد يحدث
تعارض بينها فعليك استخدام الـ Exclusive Lock
**استخدام الـ Serializable في الـ Transaction**
لكن بالطبع هذا يعتبر حلًا مكلفًا لأنه يقوم يجعل الـ Transaction تعمل بشكل تسلسلي
وهذا يعني أنها ستعمل بشكل بطيء
**استخدام الـ Optimistic Lock**
الحل الأخير هو أن تقوم بتطبيق الـ Optimistic Lock كما شرحناه في الأمثلة
السابقة
وهذه من أحدى الأسباب لتفضيل الـ Optimistic Lock على الـ Pessimistic Lock
لأنه كما قلنا أن الـ Optimistic Lock لا يقوم بقفل البيانات ولا يتعب نفسه في
تنفيذ الـ Lock والـ Retry
بل يقوم بتنفيذ الـ Query بشكل عادي ويتحقق من الـ version أو الـ timestamp
بعد الانتهاء من التعديل
Shared Lock
هذا النوع من الـ Lock يقوم بقفل البيانات ويسمح للـ Query الأخرى بقراءتها
فقط ولكن لا يسمح لها بتعديلها أو حذفها أو إضافة بيانات جديدة
بالتالي الـ Query التي تفعل الـ Shared Lock هي الوحيدة التي تستطيع تعديل
البيانات وحذفها وإضافة بيانات جديدة
أما أي Query أخرى فهي تستطيع فقط قراءة البيانات ولا تستطيع تعديلها أو حذفها أو
إضافة بيانات جديدة حتى يزال الـ Lock
في Laravel يمكنك تطبيق الـ Shared Lock باستخدام دالة تدعى sharedLock()
public function updatePlayerScore()
{
DB::transaction(function () {
$player = Player::where('id', '=', 10)->sharedLock()->first();
$player->score += 100;
sleep(3); // محاكاة للتأخير لأي سبب
$player->save();
});
}
public function addBonusPoints()
{
DB::transaction(function () {
$player = Player::where('id', '=', 10)->sharedLock()->first();
$player->score += 200;
sleep(3); // محاكاة للتأخير لأي سبب
$player->save();
});
}
لقد وضعنا الـ Shared Lock في كلا الدالتين
الآن بحسب خبرتك الطويلة في قراءة هذه المقالة التي لا أشعر أنها ستنتهي قريبًا
إذا تم تنفيذ الدالتين في نفس الوقت ماذا سيحدث ؟
... هيا أظنك تعرف الإجابة بعد هذا الكم الهائل من المعلومات التي قرأتها
حسنًا دعنا نتتبع خطوات التنفيذ
- الدالة الأولى تقوم بتنفيذ الـ
Queryوتقوم بعملShared Lockعلى البيانات - الدالة الأولى تستمر في تنفيذ الـ
Queryوتقرأ بيانات اللاعبAhmedالذي يملك100 - الدالة الثانية تحاول تنفيذ الـ
Queryوتجد أن البيانات مقفلة من قبل الـShared Lock - الدالة الثانية تقرأ بيانات اللاعب
Ahmedالذي يملك100لأن الـShared Lockتسمح بالقراءة فقط - كلا الدالتين تمتلكان نسخة من البيانات القديمة لللاعب
Ahmedالذي يملك100 - الدالة الأولى بما أنها من فعلت الـ
Shared Lockفهي الوحيدة التي تستطيع تعديل البيانات وحذفها وإضافة بيانات جديدة - الدالة الأولى تقوم بإضافة
100نقطة لللاعبAhmedوتقوم بعملCOMMITللبيانات ويتم ازالة الـLock - الدالة الثانية تحاول تعديل البيانات ... لكن لا تستطيع لأنها غير مسموح لها بذلك
لأنها قرأت بيانات كان عليها الـ
Shared Lockبالتالي لا تستطيع تعديلها - الدالة الثانية تقوم برمي
Exceptionأو تقوم بعملRetryللعملية
أظننا واجهنا مثال مشابه لما حدث في الأمثلة السابقة
على أي حال الـ Exception التي حصلت عليها الدالة الثانية هي بسبب أنها لم تستطع
تعديل البيانات لأنها قرأت بيانات كان عليها الـ Shared Lock
ورسالة الـ Exception توضح هذا
Illuminate\Database\QueryException SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction (Connection: mysql, SQL: update `players` set `score` = 300, `players`.`updated_at` = 2025-02-07 19:18:32 where `id` = 10).
لاحظ أنه يقول لك try restarting transaction أي حاول تنفيذ الـ Transaction من
جديد
وهو فكرة الـ Retry التي تحدثنا عنها في الأمثلة السابقة
فهنا نستطيع فعل نفس الشيء باخبار الـ Transaction بأنه يجب عليك تنفيذ وتكرار
العملية عدد مرات معينة نحن نحددها حتى تنجح العملية
مراجعة على أهم النقاط في مبدأ الـ Isolation
- مبدأ الـ
Isolationيقوم بعزل الـTransactionعن أي تغييرات تحدث في البيانات - يقوم بمنع أي
QueryأوTransactionأخرى من الوصول إلى البيانات التي تقوم بتعديلها - يوجد عدة مستويات للعزل منها
Read Uncommitted: يسمح لأيTransactionبقراءة البيانات حتى لو لم يتم عملCOMMITللبياناتRead Committed: يمنع أيTransactionمن قراءة البيانات حتى يتم عملCOMMITللبياناتRepeatable Read: يعزل الـTransactionعن أي تغييرات خارجية تحدث في البياناتSerializable: ينفذ الـTransactionبشكل تسلسلي ويمنع أيTransactionمن التداخل مع الـTransactionالأخرىSnapshot: يقوم بعمل نسخة زمنية من البيانات ويعمل على قراءة هذه النسخة وليس البيانات الحالية
- يوجد مشاكل تحدث في حالة عدم تطبيق مبدأ الـ
IsolationمثلDirty Read: القدرة على قراءة البيانات قبل عملCOMMITلهاNon-Repeatable ReadPhantom ReadLost Update
- يمكنك تطبيق مبدأ الـ
Isolationبعدة طرق منهاPessimistic Lock- له نوعان مهمين وهما
Exclusive LockShared Lock
- له نوعان مهمين وهما
Optimistic Lock- يقوم على فكرة وجود حقل
versionأوtimestampلتحقق منه بعد التعديل
- يقوم على فكرة وجود حقل