يا أهلًا وسهلا فيكم يا جماعة الخير. اسمي أبو عمر، مبرمج فلسطيني قضيت سنين عمري بين الأكواد والخوارزميات، وشفت العجب العُجاب في عالم التكنولوجيا. اليوم بدي أحكيلكم قصة صارت معي ومع فريقي، قصة فيها شوية توتر، شوية قهوة زيادة عن اللزوم، وفي نهايتها درس مهم جدًا لكل مطور بتعامل مع أنظمة حساسة.
كنا في فترة إطلاق نظام دفع جديد لواحد من عملائنا الكبار في مجال التجارة الإلكترونية. الأمور كانت ماشية زي الحلاوة، والنظام شغال زي الساعة. بعد أسبوع من الإطلاق، وفي صباح يوم خميس، وصلني اتصال من مدير المشروع، صوته كان فيه نبرة قلق ما بتريحش: “أبو عمر، الحق! في عميل بحكي إنه دفع حق الطلبية مرتين! والمصاري انخصمت من حسابه مرتين!”.
قلبي وقتها نزل عند ركبي، زي ما بحكوها. فكرة إنه نظامنا بخربط وبخصم مصاري الناس بالزيادة هي كابوس أي مبرمج. على طول جمعت الفريق، وصرنا زي خلية النحل، واحد براجع سجلات قاعدة البيانات (Logs)، وواحد بفحص بوابة الدفع، وأنا كنت بحاول أحلل طلبات الـ API اللي وصلت سيرفراتنا. بعد ساعات من البحث والتدقيق، اكتشفنا إشي غريب: وصلنا طلبين متطابقين تمامًا لنفس عملية الدفع، بفارق ثانيتين بس! الطلب الأول نجح، والثاني كمان نجح، وبالتالي العميل المسكين اندفع مرتين.
لكن السؤال المحيّر: ليش العميل يطلب مرتين؟ تواصلنا مع العميل وفهمنا منه إنه الشبكة عنده كانت بطيئة، ضغط على زر “تأكيد الدفع”، وما صار إشي، فكّر إنه الضغطة ما انحسبت، فضغط مرة ثانية. وهون كانت الكارثة. التطبيق من جهة العميل أرسل الطلب الأول، وبسبب ضعف الشبكة، ما وصله رد من السيرفر تبعنا بالوقت المناسب (Timeout). فمنطق إعادة المحاولة (Retry Logic) في التطبيق اشتغل تلقائيًا وأرسل الطلب مرة ثانية. بالنسبة لسيرفرنا، هدول طلبين جداد ومنفصلين، فقام بمعالجتهم الاثنين. यहीं से بدأت رحلتنا مع مفهوم الـ Idempotency.
ما هي مشكلة الطلبات المكررة؟
في عالم الأنظمة الموزعة (Distributed Systems)، واللي بتعتمد فيه تطبيقات الموبايل والويب على التواصل مع سيرفرات بعيدة عبر الإنترنت، مشاكل الشبكة هي أمر وارد وطبيعي. ممكن الإنترنت يفصل للحظة، ممكن يصير بطء مفاجئ، أو ممكن المستخدم نفسه يضغط على زر معين مرتين بالخطأ.
لما هاي المشاكل تصير مع عمليات حساسة زي:
- إنشاء عملية دفع.
- تقديم طلب شراء.
- إرسال حوالة مالية.
- حجز مقعد في طيارة.
النتيجة ممكن تكون كارثية. تخيل إنك تحجز مقعد وينحجزلك مقعدين، أو تشتري غرض وتدفع حقه مرتين! هاي المشكلة بتصير لأنه عمليات الـ POST في بروتوكول HTTP، بطبيعتها، هي عمليات “غير آمنة للتكرار” (Non-idempotent). كل مرة بتستدعيها، بتنفذ إشي جديد على السيرفر.
مفهوم عدم التكرار (Idempotency)
في الرياضيات وعلوم الحاسوب، العملية “Idempotent” هي العملية اللي لو نفذتها مرة أو ألف مرة، النتيجة النهائية بتكون نفسها كأنك نفذتها مرة واحدة بس.
خلونا ناخد مثال بسيط من حياتنا اليومية: زر استدعاء المصعد. لما تضغط عليه أول مرة، المصعد بستجيب وببدأ يجيلك. لو رجعت ضغطت عليه عشر مرات ورا بعض، هل رح يوصلك عشر مصاعد؟ لأ طبعًا، النتيجة النهائية واحدة: مصعد واحد قادم. عملية الضغط على الزر بعد المرة الأولى هي عملية “Idempotent”.
في عالم الـ REST APIs، بعض الأفعال (Verbs) مصممة لتكون idempotent بطبيعتها:
GET: طلب البيانات. لو طلبت بيانات مستخدم ألف مرة، ما رح يتغير إشي في بياناته.PUT: تحديث مورد بالكامل. لو أرسلت طلب لتحديث اسم مستخدم ليصير “أحمد”، وأرسلت نفس الطلب 5 مرات، النتيجة النهائية هي إن اسمه رح يضل “أحمد”.DELETE: حذف مورد. لو حذفت مستخدم، ورجعت طلبت حذفه مرة ثانية، رح تلاقي إنه محذوف أصلًا. النتيجة النهائية واحدة.
المشكلة الحقيقية تكمن في POST، اللي غالبًا ما يستخدم لإنشاء موارد جديدة. كل طلب POST يُفترض أن يُنشئ شيئًا جديدًا.
الحل السحري: مفاتيح عدم تكرار العملية (Idempotency Keys)
بعد ما فهمنا المشكلة، كان لازم نلاقي حل يخلي عمليات الـ POST الحساسة عنا تتصرف كأنها Idempotent. الحل هو نمط تصميمي مشهور جدًا، بتستخدمه شركات عملاقة زي Stripe وPayPal، واسمه “Idempotency Keys”.
الفكرة بسيطة وعبقرية بنفس الوقت:
- من جهة العميل (Client-Side): قبل إرسال أي طلب حساس (مثل عملية دفع)، يقوم العميل بإنشاء “مفتاح” فريد وخاص بهذه العملية. هذا المفتاح عادةً ما يكون UUID (Universally Unique Identifier).
- إرسال الطلب: يرسل العميل هذا المفتاح مع الطلب، غالبًا في ترويسة (Header) خاصة مثل
Idempotency-Key. - من جهة الخادم (Server-Side): لما الخادم يستقبل الطلب، أول إشي بعمله هو إنه بفحص هاي الترويسة.
- إذا كان المفتاح جديدًا (أول مرة يشوفه): يقوم الخادم بمعالجة الطلب كالمعتاد. وقبل ما يرجع الجواب للعميل، بقوم بتخزين نتيجة هاي العملية (الجواب نفسه، سواء كان نجاح أو فشل) مع المفتاح الفريد في مكان مؤقت (مثل قاعدة بيانات سريعة زي Redis أو جدول في قاعدة البيانات الرئيسية).
- إذا كان المفتاح مكررًا (الخادم شافه قبل هيك): هنا يكمن السحر! الخادم ما بعيد معالجة الطلب من الصفر. بدلًا من ذلك، بروح مباشرةً على المكان اللي خزّن فيه النتيجة الأولى، وبيرجعها نفسها للعميل كما هي.
بهذه الطريقة، حتى لو العميل أرسل نفس الطلب 10 مرات بنفس المفتاح، العملية الحقيقية (مثل خصم المال) رح تتنفذ مرة واحدة فقط! والعميل رح يوصله نفس الجواب في كل مرة، كأنه الطلب نجح من أول مرة.
مثال عملي بالكود
لنفترض أن العميل (تطبيق ويب مكتوب بـ JavaScript) يريد إنشاء عملية دفع:
كود جهة العميل (Client-Side)
// 1. إنشاء مفتاح فريد لكل محاولة دفع (وليس لكل ضغطة زر)
// يجب تخزين هذا المفتاح لضمان استخدامه في محاولات الإعادة لنفس العملية
function createPayment(paymentData) {
let idempotencyKey = localStorage.getItem('current_payment_key');
if (!idempotencyKey) {
idempotencyKey = self.crypto.randomUUID(); // e.g., 'f1c7a4b2-a8f8-4f5f-8d1e-2d8a9b3c4d5e'
localStorage.setItem('current_payment_key', idempotencyKey);
}
fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // 2. إرسال المفتاح في الترويسة
},
body: JSON.stringify(paymentData)
})
.then(response => {
if (response.ok) {
// نجحت العملية، يمكننا الآن حذف المفتاح
localStorage.removeItem('current_payment_key');
return response.json();
}
// إذا فشل الطلب بسبب مشكلة شبكة، المحاولة التالية ستستخدم نفس المفتاح
})
.then(data => console.log('Payment successful:', data))
.catch(error => console.error('Payment failed:', error));
}
منطق جهة الخادم (Server-Side) – مثال مبسط باستخدام Express.js
هنا سنصمم Middleware بسيط لاعتراض الطلبات والتحقق من المفتاح.
// لنفترض أننا نستخدم Redis لتخزين المفاتيح والنتائج
const redisClient = require('./redisClient');
async function idempotencyMiddleware(req, res, next) {
// هذا المنطق ينطبق فقط على العمليات التي تحتاج لضمان عدم التكرار
if (req.method !== 'POST') {
return next();
}
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
// إذا لم يتم إرسال المفتاح، تعامل مع الطلب بشكل طبيعي (أو أرجع خطأ)
return next();
}
const cacheKey = `idempotency:${idempotencyKey}`;
try {
// 1. تحقق إذا كانت النتيجة مخزنة مسبقًا
const cachedResponse = await redisClient.get(cacheKey);
if (cachedResponse) {
console.log(`[${idempotencyKey}] Returning cached response.`);
const { status, body } = JSON.parse(cachedResponse);
return res.status(status).json(body);
}
// 2. إذا لم تكن مخزنة، سنحتاج لتخزينها بعد المعالجة
// نستخدم خدعة صغيرة لاعتراض الجواب قبل إرساله
const originalJson = res.json;
const originalStatus = res.status;
res.json = (body) => {
// فقط خزّن النتيجة إذا كانت العملية ناجحة (2xx)
if (res.statusCode >= 200 && res.statusCode {
// ... هنا منطق معالجة الدفع الحقيقي ...
// هذا الكود سيتنفذ مرة واحدة فقط لكل مفتاح
console.log(`[${req.headers['idempotency-key']}] Processing new payment...`);
const paymentResult = { transactionId: 'txn_' + Date.now(), status: 'completed' };
res.status(201).json(paymentResult);
});
ملاحظة هامة: المثال أعلاه هو نسخة مبسطة للتوضيح. في الأنظمة الحقيقية، يجب التعامل مع حالات أكثر تعقيدًا مثل “السباق” (Race Conditions) حيث يصل طلبان بنفس المفتاح في نفس اللحظة تمامًا. يمكن حل هذه المشكلة باستخدام آليات القفل (Locking) في Redis أو قاعدة البيانات.
نصائح من خبرة أبو عمر
- لا تخزن المفاتيح إلى الأبد: لا يوجد سبب يدعو للاحتفاظ بمفتاح عدم التكرار إلى ما لا نهاية. حدد فترة صلاحية (TTL – Time To Live) معقولة، مثلاً 24 ساعة. هذا يمنع قاعدة بياناتك من الامتلاء ببيانات قديمة لا فائدة منها.
- اختر وسيلة التخزين المناسبة: Redis يعتبر خيارًا ممتازًا لتخزين المفاتيح والنتائج بسبب سرعته الفائقة ودعمه المدمج لفترة الصلاحية (TTL). إذا لم يكن Redis متاحًا، يمكن استخدام جدول عادي في قاعدة بياناتك، لكن تأكد من وجود فهرس (index) على عمود المفتاح لتسريع البحث.
- العميل هو المسؤول عن المفتاح: تأكد من أن منطق إنشاء المفتاح موجود بالكامل في جهة العميل. إذا قام الخادم بإنشاء المفتاح، فإننا نفقد كل الفائدة المرجوة.
- خزّن الاستجابة الناجحة فقط: من الأفضل أن تقوم بتخزين نتيجة الطلب فقط عندما يكون ناجحًا (HTTP Status 2xx). إذا فشل الطلب (بسبب خطأ في البيانات مثلاً – 4xx، أو خطأ في الخادم – 5xx)، لا تقم بتخزين هذه النتيجة. هذا يسمح للعميل بتصحيح الخطأ وإعادة المحاولة بنفس المفتاح.
- ليست كل الـ APIs تحتاج لهذا: لا تبالغ في استخدام هذا النمط. طبقه فقط على العمليات الحرجة وغير المتكررة بطبيعتها (Critical, Non-Idempotent Actions) مثل إنشاء الطلبات والمدفوعات.
الخلاصة: راحة بال لا تقدر بثمن
بعد ما طبقنا نظام مفاتيح عدم التكرار، ارتحنا وريّحنا عملائنا. لم نعد نخاف من كبسة الزر المزدوجة أو من تقطعات الشبكة. صار نظامنا أكثر قوة وصلابة، وقادر على التعامل مع ظروف الإنترنت غير المثالية بكل ثقة.
قد يبدو تطبيق هذا النمط مجهودًا إضافيًا في البداية، لكن صدقني، هو استثمار بسيط يمنحك راحة بال لا تقدر بثمن، ويحمي سمعة نظامك ومصداقيته أمام المستخدمين. تذكر دائمًا: بناء الثقة مع العميل يبدأ من حماية أبسط حقوقه، ومنها أن لا يدفع ثمن السلعة مرتين! 🙏