Mert Özen Her Satırda Daha İleri
Test ve Kalite Performance ve Load Testing — #02

Workload Modeling ve Test Verisi: Gerçekçi Trafik Karışımı, Think Time, Cache Durumu ve Bağımlılıkları Taklit Etmek

Mert Özen 14 Kas 2026 16 dk 13 görüntülenme
Workload Modeling ve Test Verisi: Gerçekçi Trafik Karışımı, Think Time, Cache Durumu ve Bağımlılıkları Taklit Etmek

Gerçekçi bir load test, trafik karışımını access log'dan ölçer, oturum yüzdesini istek yüzdesinden ayırır, rastgele think time kullanır, production boyutunda ve dağılımında veriyle, bilinçli seçilmiş cache durumuyla ve stub'lanmış bağımlılıklarla koşar. Bookstore'da hep aynı kitabı isteyen testin veritabanına bir dakikada yalnız 41 sorgu gönderdiğini, gecikmesiz ödeme stub'ının siparişi yirmi kat hızlı gösterdiğini gerçek koşularla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; yük testleri k6 2.4.0 ile, Bookstore.Api ve SQL Server 2025 Docker Compose üzerinde, ödeme sağlayıcısı stub'ı WireMock.Net 2.31 ile çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) kitap detayı endpoint'ine bir dakikalık output cache eklendi. Ekip aynı endpoint'i saniyede 600 istekle, üç ayrı şekilde test ediyor: her istekte hep aynı kitap (Dune), 40.000 kitabın her birine eşit olasılık, ya da birkaç popüler kitaba yığılan gerçekçi bir dağılım. Sence üç testte SQL Server'a giden sorgu sayısı nasıl farklılaşır? Hangisi veritabanını gerçekten test eder?

İkinci soru: senaryoda oturumların yüzde 10'u checkout ise, API'nin access log'unda checkout endpoint'lerinin payı yüzde kaç çıkar? 01'deki testler tek endpoint'e ve 20 kitaplık bir kataloğa gidiyordu. Bu yazıda workload model'i gerçeğe yaklaştırıyoruz: trafik karışımı, oturum akışı ve think time, veri boyutu ve dağılımı, cache durumu ve dışarıdaki bağımlılıklar.

Trafik Karışımını Access Log'dan Çıkarmak

Workload model'in ilk girdisi production'daki trafiğin dağılımıdır: hangi endpoint'e ne oranda istek geliyor, kullanıcı hangi sırayla geziyor. Bu bilgi tahminle değil ölçümle alınır. Kaynak, reverse proxy'nin ya da uygulamanın access log'u, ya da endpoint bazında request metric'leri olabilir. Bookstore.Api'ye ASP.NET Core'un W3C (World Wide Web Consortium; web standartlarını yayımlayan kuruluş) formatındaki access log'unu ekledim. Configuration ile açılıp kapanıyor, çünkü her isteği diske yazmak ücretsiz değil:

// İsteğe bağlı W3C access log (istek başına bir satır); load test'in trafik karışımını çıkarmak için.
bool accessLogEnabled = builder.Configuration.GetValue<bool>("AccessLog:Enabled");
if (accessLogEnabled)
{
    builder.Services.AddW3CLogging(options =>
    {
        options.LoggingFields = W3CLoggingFields.Date | W3CLoggingFields.Time | W3CLoggingFields.Method
            | W3CLoggingFields.UriStem | W3CLoggingFields.UriQuery | W3CLoggingFields.ProtocolStatus
            | W3CLoggingFields.TimeTaken;
        options.LogDirectory = builder.Configuration["AccessLog:Directory"] ?? "logs";
        options.FlushInterval = TimeSpan.FromSeconds(2);
    });
}
#Fields: date time cs-method cs-uri-stem cs-uri-query sc-status time-taken
2026-11-14 12:21:56 POST /api/carts - 201 70.0251
2026-11-14 12:21:56 PUT /api/carts/01a1da01-d7ce-7741-b549-909d2599fbdb/items/9780441172719 - 200 155.3652
2026-11-14 12:21:56 POST /api/orders - 201 262.6972

Ham log'dan oran çıkarmak için yolları endpoint şablonuna indirmek gerekir: /api/books/9780441172719 ile /api/books/9790000000018 aynı endpoint. Bunun için .NET 10'un file-based app özelliğiyle tek dosyalık bir araç yazdım; proje dosyası yok, dotnet run ile çalışıyor:

#:property PublishAot=false
// W3C access log'larını okur; endpoint bazında istek dağılımını ve en çok bakılan kitapların payını yazar.
// Kullanım: dotnet run load/tools/traffic-mix.cs -- <log klasörü>
using System.Globalization;
using System.Text.RegularExpressions;

CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;

string folder = args.Length > 0 ? args[0] : "logs";
Dictionary<string, int> endpoints = [];
Dictionary<string, int> bookViews = [];
int total = 0;
string[] fields = [];

foreach (string file in Directory.GetFiles(folder, "*.txt").Order())
{
    foreach (string line in File.ReadLines(file))
    {
        if (line.StartsWith("#Fields:"))
        {
            fields = line["#Fields:".Length..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
            continue;
        }
        if (line.StartsWith('#') || line.Length == 0)
        {
            continue;
        }
        string[] values = line.Split(' ');
        string method = values[Array.IndexOf(fields, "cs-method")];
        string path = values[Array.IndexOf(fields, "cs-uri-stem")];
        string query = values[Array.IndexOf(fields, "cs-uri-query")];

        string endpoint = method + " " + Normalize(path);
        if (endpoint == "GET /api/books" && query.Contains("search="))
        {
            endpoint = "GET /api/books?search=";
        }
        endpoints[endpoint] = endpoints.GetValueOrDefault(endpoint) + 1;
        if (endpoint == "GET /api/books/{isbn}")
        {
            bookViews[path] = bookViews.GetValueOrDefault(path) + 1;
        }
        total++;
    }
}
// ... yüzdeleri ve en çok bakılan 10/100/1000 kitabın payını yazdırır

// /api/books/9780441172719 → /api/books/{isbn}, /api/carts/0196.../items/... → /api/carts/{id}/items/{isbn}
static string Normalize(string path)
{
    path = Regex.Replace(path, "[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}", "{id}");
    path = Regex.Replace(path, @"/\d{13}(?=/|$)", "/{isbn}");
    path = Regex.Replace(path, @"/orders/\d+$", "/orders/{id}");
    return path.TrimEnd('/');
}

Production'da bu aracı bir haftalık log üzerinde koşarsın ve karışımı oradan alırsın. Bookstore'un production'ı yok, bu yüzden yöntemi ters yönden doğruladım: aşağıda kuracağımız k6 senaryosunu üç dakika koşturup ürettiği log'u okuttum. Senaryo oturumların yüzde 70'ini gezinme, yüzde 20'sini arama, yüzde 10'unu checkout olarak tanımlıyor:

dotnet run load/tools/traffic-mix.cs -- ./accesslog
10047 requests
  58.4%  GET /api/books/{isbn}
  23.9%  GET /api/books
   7.1%  GET /api/books?search=
   3.5%  POST /api/carts
   3.5%  PUT /api/carts/{id}/items/{isbn}
   3.5%  POST /api/orders
4498 distinct books viewed
top   10 books: 6.4% of book views
top  100 books: 14.9% of book views
top 1000 books: 40.4% of book views

İkinci tahmin sorusunun cevabı burada: yüzde 10 checkout oturumu, isteklerin yüzde 10,5'i, ama her bir checkout endpoint'i yalnız yüzde 3,5. Yüzde 20 arama oturumu ise isteklerin sadece yüzde 7,1'i. Oturum yüzdesi ile istek yüzdesi farklı şeylerdir, çünkü her akış farklı sayıda istek atar: gezinme 3, arama 2, checkout 4. Log'dan gelen sayılar istek yüzdesi, senaryoya yazdığın sayılar oturum yüzdesi. Birini ötekine çevirmeden "log'daki gibi yüzde 3,5 sipariş" diye senaryo kurarsan checkout'u üç kat eksik test edersin.

Oturum Akışı ve Think Time

Gerçek kullanıcı endpoint'leri rastgele çağırmaz; bir akışı izler: kategori listesi, bir kitap, bir kitap daha. Akışın sırası da önemlidir, çünkü sepet oluşturulmadan sipariş verilemez ve her adımın cache'e, veritabanına etkisi bir öncekine bağlıdır. Bookstore'un workload model'i open model'de çalışıyor: saniyede belirli sayıda oturum gelir, her oturum bir akış seçer ve adımlar arasında düşünür:

export const options = {
    scenarios: {
        sessions: {
            executor: 'constant-arrival-rate',
            rate: Number(__ENV.RATE || 20),
            timeUnit: '1s',
            duration: __ENV.DURATION || '5m',
            // Little's Law: saniyede 20 oturum x oturum başına ~8,5 sn = ~170 eşzamanlı oturum.
            preAllocatedVUs: 200,
            maxVUs: 1000,
        },
    },
};

function think(min, max) {
    sleep(THINK * (min + Math.random() * (max - min)));
}

function browse() {
    const category = categories[Math.floor(Math.random() * categories.length)];
    const page = 1 + Math.floor(Math.random() * 5);
    const list = http.get(`${BASE_URL}/api/books?category=${category}&page=${page}`, { tags: { name: 'GET /api/books' } });
    check(list, { 'list 200': (r) => r.status === 200 });
    think(2, 5);
    viewBook(pickIsbn());
    think(3, 8);
    viewBook(pickIsbn());
}

export default function () {
    const r = Math.random();
    if (r < 0.7) browse();
    else if (r < 0.9) search();
    else checkout();
}

Think time'ın aralıkları da log'dan gelir: aynı oturumdaki iki isteğin arasındaki sürenin dağılımı. Think time'ı atlamak yaygın bir hata: aynı oturum sayısıyla çok daha fazla istek üretirsin, ama sistemin gerçek yükte aynı anda tuttuğu oturum, bağlantı ve sepet sayısını hiç görmezsin. Burada da Little's Law işe yarıyor. k6'nın ölçtüğü ortalama oturum süresi 8,43 saniye; saniyede 20 oturumla aynı anda yaklaşık 169 oturum açık olmalı. Ölçülen en yüksek değer 179 VU (virtual user; k6'nın sanal kullanıcısı).

İlk denemede preAllocatedVUs 50'ydi ve k6 133 oturumu başlatamadı (dropped_iterations). Sistem yavaş değildi; k6 yük sırasında yeni VU açmaya yetişemedi. Little's Law ile hesaplanan eşzamanlılığa göre baştan 200 VU ayırınca bu kayıp ortadan kalkıyor. Öbür tarafta da bir tuzak var: think time'ı sabit verme. 01'deki closed model testini önce herkes tam bir saniye düşünecek şekilde yazmıştım. 500 VU aynı anda başlayıp aynı anda uyandığı için istekler saniye başlarında yığıldı ve p95 59 ms çıktı. Aynı test 0,5 ile 1,5 saniye arası rastgele beklemeyle 2,97 ms verdi. Ölçtüğün şey sistemin değil, test script'inin senkronize ettiği kullanıcıların sonucuydu.

Test Verisi: Boyut ve Dağılım

20 kitaplık bir katalog, 40.000 kitaplık bir katalogla aynı sorgu planını, aynı index kullanımını ve aynı bellek davranışını göstermez. Bookstore'un tahmini katalog büyüklüğü 40.000 kitap; test veritabanını bu boyuta getiren T-SQL script'i set tabanlı çalışıyor ve geçerli, 13 haneli ISBN'ler (International Standard Book Number; kitabın uluslararası kimlik numarası) üretiyor. Her ISBN, '979' önekinden, 9 haneli bir sıra numarasından ve kontrol hanesinden oluşuyor:

WITH numbers AS (
    SELECT TOP (40000 - (SELECT COUNT(*) FROM catalog.Books))
        ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
    FROM sys.all_objects a CROSS JOIN sys.all_objects b
),
isbn12 AS (
    SELECT n, '979' + RIGHT('000000000' + CAST(n AS varchar(9)), 9) AS digits FROM numbers
)
INSERT catalog.Books (Isbn, Title, Description, Price, PublishedYear, AuthorId, CategoryId)
SELECT
    i.digits + CAST((10 - (
        CAST(SUBSTRING(i.digits, 1, 1) AS int) + 3 * CAST(SUBSTRING(i.digits, 2, 1) AS int)
        + CAST(SUBSTRING(i.digits, 3, 1) AS int) + 3 * CAST(SUBSTRING(i.digits, 4, 1) AS int)
        + CAST(SUBSTRING(i.digits, 5, 1) AS int) + 3 * CAST(SUBSTRING(i.digits, 6, 1) AS int)
        + CAST(SUBSTRING(i.digits, 7, 1) AS int) + 3 * CAST(SUBSTRING(i.digits, 8, 1) AS int)
        + CAST(SUBSTRING(i.digits, 9, 1) AS int) + 3 * CAST(SUBSTRING(i.digits, 10, 1) AS int)
        + CAST(SUBSTRING(i.digits, 11, 1) AS int) + 3 * CAST(SUBSTRING(i.digits, 12, 1) AS int)) % 10) % 10 AS varchar(1)),
    a.v + N' ' + o.v + CASE WHEN i.n > 1200 THEN N' ' + CAST(i.n / 1200 + 1 AS nvarchar(10)) ELSE N'' END,
    N'Generated book for load tests.',
    CAST(99 + (i.n * 37) % 900 AS decimal(10, 2)) + 0.90,
    1950 + i.n % 76,
    @firstAuthorId + i.n % 4000,
    1 + i.n % 4
FROM isbn12 i
JOIN @adj a ON a.n = i.n % 30
JOIN @noun o ON o.n = (i.n / 30) % 40;
# PowerShell 7
docker compose cp load/data/seed-catalog.sql sql:/tmp/seed-catalog.sql
docker compose exec -T sql /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P $env:SA_PASSWORD -d BookstoreDb -b -i /tmp/seed-catalog.sql
docker compose exec -T sql /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P $env:SA_PASSWORD -d BookstoreDb -h -1 -W `
    -Q "SET NOCOUNT ON; SELECT Isbn FROM catalog.Books ORDER BY Id" > load/data/isbns.txt

Son komut ISBN'leri bir dosyaya yazıyor. k6 tarafında bu dosya SharedArray ile bir kez okunur ve bütün VU'lar aynı kopyayı paylaşır; her VU'nun kendi kopyasını tutması 200 VU'da gereksiz bellek demek. SQL Server'ın şifresi komut satırında görünüyor; bunu yalnız yerel test container'ında yap.

Boyut kadar dağılım da önemli. Bir kitapçıda her kitaba eşit bakılmaz: birkaç yüz popüler kitap trafiğin büyük kısmını alır, kalan on binlerce kitap seyrek açılır. Senaryoda bunu basit bir eğriyle taklit ettim. Rastgele sayının küpünü almak küçük index'lere, yani listenin başındaki kitaplara ağırlık verir. Log'daki ölçüm, en çok bakılan 1.000 kitabın, yani kataloğun yüzde 2,5'inin, görüntülemelerin yüzde 40'ını aldığını gösteriyor. Production'da bu eğriyi tahmin etme, log'dan ölç; yukarıdaki araç "top N" satırlarını bunun için yazıyor.

Bir şey daha: test verisi tükenebilir. Seed verisindeki 20 klasik kitabın her birinde 25 adet stok vardı, Don Quixote'ta hiç yoktu. Popüler kitaplar listenin başında olduğu için checkout'lar bu kitaplara yığıldı ve üç dakikalık koşuda bir sipariş "Not enough stock" ile 409 döndü. Bir saatlik soak testte Dune'un stoğu ilk dakikalarda biter ve hata oranı yükselir. Bu, sistemin değil verinin sorunu. Script bu yüzden yük testi veritabanındaki bütün stokları bir milyona tamamlıyor. Aynı şey test kullanıcıları, kupon kodları ve token'lar için de geçerli: tek kullanımlık bir kaynağı paylaşan VU'lar ya birbirini bozar ya da sahte hata üretir.

Cache Durumu: Sıcak, Soğuk ve Sahte Hit

Kitap detayına bir dakikalık output cache ekledim. Kitap bilgisi nadiren değişiyor, sayfadaki stok bilgisi en fazla bir dakika eski kalabilir. Bu ödünleşimin ve hit oranının nasıl ölçüleceğinin ayrıntısı Caching serisinin "Cache'i Ölçmek ve Test Etmek: Hit Ratio, Metric'ler, Bellek Kullanımı ve Tutarlılık Testleri" yazısında.

// Kitap detayı nadiren değişir: bir dakika output cache (stok bilgisi en fazla bir dakika eski olabilir).
catalog.MapGet("/books/{isbn}", GetBook).CacheOutput(policy => policy.Expire(TimeSpan.FromMinutes(1)));

Sonra tahmin sorusundaki üç testi koşturdum: saniyede 600 istek, 60 saniye, yalnız kitabın nasıl seçildiği farklı. Veritabanının gördüğü yükü SQL Server'ın Batch Requests/sec sayacının test öncesi ve sonrası farkıyla ölçtüm. Cache'te olmayan her istek iki sorgu atıyor: kitap ve stok.

k6 run -e POPULARITY=single -e RATE=600 book-details.js
k6 run -e POPULARITY=uniform -e RATE=600 book-details.js
k6 run -e POPULARITY=zipf -e RATE=600 book-details.js
Kitap seçimiİstekSQL batchYaklaşık hit oranıp50p95
Hep aynı kitap35.97241yüzde 1000,40 ms0,78 ms
Eşit dağılım (40.000 kitap)36.00140.165yüzde 441,01 ms1,82 ms
Popüler kitaplara yığılan36.00136.184yüzde 500,87 ms2,06 ms

Hep aynı kitabı isteyen test bir dakikada veritabanına 41 sorgu gönderdi; öteki ikisi 36-40 bin. İlk test API'yi değil cache'i test etti. "p95 0,8 ms" sonucu da kampanya akşamında veritabanının göreceği yük hakkında hiçbir şey söylemiyor. Süreler bu makinede birbirine yakın, çünkü 40.000 satır SQL Server'ın belleğine sığıyor ve sorgular index üzerinden tek satır okuyor. Veritabanı başka trafikle meşgulken ya da veri belleğe sığmadığında 41 ile 36.000 sorgu arasındaki fark doğrudan süreye yansır. Eşit dağılım da gerçekçi değil: popüler kitapların cache'te kalmasını hiç sağlamadığı için hit oranını olduğundan düşük gösterir.

Cache'in başlangıç durumu da sonucu değiştirir. API container'ını yeniden başlatıp aynı testi 30 saniye koşturdum, hemen ardından bir 30 saniye daha:

== cold
    http_req_duration..............: avg=2.43ms min=146µs    med=1.08ms max=798.96ms p(90)=2.17ms p(95)=2.89ms
== warm
    http_req_duration..............: avg=829.95µs min=152µs med=339µs   max=59.83ms p(90)=1.54ms p(95)=1.93ms

Soğuk koşuda ortanca üç kat yüksek ve en yavaş istek 800 ms. Bu ilk saniyelerde JIT (just-in-time; kodun ilk çağrıda makine koduna çevrilmesi) derlemesi, connection pool'un dolması ve boş cache birlikte etkili. Hangisini ölçtüğüne bilerek karar ver. Deploy sonrası ilk dakikayı merak ediyorsan soğuk başla ve bu dakikayı ayrı raporla. Kararlı durumu ölçüyorsan bir warm-up aşaması koy ve sonuçlardan çıkar. 03'te bunu ayrı bir scenario ya da ramp-up ile yapıyoruz.

Bağımlılıkları Taklit Etmek: WireMock.Net ile Ödeme Stub'ı

Bookstore'un checkout'u siparişi kaydettikten sonra ödeme sağlayıcısına bir charge isteği gönderiyor. Load test'te bu isteği gerçek sağlayıcıya, hatta onun sandbox'ına göndermek yanlış. Sağlayıcının sistemine izinsiz yük göndermiş olursun, sandbox'ların çoğunda istek limiti vardır ve ölçtüğün süreye senin kontrol etmediğin bir sistemin süresi karışır. Çözüm bağımlılığı bir stub ile değiştirmek. Stub'ın gerçek sağlayıcının API sözleşmesine uyması gerekir, gecikmesinin de sağlayıcının production'da ölçülen süresine. Önce API tarafı: ödeme bir HttpClient üzerinden yapılıyor ve adres configuration'dan geliyor. Adres verilmezse ödeme hiç yapılmıyor ve sipariş "Placed" kalıyor; mevcut integration testleri bu yüzden değişmedi. Ödeme sonucunun sipariş durumuna yansıdığını sahte bir IPaymentGateway ile iki yeni testte doğruladım.

// Ödeme sağlayıcısını çağırır (load test'te gerçek sağlayıcı değil, WireMock.Net stub'ı).
internal sealed class HttpPaymentGateway(HttpClient http, ILogger<HttpPaymentGateway> logger) : IPaymentGateway
{
    public async Task<ChargeOutcome> ChargeAsync(int orderId, decimal amount, string currency, CancellationToken ct)
    {
        try
        {
            using HttpResponseMessage response = await http.PostAsJsonAsync(
                "v1/charges", new ChargeRequest($"order-{orderId}", amount, currency), ct);
            return response.IsSuccessStatusCode ? ChargeOutcome.Succeeded : ChargeOutcome.Failed;
        }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
        {
            logger.LogWarning(ex, "Payment for order {OrderId} failed", orderId);
            return ChargeOutcome.Failed;
        }
    }
}

public static class PaymentsModule
{
    public static IServiceCollection AddPaymentsModule(this IServiceCollection services, IConfiguration configuration)
    {
        if (configuration["Payments:BaseUrl"] is { Length: > 0 } baseUrl)
        {
            services.AddHttpClient<IPaymentGateway, HttpPaymentGateway>(client =>
            {
                client.BaseAddress = new Uri(baseUrl);
                client.Timeout = TimeSpan.FromSeconds(5);
            });
        }
        else
        {
            services.AddSingleton<IPaymentGateway, NoPaymentGateway>();
        }
        return services;
    }
}
        await transaction.CommitAsync(ct);

        // Ödeme commit'ten sonra: dışarıya yapılan çağrı stok satırlarını kilitli tutmaz.
        ChargeOutcome charge = await payments.ChargeAsync(order.Id, order.Total, order.Currency, ct);
        if (charge != ChargeOutcome.Skipped)
        {
            order.Status = charge == ChargeOutcome.Succeeded ? OrderStatus.Paid : OrderStatus.PaymentPending;
            await db.SaveChangesAsync(ct);
        }

Charge isteği transaction commit edildikten sonra gidiyor. Dışarıya yapılan bir çağrının sürdüğü süre boyunca stok satırlarının kilitli kalması, yük altında bütün checkout'ları o çağrının hızına bağlardı. Stub, WireMock.Net ile yazılmış küçük bir console uygulaması:

using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
using WireMock.Settings;

// Load test sırasında ödeme sağlayıcısının yerine geçer: gerçek sağlayıcıya ya da sandbox'ına yük gönderilmez.
// Kullanım: dotnet run -c Release -- [port] [minDelayMs] [maxDelayMs]
int port = args.Length > 0 ? int.Parse(args[0]) : 5099;
int minDelay = args.Length > 1 ? int.Parse(args[1]) : 0;
int maxDelay = args.Length > 2 ? int.Parse(args[2]) : minDelay;

using WireMockServer server = WireMockServer.Start(new WireMockServerSettings { Port = port });

IResponseBuilder response = Response.Create()
    .WithStatusCode(201)
    .WithHeader("Content-Type", "application/json")
    .WithBody("""{ "id": "ch_test", "status": "succeeded" }""");
if (maxDelay > 0)
{
    // Gerçek sağlayıcı hiçbir zaman sabit hızda değildir: her cevap [minDelay, maxDelay] aralığında rastgele bekler.
    response = response.WithRandomDelay(minDelay, maxDelay);
}

server.Given(Request.Create().WithPath("/v1/charges").UsingPost()).RespondWith(response);

Console.WriteLine($"Payment stub on http://localhost:{port}/v1/charges, delay {minDelay}-{maxDelay} ms. Ctrl+C to stop.");
using CancellationTokenSource stop = new();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Cancel(); };
try
{
    await Task.Delay(Timeout.Infinite, stop.Token);
}
catch (OperationCanceledException)
{
}
Console.WriteLine($"Charges received: {server.LogEntries.Count()}");
# 1. terminal: ödeme stub'ı, 100-300 ms gecikme
dotnet run -c Release --project tools/Bookstore.PaymentStub -- 5099 100 300

# 2. terminal (PowerShell): API stub'ı kullanacak şekilde yeniden oluşturulur
$env:PAYMENTS_BASE_URL = "http://host.docker.internal:5099"
docker compose up -d api
k6 run -e RATE=20 -e DURATION=2m load/k6/bookstore-mix.js

Aynı karışımı iki dakika boyunca önce gecikmesiz, sonra 100-300 ms gecikmeli stub'la koşturdum. k6'nın CSV (virgülle ayrılmış düz metin) çıktısından sipariş endpoint'inin sürelerini ayırdım:

Stub gecikmesiPOST /api/orders p50POST /api/orders p95Tüm isteklerin p95'i
0 ms9,6 ms41,9 ms99 ms
100-300 ms219 ms300,7 ms154 ms

Gecikmesiz stub, sipariş endpoint'inin ortancasını yirmi kattan fazla hızlı gösteriyor. Bu sayıyla kurulan bir SLO (Service Level Objective; servisin hedeflenen güvenilirlik seviyesi), ilk gerçek ödeme gecikmesinde aşılır. Gecikmenin etkisi süreyle de bitmiyor. Her sipariş isteği sağlayıcıyı beklerken sunucuda bir istek, bir HttpClient bağlantısı ve bellek tutuyor. Little's Law ile: saniyede 200 siparişte 0,22 saniyelik bekleyiş, aynı anda 44 açık ödeme çağrısı demek. Stub'a bu yüzden sabit değil, aralıklı gecikme verdim. Sağlayıcının gerçek p50 ve p95'ini kendi metric'lerinden alıp aralığı ona göre ayarlamak en doğrusu. Aynı mantık sağlayıcının yavaşladığı ya da hata döndüğü durumlar için de geçerli: stub'a 5 saniyelik gecikme ya da yüzde 5 hata ekleyip timeout ve retry davranışını yük altında görebilirsin. Bu davranışların kendisi System Design serisinin "Reliability Pattern'leri: Timeout, Retry, Backoff + Jitter, Circuit Breaker ve Load Shedding" yazısında.

CDN (Content Delivery Network; içeriği kullanıcıya yakın sunuculardan dağıtan ağ) için kural ters yönde işler: load test'i CDN'in önünden değil, doğrudan origin'e yap. CDN'in önünden yapılan test, CDN sağlayıcısının sistemini ölçer ve ona izinsiz yük göndermiş olursun. Senin kapasite sorun ise CDN'in kaçırdığı isteklerin origin'e ulaştığı an. Origin testinde CDN'in hit oranını hesaba katmak için cache'lenebilir isteklerin bir kısmını senaryodan çıkar, yani yalnız CDN'den geçip origin'e ulaşan trafiği modelle.

Üretimde Dikkat

Yük üreten makine darboğaz olmasın. k6 tek bir Go process'i olarak çok yük üretebilir, ama CPU'su dolarsa istekleri geç gönderir ve bu gecikme sonuçlarda sistemin gecikmesi gibi görünür. Bu yazıdaki testlerde k6 ile API aynı makinedeydi; mutlak sayılar bu yüzden de makineye özel. Gerçek bir test ortamında yük üreticisini ayrı bir makineye koy ve CPU'sunu test boyunca izle.

Aynı ağda test etmek ağ gecikmesini sıfıra indirir. Bookstore'un kullanıcıları API'ye internet üzerinden ve TLS (Transport Layer Security; ağ trafiğini şifreleyen protokol) ile gelir; aynı makineden atılan istek bunları ölçmez. Bu bilinçli bir tercih olabilir, çünkü sunucunun kapasitesini ölçüyorsun, ama raporda bunu yaz ve kullanıcı tarafı sürelerini sunucu süresiyle karıştırma. Ağ katmanındaki gecikmenin nasıl ayrıştırılacağı Networking ve HTTP Nasıl Çalışır serisinin "Yavaş İstek Nereden Geliyor" yazısında.

Sistemde bir rate limiter varsa, load test sırasında tetiklenip tetiklenmediğini kontrol et. Tüm sanal kullanıcılar tek bir IP'den geldiği için IP bazlı bir limit gerçek trafikte hiç devreye girmeyecekken testte ilk saniyede devreye girer. Sonuç ya bol 429 (Too Many Requests) olur ya da limit cevabı çok hızlı döndüğü için yanıltıcı derecede iyi bir p95. Status code'ları check'le ve 429'u ayrı say; limiti test ortamında kapatıyorsan bunu da raporda yaz.

Sık Düşülen Tuzaklar

  • Log'daki istek yüzdesini senaryoya oturum yüzdesi olarak yazmak (ya da tersi).
  • Think time'ı atlamak ya da sabit vermek: biri eşzamanlılığı, öteki istek dağılımını bozar.
  • Hep aynı ID ile test etmek: cache ve veritabanı buffer'ı sonucu sahte iyi gösterir.
  • 20 satırlık bir veritabanıyla ölçüp 40.000 satırlık production'a taşımak.
  • Tükenen test verisi (stok, kupon, tek kullanımlık token) yüzünden çıkan hatayı sistemin hatası sanmak.
  • Cold start ile kararlı durumu aynı sonuçta ortalamak.
  • Ödeme sağlayıcısının sandbox'ına ya da CDN'e yük göndermek; origin'i stub'larla test etmek gerekir.
  • Rate limiter'ın 429'larını fark etmeden "çok hızlı" bir sonuç raporlamak.

Küçük Bir Deneme

★☆☆ bookstore-mix.js'i -e THINK=0 ile çalıştır. Aynı oturum hızında istek sayısı, en yüksek VU sayısı ve p95 nasıl değişti? Little's Law ile açıkla.

★★☆ Access log'u açıp bookstore-mix.js'i iki dakika koştur, traffic-mix.cs'i log üzerinde çalıştır. Sonra senaryodaki yüzdeleri, log'da yüzde 15 checkout isteği çıkacak şekilde yeniden hesapla ve doğrula.

★★★ Ödeme stub'ına yüzde 5 oranında 500 dönen ikinci bir mapping ekle (WireMock.Net'te WithProbability ya da bir sayaç). Siparişlerin kaçının PaymentPending'de kaldığını sorgula ve bu durumun bir SLO ihlali sayılıp sayılmayacağını tartış.

Workload model hazır. Bir sonraki yazıda bu modeli k6'da tam bir load test'e çeviriyoruz: TypeScript script, akış başına ayrı scenario'lar ve arrival-rate executor'ları, endpoint bazında threshold'lar, setup() ve 2.0 ile gelen expect() assertion'ları, makinenin okuyabileceği JSON özet ve çıktıyı satır satır okumak.