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

Database per Service

คุณแยกระบบออกเป็นหลาย service ตาม business capability แล้ว ทั้ง orders, inventory, payments, shipping แต่ละตัวมี codebase และทีมของตัวเอง ตอนนี้ถึงคิวตัดสินใจว่าข้อมูลของแต่ละ service จะไปอยู่ที่ไหน ทางที่ง่ายที่สุดคือชี้ทุกตัวไปที่ database เดิมที่มีอยู่แล้ว วิธีนี้ใช้ได้ดีในวันแรก แล้วค่อย ๆ กัดกร่อนทุกอย่างที่การแยกส่วนควรจะให้คุณแบบเงียบ ๆ

พอ service ใช้ database ร่วมกัน ก็เท่ากับใช้ schema ร่วมกันไปด้วย ทีม shipping เพิ่มคอลัมน์ลงตาราง orders แล้ว query ของทีม orders พัง ทีม inventory อยากย้ายตารางจาก PostgreSQL ไป document store แต่ทำไม่ได้ เพราะมีอีกสี่ service อ่านตารางนั้นตรง ๆ อยู่ แล้วรายงานที่รันช้าของ service ตัวเดียวก็ดูด connection pool ที่ทุก service พึ่งพาจนแห้ง

การใช้ database ร่วมกันก็คือการสร้าง monolith ขึ้นมาใหม่ที่ชั้นข้อมูล แรงที่ขัดกันจึงเป็นแบบนี้ คุณจะให้แต่ละ service พัฒนา data model ของตัวเอง เลือกเทคโนโลยีจัดเก็บของตัวเอง และ scale ตามจังหวะของตัวเองได้อย่างไร โดยไม่ให้ service อื่นแอบไปผูกกับตารางภายในของตัวเอง

ให้แต่ละ service เป็นเจ้าของข้อมูลของตัวเองแต่ผู้เดียว โดยเก็บไว้ใน database ส่วนตัว จะแยกเป็น schema แยกเป็น database instance หรือแยกเป็น database server ก็ขึ้นอยู่กับว่าต้องการ isolation แข็งแค่ไหน service อื่นห้ามต่อเข้ามาเด็ดขาด ทางเดียวที่จะอ่านหรือแก้ข้อมูลของ service หนึ่งคือผ่าน API ที่ service นั้น publish ไว้ หรือ consume event ที่ส่งออกมา

database จึงกลายเป็นรายละเอียดภายในของ service ไม่ต่างจากโครงสร้างข้อมูลใน memory ทีมมีอิสระเปลี่ยนตาราง เปลี่ยน index หรือแม้แต่เปลี่ยน database engine ได้ ตราบใดที่ contract ของ API ยังนิ่ง

flowchart TB
  subgraph Order[Order Service]
    OAPI[Order API]
    ODB[(PostgreSQL)]
    OAPI --> ODB
  end
  subgraph Shipping[Shipping Service]
    SAPI[Shipping API]
    SDB[(MongoDB)]
    SAPI --> SDB
  end
  SAPI -- HTTP / gRPC call --> OAPI
  Forbidden[Shipping reaching into Orders DB] -. NOT ALLOWED .-x ODB
  style Forbidden fill:#fee,stroke:#c00,stroke-dasharray: 5 5
service หนึ่งเรียก API ของอีก service ได้ แต่ห้ามต่อเข้า database ของกันและกันเด็ดขาด

pattern นี้เป็นเรื่องโครงสร้างมากกว่าอัลกอริทึม ตัวอย่างที่ชัดที่สุดจึงเป็น configuration ข้างล่างนี้ มีสอง service ที่ต่อสายไว้ให้แต่ละตัวถือ credentials ของ database ตัวเองเท่านั้น สังเกตว่า Shipping service ไม่มี connection string ของ database Orders เลย มีแค่ URL ของ Order service เท่านั้น

# docker-compose excerpt — each service gets its own DB and its own credentials
services:
order-service:
image: shop/order-service:1.0
environment:
DATABASE_URL: postgres://order_user:secret@order-db:5432/orders
# No reference to any other service's database.
order-db:
image: postgres:16
environment:
POSTGRES_DB: orders
POSTGRES_USER: order_user
POSTGRES_PASSWORD: secret
shipping-service:
image: shop/shipping-service:1.0
environment:
DATABASE_URL: mongodb://ship_user:secret@shipping-db:27017/shipping
# To learn about an order, Shipping calls the Order API, not its DB.
ORDER_SERVICE_URL: http://order-service:8080
shipping-db:
image: mongo:7
environment:
MONGO_INITDB_DATABASE: shipping

ขอบเขตนี้ยังเป็นขอบเขตของสิทธิ์ (permissions) ด้วย แม้ว่าใครบางคนจะรู้ hostname ของฐานข้อมูล orders แต่ credentials ก็ไม่มีอยู่ที่ใดเลยนอกเหนือจาก Order service:

order-service --owns--> orders DB (order_user can connect)
shipping-service --owns--> shipping DB (ship_user can connect)
shipping-service --calls--> Order API --> orders DB (allowed, indirect)
shipping-service --X--> orders DB (blocked: no route, no credentials)

สิ่งที่คุณได้รับ:

  • Loose coupling แต่ละทีมเป็นเจ้าของ schema ของตัวเองและ refactor ได้อย่างอิสระ การเปลี่ยนแปลงภายใน database หนึ่งทำให้ service อื่นพังไม่ได้
  • Polyglot persistence แต่ละ service เลือก storage ที่เหมาะกับงานของตัวเองได้ ใช้ relational กับ orders ใช้ document กับ shipping ใช้ search index กับ catalog
  • การ scale อย่างอิสระและการแยกความล้มเหลว (failure isolation). ภาระ query ที่หนักหรือฐานข้อมูลล่มของ service หนึ่งจะไม่ลากทุก service อื่นล้มตามไปอีกต่อไป

สิ่งที่คุณต้องจ่าย:

  • ไม่มี transaction ข้าม service คุณห่อการ insert order กับการลด inventory ไว้ใน atomic commit เดียวไม่ได้ เพราะข้อมูลอยู่คนละ database การกู้ความสอดคล้องข้าม service คืองานของ Saga พอดี
  • ไม่มี join ข้าม service query ที่ต้องใช้ข้อมูล order, customer และ shipping พร้อมกันเขียนเป็น SQL statement เดียวไม่ได้อีกแล้ว ต้องประกอบขึ้นมาด้วย API Composition หรือ CQRS แทน
  • ข้อมูลซ้ำซ้อนบางส่วน. service มัก cache ข้อมูลบางส่วนเล็ก ๆ ที่บริโภคมาจาก service อื่น ซึ่งคุณต้องคอยซิงค์ให้ตรงกัน

ข้อคิดสำคัญคือ Database per Service ไม่ได้แก้ปัญหาความสอดคล้องหรือการ query แต่จงใจตัดทางลัดทิ้ง เพื่อบังคับให้คุณแก้ปัญหาเหล่านั้นอย่างชัดเจนและแยกตาม service และนั่นคือเหตุผลที่โมดูลนี้ยังมีบทเรียนที่เหลือรออยู่

  • Saga — ความสอดคล้องข้ามฐานข้อมูลส่วนตัวโดยไม่ต้องใช้ distributed transaction
  • API Composition — การตอบ query ที่เคยเป็น join
  • CQRS — read model ที่พาดข้าม service ได้อย่างมีประสิทธิภาพ
ข้อดีข้อแลกเปลี่ยน
service เปลี่ยน database tech ได้อิสระquery ข้าม service ต้องใช้ API Composition หรือ event
schema เปลี่ยนโดยไม่กระทบ service อื่นdistributed transaction ซับซ้อน — ต้องใช้ Saga
database ล้มกระทบแค่ service เดียวdata duplication ข้าม service เป็นเรื่องปกติ
scale database แยกตาม load patternoperational cost สูงกว่า — ดูแลหลาย database

Shared Database — หลาย service อ่านเขียน database เดียวกัน อาการ:

  • service A เปลี่ยน schema ทำให้ service B พัง
  • ไม่มี service ไหน “เป็นเจ้าของ” table ชัดเจน
  • deployment ต้อง coordinate ทุก service ที่ใช้ database นั้น

Database per Table — แยก database ละเอียดเกินไปจนไม่คุ้ม อาการ:

  • service เล็กมากมี database ของตัวเองที่มีแค่ 1 table
  • operational overhead สูงกว่าประโยชน์ที่ได้

💡 ตัวอย่างจากของจริง

Amazon:

  • หนึ่งในกฎดังของ Amazon คือ “no shared databases”
  • แต่ละ service เลือก database ที่เหมาะกับ use case: DynamoDB, RDS, ElasticSearch
  • payment service ใช้ RDBMS สำหรับ ACID, recommendation ใช้ graph database

Netflix:

  • Cassandra สำหรับ viewing history (high write throughput)
  • MySQL สำหรับ billing (ACID required)
  • ElasticSearch สำหรับ search catalog
วิธีเดียวที่ถูกต้องที่ service หนึ่งจะอ่านข้อมูลของอีก service หนึ่งได้คืออะไร?
Database per Service เปิดทางให้ประโยชน์ใดได้โดยตรง?
Database per Service เอา join ข้าม service ออกไป pattern ใดที่คืนการ query ข้าม service กลับมา?