عندما يعلن فريق كرة القدم أو منظم حفل كبير أن بيع التذاكر يبدأ في الساعة العاشرة صباحاً، فإن عشرات الآلاف من الناس يجلسون أمام الشاشة قبلها بدقائق، ويضغطون زر التحديث مع أول ثانية، وكلهم يريدون المقاعد نفسها في الصفوف الأمامية. وقد يبدو نظام الحجز من الوهلة الأولى جدولاً للمقاعد وزراً يكتب سطراً في قاعدة البيانات، لكن في الواقع فهذا النظام يجمع مشكلتين صعبتين في لحظة واحدة: الأولى مشكلة صحة 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، ثم الخدمات، ثم البيانات، ثم ما يحدث بعد البيع خارج الطلب، ثم المزودون الخارجيون:

في الصورة أعلاه لاحظ التالي:
- المشجع لا يصل إلى خدمة الحجز إلا عبر بوابة ال 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 بت ÷ 8 | 15,000 بايت، أي قرابة 15 كيلوبايت |
| قراءة الخريطة دون غرفة انتظار | 1,000,000 × 3 ÷ 60 | 50,000 طلب في الثانية |
| الشبكة لهذه القراءات | 50,000 × 15 كيلوبايت | 750 ميغابايت في الثانية، أي 6 غيغابت في الثانية تقريباً |
| من في صفحة المقاعد مع غرفة الانتظار | 2,000 في الدقيقة × 5 دقائق | 10,000 مشتر في نفس الوقت |
| قراءة الخريطة مع الغرفة | 10,000 ÷ 5 ثوان | 2,000 طلب في الثانية، و30 ميغابايت في الثانية |
| الكتابة في PostgreSQL | 2,000 ÷ 60 × 4 كتابات | قرابة 133 كتابة في الثانية، و400 إذا ضربتها في 3 كهامش للذروة وإعادة المحاولة |
| عدد الطلبات حتى نفاد التذاكر | 60,000 ÷ 2.5 | 24,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 التي شرحناها على مستوى الكود في مقال الخيوط والأقفال، وهنا تحدث بين عدة طلبات على عدة سيرفرات، والصورة التالية تبين ذلك:

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

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

القفل المتشائم 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: صفحة خفيفة خارج النظام يدخل إليها الجميع، ويحصل كل زائر على رقم في الطابور، ثم تدخل الغرفة عدداً ثابتاً في الدقيقة يتحمله النظام، والصورة التالية تبين ذلك:

وتصميم الغرفة فيه قرارات تحتاج أن تفكر فيها مسبقاً:
- الترتيب: من وصل قبل موعد الفتح بنصف ساعة لا يستحق ميزة على من وصل قبل دقيقة، وإلا تحول الأمر إلى سباق على تحديث الصفحة، لذلك تعطي أغلب الأنظمة من وصلوا قبل الموعد ترتيباً عشوائياً عند الفتح، ثم الأسبق فالأسبق بعد ذلك. ووثائق 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 لكل حساب وعنوان، كما شرحنا في حماية تطبيقات الويب.
- فحص البوتات عند دخول الغرفة لا عند كل نقرة، حتى لا تعاقب المشتري الحقيقي باختبارات متكررة في أصعب لحظة.
- التذكرة الرقمية المرتبطة بالحساب، والبيع الثانوي داخل المنصة بسعر محدد، فتقل قيمة التذكرة التي اشتراها البوت.
مسار المشتري في المعمارية خطوة بخطوة
بعد أن رأيت كل جزء بالتفصيل نعود إلى المعمارية الكاملة التي بدأنا بها، ولكن هذه المرة من جهة المشتري وبترتيب ما يحدث له، فإذا جمعت كل ما سبق في نظام واحد فسوف تجد طريقين منفصلين: طريق القراءة الكثيرة الذي يخدم من ال CDN وال Cache ويتحمل أن يكون متأخراً ثانية، وطريق الحجز الضيق الذي يمر من غرفة الانتظار ثم واجهة الحجز ثم قاعدة البيانات ولا يقبل أي خطأ، وكل ما يمكن أن يتأخر بعد البيع يخرج إلى الطابور، والصورة التالية تبين ذلك:

وقد يتساءل البعض: هل أحتاج كل هذا لفريق رياضي يبيع تذاكر مباراة في ملعب صغير؟ والإجابة لا، فسيرفر واحد مع 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 50for-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 50for-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 50conditional 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 50naive+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-expiry14: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-admissionone 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.