TL;DR
ASP.NET Core 10'da her request için
dbo.PageHitsINSERT atan middleware'in allocation profile'ını sıfıra yaklaştırırım:Span<char>ile path/UA parse,IMemoryCache30sn dedup, fire-and-forgetTask.RunINSERT. 50K req/dakika sentetik yük altında Gen0 GC %15→%2, P99 latency 12ms→3ms. Tam kod vedotnet-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
- Request başına maliyet: nereden gelir?
PageHitsMiddlewareskeleton: middleware vs filter- Dedup cache:
IMemoryCache30 saniye penceresi Span<char>+MemoryExtensions: parse'da sıfır alloc- Fire-and-forget INSERT:
Task.Run+IServiceScopeFactory - Benchmark:
dotnet-countersöncesi/sonrası - Sıkça Sorulan Sorular
- İlgili Yazılar
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.

Ö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:
Dapperveya rawSqlCommand— 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.

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
- Pillar 2 — K8s'te .NET Security Telemetry (cross-link back)
- C2 — AttackProbes + ThreatScore formülü derinleştirme
- C3 (gelecek) — AttackBurstCounter MERGE+OUTPUT SQL pattern
- B1 (paralel cluster) — Cloudflare WAF API CRUD (related ASP.NET Core middleware konusu)
Yorumlar (0)
Henüz yorum yapılmamış. İlk yorumu sen bırak.