NHibernate Second‑Level Cache: Architecture Decisions and Operational Guardrails
A concise architecture note on NHibernate's second‑level cache: requirements, minimal design, trust boundaries, operational checks, failure modes, and when to redesign. Includes concrete SysCache2 configuration, query caching, and a diagnostic checklist.
20 May 2026, 11:00 UTC

Requirements
Applications that repeatedly read the same entities or query results can spend a large fraction of their latency on database round‑trips. NHibernate’s first‑level (session) cache only lives for the duration of a single ISession. When multiple sessions—often on different threads—need the same data, a shared cache that survives across sessions reduces load on the database and improves response time.
The requirement is therefore a process‑wide, thread‑safe cache that:
- Stores fully hydrated entity instances and query result sets.
- Participates in NHibernate’s transaction semantics so that cached data stays consistent with the current isolation level.
- Can be swapped for an in‑memory, distributed, or external provider without changing mapping code.
Minimal Viable Design
The smallest design that satisfies the requirements is:
- Enable the second‑level cache in the session‑factory configuration.
- Choose a provider that matches the deployment topology (in‑memory for a single node, Redis/Memcached for a cluster).
- Annotate only the entities and queries that benefit from caching; avoid a blanket “cache everything” approach.
- Configure a cache region and concurrency strategy per entity (e.g.,
read‑writefor frequently updated data,nonstrict‑read‑writefor mostly static reference data).
Configuration Example (SysCache2 – in‑memory)
<hibernate-configuration xmlns="urn:nhibernate-configuration-2.2">
<session-factory>
<property name="cache.provider_class">NHibernate.Caches.SysCache2.SysCacheProvider, NHibernate.Caches.SysCache2</property>
<property name="cache.use_second_level_cache">true</property>
<property name="cache.use_query_cache">true</property>
<property name="show_sql">true</property>
</session-factory>
</hibernate-configuration>
Entity Mapping with Cache Region
<class name="Order" table="Orders">
<cache usage="read-write" region="OrderCache" />
<id name="Id">
<generator class="identity" />
</id>
<property name="CustomerName" />
<property name="TotalAmount" />
</class>
Query Caching (LINQ)
var recentOrders = session.Query<Order>()
.Where(o => o.CreatedAt > DateTime.UtcNow.AddDays(-7))
.Cacheable()
.CacheRegion("RecentOrders")
.ToList();
Trust and Data Boundaries
The second‑level cache sits outside the transaction boundary of any single session. It is populated when a session commits (for read‑write) or when an entity is first read (for nonstrict‑read‑write). This creates two trust zones:
- NHibernate‑managed writes – the cache is invalidated or updated automatically because NHibernate coordinates the transaction.
- External writes – raw SQL, other applications, or bulk‑load jobs bypass NHibernate and can leave stale entries unless an explicit invalidation mechanism (e.g.,
sessionFactory.EvictEntity, cache provider notifications, or a TTL) is in place.
Design the cache region boundaries to match the trust zone: keep reference data (lookup tables, configuration) in a region with a long TTL and read‑only strategy; keep transactional aggregates in regions with read‑write and short expiration.
Operational Checks
After deployment, verify that the cache behaves as intended:
- Hit/Miss Ratio – Enable NHibernate statistics (
sessionFactory.Statistics.IsStatisticsEnabled = true) and monitorSecondLevelCacheHitCountvs.SecondLevelCacheMissCount. A healthy read‑heavy workload should show >80 % hits for cached entities. - SQL Suppression – With
show_sql=true, a second load of the same entity by primary key should emit noSELECTstatement. - Expiration / Eviction – Confirm that the provider’s expiration policy (absolute, sliding, or none) matches the data volatility. For SysCache2, configure
<syscache>sections inapp.configwithexpirationattributes. - Concurrency Strategy Alignment – Ensure the chosen strategy matches the provider’s capabilities. Distributed providers (Redis) typically support
read‑writewith proper locking; in‑memory providers may only guaranteenonstrict‑read‑write.
Diagnostic Command (run in the application process)
// Enable statistics at startup
var cfg = new Configuration().Configure();
cfg.SetProperty("hibernate.generate_statistics", "true");
var factory = cfg.BuildSessionFactory();
factory.Statistics.IsStatisticsEnabled = true;
// After a workload
Console.WriteLine($"Hits: {factory.Statistics.SecondLevelCacheHitCount}");
Console.WriteLine($"Misses: {factory.Statistics.SecondLevelCacheMissCount}");
Console.WriteLine($"Put count: {factory.Statistics.SecondLevelCachePutCount}");
Where to run: Inside the same AppDomain that builds the ISessionFactory (e.g., a console test harness or a diagnostic endpoint in a web app). Permissions: No elevated privileges; just read access to the NHibernate statistics API. Expected check: After loading an entity twice in separate sessions, SecondLevelCacheHitCount increments by one and the SQL log shows only the first SELECT. Risk: Enabling statistics in production adds a small overhead; disable or sample in high‑throughput environments.
Failure Modes
| Failure Mode | Symptom | Root Cause | Mitigation |
|---|---|---|---|
| Stale reads | Application shows outdated values after an external update. | Cache not invalidated for writes outside NHibernate. | Use cache provider notifications (Redis pub/sub), explicit Evict calls, or short TTL. |
| Lost updates | Two concurrent sessions overwrite each other’s changes. | read‑write strategy used with a non‑transactional provider. |
Match strategy to provider capabilities; prefer nonstrict‑read‑write for in‑memory caches. |
| Memory pressure | Process OOM or excessive GC pauses. | Unbounded cache regions, caching large object graphs. | Set max element counts per region, enable eviction policies, avoid caching collections with many children. |
| Test flakiness | Unit tests pass/fail depending on previous test runs. | Second‑level cache persists across test fixtures. | Disable second‑level cache in test configuration or use an in‑memory provider cleared in SetUp/TearDown. |
When to Re‑evaluate the Design
- Workload shifts – If read‑heavy becomes write‑heavy, the overhead of cache invalidation may outweigh benefits.
- Scaling out – Moving from a single node to a farm requires a distributed provider; the current in‑memory provider will no longer share state.
- Data ownership changes – If another service starts writing to the same tables, you must add explicit invalidation or drop caching for those entities.
- Latency budget tightens – When sub‑millisecond latency is required, even a cache hit may be too slow; consider moving hot data to an in‑process dictionary or a dedicated read model.
Practical Verification Checklist
- Build a minimal console program that configures SysCache2, maps one entity with
read‑write, and runs two sessions loading the same ID. - Run with
show_sql=trueand confirm the second session produces noSELECT. - Inspect
factory.Statistics.SecondLevelCacheHitCount– it should be 1 after the second load. - Introduce an external SQL update (e.g., via SSMS) and verify the next load reflects the change only after the cache entry expires or is evicted.
If any step fails, revisit the provider configuration, concurrency strategy, or region expiration settings.
Limitations
- Second‑level caching does not replace a well‑designed read model; it only accelerates repeated entity loads.
- Cache consistency guarantees are only as strong as the provider’s implementation and the chosen concurrency strategy.
- Complex object graphs with deep collections can cause cache bloat; consider caching only aggregate roots.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.