NHibernate Hashtable Second‑Level Cache: A Minimal Design for Read‑Heavy Workloads
Learn how to safely use NHibernate’s built‑in Hashtable second‑level cache for read‑heavy workloads, what to monitor, where it can fail, and when to move to a distributed cache.
05 Jan 2026, 09:38 UTC

Problem
Read‑heavy applications often suffer from repeated database trips for reference data that changes rarely. Each trip adds latency and load, yet introducing a distributed cache adds operational complexity that may be unnecessary for a single‑node deployment.
Takeaway
Enable NHibernate’s built‑in Hashtable second‑level cache for immutable or infrequently updated entities, monitor hit/miss ratios via NHibernate statistics, and verify memory stability. Switch to a clustered cache only when consistency, cross‑node coherence, or regulatory constraints demand it.
Requirements
- Read‑dominant workload with reference data (lookup tables, enumerations) that is mostly static.
- Acceptable tolerance for occasional stale reads if data is updated outside NHibernate.
- Single‑node deployment or willingness to accept in‑process cache limits.
- Need for observable cache effectiveness without adding external dependencies.
Smallest Suitable Design
- Add the Hashtable cache provider in
hibernate.cfg.xml:<cache provider="NHibernate.Cache.HashtableHashtableCacheProvider, NHibernate" /> - Mark only the target entities as cacheable with a read‑only usage:
<class name="MyApp.Model.Country, MyApp" table="Countries"> <id name="Id" column="CountryId"> <generator class="native" /> </id> <property name="Name" /> <cache usage="read-only" /> </class> - Leave all mutable entities uncached (
usage="none") to avoid stale‑data risk.
Trust and Data Boundaries
The Hashtable cache lives inside the NHibernate SessionFactory’s process memory. No network boundary is crossed, so the cache is trusted as long as the application code is the sole writer to the cached tables. If another process updates the database directly, the cache becomes a stale‑data source; this is an explicit trust boundary that must be acknowledged.
Operational Checks
- Enable NHibernate statistics at startup:
sessionFactory.Statistics.IsStatisticsEnabled = true; - After a representative load test, read the second‑level cache counters:
Aim for a hit ratio > 0.8 for cached regions.var stats = sessionFactory.Statistics; long puts = stats.SecondLevelCachePutCount; long gets = stats.SecondLevelCacheGetCount; long hits = stats.SecondLevelCacheHitCount; double hitRatio = gets > 0 ? (double)hits / gets : 0; - Monitor process memory (e.g., via PerfCounter ".NET CLR Memory\# Bytes in all Heaps") to ensure growth stabilizes after the cache warms up.
Failure Modes
- Stale data: Direct SQL or another service updates a cached table; NHibernate does not evict the entry automatically.
- Memory pressure: Caching too many entities or large collections can exhaust the process heap, leading to
OutOfMemoryException. - Concurrent corruption: Although NHibernate guards internal Hashtable access, extreme write concurrency on the same region can still cause race conditions if external code manipulates the cache directly.
Conditions That Would Change the Design
- Shift to a read‑write workload where transactional consistency is required; consider a read‑write cache provider (e.g., SysCache2) or disable caching for those entities.
- Need for cache coherence across multiple application nodes; replace the Hashtable provider with a distributed provider such as Redis, Memcached, or NHibernate.SysCache2.
- Regulatory or security policies that prohibit storing sensitive data in process memory; move caching to an external, secured store.
Practical Verification
- Run a load script that repeatedly queries a cached entity (e.g.,
session.Get<Country>(id)) and a non‑cached entity. - Record the second‑level cache statistics before and after the run; verify that the cached entity shows a high hit ratio while the non‑cached entity shows zero puts/gets.
- Introduce a controlled direct‑SQL update:
UPDATE Countries SET Name = 'Updated' WHERE CountryId = 1;then repeat the NHibernate query. Observe that the returned name remains the pre‑update value unless you manually evict the region (sessionFactory.EvictEntity(typeof(Country), id)). This demonstrates the stale‑data risk and the need for external invalidation when bypassing NHibernate.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.