skip to content

Hibernate's query cache is disabled by default. Explain why that default is chosen, and describe the workload shape where enabling it genuinely pays off versus where it quietly makes an application slower.

level: principalimportance: should knowfreq 30%

answer

  1. Fine keys vs table-coarse invalidation
  2. Entity queries cache ids → need entity L2 or N+1
  3. Write path pays timestamps + serialization
  4. Wins: low-cardinality params, rarely written tables
  5. Silent failure: puts ≈ executions, hits ≈ 0

basics

~20 s

It is off by default because it is easy to make things worse: fine-grained keys, table-coarse invalidation, dependence on the entity cache, and real write cost. It pays off only for repeated queries with low-cardinality parameters over rarely written tables.

solid answer

~50 s

The default is off because the failure modes are silent and common: - **Key cardinality**: keys include the query text, all parameters and the paging window, so a query varying per user writes an entry per call and never reads it back. - **Coarse invalidation**: any write to a queried table drops all cached results for it, so a busy table yields no hits regardless of parameters. - **Hydration coupling**: entity queries cache ids, so without the entity region a hit becomes N SELECTs — a single query turned into an N+1. - **Cost on the write path**: maintaining the update-timestamps region and serialising results is not free. It earns its place when all four hold: the query repeats often with the **same** parameters; the tables are written rarely; the returned entities are themselves cached; and stale-free semantics are worth the memory. Reference lookups and configuration queries fit; user-scoped, search and reporting queries do not. Adopt per query, with statistics proving hits, and be willing to remove it.

code

java · 7 lines
java
Statistics stats = sessionFactory.getStatistics();
QueryStatistics q = stats.getQueryStatistics(
        "select c from Country c where c.active = :flag");
long hits = q.getCacheHitCount();
long puts = q.getCachePutCount();
long execs = q.getExecutionCount();
// puts ~ execs with hits ~ 0  ->  remove setCacheable, do not enlarge the region

go deeper

for a junior

Say it is off by default because caching queries only helps when the same query with the same parameters repeats over data that rarely changes.

for a middle

Name the concrete failure modes: key cardinality, table-level invalidation, and the identifier hydration dependency on the entity cache.

for a senior

Add measurement and remediation — read per-query statistics, distinguish the three failure signatures, and size regions accordingly.

for a principal

Frame adoption as a per-query claim that must keep proving itself, and compare against an application-level cache whose key and invalidation express domain rules rather than table writes.

## Why a cache ships disabled Most caches default to off when the cost of misuse exceeds the benefit of default-on. The query cache qualifies on four counts, each of which is invisible until measured. **1. Keys are fine, invalidation is coarse.** The cache key includes the query text, every bind parameter and type, and the paging window — maximum precision. Invalidation works on **query spaces**: whole tables. Any write to any table a cached query read discards its results. The two granularities pull in opposite directions: precise keys create many entries; coarse invalidation destroys them in bulk. Only a workload with low parameter cardinality *and* very low write rates survives both. **2. It is not self-sufficient.** For entity queries only identifiers are cached, so a hit still needs entity state. If the entity is not in the second-level cache, Hibernate loads each id individually and one round trip becomes N. Adopting the query cache therefore commits you to caching the underlying entities — a correctness decision about staleness, not just tuning. **3. There is a write-side cost.** Every flush registers modified tables in the timestamps region; every cacheable execution serialises a result to put. Under a write-dominated workload you pay continuously for a hit rate that never materialises. **4. The failure is silent.** Nothing throws. Throughput drops slightly, heap use rises, and the region churns. Without per-query statistics, teams often conclude caching “didn't help much” and leave it on, paying forever. ## Where it genuinely pays All of these should hold: - **High repetition with identical parameters.** The parameter domain is small and fixed — a status enum, a locale, a tier, a boolean. Two to a few dozen distinct keys, each hit constantly. - **Very low write rate on every table the query touches.** Reference and configuration data: countries, currencies, fee schedules, feature catalogues, navigation trees. Remember a joined query inherits the write rate of its most volatile table. - **The returned entities are cached in L2**, with a region large enough that hydration does not fall through to the database. - **The query is expensive enough to matter.** Caching a primary-key lookup that the entity cache already serves is pointless. - **Staleness within the invalidation model is acceptable**, including cross-node behaviour if the provider is local rather than clustered. A good sign you are in the right territory: the same result is being computed thousands of times per minute for every user, and the data behind it changes when someone edits an admin screen. ## Where it quietly hurts - **User-scoped queries.** `where customerId = :id` — one key per customer, effectively no reuse, and every put evicts something useful. - **Search and filtering.** Free-text, date ranges, dynamic predicates: unbounded key space. - **Reporting over transactional tables.** Even with fixed parameters, continuous writes invalidate everything. - **Deep pagination.** The window is part of the key, so each page is a separate entry and users browsing generate garbage. - **Multi-tenant hot paths** where the tenant id multiplies an already-large key space. ## Alternatives worth weighing first Before reaching for the query cache, ask whether the cheaper fix applies: an index that makes the query fast enough; a fetch join or batch size that removes an N+1; a scalar/DTO projection that avoids materialising entities at all; the entity cache alone where access is by id; or a **purpose-built application cache** at a higher layer, where you choose the key, the TTL and the invalidation trigger explicitly and can express domain rules (“invalidate the menu when an admin publishes”) that a table-granularity mechanism cannot. The application-level option is often superior precisely because it caches the *answer the domain cares about* rather than a row list, and its invalidation is an explicit, testable contract rather than an emergent property of which tables the ORM happened to read. ## Adoption discipline If you do adopt it: enable per query, never wholesale; give heavy queries their own region so they cannot evict others; keep the timestamps region unbounded and unexpiring; ensure returned entities are cached with adequately sized regions; export per-query cache hit, miss and put counts; and review them. A query whose puts track executions with no hits should have the flag removed, not the region enlarged. Treat every cacheable query as a standing claim that must keep proving itself.

  • When would you prefer an application-level cache over Hibernate's query cache for the same data?
    When the invalidation rule is a domain rule rather than a table-write rule — for example, refresh the published menu when an editor clicks publish, not whenever any row in the underlying tables changes. An application cache lets you choose the key, the value shape (often a DTO rather than entity ids), the TTL and the invalidation trigger explicitly, and it does not drag in a dependency on entity regions. The trade is that you own the correctness rather than inheriting it.
  • A team reports that enabling the query cache made throughput slightly worse. How do you investigate?
    Read per-query cache statistics: execution, hit and put counts. Puts tracking executions with no hits means key cardinality is too high — remove the flag from those queries. Hits present but a burst of primary-key SELECTs afterwards means the entity region is missing or undersized. Hits low despite fixed parameters means the queried tables are written too often for table-granularity invalidation. Each diagnosis has a distinct fix, and enlarging the region is rarely the right one.

It is a season pass: worth buying only if you will visit the same place many times and the venue rarely closes. Buy one for a place that shuts every afternoon and you have paid for nothing.

saying these in an interview costs you the question

  • Treating the query cache as a general-purpose speed switch
  • Marking user-scoped or search queries cacheable
  • Enabling it without caching the entities the queries return
  • Enlarging the region when hits are near zero instead of removing the flag
  • Assuming it is off by default merely for historical or compatibility reasons

context