One Thing To Rule Them All
ฐานข้อมูล/สิ่งเดียวที่ทุก app ใช้ร่วมกันจนกลายเป็นคอขวด
ปัญหาคืออะไร
หัวข้อที่มีชื่อว่า “ปัญหาคืออะไร”“One Thing To Rule Them All” คือ antipattern ที่เกิดขึ้นเมื่อทีมงานตัดสินใจรวมสิ่งที่ควรจะเป็นหลายสิ่งอิสระจากกัน ให้เหลือเพียง “สิ่งเดียว” ที่ทุกส่วนของระบบต้องพึ่งพิงร่วมกัน รูปแบบที่พบบ่อยที่สุดคือ “One Database to Rule Them All” — ฐานข้อมูลเดียวที่ application หรือ microservice หลายสิบตัวเชื่อมต่อเข้าไปอ่าน/เขียนตารางเดียวกันโดยตรง แต่รูปแบบนี้ก็ปรากฏในที่อื่นได้เหมือนกัน เช่น class config กลางตัวเดียวที่ทุก module อ้างอิงตรง ๆ, library กลางที่ทุกทีมต้อง import และแก้ไขร่วมกัน หรือคิวข้อความกลางเพียงคิวเดียวที่ทุก domain ธุรกิจส่ง event ปนกันหมด
หัวใจของปัญหาคือ “สิ่งเดียว” นั้นไม่ได้เป็นของใครเป็นเจ้าของ (no clear ownership) ทุกทีมมีสิทธิ์เข้าถึงเท่ากัน แต่ไม่มีใครมีอำนาจเปลี่ยนแปลงมันได้อย่างอิสระ เพราะการเปลี่ยนแปลงจะกระทบทุกคนที่พึ่งพิงอยู่
ทำไมถึงดูน่าใช้
หัวข้อที่มีชื่อว่า “ทำไมถึงดูน่าใช้”ในช่วงเริ่มต้นของระบบ การมีทรัพยากรกลางเพียงชิ้นเดียวมักดูสมเหตุสมผลมาก:
- เร็วในการพัฒนา — ไม่ต้องตั้งค่าฐานข้อมูล/บริการใหม่ทุกครั้งที่สร้าง app ใหม่ แค่เชื่อมต่อของเดิมที่มีอยู่แล้ว
- ดูเหมือนสอดคล้องกับ DRY/Once and Only Once — “ทำไมต้องมีฐานข้อมูลลูกค้าสองที่ ทั้งที่ควรมีแค่ที่เดียว”
- รายงานและ join ข้าม domain ทำได้ง่าย — เขียน SQL join ข้ามตารางได้ทันทีโดยไม่ต้องเรียก API หลายรอบ
- ประหยัดทรัพยากรโครงสร้างพื้นฐาน — ดูแล server ฐานข้อมูลเดียวง่ายกว่าดูแลหลายสิบตัว
- เป็นเทคโนโลยีที่ทีมคุ้นเคยอยู่แล้ว — ใช้ SQL ตรง ๆ ผ่าน stored procedure แทนที่จะออกแบบ contract ใหม่
ข้อดีเหล่านี้เป็นของจริงในระยะสั้น แต่เป็นภาพลวงตาที่บดบังต้นทุนซึ่งจะค่อย ๆ สะสมขึ้นเรื่อย ๆ เมื่อจำนวน app ที่ใช้ทรัพยากรร่วมนั้นเพิ่มขึ้น
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”- คอขวดด้านประสิทธิภาพ (bottleneck) — เมื่อทุก app ยิง query ไปที่ฐานข้อมูลเดียวกัน การแย่ง connection pool, lock และ deadlock จะเพิ่มขึ้นตามภาระงานรวมของทั้งองค์กร ไม่ใช่แค่ของ app ใด app หนึ่ง
- จุดล้มเหลวเดียว (single point of failure) — ถ้าฐานข้อมูลกลางล่ม ทุกระบบที่พึ่งพิงมันล่มพร้อมกันหมด แม้ระบบเหล่านั้นจะไม่เกี่ยวข้องกันทางธุรกิจเลยก็ตาม
- การเชื่อมโยงแบบซ่อนเร้น (hidden coupling) — แม้ app จะถูกแยก deploy เป็นคนละบริการ แต่ตราบใดที่ schema ตารางเดียวกันถูกใช้ร่วมกัน การเปลี่ยน column 1 column ในทีมหนึ่งอาจทำให้อีกสามทีมพังทันที นี่คือการทำลาย Bounded Context และ Single Responsibility ในระดับสถาปัตยกรรมทั้งระบบ
- deploy ร่วมกันไม่ได้อิสระ — schema migration กลายเป็นเรื่องที่ต้องประสานงานข้ามทีม แทนที่แต่ละบริการจะปล่อย version ใหม่ได้เมื่อพร้อม
- บังคับใช้ security/caching/validation ที่ระดับ application ไม่ได้ — เพราะมีทางเข้าตรงไปที่ตารางเสมอ ต่อให้ออกแบบ business logic ไว้ดีแค่ไหน app อื่นก็ยัง bypass ผ่าน SQL ตรง ๆ ได้อยู่ดี
- ผลกระทบแบบ “หน้าต่างแตก” — เมื่อมีข้อยกเว้นแรกที่ยอมให้ app หนึ่งเข้าถึงตารางของอีก domain หนึ่งโดยตรง มันจะกลายเป็นบรรทัดฐานที่ทีมอื่นทำตาม จนขอบเขตของระบบพร่าเลือนไปเรื่อย ๆ
ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”code ต่อไปนี้แสดงรูปแบบ antipattern ที่พบบ่อย — DbContext ตัวเดียวที่รวมตารางจากหลาย domain ธุรกิจไว้ด้วยกัน แล้วให้ทุกบริการอ้างอิงมันตรง ๆ:
// แบบผิด: DbContext ตัวเดียวที่ "ทุกบริการ" ต้องอ้างอิงตรง ๆpublic class EnterpriseDbContext : DbContext{ public DbSet<Order> Orders { get; set; } public DbSet<Customer> Customers { get; set; } public DbSet<InventoryItem> InventoryItems { get; set; } public DbSet<Invoice> Invoices { get; set; } public DbSet<ShippingLabel> ShippingLabels { get; set; } // ... อีกหลายสิบตารางจากทุก domain ในองค์กร}
// OrderService, BillingService, ShippingService ต่างก็ new ตัวนี้ขึ้นมาตรง ๆpublic class OrderService{ private readonly EnterpriseDbContext _db;
public OrderService(EnterpriseDbContext db) => _db = db;
public void MarkAsPaid(int orderId) { var order = _db.Orders.Find(orderId); order.Status = "Paid";
// แถมยัง "แอบ" ไปแก้ตาราง Invoices ซึ่งเป็นของ BillingService โดยตรง // เพราะ schema อยู่ในฐานเดียวกัน เขียนแบบนี้ง่ายกว่าเรียก API ของทีมอื่น var invoice = _db.Invoices.First(i => i.OrderId == orderId); invoice.PaidOn = DateTime.UtcNow;
_db.SaveChanges(); }}ปัญหาคือ OrderService รู้จักและแก้ไขตาราง Invoices ซึ่งเป็นข้อมูลภายในของ BillingService โดยตรง ถ้าวันหนึ่งทีม Billing ต้องการเปลี่ยนโครงสร้างตาราง Invoices (เช่น แยกเป็นหลายตารางเพื่อรองรับการแบ่งงวดชำระเงิน) code ของ OrderService จะพังทันทีโดยที่ทีม Billing ไม่รู้ตัวด้วยซ้ำว่ามีใครมาพึ่งพิง schema ภายในของตนอยู่
ทางแก้และการ refactor
หัวข้อที่มีชื่อว่า “ทางแก้และการ refactor”หลักการแก้คือให้แต่ละ Bounded Context/บริการ เป็นเจ้าของข้อมูลของตนเอง แล้วเปิดเผยข้อมูลนั้นผ่าน contract ที่ชัดเจน (API, event, message) แทนที่จะให้เข้าถึง schema กันตรง ๆ แนวทางนี้ตรงกับสิ่งที่ชุมชน microservice เรียกว่า “database per service”
flowchart LR
subgraph Before
OrderSvc1 --> SharedDB
BillingSvc1 --> SharedDB
ShippingSvc1 --> SharedDB
end
subgraph After
OrderSvc2 --> OrderDB
BillingSvc2 --> BillingDB
ShippingSvc2 --> ShippingDB
OrderSvc2 --> BillingSvc2
BillingSvc2 --> ShippingSvc2
end
ฝั่งซ้ายคือสภาพเดิมที่ทุกบริการยิงตรงเข้าฐานข้อมูลกลาง ฝั่งขวาคือหลัง refactor — แต่ละบริการมีฐานข้อมูลเป็นของตัวเอง และสื่อสารข้ามขอบเขตผ่าน API/สัญญาที่ชัดเจนแทน
// แต่ละบริการมี DbContext/schema เป็นของตัวเอง เห็นเฉพาะตารางในขอบเขตของตนpublic class OrderDbContext : DbContext{ public DbSet<Order> Orders { get; set; }}
public class OrderService{ private readonly OrderDbContext _db; private readonly IBillingClient _billing; // เรียกผ่าน API/message ไม่ใช่ query ตรง
public OrderService(OrderDbContext db, IBillingClient billing) { _db = db; _billing = billing; }
public async Task MarkAsPaidAsync(int orderId) { var order = await _db.Orders.FindAsync(orderId); order.Status = "Paid"; await _db.SaveChangesAsync();
// แจ้ง BillingService ผ่าน contract ที่ตกลงกันไว้ แทนการแก้ตารางของมันตรง ๆ await _billing.MarkInvoicePaidAsync(new MarkInvoicePaidRequest(orderId, DateTime.UtcNow)); }}แนวทางปฏิบัติที่ใช้ประกอบกันได้:
- Database per service — แยก schema/instance ฐานข้อมูลตามขอบเขตความรับผิดชอบ ให้สอดคล้องกับ SRP ในระดับระบบ ไม่ใช่แค่ระดับ class
- Anti-Corruption Layer — เมื่อยังต้องพึ่งฐานข้อมูล legacy อยู่ระหว่างการย้ายระบบ ให้ห่อการเข้าถึงไว้หลัง Repository หรือชั้นแปลข้อมูล เพื่อไม่ให้ model ภายในรั่วไหลออกไป
- สื่อสารข้ามขอบเขตด้วย API/event — ใช้ Domain Events หรือ message queue แทนการ join ข้ามฐานข้อมูล เพื่อให้แต่ละบริการปล่อย version ใหม่ได้อิสระจากกัน
- แก้ปัญหา query ข้ามบริการด้วย CQRS — สร้าง read model/materialized view ที่รวมข้อมูลจากหลายบริการไว้ต่างหาก แทนที่จะเปิดให้ join ข้ามฐานข้อมูลจริงโดยตรง
- ย้ายทีละส่วนแบบค่อยเป็นค่อยไป — ถ้าฐานข้อมูลกลางฝังลึกอยู่แล้ว ให้ใช้แนวทาง Strangler Fig แกะทีละตาราง/ทีละ domain แทนการ big-bang migration