تخطّى إلى المحتوى الرئيسي
لماذا يصبح الترقيم بـ OFFSET أبطأ مع كل صفحة: مؤشرات keyset ومفاتيح الترتيب المستقرة والفهرس الذي يجعلها تعمل

لماذا يصبح الترقيم بـ OFFSET أبطأ مع كل صفحة: مؤشرات keyset ومفاتيح الترتيب المستقرة والفهرس الذي يجعلها تعمل

September 21, 2026
هندسة الخوادم
7 دقيقة قراءة

أبرز النقاط

• تكلفة الترقيم بـ OFFSET متناسبة مع العمق: الجملة LIMIT 20 OFFSET 10000 تجعل PostgreSQL ينتج 10,020 صفًا بالترتيب ثم يتخلص من 10,000 منها.

• يستبدل ترقيم keyset عملية التخطي ببحث نطاقي: الشرط WHERE (created_at, id) < ($1, $2) يبدأ مسح الفهرس من آخر صف في الصفحة السابقة، فتصبح تكلفة الصفحة 500 قريبة من تكلفة الصفحة الأولى.

• يجب أن ينتهي كل ترتيب مستخدم في الترقيم بعمود فاصل فريد، وعادةً يكون المفتاح الأساسي، وإلا تُتخطى الصفوف المتساوية في قيمة الترتيب أو تتكرر عند حدود الصفحة.

• يجب أن يطابق الفهرس المركّب جملة ORDER BY بما في ذلك اتجاه الفرز؛ والترتيب المختلط مثل created_at DESC, id ASC يحتاج فهرسًا خاصًا به.

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

لماذا يصبح الترقيم بـ OFFSET أبطأ في الصفحات المتأخرة؟

لا تسمح OFFSET لـ PostgreSQL بالبدء من الصف رقم n. ينتج المنفّذ الصفوف بالترتيب من البداية ويتخلص من أول n صفًا قبل إعادة أي نتيجة، فيكون الجهد متناسبًا مع مجموع OFFSET وLIMIT. ويوضح EXPLAIN ANALYZE ذلك صراحةً: عقدة Limit تعيد 20 صفًا بينما مسح الفهرس التابع لها يسجل 10,020 صفًا فعليًا.

كما أن OFFSET غير مستقر أثناء عمليات الكتابة المتزامنة، لأنه يعد مواضع داخل نتيجة يُعاد حسابها مع كل طلب؛ فإدراج صف أعلى النافذة يزيح كل شيء لأسفل ويرى المستخدم صفًا مكررًا، بينما يؤدي الحذف إلى تخطي صف لا يلاحظ أحد غيابه.

ما هو ترقيم keyset وكيف يختلف الاستعلام؟

يحتفظ ترقيم keyset، ويسمى أيضًا ترقيم البحث، بقيم مفتاح الترتيب لآخر صف في الصفحة السابقة ويطلب الصفوف التالية لتلك القيم تمامًا. المؤشر هنا بيانات وليس موضعًا. يصبح الاستعلام: SELECT id, title, created_at FROM posts WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 20.

التعبير (created_at, id) < ($1, $2) هو مقارنة قيم صفية يقيّمها PostgreSQL معجميًا ويحوّلها إلى مسح نطاقي على الفهرس. أما الصيغة الموسّعة created_at < $1 OR (created_at = $1 AND id < $2) فتعيد النتائج نفسها لكنها شرط OR غالبًا ما يتعامل معه المخطط بكفاءة أقل، لذا نحتفظ بصيغة القيم الصفية ونتحقق بـ EXPLAIN عند إعادة كتابتها من قبل أدوات ORM.

نطلب صفًا واحدًا زائدًا عن حجم الصفحة؛ فإن وُجد الصف الإضافي فهناك صفحة تالية، ويصبح مفتاحه هو المؤشر التالي، ما يغني عن استعلام منفصل للتحقق.

أي فهرس يحتاجه استعلام keyset فعليًا؟

يجب أن يغطي الفهرس مجموعة أعمدة ORDER BY بالترتيب نفسه وباتجاه متوافق. فمع ORDER BY created_at DESC, id DESC يعمل فهرس على (created_at DESC, id DESC)، ويعمل كذلك (created_at ASC, id ASC) لأن PostgreSQL يستطيع مسح شجرة B للخلف عندما تنعكس كل الأعمدة معًا.

الاتجاهات المختلطة هي الفخ: الترتيب created_at DESC, id ASC لا يخدمه أي من الفهرسين السابقين لأن أي مسح عكسي واحد لا ينتج هذا الترتيب. لذلك يحتاج كل خيار فرز يقدمه المنتج فهرسًا مركّبًا خاصًا ينتهي بعمود الفصل.

وعندما تكون الواجهة محصورة دائمًا داخل مستأجر واحد، يجب أن يبدأ الفهرس بعمود التصفية — (tenant_id, created_at DESC, id DESC) — ليقع البحث داخل شريحة المستأجر لا عبر الجدول كله.

كيف تبقى المؤشرات صحيحة أثناء الإضافة والحذف؟

تصمد مؤشرات keyset أمام الكتابة المتزامنة لأنها تسمي قيمًا لا مواضع. فحذف الصف الذي يشير إليه المؤشر لا يكسر الطلب التالي، إذ تُقيَّم المقارنة مقابل القيم المخزنة ولا يلزم أن تظل تلك القيم موجودة في الجدول.

ويجب أن يكون مفتاح الترتيب فريدًا ككل. فالفرز بعمود غير فريد وحده يعني اشتراك عدة صفوف في قيمة المؤشر، فتُسقِط المقارنة الصارمة بقية الصفوف بينما تكرر المقارنة غير الصارمة صف الحدود. وإلحاق المفتاح الأساسي يزيل هذه المفاضلة.

أدق الأعطال التي واجهناها كان في الدقة. يخزن نوع timestamptz في PostgreSQL أجزاء المليون من الثانية بينما يحمل كائن Date في JavaScript أجزاء الألف فقط، فتمرير المؤشر عبر new Date(value).toISOString() يقتطع ثلاث خانات، وتأتي القيمة المقتطعة بعد الصف الحقيقي في الترتيب فتتخطى بصمت كل صف في تلك الميلي ثانية. صرنا نعامل الطابع الزمني كسلسلة نصية مبهمة، ونحمل القيمة الخام كما أعادها المشغّل داخل المؤشر، ونعيدها نصًا ليحوّلها PostgreSQL بنفسه.

كيف يجب ترميز المؤشر في واجهة برمجية عامة؟

نرمّز المؤشر كـ JSON بترميز base64url ليتعامل معه العملاء ككيان مبهم، ثم نتحقق منه عند العودة بوصفه مدخلًا غير موثوق. والإبهام ليس سرية: base64 ترميز لا تشفير، وأي عميل يستطيع فك المؤشر وتعديله.

ينبغي أن يعيد المؤشر التالف الرمز 400 بدل الرجوع الصامت إلى الصفحة الأولى، لأن الرجوع الصامت يحوّل خطأ العميل إلى حلقة تمرير لا نهائية. كما يجب ألا يحمل المؤشر أي صلاحيات؛ تُعاد تصفية المستأجر والأذونات على الخادم من الجلسة، فيحصل من يعدّل مؤشرًا على صفحة فارغة لا على صفوف عميل آخر.

كيف يتكامل ذلك مع قوائم TanStack Query اللانهائية؟

صُمم useInfiniteQuery في TanStack Query v5 للمؤشرات: يزرع initialPageParam الطلب الأول، ويقرأ getNextPageParam المؤشر التالي من استجابة آخر صفحة. وإعادة undefined من getNextPageParam هي ما يجعل hasNextPage تساوي false، بينما إعادة null لا تفعل ذلك، وهو السبب المعتاد في بقاء زر تحميل المزيد ظاهرًا إلى الأبد.

يجب أن يكون ترتيب الفرز جزءًا من queryKey، لأن تغيير الفرز يبطل كل المؤشرات، ومفتاح مشترك سيُلحق صفوفًا جُلبت بالترتيب القديم بقائمة بُنيت بالترتيب الجديد. أما خيار maxPages فيحد من عدد الصفحات المخزنة لكنه يتطلب getNextPageParam وgetPreviousPageParam معًا، لأن إسقاط صفحات من طرف يستلزم القدرة على إعادة جلبها من الطرف الآخر.

متى يبقى OFFSET الخيار الصحيح؟

• التكلفة عند الصفحة 500: ترتفع مع العمق في OFFSET، وتبقى ثابتة مع keyset.

• الانتقال إلى صفحة عشوائية: متاح مع OFFSET وغير متاح مع مؤشرات keyset.

• إجمالي عدد الصفحات: متاح عبر COUNT مع OFFSET، بينما يحتاج keyset عدًا منفصلًا أو تقديريًا.

• الصحة أثناء الإدراج المتزامن: تنزاح الصفوف مع OFFSET، بينما يقارن keyset القيم فلا تنزاح.

• متطلب الفهرسة: فهرس واحد مطابق للترتيب مع OFFSET، وفهرس لكل خيار فرز ينتهي بعمود فاصل فريد مع keyset.

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

الأسئلة الشائعة

س: هل يمكن عرض صفحة 4 من 120 مع ترقيم keyset؟

ج: ليس مباشرة، لأن المؤشر يعرف موضعه ولا يعرف عدد الصفحات التي تسبقه. اعرض تقديرًا من إحصاءات reltuples في PostgreSQL أو عدًا مخزنًا مؤقتًا، وقدّم القائمة بأسلوب تحميل المزيد بدل أرقام الصفحات.

س: هل يعمل ترقيم keyset عندما يختار المستخدم عمود الفرز؟

ج: نعم، بشرط أن يملك كل خيار فرز فهرسًا مركّبًا ينتهي بالمفتاح الأساسي، وأن يسجل المؤشر الفرز الذي أُنشئ تحته. وتغيير الفرز يجب أن يعيد ضبط المؤشر.

س: هل يستطيع Prisma تنفيذ ترقيم keyset؟

ج: خيار cursor في Prisma مع take وskip: 1 ينفذ ترقيم keyset على حقل فريد، وهو ما يغطي الفرز بـ id. أما الفرز بعمود غير فريد مع عمود فاصل فيحتاج استعلامًا خامًا أو أداة مثل Drizzle قادرة على التعبير عن مقارنة القيم الصفية.

س: هل ينطبق هذا خارج PostgreSQL؟

ج: الأسلوب عام. يدعم MySQL 8 صيغة مقارنة القيم الصفية نفسها، وخاصية search_after في Elasticsearch هي الفكرة ذاتها بمصفوفة قيم فرز. تحقق دائمًا بـ EXPLAIN من أن المحرك أنتج مسحًا نطاقيًا على الفهرس لا مجرد تصفية.

س: هل يكفي base64 لمنع العبث بالمؤشرات؟

ج: لا. تحقق من بنية المؤشر بعد فكه، وأعد تطبيق تصفية الصلاحيات على الخادم، ووقّع المؤشر بـ HMAC إذا كان اكتشاف العبث مطلوبًا لا مجرد النجاة منه.