مستوى المقال: محترف — الكلام ده موجّه لمطور عنده تجربة مع SQL و EXPLAIN ANALYZE، وبيدير جداول فيها ملايين الصفوف. لو لسه بتبدأ مع قواعد البيانات، الفكرة هتوصلك من المثال البسيط في الأول، بس التطبيق الكامل يحتاج خلفية في الـ indexing.
ليه OFFSET بيبطّئ صفحاتك الأخيرة، والحل في سطر واحد
لو صفحة 5000 في الـ API بتاعتك بتاخد ثانية كاملة بينما صفحة 1 بترجع في 2 مللي ثانية، المشكلة مش في السيرفر ولا في غياب الـ index. المشكلة في OFFSET نفسه. لو غيّرت أسلوب الترقيم لـ Keyset (المعروف بالـ Seek Method)، أي صفحة هتتحمّل في زمن ثابت تقريبًا مهما كان رقمها.
المشكلة باختصار
تخيّل دليل تليفونات ورقي مرتّب بالاسم. لو طلبت منك الاسم رقم 100,020، إنت مش هتقدر تقفز له على طول. هتعدّ من الأول: واحد، اتنين، تلاتة... لحد ما توصل للرقم 100,000، وبعدها تبدأ تقرأ العشرين اسم اللي إنت عايزهم. الـ 100,000 اسم اللي عدّيت عليهم كانوا شغل ضايع بالكامل.
LIMIT 20 OFFSET 100000 بيعمل بالظبط ده. قاعدة البيانات لازم تولّد وتعدّ 100,000 صف قبل المطلوب، ترميهم، وترجّع العشرين الباقيين. الافتراض إن عندك جدول بـ 2 مليون صف ومستخدم بيتصفّح بعمق، التكلفة بتكبر خطّيًا مع رقم الصفحة. ده اللي بيخلّي الصفحات الأخيرة بطيئة بينما الأولى سريعة.
ليه OFFSET بطيء فعلًا (الشرح الدقيق)
الـ index من نوع B-tree في PostgreSQL مرتّب ويقدر يقفز لأي قيمة بحثًا عنها في زمن لوغاريتمي. لكن OFFSET مش قيمة يبحث عنها، هو عدّاد صفوف. المُحرّك مفيش عنده طريقة يعرف بيها "وين الصف رقم 100,000" غير إنه يمشي على الصفوف المرتّبة واحد واحد ويعدّهم. ده اللي بيسمّوه في خطة التنفيذ تكلفة على شكل rows removed by offset.
-- جدول الطلبات: 2 مليون صف، index على id
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
ORDER BY id
LIMIT 20 OFFSET 100000;
-- النتيجة المختصرة:
-- Limit (actual time=842.105..842.140 rows=20)
-- -> Index Scan using orders_pkey on orders
-- (actual rows=100020 loops=1)
-- Execution Time: 842.318 msلاحظ actual rows=100020. المُحرّك قرأ 100,020 صف فعلًا عشان يرمي منهم 100,000. ده مش index بطيء، ده استخدام غلط للـ index. والمشكلة بتتضاعف مع كل صفحة أعمق.
الحل: Keyset / Seek Method
الفكرة بسيطة: بدل ما تقول "تخطّ أول 100,000 صف"، قول "ابدأ من بعد آخر صف شفته". إنت بتحوّل الترقيم من عدّاد لـ شرط بحث، والشرط ده الـ index بيخدمه مباشرة.
- رتّب على عمود (أو أعمدة) فريد ومُفهرَس، زي
idأو(created_at, id). - في كل استجابة، خزّن قيمة آخر صف (الـ cursor) ورجّعها للـ frontend.
- الطلب اللي بعده يبعت الـ cursor، وإنت تستخدمه في شرط
WHEREبدلOFFSET.
-- الصفحة الأولى
SELECT * FROM orders
ORDER BY id
LIMIT 20;
-- الصفحات اللي بعدها: مرّر آخر id شفته (مثلاً 100000)
SELECT * FROM orders
WHERE id > 100000
ORDER BY id
LIMIT 20;
-- EXPLAIN: actual rows=20 فقط، Execution Time: ~2 msلو الترتيب على عمود مش فريد (زي created_at اللي ممكن يتكرر)، استخدم مقارنة مركّبة عشان متفقدش ولا صف ومتكرّرش حد:
SELECT * FROM orders
WHERE (created_at, id) > ('2026-06-19 10:00:00', 100000)
ORDER BY created_at, id
LIMIT 20;
-- ويفضّل index: CREATE INDEX ON orders (created_at, id);القياس على جدول 2 مليون صف: OFFSET عند صفحة 5000 وصل لـ 900 مللي ثانية، بينما Keyset فضل ثابت حوالي 2.2 مللي ثانية على نفس الصفحة. الفرق مش نسبة ثابتة، هو بيكبر كل ما المستخدم يغوص أعمق.
الـ trade-offs اللي لازم تعرفها
- القفز العشوائي للصفحات بيتكسر. بتكسب زمن ثابت، بتخسر القدرة تروح لـ "صفحة 5000" مباشرة. Keyset بيدعم "التالي/السابق" مش "اقفز لرقم". لو عندك pagination بأرقام صفحات في الـ UI، ده تغيير في تجربة المستخدم.
- الترتيب لازم يكون فريد وحتمي. لو رتّبت على عمود غير فريد من غير tie-breaker زي
id، ممكن تفقد أو تكرّر صفوف عند حدود الصفحات. - الـ index لازم يطابق الترتيب. الترتيب في
ORDER BYلازم يكون مغطّى بـ index بنفس الأعمدة ونفس الاتجاه، وإلا هترجع لنفس البطء. - العدّ الكلي (total count) منفصل. Keyset مبيديكش رقم الصفحات الإجمالي ببلاش. لو محتاج العدد، احسبه باستعلام منفصل أو بتقدير عبر
reltuples.
متى لا تستخدم Keyset Pagination
متستخدمهوش لو الجدول صغير (آلاف قليلة من الصفوف) — هنا OFFSET أبسط وفرق الأداء مش محسوس، والتعقيد الزيادة مش مبرّر. كمان متستخدمهوش لو المستخدم محتاج فعلًا يقفز لأرقام صفحات عشوائية أو عايز ترتيب متغيّر بشكل ديناميكي على أعمدة غير مُفهرَسة. وفي الـ data exports الكبيرة، فكّر في cursor على مستوى الـ DB أو COPY بدل أي pagination في طبقة التطبيق.
الخطوة التالية
افتح أبطأ endpoint فيه list بيستخدم OFFSET، وشغّل عليه EXPLAIN (ANALYZE, BUFFERS) على صفحة عميقة. لو شفت actual rows أكبر بكتير من الـ LIMIT، ده تأكيد إن OFFSET بيقرا ويرمي. حوّله لـ Keyset على عمود مُفهرَس وفريد، وقيس الفرق. لو الزمن مفضلش ثابت بعد التحويل، غالبًا الـ ORDER BY مش مغطّى بالـ index الصح.
مصادر
- توثيق PostgreSQL الرسمي — LIMIT and OFFSET، وملاحظتهم إن الصفوف اللي يتخطّاها OFFSET لازم تُحسب داخليًا (postgresql.org/docs).
- توثيق PostgreSQL — Indexes and ORDER BY حول مطابقة الـ index لاتجاه الترتيب (postgresql.org/docs/current/indexes-ordering.html).
- Markus Winand — "No Offset" و use-the-index-luke.com، المرجع الأشهر في شرح الـ Seek Method وحدود OFFSET.
- أرقام الزمن في المقال تقديرية ومقاسة عبر EXPLAIN ANALYZE على جدول اختباري بـ 2 مليون صف على PostgreSQL 16، وتختلف حسب العتاد وحجم الصف والـ caching.