مبادئ الـ ACID وعالم الـ Database Transactions
السلام عليكم ورحمة الله وبركاته
الفهرس
المقدمة
هذه المقالة ستكون دسمة ومهمة جدًا لكل شخص يريد البدء في عالم قواعد البيانات
وهي المبادئ التي تدور حول الـ Database
وهي مبادئ الـ ACID
الأربعة
والتي تعتبر من أهم المبادئ التي يجب على كل مطور أن يعرفها ويفهمها جيدًا
وهي كالتالي:
- Atomicity
- Consistency
- Isolation
- Durability
وهذه المبادئ هي التي تقوم عليها فكرة الـ Database Transactions
وهي تساعدنا على تنفيذ العمليات بشكل آمن ومضمون ودقيق
لكن ما هي الـ Transactions
؟
كيف يمكننا أن نشرح ونتعلم مبادئ شئ نحن نجهله من الأساس
لذا قبل أن نبدأ في شرح كل مبدأ على حدة سنتعرف على ما هي الـ Transactions
أولًا
ما هي الـ Transactions ؟
الـ Transactions
هي مجموعة من العمليات التي تتم بشكل متسلسل ومتتابع
والعمليات نقصد بها أي Query
تقوم بتنفيذها على قاعدة البيانات
فمثلًا إذا قمت بإضافة مستخدم جديد إلى قاعدة البيانات ثم قمت بتعديل شيء ما وقمت في عملية أخرى لتحذف شيء معين
INSERT INTO users (name, email) VALUES ('Ahmed', 'eltabaraniahmed@gmail');
UPDATE users SET name = 'Mohamed' WHERE id = 1;
DELETE FROM users WHERE id = 2;
كل عملية منفصلة هنا تسمى Query
والعمليات الثلاثة مجتمعة نسميها Transaction
إذا نستطيع أن نقول أن الـ Transaction
هي مجموعة من الـ Query
التي تتم بشكل
متسلسل ومتتابع وغالبًا ما تكون مرتبطة ببعضها البعض وتحتاج لتنفيذها بترتيب معين
ومبادئ الـ ACID
هي مبادئ وضعت لجعل الـ Transactions
تنفذ بشكل صحيح دون أي
مشاكل
في معظم الـ SQL
لكي تضع مجموعة من الـ Query
في Transaction
تقوم بكتابة
BEGIN
ثم تبدأ بكتابة مجموعة الـ Query
الخاصة بك ثم تنهي بـ COMMIT
وكلمة COMMIT
تعني نهاية الـ Transaction
وبالتالي سيتم تطبيق كل الـ Query
التي كتبتها داخل الـ Transaction
على قاعدة البيانات
وإذا حدث أي خطأ في أي Query
خلال الـ Transaction
سيتم إلغاء الـ
Transaction
بشكل تلقائي
وعملية إلغاء الـ Transaction
تعرف بـ ROLLBACK
أي تراجع عن كل ما سبق تنفيذه
ليس كل العمليات التي تنفذ بشكل متسلسل ومتتابع تعتبر Transaction
عليك أن تضعها داخل Transaction
لكي تكون Transaction
وتتمتع بكل خصائص الـ
ACID
وكل لغة SQL
تقدم لك طريقة لكتابة Transaction
وتنفيذها وسنرى هذا تاليًا
لنفترض أن لدينا جدول يسمى Accounts
وجدول آخر يسمى Histories
ونريد تنفيذ عملية تحويل مبلغ من حساب إلى حساب آخر على سبيل المثال
ونريد تنفيذ هذه العمليات داخل Transaction
لذا يمكننا كتابتها كالتالي
UPDATE Accounts SET balance = balance - 100 WHERE user_id = 1;
INSERT INTO Histories (user_id, amount) VALUES (1, -100);
UPDATE Accounts SET balance = balance + 100 WHERE user_id = 2;
INSERT INTO Histories (user_id, amount) VALUES (2, 100);
هكذا تخيل معي أنه تم تنفيذ الـ Query
الأولى بنجاح والثانية بنجاح لكن عندما
وصلنا للـ Query
الثالثة حدث خطأ ما
في هذه الحالة تم سحب المال من المستخدم رقم 1
وتم تسجيل العملية في جدول
Histories
**ولكن لم يتم إضافة المال للمستخدم رقم 2
**
وهذه مشكلة كبيرة
لكن لو كنا وضعنا هذه العمليات داخل Transaction
فإن النتيجة ستكون مختلفة
BEGIN;
UPDATE Accounts SET balance = balance - 100 WHERE user_id = 1;
INSERT INTO Histories (user_id, amount) VALUES (1, -100);
UPDATE Accounts SET balance = balance + 100 WHERE user_id = 2;
INSERT INTO Histories (user_id, amount) VALUES (2, 100);
COMMIT;
نفس الكود السابق ولكن هذه المرة قمنا بوضعه داخل Transaction
عن طريق كتابة
BEGIN
و COMMIT
وهذا يعني أنه إذا حدث أي خطأ في أي Query
خلال الـ Transaction
سيتم التراجع
عن كل ما تم تنفيذه
بالتالي لو تم تنفيذ الـ Query
الأولى والثانية بنجاح ولكن حدث خطأ في الـ
Query
الثالثة فسيتم التراجع عن كل شيء تم تنفيذه ولن يتم تطبيق أي شيء على
قاعدة البيانات
لذا الـ Transactions
تساعدنا عن تفادي مشاكل وكوارث محتملة قد تحدث في قاعدة
البيانات
وفي الحقيقة ما شرحته لك للتو يتضمن بعض فوائد وخلاصة فوائد مبادئ الـ ACID
لأن الـ Transaction
كما قلنا يحقق لك هذه المبادئ لأنه تم بناءه عليها وبالتالي
هو يتبع هذه المبادئ بشكل تلقائي
وهذا ما يجعل الـ Transactions
تعمل بشكل صحيح وبدون أي مشاكل
لكن يبقى السؤال ما هي مبادئ الـ ACID
بالتحديد ؟
أظن أننا قد تكلمنا عن الـ Transactions
وكيف هي تحقق بمبادئ الـ ACID
ولكن لم
نتكلم عن كل مبدأ على حدة
ولا أريدك أن تظن أن الـ Transactions
هي مجرد مجموعة من الـ Query
لا أكثر ولا
أقل
لذا سنتعرف على كل مبدأ على حدة ونتعرف على فائدته وما الذي يقدمه في الـ
Transaction
وكيف يساعدنا على تفادي المشاكل
حسنًا كما قلنا فأن هناك أربعة مبادئ أساسية للـ Transactions
وهي مبادئ الـ
ACID
وهذه المبادئ هي التي تجعل الـ Transactions
تعمل بشكل صحيح بدون أي مشاكل
لذا لكي نفهم الـ Transactions
جيدًا علينا أن نتعرف على هذه المبادئ التي بنيت
عليها
مبدأ الـ Atomicity
هذا المبدأ يضمن لك أن الـ Transaction
بأكملها يجب أن تنفذ كوحدة واحدة
بمعنى أنه إما تنفذها كلها أو أن لا تنفذ شيئا منها أي لو كانت الـ
Transaction
تحتوي على 10
من الـ Query
فمبدأ الـ Atomicity
يقول لك يا تنفذ كل الـ Query
التي بداخل الـ
Transaction
أي تنفذ الـ 10
كلها أو لا تنفذ أي واحدة منهم
يا أما الـ Transaction
كلها تنجح أو يا أما كلها تفشل
بالتالي لو حصل أي مشكلة في الـ Transaction
ولو خطأ بسيط في أي Query
فإن الـ
Transaction
كلها ستفشل وتتم إلغاؤها ويتم التراجع عن كل ما تم تنفيذه
وهو ما يسمى بـ ROLLBACK
أي التراجع عن العمليات وكأن شيئًا لم يحدث
ولو كل الـ Query
تم تنفيذها بنجاح ففي هذه الحالة فقط سيتم تطبيق الـ
Transaction
بنجاح ويتم تطبيق كل الـ Query
على قاعدة البيانات وهو ما يسمى بـ
COMMIT
أحب أن أتذكر هذا المبدأ بعبارة يا نموت كلنا يا نعيش كلنا وهذا يختصر المبدأ
بحيث الـ Transaction
كلها تنجح أو تفشل
لنأخذ المثال السابق حيث لدينا جدول يسمى Accounts
وجدول آخر يسمى Histories
ونريد تنفيذ العمليات التالية داخل Transaction
BEGIN;
UPDATE Accounts SET balance = balance - 100 WHERE user_id = 1;
INSERT INTO Histories (user_id, amount) VALUES (1, -100);
UPDATE Accounts SET balance = balance + 100 WHERE user_id = 2;
INSERT INTO Histories (user_id, amount) VALUES (2, 100);
COMMIT;
في هذا المثال قلنا أنه لو حدث أي خطأ في أي Query
فإن الـ Transaction
كلها
ستفشل ويتم التراجع عن كل ما تم تنفيذه
مثل ما قلنا فمثلا لنتخل أنه تم تنفيذ الـ Query
الأولى بنجاح لكن حدث خطأ في الـ
Query
الثانية
هنا وفقًا لمبدأ الـ Atomicity
فإن الـ Transaction
يجب أن يتم التراجع عن كل ما
تم تنفيذه فيها أي يتم تنفيذ ROLLBACK
بالتالي الـ Query
الأولى ستتم التراجع عنها وكأنها لم تحدث ولن يتم تطبيق أي شيء
على قاعدة البيانات
وإذا تم تنفيذ كل الـ Query
بنجاح فإن الـ Transaction
ستنجح ويتم
تطبيق كل الـ Query
على قاعدة البيانات وهذا ما يسمى بـ COMMIT
وهذا هو ببساطة مبدأ الـ Atomicity
يضمن لك أن الـ Transaction
ستنجح بشكل كامل
أو ستفشل بشكل كامل
وهو يعد أهم مبدأ في الـ ACID
وهو الذي يعطي الهوية والفائدة الكبيرة للـ
Transactions
تطبيق بسيط لفائدة الـ Atomicity
سأقوم بشرح مثال بسيط لفائدة الـ Atomicity
باستخدام الـ ORM
الخاص بـ
Laravel
لكي أقرب لك شيء من الواقع التي قد تواجهه بشكل يومي
لأنك غالبًا ما ستستخدم ORM
في مشروعك وليس الـ SQL
بشكل مباشر
لنفترض أننا لدينا دالة تقوم بعمل تسجيل لطالب جديد في موقع تعليمي
الطالب يسجل ويختار الدورات التي يريد الاشتراك بها
ونريد أن نقوم بإنشاء الطالب في قاعدة البيانات ونربطه بالدورات التي اختارها
public function signup(Request $request)
{
// 1. Validate the input
$request->validate([
'name' => 'required|string',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8',
'course_ids' => 'required|array|min:1',
'course_ids.*' => 'required|integer|exists:courses,id',
]);
// 2. Create the user into the database
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
// 3. Assign student role to the user
$user->assignRole(Role::STUDENT);
// 4. Attach the courses to the user
$user->attachCourses($request->course_ids);
// 5. Generate a token for the user
$token = $user->createAccessToken();
// 6. Return a success response
return response()->json([
'message' => 'You have been registered successfully',
'token' => $token,
'user' => $user,
], 201);
}
في هذا المثال لدينا دالة تقوم بعمل Signup
للطالب الجديد وتربطه بالدورات التي
اختارها أثناء التسجيل
وفي هذه الدالة نقوم بعمل الخطوات التالية:
- تقوم بالتحقق من البيانات المرسلة من الطالب
- تقوم بإنشاء الطالب في قاعدة البيانات
- تقوم بتعين رتبة المستخدم كـ
STUDENT
- تقوم بربط الطالب بالدورات التي اختارها
- تقوم بإنشاء
Token
للطالب - تقوم بإرجاع رسالة نجاح مع الـ
Token
وبيانات الطالب
على أي حال الدالة تقوم بعمل Signup
كما ترى وكل شيء يبدو على ما يرام ... صحيح
؟
أظنك تعرف الإجابة ... ستقول أن هناك مشكلة قد تحدث .. أو بعض المشاكل التي قد تحدث
وهذه المشكلة هى أن دالة الـ Signup
تحتوي على العديد من الخطوات والعمليات
وهناك احتمال وارد أن تفشل أحد العمليات أو تحدث خطأ في أحد الخطوات
فتخيل معين أنه تم تخزين بيانات الطالب بنجاح في قاعدة البيانات
وتم تعين رتبته كـ STUDENT
بنجاح
ولكن حدث مشكلة ما في الخطوة الثالثة أثناء ربط الطالب بالدورات التي اختارها لأي
سبب كان مثلًا لديك Exception
بسبب معين أيًا ما كان
هنا من وجه نظر الطالب سيجد أنه بعد ما حاول التسجيل جاءه Exception
فتوهم أنه لم
يتم تسجيله بنجاح ولكن في الحقيقة لقد تم تسجيله بنجاح ولكن حدث خطأ في الخطوة
الثالثة فقط
بالتالي عندما يحاول التسجيل مرة أخرى سيجد أنه لا يستطيع لأن البريد الإلكتروني
موجود بالفعل في قاعدة البيانات
لذا سحاول عمل login
لكن سيتفاجيء أن الدورات التي اختارها لم تتم إضافتها له،
لأنها لم يتم تسجيلها اثناء الـ signup
بسبب الـ Exception
التي حدثت
بالتالي أصبح لديك مشكلة كبيرة جدًا وهي أنك لديك طالب تم تسجيله في قاعدة البيانات لكن ببيانات ناقصة وبدون الدورات التي اختارها
هنا يأتي دور مبدأ الـ Atomicity
وهو أنك ستضع دالة الـ Signup
كلها داخل Transaction
بحيث أنه عندما تنتهي الدالة من تنفيذ كل محتواها دون مشاكل فقط في هذه الحالة سيتم
تطبيق وتنفيذ كل شيء في الـ Database
وإذا حدث أي خطأ في أي جزء من الدالة فسيتم التراجع عن كل ما تم تنفيذه ولن يتم
تطبيق أي شيء على الـ Database
public function signup(Request $request)
{
$request->validate([
'name' => 'required|string',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:6',
'course_ids' => 'required|array|min:1',
'course_ids.*' => 'required|integer|exists:courses,id',
]);
// Wrap the main logic inside a transaction
DB::transaction(function () use ($request) {
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
$user->assignRole(Role::STUDENT);
$user->attachCourses($request->course_ids);
});
$token = $user->createAccessToken();
return response()->json([
'message' => 'You have been registered successfully',
'token' => $token,
'user' => $user,
], 201);
}
في Laravel
يمكنك بسهولة وضع الدالة داخل Transaction
بوضعها داخل
DB::transaction
الـ ORM
التي تتبنى Laravel
تقوم بتبسيط كل شيء لك وتقوم بتنفيذ الـ
Transaction
بشكل تلقائي
لكن لاحظ شيء هنا، نحن لم نضع الدالة كلها داخل الـ Transaction
بل قمنا بوضع
الجزء الرئيسي من الدالة داخل الـ Transaction
نحن نضع الأجزاء المتعلقة بالـ Database
بمعنى أن عملية التحقق من بيانات
المستخدم لا تأثر على الـ Database
فسواء حصل Exception
فلا يوجد شيء تم تخزينه أو تعديله أو حذفه في الـ Database
نحتاج للتراجع عنه
لذا ستلاحظ أننا وضعنا فقط الأجزاء الرئيسية التي نقوم فيها بإنشاء المستخدم
وتعيين له الرتبة وربطه بالدورات داخل الـ Transaction
هذه هى الأجزاء المركزية والمهمة والتي تؤثر على الـ Database
أما الأجزاء الأخرى مثل التحقق من البيانات وإنشاء الـ Token
وإرجاع الرسالة
فهذه الأجزاء لا تؤثر على الـ Database
ولذا لا داعي لوضعها داخل الـ
Transaction
في Laravel
يوجد طريقتين لوضع الـ Transaction
الطريقة الأولى هى استخدام DB::transaction
مثل ما قمنا به في المثال السابق
DB::transaction(function () {
// اكتب كودك الجميل هنا
});
الطريقة الثانية هي استخدام DB::beginTransaction
و DB::commit
و
DB::rollback
DB::beginTransaction();
try {
// اكتب كودك الجميل هنا
DB::commit();
} catch (Exception $e) {
DB::rollback();
}
نحن في هذه المقال والشرح سنستخدم الطريقة الأولى
مثال آخر لفائدة الـ Atomicity
لنأخذ مثال آخر لفائدة الـ Atomicity
وهو مثال كبير قليلًا
لنفترض أن هناك شخص يقوم برفع منتج معين وعرضه للبيع على موقع معين
وهذا الموقع لديه دالة تسمى uploadProduct
تقوم برفع المنتج وعرضه للبيع
public function uploadProduct(Request $request)
{
// 1. Validate the input
$request->validate([
'name' => 'required|string',
'price' => 'required|numeric',
'quantity' => 'required|integer',
'last_date_to_sell' => 'required|date',
'hand_made' => 'required|boolean',
]);
// 2. Create the product into the database
$product = Product::create([
'name' => $request->name,
'price' => $request->price,
'quantity' => $request->quantity,
'last_date_to_sell' => $request->last_date_to_sell,
'hand_made' => $request->hand_made,
]);
// 3. Calculate the score of the product
$statistic = StatisticService::getProductsStatistic();
$score = ScoreService::calculate($product, $statistic);
// 4. Update the product score board
ProductsScoreBoard::create([
'product_id' => $product->id,
'statistic_id' => $statistic->id,
'score' => $score,
]);
// 5. Attach reviewers to the product
ReviewerService::attachReviewers($product);
// 6. Send notification to the admins
NotificationService::sendProductUploadedNotification($product);
// 7. Return a success response
return response()->json([
'message' => 'The product has been uploaded successfully',
], 201);
}
حسنًا أمعن النظر في الدالة السابقة وتأمل فيها جيدًا وقل لي ما هي المشكلة التي قد تحدث هنا ؟
مشكلة.. اا .. أعني ما المشكلات التي قد تحدث هنا ؟
ماذا لو حدث خطأ في حساب الـ Score
؟
أي أن الدالة ScoreService::calculate($product);
حدث بها خطأ ما وألقت
Exception
في هذه الحالة المنتج قد تم إنشاؤه بالفعل ولكن الـ Request
لم يكتمل بشكل كامل
وبالتالي لم يتم حساب الـ Score
وهكذا سيكون لديك منتج في قاعدة البيانات لكن ليس
لديه Score
**برغم من أنه قد حصل Exception
**
وبالتالي لن يتم ربط المراجعين للمنتج ولن يتم إرسال أي إشعارات أو تنبيهات
للمشرفين
أو ماذا لو الخطأ حدث في دالة attachReviewers
؟
أو في دالة sendProductUploadedNotification
؟
وهكذا من الأمور التي قد تحدث وتؤدي إلى عدم استكمال العملية بشكل كامل أو بشكل
صحيح ومتوقع
وهذه مشكلة كبيرة، لأنه من ناحية المستخدم سيجد أنه قد قام برفع المنتج لكنه استقبل
Exception
وظن واعتقد أن المنتج لم يتم رفعه
لكن في الحقيقة المنتج قد تم رفعه بالفعل وتم تسجيله لكن لم يتم حساب الـ Score
أو ربط المراجعين أو إرسال الإشعارات بسبب الـ Exception
ومن من ناحية التطبيق أو الـ Server
سيكون لديه منتج مرفوع ليس لديه Score
وغير
مربوط بأي من المراجعين ولم يتم إرسال أي إشعارات للمشرفين حتى
هنا يأتي دور مبدأ الـ Atomicity
المستخدم في الـ Transactions
وهو أنك يجب أن تضع كل العمليات التي تريد تنفيذها داخل Transaction
بالتالي إذا حدث أي خطأ في أي جزء من الـ Transaction
فسيتم التراجع عن كل ما تم
تنفيذه ولن يتم تطبيق أي شيء على الـ Database
public function uploadProduct(Request $request)
{
$request->validate([
'name' => 'required|string',
'price' => 'required|numeric',
'quantity' => 'required|integer',
'last_date_to_sell' => 'required|date',
'hand_made' => 'required|boolean',
]);
// Wrap the main logic inside a transaction
DB::transaction(function () use ($request) {
$product = Product::create([
'name' => $request->name,
'price' => $request->price,
'quantity' => $request->quantity,
'last_date_to_sell' => $request->last_date_to_sell,
'hand_made' => $request->hand_made,
]);
$statistic = StatisticService::getProductsStatistic();
$score = ScoreService::calculate($product, $statistic);
ProductsScoreBoard::create([
'product_id' => $product->id,
'statistic_id' => $statistic->id,
'score' => $score,
]);
ReviewerService::attachReviewers($product);
NotificationService::sendProductUploadedNotification($product);
});
return response()->json([
'message' => 'The product has been uploaded successfully',
], 201);
}
هنا قمنا بوضع الأجزاء المهمة والتي تتعامل مع الـ Database
داخل الـ
Transaction
وهي من لحظة إنشاء المنتج وحتى حساب الـ Score
وتحديث المنتج بالـ Score
الجديد
لكي نضم أنه إذا حدث أي خطأ في أي جزء من الـ Transaction
فسيتم التراجع عن كل ما
تم تنفيذه ولن يتم تطبيق أي شيء على الـ Database
وهو ما نسميه بـ ROLLBACK
وهذا ببساطة مبدأ الـ Atomicity
أرجو أن تكون قد فهمته بشكل جيد
مبدأ الـ Consistency
مبدأ الـ Consistency
هو المبدأ الذي يضمن لك أن الـ Transaction
بعد تنفيذها
ستجعل البيانات في الـ Database
متناسقة
أي البيانات ستحفظ بشكل صحيح وسليم داخل الـ Database
ولن يقوم الـ Transaction
بافسادها أو التلاعب بها
في الحقيقة مبدأ الـ Consistency
يعني الكثير من الأشياء منها أن الـ
Transaction
يجب أن تحترم القواعد والقيود التي تم وضعها على الـ Database
بمعنى أن أي Constraint
سواء كان Primary Key
أو Foreign Key
أو Unique
قيمة Default
أو أي قيد آخر يجب أن تحترمه الـ Transaction
ولا تخالفه
مبدأ الـ Consistency
أيضًا يعني أنه لا يجب أن يكون هناك أي تعارض بين البيانات
فمثلًا لنتخيل أن هناك شخص يدعى Ahmed
ولديه ID
رقم 10
وقام هذا الشخص بشراء
منتج يدعى Laptop
ولديه ID
رقم 20
لذا قمنا بتسجيل هذه العملية في جدول يسمى Orders
وقلنا أنه تم شراء المنتج
Laptop
الـ ID
صاحب الـ 20
من قبل Ahmed
صاحب الـ ID
رقم 10
Users Table
+----+-------+
| id | name |
+----+-------+
| 10 | Ahmed |
+----+-------+
Products Table
+----+--------+
| id | name |
+----+--------+
| 20 | Laptop |
+----+--------+
Orders Table
+----+---------+------------+------------+------------+
| id | user_id | product_id | sold_at | arrived_at |
+----+---------+------------+------------+------------+
| 30 | 10 | 20 | 2024-11-01 | 2024-11-05 |
+----+---------+------------+------------+------------+
هكذا البيانات تبدو متناسقة وسليمة ولا توجد بها أي تعارض
وما ليس بالمتناسق هو أن يتم تسجيل البيانات بشكل خاطئ فمثلًا
نسجل البيانات بشكل خاطئ ونقول أن الـ product_id
هو 2
بينما في الحقيقة هو
1
أو ننسبه للـ user_id
مختلف أو حتى نكتب تاريخ العملية بشكل خاطئ فنقول أنه تم
شراء المنتج في 2024-10-01
بينما في الحقيقة هو في 2024-11-01
أو نقول أنه تم تسليم المنتج في الماضي أي قبل تاريخ الشراء حتى وهذا يعتبر تعارض
وخطأ
مثال أخر قد يكون لديك منتجات تم حذفها من قبل المستخدمين ولكن لا تزال موجودة في
قاعدة البيانات كـ foreign key
ويتم استخدامها في العمليات والحسابات الأخرى على الرغم من أنها تم حذفها
أو قد يكون لديك بيانات متكررة سواء تكرار في الـ Primary Key
أو في الـ Unique
وهكذا من الأمور التي تجعل البيانات غير متناسقة وغير صحيحة
وهذا ما يحاول مبدأ الـ Consistency
أن يحميك منه
هو ضمان أن البيانات والعمليات التي بداخل الـ Transaction
سيتم تنفيذها وتخزينها
بشكل صحيح
عن طريق الـ Constraints
والقيود التي تم وضعها على الـ Database
وعن طريق
التحقق من البيانات والعمليات قبل تنفيذها
المبدأ شامل أي شيء قد يؤدي إلى تعارض البيانات أو تخزينها بشكل خاطئ
أذكر مثال أنني كنت أحدث صورتي الشخصية في أحد المواقع
وبعد تحديث الصورة قامت الصفحة بعرض الصورة الجديدة
لكن الغريب أن تطبيق الهاتف الخاص بالموقع ظل يعرض الصورة القديمة لعدة أيام
وصورتي القديمة كانت عند بعض الأشخاص والصورة الجديدة تظهر عند البعض الآخر
وهكذا ... هذا أيضًا يدخل ضمن مبدأ الـ Consistency
وفي هذه الحالة يزجد عند تناسق بين بيانات الشخص عبر المنصات المختلفة والأجهزة
المختلفة
لعل السبب قد يكون Caching
أو CDN
أو أن بياناتي تحدثت في Database
في أماكن
ومناطق مختلفة ولم تتم مزامنتها بشكل صحيح
أو تأخرت عملية التحديث في بعض الأماكن ولم تتم بشكل سريع وهكذا
لكن هذا مبدأ الـ Consistency
بشكل عام، نحن هنا نتكلم عن الـ Consistency
في
الـ Transaction
الواحدة
وهو أن الـ Transaction
يجب أن تحترم القيود والقواعد التي تم وضعها على الـ
Database
ولا تخالفها
وأن تحدث البيانات بشكل صحيح ومتناسق ولا توجد بها أي تعارضات أو تناقضات
تطبيق بسيط لفائدة الـ Consistency
لنتخيل أن لدينا جدول للـ Products
وجدول آخر للـ Categories
وجدول آخر للـ
ProductCategories
بحيث أن هناك أكثر من Product
لديه أو ينتمي إلى أكثر من Category
وهذا العلاقة
تسمى Many to Many
لذا سيتم تخزينها في جدول الـ ProductCategories
ونتخيل أن الـ Database
تحتوي على البيانات التالية
Products Table
+----+--------+
| id | name |
+----+--------+
| 10 | Laptop |
| 20 | Mouse |
| 30 | Chair |
+----+--------+
Categories Table
+-----+--------+
| id | name |
+-----+--------+
| 100 | Tech |
| 200 | Home |
+-----+--------+
ProductCategories Table
+----+------------+-------------+
| id | product_id | category_id |
+----+------------+-------------+
| 1 | 10 | 100 |
| 3 | 20 | 100 |
| 2 | 30 | 100 |
| 4 | 30 | 200 |
+----+------------+-------------+
الآن لنفترض أننا نريد إضافة منتج جديد ونريد أن ينتمي إلى الفئة Tech
ونريد أن نتأكد من أن الـ Product
سيتم تخزينه بشكل صحيح وأنه سينتمي إلى الفئة
Tech
وأيضا سيتم إضافتهما في جدول الـ ProductCategories
public function addProduct(Request $request)
{
$request->validate([
'name' => 'required|string',
'category_id' => 'required|integer|exists:categories,id',
]);
Product::create([
'name' => $request->name,
]);
return response()->json([
'message' => 'The product has been added successfully',
], 201);
}
حسنًا هذه الدالة تقوم بإضافة منتج جديد وتقوم بتخزينه في جدول الـ Products
أين المشكلة هنا ؟ .. هل الدالة تنفذ المطلوب ؟
الإجابة هي لا
لأن الدالة لا تقوم بتخزين الـ Product
في جدول الـ ProductCategories
بالتالي لن يتم تخزين العلاقة بين الـ Product
والـ Category
وهذا لا يعد
Consistency
انتظر هذا خطأ بشري ؟
حسنًا ومن قال أن الـ Consistency
سيحميك من الأخطاء البشرية ؟
الـ Consistency
مبدأ يهتم بالبيانات والعمليات التي تخزن في الـ Database
لكن هناك أمور لن يستطيع مبدأ الـ Consistency
أو حتى الـ ACID
حمايتك منها
وهي هل أنت كتبت الدالة بشكل صحيح من الأساس ؟ الـ Consistency
لن يقول لك
لقد نسيت كتابة الجزء الخاص بالـ ProductCategories
مبدأ الـ Consistency
ينظر إلى تطبيقك بشكل عام ويقول لك هل البيانات التي تم
تخزينها في الـ Database
متناسقة وصحيحة أم لا
ففي بعض الحالات يكون الـ Consistency
مجرد كلام نظري وليس عملي مثل الحالة
السابقة يجب على الدالة أن تنفذ المطلوب بشكل صحيح
لكن ماذا لو نسيت شيء هل سيحميك الـ Consistency
؟ بالطبع لا فهو لن يكتب الدالة
بدلاً عنك
هناك حدود لكل شيء والـ Consistency
لن يحميك من الأخطاء البشرية
الـ Consistency
قد يحميك وينبهك من الأخطاء التي قد تحدث بسبب الـ Database
أو
الـ Transaction
ولكن ليس من الأخطاء البشرية
الآن لنقم بتحسين الدالة السابقة ونضع الجزء الخاص بالـ ProductCategories
public function addProduct(Request $request)
{
$request->validate([
'name' => 'required|string',
'category_id' => 'required|integer|exists:categories,id',
]);
$product = Product::create([
'name' => $request->name,
]);
ProductCategory::create([
'product_id' => $product->id,
'category_id' => $request->category_id,
]);
return response()->json([
'message' => 'The product has been added successfully',
], 201);
}
حسنًا الآن الدالة تقوم بإضافة المنتج وتقوم بإضافته إلى الفئة المحددة وتخزينه في
جدول الـ ProductCategories
هل قد يحدث خطأ هنا ؟
الإجابة هي نعم لكن أين وكيف ؟
ماذا لو كان الـ Product
لكن عندما حاول تخزينه في الـ ProductCategories
حدث
خطأ ما ؟ حصل Database Exception
أو Server Error
أو أي شيء آخر
في هذه الحالة الـ Product
قد تم تخزينه في الـ Products
ولكن لم يتم تخزينه في
الـ ProductCategories
هنا أيضًا الـ Database
لن تكون متناسقة أو تتبع مبدأ الـ Consistency
لأنها بها
خلل بحيث الـ Product
لديه Category
ولكن لم يتم تخزينه في الـ
ProductCategories
ماذا نفعل الآن ؟ كيف نتجنب هذا الخطأ ؟
نحتاج شيء يضمن لنا أنه إذا حدث خطأ في أي جزء من الـ Transaction
فسيتم التراجع
عن كل شيء تم تنفيذه ولن يتم تطبيق أي شيء على الـ Database
... نعم نحتاج إلى مبدأ الـ Atomicity
.. وكيف نحقق ذلك ؟ .. بالطبع بوضع الدالة
داخل Transaction
public function addProduct(Request $request)
{
$request->validate([
'name' => 'required|string',
'category_id' => 'required|integer|exists:categories,id',
]);
DB::transaction(function () use ($request) {
$product = Product::create([
'name' => $request->name,
]);
ProductCategory::create([
'product_id' => $product->id,
'category_id' => $request->category_id,
]);
});
return response()->json([
'message' => 'The product has been added successfully',
], 201);
}
الآن الدالة تقوم بإضافة المنتج وتقوم بإضافته إلى الفئة المحددة وتخزينه في جدول
الـ ProductCategories
الدالة من الناحية العملية تبدو جيدة وتقوم بكل ما نريد
وأيضًا لاحظ أننا وضعنا الدالة داخل Transaction
هكذا سيقوم مبدأ الـ
Consistency
بجزءه العملي وهو ضمان أن البيانات تتبع الـ Constraints
والقيود
الموجودة على الـ Database
وأيضًا ضمننا معنا مبدأ الـ Atomicity
وهو ساعدنا في حال حدوث Exception
ونحتاج
للتراجع عن كل شيء تم تنفيذه
بالتالي مبدأ الـ Atomicity
ساعدنا في تحقيق مبدأ الـ Consistency
مبدأ الـ Consistency
مبدأ عام وأحيانًا قد يكون مجرد كلام نظري وليس عملي كثيرًا
لكن ما يهمنا أن الـ Transaction
تم بناءها على بعض المبادئ ومن ضمنها مبدأ الـ
Consistency
ليضمن عدم تلف البيانات أو تخزينها بشكل خاطئ
عن طريق احترام الـ Constraints
والقيود الموجودة على الـ Database
والتحقق من
البيانات قبل تنفيذها
مثل التحقق من الـ Foreign Key
والـ Unique
والـ Primary Key
وغيرها
لكن يوجد حدود عملية خارجة عن نطاق الـ Consistency
وهي الأخطاء البشرية والتي لا
يمكن للـ Consistency
حمايتك منها
لكنها تخالف مبدأ الـ Consistency
لأنك كمبرمج نفذت الدالة بشكل خاطئ من الأساس
وليس بسبب الـ Database
مبدأ الـ Isolation
مبدأ الـ 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
الأخيرة لم يكن صحيحًا
هذه المشكلة تسمى Dirty Read
وهي تحدث عندما تقوم Query
بقراءة بيانات قد يتم
تعديلها أو حذفها من قبل Query
أخرى في نفس الوقت
بالتالي عندما تقوم بقراءتها مرة أخرى ستجد أن البيانات قد تغيرت ولم تعد كما كانت
وهنا يأتي دور مبدأ الـ Isolation
لحل هذه المشكلة
فمبدأ الـ Isolation
يضمن لك أن كل Query
تعمل بشكل منفصل عن الأخرى ولا تتداخل
معها ولا تؤثر عليها
عن طريق تقديم حلول لحل هذه المشكلة والمشاكل المشابهة، ومن ضمن هذه الحلول مفهوم
الـ Lock
وهو يعني مثل ما يوحي الاسم أنه يقوم بقفل أو تجميد أو تأمين البيانات التي تقوم
بقراءتها ويمنع أي Query
أو Transaction
أخرى من التعديل عليها
بالتالي لنفترض أن الـ Query
الأولى تقوم بعمل تقرير عن نقاط اللاعبين في جدول
الـ Players
هنا ستقوم الـ Query
بقفل جدول الـ Players
ومنع أي Query
أخرى من التعديل أو
الحذف أو الإضافة على هذا الجدول حتى تنتهي العملية
بالتالي ضمنا أن البيانات التي تقرأها الـ Query
الأولى ستكون كما هي حتى تنتهي
العملية
مستويات العزل الخاصة بالـ 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
يعد هذا المستوى من أفضل المستويات للعزل وأكثرها تكلفة وأبطأها لأنه كأنه يقوم بعمل صورة زمنية للبيانات لكلTransaction
- هذا المستوى يعتمد على فكرة أنه يقوم بعمل
معظم قواعد البيانات تستخدم الـ Read Committed
أو الـ Repeatable Read
كمستويات افتراضية
لكن بالطبع يمكنك تغييرها واختيار مستوى الـ Isolation
الذي تريده حسب الحاجة
بعض المفاهيم بجانب الـ Isolation
هناك بعض المفاهيم التي تأتي بجانب مبدأ الـ Isolation
وهي متعلقة بالـ Read
والـ Lock
والـ Isolation Level
سأذكرها مع إعطاء مثال عملي عليها لتأخذ فكرة عنها
Dirty Read
هذه المشكلة تحدث عندما تقوم Query
بقراءة بيانات معينة
وفي نفس الوقت هناك Query
أخرى في نفس الوقت قامت بعمل تعديل على هذه البيانات
ولكن لم تقم بعمل COMMIT
لها بعد
بالتالي عندما تقوم Query
الأولى بقراءة هذه البيانات ستجدها قد تغيرت ولم تعد
كما كانت
لأنها قرأت بيانات قد تم تعديلها ولم تقم بعمل COMMIT
لها بعد
مثًلا يوجد Query
أضافت بعض البيانات ولكن لم تقم بعمل COMMIT
لها
ثم جائت Query
أخرى وقامت بقراءة هذه البيانات، في هذه اللحظة هذه القراءة تسمى
Dirty Read
لأن هذه البيانات لم تتم تأكيدها بعد
ويمكن في أي لحظة لا يتم عمل COMMIT
لها وتتم عملية ROLLBACK
وبالتالي البيانات
التي قرأتها لم تعد لها وجود وكأنها لم تكن
بالتالي الـ Dirty Read
تحدث عندما تقوم بقراءة بيانات قد تم تعديلها ولم تقم
بعمل COMMIT
لها بعد
وقد تتم عمل تعديلات أخرى عليها أو عمل ROLLBACK
لها
لنأخذ مثالًا عمليًا
ملحوظة: هذه المشكلة تحدث عندما تكون مستوى الـ
Isolation
هوRead Uncommitted
وجميع قواعد البيانات لا تجعل هذا المستوى هو المستوى الافتراضي للـIsolation
لذا لن تقابل هذه المشكلة في الحالات العادية
في هذا المثال سنفترض أن مستوى الـIsolation
هوRead Uncommitted
لغرض التوضيح فقط
لذا لسبب ما سنفترض أنك جعلت مستوى الـ Isolation
هو Read Uncommitted
DB::statement('SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED');
لكن أؤكد لك أن هذا ليس تصرفًا حكيمًا
الآن لنفترض أننا لدينا البيانات التالية
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 |
+----+-------+--------+--------------+
لنفترض أنك تريد عمل تقرير يومي يحتوي على شيئين وهما
- عرض اسم أعلى لاعب من حيث النقاط
- عرض أعلى ثلاث لاعبين من حيث النقاط في مصر فقط
وهذا التقرير يعمل بشكل تلقائي كل يوم في الساعة 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
لها
وهنا يأتي دور مبدأ الـ Isolation
لحل هذه المشكلة
لكن بالطبع المشكلة تحدث بسببين السبب الأول هو أن مستوى الـ Isolation
هو
Read Uncommitted
السبب الثاني أننا لم نقم بوضع الدالة داخل Transaction
لذا لحل هذه المشكلة يجب عليناأولًا وضع الدالة داخل 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
لاعبين وهكذا
لنأخذ مثالًا عمليًا
لدينا نفس الجدول السابق
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
لكننا نريد أن تكون الدالة 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
أخرى
لنأخذ مثالًا عمليًا
لنفترض أنك تريد عرض نقاط اللاعب صاحب الـ 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
أخرى
وهنا الحل كالعادة سيكون بوضع الدالة داخل 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
أخرى
لنأخذ مثالًا عمليًا
حسنًا ...في الحقيقة لا داعي لأخذ أي مثال عملي هنا
لأن كل ما في الأمر عندما تكون مستوى الـ Isolation
هو Repeatable Read
ويكون
الكود داخل 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
الأولى
لنأخذ مثالًا عمليًا
لنفترض أن لدينا نفس الجدول السابق
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
نقطة
هنا أظنك تضع ابتسامة على وجهك وستقول لي الآن أن الحل سيكون بوضع الدالتين داخل
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
وهذا ما سنتحدث عنه في القسم القادم وما هي أنواع الـ Lock
وكيفية تطبيقها
وسنعرف كيف نحل نفس المشكلة السابقة إذا كنت تستخدم MySQL
لكن أولًا لنرى الجدول التالي
الذي يوضح مستويات الـ Isolation
ومشاكل التي تحدث في كل مستوى
Isolation Level | Dirty Read | Lost Update | Non-Repeatable Read | Phantom Read |
---|---|---|---|---|
Read Uncommitted | ❌ | ❌ | ❌ | ❌ |
Read Committed | ✅ | ❌ | ❌ | ❌ |
Repeatable Read | ✅ | ✅ | ✅ | ❌ |
Serializable | ✅ | ✅ | ✅ | ✅ |
Snapshot | ✅ | ✅ | ✅ | ✅ |
علامة ✅
تعني أمان وأن هذه المشكلة لن تحدث في هذا المستوى علامة ❌
تعني
أن هذه المشكلة قد تحدث في هذا المستوى
الـ Lock وأنواعها
دعونا نتكلم قليلًا عن الـ Lock
وما هي أنواعها
الـ Lock
وهو كما يوحي الاسم يقوم بقفل البيانات ويمنع أي Query
أو
Transaction
أخرى من الوصول إليها
وكما رأينا أن الـ Transaction
به نوعًا من الـ Lock
يقوم بقفل الـ Database
ويمنع أي Query
أو Transaction
أخرى من الوصول إليها
لأنه يحقق مبدأ الـ Isolation
أي انعزالية الـ Transaction
عن أي تغييرات تحدث
في البيانات
وهناك 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 Read
Phantom Read
Lost Update
- يمكنك تطبيق مبدأ الـ
Isolation
بعدة طرق منهاPessimistic Lock
- له نوعان مهمين وهما
Exclusive Lock
Shared Lock
- له نوعان مهمين وهما
Optimistic Lock
- يقوم على فكرة وجود حقل
version
أوtimestamp
لتحقق منه بعد التعديل
- يقوم على فكرة وجود حقل
مبدأ الـ Durability
حسنًا لقد وصلنا لأخر مبدأ في الـ ACID
وهو مبدأ الـ Durability
لا تقلق لن أطيل عليك سيكون خفيفًا ونظريًا مثل مبدأ الـ Consistency
لأن مبدأ الـ Durability
هو مبدأ موجه للأشخاص الذين يقومون بتطوير الـ
Database Management System
مثل MySQL
أو PostgreSQL
أو MongoDB
وكيف يحققون هذا المبدأ في قواعدهم
لكن يمكنك تحقيقه بنفسك عن طريق توفير نوع من الـ Backup
لقاعدة البيانات الخاصة
بك
مبدأ Durability
هو آخر مبدأ من مبادئ الـ ACID
، وهو يضمن أن جميع العمليات
التي تمت داخل Transaction
لن تُفقد حتى في حالة حدوث عطل أو انقطاع مفاجئ في
النظام
بمعنى آخر، عند تنفيذ Transaction
بنجاح ووصولها إلى مرحلة COMMIT
، فإن جميع
التعديلات على قاعدة البيانات يجب أن تُحفظ فعلًا في الـ Database
لأنك عندما تقول لي أنك قمت بعمل COMMIT
بنجاح فهذا يعني أنك فعلًا قمت بتنفيذ الـ
Transaction
بنجاح وقمت بحفظ البيانات في الـ Database
لا تقول لي لقد تم العملية بنجاح وبعد لحظات يحدث انقطاع في الكهرباء أو أن الـ
Server
تعطل
ثم نفاجيء بأن البيانات لم يتم حفظها فعليًا برغم من أنك قمت بعمل COMMIT
وجاءتك
رسالة أن العملية تمت بنجاح
هذا ما يعنيه مبدأ الـ Durability
أنك عندما تقول لي أنك قمت بعمل COMMIT
بأنك
تضمن لي أن البيانات قد تم حفظها
ولو حدث أي كارثة فإن البيانات ستكون موجودة ولن تفقد
كيف يعمل مبدأ Durability ؟
كل أنظمة قواعد البيانات سواء كانت MySQL
أو PostgreSQL
أو MongoDB
تحاول
تحقيق مبدأ Durability
بطرق مختلفة
بمعنى أن تكون تخزن البيانات في الذاكرة العشوائية RAM
وبعد ما تقوم بعمل
COMMIT
تقوم بنقل البيانات من الـ RAM
إلى الـ Database
وهذا غير مضمون للـ Durability
لأن طالما أن البيانات والتعديل في الـ RAM
فسوف
تفقدها في حالة حدوث أي عطل
الأسلوب الأخر هو أن تخزنها في الـ Hard Disk
مباشرة ثم بعد الـ COMMIT
تقوم
بنقلها إلى الـ Database
لذا في حالة حدوث أي عطل فإن البيانات ستكون موجودة في الـ Hard Disk
ويمكنك
استعادتها
يمكننا تحقيق مبدأ الـ Durability
بعدة تقنيات وأساليب، منها:
Write-Ahead Logging
تسجل جميع العمليات في ملف Log File
في الـ Hard Disk
قبل تنفيذها أو نقلها إلى
الـ Database
مما يتيح استعادة البيانات في حال حدوث أي خطأ
يعد الأكثر شيوعًا واستخدامًا في تحقيق مبدأ الـ Durability
وطريقة عمله ببساطة هي أنه يقوم بتسجيل جميع العمليات التي تمت داخل الـ
Transaction
في ملف Log
قبل تنفيذها
ثم في حالة الـ COMMIT
يقوم بنقل البيانات من الـ Log
إلى الـ Database
في حالة حدوث أي عطل فإنه يمكنك استعادة البيانات من الـ Log
شكل الملف يكون أشبه بملفات الـ Migration
الخاصة بالـ Database
التي تحدثنا
عنها في مقالة
ما هي الـ Migration وما علاقتها بالـ Database ؟
[2025-02-17 10:00:00] TXN-ID: 101 | BEGIN
[2025-02-17 10:00:01] TXN-ID: 101 | UPDATE users SET balance = 500 WHERE id = 5
[2025-02-17 10:00:02] TXN-ID: 101 | COMMIT
[2025-02-17 10:05:00] TXN-ID: 102 | BEGIN
[2025-02-17 10:05:01] TXN-ID: 102 | INSERT INTO orders (product, price) VALUES ('Laptop', 1500)
[2025-02-17 10:05:02] TXN-ID: 102 | COMMIT
هذا مثال بسيط على كيفية تسجيل العمليات في ملف Log
وبالطبع هذا شكل تخيلي لأن الـ Log
يحتوي على العديد من المعلومات الأخرى ويختلف
شكل الـ Log
من قاعدة بيانات لأخرى
وبالطبع هذا الملف الصغير يحتوي على العديد من العمليات والـ Transaction
التي
تمت في الـ Database
بالتالي في حالة حدوث أي عطل يمكنك استعادة البيانات من هذا الملف عن طريق تنفيذ
العمليات التي تمت فيه بالترتيب
Asynchronous Snapshots
يقوم بعمل نسخة احتياطية من الـ Database
بشكل دوري ومتكرر لضمان عدم فقدان
البيانات في حالة الأعطال
بالتالي عند حدوث أي عطل يمكنك استعادة البيانات من أحدث نسخة احتياطية والأمر يكون
اشبه بالـ Backup
فلو افترضنا أن السيرفر تعطل في الساعة 3:00
وآخر نسخة احتياطية Snapshot
كانت
في الساعة 2:30
فبديهيًا أنك يمكنك استعادة البيانات من هذه النسخة الاحتياطية فقط بالتالي ستفقد
البيانات التي تمت بعد الساعة 2:30
في Redis
تستخدم هذه التقنية مع الـ Append Only Files
لتحقيق مبدأ الـ
Durability
وبالطبع قد تجمع بعض الـ Database Management System
بين Write-Ahead Logging
وAsynchronous Snapshots
لتحقيق الـ Durability
أيضًا
Append Only Files
مثل الـ WAL
لكنه يستخدم في Redis
وما يشبها لتسجيل تسلسل العمليات التي تم
تنفيذها
في الحقيقة الـ Append Only Files
تم تصميمه ليناسب Redis
وشبيهاتها
أعني هنا أنظمة الـ In-Memory Database
تستخدم هذه التقنية لتحقيق مبدأ الـ
Durability
عن طريق مزامنة التغيرات التي تحدث في الـ Database
مع الـ Append Only Files
ختامًا
هذا كان شرحنا لمبادئ الـ ACID
وكيفية تطبيقها في الـ Database
وكيفية تحقيق كل مبدأ من هذه المبادئ وكيفية تطبيقها عمليًا
أعرف أنني أطلت عليكم لكن كنت أريد أن أشرح كل شيء بشكل عملي مع أمثلة واقعية على
قدر المستطاع
وحلولها بإستخدام Laravel
وبالطبع شرحت باستخدام الـ ORM
الذي يقدمه لأنني أردت
التركيز على الأمثلة العملية التي ستقابلها في العمل
وأنت في الغالب ستتعامل مع الـ Database
عن طريق الـ ORM
وليس الـ SQL
بشكل
مباشر
ملخص المقالة
أولًا تكلمنا عن الـ Transaction
وكيف أنها عبارة عن مجموعة من الـ Query
التي
تعمل معًا وتنفذ معًا
وكيف أنها تحقق لك المباديء الأربعة للـ ACID
بما في ذلك Atomicity
وConsistency
وIsolation
وDurability
وتضمن لك أن الـ Transaction
ستنفذ بنجاح وستحفظ البيانات بشكل صحيح
ثم انتقلنا لمبدأ الـ Atomicity
والذي يمكننا أن نختصره بجملة واحدة وهي أن الـ
Transaction
يجب أن تنفذ بالكامل أو لا تنفذ على الإطلاق
بالتالي إذا حدث أي خطأ في الـ Transaction
فإنه يجب عليك إلغاء كل التعديلات
التي تمت ROLLBACK
وإعادة البيانات إلى حالتها الأولى وكأن شيء لم يحدث
ثم تحدثنا عن مبدأ الـ Consistency
وهو مفهوم نظري واسع جدًا والذي يضمن لك أن
البيانات ستكون دائمًا في حالة صحيحة ومتسقة
ويضمن أن الـ Transaction
تلتزم بالـ Constraints
الذي وضعتها على البيانات ولا
تخالفها
بالتالي ستحترم على سبيل المثال الـ Foreign Key
والـ Unique Key
والـ Check
والـ Default
والـ Not Null
وغيرها
ومفهوم الـ Consistency
مفهوم واسع ويتطرق للعديد من الأمور سواء على مستوى
الأخطاء البشرية أو الأمور التي تحدث في الـ Database Distribution System
وهل
هناك تناقضات بين البيانات في الـ Database
المختلفة الموزعة في السيرفرات
المختلفة
ثم تحدثنا عن مبدأ الـ Isolation
وهو مبدأ يحدد لك كيفية تعامل الـ Transaction
مع البيانات وكيفية عزل الـ 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 Read
Phantom Read
Lost Update
وأيضًا يمكنك تطبيق مبدأ الـ Isolation
بعدة طرق منها
Pessimistic Lock
- له نوعان مهمين وهما
Exclusive Lock
Shared Lock
- له نوعان مهمين وهما
Optimistic Lock
- يقوم على فكرة وجود حقل
version
أوtimestamp
لتحقق منه بعد التعديل
- يقوم على فكرة وجود حقل
ثم أنهينا بمبدأ الـ Durability
والذي يضمن لك أن البيانات ستكون موجودة ولن تفقد
حتى في حالة حدوث عطل أو انقطاع مفاجئ في النظام
والذي يمكنك أن نخترصه بأنك عندما تقول لي أنك قمت بعمل COMMIT
بأنك تضمن لي أن
البيانات قد تم حفظها بمعنى أن الراجل يلتزم بكلمته
والمبدأ مثله مثل الـ Consistency
يعتبر مبدأ نظري ويخص بناء أنظمة قواعد
البيانات مثل MySQL
و PostgreSQL
وغيرها
ولكن يمكنك تحقيقه بنفسك عن طريق توفير نوع من الـ Backup
لقاعدة البيانات الخاصة
بك
أتمنى أن تكون قد استفدت من هذه المقالة وأن تكون قد فهمت كل شيء بشكل جيد