Cutting Repeat Database Trips with NHibernate's Second-Level Cache and SysCache2
Repeatedly loading the same reference data on every request wastes database round-trips. Here's how NHibernate's second-level cache with the SysCache2 provider fixes that — and where it falls down.
08 Oct 2026, 22:06 UTC

Your application loads the same Product, Country, or Settings rows on nearly every request. Each ISession has its own first-level cache, but that cache dies with the session — so the next request hits the database again for data that changed last month. NHibernate's second-level cache exists precisely for this gap, and on classic .NET Framework / ASP.NET applications the SysCache2 provider is the simplest way to turn it on.
What the second-level cache actually does
The second-level cache lives at the ISessionFactory level, not the session level. When an entity is loaded by id, NHibernate can store its dehydrated state (the raw column values, not the object graph) in a named cache region shared by every session the factory creates. A later session asking for the same id can be satisfied from that region without issuing a SELECT.
SysCache2 is a provider that backs those regions with ASP.NET's built-in System.Web.Caching.Cache. That gives you in-process caching with expiration policies, and optionally SQL cache dependencies that invalidate entries when the underlying table changes. Because it is built on System.Web, it only applies to .NET Framework applications — more on that limitation below.
Configuring it: three moving parts
All three of these must be in place; missing any one silently disables caching.
- Add the provider package. Install
NHibernate.Caches.SysCache2from NuGet. Check that the package version targets your NHibernate major version — provider APIs have changed across releases, so confirm compatibility before upgrading either side. - Enable the cache in the session-factory configuration:
<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">false</property>
<property name="generate_statistics">true</property>
The use_second_level_cache switch is the one people forget: cache declarations in mappings are ignored entirely without it. generate_statistics is optional in production but invaluable while tuning.
- Declare a concurrency strategy per entity. In an XML mapping this is an attribute on the class:
<class name="Product" table="Products">
<cache usage="read-write" region="ReferenceData"/>
<id name="Id"><generator class="identity"/></id>
<property name="Name"/>
<property name="Price"/>
</class>
The read-write strategy uses timestamps to avoid serving stale data after an update. read-only is safer and faster for data that never changes; nonstrict-read-write allows a brief window of staleness in exchange for no locking. The region name lets you tune expiration per group of entities in the SysCache2 configuration section rather than per class.
Seeing it work
The behavior to expect, in a console or integration test against a real database:
using (var s1 = sessionFactory.OpenSession())
{
var p = s1.Get<Product>(42); // issues SELECT
}
using (var s2 = sessionFactory.OpenSession())
{
var p = s2.Get<Product>(42); // served from second-level cache, no SELECT
}
Don't take that on faith — verify it. With generate_statistics enabled, inspect sessionFactory.Statistics.SecondLevelCacheHitCount after the second load; it should have incremented. In parallel, run SQL Server Profiler (or enable NHibernate's SQL logging via log4net/NLog) and confirm no SELECT appears for the second session. As a negative control, call sessionFactory.Evict(typeof(Product), 42) and load again: the SELECT should reappear, proving the cache layer is genuinely in the path and you aren't fooling yourself with the first-level cache or a reused session.
The trade-offs you sign up for
SysCache2 is an in-process cache. In a web farm, each server has its own copy, so an update on node A leaves node B serving stale data until expiration or a SQL cache dependency fires. If you run multiple nodes, either use SQL cache dependencies deliberately or look at a distributed provider.
Second, caching write-heavy entities is usually a net loss. Every update must coordinate with the cache, and with nonstrict-read-write you accept windows where readers see old values. The sweet spot is reference data: read constantly, written rarely. Start there, not with your hottest transactional tables.
Third, the hard platform limit: SysCache2 depends on System.Web, so it is not an option for .NET Core or ASP.NET Core. On modern stacks, evaluate the CoreCache or distributed providers instead — the configuration concepts (regions, concurrency strategies, statistics) carry over.
Where to start
Pick one genuinely read-mostly table, enable the second-level cache for just that entity, and watch SecondLevelCacheHitCount versus SecondLevelCacheMissCount under realistic traffic. A healthy hit ratio on reference data is a clear signal to expand; a poor one tells you the data changes too often and the cache is overhead, not help. Tune region expirations to match how stale each data set is allowed to be, and re-measure after every change.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.