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

الفهارس Indexes: لماذا تسرع الاستعلام وكيف تقرأ EXPLAIN

كيف يبحث PostgreSQL داخل الفهرس، ومتى يتجاهله، وكم يكلفك كل فهرس عند الكتابة، وكيف تقرأ خطة EXPLAIN سطراً سطراً.

الفهارس Indexes: لماذا تسرع الاستعلام وكيف تقرأ EXPLAIN

يكتب المطور استعلاماً يعمل في أجزاء من الثانية على جهازه، ثم يصل إلى الإنتاج فيأخذ ثوان، فيضيف فهرساً Index ويعود الاستعلام سريعاً، ثم يضيف فهرساً آخر لاستعلام آخر، وبعد سنة يجد أن الجدول عليه تسعة فهارس، وأن الإدخال أصبح بطيئاً، وأن بعض الاستعلامات ما زالت تقرأ الجدول كله مع أن الفهرس موجود. والسبب في أغلب هذه الحالات أن الفهرس يعامل كصندوق أسود: نضيفه ونأمل أن يستخدم، بدل أن نفهم كيف يبحث فيه PostgreSQL، ومتى يقرر المخطط Planner أن قراءة الجدول كله أرخص منه، وما الذي يدفعه كل INSERT وUPDATE مقابل كل فهرس.

وقد ذكرنا فكرة الفهرس في الجزء الثالث من سلسلة تعلم SQL، وفي تصميم قواعد البيانات، وطبقنا الفهرس المركب والجزئي والمغطي وBRIN على جدول طلبات من 30 مليون صف في PostgreSQL للجداول الضخمة، أما هذا المقال فيفتح الفهرس من الداخل، ويعلمك أن تقرأ خطة EXPLAIN سطراً سطراً حتى تعرف السبب بنفسك في المرة القادمة.

دليلتعلم SQL (3): CTE ودوال النوافذ والمعاملاتكيف تكتب استعلامات أوضح بجمل WITH ودوال النوافذ، وتحمي بياناتك بالمعاملات ومستويات العزل ومن حقن SQL.دليلتصميم قواعد البيانات: التطبيع Normalization ومتى تكسرهلماذا يفشل الجدول الواحد الكبير، وكيف تقسمه إلى جداول لا تناقض نفسها، وما الذي يتغير عندما تكبر البيانات.دليلPostgreSQL للجداول الضخمة: عشرات الملايين من الصفوفماذا تفعل عندما يصل جدول الطلبات إلى عشرات الملايين من الصفوف وتبطئ لوحة التحكم ويمتلئ القرص، وكيف تقيس قبل أن تصلح.

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

  • كيف تقرأ خطة التنفيذ EXPLAIN (ANALYZE, BUFFERS) سطراً سطراً، وما معنى كل رقم فيها.
  • شجرة B-tree من الداخل بالإضافة pageinspect: كم صفحة تقرأ قاعدة البيانات لتجد صفاً واحداً من خمسة ملايين.
  • متى يكون الفهرس أبطأ من قراءة الجدول كله، ودور الانتقائية Selectivity والترابط Correlation.
  • ترتيب الأعمدة في الفهرس المركب، والبحث بالتخطي Skip Scan الجديد في PostgreSQL 18.
  • الاستعلامات التي لا تستخدم الفهرس رغم وجوده، وفهارس التعبيرات Expression Indexes وفهارس الأنماط.
  • فهارس GIN للنص وJSONB، والبحث داخل الفهرس دون قراءة الجدول Index Only Scan.
  • ثمن الفهرس عند الكتابة بالأرقام، والتحديث السريع HOT.
  • قراءة خطة فيها ربط Join، وما يحدث عندما يخطئ المخطط في تقدير عدد الصفوف.

تجهيز المختبر: خمسة ملايين مستخدم وعشرة ملايين طلب

سوف نعمل على سيرفر PostgreSQL 18.6 واحد في Docker، والمنفذ مربوط على 127.0.0.1 فقط، وملف ال Compose كما يلي:

name: lab-idx
services:
  pg:
    image: postgres:18.6
    container_name: lab-idx-pg
    environment:
      POSTGRES_PASSWORD: lab-only-password
      POSTGRES_DB: shop
    command: >
      postgres -c shared_buffers=512MB -c work_mem=32MB -c maintenance_work_mem=512MB
               -c max_wal_size=4GB -c track_io_timing=on
    ports:
      - "127.0.0.1:15441:5432"
    volumes:
      - lab-idx-data:/var/lib/postgresql
    shm_size: 1g
volumes:
  lab-idx-data:

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

  • الإعداد track_io_timing=on يجعل EXPLAIN يعرض الوقت الذي ذهب في انتظار القرص في سطر I/O Timings، وهو مغلق افتراضياً.
  • ذاكرة shared_buffers هنا 512 ميجابايت، والجدولان معاً قرابة 1.4 جيجابايت، فلا يتسع كل شيء في الذاكرة، وهذا مقصود حتى ترى الفرق بين القراءة من الذاكرة والقراءة من القرص.

بعد ذلك ننشئ جدول مستخدمين فيه خمسة ملايين صف وجدول طلبات فيه عشرة ملايين، والبيانات كلها تولد داخل PostgreSQL بالدالة generate_series، ولاحظ أن نسبة الباقات مقصودة: 90% مجانية و9% Pro و1% Enterprise، وأن كل مدينة تنتمي دائماً إلى المنطقة نفسها، وسوف نحتاج هاتين الملاحظتين لاحقاً:

CREATE EXTENSION IF NOT EXISTS pageinspect;
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE TABLE users (
    id         bigint PRIMARY KEY,
    email      text        NOT NULL,
    city       text        NOT NULL,
    region     text        NOT NULL,
    plan       text        NOT NULL,
    signup_at  timestamptz NOT NULL,
    last_login timestamptz,
    meta       jsonb       NOT NULL
);

CREATE TABLE orders (
    id         bigint PRIMARY KEY,
    user_id    bigint        NOT NULL,
    total      numeric(10,2) NOT NULL,
    status     text          NOT NULL,
    created_at timestamptz   NOT NULL
);

-- 20 cities, each always in the same region (city and region are correlated on purpose)
CREATE TABLE cities (n int PRIMARY KEY, city text, region text);
INSERT INTO cities VALUES
 (0,'Riyadh','Riyadh'),(1,'Jeddah','Makkah'),(2,'Makkah','Makkah'),(3,'Madinah','Madinah'),(4,'Dammam','Eastern'),
 (5,'Khobar','Eastern'),(6,'Abha','Asir'),(7,'Taif','Makkah'),(8,'Tabuk','Tabuk'),(9,'Buraidah','Qassim'),
 (10,'Hail','Hail'),(11,'Jazan','Jazan'),(12,'Najran','Najran'),(13,'Yanbu','Madinah'),(14,'Jubail','Eastern'),
 (15,'Hofuf','Eastern'),(16,'Khamis Mushait','Asir'),(17,'Arar','Northern Borders'),(18,'Sakaka','Jouf'),(19,'Baha','Baha');

INSERT INTO users
SELECT g,
       'user' || g || '@' || (ARRAY['gmail.com','outlook.com','yahoo.com','company.sa'])[1 + g % 4],
       c.city, c.region,
       CASE WHEN abs(hashint8(g)) % 100 < 90 THEN 'free' WHEN abs(hashint8(g)) % 100 < 99 THEN 'pro' ELSE 'enterprise' END,
       timestamptz '2021-01-01' + (g * interval '30 seconds'),
       CASE WHEN g % 5 = 0 THEN NULL ELSE now() - random() * interval '365 days' END,
       jsonb_build_object('lang', CASE WHEN g % 3 = 0 THEN 'en' ELSE 'ar' END,
                          'newsletter', g % 7 = 0,
                          'device', (ARRAY['ios','android','web'])[1 + g % 3])
FROM generate_series(1::bigint, 5000000) AS g
JOIN cities c ON c.n = (CASE WHEN g % 10 < 4 THEN 0 ELSE (g * 7919) % 20 END);

INSERT INTO orders
SELECT g, 1 + (g * 7919) % 5000000, round((10 + random() * 990)::numeric, 2),
       (ARRAY['delivered','delivered','delivered','shipped','pending','cancelled'])[1 + g % 6],
       timestamptz '2021-01-01' + (g * interval '15 seconds')
FROM generate_series(1::bigint, 10000000) AS g;

VACUUM ANALYZE users;
VACUUM ANALYZE orders;

استغرق التوليد قرابة دقيقتين ونصف على جهاز المختبر، والملفات كاملة مع كل خطوات المقال بالترتيب في مجلد lab مع المقال. وهذا حجم الجدولين:

 relname | n_live_tup | table_size
---------+------------+------------
 users   |    4999942 | 748 MB
 orders  |   10000002 | 704 MB
(2 rows)
💡
كل الأزمنة في هذا المقال من جهاز واحد عليه Docker Desktop، وهي تبين الفرق بين الطرق على البيانات نفسها، وليست سرعة تتوقعها على سيرفرك. والرقم الأصدق للمقارنة هو عدد الصفحات في سطر Buffers، لأنه لا يتغير مع حمل الجهاز.

قبل الفهرس: كيف تقرأ خطة EXPLAIN؟

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

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, city, plan FROM users WHERE email = '[email protected]';

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

                                                           QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------
 Gather  (cost=1000.00..122829.46 rows=1 width=19) (actual time=15.724..626.251 rows=1.00 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   Buffers: shared hit=513 read=95275
   I/O Timings: shared read=389.838
   ->  Parallel Seq Scan on users  (cost=0.00..121829.36 rows=1 width=19) (actual time=380.644..582.101 rows=0.33 loops=3)
         Filter: (email = '[email protected]'::text)
         Rows Removed by Filter: 1666666
         Buffers: shared hit=513 read=95275
         I/O Timings: shared read=389.838
 Planning:
   Buffers: shared hit=11 read=1
   I/O Timings: shared read=0.036
 Planning Time: 0.201 ms
 JIT:
   Functions: 12
   Options: Inlining false, Optimization false, Expressions true, Deforming true
   Timing: Generation 1.458 ms (Deform 0.530 ms), Inlining 0.000 ms, Optimization 1.280 ms, Emission 27.908 ms, Total 30.646 ms
 Execution Time: 688.262 ms

الأمر EXPLAIN وحده يعرض الخطة Plan التي اختارها المخطط مع تقديراته دون أن ينفذ شيئاً، ومع ANALYZE ينفذ الاستعلام فعلاً ويضيف بجانب كل تقدير ما حدث في الواقع، ومع BUFFERS يعرض عدد الصفحات التي لمسها، وقد أصبح PostgreSQL 18 يعرض BUFFERS تلقائياً مع ANALYZE كما في ملاحظات الإصدار 18، ولكننا نكتبها صراحة حتى يعمل الأمر نفسه على الإصدارات الأقدم. والخطة شجرة من العقد Nodes، وكل سطر يبدأ بالسهم -> عقدة تعطي صفوفها للعقدة التي فوقها، لذلك اقرأها من الأكثر إزاحة إلى اليمين، أي من الداخل إلى الخارج، والصورة التالية تفكك سطراً واحداً منها:

سطر عقدة Parallel Seq Scan من خطة EXPLAIN مقسم إلى أجزاء: اسم العقدة، ثم التقدير cost=0.00..121829.36 rows=1 width=19، ثم الواقع actual time=380.644..582.101 rows=0.33 loops=3، وتحته الأسطر Filter وRows Removed by Filter وBuffers shared hit read، مع شرح لكل جزء وسهم يبين أن القراءة من العقدة الداخلية إلى الخارجية
كل سطر عقدة فيه تقدير المخطط بين القوسين الأولين، وما حدث فعلاً بين القوسين الثانيين

وفي المخرج أعلاه لاحظ التالي، من الداخل إلى الخارج:

  • Parallel Seq Scan on users: القراءة المتسلسلة Sequential Scan تعني أن PostgreSQL قرأ كل صفحات الجدول من أولها إلى آخرها، وكلمة Parallel تعني أن أكثر من عملية تقاسمت الصفحات.
  • cost=0.00..121829.36: هذه وحدات تكلفة تقديرية وليست ملي ثانية، والرقم الأول تكلفة البدء Startup Cost قبل أن تخرج أول نتيجة، والثاني التكلفة الكلية، ويحسبها المخطط من عدد الصفحات والصفوف بأوزان مثل seq_page_cost وقيمتها 1 وrandom_page_cost وقيمتها 4 كما في إعدادات المخطط. وهذه الأرقام تفيدك عندما تقارن خطتين للاستعلام نفسه، ولا معنى لها بين استعلامين مختلفين.
  • rows=1 width=19: المخطط قدر أن صفاً واحداً سوف يخرج بعرض 19 بايت تقريباً، وrows=0.33 loops=3 في الجزء الحقيقي تعني أن العقدة نفذت ثلاث مرات، مرة في كل عملية، ومتوسط ما أخرجته في المرة الواحدة ثلث صف، أي صف واحد في المجموع. وتذكر هذه القاعدة لأنها أكثر ما يخدع في قراءة الخطط: الأرقام الحقيقية في كل عقدة متوسط للمرة الواحدة، فاضربها في loops.
  • actual time=380.644..582.101: الرقم الأول متى خرج أول صف، والثاني متى انتهت العقدة، بالملي ثانية ولكل loop.
  • Rows Removed by Filter: 1666666: كل عملية قرأت قرابة مليون وستمئة ألف صف ورمتها لأنها لا تطابق الشرط، أي أن PostgreSQL قرأ خمسة ملايين صف ليعيد صفاً واحداً، وهذا السطر هو أوضح علامة على فهرس ناقص.
  • Buffers: shared hit=513 read=95275: الصفحة Page في PostgreSQL حجمها 8 كيلوبايت، وقد لمس الاستعلام 95,788 صفحة، أي الجدول كله تقريباً، منها 513 كانت في الذاكرة المشتركة shared buffers والباقي قرئ من نظام التشغيل، وسطر I/O Timings يقول إن قرابة 390 ملي ثانية ذهبت في انتظار القراءة.
  • Gather وWorkers Launched: 2: العقدة العليا جمعت نتائج العاملين Workers مع العملية الرئيسية.
  • JIT: لأن المخطط قدر أن الاستعلام مكلف فقد ترجم جزءاً منه إلى كود الآلة، وهذا أخذ 30 ملي ثانية، وسوف نوقفه بالأمر SET jit = off في بقية التجارب حتى تكون الخطط أقصر وأوضح.
  • Execution Time: 688.262 ms: الوقت الكلي للتنفيذ داخل السيرفر، ولا يشمل إرسال النتائج عبر الشبكة.
⚠️
الخيار ANALYZE ينفذ الاستعلام فعلاً، فإذا شغلته على UPDATE أو DELETE فسوف يعدل البيانات، لذلك ضعه داخل معاملة وألغها بعده: BEGIN ثم EXPLAIN ANALYZE ثم ROLLBACK، كما يحذر توثيق EXPLAIN.

شجرة B-tree من الداخل

الفهرس الافتراضي في PostgreSQL هو B-tree، وهو ملف منفصل عن الجدول، فيه نسخة مرتبة من قيم العمود، وبجانب كل قيمة عنوان الصف في الجدول، والعنوان اسمه ctid ويتكون من رقم الصفحة ورقم الصف داخلها. والآن ننشئ فهرساً على البريد ونقيس:

\timing on
CREATE INDEX users_email_idx ON users (email);
\timing off
SELECT pg_size_pretty(pg_relation_size('users_email_idx')) AS index_size;
Timing is on.
CREATE INDEX
Time: 25368.168 ms (00:25.368)
Timing is off.
 index_size
------------
 194 MB
(1 row)

بناء الفهرس على خمسة ملايين صف أخذ 25 ثانية، وحجمه 194 ميجابايت، أي قرابة ربع حجم الجدول، ولاحظ أن CREATE INDEX العادي يمنع الكتابة على الجدول حتى ينتهي، لذلك في الإنتاج استخدم CREATE INDEX CONCURRENTLY كما شرحنا في مقال الجداول الضخمة، وسوف نعود إلى الأقفال في مقال الأقفال.

والإضافة pageinspect تسمح لنا بأن نفتح صفحات الفهرس وننظر داخلها، ونبدأ بالصفحة الوصفية Metapage التي تقول أين تبدأ الشجرة وكم طابقاً فيها:

SELECT magic, version, root, level, fastroot, fastlevel FROM bt_metap('users_email_idx');
SELECT blkno, type, live_items, avg_item_size, free_size
FROM bt_page_stats('users_email_idx', (SELECT root FROM bt_metap('users_email_idx')));
 magic  | version | root | level | fastroot | fastlevel
--------+---------+------+-------+----------+-----------
 340322 |       4 |  164 |     2 |      164 |         2
(1 row)

 blkno | type | live_items | avg_item_size | free_size
-------+------+------------+---------------+-----------
   164 | r    |        156 |            31 |      2556
(1 row)

الصفحة العليا Root Page هي رقم 164، وlevel = 2 يعني أن تحتها طابقين، فالشجرة ثلاثة طوابق: الصفحة العليا، ثم الصفحات الداخلية Internal Pages، ثم صفحات الأوراق Leaf Pages التي فيها القيم الفعلية. والصفحة العليا فيها 156 مدخلاً فقط، وكل مدخل يشير إلى صفحة داخلية. ولنعد صفحات كل نوع في الفهرس كله:

SELECT type, count(*) AS pages, sum(live_items) AS items
FROM bt_multi_page_stats('users_email_idx', 1, -1) GROUP BY type ORDER BY type DESC;
 type | pages |  items
------+-------+---------
 r    |     1 |     156
 l    | 24631 | 5024630
 i    |   156 |   24786
(3 rows)

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

  • صفحة واحدة عليا r، و156 صفحة داخلية i، و24,631 صفحة أوراق l.
  • عدد المداخل في الأوراق 5,024,630 وليس خمسة ملايين بالضبط، والفرق 24,630 هو مفتاح أعلى High Key في كل صفحة أوراق ما عدا آخرها، وهو أكبر قيمة يسمح بها في الصفحة، ويستخدمه PostgreSQL ليعرف أن البحث يجب أن ينتقل إلى الصفحة المجاورة إذا انقسمت الصفحة أثناء البحث، وهذه فكرة خوارزمية Lehman وYao التي يطبقها PostgreSQL كما في ملف README لكود ال B-tree.
  • كل صفحة داخلية تشير إلى قرابة 158 صفحة تحتها، وهذا ما يسمى درجة التفرع Fanout، ومنه تأتي قوة ال B-tree: طابق إضافي واحد يضرب السعة في قرابة 160، فثلاثة طوابق تكفي لخمسة ملايين بريد، وأربعة تكفي لمئات الملايين.

وماذا يوجد داخل الصفحة العليا؟ كل مدخل فيها مفتاح فاصل Separator Key وعنوان الصفحة التي تحته، وقد حولنا البايتات إلى نص حتى تقرأها:

SELECT itemoffset, ctid,
       convert_from(rtrim(substring(decode(replace(data, ' ', ''), 'hex') from 2), '\x00'::bytea), 'UTF8') AS separator_key
FROM bt_page_items('users_email_idx', (SELECT root FROM bt_metap('users_email_idx')))
WHERE itemoffset <= 4;
 itemoffset |  ctid   |     separator_key
------------+---------+-----------------------
          1 | (3,0)   |
          2 | (163,1) | [email protected]
          3 | (323,1) | [email protected]
          4 | (482,1) | [email protected]
(4 rows)

المدخل الأول بلا مفتاح ويعني «كل ما هو أصغر من المفتاح التالي»، والمدخل الثاني يقول إن كل بريد من [email protected] وحتى المفتاح التالي موجود تحت الصفحة 163، وهكذا. ولاحظ أن user102886 يأتي قبل user1057736 لأن النص يرتب حرفاً حرفاً حسب ترتيب اللغة Collation وليس كرقم، وهذا ما يجعل البحث بالمساواة وبالبداية ممكناً، كما سوف نرى. والآن نعيد استعلام تسجيل الدخول:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, city, plan FROM users WHERE email = '[email protected]';
                                                        QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------
 Index Scan using users_email_idx on users  (cost=0.43..8.45 rows=1 width=19) (actual time=0.072..0.073 rows=1.00 loops=1)
   Index Cond: (email = '[email protected]'::text)
   Index Searches: 1
   Buffers: shared hit=2 read=2
   I/O Timings: shared read=0.032
 Planning:
   Buffers: shared hit=49
 Planning Time: 0.369 ms
 Execution Time: 0.089 ms

نزل الوقت من 688 ملي ثانية إلى 0.089 ملي ثانية، والأهم أن عدد الصفحات نزل من قرابة 96 ألفاً إلى أربع صفحات بالضبط: الصفحة العليا، ثم صفحة داخلية، ثم صفحة أوراق، ثم صفحة الجدول التي فيها الصف، والصورة التالية تبين هذا الطريق:

شجرة B-tree بثلاثة طوابق للفهرس users_email_idx: صفحة عليا واحدة رقم 164 فيها 156 مفتاحاً فاصلاً، ثم 156 صفحة داخلية، ثم 24631 صفحة أوراق مرتبطة ببعضها، والبحث عن user4242@yahoo.com ينزل في ثلاث صفحات ثم يقفز بالعنوان ctid إلى صفحة واحدة في ملف الجدول، والمجموع أربع صفحات بدل 95788
البحث في الفهرس ينزل طابقاً في كل خطوة، ثم يقفز إلى صفحة واحدة في الجدول

وقد يتساءل البعض: إذا كان الفهرس بهذه السرعة، فلماذا لا نضع فهرساً على كل عمود؟ والإجابة في القسمين التاليين: الفهرس لا يفيد كل استعلام، وكل فهرس له ثمن يدفع مع كل كتابة.

متى يكون الفهرس أبطأ من قراءة الجدول كله؟

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

SET jit = off;
CREATE INDEX users_plan_idx ON users (plan);
SELECT plan, count(*), round(100.0 * count(*) / sum(count(*)) OVER (), 2) AS pct FROM users GROUP BY plan ORDER BY 2 DESC;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT count(*), max(last_login) FROM users WHERE plan = 'enterprise';
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT count(*), max(last_login) FROM users WHERE plan = 'free';
    plan    |  count  |  pct
------------+---------+-------
 free       | 4499618 | 89.99
 pro        |  450106 |  9.00
 enterprise |   50276 |  1.01
(3 rows)

                                            QUERY PLAN
---------------------------------------------------------------------------------------------------
 Aggregate (actual time=597.098..597.099 rows=1.00 loops=1)
   Buffers: shared hit=36 read=39256 written=225
   I/O Timings: shared read=432.145 write=1.719
   ->  Index Scan using users_plan_idx on users (actual time=0.042..575.685 rows=50276.00 loops=1)
         Index Cond: (plan = 'enterprise'::text)
         Index Searches: 1
         Buffers: shared hit=36 read=39256 written=225
         I/O Timings: shared read=432.145 write=1.719
 Planning:
   Buffers: shared hit=6
 Planning Time: 0.155 ms
 Execution Time: 597.135 ms
(12 rows)

                                             QUERY PLAN
----------------------------------------------------------------------------------------------------
 Finalize Aggregate (actual time=1652.088..1671.969 rows=1.00 loops=1)
   Buffers: shared hit=33439 read=62349
   I/O Timings: shared read=216.859
   ->  Gather (actual time=1648.340..1671.955 rows=3.00 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         Buffers: shared hit=33439 read=62349
         I/O Timings: shared read=216.859
         ->  Partial Aggregate (actual time=1645.635..1645.636 rows=1.00 loops=3)
               Buffers: shared hit=33439 read=62349
               I/O Timings: shared read=216.859
               ->  Parallel Seq Scan on users (actual time=0.144..1313.026 rows=1499872.67 loops=3)
                     Filter: (plan = 'free'::text)
                     Rows Removed by Filter: 166794
                     Buffers: shared hit=33439 read=62349
                     I/O Timings: shared read=216.859
 Planning Time: 0.089 ms
 Execution Time: 1672.018 ms
(18 rows)

للباقة Enterprise، وهي 1% من المستخدمين، اختار المخطط الفهرس، وللباقة المجانية، وهي 90%، تجاهله وقرأ الجدول كله مع أن الفهرس موجود، وهذا قرار صحيح. ولنتأكد نجبره على عدم القراءة المتسلسلة بالإعداد enable_seqscan، وهو إعداد للتجارب فقط ولا تضعه في الإنتاج:

SET enable_seqscan = off;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT count(*), max(last_login) FROM users WHERE plan = 'free';
                                                     QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
 Finalize Aggregate (actual time=1761.151..1772.391 rows=1.00 loops=1)
   Buffers: shared hit=12764 read=86854 written=18240
   I/O Timings: shared read=221.408 write=338.514
   ->  Gather (actual time=1759.213..1772.376 rows=3.00 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         Buffers: shared hit=12764 read=86854 written=18240
         I/O Timings: shared read=221.408 write=338.514
         ->  Partial Aggregate (actual time=1752.888..1752.890 rows=1.00 loops=3)
               Buffers: shared hit=12764 read=86854 written=18240
               I/O Timings: shared read=221.408 write=338.514
               ->  Parallel Bitmap Heap Scan on users (actual time=321.105..1381.029 rows=1499872.67 loops=3)
                     Recheck Cond: (plan = 'free'::text)
                     Heap Blocks: exact=31343
                     Buffers: shared hit=12764 read=86854 written=18240
                     I/O Timings: shared read=221.408 write=338.514
                     Worker 0:  Heap Blocks: exact=30503
                     Worker 1:  Heap Blocks: exact=33942
                     ->  Bitmap Index Scan on users_plan_idx (actual time=275.805..275.806 rows=4499618.00 loops=1)
                           Index Cond: (plan = 'free'::text)
                           Index Searches: 1
                           Buffers: shared hit=2 read=3788
                           I/O Timings: shared read=47.297
 Planning Time: 0.167 ms
 Execution Time: 1772.494 ms

هنا استخدم الفهرس بطريقة المسح بالخريطة Bitmap Scan: عقدة Bitmap Index Scan تقرأ الفهرس وتبني خريطة بكل الصفحات التي فيها صفوف مطلوبة، ثم Bitmap Heap Scan تقرأ هذه الصفحات بترتيبها في الملف، وهي طريقة وسطى بين البحث في الفهرس والقراءة المتسلسلة كما يشرح التوثيق. والنتيجة 1772 ملي ثانية مقابل 1672 بالقراءة المتسلسلة، أي أن الفهرس لم يوفر شيئاً، والسبب أن 4.5 مليون صف من الخمسة موزعة على كل صفحات الجدول، فالفهرس أضاف عمل قراءة 3,790 صفحة من الفهرس نفسه دون أن يستبعد صفحة واحدة من الجدول.

ولكن انظر مرة أخرى إلى الباقة Enterprise: 50,276 صفاً فقط، ومع ذلك لمس الاستعلام 39,292 صفحة، أي قرابة 40% من الجدول. لماذا؟ لأن هؤلاء المستخدمين متفرقون في الملف، فكل مستخدم تقريباً في صفحة مختلفة. وقارن ذلك باستعلام يطلب عدداً مقارباً من الصفوف بمدى من تاريخ التسجيل، بعد أن نضع فهرساً على هذا العمود:

SET max_parallel_workers_per_gather = 0;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT count(*), max(last_login) FROM users WHERE signup_at >= '2022-01-01' AND signup_at < '2022-01-15';
CREATE INDEX users_signup_idx ON users (signup_at);
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT count(*), max(last_login) FROM users WHERE signup_at >= '2022-01-01' AND signup_at < '2022-01-15';
                                                                        QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregate (actual time=1675.102..1675.104 rows=1.00 loops=1)
   Buffers: shared hit=65046 read=30742
   I/O Timings: shared read=120.062
   ->  Seq Scan on users (actual time=410.550..1670.351 rows=40320.00 loops=1)
         Filter: ((signup_at >= '2022-01-01 00:00:00+00'::timestamp with time zone) AND (signup_at < '2022-01-15 00:00:00+00'::timestamp with time zone))
         Rows Removed by Filter: 4959680
         Buffers: shared hit=65046 read=30742
         I/O Timings: shared read=120.062
 Planning:
   Buffers: shared hit=8 read=3
   I/O Timings: shared read=0.028
 Planning Time: 0.252 ms
 Execution Time: 1675.147 ms
(13 rows)

CREATE INDEX
                                                                          QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregate (actual time=40.927..40.929 rows=1.00 loops=1)
   Buffers: shared hit=314 read=575
   I/O Timings: shared read=5.673
   ->  Index Scan using users_signup_idx on users (actual time=0.086..34.655 rows=40320.00 loops=1)
         Index Cond: ((signup_at >= '2022-01-01 00:00:00+00'::timestamp with time zone) AND (signup_at < '2022-01-15 00:00:00+00'::timestamp with time zone))
         Index Searches: 1
         Buffers: shared hit=314 read=575
         I/O Timings: shared read=5.673
 Planning:
   Buffers: shared hit=18 read=1
   I/O Timings: shared read=0.025
 Planning Time: 0.365 ms
 Execution Time: 40.959 ms
(13 rows)

40,320 صفاً في 889 صفحة فقط، والاستعلام نزل من 1675 إلى 41 ملي ثانية. والفرق بين الحالتين اسمه الترابط Correlation، أي هل ترتيب القيم في العمود قريب من ترتيب الصفوف في الملف؟ المستخدمون سجلوا بالترتيب، فالصفوف المتجاورة في التاريخ متجاورة في الملف، أما الباقة فلا علاقة لها بمكان الصف. ويحفظ PostgreSQL هذا الرقم لكل عمود في العرض pg_stats، ويستخدمه في تقدير تكلفة البحث بالفهرس:

SELECT attname, n_distinct, round(correlation::numeric, 3) AS correlation
FROM pg_stats WHERE tablename = 'users' AND attname IN ('id','plan','signup_at','email','city');
  attname  | n_distinct | correlation
-----------+------------+-------------
 id        |         -1 |       1.000
 email     |         -1 |       0.334
 city      |         13 |       0.190
 plan      |          3 |       0.816
 signup_at |         -1 |       1.000
(5 rows)

العمودان id وsignup_at ترابطهما 1، أي مرتبان تماماً مع الملف، والبريد 0.334، ولاحظ أن الرقم للباقة 0.816 يبدو عالياً، والسبب أن 90% من القيم هي free فالعمود يبدو مرتباً في أغلبه، ولكن الصفوف القليلة من Enterprise متفرقة رغم ذلك، وهذا مثال على أن الإحصاءات تقريب وليست حقيقة كاملة. والصورة التالية تبين الحالتين:

مقارنة بين حالتين على ملف الجدول المكون من 95788 صفحة. في الأولى 50276 مستخدماً من الباقة enterprise متفرقون في الملف فيلمس الاستعلام 39292 صفحة. في الثانية 40320 مستخدماً سجلوا في أسبوعين متجاورون في الملف فيلمس الاستعلام 889 صفحة فقط
عدد الصفوف متقارب، وعدد الصفحات مختلف جداً لأن مكان الصفوف في الملف مختلف

وهل الفهرس على الباقة بلا فائدة إذن؟ لا، فقد شغلنا استعلام Enterprise مرتين بالفهرس ثم مرتين بالقراءة المتسلسلة دون توازي Parallelism، فكان الوقت 668 ثم 110 ملي ثانية بالفهرس، و3251 ثم 3164 ملي ثانية دونه، فالفهرس ما زال أسرع بكثير لنسبة 1%، ولكنه يخسر عندما تقترب النسبة من أغلب الجدول. والقاعدة العملية: الفهرس على عمود قيمه قليلة ومتكررة لا يفيد إلا للقيم النادرة فيه، وفي هذه الحالة فكر في الفهرس الجزئي Partial Index على القيمة النادرة وحدها، فهو أصغر بكثير ويقوم بالعمل نفسه.

الفهرس المركب وترتيب الأعمدة

الفهرس المركب Composite Index على عدة أعمدة مرتب بالعمود الأول، ثم بالثاني داخل كل قيمة من الأول، كدليل الهاتف المرتب بالمدينة ثم بالتاريخ، وقد شرحنا قاعدة الترتيب في مقال الجداول الضخمة: عمود المساواة أولاً ثم عمود المدى. ننشئ فهرساً على المدينة ثم تاريخ التسجيل، ونسأل عن مستخدمي أبها في شهر واحد:

CREATE INDEX users_city_signup_idx ON users (city, signup_at);
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM users WHERE city = 'Abha' AND signup_at >= '2024-01-01' AND signup_at < '2024-02-01';
                                                                                       QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregate (actual time=1.328..1.329 rows=1.00 loops=1)
   Buffers: shared hit=1 read=20
   I/O Timings: shared read=0.205
   ->  Index Only Scan using users_city_signup_idx on users (actual time=0.091..0.987 rows=4464.00 loops=1)
         Index Cond: ((city = 'Abha'::text) AND (signup_at >= '2024-01-01 00:00:00+00'::timestamp with time zone) AND (signup_at < '2024-02-01 00:00:00+00'::timestamp with time zone))
         Heap Fetches: 0
         Index Searches: 1
         Buffers: shared hit=1 read=20
         I/O Timings: shared read=0.205
 Planning:
   Buffers: shared hit=92 read=15
   I/O Timings: shared read=0.209
 Planning Time: 0.880 ms
 Execution Time: 1.410 ms

بحث واحد في الفهرس Index Searches: 1 نزل مباشرة إلى بداية «أبها، يناير 2024» وقرأ 21 صفحة. ولاحظ أن العقدة هنا Index Only Scan، أي أن PostgreSQL أجاب من الفهرس وحده دون أن يلمس الجدول، لأن كل ما يحتاجه الاستعلام موجود في الفهرس، وسوف نعود إليها في قسم Index Only Scan.

والسؤال المهم: ماذا يحدث إذا سألنا بالتاريخ وحده دون المدينة؟ في الإصدارات قبل 18 كان الجواب في الغالب أن هذا الفهرس لا يفيد إلا بقراءته كاملاً، أما PostgreSQL 18 فقد أضاف البحث بالتخطي Skip Scan كما في توثيق الفهارس المركبة. سوف نحذف الفهرس على التاريخ داخل معاملة ثم نلغي الحذف، حتى لا يبقى أمام المخطط إلا الفهرس المركب:

BEGIN;
DROP INDEX users_signup_idx;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM users WHERE signup_at >= '2024-01-01' AND signup_at < '2024-02-01';
ROLLBACK;
                                                                          QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregate (actual time=34.879..34.883 rows=1.00 loops=1)
   Buffers: shared hit=52 read=394
   I/O Timings: shared read=9.196
   ->  Index Only Scan using users_city_signup_idx on users (actual time=0.118..27.753 rows=89280.00 loops=1)
         Index Cond: ((signup_at >= '2024-01-01 00:00:00+00'::timestamp with time zone) AND (signup_at < '2024-02-01 00:00:00+00'::timestamp with time zone))
         Heap Fetches: 0
         Index Searches: 27
         Buffers: shared hit=52 read=394
         I/O Timings: shared read=9.196
 Planning:
   Buffers: shared hit=4
 Planning Time: 0.116 ms
 Execution Time: 34.939 ms
(13 rows)

السطر المهم هو Index Searches: 27، فبدل أن يقرأ الفهرس كله، قفز PostgreSQL إلى كل مدينة على حدة وبحث داخلها عن يناير 2024، وعندنا 20 مدينة، والبحثات الإضافية هي محاولات يقوم بها ليكتشف أين تبدأ المدينة التالية. والنتيجة 446 صفحة و35 ملي ثانية، والصورة التالية تبين الفرق بين البحثين:

فهرس مركب على city ثم signup_at مرسوم كأقسام متتالية لكل مدينة مرتبة بالتاريخ داخلها. البحث عن Abha مع شهر يناير ينزل إلى مكان واحد ويقرأ 21 صفحة. البحث عن يناير وحده دون مدينة يقفز إلى كل مدينة من Abha إلى Yanbu ويقرأ الجزء الخاص بيناير في كل واحدة، وعدد البحثات 27
البحث بالتخطي يقفز إلى كل قيمة من العمود الأول، وهذا مفيد فقط عندما تكون قيمه قليلة

وهذا لا يغير القاعدة، فالتخطي نجح هنا لأن العمود الأول فيه 20 قيمة فقط، ولو كان العمود الأول هو البريد بخمسة ملايين قيمة لكان على PostgreSQL أن يقفز خمسة ملايين مرة، وعندها يفضل المخطط قراءة الجدول. لذلك رتب أعمدة الفهرس المركب حسب استعلاماتك الأكثر تكراراً، واعتبر التخطي ميزة تنقذ استعلاماً نادراً وليس سبباً لتجاهل الترتيب.

لماذا لا يستخدم PostgreSQL الفهرس الذي أنشأته؟

هذا أكثر سؤال يصلنا في هذا الموضوع، والإجابة في أغلب الحالات أن الاستعلام نفسه يمنع الفهرس، لأن الفهرس مرتب بقيم العمود كما هي، وأي شرط لا يمكن ترجمته إلى «من هذه القيمة إلى تلك» في هذا الترتيب لا يستطيع أن يستخدمه. وفيما يلي ثلاث حالات حقيقية على الجدول نفسه، والفهارس على email وsignup_at موجودة:

SET jit = off; SET max_parallel_workers_per_gather = 0;
-- 1) a function around the column
EXPLAIN (ANALYZE, COSTS OFF) SELECT id FROM users WHERE lower(email) = '[email protected]';
-- 2) the date of a timestamp
EXPLAIN (ANALYZE, COSTS OFF) SELECT count(*) FROM users WHERE signup_at::date = '2024-01-10';
EXPLAIN (ANALYZE, COSTS OFF) SELECT count(*) FROM users WHERE signup_at >= '2024-01-10' AND signup_at < '2024-01-11';
-- 3) prefix search with LIKE
SELECT datcollate FROM pg_database WHERE datname = current_database();
EXPLAIN (ANALYZE, COSTS OFF) SELECT id FROM users WHERE email LIKE 'user4242@%';
                             QUERY PLAN
--------------------------------------------------------------------
 Seq Scan on users (actual time=5.620..11051.185 rows=1.00 loops=1)
   Filter: (lower(email) = '[email protected]'::text)
   Rows Removed by Filter: 4999999
   Buffers: shared hit=64467 read=31321
   I/O Timings: shared read=33.820
 Planning:
   Buffers: shared hit=122
 Planning Time: 0.691 ms
 Execution Time: 11051.257 ms
(9 rows)

                                                 QUERY PLAN
-------------------------------------------------------------------------------------------------------------
 Aggregate (actual time=1877.548..1877.549 rows=1.00 loops=1)
   Buffers: shared hit=3 read=13664 written=1
   I/O Timings: shared read=407.475 write=0.045
   ->  Index Only Scan using users_signup_idx on users (actual time=1340.320..1877.247 rows=2880.00 loops=1)
         Filter: ((signup_at)::date = '2024-01-10'::date)
         Rows Removed by Filter: 4997120
         Heap Fetches: 0
         Index Searches: 1
         Buffers: shared hit=3 read=13664 written=1
         I/O Timings: shared read=407.475 write=0.045
 Planning:
   Buffers: shared hit=22
 Planning Time: 0.258 ms
 Execution Time: 1877.588 ms
(14 rows)

                                                                          QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregate (actual time=0.687..0.689 rows=1.00 loops=1)
   Buffers: shared hit=11 read=1
   I/O Timings: shared read=0.013
   ->  Index Only Scan using users_signup_idx on users (actual time=0.050..0.457 rows=2880.00 loops=1)
         Index Cond: ((signup_at >= '2024-01-10 00:00:00+00'::timestamp with time zone) AND (signup_at < '2024-01-11 00:00:00+00'::timestamp with time zone))
         Heap Fetches: 0
         Index Searches: 1
         Buffers: shared hit=11 read=1
         I/O Timings: shared read=0.013
 Planning:
   Buffers: shared hit=8
 Planning Time: 0.158 ms
 Execution Time: 0.726 ms
(13 rows)

 datcollate
------------
 en_US.utf8
(1 row)

                            QUERY PLAN
-------------------------------------------------------------------
 Seq Scan on users (actual time=2.978..1857.592 rows=1.00 loops=1)
   Filter: (email ~~ 'user4242@%'::text)
   Rows Removed by Filter: 4999999
   Buffers: shared hit=50933 read=44855
   I/O Timings: shared read=123.903
 Planning:
   Buffers: shared hit=5 read=4
   I/O Timings: shared read=6.048
 Planning Time: 6.252 ms
 Execution Time: 1857.632 ms
(10 rows)

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

  • الدالة lower(email) جعلت الاستعلام يقرأ الجدول كله وينفذ الدالة على خمسة ملايين بريد، فأخذ 11 ثانية، والسبب أن الفهرس فيه البريد كما كتب، وليس فيه نتيجة lower، وهذا يحدث كثيراً عندما يضيف المطور lower ليجعل تسجيل الدخول لا يفرق بين الأحرف الكبيرة والصغيرة.
  • التحويل signup_at::date له الأثر نفسه، فقد استخدم PostgreSQL الفهرس هنا ولكن بقراءته كاملاً كقائمة، وحسب التاريخ لكل مدخل، فقرأ 13,667 صفحة في 1.9 ثانية، أما الشرط نفسه مكتوباً كمدى على العمود كما هو فقد أخذ 0.7 ملي ثانية. وانتبه هنا إلى أن حدود اليوم بتوقيت UTC، وقد شرحنا ذلك في مقال التاريخ والوقت.
  • البحث بالبداية LIKE 'user4242@%' يبدو مناسباً لل B-tree، ومع ذلك قرأ الجدول كله، والسبب أن ترتيب قاعدة البيانات هو en_US.utf8، وفي هذا الترتيب لا تضمن مقارنة الأحرف أن كل ما يبدأ بنص معين يقع في مدى متصل من الفهرس، لذلك لا يستخدم PostgreSQL الفهرس العادي ل LIKE إلا مع الترتيب C، كما في توثيق فئات المعاملات Operator Classes.

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

\timing on
CREATE INDEX users_email_lower_idx ON users (lower(email));
CREATE INDEX users_email_pattern_idx ON users (email text_pattern_ops);
\timing off
EXPLAIN (ANALYZE, COSTS OFF) SELECT id FROM users WHERE lower(email) = '[email protected]';
EXPLAIN (ANALYZE, COSTS OFF) SELECT id FROM users WHERE email LIKE 'user4242@%';
EXPLAIN (ANALYZE, COSTS OFF) SELECT id FROM users WHERE email LIKE '%4242@yahoo%';
Timing is on.
CREATE INDEX
Time: 37942.331 ms (00:37.942)
CREATE INDEX
Time: 5188.747 ms (00:05.189)
Timing is off.
                                          QUERY PLAN
-----------------------------------------------------------------------------------------------
 Bitmap Heap Scan on users (actual time=0.053..0.054 rows=1.00 loops=1)
   Recheck Cond: (lower(email) = '[email protected]'::text)
   Heap Blocks: exact=1
   Buffers: shared hit=1 read=3
   I/O Timings: shared read=0.023
   ->  Bitmap Index Scan on users_email_lower_idx (actual time=0.043..0.043 rows=1.00 loops=1)
         Index Cond: (lower(email) = '[email protected]'::text)
         Index Searches: 1
         Buffers: shared read=3
         I/O Timings: shared read=0.023
 Planning:
   Buffers: shared hit=107 read=11
   I/O Timings: shared read=0.198
 Planning Time: 0.905 ms
 Execution Time: 0.098 ms
(15 rows)

                                           QUERY PLAN
------------------------------------------------------------------------------------------------
 Index Scan using users_email_pattern_idx on users (actual time=0.055..0.056 rows=1.00 loops=1)
   Index Cond: ((email ~>=~ 'user4242@'::text) AND (email ~<~ 'user4242A'::text))
   Filter: (email ~~ 'user4242@%'::text)
   Index Searches: 1
   Buffers: shared hit=1 read=3
   I/O Timings: shared read=0.028
 Planning:
   Buffers: shared hit=53 read=4 dirtied=2
   I/O Timings: shared read=0.058
 Planning Time: 0.355 ms
 Execution Time: 0.069 ms
(11 rows)

                             QUERY PLAN
---------------------------------------------------------------------
 Seq Scan on users (actual time=1.893..1781.650 rows=500.00 loops=1)
   Filter: (email ~~ '%4242@yahoo%'::text)
   Rows Removed by Filter: 4999500
   Buffers: shared hit=52470 read=43318
   I/O Timings: shared read=27.750
 Planning Time: 0.097 ms
 Execution Time: 1782.472 ms
(7 rows)

الاستعلامان الأولان نزلا إلى أقل من عشر ملي ثانية، ولاحظ في الثاني كيف حول PostgreSQL الشرط LIKE 'user4242@%' إلى مدى ~>=~ 'user4242@' و~<~ 'user4242A'، أي «من هذه القيمة إلى ما قبل الحرف التالي»، وهذا بالضبط ما يحتاجه ال B-tree. أما البحث عن نص في وسط القيمة '%4242@yahoo%' فلا يمكن أن يتحول إلى مدى في أي ترتيب، وما زال يقرأ الجدول كله، وحله في نوع آخر من الفهارس.

ولكن انتبه إلى الثمن قبل أن تضيف فهارس التعبيرات: عندنا الآن ثلاثة فهارس على البريد، كل واحد 194 ميجابايت، وفي تطبيق حقيقي اختر واحداً، فإذا كان كل الكود يبحث ب lower(email) فاحفظ البريد بأحرف صغيرة عند التسجيل، أو استخدم النوع citext، ويكفيك فهرس واحد.

فهارس GIN للبحث داخل النص وJSONB

ال B-tree يحفظ القيمة كاملة في مكان واحد، أما الفهرس المعكوس GIN، أي Generalized Inverted Index، فيقسم كل قيمة إلى أجزاء ويحفظ لكل جزء قائمة الصفوف التي تحتويه، وهي نفس فكرة الفهرس المعكوس في محركات البحث التي شرحناها في كيف يعمل البحث. ومع الإضافة pg_trgm يقسم النص إلى مقاطع من ثلاثة أحرف Trigrams، فيصبح البحث عن نص في وسط القيمة ممكناً، ومع JSONB يقسم المستند إلى مفاتيح وقيم:

\timing on
CREATE INDEX users_email_trgm_idx ON users USING gin (email gin_trgm_ops);
CREATE INDEX users_meta_idx ON users USING gin (meta jsonb_path_ops);
\timing off
EXPLAIN (ANALYZE, COSTS OFF) SELECT id FROM users WHERE email LIKE '%4242@yahoo%';
Timing is on.
CREATE INDEX
Time: 49526.640 ms (00:49.527)
CREATE INDEX
Time: 8448.081 ms (00:08.448)
Timing is off.
                                            QUERY PLAN
--------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on users (actual time=94.907..110.483 rows=500.00 loops=1)
   Recheck Cond: (email ~~ '%4242@yahoo%'::text)
   Rows Removed by Index Recheck: 31
   Heap Blocks: exact=531
   Buffers: shared hit=2050 read=282
   I/O Timings: shared read=10.599
   ->  Bitmap Index Scan on users_email_trgm_idx (actual time=94.396..94.397 rows=531.00 loops=1)
         Index Cond: (email ~~ '%4242@yahoo%'::text)
         Index Searches: 1
         Buffers: shared hit=1801
 Planning:
   Buffers: shared hit=148 read=10
   I/O Timings: shared read=0.240
 Planning Time: 1.191 ms
 Execution Time: 111.364 ms
(15 rows)

نزل البحث في وسط النص من 1782 إلى 111 ملي ثانية، ولاحظ السطر Rows Removed by Index Recheck: 31: الفهرس أعطى 531 صفاً مرشحاً تحتوي على كل المقاطع الثلاثية المطلوبة، ثم تحقق PostgreSQL من الشرط الحقيقي على كل صف فرفض 31 منها، لأن وجود المقاطع لا يعني أنها متتالية بالترتيب نفسه. وهذا طبيعي في GIN، فهو يعطي مرشحين ثم يتحقق.

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

UPDATE users SET meta = meta || '{"beta": true}' WHERE id % 5000 = 0;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT count(*) FROM users WHERE meta @> '{"beta": true}';
BEGIN; DROP INDEX users_meta_idx;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT count(*) FROM users WHERE meta @> '{"beta": true}';
ROLLBACK;
UPDATE 1000
                                           QUERY PLAN
-------------------------------------------------------------------------------------------------
 Aggregate (actual time=0.948..0.950 rows=1.00 loops=1)
   Buffers: shared hit=35
   ->  Bitmap Heap Scan on users (actual time=0.350..0.869 rows=1000.00 loops=1)
         Recheck Cond: (meta @> '{"beta": true}'::jsonb)
         Heap Blocks: exact=23
         Buffers: shared hit=35
         ->  Bitmap Index Scan on users_meta_idx (actual time=0.275..0.276 rows=1000.00 loops=1)
               Index Cond: (meta @> '{"beta": true}'::jsonb)
               Index Searches: 1
               Buffers: shared hit=12
 Planning:
   Buffers: shared hit=10
 Planning Time: 0.412 ms
 Execution Time: 1.000 ms
(14 rows)

BEGIN
DROP INDEX
                                 QUERY PLAN
-----------------------------------------------------------------------------
 Aggregate (actual time=3195.698..3195.700 rows=1.00 loops=1)
   Buffers: shared hit=39094 read=56714 dirtied=3
   I/O Timings: shared read=162.525
   ->  Seq Scan on users (actual time=34.714..3195.601 rows=1000.00 loops=1)
         Filter: (meta @> '{"beta": true}'::jsonb)
         Rows Removed by Filter: 4999000
         Buffers: shared hit=39094 read=56714 dirtied=3
         I/O Timings: shared read=162.525
 Planning:
   Buffers: shared hit=5
 Planning Time: 0.154 ms
 Execution Time: 3195.758 ms
(12 rows)

ROLLBACK

ملي ثانية واحدة و35 صفحة مقابل 3.2 ثانية و95,808 صفحات، والفئة jsonb_path_ops التي اخترناها أصغر وأسرع من الفئة الافتراضية لكنها تدعم معاملات الاحتواء فقط، ولا تدعم السؤال عن وجود مفتاح بالمعامل ?، كما يشرح توثيق JSONB، والفهرس كله 16 ميجابايت. وقد شرحنا متى تكفيك JSONB بدل قاعدة مستندات في PostgreSQL لكل شيء، وفي مقال أنواع قواعد البيانات.

وبقية أنواع الفهارس باختصار، والتفصيل في صفحة أنواع الفهارس:

  • BRIN: يحفظ أقل قيمة وأكبر قيمة لكل مجموعة من الصفحات، فهو صغير جداً ويناسب عموداً مرتباً مع الملف مثل تاريخ الإدخال، وقد قسناه على 30 مليون طلب في مقال الجداول الضخمة، وترابط 1 الذي رأيته في signup_at هو بالضبط الشرط الذي يحتاجه.
  • GiST وSP-GiST: للبيانات التي لا ترتب في خط واحد، مثل الإحداثيات الجغرافية في PostGIS والمديات الزمنية التي تتداخل.
  • Hash: يدعم المساواة فقط، ونادراً ما يكون أفضل من B-tree في الواقع.

البحث في الفهرس وحده Index Only Scan وخريطة الرؤية

رأينا Index Only Scan مع Heap Fetches: 0 في الفهرس المركب، ومعناه أن PostgreSQL أجاب من الفهرس دون أن يقرأ صفحات الجدول. ولكن هناك شرط لا يظهر في تعريف الفهرس: الفهرس لا يعرف هل الصف الذي يشير إليه ما زال مرئياً لمعاملتك أم أنه نسخة قديمة، لأن معلومات الرؤية موجودة في الجدول فقط، كما سوف نشرح في مقال التخزين من الداخل. والحل خريطة الرؤية Visibility Map، وهي بت لكل صفحة يقول «كل الصفوف هنا مرئية للجميع»، وإذا كان البت مرفوعاً تجاوز PostgreSQL الجدول، وإلا ذهب إليه ليتأكد، وهذا ما يحسبه السطر Heap Fetches. والتجربة التالية تبين ذلك:

VACUUM users;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM users WHERE signup_at >= '2023-01-01' AND signup_at < '2023-02-01';
UPDATE users SET last_login = now() WHERE signup_at >= '2023-01-01' AND signup_at < '2023-02-01' AND id % 10 = 0;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM users WHERE signup_at >= '2023-01-01' AND signup_at < '2023-02-01';
VACUUM users;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM users WHERE signup_at >= '2023-01-01' AND signup_at < '2023-02-01';
VACUUM
                                                                          QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregate (actual time=30.980..30.982 rows=1.00 loops=1)
   Buffers: shared hit=38 read=246
   I/O Timings: shared read=6.197
   ->  Index Only Scan using users_signup_idx on users (actual time=0.107..23.110 rows=89280.00 loops=1)
         Index Cond: ((signup_at >= '2023-01-01 00:00:00+00'::timestamp with time zone) AND (signup_at < '2023-02-01 00:00:00+00'::timestamp with time zone))
         Heap Fetches: 0
         Index Searches: 1
         Buffers: shared hit=38 read=246
         I/O Timings: shared read=6.197
 Planning:
   Buffers: shared hit=59
 Planning Time: 0.504 ms
 Execution Time: 31.052 ms
(13 rows)

UPDATE 8928
                                                                          QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregate (actual time=65.059..65.060 rows=1.00 loops=1)
   Buffers: shared hit=2359
   ->  Index Only Scan using users_signup_idx on users (actual time=0.097..48.721 rows=89280.00 loops=1)
         Index Cond: ((signup_at >= '2023-01-01 00:00:00+00'::timestamp with time zone) AND (signup_at < '2023-02-01 00:00:00+00'::timestamp with time zone))
         Heap Fetches: 89291
         Index Searches: 1
         Buffers: shared hit=2359
 Planning Time: 0.178 ms
 Execution Time: 65.102 ms
(9 rows)

VACUUM
                                                                          QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregate (actual time=28.202..28.203 rows=1.00 loops=1)
   Buffers: shared hit=285
   ->  Index Only Scan using users_signup_idx on users (actual time=0.120..18.588 rows=89280.00 loops=1)
         Index Cond: ((signup_at >= '2023-01-01 00:00:00+00'::timestamp with time zone) AND (signup_at < '2023-02-01 00:00:00+00'::timestamp with time zone))
         Heap Fetches: 7
         Index Searches: 1
         Buffers: shared hit=285
 Planning:
   Buffers: shared hit=5
 Planning Time: 0.324 ms
 Execution Time: 28.253 ms

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

  • بعد VACUUM كان البحث يقرأ 284 صفحة من الفهرس فقط، وHeap Fetches: 0.
  • عدلنا 10% فقط من صفوف الشهر، ولكن كل صفحة فيها صف معدل فقدت علامة «مرئية للجميع»، فذهب PostgreSQL إلى الجدول 89,291 مرة، وارتفعت الصفحات إلى 2,359 والوقت إلى الضعف.
  • بعد VACUUM عادت العلامات وعادت الأرقام كما كانت تقريباً.

لذلك فالفهرس المغطي Covering Index الذي تضيف إليه أعمدة بالكلمة INCLUDE حتى يجيب الاستعلام منه وحده، كما في مقال الجداول الضخمة، يعمل بكامل سرعته على الجداول التي يمر عليها autovacuum بانتظام، أما على جدول يعدل طوال الوقت فقد يعود إلى قراءة الجدول في أغلب الصفوف، كما يشرح توثيق Index Only Scan.

ثمن الفهرس عند الكتابة

كل فهرس نسخة مرتبة إضافية من جزء من الجدول، وكل INSERT يجب أن يضيف مدخلاً في كل فهرس، في المكان الصحيح من الترتيب، وأن يكتب ذلك في سجل WAL. والآن على الجدول تسعة فهارس، وهذا حجمها مقارنة بالجدول:

          index          |  size
-------------------------+--------
 users_email_lower_idx   | 194 MB
 users_email_idx         | 194 MB
 users_email_pattern_idx | 194 MB
 users_city_signup_idx   | 153 MB
 users_email_trgm_idx    | 146 MB
 users_signup_idx        | 107 MB
 users_pkey              | 107 MB
 users_plan_idx          | 33 MB
 users_meta_idx          | 16 MB
(9 rows)

 table_size | all_indexes
------------+-------------
 748 MB     | 1143 MB
(1 row)

الفهارس معاً 1143 ميجابايت، أي أكبر من الجدول نفسه، وهذا شائع في تطبيقات عمرها سنوات. ولكي نقيس ثمن الكتابة، كتبنا إجراءً ينسخ 500 ألف مستخدم إلى جدول جديد فيه صفر، ثم واحد، ثم ثلاثة، ثم تسعة فهارس، ويقيس الوقت وحجم ال WAL الذي كتبه:

CREATE TEMP TABLE results (indexes int, ms numeric, wal text);
CREATE OR REPLACE PROCEDURE bench(n_idx int) LANGUAGE plpgsql AS $$
DECLARE t0 timestamptz; l0 pg_lsn;
BEGIN
  DROP TABLE IF EXISTS users_copy;
  CREATE TABLE users_copy (LIKE users);
  IF n_idx >= 1 THEN ALTER TABLE users_copy ADD PRIMARY KEY (id); END IF;
  IF n_idx >= 3 THEN CREATE INDEX ON users_copy (email); CREATE INDEX ON users_copy (signup_at); END IF;
  IF n_idx >= 9 THEN
    CREATE INDEX ON users_copy (plan); CREATE INDEX ON users_copy (city, signup_at);
    CREATE INDEX ON users_copy (lower(email)); CREATE INDEX ON users_copy (email text_pattern_ops);
    CREATE INDEX ON users_copy USING gin (email gin_trgm_ops); CREATE INDEX ON users_copy USING gin (meta jsonb_path_ops);
  END IF;
  CHECKPOINT;
  t0 := clock_timestamp(); l0 := pg_current_wal_lsn();
  INSERT INTO users_copy SELECT * FROM users WHERE id <= 500000;
  INSERT INTO results VALUES (n_idx, round(extract(epoch FROM clock_timestamp() - t0)::numeric * 1000),
                              pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), l0)));
END $$;
CALL bench(0);
CALL bench(1);
CALL bench(3);
CALL bench(9);
SELECT * FROM results;

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

 indexes |  ms   |  wal
---------+-------+--------
       0 |  3730 | 85 MB
       1 |  4206 | 102 MB
       3 | 20412 | 201 MB
       9 | 52286 | 793 MB
(4 rows)

الإدخال نفسه أخذ 3.7 ثانية دون فهارس، و52 ثانية مع تسعة فهارس، أي أبطأ بقرابة 14 مرة، وال WAL الذي يجب أن يكتب على القرص وينتقل إلى نسخ القراءة والنسخ الاحتياطي زاد من 85 إلى 793 ميجابايت. ولاحظ أن القفزة الكبيرة جاءت مع فهارس النص، فالبريد قيمة عشوائية الترتيب، وكل إدخال يذهب إلى صفحة مختلفة من الفهرس، أما فهرس GIN بالمقاطع الثلاثية فيضيف عشرات المداخل لكل بريد. والفكرة نفسها تشرح لماذا يتعب UUIDv4 الفهرس، كما قسناه في مقال المعرفات الفريدة.

التحديث السريع HOT: متى لا يلمس التحديث الفهارس؟

التحديث في PostgreSQL يكتب نسخة جديدة من الصف، والنسخة الجديدة لها عنوان جديد، فهل يجب تحديث كل الفهارس التسعة مع كل UPDATE؟ ليس دائماً، فهناك تحسين اسمه HOT، أي Heap-Only Tuple، وفيه تكتب النسخة الجديدة في الصفحة نفسها، وتبقى الفهارس تشير إلى العنوان القديم الذي يحول القارئ إلى الجديد، بشرطين كما في توثيق HOT: ألا يغير التحديث عموداً عليه فهرس، وأن توجد مساحة فارغة في الصفحة نفسها. والعمود last_login لا يوجد عليه فهرس، فلنحدثه لمئة ألف مستخدم متتالين:

VACUUM users;
SELECT pg_stat_reset_single_table_counters('users'::regclass);
\timing on
UPDATE users SET last_login = now() WHERE id BETWEEN 1000001 AND 1100000;
\timing off
SELECT pg_sleep(1.5);
SELECT n_tup_upd, n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = 'users';
Timing is on.
UPDATE 100000
Time: 20906.824 ms (00:20.907)
Timing is off.
 pg_sleep
----------

(1 row)

 n_tup_upd | n_tup_hot_upd
-----------+---------------
    100000 |            21
(1 row)

21 تحديثاً فقط من مئة ألف كان HOT، والتحديث أخذ 21 ثانية، والسبب الشرط الثاني: الجدول أنشئ بإدخال متواصل فصفحاته ممتلئة تماماً، فذهبت كل نسخة جديدة إلى صفحة أخرى واحتاجت مدخلاً جديداً في الفهارس التسعة. والإعداد fillfactor يترك مساحة فارغة في كل صفحة، وهو لا يطبق إلا على الصفحات التي تكتب بعده، لذلك أعدنا كتابة الجدول بالأمر VACUUM FULL، وهو أمر يقفل الجدول كاملاً ويصلح للمختبر فقط، ثم كررنا التحديث على مئة ألف مستخدم آخرين:

ALTER TABLE users SET (fillfactor = 85);
VACUUM FULL users;
ANALYZE users;
SELECT pg_stat_reset_single_table_counters('users'::regclass);
UPDATE users SET last_login = now() WHERE id BETWEEN 3000001 AND 3100000;
Timing is on.
VACUUM
Time: 215785.689 ms (03:35.786)
Timing is off.
ANALYZE
 table_size
------------
 884 MB
(1 row)

 pg_stat_reset_single_table_counters
-------------------------------------

(1 row)

Timing is on.
UPDATE 100000
Time: 5935.432 ms (00:05.935)
Timing is off.
 pg_sleep
----------

(1 row)

 n_tup_upd | n_tup_hot_upd
-----------+---------------
    100000 |         18185
(1 row)

الجدول كبر من 748 إلى 884 ميجابايت بسبب المساحة الفارغة، والتحديث نزل من 21 ثانية إلى ست ثوان، ولكن HOT كان 18% فقط، والسبب أننا حدثنا مستخدمين متتالين، أي كل الصفوف في كل صفحة، والمساحة الفارغة 15% تكفي لنسخ جديدة لبعضها فقط. وفي الواقع لا تحدث التطبيقات صفحة كاملة في وقت واحد، وإنما صفوفاً متفرقة، فكررنا التحديث على مئة ألف مستخدم متفرقين في كل الجدول:

SELECT pg_stat_reset_single_table_counters('users'::regclass);
UPDATE users SET last_login = now() WHERE id % 50 = 7;
Timing is on.
UPDATE 100000
Time: 50852.812 ms (00:50.853)
Timing is off.
 pg_sleep
----------

(1 row)

 n_tup_upd | n_tup_hot_upd
-----------+---------------
    100000 |        100000
(1 row)

كل التحديثات المئة ألف كانت HOT ولم تلمس أي فهرس، وأما الوقت في هذه التجربة فلا يقارن بما قبله، لأن الشرط id % 50 = 7 لا يستخدم فهرساً فقرأ الاستعلام الجدول كله. والدرس هنا مزدوج: كل فهرس على عمود يتغير كثيراً يمنع HOT لكل تحديث على هذا العمود، والصفحات الممتلئة تمنعه حتى للأعمدة التي لا فهرس عليها. ولذلك قبل أن تضيف فهرساً اسأل: كم مرة يقرأ هذا الاستعلام، وكم مرة يكتب الجدول؟ وراقب النسبة بين n_tup_hot_upd وn_tup_upd، واحذف الفهارس التي لا يستخدمها أحد. والصورة التالية تلخص الحالتين:

يسار الصورة رسم أعمدة لوقت إدخال 500 ألف صف: 3.7 ثانية دون فهارس، و4.2 مع فهرس واحد، و20.4 مع ثلاثة، و52.3 مع تسعة، ومعه حجم WAL من 85 إلى 793 ميجابايت. يمين الصورة صفحة جدول فيها مساحة فارغة: التحديث HOT يكتب النسخة الجديدة في الصفحة نفسها ولا يلمس الفهارس، والتحديث العادي ينقل النسخة إلى صفحة أخرى ويضيف مدخلاً في كل فهرس من الفهارس التسعة
كل فهرس يضاف إلى عمل كل إدخال، والتحديث HOT هو الحالة الوحيدة التي لا يلمس فيها التحديث الفهارس

قراءة خطة فيها ربط Join

الخطط الحقيقية فيها عدة جداول، والقراءة بالطريقة نفسها: من الداخل إلى الخارج، مع الانتباه إلى loops. لنسأل عن مجموع مشتريات كل مستخدم Enterprise في أبها، وقد أضفنا فهرساً على orders.user_id، وهو فهرس ينساه كثيرون لأن PostgreSQL لا ينشئه تلقائياً للمفتاح الأجنبي كما يذكر توثيق القيود:

CREATE INDEX orders_user_idx ON orders (user_id);
VACUUM ANALYZE users;
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.id, u.email, sum(o.total) AS spent
FROM users u JOIN orders o ON o.user_id = u.id
WHERE u.city = 'Abha' AND u.region = 'Asir' AND u.plan = 'enterprise'
GROUP BY u.id, u.email;
                                                                               QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 GroupAggregate  (cost=17926.82..17933.36 rows=242 width=62) (actual time=81.117..85.330 rows=2535.00 loops=1)
   Group Key: u.id
   Buffers: shared hit=16252 read=3
   I/O Timings: shared read=0.261
   ->  Sort  (cost=17926.82..17927.99 rows=468 width=36) (actual time=81.082..81.876 rows=5070.00 loops=1)
         Sort Key: u.id
         Sort Method: quicksort  Memory: 470kB
         Buffers: shared hit=16252 read=3
         I/O Timings: shared read=0.261
         ->  Nested Loop  (cost=6403.92..17906.06 rows=468 width=36) (actual time=59.595..79.846 rows=5070.00 loops=1)
               Buffers: shared hit=16252 read=3
               I/O Timings: shared read=0.261
               ->  Bitmap Heap Scan on users u  (cost=6403.48..14891.52 rows=242 width=30) (actual time=59.527..63.936 rows=2535.00 loops=1)
                     Recheck Cond: ((plan = 'enterprise'::text) AND (city = 'Abha'::text))
                     Filter: (region = 'Asir'::text)
                     Heap Blocks: exact=2554
                     Buffers: shared hit=3577 read=3
                     I/O Timings: shared read=0.261
                     ->  BitmapAnd  (cost=6403.48..6403.48 rows=2390 width=0) (actual time=57.979..57.981 rows=0.00 loops=1)
                           Buffers: shared hit=1026
                           ->  Bitmap Index Scan on users_plan_idx  (cost=0.00..526.05 rows=47683 width=0) (actual time=7.428..7.428 rows=51111.00 loops=1)
                                 Index Cond: (plan = 'enterprise'::text)
                                 Index Searches: 1
                                 Buffers: shared hit=46
                           ->  Bitmap Index Scan on users_city_signup_idx  (cost=0.00..5877.06 rows=250750 width=0) (actual time=46.374..46.374 rows=254018.00 loops=1)
                                 Index Cond: (city = 'Abha'::text)
                                 Index Searches: 1
                                 Buffers: shared hit=980
               ->  Index Scan using orders_user_idx on orders o  (cost=0.43..12.44 rows=2 width=14) (actual time=0.004..0.006 rows=2.00 loops=2535)
                     Index Cond: (user_id = u.id)
                     Index Searches: 2535
                     Buffers: shared hit=12675
 Planning:
   Buffers: shared hit=194
 Planning Time: 1.904 ms
 Execution Time: 85.838 ms

اقرأها من الداخل إلى الخارج كما يلي:

  1. عقدتا Bitmap Index Scan: الأولى تجمع من فهرس الباقة عناوين Enterprise، والثانية من الفهرس المركب عناوين أبها، ولاحظ أن PostgreSQL استخدم الفهرس المركب للعمود الأول وحده، وهذا مسموح دائماً.
  2. BitmapAnd: تقاطع الخريطتين، أي الصفحات التي فيها صفوف تحقق الشرطين، وهكذا يجمع PostgreSQL فهرسين منفصلين في استعلام واحد.
  3. Bitmap Heap Scan on users: يقرأ 2,554 صفحة من الجدول، ويطبق الشرط الثالث region = 'Asir' كمرشح Filter لأنه لا يوجد فهرس عليه، ويخرج 2,535 مستخدماً.
  4. Nested Loop: لكل مستخدم من الخطوة السابقة يبحث في الطلبات، ولذلك ترى في العقدة الداخلية loops=2535 وIndex Searches: 2535، وrows=2.00 هنا متوسط للمرة الواحدة، أي 5,070 طلباً في المجموع، وهو الرقم الذي تراه في العقدة فوقها.
  5. Sort ثم GroupAggregate: ترتيب الطلبات برقم المستخدم ثم جمعها، وSort Method: quicksort Memory: 470kB يعني أن الترتيب تم في الذاكرة دون أن يكتب ملفات مؤقتة على القرص، وإذا رأيت external merge Disk فهذا علامة على أن work_mem لا يكفي.

والآن قارن التقدير بالواقع في عقدة المستخدمين: قدر المخطط 242 صفاً، والحقيقة 2,535، أي أخطأ عشر مرات. لماذا؟ لأن المخطط يفترض افتراضياً أن الأعمدة مستقلة، فيضرب نسبة «أبها» في نسبة «عسير» في نسبة Enterprise، ولكن كل من في أبها هو في عسير أصلاً، فالشرط الثاني لا يستبعد أحداً. والخطأ هنا لم يغير الخطة لأن الأرقام صغيرة، ولكن في استعلامات أكبر يجعل المخطط يختار Nested Loop على مئات الآلاف من الصفوف بدل Hash Join، وهذه من أشهر أسباب الاستعلام الذي يبطئ فجأة. والحل أن تخبر PostgreSQL أن العمودين مرتبطان بالإحصاءات الموسعة CREATE STATISTICS:

CREATE STATISTICS users_city_region (dependencies) ON city, region FROM users;
ANALYZE users;

وبعد إعادة الاستعلام نفسه أصبحت عقدة المستخدمين كما يلي:

               ->  Bitmap Heap Scan on users u  (cost=6691.79..16212.26 rows=2705 width=30) (actual time=46.138..50.589 rows=2535.00 loops=1)

التقدير أصبح 2,705 والواقع 2,535، وهذا قريب جداً، وتشرح صفحة إحصاءات المخطط الأنواع الثلاثة من الإحصاءات الموسعة. وتذكر قاعدة عامة عند قراءة أي خطة: ابحث عن أول عقدة من الداخل يختلف فيها التقدير عن الواقع بعشرة أضعاف أو أكثر، فهناك في الغالب سبب المشكلة، وكل ما فوقها بني على رقم خاطئ.

ويجدر الإشارة هنا إلى أن عقدة فهرس الباقة أعطت rows=51111 مع أن عدد مستخدمي Enterprise 50,276، والسبب أن Bitmap Index Scan يعد مداخل الفهرس، وقد يبقى في الفهرس مداخل لنسخ قديمة من صفوف عدلناها، ثم يستبعدها Bitmap Heap Scan عند قراءة الجدول، فلا تعتمد على هذا الرقم كعدد للصفوف.

أخطاء شائعة

  • إضافة فهرس على كل عمود في جملة WHERE دون قياس، فيصبح حجم الفهارس أكبر من الجدول ويبطئ كل إدخال.
  • فهرس على عمود قيمه قليلة مثل الحالة أو الجنس أو الباقة، وهو لا يفيد إلا للقيم النادرة، والأنسب له فهرس جزئي.
  • كتابة دالة أو تحويل حول العمود في الشرط، مثل lower(email) أو created_at::date، ثم الاستغراب من أن الفهرس لا يستخدم.
  • توقع أن يعمل LIKE 'abc%' مع الفهرس العادي في قاعدة ترتيبها ليس C، أو أن يعمل LIKE '%abc%' مع أي B-tree.
  • نسيان الفهرس على المفتاح الأجنبي في الجدول الابن، فيبطئ الربط ويبطئ حذف الصف الأب.
  • قراءة rows وactual time في العقدة الداخلية دون ضربها في loops.
  • تشغيل EXPLAIN ANALYZE على UPDATE أو DELETE خارج معاملة يتم إلغاؤها.
  • الحكم على الفهرس من تشغيلة واحدة، فالمرة الأولى تقرأ من القرص والثانية من الذاكرة، وقد رأيت 668 ثم 110 ملي ثانية للاستعلام نفسه، لذلك قارن عدد الصفحات في Buffers.

حذف المختبر

الأمر التالي يحذف ال Container وال Volume والشبكة التي أنشأها ملف ال Compose فقط:

docker compose -p lab-idx down -v

الخلاصة

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

  • اقرأ خطة EXPLAIN من الداخل إلى الخارج، واضرب أرقام كل عقدة في loops، وقارن التقدير بالواقع، واعتمد على عدد الصفحات في Buffers أكثر من الوقت.
  • ال B-tree شجرة قليلة الطوابق بدرجة تفرع كبيرة، فوجدنا صفاً من خمسة ملايين بقراءة أربع صفحات بدل 95 ألفاً.
  • الفهرس يفيد عندما يطلب الاستعلام نسبة صغيرة من الصفوف، أو صفوفاً متجاورة في الملف، ويخسر أمام القراءة المتسلسلة عندما تكون الصفوف كثيرة ومتفرقة.
  • رتب الفهرس المركب حسب استعلاماتك، والبحث بالتخطي في PostgreSQL 18 ينقذ الاستعلام بالعمود الثاني فقط عندما تكون قيم العمود الأول قليلة.
  • الدالة حول العمود والتحويل وLIKE في قاعدة غير C تمنع الفهرس، والحل فهرس يطابق الاستعلام أو استعلام يطابق الفهرس.
  • فهارس GIN تبحث داخل النص وJSONB، وتعطي مرشحين يتحقق منهم PostgreSQL.
  • كل فهرس ثمنه مساحة ووقت وWAL مع كل كتابة، وقد أبطأت تسعة فهارس الإدخال 14 مرة، والتحديث HOT يحتاج عموداً بلا فهرس ومساحة فارغة في الصفحة.
  • عندما يخطئ المخطط في التقدير بسبب أعمدة مترابطة، فالإحصاءات الموسعة تصحح الرقم.

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

دليلكيف تخزن قاعدة البيانات بياناتك من الداخلماذا يحدث على القرص عند INSERT وUPDATE وCOMMIT، ولماذا تنجو قاعدة البيانات من الانهيار، وكيف تختلف LSM عن B-tree.دليلالأقفال Locks والجمود Deadlock في قواعد البياناتلماذا يوقف أمر ALTER TABLE صغير كل الاستعلامات خلفه، وكيف تقرأ pg_locks وتتجنب الجمود وظواهر العزل في PostgreSQL.دليلتقسيم الجداول Partitioning وتوزيعها على السيرفرات Shardingمتى تقسم الجدول داخل سيرفر واحد، ومتى توزع البيانات على عدة سيرفرات، وكيف تضيف سيرفراً وتتعامل مع العميل الضخم.

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

  • أكتوبر 2026: كتابة المقال، وتنفيذ كل التجارب على Docker Engine 29.8.0 مع PostgreSQL 18.6.
Could not load content
نشرة عرب رووت | ArabRoot

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

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

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

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