جداول PostgreSQL ممكن تتضخم 40% أو أكتر من غير ما تضيف صف داتا واحد. السبب مش زيادة في الاستخدام، السبب اسمه table bloat، وغالبًا autovacuum مش شغّال زي ما انت فاكر. المقال ده هيوريك إزاي تكتشف المشكلة بدقة، تضبط autovacuum على مستوى الجدول، وتسترجع المساحة في إنتاج بدون ما تقفل قاعدة البيانات.
جداول PostgreSQL بتكبر بدون داتا جديدة؟ المشكلة في table bloat
المشكلة باختصار
عندك جدول users فيه 2 مليون صف، حجم البيانات الفعلية 800MB، لكن لما تعمل SELECT pg_total_relation_size('users') الرقم بيطلع 2.4GB. الفرق ده هو الـ bloat — مساحة محجوزة لصفوف اتمسحت أو اتعدّلت ولسه قاعدة محتلّاها على القرص. النتيجة: استعلامات أبطأ، backups أتقل، وفاتورة storage بتزيد بدون سبب واضح.
مثال للمبتدئين: كراسة وممحاة
تخيل كراسة بتكتب فيها 100 سطر كل يوم. لما تغلط في سطر، بدل ما تستخدم الممحاة، بتشطب السطر بقلم وتكتب الجديد تحته. بعد شهر هتلاقي الكراسة فيها 3000 سطر مكتوب، بس 1500 منهم مشطوب. الكراسة وزنها اتضاعف من غير ما تكتب معلومات جديدة فعلًا.
PostgreSQL بيشتغل بنفس الطريقة بالظبط. لما تعمل UPDATE على صف، PostgreSQL مش بيعدّل الصف القديم في مكانه؛ بيعمل نسخة جديدة في مكان تاني وبيسيب القديم محجوز. الصفوف القديمة دي اسمها dead tuples، والمسؤول عن مسحها فعليًا اسمه autovacuum. لو autovacuum ما لحقش، الكراسة بتكبر بلا توقف.
التعريف العلمي: ليه PostgreSQL بيعمل كده
PostgreSQL بيستخدم نموذج اسمه MVCC (Multi-Version Concurrency Control). الفكرة إن كل transaction بتشوف نسخة من الداتا متناسقة في لحظة بدايتها، حتى لو في transactions تانية بتعدّل في نفس اللحظة. علشان ده يحصل بدون locks ثقيلة، PostgreSQL بيحتفظ بنسخ متعددة من الصف الواحد، وكل نسخة بتاخد رقم transaction (XID) بيحدد إمتى تكون visible لمين.
لما الـ transactions بتنتهي، النسخ القديمة بتفضل في الجدول، وautovacuum مهمته يمشي كل فترة، يحدد النسخ اللي مفيش transaction نشطة فاكراها، ويفرّغ المساحة بتاعتها. لو autovacuum بطيء، أو الإعدادات الافتراضية بتاعته مش مناسبة لحجم الجدول، الجدول بيفضل bloated وبيكبر مع الوقت.
إزاي تكتشف bloat فعليًا
أول حاجة، نصب الـ extension اللي بيقيس bloat بدقة:
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT
schemaname || '.' || relname AS table_name,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
ROUND((s.dead_tuple_percent)::numeric, 1) AS dead_pct,
pg_size_pretty(s.dead_tuple_len) AS dead_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
CROSS JOIN LATERAL pgstattuple(c.oid) s
WHERE n.nspname = 'public'
AND c.relkind = 'r'
ORDER BY s.dead_tuple_len DESC
LIMIT 10;