6 puan yazan GN⁺ 5 시간 전 | 1 yorum | WhatsApp'ta paylaş
  • Envanter rezervasyon sistemi, ödeme işleme sırasında aynı ürünün iki kez satılmasını önleyen kritik bir altyapı ve Shopify bunu yıllardır Redis tabanlı olarak işletiyordu
  • MySQL 8'in SKIP LOCKED özelliğinden yararlanarak, öğe başına miktar sütunu yerine satış birimi başına 1 satır yapısıyla sistemi yeniden tasarladı ve Redis olmadan da yüksek performans elde etti
  • Bileşik birincil anahtar, READ COMMITTED yalıtım seviyesi, tutarlı kilitleme sırası, UNION ALL ile toplu işleme gibi MySQL optimizasyon tekniklerini birleştirerek kilit çekişmesi ve deadlock sorunlarını giderdi
  • Gerçek darboğazın rezervasyon sorguları değil bağlantı işgali olduğu ortaya çıktı; checkout yolunun tamamı ölçümlenerek DB okumalarında %50, transaction sayısında %33 azalma sağlandı
  • 2025 Black Friday zirvesinde dakikada 5,1 milyon dolar gelir işlenirken writer CPU %50'nin, reader CPU ise %16'nın altında tutuldu ve hedef throughput aşıldı

Arka plan: Oversell önleme sisteminin gereksinimleri

  • Checkout tamamlandığında stokta gerçekten ürün kaldığını garanti eden bir Oversell Protection sistemine ihtiyaç var
    • Reserve: ödeme başlarken ilgili ürünü birkaç dakika boyunca geçici olarak kilitler
    • Claim: ödeme tamamlandığında stok defterinden miktarı kalıcı olarak düşer
  • Her iki yönde de hataya tolerans yok
    • Hata olursa aynı ürünü iki kişi satın alabilir ya da stok varken ürün tükendi sayılarak gelir kaybı oluşabilir
  • Ölçek gereksinimi: Shopify, ABD e-ticaretinin %14'ünden fazlasını taşıyor ve 2025 Black Friday'de yıllık bazda %11 artışla dakikada 5,1 milyon dolar gelir kaydetti
  • Çoklu konum envanteri (Multi-location inventory), ACID garantileri, yüksek performanslı throughput ve doğruluğun öncelikli olması temel gereksinimlerdi

Mevcut Redis modelinin sınırları

  • Redis'te her öğe bir miktar anahtarına sahipti; rezervasyon DECR, serbest bırakma ise INCR ile yapılıyordu
  • Temel sorun: rezervasyon verisi (Redis) ile stok defteri (MySQL) farklı sistemlerdeydi
    • Claim aşamasında MySQL güncellemesi ile Redis temizliğini tek bir atomik transaction içinde birleştirmek mümkün değildi
    • Çalışma sırasına bağlı olarak oversell (ürün satıldı ama defterden düşülmedi) veya undersell (defterden düşüldü ama hâlâ rezerve görünüyor) oluşabiliyordu
  • Çoklu konum stok farkındalığı yoktu ve ayrı bir Redis kümesini işletmenin maliyeti vardı

Temel çözüm: SKIP LOCKED tabanlı MySQL yeniden tasarımı

Temel yapı: birim başına 1 satır (One Row Per Unit)

  • Öğe başına miktar sütunu yerine satılabilir her birim için 1 satır yapısı benimsendi
    • Örneğin stokta 10 adet olan bir ürün → 10 satır; 3 adet rezervasyon için tek bir transaction içinde 3 satır seçilip taşınıyor
  • Rezervasyon ile stok defteri aynı MySQL veritabanında tutularak reserve ve claim işlemleri ACID transaction olarak yürütüldü; böylece Redis'teki hata sınıfları ortadan kaldırıldı
  • SKIP LOCKED: başka bir transaction'ın kilitlediği satırları atlayıp kullanılabilir satırları hemen döndürür → aynı satır üzerinde bekleme olmadan çekişmeyi azaltır

Havuz boyutu sınırı: konum başına en fazla 1.000 satır

  • Öğe/konum kombinasyonu başına kullanılabilir satırlar en fazla 1.000 adet ile sınırlandı; böylece tablo boyutu ve tarama performansı kontrol altında tutuldu
    • Örnek: 50.000 stok × 10 konum = 500.000 satırlık durumun önüne geçildi
  • Havuz tükendiğinde inline replenishment tetikleniyor; aynı anda çok sayıda transaction'ın satır eklemesini önlemek için yalnızca tek bir transaction'ın takviye yapmasına izin veriliyor ve böylece thundering herd engelleniyor
  • Havuz tamamen boşalsa bile gecikme yalnızca ilgili rezervasyonda yaşanıyor; stok gerçekten varsa müşterinin ürünü tükendi görmesi söz konusu olmuyor

Dört temel teknik karar

1. Bileşik birincil anahtarla kilit sayısını azaltmak

  • İlk prototipte auto-increment ID birincil anahtar olarak kullanıldığında, InnoDB hem ikincil indeksi hem de clustered index'i kilitlediği için rezervasyon başına 2 satır kilidi oluşuyordu
  • shop_id, inventory_item_id, inventory_group_id, id bileşenlerinden oluşan bileşik birincil anahtar uygulandı → filtreleme sütunları birincil anahtarın içinde olduğu için kilit sayısı 1'e düştü
  • Saniyede binlerce rezervasyonun olduğu ortamda indeks ve birincil anahtar tasarımı, kilit sayısını ve throughput'u doğrudan etkiliyor

2. READ COMMITTED ile gap lock'ları kaldırmak

  • Boş tabloda SELECT ... FOR UPDATE SKIP LOCKED çalıştırıldığında gap lock'lar (supremum dahil) oluşuyor, bu da replenishment transaction'ının INSERT işlemini engelliyor ve deadlock'a yol açıyordu
  • Yalıtım seviyesi MySQL varsayılanı olan REPEATABLE READ yerine READ COMMITTED olarak değiştirildi → gap lock davranışı değişti ve replenishment transaction'ı normal şekilde ilerledi
  • Bu, ilgili kod tabanında ilk kez varsayılan dışı yalıtım seviyesi kullanımıydı; bu yüzden transaction bazında yalıtım seviyesi ayarlamak için küçük bir framework desteği gerekti

3. Tutarlı kilit sırasıyla deadlock önlemek

  • Reserve ve claim iki tabloya farklı sırayla eriştiği için deadlock oluşuyordu
    • reserve: reserved_quantities INSERTreservation_units DELETE
    • claim: reserved_quantities DELETE
  • Çözüm: reserve işleminin her zaman önce units tablosunda DELETE, sonra reserved_quantities üzerinde INSERT yapacak şekilde sıra standartlaştırıldı → circular wait ortadan kaldırıldı

4. UNION ALL ile toplu işleme sayesinde round-trip azaltmak

  • Sepette birden fazla line item olduğunda rezervasyon sorguları UNION ALL ile tek bir round-trip içinde topluca işlendi
  • Toplam round-trip sayısının azalması, yük altında latency'yi iyileştirdi

Gerçek darboğaz: sorgular değil bağlantı işgali

Sorunun keşfedilme süreci

  • Üretim ortamında hedef throughput'un altında tavana ulaşıldı; P90 latency iyiydi, CPU sınırda değildi ve sorgular da optimize edilmişti
  • Yük testlerinde gözlenen belirtiler:
    • MySQL içinde thread queueing
    • Kuyruktaki işler çalışmaya başladığında CPU'nun aniden yükselmesi
    • ProxySQL katmanında MySQL backend bağlantılarının tükenmesi

Bağlantı görünürlüğü sağlamak

  • Uygulama katmanı: tüm SQL ifadelerine /* conn_tag:checkout_completion */ biçiminde iş sürecini tanımlayan yorumlar eklendi
  • ProxySQL katmanı: etiketleri ayrıştırıp çağıran tarafa göre bağlantı işgal süresi toplama yeteneği eklendi
  • Sonuç: hangi sürecin bağlantıyı ne kadar süre tuttuğu anında görülebilir hale geldi

Bulgular ve çözüm

  • Rezervasyon dışındaki checkout yolundaki başka kodlar, bağlantıları gerekenden uzun süre tutuyordu
    • Bunlar daha önce ilk sınıra çarpan bileşenler olmadığı için optimizasyon hedefi dışında kalmıştı
  • Checkout yolu sadeleştirilince primary DB okumaları %50, transaction sayısı %33 azaldı
  • Yıllar önce temkinli şekilde ayarlanıp tekrar gözden geçirilmeyen InnoDB thread concurrency ayarı güncellenerek ek darboğaz da kaldırıldı
  • İyileştirme sonrasında yüksek hacimli flash sale ölçümünde writer CPU %50'nin, reader CPU ise %16'nın altında kaldı

Geçiş yöntemi: Shadow Mode

  • Redis'ten MySQL'e bir anda geçmek yerine, Shadow Mode ile iki sistem paralel çalıştırıldı
    • Tüm rezervasyonlar aynı anda hem Redis'e hem MySQL'e yazıldı; source of truth olarak Redis korunmaya devam etti
    • Gerçek üretim trafiğinde MySQL'in doğruluğu ve performansı paralel olarak doğrulandı
  • Devam eden rezervasyonları taşımaya gerek kalmadan geçiş yapılabildi; çünkü iki sistem aynı anda aktifti
  • Source of truth MySQL'e geçirildikten sonra bile kill switch korundu ve çift yazma yolu sayesinde Redis her zaman güncel kaldı
  • Yaygınlaştırma, düşük trafikli pod'lardan en yüksek hacimli merchant'lara kadar pod bazında kademeli olarak yapıldı

Çıkarımlar

1. Eski kararları yeniden değerlendirin

  • 5 yıl önce mümkün olmayan bir MySQL yaklaşımı, SKIP LOCKED gibi yeni özelliklerle bugün uygulanabilir hale geldi
  • Thread limitleri gibi "pratik kural" ayarları, iş yükü ve donanım değiştiğinde yeniden gözden geçirilmeli
  • CPU düşükken queueing yaşanıyorsa, nedenini mutlaka derinlemesine araştırmak gerekir

2. Küçük başlayın ve gözlemleyin

  • Tam Rails framework'ü olmadan, küçük bir Ruby betiği ve MySQL ile minimal bir prototip kuruldu
  • İkinci bir terminalde kilit davranışını doğrudan gözlemlemek, teoriden daha fazla şey öğretti
  • Bağlantı işgali ölçüm kalıbı (uygulama katmanı etiketleri + proxy toplulaştırması) hem uygulanması kolay hem de hemen devreye alınabilir

1 yorum

 
hso2341 30 분 전

Uzun zamandır gerçekten geliştirme yazısı gibi bir yazı paylaşılıyor.