يا مساء الخير يا جماعة، بتذكر هذاك اليوم زي كأنه امبارح. كنت قاعد على مكتبي، كاسة الشاي بالمرمية جنبي، والهدوء يعم المكان. فجأة، وصلني اتصال من فريق الدعم الفني، وصوت الشب على الطرف الثاني فيه نبرة هلع: “أبو عمر الحقنا! في عملاء بتشكي إنه الدفعة انسحبت منهم مرتين!”.
في هاي اللحظات، قلب المبرمج بنزل في رجليه. يا زلمة شو اللي بصير؟ أول فكرة خطرت ببالي كانت إنه في خطأ كارثي في منطق الدفع اللي كتبته. بدأت أراجع الكود سطر سطر، وأحلل سجلات الخادم (logs)، وما في أي إشارة لخطأ برمجي واضح. كل عملية دفع كانت تبدو سليمة ومنفصلة عن الأخرى. بعد ساعات من البحث والتمحيص، وتحليل سلوك المستخدمين، لاحظنا نمطًا غريبًا: الطلبات المكررة كانت تأتي من نفس المستخدم في فارق زمني لا يتجاوز الثانية أو الثانيتين.
وهنا “أجا الفرج”، استوعبنا المشكلة. المستخدم، بسبب ضعف الإنترنت أو من باب القلق، كان يضغط على زر “ادفع الآن” مرتين أو ثلاث بسرعة. نظامنا، في كل مرة، كان يعتبرها طلبية جديدة تمامًا و”بسم الله” يخصم المبلغ مرة أخرى. كانت كارثة صغيرة، لكنها كانت تكلفنا مالًا وسمعة. وهنا كان لا بد من إيجاد حل جذري، حل اسمه “Idempotency”.
ما هي مشكلة “عدم تكرار الطلب” (Idempotency)؟
في عالم الـ APIs، مصطلح “Idempotent” يعني أن تنفيذ نفس العملية عدة مرات يعطي نفس النتيجة كما لو نُفذت مرة واحدة فقط. فكر فيها: لو طلبت من الخادم “أعطني بيانات المستخدم رقم 5” (عملية GET)، يمكنك أن تطلبها مليون مرة وستحصل دائمًا على نفس البيانات دون تغيير أي شيء في النظام. هذا الطلب بطبيعته Idempotent.
لكن المشكلة تظهر في العمليات التي تغير حالة النظام (Mutative requests)، مثل:
POST: لإنشاء مورد جديد (مثل إنشاء طلبية جديدة، أو إجراء عملية دفع).PUT/PATCH: لتحديث مورد موجود.DELETE: لحذف مورد.
عندما يرسل المستخدم طلب POST لإنشاء عملية دفع، ويتأخر الرد بسبب الشبكة، ماذا يفعل؟ يضغط الزر مرة أخرى. هذا يرسل طلب POST آخر. الخادم، الذي لا يملك ذاكرة “للطوشة” الأولى، يرى طلبًا جديدًا وينفذه بكل سرور، مما يؤدي إلى عملية دفع مكررة. هذه المشكلة ليست حكراً على أنظمة الدفع، بل تمتد إلى أي عملية حساسة: إنشاء حساب مستخدم مرتين، إرسال نفس البريد الإلكتروني مرتين، حجز نفس المقعد في طائرة مرتين.
الحل السحري: مفاتيح عدم تكرار الطلب (Idempotency Keys)
هنا يأتي دور “مفتاح عدم تكرار الطلب” أو الـ Idempotency Key. الفكرة بسيطة بعبقريتها: بدلًا من أن يكون الخادم “أهبل” وينفذ كل ما يأتيه، نجعله يتذكر الطلبات التي نفذها مؤخرًا.
المفتاح هو عبارة عن قيمة فريدة من نوعها (unique identifier) يقوم العميل (المتصفح أو تطبيق الموبايل) بإنشائها لكل عملية “حساسة” يرغب في تنفيذها. ثم يرسل هذا المفتاح مع الطلب، عادةً في ترويسة (Header) خاصة مثل Idempotency-Key.
كيف تعمل هذه المفاتيح؟ (خطوة بخطوة)
عندما يستقبل الخادم طلبًا يحتوي على Idempotency Key، يقوم بالآتي:
- التحقق من المفتاح: يبحث الخادم في مخزن مؤقت (cache) أو قاعدة بيانات عن هذا المفتاح.
- سيناريو (أ): المفتاح جديد ولم يُرَ من قبل.
- ينفذ الخادم العملية المطلوبة (مثلاً، خصم المبلغ).
- يخزن نتيجة العملية (سواء نجحت أم فشلت) مع المفتاح الأصلي.
- يرسل الرد النهائي إلى العميل.
- سيناريو (ب): المفتاح موجود ومكرر.
- يتجاهل الخادم تنفيذ العملية المطلوبة تمامًا.
- يسترجع النتيجة الأصلية المخزنة والمربوطة بهذا المفتاح.
- يرسل نفس الرد الأصلي إلى العميل مرة أخرى.
بهذه الطريقة، حتى لو ضغط المستخدم على الزر 10 مرات، فإن العملية الحقيقية (خصم المبلغ) ستتم مرة واحدة فقط. وفي كل مرة من المرات التسع التالية، سيعيد الخادم نفس الرد الأول وكأن شيئًا لم يكن، مما يضمن سلامة البيانات وراحة بال المستخدم (وجيبته!).
تطبيق عملي: لنكتب بعض الكود
دعنا نرى مثالاً بسيطاً باستخدام Node.js و Express. هذا مجرد مثال توضيحي، في التطبيقات الحقيقية ستحتاج إلى حلول أكثر قوة مثل Redis للتخزين المؤقت.
أولاً: جانب العميل (Client-Side)
قبل إرسال الطلب، نقوم بإنشاء مفتاح فريد. مكتبة مثل uuid ممتازة لهذا الغرض.
// لنفترض أنك تستخدم fetch في المتصفح
// 1. قم بتثبيت مكتبة UUID أو استخدم crypto المدمج
// npm install uuid
import { v4 as uuidv4 } from 'uuid';
async function processPayment() {
const idempotencyKey = uuidv4(); // إنشاء مفتاح فريد لكل محاولة دفع
try {
const response = await fetch('/api/pay', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey // إرسال المفتاح في الترويسة
},
body: JSON.stringify({
amount: 100,
currency: 'USD',
cardToken: 'tok_visa'
})
});
const data = await response.json();
console.log('Payment successful:', data);
} catch (error) {
console.error('Payment failed:', error);
// هنا يمكنك تطبيق منطق إعادة المحاولة بنفس المفتاح
}
}
ثانياً: جانب الخادم (Server-Side Middleware)
سنكتب “وسيطًا” (Middleware) في Express يعترض الطلبات ويطبق منطق عدم التكرار.
// على الخادم (server.js)
const express = require('express');
const app = express();
// للتوضيح فقط، في الواقع استخدم Redis أو ما شابه
const idempotencyStore = new Map();
// Middleware للتحقق من عدم التكرار
const idempotencyMiddleware = async (req, res, next) => {
const idempotencyKey = req.headers['idempotency-key'];
// إذا لم يوجد مفتاح، استمر بشكل طبيعي
if (!idempotencyKey) {
return next();
}
// إذا كان المفتاح موجودًا في المخزن، أرجع النتيجة المخزنة
if (idempotencyStore.has(idempotencyKey)) {
console.log(`[${idempotencyKey}] Request already processed. Returning cached response.`);
const cachedResponse = idempotencyStore.get(idempotencyKey);
return res.status(cachedResponse.statusCode).json(cachedResponse.body);
}
// إذا كان المفتاح جديدًا، نحتاج لتخزين الاستجابة بعد اكتمالها
const originalJson = res.json;
const originalStatus = res.status;
res.status = (statusCode) => {
res.statusCode = statusCode;
return res;
};
res.json = (body) => {
const responseToCache = { statusCode: res.statusCode || 200, body };
idempotencyStore.set(idempotencyKey, responseToCache);
// من الجيد تحديد عمر للمفتاح (مثلاً 24 ساعة)
setTimeout(() => idempotencyStore.delete(idempotencyKey), 24 * 60 * 60 * 1000);
res.json = originalJson; // استعادة الدالة الأصلية
return res.status(responseToCache.statusCode).json(body);
};
next();
};
app.post('/api/pay', idempotencyMiddleware, (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
console.log(`[${idempotencyKey}] Processing new payment request...`);
// ... هنا منطق الدفع الفعلي مع بوابة الدفع ...
// ... محاكاة لعملية ناجحة ...
const paymentResult = {
success: true,
transactionId: `txn_${Date.now()}`
};
res.status(201).json(paymentResult);
});
app.listen(3000, () => console.log('Server running on port 3000'));
نصائح من خبرة أبو عمر
بعد سنوات من التعامل مع هذه الأنظمة، اسمحوا لي أن أقدم لكم بعض النصائح العملية:
1. اختر المفتاح بعناية
يجب أن يكون المفتاح فريدًا عالميًا (Universally Unique) ويتم إنشاؤه من طرف العميل. استخدام UUID (v4) هو الخيار الأمثل. لا تستخدم أبدًا بيانات قد تتكرر مثل اسم المستخدم أو وقت الطلب وحده.
2. أين تخزن المفاتيح؟
المثال أعلاه يستخدم Map في الذاكرة، وهذا لا يصلح للإنتاج لأنه سيفقد كل شيء عند إعادة تشغيل الخادم ولا يعمل في بيئة متعددة الخوادم (multi-instance). الحل الأفضل هو استخدام مخزن خارجي سريع مثل Redis أو Memcached. ولا تنسَ وضع تاريخ انتهاء صلاحية (TTL) للمفتاح (مثلاً 24 ساعة) لتجنب امتلاء الذاكرة إلى الأبد.
3. انتبه لحالة السباق (Race Condition)
ماذا لو وصل طلبان بنفس المفتاح في نفس الملي ثانية؟ قد يتجاوز كلاهما فحص “هل المفتاح موجود؟” ويبدآن في تنفيذ العملية. الحل هو استخدام آلية قفل (locking). عند استلام مفتاح جديد، يجب “قفل” هذا المفتاح في Redis، ثم تنفيذ العملية، ثم تخزين النتيجة، وأخيرًا تحرير القفل. هذا يضمن أن طلبًا واحدًا فقط هو الذي سينفذ العملية.
4. ليست كل الطلبات بحاجة لهذا التعقيد
هذه التقنية ضرورية للعمليات التي تغير البيانات (POST, PUT, PATCH, DELETE) والتي لا يمكن تكرارها بأمان. أما طلبات GET و OPTIONS و HEAD فهي بطبيعتها آمنة للتكرار (idempotent)، فلا داعي لتعقيدها.
الخلاصة: نقرة واحدة، عملية واحدة 👍
في النهاية، قد تبدو مفاتيح عدم التكرار تفصيلاً صغيرًا، لكنها في الحقيقة أحد أعمدة بناء أنظمة قوية وموثوقة، خاصة في المجالات المالية والتجارة الإلكترونية. إنها الخط الفاصل بين نظام محترف يتعامل مع فوضى الإنترنت بذكاء، ونظام بدائي يسبب المشاكل لعملائك ولك.
الدرس الذي تعلمته في ذلك اليوم العصيب هو أننا كمطورين، يجب ألا نثق أبدًا بالشبكة أو بسلوك المستخدم. مهمتنا هي توقع الأسوأ وبناء أنظمة قادرة على الصمود. فلا تستهينوا بقوة النقرة المزدوجة، ففي عالم البرمجة، الوقاية خير من ألف علاج.