تعلم معاملات Postgres لتدفقات العمل متعددة الخطوات: كيفية تجميع التحديثات بأمان، منع الكتابات الجزئية، التعامل مع إعادة المحاولات، والحفاظ على اتساق البيانات.

معظم الميزات الحقيقية ليست تحديثًا واحدًا في قاعدة البيانات. هي سلسلة قصيرة: إدخال صف، تحديث رصيد، وضع علامة على حالة، كتابة سجل تدقيق، وربما إدراج مهمة في قائمة الانتظار. تحدث الكتابة الجزئية عندما تصل بعض هذه الخطوات إلى قاعدة البيانات فقط.
يظهر هذا عندما يقطع شيء ما السلسلة: خطأ في الخادم، مهلة بين التطبيق وPostgres، تعطل بعد الخطوة الثانية، أو إعادة محاولة تعيد تنفيذ الخطوة الأولى. كل بيان على حدة قد يكون سليمًا. لكن يتكسر تدفق العمل عندما يتوقف في منتصف الطريق.
غالبًا يمكنك اكتشافه بسرعة:
مثال ملموس: ترقية خطة تحدث تغييرًا في خطة العميل، تضيف سجل دفع، وتزيد من الرصيد المتاح. إذا تعطّل التطبيق بعد حفظ الدفعة وقبل إضافة الرصيد، سيرى الدعم "مدفوع" في جدول واحد و"لا رصيد" في جدول آخر. وإذا أعاد العميل المحاولة، قد تسجل الدفعة مرتين.
الهدف بسيط: تعامل مع تدفق العمل كمفتاح واحد. إما أن تنجح كل الخطوات، أو لا شيء منها ينجح، حتى لا تخزن عملًا نصف مكتمل.
المعاملة هي طريقة قاعدة البيانات للقول: تعامل مع هذه الخطوات كوحدة عمل واحدة. إما أن تحدث كل التغييرات، أو لا يحدث شيء منها. هذا مهم متى احتاج تدفق العمل أكثر من تحديث واحد، مثل إنشاء صف، تحديث رصيد، وكتابة سجل تدقيق.
تخيّل نقل المال بين حسابين. يجب أن تخصم من الحساب A وتضيف إلى الحساب B. إذا تعطل التطبيق بعد الخطوة الأولى، لا تريد أن يتذكّر النظام فقط الخصم.
عندما تلتزم (commit)، تقول لـ Postgres: احتفظ بكل ما فعلته في هذه المعاملة. تصبح كل التغييرات دائمة ومرئية للجلسات الأخرى.
عندما تتراجع (rollback)، تقول لـ Postgres: انسَ كل ما فعلته في هذه المعاملة. يتراجع Postgres عن التغييرات كما لو أن المعاملة لم تحدث.
داخل المعاملة، يضمن لك Postgres ألا تعرض نتائج نصف منجزة للجلسات الأخرى قبل الالتزام. إذا فشل شيء ما وتراجعت، تنظف قاعدة البيانات الكتابات من تلك المعاملة.
المعاملة لا تصحح تصميم تدفق عمل سيئ. إذا خصمت المبلغ الخاطئ، استخدمت معرف المستخدم الخطأ، أو تجاوزت فحصًا ضروريًا، سيقوم Postgres بالالتزام بالنتيجة الخاطئة بصِدق. كما أن المعاملات لا تمنع تلقائيًا كل تضارب على مستوى العمل (مثل بيع أكثر من المخزون) ما لم تقترن بالقيود، الأقفال، أو مستوى العزلة المناسب.
كلما حدثت تحديثات في أكثر من جدول واحد (أو أكثر من صف واحد) لإتمام فعل واحد في العالم الحقيقي، فهناك مرشح لاستخدام معاملة. الفكرة تبقى: إما أن يُنجَز كل شيء، أو لا شيء.
تدفق الطلب هو الحالة الكلاسيكية. قد تنشئ صف طلب، تحجز مخزونًا، تستلم الدفع، ثم تضع علامة "مدفوع" على الطلب. إذا نجح الدفع لكن فشل تحديث الحالة، يصبح لديك مال مُقتَطَع مع طلب يبدو غير مدفوع. إذا أنشئت صف الطلب لكن لم تُحجز الكمية، قد تبيع عناصر ليس لديك منها.
كذلك ينهار انضمام المستخدمين بصمت. إنشاء المستخدم، إدراج سجل ملف تعريف، تعيين الأدوار، وتسجيل أنه يجب إرسال بريد ترحيبي هي فعل منطقي واحد. بدون التجميع، ينتهي بك الأمر بمستخدم يمكنه تسجيل الدخول لكن بلا أذونات، أو بملف تعريف موجود بلا مستخدم.
الإجراءات الإدارية تحتاج غالبًا إلى سلوك صارم "سجل + تغيير حالة". الموافقة على طلب، كتابة سجل تدقيق، وتحديث رصيد يجب أن تنجح معًا. إذا تغير الرصيد لكن سجل التدقيق مفقود، تفقد دليلاً على من غيّر ماذا ولماذا.
تستفيد أيضًا وظائف الخلفية، خاصة عند معالجة عنصر عمل بعدة خطوات: احتِل العنصر حتى لا يقوم عاملان بتنفيذه، طبّق التحديث التجاري، سجّل نتيجة للتقارير وإعادة المحاولات، ثم علّم العنصر بأنه مكتمل (أو فشل مع سبب). إذا تفرّقت هذه الخطوات، تخلق إعادة المحاولات والتزامن فوضى.
تنهار الميزات متعددة الخطوات عندما تعاملها كمجموعة تحديثات مستقلة. قبل فتح عميل قاعدة البيانات، اكتب تدفق العمل كقصة قصيرة بخط نهاية واحد واضح: ما الذي يُعتبر "منجزًا" للمستخدم بالضبط؟
ابدأ بسرد الخطوات بلغة بسيطة، ثم عرّف شرط نجاح واحدًا. مثال: "تم إنشاء الطلب، تم حجز المخزون، ويرى المستخدم رقم تأكيد الطلب." أي شيء أقل من ذلك ليس نجاحًا، حتى لو تم تحديث بعض الجداول.
بعدها ارسم خطًا فاصلاً واضحًا بين العمل داخل قاعدة البيانات والعمل الخارجي. خطوات قاعدة البيانات هي ما يمكنك حمايته بالمعاملات. المكالمات الخارجية مثل مدفوعات البطاقات، إرسال الرسائل، أو استدعاء واجهات طرف ثالث قد تفشل بطرق بطيئة وغير متوقعة، وعادة لا يمكنك التراجع عنها.
نهج تخطيط بسيط: قسّم الخطوات إلى (1) يجب أن تكون كلّها أو لا شيء، (2) يمكن أن تحدث بعد الالتزام.
داخل المعاملة، أبقِ فقط الخطوات التي يجب أن تظل متسقة معًا:
نقل الآثار الجانبية إلى الخارج. على سبيل المثال، التزم بالطلب أولًا، ثم أرسل البريد بناءً على سجل outbox.
لكل خطوة، اكتب ما يجب أن يحدث إذا فشلت الخطوة التالية. قد يعني "التراجع" تراجع قاعدة البيانات، أو قد يعني إجراء تعويضي.
مثال: إذا نجحت الدفعة لكن فشل حجز المخزون، قرّر مسبقًا ما إذا كنت سترجع المبلغ فورًا، أم تضع الطلب كـ "تم تحصيل الدفع، في انتظار المخزون" وتتعامل معه بشكل غير متزامن.
المعاملة تقول لـ Postgres: عامل هذه الخطوات كوحدة واحدة. إما أن يحدث كل شيء، أو لا شيء. هذه أبسط طريقة لمنع الكتابات الجزئية.
استخدم اتصال قاعدة بيانات واحد (جلسة واحدة) من البداية حتى النهاية. إذا وزّعت الخطوات عبر اتصالات مختلفة، لا يستطيع Postgres ضمان نتيجة كلّها أو لا شيء.
التسلسل بسيط: begin، نفّذ القراءات والكتابات المطلوبة، التزم إذا نجح كل شيء، وإلا تراجع وارجع خطأ واضح.
إليك مثالًا بسيطًا في SQL:
BEGIN;
-- reads that inform your decision
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- writes that must stay together
UPDATE accounts SET balance = balance - 50 WHERE id = 42;
INSERT INTO ledger(account_id, amount, note) VALUES (42, -50, 'Purchase');
COMMIT;
-- on error (in code), run:
-- ROLLBACK;
المعاملات تحتفظ بالأقفال أثناء تشغيلها. كلما أبقيتها مفتوحة أطول، زادت احتمالية حجب عمل آخر وزادت فرص وصولك إلى مهلات أو حالات انسداد (deadlocks). اجعل داخل المعاملة الأمور الأساسية فقط، وانقل المهام البطيئة (إرسال رسائل، استدعاء مزودي دفع، إنشاء ملفات PDF) إلى الخارج.
عند فشل شيء ما، سجِّل سياقًا كافيًا لإعادة إنتاج المشكلة دون تسريب بيانات حساسة: اسم تدفق العمل، order_id أو user_id، المعلمات الرئيسية (المبلغ، العملة)، ورمز خطأ Postgres. تجنَّب تسجيل الحمولات الكاملة، بيانات البطاقات، أو التفاصيل الشخصية.
التزامن هو مجرد حدوث شيئين في الوقت نفسه. تخيّل عميلين يحاولان شراء آخر تذكرة لحفلة. كلتا الشاشتين تُظهر "1 متبقٍ"، وكل منهما يضغط "ادفع"، والآن يجب أن يقرر تطبيقك من يحصل عليها.
بدون حماية، يمكن أن تقرأ الطلبات نفس القيمة القديمة ثم كلاهما يكتب تحديثًا. هكذا ينتهي بك الأمر بمخزون سالب، حجوزات مكررة، أو دفع بلا طلب.
أقفال الصفوف هي الحاجز الأبسط. تقفل الصف المحدد الذي ستغيّره، تجري الفحوصات، ثم تحدّثه. يجب أن تنتظر المعاملات الأخرى التي تلمس نفس الصف حتى تلتزم أو تتراجع، مما يمنع التحديث المزدوج.
نمط شائع: ابدأ معاملة، اختر صف المخزون مع FOR UPDATE، تحقق من وجود مخزون، أنقصه، ثم أدخل الطلب. هذا "يُمسك الباب" أثناء إتمام الخطوات الحيوية.
مستويات العزلة تتحكم في مقدار السلوك الغريب الذي تسمح به من معاملات متزامنة. المقايضة عادة بين الأمان والسرعة:
أبقِ الأقفال قصيرة. إذا بقيت المعاملة مفتوحة أثناء استدعاء واجهة خارجية أو انتظار فعل من المستخدم، ستنشئ انتظارًا طويلًا ومهلات. فضّل مسار فشل واضح: عيّن مهلة قفل، التقط الخطأ، وأرجع "يرجى إعادة المحاولة" بدل ترك الطلبات معلقة.
إذا احتجت للعمل خارج قاعدة البيانات (مثل خصم بطاقة)، قسّم تدفق العمل: احجز بسرعة، التزم، ثم قم بالجزء البطيء، وأنهِ ذلك بمعاملة قصيرة أخرى.
إعادة المحاولات أمر طبيعي في التطبيقات القائمة على Postgres. قد يفشل الطلب حتى عندما يكون الكود صحيحًا: انسدادات، مهلات عبارات، انقطاعات شبكة قصيرة، أو خطأ تسلسلي تحت مستويات عزلة أعلى. إذا أعدت تشغيل نفس المعالج ببساطة، تخاطر بإنشاء طلب ثانٍ، شحن مرتين، أو إدراج صفوف حدث مكررة.
الحل هو القابلية للتكرار بأمان (idempotency): يجب أن تكون العملية آمنة للتنفيذ مرتين بنفس الإدخال. يجب أن تتمكن قاعدة البيانات من التعرف على "هذا هو نفس الطلب" والرد بشكل متسق.
نمط عملي هو إرفاق مفتاح idempotency (غالبًا request_id من العميل) بكل تدفق عمل متعدد الخطوات وتخزينه على السجل الأساسي، ثم إضافة قيد فريد على هذا المفتاح.
على سبيل المثال: في سلة الشراء، ولّد request_id عندما ينقر المستخدم "ادفع"، ثم أدخل الطلب مع ذلك request_id. إذا حدثت إعادة محاولة، تثير المحاولة الثانية قيد التفرد وتعيد الطلب الموجود بدل إنشاء واحد جديد.
ما يهم عادة:
احتفظ بحلقة إعادة المحاولة خارج المعاملة. كل محاولة يجب أن تبدأ معاملة جديدة وتعيد تنفيذ وحدة العمل كاملة من الأعلى. إعادة المحاولة داخل معاملة فاشلة لا تفيد لأن Postgres يضعها في حالة ملغاة.
مثال صغير: يحاول تطبيقك إنشاء طلب وحجز مخزون، لكنه ينتهي بالمهلة مباشرة بعد COMMIT. يعيد العميل المحاولة. مع مفتاح idempotency، تعيد المحاولة الثانية الطلب الموجود وتتخطى حجزًا ثانيًا بدل مضاعفة العمل.
المعاملات تجمع تدفق العمل، لكنها لا تجعل البيانات صحيحة تلقائيًا. طريقة قوية لتجنب نتائج الكتابات الجزئية هي جعل الحالات "الخاطئة" صعبة أو مستحيلة في قاعدة البيانات، حتى لو حدث خلل في كود التطبيق.
ابدأ بحواجز أمان أساسية. المفاتيح المرجعية (foreign keys) تتأكد من أن الإشارات حقيقية (سطر تفاصيل الطلب لا يمكن أن يشير إلى طلب مفقود). NOT NULL يمنع الصفوف الناقصة. قيود CHECK تلتقط القيم غير المعقولة (مثل quantity > 0، total_cents >= 0). تعمل هذه القواعد على كل كتابة، أيًا كان الخدمة أو السكربت الذي يتعامل مع القاعدة.
لتدفقات أطول، نمذِج تغييرات الحالة صراحةً. بدل العديد من الأعلام الثنائية، استخدم عمود حالة واحد (pending, paid, shipped, canceled) واسمح فقط بالانتقالات الصحيحة. يمكنك فرض هذا بقيود أو مشغلات حتى ترفض القاعدة القفزات غير القانونية مثل shipped -> pending.
التفرد شكل آخر من أشكال الصحة. أضف قيودًا فريدة حيثما تكسر المكرر العمل: order_number، invoice_number، أو idempotency_key المستخدم لإعادة المحاولات. ثم، إذا أعاد التطبيق نفس الطلب، تمنع قاعدة البيانات الإدخال الثاني ويمكنك إرجاع "معالج بالفعل" بدل إنشاء طلب ثانٍ.
عند الحاجة لتتبع، خزّنه صراحة. جدول تدقيق (أو جدول سجل تاريخي) يسجل من غيّر ماذا ومتى، يحول "التحديثات الغامضة" إلى حقائق يمكنك استعلامها أثناء الحوادث.
معظم الكتابات الجزئية لا تنتج عن "SQL سيئ." تأتي من قرارات تدفق العمل التي تجعل الالتزام بنصف القصة سهلًا.
accounts ثم orders، لكن آخر يحدث orders ثم accounts، تزيد فرصة حدوث deadlocks تحت الحمل.مثال ملموس: في عملية الدفع، تحجز المخزون، تنشئ الطلب، ثم تفرض خصم البطاقة. إذا خصمت البطاقة داخل نفس المعاملة، قد تحتجز قفل المخزون أثناء انتظار الشبكة. إذا نجحت العملية لكن تراجعت المعاملة لاحقًا، خصمت العميل بدون طلب.
نمط أكثر أمانًا هو: اجعل المعاملة محصورة بحالة قاعدة البيانات (حجز المخزون، إنشاء الطلب، تسجيل محاولة الدفع كقيد مؤقت)، التزم، ثم استدعِ واجهة الدفع، ثم سجّل النتيجة في معاملة قصيرة جديدة. ينفذ العديد من الفرق هذا بنمط حالة مؤقتة ووظيفة خلفية.
عندما يحتوي تدفق العمل على خطوات متعددة (إدراج، تحديث، خصم، إرسال)، الهدف بسيط: إما يسجَّل كل شيء، أو لا شيء.
أبقِ كل كتابة قاعدة بيانات لازمة داخل معاملة واحدة. إذا فشلت خطوة، تراجع واترك البيانات كما كانت.
اجعل شرط النجاح صريحًا. على سبيل المثال: "تم إنشاء الطلب، تم حجز المخزون، وتم تسجيل حالة الدفع." أي شيء آخر هو مسار فشل يجب أن يُبطل المعاملة.
BEGIN ... COMMIT واحدة.ROLLBACK، ويتلقى المُستدعي نتيجة فشل واضحة.افترض أن نفس الطلب قد يُعاد محاولته. يجب أن تساعدك قاعدة البيانات على فرض قواعد التنفيذ مرة واحدة فقط.
قم بالحد الأدنى من العمل داخل المعاملة، وتجنب الانتظار على مكالمات الشبكة أثناء الاحتفاظ بالأقفال.
إذا لم تستطع رؤية أين يحدث الخلل، ستستمر بالتخمين.
للدفع عدة خطوات يجب أن تتحرك معًا: إنشاء الطلب، حجز المخزون، تسجيل محاولة الدفع، ثم وضع علامة على حالة الطلب.
تخيل أن المستخدم ينقر شراء لعنصر واحد.
داخل معاملة واحدة، قم فقط بالتغييرات في قاعدة البيانات:
orders بالحالة pending_payment.inventory.available أو أنشئ صفًا في reservations).payment_intents مع idempotency_key مقدم من العميل (فريد).outbox مثل "order_created".إذا فشل أي بيان (نفد المخزون، خطأ قيد، تعطل)، يتراجع Postgres عن كامل المعاملة. لن ينتهي بك الأمر بطلب بدون حجز، أو حجز بدون طلب.
مقدم الدفع خارج قاعدة بياناتك، لذا عاملها كخطوة منفصلة.
إذا فشل الاستدعاء قبل الالتزام، ألغي المعاملة ولا يُكتب شيء. إذا فشل بعد الالتزام، نفّذ معاملة جديدة تحدّث حالة محاولة الدفع إلى فشل، وتفرج الحجز، وتضع حالة الطلب كملغاة.
اطلب من العميل إرسال idempotency_key لكل محاولة دفع. فرضه بفهرس فريد على payment_intents(idempotency_key) (أو على orders إن رغبت). عند إعادة المحاولة، يبحث كودك عن الصفوف الموجودة ويستمر بدل إدراج طلب جديد.
لا تَرسِل الرسائل داخل المعاملة. اكتب سجل outbox في نفس المعاملة، ثم دع عاملًا في الخلفية يرسل البريد بعد الالتزام. بهذه الطريقة لا ترسل بريدًا لطلب تم التراجع عنه.
اختر تدفق عمل يتعامل مع أكثر من جدول: تسجيل + إدراج بريد ترحيبي في الطابور، الدفع + المخزون، فاتورة + دفتر اليومية، أو إنشاء مشروع + إعدادات افتراضية.
اكتب الخطوات أولًا، ثم اكتب القواعد التي يجب أن تصدُق دائمًا (الافتراضات). مثال: "الطلب إما مدفوع ومحجوز بالكامل، أو غير مدفوع وغير محجوز. لا يوجد حالات نصف محجوزة." حوّل هذه القواعد إلى وحدة كلّها أو لا شيء.
خطة بسيطة:
ثم اختبر الحالات القبيحة عمدًا. قم بمحاكاة تعطل بعد الخطوة 2، مهلة قبل الالتزام مباشرةً، وإرسال مزدوج من واجهة المستخدم. الهدف نتائج مملة: لا صفوف يتيمة، لا خصومات مزدوجة، لا حالات معلقة إلى الأبد.
إذا كنت تجري نموذجًا سريعًا، يساعد رسم تخطيطي لتدفق العمل في أداة مخططة قبل إنشاء المعالجات والمخطط. على سبيل المثال، Koder.ai (koder.ai) لديه Planning Mode ويدعم snapshots وrollback، والذي قد يكون مفيدًا أثناء تكرارك على حدود المعاملات والقيود.
قم بذلك لتدفق عمل واحد هذا الأسبوع. الثاني سيكون أسرع بكثير.