YG

B2B sipariş sisteminde Elasticsearch ve Redis ile hızlı ürün araması

8 Ekim 2026·4 dk okuma·Yusuf GÜNER

Bir B2B sipariş sisteminde ürün aramasını dört katmanlı kurdum: önce uygulama belleği, sonra Redis, sonra Elasticsearch, Elasticsearch kapalıysa da SQL Server. 30.000 ürünlük örnek veriyle ilk arama ortalama 11 ms, aynı aramanın tekrarı ortalama 0,01 ms sürüyor. Backend .NET 9 Web API, veritabanı EF Core ile SQL Server.

Bu rakamların nereden geldiğini baştan söyleyeyim. Süre servisin içinde Stopwatch ile ölçülüyor ve her cevapta X-Search-Ms başlığında dönüyor, cevabın hangi katmandan geldiği de X-Search-Source başlığında. Ölçüm kendi makinemde, Docker'da tek düğümlü ve 512 MB heap'li bir Elasticsearch ile yapıldı. Sürelere ağ gecikmesi eklenmiyor. Tarayıcıdan bakınca rakam doğal olarak daha büyük çıkar.

SQL'deki LIKE neden yetmedi

Örnek veri otomotiv yedek parçası: balata, fren diski, yağ filtresi, buji gibi ürünler, Bosch ya da Brembo gibi markalar. Bayi bu ekranda ürün adını tam yazmıyor. Kodun ilk birkaç karakterini yazıyor, "yag filtresi" diye Türkçe karaktersiz yazıyor, bazen de harf kaçırıyor.

Yazıyor bazen "balata" yerine "blata", arama yine de doğru ürünü bulmalı.

ProductName.Contains(q) bunların hiçbirini çözmüyor. Yanlış yazımı tanımıyor, "ğ" ile "g"yi eşlemiyor, sonuçları alakaya göre sıralamıyor. Her aramada tabloyu baştan taradığı için ürün sayısı arttıkça da yavaşlıyor.

Zincir nasıl çalışıyor

Arama servisi sırayla dört yere bakıyor ve bulduğu ilk yerde duruyor:

// Temsili özet, gerçek servisten sadeleştirildi
long surum = await cache.SurumAsync(firmaId);
string anahtar = cache.Anahtar(firmaId, surum, istek);

if (cache.BellektenAl(anahtar, out var sonuc)) return (sonuc, "memory");
if (await cache.RedistenAlAsync(anahtar) is { } r) return (r, "redis");

try
{
    var es = await indeks.AraAsync(firmaId, istek);
    await cache.YazAsync(anahtar, es);
    return (es, "elastic");
}
catch (HttpRequestException)
{
    return (await SqldenAraAsync(istek), "sql");
}

Bellek katmanı .NET'in IMemoryCache'i, sonuçları 60 saniye tutuyor. Redis 10 dakika tutuyor ve birden fazla uygulama örneği çalışırsa ortak katman o oluyor. Redis'ten okunan sonuç belleğe de yazılıyor, böylece aynı arama ikinci kez Redis'e bile gitmiyor.

0,01 ms'lik rakam bu bellek isabetinden geliyor.

Redis'e bağlanılamazsa ya da hata verirse kod onu atlıyor, arama durmuyor. Elasticsearch'e ulaşılamazsa log'a bir uyarı düşüyor ve aynı filtrelerle SQL'den aranıyor. Yani dört katmandan herhangi biri düştüğünde bayi yavaş bir sonuç alıyor, hata sayfası görmüyor.

İndeks ayarları işin yarısı

Elasticsearch'ü bu iş için hızlı ve kullanışlı yapan şey, sunucunun gücünden çok indeksin hangi analizörlerle ve hangi alt alanlarla kurulduğu. Ürün adına Türkçe küçük harf filtresi ve asciifolding uyguluyorum. Bu sayede "yağ" ile "yag" aynı kelimeye dönüşüyor. Ürün kodu için ayrı bir edge_ngram alanı var (1-20 karakter), "FRN-0" yazan bayi o önekle başlayan kodları hemen görüyor. Ad alanında 2-15 karakterlik önek alanı ve fuzziness: AUTO var; "blata" yazınca balata geliyor.

Sıralamada tam kod eşleşmesi 10 kat, kod öneki 5 kat ağırlık alıyor. Bayi bir parça kodunu tam biliyorsa o ürün listenin en üstünde çıkıyor. Arama terimi yoksa liste ada göre sıralanıyor. Veri küçük olduğu için indeks tek shard'lı ve replikasız.

Tenant izolasyonu üç yerde

Sistem çok firmalı, yani aynı veritabanında birden fazla firmanın ürünleri duruyor ve bir firmanın arama sonucu, önbellekten gelse bile, hiçbir koşulda başka bir firmanın bayisine dönmemeli. Bunu tek bir yere bırakmadım, üç ayrı noktada uyguladım.

Birincisi Elasticsearch sorgusu. Her sorgunun filter bölümünün ilk maddesi firmaId üzerinde bir term filtresi. Filtre bağlamında olduğu için puanlamaya karışmıyor ve önbelleğe alınabiliyor. Firma kimliği JWT içindeki claim'den okunuyor, istekteki bir parametreden alınmıyor. Bayi parametreyle oynasa da başka firmanın ürününe ulaşamıyor.

İkincisi önbellek anahtarı. Anahtar ara|firmaId|sürüm|sorgu|marka|fiyat aralığı|stok|sayfa|adet biçiminde, firma kimliğiyle başlıyor. İki firma aynı kelimeyi arasa bile farklı anahtarlara düşüyorlar.

Üçüncüsü SQL yedeği. EF Core'da Product için global sorgu filtresi var: HasQueryFilter(x => x.FirmaId == AktifFirmaId).

Burada bir hata yapmıştım ve sonradan düzelttim. Filtre firmayı her sorguda okumalı, bunun için değeri DbContext üzerindeki bir özellikten alması gerekiyor. Filtre, değerin bir kez yakalanıp sonraki isteklerde de kullanılabileceği bir biçimde yazılırsa izolasyon sessizce bozuluyor.

İndeksi güncel tutmak

Arama ne kadar hızlı olursa olsun, eski veri gösteriyorsa bayi için hiçbir anlamı yok, çünkü sipariş kararını o ekranda gördüğü stok ve fiyata bakarak veriyor. Stoğu biten bir ürün "stokta" diye görünürse bayi sipariş veriyor, sorun depoda çıkıyor.

Bunun için EF Core'un SaveChangesInterceptor'ını kullandım. Kayıttan hemen önce değişen, eklenen ve silinen ürünler not alınıyor. Kayıt başarılı olursa bu liste Elasticsearch'e tek bir _bulk isteğiyle yazılıyor, silinenler indeksten çıkarılıyor. Kayıt hata verirse not alınanlar bırakılıyor ve indekse hiçbir şey gitmiyor.

Ürün güncelleyen kodun arama tarafından haberi bile yok.

Önbelleği temizlemek için anahtar silmiyorum. Her firmanın Redis'te bir sürüm sayacı var. Ürün değişince o firmanın sayacı bir artıyor ve sürüm anahtarın içinde olduğu için eski sonuçlar bir daha hiç okunmuyor. Süreleri dolunca kendiliğinden düşüyorlar. Sürüm numarası bellekte 1 saniye tutuluyor, yani farklı bir uygulama örneğinde en fazla bir saniyelik gecikme oluyor.

Uygulama açılırken arka planda çalışan bir servis indeks var mı diye bakıyor. Yoksa kuruyor ve tüm ürünleri 2.000'erlik parçalarla yüklüyor.

Nerede eksik kalıyor

Üç zayıf noktası var bu yapının.

Elasticsearch'e yazma başarısız olursa yalnız bir uyarı log'u düşüyor. Ürün SQL'de güncel ama indekste eski kalıyor ve açılıştaki yeniden yükleme yalnız indeks hiç yoksa çalışıyor. Bir sonraki adım başarısız yazmaları bir kuyruğa alıp yeniden denemek olmalı.

SQL yedeği Contains ile arıyor. Elasticsearch kapalıyken "blata" yazan bayi hiçbir şey bulamıyor, Türkçe karakter eşlemesi de yok. Yedek sistemi ayakta tutuyor ama arama kalitesi belirgin şekilde düşüyor.

_bulk isteği refresh=true ile gidiyor, değişiklik hemen aranabilir oluyor. Tek tek ürün güncellemelerinde bunun maliyeti hissedilmiyor. Ama binlerce ürünlük bir toplu içe aktarmada her parçada refresh zorlamak pahalı olur, o senaryoda ayarı ayırmak gerekecek.

Ölçümü koda koymak

Hız iddiasını bir script'e bağladım. 25 arama terimi, her biri 4 sayfa, toplam 100 arama önce soğuk, sonra aynı sırayla sıcak çalışıyor. Script her cevaptan X-Search-Ms ve X-Search-Source başlıklarını okuyup katmana göre ortalama, P95 ve en kötü süreyi tablo olarak basıyor. Terimlerin içinde "blata" gibi bilerek yanlış yazılmış olanlar ve "FRN-0" gibi kod önekleri de var.

Kendi projende bir önbellek katmanı ekliyorsan, cevabın nereden geldiğini bir başlıkta döndür. Hangi katmanın gerçekten çalıştığını görürsün ve bir katman sessizce devre dışı kaldığında ilk fark eden sen olursun.

.NETElasticsearchRedisB2Barama

Diğer yazılar