الأدلة التقنية

NATS: رسائل سريعة وخفيفة للخدمات المصغرة

كيف تربط خدماتك بالطلب والرد عبر NATS، ومتى تحتاج JetStream ليحفظ الرسائل ويتذكر أين وصل كل مستهلك.

NATS: رسائل سريعة وخفيفة للخدمات المصغرة

في المقالات السابقة من مرحلة الطوابير وبث الأحداث شغلنا RabbitMQ وApache Kafka، وكلاهما يحفظ كل رسالة على القرص وينسخها إلى عدة سيرفرات قبل أن يؤكدها، وهذا ما تريده لطلب شراء أو حدث دفع. لكن ليست كل رسالة بهذه الأهمية: خدمة الدفع تسأل خدمة الأسعار عن سعر منتج وتنتظر الجواب، أو خدمة ترسل تحديث موقع سائق كل ثانية، أو إشعار يقول «امسح هذا المفتاح من ال Cache»، فهذه رسائل قيمتها في سرعتها، والرسالة القديمة منها لا فائدة لها. وهنا يأتي NATS، وهو وسيط صغير جداً وسريع، يبدأ بإيصال الرسائل لمن يستمع الآن فقط، ثم يضيف التخزين عند الحاجة عبر JetStream، وسوف نناقش في هذا المقال ما يلي:

  • ما هو NATS، ومتى يناسبك ومتى لا يناسبك.
  • NATS الأساسي Core NATS: الموضوعات Subjects والرموز البديلة Wildcards، والطلب والرد Request/Reply، ومجموعات الطوابير Queue Groups، وضمان «مرة على الأكثر».
  • JetStream: ال Streams والمستهلكون Consumers وسياسات التأكيد، ومنع التكرار، ومخزن المفاتيح KV ومخزن الكائنات Object Store.
  • الرخصة Apache 2.0، والخلاف بين Synadia وCNCF في 2025 وكيف انتهى.
  • تجربة عملية: خدمتان بالطلب والرد، ومجموعة طوابير، ومستهلك دائم يبقى بعد إعادة تشغيل السيرفر، ومراقبة مفتاح في KV.

ما هو NATS، ومتى يناسبك؟

NATS نظام رسائل مفتوح المصدر مكتوب بلغة Go، يعمل سيرفره من ملف تنفيذي واحد صغير، وفي تجربتنا استهلك 8 ميجابايت من الذاكرة بعد التشغيل، و19 ميجابايت بعد كل ما فعلناه. والفكرة الأساسية فيه هي الموضوع Subject، وهو اسم بكلمات تفصلها نقاط مثل orders.sa.created، فالمرسل ينشر إلى موضوع، وكل من اشترك في هذا الموضوع يستلم الرسالة، ولا يحتاج أي منهما أن يعرف عنوان الآخر.

ويناسبك NATS في الحالات التالية:

  • الطلب والرد بين الخدمات المصغرة: خدمة تسأل أخرى وتنتظر الجواب، مع توزيع الطلبات على عدة نسخ دون Load Balancer منفصل.
  • الأخبار اللحظية: مواقع، وأسعار، وإشعارات مسح ال Cache، فالرسالة الجديدة تغني عن القديمة.
  • الأجهزة الطرفية Edge وإنترنت الأشياء: سيرفر خفيف يعمل على جهاز صغير ويتصل بعنقود مركزي.
  • طوابير وسجل أحداث بحجم متوسط: عبر JetStream، إذا كنت لا تريد تشغيل نظام ثان لذلك.

ولا يناسبك في الحالات التالية:

  • توجيه معقد للمهام: إذا احتجت موزعات بأنواعها وأولويات و Dead Letter Queue جاهزة كما في RabbitMQ، فستبني بعض ذلك بنفسك في NATS.
  • نظام قائم على Kafka: إذا كانت أدواتك وموصلاتك كلها تتكلم Kafka API، فإن NATS لا يتكلم هذا البروتوكول، والانتقال إليه يعني تغيير الكود.
  • أحجام ضخمة بمواضيع يحتفظ بها سنوات وتعيد قراءتها أنظمة كثيرة: هنا ما زالت Kafka هي الأداة الأشهر بأكبر نظام محيط.

الرخصة، والخلاف بين Synadia وCNCF في 2025

سيرفر NATS برخصة Apache 2.0 بحسب ملف الرخصة في المستودع، والمشروع ضمن مؤسسة الحوسبة السحابية الأصلية CNCF، والشركة التي تكتب أغلب كوده هي Synadia. وفي أبريل 2025 ظهر خلاف علني بين الطرفين يستحق أن تعرفه لأنه كان يمكن أن يغير رخصة المشروع:

  • بحسب مقال CNCF، طلبت Synadia عبر محاميها أن تعود إليها العلامة التجارية NATS واسم النطاق nats.io، وأن يخرج المشروع من CNCF، وأن تتحول رخصة السيرفر بعد الخروج إلى Business Source License، وهي رخصة غير مفتوحة المصدر.
  • وفي رد Synadia في 25 أبريل 2025 قالت إنها وشركتها السابقة مولتا قرابة 97% من مساهمات السيرفر، وإنها كانت تدرس رخصة BSL للإصدارات القادمة تتحول إلى Apache 2.0 بعد مدة، مع بقاء نسخة برخصة Apache 2.0 متاحة ومدعومة دائماً.
  • وانتهى الخلاف بحسب إعلان CNCF في 1 مايو 2025 باتفاق: نقلت Synadia تسجيلي العلامة التجارية NATS إلى Linux Foundation، ويبقى المشروع في CNCF برخصة Apache 2.0، وتبقى أصوله مثل اسم النطاق ومستودعات GitHub لدى CNCF، وتستطيع Synadia أن تقدم منتجات تجارية خاصة بها، لكن أي نسخة مغلقة تحتاج اسماً آخر غير NATS.

والنتيجة العملية لك: NATS اليوم برخصة Apache 2.0 تحت مؤسسة محايدة، وهذا وضع أفضل من وضعه قبل الخلاف لأن العلامة التجارية لم تعد بيد شركة واحدة، لكن القصة تذكرك بأن المشروع الذي تموله شركة واحدة قد تتغير ظروفه، لذلك تابع أخبار الأدوات التي يعتمد عليها نظامك، كما فعلنا مع RabbitMQ وRedpanda في هذه المرحلة.

NATS الأساسي: لمن يستمع الآن فقط

يقول توثيق Core NATS بوضوح إن NATS الأساسي يعطي ضمان «مرة على الأكثر» At-Most-Once، فالرسالة تصل إلى كل مشترك متصل لحظة النشر مرة واحدة على الأكثر، وإذا كان المشترك غير متصل أو يعيد التشغيل أو لم يشترك بعد فلن يرى الرسالة أبداً، والسيرفر لا يحفظها. وقد يبدو هذا عيباً، لكنه اختيار مقصود: لا يوجد قرص ولا تأكيد ولا إعادة إرسال، لذلك هو صغير وسريع، ويناسب الرسائل التي تفقد قيمتها بسرعة. وعلى هذا الأساس تبنى ثلاثة أنماط:

  • الموضوعات والرموز البديلة: بحسب توثيق الموضوعات، الرمز * يطابق كلمة واحدة بالضبط، والرمز > يطابق كلمة واحدة أو أكثر ولا يأتي إلا في آخر الموضوع، فالاشتراك orders.> يستلم كل أحداث الطلبات.
  • الطلب والرد Request/Reply: المرسل ينشر الطلب مع عنوان رد خاص به يسمى Inbox، والخدمة ترد إلى هذا العنوان، فتحصل على شكل يشبه استدعاء API لكن دون أن يعرف المرسل عنوان الخدمة، وإذا لم يكن هناك أي مشترك في الموضوع يرد السيرفر فوراً بأنه لا يوجد من يجيب No Responders، بدل أن ينتظر المرسل حتى تنتهي المهلة.
  • مجموعات الطوابير Queue Groups: عدة مشتركين بالاسم نفسه للمجموعة، فتذهب كل رسالة إلى واحد منهم فقط، وهذا توزيع للحمل بين نسخ الخدمة دون أي إعداد في السيرفر.

والصورة التالية تبين ذلك كما رأيناه في التجربة:

مخطط من جزأين: في اليسار ثلاثة مواضيع orders.sa.created وorders.ae.paid وorders.sa.riyadh.created تصل إلى سيرفر NATS، والاشتراك orders.*.created يستلم orders.sa.created فقط لأن النجمة كلمة واحدة، والاشتراك orders.> يستلم الثلاثة، ورسالة نشرت قبل أن يشترك أحد ضاعت لأن الضمان مرة على الأكثر. في اليمين خدمة checkout ترسل طلباً على price.get إلى السيرفر، فيوصله إلى عضو واحد من مجموعة الطوابير pricing التي فيها pricing-1 وpricing-2، ويعود الرد إلى عنوان رد خاص، وإذا لم يكن أي عضو يعمل يأتي الرد no responders فوراً
الموضوع يحدد من يستلم، ومجموعة الطوابير توزع الطلبات على نسخ الخدمة

JetStream: عندما تحتاج أن تبقى الرسالة

JetStream طبقة تخزين داخل سيرفر NATS نفسه، تفعلها بخيار في التشغيل، وفكرتها بسيطة: تعرف Stream يلتقط كل الرسائل التي موضوعها يطابق نمطاً معيناً ويحفظها على القرص، ثم تعرف عليه مستهلكين Consumers يقرأ كل منهم الرسائل ويؤكدها. وبحسب دليل JetStream:

  • التأكيد لكل قارئ: الرسالة تعتبر منتهية عند المستهلك عندما يؤكدها، فيصبح الضمان «مرة على الأقل» كما في RabbitMQ.
  • سياسات التأكيد Ack Policy: Explicit يؤكد فيها المستهلك كل رسالة وحدها، وAll يؤكد فيها رسالة فيعتبر ما قبلها مؤكداً، وNone لا تأكيد فيها.
  • إعادة التسليم: إذا لم يصل التأكيد خلال المدة AckWait أعاد JetStream الرسالة، ويحدد Max Deliver عدد المحاولات.
  • المستهلك الدائم Durable: له اسم، ويحفظ موقعه في ال Stream، فيكمل من حيث توقف بعد انقطاعه أو بعد إعادة تشغيل السيرفر.
  • منع التكرار: إذا أرسل الناشر مع الرسالة معرفاً في الترويسة Nats-Msg-Id فإن ال Stream يتجاهل الرسالة التي وصله معرفها خلال نافذة زمنية، وهي دقيقتان افتراضياً كما سنرى في التجربة.

ولاحظ أن ال Stream هنا أقرب إلى سجل Kafka منه إلى طابور RabbitMQ: الرسالة تبقى بعد تأكيدها بحسب حدود ال Stream، مثل المدة أو العدد أو الحجم، ويستطيع مستهلك جديد أن يقرأ من البداية، وتستطيع أن تجعله يتصرف كطابور عمل بسياسة الاحتفاظ WorkQueue التي تحذف الرسالة بعد تأكيدها. والصورة التالية تبين ما فعلناه في التجربة:

مخطط: ناشر يرسل على orders.sa.created إلى stream اسمه ORDERS يلتقط orders.> ويحفظ على القرص لمدة 7 أيام، وفيه خمس رسائل: 1 و2 مؤكدتان، و3 أخذت ولم تؤكد، و4 و5 تنتظران، ونقطة التأكيد عند 2 محفوظة على القرص. المستهلك الدائم shipping من نوع pull بسياسة AckExplicit وAckWait 30 ثانية. بعد إيقاف السيرفر وإعادة تشغيله يعود ال Stream ونقطة التأكيد، وتعاد الرسالة 3 بعد AckWait بمحاولة ثانية. في الأسفل KV bucket اسمه config هو في الحقيقة stream اسمه KV_config فيه عمليات PUT وDEL يستلمها المراقبون فوراً، ومخزن كائنات reports فيه ملف 2.9 ميجابايت مقسم إلى 23 جزءاً
ال Stream يحفظ الرسائل، والمستهلك الدائم يحفظ موقعه، وKV ومخزن الكائنات مبنيان على ال Streams

مخزن المفاتيح KV ومخزن الكائنات

بنى فريق NATS فوق JetStream شيئين يحتاجهما أغلب أنظمة الخدمات المصغرة. الأول مخزن مفاتيح وقيم Key-Value، ويقول توثيق KV إن كل مجموعة مفاتيح Bucket هي في الحقيقة Stream اسمه KV_ متبوعاً باسمها، وكل مفتاح موضوع فيه، وكل قيمة رسالة، لذلك تحصل على تاريخ القيم Revisions، والمراقبة Watch التي تخبرك بكل تغيير فوراً، والتحديث المشروط Compare-and-Swap. والاستخدام الشائع لذلك إعدادات الخدمات ومفاتيح تفعيل الميزات Feature Flags، فبدل أن تعيد تشغيل الخدمة لتغيير إعداد تراقب الخدمة المفتاح وتطبق القيمة الجديدة لحظة تغيرها، وهي فكرة قريبة مما شرحناه مع Consul وetcd في مقال اكتشاف الخدمات وإعداداتها. والثاني مخزن الكائنات Object Store لملفات أكبر من حجم الرسالة، يقسمها إلى أجزاء ويحفظها مع بصمة SHA-256، ويناسب ملفات صغيرة ومتوسطة مثل التقارير وملفات الإعداد، وليس بديلاً عن تخزين كائنات كامل لملفات بحجم كبير.

العنقود والنسخ

في تجربتنا سيرفر واحد، أما في الإنتاج فتشغل عنقوداً، ويقول توثيق عنقود JetStream إن العنقود يحتاج عدداً فردياً من السيرفرات لأن الأغلبية يجب أن تبقى متصلة، وإن ثلاثة سيرفرات هي المعتاد، وإن كل Stream يحدد عدد نسخه، وإن ثلاث نسخ R3 هي الحد الأدنى للإنتاج. ويحذر التوثيق من خطأ شائع: Stream بنسخة واحدة R1 على عنقود من ثلاثة لا يتحمل سقوط سيرفره، لأن النسخ لا يحدث تلقائياً، فحدد --replicas 3 عند إنشاء كل Stream مهم.

تجربة عملية

الآن سوف نشغل سيرفر NATS 2.15.0 مع JetStream، ومعه Container فيه أداة سطر الأوامر nats من ال Image natsio/nats-box، وخدمتين بلغة Python بمكتبة nats-py 2.16.0. ونفذنا الأوامر من Git Bash على Windows مع Docker Engine 29.8.0، وإذا كنت على Git Bash فنفذ export MSYS_NO_PATHCONV=1 أولاً حتى لا يغير المسارات التي تبدأ ب /. ولا يوجد أي منفذ منشور على الجهاز.

ملفات التجربة

أنشئ مجلداً فيه compose.yaml التالي:

name: lab-nats

services:
  nats:
    image: nats:2.15.0-alpine
    container_name: lab-nats-server
    hostname: nats
    command: ["-js", "-sd", "/data", "-m", "8222"]   # JetStream on, its files in /data, monitoring on 8222
    volumes: ["nats-data:/data"]
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8222/healthz"]
      interval: 3s
      retries: 30

  box:
    image: natsio/nats-box:0.20.0                     # the nats CLI and friends
    container_name: lab-nats-box
    environment:
      NATS_URL: nats://nats:4222
    command: ["sleep", "infinity"]
    depends_on:
      nats:
        condition: service_healthy

  pricing-1: &svc
    build: .
    image: lab-nats-app:1
    container_name: lab-nats-pricing-1
    hostname: pricing-1
    command: ["python", "pricing.py"]
    profiles: ["services"]
  pricing-2:
    <<: *svc
    container_name: lab-nats-pricing-2
    hostname: pricing-2
  checkout:
    <<: *svc
    container_name: lab-nats-checkout
    hostname: checkout
    command: ["python", "checkout.py"]
    profiles: ["tools"]

volumes:
  nats-data:

في الإعداد أعلاه لاحظ التالي:

  • الخيار -js يفعل JetStream، و-sd /data يضع ملفاته في Volume حتى تبقى بعد إعادة تشغيل ال Container، و-m 8222 يفتح منفذ المراقبة الذي نستخدمه في فحص الصحة /healthz.
  • الخدمتان pricing-1 وpricing-2 نسختان من الخدمة نفسها، ولكل منهما Hostname مختلف حتى نعرف من رد.

ثم Dockerfile للخدمات:

FROM python:3.13.16-slim
RUN pip install --no-cache-dir nats-py==2.16.0
WORKDIR /app
COPY *.py ./
ENV PYTHONUNBUFFERED=1

ثم pricing.py، خدمة الأسعار التي تشترك في price.get ضمن مجموعة الطوابير pricing:

# A small pricing service: answers requests on "price.get" as a member of the queue group "pricing"
import asyncio, json, os, socket
import nats

PRICES = {"A-100": 100, "B-200": 250}

async def main():
    nc = await nats.connect(os.environ.get("NATS_URL", "nats://nats:4222"), name="pricing")
    me = socket.gethostname()

    async def handle(msg):
        sku = json.loads(msg.data)["sku"]
        await msg.respond(json.dumps({"sku": sku, "price": PRICES.get(sku), "from": me}).encode())

    await nc.subscribe("price.get", queue="pricing", cb=handle)
    print(f"[{me}] pricing service ready")
    await asyncio.Event().wait()            # serve forever

asyncio.run(main())

ثم checkout.py، الخدمة التي تسأل عن الأسعار بمهلة ثانية واحدة لكل طلب:

# The checkout service asks the pricing service for prices, with a 1 second timeout per request
import asyncio, json, os
import nats
from nats.errors import NoRespondersError, TimeoutError

async def main():
    nc = await nats.connect(os.environ.get("NATS_URL", "nats://nats:4222"), name="checkout")
    for sku in ["A-100", "B-200", "A-100", "B-200"]:
        try:
            reply = await nc.request("price.get", json.dumps({"sku": sku}).encode(), timeout=1)
            print("checkout got", reply.data.decode())
        except NoRespondersError:
            print("checkout: no pricing service is running")
        except TimeoutError:
            print("checkout: pricing did not answer within 1s")
    await nc.close()

asyncio.run(main())

مرة على الأكثر، والرموز البديلة

نشغل السيرفر، وننشر رسالة قبل أن يشترك أحد، ثم نشغل مشتركين في الخلفية، الأول على orders.*.created والثاني على orders.>، وننشر ثلاث رسائل:

docker compose up -d --wait nats box
N="docker exec -i lab-nats-box nats"
$N pub orders.sa.created '{"id":1}'
docker exec -d lab-nats-box sh -c 'nats sub "orders.*.created" > /tmp/a.log 2>&1'
docker exec -d lab-nats-box sh -c 'nats sub "orders.>" > /tmp/b.log 2>&1'
sleep 1
$N pub orders.sa.created '{"id":2}'
$N pub orders.ae.paid '{"id":3}'
$N pub orders.sa.riyadh.created '{"id":4}'
sleep 1
docker exec lab-nats-box cat /tmp/a.log
docker exec lab-nats-box cat /tmp/b.log

والمخرج بعد حذف الأسطر الفارغة سوف يكون كما يلي:

10:31:09 Published 8 bytes to "orders.sa.created"
10:31:32 Published 8 bytes to "orders.sa.created"
10:31:33 Published 8 bytes to "orders.ae.paid"
10:31:34 Published 8 bytes to "orders.sa.riyadh.created"
10:31:27 Subscribing on orders.*.created
[#1] Received on "orders.sa.created"
{"id":2}
10:31:28 Subscribing on orders.>
[#1] Received on "orders.sa.created"
{"id":2}
[#2] Received on "orders.ae.paid"
{"id":3}
[#3] Received on "orders.sa.riyadh.created"
{"id":4}

في المخرج أعلاه لاحظ التالي:

  • الرسالة الأولى {"id":1} نشرت بنجاح، لكن لم يستلمها أحد ولن يستلمها أحد، لأنه لم يكن هناك مشترك لحظة نشرها، وهذا هو «مرة على الأكثر».
  • الاشتراك orders.*.created استلم orders.sa.created فقط، ولم يستلم orders.sa.riyadh.created لأن النجمة كلمة واحدة، وهي القاعدة نفسها التي رأيناها في موزع Topic في RabbitMQ.
  • الاشتراك orders.> استلم الثلاث، وكل مشترك استلم نسخته، فهذا نشر واشتراك دون أي إعداد مسبق في السيرفر.

مجموعة طوابير: عاملان يتقاسمان الرسائل

docker exec -d lab-nats-box sh -c 'nats sub "jobs.>" --queue workers > /tmp/w1.log 2>&1'
docker exec -d lab-nats-box sh -c 'nats sub "jobs.>" --queue workers > /tmp/w2.log 2>&1'
sleep 1
$N pub jobs.email 'job {{Count}}' --count 10
sleep 1
echo "worker1: $(docker exec lab-nats-box grep -c Received /tmp/w1.log) worker2: $(docker exec lab-nats-box grep -c Received /tmp/w2.log)"
10:32:26 Published 6 bytes to "jobs.email"
worker1: 6 worker2: 4

لاحظ أن الرسائل العشر توزعت بين المشتركين، ستة لأحدهما وأربعة للآخر، ولم تصل أي رسالة إلى الاثنين معاً، ولاحظ أن التوزيع لم يكن بالتساوي بالضبط. ولاحظ أن هذا ما زال NATS الأساسي، فإذا توقف أحدهما أثناء معالجة رسالة ضاعت، ولا يوجد تأكيد، لذلك هذا التوزيع يناسب الطلبات التي يعيدها المرسل إذا لم يصله رد، أما المهام التي يجب ألا تضيع فمكانها JetStream.

الطلب والرد بين خدمتين

نشغل نسختين من خدمة الأسعار، ثم نشغل خدمة الدفع checkout، ثم نوقف النسختين ونشغل checkout من جديد:

docker compose --profile services build
docker compose --profile services up -d pricing-1 pricing-2
docker compose run --rm -T checkout
docker compose --profile services stop pricing-1 pricing-2
docker compose run --rm -T checkout

والمخرج بعد حذف أسطر Compose سوف يكون كما يلي:

checkout got {"sku": "A-100", "price": 100, "from": "pricing-1"}
checkout got {"sku": "B-200", "price": 250, "from": "pricing-2"}
checkout got {"sku": "A-100", "price": 100, "from": "pricing-2"}
checkout got {"sku": "B-200", "price": 250, "from": "pricing-1"}
checkout: no pricing service is running
checkout: no pricing service is running
checkout: no pricing service is running
checkout: no pricing service is running

في المخرج أعلاه لاحظ التالي:

  • الطلبات الأربعة توزعت على النسختين، دون أي Load Balancer ودون أن يعرف checkout عنوان أي منهما، ولو أضفت نسخة ثالثة لدخلت المجموعة تلقائياً.
  • بعد إيقاف النسختين لم ينتظر checkout مهلة الثانية، وإنما وصله الخطأ NoRespondersError فوراً، وهذا مفيد جداً في الخدمات المصغرة لأنك تعرف مباشرة أن الخدمة متوقفة بدل أن تنتظر، وهي الفكرة التي شرحناها عن الفشل السريع في مقال حماية النظام من الخدمة البطيئة.

ونفس الخطأ تراه من أداة سطر الأوامر عندما لا يوجد من يجيب:

$N request price.get '{"sku":"A-100"}'
10:31:58 Sending request on "price.get"
10:31:58 No responders are available

JetStream: Stream ومستهلك دائم

ننشئ Stream اسمه ORDERS يلتقط كل مواضيع orders.> ويحفظها في ملفات سبعة أيام، ومستهلكاً دائماً اسمه shipping يسحب الرسائل بنفسه Pull ويؤكد كل رسالة وحدها، ويحاول خمس مرات على الأكثر:

$N stream add ORDERS --subjects "orders.>" --storage file --retention limits --max-age 7d --replicas 1 --defaults
$N consumer add ORDERS shipping --pull --ack explicit --deliver all --max-deliver 5 --defaults

والمخرج بعد حذف بعض الأسطر سوف يكون كما يلي:

Stream ORDERS was created

Information for Stream ORDERS created 2026-10-11 10:32:41

                     Subjects: orders.>
                     Replicas: 1
                      Storage: File

Options:

                    Retention: Limits
              Acknowledgments: true
               Discard Policy: Old
             Duplicate Window: 2m0s
...
                  Maximum Age: 7d0h0m0s
Information for Consumer ORDERS > shipping created 2026-10-11 10:32:42

Configuration:

                    Name: shipping
               Pull Mode: true
          Deliver Policy: All
              Ack Policy: Explicit
                Ack Wait: 30.00s
           Replay Policy: Instant
      Maximum Deliveries: 5
         Max Ack Pending: 1,000

لاحظ القيم الافتراضية التي لم نحددها: Ack Wait: 30.00s أي أن الرسالة التي لا تؤكد خلال 30 ثانية تعاد، وDuplicate Window: 2m0s وهي نافذة منع التكرار، وMax Ack Pending: 1,000 وهو أقصى عدد من الرسائل غير المؤكدة مع المستهلك، أي ما يعادل ال Prefetch في RabbitMQ.

الآن ننشر خمس رسائل عبر JetStream، ونقرأ رسالتين ونؤكدهما:

for i in 1 2 3 4 5; do $N pub --jetstream orders.sa.created "{\"order\":$i}"; done
$N consumer next ORDERS shipping --count 2
10:33:00 Published 11 bytes to "orders.sa.created"
10:33:00 Stored in Stream: ORDERS Sequence: 1
...
10:33:04 Published 11 bytes to "orders.sa.created"
10:33:04 Stored in Stream: ORDERS Sequence: 5
[10:33:04] subj: orders.sa.created / tries: 1 / cons seq: 1 / str seq: 1 / pending: 4

{"order":1}

Acknowledged message

[10:33:05] subj: orders.sa.created / tries: 1 / cons seq: 2 / str seq: 2 / pending: 3

{"order":2}

Acknowledged message

لاحظ أن النشر مع --jetstream يرد بالرقم الذي حفظت به الرسالة في ال Stream، فالناشر يعرف أنها حفظت، وهذا ما يعادل تأكيد النشر في RabbitMQ، أما النشر العادي فلا يعرف ذلك.

نوقف السيرفر بالقوة، والمستهلك يكمل

نأخذ الرسالة الثالثة دون أن نؤكدها، ثم نوقف السيرفر بالقوة ونعيد تشغيله، وننظر في حالة المستهلك:

$N consumer next ORDERS shipping --no-ack --count 1
docker kill lab-nats-server; docker start lab-nats-server
until docker exec lab-nats-box nats server check connection >/dev/null 2>&1; do sleep 1; done
docker logs lab-nats-server 2>&1 | grep -E "Restored|consumer" | tail -2
$N consumer info ORDERS shipping

والمخرج بعد حذف بعض الأسطر سوف يكون كما يلي:

[10:33:18] subj: orders.sa.created / tries: 1 / cons seq: 3 / str seq: 3 / pending: 2

{"order":3}
[1] 2026/10/11 10:33:23.403601 [INF]   Restored 5 messages for stream '$G > ORDERS' in 1ms
[1] 2026/10/11 10:33:23.403690 [INF]   Recovering 1 consumers for stream - '$G > ORDERS'
State:

  Last Delivered Message: Consumer sequence: 3 Stream sequence: 3
    Acknowledgment Floor: Consumer sequence: 2 Stream sequence: 2
        Outstanding Acks: 1 out of maximum 1,000
    Redelivered Messages: 0
    Unprocessed Messages: 2

لاحظ أن السيرفر استعاد الرسائل الخمس من القرص، واستعاد المستهلك بحالته: آخر رسالة أكدت هي الثانية Acknowledgment Floor، والثالثة سلمت ولم تؤكد Outstanding Acks: 1، وبقيت رسالتان لم تسلما. الآن ننتظر حتى تنتهي مدة ال Ack Wait ونقرأ الباقي:

sleep 25
$N consumer next ORDERS shipping --count 3
$N consumer info ORDERS shipping | grep -E "Unprocessed|Outstanding"
$N stream info ORDERS | grep Messages:
[10:34:00] subj: orders.sa.created / tries: 2 / cons seq: 4 / str seq: 3 / pending: 2
{"order":3}
[10:34:00] subj: orders.sa.created / tries: 1 / cons seq: 5 / str seq: 4 / pending: 1
{"order":4}
[10:34:00] subj: orders.sa.created / tries: 1 / cons seq: 6 / str seq: 5 / pending: 0
{"order":5}
        Outstanding Acks: 0 out of maximum 1,000
    Unprocessed Messages: 0
                     Messages: 5

في المخرج أعلاه لاحظ التالي:

  • الرسالة الثالثة عادت مع tries: 2، فلم تضع مع أنها كانت مع المستهلك لحظة إيقاف السيرفر، وهذا الفرق بين JetStream وNATS الأساسي الذي رأيناه قبل قليل.
  • رقم المستهلك cons seq زاد إلى 4 بينما رقم ال Stream بقي 3، فالأول يعد مرات التسليم والثاني يعرف الرسالة نفسها.
  • ال Stream ما زال فيه الرسائل الخمس بعد تأكيدها كلها، لأن سياسة الاحتفاظ Limits تحذف بحسب المدة والحجم وليس بحسب التأكيد، فمستهلك جديد يستطيع أن يقرأها من البداية.

منع التكرار بمعرف الرسالة

for i in 1 2; do $N pub --jetstream -H Nats-Msg-Id:order-6 orders.sa.created '{"order":6}'; done
$N stream info ORDERS | grep -E "Messages:|Last Sequence"
10:34:30 Published 11 bytes to "orders.sa.created"
10:34:30 Stored in Stream: ORDERS Sequence: 6
10:34:31 Published 11 bytes to "orders.sa.created"
10:34:31 Stored in Stream: ORDERS Sequence: 6 Duplicate: true
             Maximum Messages: unlimited
                     Messages: 6
                Last Sequence: 6 @ 2026-10-11 10:34:30

لاحظ أن النشر الثاني بالمعرف نفسه رد بالرقم 6 نفسه مع Duplicate: true، وبقي عدد الرسائل ستاً، فإذا أعاد ناشرك الإرسال بعد انقطاع خلال دقيقتين فلن تتكرر الرسالة في ال Stream، لكن تذكر أن هذا لا يغني عن مستهلك آمن عند التكرار كما في مقال Idempotency، والسبب أن إعادة التسليم إلى المستهلك، كما رأينا مع الرسالة الثالثة، شيء آخر.

مراقبة مفتاح في KV

ننشئ Bucket اسمه config يحفظ آخر خمس قيم لكل مفتاح، ونشغل مراقباً في الخلفية، ثم نغير مفتاحين ونحذف أحدهما:

$N kv add config --history 5
docker exec -d lab-nats-box sh -c 'nats kv watch config > /tmp/watch.log 2>&1'
sleep 1
$N kv put config feature.new_checkout off
$N kv put config feature.new_checkout on
$N kv put config payments.timeout_ms 3000
$N kv del config payments.timeout_ms -f
sleep 1
docker exec lab-nats-box cat /tmp/watch.log
$N kv history config feature.new_checkout
$N stream ls -a

والمخرج بعد حذف بعض الأسطر سوف يكون كما يلي:

[2026-10-11 10:34:51] PUT config > feature.new_checkout: off
[2026-10-11 10:34:52] PUT config > feature.new_checkout: on
[2026-10-11 10:34:52] PUT config > payments.timeout_ms: 3000
[2026-10-11 10:34:53] DELETE config > payments.timeout_ms
╭──────────────────────────────────────────────────────────────────────────────╮
│                   History for config > feature.new_checkout                  │
├──────────────────────┬──────────┬─────┬─────────────────────┬────────┬───────┤
│ Key                  │ Revision │ Op  │ Created             │ Length │ Value │
├──────────────────────┼──────────┼─────┼─────────────────────┼────────┼───────┤
│ feature.new_checkout │        1 │ PUT │ 2026-10-11 10:34:51 │ 3      │ off   │
│ feature.new_checkout │        2 │ PUT │ 2026-10-11 10:34:52 │ 2      │ on    │
╰──────────────────────┴──────────┴─────┴─────────────────────┴────────┴───────╯
╭─────────────────────────────────────────────────────────────────────────────────╮
│                                     Streams                                     │
├───────────┬─────────────┬─────────────────────┬──────────┬───────┬──────────────┤
│ Name      │ Description │ Created             │ Messages │ Size  │ Last Message │
├───────────┼─────────────┼─────────────────────┼──────────┼───────┼──────────────┤
│ KV_config │             │ 2026-10-11 10:34:48 │ 4        │ 286 B │ 2.38s        │
│ ORDERS    │             │ 2026-10-11 10:32:41 │ 6        │ 386 B │ 25.08s       │
╰───────────┴─────────────┴─────────────────────┴──────────┴───────┴──────────────╯

في المخرج أعلاه لاحظ التالي:

  • المراقب استلم كل تغيير لحظة حدوثه، ومنها الحذف، فخدمة تراقب feature.new_checkout تستطيع أن تفعل الميزة أو تعطلها دون إعادة تشغيل.
  • التاريخ يحفظ القيم السابقة برقم مراجعة Revision لكل منها، وهو ما يسمح بالتحديث المشروط: «غير القيمة فقط إذا كانت المراجعة الحالية 2».
  • ال Bucket ظهر في قائمة ال Streams باسم KV_config وفيه أربع رسائل، وهذا ما قاله التوثيق: KV مبني على ال Streams، فكل ما تعرفه عن ال Streams من نسخ ومدة حفظ ينطبق عليه.

ملف في مخزن الكائنات

$N object add reports
docker exec lab-nats-box sh -c 'head -c 3000000 /dev/urandom > /tmp/report-2026-10.pdf'
$N object put reports /tmp/report-2026-10.pdf --name report-2026-10.pdf -f
$N object ls reports
docker stats --no-stream --format '{{.Name}} {{.MemUsage}}' lab-nats-server
Object information for reports > report-2026-10.pdf

               Size: 2.9 MiB
  Modification Time: 2026-10-11 10:35:02
             Chunks: 23
             Digest: SHA-256 b9fcd4a21ee7876d5ce390243b1fad21449145403f02dc3a073983b327ac1663
╭────────────────────────────────────────────────────╮
│                   Bucket Contents                  │
├────────────────────┬─────────┬─────────────────────┤
│ Name               │ Size    │ Time                │
├────────────────────┼─────────┼─────────────────────┤
│ report-2026-10.pdf │ 2.9 MiB │ 2026-10-11 10:35:02 │
╰────────────────────┴─────────┴─────────────────────╯

lab-nats-server 18.76MiB / 31.29GiB

لاحظ أن الملف قسم إلى 23 جزءاً وحفظت معه بصمته، وأن السيرفر بعد كل ما فعلناه، من Streams ومستهلكين وKV وملف بثلاثة ميجابايتات، يستهلك أقل من 19 ميجابايت من الذاكرة، وهذا ما يجعل NATS مناسباً حتى للأجهزة الصغيرة.

التشغيل في الإنتاج

  • العنقود: ثلاثة سيرفرات أو خمسة، وكل Stream مهم بثلاث نسخ --replicas 3، كما يقول توثيق العنقود.
  • الحماية: سيرفر التجربة دون مستخدمين، أما في الإنتاج ففعل المصادقة، وأقلها مستخدم وكلمة مرور لكل خدمة، وفعل TLS، وحدد لكل خدمة المواضيع التي تنشر إليها وتشترك فيها فقط، والسبب أن أي عميل متصل دون قيود يستطيع أن يشترك في > فيقرأ كل شيء.
  • المراقبة: منفذ المراقبة 8222 الذي استخدمناه في فحص الصحة يعطي إحصاءات الاتصالات والاشتراكات وJetStream بصيغة JSON، فاجمعها في نظام المراقبة لديك، ولا تنشره على الإنترنت.
  • النسخ الاحتياطي: الأمر nats backup stream في أداة nats يأخذ نسخة من Stream محدد عبر الشبكة، والأمر القديم nats stream backup تظهر معه رسالة أنه سيحذف، وحماية البيانات اليومية هي النسخ على ثلاثة سيرفرات.

أخطاء شائعة

  • الاعتماد على NATS الأساسي لرسائل يجب ألا تضيع: رأينا أن الرسالة التي تنشر دون مشترك تضيع، فاستخدم JetStream لكل ما يجب أن يبقى.
  • Stream بنسخة واحدة على عنقود: لا يتحمل سقوط سيرفره، فحدد عدد النسخ عند الإنشاء.
  • النشر العادي إلى Stream: النشر دون --jetstream، أي دون انتظار الرد، لا يخبرك هل حفظت الرسالة، فاستخدم النشر الذي ينتظر الرقم في الكود كما في الأداة.
  • مستهلك يأخذ وقتاً أطول من Ack Wait: ستعاد الرسالة إلى مستهلك آخر وهو ما زال يعالجها، فاضبط المدة على أطول معالجة متوقعة، أو أرسل إشارة تقدم من المستهلك.
  • الاعتماد على Nats-Msg-Id وحده: يمنع التكرار في ال Stream خلال النافذة فقط، أما إعادة التسليم إلى المستهلك فتحتاج مستهلكاً آمناً عند التكرار.
  • عميل دون صلاحيات على المواضيع: كل خدمة يجب أن ترى مواضيعها فقط.

حذف المختبر

docker compose --profile services --profile tools down -v
docker image rm lab-nats-app:1

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • NATS وسيط صغير مكتوب بلغة Go برخصة Apache 2.0، استهلك في تجربتنا أقل من 19 ميجابايت، ويناسب الطلب والرد بين الخدمات المصغرة والأخبار اللحظية والأجهزة الطرفية.
  • في 2025 طلبت Synadia أن تخرج NATS من CNCF وتغير رخصتها إلى BSL، وانتهى الخلاف باتفاق نقلت فيه العلامة التجارية إلى Linux Foundation وبقي المشروع في CNCF برخصة Apache 2.0.
  • NATS الأساسي يوصل الرسالة لمن يستمع الآن فقط، مرة على الأكثر، والموضوعات مع * و> تحدد من يستلم، والطلب والرد مع مجموعة طوابير يوزع الطلبات ويرد فوراً بأنه لا يوجد من يجيب إذا توقفت الخدمة.
  • JetStream يحفظ الرسائل في Streams، والمستهلك الدائم يحفظ موقعه، وفي تجربتنا عادت الرسالة غير المؤكدة بعد إيقاف السيرفر بالقوة مع tries: 2.
  • معرف الرسالة Nats-Msg-Id يمنع التكرار في ال Stream خلال نافذة دقيقتين، وKV ومخزن الكائنات مبنيان على ال Streams.

وبهذا نكون قد شغلنا الأدوات الأربعة في المرحلة، وفي مقال قادم بإذن الله نقارن بين RabbitMQ وKafka وNATS في جدول واحد يساعدك على الاختيار.

دليلRabbitMQ: طوابير موثوقة لتطبيقاتك على سيرفرككيف تشغل RabbitMQ في عنقود من ثلاث عقد، وتوجه الرسائل بالموزعات، وتضمن ألا تضيع رسالة إذا سقطت عقدة.دليلApache Kafka: بث الأحداث بأحجام ضخمةكيف يحفظ عنقود Kafka أحداثك بنسخ على عدة سيرفرات، وماذا يحدث للمنتجين والمستهلكين عندما يسقط أحدها.دليلRedpanda: بديل متوافق مع Kafka وأخف تشغيلاًكيف يعمل Redpanda بواجهة Kafka نفسها دون JVM، وما الذي تسمح به رخصته، وكم يوفر من الموارد فعلاً.دليلالطابور Queue للمبتدئين: لماذا تحتاجه وكيف يعمللماذا تضع العمل البطيء في طابور بدل أن ينتظره المستخدم، وكيف تضمن ألا تضيع الرسالة إذا توقف ال Worker فجأة.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ تجربة NATS 2.15.0 مع JetStream، وأداة nats 0.5.0 من natsio/nats-box 0.20.0، ومكتبة nats-py 2.16.0 على Python 3.13.16 في Docker.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.