انتقال للمقال
وقت القراءة: ≈ 15 دقيقة

استخدام Redis كـ Memory مشترك

السلام عليكم ورحمة الله وبركاته


المقدمة

في المقالة السابقة موازنة الحمل بـ Nginx كـ Load Balancer قمنا بإضافة طبقة Nginx كـ Load Balancer لتوزيع الطلبات بين ثلاث نسخ من الـ nodejs-service داخل Docker Compose
وتعلمنا كيف نزيد ونقلل عدد النسخ بسهولة باستخدام --scale أو replicas
وكانت النسخ الثلاثة تشترك في نفس قاعدة البيانات MySQL وهذا يعني أن أي بيانات تُكتب من خلال أي نسخة تظهر في كل النسخ الأخرى

في نهاية المقالة السابقة طرحنا تمرينًا عمليًا وهو إضافة طبقة Redis كـ Cache مشترك بين النسخ
في هذه المقالة سنطبق هذا التمرين بالكامل، لكن قبل أن نبدأ في الكود، سنفهم أولًا لماذا نحتاج Redis أصلًا، وما المشكلة التي يحلها في بيئة الـ Horizontal Scaling

لن أقوم بإعادة شرح المشروع الذي بنيناه في المقالة السابقة، لأننا شرحناه بالتفصيل في المقالة السابقة، نحن فقط سنقوم باستكمال المشروع بإضافة Redis كـ Service جديد في Docker Compose
لكن لا تقلق سأعرض الأجزاء المهمة والتغيرات التي سنجريها بشكل واضح

استرجاع المثال الذي بنيناه في المقالة السابقة

دعنا نتذكر شكل مشروعنا الذي بنيناه في المقالة السابقة
كان لدينا ثلاث نسخ من nodejs-service تعمل خلف Nginx كـ Load Balancer، وكلها تتصل بنفس الـ mysql-service

services:
  mysql-service:
    image: mysql:9.6.0
    environment:
      - MYSQL_ROOT_PASSWORD=tabarani-very-secret
      - MYSQL_DATABASE=tabarani-app
    volumes:
      - mysql-tabarani-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 20s
      retries: 10

  nodejs-service:
    build: .
    env_file:
      - .env
    depends_on:
      mysql-service:
        condition: service_healthy
    deploy:
      replicas: 3

  nginx-service:
    image: nginx:latest
    ports:
      - "8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - nodejs-service

volumes:
  mysql-tabarani-db:

وهذا شكل المشروع كما رأيناه في المقالة السابقة

                     +-----------------+
   Client ---------> |    Nginx 8080   |
                     |  Load Balancer  |
                     +-----------------+
                              |
         +--------------------+-------------------+
         |                    |                   |
         v                    v                   v
 +----------------+  +----------------+  +----------------+
 | nodejs-service |  | nodejs-service |  | nodejs-service |
 | replica #1     |  | replica #2     |  | replica #3     |
 | internal:3000  |  | internal:3000  |  | internal:3000  |
 +----------------+  +----------------+  +----------------+
          \                   |                  /
           \                  |                 /
            +-----------------+----------------+
                              |
                              v
                       +-------------+      +-------------+
                       |    MySQL    | ---> |    Volume   |
                       +-------------+      +-------------+

الطلبات تتوزع بين النسخ الثلاثة، والبيانات مشتركة في MySQL بين كل النسخ

ملحوظة: ما يهمنا الآن أن كل نسخة من nodejs-service لها RAM خاصة بها، بالتالي لو قامت نسخة بتخزين بيانات في الـ RAM الخاص بها، النسخ الأخرى لن ترى هذه البيانات لأنها في RAM مختلفة وهذا هو جوهر المشكلة التي سنحلها باستخدام Redis

الـ Cache العادي على مستوى الـ Memory

الـ Cache ببساطة هو تخزين نتيجة عملية ما في مكان سريع، لكي لا نعيد تنفيذ نفس العملية كل مرة
على سبيل المثال بدل أن نرسل Query إلى قاعدة البيانات في كل مرة لجلب قائمة المنتجات
نستطيع أن نخزن النتيجة مؤقتًا في Cache، وفي المرة القادمة نجلبها من الـ Cache مباشرةً بدون الذهاب إلى قاعدة البيانات

الطريقة الأبسط لعمل Cache في Node.js هي تخزين البيانات في متغير داخل الكود مباشرةً في الـ RAM الخاصة بالـ Process

const cache = new Map();

app.get("/products", async (req, res) => {
  // Check Cache first
  if (cache.has("products")) {
    return res.json({ products: cache.get("products"), source: "cache" });
  }

  // If not in Cache, fetch from database
  const [rows] = await connection.execute("SELECT * FROM products");
  cache.set("products", rows); // Store in Cache

  res.json({ products: rows, source: "database" });
});

هنا مثال بسيط جدًا لعمل Cache باستخدام Map في Node.js
نقوم بعمل متغير يدعى cache وهو عبارة عن Map لتخزين البيانات
ثم عندما يقوم الشخص بطلب /products، نتحقق أولًا هل البيانات موجودة في cache أم لا
إذا كانت موجودة نرجعها مباشرةً، وإذا لم تكن موجودة نذهب لقاعدة البيانات، ثم نخزن النتيجة في cache للطلبات القادمة

بالطبع يمكننا استخدام مكتبات أخرى مثل node-cache أو lru-cache لكن أريد أن أبسط الفكرة لأشرحها بشكل واضح لا أكثر

عندما نجد أن البيانات موجودة في cache نسميها Cache HIT، وعندما لا نجدها نسميها Cache MISS
الهدف من الـ Cache هو تقليل الضغط على قاعدة البيانات وتسريع الاستجابة للطلبات المتكررة
مع إعطاء مهلة صلاحية للـ Cache بحيث لا تبقى البيانات قديمة إلى الأبد
وهذه المهلة تحددها حسب طبيعة البيانات، مثلاً في هذا المثال قد نريد أن نجدد الـ Cache كل دقيقة أو كل ساعة حسب طبيعة المنتجات وتغيرها

const NodeCache = require("node-cache");
const myCache = new NodeCache();

app.get("/products", async (req, res) => {
  const cachedProducts = myCache.get("products");
  if (cachedProducts) {
    // Cache HIT
    return res.json({ products: cachedProducts, source: "cache" });
  }

  // Cache MISS
  const [rows] = await connection.execute("SELECT * FROM products");
  myCache.set("products", rows, 60); // Cache for 60 seconds

  res.json({ products: rows, source: "database" });
});

هذا نفس المثال السابق لكن باستخدام مكتبة node-cache وستلاحظ أنني حددت مهلة صلاحية للـ Cache وهي 60 ثانية، أي بعد 60 ثانية سيتم حذف البيانات من الـ Cache تلقائيًا

أين المشكلة في استخدام الـ Cache العادي ؟

لو كان مشروعك يعمل على نسخة واحدة فقط، هذا النوع من الـ Cache سيكون مفيد جدًا وسيقلل الضغط على قاعدة البيانات بشكل كبير
لكن في بيئة الـ Horizontal Scaling حيث لدينا عدة نسخ من نفس الـ Service، هذا النوع من الـ Cache يصبح بلا فائدة

لأنك عندما تقوم بعمل Horizontal Scaling وتكرار نسخ الـ nodejs-service
فكل نسخة من الـ nodejs-service تعمل داخل container مختلف، وكل container له RAM خاصة به ومستقلة تمامًا عن باقي الـ containers
بالتالي لو نسخة واحدة قامت بتخزين البيانات في الـ cache الخاص بها، النسخ الأخرى لن ترى هذه البيانات لأنها في RAM مختلفة

 +----------------+  +----------------+  +----------------+
 | nodejs-service |  | nodejs-service |  | nodejs-service |
 | replica #1     |  | replica #2     |  | replica #3     |
 |                |  |                |  |                |
 | RAM: Cache A   |  | RAM: Cache B   |  | RAM: Cache C   |
 +----------------+  +----------------+  +----------------+

ومشكلة الـ Cache المنعزل بين النسخ ليست المشكلة الوحيدة، بل نفس المشكلة تظهر في حالات أخرى مثل Rate Limiting و Cache Lock والـ Socket Connections أو الأمور التي تحتاج إلى مشاركة البيانات بين النسخ المختلفة

وكما تعرف أننا في الـ Horizontal Scaling سيكون لدينا Load Balancer يوزع الطلبات بين النسخ بشكل عشوائي
وبالتالي لا يمكننا ضمان أن الطلب القادم سيذهب إلى نفس النسخة التي خزنت فيها البيانات في الـ Cache
أو ممكن أن شخصًا ما تجاوز الحد المسموح به في Rate Limiting في نسخة معينة، لكن الطلب القادم يذهب لنسخة أخرى لا تعرف أن هذا المستخدم تجاوز الحد
أو أن هناك Real-Time Connection مثل WebSocket وهناك مجموعة من المستخدمين يريدون التواصل فيما بينهم، لكن الـ Load Balancer يوزع كل شخص إلى نسخة مختلفة، مما ينشيء Socket Connection بين كل شخص ونسخة مختلفة، وبالتالي ستجد صعوبة في إدارة هذه الأمور

وهذا هو جوهر المشاكل التي نحتاج إلى حلها عندما نتعامل مع بيئة الـ Horizontal Scaling

ونستطيع إختصار المشكلة والحل أننا نحتاج إلى نظام تخزين مشترك بين النسخ
أو بمعنى آخر Memory مشتركة بين كل النسخ، بحيث كل نسخة تكتب وتقرأ من نفس المكان المشترك

ما هو Redis ؟

الـ Redis هو Server مستقل بذاته يعمل على port منفصل، ووظيفته الأساسية هي أن يكون نظام تخزين في الـ RAM
حتى أنه يصنف كـ In-Memory Data Store أو In-Memory Database، أي قاعدة بيانات تتعامل مع البيانات في الـ RAM بدلاً من الـ Disk
اسم Redis اختصار لـ Remote Dictionary Server أي أنه Server مستقل لتخزين البيانات في شكل key-value في الـ RAM

الفرق الجوهري بينه وبين قاعدة البيانات التقليدية هو مكان التخزين
بحيث أن الـ Database التقليدية مثل MySQL تخزن البيانات في الـ Disk، بينما Redis يخزن البيانات في الـ RAM
ومن المتعارف عليه أن الـ RAM أسرع بكثير من الـ Disk، وهذا هو سبب سرعة Redis
لكن الـ RAM تعد مكان تخزين مؤقت، أي أن البيانات الموجودة في Redis ستفقد إذا تم إيقاف الـ Server أو إعادة تشغيله، بينما البيانات في MySQL ستبقى محفوظة حتى بعد إعادة التشغيل لأنها مخزنة في الـ Disk

لذا كل منهما له استخداماته، الـ MySQL يستخدم للتخزين الدائم للبيانات، بينما Redis يستخدم للتخزين المؤقت والسريع للبيانات التي تحتاج إلى وصول سريع ومشاركة بين نسخ متعددة
وهذا ما نحتاجه عندما نتعامل مع الـ Cache و Rate Limiting و Distributed Lock وغيرها في بيئة الـ Horizontal Scaling

لذا يعد Redis هو الحل المثالي عندما نحتاج إلى Memory مشتركة بين نسخ متعددة من نفس الـ Service

+-----------------------------------------------------------------+
|                           Docker Network                        |
|                                                                 |
|  +-----------------+  +-----------------+  +------------------+ |
|  | nodejs-service  |  | nodejs-service  |  | nodejs-service   | |
|  | replica #1      |  | replica #2      |  | replica #3       | |
|  +-----------------+  +-----------------+  +------------------+ |
|          |                     |                     |          |
|          +---------------------+---------------------+          |
|                                |                                |
|                                v                                |
|                   +------------------------+                    |
|                   |         Redis          |                    |
|                   |   Shared Memory for    |                    |
|                   |     all replicas       |                    |
|                   +------------------------+                    |
|                                                                 |
+-----------------------------------------------------------------+

بدل ما كل نسخة تحتفظ بـ cache خاص بها في RAM مستقلة، كل النسخ الآن تقرأ وتكتب في نفس الـ Memory في Redis
وبالتالي لو النسخة الأولى قامت بعمل Cache في Redis، النسخة الثانية والثالثة يمكنهم قراءة هذا الـ Cache مباشرةً من Redis دون الحاجة للذهاب لقاعدة البيانات

ماذا لو كررنا Node.js بدون Redis ؟

دعنا نرى بشكل عملي ماذا يحدث لو حاولنا عمل Cache بالطريقة القديمة داخل الكود مع وجود ثلاث نسخ
لنفترض أن لدينا هذا الـ endpoint الذي يعمل Cache بسيط على مستوى الـ Process

const NodeCache = require("node-cache");
const cache = new NodeCache();

app.get("/products", async (req, res) => {
  const hostname = os.hostname();

  if (cache.has("products")) {
    // Cache HIT
    return res.json({
      products: cache.get("products"),
      source: "cache",
      server: hostname,
    });
  }

  // Cache MISS
  const [rows] = await connection.execute("SELECT * FROM products");
  cache.set("products", rows, 60); // Cache for 60 seconds

  res.json({
    products: rows,
    source: "database",
    server: hostname,
  });
});

هنا قمنا بعمل Cache باستخدام مكتبة node-cache داخل كل نسخة من الـ Node.js
وقمنا بتعديل الـ GET /products بحيث يتحقق أولًا من الـ Cache فإذا وجد البيانات يرجعها مباشرةً، وإذا لم يجدها يذهب لقاعدة البيانات ثم يخزن النتيجة في الـ Cache الخاص به
وكل شيء نرجع بيانات مثل source و server لكي نعرف من أين جاءت البيانات ومن أي نسخة

لنقم بإعادة تشغيل المشروع الآن:

> docker compose up -d --build
[+] Building 0.6s (11/11) FINISHED                                                                                                        docker:desktop-linux
...
 => [nodejs-service 1/5] FROM docker.io/library/node:25-alpine@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4                    0.0s
 => => resolve docker.io/library/node:25-alpine@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4                                   0.0s
 => [nodejs-service internal] load build context                                                                                                          0.0s
 => => transferring context: 189B                                                                                                                         0.0s
 => CACHED [nodejs-service 2/5] WORKDIR /app                                                                                                              0.0s
 => [nodejs-service 3/5] COPY package*.json .                                                                                                             0.0s
 => [nodejs-service 4/5] RUN npm install                                                                                                                 69.3s
 => [nodejs-service 5/5] COPY . .                                                                                                                         0.1s
...
[+] Running 6/6
 ✔ nodejs-service                            Built                                                                                                        0.0s
 ✔ Container redis-example-mysql-service-1   Healthy                                                                                                      1.1s
 ✔ Container redis-example-nginx-service-1   Started                                                                                                      1.9s
 ✔ Container redis-example-nodejs-service-3  Started                                                                                                      1.6s
 ✔ Container redis-example-nodejs-service-2  Started                                                                                                      1.3s
 ✔ Container redis-example-nodejs-service-1  Started                                                                                                      1.1s

قمنا بعمل --build لأننا قمنا بتعديلات على ملف app.js واستخدمنا مكتبة node-cache
الآن سنضيف بعض البيانات إلى قاعدة البيانات لكي نختبر الـ Cache

> curl -X POST http://localhost:8080/products -H "Content-Type: application/json" -d '{"name":"Laptop","price":1200}'
{"id":1,"name":"Laptop","price":1200,"message":"Product created on server running on host da81f1eb9991"}

> curl -X POST http://localhost:8080/products -H "Content-Type: application/json" -d '{"name":"Phone","price":800}'
{"id":2,"name":"Phone","price":800,"message":"Product created on server running on host 00eecec2d1be"}

أضفنا منتجين جديدين إلى قاعدة البيانات، والآن كل نسخة من الـ Node.js لديها بيانات في قاعدة البيانات، لكن الـ Cache الخاص بكل نسخة لا يزال فارغًا

الآن لنرسل عدة طلبات متتالية لجلب المنتجات ونرى كيف يتصرف الـ Cache في كل نسخة

> curl http://localhost:8080/products
{"products":[...],"source":"database","server":"da81f1eb9991"}

> curl http://localhost:8080/products
{"products":[...],"source":"database","server":"00eecec2d1be"}

> curl http://localhost:8080/products
{"products":[...],"source":"cache","server":"00eecec2d1be"}

> curl http://localhost:8080/products
{"products":[...],"source":"database","server":"e246539cd33e"}

لاحظ أن الطلب الأول ذهب إلى النسخة الأولى da81f1eb9991، ولم يجد البيانات في الـ Cache، لذلك ذهب لقاعدة البيانات وجلب البيانات منها، ثم خزنها في الـ Cache الخاص به
ثم جاء الطلب الثاني وذهب إلى النسخة الثانية 00eecec2d1be، ولم يجد البيانات في الـ Cache الخاص به أيضًا، لذلك ذهب لقاعدة البيانات وجلب البيانات منها، ثم خزنها في الـ Cache الخاص به
الطلب الثالث جاء إلى نفس النسخة الثانية 00eecec2d1be، هذه المرة وجد البيانات في الـ Cache الخاص به، لذلك رجعها مباشرةً بدون الذهاب لقاعدة البيانات

الطلب الرابع جاء إلى النسخة الثالثة e246539cd33e، ولم يجد البيانات في الـ Cache الخاص به، لذلك ذهب لقاعدة البيانات وجلب البيانات منها، ثم خزنها في الـ Cache الخاص به

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

+------------------------------------------------------------+
| Request 1 --> replica #1 -> Cache MISS --> Query Database  |
|   -> Store in Cache A (RAM of replica #1)                  |
|                                                            |
| Request 2 --> replica #2 -> Cache MISS --> Query Database  |
|   -> Store in Cache B (RAM of replica #2)                  |
|                                                            |
| Request 3 --> replica #2 -> Cache HIT -> Read from Cache B |
|                                                            |
| Request 4 --> replica #3 -> Cache MISS --> Query Database  |
|   -> Store in Cache C (RAM of replica #3)                  |
+------------------------------------------------------------+

الـ Cache الذي كان من المفترض أن يوفر لنا سرعة ويقلال الضغط على قاعدة البيانات، أصبح لا فائدة منه بل ربما أصبح يضرنا أكثر ما ينفعنا
هذه هي المشكلة الجوهرية التي سيحلها Redis

لنقم بوقف الـ Docker Compose

> docker compose down
[+] Running 6/6
 ✔ Container redis-example-nginx-service-1   Removed                                                                                                                                                0.6s
 ✔ Container redis-example-nodejs-service-3  Removed                                                                                                                                                1.2s
 ✔ Container redis-example-nodejs-service-1  Removed                                                                                                                                                1.4s
 ✔ Container redis-example-nodejs-service-2  Removed                                                                                                                                                1.3s
 ✔ Container redis-example-mysql-service-1   Removed                                                                                                                                                1.9s
 ✔ Network redis-example_default             Removed                                                                                                                                                0.7s

إضافة Redis كـ Service في Docker Compose

الآن سنضيف Redis كـ Service جديد في ملف docker-compose.yml
Redis له image رسمي جاهز مثل MySQL تمامًا، وكل ما نحتاجه هو إضافته كـ Service وتوصيل النسخ به

services:
  mysql-service: ...

  redis-service:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      timeout: 5s
      retries: 5

  nodejs-service:
    build: .
    env_file:
      - .env
    depends_on:
      mysql-service:
        condition: service_healthy
      redis-service:
        condition: service_healthy
    deploy:
      replicas: 3

  nginx-service: ...
...

قمنا بإضافة redis-service وهو يستخدم الـ image الرسمية redis:7-alpine
اخترنا alpine لأنها نسخة خفيفة جدًا من الـ image، وهي تحتوي على كل ما نحتاجه دون زيادة في الحجم

لاحظ أننا أضفنا healthcheck للـ redis-service يستخدم الأمر redis-cli ping
لنرسل ping لـ Redis لتأكيد أنه يعمل بشكل صحيح

وأضفنا الـ redis-service في depends_on الخاص بالـ nodejs-service مع شرط service_healthy لكي نضمن أن Redis جاهز قبل أن تبدأ النسخ في محاولة الاتصال به

لاحظ أيضًا أننا لم نضف port mapping للـ redis-service
كما فعلنا مع الـ mysql-service، بحيث أننا لا نريد أن يكون Redis متاحًا من خارج الـ Docker Network
فهو فقط يحتاج أن يكون متاحًا داخل الـ Docker Network لكي تتواصل معه نسخ الـ nodejs-service فقط


ثم نضيف إعدادات الاتصال بـ Redis في ملف .env

# APP Port
PORT=3000

# Database configuration
DB_HOST=mysql-service
DB_PORT=3306
DB_USER=root
DB_PASSWORD=tabarani-very-secret
DB_NAME=tabarani-app

# Redis configuration
REDIS_HOST=redis-service
REDIS_PORT=6379

نفس المبدأ الذي استخدمناه مع MySQL، نستخدم اسم الـ Service وليس اسم الـ Container
الـ 6379 هو الـ port الافتراضي لـ Redis

تعديل كود الـ Node.js لاستخدام Redis

الآن سنعدل ملف docker-compose.yml لنضيف service جديد للـ Redis

services:
  mysql-service: ...

  redis-service:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      timeout: 5s
      retries: 5

  nodejs-service:
    build: .
    env_file:
      - .env
    depends_on:
      mysql-service:
        condition: service_healthy
      redis-service:
        condition: service_healthy
    deploy:
      replicas: 3

  nginx-service: ...

هنا قمنا بإضافة redis-service كـ Service جديد، وأضفناه في depends_on الخاص بالـ nodejs-service مع شرط service_healthy لكي نضمن أن Redis جاهز قبل أن تبدأ النسخ في محاولة الاتصال به

الآن في كود الـ Node.js سنستخدم مكتبة redis الرسمية للتواصل مع Redis من داخل كود الـ Node.js
سنقوم بإنشاء client للاتصال بـ Redis باستخدام المكتبة الرسمية

const { createClient } = require("redis");
const redis = createClient({
  socket: {
    host: process.env.REDIS_HOST || "localhost",
    port: process.env.REDIS_PORT || 6379,
  },
});

الـ port الافتراضي لـ Redis هو 6379
والـ host سيكون redis-service كما هو محدد في ملف الـ .env

والـ redis-service هو اسم الـ Service الذي أضفناه في docker-compose.yml، وهذا يعني أن كل النسخ من الـ nodejs-service ستتصل بنفس الـ Redis المشترك

// dbConfig and connectDB() code ...

async function bootstrap() {
  await redis.connect();
  console.log("Connected to Redis");
  await connectDB();
  app.listen(port, () => {
    console.log(`Server running on host ${os.hostname()} at port ${port}`);
  });
}

app.get("/products", async (req, res) => {
  const hostname = os.hostname();
  const cached = await redis.get("products");

  if (cached) {
    return res.json({
      products: JSON.parse(cached),
      source: "cache",
      server: hostname,
    });
  }

  const [rows] = await connection.execute("SELECT * FROM products");
  await redis.set("products", JSON.stringify(rows), { EX: 60 }); // Cache for 60 seconds

  res.json({ products: rows, source: "database", server: hostname });
});

// rest of the code ...

دعنا نستعرض التغييرات التي أجريناها في كود الـ Node.js
أولًا بعد إنشاء client للاتصال بـ Redis، قمنا باستدعاء redis.connect() داخل دالة bootstrap لنتأكد من أننا متصلين بـ Redis قبل أن نبدأ في استقبال الطلبات
ثم في الـ GET /products قمنا بتغيير طريقة التحقق من الـ Cache لأننا الآن نستخدم Redis بدلاً من node-cache
فحن في redis نقوم باستخدام redis.get("products") لجلب البيانات من Redis، وإذا كانت موجودة نرجعها مباشرةً، وإذا لم تكن موجودة نذهب لقاعدة البيانات ثم نخزن النتيجة في Redis باستخدام redis.set("products", JSON.stringify(rows), { EX: 60 })

هناك تفاصيل أخرى لكن لن تهمنا الآن، المهم هو أننا قمنا بتعديل الكود ليستخدم Redis كـ Shared Cache بين كل النسخ من الـ nodejs-service

تشغيل المشروع واختبار الـ Shared Cache

الآن لنقم بتشغيل المشروع ونرى كيف يعمل الـ Shared Cache في الواقع

> docker compose up -d --build
[+] Building 2.5s (12/12) FINISHED                                                                                                        docker:desktop-linux
...
 => [nodejs-service 1/5] FROM docker.io/library/node:25-alpine@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4                    0.0s
 => => resolve docker.io/library/node:25-alpine@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4                                   0.0s
 => [nodejs-service internal] load build context                                                                                                          0.0s
 => => transferring context: 189B                                                                                                                         0.0s
  => CACHED [nodejs-service 2/5] WORKDIR /app                                                                                                                                                        0.0s
 => [nodejs-service 3/5] COPY package*.json .                                                                                                                                                       0.0s
 => [nodejs-service 4/5] RUN npm install                                                                                                                                                           15.9s
 => [nodejs-service 5/5] COPY . .                                                                                                                                                                   0.1s
...
[+] Running 8/8
 ✔ nodejs-service                            Built                                                                                                                                                  0.0s
 ✔ Network redis-example_default             Created                                                                                                                                                0.0s
 ✔ Container redis-example-mysql-service-1   Healthy                                                                                                                                               30.9s
 ✔ Container redis-example-redis-service-1   Healthy                                                                                                                                               30.9s
 ✔ Container redis-example-nodejs-service-3  Started                                                                                                                                               31.1s
 ✔ Container redis-example-nodejs-service-1  Started                                                                                                                                               30.9s
 ✔ Container redis-example-nodejs-service-2  Started                                                                                                                                               31.3s
 ✔ Container redis-example-nginx-service-1   Started                                                                                                                                               31.3s

لاحظ أن redis-service تم إنشاؤه وأصبح Healthy قبل أن تبدأ نسخ الـ nodejs-service، وهذا بسبب الـ depends_on مع شرط service_healthy الذي أضفناه

لنبدأ بإرسال بعض الطلبات لجلب المنتجات ونرى كيف يعمل الـ Shared Cache في Redis

> curl http://localhost:8080/products
{"products":[...],"source":"database","server":"fb255dba6179"}

> curl http://localhost:8080/products
{"products":[...],"source":"cache","server":"f3fae445dc89"}

> curl http://localhost:8080/products
{"products":[...],"source":"cache","server":"f14874b87cf5"}

> curl http://localhost:8080/products
{"products":[...],"source":"cache","server":"fb255dba6179"}

لاحظ أن الطلب الأول ذهب إلى النسخة الأولى fb255dba6179، ولم يجد البيانات في الـ Cache، لذلك ذهب لقاعدة البيانات وجلب البيانات منها، ثم خزنها في الـ Redis المشترك
ثم جاء الطلب الثاني وذهب إلى النسخة الثانية f3fae445dc89، هذه المرة وجد البيانات في الـ Cache المشترك في Redis، لذلك رجعها مباشرةً بدون الذهاب لقاعدة البيانات
الطلب الثالث جاء إلى النسخة الثالثة f14874b87cf5، هذه المرة أيضًا وجد البيانات في الـ Cache المشترك في Redis، لذلك رجعها مباشرةً بدون الذهاب لقاعدة البيانات
الطلب الرابع جاء إلى النسخة الأولى fb255dba6179، وجد البيانات في الـ Cache المشترك في Redis، لذلك رجعها مباشرةً بدون الذهاب لقاعدة البيانات

وهكذا نرى أن كل النسخ تستفيد من نفس الـ Cache في Redis بحيث أن النسخة الأولى التي قامت بعمل Cache في Redis، النسخ الأخرى استطاعت أن ترى هذا الـ Cache وتستخدمه
هكذا أصبح لدينا Shared Cache بين كل النسخ باستخدام Redis لأن كل النسخ تكتب وتقرأ من نفس الـ Memory في Redis
بالتالي Server الـ Redis أصبح هو الـ Memory المشتركة بين كل النسخ التي كنا نحتاجها في بيئة الـ Horizontal Scaling

هكذا قللنا الضغط على قاعدة البيانات بشكل كبير، لأن كل النسخ تستفيد من نفس الـ Cache في Redis، وبالتالي قللنا عدد الـ Cache MISS وزاد عدد الـ Cache HIT مما أدى إلى تحسين الأداء بشكل كبير


لنقم بوقف الـ Docker Compose بعد الانتهاء

> docker compose down
[+] Running 7/7
 ✔ Container redis-example-nginx-service-1   Removed                                                                                                                                                0.5s
 ✔ Container redis-example-nodejs-service-1  Removed                                                                                                                                                1.0s
 ✔ Container redis-example-nodejs-service-2  Removed                                                                                                                                                1.2s
 ✔ Container redis-example-nodejs-service-3  Removed                                                                                                                                                1.3s
 ✔ Container redis-example-mysql-service-1   Removed                                                                                                                                                1.3s
 ✔ Container redis-example-redis-service-1   Removed                                                                                                                                                0.5s
 ✔ Network redis-example_default             Removed                                                                                                                                0.6s

الصورة الكاملة بعد إضافة Redis

الآن شكل مشروعنا النهائي بعد إضافة Redis

                     +-----------------+
   Client ---------> |    Nginx 8080   |
                     |  Load Balancer  |
                     +-----------------+
                              |
         +--------------------+-------------------+
         |                    |                   |
         v                    v                   v
 +----------------+  +----------------+  +----------------+
 | nodejs-service |  | nodejs-service |  | nodejs-service |
 | replica #1     |  | replica #2     |  | replica #3     |
 | internal:3000  |  | internal:3000  |  | internal:3000  |
 +----------------+  +----------------+  +----------------+
          \                   |                  /
           \                  |                 /
            +-----------------+----------------+
                              |
        +---------------------+
        |                     |
        v                     v
  +-------------+      +-------------+      +-------------+
  |    Redis    |      |    MySQL    | ---> |    Volume   |
  +-------------+      +-------------+      +-------------+

الآن كل النسخ من الـ nodejs-service تتواصل مع نفس الـ Redis المشترك لعمل Cache، وتتواصل مع نفس الـ MySQL المشترك لتخزين البيانات الدائمة، وبالتالي أصبح لدينا بيئة متكاملة للـ Horizontal Scaling مع وجود Shared Memory باستخدام Redis

الخلاصة

في هذه المقالة أضفنا طبقة Redis كـ Memory مشتركة لأن مشروعنا كان يعمل في بيئة الـ Horizontal Scaling حيث لدينا عدة نسخ من نفس الـ Service، وكل نسخة لها RAM خاصة بها ومستقلة عن باقي النسخ
تعرفنا على مشكلة في بيئة الـ Horizontal Scaling وهي أن كل نسخة لها Memory خاصة بها، وبالتالي لو قمنا بعمل Cache في كل نسخة، النسخ الأخرى لن ترى هذا الـ Cache لأنها في RAM مختلفة، مما يجعل الـ Cache بلا فائدة في بيئة الـ Horizontal Scaling

ثم شرحنا إن الفائدة الحقيقية لاستخدام Redis هى عندما نقوم بعمل Horizontal Scaling ففي هذه اللحظة نحتاج إلى Memory مشتركة بين كل النسخ، والـ Redis هو الحل المثالي ليكون Server مستقل لتخزين البيانات في الـ RAM ومشاركتها بين كل النسخ

لو كان مشروعك يعمل على نسخة واحدة فقط ولن تقوم بعمل Horizontal Scaling في المستقبل، يمكنك استخدام Cache عادي داخل الكود بدون الحاجة لـ Redis، لكن لو كنت تخطط لعمل Horizontal Scaling في المستقبل، فمن الأفضل أن تبدأ باستخدام Redis من البداية لتجنب المشاكل التي قد تواجهها لاحقًا عندما تقوم بعمل Horizontal Scaling

وبرغم من أن الـ Cache هو أشهر استخدامات Redis، لكنه ليس الاستخدام الوحيد كما ذكرنا
نستخدم الـ Redis مع أي مفهوم يحتاج لمبدأ الـ Shared State بين النسخ المختلفة، مثل الـ Cache
والـ Rate Limiting حيث نحتاج إلى عداد مشترك بين كل النسخ لتتبع عدد الطلبات من كل مستخدم
والـ Cache Lock حيث يجب أن يكون الـ Lock مرئي لجميع النسخ لمنع حدوث تعارض في البيانات فعندما نقوم بعمل Lock في نسخة معينة، يجب أن تحترم كل النسخ هذا الـ Lock وتمنع أي عملية قد تتعارض معه
والـ Socket Connections في تطبيقات الـ Real-Time حيث كل نسخة تحتاج إلى Connection مشترك بينها وبين كل الـ Clients المتصلين بها، بحيث أن كل نسخة تستطيع أن ترى الـ Connections الخاصة بالنسخ الأخرى وتتفاعل معها

وهكذا مع كل المفاهيم التي تحتاج إلى مشاركة البيانات بين النسخ المختلفة، فإن Redis هو الحل المثالي لتوفير Memory مشتركة بين كل النسخ في بيئة الـ Horizontal Scaling


رسالة خاصة

أرسل ملاحظاتك أو رأيك بشكل خاص — لن يظهر للآخرين

التعليقات

شاركنا رأيك في هذه المقالة أو اسأل عن أي شيء يخصها