Showing posts with label redis cache. Show all posts
Showing posts with label redis cache. Show all posts

Saturday, October 12, 2024

Implementing the Cache-Aside Pattern in .NET 8.0 APIs with Entity Framework Core using Redis Cache

Introduction

In this blog post, we'll explore how to implement the Cache-Aside pattern in a .NET 8.0 API using Entity Framework Core. We'll focus on a real-world scenario: an online transaction processing system for an e-commerce platform. By the end of this post, you'll understand how to effectively use caching to improve your API's performance and reduce database load.

What is the Cache-Aside Pattern?

The Cache-Aside pattern is a caching strategy where the application is responsible for maintaining the cache. When data is requested, the application first checks the cache. If the data is not found (a cache miss), it retrieves the data from the database, stores it in the cache, and then returns it to the caller.

Our Scenario: E-commerce Order Processing

We'll build an API for an e-commerce platform that handles order processing. Our focus will be on the following operations:

  1. Retrieving product details
  2. Placing an order
  3. Retrieving order status

Setting Up the Project

First, let's set up our .NET 8.0 API project with Entity Framework Core.

bash
dotnet new webapi -n ECommerceApi cd ECommerceApi dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

Implementing the Data Models

Let's create our data models for products and orders.

csharp
public class Product { public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public int StockQuantity { get; set; } } public class Order { public int Id { get; set; } public string CustomerEmail { get; set; } public DateTime OrderDate { get; set; } public decimal TotalAmount { get; set; } public string Status { get; set; } public List<OrderItem> Items { get; set; } } public class OrderItem { public int Id { get; set; } public int ProductId { get; set; } public int Quantity { get; set; } public decimal UnitPrice { get; set; } }

Setting Up Entity Framework Core

Now, let's set up our DbContext and configure Entity Framework Core.

csharp
public class ECommerceContext : DbContext { public ECommerceContext(DbContextOptions<ECommerceContext> options) : base(options) { } public DbSet<Product> Products { get; set; } public DbSet<Order> Orders { get; set; } public DbSet<OrderItem> OrderItems { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { // Configure your entity relationships and constraints here } }

Add the following to your Program.cs:

csharp
builder.Services.AddDbContext<ECommerceContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

Implementing the Cache-Aside Pattern

We'll use Redis as our distributed cache. Add the following to your Program.cs:

csharp
builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = builder.Configuration.GetConnectionString("RedisConnection"); options.InstanceName = "ECommerceCache_"; });

Now, let's create a caching service that implements the Cache-Aside pattern:

csharp
public interface ICacheService { Task<T> GetOrSetAsync<T>(string key, Func<Task<T>> getItemCallback, TimeSpan expirationTime); Task RemoveAsync(string key); } public class RedisCacheService : ICacheService { private readonly IDistributedCache _cache; private readonly ILogger<RedisCacheService> _logger; public RedisCacheService(IDistributedCache cache, ILogger<RedisCacheService> logger) { _cache = cache; _logger = logger; } public async Task<T> GetOrSetAsync<T>(string key, Func<Task<T>> getItemCallback, TimeSpan expirationTime) { var cachedResult = await _cache.GetStringAsync(key); if (cachedResult != null) { _logger.LogInformation("Cache hit for key: {Key}", key); return JsonSerializer.Deserialize<T>(cachedResult); } _logger.LogInformation("Cache miss for key: {Key}", key); var result = await getItemCallback(); await _cache.SetStringAsync(key, JsonSerializer.Serialize(result), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = expirationTime }); return result; } public async Task RemoveAsync(string key) { await _cache.RemoveAsync(key); _logger.LogInformation("Removed cache for key: {Key}", key); } }

Register the cache service in Program.cs:

csharp
builder.Services.AddSingleton<ICacheService, RedisCacheService>();

Implementing the API Endpoints

Now, let's implement our API endpoints using the Cache-Aside pattern.

Product Controller

csharp
[ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { private readonly ECommerceContext _context; private readonly ICacheService _cacheService; private readonly ILogger<ProductsController> _logger; public ProductsController(ECommerceContext context, ICacheService cacheService, ILogger<ProductsController> logger) { _context = context; _cacheService = cacheService; _logger = logger; } [HttpGet("{id}")] public async Task<ActionResult<Product>> GetProduct(int id) { var cacheKey = $"product_{id}"; var product = await _cacheService.GetOrSetAsync(cacheKey, async () => { _logger.LogInformation("Fetching product {Id} from database", id); return await _context.Products.FindAsync(id); }, TimeSpan.FromMinutes(10)); if (product == null) { return NotFound(); } return product; } [HttpPost] public async Task<ActionResult<Product>> CreateProduct(Product product) { _context.Products.Add(product); await _context.SaveChangesAsync(); // Invalidate cache for this product await _cacheService.RemoveAsync($"product_{product.Id}"); return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product); } [HttpPut("{id}")] public async Task<IActionResult> UpdateProduct(int id, Product product) { if (id != product.Id) { return BadRequest(); } _context.Entry(product).State = EntityState.Modified; try { await _context.SaveChangesAsync(); // Invalidate cache for this product await _cacheService.RemoveAsync($"product_{id}"); } catch (DbUpdateConcurrencyException) { if (!await ProductExists(id)) { return NotFound(); } else { throw; } } return NoContent(); } private async Task<bool> ProductExists(int id) { return await _context.Products.AnyAsync(e => e.Id == id); } }

Order Controller

csharp
[ApiController] [Route("api/[controller]")] public class OrdersController : ControllerBase { private readonly ECommerceContext _context; private readonly ICacheService _cacheService; private readonly ILogger<OrdersController> _logger; public OrdersController(ECommerceContext context, ICacheService cacheService, ILogger<OrdersController> logger) { _context = context; _cacheService = cacheService; _logger = logger; } [HttpPost] public async Task<ActionResult<Order>> PlaceOrder(Order order) { using var transaction = await _context.Database.BeginTransactionAsync(); try { foreach (var item in order.Items) { var product = await _context.Products.FindAsync(item.ProductId); if (product == null || product.StockQuantity < item.Quantity) { throw new InvalidOperationException($"Insufficient stock for product {item.ProductId}"); } product.StockQuantity -= item.Quantity; _context.Entry(product).State = EntityState.Modified; // Invalidate cache for this product await _cacheService.RemoveAsync($"product_{item.ProductId}"); } order.OrderDate = DateTime.UtcNow; order.Status = "Pending"; _context.Orders.Add(order); await _context.SaveChangesAsync(); await transaction.CommitAsync(); return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order); } catch (Exception ex) { await transaction.RollbackAsync(); _logger.LogError(ex, "Error placing order"); return StatusCode(500, "An error occurred while placing the order."); } } [HttpGet("{id}")] public async Task<ActionResult<Order>> GetOrder(int id) { var cacheKey = $"order_{id}"; var order = await _cacheService.GetOrSetAsync(cacheKey, async () => { _logger.LogInformation("Fetching order {Id} from database", id); return await _context.Orders .Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == id); }, TimeSpan.FromMinutes(5)); if (order == null) { return NotFound(); } return order; } [HttpPut("{id}/status")] public async Task<IActionResult> UpdateOrderStatus(int id, string status) { var order = await _context.Orders.FindAsync(id); if (order == null) { return NotFound(); } order.Status = status; await _context.SaveChangesAsync(); // Invalidate cache for this order await _cacheService.RemoveAsync($"order_{id}"); return NoContent(); } }

Performance Considerations

  1. Cache Expiration: We've set different expiration times for products (10 minutes) and orders (5 minutes). Adjust these based on your specific requirements and data volatility.
  2. Cache Invalidation: We invalidate the cache when products are updated or when order statuses change. This ensures that the cached data remains consistent with the database.
  3. Batch Operations: For high-volume scenarios, consider implementing batch cache operations to reduce network overhead.
  4. Monitoring: Implement proper logging and monitoring to track cache hit/miss ratios and identify potential bottlenecks.

Conclusion

We've implemented the Cache-Aside pattern in our .NET 8.0 API using Entity Framework Core and Redis. This approach significantly reduces database load for read-heavy operations while ensuring data consistency for write operations.

Key takeaways:

  1. The Cache-Aside pattern improves performance for frequently accessed, relatively static data.
  2. Proper cache invalidation is crucial to maintain data consistency.
  3. Use distributed caching (like Redis) for scalability in multi-instance deployments.
  4. Adjust cache expiration times based on your data's volatility and consistency requirements.

By following these practices, you can build high-performance, scalable APIs that can handle the demands of modern e-commerce platforms.

Saturday, September 28, 2024

Caching Strategies for Azure App Services and Azure Function Apps

 When working with Azure cloud services, particularly Azure App Services and Azure Function Apps, implementing effective caching strategies becomes even more crucial for maintaining performance and reducing costs. Let's explore some caching techniques specifically tailored for these Azure services.

Azure App Services

Azure App Services provide a fully managed platform for building, deploying, and scaling web apps. Here are some caching strategies you can implement:

  1. In-Memory Caching with IMemoryCache For single-instance App Services, you can use the IMemoryCache as we discussed earlier. However, keep in mind that if your App Service scales out to multiple instances, each instance will have its own separate cache.
  2. Azure Redis Cache For multi-instance scenarios, Azure Redis Cache is an excellent choice. It provides a distributed caching layer that can be shared across multiple App Service instances. To use Azure Redis Cache: a. Create an Azure Redis Cache instance in your Azure portal. b. Add the following NuGet packages to your project:
    Microsoft.Extensions.Caching.StackExchangeRedis
    c. Configure Redis in your Program.cs:
    csharp
    builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = builder.Configuration.GetConnectionString("RedisConnection"); options.InstanceName = "YourAppPrefix"; });
    d. Use the IDistributedCache interface in your controllers or services as shown in the distributed caching example earlier.
  3. Azure Blob Storage for Large Objects For caching large objects that don't fit well in Redis, you can use Azure Blob Storage:
    csharp
    public class BlobStorageCacheService { private readonly BlobServiceClient _blobServiceClient; private readonly string _containerName; public BlobStorageCacheService(string connectionString, string containerName) { _blobServiceClient = new BlobServiceClient(connectionString); _containerName = containerName; } public async Task SetAsync(string key, byte[] data, TimeSpan expiration) { var container = _blobServiceClient.GetBlobContainerClient(_containerName); await container.CreateIfNotExistsAsync(); var blob = container.GetBlobClient(key); await blob.UploadAsync(new BinaryData(data), overwrite: true); var headers = new BlobHttpHeaders { CacheControl = $"max-age={expiration.TotalSeconds}" }; await blob.SetHttpHeadersAsync(headers); } public async Task<byte[]> GetAsync(string key) { var container = _blobServiceClient.GetBlobContainerClient(_containerName); var blob = container.GetBlobClient(key); if (await blob.ExistsAsync()) { var response = await blob.DownloadContentAsync(); return response.Value.Content.ToArray(); } return null; } }

Azure Function Apps

Azure Functions can benefit greatly from caching, especially for reducing cold start times and improving performance for frequently accessed data.

  1. In-Memory Caching with Static Variables For simple scenarios, you can use static variables to cache data within a Function App instance:
    csharp
    public static class MyFunctionApp { private static readonly ConcurrentDictionary<string, object> _cache = new ConcurrentDictionary<string, object>(); [FunctionName("MyFunction")] public static async Task<IActionResult> Run( [HttpTrigger(AuthorizationLevel.Function, "get", Route = null)] HttpRequest req, ILogger log) { string cacheKey = "myDataKey"; if (!_cache.TryGetValue(cacheKey, out object cachedData)) { // Fetch data from the source cachedData = await FetchDataFromSourceAsync(); _cache[cacheKey] = cachedData; } return new OkObjectResult(cachedData); } }
    Remember that this cache is not shared across multiple instances of your Function App.
  2. Azure Redis Cache for Distributed Caching For a distributed caching solution in Azure Functions, you can use Azure Redis Cache:
    csharp
    public static class MyFunctionApp { private static Lazy<ConnectionMultiplexer> lazyConnection = new Lazy<ConnectionMultiplexer>(() => { return ConnectionMultiplexer.Connect(Environment.GetEnvironmentVariable("RedisConnection")); }); public static ConnectionMultiplexer Connection => lazyConnection.Value; [FunctionName("MyFunction")] public static async Task<IActionResult> Run( [HttpTrigger(AuthorizationLevel.Function, "get", Route = null)] HttpRequest req, ILogger log) { IDatabase cache = Connection.GetDatabase(); string cacheKey = "myDataKey"; string cachedData = await cache.StringGetAsync(cacheKey); if (cachedData == null) { // Fetch data from the source var data = await FetchDataFromSourceAsync(); cachedData = JsonSerializer.Serialize(data); await cache.StringSetAsync(cacheKey, cachedData, TimeSpan.FromMinutes(10)); } return new OkObjectResult(JsonSerializer.Deserialize<YourDataType>(cachedData)); } }
  3. Durable Entities for Stateful Caching Azure Durable Functions provide a feature called Durable Entities, which can be used as a form of distributed cache:
    csharp
    [FunctionName(nameof(CacheEntity))] public static void CacheEntity([EntityTrigger] IDurableEntityContext ctx) { switch (ctx.OperationName.ToLowerInvariant()) { case "set": ctx.SetState(ctx.GetInput<string>()); break; case "get": ctx.Return(ctx.HasState ? ctx.GetState<string>() : null); break; } } [FunctionName("MyFunction")] public static async Task<IActionResult> Run( [HttpTrigger(AuthorizationLevel.Function, "get", Route = null)] HttpRequest req, [DurableClient] IDurableEntityClient client, ILogger log) { string cacheKey = "myDataKey"; var entityId = new EntityId(nameof(CacheEntity), cacheKey); var response = await client.ReadEntityStateAsync<string>(entityId); if (!response.EntityExists || response.EntityState == null) { // Fetch data from the source var data = await FetchDataFromSourceAsync(); await client.SignalEntityAsync(entityId, "set", JsonSerializer.Serialize(data)); return new OkObjectResult(data); } return new OkObjectResult(JsonSerializer.Deserialize<YourDataType>(response.EntityState)); }

Best Practices for Azure Caching

  1. Use Azure Redis Cache for multi-instance scenarios: This ensures consistency across all instances of your App Service or Function App.
  2. Implement circuit breakers: Use libraries like Polly to handle transient failures in your caching layer gracefully.
  3. Monitor cache performance: Use Azure Monitor and Application Insights to track cache hit rates, miss rates, and overall performance improvements.
  4. Optimize cache expiration: Set appropriate TTL (Time To Live) values based on how frequently your data changes.
  5. Consider Azure CDN for static content: For static assets, consider using Azure Content Delivery Network (CDN) to cache content closer to your users.
  6. Use Azure Front Door for global applications: If your application serves users globally, consider using Azure Front Door, which provides integrated caching capabilities.

By implementing these caching strategies in your Azure App Services and Function Apps, you can significantly improve your application's performance, reduce load on your backend services, and potentially lower your Azure hosting costs.

Remember to always measure the impact of your caching strategies and adjust them based on your specific application needs and usage patterns.