ترحيل مخطط Postgres دون توقف: نمط التوسيع والتقليص و lock_timeout وطابور الأقفال
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗أهم النقاط
تُرتَّب طلبات الأقفال في Postgres حسب ترتيب وصولها. أمر ALTER TABLE المعلّق بسبب استعلام SELECT طويل واحد يحجب كل عمليات القراءة والكتابة التي تصل بعده، ولهذا قد يتسبب أمر DDL فوري في توقف يمتد دقائق.
الإعداد lock_timeout في Postgres يحدد المدة التي ينتظرها الأمر للحصول على القفل، بينما يحدد statement_timeout مدة تنفيذه بعد حصوله على القفل. ضبط lock_timeout على بضع ثوانٍ مع إعادة المحاولة يجعل الترحيل يفشل سريعًا بدل تجميد حركة المرور.
نمط التوسيع والتقليص (expand/contract) هو تسلسل من ثلاثة إصدارات: إضافة المخطط الجديد بجانب القديم، ثم التعبئة والكتابة المزدوجة، ثم إزالة المخطط القديم. سبب وجوده أن النشر التدريجي يشغّل النسختين القديمة والجديدة من التطبيق على قاعدة البيانات نفسها في الوقت نفسه.
منذ Postgres 11، يخزّن الأمر ADD COLUMN بقيمة NOT NULL DEFAULT ثابتة القيمةَ الافتراضية في الكتالوج ولا يعيد كتابة الجدول. أما القيمة الافتراضية المتغيرة مثل gen_random_uuid() فما زالت تعيد كتابة كل صف.
الأمر CREATE INDEX CONCURRENTLY لا يحجب الكتابة لكنه لا يعمل داخل كتلة معاملة، لذا يحتاج خطوة ترحيل خاصة به، وفشله يترك فهرسًا بحالة INVALID يجب حذفه قبل إعادة المحاولة.
لماذا يُسقط ترحيل ينفَّذ فوريًا الموقعَ بأكمله؟
لأن Postgres يرتّب طلبات الأقفال حسب وصولها، وأمر ALTER TABLE المحجوب يحجب كل من خلفه. يحتاج ALTER TABLE إلى قفل ACCESS EXCLUSIVE، وهو أقوى مستوى قفل في Postgres ويتعارض مع كل الأقفال الأخرى بما فيها قفل ACCESS SHARE الذي يأخذه استعلام SELECT عادي. فإذا كانت أي معاملة تحمل قفلًا متعارضًا على الجدول، ينتظر ALTER TABLE.
لا يسمح Postgres لطلبات الأقفال الأضعف اللاحقة بتخطي الطابور. فينتظر استعلام SELECT التالي خلف ALTER TABLE، وينتظر كل طلب بعده. وهكذا يتحول تحديث كتالوج مدته جزء من الثانية إلى انقطاع تساوي مدته زمن تنفيذ ما كان يحمل الجدول أصلًا.
المتسببون المعتادون ثلاثة في كل مرة: استعلام تحليلي طويل، وجلسة عالقة في حالة idle in transaction فتحها طلب ويب ولم تُغلق، وعملية autovacuum في وضع منع الالتفاف (anti-wraparound) التي لا تتنازل عن القفل كما تفعل عملية autovacuum العادية.
لرؤية ذلك مباشرة، استعلم عن pg_stat_activity مع الدالة pg_blocking_pids(). كل صف يحمل مصفوفة معرّفات حاجبة غير فارغة هو صف منتظِر، والمعرّف داخل تلك المصفوفة هو المعاملة التي يجب التعامل معها فعليًا.
ما هو نمط التوسيع والتقليص في الترحيل؟
نمط التوسيع والتقليص تسلسل من ثلاثة إصدارات يبقي قاعدة البيانات متوافقة مع النسختين القديمة والجديدة من التطبيق في كل لحظة. وهو ضروري لأي نشر تدريجي أو بلا خوادم، لأن النسختين تخدمان الطلبات على قاعدة بيانات واحدة أثناء الطرح.
تبدو إعادة تسمية users.full_name إلى users.display_name أمرًا واحدًا، وفعلًا فإن ALTER TABLE ... RENAME COLUMN عملية فورية على مستوى البيانات الوصفية في Postgres. لكنها أيضًا انقطاع: كل نسخة قديمة ما زالت تنفّذ SELECT full_name تبدأ بإرجاع أخطاء لحظة تثبيت التغيير.
التوسيع: أضف عمود display_name قابلًا للقيمة الفارغة، ثم انشر كودًا يكتب في العمودين وما زال يقرأ من القديم. تستمر النسخة القديمة بالعمل لأن العمود الجديد اختياري.
التعبئة: انسخ الصفوف الحالية على دفعات، ثم حوّل القراءة إلى display_name وانشر. عندها يكون العمودان معبّأين وصحيحين.
التقليص: أوقف الكتابة في full_name، انشر، وبعد ذلك فقط نفّذ ALTER TABLE users DROP COLUMN full_name. حذف العمود عملية وصفية فورية لكنها غير قابلة للتراجع عمليًا، لذا تأتي أخيرًا وبمفردها.
القاعدة التي يتبعها فريقنا: يجوز للإصدار الواحد أن يضيف أشياء اختيارية أو يزيل أشياء غير مستخدمة، لا الاثنين معًا، ولا أي شيء تعتمد عليه نسخة قيد التشغيل.
أي أوامر DDL آمنة في Postgres وأيها يعيد كتابة الجدول كاملًا؟
إعادة كتابة الجدول تنسخ كل صف إلى ملفات جديدة أثناء حمل قفل ACCESS EXCLUSIVE، وهذا على جدول كبير انقطاع غير محدود المدة. السلوك الموصوف أدناه يخص Postgres 12 فما فوق.
آمن ووصفي فقط: إضافة عمود قابل للقيمة الفارغة أو بقيمة افتراضية ثابتة (Postgres 11 فأحدث)، وتغيير النوع من varchar(50) إلى text لأنه قابل للتحويل ثنائيًا، وحذف عمود، وإعادة تسمية عمود.
يعيد كتابة الجدول بالكامل: إضافة عمود بقيمة افتراضية متغيرة مثل gen_random_uuid()، وتغيير النوع من int إلى bigint. يُستبدل كلاهما بعمود جديد مع تعبئة على دفعات وتبديل عبر نمط التوسيع والتقليص.
مسح حاجب: الأمر ALTER COLUMN SET NOT NULL يمسح الجدول كاملًا تحت قفل ACCESS EXCLUSIVE، وإضافة مفتاح أجنبي تمسح وتقفل الجدولين معًا، وأمر CREATE INDEX يحجب الكتابة طوال بناء الفهرس.
آمن لقاعدة البيانات وغير آمن للكود العامل: حذف العمود وإعادة تسميته عمليتان فوريتان، لكنهما تكسران أي نسخة تطبيق قديمة ما زالت تشير إلى العمود. كلاهما ينتمي إلى مرحلة التقليص فقط.
حيلة NOT VALID هي الأكثر استخدامًا لدينا. إضافة مفتاح أجنبي بصيغة NOT VALID عملية فورية تتحقق من الكتابات الجديدة فقط، ثم يمسح أمر VALIDATE CONSTRAINT الصفوف الحالية تحت قفل SHARE UPDATE EXCLUSIVE الذي لا يحجب القراءة ولا الكتابة.
النمط نفسه يجعل العمود NOT NULL دون مسح حاجب. أضف قيد CHECK (col IS NOT NULL) بصيغة NOT VALID، ثم نفّذ VALIDATE CONSTRAINT، ثم SET NOT NULL. فمنذ Postgres 12 يستخدم الأمر SET NOT NULL قيد CHECK المُتحقَّق منه كدليل ويتخطى مسحه الخاص.
كيف نمنع الترحيل من حجب حركة المرور؟
اضبط lock_timeout قبل أمر DDL كي يستسلم الترحيل بدل أن يقف على رأس طابور الأقفال. هذا أعلى سطر قيمة في إعداد الترحيل لدينا.
بضبط lock_timeout على ثلاث ثوانٍ، يفشل الترحيل الذي لا يحصل على قفله خلالها برسالة 'canceling statement due to lock timeout' (الرمز 55P03) ويحرر الطابور. فلا ينتظر أي طلب خلفه أكثر من ثلاث ثوانٍ.
الفشل هنا مقبول لأن الاستجابة الصحيحة هي إعادة المحاولة. التزاحم على الأقفال عابر، والمحاولة الثالثة أو الرابعة تصادف عادة فجوة بين الاستعلامات الطويلة. نغلّف الترحيل بدالة إعادة محاولة تلتقط الرمز 55P03 وتتراجع أسّيًا.
تحذير يكلف ساعات: الأمر CREATE INDEX CONCURRENTLY لا يعمل داخل كتلة معاملة. ومعظم منفّذي الترحيل يغلّفون كل ملف بمعاملة واحدة، لذا يحتاج الفهرس المتزامن خطوة تتخطى هذا الغلاف. وفشل التنفيذ يترك فهرسًا بحالة INVALID يجب حذفه بـ DROP INDEX CONCURRENTLY قبل إعادة المحاولة.
كيف نعبّئ ملايين الصفوف دون معاملة طويلة واحدة؟
عبّئ على دفعات محدودة، كل دفعة في معاملتها الخاصة، مع توقف قصير بينها. أمر UPDATE واحد على جدول كبير يحمل أقفال الصفوف طوال مدة تنفيذه، ويضخّم الجدول بصفوف ميتة أسرع مما تستعيده autovacuum، ويضخّم سجل WAL بما يكفي لتأخر النسخة المتماثلة.
تختار كل دفعة مجموعة محدودة من المفاتيح الأساسية عبر ORDER BY id LIMIT 5000 FOR UPDATE SKIP LOCKED، ثم تحدّث تلك الصفوف فقط. تعني SKIP LOCKED أن التعبئة تتخطى الصفوف التي تعدّلها حركة المرور الحية بدل انتظارها، وتلتقطها الدفعة التالية.
نشغّل الحلقة من سكربت لا من ملف ترحيل، كي يمكن إيقافها واستئنافها ومراقبتها. تتوقف الحلقة عندما تُبلّغ دفعة عن تحديث صفر صف، وتنام فترة قصيرة بين الدفعات كي تلحق النسخ المتماثلة و autovacuum.
حجم الدفعة الجدير بالضبط هو الذي تبقى معه الدفعة الواحدة أقل بكثير من statement_timeout ويبقى تأخر التماثل مستقرًا. نبدأ من 5000 صف ونعدّل استنادًا إلى رسم التأخر الفعلي لا إلى رقم منشور على الإنترنت.
أين يجب أن يعمل الترحيل داخل خط CI/CD؟
ينتمي الترحيل إلى خطوة مخصصة تعمل بعد البناء وقبل أن تستقبل النسخة الجديدة حركة المرور. ولا يجوز تشغيله داخل أمر البناء next build ولا عند إقلاع التطبيق.
تشغيل الترحيل عند إقلاع التطبيق هو أكثر أنماط الفشل التي نراها في المشاريع بلا خوادم: كل نسخة تبدأ من إقلاع بارد تحاول الترحيل بالتوازي على قاعدة البيانات نفسها.
تأخذ أداتا Drizzle Kit و Prisma Migrate قفلًا استشاريًا في Postgres كي تتسلسل عمليات الترحيل المتزامنة بدل أن تتصادم. هذا يمنع الفساد لكنه لا يحل المشكلة الأعمق، وهي أن النشر لا يملك نقطة توقف نظيفة عند فشل الترحيل. الخطوة المنفصلة تملكها.
يجب أن يستخدم الترحيل اتصالًا مباشرًا بقاعدة البيانات لا وسيط تجميع بوضع المعاملات، لأن إعدادات مستوى الجلسة مثل SET lock_timeout والأقفال الاستشارية لا تعمل بشكل صحيح حين يمكن لكل أمر أن يصل إلى خادم خلفي مختلف.
الترحيل أحادي الاتجاه إلى الأمام. فنمط التوسيع والتقليص يضمن أصلًا أن النسخة السابقة من التطبيق تعمل جيدًا على المخطط الجديد، وهي قصة تراجع أوثق بكثير من سكربت هبوط لم ينفّذه أحد على بيانات الإنتاج.
الأسئلة الشائعة
س: هل نحتاج نمط التوسيع والتقليص لمجرد إضافة عمود؟
ج: لا. العمود القابل للقيمة الفارغة أو ذو القيمة الافتراضية الثابتة متوافق مع الخلف، وتتجاهله النسخة القديمة من التطبيق ببساطة. النمط لازم فقط عند إعادة تسمية شيء قائم أو حذفه أو تغيير نوعه أو جعله إلزاميًا.
س: هل يمكن تشغيل CREATE INDEX CONCURRENTLY من ترحيلات Drizzle أو Prisma؟
ج: نعم، لكن فقط في خطوة ترحيل غير مغلّفة بمعاملة، لأن Postgres يرفض CONCURRENTLY داخل كتلة معاملة. ضعه في ملف خاص به دون أي أوامر أخرى.
س: ما القيمة المناسبة لـ lock_timeout؟
ج: بضع ثوانٍ. ثلاث ثوانٍ مدة كافية لكسب سباق قفل عادي وقصيرة بما لا يلاحظه أي طلب في الطابور. اقرنها بإعادة المحاولة والتراجع الأسّي، لأن انتهاء مهلة القفل نتيجة متوقعة لا خطأ.
س: كيف نعرف ما الذي حجب الترحيل بعد وقوعه؟
ج: استعلم عن pg_stat_activity مع pg_blocking_pids() أثناء حدوث المشكلة، وفعّل log_lock_waits كي يكتب Postgres سطر سجل كلما انتظرت جلسة قفلًا مدة أطول من deadlock_timeout.
س: هل تستحق ترحيلات التراجع كتابتها؟
ج: نادرًا. فبمجرد تنفيذ الترحيل على بيانات الإنتاج، يؤدي التراجع عنه عادة إلى إتلاف بيانات أنشأتها النسخة الأمامية. ونمط التوسيع والتقليص يبقي كل مخطط وسيط متوافقًا مع نسختي التطبيق المجاورتين، وهو ما يحتاجه التراجع فعليًا.
قراءات إضافية
هندسة الخوادم
لماذا يصبح الترقيم بـ OFFSET أبطأ مع كل صفحة: مؤشرات keyset ومفاتيح الترتيب المستقرة والفهرس الذي يجعلها تعمل
استبدلنا الترقيم بـ OFFSET في واجهة إنتاجية بعدما بدأت الصفحات العميقة تتجاوز مهلة الاستجابة. هذه ملاحظاتنا الهندسية حول سبب كون تكلفة OFFSET متناسبة مع عمق الصفحة، وكيف تحوّل مؤشرات keyset الترقيم إلى بحث مباشر داخل الفهرس، وأي فهرس مركّب يحتاجه الاستعلام، وخطأ دقة الطوابع الزمنية الذي جعل مؤشراتنا تتخطى صفوفًا.
هندسة الخوادم
المهام الخلفية على Vercel في 2026: كيف نختار بين waitUntil وQueues وWorkflow وCron
لم تقضِ البنية بدون خوادم على المهام الخلفية، بل قضت على المهام التي تستمر بعد إرسال الاستجابة دون إبلاغ بيئة التشغيل. نشرح الأدوات الأربع التي يستخدمها فريقنا لتأجيل التنفيذ على Vercel، ونوع الفشل الذي تتحمله كل أداة، وشرط الـidempotency الذي يفرضه التسليم مرة واحدة على الأقل.