PageHits Middleware: ASP.NET Core 10'da Minimal Allocation Request Sayım Pipeline

0 yorum 453 görüntülenme

Siber Güvenlik dotnet devops ASP.NET Core Performans Middleware Telemetri

8 dk okuma 1401 kelime

TL;DR

ASP.NET Core 10'da her request için dbo.PageHits INSERT atan middleware'in allocation profile'ını sıfıra yaklaştırırım: Span<char> ile path/UA parse, IMemoryCache 30sn dedup, fire-and-forget Task.Run INSERT. 50K req/dakika sentetik yük altında Gen0 GC %15→%2, P99 latency 12ms→3ms. Tam kod ve dotnet-counters ölçüm aşağıda — bilalkose.com.tr canlı sisteminden.

Yazar notu: Bu yazıdaki kod ve ölçümler bilalkose.com.tr canlı sistemden. PageHits middleware 2026-05-10 prod'a girdi; 24 saatte 246 unique hit + %33/%67 gerçek/şüpheli oranı ölçtüm, P99 middleware overhead 3ms altı. Pillar 2 H2-2'de mimari özetlenmişti; bu yazıda allocation-conscious implementation + benchmark + tuning detayı.


İçindekiler

  1. Request başına maliyet: nereden gelir?
  2. PageHitsMiddleware skeleton: middleware vs filter
  3. Dedup cache: IMemoryCache 30 saniye penceresi
  4. Span<char> + MemoryExtensions: parse'da sıfır alloc
  5. Fire-and-forget INSERT: Task.Run + IServiceScopeFactory
  6. Benchmark: dotnet-counters öncesi/sonrası
  7. Sıkça Sorulan Sorular
  8. İlgili Yazılar

TrafficClassifierMiddleware request pipeline: ShouldSkip → VisitorHash → IMemoryCache 30sn dedup → fire-and-forget Task.Run INSERT (non-blocking)
TrafficClassifierMiddleware request pipeline: ShouldSkip → VisitorHash → IMemoryCache 30sn dedup → fire-and-forget Task.Run INSERT (non-blocking)


Request Başına Maliyet: Nereden Gelir?

Her request'i DB'ye yazmak ilk bakışta basit görünür: _db.PageHits.Add(new PageHit(...)); await db.SaveChangesAsync();. Ama 50K req/dakika seviyesinde bu naive implementation Gen0 GC pressure'ı + P99 latency artışı + connection pool exhaust üretiyor.

Aday allocation kaynakları:

  • HttpContext.Request.Path.ToString() → her çağrıda yeni string allocation.
  • Request.Headers["User-Agent"].ToString() → StringValues struct'ından string conversion.
  • Guid.NewGuid().ToString("N") → 32 karakterlik yeni string her request.
  • JsonSerializer.Serialize(...) → buffer rental + final string assembly.
  • MD5.HashData(...)byte[] heap allocation.
  • db.PageHits.Add(...) + await SaveChangesAsync() → EF ChangeTracker + ExecutionStrategy + retry policy overhead.

Allocation'ların hepsi eşit önemli değil. Önemsiz olanlar: hot-path dedup cache hit'inde sadece read, allocation yok. Per-request 5-10 küçük string Gen0'da kalır, problem değil.

dotnet-counters canlı snapshot: System.Runtime → gc.collections (Gen0/1/2), allocation-rate, jit.compilation, lock_contentions — bilalkose.com.tr prod ortamından

Önemli olanlar:

  • 50K req/dak'da MD5 byte[] + Guid string = ~500KB/dakika Gen0 — sürekli GC pressure.
  • EF Core overhead: ChangeTracker + DbContext per-request scope açma + retry policy = ~2ms/request her request.
  • Fix: Dapper veya raw SqlCommand — EF'siz INSERT, %~70 daha hızlı.

Bu yazının amacı bu maliyetleri yok etmek.


PageHitsMiddleware Skeleton: Middleware vs Filter

İlk soru: PageHits logic'i nereye koymalı — middleware mi IActionFilter mı?

IActionFilter sadece controller action'larda çalışır. Static dosyalar, health check, hubs/* request'leri kaçırır. Ayrıca filter pipeline routing sonrası, yani route lookup overhead'i her request'te ödenir.

Middleware tüm pipeline'da çalışır, exclude logic'i explicit. Bilalkose.com.tr'de TrafficClassifierMiddleware ile birleşik — tek karar noktası (Pillar 2 detayda). PageHits + AttackProbes + IsSuspectNetwork flag aynı yerden okunur.

Pipeline sırası Program.cs:

app.UseForwardedHeaders();
app.UseResponseCompression();
app.UseHostFiltering();
app.UseMiddleware<TrafficClassifierMiddleware>(); // ← PageHits burada
app.UseAttackProbeBlock();
// ... static files, routing, controllers

Skeleton kod:

public sealed class TrafficClassifierMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IMemoryCache _dedupCache;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<TrafficClassifierMiddleware> _logger;

    public async Task InvokeAsync(HttpContext context)
    {
        if (ShouldSkip(context.Request.Path))
        {
            await _next(context);
            return;
        }

        var visitorHash = ComputeVisitorHash(context);
        var dedupKey = $"vh:{visitorHash}";

        if (!_dedupCache.TryGetValue(dedupKey, out _))
        {
            _dedupCache.Set(dedupKey, true, TimeSpan.FromSeconds(30));
            EnqueueRecord(context, visitorHash); // fire-and-forget
        }

        await _next(context);
    }

    private static bool ShouldSkip(PathString path)
    {
        var span = path.HasValue ? path.Value.AsSpan() : ReadOnlySpan<char>.Empty;
        return span.StartsWith("/health", StringComparison.OrdinalIgnoreCase)
            || span.StartsWith("/metrics", StringComparison.OrdinalIgnoreCase)
            || span.StartsWith("/dev/", StringComparison.OrdinalIgnoreCase)
            || span.StartsWith("/css/", StringComparison.OrdinalIgnoreCase)
            || span.StartsWith("/js/", StringComparison.OrdinalIgnoreCase)
            || span.StartsWith("/uploads/", StringComparison.OrdinalIgnoreCase);
    }
}

Skip path'leri: /health/*, /metrics, /dev/*, static asset path'leri (/css/*, /js/*, /images/*, /lib/*, /uploads/*), bot pattern path'leri (TrafficClassifier'da ayrı handle).


Dedup Cache: IMemoryCache 30 Saniye Penceresi

Neden 30 saniye? Pragmatik gerekçe:

  • Aynı kullanıcı bir sayfada 30 saniyede 5-10 click yapar (scroll-trigger, navigation, modal). Bunları tek hit sayarız.
  • 60 saniye çok uzun → bot tek session içinde 2. count'tan kaçar.
  • 10 saniye çok kısa → SPA navigation tek visit yerine 3-4 visit olur.

VisitorHash composition kullanıcı tracking değil, daily-rolling salt'la anonymize:

private string ComputeVisitorHash(HttpContext ctx)
{
    var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0";
    var ua = ctx.Request.Headers.UserAgent.ToString();
    var dailySalt = DateTime.UtcNow.ToString("yyyyMMdd");
    var secret = _vaultSecret; // VISITOR_HASH_SECRET

    var raw = $"{ip}|{ua}|{dailySalt}|{secret}";
    var bytes = MD5.HashData(Encoding.UTF8.GetBytes(raw));
    return Convert.ToHexString(bytes); // 32 char
}

KVKK m.5/2-(f) "meşru menfaat" gereği IP hash'lendiği için ham IP DB'de tutulmuyor. Günlük salt rotation → uzun-vadeli kullanıcı tracking imkansız (Pillar 2 H2-11'de detaylı).

Cache eviction sliding 30 saniye — her hit'te yenilenir, memory limit yok. IMemoryCache LRU eviction memory pressure'da otomatik. Pod restart'ında cache temizlenir, geçici hit-spike olur (kabul edilebilir, telemetry tutarlı görüntü değil).


Span<char> + MemoryExtensions: Parse'da Sıfır Alloc

İki kritik optimizasyon path parse + hash composition'da.

Path parse — string vs Span:

// ALLOC: Path.Value → string allocation her request
var path = context.Request.Path.Value ?? string.Empty;
bool isAdmin = path.StartsWith("/admin/", StringComparison.OrdinalIgnoreCase);

// ALLOC-FREE: Path string'i zaten var, AsSpan() yeni alloc atmaz
var pathSpan = context.Request.Path.HasValue
    ? context.Request.Path.Value.AsSpan()
    : ReadOnlySpan<char>.Empty;
bool isAdmin = pathSpan.StartsWith("/admin/", StringComparison.OrdinalIgnoreCase);

PathString immutable struct'ı interned string tutuyor — Span çıkarmak stack-only, sıfır alloc.

MD5 hash composition — stackalloc vs byte[]:

// ALLOC: Encoding.GetBytes + new byte[16]
var bytes = Encoding.UTF8.GetBytes(raw);          // ~30-60 byte heap
var hash = MD5.HashData(bytes);                   // 16 byte heap

// ALLOC-FREE: stackalloc + UTF8 encode
Span<byte> buffer = stackalloc byte[256];         // stack
int written = Encoding.UTF8.GetBytes(raw, buffer);
Span<byte> hash = stackalloc byte[16];            // stack
MD5.HashData(buffer[..written], hash);
return Convert.ToHexString(hash);

256 byte buffer raw string'in (IP + UA + salt + secret) max'ı için yeterli. Encoding.UTF8.GetBytes(string, Span<byte>) overload zaten alloc-free.

Convert.ToHexString SIMD optimized. Manual StringBuilder + b.ToString("x2") loop yaklaşık 3-4x yavaş + char allocation heavy. .NET 5+ tek satır helper kullan.

BenchmarkDotNet ile ölçüm: naive (string + byte[]) ~340ns / 280B alloc, optimized (Span + stackalloc) ~95ns / 32B alloc (sadece final hex string). Per-request fark mikro, scale'de büyük: 50K req/dak'da -5MB Gen0 / dakika.


Fire-and-Forget INSERT: Task.Run + IServiceScopeFactory

Neden DB INSERT'i await etmiyoruz?

  • Blocking yapılsa middleware latency'si DB'ye bağımlı olur. Connection pool exhaust olduğunda request flow'u durur.
  • DB down olsa bile site servis etmeye devam etmeli (resilience).
  • Trade-off: rare cases'de hit kaybı (DB transient timeout, %~0.1-0.5) — acceptable çünkü PageHits unique visitor metrik odaklı, audit-grade değil.

Implementation pattern:

private void EnqueueRecord(HttpContext ctx, string visitorHash)
{
    // HttpContext fire-and-forget Task'a CAPTURE EDİLEMEZ — request bitince invalidate
    // Sadece primitive value'ları snapshot al
    var path = ctx.Request.Path.Value ?? "";
    var method = ctx.Request.Method;
    var ua = ctx.Request.Headers.UserAgent.ToString();
    var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0";

    var hostEnv = ctx.RequestServices.GetRequiredService<IHostApplicationLifetime>();
    var cancellation = hostEnv.ApplicationStopping;

    _ = Task.Run(async () =>
    {
        try
        {
            using var scope = _scopeFactory.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            db.PageHits.Add(new PageHit
            {
                VisitorHash = visitorHash,
                Path = path,
                Method = method,
                UserAgent = ua,
                // ... GeoIP enrichment (Pillar 2 H2-6'da)
                CreatedDate = DateTime.UtcNow
            });
            await db.SaveChangesAsync(cancellation);
        }
        catch (OperationCanceledException)
        {
            // App shutdown — ignore
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "PageHits INSERT failed for {Path}", path);
        }
    }, cancellation);
}

Gotcha 1 — HttpContext capture yasak. Request bitince context invalidate olur, fire-and-forget Task'ta NullReferenceException üretir. Sadece primitive snapshot kopyala.

Gotcha 2 — IServiceScopeFactory singleton. Her record için yeni scope açılır (DbContext scoped olduğu için). Singleton DbContext anti-pattern; race condition + ChangeTracker conflict.

Graceful shutdown. IHostApplicationLifetime.ApplicationStopping token'ı pas et. Pod terminationGracePeriodSeconds 30sn pencerede pending INSERT'ler gracefully iptal — OperationCanceledException swallow.


Benchmark: dotnet-counters Öncesi/Sonrası

Production'da ölçüm aracı tercih sebebim:

dotnet-counters monitor -p $(pgrep dotnet) \
    System.Runtime[gen-0-gc-count,gen-1-gc-count,gen-2-gc-count,allocation-rate]

Sentetik 50K req/dakika yük altında (k6 load test, mini app container, 2 CPU + 1GB RAM):

Metrik Naive Optimized Δ
Gen0 GC/sn 8.2 1.1 -%87
Allocation rate (MB/s) 14.3 2.4 -%83
P50 middleware latency 4ms 1ms -%75
P99 middleware latency 12ms 3ms -%75
Request throughput 825 req/s 880 req/s +%7

Profiling sürprizi: en büyük kazanım nerede?

  • MD5 stackalloc: -%35 allocation rate
  • Path Span parse: -%20 allocation rate
  • Dedup cache hit'inde tüm allocation bypass: kalan -%30

Bilalkose.com.tr canlısı 50K req/dak seviyesinde değil (gerçek trafik 50-150 req/dakika), ama optimize edilmiş middleware overhead canlıda 0.3-0.8ms P99 — telemetry CPU bütçesi neredeyse görünmez.

BenchmarkDotNet sonuç tablosu — naive vs optimized: Gen0 GC -%87, allocation rate -%83, P99 latency 12ms → 3ms, throughput +%7


Sıkça Sorulan Sorular

IMemoryCache vs IDistributedCache — hangisini kullanmalı?

Pod-local için MemoryCache. Multi-pod tutarlılık gerekirse Redis (IDistributedCache). Dedup için MemoryCache yeterli — pod-A'da görülen visitor 30sn pencerede pod-B'ye düşse 1 ekstra INSERT olur, %~5 false-positive acceptable.

Fire-and-forget INSERT'de hit kaybı oluyor mu?

Evet, %~0.1-0.5 oranında (DB connection pool exhaust, transient timeout). PageHits unique-visitor metrik olduğu için kaybı tolere edilir. Audit-grade gerekirse IBackgroundTaskQueue ile retry queue ekle (Channel-based, in-memory bounded).

Span<char> neden bu kadar önemli?

string immutable + Gen0/Gen1 heap'e düşer. 50K req/dak'da yüzlerce küçük string allocation = sürekli GC pressure → P99 latency artışı. Span stack-only, sıfır heap alloc.

MD5 deprecated diye duydum, neden hâlâ kullanıyoruz?

PageHits visitor hash anonymization amaçlı, cryptographic integrity değil. MD5 hızlı + 16 byte sabit output VisitorHash uniqueness için yeterli. SHA256 yavaş + 32 byte alloc, bu use case için gereksiz.


İlgili Yazılar

Yorumlar (0)

Yorum ve puan bırakın

Henüz yorum yapılmamış. İlk yorumu sen bırak.