Fatura Çıkarma: Bir GraphQL Dahili API Optimizasyonu Hikayesi
Yayınlanan: 2022-03-11Toptal mühendislik ekibinin ana önceliklerinden biri, hizmet tabanlı bir mimariye geçiştir. Girişimin çok önemli bir unsuru, Faturalandırma işlevini ayrı bir hizmet olarak dağıtmak için Toptal platformundan izole ettiğimiz bir proje olan Billing Extraction idi.
Geçtiğimiz birkaç ay içinde, işlevselliğin ilk bölümünü çıkardık. Faturalandırmayı diğer hizmetlerle entegre etmek için hem eşzamansız bir API (Kafka tabanlı) hem de eşzamanlı bir API (HTTP tabanlı) kullandık.
Bu makale, senkronize API'yi optimize etmeye ve stabilize etmeye yönelik çabalarımızın bir kaydıdır.
Artımlı Yaklaşım
Bu girişimimizin ilk aşamasıydı. Tam fatura çıkarma yolculuğumuzda, üretimde küçük ve güvenli değişiklikler sunarak aşamalı bir şekilde çalışmaya çalışıyoruz. (Bu projenin başka bir yönü hakkında mükemmel bir konuşmadan slaytlara bakın: bir motorun bir Rails uygulamasından kademeli olarak çıkarılması.)
Başlangıç noktası, monolitik bir Ruby on Rails uygulaması olan Toptal platformuydu . Veri düzeyinde faturalandırma ve Toptal platformu arasındaki bağlantı noktalarını belirleyerek başladık. İlk yaklaşım, Aktif Kayıt (AR) ilişkilerini normal yöntem çağrılarıyla değiştirmekti. Ardından, yöntem tarafından döndürülen verileri getiren faturalandırma hizmetine bir REST çağrısı uygulamamız gerekiyordu.
Platformla aynı veri tabanına erişen küçük bir faturalama hizmeti kurduk. HTTP API kullanarak veya veritabanına doğrudan çağrılarla fatura sorgulaması yapabildik. Bu yaklaşım, güvenli bir geri dönüş uygulamamıza izin verdi; HTTP isteğinin herhangi bir nedenle başarısız olması durumunda (yanlış uygulama, performans sorunu, dağıtım sorunları), doğrudan bir çağrı kullandık ve arayan kişiye doğru sonucu döndürdük.
Geçişleri güvenli ve sorunsuz hale getirmek için HTTP ve doğrudan çağrılar arasında geçiş yapmak için bir özellik bayrağı kullandık. Ne yazık ki, REST ile uygulanan ilk girişimin kabul edilemez derecede yavaş olduğu kanıtlandı. AR ilişkilerinin uzak isteklerle değiştirilmesi, HTTP etkinleştirildiğinde çökmelere neden oldu. Bunu yalnızca nispeten küçük bir arama yüzdesi için etkinleştirmemize rağmen, sorun devam etti.
Radikal olarak farklı bir yaklaşıma ihtiyacımız olduğunu biliyorduk.
Faturalandırma Dahili API'si (diğer adıyla B2B)
İstemci tarafında daha fazla esneklik elde etmek için REST'i GraphQL (GQL) ile değiştirmeye karar verdik. Bu sefer sonuçları tahmin edebilmek için bu geçiş sırasında veriye dayalı kararlar vermek istedik.
Bunu yapmak için, Toptal platformundan (monolith) faturalandırmaya kadar her talebi ölçtük ve ayrıntılı bilgileri günlüğe kaydettik: yanıt süresi, parametreler, hatalar ve hatta üzerlerindeki yığın izi (platformun hangi bölümlerinin faturalandırmayı kullandığını anlamak için). Bu, koddaki birçok istek gönderen veya yavaş yanıtlara neden olan sıcak noktaları tespit etmemizi sağladı. Ardından, stacktrace ve parametreler ile sorunları yerel olarak yeniden üretebilir ve birçok düzeltme için kısa bir geri bildirim döngüsüne sahip olabiliriz.
Üretimde kötü sürprizlerden kaçınmak için, başka bir özellik bayrakları seviyesi ekledik. REST'ten GraphQL'ye geçmek için API'de yöntem başına bir bayrağımız vardı. Yavaş yavaş HTTP'yi etkinleştiriyor ve günlüklerde "kötü bir şey" olup olmadığını izliyorduk.
Çoğu durumda, "kötü bir şey" ya uzun (birden çok saniyelik) yanıt süresi, 429 Too Many Requests ya da 502 Bad Gateway . Bu sorunları çözmek için birkaç model kullandık: verileri önceden yükleme ve önbelleğe alma, sunucudan alınan verileri sınırlama, titreşim ekleme ve hız sınırlama.
Ön Yükleme ve Önbelleğe Alma
Fark ettiğimiz ilk sorun, SQL'deki N+1 sorununa benzer şekilde, tek bir sınıftan/görünümden gönderilen bir istek seliydi.
Aktif Kayıt önceden yükleme, hizmet sınırı üzerinde çalışmadı ve sonuç olarak, her yeniden yüklemede faturalandırmaya ~1.000 istek gönderen tek bir sayfamız oldu. Tek sayfadan bin istek! Bazı arka plan işlerinde durum çok daha iyi değildi. Binlerce değil onlarca istek yapmayı tercih ettik.
Arka plan işlerinden biri, iş verilerini almaktı (bu modele Product adını verelim) ve bir ürünün faturalandırma verilerine göre etkin değil olarak işaretlenmesi gerekip gerekmediğini kontrol etmekti (bu örnek için, BillingRecord modelini arayacağız). Ürünler partiler halinde getirilse de her ihtiyaç duyulduğunda fatura bilgileri istendi. Her ürünün fatura kayıtlarına ihtiyacı vardı, bu nedenle her bir ürünün işlenmesi, faturalandırma hizmetinin bunları getirmesi için bir talep oluşmasına neden oldu. Bu, ürün başına bir istek anlamına geliyordu ve tek bir iş yürütmesinden gönderilen yaklaşık 1.000 istekle sonuçlandı.
Bunu düzeltmek için fatura kayıtlarının toplu olarak önceden yüklenmesini ekledik. Veritabanından alınan her ürün grubu için bir kez fatura kayıtlarını istedik ve ardından bunları ilgili ürünlere atadık:
# fetch all required billing records and assign them to respective products def cache_billing_records(products) # array of billing records billing_records = Billing::QueryService .billing_records_for_products(*products) indexed_records = billing_records.group_by(&:product_gid) products.each do |p| e.cache_billing_records!(indexed_records[p.gid].to_a) } end end100'lük gruplar ve toplu iş başına faturalandırma hizmetine yönelik tek bir istekle, iş başına ~1.000 istekten ~10'a çıktık.
İstemci Tarafı Birleştirmeler
Toplu talepler ve fatura kayıtlarını önbelleğe almak, bir ürün koleksiyonumuz olduğunda ve onların fatura kayıtlarına ihtiyacımız olduğunda işe yaradı. Peki ya diğer yol: fatura kayıtlarını getirip ardından platform veritabanından alınan ilgili ürünlerini kullanmaya çalışsaydık?
Beklendiği gibi bu, bu sefer platform tarafında başka bir N+1 sorununa neden oldu. N adet fatura kaydı toplamak için ürünleri kullanırken, N adet veritabanı sorgusu gerçekleştiriyorduk.
Çözüm, gerekli tüm ürünleri bir kerede getirmek, bunları ID tarafından indekslenmiş bir karma olarak saklamak ve ardından ilgili fatura kayıtlarına atamaktı. Basitleştirilmiş bir uygulama:
def product_billing_records(products) products_by_gid = products.index_by(&:gid) product_gids = products_by_gid.keys.compact return [] if product_gids.blank? billing_records = fetch_billing_records(product_gids: product_gids) billing_records.each do |billing_record| billing_record.preload_product!( products_by_gid[billing_record.product_gid] ) end endBunun bir karma birleştirmeye benzediğini düşünüyorsanız, yalnız değilsiniz.
Sunucu Tarafı Filtreleme ve Yetersiz Getirme
Platform tarafında en kötü talep artışları ve N+1 sorunlarıyla mücadele ettik. Yine de yavaş tepkiler aldık. Platforma çok fazla veri yükleyip orada filtrelemekten (istemci tarafı filtreleme) kaynaklandığını belirledik. Verileri belleğe yüklemek, seri hale getirmek, ağ üzerinden göndermek ve sadece çoğunu bırakmak için seriyi kaldırmak muazzam bir israftı. Uygulama sırasında kullanışlıydı çünkü genel ve yeniden kullanılabilir uç noktalarımız vardı. Operasyonlar sırasında kullanılamaz hale geldi. Daha spesifik bir şeye ihtiyacımız vardı.
GraphQL'ye filtreleme argümanları ekleyerek sorunu çözdük. Yaklaşımımız, filtrelemeyi uygulama düzeyinden DB sorgusuna taşımayı içeren iyi bilinen bir optimizasyona benziyordu ( find_all vs. where Rails). Veritabanı dünyasında, bu yaklaşım açıktır ve SELECT sorgusunda WHERE olarak mevcuttur. Bu durumda, sorgu işlemeyi kendimiz uygulamamız gerekiyordu (Faturalandırmada).
Filtreleri yerleştirdik ve bir performans artışı görmeyi bekledik. Bunun yerine platformda 502 hata gördük (ve kullanıcılarımız da gördü). İyi değil. Hiç iyi değil!
Bu neden oldu? Bu değişiklik, hizmeti bozmamalı, yanıt süresini iyileştirmelidir. Yanlışlıkla ince bir hata ortaya koymuştuk. API'nin her iki sürümünü de (GQL ve REST) istemci tarafında tuttuk. Bir özellik bayrağıyla kademeli olarak geçiş yaptık. Dağıttığımız ilk talihsiz sürüm, eski REST dalında bir gerileme başlattı. Testimizi GQL şubesine odakladık, bu nedenle REST'teki performans sorununu kaçırdık. Alınan ders: Arama parametreleri eksikse, veritabanınızdaki her şeyi değil, boş bir koleksiyon döndürün.
Faturalandırma için NewRelic verilerine bir göz atın. Değişiklikleri, trafikteki durgunluk sırasında sunucu tarafı filtreleme ile devreye aldık (platform sorunlarıyla karşılaştıktan sonra faturalandırma trafiğini kapattık). Dağıtımdan sonra yanıtların daha hızlı ve daha öngörülebilir olduğunu görebilirsiniz.
Bir GQL şemasına filtre eklemek çok zor değildi. GraphQL'nin gerçekten öne çıktığı durumlar, çok fazla nesne değil, çok fazla alan getirdiğimiz durumlardı. REST ile muhtemelen ihtiyaç duyulabilecek tüm verileri gönderiyorduk. Genel bir uç nokta oluşturmak, bizi, onu platformda kullanılan tüm veriler ve ilişkilendirmelerle paketlemeye zorladı.

GQL ile alanları seçebildik. Birkaç veritabanı tablosunun yüklenmesini gerektiren 20'den fazla alan getirmek yerine, sadece gerekli olan üç ila beş alanı seçtik. Bu, platform dağıtımları sırasında faturalandırma kullanımındaki ani artışları ortadan kaldırmamızı sağladı, çünkü bu sorgulardan bazıları dağıtım sırasında çalıştırılan esnek arama yeniden dizin oluşturma işleri tarafından kullanıldı. Olumlu bir yan etki olarak, dağıtımları daha hızlı ve daha güvenilir hale getirdi.
En Hızlı İstek Yapmadığınızdır
Getirilen nesnelerin sayısını ve her nesneye paketlenen veri miktarını sınırladık. Başka ne yapabilirdik? Belki de verileri hiç getirmiyor?
İyileştirilmesi gereken başka bir alan fark ettik: Platformdaki son fatura kaydının oluşturulma tarihini sık sık kullanıyorduk ve her seferinde onu almak için faturalandırmayı arıyorduk. Her ihtiyaç duyulduğunda eşzamanlı olarak getirmek yerine, faturalandırmadan gönderilen olaylara göre önbelleğe alabileceğimize karar verdik.
Önceden planladık, görevler hazırladık (dört ila beşi) ve bu talepler önemli bir yük oluşturduğundan, mümkün olan en kısa sürede yapmak için çalışmaya başladık. Önümüzde iki haftalık bir iş vardı.
Neyse ki, başladıktan kısa bir süre sonra soruna ikinci kez baktık ve halihazırda platformda bulunan verileri farklı bir biçimde kullanabileceğimizi fark ettik. Kafka'dan gelen verileri önbelleğe almak için yeni tablolar eklemek yerine, faturalandırma ve platformdan gelen verileri karşılaştırmak için birkaç gün harcadık. Platform verilerini kullanıp kullanamayacağımız konusunda alan uzmanlarına da danıştık.
Son olarak, uzak çağrıyı bir DB sorgusu ile değiştirdik. Bu, hem performans hem de iş yükü açısından büyük bir kazançtı. Ayrıca bir haftadan fazla geliştirme süresinden tasarruf ettik.
Yükün Dağıtılması
Bu optimizasyonları tek tek uyguluyor ve dağıtıyorduk, ancak faturalandırmanın 429 Too Many Requests yanıt verdiği durumlar hala vardı. Nginx'te istek sınırını arttırabilirdik, ancak iletişimin beklendiği gibi davranmadığına dair bir ipucu olduğu için sorunu daha iyi anlamak istedik. Hatırlayacağınız gibi, son kullanıcılar tarafından görülmediklerinden (doğrudan aramaya geri dönüş nedeniyle) üretimde bu hatalara sahip olmayı göze alabilirdik.
Hata, platformun yetenek ağı üyeleri için gecikmiş zaman çizelgeleriyle ilgili hatırlatıcılar planladığı her Pazar meydana geldi. Hatırlatıcıları göndermek için bir iş, ilgili ürünler için binlerce kayıt içeren fatura verilerini getirir. Optimize etmek için yaptığımız ilk şey, fatura verilerini toplu hale getirmek ve önceden yüklemek ve yalnızca gerekli alanları getirmekti. Her ikisi de iyi bilinen numaralardır, bu yüzden burada ayrıntılara girmeyeceğiz.
Bir sonraki Pazar gününü konuşlandırdık ve bekledik. Sorunu çözeceğimizden emindik. Ancak, Pazar günü, hata yeniden ortaya çıktı.
Faturalama servisi sadece zamanlama sırasında değil, aynı zamanda bir şebeke üyesine bir hatırlatma gönderildiğinde de aranıyordu. Hatırlatıcılar ayrı arka plan işlerinde gönderilir (Sidekiq kullanılarak), bu nedenle önceden yükleme söz konusu değildi. Başlangıçta, her ürünün bir hatırlatıcıya ihtiyacı olmadığı ve hatırlatıcıların hepsi bir kerede gönderildiği için bunun bir sorun olmayacağını varsaymıştık. Hatırlatıcılar, ağ üyesinin saat diliminde 17:00 için planlanmıştır. Yine de önemli bir ayrıntıyı gözden kaçırdık: Üyelerimiz zaman dilimlerine eşit olarak dağılmamıştır.
Yaklaşık %25'i bir zaman diliminde yaşayan binlerce ağ üyesine hatırlatıcılar planlıyorduk. Yaklaşık %15'i en kalabalık ikinci zaman diliminde yaşıyor. Bu saat dilimlerinde saat 17.00'yi işaret ettiğinden, aynı anda yüzlerce hatırlatma göndermek zorunda kaldık. Bu, faturalandırma hizmetine, hizmetin kaldırabileceğinden daha fazla olan yüzlerce istek patlaması anlamına geliyordu.
Hatırlatıcılar bağımsız işlerde planlandığından fatura verilerini önceden yüklemek mümkün değildi. Bu sayıyı zaten optimize ettiğimiz için faturalandırmadan daha az alan getiremedik. Ağ üyelerini daha az nüfuslu zaman dilimlerine taşımak da söz konusu değildi. Peki biz ne yaptık? Hatırlatıcıları biraz taşıdık.
Tüm hatırlatıcıların aynı anda gönderileceği bir durumdan kaçınmak için hatırlatıcıların planlandığı zamana titreşim ekledik. Kesin olarak 17:00'de planlamak yerine, onları 17:59 ile 18:01 arasında iki dakikalık bir aralıkta planladık.
Hizmeti devreye aldık ve sorunu nihayet çözdüğümüzden emin olarak takip eden Pazar gününü bekledik. Ne yazık ki, Pazar günü hata tekrar ortaya çıktı.
Şaşırdık. Hesaplarımıza göre, istekler iki dakikalık bir süreye yayılmış olmalıydı, bu da saniyede en fazla iki isteğimiz olacağı anlamına geliyordu. Bu hizmetin kaldıramayacağı bir şey değildi. Faturalandırma taleplerinin günlüklerini ve zamanlamasını analiz ettik ve jitter uygulamamızın çalışmadığını fark ettik, bu nedenle talepler hala sıkı bir grupta görünüyordu.
Bu davranışa ne sebep oldu? Sidekiq'in zamanlamayı uygulama şekli buydu. Her 10-15 saniyede bir yeniden yoklama yapar ve bu nedenle bir saniyelik çözünürlük sağlayamaz. İsteklerin tek tip bir dağılımını sağlamak için Sidekiq Enterprise tarafından sağlanan bir sınıf olan Sidekiq::Limiter kullandık. Hareketli bir saniyelik pencere için sekiz isteğe izin veren pencere sınırlayıcıyı kullandık. Bu değeri seçtik çünkü faturalandırmada Nginx'in saniyede 10 istek sınırı vardı. Titreşim kodunu, kaba taneli istek dağılımı sağladığı için tuttuk: Sidekiq işlerini iki dakikalık bir süre boyunca dağıttı. Ardından, her bir iş grubunun tanımlanan eşiği aşmadan işlenmesini sağlamak için Sidekiq Limiter kullanıldı.
Bir kez daha konuşlandırdık ve Pazar gününü bekledik. Sonunda sorunu çözdüğümüze emindik ve başardık. Hata ortadan kalktı.
API Optimizasyonu: Nihil Novi Alt Taban
Sanırım uyguladığımız çözümler sizi şaşırtmadı. Toplu işleme, sunucu tarafı filtreleme, yalnızca gerekli alanları gönderme ve hız sınırlama yeni teknikler değildir. Deneyimli yazılım mühendisleri kuşkusuz bunları farklı bağlamlarda kullanmışlardır.
N+1'den kaçınmak için önceden yükleniyor mu? Her ORM'de var. Hash katılıyor mu? MySQL bile şimdi onlara sahip. Yetersiz getirme mi? SELECT * vs. SELECT field bilinen bir numaradır. Yükü dağıtmak mı? Yeni bir kavram da değil.
Peki bu yazıyı neden yazdım? Neden en başından doğru yapmadık? Her zamanki gibi, bağlam anahtardır. Bu tekniklerin çoğu, yalnızca onları uyguladıktan sonra veya koda baktığımızda değil, çözülmesi gereken bir üretim sorunu fark ettiğimizde tanıdık geliyordu.
Bunun birkaç olası açıklaması vardı. Çoğu zaman, aşırı mühendislikten kaçınmak için işe yarayabilecek en basit şeyi yapmaya çalışıyorduk. Sıkıcı bir REST çözümüyle başladık ve ancak daha sonra GQL'ye geçtik. Değişiklikleri bir özellik bayrağının arkasına yerleştirdik, trafiğin çok küçük bir bölümünde her şeyin nasıl davrandığını izledik ve gerçek dünya verilerine dayalı iyileştirmeler uyguladık.
Keşiflerimizden biri, yeniden düzenleme yapılırken performans düşüşünün gözden kaçırılmasının kolay olduğuydu (ve çıkarma, önemli bir yeniden düzenleme olarak ele alınabilir). Katı bir sınır eklemek, kodu optimize etmek için eklenen bağları kesmemiz anlamına geliyordu. Yine de performansı ölçene kadar belirgin değildi. Son olarak, bazı durumlarda üretim trafiğini geliştirme ortamında yeniden oluşturamadık.
Faturalama hizmetinin evrensel bir HTTP API'sinin küçük bir yüzeyine sahip olmaya çalıştık. Sonuç olarak, farklı kullanım durumlarında ihtiyaç duyulan verileri taşıyan bir dizi evrensel uç nokta/sorgu elde ettik. Ve bu, birçok kullanım durumunda verilerin çoğunun işe yaramaz olduğu anlamına geliyordu. DRY ve YAGNI arasında biraz ödünleşim var: DRY ile, yalnızca bir uç nokta/sorgu döndüren faturalandırma kayıtlarımız varken, YAGNI ile uç noktada yalnızca performansa zarar veren kullanılmayan verilerle sonuçlanıyoruz.
Ayrıca faturalandırma ekibiyle titreşimi tartışırken başka bir ödünleşim fark ettik. İstemci (platform) açısından, her istek, platformun ihtiyacı olduğunda bir yanıt almalıdır. Performans sorunları ve sunucu aşırı yükü, faturalama hizmetinin soyutlanmasının arkasına gizlenmelidir. Faturalama hizmeti açısından, yüke dayanmak için istemcileri sunucu performans özelliklerinden haberdar etmenin yollarını bulmamız gerekiyor.
Yine, burada hiçbir şey yeni veya çığır açan değil. Farklı bağlamlarda bilinen kalıpları belirlemek ve değişikliklerin getirdiği ödünleşimleri anlamakla ilgilidir. Bunu zor yoldan öğrendik ve umarız sizi hatalarımızı tekrarlamaktan kurtarmışızdır. Hatalarımızı tekrarlamak yerine, şüphesiz kendi hatalarınızı yapacak ve onlardan öğreneceksiniz.
Çalışmalarımıza katılan meslektaşlarıma ve ekip arkadaşlarıma özel teşekkürler:
- Makar Ermokhin
- Gabriele Renzi
- Samuel Vega Caballero
- Luca Guidi
