En iyi performans için Rails üzerinde SQLite’ın nasıl ve neden kullanılması gerektiği
(fractaledmind.github.io)- Rails’te SQLite’ı varsayılan haliyle üretim ortamında kullanmak hâlâ yeterli değil; ancak birkaç ayar ekleyerek yüksek performanslı ve dayanıklı uygulamalar oluşturmak mümkün
- Eşzamanlı yazma yükü altında SQLite’ın global yazma kilidi nedeniyle
SQLITE_BUSYoluşur; Rails’in tipik yazma transaction akışı da varsayılan deferred moduyla pek iyi uyuşmaz sqlite3-ruby1.6.9’dan itibarendatabase.ymliçinde varsayılan transaction modu IMMEDIATE olarak değiştirilebilir; böylece yazma kilidi alma başarısızlıklarında daha güvenli biçimde bekleme ve yeniden deneme yapılabilirbusy_timeout, bekleme sırasında bile Ruby GVL’yi tuttuğu için Puma paralelliğini zedeler;busy_handleriçindesleepile GVL’yi bırakıp 1 ms aralıklı yeniden deneme kullanmak uzun kuyruk gecikmelerini azaltır- WAL modu, IMMEDIATE transaction’lar, GVL’yi serbest bırakan busy handler, Rails 7.1’in SQLite varsayılan ayarları ve isteğe bağlı okuma/yazma connection pool ayrımı, Rails üzerinde SQLite performansını iyileştirmenin ana eksenleridir
Varsayılan Rails + SQLite ayarlarında görülen sınırlar
- Rails uygulamasını SQLite tabanlı olarak performanslı ve dayanıklı biçimde çalıştırmak için mevcut varsayılan ayarlar tek başına yeterli değildir
- Amaç, Rails 8’de SQLite’ın varsayılan deneyimini üretim ortamına hazır hale getirmektir
- Demo uygulaması Lorem News’tur
- Kullanıcılar, gönderiler ve yorumlar içeren temel bir Hacker News klonudur
- İçerik Lorem Ipsum’dan oluşur
- Yük testlerinde oha load testing CLI ve uygulamaya yerleşik benchmarking routes kullanılır
post#createendpoint’ine 5 saniye boyunca sıralı istek gönderildiğinde RPS istikrarlıdır ve tüm istekler başarılı olur- Aynı endpoint’e 5 saniye boyunca 4 eşzamanlı istek gönderildiğinde bazı istekler 500 yanıtı döndürür
SQLITE_BUSY ve IMMEDIATE transaction’lar
- Loglarda ilk görülen sorun
SQLITE_BUSYistisnasıdır - SQLite, aynı anda yalnızca tek bir yazma işlemine izin vermek için veritabanında yazma kilidi kullanır
- Aynı anda yalnızca bir connection yazma kilidine sahip olabilir
- Başka bir connection kilidi tutarken yeni bir connection yazma kilidi almaya çalışırsa
SQLITE_BUSYoluşur
- Rails uygulaması daha büyük eşzamanlı yük aldığında
SQLITE_BUSYnedeniyle başarısız olan istek oranı da artar - SQLite’ın varsayılan transaction modu deferred’dır ve gerçek yazma işlemi gerçekleşene kadar kilit almaz
- Tek connection’lı ortamlarda veya salt-okunur transaction’ların çok olduğu ortamlarda performans açısından avantajlıdır
- Üretimdeki Rails uygulamaları birden çok thread’de birden çok connection kullanır ve Rails yazma sorgularını transaction içine aldığı için bu varsayılanla çatışır
- Transaction ortasında yazma kilidi alma başarısız olursa SQLite, serializable isolation’ı bozmamak için ilgili sorguyu güvenli biçimde yeniden deneyemez ve hemen istisna fırlatır
IMMEDIATEtransaction, transaction başlar başlamaz yazma kilidi almaya çalışır- SQLite yazma sorgusunu kuyruğa alıp daha sonra yeniden kilit almayı deneyebilir
- Rails’in yazma transaction kalıbı için bu yöntem deferred’dan daha uygundur
sqlite3-rubygem’i 1.6.9’dan itibaren varsayılan transaction modu ayarını destekler- Rails,
database.ymliçindeki üst seviye anahtarlarısqlite3-rubyveritabanı ilklendirmesine iletir database.ymliçinde Rails’in SQLite transaction’larını IMMEDIATE modunda çalışacak şekilde ayarlayabilirsiniz
- Rails,
- Bu değişiklikten sonra basit yük testlerinde eşzamanlı yük altında neredeyse hiç 500 hatası olmadan işlem yapılır; ancak 16 eşzamanlı istekte yeniden bazı hatalar görülür
busy_timeout, GVL, özel busy_handler
- Bir sonraki darboğaz, eşzamanlı istek sayısı Puma worker sayısına yaklaştığında veya onu aştığında p99 gecikmesinin keskin biçimde artmasıdır
- Gerçek istek işleme süresi, Puma worker sayısının 3 katı eşzamanlı yükte bile istikrarlıdır; ancak yaklaşık 5 saniye süren istekler oluşmaya başladığında bunlarla birlikte
SQLITE_BUSY500 yanıtları da ortaya çıkar - Bu 5 saniye,
database.ymliçindeki timeout ayarıyla eşleşir ve SQLite’ınbusy_timeoutayarına map edilir busy_timeout, hemenBUSYistisnası fırlatmak yerine belirtilen milisaniye kadar bekleyerek yazma kilidini yeniden almaya çalışır- SQLite, kilidi yeniden almak için bir tür üstel backoff yöntemi kullanır
- Yalnızca timeout içinde kilit alınamazsa
BUSYistisnası fırlatır - Web uygulamaları birden çok connection açsa bile yazma sırasını doğrudan düzenlemek yerine bunu SQLite’a bırakabilir
- Darboğaz, SQLite’ın Ruby süreci içine gömülü olmasından ve
sqlite3-ruby’nin SQLite C kodunu çağırırken Ruby GVL’yi serbest bırakmamasından kaynaklanır- Bir Puma worker veritabanı sorgusunun dönmesini beklerken GVL’yi tutmaya devam eder
- Diğer Puma worker’ların aynı anda SQLite’a yazma sorgusu göndermesi bile zorlaşır
- SQLite’ın sıralı yazma özelliği, Rails istek işlemesini de daha doğrusal hale getirerek throughput’u ciddi biçimde düşürür
- SQLite,
busy_timeout’tan daha düşük seviyeli birbusy_handlerhook’u sağlarbusy_timeout, SQLite’ın sağladığı belirli birbusy_handlerimplementasyonudursqlite3-ruby,sqlite3_busy_handlerC fonksiyonuna binding sağladığından, sorgu kuyruğa girdiğinde çağrılan bir Ruby callback’i oluşturulabilir
- Ruby callback’inde
Kernel.sleepkullanılırsa sorgu yazma kilidi için yeniden denemeyi beklerken GVL serbest bırakılabilir - Bu yöntem, eşzamanlı yükte p99 gecikmesini ciddi biçimde iyileştirir; ancak p99.99 gecikmesinde, eşzamanlı yük arttıkça en yavaş isteklerin daha da yavaşlaması durumu devam eder
Üstel backoff’un uzun kuyruğu ve 1 ms yeniden deneme
- Ruby ile yeniden implemente edilen SQLite
busy_timeoutmantığı, uzun süre beklemiş sorguları daha dezavantajlı hale getiren bir yapıya sahiptir - İlk yeniden denemelerde bekleme süresi küçüktür; ancak callback çağrısı sayısı arttıkça bekleme süresi büyür
- İlk beklemede 1 ms bekler
- Onuncu çağrıda 50 ms bekler
-
- çağrıdan sonra 100 ms’lik beklemeler yapar
- Birikimli bekleme süresi timeout olan 5000 ms’yi aşarsa istisna oluşur
- Sürekli yeni yazma sorguları gelirse yeni sorgular kısa bekleme süreleriyle kilit almayı daha sık yeniden deneyebilir
- Zaten üç kez beklemiş eski bir sorgu 10 ms beklerken yeni bir sorgu 1 ms, 2 ms, 5 ms beklemelerden geçerek üç kez yeniden deneyebilir
- Bu artan backoff, eski sorgunun yazma kilidini alamadan timeout’a düşme olasılığını artırır
- Çözüm, sorgunun yaşından bağımsız olarak tüm sorguların aynı sıklıkta yeniden denemesini sağlamaktır
sqlite3-rubymain branch’inde bu değişiklik yer alır- Bu özellik yazının yazıldığı an itibarıyla tagged release’e henüz dahil değildir
- Ruby callback’i
sleepile bekleme sırasında GVL’yi serbest bırakır - Her zaman yalnızca 1 ms sleep eder
- Bu değişiklikten sonra benchmark’ta p99.99 gecikme eğrisi düzleşir
- Eşzamanlılık Puma worker sayısının yarısını geçtiğinde görülen sıçrama devam eder
- Sonrasında uzun kuyruk gecikmesi yaklaşık 0,5 saniye seviyesinde düzleşir
WAL modu ve okuma/yazma connection pool ayrımı
- Rails üzerinde SQLite performansı için gerekli dört koşul: IMMEDIATE transaction’lar, GVL’yi serbest bırakan busy handler, uygun SQLite ayarları ve WAL modudur
- write-ahead log, SQLite’ın birden çok eşzamanlı okuma işlemini işleyebilmesini sağlar
- Varsayılan rollback journal mode, okuma ya da yazma fark etmeksizin aynı anda yalnızca tek bir sorguya izin verir
- WAL modu aynı anda birden çok reader’a izin verir; ancak writer yine aynı anda yalnızca bir tane olabilir
- Rails 7.1’den itibaren Rails, SQLite veritabanlarına daha iyi varsayılan ayarlar uygular
- Bu ayarlar, SQLite’ın web uygulaması bağlamında iyi çalışması için önemlidir
- Ayrıntılı ayarlar ve gerekçeleri ayrı bir blog yazısında ele alınır
- İsteğe bağlı beşinci performans kolu, salt-okuma pool’u ile salt-yazma pool’unu ayırmaktır
- SQLite WAL modu birden çok okuma connection’ını ve tek bir yazma connection’ını destekler
- Active Record connection pool’u yazma connection’larıyla dolarsa okuma işleri gereksiz yere engellenebilir
- Rails’in multiple databases desteği kullanılarak reader ve writer ayarları aynı SQLite veritabanına yönlendirilebilir
- Gerçekte ayrı bir veritabanı değil, aynı tek veritabanını işaret eder
- Sonuç olarak birbirinden ayrılmış connection pool’ları ve connection ayarları oluşturulabilir
- Reader connection pool’u salt-okunur connection’lardan oluşturulur; writer connection pool’unda ise yalnızca tek bir connection bulunur
- Active Record modelleri, rollerine göre uygun connection pool’una bağlanacak şekilde ayarlanır
- Rails’in automatic role switching özelliği kullanılarak tüm web isteklerinin varsayılan connection’ı reader pool yapılır; yalnızca veritabanına yazma gerektiğinde writer pool’a geçilir
- Aynı veritabanı kullanıldığı için
read your own writesgarantisi içindelaygerekmez - ActiveRecord adapter’ının
transactionmetodu patch’lenerek transaction’ın writer veritabanına bağlanması sağlanır
- Aynı veritabanı kullanıldığı için
- Bu “deferred requests” yaklaşımı ile izole connection pool’larının birleşimi, comment create endpoint testinde yalnızca RPS bazında performans iyileşmesi gösterir
Gem olarak paketlenen iyileştirmeler
- Tüm iyileştirmeleri Rails uygulamasında doğrudan implemente etmek gerekmez
activerecord-enhancedsqlite3-adapterkurulursa ilgili iyileştirmeler uygulanabilir- İzole connection pool özelliği daha yeni ve deneysel bir özellik olduğundan opt-in için ek ayar gerekir
- Bu yaklaşım, SQLite’ı Rails üretim ortamında hızlı ve esnek kullanmak için araçları, teknikleri ve varsayılanları bir arada sunar
- Rails, SQLite ile çalışmak için iyi bir web uygulaması framework’üdür; ilgili araçlar ve gem ekosistemi büyümektedir
1 yorum
Hacker News yorumları
SQLite + Rails kullanmayı düşünen herkesin Oldmoe’nun (X/GitHub) Litestack projesine mutlaka bakması değer
Litestack, Ruby ve Ruby on Rails uygulamalarında SQLite’ın gömülü yapısından yararlanarak SQL veritabanı, hızlı cache, iş kuyruğu, mesaj broker’ı, tam metin arama motoru ve metrik platformunu tek pakette sunan bir Ruby gem’i
Şu an bir projede kullanıyorum ve çok memnunum; bağlantı: https://github.com/oldmoe/litestack
Bu kadar ayrıntılı bir yazıyı yazmanın kaç gün sürdüğünü hayal bile edemiyorum
Rails’in ötesinde SQLite web uygulamalarını ölçeklendirmeyi düşünen herkes için faydalı bir yazı
SQLite ile çalışan herkes, kullandığı dil veya framework ne olursa olsun bu yazıyı okumalı
Birkaç yıl önce bunların çoğunu kendi başıma çözmek zorunda kalmış biri olarak, derlediğiniz için teşekkürler
Kurulumu kolay olması gereken bir FOSS analiz sistemi geliştiriyorum; bu yüzden olay verilerini ana uygulama verilerinden ayrı ayrı bir SQLite veritabanına göndermek istiyorum
Orta düzeyde yoğun bir web sitesi bile saniyede 1000’den fazla olay üretebilir; ölçekleme konusunda endişeliyim
Olayları sunucu belleğinde toplayıp her 1 saniyede bir toplu yazma yapmak, SQLite’ın çoklu yazma sınırını aşmak için makul bir yöntem mi, yoksa daha iyi fikirler var mı merak ediyorum
Bunu benchmark ile kolayca kanıtlayabilirsiniz; ayrıca tüm toplu öğeleri tek bir transaction içine koyabilirsiniz
Toplu işleme yapıldığında, fiilen batch’i alıp gerçek yazmayı yapan tek bir thread olur; bu da SQLite’ın aynı anda 1 yazma sınırıyla iyi örtüşür
Ancak karmaşıklık biraz artar. Tek bir toplu yazma 1 saniye içinde bitmezse ne yapılacağı, bellekte tutulan kuyruğun boyutunun sınırsız olup olmayacağı, sınırsızsa sunucu aşırı yük altında OOM ile ölmeyeceğinden emin olup olunamayacağı, sınır konursa öğe kaybının göze alınıp alınmayacağı, atılacaksa hangi öğelerin atılacağı ve kuyruk sınırının ne olacağı belirlenmeli
Bu sorular, kuyruk gerektiren neredeyse her sistemde mutlaka ortaya çıkar ve yalnızca bugün için değil, gelecekteki durumlar için de faydalıdır
SQLite’ın transaction eşzamanlılık performansına güvenmek zor geliyorsa, tüm yazmaları belirli bir thread veya process üzerinde seri hale getirebilirsiniz
ClickHouse’un async-insert[0] özelliğini kullanırsanız uygulama tarafında olayları toplu işleme konusunda endişelenmeniz gerekmez
Gömülü bir çözüm arıyorsanız ClickHouse tabanlı chDB’yi kullanabilirsiniz
[0] https://clickhouse.com/blog/asynchronous-data-inserts-in-cli...
İstediğiniz kadar reader worker olabilir; writer ise kuyruk gibi bir yerden girdileri alabilir
Sadece bunu yapmak bile performansı çoğu zaman gerçekten şaşırtıcı hale getirir
SQLite’ı üretim arka uç veritabanı olarak kullanma eğilimini hâlâ pek anlayamıyorum
SQLite, telefon rehberi gibi küçük ve gömülebilir istemci tarafı uygulama veritabanları için harika; ancak geliştiricileri de kapsamının bunun ötesine genişlemesine sürekli karşı çıktı
Örneğin tarih/saat ya da UUID gibi kullanışlı yerel tipler eklemiyor. Çünkü kod ve gömülü nesne boyutları büyür; sonuçta “her şey string” durumuna sıkışıp kalıyorsunuz
Referans bütünlüğü açılabiliyor ama kısıt seçenekleri de çok sınırlı
Neden uygun olmayan ve doğru dürüst desteklenmeyen bir role zorla sokulmaya devam edildiğini anlamıyorum
Nedeni basit. Okuma ağırlıklı erişim desenlerinde SQLite inanılmaz hızlı; veritabanı erişim kodunu basitleştirecek kadar hızlı ve örneğin N+1 sorgularının çoğu durumda fiilen sorun olmamasını sağlayabiliyor
Ayrıca SQLite, N katmanlı mimaride bir katmanı ortadan kaldırır ve böylece arızalanabilecek unsurları da azaltır. Postgres ya da MySQL’i doğrudan işlettiyseniz, gerçekten pek çok şeyin ters gidebileceğini bilirsiniz
Her uygulama, hatta çoğu uygulama için mükemmel seçim değil; ama mevcut eğilim, SQLite’ın yalnızca “küçük gömülü istemci uygulaması veritabanı”na uygun olduğu şeklindeki açıkça yanlış düşünceye karşı bir denge ayarı gibi
Böyle bakınca, aynı verilerle çalışan bir DBMS’nin yanında başka bir DBMS daha olması epey komik hale geliyor
Bu yüzden ya istemcilerin doğrudan Postgres’e bağlanmasına izin verme ya da Postgres’i çıkarıp kendi DBMS’inize daha fazla odaklanma yönünde ayrışılıyor
İkinci yolu seçerseniz SQLite, üzerine bir şeyler inşa etmesi kolay bir motor. Mükemmel olmasa da elimizdeki araç bu
Bu farkındalığın geniş ölçekte yayılması nispeten yeni, bu yüzden neyin olup neyin olamayacağını anlamaya yönelik çok sayıda deney yapılıyor
Bu, bilişimin doğal döngüsü; eski olanın yeniden yeni hale gelmesi
Postgres’i MySQL, MSSQL, Oracle ya da başka bir DBMS olarak da okuyabilirsiniz
Bu tür basitleştirme yalnızca SQLite geliştiricilerine veya düşük donanımlı cihazlara değil, uygulama geliştiricilerine de birçok açıdan fayda sağlar
Dokümantasyon sadeleşir, öğrenme eğrisi kısalır; hata yüzeyi ve ikili dosya boyutu da azalır
Günümüz yazılımlarında her şeye şişkinlik ve karmaşıklık ekleme eğilimi var; SQLite gibi bazı projelerin buna karşı durması çok iyi
Harika bir yazı; Django için de benzer bir kaynak var mı merak ediyorum
ArchiveBox, Django üzerinden SQLite kullanıyor ve yazıda Rails için anlatılanlarla birebir aynı sorunları oldukça sık yaşadım
Uygulamanın başka yollarından tüm yazmaları seri hale getirmek zorunda bırakmayan bir SQLite katmanı çözümü olsa iyi olurdu
sqlite3-rubygem’i tasarım gereği SQLite çağrıları sırasında GVL’yi serbest bırakmıyor; bağlantı verilen issue yorumuna https://github.com/sparklemotion/sqlite3-ruby/issues/287#iss... bakınca kilidi yeniden edinmenin maliyetli olduğuna dair bir şüphe varmış ama doğrulanmamış gibi görünüyorTüm bu dolambaçlı işleri düşününce biraz kuşku uyandırıyor
Python extension kültüründe bunun ters yönde tasarlanacağını sanırdım; orada gerçekte nasıl yapıldığını merak ediyorum
Ek olarak, bağlantı verilen issue’da “extralite gem’i, bloklama sırasında GVL’yi serbest bırakan alternatif bir SQLite istemcisidir; eşzamanlılıkla ilgili bilgiler https://github.com/digital-fabric/extralite?tab=readme-ov-fi... adresindedir. Genel olarak bu gem’den çok daha hızlıdır ve eşzamanlılık sorunu yoktur” diyen bir yorum da var
SQLite sanal makinesinin her
step’inde GVL’yi basitçe serbest bırakmanın tek iş parçacıklı performansı ciddi şekilde kötüleştirdiği doğrulandıTek iş parçacıklı ve çok iş parçacıklı performans arasında orta noktayı bulmak zor
Rails’te bağlantı havuzu nedeniyle çok iş parçacıklı olduğunu biliyoruz; ancak düşük seviyeli gem, tek iş parçacıklı ortamlarda kullanılan başka kütüphaneler ve araçlar tarafından da yaygın biçimde kullanılıyor
Kişisel oyuncak web servislerimde tuttuğum birkaç ayar değeri şöyle
PRAGMA journal_mode = WAL;PRAGMA busy_timeout = 5000;PRAGMA synchronous = NORMAL;PRAGMA cache_size = 1000000000;PRAGMA foreign_keys = true;PRAGMA temp_store = memory;Ve BEGIN IMMEDIATE transaction kullanıyorum
https://kerkour.com/sqlite-for-servers
cache_sizevemmap_sizehakkında ne düşünüyorsunuz?Hem SQLite’i hem de Rails’i seviyorum ama bu, üretim ortamında MS Access kullanmaya benziyor gibi görünüyor
Üstelik Access kullanımının daha yaygın olduğu onlarca yıl öncesine göre bugün bilgisayarlar ve diskler çok daha hızlı
Okuma ağırlıklıysa SQLite, hatta Jet bile saniyede on binlerce isteğe oldukça kolay ulaşabilir
Çoğu uygulamada eşzamanlı kullanıcı sayısı yüz binlere varmadığından SQLite iyi uyabilir
SQLite’ın neredeyse tüm platformlar ve diller için istemcisi olması da güçlü bir yanı
Arşivleme, yedekleme ve taşınabilirlik de SQLite’a uygun kullanım alanlarıdır. Belirli bir dönemle sınırlı veri girişi olan bir projede kutu başına SQLite kullanılmasını güçlü biçimde savunmuştum; hâlâ bunun daha iyi olacağını düşünüyorum
Karmaşık bir şema ve özel dışa aktarma/arşivleme özellikleri yapmak yerine tek bir dosyayı kopyalayıp arşiv ya da yedek olarak kullanabilirdiniz; zamanla ortaya çıkan şema değişikliklerini de o kadar derinlemesine düşünmek gerekmezdi
Duruma göre değişir ama birçok sorun için oldukça iyi bir çözümdür. Çoğu uygulamada daha ölçeklenebilir NoSQL seçenekleri yerine PostgreSQL veya başka bir ilişkisel DBMS’in çoğu zaman daha iyi bir seçim olmasına benzer
Aşırı tasarlama eğilimimiz vardı; artık bilişim ve G/Ç performansı, bu tür çabaları giderek daha az haklı çıkaran bir seviyeye yaklaşıyor
Çok faydalı ve iyi yazılmış bir yazı
Varsayılan
busy_timeoutmetodunun eski sorguları cezalandıran üstel gecikmeye neden sahip olduğunu merak ediyorumBunun varsayılan değer olarak makul olmasının sebebi ne olabilir?