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

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 และส่ง event ออกมา; projection สร้าง read model ที่ฝั่ง query ใช้รับใช้

ด้านล่างนี้ 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');

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

  • 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 performancesynchronization 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
CQRS แยกอะไรออกจากกัน?
read model ของ CQRS ถูกทำให้ทันสมัยได้อย่างไร?
ข้อใดเป็นข้อเสียที่ติดมากับ CQRS โดยธรรมชาติ?
เมื่อใดที่คุณควรเลือก API Composition แทน CQRS?