قد تتسبب حالات التسابق في تطبيقات CRUD بطلبات مكررة ومجاميع خاطئة. تعرّف على نقاط التصادم الشائعة وحلول عملية باستخدام قيود قاعدة البيانات، الأقفال، وحواجز واجهة المستخدم.

تحصل حالة التسابق عندما يصل طلبان (أو أكثر) لتحديث نفس البيانات تقريبًا في نفس الوقت، وتصبح النتيجة النهائية مرهونة بالترتيب الزمني. كل طلب يبدو صحيحًا لوحده. لكن معًا ينتجان نتيجة خاطئة.
مثال بسيط: شخصان يضغطان "حفظ" على نفس سجل العميل في غضون ثانية. أحدهما يُحدّث البريد الإلكتروني، والآخر يُحدّث رقم الهاتف. إذا أرسلت كلتا الطلبين السجل الكامل، قد يقوم الكتابة الثانية باستبدال الأولى، وتختفي إحدى التعديلات دون خطأ.
ترى هذا أكثر في التطبيقات السريعة لأن المستخدمين يمكنهم تنفيذ المزيد من الإجراءات في الدقيقة. كما يزداد أثناء فترات الذروة: عروض محدودة، تقارير نهاية الشهر، حملة بريدية كبيرة، أو أي وقت تضرب فيه مجموعة من الطلبات نفس الصفوف.
نادراً ما يبلغ المستخدمون عن "حالة تسابق" صريحة. إنهم يشرحون الأعراض: طلبات أو تعليقات مكررة، تحديثات مفقودة ("حفظتُها لكنها عادت كما كانت"), مجاميع غريبة (المخزون يصبح سالبًا، العداد ينخفض)، أو حالات تتقلب بشكل غير متوقع (معتمد ثم عائد إلى قيد الانتظار).
المحاولات المتكررة تزيد المشكلة. يضغط الناس مزدوجًا، يحدث تحديث للصفحة بعد استجابة بطيئة، يرسلون من علامة تبويبين، أو يعيد المتصفح/التطبيق إرسال الطلبات عند الشبكات غير المستقرة. إذا نظر الخادم لكل طلب ككتابة جديدة، قد تحصل إنشاءات مزدوجة، دفعات مزدوجة، أو تغييرات حالة نفذت مرتين بينما قُصِدَت أن تحدث مرة واحدة.
معظم تطبيقات CRUD تبدو بسيطة: اقرأ صفًا، غيّر حقلًا، احفظ. المشكلة أن التطبيق لا يتحكم في التوقيت. قواعد البيانات، الشبكة، المحاولات المتكررة، الأعمال الخلفية، وسلوك المستخدم كلها تتداخل.
مُحرِّك شائع هو أن شخصين يحرران نفس السجل. كلاهما يحمل القيم "الحالية" نفسها، ويجريان تغييرات صحيحة، والحفظ الأخير يكتب فوق الأول بصمت. لا أحد فعل خطأ واضحًا، لكن أحد التحديثات خُسِر.
يحدث ذلك أيضًا مع شخص واحد. نقرة مزدوجة على زر الحفظ، أو الضغط مرّتين بسبب بطء الاتصال، أو اتصال بطيء يدفع المستخدم لإعادة الإرسال يمكن أن يرسل نفس الكتابة مرتين. إذا لم يكن الـ endpoint قابلًا لإعادة التطبيق idempotent، قد تنشأ نسخ مكررة، تُخصم المدفوعات مرتين، أو يتقدّم حالة مرتين.
الاستخدام الحديث يزيد التداخل: علامات تبويب متعددة أو أجهزة متعددة مسجلة بنفس الحساب قد تُصدر تحديثات متعارضة. وظائف الخلفية (بريد، فواتير، مزامنة، تنظيف) قد تلمس نفس الصفوف كطلبات الويب. إعادة المحاولة التلقائية على العميل، الموازن، أو مشغّل المهام يمكن أن تكرر طلبًا سبق أن نجح.
إذا كنتم تُطْلِقون ميزات بسرعة، غالبًا ما يُحدَّث نفس السجل من أماكن أكثر مما يتذكره أحد. إذا كنتم تستخدمون منشئًا محادثيًا مثل Koder.ai، يمكن أن ينمو التطبيق أسرع، لذلك من الأفضل اعتبار التزامن سلوكًا عاديًا لا حالة طرفية.
نادراً ما تظهر حالات التسابق في أمثلة "إنشاء سجل" التعليمية. تظهر حيث يلمس طلبان نفس حقيقة المصدر في لحظة متقاربة. معرفة أماكن الخطر المعتادة تساعدك على تصميم كتابات آمنة منذ البداية.
أي شيء يبدو كـ "فقط اجمع 1" يمكن أن ينكسر تحت التحميل: الإعجابات، عدّ المشاهدات، المجاميع، أرقام الفواتير، أرقام التذاكر. النمط الخطر هو قراءة القيمة، الإضافة، ثم الكتابة مرة أخرى. قد يقرأ طلبان نفس القيمة ويستبدل كل منهما الآخر.
سير العمل مثل مسودة -> مرسلة -> معتمدة -> مدفوعة تبدو بسيطة، لكن التصادمات شائعة. تبدأ المشكلة عندما يكون فعلان ممكنان في نفس الوقت (اعتماد وتعديل، إلغاء ودفع). بدون حواجز، قد ينتهي بك سجل يتخطى خطوات، يتقلب، أو يظهر حالات مختلفة في جداول مختلفة.
عامل تغييرات الحالة كعقد: اسمح فقط بالخطوة التالية الصالحة وارفض أي شيء آخر.
المقاعد المتبقية، عدّات المخزون، مواعيد الحجز، وحقول "السعة المتبقية" تخلق مشكلة البيع الزائد الكلاسيكية. مشتريان يتقدمان للدفع في نفس الوقت، كلاهما يرى التوفر، وكلاهما ينجح. إذا لم تكن قاعدة البيانات هي القاضي النهائي، ستبيع أكثر مما لديك.
بعض القواعد مطلقة: بريد إلكتروني واحد لكل حساب، اشتراك نشط واحد لكل مستخدم، سلة مفتوحة واحدة لكل مستخدم. غالبًا ما تفشل هذه القواعد عندما تتحقق أولًا ("هل يوجد؟") ثم تُدرج. تحت التزامن يمكن أن يجتاز كلا الطلبين الفحص.
إذا كنت تُنشئ تدفقات CRUD بسرعة (مثلاً عن طريق الدردشة لبناء التطبيق على Koder.ai)، دون هذه النقاط المبكرة وادعمها بقيود وكتابات آمنة، لا بمجرد فحوصات واجهة المستخدم.
الكثير من حالات التسابق تبدأ بشيء ممل: نفس الفعل يُرسل مرتين. المستخدمون يضغطون مزدوجًا. الشبكة بطيئة فيضغطون مرة أخرى. هاتف يسجل نقرتين. أحيانًا ليس عن قصد: الصفحة تتحديث بعد POST والمتصفح يعرض إعادة الإرسال.
عند حدوث ذلك، يمكن للـ backend تنفيذ إنشائين أو تحديثين متوازيين. إذا نجح كلاهما، تحصل نسخًا مكررة، مجاميع خاطئة، أو تغيير حالة ينفذ مرتين. يبدو عشوائيًا لأنه يعتمد على التوقيت.
أكثر الطرق أمانًا هي الدفاع متعدد الطبقات. أصلح الواجهة، لكن افترض أن الواجهة قد تفشل.
تغييرات عملية يمكنك تطبيقها على معظم تدفقات الكتابة:
مثال: يضغط المستخدم "ادفع الفاتورة" مرتين على الجوال. يجب أن تمنع الواجهة النقرة الثانية. كما يجب أن يرفض الخادم الطلب الثاني عندما يرى نفس مفتاح عدم التكرار، ويعيد نتيجة النجاح الأصلية بدلًا من تحصيل المبلغ مرتين.
تشعر حقول الحالة بالبساطة حتى يحاول شيءان تغيّرها في آنٍ واحد. يضغط المستخدم اعتماد بينما مهمة آلية تعيّن نفس السجل منتهٍ، أو عضوان في الفريق يعالجان العنصر في تبويبين مختلفين. قد ينجح التحديثان، لكن الحالة النهائية تعتمد على التوقيت، لا على قواعدك.
عامل الحالة كآلة حالات صغيرة. احتفظ بجدول قصير للانتقالات المسموح بها (مثال: Draft -> Submitted -> Approved، و Submitted -> Rejected). ثم يفحص كل كتابة: "هل هذا الانتقال مسموح من الحالة الحالية؟" إذا لم يكن كذلك، ارفضه بدلًا من الكتابة فوقه بصمت.
القفل التفاؤلي (optimistic locking) يساعدك على كشف التحديثات القديمة دون منع المستخدمين الآخرين. أضف رقم إصدار (أو updated_at) واطلب مطابقته عند الحفظ. إذا غيّر شخص آخر الصف بعد تحميله، لن يؤثر تحديثك على أي صفوف ويمكنك إظهار رسالة واضحة مثل "هذا العنصر تغيّر، حدّث الصفحة وأعد المحاولة."
نمط بسيط لتحديثات الحالة:
أيضًا، اجعل تغييرات الحالة تحدث في مكان واحد. إذا كانت التحديثات مبعثرة عبر شاشات، وظائف خلفية، وwebhooks، ستفشل في تطبيق القاعدة. ضعها خلف دالة أو نقطة نهاية واحدة تفرض نفس فحوصات الانتقال في كل مرة.
أكثر أخطاء العدادات شيوعًا يبدو بريئًا: التطبيق يقرأ قيمة، يضيف 1، ثم يكتبها. تحت التحميل، قد يقرأ طلبان نفس الرقم ويكتب كل منهما نفس الرقم الجديد، فيفقد أحد الزيادات. هذا سهل الفقدان لأنه "يعمل عادةً" في الاختبارات.
إذا كانت القيمة تُزاد أو تُنقص فقط، دع قاعدة البيانات تقوم بذلك في جملة واحدة. حينها تطبّق قاعدة البيانات التغييرات بأمان حتى لو ضرب الكثير من الطلبات في آنٍ واحد.
UPDATE posts
SET like_count = like_count + 1
WHERE id = $1;
تطبق الفكرة ذاتها على المخزون، عدّات المشاهدات، عدّات المحاولات، وأي شيء يمكن التعبير عنه كـ "جديد = قديم + دلتا".
المجاميع غالبًا ما تخطئ عندما تخزن رقمًا مشتقًا (order_total, account_balance, project_hours) ثم تحدّثه من أماكن متعددة. إذا كان بإمكانك حساب المجموع من الصفوف المصدر (عناصر السطر، قيود الدفتر)، تتجنّب فئة كاملة من أخطاء الانحراف.
عندما يجب تخزين المجموع للسرعة، عاملها ككتابة حرجة. اجمع تحديثات صفوف المصدر والمجموع المخزّن في نفس المعاملة. تأكد أن كاتبًا واحدًا فقط يمكنه تحديث نفس المجموع في آن واحد (قفل، تحديثات محمية، أو مسار مالك واحد). أضف قيودًا تمنع القيم المستحيلة (مثل أن يصبح المخزون سالبًا). ثم أجرِ مطابقة دورية تعيد حساب وتعلم على التباينات.
مثال ملموس: شخصان يضيفان عناصر إلى نفس السلة في نفس الوقت. إذا قرأ كل طلب cart_total وأضاف سعر العنصر ثم كتب النتيجة، قد تختفي إضافة أحدهما. إذا حدّثت عناصر السلة والمجموع المخزّن معًا في معاملة واحدة، يبقى المجموع صحيحًا حتى مع نقرات متوازية.
إذا أردت تقليل حالات التسابق، ابدأ من قاعدة البيانات. كود التطبيق قد يعيد المحاولة، ينتهي بالمهلة، أو يُشغّل مرتين. قيد قاعدة البيانات هو البوابة النهائية التي تبقى صحيحة حتى مع وصول طلبين في آنٍ واحد.
القيود الفريدة تمنع التكرارات التي "لا يجب أن تحدث" لكنها تحدث: عناوين البريد، أرقام الطلبات، أرقام الفواتير، أو قاعدة "اشتراك نشط واحد لكل مستخدم". عندما تهبط عمليتا تسجيل في آنٍ واحد، تقبل قاعدة البيانات صفًا واحدًا وترفض الآخر.
المفاتيح الأجنبية تمنع المراجع المكسورة. بدونها، قد يحذف طلب أحدهم سجل الأصل بينما ينشئ آخر سجل ابنًا يشير لشيء غير موجود، تاركًا صفوفًا يتيمة يصعب تنظيفها لاحقًا.
قيود الفحص (CHECK) تُبقي القيم في النطاق الآمن وتفرض قواعد بسيطة للحالة. مثال: quantity >= 0، rating بين 1 و5، أو حالة محدودة لمجموعة مسموح بها.
عامل فشل القيود كحالة متوقعة لا "خطأ خادم". التقط انتهاكات التفرد، والمفاتيح الأجنبية، وقيود الفحص، وأعد رسالة واضحة مثل "هذا البريد مستخدم بالفعل"، وسجل التفاصيل للتصحيح دون كشف معلومات داخلية.
مثال: يضغطان "إنشاء طلب" مرتين أثناء التأخر. مع قيد تفرد على (user_id, cart_id)، لن تحصل على طلبيْن. ستحصل على طلب واحد ورفض نظيف قابل للشرح للآخر.
بعض الكتابات ليست عبارة عن جملة واحدة. تقرأ صفًا، تتحقق من قاعدة، تغيّر حالة، وربما تُدرج سجل تدقيق. إذا فعل ذلك طلبان في نفس الوقت، قد يمر كلاهما وتُكتب القيم مرتين. هذا النمط الكلاسيكي للفشل.
لفّ الكتابة متعددة الخطوات في معاملة قاعدة بيانات بحيث تنجح كل الخطوات معًا أو لا شيء. والأهم أن المعاملة تمنحك مكانًا للتحكم بمن يُسمح له بتغيير نفس البيانات في نفس الوقت.
عندما يكون مسموحًا لفعل واحد فقط أن يغيّر السجل في آنٍ واحد، استخدم قفل صف. مثال: قفل صف الطلب، تأكّد أنه لا يزال في الحالة "قيد الانتظار"، ثم حوِّله إلى "معتمد" واكتب سجل التدقيق. الطلب الثاني سينتظر ثم يعيد التحقق من الحالة ويتوقف.
اختر بناءً على مدى تكرار التصادمات:
أبقِ زمن القفل قصيرًا. قم بأقل قدر من العمل أثناء الاحتفاظ به: لا استدعاءات خارجية، لا أعمال ملفات بطيئة، لا حلقات كبيرة. إذا كنت تبني تدفقات في أداة مثل Koder.ai، حافظ على المعاملة محصورة في خطوات قاعدة البيانات فقط، ثم قم بالباقي بعد الالتزام.
اختر تدفقًا قد يخسرك مالًا أو ثقة عند حدوث تصادم. شائع: إنشاء طلب، حجز مخزون، ثم تعيين حالة الطلب إلى مؤكد.
اكتب خطوات الكود الحالية بالتحديد، بالترتيب. كن محددًا بما يُقرأ، وما يُكتب، ومعنى "النجاح". تختبئ التصادمات في الفجوة بين القراءة والكتابة اللاحقة.
مسار تقوية يعمل في معظم البيئات:
أضف اختبارًا واحدًا يثبت الحل. شغّل طلبين في آنٍ واحد على نفس المنتج والكمية. تأكد أن طلبًا واحدًا فقط يصبح مؤكدًا، والثاني يفشل بطريقة مضبوطة (لا مخزون سالب، ولا صفوف حجز مكررة).
إذا كنت تولّد تطبيقات بسرعة (بما في ذلك باستخدام منصات مثل Koder.ai)، هذه القائمة تستحق التنفيذ في مسارات الكتابة القليلة الحرجة.
أحد أكبر أسباب حالات التسابق هو الثقة المفرطة في الواجهة. تعطيل الأزرار وفحوصات جهة العميل مفيدة، لكن يمكن للمستخدم أن يضغط مرتين، يحدث تحديث للصفحة، يفتح تبويبتين، أو يعيد تشغيل طلب من اتصال فقير. إذا لم يكن الخادم idempotent، تنزلق التكرارات.
خطأ هادئ آخر: تلتقط خطأ قاعدة بيانات (مثل انتهاك قيد التفرد) وتستمر في سير العمل كأن شيئًا لم يحدث. غالبًا يتحول ذلك إلى "فشل الإنشاء، لكن أرسلنا البريد" أو "فشل الدفع، لكن علمنا أن الطلب مدفوع." بعد وقوع آثار جانبية، يصعب التراجع.
المعاملات الطويلة فخ أيضًا. إذا أبقيت معاملة مفتوحة أثناء استدعاء بريد أو مدفوعات أو واجهات طرف ثالث، تحتفظ بالأقفال أطول من اللازم. هذا يزيد الانتظار والمهلات وفرص حظر الطلبات بعضها لبعض.
خلط وظائف الخلفية وإجراءات المستخدم دون مصدر واحد للحقيقة يخلق حالة انقسام. يعيد مشغل مهمة المحاولة ويحدّث صفًا بينما المستخدم يعدّله، ويظن كل منهما أنه آخر من كتب.
بعض "الإصلاحات" التي لا تصلح فعلاً:
إذا كنت تبني عبر أداة محادثة إلى تطبيق مثل Koder.ai، نفس القواعد تنطبق: اطلب قيودًا من جهة الخادم وحدودًا معاملية واضحة، لا مجرد حراسة واجهة أجمل.
تظهر حالات التسابق عادة تحت حمل حقيقي. تمريرة قبل الإطلاق يمكن أن تلتقط نقاط التصادم الشائعة دون إعادة كتابة كاملة.
ابدأ من قاعدة البيانات. إن كان شيء ما يجب أن يكون فريدًا (بريد إلكتروني، أرقام فواتير، اشتراك واحد نشط لكل مستخدم) اجعله قيد تفرد حقيقيًا، لا قاعدة "نحن نتحقق أولًا" على مستوى التطبيق. ثم تأكد أن الكود يتوقع فشل القيد أحيانًا ويعيد استجابة واضحة وآمنة.
بعدها، انظر إلى الحالة. أي تغيير حالة (Draft -> Submitted -> Approved) يجب التحقق منه مقابل مجموعة صريحة من الانتقالات المسموح بها. إذا حاول طلبان نقل نفس السجل، يجب أن يُرفض الثاني أو يصبح لا-عملية، لا أن يخلق حالة وسطى.
قائمة تحقق عملية قبل الإطلاق:
إذا كنت تبني تدفقات في Koder.ai، اعتبر هذه معايير قبول: يجب أن يفشل التطبيق المتولَّد بأمان تحت الإعادة والتزامن، لا أن ينجح فقط في مسار الحظ السعيد.
اثنان من الموظفين يفتحان نفس طلب الشراء. كلاهما يضغط اعتماد خلال ثوانٍ. يصل الطلبان إلى الخادم.
ما قد يخطئ فوضوي: الطلب يُعتمد مرتين، تصل إشعاران، وأي مجاميع مرتبطة بالاعتماد (الموازنة المستخدمة، عدّ الاعتمادات اليومي) قد تزيد بمقدار 2. كلتا التحديثات صحيحتان بمفردهما لكنهما يتصادمان.
ها خطة إصلاح تعمل جيدًا مع قاعدة بيانات على طراز PostgreSQL.
أضف قاعدة تضمن وجود سجل اعتماد واحد فقط لطلب. مثال: خزّن بيانات الاعتمادات في جدول منفصل وفرض قيد تفرد على request_id. الآن الإدخال الثاني يفشل حتى لو كان كود التطبيق به عيب.
عند الاعتماد، قم بكل الانتقال في معاملة واحدة:
إذا وصل الموظف الثاني بعد ذلك، إما يرى 0 صفوف مُحدثة أو خطأ قيد تفرد. في كلتا الحالتين، فائز واحد فقط.
بعد الإصلاح، يرى الموظف الأول "معتمد" ويحصل على التأكيد العادي. يرى الموظف الثاني رسالة ودّية مثل: "تم اعتماد هذا الطلب من قبل شخص آخر. حدّث الصفحة لرؤية الحالة الأحدث." لا تدوير غير منتهي، لا إشعارات مكررة، ولا إخفاقات صامتة.
إذا كنت تولّد تدفق CRUD على منصة مثل Koder.ai (backend بـ Go و PostgreSQL)، يمكنك تضمين هذه الفحوصات في فعل الاعتماد مرة واحدة وإعادة استخدامها لأنواع أخرى من إجراءات "فائز واحد فقط".
أسهل إصلاح حالات التسابق عندما تعاملها كروتين قابل للتكرار لا كبحث أخطاء مرة واحدة. ركّز على مسارات الكتابة القليلة المهمة واجعلها صحيحة بشكل ممل قبل أن تُجمّل أي شيء آخر.
ابدأ بتسمية نقاط التصادم الأعلى لديك. في كثير من تطبيقات CRUD القائمة الثلاثية هي نفسها: العدادات (إعجابات، مخزون، أرصدة)، تغييرات الحالة (Draft -> Submitted -> Approved)، والإرسال المزدوج (نقرتان، محاولات، شبكات بطيئة).
روتين عملي يثبت نفسه:
إذا كنت تبني على Koder.ai، فـ Planning Mode مكان عملي لرسم كل تدفق كتابة كخطوات وقواعد قبل توليد تغييرات في Go و PostgreSQL. اللقطات والرجوع مفيدة أيضًا عند نشر قيود جديدة أو سلوك أقفال وتريد طريقًا سريعًا للعودة إن ظهرت حالة طرفية.
مع الزمن، يصبح هذا عادة: كل ميزة كتابة جديدة تحصل على قيد، وخطة معاملة، واختبار تزامن. هكذا تتوقف حالات التسابق في تطبيقات CRUD عن أن تكون مفاجآت.