Shopify, envanter rezervasyon sistemini Redis'ten MySQL'e taşıdı
(shopify.engineering)- 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 COMMITTEDyalıtım seviyesi, tutarlı kilitleme sırası,UNION ALLile 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 iseINCRile 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, idbileş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ınINSERTişlemini engelliyor ve deadlock'a yol açıyordu - Yalıtım seviyesi MySQL varsayılanı olan
REPEATABLE READyerineREAD COMMITTEDolarak 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_quantitiesINSERT→reservation_unitsDELETE - claim:
reserved_quantitiesDELETE
- reserve:
- Çözüm: reserve işleminin her zaman önce units tablosunda
DELETE, sonrareserved_quantitiesüzerindeINSERTyapacak ş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 ALLile 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 LOCKEDgibi 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
Uzun zamandır gerçekten geliştirme yazısı gibi bir yazı paylaşılıyor.