Yeniden mimarilendirme: Redis’ten SQLite’a geçiş
(wafris.org)- Wafris, Rails middleware istemcisi v1’de kullanıcıya ait Redis deposunu v2’de SQLite tabanlı depolama ile değiştirerek hem dağıtım zorluğunu hem de istek değerlendirme gecikmesini azaltmayı hedefliyor
- Başlangıçtaki Redis tercihi, Heroku ve Sidekiq gibi Rails ekosistemindeki alışkanlıklardan etkilenmişti; ancak gerçek operasyonlarda kullanıcıların Redis yöneticisi rolünü de üstlenmesi sorunu büyüdü
- Tüm gelen HTTP isteklerini kurallarla karşılaştıran okuma yolu temel darboğaz; raporlama amaçlı yazmalar ise daha yavaş işlenebilir ya da toplu/asenkron biçimde ayrıştırılabilir
- Yerel bir MacBook Air M2’de 1,2 milyon aralık içeren veri kümesiyle en kötü durum IP aralığı sorgusu test edildiğinde SQLite, yerel Redis’ten yaklaşık 3 kat daha hızlı performans gösterdi; ağ gecikmesi buna dahil değil
- v2, belirli bir süreye veya istek sayısına göre yeni kuralları kontrol edip ardından yeni SQLite DB’nin tamamını indiren bir senkronizasyon yapısı kullanıyor; başarılı kurulumlar yaklaşık 3 kat arttı
Redis tabanlı v1’de ortaya çıkan dağıtım sürtünmesi
- Wafris, açık kaynaklı bir web uygulaması güvenlik duvarı şirketi olarak Rails uygulamalarına bağlanan bir middleware istemcisi sunuyor
- v1 istemcisi, uygulamayla birlikte dağıtılan kullanıcıya ait bir Redis deposu gerektiriyordu
- İlk Redis seçiminde Heroku’da Redis’i kolayca ekleyebilme ortamı, uzaktan erişim kolaylığı ve Sidekiq gibi başarılı örnekler etkili oldu
- Gerçek kullanıcı ortamları daha çeşitliydi ve birçok kullanıcı Redis dağıtımı ve yapılandırma sorunlarını ayıklamakta zorlandı
- RailsWorld 2023 sırasında da Rails uygulamasının yanında bir Redis sunucusunun doğal olarak gerekli olduğu varsayımına karşı olumsuz bir hava vardı
Hız sorununun özü ağ gecikmesi
- Redis, geleneksel RDBMS’lerle karşılaştırıldığında hızlıdır; ancak ayrı bir veritabanı olduğu için bağlantı, bellek ve süreç yönetimi gerektirir
- Bulut ortamlarında ağ gecikmesi, istek işleme performansını doğrudan etkiler
- Wafris, uygulamaya gelen her HTTP isteğini kayıtlı kurallarla karşılaştırmak zorundadır
- v1 istemcisi ne kadar hızlı yapılırsa yapılsın, uygulamanın konuşlandığı ağ yavaşsa toplam yanıt süresi yavaşlayabiliyordu
- Rails uygulamaları da her zaman tekil bir “majestic monolith” biçiminde dağıtılmıyordu
- Birden fazla zona dağıtılan uygulamalar
- Sorumlulukları çakışan birden fazla sunucuya bölünmüş uygulamalar
- Yalnızca bir kısmı Rails olan, başka diller veya framework’lerle birlikte dağıtılan uygulamalar
- Bu tür operasyonel ortamlarda Redis kullanımındaki sürtünme daha da arttı
Wafris istek işlemede okuma ve yazmayı ayırma
- Wafris, Rails middleware olarak kurulur; “IP 1.2.3.4’ü engelle” gibi kurallar ayarlandıktan sonra gelen istekler bu kurallarla karşılaştırılır
- Basitleştirilmiş işlem akışı iki adımdan oluşur
- HTTP isteğini kurallarla karşılaştır; eşleşirse 403, eşleşmezse 200 işle
- Engelleme, izin verme, geçirme gibi işlem sonuçlarını raporla
- İlk adım olan kural okuma, raporlama yazmasından çok daha önemlidir
- İstekler sıralı olarak işlenmelidir
- Filtreleme çalışmazsa kötü istekler geçebilir
- İstek değerlendirmesi, kullanıcının hissettiği site performansını etkiler
- Raporlama yazması daha yavaş, toplu halde ve asenkron işlenebilir
SQLite’ın seçilme nedeni
- Wafris’in ana darboğazı ağ I/O’suydu ve SQLite dokümantasyonundaki “SQLite, istemci/sunucu veritabanlarıyla rekabet etmez. SQLite, fopen() ile rekabet eder” cümlesi bu tercihi etkiledi
- Sadece ağ gidiş-dönüşlerini ortadan kaldırmanın bile Redis tabanlı yapıdan daha hızlı olabileceği varsayımıyla SQLite ve Redis benchmark’a tabi tutuldu
- Başvurulan kaynaklar şunlardı
- Aaron Francis’in “High Performance SQLite”: https://highperformancesqlite.com/
- Stephen Margheim’in SQLite on Rails - the how and why of optimal performance
- Oldmoe: https://oldmoe.blog/
Benchmark kapsamı ve bilinçli sınırlamalar
- Benchmark, genel bir veritabanı performans karşılaştırması değil; Wafris’in hot path’ini ve en kötü durum sorgusunu hedefleyen taraflı bir testti
- En kötü durum sorgusu, IP aralıklarıyla kategorileri eşleyen “lexical decimal” veri yapısı üzerinde yapılan sorguydu
- Basit bir örnek olarak, bir IP adresinin iki adres arasındaki aralıkta olup olmadığını kontrol edip ülkeyi döndüren IP → ülke eşlemesi verilebilir
- Bu yapı milyonlarca satır ölçeğindedir ve IPv6’da her öğe büyüktür
- Aralık sorguları önceden hesaplandıktan sonra iki depoya yazıldı
- SQLite tablosu
- Redis sorted set
- Patolojik durumda her gelen HTTP isteği, istek IP’sini şu aralıklarla karşılaştırmak zorundadır
- Kullanıcı tanımlı izin aralıkları
- Kullanıcı tanımlı engelleme aralıkları
- GeoIP aralıkları
- IP itibar aralıkları
- Bu sorgu türü yeterince önemli olduğu için diğer sorgular veya özellikler taşınmadan yalnızca bu tek tür test edildi
Test yöntemi ve sonuçlar
- Testler, Homebrew ile kurulmuş Redis ve yerel SQLite DB kullanılarak yerel bir MacBook Air M2 üzerinde yapıldı
- Protokol şöyleydi
- Mevcut 1,2 milyon öğelik aralık veri kümesini kullan
- Birden fazla IP kümesini aynı sırayla SQLite ve Redis üzerinde çalıştır
- Her çarpan için testi 5 kez çalıştır ve ortalamayı kullan
- Wafris’in belirli kullanım örneğinde SQLite, yerel Redis’ten yaklaşık 3 kat daha hızlı performans gösterdi
- Bu sonuçlar ağ gecikmesi hesaba katılmadan önceki değerlerdir
- Testler bilinçli olarak basit bir kurulumu ve gerçek kullanımın kusurlarını yansıtacak şekilde tasarlandı; genel bir veritabanı karşılaştırmasına genellemek zordur
Grafikte görünmeyen operasyonel farklar
- SQLite performansı benchmark’ta Redis’ten belirgin biçimde kötü olsaydı bile, aynı veri merkezindeki veya bölgedeki Redis’e olan ağ gecikmesi nedeniyle gerçek ortamda daha hızlı olabileceği değerlendirildi
- Redis sunucusu cluster veya sharding ile sağlam yapılandırılsa bile ağ bant genişliği, bağlantı sayısı ve bölgeler arası gecikme gibi sınırlamalar kalır
- SQLite her compute instance’ında yerel olarak bulunduğu için Wafris’in bu kullanım örneğinde yatay ölçekleme maliyeti neredeyse ortadan kalkar
- Onboarding de SQLite tarafında daha basittir
- Kullanıcı, SQLite kullanıldığını bilmeden bile gem’i web uygulamasına ekleyip çalıştırabilir
- Redis tarafında da ek optimizasyon için çok alan vardır; ancak Wafris, kullanıcılara cache eviction politikası gibi temel ayar değişikliklerini bile tutarlı biçimde kabul ettirmekte zorlanıyordu
SQLite’a geçişten sonra gereken mimari değişiklikler
- Redis tabanlı v1’in güncelleme akışı basitti
- Kullanıcı Wafris Hub’da kuralları günceller
- Wafris Hub, kullanıcının Redis deposundaki kuralları günceller
- SQLite’ta Wafris Hub, SQLite veritabanını web sunucusuna doğrudan “push” edemiyordu
- Bazı SQLite as a service sağlayıcıları benzer bir yöntemi mümkün kılsa da maliyet, performans ve güvenlik nedenleriyle Wafris’e uygun değildi
- Bunu her kullanıcının ayrı ayrı dağıtması gerekir
- Port açılması gerekir
- Gelen bağlantılara izin verilmesi gerekir
- SQLite tabanlı v2’nin güncelleme akışı şöyledir
- Kullanıcı Wafris Hub’da kuralları günceller
- İstemci, zamana veya istek sayısına dayalı belirli aralıklarla güncellenmiş kuralları kontrol eder
- Kurallar değiştiyse istemci tamamen yeni bir SQLite veritabanı indirir
- Bu yapı, kullanıcı tarafındaki kurulum ve yapılandırma sorumluluğunu büyük ölçüde azalttı; v2 istemcisinin başarılı kurulumları yaklaşık 3 kat arttı
Dağıtık dağıtımlarda SQLite yapısı
- AWS, Heroku, Fly gibi bulut ortamlarında Rails uygulaması autoscaling yaptığında compute instance’ları artar; ancak çoğu zaman veritabanı birlikte artmaz
- İstekler 100 req/s’den 10.000 req/s’ye çıktığında dyno, machine, EC2 instance’ları ayağa kalkar; fakat veritabanı darboğazı aynı kalabilir
- Wafris’in gözlemlediği Rails uygulama arızalarının başlıca nedeni, gerçek DDoS’tan çok credential stuffing veya kötü bot trafiğiydi
- Bu tür trafik autoscaling’i tetikler
- Ardından veritabanı bağlantıları tükenir ve uygulama çökebilir
- SQLite DB her compute instance’ına senkronize edildiğinde, yeni instance’taki tüm çağrılar yerel tutulabilir
Yazma yolu istemciden kaldırıldı
- Önceki testler yazma işlemlerini dikkate almadı ve SQLite’ın her role uygun olduğunu da varsaymıyor
- Wafris, SQLite’ı okuma ağırlıklı role uygun şekilde kullanıp yazma yolunu ayrıca yeniden tasarladı
- v2’nin raporlama yolu şöyle değişti
- Wafris Hub’a asenkron bağlanıp raporla
- Raporlama verilerini toplu gönder
- İstemcide veritabanı yazmalarını tamamen kaldır
- Bu yapı diğer hizmetlerin çoğu için uygun olmayabilir; ancak Wafris kullanıcılarının istediği kolay dağıtım ve hızlı istemci hedefleriyle uyumludur
- SQLite tabanlı v2 mimarisi şimdiden birçok sitenin saldırılara dayanıp çevrim içi kalmasına yardımcı oldu; ayrıca Wafris’in destek işlerini ve kullanıcıların yaşadığı sürtünmeyi azalttı
1 yorum
Hacker News yorumları
Her uygulama sunucusunun SQLite veritabanı dosyasının bir kopyasına sahip olduğu ve bunu belirli aralıklarla bütünüyle değiştirdiği model gerçekten ilginç.
Burada web uygulaması güvenlik duvarı kuralları için kullanılıyor, ama feature flag ayarları için de çok uygun olur gibi. Feature flag'ler istek başına onlarca kez kontrol edilebilir ve çoğu zaman “kullanıcı A grubunda ve IP'si B ülkesinde” gibi sorgular gerekir; yerel SQLite bunu iyi karşılayabilir. Feature flag güncellemelerinin birkaç saniye, hatta daha uzun süre gecikmeyle yayılması genelde kabul edilebilir.
Ancak bu amaçla SQLite kullanılan bir örnek henüz görmedim; genelde LMDB veya Kyoto Cabinet gibi DBM tarzı veritabanları kullanılıyor.
Başlarda her sunucudaki Cron işi ilgili servisin verilerini çekiyordu; Kubernetes'e geçildikten sonra ise DaemonSet ve host etiketleme/taints benzeri bir yaklaşımla çeşitli veriler her host'a indirildi, ardından bu verileri kullanan servisin yalnızca ilgili host'ta çalışması garanti edildi. Ruby tarafında buna “hammerspace” deniyordu: https://github.com/airbnb/hammerspace
Böylece tek bir istekte pahalı sorgulama maliyetini birden fazla kez ödemezsiniz ve istek işlenirken flag güncellenip değerin tutarsız hale gelmesi riski de olmaz.
Bunun üzerine gossip ağı ya da açıkça tanımlanmış ağaç biçimli cache/dağıtım ağı gibi çeşitli dağıtım mekanizmaları koyabilirsiniz. Daha yeni sürüme sahip olan herhangi biri güncellemeyi verebilir.
Mevcut DB sürümünü uygulama çıktısına dahil edip, uygulama çalıştıktan sonra verimli şekilde güncelleyebilirsiniz. Feature flag veya canlı yapılandırmada, kod seviyesindeki varsayılanlar yerine yakın tarihli duruma fallback yapılabilir.
Herhangi bir istemci küresel sayacı ETAG gibi gönderip yalnızca değişiklikleri alabilir; kısa süreli ağ kesintisinden sonra yeniden bağlanmak da basit ve ucuz hale gelir.
Birkaç dakikalık geçmiş tutulabiliyorsa, W3C Baggage header'ı gibi bir yöntemle mikroservis çağrılarına sayaç eklenebilir ve birden fazla serviste flag/yapılandırma/veriler aynı sürüm numarası temelinde değerlendirilebilir. Zamana göre değerlendirme özelliği olmasa bile üretim numarasını log'lara yazmak, sonradan debug için çok yardımcı olur.
Ağ gecikmesinin dışında, Redis'te okuma/yazma gecikmesinin sorgulanan anahtar sayısıyla neredeyse doğrusal orantılı olma eğiliminde olduğunu gördüm; yazıdaki grafik de buna benzer görünüyor.
Monolitik bir uygulamada Postgres ve Redis'i kullanım amaçlarına göre birlikte kullandığımızda oldukça iyi çalışıyordu, ama yeni özellikleri paylaşılan Redis cluster'ına itmek fazla kolaydı. Redis tek thread'li olduğu için, 100 bin+ anahtarı topluca sorgulayan düşüncesiz tek bir özellik diğer özellikleri de yavaşlatabilir.
Bu yüzden Redis'in bir seferde tek bir anahtar ya da küçük ve sabit sayıda anahtar okunup yazıldığında, örneğin popüler endpoint'lerde lock veya rate limiting gibi birçok rastgele özelliğin Redis kullandığı durumlarda özellikle iyi olduğu yönünde bir kılavuz önermiştim.
Bu durumda Redis basit tek anahtarlı (IP adresi) sorgularda parlayacaktır, ancak aralık sorgusu ifadeleri gibi daha karmaşık okumalara pek uygun görünmüyor. Yerel Redis'e kıyasla SQLite'ın neden bu kadar iyi çalıştığını derinlemesine bilmiyorum; bu yüzden beklenmedik ve ilginç geldi.
Redis'i, daha zengin temel operasyonlara sahip bir cache olarak görmek en iyisi. Doğru kullanıldığında hızlı ve güvenilirdir.
Ama temel RDBMS'e pek uymayan şeyleri Redis'e koymaya başladığınızda kısa sürede iş kuyrukları, çeşitli lock türleri vb. ortaya çıkar; eninde sonunda performansın uçurumdan düşmesi ya da başka bir nedenle sistemin çökmesi an meselesi olur. Kurtarma süreci genellikle dağınık hale gelir ve çoğu zaman bir miktar veri kaybını kabullenmekle sonuçlanır.
Bu akış küçük adımlarla kolayca gerçekleştiğinden, kaçınmak için disiplin gerekir. SQLite performansı yalnızca ağ overhead'inden kaçınmaktan ibaret değil; birçok kişinin serileştirme/deserileştirme maliyetini hafife alması da büyük etken. Redis protokolü oldukça minimal olsa bile maliyetler birikir; SQLite'ta ise pek çok işlem süreç içi bellek kopyalamaya indirgenir.
Birkaç hafta önce Neon içindeki hackathon'da Redis'in wire protocol'ü olan RESP'yi Postgres sorgularına çeviren küçük bir Node.js sunucusu yaptım.
Çok eğlenceli bir hack projesiydi: https://github.com/btholt/redis-to-postgres
Veritabanının salt okunur olması nedeniyle replikasyon gerektirmeyen sunucu tarafı SQLite için oldukça uygun, özel bir kullanım durumu gibi geliyor.
Başka bir alternatif statik dosyaları belleğe almak olabilir, ama bu durumda veriler bellekte sürekli tutulamayacak kadar fazla olduğundan SQLite iyi bir alternatif gibi görünüyor.
Fark şu ki SQLite çok sağlam, performanslı ve olağanüstü iyi test edilmiş durumda.
RailsWorld 2023’te Redis için “kan kokusunu aldıkları” yönünde bir hava olduğuna dair kısım merak uyandırıcı
Kariyerimde prodüksiyonda yalnızca bir Rails uygulamasıyla çalıştım ve o uygulama Redis kullanıyordu; bu yüzden güncel eğilimleri pek bilmiyorum. Ekosistemin iş perspektifinden Redis’e sırtını mı döndüğünü, bunun lisans değişikliği yüzünden mi olduğunu, yoksa YAGNI’ye daha yakın bir durum mu olduğunu merak ediyorum
O dönemde Rescue ile birlikte indeks oluşturma, transcoding gibi asenkron işleri zamanlamak için kullanıyorduk; o zamanlar gayet iyi bir araç gibi görünüyordu
2024 Rails topluluğu anketinde de Redis hâlâ uygulamalarda en çok kullanılan veri deposu olarak görünüyor
Ancak birçok uygulama Redis kullansa da pratikte bunu yalnızca Sidekiq benzeri amaçlarla kullanıyor; gerçek zamanlı liderlik tabloları ya da vektör DB özellikleri gibi alanlarda kullanmıyor. Bu yüzden gerçek kullanım deseni biraz bulanık
Bugün belirli bir trafiği olan bir Rails kurulumunda genellikle frontend sunucusu, SQL DB, anahtar-değer deposu (Redis veya Memcached) ve bu anahtar-değer deposunu işaret eden bir cache deposu bulunuyor. Ama API kotası ya da hız sınırı tutmak gibi genel anahtar-değer kullanımları ile cache kullanım desenleri oldukça farklı olduğundan bu zahmetli hale geliyor
Disk performansı yeterince hızlandı, SQL performansı da iyileşti; bu yüzden anahtar-değer deposunu kaldırıp geleneksel anahtar-değer amaçlarını SQL DB’ye, cache backend’ini ise diske ayırma yönünde bir hareket var. Yeni NVMe diskler RAM’e neredeyse yaklaşacak kadar hızlı, üstelik çok daha ucuz; bu yüzden daha fazla cache tutmak mümkün
Ama Kubernetes ve Redis-as-a-service çağında bu “paylaşımlı bellek” başka bir rack’teki başka bir VM’de duruyor. O noktaya gelindiğinde zaten kaynak verimliliğinden vazgeçilmiş oluyor; bence dosyayı S3’ten okuyup yazmak daha iyi
“Redis geleneksel RDBMS’lere göre hızlıdır, ancak yine de bağlantı, bellek, process vb. yönetilmesi gereken bir veritabanı olduğu için stack’i daha kırılgan hale getirir” kısmına gelince; oyuncak seviyesini aşan transaction’larla uğraşmaya başladığınızda, ilişkisel olsun ya da olmasın tüm veritabanlarının aşağı yukarı benzer düzeyde yönetim ve bakım gerektirdiğini düşünüyorum
“Hızlıdır” kısmı da biraz ilginç. Join’leri dert etmiyorsanız satır ekleme ve sorgulama da oldukça hızlıdır
Yerel diskten doğrudan okuduğunu anladığınızda, join ya da transaction tartışmaları çok anlamlı olmaktan çıkıyor. Zaten başlı başına birkaç büyüklük mertebesi daha hızlı bir yol
VACUUM’un ne zaman çalıştırılması gerektiğine dair dokümanları her okuduğumda kafam karışıyor. Son SQLite tabanlı uygulama dağıtımımda basitçe bir sayaç tutmuş, çok sayıda yazma işleminden sonra VACUUM çalıştıracak şekilde çözmüştüm
Bağımsız bir sunucu process’i çalıştırılması gereken veritabanlarında yönetim ve bakım eforu kaçınılmaz olarak farklılaşır. Uç bir örnek olarak, SQLite ile Oracle kullanmanın gerçekten hiçbir farkı olmadığına inanmak zor
Redis’i SQLite ile Go’da yeniden uygulayan Redka ilginizi çekebilir: https://github.com/nalgeon/redka
Harika bir proje olduğu doğru, ama bu düzeyde geçiş için neredeyse hiç teşvik yok
Redis kullanım şeklinin doğru olup olmadığını teyit etmek istiyorum
v1’de WAF ile Redis aynı sunucuda mıydı; istemci yönetim panelinde yeni bir kural ayarladığında bu kural yönetim paneliyle aynı sunucudaki Redis’e giriyor, Redis’in dahili senkronizasyon mekanizması sayesinde dünyanın dört bir yanındaki WAF’ların yanındaki yerel Redis’lere kural güncelleniyor muydu? Ardından yeni bir istek herhangi bir WAF’a geldiğinde WAF’ın, güncellenmiş Redis kurallarıyla isteği/IP’yi doğruladığı akış bu muydu, teyit etmek istiyorum
v2’de ise Redis cluster’ı kaldırılıp, tüm WAF sunucularında SQLite DB bulunduğu, yönetim panelinden her WAF+SQLite sunucusuna yeni kuralları dağıtan ayrı bir senkronizasyon mekanizması oluşturulduğu ve yeni istek geldiğinde WAF’ın güncellenmiş SQLite kurallarıyla hızlıca doğrulama yaptığı yapı mı var, merak ediyorum
En iyi cümle şu: “SQLite, istemci/sunucu veritabanlarıyla rekabet etmez. SQLite, fopen() ile rekabet eder”