هذا المقال يتطلب مستوى: متوسط. لو انت مبتدئ، الأمثلة هتوصلك خطوة بخطوة؛ ولو محترف، ركّز على آخر جزء الخاص بالـ race condition لأنه النقطة اللي بيغلط فيها أغلب الناس.
الـ Idempotency: ليه ضغطة زر واحدة ممكن تخصم العميل مرتين
لو زرار "ادفع" عندك ممكن يخصم مرتين لما العميل يضغط مرتين أو النت يقطع، الحل مش في منع الضغط. الحل مفتاح واحد اسمه Idempotency-Key. المقال ده بيوريك ليه بيحصل، وإزاي تمنعه بكود شغّال.
الفكرة الأول بمثال بسيط
تخيّل زرار المصعد. لو ضغطت عليه عشر مرات، المصعد بييجي مرة واحدة. الضغطة التانية والتالتة مالهاش أي تأثير جديد. ده بالظبط معنى "idempotent": تكرار نفس العملية بيدّي نفس النتيجة، مش نتيجة مضاعفة.
دلوقتي الشرح العلمي بالتفاصيل: عملية توصف بإنها idempotent لو تنفيذها مرة واحدة زي تنفيذها N مرة على نفس المدخل بالظبط. في بروتوكول HTTP، الأفعال GET وPUT وDELETE معرّفة رسميًا كـ idempotent. لكن POST مش كده — وده بالظبط مصدر المشكلة، لأن إنشاء عملية دفع بيتعمل غالبًا بـ POST.
المشكلة باختصار
العميل بيبعت POST /payments. الرد اتأخر بسبب الشبكة. المتصفح أو تطبيق الموبايل بيعيد المحاولة تلقائيًا. السيرفر بيستقبل طلبين، وبيخصم مرتين. اللي بيحصل فعلاً إن الطلب الأول اتنفذ تمام، بس رده ضاع في الطريق، فالعميل فاكر إنه فشل.
الافتراض هنا عشان نحط رقم: عندك 200 ألف طلب دفع يوميًا، ونسبة إعادة الإرسال 0.5% بس. ده معناه حوالي 1000 خصم مكرر كل يوم. رقم كبير جدًا على أي بوابة دفع، وكل واحد منهم شكوى عميل واسترجاع فلوس.
الحل: مفتاح ثبات لكل عملية
الفكرة بسيطة. العميل بيولّد مفتاح فريد (UUID مثلاً) لكل عملية، وبيبعته في هيدر Idempotency-Key. السيرفر بيخزّن المفتاح مع نتيجة أول تنفيذ. أي طلب تاني بنفس المفتاح بياخد النتيجة المخزّنة من غير ما يخصم تاني.
كود شغّال (Python + Flask + Redis)
import json
import redis
from flask import Flask, request, jsonify
app = Flask(__name__)
r = redis.Redis()
@app.post("/payments")
def create_payment():
key = request.headers.get("Idempotency-Key")
if not key:
return jsonify(error="Idempotency-Key required"), 400
cached = r.get(f"idem:{key}")
if cached: # الطلب اتنفذ قبل كده
return jsonify(json.loads(cached)), 200
# نفّذ الخصم مرة واحدة بس
result = charge_card(amount=request.json["amount"])
r.set(f"idem:{key}", json.dumps(result), ex=86400) # يعيش 24 ساعة
return jsonify(result), 200
هنا Redis بيخزّن المفتاح 24 ساعة عبر ex=86400. أي إعادة محاولة جوه المدة دي بترجّع نفس الرد فورًا، والخصم بيحصل مرة واحدة بس.
الـ trade-off
كل حل له ثمنه. بتكسب: منع الخصم المزدوج نهائيًا. بتخسر: قراءة زيادة من Redis في كل طلب، حوالي 1 مللي ثانية، بالإضافة لتخزين مؤقت لكل مفتاح. على 200 ألف طلب في اليوم، دي تكلفة تافهة مقابل حماية فلوس العملاء وسمعة المنتج.
نقطة المحترفين: الـ race condition
الكود اللي فوق فيه ثغرة حقيقية. لو طلبين بنفس المفتاح وصلوا في نفس الجزء من الثانية، الاتنين هيلاقوا المفتاح مش موجود في Redis، والاتنين هيخصموا. المشكلة إن الفحص والكتابة عمليتين منفصلتين.
الحل: احجز المفتاح الأول بشكل ذرّي (atomic) بـ SET NX يعني "اكتب بس لو مش موجود"، قبل ما تنفّذ الخصم.
reserved = r.set(f"idem:{key}", "processing", nx=True, ex=86400)
if not reserved:
# طلب تاني بنفس المفتاح شغّال دلوقتي أو خلص — استنى النتيجة أو رجّعها
return jsonify(status="in_progress"), 409
result = charge_card(amount=request.json["amount"])
r.set(f"idem:{key}", json.dumps(result), ex=86400)
كده أول طلب بس هو اللي بيقدر يحجز المفتاح، والباقي بيترفض بأمان.
متى لا تحتاج هذا
لو العملية idempotent بطبيعتها، زي GET أو تحديث حقل بقيمة ثابتة (PUT status=paid)، متضيفش الطبقة دي. الـ Idempotency-Key مهم للعمليات اللي بتغيّر حالة أو بتحرّك فلوس فعليًا. للقراءة البسيطة، ده تعقيد بلا فايدة.
الخطوة التالية
افتح أهم endpoint بيحرّك فلوس عندك دلوقتي. لو مفيهوش تحقق من Idempotency-Key، ضيف الحجز بـ SET NX وخزّن الرد. بعدين جرّب تبعت نفس الطلب مرتين بنفس المفتاح، ولازم تلاقي خصم واحد بس. لو لقيت اتنين، يبقى الحجز مش شغّال.
المصادر
- توثيق Stripe عن الطلبات المتكررة (Idempotent Requests): stripe.com/docs/api/idempotent_requests
- مسودة IETF: The Idempotency-Key HTTP Header Field (draft-ietf-httpapi-idempotency-key-header)
- MDN Web Docs: تعريف الأفعال الـ idempotent في HTTP
- RFC 9110 (HTTP Semantics)، القسم 9.2.2 عن الـ Idempotent Methods
- توثيق Redis عن أمر SET وخيار NX: redis.io/commands/set