Understanding Cache in C#: In-Memory and Distributed Cache

Caching is a powerful technique used in software development to enhance the performance and scalability of applications. In C#, caching is commonly implemented in two main ways: in-memory cache and distributed cache. This article will explore these caching types, their benefits, and when to use each.

What is In-Memory Cache?

In-memory caching involves storing data directly in the application's memory. This cache is maintained within the process of the application and is typically used for fast access to frequently requested data.

Example:

  • Setup: Add the in-memory cache to the services collection in Program.cs using the AddMemoryCache method.
program-in-memory.png

Usage in Controller: Inject IMemoryCache into a controller (e.g., ProductsController). The Get method checks if the products list is already in the cache. If cached, the data is returned. Otherwise, it fetches the data from a source (e.g., AppData.GetProducts()), stores it in the cache with expiration settings, and returns it.

controller-example-in-memory.png

When to Use In-Memory Cache

In-memory caching is an excellent choice for scenarios where your application operates on a single instance. By storing data directly in the application's memory, it offers ultra-fast data access. However, there are key considerations to keep in mind:

Horizontal Scaling:

When scaling horizontally (e.g., in a Kubernetes environment), each instance or pod maintains its own in-memory cache. This can lead to inconsistencies because load balancers typically distribute requests in a round-robin or similar manner. If a cached item is missing in one instance, the system may fetch the data from the database again, reducing cache efficiency.

Best Use Cases:

In-memory caching is well-suited for data that:

  • Changes infrequently
  • Includes static or semi-static content like product categories, user roles, or country lists

Volatility:

An in-memory cache is ephemeral; all cached data is lost if the application restarts or crashes. Ensure your application is resilient to such scenarios and can gracefully reload critical data into the cache.

What is Distributed Cache?

A distributed cache stores data across multiple servers, allowing for better scalability and consistency in high-load environments. A popular option for distributed caching is Redis, known for its speed and flexibility. Redis stores data as key-value pairs in memory, ensuring rapid access without overloading the database.

Implementing Redis in a C# Application:

Step 1: Configure Redis

Add Redis to your project:

dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

In your application, configure AddStackExchangeRedisCache to connect to a Redis instance, such as one running in Docker. Add DbContext (in this example, I am using an in-memory database for simplicity). Implement an ICacheService interface along with a CacheService implementation to handle caching operations. I am using Mediator to push messages to manage and deal with different scenarios. If you want to see this project, here is the link: Redis Example.

Step 2: Create an ICacheService Interface

Define the interface for caching services.

Step 3: Implement the CacheService

In this implementation, the Cache-Aside pattern is used, meaning the application controls when to load data into the cache and when to fetch it from the main data source. The application first checks the cache for the data, and if it's not there, it loads the data from the source and stores it in the cache for future use.

To handle concurrency and avoid a cache stampede, I'm using a SemaphoreSlim. A cache stampede happens when multiple requests try to fetch the same data from the cache at the same time, leading to multiple calls to load the same data from the source, which can cause unnecessary load and performance issues. The SemaphoreSlim ensures that only one request at a time triggers the data loading, preventing this problem and improving efficiency.

Cache Invalidation:

Cache invalidation is a process where we tell the system to clear or update the cached data. Why? Because when we change data (e.g., adding or deleting a todo), the cache might still have the old data, and we don't want to show outdated information. So, we need to delete or update that data in the cache.

How to Implement Cache Invalidation:

Step 1: Define Events Using MediatR

Use MediatR to handle events related to cache invalidation. Define events (e.g., item creation, deletion) and inherit the INotification interface.

Step 2: Inject IPublisher in Controller

Inject IPublisher via the controller constructor to publish cache invalidation events when data changes.

Step 3: Publish Events

When data changes, such as when an item is created, updated, or deleted, you publish an event that will trigger cache invalidation. For instance, after an item is created, you can publish the createdTodoEvent.

Step 4: Handle Cache Invalidation in Event Handler

When the event is triggered, the CacheInvalidationTodoHandler is called to invalidate the cache, meaning it removes or marks the cached data as outdated. This ensures that the next time the data is accessed, the system fetches the most recent information from the database or the original data source. This way, you always have up-to-date data without relying on stale cache information.

When to Use Distributed Cache

Distributed caching is ideal for scenarios where your application needs to scale across multiple instances or servers, providing consistent and reliable access to cached data. Unlike in-memory cache, which is limited to a single application instance, distributed cache allows data to be shared across multiple servers, enabling better performance and fault tolerance in high-load or multi-instance environments. However, there are key considerations to keep in mind:

Horizontal Scaling:

Distributed cache is well-suited for horizontally scaled applications, such as those running in cloud environments (e.g., Kubernetes, AWS, or Azure). Since the cache is shared across multiple instances, it helps ensure consistency in retrieving cached data, even when the load balancer directs traffic to different instances. This eliminates the risk of inconsistencies that can occur in in-memory cache during horizontal scaling.

Best Use Cases:

Distributed caching is ideal for:

  1. Frequently changing data: For example, user session information, shopping cart data, or real-time analytics.
  2. High-volume applications: When dealing with a large number of requests, distributed caching can reduce the load on the primary data store by serving cached data from the distributed cache.
  3. Large-scale, fault-tolerant systems: When system resilience and data availability are critical, such as in applications with multiple redundant instances or services.
  4. Data Consistency:
    Distributed cache helps maintain data consistency across instances and provides a shared cache, ensuring that all instances of your application have access to the same data. This is particularly useful for high-traffic applications where maintaining up-to-date information across multiple servers is essential.

  5. Persistence:
    Distributed cache solutions like Redis can be configured to persist data, which provides an additional layer of protection against data loss. While in-memory cache is volatile (losing data on restart), distributed cache can be set up with persistence options to ensure that important data remains intact even after server failures or restarts.

  6. Fault Tolerance and Availability:
    Distributed cache systems, such as Redis or Memcached, are often designed with built-in fault tolerance. They replicate data across multiple nodes or servers, ensuring that if one cache node fails, another node can take over. This makes distributed caches a more reliable option for critical data that must always be available.

Conclusion

Both in-memory and distributed caching offer significant performance benefits, but their use depends on the application's architecture and scalability requirements. In-memory cache is fast and ideal for single-instance applications, but it can become inefficient when scaled horizontally. Distributed cache, on the other hand, provides consistency and scalability across multiple instances, making it a better choice for high-traffic, horizontally scaled applications. Choosing the right caching strategy is essential to optimizing performance, ensuring data consistency, and maintaining reliability in your application.