الأدلة التقنية

PostgreSQL لكل شيء: متى يكفيك ومتى تحتاج غيره؟

كم عملاً يستطيع PostgreSQL واحد أن يقوم به في شركتك الناشئة قبل أن تحتاج Redis أو Elasticsearch، وما الإشارة التي تقول لك إن الوقت قد حان.

PostgreSQL لكل شيء: متى يكفيك ومتى تحتاج غيره؟

لنفرض أنك تبني منتجاً جديداً مع فريق من ثلاثة مطورين، وبعد ستة أشهر نظرت إلى ملف docker-compose.yml فوجدت PostgreSQL للبيانات، وRedis للتخزين المؤقت Cache، وRabbitMQ لطابور المهام Job Queue، وElasticsearch للبحث، وMongoDB لأن أحدهم احتاج "مستندات مرنة"، وقاعدة متجهات Vector Database لميزة البحث بالذكاء الاصطناعي، وسيرفر cron صغير للمهام المجدولة، وكل نظام من هذه له نسخته الاحتياطية Backup وترقياته وكلمات مروره ومراقبته وأخطاؤه الخاصة، والفريق ما زال ثلاثة مطورين. وفي المقابل انتشرت بين الشركات الناشئة فكرة معاكسة تماماً اسمها "Just use Postgres": استخدم قاعدة PostgreSQL واحدة لكل هذه الأعمال حتى يثبت لك قياس حقيقي أنك تحتاج غيرها. وقد يتساءل البعض: هل هذا كلام جاد أم مبالغة من محبي PostgreSQL؟ والإجابة أن الفكرة صحيحة إلى حد بعيد ولكن لها حدود واضحة يمكن قياسها، وهذا ما سوف نفعله في هذا المقال على Container واحد.

وهذا المقال لا يكرر الأساسيات، فقد شرحنا لغة SQL في سلسلة تعلم SQL من ثلاثة أجزاء، وتصميم الجداول في تصميم قواعد البيانات، والجداول الضخمة والتقسيم Partitioning وال VACUUM في PostgreSQL للجداول الضخمة، أما هنا فسوف نأخذ كل عمل يذهب عادة إلى نظام متخصص ونرى كيف يقوم به PostgreSQL 18، ونشغل مثالاً حقيقياً، ثم نحدد أين يتوقف وما الإشارة التي تقول لك إن وقت الفصل قد جاء، وسوف نناقش في هذا المقال ما يلي:

  • من أين جاءت فكرة "Just use Postgres"، ولماذا تنجح مع الشركات الناشئة.
  • طابور المهام Job Queue مع FOR UPDATE SKIP LOCKED مع اثنين من برامج المعالجة Workers، والأدوات الجاهزة المبنية عليه، ثم الإشعارات LISTEN/NOTIFY وحدودها.
  • التخزين المؤقت Cache في جدول UNLOGGED وفي العروض المحفوظة Materialized Views، مع قياس صريح لزمن الاستجابة Latency.
  • البحث النصي الكامل Full-Text Search بالعربية، والمستندات JSONB، والبيانات الجغرافية مع PostGIS، والمتجهات Embeddings مع pgvector.
  • السلاسل الزمنية Time Series بالتقسيم وفهرس BRIN، والمهام المجدولة مع pg_cron، ونمط Outbox وسجل الأحداث.
  • الواجهة البرمجية API من قاعدة البيانات مباشرة مع PostgREST وSupabase، والتحليلات Analytics داخل PostgreSQL.
  • الثمن الحقيقي لقاعدة واحدة، وجدول قرار، وقائمة إشارات قابلة للقياس تقول لك متى تفصل.
💡
كل الأزمنة في هذا المقال من Container واحد على جهاز واحد عليه Docker، وهي للتوضيح فقط، فهي تبين الفرق بين طريقتين على البيانات نفسها وليست سرعة تتوقعها على سيرفرك، والسيرفر بقرص NVMe وذاكرة أكبر سوف يعطيك أرقاماً مختلفة.

من أين جاءت فكرة "Just use Postgres"؟

الفكرة أقدم من الشعار، ففي مارس 2015 كتب Dan McKinley مقاله المعروف Choose Boring Technology، وخلاصته أن كل شركة تملك عدداً قليلاً من "رموز الابتكار" Innovation Tokens، وكل تقنية جديدة تضيفها تستهلك واحداً منها لأنك سوف تتعلم أعطالها في الإنتاج Production بنفسك، لذلك فالأفضل أن تصرف هذه الرموز على ما يميز منتجك لا على البنية التحتية، وقد جمع لاحقاً أفكاره في موقع boringtechnology.club. ثم جاء من طبق الفكرة على PostgreSQL تحديداً، مثل مقال Stephan Schmidt Just Use Postgres for Everything الذي يعدد الأنظمة التي يمكن أن يحل محلها PostgreSQL، وقائمة Postgres is Enough التي تجمع الإضافات والأدوات لكل عمل، وكتاب Denis Magda Just Use Postgres! من Manning.

ولم تبق الفكرة عند PostgreSQL، ففي إعلان Rails 8 في نوفمبر 2024 أصبح الطابور والتخزين المؤقت وWebSockets تعمل من قاعدة البيانات نفسها عبر Solid Queue وSolid Cache وSolid Cable، والسبب الذي ذكره الإعلان أن الأقراص أصبحت سريعة بما يكفي لأعمال كانت تحتاج الذاكرة RAM في الماضي. والصورة التالية تبين الفرق بين الطريقتين:

مخطط من جزأين: على اليمين التطبيق متصل بثمانية أنظمة هي PostgreSQL وRedis وRabbitMQ أو SQS وElasticsearch وMongoDB وQdrant أو Pinecone وInfluxDB وسيرفر cron، وتحتها ثماني نسخ احتياطية وثماني ترقيات وثماني مجموعات من كلمات المرور ولا توجد معاملة مشتركة. وعلى اليسار التطبيق متصل بقاعدة PostgreSQL 18 واحدة فيها طابور SKIP LOCKED وجدول UNLOGGED للكاش وtsvector مع GIN وJSONB وPostGIS وpgvector والتقسيم مع BRIN وpg_cron، وتحتها نسخة احتياطية واحدة ومجمع اتصالات واحد ومعاملة واحدة عبر الميزات
نفس الأعمال الثمانية: ثمانية أنظمة يديرها الفريق، أو قاعدة واحدة بميزات وإضافات

لماذا تنجح الفكرة في الشركات الناشئة؟

الشركة الناشئة لا تموت عادة لأن قاعدة بياناتها بطيئة، وإنما لأن فريقها الصغير يصرف وقته على تشغيل الأنظمة بدل بناء المنتج، وفي خارطة CTO لشركة SaaS ناشئة ذكرنا أن أول قاعدة هي أن تؤجل كل ما لا تحتاجه الآن، وقاعدة واحدة تعطيك ما يلي:

  • نسخة احتياطية Backup واحدة واستعادة واحدة: عندما تستعيد قاعدة البيانات إلى الساعة العاشرة صباحاً تعود الطلبات والطابور والكاش والمتجهات إلى اللحظة نفسها، أما مع خمسة أنظمة فكل واحد يعود إلى لحظة مختلفة، وعليك أن تصلح الفرق بيدك.
  • معاملة Transaction واحدة عبر الميزات: إضافة الطلب وإضافة مهمة إرسال البريد في معاملة واحدة، فإما أن يحدثا معاً أو لا يحدث أي منهما، وهذا مستحيل بين PostgreSQL وRabbitMQ دون أنماط إضافية كما سيأتي في قسم Outbox.
  • مجمع اتصالات Connection Pool واحد، ومراقبة واحدة، وصلاحيات واحدة: نفس pg_stat_statements يخبرك بأبطأ استعلام سواء كان بحثاً أو طابوراً، ونفس سياسات الصلاحيات تحمي الجميع.
  • مهارة واحدة في الفريق: كل مطور يعرف SQL، وليس كل مطور يعرف إعدادات Elasticsearch أو سلوك RabbitMQ عند امتلاء القرص.
  • تكلفة أقل: سيرفر واحد أكبر قليلاً بدل عدة سيرفرات صغيرة أو عدة خدمات مدارة Managed Services لكل منها فاتورة.

ولكن لاحظ أن كل نقطة من هذه لها وجه آخر، فالقاعدة الواحدة نقطة فشل واحدة Single Point of Failure، والأحمال كلها تتقاسم المعالج والقرص، وسوف نعود إلى هذا الثمن بالتفصيل في قسم الثمن بعد أن نرى كل حالة على حدة.

تجهيز المختبر: PostgreSQL 18 مع الإضافات

سوف نشغل PostgreSQL 18 في Container واحد على المنفذ 55490 المربوط على العنوان المحلي فقط، ثم نثبت داخله ثلاث إضافات من مستودع PostgreSQL الرسمي apt.postgresql.org الموجود أصلاً في ال Image الرسمية، وهي PostGIS للبيانات الجغرافية وpgvector للمتجهات وpg_cron للمهام المجدولة، وإذا لم يكن Docker مثبتاً لديك فابدأ بدليل تثبيت Docker على Ubuntu:

docker run -d --name lab-pgall-pg -p 127.0.0.1:55490:5432 \
  -e POSTGRES_PASSWORD="$(openssl rand -hex 16)" --shm-size=512m \
  postgres:18.6

docker exec lab-pgall-pg bash -c "apt-get update -qq && \
  apt-get install -y -qq postgresql-18-postgis-3 postgresql-18-pgvector postgresql-18-cron"

docker exec lab-pgall-pg psql -U postgres \
  -c "ALTER SYSTEM SET shared_preload_libraries = 'pg_cron';" \
  -c "ALTER SYSTEM SET cron.database_name = 'postgres';"
docker restart lab-pgall-pg

docker exec lab-pgall-pg psql -U postgres \
  -c "CREATE EXTENSION pg_cron; CREATE EXTENSION postgis; CREATE EXTENSION vector;
      CREATE EXTENSION pg_trgm; CREATE EXTENSION unaccent;" \
  -c "SELECT extname, extversion FROM pg_extension ORDER BY 1;"

والمخرج سوف يكون كما يلي:

 extname  | extversion
----------+------------
 pg_cron  | 1.6
 pg_trgm  | 1.6
 plpgsql  | 1.0
 postgis  | 3.6.4
 unaccent | 1.1
 vector   | 0.8.7
(6 rows)

في الإعداد أعلاه لاحظ التالي:

  • الإضافة pg_cron تعمل كعملية خلفية Background Worker داخل السيرفر، لذلك يجب أن تحمل من shared_preload_libraries ثم يعاد تشغيل السيرفر، وهي تنشئ جداولها في قاعدة بيانات واحدة تحددها cron.database_name كما يشرح ملف README الخاص بها.
  • الإضافتان pg_trgm وunaccent موجودتان مع PostgreSQL نفسه ولا تحتاجان أي تثبيت.
  • تثبيت الحزم داخل Container يعمل يضيع إذا حذفته وأنشأته من جديد، لذلك في الإنتاج ابن Image خاصة بك بملف Dockerfile يبدأ من postgres:18.6 ويثبت نفس الحزم بإصدارات محددة، أو استخدم خدمة مدارة تدعم هذه الإضافات، وسوف نتحدث عن الخدمات المدارة في قسم الثمن.

طابور المهام Job Queue مع SKIP LOCKED

طابور المهام هو المكان الذي يضع فيه التطبيق عملاً يحتاج وقتاً، مثل إرسال بريد أو إنشاء ملف PDF أو استدعاء خدمة خارجية، ثم يعود للمستخدم فوراً، ويأتي برنامج معالجة يعمل في الخلفية Worker يأخذ المهمة وينفذها، وقد شرحنا لماذا تحتاجه في البريد التفاعلي Transactional Email. والأداة المعتادة لهذا العمل وسيط رسائل Message Broker مثل RabbitMQ أو خدمة مثل Amazon SQS، والسؤال هنا: هل يكفي جدول في PostgreSQL؟

لنبدأ بالجدول والدالة التي يستدعيها ال Worker، احفظ الملف التالي باسم queue.sql ثم نفذه بالأمر docker exec -i lab-pgall-pg psql -U postgres < queue.sql:

CREATE TABLE jobs (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kind        text        NOT NULL,
    payload     jsonb       NOT NULL,
    status      text        NOT NULL DEFAULT 'queued',
    run_at      timestamptz NOT NULL DEFAULT now(),
    attempts    int         NOT NULL DEFAULT 0,
    done_by     text,
    done_at     timestamptz
);
CREATE INDEX jobs_queued_idx ON jobs (run_at, id) WHERE status = 'queued';

INSERT INTO jobs (kind, payload)
SELECT 'send_email', jsonb_build_object('order_id', g, 'to', 'customer' || g || '@example.com')
FROM generate_series(1, 20000) AS g;

CREATE PROCEDURE work(worker text, batch int DEFAULT 10)
LANGUAGE plpgsql AS $$
DECLARE n int;
BEGIN
  LOOP
    WITH next AS (
      SELECT id FROM jobs
      WHERE status = 'queued' AND run_at <= now()
      ORDER BY run_at, id
      LIMIT batch
      FOR UPDATE SKIP LOCKED
    )
    UPDATE jobs j
       SET status = 'done', done_by = worker, done_at = clock_timestamp(), attempts = attempts + 1
      FROM next WHERE j.id = next.id;
    GET DIAGNOSTICS n = ROW_COUNT;
    EXIT WHEN n = 0;
    PERFORM pg_sleep(0.001);   -- the "work": 1 ms per batch
    COMMIT;
  END LOOP;
END $$;

في الإعداد أعلاه لاحظ التالي:

  • الفهرس الجزئي Partial Index على (run_at, id) للمهام المنتظرة فقط يبقى صغيراً مهما كبر الجدول، لأن المهام المنتهية تخرج منه.
  • العمود run_at يسمح بتأجيل مهمة أو إعادة محاولتها بعد دقائق، وهو ما تفعله الأدوات الجاهزة عند الفشل.
  • الإجراء work يأخذ عشر مهام في كل دورة ثم يقوم ب COMMIT، والسطر pg_sleep(0.001) يمثل العمل الحقيقي فقط.

الحل البسيط: FOR UPDATE وحده

أول ما يخطر على البال أن يقرأ ال Worker أول مهمة منتظرة ويقفلها بـ FOR UPDATE حتى لا يأخذها غيره، والمشكلة أن ال Worker الثاني سوف يحاول قفل الصف نفسه فينتظر، ولنجرب ذلك بجلستين، في الجلسة الأولى يأخذ ال Worker الأول ثلاث مهام ويبقى في المعاملة ست ثوان:

docker exec lab-pgall-pg psql -U postgres -c "BEGIN; SELECT id FROM jobs WHERE status='queued' ORDER BY run_at,id LIMIT 3 FOR UPDATE; SELECT pg_sleep(6); COMMIT;"

وفي جلسة ثانية خلال هذه الثواني نجرب ال Worker الثاني بالطريقتين:

docker exec lab-pgall-pg psql -U postgres -c "SET lock_timeout='2s';" \
  -c "SELECT id FROM jobs WHERE status='queued' ORDER BY run_at,id LIMIT 3 FOR UPDATE;"
docker exec lab-pgall-pg psql -U postgres \
  -c "SELECT id FROM jobs WHERE status='queued' ORDER BY run_at,id LIMIT 3 FOR UPDATE SKIP LOCKED;"

والمخرج سوف يكون كما يلي:

SET
ERROR:  canceling statement due to lock timeout
CONTEXT:  while locking tuple (0,1) in relation "jobs"
 id
----
  4
  5
  6
(3 rows)

الطريقة الأولى انتظرت الصف الأول حتى انتهت المهلة، ودون lock_timeout كانت سوف تنتظر حتى ينتهي ال Worker الأول، أي أن كل ال Workers يصطفون خلف Worker واحد، أما SKIP LOCKED فقد تجاوز الصفوف المقفلة وأخذ الصفوف 4 إلى 6 فوراً، وهذا بالضبط ما تقوله وثائق SELECT: تجاوز الصفوف المقفلة يعطي رؤية غير متسقة للبيانات فلا يصلح للاستعلامات العامة، ولكنه يصلح لعدة مستهلكين يقرؤون من جدول يعمل كطابور. والصورة التالية تبين ما حدث:

مخطط لجدول jobs فيه ثمانية صفوف: الصفوف 1 إلى 3 مقفلة من worker-1، وال Worker الثاني مع FOR UPDATE العادي ينتظر الصف 1 حتى ينتهي ال Worker الأول أو تنتهي مهلة lock_timeout، ومع SKIP LOCKED يأخذ الصفوف 4 إلى 6 دون انتظار ودون تكرار، ثم UPDATE إلى done وCOMMIT ويترك كل صف منته صفاً ميتاً لل autovacuum
مع SKIP LOCKED يأخذ كل Worker صفوفاً مختلفة بدل أن يصطف خلف ال Worker الأول

اثنان من ال Workers على عشرين ألف مهمة

الآن نشغل اثنين من ال Workers في الوقت نفسه على كل المهام:

docker exec lab-pgall-pg psql -U postgres -c "CALL work('worker-1');" &
docker exec lab-pgall-pg psql -U postgres -c "CALL work('worker-2');" &
wait
docker exec lab-pgall-pg psql -U postgres \
  -c "SELECT done_by, count(*) AS jobs, max(attempts) AS max_attempts,
             round(extract(epoch FROM max(done_at)-min(done_at))::numeric,2) AS seconds
      FROM jobs GROUP BY done_by ORDER BY 1;" \
  -c "SELECT count(*) FILTER (WHERE status='queued') AS still_queued,
             count(*) FILTER (WHERE attempts > 1) AS done_twice FROM jobs;"

والمخرج سوف يكون كما يلي:

 done_by  | jobs  | max_attempts | seconds
----------+-------+--------------+---------
 worker-1 |  9980 |            1 |    5.96
 worker-2 | 10020 |            1 |    5.96
(2 rows)

 still_queued | done_twice
--------------+------------
            0 |          0
(1 row)

تقاسم ال Workers الاثنان العمل تقريباً بالنصف، ولم تبق أي مهمة، ولم تنفذ أي مهمة مرتين، ولكن انظر الآن إلى ما تركه الطابور خلفه:

docker exec lab-pgall-pg psql -U postgres -c "SELECT pg_stat_force_next_flush();" \
  -c "SELECT relname, n_live_tup, n_dead_tup, n_tup_upd,
             pg_size_pretty(pg_total_relation_size('jobs')) AS size
      FROM pg_stat_user_tables WHERE relname='jobs';"
 relname | n_live_tup | n_dead_tup | n_tup_upd |  size
---------+------------+------------+-----------+---------
 jobs    |      20000 |      20000 |     20000 | 5688 kB
(1 row)

كل تحديث في PostgreSQL يكتب نسخة جديدة من الصف ويترك القديمة صفاً ميتاً Dead Tuple حتى ينظفه autovacuum، وقد شرحنا السبب في قسم VACUUM من مقال الجداول الضخمة، والطابور هو أسوأ حالة لهذا السلوك لأن كل مهمة تكتب مرتين على الأقل، لذلك احذف المهام المنتهية أو انقلها إلى جدول أرشيف على دفعات، واضبط autovacuum لجدول الطابور بنسبة صغيرة، وإذا كان الطابور كبيراً جداً فقسمه حسب اليوم واحذف الأيام القديمة كاملة.

لا تكتب الطابور بنفسك: الأدوات الجاهزة

المثال أعلاه يشرح الفكرة، أما في الإنتاج فهناك أشياء كثيرة لم نكتبها: إعادة المحاولة مع تأخير متزايد، والمهام العالقة عندما يتوقف ال Worker فجأة في منتصف العمل، والأولويات، والمهام الفريدة، ولوحة المتابعة، لذلك استخدم أداة مبنية على هذه الفكرة بلغتك:

الأداةاللغةالرخصةما يميزها
pgmqSQL فقط، مع مكتبات لعدة لغاتPostgreSQL Licenseإضافة لا تحتاج Background Worker، واجهتها تشبه Amazon SQS مع مهلة الإخفاء Visibility Timeout، وتدعم PostgreSQL من 14 إلى 18
RiverGoMPL-2.0إضافة المهمة داخل معاملة التطبيق نفسها Transactional Enqueueing، وبعض الميزات مثل Workflows في نسخة مدفوعة اسمها River Pro
Graphile WorkerNode.jsMITيستخدم LISTEN/NOTIFY لتبدأ المهمة خلال أجزاء من الثانية
pg-bossNode.jsMITمبني على SKIP LOCKED، ويضيف المهمة داخل معاملة قائمة مع محولات لعدة ORM
ObanElixirApache-2.0يعمل على PostgreSQL وSQLite3 وMySQL، مع تحكم كامل بالمعاملة
Solid QueueRuby on RailsMITجاء مع Rails 8 بديلاً عن Redis وSidekiq لأغلب التطبيقات، ويعمل على قاعدة بيانات التطبيق
⚠️
متى تتجاوز الطابور في PostgreSQL؟ عندما تحتاج أن يقرأ الحدث نفسه عدة مستهلكين مستقلين وأن يعيدوا قراءته من نقطة سابقة، وهذا عمل نظام بث أحداث Event Streaming مثل Kafka وليس طابوراً، أو عندما يتبادل فريقان أو خدمتان الرسائل وكل واحدة لها قاعدة بياناتها، أو عندما ترى أن autovacuum لا يلحق بجدول الطابور، أي أن n_dead_tup يزيد يوماً بعد يوم رغم الضبط، أو أن استعلام أخذ المهام أصبح من أعلى الاستعلامات وقتاً في pg_stat_statements.

الإشعارات LISTEN/NOTIFY: جرس وليس صندوق بريد

ال Worker في المثال السابق يسأل الجدول كل فترة: هل هناك مهام جديدة؟ وهذا يسمى الاستطلاع Polling، والبديل أن يخبره PostgreSQL فور وصول مهمة، وهذا ما يفعله الأمران LISTEN وNOTIFY، وهما نوع بسيط من النشر والاشتراك Pub/Sub داخل قاعدة البيانات. لنضف Trigger يرسل إشعاراً عند كل مهمة جديدة:

CREATE FUNCTION jobs_notify() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  PERFORM pg_notify('new_job', json_build_object('id', NEW.id, 'kind', NEW.kind)::text);
  RETURN NEW;
END $$;
CREATE TRIGGER jobs_notify AFTER INSERT ON jobs
FOR EACH ROW EXECUTE FUNCTION jobs_notify();

ثم نفتح جلسة تستمع أربع ثوان، وفي جلسة أخرى نضيف مهمتين داخل معاملة واحدة بينهما ثانية:

docker exec lab-pgall-pg psql -U postgres -c "LISTEN new_job;" -c "SELECT pg_sleep(4);" &
sleep 1.5
docker exec lab-pgall-pg psql -U postgres -c "BEGIN;" \
  -c "INSERT INTO jobs (kind, payload) VALUES ('send_sms', '{\"order_id\": 20001}');" \
  -c "SELECT pg_sleep(1);" \
  -c "INSERT INTO jobs (kind, payload) VALUES ('send_email', '{\"order_id\": 20002}');" \
  -c "COMMIT;"
wait

والمخرج من جلسة الاستماع سوف يكون كما يلي:

LISTEN
 pg_sleep
----------

(1 row)

Asynchronous notification "new_job" with payload "{"id" : 20001, "kind" : "send_sms"}" received from server process with PID 129.
Asynchronous notification "new_job" with payload "{"id" : 20002, "kind" : "send_email"}" received from server process with PID 129.

الإشعاران وصلا معاً بعد COMMIT وليس عند كل INSERT، وهذا مقصود، فلو ألغيت المعاملة لما وصل أي إشعار عن مهمة لم تحفظ، والآن لنكسر شيئاً، فالحمولة Payload لها حد أقصى:

docker exec lab-pgall-pg psql -U postgres -c "SELECT pg_notify('new_job', repeat('x', 8000));"
ERROR:  payload string too long

والصورة التالية تبين الطريقة الصحيحة لاستخدام الإشعارات مع الطابور:

مخطط: طلب API يبدأ معاملة ويضيف مهمة إلى جدول jobs ويستدعي Trigger الإشعار pg_notify ثم COMMIT، فيذهب الإشعار إلى طابور الإشعارات بحد أقصى 8 جيجابايت وحمولة أقل من 8000 بايت ولا يرسل إلا بعد COMMIT، ثم يصل إلى worker-1 وworker-2 المتصلين فيستيقظان، أما worker-3 المنقطع فيضيع عليه الإشعار، وبعد ذلك يقرأ كل Worker جدول jobs مع SKIP LOCKED ويستطلعه كل بضع ثوان احتياطاً
الإشعار ينبه ال Worker فقط، والمهمة نفسها محفوظة في الجدول

وفيما يلي حدود الإشعارات كما تذكرها وثائق NOTIFY، ومنها تفهم لماذا لا تصلح لتكون الطابور نفسه:

  • الإشعار لا يحفظ في أي مكان، فإذا كان ال Worker منقطعاً لحظة الإرسال فقد ضاع عليه، لذلك ضع المهمة في جدول دائماً، واجعل الإشعار مجرد تنبيه، واجعل ال Worker يستطلع الجدول كل بضع ثوان احتياطاً.
  • الحمولة أقل من 8000 بايت في الإعداد الافتراضي، والوثائق نفسها تنصح بأن تضع البيانات في جدول وترسل المفتاح فقط.
  • الإشعارات المرسلة تنتظر في طابور حجمه 8 جيجابايت في الإعداد القياسي، وإذا امتلأ فشلت كل معاملة ترسل إشعاراً عند COMMIT، وجلسة تستمع ثم تبقى في معاملة مفتوحة طويلاً تمنع تنظيف هذا الطابور، ويمكنك مراقبته بالدالة pg_notification_queue_usage().
  • الجلسة التي تستمع تحتاج اتصالاً ثابتاً بالسيرفر، فلا تعمل خلف PgBouncer في وضع Transaction Pooling كما يبين جدول ميزات PgBouncer، وقد شرحنا هذه الأوضاع في تجميع الاتصالات Connection Pooling.
📘
في مارس 2025 توقفت قاعدة بيانات شركة Recall.ai تحت عشرات الآلاف من الجلسات التي تكتب في الوقت نفسه، ووجد فريقها أن كل معاملة تستدعي NOTIFY تأخذ قفلاً عاماً على قاعدة البيانات كلها عند COMMIT، فأصبحت كل عمليات COMMIT تنتظر في صف واحد، وقد نشروا القصة كاملة، وكان الحل أن نقلوا هذا المنطق من NOTIFY إلى كود التطبيق. وقد حسنت ملاحظات إصدار PostgreSQL 19 القادم أداء NOTIFY بحيث لا يوقظ إلا الجلسات التي تستمع للقناة فعلاً، أما على PostgreSQL 18 فالدرس كما هو: لا تضع NOTIFY في Trigger على جدول تصله آلاف عمليات الكتابة في الثانية.

التخزين المؤقت Cache: جدول UNLOGGED والعروض المحفوظة

الأداة المعتادة للتخزين المؤقت Redis أو Valkey، وهي تحفظ البيانات في الذاكرة RAM وتعطيك قيمة مفتاح Key-Value مع مدة صلاحية TTL وطرد تلقائي للقديم Eviction عند امتلاء الذاكرة، أما في PostgreSQL فلديك طريقتان، والصورة التالية تبينهما:

مخطط من جزأين: على اليمين جدول cache من نوع UNLOGGED فيه key وvalue وexpires_at، والتطبيق يقرأ منه عند وجود القيمة، وعند غيابها يحسبها ويضيفها، وpg_cron يحذف القيم المنتهية كل دقيقة، ومزاياه أن الكتابة أسرع لأنه لا يكتب WAL، وعيبه أنه يفرغ بعد التوقف المفاجئ ولا ينسخ إلى نسخ القراءة ولا يطرد القديم تلقائياً. وعلى اليسار عرض محفوظ device_hourly من 83,500 صف يلخص جدول metrics من 6 ملايين صف، ويحدثه pg_cron كل خمس دقائق بأمر REFRESH MATERIALIZED VIEW CONCURRENTLY، ولوحة التحكم تقرؤه في 5 مللي ثانية، والبيانات بعمر آخر تحديث
جدول UNLOGGED للمفاتيح والقيم، وعرض محفوظ لنتائج التقارير الثقيلة

جدول UNLOGGED للمفاتيح والقيم

الجدول من نوع UNLOGGED لا يكتب في سجل WAL، لذلك تكون الكتابة فيه أسرع بكثير، ولنقس ذلك بأداة pgbench الموجودة في ال Image نفسها، أولاً الجدولان:

CREATE UNLOGGED TABLE cache (
    key        text PRIMARY KEY,
    value      jsonb       NOT NULL,
    expires_at timestamptz NOT NULL
);
CREATE INDEX cache_expires_idx ON cache (expires_at);
CREATE TABLE cache_logged (LIKE cache INCLUDING ALL);

ثم ملف الكتابة set_unlogged.pgb الذي يكتب منتجاً عشوائياً من مئة ألف بصلاحية خمس دقائق، وملف القراءة get_unlogged.pgb، ونسخة من ملف الكتابة على الجدول العادي باسم set_logged.pgb:

-- set_unlogged.pgb
\set k random(1, 100000)
INSERT INTO cache (key, value, expires_at)
VALUES ('product:' || :k, jsonb_build_object('id', :k, 'price', :k % 500), now() + interval '5 minutes')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at;

-- get_unlogged.pgb
\set k random(1, 100000)
SELECT value FROM cache WHERE key = 'product:' || :k AND expires_at > now();

الآن ننسخ الملفات إلى ال Container ونشغل كل ملف 15 ثانية بثمانية عملاء على جهاز بثماني أنوية:

for f in set_logged set_unlogged get_unlogged; do
  docker cp $f.pgb lab-pgall-pg:/tmp/
  echo "== $f"
  docker exec lab-pgall-pg pgbench -U postgres -n -c 8 -j 4 -T 15 -f /tmp/$f.pgb postgres \
    | grep -E 'latency average|tps ='
done

والمخرج سوف يكون كما يلي:

== set_logged
latency average = 4.408 ms
tps = 1814.707698 (without initial connection time)
== set_unlogged
latency average = 0.431 ms
tps = 18567.406312 (without initial connection time)
== get_unlogged
latency average = 0.425 ms
tps = 18826.758464 (without initial connection time)

في المخرج أعلاه لاحظ التالي:

  • الكتابة في الجدول العادي أخذت 4.4 مللي ثانية لأن كل COMMIT ينتظر أن يصل WAL إلى القرص، وهذا الرقم كبير هنا لأن انتظار القرص fsync بطيء في بيئة المختبر، وعلى سيرفر بقرص NVMe سوف يكون الفرق أصغر، ولكن الجدول UNLOGGED تجاوز هذا الانتظار تماماً فأصبحت الكتابة 0.43 مللي ثانية.
  • القراءة 0.43 مللي ثانية في المتوسط بما فيها الشبكة والتحليل والتخطيط، وهذا رقم ممتاز لصفحة ويب، ولكن كن صريحاً مع نفسك: Redis يحفظ القيم في الذاكرة ويرد بأوامر بسيطة دون محلل SQL أو مخطط استعلامات أو نظام معاملات، لذلك فهو أسرع وأخف على المعالج لكل طلب، ولم نقسه هنا على الجهاز نفسه فلن نضع له رقماً.
  • القراءات من الكاش تأخذ اتصالات ومعالجاً من نفس السيرفر الذي يخدم الطلبات، وهذا هو الثمن الحقيقي، وسوف نعود إليه في قسم الثمن.

أما عيب UNLOGGED فلنره بأعيننا، نوقف ال Container فجأة كأن السيرفر انقطعت عنه الكهرباء، ثم نعد الصفوف:

docker exec lab-pgall-pg psql -U postgres -c "CHECKPOINT;" \
  -c "SELECT (SELECT count(*) FROM cache) AS cache_rows, (SELECT count(*) FROM cache_logged) AS logged_rows;"
docker kill lab-pgall-pg && docker start lab-pgall-pg
docker exec lab-pgall-pg psql -U postgres \
  -c "SELECT (SELECT count(*) FROM cache) AS cache_rows, (SELECT count(*) FROM cache_logged) AS logged_rows;"
 cache_rows | logged_rows
------------+-------------
      93850 |       23860
(1 row)

 cache_rows | logged_rows
------------+-------------
          0 |       23860
(1 row)

الجدول UNLOGGED أصبح فارغاً بعد الاستعادة من التوقف المفاجئ، وهذا سلوك موثق: الجدول لا يحمي بياناته من التوقف ويفرغ تلقائياً، ولا ينسخ إلى نسخ القراءة Standby، وهذا مقبول تماماً للكاش لأنه يعاد بناؤه من البيانات الأصلية، ولكنه غير مقبول لأي شيء آخر، فلا تضع فيه الجلسات Sessions إلا إذا كان خروج المستخدمين بعد كل توقف مقبولاً لديك. ولاحظ أيضاً أن الجدول لا يطرد القيم القديمة وحده، لذلك نحتاج مهمة تحذف المنتهي منها، وسوف نجدولها مع pg_cron في قسم المهام المجدولة.

العروض المحفوظة Materialized Views للتقارير

النوع الثاني من الكاش هو نتيجة استعلام ثقيل تحتاجه لوحة التحكم مرات كثيرة، فبدلاً من أن تحفظ النتيجة في Redis من كود التطبيق تحفظها قاعدة البيانات نفسها في عرض محفوظ Materialized View، وعلى جدول القراءات metrics الذي سوف ننشئه في قسم السلاسل الزمنية بستة ملايين صف:

CREATE MATERIALIZED VIEW device_hourly AS
SELECT device_id, date_trunc('hour', ts) AS hour, avg(temp_c) AS avg_temp, count(*) AS readings
FROM metrics GROUP BY 1, 2;
CREATE UNIQUE INDEX ON device_hourly (device_id, hour);   -- required for REFRESH ... CONCURRENTLY

والقراءة من العرض تأخذ أقل من مللي ثانية لأنها تقرأ ملخصاً جاهزاً من 83,500 صف:

docker exec lab-pgall-pg psql -U postgres -c "\timing on" \
  -c "SELECT device_id, hour, round(avg_temp::numeric, 2) AS avg_temp, readings FROM device_hourly
      WHERE device_id = 7 AND hour >= '2026-10-05 10:00+00' ORDER BY hour LIMIT 3;"
 device_id |          hour          | avg_temp | readings
-----------+------------------------+----------+----------
         7 | 2026-10-05 10:00:00+00 |    25.70 |       72
         7 | 2026-10-05 11:00:00+00 |    25.70 |       72
         7 | 2026-10-05 12:00:00+00 |    25.70 |       72
(3 rows)

Time: 0.817 ms

والثمن أن البيانات عمرها عمر آخر تحديث، وأن كل تحديث يعيد حساب العرض كله، وقد أخذ التحديث في المختبر بين 6 و8 ثوان كما سوف نرى في سجل pg_cron، والخيار CONCURRENTLY يسمح بالقراءة أثناء التحديث بشرط وجود فهرس فريد على العرض.

⚠️
متى تنتقل إلى Redis أو Valkey؟ عندما تصبح قراءات الكاش وكتاباته جزءاً كبيراً من وقت المعالج في pg_stat_statements على حساب الطلبات الحقيقية، أو عندما تحتاج طرداً تلقائياً للقيم القديمة عند امتلاء الذاكرة، أو عدادات تتغير آلاف المرات في الثانية مثل تحديد معدل الطلبات Rate Limiting، أو هياكل جاهزة مثل القوائم المرتبة Sorted Sets، أو عندما يكون زمن الاستجابة المطلوب أقل من مللي ثانية بثبات تحت الضغط.

البحث النصي الكامل Full-Text Search بالعربية

الأداة المعتادة للبحث Elasticsearch أو OpenSearch أو Meilisearch، أما PostgreSQL ففيه بحث نصي كامل منذ سنوات: النص يتحول إلى tsvector أي قائمة كلمات بعد إرجاع كل كلمة إلى أصلها Stemming، والسؤال يتحول إلى tsquery، ويجمعهما فهرس GIN كما تشرح وثائق البحث النصي. وقد يتساءل البعض: وهل يدعم العربية؟ والإجابة نعم، وهذا مما يجهله كثيرون، فقد أضاف إصدار PostgreSQL 12 إرجاع الكلمات العربية إلى أصلها من مكتبة Snowball، ولنتأكد على PostgreSQL 18:

docker exec lab-pgall-pg psql -U postgres -c "\dF arabic" \
  -c "SELECT to_tsvector('simple', 'المستشفيات الحكومية في الرياض') AS simple,
             to_tsvector('arabic', 'المستشفيات الحكومية في الرياض') AS arabic;" \
  -c "SELECT unaccent('مُسْتَشْفَى') AS unaccent_ar, unaccent('café') AS unaccent_fr;"
           List of text search configurations
   Schema   |  Name  |            Description
------------+--------+-----------------------------------
 pg_catalog | arabic | configuration for arabic language
(1 row)

                    simple                     |               arabic
-----------------------------------------------+------------------------------------
 'الحكومية':2 'الرياض':4 'المستشفيات':1 'في':3 | 'حكوم':2 'رياض':4 'في':3 'مستشف':1
(1 row)

 unaccent_ar | unaccent_fr
-------------+-------------
 مُسْتَشْفَى      | cafe
(1 row)

في المخرج أعلاه لاحظ التالي:

  • الإعداد simple يحفظ الكلمة كما هي، فالبحث عن "مستشفى" لن يجد "المستشفيات"، أما الإعداد arabic فقد حذف "ال" وأرجع الكلمات إلى أصل تقريبي.
  • كلمة "في" بقيت في الإعداد العربي، لأنه لا يأتي بقائمة كلمات شائعة Stop Words عربية، وهذا يضخم الفهرس قليلاً ولا يضر النتائج كثيراً.
  • الإضافة unaccent حذفت العلامة من الحرف الفرنسي ولم تحذف الحركات العربية، لأن قواعدها الافتراضية مكتوبة للحروف اللاتينية، لذلك لا تعتمد عليها في العربية واكتب دالة توحيد خاصة.

والعربية تكتب الكلمة نفسها بأكثر من شكل: الألف بهمزة ودونها، والياء والألف المقصورة، والتاء المربوطة والهاء، والحركات والتطويل، وقد شرحنا هذه المشكلة مع البحث الدلالي في قسم النص العربي من مقال RAG، والحل البسيط أن نوحد النص قبل الإعداد العربي في الكتابة وفي السؤال معاً:

CREATE FUNCTION ar_normalize(t text) RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
  SELECT translate(
           regexp_replace(t, '[ً-ْـ]', '', 'g'),  -- remove harakat and tatweel
           'أإآٱىة',                                               -- unify alef forms, alef maqsura, ta marbuta
           'اااايه')
$$;

CREATE TABLE posts (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    title  text NOT NULL,
    body   text NOT NULL,
    search tsvector GENERATED ALWAYS AS (
             setweight(to_tsvector('arabic', ar_normalize(title)), 'A') ||
             setweight(to_tsvector('arabic', ar_normalize(body)),  'B')) STORED
);

INSERT INTO posts (title, body)
SELECT
  (ARRAY['افتتاح','توسعة','تقييم','مواعيد','أسعار','دليل'])[1 + g % 6] || ' ' ||
  (ARRAY['مستشفى','مدرسة','مطعم','صيدلية','مكتبة','حديقة','مَسْجِد'])[1 + (g / 6) % 7] || ' ' ||
  (ARRAY['الرياض','جدة','الدمام','مكة','المدينة','أبها','تبوك','الطائف'])[1 + (g / 42) % 8],
  'خبر رقم ' || g || ' عن ' ||
  (ARRAY['الخدمات الصحية','التعليم','المطاعم الشعبية','الإجازة الصيفية','المواصلات','الفعاليات'])[1 + (g / 7) % 6] ||
  ' مع تفاصيل الأسعار والمواقع وساعات العمل.'
FROM generate_series(1, 300000) AS g;
ANALYZE posts;

في الإعداد أعلاه لاحظ التالي:

  • الدالة IMMUTABLE لأنها تعطي النتيجة نفسها للنص نفسه دائماً، وهذا شرط لاستخدامها في عمود محسوب Generated Column أو في فهرس.
  • العمود search يحسب تلقائياً عند كل إضافة أو تعديل، والدالة setweight تعطي العنوان وزن A والنص وزن B حتى يتقدم في الترتيب ما وجدت الكلمة في عنوانه.

الآن نبحث عن "مستشفي الرياض" مكتوبة بالياء كما يكتبها كثير من الناس، مرة دون توحيد ومرة معه:

docker exec lab-pgall-pg psql -U postgres -c "SELECT
  (SELECT count(*) FROM posts WHERE to_tsvector('arabic', title) @@ websearch_to_tsquery('arabic', 'مستشفي الرياض')) AS without_normalize,
  (SELECT count(*) FROM posts WHERE search @@ websearch_to_tsquery('arabic', ar_normalize('مستشفي الرياض'))) AS with_normalize;"
 without_normalize | with_normalize
-------------------+----------------
                 0 |           5357
(1 row)

دون توحيد لم يجد البحث أي نتيجة، والسبب أن "مستشفى" بالألف المقصورة أصبحت في الفهرس 'مستشفي' بينما أصبحت "مستشفي" في السؤال 'مستشف'، فالمكتبة نفسها ترجع الشكلين إلى أصلين مختلفين، أما مع التوحيد فقد أصبح الشكلان نصاً واحداً قبل أن يصلا إليها. الآن نضيف فهرس GIN ونقارن الوقت:

docker exec lab-pgall-pg psql -U postgres -c "CREATE INDEX posts_search_idx ON posts USING gin (search);" \
  -c "SELECT pg_size_pretty(pg_relation_size('posts_search_idx')) AS gin_size, pg_size_pretty(pg_relation_size('posts')) AS table_size;" \
  -c "\timing on" \
  -c "SELECT id, title, round(ts_rank(search, q)::numeric, 3) AS rank
      FROM posts, websearch_to_tsquery('arabic', ar_normalize('مستشفي الرياض -توسعة')) AS q
      WHERE search @@ q ORDER BY rank DESC, id LIMIT 3;"
 gin_size | table_size
----------+------------
 21 MB    | 130 MB
(1 row)

Timing is on.
 id |        title         | rank
----+----------------------+-------
  2 | تقييم مستشفى الرياض  | 0.991
  3 | مواعيد مستشفى الرياض | 0.991
  4 | أسعار مستشفى الرياض  | 0.991
(3 rows)

Time: 13.161 ms

والدالة websearch_to_tsquery تفهم صيغة محركات البحث، فالعلامة -توسعة استبعدت أخبار التوسعة. وفي خطة التنفيذ نرى أن البحث عن 5,357 صفاً أخذ 6.3 مللي ثانية بالفهرس، مقابل 43 مللي ثانية بقراءة الجدول كله على ثلاثة معالجات بالتوازي عندما عطلنا الفهرس بالأمر SET enable_bitmapscan = off:

 Bitmap Heap Scan on posts (actual time=3.169..5.834 rows=5357.00 loops=1)
   Recheck Cond: (search @@ '''مستشف'' & ''رياض'''::tsquery)
   ->  Bitmap Index Scan on posts_search_idx (actual time=3.026..3.027 rows=5357.00 loops=1)
 Execution Time: 6.266 ms

والصورة التالية تبين مسار النص والسؤال حتى يلتقيا في الفهرس:

مخطط: المستند بالحركات يمر بالدالة ar_normalize فيصبح بلا حركات وبالياء، ثم to_tsvector بالإعداد arabic فيعطي كلمتين هما مستشف ورياض، ثم يدخل فهرس GIN الذي يربط كل كلمة بالصفوف التي تحتويها، والسؤال المكتوب بشكل مختلف يمر بالتوحيد نفسه ثم websearch_to_tsquery فيعطي الكلمتين نفسيهما ويلتقي بالفهرس. وفي الأسفل ما لا يلتقطه الإعداد العربي: جموع التكسير مثل مدرسة ومدارس، والأخطاء الإملائية مثل الرياظ، والحل الاحتياطي pg_trgm بدرجة تشابه 0.65
نفس التوحيد في الكتابة وفي السؤال، ثم pg_trgm لما لا يلتقطه الإعداد العربي

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

docker exec lab-pgall-pg psql -U postgres \
  -c "CREATE INDEX posts_title_trgm ON posts USING gin (ar_normalize(title) gin_trgm_ops);" \
  -c "SELECT DISTINCT title, round(word_similarity(ar_normalize('مستشفيات الرياظ'), ar_normalize(title))::numeric, 2) AS sim
      FROM posts WHERE ar_normalize('مستشفيات الرياظ') <% ar_normalize(title)
      ORDER BY sim DESC, title LIMIT 3;"
        title         | sim
----------------------+------
 أسعار مستشفى الرياض  | 0.65
 افتتاح مستشفى الرياض | 0.65
 تقييم مستشفى الرياض  | 0.65
(3 rows)

البحث بالجمع وبخطأ إملائي وجد العناوين الصحيحة، والطريقة المعتادة أن تبحث بالنص الكامل أولاً، فإذا قلت النتائج جربت التشابه، ولاحظ أن الدالة ts_rank ترتب حسب تكرار الكلمات وليست ترتيب BM25 الذي تستخدمه محركات البحث، فإذا احتجت BM25 داخل PostgreSQL فهناك إضافة pg_textsearch برخصة PostgreSQL، ومشروع ParadeDB برخصة AGPL-3.0، وقد قارنا البحث بالكلمات والبحث الدلالي في قسم البحث الهجين من مقال RAG.

⚠️
متى تنتقل إلى محرك بحث؟ عندما يصبح البحث هو المنتج نفسه، وتحتاج تصحيح الأخطاء الإملائية أثناء الكتابة، والمرشحات Facets مع العدد بجانب كل قيمة، والمرادفات، وضبط الترتيب لكل حقل، ومحللاً عربياً أقوى، أو عندما تكبر النصوص إلى مئات الملايين من المستندات ويصبح تحديث فهرس GIN مع كل كتابة عبئاً واضحاً على سيرفر الطلبات. وعندها انقل البحث وحده إلى محرك مثل OpenSearch أو Meilisearch وغذه من جدول Outbox كما سيأتي.

المستندات JSONB بدل MongoDB

السبب المعتاد لإضافة MongoDB هو أن بعض البيانات لا تتبع شكلاً ثابتاً، مثل مواصفات المنتجات حيث للحاسوب ذاكرة ومنافذ وللهاتف سعة وألوان وللكتاب لغة وعدد صفحات، والنوع JSONB في PostgreSQL يحفظ هذه المستندات بصيغة ثنائية ويفهرسها، والمثال التالي يبين ذلك بثلاثمئة ألف منتج:

CREATE TABLE products (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name   text  NOT NULL,
    price  numeric(10,2) NOT NULL,
    attrs  jsonb NOT NULL
);
INSERT INTO products (name, price, attrs)
SELECT 'product ' || g, (g % 900) + 10,
  CASE g % 3
    WHEN 0 THEN jsonb_build_object('type','laptop','brand',(ARRAY['lenovo','dell','hp','apple'])[1 + g % 4],
                                   'ram_gb',(ARRAY[8,16,32,64])[1 + (g/12) % 4],'ports',jsonb_build_array('usb-c','hdmi'))
    WHEN 1 THEN jsonb_build_object('type','phone','brand',(ARRAY['samsung','apple','xiaomi'])[1 + g % 3],
                                   'storage_gb',(ARRAY[128,256,512])[1 + (g/9) % 3],'colors',jsonb_build_array('black','white'))
    ELSE        jsonb_build_object('type','book','lang',(ARRAY['ar','en'])[1 + g % 2],'pages',100 + g % 400)
  END
FROM generate_series(1, 300000) AS g;
ANALYZE products;

نبحث عن حواسيب Lenovo بذاكرة 32 جيجابايت بالمعامل Operator @> الذي يعني "يحتوي"، قبل الفهرس وبعده:

docker exec lab-pgall-pg psql -U postgres \
  -c "SELECT id, attrs FROM products WHERE attrs @> '{\"type\":\"laptop\",\"brand\":\"lenovo\",\"ram_gb\":32}' ORDER BY id LIMIT 2;" \
  -c "EXPLAIN (ANALYZE, COSTS OFF) SELECT count(*) FROM products WHERE attrs @> '{\"type\":\"laptop\",\"brand\":\"lenovo\",\"ram_gb\":32}';" \
  -c "CREATE INDEX products_attrs_idx ON products USING gin (attrs jsonb_path_ops);" \
  -c "EXPLAIN (ANALYZE, COSTS OFF) SELECT count(*) FROM products WHERE attrs @> '{\"type\":\"laptop\",\"brand\":\"lenovo\",\"ram_gb\":32}';" \
  -c "SELECT pg_size_pretty(pg_relation_size('products_attrs_idx')) AS gin_size;"

والمخرج سوف يكون كما يلي بعد اختصار الخطط إلى الأسطر المهمة:

 id |                                      attrs
----+---------------------------------------------------------------------------------
 24 | {"type": "laptop", "brand": "lenovo", "ports": ["usb-c", "hdmi"], "ram_gb": 32}
 72 | {"type": "laptop", "brand": "lenovo", "ports": ["usb-c", "hdmi"], "ram_gb": 32}
(2 rows)

               ->  Parallel Seq Scan on products (actual time=0.018..35.047 rows=2083.33 loops=3)
                     Filter: (attrs @> '{"type": "laptop", "brand": "lenovo", "ram_gb": 32}'::jsonb)
                     Rows Removed by Filter: 97917
 Execution Time: 40.668 ms

CREATE INDEX
   ->  Bitmap Heap Scan on products (actual time=3.306..10.094 rows=6250.00 loops=1)
         ->  Bitmap Index Scan on products_attrs_idx (actual time=2.774..2.775 rows=6250.00 loops=1)
               Index Cond: (attrs @> '{"type": "laptop", "brand": "lenovo", "ram_gb": 32}'::jsonb)
 Execution Time: 10.550 ms

 gin_size
----------
 2096 kB

في المخرج أعلاه لاحظ التالي:

  • دون فهرس قرأ PostgreSQL المنتجات كلها واستبعد 97,917 صفاً في كل واحد من ال Parallel Workers الثلاثة، ومع فهرس GIN ذهب مباشرة إلى 6,250 منتجاً مطابقاً.
  • اخترنا jsonb_path_ops بدل الفئة الافتراضية لأن الوثائق تذكر أنه أصغر وأسرع، ولكنه يدعم عوامل الاحتواء @> والمسارات فقط، ولا يدعم السؤال عن وجود مفتاح ?.

والآن لنكسر شيئاً، نسأل عن حواسيب بذاكرة 32 جيجابايت أو أكثر، أي مقارنة وليست احتواء:

docker exec lab-pgall-pg psql -U postgres \
  -c "EXPLAIN (COSTS OFF) SELECT count(*) FROM products WHERE (attrs->>'ram_gb')::int >= 32;"
               ->  Parallel Seq Scan on products
                     Filter: (((attrs ->> 'ram_gb'::text))::integer >= 32)

فهرس GIN لم يستخدم لأنه لا يفهم المقارنة بالأرقام، والحل فهرس عادي B-tree على التعبير نفسه، ويفضل أن يكون جزئياً على نوع المنتج:

docker exec lab-pgall-pg psql -U postgres \
  -c "CREATE INDEX products_ram_idx ON products (((attrs->>'ram_gb')::int)) WHERE attrs->>'type' = 'laptop';" \
  -c "EXPLAIN (ANALYZE, COSTS OFF) SELECT count(*) FROM products WHERE attrs->>'type' = 'laptop' AND (attrs->>'ram_gb')::int >= 32;"
   ->  Bitmap Heap Scan on products (actual time=2.304..17.398 rows=50000.00 loops=1)
         ->  Bitmap Index Scan on products_ram_idx (actual time=1.703..1.703 rows=50000.00 loops=1)
               Index Cond: (((attrs ->> 'ram_gb'::text))::integer >= 32)
 Execution Time: 22.305 ms

والقاعدة العملية هنا أن تبقي في الأعمدة العادية كل ما تبحث به كثيراً أو تربط به جداول أو تضع عليه قيوداً Constraints، مثل السعر والنوع والمالك، وأن تضع في JSONB ما يختلف من منتج إلى آخر، والسبب أن الأعمدة لها أنواع وقيود وإحصاءات دقيقة يستخدمها المخطط، أما الحقول داخل JSONB فلا يعرف المخطط عنها إلا القليل، وقد شرحنا متى تكسر التطبيع عن قصد في تصميم قواعد البيانات.

⚠️
متى تحتاج قاعدة مستندات Document Database؟ عندما تكون البيانات كلها مستندات كبيرة متداخلة تقرأ وتكتب كاملة ولا تربطها علاقات، وحجمها يتجاوز سيرفراً واحداً فتحتاج توزيعاً Sharding مدمجاً في قاعدة البيانات، وهذه حالة أقل بكثير مما يظن الناس، والإشارة العملية هي أن تجد أغلب استعلاماتك تستخرج الحقول من JSONB وأغلب فهارسك على تعبيرات داخلها، فهنا إما أن تحول هذه الحقول إلى أعمدة، أو أن البيانات فعلاً لا تناسب الجداول.

البيانات الجغرافية Geospatial مع PostGIS

هنا لا يوجد صراع حقيقي، فإضافة PostGIS برخصة GPL-2.0 هي نفسها المعيار الذي تبنى عليه أنظمة كثيرة للخرائط، وهي تضيف أنواعاً للنقاط والخطوط والمضلعات ودوال للمسافة والتقاطع وفهرس GiST مكاني، والمثال التالي يضع مئتي ألف مكان عشوائي داخل مستطيل يغطي مدينة الرياض، صيدليات ومقاهي ومساجد ومدارس ومحطات وقود:

CREATE TABLE places (
    id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kind  text NOT NULL,
    geom  geography(Point, 4326) NOT NULL
);
SELECT setseed(0.42);
INSERT INTO places (kind, geom)
SELECT (ARRAY['pharmacy','cafe','mosque','school','fuel'])[1 + g % 5],
       ST_SetSRID(ST_MakePoint(46.55 + random() * 0.35, 24.55 + random() * 0.40), 4326)::geography
FROM generate_series(1, 200000) AS g;
ANALYZE places;
CREATE INDEX places_geom_idx ON places USING gist (geom);

ثم نسأل سؤالين معتادين في أي تطبيق توصيل أو خرائط: أقرب خمس صيدليات إلى نقطة في الرياض، وكم صيدلية في دائرة نصف قطرها كيلومتر واحد:

SELECT p.id, p.kind, round(ST_Distance(p.geom, me.g)::numeric) AS meters
FROM places p, (SELECT ST_SetSRID(ST_MakePoint(46.6744, 24.7114), 4326)::geography AS g) AS me
WHERE p.kind = 'pharmacy'
ORDER BY p.geom <-> me.g
LIMIT 5;

SELECT count(*) FROM places
WHERE kind = 'pharmacy'
  AND ST_DWithin(geom, ST_SetSRID(ST_MakePoint(46.6744, 24.7114), 4326)::geography, 1000);
   id   |   kind   | meters
--------+----------+--------
 198000 | pharmacy |    106
 171690 | pharmacy |    115
 119175 | pharmacy |    173
  20040 | pharmacy |    197
  32795 | pharmacy |    229
(5 rows)

Time: 0.543 ms
 count
-------
    94
(1 row)

Time: 3.254 ms

في الإعداد أعلاه لاحظ التالي:

  • النوع geography يحسب المسافة بالمتر على سطح الأرض مباشرة، فلا تحتاج إلى تحويل الدرجات إلى أمتار بنفسك، وترتيب الإحداثيات في ST_MakePoint هو خط الطول ثم خط العرض، وهذا أكثر خطأ نراه في هذه الاستعلامات.
  • المعامل Operator <-> في ORDER BY يجعل فهرس GiST يرجع الأقرب فالأقرب دون حساب المسافة لكل الصفوف، فأخذ الاستعلام 0.54 مللي ثانية مقابل 44 مللي ثانية دون الفهرس، والدائرة أخذت 3.3 مللي ثانية مقابل 1,331 مللي ثانية دونه، وحجم الفهرس 14 ميجابايت.
⚠️
متى تحتاج نظاماً آخر للخرائط؟ ليس لتخزين الأماكن والبحث فيها، وإنما للأعمال حولها: حساب الطرق والمسافة بالسيارة يحتاج محرك توجيه Routing مثل OSRM أو إضافة pgRouting، وتقديم الخرائط للمتصفح يحتاج خادم مربعات Tile Server، وتتبع ملايين المركبات المتحركة كل ثانية يتحول إلى عمل كتابة كثيف من نوع السلاسل الزمنية، والإشارة هنا أن تجد تحديث مواقع المركبات يأخذ أغلب الكتابة في قاعدة بيانات الطلبات.

المتجهات Embeddings مع pgvector

البحث الدلالي Semantic Search يحول كل نص إلى متجه Vector من مئات الأرقام، ثم يبحث عن أقرب المتجهات إلى متجه السؤال، وقد شرحنا الفكرة كاملة مع نموذج حقيقي في البحث في مستنداتك مع RAG، والأدوات المتخصصة لهذا العمل قواعد متجهات مثل Qdrant وPinecone، أما في PostgreSQL فالإضافة pgvector برخصة PostgreSQL تضيف النوع vector وفهرس HNSW. ولن نعيد هنا ما شرحناه في مقال RAG، وإنما نقيس السرعة والحدود على مئة ألف متجه بطول 384 موزعة على ألف موضوع، أي مركز لكل موضوع مع ضجيج حوله حتى تشبه المتجهات الحقيقية، فالمتجهات العشوائية تماماً لا تشبه أي بيانات حقيقية وتعطي أسوأ نتيجة ممكنة للفهرس:

CREATE TABLE chunks (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    topic      int  NOT NULL,
    embedding  vector(384) NOT NULL
);
SELECT setseed(0.7);
CREATE TEMP TABLE centres AS
SELECT t AS topic, (SELECT array_agg(random() - 0.5) FROM generate_series(1, 384) WHERE t >= 0) AS c
FROM generate_series(0, 999) AS t;
INSERT INTO chunks (topic, embedding)
SELECT g % 1000,
       (SELECT array_agg(c[i] + (random() - 0.5) * 0.6) FROM generate_series(1, 384) AS i WHERE g > 0)::vector
FROM generate_series(1, 100000) AS g JOIN centres ON centres.topic = g % 1000;
-- the question: a new vector near topic 42
CREATE TABLE probe AS
SELECT (SELECT array_agg(c[i] + (random() - 0.5) * 0.6) FROM generate_series(1, 384) AS i)::vector(384) AS q
FROM centres WHERE topic = 42;
ANALYZE chunks;

CREATE TABLE exact10 AS SELECT id FROM chunks ORDER BY embedding <=> (SELECT q FROM probe) LIMIT 10;
SET maintenance_work_mem = '512MB';
CREATE INDEX chunks_hnsw ON chunks USING hnsw (embedding vector_cosine_ops);

الجدول exact10 يحفظ أقرب عشرة متجهات بالبحث الكامل قبل الفهرس، حتى نقارن به نتيجة الفهرس، وقد أخذ بناء فهرس HNSW نحو 19 ثانية وحجمه 195 ميجابايت مقابل 156 ميجابايت للجدول نفسه. الآن نبحث مرة دون الفهرس ومرة به، ثم نعد كم من العشرة الصحيحة وجدها الفهرس، وذلك بملف knn.sql ننفذه بالأمر docker exec -i lab-pgall-pg psql -U postgres < knn.sql:

SELECT q::text AS q FROM probe \gset
SET enable_indexscan = off;
\timing on
SELECT id, topic, round((embedding <=> :'q')::numeric, 4) AS cosine_distance
FROM chunks ORDER BY embedding <=> :'q' LIMIT 5;
\timing off
RESET enable_indexscan;
\timing on
SELECT id, topic, round((embedding <=> :'q')::numeric, 4) AS cosine_distance
FROM chunks ORDER BY embedding <=> :'q' LIMIT 5;
SELECT id, topic, round((embedding <=> :'q')::numeric, 4) AS cosine_distance
FROM chunks ORDER BY embedding <=> :'q' LIMIT 5;
\timing off
SELECT count(*) AS found_of_10 FROM (SELECT id FROM chunks ORDER BY embedding <=> :'q' LIMIT 10) a JOIN exact10 USING (id);
  id  | topic | cosine_distance
------+-------+-----------------
 4230 |    42 |          0.2284
 4225 |    42 |          0.2294
 4217 |    42 |          0.2311
 4271 |    42 |          0.2336
 4205 |    42 |          0.2352
(5 rows)

Time: 82.901 ms
  id  | topic | cosine_distance
------+-------+-----------------
 4230 |    42 |          0.2284
 4225 |    42 |          0.2294
 4217 |    42 |          0.2311
 4271 |    42 |          0.2336
 4205 |    42 |          0.2352
(5 rows)

Time: 2.834 ms
  id  | topic | cosine_distance
------+-------+-----------------
 4230 |    42 |          0.2284
 4225 |    42 |          0.2294
 4217 |    42 |          0.2311
 4271 |    42 |          0.2336
 4205 |    42 |          0.2352
(5 rows)

Time: 1.180 ms
 found_of_10
-------------
         10
(1 row)

الفهرس أعطى النتائج نفسها في 2.8 مللي ثانية في المرة الأولى و1.2 مللي ثانية في الثانية بعد أن أصبحت صفحاته في الذاكرة، مقابل 83 مللي ثانية للبحث الكامل، ووجد العشرة الصحيحة كلها، أي أن نسبة الاسترجاع Recall هنا 10 من 10، ولكن تذكر أن HNSW فهرس تقريبي، فقد يفوته بعض الجيران الحقيقيين، والإعداد hnsw.ef_search يرفع الدقة على حساب السرعة.

ولاحظ أننا مررنا متجه السؤال كقيمة ثابتة عبر متغير psql، والسبب أننا عندما كتبنا الاستعلام في البداية بربط الجدول probe مع chunks لم يستخدم المخطط الفهرس أبداً وقرأ الجدول كله، فالفهرس يعمل عندما يكون متجه السؤال قيمة معروفة لحظة التنفيذ، وهو ما يحدث طبيعياً عندما يرسل التطبيق المتجه كمعامل Parameter.

الشرط WHERE مع فهرس المتجهات

والآن الحالة التي تفاجئ أغلب من يستخدم pgvector لأول مرة، نبحث عن أقرب خمسة متجهات إلى السؤال نفسه ولكن داخل الموضوع رقم 7 فقط، كأن تبحث في مستندات مستأجر Tenant واحد:

SELECT q::text AS q FROM probe \gset
SELECT count(*) AS rows_returned FROM (
  SELECT id FROM chunks WHERE topic = 7 ORDER BY embedding <=> :'q' LIMIT 5) t;
SET hnsw.iterative_scan = relaxed_order;
\timing on
SELECT id, topic, round((embedding <=> :'q')::numeric, 4) AS cosine_distance
FROM chunks WHERE topic = 7 ORDER BY embedding <=> :'q' LIMIT 5;
 rows_returned
---------------
             0
(1 row)

SET
Timing is on.
 id  | topic | cosine_distance
-----+-------+-----------------
 745 |     7 |          0.9507
 795 |     7 |          0.9625
 774 |     7 |          0.9646
 718 |     7 |          0.9724
 743 |     7 |          0.9789
(5 rows)

Time: 182.542 ms

طلبنا خمس نتائج فرجع الاستعلام صفراً، والسبب كما يشرح قسم Filtering في pgvector أن الشرط يطبق بعد أن يرجع الفهرس مرشحيه، والفهرس يرجع 40 مرشحاً في الإعداد الافتراضي hnsw.ef_search = 40، وكلها قريبة من الموضوع 42 فلم يبق منها شيء للموضوع 7، ومنذ الإصدار 0.8.0 يمكنك تفعيل البحث المتكرر Iterative Scan فيستمر الفهرس في البحث حتى يجد العدد المطلوب، وقد أعطى النتائج الخمس في 182 مللي ثانية، وهذا أبطأ بكثير ولكنه صحيح. والصورة التالية تبين الحالتين:

مخطط من جزأين: على اليسار فهرس HNSW بثلاث طبقات، العليا قليلة النقاط والسفلى فيها كل المتجهات المئة ألف، والبحث يبدأ من نقطة دخول في الطبقة العليا وينزل بقفزات قليلة إلى أقرب المتجهات في نحو مللي ثانية واحدة بدل 83، وهو تقريبي قد يفوته بعض الجيران. وعلى اليمين استعلام فيه WHERE topic = 7، والسؤال قريب من الموضوع 42، فيرجع الفهرس 40 مرشحاً حسب ef_search ولا يبقى منها شيء بعد الشرط فتكون النتيجة صفر صفوف، ومع hnsw.iterative_scan = relaxed_order يستمر الفهرس حتى يجد خمسة صفوف في 182 مللي ثانية، وللشرط الدائم مثل المستأجر أو اللغة استخدم فهرساً جزئياً أو قسم الجدول
الفهرس سريع ودقيق دون شروط، والشرط WHERE يحتاج بحثاً متكرراً أو فهرساً جزئياً

فإذا كان الشرط ثابتاً في أغلب الاستعلامات، مثل اللغة أو نوع المستند، فالأفضل فهرس جزئي لكل قيمة، وإذا كان الشرط بقيم كثيرة مثل المستأجر فالأفضل تقسيم الجدول، وهذا ما ينصح به README نفسه، وقد شرحنا عزل المستأجرين في بناء تطبيق SaaS متعدد المستأجرين.

⚠️
متى تحتاج قاعدة متجهات متخصصة؟ عندما لا يتسع فهرس HNSW في ذاكرة السيرفر، فالفهرس هنا كان أكبر من الجدول نفسه، وعندما يصبح بناء الفهرس أو إعادة بنائه يأخذ ساعات ويثقل على سيرفر الطلبات، أو عندما تحتاج متجهات بأكثر من 2,000 بعد في فهرس، فهذا حد النوع vector في pgvector ويمكن رفعه إلى 4,000 بالنوع halfvec، أو عندما تحتاج بحثاً مع شروط كثيرة متغيرة على مئات الملايين من المتجهات بزمن ثابت. والإشارة العملية أن تقيس نسبة الاسترجاع وزمن الاستجابة معاً على أسئلة حقيقية، فإذا لم تصل إلى الدقة المطلوبة إلا بزمن لا تقبله فقد حان وقت الفصل.

السلاسل الزمنية Time Series: التقسيم وBRIN

قراءات الحساسات وسجلات الأحداث والمقاييس Metrics كلها بيانات تصل بترتيب الوقت، وتقرأ غالباً بنطاق زمني، وتحذف بعد مدة، والأدوات المتخصصة لها مثل InfluxDB، أما في PostgreSQL فثلاث أدوات تكفي أغلب الحالات: التقسيم حسب اليوم، وفهرس BRIN الصغير جداً، وpg_cron لإنشاء الأقسام وحذفها، وقد شرحنا التقسيم بالتفصيل في قسم التقسيم من مقال الجداول الضخمة، لذلك نركز هنا على الأتمتة:

CREATE TABLE metrics (
    device_id  int         NOT NULL,
    ts         timestamptz NOT NULL,
    temp_c     real        NOT NULL
) PARTITION BY RANGE (ts);

CREATE FUNCTION metrics_add_day(d date) RETURNS void
LANGUAGE plpgsql AS $$
BEGIN
  EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF metrics FOR VALUES FROM (%L) TO (%L)',
                 'metrics_' || to_char(d, 'YYYYMMDD'), d, d + 1);
END $$;

SELECT metrics_add_day(d::date)
FROM generate_series(date '2026-10-01', date '2026-10-08', interval '1 day') AS d;

INSERT INTO metrics (device_id, ts, temp_c)
SELECT g % 500, timestamptz '2026-10-01 00:00+00' + (g * interval '100 milliseconds'), 20 + (g % 150) / 10.0
FROM generate_series(1, 6000000) AS g;   -- one week, about 10 readings per second

CREATE INDEX metrics_ts_brin ON metrics USING brin (ts);
ANALYZE metrics;

CREATE FUNCTION metrics_drop_older_than(keep interval) RETURNS int
LANGUAGE plpgsql AS $$
DECLARE r record; n int := 0;
BEGIN
  FOR r IN SELECT c.relname FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid
           WHERE i.inhparent = 'metrics'::regclass
             AND to_date(right(c.relname, 8), 'YYYYMMDD') < current_date - keep
  LOOP
    EXECUTE format('DROP TABLE %I', r.relname);   -- the whole day's file goes, no DELETE and no dead rows
    n := n + 1;
  END LOOP;
  RETURN n;
END $$;

ثم نسأل عن متوسط الحرارة في ثلاث ساعات من يوم 5 أكتوبر، وننظر إلى الخطة:

docker exec lab-pgall-pg psql -U postgres \
  -c "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT count(*) FROM metrics
      WHERE ts >= '2026-10-05 10:00+00' AND ts < '2026-10-05 13:00+00';" \
  -c "\di+ metrics_20261005*" -c "SELECT pg_size_pretty(pg_relation_size('metrics_20261005'));"

والمخرج سوف يكون كما يلي بعد اختصار الخطة إلى الأسطر المهمة:

               ->  Parallel Bitmap Heap Scan on metrics_20261005 metrics (actual rows=36000.00 loops=3)
                     Recheck Cond: ((ts >= '2026-10-05 10:00:00+00'::timestamp with time zone) AND (ts < '2026-10-05 13:00:00+00'::timestamp with time zone))
                     Rows Removed by Index Recheck: 10891
                     Heap Blocks: lossy=393
                     ->  Bitmap Index Scan on metrics_20261005_ts_idx (actual rows=8960.00 loops=1)
 Execution Time: 19.476 ms

 Schema |          Name           | Type  |  Owner   |      Table       | Persistence | Access method | Size  | Description
--------+-------------------------+-------+----------+------------------+-------------+---------------+-------+-------------
 public | metrics_20261005_ts_idx | index | postgres | metrics_20261005 | permanent   | brin          | 48 kB |

 pg_size_pretty
----------------
 43 MB

في المخرج أعلاه لاحظ التالي:

  • من بين سبعة أقسام بستة ملايين صف قرأ PostgreSQL قسماً واحداً فقط هو يوم 5 أكتوبر، وهذا ما يسمى تقليم الأقسام Partition Pruning.
  • فهرس BRIN لليوم الواحد حجمه 48 كيلوبايت مقابل 43 ميجابايت للبيانات، لأنه لا يحفظ كل قيمة وإنما أصغر وأكبر وقت في كل مجموعة من الصفحات، وهذا يعمل فقط لأن القراءات تصل بترتيب الوقت، والصفوف التي استبعدها بعد القراءة Rows Removed by Index Recheck هي ثمن هذا الحجم الصغير.

والصورة التالية تبين دورة حياة الأقسام:

مخطط لجدول metrics مقسم حسب اليوم على خط زمني: قسم أقدم من 30 يوماً يحذف، ثم أقسام 3 إلى 6 أكتوبر بـ 864,000 صف لكل يوم، وقسم اليوم 7 أكتوبر الذي تصل إليه الإضافات، وقسم 8 أكتوبر فارغ وجاهز. وpg_cron في الساعة 01:00 ينشئ قسم الغد بالدالة metrics_add_day، وفي 01:30 يحذف الأقسام القديمة بالدالة metrics_drop_older_than بأمر DROP TABLE دون DELETE ودون صفوف ميتة، واستعلام لثلاث ساعات من 5 أكتوبر يقرأ قسماً واحداً بفهرس BRIN في 19 مللي ثانية، وحجم يوم واحد 43 ميجابايت للجدول و48 كيلوبايت لفهرس BRIN
قسم لكل يوم، وpg_cron ينشئ الغد ويحذف ما تجاوز مدة الاحتفاظ

وقد يتساءل البعض: ولماذا لا نستخدم TimescaleDB وهي إضافة لـ PostgreSQL مخصصة لهذا العمل؟ والإجابة أنها خيار ممتاز ولكن انتبه لرخصتها، فالمستودع فيه كود برخصة Apache 2.0 وكود في مجلد tsl برخصة Timescale License التي تسمى اليوم Tiger Data License كما يبين ملف الرخصة، وصفحة النسخ تبين أن الضغط والتخزين العمودي Columnstore والملخصات المستمرة Continuous Aggregates وسياسات الحذف add_retention_policy كلها في نسخة Community بالرخصة الخاصة وليست في نسخة Apache 2. والرخصة الخاصة تسمح لك بتشغيلها مجاناً على سيرفرك، ولكنها لا تسمح لك ببيعها كخدمة قاعدة بيانات، ولاحظ أن الحزمة postgresql-18-timescaledb في مستودع apt.postgresql.org لا تحتوي إلا ملفات Apache، فقد فحصنا محتواها فوجدنا timescaledb-2.30.2.so دون ملف -tsl، أي أن هذه الميزات غير موجودة فيها، وقد شرحنا أنواع الرخص في البرمجيات مفتوحة المصدر وتراخيصها.

⚠️
متى تحتاج قاعدة سلاسل زمنية متخصصة؟ عندما تصل الكتابة إلى مئات الآلاف من القراءات في الثانية بشكل مستمر، أو عندما تحتاج ضغطاً كبيراً للبيانات القديمة لأن القرص يكبر أسرع من الميزانية، أو عندما تكون أغلب الاستعلامات تجميعات على أشهر كاملة، والإشارة العملية أن ترى حجم بيانات الأيام يتضاعف أسرع من بيانات التطبيق نفسه، أو أن تجميعات لوحات المراقبة أصبحت تأخذ ثواني على أقسام كثيرة.

المهام المجدولة Scheduled Jobs مع pg_cron

كل ما سبق يحتاج مهاماً دورية: حذف الكاش المنتهي، وتحديث العرض المحفوظ، وإنشاء قسم الغد، وحذف الأقسام القديمة، والمعتاد أن تكتب لها سكربتات على سيرفر cron منفصل أو في أداة مثل Airflow، أما pg_cron برخصة PostgreSQL فيقرأ صيغة cron المعروفة ويشغل أوامر SQL من داخل قاعدة البيانات:

SELECT cron.schedule('purge-cache',    '* * * * *',   $$DELETE FROM cache WHERE expires_at < now()$$);
SELECT cron.schedule('refresh-hourly', '*/5 * * * *', $$REFRESH MATERIALIZED VIEW CONCURRENTLY device_hourly$$);
SELECT cron.schedule('next-partition', '0 1 * * *',   $$SELECT metrics_add_day(current_date + 1)$$);
SELECT cron.schedule('drop-old-days',  '30 1 * * *',  $$SELECT metrics_drop_older_than('30 days')$$);

ولنتأكد أن حذف الكاش يعمل نضيف خمسة آلاف قيمة منتهية، وبعد دقيقتين نقرأ سجل التنفيذ:

docker exec lab-pgall-pg psql -U postgres \
  -c "INSERT INTO cache (key, value, expires_at) SELECT 'k' || g, '{}', now() - interval '1 minute' FROM generate_series(1, 5000) g;"
docker exec lab-pgall-pg psql -U postgres \
  -c "SELECT j.jobname, d.status, d.return_message, to_char(d.start_time, 'HH24:MI:SS') AS started,
             round(extract(epoch FROM d.end_time - d.start_time)::numeric * 1000) AS ms
      FROM cron.job_run_details d JOIN cron.job j USING (jobid)
      ORDER BY d.start_time DESC LIMIT 6;"
    jobname     |  status   |      return_message       | started  |  ms
----------------+-----------+---------------------------+----------+------
 purge-cache    | succeeded | DELETE 0                  | 14:03:00 |   18
 purge-cache    | succeeded | DELETE 0                  | 14:02:00 |    1
 purge-cache    | succeeded | DELETE 0                  | 14:01:00 |    2
 purge-cache    | succeeded | DELETE 0                  | 14:00:00 |    2
 refresh-hourly | succeeded | REFRESH MATERIALIZED VIEW | 14:00:00 | 6459
 purge-cache    | succeeded | DELETE 0                  | 13:59:00 |    1
(6 rows)

وقبل هذه الأسطر كان التنفيذ الأول في الساعة 13:58 قد أرجع DELETE 5000 في 4 مللي ثانية، ثم أصبحت الحذوفات التالية صفراً لأنه لم تنته قيم جديدة، والعرض المحفوظ أخذ تحديثه 6.5 ثانية من عمل السيرفر كل خمس دقائق، وهذا رقم يجب أن تراقبه لأنه يتقاسم المعالج مع الطلبات. وفي الإعداد أعلاه لاحظ التالي:

  • الجدول cron.job_run_details يكبر مع كل تنفيذ، فمهمة كل دقيقة تضيف أكثر من 1,400 صف في اليوم، لذلك أضف مهمة تحذف السجل الأقدم من أسبوع.
  • الأوقات بتوقيت GMT في الإعداد الافتراضي، ويمكنك تغييرها بالإعداد cron.timezone كما يذكر README.
  • pg_cron لا يشغل أي مهمة على سيرفر في وضع Hot Standby، أي أن المهام تعمل على السيرفر الرئيسي فقط، ويبدأ العمل تلقائياً على النسخة التي تصبح رئيسية بعد الانتقال Failover كما يذكر README.
⚠️
متى تحتاج منسق مهام Orchestrator مثل Airflow؟ عندما تصبح المهام سلاسل تعتمد كل خطوة فيها على نجاح ما قبلها، وتحتاج إعادة محاولة لكل خطوة وتنبيهاً عند الفشل ولوحة لمتابعة كل تشغيل، أو عندما تخرج المهام من قاعدة البيانات إلى أنظمة أخرى مثل نقل الملفات واستدعاء الخدمات، فpg_cron ممتاز لأوامر SQL الدورية فقط ولا يعرف شيئاً عن العلاقات بين المهام.

سجل الأحداث Event Log ونمط Outbox

في مقال الأنظمة الموزعة شرحنا نمط Outbox نظرياً: التطبيق يحفظ الطلب ثم يرسل حدثاً إلى وسيط الرسائل، وإذا توقف بين الخطوتين ضاع الحدث، والحل أن يكتب الحدث في جدول داخل المعاملة نفسها، ثم يأتي Worker يرسله لاحقاً، وهنا نطبقه على PostgreSQL ونكسره عن قصد:

CREATE TABLE orders (
    id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer text          NOT NULL,
    total    numeric(10,2) NOT NULL CHECK (total > 0)
);
CREATE TABLE outbox (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    topic        text        NOT NULL,
    payload      jsonb       NOT NULL,
    created_at   timestamptz NOT NULL DEFAULT now(),
    published_at timestamptz
);
CREATE INDEX outbox_pending_idx ON outbox (id) WHERE published_at IS NULL;

ثم ملف 14-outbox-run.sql فيه طلب صحيح وطلب بقيمة سالبة يخالف القيد، ثم ال Worker الذي يرسل الأحداث المنتظرة، ونشغله بالأمرين docker cp 14-outbox-run.sql lab-pgall-pg:/tmp/ وdocker exec lab-pgall-pg psql -U postgres -f /tmp/14-outbox-run.sql:

BEGIN;
WITH o AS (INSERT INTO orders (customer, total) VALUES ('c-1001', 250.00) RETURNING id, customer, total)
INSERT INTO outbox (topic, payload)
SELECT 'order.created', jsonb_build_object('order_id', id, 'customer', customer, 'total', total) FROM o;
COMMIT;

BEGIN;
WITH o AS (INSERT INTO orders (customer, total) VALUES ('c-1002', -5) RETURNING id, customer, total)
INSERT INTO outbox (topic, payload)
SELECT 'order.created', jsonb_build_object('order_id', id) FROM o;
COMMIT;

SELECT (SELECT count(*) FROM orders) AS orders, (SELECT count(*) FROM outbox) AS outbox_rows;

-- the relay: take pending events, publish them to the broker, mark them
WITH batch AS (
  SELECT id FROM outbox WHERE published_at IS NULL ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED
)
UPDATE outbox SET published_at = now() FROM batch WHERE outbox.id = batch.id
RETURNING outbox.id, outbox.topic, outbox.payload;
BEGIN
INSERT 0 1
COMMIT
BEGIN
psql:/tmp/14-outbox-run.sql:10: ERROR:  new row for relation "orders" violates check constraint "orders_total_check"
DETAIL:  Failing row contains (2, c-1002, -5.00).
ROLLBACK
 orders | outbox_rows
--------+-------------
      1 |           1
(1 row)

 id |     topic     |                        payload
----+---------------+--------------------------------------------------------
  1 | order.created | {"total": 250.00, "customer": "c-1001", "order_id": 1}
(1 row)

UPDATE 1

الطلب الخاطئ رفضه القيد، فألغيت المعاملة كلها ولم يبق له حدث في Outbox، وهذا هو المقصود: الطلب والحدث يحفظان معاً أو لا يحفظ أي منهما، وال Worker أخذ الحدث الوحيد بـ SKIP LOCKED حتى يعمل أكثر من Worker دون تكرار، ثم يرسله إلى الوسيط أو البريد أو Webhook. والصورة التالية تبين الطريقتين:

مخطط من جزأين: في الطريقة البسيطة يحفظ التطبيق الطلب ثم يستدعي الوسيط، ويحدث توقف أو انتهاء مهلة بينهما فيحفظ الطلب ويضيع الحدث. وفي نمط Outbox يبدأ التطبيق معاملة ويضيف صفاً في orders وصفاً في outbox ثم COMMIT فيحفظ الاثنان أو لا شيء، ثم يأخذ Worker اسمه relay دفعة بـ SKIP LOCKED ويرسلها إلى وسيط أو بريد أو Webhook ويضع published_at، وإعادة المحاولة قد ترسل الحدث مرتين فيتجاهل المستهلك المكرر بمعرفه، والجدول نفسه يمكن أن يغذي سجل تدقيق أو فهرس بحث أو تحديث كاش
Outbox يجعل الطلب والحدث في معاملة واحدة، وال Worker يرسل لاحقاً

ولاحظ أن ال Worker قد يرسل الحدث ثم يتوقف قبل أن يضع published_at، فيرسله مرة أخرى بعد عودته، لذلك فالتسليم هنا "مرة واحدة على الأقل"، ويجب أن يتجاهل المستهلك الحدث المكرر بمعرفه، وهذا ما شرحناه باسم Idempotency في مقال الأنظمة الموزعة. والجدول نفسه يصلح سجلاً للأحداث Event Log تغذي منه فهرس البحث أو تحديث الكاش أو سجل التدقيق، أما سجل التدقيق Audit Trail الذي لا يقبل التعديل فقد خصصنا له مقالاً كاملاً هو سجل التدقيق Audit Trail مع جدول Append-only وسلسلة هاش في PostgreSQL 18.

الواجهة البرمجية API من قاعدة البيانات: PostgREST وSupabase

الخطوة الأبعد في هذه الفكرة أن تصبح قاعدة البيانات نفسها هي الواجهة البرمجية، فأداة PostgREST برخصة MIT تقرأ الجداول والعروض والدوال وتعطيك REST API كاملة لها، والصلاحيات لا تكتب في كود التطبيق وإنما في قاعدة البيانات نفسها بأدوار PostgreSQL وسياسات الصفوف Row-Level Security، فكل طلب يحمل توكن JWT يحدد الدور، وكل سياسة تحدد الصفوف التي يراها هذا الدور، ومنصة Supabase تبني واجهتها البرمجية على PostgREST كما تذكر وثائقها وتعتمد على RLS لحماية البيانات.

وهذا الأسلوب يناسب لوحات الإدارة والتطبيقات التي أغلبها قراءة وكتابة مباشرة، ولكنه ينقل منطق العمل إلى SQL، فإذا كان فريقك لا يكتب SQL جيداً أو كان منطق العمل معقداً يستدعي خدمات خارجية فالأفضل أن تبقى الواجهة في كود التطبيق، أما سياسات RLS نفسها فهي مفيدة في الحالتين، وقد طبقناها خطوة بخطوة مع الاختبارات في سياسات الصفوف Row-Level Security، فلن نكررها هنا.

التحليلات Analytics داخل PostgreSQL

PostgreSQL قاعدة صفوف Row Store، أي أن كل صف محفوظ كاملاً في مكان واحد، وهذا ممتاز لطلب واحد يقرأ صفاً واحداً، أما التقرير الذي يجمع عموداً واحداً من ملايين الصفوف فيقرأ كل الصفوف بكل أعمدتها، وقد شرحنا الفرق بين عمليات التطبيق OLTP والتحليل OLAP في تصميم قواعد البيانات، ولنقس ذلك على جدول القراءات:

docker exec lab-pgall-pg psql -U postgres -c "\timing on" \
  -c "SELECT date_trunc('day', ts) AS day, round(avg(temp_c)::numeric, 2) AS avg_temp, count(*) AS readings
      FROM metrics GROUP BY 1 ORDER BY 1 LIMIT 3;"
          day           | avg_temp | readings
------------------------+----------+----------
 2026-10-01 00:00:00+00 |    27.45 |   863999
 2026-10-02 00:00:00+00 |    27.45 |   864000
 2026-10-03 00:00:00+00 |    27.45 |   864000
(3 rows)

Time: 1867.155 ms

تقرير بسيط على ستة ملايين صف أخذ نحو ثانيتين من المعالج والقرص، وهذا مقبول لتقرير يطلب مرة في اليوم، وغير مقبول للوحة تحكم يفتحها مئة موظف كل صباح على سيرفر الطلبات نفسه، والحل الأول هو العرض المحفوظ الذي رأيناه في قسم الكاش، والحل الثاني نسخة قراءة Read Replica للتقارير، فإذا لم يكفيا فهناك إضافات تضيف محركاً عمودياً داخل PostgreSQL، وحالتها اليوم كما يلي:

  • pg_duckdb برخصة MIT، وهي الإضافة الرسمية من DuckDB وآخر إصدار لها 1.1.1 في ديسمبر 2025، وتدعم PostgreSQL من 14 إلى 18، وتنفذ استعلامات التحليل بمحرك DuckDB على جداولك أو على ملفات Parquet وIceberg.
  • pg_mooncake برخصة MIT، وهي تنشئ نسخة عمودية من جداولك بصيغة Iceberg، وآخر إصدار منشور على GitHub هو 0.1.2 في فبراير 2025، لذلك تعامل معها كمشروع في بدايته.
  • Hydra Columnar برخصة Apache-2.0، وآخر تحديث لمستودعها في فبراير 2025، وقد شارك فريقها في بناء pg_duckdb.
  • التخزين العمودي في TimescaleDB، وهو في نسخة Community بالرخصة الخاصة كما ذكرنا في قسم السلاسل الزمنية.

ولاحظ أن أياً من هذه الإضافات ليس في قائمة الإضافات المدعومة في Amazon RDS ولا في Cloud SQL عند كتابة هذا المقال، أي أنها تناسب PostgreSQL الذي تديره بنفسك.

⚠️
متى تحتاج مستودع بيانات Data Warehouse؟ عندما يحتاج الفريق أن يربط بيانات التطبيق ببيانات أنظمة أخرى مثل المبيعات والتسويق، أو عندما تصبح التقارير بالدقائق حتى على نسخة القراءة، أو عندما يكتب المحللون استعلامات لا تتوقعها فتثقل على سيرفر الإنتاج، وهنا انقل البيانات إلى مخزن تحليلي عمودي مثل ClickHouse أو BigQuery أو حتى ملفات Parquet مع DuckDB، ولا تشغل التحليل على السيرفر الذي يخدم العملاء.

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

كل ما سبق يعمل، ولكن الكلمة المهمة في الفكرة هي "واحدة"، فكل هذه الأعمال تتقاسم المعالج والذاكرة والقرص وسجل WAL والاتصالات وعمليات autovacuum نفسها، والصورة التالية تبين ذلك:

مخطط لقاعدة PostgreSQL واحدة في الوسط مع أشرطة استهلاك المعالج والذاكرة والقرص والاتصالات، ويتصل بها من اليسار طلبات API قصيرة وكثيرة وحساسة للزمن، و Workers الطابور الذين يتركون صفوفاً ميتة Dead Tuples، وكتابات الكاش بالآلاف في الثانية، ومن اليمين التقارير التي تقرأ 6 ملايين صف في ثانيتين، وبناء فهرس المتجهات في 19 ثانية بحجم 195 ميجابايت، وتحديث فهارس البحث مع كل كتابة. وفي الأسفل أربع مشكلات: الجار المزعج حيث يبطئ تقرير أو بناء فهرس الطلبات بجانبه، ونقطة الفشل الواحدة حيث يتوقف الطابور والكاش والبحث والواجهة معاً، والانتفاخ لأن الطوابير والكاش تعيد كتابة الصفوف، والاتصالات التي يتقاسمها ال Workers وجلسات LISTEN والواجهة ضمن max_connections الذي قيمته الافتراضية 100
كل الأحمال على سيرفر واحد: أربع مشكلات تظهر مع النمو
  • الجار المزعج Noisy Neighbour: تقرير ثقيل أو بناء فهرس HNSW أو تحديث عرض محفوظ يأخذ المعالج والقرص من طلبات المستخدمين في اللحظة نفسها، فيرتفع زمن الاستجابة في صفحة لا علاقة لها بالتقرير، والحل أن تنقل القراءات الثقيلة إلى نسخة قراءة، وأن تشغل الأعمال الثقيلة في ساعات الهدوء، وأن تضع حداً لوقت الاستعلامات بالإعداد statement_timeout لكل دور.
  • نقطة فشل واحدة Single Point of Failure: عندما يتوقف السيرفر يتوقف الطابور والكاش والبحث والواجهة معاً، وعندما تفسد ترقية فاشلة القاعدة تفقد كل شيء، لذلك فقاعدة لكل شيء تحتاج نسخة احتياطية Standby مع انتقال تلقائي Failover ونسخاً احتياطية جربت استعادتها، وقد شرحنا ذلك في عناقيد قواعد البيانات.
  • الانتفاخ Bloat وVACUUM: الطابور والكاش والجلسات أعمال تعيد كتابة الصفوف طوال اليوم، ورأينا أن مرور الطابور مرة واحدة ترك 20,000 صف ميت، وإذا لم يلحق autovacuum بها كبرت الجداول والفهارس وبطؤت، وأخطر من ذلك أن معاملة واحدة مفتوحة طويلاً تمنع تنظيف الجميع.
  • الاتصالات Connections: القيمة الافتراضية لـ max_connections هي 100 كما تذكر الوثائق، وكل Worker للطابور وكل جلسة LISTEN وكل نسخة من التطبيق تأخذ منها، والحل PgBouncer مع ترك جلسات LISTEN على اتصال مباشر، وقد شرحنا ذلك بالأرقام في تضاعف الاتصالات Connection Multiplication.
  • الإضافات في الخدمات المدارة: إذا كنت على خدمة مدارة فأنت محصور في قائمة إضافاتها، ففي قائمة Amazon RDS وقائمة Cloud SQL تجد pg_cron وpgvector وPostGIS وpg_trgm، ولا تجد TimescaleDB ولا pgmq ولا pg_duckdb، لذلك راجع القائمة قبل أن تبني ميزة على إضافة.
  • الترقية الكبرى: كل إضافة يجب أن يكون لها إصدار يدعم إصدار PostgreSQL الجديد قبل أن تستطيع الترقية، فكلما زادت الإضافات زاد ما تنتظره عند كل ترقية.

جدول القرار Decision Table

الجدول التالي يجمع ما رأيناه في كل حالة، ونقاط القوة ونقاط الضعف هي ما قسناه أو وثقناه أعلاه:

العملالنظام المعتادفي PostgreSQLنقاط القوةنقاط الضعفإشارة الفصل
طابور المهام Job QueueRabbitMQ، SQSFOR UPDATE SKIP LOCKED، pgmq، River، pg-bossالمهمة والبيانات في معاملة واحدة، ولا تكرارصفوف ميتة مع كل مهمة، ولا يوجد بث لعدة مستهلكينn_dead_tup يزيد رغم الضبط، أو تحتاج إعادة قراءة الأحداث
النشر والاشتراك Pub/SubRedis Pub/Sub، وسيط رسائلLISTEN/NOTIFYيصل بعد COMMIT فقط، دون نظام إضافيلا يحفظ، وحمولة أقل من 8000 بايت، وقفل عند COMMITآلاف عمليات الكتابة في الثانية ترسل إشعارات
التخزين المؤقت CacheRedis، Valkeyجدول UNLOGGED، عرض محفوظكتابة 0.43 مللي ثانية في المختبر، ونفس النسخ الاحتياطييفرغ بعد التوقف، ولا طرد تلقائي، ويأخذ من معالج السيرفرالكاش جزء كبير من وقت المعالج، أو تحتاج Eviction وعدادات سريعة
البحث النصي Full-Text SearchElasticsearch، OpenSearch، Meilisearchtsvector وGIN، إعداد arabic، pg_trgmبحث بالعربية مع توحيد، 6 مللي ثانية على 300 ألف مستندلا يوجد BM25 ولا Facets، وجموع التكسير لا ترجعالبحث هو المنتج، أو مئات الملايين من المستندات
المستندات DocumentsMongoDBJSONB مع GIN وفهارس التعبيراتقيود وعلاقات ومعاملات مع المرونةالمخطط لا يعرف الكثير عن الحقول الداخليةأغلب الاستعلامات والفهارس داخل JSONB
البيانات الجغرافية Geospatialأنظمة GIS متخصصةPostGIS مع GiSTهو المعيار نفسه، 0.5 مللي ثانية لأقرب خمسةلا يوجد توجيه طرق ولا تقديم مربعاتتحديث مواقع المركبات يأخذ أغلب الكتابة
المتجهات EmbeddingsQdrant، Pineconepgvector مع HNSW1.2 مللي ثانية واسترجاع 10 من 10 في المختبرالشرط WHERE بعد الفهرس، والفهرس أكبر من الجدولالفهرس لا يتسع في الذاكرة، أو الدقة لا تصل دون زمن كبير
السلاسل الزمنية Time SeriesInfluxDBالتقسيم، BRIN، pg_cron، TimescaleDBقسم واحد يقرأ، وحذف اليوم بأمر DROPلا يوجد ضغط في النسخة المفتوحة دون TimescaleDB Communityمئات الآلاف من القراءات في الثانية، أو القرص يسبق الميزانية
المهام المجدولة Scheduled Jobscron، Airflowpg_cronسجل تنفيذ في جدول، ولا سيرفر إضافيلا يوجد اعتماد بين المهام، والسيرفر الرئيسي فقطسلاسل مهام تعتمد على بعضها وتخرج من القاعدة
سجل الأحداث Event Logوسيط رسائل مع كود مزدوججدول Outboxالطلب والحدث معاً أو لا شيءالتسليم مرة واحدة على الأقلعدة مستهلكين يحتاجون بثاً مستمراً، فأضف Kafka بعد Outbox
التحليلات Analyticsمستودع بياناتعروض محفوظة، نسخة قراءة، pg_duckdbملخص في أقل من مللي ثانيةتقرير الملايين يأخذ ثواني من سيرفر الطلباتتقارير بالدقائق حتى على نسخة القراءة

متى تفصل؟ قائمة إشارات قابلة للقياس Signals

الخطأ الأول في هذا الموضوع أن تفصل لأن مقالاً قال إن Redis أسرع، والخطأ الثاني أن تبقي كل شيء في قاعدة واحدة لأن مقالاً قال إن PostgreSQL يكفي لكل شيء، والطريقة الصحيحة أن تصعد السلم درجة درجة، وأن لا تصعد إلا عندما تقول لك إشارة مقاسة ذلك، والصورة التالية تبين الدرجات:

سلم من أربع درجات: الأولى قس من يستخدم السيرفر بـ pg_stat_statements لكل عمل وزمن الاستجابة p99 للواجهة، والثانية أصلح داخل السيرفر نفسه بالفهارس والدفعات وضبط autovacuum لكل جدول وPgBouncer والتقسيم والاحتفاظ وهذا يكفي غالباً لوقت طويل، والثالثة قاعدة PostgreSQL ثانية أي نسخة قراءة للتقارير أو سيرفر منفصل للعمل المزعج بنفس المهارات والأدوات، والرابعة نظام متخصص مثل Redis أو Valkey أو محرك بحث أو وسيط رسائل أو مخزن تحليلي للعمل الذي تجاوز إشارته فقط، وفوقها عبارة اصعد درجة فقط عندما تقول إشارة مقاسة ذلك
أغلب الشركات الناشئة تبقى سنوات على الدرجتين الأولى والثانية

ولاحظ الدرجة الثالثة، فهي التي ينساها كثيرون، فعندما يزعج الطابور أو التقارير بقية الأعمال فالخطوة التالية غالباً قاعدة PostgreSQL ثانية لهذا العمل، بنفس المهارات ونفس أدوات النسخ الاحتياطي ونفس الكود، وليس نظاماً جديداً تتعلمه من الصفر. وفيما يلي قائمة الإشارات، وكل واحدة منها تقاس من PostgreSQL نفسه:

  1. حصة كل عمل من وقت السيرفر: اجمع total_exec_time في pg_stat_statements حسب العمل، فإذا أخذ الكاش أو الطابور أو البحث أكثر من نصف وقت السيرفر وهو ليس العمل الأساسي لمنتجك فقد حان وقت فصله.
  2. زمن الاستجابة للطلبات أثناء الأعمال الثقيلة: قارن p99 لواجهة التطبيق في ساعة تحديث العروض أو بناء الفهارس مع ساعة هادئة، فإذا تضاعف فالأعمال الثقيلة تحتاج سيرفراً آخر أو نسخة قراءة.
  3. الصفوف الميتة Dead Tuples: راقب n_dead_tup وlast_autovacuum في pg_stat_user_tables لجداول الطابور والكاش، فإذا كانت تزيد أسبوعاً بعد أسبوع رغم ضبط autovacuum فهذا العمل يكتب أكثر مما يحتمل الجدول.
  4. الاتصالات: إذا كان عدد الاتصالات في pg_stat_activity يقترب من max_connections في الذروة حتى مع PgBouncer، فوزع الأعمال على أكثر من سيرفر.
  5. طابور الإشعارات: إذا كانت pg_notification_queue_usage() أكبر من صفر بشكل مستمر، أو رأيت انتظار القفل على COMMIT في السجلات، فأخرج الإشعارات من مسار الكتابة.
  6. الذاكرة والفهارس: إذا كانت نسبة القراءة من الذاكرة في pg_statio_user_indexes لفهرس HNSW أو GIN تنخفض لأن الفهرس لم يعد يتسع، فالمتجهات أو البحث يحتاجان سيرفراً بذاكرة أكبر أو نظاماً متخصصاً.
  7. الدقة المطلوبة: للبحث والمتجهات حدد على أسئلة حقيقية نسبة النجاح التي تقبلها، فإذا لم تصل إليها دون زمن لا تقبله فالحل نظام متخصص وليس ضبطاً إضافياً.
  8. الميزة غير الموجودة: إذا احتجت شيئاً لا يوجد في PostgreSQL أصلاً، مثل طرد الكاش تلقائياً أو إعادة قراءة الأحداث من نقطة سابقة أو Facets في البحث، فلا تبنيه بنفسك فوق الجداول.

وعندما تفصل عملاً فافصل ذلك العمل وحده، وأبق الباقي حيث هو، فإذا فصلت البحث فلا تنقل الطابور معه، والسبب أن لكل عمل إشارته الخاصة.

أخطاء شائعة

  • طابور دون حذف المهام المنتهية: الجدول يكبر بلا نهاية رغم الفهرس الجزئي، فاحذف المنتهي أو انقله على دفعات أو قسم الجدول حسب اليوم.
  • الإشعار بدل الجدول: الاعتماد على NOTIFY وحده يضيع المهام عند أي انقطاع، فالجدول هو المصدر والإشعار تنبيه فقط.
  • بيانات مهمة في جدول UNLOGGED: رأينا أنه فرغ تماماً بعد التوقف المفاجئ، فلا تضع فيه إلا ما يمكن إعادة بنائه.
  • التوحيد في الكتابة دون السؤال: إذا وحدت النص المحفوظ ونسيت توحيد سؤال المستخدم فلن تتطابق الكلمات، فاستخدم الدالة نفسها في الجهتين.
  • الاعتماد على unaccent للحركات العربية: لا تحذفها، فاكتب دالة توحيد خاصة كما فعلنا.
  • شرط WHERE مع فهرس المتجهات دون اختبار: رأينا استعلاماً طلب خمس نتائج فرجع صفراً، فاختبر الاستعلامات بشروطها الحقيقية وفعل البحث المتكرر أو استخدم فهرساً جزئياً.
  • بناء فهرس HNSW أو تحديث عرض محفوظ في ساعة الذروة: كلاهما يأخذ ثواني إلى دقائق من المعالج، فجدولهما في وقت هادئ وراقب مدتهما في cron.job_run_details.
  • إضافة لا تدعمها الخدمة المدارة: بناء ميزة على إضافة ثم اكتشاف أن مزود الخدمة لا يدعمها يوم الانتقال، فراجع القائمة أولاً.
  • قاعدة واحدة دون نسخة احتياطية جاهزة: كل شيء في مكان واحد يعني أن توقف هذا المكان يوقف كل شيء، فقاعدة لكل شيء تحتاج Standby ونسخاً احتياطية جربت استعادتها أكثر من أي قاعدة أخرى.

حذف المختبر

بعد أن تنتهي احذف ال Container وال Volume المجهول الذي أنشأه، ولا تحذف غيرهما:

docker rm -f -v lab-pgall-pg

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • فكرة "Just use Postgres" تأتي من مبدأ اختيار التقنية المملة Boring Technology: كل نظام إضافي يكلف فريقك تعلماً وتشغيلاً، وقاعدة واحدة تعطيك نسخة احتياطية واحدة ومعاملة واحدة عبر الميزات ومهارة واحدة.
  • الطابور بـ FOR UPDATE SKIP LOCKED وزع 20,000 مهمة على اثنين من ال Workers دون تكرار، ولكنه ترك 20,000 صف ميت، فاستخدم أداة جاهزة مثل pgmq أو River أو pg-boss أو Graphile Worker واحذف المهام المنتهية.
  • LISTEN/NOTIFY جرس وليس صندوق بريد: يصل بعد COMMIT فقط، ولا يحفظ، وحمولته أقل من 8000 بايت، ويأخذ قفلاً عند COMMIT يصبح مشكلة مع آلاف عمليات الكتابة المتزامنة.
  • جدول UNLOGGED أسرع بعشر مرات في الكتابة في المختبر ولكنه فرغ تماماً بعد التوقف المفاجئ، والعرض المحفوظ مع pg_cron يحول تقرير الثانيتين إلى قراءة أقل من مللي ثانية.
  • PostgreSQL فيه إعداد بحث عربي arabic منذ الإصدار 12، ومع دالة توحيد للحروف تحولت النتائج من صفر إلى 5,357، وpg_trgm يلتقط الأخطاء الإملائية وجموع التكسير.
  • JSONB مع GIN للاحتواء وفهرس تعبير للمقارنات، وPostGIS هو المعيار نفسه، وpgvector أعطى 1.2 مللي ثانية واسترجاعاً كاملاً، مع فخ الشرط WHERE بعد الفهرس.
  • السلاسل الزمنية بالتقسيم اليومي وBRIN وpg_cron تقرأ قسماً واحداً وتحذف اليوم القديم بأمر DROP، وTimescaleDB خيار جيد بشرط أن تعرف أي ميزاتها بالرخصة الخاصة.
  • الثمن حقيقي: جار مزعج، ونقطة فشل واحدة، وانتفاخ، واتصالات محدودة، وإضافات قد لا تجدها في الخدمة المدارة، لذلك اصعد سلم الفصل درجة درجة، وابدأ بقاعدة PostgreSQL ثانية قبل أي نظام جديد، وافصل العمل الذي تجاوز إشارته وحده.

وفي مقالات قادمة بإذن الله سوف نشرح النسخ المتماثل والانتقال التلقائي مع Patroni، والنسخ الاحتياطي مع pgBackRest والاستعادة إلى نقطة زمنية، وهما ما تحتاجه أي قاعدة تحمل كل شيء.

دليلPostgreSQL للجداول الضخمة: عشرات الملايين من الصفوفماذا تفعل عندما يصل جدول الطلبات إلى عشرات الملايين من الصفوف وتبطئ لوحة التحكم ويمتلئ القرص، وكيف تقيس قبل أن تصلح.دليلConnection Pooling: لماذا الاتصال بقاعدة البيانات مكلفلماذا تظهر رسالة too many clients عندما تزيد نسخ تطبيقك، وكيف يحل مجمع الاتصالات وPgBouncer هذه المشكلة.دليلعناقيد قواعد البيانات Database Clustering ومتى تحتاجهاماذا تعني كلمة Cluster في كل قاعدة بيانات، ومتى تحتاج نسخاً أو انتقالاً تلقائياً، ومتى يكفيك سيرفر واحد مع نسخ احتياطية جيدة.دليلRAG للمبتدئين: نموذج لغة يجيب من مستنداتك على سيرفرككيف تجعل نموذج اللغة يجيب من مستندات مؤسستك ويذكر مصدره، دون أن يخرج أي مستند من سيرفرك.دليلتطبيق SaaS متعدد المستأجرين عملياً مع Row-Level Securityنبني على PostgreSQL تطبيق SaaS يعزل كل مستأجر Tenant عن غيره بسياسات Row-Level Security، ونرى أين تتسرب البيانات رغم ذلك.دليلخارطة CTO لشركة SaaS ناشئة من اليوم الأول إلى خمسين موظفاًماذا يجهز المدير التقني في كل مرحلة من نمو شركة SaaS ناشئة، وما الذي يؤجله حتى يحتاجه فعلاً.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال واختباره على PostgreSQL 18.6 مع PostGIS 3.6.4 وpgvector 0.8.7 وpg_cron 1.6.8 وpg_trgm وunaccent في Container واحد.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.