“أبو عمر، ولعت الدنيا! العملاء بنخصم منهم مرتين!”
كانت ليلة خميس هادئة، وأنا أستعد لإنهاء أسبوع عمل طويل ومتعب. القهوة في يدي، وآخر “commit” تم دفعه للتو. فجأة، يرن هاتفي. على الطرف الآخر، صوت مدير قسم المحاسبة يملؤه الهلع: “أبو عمر، الحقنا! في مصيبة! نظام الدفع بخصم من العملاء مرتين وثلاث على نفس الطلب!”.
شعرت بدمي يتجمد في عروقي. تركت كل شيء وركضت إلى حاسوبي. فتحت لوحة التحكم الخاصة ببوابة الدفع، لأرى ما لم أكن أتمنى رؤيته أبداً: معاملات دفع مكررة لنفس العملاء، بنفس المبالغ، وبفارق ثوانٍ معدودة فقط. يا إلهي، الكارثة حقيقية. كيف حدث هذا؟ نظامنا متين، واختبرناه مئات المرات.
بعد ساعات من البحث والتدقيق في سجلات الخادم (logs)، وتتبع الطلبات واحداً تلو الآخر، اكتشفنا الجاني البريء: النقرة المزدوجة. مستخدم لديه اتصال إنترنت بطيء، ضغط على زر “تأكيد الدفع” ولم يرَ استجابة فورية، فظن أن النقرة الأولى لم تعمل، فضغط مرة أخرى… وربما ثالثة. الواجهة الأمامية (Frontend) أرسلت طلب `POST` جديداً في كل مرة، والخادم المسكين، الذي لا يملك ذاكرة، استقبل كل طلب على أنه عملية شراء جديدة ومستقلة ونفذها بكل أمانة. كانت تلك الليلة من أصعب الليالي في مسيرتي المهنية، لكنها علمتني درساً لن أنساه أبداً عن قوة مفهوم “العطالة”.
ما هي “العطالة” (Idempotency) في عالم الـ APIs؟ ببساطة يا جماعة
قبل أن نغوص في الحل، دعونا نبسط هذا المصطلح الذي يبدو معقداً. “العطالة” أو Idempotency في سياق الواجهات البرمجية تعني أن تكرار نفس الطلب عدة مرات يُنتج نفس النتيجة والتأثير الذي أنتجه الطلب الأول، دون أي آثار جانبية إضافية.
لنفكر في مثال بسيط من حياتنا اليومية: زر استدعاء المصعد في بنايتك. عندما تضغط عليه لأول مرة، يتم تسجيل طلبك والمصعد يبدأ بالتحرك إليك (هذا هو التأثير الأول). لكن ماذا لو ضغطت على الزر عشر مرات أخرى بينما المصعد قادم؟ لا شيء سيتغير. لن يأتي إليك عشرة مصاعد، ولن يسرع المصعد أكثر. الطلب الأول له تأثير، وكل الطلبات المكررة اللاحقة ليس لها أي تأثير جديد. هذا بالضبط هو مفهوم العطالة.
في عالم REST APIs، بعض الأفعال (HTTP Methods) عاطلة بطبيعتها:
GET: طلب بيانات. يمكنك طلب بيانات المستخدم ألف مرة، وستبقى البيانات كما هي.PUT: تحديث مورد بالكامل. لو أرسلت طلبPUT /users/123لتغيير اسم المستخدم إلى “أحمد”، فإن الطلب الأول سيغيره، والطلبات التالية ستؤكد نفس التغيير، والنتيجة النهائية واحدة.DELETE: حذف مورد. الطلب الأول سيحذف المستخدم/users/123. الطلبات التالية ستجد أن المستخدم غير موجود أصلاً، والنتيجة النهائية واحدة (المستخدم محذوف).
لكن المشكلة الكبرى تكمن في الفعل POST. هذا الفعل يُستخدم لإنشاء مورد جديد. كل طلب POST إلى /orders من المفترض أن يُنشئ طلباً جديداً. وهذا هو أصل الكارثة التي حصلت معنا. كيف نجعل عملية إنشاء حساسة (مثل الدفع أو إنشاء طلب) تتصرف بشكل “عاطل” وآمن؟
الحل السحري: مفاتيح العطالة (Idempotency Keys)
هنا يأتي دور البطل المنقذ: “مفتاح العطالة” أو Idempotency Key. الفكرة عبقرية في بساطتها. هي عبارة عن اتفاق بين العميل (Client) والخادم (Server) لضمان عدم تكرار العمليات الخطرة.
إليكم آلية العمل خطوة بخطوة:
- العميل يُنشئ مفتاحاً فريداً: قبل إرسال الطلب الحساس (مثل طلب الدفع)، يقوم العميل (تطبيق الويب أو الجوال) بإنشاء مُعرّف فريد وخاص بهذه العملية تحديداً. عادة ما يكون هذا المعرّف عبارة عن UUID (Universally Unique Identifier).
- العميل يرسل المفتاح مع الطلب: يضع العميل هذا المفتاح الفريد في ترويسة (Header) خاصة ضمن طلب الـ HTTP، مثلاً:
Idempotency-Key: a1b2c3d4-e5f6-7890-1234-567890abcdef. - الخادم يستقبل الطلب ويتحقق من المفتاح: عندما يستقبل الخادم الطلب، أول شيء يفعله هو النظر إلى هذه الترويسة.
- السيناريو الأول (الطلب جديد): إذا كان الخادم لم يرَ هذا المفتاح من قبل، فإنه يفهم أن هذه عملية جديدة. يقوم بتنفيذ العملية (مثلاً، خصم المبلغ)، ثم يخزن نتيجة هذه العملية (سواء نجحت أم فشلت) مع المفتاح نفسه في مكان مؤقت (مثل قاعدة بيانات Redis) لمدة معينة (مثلاً 24 ساعة). بعد ذلك، يرسل الاستجابة الطبيعية للعميل.
- السيناريو الثاني (الطلب مكرر): إذا استقبل الخادم طلباً آخر يحمل نفس المفتاح (وهذا ما يحدث عند النقرة المزدوجة)، فإنه يبحث في مخزنه المؤقت، يجد المفتاح، ويدرك أنه قد نفذ هذه العملية من قبل. بدلاً من تنفيذها مرة أخرى، يقوم ببساطة بجلب النتيجة المحفوظة مسبقاً وإرسالها مباشرة إلى العميل.
بهذه الطريقة، نحن “نُعلّم” الخادم كيف يتذكر العمليات التي نفذها مؤخراً، ونحول طلب
POSTالخطير إلى عملية آمنة وعاطلة. النقرة الثانية والثالثة لن تؤدي إلى خصم جديد، بل ستحصل على نفس نتيجة الخصم الأول.
لنطبق الأمر عملياً: مثال كود (يا مبرمجين ركزوا معي)
الكلام النظري جميل، لكن دعونا نرى كيف يمكن تطبيق هذا عملياً. سأستخدم مثالاً مبسطاً باستخدام Node.js و Express، لكن المبدأ نفسه ينطبق على أي لغة أو إطار عمل.
أولاً: جهة العميل (Client-Side)
عندما يضغط المستخدم على زر الدفع، نُنشئ مفتاحاً فريداً ونرسله مع الطلب. يمكن استخدام مكتبة مثل uuid أو الدالة المدمجة crypto.randomUUID().
// باستخدام JavaScript Fetch API
async function processPayment() {
const idempotencyKey = crypto.randomUUID(); // مثال: '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed'
try {
const response = await fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // هنا نرسل المفتاح!
},
body: JSON.stringify({
amount: 5000, // 50.00 دولار
currency: 'usd',
orderId: 'ORD-12345'
}),
});
const data = await response.json();
console.log('Payment processed:', data);
// عرض رسالة نجاح للمستخدم
} catch (error) {
console.error('Payment failed:', error);
// عرض رسالة خطأ للمستخدم
}
}
ثانياً: جهة الخادم (Server-Side)
على الخادم، أفضل طريقة لتطبيق هذا هي عبر “برنامج وسيط” (Middleware) يعترض الطلبات قبل وصولها إلى منطق العمل الرئيسي.
// مثال باستخدام Express.js و Redis
const express = require('express');
const redis = require('redis').createClient(); // افترض أن Redis يعمل لديك
const app = express();
app.use(express.json());
(async () => { await redis.connect(); })();
// Middleware للتحقق من العطالة
const idempotencyCheck = async (req, res, next) => {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return next(); // إذا لم يوجد مفتاح، أكمل بشكل طبيعي
}
const key = `idempotency:${idempotencyKey}`;
try {
// 1. التحقق إذا كان المفتاح موجوداً في Redis
const cachedResponse = await redis.get(key);
if (cachedResponse) {
// 2. إذا كان موجوداً، أرجع الاستجابة المخزنة
console.log(`[${idempotencyKey}] Request is a duplicate. Returning cached response.`);
const { status, body } = JSON.parse(cachedResponse);
return res.status(status).json(body);
}
// 3. إذا لم يكن موجوداً، سنحتاج لتخزين الاستجابة بعد اكتمال الطلب
// نحتفظ بالدوال الأصلية للإرسال
const originalJson = res.json;
const originalStatus = res.status;
// "نخطف" استجابة Express لتخزينها قبل إرسالها
res.json = async (body) => {
const responseToCache = {
status: res.statusCode,
body: body
};
// نخزن النتيجة في Redis مع مدة صلاحية (مثلاً 24 ساعة)
await redis.set(key, JSON.stringify(responseToCache), { EX: 24 * 60 * 60 });
console.log(`[${idempotencyKey}] New request. Caching response.`);
originalJson.call(res, body);
};
// نمرر الطلب إلى المتحكم الرئيسي (Controller)
next();
} catch (error) {
console.error('Idempotency middleware error:', error);
next(error);
}
};
// تطبيق الـ Middleware على المسار الحساس
app.post('/api/payments', idempotencyCheck, (req, res) => {
// هذا الكود لن يُنفذ إلا مرة واحدة فقط لكل مفتاح عطالة
console.log(`[${req.headers['idempotency-key']}] Processing new payment for order ${req.body.orderId}...`);
// ...
// هنا تضع منطق معالجة الدفع الفعلي والتواصل مع بوابة الدفع
// ...
const paymentResult = { success: true, transactionId: `txn_${Date.now()}` };
res.status(201).json(paymentResult);
});
app.listen(3000, () => console.log('Server is running on port 3000'));
هذا المثال يوضح الفكرة تماماً. الـ Middleware يعتني بكل شيء، والمنطق الخاص بالدفع يبقى نظيفاً ومركزاً على مهمته الأساسية، مع ضمان أنه لن يتم استدعاؤه مرتين لنفس العملية.
نصائح من خبرة أبو عمر: أشياء لازم تنتبه إلها
تطبيق المبدأ سهل، لكن الشيطان يكمن في التفاصيل. إليك بعض النصائح من أرض المعركة:
من يولّد المفتاح؟ (Who generates the key?)
دائماً وأبداً: العميل. يجب أن يكون العميل هو المسؤول عن إنشاء المفتاح الفريد وإرساله. لا تحاول أبداً إنشاء المفتاح على الخادم بناءً على محتوى الطلب، لأنك لا تستطيع أن تضمن 100% ما إذا كان طلبان متشابهان هما عمليتان منفصلتان أم تكرار لنفس العملية.
أين نخزن المفاتيح؟ (Where to store the keys?)
الخيار الأفضل هو قاعدة بيانات In-Memory سريعة مثل Redis. لماذا؟ لأنها سريعة جداً في القراءة والكتابة، وتدعم بشكل أصلي ميزة تحديد “مدة حياة” للمفتاح (TTL – Time To Live)، وهو ما نحتاجه تماماً.
كم مدة صلاحية المفتاح؟ (How long should the key live?)
لا تحتفظ بالمفاتيح إلى الأبد! هذا سيملأ ذاكرتك بلا داعٍ. مدة 24 ساعة هي معيار شائع ومناسب جداً (تستخدمه شركات كبرى مثل Stripe). من النادر جداً أن يحاول مستخدم إعادة إرسال نفس الطلب بعد 24 ساعة.
تعامل مع الحالات الحرجة (Handling Race Conditions)
ماذا لو وصل طلبان بنفس المفتاح في نفس الميلي ثانية؟ قد يقرأ كلاهما المخزن ولا يجد المفتاح، ثم يحاول كلاهما تنفيذ العملية. هنا تحتاج إلى آلية قفل (Locking). في Redis، يمكنك استخدام أمر مثل SETNX (Set if Not Exists) الذي يضمن أن عملية واحدة فقط هي التي ستنجح في حجز المفتاح أولاً. العملية الثانية ستفشل في حجز المفتاح وتنتظر حتى تظهر النتيجة المخزنة.
الخلاصة: نقرة واحدة آمنة أفضل من ألف نقرة كارثية 😌
في عالم الأنظمة الموزعة والشبكات غير الموثوقة، لم تعد “العطالة” رفاهية، بل هي ضرورة أساسية لبناء أنظمة قوية يمكن الاعتماد عليها، خاصة عندما يتعلق الأمر بالمال أو البيانات الحساسة.
الدرس الذي تعلمته في تلك الليلة القاسية كان ثميناً جداً:
- ✅ بناء الثقة مع المستخدمين يبدأ من ضمان عدم وقوعهم في أخطاء تكلفهم مالهم.
- ✅ مفاتيح العطالة تحميك من كوابيس الدعم الفني والمحاسبة والمشاكل التشغيلية.
- ✅ تطبيقها الصحيح يحول واجهتك البرمجية من مجرد خدمة إلى حصن منيع ضد الأخطاء المكررة.
نصيحتي الأخيرة لك يا صديقي المطور: قبل أن تطلق أي API فيه عمليات مالية أو حساسة، اسأل نفسك هذا السؤال البسيط: “شو بصير لو المستخدم كبس الزر مرتين؟”. إذا كان الجواب “كارثة”، فقد حان الوقت لتستدعي البطل المنقذ: مفتاح العطالة. وسلامتكم!