• saas
  • multi-tenancy
  • postgres
  • row-level security
  • rls
  • trigger function
  • multi-tenant architecture
  • single-tenant architecture

การใช้งาน Multi-tenancy ด้วย PostgreSQL: เรียนรู้ผ่านตัวอย่างง่ายๆ ในโลกจริง

เรียนรู้วิธีการใช้สถาปัตยกรรม multi-tenant กับ PostgreSQL Row-Level Security (RLS) และบทบาทฐานข้อมูล ผ่านตัวอย่างจริงเพื่อการแยกข้อมูลที่ปลอดภัยระหว่างผู้เช่าแต่ละราย

Yijun
Yijun
Developer

ในบางบทความก่อนหน้า เราเจาะลึกถึงแนวคิดของ multi-tenancy และการประยุกต์ใช้ในผลิตภัณฑ์และสถานการณ์ธุรกิจในโลกจริง

ในบทความนี้ เราจะสำรวจวิธีการใช้สถาปัตยกรรม multi-tenant สำหรับแอปพลิเคชันของคุณโดยใช้ PostgreSQL จากมุมมองทางเทคนิค

สถาปัตยกรรม single-tenant คืออะไร?

สถาปัตยกรรม single-tenant หมายถึงสถาปัตยกรรมซอฟต์แวร์ที่แต่ละลูกค้ามี instance ของแอปพลิเคชันและฐานข้อมูลที่แยกเป็นของตนเอง

ในสถาปัตยกรรมนี้ ข้อมูลและทรัพยากรของผู้เช่าแต่ละรายจะแยกออกจากกันอย่างสิ้นเชิง

Single-tenancy

สถาปัตยกรรม multi-tenant คืออะไร?

สถาปัตยกรรม multi-tenant คือสถาปัตยกรรมซอฟต์แวร์ที่มีผู้ใช้หลายคน (ผู้เช่า) ใช้แอปพลิเคชัน instance และโครงสร้างพื้นฐานเดียวกัน แต่ข้อมูลที่ใช้ยังคงแยกกันอยู่ ในสถาปัตยกรรมนี้ ซอฟต์แวร์ instance หนึ่งจะให้บริการแก่ผู้เช่าหลายราย โดยแต่ละรายจะรักษาข้อมูลที่แยกจากกันผ่านกลไกแยกต่าง ๆ

Multi-tenancy

สถาปัตยกรรม single-tenant vs สถาปัตยกรรม multi-tenant

สถาปัตยกรรม single-tenant และ multi-tenant แตกต่างกันในแง่ต่าง ๆ เช่น การแยกข้อมูล การใช้ทรัพยากร การขยายตัว การจัดการและบำรุงรักษา และความปลอดภัย

ในสถาปัตยกรรม single-tenant แต่ละลูกค้ามีพื้นที่ข้อมูลที่แยกเป็นของตนเอง ทำให้การใช้งานทรัพยากรต่ำ แต่เป็นที่นิยมในการปรับแต่ง ในการใช้งานซอฟต์แวร์ single-tenant มักจะปรับให้ตรงกับความต้องการของลูกค้า เช่น ระบบจัดการสินค้าสำหรับผู้จำหน่ายผ้าหรือแอพบล็อกส่วนตัว สิ่งที่คล้ายกันคือแต่ละลูกค้ามี instance ของเซอร์วิสแอพที่แยกออก ทำให้สะดวกในการปรับแต่งตามความต้องการเฉพาะ

ในสถาปัตยกรรม multi-tenant, ผู้เช่าหลายรายใช้ทรัพยากรร่วมกัน, ทำให้การใช้ทรัพยากรสูงขึ้น อย่างไรก็ตาม จำเป็นต้องมั่นใจในเรื่องของการแยกและความปลอดภัยของข้อมูล

สถาปัตยกรรม multi-tenant มักเป็นที่ต้องการเมื่อนักให้บริการเสนอเซอร์วิสมาตรฐานไปยังลูกค้าต่าง ๆ ซึ่งเซอร์วิสมักจะมีการปรับแต่งน้อย และลูกค้าทั้งหมดใช้แอพพลิเคชั่น instance เดียวกัน เมื่อแอพต้องการอัปเดต การอัปเดต instance แอพพลิเคชั่นเพียง instance เดียวเท่ากับการอัปเดตแอพสำหรับลูกค้าทั้งหมด เช่น ระบบ CRM (Customer Relationship Management) ซึ่งเป็นการเปรียบเทียบมาตรฐาน ความต้องการของระบบเหล่านี้คือการใช้สถาปัตยกรรม multi-tenant เพื่อให้บริการเดียวกันแก่ผู้เช่าทั้งหมด

กลยุทธ์การแยกข้อมูลของผู้เช่าในสถาปัตยกรรม multi-tenant

ในสถาปัตยกรรม multi-tenant ผู้เช่าทุกคนใช้ทรัพยากรพื้นฐานร่วมกัน ทำให้การแยกทรัพยากรระหว่างผู้เช่าเป็นเรื่องสำคัญ การแยกนี้ไม่จำเป็นต้องเป็นการแยกทางกายภาพ เพียงแต่ต้องมั่นใจว่าทรัพยากรระหว่างผู้เช่าไม่ได้มีความสามารถในการมองเห็นหรือเข้าถึงกัน

ในออกแบบสถาปัตยกรรม สามารถทำการแยกระดับทรัพยากรระหว่างผู้เช่าได้หลายระดับ:

Isolated to shared

โดยทั่วไปแล้ว ยิ่งแชร์ทรัพยากรระหว่างผู้เช่ามากเท่าใด ค่าใช้จ่ายในการพัฒนาระบบและการบำรุงรักษาก็จะต่ำลง ในทางตรงกันข้าม ยิ่งแชร์ทรัพยากรน้อย ค่าใช้จ่ายก็จะสูงขึ้น

เริ่มต้นการใช้งาน multi-tenant ด้วยตัวอย่างจริง

ในบทความนี้ เราจะใช้ระบบ CRM เป็นตัวอย่างเพื่อแนะนำสถาปัตยกรรม multi-tenant ที่เรียบง่ายแต่มีความสามารถ

เราตระหนักดีว่าผู้เช่าทั้งหมดใช้เซอร์วิสมาตรฐานเดียวกัน ดังนั้นเราจึงตัดสินใจให้ผู้เช่าทั้งหมดแชร์ทรัพยากรพื้นฐานเดียวกัน และเราจะทำการแยกข้อมูลระหว่างผู้เช่าต่าง ๆ ที่ระดับฐานข้อมูลโดยใช้ Row-Level Security ของ PostgreSQL

นอกจากนี้ เรายังจะสร้างการเชื่อมต่อข้อมูลแยกเป็นสำหรับแต่ละผู้เช่าเพื่อการจัดการสิทธิ์การใช้งานของผู้เช่าได้ดียิ่งขึ้น

ต่อไป เราจะอธิบายวิธีการใช้งานสถาปัตยกรรม multi-tenant นี้

วิธีการใช้ multi-tenant architecture กับ PostgreSQL

เพิ่มตัวระบุผู้เช่าสำหรับทุกทรัพยากร

ในระบบ CRM ของเรา จะมีทรัพยากรมากมายและเก็บในตารางต่าง ๆ เช่น ข้อมูลลูกค้าเก็บอยู่ในตาราง customers

ก่อนการใช้งาน multi-tenancy ทรัพยากรเหล่านี้ไม่ได้เชื่อมโยงกับผู้เช่าใด ๆ

เพื่อแยกความแตกต่างของผู้เช่าซึ่งเป็นเจ้าของทรัพยากรต่างๆ เราแนะนำให้มีตาราง tenants เพื่อเก็บข้อมูลผู้เช่า (ที่ db_user และ db_user_password ใช้เก็บข้อมูลการเชื่อมต่อฐานข้อมูลของแต่ละผู้เช่า จะอธิบายรายละเอียดด้านล่าง) นอกจากนี้เรายังเพิ่มฟิลด์ tenant_id ในแต่ละทรัพยากรเพื่อระบุว่ามันเป็นของผู้เช่ารายใด:

ตอนนี้ แต่ละทรัพยากรมีการเชื่อมโยงกับ tenant_id ซึ่งในทางทฤษฎีทำให้เราสามารถเพิ่มคำสั่ง where ไปยังคำสั่ง query เพื่อจำกัดการเข้าถึงทรัพยากรของแต่ละผู้เช่า:

ในตอนแรก ดูเหมือนง่ายและเป็นไปได้ แต่จะมีปัญหาอื่นๆ ที่จะเกิดขึ้น:

  • เกือบทุกคำสั่ง query จะต้องมีคำสั่ง where นี้ ทำให้โค้ดยุ่งเหยิงและยากต่อการบำรุงรักษา โดยเฉพาะเมื่อเขียนคำสั่ง join ที่ซับซ้อน
  • นักพัฒนาใหม่ที่เข้ามาอาจลืมที่จะเพิ่มคำสั่ง where นี้ได้ง่ายๆ
  • ข้อมูลระหว่างผู้เช่าต่างกันยังไม่แยกกันจริง เพราะแต่ละผู้เช่ายังมีสิทธิ์เข้าถึงข้อมูลที่เป็นของผู้เช่าอื่นได้

ดังนั้นเราจะไม่ใช้วิธีนี้ แต่จะใช้ PostgreSQL' Row Level Security เพื่อจัดการกับข้อกังวลเหล่านี้ อย่างไรก็ตาม ก่อนดำเนินการต่อ เราจะสร้างบัญชีฐานข้อมูลแยกเฉพาะผู้เช่าแต่ละคนเท่านั้นเพื่อเข้าถึงฐานข้อมูลที่ร่วมกันนี้

ตั้งค่า DB roles สำหรับผู้เช่า

มันเป็นการปฏิบัติที่ดีที่จะกำหนดบทบาทฐานข้อมูลให้กับผู้ใช้แต่ละคนที่สามารถเชื่อมต่อกับฐานข้อมูลได้ สิ่งนี้อนุญาตให้มีการควบคุมการเข้าถึงของผู้ใช้แต่ละคนได้ดีขึ้น สิ่งนี้ช่วยให้มีการแยกการดำเนินการระหว่างผู้ใช้ต่าง ๆ และปรับปรุงความมั่นคงและความปลอดภัยของระบบ

เพราะผู้เช่าทั้งหมดมีสิทธิ์การดำเนินการพื้นฐานของฐานข้อมูลเหมือนกัน เราสามารถสร้างบทบาทพื้นฐานเพื่อจัดการสิทธิ์เหล่านี้ได้:

จากนั้นเพื่อต่างบทย่อยจากแต่ละบทที่สืบทอดจากบทบาทพื้นฐาน จะมีบทบาทที่สืบทอดจากบทบาทพื้นฐานพร้อมการระบุของผู้เช่าเมื่อมีการสร้าง:

ถัดไป ข้อมูลการเชื่อมต่อฐานข้อมูลสำหรับผู้เช่าแต่ละคนจะถูกเก็บในตาราง tenants:

iddb_userdb_user_password
x2euiccrm_tenant_x2euicpa55w0rd

กลไกนี้ให้ผู้เช่าแต่ละคนมีบทบาทฐานข้อมูลของตัวเอง และบทบาทเหล่านี้จะใช้สิทธิ์ที่มอบให้กับบทบาท crm_tenant

เราสามารถกำหนดขอบเขตของสิทธิ์สำหรับผู้เช่าผ่านบทบาท `crm_tenant':

  • ผู้เช่าต้องมีสิทธิ์ CRUD ในตารางทรัพยากรทั้งหมดของระบบ CRM
  • ตารางที่ไม่ได้เกี่ยวข้องกับทรัพยากรระบบ CRM ควรมองไม่เห็นสำหรับผู้เช่า (ถือว่ามีเฉพาะตาราง systems)
  • ผู้เช่าไม่ควรมีสิทธิ์เปลี่ยนแปลงตาราง tenants และควรเห็นเฉพาะฟิลด์ id และ db_user เท่านั้นเพื่อใช้สอบถามเมื่อทำการดำเนินการฐานข้อมูล

เมื่อบทบาทของผู้เช่าถูกตั้งค่าแล้ว เมื่อผู้เช่าต้องการเข้าสู่บริการ เราสามารถติดต่อฐานข้อมูลโดยใช้บทบาทฐานข้อมูลที่แสดงถึงผู้เช่านั้น ๆ ได้

ป้องกันข้อมูลของผู้เช่าโดยใช้ PostgreSQL Row-Level Security

หลังจากนี้ เราได้กำหนดบทบาทฐานข้อมูลสำหรับผู้เช่าแต่ละคนแล้ว แต่สิ่งนี้ยังไม่จำกัดการเข้าถึงข้อมูลระหว่างผู้เช่า ตอนนี้เราจะใช้คุณสมบัติของ PostgreSQL คือ Row-Level Security เพื่อจำกัดการเข้าถึงข้อมูลของผู้เช่าแต่ละคนเฉพาะข้อมูลของตัวเอง

ใน PostgreSQL ตารางสามารถมี นโยบายความปลอดภัยระดับแถว ที่ควบคุมแถวที่สามารถเข้าถึงได้โดยคำสั่ง query หรือสามารถแก้ไขได้โดยคำสั่งการจัดการข้อมูล ฟีเจอร์นี้ยังรู้จักกันในชื่อ RLS (Row-Level Security)

โดยปกติแล้ว ตารางไม่มีนโยบายความปลอดภัยระดับแถว หากต้องการใช้ RLS คุณจำเป็นต้องเปิดใช้งานมันสำหรับตารางและสร้างนโยบายความปลอดภัยที่จะดำเนินการทุกครั้งเมื่อตารางถูกเข้าถึง

ตัวอย่างที่ใช้ตาราง customers ในระบบ CRM เราจะเปิดใช้งาน RLS และสร้างนโยบายความปลอดภัยเพื่อจำกัดการเข้าถึงข้อมูลของลูกค้าเฉพาะข้อมูลของผู้เช่าเองเท่านั้น:

ในคำสั่งสร้างนโยบายความปลอดภัย:

  • สำหรับทั้งหมด (ไม่จำเป็น) ระบุนโยบายการเข้าถึงนี้จะถูกใช้สำหรับการปฏิบัติการ select, insert, update, และ delete บนตาราง คุณสามารถระบุให้ for ตามด้วยคำหลักของคำสั่งเพื่อใช้นโยบายการเข้าถึงสำหรับการปฏิบัติการเฉพาะ
  • ถึง crm_tenant ระบุนโยบายนี้ใช้กับผู้ใช้ที่มีบทบาทฐานข้อมูล crm_tenant ซึ่งหมายถึงผู้เช่าทุกคน
  • แบบ restrict ระบุโหมดการบังคับของนโยบาย หมายถึงการเข้าถึงควรถูกจำกัดอย่างเข้มงวด ค่าเริ่มต้น ตารางสามารถมีนโยบายได้โดยที่หลายนโยบายจะถูกรวมเข้ากับความสัมพันธ์ของ OR สำหรับผู้ใช้ที่อยู่ในระบบ CRM
  • เงื่อนไข using กำหนดความจำเป็นในการเข้าถึงจริง โดยจำกัดผู้ใช้ฐานข้อมูลที่ทำการ query ปัจจุบันให้ดูเฉพาะข้อมูลที่เป็นของผู้เช่าเดียวกัน ข้อจำกัดนี้ใช้กับแถวข้อมูลที่ถูกเลือกโดยคำสั่ง (select, update, หรือ delete)
  • เงื่อนไข with check กำหนดข้อจำกัดที่จำเป็นเมื่อต้องแก้ไขแถวข้อมูล (insert หรือ update), ประกันให้ผู้เช่าสามารถเพิ่มหรืออัพเดตข้อมูลได้แต่เพียงของตนเอง

การใช้ RLS ในการจำกัดการเข้าถึงของผู้เช่าในตารางทรัพยากรของเรามีข้อดีหลายประการ:

  • นโยบายนี้มีผลในการเพิ่ม where tenant_id = (select id from tenants where db_user = current_user) ลงในปฏิบัติการ query ทั้งหมด (select, update, หรือ delete) ได้อย่างมีประสิทธิภาพ สำหรับตัวอย่าง เมื่อต้องใช้ select * from customers, มันเท่ากับการใช้ select * from customers where tenant_id = (select id from tenants where db_user = current_user) สิ่งนี้ขจัดความจำเป็นในการเพิ่มเงื่อนไข where ในโค้ดของแอปพลิเคชัน ลดความซับซ้อนของมันและลดความเสี่ยงของข้อผิดพลาด
  • มันควบคุมการเข้าถึงข้อมูลส่วนกลางระหว่างผู้เช่าต่าง ๆ ที่ระดับฐานข้อมูล ลดความเสี่ยงของช่องโหว่หรือข้อผิดพลาดในแอปพลิเคชัน เพิ่มความปลอดภัยของระบบให้ดีขึ้น

อย่างไรก็ตาม มีจุดที่ต้องคำนึงถึง:

  • นโยบาย RLS จะถูกดำเนินการสำหรับทุกแถวข้อมูล หากเงื่อนไข query ภายในนโยบาย RLS ซับซ้อนเกินไป มันอาจมีผลกระทบต่อประสิทธิภาพของระบบ โชคดีการตรวจสอบข้อมูลผู้เช่าของเราเป็น query ง่ายและไม่ส่งผลต่อประสิทธิภาพ หากคุณวางแผนที่จะใช้ฟังก์ชันอื่น ๆ โดยใช้ RLS ในภายหลัง คุณสามารถทำตาม คำแนะนำเพื่อปรับปรุงประสิทธิภาพของ RLS จาก Supabase เพื่อเพิ่มประสิทธิภาพ RLS
  • นโยบาย RLS ไม่เติม tenant_id โดยอัตโนมัติระหว่างการใช้งาน insert. มันแค่จำกัดให้ผู้เช่าใส่ข้อมูลของตนเองเท่านั้น นี่หมายความว่าเมื่อต้องใส่ข้อมูลใหม่ เรายังคงต้องให้ ID ผู้เช่าซึ่งไม่ได้สอดคล้องกับกระบวนการ query และอาจทำให้เกิดความสับสนในระหว่างการพัฒนา เพิ่มโอกาสของข้อผิดพลาด (สิ่งนี้จะได้รับการแก้ไขในขั้นตอนถัดไป)

นอกจากตาราง customers แล้ว เรายังต้องนำกระบวนการเดียวกันนี้มาใช้กับทรัพยากรของระบบ CRM ทั้งหมด (ขั้นตอนนี้อาจทำให้รู้สึกวุ่นวาย แต่เราสามารถเขียนโปรแกรมเพื่อตั้งค่าได้ในระหว่างการเริ่มต้นตาราง) เพื่อแยกแยะข้อมูลจากผู้เช่าที่ต่างกัน

สร้างทริกเกอร์ฟังก์ชันสำหรับการใส่ข้อมูล

ตามที่กล่าวถึงก่อนหน้านี้ RLS (Row-Level Security) อนุญาตให้เราดำเนินการ query โดยไม่ต้องกังวลเกี่ยวกับการมีอยู่ของ tenant_id, เพราะฐานข้อมูลจัดการให้เอง อย่างไรก็ตาม สำหรับการใส่ข้อมูล (insert operations) เรายังต้องระบุ tenant_id ให้ชัดเจน

เพื่อให้สะดวกเหมือนความสามารถของ RLS สำหรับการใส่ข้อมูล เรายังต้องการให้ฐานข้อมูลสามารถจัดการ tenant_id โดยอัตโนมัติในระหว่างการใส่ข้อมูล

สิ่งนี้จะมีข้อได้เปรียบที่ชัดเจน: ในระดับการพัฒนาแอปพลิเคชัน เราไม่ต้องพิจารณาว่าข้อมูลเป็นของผู้เช่าใด ซึ่งจะลดโอกาสของข้อผิดพลาดและลดภาระจิตใจของเราเมื่อพัฒนาแอปพลิเคชัน multi-tenant

โชคดี PostgreSQL ให้ความสามารถของฟังก์ชันทริกเกอร์ที่มีความสามารถ

ทริกเกอร์เป็นฟังก์ชันพิเศษที่เชื่อมต่อกับตารางที่ดำเนินการเฉพาะเมื่อการกระทำ (เช่น การใส่, การอัพเดต, หรือการลบ) ถูกรับทำบนตาราง การกระทำเหล่านี้สามารถเรียกใช้ในระดับแถว (สำหรับแต่ละแถว) หรือระดับคำสั่ง (สำหรับแถลงการณ์ทั้งหมด) ด้วยทริกเกอร์ เราสามารถดำเนินการลอจิกกำหนดเองก่อนหรือหลังการปฏิบัติการฐานข้อมูลเฉพาะ ทำให้เราสามารถบรรลุเป้าหมายได้ง่าย

ขั้นแรก ลองสร้างฟังก์ชันทริกเกอร์ set_tenant_id เพื่อดำเนินการก่อนการใส่ข้อมูลแต่ละครั้ง:

จากนั้นเชื่อมโยงฟังก์ชันทริกเกอร์นี้กับตาราง customers สำหรับการใส่ข้อมูล (เหมือนกับการเปิดใช้งาน RLS สำหรับตาราง จะต้องเชื่อมโยงฟังก์ชันทริกเกอร์นี้กับตารางที่เกี่ยวข้องทั้งหมด):

ทริกเกอร์นี้สร้างความมั่นใจว่าข้อมูลที่จะใส่มี tenant_id ที่ถูกต้อง หากข้อมูลใหม่มีอยู่แล้วใน tenant_id, ฟังก์ชันทริกเกอร์จะไม่ทำอะไร มิฉะนั้น มันจะเติม field tenant_id อัตโนมัติตามข้อมูลของผู้ใช้ปัจจุบันในตาราง tenants

ด้วยวิธีนี้ เราจะบรรลุการจัดการ tenant_id อัตโนมัติในระดับฐานข้อมูลในระหว่างการใส่ข้อมูลโดยผู้เช่า

สรุป

ในบทความนี้ เราได้เจาะลึกการใช้จริงของสถาปัตยกรรม multi-tenant โดยใช้ระบบ CRM เป็นตัวอย่างเพื่อแสดงทางออกที่ใช้งานได้โดยใช้ฐานข้อมูล PostgreSQL

เราได้พูดถึงการจัดการบทบาทฐานข้อมูล การควบคุมการเข้าถึง และฟีเจอร์ Row-Level Security ของ PostgreSQL เพื่อมั่นใจถึงการแยกข้อมูลระหว่างผู้เช่า นอกจากนี้เรายังใช้ฟังก์ชันทริกเกอร์เพื่อลดภาระความคิดของนักพัฒนาในการจัดการผู้เช่าที่แตกต่างกัน

นั่นคือทั้งหมดสำหรับบทความนี้ หากคุณต้องการเสริมสร้างแอปพลิเคชัน multi-tenant ของคุณด้วยการจัดการการเข้าถึงของผู้ใช้ คุณสามารถอ้างถึง คู่มือการเริ่มต้นกับ Logto organizations - สำหรับการสร้างแอป multi-tenant สำหรับข้อมูลเพิ่มเติม