Caching the most expensive data using Redis Cache in an ASP.NET Core project


overview

I have developed a small ASP.NET Core 3.0 web API that provides information that changes rarely (several times a day) but is read frequently (~ 10 K / day).

Although traffic is fairly low, there was an opportunity to use Redis Cache and the Stack Exchange client.

I would like to achieve the following:

  • Allow services to access and set data transparently through the cache: simply provide a key, a function to create the data when it is not in the cache, and an expiration period
  • should be thread safe

The code

RedisCacheService.cs – The general service with which other services can use the Redis cache transparently

public class RedisCacheService : IRedisCacheService
{
    private ConnectionMultiplexer Redis = null;
    private IDatabase Database = null;

    private ILoggingService Logger { get; }

    private static readonly object SyncLock = new object();

    public RedisCacheService(ILoggingService loggingService)
    {
        Logger = loggingService;
    }

    /// 
    /// checks that database is initialized. If not it is initialized
    /// 
    private void EnsureConnection()
    {
        if (Database == null)
        {
            lock (SyncLock)
            {
                if (Database == null)
                {
                    //TODO: use appsettings 
                    Redis = ConnectionMultiplexer.Connect("localhost");
                    Database = Redis.GetDatabase();
                }
            }
        }
    }

    (System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = ""))
    (System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = ""))
    (System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = ""))
    public async Task GetInfoThroughCache(string key, int expirationSeconds, Func computeData)
    {
        if (computeData == null)
            throw new ArgumentException($"{nameof(computeData)} is null");

        string cachedValue;
        try
        {
            EnsureConnection();

            // checking the cache (no locking yet)
            cachedValue = await Database.StringGetAsync(key);
        }
        catch(Exception exc )
        {
            Logger.LogError(exc, $"Error getting cached value for key {key}");
            return computeData();
        }

        if (string.IsNullOrEmpty(cachedValue) || cachedValue == "nil")
        {
            lock(SyncLock)
            {
                T newData = computeData();
                string serializedData = JsonConvert.SerializeObject(newData);
                TimeSpan expires = new TimeSpan(0, 0, expirationSeconds);

                try
                {
                    bool set = Database.StringSet(key, serializedData, expires);
                }
                catch(Exception exc)
                {
                    Logger.LogError(exc, $"Error setting cached value for key {key}");
                    return computeData();
                }

                return newData;
            }
        }

        // information is cached
        try
        {
            T cacheData = JsonConvert.DeserializeObject(cachedValue);
            return cacheData;
        }
        catch(Exception exc)
        {
            Logger.LogError(exc, $"Error deserializing  cached value for key {key}");
            throw;
        }
    }
}

example

var questionInfo = await RedisCacheService.GetInfoThroughCache($"question_{questionId}", QuestionFullInfoCacheExpiration, () =>
    {
        var questionInfo = GetQuestionHeaderInfo(questionId);
        if (questionInfo == null)
            throw new ArgumentException($"No question info for id = {questionId}");

        GetQuestionFullInfo_QuestionComments(questionInfo);
        GetQuestionFullInfo_Answers(questionInfo);

        return questionInfo;
    });

This seems to be okay (caching saves considerable time for database processing), although I haven't done any parallel tests (multiple threads access this functionality).

The next step would be to try a faster serializer (Protobuf?).

Any thoughts? I am mainly interested in code design and not in performance aspects.