OFFSET/LIMIT versus Keyset Pagination in Hibernate for Stable Ordering and Deep Page Access
0 reputation · 06 Aug 2022, 05:54 UTC
0 reputation · 06 Aug 2022, 05:54 UTC
Goal: Implement pagination for a large dataset in a Hibernate/JPA application where the result set must be ordered stably and query latency is critical.
Constraint: Using Hibernate’s setFirstResult/setMaxResults produces OFFSET/LIMIT clauses that are easy to code but incur O(offset) cost and can return inconsistent pages under concurrent updates. Keyset pagination avoids the scan by seeking on the last seen values of the ordered columns, giving constant‑time lookup when those columns are indexed, yet it cannot jump to an arbitrary page number and requires a deterministic, unique ordering (often a combination of indexed columns and the primary key).
Uncertainty: The application must both guarantee stable ordering and allow users to jump to any page number (e.g., via a page‑selector UI), while keeping response times low for deep offsets.
Which approach should be adopted—retaining OFFSET/LIMIT despite its performance penalty, or switching to keyset pagination and redesigning the API to use cursor‑based navigation instead of page numbers?
Switch to keyset (seek) pagination and redesign the API around cursor-based navigation. It is the only option that simultaneously satisfies your two hard requirements: stable ordering under concurrent writes and low latency at deep positions. Retaining setFirstResult/setMaxResults keeps the page-number UI but fails both of those requirements, so the page selector is the constraint that has to give.
Hibernate translates setFirstResult(n) into SQL with an OFFSET n clause. The database must still read and discard the first n rows, so cost grows linearly with page depth. Worse, a page is defined by position, not by data: if rows are inserted or deleted ahead of the current offset between requests, subsequent pages silently skip or repeat rows. No index or tuning fixes this — it is inherent to positional addressing.
Instead of skipping rows, each query seeks past the last row of the previous page:
SELECT e FROM Event e
WHERE (e.createdAt, e.id) < :lastCreatedAt, :lastId
ORDER BY e.createdAt DESC, e.id DESC
-- page size via setMaxResults onlyTwo conditions are non-negotiable:
ORDER BY must end with a unique tie-breaker, typically the primary key. Without it, rows sharing a sort value can be skipped or duplicated between pages.(created_at, id) lets the database satisfy the seek with an index range scan, giving roughly constant time regardless of depth. Without the index, keyset degrades to a scan and loses its advantage.Keyset cannot jump to page 47 without traversing pages 1–46, because cursors are data values, not positions. Your realistic options:
If the page selector is truly immutable, OFFSET/LIMIT is unavoidable and you should mitigate instead: cap the maximum page, add a covering index, and accept instability — but be explicit that this trades away the stated goals.
hibernate.show_sql or a JDBC proxy) and confirm the keyset query emits a WHERE seek plus ORDER BY, with no OFFSET.EXPLAIN on both queries: OFFSET should show a scan with limit; keyset should show an index range scan on the composite index.Assumption: this reflects well-established behavior in Hibernate 5/6 and mainstream relational databases; row-value comparison syntax ((a, b) > (?, ?)) varies by database, so verify against your dialect — expanded OR conditions are the portable fallback.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.