يا جماعة الخير، خلوني أحكيلكم هالسالفة اللي صارت معي قبل كم سنة. كنا شغالين على نظام دفع إلكتروني لأحد العملاء، وكان يوم خميس، والكل مستعجل بده يروّح على الدار. فجأة، بلّشت توصلنا رسايل على جروب الدعم الفني: “تم خصم المبلغ مرتين!”، “دفعت الفاتورة وانسحب الرصيد مرتين!”، “شو هالحكي يا جماعة؟ مصارينا راحت!”.
في هذيك اللحظة، بتحس الدم هرب من وجهك. ركضنا على لوحات المراقبة (Dashboards)، وإذ بنشوف مصيبة. بعض العمليات المالية مسجلة مرتين وثلاث! نفس المستخدم، نفس المبلغ، نفس المنتج، لكن بمعرّفات عمليات مختلفة. قضينا ليلتها “نلفلف” الموضوع يدويًا، نرجع المصاري للناس، ونعتذر يمين وشمال، وثقة العميل فينا صارت بالأرض.
بعد ما هديت العاصفة، قعدنا كفريق نحلل شو اللي صار. المشكلة ما كانت في منطق الدفع نفسه، بل في شيء أبسط وأخطر بكثير: الطلبات المزدوجة (Duplicate Requests). المستخدم كان يكبس على زر “ادفع الآن”، والشبكة عنده بطيئة، فما يوصله رد من السيرفر بسرعة. بطبيعته البشرية المستعجلة، يرجع يكبس على الزر مرة ومرتين وثلاث. ومن جهة نظامنا، كل كبسة كانت طلبًا جديدًا، وعملية دفع جديدة. وهون كانت الكارثة.
هذه التجربة المريرة كانت الدرس الأهم في مسيرتي المهنية، وعلّمتني عن سلاح سري اسمه “مفاتيح عدم التكرار” أو الـ Idempotency Keys. اليوم، جاي أشارككم هالمعرفة، عشان ما تقعوا في نفس الحفرة اللي وقعنا فيها.
ما هي مشكلة الطلبات المزدوجة؟ ولماذا تحدث؟
ببساطة، الطلب المزدوج هو وصول نفس الطلب إلى الخادم (Server) أكثر من مرة. هذا لا يعني بالضرورة أن المستخدم ضغط على الزر مرتين. الأسباب كثيرة ومعقدة:
- مشاكل الشبكة (Network Glitches): قد يفشل الطلب في منتصف الطريق، فيقوم العميل (المتصفح أو التطبيق) بإعادة إرساله تلقائيًا.
- سلوك المستخدم (User Behavior): كما في قصتي، المستخدم الذي لا يرى استجابة فورية قد يعيد المحاولة يدويًا.
- آليات إعادة المحاولة (Retry Mechanisms): بعض المكتبات البرمجية والـ Frameworks تحتوي على منطق لإعادة إرسال الطلبات الفاشلة تلقائيًا.
- الأنظمة الموزعة (Distributed Systems): في الخدمات المصغرة (Microservices)، قد تقوم خدمة بإعادة إرسال طلب إلى خدمة أخرى إذا لم تتلق ردًا في الوقت المناسب.
المشكلة لا تكمن في كل أنواع الطلبات. طلبات القراءة مثل GET آمنة. يمكنك طلب بيانات المستخدم ألف مرة، والنتيجة دائمًا واحدة. لكن المصيبة تقع في الطلبات التي تُحدث تغييرًا، وخصوصًا POST.
نصيحة من أبو عمر: افترض دائمًا أن أي طلب
POSTيمكن أن يصلك مرتين. صمم نظامك على هذا الأساس. هذا ليس “احتمالًا نادرًا”، بل هو “حتمية إحصائية” في أي نظام يعمل على نطاق واسع.
مفهوم الـ Idempotency: الحل السحري
قبل ما نحكي عن المفاتيح، لازم نفهم المبدأ اللي وراها: Idempotency أو “عدم التكرار”.
في عالم الرياضيات والبرمجة، العملية “Idempotent” هي العملية التي إذا طبقتها مرة واحدة أو طبقتها مليون مرة، فإن النتيجة النهائية تظل كما هي بعد التطبيق الأول.
خلونا ناخذ أمثلة من الحياة:
- عملية غير Idempotent: الضغط على مفتاح الضوء. الضغطة الأولى تشعل الضوء، والثانية تطفئه. حالة النظام تتغير مع كل ضغطة.
- عملية Idempotent: الضغط على زر استدعاء المصعد للطابق الذي أنت فيه. بعد الضغطة الأولى، يتم استدعاء المصعد. كل ضغطة تالية لا تفعل شيئًا جديدًا؛ المصعد قادم على أي حال.
في عالم الـ APIs، طلبات GET, PUT, DELETE مصممة لتكون Idempotent بطبيعتها. طلب DELETE /users/123 سيحذف المستخدم 123. إذا أرسلته مرة أخرى، سيخبرك أن المستخدم غير موجود، لكن حالة النظام (المستخدم 123 محذوف) لم تتغير. المشكلة دائمًا في POST، الذي يُستخدم لإنشاء موارد جديدة.
إذًا كيف نجعل عملية POST “عديمة التكرار”؟
هنا يأتي دور بطل قصتنا: مفتاح عدم التكرار (Idempotency Key).
آلية عمل مفاتيح عدم التكرار (Idempotency Keys)
الفكرة عبقرية في بساطتها. بدلًا من أن يحاول الخادم تخمين ما إذا كان الطلب جديدًا أم مكررًا، نحن نمنحه طريقة واضحة ليعرف ذلك. الآلية تتم كالتالي:
- العميل (Client) يُنشئ مفتاحًا فريدًا: قبل إرسال الطلب الحساس (مثل طلب الدفع)، يقوم العميل بإنشاء معرّف فريد تمامًا. عادةً ما يكون هذا UUID (Universally Unique Identifier).
- العميل يُرفق المفتاح بالطلب: يتم إرسال هذا المفتاح في هيدر (Header) خاص مع الطلب. الهيدر المتعارف عليه هو
Idempotency-Key. - الخادم (Server) يستقبل الطلب: أول شيء يفعله الخادم هو قراءة هذا الهيدر.
- الخادم يتحقق من المفتاح: يقوم الخادم بالبحث في قاعدة بيانات مؤقتة (مثل Redis أو جدول في قاعدة البيانات) عن هذا المفتاح.
- إذا كان المفتاح غير موجود: هذا طلب جديد. يقوم الخادم بحفظ المفتاح (مع وضع علامة “قيد المعالجة”)، ثم ينفذ العملية كالمعتاد (مثلاً، خصم المبلغ). بعد انتهاء العملية بنجاح، يقوم الخادم بتخزين نتيجة العملية (الاستجابة الكاملة) وربطها بالمفتاح.
- إذا كان المفتاح موجودًا: هذا طلب مكرر! هنا، لا يقوم الخادم بتنفيذ العملية مرة أخرى. بدلًا من ذلك، يسترجع الاستجابة التي قام بتخزينها مسبقًا ويرسلها مرة أخرى للعميل، وكأن شيئًا لم يكن.
بهذه الطريقة، حتى لو أرسل العميل نفس الطلب مع نفس المفتاح ألف مرة، فإن عملية الدفع ستتم مرة واحدة فقط لا غير. مشكلة حلّت! ✅
مثال عملي: كود للتوضيح
لنفترض أننا نستخدم Node.js مع Express. يمكننا كتابة Middleware للتعامل مع هذا المنطق.
1. جهة العميل (Client-Side – JavaScript)
هنا كيف يمكن للعميل إنشاء المفتاح وإرساله باستخدام fetch.
// دالة لإنشاء UUID v4
// في المتصفحات الحديثة، يمكنك استخدام: crypto.randomUUID()
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
async function makePayment() {
const idempotencyKey = generateUUID();
const paymentData = {
amount: 100,
currency: 'USD',
// ... other payment details
};
try {
const response = await fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // ها هو مفتاحنا السري
},
body: JSON.stringify(paymentData),
});
const result = await response.json();
console.log('Payment successful:', result);
} catch (error) {
console.error('Payment failed. You can safely retry with the same idempotencyKey.');
// يمكن إعادة المحاولة بنفس المفتاح هنا
}
}
makePayment();
2. جهة الخادم (Server-Side – Node.js/Express Middleware)
هذا مثال توضيحي مبسط لكيفية بناء Middleware. في الواقع، ستحتاج إلى استخدام قاعدة بيانات حقيقية مثل Redis للسرعة.
// لنفترض أن لدينا مخزنًا بسيطًا (في الواقع استخدم Redis)
const idempotencyStore = new Map();
// هذا هو الـ Middleware تبعنا
function idempotencyMiddleware(req, res, next) {
// نهتم فقط بطلبات POST أو الطلبات التي تحتاج حماية
if (req.method !== 'POST') {
return next();
}
const idempotencyKey = req.headers['idempotency-key'];
// إذا لم يرسل العميل المفتاح، نتجاوز ونكمل (أو نرجع خطأ)
if (!idempotencyKey) {
return next();
}
// هل رأينا هذا المفتاح من قبل؟
if (idempotencyStore.has(idempotencyKey)) {
const cachedResponse = idempotencyStore.get(idempotencyKey);
// إذا كانت العملية لا تزال قيد التنفيذ، نطلب من العميل الانتظار
if (cachedResponse.status === 'processing') {
return res.status(409).json({ message: 'Request is already being processed.' });
}
// إذا انتهت العملية، نرجع النتيجة المخزنة
console.log(`[Idempotency] Returning cached response for key: ${idempotencyKey}`);
return res.status(cachedResponse.statusCode).json(cachedResponse.body);
}
// هذا طلب جديد! لنبدأ بتسجيله
console.log(`[Idempotency] New key received: ${idempotencyKey}. Processing...`);
idempotencyStore.set(idempotencyKey, { status: 'processing' });
// نحتاج لتخزين الاستجابة بعد اكتمالها
const originalJson = res.json;
const originalStatus = res.status;
// نغلف دوال الاستجابة لنتمكن من تخزين النتيجة
res.json = (body) => {
const responseToCache = {
status: 'completed',
statusCode: res.statusCode,
body: body,
};
idempotencyStore.set(idempotencyKey, responseToCache);
// ضبط مؤقت لحذف المفتاح بعد فترة (مثلاً 24 ساعة)
setTimeout(() => idempotencyStore.delete(idempotencyKey), 24 * 60 * 60 * 1000);
return originalJson.call(res, body);
};
res.status = (code) => {
res.statusCode = code;
return originalStatus.call(res, code);
};
// الآن، اسمح للطلب بالمرور إلى المعالج الفعلي
next();
}
// كيف نستخدمه في Express
app.use(idempotencyMiddleware);
app.post('/api/payments', (req, res) => {
// هنا منطق الدفع الفعلي
// ... عملية خصم المبلغ من البطاقة ...
const paymentResult = { transactionId: 'txn_' + Date.now(), status: 'success' };
// عندما نرسل الاستجابة، سيقوم الـ Middleware بتخزينها تلقائيًا
res.status(201).json(paymentResult);
});
نصائح من مطبخ أبو عمر البرمجي
تطبيق المفهوم شيء، وتطبيقه بشكل صحيح في بيئة إنتاج حقيقية شيء آخر. إليكم بعض الخبرات التي تعلمتها بالطريقة الصعبة:
- عمر المفتاح (Key Expiration): لا تحتفظ بالمفاتيح إلى الأبد! هذا سيملأ قاعدة بياناتك بلا داعٍ. قم بتعيين فترة صلاحية (TTL – Time To Live) للمفاتيح، مثلاً 24 ساعة. هذا كافٍ لمعالجة معظم مشاكل الشبكة وإعادات المحاولة دون استهلاك مساحة تخزين دائمة.
- التعامل مع حالات التسابق (Race Conditions): ماذا لو وصل طلبان بنفس المفتاح في نفس اللحظة تمامًا إلى خادمين مختلفين؟ كلاهما سيرى أن المفتاح “جديد”. الحل هو استخدام عملية “قفل” ذرية (Atomic Lock) عند التحقق من المفتاح. أدوات مثل Redis توفر أوامر مثل
SETNX(Set if Not Exists) المصممة خصيصًا لهذه المهمة. - من يُنشئ المفتاح؟: دائمًا وأبدًا، العميل هو المسؤول عن إنشاء المفتاح. إذا أنشأه الخادم، فإنه يفقد كل قيمته.
- ماذا نخزن؟: لا تخزن المفتاح فقط. خزّن الاستجابة الكاملة للطلب الأصلي (كود الحالة + جسم الاستجابة). عندما يأتي طلب مكرر، يجب أن تكون الاستجابة التي ترجعها مطابقة 100% للاستجابة الأصلية.
- لا تفرط في الاستخدام: لست بحاجة لتطبيق هذا على كل Endpoints في نظامك. ركز على العمليات الحرجة وغير المتكررة بطبيعتها (
POSTلإنشاء الطلبات،POSTلعمليات الدفع، إلخ).
الخلاصة يا جماعة 💡
في عالم الأنظمة الموزعة والشبكات التي لا يمكن التنبؤ بها، لم تعد الطلبات المزدوجة احتمالًا، بل هي واقع يجب التعامل معه. تجاهل هذه المشكلة هو وصفة لكارثة محققة ستكلفك الكثير من المال والجهد، والأهم من ذلك، ثقة عملائك.
مفاتيح عدم التكرار (Idempotency Keys) ليست مجرد تقنية معقدة، بل هي درع بسيط وفعّال يحمي عملياتك الحساسة. من خلال جعل العميل والخادم يتفقان على “بصمة” فريدة لكل عملية حرجة، يمكنك تحويل طلبات POST الفوضوية إلى عمليات آمنة وموثوقة يمكن إعادة محاولتها بأمان.
لا تنتظر حتى يأتيك اتصال “الخميس المرعب” من الدعم الفني. ابدأ اليوم بمراجعة نقاط الـ API الحرجة في نظامك، وادمج منطق الـ Idempotency فيها. صدقني، ستنام بشكل أفضل في الليل. ويعطيكم العافية.