Cursor Pagination לעומק: קידוד נכון, אינדקסים ומקרי קצה שקל לפספס

מאת צוות מדיה דיל · 04.08.2026 · API Engineering · 8 דק׳

Cursor Pagination פותר את בעיות הביצועים של Offset, אבל רק כשמקפידים על Tiebreaker, אינדקסים תואמים וטיפול נכון בדפדוף אחורה.

צוות פיתוח מדווח שהאינטגרציה שלהם עם ה-API "מדלגת" על רשומות - לקוח שמדפדף דרך רשימת הזמנות מגלה שכל כמה עמודים חסרה הזמנה שלמה, בלי שום שגיאה גלויה. אחרי חקירה מתברר שה-Cursor שנבנה במערכת מקודד רק לפי created_at, ולשתי הזמנות שנוצרו באותה מילישנייה בדיוק (בעומס גבוה זה קורה) יש אותו Cursor - כך שהעמוד הבא "קופץ" מעל אחת מהן. זו דוגמה טיפוסית לכך ש-Cursor Pagination, שנתפס לרוב כפתרון "הנכון מכל הבחינות" מול Offset, מסתיר בתוכו לא מעט מלכודות שרק מימוש מדויק נמנע מהן.

למה בכלל צריך Cursor ולא מספיק "עמוד הבא"

הרעיון הבסיסי של Cursor Pagination הוא להחליף מיקום יחסי (עמוד מספר 40) במצביע אטום שמצביע על נקודה קונקרטית בתוך אוסף הנתונים הממוין. במקום לבקש מהשרת "תן לי את 20 הרשומות שמתחילות במיקום ה-800", הלקוח מבקש "תן לי את 20 הרשומות הבאות אחרי הרשומה הזו". ההבדל הזה נשמע קוסמטי אבל הוא משנה לחלוטין את אופי השאילתה במסד הנתונים - היא הופכת מסריקה שדורשת לספור ולדלג, לשאילתת טווח (Range Query) שיכולה לנצל אינדקס ולרוץ בזמן קבוע ללא תלות בעומק הדפדוף. זה גם פותר את בעיית העקביות בזמן כתיבה מקבילית: כל עוד הרשומה שסימנה את הנקודה האחרונה לא נמחקה, המשך הדפדוף תקין גם אם נוספו רשומות חדשות למעלה.

מבנה ה-Cursor - מה בדיוק מקודדים בפנים

Cursor טוב הוא לא סתם מספר ID גולמי. הוא צריך לקודד את כל המידע הדרוש כדי לשחזר את "נקודת העצירה" בצורה חד-משמעית, כולל טיפול במקרים שבהם עמודת המיון אינה ייחודית. המימוש הנפוץ ביותר הוא Base64 של אובייקט JSON שמכיל את ערך עמודת המיון בתוספת מזהה ייחודי כ-Tiebreaker:

function encodeCursor(sortValue, id) {
  const payload = JSON.stringify({ v: sortValue, id });
  return Buffer.from(payload).toString('base64url');
}

function decodeCursor(cursor) {
  const raw = Buffer.from(cursor, 'base64url').toString();
  return JSON.parse(raw);
}

הקידוד ל-Base64 משרת מטרה כפולה: הוא הופך את ה-Cursor ל"אטום" מבחינת הלקוח (שלא אמור לפרסר אותו או להסתמך על מבנהו), וגם מקל על העברתו בבטחה בתוך פרמטרי URL בלי בעיות תווים מיוחדים. חשוב להדגיש - זו לא הצפנה אמיתית ואסור לסמוך עליה כמנגנון אבטחה. אם ה-Cursor חושף מידע רגיש (כמו ID פנימי שלא אמור להיות גלוי), יש להוסיף שכבת חתימה (HMAC) שמונעת מהלקוח לזייף Cursor שמצביע על נתונים שהוא לא אמור לגשת אליהם.

הבעיה של עמודות מיון לא ייחודיות

זו בדיוק הבעיה מהתרחיש שפתחנו איתו. אם ממיינים לפי created_at בלבד וכמה רשומות נוצרו באותה מילישנייה, שאילתת ה-Cursor WHERE created_at > X עלולה לדלג על רשומות עם אותו timestamp בדיוק, או להחזיר אותן פעמיים בדפדוף חוזר. הפתרון היחיד שעובד באמינות הוא Keyset מרוכב - שילוב של עמודת המיון עם עמודה ייחודית (בדרך כלל ה-ID הראשי) כ-Tiebreaker, ובניית תנאי ה-WHERE כך שהוא תקין גם כשהערכים הראשיים שווים:

SELECT * FROM orders
WHERE (created_at, id) > (:cursor_created_at, :cursor_id)
ORDER BY created_at, id
LIMIT 20;

התחביר הזה, המכונה Row Value Comparison, נתמך במרבית מסדי הנתונים היחסיים (PostgreSQL, MySQL גרסה עדכנית) והוא הדרך הנכונה לבטא "אחרי הנקודה הזו" כשיש שני קריטריוני מיון. שימוש בתנאי OR ידני במקום זאת (created_at > X OR (created_at = X AND id > Y)) עובד באותה צורה לוגית אבל בדרך כלל פחות יעיל מבחינת תכנון השאילתה (Query Planner) של מסד הנתונים.

אינדקסים - התנאי ההכרחי שקל לשכוח

כל היתרון הביצועי של Cursor Pagination תלוי לחלוטין באינדקס מרוכב (Composite Index) שתואם בדיוק לסדר עמודות המיון. אינדקס על created_at בלבד לא מספיק אם המיון בפועל הוא לפי (created_at, id) - מסד הנתונים עדיין יצטרך למיין ידנית חלק מהתוצאות. יש לבנות את האינדקס בדיוק לפי סדר ה-ORDER BY, כולל כיוון המיון (עולה או יורד) אם המנוע הספציפי רגיש לכך. טעות נפוצה היא הוספת אינדקס רק אחרי שהמערכת כבר בפרודקשן וסובלת מביצועים גרועים - כדאי לוודא באמצעות EXPLAIN ANALYZE כבר בשלב הפיתוח שהשאילתה אכן משתמשת באינדקס (Index Scan) ולא בסריקת טבלה מלאה (Sequential Scan).

דפדוף אחורה - הכיוון שרוב הצוותים שוכחים

מרבית המימושים של Cursor Pagination תומכים היטב ב"הבא" אבל שוכחים לגמרי מ"הקודם". דפדוף אחורה דורש היפוך של כיוון האי-שוויון (< במקום >) וגם היפוך של כיוון המיון עצמו בשאילתה, ואז היפוך חוזר של סדר התוצאות לפני החזרתן ללקוח - כדי שהעמוד יוצג באותו סדר הגיוני. זו נקודה שקל לפספס בבדיקות כי היא לא נבדקת אלא אם מישהו בכוונה מנסה לחזור אחורה, ובפועל היא קריטית לכל ממשק שמאפשר ניווט דו-כיווני בין עמודים, לא רק "עוד ועוד".

בדיקות אוטומטיות שתופסות בעיות לפני פרודקשן

מכיוון שהבאגים המסוכנים ביותר ב-Cursor Pagination מתגלים רק במקרי קצה נדירים (ערכים כפולים, מחיקה באמצע דפדוף, עומס גבוה), חשוב לכתוב בדיקות שמדמות אותם במפורש ולא לסמוך על בדיקה ידנית. בדיקה שימושית ראשונה היא יצירת עשרות רשומות עם אותו ערך מיון בדיוק (לדוגמה, הכנסת אצווה שלמה עם אותו created_at נעול מראש) ואימות שדפדוף מלא על פני האצווה הזו לא מדלג ולא מכפיל אף רשומה. בדיקה שנייה מדמה מחיקה של הרשומה שסימנה את ה-Cursor האחרון בדיוק בין קריאה לקריאה, ומוודאת שהעמוד הבא עדיין מוחזר נכון. בדיקה שלישית, שקל לשכוח, בודקת עקביות בין דפדוף קדימה לאחור - דפדוף שלוש עמודים קדימה ואז שני עמודים אחורה צריך להחזיר בדיוק לאותה נקודה שבה היה הלקוח אחרי עמוד אחד קדימה. שילוב הבדיקות האלה ב-CI, על טבלת בדיקה עם כמות רשומות משמעותית ולא רק כמה שורות לדוגמה, הוא ההבדל בין לגלות בעיה בסביבת פיתוח לבין לגלות אותה דרך פנייה של לקוח כועס.

תמיכת ORM-ים ומסגרות עבודה נפוצות

לא כל ORM תומך היטב ב-Keyset Pagination מהקופסה. ORM-ים כמו Prisma או TypeORM דורשים בדרך כלל בניית תנאי ה-WHERE הרב-עמודתי באופן ידני, כי ה-API הגבוה שלהם לא תמיד חושף ישירות את התחביר של Row Value Comparison. חלק מהצוותים פותרים את זה בכתיבת Query Builder ייעודי או שאילתת SQL גולמית לנתיב הזה בלבד, גם אם שאר האפליקציה משתמשת ב-ORM לכל השאר - זו פשרה סבירה כי הנתיב הזה קריטי לביצועים ומצדיק בקרה מדויקת יותר. ספריות ייעודיות ל-Keyset Pagination (כמו implementations בסגנון Relay Cursor Connections עבור GraphQL, או ספריות Keyset ל-SQLAlchemy בפייתון) כבר פתרו חלק ניכר מהבעיות שתוארו כאן, ושווה לבדוק אותן לפני בניית מימוש עצמאי מאפס - במיוחד את הטיפול בכיוון מיון הפוך ובעמודות nullable, שהוא מקור נפוץ לבאגים עדינים.

Trade-offs מול Offset - מה מוותרים עליו

המחיר העיקרי של Cursor Pagination הוא אובדן היכולת "לקפוץ לעמוד שרירותי". אין דרך זולה לדעת מהו ה-Cursor של "עמוד 50" בלי לעבור על כל הרשומות שקדמו לו - בדיוק אותה בעיה ש-Cursor נועד לפתור מלכתחילה. עבור ממשקי משתמש שחייבים מספרי עמודים לחיצים (1, 2, 3...) זה אילוץ אמיתי. פתרון ביניים נפוץ הוא שילוב היברידי: Cursor לצורך גלילה רציפה (Infinite Scroll) בממשק המשתמש, לצד Offset מוגבל למספר עמודים קטן יחסית (למשל 5-10 העמודים הראשונים בלבד, שם העלות עדיין נמוכה) עבור ניווט ישיר. חיסרון נוסף הוא שאי אפשר להציג בקלות "סה"כ X תוצאות" בלי שאילתת ספירה נפרדת, שיכולה להיות יקרה בפני עצמה. שווה גם לציין ש-GraphQL כבר נתן לבעיה הזו תשובה סטנדרטית משותפת דרך מפרט Relay Cursor Connections - מבנה אחיד של edges, node, cursor ו-pageInfo עם hasNextPage/hasPreviousPage. גם ב-REST, שבו אין תקן מחייב, כדאי לשקול לאמץ מבנה תשובה בהשראת Relay, פשוט כדי ליהנות מהעקביות המחשבתית שהוא מספק וכדי שמפתחים שכבר עבדו עם API אחד בסגנון הזה ירגישו בבית מיד.

טעויות נפוצות בפרודקשן

הטעות הראשונה, כפי שראינו, היא מיון לפי עמודה לא ייחודית ללא Tiebreaker. השנייה היא חוסר טיפול ב-Cursor לא תקין - אם לקוח שולח Cursor שגוי (למשל אחרי שינוי סכמה שהוריד ID ישן), השרת חייב להחזיר שגיאה ברורה (400) ולא לקרוס או להתעלם ולהחזיר תוצאה שגויה בשקט. השלישית היא שכחת בדיקת מקרה הקצה של "הרשומה שסימנה את ה-Cursor נמחקה" - הדפדוף עדיין צריך לעבוד תקין (השאילתה עדיין תופסת נכון "הכל אחרי הערך הזה", גם אם הערך עצמו כבר לא קיים בטבלה). הרביעית, ואולי הכי מזיקה לטווח ארוך, היא חשיפת Cursor לא אטום שמאפשר ללקוחות "לפרסר" אותו ולבנות תלות סמויה במבנה הפנימי - מה שהופך כל שינוי עתידי ב-Cursor ל-Breaking Change בפועל, גם אם הוא לא אמור להיות. הרחבה על עקרונות כלליים של Pagination ותכנון ה-Response Envelope אפשר למצוא במאמר על Pagination ב-API.

מתי Cursor מתאים ומתי כדאי לוותר עליו

Cursor Pagination הוא הבחירה הנכונה כמעט תמיד עבור טבלאות גדולות, feeds בזמן אמת, ו-API-ים ציבוריים שצריכים לשרת סנכרון מלא של נתונים. הוא פחות מתאים כשה-UI חייב ניווט ישיר לעמוד שרירותי, או כשמדובר בטבלה קטנה יחסית (עד כמה עשרות אלפי רשומות) שבה יתרון הביצועים כלל לא מורגש - שם הפשטות של Offset עדיפה. שווה גם לזכור שהמימוש של Cursor דורש משמעת גבוהה יותר בצד השרת (אינדקסים נכונים, טיפול במקרי קצה), ולכן צוותים קטנים שצריכים לספק פתרון מהר לפעמים בוחרים להתחיל ב-Offset ולעבור ל-Cursor רק כשמופיעה בעיית ביצועים אמיתית - זו החלטה סבירה, כל עוד ה-API מתוכנן מראש כך שהמעבר לא ישבור לקוחות קיימים.

ביצועים תחת נתונים אמיתיים - מה קורה כשמפסיקים לנחש

ההבדל בין Cursor Pagination תיאורטי לבין כזה שבאמת עומד בעומס נמדד רק כשמריצים אותו על טבלה עם היקף נתונים ריאלי, לא על כמה עשרות שורות בסביבת פיתוח. מומלץ להוסיף לתהליך ה-CI בדיקת ביצועים ייעודית שממלאת טבלת בדיקה במיליוני רשומות פיקטיביות ובודקת שזמן התגובה של שאילתת ה-Cursor נשאר קבוע בין העמוד הראשון לעמוד האלף - כי זו בדיוק ההבטחה שהמנגנון הזה נותן, ואם היא לא מתקיימת בפועל סימן שהאינדקס לא תואם או שהמנוע לא מנצל אותו כמצופה. כדאי גם לבדוק את זמן הבנייה של ה-Cursor עצמו (קידוד וחתימה אם יש) בנפרד מזמן השאילתה, כדי לוודא שהוא לא הופך בעצמו לצוואר בקבוק בנפח בקשות גבוה.

סיכום

Cursor Pagination פותר את בעיית הביצועים והעקביות של Offset, אבל רק כשהוא ממומש בקפידה - עם Tiebreaker לעמודות לא ייחודיות, אינדקס מרוכב תואם, טיפול נכון בדפדוף אחורה, ואטימות אמיתית של ה-Cursor כלפי הלקוח. הפרטים הקטנים האלה הם ההבדל בין מנגנון דפדוף שעובד בכל תרחיש לבין אחד שנשבר בשקט תחת עומס או במקרי קצה נדירים. ההשקעה בפרטים האלה מראש חוסכת סבב שלם של תיקוני באגים בהמשך, כשהמערכת כבר משרתת לקוחות אמיתיים עם נתונים אמיתיים.

תגיות: Cursor Pagination · Keyset Pagination · API Design · PostgreSQL · אינדקסים · Infinite Scroll · REST API

← חזרה לבלוג · צור קשר