ข้าม​ไป​ยัง​เนื้อหา

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 ภายใน​ของ​ตน​อยู่

หลักการ​แก้​คือ​ให้​แต่ละ 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