Sie sind hier: Weblog

Cache-Invalidierung für Second-Level-Cache in Entity Framework 6 mit SqlDependency

Foto ,
20.12.2013 16:40:00

Unter [1] beschreibe ich, wie man mit den Erweiterungspunkten, die ab Entity Framework 6 zur Verfügung stehen, einen Second-Level-Cache implementieren kann. Daten lediglich zu cachen ist jedoch nur die halbe Miete, denn schlussendlich ändern sich Entitäten und in diesem Fall sind die betroffenen Cache-Einträge zu aktualisieren oder zumindest aus dem Cache zu entfernen.

SqlDependency und der Service Broker in SQL Server

Eine sehr einfache Möglichkeit um herauszufinden, dass sich gecachte Daten in der Datenbank geändert haben stellt der Einsatz einer SqlDependency dar. Unter der Motorhaube kommt hierbei der SQL Server Service Broker zum Einsatz. Dieser bietet Queues an, welche eine SqlDependency benachrichtigen, wenn sich die Ergebnismenge einer gegebenen Abfrage geändert hat. Aus diesem Grund muss der Entwickler auch die Unterstützung für den Service Broker in der Datenbank der Wahl aktivieren:

ALTER DATABASE DatabaseName SET ENABLE_BROKER

In manchen Szenarien ist es darüber hinaus notwendig, dass der Besitzer der Datenbank ein SQL-Server-Interner Benutzer ist. Informationen zum Einsatz von SqlDependency findet man zum Beispiel unter [1]. 

Damit Änderungen mit SqlDependency erkannt werden können, müssen die betroffenen Abfragen einigen Anforderungen genügen. Diese findet man unter [3].

Cache-Invalidierung mit SqlDependency

Das nachfolgende Beispiel zeigt, wie der Entwickler einen Second-Level-Cache, welcher eine Invalidierung der gecachten Einträge unter Verwendung von SqlDependency durchführt, für Entity Framework implementieren kann. Es übergibt das Command, welches die zu überwachende Abfrage repräsentiert, an die SqlDependency. Das genügt, damit die SqlDependency mit der Überwachung starten kann. Für die Cache-Implementierung in .NET existiert daneben ein sogenannter SqlChangeMonitor, welcher auf eine SqlDependency verweist und sich von dieser über die Änderung von Daten informieren lässt. Hat er eine solche Information erhalten, invalidiert er den damit assoziierten Cache-Eintrag.

// Nicht vergessen, Broker zu aktivieren und DB-Besitzer setzen ...

public class SqlDependencyCachingCommandInterceptor : IDbCommandInterceptor
{
    private MemoryCache cache = MemoryCache.Default;
    private DbProviderFactory factory;
    private int absoluteExpirationInMinutes;
    private static bool isInitialized = false;

    public SqlDependencyCachingCommandInterceptor(DbProviderFactory factory, int absoluteExpirationInMinutes)
    {
        this.factory = factory;
        this.absoluteExpirationInMinutes = absoluteExpirationInMinutes;
    }

    public void NonQueryExecuted(System.Data.Common.DbCommand command, DbCommandInterceptionContext<int> interceptionContext)
    {
    }

    public void NonQueryExecuting(System.Data.Common.DbCommand command, DbCommandInterceptionContext<int> interceptionContext)
    {
    }

    public void ReaderExecuting(System.Data.Common.DbCommand command, DbCommandInterceptionContext<System.Data.Common.DbDataReader> interceptionContext)
    {

        // ReaderExecuting is also called for batches like insert into [...]; select @@identity
        if (!command.CommandText.ToLower().StartsWith("select ")) return;

        EnsureIsInitialized(command.Connection.ConnectionString);

        if (cache.Contains(command.CommandText))
        {
            var table = (DataTable)cache[command.CommandText];
            interceptionContext.Result = table.CreateDataReader();
        }
        else
        {
            var policy = new CacheItemPolicy();
            policy.AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(absoluteExpirationInMinutes);
            SqlDependency sqlDependency = null;

            // Cache-Invalidation currently only supported for Sql-Server
            if (command is SqlCommand)
            {
                sqlDependency = new SqlDependency(command as SqlCommand);
                var changeMonitor = new SqlChangeMonitor(sqlDependency);
                policy.ChangeMonitors.Add(changeMonitor);
            }

            var table = new DataTable();
            var adapter = factory.CreateDataAdapter();
            adapter.SelectCommand = command;
            adapter.Fill(table);

            // HasCachanges is also true, if the select-command is not
            // supported for change notifications using SqlDependency 
            // and the service broker
            if (sqlDependency != null && !sqlDependency.HasChanges)
            {
                cache.Add(command.CommandText, table, policy);
            }

            interceptionContext.Result = table.CreateDataReader();
        }
    }

    private void EnsureIsInitialized(string connectionString)
    {

        if (!isInitialized)
        {
            lock (this)
            {
                if (!isInitialized) // check one more time within lock
                {
                    SqlDependency.Start(connectionString);
                    isInitialized = true;
                }
            }
        }
    }

    public void ReaderExecuted(System.Data.Common.DbCommand command, DbCommandInterceptionContext<System.Data.Common.DbDataReader> interceptionContext)
    {
    }

    public void ScalarExecuted(System.Data.Common.DbCommand command, DbCommandInterceptionContext<object> interceptionContext)
    {
    }

    public void ScalarExecuting(System.Data.Common.DbCommand command, DbCommandInterceptionContext<object> interceptionContext)
    {
    }

}

Den Interceptor registrieren

Damit EF den hier betrachteten SqlDependencyCachingCommandInterceptor auch verwendet, ist dieser zu registrieren. Dies geschieht im einfachsten Fall im Konstruktor einer DbConfiguration-Implementierung, welche - sofern es sich dabei um die einzige handelt – Entity Framework automatisch lädt und verwendet.

public class CustomDbConfiguration : DbConfiguration
{
    public CustomDbConfiguration()
    {
        AddInterceptor(
          new SqlDependencyCachingCommandInterceptor(
                           SqlClientFactory.Instance, 60 * 8));
    }
}

Diskussion und Ausblick

Der Vorteil der hier betrachteten Lösung liegt in der einfachen Verwendung von SqlDependency und darin, dass damit der Entwickler alle von einer Änderung betroffenen Ergebnismengen erkennen kann. Der Nachteil liegt darin, dass SqlDependency nur für SELECT-Anweisungen, die bestimmten Bedingungen genügen, funktioniert. Darüber hinaus ist SqlDependency auf SQL-Server beschränkt. Da durch die einzelnen Benachrichtigungen ein bestimmter Overhead entsteht, wird der Einsatz dieses Mechanismus‘ aus Performance-Gründen nicht für große hochfrequentierte Datenbanken empfohlen. Aus diesem Grund werde ich hier mit einem weiteren Blog-Eintrag eine Alternative zur Cache-Invalidierung berichten.

 

[1] http://www.softwarearchitekt.at/post/2013/12/07/Second-Level-Cache-fur-Entity-Framework-6-mit-Command-Interceptoren.aspx

[2] http://www.codeproject.com/Articles/12335/Using-SqlDependency-for-data-change-events

[3] http://msdn.microsoft.com/library/ms181122.aspx