- الصيغة
- هندسة معمّقة
- موجّه إلى
- للمهندسين والقادة التقنيين
- المحور
- هندسة الموثوقية
مفاتيح عدم التكرار عقدٌ لا ترويسة: تصميم عمليات آمنة عند إعادة المحاولة
إعادة المحاولة لا تكون آمنة إلا إذا التزم الخادم مسبقًا بما يحدث في المرة الثانية. يصمم هذا العدد ذلك الالتزام من جدول المفاتيح حتى مستهلكي الطوابير والاستدعاءات الخارجية.
يرسل تطبيق جوال طلب POST /orders. يتحقق الخادم من الطلب، ويسجّل الطلبية، ويخصم المبلغ من البطاقة، ثم يبدأ بإرسال الاستجابة. في مكان ما بين الخادم والهاتف تنتهي مهلة موازن الأحمال، أو يدخل القطار نفقًا. يرى التطبيق خطأً فيفعل ما يُنصح به كل عميل متين: يعيد المحاولة. أما هل أصبح لدى العميل طلبية واحدة أم اثنتان، فيتوقف كليًا على قرار اتخذه أحدهم، أو أغفله، عند تصميم نقطة النهاية. الإجابة المعتادة هي مفاتيح عدم التكرار (Idempotency keys)، لكن إرسال ترويسة Idempotency-Key لا يحقق شيئًا بمفرده. الترويسة هي الطرف الظاهر من عقد، والعقد نفسه يعيش في طبقة التخزين وحدود المعاملات والاستدعاءات التي تجريها إلى أنظمة أخرى.
انتهاء المهلة ليس فشلًا، بل نتيجة مجهولة
أصل المشكلة أن العميل لا يستطيع التمييز بين «الطلب لم يصل أصلًا» و«الطلب نجح وضاعت الاستجابة». الحالتان تظهران له على شكل انتهاء مهلة أو اتصال مقطوع. والتصرف الآمن الوحيد أمام نتيجة مجهولة هو تكرار الطلب، والتكرار لا يكون آمنًا إلا إذا عامل الخادم الطلب المكرر على أنه الطلب نفسه.
لدى بروتوكول HTTP مفردات جاهزة لهذا. يعرّف RFC 9110 الطريقة بأنها عديمة الأثر عند التكرار إذا كان الأثر المقصود لعدة طلبات متطابقة مساويًا لأثر طلب واحد؛ وتنطبق هذه الصفة على PUT وDELETE والطرق الآمنة، ولا تنطبق على POST وPATCH. هنا نقطتان تستحقان الانتباه. الأولى أن هذا وصفٌ للدلالة المقصودة، وليس ضمانًا يفرضه تنفيذك. فمعالج DELETE يكتب سجل تدقيق ويرسل بريدًا إلكترونيًا في كل مرة يُستدعى فيها هو عديم الأثر في المواصفة فقط، لا في الواقع. والثانية أن معظم العمليات التجارية التي تُنشئ شيئًا، كالطلبية أو الدفعة أو الحجز، تكون بطبيعتها POST، وهذا تحديدًا موضع الألم عند إعادة المحاولة.
والمشكلة نفسها تظهر بعيدًا عن HTTP. فوسطاء الرسائل يقدمون في الغالب تسليمًا «مرة واحدة على الأقل»، أي أن المستهلك سيرى الرسالة نفسها مرتين في وقت ما: بعد إعادة توزيع الأقسام، أو تعطلٍ قبل الإقرار بالاستلام، أو انتهاء مهلة الإخفاء أثناء مهمة بطيئة. وإن كنت تعتمد على الطوابير، فإن التصميم الموصوف في المعمارية القائمة على الطوابير للمعالجة الموثوقة لا يبقى صحيحًا إلا إذا كان كل مستهلك عديم الأثر عند التكرار.
ما الذي تَعِد به مفاتيح عدم التكرار فعلًا
مفتاح عدم التكرار معرّفٌ يولّده العميل لعملية واحدة مقصودة. والعقد الذي يلتزم به الخادم حين يقبله له أربعة بنود، وأي تنفيذ يتجاوز أحدها لم يكتمل بعد.
البند الأول: المفتاح نفسه مع الطلب نفسه ينتج الأثر مرة واحدة، وكل تكرار يتلقى استجابة مكافئة للاستجابة الأصلية. وكلمة «مكافئة» مهمة هنا. فإعادة 409 Already exists عند إعادة المحاولة خطأ شائع، لأن العميل لا يستطيع أن يعرف هل أنشأ طلبُه هو المورد أم طلبُ غيره. وتصف أمازون ذلك في مقالها عن الواجهات عديمة الأثر ضمن مكتبة البنّائين (Builders' Library): يجب أن تحمل الاستجابة عند إعادة المحاولة المعنى نفسه الذي حملته الأولى.
البند الثاني: المفتاح نفسه مع طلب مختلف خطأٌ، لا إعادةٌ صامتة للاستجابة القديمة. فإذا أعاد العميل استخدام المفتاح لمبلغ مختلف، فإن إرجاع الاستجابة السابقة يخفي خللًا حقيقيًا. وتوصي مسودة مجموعة عمل HTTPAPI في IETF لترويسة Idempotency-Key بالرمز 422 Unprocessable Content لهذه الحالة، و409 Conflict حين يكون طلب بالمفتاح نفسه لا يزال قيد المعالجة، و400 Bad Request حين يغيب مفتاح مطلوب. وحتى أواخر 2026 لا تزال الوثيقة مسودة إنترنت (المراجعة 08) ولم تصبح RFC، فتعامل معها على أنها عرفٌ لا معيار؛ والرموز التي تقترحها عرفٌ معقول يحسن اتباعه.
البند الثالث: للمفتاح نطاق. فهو فريد لكل حساب أو لكل بيانات اعتماد للواجهة، لا على مستوى النظام كله. وإلا فقد يتصادم عميلان تصادف أن ولّدت تطبيقاتهما السلسلة نفسها، والأسوأ أن يتلقى أحدهما الاستجابة المخزنة للآخر.
البند الرابع: للمفتاح عمر محدد، والعميل يعرفه. فشركة Stripe مثلًا توثّق أن المفاتيح في الإصدار الأول من واجهتها (v1) قد تُحذف بعد مرور 24 ساعة على الأقل على إنشائها، وبعدها يُعامَل المفتاح المعاد استخدامه كطلب جديد. ويجب أن تكون مدة احتفاظك بالمفاتيح أطول من أطول نافذة إعادة محاولة لدى أي عميل، بما في ذلك تطبيقات الجوال التي تحتفظ بالعمل في طابور دون اتصال لأيام.
ومن البند الأول ينتج قرار تصميمي واضح: يجب أن يأتي المفتاح من العميل، وأن يُولَّد مرة واحدة لكل نية لدى المستخدم، ثم يُعاد استخدامه في كل محاولة لتلك النية. أما اشتقاق المفتاح على الخادم من محتوى الطلب فهو مغرٍ وخاطئ، لأن طلبين متطابقين قد يمثلان نيتين مختلفتين مشروعتين، كعميل يريد فعلًا صنفين متطابقين في طلبيتين منفصلتين.
التخزين الذي يجعل العقد حقيقيًا
أبسط تنفيذ صحيح يحتفظ بالمفتاح وبصمة الطلب والاستجابة المخزنة في قاعدة البيانات نفسها التي تحوي بيانات العمل، بحيث يُثبَّت تسجيل المفتاح وتنفيذ العمل معًا أو يُتراجع عنهما معًا.
CREATE TABLE idempotency_keys ( account_id bigint NOT NULL, idem_key text NOT NULL, request_hash bytea NOT NULL, state text NOT NULL CHECK (state IN ('started', 'completed')), response_code int, response_body jsonb, locked_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (account_id, idem_key) );
يحجز المعالج المفتاح بعملية كتابة لا يمكن أن تنجح مرتين، ثم إما أن ينفذ العمل وإما أن يعيد ما هو مخزن.
async function createOrderHandler(req: ApiRequest, db: Db): Promise<ApiResponse> { const key = req.header('Idempotency-Key'); if (!key) return json(400, { error: 'Idempotency-Key header is required' }); const hash = sha256(`${req.method} ${req.route}\n${canonicalJson(req.body)}`); return db.transaction(async (tx) => { const claimed = await tx.query( `INSERT INTO idempotency_keys (account_id, idem_key, request_hash, state) VALUES ($1, $2, $3, 'started') ON CONFLICT (account_id, idem_key) DO NOTHING RETURNING idem_key`, [req.accountId, key, hash]); if (claimed.rowCount === 0) { const { rows: [row] } = await tx.query( `SELECT request_hash, state, response_code, response_body FROM idempotency_keys WHERE account_id = $1 AND idem_key = $2 FOR UPDATE`, [req.accountId, key]); if (!row.request_hash.equals(hash)) { return json(422, { error: 'Idempotency-Key reused with a different request' }); } if (row.state === 'completed') return json(row.response_code, row.response_body); return json(409, { error: 'A request with this key is still in progress' }); } const order = await insertOrder(tx, req.accountId, req.body); const body = { id: order.id, status: order.status }; await tx.query( `UPDATE idempotency_keys SET state = 'completed', response_code = 201, response_body = $3 WHERE account_id = $1 AND idem_key = $2`, [req.accountId, key, body]); return json(201, body); }); }
بعض التفاصيل أثقل وزنًا مما تبدو. فالبصمة تُحسب على اسم العملية وعلى صيغة قياسية من محتوى الطلب، بترتيب ثابت للحقول وأرقام موحّدة الصيغة، وإلا بدا تمثيلان مختلفان للطلب نفسه طلبين مختلفين، ومرّ استخدام المفتاح نفسه على مسار آخر دون أن يُلاحَظ. وإذا فشل التحقق داخل المعاملة، فإن التراجع يزيل المفتاح أيضًا، فيستطيع العميل الذي صحّح طلبه أن يحاول من جديد. وفي PostgreSQL، عند مستوى العزل الافتراضي READ COMMITTED، ينتظر الحجز المتزامن المكرر عبر ON CONFLICT DO NOTHING حتى تنتهي المعاملة الأولى: فإن ثُبّتت وجد المكرر سطرًا مكتملًا فأعاد استجابته، وإن تراجعت حجز المكرر المفتاح لنفسه. أما تحت REPEATABLE READ أو SERIALIZABLE فيتلقى المكرر خطأ تعارض في التسلسل (serialization failure)، وعلى المعالج أن يلتقطه ويعيد المحاولة. في هذه النسخة ذات المعاملة الواحدة لا تظهر الحالة started لأي طرف آخر أبدًا.
حين يقع الأثر في نظام آخر
تتوقف المعاملة الواحدة عن العمل لحظة تتضمن العملية استدعاءً لنظام آخر: مزود دفع، أو بوابة رسائل نصية، أو واجهة شريك. فلا يمكنك إبقاء معاملة قاعدة بيانات مفتوحة عبر استدعاء شبكي مجهول المدة، ولا يمكنك إلغاء خصم من بطاقة بالتراجع عن معاملتك المحلية.
يصبح النمط عندئذ آلة حالات صغيرة لها نقاط استئناف. ثبّت المفتاح المحجوز في الحالة started أولًا. ثم أجرِ الاستدعاء الخارجي ممررًا مفتاح عدم تكرار للنظام الآخر مشتقًا من مفتاحك بطريقة حتمية، من الحساب ومفتاحك واسم الخطوة مثلًا. ثم ثبّت النتيجة وعلّم المفتاح بالحالة completed. هنا يكتسب فرع 409 والعمود locked_at قيمتهما: فالطلب المكرر المتزامن يرى started فيُطلب منه الانتظار، ومهمةُ استرداد تلتقط المفاتيح العالقة في started بعد تجاوز مهلة محددة.
والاسترداد آمن فقط لأن الاستدعاء الخارجي نفسه كان عديم الأثر عند التكرار. فالمهمة تستطيع ببساطة أن تكرره بالمفتاح الخارجي نفسه، فيعيد المزود النتيجة الأصلية بدلًا من الخصم مرة أخرى. ومن دون مفتاح خارجي، تضطر مهمة الاسترداد إلى سؤال المزود «هل حدث هذا؟»، وكثير من الواجهات لا تستطيع الإجابة عن هذا السؤال بموثوقية. وإن كنت تدمج مدفوعات البطاقات، فإن مقال أنماط تكامل Stripe في بيئة الإنتاج يشرح كيف يتكامل عدم التكرار لدى المزود مع خطافات الويب؛ والمهم هنا أن مفتاحك ومفتاح المزود يجب أن يكونا مرتبطين، لا أن يُولَّد كل منهما مستقلًا في كل محاولة.
أما الآثار التي ليست استدعاءات بل إشعارات، كرسائل البريد والإشعارات الفورية والأحداث الموجهة لخدمات أخرى، فلا ينبغي إرسالها من داخل المعالج أصلًا. سجّلها في المعاملة نفسها التي تحمل التغيير التجاري، وأرسلها لاحقًا من ذلك السجل، حتى لا تُرسل محاولةٌ تراجعت بريدًا لأحد، ولا تُرسل محاولةٌ مكررة البريد مرتين.
عدم التكرار دون جدول مفاتيح
ليست كل عملية بحاجة إلى الآلية السابقة. فغالبًا ما يكون أرخص أشكال عدم التكرار هو تصميم العملية بحيث يصبح التنفيذ الثاني بطبيعته بلا أثر.
المعرّفات التي يولّدها العميل تحوّل الإنشاء إلى عملية «أنشئ إن لم يوجد». فإذا اختار العميل المعرّف UUID للطلبية وكتبها الخادم مع ON CONFLICT (id) DO NOTHING، فإن التكرار لا ينشئ شيئًا، والسطر المخزن هو الاستجابة. ويمكن جعل انتقالات الحالة مشروطة: فالأمر UPDATE invoices SET status = 'paid' WHERE id = $1 AND status = 'open' يغيّر سطرًا واحدًا في المرة الأولى ولا يغير شيئًا بعدها، ويستطيع المعالج اعتبار «صفر أسطر» على فاتورة مدفوعة مسبقًا نجاحًا. كما أن الطلبات المشروطة في HTTP نفسه، أي If-Match مع ETag، تجعل التحديثات آمنة أمام إعادة المحاولة والتعديلات المتزامنة معًا.
وفي حالة مستهلكي الطوابير، يقابل جدولَ المفاتيح جدولٌ للرسائل المعالَجة مفتاحه معرّف الرسالة، ويُكتب في المعاملة نفسها التي تحمل آثار المستهلك. ولا يفيد معرّف الرسالة إلا إذا عيّنه المنتِج لكل حدث تجاري؛ أما المعرّف الذي يُسند عند كل محاولة نشر فيجعل كل إعادة محاولة تبدو رسالة جديدة.
والزيادات العددية هي الفخ الكلاسيكي. فالأمر UPDATE balances SET amount = amount + 10 ليس عديم الأثر تحت أي سياسة لإعادة المحاولة. فإما أن تسجّل كل إضافة منفردة بمعرّف فريد وتشتق الرصيد منها، وإما أن تحمي الزيادة بإحدى الآليات السابقة.
أخطاء التصميم التي تُنتج الأثر مرتين
الأخطاء التي تُنتج الأثر مرتين قليلة العدد، ومن السهل البحث عن كل منها أثناء المراجعة.
يُولَّد المفتاح داخل حلقة إعادة المحاولة. فتحصل كل محاولة على مفتاح جديد، ويرى الخادم طلبات مستقلة. يجب أن يُنشأ المفتاح حيث تُنشأ نية المستخدم، وأن يُحفظ إن كان العميل قد يُعاد تشغيله.
مدة الاحتفاظ أقصر من نافذة إعادة المحاولة. فالتطبيق القادر على العمل دون اتصال، إذا أعاد إرسال طلب من طابوره بعد ثلاثة أيام إلى خادم يحتفظ بالمفاتيح يومًا واحدًا، سينشئ نسخًا مكررة تبدو صحيحة تمامًا.
تُخزَّن الاستجابة قبل أن يصبح العمل دائمًا، أو يصبح العمل دائمًا قبل المفتاح. وكلا الترتيبين يترك نافذة يُنتج فيها التعطل مفتاحًا بلا أثر، أو أثرًا بلا مفتاح. إما أن يُثبَّت الاثنان معًا، وإما أن يغلق مسار الاسترداد الموصوف أعلاه هذه الفجوة.
تُخزَّن الأخطاء دون تمييز. فإعادة رمز 500 مخزن إلى الأبد لطلب فشل قبل أن يبدأ أي أثر تعاقبُ العميلَ على عطل عابر. احسم الأمر صراحة: الإخفاقات التي تقع قبل بدء الأثر يجب أن تحرر المفتاح؛ والنتائج التي تقع بعد بدئه، نجاحًا كانت أو رفضًا تجاريًا، يجب أن تُخزَّن وتُعاد. وواجهة Stripe في إصدارها الأول ترسم الحد في الموضع نفسه: فهي تحفظ النتائج، بما فيها رموز 500، فقط بعد أن يبدأ تنفيذ نقطة النهاية، ولا تحفظ إخفاقات التحقق ولا التعارض مع طلب متزامن.
تُستخدم المفاتيح لعمليات مختلفة. فالعميل الذي يستخدم مفتاحًا لـ«إنشاء طلبية» ثم المفتاح نفسه لـ«إضافة صنف» سيتلقى في أحسن الأحوال رموز 422 محيّرة. وإدخال المسار أو اسم العملية في البصمة، كما يفعل المعالج أعلاه، يجعل هذا الخطأ قابلًا للاكتشاف.
قبل أن تصف نقطة نهاية بأنها آمنة عند إعادة المحاولة
قائمة مراجعة قصيرة لأي نقطة نهاية أو مستهلك يُسمح للعملاء بإعادة المحاولة عليه:
- لكل عملية تُنشئ شيئًا أو تغيّره سلوكٌ محدد عند التكرار: جدول مفاتيح، أو معرّف يولّده العميل، أو تحديث مشروط، أو سجل للرسائل المعالَجة.
- يولّد العميل المفاتيح مرة واحدة لكل نية، ويكون نطاقها الحساب، وتُحفظ مدة أطول من أطول نافذة إعادة محاولة لدى العملاء.
- تشمل بصمة الطلب المحتوى بصيغته القياسية واسم العملية؛ وعدم التطابق يعيد خطأً لا استجابة سابقة.
- يُثبَّت المفتاح والأثر التجاري معًا بشكل ذري، أو تتولى مهمة استرداد حسم المفاتيح العالقة قيد المعالجة.
- يحمل كل استدعاء خارجي مفتاح عدم تكرار مشتقًا من مفتاحك.
- تُرسل الإشعارات والأحداث من سجلات مثبّتة، لا من المعالج مباشرة.
- يرسل اختبارٌ الطلب نفسه مرتين بالتزامن ومرتين بالتتابع، ويتأكد من حدوث أثر واحد فقط.
البند الأخير هو الذي تتجاوزه الفرق عادة، وهو الوحيد الذي يثبت العقد بدلًا من أن يصفه. وحين تلتقي إعادة المحاولة والطوابير والمدفوعات في منصة واحدة، يكون هذا عملًا تأسيسيًا لا تحسينًا يُؤجَّل، ومكانه الإصدار الأول؛ وهو جزء من طريقتنا في تطوير منصات الويب. وتبني عليه أعداد لاحقة من تحت الغطاء.
المراجع
مقالات ذات صلة
تصميم الخلفيةقراءة 19 دقيقة
بنية قوائم الانتظار: دليل تنفيذ Redis و RabbitMQ و SQS
بناء أنظمة قوائم انتظار موثوقة مع Redis و RabbitMQ و AWS SQS. يغطي dead letter queues والتكافؤ وأنماط المعالجة الحقيقية.
تكامل بوابات الدفعقراءة 32 دقيقة
تكامل Stripe مع React و Node.js: Payment Intents و Webhooks و 3D Secure
تكامل Stripe الإنتاجي مع Payment Intents و webhooks و 3D Secure. يغطي فواتير الاشتراكات ومعالجة الأخطاء وأنماط امتثال PCI.