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
6 yorum
Uzun zamandır gerçekten geliştirme yazısı gibi bir yazı paylaşılıyor.
Ben de uzun zamandır böyle bir yazı görmemiştim, hissi farklı geliyor.
Sanırım yapay zekadan fazlasıyla yorulmuşuz.
Katılıyorum haha, böyle yazıları görmek sevindirici hale geldi!
AI Slop tanıtım yazılarından bıkan tek kişi ben değilmişim demek ki haha
Alkışlar
PostgreSQL ya da Oracle'da eskiden beri
skip lockedyok muydu? diye düşünürken deOracle'ın lisans maliyeti başlı başına yük oluyor,
PG'de ise MVCC implementasyonu nedeniyle
updateyükünün epey fazla olacağı öngörüldüğünden, bu özelliğin MySQL 8'e gelmesinden sonra devreye almalarının nedeni bu olabilir diye tahmin ediyorum