Caching, Scrutor Decorators, Redis — Практическое Руководство
Назначение
Документ описывает, как правильно использовать Redis caching, регистрировать decorators через Scrutor, организовывать logging/tracing decorators для ports и adapters. Является практическим дополнением к Caching, Decorators And Resilience.
Где Кэшировать
Разрешено
| Что Кэшировать | TTL | Инвалидация |
|---|---|---|
Lookup-справочники handbook (валюты, страны) | 1 час | По событию pin.handbook.*-upserted (ADR-0063) |
| Catalog public projections | 5 мин | По integration event от catalog service |
| Tenant/brand settings snapshots | 2 мин | По integration event от platform config |
| Payment provider availability | 30 сек | По status event / health check |
| User dashboard summary | 15 сек | По accounting ledger event |
| Partner dashboard aggregates | 1 мин | С freshness marker в response |
| Elasticsearch/ClickHouse report metadata | 30 сек | По freshness SLA |
| Idempotency short replay cache | TTL операции | Поверх DB unique constraints |
Запрещено
- Ledger balance как единственный источник истины.
- KYC/AML/User Safety decisions для high-impact command без owner check.
- Provider credentials, tokens, raw PII.
- Unbounded report result без row limit и TTL.
- Кэш без scope (TenantId/BrandId/ActorId) для user/partner data.
Cache Key Convention
Pin:{env}:{service}:{capability}:tenant:{tenantId}:brand:{brandId}:{key}
Примеры:
Pin:prod:accounting:account-summary:tenant:018f...:brand:018f...:user:018f...
Pin:prod:catalog:game-page:tenant:018f...:brand:018f...:slugs:slots:p1
Pin:prod:reference:currencies:global (нет tenant scope)
Pin:prod:platform:settings:tenant:018f...:brand:018f...
Правила:
- User/partner-owned cache всегда включает
UserIdилиPartnerId. - Global/reference data cache не включает actor scope.
- Cache key не содержит raw PII, email, phone, IP.
- Среда (
prod/staging/dev) предотвращает пересечение данных.
ICacheableQuery В MediatR
Query объявляет себя кешируемым через ICacheableQuery<TResult>:
// Pin.Accounting.Application/Accounts/GetAccountingSummary/GetAccountingSummaryQuery.cs
public sealed record GetAccountingSummaryQuery(AccountingAccountId AccountingId)
: IQuery<AccountingSummaryView>, ICacheableQuery<AccountingSummaryView>
{
// Инжектируем providers через constructor для key формирования
// НО! CacheKey должен быть pure и зависеть только от request data + scope
// Scope vars берутся из CacheKey при регистрации behavior
public string CacheKey => $"accounting:account-summary:{AccountingId}";
public TimeSpan Ttl => TimeSpan.FromSeconds(30);
public string? InvalidationTag => $"accounting:account:{AccountingId}";
public CachePrivacyLevel PrivacyLevel => CachePrivacyLevel.UserOwned;
}
QueryCacheBehavior строит full cache key добавляя tenant/brand/actor scope из providers:
// В behavior, не в query
private string BuildFullCacheKey(ICacheableQuery<TResult> query)
{
var tenantId = _tenantProvider.TryGet()?.ToString() ?? "global";
var brandId = _brandProvider.TryGet()?.ToString() ?? "any";
var actorScope = query.PrivacyLevel switch
{
CachePrivacyLevel.UserOwned => $":user:{_userProvider.TryGet()}",
CachePrivacyLevel.PartnerOwned => $":partner:{_partnerProvider.TryGet()}",
_ => ""
};
return $"Pin:{_env}:{_service}:{query.CacheKey}:tenant:{tenantId}:brand:{brandId}{actorScope}";
}
Scrutor Decorator Pattern
Scrutor позволяет регистрировать decorators через DI без изменения реализации:
// Talent.Common.Observability/DependencyInjection/
// Без Scrutor (боilerplate)
services.AddTransient<IPaymentProviderAdapter, StripeAdapter>();
services.AddTransient<IPaymentProviderAdapter>(sp =>
new LoggingDecorator<IPaymentProviderAdapter>(
new TracingDecorator<IPaymentProviderAdapter>(
sp.GetRequiredService<StripeAdapter>())));
// С Scrutor (чисто)
services.AddTransient<IPaymentProviderAdapter, StripeAdapter>();
services.Decorate<IPaymentProviderAdapter, LoggingDecorator<IPaymentProviderAdapter>>();
services.Decorate<IPaymentProviderAdapter, TracingDecorator<IPaymentProviderAdapter>>();
services.Decorate<IPaymentProviderAdapter, MetricsDecorator<IPaymentProviderAdapter>>();
services.Decorate<IPaymentProviderAdapter, RetryDecorator<IPaymentProviderAdapter>>();
services.Decorate<IPaymentProviderAdapter, CircuitBreakerDecorator<IPaymentProviderAdapter>>();
Extension Method Для Стандартного Набора
// Talent.Common.Observability
public static class ServiceCollectionExtensions
{
// Добавляет logging + tracing + metrics + retry decorators для port/adapter
public static IServiceCollection DecorateWithPinDefaults<TInterface>(
this IServiceCollection services)
where TInterface : class
{
services.Decorate<TInterface, LoggingDecorator<TInterface>>();
services.Decorate<TInterface, TracingDecorator<TInterface>>();
services.Decorate<TInterface, MetricsDecorator<TInterface>>();
return services;
}
// Добавляет cache decorator для read ports
public static IServiceCollection DecorateWithCache<TInterface>(
this IServiceCollection services,
Action<CacheDecoratorOptions> configure)
where TInterface : class
{
services.Configure(configure);
services.Decorate<TInterface, CacheDecorator<TInterface>>();
return services;
}
// Добавляет resilience decorators для external provider adapters
public static IServiceCollection DecorateWithResilience<TInterface>(
this IServiceCollection services,
string policyName)
where TInterface : class
{
services.Decorate<TInterface>(
(inner, sp) => new RetryDecorator<TInterface>(inner,
sp.GetRequiredService<IResiliencePolicyProvider>().GetRetry(policyName)));
services.Decorate<TInterface>(
(inner, sp) => new CircuitBreakerDecorator<TInterface>(inner,
sp.GetRequiredService<IResiliencePolicyProvider>().GetCircuitBreaker(policyName)));
return services;
}
}
Logging Decorator
Generic logging decorator для любого port/adapter:
// Talent.Common.Observability/Decorators/LoggingDecorator.cs
public sealed class LoggingDecorator<TInterface> : DispatchProxy
where TInterface : class
{
private TInterface _inner = default!;
private ILogger _logger = default!;
private ICorrelationContext _correlationContext = default!;
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
var methodName = targetMethod?.Name ?? "Unknown";
var correlationId = _correlationContext.CorrelationId;
_logger.LogDebug(
"Calling {Interface}.{Method} {CorrelationId}",
typeof(TInterface).Name, methodName, correlationId);
try
{
var result = targetMethod!.Invoke(_inner, args);
if (result is Task task)
return HandleAsync(task, methodName, correlationId);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error in {Interface}.{Method} {CorrelationId}",
typeof(TInterface).Name, methodName, correlationId);
throw;
}
}
// Для async методов
private async Task HandleAsync(Task task, string methodName, CorrelationId correlationId)
{
var sw = Stopwatch.StartNew();
try
{
await task;
_logger.LogDebug(
"Completed {Interface}.{Method} in {Ms}ms {CorrelationId}",
typeof(TInterface).Name, methodName, sw.ElapsedMilliseconds, correlationId);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed {Interface}.{Method} in {Ms}ms {CorrelationId}",
typeof(TInterface).Name, methodName, sw.ElapsedMilliseconds, correlationId);
throw;
}
}
// Factory метод
public static TInterface Create(
TInterface inner, ILogger logger, ICorrelationContext correlationContext)
{
var proxy = (LoggingDecorator<TInterface>)Create<TInterface, LoggingDecorator<TInterface>>();
proxy._inner = inner;
proxy._logger = logger;
proxy._correlationContext = correlationContext;
return (TInterface)(object)proxy;
}
}
Упрощённый Вариант Через Класс-Обёртку
Для конкретных interfaces предпочтительнее именованная обёртка (не DispatchProxy):
// Pin.Accounting.Infrastructure/Adapters/Stripe/
internal sealed class LoggingStripeAdapter : IStripePaymentAdapter
{
private readonly IStripePaymentAdapter _inner;
private readonly ILogger<LoggingStripeAdapter> _logger;
private readonly ICorrelationContext _correlation;
public async Task<StripeChargeResult> CreateCharge(
StripeChargeRequest request, CancellationToken ct)
{
_logger.LogInformation(
"Stripe CreateCharge amount={Amount} currency={Currency} correlationId={CorrelationId}",
request.Amount, request.Currency, _correlation.CorrelationId);
var sw = Stopwatch.StartNew();
try
{
var result = await _inner.CreateCharge(request, ct);
_logger.LogInformation(
"Stripe CreateCharge completed chargeId={ChargeId} in {Ms}ms",
result.ChargeId, sw.ElapsedMilliseconds);
return result;
}
catch (StripeException ex)
{
_logger.LogWarning(
"Stripe CreateCharge failed code={Code} in {Ms}ms correlationId={CorrelationId}",
ex.StripeError?.Code, sw.ElapsedMilliseconds, _correlation.CorrelationId);
throw;
}
}
}
Tracing Decorator
// Talent.Common.Observability/Decorators/TracingDecorator.cs
public sealed class TracingStripeAdapter : IStripePaymentAdapter
{
private readonly IStripePaymentAdapter _inner;
private static readonly ActivitySource _source = PinActivitySource.Instance;
public async Task<StripeChargeResult> CreateCharge(
StripeChargeRequest request, CancellationToken ct)
{
using var activity = _source.StartActivity(
"stripe.create_charge",
ActivityKind.Client);
// Безопасные tags — без PII/secrets
activity?.SetTag("payment.provider", "stripe");
activity?.SetTag("payment.currency", request.Currency);
// НЕ добавляем: card number, CVV, provider secret key
try
{
var result = await _inner.CreateCharge(request, ct);
activity?.SetTag("payment.charge_id_hash",
HashHelper.StableHash(result.ChargeId)); // hash, не raw id
activity?.SetStatus(ActivityStatusCode.Ok);
return result;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.RecordException(ex);
throw;
}
}
}
Cache Decorator для Read Ports
// Talent.Common.Observability/Decorators/CacheDecorator.cs
public sealed class CachingGameCatalogPort : IGameCatalogPort
{
private readonly IGameCatalogPort _inner;
private readonly IDistributedCacheProvider _cache;
private readonly ITenantScopeProvider _tenantProvider;
private readonly IBrandScopeProvider _brandProvider;
public async Task<GamePageResult> GetGamePage(
GamePageQuery query, CancellationToken ct)
{
var tenantId = _tenantProvider.GetRequired();
var brandId = _brandProvider.TryGet();
var cacheKey = $"Pin:prod:catalog:game-page:tenant:{tenantId}:brand:{brandId}:{query.Slug}:p{query.Page}";
var cached = await _cache.Get<GamePageResult>(cacheKey, ct);
if (cached is not null)
{
_logger.LogDebug("Cache hit for game page {Key}", cacheKey);
return cached with { Freshness = FreshnessView.FromCache() };
}
var result = await _inner.GetGamePage(query, ct);
await _cache.Set(cacheKey, result, TimeSpan.FromMinutes(5), ct);
return result;
}
}
Redis Provider
Инициализация Redis
// Talent.Common.Infrastructure/DependencyInjection/AddRedis.cs
public static IPinInfrastructureBuilder AddPinRedis(
this IPinInfrastructureBuilder builder,
Action<RedisOptions>? configure = null)
{
builder.Services
.AddOptions<RedisOptions>()
.BindConfiguration("Redis")
.ValidateDataAnnotations()
.ValidateOnStart();
if (configure is not null)
builder.Services.Configure(configure);
builder.Services.AddStackExchangeRedisCache(opts =>
{
opts.Configuration = builder.Services
.BuildServiceProvider()
.GetRequiredService<IOptions<RedisOptions>>().Value.ConnectionString;
});
builder.Services.AddSingleton<IDistributedCacheProvider, RedisDistributedCacheProvider>();
builder.Services.AddSingleton<IRedisLockProvider, RedisLockProvider>();
builder.Services.AddSingleton<INonceReplayProtectionStore, RedisNonceStore>();
builder.Services.AddSingleton<IShortLivedIdempotencyCache, RedisIdempotencyCache>();
return builder;
}
Redis Lock
// Talent.Common.Infrastructure/Redis/RedisLockProvider.cs
public sealed class RedisLockProvider : IRedisLockProvider
{
// Distributed lock с owner token, TTL и safe release
public async Task<IAsyncDisposable?> TryAcquireAsync(
string lockKey, TimeSpan ttl, CancellationToken ct)
{
var ownerToken = Guid.NewGuid().ToString("N");
var fullKey = $"Pin:lock:{_service}:{lockKey}";
var acquired = await _database.StringSetAsync(
fullKey, ownerToken, ttl, When.NotExists);
if (!acquired)
{
_logger.LogDebug("Lock {Key} not acquired", fullKey);
return null;
}
return new LockHandle(
_database, fullKey, ownerToken, ttl, _logger);
}
}
private sealed class LockHandle : IAsyncDisposable
{
// Safe release — проверяет owner token перед удалением
public async ValueTask DisposeAsync()
{
var script = LuaScript.Prepare(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end");
await _database.ScriptEvaluateAsync(script,
new { KEYS = new RedisKey[] { _key }, ARGV = new RedisValue[] { _token } });
}
}
Cache Invalidation
Инвалидация через domain/integration events:
// Consumer в accounting service
public sealed class AccountingBalanceChangedConsumer
: IConsumer<AccountingLedgerTransactionPostedEvent>
{
public async Task Consume(ConsumeContext<AccountingLedgerTransactionPostedEvent> ctx)
{
var accountingId = ctx.Message.Payload.AccountId;
var tenantId = ctx.Message.TenantId;
var userId = ctx.Message.Payload.UserId;
// Инвалидируем cache по tag
await _cache.RemoveByTag($"accounting:account:{accountingId}", ct);
await _cache.RemoveByTag($"accounting:user-summary:{userId}", ct);
}
}
Rate Limiting Через Redis
// Talent.Common.Infrastructure/Redis/
public sealed class RedisRateLimitCounterStore : IRateLimitCounterStore
{
public async Task<RateLimitResult> IncrementAsync(
string key, int limit, TimeSpan window, CancellationToken ct)
{
var fullKey = $"Pin:ratelimit:{_service}:{key}";
var script = LuaScript.Prepare(@"
local count = redis.call('incr', KEYS[1])
if count == 1 then
redis.call('expire', KEYS[1], ARGV[1])
end
return count");
var count = (long)await _database.ScriptEvaluateAsync(script,
new { KEYS = new RedisKey[] { fullKey }, ARGV = new RedisValue[] { (int)window.TotalSeconds } });
return new RateLimitResult
{
Count = (int)count,
Limit = limit,
IsExceeded = count > limit,
RetryAfter = count > limit ? window : null
};
}
}
Нельзя Делать С Redis
- Хранить Redis как единственный source of truth для balance, KYC, AML decisions.
- Очищать весь namespace без incident/runbook reason.
- Хранить secrets, raw provider credentials, refresh tokens.
- Использовать Redis как message broker вместо Kafka/MassTransit.
- Хранить unbounded collections без TTL и size limit.
- Кэшировать user/partner data без tenant/actor scope в key.
Ссылки
- Caching, Decorators And Resilience
- Data Access Providers
- MediatR Pipeline Behaviors
- 03 — NuGet Package Structure
Обратные ссылки
Автоссылки: где используется этот документ.
- index.md (1): Backend SDK — Руководство Разработчика
- backend-shared-standards-index.md (1): Backend Shared Standards Index