أغلب الأنظمة تعرف القفزة في الحمل بعد أن تحدث، فمنشور ينتشر فجأة أو خبر يدفع الناس إلى موقعك في نفس الساعة، أما أنظمة الحشود الكبرى فتعرف موعد القفزة قبل أشهر: يوم فتح التسجيل للحج، وآخر يوم للسداد، والعشر الأواخر من رمضان للعمرة، ويوم المباراة النهائية، وموسم الفعاليات الكبرى. وقد يبدو هذا من الوهلة الأولى ميزة تجعل المشكلة سهلة، لكن في الواقع فمعرفة الموعد لا تقلل الحمل، وإنما تزيل العذر، فإذا سقط النظام في يوم معروف منذ سنة فالمشكلة في الاستعداد وليست في المفاجأة، ووراء كل طلب هنا إنسان سافر آلاف الكيلومترات أو ينتظر موعداً لا يتكرر، والخطأ لا يعني صفحة خطأ فقط، وإنما قد يعني زحاماً حقيقياً عند بوابة.
وهذا المقال من قسم دراسات الحالة في خارطة الطريق، ويبني على مقال نظام حجز التذاكر الذي شرحنا فيه منع الحجز المكرر وغرفة الانتظار، وعلى مقال نظام الإشعارات، فلن نكرر ما فيهما، وسوف نركز على ما يخص المواسم الكبرى: التخطيط للسعة، وخطط التخفيف، والرسائل الجماعية، والتقنية في الموقع نفسه.
دليلكيف تبني نظام حجز تذاكر يتحمل لحظة فتح البيعكيف تمنع بيع المقعد الواحد مرتين، وكيف يصمد نظام الحجز عندما يدخل الجميع في الدقيقة نفسها لشراء التذاكر.
دليلكيف تبني نظام إشعارات يصل إلى ملايين المستخدمينكيف تصل الرسالة الصحيحة إلى المستخدم مرة واحدة وفي وقتها عبر الإشعار والرسائل والبريد، حتى عندما يتعطل المزود.
دليلاختبار الأحمال والضغط Load Testing للمبتدئينلماذا يعمل تطبيقك مع خمسة مستخدمين وينهار مع خمسمئة، وكيف تكتشف ذلك وتصلحه قبل يوم الإطلاق.وسوف نناقش في هذا المقال ما يلي:
- الحج والعمرة بالأرقام الرسمية، وما تقوله المصادر الرسمية عن منصة نسك Nusuk.
- شكل الحمل في الموسم، ولماذا تعرف مواعيد القفزات مسبقاً.
- معمارية عامة كاملة في مخطط واحد، مع الأدوات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو وما يحدث عندما يتعطل كل جزء.
- التصاريح والحصص Quotas: الحجز عندما تكون السعة مكاناً حقيقياً وليست رقماً في قاعدة بيانات.
- تخطيط السعة Capacity Planning، وأين يقع أضيق جزء في الطريق، وما حدث في إطلاق لعبة Pokémon GO.
- غرفة الانتظار وال Cache وال CDN، وخطة التخفيف Graceful Degradation.
- الرسائل الجماعية في الموسم، وتقنية إدارة الحشود في الموقع: التحقق من التصاريح عند البوابة والكاميرات والعدادات.
- تجربة عملية بأداة k6: ألفا شخص يحجزون في نفس الثانية، مرة دون غرفة انتظار ومرة معها، بأرقام من جهاز واحد.
الحج والعمرة بالأرقام الرسمية
بحسب الهيئة العامة للإحصاء في بيانها المنشور في 5 يونيو 2025 بلغ عدد حجاج موسم 1446 هـ 1,673,230 حاجاً، منهم 1,506,576 قدموا من خارج المملكة، و166,654 من المواطنين والمقيمين، ووصل القادمون من الخارج عبر المطارات 1,435,017 حاجاً، وعبر المنافذ البرية 66,465، وعبر الموانئ البحرية 5,094، والمصدر بحسب البيان هو السجلات الإدارية لوزارة الداخلية. وهذه الأرقام تقول للمهندس شيئاً مهماً: النظام لا يخدم الحجاج فقط، وإنما كل من حولهم من شركات الطيران والفنادق والنقل ومقدمي الخدمات والجهات الحكومية، وكلهم يعملون في نفس الأسابيع.
وعلى جانب الأنظمة تذكر صفحة منصة نسك في موقع رؤية 2030 أن وزارة الحج والعمرة أطلقت نسك سنة 2022 لتسهيل التخطيط للعمرة وحجز كل ما تحتاجه، من التأشيرة الإلكترونية إلى السكن والنقل، ويعرض موقع نسك نفسه خدمات منها حجز تصريح الحج لمن هم داخل المملكة، وتصريح العمرة، وحجز موعد للصلاة في الروضة الشريفة بهدف تنظيم الزيارات وضمان راحة المصلين وسلامتهم، وينبه عند التسجيل لحج 1447 هـ إلى أن التسجيل يساعدك على الاستعداد ولا يضمن قبولك. أما الحجاج من الخارج فتصف منصة نسك حج نفسها بأنها منصة شاملة تشرف عليها الوزارة، تعرض على حجاج الدول المخدومة باقات من مقدمي خدمات معتمدين. وكل هذا جزء من برنامج خدمة ضيوف الرحمن الذي بدأ سنة 2019، ومن أهدافه المعلنة استضافة 15 مليون معتمر من الخارج سنوياً بحلول 2025.
شكل الحمل في الموسم
الحمل في هذه الأنظمة ليس منحنى هادئاً يرتفع خلال اليوم ثم ينخفض، وإنما قفزات حادة مرتبطة بقرارات معلنة، والصورة التالية تبين الشكل دون أرقام:

وهذا الشكل له ثلاث نتائج على التصميم:
- لا تشتر سعة دائمة لأعلى قفزة، فالسيرفرات التي تتحمل يوم فتح التسجيل تبقى شبه فارغة بقية السنة، والأنسب سعة أساسية معقولة تضاف إليها سعة مؤقتة قبل كل قفزة بأيام، ثم تزال بعدها، وقد ناقشنا تكلفة ذلك في السحابة ليست دائماً الأرخص.
- لا تجعل الناس يتسابقون إذا لم يكن السباق ضرورياً، فإذا كان القبول بالقرعة أو بمعايير محددة فلا معنى لأن يكون التسجيل في الدقيقة الأولى أفضل من التسجيل في اليوم الثالث، وعندما يعرف الناس ذلك تتوزع القفزة على أيام بدل دقائق، ورسالة نسك «التسجيل لا يضمن قبولك» من هذا النوع.
- المواسم الدينية تتبع التقويم الهجري، فتتحرك كل سنة قرابة 11 يوماً في التقويم الميلادي، وكل جدولة للسعة والصيانة والإجازات يجب أن تحسب ذلك، وقد شرحنا التعامل مع التاريخ الهجري في الكود في التاريخ والوقت.
المعمارية الكاملة: كيف تتصل الأجزاء ببعضها؟
كما ذكرنا في التنبيه أعلاه، لم تنشر الجهات المشغلة معمارية نسك أو أنظمة الحج، لذلك فالمعمارية التالية تصميم عام لأي نظام تصاريح يخدم حشوداً كبيرة في مواعيد معروفة، وليست وصفاً لنظام حقيقي. وفيه نظامان: نظام إلكتروني يوزع التصاريح والحجوزات في قفزات معروفة الموعد، ويحمي أضيق جزء فيه بغرفة انتظار ومعدل دخول، ويخدم القراءة من ال CDN وال Cache، ويطفئ ما يمكن الاستغناء عنه حسب خطة مكتوبة، ونظام ميداني يعمل حتى لو انقطع عن الأول، يتحقق من التصاريح الموقعة عند البوابات، ويعد الناس في كل منطقة، ويحول الأرقام إلى قرارات. والصورة التالية تبين النظامين، والخط المتصل فيها طلب ينتظر رداً Sync، والخط المتقطع حدث يرسل ولا ينتظر Async:

وفي التجربة العملية سوف ترى الجزء الأوسط من هذه الصورة بحجم صغير: غرفة الانتظار والتوكن وخدمة الحجز وPostgreSQL في خدمة واحدة، أما في الإنتاج فتكون غرفة الانتظار خدمة مستقلة أو على الحافة Edge، والسبب أن أسئلة «هل جاء دوري؟» تأتي من ملايين الصفحات، ويجب ألا تتنافس مع الحجز على نفس السيرفرات.
المكونات والأدوات المقترحة
| المكون | مسؤوليته | أداة مفتوحة المصدر مقترحة | لماذا هي |
|---|---|---|---|
| شبكة توصيل المحتوى CDN | الصفحات الثابتة والأسئلة الشائعة وصفحة الحالة في يوم القفزة | CDN تجاري أمام الموقع، أو Varnish Cache (BSD-2-Clause) على سيرفراتك | القراءة يجب ألا تصل إلى قاعدة البيانات أبداً، كما شرحنا في شبكة توصيل المحتوى |
| غرفة الانتظار Waiting Room | رقم لكل شخص، ومعدل دخول ثابت، وتوكن موقع لمن يأتي دوره | كود صغير كالذي في التجربة فوق Valkey، أو خدمة جاهزة على الحافة | المنطق بسيط: عداد وتوقيع HMAC، والمهم أن يكون أمام كل طريق إلى الحجز |
| موزع الحمل Load Balancer | توزيع الطلبات، ورفض الطلب الذي لا يحمل توكناً قبل أن يصل إلى التطبيق | HAProxy (GPLv2) | يحد عدد الطلبات لكل عنوان، ويوزع على سيرفرات التطبيق، ويتحمل القفزة نفسها |
| خدمة الحجز Booking API | التحقق من الأهلية والحصص والحجز بتحديث مشروط وإصدار التصريح | Node.js مع node-postgres، كما في التجربة | خدمة بلا حالة تضاف منها نسخ بسهولة، والحالة كلها في قاعدة البيانات |
| قاعدة البيانات | النوافذ الزمنية والحصص والحجوزات، وهي أضيق جزء في الطريق | PostgreSQL مع PgBouncer (ISC) وPatroni (MIT) | تحديث مشروط وقيد فريد يمنعان الحجز الزائد والمكرر، وPgBouncer يحد الاتصالات، وPatroni ينقل العمل إلى نسخة احتياطية عند تعطل الرئيسية |
| ال Cache | الأماكن المتاحة لكل نافذة، يتحدث كل ثانية أو ثانيتين | Valkey (BSD-3-Clause) | آلاف الصفحات تسأل عن المتاح، وجواب عمره ثانية يكفيها |
| مفاتيح خطة التخفيف | إطفاء التوصيات والتقارير والرسائل التسويقية حسب المستوى | Unleash (AGPL-3.0) | المفتاح جاهز في الكود ويضغط من لوحة دون نشر نسخة جديدة، كما في مفهوم Feature Flags |
| توقيع التصاريح | توقيع رمز QR بمفتاح خاص لا يخرج من مكانه | libsodium (ISC) بتوقيع Ed25519، والمفتاح في OpenBao (MPL-2.0) | توقيع قصير يتحقق منه الماسح بالمفتاح العام فقط، والمفتاح الخاص في خزنة لا في ملف إعداد |
| الرسائل الجماعية | تأكيد التصاريح وتغيير المواعيد وتنبيهات السلامة | نظام الإشعارات الذي شرحناه في نظام الإشعارات | طوابير بأولويات ومزود احتياطي وموافقة شخصين على الإرسال الجماعي |
| الموقع: العدادات والكاميرات | أعداد الداخلين والخارجين والكثافة في كل منطقة | Mosquitto (EPL-2.0 أو EDL-1.0) لنقل الأعداد عبر MQTT، وFrigate (MIT) لتحليل الفيديو | MQTT خفيف ويتحمل الشبكات الضعيفة، والتحليل يحدث قرب الكاميرات فلا يخرج الفيديو |
| اختبار الأحمال والمراقبة | قياس أضيق جزء قبل الموسم، ورؤية زمن الحجز والأخطاء أثناءه | k6 (AGPL-3.0) كما في التجربة، مع Prometheus (Apache-2.0) وGrafana (AGPL-3.0) | الرقم الذي ينقلك بين مستويات خطة التخفيف يجب أن يكون على شاشة أمام الفريق |
نموذج البيانات
الجدولان slots وbookings هما نفس جدولي التجربة، والجداول الأخرى ما يضاف إليهما في نظام حقيقي:
-- from the lab: one row per time slot, and one booking per person
CREATE TABLE slots (id int PRIMARY KEY, capacity int NOT NULL, booked int NOT NULL DEFAULT 0);
CREATE TABLE bookings (
id bigserial PRIMARY KEY,
user_id text NOT NULL UNIQUE, -- one permit per person (per season in a real system)
slot_id int NOT NULL REFERENCES slots(id),
created_at timestamptz NOT NULL DEFAULT now()
);
-- quotas per country, provider or category, split into shards so no single row gets every update
CREATE TABLE quotas (
slot_id int NOT NULL REFERENCES slots(id),
group_key text NOT NULL, -- e.g. 'provider:117' or 'category:domestic'
shard int NOT NULL, -- 0..15: pick one at random, try another if it is full
capacity int NOT NULL,
booked int NOT NULL DEFAULT 0 CHECK (booked <= capacity),
PRIMARY KEY (slot_id, group_key, shard)
);
-- the permit the gate scanner checks offline
CREATE TABLE permits (
booking_id bigint PRIMARY KEY REFERENCES bookings(id),
key_id text NOT NULL, -- which signing key, so keys can be rotated
payload text NOT NULL, -- slot, place, gate, validity window
signature bytea NOT NULL,
revoked_at timestamptz
);
-- entries sent by the scanners when the network allows, possibly hours later
CREATE TABLE entry_events (
permit_id bigint NOT NULL,
gate_id text NOT NULL,
scanned_at timestamptz NOT NULL, -- the scanner's clock
synced_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (permit_id, gate_id, scanned_at) -- the same batch sent twice adds nothing
);الواجهات الرئيسية
المسارات الأربعة الأولى موجودة في خدمة التجربة server.mjs، والبقية ما يضاف في الإنتاج:
| الطريقة | المسار | ماذا يفعل |
|---|---|---|
| POST | /queue/join | يعطي الشخص رقماً في غرفة الانتظار، ويحتاج توكن تسجيل الدخول في الإنتاج، فلا يأخذ البوت ألف رقم |
| GET | /queue/status?ticket=N | الموقع في الطابور، أو توكن موقع لمدة دقيقتين عندما يأتي الدور، وتسأله الصفحة كل عدة ثوان مع عشوائية |
| POST | /book?slot=S مع الترويسة X-Token | الحجز نفسه، ويرفض دون توكن صالح، والقيد الفريد على user_id يجعل إعادة الطلب بعد انقطاع آمنة، فالطلب الثاني يرد «محجوز مسبقاً» ولا ينشئ حجزاً ثانياً |
| GET | /stats | عدد الحجوزات وحالة غرفة الانتظار لغرفة العمليات، وفي الإنتاج للموظفين فقط |
| GET | /slots | النوافذ والأماكن المتاحة من ال Cache، ولا تصل إلى قاعدة البيانات |
| GET | /permits/{id} | التصريح الموقع ورمز QR، ويعمل حتى في أعلى مستويات خطة التخفيف |
| POST | /gate-events/batch | الماسح يرسل أحداث الدخول دفعة واحدة عندما يتوفر الاتصال، ومفتاح الحدث يمنع تكراره |
تقدير السعة على ظهر ورقة
لا توجد أرقام منشورة عن الحمل على أنظمة الحج، لذلك فالأرقام التالية افتراضات لنظام عام، والمهم طريقة الحساب كما شرحنا في مدخل إلى تصميم الأنظمة. نفترض ما يلي:
- مليونا شخص يريدون الحجز في يوم الفتح، و30% منهم يحاولون في أول عشر دقائق.
- كل شخص يطلب نحو 20 صفحة أو قراءة، وحجم الصفحة الأولى مع ملفاتها 500 كيلوبايت.
- كل حجز يمسك اتصالاً بقاعدة البيانات 20 ملي ثانية على الأقل، كما في التجربة.
- في الموقع: 500 عداد وحساس يرسل كل منها رقماً من 50 بايت كل خمس ثوان.
first 10 minutes = 2,000,000 x 30% = 600,000 people in 600 s
booking attempts = 600,000 / 600 s ~ 1,000 bookings/s
reads = 600,000 x 20 / 600 s ~ 20,000 reads/s (CDN and cache, not the database)
first page = 600,000 x 500 KB = 300 GB in 600 s ~ 4 Gbit/s at the CDN
waiting-room polls = 600,000 waiting / 1 s = 600,000 polls/s
= 600,000 waiting / 20 s = 30,000 polls/s
one connection = 1 s / 20 ms = at most 50 bookings/s
for 1,000 bookings/s = 1,000 / 50 = at least 20 busy connections, plus margin
database writes = 1,000 x (1 UPDATE + 1 INSERT) ~ 2,000 writes/s on a few hot rows
bookings storage = 2,000,000 x ~200 B ~ 400 MB (small)
counters on site = 500 x (86,400 / 5) x 50 B ~ 432 MB/dayوفي الحساب أعلاه لاحظ التالي:
- البيانات نفسها صغيرة، أربعمئة ميجابايت لكل الحجوزات، فالمشكلة في هذه الأنظمة ليست التخزين وإنما عدد الطلبات في نفس الدقيقة، لذلك لا تحتاج تقسيم قاعدة البيانات Sharding بقدر ما تحتاج حماية الصفوف الساخنة.
- مدة السؤال في صفحة الانتظار تغير الحمل عشرين مرة، فالسؤال كل ثانية يعني 600 ألف طلب في الثانية، أي أكثر من الحجز نفسه بستمئة مرة، والسؤال كل عشرين ثانية يعني 30 ألفاً، وهذا هو السبب في أن الخدمات الجاهزة تسأل كل 20 ثانية.
- عشرون اتصالاً مشغولاً على الأقل لألف حجز في الثانية، وهذا إذا لم يزد زمن الحجز في الذروة، فالرقم الحقيقي يأتي من اختبار أحمال على أضيق جزء، ومعدل الدخول من غرفة الانتظار يجب أن يكون أقل منه، كما فعلنا في التجربة بمعدل 250 في الثانية.
مراحل النمو
| الجزء | البداية MVP (فعالية محلية، سيرفر أو اثنان) | النمو (عشرات الآلاف في نفس الساعة) | الحجم الكبير (ملايين في يوم معروف) |
|---|---|---|---|
| الحجز | خدمة واحدة وPostgreSQL واحدة، بتحديث مشروط وقيد فريد | عدة نسخ من الخدمة خلف HAProxy، وPgBouncer، ونسخة احتياطية مع Patroni | حصص مقسمة إلى أجزاء، وفصل كل موسم أو منطقة في قاعدة مستقلة |
| القراءة | الخدمة تقرأ من قاعدة البيانات | Cache للمتاح وCDN للصفحات | CDN لكل ما يمكن، وصفحة حالة ثابتة جاهزة، ونسخ قراءة للتقارير |
| غرفة الانتظار | لا تحتاجها إذا كانت القفزة أقل من سعتك المقاسة | غرفة بسيطة في نفس الخدمة كما في التجربة | غرفة مستقلة على الحافة، وتسجيل على أيام ثم قرعة بدل السباق |
| التشغيل | اختبار أحمال قبل الفعالية | خطة تخفيف مكتوبة ومفاتيح Feature Flags | سعة مؤقتة قبل كل قفزة، وتدريب على المستويات، وغرفة عمليات بأرقام مباشرة |
| الموقع | رمز QR موقع وماسح بتطبيق هاتف | ماسحات تعمل دون اتصال وترسل الأحداث لاحقاً | عدادات وكاميرات لكل منطقة، وغرفة تحكم بقرارات مكتوبة لكل نسبة امتلاء |
وقد يتساءل البعض: هل تحتاج فعالية محلية بعشرة آلاف زائر كل هذا؟ والإجابة لا، لكن الأفكار نفسها تنطبق بحجم أصغر: اعرف أضيق جزء عندك وقسه، وضع أمامه غرفة انتظار بسيطة إذا كانت القفزة أكبر منه، واجعل التذكرة قابلة للتحقق دون اتصال، واكتب ما تطفئه إذا اشتد الحمل. ولا تقم ببناء غرفة انتظار على الحافة وتقسيم قاعدة البيانات وعدة مراكز بيانات قبل أن يقول لك اختبار الأحمال إنك تحتاجها.
ماذا لو تعطل جزء؟
| ماذا لو تعطل…؟ | الأثر | الحل |
|---|---|---|
| قاعدة البيانات الرئيسية | لا حجوزات جديدة | Patroni ينقل العمل تلقائياً إلى النسخة الاحتياطية بعد انتهاء مهلة القائد Leader TTL التي تضبطها، وغرفة الانتظار توقف الدخول مؤقتاً بدل أن يرى الناس أخطاء |
| خدمة الهوية لدى جهة أخرى | كل حجز ينتظرها حتى ينتهي وقته | مهلة قصيرة وقاطع دائرة Circuit Breaker، وخفض معدل الدخول من غرفة الانتظار إلى سعتها، أو قبول الطلب والتحقق منه لاحقاً قبل إصدار التصريح |
| غرفة الانتظار أو Valkey | لا أحد يحصل على توكن، أو يدخل الجميع معاً | القاعدة أن تغلق الغرفة ولا تفتح، أي أن الحجز يتوقف ولا يتحول إلى سباق، مع صفحة حالة ثابتة من ال CDN |
| ال CDN | الصفحات تأتي من سيرفراتك مباشرة، فيتضاعف الحمل | صفحة حالة ثابتة على مزود ثان، وخطة التخفيف تطفئ كل ما ليس حجزاً |
| الشبكة في الموقع | الماسحات لا تصل إلى السيرفر | التحقق من التوقيع دون اتصال، وحفظ أحداث الدخول محلياً حتى يعود الاتصال، فلا يقف أحد عند البوابة |
| تسرب مفتاح التوقيع | من يملكه يصنع تصاريح مزورة | رقم المفتاح key_id في كل تصريح حتى تستبدله، وتوزيع المفتاح العام الجديد على الماسحات، وإلغاء كل ما وقع بالقديم بعد تاريخ معين |
التصاريح والحصص: عندما تكون السعة مكاناً حقيقياً
في نظام التذاكر كانت السعة عدد مقاعد في ملعب، أما في التصاريح فالسعة قد تكون عدد من يستطيع أن يقف في مكان معين في ساعة معينة بأمان، وهذا يغير طريقة التفكير: الحجز لم يعد بيعاً لمقعد، وإنما توزيع لوقت ومكان، ويحمل التصريح عادة نافذة زمنية Time Slot ومكاناً، وأحياناً بوابة دخول محددة. والنمط الذي شرحناه في نظام التذاكر يطبق هنا كما هو: جملة تحديث مشروطة تزيد العدد فقط إذا لم يتجاوز السعة، وقيد فريد يمنع الشخص الواحد من حجز تصريحين لنفس الفترة، وهو ما سوف تراه في جدول bookings في التجربة العملية.
وهناك فروق تستحق الانتباه:
- الحصص Quotas متعددة المستويات: سعة لكل نافذة زمنية، وحصة لكل دولة أو مقدم خدمة أو فئة، ويجب أن تتحقق كلها في نفس العملية، فإذا كانت كل حصة صفاً في جدول فقد عدنا إلى مشكلة الصف الساخن، والحل تقسيم الحصة الكبيرة إلى أجزاء كما في المقاعد غير المرقمة.
- التحقق من الهوية والأهلية يحدث عادة عبر أنظمة جهات أخرى، مثل التأكد من الهوية أو من شروط الأهلية التي تحددها الجهة المسؤولة، وهذه الأنظمة لها سعتها وحدودها، فإذا كانت في طريق كل حجز صارت هي أضيق جزء في النظام كله، كما سيأتي.
- التصريح يجب أن يكون قابلاً للتحقق في مكان قد يكون فيه الاتصال ضعيفاً جداً لكثرة الناس، لذلك يحمل توقيعاً رقمياً يتحقق منه الماسح دون أن يسأل السيرفر، وقد شرحنا الفرق بين التوقيع والتشفير في التشفير للمطورين.
تخطيط السعة Capacity Planning
الحجز الواحد يمر بعدة أجزاء، ولكل جزء عدد من الطلبات يتحمله في الثانية، والنظام كله لا يخدم أسرع من أضيق جزء فيه، والصورة التالية تبين ذلك:

وخطوات تخطيط السعة عملياً:
- قدر الحمل المتوقع من بيانات المواسم السابقة، لا من التخمين: عدد المستخدمين في أعلى ساعة، وعدد الطلبات لكل مستخدم، ثم اضربه في هامش أمان.
- ارسم طريق الطلب الأهم، وهو الحجز، واكتب لكل جزء سعته، وما لا تعرف سعته قسه باختبار أحمال كما شرحنا في اختبار الأحمال، أما خدمات الجهات الأخرى فاطلب حدودها مكتوبة.
- اجعل أضيق جزء هو الذي يحدد معدل الدخول من غرفة الانتظار، مع هامش تحته.
- اختبر القفزة نفسها قبل الموسم بأسابيع، بنفس الشكل الذي تتوقعه، وليس بحمل يرتفع بهدوء.
غرفة الانتظار وال Cache وال CDN
أغلب الطلبات في يوم القفزة قراءة: صفحة المعلومات، والأسئلة الشائعة، وحالة الطلب، والمواعيد المتاحة، وهذه لا يجب أن تصل إلى قاعدة البيانات أبداً، فالصفحات الثابتة من ال CDN كما شرحنا في شبكة توصيل المحتوى، والمواعيد المتاحة من Cache يتحدث كل ثانية أو ثانيتين كما في مدخل إلى تصميم الأنظمة، وحالة الطلب لكل مستخدم يمكن أن تتأخر دقيقة دون أن يضر ذلك أحداً. أما الكتابة، أي الحجز نفسه، فتمر من غرفة الانتظار، وهي Waiting room في مخطط المعمارية أعلاه، وقد شرحناها بالتفصيل في نظام التذاكر، وتدخل الناس بمعدل ثابت أقل من أضيق جزء، وفي التجربة العملية سوف ترى الفرق بالأرقام.
وهناك تفصيل صغير في غرفة الانتظار يصنع فرقاً كبيراً، وهو أن صفحة الانتظار تسأل السيرفر كل فترة «هل جاء دوري؟»، فإذا سألت كل الصفحات في نفس اللحظة من كل ثانية فإن من يدخلون في تلك الثانية يصلون إلى نظام الحجز دفعة واحدة، وتحولت القفزة الكبيرة إلى قفزات صغيرة متكررة، والحل إضافة عشوائية Jitter إلى موعد السؤال، وهي نفس فكرة العشوائية في إعادة المحاولة التي رأيناها في نظام الإشعارات. وفي التجربة نزل زمن الحجز في النسبة 95 من 591 ملي ثانية إلى 47 ملي ثانية بهذا التغيير وحده. ولاحظ أن الخدمات الجاهزة تسأل أقل بكثير، فبحسب وثائق Cloudflare Waiting Room تتحدث صفحة الانتظار تلقائياً كل 20 ثانية.
خطة التخفيف Graceful Degradation
مهما خططت فقد يأتي حمل أكبر من خطتك، أو يتعطل جزء في أسوأ يوم، والسؤال عندها ليس «كيف نمنع التوقف؟» وإنما «ما الذي نوقفه نحن قبل أن يتوقف كل شيء وحده؟». والجواب يجب أن يكون مكتوباً قبل الموسم على شكل مستويات، والصورة التالية تبين مثالاً:

- كل مستوى مفتاح Feature Flag جاهز في الكود ومجرب، وليس تعديلاً يكتب أثناء الأزمة، وقد شرحنا المفاتيح في مفهوم Feature Flags.
- من يقرر الانتقال بين المستويات، وبناء على أي رقم؟ اكتب ذلك مسبقاً، مثل «إذا تجاوز زمن الحجز في النسبة 95 ثانيتين لمدة خمس دقائق ننتقل للمستوى الثاني».
- التصاريح الصادرة يجب أن تبقى قابلة للاستخدام حتى لو توقف نظام الحجز كله، فالحاج الذي معه تصريح موقع لا يجب أن يقف عند البوابة لأن السيرفر لا يرد، وهذا أهم من أي خدمة أخرى.
- جرب المستويات في تدريب قبل الموسم، كما شرحنا في اختبار التعطل، والسبب أن المفتاح الذي لم يجرب لا تعرف ماذا يكسر عندما تضغطه.
الرسائل الجماعية في الموسم
في الموسم ترسل الأنظمة رسائل لعدد كبير جداً في نفس الوقت: تأكيد التصاريح، وتغيير موعد، وتنبيه حرارة أو ازدحام، وكلها تواجه ما شرحناه في نظام الإشعارات بحجم أكبر، مع أمور تخص الموسم:
- اللغات: الحجاج من دول كثيرة، فالقالب يجب أن يكون بلغة المستخدم، والرسالة القصيرة بالعربية أو بلغات غير لاتينية تتسع ل 70 حرفاً فقط في الجزء الواحد، فاكتب القوالب قصيرة واحسب أجزاءها.
- الأرقام الدولية: كثير من المستخدمين بأرقام من خارج المملكة، وتكلفة SMS الدولية وتأخرها مختلفان، والإشعار داخل التطبيق أرخص وأسرع لمن ثبته.
- الأولوية: تنبيه السلامة يسبق أي رسالة أخرى، ويجب أن يكون له طابوره ومزوده الاحتياطي.
- الحذر من الإرسال الخاطئ: رسالة جماعية خاطئة لمليون شخص في مكان مزدحم أخطر بكثير من رسالة لم تصل، لذلك تحتاج قوالب التنبيهات الجماعية موافقة شخصين وقالب تصحيح جاهزاً، كما رأينا في قصة هاواي في نظام الإشعارات.
التقنية في الموقع: البوابات والكاميرات والعدادات
النظام لا ينتهي عند الحجز، وهذا هو الجزء السفلي من مخطط المعمارية أعلاه، فالتصريح يجب أن يتحقق منه عند البوابة، وعدد الناس في كل منطقة يجب أن يعرف في الوقت الحقيقي، والصورة التالية تبين الأجزاء:

- التحقق دون اتصال Offline Verification: التصريح يحمل بياناته وتوقيعاً رقمياً، والماسح عنده المفتاح العام فقط، فيتحقق في أجزاء من الثانية دون أن يسأل السيرفر، ويحفظ أحداث الدخول ليرسلها عندما يتحسن الاتصال، ويمنع استخدام نفس التصريح مرتين في نفس البوابة محلياً.
- عد الحشود: الكاميرات مع نماذج تحليل الفيديو تعد الأشخاص وتقدر الكثافة في كل منطقة، والعدادات عند البوابات والممرات تعطي أعداد الداخلين والخارجين، والفرق بينهما يعطي عدد الموجودين، وقد شرحنا أنواع الكاميرات واختيارها في أنواع كاميرات المراقبة، وتشغيل التحليل على سيرفرك في Frigate.
- من الرقم إلى القرار: الرقم في غرفة التحكم لا قيمة له إذا لم يتحول إلى فعل، مثل إيقاف بوابة مؤقتاً، أو توجيه الناس إلى طريق آخر عبر اللوحات والإشعارات، أو إرسال فريق، لذلك تحدد مسبقاً لكل منطقة نسبة الامتلاء التي يتغير عندها الإجراء.
- الخصوصية: الكاميرات في مكان عبادة تحتاج سياسة واضحة لما يحفظ ومدة حفظه ومن يراه، والعد لا يحتاج حفظ الوجوه، فإذا كان الهدف عدداً فاحفظ العدد وحده.
تجربة عملية: ألفا شخص في نفس الثانية
في هذه التجربة نشغل خدمة صغيرة لحجز تصاريح بنوافذ زمنية مكتوبة بلغة Node.js أمام PostgreSQL، ونعطي كل منهما معالجاً واحداً فقط، ونحدد للخدمة عشرة اتصالات بقاعدة البيانات، وكل حجز يمضي 20 ملي ثانية في التحقق داخل المعاملة ليمثل فحص الهوية والأهلية. ثم نرسل إليها ألفي شخص بأداة k6 في نفس الثانية، مرة يضغط الجميع زر الحجز مباشرة، ومرة يأخذون رقماً من غرفة انتظار تدخل 250 شخصاً في الثانية، ثم نكرر الثانية بإضافة العشوائية إلى أسئلة صفحة الانتظار. وكل الأرقام هنا من جهاز واحد عليه Docker Desktop، فهي تبين الفرق بين الطرق ولا تقيس أي نظام حقيقي، وقد شرحنا k6 وطريقة قراءة نتائجه في اختبار الأحمال.
المتطلبات: جهاز عليه Docker Engine وDocker Compose، والمنفذ 38090 منشور على 127.0.0.1 فقط لتسأل الخدمة بنفسك.
ملفات التجربة
الملف compose.yaml، ولاحظ الإعداد cpus الذي يحدد معالجاً واحداً لكل من قاعدة البيانات والخدمة، حتى تظهر حدود السعة على جهاز قوي:
name: lab-case2-crowd
services:
db:
image: postgres:18.6
container_name: lab-case2-crowd-db
environment: { POSTGRES_PASSWORD: lab-only-password, POSTGRES_DB: permits }
volumes: ["./schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro"]
cpus: 1.0
healthcheck: { test: ["CMD-SHELL", "pg_isready -U postgres -d permits"], interval: 2s, retries: 30 }
app:
build: .
image: lab-case2-crowd-app:1
container_name: lab-case2-crowd-app
environment:
DATABASE_URL: postgresql://postgres:lab-only-password@db:5432/permits
ADMIT_PER_SECOND: "250" # how many people the waiting room lets through each second
TOKEN_SECRET: lab-only-secret
ports: ["127.0.0.1:38090:8080"]
cpus: 1.0
depends_on: { db: { condition: service_healthy } }
k6:
image: grafana/k6:2.3.0
container_name: lab-case2-crowd-k6
volumes: ["./k6:/scripts:ro"]
environment: { BASE: "http://app:8080", JITTER: "${JITTER:-1}" }
profiles: ["run"]والملف schema.sql:
-- 50 time slots (like visiting windows), 100 places each: 5,000 places for 3,000 people, so nobody fails for lack of places
CREATE TABLE slots (id int PRIMARY KEY, capacity int NOT NULL, booked int NOT NULL DEFAULT 0);
INSERT INTO slots SELECT g, 100, 0 FROM generate_series(1, 50) g;
CREATE TABLE bookings (
id bigserial PRIMARY KEY,
user_id text NOT NULL UNIQUE, -- one permit per person
slot_id int NOT NULL REFERENCES slots(id),
created_at timestamptz NOT NULL DEFAULT now()
);والملفان Dockerfile وpackage.json:
FROM node:24.21.0-alpine3.24
WORKDIR /app
COPY package.json .
RUN npm install --omit=dev --no-audit --no-fund
COPY server.mjs .
CMD ["node", "server.mjs"]{ "name": "lab-crowd", "private": true, "type": "module", "dependencies": { "pg": "8.23.1" } }والخدمة نفسها server.mjs، وفيها الحجز وغرفة الانتظار معاً:
// A small permit-booking service with an optional waiting room in front of it.
// POST /book?user=U&slot=S books directly (no gate)
// POST /queue/join?user=U takes a number in the waiting room
// GET /queue/status?ticket=N&user=U position, or a signed token once admitted
// POST /book?user=U&slot=S + header X-Token books with a waiting-room token (gated mode)
import http from 'node:http';
import crypto from 'node:crypto';
import pg from 'pg';
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // the database can do a limited amount of work at once
connectionTimeoutMillis: 2000, // waiting longer than 2 s for a connection = give up (503)
});
const SECRET = process.env.TOKEN_SECRET;
const RATE = Number(process.env.ADMIT_PER_SECOND);
// ---- waiting room state (one instance in the lab; a real one keeps this in Valkey or at the edge) ----
let issued = 0, admitted = 0;
setInterval(() => { admitted = Math.min(issued, admitted + RATE / 10); }, 100);
const sign = (user, exp) => crypto.createHmac('sha256', SECRET).update(`${user}.${exp}`).digest('hex').slice(0, 32);
function tokenFor(user) {
const exp = Date.now() + 120_000; // the token is valid for 2 minutes
return `${exp}.${sign(user, exp)}`;
}
function validToken(user, token) {
const [exp, sig] = String(token || '').split('.');
return Number(exp) > Date.now() && sig === sign(user, Number(exp));
}
async function book(user, slot) {
const c = await pool.connect(); // throws after 2 s if all 10 connections are busy
try {
await c.query('BEGIN');
await c.query('SELECT pg_sleep(0.02)'); // stands for the checks a permit needs: identity, eligibility, quota
const r = await c.query('UPDATE slots SET booked = booked + 1 WHERE id = $1 AND booked < capacity RETURNING booked', [slot]);
if (!r.rowCount) { await c.query('ROLLBACK'); return [409, { error: 'slot full' }]; }
await c.query('INSERT INTO bookings (user_id, slot_id) VALUES ($1, $2)', [user, slot]);
await c.query('COMMIT');
return [200, { ok: true, slot }];
} catch (e) {
await c.query('ROLLBACK').catch(() => {});
if (e.code === '23505') return [409, { error: 'already booked' }];
throw e;
} finally { c.release(); }
}
const send = (res, code, body) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
http.createServer(async (req, res) => {
const u = new URL(req.url, 'http://x');
const user = u.searchParams.get('user');
try {
if (u.pathname === '/queue/join') {
const ticket = ++issued;
return send(res, 200, { ticket, position: Math.max(0, ticket - Math.floor(admitted)) });
}
if (u.pathname === '/queue/status') {
const ticket = Number(u.searchParams.get('ticket'));
if (ticket <= admitted) return send(res, 200, { admitted: true, token: tokenFor(user) });
return send(res, 200, { admitted: false, position: ticket - Math.floor(admitted), eta_s: Math.ceil((ticket - admitted) / RATE) });
}
if (u.pathname === '/book') {
if (u.searchParams.get('gated') === '1' && !validToken(user, req.headers['x-token'])) return send(res, 403, { error: 'no valid waiting-room token' });
const [code, body] = await book(user, Number(u.searchParams.get('slot')));
return send(res, code, body);
}
if (u.pathname === '/stats') {
const r = await pool.query('SELECT (SELECT count(*) FROM bookings)::int AS bookings, (SELECT sum(booked) FROM slots)::int AS booked');
return send(res, 200, { ...r.rows[0], issued, admitted: Math.floor(admitted) });
}
if (u.pathname === '/reset') {
await pool.query('TRUNCATE bookings; UPDATE slots SET booked = 0');
issued = 0; admitted = 0;
return send(res, 200, { reset: true });
}
send(res, 404, { error: 'not found' });
} catch (e) {
send(res, 503, { error: e.message }); // pool timeout or database error: the user sees "try again"
}
}).listen(8080, () => console.log(`listening on :8080, waiting room admits ${RATE}/s`));وثلاثة ملفات k6 في المجلد k6، الأول k6/no-gate.js يرسل الجميع إلى الحجز مباشرة:
// Everybody presses "book" at the same second, straight to the booking service
import http from 'k6/http';
import { Counter, Trend } from 'k6/metrics';
import { summary } from './common.js';
const BASE = __ENV.BASE;
const people = new Counter('people'), s200 = new Counter('status_200'), s503 = new Counter('status_503'), other = new Counter('status_other');
const bookMs = new Trend('book_ms', true);
export const options = {
scenarios: { opening: { executor: 'per-vu-iterations', vus: 2000, iterations: 1, maxDuration: '3m' } },
summaryTrendStats: ['med', 'p(95)', 'max'],
};
export default function () {
const user = `u${__VU}`, slot = 1 + (__VU % 50);
people.add(1);
const res = http.post(`${BASE}/book?user=${user}&slot=${slot}`, null, { timeout: '60s' });
bookMs.add(res.timings.duration);
(res.status === 200 ? s200 : res.status === 503 ? s503 : other).add(1);
}
export const handleSummary = summary('no waiting room: 2,000 people at once');والثاني k6/gated.js يأخذ رقماً ثم يسأل حتى يأتي دوره ثم يحجز بالتوكن:
// The same 2,000 people, but they take a number first and book only when the waiting room lets them in
import http from 'k6/http';
import { sleep } from 'k6';
import { Counter, Trend } from 'k6/metrics';
import { summary } from './common.js';
const BASE = __ENV.BASE;
const JITTER = __ENV.JITTER !== '0'; // JITTER=0: every page asks exactly once a second
const people = new Counter('people'), s200 = new Counter('status_200'), s503 = new Counter('status_503'), other = new Counter('status_other');
const polls = new Counter('polls');
const bookMs = new Trend('book_ms', true), waitS = new Trend('wait_s');
export const options = {
scenarios: { opening: { executor: 'per-vu-iterations', vus: 2000, iterations: 1, maxDuration: '3m' } },
summaryTrendStats: ['med', 'p(95)', 'max'],
};
export default function () {
const user = `u${__VU}`, slot = 1 + (__VU % 50);
people.add(1);
const start = Date.now();
const { ticket } = http.post(`${BASE}/queue/join?user=${user}`).json();
let token;
while (!token) { // the waiting-room page asks every second
const st = http.get(`${BASE}/queue/status?ticket=${ticket}&user=${user}`).json();
polls.add(1);
if (st.admitted) token = st.token; else sleep(JITTER ? 0.5 + Math.random() : 1);
}
waitS.add((Date.now() - start) / 1000);
const res = http.post(`${BASE}/book?gated=1&user=${user}&slot=${slot}`, null, { headers: { 'X-Token': token }, timeout: '60s' });
bookMs.add(res.timings.duration);
(res.status === 200 ? s200 : res.status === 503 ? s503 : other).add(1);
}
export const handleSummary = summary(`with a waiting room (250 people per second), polling ${JITTER ? 'with' : 'without'} jitter`);والثالث k6/common.js يطبع الأرقام التي نقارنها فقط:
// Shared summary: print only the numbers the article compares
export function summary(title) {
return (data) => {
const m = data.metrics, n = (k) => (m[k] ? m[k].values.count : 0);
const t = (k) => m[k] ? `median ${Math.round(m[k].values.med)} ms · p95 ${Math.round(m[k].values['p(95)'])} ms · max ${Math.round(m[k].values.max)} ms` : '-';
const lines = [
`== ${title}`,
`people: ${n('people')} booked (200): ${n('status_200')} errors (503): ${n('status_503')} other: ${n('status_other')}`,
`booking request time: ${t('book_ms')}`,
];
if (m.wait_s) lines.push(`time in the waiting room: median ${m.wait_s.values.med.toFixed(1)} s · p95 ${m.wait_s.values['p(95)'].toFixed(1)} s · max ${m.wait_s.values.max.toFixed(1)} s status polls: ${n('polls')}`);
lines.push(`whole test: ${(data.state.testRunDurationMs / 1000).toFixed(1)} s`);
return { stdout: lines.join('\n') + '\n' };
};
}في الإعداد أعلاه لاحظ التالي:
- المنفذ
per-vu-iterationsفي k6 يجهز ألفي مستخدم افتراضي VU أولاً ثم يطلقهم معاً، وكل واحد يحجز مرة واحدة، وهذا أقرب إلى لحظة فتح الحجز من حمل يرتفع بالتدريج. - الخدمة ترد ب 503 إذا انتظر الطلب أكثر من ثانيتين ليحصل على اتصال بقاعدة البيانات، بسبب
connectionTimeoutMillis: 2000، وهذا ما يراه المستخدم كرسالة «حاول لاحقاً». - التوكن في غرفة الانتظار توقيع HMAC لاسم المستخدم ووقت الانتهاء، والحجز في الوضع المحمي
gated=1يرفض أي طلب دون توكن صالح. - السعة 50 نافذة في كل منها 100 مكان، أي 5,000 مكان لألفي شخص، فلا يفشل أحد لنفاد الأماكن، وكل فشل في التجربة سببه الحمل وحده.
شغل الخدمة وقاعدة البيانات:
docker compose up -d --build --waitدون غرفة انتظار
curl -s -X POST http://127.0.0.1:38090/reset
docker compose run --rm k6 run -q /scripts/no-gate.js
curl -s http://127.0.0.1:38090/statsوالمخرج سوف يكون كما يلي:
== no waiting room: 2,000 people at once
people: 2000 booked (200): 1118 errors (503): 882 other: 0
booking request time: median 2010 ms · p95 2253 ms · max 2365 ms
whole test: 3.6 s
{"bookings":1118,"booked":1118,"issued":0,"admitted":0}من ألفي شخص حجز 1118 فقط، وحصل 882 على خطأ 503، أي 44%، وحتى من نجح انتظر ثانيتين تقريباً، والسبب أن ألفي طلب تزاحمت على عشرة اتصالات، فمن لم يحصل على اتصال خلال ثانيتين خرج بخطأ. ولاحظ أن هؤلاء ال 882 في الواقع لن يذهبوا، وإنما سيضغطون الزر مرة أخرى فوراً، فتأتي موجة ثانية أكبر من الأولى، وهذه هي الطريقة التي ينهار بها نظام في يوم فتح الحجز.
مع غرفة انتظار
نفس الألفي شخص، لكن صفحة الانتظار تسأل كل ثانية بالضبط، أي دون عشوائية:
curl -s -X POST http://127.0.0.1:38090/reset
JITTER=0 docker compose run --rm k6 run -q /scripts/gated.js== with a waiting room (250 people per second), polling without jitter
people: 2000 booked (200): 2000 errors (503): 0 other: 0
booking request time: median 278 ms · p95 591 ms · max 690 ms
time in the waiting room: median 4.4 s · p95 7.7 s · max 8.3 s status polls: 8979
whole test: 8.8 sثم مع العشوائية، وهي الإعداد الافتراضي في السكربت:
curl -s -X POST http://127.0.0.1:38090/reset
docker compose run --rm k6 run -q /scripts/gated.js
curl -s http://127.0.0.1:38090/stats== with a waiting room (250 people per second), polling with jitter
people: 2000 booked (200): 2000 errors (503): 0 other: 0
booking request time: median 26 ms · p95 47 ms · max 79 ms
time in the waiting room: median 5.1 s · p95 8.6 s · max 9.5 s status polls: 10819
whole test: 9.8 s
{"bookings":2000,"booked":2000,"issued":2000,"admitted":2000}والصورة التالية تجمع النتائج الثلاث:

في المخرج أعلاه لاحظ التالي:
- مع غرفة الانتظار حجز الألفان كلهم ولم يحصل أحد على خطأ، والثمن أن الشخص انتظر في الغرفة قرابة خمس ثوان في المنتصف وتسع ثوان في أسوأ حالة، وهذا ما يحدث في الحقيقة بدقائق بدل ثوان، لكنه انتظار معروف برقم في الطابور، وليس صفحة خطأ.
- دون العشوائية كانت الصفحات تسأل في نفس اللحظة من كل ثانية، فيدخل مئتان وخمسون شخصاً معاً إلى نظام الحجز، فارتفع زمن الحجز إلى 591 ملي ثانية في النسبة 95، ومع العشوائية توزع الدخول داخل الثانية فنزل إلى 47 ملي ثانية.
- صفحات الانتظار سألت الخدمة قرابة عشرة آلاف مرة، وهذه أسئلة رخيصة هنا لأنها لا تلمس قاعدة البيانات، لكنها في نظام حقيقي بملايين المنتظرين سبب كاف لأن تكون الغرفة على الحافة Edge أو خدمة مستقلة، وأن تسأل الصفحة كل عدة ثوان لا كل ثانية.
- معدل 250 في الثانية اخترناه أقل من سعة هذه الخدمة الصغيرة، وفي نظامك يأتي هذا الرقم من قياس أضيق جزء لديك.
نكسر التجربة: طلب مباشر دون توكن
غرفة الانتظار لا تحمي شيئاً إذا استطاع أحد أن يتجاوزها بطلب مباشر إلى خدمة الحجز، وهذا ما يجربه أول بوت يكتب لموسم الحجز:
curl -s -i -X POST "http://127.0.0.1:38090/book?gated=1&user=bot1&slot=3"HTTP/1.1 403 Forbidden
content-type: application/json
Transfer-Encoding: chunked
{"error":"no valid waiting-room token"}الخدمة رفضت الطلب لأن التوكن غير موجود، ولاحظ أن التحقق رخيص جداً، فهو حساب HMAC دون أي سؤال لقاعدة البيانات، لذلك يمكن أن يحدث في أول نقطة يمر بها الطلب. أما في التجربة فما زال الحجز المباشر دون gated=1 مفتوحاً لأننا نحتاجه للمقارنة، وفي نظام حقيقي لا يوجد إلا الطريق المحمي.
حذف التجربة
docker compose down -v
docker image rm lab-case2-crowd-app:1أخطاء شائعة
- شراء سعة دائمة لأعلى قفزة، أو العكس: انتظار القفزة دون سعة مؤقتة مجهزة قبلها.
- تصميم يجعل الناس يتسابقون في الدقيقة الأولى دون حاجة، بدل التسجيل على أيام ثم القرعة أو المعايير.
- عدم معرفة أضيق جزء في الطريق، أو اكتشاف حدود خدمة جهة أخرى في يوم الموسم.
- غرفة انتظار يمكن تجاوزها، أو صفحات انتظار تسأل كلها في نفس اللحظة.
- خطة تخفيف غير مكتوبة، أو مفاتيح لم تجرب قبل الموسم.
- تصريح لا يمكن التحقق منه إلا بالاتصال بالسيرفر، في مكان يضعف فيه الاتصال لكثرة الناس.
- جدولة المواسم بالتقويم الميلادي ونسيان أنها تتحرك كل سنة.
- رسائل جماعية بلغة واحدة، أو تنبيه جماعي دون موافقة شخص ثان وقالب تصحيح.
- كاميرات تحفظ أكثر مما يحتاجه الهدف.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- القفزات في أنظمة الحشود معروفة الموعد، فالسقوط في يوم معروف مشكلة استعداد، والحل سعة مؤقتة قبل كل قفزة وتصميم يقلل السباق.
- بحسب الهيئة العامة للإحصاء بلغ عدد حجاج 1446 هـ 1,673,230 حاجاً، ونسك أطلقتها وزارة الحج والعمرة سنة 2022 للتخطيط للعمرة وحجزها.
- السعة في التصاريح مكان ووقت حقيقيان، والحجز بنفس أدوات نظام التذاكر: تحديث مشروط وقيد فريد وحصص مقسمة.
- النظام لا يخدم أسرع من أضيق جزء فيه، فقسه، واجعل معدل الدخول من غرفة الانتظار تحته، وتذكر أن Pokémon GO وصلت إلى عشرة أضعاف أسوأ تقديرها.
- غرفة الانتظار ألغت الأخطاء في التجربة من 44% إلى صفر، والعشوائية في أسئلة صفحة الانتظار أنزلت زمن الحجز من 591 إلى 47 ملي ثانية.
- خطة التخفيف مستويات مكتوبة ومفاتيح مجربة، والتصاريح الصادرة تبقى صالحة حتى لو توقف نظام الحجز.
- في الموقع: تصريح موقع يتحقق منه دون اتصال، وعد للناس في كل منطقة، وقرار محدد مسبقاً لكل نسبة امتلاء، وخصوصية تحفظ العدد لا الوجوه.
وفي مقالات هذا القسم أنظمة أخرى بنفس الأفكار:
دليلكيف تبني نظام حجز تذاكر يتحمل لحظة فتح البيعكيف تمنع بيع المقعد الواحد مرتين، وكيف يصمد نظام الحجز عندما يدخل الجميع في الدقيقة نفسها لشراء التذاكر.
دليلكيف تبني نظام إشعارات يصل إلى ملايين المستخدمينكيف تصل الرسالة الصحيحة إلى المستخدم مرة واحدة وفي وقتها عبر الإشعار والرسائل والبريد، حتى عندما يتعطل المزود.
دليلكيف تبني تطبيق محادثة مثل WhatsApp لملايين المستخدمينكيف تصل الرسالة من هاتف إلى آخر عبر سيرفرات مختلفة، وكيف تحفظ لمن انقطع اتصاله وتظهر علامات التسليم والقراءة.
دليلأنواع كاميرات المراقبة وكيف تختار بينهاما الفرق بين أنواع كاميرات المراقبة، وما الذي تحتاجه في الكاميرا حتى تسجلها على جهازك، وكم قرصاً تحتاج للتسجيلات.
دليلمفاهيم التوفر العالي High Availability للمبتدئينماذا يعني أن تبقى خدمتك تعمل رغم تعطل جزء منها، وكيف تحسب التوفر وتجد نقاط الفشل الواحدة وتقرر متى يستحق ذلك تكلفته.
دليلFeature Flags: انشر الكود وفعل الميزة لاحقاًكيف يفصل ال Feature Flag بين نشر الكود وإطلاق الميزة، وكيف تطلقها لجزء من المستخدمين أولاً وتوقفها فوراً إذا تعطلت.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة العملية على Docker Engine 29.8.0 وDocker Compose 5.5.1 مع Grafana k6 2.3.0 وNode.js 24.21.0 وnode-postgres 8.23.1 وPostgreSQL 18.6.
- أكتوبر 2026: إضافة قسم المعمارية الكاملة: المخطط والمكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو.