يا جماعة الخير، السلام عليكم. معكم أخوكم أبو عمر.
بدي أحكيلكم قصة صارت معي قبل كم سنة، قصة علّمتني درس ما بنساه طول ما أنا بكتب كود. كنا وقتها شغالين على نظام دفع إلكتروني لتطبيق تجارة إلكترونية، وكنا على وشك الإطلاق. سهر وتعب ليالي، والقهوة صارت صديقنا الصدوق. في ليلة من الليالي، وقبل الإطلاق الرسمي بأيام قليلة، بنعمل آخر الاختبارات، وإذ برنّ تلفون مدير المشروع. على الخط واحد من المستخدمين اللي بنجرب معهم النظام (Beta Testers) وصوته معصّب: “يا جماعة كيف هيك! خصمتوا مني المبلغ مرتين!”.
قلوبنا وقعت في رجلينا. كيف مرتين؟ دخلنا على السجلات (logs) بسرعة البرق، وبنبحث وبنقلّب. لقينا طلبين (API requests) ورا بعض بالمللي ثانية، لنفس المستخدم، بنفس المبلغ، لنفس المنتج. الطلب الأول نجح، والثاني كمان نجح! المصيبة إنه المستخدم حلف يمين إنه كبس على زر “ادفع الآن” مرة وحدة بس، بس لأنه النت عنده كان “بلفّ” وبحمّل، فكّر إنه الكبسة ما انكبست، راح كابسها كمان مرة “احتياط”. وهون كانت الكارثة.
في هذيك اللحظة، عرفنا إنه مشكلة “النقرة المزدوجة” مش مجرد إزعاج بسيط، هاي مصيبة ممكن تدمّر سمعة أي منتج مالي. ومن قلب هذيك الأزمة، طلع الحل اللي أنقذنا من جحيم العمليات المزدوجة: مفاتيح عدم التكرار أو الـ Idempotency Keys.
ما هي قصة عدم التكرار (Idempotency)؟
قبل ما نغوص في الحل، خلينا نفهم أصل المشكلة. مصطلح “Idempotency” أو “عدم التكرار” باللغة العربية، هو مفهوم أساسي في الرياضيات وعلوم الحاسوب. ببساطة، العملية “عديمة التكرار” هي العملية اللي لو نفذتها مرة أو ألف مرة بنفس المدخلات، النتيجة النهائية بتكون نفسها.
أفضل مثال من حياتنا اليومية هو زر المصعد. لما تكبس على زر الطابق اللي بدك تروحه، المصعد بستقبل طلبك وببلش يتحرك. لو رجعت كبست على نفس الزر عشر مرات والمصعد لسا في طريقه، ما رح يصير إشي جديد. النتيجة النهائية واحدة: المصعد رح يوصلك على نفس الطابق. هذا هو بالضبط معنى الـ Idempotency.
في عالم الـ APIs، بعض عمليات الـ HTTP بطبيعتها عديمة التكرار:
- GET, HEAD, OPTIONS, PUT, DELETE: هاي العمليات لو كررتها، المفروض النتيجة ما تتغير. لو طلبت بيانات مستخدم (GET) ألف مرة، رح تجيك نفس البيانات. ولو حذفت منتج (DELETE) مرتين، المرة الأولى بتحذفه والمرة الثانية رح تحكيلك “مش موجود أصلاً”، بس الحالة النهائية للنظام واحدة (المنتج محذوف).
- POST: هون المصيبة. عملية POST مصممة لإنشاء مورد جديد. كل مرة بتبعث طلب POST، أنت بتقول للسيرفر “لو سمحت اعملي إشي جديد”. فلو كررت طلب POST لإنشاء عملية دفع، السيرفر رح ينشئ عمليتي دفع!
المشكلة الحقيقية: جحيم العمليات المزدوجة
مشكلة العميل اللي انخصم منه مرتين هي مجرد قمة جبل الجليد. تخيل معي السيناريوهات الكارثية التالية اللي ممكن تصير بسبب تكرار الطلبات:
- المدفوعات الإلكترونية: خصم المبلغ مرتين من العميل، وتحويل الأموال مرتين للتاجر. مصيبة مالية وقانونية.
- إنشاء الطلبات (Orders): إنشاء طلبين لنفس المنتج، وشحن المنتج مرتين للعميل. خسارة مادية وفوضى في المستودعات.
- إرسال الإشعارات: إرسال نفس البريد الإلكتروني أو رسالة SMS مرتين. “يا زلمة، فشّلتنا مع العميل!”، بتصير رسالة الترحيب رسالة إزعاج.
- العمليات الحساسة: أي عملية بتغير حالة النظام (state) بدون ما يكون إلها طريقة سهلة للتراجع عنها.
سبب التكرار مش دايماً من المستخدم. ممكن يكون بسبب ضعف في الشبكة، أو انتهاء مهلة الطلب (timeout) عند العميل مع إنه الطلب وصل للسيرفر وتنفذ، أو حتى بسبب آليات إعادة المحاولة (Retry-mechanisms) في البنية التحتية للشبكة.
الحل السحري: مفاتيح عدم التكرار (Idempotency Keys)
هنا يأتي دور البطل في قصتنا. الـ Idempotency Key هو حل بسيط وأنيق جداً. الفكرة هي إنه نخلي طلبات الـ POST اللي بطبيعتها مش عديمة التكرار، تتصرف كأنها عديمة التكرار.
شو هاد الـ Idempotency Key؟
ببساطة شديدة، هو عبارة عن “مفتاح” أو “رمز” فريد من نوعه يقوم الـ Client (تطبيق الويب أو الموبايل) بإنشائه لكل عملية فريدة يريد تنفيذها (مثل عملية دفع واحدة). ثم يرسل هذا المفتاح مع الطلب إلى الخادم (Server) داخل الـ Headers.
لما الخادم يستقبل الطلب، بيشوف هاد المفتاح. إذا كان أول مرة بشوفه، بنفذ العملية عادي. بس إذا كان شافه من قبل، بيعرف إنه هاد طلب مكرر، فما بنفذ العملية مرة تانية، وبيرجع نفس النتيجة اللي رجعها في المرة الأولى.
آلية العمل خطوة بخطوة
- العميل (Client) يولّد المفتاح: قبل إرسال طلب POST حساس (مثل الدفع)، يقوم العميل بإنشاء مفتاح فريد. أفضل طريقة هي استخدام UUID (Universally Unique Identifier).
- إرسال الطلب مع المفتاح: يرسل العميل الطلب إلى السيرفر، ويضع المفتاح في هيدر خاص، المتعارف عليه هو
Idempotency-Key.
Idempotency-Key: a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6 - الخادم (Server) يستقبل ويفحص: هنا قلب الموضوع. الخادم بيعمل الآتي:
- الحالة الأولى (المفتاح جديد): الخادم يبحث عن هذا المفتاح في قاعدة بيانات مؤقتة (مثل Redis). إذا لم يجده، فهذا طلب جديد تمامًا. يقوم الخادم بتخزين المفتاح مع حالة “قيد التنفيذ”، ثم ينفذ العملية المطلوبة (مثلاً، خصم المبلغ). بعد اكتمال العملية بنجاح، يقوم بتخزين نتيجة العملية (الـ response body والـ status code) ويربطها بنفس المفتاح، ويغير حالته إلى “مكتمل”. وأخيراً، يرسل الرد للعميل.
- الحالة الثانية (المفتاح موجود و”مكتمل”): إذا وجد الخادم المفتاح وكانت العملية المرتبطة به “مكتملة”، فهذا يعني أن هذا طلب مكرر لعملية تمت بنجاح. في هذه الحالة، لا يقوم بتنفيذ العملية مرة أخرى. كل ما يفعله هو استرجاع النتيجة المخزنة مسبقاً وإرسالها مرة أخرى للعميل. وبهيك، العميل بيحصل على نفس الرد وكأن العملية تمت للتو، والنظام بكون آمن.
- الحالة الثالثة (المفتاح موجود و”قيد التنفيذ”): إذا وجد الخادم المفتاح وحالته “قيد التنفيذ”، هذا يعني أن الطلب الأول ما زال قيد المعالجة، ووصل طلب ثاني بنفس المفتاح في نفس اللحظة (حالة سباق – Race Condition). هنا، يجب على الخادم أن يرفض الطلب الثاني فوراً برمز خطأ مثل
409 Conflict، ليعطي إشارة للعميل بأن “طلبك الأول لسا شغال، اصبر شوي”.
مثال عملي بالكود (Node.js و Express)
حكي النظري حلو، بس خلينا نشوف الكود. هذا مثال مبسط لميدل وير (middleware) في Express.js يوضح الفكرة. في الواقع العملي، يجب استخدام قاعدة بيانات مثل Redis بدلاً من التخزين في الذاكرة.
// في مشروع حقيقي، استخدم Redis أو قاعدة بيانات مشابهة
// In a real project, use Redis or a similar database
const idempotencyStore = new Map();
async function idempotencyMiddleware(req, res, next) {
const idempotencyKey = req.get('Idempotency-Key');
// إذا لم يوجد مفتاح، استمر بشكل طبيعي
if (!idempotencyKey) {
return next();
}
const cachedRequest = idempotencyStore.get(idempotencyKey);
if (cachedRequest) {
// تم رؤية هذا الطلب من قبل
if (cachedRequest.status === 'processing') {
// الطلب الأصلي لا يزال قيد المعالجة
return res.status(409).json({ message: 'Request is already being processed.' });
}
// الطلب اكتمل، أرجع النتيجة المخزنة
console.log(`Returning cached response for key: ${idempotencyKey}`);
return res.status(cachedRequest.statusCode).json(cachedRequest.body);
}
// هذا طلب جديد، قم بتخزينه كـ "قيد المعالجة"
idempotencyStore.set(idempotencyKey, { status: 'processing' });
console.log(`Processing new request for key: ${idempotencyKey}`);
// نحتاج لالتقاط الرد الأصلي لتخزينه
const originalJson = res.json;
const originalStatus = res.status;
let responseBody;
let responseStatusCode;
// نغلف دالة status لتسجيل الـ status code
res.status = (code) => {
responseStatusCode = code;
return originalStatus.call(res, code);
};
// نغلف دالة json لتخزين الرد قبل إرساله
res.json = (body) => {
responseBody = body;
const responseToCache = {
status: 'completed',
statusCode: responseStatusCode || 200,
body: responseBody,
};
// خزن النتيجة النهائية
idempotencyStore.set(idempotencyKey, responseToCache);
// مهم: احذف المفتاح بعد فترة (مثلاً 24 ساعة) لتجنب تضخم الذاكرة
setTimeout(() => {
idempotencyStore.delete(idempotencyKey);
console.log(`Idempotency key ${idempotencyKey} expired and removed.`);
}, 24 * 60 * 60 * 1000); // 24 hours
return originalJson.call(res, body);
};
next();
}
// مثال على استخدامه في Express
// app.post('/api/payments', idempotencyMiddleware, (req, res) => {
// // ... منطق معالجة الدفع هنا ...
// // const paymentResult = processPayment(req.body);
// // res.status(201).json(paymentResult);
// });
هذا الكود يوضح الفكرة الأساسية. في التطبيقات الحقيقية، ستحتاج إلى التعامل مع حالات السباق (race conditions) بشكل أدق باستخدام أوامر ذرية (atomic) مثل SETNX في Redis.
نصائح أبو عمر الذهبية (خبرة سنين يا خال)
بعد ما طبقنا هذا الحل في مشاريع كثيرة، جمعتلكم هاي النصائح من أرض الواقع:
- وين تولّد المفتاح؟ دايماً على العميل (الـ client). لأنه هو الوحيد اللي بيعرف إنه هاي “محاولة” فريدة منه. لو ولّدته على السيرفر، بتفقد كل الفائدة.
- شو شكل المفتاح؟ استخدم UUID v4. لا تحاول تخترع العجل أو تستخدم بيانات المستخدم (مثل ايميله + الوقت) لأنها ممكن تتكرر. UUID بسيطة ومضمونة عالمياً.
- وين تخزّن المفاتيح؟ لا تخزنها في ذاكرة السيرفر زي المثال فوق (هذا للتوضيح فقط). لو السيرفر عمل ريستارت، بتروح كل المفاتيح. استخدم قاعدة بيانات سريعة ومستقلة مثل Redis، فهي مثالية لهذا الغرض.
- كم مدة صلاحية المفتاح؟ لا تترك المفتاح للأبد في قاعدة البيانات. هذا سيؤدي إلى تضخمها بلا فائدة. حدد فترة صلاحية معقولة (مثلاً 24 ساعة). بعد هذه الفترة، يمكن حذف المفتاح. شركة Stripe، وهي رائدة في مجال المدفوعات، تستخدم صلاحية 24 ساعة.
- لا تطبقها على كل شيء: مش كل الـ API endpoints بتحتاج هذا المنطق. ركز على العمليات الحساسة التي تغير حالة النظام (POST requests) والتي لا يمكن التراجع عنها بسهولة، مثل إنشاء دفع، إنشاء طلب، إرسال رسالة، إلخ.
الخلاصة: نقرة آمنة، وراس مرتاح
يا جماعة، في عالمنا الرقمي اللي كل شي فيه سريع وممكن يتقطع، بناء أنظمة موثوقة هو أساس النجاح والثقة. مشكلة تكرار العمليات مشكلة حقيقية ومؤذية، والعميل اللي بتنخصم منه الفلوس مرتين ما رح يرجع يستخدم تطبيقك.
مفتاح عدم التكرار (Idempotency Key) ليس مجرد حيلة تقنية، بل هو فلسفة في بناء الثقة مع المستخدم والنظام. هو وعد منك كمطور بأنك فكرت في كل السيناريوهات السيئة وحميت المستخدم منها.
تطبيقها قد يأخذ بعض الوقت والجهد في البداية، ولكنه يوفر عليك وعلى فريقك ساعات لا تحصى من حل المشاكل ودعم العملاء الغاضبين، ويحمي سمعة منتجك. لا تبخلوا على حالكم وعلى مستخدميكم براحة البال هاي. طبقوها، وادعولي. 😉