Hibernate scroll() vs offset pagination: inconsistent row visibility during zero‑downtime migration
0 reputation · 22 Sept 2024, 21:57 UTC
0 reputation · 22 Sept 2024, 21:57 UTC
Migrating a small Java application with zero downtime requires a pagination method that preserves data consistency while keeping latency low.
Fetch‑size based pagination reduces memory overhead but demands careful transaction boundary management; offset pagination via maxResults/setFirstResult degrades on large tables and can miss or duplicate rows if concurrent inserts occur, and some JDBC drivers ignore the fetch‑size hint. Hibernate 5 improves scroll() for large result sets, while Hibernate 6 may alter cache interaction semantics.
The unresolved decision is whether iterator‑based scroll() or limit‑offset queries give a better trade‑off for this migration scenario.
Which strategy guarantees no missed or duplicate rows under concurrent writes? How does fetch‑size interact with the chosen transaction isolation level? What benchmark metrics should be collected to decide?
Only scroll() — used with a forward-only cursor inside a single transaction at REPEATABLE_READ (or stricter) isolation — can guarantee no missed or duplicate rows under concurrent writes. Offset pagination (setFirstResult/setMaxResults) cannot provide that guarantee at any isolation level, because each page is a separate query against a table whose row positions shift as other transactions insert or delete rows. For a zero-downtime migration where correctness of the copied dataset matters, scroll-based streaming is the right default; offset pagination is acceptable only if you can tolerate re-processing or deduplication downstream.
With offset pagination, page N is computed as "skip N rows, take M." If a concurrent insert lands before the current offset between two page queries, one row shifts into the already-read range (missed) or a row is read twice (duplicate). Isolation level doesn't save you here unless the entire pagination run lives in one snapshot transaction — which offset pagination typically doesn't, since each page is usually its own query/transaction.
With ScrollableResults in ScrollMode.FORWARD_ONLY, the database cursor reads from a single transaction snapshot. Under REPEATABLE_READ (the default on PostgreSQL and MySQL InnoDB for this purpose), rows inserted or deleted by other transactions after your transaction began are simply not visible to the cursor, and rows you have already read are stable. That snapshot property is what delivers the guarantee — not Hibernate itself.
setFetchSize() is purely a performance hint to the JDBC driver controlling how many rows are fetched per round-trip. It has no effect on consistency. Two practical caveats:
useCursorFetch=true or Integer.MIN_VALUE fetch size for streaming). Consistency is unaffected, but memory behavior changes drastically — verify with a heap profile on a large table.The cursor's consistency comes from the transaction snapshot, so the isolation level is the real decision:
READ_COMMITTED: each statement may see a new snapshot. A long cursor can observe inserts committed mid-scroll — the guarantee is weakened. Avoid for migration reads.REPEATABLE_READ: one snapshot for the whole transaction. This is the practical sweet spot — consistent reads without blocking writers on InnoDB/PostgreSQL MVCC.SERIALIZABLE: strongest guarantee, but higher abort/contention rates under write load. Only needed if your migration logic also writes based on what it read.Holding a cursor open for a long migration increases transaction duration, can bloat undo/WAL retention, and risks idle-in-transaction timeouts. Mitigate by chunking the migration into keyset-bounded sub-ranges (e.g., scroll within WHERE id > :last windows) so each transaction stays short while still being snapshot-consistent per chunk, with the chunk boundary chosen on an indexed, immutable key.
One detail worth confirming before finalizing: your database and Hibernate version. The snapshot behavior above is well established for PostgreSQL and MySQL InnoDB with Hibernate 5/6, but driver-specific streaming behavior (especially MySQL) should be verified on your exact stack with the heap test described above.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.