انتقال للمقال
وقت القراءة: ≈ 20 دقيقة (بمعدل فنجان واحد من القهوة 😊)

مفهوم الـ Idempotency Key ومثال عملي عليه

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

المقدمة

في هذه المقالة سنتحدث عن مفهوم الـ Idempotency Key وكيفية استخدامه لحل مشكلة مشهورة في عالم البرمجة وهي مشكلة الـ Duplicate Submissions أو تكرار الطلبات وهي مشكلة تحدث عندما يصل نفس الـ Request إلى الـ Server أكثر من مرة بسبب إعادة المحاولة أو ضغط الزر أكثر من مرة أو أي سبب آخر يؤدي إلى تكرار نفس الطلب

مشكلة الـ Duplicate Submissions قد تعد نوع من أنواع الـ Race Condition بحيث أن نفس الطلب يصل مرتين فكأنه يتسابق مع نفسه
في مقالة مشكلة الـ Race Condition وكيف نتعامل معها تحدثنا عن مشكلة الـ Race Condition وكيفية التعامل معها باستخدام الـ Cache Lock
وتطرقنا لمثال عن الـ Duplicate Submissions كنوع من أنواع الـ Race Condition
وتم حل المشكلة باستخدام الـ Cache Lock لمنع تنفيذ نفس العملية في نفس اللحظة

لكن هناك بعض الحالات للـ Duplicate Submissions لا يمكن حلها بشكل كامل باستخدام الـ Cache Lock فقط
فمثلًا مدير مبيعات يستخدم تطبيق لإدارة العملاء ويضغط على زر إضافة عملية بيع جديدة مثلًا لقد تم بيع 100 وحدة من منتج معين
لكن بسبب بطء الإنترنت أو تأخر الـ Server يضغط على زر الإضافة عدة مرات ظنًا منه أن الطلب لم يصل
ولنفترض أنه ضغط على الزر 3 مرات في نفس اللحظة
لكن بسبب بطء الإنترنت كل طلب وصل إلى الـ Server بعد تأخير 5 ثواني بمعنى أن الطلب الأول وصل في الثانية 5 والثاني في الثانية 10 والثالث في الثانية 15
هنا حتى لو استخدمنا Cache Lock لمنع تنفيذ نفس العملية في نفس اللحظة
فإن الطلب الأول سينفذ ويضيف 100 وحدة إلى المخزون
ثم بعد 5 ثواني يأتي الطلب الثاني ويجد أن الـ Lock غير موجود لأن الطلب الأول انتهى
فينفذ أيضًا ويضيف 100 وحدة أخرى إلى المخزون
ثم بعد 5 ثواني يأتي الطلب الثالث ويجد أن الـ Lock غير موجود أيضًا فينفذ ويضيف 100 وحدة أخرى إلى المخزون
في النهاية تم إضافة 300 وحدة إلى المخزون بدلًا من 100 وحدة فقط وهذا خطأ واضح

فهذه مشكلة الـ Duplicate Submissions لا يمكن حلها باستخدام الـ Cache Lock لأن الطلبات تصل في أوقات مختلفة وليس في نفس اللحظة بسبب تأخر الإنترنت أو تأخر الـ Server

لذا هناك حاجة إلى حل آخر لحماية النظام من تكرار نفس الطلبات حتى لو وصلت في أوقات مختلفة
وهي باستخدام الـ Idempotency Key
وهذا ما سنتحدث عنه في هذه المقالة بالتفصيل


أنصحك بقراءة مقالة مشكلة الـ Race Condition وكيف نتعامل معها لأن الـ Duplicate Submissions تعد نوع من أنواع الـ Race Condition وحلها سيكون باستخدام الـ Cache Lock مع الـ Idempotency Key كما سنشرح في هذه المقالة

ما هي مشكلة الـ Duplicate Submissions ؟

الـ Duplicate Submissions وهي كما يوحي الاسم الطلبات المكررة
تحدث عندما يصل نفس الـ Request إلى الـ Server أكثر من مرة بسبب أن الـ Client أرسل نفس الطلب مرتين لسبب ما
قد يكون السبب أن المستخدم ضغط على زر الإرسال أكثر من مرة لأنه لم يحصل على رد سريع من الـ Server فظن أن الطلب لم يصل فأعاد المحاولة
أو قد يكون السبب أن الـ Client لديه retry logic يقوم بإعادة إرسال الطلب تلقائيًا إذا لم يحصل على رد خلال فترة معينة
أو أن هناك شخص شرير يحاول استغلال ضعف في النظام وإرسال نفس الطلب مرات عديدة لإحداث ضرر أو استغلال ثغرة معينة

الفكرة أن نفس الطلب قد يصل أكثر من مرة لأسباب مختلفة في أوقات مختلفة
فكما رأينا في المثال السابق قد يصل نفس الطلب بعد 5 ثواني أو 10 ثواني أو حتى بعد ساعة أو يوم
نفس الطلب تكرر مرات عديدة لعدة احتمالات مختلفة
مثل أن الإنترنت كان بطيء كما قلنا
أو أن طلب كان ينفذ في Job أو Queue فحصل تكرار بسبب مشكلة معينة في الكود أو في الـ Server تحتاج لإصلاح
أو حصلت مشكلة في الـ Server المسؤول عن تنفيذ الـ Job أو الـ Queue تؤدي لتكرار نفس الـ Job أو الـ Queue أكثر من مرة

أسباب كثيرة ومتنوعة تؤدي إلى تكرار نفس الطلبات
وكلها تؤدي إلى نفس المشكلة وهي أن نفس العملية تنفذ أكثر من مرة مما يسبب نتائج غير صحيحة أو خسائر مالية أو مشاكل في البيانات أو حتى مشاكل أمنية في بعض الحالات
وحتى الـ Lock لا يستطيع حل هذه الحالات لأن الـ Lock لا يمنع تكرار نفس الطلب إذا وصل في أوقات مختلفة
لذا نحتاج إلى الـ Idempotency Key لأن الـ Idempotency Key يمنع تكرار نفس الطلب بغض النظر عن توقيته
وهذا هو الفرق الأساسي بين الـ Lock والـ Idempotency Key

لكن لحل مشكلة الـ Duplicate Submissions بشكل كامل نحتاج إلى دمج الـ Idempotency Key مع الـ Lock
لأن الـ Lock يحمي من تكرار نفس الطلب في نفس اللحظة
والـ Idempotency Key يحمي من تكرار نفس الطلب في أوقات مختلفة

في هذه المقالة سنشرح مفهوم الـ Idempotency Key بشكل مفصل
ثم نطبق أيضًا Lock مع الـ Idempotency Key لحل مشكلة الـ Duplicate Submissions بشكل كامل

مثال عملي على مشكلة الـ Duplicate Submissions

لنقم بتبسيط الأمور لكي نقوم بتطبيق مفهوم الـ Idempotency Key بشكل عملي
لنأخذ مثال بسيط جدًا لمشكلة الـ Duplicate Submissions
تخيل أن لديك تطبيق تجاري وقام المستخدم Ahmed بالضغط على زر ادفع الآن لإتمام عملية شراء بقيمة 500 جنيه
لكن الإنترنت كان بطيء ولم تصله أي استجابة من الـ Server
فضغط على الزر مرة ثانية ليتأكد أن الطلب وصل

ولنتخيل أن الفرق بين وصول الطلب الأول والثاني هو 5 ثواني فقط
في هذه الحالة حتى لو كان الـ Server يستخدم Cache Lock لمنع تنفيذ نفس العملية في نفس اللحظة
فإن الطلب الأول سينفذ ويخصم 500 جنيه من رصيد Ahmed
ثم بعد 5 ثواني يأتي الطلب الثاني ويجد أن الـ Lock غير موجود لأن الطلب الأول انتهى
فينفذ أيضًا ويخصم 500 جنيه أخرى من رصيد Ahmed

هنا سيتفاجأ Ahmed بأنه تم خصم 1000 جنيه من حسابه بدلًا من 500 جنيه
وأنه حصل على نفس المنتج مرتين أو دفع فاتورة المنتج مرتين
هنا تكمن خطورة مشكلة الـ Duplicate Submissions بحيث الـ Client يعتقد أنه قام بعملية واحدة فقط
لكن في الحقيقة تم تنفيذ العملية مرتين بسبب تكرار الطلبات
وهنا الـ Lock لا يستطيع حل المشكلة لأن الطلبات وصلت في أوقات مختلفة وليس في نفس اللحظة


لنبدأ بتجهيز مثال عملي بسيط لشرح المشكلة بشكل واضح
هنا سنستخدم Laravel كمثال لتوضيح الفكرة لكن المفهوم نفسه ينطبق على أي لغة أو Framework آخر

الآن لنفترض أن لدينا جدول users يحتوي على المستخدمين وجدول payments يسجل كل عمليات الدفع

Users Table
+----+-------+---------+
| id | name  | balance |
+----+-------+---------+
| 1  | Ahmed | 5000.00 |
+----+-------+---------+

Payments Table
+----+---------+--------+-----------+---------------------+
| id | user_id | amount | status    | created_at          |
+----+---------+--------+-----------+---------------------+
| 1  | 1       | 200.00 | completed | 2025-01-10 10:00:00 |
+----+---------+--------+-----------+---------------------+

هنا لدي Ahmed ورصيده 5000 جنيه
وهناك سجل واحد في جدول payments يوضح أنه دفع 200 جنيه في عملية سابقة

ولدينا دالة بسيطة تقوم بعملية الدفع لمستخدم معين

public function processPayment(User $user, float $amount)
{
    if (!$user->hasEnoughBalance($amount)) {
        return response()->json([
            'message' => 'Insufficient balance',
            'success' => false,
        ], 422);
    }

    sleep(3); // محاكاة للتأخير لأي سبب

    $user->decrement('balance', $amount);

    Payment::create([
        'user_id' => $user->id,
        'amount'  => $amount,
        'status'  => PaymentStatus::COMPLETED,
    ]);

    return response()->json([
        'message' => 'Payment successful',
        'success' => true,
    ]);
}

الدالة تستقبل المستخدم والمبلغ وتتحقق من أن رصيده يكفي لدفع المبلغ
إذا كان الرصيد غير كافي ترجع رسالة خطأ
وإذا كان الرصيد كافيًا تخصم المبلغ وتسجل عملية الدفع في الـ Database
وبالطبع كالعادة وضعنا sleep(3) لمحاكاة تأخير في العملية لأي سبب كان

أين المشكلة ؟

لو قام Ahmed بالضغط على زر الدفع مرتين في نفس الوقت أو أرسل نفس الطلب مرتين لأي سبب
وحدثت تأخيرات في وصول الطلبات كما في المثال السابق
فستحدث الخطوات التالية

    Request 1
      |
      |  Check balance
      |  -> 5000.00 >= 500.00
      |
      |       sleep(3)...
      |
      |  decrement balance by 500
      |  INSERT payment (500.00)
      |
      |
      v
"Payment successful"

      .
      .
After 5 seconds delay (due to slow internet or server bug/issue)
      .
      .

    Request 2
      |
      |  Check balance
      |  -> 5000.00 >= 500.00
      |
      |       sleep(3)...
      |
      |
      |
      |  decrement balance by 500
      |  INSERT payment (500.00) <- duplicate
      v
"Payment successful"

الطلب الأول يقرأ الرصيد ويجده 5000 وهو يكفي لدفع 500 جنيه
لذا يتم خصم 500 جنيه من رصيد Ahmed ويصبح رصيده 4500 جنيه

الطلب الثاني يصل بعد 5 ثواني بسبب بطء الإنترنت أو تأخر الـ Server
ويقرأ الرصيد مرة أخرى ويجده 4500 جنيه وهو أيضًا يكفي لدفع 500 جنيه
لذا يتم خصم 500 جنيه أخرى من رصيد Ahmed ويصبح رصيده 4000 جنيه

لذا في النهاية تم خصم 1000 جنيه من رصيد Ahmed بدلًا من 500 جنيه فقط

// Request 1
{ "message": "Payment successful", "success": true }

// Request 2 (after 5 seconds delay)
{ "message": "Payment successful", "success": true }

الآن لنرى جدول الـ users وجدول الـ payments بعد تنفيذ الطلبين

Users Table
+----+-------+---------+
| id | name  | balance |
+----+-------+---------+
| 1  | Ahmed | 4000.00 |
+----+-------+---------+

Payments Table
+----+---------+--------+-----------+---------------------+
| id | user_id | amount | status    | created_at          |
+----+---------+--------+-----------+---------------------+
| 1  | 1       | 200.00 | completed | 2025-01-10 10:00:00 |
| 2  | 1       | 500.00 | completed | 2025-01-15 14:30:00 |
| 3  | 1       | 500.00 | completed | 2025-01-15 14:30:05 |
+----+---------+--------+-----------+---------------------+

أصبح رصيد Ahmed هو 4000 بدلًا من 4500
وتم تسجيل عمليتي دفع في جدول payments بدلًا من عملية واحدة فقط

ما هو الـ Idempotency ؟

قبل أن نتحدث عن الحل دعنا نفهم مصطلح الـ Idempotency أولًا

الـ Idempotency هو مبدأ يعني أن تنفيذ نفس العملية أكثر من مرة يعطي نفس النتيجة تمامًا كما لو نفذتها مرة واحدة فقط
بمعنى أن النتيجة لا تتغير بغض النظر عن عدد مرات التنفيذ
وهو مفهوم رياضي في الاصل يسمى تساوي القوى وهو عندما تقوم بتكرار نفس العملية عدة مرات وتحصل على نفس الناتج في كل مرة 1 * 1 * 1 ... * 1 = 1

وستجده في أمور كثيرة سواء في البرمجة أو في الحياة اليومية
وقد شرحنا سابقًا عن الـ Idempotency Methods في مقالة بناء RESTful API متوافق مع المبادئ حيث تحدثنا عن أن بعض الـ HTTP Methods مثل GET و PUT و DELETE تعتبر Idempotent بطبيعتها لأن تنفيذها عدة مرات يعطي نفس النتيجة
أما POST فهي ليست Idempotent بطبيعتها لأن تنفيذها عدة مرات قد يعطي نتائج مختلفة
وهذا هو السبب في أن مشكلة الـ Duplicate Submissions تحدث غالبًا مع عمليات الـ POST لأنها ليست Idempotent بطبيعتها

لذا لحل مشكلة الـ Duplicate Submissions نحتاج إلى جعل الـ POST تكون Idempotent
بالطبع المشكلة لا تقتصر فقط على الـ POST بشكل عام
أي عملية غير Idempotent قد تتأثر بمشكلة الـ Duplicate Submissions
سواء على مستوى الـ API أو الـ Job والـ Queue أو في العمليات الداخلية في الكود
أو العمليات التي تكون مع External APIs/Services التي قد تتأثر بمشكلة الـ Duplicate Submissions بسبب تأخر الاستجابة أو مشاكل في الاتصال

على أي حال باختصار شديد نحتاج أن نحقق مبدأ الـ Idempotency في العمليات التي نريد حمايتها من مشكلة الـ Duplicate Submissions
لأن الـ Idempotency تعني أن نفس العملية تعطي نفس النتيجة بغض النظر عن عدد مرات التنفيذ وهذا ما نحتاجه لحل مشكلة الـ Duplicate Submissions بشكل فعال

وهنا تأتي فكرة الـ Idempotency Key كأداة لتحقيق مبدأ الـ Idempotency في العمليات التي لا تكون Idempotent بطبيعتها
سواء كانت عملية دفع أو عملية إنشاء منتج جديد أو أي عملية أخرى قد تتأثر بمشكلة الـ Duplicate Submissions كما ذكرنا

ما هي الـ Idempotency Key ؟

الـ Idempotency Key هو key يرسله الـ Client مع كل طلب ويكون unique لكل عملية معينة
يستخدمه الـ Server للتعرف على الـ Request وليعرف إذا كان قد تم تنفيذه من قبل أم لا
فإذا رأى الـ Server هذا الـ Key للمرة الأولى ينفذ العملية ويحفظ النتيجة
وإذا رأى نفس الـ Key مرة ثانية يرجع نفس النتيجة المحفوظة مباشرة بدون تنفيذ العملية مجددًا أو يرفض العملية الثانية في بعض الحالات حسب نوع العملية والنتيجة المتوقعة

هكذا بغض النظر عن عدد المرات التي يرسل فيها الـ Client نفس الطلب
ستكون النتيجة دائمًا واحدة ونفس العملية لن تنفذ إلا مرة واحدة فقط
لأنه إذا قام الـ Client بإرسال نفس عدة مرات فسيتم توليد نفس الـ Idempotency Key لكل طلب

الـ Idempotency Key عادة يكون UUID يولده الـ Client ويرسله في الـ Header مع كل طلب

Request Headers
+---------------------------+--------------------------------------+
| Header                    | Value                                |
+---------------------------+--------------------------------------+
| Idempotency-Key           | 4A314C9E-C008-442A-93BA-612CCBD192EA |
+---------------------------+--------------------------------------+

الـ Header قد يكون أي اسم تريده لكن عادةً يستخدم Idempotency-Key
ويكون هذا الـ Header متفق عليه بين الـ Client والـ Server بحيث يعرف الـ Server أنه يجب التعامل مع هذا الـ Header كـ Idempotency Key
ويكون هذا الـ Header إلزامي في الطلبات التي تحتاج إلى حماية من مشكلة الـ Duplicate Submissions
بحيث أنك إذا لم ترسل هذا الـ Header مع الطلب يتم رفض الطلب مباشرة لأن الـ Server لا يستطيع ضمان أن الطلب ليس مكررًا بدون وجود الـ Idempotency Key

بعد أن يرسل الـ Client هذا الـ Idempotency Key مع الطلب
الـ Server يحفظ هذا الـ Key في الـ Cache ويحفظ النتيجة المرتبطة به
وعند استقبال أي طلب جديد يتحقق أولًا هل هذا الـ Key موجود في الـ Cache أم لا
إذا كان موجودًا يرجع النتيجة المحفوظة مباشرة للـ Client
وإذا لم يكن موجودًا ينفذ العملية ويحفظ النتيجة في الـ Cache ثم يرجعها للـ Client

حل مشكلة الـ Duplicate Submissions باستخدام الـ Idempotency Key

لنفترض أن الـ Client الآن أصبح يولد UUID فريدًا لكل عملية دفع ويرسله في الـ header مع الطلب
لذا سنقوم بتعديل دالة processPayment لتتعامل مع الـ Idempotency Key وتحقق من وجوده في الـ Cache قبل تنفيذ العملية

public function processPayment(Request $request, User $user)
{
    $idempotencyKey = $request->header('Idempotency-Key');
    if (!$idempotencyKey) {
        return response()->json([
            'message' => 'Idempotency-Key header is required',
            'success' => false,
        ], 422);
    }

    $idempotencyCacheKey = "idempotency:{$user->id}:{$idempotencyKey}";

    // تحقق هل هذا الطلب تم معالجته من قبل أم لا
    if (Cache::has($idempotencyCacheKey)) {
        $cachedResponse = Cache::get($idempotencyCacheKey);

        return response()->json($cachedResponse);
    }

    $amount = $request->input('amount');

    if (!$user->hasEnoughBalance($amount)) {
        return response()->json([
            'message' => 'Insufficient balance',
            'success' => false,
        ], 422);
    }

    sleep(3); // محاكاة للتأخير لأي سبب

    $user->decrement('balance', $amount);

    Payment::create([
        'user_id' => $user->id,
        'amount'  => $amount,
        'status'  => 'completed',
    ]);

    $responseData = [
        'message' => 'Payment successful',
        'success' => true,
    ];

    Cache::put($idempotencyCacheKey, $responseData, now()->addHours(24));

    return response()->json($responseData);
}

قد يكون الكود كبيرًا بعض الشيء لكن الفكرة بسيطة جدًا
لنشرح كل جزء منه على حدة

$idempotencyKey = $request->header('Idempotency-Key');

if (!$idempotencyKey) {
    return response()->json([
        'message' => 'Idempotency-Key header is required',
        'success' => false,
    ], 422);
}

هنا نقوم بجلب قيمة الـ Idempotency Key من الـ Header
وإذا لم يكن موجودًا نرجع رسالة خطأ لأن هذا الـ Header إلزامي ولا يمكن معالجة الطلب بدون وجوده
وبالطبع هذا الجزء من الكود قد يتم وضعه في Middleware مستقل بدلًا من وضعه داخل الدالة نفسها لكي نستخدم الـ Middleware في جميع العمليات التي تحتاج إلى حماية من مشكلة الـ Duplicate Submissions

$idempotencyCacheKey = "idempotency:{$user->id}:{$idempotencyKey}";

if (Cache::has($idempotencyCacheKey)) {
    $cachedResponse = Cache::get($idempotencyCacheKey);

    return response()->json($cachedResponse);
}

هنا نولد cache key فريد لكل طلب بناءً على الـ user_id والـ idempotencyKey
ثم نتحقق إذا كان هذا الـ cache key موجودًا في الـ Cache
إذا كان موجودًا فهذا يعني أن هذا الطلب تم معالجته من قبل
لذا نرجع النتيجة المحفوظة في الـ Cache مباشرة بدون تنفيذ العملية مجددًا
أو يمكننا رفض الطلب المكرر بحسب طبيعة العملية والنتيجة المتوقعة التي نريدها في حالة تكرار الطلب

$amount = $request->input('amount');

if (!$user->hasEnoughBalance($amount)) {
    return response()->json([
        'message' => 'Insufficient balance',
        'success' => false,
    ], 422);
}

sleep(3); // محاكاة للتأخير لأي سبب

$user->decrement('balance', $amount);

Payment::create([
    'user_id' => $user->id,
    'amount'  => $amount,
    'status'  => 'completed',
]);

هذا الجزء هو نفس الكود السابق الذي يقوم بتنفيذ عملية الدفع وهو الجزء الذي نريد حمايته من مشكلة الـ Duplicate Submissions

$responseData = [
    'message' => 'Payment successful',
    'success' => true,
];

Cache::put($idempotencyCacheKey, $responseData, now()->addHours(24));

return response()->json($responseData);

في النهاية بعد تنفيذ العملية بنجاح نحتفظ بالنتيجة في الـ Cache باستخدام نفس الـ cache key
ثم نرجع النتيجة للـ Client

لاحظ أن النتيجة التي نحتفظ بها في الـ Cache هي نفس النتيجة التي نرجعها للـ Client
قد تكون النتيجة تحتوي على بيانات أخرى بحسب نوع العملية
لكننا نقوم بعمل مثال بسيط جدًا لشرح الفكرة فقط

لنرى كيف ستحل المشكلة الآن

لو قام Ahmed بإرسال نفس الطلب مرتين مع نفس الـ Idempotency-Key
فستحدث الخطوات التالية

    Request 1 (Idempotency-Key: abc-123)
      |
      |  Check Cache
      |  -> Key NOT found
      |
      |  Check balance -> 5000 >= 500
      |
      |  sleep(3)...
      |
      |  decrement balance (500)
      |  INSERT payment
      |  Cache::put('abc-123', response)
      |
      |
      |
      v
"Payment successful"
        .
        .
After 5 seconds delay (due to slow internet or server bug/issue)
        .
        .
    Request 2 (Idempotency-Key: abc-123)
      |
      |  Check Cache
      |  -> Key FOUND
      |  return cached response
      v
"Payment successful" (from cache)

لاحظ أن كلا الطلبين يحملان نفس الـ Idempotency-Key وهو abc-123
لذا الطلب الأول يمر من جميع الخطوات وينفذ العملية ويخصم 500 جنيه من رصيد Ahmed
ثم يحفظ النتيجة في الـ Cache تحت نفس الـ Idempotency-Key
ثم بعد 5 ثواني يأتي الطلب الثاني ويجد أن نفس الـ Idempotency-Key موجود في الـ Cache
لذا يرجع نفس النتيجة المحفوظة في الـ Cache مباشرة بدون تنفيذ العملية مجددًا

ولو نظرنا في الـ Database:

Users Table
+----+-------+---------+
| id | name  | balance |
+----+-------+---------+
| 1  | Ahmed | 4500.00 |
+----+-------+---------+

Payments Table
+----+---------+--------+-----------+---------------------+
| id | user_id | amount | status    | created_at          |
+----+---------+--------+-----------+---------------------+
| 1  | 1       | 200.00 | completed | 2025-01-10 10:00:00 |
| 2  | 1       | 500.00 | completed | 2025-01-15 14:30:00 |
+----+---------+--------+-----------+---------------------+

رصيد Ahmed أصبح 4500 وليس 4000 كما في الحالة السابقة
وتم تسجيل عملية دفع واحدة فقط في جدول payments


لغاية هذه النقطة تم حل مشكلة الـ Duplicate Submissions وفهمنا المشكلة وفهمنا فكرة الـ Idempotency Key وكيفية استخدامه لحل المشكلة
الآن برغم أننا حللنا مشكلة الـ Duplicate Submissions باستخدام الـ Idempotency Key إلا أننا معرضون لمشكلة أخرى وهي مشكلة الـ Race Condition في حالة وصول
في حالة وصول طلبين بنفس الـ Idempotency-Key في نفس اللحظة تمامًا قبل أن يتم حفظ النتيجة في الـ Cache

بمعنى أننا لو قام Ahmed بإرسال نفس الطلب مرتين في نفس اللحظة تمامًا
وكل طلب يحمل نفس الـ Idempotency-Key هنا قد يحدث Race Condition

بحيث لو الطلبين وصلا لهذا الكود في نفس اللحظة

if (Cache::has($idempotencyCacheKey)) {
    $cachedResponse = Cache::get($idempotencyCacheKey);

    return response()->json($cachedResponse);
}

كل طلب سيجد أن الـ Cache لا يحتوي على هذا الـ idempotencyCacheKey لأنه لم يتم حفظ النتيجة بعد
فكل طلب سيظن أنه الطلب الأول وسيقوم بتنفيذ العملية
لذا في هذه الحالة سيحدث Race Condition بين الطلبين

وبالطبع لحل هذه المشكلة نحتاج إلى دمج الـ Idempotency Key مع الـ Cache Lock
في المقالة المتعلقة بالـ Race Condition تحدثنا عن كيفية استخدام الـ Cache Lock لحل مشكلة الـ Race Condition
لكن قلنا أننا معرضون لمشكلة الـ Duplicate Submissions في حالة وصول طلبين في فترات متباعدة

تطبيق Cache Lock مع الـ Idempotency Key

لحل مشكلة الـ Race Condition التي قد تحدث عند وصول طلبين بنفس الـ Idempotency-Key في نفس اللحظة
نحتاج إلى دمج الـ Cache Lock مع الـ Idempotency Key
بحيث يكون الـ Cache Lock هو الحارس الأول الذي يمنع تنفيذ نفس العملية في نفس اللحظة
والـ Idempotency Key هو الحارس الثاني الذي يمنع تنفيذ نفس العملية في أوقات مختلفة

لنقم بتعديل دالة processPayment لتدمج الـ Cache Lock مع الـ Idempotency Key

public function processPayment(Request $request, User $user)
{
    $idempotencyKey = $request->header('Idempotency-Key');
    if (!$idempotencyKey) {
        return response()->json([
            'message' => 'Idempotency-Key header is required',
            'success' => false,
        ], 422);
    }

    $idempotencyCacheKey = "idempotency:{$user->id}:{$idempotencyKey}";
    $lockCacheKey = "lock:{$user->id}:{$idempotencyKey}";

    $lock = Cache::lock($lockCacheKey, 10);

    try {
        $lock->block(5);

        // تحقق هل هذا الطلب تم معالجته من قبل أم لا
        if (Cache::has($idempotencyCacheKey)) {
            $cachedResponse = Cache::get($idempotencyCacheKey);

            return response()->json($cachedResponse);
        }

        $amount = $request->input('amount');

        if (!$user->hasEnoughBalance($amount)) {
            return response()->json([
                'message' => 'Insufficient balance',
                'success' => false,
            ], 422);
        }

        sleep(3); // محاكاة للتأخير لأي سبب

        $user->decrement('balance', $amount);

        Payment::create([
            'user_id' => $user->id,
            'amount'  => $amount,
            'status'  => 'completed',
        ]);

        $responseData = [
            'message' => 'Payment successful',
            'success' => true,
        ];

        Cache::put($idempotencyCacheKey, $responseData, now()->addHours(24));

        return response()->json($responseData);

    } catch (LockTimeoutException $e) {
        return response()->json([
            'message' => 'Request is being processed, please try again later',
            'success' => false,
        ], 409);
    } finally {
        $lock?->release();
    }
}

لو قرأت مقالة الـ Race Condition ستجد أن هذا الكود مشابه جدًا لكود الـ Cache Lock الذي شرحناه في تلك المقالة
لكن هنا قمنا بدمج الـ Cache Lock مع الـ Idempotency Key لحل مشكلة الـ Duplicate Submissions بشكل كامل

لنشرح التعديلات الجديدة التي أضفناها على الكود

$lockCacheKey = "lock:{$user->id}:{$idempotencyKey}";
$lock = Cache::lock($lockCacheKey, 10);

هنا أضفنا lockCacheKey وهو cache key جديد سنستخدمه لإنشاء Lock خاص بكل طلب بناءً على الـ user_id والـ idempotencyKey
ثم قمنا بإنشاء الـ Lock باستخدام Cache::lock وجعلنا مدة الـ Lock هي 10 ثواني فقط
مدة الـ Lock تعتمد على طبيعة العملية والوقت المتوقع لتنفيذها

على أي حال لاحظ أننا استخدمنا try-catch-finally للتعامل مع الـ Lock داخل الـ try قمنا بمحاولة الحصول على الـ Lock باستخدام block

$lock->block(5);

وهذا السطر البسيط يقوم بإيقاف تنفيذ الكود وينتظر 5 ثواني حتى يتوفر الـ Lock بمعنى لو جاء طلبان في نفس اللحظة تمامًا
الطلب الأول سيحصل على الـ Lock فورًا وسيبدأ في تنفيذ العملية
الطلب الثاني سينتظر حتى 5 ثواني للحصول على الـ Lock

إذا توفر الـ Lock خلال 5 ثواني سيحصل عليه الطلب الثاني ويبدأ في تنفيذ العملية
وإذا لم نستطع الحصول على الـ Lock خلال 5 ثواني
سيتم رمي Exception يدعى LockTimeoutException ونتعامل معه في الـ catch

catch (LockTimeoutException $e) {
    return response()->json([
        'message' => 'Request is being processed, please try again later',
        'success' => false,
    ], 409);
}

الآن بعد أن قمنا بمحاولة الحصول على الـ Lock
نقوم بالتحقق من وجود نفس الـ Idempotency-Key في الـ Cache

if (Cache::has($idempotencyCacheKey)) {
    $cachedResponse = Cache::get($idempotencyCacheKey);

    return response()->json($cachedResponse);
}

إذا وجدنا نفس الـ Idempotency-Key في الـ Cache فهذا يعني أن هذا الطلب تم معالجته من قبل
لذا نرجع النتيجة المحفوظة في الـ Cache مباشرة بدون تنفيذ العملية مجددًا
وإذا لم يكن موجودًا فهذا يعني أن هذا هو الطلب الأول الذي يحمل هذا الـ Idempotency-Key
لذا نبدأ في تنفيذ العملية كالمعتاد

$amount = $request->input('amount');

if (!$user->hasEnoughBalance($amount)) {
    return response()->json([
        'message' => 'Insufficient balance',
        'success' => false,
    ], 422);
}

sleep(3); // محاكاة للتأخير لأي سبب

$user->decrement('balance', $amount);

Payment::create([
    'user_id' => $user->id,
    'amount'  => $amount,
    'status'  => 'completed',
]);

هذا الجزء هو الكود الأساسي الذي ينفذ عملية الدفع ويخصم المبلغ من رصيد المستخدم
وهو الجزء الذي نريد حمايته من مشكلة الـ Duplicate Submissions بحيث لا يتم تنفيذها أكثر من مرة لنفس الـ Idempotency-Key

الآن بعد تنفيذ العملية بنجاح نحتفظ بالنتيجة في الـ Cache باستخدام نفس الـ idempotencyCacheKey

$responseData = [
    'message' => 'Payment successful',
    'success' => true,
];

Cache::put($idempotencyCacheKey, $responseData, now()->addHours(24));

return response()->json($responseData);

في النهاية نرجع النتيجة للـ Client ونحتفظ بها في الـ Cache تحت نفس الـ Idempotency-Key
لذا إذا جاء طلب آخر بنفس الـ Idempotency-Key في أي وقت لاحق
فسيجد نفس الـ Idempotency-Key في الـ Cache ويرجع نفس النتيجة مباشرة بدون تنفيذ العملية مجددًا

finally {
    $lock?->release();
}

وأخيرًا في الـ finally نتأكد من فك الـ Lock بعد انتهاء العملية بغض النظر عن نتيجتها
سواء نجحت أو فشلت Exception


الآن لو قام Ahmed بإرسال نفس الطلب مرتين في نفس اللحظة تمامًا
وكل طلب يحمل نفس الـ Idempotency-Key
فستحدث الخطوات التالية

    Request 1 (Idempotency-Key: abc-123)     Request 2 (Idempotency-Key: abc-123)
      |                                         |
      |  Acquire Lock                           |  Acquire Lock
      |  -> Lock acquired                       |  -> Waiting for lock...
      |                                         |  .
      |  Check Cache                            |  .
      |  -> Key NOT found                       |  .
      |                                         |  .
      |  Check balance -> 5000 >= 500           |  .
      |                                         |  .
      |  sleep(3)...                            |  .
      |                                         |  .
      |  decrement balance (500)                |  .
      |  INSERT payment                         |  .
      |  Cache::put('abc-123', response)        |  .
      |  $lock->release()                       |  .
      |                                         |  -> Lock acquired
      v                                         |
"Payment successful"                            |  Check Cache
                                                |  -> Key FOUND
                                                |  $lock->release()
                                                v
                                    "Payment successful" (from cache)

لنشرح الرسمة بشكل سريع، برغم أنك قد فهمت الفكرة من الشرح السابق والرسومات التي أقوم بوضعها تشرح نفسها بنفسها لكن سأشرحها بشكل سريع لتوضيح الفكرة بشكل كامل
الطلب الأول يحصل على الـ Lock فورًا ويبدأ في تنفيذ العملية
والطلب الثاني ينتظر الـ Lock حتى يتم فك الـ Lock من قبل الطلب الأول
بعد انتهاء الطلب الأول من التنفيذ يقوم بتخزين الناتج في الـ Cache الخاص بالـ Idempotency-Key ويتم فك الـ Lock يحصل الطلب الثاني على الـ Lock ويجد أن الـ Idempotency-Key موجود في الـ Cache
فيرجع نفس النتيجة المحفوظة مباشرة بدون تنفيذ العملية مجددًا

هكذا دمجنا الـ Cache Lock مع الـ Idempotency Key لحل مشكلة الـ Duplicate Submissions بشكل كامل
الـ Lock يحمي من تكرار نفس الطلب في نفس اللحظة
والـ Idempotency Key يحمي من تكرار نفس الطلب في أوقات مختلفة

ملحوظة: لاحظ أننا نتحقق من الـ Cache الخاص بالـ Idempotency-Key بعد فك الـ Lock وليس قبله
هذا مهم جدًا لأنه يضمن أنه بعد أن ينتهي الطلب الأول من تنفيذ العملية ويحفظ النتيجة في الـ Cache
سيقوم الطلب الثاني بعد فك الـ Lock بالتحقق من الـ Cache الخاص بالـ Idempotency-Key ليتأكد أن النتيجة موجودة أم لا
في حالة وجود النتيجة في الـ Cache سيقوم بإرجاعها مباشرة بدون تنفيذ العملية مجددًا
هكذا لو قام الـ Client بإرسال 1000 طلب بنفس الـ Idempotency-Key سيقوم الطلب الأول بتنفيذ العملية وحفظ النتيجة في الـ Cache
بالتالي الـ 999 طلب ستقوم بإرجاع نفس النتيجة التي تم حفظها في الـ Cache من قبل الطلب الأول بدون تنفيذ العملية مجددًا

خلاصة

في هذه المقالة تحدثنا عن مشكلة الـ Duplicate Submissions وكيفية حلها باستخدام الـ Idempotency Key
وشرحنا أن الـ Duplicate Submissions تحدث عندما يصل نفس الطلب أكثر من مرة لأسباب مختلفة مثل بطء الإنترنت أو مشاكل في الـ Server أو حتى بسبب سوء استخدام من قبل المستخدم
وشرحنا أن الـ Lock لا يستطيع حل مشكلة الـ Duplicate Submissions بمفرده لأنه لا يمنع تكرار نفس الطلب إذا وصل في أوقات مختلفة
لذا نحتاج إلى الـ Idempotency Key لحل مشكلة الـ Duplicate Submissions بشكل كامل
وشرحنا أن الـ Idempotency Key هو key يرسله الـ Client مع كل طلب ويستخدمه الـ Server للتعرف على الطلبات المكررة وإرجاع نفس النتيجة بدون تنفيذ العملية مجددًا
وشرحنا كيفية دمج الـ Cache Lock مع الـ Idempotency Key لحل مشكلة الـ Duplicate Submissions بشكل كامل بحيث الـ Lock يحمي من تكرار نفس الطلب في نفس اللحظة
والـ Idempotency Key يحمي من تكرار نفس الطلب في أوقات مختلفة


بعض الملحوظات المهمة التي يجب الانتباه لها عند استخدام الـ Idempotency Key:

يجب أن يكون الـ Idempotency Key فريدًا لكل عملية معينة تحدث من قبل الـ Client
وكلمة Client لا أقصد بها المستخدم أو الـ Frontend فقط
بل أقصد أي جهة ترسل الطلبات للـ Server سواء كانت من الـ Frontend أو من الـ Backend نفسه أو من أي نظام آخر يتفاعل مع الـ Server
لذا يجب أن يكون الـ Idempotency Key فريدًا لكل عملية معينة تحدث
يجب أن يتفق الـ Client والـ Server على استخدام نفس الـ Header لتمرير الـ Idempotency Key
وعلى الـ Server أن يفرض وجود هذا الـ Header في الطلبات التي تحتاج إلى حماية من مشكلة الـ Duplicate Submissions
وإذا لم يكن موجودًا يجب أن يرفض الطلب مباشرة لأن الـ Server لا يستطيع ضمان أن الطلب ليس مكررًا بدون وجود الـ Idempotency Key

يجب أن يتم تخزين الـ Idempotency Key والنتيجة المرتبطة به في مكان سريع مثل الـ Cache
وغالبًا ما يفضل أن يكون Redis لكي يكون Cache Server منفصل في حالة استخدامنا لـ Distributed System والأمور تقوم بعمل Load Balancing و Replication للتطبيق على أكثر من Server


على الـ Client أن يضمن أن الـ Idempotency Key يتم توليده بشكل فريد لكل عملية معينة وليس لكل Request
بمعنى لو الشخص يقوم بعملية دفع واحدة فقط في التطبيق لكنه قام بإرسال 5 طلبات دفع بسبب بطء الإنترنت أو مشاكل في الاتصال
على الـ Client أن يضمن أن كل Request يحمل نفس الـ Idempotency Key الخاص بهذه العملية
وليس أن يقوم بتوليد Idempotency Key جديد لكل طلب لأنه في هذه الحالة كل طلب سيحمل Idempotency Key مختلف وسيتم تنفيذ العملية لكل طلب مما يؤدي إلى مشكلة الـ Duplicate Submissions التي نريد حلها


رسالة خاصة

أرسل ملاحظاتك أو رأيك بشكل خاص — لن يظهر للآخرين

التعليقات

شاركنا رأيك في هذه المقالة أو اسأل عن أي شيء يخصها