التصنيف القائم على المؤشر مقابل التصنيف بالمسافة — لماذا يُفقد السجلات تلقائيًا عند page=2
توجد خطأ قابل للإعادة في تصفح الصفوف المُستَنَفَّة: تُتجاهل السجلات عند إدراج صفوف جديدة بين طلبات الصفحة. إليك الـ SQL الذي يثبت ذلك، ومتى يجب استخدام تصفح المُرشح بدلاً من ذلك.
أنت تُنشئ تدفقًا. تُحمّل الصفحة 1، ثم يُجرِّس المستخدم إلى الأسفل، وتُستَمَرَّ عملية تحميل الصفحة 2. لكن بعض العناصر التي كانت موجودة عند تحميل الصفحة 1 لا تظهر على الصفحة 2. لا توجد خطأ، ولا تحذير — فقط لا تظهر.
هذا هو عيب تصفية الموضع، ولا يُعدّ حالة توازي. إنه مُحدد. كل نظام يستخدم LIMIT x OFFSET y مُقابلًا لجدول حي وله نشاط كتابي سيء سيُواجهه في النهاية.
الاستعلام SQL الذي يسببه
تُترجم تصفية الموضع إلى:
-- Page 1: rows 1–10
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 0;
-- Page 2: rows 11–20
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;
لا يمتلك قاعدة البيانات ذاكرة عن ما كان عليه الصفحة 1 عند طلب الصفحة 2. يُعدّ من الموضع 0 كل مرة، مُقابلًا للصفوف الموجودة في تلك اللحظة.
السيناريو الذي يُسببه التلف
ابدأ بـ 20 مشاركة، مع أرقام 1 إلى 20، من الأحدث إلى الأقدم. يُحمّل المستخدم الصفحة 1 — يحصل على الأرقام 20 إلى 11. بين هذا الطلب والآخر، يتم إدخال ثلاث مشاركات جديدة: الأرقام 21، 22، 23.
الآن، يُجرِّس المستخدم ويطلب الصفحة 2:
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;
-- Returns: IDs 13–4, not 10–1
المراتب 10، 9، و8 — التي لم يرها المستخدم بعد — تم دفعها بفعل الموضع. أدى إدخال ثلاث مشاركات جديدة إلى تجاهل ثلاث سجلات. الحساب دقيق دائمًا.
يُرى المستخدم لا شيء خاطئ. تُظهر السجلات لا شيء خاطئ. توجد السجلات في قاعدة البيانات. لكنها غير مرئية في هذا الجلسة.
تُحلّل تصفية الموضع بالمؤشر
بدلاً من التصنيف من الموضع، يُربط التالية بالسجل الأخير الذي تم إرجاعه. يصبح الاستعلام "أعطيني 10 صفوف بعد هذا السجل المحدد" بدلًا من "أعطيني الصفوف 11 إلى 20."
-- Page 1: no cursor, start from top
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 10;
-- Last row returned: created_at = '2024-01-10 12:00:00', id = 11
-- Page 2: anchor to that exact row
SELECT * FROM posts
WHERE (created_at < '2024-01-10 12:00:00')
OR (created_at = '2024-01-10 12:00:00' AND id < 11)
ORDER BY created_at DESC, id DESC
LIMIT 10;
لا تؤثر إدخالات جديدة على هذا تمامًا. تُوضع في أعلى ترتيب الترتيب، قبل موضع المؤشر. كل شيء من المؤشر إلى الأسفل يبقى مستقرًا بغض النظر عن الكتابات المتوازية.
ما هو المؤشر الفعلي
المؤشر هو الموضع المُرمّز للسجل الأخير تم إرجاعه. في الممارسة، يكون عادةً أحد:
- زوج زمني + رقم مُرمّز — الطريقة الأكثر شيوعًا. أنشئ
{"created_at": "...", "id": 11}بشكل base64 وارجعه كـnext_cursor. - مُعرف مُغلق أو معرف مُستقل — بعض الأنظمة تستخدم المعرف الأساسي مباشرة عندما يكون الترتيب الزمني (ULIDs، UUID v7). يُستخدم مُولد UUID عندما تُقرر بين أنماط المُعرفات لخطة التصميم — v4 عشوائي ولا يُرتّب زمنيًا، v7 مبني على الوقت ويُرتّب. مُولد مُعرفات مفيد عند اتخاذ قرار بين أنماط المُعرفات لخطة التصميم — v4 عشوائي ولا يُرتّب زمنيًا، v7 مبني على الوقت ويُرتّب.
- مُدخل مُوقّع — طريقة ستريت، تمنع العملاء من إنشاء قيم مؤشر تتجاوز موضعًا عشوائيًا.
عندما يكون تصفية الموضع مناسبًا
لا تحتاج كل حالة إلى مؤشرات. تُعتبر تصفية الموضع مناسبة عندما:
- البيانات ثابتة أو تُضاف فقط من الأسفل. صفحات الملفات، ووثائق، وصفحات التصدير. إذا لم تُدخل أي بيانات في الأعلى من ترتيب الترتيب، يبقى الموضع مستقرًا.
- مُنظّمات الإدارة مع أرقام صفحات محددة. يُتوقع من المستخدم الانتقال إلى "الصفحة 5" ويُرى ذلك الجزء المحدد. لا يمكن للمؤشرات أن تفعل ذلك — لا يوجد مفهوم لـ "الصفحة N" عندما يكون المُؤشر سجلًا وليس عددًا.
- بيانات صغيرة تحت سيطرتك. إذا كنت تُصفّي 200 سجل في أداة داخلية حيث تُدخل البيانات عبر مهام يومية؟ فإن تعقيد المؤشرات ليس مبررًا.
- تُدخل البيانات فقط في الأسفل من ترتيب الترتيب. إذا كان ترتيب التصفية تصاعديًا من خلال المعرف، فإن السجلات الجديدة تُدخل أرقامًا متزايدة، وبالتالي لا تؤثر على الصفحات المبكرة.
السؤال المحدد: هل يمكن إدخال سجلات جديدة 🔒 فحوصات الأمان: في موضع المؤشر بين الطلبات؟ إذا كان الجواب نعم، فإن تصفية الموضع ستفقد السجلات بشكل سلبي. إذا كان الجواب لا، فهي مناسبة.
مقارنة
| تصفية الموضع | تصفية الموضع بالمؤشر | |
|---|---|---|
| SQL | LIMIT x OFFSET y | WHERE (created_at, id) < (cursor) LIMIT x |
| مُستقر في حالة التدخل المتوازي | لا — يُستبعد السجلات بشكل سلبي | نعم — الموضع يبقى مستقرًا |
| الانتقال إلى صفحة عشوائية | نعم (OFFSET = page * size) | لا — التسلسل فقط متوازي |
| إجمالي عدد الصفحات | سهل (COUNT(*) / size) | غير ممكن بدون مسح كامل |
| الأداء عند مسافات كبيرة | يُضعف — OFFSET 100000 يُمسك 100 ألف سجل لاستبعادها | مُستقر — يستخدم دائمًا مسح مُحدد للإحداثيات |
| تعقيد العميل | منخفض — مجرد رقم صفحة | أعلى — يجب تخزين ونقل مفتاح المؤشر |
كيف تتعامل معه غِيتيه وستريت
تستخدم كلا من واجهة غِيتيه وستريت تصفية الموضع بالمؤشر في واجهات برمجية الخاصة بهم — وللأسف الأسباب المحددة لكل منهما.
جيثب
تُستخدم واجهة غِيتيه REST على معظم النطاقات، ولكن واجهة GraphQL تستخدم تصفية الموضع بالمؤشر مع page و per_page هذا المؤشر ( after و before:
query {
repository(owner: "vercel", name: "next.js") {
issues(first: 10, after: "Y3Vyc29yOnYyOpHOAABGPQ==") {
pageInfo {
endCursor
hasNextPage
}
nodes {
title
number
}
}
}
}
مُرمّز بـ base64 — عند التشفير، تُحصل على مرجع سجل داخلي، وليس رقم صفحة. على مثال مُخزن مثل next.js، يمكن أن تُرفع عشرات المشكلات بين طلب الصفحة 1 وطلب الصفحة 2. مع تصفية الموضع، ستُستبعد بعض هذه المشكلات بشكل سلبي.Y3Vyc29yOnYyOpHOAABGPQ==)
Stripe
تستخدم واجهات قائمة ستريت starting_after و ending_before — تُرسل معرف السجل الأخير تم استلامه:
# First page
curl https://api.stripe.com/v1/charges -u sk_test_xxx: -d limit=10
# Next page — pass the last charge ID from the previous response
curl https://api.stripe.com/v1/charges -u sk_test_xxx: -d limit=10 -d starting_after=ch_1ABC123def456
تُحتوي على مُعرفات مُرتّبة زمنيًا (ch_, cus_, pi_)، لذا فإن المعرف نفسه يُستخدم كمُؤشر. لم تُقدّم واجهة ستريت تصفية موضعية لأن البيانات المالية تصبح مشكلة خطيرة عند التسرب — فقد لا يتم تحميل سداد مالي ليس فقط مشكلة تجربة المستخدم.
إطلاق المؤشرات: نمط بسيط
إليك نمطًا بسيطًا لتنفيذ Node.js / PostgreSQL باستخدام مؤشر مركب من الوقت والرقم المُرمّز:
function encodeCursor(row) {
return Buffer.from(JSON.stringify({
created_at: row.created_at,
id: row.id
})).toString('base64url');
}
function decodeCursor(cursor) {
return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
}
async function fetchPage(db, cursor = null, limit = 10) {
let rows;
if (!cursor) {
rows = await db.query(
'SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT $1',
[limit]
);
} else {
const { created_at, id } = decodeCursor(cursor);
// PostgreSQL supports tuple comparison natively.
// MySQL requires: WHERE created_at < $1 OR (created_at = $1 AND id < $2)
rows = await db.query(
`SELECT * FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3`,
[created_at, id, limit]
);
}
const nextCursor = rows.length === limit
? encodeCursor(rows[rows.length - 1])
: null;
return { rows, nextCursor };
}
شيء يجب التأكد منه: إذا كنت تستخدم مُعرفات UUID بدلًا من أرقام تُزداد تلقائيًا، فإن الترتيب المركب يعمل فقط إذا كانت مُعرفاتك مُرتّبة زمنيًا. مُعرفات UUID v4 عشوائية — الترتيب من خلال (created_at, uuid_v4) يُعمل، لكن تفقد الضمان المُضمن من المعرف المتسلسل. مُعرفات UUID v7 أو ULIDs مُرتّبة زمنيًا وتتجنب هذه المشكلة تمامًا.
النسخة المختصرة
تصفية الموضع بسيطة للاستعمال وتعمل بشكل جيد حتى في حالة وجود بيانات حية ومتعددة الكتابات. في اللحظة التي يُمكن فيها المستخدمون إدخال سجلات بين طلبات التصفية — مثل المنشورات، السدادات، المشكلات، أي شيء — تُستبعد السجلات بشكل سلبي. عدد السجلات المُستبعد يساوي عدد الإدخالات الجديدة في الأعلى من ترتيب الترتيب. لا يوجد خطأ، ولا تحذير.
تصفية الموضع بالمؤشر تُبقي على الاستقرار في الموضع بدلًا من القدرة على الانتقال إلى صفحة معينة. في التدفقات المُعرضة للمستخدمين أو أي واجهة تُبنى عليها من قبل الآخرين، فإن هذا هو التبادل الصحيح. تُعتبر تصفية الموضع مناسبة في أدوات الإدارة والتصديرات الثابتة — لا تُضيف تعقيدات المؤشرات حيث لا يمكن أن تحدث المشكلة.
تثبيت ملحقاتنا
أضف أدوات IO إلى متصفحك المفضل للوصول الفوري والبحث بشكل أسرع
恵 وصلت لوحة النتائج!
لوحة النتائج هي طريقة ممتعة لتتبع ألعابك، يتم تخزين جميع البيانات في متصفحك. المزيد من الميزات قريبا!
