When Hibernate stores a cacheable query result, what makes up the key it is stored under? Explain what that implies for queries that take bind parameters or use paging.
answer
- Key = SQL text + params (+types) + firstResult/maxResults + tenant
- One key per distinct parameter combination
- Paging fragments: page 1 ≠ page 2
- High-cardinality params → puts, no hits, eviction damage
- Keys are fine-grained; invalidation is table-coarse
basics
~20 sThe key covers the query text, every bind parameter value and type, the paging window (first result and max results), the result-set transformation, and the tenant identifier. Each distinct combination is a separate entry, so varied parameters mean no reuse.
solid answer
~50 sA query cache entry is keyed by everything that could change the result set: - the query string (the generated SQL, so JPQL and native forms differ), - every **bind parameter value and type**, - the **paging window** — `firstResult` and `maxResults`, - result transformation/return types, and the tenant identifier under multi-tenancy. The consequences are practical. Each distinct parameter combination gets its own entry, so a query parameterised by user id or a search string produces one entry per caller and near-zero hits while evicting entries that would have been useful. Each page of a paginated query is a separate entry, and page 2 gains nothing from page 1 being cached. So cacheable queries should have a **small, repeating parameter domain** — a status enum, a tier, a boolean flag — not open-ended input. Also note that a literal baked into the query text and the same value passed as a parameter produce different keys, since the query string differs.
code
java · 11 lines// good: two possible keys
em.createQuery("select c from Country c where c.active = :flag", Country.class)
.setParameter("flag", true)
.setHint("org.hibernate.cacheable", true)
.getResultList();
// bad: one key per customer, effectively never reused
em.createQuery("select o from Order o where o.customerId = :id", Order.class)
.setParameter("id", customerId)
.setHint("org.hibernate.cacheable", true) // remove this
.getResultList();go deeper
Know that the key includes the query and its parameter values, so different parameters mean different cached results.
Add paging and tenant to the key, and reason about parameter cardinality as the predictor of hit rate.
Diagnose from per-query statistics, use dedicated regions to contain damage, and recognise that fine keys still meet coarse table-level invalidation.
Set the policy: cacheable queries need a small, repeating parameter domain over rarely written tables, and anything else belongs to a purpose-built cache with an explicit key and invalidation contract.
## What a key must capture A cache is only correct if the key captures every input that can change the value. For a query result, those inputs are: **The query itself.** Hibernate keys on the SQL it will execute, so two JPQL strings that differ only in whitespace or alias naming can generate identical SQL and share an entry, while a JPQL query and a native query that fetch the same rows generally do not. **Bind parameter values and their types.** `where status = 'ACTIVE'` and `where status = 'CLOSED'` must not share a result. Types are included because the same literal under a different type can produce different results and different SQL semantics. **The paging window.** `firstResult` and `maxResults` are part of the key because they change what the database returns. Page 1 and page 2 of the same query are independent entries. **Result shaping and context.** The declared return types / transformer and, under multi-tenancy, the tenant identifier — the last is a correctness necessity: sharing a cached result across tenants would be a data leak. ## What follows for real queries ### Parameter cardinality decides the hit rate The useful mental model is: *how many distinct keys will this query generate, and how often is each one repeated?* - `select c from Country c where c.active = :flag` — two possible keys, each hit constantly. Excellent. - `select f from FeeSchedule f where f.tier = :tier` with five tiers — five keys. Excellent. - `select o from Order o where o.customerId = :id` across a million customers — a million keys, most used once. Terrible: every execution writes an entry that will likely never be read, and that write evicts entries that would have been. - Anything parameterised by a timestamp, a free-text search or a request id — effectively unique per call. Never cache. A high-cardinality cacheable query is worse than no cache: you pay serialization and a put on every call, get no hits, and actively damage the hit rate of well-chosen entries sharing the region. Giving such a query its own region with `setCacheRegion(...)` at least contains the damage, but the real fix is not to cache it. ### Paging fragments the cache Because the window is part of the key, an infinite-scroll or paginated screen creates one entry per page per parameter combination. For a stable reference list of moderate size, caching the unpaged query and paging in memory can be far more effective — one entry serving every page — provided the result set is genuinely small. ### Literals versus parameters Baking a value into the query text produces a different query string, hence a different key, and defeats reuse of the parameterised form. It also loses the database's own statement reuse. Keep values in bind parameters, and let the low cardinality of those parameters be the reason the query is cacheable. ### Ordering and distinctness Any clause that changes the emitted SQL — a different `order by`, adding `distinct` — yields a different key. Two code paths that build “the same” query with different ordering will not share cached results. ## Interaction with invalidation The key determines *lookup*; invalidation is separate and is table-based, driven by the update-timestamps region. That asymmetry matters: keys can be arbitrarily fine-grained, but invalidation is coarse. A single write to a queried table drops **every** cached result over that table, no matter how carefully parameterised. So low parameter cardinality is necessary but not sufficient — the tables must also be written rarely. ## Diagnosing in practice Enable `hibernate.generate_statistics=true` and read per-query statistics by query string: execution count, cache hit count, cache put count. Puts tracking executions one-for-one with hits near zero is the definitive signature of a key-cardinality problem. Element counts in the results region climbing without bound point the same way. The remedy is to remove the cacheable flag from that query rather than to enlarge the region.
- A paginated screen marks its query cacheable and the results region grows without bound. What is happening and what would you change?firstResult and maxResults are part of the cache key, so every page of every parameter combination becomes its own entry, and users browsing deep pages create entries nobody reads again. Either stop caching the paged query, give it a dedicated bounded region so it cannot evict other results, or — if the full result set is genuinely small and stable — cache the unpaged query once and page it in memory.
- Does the tenant identifier form part of the query cache key, and why does it matter?Yes. Under Hibernate's multi-tenancy support the tenant identifier is included in the cache key. It has to be: two tenants running the identical query with identical parameters must not share a result, or one tenant would see the other's rows. It is the clearest example of the principle that the key must capture every input that can change the result set.
saying these in an interview costs you the question
- Assuming one cache entry serves all parameter values of a query
- Expecting page 2 to be served from a cached page 1
- Marking user-scoped or free-text search queries cacheable
- Believing a hard-coded literal and a bind parameter share a key
- Fixing a low hit rate by enlarging the region instead of removing the cacheable flag