backend

عدم إزالة الـ Dead Tuples في Postgres: الحلول (2026)

٢٧ أغسطس ٢٠٢٦

Postgres Dead Tuples Not Being Removed: Fixes (2026)

عندما لا يتم إزالة الـ dead tuples في Postgres، يكون السبب المعتاد هو أن VACUUM قد عمل ولم يجد شيئاً مسموحاً له بحذفه. فهو يزيل فقط إصدارات الصفوف الأقدم من أفق xmin، وهناك أربعة أشياء موثقة تعيق هذا الأفق: المعاملات المفتوحة (open transactions)، والمعاملات المُعدة (prepared transactions)، وفتحات النسخ المتماثل (replication slots)، وتغذية الـ standby (standby feedback).

ملخص

لا يقرر VACUUM ما هو "النفايات" من خلال النظر إلى جدولك، بل يقوم بحساب معرف معاملة قطع (cutoff transaction ID) ويزيل فقط إصدارات الصفوف التي تم حذفها قبل هذا المعرف. إجراء الاسترداد الخاص بـ PostgreSQL يحدد الأشياء التي تسحب هذا القطع للخلف، ويذكر ثلاثة منها في مكان واحد: المعاملات المُعدة في pg_prepared_xacts، والمعاملات طويلة التشغيل في pg_stat_activity، وفتحات النسخ المتماثل في pg_replication_slots.1 أما الرابع، وهو hot_standby_feedback، فموثق بشكل منفصل، وينص وصفه على أنه "يمكن أن يسبب تضخماً (bloat) في قاعدة البيانات الأساسية لبعض أعباء العمل".2

لذا، عندما لا يتم إزالة الـ dead tuples في Postgres، فإن السؤال المفيد ليس "لماذا لا يقوم autovacuum بتنظيف جدولي" بل "ما هو معرف معاملة القطع الخاص بي، ومن يملكها". أمر VACUUM (VERBOSE) واحد يجيب على النصف الأول في سطر واحد. وأربعة استعلامات قصيرة من الكتالوج تجيب على النصف الثاني. هذا هو المسار الأرخص للاستبعاد — ولكن استبعده بدلاً من افتراضه، لأن المسار الآخر، حيث لا يصل autovacuum إلى الجدول على الإطلاق، له حلول مختلفة تماماً ويخصص له ثلاثة أقسام أدناه.

يعمل هذا الدليل بهذا الترتيب: ماذا تعني هذه العبارة، وهل عمل vacuum على الإطلاق، ومن الذي يمسك بالأفق، ولماذا قد لا يصل autovacuum إلى الجدول، وكيفية استعادة المساحة، وكيفية منع ذلك، وماذا يحدث إذا لم تفعل.

نطاق الإصدار. كل ما يلي مكتوب ومُحقق مقابل توثيقات PostgreSQL 18، والتي تصف الإصدار 18.6 وقت كتابة هذا المقال.1 الآلية — MVCC، وأفق xmin، والممسكون الأربعة — هي نفسها في كل إصدار مدعوم. أما المعلمات المحددة، وأعمدة العرض (view columns) وتنسيقات أسطر السجلات فليست كذلك: العديد مما تم استخدامه هنا أضيف في 17 أو 18 وتم تمييزها في النص. إذا كنت تستخدم 14 أو 15 أو 16 أو 17، فراجع الصفحة المقابلة في توثيقات إصدارك قبل نسخ أي استعلام أو إعداد.

ما ستتعلمه

  • ماذا تعني فعلياً عبارة "dead but not yet removable" في مخرجات VACUUM، وكيفية قراءتها جنباً إلى جنب مع سطر removable cutoff
  • كيفية التأكد من أن autovacuum قد عمل على الإطلاق، وماذا فعل، باستخدام طرق عرض إحصائيات الجدول و pg_stat_progress_vacuum
  • عملية المسح بأربعة استعلامات التي تحدد أي جلسة (session)، أو فتحة (slot)، أو standby يمسك بأفق xmin
  • لماذا تتسبب الجلسة الخاملة (idle session) داخل معاملة مفتوحة في تضخيم الجداول حتى لو لم تكن تملك أي أقفال (locks)
  • كيف تؤدي فتحة نسخ متماثل منسية إلى إيقاف التنظيف، وكيف تعرف ما إذا كانت هي الجاني فعلاً، وتكلفة حذف الفتحة الخاطئة
  • لماذا يضحي hot_standby_feedback بإلغاء الاستعلامات على الـ replica مقابل التضخم في الـ primary
  • لماذا تعتبر المعاملات المُعدة (prepared transactions) هي ممسك الأفق الوحيد الذي لن يقوم أي مهلة زمنية (timeout) بتنظيفه نيابة عنك
  • كيف يتم حساب حد تحفيز autovacuum (trigger threshold)، وما الذي يغيره الحد الأقصى الجديد في PostgreSQL 18 للجداول الكبيرة
  • لماذا يمكن أن يبدأ autovacuum في جدول ولا ينتهي أبداً، وأي أمر روتيني يمكن أن يمنعه للأبد، وكيف يتم تخطي تنظيف الفهارس (index cleanup) عمداً
  • ما هي الجداول التي لا يلمسها autovacuum على الإطلاق
  • لماذا لا يتقلص ملف الجدول حتى عندما يتم إزالة الصفوف الميتة فعلياً
  • متى يكون VACUUM FULL هو الخيار الصحيح، وما هي تكلفة البدائل
  • ماذا يعني سطر "tuples missed … cleanup lock contention"، ولماذا تعتبر هذه مشكلة مختلفة
  • ما هي المهلات (timeouts) التي تقلل المخاطر، ومن هو الحامل الذي لا يمكن لأي منها لمسه، ولماذا ينتمي lock_timeout إلى هذه القائمة
  • ماذا يحدث إذا تركت الأمر: تحذيرات الـ wraparound، التوقف التام، وترتيب الاستعادة الموثق
  • ماذا يعني "dead but not yet removable" في Postgres؟

    هذا يعني أن VACUUM وجد إصدارات صفوف ميتة ولم يُسمح له بحذفها، لأن هناك عملية (transaction) قد تظل بحاجة لرؤيتها ولم تنتهِ بعد. تظل هذه الصفوف على القرص وتستمر في الحساب ضمن n_dead_tup حتى يتقدم الأفق (horizon) ليتجاوزها.

    هذا نتيجة مباشرة لـ MVCC. وكما تذكر الوثائق، فإن "عملية UPDATE أو DELETE لصف لا تزيل الإصدار القديم من الصف فوراً... يجب عدم حذف إصدار الصف طالما أنه لا يزال مرئياً لعمليات أخرى."1 يتم تحديد الرؤية من خلال مقارنة معرف العملية (transaction ID)، لذا يقوم VACUUM بحساب نقطة قطع XID واحدة للعملية بأكملها ويطبقها بشكل موحد. أي شيء تم حذفه بواسطة عملية أحدث من نقطة القطع هذه ينجو من عملية التنظيف، بغض النظر عن مدى وضوح كونه "نفايات" بالنسبة لك.

    يقوم PostgreSQL بطباعة كلا جانبي هذه القصة. في PostgreSQL 18، تحتوي كتلة الملخص التي يكتبها VACUUM (VERBOSE) — وبواسطة autovacuum، عندما يجعلها log_autovacuum_min_duration ترسل تقريراً1 — على سطر للـ tuple وسطر لنقطة القطع (cutoff):

    INFO:  vacuuming "app.public.events"
    INFO:  finished vacuuming "app.public.events": index scans: 0
    pages: 0 removed, 1842317 remain, 1842317 scanned (100.00% of total), 0 eagerly scanned
    tuples: 0 removed, 41231334 remain, 8842190 are dead but not yet removable
    removable cutoff: 2839471104, which was 41 XIDs old when operation ended
    

    هذه هي تنسيقات الأسطر في PostgreSQL 18، مأخوذة من المصدر الذي يصدرها.3 الإصدارات السابقة تطبع كتلة مشابهة ولكن ليست متطابقة — حقل eagerly scanned، على سبيل المثال، يصاحب عمل الـ eager-freezing في إصدار 18 — لذا قارن مع مخرجات إصدارك بدلاً من افتراض تطابق الأشكال.

    اقرأ السطرين المهمين معاً. ظهور 0 removed مع عدد كبير من "dead but not yet removable" هو العلامة المميزة لأفق محتجز (held horizon). يحدد سطر نقطة القطع الـ XID الدقيق الذي استخدمه VACUUM ومدى تأخره عن عداد العمليات الحالي عند انتهاء العملية — هذه الصياغة تأتي مباشرة من مسار كود vacuum الذي يصدرها.3 عمر يتكون من بضع عشرات من XIDs يعتبر صحياً. أما العمر الذي يصل للملايين فيعني أن هناك شيئاً ما يقف عند نقطة القطع هذه لفترة طويلة، والقسم التالي يوضح كيف تجده.

    هذه الصياغة تستحق الاستيعاب لأنها هي ما ستبحث عنه باستخدام grep. بخصوص VERBOSE، تقول الوثائق فقط أنه "يطبع تقريراً مفصلاً عن نشاط vacuum لكل جدول بمستوى INFO."4 في مستوى INFO: عبارة "dead but not yet removable" ليست خطأً وليست تحذيراً. بل هي عملية vacuum ترفض بشكل صحيح تدمير بيانات قد يظل شخص ما يقرأها.

    لماذا لا ينخفض n_dead_tup بعد عملية VACUUM؟

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

    تجيب pg_stat_all_tables، والمجموعة الفرعية منها pg_stat_user_tables، على هذا. في PostgreSQL 18، تحمل الرؤية (view) عدد الـ dead-tuple، والطوابع الزمنية لآخر عمليات تنظيف يدوية وتلقائية، وعدد كل منها، و — كإضافة جديدة في هذا الإصدار — إجمالي الوقت التراكمي لكليهما:5

    SELECT relname,
           n_live_tup,
           n_dead_tup,
           n_ins_since_vacuum,
           last_vacuum,
           last_autovacuum,
           vacuum_count,
           autovacuum_count,
           total_autovacuum_time
    FROM pg_stat_user_tables
    ORDER BY n_dead_tup DESC
    LIMIT 20;
    

    إن total_vacuum_time و total_autovacuum_time غير موجودين في تعريف هذا العرض في PostgreSQL 17؛ فقد تمت إضافتهما للإصدار 18.6 في الإصدارات من 14 إلى 17، قم بحذف هذين العمودين من الاستعلام.

    ملاحظة حول النطاق. تصف الوثائق pg_stat_user_tables على أنها "نفس pg_stat_all_tables، باستثناء أنه يتم عرض جداول المستخدمين فقط" — لذا فهي تخفي كتالوجات النظام وعلاقات TOAST.7 القيم الخارجة عن السطر (out-of-line) في الجداول العريضة تعيش في علاقة TOAST الخاصة بها، والتي لها صف إحصائيات خاص بها. يقوم VACUUM العادي بمعالجتها مع الجدول الأساسي افتراضيًا — وهذا ما يتحكم فيه خيار PROCESS_TOAST4 — ولكن إعدادات autovacuum الخاصة بها يتم تكوينها بشكل منفصل عن الجدول الأساسي. إذا بدا الـ heap نظيفًا ولكن الحجم الإجمالي للعلاقة يستمر في الارتفاع، فقم بتشغيل نفس الاستعلام على pg_stat_all_tables واختر relid::regclass ليكون المخطط (schema) مرئيًا.

    ثلاث قراءات، وثلاث استنتاجات مختلفة:

    ما تراهما يعنيه ذلكالخطوة التالية
    last_autovacuum حديث، و autovacuum_count في ارتفاع، و n_dead_tup لا يزال مرتفعًاعملية Vacuum تعمل ولكنها ممنوعة من إزالة الصفوفمسح الأفق (horizon sweep)، أدناه
    last_autovacuum فارغ (null) أو قديم جدًا، و n_dead_tup مرتفعلم يصل autovacuum إلى هذا الجدول بعد"لماذا لا يعمل autovacuum على جدولي على الإطلاق؟"
    last_autovacuum لا يتقدم أبدًا بينما يوجد worker للجدولتبدأ عملية Vacuum ثم يتم إلغاؤها أو تظل عالقة في مرحلة طويلة"لماذا يبدأ autovacuum ولكن لا ينتهي أبدًا؟"

    لرؤية عملية جارية، يحتوي pg_stat_progress_vacuum على صف واحد لكل backend يقوم بعملية vacuum حاليًا، "بما في ذلك عمليات autovacuum worker".8 وهو يوضح الـ phase الحالية، وكتل heap التي تم فحصها وتنظيفها، وعدد دورات vacuum للفهارس التي اكتملت:

    SELECT p.pid,
           p.relid::regclass AS table_name,
           p.phase,
           p.heap_blks_scanned,
           p.heap_blks_total,
           p.index_vacuum_count,
           a.query
    FROM pg_stat_progress_vacuum p
    JOIN pg_stat_activity a USING (pid);
    

    تنبيه واحد قد يفاجئ البعض: VACUUM FULL لا يظهر هنا على الإطلاق. لأن VACUUM FULL و CLUSTER يقومان بإعادة كتابة الجدول بدلاً من تعديله في مكانه، لذا يتم الإبلاغ عن تقدمهما من خلال pg_stat_progress_cluster بدلاً من ذلك، حيث يظهر في عمود command إما CLUSTER أو VACUUM FULL.8

    ما هو أفق xmin وكيف أجد ما الذي يعطله؟

    أفق xmin هو أقدم معرف معاملة (transaction ID) لا يزال بحاجة إلى القدرة على رؤية إصدارات الصفوف القديمة. لن يقوم VACUUM بإزالة إصدار صف تم حذفه بواسطة معاملة أحدث منه. تغطي أربعة استعلامات للكتالوج كل المسببات الموثقة؛ قم بتشغيل الأربعة جميعًا، لأنه يمكن أن يكون هناك أكثر من مسبب واحد.

    قم بتشغيلها كمستخدم خارق (superuser) أو كعضو في pg_read_all_stats. عروض الإحصائيات مقيدة أمنيًا: "يمكن للمستخدمين العاديين فقط رؤية جميع المعلومات حول جلساتهم الخاصة... وفي الصفوف المتعلقة بالجلسات الأخرى، ستكون العديد من الأعمدة فارغة (null)".7 عند الاتصال بدور التطبيق الخاص بك، سترى عمودًا مطمئنًا من قيم NULL بينما يكمن المسبب في نفس الجدول.

    إن إجراء PostgreSQL الخاص بالتعافي من استنفاد معرف المعاملات (transaction ID exhaustion) هو، في الواقع، قائمة مراجعة لحاملي الأفق (horizon holders)، ويسردها بهذا الترتيب: حل المعاملات المُعدة (prepared transactions) القديمة الموجودة في pg_prepared_xacts؛ إنهاء المعاملات المفتوحة طويلة الأمد الموجودة في pg_stat_activity؛ حذف فتحات النسخ المتماثل (replication slots) القديمة الموجودة في pg_replication_slots.1 أما الرابع، وهو تعليقات standby (standby feedback)، فقد تم توثيقه ضمن إعدادات النسخ المتماثل.2 لاحظ أن هذا هو ترتيب الوثائق لدليل التعافي، وليس ترتيباً حسب عدد المرات التي تسبب فيها كل منها في مشكلة — لا يوجد مصدر يصنفها، وهذا الدليل لن يفعل ذلك أيضاً.

    1. المعاملات المفتوحة ولقطاتها (snapshots). يتم توثيق pg_stat_activity.backend_xmin، بإيجاز ودقة، على أنه "أفق xmin الخاص بالخلفية الحالية"؛ بينما backend_xid هو "معرف المعاملة على المستوى الأعلى لهذه الخلفية، إن وجد".7 تحقق من كليهما، وهو ما يخبرك به إجراء التعافي الخاص بـ PostgreSQL — حيث ينصح بالبحث عن الصفوف "التي يكون فيها age(backend_xid) أو age(backend_xmin) كبيراً".1 يمكن لجلسة القراءة فقط أن تحجز الأفق من خلال لقطتها دون وجود XID على الإطلاق، ويمكن لجلسة الكتابة أن تحجزه من خلال XID الخاص بها؛ وليس بالضرورة أن تكون أي منهما خاملة. فتقرير يستغرق ست ساعات ويعمل بنشاط يُحسب تماماً مثل جلسة تركها شخص ما.

    SELECT pid,
           datname,
           usename,
           state,
           age(backend_xid)  AS xid_age,
           age(backend_xmin) AS xmin_age,
           now() - xact_start AS xact_duration,
           left(query, 80)   AS query
    FROM pg_stat_activity
    WHERE backend_xid IS NOT NULL
       OR backend_xmin IS NOT NULL
    ORDER BY greatest(age(backend_xid), age(backend_xmin)) DESC;
    

    قم بالفرز حسب العمر (age)، وليس حسب المدة (duration) — فالجلسة التي ظلت مفتوحة لمدة ساعة ولكنها أخذت لقطتها منذ دقيقة واحدة فقط غير ضارة، بينما معاملة REPEATABLE READ التي أخذت لقطة في بداية تقرير مدته أربع ساعات ليست كذلك. هناك فلتران يستحقان الإضافة في العناقيد (clusters) المزدحمة: استبعاد عمال autovacuum و walsenders عن طريق التحقق من backend_type، وتذكر أن الاستعلام كما هو مكتوب يشمل كل قاعدة بيانات في العنقود، لذا فإن أقدم صف قد ينتمي إلى قاعدة بيانات ليست هي التي تحقق فيها.

    2. المعاملات المُعدة (Prepared transactions). يحتوي pg_prepared_xacts على صف واحد لكل معاملة التزام ثنائية المرحلة (two-phase-commit) تنتظر الحل، مع XID، والمعرف العالمي (global ID)، ووقت إعدادها. يختفي الإدخال فقط عندما يتم تثبيت المعاملة (commit) أو التراجع عنها (roll back).9

    SELECT gid, database, owner, prepared, age(transaction) AS xid_age
    FROM pg_prepared_xacts
    ORDER BY age(transaction) DESC;
    

    3. فتحات النسخ المتماثل (Replication slots). يتم وصف عمود xmin في pg_replication_slots بأنه "أقدم معاملة تحتاج هذه الفتحة من قاعدة البيانات الاحتفاظ بها. لا يمكن لـ VACUUM إزالة الصفوف (tuples) التي تم حذفها بواسطة أي معاملة لاحقة". ويقول catalog_xmin الشيء نفسه بالنسبة لصفوف كتالوج النظام.10

    SELECT slot_name, slot_type, database, active, active_pid,
           age(xmin)         AS xmin_age,
           age(catalog_xmin) AS catalog_xmin_age,
           restart_lsn,
           wal_status,
           inactive_since,        -- PostgreSQL 17+
           invalidation_reason    -- PostgreSQL 18+
    FROM pg_replication_slots
    ORDER BY greatest(age(xmin), age(catalog_xmin)) DESC NULLS LAST;
    

    هناك عمودان يعتمدان على الإصدار: لا يحتوي pg_replication_slots في PostgreSQL 16 على inactive_since ولا invalidation_reason — بل يعرض conflicting بدلاً من ذلك — لذا قم بحذف تلك السطور في الإصدار 16 وما قبله وإلا سيظهر خطأ في الاستعلام.11

    اقرأ النتيجة بعناية قبل اتخاذ أي إجراء. لا يكون الـ slot بمثابة حاجز للأفق (horizon holder) إلا إذا كانت قيمة xmin أو catalog_xmin غير فارغة (non-null). إن catalog_xmin بمفرده أضيق مما يبدو: فهو يمثل أقدم معاملة "تؤثر على كتالوجات النظام" والتي يحتاج الـ slot للاحتفاظ بها، و VACUUM "لا يمكنه إزالة عناصر الكتالوج (catalog tuples) التي تم حذفها بواسطة أي معاملة لاحقة" — أي عناصر الكتالوج، وليس صفوف جدولك.10 أما الـ slot الذي تكون فيه كلتا القيمتين فارغتين فلا يعيق عملية التنظيف على الإطلاق؛ وإذا كان restart_lsn الخاص به متأخراً جداً، فهو يحتفظ بـ WAL، وهي مشكلة مساحة قرص لها حل مختلف.

    4. تعليقات الاحتياطي (Standby feedback). في الخادم الأساسي (primary)، يمثل pg_stat_replication.backend_xmin "أفق xmin الخاص بهذا الاحتياطي كما أبلغ عنه hot_standby_feedback."7 وجود قيمة غير فارغة وتزداد قدماً هنا يعني أن هناك استعلاماً على نسخة احتياطية (replica) يعيق عملية التنظيف على الخادم الأساسي:

    SELECT application_name, client_addr, state,
           age(backend_xmin) AS standby_xmin_age
    FROM pg_stat_replication
    ORDER BY age(backend_xmin) DESC NULLS LAST;
    

    أياً كان الاستعلام الذي يعيد أكبر عمر (age)، قارنه بعمر removable cutoff من مخرجات vacuum. يجب أن يشير كلاهما إلى نفس المشكلة. إذا كان عمر أكبر حاجز يقارب عمر الـ cutoff، فقد وجدت إجابتك.

    هل تعيق جلسة "خاملة في معاملة" (idle in transaction) عملية VACUUM في Postgres؟

    نعم — وهذا ما تذكره الوثائق الرسمية في وصف المهلة الزمنية (timeout) الموجودة لإنهاء مثل هذه الجلسات. المهم هو أن المعاملة مفتوحة، وليس ما إذا كانت تقوم بأي عمل، ولا ما إذا كانت تحتجز أقفالاً (locks).

    الصياغة مباشرة بشكل غير معتاد: "حتى عند عدم وجود أقفال مهمة، فإن المعاملة المفتوحة تمنع عملية vacuum من إزالة العناصر الميتة حديثاً التي قد تكون مرئية فقط لهذه المعاملة؛ لذا فإن البقاء في حالة خمول لفترة طويلة يمكن أن يساهم في تضخم الجدول (table bloat)."12

    هذا هو نمط الفشل الذي ينتج غالباً عن كود التطبيق بدلاً من مسؤول قاعدة البيانات (DBA). مثل ORM يفتح معاملة في بداية طلب ويب ويحتفظ بها أثناء إجراء مكالمة HTTP خارجية؛ أو مجمع اتصالات (connection pool) يسلم جلسة لا تزال تحتوي على BEGIN غير مثبتة (uncommitted)؛ أو نافذة psql تفاعلية تركها شخص ما مفتوحة بعد كتابة BEGIN;. لا يبدو أي من هذه الحالات كمشكلة في قاعدة البيانات، ولا تظهر أي منها كصراع على الأقفال (lock contention).

    يمكنك العثور عليها باستخدام استعلام pg_stat_activity المذكور أعلاه، مع التصفية حسب الحالة (state):

    SELECT pid, usename, application_name, client_addr, backend_type,
           now() - state_change AS idle_for,
           age(backend_xid)     AS xid_age,
           age(backend_xmin)    AS xmin_age
    FROM pg_stat_activity
    WHERE state = 'idle in transaction'
    ORDER BY greatest(age(backend_xid), age(backend_xmin)) DESC NULLS LAST;
    

    اختر كلا عمودي العمر، وليس backend_xmin فقط. اعتماداً على مستوى العزل (isolation level) وما إذا كانت المعاملة قد كتبت أي شيء، يمكن أن يكون أحد الاثنين فارغاً بينما يكون الآخر هو ما يعيق التنظيف فعلياً — وهذا هو السبب في أن الإجراء الموثق يفحص age(backend_xid) أو age(backend_xmin) بدلاً من أي منهما بمفرده.1

    لإغلاق أحدهم، استخدم SELECT pg_terminate_backend(pid); — وهو الحل الموثق الذي تسميه إجراءات استعادة الـ wraparound صراحةً لهذه الحالة تحديداً.1 هناك أمران يجب معرفتهما قبل التشغيل. إن إنهاء الـ backend يؤدي إلى تراجع (rollback) المعاملة الخاصة به، لذا تأكد من أن العمل الموجود بداخله قد تم التخلي عنه فعلياً. كما أنه ليس الأداة المناسبة لكل صف وجدته في عملية المسح: إذا كان الـ standby خلف PID الخاص بـ walsender من الاستعلام الرابع يستخدم replication slot، فإن إنهاء ذلك الـ walsender قد لا يحرر أي شيء، لأن الـ slot يحتفظ بـ xmin الخاص به في الكتالوج — وهذا هو بالضبط السبب في أن الوثائق تصف الـ slots الخاصة بـ "الخوادم التي لم تعد موجودة" بأنها لا تزال تعيق عملية التنظيف.110 الإصلاح الدائم هو استخدام timeout، وهو موضح أدناه.

    هل يمكن لـ replication slot أن يمنع VACUUM من إزالة الصفوف الميتة؟

    نعم، ويمكن لهذا الإصدار من المشكلة أن يستمر إلى أجل غير مسمى، لأن الـ slot لا يحتاج إلى جلسة (session)، ولا عملية (process)، ولا اتصال شبكة للاستمرار في تثبيت الأفق (horizon). هو ببساطة يستقر في الكتالوج.

    الـ slot غير النشط يحتفظ بـ xmin الخاص به، ولا يمكن لـ VACUUM إزالة الـ tuples التي تم حذفها بعده.10 إرشادات الاستعادة في الوثائق صريحة بشأن السبب المعتاد: "في كثير من الحالات، تم إنشاء مثل هذه الـ slots من أجل النسخ المتماثل لخوادم لم تعد موجودة، أو كانت متوقفة لفترة طويلة."1 سواء كانت نسخة احتياطية خارج الخدمة، أو logical subscriber تم تفكيكه دون حذف الـ slot الخاص به، أو خط أنابيب change-data-capture تم إيقافه — كل ذلك يمكن أن يترك خلفه slot يستمر في تثبيت الأفق دون وجود أي شيء في النظام الحالي يشير إليه.

    قبل حذف أي شيء، قم بإجراء ثلاثة فحوصات. هل xmin أو catalog_xmin غير فارغ (non-null) فعلياً — أي هل هذا الـ slot هو من يثبت الأفق أصلاً؟ هل active قيمته false و active_pid قيمته null؟ وهل المستهلك قد اختفى فعلياً، وليس مجرد انقطع اتصاله؟ تربط الوثائق نتيجة حقيقية بالتخمين الخاطئ: "إذا قمت بحذف slot لخادم لا يزال موجوداً وقد يحاول الاتصال بهذا الـ slot، فقد يحتاج ذلك الـ replica إلى إعادة بناء."1 بالنسبة لـ logical slot، فإن الخسارة المعادلة هي موقع الـ subscriber، وهو ما يعني عادةً إعادة نسخ كاملة. حذف الـ slot عملية غير قابلة للتراجع، لذا تعامل معها كخطوة أخيرة وليس أولى. عندما تتأكد:

    SELECT pg_drop_replication_slot('slot_name_here');
    

    يمكن لـ PostgreSQL 18 إخراج الـ slot من المشهد دون أن تقوم بحذفه. ميزة idle_replication_slot_timeout، التي أضيفت في هذا الإصدار، تقوم بـ إبطال (invalidate) — وليس حذف — الـ slots التي ظلت غير نشطة لفترة أطول من المدة المحددة؛ والقيمة الافتراضية هي 0، مما يعني أنها معطلة. يحدث الإبطال في وقت الـ checkpoint وليس في اللحظة التي يتم فيها تجاوز الحد الزمني، لذا يوجد تأخير بين تجاوز الـ timeout والإبطال الفعلي للـ slot — قم بفرض checkpoint إذا كنت بحاجة لذلك في وقت أقرب. يتم قياس المدة من قيمة inactive_since الخاصة بالـ slot، ولا تنطبق هذه الآلية على الـ slots التي لا تحجز WAL أو على الـ standby slots المتزامنة من primary.2 بمجرد إبطال الـ slot، يقوم pg_replication_slots.invalidation_reason بتسجيل السبب، وتكون قيمة idle_timeout هي القيمة في هذه الحالة.10

    هناك فرق يستحق المعرفة إذا كنت تلاحق multixact بدلاً من عمر معرف المعاملة (transaction ID age): "على عكس التفاف معرف المعاملة (transaction ID wraparound)، فإن فتحات النسخ المتماثل (replication slots) لا تعيق عملية تنظيف multixact بشكل مباشر."1

    هل يتسبب hot_standby_feedback في تضخم (bloat) على الخادم الأساسي؟

    نعم يمكنه ذلك، وهذه المقايضة موثقة وليست عرضية. يخبر hot_standby_feedback الخادم الأساسي عن الاستعلامات التي تعمل على الخادم الاحتياطي (standby) حتى لا تقوم سجلات التنظيف بإلغائها. والثمن هو أن الخادم الأساسي يؤجل عملية التنظيف بدلاً من ذلك.

    يوضح وصف المعاملة هذه الصفقة في جملة واحدة: "يمكن استخدامه للقضاء على إلغاء الاستعلامات الناتجة عن سجلات التنظيف، ولكن يمكن أن يتسبب في تضخم قاعدة البيانات على الخادم الأساسي لبعض أعباء العمل." القيمة الافتراضية هي off.2 إذا قمت بتفعيله لمنع النسخ المتماثلة من إرسال إلغاءات التعارض لاستعلامات التحليل الخاصة بك، فقد نقلت المشكلة بدلاً من إزالتها، وستظهر المشكلة الجديدة في pg_stat_replication.backend_xmin على الخادم الأساسي.7

    ثلاث تفاصيل تشكل كيفية سلوكه في الممارسة العملية، جميعها من نفس الصفحة:2

    • يتم إرسال التغذية الراجعة (Feedback) بمعدل لا يزيد عن مرة واحدة لكل wal_receiver_status_interval، والتي تبلغ افتراضياً 10 ثوانٍ — لذا فإن رؤية الخادم الأساسي لأفق الخادم الاحتياطي تكون دائماً قديمة قليلاً.
    • في حالة النسخ المتماثل المتسلسل (cascading replication)، يتم تمرير التغذية الراجعة إلى الأعلى حتى تصل إلى الخادم الأساسي؛ ولا تستخدم الخوادم الاحتياطية الوسيطة "التغذية الراجعة التي تتلقاها في أي شيء آخر سوى تمريرها إلى الأعلى."
    • دمج ذلك مع recovery_min_apply_delay يضاعف التأثير: "سيتم تأخير hot_standby_feedback بسبب استخدام هذه الميزة مما قد يؤدي إلى تضخم على الخادم الأساسي؛ استخدم كليهما معاً بحذر."

    هناك أيضاً وضع فشل متعلق بانحراف الساعة (clock-skew) يسهل تشخيصه خطأً على أنه خطأ في vacuum: "إذا تم تقديم أو تأخير الساعة على الخادم الاحتياطي، فقد لا يتم إرسال رسالة التغذية الراجعة في الفاصل الزمني المطلوب. في الحالات القصوى، يمكن أن يؤدي ذلك إلى خطر طويل الأمد بعدم إزالة الصفوف الميتة على الخادم الأساسي لفترات ممتدة، لأن آلية التغذية الراجعة تعتمد على الطوابع الزمنية."2 إذا لم تكن الخوادم الاحتياطية لديك تعمل بتوقيت متزامن، فقم بإصلاح ذلك قبل ضبط autovacuum.

    البدائل هي ترك التغذية الراجعة مغلقة ورفع قيمة max_standby_streaming_delay و max_standby_archive_delay (كلاهما افتراضياً 30 ثانية، و -1 يسمح للخادم الاحتياطي بالانتظار للأبد للاستعلامات المتعارضة)، أو نقل استعلامات التحليل الطويلة بعيداً عن النسخة المتماثلة تماماً.2 في كلتا الحالتين، هذا قرار يتعلق بجدولة المهام، وليس إعداداً لـ vacuum.

    هل تعيق المعاملات المُعدة (prepared transactions) عملية VACUUM؟

    نعم — التوثيق يقول ذلك صراحةً — وهي تمثل "حامل الأفق" الذي سيظل موجوداً غداً، لأن أياً من مهلات الجلسة (session timeouts) لا تنطبق عليها.

    تحمل صفحة PREPARE TRANSACTION تحذيراً يذكر النتيجة بوضوح: "من غير الحكمة ترك المعاملات في الحالة المُعدة لفترة طويلة. سيؤدي ذلك إلى التداخل مع قدرة VACUUM على استعادة مساحة التخزين... ضع في اعتبارك أيضاً أن المعاملة تستمر في الاحتفاظ بأي أقفال كانت تمتلكها."13

    المعاملة المُجهزة (prepared transaction) هي معاملة التزام ثنائية المرحلة (two-phase-commit) تم تجهيزها ولكن لم يتم التزامها أو التراجع عنها بعد؛ في تلك النقطة "لم تعد المعاملة مرتبطة بالجلسة الحالية؛ بدلاً من ذلك، يتم تخزين حالتها بالكامل على القرص".13 يظهر pg_prepared_xacts صفاً واحداً لكل منها، و"يتم إزالة الصف عندما يتم التزام المعاملة أو التراجع عنها" — لا يوجد مخرج آخر.9 إذا تعطل منسق المعاملات الموزعة في منتصف البروتوكول، أو قام شخص ما بتجربة PREPARE TRANSACTION ولم ينهِ العملية أبداً، يظل معرف المعاملة (XID) مثبتاً إلى أجل غير مسمى.

    يتم توضيح عدم التماثل الحرج كملاحظة حول transaction_timeout: "المعاملات المُجهزة لا تخضع لهذا المهلة الزمنية."12 كما أن idle_in_transaction_session_timeout أو statement_timeout لا يصلان إليها أيضاً، لأنه لا توجد جلسة متبقية لإنهائها. وليس لدى pg_terminate_backend أي شيء لإنهائه. المخرج الموثق هو إنهاء المعاملة:

    -- Inspect first; the gid identifies the transaction to the coordinator.
    SELECT gid, database, owner, prepared, age(transaction) AS xid_age
    FROM pg_prepared_xacts
    ORDER BY prepared;
    
    -- Then resolve each one, in agreement with whatever coordinated it.
    ROLLBACK PREPARED 'the_gid_here';
    -- or
    COMMIT PREPARED 'the_gid_here';
    

    لا تقم بالتراجع عنها بشكل تلقائي. توجد المعاملة المُجهزة تحديداً لأن بعض المنسقين أُبلغوا بأنه سيتم الوفاء بها؛ وحلها من جانب واحد في الاتجاه الخاطئ هو الطريقة التي يفقد بها النظام الموزع اتساقه. إذا كنت لا تستخدم الالتزام ثنائي المرحلة على الإطلاق، فاتبع نصيحة التوثيق نفسه: "إذا لم تقم بإعداد مدير معاملات خارجي لتتبع المعاملات المُجهزة وضمان إغلاقها على الفور، فمن الأفضل إبقاء ميزة المعاملات المُجهزة معطلة عن طريق تعيين max_prepared_transactions إلى صفر. سيمنع هذا الإنشاء العرضي للمعاملات المُجهزة التي قد تُنسى لاحقاً وتسبب مشاكل في النهاية."13

    لماذا لا يعمل autovacuum على جدولي على الإطلاق؟

    لأن الجدول لم يتجاوز حد التشغيل (trigger threshold)، أو لأن autovacuum لا يمكنه رؤيته. الحد هو معادلة، وليس رقماً ثابتاً، وفي الجداول الكبيرة يمكن أن يكون مرتفعاً بشكل مذهل.

    يوضح PostgreSQL 18 الحساب كالتالي:1

    vacuum threshold = Minimum(vacuum max threshold,
                               vacuum base threshold + vacuum scale factor * number of tuples)
    

    مع كون القيم الافتراضية هي autovacuum_vacuum_threshold = 50 tuple، و autovacuum_vacuum_scale_factor = 0.2 (20% من حجم الجدول)، و autovacuum_vacuum_max_threshold = 100,000,000 tuple. تعيين الحد الأقصى إلى -1 يزيل هذا السقف. مصطلح number of tuples هو pg_class.reltuples.14

    لنقم بحسابها. جدول يحتوي على 50 مليون صف يحتاج إلى 50 + 0.2 × 50,000,000 = 10,000,050 dead tuples قبل أن ينظر فيه autovacuum. أما جدول يحتوي على مليار صف، فبدون سقف، سيحتاج إلى 50 + 0.2 × 1,000,000,000 = 200,000,050. تمت إضافة المعامل autovacuum_vacuum_max_threshold في PostgreSQL 18 ويضع سقفاً لذلك عند 100,000,000 — مما يقلل نقطة التشغيل في ذلك الجدول إلى النصف تقريباً، ويثبتها لأي شيء أكبر.14 في الإصدارات من PostgreSQL 14 إلى 17 لا يوجد سقف، وهذا هو السبب في أن معلمات التخزين لكل جدول في الجداول الكبيرة والنشطة كانت نصيحة قياسية لسنوات:

    ALTER TABLE events SET (
      autovacuum_vacuum_scale_factor = 0.01,
      autovacuum_vacuum_threshold    = 1000
    );
    

    تكون لها الأولوية لمعاملات التخزين الخاصة بكل جدول: "إذا تم تغيير إعداد عبر معاملات التخزين الخاصة بجدول ما، يتم استخدام هذه القيمة عند معالجة ذلك الجدول؛ وإلا يتم استخدام الإعدادات العامة."1 وهذا ينطبق في الاتجاهين — تأكد من أن أحداً لم يقم بضبط autovacuum_enabled = false على الجدول منذ سنوات:

    SELECT relnamespace::regnamespace AS schema,
           relname,
           relkind,
           reloptions
    FROM pg_class
    WHERE reloptions::text ~ 'autovacuum'
      AND relkind IN ('r', 'm', 'p', 't');
    

    هذا الفلتر يبعد fillfactor والخيارات الأخرى غير ذات الصلة، وتضمين relkind = 't' يظهر علاقات TOAST، والتي يتم ضبط خيارات autovacuum الخاصة بها بشكل منفصل عن الجدول الأب.

    تحقق أيضاً من الـ daemon نفسه. يكون autovacuum مفعلاً بشكل افتراضي، ولكنه يتطلب بالإضافة إلى ذلك تفعيل track_counts، لأن منطق التشغيل يقرأ نظام الإحصائيات التراكمية.14 هناك استثناء واحد لكل ما سبق: "حتى عندما يكون هذا المعامل معطلاً، سيقوم النظام بتشغيل عمليات autovacuum إذا لزم الأمر لمنع transaction ID wraparound."14

    ما هي الجداول التي لا يلمسها autovacuum أبداً؟

    فئتان، كلتاهما موثقة، ومن السهل التغاضي عنهما لأن لا شيء في الجداول نفسها يبدو غير عادي.

    الجداول المؤقتة (Temporary tables). "لا يمكن لـ autovacuum الوصول إلى الجداول المؤقتة. لذلك، يجب تنفيذ عمليات vacuum و analyze المناسبة عبر أوامر SQL الخاصة بالجلسة."1 الجلسة طويلة الأمد التي تنشئ جدولاً مؤقتاً وتجري عليه عمليات مكثفة — مثل وظيفة دفعية (batch job)، أو عملية عامل (worker process) تعيد استخدام اتصالها لساعات — تراكم tuples ميتة لا يمكن إزالتها إلا من خلال VACUUM صريح في نفس تلك الجلسة.

    الجداول الأب المقسمة (Partitioned parent tables). "الجداول المقسمة لا تخزن tuples بشكل مباشر وبالتالي لا يتم معالجتها بواسطة autovacuum. (يقوم autovacuum بمعالجة أقسام الجداول تماماً مثل الجداول الأخرى.)"1 يتم عمل vacuum للأقسام نفسها بشكل طبيعي، لذا فهذه ليست مشكلة تضخم (bloat) — بل هي مشكلة إحصائيات، لأن autoanalyze لا يعمل على الجدول الأب أيضاً، وتوصي الوثائق بتشغيل ANALYZE يدوياً على الجداول المقسمة عند ملئها لأول مرة وكلما تغير توزيع البيانات.1 وينطبق الشيء نفسه على آباء الوراثة (inheritance parents): "الـ tuples التي تتغير في الأقسام وأبناء الوراثة لا تحفز عملية analyze على الجدول الأب."1

    الجداول الخارجية (Foreign tables) هي حالة ثالثة خاصة بـ ANALYZE تحديداً — حيث لا يقوم الـ daemon بإصدار ANALYZE لها "بما أنه لا يملك وسيلة لتحديد مدى تكرار فائدة ذلك."1

    لماذا يبدأ autovacuum ولكن لا ينتهي أبداً؟

    لأن هناك شيئاً ما يستمر في إلغائه، أو لأن تمريرة واحدة تستغرق فعلياً وقتاً أطول من الفاصل الزمني بين الأحداث التي تحفز تشغيله، أو لأنه ينتهي بينما يتخطى عمداً عمل الفهرس (index work). تظهر الحالتان الأوليان في شكل autovacuum_count يكاد لا يتحرك بينما ينمو n_dead_tup؛ أما الحالة الثالثة فتظهر autovacuum_count صحي ومؤشرات أسطر ميتة (dead line pointers) لا تختفي أبداً.

    إلغاء القفل. يحتفظ Autovacuum بقفل من نوع SHARE UPDATE EXCLUSIVE. "إذا حاولت عملية الحصول على قفل يتعارض مع قفل SHARE UPDATE EXCLUSIVE الذي يحتفظ به autovacuum، فإن الحصول على القفل سيؤدي إلى مقاطعة الـ autovacuum."1 هذا عادةً ما يكون أمرًا جيدًا — فهو يمنع عمليات الصيانة من تعطيل الـ DDL. ولكنه يصبح مشكلة مرضية عندما يتم تشغيل الأمر المتعارض وفق جدول زمني، وهو ما تشير إليه الوثائق كتحذير صريح: "إن تشغيل الأوامر التي تطلب أقفالاً تتعارض مع قفل SHARE UPDATE EXCLUSIVE بانتظام (مثل ANALYZE) يمكن أن يمنع عمليات autovacuums من الاكتمال فعليًا."1 فمثلاً، مهمة cron تقوم بتشغيل ANALYZE على جدول كبير كل خمس عشرة دقيقة، مقابل عملية vacuum تحتاج إلى عشرين دقيقة، ستؤدي إلى تجويع ذلك الجدول إلى أجل غير مسمى.

    هناك حالة واحدة من autovacuum لا تتنازل: "إذا كان autovacuum يعمل لمنع التفاف معرف المعاملة (transaction ID wraparound) (أي أن اسم استعلام autovacuum في عرض pg_stat_activity ينتهي بـ (to prevent wraparound))، فإن الـ autovacuum لا يتم مقاطعته تلقائيًا."1 رؤية هذه اللاحقة تعني أن الموقف قد تفاقم بالفعل.

    مرات مرور متكررة على الفهرس. في pg_stat_progress_vacuum، تعتبر قيمة index_vacuum_count التي تزيد عن 1 هي العلامة الموثقة على مرور لم يستطع الاحتفاظ بجميع معرفات العناصر الميتة في الذاكرة واضطر إلى العودة للدورة من جديد. مرحلة vacuuming indexes "قد تحدث عدة مرات لكل vacuum إذا كانت maintenance_work_mem (أو في حالة autovacuum، تكون autovacuum_work_mem إذا تم تعيينها) غير كافية لتخزين عدد الـ tuples الميتة التي تم العثور عليها."8 كل دورة تعيد فحص كل فهرس في الجدول. تشير الوثائق إلى إعدادات الذاكرة كعائق، لذا فإن رفع autovacuum_work_mem هو أول شيء يجب تجربته — ولكن تحقق من الحدود الخاصة بإصدارك حول مقدار ما يمكن لـ vacuum استخدامه فعليًا قبل تعيين قيمة كبيرة جدًا.

    إيقاف تنظيف الفهرس. يمكن تعيين خيار INDEX_CLEANUP في VACUUM على OFF لـ "إجبار VACUUM على تخطي تنظيف الفهرس دائمًا، حتى عندما يكون هناك العديد من الـ tuples الميتة في الجدول"، وهذا الخيار متاح أيضًا كمعامل تخزين لكل جدول. والنتيجة موضحة كالتالي: "إذا لم يتم إجراء تنظيف الفهرس بانتظام، فقد يتأثر الأداء، لأنه مع تعديل الجدول ستتراكم الـ tuples الميتة في الفهارس وسيتراكم في الجدول نفسه مؤشرات أسطر ميتة لا يمكن إزالتها حتى يكتمل تنظيف الفهرس."4 الجدول الذي يُترك في هذه الحالة يراكم شيئًا يشبه تمامًا المشكلة التي يتناولها هذا الدليل، ولن يفسر أي استعلام horizon ذلك — لذا تحقق من reloptions الخاصة بالجدول بجانب autovacuum_enabled.

    يقوم نظام الأمان ضد الالتفاف (wraparound failsafe) بنفس الشيء عن قصد. خيار INDEX_CLEANUP "ليس له تأثير على آلية الأمان ضد التفاف معرف المعاملة. عند تفعيلها، سيتم تخطي تنظيف الفهرس، حتى عندما يكون INDEX_CLEANUP مضبوطًا على ON."4 لذا فإن العنقود (cluster) الذي يعمل بالقرب من vacuum_failsafe_age — 1.6 مليار معاملة افتراضيًا14 — قد يكون بصدد إكمال عمليات vacuums لا تقوم عمدًا بأي عمل على الفهارس على الإطلاق.

    تحديد السرعة (Throttling). ينام Autovacuum بموجب تأخير قائم على التكلفة. القيمة الافتراضية لـ autovacuum_vacuum_cost_delay هي 2 ميلي ثانية، والقيمة الافتراضية لـ autovacuum_vacuum_cost_limit هي -1، والتي تعود إلى قيمة vacuum_cost_limit (200).14 هذا الحد مشترك: "يتم توزيع القيمة بشكل تناسبي بين عمال autovacuum الذين يعملون، إذا كان هناك أكثر من واحد، بحيث لا يتجاوز مجموع الحدود لكل عامل قيمة هذا المتغير."14 لذلك، فإن إضافة عمال لا تزيد من الإنتاجية إلا إذا قمت برفع حد التكلفة أيضاً. في PostgreSQL 18، يقوم pg_stat_progress_vacuum.delay_time بالإبلاغ عن الميلي ثانية التي تم قضاؤها في النوم، عندما يكون track_cost_delay_timing مفعلاً.8

    مجاعة العمال (Worker starvation). القيمة الافتراضية لـ autovacuum_max_workers هي 3.14 "إذا أصبحت عدة جداول كبيرة مؤهلة لعملية vacuum في فترة زمنية قصيرة، فقد ينشغل جميع عمال autovacuum بتنظيف تلك الجداول لفترة طويلة. سيؤدي هذا إلى عدم تنظيف الجداول وقواعد البيانات الأخرى حتى يصبح أحد العمال متاحاً."1 يمكن لجدول صغير ونشط أن يعاني من "المجاعة" بسبب ثلاثة جداول كبيرة وخاملة.

    لماذا لا يزال حجم جدولي كما هو بعد عملية VACUUM؟

    لأن هذا هو الهدف الذي صُمم من أجله VACUUM العادي. فهو يجعل المساحة قابلة لإعادة الاستخدام داخل الجدول؛ ولكنه لا يعيدها إلى نظام التشغيل.

    الصياغة لا تترك مجالاً للتأويل: "النموذج القياسي لـ VACUUM يزيل إصدارات الصفوف الميتة في الجداول والفهارس ويحدد المساحة المتاحة لإعادة الاستخدام في المستقبل. ومع ذلك، لن يعيد المساحة إلى نظام التشغيل، إلا في الحالة الخاصة التي تصبح فيها صفحة واحدة أو أكثر في نهاية الجدول فارغة تماماً ويمكن الحصول على قفل حصري للجدول بسهولة."1 يتم التحكم في هذا الاستثناء بواسطة vacuum_truncate، وهو متغير منطقي قيمته الافتراضية true؛ وعندما ينطبق، "يتم إعادة مساحة القرص للصفحات المقتطعة إلى نظام التشغيل"، ويتطلب الاقتطاع قفلاً من نوع ACCESS EXCLUSIVE.14

    هذا أمر متعمد، وتوضح الوثائق السبب: "الفكرة ليست الحفاظ على الجداول في الحد الأدنى من حجمها، بل الحفاظ على استخدام مستقر لمساحة القرص: يشغل كل جدول مساحة تعادل الحد الأدنى من حجمه بالإضافة إلى مقدار المساحة التي يتم استهلاكها بين عمليات vacuum."1 الجدول الذي يستقر حجمه أعلى قليلاً من حده الأدنى النظري ثم يتوقف عن النمو هو جدول سليم، وليس جدولاً منتفخاً (bloated).

    لذا، إذا انخفضت قيمة n_dead_tup إلى ما يقرب من الصفر ولم يتغير حجم الملف، فإن vacuum قد عمل بنجاح. المساحة موجودة داخل الجدول، في انتظار صفوف جديدة. ستحتاج فقط إلى استعادتها إذا كان الجدول لن ينمو لملئها مرة أخرى — بعد عملية حذف جماعية لمرة واحدة، على سبيل المثال.

    هناك حد واحد لهذا القسم: هو يتعلق بـ heap. إذا كنت تراقب pg_total_relation_size، فإن هذا الرقم يشمل أيضاً الفهارس (indexes) وأي علاقة TOAST. يقوم VACUUM بإزالة إصدارات الصفوف الميتة "في الجداول والفهارس"،1 ولكن الفهرس الذي تعرض لتغييرات مكثفة يمكن أن يظل كبيراً مادياً حتى بعد اختفاء مدخلاته، وأوامر إعادة الكتابة في القسم التالي هي التي "تبني فهارس جديدة" من الصفر.1 إذا تقلص الـ heap ولم يتقلص الإجمالي، فانظر إلى الفهارس بدلاً من النظر إلى vacuum.

    هل يجب أن أقوم بتشغيل VACUUM FULL لاستعادة المساحة؟

    فقط عندما يكون الجدول حقاً لن يعيد استخدام المساحة، وفقط مع وجود نافذة صيانة. يقوم VACUUM FULL "بضغط الجداول بنشاط عن طريق كتابة نسخة جديدة كاملة من ملف الجدول بدون مساحات ميتة"، مما يقلل الحجم "ولكن قد يستغرق وقتاً طويلاً".1

    ثلاث تكاليف، جميعها موثقة:1

    1. إنه "يتطلب قفل ACCESS EXCLUSIVE على الجدول الذي يعمل عليه، وبالتالي لا يمكن القيام به بالتوازي مع استخدامات أخرى للجدول". عمليات القراءة تُحظر أيضاً، وليس فقط عمليات الكتابة.
    2. "يتطلب أيضاً مساحة قرص إضافية للنسخة الجديدة من الجدول، حتى تكتمل العملية". وبما أن الفهارس يتم إعادة بنائها أيضاً، فاحسب الميزانية بناءً على pg_total_relation_size، وليس الـ heap وحده.
    3. لن يقوم Autovacuum أبداً بهذا الأمر نيابة عنك — فالمحرك (daemon) "في الواقع لن يصدر أبداً أمر VACUUM FULL."

    التوجيه العام هو تجنبه: "يجب على المسؤولين السعي لاستخدام VACUUM القياسي وتجنب VACUUM FULL"، و"الهدف المعتاد من عملية vacuum الروتينية هو القيام بعمليات VACUUM قياسية بشكل متكرر بما يكفي لتجنب الحاجة إلى VACUUM FULL."1

    النهجالقفل (Lock)مساحة قرص إضافيةملاحظات
    VACUUMSHARE UPDATE EXCLUSIVEلا يوجديتم إعادة استخدام المساحة في مكانها، ولا يتم إرجاعها إلى نظام التشغيل
    VACUUM FULLACCESS EXCLUSIVE≈ حجم الجدوليعيد كتابة الـ heap والفهارس؛ النتيجة هي أصغر حجم ممكن
    CLUSTERACCESS EXCLUSIVE≈ حجم الجدولنفس عملية إعادة الكتابة، ولكن مرتبة حسب فهرس معين
    إعادة كتابة الجدول باستخدام ALTER TABLEACCESS EXCLUSIVE≈ حجم الجدولنفس فئة العمليات
    TRUNCATEACCESS EXCLUSIVEلا يوجدفقط عند التخلص من جميع الصفوف؛ لا حاجة لعمل vacuum بعدها
    pg_repack (إضافة خارجية)راجع وثائق المشروعراجع وثائق المشروعليست جزءاً من نواة PostgreSQL ولا تغطيها الوثائق المذكورة هنا؛ تحقق من متطلباتها الخاصة قبل الاستخدام في بيئة الإنتاج

    يتم توثيق VACUUM FULL و CLUSTER ومتغيرات ALTER TABLE التي تعيد كتابة الجدول معاً كعمليات متكافئة في هذا الصدد: "تقوم هذه الأوامر بإعادة كتابة نسخة جديدة كاملة من الجدول وبناء فهارس جديدة له. تتطلب كل هذه الخيارات قفل ACCESS EXCLUSIVE."1 إذا كنت تقوم بإفراغ جدول بالكامل بشكل روتيني، فإن TRUNCATE هي الأداة الأفضل: فهي "تزيل محتوى الجدول بالكامل فوراً، دون الحاجة إلى VACUUM أو VACUUM FULL لاحقاً لاستعادة مساحة القرص غير المستخدمة الآن،" وذلك على حساب انتهاك دلالات MVCC الصارمة.1

    تحذيران تشغيليان. أولاً، مساحة القرص الإضافية التي تحتاجها عمليات إعادة الكتابة هذه تغطي الفهارس الجديدة وكذلك الـ heap الجديد، لذا في الجدول الذي تضاهي فهارسه حجم الـ heap، قم بحساب الميزانية بناءً على pg_total_relation_size بدلاً من حجم الـ heap وحده. ثانياً، يجب الحصول على قفل ACCESS EXCLUSIVE قبل أن يتم تثبيته: إذا كانت هناك معاملة (transaction) طويلة تعمل بالفعل على الجدول، فستنتظر عملية إعادة الكتابة في الطابور، وكل استعلام يصل بعدها سينتظر خلفها. قم بتعيين lock_timeout في نفس الجلسة قبل البدء — حيث يقوم بإلغاء أي أمر "ينتظر لفترة أطول من الوقت المحدد أثناء محاولة الحصول على قفل،" وهو معطل افتراضياً.12

    SET lock_timeout = '5s';
    VACUUM FULL events;
    

    شيء واحد يجب حسمه قبل كل هذا: إذا كان الـ horizon لا يزال محجوزاً، فأنت على وشك دفع الثمن الكامل لإعادة الكتابة بينما لا يزال الشيء الذي تسبب في التضخم (bloat) يعمل. سيبدأ الجدول في النمو مرة أخرى في اللحظة التي تنتهي فيها. قم بإزالة حاجز الـ horizon أولاً، ثم قم بتشغيل VACUUM عادي، وتأكد من أن n_dead_tup ينخفض بالفعل، وعندها فقط قرر ما إذا كنت لا تزال بحاجة إلى استعادة المساحة.

    ماذا يعني "tuples missed … cleanup lock contention"؟

    هذا يعني أن vacuum وصل إلى صفحات لم يتمكن من الحصول على قفل تنظيف (cleanup lock) لها — لأن backend آخر كان يثبتها — وتجاوز الـ tuples الميتة هناك تماماً. يظهر هذا السطر فقط عندما يكون العدد أكبر من صفر:3

    tuples missed: 812 dead from 47 pages not removed due to cleanup lock contention
    

    هذا عداد منفصل عن "dead but not yet removable" ويعني شيئاً مختلفاً. "Not yet removable" هو قرار متعلق بالرؤية (visibility): الصفوف لا تزال مطلوبة. أما "Missed … cleanup lock contention" فهو حادث تزامني (concurrency accident): كان مسموحاً لـ vacuum إزالتها ولكنه لم يتمكن من الوصول إلى الصفحة. يمكن أن يظهر السطران في نفس الملخص ويجب تشخيصهما بشكل مستقل.

    العدادات الصغيرة هنا غير ملحوظة في جدول مزدحم، وللمرة القادمة فرصة أخرى في تلك الصفحات. أما العدادات الكبيرة المستمرة فتشير إلى صفحات تحت تثبيت (pin) شبه دائم — صف واحد ساخن جداً، أو نمط استعلام يبقي المؤشر (cursor) مفتوحاً على صف ما. الحل هنا يكمن في نمط الاستعلام بدلاً من معايير vacuum: القيد هو تثبيت صفحة بواسطة backend آخر، وليس أي شيء تم تكوين vacuum للقيام به.

    كيف أمنع تراكم الـ dead tuples مرة أخرى؟

    باستخدام مهلات الزمن (timeouts)، ومع معايير تخزين autovacuum لكل جدول من قسم العتبة (threshold) أعلاه، ومع مراقبة تنبه بناءً على عمر الأفق (horizon age) بدلاً من عدد الـ dead-tuple. كل مهلة زمنية أدناه تكون معطلة افتراضياً، لذا لا شيء من هذا يعمل إلا إذا قمت بتفعيله.

    الإعدادالافتراضيما الذي يكتشفهما الذي يغفله
    idle_in_transaction_session_timeout0 (مغلق)الجلسات الخاملة داخل معاملة مفتوحةالجلسات التي تشغل استعلاماً طويلاً بنشاط
    transaction_timeout (PostgreSQL 17+)0 (مغلق)أي معاملة، صريحة أو ضمنية، تستغرق وقتاً طويلاً جداًالمعاملات المُعدة (Prepared transactions)
    statement_timeout0 (مغلق)البيانات الفردية الطويلةمعاملة طويلة مكونة من بيانات قصيرة
    idle_session_timeout0 (مغلق)الجلسات الخاملة خارج المعاملةأي شيء يحتفظ بلقطة (snapshot)
    lock_timeout0 (مغلق)البيانات العالقة في انتظار قفل (lock)أي شيء يحتفظ بقفل بالفعل
    idle_replication_slot_timeout (PostgreSQL 18+)0 (مغلق)الفتحات (slots) غير النشطة التي تتجاوز المدة المحددةالفتحات النشطة على مستهلك متأخر

    لا يوجد إعداد واحد هنا يمنع المشكلة بأكملها، والعمود الأيمن في الجدول هو السبب. المعاملات المُعدة (Prepared transactions) تفلت من جميع مهلات الجلسة. والفتحات (Slots) ليست جلسات. والاستعلام الطويل الذي يعمل بنشاط لا يمكن الوصول إليه إلا عبر statement_timeout أو transaction_timeout، وكلاهما يجب ضبطهما بقيمة منخفضة بما يكفي للتأثير — وهو أيضاً منخفض بما يكفي لقتل التقارير المشروعة التي كنت تنوي الإبقاء عليها.

    transaction_timeout، الذي تمت إضافته في PostgreSQL 17، هو الأشمل من بين الثلاثة على مستوى الجلسة: فهو ينهي "أي جلسة تستغرق وقتاً أطول من الوقت المحدد في معاملة"، و"ينطبق الحد على كل من المعاملات الصريحة (التي تبدأ بـ BEGIN) والمعاملة التي تبدأ ضمنياً والمقابلة لبيان واحد".12 لاحظ التفاعل، والذي من السهل الخلط فيه: "إذا كان transaction_timeout أقصر من أو يساوي idle_in_transaction_session_timeout أو statement_timeout، فسيتم تجاهل المهلة الأطول".12

    بالنسبة لـ statement_timeout و transaction_timeout و lock_timeout، تقول الوثائق بوضوح أن ضبطها في postgresql.conf "غير موصى به لأنه سيؤثر على جميع الجلسات".12 قم بتطبيقها لكل دور (role) أو لكل تطبيق بدلاً من ذلك. (idle_replication_slot_timeout هو الاستثناء في الاتجاه الآخر: حيث "يمكن ضبطه فقط في ملف postgresql.conf أو في سطر أوامر الخادم".2)

    ALTER ROLE web_app SET idle_in_transaction_session_timeout = '60s';
    ALTER ROLE reporting SET statement_timeout = '30min';
    
    -- PostgreSQL 17 and later only:
    ALTER ROLE web_app SET transaction_timeout = '5min';
    

    يتطلب idle_session_timeout عناية إضافية في بيئة الـ pooled: "كن حذراً عند فرض هذا المهلة الزمنية (timeout) على الاتصالات التي تتم عبر برامج connection-pooling أو أي middleware أخرى، حيث أن مثل هذه الطبقة قد لا React بشكل جيد مع الإغلاق غير المتوقع للاتصال."12 إذا كنت تستخدم pooler أمام Postgres، فراجع دليلنا حول production Postgres connection pooling with PgBouncer and Supavisor قبل ضبطه.

    بالنسبة للمراقبة، قم بضبط التنبيهات على الأرقام التي تسبق المشكلة بدلاً من تلك التي تتبعها: الحد الأقصى لـ age(backend_xid) و age(backend_xmin) في pg_stat_activity، و age(xmin) و age(catalog_xmin) في pg_replication_slots، و age(backend_xmin) في pg_stat_replication، و age(transaction) في pg_prepared_xacts — وهذا الأخير تحديداً، لأنه العنصر الذي لن تقوم أي مهلة زمنية بتنظيفه أبداً — و age(relfrozenxid) لكل جدول. تخبرك أعداد الـ Dead-tuple بأن المشكلة قد حدثت بالفعل. اضبط log_autovacuum_min_duration على قيمة منخفضة بما يكفي لالتقاط عمليات المسح التي تهمك — فهذا ما يجعل autovacuum يكتب نفس كتلة الملخص التي يطبعها VACUUM (VERBOSE)، بحيث يظهر سطر القطع في سجل الخادم الخاص بك في كل عملية مسح مؤهلة.1 تحقق من القيمة التي تأتي مع إصدارك قبل افتراض أنها معطلة.

    ماذا يحدث إذا تجاهلت الأمر؟

    تتوقف عملية التنظيف عن كونها مسألة مساحة قرص وتصبح مسألة توفر (availability). عندما لا يمكن إزالة الصفوف أو تجميدها، يتوقف relfrozenxid عن التقدم، ويقوم PostgreSQL بالتصعيد وفقاً لجدول زمني موثق.

    المتطلب مطلق: "من الضروري إجراء vacuum لكل جدول في كل قاعدة بيانات مرة واحدة على الأقل كل ملياري عملية (transactions)."1 تتبع مدى قربك من ذلك باستخدام الاستعلام الذي توفره الوثائق نفسها:1

    SELECT c.oid::regclass as table_name,
           greatest(age(c.relfrozenxid),age(t.relfrozenxid)) as age
    FROM pg_class c
    LEFT JOIN pg_class t ON c.reltoastrelid = t.oid
    WHERE c.relkind IN ('r', 'm');
    
    SELECT datname, age(datfrozenxid) FROM pg_database;
    

    يحدث التصعيد الأول عندما "تصل أقدم XIDs في قاعدة البيانات إلى أربعين مليون عملية من نقطة الالتفاف (wraparound point)"، وعند هذه النقطة يبدأ الخادم في تسجيل السجلات:1

    WARNING:  database "mydb" must be vacuumed within 39985967 transactions
    HINT:  To avoid XID assignment failures, execute a database-wide VACUUM in that database.
    

    إذا تجاهلت ذلك، فإن "النظام سيرفض تعيين XIDs جديدة بمجرد بقاء أقل من ثلاثة ملايين عملية حتى حد الالتفاف":1

    ERROR:  database is not accepting commands that assign new transaction IDs to avoid wraparound data loss in database "mydb"
    HINT:  Execute a database-wide VACUUM in that database.
    

    في هذه الحالة، تستمر العمليات التي بدأت بالفعل، ويمكن بدء عمليات القراءة فقط (read-only)، ولكن أي شيء يقوم بتعديل السجلات أو تقليص العلاقات (truncate relations) سيفشل. لا يزال VACUUM يعمل بشكل طبيعي. ترتيب الاسترداد الموثق هو نفس عملية مسح الأفق التي وصفها هذا الدليل، ولكن يتم تطبيقها تحت الضغط: حل العمليات المُعدة (prepared transactions)، إنهاء العمليات طويلة الأمد، حذف فتحات النسخ المتماثل (replication slots) القديمة، ثم تشغيل VACUUM على مستوى قاعدة البيانات بالكامل.1

    هناك تعليمات في هذا الإجراء من السهل الخطأ فيها. "لا تستخدم VACUUM FULL في هذا السيناريو، لأنه يتطلب XID وبالتالي سيفشل، إلا في وضع المستخدم الخارق (super-user mode)، حيث سيقوم بدلاً من ذلك باستهلاك XID وبالتالي يزيد من خطر حدوث transaction ID wraparound. لا تستخدم VACUUM FREEZE أيضاً، لأنه سيقوم بعمل أكثر من الحد الأدنى المطلوب لاستعادة التشغيل الطبيعي."1 وعلى عكس النصائح القديمة التي لا تزال متداولة، فإن إيقاف الـ postmaster لم يعد جزءاً من الحل: "في السيناريوهات النموذجية، لم يعد هذا ضرورياً، ويجب تجنبه كلما أمكن، لأنه يتضمن إيقاف النظام."1

    الخلاصة

    غالباً ما لا تكون مشكلة عدم إزالة الـ dead tuples في Postgres مشكلة في إعدادات vacuum على الإطلاق. إنها مشكلة رؤية (visibility) تتنكر في زي vacuum، والأفق (horizon) هو أول شيء يستحق استبعاده لأنه أسهل شيء يمكن التحقق منه.

    قم بذلك بهذا الترتيب. تأكد من pg_stat_user_tables ما إذا كان vacuum يعمل على هذا الجدول أصلاً — إذا كان last_autovacuum فارغاً (null) أو قديماً، فابدأ بأقسام العتبة (threshold) والإلغاء، لأن ممسك الأفق ليس هو ما يمنع vacuum لم يتم تشغيله أبداً. إذا كان يعمل، فقم بتشغيل VACUUM (VERBOSE)، واقرأ سطر removable cutoff، وقارن عمره مقابل استعلامات الأفق الأربعة بصفتك superuser أو عضواً في pg_read_all_stats. في كثير من الحالات، سيكون أحدها قديماً بشكل واضح ومحرج — نافذة psql، أو slot لنسخة احتياطية (replica) تم إخراجها من الخدمة في الربع الماضي، أو منسق (coordinator) تعطل في منتصف عملية two-phase-commit.

    قم بإصلاح الممسك، وشغل VACUUM عادياً، وتأكد من انخفاض n_dead_tup. عندها فقط قرر ما إذا كنت بحاجة أيضاً إلى استعادة مساحة القرص، وتذكر أن VACUUM العادي لم يكن ليعطيك إياها أبداً. أخيراً، قم بتعيين idle_in_transaction_session_timeout لكل role — بالإضافة إلى transaction_timeout إذا كنت تستخدم PostgreSQL 17 أو إصداراً أحدث — حتى لا تتمكن نفس الجلسة من تكرار ذلك، وقم بتفعيل التنبيهات بناءً على عمر الأفق بدلاً من عدد الـ dead-tuples.

    لقراءة ذات صلة: جداول الطوابير ذات التغيير العالي (high-churn queue tables) هي المولد الكلاسيكي للتضخم (bloat)، ويوضح pg-boss on Postgres كيف يبدو ضغط العمل هذا في الممارسة العملية. إذا كانت عمليات الحذف الجماعية هي التي دفعتك إلى هنا، فإن أتمتة إدارة الأقسام باستخدام pg_partman و pg_cron تستبدلها بعمليات إسقاط للأقسام (partition drops) لا تحتاج إلى vacuum على الإطلاق. وإذا كان الـ slot الذي يمسك أفقك ينتمي إلى إعداد replication منطقي، فإن شرحنا لـ الترقيات بدون توقف (zero-downtime upgrades) باستخدام pg_createsubscriber يغطي كيفية إنشاء هذه الـ slots وتنظيفها.

    Footnotes

    1. وثائق PostgreSQL 18، "24.1. Routine Vacuuming." https://www.postgresql.org/docs/18/routine-vacuuming.html 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50

  • وثائق PostgreSQL 18، "19.6. Replication" (hot_standby_feedback, idle_replication_slot_timeout, إعدادات standby delay). https://www.postgresql.org/docs/18/runtime-config-replication.html 2 3 4 5 6 7 8 9

  • كود PostgreSQL المصدري، src/backend/access/heap/vacuumlazy.c, REL_18_STABLE — الرسائل الصادرة عن VACUUM (VERBOSE) وسجلات autovacuum. https://GitHub.com/postgres/postgres/blob/REL_18_STABLE/src/backend/access/heap/vacuumlazy.c 2 3 4 5 6

  • وثائق PostgreSQL 18، "VACUUM" (مستوى مخرجات VERBOSE, INDEX_CLEANUP, PROCESS_TOAST, FULL). https://www.postgresql.org/docs/18/sql-vacuum.html 2 3 4 5

  • سورس PostgreSQL، src/backend/catalog/system_views.sql، REL_18_STABLE — تعريف pg_stat_all_tables. https://GitHub.com/postgres/postgres/blob/REL_18_STABLE/src/backend/catalog/system_views.sql 2

  • سورس PostgreSQL، src/backend/catalog/system_views.sql، REL_17_STABLE — تعريف PostgreSQL 17 لـ pg_stat_all_tables، والذي لا يحتوي على عمود total_vacuum_time أو total_autovacuum_time. https://GitHub.com/postgres/postgres/blob/REL_17_STABLE/src/backend/catalog/system_views.sql

  • توثيق PostgreSQL 18، "27.2. The Cumulative Statistics System" (pg_stat_activity.backend_xmin، pg_stat_replication.backend_xmin). https://www.postgresql.org/docs/18/monitoring-stats.html 2 3 4 5 6 7

  • توثيق PostgreSQL 18، "27.4. Progress Reporting" (pg_stat_progress_vacuum، pg_stat_progress_cluster). https://www.postgresql.org/docs/18/progress-reporting.html 2 3 4

  • توثيق PostgreSQL 18، "53.17. pg_prepared_xacts." https://www.postgresql.org/docs/18/view-pg-prepared-xacts.html 2 3

  • وثائق PostgreSQL 18، "53.20. pg_replication_slots." https://www.postgresql.org/docs/18/view-pg-replication-slots.html 2 3 4 5 6 7

  • وثائق PostgreSQL 16، "54.19. pg_replication_slots" — قائمة أعمدة PostgreSQL 16، والتي لا تحتوي على inactive_since ولا invalidation_reason. https://www.postgresql.org/docs/16/view-pg-replication-slots.html 2

  • وثائق PostgreSQL 18، "19.11. Client Connection Defaults" (idle_in_transaction_session_timeout, transaction_timeout, statement_timeout, idle_session_timeout). https://www.postgresql.org/docs/18/runtime-config-client.html 2 3 4 5 6 7 8 9 10 11

  • توثيق PostgreSQL 18، "PREPARE TRANSACTION" — بما في ذلك التحذير بشأن ترك المعاملات في حالة الاستعداد، والنصيحة بتعطيل هذه الميزة عبر max_prepared_transactions عندما لا يكون هناك مدير معاملات خارجي مستخدم. https://www.postgresql.org/docs/18/sql-prepare-transaction.html 2 3

  • توثيق PostgreSQL 18، "19.10. Vacuuming" (بارامترات تكوين autovacuum و vacuum). https://www.postgresql.org/docs/18/runtime-config-vacuum.html 2 3 4 5 6 7 8 9 10 11

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

    لأن VACUUM يزيل فقط إصدارات الصفوف الأقدم من أفق xmin ، وهناك شيء ما يعيق هذا الأفق: معاملة مفتوحة (open transaction)، أو معاملة مُعدة (prepared transaction)، أو replication slot، أو standby feedback. قم بتشغيل VACUUM (VERBOSE) ، واقرأ سطر removable cutoff ، وقارن عمره باستعلامات الأفق الأربعة. 1 3