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

كيف تبني نظام حجز تذاكر يتحمل لحظة فتح البيع

كيف تمنع بيع المقعد الواحد مرتين، وكيف يصمد نظام الحجز عندما يدخل الجميع في الدقيقة نفسها لشراء التذاكر.

كيف تبني نظام حجز تذاكر يتحمل لحظة فتح البيع

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

وهذا المقال من قسم دراسات الحالة في خارطة الطريق، والمفاهيم العامة مثل موزع الحمل والتخزين المؤقت والطوابير شرحناها في مدخل إلى تصميم الأنظمة، والمعاملات Transactions ومستويات العزل Isolation Levels شرحناها بتجارب على جلستين في الجزء الثالث من سلسلة تعلم SQL، فلن نكررها هنا وسوف نركز على ما يخص الحجز نفسه.

دليلمدخل إلى تصميم الأنظمة: من سيرفر واحد إلى ملايين المستخدمينكيف يكبر متجر إلكتروني من سيرفر واحد إلى نظام يخدم ملايين المستخدمين، وما الثمن الذي تدفعه في كل خطوة.دليلتعلم SQL (1): ما هو SQL وكيف تكتب أول استعلامتتعلم SQL من الصفر: ما هي قاعدة البيانات العلائقية، وكيف تنشئ الجداول وتضيف البيانات وتكتب أول استعلام بأمان.دليلتعلم SQL (3): CTE ودوال النوافذ والمعاملاتكيف تكتب استعلامات أوضح بجمل WITH ودوال النوافذ، وتحمي بياناتك بالمعاملات ومستويات العزل ومن حقن SQL.دليلالأنظمة الموزعة للمبتدئين: ماذا يحدث عندما تنقطع الشبكةلماذا تتصرف الأنظمة الموزعة بشكل غريب عند انقطاع الشبكة، وكيف تتفق السيرفرات على قرار واحد رغم ذلك.

وسوف نناقش في هذا المقال ما يلي:

  • ما الذي تبنيه بالضبط، وتقدير سريع للأرقام قبل أي سطر كود.
  • المعمارية الكاملة: المكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو وماذا يحدث عندما يتعطل كل جزء.
  • أين يحدث الحجز المكرر Double Booking بالضبط، ولماذا لا يظهر في بيئة التطوير.
  • الحجز المؤقت Hold بمهلة TTL، وماذا يحدث عندما تنتهي المهلة والمشتري في صفحة الدفع.
  • ثلاث طرق لمنع التعارض: القفل المتشائم SELECT … FOR UPDATE، والتحديث المشروط والقفل المتفائل Optimistic Locking، وقفل Redis ومشكلاته، ثم القيد الفريد Unique Constraint كخط دفاع أخير.
  • المقاعد غير المرقمة والصف الساخن Hot Row، وتقسيم المخزون Inventory Sharding.
  • غرفة الانتظار Waiting Room عند فتح البيع، والدفع ومهلته، والبوتات والعدالة بين المشترين، مع ما حدث في بيع تذاكر جولة Taylor Swift سنة 2022.
  • تجربة عملية على PostgreSQL تبيع مقعداً واحداً لخمسين مشترياً بالطريقة البسيطة ثم بالطرق الصحيحة، وتنتهي فيها مهلة حجز أمامك.

ما الذي تبنيه بالضبط؟

نظام حجز التذاكر يبيع مخزوناً Inventory محدوداً وثابتاً، فالملعب فيه عدد معروف من المقاعد ولن يزيد مهما زاد الطلب، وهذا يختلف عن متجر إلكتروني يستطيع أن يطلب بضاعة جديدة من المورد. ولذلك فالوظائف الأساسية قليلة: صفحة الفعالية Event، وخريطة المقاعد Seat Map التي تبين المتاح والمباع، واختيار مقعد أو عدد من التذاكر، ثم الدفع، ثم إصدار التذكرة برمز QR. أما المتطلبات غير الوظيفية Non-functional Requirements فهي التي تحدد التصميم كله:

  • الصحة Correctness: لا يباع المقعد مرتين، ولا يدفع المشتري ثمن تذكرة لا يحصل عليها، وهذا شرط لا يقبل التفاوض.
  • تحمل القفزة Spike: الحمل لا يأتي موزعاً على اليوم، وإنما يأتي كله في الدقائق الأولى بعد فتح البيع.
  • العدالة Fairness: المشتري الحقيقي يجب أن تكون فرصته معقولة أمام البوتات Bots وأمام من يفتح عشرين نافذة.
  • الوضوح: من لم يحصل على تذكرة يجب أن يعرف ذلك بسرعة، بدل أن يبقى في صفحة تحميل لا تنتهي.

ولنأخذ مثالاً بأرقام تقديرية: ملعب فيه 60,000 مقعد، ومليون شخص يريدون الدخول عند فتح البيع، وكل شخص يفتح خريطة المقاعد ويحدثها عدة مرات في الدقيقة، فهذا يعني ملايين طلبات القراءة في الدقائق الأولى، بينما عمليات الكتابة الناجحة لا يمكن أن تزيد عن 60,000 عملية بيع مهما حدث. وهذه الملاحظة هي مفتاح التصميم كله: القراءة ضخمة ويمكن أن تكون متأخرة قليلاً، والكتابة محدودة ويجب أن تكون صحيحة تماماً، لذلك تفصل بين الطريقين، فالقراءة تخدم من ال Cache وشبكة توصيل المحتوى CDN، والكتابة تمر عبر بوابة ضيقة إلى قاعدة البيانات التي تملك الحقيقة.

المعمارية الكاملة: كيف تتصل الأجزاء ببعضها؟

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

ست طبقات. CLIENTS فيها هاتف وحاسوب المشجعين. EDGE فيها CDN لصفحة الفعالية وخريطة المقاعد وصفحة غرفة الانتظار، وموزع الحمل مع بوابة ال API للتشفير وحد معدل الطلبات والتحقق من توكن غرفة الانتظار. SERVICES فيها خمس خدمات: Waiting room لرقم الدور والتوكن الموقع، وCatalog للفعاليات والأسعار وألوان المقاعد، وBooking للحجز المؤقت بجملة UPDATE مشروطة، وPayment بمفتاح Idempotency Key واستقبال ال Webhook، والمهام المجدولة لتحرير الحجوزات المنتهية والمطابقة. DATA فيها Valkey لنسخة خريطة المقاعد وطابور الانتظار وعدادات حد الطلبات، وPostgreSQL للمقاعد والطلبات والمدفوعات وجدول outbox عبر PgBouncer. ASYNC فيها RabbitMQ يستقبل الحدث booking.confirmed ويوزعه على Ticket issuer لرمز QR وعلى Notifications. EXTERNAL فيها مزود الدفع PSP ومزودو البريد والرسائل. الخطوط المتصلة طلبات متزامنة، والمتقطعة أحداث غير متزامنة مثل ال Webhook والحدث من قاعدة البيانات إلى الطابور
طريق القراءة ينتهي عند ال CDN وValkey، وطريق الحجز ينتهي عند PostgreSQL، وكل ما بعد البيع يخرج إلى الطابور

في الصورة أعلاه لاحظ التالي:

  • المشجع لا يصل إلى خدمة الحجز إلا عبر بوابة ال API، والبوابة ترفض أي طلب حجز لا يحمل توكن غرفة الانتظار، فلا يستطيع البوت أن يقفز فوق الطابور بطلب مباشر.
  • الخطوط الزرقاء إلى Valkey قراءات سريعة يجوز أن تكون متأخرة ثانية، والخط الأحمر إلى PostgreSQL هو الكتابة الوحيدة التي تقرر من يملك المقعد.
  • الخطوط المتقطعة لا ينتظرها المشتري: إشعار الدفع Webhook يصل من المزود متى شاء، والحدث booking.confirmed يخرج من قاعدة البيانات إلى الطابور عبر جدول outbox، والمهام المجدولة تعمل كل دقيقة أو كل ليلة.

المكونات ومسؤولية كل منها

الجدول التالي يبين كل مكون في الصورة، والأداة مفتوحة المصدر التي نقترحها له، والسبب، وتستطيع أن تبدل أي أداة بما يعرفه فريقك بشرط أن تؤدي المسؤولية نفسها:

المكونمسؤوليتهأداة مفتوحة المصدر مقترحةلماذا هي
شبكة توصيل المحتوى CDNصفحة الفعالية وصورة خريطة الملعب وصفحة غرفة الانتظار الثابتةخدمة CDN تجارية في الغالب، ولا يوجد سبب لبنائها بنفسكهي التي تتحمل المليون زائر، وشرحنا طريقة عملها في مقال ال CDN
موزع الحمل وبوابة ال API (Load Balancer وAPI Gateway)TLS، وتوزيع الطلبات على نسخ الخدمات، وحد معدل الطلبات لكل حساب وعنوان، والتحقق من توكن الغرفةHAProxy (رخصة GPLv2) أو NGINX (رخصة BSD-2-Clause)كلاهما مجرب تحت الحمل العالي، وفي HAProxy جداول Stick Tables لحد المعدل دون أي خدمة إضافية، وشرحنا تثبيته في دليل HAProxy
خدمة غرفة الانتظار Waiting Roomإعطاء كل زائر رقماً، وإدخال عدد ثابت في الدقيقة، وإصدار توكن موقع له مدة صلاحيةخدمة صغيرة تكتبها أنت فوق Valkey (رخصة BSD-3-Clause)المجموعة المرتبة Sorted Set في Valkey تعطيك الإضافة ومعرفة الترتيب بأمر واحد ذري في الذاكرة
خدمة الفعاليات Catalogبيانات الفعالية والأسعار، وألوان المقاعد للعرضكودك مع نسخة القراءة من PostgreSQL وValkeyبيانات تقرأ كثيراً وتتغير نادراً، فتخدم من ال Cache ولا تلمس قاعدة البيانات الرئيسية
خدمة الحجز Bookingالحجز المؤقت والتحديث المشروط، وحد المقاعد لكل حسابكودك فوق PostgreSQL (رخصة PostgreSQL License)أقفال الصفوف وSKIP LOCKED والقيد الفريد كلها داخل قاعدة البيانات كما رأيت في التجربة العملية
تجميع الاتصالات Connection Poolingحماية PostgreSQL من آلاف الاتصالات عند القفزةPgBouncer (رخصة ISC)يجعل مئات النسخ من الخدمة تتقاسم عدداً محدوداً من الاتصالات، وشرحناه في مقال تجميع الاتصالات
خدمة الدفع Paymentإنشاء عملية الدفع بمفتاح Idempotency Key، واستقبال ال Webhook والتحقق من توقيعه، وتحويل الحجز إلى بيعكودك، ومزود الدفع PSP خارجيهذا الجزء لا تملكه، فالمهم أن يكون كل ما تكتبه فيه قابلاً للإعادة دون أثر مكرر
الطابور Queueنقل الحدث booking.confirmed إلى من يصدر التذكرة ويرسل الإشعارRabbitMQ (رخصة MPL 2.0)طابور عمل Work Queue فيه تأكيد الاستلام Ack وإعادة المحاولة وطابور الرسائل الفاشلة، وهذا ما تحتاجه المهام هنا، والفرق بينه وبين Kafka في مقال الطابور أم بث الأحداث
العمال Workersإصدار التذكرة برمز QR، وإرسال البريد والرسائل والإشعاراتكودك يقرأ من RabbitMQبطء مزود البريد لا يؤخر رد المشتري، وفشل الإرسال يعاد من الطابور
المهام المجدولةتنظيف الحجوزات المنتهية لتحديث الألوان، والمطابقة اليومية مع مزود الدفعpg_cron (رخصة PostgreSQL License) أو cron عاديالمهمة هنا للتنظيف فقط وليست شرطاً للصحة، فشرط انتهاء المهلة داخل جملة الحجز نفسها

نموذج البيانات

الجدولان seats وbookings هما نفس جدولي التجربة العملية بنفس الأعمدة، ونضيف حولهما ما يحتاجه النظام الحقيقي: الفعالية، والطلب Order الذي يجمع تذاكر المشتري ويحمل مفتاح منع التكرار، والدفعة بمرجع المزود، وجدول outbox الذي يكتب فيه الحدث في نفس المعاملة التي تبيع المقعد:

-- seats and bookings are the lab tables (schema.sql), unchanged:
--   seats    (event_id, seat_id, status available|held|sold, held_by, hold_expires_at, version)  PK (event_id, seat_id)
--   bookings (id, event_id, seat_id, buyer, created_at)  + UNIQUE INDEX bookings_one_per_seat (event_id, seat_id)
--   ga_counter / ga_tickets for general admission

CREATE TABLE events (
    event_id        int         PRIMARY KEY,
    name            text        NOT NULL,
    venue_id        int         NOT NULL,
    sale_opens_at   timestamptz NOT NULL,
    max_per_account int         NOT NULL DEFAULT 4
);

CREATE TABLE orders (
    order_id        bigint      GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    event_id        int         NOT NULL REFERENCES events,
    buyer           text        NOT NULL,
    status          text        NOT NULL CHECK (status IN ('pending', 'paid', 'expired', 'refunded')),
    amount_halalas  bigint      NOT NULL,
    idempotency_key text        NOT NULL UNIQUE,          -- the same click twice is the same order
    created_at      timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE bookings ADD COLUMN order_id bigint REFERENCES orders;

CREATE TABLE payments (
    psp_ref         text        PRIMARY KEY,              -- the provider's reference: a webhook seen twice is ignored
    order_id        bigint      NOT NULL REFERENCES orders,
    status          text        NOT NULL,                 -- authorized, captured, failed, refunded
    amount_halalas  bigint      NOT NULL,
    received_at     timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE outbox (                                     -- written in the same transaction as the sale
    id         bigint      GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    topic      text        NOT NULL,                      -- booking.confirmed
    payload    jsonb       NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    sent_at    timestamptz                                -- NULL until the relay publishes it to RabbitMQ
);

وأما في Valkey فلا توجد جداول، وإنما مفاتيح Keys بأسماء ثابتة، وهذه الأسماء اقتراح منا:

المفتاحالنوعما فيه
wr:{event_id}Sorted Setكل زائر في غرفة الانتظار، ودرجته رقم عشوائي لمن وصل قبل الفتح ووقت الوصول لمن جاء بعده
seatmap:{event_id}String بأوامر BITFIELDبتان لكل مقعد: متاح أو محجوز أو مباع، وهي نسخة للعرض فقط تتحدث كل ثانية أو ثانيتين
rl:{account}:{minute}عداد مع EXPIREعدد طلبات الحساب في الدقيقة الحالية لحد معدل الطلبات

الواجهات الرئيسية

الواجهة API التي يستخدمها تطبيق المشجع قليلة، وأهم ما فيها أن كل طلب يغير شيئاً يحمل مفتاح Idempotency-Key في الرأس، وأن طلبات الحجز تحمل توكن الغرفة بالإضافة إلى توكن الحساب:

الطلبالمسارما يفعلهملاحظات
GET/events/{id}صفحة الفعالية والأسعارمن ال CDN، ولا تحتاج تسجيل دخول
POST/events/{id}/queueدخول غرفة الانتظار وأخذ رقممرة واحدة لكل حساب، وفحص البوتات هنا
GET/events/{id}/queue/meالدور الحالي ووقت الانتظار المتوقع، ثم توكن الدخول عندما يأتي الدورالتوكن موقع وفيه الحساب والفعالية ووقت الانتهاء
GET/events/{id}/seatmapألوان المقاعدمن Valkey، وقد تتأخر ثانية أو ثانيتين
POST/events/{id}/holdsحجز مؤقت لمقاعد محددة أو عدد من التذاكر، ويرد بوقت انتهاء المهلة أو 409 إذا سبقك غيركتوكن الغرفة، وIdempotency-Key، وحد المقاعد لكل حساب
POST/ordersتحويل الحجز إلى طلب وإنشاء عملية الدفع لدى المزودIdempotency-Key نفسه يذهب إلى المزود، فلا يخصم مرتين
POST/webhooks/pspإشعار المزود بنجاح الدفع أو فشله، فيباع المقعد أو يسترد المبلغالتحقق من التوقيع، وpsp_ref مفتاح أساسي يمنع المعالجة مرتين
حدثbooking.confirmedمن outbox إلى RabbitMQ، فيصدر ال Worker التذكرة ويرسل الإشعارقد يصل مرتين، فإصدار التذكرة مشروط بعدم وجودها

تقدير السعة بالأرقام

نبدأ من أرقام المثال في القسم السابق، ونضيف إليها افتراضات نكتبها صراحة، والسبب أن التقدير الذي لا تعرف افتراضاته لا تستطيع أن تراجعه عندما يتغير الواقع:

  • من المثال: ملعب فيه 60,000 مقعد، ومليون شخص عند فتح البيع.
  • افتراض: من لم يدخل غرفة الانتظار يحدث خريطة المقاعد ثلاث مرات في الدقيقة.
  • افتراض: الغرفة تدخل 2,000 مشتر في الدقيقة، وكل مشتر يبقى خمس دقائق في صفحة المقاعد ويحدثها كل خمس ثوان.
  • افتراض: كل مشتر يرسل محاولتي حجز في المتوسط وطلب دفع واحداً ويصل له Webhook واحد، أي أربع كتابات، ويشتري 2.5 تذكرة في المتوسط، و60% ممن يدخلون يشترون فعلاً.
  • افتراض: خريطة المقاعد مضغوطة ببتين لكل مقعد، وكل طلب مع سطوره وفهارسه يأخذ قرابة 1.5 كيلوبايت في قاعدة البيانات.

والحساب كما يلي:

الكميةالحسابالنتيجة
حجم خريطة المقاعد60,000 مقعد × 2 بت ÷ 815,000 بايت، أي قرابة 15 كيلوبايت
قراءة الخريطة دون غرفة انتظار1,000,000 × 3 ÷ 6050,000 طلب في الثانية
الشبكة لهذه القراءات50,000 × 15 كيلوبايت750 ميغابايت في الثانية، أي 6 غيغابت في الثانية تقريباً
من في صفحة المقاعد مع غرفة الانتظار2,000 في الدقيقة × 5 دقائق10,000 مشتر في نفس الوقت
قراءة الخريطة مع الغرفة10,000 ÷ 5 ثوان2,000 طلب في الثانية، و30 ميغابايت في الثانية
الكتابة في PostgreSQL2,000 ÷ 60 × 4 كتاباتقرابة 133 كتابة في الثانية، و400 إذا ضربتها في 3 كهامش للذروة وإعادة المحاولة
عدد الطلبات حتى نفاد التذاكر60,000 ÷ 2.524,000 طلب
مدة البيع24,000 ÷ (2,000 × 60%)20 دقيقة تقريباً
التخزين للفعالية الواحدة24,000 × 1.5 كيلوبايت36 ميغابايت
التخزين في السنة (افتراض 5 ملايين تذكرة)5,000,000 ÷ 2.5 × 1.5 كيلوبايت3 غيغابايت تقريباً

والخلاصة من هذه الأرقام أن التخزين ليس المشكلة أبداً، فثلاثة غيغابايت في السنة تتسع في أصغر سيرفر، وأن الكتابة نفسها ليست ضخمة، فمئات الكتابات في الثانية تتحملها قاعدة PostgreSQL واحدة، وفي التجربة العملية باعت طريقة SKIP LOCKED أكثر من 600 تذكرة في الثانية على جهاز تطوير، بينما توقف العداد الواحد عند 222. أما المشكلة الحقيقية فهي القراءة والشبكة في الدقائق الأولى، فالفرق بين 50,000 طلب في الثانية و2,000 هو بالضبط ما تفعله غرفة الانتظار، ولهذا كان المكون الأهم في الصورة هو الذي لا يكتب أي شيء في قاعدة البيانات.

مراحل النمو: ماذا تبني اليوم، وماذا تؤجل؟

الجزءالبداية MVP: سيرفر أو اثنانالنمو: آلاف المشترين في نفس الوقتالحجم الكبير: ملايين عند الفتح
التطبيقتطبيق واحد فيه كل الخدمات خلف NGINXعدة نسخ من التطبيق خلف HAProxy، وPgBouncer أمام قاعدة البياناتفصل غرفة الانتظار والحجز والدفع إلى خدمات مستقلة تتوسع كل منها وحدها
قاعدة البياناتPostgreSQL واحدة، مع نسخ احتياطي يومي واستعادة مجربةنسخة متماثلة Replica للقراءة، وتحويل تلقائي عند العطل بأداة مثل Patroni (رخصة MIT)تقسيم المخزون حسب الفعالية ثم حسب المنطقة على عدة قواعد
القراءةمن قاعدة البيانات مباشرةValkey لخريطة المقاعد، وCDN للصفحاتنسخة الخريطة تدفع للمتصفحات أو تخدم من الحافة
غرفة الانتظارلا تحتاجها، ويكفي حد معدل الطلباتغرفة على Valkey بتوكن موقعغرفة على الحافة مستقلة تماماً عن نظام الحجز، مع تسجيل مسبق
ما بعد البيعجدول outbox ومهمة تقرؤه كل بضع ثوانRabbitMQ وWorkers منفصلةWorkers كثيرة لكل مزود إشعارات، ومراقبة طول الطابور

والذي لا تبنيه في اليوم الأول: لا تقسم قاعدة البيانات Sharding، ولا تنشر في أكثر من منطقة Multi-region، ولا تفصل الخدمات إلى Microservices، ولا تضع Kafka، والسبب أن كل واحد منها يضيف تعقيداً تشغيلياً كبيراً لا تحتاجه قبل أن يقول لك اختبار الأحمال إنك وصلت حد المرحلة الحالية. أما ما يجب أن يكون موجوداً من اليوم الأول فهو ما يحمي الصحة: الحجز المؤقت، والتحديث المشروط، والقيد الفريد، ومفتاح منع التكرار، لأن تغييرها بعد الإطلاق أصعب بكثير.

ماذا لو تعطل جزء منها؟

ماذا لو تعطلالأثركيف تخفف الأثر
PostgreSQL الرئيسيةلا حجز ولا بيع، والقراءة من ال Cache تستمرنسخة متماثلة مع تحويل تلقائي، وغرفة الانتظار توقف الإدخال حتى تعود القاعدة بدل أن ترسل الناس إلى أخطاء
Valkeyخريطة المقاعد لا تتحدث، وقد يضيع ترتيب غرفة الانتظارنسخة متماثلة، وحفظ AOF لمفتاح الغرفة، والحجز نفسه صحيح لأن القرار في PostgreSQL وليس في ال Cache
مزود الدفعلا يكتمل الدفع، وتنتهي مهلات الحجزتمديد المهلة لمن بدأ الدفع فعلاً، ورسالة واضحة للمشتري، والمطابقة اليومية تكشف كل دفعة لم تتحول إلى تذكرة
RabbitMQ أو ال Workersتتأخر التذاكر والإشعارات، والبيع نفسه لا يتأثرالحدث محفوظ في outbox ولا يضيع، وصفحة «تذاكري» تقرأ من قاعدة البيانات مباشرة
خدمة غرفة الانتظارلا يدخل أحد جديد إلى الحجزالبوابة ترفض الحجز دون توكن ولا تفتح الباب للجميع، وصفحة الانتظار الثابتة على ال CDN تبقى تعمل
ال CDNكل القراءات تصل إلى سيرفراتكغرفة الانتظار تحمي الحجز، ومزود CDN ثان أو صفحة ثابتة خفيفة جداً على سيرفرات منفصلة

الحجز المكرر Double Booking: أين يحدث بالضبط؟

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

خط زمني لمشتريين. المشتري A يقرأ حالة المقعد A1 فيجده available، ثم المشتري B يقرأه available أيضاً، ثم يعمل التطبيق 50 ملي ثانية لكل منهما، ثم يكتب A أن المقعد مباع له، ثم يكتب B فوقه. في النهاية تذكرتان حمراوان لنفس المقعد A1 باسم A وباسم B
كل مشتر يكتب بناء على قراءة قديمة، فيخرج النظام تذكرتين لمقعد واحد

ولاحظ أن الكتابة الثانية لا تفشل، فقاعدة البيانات نفذت ما طلب منها بالضبط، والخطأ في منطق التطبيق الذي افترض أن ما قرأه قبل 50 ملي ثانية ما زال صحيحاً. وقد يتساءل البعض: أليست المعاملة Transaction كافية لحماية ذلك؟ والإجابة لا، والسبب أن مستوى العزل الافتراضي في PostgreSQL هو القراءة المؤكدة Read Committed، وفيه ترى كل معاملة آخر ما تم تأكيده لحظة القراءة، ولا تمنع غيرها من قراءة نفس الصف، وسوف ترى في التجربة العملية أن خمسين مشترياً حصلوا على نفس المقعد داخل معاملات سليمة تماماً.

الحجز المؤقت Hold ومهلته TTL

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

ثلاثة مقاعد ملونة: أخضر available، وبرتقالي held ومعه ساعة رملية وحقلا held_by وhold_expires_at، وأحمر sold ومعه تذكرة. سهم أخضر من المتاح إلى المحجوز عند اختيار المقعد بعملية UPDATE مشروطة، وسهم أحمر من المحجوز إلى المباع عندما يؤكد الدفع قبل انتهاء المهلة، وسهم برتقالي متقطع يعيد المحجوز إلى المتاح عند انتهاء المهلة أو الإلغاء أو فشل الدفع، وبطاقة دفع مع علامة رفض حمراء للدفع الذي يصل بعد انتهاء المهلة
المقعد في واحدة من ثلاث حالات، والمهلة تعيده دون تدخل أحد

وهناك ثلاثة قرارات صغيرة في هذا الجزء تصنع فرقاً كبيراً:

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

ولاحظ أن الحجز المؤقت نفسه يمكن أن يستخدم كسلاح، فمن يكتب سكربتاً يحجز آلاف المقاعد دون أن يدفع يستطيع أن يخرجها من البيع طوال المهلة، ثم يعيد الكرة، وتسمي OWASP هذا الهجوم حرمان المخزون Denial of Inventory، لذلك ضع حداً لعدد المقاعد المحجوزة لكل حساب في نفس الوقت، ولا تسمح بحجز جديد قبل أن ينتهي الحجز السابق أو يدفع.

ثلاث طرق لمنع الحجز المكرر

كل الطرق الصحيحة تفعل نفس الشيء في النهاية: تجعل القراءة والكتابة على المقعد خطوة واحدة لا يدخل بينها طلب آخر، والفرق بينها في المكان الذي يحدث فيه ذلك، وفيمن ينتظر ومن يرفض، والصورة التالية تبين الطرق الثلاث:

ثلاث لوحات. اليسرى PESSIMISTIC LOCK بجملة SELECT FOR UPDATE: قاعدة بيانات عليها قفل أخضر، والمشتري الأول يملك قفل الصف، وخمسة مشترين ينتظرون في صف، ومزاياها بسيطة وصحيحة دائماً، وعيوبها أن الصف الساخن يصبح طابوراً وأنها تحتاج معاملة حقيقية. الوسطى CONDITIONAL أو OPTIMISTIC بجمل UPDATE مشروطة بالحالة أو برقم النسخة: مشتر أخضر يحصل على UPDATE 1 وآخر أحمر على UPDATE 0، دون انتظار. اليمنى REDIS أو VALKEY LOCK بجملة SET NX PX 30000: العامل الأول في توقف GC مدة 40 ثانية، وساعة تبين أن مهلة القفل انتهت، والعامل الثاني أخذ القفل، وكلاهما يكتب إلى قاعدة البيانات
القفل المتشائم ينتظر، والتحديث المشروط يرفض فوراً، وقفل Redis ينتهي وحده

القفل المتشائم Pessimistic Locking مع SELECT … FOR UPDATE

الطريقة الأولى أن تطلب من قاعدة البيانات قفل الصف Row Lock عند قراءته، فتضيف FOR UPDATE إلى جملة القراءة، وبحسب وثائق PostgreSQL عن الأقفال فهذا القفل يمنع أي معاملة أخرى من تعديل الصف أو قفله بنفس الطريقة حتى تنتهي المعاملة الأولى بالتأكيد COMMIT أو التراجع ROLLBACK. وهذا يعني أن الطلب الثاني لا يقرأ الحالة القديمة، وإنما ينتظر حتى ينتهي الأول ثم يقرأ أن المقعد صار مباعاً، والكود يكاد يكون نفس كود الحل البسيط:

# 2) Pessimistic: lock the row, the others wait in line on that row.
def for_update(conn, buyer):
    with conn.transaction():
        status = conn.execute("SELECT status FROM seats WHERE event_id=%s AND seat_id='A1' FOR UPDATE", (EVENT,)).fetchone()[0]
        if status != "available":
            return "taken"
        time.sleep(WORK)
        conn.execute("UPDATE seats SET status='sold', held_by=%s WHERE event_id=%s AND seat_id='A1'", (buyer, EVENT))
        conn.execute("INSERT INTO bookings (event_id, seat_id, buyer) VALUES (%s, 'A1', %s)", (EVENT, buyer))
        return "booked"

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

التحديث المشروط والقفل المتفائل Optimistic Locking

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

# 3) One atomic statement: the condition and the change in the same UPDATE.
def conditional(conn, buyer):
    with conn.transaction():
        row = conn.execute("""
            UPDATE seats SET status='held', held_by=%s, hold_expires_at=now() + interval '10 minutes'
             WHERE event_id=%s AND seat_id='A1'
               AND (status='available' OR (status='held' AND hold_expires_at < now()))
            RETURNING seat_id""", (buyer, EVENT)).fetchone()
        return "held" if row else "taken"

أما القفل المتفائل Optimistic Locking فهو نفس الفكرة عندما تحتاج أن تقرأ الصف وتفكر قبل الكتابة، فتضيف للجدول عموداً لرقم النسخة version، وتقرأه مع البيانات، ثم تكتب بشرط أن الرقم لم يتغير وتزيده واحداً، فإذا غير أحد الصف في الوسط لن يطابق الشرط وتحصل على صفر صفوف، فتخبر المستخدم أن المقعد لم يعد متاحاً أو تعيد المحاولة على مقعد آخر:

# 4) Optimistic: read the version, work without a lock, write only if the version did not move.
def optimistic(conn, buyer):
    with conn.transaction():
        status, version = conn.execute("SELECT status, version FROM seats WHERE event_id=%s AND seat_id='A1'", (EVENT,)).fetchone()
    if status != "available":
        return "taken"
    time.sleep(WORK)
    with conn.transaction():
        cur = conn.execute("""UPDATE seats SET status='sold', held_by=%s, version=version+1
                               WHERE event_id=%s AND seat_id='A1' AND version=%s""", (buyer, EVENT, version))
        if cur.rowcount == 0:
            return "conflict"
        conn.execute("INSERT INTO bookings (event_id, seat_id, buyer) VALUES (%s, 'A1', %s)", (EVENT, buyer))
        return "booked"

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

قفل Redis أو Valkey: متى يفيد، وأين يخدعك؟

والطريقة الثالثة التي تسمعها كثيراً أن تأخذ قفلاً من Redis أو Valkey قبل أن تلمس المقعد، بجملة مثل SET lock:A1 <random-id> NX PX 30000، أي اكتب المفتاح فقط إذا لم يكن موجوداً واجعله ينتهي بعد 30 ثانية، وهي الطريقة التي تشرحها وثائق Redis عن الأقفال الموزعة، مع تنبيهين مهمين فيها: أن القيمة يجب أن تكون عشوائية خاصة بمن أخذ القفل، وأن الحذف عند الانتهاء يجب أن يتحقق أن القفل ما زال لك بسكربت Lua واحد، والسبب أن الحذف البسيط قد يحذف قفل شخص آخر أخذه بعد انتهاء مهلتك.

والمشكلة الحقيقية في هذا القفل أنه عقد إيجار Lease ينتهي بالوقت، وليس قفلاً تملكه حتى تتركه، فإذا توقف ال Worker الذي أخذ القفل مدة أطول من المهلة، بسبب توقف جامع الذاكرة Garbage Collection Pause أو بطء في الشبكة أو تجميد الجهاز الافتراضي، فإن القفل ينتهي ويأخذه Worker آخر، ثم يستيقظ الأول ويكمل كتابته وهو يظن أن القفل ما زال معه. وقد شرح Martin Kleppmann هذا السيناريو في مقاله How to do distributed locking المنشور في 8 فبراير 2016، وكان رده على خوارزمية Redlock، وخلاصته أن القفل الذي ينتهي بالوقت لا يكفي وحده لحماية صحة البيانات، وأن الحل أن يحمل كل قفل رقماً متزايداً يسمى رمز الحماية Fencing Token، ويرفض التخزين أي كتابة تحمل رقماً أقدم من آخر رقم رآه، وقد شرحنا هذه الفكرة في الأنظمة الموزعة.

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

القيد الفريد Unique Constraint: خط الدفاع الأخير

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

CREATE UNIQUE INDEX bookings_one_per_seat ON bookings (event_id, seat_id);

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

المقاعد غير المرقمة والصف الساخن Hot Row

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

وهناك طريقتان شائعتان للخروج من هذا الطابور: الأولى أن تنشئ صفاً لكل تذكرة مسبقاً، ثم يأخذ كل مشتر أي صف متاح لم يقفله غيره بجملة FOR UPDATE SKIP LOCKED، أي تجاوز الصفوف المقفلة بدل انتظارها، وهي نفس الفكرة التي يبنى بها طابور المهام على PostgreSQL كما شرحنا في PostgreSQL لكل شيء. والثانية أن تقسم العداد إلى عدة صفوف Sharded Counters، فتجعل للمنطقة ثماني حصص كل منها 125 تذكرة، ويختار المشتري حصة عشوائية، فيقل التنافس على كل صف بنفس النسبة. وفي التجربة العملية باع العداد الواحد ألف تذكرة لألفي مشتر في 9 ثوان تقريباً، وباعتها طريقة SKIP LOCKED في 3 ثوان أو أقل على نفس الجهاز.

تقسيم المخزون Inventory Sharding: أين تضع مقاعد كل فعالية؟

عندما يكبر النظام ويخدم آلاف الفعاليات في نفس الوقت لن تكفي قاعدة بيانات واحدة لكل شيء، والسؤال هو كيف تقسم Partitioning البيانات بين عدة سيرفرات، وقد شرحنا التقسيم بالمدى وبالهاش في الأنظمة الموزعة. والمفتاح الطبيعي في الحجز هو الفعالية نفسها، والسبب أن كل عملية حجز تخص فعالية واحدة، فإذا كانت مقاعد الفعالية كلها على نفس السيرفر بقيت المعاملة محلية سريعة، ولم تحتج معاملة موزعة بين سيرفرين. لكن هذا التقسيم وحده لا يحل مشكلة المباراة الكبيرة، لأن كل الحمل يذهب إلى السيرفر الذي يحمل تلك المباراة، لذلك تقسم الفعالية الكبيرة نفسها حسب المنطقة Section أو البوابة، فتصبح كل منطقة وحدة مستقلة يمكن أن تكون على سيرفر مختلف، ولا يحتاج الحجز داخل منطقة واحدة أن يعرف ما يحدث في غيرها.

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

غرفة الانتظار Waiting Room عند فتح البيع

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

في اليسار مجموعات من المشجعين وبوتان حمراوان عند الساعة 10:00:00 مع فتح البيع، وسهم أحمر كبير إلى صندوق WAITING ROOM فيه ساعة رملية وأرقام الطابور من 1 إلى 48,214، وترتيب عشوائي لمن جاء مبكراً ثم الأسبق فالأسبق، ودرع أخضر لفحص البوتات ومكان واحد لكل حساب وتوكن موقع له مدة صلاحية. ومن الغرفة سهم أخضر بمعدل 500 في الدقيقة إلى Booking API الذي يتحقق من التوكن ثم إلى قاعدة المخزون بحمل ثابت. وسهم أزرق متقطع من المخزون إلى الغرفة يخبر المنتظرين بنفاد التذاكر
غرفة الانتظار تحول قفزة لا يتحملها أحد إلى تدفق ثابت يتحمله النظام

وتصميم الغرفة فيه قرارات تحتاج أن تفكر فيها مسبقاً:

  • الترتيب: من وصل قبل موعد الفتح بنصف ساعة لا يستحق ميزة على من وصل قبل دقيقة، وإلا تحول الأمر إلى سباق على تحديث الصفحة، لذلك تعطي أغلب الأنظمة من وصلوا قبل الموعد ترتيباً عشوائياً عند الفتح، ثم الأسبق فالأسبق بعد ذلك. ووثائق Cloudflare Waiting Room مثلاً تعرض طريقتي الترتيب FIFO والعشوائي Random.
  • التوكن: الخروج من الغرفة يعطي المشتري توكناً موقعاً بمفتاح سري فيه معرف الحساب ووقت الانتهاء، وواجهة الحجز ترفض أي طلب دون توكن صالح، وإلا تجاوز البوت الغرفة بطلب مباشر إلى واجهة الحجز، ولهذا وضعنا هذا التحقق في بوابة ال API في المعمارية أعلاه وليس في خدمة الحجز وحدها.
  • معدل الدخول: يحدد من اختبار أحمال حقيقي وليس من التخمين، والطريقة شرحناها في اختبار الأحمال، وتطبيق ذلك بالأرقام مع غرفة انتظار حقيقية تجده في مقال أنظمة الحشود الكبرى.
  • الصدق مع المنتظرين: الرقم في الطابور ووقت الانتظار المتوقع يقللان التحديث العصبي للصفحة، وعندما تنفد التذاكر أخبر المنتظرين فوراً، والسبب أن من يبقى ساعة في طابور ثم يرى «نفدت التذاكر» سوف يغضب أكثر ممن عرف ذلك بعد دقيقة.

وتذكر أن صفحة الغرفة نفسها يجب أن تتحمل الحمل كله، لذلك تكون صفحة ثابتة على ال CDN، ويكون منطق الطابور على الحافة Edge أو على خدمة صغيرة مستقلة عن نظام الحجز، فإذا تعطل نظام الحجز بقيت الغرفة تعمل وتخبر الناس بالانتظار.

الدفع ومهلته

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

  • كل طلب دفع يحمل مفتاح Idempotency Key مرتبطاً بالطلب Order، فإذا أعيد الطلب بسبب انقطاع أو ضغطة مكررة تعرف بوابة الدفع أنه نفس الطلب ولا تخصم مرة ثانية، وقد شرحنا ذلك بتجربة في تصميم واجهات API.
  • لا تعتمد على عودة المتصفح من صفحة البنك لتأكيد البيع، لأن المشتري قد يغلق الصفحة، وإنما على إشعار Webhook من بوابة الدفع إلى سيرفرك، مع التحقق من توقيعه، ومعالجته بنفس جملة البيع المشروطة التي رأيتها في الحجز المؤقت.
  • كل دفعة لم تتحول إلى تذكرة لأي سبب، مثل انتهاء المهلة أو خطأ بعد الخصم، تدخل عملية استرداد تلقائية، مع مهمة يومية تقارن مدفوعات بوابة الدفع بالتذاكر الصادرة Reconciliation، والسبب أن الخطأ النادر في يوم فيه مئة ألف عملية بيع يعني مئات العملاء.

وإصدار التذكرة نفسها برمز QR وإرسالها بالبريد وإشعار الهاتف لا يحدث داخل طلب الدفع، وإنما يدخل طابوراً كما شرحنا في مفاهيم الطوابير، حتى لا يتأخر رد المشتري بسبب خدمة بريد بطيئة، وهذا هو الحدث booking.confirmed الذي يكتب في جدول outbox مع البيع كما في نموذج البيانات أعلاه، وتفاصيل الإشعارات نفسها في مقال نظام الإشعارات.

البوتات والعدالة بين المشترين

في الحفلات المطلوبة جداً يكون سعر التذكرة الرسمي أقل بكثير مما يدفعه الناس في السوق الثانوية Secondary Market، وهذا الفرق هو ما يجعل شراء التذاكر بالبوتات ثم إعادة بيعها تجارة كاملة، وتسمي OWASP هذا الهجوم الشراء لإعادة البيع Scalping. وفي الولايات المتحدة يمنع قانون BOTS Act تجاوز وسائل الحماية التي تضعها منصات التذاكر، لكن القانون لا يمنع البوت من المحاولة، لذلك تبقى الحماية تقنية في المقام الأول:

  • التسجيل المسبق Pre-registration: يسجل الراغب قبل البيع بأيام، ويتحقق النظام من الحساب ورقم الهاتف وتاريخ الشراء، ثم يحصل المختارون على رمز دخول، فيصبح عدد الداخلين عند الفتح معروفاً مسبقاً.
  • حد لكل حساب: عدد أقصى من التذاكر للحساب الواحد وللبطاقة الواحدة، وحد لمعدل الطلبات Rate Limiting لكل حساب وعنوان، كما شرحنا في حماية تطبيقات الويب.
  • فحص البوتات عند دخول الغرفة لا عند كل نقرة، حتى لا تعاقب المشتري الحقيقي باختبارات متكررة في أصعب لحظة.
  • التذكرة الرقمية المرتبطة بالحساب، والبيع الثانوي داخل المنصة بسعر محدد، فتقل قيمة التذكرة التي اشتراها البوت.
📌
في 15 نوفمبر 2022 فتحت Ticketmaster البيع المسبق لجولة Taylor Swift المسماة The Eras Tour، وبحسب بيان الشركة في 19 نوفمبر 2022 سجل أكثر من 3.5 مليون شخص في برنامج Verified Fan، وأرسلت رموز الدخول لنحو 1.5 مليون منهم، لكن هجمات البوتات ومن ليس لديهم رموز أوصلت الطلبات إلى 3.5 مليار طلب، أي أربعة أضعاف أعلى ذروة سابقة، فأبطأت الشركة البيع وأجلت بعضه، وتقدر أن 15% من التفاعلات في الموقع واجهت مشكلات، منها أخطاء التحقق من الرمز التي جعلت مشترين يفقدون تذاكر وضعوها في السلة. وفي شهادة رئيس Live Nation أمام لجنة القضاء في مجلس الشيوخ الأمريكي في 24 يناير 2023 قال إن حركة البوتات كانت ثلاثة أضعاف أعلى ما رأوه، وإنها هاجمت لأول مرة سيرفرات رموز Verified Fan، وإن الدرس أنهم كان يجب أن يوزعوا البيع على مدة أطول. والدرس لك أن غرفة الانتظار وحدها لا تكفي إذا كانت الخدمات التي تتحقق من الداخلين أضعف منها، فاختبر كل خدمة في طريق المشتري بنفس حجم القفزة.

مسار المشتري في المعمارية خطوة بخطوة

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

المشجعون بالهاتف والحاسوب. الخطوة 1 صفحة الفعالية وصورة خريطة المقاعد من CDN. الخطوة 2 غرفة الانتظار بتوكن وفحص بوتات ثم Booking API بحد لمعدل الطلبات لكل حساب. Booking API يقرأ ألوان الخريطة من Seat map cache على Valkey، وفي الخطوة 3 يرسل الحجز المؤقت إلى Inventory DB المقسمة حسب الفعالية أو المنطقة، والتغييرات تحدث ال Cache. الخطوة 4 الدفع لدى Payment provider بمفتاح Idempotency Key، وال Webhook يعيد paid إلى sold في قاعدة البيانات. الخطوة 5 قاعدة البيانات تضع رسالة في Queue يقرؤها Ticket issuer لرمز QR ومحفظة الهاتف، وخدمة Notifications. وفي الأسفل Hold sweeper يعيد كل دقيقة الحجوزات المنتهية إلى متاحة
طريق للقراءة الكثيرة، وطريق ضيق للحجز، وكل ما بعد البيع خارج الطلب

وقد يتساءل البعض: هل أحتاج كل هذا لفريق رياضي يبيع تذاكر مباراة في ملعب صغير؟ والإجابة لا، فسيرفر واحد مع PostgreSQL وجملة تحديث مشروطة وقيد فريد يكفي لآلاف المشترين، وما تحتاجه من اليوم الأول هو الصحة فقط، أي الحجز المؤقت والتحديث المشروط والقيد الفريد ومفتاح الدفع، لأن تغييرها بعد الإطلاق صعب. أما غرفة الانتظار وتقسيم المخزون وال CDN فتضيفها عندما يقول لك اختبار الأحمال إن القفزة المتوقعة أكبر من سعتك، كما في جدول مراحل النمو أعلاه.

تجربة عملية: خمسون مشترياً على مقعد واحد

في هذه التجربة نشغل PostgreSQL في Container، ثم سكربت Python يفتح خمسين اتصالاً ويطلقها كلها في نفس اللحظة على المقعد A1 بحاجز Barrier، كما يحدث عند فتح البيع، ثم يجرب الطرق السابقة واحدة واحدة ويطبع النتيجة.

المتطلبات: جهاز عليه Docker Engine وDocker Compose، ولا تحتاج نشر أي منفذ على جهازك، فكل الاتصال داخل شبكة Docker الخاصة بالتجربة.

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

أنشئ مجلداً فيه أربعة ملفات، الأول compose.yaml:

name: lab-case2-tb
services:
  db:
    image: postgres:18.6
    container_name: lab-case2-tb-db
    environment:
      POSTGRES_PASSWORD: lab-only-password
      POSTGRES_DB: tickets
    command: ["postgres", "-c", "max_connections=200"]
    volumes:
      - ./schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d tickets"]
      interval: 2s
      retries: 30
  buyers:
    build: .
    image: lab-case2-tb-buyers:1
    container_name: lab-case2-tb-buyers
    environment:
      DATABASE_URL: postgresql://postgres:lab-only-password@db:5432/tickets
    depends_on:
      db:
        condition: service_healthy
    profiles: ["run"]

والثاني schema.sql الذي ينشئ الجداول عند أول تشغيل لقاعدة البيانات:

-- One concert, a few seats, and the bookings table
CREATE TABLE seats (
    event_id        int         NOT NULL,
    seat_id         text        NOT NULL,
    status          text        NOT NULL DEFAULT 'available'
                    CHECK (status IN ('available', 'held', 'sold')),
    held_by         text,
    hold_expires_at timestamptz,
    version         int         NOT NULL DEFAULT 0,
    PRIMARY KEY (event_id, seat_id)
);

CREATE TABLE bookings (
    id         bigserial   PRIMARY KEY,
    event_id   int         NOT NULL,
    seat_id    text        NOT NULL,
    buyer      text        NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
    -- no UNIQUE (event_id, seat_id) yet: the first experiment shows why you need it
);

-- General admission: 1,000 tickets with no seat number
CREATE TABLE ga_counter (event_id int PRIMARY KEY, capacity int NOT NULL, sold int NOT NULL DEFAULT 0);
CREATE TABLE ga_tickets (id bigserial PRIMARY KEY, event_id int NOT NULL, status text NOT NULL DEFAULT 'available', buyer text);
CREATE INDEX ga_tickets_available ON ga_tickets (event_id) WHERE status = 'available';

والثالث Dockerfile لسكربت المشترين:

FROM python:3.13.16-slim
RUN pip install --no-cache-dir "psycopg[binary]==3.3.6"
WORKDIR /app
COPY booking.py .
ENTRYPOINT ["python", "-u", "booking.py"]

والرابع booking.py، وهو السكربت الذي رأيت أجزاءه في الأقسام السابقة، وفيه الطرق كلها والتجارب الثلاث، افتح البطاقة التالية وانسخه كما هو:

booking.py كاملاً
"""Seat booking experiments on PostgreSQL.

    python booking.py naive|for-update|for-update-autocommit|conditional|optimistic   [buyers]
    python booking.py unique-index
    python booking.py hold-expiry
    python booking.py general-admission
"""
import os, sys, time, threading
import psycopg

DSN = os.environ["DATABASE_URL"]
EVENT = 1
WORK = 0.05          # 50 ms between reading the seat and writing it: the app checks the price, calls the payment service...


def reset(seats=("A1",)):
    with psycopg.connect(DSN, autocommit=True) as c:
        c.execute("TRUNCATE seats, bookings, ga_counter, ga_tickets RESTART IDENTITY")
        for s in seats:
            c.execute("INSERT INTO seats (event_id, seat_id) VALUES (%s, %s)", (EVENT, s))


def crowd(fn, buyers):
    """Start all buyers at the same instant (a barrier), like a sale that opens at 10:00:00."""
    results, gate = [None] * buyers, threading.Barrier(buyers)
    conns = [psycopg.connect(DSN) for _ in range(buyers)]

    def run(i):
        gate.wait()
        try:
            results[i] = fn(conns[i], f"buyer-{i:02d}")
        except Exception as e:  # noqa: BLE001
            conns[i].rollback()
            results[i] = f"error: {type(e).__name__}"
    t0 = time.perf_counter()
    threads = [threading.Thread(target=run, args=(i,)) for i in range(buyers)]
    [t.start() for t in threads]
    [t.join() for t in threads]
    elapsed = time.perf_counter() - t0
    [c.close() for c in conns]
    return results, elapsed


# 1) Read, think, write. Every buyer sees the seat free.
def naive(conn, buyer):
    with conn.transaction():
        status = conn.execute("SELECT status FROM seats WHERE event_id=%s AND seat_id='A1'", (EVENT,)).fetchone()[0]
        if status != "available":
            return "taken"
        time.sleep(WORK)
        conn.execute("UPDATE seats SET status='sold', held_by=%s WHERE event_id=%s AND seat_id='A1'", (buyer, EVENT))
        conn.execute("INSERT INTO bookings (event_id, seat_id, buyer) VALUES (%s, 'A1', %s)", (EVENT, buyer))
        return "booked"


# 2) Pessimistic: lock the row, the others wait in line on that row.
def for_update(conn, buyer):
    with conn.transaction():
        status = conn.execute("SELECT status FROM seats WHERE event_id=%s AND seat_id='A1' FOR UPDATE", (EVENT,)).fetchone()[0]
        if status != "available":
            return "taken"
        time.sleep(WORK)
        conn.execute("UPDATE seats SET status='sold', held_by=%s WHERE event_id=%s AND seat_id='A1'", (buyer, EVENT))
        conn.execute("INSERT INTO bookings (event_id, seat_id, buyer) VALUES (%s, 'A1', %s)", (EVENT, buyer))
        return "booked"


# 2b) The same FOR UPDATE, but each statement commits on its own (autocommit): the lock dies with the SELECT.
def for_update_autocommit(conn, buyer):
    conn.autocommit = True
    status = conn.execute("SELECT status FROM seats WHERE event_id=%s AND seat_id='A1' FOR UPDATE", (EVENT,)).fetchone()[0]
    if status != "available":
        return "taken"
    time.sleep(WORK)
    conn.execute("UPDATE seats SET status='sold', held_by=%s WHERE event_id=%s AND seat_id='A1'", (buyer, EVENT))
    conn.execute("INSERT INTO bookings (event_id, seat_id, buyer) VALUES (%s, 'A1', %s)", (EVENT, buyer))
    return "booked"


# 3) One atomic statement: the condition and the change in the same UPDATE.
def conditional(conn, buyer):
    with conn.transaction():
        row = conn.execute("""
            UPDATE seats SET status='held', held_by=%s, hold_expires_at=now() + interval '10 minutes'
             WHERE event_id=%s AND seat_id='A1'
               AND (status='available' OR (status='held' AND hold_expires_at < now()))
            RETURNING seat_id""", (buyer, EVENT)).fetchone()
        return "held" if row else "taken"


# 4) Optimistic: read the version, work without a lock, write only if the version did not move.
def optimistic(conn, buyer):
    with conn.transaction():
        status, version = conn.execute("SELECT status, version FROM seats WHERE event_id=%s AND seat_id='A1'", (EVENT,)).fetchone()
    if status != "available":
        return "taken"
    time.sleep(WORK)
    with conn.transaction():
        cur = conn.execute("""UPDATE seats SET status='sold', held_by=%s, version=version+1
                               WHERE event_id=%s AND seat_id='A1' AND version=%s""", (buyer, EVENT, version))
        if cur.rowcount == 0:
            return "conflict"
        conn.execute("INSERT INTO bookings (event_id, seat_id, buyer) VALUES (%s, 'A1', %s)", (EVENT, buyer))
        return "booked"


def report(name, results, elapsed):
    with psycopg.connect(DSN) as c:
        rows = c.execute("SELECT buyer FROM bookings WHERE seat_id='A1' ORDER BY id").fetchall()
        seat = c.execute("SELECT status, held_by FROM seats WHERE seat_id='A1'").fetchone()
    summary = {}
    for r in results:
        summary[r] = summary.get(r, 0) + 1
    print(f"{name:<12} buyers={len(results)}  time={elapsed:.2f}s  results={dict(sorted(summary.items()))}")
    print(f"{'':<12} bookings rows for A1: {len(rows)}  first={rows[0][0] if rows else '-'}  seat now: {seat[0]} by {seat[1]}")


def hold_expiry():
    reset(("A1",))
    with psycopg.connect(DSN, autocommit=True) as c:
        def hold(buyer, ttl):
            r = c.execute("""UPDATE seats SET status='held', held_by=%s, hold_expires_at=now() + make_interval(secs => %s)
                              WHERE event_id=%s AND seat_id='A1'
                                AND (status='available' OR (status='held' AND hold_expires_at < now()))
                             RETURNING to_char(hold_expires_at, 'HH24:MI:SS')""", (buyer, ttl, EVENT)).fetchone()
            return f"hold until {r[0]}" if r else "seat not free"

        def pay(buyer):
            r = c.execute("""UPDATE seats SET status='sold', hold_expires_at=NULL
                              WHERE event_id=%s AND seat_id='A1' AND status='held' AND held_by=%s AND hold_expires_at > now()
                             RETURNING seat_id""", (EVENT, buyer)).fetchone()
            return "payment accepted, seat sold" if r else "hold expired, payment refused (refund the charge)"

        def now():
            return c.execute("SELECT to_char(now(), 'HH24:MI:SS')").fetchone()[0]

        print(f"{now()}  buyer-A  {hold('buyer-A', 3)}")
        print(f"{now()}  buyer-B  {hold('buyer-B', 3)}")
        time.sleep(3.5)
        print(f"{now()}  buyer-B  {hold('buyer-B', 3)}")
        print(f"{now()}  buyer-A  {pay('buyer-A')}")
        print(f"{now()}  buyer-B  {pay('buyer-B')}")
        print(c.execute("SELECT status, held_by FROM seats WHERE seat_id='A1'").fetchone())


def general_admission(buyers=2000, workers=32, capacity=1000, work=0.005):
    """1,000 tickets without seat numbers, 2,000 buyers, 32 connections (the app's pool)."""
    def setup():
        reset(())
        with psycopg.connect(DSN, autocommit=True) as c:
            c.execute("INSERT INTO ga_counter VALUES (%s, %s, 0)", (EVENT, capacity))
            c.execute("INSERT INTO ga_tickets (event_id) SELECT %s FROM generate_series(1, %s)", (EVENT, capacity))

    def counter(conn, buyer):   # every buyer updates the same row
        with conn.transaction():
            r = conn.execute("UPDATE ga_counter SET sold=sold+1 WHERE event_id=%s AND sold < capacity RETURNING sold", (EVENT,)).fetchone()
            time.sleep(work)    # insert the order, create the payment intent... while the row is still locked
            return r is not None

    def skip_locked(conn, buyer):   # every buyer takes any free ticket row that nobody else holds
        with conn.transaction():
            r = conn.execute("""SELECT id FROM ga_tickets WHERE event_id=%s AND status='available'
                                 LIMIT 1 FOR UPDATE SKIP LOCKED""", (EVENT,)).fetchone()
            if not r:
                return False
            conn.execute("UPDATE ga_tickets SET status='sold', buyer=%s WHERE id=%s", (buyer, r[0]))
            time.sleep(work)
            return True

    for name, fn in (("one counter row", counter), ("SKIP LOCKED", skip_locked)):
        setup()
        queue = list(range(buyers))
        lock = threading.Lock()
        sold = [0]

        def worker():
            conn = psycopg.connect(DSN)
            while True:
                with lock:
                    if not queue:
                        break
                    i = queue.pop()
                if fn(conn, f"buyer-{i}"):
                    with lock:
                        sold[0] += 1
            conn.close()
        t0 = time.perf_counter()
        ts = [threading.Thread(target=worker) for _ in range(workers)]
        [t.start() for t in ts]
        [t.join() for t in ts]
        el = time.perf_counter() - t0
        with psycopg.connect(DSN) as c:
            db_sold = c.execute("SELECT (SELECT sold FROM ga_counter), (SELECT count(*) FROM ga_tickets WHERE status='sold')").fetchone()
        print(f"{name:<16} sold={sold[0]}  time={el:.2f}s  ({buyers / el:.0f} buyers/s)  db: counter={db_sold[0]} rows_sold={db_sold[1]}")


if __name__ == "__main__":
    mode = sys.argv[1]
    n = int(sys.argv[2]) if len(sys.argv) > 2 else 50
    fns = {"naive": naive, "for-update": for_update, "for-update-autocommit": for_update_autocommit, "conditional": conditional, "optimistic": optimistic}
    if mode in fns:
        reset()
        with psycopg.connect(DSN, autocommit=True) as c:
            c.execute("DROP INDEX IF EXISTS bookings_one_per_seat")
        report(mode, *crowd(fns[mode], n))
    elif mode == "unique-index":
        reset()
        with psycopg.connect(DSN, autocommit=True) as c:
            c.execute("CREATE UNIQUE INDEX IF NOT EXISTS bookings_one_per_seat ON bookings (event_id, seat_id)")
        report("naive+unique", *crowd(naive, n))
    elif mode == "hold-expiry":
        hold_expiry()
    elif mode == "general-admission":
        general_admission()

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

  • الدالة crowd تفتح كل الاتصالات أولاً، ثم تنتظر كل الخيوط عند threading.Barrier حتى تنطلق معاً، والسبب أن فتح الاتصال يستغرق وقتاً، ولو فتح كل خيط اتصاله عند الانطلاق لتوزعت الطلبات وقل التزاحم الذي نريد رؤيته.
  • المتغير WORK يمثل 50 ملي ثانية بين القراءة والكتابة، وهو الوقت الذي يقضيه التطبيق الحقيقي في حساب السعر أو التحقق من حدود المشتري، وكلما زاد هذا الوقت زادت فرصة السباق.
  • جدول bookings بلا قيد فريد في البداية عمداً، وتجربة unique-index تضيفه، وكل تجارب السباق الأخرى تحذفه قبل أن تبدأ.

شغل قاعدة البيانات وابن ال Image الخاصة بالسكربت:

docker compose up -d --wait db
docker compose --profile run build buyers

الطريقة البسيطة: قراءة ثم كتابة

docker compose run --rm buyers naive 50

والمخرج سوف يكون كما يلي:

naive        buyers=50  time=0.25s  results={'booked': 50}
             bookings rows for A1: 50  first=buyer-25  seat now: sold by buyer-06

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

مع SELECT … FOR UPDATE

docker compose run --rm buyers for-update 50
for-update   buyers=50  time=0.17s  results={'booked': 1, 'taken': 49}
             bookings rows for A1: 1  first=buyer-17  seat now: sold by buyer-17

حجز واحد فقط، والتسعة والأربعون الآخرون انتظروا خلف القفل ثم قرؤوا أن المقعد صار مباعاً.

نكسر التجربة: نفس FOR UPDATE دون معاملة

الآن نشغل نفس الكود بنفس جملة FOR UPDATE، لكن الاتصال في وضع التأكيد التلقائي، أي كل جملة معاملة مستقلة، وهو ما يحدث عندما تنسى فتح المعاملة أو يكون الإعداد الافتراضي لمكتبة الاتصال كذلك:

# 2b) The same FOR UPDATE, but each statement commits on its own (autocommit): the lock dies with the SELECT.
def for_update_autocommit(conn, buyer):
    conn.autocommit = True
    status = conn.execute("SELECT status FROM seats WHERE event_id=%s AND seat_id='A1' FOR UPDATE", (EVENT,)).fetchone()[0]
    if status != "available":
        return "taken"
    time.sleep(WORK)
    conn.execute("UPDATE seats SET status='sold', held_by=%s WHERE event_id=%s AND seat_id='A1'", (buyer, EVENT))
    conn.execute("INSERT INTO bookings (event_id, seat_id, buyer) VALUES (%s, 'A1', %s)", (EVENT, buyer))
    return "booked"
docker compose run --rm buyers for-update-autocommit 50
for-update-autocommit buyers=50  time=0.20s  results={'booked': 50}
             bookings rows for A1: 50  first=buyer-00  seat now: sold by buyer-14

عادت الخمسون حجزاً، ولم تظهر أي رسالة خطأ ولا أي تحذير، والسبب أن القفل أخذ فعلاً ثم ترك مع نهاية جملة SELECT نفسها، فلم يحم شيئاً. وهذا هو الخطأ الذي يجعل المطور يقول «لكنني استخدمت FOR UPDATE»، فإذا رأيته في مراجعة الكود فابحث أولاً عن بداية المعاملة ونهايتها.

التحديث المشروط والقفل المتفائل

docker compose run --rm buyers conditional 50
docker compose run --rm buyers optimistic 50
conditional  buyers=50  time=0.12s  results={'held': 1, 'taken': 49}
             bookings rows for A1: 0  first=-  seat now: held by buyer-31
optimistic   buyers=50  time=0.17s  results={'booked': 1, 'conflict': 49}
             bookings rows for A1: 1  first=buyer-22  seat now: sold by buyer-22

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

  • التحديث المشروط أعطى المقعد لمشتر واحد في حالة held مع مهلة، ولم يكتب أي حجز في جدول الحجوزات بعد، والسبب أن البيع يحدث عند الدفع وليس عند اختيار المقعد.
  • القفل المتفائل باع المقعد مرة واحدة، والتسعة والأربعون الآخرون حصلوا على conflict، أي أنهم عملوا كل شيء ثم رفضت كتابتهم لأن رقم النسخة تغير.

القيد الفريد يحمي الكود الخاطئ

docker compose run --rm buyers unique-index 50
naive+unique buyers=50  time=0.16s  results={'booked': 1, 'error: UniqueViolation': 49}
             bookings rows for A1: 1  first=buyer-18  seat now: sold by buyer-18

هذا هو الكود البسيط الخاطئ نفسه الذي باع المقعد خمسين مرة، والفرق الوحيد هو الفهرس الفريد على (event_id, seat_id)، فنجح أول من أدخل صفه، وفشل الباقون بخطأ UniqueViolation وتراجعت معاملاتهم كلها، بما فيها تحديث جدول المقاعد، لذلك صار الصف في جدول المقاعد يطابق الحجز الوحيد.

انتهاء مهلة الحجز المؤقت أمامك

في هذه التجربة يحجز المشتري A المقعد بمهلة 3 ثوان، ثم يحاول B فيرفض، ثم ننتظر ثلاث ثوان ونصف فيحجزه B، ثم يحاول A أن يدفع متأخراً:

docker compose run --rm buyers hold-expiry
14:56:36  buyer-A  hold until 14:56:39
14:56:36  buyer-B  seat not free
14:56:39  buyer-B  hold until 14:56:42
14:56:39  buyer-A  hold expired, payment refused (refund the charge)
14:56:39  buyer-B  payment accepted, seat sold
('sold', 'buyer-B')

لاحظ أن أحداً لم يحرر المقعد بعد انتهاء المهلة، فلا توجد مهمة مجدولة في هذه التجربة أصلاً، وإنما جملة الحجز نفسها اعتبرت المقعد متاحاً لأن مهلته انتهت، ثم رفضت جملة البيع دفع A لأن الشرط hold_expires_at > now() لم يعد صحيحاً، وهذه هي الحالة التي يجب أن تنتهي باسترداد المبلغ تلقائياً. والوقت هنا بتوقيت UTC داخل قاعدة البيانات.

ألف تذكرة دون أرقام مقاعد

وفي التجربة الأخيرة ألفا مشتر على ألف تذكرة، عبر 32 اتصالاً كما يفعل مجمع الاتصالات Connection Pool في تطبيق حقيقي، وكل عملية بيع تبقى 5 ملي ثانية داخل المعاملة لتمثل إدخال الطلب وإنشاء عملية الدفع:

docker compose run --rm buyers general-admission
one counter row  sold=1000  time=9.01s  (222 buyers/s)  db: counter=1000 rows_sold=0
SKIP LOCKED      sold=1000  time=3.20s  (626 buyers/s)  db: counter=0 rows_sold=1000

هذه أرقام جهاز واحد عليه Docker Desktop وليست قياساً لسيرفر حقيقي، وقد تغير رقم SKIP LOCKED بين تشغيل وآخر من 1.7 ثانية إلى 3.2 ثانية، أما العداد الواحد فبقي بين 9 و10 ثوان في كل مرة، والسبب أن كل عملية بيع فيه تنتظر انتهاء التي قبلها على نفس الصف، فالوقت الكلي قريب من مجموع أوقات الأقفال واحداً بعد الآخر، بينما تعمل الاتصالات الاثنان والثلاثون في SKIP LOCKED على صفوف مختلفة في نفس الوقت. ولاحظ أن الطريقتين باعتا ألف تذكرة بالضبط، فكلاهما صحيح، والفرق في السرعة فقط.

حذف التجربة

docker compose down -v
docker image rm lab-case2-tb-buyers:1

أخطاء شائعة

  • قراءة حالة المقعد ثم كتابتها في جملتين دون قفل أو شرط، والاعتماد على أن الطلبات لا تتزامن.
  • استخدام FOR UPDATE في اتصال بوضع التأكيد التلقائي، فيترك القفل قبل الكتابة.
  • وضع استدعاء بوابة الدفع أو أي خدمة خارجية داخل المعاملة التي تقفل المقعد، فينتظر الجميع خلف بطء البنك.
  • الاعتماد على قفل Redis وحده لضمان عدم البيع مرتين، ونسيان أن مهلته قد تنتهي وال Worker ما زال يعمل.
  • غياب القيد الفريد في جدول الحجوزات، فلا يوجد ما يمسك الخطأ عندما يضاف مسار حجز جديد.
  • تحرير المقاعد المنتهية بمهمة مجدولة فقط، فإذا توقفت المهمة بقيت المقاعد محجوزة لمن غادر.
  • تأكيد البيع عند عودة المتصفح من صفحة البنك بدل إشعار Webhook، فتضيع عمليات من أغلق الصفحة.
  • غرفة انتظار يمكن تجاوزها بطلب مباشر إلى واجهة الحجز، لأن الواجهة لا تتحقق من التوكن.
  • ترك المنتظرين في الطابور بعد نفاد التذاكر.

الخلاصة

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

  • نظام التذاكر يجمع مشكلة صحة لا تقبل الخطأ ومشكلة قفزة في الحمل، فافصل طريق القراءة الكثيرة عن طريق الحجز الضيق.
  • الحجز المكرر يحدث لأن التطبيق يكتب بناء على قراءة قديمة، والمعاملة بمستوى العزل الافتراضي لا تمنعه وحدها.
  • الحجز المؤقت بمهلة يجعل الدفع ممكناً دون أن تحجز المقاعد للأبد، وشرط انتهاء المهلة مكانه داخل جملة الحجز وجملة البيع.
  • القفل المتشائم بسيط وصحيح داخل معاملة قصيرة، والتحديث المشروط والقفل المتفائل يرفضان الخاسر فوراً، وقفل Redis ينتهي بالوقت فلا تعتمد عليه وحده.
  • القيد الفريد في قاعدة البيانات هو خط الدفاع الأخير، وقد حول الخطأ في التجربة من خمسين تذكرة إلى تذكرة واحدة.
  • الصف الساخن يحول كل شيء إلى طابور، والحل صف لكل تذكرة مع SKIP LOCKED أو عدادات مقسمة.
  • غرفة الانتظار تحول القفزة إلى تدفق ثابت، وتحتاج توكناً موقعاً وترتيباً عادلاً وصدقاً مع المنتظرين، وكل خدمة خلفها يجب أن تتحمل نفس القفزة.
  • الدفع بمفتاح Idempotency Key وتأكيد عبر Webhook واسترداد تلقائي ومطابقة يومية.

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

دليلكيف تبني تطبيق محادثة مثل WhatsApp لملايين المستخدمينكيف تصل الرسالة من هاتف إلى آخر عبر سيرفرات مختلفة، وكيف تحفظ لمن انقطع اتصاله وتظهر علامات التسليم والقراءة.دليلكيف تبني نظام إشعارات يصل إلى ملايين المستخدمينكيف تصل الرسالة الصحيحة إلى المستخدم مرة واحدة وفي وقتها عبر الإشعار والرسائل والبريد، حتى عندما يتعطل المزود.دليلأنظمة الحشود الكبرى: كيف تصمد في مواسم الحج والعمرةكيف تصمم نظام حجز وتصاريح يتحمل ملايين الطلبات في مواعيد معروفة مسبقاً، وماذا تطفئ عندما يتجاوز الحمل خطتك.دليلاختبار الأحمال والضغط Load Testing للمبتدئينلماذا يعمل تطبيقك مع خمسة مستخدمين وينهار مع خمسمئة، وكيف تكتشف ذلك وتصلحه قبل يوم الإطلاق.دليلكيف تصمم API لا تضطر إلى كسره بعد سنةمبادئ تصميم API يتطور دون أن يكسر التطبيقات التي تعتمد عليه، وكيف تختار الشكل المناسب لنظامك.دليلPostgreSQL لكل شيء: متى يكفيك ومتى تحتاج غيره؟كم عملاً يستطيع PostgreSQL واحد أن يقوم به في شركتك الناشئة قبل أن تحتاج Redis أو Elasticsearch، وما الإشارة التي تقول لك إن الوقت قد حان.

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

  • أكتوبر 2026: إضافة قسم المعمارية الكاملة: المخطط والمكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو.
  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة العملية على Docker Engine 29.8.0 وDocker Compose 5.5.1 مع PostgreSQL 18.6 وPython 3.13.16 وpsycopg 3.3.6.
Could not load content
نشرة عرب رووت | ArabRoot

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

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

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

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