יישום פג'ינציה ב-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?
שלב אחר שלב:
- הגדירו את סוג ה-Connection (edges, pageInfo) בסכמה.
- ב-resolver, הביאו רשומה אחת נוספת מעבר ל-limit כדי לקבוע hasNextPage.
- קודדו את ה-cursor ב-base64 (ניתן להשתמש ב-ID או במפתח מורכב).
- בצד הלקוח, הגדירו 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 ימי עסקים. העלות מחושבת באופן אישי לפי מורכבות הסכמה. נעריך את הפרויקט שלכם.







