> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# ضبط أداء PostgreSQL بالقياس وليس بالتخمين
- URL: https://arabroot.io/articles/ضبط-اداء-postgresql-بالقياس/
- Published: 2026-10-10T19:30:00.000Z
- Updated: 2026-10-11T11:18:05.000Z
- Description: كيف تضبط ذاكرة PostgreSQL ونقاط التفتيش وال autovacuum، وتقيس أثر كل إعداد على نفس الحمل قبل أن تعتمده.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, هندسة البرمجيات وتصميم الأنظمة, قواعد البيانات, DevOps وCI/CD, الاستضافة الذاتية

عندما يبطئ التطبيق، أول ما يفعله كثير من الفرق هو البحث عن «أفضل إعدادات PostgreSQL للأداء»، ثم نسخ ملف من مدونة أو من أداة مثل PGTune، وإعادة تشغيل القاعدة، والأمل في أن تختفي المشكلة. وأحياناً تتحسن الأمور فعلاً، وأحياناً لا يتغير شيء، وأحياناً تتوقف القاعدة في منتصف الليل لأن نظام التشغيل قتلها بعد أن نفدت الذاكرة. والسبب أن الإعدادات ليست وصفة واحدة، وإنما أرقام تعتمد على ذاكرة السيرفر، وعدد الاتصالات، ونوع العمل، والقرص. وفي [مقال PostgreSQL للجداول الضخمة](https://arabroot.io/articles/postgresql-%D9%84%D9%84%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B6%D8%AE%D9%85%D8%A9/#config) ذكرنا قيم البداية باختصار، وفي هذا المقال سوف نقيسها: نفس الحمل قبل الضبط وبعده بأرقام حقيقية، ثم نكسر كل إعداد عمداً لنرى أثره.

وسوف نناقش في هذا المقال ما يلي:

- من أين تبدأ: هل المشكلة في الإعدادات أصلاً؟
- الذاكرة: `shared_buffers` و`effective_cache_size` و`work_mem` و`maintenance_work_mem`، ولماذا يتضاعف بعضها مع الاتصالات.
- WAL ونقاط التفتيش Checkpoints و`wal_compression`.
- ال autovacuum: متى يعمل، ولماذا ينتظر طويلاً في الجداول الكبيرة، وكيف تضبطه لكل جدول.
- `random_page_cost` على أقراص SSD، والتجربة التي جعلت الاستعلام أبطأ بثلاث مرات.
- مختبر pgbench قبل الضبط وبعده، ومثال حقيقي لاستعلام يكتب على القرص لأن `work_mem` صغير.
- أدوات الحساب مثل PGTune وحدودها، والأخطاء الشائعة، وقائمة تحقق.

أما الاتصالات و PgBouncer وأوضاعه فقد شرحناها كاملة في [مقال تجميع الاتصالات Connection Pooling](https://arabroot.io/articles/%D8%AA%D8%AC%D9%85%D9%8A%D8%B9-%D8%A7%D9%84%D8%A7%D8%AA%D8%B5%D8%A7%D9%84%D8%A7%D8%AA-connection-pooling-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، ولن نكررها هنا إلا حيث تؤثر في حساب الذاكرة.

## من أين تبدأ؟

قبل أي إعداد اسأل: ما الذي يأخذ الوقت فعلاً؟ والإجابة تأتي من الإضافة `pg_stat_statements` التي تجمع لكل استعلام عدد مرات تنفيذه، ومتوسط وقته، وكم صفحة قرأ من الذاكرة ومن القرص، وكم كتب في ملفات مؤقتة. وقد شرحنا تفعيلها وقراءتها في [مقال الجداول الضخمة](https://arabroot.io/articles/postgresql-%D9%84%D9%84%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B6%D8%AE%D9%85%D8%A9/)، وقراءة خطة التنفيذ بالأمر `EXPLAIN` في [مقال الفهارس وقراءة EXPLAIN](https://arabroot.io/articles/%D8%A7%D9%84%D9%81%D9%87%D8%A7%D8%B1%D8%B3-indexes-%D9%88%D9%82%D8%B1%D8%A7%D8%A1%D8%A9-explain/). وهذا ما ظهر في المختبر بعد دقيقتين من الحمل بالإعدادات الافتراضية:

```text
                            query                             |  calls  | mean_ms | shared_blks_hit | shared_blks_read | temp_blks_written
--------------------------------------------------------------+---------+---------+-----------------+------------------+-------------------
 SELECT abalance FROM pgbench_accounts WHERE aid = $1         | 1186454 |    0.20 |         2917385 |          1880857 |                 0
 UPDATE pgbench_branches SET bbalance = bbalance + $1 WHERE b |   64222 |    1.94 |          289045 |                1 |                 0
 UPDATE pgbench_accounts SET abalance = abalance + $1 WHERE a |   64222 |    0.28 |          485578 |           106610 |                 0
 UPDATE pgbench_tellers SET tbalance = tbalance + $1 WHERE ti |   64222 |    0.26 |          324562 |               26 |                 0
 INSERT INTO pgbench_history (tid, bid, aid, delta, mtime) VA |   64222 |    0.02 |           65370 |                5 |                 0
```

وهنا معلومتان مختلفتان تماماً: الاستعلام الأول قرأ 1.88 مليون صفحة من خارج `shared_buffers`، وهذه مشكلة ذاكرة يحلها الضبط، أما تحديث `pgbench_branches` فمتوسطه 1.94 مللي ثانية مقابل 0.28 لتحديث الحسابات رغم أنه يقرأ من الذاكرة كلياً، والسبب أن الجدول فيه 100 صف فقط يتنافس عليها 16 اتصالاً، فالوقت انتظار للأقفال Locks، ولا يوجد إعداد ذاكرة يحل ذلك، وإنما تصميم الكتابة كما في [مقال الأقفال والجمود](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%82%D9%81%D8%A7%D9%84-locks-%D9%88%D8%A7%D9%84%D8%AC%D9%85%D9%88%D8%AF-deadlock-%D9%81%D9%8A-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA/). وتذكر القاعدة: الضبط يحسن ما يحدث داخل السيرفر الواحد، ولكنه لن يعوض فهرساً ناقصاً أو استعلاماً يقرأ الجدول كله، وهذه عادة تعطي أضعاف ما يعطيه أي إعداد، وإذا كان نفس الاستعلام يتكرر آلاف المرات بنفس النتيجة فالحل قد يكون ألا يصل إلى القاعدة أصلاً، كما في [مقال التخزين المؤقت Caching](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%AE%D8%B2%D9%8A%D9%86-%D8%A7%D9%84%D9%85%D8%A4%D9%82%D8%AA-caching-%D8%A7%D9%84%D8%A7%D8%B3%D8%AA%D8%B1%D8%A7%D8%AA%D9%8A%D8%AC%D9%8A%D8%A7%D8%AA-%D9%88%D8%A7%D9%84%D8%A7%D8%A8%D8%B7%D8%A7%D9%84/).

## المختبر: نفس الحمل قبل الضبط وبعده

شغلنا PostgreSQL 18.6 في Container محدود بأربع أنوية و4 جيجابايت ذاكرة، كأنه سيرفر صغير مخصص للقاعدة، مع قاعدة `pgbench` بمعامل 100 حجمها 1.5 جيجابايت، أي أكبر من الإعداد الافتراضي `shared_buffers = 128MB` بكثير وأصغر من الذاكرة الكلية. والإعدادات في ملفات منفصلة في مجلد `conf.d` يقرؤها PostgreSQL بعد ملفه الرئيسي، فتراجعها في Git بدل أن تضيع في `ALTER SYSTEM`:

```yaml
# One PostgreSQL limited to 4 CPUs and 4 GB, first with the defaults, then with conf/10-tuned.conf included.
name: lab-tuning
services:
  db:
    image: postgres:18.6
    container_name: lab-tuning-db
    environment:
      POSTGRES_PASSWORD: lab-only-password
    volumes:
      - lab-tuning-data:/var/lib/postgresql
      - ./conf.d:/etc/postgresql/conf.d:ro
      - ./initdb:/docker-entrypoint-initdb.d:ro
    shm_size: 1g
    stop_grace_period: 2m   # a checkpoint at shutdown with a large shared_buffers can take more than the 10s default
    cpus: 4
    mem_limit: 4g
    ports:
      - "127.0.0.1:55433:5432"
volumes:
  lab-tuning-data:
    name: lab-tuning-data
```

والسكربت يشغل نفس الحمل من Container منفصل: تسخين 30 ثانية، ثم دقيقتان من القراءة والكتابة على طريقة TPC-B، ثم دقيقتان من القراءة فقط، مع 16 اتصالاً، ويجمع بعد كل مرحلة عدد نقاط التفتيش وحجم WAL ونسبة القراءة من الذاكرة:

```bash
#!/bin/sh
# Same load before and after tuning: 16 clients, read-write (TPC-B like) then read-only, from a separate container.
# usage: sh bench.sh <label>
set -eu
NET=lab-tuning_default
q() { docker exec -u postgres lab-tuning-db psql -XAt -c "$1"; }
run() { docker run --rm --network $NET --cpus 2 -e PGPASSWORD=lab-only-password postgres:18.6 \
          pgbench -h db -U postgres -c 16 -j 4 -T 120 -n "$@" postgres 2>&1 | grep -E "^(tps|latency average)"; }
# warm-up so both runs start with the same cache state
docker run --rm --network $NET --cpus 2 -e PGPASSWORD=lab-only-password postgres:18.6 pgbench -h db -U postgres -c 16 -j 4 -T 30 -n -S postgres >/dev/null 2>&1
q "SELECT pg_stat_reset_shared('checkpointer'); SELECT pg_stat_reset_shared('wal'); SELECT pg_stat_reset(); SELECT pg_stat_statements_reset();" >/dev/null
echo "== $1 read-write"; run
q "SELECT 'checkpoints timed=' || num_timed || ' requested=' || num_requested || ' buffers_written=' || buffers_written FROM pg_stat_checkpointer"
q "SELECT 'wal=' || pg_size_pretty(wal_bytes) || ' full_page_images=' || wal_fpi FROM pg_stat_wal"
q "SELECT 'cache_hit=' || round(100.0*blks_hit/(blks_hit+blks_read),2) || '% blks_read=' || blks_read FROM pg_stat_database WHERE datname='postgres'"
q "SELECT pg_stat_reset();" >/dev/null
echo "== $1 read-only"; run -S
q "SELECT 'cache_hit=' || round(100.0*blks_hit/(blks_hit+blks_read),2) || '% blks_read=' || blks_read FROM pg_stat_database WHERE datname='postgres'"
```

وملف الضبط الذي أضفناه في المرحلة الثانية:

```ini
# starting values for a dedicated 4 vCPU / 4 GB database server with SSD, about 100 connections through a pool
shared_buffers = 1GB                    # ~25% of RAM
effective_cache_size = 3GB              # ~75% of RAM: a hint for the planner, allocates nothing
work_mem = 16MB                         # per sort/hash node, per connection
maintenance_work_mem = 256MB            # VACUUM, CREATE INDEX
max_wal_size = 4GB                      # fewer forced checkpoints under heavy writes
checkpoint_timeout = 15min
checkpoint_completion_target = 0.9     # the default since PostgreSQL 14
wal_compression = zstd
random_page_cost = 1.1                  # SSD: random reads cost about the same as sequential
effective_io_concurrency = 200
autovacuum_vacuum_scale_factor = 0.05   # vacuum after 5% dead rows instead of 20%
autovacuum_analyze_scale_factor = 0.02
```

والنتائج كما يلي، وكل رقم من تشغيل حقيقي:

| التشغيل                        | قراءة وكتابة tps | زمن الاستجابة | قراءة فقط tps | نقاط تفتيش بسبب WAL | حجم WAL | القراءة من الذاكرة (كتابة / قراءة) |
| ------------------------------ | ---------------- | ------------- | ------------- | ------------------- | ------- | ---------------------------------- |
| الافتراضي، المرة الأولى        | 535              | 29.9 ms       | 9364          | 1                   | 618 MB  | 93.1% / 58.3%                      |
| الافتراضي، المرة الثانية       | 486              | 32.9 ms       | 5016          | 1                   | 421 MB  | 91.6% / 58.3%                      |
| بعد الضبط، المرة الأولى        | 892              | 17.9 ms       | 10296         | 0                   | 121 MB  | 98.3% / 90.5%                      |
| بعد الضبط، المرة الثانية       | 1031             | 15.5 ms       | 11028         | 0                   | 121 MB  | 97.6% / 90.5%                      |
| بعد الضبط دون wal\_compression | 1389             | 11.5 ms       | 12460         | 0                   | 550 MB  | 98.0% / 90.5%                      |

💡

كل الأرقام من جهاز واحد عليه Docker Desktop، وكان المعالج مشغولاً بأعمال أخرى أثناء القياس (حمل النظام 96% في أحد القياسات)، لذلك يختلف التشغيل الواحد عن تكراره بنسبة كبيرة، كما ترى في الصفين الأولين. فخذ الاتجاه وليس الرقم: الضبط ضاعف تقريباً عدد معاملات الكتابة، أما ال tps على سيرفرك فلن يكون هذا الرقم.

في النتائج أعلاه لاحظ التالي:

- أكبر فرق جاء من `shared_buffers`: نسبة القراءة من الذاكرة في القراءة فقط ارتفعت من 58% إلى 90%، لأن الجدول 1.5 جيجابايت لا يدخل في 128 ميجابايت، فكانت كل قراءة تقريباً تمر بذاكرة نظام التشغيل أو القرص.
- عدد نقاط التفتيش التي طلبها امتلاء WAL انخفض من واحدة في كل دقيقتين إلى صفر، وفي السجل كانت النقطة بالإعداد الافتراضي تبدأ بالسبب `wal` وليس `time`.
- الإعداد `wal_compression = zstd` خفض حجم WAL من 550 إلى 121 ميجابايت لنفس الحمل، ولكن التشغيل دونه كان أسرع على جهازنا، والسبب المرجح أن المعالج كان مشغولاً والضغط يأخذ من وقته، لذلك هو مفيد عندما يكون القرص أو الشبكة إلى النسخ المتماثلة Replicas أو الأرشيف هو الحد، وليس المعالج، وقرر بعد أن تقيس على سيرفرك.

والآن سوف نأخذ كل إعداد على حدة، ما الذي يفعله، وكيف تختار قيمته، وما الذي ينكسر إذا أخطأت.

## الذاكرة: ما المشترك وما الذي يتضاعف

الصورة التالية تبين أين تذهب ذاكرة سيرفر قاعدة البيانات:

![شريط يمثل ذاكرة السيرفر 4 جيجابايت: جزء shared_buffers بحجم 1 جيجابايت أي 25% مشترك بين كل العمليات، وجزء ذاكرة نظام التشغيل Page Cache حيث يحفظ Linux صفحات الملفات وeffective_cache_size رقم يخبر المخطط بمجموعهما ولا يحجز شيئاً، وجزء ذاكرة كل عملية فيه work_mem وmaintenance_work_mem، وتحته مثال: مئة اتصال كل منها فيه عملية ترتيب Sort وعملية Hash بحجم 16 ميجابايت يعني 3.2 جيجابايت في أسوأ حالة وهو أكثر من الذاكرة الباقية، وبجانبه حالة work_mem الصغير حيث يكتب الترتيب على القرص external merge Disk 50896kB](https://arabroot.io/content/images/2026/10/postgres-tuning-01-memory.webp)

الذاكرة المشتركة رقم ثابت، أما work\_mem فيتضاعف مع عدد الاتصالات وعدد العمليات في كل استعلام

### shared\_buffers

هو الذاكرة التي يحفظ فيها PostgreSQL صفحات الجداول والفهارس، وتقول [الوثائق الرسمية](https://www.postgresql.org/docs/18/runtime-config-resource.html?ref=arabroot.io) إن نقطة البداية المعقولة لسيرفر مخصص بذاكرة 1 جيجابايت أو أكثر هي 25% من الذاكرة، وإنه من غير المرجح أن يكون أكثر من 40% أفضل، والسبب أن PostgreSQL يقرأ الملفات عبر نظام التشغيل الذي يحتفظ بنسخة منها في ذاكرته Page Cache، فإذا أعطيت PostgreSQL أكثر من اللازم بقيت نفس الصفحة في مكانين وضاقت ذاكرة نظام التشغيل. وتغييره يحتاج إعادة تشغيل القاعدة.

وهنا درس من المختبر: بعد رفعه إلى 1 جيجابايت أعدنا تشغيل ال Container بالأمر `docker restart`، فبدأت القاعدة برسالة `database system was not properly shut down; automatic recovery in progress`، والسبب أن Docker ينتظر عشر ثوان افتراضياً ثم يقتل العملية، والإغلاق السليم يكتب كل الصفحات المعدلة في نقطة تفتيش أخيرة، وهذا مع ذاكرة كبيرة وقرص بطيء قد يأخذ أكثر من عشر ثوان. لذلك أضفنا `stop_grace_period: 2m` في ملف Compose، فالاستعادة بعد التوقف المفاجئ آمنة ولكنها تؤخر بدء القاعدة دون داع.

### effective\_cache\_size

هذا الإعداد لا يحجز أي ذاكرة، وإنما يخبر المخطط Query Planner بتقديرك لحجم الذاكرة المتاحة للتخزين المؤقت في PostgreSQL ونظام التشغيل معاً، فيقرر على أساسه هل قراءة الفهرس ستجد الصفحات في الذاكرة غالباً، كما في [وثائق إعدادات المخطط](https://www.postgresql.org/docs/18/runtime-config-query.html?ref=arabroot.io). والقيمة المعتادة 50% إلى 75% من ذاكرة السيرفر المخصص، وخطؤه لا يسبب نفاد ذاكرة، وإنما خططاً أسوأ.

### work\_mem: الإعداد الذي يتضاعف

هو حد الذاكرة لكل عملية ترتيب Sort أو جدول Hash داخل الاستعلام قبل أن يكتب إلى ملفات مؤقتة على القرص، والقيمة الافتراضية 4 ميجابايت. والنقطة التي يخطئ فيها الكثيرون أنه ليس حداً للاتصال، فالاستعلام الواحد قد يحتوي عدة عمليات ترتيب وHash، وكل منها يأخذ حتى هذا الحد، وعمليات Hash يمكن أن تأخذ أضعافه بحسب `hash_mem_multiplier` الذي قيمته الافتراضية 2، وكل اتصال يشغل استعلامه الخاص. فإذا جعلته 64 ميجابايت مع 100 اتصال، فالأسوأ ليس 6.4 جيجابايت، وإنما أكثر من ذلك، وهذا ما يجعل نظام التشغيل يقتل PostgreSQL بأداة OOM Killer في ساعة الذروة وليس في الاختبار.

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

```sql
SET max_parallel_workers_per_gather = 0;
CREATE TABLE sales AS
  SELECT g AS id, (random()*1e9)::bigint AS k, (random()*500)::int AS customer_id,
         round((random()*100)::numeric, 2) AS amount
  FROM generate_series(1, 2000000) g;
VACUUM ANALYZE sales;

SET work_mem = '4MB';
EXPLAIN (ANALYZE, COSTS OFF) SELECT id, k FROM sales ORDER BY k;
SET work_mem = '128MB';
EXPLAIN (ANALYZE, COSTS OFF) SELECT id, k FROM sales ORDER BY k;
```

والمخرج سوف يكون كما يلي (أفضل وقت من ثلاث مرات لكل حالة):

```text
work_mem = 4MB
   Sort Method: external merge  Disk: 50896kB
 Execution Time: 5237.745 ms

work_mem = 128MB
   Sort Method: quicksort  Memory: 111653kB
 Execution Time: 4592.029 ms
```

وفي سجل PostgreSQL ظهر السطر التالي بسبب الإعداد `log_temp_files = 0`:

```text
LOG:  temporary file: path "base/pgsql_tmp/pgsql_tmp186.0", size 52117504
```

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

- `external merge Disk: 50896kB` يعني أن الترتيب قسم البيانات إلى أجزاء وكتبها على القرص ثم دمجها، و`quicksort Memory` يعني أنه رتبها كلها في الذاكرة، وهذا السطر في `EXPLAIN ANALYZE` هو الطريقة الأوضح لتعرف أن `work_mem` صغير لاستعلام معين.
- الترتيب في الذاكرة احتاج 109 ميجابايت تقريباً، بينما الملفات على القرص 50 ميجابايت، والسبب أن الترتيب في الذاكرة يحفظ معلومات إضافية لكل صف، فلا تجعل `work_mem` بحجم ملف القرص الذي تراه وتتوقع أن يكفي.
- الفرق في الوقت هنا 12% فقط، والسبب أن الملف المؤقت بقي في ذاكرة نظام التشغيل ولم يصل فعلاً إلى القرص البطيء، وعلى سيرفر مشغول بذاكرة ممتلئة يكون الفرق أكبر بكثير، والمهم أن ترى الملفات المؤقتة في السجل وفي `temp_blks_written` قبل أن تشعر بالبطء.

والقرار الصحيح: أبق القيمة العامة متواضعة، واحسبها تقريباً هكذا: الذاكرة المتبقية بعد `shared_buffers` مقسومة على عدد الاتصالات الحقيقية مضروبة في عدد عمليات الترتيب المتوقعة، ثم ارفعها لدور التقارير فقط `ALTER ROLE reports SET work_mem = '256MB'` أو للاستعلام الثقيل داخل معاملته بالأمر `SET LOCAL work_mem`. ولاحظ أن مجمع الاتصالات يجعل هذا الحساب ممكناً، لأن عدد الاتصالات الحقيقية بالقاعدة يصبح رقماً صغيراً تعرفه.

### maintenance\_work\_mem

هو الذاكرة لعمليات الصيانة: `VACUUM` و`CREATE INDEX` و`ALTER TABLE ADD FOREIGN KEY`، والقيمة الافتراضية 64 ميجابايت. وبما أن هذه العمليات قليلة في نفس الوقت، فرفعه آمن نسبياً، وفي المختبر عندما أنشأنا فهرساً على عمود `k` في جدول المليوني صف، كتب PostgreSQL بالقيمة الافتراضية ملفين مؤقتين بحجم 28 و40 ميجابايت، وبقيمة 512 ميجابايت لم يكتب أي ملف. أما الأزمنة فلم نعتمدها، لأن نفس الأمر بنفس القيمة أخذ مرة 5 ثوان ومرة 38 ثانية بسبب انشغال الجهاز، وهذا بالضبط سبب تكرار القياس قبل أي استنتاج. وتذكر أن ال autovacuum يستخدم نفس القيمة لكل Worker إذا لم تحدد `autovacuum_work_mem`، فمع ثلاثة Workers وقيمة 1 جيجابايت قد تذهب 3 جيجابايت للتنظيف.

## WAL ونقاط التفتيش Checkpoints

كل تغيير يكتب أولاً في WAL، ثم تكتب الصفحات المعدلة إلى ملفات الجداول على مراحل، وكل فترة يضمن PostgreSQL أن كل الصفحات المعدلة حتى لحظة معينة وصلت إلى القرص، وهذه هي نقطة التفتيش Checkpoint، وبعدها يستطيع إعادة استخدام ملفات WAL القديمة، والتفاصيل في [وثائق إعدادات WAL](https://www.postgresql.org/docs/18/wal-configuration.html?ref=arabroot.io) وفي [مقال كيف تخزن قاعدة البيانات بياناتك من الداخل](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AE%D8%B2%D9%86-%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA%D9%83-%D9%85%D9%86-%D8%A7%D9%84%D8%AF%D8%A7%D8%AE%D9%84/). ونقطة التفتيش تبدأ لأحد سببين: مرور `checkpoint_timeout` وقيمته الافتراضية 5 دقائق، أو وصول حجم WAL منذ آخر نقطة إلى `max_wal_size` وقيمته الافتراضية 1 جيجابايت.

الصورة التالية تبين لماذا يهم ذلك:

![محوران زمنيان: في الأعلى max_wal_size بالقيمة الافتراضية 1 جيجابايت مع كتابة كثيرة، فيمتلئ WAL خمس مرات وتبدأ نقطة تفتيش حمراء في كل مرة بالسبب wal، وبعد كل نقطة كتلة برتقالية تمثل الصفحات الكاملة Full Page Images، وفي الأسفل max_wal_size بقيمة 4 جيجابايت وcheckpoint_timeout بقيمة 15 دقيقة فتحدث نقطة تفتيش واحدة بحسب الوقت موزعة على 90% من الفترة، مع صفحات كاملة أقل وWAL أقل وكتابة أهدأ على القرص، والثمن استعادة أطول بعد التوقف المفاجئ](https://arabroot.io/content/images/2026/10/postgres-tuning-02-checkpoints.webp)

النقاط المتقاربة تعني كتابة أكثر على القرص وWAL أكبر، والمتباعدة تعني استعادة أطول بعد التوقف

والسبب في أن النقاط المتقاربة تكلف أكثر مما تتوقع هو الصفحات الكاملة Full Page Images: أول تعديل على أي صفحة بعد نقطة التفتيش يكتب الصفحة كاملة (8 كيلوبايت) في WAL وليس التغيير الصغير فقط، حتى يستطيع PostgreSQL إصلاح الصفحة إذا انقطعت الكهرباء في منتصف كتابتها، فكلما تقاربت النقاط تكررت كتابة الصفحات الكاملة. وفي المختبر بالإعداد الافتراضي ظهر في السجل:

```text
LOG:  checkpoint starting: wal
LOG:  checkpoint complete: wrote 1295 buffers (7.9%), wrote 31 SLRU buffers; 0 WAL file(s) added, 0 removed, 33 recycled; write=94.474 s, sync=0.160 s, total=95.882 s; ... distance=540425 kB, estimate=540425 kB
```

كلمة `wal` بعد `checkpoint starting` تعني أن النقطة بدأت لأن WAL امتلأ وليس لأن الوقت حان، وإذا رأيتها كثيراً في السجل، أو رأيت التحذير `checkpoints are occurring too frequently`، أو رأيت العداد `num_requested` في الجدول `pg_stat_checkpointer` يزيد أسرع من `num_timed`، فارفع `max_wal_size`. والثمن الوحيد هو مساحة القرص لملفات WAL، ووقت أطول لاستعادة القاعدة بعد توقف مفاجئ لأنها تعيد تنفيذ WAL منذ آخر نقطة تفتيش. أما `checkpoint_completion_target = 0.9` فهو القيمة الافتراضية منذ PostgreSQL 14، ويوزع كتابة النقطة على 90% من الفترة بدل دفعة واحدة تخنق القرص.

و`wal_compression` يضغط الصفحات الكاملة في WAL، ويقبل منذ PostgreSQL 15 الخوارزميات `pglz` و`lz4` و`zstd`، وقد رأيت في المختبر أنه خفض WAL إلى الخمس تقريباً، وأن ثمنه وقت المعالج.

## ال autovacuum: لماذا ينتظر طويلاً في الجداول الكبيرة؟

في PostgreSQL التحديث `UPDATE` لا يغير الصف في مكانه، وإنما يكتب نسخة جديدة ويترك القديمة ميتة Dead Tuple حتى لا يراها أحد، ثم يأتي `VACUUM` لينظفها ويجعل مكانها قابلاً لإعادة الاستخدام، كما شرحنا في [مقال الجداول الضخمة](https://arabroot.io/articles/postgresql-%D9%84%D9%84%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B6%D8%AE%D9%85%D8%A9/). وال autovacuum يقرر متى ينظف الجدول بهذه المعادلة من [وثائق التنظيف الدوري](https://www.postgresql.org/docs/18/routine-vacuuming.html?ref=arabroot.io#AUTOVACUUM):

```text
vacuum threshold = min(autovacuum_vacuum_max_threshold,
                       autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples)
                 = min(100,000,000, 50 + 0.2 * rows)
```

أي أن الجدول بعشرة ملايين صف ينتظر مليوني صف ميت قبل التنظيف، والجدول بمليار صف كان ينتظر مئتي مليون قبل أن يضيف PostgreSQL 18 الحد الأعلى `autovacuum_vacuum_max_threshold` بقيمة مئة مليون. وفي المختبر حدثنا مليون صف من عشرة ملايين، وانتظرنا دقيقة ونصفاً:

```text
     relname      | n_live_tup | n_dead_tup | vacuum_after | last_autovacuum
------------------+------------+------------+--------------+-----------------
 pgbench_accounts |    9999844 |    1163613 |      2000019 | 
```

مليون ومئة ألف صف ميت ولم يبدأ التنظيف، لأن الحد مليونان. والحل الذي شرحناه في [مقال الجداول الضخمة](https://arabroot.io/articles/postgresql-%D9%84%D9%84%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B6%D8%AE%D9%85%D8%A9/#autovacuum-tuning) هو نسبة أصغر لهذا الجدول وحده:

```sql
ALTER TABLE pgbench_accounts SET (autovacuum_vacuum_scale_factor = 0.05, autovacuum_analyze_scale_factor = 0.02);
```

فبدأ التنظيف في الدورة التالية، والجديد هنا ليس النسبة، وإنما ما يقوله السجل عن سرعة التنظيف وثمنه، وقد ظهر بسبب `log_autovacuum_min_duration = 0`:

```text
LOG:  automatic vacuum of table "postgres.public.pgbench_accounts": index scans: 1
	pages: 0 removed, 180369 remain, 180369 scanned (100.00% of total), 0 eagerly scanned
	tuples: 1000048 removed, 10000000 remain, 0 are dead but not yet removable
	frozen: 176000 pages from table (97.58% of total) had 1518327 tuples frozen
	I/O timings: read: 4764.687 ms, write: 22783.781 ms
	avg read rate: 7.729 MB/s, avg write rate: 8.207 MB/s
	WAL usage: 734979 records, 374270 full page images, 2928154436 bytes, 1858 buffers full
	system usage: CPU: user: 13.10 s, system: 16.86 s, elapsed: 356.28 s
```

الصورة التالية تبين الفرق بين النسبتين:

![رسم بياني لعدد الصفوف الميتة في جدول بعشرة ملايين صف مع الوقت: خط أحمر متقطع عند الحد الافتراضي 2,000,050 أي 20%، وخط أخضر متقطع عند 500,050 أي 5% لهذا الجدول، الخط الأحمر يصعد طويلاً حتى يصل إلى الحد عند النقطة 1 ثم ينزل بتنظيف كبير، والخط الأخضر يصعد وينزل أربع مرات عند النقطة 2 بتنظيفات صغيرة، وفي الوسط نتيجة المختبر: 1,163,613 صفاً ميتاً دون autovacuum بنسبة 20% وتنظيف خلال دقائق بنسبة 5%](https://arabroot.io/content/images/2026/10/postgres-tuning-03-autovacuum.webp)

النسبة الافتراضية تؤجل التنظيف في الجداول الكبيرة حتى يصبح ثقيلاً، والنسبة الأصغر لجدول واحد تجعله أخف وأكثر تكراراً

في السجل أعلاه لاحظ التالي:

- التنظيف أخذ ست دقائق تقريباً بسرعة 8 ميجابايت في الثانية، وهذا ليس بطء القرص، وإنما حد مقصود: ال autovacuum يتوقف لحظة كلما أنفق قدراً معيناً من العمل، بحسب `autovacuum_vacuum_cost_limit` الذي يأخذ القيمة 200 من `vacuum_cost_limit` و`autovacuum_vacuum_cost_delay = 2ms`، حتى لا يزاحم الاستعلامات، كما في [وثائق إعدادات التنظيف](https://www.postgresql.org/docs/18/runtime-config-vacuum.html?ref=arabroot.io). وعلى سيرفر بقرص SSD سريع يرفع كثيرون الحد إلى 1000 أو أكثر حتى لا يتأخر التنظيف عن الكتابة.
- التنظيف جمد Froze 97% من صفحات الجدول، وكتب 2.9 جيجابايت من WAL، وهذا جزء طبيعي من عمله يحمي من نفاد أرقام المعاملات Transaction ID Wraparound، ولكنه يعني أن أول تنظيف لجدول كبير لم ينظف من قبل قد يكون ثقيلاً على القرص وعلى النسخ المتماثلة.
- غيرنا النسبة للجدول الكبير وحده وليس للسيرفر كله، والسبب أن الجداول الصغيرة لا تحتاج ذلك، وتغييرها يعني تنظيفاً أكثر دون فائدة.

ومتى تعرف أن ال autovacuum متأخر؟ عندما يزيد `n_dead_tup` في `pg_stat_user_tables` أياماً، أو يكبر حجم الجدول وعدد صفوفه ثابت، أو ترى أن `last_autovacuum` قديم لجدول يتغير كل دقيقة. ولا تقم بإيقاف ال autovacuum لأنه «يبطئ القاعدة»، والسبب أن الجدول عندها يكبر بالصفوف الميتة حتى تصبح كل قراءة أبطأ، ثم يجبرك PostgreSQL على تنظيف طارئ عندما تقترب أرقام المعاملات من نهايتها.

## random\_page\_cost على SSD: التجربة التي جعلت الاستعلام أبطأ

المخطط يقدر تكلفة كل خطة بأرقام نسبية، و`random_page_cost` هو تكلفة قراءة صفحة من مكان عشوائي مقارنة بالقراءة المتتابعة `seq_page_cost = 1`، وقيمته الافتراضية 4.0 من زمن الأقراص الدوارة. والنصيحة المنتشرة أن تجعله 1.1 مع SSD، ووثائق PostgreSQL نفسها تذكر أن القيمة الأقل مناسبة للأقراص التي يكون فيها الوصول العشوائي قريباً من المتتابع. ولكن لنرى ماذا حدث لاستعلام على جدول المبيعات بعد إضافة فهرس على عمود `customer_id` الذي قيمه موزعة عشوائياً على الصفحات:

```sql
CREATE INDEX sales_customer_idx ON sales (customer_id);
ANALYZE sales;
SET random_page_cost = 4;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT sum(amount) FROM sales WHERE customer_id BETWEEN 1 AND 200;
SET random_page_cost = 1.1;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT sum(amount) FROM sales WHERE customer_id BETWEEN 1 AND 200;
```

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

```text
== random_page_cost=4
Aggregate (actual time=923.145..923.147 rows=1.00 loops=1)
  Buffers: shared hit=15392
  ->  Bitmap Heap Scan on sales (actual time=206.140..581.158 rows=798931.00 loops=1)
        Recheck Cond: ((customer_id >= 1) AND (customer_id <= 200))
        Heap Blocks: exact=14706
        ->  Bitmap Index Scan on sales_customer_idx (actual time=192.194..192.195 rows=798931.00 loops=1)
Execution Time: 923.189 ms

== random_page_cost=1.1
Aggregate (actual time=2707.941..2707.943 rows=1.00 loops=1)
  Buffers: shared hit=701003
  ->  Index Scan using sales_customer_idx on sales (actual time=0.017..2380.489 rows=798931.00 loops=1)
Execution Time: 2707.973 ms
```

الاستعلام يطلب 40% من الجدول، وبالقيمة 4 اختار المخطط Bitmap Heap Scan الذي يجمع الصفحات المطلوبة ثم يقرأ كل صفحة مرة واحدة، 15 ألف صفحة. وبالقيمة 1.1 أصبحت القراءة العشوائية رخيصة في حسابه فاختار Index Scan الذي يزور الصفحة مع كل صف، فقرأ 701 ألف صفحة وكان أبطأ بثلاث مرات تقريباً، رغم أن كل الصفحات كانت في الذاكرة. والدرس ليس أن 1.1 خطأ، فمع استعلامات تطلب صفوفاً قليلة يجعل المخطط يستخدم الفهارس بدل قراءة الجدول كله وهذا غالباً ما تريده على SSD، وإنما أن كل إعداد للمخطط يغير خططاً لم تكن تفكر فيها، لذلك قارن أبطأ الاستعلامات في `pg_stat_statements` قبل التغيير وبعده، أو جرب القيمة لجلسة واحدة بالأمر `SET` قبل أن تكتبها في الملف.

## أدوات الحساب مثل PGTune: نقطة بداية وليست إجابة

أداة [PGTune](https://github.com/le0pard/pgtune?ref=arabroot.io) تسألك عن الذاكرة والمعالج ونوع العمل وعدد الاتصالات ونوع القرص، ثم تحسب القيم بمعادلات ثابتة قريبة مما في ملف الضبط أعلاه: ربع الذاكرة ل `shared_buffers`، وثلاثة أرباعها ل `effective_cache_size`، و`work_mem` بقسمة الباقي على الاتصالات. وهي مفيدة كبداية لأنها تمنعك من الأرقام المجنونة، ولكن حدودها واضحة:

- لا تعرف أن نصف ذاكرة السيرفر يأخذها تطبيق آخر يعمل بجانب القاعدة، أو أن ال Container محدود بذاكرة أقل من ذاكرة السيرفر.
- تحسب `work_mem` من `max_connections` وليس من الاتصالات الحقيقية خلف المجمع، ولا تعرف أن عندك تقارير تحتاج قيمة أكبر لدور واحد.
- لا تعرف شيئاً عن جداولك: أيها كبير ويحتاج autovacuum خاصاً، وأيها عليه أقفال.
- القيم المرتفعة فيها قد تكون صحيحة لسيرفر مخصص، وخطيرة لسيرفر مشترك.

فاستخدمها لتعرف من أين تبدأ، ثم غير قيمة واحدة في كل مرة، وقس نفس الحمل قبل وبعد، واحتفظ بالقيمة التي أثبتت فائدتها فقط.

## أخطاء شائعة

- **رفع `max_connections` إلى آلاف بدل مجمع اتصالات:** كل اتصال عملية بذاكرتها، و`work_mem` يتضاعف معها.
- **`work_mem` كبير للسيرفر كله:** يعمل في الاختبار ويقتل القاعدة في الذروة، فارفعه لدور أو استعلام.
- **`shared_buffers` بأكثر من نصف الذاكرة:** يضيق ذاكرة نظام التشغيل التي يعتمد عليها PostgreSQL أيضاً.
- **إيقاف ال autovacuum أو تركه بإعداداته على جداول بمئات الملايين:** انتفاخ Bloat ثم تنظيف طارئ.
- **نسخ ملف إعدادات دون قياس:** لا تعرف أي سطر أفاد وأيها أضر.
- **قياس مرة واحدة:** في مختبرنا اختلف نفس التشغيل عن تكراره بأكثر من 40% في القراءة فقط.
- **تغيير إعدادات المخطط للسيرفر كله لإصلاح استعلام واحد:** يغير خطط استعلامات أخرى كانت جيدة.

## قائمة تحقق

1. هل عرفت أبطأ الاستعلامات من `pg_stat_statements`، وهل السبب ذاكرة أم فهرس أم أقفال؟
2. هل `shared_buffers` قريب من ربع الذاكرة المتاحة للقاعدة فعلاً، وهل تراقب نسبة القراءة من الذاكرة؟
3. هل حسبت `work_mem` مع عدد الاتصالات الحقيقية خلف المجمع، وهل `log_temp_files` مفعل؟
4. هل نقاط التفتيش تبدأ بالوقت غالباً وليس بامتلاء WAL؟
5. هل للجداول الكبيرة نسب autovacuum خاصة، وهل حد التكلفة مناسب لقرصك؟
6. هل غيرت إعدادات المخطط بعد قياس الاستعلامات المهمة قبل وبعد؟
7. هل الإعدادات في ملفات تراجع في Git، مع `stop_grace_period` كاف في Compose؟

## الخلاصة

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

- ابدأ من `pg_stat_statements` لتعرف هل المشكلة ذاكرة أم استعلام أم أقفال، فالضبط لا يصلح الأخيرين.
- في المختبر ضاعف الضبط تقريباً عدد معاملات الكتابة على نفس الجهاز، وأغلب الفرق جاء من `shared_buffers` الذي رفع القراءة من الذاكرة من 58% إلى 90%، ومن `max_wal_size` الذي أوقف نقاط التفتيش المتقاربة.
- `work_mem` حد لكل عملية ترتيب أو Hash في كل اتصال، فأبقه متواضعاً وارفعه لدور أو استعلام، والسطر `external merge Disk` يخبرك متى يكون صغيراً.
- النسبة الافتراضية لل autovacuum تنتظر مليوني صف ميت في جدول بعشرة ملايين، فاضبط النسبة للجداول الكبيرة وحدها، وراجع حد التكلفة.
- `random_page_cost = 1.1` جعل أحد استعلاماتنا أبطأ بثلاث مرات، فكل إعداد للمخطط يحتاج قياساً قبل وبعد.
- PGTune وأمثالها نقطة بداية، والقياس المتكرر هو الذي يقرر.

[![](https://arabroot.io/content/images/2026/10/connection-pooling-cover-cover.webp)دليلConnection Pooling: لماذا الاتصال بقاعدة البيانات مكلفلماذا تظهر رسالة too many clients عندما تزيد نسخ تطبيقك، وكيف يحل مجمع الاتصالات وPgBouncer هذه المشكلة.](https://arabroot.io/articles/%D8%AA%D8%AC%D9%85%D9%8A%D8%B9-%D8%A7%D9%84%D8%A7%D8%AA%D8%B5%D8%A7%D9%84%D8%A7%D8%AA-connection-pooling-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/postgres-large-tables-cover-cover.webp)دليلPostgreSQL للجداول الضخمة: عشرات الملايين من الصفوفماذا تفعل عندما يصل جدول الطلبات إلى عشرات الملايين من الصفوف وتبطئ لوحة التحكم ويمتلئ القرص، وكيف تقيس قبل أن تصلح.](https://arabroot.io/articles/postgresql-%D9%84%D9%84%D8%AC%D8%AF%D8%A7%D9%88%D9%84-%D8%A7%D9%84%D8%B6%D8%AE%D9%85%D8%A9/)[![](https://arabroot.io/content/images/2026/10/db-indexes-cover-cover.webp)دليلالفهارس Indexes: لماذا تسرع الاستعلام وكيف تقرأ EXPLAINكيف يبحث PostgreSQL داخل الفهرس، ومتى يتجاهله، وكم يكلفك كل فهرس عند الكتابة، وكيف تقرأ خطة EXPLAIN سطراً سطراً.](https://arabroot.io/articles/%D8%A7%D9%84%D9%81%D9%87%D8%A7%D8%B1%D8%B3-indexes-%D9%88%D9%82%D8%B1%D8%A7%D8%A1%D8%A9-explain/)[![](https://arabroot.io/content/images/2026/10/postgres-setup-cover-cover.webp)دليلPostgreSQL للفرق: تثبيت آمن وصلاحيات واضحةكيف تثبت PostgreSQL لفريقك بأدوار لا تسمح للتطبيق بحذف الجداول، واتصال مشفر، ونسخة احتياطية تختبر استعادتها كل ليلة.](https://arabroot.io/articles/postgresql-%D9%84%D9%84%D9%81%D8%B1%D9%82-%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-%D8%A7%D9%85%D9%86-%D9%88%D8%B5%D9%84%D8%A7%D8%AD%D9%8A%D8%A7%D8%AA-%D9%88%D8%A7%D8%B6%D8%AD%D8%A9/)

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

- أكتوبر 2026: كتابة المقال واختباره على PostgreSQL 18.6 (الصورة postgres:18.6) في Container محدود بأربع أنوية و4 جيجابايت، مع pgbench 18.6 وDocker Engine 29.8.0.