CQRS
query ข้าม service ของคุณเริ่มจาก API composition ง่าย ๆ และช่วงแรกก็ไปได้ดี จากนั้น query ก็เริ่มโหด ทั้ง dashboard ที่โชว์ order มูลค่าสูงสุดรายภูมิภาค ช่อง search ที่ filter ลูกค้าตามยอดใช้จ่ายข้ามหลาย service และรายงานที่ sort กับแบ่งหน้าจากข้อมูลนับล้าน row การ compose ของพวกนี้ตอน request แปลว่าต้องดึงผลลัพธ์ก้อนมหึมาจากแต่ละ service มา join ใน memory ทุกครั้งที่โหลดหน้า ซึ่งช้าเกินไป แถมยังไปพึ่ง service ที่ไม่เคยถูกออกแบบมาตอบคำถามแบบนั้นเลย
โมเดลข้อมูลเดียวกันมักไม่ได้รับใช้ทั้งสองงานได้ดี write model ถูก normalize และจัดรูปร่างเพื่อบังคับใช้กฎทางธุรกิจทีละ record query ที่เรียกร้องสูงต้องการข้อมูลที่ pre-join ไว้ denormalize ทำ index และบางครั้งก็พาดข้ามสิ่งที่หลาย service เป็นเจ้าของ การบังคับให้โมเดลเดียวทำทั้งสองอย่างทำให้การเขียนเก้กังและการอ่านช้า
คุณจะรับใช้ query ที่แพง พาดข้ามหลายด้าน หรือมีรูปร่างแปลก ๆ ได้อย่างมีประสิทธิภาพได้อย่างไร โดยไม่บิดเบือนโมเดลที่จัดการการเขียนของคุณ?
วิธีแก้
หัวข้อที่มีชื่อว่า “วิธีแก้”CQRS ย่อจาก Command Query Responsibility Segregation คือการแยกโมเดลออกเป็นสองฝั่ง ฝั่ง command ดูแลการเขียน เป็นเจ้าของสถานะต้นฉบับ บังคับใช้ invariant และ publish domain event ออกมาทุกครั้งที่มีการเปลี่ยนแปลง ส่วน ฝั่ง query ดูแล read model หนึ่งตัวหรือหลายตัว ที่เป็น view แบบ denormalized ที่ปั้นรูปร่างมาให้ตรงกับ query ที่ต้องรับใช้พอดี read model สร้างขึ้นด้วยการ subscribe event เหล่านั้นแล้ว project ลงที่จัดเก็บของตัวเอง เวลา query ก็ยิงเข้า view ที่ pre-join ไว้แล้วตรง ๆ
read model จะใช้เทคโนโลยีอะไรก็ได้ที่เหมาะกับ query ไม่ว่าจะเป็นตาราง SQL แบบ denormalized, document store หรือ search index และรวม event จากหลาย service มาไว้ใน view เดียวได้ด้วย นี่แหละคือเหตุผลที่ CQRS เก่งเรื่อง query ข้าม service มาก
flowchart LR Client[Client] -->|command| CmdSide[Command Side] CmdSide --> WDB[(Write Model)] CmdSide -->|domain events| Bus[(Event Bus)] Bus --> Proj[Projection / Event Handler] Proj --> RDB[(Read Model - denormalized)] Client -->|query| QSide[Query Side] QSide --> RDB
ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”ด้านล่างนี้ command handler อัปเดตสถานะที่เป็นต้นฉบับและส่ง event ออกมา projection แยกต่างหากบริโภค event นั้นและ upsert row แบบ denormalized ฝั่ง query เพียงแค่อ่าน row นั้น สองครึ่งนี้ไม่เคยใช้โมเดลร่วมกันเลย
// --- Command side: handle a write, emit an event ---async function placeOrder(cmd: { orderId: string; customerId: string; total: number }) { await writeDb.insertOrder(cmd.orderId, cmd.customerId, cmd.total, 'CONFIRMED'); await eventBus.publish({ type: 'OrderPlaced', orderId: cmd.orderId, customerId: cmd.customerId, total: cmd.total, });}
// --- Query side: a projection keeps a denormalized read model in sync ---eventBus.on('OrderPlaced', async (e) => { const customer = await customerSvc.getCustomer(e.customerId); await readDb.upsertOrderSummary({ orderId: e.orderId, total: e.total, customerName: customer.name, // pre-joined at projection time });});
// A query is now a single fast lookup against the read model.const topOrders = () => readDb.query('SELECT * FROM order_summary ORDER BY total DESC LIMIT 20');# --- Command side: handle a write, emit an event ---async def place_order(cmd: dict) -> None: await write_db.insert_order(cmd["order_id"], cmd["customer_id"], cmd["total"], "CONFIRMED") await event_bus.publish({ "type": "OrderPlaced", "order_id": cmd["order_id"], "customer_id": cmd["customer_id"], "total": cmd["total"], })
# --- Query side: a projection keeps a denormalized read model in sync ---@event_bus.on("OrderPlaced")async def project_order_placed(e: dict) -> None: customer = await customer_svc.get_customer(e["customer_id"]) await read_db.upsert_order_summary({ "order_id": e["order_id"], "total": e["total"], "customer_name": customer["name"], # pre-joined at projection time })
# A query is now a single fast lookup against the read model.async def top_orders(): return await read_db.query("SELECT * FROM order_summary ORDER BY total DESC LIMIT 20")// --- Command side: handle a write, emit an event ---func PlaceOrder(ctx context.Context, cmd PlaceOrderCmd) error { if err := writeDB.InsertOrder(ctx, cmd.OrderID, cmd.CustomerID, cmd.Total, "CONFIRMED"); err != nil { return err } return eventBus.Publish(ctx, Event{ Type: "OrderPlaced", OrderID: cmd.OrderID, CustomerID: cmd.CustomerID, Total: cmd.Total, })}
// --- Query side: a projection keeps a denormalized read model in sync ---func OnOrderPlaced(ctx context.Context, e Event) error { customer, err := customerSvc.GetCustomer(ctx, e.CustomerID) if err != nil { return err } return readDB.UpsertOrderSummary(ctx, OrderSummary{ OrderID: e.OrderID, Total: e.Total, CustomerName: customer.Name, // pre-joined at projection time })}
// A query is now a single fast lookup against the read model.func TopOrders(ctx context.Context) ([]OrderSummary, error) { return readDB.Query(ctx, "SELECT * FROM order_summary ORDER BY total DESC LIMIT 20")}// --- Command side: handle a write, emit an event ---async fn place_order(cmd: PlaceOrderCmd) -> Result<(), Error> { write_db::insert_order(&cmd.order_id, &cmd.customer_id, cmd.total, "CONFIRMED").await?; event_bus::publish(Event::OrderPlaced { order_id: cmd.order_id, customer_id: cmd.customer_id, total: cmd.total, }) .await}
// --- Query side: a projection keeps a denormalized read model in sync ---async fn on_order_placed(e: OrderPlaced) -> Result<(), Error> { let customer = customer_svc::get_customer(&e.customer_id).await?; read_db::upsert_order_summary(OrderSummary { order_id: e.order_id, total: e.total, customer_name: customer.name, // pre-joined at projection time }) .await}
// A query is now a single fast lookup against the read model.async fn top_orders() -> Result<Vec<OrderSummary>, Error> { read_db::query("SELECT * FROM order_summary ORDER BY total DESC LIMIT 20").await}ผลลัพธ์ที่ตามมา
หัวข้อที่มีชื่อว่า “ผลลัพธ์ที่ตามมา”สิ่งที่คุณได้รับ:
- query เร็วขึ้นมาก read model ถูก pre-join และทำ index ไว้ให้ตรงกับ query ที่ต้องรับใช้พอดี แม้แต่ query ข้าม service หรือ query ขนาดใหญ่ก็เหลือแค่ lookup ครั้งเดียวที่เร็วมาก
- การ scale อย่างอิสระ. การอ่านและการเขียน scale แยกกัน workload ที่อ่านหนักสามารถมี read model หลาย replica ได้โดยไม่แตะฝั่งเขียน
- เลือกเครื่องมือให้เหมาะกับแต่ละฝั่งได้ ใช้ relational store กับฝั่งเขียน และใช้ search index หรือ document store กับฝั่งอ่าน แต่ละตัว optimize มาเพื่องานของตัวเองโดยเฉพาะ
สิ่งที่คุณต้องจ่าย:
- ชิ้นส่วนที่ขยับเขยื้อนมากขึ้น. ตอนนี้คุณต้องดูแล event bus, projection และที่จัดเก็บอย่างน้อยสองตัวแทนที่จะเป็นหนึ่ง นี่เป็น overhead ทั้งด้านการดำเนินงานและการรับรู้ที่เป็นเรื่องจริง
- eventual consistency. read model ตามหลัง write model อยู่ตามเวลาที่ event ใช้ในการแพร่กระจายและ project ผู้ใช้ที่เพิ่งเขียนอาจอ่านข้อมูลเก่าได้ชั่วครู่ ดังนั้น UI ต้องเผื่อรองรับเรื่องนี้
- ข้อมูลซ้ำซ้อนและต้อง replay projection ได้ read model คือสำเนาข้อมูลอีกชุด และคุณต้องมีวิธีสร้างขึ้นใหม่จากศูนย์เวลาที่ view เปลี่ยนหรือ projection มี bug
เพราะ overhead พวกนี้ ให้หยิบ CQRS มาใช้เฉพาะตอนที่ query ต้องการจริง ๆ ถ้าเป็น query ข้าม service ที่ไม่ใหญ่มาก API Composition ซึ่งง่ายกว่ามักเป็นทางเลือกที่ดีกว่า
เนื้อหาที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “เนื้อหาที่เกี่ยวข้อง”- API Composition — ทางเลือกที่ง่ายกว่า ใช้ตัวนี้ไปก่อนจนกว่า query จะพิสูจน์ว่าแพงเกินกว่าจะ compose สด ๆ ได้
- Event Sourcing — แหล่งที่มาตามธรรมชาติของ event ที่ป้อนให้ CQRS projection
- Saga — read model มักเปิดเผยสถานะกลางที่ saga สร้างขึ้น
| ข้อดี | ข้อแลกเปลี่ยน |
|---|---|
| optimize read model แยกจาก write model ได้อย่างอิสระ | eventual consistency ระหว่าง write และ read model |
| scale read และ write แยกกัน | complexity สูง — ต้องดูแล model สองชุด |
| read query ไม่กระทบ write performance | synchronization delay — read อาจเห็นข้อมูลเก่า |
| เหมาะกับระบบที่ read มากกว่า write มาก | ไม่คุ้มถ้า read/write pattern ไม่ต่างกันมาก |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”CQRS ทุกที่ — apply CQRS กับทุก service โดยไม่ดูความจำเป็น อาการ:
- service ที่ read/write เท่ากันก็ใช้ CQRS
- complexity เพิ่มโดยไม่ได้ประโยชน์จาก separate model
- ทีมใช้เวลา maintain sync logic มากกว่า build feature
Read Model Staleness ที่ไม่ Communicate — user เห็นข้อมูลเก่าโดยไม่รู้ว่าเก่า อาการ:
- submit form แล้ว list ยังไม่อัปเดต user งง
- ไม่มี UI indicator ว่า “กำลัง sync”
💡 ตัวอย่างจากของจริง
Microsoft (Azure Cosmos DB):
- CQRS built-in ผ่าน change feed — write ไปที่ primary, read จาก replica
- change feed trigger sync read model อัตโนมัติ
Grab:
- order service ใช้ CQRS: driver app (write-heavy) แยก model จาก passenger tracking (read-heavy)
- read model optimize สำหรับ geospatial query