مبادئ الـ ACID وعالم الـ Database Transactions

السلام عليكم ورحمة الله وبركاته

وقت القراءة: ≈ 50 دقيقة (بمعدل برميلًا من الشاي وراحة كل فترة 😅)

المقدمة

هذه المقالة ستكون دسمة ومهمة جدًا لكل شخص يريد البدء في عالم قواعد البيانات
وهي المبادئ التي تدور حول الـ Database وهي مبادئ الـ ACID الأربعة
والتي تعتبر من أهم المبادئ التي يجب على كل مطور أن يعرفها ويفهمها جيدًا

وهي كالتالي:

وهذه المبادئ هي التي تقوم عليها فكرة الـ 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 للطالب الجديد وتربطه بالدورات التي اختارها أثناء التسجيل

وفي هذه الدالة نقوم بعمل الخطوات التالية:

  1. تقوم بالتحقق من البيانات المرسلة من الطالب
  2. تقوم بإنشاء الطالب في قاعدة البيانات
  3. تقوم بتعين رتبة المستخدم كـ STUDENT
  4. تقوم بربط الطالب بالدورات التي اختارها
  5. تقوم بإنشاء Token للطالب
  6. تقوم بإرجاع رسالة نجاح مع الـ 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 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

لنفترض أن هناك 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 نفذت الخطوة الأولى وأحضرت لاعب من حيث النقاط ولكنها لم تنتهي بعد

بالتالي ترتيب الخطوات كان كالتالي:

  1. الدالة الأولى قامت بقراءة البيانات وجلب أعلى لاعب من حيث النقاط وكان اللاعب Ismail بـ 500 نقطة
  2. فجأة الدالة الثانية بشكل متزامن قامت بتحديث نقاط للاعب Omar وجعلته 1000 نقطة
  3. الدالة الأولى قامت بقراءة البيانات وجلب أعلى ثلاث لاعبين من حيث النقاط في مصر
    وهم 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

  1. الدالة الأولى getTopPlayers قامت بتنفيذ الـ Query الأولى وأحضرت لاعب من حيث النقاط وكان اللاعب Ismail بـ 500 نقطة
    الساعة كان 12:00:00
  2. الدالة الثانية updatePlayerScore قامت بتحديث نقاط لاعب Omar وجعلته 1000 نقطة
    الساعة كانت 12:00:01
  3. الدالة الأولى 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 مرة أخرى ستجد أن هناك لاعب جديد ظهر من العدم

لأن الترتيب كان كالتالي

  1. الدالة getHighScoringPlayers قامت بقراءة اللاعبين الذين لديهم نقاط أكبر من 200
    وأحضرت بين Yehia و Omar و Ismail
  2. الدالة addPlayer قامت بإضافة لاعب جديد يدعى Tamer ولديه 250 نقطة
  3. الدالة 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 على أكثر من مستوى، يعني يمكنك قفل البيانات

وهناك نوعين من طريقة عزل البيانات تندرج تحت مبدأ الـ 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

بالتالي إذا تتبعنا خطوات التنفيذ فسيكون كالتالي

هكذا تم حل مشكلة الـ 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 في كلا الدالتين

الآن بحسب خبرتك الطويلة في قراءة هذه المقالة التي لا أشعر أنها ستنتهي قريبًا
إذا تم تنفيذ الدالتين في نفس الوقت ماذا سيحدث ؟

... هيا أظنك تعرف الإجابة بعد هذا الكم الهائل من المعلومات التي قرأتها
حسنًا دعنا نتتبع خطوات التنفيذ

أظننا واجهنا مثال مشابه لما حدث في الأمثلة السابقة
على أي حال الـ 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

مبدأ الـ 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 أخرى من الوصول إلى البيانات التي تقوم بتعديلها
ويحتوى على عدة مستويات للعزل منها

يوجد مشاكل تحدث في حالة عدم تطبيق مبدأ الـ Isolation مثل

وأيضًا يمكنك تطبيق مبدأ الـ Isolation بعدة طرق منها

ثم أنهينا بمبدأ الـ Durability والذي يضمن لك أن البيانات ستكون موجودة ولن تفقد حتى في حالة حدوث عطل أو انقطاع مفاجئ في النظام
والذي يمكنك أن نخترصه بأنك عندما تقول لي أنك قمت بعمل COMMIT بأنك تضمن لي أن البيانات قد تم حفظها بمعنى أن الراجل يلتزم بكلمته
والمبدأ مثله مثل الـ Consistency يعتبر مبدأ نظري ويخص بناء أنظمة قواعد البيانات مثل MySQL و PostgreSQL وغيرها
ولكن يمكنك تحقيقه بنفسك عن طريق توفير نوع من الـ Backup لقاعدة البيانات الخاصة بك

أتمنى أن تكون قد استفدت من هذه المقالة وأن تكون قد فهمت كل شيء بشكل جيد