יישום פגינציה ב-GraphQL: מבוססת קורסור לעומת מבוססת אופסט

פידוד ב-GraphQL עלול להפתיע: פריטים כפולים כשהנתונים משתנים או שאילתות איטיות על נפחים גדולים. אנחנו מספקים את שתי האסטרטגיות מוכנות לשימוש — cursor-based ו-offset-based — ועוזרים לכם לבחור את ההתאמה הטובה ביותר ל-API שלכם. הצוות המומחה שלנו יגדיר את הפידוד כך שיפעל בצורה אמינה ובזמן, עם תמיכה מתמשכת.

פיתוח ותחזוקה של כל סוגי האתרים:

אתרי מידע או יישומי אינטרנט
אתרי תדמית, דפי נחיתה, אתרי חברה, קטלוגים מקוונים, חידונים, אתרי קידום, בלוגים, מקורות חדשות, פורטלי מידע, פורומים, אגרגטורים
אתרי מסחר אלקטרוני או יישומי אינטרנט
חנויות מקוונות, פורטלי B2B, שווקים, בורסות מקוונות, אתרי קאשבק, בורסות, פלטפורמות דרופשיפינג, מנתחי מוצרים
יישומי אינטרנט לניהול תהליכים עסקיים
מערכות CRM, מערכות ERP, פורטלים ארגוניים, מערכות ניהול ייצור, מנתחי מידע
אתרי שירות אלקטרוני או יישומי אינטרנט
פלטפורמות מודעות, בתי ספר מקוונים, בתי קולנוע מקוונים, בוני אתרים, פורטלים לשירותים אלקטרוניים, פלטפורמות אירוח וידאו, פורטלים נושאיים

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

השירותים שאנו מציעים
מציג 1 מתוך 1כל 2062 השירותים
יישום פגינציה ב-GraphQL: מבוססת קורסור לעומת מבוססת אופסט
בינוני
~2-3 ימים

הכישורים שלנו:

שאלות נפוצות

העבודות האחרונות

  • פיתוח אפליקציית ווב עבור FEEDME
    פיתוח אפליקציית ווב עבור FEEDME
    1365
  • פיתוח חנות מקוונת לחברת FURNORO
    פיתוח חנות מקוונת לחברת FURNORO
    1330
  • פיתוח אפליקציית ווב עבור Enviok
    פיתוח אפליקציית ווב עבור Enviok
    1077
  • פיתוח CRM עבור Chasseurs
    פיתוח CRM עבור Chasseurs
    1113
  • פיתוח אתר עבור SBH Partners
    פיתוח אתר עבור SBH Partners
    1201
  • פיתוח אתר עבור Red Pear
    פיתוח אתר עבור Red Pear
    619

יישום פג'ינציה ב-GraphQL: מבוססת Cursor לעומת מבוססת Offset

דמיינו שחנות המקוונת שלכם מציגה קטלוג מוצרים עם פג'ינציה, 20 פריטים בעמוד. משתמש מנווט לעמוד 3, ובאותו רגע מנהל מוסיף מוצר חדש. מה קורה? פג'ינציה מבוססת Offset מזיזה את כל השורות — עמוד 3 מציג כעת כפילויות מעמוד 2. נשמע מוכר? פג'ינציה מבוססת Cursor פותרת זאת לחלוטין: ה-cursor קובע את המיקום, ושום הוספה לא משבשת את הבחירה.

בפועל, אנו משתמשים בשתי האסטרטגיות בהתאם למשימה. במאמר זה, נצלול ליישום הטכני של פג'ינציה מבוססת Cursor (בסגנון Relay) ומבוססת Offset, נשווה את הביצועים שלהן, ונספק דוגמאות קוד מוכנות.

בעיות שאנו פותרים

כפילויות עם Offset. אם משתמש עובר לעמוד 3 ורשומה חדשה מוכנסת בין בקשות, זה מזיז את כל השורות — המשתמש רואה כפילויות. ביצועים איטיים עם Offset גדול. LIMIT 20 OFFSET 1000000 מאלץ את מסד הנתונים לסרוק מיליון שורות לפני החזרת 20 — הפרש הזמן יכול להיות פי 100 בהשוואה לשאילתת cursor. לדוגמה, בטבלת בדיקה עם מיליון רשומות, offset עם דילוג של 500,000 לקח 2.3 שניות, בעוד ששאילתת cursor לקחה 0.02 שניות. קושי בקפיצות שרירותיות ב-cursor. פג'ינציה מבוססת Cursor אינה תומכת בקפיצה לעמוד שרירותי — רק קדימה/אחורה. אנו מטפלים בכל התרחישים הללו על ידי בחירת האסטרטגיה האופטימלית לסטack שלכם.

פג'ינציה מבוססת Offset

מתאימה לטבלאות ניהול ורשימות עם עדכונים נדירים:

type Query {
  posts(limit: Int = 20, offset: Int = 0): PostList!
}

type PostList {
  items: [Post!]!
  total: Int!
  limit: Int!
  offset: Int!
  hasNextPage: Boolean!
}
const resolvers = {
  Query: {
    posts: async (parent, { limit = 20, offset = 0 }, context) => {
      const safeLimit = Math.min(limit, 100)
      const [items, total] = await Promise.all([
        context.db.query(
          'SELECT * FROM posts ORDER BY created_at DESC LIMIT $1 OFFSET $2',
          [safeLimit, offset]
        ),
        context.db.queryOne('SELECT COUNT(*) as total FROM posts')
      ])
      return {
        items,
        total: parseInt(total.total),
        limit: safeLimit,
        offset,
        hasNextPage: offset + safeLimit < parseInt(total.total)
      }
    }
  }
}

פג'ינציה מבוססת Cursor (חיבור Relay)

תקן Relay (ראו מפרט Relay GraphQL Server) הוא הבחירה הנכונה לגלילה אינסופית ונתונים המשתנים תדיר:

type Query {
  posts(
    first: Int
    after: String
    last: Int
    before: String
    filter: PostFilter
  ): PostConnection!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}
// Курсор — base64-закодированный ID или timestamp
function encodeCursor(id) {
  return Buffer.from(`cursor:${id}`).toString('base64')
}
function decodeCursor(cursor) {
  const decoded = Buffer.from(cursor, 'base64').toString('utf8')
  const match = decoded.match(/^cursor:(.+)$/)
  return match ? match[1] : null
}
const resolvers = {
  Query: {
    posts: async (parent, { first = 20, after, last, before, filter }, context) => {
      const limit = Math.min(first || last || 20, 100)
      let query = 'SELECT * FROM posts'
      const params = []
      const conditions = []
      if (filter?.authorId) {
        params.push(filter.authorId)
        conditions.push(`author_id = $${params.length}`)
      }
      if (after) {
        const afterId = decodeCursor(after)
        params.push(afterId)
        conditions.push(`id < $${params.length}`) // для DESC сортировки
      }
      if (before) {
        const beforeId = decodeCursor(before)
        params.push(beforeId)
        conditions.push(`id > $${params.length}`)
      }
      if (conditions.length) {
        query += ' WHERE ' + conditions.join(' AND ')
      }
      query += ' ORDER BY id DESC'
      params.push(limit + 1)
      query += ` LIMIT $${params.length}`
      const rows = await context.db.query(query, params)
      const hasMore = rows.length > limit
      const items = hasMore ? rows.slice(0, limit) : rows
      const edges = items.map(row => ({ node: row, cursor: encodeCursor(row.id) }))
      const totalCount = await context.db.queryOne(
        'SELECT COUNT(*) FROM posts'
      ).then(r => parseInt(r.count))
      return {
        edges,
        totalCount,
        pageInfo: {
          hasNextPage: after ? hasMore : false,
          hasPreviousPage: before ? hasMore : false,
          startCursor: edges[0]?.cursor ?? null,
          endCursor: edges[edges.length - 1]?.cursor ?? null
        }
      }
    }
  }
}

מדוע פג'ינציה מבוססת Cursor מהירה יותר ב-Offsets גדולים?

שאילתת offset עם type Query { posts(limit: Int = 20, offset: Int = 0): PostList! } type PostList { items: [Post!]! total: Int! limit: Int! offset: Int! hasNextPage: Boolean! } מאלצת את מסד הנתונים לקרוא מיליון שורות לפני החזרת 20. פג'ינציה מבוססת Cursor משתמשת בחיפוש אינדקס: const resolvers = { Query: { posts: async (parent, { limit = 20, offset = 0 }, context) => { const safeLimit = Math.min(limit, 100) const [items, total] = await Promise.all([ context.db.query( 'SELECT * FROM posts ORDER BY created_at DESC LIMIT $1 OFFSET $2', [safeLimit, offset] ), context.db.queryOne('SELECT COUNT(*) as total FROM posts') ]) return { items, total: parseInt(total.total), limit: safeLimit, offset, hasNextPage: offset + safeLimit < parseInt(total.total) } } } } — זהו O(log N). בטבלה עם 10 מיליון שורות, הפרש הזמן מגיע לפי 100. פג'ינציה מבוססת Cursor חוסכת משאבי CPU ו-I/O, דבר קריטי תחת עומס גבוה.

כיצד לבחור בין פג'ינציה מבוססת Offset לבין Cursor?

הבחירה תלויה בתרחיש. לדוגמה, עבור פיד מדיה חברתית אנו משתמשים רק במבוססת Cursor — המשתמש לא ישים לב לכפילויות כאשר מתווספים פוסטים חדשים. עבור פאנל ניהול עם חיפוש לפי ID, offset מספיק כי צריך עמוד 5 מתוך 100.

תרחיש המלצה
פאנל ניהול עם ניווט עמודים Offset
פיד גלילה אינסופית Cursor
עדכונים בזמן אמת (צ'אט, התראות) Cursor
ייצוא כל הנתונים (ללא פג'ינציה) Offset עם limit
חיפוש עם פילטרים תלוי בתדירות העדכונים

כיצד ליישם פג'ינציה עם חיבור Relay?

שלב אחר שלב:

  1. הגדירו את סוג ה-Connection (edges, pageInfo) בסכמה.
  2. ב-resolver, הביאו רשומה אחת נוספת מעבר ל-limit כדי לקבוע hasNextPage.
  3. קודדו את ה-cursor ב-base64 (ניתן להשתמש ב-ID או במפתח מורכב).
  4. בצד הלקוח, הגדירו fetchMore ומדיניות שדה למיזוג.
// Apollo Client — бесконечная прокрутка
const { data, fetchMore, loading } = useQuery(GET_POSTS, {
  variables: { first: 20 }
})

const loadMore = () => {
  const endCursor = data.posts.pageInfo.endCursor
  if (!endCursor || !data.posts.pageInfo.hasNextPage) return
  fetchMore({
    variables: { first: 20, after: endCursor },
    updateQuery: (prev, { fetchMoreResult }) => {
      if (!fetchMoreResult) return prev
      return {
        posts: {
          ...fetchMoreResult.posts,
          edges: [
            ...prev.posts.edges,
            ...fetchMoreResult.posts.edges
          ]
        }
      }
    }
  })
}

// С Apollo Client 3 — InMemoryCache field policies
const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        posts: relayStylePagination(['filter'])
      }
    }
  }
})

השוואה: Offset לעומת Cursor

קריטריון Offset Cursor
קפיצה לעמוד שרירותי כן לא
נכונות בעת הוספה לא (כפילויות/דילוגים) כן
מיון לפי כל שדה קל דורש אינדקס
גלילה אינסופית לא כן
מדרגיות (OFFSET 1M) איטי מהיר (עד פי 100)
יישום פשוט יותר מורכב יותר

מה כלול ביישום פג'ינציה במפתח מוכן

הצוות שלנו מספק:

  • ניתוח סכמת הנתונים הנוכחית שלכם ובחירת אסטרטגיה;
  • עיצוב סוגי Connection ו-resolvers;
  • יישום פג'ינציה מבוססת Offset ו-Cursor לפי תקן Relay;
  • הגדרה בצד הלקוח (Apollo Client, מדיניות שדות);
  • בדיקות עומס (עד 10,000 רשומות, עד 1,000 בקשות במקביל);
  • תיעוד API ב-GraphQL Playground;
  • מסירת קוד מקור וגישה.

צרו קשר לייעוץ — נמצא את הפתרון האופטימלי למשימה שלכם. הזמינו יישום פג'ינציה, ואנו מבטיחים פעולה תקינה בכל תרחישי עדכון הנתונים. הניסיון שלנו כולל למעלה מ-30 פרויקטים עם פג'ינציה ב-GraphQL.

לוח זמנים

יישום פג'ינציה (offset + cursor Relay Connection) עבור GraphQL API: 1–2 ימי עסקים. העלות מחושבת באופן אישי לפי מורכבות הסכמה. נעריך את הפרויקט שלכם.