Cara dan alasan menggunakan SQLite on Rails untuk performa optimal
(fractaledmind.github.io)- Di Rails, SQLite masih belum cukup untuk dipakai di lingkungan produksi dengan konfigurasi bawaan apa adanya, tetapi dengan beberapa pengaturan tambahan, Anda bisa membuat aplikasi yang berperforma tinggi dan tangguh
- Saat beban tulis konkuren terjadi, write lock global SQLite menyebabkan
SQLITE_BUSY, dan alur transaksi tulis yang umum di Rails tidak terlalu cocok dengan mode deferred bawaan - Mulai
sqlite3-ruby1.6.9, mode transaksi default dapat diubah menjadi IMMEDIATE melaluidatabase.yml, sehingga kegagalan memperoleh write lock bisa ditunggu dan dicoba ulang dengan lebih aman busy_timeouttetap memegang GVL Ruby saat menunggu sehingga merusak paralelisme Puma; jika memakai retry tiap 1 ms dengansleepdibusy_handleruntuk melepas GVL, latensi ekor panjang berkurang- Mode WAL, transaksi IMMEDIATE, busy handler yang melepas GVL, konfigurasi bawaan SQLite di Rails 7.1, serta pemisahan opsional pool koneksi baca/tulis adalah pilar utama peningkatan performa SQLite on Rails
Keterbatasan yang terlihat pada konfigurasi dasar Rails + SQLite
- Untuk menjalankan aplikasi Rails berbasis SQLite dengan performa baik dan tangguh, konfigurasi bawaan saat ini belum cukup
- Tujuannya adalah membuat pengalaman default SQLite di Rails 8 menjadi siap untuk lingkungan produksi
- Aplikasi demonya adalah Lorem News
- Clone dasar bergaya Hacker News yang mencakup pengguna, posting, dan komentar
- Kontennya tersusun dari Lorem Ipsum
- Untuk load test digunakan oha load testing CLI dan benchmarking routes yang tertanam di aplikasi
- Jika mengirim request berurutan selama 5 detik ke endpoint
post#create, RPS stabil dan semua request berhasil - Jika mengirim 4 request konkuren selama 5 detik ke endpoint yang sama, sebagian request mengembalikan respons 500
SQLITE_BUSY dan transaksi IMMEDIATE
- Masalah pertama yang terlihat di log adalah exception
SQLITE_BUSY - SQLite menggunakan write lock pada database untuk hanya mengizinkan satu operasi tulis pada saat yang sama
- Hanya satu koneksi yang dapat memiliki write lock pada satu waktu
- Jika koneksi baru mencoba memperoleh write lock saat koneksi lain sedang memegang lock,
SQLITE_BUSYterjadi
- Ketika aplikasi Rails menerima beban konkuren yang lebih besar, persentase request yang gagal karena
SQLITE_BUSYjuga meningkat - Mode transaksi default SQLite adalah deferred, dan lock tidak diperoleh sampai operasi tulis benar-benar terjadi
- Ini menguntungkan dari sisi performa pada lingkungan satu koneksi atau lingkungan dengan banyak transaksi read-only
- Aplikasi Rails produksi menggunakan banyak koneksi dari banyak thread, dan Rails membungkus query tulis dalam transaksi, sehingga berbenturan dengan default ini
- Jika gagal memperoleh write lock di tengah transaksi, SQLite tidak dapat mencoba ulang query tersebut dengan aman tanpa melanggar isolasi serializable, sehingga langsung melempar exception
- Transaksi
IMMEDIATEmencoba memperoleh write lock sejak transaksi dimulai- SQLite dapat memasukkan query tulis ke antrean dan mencoba memperoleh lock lagi nanti
- Untuk pola transaksi tulis Rails, pendekatan ini lebih cocok daripada deferred
- Gem
sqlite3-rubymendukung konfigurasi mode transaksi default mulai 1.6.9- Rails meneruskan key tingkat atas di
database.ymlke inisialisasi databasesqlite3-ruby - Di
database.yml, transaksi SQLite Rails dapat dikonfigurasi agar berjalan dalam mode IMMEDIATE
- Rails meneruskan key tingkat atas di
- Setelah perubahan ini, load test sederhana dapat diproses hampir tanpa error 500 bahkan pada beban konkuren, tetapi pada 16 request konkuren sebagian error kembali muncul
busy_timeout, GVL, custom busy_handler
- Bottleneck berikutnya adalah fenomena ketika latensi p99 meningkat tajam saat jumlah request konkuren mendekati atau melampaui jumlah worker Puma
- Waktu pemrosesan request sebenarnya tetap stabil bahkan pada beban konkuren 3 kali jumlah worker Puma, tetapi ketika mulai muncul request yang memakan waktu sekitar 5 detik, respons 500
SQLITE_BUSYjuga ikut terjadi - Angka 5 detik ini sesuai dengan konfigurasi timeout di
database.yml, yang dipetakan ke pengaturanbusy_timeoutSQLite - Alih-alih langsung melempar exception
BUSY,busy_timeoutmenunggu selama jumlah milidetik yang ditentukan sambil mencoba memperoleh kembali write lock- SQLite mencoba memperoleh kembali lock dengan semacam exponential backoff
- Exception
BUSYhanya dilempar jika lock tidak berhasil diperoleh dalam timeout - Aplikasi web dapat membuka banyak koneksi tanpa mengatur langsung urutan penulisan dan menyerahkannya ke SQLite
- Bottleneck muncul karena SQLite tertanam di dalam proses Ruby, dan
sqlite3-rubytidak melepas GVL Ruby saat memanggil kode C SQLite- Satu worker Puma terus memegang GVL saat menunggu query database kembali
- Worker Puma lain bahkan menjadi kesulitan mengirim query tulis ke SQLite secara bersamaan
- Karakteristik penulisan berurutan SQLite membuat pemrosesan request Rails menjadi lebih linear, sehingga throughput turun besar
- SQLite menyediakan hook
busy_handleryang lebih rendah levelnya daripadabusy_timeoutbusy_timeoutadalah implementasibusy_handlertertentu yang disediakan SQLitesqlite3-rubymenyediakan binding untuk fungsi Csqlite3_busy_handler, sehingga callback Ruby yang dipanggil saat query masuk antrean dapat dibuat
- Jika menggunakan
Kernel.sleepdi callback Ruby, GVL dapat dilepas selama query menunggu retry write lock - Pendekatan ini sangat memperbaiki latensi p99 pada beban konkuren, tetapi pada latensi p99.99 masih tersisa fenomena request paling lambat menjadi lebih lambat seiring meningkatnya beban konkuren
Ekor panjang exponential backoff dan retry 1 ms
- Logika
busy_timeoutSQLite yang diimplementasikan ulang dengan Ruby memiliki struktur yang makin merugikan query yang sudah menunggu lama - Pada retry awal, waktu tunggunya kecil, tetapi makin banyak callback dipanggil, makin besar waktu tunggunya
- Pada tunggu pertama, ia menunggu 1 ms
- Pada panggilan kesepuluh, ia menunggu 50 ms
- Setelah panggilan ke-12, ia menunggu masing-masing 100 ms
- Jika total waktu tunggu melewati timeout 5000 ms, exception terjadi
- Jika query tulis baru terus masuk, query baru dapat mencoba memperoleh lock lebih sering dengan waktu tunggu yang pendek
- Saat query lama yang sudah menunggu tiga kali menunggu 10 ms, query baru dapat melewati tunggu 1 ms, 2 ms, dan 5 ms serta mencoba ulang tiga kali
- Backoff yang meningkat ini memperbesar kemungkinan query lama tidak memperoleh write lock dan timeout
- Solusinya adalah membuat semua query mencoba ulang dengan frekuensi yang sama, tanpa bergantung pada usia query
- Perubahan ini sudah tercermin di
sqlite3-rubybranch main- Pada saat artikel ditulis, fitur tersebut belum termasuk dalam tagged release
- Callback Ruby melepas GVL saat menunggu dengan
sleep - Selalu hanya sleep 1 ms
- Setelah perubahan ini, kurva latensi p99.99 pada benchmark menjadi datar
- Lompatan saat konkurensi melewati setengah jumlah worker Puma masih tersisa
- Setelah itu, latensi ekor panjang mendatar di sekitar 0,5 detik
Mode WAL dan pemisahan pool koneksi baca/tulis
- Empat syarat yang dibutuhkan untuk performa SQLite on Rails adalah transaksi IMMEDIATE, busy handler yang melepas GVL, konfigurasi SQLite yang tepat, dan mode WAL
- write-ahead log memungkinkan SQLite menangani beberapa operasi baca konkuren
- rollback journal mode bawaan hanya mengizinkan satu query pada satu waktu, baik baca maupun tulis
- Mode WAL mengizinkan banyak reader secara bersamaan, tetapi hanya satu writer pada satu waktu
- Mulai Rails 7.1, Rails menerapkan default yang lebih baik untuk database SQLite
- Pengaturan ini penting agar SQLite berjalan baik dalam konteks aplikasi web
- Detail konfigurasi dan alasannya dibahas di artikel blog terpisah
- Tuas performa kelima yang opsional adalah memisahkan pool read-only dan pool write-only
- Mode WAL SQLite mendukung banyak koneksi baca dan satu koneksi tulis
- Jika pool koneksi Active Record dipenuhi oleh koneksi tulis, operasi baca dapat terblokir tanpa perlu
- Dengan memanfaatkan dukungan multiple databases Rails, konfigurasi reader dan writer dapat diarahkan ke database SQLite yang sama
- Secara nyata ini menunjuk ke satu database yang sama, bukan database terpisah
- Hasilnya, pool koneksi dan konfigurasi koneksi yang saling terpisah dapat dibuat
- Pool koneksi reader terdiri dari koneksi read-only, dan pool koneksi writer hanya memiliki satu koneksi
- Model Active Record dikonfigurasi agar terhubung ke pool koneksi yang sesuai berdasarkan perannya
- Dengan menggunakan automatic role switching Rails, koneksi default semua request web dibuat ke pool reader, lalu beralih ke pool writer hanya saat penulisan database diperlukan
- Karena memakai database yang sama,
delayuntuk menjaminread your own writestidak diperlukan - Metode
transactionpada adapter ActiveRecord dipatch agar transaksi terhubung ke database writer
- Karena memakai database yang sama,
- Kombinasi “deferred requests” dan pool koneksi terisolasi ini menunjukkan peningkatan performa berdasarkan RPS sederhana pada pengujian endpoint comment create
Peningkatan yang dikemas sebagai gem
- Tidak semua peningkatan perlu diimplementasikan langsung di aplikasi Rails
- Dengan menginstal
activerecord-enhancedsqlite3-adapter, peningkatan terkait dapat diterapkan - Fitur pool koneksi terisolasi adalah fitur eksperimental yang lebih baru, sehingga perlu menambahkan konfigurasi untuk opt-in
- Pendekatan ini menyediakan kumpulan alat, teknik, dan default untuk menggunakan SQLite secara cepat dan fleksibel di lingkungan produksi Rails
- Rails adalah framework aplikasi web yang nyaman digunakan bersama SQLite, dan ekosistem alat serta gem terkait terus berkembang
1 komentar
Opini Hacker News
Jika Anda ingin memakai SQLite + Rails, proyek Litestack dari Oldmoe (X/GitHub) sangat layak dilihat
Litestack adalah Ruby gem yang memanfaatkan sifat embedded SQLite untuk aplikasi Ruby dan Ruby on Rails, menyediakan database SQL, cache cepat, job queue, message broker, mesin full-text search, dan platform metrik dalam satu paket
Saya sedang memakainya di proyek saat ini dan sangat puas; tautannya https://github.com/oldmoe/litestack
Sulit membayangkan berapa hari yang dibutuhkan untuk menulis artikel sedetail ini
Ini berguna bagi siapa pun yang memikirkan penskalaan aplikasi web SQLite, bukan hanya Rails
Siapa pun yang bekerja dengan SQLite, terlepas dari bahasa atau framework yang digunakan, sebaiknya membaca tulisan ini
Sebagai orang yang beberapa tahun lalu harus mencari tahu sendiri sebagian besar hal seperti ini, saya berterima kasih sudah merangkumnya
Saya sedang membuat sistem analitik FOSS dan pemasangannya harus mudah, jadi saya ingin mengirim data event ke database SQLite terpisah dari data aplikasi utama
Bahkan situs web yang cukup sibuk saja bisa menghasilkan lebih dari 1000 event per detik, jadi saya khawatir soal penskalaan
Saya penasaran apakah mengumpulkan event di memori server lalu melakukan batch write sekali tiap 1 detik adalah cara yang masuk akal untuk menghindari batasan banyak penulisan SQLite, atau apakah ada ide yang lebih baik
Ini juga mudah dibuktikan dengan benchmark, dan seluruh item dalam batch bisa dimasukkan ke satu transaksi
Dengan batching, pada dasarnya akan ada satu thread yang mengambil batch dan melakukan penulisan sebenarnya, jadi ini juga cocok dengan batas satu concurrent write pada SQLite
Namun kompleksitasnya sedikit meningkat. Anda harus menentukan apa yang dilakukan jika satu batch write tidak selesai dalam 1 detik, apakah ukuran queue yang disimpan di memori dibiarkan tanpa batas, jika tanpa batas apakah Anda yakin server tidak akan mati karena OOM saat overload, jika diberi batas apakah Anda siap kehilangan item, jika dibuang item mana yang akan dibuang, dan berapa batas queue-nya
Pertanyaan-pertanyaan seperti ini pasti muncul di hampir semua sistem yang membutuhkan queue, dan akan berguna bukan hanya sekarang tetapi juga untuk situasi mendatang
Jika Anda sulit mempercayai performa konkurensi transaksi SQLite, Anda bisa menserialisasi semua penulisan pada thread atau proses tertentu
Dengan fitur async-insert[0] dari ClickHouse, Anda tidak perlu memikirkan batching event di sisi aplikasi
Jika mencari solusi embedded, Anda bisa memakai chDB yang berbasis ClickHouse
[0] https://clickhouse.com/blog/asynchronous-data-inserts-in-cli...
Anda bisa memiliki worker baca sebanyak yang diinginkan, sementara writer menerima input dari sesuatu seperti queue
Dengan itu saja, performanya sering kali benar-benar mengejutkan
Saya masih belum benar-benar memahami tren memakai SQLite sebagai basis data backend produksi
SQLite memang sangat bagus sebagai basis data aplikasi sisi klien yang kecil dan embeddable, seperti buku alamat di ponsel, tetapi para pengembangnya sendiri pun secara konsisten menolak memperluasnya melampaui cakupan itu
Misalnya, mereka tidak menambahkan tipe native yang berguna seperti date/time atau UUID. Alasannya karena itu akan memperbesar kode dan ukuran objek bawaan, dan akibatnya kita terjebak dalam kondisi “semuanya adalah string”
Integritas referensial bisa diaktifkan, tetapi opsi constraint-nya juga sangat terbatas
Saya tidak mengerti kenapa orang terus memaksakannya masuk ke peran yang tidak cocok dan tidak benar-benar didukung
Alasannya sederhana. Untuk pola akses yang dominan baca, SQLite luar biasa cepat, dan cukup cepat sehingga kode akses basis data bisa disederhanakan; misalnya, query N+1 pun sering kali pada praktiknya bukan masalah
Selain itu, SQLite menghapus satu lapisan dalam arsitektur N-tier, dan dengan itu mengurangi komponen yang bisa rusak. Kalau pernah mengoperasikan Postgres atau MySQL sendiri secara langsung, Anda tahu bahwa banyak hal bisa benar-benar salah
Ini bukan pilihan sempurna untuk semua aplikasi, bahkan bukan untuk banyak aplikasi, tetapi tren saat ini lebih mirip penyeimbangan ulang terhadap anggapan yang jelas keliru bahwa SQLite hanya cocok untuk “basis data aplikasi klien embedded kecil”
Setelah melihatnya begitu, arsitektur dengan DBMS lain di samping DBMS yang menangani data yang sama terasa cukup konyol
Jadi arahnya terbagi antara membuat klien terhubung langsung ke Postgres, atau menyingkirkan Postgres dan lebih berfokus pada DBMS buatan sendiri
Jika memilih opsi kedua, SQLite adalah engine yang nyaman untuk dibangun di atasnya. Tidak sempurna, tetapi itulah alat yang tersedia saat ini
Kesadaran seperti ini menyebar dalam skala besar relatif baru-baru ini, jadi sekarang ada banyak eksperimen untuk mencari tahu apa yang bisa dan tidak bisa dilakukan
Ini adalah siklus alami komputasi, ketika yang lama menjadi baru lagi
Postgres bisa dibaca sebagai MySQL, MSSQL, Oracle, atau DBMS lain
Penyederhanaan seperti ini menguntungkan dalam banyak hal, bukan hanya bagi pengembang SQLite atau perangkat keras kelas rendah, tetapi juga bagi pengembang aplikasi
Dokumentasi menjadi lebih sederhana, kurva belajarnya lebih pendek, permukaan bug dan ukuran biner juga berkurang
Perangkat lunak masa kini cenderung menambahkan kegemukan dan kompleksitas ke segala hal, jadi sangat bagus ada beberapa proyek seperti SQLite yang melawannya
Tulisan yang bagus, dan saya penasaran apakah ada materi serupa untuk Django
ArchiveBox memakai SQLite melalui Django, dan kami cukup sering mengalami masalah yang persis sama seperti yang dijelaskan tulisan itu untuk Rails
Akan menyenangkan jika ada solusi lapisan SQLite yang tidak mengharuskan semua write diserialisasi lewat jalur lain di aplikasi
Gem
sqlite3-rubysecara desain tidak melepaskan GVL saat pemanggilan SQLite, dan jika melihat komentar isu yang ditautkan https://github.com/sparklemotion/sqlite3-ruby/issues/287#iss..., tampaknya ada dugaan bahwa biaya memperoleh kembali lock itu besar, tetapi belum diverifikasiMengingat semua workaround ini, rasanya agak meragukan
Kalau budaya ekstensi Python, sepertinya desainnya akan kebalikan; saya penasaran bagaimana sebenarnya mereka melakukannya di sana
Selain itu, di isu yang ditautkan juga ada komentar bahwa “gem extralite adalah klien SQLite alternatif yang melepaskan GVL saat blocking, dan pembahasan konkurensinya ada di https://github.com/digital-fabric/extralite?tab=readme-ov-fi.... Secara umum gem ini jauh lebih cepat daripada gem tersebut dan tidak punya masalah konkurensi”
Sudah diverifikasi bahwa sekadar melepaskan GVL pada setiap
stepmesin virtual SQLite membuat performa single-thread turun signifikanMenemukan titik tengah antara performa single-thread dan multi-thread itu rumit
Di Rails, kita tahu penggunaannya multi-thread karena ada connection pool, tetapi gem level rendah juga banyak dipakai oleh library dan tool lain di lingkungan single-thread
Beberapa nilai penyetelan yang saya pertahankan untuk layanan web mainan pribadi adalah sebagai berikut
PRAGMA journal_mode = WAL;PRAGMA busy_timeout = 5000;PRAGMA synchronous = NORMAL;PRAGMA cache_size = 1000000000;PRAGMA foreign_keys = true;PRAGMA temp_store = memory;Dan saya menggunakan transaksi BEGIN IMMEDIATE
https://kerkour.com/sqlite-for-servers
cache_sizedanmmap_sizeSaya suka SQLite maupun Rails, tetapi ini terlihat mirip seperti memakai MS Access di lingkungan produksi
Selain itu, komputer dan disk sekarang jauh lebih cepat dibanding puluhan tahun lalu ketika penggunaan Access lebih banyak
Jika didominasi baca, bukan hanya SQLite, Jet pun mungkin bisa mencapai puluhan ribu request per detik dengan cukup mudah
Sebagian besar aplikasi tidak memiliki ratusan ribu pengguna bersamaan, jadi SQLite bisa sangat cocok
Kekuatan SQLite juga terletak pada adanya klien untuk hampir semua platform dan bahasa
Arsip, backup, dan portabilitas juga merupakan penggunaan yang cocok untuk SQLite. Dalam proyek dengan input data yang terbatas pada periode tertentu, saya sangat mendorong penggunaan SQLite per kotak, dan sampai sekarang saya merasa itu akan lebih baik
Alih-alih membuat skema yang rumit serta fitur ekspor/arsip khusus pengguna, kita bisa menyalin satu file saja sebagai arsip atau backup, dan tidak perlu terlalu memikirkan perubahan skema yang terjadi seiring waktu
Tergantung situasinya, tetapi ini solusi yang cukup baik untuk banyak masalah. Mirip seperti bagaimana untuk sebagian besar aplikasi, PostgreSQL atau DBMS relasional lain sering kali menjadi pilihan yang lebih baik daripada opsi NoSQL yang lebih skalabel
Dulu ada kecenderungan untuk melakukan desain berlebihan, dan sekarang performa komputasi serta input/output makin mendekati titik di mana upaya seperti itu semakin sulit dibenarkan
Artikel yang sangat informatif dan ditulis dengan baik
Saya penasaran mengapa metode
busy_timeoutdefault memiliki penundaan eksponensial yang menghukum kueri lamaApa alasan yang membuat ini masuk akal sebagai nilai default?