Cursor Pagination — μέθοδος σελιδοποίησης δεδομένων που χρησιμοποιεί ένα μοναδικό δρομέα για πλοήγηση σε ένα ταξινομημένο σύνολο εγγραφών. Σύμφωνα με το GraphQL Specification (2025), η σελιδοποίηση με δρομέα είναι το συνιστώμενο πρότυπο για API που εργάζονται με δυναμικά δεδομένα. Cursor Pagination εξαλείφει τα κύρια μειονεκτήματα της προσέγγισης Offset: αστάθεια κατά την εισαγωγή και πτώση απόδοσης σε μεγάλες μετατοπίσεις.
Κύρια σημεία
Cursor Pagination (σελιδοποίηση με δρομέα) — μέθοδος σελιδοποίησης όπου ο διακομιστής επιστρέφει μαζί με τα δεδομένα έναν ειδικό δείκτη — δρομέα. Ο πελάτης χρησιμοποιεί αυτόν το δρομέα στο επόμενο αίτημα για να λάβει την επόμενη μερίδα εγγραφών. Ο δρομέας είναι το μοναδικό αναγνωριστικό του τελευταίου στοιχείου της τρέχουσας σελίδας.
Σε αντίθεση με το Offset-pagination, όπου ο πελάτης λέει “δώσε μου τη σελίδα 5 με 20 εγγραφές”, η σελιδοποίηση με δρομέα λειτουργεί διαφορετικά: “δώσε μου 20 εγγραφές μετά την εγγραφή με ID = 83”. Ο διακομιστής εκτελεί το ερώτημα με συνθήκη WHERE id > 83 και LIMIT 20. Αυτή η προσέγγιση εγγυάται ότι κάθε εγγραφή καταλήγει ακριβώς σε μία σελίδα ανεξάρτητα από εισαγωγές.
Η έννοια της σελιδοποίησης με δρομέα έχει γίνει ευρέως διαδεδομένη χάρη στην προδιαγραφή Relay Connection (GraphQL), η οποία έκανε το cursor-based pagination πρότυπο για σύγχρονα API. Το Relay ορίζει τη μορφή απάντησης: edges (πίνακας εγγραφών με δρομείς), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
Το cursor-pagination δεν είναι νέα τεχνική — χρησιμοποιούνταν σε βάσεις δεδομένων πολύ πριν από την εμφάνιση του ιστού. Στην SQL ονομάζεται keyset pagination ή seek method. Η μέθοδος έγινε δημοφιλής σε API μετά τη δημοσίευση της προδιαγραφής Relay το 2015, η οποία επισημοποίησε τη μορφή του δρομέα ως κωδικοποιημένο base64 string για ομοιομορφία μετάδοσης μέσω HTTP.
Η βασική αρχή της σελιδοποίησης με δρομέα — το ερώτημα χρησιμοποιεί συνθήκη WHERE σε ένα indexed πεδίο για τοποθέτηση, όχι μετατόπιση. Για μπροστινή κατεύθυνση εφαρμόζεται WHERE id > last_id, για πίσω κατεύθυνση — WHERE id < first_id. Το ευρετήριο B-tree επιτρέπει την εύρεση της πρώτης εγγραφής μετά το δρομέα σε O(log n), παρέχοντας σταθερό χρόνο απόκρισης.
-- Λήψη 20 εγγραφών μετά τον κέρσορα '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- Λήψη 20 εγγραφών ΠΡΙΝ τον κέρσορα '83' (πίσω)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
Ο δρομέας μπορεί να είναι απλός (τιμή ID) ή σύνθετος (αποτελούμενος από πολλά πεδία). Απλοί δρομείς είναι το πρωτεύον κλειδί της εγγραφής, για παράδειγμα auto-increment id ή UUID. Σύνθετοι δρομείς χρησιμοποιούνται για ταξινόμηση κατά μη μοναδικά πεδία, για παράδειγμα (created_at, id), όπου το id εγγυάται μοναδικότητα σε πανομοιότυπες χρονικές σημάνσεις.
Τυπική μορφή API — δρομέας ως κωδικοποιημένο base64 string. Ο διακομιστής αποκωδικοποιεί το δρομέα, εξάγει την τιμή και κατασκευάζει το ερώτημα SQL. Η κωδικοποίηση Base64 κρύβει την εσωτερική δομή του δρομέα από τον πελάτη και επιτρέπει την αλλαγή μορφής χωρίς απώλεια συμβατότητας προς τα πίσω. Ο πελάτης λαμβάνει τους δρομείς στο πεδίο endCursor της απάντησης και τους μεταδίδει ως string στο επόμενο αίτημα.
Η σελιδοποίηση με δρομέα υποστηρίζει αμφίδρομη πλοήγηση. Για κίνηση μπροστά (next) χρησιμοποιείται ο δρομέας του τελευταίου στοιχείου της τρέχουσας σελίδας, για πίσω (previous) — ο δρομέας του πρώτου στοιχείου. Οι παράμετροι after και before στο αίτημα καθορίζουν την κατεύθυνση: after λαμβάνει εγγραφές μετά το δρομέα, before — πριν από το δρομέα.
Η επιλογή μεταξύ σελιδοποίησης με δρομέα και Offset-pagination — μία από τις βασικές αρχιτεκτονικές ερωτήσεις κατά το σχεδιασμό API. Κάθε μέθοδος έχει δυνατά και αδύνατα σημεία. Cursor-pagination κερδίζει σε σενάρια με δυναμικά δεδομένα, Offset — σε σενάρια με αυθαίρετη πλοήγηση.
| Χαρακτηριστικό | Cursor | Offset |
|---|---|---|
| Σταθερότητα κατά την εισαγωγή | Υψηλή (χωρίς διπλότυπα) | Χαμηλή (μετατόπιση σελίδων) |
| Απόδοση σε μεγάλα σύνολα | O(log n) — σταθερή | O(n) — μειώνεται με αύξηση |
| Πλοήγηση κατά αριθμό σελίδας | Όχι | Ναι (page=5) |
| Πολυπλοκότητα υλοποίησης | Μέτρια | Χαμηλή |
| Υποστήριξη σε REST | cursor/before/after | page/offset |
| Υποστήριξη σε GraphQL | Πρότυπο Relay | Δεν συνιστάται |
Το Offset-pagination εκτελεί πλήρη σάρωση πίνακα μέχρι τη θέση OFFSET. Σε offset=100000, η βάση δεδομένων διαβάζει και παραλείπει 100000 γραμμές, ακόμα κι αν το LIMIT είναι 20. Τα MySQL και PostgreSQL δεν μπορούν να βελτιστοποιήσουν το OFFSET — αυτό είναι χαρακτηριστικό της υλοποίησης LIMIT/OFFSET στην SQL. Το Cursor-pagination χρησιμοποιεί το ευρετήριο B-tree, το οποίο βρίσκει τη θέση σε O(log n).
Ένα πρόσθετο πρόβλημα του Offset — “παράλειψη” εγγραφών κατά τη σελιδοποίηση προς τα πίσω. Εάν ο χρήστης φόρτωσε τη σελίδα 5 και εκείνη τη στιγμή προστέθηκαν νέες εγγραφές, κατά το αίτημα για τη σελίδα 6 θα δει ξανά την εγγραφή από τη σελίδα 5 ή θα παραλείψει νέες. Το Cursor-pagination εξαλείφει πλήρως αυτό το σενάριο: ο δρομέας δείχνει σε συγκεκριμένο σημείο στο σύνολο και οι εισαγωγές δεν αλλάζουν τη θέση.
Ας εξετάσουμε την υλοποίηση της σελιδοποίησης με δρομέα στο backend (Kotlin + Spring) και στον πελάτη (Android + Retrofit). Ο διακομιστής λαμβάνει παραμέτρους after, before, limit και επιστρέφει μια λίστα εγγραφών με δρομείς και pageInfo. Η τυπική απάντηση περιέχει hasNextPage και hasPreviousPage για τη διαχείριση του UI σελιδοποίησης.
@GetMapping("/posts")
fun getPosts(
@RequestParam after: Long?,
@RequestParam(defaultValue = "20") limit: Int
): CursorResponse<Post> {
val cursor = after ?: Long.MAX_VALUE
val posts = repository.findByIdLessThanOrderByIdDesc(
cursor, PageRequest.of(0, limit)
)
val endCursor = posts.lastOrNull()?.id
return CursorResponse(
data = posts,
pageInfo = PageInfo(
hasNextPage = posts.size == limit,
endCursor = endCursor
)
)
}
Στην πλευρά του πελάτη, η σελιδοποίηση με δρομέα υλοποιείται μέσω του PagingSource από το Paging 3, όπου το κλειδί είναι ο δρομέας (Long). Το PagingSource.load λαμβάνει το LoadParams.key — δρομέα της τελευταίας φορτωμένης εγγραφής. LoadResult.Page επιστρέφει δεδομένα και nextKey — δρομέα για την επόμενη σελίδα. Όταν nextKey = null — η σελιδοποίηση ολοκληρώθηκε.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource με κλειδί δρομέα
class PostPagingSource(
private val api: PostApi
) : PagingSource<Long, Post>() {
override suspend fun load(
params: LoadParams<Long>
): LoadResult<Long, Post> = try {
val response = api.getPosts(
after = params.key,
limit = params.loadSize
)
val nextKey = response.pageInfo.endCursor
LoadResult.Page(
data = response.data,
prevKey = null,
nextKey = nextKey
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
Στο GraphQL, η σελιδοποίηση με δρομέα υλοποιείται μέσω του προτύπου Connection Relay. Κάθε τύπος έχει Connection (με pageInfo και edges) και Edge (node + cursor). Το αίτημα μεταδίδει παραμέτρους first, after, last, before. Ο διακομιστής επιστρέφει έναν πίνακα edges με δρομείς και pageInfo με hasNextPage/hasPreviousPage.
Το cursor-pagination συνιστάται για API που εργάζονται με δυναμικά δεδομένα, όπου οι εγγραφές προστίθενται ή διαγράφονται συχνά. Κλασικά παραδείγματα: ροή ειδήσεων σε κοινωνικό δίκτυο, μηνύματα chat, ιστορικό συναλλαγών, σχόλια σε αναρτήσεις. Σε όλα αυτά τα σενάρια, η συνέπεια και η απουσία διπλοτύπων είναι σημαντική.
Υπάρχουν σενάρια όπου το Offset-pagination είναι πιο βολικό: διαχειριστικοί πίνακες όπου απαιτείται πλοήγηση κατά αριθμό σελίδας; αναζήτηση με σελιδοποίηση όπου τα αποτελέσματα μπορεί να αλλάζουν; αναφορές και αναλυτικά στοιχεία όπου χρειάζεται σταθερός σύνδεσμος προς τη σελίδα 5. Σε αυτές τις περιπτώσεις, τα πλεονεκτήματα του δρομέα δεν υπερτερούν της πολυπλοκότητας υλοποίησης.
Η σελιδοποίηση με δρομέα δεν υποστηρίζει “άλμα” σε αυθαίρετη σελίδα — ο χρήστης δεν μπορεί να κάνει κλικ στο “Σελίδα 5” και να μεταβεί εκεί. Αυτός είναι αρχιτεκτονικός περιορισμός: για τον υπολογισμό του συνολικού αριθμού σελίδων απαιτείται ξεχωριστό ερώτημα COUNT, το οποίο μπορεί να είναι δαπανηρό για μεγάλους πίνακες. Σε τέτοιες περιπτώσεις, υβριδική προσέγγιση: cursor για δεδομένα + count για σελιδοποίηση.
Συχνές ερωτήσεις
Ο δρομέας είναι το μοναδικό αναγνωριστικό της εγγραφής που υποδεικνύει τη θέση στο σύνολο δεδομένων. Μπορεί να είναι απλός (ID εγγραφής) ή σύνθετος (πολλά πεδία). Ο πελάτης λαμβάνει το δρομέα της τελευταίας εγγραφής της σελίδας και τον μεταδίδει στο επόμενο αίτημα για να λάβει την επόμενη μερίδα.
Το cursor-pagination δεν υπόκειται σε μετατόπιση κατά την προσθήκη νέων εγγραφών — κάθε στοιχείο καταλήγει ακριβώς σε μία σελίδα. Επίσης διατηρεί την ταχύτητα σε μεγάλους όγκους χάρη στη χρήση ευρετηρίων αντί της σάρωσης των πρώτων n γραμμών. Το Offset είναι απλούστερο αλλά ασταθές για δυναμικά δεδομένα.
Ναι, η σελιδοποίηση με δρομέα δεν είναι συνδεδεμένη με το GraphQL. Μπορεί να υλοποιηθεί σε οποιοδήποτε REST API μεταδίδοντας το δρομέα ως παράμετρο ερωτήματος ?after=83&limit=20. Η απάντηση πρέπει να περιέχει pageInfo με endCursor και hasNextPage — αυτό επιτρέπει στον πελάτη να διαχειρίζεται τη φόρτωση χωρίς γνώση της εσωτερικής δομής του δρομέα.
Auto-increment ID — η βέλτιστη επιλογή: αυξάνεται μονότονα, δεν αλλάζει, ευρετηριάζεται αποτελεσματικά. Το UUID v7 (χρονικά ταξινομημένο) είναι επίσης κατάλληλο. Το timestamp μπορεί να δώσει διπλότυπα στον ίδιο χρόνο, γι' αυτό συνδυάζεται με ID: (created_at, id) για εγγύηση μοναδικότητας του δρομέα.
Η σελιδοποίηση με δρομέα δεν παρέχει συνολικό αριθμό σελίδων — αυτός είναι ο περιορισμός της. Εάν χρειάζεται πληροφορία για το total, εκτελέστε ξεχωριστό ερώτημα COUNT με τα ίδια φίλτρα. Για μεγάλους πίνακες, χρησιμοποιήστε κατά προσέγγιση μέτρηση μέσω EXPLAIN ή cached total από αναλυτικά στοιχεία.
Σύνοψη
Θα αναπτύξουμε μια εφαρμογή για κινητά έτοιμη για χρήση
Η IT Sectr δημιουργεί εφαρμογές iOS και Android για νεοφυείς επιχειρήσεις και επιχειρήσεις από το 2017. Θα σας συμβουλεύσουμε και θα προτείνουμε την καλύτερη λύση.
Διαβάστε επίσης