تخطّى إلى المحتوى الرئيسي
لماذا مسح تبويبان تعديلات بعضهما بصمت: ملاحظات ميدانية عن التحديثات المفقودة وأعمدة الإصدار وETag

لماذا مسح تبويبان تعديلات بعضهما بصمت: ملاحظات ميدانية عن التحديثات المفقودة وأعمدة الإصدار وETag

September 27, 2026
هندسة الأنظمة الخلفية
9 دقيقة قراءة

الخلاصة: يحدث «التحديث المفقود» (lost update) عندما يقرأ عميلان السجل نفسه، ويعدّله كلٌّ منهما، ثم يكتب الحفظ الثاني فوق الأول بصمت. لا تمنع معاملات قاعدة البيانات ذلك عندما تتم القراءة والكتابة في طلبَي HTTP منفصلين؛ ما يمنعه هو التحكم المتفائل في التزامن (optimistic concurrency control): عمود إصدار رقمي (version) يُتحقَّق منه داخل شرط WHERE في جملة UPDATE، ويُعرض لعملاء HTTP عبر ترويستَي ETag وIf-Match، ويظهر للمستخدم تعارضًا صريحًا بدلًا من فقدان صامت للبيانات.

اكتشف فريقنا هذه المشكلة أثناء اختبار لوحة إدارة داخلية. فتحنا المنتج نفسه في تبويبين في المتصفح، وغيّرنا السعر في التبويب الأول وحفظنا، ثم غيّرنا الوصف في التبويب الثاني وحفظنا. وصل الوصف، واختفى تغيير السعر. لم يظهر أي خطأ، ولم يفشل أي طلب، ولم يُسجَّل أي سطر في السجلات — فقد أرسل النموذج الثاني كل الحقول من جديد، بما فيها السعر الذي حمّله قبل الحفظ الأول. كانت كل مسارات الكتابة تستخدم دالة update الافتراضية في الـORM، وهي تعمل بمبدأ «آخر كتابة تفوز» (last-write-wins). هذه ملاحظاتنا عن جعل التعديلات المتزامنة تفشل بوضوح بدلًا من ذلك.

أهم النقاط

• التحديث المفقود هو سباق قراءة-تعديل-كتابة يمتد عبر الطلبات: يحمّل عميلان الإصدار 7 من السجل، ويحفظ كلاهما، فتمحو الكتابة اللاحقة الكتابة السابقة دون أي خطأ.

• المعاملات لا تساعد عبر طلبات HTTP. تحدث القراءة في الطلب الذي يعرض النموذج، وتحدث الكتابة في طلب POST أو PATCH أو Server Action لاحق، فهما معاملتان منفصلتان صالحتان كلٌّ على حدة — حتى عند مستوى العزل SERIALIZABLE.

• التحكم المتفائل في التزامن هو شرط إضافي واحد: عمود version رقمي، وجملة UPDATE بشرط WHERE id = $1 AND version = $2، وتعيين version = version + 1، واعتبار عدم تأثر أي صف تعارضًا.

• بروتوكول HTTP يملك المفردات اللازمة أصلًا: أرسل الإصدار في ترويسة ETag، واشترط If-Match في عمليات الكتابة، وأجب بـ 412 Precondition Failed عند عدم التطابق، وبـ 428 Precondition Required عند غياب الترويسة.

• التعارض قرار في تجربة المستخدم، لا إعادة محاولة. اعرض على المستخدم ما تغيّر، واتركه يعيد التحميل أو يكتب فوق التغييرات عن قصد؛ فإعادة المحاولة التلقائية بالإصدار الجديد تعيد إنتاج الكتابة الصامتة نفسها.

ما هو التحديث المفقود، ولماذا لا تمنعه معاملة قاعدة البيانات؟

التحديث المفقود خلل في التزامن يبني فيه كاتبان تغييراتهما على القيمة الأصلية نفسها، فتُسقط الكتابة الثانية تغيير الكاتب الأول. إنه سباق القراءة-التعديل-الكتابة الكلاسيكي، وفي تطبيقات الويب تحدث خطوة «التعديل» عادةً في ذهن شخص، خلال ثوانٍ أو دقائق، بين طلبَي HTTP.

لا تستطيع معاملة قاعدة البيانات منع تحديث مفقود يمتد عبر طلبات HTTP، لأنه لا توجد معاملة واحدة تحتوي على القراءة والكتابة معًا. الصفحة التي تعرض نموذج التعديل تنفّذ SELECT في معاملة قصيرة ثم تُنهيها. وبعد دقائق، ينفّذ معالج الحفظ UPDATE في معاملة ثانية. من وجهة نظر PostgreSQL، حدّثت معاملتان متتاليتان صفًّا، ولا شيء غير طبيعي في ذلك. رفع مستوى العزل إلى REPEATABLE READ أو SERIALIZABLE يغيّر سلوك المعاملات المتداخلة فقط؛ ولا علاقة له بمعاملات لم تتداخل أصلًا.

ليست كل كتابة بحاجة إلى حماية. عندما يمكن التعبير عن التغيير بلغة SQL، نترك قاعدة البيانات تُجري الحساب بشكل ذري ونتخطى القراءة تمامًا — مثل UPDATE products SET stock = stock - 1 WHERE id = $1 AND stock > 0. العدّادات والأرصدة والمخزون تناسب هذا الشكل. أما النماذج التي يعدّلها شخص فلا تناسبه، لأن القيمة الجديدة هي ما كتبه الشخص بناءً على ما رآه. وهنا بالضبط مكان التحكم المتفائل في التزامن.

كيف تضيف تحققًا من الإصدار باستخدام Drizzle أو Prisma؟

التحكم المتفائل في التزامن (OCC) استراتيجية تسمح للكتّاب بالمتابعة دون أقفال، وتكتشف التعارضات لحظة الكتابة عبر التحقق من أن الصف ما زال يحمل الإصدار الذي قرأه الكاتب في البداية. التنفيذ عبارة عن عمود version رقمي قيمته الافتراضية 1، وشرط إضافي واحد في WHERE لكل عملية تحديث: UPDATE products SET name = $3, price = $4, version = version + 1 WHERE id = $1 AND version = $2.

في الدالة المساعدة لدينا المبنية على Drizzle ORM، تستدعي updateProduct الدالة db.update(products) بالقيم الجديدة مع زيادة version بمقدار 1، وتصفّي بـ and(eq(products.id, id), eq(products.version, expectedVersion))، وتقرأ الإصدار الجديد عبر returning(). النتيجة الفارغة تعني أن الكتابة المحمية لم تطابق أي صف، فترمي الدالة ConflictError؛ وإلا فإنها تُرجع رقم الإصدار الجديد.

في Prisma، المكافئ هو updateMany({ where: { id, version }, data: { ...input, version: { increment: 1 } } })، التي تُرجع { count }؛ وقيمة 0 تعني التعارض. لكن عدم تأثر أي صف أمر مبهم — فقد يكون السجل حُذف لا عُدّل — لذا ننفّذ عند الإخفاق SELECT إضافيًا واحدًا لنُرجع 404 للصف المفقود وتعارضًا للإصدار القديم.

نستخدم رقمًا صحيحًا بدلًا من updatedAt كإصدار. يخزّن النوع timestamptz في PostgreSQL الوقت بدقة أجزاء من المليون من الثانية، بينما يحتفظ Date في JavaScript بدقة أجزاء من الألف، لذا قد يتوقف الطابع الزمني الذي يمر عبر JSON عن مطابقة القيمة المخزنة فيُنتج تعارضات زائفة.

كيف ترتبط ETag وIf-Match بعمود الإصدار؟

ETag ترويسة استجابة في HTTP تحدد إصدارًا معينًا من المورد، وIf-Match ترويسة طلب تجعل الكتابة مشروطة ببقاء المورد على قيمة ETag نفسها. وكلتاهما معرّفتان في RFC 9110 (HTTP Semantics). يرتبط عمود الإصدار بهما مباشرة: يرسل معالج GET الإصدار في ETag، ويعيده العميل في If-Match، ويجيب الخادم بـ 412 Precondition Failed عندما لا يعود الشرط محققًا. أما 428 Precondition Required، المعرّفة في RFC 6585، فترفض عمليات الكتابة التي تُغفل If-Match، حتى لا يعود أي عميل إلى مبدأ «آخر كتابة تفوز» عن طريق الخطأ.

في Route Handler ضمن Next.js، يُرجع GET المنتج مع ترويسة ETag تحتوي الإصدار بين علامتي تنصيص، مثل "7". ويقرأ PATCH ترويسة If-Match، ويُرجع 428 عند غيابها، ويزيل علامات التنصيص، ويتحقق من صحة جسم الطلب، ثم يستدعي دالة التحديث المحمية نفسها. يُرجع النجاح 204 مع قيمة ETag الجديدة، ويتحول ConflictError إلى 412.

هناك تفصيلتان مهمتان. الأولى أن If-Match تستخدم المقارنة القوية، لذا فإن ETag الضعيف مثل W/"7" لا يتطابق أبدًا؛ ونحن نرسل القيمة القوية "7". والثانية أن الاستجابة الناجحة تُرجع قيمة ETag الجديدة، فيرسل العميل الذي يواصل التعديل الإصدار الصحيح في المرة التالية بدلًا من أن يتعارض مع حفظه السابق.

كيف تمرّر الإصدار عبر نموذج يستخدم Server Action؟

لا يستطيع Server Action في Next.js يُستدعى من نموذج أن يضبط ترويسات طلب مخصصة، لذا ينتقل الإصدار في جسم النموذج كحقل مخفي بدلًا من If-Match. يقرأ الإجراء saveProduct لدينا المعرّف والإصدار من FormData، ويتحقق من بقية الحقول، وينفّذ التحديث المحمي نفسه. عند النجاح يستدعي revalidatePath ويُرجع { status: 'saved', version }؛ وعند ConflictError يُرجع { status: 'conflict', current } مع أحدث نسخة من السجل، ليتمكن useActionState من عرض لوحة تعارض بدلًا من صفحة خطأ.

خلل «التعارض مع الذات» كان أول ما واجهناه: بعد حفظ ناجح، ظل النموذج يحمل الإصدار الذي حمّله في البداية، فأبلغ الحفظ الثاني من التبويب نفسه عن تعارض مع نفسه. أصلحنا ذلك بعرض state.version ?? product.version في الحقل المخفي.

حقل الإصدار ليس أداة أمان. يستطيع العميل دائمًا إرسال الإصدار الحالي، وهذا بالضبط ما يفعله زر «الكتابة فوقه على أي حال»، لذا تبقى المصادقة والتفويض هما ما يحدد من يحق له الكتابة على السجل أصلًا. التحقق من الإصدار يحمي المستخدمين الصادقين من السباقات، لا التطبيق من المهاجمين.

ماذا يجب أن يرى المستخدم عند حدوث تعارض؟

عندما يصطدم الحفظ بتعارض في الإصدار، يجب أن يرى المستخدم أن شخصًا آخر عدّل السجل، وما الذي عدّله، وأن يختار بين إعادة تحميل أحدث إصدار أو الكتابة فوقه عن قصد. تعرض لوحة التعارض لدينا كل حقل أصبحت قيمته على الخادم مختلفة عن القيمة التي حمّلها المستخدم في البداية، بجانب القيمة غير المحفوظة لدى المستخدم. خيار «إعادة التحميل» يتجاهل التعديلات المحلية؛ وخيار «الكتابة فوقه على أي حال» يعيد الإرسال بالإصدار الحالي، فيتحول الحادث إلى قرار.

يجب ألا تُعاد محاولة تعارض الإصدار تلقائيًا أبدًا. حلقة إعادة المحاولة التي تجلب الإصدار من جديد وتعيد الإرسال ليست سوى «آخر كتابة تفوز» مع رحلات ذهاب وإياب إضافية. الحل التلقائي الوحيد الذي نسمح به هو الدمج على مستوى الحقول: إذا لم تتداخل الحقول التي غيّرها المستخدم مع الحقول التي غيّرها الكاتب الآخر، يطبّق النموذج حقول المستخدم فوق أحدث إصدار ويعيد الإرسال. يتطلب ذلك الاحتفاظ باللقطة المحمّلة أصلًا على جهة العميل كأساس للمقارنة، لذا لم نبنِه إلا لأكبر النماذج.

يتفاعل التحكم المتفائل في التزامن أيضًا مع إعادة المحاولة على مستوى الشبكة. إذا نجح الحفظ لكن ضاعت الاستجابة، فإن إعادة العميل للمحاولة بالإصدار نفسه تحصل على 412 بسبب كتابته الناجحة نفسها. مفتاح عدم التكرار (idempotency key) في طلب الحفظ، الذي تناولناه في ملاحظاتنا عن إعادة المحاولة وقواطع الدارة، يسمح للخادم بإعادة نتيجة النجاح الأصلية بدلًا من ذلك.

متى تستخدم القفل المتفائل بدلًا من SELECT ... FOR UPDATE؟

استخدم القفل المتفائل عندما يفصل بين القراءة والكتابة وقتُ تفكير المستخدم، واستخدم القفل المتشائم فقط عندما يحدث الاثنان داخل معاملة واحدة قصيرة على الخادم. تحتفظ SELECT ... FOR UPDATE بقفل على الصف حتى تنتهي المعاملة، لذا لا يمكنها أن تمتد عبر طلبَي HTTP، وإبقاء معاملة مفتوحة بينما يعدّل شخص نموذجًا سيحجب الكتّاب الآخرين ويحتجز اتصالًا من مجمّع الاتصالات.

• «آخر كتابة تفوز» (الافتراضي في الـORM): تحديث UPDATE أعمى لكل الحقول المرسلة. يناسب: البيانات التي يملكها شخص واحد، مثل إعداداته الخاصة. المقابل: فقدان صامت للبيانات عند التعديل المتزامن.

• التحديث الذري: SET stock = stock - 1 WHERE stock > 0. يناسب: العدّادات والأرصدة والمخزون. المقابل: لا يعمل إلا عندما يمكن التعبير عن التغيير بلغة SQL.

• التزامن المتفائل (عمود الإصدار): WHERE version = $2 ثم version + 1. يناسب: السجلات التي يعدّلها البشر عبر طلبات HTTP. المقابل: تصل التعارضات إلى المستخدم وتحتاج إلى واجهة.

• القفل المتشائم (SELECT ... FOR UPDATE): قفل على الصف حتى تنتهي المعاملة. يناسب: قراءة-تعديل-كتابة قصيرة داخل طلب أو مهمة واحدة. المقابل: لا يمتد عبر الطلبات، مع انتظار الأقفال واحتمال الجمود (deadlocks).

• عقد التعديل («شخص ما يعدّل الآن»): قفل على مستوى التطبيق بمدة صلاحية. يناسب: المستندات الطويلة بمحرر واحد في كل مرة. المقابل: عقود منتهية عالقة وحالة إضافية تحتاج إلى إدارة.

ينتهي الأمر بمعظم تطبيقات CRUD إلى مزيج: تحديثات ذرية للعدّادات، وتزامن متفائل لكل نموذج يعدّله البشر، وأقفال صفوف لعدد قليل من مهام القراءة-التعديل-الكتابة على الخادم، و«آخر كتابة تفوز» فقط حيث يملك شخص واحد البيانات.

الأسئلة الشائعة

س: هل تمنع معاملة قاعدة البيانات التحديثات المفقودة؟

ج: فقط عندما تحدث القراءة والكتابة داخل المعاملة نفسها. في تطبيقات الويب تحدث القراءة عند تحميل النموذج وتحدث الكتابة في طلب لاحق، فتعمل كلٌّ منهما في معاملتها الخاصة ولا ترى قاعدة البيانات أي خطأ. التحقق من الإصدار داخل شرط WHERE في جملة UPDATE هو ما يكشف السباق.

س: ما رمز حالة HTTP الذي يجب أن يُرجعه تعارض الإصدار؟

ج: أرجِع 412 Precondition Failed عندما لا تطابق ترويسة If-Match قيمة ETag الحالية، و428 Precondition Required عندما تُغفل عملية الكتابة If-Match. وأرجِع 409 Conflict عندما ينتقل الإصدار في جسم الطلب بدلًا من ترويسة، كما في نماذج Server Actions.

س: هل ينطبق التزامن المتفائل على طلبات DELETE؟

ج: نعم. اشترط If-Match في DELETE واحذف باستخدام WHERE id = $1 AND version = $2، حتى لا يحذف أحد سجلًا بناءً على نسخة قديمة منه.

س: متى أحتاج إلى CRDTs بدلًا من القفل المتفائل؟

ج: عندما يعدّل عدة أشخاص المستند نفسه في الوقت نفسه ويتوقعون رؤية تغييرات بعضهم مباشرة. مكتبات CRDT مثل Yjs وAutomerge تدمج التعديلات المتزامنة بدلًا من رفضها؛ أما القفل المتفائل فيناسب النماذج التي تندر فيها التعديلات المتزامنة ويكون فيها عرض شاشة تعارض مقبولًا.