معمارية الخدمات المصغّرة وتصميمها

قاعدة بيانات لكل خدمة

18 دقيقة الدرس 4 من 12

قاعدة بيانات لكل خدمة

من أهم القرارات في أنظمة الخدمات المصغّرة هو القاعدة التي تقضي بأن كل خدمة تمتلك مخزنها للبيانات بصورة حصرية، ولا يحق لأي خدمة أخرى الاتصال بقاعدة بياناتها مباشرةً. هذا المبدأ — قاعدة بيانات لكل خدمة — ليس مجرد اختيار مريح؛ بل هو الضمان البنيوي الذي يتيح لكل خدمة التطور باستقلالية. بدونه تبقى الاستقلالية التي تعدنا بها الخدمات المصغّرة وهمًا.

لماذا تكون قواعد البيانات المشتركة خطيرة

في التطبيق أحادي البنية تقرأ وتكتب جميع الوحدات في نفس المخطط. يبدو ذلك مريحًا. لكن حين تستخرج الخدمات وتبقي قاعدة بيانات مشتركة، فأنت وزّعت وقت التشغيل دون أن توزّع الاقتران. في هذه الحالة تحصل على أسوأ ما في العالمين: التعقيد التشغيلي للأنظمة الموزعة مضافًا إليه الاقتران الشديد لأحادي البنية.

المشاكل الملموسة التي تبرز فورًا:

  • شلل تغيير المخطط. إعادة تسمية عمود في جدول orders تستلزم تنسيق كل خدمة تلمسه. تصبح عمليات النشر حدثًا على مستوى النظام بأسره بدلًا من قرار يتخذه الفريق المسؤول.
  • حمل غير متوقع وتنافس على الموارد. استعلام ثقيل في خدمة التقارير يكتسب أقفالًا أو يستهلك المعالج، فيُخفّض أداء خدمة الطلبات التي تشاركها نفس الخادم.
  • اقتران بيانات غير خاضع للسيطرة. بإمكان الخدمة قراءة أو تعديل بيانات لا ينبغي لها منطقيًا أن تلمسها. تضيع عقود البيانات وتصبح الأخطاء غير محلية.
  • نطاق تأثير واسع للاختراقات الأمنية. بيانات اعتماد خدمة مخترقة تمنح المهاجم وصولًا مباشرًا عبر SQL إلى كل جدول في الحالة المشتركة — الطلبات والمدفوعات والمستخدمين وكل شيء.
اختصار "قاعدة البيانات المشتركة" سيُسدَّد ثمنه لاحقًا. الفرق التي تتجاوز هذا المبدأ عند تفكيك التطبيقات تُبلّغ في الغالب عن أن قاعدة البيانات المشتركة تصبح عنق الزجاجة وأكثر هدف للترحيل رهبةً وأكبر مصدر للحوادث في الإنتاج في غضون ستة إلى ثمانية عشر شهرًا.

ما الذي يعنيه هذا النمط فعليًا

لا يشترط نمط قاعدة بيانات لكل خدمة وجود خادم مادي مستقل لكل خدمة، وإن كان ذلك مسموحًا به. بل يشترط حدود عزل منطقية مستقلة: بيانات الخدمة لا تكون في متناول سواها إلا عبر واجهة برمجة تطبيقات (API) تلك الخدمة. يمكن تطبيق هذا العزل على عدة مستويات:

  1. مخطط منفصل / قاعدة بيانات منفصلة على خادم قواعد بيانات مشترك — الحد الأدنى القابل للتطبيق والشائع في بدايات التفكيك.
  2. خادم قواعد بيانات منفصل — عزل مادي كامل؛ ضروري حين تمتلك الخدمات متطلبات مستوى خدمة أو تحجيم أو امتثال متباينة.
  3. التخزين متعدد التقنيات (Polyglot Persistence) — تختار كل خدمة تقنية التخزين الأنسب لحمل عملها: قاعدة علائقية لخدمة الطلبات، ومخزن مستندات للكتالوج، وRedis لخدمة الجلسات، وقاعدة بيانات سلاسل زمنية لخدمة المقاييس.
القاعدة تتعلق بالوصول لا بالطوبولوجيا. قد تستعلم خدمتان بالصدفة من نفس خادم PostgreSQL المادي، طالما تتصلان بمخططات مختلفة ببيانات اعتماد مختلفة ولا توجد استعلامات بين المخططين. لحظة تنفيذ الخدمة ب الاستعلام SELECT * FROM service_a_schema.orders يكون النمط قد انكسر.

تطبيق الحد في Spring Boot

في Spring Boot 3 أنظف طريقة لتطبيق هذا المبدأ هي منح كل خدمة مصدر بيانات DataSource خاصًا بها مهيَّأً في ملف application.yml الخاص بها. لا يوجد bean مشترك، ولا connection pool مشترك، ولا بيانات اعتماد مشتركة.

إعدادات خدمة الطلبات (application.yml):

spring: datasource: url: jdbc:postgresql://order-db:5432/orderdb username: ${ORDER_DB_USER} password: ${ORDER_DB_PASSWORD} jpa: hibernate: ddl-auto: validate properties: hibernate: default_schema: orders

إعدادات خدمة المخزون (application.yml):

spring: datasource: url: jdbc:postgresql://inventory-db:5432/inventorydb username: ${INVENTORY_DB_USER} password: ${INVENTORY_DB_PASSWORD} jpa: hibernate: ddl-auto: validate properties: hibernate: default_schema: inventory

لاحظ أن خدمة الطلبات لا تمتلك كيان InventoryItem وخدمة المخزون لا تمتلك كيان Order. إنهما سياقان منفصلان تمامًا لـ JPA. حين تحتاج خدمة الطلبات للتحقق من المخزون، تستدعي REST API خدمة المخزون — لا قاعدة بياناتها.

// OrderService.java — في الخدمة المصغّرة للطلبات @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final InventoryClient inventoryClient; // استدعاء Feign / RestClient @Transactional public Order placeOrder(CreateOrderRequest req) { // نسأل خدمة المخزون — لا نلمس قاعدة بياناتها أبدًا StockResponse stock = inventoryClient.checkStock(req.getProductId(), req.getQuantity()); if (!stock.isAvailable()) { throw new InsufficientStockException(req.getProductId()); } Order order = new Order(req.getProductId(), req.getQuantity(), OrderStatus.CONFIRMED); return orderRepository.save(order); } }
// InventoryClient.java — عميل HTTP تصريحي عبر Feign @FeignClient(name = "inventory-service", url = "${services.inventory.url}") public interface InventoryClient { @GetMapping("/api/v1/stock/{productId}") StockResponse checkStock( @PathVariable String productId, @RequestParam int quantity ); }
بيانات الاعتماد من متغيرات البيئة فقط. العناصر النائبة ${ORDER_DB_USER} و${ORDER_DB_PASSWORD} لا تُودَع في نظام إدارة الكود المصدري أبدًا. في Kubernetes تأتي من Secrets؛ في Docker Compose من ملف .env. هذا يُقيّد مباشرةً نطاق تأثير اختراق الخدمة: مهاجم يقرأ بيئة خدمة الطلبات لا يصل إلا إلى قاعدة بيانات الطلبات.

التعامل مع البيانات التي كانت JOIN

الاعتراض الأكثر شيوعًا على نمط قاعدة بيانات لكل خدمة هو: "لكنني أحتاج بيانات من خدمتين في استعلام واحد." في التطبيق الأحادي كنت تكتب ربط SQL. في البيئة الموزعة لديك ثلاثة خيارات رئيسية:

  • تأليف API. تجلب الخدمة الطالبة (أو BFF/API Gateway مخصص) من كلتا الخدمتين وتدمج النتائج في الكود. صحيح للقراءات منخفضة الحجم.
  • نموذج قراءة مدفوع بالأحداث مع CQRS. تنشر الخدمات أحداثًا للدومين؛ خدمة قراءة منفصلة (أو الجانب القرائي للخدمة ذاتها) تحتفظ بإسقاط مُسطَّح مُحسَّن للاستعلام. تنشر خدمة الطلبات حدث OrderPlaced؛ تستهلكه خدمة التقارير وتخزّن صفًا مسطحًا يحتوي بالفعل على اسم المنتج الذي استقبلته من خدمة المخزون عبر حدث منفصل. لا حاجة لربط في وقت الاستعلام.
  • البيانات المرجعية المشتركة. البيانات الثابتة حقًا (رموز الدول، رموز العملات) يمكن نشرها لمخزن مشترك للقراءة فقط أو نسخها عبر الأحداث. تمتلكها خدمة واحدة؛ تستخدم سائر الخدمات نسخة مخبّأة للقراءة فقط.

اتساق البيانات بلا معاملات مشتركة

معاملة ACID واحدة تمتد عبر قاعدتَي بيانات مستحيلة بدون بروتوكول معاملات موزعة (2PC)، وهو بطيء ويقرن الخدمات على مستوى البروتوكول. بدلًا من ذلك تتبنى الخدمات المصغّرة الاتساق النهائي (Eventual Consistency) عبر نمط Saga:

  1. كل خطوة في العملية متعددة الخدمات هي معاملة محلية في قاعدة بيانات خدمة واحدة.
  2. إذا فشلت خطوة لاحقة، تُلغي معاملات تعويضية الخطوات السابقة.
  3. يتقارب النظام إلى حالة اتساق، لكن ليس بشكل ذري.

هذه مقايضة متعمدة: تكسب استقلالية النشر وعزل الأعطال؛ وتدفع بمعالجة فشل أكثر تعقيدًا. الدرس السابع من هذا البرنامج التعليمي يتناول البيانات الموزعة والاتساق بتفصيل كامل.

الآثار الأمنية

نمط قاعدة بيانات لكل خدمة هو أيضًا حد أمني. بفصل بيانات الاعتماد وسياسات الشبكة (كل قاعدة بيانات قابلة للوصول فقط من pod/حاوية خدمتها)، لا يستطيع استغلال ناجح لخدمة واحدة سرقة بيانات خدمة أخرى مباشرةً. في البيئات الخاضعة للتنظيم (PCI-DSS وHIPAA) هذا العزل كثيرًا ما يكون متطلب امتثال لا مجرد تفضيل معماري.

طبّق مبدأ الحد الأدنى من الصلاحيات على مستوى قاعدة البيانات أيضًا. مستخدم قاعدة بيانات خدمة الطلبات ينبغي أن يمتلك فقط SELECT وINSERT وUPDATE وDELETE على مخطط orders — لا DROP TABLE، ولا صلاحيات المشرف. استخدم REVOKE والمنح المستندة إلى الأدوار عند توفير المخطط.

الخلاصة

يفرض نمط قاعدة بيانات لكل خدمة حدًّا صارمًا للوصول: بيانات الخدمة لا تكون في متناول سواها إلا عبر API المنشورة لتلك الخدمة. يُتيح ذلك استقلالية النشر واختيار التقنية لكل خدمة وتحديدًا هادفًا لنطاق الأثر الأمني. يكمن الثمن في أن الاستعلامات بين الخدمات تصبح تأليفًا للـ API أو نماذج قراءة مدفوعة بالأحداث، والتعديلات بين الخدمات تصبح Sagas لا معاملات ACID. هذه المقايضة متعمدة وجوهرية — تبنّها مبكرًا بدلًا من الإصلاح اللاحق تحت ضغط الإنتاج.