Design a memory-safe path to export millions of rows via a Spring Data JPA repository. Which mechanisms do you combine and why?
answer
- Stream + readOnly tx + close
- fetchSize hint (MySQL = Integer.MIN_VALUE)
- readOnly hint = no snapshots
- detach/clear or DTO projection to bound heap
- keyset paging as restartable alternative
basics
~10 sReturn a Stream<T>, consume it inside a read-only transaction, and close it with try-with-resources. Add @QueryHints for fetch size and readOnly, and periodically detach/clear entities so the persistence context and heap stay bounded.
solid answer
~40 sThe goal is constant memory regardless of row count. I return `Stream<T>` (a JDBC cursor, not a materialized `List`) and consume it inside a single `@Transactional(readOnly = true)` method so the connection stays open, closing the stream with try-with-resources. I attach `@QueryHints`: `org.hibernate.fetchSize` (e.g. 1000; on MySQL specifically `Integer.MIN_VALUE`) to control round-trip batching, and `org.hibernate.readOnly` so Hibernate keeps no dirty-check snapshots. Even then the persistence context accumulates entities, so I periodically `entityManager.detach(...)` or `clear()` to cap the heap. I keep the transaction long only for the read, stream straight to the output (CSV/HTTP), and avoid buffering. Alternatives I'd weigh: keyset (seek) pagination for restartability and shorter transactions, or a stored procedure with REF_CURSOR if the DB should own the query. Streaming wins when one long sequential pass is acceptable.
code
java · 27 linespublic interface OrderRepository extends JpaRepository<Order, Long> {
@QueryHints({
@QueryHint(name = "org.hibernate.fetchSize", value = "1000"),
@QueryHint(name = "org.hibernate.readOnly", value = "true")
})
@Query("select o from Order o")
Stream<Order> streamAll();
}
@Service
class OrderExportService {
private final OrderRepository repo;
private final EntityManager em;
OrderExportService(OrderRepository repo, EntityManager em) { this.repo = repo; this.em = em; }
@Transactional(readOnly = true) // spans the whole consumption
public void exportCsv(Writer out) {
long n = 0;
try (Stream<Order> orders = repo.streamAll()) { // must close the cursor
var it = orders.iterator();
while (it.hasNext()) {
writeCsvRow(out, it.next());
if (++n % 1000 == 0) em.clear(); // keep persistence context bounded
}
}
}
}go deeper
Understand that loading everything into a List can OOM; streaming reads lazily.
Assemble Stream + readOnly tx + close + fetch-size/readOnly hints correctly.
Add persistence-context management (detach/clear or DTO projection) and the MySQL fetch-size caveat.
Trade streaming vs keyset paging vs REF_CURSOR vs Spring Batch on transaction duration, pool pressure, restartability, and testability.
## The failure mode to avoid A naive `List<T> findAll()` loads **every** row and every managed entity into memory before you write a byte — for millions of rows that's an `OutOfMemoryError`. The design target is **bounded memory** independent of result size. ## The core recipe (streaming) 1. **Return `Stream<T>`** — Spring Data backs it with a scrollable JDBC cursor, so rows are produced lazily instead of materialized. 2. **Open transaction over consumption** — the cursor/connection must stay open, so the *consuming* method is `@Transactional(readOnly = true)`. Read-only lets Hibernate skip snapshots and enables driver/DB read optimizations. The transaction must span the whole consumption, not just the repository call. 3. **Close the stream** — it holds a cursor + connection; use try-with-resources. `forEach` alone does not close it, so leaking here drains the connection pool. 4. **`@QueryHints`:** - `org.hibernate.fetchSize` — JDBC rows per round trip. Too small = chatty; too large = memory spikes. On **MySQL** true streaming needs `Integer.MIN_VALUE` (a driver quirk); a positive value there buffers the whole result — a classic silent OOM. - `org.hibernate.readOnly` — no dirty-check snapshots for streamed entities → less heap/CPU. 5. **Bound the persistence context** — every managed entity is still added to the `EntityManager`. Periodically `em.detach(entity)` (or `em.clear()` every N rows) so the first-level cache doesn't grow unbounded. Projections/DTOs (interface or class-based) avoid managed entities entirely and are often the cleanest fix. 6. **Stream to the sink** — write each row straight to the CSV/HTTP `OutputStream`/`Writer`; never collect into a `List` first, or you reintroduce the memory problem. ## Why each piece matters (interaction) Streaming without read-only + detach still OOMs on the persistence context. Read-only without fetch size still round-trips inefficiently. On MySQL, all of it fails silently unless fetch size is `Integer.MIN_VALUE`. The mechanisms are complementary, not alternatives. ## Alternatives and trade-offs - **Keyset / seek pagination** (`WHERE id > :last ORDER BY id LIMIT n`): each page is a short, independent transaction — restartable, pool-friendly, no long-held cursor. Better for very long or resumable exports; slightly more code. Preferred over offset pagination, which degrades on deep offsets. - **`Slice`/`Page` with offset**: simple but `OFFSET` gets slow at depth and `Page` adds a count query. - **Stored procedure + `REF_CURSOR`**: push the query into the DB (`@NamedStoredProcedureQuery`) when the DB should own it; costs portability and testability. - **Batch frameworks** (Spring Batch) for chunk-oriented, restartable, monitored jobs at scale. ## Operational concerns a principal weighs - **Long-running read transaction** can hold a connection and, on some DBs, MVCC snapshots for a long time — keyset paging avoids that. - **Connection-pool sizing**: a streaming export ties up one connection for its full duration. - **Timeouts / backpressure**: streaming to a slow HTTP client keeps the DB cursor open; consider server timeouts. - **Testability**: streaming/procedure code is harder to unit test than plain repository methods. ## Bottom line For a single sequential pass with acceptable transaction duration, `Stream<T>` + read-only tx + fetch-size/read-only hints + detach/clear (or DTO projection) gives constant-memory export. For resumability, very long runs, or pool pressure, prefer keyset pagination; for DB-owned logic, a REF_CURSOR procedure.
- When would you choose keyset pagination over Stream<T> for a large export?When you need restartability, shorter transactions, or to avoid holding one connection/cursor (and DB snapshot) open for the whole run. Keyset (WHERE id > :last ORDER BY id LIMIT n) processes independent pages; streaming is a single long-lived cursor.
- Why is a positive fetchSize dangerous on MySQL for streaming?The MySQL JDBC driver buffers the entire result set for positive fetch sizes; only Integer.MIN_VALUE triggers true row-by-row streaming. So a positive value silently reintroduces the OutOfMemoryError you were trying to avoid.
- How do DTO projections help here?Interface/class-based projections return unmanaged objects, so nothing is added to the persistence context — no dirty-check snapshots and no need to detach/clear, cutting memory further than streaming entities.
saying these in an interview costs you the question
- Collecting the stream into a List before writing (defeats the purpose)
- Positive fetchSize on MySQL expecting streaming
- No detach/clear, letting the persistence context grow unbounded
- Transaction that ends before the stream is fully consumed
- Using deep OFFSET pagination for millions of rows