1 poin oleh GN⁺ 2025-04-29 | 1 komentar | Bagikan ke WhatsApp
  • Menjelaskan cara membangun arsitektur yang menggunakan database terpisah untuk setiap tenant di Rails serta tantangan yang dihadapi
  • ActiveRecord pada dasarnya dirancang dengan asumsi satu koneksi DB, sehingga perpindahan koneksi per tenant menjadi rumit dan sulit
  • Mengusulkan cara untuk mengganti koneksi secara dinamis saat runtime dengan memanfaatkan fitur connected_to di Rails 6 ke atas
  • SQLite3 cocok untuk menangani banyak DB kecil yang saling independen, sehingga memudahkan backup, debugging, dan penghapusan
  • Menekankan bahwa, berbeda dari infrastruktur Rails yang berkembang dengan fokus pada optimasi sistem besar, arsitektur berbasis database kecil dan independen juga memungkinkan

Alasan menggunakan database terpisah untuk setiap tenant

  • Jika dipisahkan berdasarkan unit tenant (Site) yang bekerja secara independen di dalam model data, isolasi dan pengelolaan data menjadi lebih mudah
  • Jika data disimpan di DB terpisah per tenant, ini lebih menguntungkan untuk skalabilitas situs besar maupun isu keamanan
  • Dengan memanfaatkan SQLite, database dapat dijalankan hanya dengan satu file tanpa konfigurasi server, sehingga sederhana dan fleksibel

Hal yang sulit di Rails

  • Operasi dasar open/close pada SQLite sangat sederhana, tetapi ActiveRecord secara internal memiliki struktur pengelolaan koneksi yang kompleks
  • ActiveRecord dirancang dengan struktur yang mengikat koneksi ke model, sehingga sulit melakukan pergantian tenant saat runtime
  • Connection pool, query cache, dan schema cache semuanya bergantung pada koneksi, sehingga mengganti koneksi setiap saat terasa membebani

Sejarah pengelolaan multi-database di Rails

  • Rails 1: dapat menentukan DB per ActiveRecord::Base
  • Rails 3: memperkenalkan connection pool
  • Rails 4: menambahkan connection_handling
  • Rails 6: memperkenalkan connected_to
  • Rails 7: memperluas fungsi connected_to dan menambahkan dukungan sharding
  • Namun, skenario seperti "menambah/menghapus tenant secara dinamis saat runtime" masih belum didukung secara bawaan

Kelebihan database per tenant

  • Hanya file per tenant yang perlu di-backup atau dipulihkan, sehingga operasional dan debugging menjadi sederhana
  • Menghapus tenant dapat dilakukan hanya dengan menghapus file (unlink)
  • Server database besar dioptimalkan untuk DB berskala puluhan terabyte, sementara SQLite dioptimalkan untuk ribuan DB kecil
  • Bahkan iCloud juga mengadopsi arsitektur yang menyimpan jutaan DB SQLite kecil di atas Cassandra

Proses pemecahan masalah

  • Cara lama (establish_connection manual) memicu error ConnectionNotEstablished di lingkungan dengan banyak koneksi
  • Disesuaikan dengan pendekatan setelah Rails 6, struktur diubah agar Rails yang menangani alih-alih mengelola connection pool secara manual
  • Membuat connection pool secara dinamis untuk setiap tenant, lalu membungkus pekerjaan dengan blok connected_to
  • Ditingkatkan dengan pendekatan yang menggunakan middleware untuk menyiapkan dan melepas koneksi DB yang diperlukan secara dinamis saat request diproses

Pola kode inti

  • Periksa connection pool, lalu buat jika belum ada
MUX.synchronize do  
  if ActiveRecord::Base.connection_handler.connection_pool_list(role_name).none?  
    ActiveRecord::Base.connection_handler.establish_connection(database_config_hash, role: role_name)  
  end  
end  
  • Setelah terhubung, jalankan query dengan aman di dalam blok connected_to
ActiveRecord::Base.connected_to(role: role_name) do  
  pages = Page.order(created_at: :desc).limit(10)  
end  

Penanganan streaming Rack

  • Jika respons Rack berupa streaming, koneksi ditutup dengan aman menggunakan Rack::BodyProxy dan Fiber untuk mengelola koneksi
connected_to_context_fiber = Fiber.new do  
  ActiveRecord::Base.connected_to(role: role_name) do  
    Fiber.yield  
  end  
end  
connected_to_context_fiber.resume  
  
status, headers, body = @app.call(env)  
body_with_close = Rack::BodyProxy.new(body) { connected_to_context_fiber.resume }  
  
[status, headers, body_with_close]  

Struktur middleware akhir

  • Menulis middleware Shardine::Middleware yang pada setiap request mencari koneksi DB yang sesuai, beralih dengan connected_to, lalu membersihkan setelah respons selesai
  • Dapat diterapkan di file config.ru proyek Rails seperti berikut
use Shardine::Middleware do |env|  
  site_name = env["SERVER_NAME"]  
  {adapter: "sqlite3", database: "sites/#{site_name}.sqlite3"}  
end  

Tugas yang masih tersisa

  • Di ActiveRecord 6, fitur shard belum dimanfaatkan, tetapi pada versi berikutnya pemisahan baca/tulis juga dimungkinkan
  • Fungsi pembersihan connection pool saat tenant dihapus belum diimplementasikan karena belum diperlukan
  • Ke depan, arsitektur yang menangani "banyak database kecil" kemungkinan akan semakin mendapat perhatian

1 komentar

 
GN⁺ 2025-04-29
Komentar Hacker News
  • Menggunakan pendekatan "database-per-tenant" dengan sekitar 1 juta pengguna

    • Pendekatan ini cocok untuk aplikasi yang berfokus pada baca, dan sebagian besar tenant berukuran kecil serta tidak memiliki banyak record di tabel, sehingga join yang kompleks pun tetap sangat cepat
    • Masalah utamanya adalah setiap database harus dimigrasikan satu per satu, sehingga waktu rilis bisa meningkat secara signifikan
    • Jika terjadi drift skema atau data, rilis bisa terhenti, dan harus dicari tahu mengapa fitur tidak berfungsi pada sebagian tenant
  • Menyukai SQLite, tetapi bertanya-tanya apakah database OLTP tradisional perlu meng-unload sebagian indeks dari memori

    • Dengan database per pengguna, tidak ada apa pun yang disimpan di memori untuk pengguna tidak aktif atau pengguna yang hanya aktif di instance lain
    • Ini mirip dengan situasi JSON di Mongo, dan Postgres dua kali lebih cepat daripada Mongo
  • Kebanyakan orang tidak memerlukan database per tenant, dan ini bukan pendekatan umum

    • Ada kasus-kasus tertentu yang dapat menutupi kekurangannya, seperti migrasi dan drift skema
    • Hanya karena bisa digunakan bukan berarti harus digunakan
    • Lanjutkan dengan hati-hati dan pastikan memang membutuhkan database per tenant
  • Sebagai pendekatan menengah, hal berikut bisa dipertimbangkan

    • Mengidentifikasi N tenant teratas
    • Memisahkan DB untuk tenant-tenant ini
    • N teratas ditentukan berdasarkan IOPS, tingkat kepentingan (dari sisi pendapatan), dan sebagainya
    • Model data harus dirancang agar dapat mengekstrak baris yang sesuai untuk tiap tenant
  • Kebetulan sedang mengerjakan FeebDB untuk Elixir

    • Ini bisa dianggap sebagai pengganti Ecto, dan tidak bekerja dengan baik ketika ada ribuan database
    • Awalnya dimulai sebagai eksperimen yang menyenangkan, tetapi arsitektur seperti ini akan sangat membantu di semua tempat saya pernah bekerja sebelumnya
    • Tujuannya adalah menghilangkan atau mengurangi masalah umum dari pendekatan database-tenant
    • Menjamin penulis tunggal untuk setiap database
    • Manajemen koneksi yang lebih baik untuk semua tenant
    • Dukungan migrasi dan backup saat diperlukan
    • Dukungan operasi map/reduce/filter pada banyak DB
    • Dukungan deployment klaster
  • Forward Email melakukan hal serupa dengan menggunakan sqlite db terenkripsi untuk setiap mailbox/pengguna

    • Ini adalah cara yang bagus untuk membedakan perlindungan per pengguna
  • Namanya sangat bagus. Mengingatkan pada Sean Connery

  • Workflow "database per tenant" sekarang baru mulai

    • James Edward Gray membahas hal ini di RailsConf 2012
  • Pernah menggunakan sesuatu yang mirip di masa lalu, dan sangat puas

    • Jika pengguna menginginkan datanya, seluruh database bisa diberikan
    • Jika pengguna menghapus akun, cukup tangani dengan rm username.sql
    • Compliance menjadi sangat mudah
  • Sulit membuat desain yang salah ketika data saling terisolasi dan tidak ada masalah penskalaan dalam satu tenant

    • Hampir semuanya akan bekerja