1 คะแนน โดย GN⁺ 2025-04-29 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • อธิบายวิธีสร้างโครงสร้างที่ใช้ ฐานข้อมูลแยกสำหรับแต่ละเทนแนนต์ ใน 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 ความคิดเห็น

 
GN⁺ 2025-04-29
ความคิดเห็นบน Hacker News
  • กำลังใช้แนวทาง "database-per-tenant" กับผู้ใช้ราว 1 ล้านคน

    • แนวทางนี้เหมาะกับแอปที่เน้นการอ่านเป็นหลัก และ tenant ส่วนใหญ่มีขนาดเล็ก มีเรคคอร์ดในตารางไม่มาก ทำให้แม้แต่การ join ที่ซับซ้อนก็ยังเร็วมาก
    • ปัญหาหลักคือต้องทำ migration ให้ฐานข้อมูลทีละตัว จึงอาจทำให้เวลา release ยาวขึ้นมาก
    • หากเกิด schema หรือ data drift ขึ้น release อาจหยุดชะงัก และต้องไล่หาสาเหตุว่าทำไมฟีเจอร์ถึงไม่ทำงานใน tenant บางราย
  • ชอบ SQLite แต่สงสัยว่าฐานข้อมูล OLTP แบบเดิมจำเป็นต้องเอาบางส่วนของดัชนีออกจากหน่วยความจำหรือไม่

    • ถ้าใช้ฐานข้อมูลแยกตามผู้ใช้ ก็ไม่ต้องเก็บอะไรไว้ในหน่วยความจำเลยสำหรับผู้ใช้ที่ไม่ active หรือผู้ใช้ที่ active อยู่แค่บนอินสแตนซ์อื่น
    • คล้ายกับสถานการณ์ JSON ของ Mongo และ Postgres ก็เร็วกว่า Mongo สองเท่า
  • คนส่วนใหญ่ไม่ได้ต้องการฐานข้อมูลแยกตาม tenant และนี่ไม่ใช่วิธีทั่วไป

    • มีกรณีเฉพาะที่คุ้มจะแลกกับข้อเสียอย่าง migration และ schema drift
    • ใช้งานได้ ไม่ได้แปลว่าจำเป็นต้องใช้เสมอไป
    • ควรทำอย่างระมัดระวัง และต้องมั่นใจว่าคุณจำเป็นต้องใช้ฐานข้อมูลแยกตาม tenant จริง ๆ
  • อาจพิจารณาแนวทางกึ่งกลางดังนี้

    • ระบุ tenant อันดับต้น ๆ จำนวน N ราย
    • แยก DB สำหรับ tenant เหล่านี้
    • ค่า N ตัดสินจาก IOPS, ความสำคัญ (ในแง่รายได้) ฯลฯ
    • โมเดลข้อมูลควรถูกออกแบบให้สามารถดึงแถวที่เป็นของแต่ละ tenant ออกมาได้
  • บังเอิญว่ากำลังทำ FeebDB สำหรับ Elixir อยู่

    • มันอาจมองได้ว่าเป็นตัวแทนของ Ecto และทำงานได้ไม่ดีนักเมื่อมีฐานข้อมูลเป็นพัน ๆ ตัว
    • เดิมทีเริ่มจากการทดลองสนุก ๆ แต่สถาปัตยกรรมแบบนี้น่าจะช่วยได้มากในทุกที่ที่เคยทำงานมา
    • เป้าหมายคือกำจัดหรือลดปัญหาทั่วไปของแนวทาง database-tenant
    • รับประกัน single writer ต่อแต่ละฐานข้อมูล
    • ปรับปรุงการจัดการการเชื่อมต่อสำหรับทุก tenant
    • รองรับ migration และ backup เมื่อต้องการ
    • รองรับงาน map/reduce/filter ข้ามหลาย DB
    • รองรับการ deploy แบบคลัสเตอร์
  • Forward Email ทำสิ่งคล้ายกันโดยใช้ sqlite db ที่เข้ารหัสแยกตามแต่ละ mailbox/ผู้ใช้

    • เป็นวิธีที่ยอดเยี่ยมในการแยกการปกป้องรายผู้ใช้
  • ชื่อนี้ยอดเยี่ยมมาก ทำให้นึกถึง Sean Connery

  • เวิร์กโฟลว์แบบ "database per tenant" มีมานานแล้ว

    • James Edward Gray เคยพูดถึงเรื่องนี้ใน RailsConf ปี 2012
  • เคยใช้สิ่งที่คล้ายกันมาก่อน และพอใจมาก

    • ถ้าผู้ใช้ต้องการข้อมูล ก็สามารถให้ทั้งฐานข้อมูลได้เลย
    • ถ้าผู้ใช้ลบบัญชี ก็จัดการได้ง่าย ๆ ด้วย rm username.sql
    • เรื่อง compliance ก็ง่ายขึ้นมาก
  • เมื่อข้อมูลถูกแยกจากกัน และไม่มีปัญหาเรื่องการสเกลภายใน tenant เดียว ก็ยากที่จะออกแบบผิดพลาด

    • แทบทุกอย่างน่าจะใช้ได้