Second-Level Cache and Query Cache with Hibernate
Cache Layers in Hibernate
Hibernate has two cache levels:
| Level | Scope | Lifetime | Shared? |
|---|---|---|---|
| First-level cache | One Session (one transaction) | Transaction | No — per session |
| Second-level cache | SessionFactory | Application lifetime | Yes — across sessions |
The first-level cache (Article 24) prevents repeated reads within one transaction. The second-level cache prevents repeated reads across transactions — once an entity is loaded, it stays in the shared cache until evicted.
When to Use the Second-Level Cache
Good candidates:
- Reference / lookup data: countries, currencies, categories, status codes — data that changes rarely
- Frequently read, rarely written: product catalogue, configuration settings
- Shared across many users: the same categories are needed by every request
Bad candidates:
- Frequently written data: orders, stock levels, user activity
- Large datasets: caching millions of rows wastes memory without much benefit
- Session-specific data: shopping carts, user preferences
If a table is updated often, the cache is invalidated constantly and provides no benefit. Worse, stale reads become a risk.
Adding EhCache (Hibernate’s Default L2 Provider)
<!-- pom.xml -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
</dependency>
# application.yml
spring:
jpa:
properties:
hibernate:
cache:
use_second_level_cache: true
use_query_cache: true
region:
factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
javax:
cache:
provider: org.ehcache.jsr107.EhcacheCachingProvider
uri: classpath:ehcache.xml
<!-- src/main/resources/ehcache.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.ehcache.org/v3"
xsi:schemaLocation="http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core.xsd">
<!-- Default cache for entities not explicitly configured -->
<default-cache-configuration>
<expiry>
<ttl unit="minutes">10</ttl>
</expiry>
<resources>
<heap unit="entries">1000</heap>
</resources>
</default-cache-configuration>
<!-- Category cache — small, rarely changes -->
<cache alias="com.example.entity.Category">
<expiry>
<ttl unit="hours">1</ttl>
</expiry>
<resources>
<heap unit="entries">500</heap>
<offheap unit="MB">10</offheap>
</resources>
</cache>
<!-- Product cache — moderate churn -->
<cache alias="com.example.entity.Product">
<expiry>
<ttl unit="minutes">5</ttl>
</expiry>
<resources>
<heap unit="entries">5000</heap>
</resources>
</cache>
<!-- Query cache region -->
<cache alias="org.hibernate.cache.internal.StandardQueryCache">
<expiry>
<ttl unit="minutes">5</ttl>
</expiry>
<resources>
<heap unit="entries">200</heap>
</resources>
</cache>
<cache alias="org.hibernate.cache.spi.UpdateTimestampsCache">
<expiry>
<none/>
</expiry>
<resources>
<heap unit="entries">1000</heap>
</resources>
</cache>
</config>
Annotating Entities for Caching
Enable caching on an entity with @Cache:
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
@Entity
@Table(name = "categories")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String slug;
// ...
}
The cache alias defaults to the fully qualified class name (com.example.entity.Category), matching the ehcache.xml configuration above.
Cache Concurrency Strategies
| Strategy | Use when |
|---|---|
READ_ONLY | Data never changes (enum-like values). Fastest. |
NONSTRICT_READ_WRITE | Rarely updated. Allows brief stale reads. |
READ_WRITE | Can be updated. Prevents stale reads with soft-locking. |
TRANSACTIONAL | Fully transactional cache. Requires JTA. |
For most reference data: READ_WRITE is the safe default.
For data that truly never changes (countries, ISO codes): READ_ONLY.
Avoid TRANSACTIONAL unless you already use JTA.
Caching Collections
Cache a collection association separately:
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
@ManyToMany
@JoinTable(...)
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
private Set<Tag> tags = new HashSet<>();
}
The collection cache stores the IDs of the collection elements. Hibernate also needs the entity cache for Tag populated to resolve the full objects.
The Query Cache
The query cache stores the primary keys returned by a JPQL or native query. On subsequent identical queries, Hibernate retrieves the IDs from the query cache and looks up the entities in the second-level entity cache.
Enable per-query:
@Repository
public interface CategoryRepository extends JpaRepository<Category, Long> {
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
List<Category> findByParentIsNull();
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
Optional<Category> findBySlug(String slug);
}
Or via @Query:
@Query("SELECT c FROM Category c WHERE c.active = true ORDER BY c.name")
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
List<Category> findActiveCategories();
How the Query Cache Works
- Query first executed → result IDs stored in query cache
- Same query executed again → IDs retrieved from query cache
- Hibernate looks up each ID in the entity cache
- If entity cache hit → no SQL
- If entity cache miss → SELECT for that entity
The query cache is invalidated whenever any entity of the queried type is inserted, updated, or deleted — even if it doesn’t affect the cached result set. For frequently-updated tables, the query cache is invalidated so often it provides no benefit.
The query cache is only beneficial for:
- Queries on rarely-updated tables (categories, tags, configuration)
- Queries with identical parameters called many times per second
- The entity cache is also populated for the queried type
Cache Statistics
Monitor cache effectiveness with Hibernate statistics:
spring.jpa.properties.hibernate.generate_statistics: true
@Autowired
private EntityManagerFactory entityManagerFactory;
public void printCacheStats() {
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
System.out.println("Second level cache hits: " + stats.getSecondLevelCacheHitCount());
System.out.println("Second level cache misses: " + stats.getSecondLevelCacheMissCount());
System.out.println("Second level cache puts: " + stats.getSecondLevelCachePutCount());
System.out.println("Query cache hits: " + stats.getQueryCacheHitCount());
System.out.println("Query cache misses: " + stats.getQueryCacheMissCount());
}
A healthy cache has a high hit rate. If misses dominate, either the TTL is too short or the data changes too frequently.
Evicting the Cache Manually
When you need to force-reload cached data:
@Autowired
private EntityManagerFactory entityManagerFactory;
// Evict a specific entity
public void evictCategory(Long categoryId) {
entityManagerFactory.getCache().evict(Category.class, categoryId);
}
// Evict all cached Category entities
public void evictAllCategories() {
entityManagerFactory.getCache().evict(Category.class);
}
// Evict everything
public void evictAll() {
entityManagerFactory.getCache().evictAll();
}
Useful after bulk updates that bypass the persistence context (e.g., @Modifying queries), which don’t automatically invalidate the second-level cache for affected IDs.
@Modifying(clearAutomatically = true)
@Query("UPDATE Category c SET c.active = false WHERE c.id IN :ids")
int deactivateCategories(@Param("ids") List<Long> ids);
// After this, manually evict
public void deactivateAndEvict(List<Long> ids) {
categoryRepository.deactivateCategories(ids);
ids.forEach(id -> entityManagerFactory.getCache().evict(Category.class, id));
}
Complete Configuration Example
@Entity
@Table(name = "categories")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Category extends BaseEntity {
private String name;
private String slug;
private boolean active;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent;
@OneToMany(mappedBy = "parent")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
private List<Category> children = new ArrayList<>();
}
@Repository
public interface CategoryRepository extends JpaRepository<Category, Long> {
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
List<Category> findByActiveTrueOrderByName();
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
Optional<Category> findBySlug(String slug);
}
@Service
@Transactional(readOnly = true)
public class CategoryService {
private final CategoryRepository categoryRepository;
// First call: DB query → cache populated
// Subsequent calls: cache hit, no SQL
public List<Category> getAllActive() {
return categoryRepository.findByActiveTrueOrderByName();
}
@Transactional
public Category createCategory(CategoryRequest request) {
Category category = new Category();
category.setName(request.getName());
category.setSlug(request.getSlug());
category.setActive(true);
return categoryRepository.save(category);
// Hibernate automatically invalidates the query cache for Category
// New entity is added to the entity cache by Hibernate
}
}
Summary
- The second-level cache is shared across transactions — loaded entities stay cached until evicted or TTL expires.
- Annotate cacheable entities with
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE). UseREAD_ONLYfor truly immutable data. - Configure cache regions in
ehcache.xmlwith appropriate TTLs and heap sizes. - The query cache stores result IDs and is only useful for frequently-called queries on rarely-updated tables.
- Monitor cache hit/miss rates with Hibernate statistics — a low hit rate means caching adds overhead without benefit.
- After bulk
@Modifyingqueries, manually evict affected entities from the cache since JPA bypasses automatic invalidation.
Next: Article 26 tackles the N+1 problem — the single most common JPA performance bug, with every detection and solution technique.