مبدأ الـ Isolation - أنواع الـ Locks
السلام عليكم ورحمة الله وبركاته
يمكنك متابعة السلسلة بالترتيب أو الانتقال مباشرة إلى أي مقال:
المقدمة
في مبدأ الـ Isolation - مستويات العزل من شرح مبدأ الـ 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 وكيفية التعامل مع البيانات
ما استخدمنا في المقالة السابقة هو الـ Pessimistic Lock وهو النوع الذي يفترض دائمًا أن هناك احتمالية كبيرة لحدوث تعارض في البيانات، لذلك يقوم بقفل البيانات مباشرة قبل أي عملية ويمنع أي Query أو Transaction أخرى من الوصول إليها باستخدام فكرة الـ Lock
الـ Optimistic Lock
وهو النوع الذي يفترض دائمًا أن هناك احتمالية قليلة لحدوث تعارض في البيانات، لذلك لا يقوم بقفل البيانات مباشرة
بل يتبع نهجًا مختلفًا في التعامل مع البيانات
وهو أن يفحص هل حدث تعارض في البيانات أم لا بعد الانتهاء من عملية التعديل
فإذا حدث تعارض يقوم بمنعها بأي طريقة مناسبة له وإن لم يحدث تعارض يقوم بعمل COMMIT للبيانات
وهذا النوع من الـ Lock مفضل عن شقيقة المتشائم الـ Pessimistic Lock لأنه لا يقوم بقفل البيانات مباشرة
لكن لتطبيقه فهناك عدة طرق وأساليب يمكنك استخدامها لتطبيق الـ Optimistic Lock
منها إضافة حقل جديد في الجدول يسمى version أو timestamp
بناءًا عليه يتحقق هل حدث تغير أم لا أو هل حدث تعارض أم لا
على عكس الـ Pessimistic Lock الذي يقوم بعمل Lock حقيقي على البيانات ويمنع أي Query أخرى من الوصول إليها
تطبيق بسيط على الـ 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 وكيف يمكنك تطبيقه بمثال بسيط
بالطبع يمكننا تجنب حدوث infinite Loop عن طريق وضع حد أقصى لعدد مرات المحاولة
وأيضًا كما تلاحظ فالـ Optimistic Lock يحل مشكلة الـ Lost Update بشكل فعال التي واجهتنا في المقالة السابقة
بدون الحاجة إلى عمل Lock حقيقي على البيانات
لأن الـ Optimistic Lock يعتمد على فكرة التحقق من الـ version أو الـ timestamp لكي يتأكد من عدم حدوث تعارض في البيانات
ويتأكد أن كل Query تقوم بقراءة أحدث نسخة من البيانات
الـ Pessimistic Lock
وهو النوع الذي يفترض دائمًا أن هناك احتمالية كبيرة لحدوث تعارض في البيانات، لذلك
يقوم بقفل البيانات مباشرة قبل أي عملية ويمنع أي Query أو Transaction أخرى من الوصول إليها باستخدام فكرة الـ Lock
كما رأينا في مثال الـ Lost Update في مقالة مبدأ الـ Isolation - مستويات العزل
ولكن عرفنا أن التعامل مع الـ 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 توضح هذا
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لتحقق منه بعد التعديل
- يقوم على فكرة وجود حقل