1 poin oleh GN⁺ 2024-09-13 | 1 komentar | Bagikan ke WhatsApp
  • 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-ruby 1.6.9, mode transaksi default dapat diubah menjadi IMMEDIATE melalui database.yml, sehingga kegagalan memperoleh write lock bisa ditunggu dan dicoba ulang dengan lebih aman
  • busy_timeout tetap memegang GVL Ruby saat menunggu sehingga merusak paralelisme Puma; jika memakai retry tiap 1 ms dengan sleep di busy_handler untuk 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_BUSY terjadi
  • Ketika aplikasi Rails menerima beban konkuren yang lebih besar, persentase request yang gagal karena SQLITE_BUSY juga 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 IMMEDIATE mencoba 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-ruby mendukung konfigurasi mode transaksi default mulai 1.6.9
    • Rails meneruskan key tingkat atas di database.yml ke inisialisasi database sqlite3-ruby
    • Di database.yml, transaksi SQLite Rails dapat dikonfigurasi agar berjalan dalam mode IMMEDIATE
  • 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_BUSY juga ikut terjadi
  • Angka 5 detik ini sesuai dengan konfigurasi timeout di database.yml, yang dipetakan ke pengaturan busy_timeout SQLite
  • Alih-alih langsung melempar exception BUSY, busy_timeout menunggu selama jumlah milidetik yang ditentukan sambil mencoba memperoleh kembali write lock
    • SQLite mencoba memperoleh kembali lock dengan semacam exponential backoff
    • Exception BUSY hanya 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-ruby tidak 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_handler yang lebih rendah levelnya daripada busy_timeout
    • busy_timeout adalah implementasi busy_handler tertentu yang disediakan SQLite
    • sqlite3-ruby menyediakan binding untuk fungsi C sqlite3_busy_handler, sehingga callback Ruby yang dipanggil saat query masuk antrean dapat dibuat
  • Jika menggunakan Kernel.sleep di 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_timeout SQLite 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-ruby branch 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, delay untuk menjamin read your own writes tidak diperlukan
    • Metode transaction pada adapter ActiveRecord dipatch agar transaksi terhubung ke database writer
  • 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

 
GN⁺ 2024-09-13
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

    • Menurut saya itu cukup masuk akal dan merupakan ide yang bagus. Saya pernah mengimplementasikan pendekatan serupa di beberapa sistem, dan secara umum batching mengurangi overhead per item
      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
    • Bukan berarti SQLite tidak bisa melakukan penulisan. Hanya saja SQLite hanya mendukung satu transaksi tulis pada satu waktu
      Jika Anda sulit mempercayai performa konkurensi transaksi SQLite, Anda bisa menserialisasi semua penulisan pada thread atau proses tertentu
    • Ini bekerja dengan baik di sini dan mengubah pandangan saya tentang SQLite. Hipp juga ikut mengerjakannya, dan SQLite bisa menghasilkan performa yang luar biasa: https://use.expensify.com/blog/scaling-sqlite-to-4m-qps-on-a...
    • Data analitik biasanya berpusat pada penulisan, jadi saya merekomendasikan ClickHouse
      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...
    • Batch write mungkin memang ide yang bagus, tetapi cara terbaik melakukan hal seperti ini dengan SQLite adalah memakai WAL dan memiliki satu writer khusus yang ditetapkan
      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

    • Pertama, saya tidak tahu apakah Richard Hipp akan berpandangan sama tentang peran yang “semula dimaksudkan” untuk SQLite
      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”
    • Pada akhirnya, orang menyadari bahwa “server API” sebenarnya sejak awal adalah DBMS
      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
    • Bukankah UUID bisa disimpan sebagai string atau BLOB, dan tanggal sebagai string atau timestamp integer/real?
      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-ruby secara 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 diverifikasi
    Mengingat 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”

    • Diskusi yang lebih rinci bisa dilihat di https://github.com/sparklemotion/sqlite3-ruby/pull/528 dan https://github.com/digital-fabric/extralite/pull/46
      Sudah diverifikasi bahwa sekadar melepaskan GVL pada setiap step mesin virtual SQLite membuat performa single-thread turun signifikan
      Menemukan 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

    • Saya penasaran bagaimana pandangan Anda tentang cache_size dan mmap_size
  • Saya suka SQLite maupun Rails, tetapi ini terlihat mirip seperti memakai MS Access di lingkungan produksi

    • Tidak sepenuhnya berbeda, tetapi dalam skenario yang didominasi baca, SQLite jauh lebih berkinerja daripada Jet (MS Access)
      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
    • Akan menyenangkan jika ada sesuatu seperti MS Access untuk aplikasi web. Saya sudah mencoba beberapa produk pembuat situs web, tetapi tidak ada yang seintuitif Access
    • Saya penasaran mengapa begitu. Khususnya untuk aplikasi yang berpusat pada baca, batas skalabilitas seperti apa yang dikhawatirkan?
    • Untuk Pieter Levels, cara ini berhasil. Tentu saja, jika jumlah pengguna melewati ambang tertentu, masalah akan muncul
  • Artikel yang sangat informatif dan ditulis dengan baik
    Saya penasaran mengapa metode busy_timeout default memiliki penundaan eksponensial yang menghukum kueri lama
    Apa alasan yang membuat ini masuk akal sebagai nilai default?