المستوى المطلوب لهذا المقال: متوسط. يفترض إنك تعرف REST APIs الأساسية وعندك فكرة عن قواعد البيانات، لكن مش لازم تكون اشتغلت على أنظمة دفع قبل كده.
Idempotency Key: ازاي تمنع الخصم المزدوج لما الطلب يتكرر
لو عميل دفع مرة واحدة لكن اتخصم منه مرتين، المشكلة غالبًا مش في بوابة الدفع. المشكلة في إن السيرفر بتاعك بيعامل كل طلب HTTP كأنه طلب جديد، حتى لو كان نفس الطلب اتبعت مرتين. الحل في سطر واحد اسمه Idempotency-Key، وهنبنيه بالتفاصيل.
المشكلة باختصار
تخيّل عندك متجر بـ 50 ألف طلب دفع في اليوم. العميل يضغط زر Pay، الصفحة تتأخر شوية، فيضغط تاني. أو الموبايل يقطع النت بعد ما الطلب وصل فعلاً للسيرفر، فالتطبيق يعيد الإرسال تلقائيًا (retry). النتيجة: طلبين متطابقين يوصلوا للسيرفر، والسيرفر يخصم مرتين. حتى لو نسبة التكرار 1% بس، ده 500 خصم مزدوج في اليوم، وكل واحد فيهم شكوى عميل أو استرجاع فلوس.
المفهوم بمثال بسيط الأول
تخيّل شباك تذاكر سينما. انت معاك رقم حجز مكتوب على ورقة. لو رحت للموظف وقلتله "احجزلي على الرقم ده"، هو يحجز ويكتب الرقم في دفتره. لو رجعت تاني بنفس الورقة ونفس الرقم، الموظف يبص في دفتره ويلاقي الرقم ده اتحجز خلاص، فيقولك "تذكرتك جاهزة" ويديك نفس التذكرة. مش هيحجزلك كرسي تاني ولا يطلب منك فلوس تاني.
الرقم اللي على الورقة هو الـ Idempotency-Key. ودفتر الموظف هو مكان تخزين المفاتيح اللي اتعاملنا معاها. الفكرة كلها: نفس المفتاح يدخل، نفس الرد يطلع، والتأثير الجانبي (الخصم) يحصل مرة واحدة بس.
التعريف الدقيق
العملية تكون idempotent لو تنفيذها مرة واحدة زي تنفيذها عدة مرات بنفس المدخلات، من ناحية التأثير على حالة النظام. في HTTP، أفعال زي GET و PUT و DELETE تُعتبر idempotent بطبيعتها حسب توثيق MDN، لكن POST مش كذلك، لأنه بيُنشئ مورد جديد كل مرة. عشان كده عمليات الدفع (اللي بتكون POST) محتاجة آلية صريحة نضمن بيها الـ idempotency. الآلية دي هي ترويسة Idempotency-Key: العميل بيولّد مفتاح فريد (UUID مثلاً) لكل عملية، ويبعته مع كل محاولة لنفس العملية. السيرفر يخزّن المفتاح مع نتيجة أول تنفيذ، ولو وصله نفس المفتاح تاني يرجّع النتيجة المخزّنة بدون إعادة التنفيذ.
الحل خطوة بخطوة بالكود
الطريقة الأبسط والأقوى: عمود UNIQUE في قاعدة البيانات يمنع تسجيل نفس المفتاح مرتين. خلي الجدول كده في PostgreSQL:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
response_body JSONB NOT NULL,
status_code INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
وفي Express، المعالج بياخد المفتاح من الترويسة، ويحاول يحجزه قبل ما ينفّذ الخصم. القيد PRIMARY KEY هو اللي بيحسم السباق لو طلبين وصلوا في نفس اللحظة:
app.post("/payments", async (req, res) => {
const key = req.header("Idempotency-Key");
if (!key) return res.status(400).json({ error: "missing Idempotency-Key" });
// 1) لو المفتاح موجود قبل كده، رجّع نفس الرد المخزّن
const seen = await db.query(
"SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
[key]
);
if (seen.rows.length) {
const r = seen.rows[0];
return res.status(r.status_code).json(r.response_body);
}
// 2) نفّذ الخصم مرة واحدة
const charge = await paymentGateway.charge(req.body); // العملية ذات التأثير الجانبي
const body = { id: charge.id, amount: charge.amount, status: "paid" };
// 3) خزّن المفتاح + الرد؛ لو طلب متزامن سبقك، القيد UNIQUE هيرفض
try {
await db.query(
"INSERT INTO idempotency_keys(key, response_body, status_code) VALUES ($1, $2, 201)",
[key, body]
);
} catch (e) {
if (e.code === "23505") { // unique_violation: حصل طلب مكرر متزامن
const r = (await db.query(
"SELECT response_body, status_code FROM idempotency_keys WHERE key = $1", [key]
)).rows[0];
return res.status(r.status_code).json(r.response_body);
}
throw e;
}
return res.status(201).json(body);
});
لو عايز طبقة أسرع قبل ما تلمس قاعدة البيانات، استخدم Redis مع SET key value NX. الأمر ده بينجح بس لو المفتاح مش موجود، فبيشتغل كـ lock ذري في عملية واحدة:
# بيرجّع OK لأول طلب، و(nil) لأي طلب مكرر خلال 24 ساعة
SET idem:a3f9-c7e1 "locked" NX EX 86400
الرسم ده بيلخّص دورة حياة الطلب: الفرع الأول بينفّذ الخصم مرة واحدة، والفرع المكرر بيرجّع نفس الرد بدون أي تأثير جانبي جديد.
الـ trade-offs اللي لازم تعرفها
- مين يولّد المفتاح؟ العميل لازم يولّده ويثبّته طول عمر العملية الواحدة. لو ولّدت مفتاح جديد مع كل محاولة، فقدت الحماية كلها. المكسب: حماية حقيقية. الثمن: منطق إضافي في الـ frontend.
- مدة صلاحية المفتاح (TTL). لو خزّنت المفاتيح للأبد، الجدول هيكبر بلا نهاية. TTL = 24 ساعة كفاية لمعظم حالات الدفع. الافتراض هنا إن العميل مش هيعيد نفس العملية بعد يوم.
- الرد المخزّن قديم. لو الطلب المكرر بيرجّع رد محفوظ من ساعة، العميل بياخد لقطة قديمة. ده مقبول في الدفع، لكن مش مناسب لو الرد بيحتوي بيانات بتتغير بسرعة.
- الطلبات المتزامنة فعلاً. القيد UNIQUE في قاعدة البيانات بيحسم السباق، بس Redis NX بيكون أسرع كطبقة أولى. الـ trade-off: طبقة كاش زيادة مقابل تعقيد إضافي في البنية.
متى لا تستخدم هذه الطريقة
متحطّش Idempotency-Key على عمليات القراءة (GET) لأنها idempotent أصلاً وملهاش تأثير جانبي. وكمان متستخدمهوش كبديل عن قفل منطق الأعمال المعقّد: لو عندك عملية بتعدّل أكتر من جدول ومحتاجة اتساق قوي، هتحتاج معاملة (transaction) كاملة مش بس مفتاح. والافتراض إن حجم تكرارك معقول؛ لو عندك ملايين المفاتيح في الدقيقة، راجع استراتيجية التخزين والـ TTL قبل ما تعتمد على جدول واحد.
الخطوة التالية
افتح أقرب endpoint عندك بيعمل POST بتأثير جانبي (دفع، إنشاء طلب، إرسال إيميل)، وضيف عمود UNIQUE لتخزين المفتاح زي المثال فوق. جرّب تبعت نفس الطلب مرتين بنفس الـ Idempotency-Key وتأكد إن التأثير حصل مرة واحدة بس. لو اتخصم مرتين، يبقى المفتاح مش بيتحجز قبل تنفيذ العملية؛ راجع ترتيب الخطوات.
المصادر
- Stripe — Idempotent requests: docs.stripe.com/api/idempotent_requests (آلية Idempotency-Key وتخزين الرد).
- IETF — The Idempotency-Key HTTP Header Field (draft): datatracker.ietf.org (مواصفة الترويسة).
- MDN — Idempotent (تعريف الأفعال idempotent في HTTP): developer.mozilla.org.
- PostgreSQL 16 — INSERT و القيود الفريدة: postgresql.org/docs/16/sql-insert.html (كود الخطأ 23505 unique_violation).
- Redis — أمر SET مع خيار NX: redis.io/docs/latest/commands/set.