Pagination ב-API: מדוע Offset נשבר בקנה מידה ומה עושים במקום

מאת צוות מדיה דיל · 03.08.2026 · API Engineering · 7 דק׳

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

לקוח מדווח שהאינטגרציה שלו "נתקעת" כשמנסים למשוך את כל רשימת הלקוחות שלו - מסתבר שהוא מנסה לקבל 800,000 רשומות בבקשה אחת, כי ה-API לא הגביל את גודל התשובה וגם לא סיפק דרך ברורה למשוך את הנתונים בחלקים. זו דוגמה נפוצה לכך ש-Pagination, שנשמע כמו פרט טכני משני, הוא בפועל אחד ההיבטים שהכי משפיעים על היציבות והשימושיות של API שמחזיר אוספי נתונים.

למה Pagination הוא לא רק "עמוד 1, עמוד 2"

ברמת המושג, Pagination פותר בעיה פשוטה: אי אפשר להחזיר אוסף בלתי מוגבל של רשומות בתשובה אחת - זה יפוצץ את הזיכרון של השרת, ייצור latency בלתי סביר, ויחשוף את המערכת להתקפות מניעת שירות פשוטות. אבל ברגע שמתחילים לממש את זה, מתגלה שיש כמה גישות שונות מהותית, וכל אחת מביאה איתה סט שונה לגמרי של בעיות ופתרונות. הבחירה הלא נכונה בשלב מוקדם יוצרת בעיות ביצועים חמורות ברגע שהנתונים גדלים - ושינוי גישת Pagination אחרי שיש לקוחות שכבר בנו אינטגרציה עליה הוא Breaking Change כואב.

Offset-based Pagination - הפשטות שגובה מחיר

הגישה הנפוצה והאינטואיטיבית ביותר היא ?page=3&limit=20 או ?offset=40&limit=20. היתרון ברור: קל להבין, קל לממש, ומאפשר קפיצה ישירה לעמוד מסוים (למשל "קפוץ לעמוד 50"). אבל יש לה שתי בעיות מהותיות שמחמירות ככל שמסד הנתונים גדל. הראשונה היא ביצועים - שאילתת OFFSET 100000 דורשת ממסד הנתונים לסרוק ולדלג על 100,000 רשומות לפני שהוא מגיע לעמוד המבוקש, מה שהופך את זמן התגובה לגדל באופן ליניארי ככל שמתקדמים בעמודים. השנייה היא עקביות - אם רשומות נוספות או נמחקות בין קריאה לקריאה, הלקוח עלול לפספס רשומות לגמרי או לקבל את אותה רשומה פעמיים, כי "המיקום" היחסי של הנתונים זז מתחתיו.

Cursor-based Pagination - הפתרון המדרגי

הגישה החלופית משתמשת ב-Cursor - מזהה אטום (למשל ID מוצפן או Timestamp מקודד) שמצביע על "היכן הפסקנו". במקום לבקש "עמוד 50", הלקוח מבקש "הבא אחרי X", ומסד הנתונים יכול לממש את זה עם שאילתת WHERE id > X LIMIT 20 שמנצלת אינדקס ורצה במהירות קבועה ללא תלות בכמות הרשומות שקדמו. זה פותר גם את בעיית העקביות - כל עוד הרשומה שסימנה את ה-Cursor לא נמחקה, ההמשך תקין גם אם נוספו רשומות חדשות. המחיר: אי אפשר לקפוץ ישירות לעמוד שרירותי, רק להתקדם צעד-צעד. למאמר מורחב על יישום נכון של הגישה הזו, אפשר לקרוא בהרחבה על Cursor Pagination.

Keyset Pagination - הגרסה המתקדמת של Cursor

גרסה מתוחכמת יותר של Cursor Pagination היא Keyset Pagination, שמשתמשת בשילוב של כמה עמודות (למשל created_at ו-id יחד) כדי להתמודד עם מקרים שבהם עמודת המיון היחידה אינה ייחודית (למשל כמה רשומות עם אותו timestamp בדיוק). זה דורש אינדקס מרוכב (Composite Index) שתואם בדיוק לסדר המיון המבוקש, אבל נותן ביצועים קבועים גם בטבלאות עם מיליארדי רשומות ותמיכה נכונה במיון דו-כיווני (הבא/הקודם).

עיצוב ה-Response Envelope

מעבר לבחירת השיטה, יש להחליט איך בדיוק נראית התשובה. גישה מומלצת היא Envelope עקבי שכולל את הנתונים עצמם ומטא-דאטה נפרד:

{
  "data": [...],
  "pagination": {
    "next_cursor": "eyJpZCI6MTIzfQ==",
    "has_more": true,
    "total_count": null
  }
}

שימו לב ל-total_count: null - זו החלטה מכוונת. חישוב מספר כולל מדויק (COUNT(*)) בטבלה גדולה הוא יקר, ולעיתים יקר יותר מהשאילתה עצמה. אם ה-UI של הלקוח לא חייב מספר מדויק, עדיף להימנע מלחשב אותו, או לחשב הערכה מקורבת בלבד (Approximate Count) שממומשת בנפרד ובאופן א-סינכרוני. חשוב גם לתעד בבירור מהו סוג הנתונים המדויק של השדה next_cursor - האם הוא אמור להיות מטופל כ-Opaque String בלבד (כלומר, הלקוח לא אמור לפרסר אותו או להסתמך על מבנהו הפנימי), או שהוא מסמך מובנה שיכול להשתנות. ברגע שלקוחות מתחילים להסתמך על המבנה הפנימי של Cursor, כל שינוי בו הופך ל-Breaking Change, גם אם מבחינת הצוות הפנימי זה נראה פרט טכני זניח.

עקביות בין דפים - הבעיה שרוב הצוותים מתעלמים ממנה

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

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

טעות ראשונה היא חוסר Default ל-limit - אם לקוח לא מציין גודל עמוד, וה-API "נדיב" ומחזיר הכל, זה בדיוק התרחיש שפתחנו איתו את המאמר. תמיד יש להגדיר גם ברירת מחדל וגם תקרה מקסימלית (למשל 100 רשומות), ולדחות בקשות שחורגות ממנה. טעות שנייה היא חשיפת Cursor לא אטום (למשל מספר ID גולמי) - זה חושף פרטי מימוש פנימיים וגם מאפשר ללקוחות "לנחש" או לתפעל Cursors, מה שעלול לשבור לוגיקה בעדכון גרסה עתידי. טעות שלישית היא Pagination לא עקבי בין endpoints שונים באותו API - חלק עם offset, חלק עם cursor, בלי מוסכמת שמות אחידה לפרמטרים - מה שהופך את חוויית המפתח (Developer Experience) לגרועה ומכפיל את זמן האינטגרציה.

ביצועים בפועל - מדידה במקום ניחוש

שווה להדגיש עם מספרים אמיתיים למה ההבדל בין Offset ל-Cursor הוא לא תיאורטי. בטבלה עם עשרה מיליון רשומות, שאילתת OFFSET 9000000 LIMIT 20 יכולה לקחת בקלות כמה שניות טובות, כי מסד הנתונים חייב לספור ולדלג פיזית על תשעה מיליון שורות לפני שהוא מגיע ליעד - גם אם אף אחת מהן לא נחוצה לתשובה עצמה. לעומת זאת, שאילתת WHERE id > 9000000 ORDER BY id LIMIT 20 עם אינדקס מתאים רצה בזמן כמעט קבוע, בין אם מדובר בעמוד הראשון או במיליון. ההבדל הזה לרוב לא מורגש בסביבת פיתוח עם כמה אלפי רשומות בלבד, מה שהופך אותו למלכודת קלאסית - הקוד "עובד מצוין" בבדיקות ונשבר רק חודשים אחרי העלייה לאוויר, כשהנתונים כבר גדלו משמעותית. לכן מומלץ לבדוק ביצועי Pagination תחת נתוני בדיקה שמדמים היקף אמיתי, ולא רק דאטה לדוגמה. גישה מעשית שאנחנו ממליצים עליה היא לכלול בתהליך ה-CI בדיקת ביצועים ייעודית שמריצה שאילתת Pagination על טבלה מזוהמת מראש במיליוני רשומות פיקטיביות, ובודקת שזמן התגובה נשאר קבוע בין העמוד הראשון לעמוד המאה - כך תופסים רגרסיה בביצועים הרבה לפני שהיא מגיעה למשתמשי קצה אמיתיים.

Pagination ב-GraphQL לעומת REST

שווה לציין שב-GraphQL הבעיה קיבלה תשובה סטנדרטית משותפת - מפרט Relay Cursor Connections, שמגדיר מבנה אחיד של edges, node, cursor ו-pageInfo עם hasNextPage ו-endCursor. זה יתרון משמעותי כי לקוחות שכבר עבדו עם API GraphQL אחד יודעים בדיוק למה לצפות. ב-REST, לעומת זאת, אין תקן מחייב, ולכן העיצוב של מבנה התשובה הוא באחריות מלאה של הצוות - מה שמחזיר אותנו לחשיבות של עקביות בין endpoints, שנדון עליה בהמשך. חלק מהצוותים שאנחנו פוגשים בוחרים לאמץ מבנה בהשראת Relay גם ב-REST, פשוט כדי ליהנות מהעקביות המחשבתית שהוא מספק, גם בלי לממש GraphQL בפועל - וזו החלטה סבירה לחלוטין כל עוד היא מתועדת ומיושמת באופן אחיד בכל ה-endpoints שמחזירים אוספים.

מתי Offset עדיין הבחירה הנכונה

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

תיעוד ו-SDK - איך הופכים Pagination לפשוט עבור המפתח

גם עיצוב מושלם של Pagination לא שווה הרבה אם המפתח שמשתמש ב-API לא מבין איך להשתמש בו נכון. תיעוד טוב כולל דוגמת קוד מלאה של לולאת דפדוף (למשל while has_more), לא רק תיאור של הפרמטרים בנפרד. במקרים רבים משתלם לספק SDK רשמי שמטפל ב-Pagination בשקיפות מוחלטת - הלקוח פשוט מריץ for item in client.list_items(): וה-SDK דואג ברקע למשוך את כל הדפים בזמן האמת שהם נצרכים (Lazy Iteration), במקום שכל צוות פיתוח לקוח יצטרך לממש בעצמו לוגיקת דפדוף עם כל הבאגים הפוטנציאליים שבה - כמו שכחת בדיקת has_more שגורמת ללולאה אינסופית, או טיפול שגוי בשגיאת רשת שקורית באמצע דפדוף ארוך.

Rate Limiting ו-Pagination ביחד

נקודה שקל לפספס היא האינטראקציה בין Pagination ל-Rate Limiting: לקוח שצריך למשוך מיליון רשומות דרך עמודים של 100 יבצע 10,000 קריאות API. אם המכסה שלו היא 1,000 קריאות בדקה, זה ייקח לו לפחות עשר דקות רק בשביל סנכרון ראשוני אחד, וזה עוד לפני שמביאים בחשבון Retry על שגיאות זמניות. לכן, עבור endpoints שמיועדים לייצוא נתונים מסיבי, כדאי לשקול חלופה ייעודית - כמו endpoint בסגנון Bulk Export שמפיק קובץ שלם (CSV או JSON Lines) באופן א-סינכרוני ושולח התראה כשהוא מוכן, במקום לכפות על הלקוח לדפדף עמוד-עמוד דרך ה-API הרגיל. זה גם חוסך עומס משמעותי על השרת וגם נותן ללקוח חוויה טובה בהרבה עבור תרחיש הסנכרון המלא.

סיכום

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

תגיות: API Pagination · Cursor Pagination · Offset Pagination · Keyset Pagination · REST API · Database Performance · API Design

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