DEV Community

nikosst
nikosst

Posted on

FusionCache και Redis σε .NET με Clean Architecture και SOLID

Ένας πρακτικός οδηγός από την πρώτη cache μέχρι μια εφαρμογή με πολλά instances, σαφή όρια εξαρτήσεων και ελεγχόμενη συμπεριφορά όταν κάτι αποτυγχάνει.

Θα χρησιμοποιήσουμε έναν κατάλογο προϊόντων. Το API διαβάζει στοιχεία προϊόντος και επιτρέπει αλλαγή τιμής. Η βάση δεδομένων παραμένει η κύρια αποθήκη, το FusionCache διαχειρίζεται την cache μέσα στην εφαρμογή και το Redis προσφέρει κοινό δεύτερο επίπεδο cache. Το παράδειγμα δίνει μεγαλύτερη έμφαση στη σωστή συμπεριφορά παρά σε έναν τεχνητά εντυπωσιακό αριθμό milliseconds.

Ο αναγνώστης χρειάζεται βασική εξοικείωση με C#, async/await, dependency injection και HTTP APIs. Οι έννοιες του caching εξηγούνται πριν χρησιμοποιηθούν στον κώδικα. Η σειρά ανάγνωσης είναι: έννοιες, αρχιτεκτονική, στήσιμο, υλοποίηση, εκτέλεση, αστοχίες, tests και production.

1 Τι αναλαμβάνει κάθε τεχνολογία

Η cache κρατά ένα επαναχρησιμοποιήσιμο αποτέλεσμα, ώστε να αποφύγουμε την επανάληψη δαπανηρής εργασίας. Το αποτέλεσμα μπορεί να προέρχεται από SQL query, εξωτερικό API ή υπολογισμό. Στο παράδειγμά μας είναι ένα μικρό DTO προϊόντος.

Το FusionCache είναι βιβλιοθήκη .NET. Προσφέρει API όπως GetOrSetAsync, οργανώνει την πρόσβαση στην cache και υποστηρίζει πρόσθετες πολιτικές ανθεκτικότητας. Το Redis είναι ξεχωριστή υπηρεσία αποθήκευσης. Πολλά instances της εφαρμογής μπορούν να συνδέονται σε αυτήν.

Στοιχείο Ρόλος στο παράδειγμα Διάρκεια ζωής δεδομένων
Βάση δεδομένων Κύρια αποθήκη των προϊόντων Σύμφωνα με την πολιτική αποθήκευσης της βάσης
FusionCache L1 Τοπική cache στη μνήμη κάθε instance Χάνεται με το process και υπόκειται σε λήξη ή απομάκρυνση
Redis L2 Κοινή cache για τα instances Εξαρτάται από TTL, eviction και τη διαμόρφωση του Redis
Backplane Ειδοποιήσεις αλλαγών μεταξύ instances Μεταφέρει ειδοποιήσεις για την cache
Serializer Μετατροπή των τιμών σε bytes και αντίστροφα Αφορά τη μορφή των αποθηκευμένων τιμών

Το FusionCache μπορεί να δουλέψει μόνο με L1. Όταν προσθέτουμε L2, χρειάζεται provider που υλοποιεί IDistributedCache και serializer. Η L1 συνεχίζει να εξυπηρετεί τις πιο κοντινές αναγνώσεις, ενώ η L2 επιτρέπει επαναχρησιμοποίηση ανάμεσα στα instances. FusionCache cache levels

Το Redis χρησιμοποιείται και για άλλες ανάγκες, όπως δομές δεδομένων, μετρητές, streams και messaging. Εδώ θα περιορίσουμε τη χρήση του στην cache και στο backplane. Redis data types

2 Οι έννοιες που θα χρησιμοποιούμε

Όρος Εξήγηση Παράδειγμα
Key Το μοναδικό όνομα της καταχώρισης product-details:v1:1
Value Το αποτέλεσμα που κρατάμε Τα στοιχεία του προϊόντος 1
Cache hit Βρήκαμε χρησιμοποιήσιμη καταχώριση Επιστρέφουμε το DTO από L1
Cache miss Χρειάζεται αναζήτηση στο επόμενο επίπεδο ή στην πηγή Η L1 δεν περιέχει το προϊόν
Factory Η συνάρτηση που φορτώνει το αποτέλεσμα από την πηγή Ένα EF Core query
TTL Χρονικό όριο εγκυρότητας μιας καταχώρισης Για το demo ξεκινάμε από 60 δευτερόλεπτα
Invalidation Απόσυρση ή λογική ακύρωση μιας τιμής Μετά από αλλαγή προϊόντος
Stale value Τιμή που έχει ξεπεράσει τη λογική διάρκεια εγκυρότητας Παλιά στοιχεία ως fallback
Serialization Μετατροπή ενός αντικειμένου σε μορφή αποθήκευσης DTO σε JSON bytes

Το TTL είναι πολιτική φρεσκάδας. Η απομάκρυνση λόγω πίεσης μνήμης είναι διαφορετικό γεγονός και μπορεί να συμβεί νωρίτερα. Επίσης, μία αλλαγή στη βάση δεν ενημερώνει αυτομάτως κάθε αντίγραφό της. Χρειάζεται στρατηγική ενημέρωσης ή invalidation. Cache aside pattern

3 Πώς εκτελείται μια ανάγνωση

Η βασική στρατηγική μας είναι cache aside. Αναζητούμε πρώτα ένα επαναχρησιμοποιήσιμο αποτέλεσμα και φορτώνουμε από τη βάση όταν χρειάζεται.

flowchart TD
    Request[Αίτημα για προϊόν] --> L1{Έγκυρη τιμή στη L1}
    L1 -->|Ναι| Return[Επιστροφή DTO]
    L1 -->|Όχι| L2{Έγκυρη τιμή στο Redis}
    L2 -->|Ναι| FillL1[Ενημέρωση τοπικής L1]
    FillL1 --> Return
    L2 -->|Όχι| Factory[Factory και query στη βάση]
    Factory --> Save[Αποθήκευση στα επίπεδα cache]
    Save --> Return

Στο πρώτο αίτημα για το προϊόν 1 είναι πιθανό να εκτελεστεί query. Το επόμενο αίτημα στην ίδια instance μπορεί να εξυπηρετηθεί από L1. Μια δεύτερη instance με άδεια L1 μπορεί να βρει το αποτέλεσμα στο Redis.

Αν επανεκκινήσει μόνο η εφαρμογή, χάνει την τοπική μνήμη της. Μπορεί όμως να επωφεληθεί από καταχωρίσεις που εξακολουθούν να υπάρχουν στη L2. Η επιβίωση μετά από επανεκκίνηση του ίδιου του Redis εξαρτάται από τη δική του διαμόρφωση. Στο τοπικό demo επιλέγουμε cache που μπορεί να ξαναχτιστεί και απενεργοποιούμε persistence.

4 Οι αρχιτεκτονικές αποφάσεις του παραδείγματος

Θέλουμε η επιχειρησιακή συμπεριφορά να παραμένει κατανοητή ακόμη και αν αλλάξει η τεχνολογία caching. Χωρίζουμε την εφαρμογή σε τέσσερα projects.

Project Ευθύνη Εξαρτήσεις προς άλλα projects
Catalog.Domain Entity και επιχειρησιακοί κανόνες προϊόντος Καμία
Catalog.Application Use cases, DTOs και συμβόλαια που χρειάζονται Domain
Catalog.Infrastructure EF Core, FusionCache, Redis και adapters Application και μέσω αυτού Domain
Catalog.Api HTTP endpoints και σύνθεση των υπηρεσιών Application και Infrastructure
flowchart LR
    API[Catalog.Api] --> App[Catalog.Application]
    API --> Infra[Catalog.Infrastructure]
    Infra --> App
    App --> Domain[Catalog.Domain]

Τα βέλη δείχνουν εξαρτήσεις του κώδικα. Στην εκτέλεση, το Application μπορεί να καλεί ένα interface και η υλοποίηση του Infrastructure να εκτελεί την εργασία. Η κατεύθυνση του runtime call και η κατεύθυνση του project reference είναι διαφορετικές έννοιες.

Το API γνωρίζει το Infrastructure για να συνθέσει την εφαρμογή στο composition root. Το Domain και το Application παραμένουν ανεξάρτητα από namespaces του FusionCache ή του Redis. Αυτή είναι η πρακτική εφαρμογή της κατεύθυνσης εξαρτήσεων της Clean Architecture. Microsoft application architectures

Στο demo το IProductCacheInvalidator βρίσκεται στο Application: το use case εκφράζει την ανάγκη ακύρωσης ενός read model χωρίς να επιλέγει provider. Σε μεγαλύτερο σύστημα η ίδια ανάγκη μπορεί να εκφράζεται με application event και outbox. Η επιλογή interface εδώ κρατά τη ροή ορατή σε λίγα αρχεία.

5 Πού εφαρμόζεται το SOLID

Το SOLID είναι χρήσιμο όταν οδηγεί σε συγκεκριμένα, κατανοητά όρια.

Αρχή Εφαρμογή στο παράδειγμα
Single Responsibility Το EfProductQueries διαβάζει από τη βάση. Το CachedProductQueries εφαρμόζει caching. Το use case οργανώνει την αλλαγή τιμής.
Open Closed Προσθέτουμε caching τυλίγοντας την ανάγνωση με decorator, διατηρώντας την υλοποίηση του query.
Liskov Substitution Οι δύο readers επιστρέφουν το ίδιο είδος DTO. Το συμβόλαιο του cached reader επιτρέπει δεδομένα σύμφωνα με την επιλεγμένη πολιτική φρεσκάδας.
Interface Segregation Οι αναγνώσεις, οι εγγραφές και το invalidation έχουν μικρά ξεχωριστά interfaces.
Dependency Inversion Τα use cases εξαρτώνται από interfaces του Application. Οι τεχνολογικές υλοποιήσεις βρίσκονται στο Infrastructure.

Η φρεσκάδα είναι μέρος του συμβολαίου. Αν ένας reader υπόσχεται την απολύτως τελευταία τιμή της βάσης, ένας decorator που μπορεί να επιστρέψει προηγούμενη τιμή αλλάζει την υπόσχεση. Γι’ αυτό οι εντολές μας φορτώνουν το entity από το repository και οι αναγνώσεις οθόνης χρησιμοποιούν το cached DTO.

Στο παράδειγμα δεν προσθέτουμε generic repository, MediatR ή μια μεγάλη γενική βιβλιοθήκη ICacheService. Μπορούν να είναι χρήσιμα σε συγκεκριμένο project, αλλά η ανάγκη εδώ καλύπτεται από λίγα σαφή συμβόλαια.

6 Προϋποθέσεις και εκδόσεις

Για να τρέξει το demo χρειάζονται .NET 10 SDK και Docker με Docker Compose για το Redis. Τα προαιρετικά HTTP smoke scripts χρησιμοποιούν Node.js με ενσωματωμένο fetch.

Στοιχείο Έκδοση παραδείγματος
Target framework net10.0
FusionCache και extensions 2.9.0
EF Core SQLite 10.0.9
Microsoft Redis caching provider 10.0.9
Redis container redis:8.2-alpine

Το συγκεκριμένο sample χρησιμοποιεί σταθερές εκδόσεις NuGet και packages.lock.json. Το Redis tag επιτρέπει ενημερώσεις στην ίδια σειρά. Για πλήρως αναπαραγώγιμο deployment κλείδωσε το container και σε επιλεγμένο image digest. Η έκδοση FusionCache του παραδείγματος δημοσιεύεται στο NuGet.

Οι εντολές εκτελούνται από τον φάκελο FusionCacheRedisDemo, εκτός αν αναφέρεται διαφορετικά. Τα παραδείγματα μεταβλητών περιβάλλοντος χρησιμοποιούν PowerShell.

dotnet --version
docker version
docker compose version
Enter fullscreen mode Exit fullscreen mode

Το SQLite επιλέγεται για να υπάρχει πραγματική βάση με ελάχιστο στήσιμο. Η ίδια λογική caching μπορεί να περιβάλλει queries προς SQL Server ή PostgreSQL. Για πραγματικό deployment πολλών μηχανών, όλα τα instances χρειάζονται πρόσβαση στην ίδια κατάλληλη βάση δεδομένων.

7 Στήσιμο του Redis τοπικά

Στο compose.yaml:

services:
  redis:
    image: redis:8.2-alpine
    ports:
      - "127.0.0.1:6379:6379"
    command: ["redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru", "--save", "", "--appendonly", "no"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
Enter fullscreen mode Exit fullscreen mode

Εκκίνηση και έλεγχος:

docker compose up -d redis
docker compose ps
docker compose exec redis redis-cli ping
Enter fullscreen mode Exit fullscreen mode

Η τελευταία εντολή πρέπει να επιστρέψει PONG. Το port δημοσιεύεται μόνο στο 127.0.0.1 του host. Η διαμόρφωση χωρίς authentication προορίζεται για αυτό το απομονωμένο τοπικό demo. Σε production χρησιμοποιούμε ιδιωτική δικτύωση, κατάλληλα ACLs και TLS. Redis Docker setup, Redis security

Το maxmemory 128mb ορίζει όριο για τα δεδομένα που λαμβάνονται υπόψη από το Redis eviction. Το allkeys-lru επιτρέπει απομάκρυνση keys όταν χρειάζεται μνήμη, με προτίμηση σε λιγότερο πρόσφατα χρησιμοποιημένα δεδομένα. Ο συνολικός χώρος μνήμης του process περιλαμβάνει επιπλέον overhead. Κράτα επαρκές περιθώριο στον host ή στο container. Redis eviction

Οι επιλογές --save "" και --appendonly no κάνουν τη συγκεκριμένη cache ανακατασκευάσιμη χωρίς persistence. Αυτή η επιλογή ταιριάζει στο demo, όπου τα προϊόντα υπάρχουν στη βάση. Αν στο ίδιο Redis φιλοξενούνται δεδομένα άλλων κατηγοριών, επανεξέτασε χωριστά persistence, eviction και απομόνωση.

Αν η εφαρμογή εκτελείται επίσης σε container στο ίδιο Compose network, η διεύθυνση γίνεται redis:6379. Το localhost μέσα σε ένα container αναφέρεται στο ίδιο το container.

8 Δομή της λύσης και εγκατάσταση πακέτων

FusionCacheRedisDemo/
  Directory.Build.props
  NuGet.Config
  compose.yaml
  FusionCacheRedisDemo.sln
  src/
    Catalog.Domain/
      Product.cs
    Catalog.Application/
      Products/
        ProductDetails.cs
        IProductQueries.cs
        IProductRepository.cs
        IProductCacheInvalidator.cs
        ProductConflictException.cs
        UpdateProductPrice.cs
    Catalog.Infrastructure/
      Caching/
      Persistence/
      DependencyInjection.cs
    Catalog.Api/
      Program.cs
      appsettings.json
  tests/
    Catalog.Tests/
  scripts/
    smoke.mjs
Enter fullscreen mode Exit fullscreen mode

Στο συνοδευτικό project τα references και τα πακέτα υπάρχουν ήδη. Εκτέλεσε:

dotnet restore FusionCacheRedisDemo.sln --locked-mode
dotnet build FusionCacheRedisDemo.sln -c Release --no-restore
Enter fullscreen mode Exit fullscreen mode

Σε υπάρχουσα λύση πρόσθεσε τα caching packages μόνο στο Infrastructure:

dotnet add src/Catalog.Infrastructure package ZiggyCreatures.FusionCache --version 2.9.0
dotnet add src/Catalog.Infrastructure package ZiggyCreatures.FusionCache.Serialization.SystemTextJson --version 2.9.0
dotnet add src/Catalog.Infrastructure package ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis --version 2.9.0
dotnet add src/Catalog.Infrastructure package Microsoft.Extensions.Caching.StackExchangeRedis --version 10.0.9
Enter fullscreen mode Exit fullscreen mode

Το core package δίνει τη βιβλιοθήκη. Το serializer package επιτρέπει αποθήκευση των DTOs στη L2. Ο Microsoft provider συνδέει το IDistributedCache με το Redis. Το backplane package καλύπτει τις ειδοποιήσεις μεταξύ instances. Η προσθήκη μόνο του core package ενεργοποιεί την τοπική λειτουργία· η L2 και το backplane συνδέονται ρητά στο configuration. FusionCache dependency injection

9 Domain με επιχειρησιακούς κανόνες

Ξεκινάμε από το entity. Η τιμή πρέπει να είναι μη αρνητική και να έχει έως δύο δεκαδικά ψηφία. Ο κανόνας ανήκει στο Domain, ανεξάρτητα από το αν η ανάγνωση θα γίνει με Redis, SQL ή καθόλου cache.

src/Catalog.Domain/Product.cs

namespace Catalog.Domain;

public sealed class Product
{
    private Product() { }

    public Product(int id, string name, decimal price)
    {
        if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("A name is required.", nameof(name));
        ValidatePrice(price);
        Id = id;
        Name = name.Trim();
        Price = price;
        Version = 1;
    }

    public int Id { get; private set; }
    public string Name { get; private set; } = string.Empty;
    public decimal Price { get; private set; }
    public long Version { get; private set; }

    public void ChangePrice(decimal price)
    {
        ValidatePrice(price);
        Price = price;
        Version = checked(Version + 1);
    }

    private static void ValidatePrice(decimal price)
    {
        if (price < 0 || decimal.Round(price, 2) != price)
            throw new ArgumentOutOfRangeException(nameof(price),
                "Price must be non-negative with at most two decimal places.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Το Version αυξάνεται σε κάθε αλλαγή και θα χρησιμοποιηθεί για optimistic concurrency. Τα private setters περιορίζουν τις αλλαγές στους δρόμους που ορίζει το entity. Το Domain δεν έχει εξάρτηση από EF Core, HTTP ή caching.

Σε ένα πραγματικό εμπορικό σύστημα θα όριζες επίσης νόμισμα, κανόνες στρογγυλοποίησης και τα κατάλληλα όρια τιμής. Εδώ κρατάμε ένα μόνο ποσό, ώστε το παράδειγμα να παραμένει συγκεντρωμένο στο caching.

10 Application με DTOs και μικρά συμβόλαια

Το αποτέλεσμα της ανάγνωσης

src/Catalog.Application/Products/ProductDetails.cs

using Catalog.Domain;

namespace Catalog.Application.Products;

public sealed record ProductDetails(int Id, string Name, decimal Price, long Version)
{
    public static ProductDetails From(Product product) =>
        new(product.Id, product.Name, product.Price, product.Version);
}
Enter fullscreen mode Exit fullscreen mode

Αποθηκεύουμε ένα immutable DTO. Τα πεδία του είναι απλές τιμές και string. Έτσι η L1 μπορεί να επαναχρησιμοποιεί το αντικείμενο χωρίς ένας caller να αλλάξει κατά λάθος την τιμή που θα δει ο επόμενος.

Ένα record που περιέχει mutable λίστα ή mutable child object δεν είναι αυτομάτως βαθιά immutable. Για τέτοια μοντέλα χρησιμοποίησε immutable collections ή σαφή στρατηγική αντιγραφής. Απόφυγε στην cache tracked EF entities, DbContext, IQueryable, streams και αντικείμενα που εξαρτώνται από ενεργό request.

Το συμβόλαιο ανάγνωσης

src/Catalog.Application/Products/IProductQueries.cs

namespace Catalog.Application.Products;

public interface IProductQueries
{
    Task<ProductDetails?> GetByIdAsync(int id, CancellationToken token = default);
}
Enter fullscreen mode Exit fullscreen mode

Το API χρειάζεται την ικανότητα ανάγνωσης προϊόντος. Δεν χρειάζεται να γνωρίζει αν το αποτέλεσμα προέρχεται από L1, Redis ή βάση.

Τα συμβόλαια εγγραφής και invalidation

src/Catalog.Application/Products/IProductRepository.cs

using Catalog.Domain;

namespace Catalog.Application.Products;

public interface IProductRepository
{
    Task<Product?> GetByIdAsync(int id, CancellationToken token = default);
    Task SaveChangesAsync(CancellationToken token = default);
}
Enter fullscreen mode Exit fullscreen mode

src/Catalog.Application/Products/IProductCacheInvalidator.cs

namespace Catalog.Application.Products;

// Best effort invalidation. Implementations log failures; durable delivery needs an outbox.
public interface IProductCacheInvalidator
{
    Task InvalidateAsync(int id, CancellationToken token = default);
}
Enter fullscreen mode Exit fullscreen mode

Το repository επιστρέφει entity για αλλαγές που ακολουθούν τους επιχειρησιακούς κανόνες. Ο invalidator εκφράζει μόνο την ακύρωση του σχετικού read model. Το συγκεκριμένο συμβόλαιό του είναι best effort: επιχειρεί invalidation, καταγράφει αποτυχίες και δεν υπόσχεται durable παράδοση. Η επιλογή αυτή επηρεάζει το consistency της εφαρμογής και θα εξεταστεί στην ενότητα του outbox.

11 Ανάγνωση από τη βάση με σωστό lifetime

Το CatalogDbContext περιγράφει την αποθήκευση:

using Catalog.Domain;
using Microsoft.EntityFrameworkCore;

namespace Catalog.Infrastructure.Persistence;

public sealed class CatalogDbContext(DbContextOptions<CatalogDbContext> options)
    : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var product = modelBuilder.Entity<Product>();
        product.HasKey(p => p.Id);
        product.Property(p => p.Id).ValueGeneratedNever();
        product.Property(p => p.Name).HasMaxLength(200).IsRequired();
        product.Property(p => p.Price).HasPrecision(18, 2);
        product.Property(p => p.Version).IsConcurrencyToken();
    }
}
Enter fullscreen mode Exit fullscreen mode

Το Version δηλώνεται concurrency token. Κατά την εγγραφή, το EF Core συγκρίνει την αρχική του τιμή με τη βάση. Αν κάποιος άλλος writer έχει προλάβει να αλλάξει το ίδιο προϊόν, η αποθήκευση μπορεί να απορριφθεί ως concurrency conflict. EF Core concurrency

Η υλοποίηση της ανάγνωσης:

using Catalog.Application.Products;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Catalog.Infrastructure.Persistence;

public sealed class EfProductQueries(
    IDbContextFactory<CatalogDbContext> factory,
    ILogger<EfProductQueries> logger) : IProductQueries
{
    public async Task<ProductDetails?> GetByIdAsync(int id, CancellationToken token = default)
    {
        // Each invocation owns its context, including a possible background refresh.
        await using var db = await factory.CreateDbContextAsync(token);
        logger.LogInformation(new EventId(4100, "DatabaseProductRead"),
            "Reading product {ProductId} from the database", id);
        return await db.Products.AsNoTracking()
            .Where(p => p.Id == id)
            .Select(p => new ProductDetails(p.Id, p.Name, p.Price, p.Version))
            .SingleOrDefaultAsync(token);
    }
}
Enter fullscreen mode Exit fullscreen mode

Η factory IDbContextFactory δημιουργεί context ανά εκτέλεση του query. Το await using εξασφαλίζει το disposal μετά την ολοκλήρωση. Αυτό επιτρέπει στον reader να χρησιμοποιηθεί και από background refresh χωρίς να στηρίζεται σε ένα request scoped context που μπορεί να έχει ήδη διατεθεί.

Το DbContext δεν είναι thread safe. Ένα singleton service δεν πρέπει να κρατά ένα κοινό instance του για ταυτόχρονες αναγνώσεις. Επίσης, κάθε async λειτουργία του context πρέπει να ολοκληρώνεται πριν χρησιμοποιηθεί ξανά το ίδιο context. EF Core DbContext lifetime

Το query προβάλλει μόνο τα πεδία που χρειάζεται το DTO. Το log Reading product ... from the database γράφεται όταν εκτελείται η πραγματική ανάγνωση και θα μας βοηθήσει να ξεχωρίσουμε την εκτέλεση της factory από τα cache hits.

Η υλοποίηση των εγγραφών παραμένει χωριστή:

using Catalog.Application.Products;
using Catalog.Domain;
using Microsoft.EntityFrameworkCore;

namespace Catalog.Infrastructure.Persistence;

public sealed class EfProductRepository(CatalogDbContext db) : IProductRepository
{
    public Task<Product?> GetByIdAsync(int id, CancellationToken token = default) =>
        db.Products.SingleOrDefaultAsync(p => p.Id == id, token);

    public async Task SaveChangesAsync(CancellationToken token = default)
    {
        try
        {
            await db.SaveChangesAsync(token);
        }
        catch (DbUpdateConcurrencyException ex)
        {
            throw new ProductConflictException("A concurrent update changed this product.", ex);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Ένα EF Core exception μεταφράζεται σε exception του Application. Έτσι ο κώδικας του use case και του API μπορεί να εκφράζει τη σύγκρουση χωρίς να δεσμεύει το Application σε provider persistence.

Το αντίστοιχο exception:

namespace Catalog.Application.Products;

public sealed class ProductConflictException(string message, Exception? inner = null)
    : Exception(message, inner);
Enter fullscreen mode Exit fullscreen mode

12 Cache keys με σαφή ταυτότητα και έκδοση

src/Catalog.Infrastructure/Caching/ProductCacheKeys.cs

namespace Catalog.Infrastructure.Caching;

public static class ProductCacheKeys
{
    public static string Details(int id) => $"product-details:v1:{id}";
    public static string Tag(int id) => $"product:{id}";
}
Enter fullscreen mode Exit fullscreen mode

Το key περιγράφει το read model και το αναγνωριστικό. Το v1 είναι έκδοση του σχήματος του cached DTO. Το ProductDetails.Version είναι διαφορετικό: περιγράφει την επιχειρησιακή έκδοση της συγκεκριμένης εγγραφής.

Το κοινό prefix προστίθεται από τη ρύθμιση του FusionCache. Για το demo είναι catalog:local:. Η λογική ταυτότητα ενός entry συνδυάζει prefix και key. Η φυσική αναπαράσταση στο Redis μπορεί να περιλαμβάνει επιπλέον εσωτερικά στοιχεία του provider ή της βιβλιοθήκης.

Σε πολυμισθωτική εφαρμογή, η ταυτότητα του tenant πρέπει να συμμετέχει όπου αλλάζει το αποτέλεσμα:

catalog:production:product-details:v1:tenant:42:product:1
Enter fullscreen mode Exit fullscreen mode

Αν η απάντηση αλλάζει ανά γλώσσα, νόμισμα ή χρήστη, συμπεριέλαβε και αυτές τις διαστάσεις ή αποθήκευσε ένα κατάλληλο κοινό read model πριν από την εξατομίκευση. Οι ταυτότητες προκύπτουν από επικυρωμένο context. Η εξουσιοδότηση εξακολουθεί να εκτελείται ακόμη και σε cache hit.

Για paginated queries χρειάζονται επίσης φίλτρα, sort order και σελίδα. Κανονικοποίησε τις τιμές και, για μεγάλο συνδυασμό παραμέτρων, χρησιμοποίησε σταθερό hash. Το GetHashCode() ενός αντικειμένου δεν αποτελεί κατάλληλο σταθερό αναγνωριστικό μεταξύ διαφορετικών processes.

13 Ο caching decorator

Ο decorator υλοποιεί το ίδιο interface με τον reader και τον τυλίγει:

using Catalog.Application.Products;
using ZiggyCreatures.Caching.Fusion;

namespace Catalog.Infrastructure.Caching;

public sealed class CachedProductQueries(
    IProductQueries inner, IFusionCache cache, CacheSettings settings) : IProductQueries
{
    public async Task<ProductDetails?> GetByIdAsync(int id, CancellationToken token = default)
    {
        if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));

        return await cache.GetOrSetAsync<ProductDetails?>(
            ProductCacheKeys.Details(id),
            async (context, factoryToken) =>
            {
                var product = await inner.GetByIdAsync(id, factoryToken);
                if (product is null)
                {
                    // Cache missing products briefly; do not reuse stale negative results.
                    context.Options.Duration = TimeSpan.FromSeconds(settings.NegativeDurationSeconds);
                    context.Options.JitterMaxDuration = TimeSpan.Zero;
                    context.Options.IsFailSafeEnabled = false;
                    context.Options.EagerRefreshThreshold = null;
                }
                return product;
            },
            tags: [ProductCacheKeys.Tag(id)],
            token: token);
    }
}
Enter fullscreen mode Exit fullscreen mode

Η κλήση GetOrSetAsync δέχεται το key και τη factory. Το FusionCache χειρίζεται την αναζήτηση στα ρυθμισμένα επίπεδα. Η factory εκτελεί το πραγματικό query όταν απαιτείται νέα τιμή.

Το factoryToken περνά στην πηγή δεδομένων. Διατηρούμε τη ροή async από το endpoint μέχρι το EF Core και αποφεύγουμε .Result ή .Wait(). Το token που δίνεται στη factory είναι το token εκτέλεσης που μας παρέχει το FusionCache.

Γιατί αποθηκεύουμε προσωρινά και το null

Ένα μη υπαρκτό προϊόν μπορεί επίσης να ζητείται επανειλημμένα. Με negative caching αποθηκεύουμε για λίγο το γεγονός ότι δεν βρέθηκε. Στο demo το null έχει TTL 5 δευτερολέπτων και απενεργοποιούμε γι’ αυτό jitter, eager refresh και fail-safe.

Η αλλαγή των options μέσω του factory context είναι adaptive caching: η πολιτική προσαρμόζεται στο αποτέλεσμα που μόλις φορτώθηκε. FusionCache adaptive caching

Αν προσθέσεις endpoint δημιουργίας προϊόντος, πρέπει να ακυρώνει και τυχόν προηγούμενη αρνητική καταχώριση για το ίδιο ID. Η αλλαγή μόνο του write path δεν αρκεί όταν έχεις αποθηκεύσει και την προηγούμενη απουσία της εγγραφής.

Το tag συνδέει το entry με το προϊόν του. Το βασικό write path του demo χρησιμοποιεί ακύρωση συγκεκριμένου key. Τα tags θα φανούν χρήσιμα όταν προστεθούν περισσότερες όψεις του ίδιου προϊόντος.

14 Ρύθμιση του FusionCache και του Redis

Η κλάση ρυθμίσεων:

namespace Catalog.Infrastructure.Caching;

public sealed class CacheSettings
{
    public bool EnableRedis { get; set; } = true;
    public string Name { get; set; } = "catalog-products-local";
    public string KeyPrefix { get; set; } = "catalog:local:";
    public int DurationSeconds { get; set; } = 60;
    public int NegativeDurationSeconds { get; set; } = 5;
    public bool EnableFailSafe { get; set; } = false;
    public bool EnableEagerRefresh { get; set; } = false;
}
Enter fullscreen mode Exit fullscreen mode

Η σύνθεση του Infrastructure βρίσκεται σε ένα σημείο:

using Catalog.Application.Products;
using Catalog.Infrastructure.Caching;
using Catalog.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZiggyCreatures.Caching.Fusion;
using ZiggyCreatures.Caching.Fusion.Serialization.SystemTextJson;

namespace Catalog.Infrastructure;

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services, IConfiguration configuration)
    {
        var settings = configuration.GetRequiredSection("Cache").Get<CacheSettings>()
            ?? throw new InvalidOperationException("Cache configuration is required.");
        if (settings.DurationSeconds <= 0 || settings.NegativeDurationSeconds <= 0 ||
            string.IsNullOrWhiteSpace(settings.Name) || string.IsNullOrWhiteSpace(settings.KeyPrefix))
            throw new InvalidOperationException("Cache durations, name and key prefix must be configured.");

        services.AddSingleton(settings);
        services.AddDbContextFactory<CatalogDbContext>(options =>
            options.UseSqlite(configuration.GetConnectionString("Catalog")
                ?? throw new InvalidOperationException("Catalog connection string is required.")));

        var builder = services.AddFusionCache(settings.Name)
            .WithOptions(options =>
            {
                options.CacheKeyPrefix = settings.KeyPrefix;
            })
            .WithDefaultEntryOptions(options =>
            {
                options.Duration = TimeSpan.FromSeconds(settings.DurationSeconds);
                options.JitterMaxDuration = TimeSpan.FromSeconds(3);
                options.IsFailSafeEnabled = settings.EnableFailSafe;
                options.FailSafeMaxDuration = TimeSpan.FromMinutes(5);
                options.FailSafeThrottleDuration = TimeSpan.FromSeconds(10);
                options.FactorySoftTimeout = TimeSpan.FromMilliseconds(150);
                options.FactoryHardTimeout = TimeSpan.FromSeconds(3);
                options.AllowTimedOutFactoryBackgroundCompletion = false;
                options.EagerRefreshThreshold = settings.EnableEagerRefresh ? 0.8f : null;
                options.DistributedCacheSoftTimeout = TimeSpan.FromMilliseconds(100);
                options.DistributedCacheHardTimeout = TimeSpan.FromMilliseconds(500);
                options.AllowBackgroundDistributedCacheOperations = false;
                options.AllowBackgroundBackplaneOperations = false;
            });

        if (settings.EnableRedis)
        {
            var redis = configuration.GetConnectionString("Redis")
                ?? throw new InvalidOperationException("Redis connection string is required.");
            services.AddStackExchangeRedisCache(options => options.Configuration = redis);
            services.AddFusionCacheStackExchangeRedisBackplane(options => options.Configuration = redis);
            builder.WithSerializer(new FusionCacheSystemTextJsonSerializer())
                .WithRegisteredDistributedCache()
                .WithRegisteredBackplane();
        }

        services.AddSingleton<EfProductQueries>();
        services.AddSingleton<IProductQueries>(provider => new CachedProductQueries(
            provider.GetRequiredService<EfProductQueries>(),
            provider.GetRequiredService<IFusionCacheProvider>().GetCache(settings.Name), settings));
        services.AddSingleton<IProductCacheInvalidator>(provider => new FusionProductCacheInvalidator(
            provider.GetRequiredService<IFusionCacheProvider>().GetCache(settings.Name),
            provider.GetRequiredService<ILogger<FusionProductCacheInvalidator>>()));
        services.AddScoped<IProductRepository, EfProductRepository>();
        services.AddScoped<UpdateProductPrice>();
        return services;
    }
}
Enter fullscreen mode Exit fullscreen mode

Τι συνδέει κάθε βήμα

Το AddDbContextFactory δίνει factory για τους readers και scoped context για τις εγγραφές. Το AddFusionCache(settings.Name) δημιουργεί named cache. Το όνομα ορίζεται κατά το registration. Την ανακτούμε μέσω IFusionCacheProvider.GetCache(settings.Name) μέσα στη σύνθεση των adapters. FusionCache named caches

Το WithSerializer επιλέγει System.Text.Json. Το WithRegisteredDistributedCache συνδέει τον Redis provider. Το WithRegisteredBackplane συνδέει τις ειδοποιήσεις. Ο reader και ο invalidator παίρνουν την ίδια named cache.

Η factory που κατασκευάζει τον decorator δίνει ως inner το συγκεκριμένο EfProductQueries. Αν ζητούσε πάλι IProductQueries, θα οδηγούσε σε κυκλική εξάρτηση κατά την κατασκευή του ίδιου decorator.

Lifetimes των υπηρεσιών

Υπηρεσία Lifetime στο demo Αιτιολόγηση
FusionCache Singleton μέσω της βιβλιοθήκης Κοινή τοπική cache και συντονισμός εντός process
EfProductQueries Singleton Κρατά factory και δημιουργεί context ανά κλήση
CachedProductQueries Singleton Κρατά ασφαλείς για αυτή τη χρήση εξαρτήσεις
Cache invalidator Singleton Χρησιμοποιεί την κοινή named cache
EfProductRepository Scoped Συνεργάζεται με scoped write context
UpdateProductPrice Scoped Οργανώνει μία μονάδα εγγραφής

Αν ο reader χρειαστεί tenant service ή άλλο request scoped dependency, επανεξέτασε το lifetime του. Για background εκτέλεση, πέρασε τις απαραίτητες επικυρωμένες τιμές ρητά και δημιούργησε σωστό scope ή factory μέσα στη λειτουργία. Η αποθήκευση ενός HttpContext σε singleton θα δημιουργούσε λανθασμένη εξάρτηση lifetime.

Οι αρχικές πολιτικές του demo

Ρύθμιση Τιμή Ερμηνεία
Κανονικό Duration 60 δευτερόλεπτα Αφετηρία για την πολιτική εγκυρότητας
Jitter έως 3 επιπλέον δευτερόλεπτα Μικρή διασπορά των λήξεων
Negative Duration 5 δευτερόλεπτα Σύντομη διατήρηση μη ύπαρξης
Fail-safe Απενεργοποιημένο Οι ληγμένες τιμές δεν χρησιμοποιούνται ως fallback αυτής της πολιτικής
Eager refresh Απενεργοποιημένο Ξεκινάμε με απλούστερη ροή
Factory hard timeout 3 δευτερόλεπτα Όριο αναμονής της factory σύμφωνα με την πολιτική του FusionCache
Background completion μετά από timeout Απενεργοποιημένο Περιορίζουμε τις διαδρομές συνέχισης στο αρχικό demo
Distributed hard timeout 500 milliseconds Περιορισμός αναμονής λειτουργιών L2
Background distributed operations Απενεργοποιημένες Οι ροές του demo παραμένουν ευκολότερες στην παρακολούθηση

Οι αριθμοί είναι εκπαιδευτικές αρχικές επιλογές. Προκύπτουν από το συγκεκριμένο παράδειγμα και χρειάζονται προσαρμογή βάσει latency, φορτίου και ανεκτής παλαιότητας της δικής σου εφαρμογής.

15 Configuration της εφαρμογής

Στο src/Catalog.Api/appsettings.json:

{
  "ConnectionStrings": {
    "Catalog": "Data Source=catalog.db",
    "Redis": "localhost:6379,abortConnect=false,connectTimeout=1000,syncTimeout=1000,asyncTimeout=1000"
  },
  "Cache": {
    "EnableRedis": true,
    "Name": "catalog-products-local",
    "KeyPrefix": "catalog:local:",
    "DurationSeconds": 60,
    "NegativeDurationSeconds": 5,
    "EnableFailSafe": false,
    "EnableEagerRefresh": false
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Warning",
      "ZiggyCreatures.Caching.Fusion": "Information"
    }
  },
  "AllowedHosts": "localhost;127.0.0.1"
}
Enter fullscreen mode Exit fullscreen mode

Για deployment άλλαξε τουλάχιστον το όνομα της cache, το prefix και τις διευθύνσεις των υπηρεσιών. Όλα τα instances του ίδιου deployment που μοιράζονται δεδομένα πρέπει να συμφωνούν για cache name, namespace και συμβατότητα serializer. Development και production χρειάζονται διαφορετική απομόνωση.

Μυστικά σύνδεσης μπαίνουν σε secret store ή κατάλληλο μηχανισμό configuration. Για τοπική χρήση του ASP.NET Core μπορείς να χρησιμοποιήσεις user secrets. Η σύνδεση του demo περιέχει μόνο localhost και χρονικά όρια.

Παράδειγμα override σε PowerShell:

$env:ConnectionStrings__Redis = 'localhost:6379,abortConnect=false,connectTimeout=1000,syncTimeout=1000,asyncTimeout=1000'
$env:Cache__Name = 'catalog-products-local'
$env:Cache__KeyPrefix = 'catalog:local:'
Enter fullscreen mode Exit fullscreen mode

Για προσωρινή εκτέλεση μόνο με L1:

$env:Cache__EnableRedis = 'false'
Enter fullscreen mode Exit fullscreen mode

Για επαναφορά της τιμής από το JSON στην ίδια PowerShell session:

Remove-Item Env:Cache__EnableRedis -ErrorAction SilentlyContinue
Enter fullscreen mode Exit fullscreen mode

Το abortConnect=false επιτρέπει στον Redis client να παραμείνει διαθέσιμος για προσπάθειες επανασύνδεσης. Η συμπεριφορά της εφαρμογής σε αστοχίες εξαρτάται επίσης από timeouts, cache policies και τη διαθεσιμότητα της βάσης. Μία επιλογή σύνδεσης δεν αποτελεί από μόνη της συνολική στρατηγική ανθεκτικότητας. StackExchange.Redis configuration

16 Εγγραφές με commit πριν από το invalidation

Το use case αλλαγής τιμής:

namespace Catalog.Application.Products;

public sealed class UpdateProductPrice(
    IProductRepository repository,
    IProductCacheInvalidator invalidator)
{
    public async Task<ProductDetails?> ExecuteAsync(
        int id, decimal price, long expectedVersion, CancellationToken token = default)
    {
        var product = await repository.GetByIdAsync(id, token);
        if (product is null) return null;

        if (product.Version != expectedVersion)
            throw new ProductConflictException("The product has changed. Reload and retry.");

        product.ChangePrice(price);
        await repository.SaveChangesAsync(token);

        // A client disconnect after commit must not suppress the invalidation attempt.
        // The Infrastructure implementation supplies its own bounded timeout.
        await invalidator.InvalidateAsync(id, CancellationToken.None);
        return ProductDetails.From(product);
    }
}
Enter fullscreen mode Exit fullscreen mode

Η σειρά έχει σημασία:

  1. Φορτώνουμε το entity από τη βάση.

  2. Ελέγχουμε το αναμενόμενο Version.

  3. Εφαρμόζουμε τον επιχειρησιακό κανόνα της τιμής.

  4. Ολοκληρώνουμε την αποθήκευση.

  5. Επιχειρούμε ακύρωση του cached read model.

  6. Επιστρέφουμε το DTO της εγγραφής που μόλις ενημερώθηκε.

Αν ακυρώναμε πριν από το commit, ένα ταυτόχρονο read θα μπορούσε να φορτώσει την προηγούμενη τιμή από τη βάση και να γεμίσει ξανά την cache. Η επιλογή commit και μετά invalidate αποφεύγει αυτή τη συγκεκριμένη σειρά προβλήματος, χωρίς να λύνει κάθε πιθανό race.

Μετά το επιτυχημένο commit χρησιμοποιούμε CancellationToken.None για την προσπάθεια invalidation. Ο αποσυνδεδεμένος HTTP client δεν πρέπει να ακυρώσει αυτή την προσπάθεια πριν αρχίσει. Η υλοποίηση του Infrastructure βάζει δικό της σύντομο timeout.

src/Catalog.Infrastructure/Caching/FusionProductCacheInvalidator.cs

using Catalog.Application.Products;
using Microsoft.Extensions.Logging;
using ZiggyCreatures.Caching.Fusion;

namespace Catalog.Infrastructure.Caching;

public sealed class FusionProductCacheInvalidator(
    IFusionCache cache,
    ILogger<FusionProductCacheInvalidator> logger) : IProductCacheInvalidator
{
    public async Task InvalidateAsync(int id, CancellationToken token = default)
    {
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
        timeout.CancelAfter(TimeSpan.FromSeconds(2));
        try
        {
            await cache.RemoveAsync(ProductCacheKeys.Details(id),
                options: new FusionCacheEntryOptions
                {
                    AllowBackgroundDistributedCacheOperations = false,
                    AllowBackgroundBackplaneOperations = false,
                    ReThrowDistributedCacheExceptions = true,
                    ReThrowBackplaneExceptions = true
                }, token: timeout.Token);
        }
        catch (Exception ex)
        {
            // The DB write already committed. Do not turn it into an ambiguous HTTP failure.
            logger.LogError(new EventId(4200, "CacheInvalidationFailed"), ex,
                "Product {ProductId} was committed but cache invalidation failed. " +
                "Use a transactional outbox when durable invalidation is required.", id);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Η ακύρωση γίνεται μέσω FusionCache, ώστε να συμμετέχουν τα επίπεδα και το backplane που έχει διαμορφωθεί. Χειροκίνητο DEL στο Redis θα παρέκαμπτε αυτή τη διαχείριση και θα μπορούσε να αφήσει παλιές τιμές στις L1 caches.

Αν το invalidation αποτύχει μετά το commit, το demo καταγράφει error και διατηρεί επιτυχημένη την επιχειρησιακή εγγραφή. Αυτή η συμπεριφορά αποφεύγει ένα παραπλανητικό HTTP failure για πράξη που έχει ήδη αποθηκευτεί, αλλά επιτρέπει προσωρινά παλιά δεδομένα. Για durable invalidation χρειάζεται επιπλέον σχεδιασμός με outbox.

17 HTTP API και εκκίνηση

src/Catalog.Api/Program.cs

using Catalog.Application.Products;
using Catalog.Domain;
using Catalog.Infrastructure;
using Catalog.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Services.AddInfrastructure(builder.Configuration);
var app = builder.Build();

// Local demo bootstrap. Production deployments use reviewed migrations and separate seeding.
await using (var scope = app.Services.CreateAsyncScope())
{
    var db = scope.ServiceProvider.GetRequiredService<CatalogDbContext>();
    await db.Database.EnsureCreatedAsync();
    if (!await db.Products.AnyAsync())
    {
        db.Products.Add(new Product(1, "Mechanical keyboard", 99.90m));
        await db.SaveChangesAsync();
    }
}

app.MapGet("/health/live", () => Results.Ok(new { status = "alive" }));

app.MapGet("/products/{id:int}", async (int id, IProductQueries queries, CancellationToken token) =>
{
    if (id <= 0) return Results.BadRequest(new { error = "Id must be positive." });
    var product = await queries.GetByIdAsync(id, token);
    return product is null ? Results.NotFound() : Results.Ok(product);
});

app.MapPut("/products/{id:int}/price", async (
    int id, UpdatePriceRequest request, UpdateProductPrice command, CancellationToken token) =>
{
    try
    {
        var product = await command.ExecuteAsync(id, request.Price, request.ExpectedVersion, token);
        return product is null ? Results.NotFound() : Results.Ok(product);
    }
    catch (ArgumentOutOfRangeException ex)
    {
        return Results.BadRequest(new { error = ex.Message });
    }
    catch (ProductConflictException ex)
    {
        return Results.Conflict(new { error = ex.Message });
    }
});

app.Run();

public sealed record UpdatePriceRequest(decimal Price, long ExpectedVersion);
Enter fullscreen mode Exit fullscreen mode

Τα endpoints έχουν μικρή ευθύνη: μετατρέπουν HTTP αιτήματα σε κλήσεις προς Application ports και μετατρέπουν τα αποτελέσματα σε HTTP responses. Η αλλαγή τιμής μπορεί να επιστρέψει 409 Conflict όταν το version του caller δεν συμφωνεί με την τρέχουσα εγγραφή.

Το EnsureCreatedAsync και το seed υπάρχουν για εύκολη τοπική εκκίνηση. Για production αντικατάστησέ τα με οργανωμένες migrations και ελεγχόμενη διαδικασία αρχικοποίησης. Το δείγμα επίσης χρειάζεται την εξουσιοδότηση και τους κανόνες πρόσβασης του πραγματικού προϊόντος πριν εκτεθεί ως υπηρεσία.

Το /health/live ελέγχει μόνο ότι το process απαντά. Η ετοιμότητα να εξυπηρετήσει χρήσιμα αιτήματα είναι διαφορετικό ζήτημα και απαιτεί κατάλληλο readiness check.

Στο πρώτο terminal, από τη ρίζα της λύσης:

docker compose up -d redis
dotnet restore FusionCacheRedisDemo.sln --locked-mode
dotnet build FusionCacheRedisDemo.sln -c Release --no-restore
$demoDatabase = Join-Path (Get-Location).Path 'catalog-demo.db'
$env:ConnectionStrings__Catalog = "Data Source=$demoDatabase"
$env:Cache__EnableRedis = 'true'
dotnet run --project src/Catalog.Api -c Release --no-build --no-launch-profile -- --urls http://localhost:5081
Enter fullscreen mode Exit fullscreen mode

Το SQLite path γίνεται απόλυτο για να είναι σαφές ποιο αρχείο μοιράζονται τα τοπικά processes. Περίμενε να ολοκληρωθεί η πρώτη εκκίνηση πριν ανοίξεις δεύτερη instance, ώστε η αρχική δημιουργία και το seed να ολοκληρωθούν μία φορά.

18 Δοκιμή της ανάγνωσης και της αλλαγής τιμής

Σε δεύτερο terminal:

$current = Invoke-RestMethod 'http://localhost:5081/products/1'
$current
Invoke-RestMethod 'http://localhost:5081/products/1'
Enter fullscreen mode Exit fullscreen mode

Στην πρώτη κλήση, αν η cache είναι άδεια, εμφανίζεται το log ανάγνωσης από τη βάση. Η δεύτερη κλήση μέσα στο διάστημα εγκυρότητας πρέπει να επαναχρησιμοποιεί το αποτέλεσμα. Αν το Redis περιέχει ήδη καταχώριση από προηγούμενη εκτέλεση, ακόμη και η πρώτη κλήση της νέας instance μπορεί να εξυπηρετηθεί από L2.

Αλλαγή τιμής χρησιμοποιώντας την έκδοση που μόλις διαβάσαμε:

$body = @{
    price = 119.90
    expectedVersion = $current.version
} | ConvertTo-Json

Invoke-RestMethod 'http://localhost:5081/products/1/price' `
    -Method Put -ContentType 'application/json' -Body $body

Invoke-RestMethod 'http://localhost:5081/products/1'
Enter fullscreen mode Exit fullscreen mode

Η απάντηση της εγγραφής προκύπτει από το ενημερωμένο entity. Το επόμενο read, μετά από επιτυχημένο invalidation, φορτώνει και αποθηκεύει το νέο DTO. Το Version θα έχει αυξηθεί κατά ένα.

Δοκίμασε επίσης αρνητική τιμή και παλιό expectedVersion. Θα πάρεις αντίστοιχα 400 Bad Request και 409 Conflict. Οι έλεγχοι της εγγραφής παραμένουν ανεξάρτητοι από την cache που χρησιμοποιείται για την εμφάνιση των στοιχείων.

Για να δεις σχετικά keys χωρίς πλήρη σάρωση με KEYS:

docker compose exec redis redis-cli --scan --pattern '*product-details*'
Enter fullscreen mode Exit fullscreen mode

Η φυσική μορφή των δεδομένων είναι υπόθεση του caching provider. Πριν προσπαθήσεις να διαβάσεις ένα key με συγκεκριμένη εντολή Redis, έλεγξε τον τύπο του με TYPE. Το GET αφορά string keys και δεν είναι γενικός τρόπος ανάγνωσης κάθε καταχώρισης ενός provider.

19 Πολλά instances και backplane

Στο δεύτερο API terminal, από την ίδια ρίζα λύσης:

$demoDatabase = Join-Path (Get-Location).Path 'catalog-demo.db'
$env:ConnectionStrings__Catalog = "Data Source=$demoDatabase"
$env:Cache__EnableRedis = 'true'
dotnet run --project src/Catalog.Api -c Release --no-build --no-launch-profile -- --urls http://localhost:5082
Enter fullscreen mode Exit fullscreen mode

Για το τοπικό πείραμα έχουμε δύο processes, δύο ανεξάρτητες L1, ένα Redis και κοινό SQLite αρχείο. Σε deployment διαφορετικών hosts χρησιμοποίησε κοινή server database.

flowchart TD
    A[Instance A και L1 A] --> Redis[Κοινό Redis L2]
    B[Instance B και L1 B] --> Redis
    A --> DB[Κοινή βάση δεδομένων]
    B --> DB
    A -. Ειδοποίηση αλλαγής .-> Backplane[Redis backplane]
    Backplane -. Απόσυρση παλιάς τοπικής τιμής .-> B

Η L2 μοιράζεται τις τιμές. Το backplane ενημερώνει τα άλλα instances για αλλαγές καταχωρίσεων, ώστε να αποσύρουν παλιές τοπικές τιμές. Οι ειδοποιήσεις έχουν μικρό payload και δεν λειτουργούν ως πλήρης μεταφορά του DTO σε κάθε process. FusionCache backplane

Πρακτική δοκιμή:

  1. Κάνε GET στην 5081 για να γεμίσει η cache.

  2. Κάνε GET στην 5082 για να γεμίσει και η L1 της δεύτερης instance.

  3. Άλλαξε την τιμή από την 5081.

  4. Κάνε GET στην 5082 και έλεγξε την καινούργια τιμή.

Χρησιμοποίησε ίδιο cache name και prefix για τις instances που ανήκουν στην ίδια ομάδα δεδομένων. Διαφορετικές ρυθμίσεις μπορούν να δημιουργήσουν δύο απομονωμένες ομάδες και να κάνουν μια σωστή σύνδεση Redis να φαίνεται σαν αποτυχημένος συγχρονισμός.

Οι ειδοποιήσεις υπόκεινται σε καθυστερήσεις και αστοχίες. Το Redis Pub/Sub έχει at most once παράδοση: ένας subscriber που χάνει ένα μήνυμα δεν το παίρνει αργότερα ως durable backlog. Redis Pub/Sub delivery

Το FusionCache διαθέτει auto recovery για προβλήματα με τα distributed components. Αυτό βοηθά την αποκατάσταση της cache, αλλά η κατάσταση του process δεν αποτελεί durable transactional outbox της επιχειρησιακής εγγραφής. FusionCache auto recovery

20 Stampede protection και τα όριά του

Σε άδεια ή ληγμένη cache, πολλά αιτήματα για το ίδιο προϊόν μπορεί να χρειαστούν τη factory ταυτόχρονα. Η προεπιλεγμένη προστασία του FusionCache συντονίζει τις κλήσεις ανά key μέσα στην ίδια cache instance.

Για παράδειγμα, σε μία instance, 40 επικαλυπτόμενα αιτήματα για το ίδιο άδειο key μπορούν να μοιραστούν μία επιτυχημένη εκτέλεση της factory. Σε διαφορετικά instances ο τοπικός συντονισμός δεν ταυτίζεται με κοινό distributed lock.

Στην έκδοση της βιβλιοθήκης του οδηγού υπάρχει και προαιρετική υποστήριξη distributed locker μέσω IFusionCacheDistributedLocker, με Redis implementation στο package ZiggyCreatures.FusionCache.Locking.Distributed.Redis. Είναι πρόσθετη επιλογή που αξιολογείς για ακριβές factories και ταυτόχρονα misses σε πολλά nodes. Το demo χρησιμοποιεί μόνο τον προεπιλεγμένο τοπικό συντονισμό. FusionCache stampede protection

Ο σκοπός αυτού του συντονισμού είναι να μειώσει περιττές φορτώσεις. Επιχειρησιακές εγγυήσεις, όπως μία και μόνο χρέωση ή σωστή κράτηση αποθέματος, εξακολουθούν να χρειάζονται τους κατάλληλους μηχανισμούς της βάσης και της διαδικασίας εγγραφής.

21 Fail safe με συγκεκριμένο όριο παλαιότητας

Το fail-safe επιτρέπει χρήση προηγούμενης ληγμένης τιμής όταν η ανανέωση αντιμετωπίζει πρόβλημα. Χρειάζεται η τιμή να έχει αποθηκευτεί με την κατάλληλη πολιτική. Δεν μπορεί να δημιουργήσει fallback για ένα προϊόν που δεν έχει φορτωθεί ποτέ.

Για να το ενεργοποιήσεις στο demo, πριν από την εκκίνηση:

$env:Cache__EnableFailSafe = 'true'
Enter fullscreen mode Exit fullscreen mode

Το παράδειγμα ορίζει FailSafeMaxDuration 5 λεπτών και FailSafeThrottleDuration 10 δευτερολέπτων. Το πρώτο αφορά τη συνολική μέγιστη διάρκεια διατήρησης για τη χρήση fail-safe, περιλαμβάνοντας το κανονικό διάστημα εγκυρότητας. Το δεύτερο περιορίζει τη συχνότητα νέων προσπαθειών ανανέωσης όταν χρησιμοποιείται το fallback. FusionCache fail safe

Είδος δεδομένων Ενδεικτική αντιμετώπιση
Περιγραφή προϊόντος Μπορεί να επιτρέπεται σύντομη χρήση προηγούμενης τιμής
Στατιστικά dashboard Συχνά αξίζει σαφής ένδειξη χρόνου τελευταίας ενημέρωσης
Προβολή ενδεικτικής τιμής Απαιτεί συμφωνημένη πολιτική φρεσκάδας
Τελική τιμή παραγγελίας Υπολογίζεται και επικυρώνεται στο write workflow
Δικαίωμα πρόσβασης ή ανάκληση Χρειάζεται ειδικό consistency contract

Το fail-safe είναι business απόφαση ανά κατηγορία read model. Η καθολική ενεργοποίησή του σε κάθε δεδομένο δυσκολεύει τη διατήρηση διαφορετικών απαιτήσεων φρεσκάδας. Για αυτό στο βασικό παράδειγμα παραμένει απενεργοποιημένο.

22 Timeouts και eager refresh

Soft και hard timeout

Το soft timeout έχει αξία όταν υπάρχει επιτρεπόμενη stale τιμή: αν η ανανέωση αργεί, μπορεί να χρησιμοποιηθεί προσωρινά το fallback. Το hard timeout ορίζει διαφορετικό όριο αναμονής και μπορεί να οδηγήσει σε timeout exception όταν δεν υπάρχει κατάλληλη εναλλακτική.

Το FusionCache επιτρέπει επίσης συνέχιση timed out factory στο background. Στο demo την απενεργοποιούμε με AllowTimedOutFactoryBackgroundCompletion = false. Η πραγματική διακοπή εργασίας παραμένει συνεργατική: ο downstream κώδικας και ο provider χρειάζεται να σέβονται το cancellation token. FusionCache timeouts

Οι ρυθμίσεις για factory και L2 αφορούν διαφορετικά στάδια. Η συνολική διάρκεια ενός HTTP request περιλαμβάνει επίσης αναμονή, σύνδεση, retries άλλων components και την υπόλοιπη επεξεργασία. Το hard timeout της factory δεν είναι από μόνο του συνολικό HTTP deadline.

Eager refresh

Ενεργοποίηση για το συγκεκριμένο demo:

$env:Cache__EnableEagerRefresh = 'true'
Enter fullscreen mode Exit fullscreen mode

Το threshold 0.8 σημαίνει ότι κατάλληλο αίτημα μετά το 80% της διάρκειας μπορεί να ξεκινήσει background ανανέωση ενώ εξυπηρετείται η υπάρχουσα τιμή. Για βασικό duration 60 δευτερολέπτων, σκέψου το όριο περίπου στα 48 δευτερόλεπτα, πριν συνυπολογιστούν λεπτομέρειες της πολιτικής και jitter.

Η ανανέωση ενεργοποιείται από αίτημα. Δεν υπάρχει υπόσχεση ότι ένα αχρησιμοποίητο key θα ανανεώνεται περιοδικά από μόνο του. FusionCache eager refresh

Ο reader με IDbContextFactory είναι χρήσιμος εδώ: η background εκτέλεση δημιουργεί και διαθέτει δικό της context. Αν η factory κρατούσε request scoped context ή αναφερόταν σε request scoped αντικείμενα μετά το τέλος του HTTP request, θα υπήρχε πρόβλημα lifetime.

23 Tags για περισσότερα read models

Σήμερα έχουμε ένα key ανά προϊόν. Αύριο μπορεί να υπάρχουν διαφορετικά DTOs για κάρτα καταλόγου, αναλυτική σελίδα και εσωτερικό dashboard. Μπορούμε να συνδέσουμε τα σχετικά entries με κοινό tag και να ζητήσουμε ακύρωση της ομάδας.

Ενδεικτική χρήση του API:

await cache.RemoveByTagAsync(ProductCacheKeys.Tag(id), token: token);
Enter fullscreen mode Exit fullscreen mode

Το invalidation με tags είναι λογικός μηχανισμός της βιβλιοθήκης. Δεν συνεπάγεται ότι κάθε φυσικό key διαγράφεται αμέσως από το Redis. Επίσης, η συνεργασία με fail-safe απαιτεί προσοχή στην πολιτική παλαιότητας. Για διαγραφή ευαίσθητων δεδομένων ή ανάκληση πρόσβασης σχεδίασε τις απαιτήσεις ανεξάρτητα από την απλή ομαδοποίηση με tag. FusionCache tagging

Για λίστες προϊόντων, ένα tag όπως products-list επιτρέπει ευρύτερη ακύρωση. Το κόστος είναι ότι μία αλλαγή μπορεί να ανανεώσει πολλές λίστες. Εναλλακτικά χρησιμοποίησε tags ανά κατηγορία, tenant ή άλλη πραγματική διάσταση των δεδομένων.

Κάθε νέο cached read model πρέπει να συνοδεύεται από απάντηση στο εξής: ποιες εγγραφές μπορούν να το καταστήσουν παλιό και ποιο write path θα το ακυρώνει;

24 Consistency και transactional outbox

Το κενό ανάμεσα σε βάση και cache

Η βάση δεδομένων και το Redis είναι διαφορετικά συστήματα. Το επιτυχημένο SQL commit δεν κάνει ατομική και την επόμενη cache operation.

Μια σημαντική περίπτωση είναι:

  1. Το προϊόν ενημερώνεται στη βάση.

  2. Το process τερματίζεται πριν εκτελέσει invalidation.

  3. Η παλιά cache παραμένει μέχρι να αντιμετωπιστεί από άλλη λειτουργία ή από την πολιτική λήξης.

Υπάρχουν και ταυτόχρονες φορτώσεις που ξεκινούν πριν από μια αλλαγή και ολοκληρώνονται μετά από αυτήν. Επομένως, το ζεύγος commit και invalidate δεν επιτρέπει από μόνο του να υποσχεθούμε ότι κάθε επόμενο read σε κάθε node θα δει αμέσως τη νεότερη έκδοση.

Αν απενεργοποιήσουμε το fail-safe, εξακολουθούν να υπάρχουν κανονικά έγκυρες cached τιμές που μπορεί να προέρχονται από προηγούμενη κατάσταση της βάσης. Το EnableFailSafe = false δεν μετατρέπει την cache σε strongly consistent αποθήκη.

Διατήρηση της πρόθεσης invalidation

Με transactional outbox, η επιχειρησιακή αλλαγή και ένα σχετικό event αποθηκεύονται στην ίδια transaction της βάσης. Ένας worker διαβάζει τα εκκρεμή events και επιχειρεί την παράδοση ή την κατάλληλη ενέργεια με retries. Transactional outbox pattern

Εννοιολογική ροή, όχι SQL προς αντιγραφή:

BEGIN TRANSACTION
  UPDATE Product με έλεγχο Version
  INSERT OutboxEvent με ProductId και νέο Version
COMMIT

Worker
  Διαβάζει εκκρεμές event
  Εκτελεί την κατάλληλη ακύρωση ή δημοσίευση
  Καταγράφει ολοκλήρωση σύμφωνα με το συμβόλαιο παράδοσης
  Σε αποτυχία διατηρεί το event για retry
Enter fullscreen mode Exit fullscreen mode

Ο worker χρειάζεται χειρισμό επανεκτέλεσης, ανταγωνισμού μεταξύ workers, backoff και αποτυχιών που απαιτούν παρέμβαση. Για επαναλαμβανόμενο invalidation η λειτουργία μπορεί συνήθως να είναι ασφαλής ως προς την επανάληψη, έστω με επιπλέον cache misses.

Μην επαναχρησιμοποιήσεις αυτούσιο τον best effort invalidator του demo ως ένδειξη ότι ένα outbox event παραδόθηκε: εκείνος καταγράφει και απορροφά exceptions. Ο worker χρειάζεται αυστηρό συμβόλαιο επιτυχίας και αποτυχίας και δεν πρέπει να σημειώνει event ως επεξεργασμένο επειδή απλώς επέστρεψε μια μέθοδος που αποκρύπτει αστοχίες.

Το outbox διατηρεί την πρόθεση επεξεργασίας μετά από crash. Αν ο τελικός μηχανισμός ειδοποίησης παραμένει απλό Pub/Sub, εξακολουθούν να ισχύουν οι περιορισμοί παράδοσής του. Για durable παράδοση προς consumers χρησιμοποίησε αντίστοιχο broker ή log και σχεδίασε το consumption. Για αυστηρή ορθότητα μιας επιχειρησιακής πράξης, επιβεβαίωσε τα κρίσιμα δεδομένα στην αρμόδια πηγή μέσα στο write workflow.

Το sample περιλαμβάνει best effort invalidation και TTL. Το outbox παρουσιάζεται ως επόμενο αρχιτεκτονικό βήμα και δεν αποτελεί υλοποιημένο subsystem μέσα στο συνοδευτικό demo.

25 Τι συμβαίνει όταν αποτυγχάνει κάτι

Περίπτωση Αναμενόμενη κατεύθυνση συμπεριφοράς
Redis μη διαθέσιμο αλλά έγκυρη L1 Η τοπική τιμή μπορεί να εξυπηρετήσει την ανάγνωση
Redis μη διαθέσιμο και άδεια L1 Χρειάζεται φόρτωση από τη βάση, με βάση τις πολιτικές σφαλμάτων
Βάση μη διαθέσιμη αλλά έγκυρη cache Μπορεί να επιστραφεί το αποθηκευμένο αποτέλεσμα
Βάση μη διαθέσιμη και stale τιμή με ενεργό fail-safe Μπορεί να χρησιμοποιηθεί επιτρεπόμενο fallback
Βάση μη διαθέσιμη χωρίς κατάλληλη τιμή Η λειτουργία αποτυγχάνει και το API χρειάζεται κατάλληλη error response
Αποτυχημένο invalidation μετά το commit Η εγγραφή παραμένει επιτυχής στο demo, με error log και πιθανή παλιά cache
Αποτυχημένο backplane Άλλα instances μπορεί προσωρινά να διατηρήσουν παλιές τοπικές τιμές
Redis eviction Το entry μπορεί να λείπει πριν από το TTL και να χρειαστεί αναφόρτωση

Η υποχώρηση από cache σε βάση αυξάνει την πίεση της βάσης. Αν ένα σύστημα στηρίζεται σε πολύ υψηλό hit rate, μία γενικευμένη αστοχία cache μπορεί να ξεπεράσει τη χωρητικότητα του fallback. Σχεδίασε capacity, περιορισμό ταυτόχρονων φορτώσεων και κατάλληλα όρια αιτημάτων.

Ο συντονισμός ανά key δεν περιορίζει ένα κύμα από χιλιάδες διαφορετικά keys. Για παράδειγμα, ένα endpoint που επιτρέπει αυθαίρετους, ανύπαρκτους συνδυασμούς παραμέτρων μπορεί να δημιουργεί πολλά ανεξάρτητα misses. Χρειάζεται validation, περιορισμός cardinality και πολιτική πρόσβασης κατάλληλη για το endpoint.

Το demo κρατά τα error paths μικρά. Σε εφαρμογή production πρόσθεσε κεντρικό exception handling με Problem Details και καθόρισε πώς μεταφράζονται source failures και timeouts σε κατάλληλα HTTP responses.

26 Tests που αποδεικνύουν χρήσιμη συμπεριφορά

Η cache είναι αρκετά εύκολο να φαίνεται σωστή σε μία σειριακή δοκιμή. Οι σημαντικές περιπτώσεις αφορούν επαναλαμβανόμενη χρήση, παράλληλα αιτήματα, αλλαγές και αστοχίες.

Έλεγχος Τι αποδεικνύει
Δύο αναγνώσεις ίδιου ID Η δεύτερη επαναχρησιμοποιεί αποτέλεσμα
Διαφορετικά IDs Τα keys απομονώνουν τις τιμές
40 παράλληλες αναγνώσεις ίδιου άδειου key Ο τοπικός συντονισμός μοιράζεται τη factory
Επαναλαμβανόμενο null Το negative caching λειτουργεί
Ανάγνωση μετά από invalidation Εκτελείται νέα φόρτωση
Δύο FusionCache instances με κοινό test L2 Γίνεται serialization και επαναχρησιμοποίηση από δεύτερη cache
Ακυρωμένο αίτημα Η ακύρωση μεταφέρεται σωστά
Εγγραφή σε πραγματικό SQLite και νέο read Συνδέονται persistence, use case και invalidation
Αποτυχία commit Δεν επιχειρείται invalidation ως επόμενο βήμα
Παλιό version Η εγγραφή απορρίπτεται πριν από commit
Μη έγκυρη τιμή Επιβάλλεται ο κανόνας του Domain

Εκτέλεση:

dotnet test FusionCacheRedisDemo.sln -c Release
Enter fullscreen mode Exit fullscreen mode

Ένα από τα tests του sample:

[Fact]
public async Task Concurrent_reads_for_one_key_share_one_factory()
{
    using var cache = CreateCache();
    var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
    var source = new CountingQueries(async (id, token) =>
    {
        await release.Task.WaitAsync(token);
        return new(id, "Product", 10m, 1);
    });
    var queries = new CachedProductQueries(source, cache, new());
    var requests = Enumerable.Range(0, 40).Select(_ => queries.GetByIdAsync(1)).ToArray();
    release.SetResult(true);
    var results = await Task.WhenAll(requests);
    Assert.All(results, result => Assert.Equal(1, result!.Id));
    Assert.Equal(1, source.Calls);
}
Enter fullscreen mode Exit fullscreen mode

Το κοινό L2 test χρησιμοποιεί MemoryDistributedCache για να απομονώσει τη συμπεριφορά serialization και διαμοιρασμού. Αυτό δεν αποδεικνύει δικτυακή λειτουργία του Redis ή παράδοση backplane. Η πραγματική multi process διαδρομή έχει ξεχωριστό smoke script.

Για τοπικό HTTP smoke test με πραγματικό SQLite και μόνο L1:

dotnet build FusionCacheRedisDemo.sln -c Release
node scripts/smoke.mjs
Enter fullscreen mode Exit fullscreen mode

Για πραγματικό Redis L2 και backplane:

docker compose up -d redis
node scripts/smoke.mjs --redis
Enter fullscreen mode Exit fullscreen mode

Στη δεύτερη λειτουργία το script εκκινεί δύο API processes με μοναδικό κοινό namespace και κοινή προσωρινή βάση. Ελέγχει ότι η δεύτερη instance παίρνει αρχικά την τιμή από Redis χωρίς δικό της database read και ότι ενημέρωση στην πρώτη γίνεται ορατή στη δεύτερη. Ελέγχει επίσης 400 για μη έγκυρη τιμή και 409 για παλιό version. Οι θύρες 5128 και 5129 πρέπει να είναι ελεύθερες.

Για έλεγχο πραγματικού deployment συμπλήρωσε δοκιμές διακοπής Redis, καθυστέρησης βάσης, επανεκκίνησης ενός node, χαμένου invalidation και schema rollout. Οι μονάδες ελέγχου και το τοπικό smoke test καλύπτουν διαφορετικές περιοχές.

27 Παρακολούθηση και μέτρηση απόδοσης

Η επιτυχία μιας cache μετριέται από το αποτέλεσμα στη συνολική εφαρμογή. Παρακολούθησε latency, πραγματικό φορτίο της βάσης και χρήση πόρων μαζί με τα cache metrics.

Μέτρηση Τι δείχνει
L1 hit rate Πόσα reads εξυπηρετούνται τοπικά
L2 hit rate μεταξύ L1 misses Πόσο βοηθά η κοινή cache όταν δεν αρκεί η τοπική
Factory execution count Πόσες πραγματικές φορτώσεις γίνονται
p50 p95 p99 latency Τη συμπεριφορά της τυπικής και των πιο αργών κλήσεων
Fail-safe activations Πόσο συχνά χρησιμοποιούνται stale fallbacks
Invalidation failures Πόσο συχνά μένουν εκκρεμείς αλλαγές στην cache
Redis memory και evictions Αν χωρά το working set και αν απομακρύνεται συχνά
Database query rate Αν το caching μειώνει την εργασία στην πηγή

Το hit rate χρειάζεται σαφή παρονομαστή. Ένα L2 hit rate υπολογισμένο μόνο ανάμεσα στα L1 misses δεν συγκρίνεται απευθείας με το ποσοστό όλων των HTTP requests. Ξεχώριζε επίσης τις fresh επιτυχίες από την επιστροφή stale fallback.

Το FusionCache προσφέρει logging και υποστήριξη OpenTelemetry. Η προαιρετική βιβλιοθήκη ZiggyCreatures.FusionCache.OpenTelemetry παρέχει instrumentation για traces και metrics. Πρόσθεσε exporter κατάλληλο για το περιβάλλον σου. FusionCache OpenTelemetry

Απόφυγε raw cache keys με IDs χρηστών ως labels metrics, επειδή δημιουργούν υψηλή cardinality και μπορεί να εκθέτουν δεδομένα. Προτίμησε κατηγορίες όπως cache name και operation. Χρησιμοποίησε προσεκτικά structured logs για τη διάγνωση συγκεκριμένων περιστατικών.

Για δίκαιη μέτρηση, ξεχώρισε cold start, ζεστή L1, L2 hit και πραγματικό miss. Σύγκρινε ίδιο φορτίο με ίδια δεδομένα και κατέγραψε και αποτυχημένες κλήσεις. Το χαμηλότερο latency από μόνο του δεν περιγράφει σωστή συμπεριφορά αν προκύπτει από ανεπιθύμητα παλιά αποτελέσματα.

28 Προσαρμογές για production

Χωρητικότητα και μνήμη

Το TTL δεν ορίζει πόσα διαφορετικά αντικείμενα μπορούν να βρίσκονται ταυτόχρονα στη L1. Υπολόγισε το ενεργό σύνολο δεδομένων και το κόστος των DTOs ανά instance. Αν χρειάζεσαι MemoryCache με SizeLimit, χρησιμοποίησε κατάλληλα ελεγχόμενη cache και συνεπή Size για τις καταχωρίσεις της. Έλεγξε και τις εσωτερικές καταχωρίσεις των λειτουργιών που ενεργοποιείς. Microsoft memory cache sizing

Οι L1 caches πολλαπλασιάζονται με τον αριθμό των replicas. Δέκα instances με ίδιο μεγάλο working set μπορούν να κρατούν δέκα τοπικά αντίγραφα, πέρα από το Redis. Βάλε όρια με βάση πραγματικές μετρήσεις και όχι μόνο τον αριθμό των keys.

Ασφάλεια και απομόνωση

Το Redis πρέπει να έχει περιορισμένη δικτυακή πρόσβαση, κατάλληλη ταυτοποίηση και TLS όπου απαιτείται. Περιόρισε τα δικαιώματα του λογαριασμού στις εντολές και τα namespaces που χρειάζονται οι επιλεγμένοι providers. Έλεγξε ότι οι απαιτούμενες λειτουργίες για L2 και Pub/Sub επιτρέπονται. Redis security model

Τα production credentials προέρχονται από secret store. Τα cache namespaces διαχωρίζουν εφαρμογές και περιβάλλοντα. Για δεδομένα ανά tenant ή χρήστη, τα keys και ο έλεγχος εξουσιοδότησης σχεδιάζονται μαζί.

Deployments και συμβατότητα DTO

Σε rolling deployment παλιά και νέα binaries μπορεί να διαβάζουν το ίδιο Redis. Αν αλλάξει ασύμβατα το DTO ή ο serializer, αύξησε την έκδοση του key ή χρησιμοποίησε συμβατή μετάβαση. Κράτα ρητή την απόφαση για το πότε αποσύρονται τα προηγούμενα namespaces.

Το prefix version απομονώνει μορφές δεδομένων, αλλά δύο versions που τρέχουν ταυτόχρονα χρειάζονται επίσης σχέδιο invalidation. Αν γράφουν τα ίδια προϊόντα και διατηρούν διαφορετικά cached read models, κάθε write πρέπει να ενημερώνει τους κατάλληλους consumers και των δύο εκδόσεων κατά τη μεταβατική περίοδο.

Συνδέσεις και διαθεσιμότητα

Διατήρησε τις υποκείμενες συνδέσεις σύμφωνα με το lifetime των Redis clients και των providers. Η δημιουργία νέας σύνδεσης ανά request προσθέτει περιττό κόστος. Το demo αφήνει τη διαχείριση στους registered providers. Σε πιο σύνθετη σύνθεση μπορείς να εξετάσεις διαμοιρασμό connection multiplexer, με σαφή ιδιοκτησία και disposal.

Για μεγαλύτερη διαθεσιμότητα εξέτασε managed Redis ή διαμόρφωση high availability που ταιριάζει στις ανάγκες σου. Χρειάζεται επίσης δοκιμασμένη συμπεριφορά όταν προσωρινά λείπει η cache.

Κριτήρια πριν από rollout

  • Έχει οριστεί ανεκτή παλαιότητα για κάθε read model.

  • Τα κρίσιμα writes επαληθεύουν την αρμόδια πηγή δεδομένων.

  • Όλα τα paths δημιουργίας, ενημέρωσης και διαγραφής καλύπτονται από invalidation.

  • Tenant, χρήστης, γλώσσα και άλλα μεταβαλλόμενα στοιχεία συμμετέχουν σωστά στην ταυτότητα των δεδομένων.

  • Τα timeouts και η χωρητικότητα της βάσης επαρκούν για περιόδους με πολλά misses.

  • Υπάρχει σχέδιο για αποτυχία μετά το commit, ενδεχομένως με outbox.

  • Οι αλλαγές schema και serializer είναι συμβατές με rolling deployment.

  • Metrics και logs επιτρέπουν να εντοπιστούν stale fallbacks και αποτυχημένα invalidations.

29 Συνηθισμένα λάθη και διόρθωση

Σύμπτωμα ή επιλογή Πιθανή αιτία ή συνέπεια Κατεύθυνση διόρθωσης
Υπάρχει Redis package αλλά μόνο L1 συμπεριφορά Δεν συνδέθηκε L2 στον builder Έλεγξε provider, serializer και WithRegisteredDistributedCache
Κάθε node δείχνει διαφορετική παλιά τιμή Οι τοπικές L1 δεν αποσύρονται εγκαίρως Έλεγξε backplane, namespaces και πολιτική TTL
Runtime error για cache name Αλλαγή ονόματος μέσω options αντί σωστού registration Χρησιμοποίησε AddFusionCache(name) και τον αντίστοιχο provider
Εξαίρεση disposed DbContext σε refresh Η factory κρατά request scoped context Context factory ή σωστά σχεδιασμένο ανεξάρτητο scope
Κυκλική DI εξάρτηση Ο decorator ζητά το ίδιο interface που κατασκευάζει Δώσε τη συγκεκριμένη inner υλοποίηση
Αλλαγές ενός caller επηρεάζουν άλλον Mutable αντικείμενο μέσα στη L1 Immutable DTOs ή ελεγχόμενη αντιγραφή
Παλιό προϊόν μετά από ενημέρωση Ελλιπές invalidation ή αστοχία μετά το commit Έλεγξε write paths και απαιτήσεις durable invalidation
Νέο προϊόν εμφανίζεται προσωρινά ως ανύπαρκτο Έμεινε negative cache entry Invalidate στο create και σύντομο negative TTL
Πολλές factory εκτελέσεις σε διαφορετικά nodes Ο συντονισμός είναι τοπικός Αξιολόγησε το κόστος πριν προσθέσεις distributed locker
Η βάση υπερφορτώνεται όταν πέφτει το Redis Ανεπαρκές fallback capacity Μέτρηση misses, όρια concurrency και σχέδιο υποβάθμισης
Ραγδαία αύξηση μνήμης Μεγάλη cardinality ή μεγάλα payloads Έλεγξε keys, DTOs, TTL και όρια μνήμης
Το fail-safe εμφανίζει ανεπιθύμητα παλιά δεδομένα Ακατάλληλη πολιτική για το read model Ρύθμιση ανά κατηγορία δεδομένων και αυστηρό write validation

30 Πώς να εφαρμόσεις την προσέγγιση σε υπάρχουσα εφαρμογή

Ξεκίνα από μία πραγματικά επαναλαμβανόμενη και ακριβή ανάγνωση. Μέτρησε τον χρόνο της και το φορτίο που δημιουργεί. Όρισε το DTO και τις απαιτήσεις φρεσκάδας πριν επιλέξεις duration.

Έπειτα βάλε τη φόρτωση πίσω από μικρό query interface και πρόσθεσε caching decorator στο Infrastructure. Κράτα την αρχική πηγή διαθέσιμη ως factory. Πρόσθεσε Redis όταν χρειάζεσαι κοινό L2 ή καλύτερη επαναχρησιμοποίηση μετά από επανεκκινήσεις των instances.

Σύνδεσε κάθε σχετικό write με τη στρατηγική invalidation. Αν η απώλεια της ενέργειας μετά το commit παραβιάζει τις απαιτήσεις σου, πρόσθεσε durable μηχανισμό όπως outbox αντί να στηριχτείς μόνο σε μια άμεση κλήση.

Ενεργοποίησε fail-safe και eager refresh επιλεκτικά, αφού κατανοήσεις ποια παλιά δεδομένα επιτρέπεται να επιστρέφονται και ποιος κώδικας μπορεί να εκτελείται στο background. Επαλήθευσε με tests τις συμπεριφορές που υπόσχεται το δικό σου Application contract και χρησιμοποίησε metrics για την επόμενη προσαρμογή.

Πηγές για περαιτέρω μελέτη

Top comments (0)