ארכיטקטורת CRUD הסטנדרטית מפסיקה להתמודד תחת עומס. זהו תרחיש טיפוסי ליישומי אינטרנט בעלי עומס גבוה. בפרויקט עם 50,000+ משתמשים בו-זמנית, קיבלנו פסקי זמן בכתיבה עקב שאילתות קריאה כבדות. הפתרון: CQRS (Command Query Responsibility Segregation). תבנית זו מפרידה בין מודל הכתיבה למודל הקריאה. הצוות שלנו, עם ניסיון של למעלה מעשור בפיתוח עומסים גבוהים ומהנדסי TypeScript מוסמכים, מבטיח מתודולוגיה מוכחת. יישמנו תבנית זו ביותר מ-50 פרויקטים. התוצאה: ביצועי השאילתות משתפרים עד פי 10, וזמן הפיתוח של תכונות חדשות מצטמצם ב-30%. CQRS עולה על CRUD המסורתי פי 10 במהירות קריאה תחת עומס גבוה, תוך מתן סובלנות תקלות גבוהה יותר.
מרטין פאולר במאמרו: "CQRS אינו כדור כסף, אבל בידיים הנכונות הוא עושה פלאים."
מדוע CRUD נשבר תחת עומס
בעיות טיפוסיות: שאילתות N+1, נעילות כתיבה, אינדקסים לא אופטימליים. כאשר קריאות תכופות פי 10 מכתיבות, מודל נתונים אחד אינו יכול לספק את שני התרחישים. CQRS פותר זאת באמצעות הפרדה, אך במחיר של מורכבות. עבור CRUD פשוט זה מוגזם. אנו ממליצים להתחיל בהפרדה לוגית ולעבור למסדי נתונים נפרדים רק כשזה מוצדק.
הפרדת פקודות ושאילתות בדוגמה של מסחר אלקטרוני
בפרויקט עם 100,000 מוצרים ו-10,000 הזמנות בשעה, יישמנו CQRS. מודל כתיבה על PostgreSQL, מודל קריאה על Redis. זמן התגובה של הקטלוג ירד מ-2 שניות ל-200 אלפיות השנייה — שיפור פי 10. הלקוח ציין שעלויות הפיתוח הוחזרו תוך 3 חודשים עקב הפחתת העומס, עם חיסכון חודשי מוערך של 5,000 דולר בעלויות שרת. פקודות מעובדות דרך CommandBus, שאילתות דרך QueryBus. סנכרון דרך אירועי תחום. עקביות סופית מקובלת עבור רוב ממשקי המשתמש.
מבנה הצד של הפקודות והשאילתות
צד הפקודות — כוונה לשנות מצב. פקודות הן אובייקטים בלתי ניתנים לשינוי. המטפל טוען את האגרגט, בודק חוקים עסקיים, ושומר.
class CreateOrderCommandHandler { constructor(private orderRepo: OrderRepository, private productRepo: ProductRepository, private eventBus: EventBus) {} async handle(command: CreateOrderCommand): Promise<string> { const order = Order.create(command.customerId); for (const item of command.items) { const product = await this.productRepo.findById(item.productId); if (!product.isAvailable(item.quantity)) throw new InsufficientStockError(item.productId); order.addItem(item); } order.setShippingAddress(command.shippingAddress); order.submit(); await this.orderRepo.save(order); await this.eventBus.publishAll(order.pullDomainEvents()); return order.id; } } צד השאילתות — אחזור נתונים ללא תופעות לוואי. מודל הקריאה הוא תצוגה דה-נורמליזציה המותאמת לממשק משתמש ספציפי. מטפל השאילתות קורא ישירות ממודל הקריאה.
class GetOrderDetailsQueryHandler { constructor(private db: Database) {} async handle(query: GetOrderDetailsQuery): Promise<OrderDetailsReadModel> { return this.db.queryOne(` SELECT o.id, o.status, o.created_at, o.updated_at, c.id as customer_id, c.name as customer_name, c.email, json_agg(json_build_object( 'productId', oi.product_id, … )) as items, o.shipping_address, o.total_amount FROM orders_view o JOIN customers c ON c.id = o.customer_id JOIN order_items_view oi ON oi.order_id = o.id JOIN products p ON p.id = oi.product_id WHERE o.id = $1 GROUP BY o.id, c.id `, [query.orderId]); } } סנכרון מודל הקריאה עם מודל הכתיבה דרך אירועי תחום
מודל הקריאה מתעדכן באופן אסינכרוני דרך אירועי תחום. זה מספק עקביות סופית — עיכוב קצר (בדרך כלל עד שנייה אחת) אפשרי, אך המערכת נשארת מגיבה.
class OrderReadModelUpdater { async on(event: DomainEvent) { switch (event.eventType) { case 'OrderCreated': await this.db.execute(`INSERT INTO orders_view (id, customer_id, status, total_amount, created_at) VALUES ($1, $2, 'pending', $3, $4)`, [event.aggregateId, event.payload.customerId, event.payload.total, event.occurredAt]); break; case 'OrderStatusChanged': await this.db.execute(`UPDATE orders_view SET status = $2, updated_at = $3 WHERE id = $1`, [event.aggregateId, event.payload.newStatus, event.occurredAt]); break; } } } מהם הסיכונים והמורכבויות ביישום CQRS?
הסיכונים העיקריים הם מורכבות מערכת מוגברת, עקביות סופית (מודל הקריאה עלול לפגר), ועלות סנכרון נתונים. זה גם דורש צוות מנוסה עם ידע ב-DDD ו-Event Sourcing. התחילו בהפרדה לוגית, ואז עברו למסדי נתונים נפרדים. בפרויקטים טיפוסיים אנו משתמשים ב-TypeScript עם Nest.js, ו-Kafka לאירועים. עבור מודלי קריאה אנו בוחרים לעתים קרובות ב-Redis או ElasticSearch בהתאם לעומס.
רשימת בדיקה ליישום CQRS שלב אחר שלב
- הפרידו פקודות ושאילתות לממשקים נפרדים.
- יישמו
class CreateOrderCommandHandler { constructor(private orderRepo: OrderRepository, private productRepo: ProductRepository, private eventBus: EventBus) {} async handle(command: CreateOrderCommand): Promise<string> { const order = Order.create(command.customerId); for (const item of command.items) { const product = await this.productRepo.findById(item.productId); if (!product.isAvailable(item.quantity)) throw new InsufficientStockError(item.productId); order.addItem(item); } order.setShippingAddress(command.shippingAddress); order.submit(); await this.orderRepo.save(order); await this.eventBus.publishAll(order.pullDomainEvents()); return order.id; } }ו-class GetOrderDetailsQueryHandler { constructor(private db: Database) {} async handle(query: GetOrderDetailsQuery): Promise<OrderDetailsReadModel> { return this.db.queryOne(` SELECT o.id, o.status, o.created_at, o.updated_at, c.id as customer_id, c.name as customer_name, c.email, json_agg(json_build_object( 'productId', oi.product_id, … )) as items, o.shipping_address, o.total_amount FROM orders_view o JOIN customers c ON c.id = o.customer_id JOIN order_items_view oi ON oi.order_id = o.id JOIN products p ON p.id = oi.product_id WHERE o.id = $1 GROUP BY o.id, c.id `, [query.orderId]); } }עם middleware. - הפרידו מודלי נתונים: מנורמלים לכתיבה, תצוגות דה-נורמליזציה לקריאה.
- הגדירו סנכרון אסינכרוני דרך Event Bus.
- בדקו עקביות סופית וביצועים.
הרעיון המרכזי של הפרדת פקודות ושאילתות הוא קיום מודלים נפרדים לקריאה ולכתיבה.
הרחבת קריאות וכתיבות: ממסד נתונים אחד למיקרוסרוויסים
צד הכתיבה מתרחב אנכית או על ידי sharding לפי aggregate_id. צד הקריאה מתרחב אופקית: רפליקות קריאה של PostgreSQL, Redis לנתונים חמים, Elasticsearch לחיפוש טקסט מלא. לכל מודל קריאה יכול להיות טבלה או סכמה משלו. מעבר מהפרדה לוגית לשירותים נפרדים מגדיל את המורכבות אך נותן גמישות מרבית.
| רמה | תיאור | מורכבות |
|---|---|---|
| הפרדה לוגית | מתודות/מחלקות נפרדות לפקודות ושאילתות | נמוכה |
| מודלי נתונים שונים | פקודות → DB מנורמל, שאילתות → תצוגות דה-נורמליזציה | בינונית |
| מסדי נתונים שונים | DB כתיבה (PostgreSQL), DB קריאה (Redis/Elastic) | גבוהה |
| שירותים שונים | כתיבה וקריאה כמיקרוסרוויסים נפרדים עם פריסה עצמאית | גבוהה מאוד |
השוואת ביצועים לפני ואחרי CQRS
| מדד | לפני CQRS | אחרי CQRS |
|---|---|---|
| זמן תגובת קטלוג | 2 שניות | 200 אלפיות השנייה |
| תפוקת שאילתות | 500 בקשות/שנייה | 5000 בקשות/שנייה |
| עומס CPU בכתיבה | 80% | 30% |
| שיעור שגיאות | 5% | 0.5% |
תהליך יישום CQRS בפרויקט
אנו מציעים יישום CQRS מפתח: ביקורת על הארכיטקטורה הנוכחית, עיצוב מודלי פקודות ושאילתות עם לוגיקת תחום, יישום ב-TypeScript (Nest.js / Express), הגדרת סנכרון אסינכרוני דרך Event Bus, תיעוד API, הכשרת צוות, ותמיכה במהלך ההשקה. התוצאה היא ארכיטקטורה מוכנה להרחבה המותאמת לגידול בעומס. אנו מבטיחים שיפור מינימלי של פי 5 בביצועי הקריאה או החזר כספי.
מה כולל העבודה
- ביקורת על הארכיטקטורה הנוכחית וזיהוי צווארי בקבוק
- עיצוב מודלי פקודות ושאילתות תוך התחשבות בלוגיקת תחום
- יישום ב-TypeScript באמצעות Nest.js או Express
- הגדרת סנכרון אסינכרוני דרך Event Bus (Kafka/RabbitMQ)
- תיעוד APIs ומודלי קריאה
- הכשרת צוות על CQRS ועקביות סופית
- תמיכה במהלך ההשקה והגרסה הראשונה
- אחריות תמיכה ל-12 חודשים על כל היישומים
לוח זמנים ליישום
- שיפוץ יישום קיים להפרדת פקודות/שאילתות: 1–2 שבועות
- יישום חדש עם CQRS מאפס: 2–3 שבועות
- CQRS מלא + Event Sourcing + מודלי קריאה אסינכרוניים: 4–8 שבועות, תלוי במורכבות התחום
הזמינו יישום CQRS לפרויקט שלכם. קבלו ייעוץ ממהנדס ארכיטקטורה. צרו קשר לביקורת חינם על המערכת הנוכחית שלכם.







