Ένας πρακτικός οδηγός από την πρώτη 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
Το 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
Εκκίνηση και έλεγχος:
docker compose up -d redis
docker compose ps
docker compose exec redis redis-cli ping
Η τελευταία εντολή πρέπει να επιστρέψει 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
Στο συνοδευτικό project τα references και τα πακέτα υπάρχουν ήδη. Εκτέλεσε:
dotnet restore FusionCacheRedisDemo.sln --locked-mode
dotnet build FusionCacheRedisDemo.sln -c Release --no-restore
Σε υπάρχουσα λύση πρόσθεσε τα 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
Το 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.");
}
}
Το 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);
}
Αποθηκεύουμε ένα 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);
}
Το 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);
}
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);
}
Το 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();
}
}
Το 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);
}
}
Η 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);
}
}
}
Ένα 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);
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}";
}
Το 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
Αν η απάντηση αλλάζει ανά γλώσσα, νόμισμα ή χρήστη, συμπεριέλαβε και αυτές τις διαστάσεις ή αποθήκευσε ένα κατάλληλο κοινό 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);
}
}
Η κλήση 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;
}
Η σύνθεση του 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;
}
}
Τι συνδέει κάθε βήμα
Το 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"
}
Για 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:'
Για προσωρινή εκτέλεση μόνο με L1:
$env:Cache__EnableRedis = 'false'
Για επαναφορά της τιμής από το JSON στην ίδια PowerShell session:
Remove-Item Env:Cache__EnableRedis -ErrorAction SilentlyContinue
Το 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);
}
}
Η σειρά έχει σημασία:
Φορτώνουμε το entity από τη βάση.
Ελέγχουμε το αναμενόμενο
Version.Εφαρμόζουμε τον επιχειρησιακό κανόνα της τιμής.
Ολοκληρώνουμε την αποθήκευση.
Επιχειρούμε ακύρωση του cached read model.
Επιστρέφουμε το 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);
}
}
}
Η ακύρωση γίνεται μέσω 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);
Τα 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
Το SQLite path γίνεται απόλυτο για να είναι σαφές ποιο αρχείο μοιράζονται τα τοπικά processes. Περίμενε να ολοκληρωθεί η πρώτη εκκίνηση πριν ανοίξεις δεύτερη instance, ώστε η αρχική δημιουργία και το seed να ολοκληρωθούν μία φορά.
18 Δοκιμή της ανάγνωσης και της αλλαγής τιμής
Σε δεύτερο terminal:
$current = Invoke-RestMethod 'http://localhost:5081/products/1'
$current
Invoke-RestMethod 'http://localhost:5081/products/1'
Στην πρώτη κλήση, αν η 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'
Η απάντηση της εγγραφής προκύπτει από το ενημερωμένο entity. Το επόμενο read, μετά από επιτυχημένο invalidation, φορτώνει και αποθηκεύει το νέο DTO. Το Version θα έχει αυξηθεί κατά ένα.
Δοκίμασε επίσης αρνητική τιμή και παλιό expectedVersion. Θα πάρεις αντίστοιχα 400 Bad Request και 409 Conflict. Οι έλεγχοι της εγγραφής παραμένουν ανεξάρτητοι από την cache που χρησιμοποιείται για την εμφάνιση των στοιχείων.
Για να δεις σχετικά keys χωρίς πλήρη σάρωση με KEYS:
docker compose exec redis redis-cli --scan --pattern '*product-details*'
Η φυσική μορφή των δεδομένων είναι υπόθεση του 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
Για το τοπικό πείραμα έχουμε δύο 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
Πρακτική δοκιμή:
Κάνε GET στην 5081 για να γεμίσει η cache.
Κάνε GET στην 5082 για να γεμίσει και η L1 της δεύτερης instance.
Άλλαξε την τιμή από την 5081.
Κάνε 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'
Το παράδειγμα ορίζει 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'
Το 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);
Το 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.
Μια σημαντική περίπτωση είναι:
Το προϊόν ενημερώνεται στη βάση.
Το process τερματίζεται πριν εκτελέσει invalidation.
Η παλιά 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
Ο 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
Ένα από τα 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);
}
Το κοινό 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
Για πραγματικό Redis L2 και backplane:
docker compose up -d redis
node scripts/smoke.mjs --redis
Στη δεύτερη λειτουργία το 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)