- อธิบายวิธีสร้างโครงสร้างที่ใช้ ฐานข้อมูลแยกสำหรับแต่ละเทนแนนต์ ใน Rails และความท้าทายระหว่างทาง
- โดยพื้นฐานแล้ว ActiveRecord ถูกออกแบบโดยสมมติให้มีการเชื่อมต่อ DB เดียว จึงทำให้การสลับการเชื่อมต่อแยกตามเทนแนนต์มีความซับซ้อนและยุ่งยาก
- เสนอวิธีใช้ฟีเจอร์ connected_to ของ Rails 6 ขึ้นไปเพื่อ สลับการเชื่อมต่อแบบไดนามิกขณะรันไทม์
- SQLite3 เหมาะกับการจัดการ DB อิสระจำนวนมากขนาดเล็ก ทำให้ สำรองข้อมูล ดีบัก และลบ ได้สะดวก
- เน้นย้ำว่า นอกเหนือจากโครงสร้างพื้นฐานของ Rails ที่พัฒนาโดยเน้นการเพิ่มประสิทธิภาพสำหรับระบบขนาดใหญ่ ก็ยังสามารถสร้างสถาปัตยกรรมที่ยึด ฐานข้อมูลขนาดเล็กและแยกอิสระ เป็นศูนย์กลางได้
เหตุผลที่ใช้ฐานข้อมูลแยกสำหรับแต่ละเทนแนนต์
- หากแยกตามหน่วยเทนแนนต์ (
Site) ที่ทำงานเป็นอิสระภายในโมเดลข้อมูล จะทำให้การแยกข้อมูลและการจัดการง่ายขึ้น
- เมื่อเก็บข้อมูลของแต่ละเทนแนนต์ไว้ใน DB คนละชุด จะได้เปรียบทั้งด้านการขยายไซต์ขนาดใหญ่และประเด็นด้านความปลอดภัย
- การใช้ SQLite ทำให้สามารถใช้งานฐานข้อมูลได้ด้วยไฟล์เพียงไฟล์เดียวโดยไม่ต้องตั้งค่าเซิร์ฟเวอร์ จึงสะดวกและยืดหยุ่น
จุดที่ยากใน Rails
- แม้งาน
open/close พื้นฐานของ SQLite จะง่ายมาก แต่ ActiveRecord ภายในมีโครงสร้างการจัดการการเชื่อมต่อที่ซับซ้อน
- ActiveRecord ถูกออกแบบให้ยึดการเชื่อมต่อไว้กับโมเดล จึงสลับเทนแนนต์ระหว่างรันไทม์ได้ยาก
- connection pool, query cache และ schema cache ต่างก็ผูกอยู่กับการเชื่อมต่อ ทำให้การเปลี่ยนการเชื่อมต่อทุกครั้งเป็นภาระ
ประวัติการจัดการหลายฐานข้อมูลใน Rails
- Rails 1: ระบุ DB ได้ในระดับ
ActiveRecord::Base
- Rails 3: เพิ่ม connection pool
- Rails 4: เพิ่ม
connection_handling
- Rails 6: เพิ่ม
connected_to
- Rails 7: ขยายความสามารถของ
connected_to และรองรับ sharding
- แต่ถึงอย่างนั้น ก็ยังไม่รองรับสถานการณ์อย่าง "เพิ่ม/ลบเทนแนนต์แบบไดนามิกขณะรันไทม์" โดยตรง
ข้อดีของฐานข้อมูลแยกตามเทนแนนต์
- สำรองหรือกู้คืนได้เฉพาะไฟล์ของแต่ละเทนแนนต์ จึงทำให้ การปฏิบัติการและการดีบักง่ายขึ้น
- การลบเทนแนนต์ทำได้ง่ายเพียงลบไฟล์ (
unlink)
- เซิร์ฟเวอร์ฐานข้อมูลขนาดใหญ่ถูกปรับแต่งมาสำหรับ DB ระดับหลายสิบเทราไบต์ แต่ SQLite ถูกปรับให้เหมาะกับ DB ขนาดเล็กหลายพันชุด
- ในการใช้งานจริง iCloud ก็ใช้โครงสร้างที่เก็บ SQLite DB ขนาดเล็กนับล้านชุดไว้บน Cassandra
กระบวนการแก้ปัญหา
- วิธีเดิม (เรียก
establish_connection แบบแมนนวล) ทำให้เกิดข้อผิดพลาด ConnectionNotEstablished ในสภาพแวดล้อมที่มีการเชื่อมต่อหลายรายการ
- จึงปรับให้สอดคล้องกับแนวทางหลัง Rails 6 โดยเปลี่ยนจากการจัดการ connection pool เอง มาเป็น ให้ Rails จัดการแทน
- สร้าง connection pool แบบไดนามิกสำหรับแต่ละเทนแนนต์ และห่อการทำงานด้วยบล็อก
connected_to
- ปรับปรุงเป็นวิธีที่ใช้มิดเดิลแวร์ในการเตรียมและปล่อย DB connection ที่จำเป็นแบบไดนามิกตามจังหวะของคำขอ
แพตเทิร์นโค้ดหลัก
- ตรวจสอบ connection pool แล้วถ้ายังไม่มีจึงสร้าง
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
- หลังเชื่อมต่อแล้ว ให้รันคิวรีอย่างปลอดภัยภายในบล็อก
connected_to
ActiveRecord::Base.connected_to(role: role_name) do
pages = Page.order(created_at: :desc).limit(10)
end
การจัดการ Rack Streaming
- หากการตอบกลับของ Rack เป็นแบบสตรีมมิง จะใช้
Rack::BodyProxy และ Fiber เพื่อปิดการเชื่อมต่ออย่างปลอดภัยระหว่างการจัดการการเชื่อมต่อ
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]
โครงสร้างมิดเดิลแวร์สุดท้าย
- เขียนมิดเดิลแวร์
Shardine::Middleware ที่ค้นหา DB connection ที่เหมาะสมในแต่ละคำขอ สลับด้วย connected_to และจัดการเก็บกวาดเมื่อการตอบกลับเสร็จสิ้น
- สามารถนำไปใช้ในไฟล์
config.ru ของโปรเจกต์ Rails ได้ดังนี้
use Shardine::Middleware do |env|
site_name = env["SERVER_NAME"]
{adapter: "sqlite3", database: "sites/#{site_name}.sqlite3"}
end
งานที่ยังเหลือ
- ใน ActiveRecord 6 ยังไม่ได้ใช้ความสามารถ
shard แต่ในเวอร์ชันถัดไปอาจแยก read/write ได้
- ฟังก์ชันล้าง connection pool เมื่อมีการลบเทนแนนต์ยังไม่ได้ทำ เพราะยังไม่จำเป็น
- ในอนาคต สถาปัตยกรรมที่จัดการ "ฐานข้อมูลขนาดเล็กจำนวนมาก" อาจได้รับความสนใจมากขึ้น
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
กำลังใช้แนวทาง "database-per-tenant" กับผู้ใช้ราว 1 ล้านคน
ชอบ SQLite แต่สงสัยว่าฐานข้อมูล OLTP แบบเดิมจำเป็นต้องเอาบางส่วนของดัชนีออกจากหน่วยความจำหรือไม่
คนส่วนใหญ่ไม่ได้ต้องการฐานข้อมูลแยกตาม tenant และนี่ไม่ใช่วิธีทั่วไป
อาจพิจารณาแนวทางกึ่งกลางดังนี้
บังเอิญว่ากำลังทำ FeebDB สำหรับ Elixir อยู่
Forward Email ทำสิ่งคล้ายกันโดยใช้ sqlite db ที่เข้ารหัสแยกตามแต่ละ mailbox/ผู้ใช้
ชื่อนี้ยอดเยี่ยมมาก ทำให้นึกถึง Sean Connery
เวิร์กโฟลว์แบบ "database per tenant" มีมานานแล้ว
เคยใช้สิ่งที่คล้ายกันมาก่อน และพอใจมาก
rm username.sqlเมื่อข้อมูลถูกแยกจากกัน และไม่มีปัญหาเรื่องการสเกลภายใน tenant เดียว ก็ยากที่จะออกแบบผิดพลาด